0120 — Feishu / Lark, DingTalk and QQ: the chat apps of China's workplaces, outward only
Status: accepted · 2026-10-09
- Status: accepted
- Date: 2026-10-09
- Amends: ADR 0018 (three more apps, the same shape), ADR 0045 (beside WeChat and WeCom)
Context
Companies in China talk in Feishu (飞书, Lark outside China), DingTalk (钉钉) and QQ, besides WeChat and WeCom (ADR 0045). What each offers a bot now (checked 2026-10-09 against the vendors' own documentation and SDKs):
- Feishu / Lark. A custom app (企业自建应用) with the Bot ability.
- Its long connection delivers events and card callbacks over a WebSocket the app opens:
POST /callback/ws/endpointwith the App ID and App Secret gives an address and a ping interval. Frames are protobuf (pbbp2.Frame:SeqID,LogID,service,method0 control / 1 data,headers,payload), long payloads in parts (sum,seq), each answered within 3 s with{"code":200,"data":<base64>}. Bad keys give 1000040345. Sources: long connection, oapi-sdk-gows/. - Card button callbacks (
card.action.trigger) come over the same connection; the answer can carry a toast, and the card is changed later withPATCH /open-apis/im/v1/messages/:idwhen it hasupdate_multi. Source: callback over long connection, card callbacks. - Messages:
im.message.receive_v1(p2porgroup; a group only delivers @mentions withim:message.group_at_msg:readonly), sendingpostwith anmdelement (the recommended Markdown, 30 KB), images 10 MB, files 30 MB, resources downloaded per message. Sources: receive, create, content. - Making the app by scanning a code: Feishu's own Node SDK registers an app with the device flow of RFC 8628 (
POST accounts.feishu.cn/oauth/v1/app/registration,archetype=PersonalAgent), with the scopes, events and callbacks filled in, and hands back its keys and the scanner'sopen_id. The subscription mode can't be filled in. Source: node-sdkscene/registration. - Lark is the same API on
open.larksuite.comandaccounts.larksuite.com.
- Its long connection delivers events and card callbacks over a WebSocket the app opens:
- DingTalk. An internal app (企业内部应用) with a robot in Stream mode:
POST api.dingtalk.com/v1.0/gateway/connections/opengives an endpoint and a one-time ticket; JSON frames (SYSTEMping and disconnect,CALLBACKon/v1.0/im/bot/messages/get), each acknowledged. Robots send withoToMessages/batchSendandgroupMessages/send(sampleMarkdown, under 15,000 bytes), take media fromoapi.dingtalk.com/media/upload, and fetch received files by download code. Voice messages carry DingTalk's own transcript. The built-in interactive card closed to new apps at the end of 2024; newer cards need a template from the card platform. The free plan allows 5,000 robot messages a month per organisation (error 20001). Sources: Stream protocol, receive, one-on-one, group, StandardCard closed, billing. - QQ. A bot on QQ's bot platform. Its WebSocket gateway is current and recommended for "an AI agent on one computer" (no deprecation on any current page; the webhook needs a public HTTPS address on 80, 443, 8080 or 8443). Hello, Identify (
GROUP_AND_C2C_EVENT1<<25,INTERACTION1<<26), heartbeats, Resume; close 4914 is a bot that isn't live.C2C_MESSAGE_CREATE,GROUP_AT_MESSAGE_CREATE(groups deliver @mentions only),INTERACTION_CREATE(must be acknowledged withPUT /interactions/:id),FRIEND_ADD. Markdown is open to every bot since 2026-04-23; custom buttons are opened by invitation; replies to a message are allowed for 60 minutes in private (4) and 5 in a group (5); messages can't be edited. Voice comes withasr_refer_textand a WAV. Unverified bots work only for their owner and up to 20 test accounts. A quick page (q.qq.com/qqbot/openclaw/) makes a bot with one scan. Sources: WebSocket, product guide, Markdown, buttons, C2C send, changelog.
OpenClaw and Hermes Agent reach all three through plugins: keys typed into a config, little checking until something fails silently (a card callback not published, a sandbox-only bot, the monthly allowance), approvals missing or off by default on QQ and DingTalk.
Decision
Three built-in channels, each in channels/<app>.ts with a pretend app in channels/mock/<app>.ts, implementing ChannelAdapter like every other. Every one connects outward from this computer, so none uses the public door, and nothing is opened to the internet.
- Feishu / Lark is one card with a region choice (
ChannelSecretsfeishu:region,appId,appSecret). Its long connection is spoken directly (feishu-frame.ts, a few dozen lines of protobuf instead of a dependency). Answers arepost+md; a question with buttons is a JSON 2.0 card whose press is answered within Feishu's three seconds with a toast saying what happened, and the card is then changed in place to say what was decided. Opening the chat with the bot for the first time is a hello. Pictures, files and voice messages (Opus, heard by Conch, ADR 0077) both ways; groups when mentioned or replied to.- The quick way is a code to scan (
feishu-register.ts,POST /api/channels/feishu/scan): Feishu makes the app with what Conch needs, the keys stay on the gateway, and whoever scanned the code Conch showed on its own page is the owner, as a Telegram link's code is (ADR 0018). Showing a code needs a recent confirmation from another device, like connecting a bot. The two switches only the console has (the long connection, publishing) are steps of their own; by hand, the permissions to import and the events to add are written out to copy.
- The quick way is a code to scan (
- DingTalk (
clientId,clientSecret; the Client ID is the robot's code). Stream mode, frames acknowledged at once, pings answered with their own data,disconnecta new ticket at once, a silent socket replaced. Answers aresampleMarkdown(code and tables as quoted lines), to a person by staff id or a group by conversation, the conversation's own reply address as the fallback before the robot is published. Approvals are numbered answers: a template from the card platform is a step too far for a person, so the question reads "Reply with a number", and what was decided comes as a new message. DingTalk's own transcript of a voice message is used; pictures and the file kinds DingTalk takes go out, others are named and refused. The monthly allowance running out is the channel's state, in words. - QQ (
appId,appSecret). The gateway with Resume after a drop, a fresh token and session on 4004, and plain words for a bot that isn't live (4914) or is banned (4915). Answers are Markdown (plain text where QQ refuses it), sent as replies while QQ allows and as the bot's own messages after. Approvals are numbered and carry callback buttons; a bot QQ hasn't opened buttons to falls back to the numbers by itself. Adding the bot as a friend is the hello. QQ gives each person a different id in a group than in private and never a name, so in a group everyone is a guest (ADR 0075), the owner too; the greeting to a person QQ doesn't name says no made-up name. - The shared pieces stay small and additive: three
ChannelKinds and secret shapes,ChannelBot.accountgainsfeishuandlark, threeChannelFields, a key on its own for each inReplaceChannelTokenBody, catalog entries,adapterForcases,normalizeSecretslines,ChannelService.createScanned(a channel whose owner scanned its code). Brand marks are in Nacre'sbrands.ts: QQ from Simple Icons, DingTalk from Remix Icon (Apache 2.0), Feishu drawn after its mark (Simple Icons has none).
What leaves the computer
Only calls to each vendor's own API, outward, with the person's own app's keys:
| App | Hosts |
|---|---|
| Feishu (mainland China) | open.feishu.cn (API and long connection), accounts.feishu.cn (making the app by scan) |
| Lark (international) | open.larksuite.com, accounts.larksuite.com |
| DingTalk (mainland China) | api.dingtalk.com (token, Stream, sending), oapi.dingtalk.com (media upload), DingTalk's own file storage for downloads |
| QQ (mainland China) | api.bot.qq.com (token, API, gateway), QQ's own media hosts for downloads |
Files are only ever fetched from the app's own hosts. Keys never appear in a logged message or an address (except DingTalk's media upload, whose older API takes the token in its query, which is never logged).
Security
As for every bot: nobody gets in until That's me or Let in (or, on Feishu, scanning the code Conch showed); strangers get one polite reply and show as a request; groups are off until turned on and only mentions are read; approvals go to the owner's private chat, and a press from anyone not let in is refused. Every delivery is deduplicated (a long connection redelivers what wasn't acknowledged). Keys live in the sealed channels.secrets.json, are listed in Passwords, and a key the app stops taking stops that channel only (needs-token).
Consequences
- Feishu still needs two switches in its console, and an admin's approval of the published version in a company.
- DingTalk has no buttons until Conch can make a card template for the person; its free plan's 5,000 messages a month are shared by the organisation.
- QQ's buttons depend on QQ's invitation; until the person verifies their identity, only they (and 20 test accounts) can use the bot.
- Not confirmed and handled defensively: Feishu's exact code for a wrong App Secret (10015 and 1000040345 are both read as one), DingTalk's
sampleImageMsgtaking a media id, and QQ's longest message (answers are cut at 3,000 characters). - Tested against pretend apps that speak each protocol (protobuf frames in parts, Stream frames, the QQ gateway's opcodes and close codes), including reconnecting, refused keys, approvals round trip, groups, strangers and files; an e2e journey (
channels-china) runs all three.