Skip to content

docs: add bot platform support matrix - #4787

Open
ying-hua wants to merge 11 commits into
apache:mainfrom
ying-hua:doc/bot-platform-matrix
Open

docs: add bot platform support matrix#4787
ying-hua wants to merge 11 commits into
apache:mainfrom
ying-hua:doc/bot-platform-matrix

Conversation

@ying-hua

@ying-hua ying-hua commented Sep 4, 2026

Copy link
Copy Markdown

Refs #3894.

Adds docs/bot-platforms.md covering the eight IM bot channels registered in BOT_PROVIDERS, plus an index entry in docs/README.md.

What's in it

  • Platform list. Eight channels, not nine — Feishu and Lark share the feishu channel and are separated by the domain setting rather than by provider.
  • Maturity model. Reuses the existing BotReadinessState enum instead of inventing a parallel vocabulary.
  • Feature matrix. Ten capability rows across all eight platforms, each traced to the method or constant that backs it.
  • Transport, credentials, message limits, reconnect/retry per platform.
  • Setup guide per platform, plus known limitations and security considerations.

Claims are cited by symbol name rather than line number, so the references survive refactors.

Verification

Every setup section was walked against a real account rather than written from the code alone. For each platform the probe mirrored the bridge's own code path — same SDK version where one is used, same constructor options, same endpoints — so a passing probe means the shipped bridge would connect.

Platform Connect Receive Send
Telegram
Slack
Discord
Feishu/Lark
WeCom
QQ
DingTalk ❌ see below
WeChat contract only contract only contract only

WeChat is the one channel with no real-account coverage. Maka ships no local bridge — it defines an HTTP contract and expects a third-party process to implement it — so the guide writes that contract down and verifies Maka's own WechatBridge class against a mock implementing it. Driving the real class rather than a reimplementation is what makes it meaningful. Building the mock is also what surfaced GET /health, which start() probes before anything else and which reading the streaming loop alone does not reveal. The section states plainly that WeChat delivery itself is unverified, as is the iLink route.

Things the walkthrough found that reading the code did not

  1. Slack silently refuses DMs until App Home → Messages Tab is enabled. Scopes, event subscriptions and the socket all look correct, message.im simply never fires, and nothing in the app's configuration explains why.
  2. DingTalk cannot reply to a 1:1 conversation. Route selection tests the chat ID for a cid prefix, which every DingTalk conversation ID carries, so direct replies go to the group endpoint and are rejected. Reproduced as a three-way controlled send, which also showed the working target is the payload's senderStaffId — a field DingTalkBotMessagePayload never captures. Fixed by fix(runtime): route DingTalk 1:1 replies by staff id #5112; the section documents main's behaviour and says what to rewrite once that merges.
  3. Discord's MESSAGE CONTENT INTENT has a quiet failure mode. Besides closing the gateway with 4014, it can leave messages arriving with every content empty, so the bot looks connected and awake while replying to nothing.
  4. Telegram bots are blind in groups by defaultgetMe reports can_read_all_group_messages: false.

Gaps worth separate issues

Flagging rather than fixing, since this PR is documentation only.

  1. allowedUserIds is enforced by three of eight bridges. Telegram, Feishu and WeCom check it. Discord, QQ, DingTalk, Slack and WeChat never read the field, so an allowlist configured there silently does nothing.
  2. No platform can send file attachments. SendCapable is text-only.
  3. Feishu and WeCom cannot receive scheduled-task deliveries.
  4. WeChat's settings UI collects Official Account App ID and Secret that no code path in the bridge reads.
  5. Proxy coverage is uneven. proxyUrl reaches REST but not gateway sockets, and the Slack channel ignores it entirely. Not reproduced as a failure here — a system-level proxy masks it — so it is recorded conditionally.

Checks

  • npm run check:asf-headers passes.
  • npm run format:check — the only failure is a pre-existing .claude/launch.json issue on main, untouched by this branch.
  • Rebased onto current main; the capability matrix was re-derived against it and the renamed snake_case status reasons picked up.
  • No code changes; documentation only.

🤖 Generated with Claude Code

@github-actions github-actions Bot added effort/M Under 500 readable lines effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 4, 2026
ying-hua and others added 11 commits September 9, 2026 22:49
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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#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 apache#3894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ying-hua
ying-hua force-pushed the doc/bot-platform-matrix branch from 88ec1b6 to 0c11b67 Compare September 9, 2026 14:52
@ying-hua
ying-hua marked this pull request as ready for review September 9, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant