apache / apache/maka

Bot bridge: streaming reply output for Telegram / Feishu / Lark / Slack (draft messages, streaming cards, native text streams)

Open
#2,504 2 comments 0 reactions 0 assignees View on GitHub
enhancement help wanted stale
Dominant language
TypeScript
Stars
5.4k
Forks
502
Avg merge
1d 2h
Merged PRs (30d)
715

Description

# Bot bridge: streaming reply output for Telegram / Feishu / Lark / Slack (draft messages, streaming cards, native text streams)

## Problem

IM users of the bot bridge wait for long agent runs with **zero feedback**: a request goes in, and the reply lands as one wall of text seconds or minutes later. The bridge today is a dumb pipe at the *output* side too — not just at session management (see #2325):

- **`bot-incoming-main.ts`** waits for the whole turn: `reply = botReply(await turn)` then one `sendMessage` call. There is no incremental path at all.
- **Telegram** (`simple-bridge.ts`): no `editMessageText` anywhere in the codebase (0 hits in code search). The only affordances are `sendChatAction(typing)` (refreshed every 4s) and UTF-16 chunked send of the *final* reply (4000 code units). A 3-minute generation shows only a "typing…" bubble, then everything at once.
- **Feishu / Lark** (`feishu-bridge.ts`): `channel.send(chatId, { markdown })` only — no interactive card, no card update API, no typing. The user watches a static chat for the whole generation.
- **Slack** (`slack-bridge.ts`, 160 lines, the thinnest bridge): `web.chat.postMessage` only — no `chat.update`, no typing (Slack has no bot typing API anyway). Long replies land as a single unbreakable block.

This is not "nice to have" polish: **all four platforms now ship official, LLM-first streaming APIs**, and every competing assistant bot (OpenClaw's agent activity, Hermes' streaming gateway, Claude's message drafts) surfaces partial output while generating. Maka is the odd one out — an AI agent that feels non-AI over IM.

## Desired outcome

Agent replies appear **progressively in place** on Telegram, Feishu, Lark, and Slack while the turn is still generating, using each platform's official streaming surface, with a single platform-agnostic streaming contract in the bot layer:

- **Telegram (P0)** — stream via `sendRichMessageDraft` (same `draft_id` per turn → animated updates, 30s ephemeral preview), finalize with `sendRichMessage`. Fallback: `sendMessage` + `editMessageText` loop for older Bot API / clients. Keep the existing typing indicator.
- **Feishu / Lark (P0)** — send a streaming-mode card (Card JSON 2.0, `config.streaming_mode: true`, `streaming_config` with `print_frequency_ms` / `print_strategy`), then stream text per chunk via `PUT /open-apis/cardkit/v1/cards/:card_id/elements/:element_id/content` (CardKit API, permission `cardkit:card:write`). Shared CardKit logic covers both `open.feishu.cn` and `open.larksuite.com`.
- **Slack (P0)** — native `chat.startStream` → `chat.appendStream` per chunk → `chat.stopStream`; optionally use `task_display_mode` + `chunks` to render the agent's plan/tasks while streaming. Fallback: `chat.postMessage` + `chat.update`.
- **Unified contract (P0)** — extend `BotBridge` / `SendCapable` in `packages/runtime/src/bots/types.ts` with optional `startStream(chatId, options)` → `{ append(chunk), finish(finalText), abort() }`. `bot-incoming-main.ts` prefers the stream path and falls back to the current one-shot send when a channel doesn't support it (e.g. WeChat iLink has no server push).
- **Reliability (P1)** — throttle appends (≤ ~20/s, mirroring Feishu's 70ms print frequency), reuse the existing Telegram retry/backoff helpers, and persist a partial reply via the normal send path when a stream aborts (client left, 30s draft expiry, etc.).

## Alternatives or workarounds

No workaround today — one-shot send is the only behavior. We compared the four platforms' official streaming surfaces:

| Dimension | Telegram | Feishu (飞书) | Lark | Slack |
|---|---|---|---|---|
| Official streaming API | `sendMessageDraft` / `sendRichMessageDraft` (native, LLM-oriented; same `draft_id` → animated) | Streaming update card: Card JSON 2.0 `streaming_mode: true` + CardKit `PUT .../elements/:element_id/content` | Same as Feishu on `open.larksuite.com` | `chat.startStream` / `chat.appendStream` / `chat.stopStream` (native text streaming for LLM tools) |
| Persistence model | Draft is **ephemeral (30s preview)** → must finalize with `sendRichMessage` | Card entity created once via CardKit, then element content patched | Same | Stream started once, appended, stopped — message persists |
| Typing affordance | `sendChatAction(typing)` ✅ | none ❌ | none ❌ | none ❌ (platform limit) |
| Classic fallback | `sendMessage` + `editMessageText` | `PATCH /open-apis/im/v1/messages/:message_id` (whole card) | Same | `chat.postMessage` + `chat.update` (error `streaming_state_conflict` if the message is being streamed) |
| Doc link | core.telegram.org/bots/api | open.feishu.cn/document/cardkit-v1/streaming-updates-openapi-overview | open.larksuite.com/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/streaming-updates-openapi-overview | api.slack.com/messaging/scheduling → "Text streaming in messages" |

Conclusions:

1. **Native streaming first, edit-loop fallback second** — Telegram drafts and Slack `chat.startStream` are purpose-built for LLM output (Slack's docs literally say "closer alignment with expected behavior from LLM tools"); Feishu/Lark cards are explicitly documented for the **AI Bot scenario**. Fallbacks (`editMessageText` / `chat.update` / message `PATCH`) exist on all four platforms and keep the feature shippable incrementally.
2. **Keep the streaming seam thin and channel-agnostic** — `startStream/append/finish/abort` lives in the core bot layer; each bridge maps it onto its platform primitives (mirrors the #2325 "keep the bridge thin" conclusion). Channels without a server push (WeChat iLink) simply never advertise `startStream`.
3. **Watch per-channel constraints** — Telegram draft expiry (30s) needs a finalize-on-timeout path; Feishu/Lark need `cardkit:card:write` + the `streaming_config` print rhythm tuned to rate limits; Slack streams can't be edited mid-stream (`streaming_state_conflict`).
4. **Scope** — WeChat, WeCom, DingTalk, QQ, Discord are out of scope for this issue (Discord/QQ already have typing indicators; card/embed streaming can be a follow-up).

中文对照(简体中文)

## 问题

bot 桥的 IM 用户在做长任务时**完全看不到过程反馈**:消息发出去,几秒甚至几分钟后,回复才像一堵墙一样一次性砸过来。现在的桥接器在"输出"这一侧同样是哑管道(会话管理侧的问题见 #2325):

- **`bot-incoming-main.ts`** 会等整个回合结束:`reply = botReply(await turn)` 之后才调一次 `sendMessage`,完全没有增量路径。
- **Telegram**(`simple-bridge.ts`):全仓库没有任何 `editMessageText`(代码搜索 0 命中)。仅有 `sendChatAction(typing)`(每 4 秒刷新)和最终回复按 UTF-16 4000 分片发送。一个 3 分钟的生成过程,用户只看到一个"正在输入…",然后全部内容一次性到达。
- **飞书 / Lark**(`feishu-bridge.ts`):只有 `channel.send(chatId, { markdown })`——没有交互卡片、没有卡片更新 API、没有 typing。整个生成期间用户盯着静态聊天窗口。
- **Slack**(`slack-bridge.ts`,160 行,最薄的桥):只有 `web.chat.postMessage`——没有 `chat.update`、没有 typing(Slack 平台本身就没有 bot typing API)。长回复作为一整块不可拆分的文本落地。

这不是"锦上添花":**四个平台现在都上线了官方、面向 LLM 的流式 API**,而竞品助手 bot(OpenClaw 的 agent 活动、Hermes 的流式网关、Claude 的消息草稿)都会在生成过程中逐步展示输出。Maka 成了异类——一个在 IM 上感觉不到"AI 感"的 AI agent。

## 期望结果

agent 在 Telegram、飞书、Lark、Slack 上生成回复时,**在聊天里原地逐步呈现**,每个平台都用官方的流式能力,bot 层只有一个与平台无关的流式契约:

- **Telegram(P0)**——用 `sendRichMessageDraft` 流式发送(每回合固定 `draft_id` → 动画更新,30 秒临时预览),完成时用 `sendRichMessage` 落库。兜底:旧 Bot API/客户端用 `sendMessage` + `editMessageText` 循环。保留现有 typing 指示器。
- **飞书 / Lark(P0)**——发送开启流式模式的卡片(Card JSON 2.0,`config.streaming_mode: true`,`streaming_config` 配 `print_frequency_ms` / `print_strategy`),然后按块用 `PUT /open-apis/cardkit/v1/cards/:card_id/elements/:element_id/content` 流式更新文本(CardKit API,权限 `cardkit:card:write`)。同一套 CardKit 逻辑同时覆盖 `open.feishu.cn` 和 `open.larksuite.com`。
- **Slack(P0)**——原生 `chat.startStream` → 每块 `chat.appendStream` → `chat.stopStream`;可选 `task_display_mode` + `chunks` 在流式时展示 agent 的计划/任务。兜底:`chat.postMessage` + `chat.update`。
- **统一契约(P0)**——在 `packages/runtime/src/bots/types.ts` 的 `BotBridge` / `SendCapable` 上加可选 `startStream(chatId, options)` → `{ append(chunk), finish(finalText), abort() }`。`bot-incoming-main.ts` 优先走流式路径,渠道不支持时回退到现有一次性发送(如微信 iLink 没有服务端推送)。
- **可靠性(P1)**——节流 append(≤ 约 20 次/秒,对齐飞书 70ms 打印节奏),复用现有 Telegram 重试/退避工具,流式中断时(用户离开、30 秒草稿过期等)用普通发送路径落一条部分回复。

## 备选方案

今天没有任何变通方案——一次性发送是唯一行为。我们对四个平台的官方流式能力做了对比:

| 维度 | Telegram | 飞书 | Lark | Slack |
|---|---|---|---|---|
| 官方流式 API | `sendMessageDraft` / `sendRichMessageDraft`(原生、面向 LLM;同 `draft_id` → 动画) | 流式更新卡片:Card JSON 2.0 `streaming_mode: true` + CardKit `PUT .../elements/:element_id/content` | 与飞书一致(`open.larksuite.com`) | `chat.startStream` / `chat.appendStream` / `chat.stopStream`(面向 LLM 的原生文本流式) |
| 持久化模型 | 草稿是**临时的(30 秒预览)** → 必须用 `sendRichMessage` 落库 | 卡片实体经 CardKit 创建一次,之后只 PATCH 组件内容 | 同左 | 流式开始/追加/结束,消息直接持久化 |
| Typing 提示 | `sendChatAction(typing)` ✅ | 无 ❌ | 无 ❌ | 无 ❌(平台限制) |
| 经典兜底 | `sendMessage` + `editMessageText` | `PATCH /open-apis/im/v1/messages/:message_id`(整卡) | 同左 | `chat.postMessage` + `chat.update`(流式中编辑会报 `streaming_state_conflict`) |
| 文档 | core.telegram.org/bots/api | open.feishu.cn/document/cardkit-v1/streaming-updates-openapi-overview | open.larksuite.com/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/streaming-updates-openapi-overview | api.slack.com/messaging/scheduling → "Text streaming in messages" |

结论:

1. **原生流式优先、编辑循环兜底**——Telegram 草稿和 Slack `chat.startStream` 就是为 LLM 输出造的(Slack 文档原话:"closer alignment with expected behavior from LLM tools");飞书/Lark 卡片文档明确点名 **AI Bot 场景**。四个平台都有兜底方案(`editMessageText` / `chat.update` / 消息 `PATCH`),可以增量推进。
2. **流式接缝要薄、与渠道无关**——`startStream/append/finish/abort` 放在核心 bot 层,每个桥把它映射到平台原语(与 #2325 的 "keep the bridge thin" 结论一致)。没有服务端推送的渠道(微信 iLink)不声明 `startStream` 即可。
3. **注意各渠道约束**——Telegram 草稿 30 秒过期要有落库超时路径;飞书/Lark 需要 `cardkit:card:write` 权限并按限流调整 `streaming_config` 打印节奏;Slack 流式中的消息不能被编辑(`streaming_state_conflict`)。
4. **范围**——微信、企业微信、钉钉、QQ、Discord 不在本 issue 范围(Discord/QQ 已有 typing 指示器;卡片/嵌入流式可作后续 issue)。

Contributor guide

Open the contributing guide

Research direction

Start with packages/runtime/src/bots/types.ts and bot-incoming-main.ts to understand the existing BotBridge, SendCapable, and one-shot send flow. Then inspect simple-bridge.ts, feishu-bridge.ts, and slack-bridge.ts, along with their existing retry or send helpers. Done means supported Telegram, Feishu/Lark, and Slack paths stream incrementally, fall back to one-shot behavior where needed, and preserve partial output when a stream aborts.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.