feat(wecom): WeChat Work (企业微信) smart-bot integration via the Channel engine#5833
Open
leroy-chen wants to merge 16 commits into
Open
feat(wecom): WeChat Work (企业微信) smart-bot integration via the Channel engine#5833leroy-chen wants to merge 16 commits into
leroy-chen wants to merge 16 commits into
Conversation
|
@leroy-chen is attempting to deploy a commit to the IndexLabs Team on Vercel. A member of the Team first needs to authorize it. |
leroy-chen
force-pushed
the
pr/wecom-smart-bot
branch
3 times, most recently
from
July 24, 2026 08:18
0fefdc3 to
b7d32de
Compare
Introduces server/internal/integrations/wecom as the WeChat Work
smart-bot ("智能机器人" / aibot) adapter for the channel-agnostic
inbound engine (MUL-3620). Each installation carries a (bot_id, secret)
pair; after the WebSocket handshake to wss://openws.work.weixin.qq.com
the client subscribes with aibot_subscribe and thereafter receives
aibot_msg_callback events and sends aibot_send_msg / aibot_respond_msg
over the same socket. No public callback URL is required — the client
initiates the connection.
Design notes:
- One installation = one bot = one WebSocket. WeChat allows only one
active connection per bot (a second connection kicks the first with
a disconnected_event), which lines up with engine.Supervisor's WS
lease so the multi-replica invariant already holds.
- The per-installation read loop lives inline in wecomChannel.Connect
(slack_channel.go pattern) rather than behind a shared connector
abstraction (lark/ws_connector.go pattern). The aibot protocol has
no bootstrap fetch, no chunk assembly, and no protobuf frames, so
the inline shape is clearer.
- Watchdog goroutine bridges ctx.Done() → conn.Close() → ReadMessage
err so lease loss / shutdown tears the socket down in bounded time
even on a silent socket (same invariant §4.4 the lark connector
documents).
- 30s heartbeat via aibot ping through a mutex-serialized writer
(gorilla forbids concurrent writes).
- Outbound iter 1 always uses aibot_send_msg (proactive push) — the
5s aibot_respond_msg window is deferred to iter 2. chat_type is
discriminated by ChatID length (userids are short, chatids ≥33
chars), same convention the internal customer-service adapter used.
- installConfig stores {app_id, bot_id, secret_encrypted} where
app_id == bot_id so the shared idx_channel_installation_type_appid
index continues to route inbound events.
- ResolverSet mirrors the callback-mode design: implicit email-
prefix identity binding, shared channel_inbound_message_dedup two-
phase idempotency, shared engine.ChatSession.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the new WeCom smart-bot adapter into the server:
- New POST /api/workspaces/{id}/wecom/install/byo endpoint takes
{bot_id, secret} and seals the secret via secretbox before writing.
- GET .../wecom/installations lists installations (member-visible),
DELETE .../wecom/installations/{id} revokes them (admin-only).
- WecomStore + WecomCredentials on Handler are the two knobs the
Web UI + inbound loop both need; nil = integration disabled → 503.
- Boot wiring in router.go: gated by MULTICA_WECOM_SECRET_KEY, mirrors
the Slack block — build a secretbox, register the wecom Factory on
the shared channel.Registry, register the ResolverSet on the shared
engine.Router with a wecom-flavored engine.ChatSession (Chinese
product voice: "企业微信群聊" etc.).
- protocol.EventWecomInstallation{Created,Revoked} for realtime
invalidation on the frontend.
The RegisterWecomBYORequest is deliberately narrow (bot_id + secret) —
smart-bot needs no CorpID, ServiceID, callback token, or AES key. The
response therefore has no callback_url either (the client initiates the
WebSocket to WeCom, not vice versa).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires MULTICA_WECOM_SECRET_KEY through docker-compose.selfhost.yml and documents it in .env.example. The server reads this key at boot to unseal a smart-bot installation's subscribe credentials; installations themselves are created from the WeCom settings tab in the app. Unset = integration disabled.
Adds the frontend counterpart to the smart-bot install endpoints: - WecomInstallation / ListWecomInstallationsResponse / RegisterWecomBYORequest types mirror WecomInstallationResponse and RegisterWecomBYORequest on the backend. Two fields on the register request (bot_id + secret) — no callback_url on the response since the smart-bot flow has no public callback. - wecomKeys + wecomInstallationsOptions wrap the list endpoint in a TanStack Query key namespace, exported as @multica/core/wecom. - api.listWecomInstallations / registerWecomBYO / deleteWecomInstallation on the shared api client. - Realtime sync invalidates wecomKeys.installations(wsId) on any wecom_installation:* event so the Settings tab updates without a manual refetch when another tab or an admin on another machine connects or disconnects a bot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders the smart-bot install flow in Settings → Integrations and on each Agent's Integrations tab. Structurally identical to SlackTab — list connected bots + a per-agent "Connect WeChat Work" dialog that opens a BYO form. The BYO dialog collapses to two inputs: Bot ID + long-connection Secret. There is no callback URL panel (the smart-bot dials WeCom, not vice versa), no console-token / AES-key fields, and no "next step" copy step — everything the operator needs is on the bot's admin page. Existing tests get two new mocks each (@multica/core/wecom, ../wecom-tab / ./wecom-tab) plus the members-note assertion switched to getAllByText so the two admin-only sections (Slack + WeCom) can both render it side by side. i18n keys added under settings.wecom.* in en/zh-Hans/ja/ko. Chinese copy follows conventions.zh.mdx — "企业微信"、"智能机器人"、"长连接 Secret" etc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The agent detail page's Integrations tab was gated behind larkListing?.configured || slackListing?.configured, so a deployment that only wires MULTICA_WECOM_SECRET_KEY (no Lark, no Slack) never saw the tab and had no UI path to bind a smart bot to an agent. Add wecomListing?.configured to the same OR so the tab appears whenever any of the three integrations is configured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The engine.Supervisor already knows each installation's id at build time, but the current channel.Config passes only (Type, Raw, Handler). Add an ID field so a Factory whose Channel needs to correlate itself with an external boot-time actor (e.g. wecom's OutboundReplier looking up the live wsSender in a per-installation registry) can pick that id up without duplicating the config-blob parse. Zero-value tolerated: nothing in tree relies on it being Valid today, but the two adapters that don't need it (Feishu, Slack) simply ignore the field. The wecom Channel uses it to key its wsSender registry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the identity problem the callback-mode adapter design papered over: aibot smart-bot events carry an anonymized T-prefixed userid that has NO relationship to a user's real enterprise userid or email, so the previous "match email local-part" heuristic never worked. Every first-time WeCom sender was silently dropped as unbound_user. Replace it with the same explicit binding flow Slack uses (MUL-3666): - wecom.BindingTokenService (binding.go) mints single-use tokens into the shared channel_binding_token table (channel_type='wecom'), then redeems them transactionally against channel_user_binding. Reuses the generic sqlc queries created by migration 124 — no new table, no new migration. - identityResolver.ResolveSender (wecom_resolvers.go) now looks up GetChannelUserBindingByUserID(installation_id, wecomUserID) and returns engine.ErrSenderUnbound on miss, which the Router pairs with the OutboundReplier's binding-prompt path. - wecom.OutboundReplier (replier.go) handles NeedsBinding / AgentOffline / AgentArchived / IssueCreated by pushing a text message back over the same aibot WebSocket the inbound loop owns. - sendersRegistry (senders_registry.go) is the small process-wide map installation_id → live wsSender that lets the boot-time Replier reach a running Channel's socket. wecomChannel.Connect self-registers on entry and clears on exit; wecomChannel.Send and OutboundReplier.post both look up through it. aibot has no REST outbound path — every write goes over the WebSocket — so the sendersRegistry is what makes the boot-time / per-install seam work. Slack keeps its outbound REST path so its Replier just opens a fresh client; wecom cannot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the WeCom binding token service into HTTP + the channel engine: - POST /api/wecom/binding/redeem redeems a raw token from the bot's binding prompt against the logged-in session user (identity taken from session, not the token — a stolen token can never bind to an attacker). Failure status codes: 410 Gone (invalid/expired), 409 Conflict (already bound to another user), 403 Forbidden (redeemer not a workspace member). - Handler.WecomBindingTokens holds the *BindingTokenService. - router.go wires: BindingTokenService, sendersRegistry, and OutboundReplier. The registry is passed into both wecom.ChannelDeps (write side, the Channel self-registers on Connect) and the Replier (read side). The ResolverSet now carries the Replier so needs_binding messages get delivered instead of silently dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Web-app landing page for the smart-bot's "link your Multica account" magic link. Same shape as SlackBindPage — requires the user to be signed in (the redeemer's identity comes from the session), then POSTs the token to /api/wecom/binding/redeem. Auto-redeems on mount; no button to click. HTTP status codes map to distinct copy (missing token / expired / already bound / not a workspace member). - packages/views/wecom/bind-page.tsx: the shared page - apps/web/app/wecom/bind/page.tsx: the Next.js route - @multica/views/wecom export in packages/views/package.json - RedeemWecomBindingTokenResponse type + api.redeemWecomBindingToken - wecom_bind i18n namespace in all four locales Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The aibot smart-bot API accepts msgtype=text ONLY on aibot_respond_msg
(the 5s in-window reply cmd). On aibot_send_msg — the proactive-push
cmd we use for every outbound message iter 1 — the supported msgtypes
are markdown and template_card only. Server silently ack'd every text
send with a non-zero errcode; the client dropped the ack because the
read loop's default case swallowed anonymous ack frames without
inspecting env.ErrCode. Net effect: user sends "hi" → server dispatches
needs_binding → Replier posts the binding-prompt "text" → server
rejects → client-side nothing happens → user thinks the bot is dead.
Fix:
- sendMsgTextBody now ships as {msgtype: markdown, markdown: {content}}.
The WeChat client renders plain text through the markdown path with
no special escaping, so the outbound API is unchanged from the
caller's perspective.
- The read loop's default case now logs env.ErrCode/ErrMsg on any
non-zero server ack. Future outbound errors are visible in the log
instead of vanishing into the socket.
Symptom trace: this branch's rev 3de20b09 log shows
outcome=needs_binding drop_reason=unbound_user
with no matching Replier log entry — because Replier.post's WriteMessage
succeeded (from gorilla's POV) and the rejection came back as a plain
ack frame the read loop discarded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The engine.OutboundReplier only handles route-level outcomes (needs_binding, agent_offline, agent_archived, issue_created). An agent's actual chat reply lands on the shared bus as EventChatDone, and Slack has a dedicated Outbound subscriber that catches it and posts back through the platform (slack/outbound.go). WeCom was missing this hook, so a bound user's agent reply was silently dropped — the Multica web UI showed it, but the WeChat Work client saw nothing. Adds wecom.Outbound: on EventChatDone it looks up the wecom chat_session binding (GetChannelChatSessionBindingBySession, generic sqlc — no new query needed), resolves the live wsSender through the shared registry, and pushes the reply as aibot_send_msg. Same markdown msgtype the OutboundReplier uses, so plaintext renders without escaping. router.go wires the subscriber inside the wecom-integration-enabled block so it registers only when the smart bot is configured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wecom resolver stamps issues created via the smart-bot /issue command
with originWecomChat="wecom_chat" (wecom_resolvers.go:31, matching the
"<platform>_chat" convention shared with lark_chat and slack_chat), but
the issue_origin_type_check CHECK constraint's whitelist did not include
it — so /issue in WeChat Work failed at INSERT time with SQLSTATE 23514,
which the router surfaced as a dispatch error and the user saw as
"nothing happened".
Symptom trace (branch feat/wecom-smart-bot commit 74e912010):
channel router: dispatch error channel_type=wecom
error="create issue from command: create issue: ERROR: new row for
relation \"issue\" violates check constraint
\"issue_origin_type_check\" (SQLSTATE 23514)"
Add wecom_chat to the whitelist. Same one-liner migration shape as
111 (lark_chat), 131 (slack_chat), and 149 (agent_create). Down script
reverts to the pre-wecom_chat list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
An /issue command on WeChat Work would:
1. Create the issue (engine.createIssue) ✅
2. Send "✅ 已创建 #N" via OutboundReplier ✅
3. Still schedule an agent run on the message — the agent then sees
"/issue foo bar", doesn't recognize it as a skill, and replies
"我这边没有 /issue 这个可用的斜杠命令 …" which clutters the
conversation with a wrong-sounding response right after the
legitimate creation confirmation.
The engine's /issue path is intentionally shared cross-platform, but
each platform can opt this message out of the agent turn. Add
channel.InboundMessage.SkipAgentRun (adapter-set, core reads only) and
gate the router's scheduleRun on it. wecom's channelMessageFromCallback
sets it true whenever the message body is a pure /issue command
(front-of-body /issue token, matching engine.ParseIssueCommand's own
detection). chat_message is still durable, OutboundReplier still fires,
only the debounced run trigger is suppressed.
Slack and Lark leave SkipAgentRun false, preserving their historical
"let the agent see /issue and respond too" behaviour. If that turns out
to be equally undesirable there, it's a one-line change in each
adapter's normalization.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The engine's Router.createIssue called IssueService.Create with an empty
IssueCreateOpts, so publishIssueCreated fell back to the stub payload
{"issue_id": ...}. The subscriber listener type-asserts on payload["issue"]
to auto-subscribe the creator + assignee; the missing key short-circuited
that listener and left issue_subscriber with only the agent (added later
as a "commenter" when the agent posted its first reply). With no member
subscribers, every downstream status_changed / new_comment notification
was filtered as "no subscribers" — the creator got zero inbox items and
wecom_notify (which listens on inbox:new) never fired.
Build the same map-shape payload autopilot uses (mirrors service.issueToMap,
kept private to that package). Every cross-platform /issue — wecom, lark,
slack — now broadcasts the full issue field set the subscriber listener
needs, so the creator lands in issue_subscriber at create time and receives
inbox notifications when the agent works the issue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an inbox:new subscriber on wecom.Outbound so a member who has a WeCom smart-bot binding gets their inbox notifications pushed through the aibot WebSocket as a markdown card. Delivery path: look up (workspace_id, member_id, channel_type='wecom') -> resolve the live wsSender for the binding's installation -> send an aibot_send_msg markdown card carrying the notification title, body, and a '查看详情' link. On any miss — non-member recipient, no wecom binding, no live sender, or a send failure — the handler is a no-op and the member still receives the notification through the in-app inbox as usual. Ships: - FindChannelBindingForMember sqlc query (workspace+member+channel_type, most-recently-bound wins, active installation only) - wecom/inbox_message.go — markdown body builder + link resolver with rune-safe truncation for Chinese content
leroy-chen
force-pushed
the
pr/wecom-smart-bot
branch
from
July 24, 2026 09:59
b7d32de to
3752de4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a first-class WeChat Work (企业微信) smart-bot ("智能机器人" / aibot) integration so a user can talk to a Multica agent directly from a WeCom chat and get streaming replies back over the same connection. It is built on the existing unified Channel engine (
channel.Channel/engine.Supervisor) that already backs Slack and Lark — there is no bespoke supervisor.Capabilities:
EventChatDone./issuecommand inside a WeCom chat creates an issue (stampedorigin_type='wecom_chat') without triggering an agent run.inbox:newitems are pushed to the bot as a markdown card (no-op when unbound; the in-app inbox is unchanged)./wecom/bind).Relationship to #4128
This is an alternative to #4128 (by @sexylowrie), which implemented the same WeChat Work smart-bot feature but with its own per-bot WebSocket supervisor and dispatcher, outside the unified Channel abstraction. The maintainers declined that approach and asked for a version built on the
channel.Channel/engine.Supervisorpattern used by Slack and Lark.This PR is that version:
engine.Supervisorlike any other channel; inbound routing goes through the sharedchannel/engine/router.go, not a parallel dispatcher.channel_installationand are managed through the same install / list / revoke surface the other channels use.One deviation to flag up front: the review note on #4128 asked for "no DB migrations." This PR includes exactly one migration —
203_issue_origin_wecom_chat— which only extends the existingissue.origin_typeCHECK constraint to allow'wecom_chat', mirroring111_issue_origin_lark_chatand131_issue_origin_slack_chat. Without it,/issuefrom WeCom would violate the constraint exactly as it did historically for Lark/Slack (see those migrations' notes). Happy to discuss alternatives if you'd prefer to avoid it.Architecture notes
server/internal/integrations/wecom/: the aibot long-connection adapter (wecom_channel.go), the WS frame codec (ws_frame.go, wire format documented at developer.work.weixin.qq.com/document/path/101463), a sender registry, binding + installation stores, resolvers, and outbound reply / inbox delivery.wss://openws.work.weixin.qq.com(seeDefaultWSURL).MULTICA_WECOM_SECRET_KEY. No credentials in env beyond that master key.Config
MULTICA_WECOM_SECRET_KEY— 32-byte base64 master key (openssl rand -base64 32). Unset = integration disabled (the WeCom Web-UI endpoints return 503).Testing
go build ./...andgo vet ./...pass.go test ./internal/integrations/...)./issue, and inbox push.Review
/cc @Bohan-J for review 🙏