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..c4ecc02e 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 { @@ -1812,6 +1824,7 @@ export type { TelegramLongPollingConfig, TelegramMessage, TelegramMessageReactionUpdated, + TelegramParseMode, TelegramRawMessage, TelegramThreadId, TelegramUpdate, 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