Skip to content

feat(wecom): WeChat Work (企业微信) smart-bot integration via the Channel engine#5833

Open
leroy-chen wants to merge 16 commits into
multica-ai:mainfrom
leroy-chen:pr/wecom-smart-bot
Open

feat(wecom): WeChat Work (企业微信) smart-bot integration via the Channel engine#5833
leroy-chen wants to merge 16 commits into
multica-ai:mainfrom
leroy-chen:pr/wecom-smart-bot

Conversation

@leroy-chen

Copy link
Copy Markdown
Contributor

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:

  • Bidirectional chat — a user messages the bound smart bot in WeCom, the message is enqueued as a chat task, and the agent's reply streams back via EventChatDone.
  • /issue command inside a WeCom chat creates an issue (stamped origin_type='wecom_chat') without triggering an agent run.
  • Inbox notifications — when a member has a WeCom binding, their inbox:new items are pushed to the bot as a markdown card (no-op when unbound; the in-app inbox is unchanged).
  • Web UI to install / list / revoke a bot installation per agent (Settings → Integrations → WeCom), plus an explicit user-binding redeem flow (/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.Supervisor pattern used by Slack and Lark.

This PR is that version:

  • The WeCom long-connection adapter plugs into engine.Supervisor like any other channel; inbound routing goes through the shared channel/engine/router.go, not a parallel dispatcher.
  • Installation credentials live in channel_installation and 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 existing issue.origin_type CHECK constraint to allow 'wecom_chat', mirroring 111_issue_origin_lark_chat and 131_issue_origin_slack_chat. Without it, /issue from 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

  • New package 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.
  • Connects to wss://openws.work.weixin.qq.com (see DefaultWSURL).
  • The per-installation smart-bot secret is encrypted at rest with 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).
  • Create an installation from Settings → Integrations → WeCom (bot_id + secret); the secret is sealed with the master key before it is stored.

Testing

  • go build ./... and go vet ./... pass.
  • Unit tests for the WS frame codec, the inbox markdown builder, resolvers, and channel/engine routing pass (go test ./internal/integrations/...).
  • Manually verified end-to-end against a real WeCom smart bot: binding, inbound chat + streaming reply, /issue, and inbox push.

Review

/cc @Bohan-J for review 🙏

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

@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
leroy-chen force-pushed the pr/wecom-smart-bot branch 3 times, most recently from 0fefdc3 to b7d32de Compare July 24, 2026 08:18
leroy-chen and others added 16 commits July 24, 2026 17:57
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
leroy-chen force-pushed the pr/wecom-smart-bot branch from b7d32de to 3752de4 Compare July 24, 2026 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant