From 69d1c91ef925983b7837ac42d21d6a7dd77b83f5 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Sun, 28 Jun 2026 13:30:12 +0800 Subject: [PATCH 1/2] fix(telegram): render markdown messages safely --- integrations/telegram-bridge/src/index.mjs | 19 +- integrations/telegram-bridge/src/lib.mjs | 210 ++++++++++++++++++ .../test/dispatch-concurrency.test.mjs | 10 + .../telegram-bridge/test/lib.test.mjs | 41 ++++ 4 files changed, 278 insertions(+), 2 deletions(-) diff --git a/integrations/telegram-bridge/src/index.mjs b/integrations/telegram-bridge/src/index.mjs index 50aeda9273..504a8f3e42 100644 --- a/integrations/telegram-bridge/src/index.mjs +++ b/integrations/telegram-bridge/src/index.mjs @@ -10,6 +10,7 @@ import { helpText, isAllowed, isGroupChat, + isTelegramMarkdownParseError, latestRunningTurn, looksLikePollingConflict, pairingRefusalText, @@ -21,6 +22,7 @@ import { stripGroupPrefix, threadListKeyboard, telegramIdentity, + telegramMessageBody, telegramPollingConflictDelayMs, telegramRetryDelayMs, telegramSendRetryDelayMs @@ -863,13 +865,26 @@ async function sendText(chatId, text, options = {}) { for (const [index, chunk] of chunks.entries()) { const body = { chat_id: chatId, - text: chunk, + ...telegramMessageBody(chunk, { markdown: true, maxChars: config.maxReplyChars }), disable_web_page_preview: true }; if (options.replyMarkup && index === chunks.length - 1) { body.reply_markup = options.replyMarkup; } - await telegramApi("sendMessage", body); + try { + await telegramApi("sendMessage", body); + } catch (error) { + if (!isTelegramMarkdownParseError(error)) throw error; + const fallbackBody = { + chat_id: chatId, + ...telegramMessageBody(chunk, { markdown: false, maxChars: config.maxReplyChars }), + disable_web_page_preview: true + }; + if (options.replyMarkup && index === chunks.length - 1) { + fallbackBody.reply_markup = options.replyMarkup; + } + await telegramApi("sendMessage", fallbackBody); + } } } diff --git a/integrations/telegram-bridge/src/lib.mjs b/integrations/telegram-bridge/src/lib.mjs index f280ba7b02..4dfc55f7c0 100644 --- a/integrations/telegram-bridge/src/lib.mjs +++ b/integrations/telegram-bridge/src/lib.mjs @@ -171,6 +171,216 @@ export function callbackAction(data) { return null; } +const MARKDOWN_V2_SPECIALS = /([_*\[\]()~`>#+\-=|{}.!])/g; +const BLOCK_PLACEHOLDER_PREFIX = "\u0000mdv2:block:"; +const INLINE_PLACEHOLDER_PREFIX = "\u0000mdv2:inline:"; +const PLACEHOLDER_SUFFIX = "\u0000"; + +export function telegramMessageBody(text, options = {}) { + const maxChars = Math.floor(Number(options.maxChars) || 0); + if (options.markdown === false) { + return { text: boundedPlainTelegramText(text, maxChars) }; + } + const markdownText = telegramMarkdownV2(text); + if (maxChars > 0 && markdownText.length > maxChars) { + return { text: boundedPlainTelegramText(text, maxChars) }; + } + return { + text: markdownText, + parse_mode: "MarkdownV2" + }; +} + +export function telegramMarkdownV2(text) { + const placeholders = []; + const source = String(text || ""); + const fenced = source.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_match, language, body) => + markdownPlaceholder( + placeholders, + `\`\`\`${safeFenceLanguage(language)}\n${escapeMarkdownV2Code(removeClosingFenceNewline(body))}\n\`\`\``, + BLOCK_PLACEHOLDER_PREFIX + ) + ); + return restoreMarkdownPlaceholders(renderMarkdownLines(fenced), placeholders, BLOCK_PLACEHOLDER_PREFIX); +} + +export function plainTelegramText(text) { + const source = String(text || ""); + return renderPlainLines( + source + .replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1") + .replace(/`([^`\n]+)`/g, "$1") + .replace(/\[([^\]\n]+)\]\(([^ \n]+)\)/g, "$1 ($2)") + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace(/__([^_\n]+)__/g, "$1") + .replace(/[*_~]/g, "") + ); +} + +function boundedPlainTelegramText(text, maxChars) { + const plain = plainTelegramText(text); + if (maxChars > 0 && plain.length > maxChars) { + return String(text || ""); + } + return plain; +} + +export function isTelegramMarkdownParseError(error) { + if (Number(error?.errorCode) !== 400) return false; + const text = String(error?.description || error?.message || "").toLowerCase(); + return ( + text.includes("parse") || + text.includes("can't parse entities") || + text.includes("entity") || + text.includes("markdown") + ); +} + +function renderMarkdownLines(text) { + const lines = String(text || "").split("\n"); + const output = []; + for (let index = 0; index < lines.length; index += 1) { + if (isMarkdownTable(lines, index)) { + const { rendered, nextIndex } = renderMarkdownTable(lines, index); + output.push(rendered); + index = nextIndex - 1; + } else { + output.push(renderMarkdownInline(lines[index])); + } + } + return output.join("\n"); +} + +function renderPlainLines(text) { + const lines = String(text || "").split("\n"); + const output = []; + for (let index = 0; index < lines.length; index += 1) { + if (isMarkdownTable(lines, index)) { + const { rendered, nextIndex } = renderPlainTable(lines, index); + output.push(rendered); + index = nextIndex - 1; + } else { + output.push(lines[index]); + } + } + return output.join("\n"); +} + +function renderMarkdownInline(text) { + const placeholders = []; + let value = String(text || ""); + value = value.replace(/\[([^\]\n]+)\]\(([^ \n]+)\)/g, (_match, label, url) => + markdownPlaceholder( + placeholders, + `[${escapeMarkdownV2Text(label)}](${escapeMarkdownV2Url(url)})`, + INLINE_PLACEHOLDER_PREFIX + ) + ); + value = value.replace(/`([^`\n]+)`/g, (_match, code) => + markdownPlaceholder(placeholders, `\`${escapeMarkdownV2Code(code)}\``, INLINE_PLACEHOLDER_PREFIX) + ); + value = value.replace(/\*\*([^*\n]+)\*\*/g, (_match, body) => + markdownPlaceholder(placeholders, `*${escapeMarkdownV2Text(body)}*`, INLINE_PLACEHOLDER_PREFIX) + ); + value = escapeMarkdownV2Text(value); + return restoreMarkdownPlaceholders(value, placeholders, INLINE_PLACEHOLDER_PREFIX); +} + +function renderMarkdownTable(lines, startIndex) { + const headers = tableCells(lines[startIndex]); + const rows = []; + let index = startIndex + 2; + while (index < lines.length && looksLikeTableRow(lines[index])) { + rows.push(tableCells(lines[index])); + index += 1; + } + const headerText = headers.map(escapeMarkdownV2Text).join(" / "); + const rendered = [`*${headerText}*`]; + for (const row of rows) { + const fields = headers.map((header, cellIndex) => { + const value = row[cellIndex] || ""; + return `${escapeMarkdownV2Text(header)}: ${escapeMarkdownV2Text(value)}`; + }); + rendered.push(`• ${fields.join("; ")}`); + } + return { rendered: rendered.join("\n"), nextIndex: index }; +} + +function renderPlainTable(lines, startIndex) { + const headers = tableCells(lines[startIndex]); + const rows = []; + let index = startIndex + 2; + while (index < lines.length && looksLikeTableRow(lines[index])) { + rows.push(tableCells(lines[index])); + index += 1; + } + const rendered = [headers.join(" / ")]; + for (const row of rows) { + const fields = headers.map((header, cellIndex) => `${header}: ${row[cellIndex] || ""}`); + rendered.push(`- ${fields.join("; ")}`); + } + return { rendered: rendered.join("\n"), nextIndex: index }; +} + +function isMarkdownTable(lines, index) { + return ( + looksLikeTableRow(lines[index]) && + index + 1 < lines.length && + looksLikeTableSeparator(lines[index + 1]) + ); +} + +function looksLikeTableRow(line) { + return tableCells(line).length >= 2; +} + +function looksLikeTableSeparator(line) { + const cells = tableCells(line); + return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} + +function tableCells(line) { + const trimmed = String(line || "").trim(); + if (!trimmed.includes("|")) return []; + return trimmed + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); +} + +function escapeMarkdownV2Text(text) { + return String(text || "").replace(MARKDOWN_V2_SPECIALS, "\\$1"); +} + +function escapeMarkdownV2Code(text) { + return String(text || "").replace(/([`\\])/g, "\\$1"); +} + +function escapeMarkdownV2Url(text) { + return String(text || "").replace(/([)\\])/g, "\\$1"); +} + +function safeFenceLanguage(language) { + return String(language || "").trim().replace(/[^\w+-]/g, ""); +} + +function removeClosingFenceNewline(text) { + return String(text || "").replace(/\n$/, ""); +} + +function markdownPlaceholder(placeholders, rendered, prefix) { + const index = placeholders.push(rendered) - 1; + return `${prefix}${index}${PLACEHOLDER_SUFFIX}`; +} + +function restoreMarkdownPlaceholders(text, placeholders, prefix) { + return String(text || "").replace( + new RegExp(`${prefix}(\\d+)${PLACEHOLDER_SUFFIX}`, "g"), + (_match, index) => placeholders[Number(index)] || "" + ); +} + export function telegramRetryDelayMs(error, fallbackMs = 3000) { const retryAfter = Number(error?.parameters?.retry_after || 0); if (Number.isFinite(retryAfter) && retryAfter > 0) { diff --git a/integrations/telegram-bridge/test/dispatch-concurrency.test.mjs b/integrations/telegram-bridge/test/dispatch-concurrency.test.mjs index 7d5e9ca2e9..8dcf390dd5 100644 --- a/integrations/telegram-bridge/test/dispatch-concurrency.test.mjs +++ b/integrations/telegram-bridge/test/dispatch-concurrency.test.mjs @@ -178,3 +178,13 @@ test("turn streams debounce last-seq writes and flush before exit", async () => assert.match(flushLastSeq, /await threadStore\.patchChat\(chatId, \{ lastSeq: latestSeq \}\);/); assert.match(flushLastSeq, /flushedSeq = latestSeq;/); }); + +test("Telegram sends MarkdownV2 with plain-text fallback on parse errors", async () => { + const source = await readBridgeSource(); + const sendText = extractFunction(source, "sendText"); + + assert.match(sendText, /telegramMessageBody\(chunk, \{ markdown: true, maxChars: config\.maxReplyChars \}\)/); + assert.match(sendText, /isTelegramMarkdownParseError\(error\)/); + assert.match(sendText, /telegramMessageBody\(chunk, \{ markdown: false, maxChars: config\.maxReplyChars \}\)/); + assert.match(sendText, /await telegramApi\("sendMessage", fallbackBody\);/); +}); diff --git a/integrations/telegram-bridge/test/lib.test.mjs b/integrations/telegram-bridge/test/lib.test.mjs index 90369fa798..191d002f5c 100644 --- a/integrations/telegram-bridge/test/lib.test.mjs +++ b/integrations/telegram-bridge/test/lib.test.mjs @@ -22,9 +22,13 @@ import { stripGroupPrefix, threadListKeyboard, telegramIdentity, + telegramMarkdownV2, + telegramMessageBody, + plainTelegramText, telegramPollingConflictDelayMs, telegramRetryDelayMs, telegramSendRetryDelayMs, + isTelegramMarkdownParseError, looksLikePollingConflict, validateBridgeConfig } from "../src/lib.mjs"; @@ -247,6 +251,43 @@ test("splitMessage chunks long text without splitting surrogate pairs", () => { assert.deepEqual(splitMessage("a🧪b", 2), ["a🧪", "b"]); }); +test("telegramMarkdownV2 escapes text while preserving useful markdown", () => { + assert.equal( + telegramMarkdownV2("**Build** passed for [CI](https://example.com/a_(b))."), + "*Build* passed for [CI](https://example.com/a_(b\\))\\." + ); + assert.equal(telegramMarkdownV2("Use `cargo test -p codewhale`."), "Use `cargo test -p codewhale`\\."); + assert.equal( + telegramMarkdownV2("```rust\nfn main() { println!(\"hi\"); }\n```"), + "```rust\nfn main() { println!(\"hi\"); }\n```" + ); +}); + +test("telegramMarkdownV2 rewrites pipe tables into phone-readable bullets", () => { + assert.equal( + telegramMarkdownV2("| Gate | Result |\n| --- | --- |\n| Lint | Pass |\n| Tests | Fail |"), + "*Gate / Result*\n• Gate: Lint; Result: Pass\n• Gate: Tests; Result: Fail" + ); +}); + +test("telegram message bodies can fall back from MarkdownV2 to plain text", () => { + assert.deepEqual(telegramMessageBody("**Done**", { markdown: true }), { + text: "*Done*", + parse_mode: "MarkdownV2" + }); + assert.deepEqual(telegramMessageBody("!!!!", { markdown: true, maxChars: 4 }), { + text: "!!!!" + }); + assert.deepEqual(telegramMessageBody("**Done**", { markdown: false }), { + text: "Done" + }); + assert.equal(plainTelegramText("[CI](https://example.com) **passed**"), "CI (https://example.com) passed"); + assert.equal( + isTelegramMarkdownParseError({ errorCode: 400, description: "Bad Request: can't parse entities" }), + true + ); +}); + test("telegramRetryDelayMs honors retry_after", () => { assert.equal(telegramRetryDelayMs({ parameters: { retry_after: 2 } }), 2000); }); From 58aabecf5d58a7f213fa332278122b25a6ce75d4 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Sun, 28 Jun 2026 14:05:37 +0800 Subject: [PATCH 2/2] chore: rerun ci for windows check Signed-off-by: cyq <15000851237@163.com>