Repository navigation
fix(telegram): escape dollar signs and keep line breaks in rich messages #1113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@chat-adapter/telegram": patch | ||
| --- | ||
|
|
||
| Rich messages now escape `$` outside code and send single line breaks as hard breaks. Telegram's rich-message parser reads `$...$` as inline math, so text such as `~$950 (-3.3% from $983.23)` rendered as a formula, and it joined lines separated by a single newline into one paragraph. Both `{ markdown }` and `{ ast }` posts, edits, and streamed rich messages now keep dollar amounts and line-per-item text as written. Inline code, code blocks, link URLs, and autolinks are unchanged. Markdown that relied on `$...$` to render a formula now shows the dollar signs as text. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -309,7 +309,7 @@ If Telegram rate limits the final edit, the adapter waits and retries when the r | |
|
|
||
| ## Markdown formatting | ||
|
|
||
| On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages, including native headings, lists, tables, task lists, formulas, details, and separate media blocks supported by the Bot API. | ||
| On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages, including native headings, lists, tables, task lists, details, and separate media blocks supported by the Bot API. The adapter escapes `$` outside code so Telegram doesn't read it as a formula delimiter, and turns single line breaks into hard breaks so each line stays on its own line, as it does in the MarkdownV2 path. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same note as the docs page: formulas still work via |
||
|
|
||
| Plain strings, raw messages, cards, and media captions retain their existing lightweight message paths. Cards and captions use Telegram's `MarkdownV2` parse mode with context-aware escaping. If an older or custom Bot API server does not support rich message methods, the adapter automatically falls back to the existing MarkdownV2 path. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,6 +57,7 @@ import { | |
| truncateForTelegram, | ||
| } from "./markdown"; | ||
| import { | ||
| escapeRichMarkdown, | ||
| fitsRichMessage, | ||
| richMessageMedia, | ||
| richMessageToMarkdown, | ||
|
|
@@ -2470,7 +2471,7 @@ export class TelegramAdapter | |
|
|
||
| let draftText: string; | ||
| if (streamUsesRich) { | ||
| draftText = truncateRichMarkdown(renderer.render()); | ||
| draftText = truncateRichMarkdown(escapeRichMarkdown(renderer.render())); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removing |
||
| } else if (streamUsesMarkdown) { | ||
| draftText = renderMarkdownText(renderer.render()); | ||
| } else { | ||
|
|
@@ -2510,7 +2511,7 @@ export class TelegramAdapter | |
| await flushDraft(); | ||
|
|
||
| if (streamUsesRich) { | ||
| const markdown = truncateRichMarkdown(finalMarkdown); | ||
| const markdown = truncateRichMarkdown(escapeRichMarkdown(finalMarkdown)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here: removing the escape on the final streamed |
||
| try { | ||
| const raw = await this.telegramFetch<TelegramMessage>( | ||
| "sendRichMessage", | ||
|
|
@@ -3958,14 +3959,19 @@ export class TelegramAdapter | |
|
|
||
| /** | ||
| * Render the untruncated rich-message markdown for a `{ markdown }` or | ||
| * `{ ast }` message, with emoji placeholders resolved. | ||
| * `{ ast }` message, with emoji placeholders resolved and text escaped for | ||
| * Telegram's rich-message parser. | ||
| */ | ||
| protected renderRichMarkdown( | ||
| message: { markdown: string } | { ast: Root } | ||
| ): string { | ||
| return convertEmojiPlaceholders( | ||
| "markdown" in message ? message.markdown : stringifyMarkdown(message.ast), | ||
| "gchat" | ||
| return escapeRichMarkdown( | ||
| convertEmojiPlaceholders( | ||
| "markdown" in message | ||
| ? message.markdown | ||
| : stringifyMarkdown(message.ast), | ||
| "gchat" | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import type { Attachment } from "chat"; | ||
| import { StreamingMarkdownRenderer } from "chat"; | ||
| import type { Attachment, Nodes } from "chat"; | ||
| import { parseMarkdown, StreamingMarkdownRenderer } from "chat"; | ||
| import type { | ||
| TelegramFile, | ||
| TelegramRichBlock, | ||
|
|
@@ -14,6 +14,7 @@ export const TELEGRAM_RICH_MESSAGE_LIMIT = 32_768; | |
| const MARKDOWN_PUNCTUATION = /[!-/:-@[-`{-~]/g; | ||
| const LINE_BREAKS = /[\r\n]/g; | ||
| const BACKTICKS = /`+/g; | ||
| const RICH_TEXT_SPECIALS = /\\[\s\S]|\$|\r?\n/g; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: CRLF handling is untested (changing |
||
|
|
||
| /** | ||
| * Whether `markdown` fits in one rich message, which Telegram measures in | ||
|
|
@@ -27,6 +28,61 @@ export function fitsRichMessage(markdown: string): boolean { | |
| return Array.from(markdown).length <= TELEGRAM_RICH_MESSAGE_LIMIT; | ||
| } | ||
|
|
||
| function collectTextRanges( | ||
| node: Nodes, | ||
| markdown: string, | ||
| ranges: [number, number][] | ||
| ): void { | ||
| const start = node.position?.start.offset; | ||
| const end = node.position?.end.offset; | ||
| if (node.type === "text") { | ||
| if (start !== undefined && end !== undefined) { | ||
| ranges.push([start, end]); | ||
| } | ||
| return; | ||
| } | ||
| // Autolinks (`<https://...>`, bare URLs and emails) keep their text as is. | ||
| if (node.type === "link" && markdown[start ?? 0] !== "[") { | ||
| return; | ||
| } | ||
| if ("children" in node) { | ||
| for (const child of node.children) { | ||
| collectTextRanges(child, markdown, ranges); | ||
|
Comment on lines
+48
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| function escapeRichText(source: string): string { | ||
| return source.replace(RICH_TEXT_SPECIALS, (match) => { | ||
| if (match === "$") { | ||
| return "\\$"; | ||
| } | ||
| if (match.endsWith("\n")) { | ||
| return `\\${match}`; | ||
| } | ||
| return match; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Adapt CommonMark to Telegram's rich-message parser. Telegram reads `$...$` | ||
| * as inline math and joins soft line breaks, so text outside code escapes | ||
| * `$` and ends each soft-wrapped line with a backslash hard break. | ||
| */ | ||
| export function escapeRichMarkdown(markdown: string): string { | ||
| const ranges: [number, number][] = []; | ||
| collectTextRanges(parseMarkdown(markdown), markdown, ranges); | ||
|
|
||
| let result = ""; | ||
| let cursor = 0; | ||
| for (const [start, end] of ranges) { | ||
| result += markdown.slice(cursor, start); | ||
| result += escapeRichText(markdown.slice(start, end)); | ||
| cursor = end; | ||
| } | ||
| return result + markdown.slice(cursor); | ||
| } | ||
|
|
||
| export function truncateRichMarkdown(markdown: string): string { | ||
| if (fitsRichMessage(markdown)) { | ||
| return markdown; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: formulas aren't actually gone. Fenced
```mathblocks are code nodes, so they pass through unescaped, and the Bot API renders them as formulas (as does inline<tg-math>). Consider keeping formulas in the list and saying they are written with a```mathblock rather than$...$. Same for the README.