@sunnoy/wecom 1.8.0 → 1.9.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,7 +9,6 @@
9
9
  - [前置要求](#前置要求)
10
10
  - [安装](#安装)
11
11
  - [运行测试](#运行测试)
12
- - [运行真实 E2E 测试(远程 OpenClaw)](#运行真实-e2e-测试远程-openclaw)
13
12
 
14
13
  ### 配置与接入
15
14
  - [配置](#配置)
@@ -25,6 +24,7 @@
25
24
  - [流式回复能力](#流式回复能力)
26
25
  - [管理员用户](#管理员用户)
27
26
  - [动态 Agent 路由](#动态-agent-路由)
27
+ - [Bindings 路由(多 Agent 绑定)](#bindings-路由多-agent-绑定)
28
28
  - [支持的目标格式](#支持的目标格式)
29
29
  - [指令白名单](#指令白名单)
30
30
  - [消息防抖合并](#消息防抖合并)
@@ -102,46 +102,6 @@ npm test
102
102
 
103
103
  运行单元测试(使用 Node.js 内置测试运行器)。
104
104
 
105
- ### 运行真实 E2E 测试(远程 OpenClaw)
106
-
107
- 本项目新增了真实联调 e2e 用例(`tests/e2e/remote-wecom.e2e.test.js`),会对真实 `/webhooks/wecom` 做加密请求、验证握手、发送消息并轮询 stream 直到结束。
108
-
109
- 1. 使用你当前环境的 `ssh ali-ai` 一键执行(自动读取远程 `~/.openclaw/openclaw.json`,并建立本地隧道):
110
-
111
- ```bash
112
- npm run test:e2e:ali-ai
113
- ```
114
-
115
- 2. 或者手动指定环境变量执行:
116
-
117
- ```bash
118
- E2E_WECOM_BASE_URL=http://127.0.0.1:28789 \
119
- E2E_WECOM_TOKEN=xxx \
120
- E2E_WECOM_ENCODING_AES_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
121
- E2E_WECOM_WEBHOOK_PATH=/webhooks/wecom \
122
- npm run test:e2e
123
- ```
124
-
125
- 可选变量:
126
- - `E2E_WECOM_TEST_USER`(默认 `wecom-e2e-user`)
127
- - `E2E_WECOM_TEST_COMMAND`(默认 `/status`)
128
- - `E2E_WECOM_POLL_INTERVAL_MS`(默认 `1200`)
129
- - `E2E_WECOM_STREAM_TIMEOUT_MS`(默认 `90000`)
130
- - `E2E_WECOM_ENABLE_BROWSER_CASE`(默认 `1`,设置 `0` 可跳过浏览器场景)
131
- - `E2E_WECOM_BROWSER_TIMEOUT_MS`(默认 `180000`)
132
- - `E2E_WECOM_BROWSER_REQUIRE_IMAGE`(默认 `0`,设置 `1` 强制断言 `msg_item` 图片出站)
133
- - `E2E_WECOM_BROWSER_PROMPT`(浏览器场景自定义提示词)
134
- - `E2E_WECOM_BROWSER_BING_PDF_PROMPT`(Bing + 保存 PDF 场景提示词)
135
- - `E2E_WECOM_ENABLE_BROWSER_BING_PDF_CASE`(默认 `1`)
136
- - `E2E_BROWSER_PREPARE_MODE`(`check`/`install`/`off`,默认 `check`)
137
- - `E2E_BROWSER_REQUIRE_READY`(默认 `0`,设置 `1` 时浏览器环境不满足则中止)
138
- - `E2E_COLLECT_BROWSER_PDF`(默认 `1`,执行后自动收集远程 sandbox 中的 PDF)
139
- - `E2E_PDF_OUTPUT_DIR`(默认 `tests/e2e/artifacts`)
140
-
141
- > 说明:`test:e2e:ali-ai` 会消耗远程实例的真实 LLM token,并覆盖多种真实入站/出站场景(含浏览器相关场景)。
142
- > 说明:执行 `test:e2e:ali-ai` 会先做 browser sandbox 准备检查(`prepare-browser-sandbox.sh`),测试后会尝试抓取 PDF 产物(`collect-browser-pdf.sh`)供用户下载。
143
- > 说明:当 browser sandbox 未就绪(缺浏览器二进制或缺 `browser` skill)时,Bing+PDF case 会自动跳过,并在准备检查输出中标记 `STATUS=MISSING`。
144
-
145
105
  ## 配置
146
106
 
147
107
  在 OpenClaw 配置文件(`~/.openclaw/openclaw.json`)中添加:
@@ -657,6 +617,49 @@ openclaw agent --agent myagent \
657
617
 
658
618
  模板目录中的文件会复制到动态 Agent 的工作区(`~/.openclaw/workspace-<agentId>/`),仅当目标文件不存在时才会复制。
659
619
 
620
+ ## Bindings 路由(多 Agent 绑定)
621
+
622
+ 通过 OpenClaw 的 `bindings` 配置,可以将不同的 WeCom 账户绑定到不同的 Agent,实现多 Agent 精确路由。
623
+
624
+ ### 配置示例
625
+
626
+ ```json
627
+ {
628
+ "bindings": [
629
+ {
630
+ "agentId": "amy",
631
+ "match": {
632
+ "channel": "wecom",
633
+ "accountId": "bot1"
634
+ }
635
+ },
636
+ {
637
+ "agentId": "bob",
638
+ "match": {
639
+ "channel": "wecom",
640
+ "accountId": "bot2"
641
+ }
642
+ }
643
+ ]
644
+ }
645
+ ```
646
+
647
+ ### 工作原理
648
+
649
+ 1. 当消息到达时,插件检查 `bindings` 中是否有匹配当前 `channel: "wecom"` 和 `accountId` 的条目
650
+ 2. 如果匹配到 binding,使用 binding 指定的 `agentId` 路由,**不会被动态 Agent 覆盖**
651
+ 3. 如果没有匹配的 binding,按正常的动态 Agent 路由逻辑处理
652
+
653
+ ### 与动态 Agent 的关系
654
+
655
+ | 场景 | 路由结果 |
656
+ |------|---------|
657
+ | 有匹配 binding | 使用 binding 中的 `agentId` |
658
+ | 无 binding + 动态 Agent 开启 | 自动生成 `wecom-dm-<userId>` 等 |
659
+ | 无 binding + 动态 Agent 关闭 | 使用默认 Agent |
660
+
661
+ > 💡 **典型场景**:多账号模式下,`bot1` 的所有消息路由到 `amy` Agent,`bot2` 的消息路由到 `bob` Agent,各自拥有独立的指令集和上下文。
662
+
660
663
  ## 支持的目标格式
661
664
 
662
665
  插件支持多种目标格式,用于消息路由和 Webhook 发送:
@@ -944,13 +947,18 @@ openclaw-plugin-wecom/
944
947
  │ ├── webhook-targets.js # Webhook 目标管理
945
948
  │ └── workspace-template.js # 工作区模板
946
949
  ├── tests/ # 测试目录
947
- │ ├── e2e/
948
- ├── remote-wecom.e2e.test.js # 真实远程 E2E(加密请求 + stream 轮询)
949
- │ └── run-ali-ai.sh # ssh ali-ai 一键联调脚本
950
- ├── prepare-browser-sandbox.sh # browser sandbox 环境检查/准备
951
- │ └── collect-browser-pdf.sh # 收集并下载 PDF 测试产物
950
+ │ ├── accounts-reserved-keys.test.js # 多账号保留键测试
951
+ │ ├── api-base-url.test.js # API 基础 URL 测试
952
+ ├── channel-plugin.media-type.test.js # 媒体类型测试
953
+ │ ├── dynamic-agent.test.js # 动态 Agent 路由测试
954
+ ├── http-handler.test.js # HTTP 处理器测试
955
+ │ ├── inbound-processor.image-merge.test.js # 图片合并测试
956
+ │ ├── issue-fixes.test.js # Issue 修复验证测试
952
957
  │ ├── outbound.test.js # 出站投递回退逻辑测试
958
+ │ ├── outbound-security.test.js # 出站安全测试
953
959
  │ ├── target.test.js # 目标解析器测试
960
+ │ ├── think-parser.test.js # 思考标签解析测试
961
+ │ ├── workspace-template.test.js # 工作区模板测试
954
962
  │ └── xml-parser.test.js # XML 解析器测试
955
963
  ├── README.md # 本文档
956
964
  ├── CONTRIBUTING.md # 贡献指南
package/index.js CHANGED
@@ -4,45 +4,50 @@ import { wecomChannelPlugin } from "./wecom/channel-plugin.js";
4
4
  import { wecomHttpHandler } from "./wecom/http-handler.js";
5
5
  import { responseUrls, setOpenclawConfig, setRuntime, streamMeta } from "./wecom/state.js";
6
6
 
7
- // Periodic cleanup for streamMeta and expired responseUrls to prevent memory leaks.
8
- setInterval(() => {
9
- const now = Date.now();
10
- // Clean streamMeta entries whose stream no longer exists in streamManager.
11
- for (const streamId of streamMeta.keys()) {
12
- if (!streamManager.hasStream(streamId)) {
13
- streamMeta.delete(streamId);
14
- }
15
- }
16
- // Clean expired responseUrls (older than 1 hour).
17
- for (const [key, entry] of responseUrls.entries()) {
18
- if (now > entry.expiresAt) {
19
- responseUrls.delete(key);
20
- }
21
- }
22
- }, 60 * 1000).unref();
7
+ function emptyPluginConfigSchema() {
8
+ return {
9
+ safeParse(value) {
10
+ if (value === undefined) return { success: true, data: undefined };
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
12
+ return { success: false, error: { message: "expected config object" } };
13
+ }
14
+ return { success: true, data: value };
15
+ },
16
+ };
17
+ }
18
+
19
+ let cleanupTimer = null;
23
20
 
24
21
  const plugin = {
25
- // Plugin id should match `openclaw.plugin.json` id (and config.plugins.entries key).
26
22
  id: "wecom",
27
23
  name: "Enterprise WeChat",
28
24
  description: "Enterprise WeChat AI Bot channel plugin for OpenClaw",
29
- configSchema: { type: "object", additionalProperties: true, properties: {} },
25
+ configSchema: emptyPluginConfigSchema(),
30
26
  register(api) {
31
27
  logger.info("WeCom plugin registering...");
32
28
 
33
- // Save runtime for message processing
34
29
  setRuntime(api.runtime);
35
30
  setOpenclawConfig(api.config);
36
31
 
37
- // Register channel
32
+ if (cleanupTimer) clearInterval(cleanupTimer);
33
+ cleanupTimer = setInterval(() => {
34
+ const now = Date.now();
35
+ for (const streamId of streamMeta.keys()) {
36
+ if (!streamManager.hasStream(streamId)) {
37
+ streamMeta.delete(streamId);
38
+ }
39
+ }
40
+ for (const [key, entry] of responseUrls.entries()) {
41
+ if (now > entry.expiresAt) {
42
+ responseUrls.delete(key);
43
+ }
44
+ }
45
+ }, 60_000);
46
+ cleanupTimer.unref();
47
+
38
48
  api.registerChannel({ plugin: wecomChannelPlugin });
39
49
  logger.info("WeCom channel registered");
40
50
 
41
- // Register webhook HTTP route with auth: "plugin" so gateway does NOT
42
- // enforce Bearer-token auth. WeCom callbacks use msg_signature verification
43
- // which the plugin handles internally.
44
- // OpenClaw 3.2 removed registerHttpHandler; use registerHttpRoute with
45
- // auth: "plugin" + match: "prefix" to handle all /webhooks/* paths.
46
51
  api.registerHttpRoute({
47
52
  path: "/webhooks",
48
53
  handler: wecomHttpHandler,
@@ -2,12 +2,12 @@
2
2
  "id": "wecom",
3
3
  "name": "OpenClaw WeCom",
4
4
  "description": "Enterprise WeChat (WeCom) messaging channel plugin for OpenClaw",
5
- "channels": [
6
- "wecom"
7
- ],
8
5
  "configSchema": {
9
6
  "type": "object",
10
7
  "additionalProperties": true,
11
8
  "properties": {}
12
- }
9
+ },
10
+ "channels": [
11
+ "wecom"
12
+ ]
13
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -28,9 +28,7 @@
28
28
  },
29
29
  "scripts": {
30
30
  "test": "npm run test:unit",
31
- "test:unit": "node --test tests/*.test.js",
32
- "test:e2e": "node --test tests/e2e/*.e2e.test.js",
33
- "test:e2e:ali-ai": "bash tests/e2e/run-ali-ai.sh"
31
+ "test:unit": "node --test tests/*.test.js"
34
32
  },
35
33
  "openclaw": {
36
34
  "extensions": [
package/utils.js CHANGED
@@ -79,6 +79,57 @@ export class MessageDeduplicator {
79
79
  this.seen.set(msgId, true);
80
80
  }
81
81
  }
82
+ // ============================================================================
83
+ // Text chunking for WeCom Agent API (2048-byte limit per message)
84
+ // ============================================================================
85
+ const AGENT_TEXT_BYTE_LIMIT = 2000; // safe margin below 2048
86
+
87
+ /**
88
+ * Split a string into chunks that each fit within a byte limit (UTF-8).
89
+ * Splits at newline boundaries when possible, otherwise at character boundaries.
90
+ */
91
+ export function splitTextByByteLimit(text, limit = AGENT_TEXT_BYTE_LIMIT) {
92
+ if (Buffer.byteLength(text, "utf8") <= limit) {
93
+ return [text];
94
+ }
95
+
96
+ const chunks = [];
97
+ let remaining = text;
98
+
99
+ while (remaining.length > 0) {
100
+ if (Buffer.byteLength(remaining, "utf8") <= limit) {
101
+ chunks.push(remaining);
102
+ break;
103
+ }
104
+
105
+ // Binary search for the max char index that fits within the byte limit.
106
+ let lo = 0;
107
+ let hi = remaining.length;
108
+ while (lo < hi) {
109
+ const mid = (lo + hi + 1) >>> 1;
110
+ if (Buffer.byteLength(remaining.slice(0, mid), "utf8") <= limit) {
111
+ lo = mid;
112
+ } else {
113
+ hi = mid - 1;
114
+ }
115
+ }
116
+
117
+ let splitAt = lo;
118
+
119
+ // Prefer splitting at a newline boundary within the last 20% of the chunk.
120
+ const searchStart = Math.max(0, Math.floor(splitAt * 0.8));
121
+ const lastNewline = remaining.lastIndexOf("\n", splitAt - 1);
122
+ if (lastNewline >= searchStart) {
123
+ splitAt = lastNewline + 1;
124
+ }
125
+
126
+ chunks.push(remaining.slice(0, splitAt));
127
+ remaining = remaining.slice(splitAt);
128
+ }
129
+
130
+ return chunks;
131
+ }
132
+
82
133
  // ============================================================================
83
134
  // Constants
84
135
  // ============================================================================
@@ -27,6 +27,7 @@ import { resolveAgentMediaTypeFromFilename } from "./channel-plugin.js";
27
27
  import { wecomFetch } from "./http.js";
28
28
  import { getRuntime, resolveAgentConfig } from "./state.js";
29
29
  import { ensureDynamicAgentListed } from "./workspace-template.js";
30
+ import { splitTextByByteLimit } from "../utils.js";
30
31
  import {
31
32
  extractEncryptFromXml,
32
33
  parseXml,
@@ -332,9 +333,19 @@ async function processAgentMessage({
332
333
  peer: { kind: peerKind, id: peerId },
333
334
  });
334
335
 
335
- if (targetAgentId) {
336
+ const hasExplicitBinding = Array.isArray(config?.bindings) &&
337
+ config.bindings.some((b) =>
338
+ b.match?.channel === "wecom" && b.match?.accountId === accountId,
339
+ );
340
+
341
+ if (targetAgentId && !hasExplicitBinding) {
336
342
  route.agentId = targetAgentId;
337
343
  route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
344
+ } else if (hasExplicitBinding) {
345
+ logger.debug("[agent-inbound] explicit binding found, skipping dynamic agent override", {
346
+ accountId,
347
+ resolvedAgentId: route.agentId,
348
+ });
338
349
  }
339
350
 
340
351
  // ── Build inbound context ─────────────────────────────────────
@@ -469,15 +480,18 @@ async function processAgentMessage({
469
480
  }
470
481
  }
471
482
 
472
- // ── Handle text ────────────────────────────────────────
483
+ // ── Handle text (with chunking for long messages) ─────
473
484
  if (!text.trim()) return;
474
485
 
475
486
  try {
476
- // Agent mode: reply via API to the sender (DM, even for group messages)
477
- await agentSendText({ agent: agentConfig, toUser: fromUser, text });
487
+ const chunks = splitTextByByteLimit(text);
488
+ for (const chunk of chunks) {
489
+ await agentSendText({ agent: agentConfig, toUser: fromUser, text: chunk });
490
+ }
478
491
  logger.info("[agent-inbound] reply delivered", {
479
492
  kind: info.kind,
480
493
  to: fromUser,
494
+ chunks: chunks.length,
481
495
  contentPreview: text.substring(0, 50),
482
496
  });
483
497
  } catch (err) {
@@ -14,6 +14,7 @@ import { webhookSendImage, webhookSendText, webhookUploadFile, webhookSendFile }
14
14
  import { normalizeWebhookPath, registerWebhookTarget } from "./webhook-targets.js";
15
15
  import { wecomFetch, setConfigProxyUrl } from "./http.js";
16
16
  import { setApiBaseUrl } from "./constants.js";
17
+ import { splitTextByByteLimit } from "../utils.js";
17
18
 
18
19
 
19
20
  const AGENT_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
@@ -46,7 +47,7 @@ export const wecomChannelPlugin = {
46
47
  schema: {
47
48
  $schema: "http://json-schema.org/draft-07/schema#",
48
49
  type: "object",
49
- additionalProperties: false,
50
+ additionalProperties: true,
50
51
  properties: {
51
52
  enabled: {
52
53
  type: "boolean",
@@ -283,6 +284,7 @@ export const wecomChannelPlugin = {
283
284
  },
284
285
  // Outbound adapter: all replies are streamed for WeCom AI Bot compatibility.
285
286
  outbound: {
287
+ deliveryMode: "direct",
286
288
  sendText: async ({ cfg: _cfg, to, text, accountId: _accountId }) => {
287
289
  // `to` format: "wecom:userid" or "userid".
288
290
  const userId = to.replace(/^wecom:/, "");
@@ -399,10 +401,14 @@ export const wecomChannelPlugin = {
399
401
  if (agentConfig) {
400
402
  try {
401
403
  const agentTarget = (target && !target.webhook) ? target : { toUser: userId };
402
- await agentSendText({ agent: agentConfig, ...agentTarget, text });
404
+ const chunks = splitTextByByteLimit(text);
405
+ for (const chunk of chunks) {
406
+ await agentSendText({ agent: agentConfig, ...agentTarget, text: chunk });
407
+ }
403
408
  logger.info("WeCom: sent via Agent API fallback (sendText)", {
404
409
  userId,
405
410
  to,
411
+ chunks: chunks.length,
406
412
  contentPreview: text.substring(0, 50),
407
413
  });
408
414
  return {
@@ -128,6 +128,20 @@ async function handleWecomRequest(req, res, targets, query, path) {
128
128
  const body = Buffer.concat(chunks).toString("utf-8");
129
129
  logger.debug("WeCom message received", { bodyLength: body.length });
130
130
 
131
+ if (body.trimStart().startsWith("<")) {
132
+ logger.warn("WeCom: XML body received on Bot webhook (expected JSON). Agent callbacks should use /webhooks/app endpoint.", {
133
+ path,
134
+ bodyPreview: body.substring(0, 120),
135
+ });
136
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
137
+ res.end(
138
+ "Invalid format: Bot webhook expects JSON.\n" +
139
+ "If you are configuring a WeCom self-built application (自建应用), " +
140
+ "use the Agent callback URL: /webhooks/app/{accountId}",
141
+ );
142
+ return;
143
+ }
144
+
131
145
  const webhook = new WecomWebhook({
132
146
  token: target.account.token,
133
147
  encodingAesKey: target.account.encodingAesKey,
@@ -50,16 +50,15 @@ export function flushMessageBuffer(streamKey, target) {
50
50
  const mergedContent = messages.map((m) => m.content || "").filter(Boolean).join("\n");
51
51
  primaryMsg.content = mergedContent;
52
52
 
53
- // Merge image attachments.
54
- const allImageUrls = messages.flatMap((m) => m.imageUrls || []);
55
- if (allImageUrls.length > 0) {
56
- primaryMsg.imageUrls = allImageUrls;
57
- }
58
- const singleImages = messages.map((m) => m.imageUrl).filter(Boolean);
59
- if (singleImages.length > 0 && !primaryMsg.imageUrl) {
60
- primaryMsg.imageUrl = singleImages[0];
61
- if (singleImages.length > 1) {
62
- primaryMsg.imageUrls = [...(primaryMsg.imageUrls || []), ...singleImages.slice(1)];
53
+ // Merge image attachments from all buffered messages.
54
+ // Collect single imageUrl fields first, then multi imageUrls arrays.
55
+ const allSingleImageUrls = messages.map((m) => m.imageUrl).filter(Boolean);
56
+ const allMultiImageUrls = messages.flatMap((m) => m.imageUrls || []);
57
+ const mergedImageUrls = [...allSingleImageUrls, ...allMultiImageUrls];
58
+ if (mergedImageUrls.length > 0) {
59
+ primaryMsg.imageUrl = mergedImageUrls[0];
60
+ if (mergedImageUrls.length > 1) {
61
+ primaryMsg.imageUrls = mergedImageUrls.slice(1);
63
62
  }
64
63
  }
65
64
 
@@ -275,10 +274,21 @@ export async function processInboundMessage({
275
274
  },
276
275
  });
277
276
 
278
- // Override default route with deterministic dynamic agent session key.
279
- if (targetAgentId) {
277
+ // Override default route with deterministic dynamic agent session key,
278
+ // but respect explicit bindings configured for this channel + account.
279
+ const hasExplicitBinding = Array.isArray(config?.bindings) &&
280
+ config.bindings.some((b) =>
281
+ b.match?.channel === "wecom" && b.match?.accountId === account.accountId,
282
+ );
283
+
284
+ if (targetAgentId && !hasExplicitBinding) {
280
285
  route.agentId = targetAgentId;
281
286
  route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
287
+ } else if (hasExplicitBinding) {
288
+ logger.debug("WeCom: explicit binding found, skipping dynamic agent override", {
289
+ accountId: account.accountId,
290
+ resolvedAgentId: route.agentId,
291
+ });
282
292
  }
283
293
 
284
294
  // Build inbound context
@@ -324,7 +334,9 @@ export async function processInboundMessage({
324
334
  };
325
335
 
326
336
  // Download, decrypt, and attach media when present.
327
- const allImageUrls = imageUrl ? [imageUrl] : imageUrls;
337
+ // Combine imageUrl (single) and imageUrls (array) so both are processed
338
+ // when a merged message carries values in both fields.
339
+ const allImageUrls = [imageUrl, ...imageUrls].filter(Boolean);
328
340
 
329
341
  if (allImageUrls.length > 0) {
330
342
  const mediaPaths = [];