@sunnoy/wecom 2.0.2 → 2.1.0

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.
package/README.md CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  > **⚠️ 从 HTTP 回调迁移到长连接:** 2.0 版本完全采用企业微信 [AI 机器人 WebSocket 长连接模式](https://open.work.weixin.qq.com/help2/pc/cat?doc_id=21657)。如果你之前使用 HTTP 回调(Token + EncodingAESKey + 回调 URL),需要在企业微信管理后台将机器人切换到长连接模式,然后删除旧的回调配置。切换后只需 `botId` 和 `secret` 即可接入。
11
11
 
12
+ > **2.1 新增:** 在 WS 长连接之外,2.1 版本新增了企业微信**自建应用"接收消息"HTTP 回调**作为可选入站通道。在 `channels.wecom.agent` 下配置 `callback.token`、`callback.encodingAESKey`、`callback.path` 即可同时启用,与 WS 通道并行运行,互不影响。
13
+
12
14
  ## 相比官方插件的增强特性
13
15
 
14
16
  下表列出了本插件相比 [官方 WeCom OpenClaw 插件](https://github.com/WecomTeam/wecom-openclaw-plugin)([npm](https://www.npmjs.com/package/@wecom/wecom-openclaw-plugin))额外提供的能力:
@@ -42,6 +44,9 @@
42
44
  | **入站图文混排**(`mixed` 消息拆解为文本 + 图片) | ❌ | ✅ |
43
45
  | **入站语音转写**(`voice.content` 自动提取) | ❌ | ✅ |
44
46
  | **入站引用消息**(`quote` 上下文透传) | ❌ | ✅ |
47
+ | **自建应用回调入站**(HTTP 回调作为独立入站通道,与 WS 并行) | ❌ | ✅ |
48
+ | **Agent API Markdown 回复**(回调入站回复默认 markdown 格式) | ❌ | ✅ |
49
+ | **入站/出站信息日志**(WS / CB 收发日志,便于追踪消息流) | ❌ | ✅ |
45
50
 
46
51
  ## 目录
47
52
 
@@ -55,6 +60,7 @@
55
60
  - [企业微信侧配置](#企业微信侧配置)
56
61
  - [消息能力与投递策略](#消息能力与投递策略)
57
62
  - [动态 Agent 与路由](#动态-agent-与路由)
63
+ - [自建应用回调入站](#自建应用回调入站)
58
64
  - [常见问题](#常见问题)
59
65
  - [项目结构](#项目结构)
60
66
  - [自定义 Skills 配合沙箱使用实践](#自定义-skills-配合沙箱使用实践)
@@ -209,11 +215,15 @@ npm test
209
215
  | `channels.wecom.agent.corpId` | string | 否 | 自建应用 CorpID |
210
216
  | `channels.wecom.agent.corpSecret` | string | 否 | 自建应用 Secret |
211
217
  | `channels.wecom.agent.agentId` | number | 否 | 自建应用 AgentId |
218
+ | `channels.wecom.agent.replyFormat` | string | 否 | 回调入站回复格式,`"markdown"`(默认)或 `"text"` |
219
+ | `channels.wecom.agent.callback.token` | string | 否 | 回调验签 Token |
220
+ | `channels.wecom.agent.callback.encodingAESKey` | string | 否 | 回调消息解密密钥(43 位) |
221
+ | `channels.wecom.agent.callback.path` | string | 否 | 回调 HTTP 路由路径,如 `"/webhooks/app"` |
212
222
  | `channels.wecom.webhooks` | object | 否 | 群机器人 webhook 映射 |
213
223
  | `channels.wecom.network.egressProxyUrl` | string | 否 | Agent / Webhook 出站代理 |
214
224
  | `channels.wecom.network.apiBaseUrl` | string | 否 | 企业微信 API 基础地址覆盖,默认官方地址 |
215
225
 
216
- Agent 增强出站不需要 `token`、`encodingAesKey`、回调 URL
226
+ Agent 增强出站不需要 `token`、`encodingAesKey`、回调 URL;只有需要同时启用**回调入站**时才需配置 `agent.callback.*`。
217
227
 
218
228
  ### 多账号示例
219
229
 
@@ -295,14 +305,19 @@ Agent 增强出站不需要 `token`、`encodingAesKey`、回调 URL。
295
305
 
296
306
  长连接模式下不需要配置 HTTP 回调 URL、Token、EncodingAESKey。
297
307
 
298
- ### 2. 创建自建应用(Agent 增强出站,可选)
308
+ ### 2. 创建自建应用(Agent 增强出站 + 可选回调入站)
299
309
 
300
- Agent 在本项目里只承担增强出站:
310
+ 自建应用承担两个可选职责:
301
311
 
312
+ **增强出站**(主动推送消息):
302
313
  - 主动给用户 / 群 / 部门 / 标签发消息
303
314
  - 上传图片和文件
304
315
  - 当 WS 断连时作为回复后备通道
305
316
 
317
+ **回调入站**(可选,与 WS 并行):
318
+ - 企业微信将用户消息以 HTTP POST 推送到你的服务器
319
+ - 适合需要独立入站 URL 或不方便使用 WS 长连接的场景
320
+
306
321
  创建步骤:
307
322
 
308
323
  1. 在企业微信后台创建自建应用
@@ -311,8 +326,7 @@ Agent 在本项目里只承担增强出站:
311
326
  - `channels.wecom.agent.corpId`
312
327
  - `channels.wecom.agent.agentId`
313
328
  - `channels.wecom.agent.corpSecret`
314
-
315
- 不需要配置接收消息回调。
329
+ 4. **仅启用回调入站时**,额外在应用的「接收消息」中配置回调 URL,并填入对应的 `agent.callback.*` 配置(见[自建应用回调入站](#自建应用回调入站))
316
330
 
317
331
  ### 3. 配置群机器人(Webhook 增强出站,可选)
318
332
 
@@ -329,8 +343,8 @@ Webhook 只负责群通知。
329
343
 
330
344
  | 能力 | Bot(WS) | Agent API | Webhook |
331
345
  | --- | :---: | :---: | :---: |
332
- | 私聊接收 | ✅ | | — |
333
- | 群聊接收(可配置 @ 触发) | ✅ | | — |
346
+ | 私聊接收 | ✅ | ✅(回调入站) | — |
347
+ | 群聊接收(可配置 @ 触发) | ✅ | ✅(回调入站) | — |
334
348
  | 被动流式回复 | ✅ | — | — |
335
349
  | 被动最终帧图片 | ✅ | ✅ | ✅ |
336
350
  | 主动发送文本 | ✅ | ✅ | ✅ |
@@ -431,6 +445,63 @@ Webhook 只负责群通知。
431
445
 
432
446
  如果 OpenClaw 配置了 `bindings`,则优先按 binding 路由,不会被动态 Agent 覆盖。
433
447
 
448
+ ## 自建应用回调入站
449
+
450
+ 自建应用的「接收消息」HTTP 回调可作为额外的入站通道,与 WS 长连接并行运行,互不干扰。适合已有自建应用的场景,或需要独立回调 URL 的情况。
451
+
452
+ ### 配置
453
+
454
+ 在 `channels.wecom.agent` 下增加 `callback` 子对象:
455
+
456
+ ```json
457
+ {
458
+ "channels": {
459
+ "wecom": {
460
+ "botId": "aib-xxx",
461
+ "secret": "bot-secret-xxx",
462
+ "agent": {
463
+ "corpId": "wwxxxxxxxxxxxx",
464
+ "corpSecret": "app-secret-xxx",
465
+ "agentId": 1000002,
466
+ "replyFormat": "markdown",
467
+ "callback": {
468
+ "token": "YourToken",
469
+ "encodingAESKey": "43位密钥",
470
+ "path": "/webhooks/app"
471
+ }
472
+ }
473
+ }
474
+ }
475
+ }
476
+ ```
477
+
478
+ | 字段 | 说明 |
479
+ | --- | --- |
480
+ | `agent.callback.token` | 企业微信「接收消息」里配置的 Token |
481
+ | `agent.callback.encodingAESKey` | 43 位 EncodingAESKey |
482
+ | `agent.callback.path` | Gateway 监听的 HTTP 路径,需与企业微信后台填写的 URL 一致 |
483
+ | `agent.replyFormat` | 回复格式,`"markdown"`(默认)或 `"text"` |
484
+
485
+ ### 企业微信侧配置
486
+
487
+ 1. 进入自建应用 → 「接收消息」
488
+ 2. 填写 URL:`https://<your-gateway-host>:<port><path>`(例如 `https://example.com:18789/webhooks/app`)
489
+ 3. 填写 Token 和 EncodingAESKey(与配置保持一致)
490
+ 4. 点击「保存」,企业微信将发送 GET 请求验证(gateway 会自动回复 echostr)
491
+ 5. 验证通过后,用户发给自建应用的消息将通过 HTTP POST 推送到 gateway
492
+
493
+ ### 支持的消息类型
494
+
495
+ | 类型 | 说明 |
496
+ | --- | --- |
497
+ | `text` | 文本消息 |
498
+ | `image` | 图片(通过 Agent API 下载) |
499
+ | `voice` | 语音文件 |
500
+ | `file` | 文件 |
501
+ | `video` | 视频文件 |
502
+
503
+ 事件类消息(关注、进入会话等)会被静默忽略。
504
+
434
505
  ## 常见问题
435
506
 
436
507
  ### Q: 2.0 和之前最大的区别是什么?
@@ -500,6 +571,9 @@ openclaw-plugin-wecom/
500
571
  │ ├── accounts.js # 多账号管理与配置继承
501
572
  │ ├── agent-api.js # Agent API(Token、发送、上传)
502
573
  │ ├── allow-from.js # allowlist 规范化与匹配
574
+ │ ├── callback-crypto.js # 回调 AES 解密与签名验证
575
+ │ ├── callback-inbound.js # 自建应用回调入站处理
576
+ │ ├── callback-media.js # 回调媒体下载
503
577
  │ ├── channel-plugin.js # 核心通道(sendNotice / sendMedia)
504
578
  │ ├── commands.js # 指令白名单与命令拦截
505
579
  │ ├── constants.js # 常量定义
@@ -518,6 +592,8 @@ openclaw-plugin-wecom/
518
592
  └── tests/
519
593
  ├── accounts-reserved-keys.test.js
520
594
  ├── api-base-url.test.js
595
+ ├── callback-crypto.test.js
596
+ ├── callback-inbound.test.js
521
597
  ├── channel-plugin.media-type.test.js
522
598
  ├── channel-plugin.notice.test.js
523
599
  ├── dynamic-agent.test.js
package/index.js CHANGED
@@ -3,6 +3,8 @@ import { logger } from "./logger.js";
3
3
  import { wecomChannelPlugin } from "./wecom/channel-plugin.js";
4
4
  import { setOpenclawConfig, setRuntime } from "./wecom/state.js";
5
5
  import { buildReplyMediaGuidance } from "./wecom/ws-monitor.js";
6
+ import { listAccountIds, resolveAccount } from "./wecom/accounts.js";
7
+ import { createCallbackHandler } from "./wecom/callback-inbound.js";
6
8
 
7
9
  const plugin = {
8
10
  id: "wecom",
@@ -15,6 +17,20 @@ const plugin = {
15
17
  setOpenclawConfig(api.config);
16
18
  api.registerChannel({ plugin: wecomChannelPlugin });
17
19
 
20
+ // Register HTTP callback endpoints for all accounts that have callback config
21
+ for (const accountId of listAccountIds(api.config)) {
22
+ const account = resolveAccount(api.config, accountId);
23
+ if (!account?.callbackConfig) continue;
24
+ const { path: cbPath } = account.callbackConfig;
25
+ logger.info(`[CB] Registering callback endpoint for account=${accountId} path=${cbPath}`);
26
+ api.registerHttpRoute({
27
+ path: cbPath,
28
+ auth: "plugin",
29
+ match: "prefix",
30
+ handler: createCallbackHandler({ account, config: api.config, runtime: api.runtime }),
31
+ });
32
+ }
33
+
18
34
  api.on("before_prompt_build", (_event, ctx) => {
19
35
  if (ctx.channelId !== "wecom") {
20
36
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/wecom/accounts.js CHANGED
@@ -157,6 +157,14 @@ function buildAccount(accountId, config, meta = {}) {
157
157
  const configured = Boolean(botId && secret);
158
158
  const agentConfigured = Boolean(agent.corpId && agent.corpSecret && agent.agentId);
159
159
 
160
+ const callbackRaw = isPlainObject(agent.callback) ? agent.callback : {};
161
+ const callbackToken = String(callbackRaw.token ?? "").trim();
162
+ const callbackAESKey = String(callbackRaw.encodingAESKey ?? "").trim();
163
+ const callbackPath = String(callbackRaw.path ?? "").trim() || "/api/channels/wecom/callback";
164
+ const callbackConfigured = Boolean(callbackToken && callbackAESKey && agent.corpId);
165
+ // "markdown" enables WeCom markdown format for agent API replies; default "markdown"
166
+ const agentReplyFormat = String(agent.replyFormat ?? "markdown").trim() === "text" ? "text" : "markdown";
167
+
160
168
  return {
161
169
  accountId,
162
170
  name: String(safeConfig.name ?? accountId ?? DEFAULT_ACCOUNT_ID).trim() || accountId,
@@ -171,7 +179,9 @@ function buildAccount(accountId, config, meta = {}) {
171
179
  storageMode: meta.storageMode ?? "dictionary",
172
180
  entryKey: meta.entryKey ?? accountId,
173
181
  agentConfigured,
182
+ callbackConfigured,
174
183
  webhooksConfigured: isPlainObject(safeConfig.webhooks) && Object.keys(safeConfig.webhooks).length > 0,
184
+ agentReplyFormat,
175
185
  agentCredentials: agentConfigured
176
186
  ? {
177
187
  corpId: String(agent.corpId),
@@ -179,6 +189,14 @@ function buildAccount(accountId, config, meta = {}) {
179
189
  agentId: agent.agentId,
180
190
  }
181
191
  : null,
192
+ callbackConfig: callbackConfigured
193
+ ? {
194
+ token: callbackToken,
195
+ encodingAESKey: callbackAESKey,
196
+ path: callbackPath,
197
+ corpId: String(agent.corpId),
198
+ }
199
+ : null,
182
200
  };
183
201
  }
184
202
 
@@ -76,7 +76,8 @@ export async function getAccessToken(agent) {
76
76
  * @param {string} params.text
77
77
  */
78
78
  export async function agentSendText(params) {
79
- const { agent, toUser, toParty, toTag, chatId, text } = params;
79
+ const { agent, toUser, toParty, toTag, chatId, text, format = "text" } = params;
80
+ const msgtype = format === "markdown" ? "markdown" : "text";
80
81
  const token = await getAccessToken(agent);
81
82
 
82
83
  const useChat = Boolean(chatId);
@@ -85,14 +86,14 @@ export async function agentSendText(params) {
85
86
  : `${AGENT_API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
86
87
 
87
88
  const body = useChat
88
- ? { chatid: chatId, msgtype: "text", text: { content: text } }
89
+ ? { chatid: chatId, msgtype, [msgtype]: { content: text } }
89
90
  : {
90
91
  touser: toUser,
91
92
  toparty: toParty,
92
93
  totag: toTag,
93
- msgtype: "text",
94
+ msgtype,
94
95
  agentid: agent.agentId,
95
- text: { content: text },
96
+ [msgtype]: { content: text },
96
97
  };
97
98
 
98
99
  const res = await wecomFetch(url, {
@@ -103,7 +104,7 @@ export async function agentSendText(params) {
103
104
  const json = await res.json();
104
105
 
105
106
  if (json?.errcode !== 0) {
106
- throw new Error(`agent send text failed: ${json?.errcode} ${json?.errmsg}`);
107
+ throw new Error(`agent send ${msgtype} failed: ${json?.errcode} ${json?.errmsg}`);
107
108
  }
108
109
 
109
110
  if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
@@ -114,7 +115,7 @@ export async function agentSendText(params) {
114
115
  ]
115
116
  .filter(Boolean)
116
117
  .join(", ");
117
- throw new Error(`agent send text partial failure: ${details}`);
118
+ throw new Error(`agent send ${msgtype} partial failure: ${details}`);
118
119
  }
119
120
  }
120
121
 
@@ -0,0 +1,80 @@
1
+ /**
2
+ * WeCom self-built app callback cryptography utilities.
3
+ *
4
+ * Implements the signature verification and AES-256-CBC decryption required
5
+ * by the WeCom callback protocol:
6
+ * https://developer.work.weixin.qq.com/document/path/90239
7
+ */
8
+
9
+ import crypto from "node:crypto";
10
+
11
+ /**
12
+ * Verify a WeCom callback request signature.
13
+ *
14
+ * Signature algorithm:
15
+ * SHA1( sort([token, timestamp, nonce, msgEncrypt]).join("") )
16
+ * The result must equal the `msg_signature` query parameter.
17
+ *
18
+ * @param {object} params
19
+ * @param {string} params.token - Callback token from account config
20
+ * @param {string} params.timestamp - `timestamp` query parameter
21
+ * @param {string} params.nonce - `nonce` query parameter
22
+ * @param {string} params.msgEncrypt - The ciphertext extracted from the XML body
23
+ * @param {string} params.signature - `msg_signature` query parameter to verify against
24
+ * @returns {boolean}
25
+ */
26
+ export function verifyCallbackSignature({ token, timestamp, nonce, msgEncrypt, signature }) {
27
+ const items = [String(token), String(timestamp), String(nonce), String(msgEncrypt)].sort();
28
+ const digest = crypto.createHash("sha1").update(items.join("")).digest("hex");
29
+ return digest === String(signature);
30
+ }
31
+
32
+ /**
33
+ * Decrypt a WeCom AES-256-CBC encrypted callback message.
34
+ *
35
+ * Key derivation:
36
+ * key = Base64Decode(encodingAESKey + "=") → 32 bytes
37
+ * iv = key.slice(0, 16)
38
+ *
39
+ * Plaintext layout (after PKCS7 unpad):
40
+ * [ 16 random bytes | 4-byte msgLen (big-endian) | msgXml | corpId ]
41
+ *
42
+ * @param {object} params
43
+ * @param {string} params.encodingAESKey - 43-char key from WeCom config
44
+ * @param {string} params.encrypted - Base64-encoded ciphertext
45
+ * @returns {{ xml: string, corpId: string }}
46
+ */
47
+ export function decryptCallbackMessage({ encodingAESKey, encrypted }) {
48
+ const key = Buffer.from(encodingAESKey + "=", "base64"); // 32 bytes
49
+ const iv = key.subarray(0, 16);
50
+ const ciphertext = Buffer.from(encrypted, "base64");
51
+
52
+ const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
53
+ decipher.setAutoPadding(false);
54
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
55
+
56
+ // Strip PKCS7 padding
57
+ const padLen = decrypted[decrypted.length - 1];
58
+ if (padLen < 1 || padLen > 32) {
59
+ throw new Error(`Invalid PKCS7 padding byte: ${padLen}`);
60
+ }
61
+ const content = decrypted.subarray(0, decrypted.length - padLen);
62
+
63
+ // Strip 16-byte random prefix
64
+ const withoutRandom = content.subarray(16);
65
+
66
+ // Read 4-byte big-endian message length
67
+ if (withoutRandom.length < 4) {
68
+ throw new Error("Decrypted content too short");
69
+ }
70
+ const msgLen = withoutRandom.readUInt32BE(0);
71
+
72
+ if (withoutRandom.length < 4 + msgLen) {
73
+ throw new Error(`Decrypted content shorter than declared msgLen (${msgLen})`);
74
+ }
75
+
76
+ const xml = withoutRandom.subarray(4, 4 + msgLen).toString("utf8");
77
+ const corpId = withoutRandom.subarray(4 + msgLen).toString("utf8");
78
+
79
+ return { xml, corpId };
80
+ }
@@ -0,0 +1,618 @@
1
+ /**
2
+ * WeCom self-built app HTTP callback inbound channel.
3
+ *
4
+ * Registers an HTTP endpoint that:
5
+ * - Answers WeCom's GET URL-verification requests
6
+ * - Receives POST message callbacks, decrypts them, and dispatches to the LLM
7
+ *
8
+ * Reply is sent via the Agent API (agentSendText / agentSendMedia) instead of
9
+ * the WebSocket, so this path works independently of the AI Bot WS connection.
10
+ */
11
+
12
+ import path from "node:path";
13
+ import { logger } from "../logger.js";
14
+ import { agentSendText, agentUploadMedia, agentSendMedia } from "./agent-api.js";
15
+ import { checkDmPolicy } from "./dm-policy.js";
16
+ import { checkGroupPolicy } from "./group-policy.js";
17
+ import { resolveWecomCommandAuthorized } from "./allow-from.js";
18
+ import { checkCommandAllowlist, getCommandConfig, isWecomAdmin } from "./commands.js";
19
+ import {
20
+ extractGroupMessageContent,
21
+ generateAgentId,
22
+ getDynamicAgentConfig,
23
+ shouldTriggerGroupResponse,
24
+ shouldUseDynamicAgent,
25
+ } from "../dynamic-agent.js";
26
+ import { ensureDynamicAgentListed } from "./workspace-template.js";
27
+ import { normalizeThinkingTags } from "../think-parser.js";
28
+ import { MessageDeduplicator, splitTextByByteLimit } from "../utils.js";
29
+ import { recordInboundMessage, recordOutboundActivity } from "./runtime-telemetry.js";
30
+ import { setConfigProxyUrl } from "./http.js";
31
+ import { setApiBaseUrl } from "./constants.js";
32
+ import { dispatchLocks, streamContext } from "./state.js";
33
+ import {
34
+ CHANNEL_ID,
35
+ CALLBACK_INBOUND_MAX_BODY_BYTES,
36
+ CALLBACK_TIMESTAMP_TOLERANCE_S,
37
+ TEXT_CHUNK_LIMIT,
38
+ MESSAGE_PROCESS_TIMEOUT_MS,
39
+ MEDIA_IMAGE_PLACEHOLDER,
40
+ MEDIA_DOCUMENT_PLACEHOLDER,
41
+ } from "./constants.js";
42
+ import { verifyCallbackSignature, decryptCallbackMessage } from "./callback-crypto.js";
43
+ import { downloadCallbackMedia } from "./callback-media.js";
44
+ import {
45
+ buildInboundContext,
46
+ resolveChannelCore,
47
+ normalizeReplyPayload,
48
+ resolveReplyMediaLocalRoots,
49
+ } from "./ws-monitor.js";
50
+
51
+ const callbackDeduplicator = new MessageDeduplicator();
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function withCallbackTimeout(promise, timeoutMs, label) {
58
+ let timer;
59
+ const timeout = new Promise((_, reject) => {
60
+ timer = setTimeout(() => reject(new Error(label ?? `timed out after ${timeoutMs}ms`)), timeoutMs);
61
+ });
62
+ promise.catch(() => {});
63
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
64
+ }
65
+
66
+ /**
67
+ * Read the POST body up to maxBytes. Returns null if the body exceeded the limit.
68
+ */
69
+ async function readBody(req, maxBytes) {
70
+ return new Promise((resolve) => {
71
+ const chunks = [];
72
+ let total = 0;
73
+ let oversize = false;
74
+
75
+ req.on("data", (chunk) => {
76
+ if (oversize) return;
77
+ total += chunk.length;
78
+ if (total > maxBytes) {
79
+ oversize = true;
80
+ req.destroy();
81
+ resolve(null);
82
+ return;
83
+ }
84
+ chunks.push(chunk);
85
+ });
86
+
87
+ req.on("end", () => {
88
+ if (!oversize) {
89
+ resolve(Buffer.concat(chunks).toString("utf8"));
90
+ }
91
+ });
92
+
93
+ req.on("error", () => resolve(null));
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Extract a CDATA or plain element value from a simple WeCom XML string.
99
+ * WeCom callback XML is well-defined; a full parser is not required.
100
+ */
101
+ function extractXmlValue(xml, tag) {
102
+ const cdata = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`));
103
+ if (cdata) return cdata[1];
104
+ const plain = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
105
+ return plain ? plain[1] ?? null : null;
106
+ }
107
+
108
+ /**
109
+ * Parse a decrypted WeCom callback XML message into a normalised structure.
110
+ * Returns null for event frames (enter_chat, etc.) that carry no user content.
111
+ *
112
+ * @param {string} xml - Decrypted inner XML
113
+ * @returns {{ msgId, senderId, chatId, isGroupChat, text, mediaId, mediaType, voiceRecognition } | null}
114
+ */
115
+ export function parseCallbackMessageXml(xml) {
116
+ const msgType = extractXmlValue(xml, "MsgType");
117
+
118
+ // Events (subscribe, click, enter_chat …) are not user messages
119
+ if (!msgType || msgType === "event") {
120
+ return null;
121
+ }
122
+
123
+ const msgId = extractXmlValue(xml, "MsgId") ?? String(Date.now());
124
+ const senderId = extractXmlValue(xml, "FromUserName") ?? "";
125
+ if (!senderId) return null;
126
+
127
+ // Self-built app basic callback: group chats are not natively supported;
128
+ // treat every message as a direct message.
129
+ const isGroupChat = false;
130
+ const chatId = senderId;
131
+
132
+ let text = null;
133
+ let mediaId = null;
134
+ let mediaType = null;
135
+ let voiceRecognition = null;
136
+
137
+ if (msgType === "text") {
138
+ text = extractXmlValue(xml, "Content") ?? "";
139
+ } else if (msgType === "image") {
140
+ mediaId = extractXmlValue(xml, "MediaId");
141
+ mediaType = "image";
142
+ } else if (msgType === "voice") {
143
+ mediaId = extractXmlValue(xml, "MediaId");
144
+ mediaType = "voice";
145
+ // `Recognition` is populated when WeCom ASR is enabled for the app
146
+ voiceRecognition = extractXmlValue(xml, "Recognition");
147
+ text = voiceRecognition || null;
148
+ } else if (msgType === "file") {
149
+ mediaId = extractXmlValue(xml, "MediaId");
150
+ mediaType = "file";
151
+ } else if (msgType === "video") {
152
+ mediaId = extractXmlValue(xml, "MediaId");
153
+ mediaType = "file"; // treat video as generic file attachment
154
+ } else {
155
+ // Unknown type: log and skip
156
+ logger.debug(`[CB] Unsupported callback MsgType="${msgType}", ignoring`);
157
+ return null;
158
+ }
159
+
160
+ return { msgId, senderId, chatId, isGroupChat, text, mediaId, mediaType, voiceRecognition };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Load a local reply-media file (LLM-generated MEDIA:/FILE: directives)
165
+ // ---------------------------------------------------------------------------
166
+
167
+ async function loadLocalReplyMedia(mediaUrl, config, agentId, runtime) {
168
+ const normalized = String(mediaUrl ?? "").trim();
169
+ if (!normalized.startsWith("/") && !normalized.startsWith("sandbox:")) {
170
+ throw new Error(`Unsupported callback reply media URL scheme: ${mediaUrl}`);
171
+ }
172
+
173
+ if (typeof runtime?.media?.loadWebMedia === "function") {
174
+ const localRoots = resolveReplyMediaLocalRoots(config, agentId);
175
+ const loaded = await runtime.media.loadWebMedia(normalized.replace(/^sandbox:\/{0,2}/, "/"), { localRoots });
176
+ const filename = loaded.fileName || path.basename(normalized.replace(/^sandbox:\/+/, "")) || "file";
177
+ return { buffer: loaded.buffer, filename, contentType: loaded.contentType || "" };
178
+ }
179
+
180
+ // Fallback when runtime.media is unavailable
181
+ const { readFile } = await import("node:fs/promises");
182
+ const localPath = normalized.replace(/^sandbox:\/{0,2}/, "");
183
+ const buffer = await readFile(localPath);
184
+ return { buffer, filename: path.basename(localPath) || "file", contentType: "" };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Core dispatch
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Process a parsed callback message: route, dispatch to LLM, and reply via
193
+ * Agent API.
194
+ *
195
+ * @param {object} params
196
+ * @param {object} params.parsedMsg - Output of parseCallbackMessageXml()
197
+ * @param {object} params.account - Resolved account object (from accounts.js)
198
+ * @param {object} params.config - Full OpenClaw config
199
+ * @param {object} params.runtime - OpenClaw runtime
200
+ */
201
+ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
202
+ const { msgId, senderId, chatId, isGroupChat, text: rawText, mediaId, mediaType } = parsedMsg;
203
+ const core = resolveChannelCore(runtime);
204
+
205
+ // Deduplication (separate namespace from WS deduplicator to avoid cross-path conflicts)
206
+ const dedupKey = `cb:${account.accountId}:${msgId}`;
207
+ if (callbackDeduplicator.isDuplicate(dedupKey)) {
208
+ logger.debug(`[CB:${account.accountId}] Duplicate message ignored`, { msgId, senderId });
209
+ return;
210
+ }
211
+
212
+ recordInboundMessage({ accountId: account.accountId, chatId });
213
+
214
+ logger.info(`[CB:${account.accountId}] ← inbound`, {
215
+ senderId,
216
+ chatId,
217
+ msgId,
218
+ mediaType: mediaId ? mediaType : null,
219
+ textLength: rawText?.length ?? 0,
220
+ preview: rawText?.slice(0, 80) || (mediaId ? `[${mediaType}]` : ""),
221
+ });
222
+
223
+ // --- Policy checks ---
224
+
225
+ if (isGroupChat) {
226
+ const groupResult = checkGroupPolicy({ chatId, senderId, account, config });
227
+ if (!groupResult.allowed) return;
228
+ }
229
+
230
+ const dmResult = await checkDmPolicy({
231
+ senderId,
232
+ isGroup: isGroupChat,
233
+ account,
234
+ wsClient: null,
235
+ frame: null,
236
+ core,
237
+ sendReply: async ({ text }) => {
238
+ if (account.agentCredentials) {
239
+ await agentSendText({ agent: account.agentCredentials, toUser: senderId, text }).catch((err) =>
240
+ logger.warn(`[CB:${account.accountId}] DM policy reply failed: ${err.message}`),
241
+ );
242
+ }
243
+ },
244
+ });
245
+ if (!dmResult.allowed) return;
246
+
247
+ let text = rawText ?? "";
248
+
249
+ // Group mention gating (not typically reached since isGroupChat=false, but kept for future)
250
+ if (isGroupChat) {
251
+ if (!shouldTriggerGroupResponse(text, account.config)) {
252
+ return;
253
+ }
254
+ text = extractGroupMessageContent(text, account.config);
255
+ }
256
+
257
+ // --- Command allowlist ---
258
+ const senderIsAdmin = isWecomAdmin(senderId, account.config);
259
+ const commandAuthorized = resolveWecomCommandAuthorized({
260
+ cfg: config,
261
+ accountId: account.accountId,
262
+ senderId,
263
+ });
264
+ const commandCheck = checkCommandAllowlist(text, account.config);
265
+ if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
266
+ if (account.agentCredentials) {
267
+ const blockMsg = getCommandConfig(account.config).blockMessage;
268
+ await agentSendText({ agent: account.agentCredentials, toUser: senderId, text: blockMsg }).catch(
269
+ (err) => logger.warn(`[CB:${account.accountId}] Command block reply failed: ${err.message}`),
270
+ );
271
+ }
272
+ return;
273
+ }
274
+
275
+ // --- Inbound media download ---
276
+ const mediaList = [];
277
+ if (mediaId && account.agentCredentials) {
278
+ try {
279
+ const downloaded = await downloadCallbackMedia({
280
+ agent: account.agentCredentials,
281
+ mediaId,
282
+ type: mediaType === "image" ? "image" : mediaType === "voice" ? "voice" : "file",
283
+ runtime,
284
+ config,
285
+ });
286
+ mediaList.push(downloaded);
287
+ } catch (error) {
288
+ logger.error(`[CB:${account.accountId}] Inbound media download failed: ${error.message}`);
289
+ }
290
+ }
291
+
292
+ const effectiveText = text;
293
+
294
+ // --- Route resolution ---
295
+ const peerKind = isGroupChat ? "group" : "dm";
296
+ const peerId = isGroupChat ? chatId : senderId;
297
+ const dynamicConfig = getDynamicAgentConfig(account.config);
298
+ const dynamicAgentId =
299
+ dynamicConfig.enabled &&
300
+ shouldUseDynamicAgent({ chatType: peerKind, config: account.config, senderIsAdmin })
301
+ ? generateAgentId(peerKind, peerId, account.accountId)
302
+ : null;
303
+
304
+ if (dynamicAgentId) {
305
+ await ensureDynamicAgentListed(dynamicAgentId, account.config.workspaceTemplate);
306
+ }
307
+
308
+ const route = core.routing.resolveAgentRoute({
309
+ cfg: config,
310
+ channel: CHANNEL_ID,
311
+ accountId: account.accountId,
312
+ peer: { kind: peerKind, id: peerId },
313
+ });
314
+
315
+ const hasExplicitBinding =
316
+ Array.isArray(config?.bindings) &&
317
+ config.bindings.some(
318
+ (b) => b.match?.channel === CHANNEL_ID && b.match?.accountId === account.accountId,
319
+ );
320
+ if (dynamicAgentId && !hasExplicitBinding) {
321
+ route.agentId = dynamicAgentId;
322
+ route.sessionKey = `agent:${dynamicAgentId}:${peerKind}:${peerId}`;
323
+ }
324
+
325
+ // Build a body object that mirrors the WS frame.body structure expected by
326
+ // buildInboundContext, so we can reuse that shared helper directly.
327
+ const syntheticBody = {
328
+ msgid: msgId,
329
+ from: { userid: senderId },
330
+ chatid: isGroupChat ? chatId : senderId,
331
+ chattype: isGroupChat ? "group" : "single",
332
+ text: effectiveText ? { content: effectiveText } : undefined,
333
+ };
334
+
335
+ const { ctxPayload, storePath } = buildInboundContext({
336
+ runtime,
337
+ config,
338
+ account,
339
+ frame: null, // no WS frame on callback path
340
+ body: syntheticBody,
341
+ text: effectiveText,
342
+ mediaList,
343
+ route,
344
+ senderId,
345
+ chatId,
346
+ isGroupChat,
347
+ });
348
+ ctxPayload.CommandAuthorized = commandAuthorized;
349
+
350
+ void core.session
351
+ .recordSessionMetaFromInbound({
352
+ storePath,
353
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
354
+ ctx: ctxPayload,
355
+ })
356
+ .catch((err) => logger.error(`[CB] Session meta record failed: ${err.message}`));
357
+
358
+ // --- Dispatch ---
359
+ const state = { accumulatedText: "", replyMediaUrls: [] };
360
+ const streamId = `cb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
361
+
362
+ const runDispatch = async () => {
363
+ try {
364
+ await streamContext.run(
365
+ { streamId, streamKey: peerId, agentId: route.agentId, accountId: account.accountId },
366
+ async () => {
367
+ await core.reply.dispatchReplyWithBufferedBlockDispatcher({
368
+ ctx: ctxPayload,
369
+ cfg: config,
370
+ // Disable block-streaming since Agent API replies are sent atomically
371
+ replyOptions: { disableBlockStreaming: true },
372
+ dispatcherOptions: {
373
+ deliver: async (payload) => {
374
+ const normalized = normalizeReplyPayload(payload);
375
+ state.accumulatedText += normalized.text;
376
+ for (const mediaUrl of normalized.mediaUrls) {
377
+ if (!state.replyMediaUrls.includes(mediaUrl)) {
378
+ state.replyMediaUrls.push(mediaUrl);
379
+ }
380
+ }
381
+ },
382
+ onError: (error, info) => {
383
+ logger.error(`[CB] ${info.kind} reply block failed: ${error.message}`);
384
+ },
385
+ },
386
+ });
387
+ },
388
+ );
389
+
390
+ const finalText = normalizeThinkingTags(state.accumulatedText.trim()) || "模型暂时无法响应,请稍后重试。";
391
+
392
+ if (!account.agentCredentials) {
393
+ logger.warn(`[CB:${account.accountId}] No agent credentials configured; callback reply skipped`);
394
+ return;
395
+ }
396
+
397
+ const target = isGroupChat ? { chatId } : { toUser: senderId };
398
+
399
+ // Send reply text (chunked to stay within WeCom message size limits)
400
+ const chunks = splitTextByByteLimit(finalText, TEXT_CHUNK_LIMIT);
401
+ logger.info(`[CB:${account.accountId}] → outbound`, {
402
+ senderId,
403
+ chatId,
404
+ format: account.agentReplyFormat,
405
+ chunks: chunks.length,
406
+ totalLength: finalText.length,
407
+ preview: finalText.slice(0, 80),
408
+ });
409
+ for (const chunk of chunks) {
410
+ await agentSendText({
411
+ agent: account.agentCredentials,
412
+ ...target,
413
+ text: chunk,
414
+ format: account.agentReplyFormat,
415
+ });
416
+ }
417
+ recordOutboundActivity({ accountId: account.accountId });
418
+
419
+ // Send any LLM-generated media (MEDIA:/FILE: directives in reply)
420
+ for (const mediaUrl of state.replyMediaUrls) {
421
+ try {
422
+ const { buffer, filename, contentType } = await loadLocalReplyMedia(
423
+ mediaUrl,
424
+ config,
425
+ route.agentId,
426
+ runtime,
427
+ );
428
+ const agentMediaType = contentType.startsWith("image/") ? "image" : "file";
429
+ const uploadedMediaId = await agentUploadMedia({
430
+ agent: account.agentCredentials,
431
+ type: agentMediaType,
432
+ buffer,
433
+ filename,
434
+ });
435
+ await agentSendMedia({
436
+ agent: account.agentCredentials,
437
+ ...target,
438
+ mediaId: uploadedMediaId,
439
+ mediaType: agentMediaType,
440
+ });
441
+ recordOutboundActivity({ accountId: account.accountId });
442
+ } catch (mediaError) {
443
+ logger.error(`[CB:${account.accountId}] Failed to send reply media: ${mediaError.message}`);
444
+ }
445
+ }
446
+ } catch (error) {
447
+ logger.error(`[CB:${account.accountId}] Dispatch error: ${error.message}`);
448
+ if (account.agentCredentials) {
449
+ const target = isGroupChat ? { chatId } : { toUser: senderId };
450
+ try {
451
+ await agentSendText({
452
+ agent: account.agentCredentials,
453
+ ...target,
454
+ text: "处理消息时出错,请稍后再试。",
455
+ format: "text",
456
+ });
457
+ } catch (sendErr) {
458
+ logger.error(`[CB:${account.accountId}] Error fallback reply failed: ${sendErr.message}`);
459
+ }
460
+ }
461
+ }
462
+ };
463
+
464
+ // Serialise per-sender to prevent concurrent replies to the same user
465
+ const lockKey = `${account.accountId}:${peerId}`;
466
+ const previous = dispatchLocks.get(lockKey) ?? Promise.resolve();
467
+ const current = previous.then(runDispatch, runDispatch);
468
+ dispatchLocks.set(lockKey, current);
469
+ current.finally(() => {
470
+ if (dispatchLocks.get(lockKey) === current) {
471
+ dispatchLocks.delete(lockKey);
472
+ }
473
+ });
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // HTTP handler factory
478
+ // ---------------------------------------------------------------------------
479
+
480
+ /**
481
+ * Create an HTTP handler for a single WeCom account's callback endpoint.
482
+ *
483
+ * The handler is registered via `api.registerHttpRoute({ auth: "plugin" })` so
484
+ * WeCom's servers can POST to it directly without gateway authentication.
485
+ *
486
+ * @param {object} params
487
+ * @param {object} params.account - Resolved account object with callbackConfig
488
+ * @param {object} params.config - Full OpenClaw config
489
+ * @param {object} params.runtime - OpenClaw runtime
490
+ * @returns {Function} HTTP handler: (req, res) => Promise<boolean|void>
491
+ */
492
+ export function createCallbackHandler({ account, config, runtime }) {
493
+ const { token, encodingAESKey, corpId } = account.callbackConfig;
494
+
495
+ // Apply network config so wecomFetch uses the right proxy/base URL
496
+ const network = account.config.network ?? {};
497
+ setConfigProxyUrl(network.egressProxyUrl ?? "");
498
+ setApiBaseUrl(network.apiBaseUrl ?? "");
499
+
500
+ return async function callbackHandler(req, res) {
501
+ const rawUrl = req.url ?? "/";
502
+ const urlObj = new URL(rawUrl, "http://localhost");
503
+
504
+ const signature = urlObj.searchParams.get("msg_signature") ?? "";
505
+ const timestamp = urlObj.searchParams.get("timestamp") ?? "";
506
+ const nonce = urlObj.searchParams.get("nonce") ?? "";
507
+
508
+ // --- GET: WeCom URL ownership verification ---
509
+ if (req.method === "GET") {
510
+ const echostrCipher = urlObj.searchParams.get("echostr") ?? "";
511
+ if (!echostrCipher) {
512
+ res.writeHead(400);
513
+ res.end("missing echostr");
514
+ return true;
515
+ }
516
+ if (!verifyCallbackSignature({ token, timestamp, nonce, msgEncrypt: echostrCipher, signature })) {
517
+ logger.warn(`[CB:${account.accountId}] GET signature mismatch`);
518
+ res.writeHead(403);
519
+ res.end("forbidden");
520
+ return true;
521
+ }
522
+ try {
523
+ const { xml: plainEchostr } = decryptCallbackMessage({ encodingAESKey, encrypted: echostrCipher });
524
+ res.writeHead(200, { "Content-Type": "text/plain" });
525
+ res.end(plainEchostr);
526
+ } catch (err) {
527
+ logger.error(`[CB:${account.accountId}] GET echostr decrypt failed: ${err.message}`);
528
+ res.writeHead(500);
529
+ res.end("error");
530
+ }
531
+ return true;
532
+ }
533
+
534
+ // --- POST: message callback ---
535
+ if (req.method !== "POST") {
536
+ res.writeHead(405);
537
+ res.end("method not allowed");
538
+ return true;
539
+ }
540
+
541
+ const body = await readBody(req, CALLBACK_INBOUND_MAX_BODY_BYTES);
542
+ if (body === null) {
543
+ res.writeHead(413);
544
+ res.end("request body too large");
545
+ return true;
546
+ }
547
+
548
+ // Extract the encrypted payload from the outer XML wrapper
549
+ const encryptMatch = body.match(/<Encrypt><!\[CDATA\[([\s\S]*?)\]\]><\/Encrypt>/);
550
+ const msgEncrypt = encryptMatch?.[1];
551
+ if (!msgEncrypt) {
552
+ logger.warn(`[CB:${account.accountId}] No <Encrypt> field in POST body`);
553
+ res.writeHead(400);
554
+ res.end("bad request");
555
+ return true;
556
+ }
557
+
558
+ // Replay-attack protection: reject requests older than 5 minutes
559
+ const tsNum = Number(timestamp);
560
+ if (!Number.isFinite(tsNum) || Math.abs(Date.now() / 1000 - tsNum) > CALLBACK_TIMESTAMP_TOLERANCE_S) {
561
+ logger.warn(`[CB:${account.accountId}] Timestamp out of tolerance`, { timestamp });
562
+ res.writeHead(403);
563
+ res.end("forbidden");
564
+ return true;
565
+ }
566
+
567
+ // Signature verification
568
+ if (!verifyCallbackSignature({ token, timestamp, nonce, msgEncrypt, signature })) {
569
+ logger.warn(`[CB:${account.accountId}] POST signature mismatch`);
570
+ res.writeHead(403);
571
+ res.end("forbidden");
572
+ return true;
573
+ }
574
+
575
+ // Decrypt
576
+ let decryptedXml;
577
+ let callbackCorpId;
578
+ try {
579
+ const result = decryptCallbackMessage({ encodingAESKey, encrypted: msgEncrypt });
580
+ decryptedXml = result.xml;
581
+ callbackCorpId = result.corpId;
582
+ } catch (err) {
583
+ logger.error(`[CB:${account.accountId}] Decryption failed: ${err.message}`);
584
+ res.writeHead(400);
585
+ res.end("bad request");
586
+ return true;
587
+ }
588
+
589
+ // CorpId integrity check
590
+ if (callbackCorpId !== corpId) {
591
+ logger.warn(`[CB:${account.accountId}] CorpId mismatch (expected=${corpId} got=${callbackCorpId})`);
592
+ res.writeHead(403);
593
+ res.end("forbidden");
594
+ return true;
595
+ }
596
+
597
+ // Respond to WeCom immediately (WeCom requires a fast HTTP response)
598
+ res.writeHead(200, { "Content-Type": "text/plain" });
599
+ res.end("success");
600
+
601
+ // Process asynchronously so we don't block the HTTP response
602
+ const parsedMsg = parseCallbackMessageXml(decryptedXml);
603
+ if (!parsedMsg) {
604
+ // Event frame or unsupported type, already logged in parseCallbackMessageXml
605
+ return true;
606
+ }
607
+
608
+ withCallbackTimeout(
609
+ processCallbackMessage({ parsedMsg, account, config, runtime }),
610
+ MESSAGE_PROCESS_TIMEOUT_MS,
611
+ `Callback message processing timed out (msgId=${parsedMsg.msgId})`,
612
+ ).catch((err) => {
613
+ logger.error(`[CB:${account.accountId}] Failed to process callback message: ${err.message}`);
614
+ });
615
+
616
+ return true;
617
+ };
618
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * WeCom self-built app callback media downloader.
3
+ *
4
+ * Downloads inbound media (image/voice/file) from WeCom via the
5
+ * Agent API `/cgi-bin/media/get` endpoint using the access token
6
+ * obtained from the self-built app credentials.
7
+ */
8
+
9
+ import path from "node:path";
10
+ import { logger } from "../logger.js";
11
+ import { getAccessToken } from "./agent-api.js";
12
+ import { wecomFetch } from "./http.js";
13
+ import { AGENT_API_ENDPOINTS, CALLBACK_MEDIA_DOWNLOAD_TIMEOUT_MS } from "./constants.js";
14
+
15
+ /**
16
+ * Download a WeCom media file (image / voice / file) by MediaId via the
17
+ * self-built app access token and save it through the core media runtime.
18
+ *
19
+ * @param {object} params
20
+ * @param {object} params.agent - { corpId, corpSecret, agentId }
21
+ * @param {string} params.mediaId - WeCom MediaId
22
+ * @param {"image"|"voice"|"file"} params.type - media type hint
23
+ * @param {object} params.runtime - OpenClaw runtime (for saveMediaBuffer)
24
+ * @param {object} params.config - OpenClaw config (for mediaMaxMb)
25
+ * @returns {Promise<{ path: string, contentType: string }>}
26
+ */
27
+ export async function downloadCallbackMedia({ agent, mediaId, type, runtime, config }) {
28
+ const token = await getAccessToken(agent);
29
+ const url = `${AGENT_API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
30
+
31
+ const mediaMaxMb = config?.agents?.defaults?.mediaMaxMb ?? 5;
32
+ const maxBytes = mediaMaxMb * 1024 * 1024;
33
+
34
+ let response;
35
+ const controller = new AbortController();
36
+ const timeoutId = setTimeout(() => controller.abort(), CALLBACK_MEDIA_DOWNLOAD_TIMEOUT_MS);
37
+ try {
38
+ response = await wecomFetch(url, { signal: controller.signal });
39
+ } finally {
40
+ clearTimeout(timeoutId);
41
+ }
42
+
43
+ if (!response.ok) {
44
+ throw new Error(`WeCom media download failed: HTTP ${response.status} for mediaId=${mediaId}`);
45
+ }
46
+
47
+ const buffer = Buffer.from(await response.arrayBuffer());
48
+ const contentType =
49
+ response.headers.get("content-type") ||
50
+ (type === "image" ? "image/jpeg" : "application/octet-stream");
51
+
52
+ // Try to extract the filename from Content-Disposition
53
+ const disposition = response.headers.get("content-disposition") ?? "";
54
+ const filenameMatch = disposition.match(/filename[*\s]*=\s*(?:UTF-8''|")?([^";]+)/i);
55
+ const filename =
56
+ filenameMatch?.[1]?.trim() ||
57
+ (type === "image" ? `${mediaId}.jpg` : type === "voice" ? `${mediaId}.amr` : mediaId);
58
+
59
+ // Save via core media runtime when available
60
+ if (typeof runtime?.media?.saveMediaBuffer === "function") {
61
+ const saved = await runtime.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes, filename);
62
+ return { path: saved.path, contentType: saved.contentType };
63
+ }
64
+
65
+ // Fallback: write to OS temp dir
66
+ const { tmpdir } = await import("node:os");
67
+ const { writeFile } = await import("node:fs/promises");
68
+ const ext = path.extname(filename) || (type === "image" ? ".jpg" : ".bin");
69
+ const tempPath = path.join(
70
+ tmpdir(),
71
+ `wecom-cb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`,
72
+ );
73
+ await writeFile(tempPath, buffer);
74
+ logger.debug(`[CB] Media saved to temp path: ${tempPath}`);
75
+ return { path: tempPath, contentType };
76
+ }
@@ -308,6 +308,22 @@ export const wecomChannelPlugin = {
308
308
  allowFrom: { type: "array", items: { type: "string" } },
309
309
  groupPolicy: { enum: ["open", "allowlist", "disabled"] },
310
310
  groupAllowFrom: { type: "array", items: { type: "string" } },
311
+ agent: {
312
+ type: "object",
313
+ additionalProperties: true,
314
+ properties: {
315
+ replyFormat: { enum: ["text", "markdown"] },
316
+ callback: {
317
+ type: "object",
318
+ additionalProperties: false,
319
+ properties: {
320
+ token: { type: "string" },
321
+ encodingAESKey: { type: "string" },
322
+ path: { type: "string" },
323
+ },
324
+ },
325
+ },
326
+ },
311
327
  },
312
328
  },
313
329
  uiHints: {
@@ -316,6 +332,10 @@ export const wecomChannelPlugin = {
316
332
  websocketUrl: { label: "WebSocket URL", placeholder: DEFAULT_WS_URL },
317
333
  welcomeMessage: { label: "Welcome Message" },
318
334
  "agent.corpSecret": { sensitive: true, label: "Application Secret" },
335
+ "agent.replyFormat": { label: "Reply Format", placeholder: "text" },
336
+ "agent.callback.token": { label: "Callback Token" },
337
+ "agent.callback.encodingAESKey": { label: "Callback Encoding AES Key", sensitive: true },
338
+ "agent.callback.path": { label: "Callback Path", placeholder: "/api/channels/wecom/callback" },
319
339
  },
320
340
  },
321
341
  config: {
@@ -324,7 +344,8 @@ export const wecomChannelPlugin = {
324
344
  defaultAccountId: (cfg) => resolveDefaultAccountId(cfg),
325
345
  setAccountEnabled: ({ cfg, accountId, enabled }) => updateAccountConfig(cfg, accountId, { enabled }),
326
346
  deleteAccount: ({ cfg, accountId }) => deleteAccountConfig(cfg, accountId),
327
- isConfigured: (account) => Boolean(account.botId && account.secret),
347
+ isConfigured: (account) =>
348
+ Boolean((account.botId && account.secret) || account.callbackConfigured),
328
349
  describeAccount,
329
350
  resolveAllowFrom: ({ cfg, accountId }) => resolveAllowFromForAccount(cfg, accountId),
330
351
  formatAllowFrom: ({ allowFrom }) => normalizeAllowFromEntries(allowFrom.map((entry) => String(entry))),
@@ -90,6 +90,11 @@ export const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
90
90
  export const AGENT_API_REQUEST_TIMEOUT_MS = 15 * 1000;
91
91
  export const MAX_REQUEST_BODY_SIZE = 1024 * 1024;
92
92
 
93
+ // Callback (self-built app HTTP inbound) constants
94
+ export const CALLBACK_INBOUND_MAX_BODY_BYTES = 1 * 1024 * 1024;
95
+ export const CALLBACK_MEDIA_DOWNLOAD_TIMEOUT_MS = 30_000;
96
+ export const CALLBACK_TIMESTAMP_TOLERANCE_S = 300;
97
+
93
98
  export function getWebhookBotSendUrl() {
94
99
  return `${resolveApiBaseUrl()}/cgi-bin/webhook/send`;
95
100
  }
@@ -877,8 +877,9 @@ function buildInboundContext({
877
877
  OriginatingChannel: CHANNEL_ID,
878
878
  OriginatingTo: isGroupChat ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${senderId}`,
879
879
  CommandAuthorized: true,
880
- ReqId: frame.headers.req_id,
881
- WeComFrame: frame,
880
+ // frame is null for callback-inbound path; use body.msgid as fallback
881
+ ReqId: frame?.headers?.req_id ?? body?.msgid ?? "",
882
+ WeComFrame: frame ?? null,
882
883
  };
883
884
 
884
885
  if (mediaList.length > 0) {
@@ -932,6 +933,17 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
932
933
  const originalText = textParts.join("\n").trim();
933
934
  let text = originalText;
934
935
 
936
+ logger.info(`[WS:${account.accountId}] ← inbound`, {
937
+ senderId,
938
+ chatId,
939
+ isGroupChat,
940
+ messageId,
941
+ textLength: originalText.length,
942
+ imageCount: imageUrls.length,
943
+ fileCount: fileUrls.length,
944
+ preview: originalText.slice(0, 80) || (imageUrls.length ? "[image]" : fileUrls.length ? "[file]" : ""),
945
+ });
946
+
935
947
  if (!text && quoteContent) {
936
948
  text = quoteContent;
937
949
  }
@@ -1542,3 +1554,11 @@ export const wsMonitorTesting = {
1542
1554
  };
1543
1555
 
1544
1556
  export { buildReplyMediaGuidance };
1557
+
1558
+ // Shared internals used by callback-inbound.js
1559
+ export {
1560
+ buildInboundContext,
1561
+ resolveChannelCore,
1562
+ normalizeReplyPayload,
1563
+ resolveReplyMediaLocalRoots,
1564
+ };