From d8ae96d1dba40062def639959ced2fda31410208 Mon Sep 17 00:00:00 2001 From: luren Date: Tue, 7 Apr 2026 07:23:05 +0800 Subject: [PATCH 1/3] feat(telegram): add native DM draft streaming --- .changeset/tidy-spoons-stream.md | 6 + README.md | 42 +- apps/docs/content/docs/adapters.mdx | 114 +- apps/docs/content/docs/index.mdx | 17 +- apps/docs/content/docs/streaming.mdx | 99 +- packages/adapter-telegram/README.md | 14 +- packages/adapter-telegram/src/index.test.ts | 1284 +++++++++++++++--- packages/adapter-telegram/src/index.ts | 782 ++++++----- packages/adapter-telegram/vitest.config.ts | 6 + packages/chat/src/index.ts | 2 + packages/chat/src/streaming-markdown.test.ts | 61 + packages/chat/src/streaming-markdown.ts | 143 +- packages/chat/src/thread.test.ts | 1169 ++-------------- packages/chat/src/thread.ts | 292 ++-- packages/chat/src/types.ts | 33 +- 15 files changed, 2097 insertions(+), 1967 deletions(-) create mode 100644 .changeset/tidy-spoons-stream.md diff --git a/.changeset/tidy-spoons-stream.md b/.changeset/tidy-spoons-stream.md new file mode 100644 index 000000000..fd1f26ffc --- /dev/null +++ b/.changeset/tidy-spoons-stream.md @@ -0,0 +1,6 @@ +--- +"chat": minor +"@chat-adapter/telegram": minor +--- + +Add native Telegram DM draft streaming with markdown-safe segment splitting, and expose segmented stream results in the chat core. diff --git a/README.md b/README.md index 1a45546eb..92e368681 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,23 @@ bot.onSubscribedMessage(async (thread, message) => { See the [Getting Started guide](https://chat-sdk.dev/docs/getting-started) for a full walkthrough. -## Adapters - -Browse official, vendor-official, and community adapters on [chat-sdk.dev/adapters](https://chat-sdk.dev/adapters). A cross-platform feature matrix is available at [chat-sdk.dev/docs/adapters](https://chat-sdk.dev/docs/adapters). +## Supported platforms + +| Platform | Package | Mentions | Reactions | Cards | Modals | Streaming | DMs | +|----------|---------|----------|-----------|-------|--------|-----------|-----| +| Slack | `@chat-adapter/slack` | Yes | Yes | Yes | Yes | Native | Yes | +| 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 | DM Draft + Fallback | Yes | +| GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No | +| Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No | +| WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | No | Yes | ## 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 DM drafts, 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 @@ -61,7 +70,24 @@ Browse official, vendor-official, and community adapters on [chat-sdk.dev/adapte - [**File uploads**](https://chat-sdk.dev/docs/files) — send and receive file attachments - [**Direct messages**](https://chat-sdk.dev/docs/direct-messages) — initiate DMs programmatically - [**Ephemeral messages**](https://chat-sdk.dev/docs/ephemeral-messages) — user-only visible messages with DM fallback -- [**Overlapping messages**](https://chat-sdk.dev/docs/concurrency) - burst, queue, debounce, drop, or process concurrent messages on the same thread + +## Packages + +| Package | Description | +|---------|-------------| +| `chat` | Core SDK with `Chat` class, types, JSX runtime, and utilities | +| `@chat-adapter/slack` | [Slack adapter](https://chat-sdk.dev/adapters/slack) | +| `@chat-adapter/teams` | [Teams adapter](https://chat-sdk.dev/adapters/teams) | +| `@chat-adapter/gchat` | [Google Chat adapter](https://chat-sdk.dev/adapters/gchat) | +| `@chat-adapter/discord` | [Discord adapter](https://chat-sdk.dev/adapters/discord) | +| `@chat-adapter/telegram` | [Telegram adapter](https://chat-sdk.dev/adapters/telegram) | +| `@chat-adapter/github` | [GitHub adapter](https://chat-sdk.dev/adapters/github) | +| `@chat-adapter/linear` | [Linear adapter](https://chat-sdk.dev/adapters/linear) | +| `@chat-adapter/whatsapp` | [WhatsApp adapter](https://chat-sdk.dev/adapters/whatsapp) | +| `@chat-adapter/state-redis` | [Redis state adapter](https://chat-sdk.dev/docs/state/redis) (production) | +| `@chat-adapter/state-ioredis` | [ioredis state adapter](https://chat-sdk.dev/docs/state/ioredis) (alternative) | +| `@chat-adapter/state-pg` | [PostgreSQL state adapter](https://chat-sdk.dev/docs/state/postgres) (production) | +| `@chat-adapter/state-memory` | [In-memory state adapter](https://chat-sdk.dev/docs/state/memory) (development) | ## AI coding agent support @@ -77,11 +103,7 @@ Full documentation is available at [chat-sdk.dev/docs](https://chat-sdk.dev/docs ## Contributing -See [CONTRIBUTING.md](./.github/CONTRIBUTING.md) for development setup and the release process. - -## Support - -For help or questions, see [SUPPORT.md](./.github/SUPPORT.md). To report a security vulnerability, see [SECURITY.md](./.github/SECURITY.md). +See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and the release process. ## License diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx index ca31f6e9f..332df367e 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -8,70 +8,63 @@ prerequisites: Adapters handle webhook verification, message parsing, and API calls for each platform. Install only the adapters you need. Browse all available adapters — including community-built ones — on the [Adapters](/adapters) page. -Need a browser chat UI? See the [Web adapter](/adapters/official/web) — it speaks the AI SDK UI stream protocol and works with React (`@ai-sdk/react`), Vue (`@ai-sdk/vue`), and Svelte (`@ai-sdk/svelte`), so the same bot serves Slack, Teams, **and** any browser framework out of the box. - Ready to build your own? Follow the [building](/docs/contributing/building) guide. ## Feature matrix - ### Messaging -| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | [Messenger](/adapters/messenger) | -|---------|-------|-------|-------------|---------|---------|--------|--------|-----------|-----------| -| Post message | | | | | | | | | | -| 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 | -| Scheduled messages | Native | | | | | | | | | +| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | +|---------|-------|-------|-------------|---------|---------|--------|--------|-----------| +| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | ✅ Images, audio, docs | +| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ DM Draft + Post+Edit fallback | ❌ | ❌ | ❌ | +| Scheduled messages | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Rich content -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| -| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | Generic/Button Templates | -| Buttons | | | | | Inline keyboard callbacks | | | Interactive replies | Max 3, postback | -| Link buttons | | | | | Inline keyboard URLs | | | | | -| Select menus | | | | | | | | | | -| Tables | Block Kit | GFM | ASCII | GFM | ASCII | GFM | GFM | | ASCII | -| Fields | | | | | | | | Template variables | ASCII | -| Images in cards | | | | | | | | | | -| Modals | | | | | | | | | | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| +| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | +| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ✅ Interactive replies | +| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | +| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | ❌ | +| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ Template variables | +| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | +| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Conversations -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| -| Slash commands | | | | | | | | | | -| Mentions | | | | | | | | | | -| Add reactions | | | | | | | | | | -| Remove reactions | | | | | | | | | | -| Typing indicator | | | | | | | Agent sessions | | | -| DMs | | | | | | | | | | -| Ephemeral messages | Native | | Native | | | | | | | -| User lookup ([`getUser`](/docs/api/chat#getuser)) | | Cached | Cached | | Seen users | | | | | -| Parent subject ([`message.subject`](/docs/subject)) | | | | | | | | | | -| Native client ([`.webClient` / `.octokit` / `.linearClient`](/docs/api/chat#getadapter)) | | | | | | | | | | -| Custom API endpoint (`apiUrl`) | | | | | | | | | | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| +| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | +| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ### Message history -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| -| Fetch messages | | | | | Cached | | | Cached sent messages only | Cached sent messages only | -| Fetch single message | | | | | Cached | | | | Cached | -| Fetch thread info | | | | | | | | | | -| Fetch channel messages | | | | | Cached | | | | Cached | -| List threads | | | | | | | | | | -| Fetch channel info | | | | | | | | | | -| Post channel message | | | | | | | | | | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| +| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | ⚠️ Cached sent messages only | +| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | ⚠️ Cached sent messages only | +| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | ⚠️ Cached sent messages only | +| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | +| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | - indicates partial support — the feature works with limitations. See individual adapter pages for details. +⚠️ indicates partial support — the feature works with limitations. See individual adapter pages for details. -## How adapters work +## How [adapters](/adapters) work Each adapter implements a standard interface that the `Chat` class uses to route events and send messages. When a webhook arrives: @@ -80,7 +73,7 @@ Each adapter implements a standard interface that the `Chat` class uses to route 3. Routes to your handlers via the `Chat` class 4. Converts outgoing messages from markdown/AST/cards to the platform's native format -## Using multiple adapters +## Using multiple [adapters](/adapters) Register multiple [adapters](/adapters) and your event handlers work across all of them: @@ -115,32 +108,3 @@ Each adapter auto-detects credentials from environment variables, so you only ne Each adapter creates a webhook handler accessible via `bot.webhooks.`. - -## Customizing an adapter via subclassing - -Each official adapter exposes its extension surface as `protected` members so you can subclass it to override or extend platform-specific behavior without forking the package. Use this when you need to handle a payload type the built-in adapter doesn't cover, intercept verification, or wrap an existing handler. - -```typescript title="lib/custom-telegram.ts" lineNumbers -import { TelegramAdapter, type TelegramUpdate } from "@chat-adapter/telegram"; -import type { WebhookOptions } from "chat"; - -export class CustomTelegramAdapter extends TelegramAdapter { - protected override processUpdate( - update: TelegramUpdate, - options?: WebhookOptions - ): void { - // Handle a payload type the base adapter doesn't, e.g. chat_join_request. - if ("chat_join_request" in update) { - this.logger.info("Received chat_join_request", { update }); - return; - } - super.processUpdate(update, options); - } -} -``` - -Construct your subclass anywhere you'd construct the base adapter — for example, `adapters: { telegram: new CustomTelegramAdapter({ ... }) }`. Members marked `private` (internal caches, in-flight runtime state, one-shot warning flags) intentionally remain inaccessible; if you find a hook you need that isn't `protected`, please open an issue. - - - The `protected` extension surface is intentionally broader than the public API but is not yet considered fully stable. Method signatures may evolve (renames, parameter changes, new hook splits) in minor releases as we learn from real-world subclasses. Pin the adapter version you build against, watch the changelog for the affected adapter, and prefer overriding the smallest hook that solves your problem so upgrades stay easy. If you rely on a particular hook, please open an issue so we can promote it to a stable, documented extension point. - diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 6961ca0b6..f79bda0b0 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -4,7 +4,7 @@ description: A unified SDK for building chat bots across Slack, Microsoft Teams, type: overview --- -Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, WhatsApp, and Messenger. +Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. ## Why Chat SDK? @@ -52,15 +52,13 @@ Each adapter factory auto-detects credentials from environment variables (`SLACK | Platform | Package | Mentions | Reactions | Cards | Modals | Streaming | DMs | |----------|---------|----------|-----------|-------|--------|-----------|-----| | Slack | `@chat-adapter/slack` | Yes | Yes | Yes | Yes | Native | Yes | -| Microsoft Teams | `@chat-adapter/teams` | Yes | Read-only | Yes | Yes | Native (DMs) / Buffered | Yes | +| 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 | -| 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 | -| Twilio | `@chat-adapter/twilio` | N/A | No | Fallback | No | Buffered | Yes | -| Messenger | `@chat-adapter/messenger` | Yes | Receive-only | Partial | No | Buffered | Yes | +| Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | DM Draft + Fallback | Yes | +| GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No | +| Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No | +| WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | No | Yes | ## AI coding agent support @@ -79,7 +77,6 @@ The SDK is distributed as a set of packages you install based on your needs: | Package | Description | |---------|-------------| | `chat` | Core SDK with `Chat` class, types, JSX runtime, and utilities | -| `chat/ai` | [AI utilities](/docs/ai) — [`createChatTools`](/docs/ai/ai-sdk-tools) for agent operations and [`toAiMessages`](/docs/ai/to-ai-messages) for converting chat history into AI SDK prompts | | `@chat-adapter/slack` | Slack adapter | | `@chat-adapter/teams` | Microsoft Teams adapter | | `@chat-adapter/gchat` | Google Chat adapter | @@ -88,8 +85,6 @@ The SDK is distributed as a set of packages you install based on your needs: | `@chat-adapter/github` | GitHub Issues adapter | | `@chat-adapter/linear` | Linear Issues adapter | | `@chat-adapter/whatsapp` | WhatsApp Business adapter | -| `@chat-adapter/twilio` | Twilio SMS and MMS adapter | -| `@chat-adapter/messenger` | Facebook Messenger adapter | | `@chat-adapter/state-redis` | Redis state adapter (production) | | `@chat-adapter/state-ioredis` | ioredis state adapter (alternative) | | `@chat-adapter/state-pg` | PostgreSQL state adapter (production) | diff --git a/apps/docs/content/docs/streaming.mdx b/apps/docs/content/docs/streaming.mdx index 1e2074702..1addf579d 100644 --- a/apps/docs/content/docs/streaming.mdx +++ b/apps/docs/content/docs/streaming.mdx @@ -6,7 +6,7 @@ prerequisites: - /docs/usage --- -Chat SDK accepts any `AsyncIterable` as a message, enabling real-time streaming of AI responses and other incremental content to chat platforms. For platforms with native or structured streaming support, you can also stream `StreamChunk` objects for rich content like task progress cards and plan updates. +Chat SDK accepts any `AsyncIterable` as a message, enabling real-time streaming of AI responses and other incremental content to chat platforms. For platforms with native streaming support (Slack), you can also stream structured `StreamChunk` objects for rich content like task progress cards and plan updates. ## AI SDK integration @@ -59,14 +59,10 @@ await thread.post(stream); | Platform | Method | Description | |----------|--------|-------------| | Slack | Native streaming API | Uses Slack's `chatStream` for smooth, real-time updates | -| 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 | +| Telegram | Private chat draft streaming | Uses Telegram's `sendMessageDraft` in private chats and falls back to post + edit elsewhere | +| 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 | 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 | -| Messenger | Buffered | Accumulates chunks and sends one final message | The post+edit fallback throttles edits to avoid rate limits. Configure the update interval when creating your `Chat` instance: @@ -109,9 +105,9 @@ When streaming content that contains GFM tables (e.g. from an LLM), the SDK auto This happens transparently — no configuration needed. -## Structured streaming chunks +## Structured streaming chunks (Slack only) -For Slack native streams and Linear agent-session streams, you can yield `StreamChunk` objects alongside plain text for rich progress updates: +For Slack's native streaming API, you can yield `StreamChunk` objects alongside plain text for rich content: ```typescript title="lib/bot.ts" lineNumbers import type { StreamChunk } from "chat"; @@ -123,7 +119,6 @@ const stream = (async function* () { type: "task_update", id: "search-1", title: "Searching documents", - details: "Querying internal docs and ranking the best matches", status: "in_progress", } satisfies StreamChunk; @@ -133,7 +128,6 @@ const stream = (async function* () { type: "task_update", id: "search-1", title: "Searching documents", - details: "Ranked 3 relevant results", status: "complete", output: "Found 3 results", } satisfies StreamChunk; @@ -149,42 +143,33 @@ await thread.post(stream); | Type | Fields | Description | |------|--------|-------------| | `markdown_text` | `text` | Streamed text content | -| `task_update` | `id`, `title`, `status`, `details?`, `output?` | Tool/step progress updates (`pending`, `in_progress`, `complete`, `error`) with optional extra task context | -| `plan_update` | `title` | Plan title updates on supported platforms | +| `task_update` | `id`, `title`, `status`, `output?` | Tool/step progress cards (`pending`, `in_progress`, `complete`, `error`) | +| `plan_update` | `title` | Plan title updates | -### Streaming with options +### Task display mode -Wrap a stream in a `StreamingPlan` to pass platform-specific options through `thread.post()` without dropping down to `adapter.stream()` directly: +Control how `task_update` chunks render in Slack by passing `taskDisplayMode` in stream options: ```typescript -import { StreamingPlan } from "chat"; - -const planned = new StreamingPlan(stream, { - groupTasks: "plan", // Slack: render task cards as a single grouped block - endWith: [feedbackBlock], // Slack: Block Kit elements appended after stream stops - updateIntervalMs: 750, // Post+edit cadence on supported adapters +await thread.stream(stream, { + taskDisplayMode: "plan", // Group all tasks into a single plan block }); - -await thread.post(planned); ``` -| Option | Platform | Description | -|--------|----------|-------------| -| `groupTasks` | Slack | `"timeline"` (default) renders task cards inline; `"plan"` groups them into one plan block | -| `endWith` | Slack | Block Kit elements attached when the stream stops (e.g. retry / feedback buttons) | -| `updateIntervalMs` | Post+edit adapters | Minimum interval between post+edit cycles in ms (default `500`) | +| Mode | Description | +|------|-------------| +| `"timeline"` | Individual task cards shown inline with text (default) | +| `"plan"` | All tasks grouped into a single plan block | -Adapters without structured chunk support extract text from `markdown_text` chunks and ignore other types. Slack-only options are silently ignored on other platforms. +Adapters without structured chunk support extract text from `markdown_text` chunks and ignore other types. ## Stop blocks (Slack only) -Use `endWith` on `StreamingPlan` to attach Block Kit elements to the final message. This is useful for adding action buttons after a streamed response completes: +When streaming in Slack, you can attach Block Kit elements to the final message using `stopBlocks`. This is useful for adding action buttons after a streamed response completes: ```typescript title="lib/bot.ts" lineNumbers -import { StreamingPlan } from "chat"; - -const planned = new StreamingPlan(textStream, { - endWith: [ +await thread.stream(textStream, { + stopBlocks: [ { type: "actions", elements: [{ @@ -195,55 +180,15 @@ const planned = new StreamingPlan(textStream, { }, ], }); - -await thread.post(planned); ``` -## Plan API - -For step-by-step task progress that lives outside an LLM stream, post a `Plan` directly. `Plan` is a `PostableObject` you can mutate after posting — every mutation re-renders the block in place. - -```typescript title="lib/bot.ts" lineNumbers -import { Plan } from "chat"; - -const plan = new Plan({ initialMessage: "Researching options..." }); -await thread.post(plan); - -const lookup = await plan.addTask({ title: "Look up customer record" }); -// ...do work... -await plan.updateTask("Found 3 matches"); - -await plan.addTask({ title: "Summarize findings" }); -await plan.complete({ completeMessage: "Done!" }); -``` - -By default `updateTask()` mutates the most recent `in_progress` task. Pass `{ id }` to target a specific task — useful when work runs in parallel or out of order: - -```typescript -const fetchTask = await plan.addTask({ title: "Fetch data" }); -const transformTask = await plan.addTask({ title: "Transform" }); - -// Update a specific task by id, even if it isn't the most recent in_progress one. -await plan.updateTask({ id: fetchTask.id, output: "Got 42 rows" }); -await plan.updateTask({ id: transformTask.id, status: "complete" }); -``` - -Adapters that don't support PostableObject editing (e.g. WhatsApp) render the plan as a fallback emoji-list message; the plan still posts, but mutations are no-ops. - -| Method | Description | -|--------|-------------| -| `addTask({ title, children? })` | Append a new task. The previous in-progress task is auto-completed | -| `updateTask(input)` | Mutate the current (or `{ id }`-targeted) task's `output`, `status`, or `title` | -| `complete({ completeMessage })` | Mark all in-progress tasks complete and update the plan title | -| `reset({ initialMessage })` | Discard all tasks and start fresh with a new initial message — useful when re-using a plan handle for a new run | - ## Streaming with conversation history Combine message history with streaming for multi-turn AI conversations. -Use [`toAiMessages()`](/docs/ai/to-ai-messages) to convert chat messages into the `{ role, content }` format expected by AI SDKs: +Use [`toAiMessages()`](/docs/api/to-ai-messages) to convert chat messages into the `{ role, content }` format expected by AI SDKs: ```typescript title="lib/bot.ts" lineNumbers -import { toAiMessages } from "chat/ai"; +import { toAiMessages } from "chat"; bot.onSubscribedMessage(async (thread, message) => { // Fetch recent messages for context @@ -256,4 +201,4 @@ bot.onSubscribedMessage(async (thread, message) => { }); ``` -See the [`toAiMessages` reference](/docs/ai/to-ai-messages) for all options including `includeNames`, `transformMessage`, and attachment handling. +See the [`toAiMessages` API reference](/docs/api/to-ai-messages) for all options including `includeNames`, `transformMessage`, and attachment handling. diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index 923c8c0f4..8b0905f90 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -118,7 +118,7 @@ All options are auto-detected from environment variables when not provided. | `mode` | No | Adapter mode: `auto` (default), `webhook`, or `polling` | | `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` | -| `apiUrl` | No | Telegram API base URL. Auto-detected from `TELEGRAM_API_BASE_URL`. Use `apiUrl` for cross-adapter consistency; the legacy `apiBaseUrl` alias is still accepted | +| `apiBaseUrl` | No | Telegram API base URL. Auto-detected from `TELEGRAM_API_BASE_URL` | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | *`botToken` is required — either via config or env vars. @@ -143,14 +143,13 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org | Edit message | Yes | | Delete message | Yes | | File uploads | Single file (`sendDocument`) | -| Attachment uploads | Single image/audio/video/file (`sendPhoto`, `sendAudio`, `sendVideo`, `sendDocument`) | -| Streaming | Post+Edit fallback | +| Streaming | DM Draft + Post+Edit fallback | ### Rich content | Feature | Supported | |---------|-----------| -| Card format | MarkdownV2 + inline keyboard buttons | +| Card format | Markdown + inline keyboard buttons | | Buttons | Inline keyboard callbacks | | Link buttons | Inline keyboard URLs | | Select menus | No | @@ -183,12 +182,6 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org | Fetch channel info | Yes | | Post channel message | Yes | -## Markdown formatting - -Outbound messages are sent with Telegram's `MarkdownV2` parse mode. The adapter walks the markdown AST and emits MarkdownV2 with context-aware escaping (normal text vs. code blocks vs. link URLs), so you author standard markdown (`**bold**`, `*italic*`, `` `code` ``, `[label](url)`) and the adapter handles every reserved character. - -Behavior change in 4.27.0: previous versions used Telegram's legacy `Markdown` parse mode, which used different syntax (`*bold*` instead of `**bold**`) and silently rejected any text containing unescaped `.`, `!`, `(`, `)`, `-`, `_`. If you were emitting raw legacy-Markdown strings or hand-escaping characters yourself, drop the manual escaping — the renderer does it for you. Pass `{ raw: "..." }` only if you need to ship a fully pre-escaped MarkdownV2 string. - ## Notes - Telegram does not expose full historical message APIs to bots. `fetchMessages` / `fetchChannelMessages` return adapter-cached messages from the current process. @@ -199,7 +192,6 @@ Behavior change in 4.27.0: previous versions used Telegram's legacy `Markdown` p - 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. -- `files` upload as Telegram documents. `attachments` preserve the normalized media type for single image, audio, video, or file uploads. Use `data` or `fetchData` for private/authenticated files; URL-only attachments must be public URLs Telegram can fetch directly. - Other rich card elements (images/select menus/radios) render as fallback text only. ## License diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 76223c9fb..4185e9eea 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -911,6 +911,503 @@ 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( + 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 firstDraftUrl = String(mockFetch.mock.calls[1]?.[0]); + const secondDraftUrl = String(mockFetch.mock.calls[2]?.[0]); + const finalSendUrl = String(mockFetch.mock.calls[3]?.[0]); + + expect(firstDraftUrl).toContain("/sendMessageDraft"); + expect(secondDraftUrl).toContain("/sendMessageDraft"); + expect(finalSendUrl).toContain("/sendMessage"); + + const firstDraftBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { + chat_id: string; + draft_id: number; + parse_mode?: string; + text: string; + }; + + const secondDraftBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { + chat_id: string; + draft_id: number; + parse_mode?: string; + text: string; + }; + + const finalSendBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + ) as { chat_id: string; parse_mode?: string; text: string }; + + expect(firstDraftBody.chat_id).toBe("123"); + expect(firstDraftBody.text).toBe("hello"); + expect(firstDraftBody.parse_mode).toBe("Markdown"); + 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("Markdown"); + }); + + it("splits long private chat streams into multiple final messages", async () => { + const longPrefix = "a".repeat(3600); + const longSuffix = "b".repeat(1200); + + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + message_id: 21, + text: "a".repeat(3500), + }) + ) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + message_id: 22, + text: `${"a".repeat(100)}${"b".repeat(1200)}`, + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + async function* textStream(): AsyncIterable { + yield longPrefix; + yield longSuffix; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: Number.MAX_SAFE_INTEGER, + }); + + expect(result).not.toBeNull(); + expect(result).toHaveProperty("messages"); + if (!(result && "messages" in result)) { + throw new Error("Expected segmented stream result"); + } + expect(result.messages).toHaveLength(2); + expect(result.messages[0]?.message.id).toBe("123:21"); + expect(result.messages[1]?.message.id).toBe("123:22"); + + const draftBodies = mockFetch.mock.calls + .slice(1) + .filter((call) => String(call[0]).endsWith("/sendMessageDraft")) + .map( + (call) => + JSON.parse(String((call[1] as RequestInit).body)) as { + draft_id: number; + text: string; + } + ); + const sendBodies = mockFetch.mock.calls + .slice(1) + .filter((call) => String(call[0]).endsWith("/sendMessage")) + .map( + (call) => + JSON.parse(String((call[1] as RequestInit).body)) as { + chat_id: string; + text: string; + } + ); + + expect(draftBodies).toHaveLength(2); + expect(draftBodies[0]?.text).toHaveLength(3500); + expect(draftBodies[1]?.text).toHaveLength(1300); + expect(draftBodies[1]?.draft_id).not.toBe(draftBodies[0]?.draft_id); + + expect(sendBodies).toHaveLength(2); + expect(sendBodies[0]?.chat_id).toBe("123"); + expect(sendBodies[0]?.text).toHaveLength(3500); + expect(sendBodies[1]?.text).toHaveLength(1300); + expect(`${sendBodies[0]?.text ?? ""}${sendBodies[1]?.text ?? ""}`).toBe( + `${longPrefix}${longSuffix}` + ); + }); + + it("splits long markdown streams on a clean prefix before unbalanced markers", async () => { + const longPrefix = `${"a".repeat(3498)}**bo`; + const longSuffix = "ld**!"; + + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + message_id: 31, + text: "a".repeat(3498), + }) + ) + ) + .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce( + telegramOk( + sampleMessage({ + message_id: 32, + text: "**bold**!", + }) + ) + ); + + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + async function* textStream(): AsyncIterable { + yield longPrefix; + yield longSuffix; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: Number.MAX_SAFE_INTEGER, + }); + + expect(result).not.toBeNull(); + expect(result).toHaveProperty("messages"); + if (!(result && "messages" in result)) { + throw new Error("Expected segmented stream result"); + } + + const sendBodies = mockFetch.mock.calls + .slice(1) + .filter((call) => String(call[0]).endsWith("/sendMessage")) + .map( + (call) => + JSON.parse(String((call[1] as RequestInit).body)) as { + parse_mode?: string; + text: string; + } + ); + + expect(sendBodies).toHaveLength(2); + expect(sendBodies[0]?.text).toBe("a".repeat(3498)); + expect(sendBodies[1]?.parse_mode).toBe("Markdown"); + expect(sendBodies[1]?.text).toBe("**bold**!"); + }); + + it("keeps markdown parse mode for an exact-limit clean segment", async () => { + const longMarkdown = `${"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: longMarkdown.length, + tail: longMarkdown.slice(-10), + parse_mode: "Markdown", + }, + { + method: "sendMessage", + len: longMarkdown.length, + tail: longMarkdown.slice(-10), + parse_mode: "Markdown", + }, + ]); + + const draftBody = requestBodies[1]?.body as { + parse_mode?: string; + text: string; + }; + const finalSendBody = requestBodies[2]?.body as { + parse_mode?: string; + text: string; + }; + + expect(draftBody.parse_mode).toBe("Markdown"); + expect(draftBody.text).toBe(longMarkdown); + expect(finalSendBody.parse_mode).toBe("Markdown"); + expect(finalSendBody.text).toBe(longMarkdown); + }); + + 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( + 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[3]?.[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( @@ -932,21 +1429,322 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - const posted = await adapter.postChannelMessage("telegram:123", { - markdown: "channel message", + const posted = await adapter.postChannelMessage("telegram:123", { + markdown: "channel message", + }); + + expect(posted.threadId).toBe("telegram:123"); + + const sendMessageBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { chat_id: string; text: string }; + + expect(sendMessageBody.chat_id).toBe("123"); + expect(sendMessageBody.text).toBe("channel message"); + }); + + it("postChannelMessage works with raw channel ID", 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()); + + const posted = await adapter.postChannelMessage("123", { + markdown: "raw id message", + }); + + expect(posted.threadId).toBe("telegram:123"); + + const sendMessageBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { chat_id: string; text: string }; + + expect(sendMessageBody.chat_id).toBe("123"); + expect(sendMessageBody.text).toBe("raw id message"); + }); + + it.each([ + { + field: "photo", + method: "sendPhoto", + mimeType: "image/png", + name: "image.png", + type: "image", + }, + { + field: "audio", + method: "sendAudio", + mimeType: "audio/mpeg", + name: "track.mp3", + type: "audio", + }, + { + field: "video", + method: "sendVideo", + mimeType: "video/mp4", + name: "clip.mp4", + type: "video", + }, + { + field: "document", + method: "sendDocument", + mimeType: "application/pdf", + name: "report.pdf", + type: "file", + }, + ] as const)("posts $type attachments with Telegram $method uploads", async ({ + field, + method, + mimeType, + name, + type, + }) => { + 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:-100123:42", { + markdown: "attached **media**", + attachments: [ + { + data: Buffer.from("payload"), + height: type === "video" ? 720 : undefined, + mimeType, + name, + type, + width: type === "video" ? 1280 : undefined, + }, + ], + }); + + expect(String(mockFetch.mock.calls[1]?.[0])).toContain(`/${method}`); + + const formData = readFormData(1); + const upload = formData.get(field); + + expect(formData.get("chat_id")).toBe("-100123"); + expect(formData.get("message_thread_id")).toBe("42"); + expect(formData.get("caption")).toBe("attached *media*"); + expect(formData.get("parse_mode")).toBe("MarkdownV2"); + expect(upload).toBeInstanceOf(Blob); + expect((upload as { name?: string }).name).toBe(name); + expect((upload as Blob).type).toBe(mimeType); + + if (type === "video") { + expect(formData.get("width")).toBe("1280"); + expect(formData.get("height")).toBe("720"); + } + }); + + it("posts attachment data loaded through fetchData", 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", + }); + const fetchData = vi.fn().mockResolvedValue(Buffer.from("payload")); + + await adapter.initialize(createMockChat()); + + await adapter.postMessage("telegram:123", { + raw: "", + attachments: [ + { + fetchData, + mimeType: "application/pdf", + name: "report.pdf", + type: "file", + }, + ], + }); + + const formData = readFormData(1); + + expect(fetchData).toHaveBeenCalledOnce(); + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/sendDocument"); + expect(formData.get("caption")).toBeNull(); + expect(formData.get("parse_mode")).toBeNull(); + expect(formData.get("document")).toBeInstanceOf(Blob); + }); + + it("posts URL-only attachments through Telegram URL fields", 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:-100123:42", { + markdown: "public **image**", + attachments: [ + { + mimeType: "image/png", + name: "image.png", + type: "image", + url: "https://cdn.example.com/image.png", + }, + ], }); - expect(posted.threadId).toBe("telegram:123"); + expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/sendPhoto"); + expect(mockFetch.mock.calls[1]?.[1]?.headers).toEqual({ + "Content-Type": "application/json", + }); + expect(JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body))).toEqual({ + caption: "public *image*", + chat_id: "-100123", + message_thread_id: 42, + parse_mode: "MarkdownV2", + photo: "https://cdn.example.com/image.png", + }); + }); - const sendMessageBody = JSON.parse( - String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) - ) as { chat_id: string; text: string }; + it("rejects multiple Telegram attachments in one message", async () => { + mockFetch.mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ); - expect(sendMessageBody.chat_id).toBe("123"); - expect(sendMessageBody.text).toBe("channel message"); + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + await expect( + adapter.postMessage("telegram:123", { + raw: "attachments", + attachments: [ + { data: Buffer.from("one"), type: "image" }, + { data: Buffer.from("two"), type: "image" }, + ], + }) + ).rejects.toThrow("single attachment upload"); + expect(mockFetch.mock.calls).toHaveLength(1); }); - it("postChannelMessage works with raw channel ID", async () => { + it("rejects mixed file uploads and attachments", 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()); + + await expect( + adapter.postMessage("telegram:123", { + raw: "mixed", + attachments: [{ data: Buffer.from("one"), type: "image" }], + files: [{ data: Buffer.from("two"), filename: "two.txt" }], + }) + ).rejects.toThrow("mixing file uploads and attachments"); + expect(mockFetch.mock.calls).toHaveLength(1); + }); + + it("rejects attachments without upload data", 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()); + + await expect( + adapter.postMessage("telegram:123", { + raw: "", + attachments: [{ type: "image" }], + }) + ).rejects.toThrow("Attachment data or URL required for image"); + expect(mockFetch.mock.calls).toHaveLength(1); + }); + + it("sets parse_mode for markdown messages", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -967,56 +1765,18 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - const posted = await adapter.postChannelMessage("123", { - markdown: "raw id message", + await adapter.postMessage("telegram:123", { + markdown: "**bold** and _italic_", }); - expect(posted.threadId).toBe("telegram:123"); - const sendMessageBody = JSON.parse( String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) - ) as { chat_id: string; text: string }; + ) as { parse_mode?: string }; - expect(sendMessageBody.chat_id).toBe("123"); - expect(sendMessageBody.text).toBe("raw id message"); + expect(sendMessageBody.parse_mode).toBe("MarkdownV2"); }); - it.each([ - { - field: "photo", - method: "sendPhoto", - mimeType: "image/png", - name: "image.png", - type: "image", - }, - { - field: "audio", - method: "sendAudio", - mimeType: "audio/mpeg", - name: "track.mp3", - type: "audio", - }, - { - field: "video", - method: "sendVideo", - mimeType: "video/mp4", - name: "clip.mp4", - type: "video", - }, - { - field: "document", - method: "sendDocument", - mimeType: "application/pdf", - name: "report.pdf", - type: "file", - }, - ] as const)("posts $type attachments with Telegram $method uploads", async ({ - field, - method, - mimeType, - name, - type, - }) => { + it("sets parse_mode for AST messages", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -1037,40 +1797,51 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - await adapter.postMessage("telegram:-100123:42", { - markdown: "attached **media**", - attachments: [ - { - data: Buffer.from("payload"), - height: type === "video" ? 720 : undefined, - mimeType, - name, - type, - width: type === "video" ? 1280 : undefined, - }, - ], - }); + const ast = new TelegramFormatConverter().toAst("**hello** world!"); + await adapter.postMessage("telegram:123", { ast }); - expect(String(mockFetch.mock.calls[1]?.[0])).toContain(`/${method}`); + const sendMessageBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; - const formData = readFormData(1); - const upload = formData.get(field); + // AST messages were shipping without parse_mode, so Telegram rendered + // MarkdownV2 asterisks literally. Guard against regression. + expect(sendMessageBody.parse_mode).toBe("MarkdownV2"); + expect(sendMessageBody.text).toContain("*hello*"); + expect(sendMessageBody.text).toContain("world\\!"); + }); - expect(formData.get("chat_id")).toBe("-100123"); - expect(formData.get("message_thread_id")).toBe("42"); - expect(formData.get("caption")).toBe("attached *media*"); - expect(formData.get("parse_mode")).toBe("MarkdownV2"); - expect(upload).toBeInstanceOf(Blob); - expect((upload as { name?: string }).name).toBe(name); - expect((upload as Blob).type).toBe(mimeType); + it("omits parse_mode for plain string messages", async () => { + mockFetch + .mockResolvedValueOnce( + telegramOk({ + id: 999, + is_bot: true, + first_name: "Bot", + username: "mybot", + }) + ) + .mockResolvedValueOnce(telegramOk(sampleMessage())); - if (type === "video") { - expect(formData.get("width")).toBe("1280"); - expect(formData.get("height")).toBe("720"); - } + const adapter = createTelegramAdapter({ + botToken: "token", + mode: "webhook", + logger: mockLogger, + userName: "mybot", + }); + + await adapter.initialize(createMockChat()); + + await adapter.postMessage("telegram:123", "plain text message"); + + const sendMessageBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { parse_mode?: string }; + + expect(sendMessageBody.parse_mode).toBeUndefined(); }); - it("posts attachment data loaded through fetchData", async () => { + it("omits parse_mode for raw messages", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -1088,32 +1859,20 @@ describe("TelegramAdapter", () => { logger: mockLogger, userName: "mybot", }); - const fetchData = vi.fn().mockResolvedValue(Buffer.from("payload")); await adapter.initialize(createMockChat()); - await adapter.postMessage("telegram:123", { - raw: "", - attachments: [ - { - fetchData, - mimeType: "application/pdf", - name: "report.pdf", - type: "file", - }, - ], - }); + await adapter.postMessage("telegram:123", { raw: "raw.unparsed!(text)" }); - const formData = readFormData(1); + const sendMessageBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; - expect(fetchData).toHaveBeenCalledOnce(); - expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/sendDocument"); - expect(formData.get("caption")).toBeNull(); - expect(formData.get("parse_mode")).toBeNull(); - expect(formData.get("document")).toBeInstanceOf(Blob); + expect(sendMessageBody.parse_mode).toBeUndefined(); + expect(sendMessageBody.text).toBe("raw.unparsed!(text)"); }); - it("posts URL-only attachments through Telegram URL fields", async () => { + it("retries markdown messages without parse_mode when Telegram can't parse entities", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -1123,7 +1882,20 @@ describe("TelegramAdapter", () => { 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", @@ -1134,40 +1906,55 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - await adapter.postMessage("telegram:-100123:42", { - markdown: "public **image**", - attachments: [ - { - mimeType: "image/png", - name: "image.png", - type: "image", - url: "https://cdn.example.com/image.png", - }, - ], + const result = await adapter.postMessage("telegram:123", { + markdown: "**broken", }); - expect(String(mockFetch.mock.calls[1]?.[0])).toContain("/sendPhoto"); - expect(mockFetch.mock.calls[1]?.[1]?.headers).toEqual({ - "Content-Type": "application/json", - }); - expect(JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body))).toEqual({ - caption: "public *image*", - chat_id: "-100123", - message_thread_id: 42, - parse_mode: "MarkdownV2", - photo: "https://cdn.example.com/image.png", - }); - }); + expect(result.id).toBe("123:11"); - it("rejects multiple Telegram attachments in one message", async () => { - mockFetch.mockResolvedValueOnce( - telegramOk({ - id: 999, - is_bot: true, - first_name: "Bot", - username: "mybot", + 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("Markdown"); + 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", @@ -1178,56 +1965,33 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - await expect( - adapter.postMessage("telegram:123", { - raw: "attachments", - attachments: [ - { data: Buffer.from("one"), type: "image" }, - { data: Buffer.from("two"), type: "image" }, - ], - }) - ).rejects.toThrow("single attachment upload"); - expect(mockFetch.mock.calls).toHaveLength(1); - }); - - it("rejects mixed file uploads and attachments", 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", + const result = await adapter.postMessage("telegram:123", { + markdown: "**", }); - await adapter.initialize(createMockChat()); + expect(result.id).toBe("123:11"); - await expect( - adapter.postMessage("telegram:123", { - raw: "mixed", - attachments: [{ data: Buffer.from("one"), type: "image" }], - files: [{ data: Buffer.from("two"), filename: "two.txt" }], - }) - ).rejects.toThrow("mixing file uploads and attachments"); - expect(mockFetch.mock.calls).toHaveLength(1); + 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("rejects attachments without upload data", async () => { - mockFetch.mockResolvedValueOnce( - telegramOk({ - id: 999, - is_bot: true, - first_name: "Bot", - username: "mybot", - }) - ); + 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", @@ -1240,14 +2004,12 @@ describe("TelegramAdapter", () => { await expect( adapter.postMessage("telegram:123", { - raw: "", - attachments: [{ type: "image" }], + markdown: "**broken**", }) - ).rejects.toThrow("Attachment data or URL required for image"); - expect(mockFetch.mock.calls).toHaveLength(1); + ).rejects.toThrow("Bad Request: chat not found"); }); - it("sets parse_mode for markdown messages", async () => { + it("retries markdown edits without parse_mode when Telegram can't parse entities", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -1257,7 +2019,21 @@ describe("TelegramAdapter", () => { username: "mybot", }) ) - .mockResolvedValueOnce(telegramOk(sampleMessage())); + .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", @@ -1268,18 +2044,34 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - await adapter.postMessage("telegram:123", { - markdown: "**bold** and _italic_", + const posted = await adapter.postMessage("telegram:123", "hello"); + const result = await adapter.editMessage("telegram:123", posted.id, { + markdown: "**broken", }); - const sendMessageBody = JSON.parse( - String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) - ) as { parse_mode?: string }; + expect(result.id).toBe("123:11"); - expect(sendMessageBody.parse_mode).toBe("MarkdownV2"); + 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("Markdown"); + 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("sets parse_mode for AST messages", async () => { + it("retries markdown edits with original text when plain-text fallback would be empty", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -1289,7 +2081,21 @@ describe("TelegramAdapter", () => { username: "mybot", }) ) - .mockResolvedValueOnce(telegramOk(sampleMessage())); + .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", @@ -1300,21 +2106,22 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - const ast = new TelegramFormatConverter().toAst("**hello** world!"); - await adapter.postMessage("telegram:123", { ast }); + const posted = await adapter.postMessage("telegram:123", "hello"); + const result = await adapter.editMessage("telegram:123", posted.id, { + markdown: "**", + }); - const sendMessageBody = JSON.parse( - String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + 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 }; - // AST messages were shipping without parse_mode, so Telegram rendered - // MarkdownV2 asterisks literally. Guard against regression. - expect(sendMessageBody.parse_mode).toBe("MarkdownV2"); - expect(sendMessageBody.text).toContain("*hello*"); - expect(sendMessageBody.text).toContain("world\\!"); + expect(secondEditBody.parse_mode).toBeUndefined(); + expect(secondEditBody.text).toBe("**"); }); - it("omits parse_mode for plain string messages", async () => { + it("falls back to plain-text draft and final send when Telegram can't parse streamed markdown", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -1324,7 +2131,21 @@ describe("TelegramAdapter", () => { username: "mybot", }) ) - .mockResolvedValueOnce(telegramOk(sampleMessage())); + .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", @@ -1335,16 +2156,30 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - await adapter.postMessage("telegram:123", "plain text message"); + async function* textStream(): AsyncIterable { + yield "**broken"; + } - const sendMessageBody = JSON.parse( - String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) - ) as { parse_mode?: string }; + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: 0, + }); - expect(sendMessageBody.parse_mode).toBeUndefined(); + expect(result?.id).toBe("123:11"); + + const retryDraftBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + const finalSendBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[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("omits parse_mode for raw messages", async () => { + it("reuses original text when streamed plain-text fallback would be empty", async () => { mockFetch .mockResolvedValueOnce( telegramOk({ @@ -1354,7 +2189,21 @@ describe("TelegramAdapter", () => { username: "mybot", }) ) - .mockResolvedValueOnce(telegramOk(sampleMessage())); + .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", @@ -1365,14 +2214,27 @@ describe("TelegramAdapter", () => { await adapter.initialize(createMockChat()); - await adapter.postMessage("telegram:123", { raw: "raw.unparsed!(text)" }); + async function* textStream(): AsyncIterable { + yield "**"; + } - const sendMessageBody = JSON.parse( - String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: 0, + }); + + expect(result?.id).toBe("123:11"); + + const retryDraftBody = JSON.parse( + String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + ) as { parse_mode?: string; text: string }; + const finalSendBody = JSON.parse( + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) ) as { parse_mode?: string; text: string }; - expect(sendMessageBody.parse_mode).toBeUndefined(); - expect(sendMessageBody.text).toBe("raw.unparsed!(text)"); + 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 () => { diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index effac2c4d..0cfa26206 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -5,7 +5,6 @@ import { cardToFallbackText, extractCard, extractFiles, - extractPostableAttachments, NetworkError, PermissionError, ResourceNotFoundError, @@ -23,8 +22,10 @@ import type { FormattedContent, Logger, RawMessage, + StreamChunk, + StreamOptions, + StreamResult, ThreadInfo, - UserInfo, WebhookOptions, } from "chat"; import { @@ -33,21 +34,17 @@ import { defaultEmojiResolver, getEmoji, Message, + markdownToPlainText, NotImplementedError, + StreamingMarkdownRenderer, + toPlainText, } from "chat"; import { cardToTelegramInlineKeyboard, decodeTelegramCallbackData, emptyTelegramInlineKeyboard, } from "./cards"; -import { - TELEGRAM_CAPTION_LIMIT, - TELEGRAM_MESSAGE_LIMIT, - TelegramFormatConverter, - type TelegramParseMode, - toBotApiParseMode, - truncateForTelegram, -} from "./markdown"; +import { TelegramFormatConverter } from "./markdown"; import type { TelegramAdapterConfig, TelegramAdapterMode, @@ -69,8 +66,11 @@ import type { } from "./types"; const TELEGRAM_API_BASE = "https://api.telegram.org"; +const TELEGRAM_MESSAGE_LIMIT = 4096; +const TELEGRAM_CAPTION_LIMIT = 1024; const TELEGRAM_SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token"; const MESSAGE_ID_PATTERN = /^([^:]+):(\d+)$/; +const TELEGRAM_MARKDOWN_PARSE_MODE = "Markdown"; const trimTrailingSlashes = (url: string): string => { let end = url.length; while (end > 0 && url[end - 1] === "/") { @@ -79,21 +79,18 @@ const trimTrailingSlashes = (url: string): string => { return url.slice(0, end); }; const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; -const ATTACHMENT_UPLOADS = { - audio: { field: "audio", method: "sendAudio" }, - file: { field: "document", method: "sendDocument" }, - image: { field: "photo", method: "sendPhoto" }, - video: { field: "video", method: "sendVideo" }, -} as const satisfies Record< - Attachment["type"], - { field: string; method: string } ->; 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_DEFAULT_STREAM_UPDATE_INTERVAL_MS = 250; +// Keep streaming segments below Telegram's 4096-character hard limit to leave +// room for Markdown parsing and avoid truncating the final sent message. +const TELEGRAM_STREAM_SEGMENT_LIMIT = 3500; +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; @@ -118,22 +115,18 @@ interface ResolvedTelegramLongPollingConfig { type TelegramRuntimeMode = "webhook" | "polling"; /** - * Escape standard-markdown special characters inside inbound entity text. - * - * Used only by `applyTelegramEntities` below (inbound path). Outbound - * MarkdownV2 escaping lives in `markdown.ts` (`escapeMarkdownV2`). + * Escape markdown special characters inside entity text so wrapping + * with markdown syntax doesn't break parsing. */ const escapeMarkdownInEntity = (text: string): string => text.replace(/([[\]()\\])/g, "\\$1"); /** - * Convert Telegram message entities (inbound) to standard markdown. + * Convert Telegram message entities to markdown. * * Telegram delivers formatting as separate entity objects alongside plain text. - * This function reconstructs **standard** markdown (`**bold**`, `~~strike~~`, - * etc.) so the result can be fed into the SDK's `parseMarkdown` — which is - * the canonical AST producer. The outbound direction (AST → MarkdownV2) is - * handled separately by `TelegramFormatConverter.fromAst`. + * This function reconstructs markdown so that links, bold, italic, code, etc. + * are preserved when the text is later parsed as markdown. * * Entities use UTF-16 offsets, which match JavaScript's native string indexing. */ @@ -210,29 +203,30 @@ export class TelegramAdapter { readonly name = "telegram"; readonly lockScope = "channel" as const; - readonly persistThreadHistory = true; + readonly persistMessageHistory = true; - protected readonly botToken: string; - protected readonly apiBaseUrl: string; - protected readonly secretToken?: string; + private readonly botToken: string; + private readonly apiBaseUrl: string; + private readonly secretToken?: string; private warnedNoVerification = false; - protected readonly logger: Logger; - protected readonly formatConverter = new TelegramFormatConverter(); + private readonly logger: Logger; + private readonly formatConverter = new TelegramFormatConverter(); private readonly messageCache = new Map< string, Message[] >(); - protected chat: ChatInstance | null = null; - protected _botUserId?: string; - protected _userName: string; - protected readonly hasExplicitUserName: boolean; - protected readonly mode: TelegramAdapterMode; - protected readonly longPolling?: TelegramLongPollingConfig; + private chat: ChatInstance | null = null; + private _botUserId?: string; + private _userName: string; + private readonly hasExplicitUserName: boolean; + private readonly mode: TelegramAdapterMode; + private readonly longPolling?: TelegramLongPollingConfig; private _runtimeMode: TelegramRuntimeMode = "webhook"; 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; @@ -261,8 +255,7 @@ export class TelegramAdapter this.botToken = botToken; this.apiBaseUrl = trimTrailingSlashes( - config.apiUrl ?? - config.apiBaseUrl ?? + config.apiBaseUrl ?? process.env.TELEGRAM_API_BASE_URL ?? TELEGRAM_API_BASE ); @@ -330,32 +323,6 @@ export class TelegramAdapter } } - async getUser(userId: string): Promise { - try { - const chat = await this.telegramFetch("getChat", { - chat_id: userId, - }); - // Only private chats represent users — groups/channels are not user lookups - if (chat.type !== "private") { - return null; - } - const fullName = [chat.first_name, chat.last_name] - .filter(Boolean) - .join(" "); - return { - email: undefined, - fullName: fullName || String(chat.id), - // Telegram's getChat API doesn't expose is_bot (only available on TelegramUser). - // Always returns false — callers needing bot detection should use message.author.isBot instead. - isBot: false, - userId: String(chat.id), - userName: chat.username || chat.first_name || String(chat.id), - }; - } catch { - return null; - } - } - async handleWebhook( request: Request, options?: WebhookOptions @@ -479,7 +446,7 @@ export class TelegramAdapter }); } - protected async resolveRuntimeMode(): Promise { + private async resolveRuntimeMode(): Promise { if (this.mode === "webhook") { return "webhook"; } @@ -514,7 +481,7 @@ export class TelegramAdapter return "polling"; } - protected async fetchWebhookInfo(): Promise { + private async fetchWebhookInfo(): Promise { try { return await this.telegramFetch("getWebhookInfo"); } catch (error) { @@ -525,7 +492,7 @@ export class TelegramAdapter } } - protected isLikelyServerlessRuntime(): boolean { + private isLikelyServerlessRuntime(): boolean { if (typeof process === "undefined" || !process.env) { return false; } @@ -540,7 +507,7 @@ export class TelegramAdapter ); } - protected processUpdate( + private processUpdate( update: TelegramUpdate, options?: WebhookOptions ): void { @@ -563,7 +530,7 @@ export class TelegramAdapter } } - protected handleIncomingMessageUpdate( + private handleIncomingMessageUpdate( telegramMessage: TelegramMessage, options?: WebhookOptions ): void { @@ -582,7 +549,7 @@ export class TelegramAdapter this.chat.processMessage(this, threadId, parsedMessage, options); } - protected handleCallbackQuery( + private handleCallbackQuery( callbackQuery: TelegramCallbackQuery, options?: WebhookOptions ): void { @@ -629,7 +596,7 @@ export class TelegramAdapter } } - protected handleMessageReactionUpdate( + private handleMessageReactionUpdate( reactionUpdate: TelegramMessageReactionUpdated, options?: WebhookOptions ): void { @@ -706,17 +673,19 @@ export class TelegramAdapter const card = extractCard(message); const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined; const parseMode = this.resolveParseMode(message, card); - const text = truncateForTelegram( + const plainText = this.truncateMessage( + convertEmojiPlaceholders( + this.renderPlainTextMessage(message, card), + "gchat" + ) + ); + const text = this.truncateMessage( convertEmojiPlaceholders( card - ? this.formatConverter.fromMarkdown( - cardToFallbackText(card, { boldFormat: "**" }) - ) + ? cardToFallbackText(card) : this.formatConverter.renderPostable(message), "gchat" - ), - TELEGRAM_MESSAGE_LIMIT, - parseMode + ) ); const files = extractFiles(message); @@ -727,21 +696,6 @@ export class TelegramAdapter ); } - const attachments = extractPostableAttachments(message); - if (attachments.length > 1) { - throw new ValidationError( - "telegram", - "Telegram adapter supports a single attachment upload per message" - ); - } - - if (files.length > 0 && attachments.length > 0) { - throw new ValidationError( - "telegram", - "Telegram adapter does not support mixing file uploads and attachments in one message" - ); - } - let rawMessage: TelegramMessage; if (files.length === 1) { @@ -753,21 +707,7 @@ export class TelegramAdapter parsedThread, file, text, - replyMarkup, - parseMode - ); - } else if (attachments.length === 1) { - const [attachment] = attachments; - if (!attachment) { - throw new ValidationError( - "telegram", - "Attachment upload payload is empty" - ); - } - rawMessage = await this.sendAttachment( - parsedThread, - attachment, - text, + plainText, replyMarkup, parseMode ); @@ -776,13 +716,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: resolvedParseMode, + }), + { + initialText: text, + fallbackText: plainText, + method: "sendMessage", + threadId, + } + ); } const resultingThreadId = this.encodeThreadId({ @@ -826,31 +776,41 @@ export class TelegramAdapter const card = extractCard(message); const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined; const parseMode = this.resolveParseMode(message, card); - const text = truncateForTelegram( + const plainText = this.truncateMessage( + convertEmojiPlaceholders( + this.renderPlainTextMessage(message, card), + "gchat" + ) + ); + const text = this.truncateMessage( convertEmojiPlaceholders( card - ? this.formatConverter.fromMarkdown( - cardToFallbackText(card, { boldFormat: "**" }) - ) + ? cardToFallbackText(card) : this.formatConverter.renderPostable(message), "gchat" - ), - TELEGRAM_MESSAGE_LIMIT, - parseMode + ) ); if (!text.trim()) { 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: 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 +915,218 @@ export class TelegramAdapter }); } + async stream( + threadId: string, + textStream: AsyncIterable, + options?: StreamOptions + ): Promise< + RawMessage | StreamResult | 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 + ); + + let renderer = new StreamingMarkdownRenderer(); + let segmentText = ""; + let draftId = this.createDraftId(); + let lastDraftText = ""; + let lastFlushAt = 0; + let draftStreamingEnabled = true; + let streamUsesMarkdown = true; + let segmentUsesMarkdown = true; + const postedSegments: StreamResult["messages"] = []; + + const renderPlainText = (text: string): string => + this.truncateMessage( + this.resolveTelegramFallbackText(text, markdownToPlainText(text)) + ); + + const resetSegment = (nextText = ""): void => { + renderer = new StreamingMarkdownRenderer(); + segmentText = ""; + draftId = this.createDraftId(); + lastDraftText = ""; + lastFlushAt = 0; + segmentUsesMarkdown = streamUsesMarkdown; + + if (nextText) { + renderer.push(nextText); + segmentText = nextText; + } + }; + + const postSegment = async ( + text: string, + useMarkdown: boolean + ): Promise => { + if (!text.trim()) { + return; + } + + const postable: AdapterPostableMessage = useMarkdown + ? { markdown: text } + : this.resolveTelegramFallbackText(text, markdownToPlainText(text)); + const message = await this.postMessage(threadId, postable); + + postedSegments.push({ + message, + postable, + }); + }; + + const flushDraft = async (sourceText = segmentText): Promise => { + if (!draftStreamingEnabled) { + return; + } + + const draftText = segmentUsesMarkdown + ? this.truncateMessage( + sourceText === segmentText ? renderer.render() : sourceText + ) + : renderPlainText(sourceText); + if (!draftText.trim() || draftText === lastDraftText) { + return; + } + + try { + if (segmentUsesMarkdown) { + await this.telegramFetch("sendMessageDraft", { + chat_id: parsedThread.chatId, + message_thread_id: parsedThread.messageThreadId, + draft_id: draftId, + text: draftText, + parse_mode: TELEGRAM_MARKDOWN_PARSE_MODE, + }); + } else { + await this.telegramFetch("sendMessageDraft", { + chat_id: parsedThread.chatId, + message_thread_id: parsedThread.messageThreadId, + draft_id: draftId, + text: draftText, + }); + } + lastDraftText = draftText; + lastFlushAt = Date.now(); + } catch (error) { + if (segmentUsesMarkdown && this.isTelegramMarkdownParseError(error)) { + streamUsesMarkdown = false; + segmentUsesMarkdown = false; + + const plainDraftText = renderPlainText(sourceText); + if (!plainDraftText.trim()) { + draftStreamingEnabled = false; + return; + } + + 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 appendText = async (text: string): Promise => { + let remaining = text; + + while (remaining.length > 0) { + const available = + TELEGRAM_STREAM_SEGMENT_LIMIT - segmentText.length || 0; + const nextSlice = available > 0 ? remaining.slice(0, available) : ""; + + if (!nextSlice) { + await flushDraft(); + await postSegment(segmentText, segmentUsesMarkdown); + resetSegment(); + continue; + } + + renderer.push(nextSlice); + segmentText += nextSlice; + remaining = remaining.slice(nextSlice.length); + + if (Date.now() - lastFlushAt >= updateIntervalMs) { + await flushDraft(); + } + + if (segmentText.length >= TELEGRAM_STREAM_SEGMENT_LIMIT) { + const committedPrefix = segmentUsesMarkdown + ? renderer.getCommittedMarkdownPrefix() + : ""; + + if (segmentUsesMarkdown && committedPrefix.trim()) { + const overflow = segmentText.slice(committedPrefix.length); + await flushDraft(committedPrefix); + await postSegment(committedPrefix, true); + resetSegment(overflow); + continue; + } + + if (segmentUsesMarkdown) { + streamUsesMarkdown = false; + segmentUsesMarkdown = false; + } + + await flushDraft(); + await postSegment(segmentText, segmentUsesMarkdown); + resetSegment(); + } + } + }; + + for await (const chunk of textStream) { + if (typeof chunk === "string") { + await appendText(chunk); + } else if (chunk.type === "markdown_text") { + await appendText(chunk.text); + } + } + + await flushDraft(); + + if (segmentText.trim()) { + await postSegment(segmentText, segmentUsesMarkdown); + } + + if (postedSegments.length === 0) { + throw new ValidationError( + "telegram", + "Telegram streaming requires text content" + ); + } + + return postedSegments.length === 1 + ? postedSegments[0].message + : { messages: postedSegments }; + } + async fetchMessages( threadId: string, options: FetchOptions = {} @@ -1119,7 +1291,7 @@ export class TelegramAdapter return this.formatConverter.fromAst(content); } - protected parseTelegramMessage( + private parseTelegramMessage( raw: TelegramMessage, threadId: string ): Message { @@ -1166,7 +1338,7 @@ export class TelegramAdapter return message; } - protected extractAttachments(raw: TelegramMessage): Attachment[] { + private extractAttachments(raw: TelegramMessage): Attachment[] { const attachments: Attachment[] = []; const photo = raw.photo?.at(-1); @@ -1221,20 +1393,10 @@ export class TelegramAdapter ); } - if (raw.video_note) { - attachments.push( - this.createAttachment("video", raw.video_note.file_id, { - size: raw.video_note.file_size, - width: raw.video_note.length, - height: raw.video_note.length, - }) - ); - } - return attachments; } - protected createAttachment( + private createAttachment( type: Attachment["type"], fileId: string, metadata?: { @@ -1252,23 +1414,11 @@ export class TelegramAdapter height: metadata?.height, name: metadata?.name, mimeType: metadata?.mimeType, - fetchMetadata: { fileId }, - fetchData: async () => this.downloadFile(fileId), - }; - } - - rehydrateAttachment(attachment: Attachment): Attachment { - const fileId = attachment.fetchMetadata?.fileId; - if (!fileId) { - return attachment; - } - return { - ...attachment, fetchData: async () => this.downloadFile(fileId), }; } - protected async downloadFile(fileId: string): Promise { + private async downloadFile(fileId: string): Promise { const file = await this.telegramFetch("getFile", { file_id: fileId, }); @@ -1300,7 +1450,7 @@ export class TelegramAdapter return Buffer.from(await response.arrayBuffer()); } - protected async sendDocument( + private async sendDocument( thread: TelegramThreadId, file: { filename: string; @@ -1308,136 +1458,71 @@ export class TelegramAdapter mimeType?: string; }, text: string, + plainText: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode: TelegramParseMode = "plain" + parseMode?: string ): Promise { const buffer = await this.toTelegramBuffer(file.data); - const formData = new FormData(); - formData.append("chat_id", thread.chatId); - if (typeof thread.messageThreadId === "number") { - formData.append("message_thread_id", String(thread.messageThreadId)); - } - - if (text.trim()) { - formData.append( - "caption", - truncateForTelegram(text, TELEGRAM_CAPTION_LIMIT, parseMode) - ); - const botApiParseMode = toBotApiParseMode(parseMode); - if (botApiParseMode) { - formData.append("parse_mode", botApiParseMode); + 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), } - } - - const blob = new Blob([new Uint8Array(buffer)], { - type: file.mimeType ?? "application/octet-stream", - }); - formData.append("document", blob, file.filename); - if (replyMarkup) { - formData.append("reply_markup", JSON.stringify(replyMarkup)); - } - - return this.telegramFetch("sendDocument", formData); + ); } - protected async sendAttachment( + private createTelegramDocumentFormData( thread: TelegramThreadId, - attachment: Attachment, + file: { + filename: string; + mimeType?: string; + }, + buffer: Buffer, text: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode: TelegramParseMode = "plain" - ): Promise { - const upload = ATTACHMENT_UPLOADS[attachment.type]; - const data = - attachment.data ?? - (attachment.fetchData ? await attachment.fetchData() : undefined); - - if (!(data || attachment.url)) { - throw new ValidationError( - "telegram", - `Attachment data or URL required for ${attachment.type}` - ); - } - - if (!data) { - const payload: Record = { - chat_id: thread.chatId, - [upload.field]: attachment.url, - }; - - if (typeof thread.messageThreadId === "number") { - payload.message_thread_id = thread.messageThreadId; - } - - if (text.trim()) { - payload.caption = truncateForTelegram( - text, - TELEGRAM_CAPTION_LIMIT, - parseMode - ); - const botApiParseMode = toBotApiParseMode(parseMode); - if (botApiParseMode) { - payload.parse_mode = botApiParseMode; - } - } - - if (attachment.type === "video") { - if (Number.isInteger(attachment.width)) { - payload.width = attachment.width; - } - if (Number.isInteger(attachment.height)) { - payload.height = attachment.height; - } - } - - if (replyMarkup) { - payload.reply_markup = replyMarkup; - } - - return this.telegramFetch(upload.method, payload); - } - - const buffer = await this.toTelegramBuffer(data); + parseMode?: string + ): FormData { const formData = new FormData(); - formData.append("chat_id", thread.chatId); if (typeof thread.messageThreadId === "number") { formData.append("message_thread_id", String(thread.messageThreadId)); } if (text.trim()) { - formData.append( - "caption", - truncateForTelegram(text, TELEGRAM_CAPTION_LIMIT, parseMode) - ); - const botApiParseMode = toBotApiParseMode(parseMode); - if (botApiParseMode) { - formData.append("parse_mode", botApiParseMode); - } - } - - 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)); + formData.append("caption", this.truncateCaption(text)); + if (parseMode) { + formData.append("parse_mode", parseMode); } } const blob = new Blob([new Uint8Array(buffer)], { - type: attachment.mimeType ?? "application/octet-stream", + type: file.mimeType ?? "application/octet-stream", }); - formData.append(upload.field, blob, attachment.name ?? "attachment"); + formData.append("document", blob, file.filename); if (replyMarkup) { formData.append("reply_markup", JSON.stringify(replyMarkup)); } - return this.telegramFetch(upload.method, formData); + return formData; } - protected async toTelegramBuffer( + private async toTelegramBuffer( data: Buffer | Blob | ArrayBuffer ): Promise { if (Buffer.isBuffer(data)) { @@ -1452,7 +1537,7 @@ export class TelegramAdapter throw new ValidationError("telegram", "Unsupported file data type"); } - protected paginateMessages( + private paginateMessages( messages: Message[], options: FetchOptions ): FetchResult { @@ -1494,7 +1579,7 @@ export class TelegramAdapter }; } - protected cacheMessage(message: Message): void { + private cacheMessage(message: Message): void { const existing = this.messageCache.get(message.threadId) ?? []; const index = existing.findIndex((item) => item.id === message.id); @@ -1508,7 +1593,7 @@ export class TelegramAdapter this.messageCache.set(message.threadId, existing); } - protected findCachedMessage( + private findCachedMessage( messageId: string ): Message | undefined { for (const messages of this.messageCache.values()) { @@ -1521,7 +1606,7 @@ export class TelegramAdapter return undefined; } - protected deleteCachedMessage(messageId: string): void { + private deleteCachedMessage(messageId: string): void { for (const [threadId, messages] of this.messageCache.entries()) { const filtered = messages.filter((message) => message.id !== messageId); if (filtered.length === 0) { @@ -1532,7 +1617,7 @@ export class TelegramAdapter } } - protected compareMessages( + private compareMessages( a: Message, b: Message ): number { @@ -1545,12 +1630,18 @@ export class TelegramAdapter return this.messageSequence(a.id) - this.messageSequence(b.id); } - protected messageSequence(messageId: string): number { + private messageSequence(messageId: string): number { const match = messageId.match(MESSAGE_SEQUENCE_PATTERN); return match ? Number.parseInt(match[1], 10) : 0; } - protected resolveThreadId(value: string): TelegramThreadId { + private createDraftId(): number { + this.nextDraftId = + this.nextDraftId >= 2_147_483_647 ? 1 : this.nextDraftId + 1; + return this.nextDraftId; + } + + private resolveThreadId(value: string): TelegramThreadId { if (value.startsWith("telegram:")) { return this.decodeThreadId(value); } @@ -1558,11 +1649,11 @@ export class TelegramAdapter return { chatId: value }; } - protected encodeMessageId(chatId: string, messageId: number): string { + private encodeMessageId(chatId: string, messageId: number): string { return `${chatId}:${messageId}`; } - protected decodeCompositeMessageId( + private decodeCompositeMessageId( messageId: string, expectedChatId?: string ): { chatId: string; messageId: number; compositeId: string } { @@ -1608,7 +1699,7 @@ export class TelegramAdapter }; } - protected toAuthor(user: TelegramUser): TelegramMessageAuthor { + private toAuthor(user: TelegramUser): TelegramMessageAuthor { const fullName = [user.first_name, user.last_name] .filter(Boolean) .join(" ") @@ -1623,7 +1714,7 @@ export class TelegramAdapter }; } - protected toReactionActorAuthor(chat: TelegramChat): TelegramMessageAuthor { + private toReactionActorAuthor(chat: TelegramChat): TelegramMessageAuthor { const name = this.chatDisplayName(chat) ?? String(chat.id); return { userId: `chat:${chat.id}`, @@ -1634,7 +1725,7 @@ export class TelegramAdapter }; } - protected chatDisplayName(chat: TelegramChat): string | undefined { + private chatDisplayName(chat: TelegramChat): string | undefined { if (chat.title) { return chat.title; } @@ -1650,7 +1741,7 @@ export class TelegramAdapter return chat.username; } - protected isBotMentioned(message: TelegramMessage, text: string): boolean { + private isBotMentioned(message: TelegramMessage, text: string): boolean { if (!text) { return false; } @@ -1687,15 +1778,15 @@ export class TelegramAdapter return mentionRegex.test(text); } - protected entityText(text: string, entity: TelegramMessageEntity): string { + private entityText(text: string, entity: TelegramMessageEntity): string { return text.slice(entity.offset, entity.offset + entity.length); } - protected escapeRegex(input: string): string { + private escapeRegex(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - protected normalizeUserName(value: unknown): string { + private normalizeUserName(value: unknown): string { if (typeof value !== "string") { return "bot"; } @@ -1703,30 +1794,64 @@ export class TelegramAdapter return value.replace(LEADING_AT_PATTERN, "").trim() || "bot"; } - protected resolveParseMode( + private resolveParseMode( message: AdapterPostableMessage, card: ReturnType - ): TelegramParseMode { - // Cards and any message routed through the format converter are rendered - // as MarkdownV2, so Telegram must parse them with MarkdownV2. + ): string | undefined { + const hasMarkdown = + typeof message === "object" && message !== null && "markdown" in message; + return card || hasMarkdown ? TELEGRAM_MARKDOWN_PARSE_MODE : undefined; + } + + private renderPlainTextMessage( + message: AdapterPostableMessage, + card: ReturnType + ): string { if (card) { - return "MarkdownV2"; + return cardToFallbackText(card); } - // Plain strings and raw messages ship verbatim — no markdown parsing. if (typeof message === "string") { - return "plain"; + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.resolveTelegramFallbackText( + message.markdown, + markdownToPlainText(message.markdown) + ); } - if (typeof message === "object" && message !== null && "raw" in message) { - return "plain"; + if ("ast" in message) { + return toPlainText(message.ast); } - // Every other shape ({markdown}, {ast}, JSX, etc.) flows through - // formatConverter.renderPostable, which emits MarkdownV2. - return "MarkdownV2"; + return this.formatConverter.renderPostable(message); } - protected toTelegramReaction( - emoji: EmojiValue | string - ): TelegramReactionType { + private resolveTelegramFallbackText( + originalText: string, + fallbackText: string + ): string { + return fallbackText.trim() ? fallbackText : originalText; + } + + private truncateMessage(text: string): string { + if (text.length <= TELEGRAM_MESSAGE_LIMIT) { + return text; + } + + return `${text.slice(0, TELEGRAM_MESSAGE_LIMIT - 3)}...`; + } + + private truncateCaption(text: string): string { + if (text.length <= TELEGRAM_CAPTION_LIMIT) { + return text; + } + + return `${text.slice(0, TELEGRAM_CAPTION_LIMIT - 3)}...`; + } + + private toTelegramReaction(emoji: EmojiValue | string): TelegramReactionType { if (typeof emoji !== "string") { return { type: "emoji", @@ -1762,7 +1887,7 @@ export class TelegramAdapter }; } - protected reactionKey(reaction: TelegramReactionType): string { + private reactionKey(reaction: TelegramReactionType): string { if (reaction.type === "emoji") { return reaction.emoji; } @@ -1770,7 +1895,7 @@ export class TelegramAdapter return `custom:${reaction.custom_emoji_id}`; } - protected reactionToEmojiValue(reaction: TelegramReactionType): EmojiValue { + private reactionToEmojiValue(reaction: TelegramReactionType): EmojiValue { if (reaction.type === "emoji") { return defaultEmojiResolver.fromGChat(reaction.emoji); } @@ -1778,7 +1903,7 @@ export class TelegramAdapter return getEmoji(`custom:${reaction.custom_emoji_id}`); } - protected async pollingLoop( + private async pollingLoop( config: ResolvedTelegramLongPollingConfig ): Promise { let offset: number | undefined; @@ -1842,7 +1967,7 @@ export class TelegramAdapter } } - protected resolvePollingConfig( + private resolvePollingConfig( override?: TelegramLongPollingConfig ): ResolvedTelegramLongPollingConfig { const baseConfig = this.longPolling ?? {}; @@ -1879,7 +2004,7 @@ export class TelegramAdapter }; } - protected clampInteger( + private clampInteger( value: number | undefined, fallback: number, min: number, @@ -1893,11 +2018,11 @@ export class TelegramAdapter return Math.max(min, Math.min(max, parsed)); } - protected isAbortError(error: unknown): boolean { + private isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } - protected async sleep(delayMs: number): Promise { + private async sleep(delayMs: number): Promise { if (delayMs <= 0) { return; } @@ -1907,7 +2032,7 @@ export class TelegramAdapter }); } - protected async telegramFetch( + private async telegramFetch( method: string, payload?: Record | FormData, request?: { @@ -1966,7 +2091,7 @@ export class TelegramAdapter return data.result; } - protected throwTelegramApiError( + private throwTelegramApiError( method: string, status: number, data: TelegramApiResponse @@ -1999,6 +2124,56 @@ export class TelegramAdapter `${description} (status ${status}, error ${errorCode})` ); } + + private async withTelegramMarkdownFallback( + parseMode: string | undefined, + operation: ( + parseMode: string | undefined, + 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 !== TELEGRAM_MARKDOWN_PARSE_MODE || + !this.isTelegramMarkdownParseError(error) + ) { + throw error; + } + + this.logger.warn( + "Telegram markdown parse failed; retrying without parse mode", + { + error: String(error), + ...context, + } + ); + + return operation( + undefined, + this.resolveTelegramFallbackText( + context.initialText, + context.fallbackText + ) + ); + } + } + + private isTelegramMarkdownParseError(error: unknown): boolean { + return ( + error instanceof ValidationError && + error.adapter === "telegram" && + TELEGRAM_MARKDOWN_PARSE_ERROR_PATTERN.test(error.message) + ); + } } export function createTelegramAdapter( @@ -2007,7 +2182,7 @@ export function createTelegramAdapter( return new TelegramAdapter(config ?? {}); } -export { escapeMarkdownV2, TelegramFormatConverter } from "./markdown"; +export { TelegramFormatConverter } from "./markdown"; export type { TelegramAdapterConfig, TelegramAdapterMode, @@ -2017,7 +2192,6 @@ export type { TelegramMessage, TelegramMessageReactionUpdated, TelegramRawMessage, - TelegramReactionType, TelegramThreadId, TelegramUpdate, TelegramUser, 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/index.ts b/packages/chat/src/index.ts index 14a7c7f70..6f477eaec 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -398,6 +398,8 @@ export type { StreamChunk, StreamEvent, StreamOptions, + StreamResult, + StreamSegment, SubscribedMessageHandler, TaskUpdateChunk, Thread, diff --git a/packages/chat/src/streaming-markdown.test.ts b/packages/chat/src/streaming-markdown.test.ts index cddced856..b42a50c7f 100644 --- a/packages/chat/src/streaming-markdown.test.ts +++ b/packages/chat/src/streaming-markdown.test.ts @@ -139,6 +139,67 @@ describe("StreamingMarkdownRenderer", () => { expect(r.render()).toBe("Hello world"); }); + it("returns the clean committed prefix before an unclosed inline marker", () => { + const r = new StreamingMarkdownRenderer(); + r.push("Hello **wor"); + + expect(r.getCommittedMarkdownPrefix()).toBe("Hello "); + }); + + it("trims dangling trailing markers with no content", () => { + const r = new StreamingMarkdownRenderer(); + r.push("Hello **"); + + expect(r.getCommittedMarkdownPrefix()).toBe("Hello "); + }); + + it("preserves valid closing bold markers in the committed prefix", () => { + const r = new StreamingMarkdownRenderer(); + r.push("Hello **world**"); + + expect(r.getCommittedMarkdownPrefix()).toBe("Hello **world**"); + }); + + it("preserves valid closing code markers in the committed prefix", () => { + const r = new StreamingMarkdownRenderer(); + r.push("Use `code` and then:\n```ts\nconst x = 1;\n```\n"); + + expect(r.getCommittedMarkdownPrefix()).toBe( + "Use `code` and then:\n```ts\nconst x = 1;\n```\n" + ); + }); + + it("preserves the full committed prefix for an exact-limit clean markdown segment", () => { + const r = new StreamingMarkdownRenderer(); + const text = `${"a".repeat(3494)}**ok**`; + r.push(text); + + expect(r.getCommittedMarkdownPrefix()).toBe(text); + }); + + it("preserves an escaped trailing bracket in the committed prefix", () => { + const r = new StreamingMarkdownRenderer(); + r.push("Telegram literal \\["); + + expect(r.getCommittedMarkdownPrefix()).toBe("Telegram literal \\["); + }); + + it("returns the committed prefix before an open code fence", () => { + const r = new StreamingMarkdownRenderer(); + r.push("Intro\n```ts\nconst x = 1;"); + + expect(r.getCommittedMarkdownPrefix()).toBe("Intro\n"); + }); + + it("returns the prefix before the last unmatched code fence", () => { + const r = new StreamingMarkdownRenderer(); + r.push("Intro\n```ts\nconst a = 1;\n```\nBetween\n```js\nconst b = 2;"); + + expect(r.getCommittedMarkdownPrefix()).toBe( + "Intro\n```ts\nconst a = 1;\n```\nBetween\n" + ); + }); + it("should handle table header without trailing newline (incomplete line)", () => { const r = new StreamingMarkdownRenderer(); r.push("Text\n\n| A | B |"); diff --git a/packages/chat/src/streaming-markdown.ts b/packages/chat/src/streaming-markdown.ts index 3f6633ac5..d1b9415d9 100644 --- a/packages/chat/src/streaming-markdown.ts +++ b/packages/chat/src/streaming-markdown.ts @@ -145,6 +145,27 @@ export class StreamingMarkdownRenderer { return findCleanPrefix(wrapped); } + /** + * Get the longest source prefix that can be finalized as a standalone, + * markdown-safe message. + * + * Unlike `render()`, this never synthesizes missing closing markers. The + * returned string is always a prefix of the original accumulated source, + * which makes it safe to split a long stream into multiple persisted + * messages without duplicating or dropping source text. + */ + getCommittedMarkdownPrefix(): string { + let committable = this.accumulated; + + if (!this.finished) { + committable = this.isAccumulatedInsideFence() + ? getPrefixBeforeTrailingOpenFence(this.accumulated) + : getCommittablePrefix(this.accumulated); + } + + return trimTrailingUnmatchedMarkerOpeners(findCleanPrefix(committable)); + } + /** Raw accumulated text (no remend, no buffering). For the final edit. */ getText(): string { return this.accumulated; @@ -177,7 +198,8 @@ const INLINE_MARKER_CHARS = new Set(["*", "~", "`", "["]); * from otherwise clean text (which is harmless for streaming). */ function isClean(text: string): boolean { - return remend(text).length <= text.length; + const sanitized = sanitizeEscapedLinkOpeners(text); + return remend(sanitized).length <= sanitized.length; } /** @@ -196,8 +218,12 @@ function findCleanPrefix(text: string): string { for (let i = text.length - 1; i >= 0; i--) { if (INLINE_MARKER_CHARS.has(text[i])) { + if (isEscaped(text, i)) { + continue; + } + // Group consecutive same characters (e.g., ** or ~~) - while (i > 0 && text[i - 1] === text[i]) { + while (i > 0 && text[i - 1] === text[i] && !isEscaped(text, i - 1)) { i--; } const candidate = text.slice(0, i); @@ -297,6 +323,119 @@ function getCommittablePrefix(text: string): string { return result; } +function getPrefixBeforeTrailingOpenFence(text: string): string { + let offset = 0; + let insideFence = false; + let lastOpenOffset = -1; + + for (const line of text.split("\n")) { + const lineLengthWithNewline = offset + line.length < text.length ? 1 : 0; + const trimmed = line.trimStart(); + + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + if (insideFence) { + insideFence = false; + lastOpenOffset = -1; + } else { + insideFence = true; + lastOpenOffset = offset; + } + } + + offset += line.length + lineLengthWithNewline; + } + + return insideFence && lastOpenOffset >= 0 + ? text.slice(0, lastOpenOffset) + : text; +} + +function trimTrailingUnmatchedMarkerOpeners(text: string): string { + let result = text; + + while (true) { + if (result.endsWith("**") && hasOddUnescapedTokenCount(result, "**")) { + result = result.slice(0, -2); + continue; + } + + if (result.endsWith("~~") && hasOddUnescapedTokenCount(result, "~~")) { + result = result.slice(0, -2); + continue; + } + + if ( + result.endsWith("*") && + !result.endsWith("**") && + hasOddUnescapedCharCount(result, "*") + ) { + result = result.slice(0, -1); + continue; + } + + if (result.endsWith("`") && hasOddUnescapedCharCount(result, "`")) { + result = result.slice(0, -1); + continue; + } + + if (result.endsWith("[") && !isEscaped(result, result.length - 1)) { + result = result.slice(0, -1); + continue; + } + + return result; + } +} + +function hasOddUnescapedTokenCount(text: string, token: string): boolean { + let count = 0; + + for (let i = 0; i <= text.length - token.length; ) { + if (text.slice(i, i + token.length) === token && !isEscaped(text, i)) { + count++; + i += token.length; + continue; + } + + i++; + } + + return count % 2 === 1; +} + +function hasOddUnescapedCharCount(text: string, char: string): boolean { + let count = 0; + + for (let i = 0; i < text.length; i++) { + if (text[i] === char && !isEscaped(text, i)) { + count++; + } + } + + return count % 2 === 1; +} + +function isEscaped(text: string, index: number): boolean { + let backslashCount = 0; + + for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) { + backslashCount++; + } + + return backslashCount % 2 === 1; +} + +function sanitizeEscapedLinkOpeners(text: string): string { + let result = ""; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + result += char === "[" && isEscaped(text, i) ? "(" : char; + } + + return result; +} + /** * Wraps confirmed GFM table blocks in code fences for append-only streaming. * diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index 455fbc983..104184985 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -1,17 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { decodeCallbackValue } from "./callback-url"; -import { Actions, Button, Card } from "./cards"; -import type { Message } from "./message"; +import { Card } from "./cards"; +import { MessageHistoryCache } from "./message-history"; import { createMockAdapter, createMockState, createTestMessage, mockLogger, } from "./mock-adapter"; -import { Plan } from "./plan"; -import { StreamingPlan } from "./streaming-plan"; import { ThreadImpl } from "./thread"; -import type { Adapter, ScheduledMessage, StreamChunk } from "./types"; +import type { Adapter, Message, ScheduledMessage, StreamChunk } from "./types"; import { NotImplementedError } from "./types"; describe("ThreadImpl", () => { @@ -217,6 +214,73 @@ describe("ThreadImpl", () => { expect(mockAdapter.postMessage).not.toHaveBeenCalled(); }); + it("should append segmented native stream messages individually to history", async () => { + const messageHistory = new MessageHistoryCache(mockState); + thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + messageHistory, + }); + + mockAdapter.stream = vi.fn().mockResolvedValue({ + messages: [ + { + message: { + id: "msg-1", + threadId: "slack:C123:1234.5678", + raw: {}, + }, + postable: { markdown: "Hello " }, + }, + { + message: { + id: "msg-2", + threadId: "slack:C123:1234.5678", + raw: {}, + }, + postable: { markdown: "World" }, + }, + ], + }); + + const textStream = createTextStream(["Hello", " ", "World"]); + const result = await thread.post(textStream); + const stored = await messageHistory.getMessages("slack:C123:1234.5678"); + + expect(result.id).toBe("msg-2"); + expect(result.text).toBe("World"); + expect(result.segments?.map((segment) => segment.id)).toEqual([ + "msg-1", + "msg-2", + ]); + expect(stored.map((message) => message.id)).toEqual(["msg-1", "msg-2"]); + expect(stored.map((message) => message.text)).toEqual(["Hello", "World"]); + }); + + 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; @@ -323,8 +387,12 @@ describe("ThreadImpl", () => { "slack:C123:1234.5678", "..." ); - // Should not edit with empty content - expect(mockAdapter.editMessage).not.toHaveBeenCalled(); + // Should edit with empty string wrapped as markdown (final content) + expect(mockAdapter.editMessage).toHaveBeenLastCalledWith( + "slack:C123:1234.5678", + "msg-1", + { markdown: "" } + ); }); it("should support disabling the placeholder for fallback streaming", async () => { @@ -366,81 +434,15 @@ describe("ThreadImpl", () => { const textStream = createTextStream([]); await threadNoPlaceholder.post(textStream); - // Should post a non-empty fallback since stream must return a SentMessage + // Should still post a message (empty) even with no chunks, wrapped as markdown expect(mockAdapter.postMessage).toHaveBeenCalledWith( "slack:C123:1234.5678", - { markdown: " " } + { markdown: "" } ); + // No edit needed since post content matches accumulated expect(mockAdapter.editMessage).not.toHaveBeenCalled(); }); - it("should not post empty content when table is buffered with null placeholder", async () => { - mockAdapter.stream = undefined; - - const threadNoPlaceholder = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - fallbackStreamingPlaceholderText: null, - }); - - const textStream = createTextStream([ - "| A | B |\n", - "|---|---|\n", - "| 1 | 2 |\n", - ]); - await threadNoPlaceholder.post(textStream); - - const postCalls = (mockAdapter.postMessage as ReturnType) - .mock.calls; - for (const call of postCalls) { - const content = call[1]; - if (typeof content === "object" && "markdown" in content) { - expect(content.markdown.trim().length).toBeGreaterThan(0); - } - } - }); - - it("should not edit placeholder to empty during LLM warm-up", async () => { - mockAdapter.stream = undefined; - const editFn = mockAdapter.editMessage as ReturnType; - - const textStream = createTextStream(["Hello world"]); - await thread.post(textStream); - - for (const call of editFn.mock.calls) { - const content = call[2]; - if (typeof content === "object" && "markdown" in content) { - expect(content.markdown.trim().length).toBeGreaterThan(0); - } - } - }); - - it("should not post empty content during streaming with whitespace chunks", async () => { - mockAdapter.stream = undefined; - - const threadNoPlaceholder = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - fallbackStreamingPlaceholderText: null, - }); - - const textStream = createTextStream([" ", "\n", " \n"]); - await threadNoPlaceholder.post(textStream); - - const postCalls = (mockAdapter.postMessage as ReturnType) - .mock.calls; - for (const call of postCalls) { - const content = call[1]; - if (typeof content === "object" && "markdown" in content) { - expect(content.markdown.length).toBeGreaterThan(0); - } - } - }); - it("should preserve newlines in streamed text (native path)", async () => { let capturedChunks: string[] = []; const mockStream = vi @@ -597,34 +599,7 @@ describe("ThreadImpl", () => { } }); - it.each([ - { - expectedTeamId: "T123", - label: "team_id", - raw: { team_id: "T123", type: "app_mention" }, - }, - { - expectedTeamId: "T234", - label: "team string", - raw: { team: "T234", type: "message" }, - }, - { - expectedTeamId: "T345", - label: "team.id", - raw: { team: { id: "T345" }, type: "block_actions" }, - }, - { - expectedTeamId: "T456", - label: "user.team_id fallback", - raw: { - type: "block_actions", - user: { team_id: "T456" }, - }, - }, - ])("should pass stream options from Slack current message context via $label", async ({ - raw, - expectedTeamId, - }) => { + it("should pass stream options from current message context", async () => { const mockStream = vi.fn().mockResolvedValue({ id: "msg-stream", threadId: "t1", @@ -632,13 +607,18 @@ describe("ThreadImpl", () => { }); mockAdapter.stream = mockStream; + // Create thread with current message context const threadWithContext = new ThreadImpl({ id: "slack:C123:1234.5678", adapter: mockAdapter, channelId: "C123", stateAdapter: mockState, - currentMessage: createTestMessage("original-msg", "test", { - raw, + currentMessage: { + id: "original-msg", + threadId: "slack:C123:1234.5678", + text: "test", + formatted: { type: "root", children: [] }, + raw: { team_id: "T123" }, author: { userId: "U456", userName: "user", @@ -646,7 +626,9 @@ describe("ThreadImpl", () => { isBot: false, isMe: false, }, - }), + metadata: { dateSent: new Date(), edited: false }, + attachments: [], + }, }); const textStream = createTextStream(["Hello"]); @@ -657,209 +639,9 @@ describe("ThreadImpl", () => { expect.any(Object), expect.objectContaining({ recipientUserId: "U456", - recipientTeamId: expectedTeamId, - }) - ); - }); - - it("should forward structured stream chunks to adapter.stream from an action-created thread", async () => { - const mockStream = vi.fn().mockResolvedValue({ - id: "msg-stream", - threadId: "t1", - raw: "Hello", - }); - mockAdapter.stream = mockStream; - - const threadWithActionContext = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - currentMessage: createTestMessage("action-msg", "", { - raw: { - team: { domain: "workspace", id: "T123" }, - type: "block_actions", - }, - author: { - userId: "U456", - userName: "user", - fullName: "Test User", - isBot: false, - isMe: false, - }, - }), - }); - - const taskChunk: StreamChunk = { - id: "task-1", - status: "pending", - title: "Thinking", - type: "task_update", - }; - async function* structuredStream(): AsyncIterable { - yield "Picking option..."; - yield taskChunk; - } - - await threadWithActionContext.post( - structuredStream() as unknown as AsyncIterable - ); - - expect(mockStream).toHaveBeenCalledTimes(1); - const [, passedStream] = mockStream.mock.calls[0]; - const collected: Array = []; - for await (const chunk of passedStream as AsyncIterable< - string | StreamChunk - >) { - collected.push(chunk); - } - expect(collected).toContain("Picking option..."); - expect(collected).toContainEqual(taskChunk); - }); - - it("should pass StreamingPlan PostableObject options to adapter.stream", async () => { - const mockStream = vi.fn().mockResolvedValue({ - id: "msg-stream", - threadId: "t1", - raw: "Hello", - }); - mockAdapter.stream = mockStream; - - const textStream = createTextStream(["Hello"]); - const streamMsg = new StreamingPlan(textStream, { - groupTasks: "plan", - endWith: [{ type: "actions" }], - updateIntervalMs: 1000, - }); - await thread.post(streamMsg); - - expect(mockStream).toHaveBeenCalledWith( - "slack:C123:1234.5678", - expect.any(Object), - expect.objectContaining({ - taskDisplayMode: "plan", - stopBlocks: [{ type: "actions" }], - updateIntervalMs: 1000, - }) - ); - }); - - it("should pass StreamingPlan with only groupTasks", async () => { - const mockStream = vi.fn().mockResolvedValue({ - id: "msg-stream", - threadId: "t1", - raw: "Hello", - }); - mockAdapter.stream = mockStream; - - const textStream = createTextStream(["Hello"]); - await thread.post( - new StreamingPlan(textStream, { groupTasks: "timeline" }) - ); - - expect(mockStream).toHaveBeenCalledWith( - "slack:C123:1234.5678", - expect.any(Object), - expect.objectContaining({ - taskDisplayMode: "timeline", - }) - ); - const options = mockStream.mock.calls[0][2]; - expect(options.stopBlocks).toBeUndefined(); - }); - - it("should pass StreamingPlan with only endWith", async () => { - const mockStream = vi.fn().mockResolvedValue({ - id: "msg-stream", - threadId: "t1", - raw: "Hello", - }); - mockAdapter.stream = mockStream; - - const textStream = createTextStream(["Hello"]); - await thread.post( - new StreamingPlan(textStream, { endWith: [{ type: "actions" }] }) - ); - - expect(mockStream).toHaveBeenCalledWith( - "slack:C123:1234.5678", - expect.any(Object), - expect.objectContaining({ - stopBlocks: [{ type: "actions" }], - }) - ); - const options = mockStream.mock.calls[0][2]; - expect(options.taskDisplayMode).toBeUndefined(); - }); - - it("should pass StreamingPlan with only updateIntervalMs", async () => { - const mockStream = vi.fn().mockResolvedValue({ - id: "msg-stream", - threadId: "t1", - raw: "Hello", - }); - mockAdapter.stream = mockStream; - - const textStream = createTextStream(["Hello"]); - await thread.post( - new StreamingPlan(textStream, { updateIntervalMs: 2000 }) - ); - - expect(mockStream).toHaveBeenCalledWith( - "slack:C123:1234.5678", - expect.any(Object), - expect.objectContaining({ - updateIntervalMs: 2000, + recipientTeamId: "T123", }) ); - const options = mockStream.mock.calls[0][2]; - expect(options.taskDisplayMode).toBeUndefined(); - expect(options.stopBlocks).toBeUndefined(); - }); - - it("should route StreamingPlan through fallback when adapter has no native streaming", async () => { - mockAdapter.stream = undefined; - - const textStream = createTextStream(["Hello", " ", "World"]); - await thread.post( - new StreamingPlan(textStream, { - groupTasks: "plan", - endWith: [{ type: "actions" }], - updateIntervalMs: 2000, - }) - ); - - // Should post initial placeholder and edit with final content - expect(mockAdapter.postMessage).toHaveBeenCalledWith( - "slack:C123:1234.5678", - "..." - ); - expect(mockAdapter.editMessage).toHaveBeenLastCalledWith( - "slack:C123:1234.5678", - "msg-1", - { markdown: "Hello World" } - ); - }); - - it("should still work without options (backward compat)", async () => { - const mockStream = vi.fn().mockResolvedValue({ - id: "msg-stream", - threadId: "t1", - raw: "Hello", - }); - mockAdapter.stream = mockStream; - - const textStream = createTextStream(["Hello"]); - await thread.post(textStream); - - expect(mockStream).toHaveBeenCalledWith( - "slack:C123:1234.5678", - expect.any(Object), - expect.any(Object) - ); - const options = mockStream.mock.calls[0][2]; - expect(options.taskDisplayMode).toBeUndefined(); - expect(options.stopBlocks).toBeUndefined(); }); }); @@ -934,7 +716,6 @@ describe("ThreadImpl", () => { type: "task_update" as const, id: "tool-1", title: "Running bash", - details: "Installing dependencies", status: "in_progress", }; yield "world"; @@ -942,7 +723,6 @@ describe("ThreadImpl", () => { type: "task_update" as const, id: "tool-1", title: "Running bash", - details: "Installed dependencies", status: "complete", output: "Done", }; @@ -957,20 +737,11 @@ describe("ThreadImpl", () => { expect(capturedChunks).toHaveLength(4); expect(capturedChunks[0]).toBe("Hello "); expect(capturedChunks[1]).toEqual( - expect.objectContaining({ - type: "task_update", - details: "Installing dependencies", - status: "in_progress", - }) + expect.objectContaining({ type: "task_update", status: "in_progress" }) ); expect(capturedChunks[2]).toBe("world"); expect(capturedChunks[3]).toEqual( - expect.objectContaining({ - type: "task_update", - details: "Installed dependencies", - output: "Done", - status: "complete", - }) + expect.objectContaining({ type: "task_update", status: "complete" }) ); // Accumulated text should only include strings, not task_update chunks @@ -1019,7 +790,6 @@ describe("ThreadImpl", () => { type: "task_update" as const, id: "tool-1", title: "Running bash", - details: "Installing dependencies", status: "in_progress", }; yield " World"; @@ -1579,7 +1349,7 @@ describe("ThreadImpl", () => { // AdapterPostableMessage | CardJSXElement which excludes AsyncIterable }); - describe("post with Plan", () => { + describe("subscribe and unsubscribe", () => { let thread: ThreadImpl; let mockAdapter: Adapter; let mockState: ReturnType; @@ -1596,444 +1366,27 @@ describe("ThreadImpl", () => { }); }); - it("should post fallback text when adapter does not support plans", async () => { - const plan = new Plan({ initialMessage: "Starting..." }); - await thread.post(plan); - - // Should have posted fallback text via postMessage - expect(mockAdapter.postMessage).toHaveBeenCalledWith( - "slack:C123:1234.5678", - expect.stringContaining("Starting...") - ); + it("should subscribe via state adapter", async () => { + await thread.subscribe(); - expect(plan.title).toBe("Starting..."); - expect(plan.tasks).toHaveLength(1); - expect(plan.tasks[0].status).toBe("in_progress"); - expect(plan.id).toBe("msg-1"); + expect(mockState.subscribe).toHaveBeenCalledWith("slack:C123:1234.5678"); }); - it("should update via editMessage in fallback mode", async () => { - const plan = new Plan({ initialMessage: "Starting..." }); - await thread.post(plan); + it("should call adapter.onThreadSubscribe when available", async () => { + const mockOnSubscribe = vi.fn().mockResolvedValue(undefined); + mockAdapter.onThreadSubscribe = mockOnSubscribe; - const task = await plan.addTask({ title: "Task 1" }); - expect(task).not.toBeNull(); - expect(task?.title).toBe("Task 1"); + await thread.subscribe(); - // Should edit the message with updated fallback text - expect(mockAdapter.editMessage).toHaveBeenCalledWith( - "slack:C123:1234.5678", - "msg-1", - expect.stringContaining("Task 1") - ); + expect(mockOnSubscribe).toHaveBeenCalledWith("slack:C123:1234.5678"); }); - it("should complete plan via editMessage in fallback mode", async () => { - const plan = new Plan({ initialMessage: "Starting..." }); - await thread.post(plan); + it("should not error when adapter has no onThreadSubscribe", async () => { + mockAdapter.onThreadSubscribe = undefined; - await plan.addTask({ title: "Step 1" }); - await plan.complete({ completeMessage: "All done!" }); - - expect(plan.title).toBe("All done!"); - for (const task of plan.tasks) { - expect(task.status).toBe("complete"); - } - - // Last editMessage call should contain completed status icons - const lastCall = ( - mockAdapter.editMessage as ReturnType - ).mock.calls.at(-1); - expect(lastCall?.[2]).toContain("✅"); - }); - - it("should call adapter postObject when supported", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Working..." }); - await thread.post(plan); - - expect(mockPostObject).toHaveBeenCalledWith( - "slack:C123:1234.5678", - "plan", - expect.objectContaining({ - title: "Working...", - tasks: expect.arrayContaining([ - expect.objectContaining({ - title: "Working...", - status: "in_progress", - }), - ]), - }) - ); - expect(plan.id).toBe("plan-msg-1"); - }); - - it("should add tasks and call editObject", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Starting" }); - await thread.post(plan); - const task = await plan.addTask({ - title: "Fetch data", - children: ["Call API", "Parse response"], - }); - - expect(task).not.toBeNull(); - expect(task?.title).toBe("Fetch data"); - expect(task?.status).toBe("in_progress"); - expect(mockEditObject).toHaveBeenCalled(); - - // Plan title should be updated to current task - expect(plan.title).toBe("Fetch data"); - expect(plan.tasks).toHaveLength(2); - }); - - it("should update current task with output", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Working" }); - await thread.post(plan); - await plan.addTask({ title: "Step 1" }); - const updated = await plan.updateTask("Got result: 42"); - - expect(updated).not.toBeNull(); - expect(mockEditObject).toHaveBeenCalled(); - }); - - it("should update a specific task by ID", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Start" }); - await thread.post(plan); - const task1 = await plan.addTask({ title: "Step 1" }); - const task2 = await plan.addTask({ title: "Step 2" }); - - const updated = await plan.updateTask({ - id: task1?.id, - output: "Step 1 result", - status: "complete", - }); - - expect(updated).not.toBeNull(); - expect(updated?.id).toBe(task1?.id); - expect(updated?.status).toBe("complete"); - - const step2 = plan.tasks.find((t) => t.id === task2?.id); - expect(step2?.status).toBe("in_progress"); - }); - - it("should return null when updating by non-existent ID", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Start" }); - await thread.post(plan); - await plan.addTask({ title: "Step 1" }); - - const updated = await plan.updateTask({ - id: "non-existent-id", - output: "nope", - }); - - expect(updated).toBeNull(); - }); - - it("should still update last in_progress task when no ID provided", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Start" }); - await thread.post(plan); - await plan.addTask({ title: "Step 1" }); - await plan.addTask({ title: "Step 2" }); - - const updated = await plan.updateTask("Some output"); - - expect(updated).not.toBeNull(); - expect(updated?.title).toBe("Step 2"); - }); - - it("should complete plan and mark tasks done", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Starting" }); - await thread.post(plan); - await plan.addTask({ title: "Task 1" }); - await plan.complete({ completeMessage: "All done!" }); - - expect(plan.title).toBe("All done!"); - // All tasks should be completed - for (const task of plan.tasks) { - expect(task.status).toBe("complete"); - } - }); - - it("should reset plan and start fresh", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "First run" }); - await thread.post(plan); - await plan.addTask({ title: "Task A" }); - await plan.addTask({ title: "Task B" }); - - expect(plan.tasks).toHaveLength(3); - - const newTask = await plan.reset({ initialMessage: "Second run" }); - expect(newTask).not.toBeNull(); - expect(plan.title).toBe("Second run"); - expect(plan.tasks).toHaveLength(1); - expect(plan.tasks[0].status).toBe("in_progress"); - }); - - it("should return currentTask correctly", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Start" }); - await thread.post(plan); - - // Initially, current task is the first one - let current = plan.currentTask; - expect(current?.title).toBe("Start"); - expect(current?.status).toBe("in_progress"); - - // After adding a new task, current should be the new one - await plan.addTask({ title: "Step 2" }); - current = plan.currentTask; - expect(current?.title).toBe("Step 2"); - expect(current?.status).toBe("in_progress"); - - // After completion, currentTask returns the last task - await plan.complete({ completeMessage: "Done" }); - current = plan.currentTask; - expect(current?.title).toBe("Step 2"); - expect(current?.status).toBe("complete"); - }); - - it("should handle various PlanContent formats in initialMessage", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - // String - let plan = new Plan({ initialMessage: "Simple string" }); - await thread.post(plan); - expect(plan.title).toBe("Simple string"); - - // Array of strings - plan = new Plan({ initialMessage: ["Line 1", "Line 2"] }); - await thread.post(plan); - expect(plan.title).toBe("Line 1 Line 2"); - - // Empty string defaults to "Plan" - plan = new Plan({ initialMessage: "" }); - await thread.post(plan); - expect(plan.title).toBe("Plan"); - }); - - it("should ensure sequential edits via queue", async () => { - const editOrder: number[] = []; - let editCount = 0; - - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockImplementation(async () => { - const myOrder = ++editCount; - // Simulate varying async delays - await new Promise((r) => setTimeout(r, Math.random() * 10)); - editOrder.push(myOrder); - }); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Start" }); - await thread.post(plan); - - // Fire off multiple updates concurrently - await Promise.all([ - plan.addTask({ title: "Task 1" }), - plan.updateTask("Output 1"), - plan.addTask({ title: "Task 2" }), - ]); - - // Despite random delays, edits should complete in order - expect(editOrder).toEqual([1, 2, 3]); - }); - - it("should return null when calling addTask before post", async () => { - const plan = new Plan({ initialMessage: "Not posted yet" }); - const task = await plan.addTask({ title: "Task 1" }); - expect(task).toBeNull(); - }); - - it("should return null when calling updateTask before post", async () => { - const plan = new Plan({ initialMessage: "Not posted yet" }); - const updated = await plan.updateTask("some output"); - expect(updated).toBeNull(); - }); - - it("should return null when calling complete before post", async () => { - const plan = new Plan({ initialMessage: "Not posted yet" }); - await plan.complete({ completeMessage: "Done" }); - expect(plan.tasks[0].status).toBe("in_progress"); - }); - - it("should propagate editObject errors from addTask", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi - .fn() - .mockRejectedValue(new Error("rate limited")); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Start" }); - await thread.post(plan); - - await expect(plan.addTask({ title: "Task 1" })).rejects.toThrow( - "rate limited" - ); - expect(plan.tasks).toHaveLength(2); - }); - - it("should continue accepting edits after a failed edit", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi - .fn() - .mockRejectedValueOnce(new Error("rate limited")) - .mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Start" }); - await thread.post(plan); - - await expect(plan.addTask({ title: "Task 1" })).rejects.toThrow(); - await plan.addTask({ title: "Task 2" }); - expect(plan.tasks).toHaveLength(3); - expect(mockEditObject).toHaveBeenCalledTimes(2); - }); - - it("should set error status via updateTask", async () => { - const mockPostObject = vi.fn().mockResolvedValue({ - id: "plan-msg-1", - threadId: "slack:C123:1234.5678", - }); - const mockEditObject = vi.fn().mockResolvedValue(undefined); - mockAdapter.postObject = mockPostObject; - mockAdapter.editObject = mockEditObject; - - const plan = new Plan({ initialMessage: "Start" }); - await thread.post(plan); - await plan.addTask({ title: "Risky step" }); - await plan.updateTask({ status: "error", output: "Something failed" }); - - const current = plan.currentTask; - expect(current?.status).toBe("error"); - }); - }); - - describe("subscribe and unsubscribe", () => { - let thread: ThreadImpl; - let mockAdapter: Adapter; - let mockState: ReturnType; - - beforeEach(() => { - mockAdapter = createMockAdapter(); - mockState = createMockState(); - - thread = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - }); - }); - - it("should subscribe via state adapter", async () => { - await thread.subscribe(); - - expect(mockState.subscribe).toHaveBeenCalledWith("slack:C123:1234.5678"); - }); - - it("should call adapter.onThreadSubscribe when available", async () => { - const mockOnSubscribe = vi.fn().mockResolvedValue(undefined); - mockAdapter.onThreadSubscribe = mockOnSubscribe; - - await thread.subscribe(); - - expect(mockOnSubscribe).toHaveBeenCalledWith("slack:C123:1234.5678"); - }); - - it("should not error when adapter has no onThreadSubscribe", async () => { - mockAdapter.onThreadSubscribe = undefined; - - await expect(thread.subscribe()).resolves.toBeUndefined(); - expect(mockState.subscribe).toHaveBeenCalledWith("slack:C123:1234.5678"); - }); + await expect(thread.subscribe()).resolves.toBeUndefined(); + expect(mockState.subscribe).toHaveBeenCalledWith("slack:C123:1234.5678"); + }); it("should unsubscribe via state adapter", async () => { await thread.subscribe(); @@ -2946,312 +2299,4 @@ describe("ThreadImpl", () => { expect(cancel2).not.toHaveBeenCalled(); }); }); - - describe("getParticipants", () => { - it("should return unique non-bot authors from messages", async () => { - const mockAdapter = createMockAdapter(); - const mockState = createMockState(); - - const msg1 = createTestMessage("1", "Hello", { - author: { - userId: "U1", - userName: "alice", - fullName: "Alice", - isBot: false, - isMe: false, - }, - }); - const msg2 = createTestMessage("2", "Hi", { - author: { - userId: "U2", - userName: "bob", - fullName: "Bob", - isBot: false, - isMe: false, - }, - }); - const msg3 = createTestMessage("3", "Hello again", { - author: { - userId: "U1", - userName: "alice", - fullName: "Alice", - isBot: false, - isMe: false, - }, - }); - - mockAdapter.fetchMessages = vi - .fn() - .mockResolvedValue({ messages: [msg1, msg2, msg3], nextCursor: null }); - - const thread = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - }); - - const participants = await thread.getParticipants(); - expect(participants).toHaveLength(2); - expect(participants.map((p) => p.userId)).toEqual( - expect.arrayContaining(["U1", "U2"]) - ); - }); - - it("should exclude bot messages", async () => { - const mockAdapter = createMockAdapter(); - const mockState = createMockState(); - - const humanMsg = createTestMessage("1", "Hello", { - author: { - userId: "U1", - userName: "alice", - fullName: "Alice", - isBot: false, - isMe: false, - }, - }); - const selfBotMsg = createTestMessage("2", "Hi there!", { - author: { - userId: "B1", - userName: "bot", - fullName: "Bot", - isBot: true, - isMe: true, - }, - }); - const thirdPartyBotMsg = createTestMessage("3", "Notification", { - author: { - userId: "B2", - userName: "jira-bot", - fullName: "Jira Bot", - isBot: true, - isMe: false, - }, - }); - - mockAdapter.fetchMessages = vi.fn().mockResolvedValue({ - messages: [humanMsg, selfBotMsg, thirdPartyBotMsg], - nextCursor: null, - }); - - const thread = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - }); - - const participants = await thread.getParticipants(); - expect(participants).toHaveLength(1); - expect(participants[0].userId).toBe("U1"); - }); - - it("should return empty array for thread with only bot messages", async () => { - const mockAdapter = createMockAdapter(); - const mockState = createMockState(); - - mockAdapter.fetchMessages = vi.fn().mockResolvedValue({ - messages: [ - createTestMessage("1", "Bot message", { - author: { - userId: "B1", - userName: "bot", - fullName: "Bot", - isBot: true, - isMe: true, - }, - }), - ], - nextCursor: null, - }); - - const thread = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - }); - - const participants = await thread.getParticipants(); - expect(participants).toHaveLength(0); - }); - - it("should include currentMessage author", async () => { - const mockAdapter = createMockAdapter(); - const mockState = createMockState(); - - const currentMsg = createTestMessage("1", "Hey bot", { - author: { - userId: "U1", - userName: "alice", - fullName: "Alice", - isBot: false, - isMe: false, - }, - }); - - mockAdapter.fetchMessages = vi - .fn() - .mockResolvedValue({ messages: [], nextCursor: null }); - - const thread = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - currentMessage: currentMsg, - }); - - const participants = await thread.getParticipants(); - expect(participants).toHaveLength(1); - expect(participants[0].userId).toBe("U1"); - }); - }); - - describe("callbackUrl processing", () => { - let thread: ThreadImpl; - let mockAdapter: Adapter; - let mockState: ReturnType; - - function makeCardWithCallback(callbackUrl = "https://example.com/hook") { - return Card({ - title: "Test", - children: [ - Actions([Button({ id: "approve", label: "Approve", callbackUrl })]), - ], - }); - } - - beforeEach(() => { - mockAdapter = createMockAdapter(); - mockState = createMockState(); - thread = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - }); - }); - - it("should encode callbackUrl when posting a card", async () => { - await thread.post(makeCardWithCallback("https://example.com/post-hook")); - - const postedCard = (mockAdapter.postMessage as ReturnType) - .mock.calls[0][1]; - const button = postedCard.children[0].children[0]; - - const { callbackToken } = decodeCallbackValue(button.value); - expect(callbackToken).toBeDefined(); - expect(button.callbackUrl).toBeUndefined(); - - const stored = await mockState.get<{ url: string }>( - `chat:callback:${callbackToken}` - ); - expect(stored?.url).toBe("https://example.com/post-hook"); - }); - - it("should encode callbackUrl when posting via postEphemeral with native support", async () => { - const mockPostEphemeral = vi.fn().mockResolvedValue({ - id: "ephemeral-1", - threadId: "slack:C123:1234.5678", - usedFallback: false, - raw: {}, - }); - mockAdapter.postEphemeral = mockPostEphemeral; - - await thread.postEphemeral( - "U456", - makeCardWithCallback("https://example.com/eph"), - { fallbackToDM: false } - ); - - const sentCard = mockPostEphemeral.mock.calls[0][2]; - const button = sentCard.children[0].children[0]; - const { callbackToken } = decodeCallbackValue(button.value); - - expect(callbackToken).toBeDefined(); - const stored = await mockState.get<{ url: string }>( - `chat:callback:${callbackToken}` - ); - expect(stored?.url).toBe("https://example.com/eph"); - }); - - it("should encode callbackUrl when scheduling a card", async () => { - const futureDate = new Date("2030-01-01T00:00:00Z"); - mockAdapter.scheduleMessage = vi.fn().mockResolvedValue({ - scheduledMessageId: "Q1", - channelId: "C123", - postAt: futureDate, - raw: {}, - cancel: vi.fn().mockResolvedValue(undefined), - }); - - await thread.schedule(makeCardWithCallback("https://example.com/sched"), { - postAt: futureDate, - }); - - const sentCard = (mockAdapter.scheduleMessage as ReturnType) - .mock.calls[0][1]; - const button = sentCard.children[0].children[0]; - const { callbackToken } = decodeCallbackValue(button.value); - - expect(callbackToken).toBeDefined(); - const stored = await mockState.get<{ url: string }>( - `chat:callback:${callbackToken}` - ); - expect(stored?.url).toBe("https://example.com/sched"); - }); - - it("should encode callbackUrl when editing a sent message with a card", async () => { - const sent = await thread.post("Hello"); - - await sent.edit(makeCardWithCallback("https://example.com/edit")); - - const editArgs = (mockAdapter.editMessage as ReturnType) - .mock.calls[0]; - const editedCard = editArgs[2]; - const button = editedCard.children[0].children[0]; - const { callbackToken } = decodeCallbackValue(button.value); - - expect(callbackToken).toBeDefined(); - const stored = await mockState.get<{ url: string }>( - `chat:callback:${callbackToken}` - ); - expect(stored?.url).toBe("https://example.com/edit"); - }); - - it("should pass plain string posts through unchanged", async () => { - await thread.post("Just text"); - - expect(mockAdapter.postMessage).toHaveBeenCalledWith( - "slack:C123:1234.5678", - "Just text" - ); - expect(mockState.set).not.toHaveBeenCalledWith( - expect.stringContaining("chat:callback:"), - expect.anything(), - expect.anything() - ); - }); - - it("should leave cards without callback buttons untouched", async () => { - const card = Card({ - title: "Plain", - children: [Actions([Button({ id: "ok", label: "OK", value: "keep" })])], - }); - await thread.post(card); - - const postedCard = (mockAdapter.postMessage as ReturnType) - .mock.calls[0][1]; - // No state writes for callback storage - const setCalls = (mockState.set as ReturnType).mock.calls; - expect( - setCalls.some( - ([key]) => typeof key === "string" && key.startsWith("chat:callback:") - ) - ).toBe(false); - expect(postedCard.children[0].children[0].value).toBe("keep"); - }); - }); }); diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index b0cad54a2..136482763 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -1,6 +1,5 @@ import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; import type { Root } from "mdast"; -import { processCardCallbackUrls } from "./callback-url"; import { cardToFallbackText } from "./cards"; import { ChannelImpl, deriveChannelId } from "./channel"; import { getChatSingleton } from "./chat-singleton"; @@ -15,9 +14,8 @@ import { toPlainText, } from "./markdown"; import { Message, type SerializedMessage } from "./message"; -import { isPostableObject, postPostableObject } from "./postable-object"; +import type { MessageHistoryCache } from "./message-history"; import { StreamingMarkdownRenderer } from "./streaming-markdown"; -import type { ThreadHistoryCache } from "./thread-history"; import type { Adapter, AdapterPostableMessage, @@ -27,14 +25,15 @@ import type { ChannelVisibility, EphemeralMessage, PostableMessage, - PostableObject, PostEphemeralOptions, + RawMessage, ScheduledMessage, SentMessage, StateAdapter, StreamChunk, StreamEvent, StreamOptions, + StreamResult, Thread, } from "./types"; import { NotImplementedError, THREAD_STATE_TTL_MS } from "./types"; @@ -66,9 +65,9 @@ interface ThreadImplConfigWithAdapter { isDM?: boolean; isSubscribedContext?: boolean; logger?: Logger; + messageHistory?: MessageHistoryCache; stateAdapter: StateAdapter; streamingUpdateIntervalMs?: number; - threadHistory?: ThreadHistoryCache; } /** @@ -111,6 +110,12 @@ function isAsyncIterable( ); } +function isStreamResult( + value: RawMessage | StreamResult | null +): value is StreamResult { + return value !== null && typeof value === "object" && "messages" in value; +} + export class ThreadImpl> implements Thread { @@ -135,8 +140,8 @@ export class ThreadImpl> private readonly _fallbackStreamingPlaceholderText: string | null; /** Cached channel instance */ private _channel?: Channel; - /** Thread history cache (set only for adapters with persistThreadHistory) */ - private readonly _threadHistory?: ThreadHistoryCache; + /** Message history cache (set only for adapters with persistMessageHistory) */ + private readonly _messageHistory?: MessageHistoryCache; private readonly _logger?: Logger; constructor(config: ThreadImplConfig) { @@ -160,7 +165,7 @@ export class ThreadImpl> // Direct mode - store adapter and state instances this._adapter = config.adapter; this._stateAdapterInstance = config.stateAdapter; - this._threadHistory = config.threadHistory; + this._messageHistory = config.messageHistory; } if (config.initialMessage) { @@ -262,7 +267,7 @@ export class ThreadImpl> stateAdapter: this._stateAdapter, isDM: this.isDM, channelVisibility: this.channelVisibility, - threadHistory: this._threadHistory, + messageHistory: this._messageHistory, }); } return this._channel; @@ -275,7 +280,7 @@ export class ThreadImpl> get messages(): AsyncIterable { const adapter = this.adapter; const threadId = this.id; - const threadHistory = this._threadHistory; + const messageHistory = this._messageHistory; return { async *[Symbol.asyncIterator]() { @@ -304,8 +309,8 @@ export class ThreadImpl> } // Fall back to cached history if adapter returned nothing - if (!yieldedAny && threadHistory) { - const cached = await threadHistory.getMessages(threadId); + if (!yieldedAny && messageHistory) { + const cached = await messageHistory.getMessages(threadId); // Yield newest first for (let i = cached.length - 1; i >= 0; i--) { yield cached[i]; @@ -318,7 +323,7 @@ export class ThreadImpl> get allMessages(): AsyncIterable { const adapter = this.adapter; const threadId = this.id; - const threadHistory = this._threadHistory; + const messageHistory = this._messageHistory; return { async *[Symbol.asyncIterator]() { @@ -347,8 +352,8 @@ export class ThreadImpl> } // Fall back to cached history if adapter returned nothing - if (!yieldedAny && threadHistory) { - const cached = await threadHistory.getMessages(threadId); + if (!yieldedAny && messageHistory) { + const cached = await messageHistory.getMessages(threadId); for (const message of cached) { yield message; } @@ -357,33 +362,6 @@ export class ThreadImpl> }; } - async getParticipants(): Promise { - const seen = new Map(); - - // Include the current message author if available - if ( - this._currentMessage && - !this._currentMessage.author.isMe && - !this._currentMessage.author.isBot - ) { - seen.set(this._currentMessage.author.userId, this._currentMessage.author); - } - - // Scan all messages for unique human authors - for await (const message of this.allMessages) { - if ( - message.author.isMe || - message.author.isBot || - seen.has(message.author.userId) - ) { - continue; - } - seen.set(message.author.userId, message.author); - } - - return [...seen.values()]; - } - async isSubscribed(): Promise { // Short-circuit if we know we're in a subscribed context if (this._isSubscribedContext) { @@ -404,44 +382,9 @@ export class ThreadImpl> await this._stateAdapter.unsubscribe(this.id); } - async post(message: T): Promise; - async post( - message: - | string - | AdapterPostableMessage - | AsyncIterable - | ChatElement - ): Promise; async post( message: string | PostableMessage | ChatElement - ): Promise { - if (isPostableObject(message)) { - // StreamingPlan PostableObject - route to streaming with options - if (message.kind === "stream") { - const data = message.getPostData() as { - stream: AsyncIterable; - options: { - groupTasks?: "plan" | "timeline"; - endWith?: unknown[]; - updateIntervalMs?: number; - }; - }; - const streamOptions: StreamOptions = { - ...(data.options.updateIntervalMs - ? { updateIntervalMs: data.options.updateIntervalMs } - : {}), - ...(data.options.groupTasks - ? { taskDisplayMode: data.options.groupTasks } - : {}), - ...(data.options.endWith ? { stopBlocks: data.options.endWith } : {}), - }; - await this.handleStream(data.stream, streamOptions); - return message; - } - await this.handlePostableObject(message); - return message; - } - + ): Promise { // Handle AsyncIterable (streaming) if (isAsyncIterable(message)) { return this.handleStream(message); @@ -460,8 +403,6 @@ export class ThreadImpl> postable = card; } - postable = await this.processCallbackUrls(postable); - const rawMessage = await this.adapter.postMessage(this.id, postable); // Create a SentMessage with edit/delete capabilities @@ -472,23 +413,13 @@ export class ThreadImpl> ); // Cache sent message for adapters with persistent history - if (this._threadHistory) { - await this._threadHistory.append(this.id, new Message(result)); + if (this._messageHistory) { + await this._messageHistory.append(this.id, new Message(result)); } return result; } - private async handlePostableObject(obj: PostableObject): Promise { - await postPostableObject( - obj, - this.adapter, - this.id, - (threadId, message) => this.adapter.postMessage(threadId, message), - this._logger - ); - } - async postEphemeral( user: string | Author, message: AdapterPostableMessage | ChatElement, @@ -510,8 +441,6 @@ export class ThreadImpl> postable = message as AdapterPostableMessage; } - postable = await this.processCallbackUrls(postable); - // Try native ephemeral if adapter supports it if (this.adapter.postEphemeral) { return this.adapter.postEphemeral(this.id, userId, postable); @@ -538,30 +467,6 @@ export class ThreadImpl> return null; } - private async processCallbackUrls( - postable: string | AdapterPostableMessage - ): Promise { - if (typeof postable === "string") { - return postable; - } - - if ("type" in postable && postable.type === "card") { - return processCardCallbackUrls(postable, this._stateAdapter); - } - - if ("card" in postable && postable.card?.type === "card") { - const processed = await processCardCallbackUrls( - postable.card, - this._stateAdapter - ); - if (processed !== postable.card) { - return { ...postable, card: processed }; - } - } - - return postable; - } - async schedule( message: AdapterPostableMessage | ChatElement, options: { postAt: Date } @@ -578,10 +483,6 @@ export class ThreadImpl> postable = message as AdapterPostableMessage; } - postable = (await this.processCallbackUrls( - postable - )) as AdapterPostableMessage; - if (!this.adapter.scheduleMessage) { throw new NotImplementedError( "Scheduled messages are not supported by this adapter", @@ -595,27 +496,28 @@ export class ThreadImpl> /** * Handle streaming from an AsyncIterable. * Normalizes the stream (supports both textStream and fullStream from AI SDK), - * then uses the adapter's stream implementation if available, otherwise falls back to post+edit. + * then uses adapter's native streaming if available, otherwise falls back to post+edit. */ private async handleStream( - rawStream: AsyncIterable, - callerOptions?: StreamOptions + rawStream: AsyncIterable ): Promise { // 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 }; + // Build streaming options from current message context + const options: StreamOptions = { + updateIntervalMs: this._streamingUpdateIntervalMs, + }; if (this._currentMessage) { options.recipientUserId = this._currentMessage.author.userId; - // recipientTeamId is only consumed by the Slack adapter; other adapters - // ignore it. Derivation is Slack-specific because `currentMessage.raw` - // shape varies across Slack webhook types (message events vs block_actions). - options.recipientTeamId = this.extractSlackRecipientTeamId( - this._currentMessage.raw - ); + // Extract teamId from raw Slack payload + const raw = this._currentMessage.raw as { + team_id?: string; + team?: string; + }; + options.recipientTeamId = raw?.team_id ?? raw?.team; } - // Use adapter-provided streaming if available. + // Use native streaming if adapter supports it if (this.adapter.stream) { // Wrap stream to collect accumulated text while passing through to adapter. // StreamChunk objects are passed through; only plain strings are accumulated. @@ -642,17 +544,43 @@ 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) { + if (isStreamResult(raw)) { + const sentSegments = raw.messages.map((segment) => + this.createSentMessage( + segment.message.id, + segment.postable, + segment.message.threadId + ) + ); + + if (this._messageHistory) { + for (const segment of sentSegments) { + await this._messageHistory.append(this.id, new Message(segment)); + } + } - if (this._threadHistory) { - await this._threadHistory.append(this.id, new Message(sent)); - } + const lastSent = sentSegments.at(-1); + if (!lastSent) { + throw new Error("Segmented stream completed without messages"); + } + + lastSent.segments = sentSegments; + return lastSent; + } - return sent; + const sent = this.createSentMessage( + raw.id, + { markdown: accumulated }, + raw.threadId + ); + + if (this._messageHistory) { + await this._messageHistory.append(this.id, new Message(sent)); + } + + return sent; + } } // Fallback: post + edit with throttling. @@ -683,47 +611,6 @@ export class ThreadImpl> return this.fallbackStream(textOnlyStream, options); } - /** - * Slack payloads carry the workspace ID in a few different shapes depending on - * the webhook type: - * - Message events: `team_id` or `team` as a string - * - `block_actions` payloads: `team.id` (object), with `user.team_id` as a fallback - */ - private extractSlackRecipientTeamId(raw: unknown): string | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - - const payload = raw as { - team?: { id?: unknown } | string; - team_id?: unknown; - user?: { team_id?: unknown }; - }; - - if (typeof payload.team_id === "string" && payload.team_id) { - return payload.team_id; - } - - if (typeof payload.team === "string" && payload.team) { - return payload.team; - } - - if ( - payload.team && - typeof payload.team === "object" && - typeof payload.team.id === "string" && - payload.team.id - ) { - return payload.team.id; - } - - if (typeof payload.user?.team_id === "string" && payload.user.team_id) { - return payload.user.team_id; - } - - return undefined; - } - async startTyping(status?: string): Promise { await this.adapter.startTyping(this.id, status); } @@ -769,7 +656,7 @@ export class ThreadImpl> } const content = renderer.render(); - if (content.trim() && content !== lastEditContent) { + if (content !== lastEditContent) { try { await this.adapter.editMessage(threadIdForEdits, msg.id, { markdown: content, @@ -795,14 +682,12 @@ export class ThreadImpl> renderer.push(chunk); if (!msg) { const content = renderer.render(); - if (content.trim()) { - msg = await this.adapter.postMessage(this.id, { - markdown: content, - }); - threadIdForEdits = msg.threadId || this.id; - lastEditContent = content; - scheduleNextEdit(); - } + msg = await this.adapter.postMessage(this.id, { + markdown: content, + }); + threadIdForEdits = msg.threadId || this.id; + lastEditContent = content; + scheduleNextEdit(); } } } finally { @@ -823,13 +708,13 @@ export class ThreadImpl> if (!msg) { msg = await this.adapter.postMessage(this.id, { - markdown: accumulated.trim() ? accumulated : " ", + markdown: accumulated, }); threadIdForEdits = msg.threadId || this.id; lastEditContent = accumulated; } - if (finalContent.trim() && finalContent !== lastEditContent) { + if (finalContent !== lastEditContent) { await this.adapter.editMessage(threadIdForEdits, msg.id, { markdown: accumulated, }); @@ -841,8 +726,8 @@ export class ThreadImpl> threadIdForEdits ); - if (this._threadHistory) { - await this._threadHistory.append(this.id, new Message(sent)); + if (this._messageHistory) { + await this._messageHistory.append(this.id, new Message(sent)); } return sent; @@ -852,9 +737,12 @@ export class ThreadImpl> const result = await this.adapter.fetchMessages(this.id, { limit: 50 }); if (result.messages.length > 0) { this._recentMessages = result.messages; - } else if (this._threadHistory) { + } else if (this._messageHistory) { // Fall back to cached history for adapters without native message APIs - this._recentMessages = await this._threadHistory.getMessages(this.id, 50); + this._recentMessages = await this._messageHistory.getMessages( + this.id, + 50 + ); } else { this._recentMessages = []; } @@ -885,7 +773,7 @@ export class ThreadImpl> channelVisibility: this.channelVisibility, currentMessage: this._currentMessage?.toJSON(), isDM: this.isDM, - adapterName: this._adapterName ?? this.adapter.name, + adapterName: this.adapter.name, }; } @@ -981,6 +869,8 @@ export class ThreadImpl> async edit( newContent: string | PostableMessage | ChatElement ): Promise { + // Auto-convert JSX elements to CardElement + // edit doesn't support streaming, so use AdapterPostableMessage let postable: string | AdapterPostableMessage = newContent as | string | AdapterPostableMessage; @@ -991,7 +881,6 @@ export class ThreadImpl> } postable = card; } - postable = await self.processCallbackUrls(postable); await adapter.editMessage(threadId, messageId, postable); return self.createSentMessage(messageId, postable); }, @@ -1047,7 +936,6 @@ export class ThreadImpl> } postable = card; } - postable = await self.processCallbackUrls(postable); await adapter.editMessage(threadId, messageId, postable); return self.createSentMessage(messageId, postable, threadId); }, diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index 31e2a3804..d0cdaedb9 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -529,6 +529,8 @@ export interface Adapter { * * The adapter consumes the async iterable and handles the entire streaming lifecycle. * Only available on platforms with native streaming support (e.g., Slack). + * 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,14 @@ 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, a segmented stream result, + * or `null` to use core fallback */ stream?( threadId: string, textStream: AsyncIterable, options?: StreamOptions - ): Promise>; + ): Promise | StreamResult | null>; /** Bot username (can override global userName) */ readonly userName: string; } @@ -1420,6 +1423,26 @@ export interface RawMessage { threadId: string; } +/** + * One persisted message emitted by a native streaming adapter. + * + * Adapters can return multiple of these when a single logical stream must be + * split into more than one platform message (for example, Telegram's 4096-char + * message limit in DM draft streaming). + */ +export interface StreamSegment { + message: RawMessage; + postable: AdapterPostableMessage; +} + +/** + * Result of a native streaming operation that finalized into multiple + * persisted platform messages. + */ +export interface StreamResult { + messages: StreamSegment[]; +} + export interface Author { /** Display name */ fullName: string; @@ -1474,6 +1497,12 @@ export interface SentMessage ): Promise>; /** Remove a reaction from this message */ removeReaction(emoji: EmojiValue | string): Promise; + /** + * Actual persisted messages emitted by a segmented native stream, in + * chronological order. Present only when a single `thread.post(stream)` + * finalized into multiple platform messages. + */ + segments?: SentMessage[]; } // ============================================================================= From e4e5f78ee58c9e6879cc95949745227473221011 Mon Sep 17 00:00:00 2001 From: luren Date: Sat, 16 May 2026 07:17:21 +0800 Subject: [PATCH 2/3] fix(telegram): avoid truncating rendered stream segments --- packages/adapter-telegram/src/index.test.ts | 87 ++++ packages/adapter-telegram/src/index.ts | 549 +++++++++++++++----- 2 files changed, 511 insertions(+), 125 deletions(-) diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 4185e9eea..3a64810eb 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -1265,6 +1265,93 @@ describe("TelegramAdapter", () => { expect(finalSendBody.text).toBe(longMarkdown); }); + it("splits streams by rendered MarkdownV2 length before final sends can truncate", async () => { + const sourceMarkdown = ".".repeat(3500); + const renderedMarkdown = "\\.".repeat(3500); + const requestBodies: Array<{ + method: string; + body: { parse_mode?: string; text?: string }; + }> = []; + let nextMessageId = 51; + + 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: body.text ?? "", + }) + ); + } + + 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 sourceMarkdown; + } + + const result = await adapter.stream("telegram:123", textStream(), { + updateIntervalMs: Number.MAX_SAFE_INTEGER, + }); + + expect(result).not.toBeNull(); + expect(result).toHaveProperty("messages"); + if (!(result && "messages" in result)) { + throw new Error("Expected segmented stream result"); + } + + const finalSendBodies = requestBodies + .filter((request) => request.method === "sendMessage") + .map((request) => request.body); + + expect(finalSendBodies.length).toBeGreaterThan(1); + expect(result.messages).toHaveLength(finalSendBodies.length); + expect( + finalSendBodies.every((body) => body.parse_mode === "MarkdownV2") + ).toBe(true); + expect( + finalSendBodies.every( + (body) => (body.text?.length ?? 0) <= TELEGRAM_MESSAGE_LIMIT + ) + ).toBe(true); + expect(finalSendBodies.map((body) => body.text ?? "").join("")).toBe( + renderedMarkdown + ); + }); + it("returns null for non-DM streaming so Chat SDK can use fallback streaming", async () => { mockFetch.mockResolvedValueOnce( telegramOk({ diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index 0cfa26206..c88523c99 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -5,6 +5,7 @@ import { cardToFallbackText, extractCard, extractFiles, + extractPostableAttachments, NetworkError, PermissionError, ResourceNotFoundError, @@ -26,6 +27,7 @@ import type { StreamOptions, StreamResult, ThreadInfo, + UserInfo, WebhookOptions, } from "chat"; import { @@ -44,7 +46,14 @@ import { decodeTelegramCallbackData, emptyTelegramInlineKeyboard, } from "./cards"; -import { TelegramFormatConverter } from "./markdown"; +import { + TELEGRAM_CAPTION_LIMIT, + TELEGRAM_MESSAGE_LIMIT, + TelegramFormatConverter, + type TelegramParseMode, + toBotApiParseMode, + truncateForTelegram, +} from "./markdown"; import type { TelegramAdapterConfig, TelegramAdapterMode, @@ -66,11 +75,8 @@ import type { } from "./types"; const TELEGRAM_API_BASE = "https://api.telegram.org"; -const TELEGRAM_MESSAGE_LIMIT = 4096; -const TELEGRAM_CAPTION_LIMIT = 1024; const TELEGRAM_SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token"; const MESSAGE_ID_PATTERN = /^([^:]+):(\d+)$/; -const TELEGRAM_MARKDOWN_PARSE_MODE = "Markdown"; const trimTrailingSlashes = (url: string): string => { let end = url.length; while (end > 0 && url[end - 1] === "/") { @@ -79,6 +85,15 @@ const trimTrailingSlashes = (url: string): string => { return url.slice(0, end); }; const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; +const ATTACHMENT_UPLOADS = { + audio: { field: "audio", method: "sendAudio" }, + file: { field: "document", method: "sendDocument" }, + image: { field: "photo", method: "sendPhoto" }, + video: { field: "video", method: "sendVideo" }, +} as const satisfies Record< + Attachment["type"], + { field: string; method: string } +>; const LEADING_AT_PATTERN = /^@+/; const EMOJI_PLACEHOLDER_PATTERN = /^\{\{emoji:([a-z0-9_]+)\}\}$/i; const EMOJI_NAME_PATTERN = /^[a-z0-9_+-]+$/i; @@ -115,18 +130,22 @@ interface ResolvedTelegramLongPollingConfig { type TelegramRuntimeMode = "webhook" | "polling"; /** - * Escape markdown special characters inside entity text so wrapping - * with markdown syntax doesn't break parsing. + * Escape standard-markdown special characters inside inbound entity text. + * + * Used only by `applyTelegramEntities` below (inbound path). Outbound + * MarkdownV2 escaping lives in `markdown.ts` (`escapeMarkdownV2`). */ const escapeMarkdownInEntity = (text: string): string => text.replace(/([[\]()\\])/g, "\\$1"); /** - * Convert Telegram message entities to markdown. + * Convert Telegram message entities (inbound) to standard markdown. * * Telegram delivers formatting as separate entity objects alongside plain text. - * This function reconstructs markdown so that links, bold, italic, code, etc. - * are preserved when the text is later parsed as markdown. + * This function reconstructs **standard** markdown (`**bold**`, `~~strike~~`, + * etc.) so the result can be fed into the SDK's `parseMarkdown` — which is + * the canonical AST producer. The outbound direction (AST → MarkdownV2) is + * handled separately by `TelegramFormatConverter.fromAst`. * * Entities use UTF-16 offsets, which match JavaScript's native string indexing. */ @@ -203,25 +222,25 @@ export class TelegramAdapter { readonly name = "telegram"; readonly lockScope = "channel" as const; - readonly persistMessageHistory = true; + readonly persistThreadHistory = true; - private readonly botToken: string; - private readonly apiBaseUrl: string; - private readonly secretToken?: string; + protected readonly botToken: string; + protected readonly apiBaseUrl: string; + protected readonly secretToken?: string; private warnedNoVerification = false; - private readonly logger: Logger; - private readonly formatConverter = new TelegramFormatConverter(); + protected readonly logger: Logger; + protected readonly formatConverter = new TelegramFormatConverter(); private readonly messageCache = new Map< string, Message[] >(); - private chat: ChatInstance | null = null; - private _botUserId?: string; - private _userName: string; - private readonly hasExplicitUserName: boolean; - private readonly mode: TelegramAdapterMode; - private readonly longPolling?: TelegramLongPollingConfig; + protected chat: ChatInstance | null = null; + protected _botUserId?: string; + protected _userName: string; + protected readonly hasExplicitUserName: boolean; + protected readonly mode: TelegramAdapterMode; + protected readonly longPolling?: TelegramLongPollingConfig; private _runtimeMode: TelegramRuntimeMode = "webhook"; private pollingAbortController: AbortController | null = null; private pollingTask: Promise | null = null; @@ -255,7 +274,8 @@ export class TelegramAdapter this.botToken = botToken; this.apiBaseUrl = trimTrailingSlashes( - config.apiBaseUrl ?? + config.apiUrl ?? + config.apiBaseUrl ?? process.env.TELEGRAM_API_BASE_URL ?? TELEGRAM_API_BASE ); @@ -323,6 +343,32 @@ export class TelegramAdapter } } + async getUser(userId: string): Promise { + try { + const chat = await this.telegramFetch("getChat", { + chat_id: userId, + }); + // Only private chats represent users — groups/channels are not user lookups + if (chat.type !== "private") { + return null; + } + const fullName = [chat.first_name, chat.last_name] + .filter(Boolean) + .join(" "); + return { + email: undefined, + fullName: fullName || String(chat.id), + // Telegram's getChat API doesn't expose is_bot (only available on TelegramUser). + // Always returns false — callers needing bot detection should use message.author.isBot instead. + isBot: false, + userId: String(chat.id), + userName: chat.username || chat.first_name || String(chat.id), + }; + } catch { + return null; + } + } + async handleWebhook( request: Request, options?: WebhookOptions @@ -446,7 +492,7 @@ export class TelegramAdapter }); } - private async resolveRuntimeMode(): Promise { + protected async resolveRuntimeMode(): Promise { if (this.mode === "webhook") { return "webhook"; } @@ -481,7 +527,7 @@ export class TelegramAdapter return "polling"; } - private async fetchWebhookInfo(): Promise { + protected async fetchWebhookInfo(): Promise { try { return await this.telegramFetch("getWebhookInfo"); } catch (error) { @@ -492,7 +538,7 @@ export class TelegramAdapter } } - private isLikelyServerlessRuntime(): boolean { + protected isLikelyServerlessRuntime(): boolean { if (typeof process === "undefined" || !process.env) { return false; } @@ -507,7 +553,7 @@ export class TelegramAdapter ); } - private processUpdate( + protected processUpdate( update: TelegramUpdate, options?: WebhookOptions ): void { @@ -530,7 +576,7 @@ export class TelegramAdapter } } - private handleIncomingMessageUpdate( + protected handleIncomingMessageUpdate( telegramMessage: TelegramMessage, options?: WebhookOptions ): void { @@ -549,7 +595,7 @@ export class TelegramAdapter this.chat.processMessage(this, threadId, parsedMessage, options); } - private handleCallbackQuery( + protected handleCallbackQuery( callbackQuery: TelegramCallbackQuery, options?: WebhookOptions ): void { @@ -596,7 +642,7 @@ export class TelegramAdapter } } - private handleMessageReactionUpdate( + protected handleMessageReactionUpdate( reactionUpdate: TelegramMessageReactionUpdated, options?: WebhookOptions ): void { @@ -673,19 +719,25 @@ export class TelegramAdapter const card = extractCard(message); const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined; const parseMode = this.resolveParseMode(message, card); - const plainText = this.truncateMessage( + const plainText = truncateForTelegram( convertEmojiPlaceholders( this.renderPlainTextMessage(message, card), "gchat" - ) + ), + TELEGRAM_MESSAGE_LIMIT, + "plain" ); - const text = this.truncateMessage( + const text = truncateForTelegram( convertEmojiPlaceholders( card - ? cardToFallbackText(card) + ? this.formatConverter.fromMarkdown( + cardToFallbackText(card, { boldFormat: "**" }) + ) : this.formatConverter.renderPostable(message), "gchat" - ) + ), + TELEGRAM_MESSAGE_LIMIT, + parseMode ); const files = extractFiles(message); @@ -696,6 +748,21 @@ export class TelegramAdapter ); } + const attachments = extractPostableAttachments(message); + if (attachments.length > 1) { + throw new ValidationError( + "telegram", + "Telegram adapter supports a single attachment upload per message" + ); + } + + if (files.length > 0 && attachments.length > 0) { + throw new ValidationError( + "telegram", + "Telegram adapter does not support mixing file uploads and attachments in one message" + ); + } + let rawMessage: TelegramMessage; if (files.length === 1) { @@ -711,6 +778,22 @@ export class TelegramAdapter replyMarkup, parseMode ); + } else if (attachments.length === 1) { + const [attachment] = attachments; + if (!attachment) { + throw new ValidationError( + "telegram", + "Attachment upload payload is empty" + ); + } + rawMessage = await this.sendAttachment( + parsedThread, + attachment, + text, + plainText, + replyMarkup, + parseMode + ); } else { if (!text.trim()) { throw new ValidationError("telegram", "Message text cannot be empty"); @@ -724,7 +807,7 @@ export class TelegramAdapter message_thread_id: parsedThread.messageThreadId, text: resolvedText, reply_markup: replyMarkup, - parse_mode: resolvedParseMode, + parse_mode: toBotApiParseMode(resolvedParseMode), }), { initialText: text, @@ -776,19 +859,25 @@ export class TelegramAdapter const card = extractCard(message); const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined; const parseMode = this.resolveParseMode(message, card); - const plainText = this.truncateMessage( + const plainText = truncateForTelegram( convertEmojiPlaceholders( this.renderPlainTextMessage(message, card), "gchat" - ) + ), + TELEGRAM_MESSAGE_LIMIT, + "plain" ); - const text = this.truncateMessage( + const text = truncateForTelegram( convertEmojiPlaceholders( card - ? cardToFallbackText(card) + ? this.formatConverter.fromMarkdown( + cardToFallbackText(card, { boldFormat: "**" }) + ) : this.formatConverter.renderPostable(message), "gchat" - ) + ), + TELEGRAM_MESSAGE_LIMIT, + parseMode ); if (!text.trim()) { @@ -803,7 +892,7 @@ export class TelegramAdapter message_id: telegramMessageId, text: resolvedText, reply_markup: replyMarkup ?? emptyTelegramInlineKeyboard(), - parse_mode: resolvedParseMode, + parse_mode: toBotApiParseMode(resolvedParseMode), }), { initialText: text, @@ -944,10 +1033,72 @@ export class TelegramAdapter let segmentUsesMarkdown = true; const postedSegments: StreamResult["messages"] = []; + 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 => - this.truncateMessage( - this.resolveTelegramFallbackText(text, markdownToPlainText(text)) + truncateForTelegram( + this.resolveTelegramFallbackText(text, markdownToPlainText(text)), + TELEGRAM_MESSAGE_LIMIT, + "plain" + ); + + const isMarkdownSegmentWithinLimit = (text: string): boolean => { + const rendered = renderMarkdownForTelegram(text); + return ( + rendered.trim().length > 0 && + truncateForTelegram(rendered, TELEGRAM_MESSAGE_LIMIT, "MarkdownV2") === + rendered ); + }; + + const getCommittedPrefixFor = (text: string): string => { + const candidateRenderer = new StreamingMarkdownRenderer(); + candidateRenderer.push(text); + return candidateRenderer.getCommittedMarkdownPrefix(); + }; + + const findMarkdownSegmentPrefixWithinLimit = (text: string): string => { + if (!text.trim()) { + return ""; + } + + const renderedLength = renderMarkdownForTelegram(text).length; + let candidateLength = text.length; + + if (renderedLength > TELEGRAM_MESSAGE_LIMIT) { + candidateLength = Math.max( + 1, + Math.min( + text.length - 1, + Math.floor((text.length * TELEGRAM_MESSAGE_LIMIT) / renderedLength) + ) + ); + } + + while (candidateLength > 0) { + const candidate = getCommittedPrefixFor(text.slice(0, candidateLength)); + if (candidate.trim() && isMarkdownSegmentWithinLimit(candidate)) { + return candidate; + } + + const nextLength = Math.floor(candidateLength * 0.9); + candidateLength = + nextLength < candidateLength ? nextLength : candidateLength - 1; + } + + return ""; + }; const resetSegment = (nextText = ""): void => { renderer = new StreamingMarkdownRenderer(); @@ -971,9 +1122,10 @@ export class TelegramAdapter return; } - const postable: AdapterPostableMessage = useMarkdown - ? { markdown: text } - : this.resolveTelegramFallbackText(text, markdownToPlainText(text)); + const postable: AdapterPostableMessage = + useMarkdown && isMarkdownSegmentWithinLimit(text) + ? { markdown: text } + : this.resolveTelegramFallbackText(text, markdownToPlainText(text)); const message = await this.postMessage(threadId, postable); postedSegments.push({ @@ -988,7 +1140,7 @@ export class TelegramAdapter } const draftText = segmentUsesMarkdown - ? this.truncateMessage( + ? renderMarkdownText( sourceText === segmentText ? renderer.render() : sourceText ) : renderPlainText(sourceText); @@ -1003,7 +1155,7 @@ export class TelegramAdapter message_thread_id: parsedThread.messageThreadId, draft_id: draftId, text: draftText, - parse_mode: TELEGRAM_MARKDOWN_PARSE_MODE, + parse_mode: toBotApiParseMode("MarkdownV2"), }); } else { await this.telegramFetch("sendMessageDraft", { @@ -1076,15 +1228,27 @@ export class TelegramAdapter await flushDraft(); } - if (segmentText.length >= TELEGRAM_STREAM_SEGMENT_LIMIT) { + const renderedOverflow = + segmentUsesMarkdown && + segmentText.length > Math.floor(TELEGRAM_MESSAGE_LIMIT / 2) && + !isMarkdownSegmentWithinLimit(segmentText); + + if ( + segmentText.length >= TELEGRAM_STREAM_SEGMENT_LIMIT || + renderedOverflow + ) { const committedPrefix = segmentUsesMarkdown ? renderer.getCommittedMarkdownPrefix() : ""; - - if (segmentUsesMarkdown && committedPrefix.trim()) { - const overflow = segmentText.slice(committedPrefix.length); - await flushDraft(committedPrefix); - await postSegment(committedPrefix, true); + const markdownPrefix = + segmentUsesMarkdown && isMarkdownSegmentWithinLimit(committedPrefix) + ? committedPrefix + : findMarkdownSegmentPrefixWithinLimit(committedPrefix); + + if (segmentUsesMarkdown && markdownPrefix.trim()) { + const overflow = segmentText.slice(markdownPrefix.length); + await flushDraft(markdownPrefix); + await postSegment(markdownPrefix, true); resetSegment(overflow); continue; } @@ -1291,7 +1455,7 @@ export class TelegramAdapter return this.formatConverter.fromAst(content); } - private parseTelegramMessage( + protected parseTelegramMessage( raw: TelegramMessage, threadId: string ): Message { @@ -1338,7 +1502,7 @@ export class TelegramAdapter return message; } - private extractAttachments(raw: TelegramMessage): Attachment[] { + protected extractAttachments(raw: TelegramMessage): Attachment[] { const attachments: Attachment[] = []; const photo = raw.photo?.at(-1); @@ -1393,10 +1557,20 @@ export class TelegramAdapter ); } + if (raw.video_note) { + attachments.push( + this.createAttachment("video", raw.video_note.file_id, { + size: raw.video_note.file_size, + width: raw.video_note.length, + height: raw.video_note.length, + }) + ); + } + return attachments; } - private createAttachment( + protected createAttachment( type: Attachment["type"], fileId: string, metadata?: { @@ -1414,11 +1588,23 @@ export class TelegramAdapter height: metadata?.height, name: metadata?.name, mimeType: metadata?.mimeType, + fetchMetadata: { fileId }, fetchData: async () => this.downloadFile(fileId), }; } - private async downloadFile(fileId: string): Promise { + rehydrateAttachment(attachment: Attachment): Attachment { + const fileId = attachment.fetchMetadata?.fileId; + if (!fileId) { + return attachment; + } + return { + ...attachment, + fetchData: async () => this.downloadFile(fileId), + }; + } + + protected async downloadFile(fileId: string): Promise { const file = await this.telegramFetch("getFile", { file_id: fileId, }); @@ -1450,7 +1636,7 @@ export class TelegramAdapter return Buffer.from(await response.arrayBuffer()); } - private async sendDocument( + protected async sendDocument( thread: TelegramThreadId, file: { filename: string; @@ -1460,7 +1646,7 @@ export class TelegramAdapter text: string, plainText: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode?: string + parseMode: TelegramParseMode = "plain" ): Promise { const buffer = await this.toTelegramBuffer(file.data); @@ -1496,7 +1682,7 @@ export class TelegramAdapter buffer: Buffer, text: string, replyMarkup?: TelegramInlineKeyboardMarkup, - parseMode?: string + parseMode: TelegramParseMode = "plain" ): FormData { const formData = new FormData(); formData.append("chat_id", thread.chatId); @@ -1505,9 +1691,13 @@ export class TelegramAdapter } if (text.trim()) { - formData.append("caption", this.truncateCaption(text)); - if (parseMode) { - formData.append("parse_mode", parseMode); + formData.append( + "caption", + truncateForTelegram(text, TELEGRAM_CAPTION_LIMIT, parseMode) + ); + const botApiParseMode = toBotApiParseMode(parseMode); + if (botApiParseMode) { + formData.append("parse_mode", botApiParseMode); } } @@ -1522,7 +1712,120 @@ export class TelegramAdapter return formData; } - private async toTelegramBuffer( + protected async sendAttachment( + thread: TelegramThreadId, + attachment: Attachment, + text: string, + plainText: string, + replyMarkup?: TelegramInlineKeyboardMarkup, + parseMode: TelegramParseMode = "plain" + ): Promise { + const upload = ATTACHMENT_UPLOADS[attachment.type]; + const data = + attachment.data ?? + (attachment.fetchData ? await attachment.fetchData() : undefined); + + if (!(data || attachment.url)) { + throw new ValidationError( + "telegram", + `Attachment data or URL required for ${attachment.type}` + ); + } + + const buffer = data ? await this.toTelegramBuffer(data) : undefined; + + return this.withTelegramMarkdownFallback( + parseMode, + (resolvedParseMode, resolvedText) => { + if (!buffer) { + const payload: Record = { + chat_id: thread.chatId, + [upload.field]: attachment.url, + }; + + if (typeof thread.messageThreadId === "number") { + payload.message_thread_id = thread.messageThreadId; + } + + if (resolvedText.trim()) { + payload.caption = truncateForTelegram( + resolvedText, + TELEGRAM_CAPTION_LIMIT, + resolvedParseMode + ); + const botApiParseMode = toBotApiParseMode(resolvedParseMode); + if (botApiParseMode) { + payload.parse_mode = botApiParseMode; + } + } + + if (attachment.type === "video") { + if (Number.isInteger(attachment.width)) { + payload.width = attachment.width; + } + if (Number.isInteger(attachment.height)) { + payload.height = attachment.height; + } + } + + if (replyMarkup) { + payload.reply_markup = replyMarkup; + } + + return this.telegramFetch(upload.method, payload); + } + + const formData = new FormData(); + + formData.append("chat_id", thread.chatId); + if (typeof thread.messageThreadId === "number") { + formData.append("message_thread_id", String(thread.messageThreadId)); + } + + if (resolvedText.trim()) { + formData.append( + "caption", + truncateForTelegram( + resolvedText, + TELEGRAM_CAPTION_LIMIT, + resolvedParseMode + ) + ); + const botApiParseMode = toBotApiParseMode(resolvedParseMode); + if (botApiParseMode) { + formData.append("parse_mode", botApiParseMode); + } + } + + 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)); + } + } + + 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( data: Buffer | Blob | ArrayBuffer ): Promise { if (Buffer.isBuffer(data)) { @@ -1537,7 +1840,7 @@ export class TelegramAdapter throw new ValidationError("telegram", "Unsupported file data type"); } - private paginateMessages( + protected paginateMessages( messages: Message[], options: FetchOptions ): FetchResult { @@ -1579,7 +1882,7 @@ export class TelegramAdapter }; } - private cacheMessage(message: Message): void { + protected cacheMessage(message: Message): void { const existing = this.messageCache.get(message.threadId) ?? []; const index = existing.findIndex((item) => item.id === message.id); @@ -1593,7 +1896,7 @@ export class TelegramAdapter this.messageCache.set(message.threadId, existing); } - private findCachedMessage( + protected findCachedMessage( messageId: string ): Message | undefined { for (const messages of this.messageCache.values()) { @@ -1606,7 +1909,7 @@ export class TelegramAdapter return undefined; } - private deleteCachedMessage(messageId: string): void { + protected deleteCachedMessage(messageId: string): void { for (const [threadId, messages] of this.messageCache.entries()) { const filtered = messages.filter((message) => message.id !== messageId); if (filtered.length === 0) { @@ -1617,7 +1920,7 @@ export class TelegramAdapter } } - private compareMessages( + protected compareMessages( a: Message, b: Message ): number { @@ -1630,18 +1933,18 @@ export class TelegramAdapter return this.messageSequence(a.id) - this.messageSequence(b.id); } - private messageSequence(messageId: string): number { + protected messageSequence(messageId: string): number { const match = messageId.match(MESSAGE_SEQUENCE_PATTERN); return match ? Number.parseInt(match[1], 10) : 0; } - private createDraftId(): number { + protected createDraftId(): number { this.nextDraftId = this.nextDraftId >= 2_147_483_647 ? 1 : this.nextDraftId + 1; return this.nextDraftId; } - private resolveThreadId(value: string): TelegramThreadId { + protected resolveThreadId(value: string): TelegramThreadId { if (value.startsWith("telegram:")) { return this.decodeThreadId(value); } @@ -1649,11 +1952,11 @@ export class TelegramAdapter return { chatId: value }; } - private encodeMessageId(chatId: string, messageId: number): string { + protected encodeMessageId(chatId: string, messageId: number): string { return `${chatId}:${messageId}`; } - private decodeCompositeMessageId( + protected decodeCompositeMessageId( messageId: string, expectedChatId?: string ): { chatId: string; messageId: number; compositeId: string } { @@ -1699,7 +2002,7 @@ export class TelegramAdapter }; } - private toAuthor(user: TelegramUser): TelegramMessageAuthor { + protected toAuthor(user: TelegramUser): TelegramMessageAuthor { const fullName = [user.first_name, user.last_name] .filter(Boolean) .join(" ") @@ -1714,7 +2017,7 @@ export class TelegramAdapter }; } - private toReactionActorAuthor(chat: TelegramChat): TelegramMessageAuthor { + protected toReactionActorAuthor(chat: TelegramChat): TelegramMessageAuthor { const name = this.chatDisplayName(chat) ?? String(chat.id); return { userId: `chat:${chat.id}`, @@ -1725,7 +2028,7 @@ export class TelegramAdapter }; } - private chatDisplayName(chat: TelegramChat): string | undefined { + protected chatDisplayName(chat: TelegramChat): string | undefined { if (chat.title) { return chat.title; } @@ -1741,7 +2044,7 @@ export class TelegramAdapter return chat.username; } - private isBotMentioned(message: TelegramMessage, text: string): boolean { + protected isBotMentioned(message: TelegramMessage, text: string): boolean { if (!text) { return false; } @@ -1778,15 +2081,15 @@ export class TelegramAdapter return mentionRegex.test(text); } - private entityText(text: string, entity: TelegramMessageEntity): string { + protected entityText(text: string, entity: TelegramMessageEntity): string { return text.slice(entity.offset, entity.offset + entity.length); } - private escapeRegex(input: string): string { + protected escapeRegex(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - private normalizeUserName(value: unknown): string { + protected normalizeUserName(value: unknown): string { if (typeof value !== "string") { return "bot"; } @@ -1794,16 +2097,28 @@ export class TelegramAdapter return value.replace(LEADING_AT_PATTERN, "").trim() || "bot"; } - private resolveParseMode( + protected resolveParseMode( message: AdapterPostableMessage, card: ReturnType - ): string | undefined { - const hasMarkdown = - typeof message === "object" && message !== null && "markdown" in message; - return card || hasMarkdown ? TELEGRAM_MARKDOWN_PARSE_MODE : undefined; + ): TelegramParseMode { + // Cards and any message routed through the format converter are rendered + // as MarkdownV2, so Telegram must parse them with MarkdownV2. + if (card) { + return "MarkdownV2"; + } + // Plain strings and raw messages ship verbatim — no markdown parsing. + if (typeof message === "string") { + return "plain"; + } + if (typeof message === "object" && message !== null && "raw" in message) { + return "plain"; + } + // Every other shape ({markdown}, {ast}, JSX, etc.) flows through + // formatConverter.renderPostable, which emits MarkdownV2. + return "MarkdownV2"; } - private renderPlainTextMessage( + protected renderPlainTextMessage( message: AdapterPostableMessage, card: ReturnType ): string { @@ -1828,30 +2143,16 @@ export class TelegramAdapter return this.formatConverter.renderPostable(message); } - private resolveTelegramFallbackText( + protected resolveTelegramFallbackText( originalText: string, fallbackText: string ): string { return fallbackText.trim() ? fallbackText : originalText; } - private truncateMessage(text: string): string { - if (text.length <= TELEGRAM_MESSAGE_LIMIT) { - return text; - } - - return `${text.slice(0, TELEGRAM_MESSAGE_LIMIT - 3)}...`; - } - - private truncateCaption(text: string): string { - if (text.length <= TELEGRAM_CAPTION_LIMIT) { - return text; - } - - return `${text.slice(0, TELEGRAM_CAPTION_LIMIT - 3)}...`; - } - - private toTelegramReaction(emoji: EmojiValue | string): TelegramReactionType { + protected toTelegramReaction( + emoji: EmojiValue | string + ): TelegramReactionType { if (typeof emoji !== "string") { return { type: "emoji", @@ -1887,7 +2188,7 @@ export class TelegramAdapter }; } - private reactionKey(reaction: TelegramReactionType): string { + protected reactionKey(reaction: TelegramReactionType): string { if (reaction.type === "emoji") { return reaction.emoji; } @@ -1895,7 +2196,7 @@ export class TelegramAdapter return `custom:${reaction.custom_emoji_id}`; } - private reactionToEmojiValue(reaction: TelegramReactionType): EmojiValue { + protected reactionToEmojiValue(reaction: TelegramReactionType): EmojiValue { if (reaction.type === "emoji") { return defaultEmojiResolver.fromGChat(reaction.emoji); } @@ -1903,7 +2204,7 @@ export class TelegramAdapter return getEmoji(`custom:${reaction.custom_emoji_id}`); } - private async pollingLoop( + protected async pollingLoop( config: ResolvedTelegramLongPollingConfig ): Promise { let offset: number | undefined; @@ -1967,7 +2268,7 @@ export class TelegramAdapter } } - private resolvePollingConfig( + protected resolvePollingConfig( override?: TelegramLongPollingConfig ): ResolvedTelegramLongPollingConfig { const baseConfig = this.longPolling ?? {}; @@ -2004,7 +2305,7 @@ export class TelegramAdapter }; } - private clampInteger( + protected clampInteger( value: number | undefined, fallback: number, min: number, @@ -2018,11 +2319,11 @@ export class TelegramAdapter return Math.max(min, Math.min(max, parsed)); } - private isAbortError(error: unknown): boolean { + protected isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } - private async sleep(delayMs: number): Promise { + protected async sleep(delayMs: number): Promise { if (delayMs <= 0) { return; } @@ -2032,7 +2333,7 @@ export class TelegramAdapter }); } - private async telegramFetch( + protected async telegramFetch( method: string, payload?: Record | FormData, request?: { @@ -2091,7 +2392,7 @@ export class TelegramAdapter return data.result; } - private throwTelegramApiError( + protected throwTelegramApiError( method: string, status: number, data: TelegramApiResponse @@ -2125,12 +2426,9 @@ export class TelegramAdapter ); } - private async withTelegramMarkdownFallback( - parseMode: string | undefined, - operation: ( - parseMode: string | undefined, - text: string - ) => Promise, + protected async withTelegramMarkdownFallback( + parseMode: TelegramParseMode, + operation: (parseMode: TelegramParseMode, text: string) => Promise, context: { initialText: string; fallbackText: string; @@ -2143,7 +2441,7 @@ export class TelegramAdapter return await operation(parseMode, context.initialText); } catch (error) { if ( - parseMode !== TELEGRAM_MARKDOWN_PARSE_MODE || + parseMode !== "MarkdownV2" || !this.isTelegramMarkdownParseError(error) ) { throw error; @@ -2158,7 +2456,7 @@ export class TelegramAdapter ); return operation( - undefined, + "plain", this.resolveTelegramFallbackText( context.initialText, context.fallbackText @@ -2167,7 +2465,7 @@ export class TelegramAdapter } } - private isTelegramMarkdownParseError(error: unknown): boolean { + protected isTelegramMarkdownParseError(error: unknown): boolean { return ( error instanceof ValidationError && error.adapter === "telegram" && @@ -2182,7 +2480,7 @@ export function createTelegramAdapter( return new TelegramAdapter(config ?? {}); } -export { TelegramFormatConverter } from "./markdown"; +export { escapeMarkdownV2, TelegramFormatConverter } from "./markdown"; export type { TelegramAdapterConfig, TelegramAdapterMode, @@ -2192,6 +2490,7 @@ export type { TelegramMessage, TelegramMessageReactionUpdated, TelegramRawMessage, + TelegramReactionType, TelegramThreadId, TelegramUpdate, TelegramUser, From 2f3497782f6976413a79f114540041db7259c295 Mon Sep 17 00:00:00 2001 From: dancer Date: Thu, 28 May 2026 04:43:32 +0100 Subject: [PATCH 3/3] fix(telegram): scope draft streaming to adapter --- .changeset/tidy-spoons-stream.md | 2 +- README.md | 42 +- .../content/adapters/official/telegram.mdx | 2 +- apps/docs/content/docs/adapters.mdx | 114 +- apps/docs/content/docs/index.mdx | 17 +- apps/docs/content/docs/streaming.mdx | 99 +- packages/adapter-telegram/README.md | 14 +- packages/adapter-telegram/src/index.test.ts | 336 +---- packages/adapter-telegram/src/index.ts | 226 +--- packages/chat/src/index.ts | 2 - packages/chat/src/streaming-markdown.test.ts | 61 - packages/chat/src/streaming-markdown.ts | 143 +-- packages/chat/src/thread.test.ts | 1119 +++++++++++++++-- packages/chat/src/thread.ts | 273 ++-- packages/chat/src/types.ts | 37 +- 15 files changed, 1536 insertions(+), 951 deletions(-) diff --git a/.changeset/tidy-spoons-stream.md b/.changeset/tidy-spoons-stream.md index fd1f26ffc..5b7b00fca 100644 --- a/.changeset/tidy-spoons-stream.md +++ b/.changeset/tidy-spoons-stream.md @@ -3,4 +3,4 @@ "@chat-adapter/telegram": minor --- -Add native Telegram DM draft streaming with markdown-safe segment splitting, and expose segmented stream results in the chat core. +Add native Telegram private chat draft streaming with fallback streaming elsewhere. diff --git a/README.md b/README.md index 92e368681..ae6fea571 100644 --- a/README.md +++ b/README.md @@ -45,23 +45,14 @@ bot.onSubscribedMessage(async (thread, message) => { See the [Getting Started guide](https://chat-sdk.dev/docs/getting-started) for a full walkthrough. -## Supported platforms - -| Platform | Package | Mentions | Reactions | Cards | Modals | Streaming | DMs | -|----------|---------|----------|-----------|-------|--------|-----------|-----| -| Slack | `@chat-adapter/slack` | Yes | Yes | Yes | Yes | Native | Yes | -| 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 | DM Draft + Fallback | Yes | -| GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No | -| Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No | -| WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | No | Yes | +## Adapters + +Browse official, vendor-official, and community adapters on [chat-sdk.dev/adapters](https://chat-sdk.dev/adapters). A cross-platform feature matrix is available at [chat-sdk.dev/docs/adapters](https://chat-sdk.dev/docs/adapters). ## 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, Telegram DM drafts, 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 @@ -70,24 +61,7 @@ See the [Getting Started guide](https://chat-sdk.dev/docs/getting-started) for a - [**File uploads**](https://chat-sdk.dev/docs/files) — send and receive file attachments - [**Direct messages**](https://chat-sdk.dev/docs/direct-messages) — initiate DMs programmatically - [**Ephemeral messages**](https://chat-sdk.dev/docs/ephemeral-messages) — user-only visible messages with DM fallback - -## Packages - -| Package | Description | -|---------|-------------| -| `chat` | Core SDK with `Chat` class, types, JSX runtime, and utilities | -| `@chat-adapter/slack` | [Slack adapter](https://chat-sdk.dev/adapters/slack) | -| `@chat-adapter/teams` | [Teams adapter](https://chat-sdk.dev/adapters/teams) | -| `@chat-adapter/gchat` | [Google Chat adapter](https://chat-sdk.dev/adapters/gchat) | -| `@chat-adapter/discord` | [Discord adapter](https://chat-sdk.dev/adapters/discord) | -| `@chat-adapter/telegram` | [Telegram adapter](https://chat-sdk.dev/adapters/telegram) | -| `@chat-adapter/github` | [GitHub adapter](https://chat-sdk.dev/adapters/github) | -| `@chat-adapter/linear` | [Linear adapter](https://chat-sdk.dev/adapters/linear) | -| `@chat-adapter/whatsapp` | [WhatsApp adapter](https://chat-sdk.dev/adapters/whatsapp) | -| `@chat-adapter/state-redis` | [Redis state adapter](https://chat-sdk.dev/docs/state/redis) (production) | -| `@chat-adapter/state-ioredis` | [ioredis state adapter](https://chat-sdk.dev/docs/state/ioredis) (alternative) | -| `@chat-adapter/state-pg` | [PostgreSQL state adapter](https://chat-sdk.dev/docs/state/postgres) (production) | -| `@chat-adapter/state-memory` | [In-memory state adapter](https://chat-sdk.dev/docs/state/memory) (development) | +- [**Overlapping messages**](https://chat-sdk.dev/docs/concurrency) - burst, queue, debounce, drop, or process concurrent messages on the same thread ## AI coding agent support @@ -103,7 +77,11 @@ Full documentation is available at [chat-sdk.dev/docs](https://chat-sdk.dev/docs ## Contributing -See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and the release process. +See [CONTRIBUTING.md](./.github/CONTRIBUTING.md) for development setup and the release process. + +## Support + +For help or questions, see [SUPPORT.md](./.github/SUPPORT.md). To report a security vulnerability, see [SECURITY.md](./.github/SECURITY.md). ## License 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 332df367e..a8cdd96a6 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -8,63 +8,70 @@ prerequisites: Adapters handle webhook verification, message parsing, and API calls for each platform. Install only the adapters you need. Browse all available adapters — including community-built ones — on the [Adapters](/adapters) page. +Need a browser chat UI? See the [Web adapter](/adapters/official/web) — it speaks the AI SDK UI stream protocol and works with React (`@ai-sdk/react`), Vue (`@ai-sdk/vue`), and Svelte (`@ai-sdk/svelte`), so the same bot serves Slack, Teams, **and** any browser framework out of the box. + Ready to build your own? Follow the [building](/docs/contributing/building) guide. ## Feature matrix + ### Messaging -| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | -|---------|-------|-------|-------------|---------|---------|--------|--------|-----------| -| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | ✅ Images, audio, docs | -| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ DM Draft + Post+Edit fallback | ❌ | ❌ | ❌ | -| Scheduled messages | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | [Messenger](/adapters/messenger) | +|---------|-------|-------|-------------|---------|---------|--------|--------|-----------|-----------| +| Post message | | | | | | | | | | +| Edit message | | | | | | | Partial | | | +| Delete message | | | | | | | Partial | | | +| File uploads | | | | | Single file/media | | | Images, audio, docs | | +| 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 -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | -| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ✅ Interactive replies | -| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | -| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | -| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | ❌ | -| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ Template variables | -| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | -| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| +| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | Generic/Button Templates | +| Buttons | | | | | Inline keyboard callbacks | | | Interactive replies | Max 3, postback | +| Link buttons | | | | | Inline keyboard URLs | | | | | +| Select menus | | | | | | | | | | +| Tables | Block Kit | GFM | ASCII | GFM | ASCII | GFM | GFM | | ASCII | +| Fields | | | | | | | | Template variables | ASCII | +| Images in cards | | | | | | | | | | +| Modals | | | | | | | | | | ### Conversations -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | -| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | -| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| +| Slash commands | | | | | | | | | | +| Mentions | | | | | | | | | | +| Add reactions | | | | | | | | | | +| Remove reactions | | | | | | | | | | +| Typing indicator | | | | | | | Agent sessions | | | +| DMs | | | | | | | | | | +| Ephemeral messages | Native | | Native | | | | | | | +| User lookup ([`getUser`](/docs/api/chat#getuser)) | | Cached | Cached | | Seen users | | | | | +| Parent subject ([`message.subject`](/docs/subject)) | | | | | | | | | | +| Native client ([`.webClient` / `.octokit` / `.linearClient`](/docs/api/chat#getadapter)) | | | | | | | | | | +| Custom API endpoint (`apiUrl`) | | | | | | | | | | ### Message history -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | ⚠️ Cached sent messages only | -| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | ⚠️ Cached sent messages only | -| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | ⚠️ Cached sent messages only | -| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | -| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | -| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| +| Fetch messages | | | | | Cached | | | Cached sent messages only | Cached sent messages only | +| Fetch single message | | | | | Cached | | | | Cached | +| Fetch thread info | | | | | | | | | | +| Fetch channel messages | | | | | Cached | | | | Cached | +| List threads | | | | | | | | | | +| Fetch channel info | | | | | | | | | | +| Post channel message | | | | | | | | | | -⚠️ indicates partial support — the feature works with limitations. See individual adapter pages for details. + indicates partial support — the feature works with limitations. See individual adapter pages for details. -## How [adapters](/adapters) work +## How adapters work Each adapter implements a standard interface that the `Chat` class uses to route events and send messages. When a webhook arrives: @@ -73,7 +80,7 @@ Each adapter implements a standard interface that the `Chat` class uses to route 3. Routes to your handlers via the `Chat` class 4. Converts outgoing messages from markdown/AST/cards to the platform's native format -## Using multiple [adapters](/adapters) +## Using multiple adapters Register multiple [adapters](/adapters) and your event handlers work across all of them: @@ -108,3 +115,32 @@ Each adapter auto-detects credentials from environment variables, so you only ne Each adapter creates a webhook handler accessible via `bot.webhooks.`. + +## Customizing an adapter via subclassing + +Each official adapter exposes its extension surface as `protected` members so you can subclass it to override or extend platform-specific behavior without forking the package. Use this when you need to handle a payload type the built-in adapter doesn't cover, intercept verification, or wrap an existing handler. + +```typescript title="lib/custom-telegram.ts" lineNumbers +import { TelegramAdapter, type TelegramUpdate } from "@chat-adapter/telegram"; +import type { WebhookOptions } from "chat"; + +export class CustomTelegramAdapter extends TelegramAdapter { + protected override processUpdate( + update: TelegramUpdate, + options?: WebhookOptions + ): void { + // Handle a payload type the base adapter doesn't, e.g. chat_join_request. + if ("chat_join_request" in update) { + this.logger.info("Received chat_join_request", { update }); + return; + } + super.processUpdate(update, options); + } +} +``` + +Construct your subclass anywhere you'd construct the base adapter — for example, `adapters: { telegram: new CustomTelegramAdapter({ ... }) }`. Members marked `private` (internal caches, in-flight runtime state, one-shot warning flags) intentionally remain inaccessible; if you find a hook you need that isn't `protected`, please open an issue. + + + The `protected` extension surface is intentionally broader than the public API but is not yet considered fully stable. Method signatures may evolve (renames, parameter changes, new hook splits) in minor releases as we learn from real-world subclasses. Pin the adapter version you build against, watch the changelog for the affected adapter, and prefer overriding the smallest hook that solves your problem so upgrades stay easy. If you rely on a particular hook, please open an issue so we can promote it to a stable, documented extension point. + diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index f79bda0b0..b011f1bb4 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -4,7 +4,7 @@ description: A unified SDK for building chat bots across Slack, Microsoft Teams, type: overview --- -Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. +Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, WhatsApp, and Messenger. ## Why Chat SDK? @@ -52,13 +52,15 @@ Each adapter factory auto-detects credentials from environment variables (`SLACK | Platform | Package | Mentions | Reactions | Cards | Modals | Streaming | DMs | |----------|---------|----------|-----------|-------|--------|-----------|-----| | Slack | `@chat-adapter/slack` | Yes | Yes | Yes | Yes | Native | Yes | -| Microsoft Teams | `@chat-adapter/teams` | Yes | Read-only | Yes | No | Post+Edit | Yes | +| 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 | DM Draft + Fallback | Yes | -| GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No | -| Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No | -| WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | No | 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 | +| Twilio | `@chat-adapter/twilio` | N/A | No | Fallback | No | Buffered | Yes | +| Messenger | `@chat-adapter/messenger` | Yes | Receive-only | Partial | No | Buffered | Yes | ## AI coding agent support @@ -77,6 +79,7 @@ The SDK is distributed as a set of packages you install based on your needs: | Package | Description | |---------|-------------| | `chat` | Core SDK with `Chat` class, types, JSX runtime, and utilities | +| `chat/ai` | [AI utilities](/docs/ai) — [`createChatTools`](/docs/ai/ai-sdk-tools) for agent operations and [`toAiMessages`](/docs/ai/to-ai-messages) for converting chat history into AI SDK prompts | | `@chat-adapter/slack` | Slack adapter | | `@chat-adapter/teams` | Microsoft Teams adapter | | `@chat-adapter/gchat` | Google Chat adapter | @@ -85,6 +88,8 @@ The SDK is distributed as a set of packages you install based on your needs: | `@chat-adapter/github` | GitHub Issues adapter | | `@chat-adapter/linear` | Linear Issues adapter | | `@chat-adapter/whatsapp` | WhatsApp Business adapter | +| `@chat-adapter/twilio` | Twilio SMS and MMS adapter | +| `@chat-adapter/messenger` | Facebook Messenger adapter | | `@chat-adapter/state-redis` | Redis state adapter (production) | | `@chat-adapter/state-ioredis` | ioredis state adapter (alternative) | | `@chat-adapter/state-pg` | PostgreSQL state adapter (production) | diff --git a/apps/docs/content/docs/streaming.mdx b/apps/docs/content/docs/streaming.mdx index 1addf579d..1740fb11e 100644 --- a/apps/docs/content/docs/streaming.mdx +++ b/apps/docs/content/docs/streaming.mdx @@ -6,7 +6,7 @@ prerequisites: - /docs/usage --- -Chat SDK accepts any `AsyncIterable` as a message, enabling real-time streaming of AI responses and other incremental content to chat platforms. For platforms with native streaming support (Slack), you can also stream structured `StreamChunk` objects for rich content like task progress cards and plan updates. +Chat SDK accepts any `AsyncIterable` as a message, enabling real-time streaming of AI responses and other incremental content to chat platforms. For platforms with native or structured streaming support, you can also stream `StreamChunk` objects for rich content like task progress cards and plan updates. ## AI SDK integration @@ -59,10 +59,14 @@ await thread.post(stream); | Platform | Method | Description | |----------|--------|-------------| | Slack | Native streaming API | Uses Slack's `chatStream` for smooth, real-time updates | -| Telegram | Private chat draft streaming | Uses Telegram's `sendMessageDraft` in private chats and falls back to post + edit elsewhere | -| Teams | Post + Edit | Posts a message then edits it as chunks arrive | +| 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 | +| 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 | +| Messenger | Buffered | Accumulates chunks and sends one final message | The post+edit fallback throttles edits to avoid rate limits. Configure the update interval when creating your `Chat` instance: @@ -105,9 +109,9 @@ When streaming content that contains GFM tables (e.g. from an LLM), the SDK auto This happens transparently — no configuration needed. -## Structured streaming chunks (Slack only) +## Structured streaming chunks -For Slack's native streaming API, you can yield `StreamChunk` objects alongside plain text for rich content: +For Slack native streams and Linear agent-session streams, you can yield `StreamChunk` objects alongside plain text for rich progress updates: ```typescript title="lib/bot.ts" lineNumbers import type { StreamChunk } from "chat"; @@ -119,6 +123,7 @@ const stream = (async function* () { type: "task_update", id: "search-1", title: "Searching documents", + details: "Querying internal docs and ranking the best matches", status: "in_progress", } satisfies StreamChunk; @@ -128,6 +133,7 @@ const stream = (async function* () { type: "task_update", id: "search-1", title: "Searching documents", + details: "Ranked 3 relevant results", status: "complete", output: "Found 3 results", } satisfies StreamChunk; @@ -143,33 +149,42 @@ await thread.post(stream); | Type | Fields | Description | |------|--------|-------------| | `markdown_text` | `text` | Streamed text content | -| `task_update` | `id`, `title`, `status`, `output?` | Tool/step progress cards (`pending`, `in_progress`, `complete`, `error`) | -| `plan_update` | `title` | Plan title updates | +| `task_update` | `id`, `title`, `status`, `details?`, `output?` | Tool/step progress updates (`pending`, `in_progress`, `complete`, `error`) with optional extra task context | +| `plan_update` | `title` | Plan title updates on supported platforms | -### Task display mode +### Streaming with options -Control how `task_update` chunks render in Slack by passing `taskDisplayMode` in stream options: +Wrap a stream in a `StreamingPlan` to pass platform-specific options through `thread.post()` without dropping down to `adapter.stream()` directly: ```typescript -await thread.stream(stream, { - taskDisplayMode: "plan", // Group all tasks into a single plan block +import { StreamingPlan } from "chat"; + +const planned = new StreamingPlan(stream, { + groupTasks: "plan", // Slack: render task cards as a single grouped block + endWith: [feedbackBlock], // Slack: Block Kit elements appended after stream stops + updateIntervalMs: 750, // Post+edit cadence on supported adapters }); + +await thread.post(planned); ``` -| Mode | Description | -|------|-------------| -| `"timeline"` | Individual task cards shown inline with text (default) | -| `"plan"` | All tasks grouped into a single plan block | +| Option | Platform | Description | +|--------|----------|-------------| +| `groupTasks` | Slack | `"timeline"` (default) renders task cards inline; `"plan"` groups them into one plan block | +| `endWith` | Slack | Block Kit elements attached when the stream stops (e.g. retry / feedback buttons) | +| `updateIntervalMs` | Post+edit adapters | Minimum interval between post+edit cycles in ms (default `500`) | -Adapters without structured chunk support extract text from `markdown_text` chunks and ignore other types. +Adapters without structured chunk support extract text from `markdown_text` chunks and ignore other types. Slack-only options are silently ignored on other platforms. ## Stop blocks (Slack only) -When streaming in Slack, you can attach Block Kit elements to the final message using `stopBlocks`. This is useful for adding action buttons after a streamed response completes: +Use `endWith` on `StreamingPlan` to attach Block Kit elements to the final message. This is useful for adding action buttons after a streamed response completes: ```typescript title="lib/bot.ts" lineNumbers -await thread.stream(textStream, { - stopBlocks: [ +import { StreamingPlan } from "chat"; + +const planned = new StreamingPlan(textStream, { + endWith: [ { type: "actions", elements: [{ @@ -180,15 +195,55 @@ await thread.stream(textStream, { }, ], }); + +await thread.post(planned); ``` +## Plan API + +For step-by-step task progress that lives outside an LLM stream, post a `Plan` directly. `Plan` is a `PostableObject` you can mutate after posting — every mutation re-renders the block in place. + +```typescript title="lib/bot.ts" lineNumbers +import { Plan } from "chat"; + +const plan = new Plan({ initialMessage: "Researching options..." }); +await thread.post(plan); + +const lookup = await plan.addTask({ title: "Look up customer record" }); +// ...do work... +await plan.updateTask("Found 3 matches"); + +await plan.addTask({ title: "Summarize findings" }); +await plan.complete({ completeMessage: "Done!" }); +``` + +By default `updateTask()` mutates the most recent `in_progress` task. Pass `{ id }` to target a specific task — useful when work runs in parallel or out of order: + +```typescript +const fetchTask = await plan.addTask({ title: "Fetch data" }); +const transformTask = await plan.addTask({ title: "Transform" }); + +// Update a specific task by id, even if it isn't the most recent in_progress one. +await plan.updateTask({ id: fetchTask.id, output: "Got 42 rows" }); +await plan.updateTask({ id: transformTask.id, status: "complete" }); +``` + +Adapters that don't support PostableObject editing (e.g. WhatsApp) render the plan as a fallback emoji-list message; the plan still posts, but mutations are no-ops. + +| Method | Description | +|--------|-------------| +| `addTask({ title, children? })` | Append a new task. The previous in-progress task is auto-completed | +| `updateTask(input)` | Mutate the current (or `{ id }`-targeted) task's `output`, `status`, or `title` | +| `complete({ completeMessage })` | Mark all in-progress tasks complete and update the plan title | +| `reset({ initialMessage })` | Discard all tasks and start fresh with a new initial message — useful when re-using a plan handle for a new run | + ## Streaming with conversation history Combine message history with streaming for multi-turn AI conversations. -Use [`toAiMessages()`](/docs/api/to-ai-messages) to convert chat messages into the `{ role, content }` format expected by AI SDKs: +Use [`toAiMessages()`](/docs/ai/to-ai-messages) to convert chat messages into the `{ role, content }` format expected by AI SDKs: ```typescript title="lib/bot.ts" lineNumbers -import { toAiMessages } from "chat"; +import { toAiMessages } from "chat/ai"; bot.onSubscribedMessage(async (thread, message) => { // Fetch recent messages for context @@ -201,4 +256,4 @@ bot.onSubscribedMessage(async (thread, message) => { }); ``` -See the [`toAiMessages` API reference](/docs/api/to-ai-messages) for all options including `includeNames`, `transformMessage`, and attachment handling. +See the [`toAiMessages` reference](/docs/ai/to-ai-messages) for all options including `includeNames`, `transformMessage`, and attachment handling. diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md index 8b0905f90..0a2a9ec3c 100644 --- a/packages/adapter-telegram/README.md +++ b/packages/adapter-telegram/README.md @@ -118,7 +118,7 @@ All options are auto-detected from environment variables when not provided. | `mode` | No | Adapter mode: `auto` (default), `webhook`, or `polling` | | `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` | +| `apiUrl` | No | Telegram API base URL. Auto-detected from `TELEGRAM_API_BASE_URL`. Use `apiUrl` for cross-adapter consistency; the legacy `apiBaseUrl` alias is still accepted | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | *`botToken` is required — either via config or env vars. @@ -143,13 +143,14 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org | Edit message | Yes | | Delete message | Yes | | File uploads | Single file (`sendDocument`) | -| Streaming | DM Draft + Post+Edit fallback | +| Attachment uploads | Single image/audio/video/file (`sendPhoto`, `sendAudio`, `sendVideo`, `sendDocument`) | +| Streaming | Private chat draft previews + post/edit fallback | ### Rich content | Feature | Supported | |---------|-----------| -| Card format | Markdown + inline keyboard buttons | +| Card format | MarkdownV2 + inline keyboard buttons | | Buttons | Inline keyboard callbacks | | Link buttons | Inline keyboard URLs | | Select menus | No | @@ -182,6 +183,12 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org | Fetch channel info | Yes | | Post channel message | Yes | +## Markdown formatting + +Outbound messages are sent with Telegram's `MarkdownV2` parse mode. The adapter walks the markdown AST and emits MarkdownV2 with context-aware escaping (normal text vs. code blocks vs. link URLs), so you author standard markdown (`**bold**`, `*italic*`, `` `code` ``, `[label](url)`) and the adapter handles every reserved character. + +Behavior change in 4.27.0: previous versions used Telegram's legacy `Markdown` parse mode, which used different syntax (`*bold*` instead of `**bold**`) and silently rejected any text containing unescaped `.`, `!`, `(`, `)`, `-`, `_`. If you were emitting raw legacy-Markdown strings or hand-escaping characters yourself, drop the manual escaping — the renderer does it for you. Pass `{ raw: "..." }` only if you need to ship a fully pre-escaped MarkdownV2 string. + ## Notes - Telegram does not expose full historical message APIs to bots. `fetchMessages` / `fetchChannelMessages` return adapter-cached messages from the current process. @@ -192,6 +199,7 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org - 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. +- `files` upload as Telegram documents. `attachments` preserve the normalized media type for single image, audio, video, or file uploads. Use `data` or `fetchData` for private/authenticated files; URL-only attachments must be public URLs Telegram can fetch directly. - Other rich card elements (images/select menus/radios) render as fallback text only. ## License diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts index 3a64810eb..f96dda3a4 100644 --- a/packages/adapter-telegram/src/index.test.ts +++ b/packages/adapter-telegram/src/index.test.ts @@ -923,6 +923,7 @@ describe("TelegramAdapter", () => { ) .mockResolvedValueOnce(telegramOk(true)) .mockResolvedValueOnce(telegramOk(true)) + .mockResolvedValueOnce(telegramOk(true)) .mockResolvedValueOnce( telegramOk( sampleMessage({ @@ -953,15 +954,17 @@ describe("TelegramAdapter", () => { expect(result?.id).toBe("123:11"); expect(result?.threadId).toBe("telegram:123"); - const firstDraftUrl = String(mockFetch.mock.calls[1]?.[0]); - const secondDraftUrl = String(mockFetch.mock.calls[2]?.[0]); - const finalSendUrl = String(mockFetch.mock.calls[3]?.[0]); + 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 firstDraftBody = JSON.parse( + const initialDraftBody = JSON.parse( String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) ) as { chat_id: string; @@ -970,7 +973,7 @@ describe("TelegramAdapter", () => { text: string; }; - const secondDraftBody = JSON.parse( + const firstDraftBody = JSON.parse( String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) ) as { chat_id: string; @@ -979,189 +982,36 @@ describe("TelegramAdapter", () => { text: string; }; - const finalSendBody = JSON.parse( + 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("Markdown"); + 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("Markdown"); - }); - - it("splits long private chat streams into multiple final messages", async () => { - const longPrefix = "a".repeat(3600); - const longSuffix = "b".repeat(1200); - - mockFetch - .mockResolvedValueOnce( - telegramOk({ - id: 999, - is_bot: true, - first_name: "Bot", - username: "mybot", - }) - ) - .mockResolvedValueOnce(telegramOk(true)) - .mockResolvedValueOnce( - telegramOk( - sampleMessage({ - message_id: 21, - text: "a".repeat(3500), - }) - ) - ) - .mockResolvedValueOnce(telegramOk(true)) - .mockResolvedValueOnce( - telegramOk( - sampleMessage({ - message_id: 22, - text: `${"a".repeat(100)}${"b".repeat(1200)}`, - }) - ) - ); - - const adapter = createTelegramAdapter({ - botToken: "token", - mode: "webhook", - logger: mockLogger, - userName: "mybot", - }); - - await adapter.initialize(createMockChat()); - - async function* textStream(): AsyncIterable { - yield longPrefix; - yield longSuffix; - } - - const result = await adapter.stream("telegram:123", textStream(), { - updateIntervalMs: Number.MAX_SAFE_INTEGER, - }); - - expect(result).not.toBeNull(); - expect(result).toHaveProperty("messages"); - if (!(result && "messages" in result)) { - throw new Error("Expected segmented stream result"); - } - expect(result.messages).toHaveLength(2); - expect(result.messages[0]?.message.id).toBe("123:21"); - expect(result.messages[1]?.message.id).toBe("123:22"); - - const draftBodies = mockFetch.mock.calls - .slice(1) - .filter((call) => String(call[0]).endsWith("/sendMessageDraft")) - .map( - (call) => - JSON.parse(String((call[1] as RequestInit).body)) as { - draft_id: number; - text: string; - } - ); - const sendBodies = mockFetch.mock.calls - .slice(1) - .filter((call) => String(call[0]).endsWith("/sendMessage")) - .map( - (call) => - JSON.parse(String((call[1] as RequestInit).body)) as { - chat_id: string; - text: string; - } - ); - - expect(draftBodies).toHaveLength(2); - expect(draftBodies[0]?.text).toHaveLength(3500); - expect(draftBodies[1]?.text).toHaveLength(1300); - expect(draftBodies[1]?.draft_id).not.toBe(draftBodies[0]?.draft_id); - - expect(sendBodies).toHaveLength(2); - expect(sendBodies[0]?.chat_id).toBe("123"); - expect(sendBodies[0]?.text).toHaveLength(3500); - expect(sendBodies[1]?.text).toHaveLength(1300); - expect(`${sendBodies[0]?.text ?? ""}${sendBodies[1]?.text ?? ""}`).toBe( - `${longPrefix}${longSuffix}` - ); + expect(finalSendBody.parse_mode).toBe("MarkdownV2"); }); - it("splits long markdown streams on a clean prefix before unbalanced markers", async () => { - const longPrefix = `${"a".repeat(3498)}**bo`; - const longSuffix = "ld**!"; - - mockFetch - .mockResolvedValueOnce( - telegramOk({ - id: 999, - is_bot: true, - first_name: "Bot", - username: "mybot", - }) - ) - .mockResolvedValueOnce(telegramOk(true)) - .mockResolvedValueOnce( - telegramOk( - sampleMessage({ - message_id: 31, - text: "a".repeat(3498), - }) - ) - ) - .mockResolvedValueOnce(telegramOk(true)) - .mockResolvedValueOnce( - telegramOk( - sampleMessage({ - message_id: 32, - text: "**bold**!", - }) - ) - ); - - const adapter = createTelegramAdapter({ - botToken: "token", - mode: "webhook", - logger: mockLogger, - userName: "mybot", - }); - - await adapter.initialize(createMockChat()); - - async function* textStream(): AsyncIterable { - yield longPrefix; - yield longSuffix; - } - - const result = await adapter.stream("telegram:123", textStream(), { - updateIntervalMs: Number.MAX_SAFE_INTEGER, - }); - - expect(result).not.toBeNull(); - expect(result).toHaveProperty("messages"); - if (!(result && "messages" in result)) { - throw new Error("Expected segmented stream result"); - } - - const sendBodies = mockFetch.mock.calls - .slice(1) - .filter((call) => String(call[0]).endsWith("/sendMessage")) - .map( - (call) => - JSON.parse(String((call[1] as RequestInit).body)) as { - parse_mode?: string; - text: string; - } - ); - - expect(sendBodies).toHaveLength(2); - expect(sendBodies[0]?.text).toBe("a".repeat(3498)); - expect(sendBodies[1]?.parse_mode).toBe("Markdown"); - expect(sendBodies[1]?.text).toBe("**bold**!"); - }); - - it("keeps markdown parse mode for an exact-limit clean segment", async () => { + 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 }; @@ -1238,118 +1088,37 @@ describe("TelegramAdapter", () => { }, { method: "sendMessageDraft", - len: longMarkdown.length, - tail: longMarkdown.slice(-10), - parse_mode: "Markdown", + len: 0, + tail: "", + parse_mode: undefined, + }, + { + method: "sendMessageDraft", + len: renderedMarkdown.length, + tail: renderedMarkdown.slice(-10), + parse_mode: "MarkdownV2", }, { method: "sendMessage", - len: longMarkdown.length, - tail: longMarkdown.slice(-10), - parse_mode: "Markdown", + len: renderedMarkdown.length, + tail: renderedMarkdown.slice(-10), + parse_mode: "MarkdownV2", }, ]); - const draftBody = requestBodies[1]?.body as { + const draftBody = requestBodies[2]?.body as { parse_mode?: string; text: string; }; - const finalSendBody = requestBodies[2]?.body as { + const finalSendBody = requestBodies[3]?.body as { parse_mode?: string; text: string; }; - expect(draftBody.parse_mode).toBe("Markdown"); - expect(draftBody.text).toBe(longMarkdown); - expect(finalSendBody.parse_mode).toBe("Markdown"); - expect(finalSendBody.text).toBe(longMarkdown); - }); - - it("splits streams by rendered MarkdownV2 length before final sends can truncate", async () => { - const sourceMarkdown = ".".repeat(3500); - const renderedMarkdown = "\\.".repeat(3500); - const requestBodies: Array<{ - method: string; - body: { parse_mode?: string; text?: string }; - }> = []; - let nextMessageId = 51; - - 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: body.text ?? "", - }) - ); - } - - 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 sourceMarkdown; - } - - const result = await adapter.stream("telegram:123", textStream(), { - updateIntervalMs: Number.MAX_SAFE_INTEGER, - }); - - expect(result).not.toBeNull(); - expect(result).toHaveProperty("messages"); - if (!(result && "messages" in result)) { - throw new Error("Expected segmented stream result"); - } - - const finalSendBodies = requestBodies - .filter((request) => request.method === "sendMessage") - .map((request) => request.body); - - expect(finalSendBodies.length).toBeGreaterThan(1); - expect(result.messages).toHaveLength(finalSendBodies.length); - expect( - finalSendBodies.every((body) => body.parse_mode === "MarkdownV2") - ).toBe(true); - expect( - finalSendBodies.every( - (body) => (body.text?.length ?? 0) <= TELEGRAM_MESSAGE_LIMIT - ) - ).toBe(true); - expect(finalSendBodies.map((body) => body.text ?? "").join("")).toBe( - renderedMarkdown - ); + 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 () => { @@ -1444,6 +1213,7 @@ describe("TelegramAdapter", () => { username: "mybot", }) ) + .mockResolvedValueOnce(telegramOk(true)) .mockResolvedValueOnce( telegramError( 400, @@ -1488,7 +1258,7 @@ describe("TelegramAdapter", () => { ); const finalSendBody = JSON.parse( - String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + String((mockFetch.mock.calls[4]?.[1] as RequestInit).body) ) as { parse_mode?: string; text: string }; expect(finalSendBody.parse_mode).toBeUndefined(); @@ -2006,7 +1776,7 @@ describe("TelegramAdapter", () => { String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) ) as { parse_mode?: string; text: string }; - expect(firstSendBody.parse_mode).toBe("Markdown"); + expect(firstSendBody.parse_mode).toBe("MarkdownV2"); expect(secondSendBody.parse_mode).toBeUndefined(); expect(secondSendBody.text).toBe("**broken"); expect(mockLogger.warn).toHaveBeenCalledWith( @@ -2145,7 +1915,7 @@ describe("TelegramAdapter", () => { String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) ) as { parse_mode?: string; text: string }; - expect(firstEditBody.parse_mode).toBe("Markdown"); + expect(firstEditBody.parse_mode).toBe("MarkdownV2"); expect(secondEditBody.parse_mode).toBeUndefined(); expect(secondEditBody.text).toBe("**broken"); expect(mockLogger.warn).toHaveBeenCalledWith( @@ -2218,6 +1988,7 @@ describe("TelegramAdapter", () => { username: "mybot", }) ) + .mockResolvedValueOnce(telegramOk(true)) .mockResolvedValueOnce( telegramError( 400, @@ -2254,10 +2025,10 @@ describe("TelegramAdapter", () => { expect(result?.id).toBe("123:11"); const retryDraftBody = JSON.parse( - String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) ) as { parse_mode?: string; text: string }; const finalSendBody = JSON.parse( - String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + String((mockFetch.mock.calls[4]?.[1] as RequestInit).body) ) as { parse_mode?: string; text: string }; expect(retryDraftBody.parse_mode).toBeUndefined(); @@ -2276,6 +2047,7 @@ describe("TelegramAdapter", () => { username: "mybot", }) ) + .mockResolvedValueOnce(telegramOk(true)) .mockResolvedValueOnce( telegramError( 400, @@ -2312,10 +2084,10 @@ describe("TelegramAdapter", () => { expect(result?.id).toBe("123:11"); const retryDraftBody = JSON.parse( - String((mockFetch.mock.calls[2]?.[1] as RequestInit).body) + String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) ) as { parse_mode?: string; text: string }; const finalSendBody = JSON.parse( - String((mockFetch.mock.calls[3]?.[1] as RequestInit).body) + String((mockFetch.mock.calls[4]?.[1] as RequestInit).body) ) as { parse_mode?: string; text: string }; expect(retryDraftBody.parse_mode).toBeUndefined(); diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts index c88523c99..22bdbf079 100644 --- a/packages/adapter-telegram/src/index.ts +++ b/packages/adapter-telegram/src/index.ts @@ -25,7 +25,6 @@ import type { RawMessage, StreamChunk, StreamOptions, - StreamResult, ThreadInfo, UserInfo, WebhookOptions, @@ -101,9 +100,6 @@ 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; -// Keep streaming segments below Telegram's 4096-character hard limit to leave -// room for Markdown parsing and avoid truncating the final sent message. -const TELEGRAM_STREAM_SEGMENT_LIMIT = 3500; const TELEGRAM_MARKDOWN_PARSE_ERROR_PATTERN = /can't parse (?:caption )?entities/i; const TELEGRAM_MAX_POLLING_LIMIT = 100; @@ -1008,9 +1004,7 @@ export class TelegramAdapter threadId: string, textStream: AsyncIterable, options?: StreamOptions - ): Promise< - RawMessage | StreamResult | null - > { + ): Promise | null> { if (!this.isDM(threadId)) { return null; } @@ -1023,15 +1017,13 @@ export class TelegramAdapter Number.MAX_SAFE_INTEGER ); - let renderer = new StreamingMarkdownRenderer(); - let segmentText = ""; - let draftId = this.createDraftId(); - let lastDraftText = ""; + const renderer = new StreamingMarkdownRenderer(); + const draftId = this.createDraftId(); + let accumulated = ""; + let lastDraftText: string | null = null; let lastFlushAt = 0; let draftStreamingEnabled = true; let streamUsesMarkdown = true; - let segmentUsesMarkdown = true; - const postedSegments: StreamResult["messages"] = []; const renderMarkdownForTelegram = (text: string): string => convertEmojiPlaceholders( @@ -1053,108 +1045,21 @@ export class TelegramAdapter "plain" ); - const isMarkdownSegmentWithinLimit = (text: string): boolean => { - const rendered = renderMarkdownForTelegram(text); - return ( - rendered.trim().length > 0 && - truncateForTelegram(rendered, TELEGRAM_MESSAGE_LIMIT, "MarkdownV2") === - rendered - ); - }; - - const getCommittedPrefixFor = (text: string): string => { - const candidateRenderer = new StreamingMarkdownRenderer(); - candidateRenderer.push(text); - return candidateRenderer.getCommittedMarkdownPrefix(); - }; - - const findMarkdownSegmentPrefixWithinLimit = (text: string): string => { - if (!text.trim()) { - return ""; - } - - const renderedLength = renderMarkdownForTelegram(text).length; - let candidateLength = text.length; - - if (renderedLength > TELEGRAM_MESSAGE_LIMIT) { - candidateLength = Math.max( - 1, - Math.min( - text.length - 1, - Math.floor((text.length * TELEGRAM_MESSAGE_LIMIT) / renderedLength) - ) - ); - } - - while (candidateLength > 0) { - const candidate = getCommittedPrefixFor(text.slice(0, candidateLength)); - if (candidate.trim() && isMarkdownSegmentWithinLimit(candidate)) { - return candidate; - } - - const nextLength = Math.floor(candidateLength * 0.9); - candidateLength = - nextLength < candidateLength ? nextLength : candidateLength - 1; - } - - return ""; - }; - - const resetSegment = (nextText = ""): void => { - renderer = new StreamingMarkdownRenderer(); - segmentText = ""; - draftId = this.createDraftId(); - lastDraftText = ""; - lastFlushAt = 0; - segmentUsesMarkdown = streamUsesMarkdown; - - if (nextText) { - renderer.push(nextText); - segmentText = nextText; - } - }; - - const postSegment = async ( + const sendDraft = async ( text: string, useMarkdown: boolean ): Promise => { - if (!text.trim()) { - return; - } - - const postable: AdapterPostableMessage = - useMarkdown && isMarkdownSegmentWithinLimit(text) - ? { markdown: text } - : this.resolveTelegramFallbackText(text, markdownToPlainText(text)); - const message = await this.postMessage(threadId, postable); - - postedSegments.push({ - message, - postable, - }); - }; - - const flushDraft = async (sourceText = segmentText): Promise => { - if (!draftStreamingEnabled) { - return; - } - - const draftText = segmentUsesMarkdown - ? renderMarkdownText( - sourceText === segmentText ? renderer.render() : sourceText - ) - : renderPlainText(sourceText); - if (!draftText.trim() || draftText === lastDraftText) { + if (!draftStreamingEnabled || text === lastDraftText) { return; } try { - if (segmentUsesMarkdown) { + if (useMarkdown) { await this.telegramFetch("sendMessageDraft", { chat_id: parsedThread.chatId, message_thread_id: parsedThread.messageThreadId, draft_id: draftId, - text: draftText, + text, parse_mode: toBotApiParseMode("MarkdownV2"), }); } else { @@ -1162,21 +1067,16 @@ export class TelegramAdapter chat_id: parsedThread.chatId, message_thread_id: parsedThread.messageThreadId, draft_id: draftId, - text: draftText, + text, }); } - lastDraftText = draftText; + lastDraftText = text; lastFlushAt = Date.now(); } catch (error) { - if (segmentUsesMarkdown && this.isTelegramMarkdownParseError(error)) { + if (useMarkdown && this.isTelegramMarkdownParseError(error)) { streamUsesMarkdown = false; - segmentUsesMarkdown = false; - const plainDraftText = renderPlainText(sourceText); - if (!plainDraftText.trim()) { - draftStreamingEnabled = false; - return; - } + const plainDraftText = renderPlainText(accumulated); try { await this.telegramFetch("sendMessageDraft", { @@ -1205,90 +1105,56 @@ export class TelegramAdapter } }; - const appendText = async (text: string): Promise => { - let remaining = text; - - while (remaining.length > 0) { - const available = - TELEGRAM_STREAM_SEGMENT_LIMIT - segmentText.length || 0; - const nextSlice = available > 0 ? remaining.slice(0, available) : ""; - - if (!nextSlice) { - await flushDraft(); - await postSegment(segmentText, segmentUsesMarkdown); - resetSegment(); - continue; - } - - renderer.push(nextSlice); - segmentText += nextSlice; - remaining = remaining.slice(nextSlice.length); - - if (Date.now() - lastFlushAt >= updateIntervalMs) { - await flushDraft(); - } - - const renderedOverflow = - segmentUsesMarkdown && - segmentText.length > Math.floor(TELEGRAM_MESSAGE_LIMIT / 2) && - !isMarkdownSegmentWithinLimit(segmentText); - - if ( - segmentText.length >= TELEGRAM_STREAM_SEGMENT_LIMIT || - renderedOverflow - ) { - const committedPrefix = segmentUsesMarkdown - ? renderer.getCommittedMarkdownPrefix() - : ""; - const markdownPrefix = - segmentUsesMarkdown && isMarkdownSegmentWithinLimit(committedPrefix) - ? committedPrefix - : findMarkdownSegmentPrefixWithinLimit(committedPrefix); - - if (segmentUsesMarkdown && markdownPrefix.trim()) { - const overflow = segmentText.slice(markdownPrefix.length); - await flushDraft(markdownPrefix); - await postSegment(markdownPrefix, true); - resetSegment(overflow); - continue; - } - - if (segmentUsesMarkdown) { - streamUsesMarkdown = false; - segmentUsesMarkdown = false; - } - - await flushDraft(); - await postSegment(segmentText, segmentUsesMarkdown); - resetSegment(); - } + 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") { - await appendText(chunk); + text = chunk; } else if (chunk.type === "markdown_text") { - await appendText(chunk.text); + text = chunk.text; } - } - await flushDraft(); + if (text === null) { + continue; + } + + accumulated += text; + renderer.push(text); - if (segmentText.trim()) { - await postSegment(segmentText, segmentUsesMarkdown); + if (Date.now() - lastFlushAt >= updateIntervalMs) { + await flushDraft(); + } } - if (postedSegments.length === 0) { + await flushDraft(); + + if (!accumulated.trim()) { throw new ValidationError( "telegram", "Telegram streaming requires text content" ); } - return postedSegments.length === 1 - ? postedSegments[0].message - : { messages: postedSegments }; + const finalPostable: AdapterPostableMessage = streamUsesMarkdown + ? { markdown: accumulated } + : this.resolveTelegramFallbackText( + accumulated, + markdownToPlainText(accumulated) + ); + + return this.postMessage(threadId, finalPostable); } async fetchMessages( diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 6f477eaec..14a7c7f70 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -398,8 +398,6 @@ export type { StreamChunk, StreamEvent, StreamOptions, - StreamResult, - StreamSegment, SubscribedMessageHandler, TaskUpdateChunk, Thread, diff --git a/packages/chat/src/streaming-markdown.test.ts b/packages/chat/src/streaming-markdown.test.ts index b42a50c7f..cddced856 100644 --- a/packages/chat/src/streaming-markdown.test.ts +++ b/packages/chat/src/streaming-markdown.test.ts @@ -139,67 +139,6 @@ describe("StreamingMarkdownRenderer", () => { expect(r.render()).toBe("Hello world"); }); - it("returns the clean committed prefix before an unclosed inline marker", () => { - const r = new StreamingMarkdownRenderer(); - r.push("Hello **wor"); - - expect(r.getCommittedMarkdownPrefix()).toBe("Hello "); - }); - - it("trims dangling trailing markers with no content", () => { - const r = new StreamingMarkdownRenderer(); - r.push("Hello **"); - - expect(r.getCommittedMarkdownPrefix()).toBe("Hello "); - }); - - it("preserves valid closing bold markers in the committed prefix", () => { - const r = new StreamingMarkdownRenderer(); - r.push("Hello **world**"); - - expect(r.getCommittedMarkdownPrefix()).toBe("Hello **world**"); - }); - - it("preserves valid closing code markers in the committed prefix", () => { - const r = new StreamingMarkdownRenderer(); - r.push("Use `code` and then:\n```ts\nconst x = 1;\n```\n"); - - expect(r.getCommittedMarkdownPrefix()).toBe( - "Use `code` and then:\n```ts\nconst x = 1;\n```\n" - ); - }); - - it("preserves the full committed prefix for an exact-limit clean markdown segment", () => { - const r = new StreamingMarkdownRenderer(); - const text = `${"a".repeat(3494)}**ok**`; - r.push(text); - - expect(r.getCommittedMarkdownPrefix()).toBe(text); - }); - - it("preserves an escaped trailing bracket in the committed prefix", () => { - const r = new StreamingMarkdownRenderer(); - r.push("Telegram literal \\["); - - expect(r.getCommittedMarkdownPrefix()).toBe("Telegram literal \\["); - }); - - it("returns the committed prefix before an open code fence", () => { - const r = new StreamingMarkdownRenderer(); - r.push("Intro\n```ts\nconst x = 1;"); - - expect(r.getCommittedMarkdownPrefix()).toBe("Intro\n"); - }); - - it("returns the prefix before the last unmatched code fence", () => { - const r = new StreamingMarkdownRenderer(); - r.push("Intro\n```ts\nconst a = 1;\n```\nBetween\n```js\nconst b = 2;"); - - expect(r.getCommittedMarkdownPrefix()).toBe( - "Intro\n```ts\nconst a = 1;\n```\nBetween\n" - ); - }); - it("should handle table header without trailing newline (incomplete line)", () => { const r = new StreamingMarkdownRenderer(); r.push("Text\n\n| A | B |"); diff --git a/packages/chat/src/streaming-markdown.ts b/packages/chat/src/streaming-markdown.ts index d1b9415d9..3f6633ac5 100644 --- a/packages/chat/src/streaming-markdown.ts +++ b/packages/chat/src/streaming-markdown.ts @@ -145,27 +145,6 @@ export class StreamingMarkdownRenderer { return findCleanPrefix(wrapped); } - /** - * Get the longest source prefix that can be finalized as a standalone, - * markdown-safe message. - * - * Unlike `render()`, this never synthesizes missing closing markers. The - * returned string is always a prefix of the original accumulated source, - * which makes it safe to split a long stream into multiple persisted - * messages without duplicating or dropping source text. - */ - getCommittedMarkdownPrefix(): string { - let committable = this.accumulated; - - if (!this.finished) { - committable = this.isAccumulatedInsideFence() - ? getPrefixBeforeTrailingOpenFence(this.accumulated) - : getCommittablePrefix(this.accumulated); - } - - return trimTrailingUnmatchedMarkerOpeners(findCleanPrefix(committable)); - } - /** Raw accumulated text (no remend, no buffering). For the final edit. */ getText(): string { return this.accumulated; @@ -198,8 +177,7 @@ const INLINE_MARKER_CHARS = new Set(["*", "~", "`", "["]); * from otherwise clean text (which is harmless for streaming). */ function isClean(text: string): boolean { - const sanitized = sanitizeEscapedLinkOpeners(text); - return remend(sanitized).length <= sanitized.length; + return remend(text).length <= text.length; } /** @@ -218,12 +196,8 @@ function findCleanPrefix(text: string): string { for (let i = text.length - 1; i >= 0; i--) { if (INLINE_MARKER_CHARS.has(text[i])) { - if (isEscaped(text, i)) { - continue; - } - // Group consecutive same characters (e.g., ** or ~~) - while (i > 0 && text[i - 1] === text[i] && !isEscaped(text, i - 1)) { + while (i > 0 && text[i - 1] === text[i]) { i--; } const candidate = text.slice(0, i); @@ -323,119 +297,6 @@ function getCommittablePrefix(text: string): string { return result; } -function getPrefixBeforeTrailingOpenFence(text: string): string { - let offset = 0; - let insideFence = false; - let lastOpenOffset = -1; - - for (const line of text.split("\n")) { - const lineLengthWithNewline = offset + line.length < text.length ? 1 : 0; - const trimmed = line.trimStart(); - - if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { - if (insideFence) { - insideFence = false; - lastOpenOffset = -1; - } else { - insideFence = true; - lastOpenOffset = offset; - } - } - - offset += line.length + lineLengthWithNewline; - } - - return insideFence && lastOpenOffset >= 0 - ? text.slice(0, lastOpenOffset) - : text; -} - -function trimTrailingUnmatchedMarkerOpeners(text: string): string { - let result = text; - - while (true) { - if (result.endsWith("**") && hasOddUnescapedTokenCount(result, "**")) { - result = result.slice(0, -2); - continue; - } - - if (result.endsWith("~~") && hasOddUnescapedTokenCount(result, "~~")) { - result = result.slice(0, -2); - continue; - } - - if ( - result.endsWith("*") && - !result.endsWith("**") && - hasOddUnescapedCharCount(result, "*") - ) { - result = result.slice(0, -1); - continue; - } - - if (result.endsWith("`") && hasOddUnescapedCharCount(result, "`")) { - result = result.slice(0, -1); - continue; - } - - if (result.endsWith("[") && !isEscaped(result, result.length - 1)) { - result = result.slice(0, -1); - continue; - } - - return result; - } -} - -function hasOddUnescapedTokenCount(text: string, token: string): boolean { - let count = 0; - - for (let i = 0; i <= text.length - token.length; ) { - if (text.slice(i, i + token.length) === token && !isEscaped(text, i)) { - count++; - i += token.length; - continue; - } - - i++; - } - - return count % 2 === 1; -} - -function hasOddUnescapedCharCount(text: string, char: string): boolean { - let count = 0; - - for (let i = 0; i < text.length; i++) { - if (text[i] === char && !isEscaped(text, i)) { - count++; - } - } - - return count % 2 === 1; -} - -function isEscaped(text: string, index: number): boolean { - let backslashCount = 0; - - for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) { - backslashCount++; - } - - return backslashCount % 2 === 1; -} - -function sanitizeEscapedLinkOpeners(text: string): string { - let result = ""; - - for (let i = 0; i < text.length; i++) { - const char = text[i]; - result += char === "[" && isEscaped(text, i) ? "(" : char; - } - - return result; -} - /** * Wraps confirmed GFM table blocks in code fences for append-only streaming. * diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index 104184985..3a551e815 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -1,14 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { Card } from "./cards"; -import { MessageHistoryCache } from "./message-history"; +import { decodeCallbackValue } from "./callback-url"; +import { Actions, Button, Card } from "./cards"; +import type { Message } from "./message"; import { createMockAdapter, createMockState, createTestMessage, mockLogger, } from "./mock-adapter"; +import { Plan } from "./plan"; +import { StreamingPlan } from "./streaming-plan"; import { ThreadImpl } from "./thread"; -import type { Adapter, Message, ScheduledMessage, StreamChunk } from "./types"; +import type { Adapter, ScheduledMessage, StreamChunk } from "./types"; import { NotImplementedError } from "./types"; describe("ThreadImpl", () => { @@ -214,51 +217,6 @@ describe("ThreadImpl", () => { expect(mockAdapter.postMessage).not.toHaveBeenCalled(); }); - it("should append segmented native stream messages individually to history", async () => { - const messageHistory = new MessageHistoryCache(mockState); - thread = new ThreadImpl({ - id: "slack:C123:1234.5678", - adapter: mockAdapter, - channelId: "C123", - stateAdapter: mockState, - messageHistory, - }); - - mockAdapter.stream = vi.fn().mockResolvedValue({ - messages: [ - { - message: { - id: "msg-1", - threadId: "slack:C123:1234.5678", - raw: {}, - }, - postable: { markdown: "Hello " }, - }, - { - message: { - id: "msg-2", - threadId: "slack:C123:1234.5678", - raw: {}, - }, - postable: { markdown: "World" }, - }, - ], - }); - - const textStream = createTextStream(["Hello", " ", "World"]); - const result = await thread.post(textStream); - const stored = await messageHistory.getMessages("slack:C123:1234.5678"); - - expect(result.id).toBe("msg-2"); - expect(result.text).toBe("World"); - expect(result.segments?.map((segment) => segment.id)).toEqual([ - "msg-1", - "msg-2", - ]); - expect(stored.map((message) => message.id)).toEqual(["msg-1", "msg-2"]); - expect(stored.map((message) => message.text)).toEqual(["Hello", "World"]); - }); - it("should fall back when adapter.stream returns null", async () => { mockAdapter.stream = vi.fn().mockResolvedValue(null); @@ -387,12 +345,8 @@ describe("ThreadImpl", () => { "slack:C123:1234.5678", "..." ); - // Should edit with empty string wrapped as markdown (final content) - expect(mockAdapter.editMessage).toHaveBeenLastCalledWith( - "slack:C123:1234.5678", - "msg-1", - { markdown: "" } - ); + // Should not edit with empty content + expect(mockAdapter.editMessage).not.toHaveBeenCalled(); }); it("should support disabling the placeholder for fallback streaming", async () => { @@ -434,15 +388,81 @@ describe("ThreadImpl", () => { const textStream = createTextStream([]); await threadNoPlaceholder.post(textStream); - // Should still post a message (empty) even with no chunks, wrapped as markdown + // Should post a non-empty fallback since stream must return a SentMessage expect(mockAdapter.postMessage).toHaveBeenCalledWith( "slack:C123:1234.5678", - { markdown: "" } + { markdown: " " } ); - // No edit needed since post content matches accumulated expect(mockAdapter.editMessage).not.toHaveBeenCalled(); }); + it("should not post empty content when table is buffered with null placeholder", async () => { + mockAdapter.stream = undefined; + + const threadNoPlaceholder = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + fallbackStreamingPlaceholderText: null, + }); + + const textStream = createTextStream([ + "| A | B |\n", + "|---|---|\n", + "| 1 | 2 |\n", + ]); + await threadNoPlaceholder.post(textStream); + + const postCalls = (mockAdapter.postMessage as ReturnType) + .mock.calls; + for (const call of postCalls) { + const content = call[1]; + if (typeof content === "object" && "markdown" in content) { + expect(content.markdown.trim().length).toBeGreaterThan(0); + } + } + }); + + it("should not edit placeholder to empty during LLM warm-up", async () => { + mockAdapter.stream = undefined; + const editFn = mockAdapter.editMessage as ReturnType; + + const textStream = createTextStream(["Hello world"]); + await thread.post(textStream); + + for (const call of editFn.mock.calls) { + const content = call[2]; + if (typeof content === "object" && "markdown" in content) { + expect(content.markdown.trim().length).toBeGreaterThan(0); + } + } + }); + + it("should not post empty content during streaming with whitespace chunks", async () => { + mockAdapter.stream = undefined; + + const threadNoPlaceholder = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + fallbackStreamingPlaceholderText: null, + }); + + const textStream = createTextStream([" ", "\n", " \n"]); + await threadNoPlaceholder.post(textStream); + + const postCalls = (mockAdapter.postMessage as ReturnType) + .mock.calls; + for (const call of postCalls) { + const content = call[1]; + if (typeof content === "object" && "markdown" in content) { + expect(content.markdown.length).toBeGreaterThan(0); + } + } + }); + it("should preserve newlines in streamed text (native path)", async () => { let capturedChunks: string[] = []; const mockStream = vi @@ -599,7 +619,34 @@ describe("ThreadImpl", () => { } }); - it("should pass stream options from current message context", async () => { + it.each([ + { + expectedTeamId: "T123", + label: "team_id", + raw: { team_id: "T123", type: "app_mention" }, + }, + { + expectedTeamId: "T234", + label: "team string", + raw: { team: "T234", type: "message" }, + }, + { + expectedTeamId: "T345", + label: "team.id", + raw: { team: { id: "T345" }, type: "block_actions" }, + }, + { + expectedTeamId: "T456", + label: "user.team_id fallback", + raw: { + type: "block_actions", + user: { team_id: "T456" }, + }, + }, + ])("should pass stream options from Slack current message context via $label", async ({ + raw, + expectedTeamId, + }) => { const mockStream = vi.fn().mockResolvedValue({ id: "msg-stream", threadId: "t1", @@ -607,18 +654,13 @@ describe("ThreadImpl", () => { }); mockAdapter.stream = mockStream; - // Create thread with current message context const threadWithContext = new ThreadImpl({ id: "slack:C123:1234.5678", adapter: mockAdapter, channelId: "C123", stateAdapter: mockState, - currentMessage: { - id: "original-msg", - threadId: "slack:C123:1234.5678", - text: "test", - formatted: { type: "root", children: [] }, - raw: { team_id: "T123" }, + currentMessage: createTestMessage("original-msg", "test", { + raw, author: { userId: "U456", userName: "user", @@ -626,9 +668,7 @@ describe("ThreadImpl", () => { isBot: false, isMe: false, }, - metadata: { dateSent: new Date(), edited: false }, - attachments: [], - }, + }), }); const textStream = createTextStream(["Hello"]); @@ -639,10 +679,210 @@ describe("ThreadImpl", () => { expect.any(Object), expect.objectContaining({ recipientUserId: "U456", - recipientTeamId: "T123", + recipientTeamId: expectedTeamId, }) ); }); + + it("should forward structured stream chunks to adapter.stream from an action-created thread", async () => { + const mockStream = vi.fn().mockResolvedValue({ + id: "msg-stream", + threadId: "t1", + raw: "Hello", + }); + mockAdapter.stream = mockStream; + + const threadWithActionContext = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + currentMessage: createTestMessage("action-msg", "", { + raw: { + team: { domain: "workspace", id: "T123" }, + type: "block_actions", + }, + author: { + userId: "U456", + userName: "user", + fullName: "Test User", + isBot: false, + isMe: false, + }, + }), + }); + + const taskChunk: StreamChunk = { + id: "task-1", + status: "pending", + title: "Thinking", + type: "task_update", + }; + async function* structuredStream(): AsyncIterable { + yield "Picking option..."; + yield taskChunk; + } + + await threadWithActionContext.post( + structuredStream() as unknown as AsyncIterable + ); + + expect(mockStream).toHaveBeenCalledTimes(1); + const [, passedStream] = mockStream.mock.calls[0]; + const collected: Array = []; + for await (const chunk of passedStream as AsyncIterable< + string | StreamChunk + >) { + collected.push(chunk); + } + expect(collected).toContain("Picking option..."); + expect(collected).toContainEqual(taskChunk); + }); + + it("should pass StreamingPlan PostableObject options to adapter.stream", async () => { + const mockStream = vi.fn().mockResolvedValue({ + id: "msg-stream", + threadId: "t1", + raw: "Hello", + }); + mockAdapter.stream = mockStream; + + const textStream = createTextStream(["Hello"]); + const streamMsg = new StreamingPlan(textStream, { + groupTasks: "plan", + endWith: [{ type: "actions" }], + updateIntervalMs: 1000, + }); + await thread.post(streamMsg); + + expect(mockStream).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.any(Object), + expect.objectContaining({ + taskDisplayMode: "plan", + stopBlocks: [{ type: "actions" }], + updateIntervalMs: 1000, + }) + ); + }); + + it("should pass StreamingPlan with only groupTasks", async () => { + const mockStream = vi.fn().mockResolvedValue({ + id: "msg-stream", + threadId: "t1", + raw: "Hello", + }); + mockAdapter.stream = mockStream; + + const textStream = createTextStream(["Hello"]); + await thread.post( + new StreamingPlan(textStream, { groupTasks: "timeline" }) + ); + + expect(mockStream).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.any(Object), + expect.objectContaining({ + taskDisplayMode: "timeline", + }) + ); + const options = mockStream.mock.calls[0][2]; + expect(options.stopBlocks).toBeUndefined(); + }); + + it("should pass StreamingPlan with only endWith", async () => { + const mockStream = vi.fn().mockResolvedValue({ + id: "msg-stream", + threadId: "t1", + raw: "Hello", + }); + mockAdapter.stream = mockStream; + + const textStream = createTextStream(["Hello"]); + await thread.post( + new StreamingPlan(textStream, { endWith: [{ type: "actions" }] }) + ); + + expect(mockStream).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.any(Object), + expect.objectContaining({ + stopBlocks: [{ type: "actions" }], + }) + ); + const options = mockStream.mock.calls[0][2]; + expect(options.taskDisplayMode).toBeUndefined(); + }); + + it("should pass StreamingPlan with only updateIntervalMs", async () => { + const mockStream = vi.fn().mockResolvedValue({ + id: "msg-stream", + threadId: "t1", + raw: "Hello", + }); + mockAdapter.stream = mockStream; + + const textStream = createTextStream(["Hello"]); + await thread.post( + new StreamingPlan(textStream, { updateIntervalMs: 2000 }) + ); + + expect(mockStream).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.any(Object), + expect.objectContaining({ + updateIntervalMs: 2000, + }) + ); + const options = mockStream.mock.calls[0][2]; + expect(options.taskDisplayMode).toBeUndefined(); + expect(options.stopBlocks).toBeUndefined(); + }); + + it("should route StreamingPlan through fallback when adapter has no native streaming", async () => { + mockAdapter.stream = undefined; + + const textStream = createTextStream(["Hello", " ", "World"]); + await thread.post( + new StreamingPlan(textStream, { + groupTasks: "plan", + endWith: [{ type: "actions" }], + updateIntervalMs: 2000, + }) + ); + + // Should post initial placeholder and edit with final content + expect(mockAdapter.postMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "..." + ); + expect(mockAdapter.editMessage).toHaveBeenLastCalledWith( + "slack:C123:1234.5678", + "msg-1", + { markdown: "Hello World" } + ); + }); + + it("should still work without options (backward compat)", async () => { + const mockStream = vi.fn().mockResolvedValue({ + id: "msg-stream", + threadId: "t1", + raw: "Hello", + }); + mockAdapter.stream = mockStream; + + const textStream = createTextStream(["Hello"]); + await thread.post(textStream); + + expect(mockStream).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.any(Object), + expect.any(Object) + ); + const options = mockStream.mock.calls[0][2]; + expect(options.taskDisplayMode).toBeUndefined(); + expect(options.stopBlocks).toBeUndefined(); + }); }); describe("fallback streaming error logging", () => { @@ -716,6 +956,7 @@ describe("ThreadImpl", () => { type: "task_update" as const, id: "tool-1", title: "Running bash", + details: "Installing dependencies", status: "in_progress", }; yield "world"; @@ -723,6 +964,7 @@ describe("ThreadImpl", () => { type: "task_update" as const, id: "tool-1", title: "Running bash", + details: "Installed dependencies", status: "complete", output: "Done", }; @@ -737,11 +979,20 @@ describe("ThreadImpl", () => { expect(capturedChunks).toHaveLength(4); expect(capturedChunks[0]).toBe("Hello "); expect(capturedChunks[1]).toEqual( - expect.objectContaining({ type: "task_update", status: "in_progress" }) + expect.objectContaining({ + type: "task_update", + details: "Installing dependencies", + status: "in_progress", + }) ); expect(capturedChunks[2]).toBe("world"); expect(capturedChunks[3]).toEqual( - expect.objectContaining({ type: "task_update", status: "complete" }) + expect.objectContaining({ + type: "task_update", + details: "Installed dependencies", + output: "Done", + status: "complete", + }) ); // Accumulated text should only include strings, not task_update chunks @@ -790,6 +1041,7 @@ describe("ThreadImpl", () => { type: "task_update" as const, id: "tool-1", title: "Running bash", + details: "Installing dependencies", status: "in_progress", }; yield " World"; @@ -1349,6 +1601,423 @@ describe("ThreadImpl", () => { // AdapterPostableMessage | CardJSXElement which excludes AsyncIterable }); + describe("post with Plan", () => { + let thread: ThreadImpl; + let mockAdapter: Adapter; + let mockState: ReturnType; + + beforeEach(() => { + mockAdapter = createMockAdapter(); + mockState = createMockState(); + + thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + }); + }); + + it("should post fallback text when adapter does not support plans", async () => { + const plan = new Plan({ initialMessage: "Starting..." }); + await thread.post(plan); + + // Should have posted fallback text via postMessage + expect(mockAdapter.postMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + expect.stringContaining("Starting...") + ); + + expect(plan.title).toBe("Starting..."); + expect(plan.tasks).toHaveLength(1); + expect(plan.tasks[0].status).toBe("in_progress"); + expect(plan.id).toBe("msg-1"); + }); + + it("should update via editMessage in fallback mode", async () => { + const plan = new Plan({ initialMessage: "Starting..." }); + await thread.post(plan); + + const task = await plan.addTask({ title: "Task 1" }); + expect(task).not.toBeNull(); + expect(task?.title).toBe("Task 1"); + + // Should edit the message with updated fallback text + expect(mockAdapter.editMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "msg-1", + expect.stringContaining("Task 1") + ); + }); + + it("should complete plan via editMessage in fallback mode", async () => { + const plan = new Plan({ initialMessage: "Starting..." }); + await thread.post(plan); + + await plan.addTask({ title: "Step 1" }); + await plan.complete({ completeMessage: "All done!" }); + + expect(plan.title).toBe("All done!"); + for (const task of plan.tasks) { + expect(task.status).toBe("complete"); + } + + // Last editMessage call should contain completed status icons + const lastCall = ( + mockAdapter.editMessage as ReturnType + ).mock.calls.at(-1); + expect(lastCall?.[2]).toContain("✅"); + }); + + it("should call adapter postObject when supported", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Working..." }); + await thread.post(plan); + + expect(mockPostObject).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "plan", + expect.objectContaining({ + title: "Working...", + tasks: expect.arrayContaining([ + expect.objectContaining({ + title: "Working...", + status: "in_progress", + }), + ]), + }) + ); + expect(plan.id).toBe("plan-msg-1"); + }); + + it("should add tasks and call editObject", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Starting" }); + await thread.post(plan); + const task = await plan.addTask({ + title: "Fetch data", + children: ["Call API", "Parse response"], + }); + + expect(task).not.toBeNull(); + expect(task?.title).toBe("Fetch data"); + expect(task?.status).toBe("in_progress"); + expect(mockEditObject).toHaveBeenCalled(); + + // Plan title should be updated to current task + expect(plan.title).toBe("Fetch data"); + expect(plan.tasks).toHaveLength(2); + }); + + it("should update current task with output", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Working" }); + await thread.post(plan); + await plan.addTask({ title: "Step 1" }); + const updated = await plan.updateTask("Got result: 42"); + + expect(updated).not.toBeNull(); + expect(mockEditObject).toHaveBeenCalled(); + }); + + it("should update a specific task by ID", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + const task1 = await plan.addTask({ title: "Step 1" }); + const task2 = await plan.addTask({ title: "Step 2" }); + + const updated = await plan.updateTask({ + id: task1?.id, + output: "Step 1 result", + status: "complete", + }); + + expect(updated).not.toBeNull(); + expect(updated?.id).toBe(task1?.id); + expect(updated?.status).toBe("complete"); + + const step2 = plan.tasks.find((t) => t.id === task2?.id); + expect(step2?.status).toBe("in_progress"); + }); + + it("should return null when updating by non-existent ID", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + await plan.addTask({ title: "Step 1" }); + + const updated = await plan.updateTask({ + id: "non-existent-id", + output: "nope", + }); + + expect(updated).toBeNull(); + }); + + it("should still update last in_progress task when no ID provided", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + await plan.addTask({ title: "Step 1" }); + await plan.addTask({ title: "Step 2" }); + + const updated = await plan.updateTask("Some output"); + + expect(updated).not.toBeNull(); + expect(updated?.title).toBe("Step 2"); + }); + + it("should complete plan and mark tasks done", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Starting" }); + await thread.post(plan); + await plan.addTask({ title: "Task 1" }); + await plan.complete({ completeMessage: "All done!" }); + + expect(plan.title).toBe("All done!"); + // All tasks should be completed + for (const task of plan.tasks) { + expect(task.status).toBe("complete"); + } + }); + + it("should reset plan and start fresh", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "First run" }); + await thread.post(plan); + await plan.addTask({ title: "Task A" }); + await plan.addTask({ title: "Task B" }); + + expect(plan.tasks).toHaveLength(3); + + const newTask = await plan.reset({ initialMessage: "Second run" }); + expect(newTask).not.toBeNull(); + expect(plan.title).toBe("Second run"); + expect(plan.tasks).toHaveLength(1); + expect(plan.tasks[0].status).toBe("in_progress"); + }); + + it("should return currentTask correctly", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + + // Initially, current task is the first one + let current = plan.currentTask; + expect(current?.title).toBe("Start"); + expect(current?.status).toBe("in_progress"); + + // After adding a new task, current should be the new one + await plan.addTask({ title: "Step 2" }); + current = plan.currentTask; + expect(current?.title).toBe("Step 2"); + expect(current?.status).toBe("in_progress"); + + // After completion, currentTask returns the last task + await plan.complete({ completeMessage: "Done" }); + current = plan.currentTask; + expect(current?.title).toBe("Step 2"); + expect(current?.status).toBe("complete"); + }); + + it("should handle various PlanContent formats in initialMessage", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + // String + let plan = new Plan({ initialMessage: "Simple string" }); + await thread.post(plan); + expect(plan.title).toBe("Simple string"); + + // Array of strings + plan = new Plan({ initialMessage: ["Line 1", "Line 2"] }); + await thread.post(plan); + expect(plan.title).toBe("Line 1 Line 2"); + + // Empty string defaults to "Plan" + plan = new Plan({ initialMessage: "" }); + await thread.post(plan); + expect(plan.title).toBe("Plan"); + }); + + it("should ensure sequential edits via queue", async () => { + const editOrder: number[] = []; + let editCount = 0; + + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockImplementation(async () => { + const myOrder = ++editCount; + // Simulate varying async delays + await new Promise((r) => setTimeout(r, Math.random() * 10)); + editOrder.push(myOrder); + }); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + + // Fire off multiple updates concurrently + await Promise.all([ + plan.addTask({ title: "Task 1" }), + plan.updateTask("Output 1"), + plan.addTask({ title: "Task 2" }), + ]); + + // Despite random delays, edits should complete in order + expect(editOrder).toEqual([1, 2, 3]); + }); + + it("should return null when calling addTask before post", async () => { + const plan = new Plan({ initialMessage: "Not posted yet" }); + const task = await plan.addTask({ title: "Task 1" }); + expect(task).toBeNull(); + }); + + it("should return null when calling updateTask before post", async () => { + const plan = new Plan({ initialMessage: "Not posted yet" }); + const updated = await plan.updateTask("some output"); + expect(updated).toBeNull(); + }); + + it("should return null when calling complete before post", async () => { + const plan = new Plan({ initialMessage: "Not posted yet" }); + await plan.complete({ completeMessage: "Done" }); + expect(plan.tasks[0].status).toBe("in_progress"); + }); + + it("should propagate editObject errors from addTask", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi + .fn() + .mockRejectedValue(new Error("rate limited")); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + + await expect(plan.addTask({ title: "Task 1" })).rejects.toThrow( + "rate limited" + ); + expect(plan.tasks).toHaveLength(2); + }); + + it("should continue accepting edits after a failed edit", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi + .fn() + .mockRejectedValueOnce(new Error("rate limited")) + .mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + + await expect(plan.addTask({ title: "Task 1" })).rejects.toThrow(); + await plan.addTask({ title: "Task 2" }); + expect(plan.tasks).toHaveLength(3); + expect(mockEditObject).toHaveBeenCalledTimes(2); + }); + + it("should set error status via updateTask", async () => { + const mockPostObject = vi.fn().mockResolvedValue({ + id: "plan-msg-1", + threadId: "slack:C123:1234.5678", + }); + const mockEditObject = vi.fn().mockResolvedValue(undefined); + mockAdapter.postObject = mockPostObject; + mockAdapter.editObject = mockEditObject; + + const plan = new Plan({ initialMessage: "Start" }); + await thread.post(plan); + await plan.addTask({ title: "Risky step" }); + await plan.updateTask({ status: "error", output: "Something failed" }); + + const current = plan.currentTask; + expect(current?.status).toBe("error"); + }); + }); + describe("subscribe and unsubscribe", () => { let thread: ThreadImpl; let mockAdapter: Adapter; @@ -2299,4 +2968,312 @@ describe("ThreadImpl", () => { expect(cancel2).not.toHaveBeenCalled(); }); }); + + describe("getParticipants", () => { + it("should return unique non-bot authors from messages", async () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + const msg1 = createTestMessage("1", "Hello", { + author: { + userId: "U1", + userName: "alice", + fullName: "Alice", + isBot: false, + isMe: false, + }, + }); + const msg2 = createTestMessage("2", "Hi", { + author: { + userId: "U2", + userName: "bob", + fullName: "Bob", + isBot: false, + isMe: false, + }, + }); + const msg3 = createTestMessage("3", "Hello again", { + author: { + userId: "U1", + userName: "alice", + fullName: "Alice", + isBot: false, + isMe: false, + }, + }); + + mockAdapter.fetchMessages = vi + .fn() + .mockResolvedValue({ messages: [msg1, msg2, msg3], nextCursor: null }); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + }); + + const participants = await thread.getParticipants(); + expect(participants).toHaveLength(2); + expect(participants.map((p) => p.userId)).toEqual( + expect.arrayContaining(["U1", "U2"]) + ); + }); + + it("should exclude bot messages", async () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + const humanMsg = createTestMessage("1", "Hello", { + author: { + userId: "U1", + userName: "alice", + fullName: "Alice", + isBot: false, + isMe: false, + }, + }); + const selfBotMsg = createTestMessage("2", "Hi there!", { + author: { + userId: "B1", + userName: "bot", + fullName: "Bot", + isBot: true, + isMe: true, + }, + }); + const thirdPartyBotMsg = createTestMessage("3", "Notification", { + author: { + userId: "B2", + userName: "jira-bot", + fullName: "Jira Bot", + isBot: true, + isMe: false, + }, + }); + + mockAdapter.fetchMessages = vi.fn().mockResolvedValue({ + messages: [humanMsg, selfBotMsg, thirdPartyBotMsg], + nextCursor: null, + }); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + }); + + const participants = await thread.getParticipants(); + expect(participants).toHaveLength(1); + expect(participants[0].userId).toBe("U1"); + }); + + it("should return empty array for thread with only bot messages", async () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + mockAdapter.fetchMessages = vi.fn().mockResolvedValue({ + messages: [ + createTestMessage("1", "Bot message", { + author: { + userId: "B1", + userName: "bot", + fullName: "Bot", + isBot: true, + isMe: true, + }, + }), + ], + nextCursor: null, + }); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + }); + + const participants = await thread.getParticipants(); + expect(participants).toHaveLength(0); + }); + + it("should include currentMessage author", async () => { + const mockAdapter = createMockAdapter(); + const mockState = createMockState(); + + const currentMsg = createTestMessage("1", "Hey bot", { + author: { + userId: "U1", + userName: "alice", + fullName: "Alice", + isBot: false, + isMe: false, + }, + }); + + mockAdapter.fetchMessages = vi + .fn() + .mockResolvedValue({ messages: [], nextCursor: null }); + + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + currentMessage: currentMsg, + }); + + const participants = await thread.getParticipants(); + expect(participants).toHaveLength(1); + expect(participants[0].userId).toBe("U1"); + }); + }); + + describe("callbackUrl processing", () => { + let thread: ThreadImpl; + let mockAdapter: Adapter; + let mockState: ReturnType; + + function makeCardWithCallback(callbackUrl = "https://example.com/hook") { + return Card({ + title: "Test", + children: [ + Actions([Button({ id: "approve", label: "Approve", callbackUrl })]), + ], + }); + } + + beforeEach(() => { + mockAdapter = createMockAdapter(); + mockState = createMockState(); + thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter: mockAdapter, + channelId: "C123", + stateAdapter: mockState, + }); + }); + + it("should encode callbackUrl when posting a card", async () => { + await thread.post(makeCardWithCallback("https://example.com/post-hook")); + + const postedCard = (mockAdapter.postMessage as ReturnType) + .mock.calls[0][1]; + const button = postedCard.children[0].children[0]; + + const { callbackToken } = decodeCallbackValue(button.value); + expect(callbackToken).toBeDefined(); + expect(button.callbackUrl).toBeUndefined(); + + const stored = await mockState.get<{ url: string }>( + `chat:callback:${callbackToken}` + ); + expect(stored?.url).toBe("https://example.com/post-hook"); + }); + + it("should encode callbackUrl when posting via postEphemeral with native support", async () => { + const mockPostEphemeral = vi.fn().mockResolvedValue({ + id: "ephemeral-1", + threadId: "slack:C123:1234.5678", + usedFallback: false, + raw: {}, + }); + mockAdapter.postEphemeral = mockPostEphemeral; + + await thread.postEphemeral( + "U456", + makeCardWithCallback("https://example.com/eph"), + { fallbackToDM: false } + ); + + const sentCard = mockPostEphemeral.mock.calls[0][2]; + const button = sentCard.children[0].children[0]; + const { callbackToken } = decodeCallbackValue(button.value); + + expect(callbackToken).toBeDefined(); + const stored = await mockState.get<{ url: string }>( + `chat:callback:${callbackToken}` + ); + expect(stored?.url).toBe("https://example.com/eph"); + }); + + it("should encode callbackUrl when scheduling a card", async () => { + const futureDate = new Date("2030-01-01T00:00:00Z"); + mockAdapter.scheduleMessage = vi.fn().mockResolvedValue({ + scheduledMessageId: "Q1", + channelId: "C123", + postAt: futureDate, + raw: {}, + cancel: vi.fn().mockResolvedValue(undefined), + }); + + await thread.schedule(makeCardWithCallback("https://example.com/sched"), { + postAt: futureDate, + }); + + const sentCard = (mockAdapter.scheduleMessage as ReturnType) + .mock.calls[0][1]; + const button = sentCard.children[0].children[0]; + const { callbackToken } = decodeCallbackValue(button.value); + + expect(callbackToken).toBeDefined(); + const stored = await mockState.get<{ url: string }>( + `chat:callback:${callbackToken}` + ); + expect(stored?.url).toBe("https://example.com/sched"); + }); + + it("should encode callbackUrl when editing a sent message with a card", async () => { + const sent = await thread.post("Hello"); + + await sent.edit(makeCardWithCallback("https://example.com/edit")); + + const editArgs = (mockAdapter.editMessage as ReturnType) + .mock.calls[0]; + const editedCard = editArgs[2]; + const button = editedCard.children[0].children[0]; + const { callbackToken } = decodeCallbackValue(button.value); + + expect(callbackToken).toBeDefined(); + const stored = await mockState.get<{ url: string }>( + `chat:callback:${callbackToken}` + ); + expect(stored?.url).toBe("https://example.com/edit"); + }); + + it("should pass plain string posts through unchanged", async () => { + await thread.post("Just text"); + + expect(mockAdapter.postMessage).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "Just text" + ); + expect(mockState.set).not.toHaveBeenCalledWith( + expect.stringContaining("chat:callback:"), + expect.anything(), + expect.anything() + ); + }); + + it("should leave cards without callback buttons untouched", async () => { + const card = Card({ + title: "Plain", + children: [Actions([Button({ id: "ok", label: "OK", value: "keep" })])], + }); + await thread.post(card); + + const postedCard = (mockAdapter.postMessage as ReturnType) + .mock.calls[0][1]; + // No state writes for callback storage + const setCalls = (mockState.set as ReturnType).mock.calls; + expect( + setCalls.some( + ([key]) => typeof key === "string" && key.startsWith("chat:callback:") + ) + ).toBe(false); + expect(postedCard.children[0].children[0].value).toBe("keep"); + }); + }); }); diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 136482763..8dd2772b8 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -1,5 +1,6 @@ import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; import type { Root } from "mdast"; +import { processCardCallbackUrls } from "./callback-url"; import { cardToFallbackText } from "./cards"; import { ChannelImpl, deriveChannelId } from "./channel"; import { getChatSingleton } from "./chat-singleton"; @@ -14,8 +15,9 @@ import { toPlainText, } from "./markdown"; import { Message, type SerializedMessage } from "./message"; -import type { MessageHistoryCache } from "./message-history"; +import { isPostableObject, postPostableObject } from "./postable-object"; import { StreamingMarkdownRenderer } from "./streaming-markdown"; +import type { ThreadHistoryCache } from "./thread-history"; import type { Adapter, AdapterPostableMessage, @@ -25,15 +27,14 @@ import type { ChannelVisibility, EphemeralMessage, PostableMessage, + PostableObject, PostEphemeralOptions, - RawMessage, ScheduledMessage, SentMessage, StateAdapter, StreamChunk, StreamEvent, StreamOptions, - StreamResult, Thread, } from "./types"; import { NotImplementedError, THREAD_STATE_TTL_MS } from "./types"; @@ -65,9 +66,9 @@ interface ThreadImplConfigWithAdapter { isDM?: boolean; isSubscribedContext?: boolean; logger?: Logger; - messageHistory?: MessageHistoryCache; stateAdapter: StateAdapter; streamingUpdateIntervalMs?: number; + threadHistory?: ThreadHistoryCache; } /** @@ -110,12 +111,6 @@ function isAsyncIterable( ); } -function isStreamResult( - value: RawMessage | StreamResult | null -): value is StreamResult { - return value !== null && typeof value === "object" && "messages" in value; -} - export class ThreadImpl> implements Thread { @@ -140,8 +135,8 @@ export class ThreadImpl> private readonly _fallbackStreamingPlaceholderText: string | null; /** Cached channel instance */ private _channel?: Channel; - /** Message history cache (set only for adapters with persistMessageHistory) */ - private readonly _messageHistory?: MessageHistoryCache; + /** Thread history cache (set only for adapters with persistThreadHistory) */ + private readonly _threadHistory?: ThreadHistoryCache; private readonly _logger?: Logger; constructor(config: ThreadImplConfig) { @@ -165,7 +160,7 @@ export class ThreadImpl> // Direct mode - store adapter and state instances this._adapter = config.adapter; this._stateAdapterInstance = config.stateAdapter; - this._messageHistory = config.messageHistory; + this._threadHistory = config.threadHistory; } if (config.initialMessage) { @@ -267,7 +262,7 @@ export class ThreadImpl> stateAdapter: this._stateAdapter, isDM: this.isDM, channelVisibility: this.channelVisibility, - messageHistory: this._messageHistory, + threadHistory: this._threadHistory, }); } return this._channel; @@ -280,7 +275,7 @@ export class ThreadImpl> get messages(): AsyncIterable { const adapter = this.adapter; const threadId = this.id; - const messageHistory = this._messageHistory; + const threadHistory = this._threadHistory; return { async *[Symbol.asyncIterator]() { @@ -309,8 +304,8 @@ export class ThreadImpl> } // Fall back to cached history if adapter returned nothing - if (!yieldedAny && messageHistory) { - const cached = await messageHistory.getMessages(threadId); + if (!yieldedAny && threadHistory) { + const cached = await threadHistory.getMessages(threadId); // Yield newest first for (let i = cached.length - 1; i >= 0; i--) { yield cached[i]; @@ -323,7 +318,7 @@ export class ThreadImpl> get allMessages(): AsyncIterable { const adapter = this.adapter; const threadId = this.id; - const messageHistory = this._messageHistory; + const threadHistory = this._threadHistory; return { async *[Symbol.asyncIterator]() { @@ -352,8 +347,8 @@ export class ThreadImpl> } // Fall back to cached history if adapter returned nothing - if (!yieldedAny && messageHistory) { - const cached = await messageHistory.getMessages(threadId); + if (!yieldedAny && threadHistory) { + const cached = await threadHistory.getMessages(threadId); for (const message of cached) { yield message; } @@ -362,6 +357,33 @@ export class ThreadImpl> }; } + async getParticipants(): Promise { + const seen = new Map(); + + // Include the current message author if available + if ( + this._currentMessage && + !this._currentMessage.author.isMe && + !this._currentMessage.author.isBot + ) { + seen.set(this._currentMessage.author.userId, this._currentMessage.author); + } + + // Scan all messages for unique human authors + for await (const message of this.allMessages) { + if ( + message.author.isMe || + message.author.isBot || + seen.has(message.author.userId) + ) { + continue; + } + seen.set(message.author.userId, message.author); + } + + return [...seen.values()]; + } + async isSubscribed(): Promise { // Short-circuit if we know we're in a subscribed context if (this._isSubscribedContext) { @@ -382,9 +404,44 @@ export class ThreadImpl> await this._stateAdapter.unsubscribe(this.id); } + async post(message: T): Promise; + async post( + message: + | string + | AdapterPostableMessage + | AsyncIterable + | ChatElement + ): Promise; async post( message: string | PostableMessage | ChatElement - ): Promise { + ): Promise { + if (isPostableObject(message)) { + // StreamingPlan PostableObject - route to streaming with options + if (message.kind === "stream") { + const data = message.getPostData() as { + stream: AsyncIterable; + options: { + groupTasks?: "plan" | "timeline"; + endWith?: unknown[]; + updateIntervalMs?: number; + }; + }; + const streamOptions: StreamOptions = { + ...(data.options.updateIntervalMs + ? { updateIntervalMs: data.options.updateIntervalMs } + : {}), + ...(data.options.groupTasks + ? { taskDisplayMode: data.options.groupTasks } + : {}), + ...(data.options.endWith ? { stopBlocks: data.options.endWith } : {}), + }; + await this.handleStream(data.stream, streamOptions); + return message; + } + await this.handlePostableObject(message); + return message; + } + // Handle AsyncIterable (streaming) if (isAsyncIterable(message)) { return this.handleStream(message); @@ -403,6 +460,8 @@ export class ThreadImpl> postable = card; } + postable = await this.processCallbackUrls(postable); + const rawMessage = await this.adapter.postMessage(this.id, postable); // Create a SentMessage with edit/delete capabilities @@ -413,13 +472,23 @@ export class ThreadImpl> ); // Cache sent message for adapters with persistent history - if (this._messageHistory) { - await this._messageHistory.append(this.id, new Message(result)); + if (this._threadHistory) { + await this._threadHistory.append(this.id, new Message(result)); } return result; } + private async handlePostableObject(obj: PostableObject): Promise { + await postPostableObject( + obj, + this.adapter, + this.id, + (threadId, message) => this.adapter.postMessage(threadId, message), + this._logger + ); + } + async postEphemeral( user: string | Author, message: AdapterPostableMessage | ChatElement, @@ -441,6 +510,8 @@ export class ThreadImpl> postable = message as AdapterPostableMessage; } + postable = await this.processCallbackUrls(postable); + // Try native ephemeral if adapter supports it if (this.adapter.postEphemeral) { return this.adapter.postEphemeral(this.id, userId, postable); @@ -467,6 +538,30 @@ export class ThreadImpl> return null; } + private async processCallbackUrls( + postable: string | AdapterPostableMessage + ): Promise { + if (typeof postable === "string") { + return postable; + } + + if ("type" in postable && postable.type === "card") { + return processCardCallbackUrls(postable, this._stateAdapter); + } + + if ("card" in postable && postable.card?.type === "card") { + const processed = await processCardCallbackUrls( + postable.card, + this._stateAdapter + ); + if (processed !== postable.card) { + return { ...postable, card: processed }; + } + } + + return postable; + } + async schedule( message: AdapterPostableMessage | ChatElement, options: { postAt: Date } @@ -483,6 +578,10 @@ export class ThreadImpl> postable = message as AdapterPostableMessage; } + postable = (await this.processCallbackUrls( + postable + )) as AdapterPostableMessage; + if (!this.adapter.scheduleMessage) { throw new NotImplementedError( "Scheduled messages are not supported by this adapter", @@ -496,28 +595,30 @@ export class ThreadImpl> /** * Handle streaming from an AsyncIterable. * Normalizes the stream (supports both textStream and fullStream from AI SDK), - * then uses adapter's native streaming if available, otherwise falls back to post+edit. + * then uses the adapter's stream implementation if available, otherwise falls back to post+edit. */ private async handleStream( - rawStream: AsyncIterable + rawStream: AsyncIterable, + callerOptions?: StreamOptions ): Promise { // Normalize: handles plain strings, AI SDK fullStream events, and StreamChunk objects const textStream = fromFullStream(rawStream); - // Build streaming options from current message context + // Build streaming options from current message context + caller options const options: StreamOptions = { updateIntervalMs: this._streamingUpdateIntervalMs, + ...callerOptions, }; if (this._currentMessage) { options.recipientUserId = this._currentMessage.author.userId; - // Extract teamId from raw Slack payload - const raw = this._currentMessage.raw as { - team_id?: string; - team?: string; - }; - options.recipientTeamId = raw?.team_id ?? raw?.team; + // recipientTeamId is only consumed by the Slack adapter; other adapters + // ignore it. Derivation is Slack-specific because `currentMessage.raw` + // shape varies across Slack webhook types (message events vs block_actions). + options.recipientTeamId = this.extractSlackRecipientTeamId( + this._currentMessage.raw + ); } - // Use native streaming if adapter supports it + // Use adapter-provided streaming if available. if (this.adapter.stream) { // Wrap stream to collect accumulated text while passing through to adapter. // StreamChunk objects are passed through; only plain strings are accumulated. @@ -545,38 +646,14 @@ export class ThreadImpl> const raw = await this.adapter.stream(this.id, wrappedStream, options); if (raw) { - if (isStreamResult(raw)) { - const sentSegments = raw.messages.map((segment) => - this.createSentMessage( - segment.message.id, - segment.postable, - segment.message.threadId - ) - ); - - if (this._messageHistory) { - for (const segment of sentSegments) { - await this._messageHistory.append(this.id, new Message(segment)); - } - } - - const lastSent = sentSegments.at(-1); - if (!lastSent) { - throw new Error("Segmented stream completed without messages"); - } - - lastSent.segments = sentSegments; - return lastSent; - } - const sent = this.createSentMessage( raw.id, { markdown: accumulated }, raw.threadId ); - if (this._messageHistory) { - await this._messageHistory.append(this.id, new Message(sent)); + if (this._threadHistory) { + await this._threadHistory.append(this.id, new Message(sent)); } return sent; @@ -611,6 +688,47 @@ export class ThreadImpl> return this.fallbackStream(textOnlyStream, options); } + /** + * Slack payloads carry the workspace ID in a few different shapes depending on + * the webhook type: + * - Message events: `team_id` or `team` as a string + * - `block_actions` payloads: `team.id` (object), with `user.team_id` as a fallback + */ + private extractSlackRecipientTeamId(raw: unknown): string | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + + const payload = raw as { + team?: { id?: unknown } | string; + team_id?: unknown; + user?: { team_id?: unknown }; + }; + + if (typeof payload.team_id === "string" && payload.team_id) { + return payload.team_id; + } + + if (typeof payload.team === "string" && payload.team) { + return payload.team; + } + + if ( + payload.team && + typeof payload.team === "object" && + typeof payload.team.id === "string" && + payload.team.id + ) { + return payload.team.id; + } + + if (typeof payload.user?.team_id === "string" && payload.user.team_id) { + return payload.user.team_id; + } + + return undefined; + } + async startTyping(status?: string): Promise { await this.adapter.startTyping(this.id, status); } @@ -656,7 +774,7 @@ export class ThreadImpl> } const content = renderer.render(); - if (content !== lastEditContent) { + if (content.trim() && content !== lastEditContent) { try { await this.adapter.editMessage(threadIdForEdits, msg.id, { markdown: content, @@ -682,12 +800,14 @@ export class ThreadImpl> renderer.push(chunk); if (!msg) { const content = renderer.render(); - msg = await this.adapter.postMessage(this.id, { - markdown: content, - }); - threadIdForEdits = msg.threadId || this.id; - lastEditContent = content; - scheduleNextEdit(); + if (content.trim()) { + msg = await this.adapter.postMessage(this.id, { + markdown: content, + }); + threadIdForEdits = msg.threadId || this.id; + lastEditContent = content; + scheduleNextEdit(); + } } } } finally { @@ -708,13 +828,13 @@ export class ThreadImpl> if (!msg) { msg = await this.adapter.postMessage(this.id, { - markdown: accumulated, + markdown: accumulated.trim() ? accumulated : " ", }); threadIdForEdits = msg.threadId || this.id; lastEditContent = accumulated; } - if (finalContent !== lastEditContent) { + if (finalContent.trim() && finalContent !== lastEditContent) { await this.adapter.editMessage(threadIdForEdits, msg.id, { markdown: accumulated, }); @@ -726,8 +846,8 @@ export class ThreadImpl> threadIdForEdits ); - if (this._messageHistory) { - await this._messageHistory.append(this.id, new Message(sent)); + if (this._threadHistory) { + await this._threadHistory.append(this.id, new Message(sent)); } return sent; @@ -737,12 +857,9 @@ export class ThreadImpl> const result = await this.adapter.fetchMessages(this.id, { limit: 50 }); if (result.messages.length > 0) { this._recentMessages = result.messages; - } else if (this._messageHistory) { + } else if (this._threadHistory) { // Fall back to cached history for adapters without native message APIs - this._recentMessages = await this._messageHistory.getMessages( - this.id, - 50 - ); + this._recentMessages = await this._threadHistory.getMessages(this.id, 50); } else { this._recentMessages = []; } @@ -773,7 +890,7 @@ export class ThreadImpl> channelVisibility: this.channelVisibility, currentMessage: this._currentMessage?.toJSON(), isDM: this.isDM, - adapterName: this.adapter.name, + adapterName: this._adapterName ?? this.adapter.name, }; } @@ -869,8 +986,6 @@ export class ThreadImpl> async edit( newContent: string | PostableMessage | ChatElement ): Promise { - // Auto-convert JSX elements to CardElement - // edit doesn't support streaming, so use AdapterPostableMessage let postable: string | AdapterPostableMessage = newContent as | string | AdapterPostableMessage; @@ -881,6 +996,7 @@ export class ThreadImpl> } postable = card; } + postable = await self.processCallbackUrls(postable); await adapter.editMessage(threadId, messageId, postable); return self.createSentMessage(messageId, postable); }, @@ -936,6 +1052,7 @@ export class ThreadImpl> } postable = card; } + postable = await self.processCallbackUrls(postable); await adapter.editMessage(threadId, messageId, postable); return self.createSentMessage(messageId, postable, threadId); }, diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index d0cdaedb9..87ff780d2 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -528,7 +528,7 @@ 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. * @@ -539,14 +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, a segmented stream result, - * or `null` to use core fallback + * @returns The raw message after streaming completes, or `null` to use core fallback */ stream?( threadId: string, textStream: AsyncIterable, options?: StreamOptions - ): Promise | StreamResult | null>; + ): Promise | null>; /** Bot username (can override global userName) */ readonly userName: string; } @@ -1156,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 @@ -1423,26 +1422,6 @@ export interface RawMessage { threadId: string; } -/** - * One persisted message emitted by a native streaming adapter. - * - * Adapters can return multiple of these when a single logical stream must be - * split into more than one platform message (for example, Telegram's 4096-char - * message limit in DM draft streaming). - */ -export interface StreamSegment { - message: RawMessage; - postable: AdapterPostableMessage; -} - -/** - * Result of a native streaming operation that finalized into multiple - * persisted platform messages. - */ -export interface StreamResult { - messages: StreamSegment[]; -} - export interface Author { /** Display name */ fullName: string; @@ -1497,12 +1476,6 @@ export interface SentMessage ): Promise>; /** Remove a reaction from this message */ removeReaction(emoji: EmojiValue | string): Promise; - /** - * Actual persisted messages emitted by a segmented native stream, in - * chronological order. Present only when a single `thread.post(stream)` - * finalized into multiple platform messages. - */ - segments?: SentMessage[]; } // =============================================================================