@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 +83 -7
- package/index.js +16 -0
- package/package.json +1 -1
- package/wecom/accounts.js +18 -0
- package/wecom/agent-api.js +7 -6
- package/wecom/callback-crypto.js +80 -0
- package/wecom/callback-inbound.js +618 -0
- package/wecom/callback-media.js +76 -0
- package/wecom/channel-plugin.js +22 -1
- package/wecom/constants.js +5 -0
- package/wecom/ws-monitor.js +22 -2
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
|
-
|
|
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
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
|
|
package/wecom/agent-api.js
CHANGED
|
@@ -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
|
|
89
|
+
? { chatid: chatId, msgtype, [msgtype]: { content: text } }
|
|
89
90
|
: {
|
|
90
91
|
touser: toUser,
|
|
91
92
|
toparty: toParty,
|
|
92
93
|
totag: toTag,
|
|
93
|
-
msgtype
|
|
94
|
+
msgtype,
|
|
94
95
|
agentid: agent.agentId,
|
|
95
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/wecom/channel-plugin.js
CHANGED
|
@@ -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) =>
|
|
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))),
|
package/wecom/constants.js
CHANGED
|
@@ -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
|
}
|
package/wecom/ws-monitor.js
CHANGED
|
@@ -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
|
-
|
|
881
|
-
|
|
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
|
+
};
|