diff --git a/.changeset/tidy-spoons-stream.md b/.changeset/tidy-spoons-stream.md new file mode 100644 index 000000000..5b7b00fca --- /dev/null +++ b/.changeset/tidy-spoons-stream.md @@ -0,0 +1,6 @@ +--- +"chat": minor +"@chat-adapter/telegram": minor +--- + +Add native Telegram private chat draft streaming with fallback streaming elsewhere. diff --git a/README.md b/README.md index 1a45546eb..ae6fea571 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Browse official, vendor-official, and community adapters on [chat-sdk.dev/adapte ## 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 streaming, Telegram private chat draft previews, 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/adapters/official/telegram.mdx b/apps/docs/content/adapters/official/telegram.mdx index 48942a8f0..bec790f9d 100644 --- a/apps/docs/content/adapters/official/telegram.mdx +++ b/apps/docs/content/adapters/official/telegram.mdx @@ -16,7 +16,7 @@ features: label: Single file streaming: status: partial - label: Post+Edit + label: Private chat drafts / Post+Edit scheduledMessages: no cardFormat: status: partial diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx index ca31f6e9f..a8cdd96a6 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -23,7 +23,7 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid | Edit message | | | | | | | Partial | | | | Delete message | | | | | | | Partial | | | | File uploads | | | | | Single file/media | | | Images, audio, docs | | -| Streaming | Native | Native (DMs) / Buffered | Post+Edit | Post+Edit | Post+Edit | Buffered | Agent sessions / Post+Edit | Buffered | Buffered | +| Streaming | Native | Native (DMs) / Buffered | Post+Edit | Post+Edit | Private chat drafts / Post+Edit | Buffered | Agent sessions / Post+Edit | Buffered | Buffered | | Scheduled messages | Native | | | | | | | | | ### Rich content diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 6961ca0b6..b011f1bb4 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 | Yes | Native (DMs) / Buffered | 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 | Private chat drafts / Post+Edit | Yes | | GitHub | `@chat-adapter/github` | Yes | Yes | No | No | Buffered | No | | Linear | `@chat-adapter/linear` | Yes | Yes | No | No | Agent sessions / Post+Edit | No | | WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | Buffered | Yes | diff --git a/apps/docs/content/docs/streaming.mdx b/apps/docs/content/docs/streaming.mdx index 1e2074702..1740fb11e 100644 --- a/apps/docs/content/docs/streaming.mdx +++ b/apps/docs/content/docs/streaming.mdx @@ -59,10 +59,10 @@ await thread.post(stream); | Platform | Method | Description | |----------|--------|-------------| | Slack | Native streaming API | Uses Slack's `chatStream` for smooth, real-time updates | +| Telegram | Private chat draft previews | Uses Telegram's `sendMessageDraft` in private chats and falls back to post + edit elsewhere | | Teams | Native (DMs) / Buffered (group chats) | Uses the Teams SDK's native `stream.emit()` for direct messages; accumulates chunks and posts one final message when no native streamer is active | | 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 | Post + Edit | Posts a message then edits it as chunks arrive | | GitHub | Buffered | Accumulates chunks and posts one final comment | | Linear | Agent sessions / Post + Edit | Uses agent session activities in agent-session threads; falls back to post+edit comments in issue threads | | WhatsApp | Buffered | Accumulates chunks and sends one final message | diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index 923c8c0f4..0a2a9ec3c 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -144,7 +144,7 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org | Delete message | Yes | | File uploads | Single file (`sendDocument`) | | Attachment uploads | Single image/audio/video/file (`sendPhoto`, `sendAudio`, `sendVideo`, `sendDocument`) | -| Streaming | Post+Edit fallback | +| Streaming | Private chat draft previews + post/edit fallback | ### Rich content diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 76223c9fb..f96dda3a4 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -911,6 +911,360 @@ describe("TelegramAdapter", () => { expect(sendMessageBody.text).toBe("hello"); }); + it("streams draft updates for private chats and sends a final message", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .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()); + + async function* textStream(): AsyncIterable { + yield "hello"; + yield " world"; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: 0, + }); + + expect(result).not.toBeNull(); + expect(result?.id).toBe("123:11"); + expect(result?.threadId).toBe("telegram:123"); + + const initialDraftUrl = String(mockFetch.mock.calls[1]?.[0]); + const firstDraftUrl = String(mockFetch.mock.calls[2]?.[0]); + const secondDraftUrl = String(mockFetch.mock.calls[3]?.[0]); + const finalSendUrl = String(mockFetch.mock.calls[4]?.[0]); + + expect(initialDraftUrl).toContain("/sendMessageDraft"); + expect(firstDraftUrl).toContain("/sendMessageDraft"); + expect(secondDraftUrl).toContain("/sendMessageDraft"); + expect(finalSendUrl).toContain("/sendMessage"); + + const initialDraftBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { + chat_id: string; + draft_id: number; + parse_mode?: string; + text: string; + }; + + const firstDraftBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { + chat_id: string; + draft_id: number; + parse_mode?: string; + text: string; + }; + + const secondDraftBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + ) as { + chat_id: string; + draft_id: number; + parse_mode?: string; + text: string; + }; + + const finalSendBody = JSON.parse( + String((mockFetch.mock.calls[4]?.[1] as RequestInit).body) + ) as { chat_id: string; parse_mode?: string; text: string }; + + expect(initialDraftBody.chat_id).toBe("123"); + expect(initialDraftBody.text).toBe(""); + expect(initialDraftBody.parse_mode).toBeUndefined(); + expect(firstDraftBody.chat_id).toBe("123"); + expect(firstDraftBody.draft_id).toBe(initialDraftBody.draft_id); + expect(firstDraftBody.text).toBe("hello"); + expect(firstDraftBody.parse_mode).toBe("MarkdownV2"); + expect(secondDraftBody.draft_id).toBe(firstDraftBody.draft_id); + expect(secondDraftBody.text).toBe("hello world"); + expect(finalSendBody.chat_id).toBe("123"); + expect(finalSendBody.text).toBe("hello world"); + expect(finalSendBody.parse_mode).toBe("MarkdownV2"); + }); + + it("keeps markdown parse mode for an exact-limit draft and final message", async () => { + const longMarkdown = `${"a".repeat(3494)}**ok**`; + const renderedMarkdown = `${"a".repeat(3494)}*ok*`; + const requestBodies: Array<{ + method: string; + body: { parse_mode?: string; text?: string }; + }> = []; + let nextMessageId = 41; + + mockFetch.mockImplementation(async (input, init) => { + const url = String(input); + const method = url.split("/").at(-1) ?? url; + const rawBody = (init as RequestInit | undefined)?.body; + const body = + typeof rawBody === "string" + ? (JSON.parse(rawBody) as { parse_mode?: string; text?: string }) + : {}; + + requestBodies.push({ method, body }); + + if (method === "getMe") { + return telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }); + } + + if (method === "sendMessageDraft") { + return telegramOk(true); + } + + if (method === "sendMessage") { + return telegramOk( + sampleMessage({ + message_id: nextMessageId++, + text: `${"a".repeat(3494)}ok`, + }) + ); + } + + throw new Error(`Unexpected Telegram method in test: ${method}`); + }); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + async function* textStream(): AsyncIterable { + yield longMarkdown; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: Number.MAX_SAFE_INTEGER, + }); + + expect(result).not.toBeNull(); + expect( + requestBodies.map((request) => ({ + method: request.method, + len: request.body.text?.length, + tail: request.body.text?.slice(-10), + parse_mode: request.body.parse_mode, + })) + ).toEqual([ + { + method: "getMe", + len: undefined, + tail: undefined, + parse_mode: undefined, + }, + { + method: "sendMessageDraft", + len: 0, + tail: "", + parse_mode: undefined, + }, + { + method: "sendMessageDraft", + len: renderedMarkdown.length, + tail: renderedMarkdown.slice(-10), + parse_mode: "MarkdownV2", + }, + { + method: "sendMessage", + len: renderedMarkdown.length, + tail: renderedMarkdown.slice(-10), + parse_mode: "MarkdownV2", + }, + ]); + + const draftBody = requestBodies[2]?.body as { + parse_mode?: string; + text: string; + }; + const finalSendBody = requestBodies[3]?.body as { + parse_mode?: string; + text: string; + }; + + expect(draftBody.parse_mode).toBe("MarkdownV2"); + expect(draftBody.text).toBe(renderedMarkdown); + expect(finalSendBody.parse_mode).toBe("MarkdownV2"); + expect(finalSendBody.text).toBe(renderedMarkdown); + }); + + it("returns null for non-DM streaming so Chat SDK can use fallback streaming", async () => { + mockFetch.mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + async function* textStream(): AsyncIterable { + yield "hello"; + } + + const result = await adapter.stream("telegram:-100123", textStream(), { + updateIntervalMs: 0, + }); + + expect(result).toBeNull(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("falls back to a final message when draft streaming updates fail", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramError(400, 400, "Bad Request: chat not found") + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "hello world", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + async function* textStream(): AsyncIterable { + yield "hello"; + yield " world"; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: 0, + }); + + expect(result?.id).toBe("123:11"); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Telegram draft streaming update failed", + expect.objectContaining({ + threadId: "telegram:123", + }) + ); + + const finalSendUrl = String(mockFetch.mock.calls[2]?.[0]); + expect(finalSendUrl).toContain("/sendMessage"); + }); + + it("continues to the final message when markdown draft retry also fails", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramError( + 400, + 400, + "Bad Request: can't parse entities: Can't find end of the entity" + ) + ) + .mockResolvedValueOnce( + telegramError(429, 429, "Too Many Requests: retry later") + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "**broken", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + async function* textStream(): AsyncIterable { + yield "**broken"; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: 0, + }); + + expect(result).not.toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Telegram draft streaming update failed", + expect.objectContaining({ + threadId: "telegram:123", + }) + ); + + const finalSendBody = JSON.parse( + String((mockFetch.mock.calls[4]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + + expect(finalSendBody.parse_mode).toBeUndefined(); + expect(finalSendBody.text).toBe("**broken"); + }); + it("postChannelMessage does not double-prefix channel ID", async () => { mockFetch .mockResolvedValueOnce( @@ -1375,6 +1729,373 @@ describe("TelegramAdapter", () => { expect(sendMessageBody.text).toBe("raw.unparsed!(text)"); }); + it("retries markdown messages without parse_mode when Telegram can't parse entities", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramError( + 400, + 400, + "Bad Request: can't parse entities: Can't find end of the entity" + ) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "**broken", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const result = await adapter.postMessage("telegram:123", { + markdown: "**broken", + }); + + expect(result.id).toBe("123:11"); + + const firstSendBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + const secondSendBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + + expect(firstSendBody.parse_mode).toBe("MarkdownV2"); + expect(secondSendBody.parse_mode).toBeUndefined(); + expect(secondSendBody.text).toBe("**broken"); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Telegram markdown parse failed; retrying without parse mode", + expect.objectContaining({ + method: "sendMessage", + threadId: "telegram:123", + }) + ); + }); + + it("retries markdown messages with original text when plain-text fallback would be empty", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramError( + 400, + 400, + "Bad Request: can't parse entities: Can't find end of the entity" + ) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "**", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const result = await adapter.postMessage("telegram:123", { + markdown: "**", + }); + + expect(result.id).toBe("123:11"); + + const secondSendBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + + expect(secondSendBody.parse_mode).toBeUndefined(); + expect(secondSendBody.text).toBe("**"); + }); + + it("does not swallow non-parse validation errors during markdown send", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce( + telegramError(400, 400, "Bad Request: chat not found") + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + await expect( + adapter.postMessage("telegram:123", { + markdown: "**broken**", + }) + ).rejects.toThrow("Bad Request: chat not found"); + }); + + it("retries markdown edits without parse_mode when Telegram can't parse entities", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(sampleMessage())) + .mockResolvedValueOnce( + telegramError( + 400, + 400, + "Bad Request: can't parse entities: Can't find end of the entity" + ) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "**broken", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const posted = await adapter.postMessage("telegram:123", "hello"); + const result = await adapter.editMessage("telegram:123", posted.id, { + markdown: "**broken", + }); + + expect(result.id).toBe("123:11"); + + const firstEditBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + const secondEditBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + + expect(firstEditBody.parse_mode).toBe("MarkdownV2"); + expect(secondEditBody.parse_mode).toBeUndefined(); + expect(secondEditBody.text).toBe("**broken"); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Telegram markdown parse failed; retrying without parse mode", + expect.objectContaining({ + messageId: posted.id, + method: "editMessageText", + threadId: "telegram:123", + }) + ); + }); + + it("retries markdown edits with original text when plain-text fallback would be empty", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(sampleMessage())) + .mockResolvedValueOnce( + telegramError( + 400, + 400, + "Bad Request: can't parse entities: Can't find end of the entity" + ) + ) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "**", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + const posted = await adapter.postMessage("telegram:123", "hello"); + const result = await adapter.editMessage("telegram:123", posted.id, { + markdown: "**", + }); + + expect(result.id).toBe("123:11"); + + const secondEditBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + + expect(secondEditBody.parse_mode).toBeUndefined(); + expect(secondEditBody.text).toBe("**"); + }); + + it("falls back to plain-text draft and final send when Telegram can't parse streamed markdown", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramError( + 400, + 400, + "Bad Request: can't parse entities: Can't find end of the entity" + ) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "**broken", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + async function* textStream(): AsyncIterable { + yield "**broken"; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: 0, + }); + + expect(result?.id).toBe("123:11"); + + const retryDraftBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + const finalSendBody = JSON.parse( + String((mockFetch.mock.calls[4]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + + expect(retryDraftBody.parse_mode).toBeUndefined(); + expect(retryDraftBody.text).toBe("**broken"); + expect(finalSendBody.parse_mode).toBeUndefined(); + expect(finalSendBody.text).toBe("**broken"); + }); + + it("reuses original text when streamed plain-text fallback would be empty", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramError( + 400, + 400, + "Bad Request: can't parse entities: Can't find end of the entity" + ) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + text: "**", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + async function* textStream(): AsyncIterable { + yield "**"; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: 0, + }); + + expect(result?.id).toBe("123:11"); + + const retryDraftBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + const finalSendBody = JSON.parse( + String((mockFetch.mock.calls[4]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + + expect(retryDraftBody.parse_mode).toBeUndefined(); + expect(retryDraftBody.text).toBe("**"); + expect(finalSendBody.parse_mode).toBeUndefined(); + expect(finalSendBody.text).toBe("**"); + }); + it("posts cards with inline keyboard buttons", async () => { mockFetch .mockResolvedValueOnce( diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index effac2c4d..22bdbf079 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -23,6 +23,8 @@ import type { FormattedContent, Logger, RawMessage, + StreamChunk, + StreamOptions, ThreadInfo, UserInfo, WebhookOptions, @@ -33,7 +35,10 @@ import { defaultEmojiResolver, getEmoji, Message, + markdownToPlainText, NotImplementedError, + StreamingMarkdownRenderer, + toPlainText, } from "chat"; import { cardToTelegramInlineKeyboard, @@ -94,6 +99,9 @@ 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_DEFAULT_STREAM_UPDATE_INTERVAL_MS = 250; +const TELEGRAM_MARKDOWN_PARSE_ERROR_PATTERN = + /can't parse (?:caption )?entities/i; const TELEGRAM_MAX_POLLING_LIMIT = 100; const TELEGRAM_MIN_POLLING_LIMIT = 1; const TELEGRAM_MIN_POLLING_TIMEOUT_SECONDS = 0; @@ -233,6 +241,7 @@ export class TelegramAdapter private pollingAbortController: AbortController | null = null; private pollingTask: Promise | null = null; private pollingActive = false; + private nextDraftId = Math.max(1, Date.now() % 2_147_483_647); get botUserId(): string | undefined { return this._botUserId; @@ -706,6 +715,14 @@ export class TelegramAdapter const card = extractCard(message); const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined; const parseMode = this.resolveParseMode(message, card); + const plainText = truncateForTelegram( + convertEmojiPlaceholders( + this.renderPlainTextMessage(message, card), + "gchat" + ), + TELEGRAM_MESSAGE_LIMIT, + "plain" + ); const text = truncateForTelegram( convertEmojiPlaceholders( card @@ -753,6 +770,7 @@ export class TelegramAdapter parsedThread, file, text, + plainText, replyMarkup, parseMode ); @@ -768,6 +786,7 @@ export class TelegramAdapter parsedThread, attachment, text, + plainText, replyMarkup, parseMode ); @@ -776,13 +795,23 @@ export class TelegramAdapter throw new ValidationError("telegram", "Message text cannot be empty"); } - rawMessage = await this.telegramFetch("sendMessage", { - chat_id: parsedThread.chatId, - message_thread_id: parsedThread.messageThreadId, - text, - reply_markup: replyMarkup, - parse_mode: toBotApiParseMode(parseMode), - }); + rawMessage = await this.withTelegramMarkdownFallback( + parseMode, + (resolvedParseMode, resolvedText) => + this.telegramFetch("sendMessage", { + chat_id: parsedThread.chatId, + message_thread_id: parsedThread.messageThreadId, + text: resolvedText, + reply_markup: replyMarkup, + parse_mode: toBotApiParseMode(resolvedParseMode), + }), + { + initialText: text, + fallbackText: plainText, + method: "sendMessage", + threadId, + } + ); } const resultingThreadId = this.encodeThreadId({ @@ -826,6 +855,14 @@ export class TelegramAdapter const card = extractCard(message); const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined; const parseMode = this.resolveParseMode(message, card); + const plainText = truncateForTelegram( + convertEmojiPlaceholders( + this.renderPlainTextMessage(message, card), + "gchat" + ), + TELEGRAM_MESSAGE_LIMIT, + "plain" + ); const text = truncateForTelegram( convertEmojiPlaceholders( card @@ -843,14 +880,22 @@ export class TelegramAdapter throw new ValidationError("telegram", "Message text cannot be empty"); } - const result = await this.telegramFetch( - "editMessageText", + const result = await this.withTelegramMarkdownFallback( + parseMode, + (resolvedParseMode, resolvedText) => + this.telegramFetch("editMessageText", { + chat_id: chatId, + message_id: telegramMessageId, + text: resolvedText, + reply_markup: replyMarkup ?? emptyTelegramInlineKeyboard(), + parse_mode: toBotApiParseMode(resolvedParseMode), + }), { - chat_id: chatId, - message_id: telegramMessageId, - text, - reply_markup: replyMarkup ?? emptyTelegramInlineKeyboard(), - parse_mode: toBotApiParseMode(parseMode), + initialText: text, + fallbackText: plainText, + messageId, + method: "editMessageText", + threadId, } ); @@ -955,6 +1000,163 @@ export class TelegramAdapter }); } + async stream( + threadId: string, + textStream: AsyncIterable, + options?: StreamOptions + ): Promise | null> { + if (!this.isDM(threadId)) { + return null; + } + + const parsedThread = this.resolveThreadId(threadId); + const updateIntervalMs = this.clampInteger( + options?.updateIntervalMs, + TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS, + 0, + Number.MAX_SAFE_INTEGER + ); + + const renderer = new StreamingMarkdownRenderer(); + const draftId = this.createDraftId(); + let accumulated = ""; + let lastDraftText: string | null = null; + let lastFlushAt = 0; + let draftStreamingEnabled = true; + let streamUsesMarkdown = true; + + const renderMarkdownForTelegram = (text: string): string => + convertEmojiPlaceholders( + this.formatConverter.fromMarkdown(text), + "gchat" + ); + + const renderMarkdownText = (text: string): string => + truncateForTelegram( + renderMarkdownForTelegram(text), + TELEGRAM_MESSAGE_LIMIT, + "MarkdownV2" + ); + + const renderPlainText = (text: string): string => + truncateForTelegram( + this.resolveTelegramFallbackText(text, markdownToPlainText(text)), + TELEGRAM_MESSAGE_LIMIT, + "plain" + ); + + const sendDraft = async ( + text: string, + useMarkdown: boolean + ): Promise => { + if (!draftStreamingEnabled || text === lastDraftText) { + return; + } + + try { + if (useMarkdown) { + await this.telegramFetch("sendMessageDraft", { + chat_id: parsedThread.chatId, + message_thread_id: parsedThread.messageThreadId, + draft_id: draftId, + text, + parse_mode: toBotApiParseMode("MarkdownV2"), + }); + } else { + await this.telegramFetch("sendMessageDraft", { + chat_id: parsedThread.chatId, + message_thread_id: parsedThread.messageThreadId, + draft_id: draftId, + text, + }); + } + lastDraftText = text; + lastFlushAt = Date.now(); + } catch (error) { + if (useMarkdown && this.isTelegramMarkdownParseError(error)) { + streamUsesMarkdown = false; + + const plainDraftText = renderPlainText(accumulated); + + try { + await this.telegramFetch("sendMessageDraft", { + chat_id: parsedThread.chatId, + message_thread_id: parsedThread.messageThreadId, + draft_id: draftId, + text: plainDraftText, + }); + lastDraftText = plainDraftText; + lastFlushAt = Date.now(); + } catch (retryError) { + draftStreamingEnabled = false; + this.logger.warn("Telegram draft streaming update failed", { + error: String(retryError), + threadId, + }); + } + return; + } + + draftStreamingEnabled = false; + this.logger.warn("Telegram draft streaming update failed", { + error: String(error), + threadId, + }); + } + }; + + const flushDraft = async (): Promise => { + if (!draftStreamingEnabled) { + return; + } + + const draftText = streamUsesMarkdown + ? renderMarkdownText(renderer.render()) + : renderPlainText(accumulated); + await sendDraft(draftText, streamUsesMarkdown); + }; + + await sendDraft("", false); + + for await (const chunk of textStream) { + let text: string | null = null; + if (typeof chunk === "string") { + text = chunk; + } else if (chunk.type === "markdown_text") { + text = chunk.text; + } + + if (text === null) { + continue; + } + + accumulated += text; + renderer.push(text); + + if (Date.now() - lastFlushAt >= updateIntervalMs) { + await flushDraft(); + } + } + + await flushDraft(); + + if (!accumulated.trim()) { + throw new ValidationError( + "telegram", + "Telegram streaming requires text content" + ); + } + + const finalPostable: AdapterPostableMessage = streamUsesMarkdown + ? { markdown: accumulated } + : this.resolveTelegramFallbackText( + accumulated, + markdownToPlainText(accumulated) + ); + + return this.postMessage(threadId, finalPostable); + } + async fetchMessages( threadId: string, options: FetchOptions = {} @@ -1308,11 +1510,46 @@ export class TelegramAdapter mimeType?: string; }, text: string, + plainText: string, replyMarkup?: TelegramInlineKeyboardMarkup, parseMode: TelegramParseMode = "plain" ): Promise { const buffer = await this.toTelegramBuffer(file.data); + return this.withTelegramMarkdownFallback( + parseMode, + (resolvedParseMode, resolvedText) => + this.telegramFetch( + "sendDocument", + this.createTelegramDocumentFormData( + thread, + file, + buffer, + resolvedText, + replyMarkup, + resolvedParseMode + ) + ), + { + initialText: text, + fallbackText: plainText, + method: "sendDocument", + threadId: this.encodeThreadId(thread), + } + ); + } + + private createTelegramDocumentFormData( + thread: TelegramThreadId, + file: { + filename: string; + mimeType?: string; + }, + buffer: Buffer, + text: string, + replyMarkup?: TelegramInlineKeyboardMarkup, + parseMode: TelegramParseMode = "plain" + ): FormData { const formData = new FormData(); formData.append("chat_id", thread.chatId); if (typeof thread.messageThreadId === "number") { @@ -1338,13 +1575,14 @@ export class TelegramAdapter formData.append("reply_markup", JSON.stringify(replyMarkup)); } - return this.telegramFetch("sendDocument", formData); + return formData; } protected async sendAttachment( thread: TelegramThreadId, attachment: Attachment, text: string, + plainText: string, replyMarkup?: TelegramInlineKeyboardMarkup, parseMode: TelegramParseMode = "plain" ): Promise { @@ -1360,81 +1598,97 @@ export class TelegramAdapter ); } - if (!data) { - const payload: Record = { - chat_id: thread.chatId, - [upload.field]: attachment.url, - }; + const buffer = data ? await this.toTelegramBuffer(data) : undefined; - if (typeof thread.messageThreadId === "number") { - payload.message_thread_id = thread.messageThreadId; - } + return this.withTelegramMarkdownFallback( + parseMode, + (resolvedParseMode, resolvedText) => { + if (!buffer) { + const payload: Record = { + chat_id: thread.chatId, + [upload.field]: attachment.url, + }; - if (text.trim()) { - payload.caption = truncateForTelegram( - text, - TELEGRAM_CAPTION_LIMIT, - parseMode - ); - const botApiParseMode = toBotApiParseMode(parseMode); - if (botApiParseMode) { - payload.parse_mode = botApiParseMode; - } - } + if (typeof thread.messageThreadId === "number") { + payload.message_thread_id = thread.messageThreadId; + } - if (attachment.type === "video") { - if (Number.isInteger(attachment.width)) { - payload.width = attachment.width; - } - if (Number.isInteger(attachment.height)) { - payload.height = attachment.height; - } - } + if (resolvedText.trim()) { + payload.caption = truncateForTelegram( + resolvedText, + TELEGRAM_CAPTION_LIMIT, + resolvedParseMode + ); + const botApiParseMode = toBotApiParseMode(resolvedParseMode); + if (botApiParseMode) { + payload.parse_mode = botApiParseMode; + } + } - if (replyMarkup) { - payload.reply_markup = replyMarkup; - } + if (attachment.type === "video") { + if (Number.isInteger(attachment.width)) { + payload.width = attachment.width; + } + if (Number.isInteger(attachment.height)) { + payload.height = attachment.height; + } + } - return this.telegramFetch(upload.method, payload); - } + if (replyMarkup) { + payload.reply_markup = replyMarkup; + } - const buffer = await this.toTelegramBuffer(data); - const formData = new FormData(); + return this.telegramFetch(upload.method, payload); + } - formData.append("chat_id", thread.chatId); - if (typeof thread.messageThreadId === "number") { - formData.append("message_thread_id", String(thread.messageThreadId)); - } + const formData = new FormData(); - if (text.trim()) { - formData.append( - "caption", - truncateForTelegram(text, TELEGRAM_CAPTION_LIMIT, parseMode) - ); - const botApiParseMode = toBotApiParseMode(parseMode); - if (botApiParseMode) { - formData.append("parse_mode", botApiParseMode); - } - } + formData.append("chat_id", thread.chatId); + if (typeof thread.messageThreadId === "number") { + formData.append("message_thread_id", String(thread.messageThreadId)); + } - if (attachment.type === "video") { - if (Number.isInteger(attachment.width)) { - formData.append("width", String(attachment.width)); - } - if (Number.isInteger(attachment.height)) { - formData.append("height", String(attachment.height)); - } - } + if (resolvedText.trim()) { + formData.append( + "caption", + truncateForTelegram( + resolvedText, + TELEGRAM_CAPTION_LIMIT, + resolvedParseMode + ) + ); + const botApiParseMode = toBotApiParseMode(resolvedParseMode); + if (botApiParseMode) { + formData.append("parse_mode", botApiParseMode); + } + } - const blob = new Blob([new Uint8Array(buffer)], { - type: attachment.mimeType ?? "application/octet-stream", - }); - formData.append(upload.field, blob, attachment.name ?? "attachment"); - if (replyMarkup) { - formData.append("reply_markup", JSON.stringify(replyMarkup)); - } + if (attachment.type === "video") { + if (Number.isInteger(attachment.width)) { + formData.append("width", String(attachment.width)); + } + if (Number.isInteger(attachment.height)) { + formData.append("height", String(attachment.height)); + } + } - return this.telegramFetch(upload.method, formData); + const blob = new Blob([new Uint8Array(buffer)], { + type: attachment.mimeType ?? "application/octet-stream", + }); + formData.append(upload.field, blob, attachment.name ?? "attachment"); + if (replyMarkup) { + formData.append("reply_markup", JSON.stringify(replyMarkup)); + } + + return this.telegramFetch(upload.method, formData); + }, + { + initialText: text, + fallbackText: plainText, + method: upload.method, + threadId: this.encodeThreadId(thread), + } + ); } protected async toTelegramBuffer( @@ -1550,6 +1804,12 @@ export class TelegramAdapter return match ? Number.parseInt(match[1], 10) : 0; } + protected createDraftId(): number { + this.nextDraftId = + this.nextDraftId >= 2_147_483_647 ? 1 : this.nextDraftId + 1; + return this.nextDraftId; + } + protected resolveThreadId(value: string): TelegramThreadId { if (value.startsWith("telegram:")) { return this.decodeThreadId(value); @@ -1724,6 +1984,38 @@ export class TelegramAdapter return "MarkdownV2"; } + protected renderPlainTextMessage( + message: AdapterPostableMessage, + card: ReturnType + ): string { + if (card) { + return cardToFallbackText(card); + } + if (typeof message === "string") { + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.resolveTelegramFallbackText( + message.markdown, + markdownToPlainText(message.markdown) + ); + } + if ("ast" in message) { + return toPlainText(message.ast); + } + return this.formatConverter.renderPostable(message); + } + + protected resolveTelegramFallbackText( + originalText: string, + fallbackText: string + ): string { + return fallbackText.trim() ? fallbackText : originalText; + } + protected toTelegramReaction( emoji: EmojiValue | string ): TelegramReactionType { @@ -1999,6 +2291,53 @@ export class TelegramAdapter `${description} (status ${status}, error ${errorCode})` ); } + + protected async withTelegramMarkdownFallback( + parseMode: TelegramParseMode, + operation: (parseMode: TelegramParseMode, text: string) => Promise, + context: { + initialText: string; + fallbackText: string; + method: string; + messageId?: string; + threadId?: string; + } + ): Promise { + try { + return await operation(parseMode, context.initialText); + } catch (error) { + if ( + parseMode !== "MarkdownV2" || + !this.isTelegramMarkdownParseError(error) + ) { + throw error; + } + + this.logger.warn( + "Telegram markdown parse failed; retrying without parse mode", + { + error: String(error), + ...context, + } + ); + + return operation( + "plain", + this.resolveTelegramFallbackText( + context.initialText, + context.fallbackText + ) + ); + } + } + + protected isTelegramMarkdownParseError(error: unknown): boolean { + return ( + error instanceof ValidationError && + error.adapter === "telegram" && + TELEGRAM_MARKDOWN_PARSE_ERROR_PATTERN.test(error.message) + ); + } } export function createTelegramAdapter( diff --git a/packages/adapter-telegram/vitest.config.ts b/packages/adapter-telegram/vitest.config.ts index edc2d946b..f9fe2900d 100644 --- a/packages/adapter-telegram/vitest.config.ts +++ b/packages/adapter-telegram/vitest.config.ts @@ -1,6 +1,12 @@ +import { resolve } from "node:path"; import { defineProject } from "vitest/config"; export default defineProject({ + resolve: { + alias: { + chat: resolve(import.meta.dirname, "../chat/src/index.ts"), + }, + }, test: { globals: true, environment: "node", diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index 455fbc983..3a551e815 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -217,6 +217,28 @@ describe("ThreadImpl", () => { expect(mockAdapter.postMessage).not.toHaveBeenCalled(); }); + it("should fall back when adapter.stream returns null", async () => { + mockAdapter.stream = vi.fn().mockResolvedValue(null); + + const textStream = createTextStream(["Hello", " ", "World"]); + await thread.post(textStream); + + expect(mockAdapter.stream).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.any(Object), + expect.objectContaining({ updateIntervalMs: 500 }) + ); + expect(mockAdapter.postMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "..." + ); + expect(mockAdapter.editMessage).toHaveBeenLastCalledWith( + "slack:C123:1234.5678", + "msg-1", + { markdown: "Hello World" } + ); + }); + it("should fall back to post+edit when adapter has no native streaming", async () => { // Ensure no stream method mockAdapter.stream = undefined; diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index b0cad54a2..8dd2772b8 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -604,7 +604,10 @@ export class ThreadImpl> // Normalize: handles plain strings, AI SDK fullStream events, and StreamChunk objects const textStream = fromFullStream(rawStream); // Build streaming options from current message context + caller options - const options: StreamOptions = { ...callerOptions }; + const options: StreamOptions = { + updateIntervalMs: this._streamingUpdateIntervalMs, + ...callerOptions, + }; if (this._currentMessage) { options.recipientUserId = this._currentMessage.author.userId; // recipientTeamId is only consumed by the Slack adapter; other adapters @@ -642,17 +645,19 @@ export class ThreadImpl> }; const raw = await this.adapter.stream(this.id, wrappedStream, options); - const sent = this.createSentMessage( - raw.id, - { markdown: accumulated }, - raw.threadId - ); + if (raw) { + const sent = this.createSentMessage( + raw.id, + { markdown: accumulated }, + raw.threadId + ); + + if (this._threadHistory) { + await this._threadHistory.append(this.id, new Message(sent)); + } - if (this._threadHistory) { - await this._threadHistory.append(this.id, new Message(sent)); + return sent; } - - return sent; } // Fallback: post + edit with throttling. diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 31e2a3804..87ff780d2 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -528,7 +528,9 @@ export interface Adapter { * Stream a message using platform-native streaming APIs. * * The adapter consumes the async iterable and handles the entire streaming lifecycle. - * Only available on platforms with native streaming support (e.g., Slack). + * Available on platforms with native streaming or preview APIs. + * Adapters may return `null` before consuming any chunks to delegate back to + * Chat SDK's built-in post+edit fallback for the current thread. * * The stream can yield plain strings (text chunks) or {@link StreamChunk} objects * for rich content like task progress cards. Adapters that don't support structured @@ -537,13 +539,13 @@ export interface Adapter { * @param threadId - The thread to stream to * @param textStream - Async iterable of text chunks or structured StreamChunk objects * @param options - Platform-specific streaming options - * @returns The raw message after streaming completes + * @returns The raw message after streaming completes, or `null` to use core fallback */ stream?( threadId: string, textStream: AsyncIterable, options?: StreamOptions - ): Promise>; + ): Promise | null>; /** Bot username (can override global userName) */ readonly userName: string; } @@ -1153,8 +1155,8 @@ export interface Thread, TRawMessage = unknown> * Post a message to this thread. * * Supports text, markdown, cards, and streaming from async iterables. - * When posting a stream (e.g., from AI SDK), uses platform-native streaming - * APIs when available (Slack), or falls back to post + edit with throttling. + * When posting a stream (e.g., from AI SDK), uses adapter-native streaming + * when available, or falls back to post + edit with throttling. * * @param message - String, PostableMessage, JSX Card, or AsyncIterable * @returns A SentMessage with methods to edit, delete, or add reactions