diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 9cb5df2b846..cafc8190d58 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -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!; diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 94508c8131a..8847baa18b1 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -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; diff --git a/src/i18n/registry.test.ts b/src/i18n/registry.test.ts new file mode 100644 index 00000000000..afa868a1171 --- /dev/null +++ b/src/i18n/registry.test.ts @@ -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(); + }); +}); diff --git a/src/markdown/frontmatter.test.ts b/src/markdown/frontmatter.test.ts index dfc822c86b9..7eb51e6bee0 100644 --- a/src/markdown/frontmatter.test.ts +++ b/src/markdown/frontmatter.test.ts @@ -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({}); diff --git a/src/markdown/frontmatter.ts b/src/markdown/frontmatter.ts index 44f497524b8..845c9cb6203 100644 --- a/src/markdown/frontmatter.ts +++ b/src/markdown/frontmatter.ts @@ -2,6 +2,17 @@ import YAML from "yaml"; export type ParsedFrontmatter = Record; +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 | 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 = {}; for (const [rawKey, value] of Object.entries(parsed as Record)) { 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 { + const result: Record = {}; 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, +): 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; } diff --git a/src/media/load-options.test.ts b/src/media/load-options.test.ts new file mode 100644 index 00000000000..52e61e59cc7 --- /dev/null +++ b/src/media/load-options.test.ts @@ -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"], + }); + }); +}); diff --git a/src/media/load-options.ts b/src/media/load-options.ts new file mode 100644 index 00000000000..69400e98ffb --- /dev/null +++ b/src/media/load-options.ts @@ -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 } : {}), + }; +} diff --git a/src/media/outbound-attachment.ts b/src/media/outbound-attachment.ts index 59ab560931b..155d234457b 100644 --- a/src/media/outbound-attachment.ts +++ b/src/media/outbound-attachment.ts @@ -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, diff --git a/src/telegram/bot/delivery.replies.ts b/src/telegram/bot/delivery.replies.ts new file mode 100644 index 00000000000..209b9bfb610 --- /dev/null +++ b/src/telegram/bot/delivery.replies.ts @@ -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; + +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 = []; + 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; + replyQuoteText?: string; + linkPreview?: boolean; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + 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; + linkPreview?: boolean; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + 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; + replyToId?: number; + thread?: TelegramThreadSpec | null; + linkPreview?: boolean; + replyMarkup?: ReturnType; + replyQuoteText?: string; +}): Promise { + 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; + linkPreview?: boolean; + replyQuoteText?: string; + replyMarkup?: ReturnType; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + 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 = { + 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; + /** 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 }; +} diff --git a/src/telegram/bot/delivery.resolve-media.ts b/src/telegram/bot/delivery.resolve-media.ts new file mode 100644 index 00000000000..81cfabbdcf4 --- /dev/null +++ b/src/telegram/bot/delivery.resolve-media.ts @@ -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: "", + 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: "", + 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. ) 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) ?? ""; + return { path: saved.path, contentType: saved.contentType, placeholder }; +} diff --git a/src/telegram/bot/delivery.send.ts b/src/telegram/bot/delivery.send.ts new file mode 100644 index 00000000000..45e81fc36d5 --- /dev/null +++ b/src/telegram/bot/delivery.send.ts @@ -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 | undefined): boolean { + if (!params) { + return false; + } + return typeof params.message_thread_id === "number"; +} + +function removeMessageThreadIdParam( + params: Record | undefined, +): Record { + if (!params) { + return {}; + } + const { message_thread_id: _ignored, ...rest } = params; + return rest; +} + +export async function sendTelegramWithThreadFallback(params: { + operation: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + requestParams: Record; + send: (effectiveParams: Record) => Promise; + shouldLog?: (err: unknown) => boolean; +}): Promise { + 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 { + const threadParams = buildTelegramThreadParams(opts?.thread); + const params: Record = {}; + 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; + }, +): Promise { + 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; + } +} diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index a0624065d0e..bbe599f46b0 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -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; - -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 = []; - 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; - replyQuoteText?: string; - linkPreview?: boolean; - replyToId?: number; - replyToMode: ReplyToMode; - progress: DeliveryProgress; -}): Promise { - 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; - linkPreview?: boolean; - replyToId?: number; - replyToMode: ReplyToMode; - progress: DeliveryProgress; -}): Promise { - 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; - linkPreview?: boolean; - replyQuoteText?: string; - replyMarkup?: ReturnType; - replyToId?: number; - replyToMode: ReplyToMode; - progress: DeliveryProgress; -}): Promise { - 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 = { - 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; - /** 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: "", - 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: "", - 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. ) 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) ?? ""; - 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; - replyToId?: number; - thread?: TelegramThreadSpec | null; - linkPreview?: boolean; - replyMarkup?: ReturnType; - replyQuoteText?: string; -}): Promise { - 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 | undefined): boolean { - if (!params) { - return false; - } - return typeof params.message_thread_id === "number"; -} - -function removeMessageThreadIdParam( - params: Record | undefined, -): Record { - if (!params) { - return {}; - } - const { message_thread_id: _ignored, ...rest } = params; - return rest; -} - -async function sendTelegramWithThreadFallback(params: { - operation: string; - runtime: RuntimeEnv; - thread?: TelegramThreadSpec | null; - requestParams: Record; - send: (effectiveParams: Record) => Promise; - shouldLog?: (err: unknown) => boolean; -}): Promise { - 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 { - const threadParams = buildTelegramThreadParams(opts?.thread); - const params: Record = {}; - 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; - }, -): Promise { - 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"; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index ceaa9113e32..ae0d5b52513 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -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, diff --git a/ui/src/i18n/lib/registry.ts b/ui/src/i18n/lib/registry.ts new file mode 100644 index 00000000000..e05d5942692 --- /dev/null +++ b/ui/src/i18n/lib/registry.ts @@ -0,0 +1,63 @@ +import type { Locale, TranslationMap } from "./types.ts"; + +type LazyLocale = Exclude; +type LocaleModule = Record; + +type LazyLocaleRegistration = { + exportName: string; + loader: () => Promise; +}; + +export const DEFAULT_LOCALE: Locale = "en"; + +const LAZY_LOCALES: readonly LazyLocale[] = ["zh-CN", "zh-TW", "pt-BR", "de"]; + +const LAZY_LOCALE_REGISTRY: Record = { + "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 = [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 { + 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; +} diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts index ef39ce54796..2f1a2da783a 100644 --- a/ui/src/i18n/lib/translate.ts +++ b/ui/src/i18n/lib/translate.ts @@ -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 = ["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 = { en } as Record; + private locale: Locale = DEFAULT_LOCALE; + private translations: Partial> = { [DEFAULT_LOCALE]: en }; private subscribers: Set = 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; - 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 { 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)[k];