From b4fcd6114b60ec11856022b66a65e2d89c7653bf Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Sat, 28 Feb 2026 14:09:30 +0100 Subject: [PATCH 01/18] Add Telegram auto mode polling and init username fallback --- .changeset/soft-hats-kiss.md | 5 + apps/docs/content/docs/adapters/telegram.mdx | 56 ++ packages/adapter-telegram/README.md | 47 ++ packages/adapter-telegram/src/index.test.ts | 508 ++++++++++++++++++- packages/adapter-telegram/src/index.ts | 320 +++++++++++- packages/adapter-telegram/src/types.ts | 55 ++ 6 files changed, 984 insertions(+), 7 deletions(-) create mode 100644 .changeset/soft-hats-kiss.md diff --git a/.changeset/soft-hats-kiss.md b/.changeset/soft-hats-kiss.md new file mode 100644 index 00000000..bd1d03c8 --- /dev/null +++ b/.changeset/soft-hats-kiss.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/telegram": patch +--- + +Add Telegram polling modes (`auto`, `webhook`, `polling`) with safe auto fallback behavior, and fix initialization when the chat username is missing. diff --git a/apps/docs/content/docs/adapters/telegram.mdx b/apps/docs/content/docs/adapters/telegram.mdx index c2a4f632..365657bc 100644 --- a/apps/docs/content/docs/adapters/telegram.mdx +++ b/apps/docs/content/docs/adapters/telegram.mdx @@ -53,6 +53,57 @@ curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \ }' ``` +## Polling (no public webhook) + +Telegram also supports long polling via `getUpdates`. +Polling starts automatically when `polling` is provided. Use `polling: true` for defaults: + +```typescript title="lib/bot.ts" lineNumbers +import { Chat } from "chat"; +import { createTelegramAdapter } from "@chat-adapter/telegram"; +import { createMemoryState } from "@chat-adapter/state-memory"; + +const telegram = createTelegramAdapter({ + mode: "polling", + polling: { + timeout: 30, + dropPendingUpdates: false, + }, +}); + +const bot = new Chat({ + userName: "mybot", + adapters: { telegram }, + state: createMemoryState(), +}); + +// Optional manual lifecycle control: +// await telegram.startPolling(); +// await telegram.stopPolling(); +``` + +### Auto mode: local polling, production webhooks + +```typescript title="lib/bot.ts" lineNumbers +import { Chat } from "chat"; +import { createTelegramAdapter } from "@chat-adapter/telegram"; +import { createMemoryState } from "@chat-adapter/state-memory"; + +const telegram = createTelegramAdapter({ + mode: "auto", // default + polling: { timeout: 30 }, // only used when auto mode picks polling +}); + +export const bot = new Chat({ + userName: "mybot", + adapters: { telegram }, + state: createMemoryState(), +}); + +// Required for long-running local processes without incoming webhooks: +void bot.initialize(); +``` + ## Configuration All options are auto-detected from environment variables when not provided. @@ -61,6 +112,8 @@ All options are auto-detected from environment variables when not provided. |--------|----------|-------------| | `botToken` | No* | Telegram bot token. Auto-detected from `TELEGRAM_BOT_TOKEN` | | `secretToken` | No | Optional webhook secret token. Auto-detected from `TELEGRAM_WEBHOOK_SECRET_TOKEN` | +| `mode` | No | Adapter mode: `auto` (default), `webhook`, or `polling` | +| `polling` | No | Optional polling mode for `getUpdates` (`true` or config: `timeout`, `limit`, `allowedUpdates`, `deleteWebhook`, `dropPendingUpdates`, `retryDelayMs`) | | `userName` | No | Bot username used for mention detection. Auto-detected from `TELEGRAM_BOT_USERNAME` or `getMe` | | `apiBaseUrl` | No | Telegram API base URL. Auto-detected from `TELEGRAM_API_BASE_URL` | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | @@ -96,6 +149,9 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org - Telegram does not expose full historical message APIs to bots. `fetchMessages` / `fetchChannelMessages` return adapter-cached messages from the current process. - `listThreads` is not available for Telegram chats. +- Polling and webhooks are mutually exclusive in Telegram. +- `mode: "polling"` deletes webhook by default before calling `getUpdates`. +- `mode: "auto"` checks `getWebhookInfo`: if a webhook URL exists it uses webhook mode; otherwise it falls back to polling on non-serverless runtimes without deleting webhook. - `Button` and `LinkButton` in card `Actions` render as inline keyboard buttons. - Telegram callback data is limited to 64 bytes. Keep button `id`/`value` payloads short. - Other rich card elements (images/select menus/radios) render as fallback text only. diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index 8ac13f7a..6f9cc7ff 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -29,6 +29,53 @@ const bot = new Chat({ Features include mentions, reactions, typing indicators, file uploads, and card fallback rendering with inline keyboard buttons for card actions. +## Polling mode + +Use long polling (`getUpdates`) when you cannot expose a public webhook endpoint. +Polling starts automatically when `polling` is provided. Pass `polling: true` to use defaults. + +```typescript +import { createMemoryState } from "@chat-adapter/state-memory"; + +const telegram = createTelegramAdapter({ + botToken: process.env.TELEGRAM_BOT_TOKEN!, + mode: "polling", + polling: { + timeout: 30, + dropPendingUpdates: false, + }, +}); + +const bot = new Chat({ + userName: "mybot", + adapters: { telegram }, + state: createMemoryState(), +}); + +// Optional manual control +await telegram.startPolling(); +await telegram.stopPolling(); +``` + +### Auto mode (local polling + production webhooks) + +```typescript +const telegram = createTelegramAdapter({ + botToken: process.env.TELEGRAM_BOT_TOKEN!, + mode: "auto", // default + polling: { timeout: 30 }, // used only when auto mode selects polling +}); + +const bot = new Chat({ + userName: "mybot", + adapters: { telegram }, + state: createMemoryState(), +}); + +// Required for long-running local processes without incoming webhooks: +void bot.initialize(); +``` + ## Documentation Full setup instructions, configuration reference, and features at [chat-sdk.dev/docs/adapters/telegram](https://chat-sdk.dev/docs/adapters/telegram). diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 86a75cde..8c4696bf 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -61,11 +61,11 @@ function telegramError( ); } -function createMockChat(): ChatInstance { +function createMockChat(options?: { userName?: unknown }): ChatInstance { return { getLogger: vi.fn().mockReturnValue(mockLogger), getState: vi.fn(), - getUserName: vi.fn().mockReturnValue("mybot"), + getUserName: vi.fn().mockReturnValue(options?.userName ?? "mybot"), handleIncomingMessage: vi.fn().mockResolvedValue(undefined), processMessage: vi.fn(), processReaction: vi.fn(), @@ -99,6 +99,33 @@ function sampleMessage(overrides?: Partial): TelegramMessage { }; } +function createAbortError(): Error { + const fallback = new Error("Aborted"); + fallback.name = "AbortError"; + + if (typeof DOMException === "undefined") { + return fallback; + } + + return new DOMException("Aborted", "AbortError"); +} + +async function waitForCondition( + predicate: () => boolean, + timeoutMs = 300 +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + throw new Error("Timed out waiting for condition"); +} + describe("createTelegramAdapter", () => { it("throws when bot token is missing", () => { process.env.TELEGRAM_BOT_TOKEN = ""; @@ -121,6 +148,7 @@ describe("TelegramAdapter", () => { it("encodes and decodes thread IDs", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, }); @@ -155,6 +183,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -199,6 +228,7 @@ describe("TelegramAdapter", () => { it("rejects webhook requests with invalid secret token", async () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", secretToken: "expected-secret", logger: mockLogger, }); @@ -219,6 +249,7 @@ describe("TelegramAdapter", () => { it("returns 400 for invalid webhook JSON", async () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, }); @@ -232,6 +263,467 @@ describe("TelegramAdapter", () => { expect(response.status).toBe(400); }); + it("throws when polling starts before initialize", async () => { + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await expect(adapter.startPolling()).rejects.toBeInstanceOf(ValidationError); + }); + + it("starts polling, advances offset, and stops cleanly", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramOk([ + { + update_id: 10, + message: sampleMessage({ + message_id: 99, + text: "polled message", + }), + }, + ]) + ) + .mockImplementationOnce((_input, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(createAbortError()); + return; + } + signal?.addEventListener( + "abort", + () => { + reject(createAbortError()); + }, + { once: true } + ); + }); + }); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + const chat = createMockChat(); + + await adapter.initialize(chat); + await adapter.startPolling({ + limit: 1, + timeout: 1, + allowedUpdates: ["message"], + retryDelayMs: 0, + }); + + await waitForCondition( + () => (chat.processMessage as ReturnType).mock.calls.length > 0 + ); + await waitForCondition(() => mockFetch.mock.calls.length >= 4); + await adapter.stopPolling(); + + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/deleteWebhook"); + expect(String(mockFetch.mock.calls[2]?.[0])).toContain("/getUpdates"); + expect(String(mockFetch.mock.calls[3]?.[0])).toContain("/getUpdates"); + + const firstPollBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { + allowed_updates?: string[]; + limit?: number; + offset?: number; + timeout?: number; + }; + const secondPollBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + ) as { + offset?: number; + }; + + expect(firstPollBody.limit).toBe(1); + expect(firstPollBody.timeout).toBe(1); + expect(firstPollBody.allowed_updates).toEqual(["message"]); + expect(firstPollBody.offset).toBeUndefined(); + expect(secondPollBody.offset).toBe(11); + + const processMessage = chat.processMessage as ReturnType; + expect(processMessage).toHaveBeenCalledTimes(1); + expect(processMessage.mock.calls[0]?.[1]).toBe("telegram:123"); + expect(adapter.isPolling).toBe(false); + }); + + it("mode polling starts polling during initialize", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce(telegramOk([])) + .mockImplementationOnce((_input, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(createAbortError()); + return; + } + signal?.addEventListener( + "abort", + () => { + reject(createAbortError()); + }, + { once: true } + ); + }); + }); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "polling", + logger: mockLogger, + userName: "mybot", + polling: { + limit: 1, + timeout: 1, + }, + }); + + await adapter.initialize(createMockChat()); + await waitForCondition(() => mockFetch.mock.calls.length >= 4); + await adapter.stopPolling(); + + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/deleteWebhook"); + expect(String(mockFetch.mock.calls[2]?.[0])).toContain("/getUpdates"); + expect(String(mockFetch.mock.calls[3]?.[0])).toContain("/getUpdates"); + expect(adapter.isPolling).toBe(false); + }); + + it("auto mode starts polling when webhook URL is missing", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramOk({ + allowed_updates: [], + has_custom_certificate: false, + pending_update_count: 0, + url: "", + }) + ) + .mockResolvedValueOnce( + telegramOk([ + { + update_id: 42, + message: sampleMessage({ + message_id: 100, + text: "auto polling message", + }), + }, + ]) + ) + .mockImplementationOnce((_input, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(createAbortError()); + return; + } + signal?.addEventListener( + "abort", + () => { + reject(createAbortError()); + }, + { once: true } + ); + }); + }); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "auto", + logger: mockLogger, + userName: "mybot", + polling: { + limit: 1, + timeout: 1, + }, + }); + const chat = createMockChat(); + + await adapter.initialize(chat); + + await waitForCondition( + () => (chat.processMessage as ReturnType).mock.calls.length > 0 + ); + await waitForCondition(() => mockFetch.mock.calls.length >= 4); + await adapter.stopPolling(); + + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); + expect(String(mockFetch.mock.calls[2]?.[0])).toContain("/getUpdates"); + expect(String(mockFetch.mock.calls[3]?.[0])).toContain("/getUpdates"); + expect(adapter.isPolling).toBe(false); + }); + + it("auto mode with polling true uses default polling settings", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramOk({ + allowed_updates: [], + has_custom_certificate: false, + pending_update_count: 0, + url: "", + }) + ) + .mockResolvedValueOnce( + telegramOk([ + { + update_id: 43, + message: sampleMessage({ + message_id: 101, + text: "auto polling true message", + }), + }, + ]) + ) + .mockImplementationOnce((_input, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(createAbortError()); + return; + } + signal?.addEventListener( + "abort", + () => { + reject(createAbortError()); + }, + { once: true } + ); + }); + }); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "auto", + logger: mockLogger, + userName: "mybot", + polling: true, + }); + const chat = createMockChat(); + + await adapter.initialize(chat); + + await waitForCondition( + () => (chat.processMessage as ReturnType).mock.calls.length > 0 + ); + await waitForCondition(() => mockFetch.mock.calls.length >= 4); + await adapter.stopPolling(); + + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); + expect(String(mockFetch.mock.calls[2]?.[0])).toContain("/getUpdates"); + expect(String(mockFetch.mock.calls[3]?.[0])).toContain("/getUpdates"); + expect(adapter.isPolling).toBe(false); + }); + + it("defaults to auto mode and falls back to polling without polling config", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramOk({ + allowed_updates: [], + has_custom_certificate: false, + pending_update_count: 0, + url: "", + }) + ) + .mockResolvedValueOnce(telegramOk([])) + .mockImplementationOnce((_input, init) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(createAbortError()); + return; + } + signal?.addEventListener( + "abort", + () => { + reject(createAbortError()); + }, + { once: true } + ); + }); + }); + + const adapter = createTelegramAdapter({ + botToken: "token", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + await waitForCondition(() => mockFetch.mock.calls.length >= 4); + await adapter.stopPolling(); + + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); + expect(String(mockFetch.mock.calls[2]?.[0])).toContain("/getUpdates"); + expect(adapter.isPolling).toBe(false); + }); + + it("auto mode stays in webhook mode when webhook URL exists", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramOk({ + allowed_updates: [], + has_custom_certificate: false, + pending_update_count: 0, + url: "https://example.com/webhook/telegram", + }) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "auto", + logger: mockLogger, + userName: "mybot", + polling: true, + }); + + await adapter.initialize(createMockChat()); + + expect(mockFetch.mock.calls).toHaveLength(2); + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); + expect(adapter.isPolling).toBe(false); + }); + + it("auto mode stays in webhook mode on serverless runtime", async () => { + const previousVercel = process.env.VERCEL; + process.env.VERCEL = "1"; + + try { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramOk({ + allowed_updates: [], + has_custom_certificate: false, + pending_update_count: 0, + url: "", + }) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "auto", + logger: mockLogger, + userName: "mybot", + polling: true, + }); + + await adapter.initialize(createMockChat()); + + expect(mockFetch.mock.calls).toHaveLength(2); + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); + expect(adapter.isPolling).toBe(false); + } finally { + if (typeof previousVercel === "string") { + process.env.VERCEL = previousVercel; + } else { + delete process.env.VERCEL; + } + } + }); + + it("does not crash when chat.getUserName() is undefined", async () => { + mockFetch.mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "telegrambot", + }) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + }); + const chat = createMockChat({ userName: undefined }); + + await adapter.initialize(chat); + + const response = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + update_id: 99, + message: sampleMessage({ + text: "hello", + }), + }), + }) + ); + + expect(response.status).toBe(200); + expect((chat.processMessage as ReturnType).mock.calls).toHaveLength( + 1 + ); + }); + it("posts, edits, deletes, and sends typing events", async () => { mockFetch .mockResolvedValueOnce( @@ -256,6 +748,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -305,6 +798,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -372,6 +866,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -408,6 +903,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -476,6 +972,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -523,6 +1020,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -587,6 +1085,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -659,6 +1158,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -699,6 +1199,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -711,6 +1212,7 @@ describe("TelegramAdapter", () => { it("maps Telegram API errors to adapter-specific error types", async () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -748,6 +1250,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); @@ -767,6 +1270,7 @@ describe("TelegramAdapter", () => { const adapter = createTelegramAdapter({ botToken: "token", + mode: "webhook", logger: mockLogger, userName: "mybot", }); diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index cf03b4ad..9889d25c 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -40,6 +40,7 @@ import { import { TelegramFormatConverter } from "./markdown"; import type { TelegramAdapterConfig, + TelegramAdapterMode, TelegramApiResponse, TelegramCallbackQuery, TelegramChat, @@ -48,11 +49,13 @@ import type { TelegramMessage, TelegramMessageEntity, TelegramMessageReactionUpdated, + TelegramPollingConfig, TelegramRawMessage, TelegramReactionType, TelegramThreadId, TelegramUpdate, TelegramUser, + TelegramWebhookInfo, } from "./types"; const TELEGRAM_API_BASE = "https://api.telegram.org"; @@ -66,6 +69,13 @@ const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; const LEADING_AT_PATTERN = /^@+/; const EMOJI_PLACEHOLDER_PATTERN = /^\{\{emoji:([a-z0-9_]+)\}\}$/i; const EMOJI_NAME_PATTERN = /^[a-z0-9_+-]+$/i; +const TELEGRAM_DEFAULT_POLLING_TIMEOUT_SECONDS = 30; +const TELEGRAM_DEFAULT_POLLING_LIMIT = 100; +const TELEGRAM_DEFAULT_POLLING_RETRY_DELAY_MS = 1000; +const TELEGRAM_MAX_POLLING_LIMIT = 100; +const TELEGRAM_MIN_POLLING_LIMIT = 1; +const TELEGRAM_MIN_POLLING_TIMEOUT_SECONDS = 0; +const TELEGRAM_MAX_POLLING_TIMEOUT_SECONDS = 300; interface TelegramMessageAuthor { fullName: string; isBot: boolean | "unknown"; @@ -74,6 +84,17 @@ interface TelegramMessageAuthor { userName: string; } +interface ResolvedTelegramPollingConfig { + allowedUpdates?: string[]; + deleteWebhook: boolean; + dropPendingUpdates: boolean; + limit: number; + retryDelayMs: number; + timeoutSeconds: number; +} + +type TelegramRuntimeMode = "webhook" | "polling"; + export class TelegramAdapter implements Adapter { @@ -93,6 +114,11 @@ export class TelegramAdapter private _botUserId?: string; private _userName: string; private readonly hasExplicitUserName: boolean; + private readonly mode: TelegramAdapterMode; + private readonly polling?: boolean | TelegramPollingConfig; + private pollingAbortController: AbortController | null = null; + private pollingTask: Promise | null = null; + private pollingActive = false; get botUserId(): string | undefined { return this._botUserId; @@ -102,6 +128,10 @@ export class TelegramAdapter return this._userName; } + get isPolling(): boolean { + return this.pollingActive; + } + constructor( config: TelegramAdapterConfig & { logger: Logger; userName?: string } ) { @@ -114,13 +144,20 @@ export class TelegramAdapter this.logger = config.logger; this._userName = this.normalizeUserName(config.userName ?? "bot"); this.hasExplicitUserName = Boolean(config.userName); + this.mode = config.mode ?? "auto"; + this.polling = config.polling; } async initialize(chat: ChatInstance): Promise { this.chat = chat; if (!this.hasExplicitUserName) { - this._userName = this.normalizeUserName(chat.getUserName()); + // Runtime JS consumers can omit Chat.userName even though TS marks it required. + // Keep a safe fallback here and let getMe() refine the username when available. + const chatUserName = chat.getUserName?.(); + if (typeof chatUserName === "string" && chatUserName.trim()) { + this._userName = this.normalizeUserName(chatUserName); + } } try { @@ -139,6 +176,21 @@ export class TelegramAdapter error: String(error), }); } + + const runtimeMode = await this.resolveRuntimeMode(); + if (runtimeMode === "polling") { + const pollingConfig = + typeof this.polling === "object" ? this.polling : undefined; + + if (this.mode === "auto") { + await this.startPolling({ + ...pollingConfig, + deleteWebhook: false, + }); + } else { + await this.startPolling(pollingConfig); + } + } } async handleWebhook( @@ -169,6 +221,121 @@ export class TelegramAdapter return new Response("OK", { status: 200 }); } + this.processUpdate(update, options); + + return new Response("OK", { status: 200 }); + } + + async startPolling(config?: TelegramPollingConfig): Promise { + if (!this.chat) { + throw new ValidationError( + "telegram", + "Cannot start polling before initialize()" + ); + } + + if (this.pollingActive) { + this.logger.debug("Telegram polling already active"); + return; + } + + const resolvedConfig = this.resolvePollingConfig(config); + this.pollingActive = true; + + try { + if (resolvedConfig.deleteWebhook) { + await this.telegramFetch("deleteWebhook", { + drop_pending_updates: resolvedConfig.dropPendingUpdates, + }); + } + } catch (error) { + this.pollingActive = false; + throw error; + } + + this.logger.info("Telegram polling started", { + limit: resolvedConfig.limit, + timeoutSeconds: resolvedConfig.timeoutSeconds, + allowedUpdates: resolvedConfig.allowedUpdates, + }); + + this.pollingTask = this.pollingLoop(resolvedConfig).finally(() => { + this.pollingActive = false; + this.pollingAbortController = null; + this.pollingTask = null; + }); + } + + async stopPolling(): Promise { + if (!this.pollingActive) { + return; + } + + this.pollingActive = false; + this.pollingAbortController?.abort(); + + if (this.pollingTask) { + await this.pollingTask; + } + + this.logger.info("Telegram polling stopped"); + } + + private async resolveRuntimeMode(): Promise { + if (this.mode === "webhook") { + return "webhook"; + } + + if (this.mode === "polling") { + return "polling"; + } + + const webhookInfo = await this.fetchWebhookInfo(); + if (typeof webhookInfo?.url === "string" && webhookInfo.url.trim()) { + this.logger.debug("Telegram auto mode selected webhook mode", { + webhookUrl: webhookInfo.url, + }); + return "webhook"; + } + + if (this.isLikelyServerlessRuntime()) { + this.logger.warn( + "Telegram auto mode detected serverless runtime without webhook URL; keeping webhook mode" + ); + return "webhook"; + } + + this.logger.info("Telegram auto mode selected polling mode"); + return "polling"; + } + + private async fetchWebhookInfo(): Promise { + try { + return await this.telegramFetch("getWebhookInfo"); + } catch (error) { + this.logger.warn("Failed to fetch Telegram webhook info", { + error: String(error), + }); + return null; + } + } + + private isLikelyServerlessRuntime(): boolean { + if (typeof process === "undefined" || !process.env) { + return false; + } + + return Boolean( + process.env.VERCEL || + process.env.AWS_LAMBDA_FUNCTION_NAME || + process.env.AWS_EXECUTION_ENV?.includes("AWS_Lambda") || + process.env.FUNCTIONS_WORKER_RUNTIME || + process.env.NETLIFY || + process.env.K_SERVICE + ); + } + + private processUpdate(update: TelegramUpdate, options?: WebhookOptions): void { const messageUpdate = update.message ?? update.edited_message ?? @@ -186,8 +353,6 @@ export class TelegramAdapter if (update.message_reaction) { this.handleMessageReactionUpdate(update.message_reaction, options); } - - return new Response("OK", { status: 200 }); } private handleIncomingMessageUpdate( @@ -1160,7 +1325,11 @@ export class TelegramAdapter return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - private normalizeUserName(value: string): string { + private normalizeUserName(value: unknown): string { + if (typeof value !== "string") { + return "bot"; + } + return value.replace(LEADING_AT_PATTERN, "").trim() || "bot"; } @@ -1232,9 +1401,132 @@ export class TelegramAdapter return getEmoji(`custom:${reaction.custom_emoji_id}`); } + private async pollingLoop( + config: ResolvedTelegramPollingConfig + ): Promise { + let offset: number | undefined; + + while (this.pollingActive) { + this.pollingAbortController = new AbortController(); + + try { + const updates = await this.telegramFetch( + "getUpdates", + { + allowed_updates: config.allowedUpdates, + limit: config.limit, + offset, + timeout: config.timeoutSeconds, + }, + { signal: this.pollingAbortController.signal } + ); + + for (const update of updates) { + offset = update.update_id + 1; + + try { + this.processUpdate(update); + } catch (error) { + this.logger.warn("Failed to process Telegram polled update", { + error: String(error), + updateId: update.update_id, + }); + } + } + } catch (error) { + if (this.isAbortError(error)) { + return; + } + + this.logger.warn("Telegram polling request failed", { + error: String(error), + retryDelayMs: config.retryDelayMs, + }); + + if (!this.pollingActive) { + return; + } + + await this.sleep(config.retryDelayMs); + } finally { + this.pollingAbortController = null; + } + } + } + + private resolvePollingConfig( + override?: TelegramPollingConfig + ): ResolvedTelegramPollingConfig { + const baseConfig = + this.polling && typeof this.polling === "object" ? this.polling : {}; + const merged = { + ...baseConfig, + ...override, + }; + const timeoutSeconds = merged.timeout ?? merged.timeoutSeconds; + + return { + allowedUpdates: + merged.allowedUpdates && merged.allowedUpdates.length > 0 + ? [...merged.allowedUpdates] + : undefined, + deleteWebhook: merged.deleteWebhook ?? true, + dropPendingUpdates: merged.dropPendingUpdates ?? false, + limit: this.clampInteger( + merged.limit, + TELEGRAM_DEFAULT_POLLING_LIMIT, + TELEGRAM_MIN_POLLING_LIMIT, + TELEGRAM_MAX_POLLING_LIMIT + ), + retryDelayMs: this.clampInteger( + merged.retryDelayMs, + TELEGRAM_DEFAULT_POLLING_RETRY_DELAY_MS, + 0, + Number.MAX_SAFE_INTEGER + ), + timeoutSeconds: this.clampInteger( + timeoutSeconds, + TELEGRAM_DEFAULT_POLLING_TIMEOUT_SECONDS, + TELEGRAM_MIN_POLLING_TIMEOUT_SECONDS, + TELEGRAM_MAX_POLLING_TIMEOUT_SECONDS + ), + }; + } + + private clampInteger( + value: number | undefined, + fallback: number, + min: number, + max: number + ): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + + const parsed = Math.trunc(value); + return Math.max(min, Math.min(max, parsed)); + } + + private isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; + } + + private async sleep(delayMs: number): Promise { + if (delayMs <= 0) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + } + private async telegramFetch( method: string, - payload?: Record | FormData + payload?: Record | FormData, + request?: { + signal?: AbortSignal; + } ): Promise { const url = `${this.apiBaseUrl}/bot${this.botToken}/${method}`; @@ -1250,8 +1542,13 @@ export class TelegramAdapter }, body: payload instanceof FormData ? payload : JSON.stringify(payload ?? {}), + signal: request?.signal, }); } catch (error) { + if (this.isAbortError(error)) { + throw error; + } + throw new NetworkError( "telegram", `Network error calling Telegram ${method}`, @@ -1338,10 +1635,20 @@ export function createTelegramAdapter( const secretToken = config?.secretToken ?? process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN; const userName = config?.userName ?? process.env.TELEGRAM_BOT_USERNAME; + const mode = config?.mode ?? "auto"; + + if (!["auto", "webhook", "polling"].includes(mode)) { + throw new ValidationError( + "telegram", + `Invalid mode: ${mode}. Expected "auto", "webhook", or "polling".` + ); + } return new TelegramAdapter({ botToken, apiBaseUrl, + mode, + polling: config?.polling, secretToken, logger: config?.logger ?? new ConsoleLogger("info").child("telegram"), userName, @@ -1351,12 +1658,15 @@ export function createTelegramAdapter( export { TelegramFormatConverter } from "./markdown"; export type { TelegramAdapterConfig, + TelegramAdapterMode, TelegramCallbackQuery, TelegramChat, TelegramMessage, TelegramMessageReactionUpdated, + TelegramPollingConfig, TelegramRawMessage, TelegramThreadId, TelegramUpdate, TelegramUser, + TelegramWebhookInfo, } from "./types"; diff --git a/packages/adapter-telegram/src/types.ts b/packages/adapter-telegram/src/types.ts index d879179f..604e68fe 100644 --- a/packages/adapter-telegram/src/types.ts +++ b/packages/adapter-telegram/src/types.ts @@ -10,10 +10,50 @@ export interface TelegramAdapterConfig { apiBaseUrl?: string; /** Telegram bot token from BotFather. */ botToken: string; + /** + * Adapter runtime mode: + * - auto: choose webhook vs polling based on webhook registration/runtime (default) + * - webhook: webhook-only mode + * - polling: polling-only mode + */ + mode?: TelegramAdapterMode; + /** Optional long-polling mode for getUpdates flow. */ + polling?: boolean | TelegramPollingConfig; /** Optional webhook secret token checked against x-telegram-bot-api-secret-token. */ secretToken?: string; } +export type TelegramAdapterMode = "auto" | "webhook" | "polling"; + +/** + * Telegram long-polling configuration. + * @see https://core.telegram.org/bots/api#getupdates + */ +export interface TelegramPollingConfig { + /** Allowed update types passed to getUpdates. */ + allowedUpdates?: string[]; + /** + * Delete webhook before polling starts. + * Telegram requires this when switching from webhook mode to getUpdates. + * @default true + */ + deleteWebhook?: boolean; + /** Passed to deleteWebhook as drop_pending_updates when deleting webhook. */ + dropPendingUpdates?: boolean; + /** + * Maximum number of updates per getUpdates call. + * Telegram range: 1-100. + * @default 100 + */ + limit?: number; + /** Delay before retrying polling after errors. @default 1000 */ + retryDelayMs?: number; + /** Long-poll timeout in seconds for getUpdates. @default 30 */ + timeout?: number; + /** @deprecated Use timeout instead. */ + timeoutSeconds?: number; +} + /** * Telegram thread ID components. */ @@ -200,4 +240,19 @@ export interface TelegramApiResponse { result?: TResult; } +/** + * Telegram webhook info response. + * @see https://core.telegram.org/bots/api#getwebhookinfo + */ +export interface TelegramWebhookInfo { + allowed_updates?: string[]; + has_custom_certificate: boolean; + ip_address?: string; + last_error_date?: number; + last_error_message?: string; + max_connections?: number; + pending_update_count: number; + url: string; +} + export type TelegramRawMessage = TelegramMessage; From 1b5f789618b30ef593f752681c3fc147e341e6e0 Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Sat, 28 Feb 2026 15:00:08 +0100 Subject: [PATCH 02/18] Expose Telegram resetWebhook API --- .changeset/soft-hats-kiss.md | 2 +- apps/docs/content/docs/adapters/telegram.mdx | 2 ++ packages/adapter-telegram/README.md | 2 ++ packages/adapter-telegram/src/index.test.ts | 31 ++++++++++++++++++++ packages/adapter-telegram/src/index.ts | 14 +++++++-- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/.changeset/soft-hats-kiss.md b/.changeset/soft-hats-kiss.md index bd1d03c8..1b2acb5e 100644 --- a/.changeset/soft-hats-kiss.md +++ b/.changeset/soft-hats-kiss.md @@ -2,4 +2,4 @@ "@chat-adapter/telegram": patch --- -Add Telegram polling modes (`auto`, `webhook`, `polling`) with safe auto fallback behavior, and fix initialization when the chat username is missing. +Add Telegram polling modes (`auto`, `webhook`, `polling`) with safe auto fallback behavior, expose `adapter.resetWebhook(...)`, and fix initialization when the chat username is missing. diff --git a/apps/docs/content/docs/adapters/telegram.mdx b/apps/docs/content/docs/adapters/telegram.mdx index 365657bc..2b0fe3a2 100644 --- a/apps/docs/content/docs/adapters/telegram.mdx +++ b/apps/docs/content/docs/adapters/telegram.mdx @@ -57,6 +57,7 @@ curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \ Telegram also supports long polling via `getUpdates`. Polling starts automatically when `polling` is provided. Use `polling: true` for defaults: +Use `adapter.resetWebhook(dropPendingUpdates?)` to clear webhook registration manually: ```typescript title="lib/bot.ts" lineNumbers import { Chat } from "chat"; @@ -78,6 +79,7 @@ const bot = new Chat({ }); // Optional manual lifecycle control: +// await telegram.resetWebhook(); // await telegram.startPolling(); // await telegram.stopPolling(); ``` diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index 6f9cc7ff..9a7173cf 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -33,6 +33,7 @@ Features include mentions, reactions, typing indicators, file uploads, and card Use long polling (`getUpdates`) when you cannot expose a public webhook endpoint. Polling starts automatically when `polling` is provided. Pass `polling: true` to use defaults. +Use `adapter.resetWebhook(dropPendingUpdates?)` to clear Telegram webhook registration manually. ```typescript import { createMemoryState } from "@chat-adapter/state-memory"; @@ -53,6 +54,7 @@ const bot = new Chat({ }); // Optional manual control +await telegram.resetWebhook(); await telegram.startPolling(); await telegram.stopPolling(); ``` diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 8c4696bf..6048e356 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -274,6 +274,37 @@ describe("TelegramAdapter", () => { await expect(adapter.startPolling()).rejects.toBeInstanceOf(ValidationError); }); + it("can reset webhook explicitly", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + await adapter.resetWebhook(true); + + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/deleteWebhook"); + const body = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { + drop_pending_updates?: boolean; + }; + expect(body.drop_pending_updates).toBe(true); + }); + it("starts polling, advances offset, and stops cleanly", async () => { mockFetch .mockResolvedValueOnce( diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 9889d25c..c5260c57 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -244,9 +244,7 @@ export class TelegramAdapter try { if (resolvedConfig.deleteWebhook) { - await this.telegramFetch("deleteWebhook", { - drop_pending_updates: resolvedConfig.dropPendingUpdates, - }); + await this.resetWebhook(resolvedConfig.dropPendingUpdates); } } catch (error) { this.pollingActive = false; @@ -281,6 +279,16 @@ export class TelegramAdapter this.logger.info("Telegram polling stopped"); } + async resetWebhook(dropPendingUpdates = false): Promise { + await this.telegramFetch("deleteWebhook", { + drop_pending_updates: dropPendingUpdates, + }); + + this.logger.info("Telegram webhook reset", { + dropPendingUpdates, + }); + } + private async resolveRuntimeMode(): Promise { if (this.mode === "webhook") { return "webhook"; From b13b46bb2a8f88db625b65cd3ba84f88085672a3 Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Sat, 28 Feb 2026 16:48:19 +0100 Subject: [PATCH 03/18] Refine Telegram API to longPolling and safer auto mode --- .changeset/soft-hats-kiss.md | 2 +- apps/docs/content/docs/adapters/telegram.mdx | 14 +- packages/adapter-telegram/README.md | 9 +- packages/adapter-telegram/src/index.test.ts | 136 ++++++++----------- packages/adapter-telegram/src/index.ts | 71 ++++++---- packages/adapter-telegram/src/types.ts | 8 +- 6 files changed, 119 insertions(+), 121 deletions(-) diff --git a/.changeset/soft-hats-kiss.md b/.changeset/soft-hats-kiss.md index 1b2acb5e..676f76f5 100644 --- a/.changeset/soft-hats-kiss.md +++ b/.changeset/soft-hats-kiss.md @@ -2,4 +2,4 @@ "@chat-adapter/telegram": patch --- -Add Telegram polling modes (`auto`, `webhook`, `polling`) with safe auto fallback behavior, expose `adapter.resetWebhook(...)`, and fix initialization when the chat username is missing. +Add Telegram adapter runtime modes (`auto`, `webhook`, `polling`) with safer auto fallback behavior, expose `adapter.resetWebhook(...)` and `adapter.runtimeMode`, switch polling config to `longPolling`, and fix initialization when the chat username is missing. diff --git a/apps/docs/content/docs/adapters/telegram.mdx b/apps/docs/content/docs/adapters/telegram.mdx index 2b0fe3a2..211730fd 100644 --- a/apps/docs/content/docs/adapters/telegram.mdx +++ b/apps/docs/content/docs/adapters/telegram.mdx @@ -56,7 +56,8 @@ curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \ ## Polling (no public webhook) Telegram also supports long polling via `getUpdates`. -Polling starts automatically when `polling` is provided. Use `polling: true` for defaults: +Polling starts automatically in `mode: "polling"` or when `mode: "auto"` selects polling. +Use `longPolling` to customize polling behavior (defaults apply when omitted): Use `adapter.resetWebhook(dropPendingUpdates?)` to clear webhook registration manually: ```typescript title="lib/bot.ts" lineNumbers @@ -66,7 +67,7 @@ import { createMemoryState } from "@chat-adapter/state-memory"; const telegram = createTelegramAdapter({ mode: "polling", - polling: { + longPolling: { timeout: 30, dropPendingUpdates: false, }, @@ -93,7 +94,7 @@ import { createMemoryState } from "@chat-adapter/state-memory"; const telegram = createTelegramAdapter({ mode: "auto", // default - polling: { timeout: 30 }, // only used when auto mode picks polling + longPolling: { timeout: 30 }, // only used when auto mode picks polling }); export const bot = new Chat({ @@ -104,6 +105,8 @@ export const bot = new Chat({ // Required for long-running local processes without incoming webhooks: void bot.initialize(); + +console.log(telegram.runtimeMode); // "webhook" | "polling" ``` ## Configuration @@ -115,7 +118,7 @@ All options are auto-detected from environment variables when not provided. | `botToken` | No* | Telegram bot token. Auto-detected from `TELEGRAM_BOT_TOKEN` | | `secretToken` | No | Optional webhook secret token. Auto-detected from `TELEGRAM_WEBHOOK_SECRET_TOKEN` | | `mode` | No | Adapter mode: `auto` (default), `webhook`, or `polling` | -| `polling` | No | Optional polling mode for `getUpdates` (`true` or config: `timeout`, `limit`, `allowedUpdates`, `deleteWebhook`, `dropPendingUpdates`, `retryDelayMs`) | +| `longPolling` | No | Optional long polling config for `getUpdates` (`timeout`, `limit`, `allowedUpdates`, `deleteWebhook`, `dropPendingUpdates`, `retryDelayMs`) | | `userName` | No | Bot username used for mention detection. Auto-detected from `TELEGRAM_BOT_USERNAME` or `getMe` | | `apiBaseUrl` | No | Telegram API base URL. Auto-detected from `TELEGRAM_API_BASE_URL` | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | @@ -153,7 +156,8 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org - `listThreads` is not available for Telegram chats. - Polling and webhooks are mutually exclusive in Telegram. - `mode: "polling"` deletes webhook by default before calling `getUpdates`. -- `mode: "auto"` checks `getWebhookInfo`: if a webhook URL exists it uses webhook mode; otherwise it falls back to polling on non-serverless runtimes without deleting webhook. +- `mode: "auto"` checks `getWebhookInfo`: if a webhook URL exists it uses webhook mode; if it is empty it falls back to polling on non-serverless runtimes without deleting webhook. +- If `getWebhookInfo` fails in `mode: "auto"`, the adapter stays in webhook mode (safe fallback). - `Button` and `LinkButton` in card `Actions` render as inline keyboard buttons. - Telegram callback data is limited to 64 bytes. Keep button `id`/`value` payloads short. - Other rich card elements (images/select menus/radios) render as fallback text only. diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index 9a7173cf..c331f643 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -32,7 +32,8 @@ Features include mentions, reactions, typing indicators, file uploads, and card ## Polling mode Use long polling (`getUpdates`) when you cannot expose a public webhook endpoint. -Polling starts automatically when `polling` is provided. Pass `polling: true` to use defaults. +Polling starts automatically in `mode: "polling"` or when `mode: "auto"` selects polling. +Use `longPolling` to customize polling behavior (defaults apply when omitted). Use `adapter.resetWebhook(dropPendingUpdates?)` to clear Telegram webhook registration manually. ```typescript @@ -41,7 +42,7 @@ import { createMemoryState } from "@chat-adapter/state-memory"; const telegram = createTelegramAdapter({ botToken: process.env.TELEGRAM_BOT_TOKEN!, mode: "polling", - polling: { + longPolling: { timeout: 30, dropPendingUpdates: false, }, @@ -65,7 +66,7 @@ await telegram.stopPolling(); const telegram = createTelegramAdapter({ botToken: process.env.TELEGRAM_BOT_TOKEN!, mode: "auto", // default - polling: { timeout: 30 }, // used only when auto mode selects polling + longPolling: { timeout: 30 }, // used only when auto mode selects polling }); const bot = new Chat({ @@ -76,6 +77,8 @@ const bot = new Chat({ // Required for long-running local processes without incoming webhooks: void bot.initialize(); + +console.log(telegram.runtimeMode); // "webhook" | "polling" ``` ## Documentation diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 6048e356..6ec2d0b4 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -271,7 +271,9 @@ describe("TelegramAdapter", () => { userName: "mybot", }); - await expect(adapter.startPolling()).rejects.toBeInstanceOf(ValidationError); + await expect(adapter.startPolling()).rejects.toBeInstanceOf( + ValidationError + ); }); it("can reset webhook explicitly", async () => { @@ -361,7 +363,8 @@ describe("TelegramAdapter", () => { }); await waitForCondition( - () => (chat.processMessage as ReturnType).mock.calls.length > 0 + () => + (chat.processMessage as ReturnType).mock.calls.length > 0 ); await waitForCondition(() => mockFetch.mock.calls.length >= 4); await adapter.stopPolling(); @@ -430,13 +433,14 @@ describe("TelegramAdapter", () => { mode: "polling", logger: mockLogger, userName: "mybot", - polling: { + longPolling: { limit: 1, timeout: 1, }, }); await adapter.initialize(createMockChat()); + expect(adapter.runtimeMode).toBe("polling"); await waitForCondition(() => mockFetch.mock.calls.length >= 4); await adapter.stopPolling(); @@ -497,7 +501,7 @@ describe("TelegramAdapter", () => { mode: "auto", logger: mockLogger, userName: "mybot", - polling: { + longPolling: { limit: 1, timeout: 1, }, @@ -505,78 +509,11 @@ describe("TelegramAdapter", () => { const chat = createMockChat(); await adapter.initialize(chat); + expect(adapter.runtimeMode).toBe("polling"); await waitForCondition( - () => (chat.processMessage as ReturnType).mock.calls.length > 0 - ); - await waitForCondition(() => mockFetch.mock.calls.length >= 4); - await adapter.stopPolling(); - - expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); - expect(String(mockFetch.mock.calls[2]?.[0])).toContain("/getUpdates"); - expect(String(mockFetch.mock.calls[3]?.[0])).toContain("/getUpdates"); - expect(adapter.isPolling).toBe(false); - }); - - it("auto mode with polling true uses default polling settings", async () => { - mockFetch - .mockResolvedValueOnce( - telegramOk({ - id: 999, - is_bot: true, - first_name: "Bot", - username: "mybot", - }) - ) - .mockResolvedValueOnce( - telegramOk({ - allowed_updates: [], - has_custom_certificate: false, - pending_update_count: 0, - url: "", - }) - ) - .mockResolvedValueOnce( - telegramOk([ - { - update_id: 43, - message: sampleMessage({ - message_id: 101, - text: "auto polling true message", - }), - }, - ]) - ) - .mockImplementationOnce((_input, init) => { - return new Promise((_resolve, reject) => { - const signal = init?.signal; - if (signal?.aborted) { - reject(createAbortError()); - return; - } - signal?.addEventListener( - "abort", - () => { - reject(createAbortError()); - }, - { once: true } - ); - }); - }); - - const adapter = createTelegramAdapter({ - botToken: "token", - mode: "auto", - logger: mockLogger, - userName: "mybot", - polling: true, - }); - const chat = createMockChat(); - - await adapter.initialize(chat); - - await waitForCondition( - () => (chat.processMessage as ReturnType).mock.calls.length > 0 + () => + (chat.processMessage as ReturnType).mock.calls.length > 0 ); await waitForCondition(() => mockFetch.mock.calls.length >= 4); await adapter.stopPolling(); @@ -587,7 +524,7 @@ describe("TelegramAdapter", () => { expect(adapter.isPolling).toBe(false); }); - it("defaults to auto mode and falls back to polling without polling config", async () => { + it("defaults to auto mode and uses default long polling settings", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -630,11 +567,21 @@ describe("TelegramAdapter", () => { }); await adapter.initialize(createMockChat()); + expect(adapter.runtimeMode).toBe("polling"); await waitForCondition(() => mockFetch.mock.calls.length >= 4); await adapter.stopPolling(); + const firstPollBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { + limit?: number; + timeout?: number; + }; + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); expect(String(mockFetch.mock.calls[2]?.[0])).toContain("/getUpdates"); + expect(firstPollBody.limit).toBe(100); + expect(firstPollBody.timeout).toBe(30); expect(adapter.isPolling).toBe(false); }); @@ -662,13 +609,13 @@ describe("TelegramAdapter", () => { mode: "auto", logger: mockLogger, userName: "mybot", - polling: true, }); await adapter.initialize(createMockChat()); expect(mockFetch.mock.calls).toHaveLength(2); expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); + expect(adapter.runtimeMode).toBe("webhook"); expect(adapter.isPolling).toBe(false); }); @@ -700,23 +647,50 @@ describe("TelegramAdapter", () => { mode: "auto", logger: mockLogger, userName: "mybot", - polling: true, }); await adapter.initialize(createMockChat()); expect(mockFetch.mock.calls).toHaveLength(2); expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); + expect(adapter.runtimeMode).toBe("webhook"); expect(adapter.isPolling).toBe(false); } finally { if (typeof previousVercel === "string") { process.env.VERCEL = previousVercel; } else { - delete process.env.VERCEL; + process.env.VERCEL = undefined; } } }); + it("auto mode stays in webhook mode when getWebhookInfo fails", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramError(500, 500, "Internal Server Error")); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "auto", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + expect(mockFetch.mock.calls).toHaveLength(2); + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/getWebhookInfo"); + expect(adapter.runtimeMode).toBe("webhook"); + expect(adapter.isPolling).toBe(false); + }); + it("does not crash when chat.getUserName() is undefined", async () => { mockFetch.mockResolvedValueOnce( telegramOk({ @@ -750,9 +724,9 @@ describe("TelegramAdapter", () => { ); expect(response.status).toBe(200); - expect((chat.processMessage as ReturnType).mock.calls).toHaveLength( - 1 - ); + expect( + (chat.processMessage as ReturnType).mock.calls + ).toHaveLength(1); }); it("posts, edits, deletes, and sends typing events", async () => { diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index c5260c57..5ac58f34 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -46,10 +46,10 @@ import type { TelegramChat, TelegramFile, TelegramInlineKeyboardMarkup, + TelegramLongPollingConfig, TelegramMessage, TelegramMessageEntity, TelegramMessageReactionUpdated, - TelegramPollingConfig, TelegramRawMessage, TelegramReactionType, TelegramThreadId, @@ -84,13 +84,13 @@ interface TelegramMessageAuthor { userName: string; } -interface ResolvedTelegramPollingConfig { +interface ResolvedTelegramLongPollingConfig { allowedUpdates?: string[]; deleteWebhook: boolean; dropPendingUpdates: boolean; limit: number; retryDelayMs: number; - timeoutSeconds: number; + timeout: number; } type TelegramRuntimeMode = "webhook" | "polling"; @@ -115,7 +115,8 @@ export class TelegramAdapter private _userName: string; private readonly hasExplicitUserName: boolean; private readonly mode: TelegramAdapterMode; - private readonly polling?: boolean | TelegramPollingConfig; + private readonly longPolling?: TelegramLongPollingConfig; + private _runtimeMode: TelegramRuntimeMode = "webhook"; private pollingAbortController: AbortController | null = null; private pollingTask: Promise | null = null; private pollingActive = false; @@ -132,6 +133,10 @@ export class TelegramAdapter return this.pollingActive; } + get runtimeMode(): TelegramRuntimeMode { + return this._runtimeMode; + } + constructor( config: TelegramAdapterConfig & { logger: Logger; userName?: string } ) { @@ -145,7 +150,7 @@ export class TelegramAdapter this._userName = this.normalizeUserName(config.userName ?? "bot"); this.hasExplicitUserName = Boolean(config.userName); this.mode = config.mode ?? "auto"; - this.polling = config.polling; + this.longPolling = config.longPolling; } async initialize(chat: ChatInstance): Promise { @@ -178,15 +183,17 @@ export class TelegramAdapter } const runtimeMode = await this.resolveRuntimeMode(); + this._runtimeMode = runtimeMode; + if (runtimeMode === "polling") { - const pollingConfig = - typeof this.polling === "object" ? this.polling : undefined; + const pollingConfig = this.longPolling; if (this.mode === "auto") { - await this.startPolling({ - ...pollingConfig, - deleteWebhook: false, - }); + await this.startPolling( + pollingConfig + ? { ...pollingConfig, deleteWebhook: false } + : { deleteWebhook: false } + ); } else { await this.startPolling(pollingConfig); } @@ -226,7 +233,7 @@ export class TelegramAdapter return new Response("OK", { status: 200 }); } - async startPolling(config?: TelegramPollingConfig): Promise { + async startPolling(config?: TelegramLongPollingConfig): Promise { if (!this.chat) { throw new ValidationError( "telegram", @@ -240,20 +247,24 @@ export class TelegramAdapter } const resolvedConfig = this.resolvePollingConfig(config); + const previousRuntimeMode = this._runtimeMode; this.pollingActive = true; try { if (resolvedConfig.deleteWebhook) { await this.resetWebhook(resolvedConfig.dropPendingUpdates); } + + this._runtimeMode = "polling"; } catch (error) { this.pollingActive = false; + this._runtimeMode = previousRuntimeMode; throw error; } this.logger.info("Telegram polling started", { limit: resolvedConfig.limit, - timeoutSeconds: resolvedConfig.timeoutSeconds, + timeout: resolvedConfig.timeout, allowedUpdates: resolvedConfig.allowedUpdates, }); @@ -299,7 +310,14 @@ export class TelegramAdapter } const webhookInfo = await this.fetchWebhookInfo(); - if (typeof webhookInfo?.url === "string" && webhookInfo.url.trim()) { + if (!webhookInfo) { + this.logger.warn( + "Telegram auto mode could not verify webhook status; keeping webhook mode" + ); + return "webhook"; + } + + if (typeof webhookInfo.url === "string" && webhookInfo.url.trim()) { this.logger.debug("Telegram auto mode selected webhook mode", { webhookUrl: webhookInfo.url, }); @@ -343,7 +361,10 @@ export class TelegramAdapter ); } - private processUpdate(update: TelegramUpdate, options?: WebhookOptions): void { + private processUpdate( + update: TelegramUpdate, + options?: WebhookOptions + ): void { const messageUpdate = update.message ?? update.edited_message ?? @@ -1410,7 +1431,7 @@ export class TelegramAdapter } private async pollingLoop( - config: ResolvedTelegramPollingConfig + config: ResolvedTelegramLongPollingConfig ): Promise { let offset: number | undefined; @@ -1424,7 +1445,7 @@ export class TelegramAdapter allowed_updates: config.allowedUpdates, limit: config.limit, offset, - timeout: config.timeoutSeconds, + timeout: config.timeout, }, { signal: this.pollingAbortController.signal } ); @@ -1463,15 +1484,13 @@ export class TelegramAdapter } private resolvePollingConfig( - override?: TelegramPollingConfig - ): ResolvedTelegramPollingConfig { - const baseConfig = - this.polling && typeof this.polling === "object" ? this.polling : {}; + override?: TelegramLongPollingConfig + ): ResolvedTelegramLongPollingConfig { + const baseConfig = this.longPolling ?? {}; const merged = { ...baseConfig, ...override, }; - const timeoutSeconds = merged.timeout ?? merged.timeoutSeconds; return { allowedUpdates: @@ -1492,8 +1511,8 @@ export class TelegramAdapter 0, Number.MAX_SAFE_INTEGER ), - timeoutSeconds: this.clampInteger( - timeoutSeconds, + timeout: this.clampInteger( + merged.timeout, TELEGRAM_DEFAULT_POLLING_TIMEOUT_SECONDS, TELEGRAM_MIN_POLLING_TIMEOUT_SECONDS, TELEGRAM_MAX_POLLING_TIMEOUT_SECONDS @@ -1656,7 +1675,7 @@ export function createTelegramAdapter( botToken, apiBaseUrl, mode, - polling: config?.polling, + longPolling: config?.longPolling, secretToken, logger: config?.logger ?? new ConsoleLogger("info").child("telegram"), userName, @@ -1669,9 +1688,9 @@ export type { TelegramAdapterMode, TelegramCallbackQuery, TelegramChat, + TelegramLongPollingConfig, TelegramMessage, TelegramMessageReactionUpdated, - TelegramPollingConfig, TelegramRawMessage, TelegramThreadId, TelegramUpdate, diff --git a/packages/adapter-telegram/src/types.ts b/packages/adapter-telegram/src/types.ts index 604e68fe..e59f623e 100644 --- a/packages/adapter-telegram/src/types.ts +++ b/packages/adapter-telegram/src/types.ts @@ -10,6 +10,8 @@ export interface TelegramAdapterConfig { apiBaseUrl?: string; /** Telegram bot token from BotFather. */ botToken: string; + /** Optional long-polling configuration for getUpdates flow. */ + longPolling?: TelegramLongPollingConfig; /** * Adapter runtime mode: * - auto: choose webhook vs polling based on webhook registration/runtime (default) @@ -17,8 +19,6 @@ export interface TelegramAdapterConfig { * - polling: polling-only mode */ mode?: TelegramAdapterMode; - /** Optional long-polling mode for getUpdates flow. */ - polling?: boolean | TelegramPollingConfig; /** Optional webhook secret token checked against x-telegram-bot-api-secret-token. */ secretToken?: string; } @@ -29,7 +29,7 @@ export type TelegramAdapterMode = "auto" | "webhook" | "polling"; * Telegram long-polling configuration. * @see https://core.telegram.org/bots/api#getupdates */ -export interface TelegramPollingConfig { +export interface TelegramLongPollingConfig { /** Allowed update types passed to getUpdates. */ allowedUpdates?: string[]; /** @@ -50,8 +50,6 @@ export interface TelegramPollingConfig { retryDelayMs?: number; /** Long-poll timeout in seconds for getUpdates. @default 30 */ timeout?: number; - /** @deprecated Use timeout instead. */ - timeoutSeconds?: number; } /** From df651f9262cf49bfe86c1884d9c285862b67bb15 Mon Sep 17 00:00:00 2001 From: Vercel Date: Sat, 28 Feb 2026 15:54:53 +0000 Subject: [PATCH 04/18] Fix: Environment variable cleanup sets `process.env.VERCEL` to the string `"undefined"` (truthy) instead of removing it, leaking a truthy VERCEL env var into subsequent tests. This commit fixes the issue reported at packages/adapter-telegram/src/index.test.ts:661 **Bug explanation:** In the test "auto mode stays in webhook mode on serverless runtime" (line 622), the cleanup code in the `finally` block at line 662 uses `process.env.VERCEL = undefined` when the env var wasn't previously set. In Node.js, `process.env` coerces all values to strings, so `process.env.VERCEL = undefined` actually sets `process.env.VERCEL` to the string `"undefined"`. This is truthy (`Boolean("undefined")` === `true`). This was verified empirically: ``` process.env.TEST_VAR = undefined; typeof process.env.TEST_VAR // "string" process.env.TEST_VAR // "undefined" Boolean(process.env.TEST_VAR) // true ``` The consequence is that after this test runs, `process.env.VERCEL` remains set to the truthy string `"undefined"`, causing `isLikelyServerlessRuntime()` to return `true` for all subsequent tests that run in the same process. This could cause any auto-mode tests that follow to incorrectly detect a serverless runtime and behave differently than expected. **Fix explanation:** Changed `process.env.VERCEL = undefined` to `delete process.env.VERCEL`, which properly removes the environment variable from `process.env`. After `delete`, `process.env.VERCEL` is `undefined` (the actual undefined value, not the string), and `Boolean(process.env.VERCEL)` correctly returns `false`. This matches the standard pattern for cleaning up environment variables in Node.js tests. Co-authored-by: Vercel Co-authored-by: timolins --- packages/adapter-telegram/src/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 6ec2d0b4..39543394 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -659,7 +659,7 @@ describe("TelegramAdapter", () => { if (typeof previousVercel === "string") { process.env.VERCEL = previousVercel; } else { - process.env.VERCEL = undefined; + delete process.env.VERCEL; } } }); From 58e6b44c1d28e3d75bbb9dff8f6d711150dca32a Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Mon, 2 Mar 2026 19:48:00 +0100 Subject: [PATCH 05/18] Fix lint: replace delete env cleanup with Reflect.deleteProperty --- packages/adapter-telegram/src/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 39543394..77885f59 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -659,7 +659,7 @@ describe("TelegramAdapter", () => { if (typeof previousVercel === "string") { process.env.VERCEL = previousVercel; } else { - delete process.env.VERCEL; + Reflect.deleteProperty(process.env, "VERCEL"); } } }); From bb01ba54344d9c91b69e4f776c4ecaf58fc55105 Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Mon, 2 Mar 2026 19:59:04 +0100 Subject: [PATCH 06/18] Harden Telegram polling tests and polish polling docs --- apps/docs/content/docs/adapters/telegram.mdx | 4 ++-- packages/adapter-telegram/src/index.test.ts | 23 +++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/docs/content/docs/adapters/telegram.mdx b/apps/docs/content/docs/adapters/telegram.mdx index 211730fd..1d5a5566 100644 --- a/apps/docs/content/docs/adapters/telegram.mdx +++ b/apps/docs/content/docs/adapters/telegram.mdx @@ -57,8 +57,8 @@ curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \ Telegram also supports long polling via `getUpdates`. Polling starts automatically in `mode: "polling"` or when `mode: "auto"` selects polling. -Use `longPolling` to customize polling behavior (defaults apply when omitted): -Use `adapter.resetWebhook(dropPendingUpdates?)` to clear webhook registration manually: +- Use `longPolling` to customize polling behavior (defaults apply when omitted). +- Use `adapter.resetWebhook(dropPendingUpdates?)` to clear webhook registration manually. ```typescript title="lib/bot.ts" lineNumbers import { Chat } from "chat"; diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 77885f59..90dce409 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -24,13 +24,34 @@ const mockLogger: Logger = { }; const mockFetch = vi.fn(); +const SERVERLESS_ENV_KEYS = [ + "VERCEL", + "AWS_LAMBDA_FUNCTION_NAME", + "AWS_EXECUTION_ENV", + "FUNCTIONS_WORKER_RUNTIME", + "NETLIFY", + "K_SERVICE", +] as const; +let originalServerlessEnv: Record = {}; beforeEach(() => { + originalServerlessEnv = {}; + for (const key of SERVERLESS_ENV_KEYS) { + originalServerlessEnv[key] = process.env[key]; + } mockFetch.mockReset(); vi.stubGlobal("fetch", mockFetch); }); afterEach(() => { + for (const key of SERVERLESS_ENV_KEYS) { + const value = originalServerlessEnv[key]; + if (typeof value === "string") { + process.env[key] = value; + } else { + Reflect.deleteProperty(process.env, key); + } + } vi.unstubAllGlobals(); }); @@ -112,7 +133,7 @@ function createAbortError(): Error { async function waitForCondition( predicate: () => boolean, - timeoutMs = 300 + timeoutMs = 1_000 ): Promise { const startedAt = Date.now(); From 552d8adde86bfe3b8dbda3ac179d8434725371ee Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Mon, 2 Mar 2026 23:09:09 +0100 Subject: [PATCH 07/18] feat(telegram): add native draft streaming with fallbacks --- README.md | 4 +- apps/docs/content/docs/adapters/index.mdx | 2 +- apps/docs/content/docs/adapters/telegram.mdx | 25 +- apps/docs/content/docs/index.mdx | 2 +- apps/docs/content/docs/streaming.mdx | 1 + packages/adapter-telegram/README.md | 22 +- packages/adapter-telegram/src/index.test.ts | 297 +++++++++++++++++++ packages/adapter-telegram/src/index.ts | 226 ++++++++++++++ 8 files changed, 550 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5e9faa3d..33629dd7 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,14 @@ See the [Getting Started guide](https://chat-sdk.dev/docs/getting-started) for a | Microsoft Teams | `@chat-adapter/teams` | Yes | Read-only | Yes | No | Post+Edit | Yes | | Google Chat | `@chat-adapter/gchat` | Yes | Yes | Yes | No | Post+Edit | Yes | | Discord | `@chat-adapter/discord` | Yes | Yes | Yes | No | Post+Edit | Yes | -| Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | Post+Edit | Yes | +| Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | Draft API (DMs) + Post+Edit | Yes | | GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No | | Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No | ## Features - [**Event handlers**](https://chat-sdk.dev/docs/usage) — mentions, messages, reactions, button clicks, slash commands, modals -- [**AI streaming**](https://chat-sdk.dev/docs/streaming) — stream LLM responses with native Slack streaming and post+edit fallback +- [**AI streaming**](https://chat-sdk.dev/docs/streaming) — stream LLM responses with native Slack/Telegram DM streaming and post+edit fallback - [**Cards**](https://chat-sdk.dev/docs/cards) — JSX-based interactive cards (Block Kit, Adaptive Cards, Google Chat Cards) - [**Actions**](https://chat-sdk.dev/docs/actions) — handle button clicks and dropdown selections - [**Modals**](https://chat-sdk.dev/docs/modals) — form dialogs with text inputs, dropdowns, and validation diff --git a/apps/docs/content/docs/adapters/index.mdx b/apps/docs/content/docs/adapters/index.mdx index e5de7913..447726c0 100644 --- a/apps/docs/content/docs/adapters/index.mdx +++ b/apps/docs/content/docs/adapters/index.mdx @@ -18,7 +18,7 @@ Adapters handle webhook verification, message parsing, and API calls for each pl | Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | -| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | +| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Draft API (DMs) + Post+Edit | ❌ | ❌ | ### Rich content diff --git a/apps/docs/content/docs/adapters/telegram.mdx b/apps/docs/content/docs/adapters/telegram.mdx index 1d5a5566..3ec07c76 100644 --- a/apps/docs/content/docs/adapters/telegram.mdx +++ b/apps/docs/content/docs/adapters/telegram.mdx @@ -53,12 +53,11 @@ curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \ }' ``` -## Polling (no public webhook) +## Polling (local development) -Telegram also supports long polling via `getUpdates`. -Polling starts automatically in `mode: "polling"` or when `mode: "auto"` selects polling. -- Use `longPolling` to customize polling behavior (defaults apply when omitted). -- Use `adapter.resetWebhook(dropPendingUpdates?)` to clear webhook registration manually. +When developing locally you typically can't expose a public URL for Telegram to deliver webhooks to. Polling mode uses `getUpdates` to fetch messages directly from Telegram instead — no public endpoint needed. + +The `longPolling` option is entirely optional. Sensible defaults are applied when omitted. ```typescript title="lib/bot.ts" lineNumbers import { Chat } from "chat"; @@ -67,10 +66,8 @@ import { createMemoryState } from "@chat-adapter/state-memory"; const telegram = createTelegramAdapter({ mode: "polling", - longPolling: { - timeout: 30, - dropPendingUpdates: false, - }, + // Optional — fine-tune polling behavior: + // longPolling: { timeout: 30, dropPendingUpdates: false }, }); const bot = new Chat({ @@ -85,7 +82,9 @@ const bot = new Chat({ // await telegram.stopPolling(); ``` -### Auto mode: local polling, production webhooks +### Auto mode + +With `mode: "auto"` (the default), the adapter picks the right strategy for you. When deployed to a serverless environment like Vercel it uses webhooks; everywhere else (e.g. local dev) it falls back to polling automatically. ```typescript title="lib/bot.ts" lineNumbers import { Chat } from "chat"; @@ -94,7 +93,6 @@ import { createMemoryState } from "@chat-adapter/state-memory"; const telegram = createTelegramAdapter({ mode: "auto", // default - longPolling: { timeout: 30 }, // only used when auto mode picks polling }); export const bot = new Chat({ @@ -103,7 +101,7 @@ export const bot = new Chat({ state: createMemoryState(), }); -// Required for long-running local processes without incoming webhooks: +// Call initialize() so polling can start in long-running local processes: void bot.initialize(); console.log(telegram.runtimeMode); // "webhook" | "polling" @@ -143,7 +141,7 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org | Reactions (add/remove) | Yes | | Cards | Text fallback + inline keyboard buttons/link buttons | | Modals | No | -| Streaming | Post+Edit fallback | +| Streaming | `sendMessageDraft` in DMs + Post+Edit fallback | | DMs | Yes | | Ephemeral messages | No | | File uploads | Single file (`sendDocument`) | @@ -161,3 +159,4 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org - `Button` and `LinkButton` in card `Actions` render as inline keyboard buttons. - Telegram callback data is limited to 64 bytes. Keep button `id`/`value` payloads short. - Other rich card elements (images/select menus/radios) render as fallback text only. +- Native draft streaming (`sendMessageDraft`) is currently available in private chats; groups/channels use post+edit fallback. diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 2de1d503..d53d0106 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -55,7 +55,7 @@ Each adapter factory auto-detects credentials from environment variables (`SLACK | Microsoft Teams | `@chat-adapter/teams` | Yes | Read-only | Yes | No | Post+Edit | Yes | | Google Chat | `@chat-adapter/gchat` | Yes | Yes | Yes | No | Post+Edit | Yes | | Discord | `@chat-adapter/discord` | Yes | Yes | Yes | No | Post+Edit | Yes | -| Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | Post+Edit | Yes | +| Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | Draft API (DMs) + Post+Edit | Yes | | GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No | | Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No | diff --git a/apps/docs/content/docs/streaming.mdx b/apps/docs/content/docs/streaming.mdx index ae7b740b..8a5aa50a 100644 --- a/apps/docs/content/docs/streaming.mdx +++ b/apps/docs/content/docs/streaming.mdx @@ -48,6 +48,7 @@ await thread.post(stream); | Teams | Post + Edit | Posts a message then edits it as chunks arrive | | Google Chat | Post + Edit | Posts a message then edits it as chunks arrive | | Discord | Post + Edit | Posts a message then edits it as chunks arrive | +| Telegram | Draft API (DMs) + Post + Edit | Uses `sendMessageDraft` in private chats, falls back to post+edit in other chats | The post+edit fallback throttles edits to avoid rate limits. Configure the update interval when creating your `Chat` instance: diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index c331f643..c3308b8c 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -27,14 +27,13 @@ const bot = new Chat({ }); ``` -Features include mentions, reactions, typing indicators, file uploads, and card fallback rendering with inline keyboard buttons for card actions. +Features include mentions, reactions, typing indicators, file uploads, Telegram draft streaming in DMs (`sendMessageDraft`) with post+edit fallback elsewhere, and card fallback rendering with inline keyboard buttons for card actions. ## Polling mode -Use long polling (`getUpdates`) when you cannot expose a public webhook endpoint. -Polling starts automatically in `mode: "polling"` or when `mode: "auto"` selects polling. -Use `longPolling` to customize polling behavior (defaults apply when omitted). -Use `adapter.resetWebhook(dropPendingUpdates?)` to clear Telegram webhook registration manually. +When developing locally, you typically can't expose a public URL for Telegram to send webhooks to. Polling mode uses `getUpdates` to fetch messages directly from Telegram instead — no public endpoint needed. + +The `longPolling` option is entirely optional. Sensible defaults are applied when omitted. ```typescript import { createMemoryState } from "@chat-adapter/state-memory"; @@ -42,10 +41,8 @@ import { createMemoryState } from "@chat-adapter/state-memory"; const telegram = createTelegramAdapter({ botToken: process.env.TELEGRAM_BOT_TOKEN!, mode: "polling", - longPolling: { - timeout: 30, - dropPendingUpdates: false, - }, + // Optional — fine-tune polling behavior: + // longPolling: { timeout: 30, dropPendingUpdates: false }, }); const bot = new Chat({ @@ -60,13 +57,14 @@ await telegram.startPolling(); await telegram.stopPolling(); ``` -### Auto mode (local polling + production webhooks) +### Auto mode + +With `mode: "auto"` (the default), the adapter picks the right strategy for you. In a serverless environment like Vercel it uses webhooks; everywhere else (e.g. local dev) it falls back to polling. ```typescript const telegram = createTelegramAdapter({ botToken: process.env.TELEGRAM_BOT_TOKEN!, mode: "auto", // default - longPolling: { timeout: 30 }, // used only when auto mode selects polling }); const bot = new Chat({ @@ -75,7 +73,7 @@ const bot = new Chat({ state: createMemoryState(), }); -// Required for long-running local processes without incoming webhooks: +// Call initialize() so polling can start in long-running local processes: void bot.initialize(); console.log(telegram.runtimeMode); // "webhook" | "polling" diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 90dce409..2ec64d2a 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -877,6 +877,303 @@ describe("TelegramAdapter", () => { }); }); + it("streams in private chats using sendMessageDraft", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "hello world", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const stream = (async function* () { + yield "hello"; + yield " world"; + })(); + + const result = await adapter.stream("telegram:123", stream, { + updateIntervalMs: 0, + }); + + expect(result.id).toBe("123:11"); + expect(result.threadId).toBe("telegram:123"); + + const draftUrl1 = String(mockFetch.mock.calls[1]?.[0]); + const draftUrl2 = String(mockFetch.mock.calls[2]?.[0]); + const finalSendUrl = String(mockFetch.mock.calls[3]?.[0]); + + expect(draftUrl1).toContain("/sendMessageDraft"); + expect(draftUrl2).toContain("/sendMessageDraft"); + expect(finalSendUrl).toContain("/sendMessage"); + + const draftBody1 = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { chat_id: string; draft_id: string; text: string }; + const draftBody2 = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { chat_id: string; draft_id: string; text: string }; + const finalBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + ) as { chat_id: string; text: string }; + + expect(draftBody1.chat_id).toBe("123"); + expect(draftBody2.chat_id).toBe("123"); + expect(draftBody1.text).toBe("hello"); + expect(draftBody2.text).toBe("hello world"); + expect(draftBody1.draft_id).toBe(draftBody2.draft_id); + expect(finalBody.chat_id).toBe("123"); + expect(finalBody.text).toBe("hello world"); + }); + + it("falls back to post+edit streaming in non-DM chats", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + chat: { id: -100123, type: "supergroup", title: "General" }, + text: "...", + }) + ) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + chat: { id: -100123, type: "supergroup", title: "General" }, + text: "hello", + edit_date: 1735689700, + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const stream = (async function* () { + yield "hello"; + })(); + + const result = await adapter.stream("telegram:-100123", stream, { + updateIntervalMs: 0, + }); + + expect(result.id).toBe("-100123:11"); + expect(result.threadId).toBe("telegram:-100123"); + + const postUrl = String(mockFetch.mock.calls[1]?.[0]); + const editUrl = String(mockFetch.mock.calls[2]?.[0]); + + expect(postUrl).toContain("/sendMessage"); + expect(editUrl).toContain("/editMessageText"); + expect(postUrl).not.toContain("/sendMessageDraft"); + expect(editUrl).not.toContain("/sendMessageDraft"); + + const postBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { chat_id: string; text: string }; + const editBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { chat_id: string; text: string }; + + expect(postBody.chat_id).toBe("-100123"); + expect(postBody.text).toBe("..."); + expect(editBody.chat_id).toBe("-100123"); + expect(editBody.text).toBe("hello"); + }); + + it("falls back to post+edit when sendMessageDraft is unavailable", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramError(404, 404, "Not Found")) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "...", + }) + ) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "hello", + edit_date: 1735689700, + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const stream = (async function* () { + yield "hello"; + })(); + + const result = await adapter.stream("telegram:123", stream, { + updateIntervalMs: 0, + }); + + expect(result.id).toBe("123:11"); + expect(result.threadId).toBe("telegram:123"); + + const draftUrl = String(mockFetch.mock.calls[1]?.[0]); + const postUrl = String(mockFetch.mock.calls[2]?.[0]); + const editUrl = String(mockFetch.mock.calls[3]?.[0]); + + expect(draftUrl).toContain("/sendMessageDraft"); + expect(postUrl).toContain("/sendMessage"); + expect(editUrl).toContain("/editMessageText"); + }); + + it("falls back to post+edit when sendMessageDraft returns method-not-found validation", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramError(400, 400, "Bad Request: method not found") + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "...", + }) + ) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "hello", + edit_date: 1735689700, + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const stream = (async function* () { + yield "hello"; + })(); + + const result = await adapter.stream("telegram:123", stream, { + updateIntervalMs: 0, + }); + + expect(result.id).toBe("123:11"); + expect(result.threadId).toBe("telegram:123"); + + const draftUrl = String(mockFetch.mock.calls[1]?.[0]); + const postUrl = String(mockFetch.mock.calls[2]?.[0]); + const editUrl = String(mockFetch.mock.calls[3]?.[0]); + + expect(draftUrl).toContain("/sendMessageDraft"); + expect(postUrl).toContain("/sendMessage"); + expect(editUrl).toContain("/editMessageText"); + }); + + it("cleans up placeholder and throws when fallback stream is empty", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + chat: { id: -100123, type: "supergroup", title: "General" }, + text: "...", + }) + ) + ) + .mockResolvedValueOnce(telegramOk(true)); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const emptyStream = (async function* () { + // empty by design + })(); + + await expect( + adapter.stream("telegram:-100123", emptyStream, { + updateIntervalMs: 0, + }) + ).rejects.toBeInstanceOf(ValidationError); + + const postUrl = String(mockFetch.mock.calls[1]?.[0]); + const deleteUrl = String(mockFetch.mock.calls[2]?.[0]); + + expect(postUrl).toContain("/sendMessage"); + expect(deleteUrl).toContain("/deleteMessage"); + }); + it("adds and removes reactions", async () => { mockFetch .mockResolvedValueOnce( diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 5ac58f34..347b75c0 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -21,6 +21,7 @@ import type { FormattedContent, Logger, RawMessage, + StreamOptions, ThreadInfo, WebhookOptions, } from "chat"; @@ -76,6 +77,8 @@ const TELEGRAM_MAX_POLLING_LIMIT = 100; const TELEGRAM_MIN_POLLING_LIMIT = 1; const TELEGRAM_MIN_POLLING_TIMEOUT_SECONDS = 0; const TELEGRAM_MAX_POLLING_TIMEOUT_SECONDS = 300; +const TELEGRAM_STREAM_PLACEHOLDER = "..."; +const TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS = 350; interface TelegramMessageAuthor { fullName: string; isBot: boolean | "unknown"; @@ -739,6 +742,117 @@ export class TelegramAdapter }); } + async stream( + threadId: string, + textStream: AsyncIterable, + options?: StreamOptions + ): Promise> { + const parsedThread = this.resolveThreadId(threadId); + + // Telegram drafts are currently private-chat only, so keep post+edit for groups/topics. + if (parsedThread.messageThreadId || !this.isDM(threadId)) { + return this.streamViaPostEdit(threadId, textStream, options); + } + + const updateIntervalMs = Math.max( + 0, + options?.updateIntervalMs ?? TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS + ); + + const iterator = textStream[Symbol.asyncIterator](); + const draftId = this.createDraftId(); + let rawAccumulated = ""; + let renderedAccumulated = ""; + let lastDraftText = ""; + let lastDraftSentAt = 0; + let draftStreamingEnabled = true; + let draftUpdatesSent = 0; + + while (true) { + const next = await iterator.next(); + if (next.done) { + break; + } + + rawAccumulated += next.value; + renderedAccumulated = this.renderStreamText(rawAccumulated); + + if (!draftStreamingEnabled || !renderedAccumulated.trim()) { + continue; + } + + const now = Date.now(); + const shouldSendDraft = + draftUpdatesSent === 0 || + (renderedAccumulated !== lastDraftText && + now - lastDraftSentAt >= updateIntervalMs); + + if (!shouldSendDraft) { + continue; + } + + try { + await this.sendDraftMessage(parsedThread.chatId, draftId, renderedAccumulated); + lastDraftText = renderedAccumulated; + lastDraftSentAt = now; + draftUpdatesSent += 1; + } catch (error) { + if (draftUpdatesSent === 0 && this.isDraftMethodUnsupported(error)) { + this.logger.info( + "Telegram: sendMessageDraft unavailable, falling back to post+edit streaming" + ); + + const resumedTextStream = (async function* ( + consumed: string, + remaining: AsyncIterator + ): AsyncIterable { + if (consumed) { + yield consumed; + } + + while (true) { + const item = await remaining.next(); + if (item.done) { + return; + } + yield item.value; + } + })(rawAccumulated, iterator); + + return this.streamViaPostEdit(threadId, resumedTextStream, options); + } + + this.logger.warn( + "Telegram: sendMessageDraft failed during stream; continuing without draft updates", + { + error: String(error), + } + ); + draftStreamingEnabled = false; + } + } + + if (!rawAccumulated.trim()) { + throw new ValidationError("telegram", "Message text cannot be empty"); + } + + renderedAccumulated = this.renderStreamText(rawAccumulated); + if (draftStreamingEnabled && renderedAccumulated !== lastDraftText) { + try { + await this.sendDraftMessage(parsedThread.chatId, draftId, renderedAccumulated); + } catch (error) { + this.logger.warn( + "Telegram: final sendMessageDraft update failed; sending final message anyway", + { + error: String(error), + } + ); + } + } + + return this.postMessage(threadId, rawAccumulated); + } + async fetchMessages( threadId: string, options: FetchOptions = {} @@ -1354,6 +1468,118 @@ export class TelegramAdapter return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } + private async streamViaPostEdit( + threadId: string, + textStream: AsyncIterable, + options?: StreamOptions + ): Promise> { + const intervalMs = Math.max( + 0, + options?.updateIntervalMs ?? TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS + ); + const posted = await this.postMessage(threadId, TELEGRAM_STREAM_PLACEHOLDER); + const threadIdForEdits = posted.threadId || threadId; + + let rawAccumulated = ""; + let lastRendered = TELEGRAM_STREAM_PLACEHOLDER; + let lastEditAt = Date.now(); + + for await (const chunk of textStream) { + rawAccumulated += chunk; + + const rendered = this.renderStreamText(rawAccumulated); + const now = Date.now(); + const shouldEdit = + rendered.trim() && + rendered !== lastRendered && + now - lastEditAt >= intervalMs; + + if (!shouldEdit) { + continue; + } + + try { + await this.editMessage(threadIdForEdits, posted.id, rawAccumulated); + lastRendered = rendered; + lastEditAt = now; + } catch (error) { + this.logger.debug("Telegram: intermediate stream edit failed", { + error: String(error), + threadId: threadIdForEdits, + messageId: posted.id, + }); + } + } + + if (!rawAccumulated.trim()) { + try { + await this.deleteMessage(threadIdForEdits, posted.id); + } catch (error) { + this.logger.debug("Telegram: failed to cleanup empty stream placeholder", { + error: String(error), + threadId: threadIdForEdits, + messageId: posted.id, + }); + } + throw new ValidationError("telegram", "Message text cannot be empty"); + } + + const finalRendered = this.renderStreamText(rawAccumulated); + if (finalRendered.trim() && finalRendered !== lastRendered) { + return this.editMessage(threadIdForEdits, posted.id, rawAccumulated); + } + + return posted; + } + + private createDraftId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `draft-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}`; + } + + private async sendDraftMessage( + chatId: string, + draftId: string, + text: string + ): Promise { + await this.telegramFetch("sendMessageDraft", { + chat_id: chatId, + draft_id: draftId, + text, + }); + } + + private isDraftMethodUnsupported(error: unknown): boolean { + if ( + error instanceof ResourceNotFoundError && + error.resourceType === "sendMessageDraft" + ) { + return true; + } + + if (error instanceof ValidationError) { + const lower = error.message.toLowerCase(); + return ( + lower.includes("sendmessagedraft") || + lower.includes("method not found") || + lower.includes("unknown method") || + lower.includes("method is not available") + ); + } + + return false; + } + + private renderStreamText(rawText: string): string { + // Telegram expects Unicode emoji, and the gchat resolver emits Unicode output. + return this.truncateMessage(convertEmojiPlaceholders(rawText, "gchat")); + } + private normalizeUserName(value: unknown): string { if (typeof value !== "string") { return "bot"; From 6b63e4ba760f6cfe147409e45f513cf72c789f9f Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Mon, 2 Mar 2026 23:30:01 +0100 Subject: [PATCH 08/18] feat(telegram): render markdown/ast via HTML parse mode --- apps/docs/content/docs/adapters/telegram.mdx | 1 + packages/adapter-telegram/README.md | 2 +- packages/adapter-telegram/src/index.test.ts | 58 +++++++- packages/adapter-telegram/src/index.ts | 83 ++++++++--- packages/adapter-telegram/src/markdown.ts | 143 ++++++++++++++++--- 5 files changed, 245 insertions(+), 42 deletions(-) diff --git a/apps/docs/content/docs/adapters/telegram.mdx b/apps/docs/content/docs/adapters/telegram.mdx index 3ec07c76..2a24d64b 100644 --- a/apps/docs/content/docs/adapters/telegram.mdx +++ b/apps/docs/content/docs/adapters/telegram.mdx @@ -160,3 +160,4 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org - Telegram callback data is limited to 64 bytes. Keep button `id`/`value` payloads short. - Other rich card elements (images/select menus/radios) render as fallback text only. - Native draft streaming (`sendMessageDraft`) is currently available in private chats; groups/channels use post+edit fallback. +- `{ markdown: ... }` and `{ ast: ... }` messages are sent with Telegram HTML parse mode for formatting support; plain strings and `{ raw: ... }` are sent as plain text. diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index c3308b8c..c5fd7bb0 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -27,7 +27,7 @@ const bot = new Chat({ }); ``` -Features include mentions, reactions, typing indicators, file uploads, Telegram draft streaming in DMs (`sendMessageDraft`) with post+edit fallback elsewhere, and card fallback rendering with inline keyboard buttons for card actions. +Features include mentions, reactions, typing indicators, file uploads, Markdown/AST formatting via Telegram HTML parse mode, Telegram draft streaming in DMs (`sendMessageDraft`) with post+edit fallback elsewhere, and card fallback rendering with inline keyboard buttons for card actions. ## Polling mode diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 2ec64d2a..1423ffe7 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -804,10 +804,17 @@ describe("TelegramAdapter", () => { const sendMessageBody = JSON.parse( String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) - ) as { chat_id: string; text: string }; + ) as { chat_id: string; text: string; parse_mode?: string }; + const editMessageBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { chat_id: string; text: string; parse_mode?: string }; expect(sendMessageBody.chat_id).toBe("123"); expect(sendMessageBody.text).toBe("hello"); + expect(sendMessageBody.parse_mode).toBe("HTML"); + expect(editMessageBody.chat_id).toBe("123"); + expect(editMessageBody.text).toBe("updated"); + expect(editMessageBody.parse_mode).toBeUndefined(); }); it("posts cards with inline keyboard buttons", async () => { @@ -857,6 +864,8 @@ describe("TelegramAdapter", () => { const sendMessageBody = JSON.parse( String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) ) as { + text: string; + parse_mode?: string; reply_markup?: { inline_keyboard: Array< Array<{ text: string; callback_data?: string; url?: string }> @@ -866,7 +875,8 @@ describe("TelegramAdapter", () => { const row = sendMessageBody.reply_markup?.inline_keyboard[0]; expect(row).toBeDefined(); - expect(sendMessageBody.parse_mode).toBe("Markdown"); + expect(sendMessageBody.parse_mode).toBe("HTML"); + expect(sendMessageBody.text).toContain("Approval needed"); expect(row?.[0]).toEqual({ text: "Approve", callback_data: encodeTelegramCallbackData("approve", "request-123"), @@ -934,7 +944,7 @@ describe("TelegramAdapter", () => { ) as { chat_id: string; draft_id: string; text: string }; const finalBody = JSON.parse( String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) - ) as { chat_id: string; text: string }; + ) as { chat_id: string; text: string; parse_mode?: string }; expect(draftBody1.chat_id).toBe("123"); expect(draftBody2.chat_id).toBe("123"); @@ -943,6 +953,7 @@ describe("TelegramAdapter", () => { expect(draftBody1.draft_id).toBe(draftBody2.draft_id); expect(finalBody.chat_id).toBe("123"); expect(finalBody.text).toBe("hello world"); + expect(finalBody.parse_mode).toBe("HTML"); }); it("falls back to post+edit streaming in non-DM chats", async () => { @@ -1006,12 +1017,13 @@ describe("TelegramAdapter", () => { ) as { chat_id: string; text: string }; const editBody = JSON.parse( String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) - ) as { chat_id: string; text: string }; + ) as { chat_id: string; text: string; parse_mode?: string }; expect(postBody.chat_id).toBe("-100123"); expect(postBody.text).toBe("..."); expect(editBody.chat_id).toBe("-100123"); expect(editBody.text).toBe("hello"); + expect(editBody.parse_mode).toBe("HTML"); }); it("falls back to post+edit when sendMessageDraft is unavailable", async () => { @@ -1128,6 +1140,44 @@ describe("TelegramAdapter", () => { expect(editUrl).toContain("/editMessageText"); }); + it("renders markdown with Telegram HTML parse mode", 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", + }); + + await adapter.initialize(createMockChat()); + + await adapter.postMessage("telegram:123", { + markdown: "**Bold** _italic_ [Docs](https://example.com) `code`", + }); + + const sendMessageBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { text: string; parse_mode?: string }; + + expect(sendMessageBody.parse_mode).toBe("HTML"); + expect(sendMessageBody.text).toContain("Bold"); + expect(sendMessageBody.text).toContain("italic"); + expect(sendMessageBody.text).toContain( + 'Docs' + ); + expect(sendMessageBody.text).toContain("code"); + }); + it("cleans up placeholder and throws when fallback stream is empty", async () => { mockFetch .mockResolvedValueOnce( diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 347b75c0..b26452f7 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -13,6 +13,7 @@ import type { Adapter, AdapterPostableMessage, Attachment, + CardElement, ChannelInfo, ChatInstance, EmojiValue, @@ -64,7 +65,7 @@ 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 TELEGRAM_HTML_PARSE_MODE = "HTML"; const TRAILING_SLASHES_REGEX = /\/+$/; const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; const LEADING_AT_PATTERN = /^@+/; @@ -529,14 +530,11 @@ export class TelegramAdapter const card = extractCard(message); const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined; - const parseMode = card ? TELEGRAM_MARKDOWN_PARSE_MODE : undefined; + const rendered = card + ? this.renderTelegramCardText(card) + : this.renderTelegramText(message); const text = this.truncateMessage( - convertEmojiPlaceholders( - card - ? cardToFallbackText(card) - : this.formatConverter.renderPostable(message), - "gchat" - ) + convertEmojiPlaceholders(rendered.text, "gchat") ); const files = extractFiles(message); @@ -559,7 +557,7 @@ export class TelegramAdapter file, text, replyMarkup, - parseMode + rendered.parseMode ); } else { if (!text.trim()) { @@ -571,7 +569,7 @@ export class TelegramAdapter message_thread_id: parsedThread.messageThreadId, text, reply_markup: replyMarkup, - parse_mode: parseMode, + parse_mode: rendered.parseMode, }); } @@ -616,14 +614,11 @@ export class TelegramAdapter const card = extractCard(message); const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined; - const parseMode = card ? TELEGRAM_MARKDOWN_PARSE_MODE : undefined; + const rendered = card + ? this.renderTelegramCardText(card) + : this.renderTelegramText(message); const text = this.truncateMessage( - convertEmojiPlaceholders( - card - ? cardToFallbackText(card) - : this.formatConverter.renderPostable(message), - "gchat" - ) + convertEmojiPlaceholders(rendered.text, "gchat") ); if (!text.trim()) { @@ -637,7 +632,7 @@ export class TelegramAdapter message_id: telegramMessageId, text, reply_markup: replyMarkup ?? emptyTelegramInlineKeyboard(), - parse_mode: parseMode, + parse_mode: rendered.parseMode, } ); @@ -850,7 +845,7 @@ export class TelegramAdapter } } - return this.postMessage(threadId, rawAccumulated); + return this.postMessage(threadId, { markdown: rawAccumulated }); } async fetchMessages( @@ -1499,7 +1494,9 @@ export class TelegramAdapter } try { - await this.editMessage(threadIdForEdits, posted.id, rawAccumulated); + await this.editMessage(threadIdForEdits, posted.id, { + markdown: rawAccumulated, + }); lastRendered = rendered; lastEditAt = now; } catch (error) { @@ -1526,7 +1523,9 @@ export class TelegramAdapter const finalRendered = this.renderStreamText(rawAccumulated); if (finalRendered.trim() && finalRendered !== lastRendered) { - return this.editMessage(threadIdForEdits, posted.id, rawAccumulated); + return this.editMessage(threadIdForEdits, posted.id, { + markdown: rawAccumulated, + }); } return posted; @@ -1580,6 +1579,48 @@ export class TelegramAdapter return this.truncateMessage(convertEmojiPlaceholders(rawText, "gchat")); } + private renderTelegramCardText(card: CardElement): { + text: string; + parseMode: string; + } { + return { + text: this.formatConverter.fromMarkdown( + cardToFallbackText(card, { boldFormat: "**" }) + ), + parseMode: TELEGRAM_HTML_PARSE_MODE, + }; + } + + private renderTelegramText( + message: AdapterPostableMessage + ): { text: string; parseMode?: string } { + if (typeof message === "string") { + return { text: message }; + } + + if ("raw" in message) { + return { text: message.raw }; + } + + if ("markdown" in message) { + return { + text: this.formatConverter.fromMarkdown(message.markdown), + parseMode: TELEGRAM_HTML_PARSE_MODE, + }; + } + + if ("ast" in message) { + return { + text: this.formatConverter.fromAst(message.ast), + parseMode: TELEGRAM_HTML_PARSE_MODE, + }; + } + + return { + text: this.formatConverter.renderPostable(message), + }; + } + private normalizeUserName(value: unknown): string { if (typeof value !== "string") { return "bot"; diff --git a/packages/adapter-telegram/src/markdown.ts b/packages/adapter-telegram/src/markdown.ts index df8c36df..3fad1616 100644 --- a/packages/adapter-telegram/src/markdown.ts +++ b/packages/adapter-telegram/src/markdown.ts @@ -1,41 +1,152 @@ /** * Telegram format conversion. * - * Telegram supports Markdown/HTML parse modes, but to avoid - * platform-specific escaping pitfalls this adapter emits normalized - * markdown text as plain message text. + * Telegram supports parse modes for rich formatting. + * We emit Telegram-compatible HTML for formatted messages. */ import { - type AdapterPostableMessage, BaseFormatConverter, + type Content, + getNodeChildren, + getNodeValue, + isBlockquoteNode, + isCodeNode, + isDeleteNode, + isEmphasisNode, + isInlineCodeNode, + isLinkNode, + isListItemNode, + isListNode, + isParagraphNode, + isStrongNode, + isTextNode, parseMarkdown, type Root, - stringifyMarkdown, } from "chat"; export class TelegramFormatConverter extends BaseFormatConverter { fromAst(ast: Root): string { - return stringifyMarkdown(ast).trim(); + return this.fromAstWithNodeConverter(ast, (node) => + this.nodeToTelegramHtml(node) + ).trim(); } toAst(text: string): Root { return parseMarkdown(text); } - override renderPostable(message: AdapterPostableMessage): string { - if (typeof message === "string") { - return message; + private nodeToTelegramHtml(node: Content): string { + if (isParagraphNode(node)) { + return getNodeChildren(node) + .map((child) => this.nodeToTelegramHtml(child)) + .join(""); } - if ("raw" in message) { - return message.raw; + + if (isTextNode(node)) { + return this.escapeHtmlText(node.value); + } + + if (isStrongNode(node)) { + const content = getNodeChildren(node) + .map((child) => this.nodeToTelegramHtml(child)) + .join(""); + return `${content}`; + } + + if (isEmphasisNode(node)) { + const content = getNodeChildren(node) + .map((child) => this.nodeToTelegramHtml(child)) + .join(""); + return `${content}`; + } + + if (isDeleteNode(node)) { + const content = getNodeChildren(node) + .map((child) => this.nodeToTelegramHtml(child)) + .join(""); + return `${content}`; + } + + if (isInlineCodeNode(node)) { + return `${this.escapeHtmlText(node.value)}`; + } + + if (isCodeNode(node)) { + const language = node.lang?.trim(); + const escapedCode = this.escapeHtmlText(node.value); + if (language) { + return `
${escapedCode}
`; + } + return `
${escapedCode}
`; + } + + if (isLinkNode(node)) { + const text = getNodeChildren(node) + .map((child) => this.nodeToTelegramHtml(child)) + .join(""); + const label = text || this.escapeHtmlText(node.url); + return `${label}`; + } + + if (isBlockquoteNode(node)) { + const content = getNodeChildren(node) + .map((child) => this.nodeToTelegramHtml(child)) + .join(""); + return content + .split("\n") + .map((line) => `>${line ? ` ${line}` : ""}`) + .join("\n"); } - if ("markdown" in message) { - return this.fromMarkdown(message.markdown); + + if (isListNode(node)) { + return getNodeChildren(node) + .map((item, index) => { + const prefix = node.ordered ? `${index + 1}.` : "•"; + const content = getNodeChildren(item) + .map((child) => this.nodeToTelegramHtml(child)) + .join(""); + return `${prefix} ${content}`; + }) + .join("\n"); + } + + if (isListItemNode(node)) { + return getNodeChildren(node) + .map((child) => this.nodeToTelegramHtml(child)) + .join(""); + } + + if (node.type === "break") { + return "\n"; + } + + if (node.type === "thematicBreak") { + return "──────────"; } - if ("ast" in message) { - return this.fromAst(message.ast); + + if (node.type === "html") { + return this.escapeHtmlText(node.value); } - return super.renderPostable(message); + + const children = getNodeChildren(node); + if (children.length > 0) { + return children.map((child) => this.nodeToTelegramHtml(child)).join(""); + } + + return this.escapeHtmlText(getNodeValue(node)); + } + + private escapeHtmlText(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">"); + } + + private escapeHtmlAttribute(value: string): string { + return this.escapeHtmlText(value) + .replace(/"/g, """) + .replace(/'/g, "'"); } } From 65d2312aafc0adb6ebde0b9d7a5368fce1ed56de Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Tue, 3 Mar 2026 10:07:01 +0100 Subject: [PATCH 09/18] fix(telegram): render markdown during draft streaming --- packages/adapter-telegram/src/index.test.ts | 60 ++++++++++++++++++++- packages/adapter-telegram/src/index.ts | 26 +++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 1423ffe7..06c7de59 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -938,10 +938,20 @@ describe("TelegramAdapter", () => { const draftBody1 = JSON.parse( String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) - ) as { chat_id: string; draft_id: string; text: string }; + ) as { + chat_id: string; + draft_id: string; + text: string; + parse_mode?: string; + }; const draftBody2 = JSON.parse( String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) - ) as { chat_id: string; draft_id: string; text: string }; + ) as { + chat_id: string; + draft_id: string; + text: string; + parse_mode?: string; + }; const finalBody = JSON.parse( String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) ) as { chat_id: string; text: string; parse_mode?: string }; @@ -950,6 +960,8 @@ describe("TelegramAdapter", () => { expect(draftBody2.chat_id).toBe("123"); expect(draftBody1.text).toBe("hello"); expect(draftBody2.text).toBe("hello world"); + expect(draftBody1.parse_mode).toBe("HTML"); + expect(draftBody2.parse_mode).toBe("HTML"); expect(draftBody1.draft_id).toBe(draftBody2.draft_id); expect(finalBody.chat_id).toBe("123"); expect(finalBody.text).toBe("hello world"); @@ -1178,6 +1190,50 @@ describe("TelegramAdapter", () => { expect(sendMessageBody.text).toContain("code"); }); + it("streams markdown drafts with Telegram HTML parse mode", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce(telegramOk(sampleMessage({ text: "done" }))); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const stream = (async function* () { + yield "**bold"; + yield "** text"; + })(); + + await adapter.stream("telegram:123", stream, { + updateIntervalMs: 0, + }); + + const draftBody1 = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { text: string; parse_mode?: string }; + const draftBody2 = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { text: string; parse_mode?: string }; + + expect(draftBody1.parse_mode).toBe("HTML"); + expect(draftBody2.parse_mode).toBe("HTML"); + expect(draftBody2.text).toContain("bold"); + }); + it("cleans up placeholder and throws when fallback stream is empty", async () => { mockFetch .mockResolvedValueOnce( diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index c27a9002..0c090310 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -770,7 +770,7 @@ export class TelegramAdapter } rawAccumulated += next.value; - renderedAccumulated = this.renderStreamText(rawAccumulated); + renderedAccumulated = this.renderStreamMarkdown(rawAccumulated); if (!draftStreamingEnabled || !renderedAccumulated.trim()) { continue; @@ -787,7 +787,11 @@ export class TelegramAdapter } try { - await this.sendDraftMessage(parsedThread.chatId, draftId, renderedAccumulated); + await this.sendDraftMessage( + parsedThread.chatId, + draftId, + renderedAccumulated + ); lastDraftText = renderedAccumulated; lastDraftSentAt = now; draftUpdatesSent += 1; @@ -831,10 +835,14 @@ export class TelegramAdapter throw new ValidationError("telegram", "Message text cannot be empty"); } - renderedAccumulated = this.renderStreamText(rawAccumulated); + renderedAccumulated = this.renderStreamMarkdown(rawAccumulated); if (draftStreamingEnabled && renderedAccumulated !== lastDraftText) { try { - await this.sendDraftMessage(parsedThread.chatId, draftId, renderedAccumulated); + await this.sendDraftMessage( + parsedThread.chatId, + draftId, + renderedAccumulated + ); } catch (error) { this.logger.warn( "Telegram: final sendMessageDraft update failed; sending final message anyway", @@ -1482,7 +1490,7 @@ export class TelegramAdapter for await (const chunk of textStream) { rawAccumulated += chunk; - const rendered = this.renderStreamText(rawAccumulated); + const rendered = this.renderStreamMarkdown(rawAccumulated); const now = Date.now(); const shouldEdit = rendered.trim() && @@ -1521,7 +1529,7 @@ export class TelegramAdapter throw new ValidationError("telegram", "Message text cannot be empty"); } - const finalRendered = this.renderStreamText(rawAccumulated); + const finalRendered = this.renderStreamMarkdown(rawAccumulated); if (finalRendered.trim() && finalRendered !== lastRendered) { return this.editMessage(threadIdForEdits, posted.id, { markdown: rawAccumulated, @@ -1550,6 +1558,7 @@ export class TelegramAdapter chat_id: chatId, draft_id: draftId, text, + parse_mode: TELEGRAM_HTML_PARSE_MODE, }); } @@ -1574,9 +1583,8 @@ export class TelegramAdapter return false; } - private renderStreamText(rawText: string): string { - // Telegram expects Unicode emoji, and the gchat resolver emits Unicode output. - return this.truncateMessage(convertEmojiPlaceholders(rawText, "gchat")); + private renderStreamMarkdown(rawText: string): string { + return this.truncateMessage(this.formatConverter.fromMarkdown(rawText)); } private renderTelegramCardText(card: CardElement): { From 219c235f2f1ead38ec8967c8daf71aced9c45c14 Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Tue, 3 Mar 2026 12:16:57 +0100 Subject: [PATCH 10/18] chore(telegram): fix lint and add changeset --- .changeset/telegram-draft-streaming-polish.md | 5 +++ packages/adapter-telegram/src/index.ts | 32 ++++++++++++------- 2 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 .changeset/telegram-draft-streaming-polish.md diff --git a/.changeset/telegram-draft-streaming-polish.md b/.changeset/telegram-draft-streaming-polish.md new file mode 100644 index 00000000..e2daf7a9 --- /dev/null +++ b/.changeset/telegram-draft-streaming-polish.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/telegram": patch +--- + +Add Telegram `sendMessageDraft` streaming in DMs with post+edit fallback, plus HTML markdown rendering for streamed and posted markdown content. diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 0c090310..d99e3dea 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -772,7 +772,7 @@ export class TelegramAdapter rawAccumulated += next.value; renderedAccumulated = this.renderStreamMarkdown(rawAccumulated); - if (!draftStreamingEnabled || !renderedAccumulated.trim()) { + if (!(draftStreamingEnabled && renderedAccumulated.trim())) { continue; } @@ -1480,7 +1480,10 @@ export class TelegramAdapter 0, options?.updateIntervalMs ?? TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS ); - const posted = await this.postMessage(threadId, TELEGRAM_STREAM_PLACEHOLDER); + const posted = await this.postMessage( + threadId, + TELEGRAM_STREAM_PLACEHOLDER + ); const threadIdForEdits = posted.threadId || threadId; let rawAccumulated = ""; @@ -1520,11 +1523,14 @@ export class TelegramAdapter try { await this.deleteMessage(threadIdForEdits, posted.id); } catch (error) { - this.logger.debug("Telegram: failed to cleanup empty stream placeholder", { - error: String(error), - threadId: threadIdForEdits, - messageId: posted.id, - }); + this.logger.debug( + "Telegram: failed to cleanup empty stream placeholder", + { + error: String(error), + threadId: threadIdForEdits, + messageId: posted.id, + } + ); } throw new ValidationError("telegram", "Message text cannot be empty"); } @@ -1540,7 +1546,10 @@ export class TelegramAdapter } private createDraftId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { return crypto.randomUUID(); } @@ -1599,9 +1608,10 @@ export class TelegramAdapter }; } - private renderTelegramText( - message: AdapterPostableMessage - ): { text: string; parseMode?: string } { + private renderTelegramText(message: AdapterPostableMessage): { + text: string; + parseMode?: string; + } { if (typeof message === "string") { return { text: message }; } From 27c94a6061cefeaebe423b44a1b13406b7a3f020 Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Tue, 3 Mar 2026 13:21:55 +0100 Subject: [PATCH 11/18] feat(chat,telegram): align fallback stream config and blockquotes --- .../chat-stream-options-adapter-fallback.md | 5 ++ packages/adapter-telegram/src/index.test.ts | 54 ++++++++++++++- packages/adapter-telegram/src/index.ts | 61 ++++++++++++----- packages/adapter-telegram/src/markdown.ts | 5 +- packages/chat/src/thread.test.ts | 65 +++++++++++-------- packages/chat/src/thread.ts | 5 +- packages/chat/src/types.ts | 4 +- 7 files changed, 148 insertions(+), 51 deletions(-) create mode 100644 .changeset/chat-stream-options-adapter-fallback.md diff --git a/.changeset/chat-stream-options-adapter-fallback.md b/.changeset/chat-stream-options-adapter-fallback.md new file mode 100644 index 00000000..758c83f4 --- /dev/null +++ b/.changeset/chat-stream-options-adapter-fallback.md @@ -0,0 +1,5 @@ +--- +"chat": patch +--- + +Pass configured fallback streaming options (`updateIntervalMs` and `fallbackPlaceholderText`) through native `adapter.stream()` calls so adapters can align their fallback behavior with `Chat` streaming config. diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 06c7de59..6b6dd4f2 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -1174,7 +1174,8 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); await adapter.postMessage("telegram:123", { - markdown: "**Bold** _italic_ [Docs](https://example.com) `code`", + markdown: + "**Bold** _italic_ [Docs](https://example.com) `code`\n\n> Quote", }); const sendMessageBody = JSON.parse( @@ -1188,6 +1189,57 @@ describe("TelegramAdapter", () => { 'Docs' ); expect(sendMessageBody.text).toContain("code"); + expect(sendMessageBody.text).toContain("
Quote
"); + }); + + it("supports disabling fallback placeholder in non-DM stream fallback", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + chat: { id: -100123, type: "supergroup", title: "General" }, + text: "hello", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const stream = (async function* () { + yield "hello"; + })(); + + await adapter.stream("telegram:-100123", stream, { + updateIntervalMs: 0, + fallbackPlaceholderText: null, + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const sendMessageUrl = String(mockFetch.mock.calls[1]?.[0]); + expect(sendMessageUrl).toContain("/sendMessage"); + + const sendMessageBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { chat_id: string; text: string; parse_mode?: string }; + + expect(sendMessageBody.chat_id).toBe("-100123"); + expect(sendMessageBody.text).toBe("hello"); + expect(sendMessageBody.parse_mode).toBe("HTML"); }); it("streams markdown drafts with Telegram HTML parse mode", async () => { diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index d99e3dea..ba61762f 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -1480,14 +1480,18 @@ export class TelegramAdapter 0, options?.updateIntervalMs ?? TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS ); - const posted = await this.postMessage( - threadId, - TELEGRAM_STREAM_PLACEHOLDER - ); - const threadIdForEdits = posted.threadId || threadId; + const placeholderText = + options?.fallbackPlaceholderText === undefined + ? TELEGRAM_STREAM_PLACEHOLDER + : options.fallbackPlaceholderText; + let posted = + placeholderText === null + ? null + : await this.postMessage(threadId, placeholderText); + let threadIdForEdits = posted?.threadId || threadId; let rawAccumulated = ""; - let lastRendered = TELEGRAM_STREAM_PLACEHOLDER; + let lastRendered = placeholderText ?? ""; let lastEditAt = Date.now(); for await (const chunk of textStream) { @@ -1495,6 +1499,21 @@ export class TelegramAdapter const rendered = this.renderStreamMarkdown(rawAccumulated); const now = Date.now(); + + if (!posted) { + if (!rendered.trim()) { + continue; + } + + posted = await this.postMessage(threadId, { + markdown: rawAccumulated, + }); + threadIdForEdits = posted.threadId || threadId; + lastRendered = rendered; + lastEditAt = now; + continue; + } + const shouldEdit = rendered.trim() && rendered !== lastRendered && @@ -1520,21 +1539,29 @@ export class TelegramAdapter } if (!rawAccumulated.trim()) { - try { - await this.deleteMessage(threadIdForEdits, posted.id); - } catch (error) { - this.logger.debug( - "Telegram: failed to cleanup empty stream placeholder", - { - error: String(error), - threadId: threadIdForEdits, - messageId: posted.id, - } - ); + if (posted) { + try { + await this.deleteMessage(threadIdForEdits, posted.id); + } catch (error) { + this.logger.debug( + "Telegram: failed to cleanup empty stream placeholder", + { + error: String(error), + threadId: threadIdForEdits, + messageId: posted.id, + } + ); + } } throw new ValidationError("telegram", "Message text cannot be empty"); } + if (!posted) { + return this.postMessage(threadId, { + markdown: rawAccumulated, + }); + } + const finalRendered = this.renderStreamMarkdown(rawAccumulated); if (finalRendered.trim() && finalRendered !== lastRendered) { return this.editMessage(threadIdForEdits, posted.id, { diff --git a/packages/adapter-telegram/src/markdown.ts b/packages/adapter-telegram/src/markdown.ts index 3fad1616..892d6996 100644 --- a/packages/adapter-telegram/src/markdown.ts +++ b/packages/adapter-telegram/src/markdown.ts @@ -93,10 +93,7 @@ export class TelegramFormatConverter extends BaseFormatConverter { const content = getNodeChildren(node) .map((child) => this.nodeToTelegramHtml(child)) .join(""); - return content - .split("\n") - .map((line) => `>${line ? ` ${line}` : ""}`) - .join("\n"); + return `
${content}
`; } if (isListNode(node)) { diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index eaefb790..cfe8bb54 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -311,10 +311,7 @@ describe("ThreadImpl", () => { const mockStream = vi .fn() .mockImplementation( - async ( - _threadId: string, - textStream: AsyncIterable - ) => { + async (_threadId: string, textStream: AsyncIterable) => { capturedChunks = []; for await (const chunk of textStream) { capturedChunks.push(chunk); @@ -357,10 +354,7 @@ describe("ThreadImpl", () => { const mockStream = vi .fn() .mockImplementation( - async ( - _threadId: string, - textStream: AsyncIterable - ) => { + async (_threadId: string, textStream: AsyncIterable) => { capturedChunks = []; for await (const chunk of textStream) { capturedChunks.push(chunk); @@ -375,19 +369,11 @@ describe("ThreadImpl", () => { mockAdapter.stream = mockStream; // Simulate an LLM streaming two paragraphs with double newline - const textStream = createTextStream([ - "hello.", - "\n\n", - "how are you?", - ]); + const textStream = createTextStream(["hello.", "\n\n", "how are you?"]); const result = await thread.post(textStream); expect(result.text).toBe("hello.\n\nhow are you?"); - expect(capturedChunks).toEqual([ - "hello.", - "\n\n", - "how are you?", - ]); + expect(capturedChunks).toEqual(["hello.", "\n\n", "how are you?"]); }); it("should concatenate multi-step text without separator (demonstrates bug)", async () => { @@ -395,10 +381,7 @@ describe("ThreadImpl", () => { const mockStream = vi .fn() .mockImplementation( - async ( - _threadId: string, - textStream: AsyncIterable - ) => { + async (_threadId: string, textStream: AsyncIterable) => { capturedChunks = []; for await (const chunk of textStream) { capturedChunks.push(chunk); @@ -431,11 +414,7 @@ describe("ThreadImpl", () => { it("should preserve newlines in fallback streaming path", async () => { mockAdapter.stream = undefined; - const textStream = createTextStream([ - "hello.", - "\n", - "how are you?", - ]); + const textStream = createTextStream(["hello.", "\n", "how are you?"]); const result = await thread.post(textStream); // Final edit should have all accumulated text with newline preserved @@ -488,6 +467,38 @@ describe("ThreadImpl", () => { expect.objectContaining({ recipientUserId: "U456", recipientTeamId: "T123", + updateIntervalMs: 500, + fallbackPlaceholderText: "...", + }) + ); + }); + + it("should pass custom fallback stream config to native stream adapters", async () => { + const mockStream = vi.fn().mockResolvedValue({ + id: "msg-stream", + threadId: "t1", + raw: "Hello", + }); + mockAdapter.stream = mockStream; + + const configuredThread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + streamingUpdateIntervalMs: 123, + fallbackStreamingPlaceholderText: null, + }); + + const textStream = createTextStream(["Hello"]); + await configuredThread.post(textStream); + + expect(mockStream).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.any(Object), + expect.objectContaining({ + updateIntervalMs: 123, + fallbackPlaceholderText: null, }) ); }); diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 2fa43335..463bb5cf 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -413,7 +413,10 @@ export class ThreadImpl> textStream: AsyncIterable ): Promise { // Build streaming options from current message context - const options: StreamOptions = {}; + const options: StreamOptions = { + updateIntervalMs: this._streamingUpdateIntervalMs, + fallbackPlaceholderText: this._fallbackStreamingPlaceholderText, + }; if (this._currentMessage) { options.recipientUserId = this._currentMessage.author.userId; // Extract teamId from raw Slack payload diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index ac4c29b9..3001b666 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -326,13 +326,15 @@ export interface Adapter { * Platform-specific options are passed through to the adapter. */ export interface StreamOptions { + /** Placeholder text for adapter-managed post+edit fallback streams. Set to null to disable. */ + fallbackPlaceholderText?: string | null; /** Slack: The team/workspace ID */ recipientTeamId?: string; /** Slack: The user ID to stream to (for AI assistant context) */ recipientUserId?: string; /** Block Kit elements to attach when stopping the stream (Slack only, via chat.stopStream) */ stopBlocks?: unknown[]; - /** Minimum interval between updates in ms (default: 1000). Used for fallback mode (GChat/Teams). */ + /** Minimum interval between updates in ms (default: 500). */ updateIntervalMs?: number; } From 53a71492a1aa897d6718a9e92c76211a68278ce3 Mon Sep 17 00:00:00 2001 From: Timo Lins Date: Tue, 3 Mar 2026 14:06:08 +0100 Subject: [PATCH 12/18] refactor(telegram): simplify stream option handling --- packages/adapter-telegram/src/index.ts | 69 +++++++++++++++----------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index ba61762f..2e2e42ba 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -749,10 +749,7 @@ export class TelegramAdapter return this.streamViaPostEdit(threadId, textStream, options); } - const updateIntervalMs = Math.max( - 0, - options?.updateIntervalMs ?? TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS - ); + const updateIntervalMs = this.resolveStreamUpdateInterval(options); const iterator = textStream[Symbol.asyncIterator](); const draftId = this.createDraftId(); @@ -801,22 +798,10 @@ export class TelegramAdapter "Telegram: sendMessageDraft unavailable, falling back to post+edit streaming" ); - const resumedTextStream = (async function* ( - consumed: string, - remaining: AsyncIterator - ): AsyncIterable { - if (consumed) { - yield consumed; - } - - while (true) { - const item = await remaining.next(); - if (item.done) { - return; - } - yield item.value; - } - })(rawAccumulated, iterator); + const resumedTextStream = this.resumeStreamFrom( + rawAccumulated, + iterator + ); return this.streamViaPostEdit(threadId, resumedTextStream, options); } @@ -1476,14 +1461,8 @@ export class TelegramAdapter textStream: AsyncIterable, options?: StreamOptions ): Promise> { - const intervalMs = Math.max( - 0, - options?.updateIntervalMs ?? TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS - ); - const placeholderText = - options?.fallbackPlaceholderText === undefined - ? TELEGRAM_STREAM_PLACEHOLDER - : options.fallbackPlaceholderText; + const intervalMs = this.resolveStreamUpdateInterval(options); + const placeholderText = this.resolveFallbackPlaceholderText(options); let posted = placeholderText === null ? null @@ -1572,6 +1551,40 @@ export class TelegramAdapter return posted; } + private resolveStreamUpdateInterval(options?: StreamOptions): number { + return Math.max( + 0, + options?.updateIntervalMs ?? TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS + ); + } + + private resolveFallbackPlaceholderText( + options?: StreamOptions + ): string | null { + return options?.fallbackPlaceholderText === undefined + ? TELEGRAM_STREAM_PLACEHOLDER + : options.fallbackPlaceholderText; + } + + private resumeStreamFrom( + consumed: string, + remaining: AsyncIterator + ): AsyncIterable { + return (async function* () { + if (consumed) { + yield consumed; + } + + while (true) { + const item = await remaining.next(); + if (item.done) { + return; + } + yield item.value; + } + })(); + } + private createDraftId(): string { if ( typeof crypto !== "undefined" && From 2f98450a5633db54f5e9457536a585c6a674ddb1 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Fri, 6 Mar 2026 15:31:52 -0800 Subject: [PATCH 13/18] fix(telegram): add comment explaining isDM heuristic for chat ID sign convention Telegram always assigns negative IDs to groups/supergroups/channels and positive IDs to private chats, making this a reliable heuristic. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-telegram/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 3e39e1fb..117eafa7 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -971,6 +971,9 @@ export class TelegramAdapter isDM(threadId: string): boolean { const { chatId } = this.resolveThreadId(threadId); + // Telegram group/supergroup/channel chat IDs are always negative. + // Private (DM) chat IDs are positive. This heuristic is reliable + // because Telegram never assigns negative IDs to private chats. return !chatId.startsWith("-"); } From 32bd75b8aed1337c07b0efa56e496ec5c4efb772 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Fri, 6 Mar 2026 15:32:45 -0800 Subject: [PATCH 14/18] fix(telegram): fall back to post+edit on mid-stream draft failures Previously, if sendMessageDraft failed after the first successful draft update, the adapter silently disabled draft streaming and the user saw no updates until the final message posted. Now any draft failure immediately falls back to post+edit streaming with the remaining chunks, ensuring a consistent streaming UX. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-telegram/src/index.ts | 33 ++++++++++---------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 117eafa7..1ec3e662 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -786,7 +786,6 @@ export class TelegramAdapter let renderedAccumulated = ""; let lastDraftText = ""; let lastDraftSentAt = 0; - let draftStreamingEnabled = true; let draftUpdatesSent = 0; while (true) { @@ -798,7 +797,7 @@ export class TelegramAdapter rawAccumulated += next.value; renderedAccumulated = this.renderStreamMarkdown(rawAccumulated); - if (!(draftStreamingEnabled && renderedAccumulated.trim())) { + if (!renderedAccumulated.trim()) { continue; } @@ -822,26 +821,20 @@ export class TelegramAdapter lastDraftSentAt = now; draftUpdatesSent += 1; } catch (error) { - if (draftUpdatesSent === 0 && this.isDraftMethodUnsupported(error)) { - this.logger.info( - "Telegram: sendMessageDraft unavailable, falling back to post+edit streaming" - ); - - const resumedTextStream = this.resumeStreamFrom( - rawAccumulated, - iterator - ); + const isUnsupported = + draftUpdatesSent === 0 && this.isDraftMethodUnsupported(error); - return this.streamViaPostEdit(threadId, resumedTextStream, options); - } + this.logger[isUnsupported ? "info" : "warn"]( + `Telegram: sendMessageDraft ${isUnsupported ? "unavailable" : "failed during stream"}, falling back to post+edit streaming`, + isUnsupported ? undefined : { error: String(error) } + ); - this.logger.warn( - "Telegram: sendMessageDraft failed during stream; continuing without draft updates", - { - error: String(error), - } + const resumedTextStream = this.resumeStreamFrom( + rawAccumulated, + iterator ); - draftStreamingEnabled = false; + + return this.streamViaPostEdit(threadId, resumedTextStream, options); } } @@ -850,7 +843,7 @@ export class TelegramAdapter } renderedAccumulated = this.renderStreamMarkdown(rawAccumulated); - if (draftStreamingEnabled && renderedAccumulated !== lastDraftText) { + if (renderedAccumulated !== lastDraftText) { try { await this.sendDraftMessage( parsedThread.chatId, From 1deb8305c0f3e638d8b47cf33c6a43d56405c995 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Fri, 6 Mar 2026 15:33:11 -0800 Subject: [PATCH 15/18] perf(telegram): defer markdown rendering until draft update interval elapses Only call renderStreamMarkdown when the throttle interval has passed, avoiding redundant parse+render cycles on every incoming chunk. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-telegram/src/index.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 1ec3e662..0abd8e2a 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -795,19 +795,18 @@ export class TelegramAdapter } rawAccumulated += next.value; - renderedAccumulated = this.renderStreamMarkdown(rawAccumulated); - if (!renderedAccumulated.trim()) { + const now = Date.now(); + const intervalElapsed = + draftUpdatesSent === 0 || now - lastDraftSentAt >= updateIntervalMs; + + if (!intervalElapsed) { continue; } - const now = Date.now(); - const shouldSendDraft = - draftUpdatesSent === 0 || - (renderedAccumulated !== lastDraftText && - now - lastDraftSentAt >= updateIntervalMs); + renderedAccumulated = this.renderStreamMarkdown(rawAccumulated); - if (!shouldSendDraft) { + if (!renderedAccumulated.trim() || renderedAccumulated === lastDraftText) { continue; } From 7851c477ce6a6ce6a93f0750fb3a4fbf79fe0a8e Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Fri, 6 Mar 2026 15:33:51 -0800 Subject: [PATCH 16/18] perf(telegram): defer markdown rendering in post+edit fallback stream Apply the same time-check-first optimization to streamViaPostEdit: skip renderStreamMarkdown entirely when the throttle interval hasn't elapsed, avoiding redundant parse+render cycles on every chunk. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-telegram/src/index.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 0abd8e2a..196672c4 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -1499,11 +1499,10 @@ export class TelegramAdapter for await (const chunk of textStream) { rawAccumulated += chunk; - - const rendered = this.renderStreamMarkdown(rawAccumulated); const now = Date.now(); if (!posted) { + const rendered = this.renderStreamMarkdown(rawAccumulated); if (!rendered.trim()) { continue; } @@ -1517,12 +1516,12 @@ export class TelegramAdapter continue; } - const shouldEdit = - rendered.trim() && - rendered !== lastRendered && - now - lastEditAt >= intervalMs; + if (now - lastEditAt < intervalMs) { + continue; + } - if (!shouldEdit) { + const rendered = this.renderStreamMarkdown(rawAccumulated); + if (!rendered.trim() || rendered === lastRendered) { continue; } From d14e1c8c1b2be44b672446ca4efac441cfa0084f Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Fri, 6 Mar 2026 15:34:07 -0800 Subject: [PATCH 17/18] refactor(telegram): simplify createDraftId with optional chaining Replace verbose typeof guards with globalThis.crypto?.randomUUID?.() and nullish coalescing fallback. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-telegram/src/index.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 196672c4..56ea825e 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -1609,16 +1609,10 @@ export class TelegramAdapter } private createDraftId(): string { - if ( - typeof crypto !== "undefined" && - typeof crypto.randomUUID === "function" - ) { - return crypto.randomUUID(); - } - - return `draft-${Date.now().toString(36)}-${Math.random() - .toString(36) - .slice(2, 10)}`; + return ( + globalThis.crypto?.randomUUID?.() ?? + `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + ); } private async sendDraftMessage( From e9a666d8460157b83c36a3ef95e8d0e80b5f16c0 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Fri, 6 Mar 2026 15:35:18 -0800 Subject: [PATCH 18/18] style(telegram): fix formatting for lint compliance Co-Authored-By: Claude Opus 4.6 --- packages/adapter-telegram/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 56ea825e..5b2e129d 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -806,7 +806,10 @@ export class TelegramAdapter renderedAccumulated = this.renderStreamMarkdown(rawAccumulated); - if (!renderedAccumulated.trim() || renderedAccumulated === lastDraftText) { + if ( + !renderedAccumulated.trim() || + renderedAccumulated === lastDraftText + ) { continue; }