From c2d1d2077678b0fe859e687c10e27243fb33fc41 Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:47:40 +0800 Subject: [PATCH 01/11] docs: add bot platform support matrix Document the eight IM bot channels registered in BOT_PROVIDERS: their transports, credential fields, capability parity, message limits, known limitations and security posture. Every claim is derived from the bridge implementations rather than from prose, and is cited by symbol name rather than line number so the references survive refactors. Setup instructions per platform are deliberately left as marked placeholders. The issue requires them to be tested against real developer accounts, and untested setup steps are worse than none. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/README.md | 1 + docs/bot-platforms.md | 313 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 docs/bot-platforms.md diff --git a/docs/README.md b/docs/README.md index a34778cccd..03726a7023 100644 --- a/docs/README.md +++ b/docs/README.md @@ -68,6 +68,7 @@ This page is the authority map for Maka documentation. Code and contract tests r - [Agent Swarm](./agent-swarm.md) - [Side Conversation](./side-conversation.md) - [Agent Graph stream scheduling](./architecture/agent-graph-stream-scheduling-draft.md) ([中文](./architecture/agent-graph-stream-scheduling-draft.zh-CN.md)) +- [Bot platform support matrix](./bot-platforms.md) - [IM 扫码接入 runtime architecture](./architecture/bot-onboarding-runtime.zh-CN.md) - [Backend architecture chapters](./architecture/) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md new file mode 100644 index 0000000000..60599ef3c3 --- /dev/null +++ b/docs/bot-platforms.md @@ -0,0 +1,313 @@ + + +# Bot platform support + +Maka connects to instant-messaging platforms through bot bridges in +`packages/runtime/src/bots/`. This page is the support matrix: which platforms +exist, what each one can do, what credentials it needs, and where the gaps are. + +Code is the authority. Every capability claim below is stated as the method or +constant that backs it, so a reader can re-derive the table from the tree when +this page drifts. + +Related: + +- [IM 扫码接入 runtime architecture](architecture/bot-onboarding-runtime.zh-CN.md) — + the QR-code onboarding flow for DingTalk, Feishu/Lark, WeCom and WeChat, + including registration endpoints and credential mapping. +- `packages/runtime/src/bots/__tests__/` — contract tests for the behaviors + described here. + +## Supported platforms + +Eight providers are registered in `BOT_PROVIDERS` +(`packages/core/src/bot-chat-settings.ts`): + +| Provider key | Platform | Bridge | +| --- | --- | --- | +| `telegram` | Telegram | `telegram-bridge.ts` | +| `slack` | Slack | `slack-bridge.ts` | +| `discord` | Discord | `discord-bridge.ts` | +| `dingtalk` | 钉钉 DingTalk | `dingtalk-bridge.ts` | +| `feishu` | 飞书 Feishu **and** Lark | `feishu-bridge.ts` | +| `wecom` | 企业微信 WeCom | `wecom-bridge.ts` | +| `wechat` | 微信 WeChat | `wechat-bridge.ts` | +| `qq` | QQ | `qq-bridge.ts` | + +Feishu and Lark share the single `feishu` channel; the account region is +selected by the `domain` setting (`feishu.cn` vs `larksuite.com`) rather than by +a separate provider. Counting them separately yields the "9 platforms" figure +seen elsewhere, but there are eight channels to configure. + +## Maturity model + +Channel maturity is not prose — it is the `BotReadinessState` enum +(`packages/core/src/bot-chat-settings.ts`), reported per channel in +`BotStatus.readiness`: + +| State | Meaning | +| --- | --- | +| `unscaffolded` | No bridge for this provider. | +| `scaffolded` | Bridge exists; channel disabled or credentials missing. | +| `configured` | Credentials present, not yet proven against the platform. | +| `credentials_valid` | Credentials probed successfully. | +| `operational` | Bridge connected and serving traffic. | +| `degraded` | Was connected, currently retrying. | + +All eight bridges reach `operational`. Readiness is per-install runtime state, +so it is not a static per-platform grade: use it to judge a deployment, not the +maturity of the code. + +`BotStatus.running` reflects only the polling/connection loop and is explicitly +not readiness — see the doc comment on `BotStatus.running` in `types.ts`. + +## Feature matrix + +Optional capabilities are declared as optional methods on `SendCapable` +(`types.ts`) and dispatched defensively by `BotRegistry`: an unimplemented +method degrades to a no-op or `false` rather than throwing, so a caller never +needs to branch on platform. + +| Capability | telegram | slack | discord | dingtalk | feishu | wecom | wechat | qq | +| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | +| Send text (`sendMessage`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Streaming reply (`startReplyStream`) | ✅ | — | — | — | — | — | — | — | +| Typing indicator (`sendTypingIndicator`) | ✅ | — | ✅ | — | — | — | — | ✅ | +| Reply threading (`replyToMessageId`) | ✅ | ✅ | ✅ | — | ✅ | — | — | ✅ | +| Ephemeral auto-delete (`ephemeralTtlMs`) | ✅ | — | — | — | — | — | — | — | +| Inbound attachment kind | ✅ | — | — | — | — | — | ✅ | — | +| Outbound file attachments | — | — | — | — | — | — | — | — | +| Send retry/backoff | ✅ | — | ✅ | ✅ | — | — | — | ✅ | +| User allowlist enforced | ✅ | — | — | — | ✅ | ✅ | — | — | +| Scheduled-task delivery | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | + +Notes on individual rows: + +- **Streaming reply** is Telegram-only. `BotRegistry.startReplyStream` returns + `null` for every other platform, and callers fall back to a single terminal + message. +- **Typing indicator** is one-shot and decorative. It returns `false` rather + than throwing on unsupported platforms, and Telegram clears it after roughly + five seconds, so sustained indication requires repeated calls. +- **Reply threading** applies to the first chunk only. A long reply split + across several messages threads its head under the user's message and sends + the remainder as ordinary sequential messages, so one answer does not fork + into N replies under the same parent (see `BotSendOptions` in `types.ts`). +- **Outbound file attachments are not supported on any platform.** `SendCapable` + carries text only. Telegram and WeChat classify *inbound* attachments into a + `BotAttachmentKind` (`photo`, `voice`, `sticker`, `document`, `video`, + `audio`, `animation`, `unknown`) so the handler can react to them; the other + six bridges do not surface attachment metadata at all. +- **Scheduled-task delivery** is gated by `BOT_DELIVERY_PROVIDERS`. Feishu and + WeCom are excluded from that list, and `scheduled-task-coordinator.ts` + rejects any bot-channel task whose platform fails `isBotDeliveryProvider`, so + scheduled tasks cannot target them. + +## Transport and connection + +`BotStatus.connection` reports the transport, from `connectionKind()` on +`BaseBotAdapter` and the per-bridge overrides: + +| Platform | `connection` | Protocol | +| --- | --- | --- | +| telegram | `polling` | Bot API long polling | +| slack | `gateway` | Socket Mode WebSocket | +| discord | `gateway` | Discord gateway opcodes via `GatewayBridgeBase` | +| qq | `gateway` | QQ gateway; identical opcode set to Discord | +| dingtalk | `gateway` | DingTalk Stream WebSocket via `WsBridgeBase` | +| feishu | `gateway` | Lark SDK WebSocket channel | +| wecom | `gateway` | WeCom AI-bot WebSocket | +| wechat | `gateway` | HTTP streaming loops (local bridge + iLink) | + +No bridge currently uses the `webhook` connection kind, even though `BotStatus` +allows it — every platform is driven by an outbound connection from the +runtime, so none of them requires a public inbound URL. + +Discord and QQ share `GatewayBridgeBase` verbatim: HELLO/HEARTBEAT/DISPATCH +opcode dispatch, jittered heartbeats, missed-ack reconnect, and identify-vs- +resume selection. They differ only in the gateway URL fetch, the identify +payload shape, and event mapping. + +Reconnect backoff is shared by every WebSocket bridge: exponential from +`RECONNECT_DELAY_MIN_MS` (1s) to `RECONNECT_DELAY_MAX_MS` (30s) in +`ws-bridge-base.ts`. Send retry, where implemented, is capped at 30s. + +WeChat is the outlier. It runs two streaming loops, one of which talks to a +**local companion bridge process** that must be reachable at +`http://127.0.0.1:18400` (or another localhost URL). `normalizeWechatBridgeUrl` +rejects anything that is not plain `http:` on `127.0.0.1`, `localhost` or +`[::1]`, and rejects URLs longer than 256 characters. + +## Credentials per platform + +These are the fields each bridge actually reads from `BotChannelSettings`. +Field names are generic across providers, so the mapping matters: + +| Platform | `token` | `appId` | `appSecret` | Other | +| --- | --- | --- | --- | --- | +| telegram | Bot token from BotFather | — | — | — | +| discord | Bot token | — | — | — | +| slack | Bot User OAuth token (`xoxb-`) | — | **App-level token (`xapp-`)** | — | +| dingtalk | — | ClientID | ClientSecret | — | +| feishu | App token | App ID | App Secret | `domain` picks Feishu vs Lark | +| wecom | — | Bot ID | Secret | — | +| qq | — | App ID | App Secret | — | +| wechat | Bot token | — | — | `webhookUrl` = local bridge URL | + +⚠️ Slack's `appSecret` holds the **app-level token** used for Socket Mode — +`SlackBotBridge.start()` reads it as `appToken` — not the signing secret. The +field name invites the wrong value. + +Changing `enabled`, `token`, `appId`, `appSecret`, `domain` or `webhookUrl` +restarts the bridge; any other settings change is applied in place +(`botSettingsRequireRestart` in `base-adapter.ts`). + +Every channel supports `proxyUrl`, and all outbound HTTP goes through +`proxiedFetch`, which honors the active proxy and its bypass list with a 15s +default timeout. + +## Message limits + +| Platform | Limit | Behavior | +| --- | --- | --- | +| telegram | 4000 UTF-16 units per message | Split into chunks, first chunk threaded | +| discord | 2000 characters per message | Split into chunks | + +Other bridges do not declare an explicit chunking constant; they send the text +as one message and surface any platform-side rejection as a send error. + +Telegram's limit is measured in UTF-16 code units rather than JavaScript string +length, because Telegram counts entities that way — see +`__tests__/telegram-utf16.test.ts`. + +Telegram's ephemeral TTL is clamped to a 1s floor and a 48-hour ceiling. +Telegram does not let a bot delete its own direct messages past 48 hours, so a +longer schedule would silently no-op. + +## Known limitations + +1. **Streaming replies are Telegram-only.** Every other platform delivers the + answer as one terminal message. +2. **The user allowlist is enforced by three bridges.** `allowedUserIds` is + checked in Telegram, Feishu and WeCom. Discord, QQ, DingTalk, Slack and + WeChat do not consult it — configuring an allowlist on those channels does + not restrict who can talk to the bot. See the security section. +3. **Scheduled tasks cannot target Feishu or WeCom** (`BOT_DELIVERY_PROVIDERS`). +4. **No platform can send files.** Text out only. +5. **WeChat requires a local companion bridge** on localhost; it cannot run + against a remote bridge host. +6. **WeCom credentials cannot be pre-verified.** The credential test validates + shape and non-emptiness only and returns `verified: false`, because the SDK + proves the credentials only through a WebSocket auth handshake (see + `BotTestResult.verified` in `types.ts` and the WeCom branch of + `bot-test.ts`). Callers must not downgrade a working WeCom channel on the + strength of that probe. +7. **One account per provider.** Onboarding runs a single session per provider; + there is no multi-account or parallel-onboarding support. + +## Security considerations + +**Allowlist gaps.** Where `allowedUserIds` is enforced, an unauthorized inbound +message is dropped silently — no acknowledgement is sent back, so a scanner +cannot use bounce behavior to enumerate the bot's policy (`types.ts` on +`allowedUserIds`). On the five bridges that do not enforce it, any user who can +reach the bot can drive it; restrict access at the platform level instead +(private server, closed workspace, internal-only app). + +**Allowlist IDs are strings.** Telegram user IDs are 64-bit and lose precision +as JavaScript numbers, so IDs are stored and compared as strings. + +**Credential storage.** Credentials live in the owner-only settings store. The +renderer receives masked values and never sees the secret. At-rest Keychain +encryption is *not* implemented yet — it is listed as a follow-up in the +onboarding architecture doc. + +**Error redaction.** Bridge errors pass through `generalizedErrorMessage` before +being logged or surfaced, so tokens embedded in provider error strings are not +written to logs. + +**No inbound webhooks.** Because every bridge dials out, no bot channel requires +opening a public inbound endpoint, and there is no webhook signature to verify. +WeChat's `webhookUrl` is a *local* bridge address, not a public callback. + +**Outbound requests are proxy-aware.** All bridge HTTP flows through +`proxiedFetch`, so an egress proxy configured for the app also covers bot +traffic. + +## Setup + +> **Status: not yet written.** The sections below are placeholders. Each one +> must be walked end-to-end against a real developer account before it lands — +> the issue's acceptance criteria require tested instructions, and untested +> setup steps are worse than none. +> +> For DingTalk, Feishu/Lark, WeCom and WeChat, check +> [the onboarding architecture doc](architecture/bot-onboarding-runtime.zh-CN.md) +> first: QR-code onboarding may make most manual steps unnecessary. + +Each platform section should cover: where to register, which console fields map +to which Maka setting (see the credentials table above), the minimum scopes or +intents required, how to invite the bot to a chat, and how to confirm the +channel reports `operational`. + +### Telegram + +_TODO — register via BotFather, single `token`._ + +### Discord + +_TODO — developer portal application, bot token, gateway intents._ + +### Slack + +_TODO — Socket Mode app; note the two distinct tokens (`xoxb-` and `xapp-`)._ + +### DingTalk 钉钉 + +_TODO — internal app, Stream mode, ClientID/ClientSecret._ + +### Feishu 飞书 / Lark + +_TODO — self-built app; document the `domain` split between regions._ + +### WeCom 企业微信 + +_TODO — AI bot, Bot ID and Secret; note the unverifiable credential test._ + +### WeChat 微信 + +_TODO — iLink bot plus the local companion bridge on 127.0.0.1._ + +### QQ + +_TODO — QQ open platform app, App ID and App Secret._ + +## Verifying a channel + +The app runs a per-platform credential probe (`bot-test.ts`) covering all eight +providers. A successful probe returns the bot identity and a capability map; a +failure returns an error plus a hint. WeCom is the one platform whose probe +cannot reach a live endpoint, and it reports `verified: false` to say so. + +To confirm the bridge layer itself after a change: + +```bash +npm run test -- packages/runtime/src/bots +``` From 17a562fb0e6450216503dacfbe0c5f63a9eaa258 Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:03:01 +0800 Subject: [PATCH 02/11] docs: add verified Feishu/Lark bot setup guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the Feishu channel end-to-end against a real custom app and documented what the runtime actually requires: bot capability, the im:message.p2p_msg:readonly scope, im.message.receive_v1 delivered over a long connection rather than a webhook, and a released version whose availability range covers the intended users. Both directions were exercised: an outbound bot DM, and an inbound user reply received over the WebSocket long connection. Also corrects the Feishu row of the credential table. The bridge reads `appSecret` or, when empty, falls back to `token` — the two are interchangeable and only one is needed. The table previously implied Feishu takes three distinct credentials. Records the failure mode the walkthrough surfaced: the handshake succeeds on app credentials alone, so an unreleased app yields a channel that reports a healthy connection while no user can find the bot to message it. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 84 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 60599ef3c3..fe1146d81a 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -166,11 +166,15 @@ Field names are generic across providers, so the mapping matters: | discord | Bot token | — | — | — | | slack | Bot User OAuth token (`xoxb-`) | — | **App-level token (`xapp-`)** | — | | dingtalk | — | ClientID | ClientSecret | — | -| feishu | App token | App ID | App Secret | `domain` picks Feishu vs Lark | +| feishu | Fallback for `appSecret` | App ID | App Secret | `domain` picks Feishu vs Lark | | wecom | — | Bot ID | Secret | — | | qq | — | App ID | App Secret | — | | wechat | Bot token | — | — | `webhookUrl` = local bridge URL | +⚠️ Feishu reads `appSecret` **or**, when that is empty, `token` — the two are +interchangeable for this channel, and only one needs to be set. Feishu does not +take three distinct credentials. + ⚠️ Slack's `appSecret` holds the **app-level token** used for Socket Mode — `SlackBotBridge.start()` reads it as `appToken` — not the signing secret. The field name invites the wrong value. @@ -253,12 +257,12 @@ traffic. ## Setup -> **Status: not yet written.** The sections below are placeholders. Each one -> must be walked end-to-end against a real developer account before it lands — -> the issue's acceptance criteria require tested instructions, and untested +> **Status: Feishu/Lark is verified end-to-end. The rest are placeholders.** +> Each remaining section must be walked against a real developer account before +> it lands — the acceptance criteria require tested instructions, and untested > setup steps are worse than none. > -> For DingTalk, Feishu/Lark, WeCom and WeChat, check +> For DingTalk, WeCom and WeChat, check > [the onboarding architecture doc](architecture/bot-onboarding-runtime.zh-CN.md) > first: QR-code onboarding may make most manual steps unnecessary. @@ -285,7 +289,75 @@ _TODO — internal app, Stream mode, ClientID/ClientSecret._ ### Feishu 飞书 / Lark -_TODO — self-built app; document the `domain` split between regions._ +Maka's Feishu channel opens a **WebSocket long connection** from the runtime +(`transport: 'websocket'` in `feishu-bridge.ts`). There is no public callback +URL to expose and no inbound port to open, so this works from a laptop behind +NAT. The console must be configured for long-connection event delivery, not for +webhook delivery. + +**1. Create a custom app.** Open the developer console — +[open.feishu.cn](https://open.feishu.cn/) for Feishu, or +[open.larksuite.com](https://open.larksuite.com/) for Lark — and create a +*企业自建应用 / Custom app*. From the app's credentials page, copy: + +| Console field | Maka setting | +| --- | --- | +| App ID (`cli_…`) | `appId` | +| App Secret | `appSecret` | + +Feishu and Lark are separate consoles with separate accounts. Create the app in +the console matching the account you will actually chat from. + +**2. Add the bot capability.** *添加应用能力 / Add features* → *机器人 / Bot*. +Without this the app has no chat identity and no message can be sent or +received. + +**3. Grant the permission scopes.** At minimum, to receive direct messages: + +- `im:message.p2p_msg:readonly` — required for `im.message.receive_v1`. + +Group chats and outbound sending need their own scopes; grant the message-send +scope for the bot, and the group-message read scope if the bot will serve group +chats rather than DMs only. + +**4. Subscribe to events over the long connection.** *事件与回调 / Events & +callbacks* → set the delivery mode to *长连接 / Long connection* (**not** +*将事件发送至开发者服务器 / Send to developer server*), then add the event: + +- `im.message.receive_v1` — 接收消息 / Receive message. + +**5. Publish a version.** *版本管理与发布 / Version management & release*. +Scopes and event subscriptions do not take effect for a tenant until a released +version is approved, and the *可用范围 / Availability* of that version must +include the users who will talk to the bot. + +**6. Configure the channel in Maka.** Enter `appId` and `appSecret`. Leave +`domain` empty for Feishu; set it to `larksuite.com` for Lark — the bridge +selects `Domain.Lark` only on that exact string and falls back to +`Domain.Feishu` otherwise, so a typo silently points a Lark app at the Feishu +endpoint. + +Optionally set `allowedUserIds` to the open IDs (`ou_…`) permitted to use the +bot. Feishu is one of the three platforms that enforce this list. Note that the +Lark SDK's own policy gate only covers DMs, so the bridge re-checks the +allowlist locally to catch group messages too. + +**7. Verify.** The channel should reach `operational`. If it does not, the +reason string on the status distinguishes the failure: +`missing-feishu-credentials` means `appId` or `appSecret`/`token` is empty, +while a handshake failure leaves readiness at `configured` with the underlying +error as the reason. + +> **A connected channel does not mean a reachable bot.** The long connection +> handshake succeeds using app credentials alone. If the app version has not +> been released, or the availability range excludes you, the channel reports a +> healthy connection while no user can find the bot to message it — the bridge +> simply receives nothing. When a channel looks connected but silent, check the +> release status before debugging the runtime. +> +> The quickest way out of that state is to have the bot open the conversation +> itself: sending a direct message to a user's open ID creates the P2P chat and +> makes it appear in that user's client. ### WeCom 企业微信 From 879b5c7dcd8dafa8be5b6efc0af674feec639753 Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:33:13 +0800 Subject: [PATCH 03/11] docs: add verified WeCom bot setup guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the WeCom channel end-to-end against a real AI bot. Both directions were exercised with the same SDK version the runtime pins (@wecom/aibot-node-sdk 1.0.7), mirroring WeComBotBridge.start(): the WebSocket handshake authenticated, an inbound single-chat frame arrived, and an outbound markdown send returned a req_id. Records the decision that governs every other step: the channel is the 智能机器人 in API mode, not a custom app, not a group webhook, and not 普通模式 — the latter answers messages with WeCom's own hosted models, so nothing reaches the long connection. Documents the addressing consequence observed in the inbound frame. A single-chat frame carries no chatid at all, which is why the bridge falls back to the sender's userid, and why a WeCom conversation cannot be addressed until the bot has received a message in it. Also notes that private deployments cannot work, since the SDK endpoint is fixed and the bridge exposes no override. Clarifies globally that an empty matrix cell means Maka does not implement the capability, not that the platform lacks it: the WeCom SDK offers streaming replies, media upload and template cards that the bridge never wires up. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 79 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index fe1146d81a..6bbd439da9 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -98,6 +98,13 @@ needs to branch on platform. | User allowlist enforced | ✅ | — | — | — | ✅ | ✅ | — | — | | Scheduled-task delivery | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | +**An empty cell means Maka does not implement the capability on that platform. +It does not mean the platform cannot do it.** The WeCom AI Bot SDK, for +instance, ships streaming replies (`replyStream`), media upload +(`uploadMedia`) and template cards; the bridge wires up none of them and sends +plain markdown only. Treat the blanks as a map of unwired surface area, not as +a statement about the vendors. + Notes on individual rows: - **Streaming reply** is Telegram-only. `BotRegistry.startReplyStream` returns @@ -257,12 +264,12 @@ traffic. ## Setup -> **Status: Feishu/Lark is verified end-to-end. The rest are placeholders.** -> Each remaining section must be walked against a real developer account before -> it lands — the acceptance criteria require tested instructions, and untested -> setup steps are worse than none. +> **Status: Feishu/Lark and WeCom are verified end-to-end. The rest are +> placeholders.** Each remaining section must be walked against a real +> developer account before it lands — the acceptance criteria require tested +> instructions, and untested setup steps are worse than none. > -> For DingTalk, WeCom and WeChat, check +> For DingTalk and WeChat, check > [the onboarding architecture doc](architecture/bot-onboarding-runtime.zh-CN.md) > first: QR-code onboarding may make most manual steps unnecessary. @@ -361,7 +368,67 @@ error as the reason. ### WeCom 企业微信 -_TODO — AI bot, Bot ID and Secret; note the unverifiable credential test._ +Maka's WeCom channel is the **智能机器人 / AI Bot**, driven through the official +`@wecom/aibot-node-sdk` over a WebSocket long connection. It is **not** a +自建应用 (custom app) and **not** a 群机器人 webhook. The distinction decides +every step below: a custom app would need a corp ID and agent ID, and +`BotChannelSettings` has no field for either. + +**1. Create the bot in API mode.** In the WeCom admin console, create a +智能机器人 and choose **API 模式** — *"连接企业自有系统或智能体"*. Do **not** +choose 普通模式 (*"使用企业微信提供的模型与数据"*), and do not use the +one-sentence AI-assisted creation flow, which produces a 普通模式 bot. + +In 普通模式 the conversation is answered by WeCom's own hosted models, so +messages are consumed on their side and never reach the long connection. Maka +is the "企业自有系统" that API 模式 exists to connect. + +**2. Set 可见范围 / Visibility.** Add the users who will talk to the bot. A +newly created bot has an empty visibility range and cannot be found in the +client. + +**3. Copy the credentials.** + +| Console field | Maka setting | +| --- | --- | +| Bot ID | `appId` | +| Secret | `appSecret` | + +**4. Configure the channel in Maka.** There is nothing else to fill in — no +callback URL, no token, no encoding key. The SDK dials +`wss://openws.work.weixin.qq.com` and authenticates by sending Bot ID and +Secret once the socket is open. + +**5. Verify.** A successful handshake moves the channel to +`credentials_valid`. Note that it does **not** go straight to `operational` — +WeCom only reaches `operational` after the first message is actually sent or +received. + +Failure reasons distinguish the cases: `no-credentials` means `appId` or +`appSecret` is empty, and an authentication failure leaves readiness at +`configured`. The handshake has a 15-second timeout. + +**Addressing replies.** WeCom has no stable per-chat identifier for direct +messages. An inbound single-chat frame carries `chattype: "single"` and **no** +`chatid` at all, so the bridge falls back to the sender's `userid` as the chat +ID; group frames carry a real `chatid`. Consequently a WeCom conversation +cannot be addressed until the bot has received a message in it — there is no +way to open a chat proactively the way Feishu allows. + +`allowedUserIds` for this channel holds WeCom user IDs, which are ordinarily +human-readable account names rather than opaque tokens. + +Sends return a `req_id`, not a message ID. Nothing in the API accepts that +value as a reply target, which is why this channel has no reply threading. + +> **Private deployments are not supported.** The SDK's built-in endpoint is +> fixed, and privately deployed WeCom organizations must use their own +> long-connection address. The bridge exposes no way to override it. + +> **The credential test cannot prove WeCom credentials.** Maka's in-app probe +> checks shape only and reports `verified: false`. The sole real check is +> completing the WebSocket handshake, so treat a "passing" WeCom credential +> test as unproven until the channel actually connects. ### WeChat 微信 From 213328d4d86bc8431ace0d744b87f77e7bd34c95 Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:44:59 +0800 Subject: [PATCH 04/11] docs: add verified QQ bot setup guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the QQ channel end-to-end against a real bot, mirroring QQBotBridge and the GatewayBridgeBase lifecycle it inherits. All three startup steps were exercised — app access token exchange, gateway URL fetch, and WebSocket IDENTIFY reaching READY — followed by an inbound C2C dispatch and an outbound send that returned a message id. Documents the failure taxonomy the code encodes but the console does not explain: 4014 and 4004 are fatal closes that stop the bridge, while every other code reconnects with backoff, so a channel that dies and stays dead is almost always a missing intent or bad credentials. Records the addressing scheme, since QQ is the only platform whose chat IDs carry a prefix. Four inbound dispatch types map to four prefixes and four REST routes, and an unprefixed chat ID is dropped before any network call. Notes three things the walkthrough surfaced: the console's Token field belongs to webhook mode and is never read; group and guild traffic only arrives when the bot is @-mentioned; and QQ supplies no display name for group or C2C senders, so the bridge uses the bot-scoped openid as the user name. Also flags that the API host is hardcoded to production, so a sandbox-confined bot cannot be exercised through this channel. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 72 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 6bbd439da9..39e8ffe713 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -264,7 +264,7 @@ traffic. ## Setup -> **Status: Feishu/Lark and WeCom are verified end-to-end. The rest are +> **Status: Feishu/Lark, WeCom and QQ are verified end-to-end. The rest are > placeholders.** Each remaining section must be walked against a real > developer account before it lands — the acceptance criteria require tested > instructions, and untested setup steps are worse than none. @@ -436,7 +436,75 @@ _TODO — iLink bot plus the local companion bridge on 127.0.0.1._ ### QQ -_TODO — QQ open platform app, App ID and App Secret._ +The QQ bridge speaks the gateway protocol directly — no vendor SDK — and +shares its opcode lifecycle with Discord through `GatewayBridgeBase`. + +**1. Create the bot** on the QQ open platform and open its credentials page. + +| Console field | Maka setting | +| --- | --- | +| AppID | `appId` | +| AppSecret | `appSecret` | +| Token | *unused* | + +The console also issues a **Token**. Maka never reads it: that value belongs to +the webhook callback mode, and this bridge authenticates by exchanging AppID +and AppSecret for an app access token instead. + +**2. Configure the channel in Maka.** `appId` and `appSecret` are the only +inputs. Startup then runs three steps, and the status reason names whichever +one fails: + +1. `POST bots.qq.com/app/getAppAccessToken` → app access token, cached and + refreshed 5 minutes before expiry. Failure reason: `getAppAccessToken-`. +2. `GET api.sgroup.qq.com/gateway/bot` → the gateway URL. Failure reason: + `gateway-bot-`. +3. WebSocket connect, then IDENTIFY. Success arrives as a `READY` dispatch + carrying the bot's ID and username, which promotes the channel to + `operational`. + +**3. Grant the intents.** The bridge requests a fixed mask of `1107300353` — +`GUILDS | DIRECT_MESSAGE | PUBLIC_GUILD_MESSAGES | PUBLIC_MESSAGES`. This is +not configurable. Two close codes are treated as **fatal**, meaning the bridge +stops rather than retrying: + +- **4014** — disallowed intent: the console has not granted something in that + mask. +- **4004** — authentication failed: AppID/AppSecret rejected. + +Any other close code reconnects with backoff, so a channel that dies +immediately and stays dead is almost always one of these two. + +**Conversation addressing.** QQ is the only platform whose chat IDs carry a +prefix, because its four conversation kinds route to four different REST +endpoints: + +| Inbound dispatch | `chatId` stamped | Send route | +| --- | --- | --- | +| `AT_MESSAGE_CREATE` | `channel:` | `/channels/{id}/messages` | +| `DIRECT_MESSAGE_CREATE` | `dm:channel:` | `/channels/{id}/messages` | +| `GROUP_AT_MESSAGE_CREATE` | `group:` | `/v2/groups/{id}/messages` | +| `C2C_MESSAGE_CREATE` | `c2c:` | `/v2/users/{id}/messages` | + +A chat ID without one of those prefixes cannot be routed and the send returns +`null` without any network call. + +**Guild and group messages require an @-mention.** Only `AT_MESSAGE_CREATE` and +`GROUP_AT_MESSAGE_CREATE` are delivered, so the bot never sees ordinary group +chatter. Direct conversations arrive unprompted. + +**Identity is an opaque, bot-scoped openid.** Group and C2C payloads carry no +display name at all, so the bridge falls back to using the openid as the user +name. Expect user IDs like `404C6F91…` rather than anything human-readable, and +note that the same person has a different openid under a different bot. + +Typing indicators work **only** for `channel:` chat IDs. Groups and C2C run on +a different messaging stack with no typing endpoint, so the bridge gates on the +prefix and returns `false` rather than emitting a confusing 404. + +> **There is no sandbox switch.** The API host is hardcoded to production +> (`api.sgroup.qq.com`). A bot still confined to the sandbox environment cannot +> be exercised through this channel. ## Verifying a channel From e0c44783bee41d467792b1927952f112b096f5a0 Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:42:10 +0800 Subject: [PATCH 05/11] docs: add verified Telegram bot setup guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the Telegram channel end-to-end against a real BotFather bot: getMe identity, getUpdates long-poll receiving three inbound messages, sendMessage, and sendChatAction typing. Confirms the matrix's one Telegram-exclusive claim rather than assuming it. sendMessageDraft is reachable by an ordinary BotFather bot, so the native streaming-reply path genuinely works; it is not a method the public Bot API rejects. Also records the two limits around it — streams are refused outright for group targets, and a single draft failure latches the stream off so streaming stops silently rather than erroring. Documents the default that makes a working bot look broken in groups: getMe reports can_read_all_group_messages false, so privacy mode hides all group traffic except commands and @-mentions until it is disabled via BotFather and the bot is re-added. Also notes that the poll requests only 'message' updates, and that a bot cannot open a conversation, so a channel that has never been messaged first is indistinguishable from a broken one. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 59 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 39e8ffe713..621a0dd7e1 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -264,8 +264,8 @@ traffic. ## Setup -> **Status: Feishu/Lark, WeCom and QQ are verified end-to-end. The rest are -> placeholders.** Each remaining section must be walked against a real +> **Status: Telegram, Feishu/Lark, WeCom and QQ are verified end-to-end. The +> rest are placeholders.** Each remaining section must be walked against a real > developer account before it lands — the acceptance criteria require tested > instructions, and untested setup steps are worse than none. > @@ -280,7 +280,60 @@ channel reports `operational`. ### Telegram -_TODO — register via BotFather, single `token`._ +The simplest channel to set up, and the most capable one: Telegram is the only +platform where every optional capability in the matrix is implemented. + +**1. Create the bot.** Message [@BotFather](https://t.me/BotFather) and send +`/newbot`. It asks for a display name and then a username, which must be +globally unique and end in `bot`. BotFather replies with a token shaped +`:` — that whole string is Maka's `token`. It is the only +credential this channel takes. + +**2. Open the conversation from the user side.** A bot cannot start a chat. +Open `https://t.me/`, press **Start**, and send a message. +`getUpdates` only returns messages sent after the bot exists, so until someone +messages it first the channel receives nothing — which looks identical to a +broken connection. + +**3. Set a proxy if Telegram is not directly reachable.** The channel's +`proxyUrl` is used for every Bot API call through `proxiedFetch`. Without it, +startup fails at the network layer rather than with an API error. + +**4. Verify.** Startup calls `getMe`. Success records the bot's ID, username +and display name, and moves the channel to `credentials_valid` — deliberately +*not* `operational`, because `getMe` proves credentials and reachability but is +not a send/receive smoke test. The channel reaches `operational` on the first +real message. A rejected token surfaces Telegram's own `description` as the +status reason. + +**Group messages are invisible by default.** A fresh bot reports +`can_read_all_group_messages: false` — Telegram's privacy mode. In groups it +therefore sees only commands and messages that @-mention it. To let a bot read +all group traffic, disable privacy mode via BotFather (`/setprivacy`) and +re-add the bot to the group; the setting applies from the next join. Direct +chats are unaffected. + +**Only `message` updates are requested.** The poll passes +`allowed_updates: ['message']`, so edited messages, callback queries, channel +posts and every other update type are never delivered, regardless of what the +bot is capable of. + +**Streaming replies work, in private chats only.** `startReplyStream` returns +`null` when the target is a group, so a group reply always arrives as one final +message. In private chats the bridge drives Telegram's native draft mechanism +(`sendMessageDraft`), which was confirmed available to an ordinary BotFather +bot. If a draft call ever fails the stream latches `nativeDraftAvailable` off +and silently degrades to a single final message, so a bot that stops streaming +mid-session is failing quietly rather than erroring. + +**Message limits.** Text is split at 4000 UTF-16 code units per message, +measured in UTF-16 rather than JavaScript string length because Telegram counts +entities that way. Ephemeral replies are clamped between 1 second and 48 hours; +Telegram will not let a bot delete its own direct messages past that window, so +a longer TTL would silently no-op. + +`allowedUserIds` holds numeric Telegram user IDs kept as strings, since they +are 64-bit and lose precision as JavaScript numbers. ### Discord From 769a6a55a41cf2572ede14d868efc571f06f9dca Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:08:40 +0800 Subject: [PATCH 06/11] docs: add verified Discord bot setup guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the Discord channel end-to-end against a real bot: REST /gateway/bot, gateway IDENTIFY reaching READY, an inbound MESSAGE_CREATE carrying non-empty content, an outbound send, and a typing indicator. Leads with the MESSAGE CONTENT INTENT because it has two failure modes and the second is the expensive one: either the gateway closes 4014, or messages arrive with every content field empty, leaving a bot that looks connected and awake while replying to nothing. Notes that Discord receives ordinary channel traffic with no @-mention, unlike QQ and Telegram groups, and that its chat IDs are bare channel IDs with none of QQ's prefixing. Records a proxy caveat honestly rather than as a confirmed defect. WsBridgeBase.createWebSocket() builds the socket without a dispatcher, so the channel's proxyUrl covers REST but not the gateway. Verification did not reproduce a failure — the unproxied socket connected — which is what a system-wide or TUN-mode proxy produces, so the gap is stated as conditional on the host rather than as an observed break. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 62 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 621a0dd7e1..21968e8efb 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -264,10 +264,11 @@ traffic. ## Setup -> **Status: Telegram, Feishu/Lark, WeCom and QQ are verified end-to-end. The -> rest are placeholders.** Each remaining section must be walked against a real -> developer account before it lands — the acceptance criteria require tested -> instructions, and untested setup steps are worse than none. +> **Status: Telegram, Discord, Feishu/Lark, WeCom and QQ are verified +> end-to-end. The rest are placeholders.** Each remaining section must be +> walked against a real developer account before it lands — the acceptance +> criteria require tested instructions, and untested setup steps are worse than +> none. > > For DingTalk and WeChat, check > [the onboarding architecture doc](architecture/bot-onboarding-runtime.zh-CN.md) @@ -337,7 +338,58 @@ are 64-bit and lose precision as JavaScript numbers. ### Discord -_TODO — developer portal application, bot token, gateway intents._ +Discord and QQ share `GatewayBridgeBase` verbatim — same opcodes, same +heartbeat and resume lifecycle. Only the auth scheme and event names differ. +A browser at `discord.com/app` is enough to drive the human side; no desktop +client is required. + +**1. Create a server** to host the bot, if you do not already have one. + +**2. Create the application and bot.** In the Discord Developer Portal, create +a New Application, open its **Bot** page, and use **Reset Token** to reveal the +bot token. That value is Maka's `token`, and it is shown only once. + +**3. Enable the MESSAGE CONTENT INTENT.** Still on the Bot page, under +**Privileged Gateway Intents**, turn on **MESSAGE CONTENT INTENT** and save. +The bridge requests a fixed intent mask of `37376` — `GUILD_MESSAGES | +DIRECT_MESSAGES | MESSAGE_CONTENT` — which is not configurable. + +This intent is privileged. It is self-serve while the bot is in fewer than 100 +servers; past that it needs Discord's approval. + +Skipping it fails in one of two ways, and the second is much harder to +diagnose than the first: + +- the gateway closes with **4014** (disallowed intent), which the bridge treats + as fatal and does not retry; or +- messages arrive normally but every `content` field is an **empty string**, so + the bot appears connected and awake while replying to nothing. + +**4. Invite the bot.** Under **OAuth2 → URL Generator**, select the `bot` scope +and at least the **Send Messages** and **Read Message History** permissions, +then open the generated URL and authorise it into your server. + +**5. Verify.** Startup fetches `/gateway/bot` over REST, opens the gateway +socket, sends IDENTIFY, and promotes the channel to `operational` on the +`READY` dispatch, which carries the bot's identity and its guild list. Close +code **4004** means the token was rejected; like 4014 it is fatal and stops the +bridge rather than retrying. + +**Discord sees ordinary channel traffic.** Unlike QQ groups and Telegram +groups, no @-mention is needed — with the content intent granted the bot +receives every message in every channel it can read. Chat IDs are the raw +Discord channel ID with no prefix. + +Messages are chunked at 2000 characters, Discord's own per-message limit. + +> **The channel proxy does not cover the gateway socket.** REST calls go +> through `proxiedFetch` and honour `proxyUrl`, but `WsBridgeBase.createWebSocket()` +> constructs the WebSocket with no dispatcher, so the gateway connection +> ignores that setting. This was *not* reproduced during verification — the +> unproxied socket connected normally — which is the expected outcome whenever +> a system-wide or TUN-mode proxy is transparently carrying raw sockets. It +> would matter on a host where Discord is reachable only through Maka's own +> proxy setting. The same applies to the QQ and DingTalk gateways. ### Slack From 124e2da866581bf0e7cac5b1bcd2bfbe9a54a44e Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:33:55 +0800 Subject: [PATCH 07/11] docs: add verified Slack bot setup guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the Slack channel end-to-end against a real workspace app: auth.test identity, Socket Mode connect, an inbound channel message and an inbound DM covering both branches of the isGroup derivation, and an outbound chat.postMessage. Documents the two-token split first, since the field named appSecret holds the app-level token rather than the signing secret, and a swap fails inside SocketModeClient.start() with an error that never names the cause. Records two setup gates found by walking it. A channel the bot has not been invited to rejects sends with not_in_channel, and direct messages stay disabled until App Home's Messages Tab allows them — until that box is checked Slack refuses DMs outright, message.im never fires, and nothing in the app's own configuration explains why. Notes that Slack is the only channel promoted to operational on connect rather than on first traffic, so a healthy-looking Slack channel proves less than a healthy-looking one elsewhere; that the mapper drops every event carrying a subtype or bot_id; and that Socket Mode has no replay, unlike Telegram's getUpdates offset. Also records that this channel ignores proxyUrl entirely, since neither WebClient nor SocketModeClient is given an agent. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 82 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 21968e8efb..e018a026e8 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -264,11 +264,10 @@ traffic. ## Setup -> **Status: Telegram, Discord, Feishu/Lark, WeCom and QQ are verified -> end-to-end. The rest are placeholders.** Each remaining section must be -> walked against a real developer account before it lands — the acceptance -> criteria require tested instructions, and untested setup steps are worse than -> none. +> **Status: Telegram, Slack, Discord, Feishu/Lark, WeCom and QQ are verified +> end-to-end. DingTalk and WeChat are still placeholders.** Both must be walked +> against a real developer account before they land — the acceptance criteria +> require tested instructions, and untested setup steps are worse than none. > > For DingTalk and WeChat, check > [the onboarding architecture doc](architecture/bot-onboarding-runtime.zh-CN.md) @@ -393,7 +392,78 @@ Messages are chunked at 2000 characters, Discord's own per-message limit. ### Slack -_TODO — Socket Mode app; note the two distinct tokens (`xoxb-` and `xapp-`)._ +Slack runs over Socket Mode, so like every other channel it dials out and needs +no public request URL. It is the only channel that takes **two** tokens. + +**1. Create the app.** At api.slack.com/apps, create a new app **from scratch** +in your workspace. + +**2. Enable Socket Mode.** This generates an **App-Level Token** beginning +`xapp-`. Save it — it goes in `appSecret`. + +**3. Add bot token scopes** under OAuth & Permissions: + +- `chat:write` — send messages +- `channels:history` — read public channel messages +- `im:history` — read direct messages + +**4. Subscribe to bot events** under Event Subscriptions: `message.channels` +and `message.im`. + +**5. Install to the workspace.** This yields the **Bot User OAuth Token** +beginning `xoxb-`, which goes in `token`. + +| Console value | Maka setting | +| --- | --- | +| Bot User OAuth Token (`xoxb-…`) | `token` | +| App-Level Token (`xapp-…`) | `appSecret` | +| Signing Secret | *unused* | + +⚠️ The field named `appSecret` holds the **app-level token**, not the signing +secret. Nothing in this channel ever reads the signing secret. Getting this +wrong fails inside `SocketModeClient.start()` with an error that does not name +the cause. + +**6. Invite the bot to each channel** it should serve: `/invite @yourbot`. +Posting to a channel the bot has not joined fails with `not_in_channel`. + +**7. Turn on direct messages.** Under **App Home → Show Tabs → Messages Tab**, +enable the tab and check *"Allow users to send Slash commands and messages from +the messages tab"*. Until then Slack refuses DMs with *"Sending messages to +this app has been turned off"*, `message.im` never fires, and nothing in the +bot's own configuration hints at why. + +**8. Verify.** Startup calls `auth.test()` to resolve identity, then opens the +Socket Mode connection. Missing either token short-circuits startup with +`missing-slack-tokens`. + +**Slack reaches `operational` on connect**, unlike every other channel — the +bridge promotes it as soon as the socket reports `connected`, without waiting +for any traffic. A healthy-looking Slack channel therefore proves less than a +healthy-looking Telegram or WeCom one. + +**Conversation shape.** `isGroup` is derived as `channel_type !== 'im'`, so +only true DMs count as private; every other conversation kind, including group +DMs, is treated as a group. Channel IDs are Slack's own — `C…` for channels, +`D…` for direct messages. + +**Replies stay threaded.** The inbound `sourceMessageId` is `thread_ts ?? ts`, +so a top-level message uses its own timestamp as the thread root, and replies +are posted with `thread_ts` to keep them on that thread. + +**Only clean user messages are delivered.** The mapper drops any event carrying +a `subtype` or a `bot_id`, so edits, joins/leaves, file shares and other bot +posts never reach the handler. + +> **Socket Mode does not replay.** Events emitted while the bridge is +> disconnected are lost, with no offset or cursor to resume from. This differs +> from Telegram, where `getUpdates` returns messages that arrived while the +> bridge was down. + +> **This channel ignores `proxyUrl` entirely.** Both `WebClient` and +> `SocketModeClient` are constructed without any agent, so neither the REST +> calls nor the socket use the configured proxy — not even the partial REST +> coverage the Discord channel gets from `proxiedFetch`. ### DingTalk 钉钉 From 0aadd8d97d72839a41ff57c7f2e7ddb875e5e69e Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:27:00 +0800 Subject: [PATCH 08/11] docs: add DingTalk setup guide and record a 1:1 send defect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the DingTalk channel against a live internal app. Setup and receive are verified: gettoken, gateway/connections/open, the Stream WebSocket, and an inbound direct message decoded from the /v1.0/im/bot/messages/get callback topic. Sending is not verified, because replying to a 1:1 conversation is broken. An inbound direct message yields conversationType "1", so isGroup is derived correctly, but chatId is stamped as conversationId and a 1:1 conversation ID also begins with "cid" — the exact prefix pickDingTalkSendRoute uses to detect a group. The reply is therefore posted to /v1.0/robot/groupMessages/send and rejected with resource.not.found. The comment above that helper describes the prefix as "cidp", which the implementation does not match. Routing to the 1:1 endpoint instead does not rescue it: that API wants staff user IDs, and the payload's senderId is an opaque $:LWCP_v1:$ token rejected with staffId.notExisted. DingTalkBotMessagePayload declares no field that endpoint accepts, so neither route can deliver a direct reply with what the bridge currently captures. Both failures were reproduced as a controlled pair against a live app, holding the robot code constant and varying only the chat ID. The section and the known-limitations list state the scope honestly: group replies were not exercised, and whether reusing appId as the robot code is valid remains unconfirmed. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 76 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index e018a026e8..19f376088b 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -232,6 +232,11 @@ longer schedule would silently no-op. strength of that probe. 7. **One account per provider.** Onboarding runs a single session per provider; there is no multi-account or parallel-onboarding support. +8. **DingTalk cannot reply to a 1:1 conversation.** The send route is picked by + testing the chat ID for a `cid` prefix, which every DingTalk conversation ID + carries — including direct ones — so direct replies are posted to the group + endpoint and rejected. Reproduced against a live app; see the DingTalk setup + section. ## Security considerations @@ -265,11 +270,11 @@ traffic. ## Setup > **Status: Telegram, Slack, Discord, Feishu/Lark, WeCom and QQ are verified -> end-to-end. DingTalk and WeChat are still placeholders.** Both must be walked -> against a real developer account before they land — the acceptance criteria -> require tested instructions, and untested setup steps are worse than none. +> end-to-end.** DingTalk is verified for setup and receive only — replying to a +> 1:1 conversation is broken, and the section records the defect. WeChat is +> still a placeholder and must be walked against a real setup before it lands. > -> For DingTalk and WeChat, check +> For WeChat, check > [the onboarding architecture doc](architecture/bot-onboarding-runtime.zh-CN.md) > first: QR-code onboarding may make most manual steps unnecessary. @@ -467,7 +472,68 @@ posts never reach the handler. ### DingTalk 钉钉 -_TODO — internal app, Stream mode, ClientID/ClientSecret._ +DingTalk Stream is not the Discord/QQ gateway shape. There is no HELLO and no +READY: the socket opening *is* the operational signal, and every frame must be +acked or the gateway redelivers it. + +**1. Create a 企业内部应用** (custom internal app) in the DingTalk open +platform, then add the **机器人 / Bot** capability under 应用能力. + +**2. Set the message mode to Stream.** In the bot configuration, choose +**Stream 模式**, not HTTP mode. This is the same decision as WeCom's API mode: +Maka opens an outbound long connection and never exposes a callback URL, so an +app configured for HTTP delivery cannot reach it. + +**3. Copy the credentials** from 凭证与基础信息: + +| Console field | Maka setting | +| --- | --- | +| Client ID (AppKey) | `appId` | +| Client Secret (AppSecret) | `appSecret` | + +**4. Publish a version** under 版本管理与发布. As with Feishu, scopes and +availability do not take effect for the org until a version is released. + +**5. Verify.** Startup runs three steps, each with its own failure reason: + +1. `GET oapi.dingtalk.com/gettoken` → access token (7200s), refreshed 5 minutes + before expiry. +2. `POST api.dingtalk.com/v1.0/gateway/connections/open` → `{endpoint, ticket}`. + Failure reason: `connections-open-`. +3. WebSocket connect to `endpoint?ticket=…`. + +The bridge subscribes to `EVENT` topic `*` plus the `CALLBACK` topic +`/v1.0/im/bot/messages/get`, and acks every frame including ones it ignores. + +**No close code is fatal.** Unlike Discord and QQ, `decideDingTalkClose` maps +every disconnect to a resumable reconnect, so a misconfigured DingTalk channel +retries forever instead of stopping with a diagnosable reason. + +> **Replying to a 1:1 conversation does not work.** This was reproduced against +> a live app and is a defect, not a setup step you can work around from the +> console. +> +> An inbound direct message arrives with `conversationType: "1"`, so the bridge +> correctly derives `isGroup: false`. But it stamps `chatId` as the payload's +> `conversationId`, and a **1:1 conversation ID also begins with `cid`**. +> `pickDingTalkSendRoute` decides the destination with +> `targetId.startsWith('cid')`, so the reply is posted to the group endpoint +> `/v1.0/robot/groupMessages/send` and fails with `resource.not.found`. The +> code comment describes the intended prefix as `cidp`, which the implementation +> does not match. +> +> Routing to the 1:1 endpoint instead does not help with what the bridge +> captures: `/v1.0/robot/oToMessages/batchSend` expects staff user IDs, and the +> Stream payload's `senderId` is an opaque `$:LWCP_v1:$…` token that it rejects +> with `staffId.notExisted`. `DingTalkBotMessagePayload` declares only +> `senderId`, `senderNick`, `conversationId`, `conversationType`, `text`, +> `robotCode` and `chatbotUserId` — none of which that endpoint accepts. +> +> `isGroup` is known accurately at receive time and discarded before send, +> which is what forces the prefix guess. Group replies were not exercised, so +> whether the group path works with a correct group conversation ID is +> unverified. The group-route failure also named `robotCode`, so whether +> reusing `appId` as the robot code is valid remains unconfirmed. ### Feishu 飞书 / Lark From 0d5657a86ca78a62e14cf24bc8b406de2e61d96b Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:41:57 +0800 Subject: [PATCH 09/11] docs: pin the DingTalk 1:1 send defect to a working fix Captured the raw Stream payload and extended the send experiment to a third case, which closes the diagnosis. Holding the conversation and the robot code constant and varying only the target: conversationId (what the bridge stamps) is rejected by the group endpoint, senderId is rejected by the 1:1 endpoint, and senderStaffId is delivered by the 1:1 endpoint and returns a processQueryKey. So the defect is not merely a misrouted prefix check. The identifier the 1:1 endpoint requires, senderStaffId, is present on every inbound bot message and is absent from DingTalkBotMessagePayload, so no reachable value in the mapped event can address a direct conversation. The payload also carries a sessionWebhook with an explicit expiry, which needs neither robot code nor staff ID and covers both conversation kinds. Recorded as a second possible route the bridge does not use. Resolves one earlier caveat: the payload's robotCode equals the app's appId, so the bridge's reuse of appId as the robot code is correct. Group replies are still unexercised and stay marked unverified. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 45 ++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 19f376088b..e39cdb9d71 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -235,8 +235,9 @@ longer schedule would silently no-op. 8. **DingTalk cannot reply to a 1:1 conversation.** The send route is picked by testing the chat ID for a `cid` prefix, which every DingTalk conversation ID carries — including direct ones — so direct replies are posted to the group - endpoint and rejected. Reproduced against a live app; see the DingTalk setup - section. + endpoint and rejected. The 1:1 endpoint needs the payload's `senderStaffId`, + which the bridge never captures. Reproduced against a live app, along with + the send that does succeed; see the DingTalk setup section. ## Security considerations @@ -522,18 +523,36 @@ retries forever instead of stopping with a diagnosable reason. > code comment describes the intended prefix as `cidp`, which the implementation > does not match. > -> Routing to the 1:1 endpoint instead does not help with what the bridge -> captures: `/v1.0/robot/oToMessages/batchSend` expects staff user IDs, and the -> Stream payload's `senderId` is an opaque `$:LWCP_v1:$…` token that it rejects -> with `staffId.notExisted`. `DingTalkBotMessagePayload` declares only -> `senderId`, `senderNick`, `conversationId`, `conversationType`, `text`, -> `robotCode` and `chatbotUserId` — none of which that endpoint accepts. +> Routing to the 1:1 endpoint does not help either, because the bridge does not +> capture the identifier that endpoint needs. +> `/v1.0/robot/oToMessages/batchSend` expects staff user IDs, and the payload's +> `senderId` is an opaque `$:LWCP_v1:$…` token it rejects with +> `staffId.notExisted`. > -> `isGroup` is known accurately at receive time and discarded before send, -> which is what forces the prefix guess. Group replies were not exercised, so -> whether the group path works with a correct group conversation ID is -> unverified. The group-route failure also named `robotCode`, so whether -> reusing `appId` as the robot code is valid remains unconfirmed. +> The field that works is **`senderStaffId`**, which DingTalk does send on every +> bot message and which `DingTalkBotMessagePayload` does not declare. Posting to +> the 1:1 endpoint with `userIds: [senderStaffId]` succeeds and returns a +> `processQueryKey`. Three sends against one live conversation, varying only the +> target: +> +> | Target passed as `chatId` | Route taken | Result | +> | --- | --- | --- | +> | `conversationId` — what the bridge stamps | group | `resource.not.found` | +> | `senderId` | 1:1 | `staffId.notExisted` | +> | `senderStaffId` | 1:1 | delivered | +> +> `isGroup` is known accurately at receive time and discarded before send, which +> is what forces the prefix guess in the first place. +> +> The same payload also carries a `sessionWebhook` with an explicit +> `sessionWebhookExpiredTime` (about 90 minutes out). Posting a reply there +> needs neither a robot code nor a staff ID and works for both conversation +> kinds, so it is a second possible route the bridge does not use. +> +> Two things this exercise settled: `robotCode` in the payload equals the app's +> `appId`, so the bridge's reuse of `appId` as the robot code is correct. Group +> replies were not exercised, so whether the group path works with a genuine +> group conversation ID remains unverified. ### Feishu 飞书 / Lark From 2f34de017447ac2f954d5c5252955e2975212a1c Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:35:50 +0800 Subject: [PATCH 10/11] docs: document both WeChat routes and verify the local-bridge contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WeChat has no ordinary bot API, so the channel picks between an iLink route and a local-bridge route from the value of webhookUrl. Both are now documented, along with the fact that Maka ships no local bridge at all: it defines an HTTP contract and expects a third-party process to implement it. Wrote that contract down as a table — /health, the SSE receive loop and its since cursor, POST /send, and the QR endpoints — then exercised Maka's own WechatBridge against a mock implementing it. Driving the real class rather than a reimplementation is what makes the exercise mean anything: every mapping and error decision under test is the shipped code. All six inbound filter rules pass, including the group-without- mention drop that most looks like a broken bridge, plus bearer auth and both send paths, with the failure diagnostic surfacing as the status reason. Building the mock is what surfaced /health, which the streaming loop alone does not reveal: start() probes it first for identity and to gate sending, and a bridge missing it never reaches the receive loop. The verification note is explicit that this covers the contract and not WeChat itself, and that the iLink route stays unverified because its token only comes from the in-app QR flow. Also records that the settings UI collects Official Account App ID and Secret which no code path in the bridge reads. With this the setup guide has no placeholders left. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 95 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 10 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index e39cdb9d71..8080b71b58 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -222,8 +222,11 @@ longer schedule would silently no-op. not restrict who can talk to the bot. See the security section. 3. **Scheduled tasks cannot target Feishu or WeCom** (`BOT_DELIVERY_PROVIDERS`). 4. **No platform can send files.** Text out only. -5. **WeChat requires a local companion bridge** on localhost; it cannot run - against a remote bridge host. +5. **WeChat requires a local companion bridge that Maka does not ship.** The + runtime defines an HTTP contract and expects a third-party process on + localhost to implement it; remote bridge hosts are rejected outright. Group + messages reaching that bridge are dropped unless the frame marks the bot as + mentioned. 6. **WeCom credentials cannot be pre-verified.** The credential test validates shape and non-emptiness only and returns `verified: false`, because the SDK proves the credentials only through a WebSocket auth handshake (see @@ -270,14 +273,16 @@ traffic. ## Setup -> **Status: Telegram, Slack, Discord, Feishu/Lark, WeCom and QQ are verified -> end-to-end.** DingTalk is verified for setup and receive only — replying to a -> 1:1 conversation is broken, and the section records the defect. WeChat is -> still a placeholder and must be walked against a real setup before it lands. +> **Verification status.** Telegram, Slack, Discord, Feishu/Lark, WeCom and QQ +> were each walked end-to-end against a real account — connect, receive, send. +> DingTalk is verified for setup and receive only; replying to a 1:1 +> conversation is broken and that section records the defect. WeChat is +> verified against its local-bridge *contract* using a mock, not against WeChat +> itself; its section states exactly what that does and does not cover. > -> For WeChat, check -> [the onboarding architecture doc](architecture/bot-onboarding-runtime.zh-CN.md) -> first: QR-code onboarding may make most manual steps unnecessary. +> For DingTalk and WeChat, also check +> [the onboarding architecture doc](architecture/bot-onboarding-runtime.zh-CN.md): +> QR-code onboarding may make most manual steps unnecessary. Each platform section should cover: where to register, which console fields map to which Maka setting (see the credentials table above), the minimum scopes or @@ -692,7 +697,77 @@ value as a reply target, which is why this channel has no reply threading. ### WeChat 微信 -_TODO — iLink bot plus the local companion bridge on 127.0.0.1._ +WeChat has no ordinary bot API, so this channel supports two quite different +routes and picks between them from the value of `webhookUrl`: + +| | **iLink** | **Local bridge** | +| --- | --- | --- | +| Selected when | `token` set **and** `webhookUrl` host is `ilinkai.weixin.qq.com` | anything else (a localhost `http://` URL) | +| Receive | `POST /ilink/bot/getupdates`, long-poll with a cursor | `GET /messages/stream?since=`, SSE | +| Send | `POST /ilink/bot/sendmessage` | `POST /send` with `{wxid, text}` | +| Auth | bot token | `Authorization: Bearer ` | +| Onboarding | QR sign-in, see [the onboarding doc](architecture/bot-onboarding-runtime.zh-CN.md) | QR served by the bridge itself | + +**Maka does not ship the local bridge.** It defines the HTTP contract and +nothing else — there is no bundled process, no submodule and no install step. +Running personal WeChat this way means supplying a third-party process that +implements the shape below. The `wxid` field in the send body is the tell: that +is the identifier unofficial WeChat protocol libraries use. + +The client requirement is **WeChat 8.0.70 or newer on iOS or Android**, and the +in-app hint notes that the bridge's *send* capability needs a `wxp_act_` +activation code, so a bridge can legitimately be receive-only. + +**The contract a local bridge must satisfy:** + +| Endpoint | Purpose | +| --- | --- | +| `GET /health` | Probed before anything else. Returns `wxid`, `alias`, `nickname` (or the same keys nested under `self`) for identity, plus `send_status`; the values `unavailable` and `blocked` mark sending as unsupported. | +| `GET /messages/stream?since=` | SSE. One JSON object per `data:` event. The cursor advances to the newest delivered message. | +| `POST /send` | Body `{wxid, text}`. Reply `{status:"ok", messageId}` on success. `{status:"failed", diagnostic}` surfaces `diagnostic` as the channel's status reason. | +| `GET /api/weixin/qrcode` or `/qrcode` | QR payload for sign-in. | + +Every call carries `Authorization: Bearer ` when the channel has a token. +The returned message ID is read from `messageId`, then `id`, then `svrId`, +falling back to the literal `wechat-submitted`. + +**Inbound frames are matched loosely but filtered strictly.** Field aliases are +accepted — `chatId | roomId | toWxid | talker` for the conversation, +`senderId | fromWxid | sender | wxid` for the sender, `messageId | msgId | id | +svrId` for the ID, `body | text | content | message` for the text — but a frame +is dropped when: + +- `fromSelf` or `isSelf` is true; +- no message ID resolves from any of the four aliases; +- there is neither text nor a recognised attachment; or +- it is a **group message without a mention**. A conversation counts as a group + when `isGroup`/`is_group` is set or the chat ID ends in `@chatroom`, and the + mention must be signalled by `isMentioned`, `isAt` or `atMe`. + +That last rule is the one most likely to look like a broken bridge: group +traffic is silently discarded unless the frame explicitly marks the bot as +mentioned. + +**The local bridge URL must be localhost.** `normalizeWechatBridgeUrl` accepts +only plain `http:` on `127.0.0.1`, `localhost` or `[::1]`, rejects URLs longer +than 256 characters, and strips any query or fragment. A remote bridge is +refused by design. + +> **Verified against the contract, not against WeChat.** Maka's `WechatBridge` +> was exercised end-to-end against a mock local bridge implementing the table +> above: the `/health` probe and identity, the SSE receive loop and its cursor, +> all six mapping rules including each drop case, bearer auth on every call, +> and both the success and `{status:"failed"}` send paths — with the channel +> moving `credentials_valid → operational` and the bridge diagnostic surfacing +> as the status reason. No real WeChat account was involved, so message +> delivery through WeChat itself remains unverified, as does the iLink route, +> whose token is only obtainable through the in-app QR flow. + +> **The Official Account fields are collected but unused.** The settings UI +> exposes an Official Account App ID and App Secret, described there as being +> for Official Account messaging. `wechat-bridge.ts` never reads `appId` or +> `appSecret` — every code path uses `token` and `webhookUrl` only. Filling +> them in changes nothing about this channel's behaviour. ### QQ From 0c11b67ee796989fa5c5653bd9c7318bf1b5d63d Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:52:22 +0800 Subject: [PATCH 11/11] docs: refresh status reasons and link the DingTalk fix Rebased onto current main, which renamed the per-channel status reasons to snake_case. Updated the three the guide quotes: missing-slack-tokens to slack_tokens_missing, missing-feishu-credentials to feishu_credentials_missing, and the generic no-credentials to wecom_credentials_missing now that each channel carries its own code. Re-derived the capability matrix against the new tree; every row still maps to the same bridges, and the load-bearing constants are unchanged (Discord's 2000-character limit, Telegram's 4000 UTF-16 units and message-only allowed_updates, QQ's fatal 4004/4014 closes, Slack promoting to operational on connect). Points the DingTalk defect at apache/maka#5112, which replaces the prefix guess with chat IDs stamped at receive time. That fix is not on main yet, so the section still documents the broken behaviour and says what to rewrite once it lands. Refs #3894 Co-Authored-By: Claude Opus 5 --- docs/bot-platforms.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 8080b71b58..7b01fad634 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -240,7 +240,8 @@ longer schedule would silently no-op. carries — including direct ones — so direct replies are posted to the group endpoint and rejected. The 1:1 endpoint needs the payload's `senderStaffId`, which the bridge never captures. Reproduced against a live app, along with - the send that does succeed; see the DingTalk setup section. + the send that does succeed; see the DingTalk setup section. A fix is open as + apache/maka#5112. ## Security considerations @@ -446,7 +447,7 @@ bot's own configuration hints at why. **8. Verify.** Startup calls `auth.test()` to resolve identity, then opens the Socket Mode connection. Missing either token short-circuits startup with -`missing-slack-tokens`. +`slack_tokens_missing`. **Slack reaches `operational` on connect**, unlike every other channel — the bridge promotes it as soon as the socket reports `connected`, without waiting @@ -558,6 +559,13 @@ retries forever instead of stopping with a diagnosable reason. > `appId`, so the bridge's reuse of `appId` as the robot code is correct. Group > replies were not exercised, so whether the group path works with a genuine > group conversation ID remains unverified. +> +> **A fix is in flight.** apache/maka#5112 replaces the prefix guess with chat +> IDs stamped explicitly at receive time — `oto:` routes to the +> 1:1 endpoint and `group:` to the group one, with unprefixed +> IDs staying on the group endpoint so already-persisted scheduled-task targets +> keep working. This section describes the behaviour on `main` and should be +> rewritten around that scheme once it merges. ### Feishu 飞书 / Lark @@ -616,7 +624,7 @@ allowlist locally to catch group messages too. **7. Verify.** The channel should reach `operational`. If it does not, the reason string on the status distinguishes the failure: -`missing-feishu-credentials` means `appId` or `appSecret`/`token` is empty, +`feishu_credentials_missing` means `appId` or `appSecret`/`token` is empty, while a handshake failure leaves readiness at `configured` with the underlying error as the reason. @@ -669,7 +677,7 @@ Secret once the socket is open. WeCom only reaches `operational` after the first message is actually sent or received. -Failure reasons distinguish the cases: `no-credentials` means `appId` or +Failure reasons distinguish the cases: `wecom_credentials_missing` means `appId` or `appSecret` is empty, and an authentication failure leaves readiness at `configured`. The handshake has a 15-second timeout.