Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions integrations/telegram-bridge/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
helpText,
isAllowed,
isGroupChat,
isTelegramMarkdownParseError,
latestRunningTurn,
looksLikePollingConflict,
pairingRefusalText,
Expand All @@ -21,6 +22,7 @@ import {
stripGroupPrefix,
threadListKeyboard,
telegramIdentity,
telegramMessageBody,
telegramPollingConflictDelayMs,
telegramRetryDelayMs,
telegramSendRetryDelayMs
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
210 changes: 210 additions & 0 deletions integrations/telegram-bridge/src/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions integrations/telegram-bridge/test/dispatch-concurrency.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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\);/);
});
41 changes: 41 additions & 0 deletions integrations/telegram-bridge/test/lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ import {
stripGroupPrefix,
threadListKeyboard,
telegramIdentity,
telegramMarkdownV2,
telegramMessageBody,
plainTelegramText,
telegramPollingConflictDelayMs,
telegramRetryDelayMs,
telegramSendRetryDelayMs,
isTelegramMarkdownParseError,
looksLikePollingConflict,
validateBridgeConfig
} from "../src/lib.mjs";
Expand Down Expand Up @@ -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);
});
Expand Down
Loading