refactor: split telegram delivery and unify media/frontmatter/i18n pipelines

This commit is contained in:
Peter Steinberger
2026-03-02 04:12:23 +00:00
parent 706cfcd54f
commit e1f3ded033
15 changed files with 1239 additions and 925 deletions

View File

@@ -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!;

View File

@@ -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
View 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();
});
});

View File

@@ -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({});

View File

@@ -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;
}

View 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
View 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 } : {}),
};
}

View File

@@ -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,

View 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 };
}

View 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 };
}

View 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;
}
}

View File

@@ -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";

View File

@@ -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,

View 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;
}

View File

@@ -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];