@turmind/halo 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +10 -1
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/1b86ba38-c29611e086b2ce25.js +1 -0
  4. package/admin-out/_next/static/chunks/{1d2d5650-f5c38b9df5ca7466.js → 1d2d5650-d8d130c85727fdbb.js} +1 -1
  5. package/admin-out/_next/static/chunks/220.1ed70c874686122f.js +1 -0
  6. package/admin-out/_next/static/chunks/23-092ac3901411582e.js +1 -0
  7. package/admin-out/_next/static/chunks/250.9094fea2325b09a3.js +1 -0
  8. package/admin-out/_next/static/chunks/{304eda7d.662c3a1a7cd50701.js → 304eda7d.f525962766560ba5.js} +1 -1
  9. package/admin-out/_next/static/chunks/{170.14d53d7f32df020c.js → 336.3171154ecc29c80a.js} +1 -1
  10. package/admin-out/_next/static/chunks/{188.0cc6998eb0ed3519.js → 362.5970ef15ec3d7b15.js} +1 -1
  11. package/admin-out/_next/static/chunks/449.a403f7ad18054e3f.js +1 -0
  12. package/admin-out/_next/static/chunks/{444.c191bedee971e484.js → 510.e6c6faeb60ef3c33.js} +2 -2
  13. package/admin-out/_next/static/chunks/522.9e25c63e3082cb1c.js +1 -0
  14. package/admin-out/_next/static/chunks/613.f314fc12e05a566c.js +1 -0
  15. package/admin-out/_next/static/chunks/690.6a83d18b488a3e2e.js +1 -0
  16. package/admin-out/_next/static/chunks/698-19872069fed27164.js +1 -0
  17. package/admin-out/_next/static/chunks/73.a4617a551a8e176c.js +1 -0
  18. package/admin-out/_next/static/chunks/{841.48a39cec2ef962f3.js → 767.16a0b33b6f5e7965.js} +1 -1
  19. package/admin-out/_next/static/chunks/839.e381287756453ffe.js +1 -0
  20. package/admin-out/_next/static/chunks/868.cc7fd84c9f2fd711.js +1 -0
  21. package/admin-out/_next/static/chunks/892.92b2ed455683b49a.js +1 -0
  22. package/admin-out/_next/static/chunks/989-056ee2aecccce015.js +1 -0
  23. package/admin-out/_next/static/chunks/app/_not-found/page-4271518f2746cf73.js +1 -0
  24. package/admin-out/_next/static/chunks/app/layout-5ec6c5f713a8562b.js +1 -0
  25. package/admin-out/_next/static/chunks/app/page-5ed9b114aa1a8c10.js +1 -0
  26. package/admin-out/_next/static/chunks/db534642.657a5bd93dc1e8c7.js +1 -0
  27. package/admin-out/_next/static/chunks/framework-c48c994d2190a38e.js +1 -0
  28. package/admin-out/_next/static/chunks/main-app-340a5b58ed229c4d.js +1 -0
  29. package/admin-out/_next/static/chunks/main-f1f665fbc24e1033.js +1 -0
  30. package/admin-out/_next/static/chunks/pages/_app-a3b7e86f97343e7a.js +1 -0
  31. package/admin-out/_next/static/chunks/pages/_error-751fca0c729a7e01.js +1 -0
  32. package/admin-out/_next/static/chunks/webpack-ca926228f8cba1ff.js +1 -0
  33. package/admin-out/_next/static/css/59402ac06b2b1a10.css +3 -0
  34. package/admin-out/_next/static/{Z6KkY5u-XmAPBuUfiAvaG → vbv7FN3A5To2JBO2ozlis}/_buildManifest.js +1 -1
  35. package/admin-out/index.html +1 -1
  36. package/admin-out/index.txt +14 -14
  37. package/bundled-docs/design/cron.md +5 -4
  38. package/bundled-docs/design/evolution.md +2 -0
  39. package/bundled-docs/design/express-self.md +11 -0
  40. package/bundled-docs/design/observability.md +4 -2
  41. package/bundled-docs/design/session.md +10 -4
  42. package/bundled-docs/design/storage.md +10 -6
  43. package/bundled-docs/design/wecom.md +217 -0
  44. package/bundled-docs/design/ws.md +8 -2
  45. package/bundled-docs/dev/api.md +19 -4
  46. package/bundled-docs/guide/channels/README.md +5 -2
  47. package/bundled-docs/guide/channels/feishu.md +10 -0
  48. package/bundled-docs/guide/channels/slack.md +10 -0
  49. package/bundled-docs/guide/channels/wechat.md +2 -0
  50. package/bundled-docs/guide/channels/wecom.md +136 -0
  51. package/dist/index.js +1698 -829
  52. package/package.json +6 -6
  53. package/templates/prompts/all/RUNTIME.md +1 -1
  54. package/templates/skills/cron/SKILL.md +7 -6
  55. package/templates/skills/cron/manage-cron.py +1 -1
  56. package/templates/skills/send-file/SKILL.md +1 -1
  57. package/templates/skills/send-file/wecom.md +15 -0
  58. package/admin-out/_next/static/chunks/132-29179dca2ef093ce.js +0 -1
  59. package/admin-out/_next/static/chunks/187.9e822f6f0d366d9a.js +0 -1
  60. package/admin-out/_next/static/chunks/311.3cfaf322ec2a9170.js +0 -1
  61. package/admin-out/_next/static/chunks/401.c5e64882d4305a08.js +0 -1
  62. package/admin-out/_next/static/chunks/496.7997fc6558c88c22.js +0 -1
  63. package/admin-out/_next/static/chunks/530.03c0a6ec971d3528.js +0 -1
  64. package/admin-out/_next/static/chunks/540.a663b46781b2c309.js +0 -1
  65. package/admin-out/_next/static/chunks/556.10e21b9f7b45d412.js +0 -1
  66. package/admin-out/_next/static/chunks/813-9056b8ef0984bee6.js +0 -1
  67. package/admin-out/_next/static/chunks/891.cfe754015339e975.js +0 -1
  68. package/admin-out/_next/static/chunks/915.c6ee9ab9bee0ce9b.js +0 -1
  69. package/admin-out/_next/static/chunks/957-48c734b74e5437a8.js +0 -1
  70. package/admin-out/_next/static/chunks/968.1676baa9449dbd9d.js +0 -1
  71. package/admin-out/_next/static/chunks/app/_not-found/page-2ea4ee5eada04f69.js +0 -1
  72. package/admin-out/_next/static/chunks/app/layout-1853d21b0644f5a1.js +0 -1
  73. package/admin-out/_next/static/chunks/app/page-2ab109c9a25f867e.js +0 -1
  74. package/admin-out/_next/static/chunks/db534642.a148101e95a7d317.js +0 -1
  75. package/admin-out/_next/static/chunks/e3080c2c-fbf81b7cad547976.js +0 -1
  76. package/admin-out/_next/static/chunks/framework-67e5f0af3ea64f2b.js +0 -1
  77. package/admin-out/_next/static/chunks/main-6b28974253a3bc7c.js +0 -1
  78. package/admin-out/_next/static/chunks/main-app-3367dd99b8642c55.js +0 -1
  79. package/admin-out/_next/static/chunks/pages/_app-51a53534a0a4a898.js +0 -1
  80. package/admin-out/_next/static/chunks/pages/_error-dfd19566fa6f79ad.js +0 -1
  81. package/admin-out/_next/static/chunks/webpack-bf41edfde9cdefec.js +0 -1
  82. package/admin-out/_next/static/css/f96c61f28fab6941.css +0 -3
  83. /package/admin-out/_next/static/{Z6KkY5u-XmAPBuUfiAvaG → vbv7FN3A5To2JBO2ozlis}/_ssgManifest.js +0 -0
@@ -0,0 +1,217 @@
1
+ # WeCom (企业微信) 智能机器人 Channel — Design
2
+
3
+ Let the user talk to Halo from WeCom on their phone or desktop, sharing the same workspace + sessions as the web side. All channels are peers — each one is a subscriber + caller against SessionManager.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ ┌── ws/ (web channel) ─┐
9
+ ├── channels/telegram/ │
10
+ Halo server (9527) ──┤├── channels/wechat/ ├── SessionManager
11
+ ├── channels/slack/ │ (per workspace, via Registry)
12
+ ├── channels/feishu/ │
13
+ └── channels/wecom/ ←─ long-connect (wss) ─┘
14
+ ↕ WSClient (@wecom/aibot-node-sdk)
15
+ wss://openws.work.weixin.qq.com
16
+ ```
17
+
18
+ WeCom uses the official SDK's `WSClient` (`@wecom/aibot-node-sdk` ^1.0.7, MIT) to maintain a persistent WebSocket connection. Structurally it is the closest sibling of the Feishu channel (long-connect, one account = one bot), with one big difference: **there is no HTTP send API at all**. Inbound callbacks (`aibot_msg_callback`), replies (`aibot_respond_msg`), proactive pushes (`aibot_send_msg`) and media upload (`aibot_upload_media_*`) are all frames on the same socket. Only file *download* is HTTP (a 5-minute URL + per-file AES key, decrypted by the SDK).
19
+
20
+ ## Data model
21
+
22
+ ### Workspace ↔ Bot mapping
23
+
24
+ One 智能机器人 (= one botId + secret pair) is bound to one workspace. A workspace can bind multiple bots. `accountId = botId` as-is (charset enforced to `[\w-]` on POST, since the id becomes a media subpath and a URL segment).
25
+
26
+ ### Session strategy
27
+
28
+ - Session ID format: `wecom_<key>_<createdAtBase36>` where `key` is `normalizeWecomId(...)` — WeCom ids may contain `@` and `.`, which are folded to `-` so the id passes `isSafeIdSegment` and the `_`-delimited prefix format
29
+ - **Single chat (DM)**: `key = userid` → one session per user, latest-by-prefix (`setOverrideOnCreate: false`, same as telegram / wechat). Label `WeCom: <userid>`
30
+ - **Group**: `key = chatid` → **one shared session per group, not per user**. Different people `@`-ing the bot land in the same conversation; the sender travels as the `user:` tag and the group as `thread:` in the agent-input tag. Label `WeCom group: <chatid>`. There is no thread concept in WeCom group chat, so no per-thread keying à la slack / feishu
31
+ - Sessions live under the bot's bound workspace and use the highest-priority agent (falls back to `default` only when none exists)
32
+ - Access level inherited from the account's `accessLevel` field (see below)
33
+
34
+ ### Access level
35
+
36
+ Each account carries `accessLevel: 'full' | 'workspace' | 'readonly' | 'observer'` (default `readonly`):
37
+ - `full` — no restrictions (no sandbox)
38
+ - `workspace` — tool execution runs inside a bwrap sandbox with the workspace mounted read-write
39
+ - `readonly` — tool execution runs inside a bwrap sandbox with the workspace mounted read-only; readonly sessions only receive read-only tools
40
+
41
+ There is no per-user whitelist; the bot's 可见范围 (visibility scope) in the WeCom admin console is the only boundary. Because a group shares one session, `full` in a group means every member drives the same shell.
42
+
43
+ ### Storage
44
+
45
+ WeCom bot accounts are stored in the unified channel DB: `~/.halo/secrets/channels/channels.db`, table `channel_accounts` with `channel_type = 'wecom'`. See [storage.md](storage.md#channel_accounts) for the full schema. **The channel owns no table of its own and added no migration** — `channel_accounts` predates it (created by `channel-db.ts`'s `CREATE TABLE IF NOT EXISTS`), and everything WeCom-specific lives in the row's `config` JSON.
46
+
47
+ WeCom-specific config JSON fields: `botId`, `secret`, `lastActiveChatId`.
48
+
49
+ - `lastActiveChatId` is the raw `userid` (single) or `chatid` (group) of the most recent inbound message, written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` (idempotent). Not consumed by cron dispatch (the dispatcher requires an explicit `chatId`).
50
+ - `secret` is never returned by `GET /api/wecom/accounts` and is not patchable — a fresh POST with the same `botId` rotates it.
51
+
52
+ Inbound media lands under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
53
+
54
+ ### Authentication model
55
+
56
+ - `botId` + `secret` are long-lived credentials from the bot's **API 模式 → 长连接** settings in the WeCom admin console (the secret is long-connect-specific; callback-URL mode uses a different Token / EncodingAESKey and enabling it invalidates the long-connect one)
57
+ - Auth is one `aibot_subscribe` frame sent by the SDK right after the socket opens; there is no token to mint or refresh
58
+ - Halo does **not** probe credentials on POST — WeCom has no HTTP endpoint to validate against. Bad credentials surface as a `WSAuthFailureError` (`code = 'WS_AUTH_FAILURE_EXHAUSTED'`) on the client's `error` event after `maxAuthFailureAttempts: 3`; the handler then drops the client and the account stays dark until the next `startAccount`
59
+ - **One live connection per bot, server-side.** A newer `aibot_subscribe` with the same botId kicks the older connection with an `event.disconnected_event` frame, after which the SDK deliberately does not reconnect (`isManualClose = true`). The handler honours that — reconnecting would just kick the other side back — logs `kicked by a newer connection for this bot (another halo instance?)` and leaves the account dark. Disable + enable (or re-POST) to reclaim the bot
60
+
61
+ ### Proactive sending (cron)
62
+
63
+ The wecom cron-dispatcher (`channels/wecom/cron-dispatcher.ts`) requires an explicit `chatId` — there is no fallback to `lastActiveChatId`. When a cron fires:
64
+
65
+ 1. **Explicit `chatId` on the target** — set when the cron was created from inside a WeCom chat (auto-pinned via `CommandContext.channel.chatId`). Shape: `<userid>` for a single chat, `<chatid>` for a group — exactly what `pickConversation` cached as `chatKey`. `sendMessage(chatId, { msgtype: 'markdown', … })` omits `chat_type`; the server's compat mode (`chat_type = 0`) resolves single vs group from the id.
66
+ 2. **Admin-UI cron without explicit target** — throws `wecom cron target requires an explicit chatId …` and the run fails visibly. There is no `/search` route (WeCom exposes no directory API on this channel), so the target must be typed as `wecom:<accountId>:<chatId>`.
67
+
68
+ Because the push rides the handler's socket, the dispatcher borrows it through the exported `liveClients: Map<accountId, WSClient>` (set on `authenticated`, cleared on stop / kick / auth exhaustion). No live client → `{ ok: false, error: 'wecom long-connect not active' }` rather than queueing. WeCom additionally refuses pushes to a user / group that has never messaged the bot, and rate-limits each conversation to 30 msgs/min · 1000/hour (replies and pushes combined).
69
+
70
+ **`MEDIA:` is not implemented for cron** — the dispatcher declares no `supportsMedia`, so `dispatchToTargets` hands it the original text with `MEDIA:` lines intact (the path degrades to visible text, same caveat as telegram / feishu in [cron.md](cron.md#dispatch-model)). The realtime uploader (`sendWecomMedia` in `handler.ts`) is anchored to an inbound `req_id`; wiring cron up means switching it to `sendMediaMessage(chatid, …)` and flipping `supportsMedia: true`.
71
+
72
+ ## Modules
73
+
74
+ Files: `packages/server/src/channels/wecom/`
75
+
76
+ - `types.ts` — `WecomAccount` (no envelope types — the SDK ships `WsFrame` / `BaseMessage` / `TextMessage` / … and the handler imports those)
77
+ - `accounts.ts` — DAL (listAccounts / listEnabledAccounts / getAccount / insertAccount / updateAccount / deleteAccount) + `normalizeWecomId`
78
+ - `handler.ts` — `WSClient` lifecycle, `msgid` dedupe, content ingestion (text / voice transcript / image / mixed / file / video), group-mention strip, `replyStream` + media upload; session routing + listener/route bookkeeping come from `channels/shared/inbound.ts` (`InboundBridge` / `deliverInbound` / `dispatchChannelCommand`). Exports `liveClients` and the pure helpers `pickConversation` / `stripGroupMention` / `sniffImageMime`
79
+ - `event-adapter.ts` — AgentSessionEvent stream → coalesced stream-message replies (`WecomResponder`, buffer + flush at paragraph boundaries)
80
+ - `cron-dispatcher.ts` — registers the cron dispatcher, requires explicit chatId targets, sends via `liveClients`
81
+ - `descriptor.ts` — ServerChannelDescriptor entry point (registered in `channels/descriptors.ts` after feishu)
82
+
83
+ Routes: `packages/server/src/routes/wecom.ts`
84
+
85
+ - `GET /api/wecom/accounts` — list bot accounts (admin; `secret` omitted)
86
+ - `POST /api/wecom/accounts` — register / upsert a bot (`accountId = botId`; stop → start the long-connect)
87
+ - `PATCH /api/wecom/accounts/:id` — update `label` / `workspacePath` / `enabled` / `accessLevel` / `language`; credentials are not patchable
88
+ - `DELETE /api/wecom/accounts/:id` — remove + stop
89
+
90
+ No `/search` route (see cron above). Admin UI: `packages/admin/src/features/wecom/wecom-settings.tsx`.
91
+
92
+ Session-prefix kind `'wecom'` is registered in `channels/shared/session-prefix.ts`; `channels/shared/markdown.ts` has no `formatForWecom` on purpose (see Event coalescing).
93
+
94
+ ## Bot lifecycle
95
+
96
+ 1. User registers a bot via the API (provides botId + secret + workspace); no remote validation
97
+ 2. Server stores the account, calls `stopAccount` (idempotent) then `startAccount` — the stop must fully `disconnect()` before the new socket subscribes, or the new connection would kick its own predecessor
98
+ 3. `startAccount` builds `new WSClient({ botId, secret, maxReconnectAttempts: -1, maxAuthFailureAttempts: 3, logger })` and calls `connect()` (synchronous; auth happens on `authenticated`). The SDK handles heartbeat (30 s ping), reconnect with exponential back-off (infinite on network drops), and the auth retry budget
99
+ 4. `authenticated` → `liveClients.set(accountId, client)`; `event.disconnected_event` (kicked) or `error` with `WS_AUTH_FAILURE_EXHAUSTED` → client dropped, `liveClients.delete`
100
+ 5. `stopAccount` = `bridge.closeAll()` (drains queued chunks, then releases reply routes) → `wsClient.disconnect()` → `liveClients.delete` → state removed
101
+ 6. On graceful shutdown the descriptor's `shutdown` calls `stopAll()`
102
+
103
+ ## Long-connect frame protocol
104
+
105
+ All frames are JSON `{ cmd, headers: { req_id }, body }`; responses echo `headers.req_id` plus `errcode` / `errmsg`. The SDK owns the wire; the handler only sees SDK events. Commands in play:
106
+
107
+ | cmd | Direction | Used by halo |
108
+ |---|---|---|
109
+ | `aibot_subscribe` | client → server | SDK, on open (`{ bot_id, secret }`) |
110
+ | `ping` / pong | client → server | SDK heartbeat |
111
+ | `aibot_msg_callback` | server → client | inbound message → `'message'` event |
112
+ | `aibot_event_callback` | server → client | events; only `disconnected_event` is handled (kicked). `enter_chat` / `template_card_event` / `feedback_event` are ignored |
113
+ | `aibot_respond_msg` | client → server | `replyStream(frame, streamId, content, finish)` — every reply |
114
+ | `aibot_send_msg` | client → server | `sendMessage(chatid, { msgtype: 'markdown' })` — cron only |
115
+ | `aibot_upload_media_init` / `_chunk` / `_finish` | client → server | `uploadMedia(buffer, { type, filename })` — chunked (≤ 512 KB × ≤ 100), returns `media_id` (valid 3 days) |
116
+
117
+ **Reply model.** A stream reply is keyed to the callback's `req_id` and carries its own `stream.id`; the same `stream.id` can be re-sent to update the bubble until `finish: true`. Halo does **not** stream: every flushed chunk / hint / command reply is sent as a *new* `stream.id` with `finish: true` in one go — one finished bubble each. This keeps the responder identical to the other channels and sidesteps the 10-minute cap on an open stream (measured from its first frame). Replies must reference the inbound `req_id`, so the reply route stored per session is `{ reqId, chatType, chatId }`, refreshed on every inbound message (the route is read lazily at send time, as in slack / feishu).
118
+
119
+ ## Message handling flow
120
+
121
+ `'message'` event (`WsFrame<BaseMessage>`):
122
+
123
+ 1. Drop if the account is missing / disabled, or the body has no `msgid` / `from`
124
+ 2. **Dedupe on `msgid`** — per-account `Set` + FIFO array capped at 500. WeCom redelivers on reconnect
125
+ 3. `pickConversation(body)`: `chattype === 'group' && chatid` → group key; otherwise single-chat key on `from.userid` (both normalized, see Session strategy)
126
+ 4. Ingest content by `msgtype` (media URLs are valid 5 minutes, so downloads happen here, before queueing):
127
+ - `text` → `text.content`
128
+ - `voice` → `voice.content` is already speech-to-text; forwarded as `[语音转文字] <text>`. No audio to save
129
+ - `image` / `mixed` image items → `downloadFile(url, aeskey)`, mime sniffed from magic bytes (png / gif / else jpeg), saved as `kind: 'image'`, base64 pushed to `images` for vision, note `[图片已保存: path]`
130
+ - `mixed` text items → joined with `\n`
131
+ - `file` / `video` → downloaded, 20 MB cap (`[… 超过 20MB,未保存]`), saved as `kind: 'file' | 'video'` under the sender filename (fallback `file`), note `[文件 "name" 已保存: path]` / `[视频已保存: path]`; failures `[文件下载失败 name: reason]` / `[视频下载失败: reason]` — same wording as wechat / feishu so the admin renders them identically
132
+ - Image / voice / file / video only arrive in single chat (WeCom doesn't forward them from groups)
133
+ 5. Group text: strip leading `@token` mentions (`/^(@\S+\s*)+/`). WeCom only delivers group messages that `@` the bot, so there is no `shouldRespond` check. A multi-word bot name leaves its tail as residue — preferable to guessing the name and eating the user's first word
134
+ 6. Slash command dispatch — **single chat only** (a group's shared session belongs to everyone). `/workspace switch` persists the new path on the account row; the reply goes out via `replyStream`
135
+ 7. Skip if neither text/notes nor images remain
136
+ 8. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): create or retrieve the session (with inherited access level) + goal-mode overlay; busy / compacting hint via `replyStream`; refresh the route and attach the `WecomResponder` listener once; `sm.sendUserMessage(sessionId, agentInput, images?)` with `[channel: wecom | user: <userid>]` (+ ` | thread: <chatid>` in groups)
137
+
138
+ ## Event coalescing (WecomResponder)
139
+
140
+ Stream `content` is capped at 20480 **bytes**. The responder splits at 5000 **chars** — 5000 × 4 bytes (UTF-8 worst case) stays under the cap, so it never has to measure bytes. Strategy:
141
+
142
+ - Buffer only streamed text flagged `final` (the turn's wrap-up; pre-tool-call filler is dropped) until `complete`
143
+ - Flush fires on **any** `complete`, so a multi-round queue drain ships each merged turn as its own message (see [session.md](session.md#message-queue-and-drain))
144
+ - Over 5000 chars → split at paragraph boundary (`\n\n`) via the shared `splitText` (`channels/shared/chunk.ts`), else hard-cut
145
+ - `system` / `error` events flush early so users always see something before the run ends
146
+ - **No markdown formatter** — WeCom stream content renders CommonMark natively (headings, bold, lists, quotes, links, code, tables), so text is sent as the agent wrote it. `channels/shared/markdown.ts` lists wecom under "leave alone"
147
+ - `MEDIA:<path>` markers intercepted and sent via native media upload
148
+ - Sub-agent events dropped (visible in web UI only)
149
+
150
+ Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3) and `close()` returns the drain promise so `InboundBridge` keeps the reply route alive until the last chunk is out — identical to the feishu adapter, see [feishu.md](feishu.md#event-coalescing-feishuresponder) for the history.
151
+
152
+ ## Media support
153
+
154
+ **Inbound:** text, voice (as transcript), images (vision + saved), mixed (text + images), files, videos — single chat only for everything but text. Saved under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
155
+
156
+ **Outbound routing** (`sendWecomMedia`, path must be under the workspace — `isMediaPathAllowed`):
157
+ - `.png` / `.jpg` / `.jpeg` / `.gif` → `uploadMedia(type: 'image')` → `replyMedia('image')`
158
+ - `.mp4` → `uploadMedia(type: 'video')` → `replyMedia('video')`
159
+ - everything else (`.webp` / `.bmp` / `.mov` / documents / audio) → `uploadMedia(type: 'file')` → `replyMedia('file')` — WeCom's `image` accepts png/jpg/gif only and `video` mp4 only
160
+ - > 20 MB → refused; the user sees `t('handler.upload_failed')` (`⚠️ 文件上传失败:name — error`)
161
+ - Voice is never sent: WeCom expects AMR, which the agent doesn't produce
162
+
163
+ ## Similarities vs Feishu and Telegram
164
+
165
+ | Aspect | WeCom | Feishu | Telegram |
166
+ |--------|-------|--------|----------|
167
+ | **Inbound delivery** | Long-connect (wss), official SDK | Long-connect (wss), official SDK | Long-polling |
168
+ | **Outbound path** | Same wss socket (no HTTP API) | Open API over HTTPS | Bot API over HTTPS |
169
+ | **Session model** | Per user in DM, **one shared per group** | Per-thread in groups, p2p anchored | Per-user (one active) |
170
+ | **Mention required** | Enforced by WeCom (only `@` messages delivered) | Yes in groups, no in p2p | No |
171
+ | **Cron target** | Explicit `userid` / `chatid`, needs live socket | Explicit chatId only | Explicit numeric chatId only |
172
+ | **Credential check on add** | None (no HTTP API) | `bot/v3/info` probe | `getMe` probe |
173
+ | **Markdown** | Native CommonMark, sent as-is | Stripped to plain text | Plain text |
174
+ | **Text limit** | 5000 chars (20480-byte cap) | ~5000 chars | ~4000 chars |
175
+ | **Connection limit** | One per bot — newer kicks older, no auto-reconnect after kick | SDK reconnects freely | N/A |
176
+
177
+ ## Configuration
178
+
179
+ ### WeCom bot setup
180
+
181
+ 1. WeCom admin console → 应用管理 → 智能机器人 → create, set the visibility scope
182
+ 2. On the bot's config page enable **API 模式**, choose **长连接**
183
+ 3. Copy **BotID** and **Secret** (long-connect secret, distinct from callback-mode Token / EncodingAESKey)
184
+
185
+ ### Register to Halo
186
+
187
+ ```bash
188
+ curl -X POST http://localhost:9527/api/wecom/accounts \
189
+ -H 'Content-Type: application/json' \
190
+ -d '{
191
+ "botId": "aib_xxxxxxxxxxxx",
192
+ "secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
193
+ "workspacePath": "/home/user/my-workspace",
194
+ "accessLevel": "full",
195
+ "label": "My WeCom Bot"
196
+ }'
197
+ ```
198
+
199
+ Server stores the row and opens the long-connect immediately; auth outcome shows up in `[WeCom]` logs, not in the response.
200
+
201
+ ## Scope and out-of-scope
202
+
203
+ Supported: single-chat text / voice transcript / image / mixed / file / video; group text (mention enforced upstream); Markdown replies; media sending (image / video / file); slash commands (single chat only); per-account access level; cron push to an explicit user or group.
204
+
205
+ Not supported: streaming partial replies into WeCom (each reply is one finished bubble); template cards, welcome messages (`enter_chat`), feedback events; per-user sessions inside a group; cron `MEDIA:` attachments; a target-search route; voice sending.
206
+
207
+ ## Key file references
208
+
209
+ - Long-connect: `packages/server/src/channels/wecom/handler.ts:connect()`
210
+ - Conversation key: `packages/server/src/channels/wecom/handler.ts:pickConversation()`
211
+ - Content ingestion: `packages/server/src/channels/wecom/handler.ts:ingestContent()`
212
+ - Event dispatch: `packages/server/src/channels/wecom/handler.ts:handleInbound()`
213
+ - Media upload: `packages/server/src/channels/wecom/handler.ts:sendWecomMedia()`
214
+ - Cron dispatch: `packages/server/src/channels/wecom/cron-dispatcher.ts:dispatch()`
215
+ - Event coalescing: `packages/server/src/channels/wecom/event-adapter.ts:WecomResponder`
216
+ - REST routes: `packages/server/src/routes/wecom.ts`
217
+ - Tests: `packages/server/test/wecom-inbound.test.ts` (pure helpers), `wecom-responder.test.ts` (chunking / ordering / markdown pass-through)
@@ -55,6 +55,8 @@ Liveness probes bound how long a zombie lives, but a chat sent *into* the zombie
55
55
 
56
56
  Source: [handler.ts](../../../packages/server/src/ws/handler.ts) — top-level `switch (msg.type)` in the connection handler.
57
57
 
58
+ Both directions are typed in `packages/core/src/protocol/ws-frames.ts` (`WsClientMessage` / `WsServerMessage`, exported from `@turmind/halo-core/protocol`) and imported by server `ws/handler.ts` and admin `ws-client.ts` — that file is the source of truth for field names; this doc explains semantics. `__ping__` is a member of `WsClientMessage.type` (previously the server compared it via a string cast).
59
+
58
60
  | Type | Purpose |
59
61
  |---|---|
60
62
  | `subscribe` | Subscribe to a session (load history, re-attach detached) |
@@ -64,7 +66,7 @@ Source: [handler.ts](../../../packages/server/src/ws/handler.ts) — top-level `
64
66
  | `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. A compacting session cancels the compact instead (same as `chat:stop`). |
65
67
  | `session:clear` | Non-destructive /session new: save the current, release its listener, create fresh (handled inline) — see [Command dispatch](#command-dispatch) |
66
68
  | `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline) |
67
- | `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the whole turn from `rawMessages` (LLM context) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting, and permanently once the session has archived UI-log segments — that refusal carries `code: 'archived'` (an ordinal is only meaningful while both sides agree where the log starts; archiving moves the start). See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
69
+ | `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the whole turn from `rawMessages` (LLM context) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the on-disk header count (`readArchiveCount`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
68
70
  | `command:<name>` | Route through shared `dispatchCommand` (see [command.md](command.md)); `/session compact` handled inline for UI callbacks |
69
71
  | `terminal:start` | Spawn a new PTY |
70
72
  | `terminal:input` | Send keystrokes. `terminalId` is **required** — an id-less frame is logged and dropped (it used to fall back to the first entry of the process-global terminal map, which with several admin connections open could write into another browser's PTY) |
@@ -101,6 +103,8 @@ Source: [event-processor.ts:48-97](../../../packages/server/src/ws/event-process
101
103
 
102
104
  `chat:thinking` / `chat:stream` / `chat:followup` / `agent:tool_call` / `agent:tool_result` additionally carry `replay: true` when synthesized by the reattach path (never on live events) — see [Reconnect flow](#reconnect-flow) step 6.
103
105
 
106
+ `chat:system` producers (`session-manager.ts`'s `stop` event handling): a `max_tokens` stop emits `⚠️ [<agent>] Response truncated: output token limit reached.`; a `refusal` stop (Anthropic `stop_reason: "refusal"`, HTTP 200 — the model declined, not an error) emits `⚠️ [<agent>] Model declined to respond (<category>): <explanation> — …` suggesting `/new` (see [session.md](session.md#resilient-execution-loop)).
107
+
104
108
  Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)) and `complete.batchBoundary`. The admin renders every streamed block, so neither is needed on the wire.
105
109
 
106
110
  ### Other Server → Client messages
@@ -127,12 +131,14 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
127
131
 
128
132
  ### `error` frames: optional `code`
129
133
 
130
- `{ type: 'error', error }` normally means "something failed"; the admin prefixes it with `Error:` when rendering. An **expected refusal** the server already phrased for the user adds a `code` (currently only `'archived'`, from `exchange:delete`), and the admin renders such a frame verbatim without the prefix. So: add `code` when the message is a complete user-facing sentence, omit it when the client should present it as a fault.
134
+ `{ type: 'error', error }` normally means "something failed"; the admin prefixes it with `Error:` when rendering. An **expected refusal** the server already phrased for the user adds a `code` (currently only `'archived'`, from `exchange:delete`, when the client's `archiveCount` anchor no longer matches the on-disk count), and the admin renders such a frame verbatim without the prefix. So: add `code` when the message is a complete user-facing sentence, omit it when the client should present it as a fault.
131
135
 
132
136
  ### Archive anchor in `state:snapshot`
133
137
 
134
138
  `archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one header read per session open. The per-turn snapshots and the snapshot pushed after `exchange:delete` deliberately omit it: the client's "load older" cursor is an anchor pinned at the moment the session was opened, and a segment written mid-session (a compact while the tab is open) must not move it. The client then requests segments over HTTP (`GET /api/sessions/logs/:id/archive/:n`), not WS — one whole segment per call, cached client-side because segments are immutable. See [session.md](session.md#ui-log-archiving).
135
139
 
140
+ The same anchor is what the client sends back as `archiveCount` on `exchange:delete`, so the server can tell a stale ordinal from a live one.
141
+
136
142
  ## WS Handler as a thin session client
137
143
 
138
144
  > **History**: originally the WS handler directly created and owned Orchestrator instances in a `client.orchestrators` map. Agent logic is now fully owned by SessionManager; the WS handler is a thin routing client.
@@ -4,6 +4,8 @@ All REST endpoints are served by Hono on port 9527 at `/api/`.
4
4
 
5
5
  Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions in `PUBLIC_PATHS` (`middleware/auth.ts`) bypass the cookie: `/api/auth/login|check|logout` (but **not** `/api/auth/change-password`), the web-channel routes (`/api/web/chat|sessions|stop|history|subscribe|file`), `/api/show/state|session`, and `/api/metrics` — these are unauthenticated or use a web-channel `x-token` instead.
6
6
 
7
+ **Trust model.** The admin cookie is a single-tenant, all-or-nothing credential: there is one password (`~/.halo/secrets/config.yaml`), one JWT secret, and no roles. A valid `halo_token` equals full control of the server process — every workspace's files (`/api/files/*` project-scoped, `/api/fs/browse` unrestricted), shell via the WS terminal, channel credentials, git credentials, cron jobs, and the ability to mint web-channel tokens at any access level. Routes under the cookie therefore don't re-check "who" beyond the cookie itself; per-user isolation is the job of channel accounts (`x-token` with `readonly` / `workspace` / `full`), not of the admin. Consequences worth knowing when adding routes: (1) never put a cookie-gated route in `PUBLIC_PATHS` to "make it easier for a client" — that's the entire boundary; (2) a route that accepts a path or id from the client still needs traversal / shape checks (`isSafeIdSegment`, `assertPathAllowed`), because the *web-channel* surface reuses the same helpers and is multi-user; (3) if multi-user admin ever lands, the first change is a role claim in the JWT plus per-route checks, and this paragraph becomes the checklist.
8
+
7
9
  Compression: every response negotiates gzip/deflate via `hono/compress` (`threshold: 1024` — bodies under 1 KiB pass through uncompressed), mounted after CORS so it covers both `/api/*` JSON and the served admin static assets. `text/event-stream` (SSE) is excluded, so `/api/web/chat`'s streamed reply is unaffected; WS upgrades never enter the Hono fetch pipeline either.
8
10
 
9
11
  ## Health
@@ -98,14 +100,14 @@ The get endpoint returns the full session file. If only `rawMessages` is present
98
100
  ## Channel accounts — shared field validation
99
101
 
100
102
  `workspacePath` and `accessLevel` are validated at the REST boundary on both
101
- POST and PATCH across all five channels (web / wechat / telegram / slack /
102
- feishu) — one shared pair of checks in `channels/shared/accounts.ts`, so the
103
- five can't drift (they did: four PATCHes never checked `isAbsolute`, and only
103
+ POST and PATCH across all six channels (web / wechat / telegram / slack /
104
+ feishu / wecom) — one shared pair of checks in `channels/shared/accounts.ts`, so the
105
+ six can't drift (they did: four PATCHes never checked `isAbsolute`, and only
104
106
  wechat's PATCH rejected an unknown `accessLevel`):
105
107
 
106
108
  | Field | Rule | 400 body |
107
109
  |---|---|---|
108
- | `accessLevel` | Must be one of the channel's legal levels when present; absent = leave unchanged. **web** allows `full` / `workspace` / `readonly` / `observer` (halo-city / metrics tokens are minted here); the **four chat channels** allow only `full` / `workspace` / `readonly` — `observer` is a dashboard role, not a chat identity | `{error: "accessLevel must be one of: …"}` |
110
+ | `accessLevel` | Must be one of the channel's legal levels when present; absent = leave unchanged. **web** allows `full` / `workspace` / `readonly` / `observer` (halo-city / metrics tokens are minted here); the **five chat channels** allow only `full` / `workspace` / `readonly` — `observer` is a dashboard role, not a chat identity | `{error: "accessLevel must be one of: …"}` |
109
111
  | `workspacePath` | Absolute and existing. Empty string / relative path → 400; PATCH treats *absence* as "leave the binding alone" but validates any value supplied. On success `ensureWorkspaceHalo()` scaffolds `.halo/` | `{error: "workspacePath must be absolute"}` / `{error: "workspace path not found"}` |
110
112
 
111
113
  POST additionally rejects a missing `workspacePath` with `{error: "workspacePath required"}`. Stored levels are still normalized to `readonly` on read, but the db no longer holds a value no code path agrees with.
@@ -161,6 +163,19 @@ File: `packages/server/src/routes/feishu.ts`. Admin cookie auth. Inbound uses lo
161
163
  | DELETE | `/api/feishu/accounts/:id` | Stop the wss stream and delete the row |
162
164
  | GET | `/api/feishu/accounts/:id/search?q=` | Search chats the bot is a member of (used by the cron form). Returns `{hits}` (max 20) |
163
165
 
166
+ ## WeCom Channel
167
+
168
+ File: `packages/server/src/routes/wecom.ts`. Admin cookie auth. Inbound uses the 智能机器人 long-connect wss (no webhook); there is **no HTTP send API**, so replies and cron pushes ride the same socket.
169
+
170
+ | Method | Path | Purpose |
171
+ |---|---|---|
172
+ | GET | `/api/wecom/accounts` | List accounts. Row: `accountId`, `botId`, `workspacePath`, `workspaceMissing`, `label`, `enabled`, `accessLevel`, `language`, timestamps. `secret` is **never** returned |
173
+ | POST | `/api/wecom/accounts` | Create / upsert. Body: `{botId, secret, workspacePath, label?, accessLevel?, language?}`. `accountId = botId` as-is (must match `[A-Za-z0-9_-]` — it becomes a media subpath + URL segment). **No credential probe** — 智能机器人 has no HTTP endpoint to validate a pair against; bad credentials surface as `WS_AUTH_FAILURE_EXHAUSTED` in the `[WeCom]` server log. Stops any live socket, then (re)connects. Returns `{accountId, botId}` |
174
+ | PATCH | `/api/wecom/accounts/:id` | Update `label` / `workspacePath` / `enabled` / `accessLevel` / `language`. `botId` / `secret` are **not** patchable — re-POST to rotate. Stops then reconnects if still enabled |
175
+ | DELETE | `/api/wecom/accounts/:id` | Close the wss stream and delete the row |
176
+
177
+ No `/search` endpoint: the cron form takes the WeCom `chatId` verbatim (a `userid` for a single chat, a group `chatid` for a group) — the 智能机器人 API has no contact/chat directory to query. `workspacePath` / `accessLevel` follow the [shared validation rules](#channel-accounts--shared-field-validation). See [design/wecom.md](../design/wecom.md).
178
+
164
179
  ## Cron
165
180
 
166
181
  File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `cron_jobs` + read-only `cron_runs`. Mutations call `reloadAll()` / `scheduleJob()` / `unscheduleJob()` so in-memory croner stays in sync with the db. Each mutation also broadcasts `cron:job_changed`.
@@ -10,6 +10,7 @@ This folder has one onboarding guide per channel. Pick the one you want and foll
10
10
  | [Telegram](telegram.md) | Personal use on phone, small whitelisted teams | BotFather token | Long-poll |
11
11
  | [Slack](slack.md) | Team workspaces (multi-user, channels, threads) | Slack App + Socket Mode (`xoxb-` + `xapp-` tokens) | WebSocket (Socket Mode) |
12
12
  | [Feishu / Lark](feishu.md) | Chinese enterprise teams | Feishu open-platform App (`appId` + `appSecret`) + long connection | WebSocket (long-connect) |
13
+ | [WeCom](wecom.md) | Chinese enterprise teams on 企业微信 (single chat + groups) | 智能机器人 (Bot ID + Secret) + long connection | WebSocket (long-connect) |
13
14
  | [WeChat](wechat.md) | China-side personal use; mobile-first | iLink-style bot, login by scanning a QR | Long-poll |
14
15
  | [ACP](acp.md) | Claude Code (or other ACP clients) driving a remote halo | Web token + adapter CLI | JSON-RPC over stdio |
15
16
 
@@ -21,6 +22,7 @@ This folder has one onboarding guide per channel. Pick the one you want and foll
21
22
  halo server ─────────┤── channels/telegram/ ── grammy long-poll
22
23
  (port 9527) ├── channels/slack/ ── Socket Mode wss
23
24
  ├── channels/feishu/ ── long-connect wss
25
+ ├── channels/wecom/ ── long-connect wss (aibot sdk)
24
26
  └── channels/wechat/ ── ilinkai long-poll
25
27
  ↓
26
28
  SessionManager (per workspace)
@@ -40,6 +42,7 @@ What the admin UI actually asks you for. Auto-filled fields (botUsername, botUse
40
42
  | Telegram | Bot token, workspace path | Label, access level, language, allowed users | BotFather `/newbot` |
41
43
  | Slack | Bot token (`xoxb-`), App token (`xapp-`), workspace path | Label, access level, language | api.slack.com → Create App → install + Socket Mode |
42
44
  | Feishu | App ID, App Secret, workspace path | Verification token, encrypt key, label, access level, language | open.feishu.cn → Create App + add bot capability |
45
+ | WeCom | Bot ID, Secret, workspace path | Label, access level, language, allowed users | work.weixin.qq.com → 智能机器人 → create bot, copy Bot ID + Secret |
43
46
  | WeChat | Workspace path (set before scanning) | Label, access level, language | QR scan in admin UI; bot token comes back from the scan |
44
47
 
45
48
  ## Access level
@@ -57,6 +60,6 @@ When in doubt start at `readonly` and raise it later. The setting is on the **ac
57
60
  - Already know which channel you want → click the row above
58
61
  - New to halo and just want to chat in a browser → [Web](web.md)
59
62
  - Want to run halo from your IDE (Claude Code etc.) → [ACP](acp.md)
60
- - Setting up a team workspace → [Slack](slack.md) or [Feishu](feishu.md)
63
+ - Setting up a team workspace → [Slack](slack.md), [Feishu](feishu.md) or [WeCom](wecom.md)
61
64
 
62
- For the design rationale and protocol details (not user-facing), see [docs/design/](../../design/) — `wechat.md`, `telegram.md`, `web.md`. Slack and Feishu design docs aren't written yet; the source of truth is the code under `packages/server/src/channels/{slack,feishu}/`.
65
+ For the design rationale and protocol details (not user-facing), see [docs/design/](../../design/) — one file per channel: `web.md`, `telegram.md`, `slack.md`, `feishu.md`, `wecom.md`, `wechat.md`.
@@ -92,6 +92,16 @@ Open halo admin → **Channels** → **Feishu** → **Add Account**:
92
92
 
93
93
  On submit halo calls Feishu's `tenant_access_token` + `bot/info` API to validate your credentials and auto-fill `botOpenId`. If that call fails the account isn't created and you'll see Feishu's error code inline (typical: `99991663` = wrong app secret, `99991668` = bot capability not enabled).
94
94
 
95
+ ### Who can talk to the bot — and what `full` means
96
+
97
+ There is **no per-user whitelist** on Feishu accounts (unlike Telegram's `allowedUsers`): the access level applies to **every tenant member who can reach the bot** — anyone who DMs it or is in a group it's been added to. The boundary is Feishu's **app availability scope** (set when creating a version in Step 6 — 可用范围 / Availability: all members vs. selected departments / people), not halo.
98
+
99
+ Pick the level with that in mind:
100
+
101
+ - `readonly` — safe default for a tenant-wide bot; the agent can read and answer but not write files or run commands
102
+ - `workspace` — the agent may write inside the bound workspace path; fine for a team that already shares that repo
103
+ - `full` — the agent has shell access as the server user, and any reachable member can drive it. Reserve `full` for an app whose availability is narrowed to people you'd also give SSH to, or point it at a dedicated workspace / server.
104
+
95
105
  ## Step 8 — Test it
96
106
 
97
107
  In Feishu desktop / mobile:
@@ -96,6 +96,16 @@ Open halo admin → **Channels** → **Slack** → **Add Account**:
96
96
 
97
97
  On submit halo calls Slack's `auth.test` to validate the bot token and auto-fills `botUserId` and `teamId`. If that call fails the account isn't created and you'll see the Slack-returned error inline.
98
98
 
99
+ ### Who can talk to the bot — and what `full` means
100
+
101
+ There is **no per-user whitelist** on Slack accounts (unlike Telegram's `allowedUsers`): the access level applies to **every member of the Slack workspace who can reach the bot** — anyone who DMs it or is in a channel it's been invited to. Membership is the boundary, and that boundary is managed on the Slack side (workspace invites, channel membership, app install scope), not in halo.
102
+
103
+ Pick the level with that in mind:
104
+
105
+ - `readonly` — safe default for a company-wide bot; the agent can read and answer but not write files or run commands
106
+ - `workspace` — the agent may write inside the bound workspace path; fine for a team that already shares that repo
107
+ - `full` — the agent has shell access as the server user, and any workspace member can drive it. Reserve `full` for a bot in a private channel with people you'd also give SSH to, or point it at a dedicated workspace / server.
108
+
99
109
  ## Step 8 — Test it
100
110
 
101
111
  1. In Slack, click the bot's name in the sidebar to start a DM → type `hello` → expect a streamed reply
@@ -34,6 +34,8 @@ The form is **just the workspace + label + access level + language** — no bot
34
34
 
35
35
  Click **Generate QR**. Halo hits `ilinkai.weixin.qq.com` to mint a fresh QR code and starts a poll loop waiting for the scan.
36
36
 
37
+ > **What the access level covers.** A WeChat account is bound to exactly one WeChat user — the one who scans — so there's no whitelist to configure; the scanner *is* the whitelist. The level you choose here is what that phone gets: `full` means whoever holds the unlocked phone has shell access as the server user. The same applies to invite QRs minted with `/qr full` from inside the chat — each scan creates a separate account at the level you named, so hand those out as you would an SSH key.
38
+
37
39
  ## Step 3 — Scan with WeChat
38
40
 
39
41
  1. Open WeChat on your phone
@@ -0,0 +1,136 @@
1
+ # WeCom (企业微信) 智能机器人
2
+
3
+ Talk to a halo agent from WeCom — DM the bot, or `@`-mention it in any group it's been added to. Halo uses the 智能机器人 **long-connect** API mode (an outbound WebSocket from the server to `wss://openws.work.weixin.qq.com`), so **no public webhook URL is required**.
4
+
5
+ ## What you'll end up with
6
+
7
+ - A 智能机器人 (intelligent bot) created in your company's WeCom admin console, switched to **API 模式 → 长连接**
8
+ - Two credentials stored in halo: **Bot ID** and **Secret** (the long-connect secret — not the callback-mode Token / EncodingAESKey)
9
+ - A bot account row pointing those credentials at one halo workspace
10
+
11
+ ## Step 1 — Create the intelligent bot
12
+
13
+ 1. Log in to the WeCom admin console (https://work.weixin.qq.com/wework_admin/) as a company admin
14
+ 2. Go to **应用管理 / App Management** → **智能机器人 / Intelligent Bot** → **创建 / Create**
15
+ 3. Fill name (e.g. `halo`), upload an avatar, pick the **可见范围 / visibility scope** (which departments / members can see and chat with the bot — this scope is your only access whitelist, see below)
16
+
17
+ ## Step 2 — Switch to API mode with long-connect
18
+
19
+ On the bot's configuration page:
20
+
21
+ 1. Find **API 模式 / API mode** and enable it
22
+ 2. Pick **长连接 / Long connection** (NOT 设置接收消息回调地址 / "set a callback URL" — that's webhook mode and needs a public endpoint)
23
+ 3. The console now shows the long-connect credentials
24
+
25
+ > API mode is either/or: switching the bot to callback-URL mode later invalidates the long-connect secret and drops halo's connection. Leave it on 长连接.
26
+
27
+ ## Step 3 — Copy Bot ID and Secret
28
+
29
+ Same page, after Step 2:
30
+
31
+ - **BotID** — the bot's unique id. Goes into halo as-is (it also becomes the account id)
32
+ - **Secret** — the long-connect key. Treat like a password; halo never displays it again after you save
33
+
34
+ ## Step 4 — Add the account in halo admin
35
+
36
+ Open halo admin → **Channels** → **WeCom** → **Add Bot**:
37
+
38
+ | Field | Value |
39
+ |---|---|
40
+ | Bot ID | from Step 3 |
41
+ | Secret | from Step 3 |
42
+ | Bind to workspace | absolute path, e.g. `/home/ubuntu/my-project` |
43
+ | Name | optional label |
44
+ | Access level | `readonly` (default), `workspace`, or `full` |
45
+ | Language | `en` or `zh` — for hints and command replies |
46
+
47
+ There is **no credential check on submit** — WeCom has no HTTP endpoint to validate a Bot ID + Secret pair against. Halo stores the row and opens the long-connect immediately; a wrong secret shows up in the server logs as `[WeCom] <botId> error: … WS_AUTH_FAILURE_EXHAUSTED` after three attempts, and the bot stays dark until you re-add it with the right one. Re-submitting the form with the same Bot ID overwrites the stored secret and reconnects. Name, workspace, access level and language can be edited in place afterwards; Bot ID and Secret cannot — re-add to rotate them.
48
+
49
+ ### Who can talk to the bot — and what `full` means
50
+
51
+ There is **no per-user whitelist** on WeCom accounts (unlike Telegram's `allowedUsers`): the access level applies to **every company member who can reach the bot** — anyone inside the bot's visibility scope who DMs it, plus anyone in a group it's been added to. The boundary is the **可见范围 / visibility scope** you set in Step 1, not halo.
52
+
53
+ Pick the level with that in mind:
54
+
55
+ - `readonly` — safe default for a company-wide bot; the agent can read and answer but not write files or run commands
56
+ - `workspace` — the agent may write inside the bound workspace path; fine for a team that already shares that repo
57
+ - `full` — the agent has shell access as the server user, and any reachable member can drive it. Reserve `full` for a bot whose visibility scope is narrowed to people you'd also give SSH to, or point it at a dedicated workspace / server.
58
+
59
+ In groups the whole group shares **one** session (see below), so `full` in a group means every member is driving the same shell.
60
+
61
+ ## Step 5 — Test it
62
+
63
+ In WeCom desktop / mobile:
64
+
65
+ 1. Search the bot's name → open the chat → send `hello` → expect a reply (the answer arrives as one message when the turn finishes — halo doesn't stream partial text into WeCom)
66
+ 2. Add the bot to a group: group settings → 添加群机器人 → pick it → `@halo hi` → expect a reply
67
+
68
+ If nothing happens, check halo server logs for `[WeCom]` lines. `long-connect authenticated` means the socket is up; `kicked by a newer connection for this bot` means another process subscribed with the same Bot ID (see Common problems).
69
+
70
+ ## How halo handles inbound
71
+
72
+ - **Single chat** (1:1 DM) — every message routes to the bot; one session per user
73
+ - **Groups** — WeCom only delivers messages that `@`-mention the bot, so there is no mention check on halo's side; the leading `@BotName` token is stripped. **One shared session per group**, not per user — everyone in the group is talking to the same conversation
74
+ - **Voice** — WeCom transcribes voice notes server-side; the agent receives the text prefixed `[语音转文字]`. No audio file is saved
75
+ - **Images** — downloaded (decrypted) and fed to the LLM as multimodal content, also saved under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`
76
+ - **Files / videos** — downloaded and saved to the same folder (20 MB cap); the agent gets `[文件 "name" 已保存: path]` / `[视频已保存: path]`
77
+ - Images, voice, files and videos only arrive in **single chat** — WeCom doesn't forward them from groups
78
+ - **Duplicates** — WeCom may redeliver a message after a reconnect; halo dedupes on `msgid`
79
+
80
+ ### Outbound media
81
+
82
+ When the agent emits a `MEDIA:<path>` line (a file under the workspace), halo uploads it and replies with the matching message type:
83
+
84
+ | File | Sent as |
85
+ |---|---|
86
+ | `.png` / `.jpg` / `.jpeg` / `.gif` | image |
87
+ | `.mp4` | video |
88
+ | everything else (`.webp`, `.bmp`, `.mov`, `.pdf`, `.docx`, …) | file |
89
+
90
+ 20 MB cap — anything larger fails with a "⚠️ 文件上传失败" reply. Voice messages are never sent (WeCom expects AMR, which the agent doesn't produce).
91
+
92
+ Replies render **Markdown natively** — headings, bold, lists, quotes, links, code blocks and tables all display as formatted text.
93
+
94
+ ## Slash commands
95
+
96
+ Same set as the other channels — type as plain text in a **single chat** (commands are ignored in groups, where the shared session belongs to everyone):
97
+
98
+ | Command | Effect |
99
+ |---|---|
100
+ | `/session <verb>` | Session lifecycle: `new` / `list` / `switch <n>` / `stop` / `interrupt` / `compact` / `context` |
101
+ | `/agent <verb>` | Manage agents (`list` / `switch` / `desc` open to all; `delete` full; `create` / `update` via skill, full) |
102
+ | `/skill <verb>` | Manage skills (`list` / `desc` open; `disable` / `enable` workspace; `delete` full; `create` / `update` via skill, full) |
103
+ | `/workspace <verb>` | Workspace: `info` (all) / `switch <path>` (full) / `setup` / `tidy` (workspace) / `share` (full) |
104
+ | `/help` | List commands — object commands show only the verbs you can run |
105
+
106
+ ## Cron jobs targeting WeCom
107
+
108
+ When a cron job is created from inside a WeCom chat, the dispatcher pins that chat (the user's id in a DM, the group's chatid in a group) and pushes the result back there. To target a WeCom chat from the admin UI, pass the id explicitly — there is no search box (WeCom offers no directory API on this channel): `--targets wecom:<botId>:<userid|chatid>`. A job without an explicit target runs silently.
109
+
110
+ Two WeCom rules to know:
111
+
112
+ - Proactive pushes ride the **same long-connect** as inbound messages. If the bot is disabled, kicked, or its secret failed, the run records `wecom long-connect not active` and nothing is sent
113
+ - WeCom only accepts a push to a user or group that has **messaged the bot at least once**. Send the bot a `hi` before scheduling reports to yourself
114
+
115
+ ## Common problems
116
+
117
+ | Symptom | Cause / fix |
118
+ |---|---|
119
+ | `WS_AUTH_FAILURE_EXHAUSTED` in logs, bot never answers | Secret wrong, or the bot was switched back to callback-URL mode — redo Steps 2-3 and re-add |
120
+ | `kicked by a newer connection for this bot` in logs | Only **one** connection per bot is allowed; a second halo instance (or the SDK sample script) subscribed with the same Bot ID. Stop the other one, then disable + enable the account in halo — halo deliberately does not auto-reconnect after a kick |
121
+ | Cron run says `wecom long-connect not active` | Account disabled / kicked / auth failed — check the account toggle and logs |
122
+ | Cron push returns an error for a user | That user has never messaged the bot; WeCom refuses cold pushes |
123
+ | Replies to a group land as one long thread of context | Expected — groups share one session. Use a DM for a private session |
124
+ | Sent a picture in a group, nothing happened | WeCom only forwards media in single chat |
125
+
126
+ ## Multi-bot setup
127
+
128
+ One halo account = one bot. To bind a second workspace, create a second 智能机器人 (Steps 1-3) and add it as another account. Bots are company-scoped; a second company needs its own bot under that company's admin console.
129
+
130
+ ## Reference
131
+
132
+ - Code: `packages/server/src/channels/wecom/`
133
+ - Routes: `packages/server/src/routes/wecom.ts`
134
+ - Admin UI: `packages/admin/src/features/wecom/wecom-settings.tsx`
135
+ - SDK: [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) (official, MIT) — wire protocol, auth, heartbeat, media upload / decrypt
136
+ - Design notes: [design/wecom.md](../../design/wecom.md)