mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor: split telegram delivery and unify media/frontmatter/i18n pipelines
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { sendMessageMattermostMock } = vi.hoisted(() => ({
|
||||
sendMessageMattermostMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./mattermost/send.js", () => ({
|
||||
sendMessageMattermost: sendMessageMattermostMock,
|
||||
}));
|
||||
|
||||
import { mattermostPlugin } from "./channel.js";
|
||||
import { resetMattermostReactionBotUserCacheForTests } from "./mattermost/reactions.js";
|
||||
import {
|
||||
@@ -10,6 +18,14 @@ import {
|
||||
} from "./mattermost/reactions.test-helpers.js";
|
||||
|
||||
describe("mattermostPlugin", () => {
|
||||
beforeEach(() => {
|
||||
sendMessageMattermostMock.mockReset();
|
||||
sendMessageMattermostMock.mockResolvedValue({
|
||||
messageId: "post-1",
|
||||
channelId: "channel-1",
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging", () => {
|
||||
it("keeps @username targets", () => {
|
||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||
@@ -199,6 +215,33 @@ describe("mattermostPlugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("outbound", () => {
|
||||
it("forwards mediaLocalRoots on sendMedia", async () => {
|
||||
const sendMedia = mattermostPlugin.outbound?.sendMedia;
|
||||
if (!sendMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMedia({
|
||||
to: "channel:CHAN1",
|
||||
text: "hello",
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "default",
|
||||
replyToId: "post-root",
|
||||
} as any);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it("formats allowFrom entries", () => {
|
||||
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom!;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10";
|
||||
import type { ChunkMode } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { RetryRunner } from "../infra/retry-policy.js";
|
||||
import { buildOutboundMediaLoadOptions } from "../media/load-options.js";
|
||||
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
@@ -420,7 +421,7 @@ async function sendDiscordMedia(
|
||||
chunkMode?: ChunkMode,
|
||||
silent?: boolean,
|
||||
) {
|
||||
const media = await loadWebMedia(mediaUrl, { localRoots: mediaLocalRoots });
|
||||
const media = await loadWebMedia(mediaUrl, buildOutboundMediaLoadOptions({ mediaLocalRoots }));
|
||||
const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : [];
|
||||
const caption = chunks[0] ?? "";
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
|
||||
30
src/i18n/registry.test.ts
Normal file
30
src/i18n/registry.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
loadLazyLocaleTranslation,
|
||||
resolveNavigatorLocale,
|
||||
} from "../../ui/src/i18n/lib/registry.ts";
|
||||
|
||||
describe("ui i18n locale registry", () => {
|
||||
it("lists supported locales", () => {
|
||||
expect(SUPPORTED_LOCALES).toEqual(["en", "zh-CN", "zh-TW", "pt-BR", "de"]);
|
||||
expect(DEFAULT_LOCALE).toBe("en");
|
||||
});
|
||||
|
||||
it("resolves browser locale fallbacks", () => {
|
||||
expect(resolveNavigatorLocale("de-DE")).toBe("de");
|
||||
expect(resolveNavigatorLocale("pt-PT")).toBe("pt-BR");
|
||||
expect(resolveNavigatorLocale("zh-HK")).toBe("zh-TW");
|
||||
expect(resolveNavigatorLocale("en-US")).toBe("en");
|
||||
});
|
||||
|
||||
it("loads lazy locale translations from the registry", async () => {
|
||||
const de = await loadLazyLocaleTranslation("de");
|
||||
const zhCN = await loadLazyLocaleTranslation("zh-CN");
|
||||
|
||||
expect(de?.common?.health).toBe("Status");
|
||||
expect(zhCN?.common?.health).toBe("健康状况");
|
||||
expect(await loadLazyLocaleTranslation("en")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,35 @@ metadata:
|
||||
expect(parsed.openclaw?.events).toEqual(["command:new"]);
|
||||
});
|
||||
|
||||
it("preserves inline description values containing colons", () => {
|
||||
const content = `---
|
||||
name: sample-skill
|
||||
description: Use anime style IMPORTANT: Must be kawaii
|
||||
---`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.description).toBe("Use anime style IMPORTANT: Must be kawaii");
|
||||
});
|
||||
|
||||
it("does not replace YAML block scalars with block indicators", () => {
|
||||
const content = `---
|
||||
name: sample-skill
|
||||
description: |-
|
||||
{json-like text}
|
||||
---`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.description).toBe("{json-like text}");
|
||||
});
|
||||
|
||||
it("keeps nested YAML mappings as structured JSON", () => {
|
||||
const content = `---
|
||||
name: sample-skill
|
||||
metadata:
|
||||
openclaw: true
|
||||
---`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.metadata).toBe('{"openclaw":true}');
|
||||
});
|
||||
|
||||
it("returns empty when frontmatter is missing", () => {
|
||||
const content = "# No frontmatter";
|
||||
expect(parseFrontmatterBlock(content)).toEqual({});
|
||||
|
||||
@@ -2,6 +2,17 @@ import YAML from "yaml";
|
||||
|
||||
export type ParsedFrontmatter = Record<string, string>;
|
||||
|
||||
type ParsedFrontmatterLineEntry = {
|
||||
value: string;
|
||||
kind: "inline" | "multiline";
|
||||
rawInline: string;
|
||||
};
|
||||
|
||||
type ParsedYamlValue = {
|
||||
value: string;
|
||||
kind: "scalar" | "structured";
|
||||
};
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
@@ -12,19 +23,28 @@ function stripQuotes(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function coerceFrontmatterValue(value: unknown): string | undefined {
|
||||
function coerceYamlFrontmatterValue(value: unknown): ParsedYamlValue | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
return {
|
||||
value: value.trim(),
|
||||
kind: "scalar",
|
||||
};
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
return {
|
||||
value: String(value),
|
||||
kind: "scalar",
|
||||
};
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
return {
|
||||
value: JSON.stringify(value),
|
||||
kind: "structured",
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -32,20 +52,20 @@ function coerceFrontmatterValue(value: unknown): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseYamlFrontmatter(block: string): ParsedFrontmatter | null {
|
||||
function parseYamlFrontmatter(block: string): Record<string, ParsedYamlValue> | null {
|
||||
try {
|
||||
const parsed = YAML.parse(block, { schema: "core" }) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const result: ParsedFrontmatter = {};
|
||||
const result: Record<string, ParsedYamlValue> = {};
|
||||
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const coerced = coerceFrontmatterValue(value);
|
||||
if (coerced === undefined) {
|
||||
const coerced = coerceYamlFrontmatterValue(value);
|
||||
if (!coerced) {
|
||||
continue;
|
||||
}
|
||||
result[key] = coerced;
|
||||
@@ -59,18 +79,10 @@ function parseYamlFrontmatter(block: string): ParsedFrontmatter | null {
|
||||
function extractMultiLineValue(
|
||||
lines: string[],
|
||||
startIndex: number,
|
||||
): { value: string; linesConsumed: number } {
|
||||
const startLine = lines[startIndex];
|
||||
const match = startLine.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!match) {
|
||||
return { value: "", linesConsumed: 1 };
|
||||
}
|
||||
|
||||
const inlineValue = match[2].trim();
|
||||
if (inlineValue) {
|
||||
return { value: inlineValue, linesConsumed: 1 };
|
||||
}
|
||||
|
||||
): {
|
||||
value: string;
|
||||
linesConsumed: number;
|
||||
} {
|
||||
const valueLines: string[] = [];
|
||||
let i = startIndex + 1;
|
||||
|
||||
@@ -80,15 +92,15 @@ function extractMultiLineValue(
|
||||
break;
|
||||
}
|
||||
valueLines.push(line);
|
||||
i++;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const combined = valueLines.join("\n").trim();
|
||||
return { value: combined, linesConsumed: i - startIndex };
|
||||
}
|
||||
|
||||
function parseLineFrontmatter(block: string): ParsedFrontmatter {
|
||||
const frontmatter: ParsedFrontmatter = {};
|
||||
function parseLineFrontmatter(block: string): Record<string, ParsedFrontmatterLineEntry> {
|
||||
const result: Record<string, ParsedFrontmatterLineEntry> = {};
|
||||
const lines = block.split("\n");
|
||||
let i = 0;
|
||||
|
||||
@@ -96,15 +108,14 @@ function parseLineFrontmatter(block: string): ParsedFrontmatter {
|
||||
const line = lines[i];
|
||||
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!match) {
|
||||
i++;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
const inlineValue = match[2].trim();
|
||||
|
||||
if (!key) {
|
||||
i++;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -113,7 +124,11 @@ function parseLineFrontmatter(block: string): ParsedFrontmatter {
|
||||
if (nextLine.startsWith(" ") || nextLine.startsWith("\t")) {
|
||||
const { value, linesConsumed } = extractMultiLineValue(lines, i);
|
||||
if (value) {
|
||||
frontmatter[key] = value;
|
||||
result[key] = {
|
||||
value,
|
||||
kind: "multiline",
|
||||
rawInline: inlineValue,
|
||||
};
|
||||
}
|
||||
i += linesConsumed;
|
||||
continue;
|
||||
@@ -122,36 +137,90 @@ function parseLineFrontmatter(block: string): ParsedFrontmatter {
|
||||
|
||||
const value = stripQuotes(inlineValue);
|
||||
if (value) {
|
||||
frontmatter[key] = value;
|
||||
result[key] = {
|
||||
value,
|
||||
kind: "inline",
|
||||
rawInline: inlineValue,
|
||||
};
|
||||
}
|
||||
i++;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseFrontmatterBlock(content: string): ParsedFrontmatter {
|
||||
function lineFrontmatterToPlain(
|
||||
parsed: Record<string, ParsedFrontmatterLineEntry>,
|
||||
): ParsedFrontmatter {
|
||||
const result: ParsedFrontmatter = {};
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
result[key] = entry.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isYamlBlockScalarIndicator(value: string): boolean {
|
||||
return /^[|>][+-]?(\d+)?[+-]?$/.test(value);
|
||||
}
|
||||
|
||||
function shouldPreferInlineLineValue(params: {
|
||||
lineEntry: ParsedFrontmatterLineEntry;
|
||||
yamlValue: ParsedYamlValue;
|
||||
}): boolean {
|
||||
const { lineEntry, yamlValue } = params;
|
||||
if (yamlValue.kind !== "structured") {
|
||||
return false;
|
||||
}
|
||||
if (lineEntry.kind !== "inline") {
|
||||
return false;
|
||||
}
|
||||
if (isYamlBlockScalarIndicator(lineEntry.rawInline)) {
|
||||
return false;
|
||||
}
|
||||
return lineEntry.value.includes(":");
|
||||
}
|
||||
|
||||
function extractFrontmatterBlock(content: string): string | undefined {
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) {
|
||||
return {};
|
||||
return undefined;
|
||||
}
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized.slice(4, endIndex);
|
||||
}
|
||||
|
||||
export function parseFrontmatterBlock(content: string): ParsedFrontmatter {
|
||||
const block = extractFrontmatterBlock(content);
|
||||
if (!block) {
|
||||
return {};
|
||||
}
|
||||
const block = normalized.slice(4, endIndex);
|
||||
|
||||
const lineParsed = parseLineFrontmatter(block);
|
||||
const yamlParsed = parseYamlFrontmatter(block);
|
||||
if (yamlParsed === null) {
|
||||
return lineParsed;
|
||||
return lineFrontmatterToPlain(lineParsed);
|
||||
}
|
||||
|
||||
const merged: ParsedFrontmatter = { ...yamlParsed };
|
||||
for (const [key, value] of Object.entries(lineParsed)) {
|
||||
if (value.startsWith("{") || value.startsWith("[")) {
|
||||
merged[key] = value;
|
||||
const merged: ParsedFrontmatter = {};
|
||||
for (const [key, yamlValue] of Object.entries(yamlParsed)) {
|
||||
merged[key] = yamlValue.value;
|
||||
const lineEntry = lineParsed[key];
|
||||
if (!lineEntry) {
|
||||
continue;
|
||||
}
|
||||
if (shouldPreferInlineLineValue({ lineEntry, yamlValue })) {
|
||||
merged[key] = lineEntry.value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, lineEntry] of Object.entries(lineParsed)) {
|
||||
if (!(key in merged)) {
|
||||
merged[key] = lineEntry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
25
src/media/load-options.test.ts
Normal file
25
src/media/load-options.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildOutboundMediaLoadOptions, resolveOutboundMediaLocalRoots } from "./load-options.js";
|
||||
|
||||
describe("media load options", () => {
|
||||
it("returns undefined localRoots when mediaLocalRoots is empty", () => {
|
||||
expect(resolveOutboundMediaLocalRoots(undefined)).toBeUndefined();
|
||||
expect(resolveOutboundMediaLocalRoots([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps trusted mediaLocalRoots entries", () => {
|
||||
expect(resolveOutboundMediaLocalRoots(["/tmp/workspace"])).toEqual(["/tmp/workspace"]);
|
||||
});
|
||||
|
||||
it("builds loadWebMedia options from maxBytes and mediaLocalRoots", () => {
|
||||
expect(
|
||||
buildOutboundMediaLoadOptions({
|
||||
maxBytes: 1024,
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
}),
|
||||
).toEqual({
|
||||
maxBytes: 1024,
|
||||
localRoots: ["/tmp/workspace"],
|
||||
});
|
||||
});
|
||||
});
|
||||
25
src/media/load-options.ts
Normal file
25
src/media/load-options.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type OutboundMediaLoadParams = {
|
||||
maxBytes?: number;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
};
|
||||
|
||||
export type OutboundMediaLoadOptions = {
|
||||
maxBytes?: number;
|
||||
localRoots?: readonly string[];
|
||||
};
|
||||
|
||||
export function resolveOutboundMediaLocalRoots(
|
||||
mediaLocalRoots?: readonly string[],
|
||||
): readonly string[] | undefined {
|
||||
return mediaLocalRoots && mediaLocalRoots.length > 0 ? mediaLocalRoots : undefined;
|
||||
}
|
||||
|
||||
export function buildOutboundMediaLoadOptions(
|
||||
params: OutboundMediaLoadParams = {},
|
||||
): OutboundMediaLoadOptions {
|
||||
const localRoots = resolveOutboundMediaLocalRoots(params.mediaLocalRoots);
|
||||
return {
|
||||
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
|
||||
...(localRoots ? { localRoots } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { buildOutboundMediaLoadOptions } from "./load-options.js";
|
||||
import { saveMediaBuffer } from "./store.js";
|
||||
|
||||
export async function resolveOutboundAttachmentFromUrl(
|
||||
@@ -6,10 +7,13 @@ export async function resolveOutboundAttachmentFromUrl(
|
||||
maxBytes: number,
|
||||
options?: { localRoots?: readonly string[] },
|
||||
): Promise<{ path: string; contentType?: string }> {
|
||||
const media = await loadWebMedia(mediaUrl, {
|
||||
maxBytes,
|
||||
localRoots: options?.localRoots,
|
||||
});
|
||||
const media = await loadWebMedia(
|
||||
mediaUrl,
|
||||
buildOutboundMediaLoadOptions({
|
||||
maxBytes,
|
||||
mediaLocalRoots: options?.localRoots,
|
||||
}),
|
||||
);
|
||||
const saved = await saveMediaBuffer(
|
||||
media.buffer,
|
||||
media.contentType ?? undefined,
|
||||
|
||||
513
src/telegram/bot/delivery.replies.ts
Normal file
513
src/telegram/bot/delivery.replies.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import { type Bot, GrammyError, InputFile } from "grammy";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { ReplyToMode } from "../../config/config.js";
|
||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||
import { danger, logVerbose } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { mediaKindFromMime } from "../../media/constants.js";
|
||||
import { buildOutboundMediaLoadOptions } from "../../media/load-options.js";
|
||||
import { isGifMedia } from "../../media/mime.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import type { TelegramInlineButtons } from "../button-types.js";
|
||||
import { splitTelegramCaption } from "../caption.js";
|
||||
import {
|
||||
markdownToTelegramChunks,
|
||||
markdownToTelegramHtml,
|
||||
renderTelegramHtmlText,
|
||||
wrapFileReferencesInHtml,
|
||||
} from "../format.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import {
|
||||
buildTelegramSendParams,
|
||||
sendTelegramText,
|
||||
sendTelegramWithThreadFallback,
|
||||
} from "./delivery.send.js";
|
||||
import { resolveTelegramReplyId, type TelegramThreadSpec } from "./helpers.js";
|
||||
|
||||
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
||||
const CAPTION_TOO_LONG_RE = /caption is too long/i;
|
||||
|
||||
type DeliveryProgress = {
|
||||
hasReplied: boolean;
|
||||
hasDelivered: boolean;
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
||||
|
||||
function buildChunkTextResolver(params: {
|
||||
textLimit: number;
|
||||
chunkMode: ChunkMode;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): ChunkTextFn {
|
||||
return (markdown: string) => {
|
||||
const markdownChunks =
|
||||
params.chunkMode === "newline"
|
||||
? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode)
|
||||
: [markdown];
|
||||
const chunks: ReturnType<typeof markdownToTelegramChunks> = [];
|
||||
for (const chunk of markdownChunks) {
|
||||
const nested = markdownToTelegramChunks(chunk, params.textLimit, {
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
if (!nested.length && chunk) {
|
||||
chunks.push({
|
||||
html: wrapFileReferencesInHtml(
|
||||
markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }),
|
||||
),
|
||||
text: chunk,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
chunks.push(...nested);
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReplyToForSend(params: {
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): number | undefined {
|
||||
return params.replyToId && (params.replyToMode === "all" || !params.progress.hasReplied)
|
||||
? params.replyToId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function markReplyApplied(progress: DeliveryProgress, replyToId?: number): void {
|
||||
if (replyToId && !progress.hasReplied) {
|
||||
progress.hasReplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
function markDelivered(progress: DeliveryProgress): void {
|
||||
progress.hasDelivered = true;
|
||||
}
|
||||
|
||||
async function deliverTextReply(params: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
chunkText: ChunkTextFn;
|
||||
replyText: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
linkPreview?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
const chunks = params.chunkText(params.replyText);
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const shouldAttachButtons = i === 0 && params.replyMarkup;
|
||||
const replyToForChunk = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId: replyToForChunk,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup: shouldAttachButtons ? params.replyMarkup : undefined,
|
||||
});
|
||||
markReplyApplied(params.progress, replyToForChunk);
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPendingFollowUpText(params: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
chunkText: ChunkTextFn;
|
||||
text: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
linkPreview?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
const chunks = params.chunkText(params.text);
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
const replyToForFollowUp = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId: replyToForFollowUp,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup: i === 0 ? params.replyMarkup : undefined,
|
||||
});
|
||||
markReplyApplied(params.progress, replyToForFollowUp);
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
}
|
||||
|
||||
function isVoiceMessagesForbidden(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return VOICE_FORBIDDEN_RE.test(err.description);
|
||||
}
|
||||
return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isCaptionTooLong(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return CAPTION_TOO_LONG_RE.test(err.description);
|
||||
}
|
||||
return CAPTION_TOO_LONG_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
async function sendTelegramVoiceFallbackText(opts: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
text: string;
|
||||
chunkText: (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
||||
replyToId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
linkPreview?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
}): Promise<void> {
|
||||
const chunks = opts.chunkText(opts.text);
|
||||
let appliedReplyTo = false;
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
// Only apply reply reference, quote text, and buttons to the first chunk.
|
||||
const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined;
|
||||
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||
replyToMessageId: replyToForChunk,
|
||||
replyQuoteText: !appliedReplyTo ? opts.replyQuoteText : undefined,
|
||||
thread: opts.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (replyToForChunk) {
|
||||
appliedReplyTo = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverMediaReply(params: {
|
||||
reply: ReplyPayload;
|
||||
mediaList: string[];
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
chunkText: ChunkTextFn;
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: boolean;
|
||||
replyQuoteText?: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
let first = true;
|
||||
let pendingFollowUpText: string | undefined;
|
||||
for (const mediaUrl of params.mediaList) {
|
||||
const isFirstMedia = first;
|
||||
const media = await loadWebMedia(
|
||||
mediaUrl,
|
||||
buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }),
|
||||
);
|
||||
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
||||
const isGif = isGifMedia({
|
||||
contentType: media.contentType,
|
||||
fileName: media.fileName,
|
||||
});
|
||||
const fileName = media.fileName ?? (isGif ? "animation.gif" : "file");
|
||||
const file = new InputFile(media.buffer, fileName);
|
||||
const { caption, followUpText } = splitTelegramCaption(
|
||||
isFirstMedia ? (params.reply.text ?? undefined) : undefined,
|
||||
);
|
||||
const htmlCaption = caption
|
||||
? renderTelegramHtmlText(caption, { tableMode: params.tableMode })
|
||||
: undefined;
|
||||
if (followUpText) {
|
||||
pendingFollowUpText = followUpText;
|
||||
}
|
||||
first = false;
|
||||
const replyToMessageId = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
const shouldAttachButtonsToMedia = isFirstMedia && params.replyMarkup && !followUpText;
|
||||
const mediaParams: Record<string, unknown> = {
|
||||
caption: htmlCaption,
|
||||
...(htmlCaption ? { parse_mode: "HTML" } : {}),
|
||||
...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}),
|
||||
...buildTelegramSendParams({
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
}),
|
||||
};
|
||||
if (isGif) {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendAnimation",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendAnimation(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "image") {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendPhoto",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendPhoto(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "video") {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendVideo",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendVideo(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "audio") {
|
||||
const { useVoice } = resolveTelegramVoiceSend({
|
||||
wantsVoice: params.reply.audioAsVoice === true,
|
||||
contentType: media.contentType,
|
||||
fileName,
|
||||
logFallback: logVerbose,
|
||||
});
|
||||
if (useVoice) {
|
||||
await params.onVoiceRecording?.();
|
||||
try {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendVoice",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
shouldLog: (err) => !isVoiceMessagesForbidden(err),
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
} catch (voiceErr) {
|
||||
if (isVoiceMessagesForbidden(voiceErr)) {
|
||||
const fallbackText = params.reply.text;
|
||||
if (!fallbackText || !fallbackText.trim()) {
|
||||
throw voiceErr;
|
||||
}
|
||||
logVerbose(
|
||||
"telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text",
|
||||
);
|
||||
const voiceFallbackReplyTo = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
await sendTelegramVoiceFallbackText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: voiceFallbackReplyTo,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
});
|
||||
markReplyApplied(params.progress, voiceFallbackReplyTo);
|
||||
markDelivered(params.progress);
|
||||
continue;
|
||||
}
|
||||
if (isCaptionTooLong(voiceErr)) {
|
||||
logVerbose(
|
||||
"telegram sendVoice caption too long; resending voice without caption + text separately",
|
||||
);
|
||||
const noCaptionParams = { ...mediaParams };
|
||||
delete noCaptionParams.caption;
|
||||
delete noCaptionParams.parse_mode;
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendVoice",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: noCaptionParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
const fallbackText = params.reply.text;
|
||||
if (fallbackText?.trim()) {
|
||||
await sendTelegramVoiceFallbackText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: undefined,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup: params.replyMarkup,
|
||||
});
|
||||
}
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
continue;
|
||||
}
|
||||
throw voiceErr;
|
||||
}
|
||||
} else {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendAudio",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendAudio(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
} else {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendDocument",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendDocument(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
if (pendingFollowUpText && isFirstMedia) {
|
||||
await sendPendingFollowUpText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
chunkText: params.chunkText,
|
||||
text: pendingFollowUpText,
|
||||
replyMarkup: params.replyMarkup,
|
||||
linkPreview: params.linkPreview,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
pendingFollowUpText = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
chatId: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
bot: Bot;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
replyToMode: ReplyToMode;
|
||||
textLimit: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
/** Callback invoked before sending a voice message to switch typing indicator. */
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
linkPreview?: boolean;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
const progress: DeliveryProgress = {
|
||||
hasReplied: false,
|
||||
hasDelivered: false,
|
||||
};
|
||||
const chunkText = buildChunkTextResolver({
|
||||
textLimit: params.textLimit,
|
||||
chunkMode: params.chunkMode ?? "length",
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
for (const reply of params.replies) {
|
||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||
if (!reply?.text && !hasMedia) {
|
||||
if (reply?.audioAsVoice) {
|
||||
logVerbose("telegram reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.(danger("reply missing text/media"));
|
||||
continue;
|
||||
}
|
||||
const replyToId =
|
||||
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
||||
const mediaList = reply.mediaUrls?.length
|
||||
? reply.mediaUrls
|
||||
: reply.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
const telegramData = reply.channelData?.telegram as
|
||||
| { buttons?: TelegramInlineButtons }
|
||||
| undefined;
|
||||
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
||||
if (mediaList.length === 0) {
|
||||
await deliverTextReply({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
chunkText,
|
||||
replyText: reply.text || "",
|
||||
replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
linkPreview: params.linkPreview,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await deliverMediaReply({
|
||||
reply,
|
||||
mediaList,
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
tableMode: params.tableMode,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
chunkText,
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyMarkup,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
return { delivered: progress.hasDelivered };
|
||||
}
|
||||
190
src/telegram/bot/delivery.resolve-media.ts
Normal file
190
src/telegram/bot/delivery.resolve-media.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { GrammyError } from "grammy";
|
||||
import { logVerbose, warn } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { retryAsync } from "../../infra/retry.js";
|
||||
import { fetchRemoteMedia } from "../../media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||
|
||||
const FILE_TOO_BIG_RE = /file is too big/i;
|
||||
const TELEGRAM_MEDIA_SSRF_POLICY = {
|
||||
// Telegram file downloads should trust api.telegram.org even when DNS/proxy
|
||||
// resolution maps to private/internal ranges in restricted networks.
|
||||
allowedHostnames: ["api.telegram.org"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the error is Telegram's "file is too big" error.
|
||||
* This happens when trying to download files >20MB via the Bot API.
|
||||
* Unlike network errors, this is a permanent error and should not be retried.
|
||||
*/
|
||||
function isFileTooBigError(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return FILE_TOO_BIG_RE.test(err.description);
|
||||
}
|
||||
return FILE_TOO_BIG_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the error is a transient network error that should be retried.
|
||||
* Returns false for permanent errors like "file is too big" (400 Bad Request).
|
||||
*/
|
||||
function isRetryableGetFileError(err: unknown): boolean {
|
||||
// Don't retry "file is too big" - it's a permanent 400 error
|
||||
if (isFileTooBigError(err)) {
|
||||
return false;
|
||||
}
|
||||
// Retry all other errors (network issues, timeouts, etc.)
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function resolveMedia(
|
||||
ctx: TelegramContext,
|
||||
maxBytes: number,
|
||||
token: string,
|
||||
proxyFetch?: typeof fetch,
|
||||
): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
} | null> {
|
||||
const msg = ctx.message;
|
||||
const downloadAndSaveTelegramFile = async (filePath: string, fetchImpl: typeof fetch) => {
|
||||
const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
fetchImpl,
|
||||
filePathHint: filePath,
|
||||
maxBytes,
|
||||
ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY,
|
||||
});
|
||||
const originalName = fetched.fileName ?? filePath;
|
||||
return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName);
|
||||
};
|
||||
|
||||
// Handle stickers separately - only static stickers (WEBP) are supported
|
||||
if (msg.sticker) {
|
||||
const sticker = msg.sticker;
|
||||
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
|
||||
if (sticker.is_animated || sticker.is_video) {
|
||||
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
|
||||
return null;
|
||||
}
|
||||
if (!sticker.file_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await ctx.getFile();
|
||||
if (!file.file_path) {
|
||||
logVerbose("telegram: getFile returned no file_path for sticker");
|
||||
return null;
|
||||
}
|
||||
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
||||
if (!fetchImpl) {
|
||||
logVerbose("telegram: fetch not available for sticker download");
|
||||
return null;
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
|
||||
|
||||
// Check sticker cache for existing description
|
||||
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
||||
if (cached) {
|
||||
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
||||
const fileId = sticker.file_id ?? cached.fileId;
|
||||
const emoji = sticker.emoji ?? cached.emoji;
|
||||
const setName = sticker.set_name ?? cached.setName;
|
||||
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
||||
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
||||
cacheSticker({
|
||||
...cached,
|
||||
fileId,
|
||||
emoji,
|
||||
setName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji,
|
||||
setName,
|
||||
fileId,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
cachedDescription: cached.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cache miss - return metadata for vision processing
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji: sticker.emoji ?? undefined,
|
||||
setName: sticker.set_name ?? undefined,
|
||||
fileId: sticker.file_id,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const m =
|
||||
msg.photo?.[msg.photo.length - 1] ??
|
||||
msg.video ??
|
||||
msg.video_note ??
|
||||
msg.document ??
|
||||
msg.audio ??
|
||||
msg.voice;
|
||||
if (!m?.file_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let file: { file_path?: string };
|
||||
try {
|
||||
file = await retryAsync(() => ctx.getFile(), {
|
||||
attempts: 3,
|
||||
minDelayMs: 1000,
|
||||
maxDelayMs: 4000,
|
||||
jitter: 0.2,
|
||||
label: "telegram:getFile",
|
||||
shouldRetry: isRetryableGetFileError,
|
||||
onRetry: ({ attempt, maxAttempts }) =>
|
||||
logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`),
|
||||
});
|
||||
} catch (err) {
|
||||
// Handle "file is too big" separately - Telegram Bot API has a 20MB download limit
|
||||
if (isFileTooBigError(err)) {
|
||||
logVerbose(
|
||||
warn(
|
||||
"telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment",
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// All retries exhausted — return null so the message still reaches the agent
|
||||
// with a type-based placeholder (e.g. <media:audio>) instead of being dropped.
|
||||
logVerbose(`telegram: getFile failed after retries: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
if (!file.file_path) {
|
||||
throw new Error("Telegram getFile returned no file_path");
|
||||
}
|
||||
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
||||
if (!fetchImpl) {
|
||||
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
|
||||
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
|
||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||
}
|
||||
172
src/telegram/bot/delivery.send.ts
Normal file
172
src/telegram/bot/delivery.send.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { type Bot, GrammyError } from "grammy";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { withTelegramApiErrorLogging } from "../api-logging.js";
|
||||
import { markdownToTelegramHtml } from "../format.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./helpers.js";
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
|
||||
const THREAD_NOT_FOUND_RE = /message thread not found/i;
|
||||
|
||||
function isTelegramThreadNotFoundError(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return THREAD_NOT_FOUND_RE.test(err.description);
|
||||
}
|
||||
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function hasMessageThreadIdParam(params: Record<string, unknown> | undefined): boolean {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
return typeof params.message_thread_id === "number";
|
||||
}
|
||||
|
||||
function removeMessageThreadIdParam(
|
||||
params: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
const { message_thread_id: _ignored, ...rest } = params;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
operation: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
requestParams: Record<string, unknown>;
|
||||
send: (effectiveParams: Record<string, unknown>) => Promise<T>;
|
||||
shouldLog?: (err: unknown) => boolean;
|
||||
}): Promise<T> {
|
||||
const allowThreadlessRetry = params.thread?.scope === "dm";
|
||||
const hasThreadId = hasMessageThreadIdParam(params.requestParams);
|
||||
const shouldSuppressFirstErrorLog = (err: unknown) =>
|
||||
allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err);
|
||||
const mergedShouldLog = params.shouldLog
|
||||
? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err)
|
||||
: (err: unknown) => !shouldSuppressFirstErrorLog(err);
|
||||
|
||||
try {
|
||||
return await withTelegramApiErrorLogging({
|
||||
operation: params.operation,
|
||||
runtime: params.runtime,
|
||||
shouldLog: mergedShouldLog,
|
||||
fn: () => params.send(params.requestParams),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const retryParams = removeMessageThreadIdParam(params.requestParams);
|
||||
params.runtime.log?.(
|
||||
`telegram ${params.operation}: message thread not found; retrying without message_thread_id`,
|
||||
);
|
||||
return await withTelegramApiErrorLogging({
|
||||
operation: `${params.operation} (threadless retry)`,
|
||||
runtime: params.runtime,
|
||||
fn: () => params.send(retryParams),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTelegramSendParams(opts?: {
|
||||
replyToMessageId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
}): Record<string, unknown> {
|
||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||
const params: Record<string, unknown> = {};
|
||||
if (opts?.replyToMessageId) {
|
||||
params.reply_to_message_id = opts.replyToMessageId;
|
||||
}
|
||||
if (threadParams) {
|
||||
params.message_thread_id = threadParams.message_thread_id;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function sendTelegramText(
|
||||
bot: Bot,
|
||||
chatId: string,
|
||||
text: string,
|
||||
runtime: RuntimeEnv,
|
||||
opts?: {
|
||||
replyToMessageId?: number;
|
||||
replyQuoteText?: string;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
linkPreview?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
},
|
||||
): Promise<number> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
thread: opts?.thread,
|
||||
});
|
||||
// Add link_preview_options when link preview is disabled.
|
||||
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
|
||||
const fallbackText = opts?.plainText ?? text;
|
||||
const hasFallbackText = fallbackText.trim().length > 0;
|
||||
const sendPlainFallback = async () => {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: baseParams,
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, fallbackText, {
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`);
|
||||
return res.message_id;
|
||||
};
|
||||
|
||||
// Markdown can render to empty HTML for syntax-only chunks; recover with plain text.
|
||||
if (!htmlText.trim()) {
|
||||
if (!hasFallbackText) {
|
||||
throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback");
|
||||
}
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
try {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: baseParams,
|
||||
shouldLog: (err) => {
|
||||
const errText = formatErrorMessage(err);
|
||||
return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText);
|
||||
},
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, htmlText, {
|
||||
parse_mode: "HTML",
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
return res.message_id;
|
||||
} catch (err) {
|
||||
const errText = formatErrorMessage(err);
|
||||
if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) {
|
||||
if (!hasFallbackText) {
|
||||
throw err;
|
||||
}
|
||||
runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,840 +1,2 @@
|
||||
import { type Bot, GrammyError, InputFile } from "grammy";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { ReplyToMode } from "../../config/config.js";
|
||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||
import { danger, logVerbose, warn } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { retryAsync } from "../../infra/retry.js";
|
||||
import { mediaKindFromMime } from "../../media/constants.js";
|
||||
import { fetchRemoteMedia } from "../../media/fetch.js";
|
||||
import { isGifMedia } from "../../media/mime.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { withTelegramApiErrorLogging } from "../api-logging.js";
|
||||
import type { TelegramInlineButtons } from "../button-types.js";
|
||||
import { splitTelegramCaption } from "../caption.js";
|
||||
import {
|
||||
markdownToTelegramChunks,
|
||||
markdownToTelegramHtml,
|
||||
renderTelegramHtmlText,
|
||||
wrapFileReferencesInHtml,
|
||||
} from "../format.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import {
|
||||
buildTelegramThreadParams,
|
||||
resolveTelegramMediaPlaceholder,
|
||||
resolveTelegramReplyId,
|
||||
type TelegramThreadSpec,
|
||||
} from "./helpers.js";
|
||||
import {
|
||||
createDeliveryProgress,
|
||||
markDelivered,
|
||||
markReplyApplied,
|
||||
resolveReplyToForSend,
|
||||
sendChunkedTelegramReplyText,
|
||||
type DeliveryProgress,
|
||||
} from "./reply-threading.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
|
||||
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
||||
const CAPTION_TOO_LONG_RE = /caption is too long/i;
|
||||
const FILE_TOO_BIG_RE = /file is too big/i;
|
||||
const THREAD_NOT_FOUND_RE = /message thread not found/i;
|
||||
const TELEGRAM_MEDIA_SSRF_POLICY = {
|
||||
// Telegram file downloads should trust api.telegram.org even when DNS/proxy
|
||||
// resolution maps to private/internal ranges in restricted networks.
|
||||
allowedHostnames: ["api.telegram.org"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
||||
|
||||
function buildChunkTextResolver(params: {
|
||||
textLimit: number;
|
||||
chunkMode: ChunkMode;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): ChunkTextFn {
|
||||
return (markdown: string) => {
|
||||
const markdownChunks =
|
||||
params.chunkMode === "newline"
|
||||
? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode)
|
||||
: [markdown];
|
||||
const chunks: ReturnType<typeof markdownToTelegramChunks> = [];
|
||||
for (const chunk of markdownChunks) {
|
||||
const nested = markdownToTelegramChunks(chunk, params.textLimit, {
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
if (!nested.length && chunk) {
|
||||
chunks.push({
|
||||
html: wrapFileReferencesInHtml(
|
||||
markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }),
|
||||
),
|
||||
text: chunk,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
chunks.push(...nested);
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
}
|
||||
|
||||
async function deliverTextReply(params: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
chunkText: ChunkTextFn;
|
||||
replyText: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
linkPreview?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
const chunks = params.chunkText(params.replyText);
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
quoteOnlyOnFirstChunk: true,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId,
|
||||
replyQuoteText,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function sendPendingFollowUpText(params: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
chunkText: ChunkTextFn;
|
||||
text: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
linkPreview?: boolean;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
const chunks = params.chunkText(params.text);
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyMarkup: params.replyMarkup,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => {
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverMediaReply(params: {
|
||||
reply: ReplyPayload;
|
||||
mediaList: string[];
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
chunkText: ChunkTextFn;
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: boolean;
|
||||
replyQuoteText?: string;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
let first = true;
|
||||
let pendingFollowUpText: string | undefined;
|
||||
for (const mediaUrl of params.mediaList) {
|
||||
const isFirstMedia = first;
|
||||
const media = await loadWebMedia(mediaUrl, {
|
||||
localRoots: params.mediaLocalRoots,
|
||||
});
|
||||
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
||||
const isGif = isGifMedia({
|
||||
contentType: media.contentType,
|
||||
fileName: media.fileName,
|
||||
});
|
||||
const fileName = media.fileName ?? (isGif ? "animation.gif" : "file");
|
||||
const file = new InputFile(media.buffer, fileName);
|
||||
const { caption, followUpText } = splitTelegramCaption(
|
||||
isFirstMedia ? (params.reply.text ?? undefined) : undefined,
|
||||
);
|
||||
const htmlCaption = caption
|
||||
? renderTelegramHtmlText(caption, { tableMode: params.tableMode })
|
||||
: undefined;
|
||||
if (followUpText) {
|
||||
pendingFollowUpText = followUpText;
|
||||
}
|
||||
first = false;
|
||||
const replyToMessageId = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
const shouldAttachButtonsToMedia = isFirstMedia && params.replyMarkup && !followUpText;
|
||||
const mediaParams: Record<string, unknown> = {
|
||||
caption: htmlCaption,
|
||||
...(htmlCaption ? { parse_mode: "HTML" } : {}),
|
||||
...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}),
|
||||
...buildTelegramSendParams({
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
}),
|
||||
};
|
||||
if (isGif) {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendAnimation",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendAnimation(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "image") {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendPhoto",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendPhoto(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "video") {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendVideo",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendVideo(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
} else if (kind === "audio") {
|
||||
const { useVoice } = resolveTelegramVoiceSend({
|
||||
wantsVoice: params.reply.audioAsVoice === true,
|
||||
contentType: media.contentType,
|
||||
fileName,
|
||||
logFallback: logVerbose,
|
||||
});
|
||||
if (useVoice) {
|
||||
await params.onVoiceRecording?.();
|
||||
try {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendVoice",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
shouldLog: (err) => !isVoiceMessagesForbidden(err),
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
} catch (voiceErr) {
|
||||
if (isVoiceMessagesForbidden(voiceErr)) {
|
||||
const fallbackText = params.reply.text;
|
||||
if (!fallbackText || !fallbackText.trim()) {
|
||||
throw voiceErr;
|
||||
}
|
||||
logVerbose(
|
||||
"telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text",
|
||||
);
|
||||
const voiceFallbackReplyTo = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
await sendTelegramVoiceFallbackText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: voiceFallbackReplyTo,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
});
|
||||
markReplyApplied(params.progress, voiceFallbackReplyTo);
|
||||
markDelivered(params.progress);
|
||||
continue;
|
||||
}
|
||||
if (isCaptionTooLong(voiceErr)) {
|
||||
logVerbose(
|
||||
"telegram sendVoice caption too long; resending voice without caption + text separately",
|
||||
);
|
||||
const noCaptionParams = { ...mediaParams };
|
||||
delete noCaptionParams.caption;
|
||||
delete noCaptionParams.parse_mode;
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendVoice",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: noCaptionParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
const fallbackText = params.reply.text;
|
||||
if (fallbackText?.trim()) {
|
||||
await sendTelegramVoiceFallbackText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
text: fallbackText,
|
||||
chunkText: params.chunkText,
|
||||
replyToId: undefined,
|
||||
thread: params.thread,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup: params.replyMarkup,
|
||||
});
|
||||
}
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
continue;
|
||||
}
|
||||
throw voiceErr;
|
||||
}
|
||||
} else {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendAudio",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendAudio(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
} else {
|
||||
await sendTelegramWithThreadFallback({
|
||||
operation: "sendDocument",
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
requestParams: mediaParams,
|
||||
send: (effectiveParams) =>
|
||||
params.bot.api.sendDocument(params.chatId, file, { ...effectiveParams }),
|
||||
});
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
if (pendingFollowUpText && isFirstMedia) {
|
||||
await sendPendingFollowUpText({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
chunkText: params.chunkText,
|
||||
text: pendingFollowUpText,
|
||||
replyMarkup: params.replyMarkup,
|
||||
linkPreview: params.linkPreview,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
pendingFollowUpText = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
chatId: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
bot: Bot;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
replyToMode: ReplyToMode;
|
||||
textLimit: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
/** Callback invoked before sending a voice message to switch typing indicator. */
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
linkPreview?: boolean;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
const progress = createDeliveryProgress();
|
||||
const chunkText = buildChunkTextResolver({
|
||||
textLimit: params.textLimit,
|
||||
chunkMode: params.chunkMode ?? "length",
|
||||
tableMode: params.tableMode,
|
||||
});
|
||||
for (const reply of params.replies) {
|
||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||
if (!reply?.text && !hasMedia) {
|
||||
if (reply?.audioAsVoice) {
|
||||
logVerbose("telegram reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.(danger("reply missing text/media"));
|
||||
continue;
|
||||
}
|
||||
const replyToId =
|
||||
params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
||||
const mediaList = reply.mediaUrls?.length
|
||||
? reply.mediaUrls
|
||||
: reply.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
const telegramData = reply.channelData?.telegram as
|
||||
| { buttons?: TelegramInlineButtons }
|
||||
| undefined;
|
||||
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
||||
if (mediaList.length === 0) {
|
||||
await deliverTextReply({
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
chunkText,
|
||||
replyText: reply.text || "",
|
||||
replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
linkPreview: params.linkPreview,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await deliverMediaReply({
|
||||
reply,
|
||||
mediaList,
|
||||
bot: params.bot,
|
||||
chatId: params.chatId,
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
tableMode: params.tableMode,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
chunkText,
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
replyMarkup,
|
||||
replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
return { delivered: progress.hasDelivered };
|
||||
}
|
||||
|
||||
export async function resolveMedia(
|
||||
ctx: TelegramContext,
|
||||
maxBytes: number,
|
||||
token: string,
|
||||
proxyFetch?: typeof fetch,
|
||||
): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
stickerMetadata?: StickerMetadata;
|
||||
} | null> {
|
||||
const msg = ctx.message;
|
||||
const downloadAndSaveTelegramFile = async (filePath: string, fetchImpl: typeof fetch) => {
|
||||
const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
fetchImpl,
|
||||
filePathHint: filePath,
|
||||
maxBytes,
|
||||
ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY,
|
||||
});
|
||||
const originalName = fetched.fileName ?? filePath;
|
||||
return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName);
|
||||
};
|
||||
|
||||
// Handle stickers separately - only static stickers (WEBP) are supported
|
||||
if (msg.sticker) {
|
||||
const sticker = msg.sticker;
|
||||
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
|
||||
if (sticker.is_animated || sticker.is_video) {
|
||||
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
|
||||
return null;
|
||||
}
|
||||
if (!sticker.file_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await ctx.getFile();
|
||||
if (!file.file_path) {
|
||||
logVerbose("telegram: getFile returned no file_path for sticker");
|
||||
return null;
|
||||
}
|
||||
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
||||
if (!fetchImpl) {
|
||||
logVerbose("telegram: fetch not available for sticker download");
|
||||
return null;
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
|
||||
|
||||
// Check sticker cache for existing description
|
||||
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
||||
if (cached) {
|
||||
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
||||
const fileId = sticker.file_id ?? cached.fileId;
|
||||
const emoji = sticker.emoji ?? cached.emoji;
|
||||
const setName = sticker.set_name ?? cached.setName;
|
||||
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
||||
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
||||
cacheSticker({
|
||||
...cached,
|
||||
fileId,
|
||||
emoji,
|
||||
setName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji,
|
||||
setName,
|
||||
fileId,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
cachedDescription: cached.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cache miss - return metadata for vision processing
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
stickerMetadata: {
|
||||
emoji: sticker.emoji ?? undefined,
|
||||
setName: sticker.set_name ?? undefined,
|
||||
fileId: sticker.file_id,
|
||||
fileUniqueId: sticker.file_unique_id,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const m =
|
||||
msg.photo?.[msg.photo.length - 1] ??
|
||||
msg.video ??
|
||||
msg.video_note ??
|
||||
msg.document ??
|
||||
msg.audio ??
|
||||
msg.voice;
|
||||
if (!m?.file_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let file: { file_path?: string };
|
||||
try {
|
||||
file = await retryAsync(() => ctx.getFile(), {
|
||||
attempts: 3,
|
||||
minDelayMs: 1000,
|
||||
maxDelayMs: 4000,
|
||||
jitter: 0.2,
|
||||
label: "telegram:getFile",
|
||||
shouldRetry: isRetryableGetFileError,
|
||||
onRetry: ({ attempt, maxAttempts }) =>
|
||||
logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`),
|
||||
});
|
||||
} catch (err) {
|
||||
// Handle "file is too big" separately - Telegram Bot API has a 20MB download limit
|
||||
if (isFileTooBigError(err)) {
|
||||
logVerbose(
|
||||
warn(
|
||||
"telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment",
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// All retries exhausted — return null so the message still reaches the agent
|
||||
// with a type-based placeholder (e.g. <media:audio>) instead of being dropped.
|
||||
logVerbose(`telegram: getFile failed after retries: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
if (!file.file_path) {
|
||||
throw new Error("Telegram getFile returned no file_path");
|
||||
}
|
||||
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
||||
if (!fetchImpl) {
|
||||
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
||||
}
|
||||
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
|
||||
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
|
||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||
}
|
||||
|
||||
function isVoiceMessagesForbidden(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return VOICE_FORBIDDEN_RE.test(err.description);
|
||||
}
|
||||
return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isCaptionTooLong(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return CAPTION_TOO_LONG_RE.test(err.description);
|
||||
}
|
||||
return CAPTION_TOO_LONG_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the error is Telegram's "file is too big" error.
|
||||
* This happens when trying to download files >20MB via the Bot API.
|
||||
* Unlike network errors, this is a permanent error and should not be retried.
|
||||
*/
|
||||
function isFileTooBigError(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return FILE_TOO_BIG_RE.test(err.description);
|
||||
}
|
||||
return FILE_TOO_BIG_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the error is a transient network error that should be retried.
|
||||
* Returns false for permanent errors like "file is too big" (400 Bad Request).
|
||||
*/
|
||||
function isRetryableGetFileError(err: unknown): boolean {
|
||||
// Don't retry "file is too big" - it's a permanent 400 error
|
||||
if (isFileTooBigError(err)) {
|
||||
return false;
|
||||
}
|
||||
// Retry all other errors (network issues, timeouts, etc.)
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendTelegramVoiceFallbackText(opts: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
runtime: RuntimeEnv;
|
||||
text: string;
|
||||
chunkText: (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
||||
replyToId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
linkPreview?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
replyQuoteText?: string;
|
||||
}): Promise<void> {
|
||||
const chunks = opts.chunkText(opts.text);
|
||||
const progress = createDeliveryProgress();
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress,
|
||||
replyToId: opts.replyToId,
|
||||
replyToMode: "first",
|
||||
replyMarkup: opts.replyMarkup,
|
||||
replyQuoteText: opts.replyQuoteText,
|
||||
quoteOnlyOnFirstChunk: true,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||
replyToMessageId,
|
||||
replyQuoteText,
|
||||
thread: opts.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isTelegramThreadNotFoundError(err: unknown): boolean {
|
||||
if (err instanceof GrammyError) {
|
||||
return THREAD_NOT_FOUND_RE.test(err.description);
|
||||
}
|
||||
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function hasMessageThreadIdParam(params: Record<string, unknown> | undefined): boolean {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
return typeof params.message_thread_id === "number";
|
||||
}
|
||||
|
||||
function removeMessageThreadIdParam(
|
||||
params: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
const { message_thread_id: _ignored, ...rest } = params;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function sendTelegramWithThreadFallback<T>(params: {
|
||||
operation: string;
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
requestParams: Record<string, unknown>;
|
||||
send: (effectiveParams: Record<string, unknown>) => Promise<T>;
|
||||
shouldLog?: (err: unknown) => boolean;
|
||||
}): Promise<T> {
|
||||
const allowThreadlessRetry = params.thread?.scope === "dm";
|
||||
const hasThreadId = hasMessageThreadIdParam(params.requestParams);
|
||||
const shouldSuppressFirstErrorLog = (err: unknown) =>
|
||||
allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err);
|
||||
const mergedShouldLog = params.shouldLog
|
||||
? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err)
|
||||
: (err: unknown) => !shouldSuppressFirstErrorLog(err);
|
||||
|
||||
try {
|
||||
return await withTelegramApiErrorLogging({
|
||||
operation: params.operation,
|
||||
runtime: params.runtime,
|
||||
shouldLog: mergedShouldLog,
|
||||
fn: () => params.send(params.requestParams),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const retryParams = removeMessageThreadIdParam(params.requestParams);
|
||||
params.runtime.log?.(
|
||||
`telegram ${params.operation}: message thread not found; retrying without message_thread_id`,
|
||||
);
|
||||
return await withTelegramApiErrorLogging({
|
||||
operation: `${params.operation} (threadless retry)`,
|
||||
runtime: params.runtime,
|
||||
fn: () => params.send(retryParams),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildTelegramSendParams(opts?: {
|
||||
replyToMessageId?: number;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
}): Record<string, unknown> {
|
||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||
const params: Record<string, unknown> = {};
|
||||
if (opts?.replyToMessageId) {
|
||||
params.reply_to_message_id = opts.replyToMessageId;
|
||||
}
|
||||
if (threadParams) {
|
||||
params.message_thread_id = threadParams.message_thread_id;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
async function sendTelegramText(
|
||||
bot: Bot,
|
||||
chatId: string,
|
||||
text: string,
|
||||
runtime: RuntimeEnv,
|
||||
opts?: {
|
||||
replyToMessageId?: number;
|
||||
replyQuoteText?: string;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
linkPreview?: boolean;
|
||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
},
|
||||
): Promise<number> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
thread: opts?.thread,
|
||||
});
|
||||
// Add link_preview_options when link preview is disabled.
|
||||
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
|
||||
const fallbackText = opts?.plainText ?? text;
|
||||
const hasFallbackText = fallbackText.trim().length > 0;
|
||||
const sendPlainFallback = async () => {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: baseParams,
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, fallbackText, {
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`);
|
||||
return res.message_id;
|
||||
};
|
||||
|
||||
// Markdown can render to empty HTML for syntax-only chunks; recover with plain text.
|
||||
if (!htmlText.trim()) {
|
||||
if (!hasFallbackText) {
|
||||
throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback");
|
||||
}
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
try {
|
||||
const res = await sendTelegramWithThreadFallback({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
thread: opts?.thread,
|
||||
requestParams: baseParams,
|
||||
shouldLog: (err) => {
|
||||
const errText = formatErrorMessage(err);
|
||||
return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText);
|
||||
},
|
||||
send: (effectiveParams) =>
|
||||
bot.api.sendMessage(chatId, htmlText, {
|
||||
parse_mode: "HTML",
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...effectiveParams,
|
||||
}),
|
||||
});
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
return res.message_id;
|
||||
} catch (err) {
|
||||
const errText = formatErrorMessage(err);
|
||||
if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) {
|
||||
if (!hasFallbackText) {
|
||||
throw err;
|
||||
}
|
||||
runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
export { deliverReplies } from "./delivery.replies.js";
|
||||
export { resolveMedia } from "./delivery.resolve-media.js";
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { RetryConfig } from "../infra/retry.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { buildOutboundMediaLoadOptions } from "../media/load-options.js";
|
||||
import { isGifMedia } from "../media/mime.js";
|
||||
import { normalizePollInput, type PollInput } from "../polls.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
@@ -558,10 +559,13 @@ export async function sendMessageTelegram(
|
||||
};
|
||||
|
||||
if (mediaUrl) {
|
||||
const media = await loadWebMedia(mediaUrl, {
|
||||
maxBytes: opts.maxBytes,
|
||||
localRoots: opts.mediaLocalRoots,
|
||||
});
|
||||
const media = await loadWebMedia(
|
||||
mediaUrl,
|
||||
buildOutboundMediaLoadOptions({
|
||||
maxBytes: opts.maxBytes,
|
||||
mediaLocalRoots: opts.mediaLocalRoots,
|
||||
}),
|
||||
);
|
||||
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
||||
const isGif = isGifMedia({
|
||||
contentType: media.contentType,
|
||||
|
||||
63
ui/src/i18n/lib/registry.ts
Normal file
63
ui/src/i18n/lib/registry.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Locale, TranslationMap } from "./types.ts";
|
||||
|
||||
type LazyLocale = Exclude<Locale, "en">;
|
||||
type LocaleModule = Record<string, TranslationMap>;
|
||||
|
||||
type LazyLocaleRegistration = {
|
||||
exportName: string;
|
||||
loader: () => Promise<LocaleModule>;
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
|
||||
const LAZY_LOCALES: readonly LazyLocale[] = ["zh-CN", "zh-TW", "pt-BR", "de"];
|
||||
|
||||
const LAZY_LOCALE_REGISTRY: Record<LazyLocale, LazyLocaleRegistration> = {
|
||||
"zh-CN": {
|
||||
exportName: "zh_CN",
|
||||
loader: () => import("../locales/zh-CN.ts"),
|
||||
},
|
||||
"zh-TW": {
|
||||
exportName: "zh_TW",
|
||||
loader: () => import("../locales/zh-TW.ts"),
|
||||
},
|
||||
"pt-BR": {
|
||||
exportName: "pt_BR",
|
||||
loader: () => import("../locales/pt-BR.ts"),
|
||||
},
|
||||
de: {
|
||||
exportName: "de",
|
||||
loader: () => import("../locales/de.ts"),
|
||||
},
|
||||
};
|
||||
|
||||
export const SUPPORTED_LOCALES: ReadonlyArray<Locale> = [DEFAULT_LOCALE, ...LAZY_LOCALES];
|
||||
|
||||
export function isSupportedLocale(value: string | null | undefined): value is Locale {
|
||||
return value !== null && value !== undefined && SUPPORTED_LOCALES.includes(value as Locale);
|
||||
}
|
||||
|
||||
export function resolveNavigatorLocale(navLang: string): Locale {
|
||||
if (navLang.startsWith("zh")) {
|
||||
return navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN";
|
||||
}
|
||||
if (navLang.startsWith("pt")) {
|
||||
return "pt-BR";
|
||||
}
|
||||
if (navLang.startsWith("de")) {
|
||||
return "de";
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export async function loadLazyLocaleTranslation(locale: Locale): Promise<TranslationMap | null> {
|
||||
if (locale === DEFAULT_LOCALE) {
|
||||
return null;
|
||||
}
|
||||
const registration = LAZY_LOCALE_REGISTRY[locale];
|
||||
if (!registration) {
|
||||
return null;
|
||||
}
|
||||
const module = await registration.loader();
|
||||
return module[registration.exportName] ?? null;
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { en } from "../locales/en.ts";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
isSupportedLocale,
|
||||
loadLazyLocaleTranslation,
|
||||
resolveNavigatorLocale,
|
||||
} from "./registry.ts";
|
||||
import type { Locale, TranslationMap } from "./types.ts";
|
||||
|
||||
type Subscriber = (locale: Locale) => void;
|
||||
|
||||
export const SUPPORTED_LOCALES: ReadonlyArray<Locale> = ["en", "zh-CN", "zh-TW", "pt-BR", "de"];
|
||||
|
||||
export function isSupportedLocale(value: string | null | undefined): value is Locale {
|
||||
return value !== null && value !== undefined && SUPPORTED_LOCALES.includes(value as Locale);
|
||||
}
|
||||
export { SUPPORTED_LOCALES, isSupportedLocale };
|
||||
|
||||
class I18nManager {
|
||||
private locale: Locale = "en";
|
||||
private translations: Record<Locale, TranslationMap> = { en } as Record<Locale, TranslationMap>;
|
||||
private locale: Locale = DEFAULT_LOCALE;
|
||||
private translations: Partial<Record<Locale, TranslationMap>> = { [DEFAULT_LOCALE]: en };
|
||||
private subscribers: Set<Subscriber> = new Set();
|
||||
|
||||
constructor() {
|
||||
@@ -23,23 +26,13 @@ class I18nManager {
|
||||
if (isSupportedLocale(saved)) {
|
||||
return saved;
|
||||
}
|
||||
const navLang = navigator.language;
|
||||
if (navLang.startsWith("zh")) {
|
||||
return navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN";
|
||||
}
|
||||
if (navLang.startsWith("pt")) {
|
||||
return "pt-BR";
|
||||
}
|
||||
if (navLang.startsWith("de")) {
|
||||
return "de";
|
||||
}
|
||||
return "en";
|
||||
return resolveNavigatorLocale(navigator.language);
|
||||
}
|
||||
|
||||
private loadLocale() {
|
||||
const initialLocale = this.resolveInitialLocale();
|
||||
if (initialLocale === "en") {
|
||||
this.locale = "en";
|
||||
if (initialLocale === DEFAULT_LOCALE) {
|
||||
this.locale = DEFAULT_LOCALE;
|
||||
return;
|
||||
}
|
||||
// Use the normal locale setter so startup locale loading follows the same
|
||||
@@ -52,27 +45,18 @@ class I18nManager {
|
||||
}
|
||||
|
||||
public async setLocale(locale: Locale) {
|
||||
const needsTranslationLoad = !this.translations[locale];
|
||||
const needsTranslationLoad = locale !== DEFAULT_LOCALE && !this.translations[locale];
|
||||
if (this.locale === locale && !needsTranslationLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy load translations if needed
|
||||
if (needsTranslationLoad) {
|
||||
try {
|
||||
let module: Record<string, TranslationMap>;
|
||||
if (locale === "zh-CN") {
|
||||
module = await import("../locales/zh-CN.ts");
|
||||
} else if (locale === "zh-TW") {
|
||||
module = await import("../locales/zh-TW.ts");
|
||||
} else if (locale === "pt-BR") {
|
||||
module = await import("../locales/pt-BR.ts");
|
||||
} else if (locale === "de") {
|
||||
module = await import("../locales/de.ts");
|
||||
} else {
|
||||
const translation = await loadLazyLocaleTranslation(locale);
|
||||
if (!translation) {
|
||||
return;
|
||||
}
|
||||
this.translations[locale] = module[locale.replace("-", "_")];
|
||||
this.translations[locale] = translation;
|
||||
} catch (e) {
|
||||
console.error(`Failed to load locale: ${locale}`, e);
|
||||
return;
|
||||
@@ -99,7 +83,7 @@ class I18nManager {
|
||||
|
||||
public t(key: string, params?: Record<string, string>): string {
|
||||
const keys = key.split(".");
|
||||
let value: unknown = this.translations[this.locale] || this.translations["en"];
|
||||
let value: unknown = this.translations[this.locale] || this.translations[DEFAULT_LOCALE];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === "object") {
|
||||
@@ -110,9 +94,9 @@ class I18nManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to English
|
||||
if (value === undefined && this.locale !== "en") {
|
||||
value = this.translations["en"];
|
||||
// Fallback to English.
|
||||
if (value === undefined && this.locale !== DEFAULT_LOCALE) {
|
||||
value = this.translations[DEFAULT_LOCALE];
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === "object") {
|
||||
value = (value as Record<string, unknown>)[k];
|
||||
|
||||
Reference in New Issue
Block a user