From 7f970e5ea4ea9932e1e5d99241c93840bacd5555 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 12 Apr 2026 08:28:39 +0000 Subject: [PATCH 1/2] feat(telegram): expose parseMode config option Since #245, the Telegram adapter hardcodes parse_mode: "Markdown" whenever the caller passes { markdown: ... }. Telegram considers legacy Markdown a deprecated mode and it has no escape support, so callers can hit "Bad Request: can't parse entities" with no way to recover other than dropping the message. Add an optional parseMode field to TelegramAdapterConfig that lets callers choose between "Markdown" (default, backward compatible), "MarkdownV2", "HTML", or "none" (omit parse_mode entirely as a safety valve). The converter is unchanged; this PR only exposes a knob so callers whose upstream renderer produces MarkdownV2/HTML/plain-text can tell the adapter not to ask Telegram to parse legacy Markdown entities. --- .changeset/telegram-parse-mode-config.md | 7 ++ packages/adapter-telegram/src/index.test.ts | 84 +++++++++++++++++++++ packages/adapter-telegram/src/index.ts | 16 +++- packages/adapter-telegram/src/types.ts | 16 ++++ 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 .changeset/telegram-parse-mode-config.md diff --git a/.changeset/telegram-parse-mode-config.md b/.changeset/telegram-parse-mode-config.md new file mode 100644 index 00000000..e4ee6668 --- /dev/null +++ b/.changeset/telegram-parse-mode-config.md @@ -0,0 +1,7 @@ +--- +"@chat-adapter/telegram": minor +--- + +Add `parseMode` config option to `TelegramAdapterConfig`. Allows callers to opt +into `MarkdownV2`, `HTML`, or disable `parse_mode` entirely (`"none"`). Defaults +to `"Markdown"` for backward compatibility. diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index f96a773b..415ac333 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -976,6 +976,90 @@ describe("TelegramAdapter", () => { expect(sendMessageBody.parse_mode).toBe("Markdown"); }); + it("honors parseMode config option for markdown messages", async () => { + for (const mode of ["MarkdownV2", "HTML"] as const) { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(sampleMessage())); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + parseMode: mode, + }); + + await adapter.initialize(createMockChat()); + + await adapter.postMessage("telegram:123", { + markdown: "**bold**", + }); + + const sendMessageBody = JSON.parse( + String( + (mockFetch.mock.calls[mockFetch.mock.calls.length - 1]?.[1] as RequestInit).body + ) + ) as { parse_mode?: string }; + + expect(sendMessageBody.parse_mode).toBe(mode); + mockFetch.mockClear(); + } + }); + + it("omits parse_mode when parseMode is 'none'", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(sampleMessage())); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + parseMode: "none", + }); + + await adapter.initialize(createMockChat()); + + await adapter.postMessage("telegram:123", { + markdown: "**bold**", + }); + + const sendMessageBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { parse_mode?: string }; + + expect(sendMessageBody.parse_mode).toBeUndefined(); + }); + + it("rejects invalid parseMode values", () => { + expect(() => + createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + // @ts-expect-error testing runtime validation + parseMode: "Bogus", + }) + ).toThrow(/Invalid parseMode/); + }); + it("posts cards with inline keyboard buttons", async () => { mockFetch .mockResolvedValueOnce( diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 3993ac7b..5d4179cd 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -49,6 +49,7 @@ import type { TelegramInlineKeyboardMarkup, TelegramLongPollingConfig, TelegramMessage, + TelegramParseMode, TelegramMessageEntity, TelegramMessageReactionUpdated, TelegramRawMessage, @@ -64,7 +65,6 @@ const TELEGRAM_MESSAGE_LIMIT = 4096; const TELEGRAM_CAPTION_LIMIT = 1024; const TELEGRAM_SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token"; const MESSAGE_ID_PATTERN = /^([^:]+):(\d+)$/; -const TELEGRAM_MARKDOWN_PARSE_MODE = "Markdown"; const trimTrailingSlashes = (url: string): string => { let end = url.length; while (end > 0 && url[end - 1] === "/") { @@ -210,6 +210,7 @@ export class TelegramAdapter private readonly hasExplicitUserName: boolean; private readonly mode: TelegramAdapterMode; private readonly longPolling?: TelegramLongPollingConfig; + private readonly parseMode: TelegramParseMode; private _runtimeMode: TelegramRuntimeMode = "webhook"; private pollingAbortController: AbortController | null = null; private pollingTask: Promise | null = null; @@ -254,6 +255,7 @@ export class TelegramAdapter this.hasExplicitUserName = Boolean(userName); this.mode = config.mode ?? "auto"; this.longPolling = config.longPolling; + this.parseMode = config.parseMode ?? "Markdown"; if (!["auto", "webhook", "polling"].includes(this.mode)) { throw new ValidationError( @@ -261,6 +263,15 @@ export class TelegramAdapter `Invalid mode: ${this.mode}. Expected "auto", "webhook", or "polling".` ); } + + if ( + !["Markdown", "MarkdownV2", "HTML", "none"].includes(this.parseMode) + ) { + throw new ValidationError( + "telegram", + `Invalid parseMode: ${this.parseMode}. Expected "Markdown", "MarkdownV2", "HTML", or "none".` + ); + } } async initialize(chat: ChatInstance): Promise { @@ -1503,7 +1514,8 @@ export class TelegramAdapter ): string | undefined { const hasMarkdown = typeof message === "object" && message !== null && "markdown" in message; - return card || hasMarkdown ? TELEGRAM_MARKDOWN_PARSE_MODE : undefined; + if (!card && !hasMarkdown) return undefined; + return this.parseMode === "none" ? undefined : this.parseMode; } private truncateMessage(text: string): string { diff --git a/packages/adapter-telegram/src/types.ts b/packages/adapter-telegram/src/types.ts index 250c8aeb..c0ea5446 100644 --- a/packages/adapter-telegram/src/types.ts +++ b/packages/adapter-telegram/src/types.ts @@ -23,6 +23,20 @@ export interface TelegramAdapterConfig { * - polling: polling-only mode */ mode?: TelegramAdapterMode; + /** + * Telegram parse_mode used when sending messages with a `markdown` field + * or a card. See https://core.telegram.org/bots/api#formatting-options. + * - "Markdown" (default): legacy Markdown — kept for backward compatibility + * with prior behavior. Telegram considers this mode legacy and it has no + * escape support. + * - "MarkdownV2": current Telegram markdown with full escape support. + * - "HTML": HTML formatting. + * - "none": omit parse_mode entirely (send as plain text). Useful as a + * safety valve when the upstream markdown converter produces output + * Telegram cannot parse. + * @default "Markdown" + */ + parseMode?: TelegramParseMode; /** Optional webhook secret token checked against x-telegram-bot-api-secret-token. Defaults to TELEGRAM_WEBHOOK_SECRET_TOKEN env var. */ secretToken?: string; /** Override bot username (optional). Defaults to TELEGRAM_BOT_USERNAME env var. */ @@ -31,6 +45,8 @@ export interface TelegramAdapterConfig { export type TelegramAdapterMode = "auto" | "webhook" | "polling"; +export type TelegramParseMode = "Markdown" | "MarkdownV2" | "HTML" | "none"; + /** * Telegram long-polling configuration. * @see https://core.telegram.org/bots/api#getupdates From 048b9519257e6df6f91d333b0fa59848347e3999 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 12 Apr 2026 08:37:13 +0000 Subject: [PATCH 2/2] feat(telegram): re-export TelegramParseMode from package entry --- packages/adapter-telegram/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 5d4179cd..c4ecc02e 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -1824,6 +1824,7 @@ export type { TelegramLongPollingConfig, TelegramMessage, TelegramMessageReactionUpdated, + TelegramParseMode, TelegramRawMessage, TelegramThreadId, TelegramUpdate,