Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions .changeset/telegram-parse-mode-config.md
Original file line number Diff line number Diff line change
@@ -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.
84 changes: 84 additions & 0 deletions packages/adapter-telegram/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 14 additions & 2 deletions packages/adapter-telegram/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import type {
TelegramInlineKeyboardMarkup,
TelegramLongPollingConfig,
TelegramMessage,
TelegramParseMode,
TelegramMessageEntity,
TelegramMessageReactionUpdated,
TelegramRawMessage,
Expand All @@ -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] === "/") {
Expand Down Expand Up @@ -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<void> | null = null;
Expand Down Expand Up @@ -254,13 +255,23 @@ 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(
"telegram",
`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<void> {
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions packages/adapter-telegram/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand Down