@sunnoy/wecom 1.3.0 → 1.4.1

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
@@ -61,6 +61,46 @@ npm test
61
61
 
62
62
  运行单元测试(使用 Node.js 内置测试运行器)。
63
63
 
64
+ ### 运行真实 E2E 测试(远程 OpenClaw)
65
+
66
+ 本项目新增了真实联调 e2e 用例(`tests/e2e/remote-wecom.e2e.test.js`),会对真实 `/webhooks/wecom` 做加密请求、验证握手、发送消息并轮询 stream 直到结束。
67
+
68
+ 1. 使用你当前环境的 `ssh ali-ai` 一键执行(自动读取远程 `~/.openclaw/openclaw.json`,并建立本地隧道):
69
+
70
+ ```bash
71
+ npm run test:e2e:ali-ai
72
+ ```
73
+
74
+ 2. 或者手动指定环境变量执行:
75
+
76
+ ```bash
77
+ E2E_WECOM_BASE_URL=http://127.0.0.1:28789 \
78
+ E2E_WECOM_TOKEN=xxx \
79
+ E2E_WECOM_ENCODING_AES_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
80
+ E2E_WECOM_WEBHOOK_PATH=/webhooks/wecom \
81
+ npm run test:e2e
82
+ ```
83
+
84
+ 可选变量:
85
+ - `E2E_WECOM_TEST_USER`(默认 `wecom-e2e-user`)
86
+ - `E2E_WECOM_TEST_COMMAND`(默认 `/status`)
87
+ - `E2E_WECOM_POLL_INTERVAL_MS`(默认 `1200`)
88
+ - `E2E_WECOM_STREAM_TIMEOUT_MS`(默认 `90000`)
89
+ - `E2E_WECOM_ENABLE_BROWSER_CASE`(默认 `1`,设置 `0` 可跳过浏览器场景)
90
+ - `E2E_WECOM_BROWSER_TIMEOUT_MS`(默认 `180000`)
91
+ - `E2E_WECOM_BROWSER_REQUIRE_IMAGE`(默认 `0`,设置 `1` 强制断言 `msg_item` 图片出站)
92
+ - `E2E_WECOM_BROWSER_PROMPT`(浏览器场景自定义提示词)
93
+ - `E2E_WECOM_BROWSER_BING_PDF_PROMPT`(Bing + 保存 PDF 场景提示词)
94
+ - `E2E_WECOM_ENABLE_BROWSER_BING_PDF_CASE`(默认 `1`)
95
+ - `E2E_BROWSER_PREPARE_MODE`(`check`/`install`/`off`,默认 `check`)
96
+ - `E2E_BROWSER_REQUIRE_READY`(默认 `0`,设置 `1` 时浏览器环境不满足则中止)
97
+ - `E2E_COLLECT_BROWSER_PDF`(默认 `1`,执行后自动收集远程 sandbox 中的 PDF)
98
+ - `E2E_PDF_OUTPUT_DIR`(默认 `tests/e2e/artifacts`)
99
+
100
+ > 说明:`test:e2e:ali-ai` 会消耗远程实例的真实 LLM token,并覆盖多种真实入站/出站场景(含浏览器相关场景)。
101
+ > 说明:执行 `test:e2e:ali-ai` 会先做 browser sandbox 准备检查(`prepare-browser-sandbox.sh`),测试后会尝试抓取 PDF 产物(`collect-browser-pdf.sh`)供用户下载。
102
+ > 说明:当 browser sandbox 未就绪(缺浏览器二进制或缺 `browser` skill)时,Bing+PDF case 会自动跳过,并在准备检查输出中标记 `STATUS=MISSING`。
103
+
64
104
  ## 配置
65
105
 
66
106
  在 OpenClaw 配置文件(`~/.openclaw/openclaw.json`)中添加:
@@ -264,8 +304,10 @@ Webhook Bot 用于向群聊发送通知消息。
264
304
  ### 工作原理
265
305
 
266
306
  1. 企业微信消息到达后,插件生成确定性的 `agentId`:
267
- - **私聊**: `wecom-dm-<userId>`
268
- - **群聊**: `wecom-group-<chatId>`
307
+ - **单账号私聊**: `wecom-dm-<userId>`
308
+ - **单账号群聊**: `wecom-group-<chatId>`
309
+ - **多账号私聊**: `wecom-<accountId>-dm-<userId>`
310
+ - **多账号群聊**: `wecom-<accountId>-group-<chatId>`
269
311
  2. OpenClaw 自动创建/复用对应的 Agent 工作区
270
312
  3. 每个用户/群聊拥有独立的对话历史和上下文
271
313
  4. **管理员用户**跳过动态路由,直接使用主 Agent
@@ -314,6 +356,60 @@ Webhook Bot 用于向群聊发送通知消息。
314
356
  }
315
357
  ```
316
358
 
359
+ ### 多账号配置(Multi-Bot)
360
+
361
+ 支持在一个 OpenClaw 实例中接入多个企业微信机器人,每个机器人独立配置 Token、Agent 凭证、Webhook 等,互不干扰。
362
+
363
+ > 💡 **典型场景**:一个企业微信里创建多个 AI 机器人(如「客服助手」「技术支持」),各自对应不同的 Agent 和会话空间。
364
+
365
+ **配置方式:** 将 `channels.wecom` 下的值改为字典结构,每个 key 是账号 ID(如 `bot1`、`bot2`),value 包含该账号的完整配置:
366
+
367
+ ```json
368
+ {
369
+ "channels": {
370
+ "wecom": {
371
+ "bot1": {
372
+ "token": "Bot1 的 Token",
373
+ "encodingAesKey": "Bot1 的 EncodingAESKey",
374
+ "adminUsers": ["admin1"],
375
+ "agent": {
376
+ "corpId": "企业 CorpID",
377
+ "corpSecret": "Bot1 应用 Secret",
378
+ "agentId": 1000001,
379
+ "token": "Bot1 回调 Token",
380
+ "encodingAesKey": "Bot1 回调 EncodingAESKey"
381
+ },
382
+ "webhooks": {
383
+ "ops-group": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
384
+ }
385
+ },
386
+ "bot2": {
387
+ "token": "Bot2 的 Token",
388
+ "encodingAesKey": "Bot2 的 EncodingAESKey",
389
+ "agent": {
390
+ "corpId": "企业 CorpID",
391
+ "corpSecret": "Bot2 应用 Secret",
392
+ "agentId": 1000002
393
+ }
394
+ }
395
+ }
396
+ }
397
+ }
398
+ ```
399
+
400
+ **说明:**
401
+
402
+ | 项目 | 说明 |
403
+ |------|------|
404
+ | 账号 ID | 字典的 key,如 `bot1`、`bot2`,仅支持小写字母、数字、`-`、`_` |
405
+ | 完全兼容 | 旧的单账号配置(`token` 直接写在 `wecom` 下)自动识别为 `default` 账号,无需修改 |
406
+ | Webhook 路径 | 自动按账号分配:`/webhooks/wecom/bot1`、`/webhooks/wecom/bot2` |
407
+ | Agent 回调路径 | 自动按账号分配:`/webhooks/app/bot1`、`/webhooks/app/bot2` |
408
+ | 动态 Agent ID | 按账号隔离:`wecom-bot1-dm-{userId}`、`wecom-bot2-group-{chatId}` |
409
+ | 冲突检测 | 启动时自动检测重复的 Token 或 Agent ID,避免消息路由错乱 |
410
+
411
+ > ⚠️ **注意**:多账号模式下,每个账号的 Webhook URL 需要在企业微信后台分别配置对应的路径(如 `/webhooks/wecom/bot1`)。
412
+
317
413
  ### 工作区模板
318
414
 
319
415
  可以为动态创建的 Agent 工作区预置初始化文件。当新 Agent 首次创建时,会自动从模板目录复制 bootstrap 文件。
@@ -559,6 +655,36 @@ openclaw send "party:2" "全体员工通知"
559
655
 
560
656
  3. **检查是否有多余空格/换行**:确保密钥字符串前后没有空格或换行符
561
657
 
658
+ ### Q: 日志报错 reply delivery failed ... 60020 not allow to access from your ip 怎么办?
659
+
660
+ **A:** 这是企业微信对「自建应用 API 主动发送消息」的安全限制。错误码 60020 表示:当前服务器出口公网 IP 未加入企业微信应用的可信 IP 白名单。
661
+
662
+ **典型日志示例:**
663
+
664
+ ```bash
665
+ [wecom] [agent-inbound] reply delivery failed {"error":"agent send text failed: 60020 not allow to access from your ip, ... from ip: xx.xx.xx.xx"}
666
+ ```
667
+
668
+
669
+
670
+ **原因说明**
671
+
672
+ 当插件使用 Agent API 回退(或 Agent 模式主动推送)发送消息时,会调用企业微信开放接口(如 qyapi.weixin.qq.com)。
673
+ 如果企业微信后台为该应用启用了 企业可信IP / 接口可信IP 校验,而当前服务器出口公网 IP 不在白名单内,企业微信会拒绝请求并返回 60020。
674
+
675
+ **解决方法**
676
+
677
+ 1. 登录企业微信管理后台
678
+
679
+ 2. 进入对应的 自建应用 详情页
680
+
681
+ 3. 找到 企业可信IP 配置项
682
+
683
+ 4. 将服务器公网出口 IP 加入白名单
684
+ - 建议以错误日志中的 from ip 为准(你的服务器公网ip)
685
+
686
+ 5. 保存配置后重试发送消息
687
+
562
688
  ## 项目结构
563
689
 
564
690
  ```
@@ -592,6 +718,12 @@ openclaw-plugin-wecom/
592
718
  │ ├── webhook-targets.js # Webhook 目标管理
593
719
  │ └── workspace-template.js # 工作区模板
594
720
  ├── tests/ # 测试目录
721
+ │ ├── e2e/
722
+ │ │ ├── remote-wecom.e2e.test.js # 真实远程 E2E(加密请求 + stream 轮询)
723
+ │ │ └── run-ali-ai.sh # ssh ali-ai 一键联调脚本
724
+ │ │ ├── prepare-browser-sandbox.sh # browser sandbox 环境检查/准备
725
+ │ │ └── collect-browser-pdf.sh # 收集并下载 PDF 测试产物
726
+ │ ├── outbound.test.js # 出站投递回退逻辑测试
595
727
  │ ├── target.test.js # 目标解析器测试
596
728
  │ └── xml-parser.test.js # XML 解析器测试
597
729
  ├── README.md # 本文档
@@ -608,3 +740,248 @@ openclaw-plugin-wecom/
608
740
  ## 开源协议
609
741
 
610
742
  本项目采用 [ISC License](./LICENSE) 协议。
743
+
744
+ ## 配置示例参考
745
+
746
+ 以下是一个生产环境的脱敏配置示例,供参考:
747
+
748
+ ```json
749
+ {
750
+ "meta": {
751
+ "lastTouchedVersion": "2026.2.25",
752
+ "lastTouchedAt": "2026-02-28T03:14:11.564Z"
753
+ },
754
+ "wizard": {
755
+ "lastRunAt": "2026-02-26T09:29:04.028Z",
756
+ "lastRunVersion": "2026.2.25",
757
+ "lastRunCommand": "onboard",
758
+ "lastRunMode": "local"
759
+ },
760
+ "logging": {
761
+ "level": "info",
762
+ "consoleLevel": "debug",
763
+ "consoleStyle": "pretty"
764
+ },
765
+ "models": {
766
+ "mode": "merge",
767
+ "providers": {
768
+ "bailian": {
769
+ "baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
770
+ "apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxx",
771
+ "api": "openai-completions",
772
+ "models": [
773
+ { "id": "qwen3.5-plus", "name": "qwen3.5-plus", "reasoning": false, "input": ["text", "image"], "contextWindow": 1000000, "maxTokens": 65536 },
774
+ { "id": "MiniMax-M2.5", "name": "MiniMax-M2.5", "reasoning": false, "input": ["text"], "contextWindow": 1000000, "maxTokens": 65536 },
775
+ { "id": "glm-5", "name": "glm-5", "reasoning": false, "input": ["text"], "contextWindow": 202752, "maxTokens": 16384 },
776
+ { "id": "glm-4.7", "name": "glm-4.7", "reasoning": false, "input": ["text"], "contextWindow": 202752, "maxTokens": 16384 },
777
+ { "id": "kimi-k2.5", "name": "kimi-k2.5", "reasoning": false, "input": ["text", "image"], "contextWindow": 262144, "maxTokens": 32768 }
778
+ ]
779
+ }
780
+ }
781
+ },
782
+ "agents": {
783
+ "defaults": {
784
+ "model": { "primary": "bailian/kimi-k2.5" },
785
+ "models": {
786
+ "bailian/qwen3.5-plus": {},
787
+ "bailian/MiniMax-M2.5": {},
788
+ "bailian/glm-5": {},
789
+ "bailian/glm-4.7": {},
790
+ "bailian/kimi-k2.5": {}
791
+ },
792
+ "workspace": "/path/to/workspace",
793
+ "userTimezone": "Asia/Shanghai",
794
+ "timeFormat": "24",
795
+ "compaction": {
796
+ "mode": "safeguard",
797
+ "reserveTokensFloor": 20000,
798
+ "memoryFlush": {
799
+ "enabled": true,
800
+ "softThresholdTokens": 4000
801
+ }
802
+ },
803
+ "thinkingDefault": "medium",
804
+ "verboseDefault": "on",
805
+ "heartbeat": {
806
+ "every": "10m",
807
+ "target": "last",
808
+ "directPolicy": "allow"
809
+ },
810
+ "sandbox": {
811
+ "mode": "all",
812
+ "workspaceAccess": "rw",
813
+ "scope": "agent",
814
+ "docker": {
815
+ "image": "your-registry.com/openclaw-agent:v2026.x.x",
816
+ "readOnlyRoot": false,
817
+ "network": "bridge",
818
+ "extraHosts": [
819
+ "your-domain.internal:xxx.xxx.xxx.xxx"
820
+ ],
821
+ "binds": [
822
+ "/path/to/skills:/workspace/skills:ro"
823
+ ],
824
+ "dangerouslyAllowReservedContainerTargets": true,
825
+ "dangerouslyAllowExternalBindSources": true
826
+ },
827
+ "prune": {
828
+ "idleHours": 87600,
829
+ "maxAgeDays": 3650
830
+ }
831
+ }
832
+ },
833
+ "list": [
834
+ { "id": "main" },
835
+ { "id": "wecom-dm-xxxxxx" }
836
+ ]
837
+ },
838
+ "commands": {
839
+ "native": "auto",
840
+ "nativeSkills": "auto",
841
+ "restart": true,
842
+ "ownerDisplay": "raw"
843
+ },
844
+ "session": {
845
+ "dmScope": "per-channel-peer"
846
+ },
847
+ "hooks": {
848
+ "internal": {
849
+ "enabled": true,
850
+ "entries": {
851
+ "boot-md": { "enabled": true },
852
+ "command-logger": { "enabled": true },
853
+ "session-memory": { "enabled": true },
854
+ "bootstrap-extra-files": { "enabled": true }
855
+ }
856
+ }
857
+ },
858
+ "channels": {
859
+ "wecom": {
860
+ "enabled": true,
861
+ "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
862
+ "encodingAesKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
863
+ "commands": {
864
+ "enabled": true,
865
+ "allowlist": ["/help", "/commands", "/status", "/context", "/whoami", "/new", "/compact", "/stop", "/reset", "/usage", "/think", "/thinking", "/t", "/verbose", "/v", "/reasoning", "/reason", "/model", "/models", "/skill"]
866
+ },
867
+ "dynamicAgents": { "enabled": true },
868
+ "dm": { "createAgentOnFirstMessage": true },
869
+ "groupChat": { "enabled": true, "requireMention": true },
870
+ "adminUsers": ["admin_userid"],
871
+ "workspaceTemplate": "/path/to/workspace-template",
872
+ "agent": {
873
+ "corpId": "wwxxxxxxxxxxxxxxxx",
874
+ "corpSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
875
+ "agentId": 1000002,
876
+ "token": "xxxxxxxxxxxxxxx",
877
+ "encodingAesKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
878
+ }
879
+ }
880
+ },
881
+ "gateway": {
882
+ "port": 18789,
883
+ "mode": "local",
884
+ "bind": "lan",
885
+ "controlUi": {
886
+ "dangerouslyAllowHostHeaderOriginFallback": true,
887
+ "allowInsecureAuth": true
888
+ },
889
+ "auth": {
890
+ "mode": "token",
891
+ "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
892
+ },
893
+ "tailscale": {
894
+ "mode": "off",
895
+ "resetOnExit": false
896
+ }
897
+ },
898
+ "skills": {
899
+ "allowBundled": ["_none_"],
900
+ "load": {
901
+ "extraDirs": ["/path/to/skills"],
902
+ "watch": true,
903
+ "watchDebounceMs": 250
904
+ },
905
+ "install": { "nodeManager": "npm" }
906
+ },
907
+ "plugins": {
908
+ "allow": ["wecom"],
909
+ "entries": { "wecom": { "enabled": true } }
910
+ }
911
+ }
912
+ ```
913
+
914
+ ## 自定义 Skills 配合沙箱使用实践
915
+
916
+ OpenClaw 支持自定义 Skills 并通过沙箱(Docker)隔离执行,以下是生产环境的实践配置:
917
+
918
+
919
+ ### 沙箱配置关键点
920
+
921
+ ```json
922
+ {
923
+ "agents": {
924
+ "defaults": {
925
+ "sandbox": {
926
+ "mode": "all",
927
+ "workspaceAccess": "rw",
928
+ "scope": "agent",
929
+ "docker": {
930
+ "image": "your-registry.com/openclaw-agent:v2026.x.x",
931
+ "readOnlyRoot": false,
932
+ "network": "bridge",
933
+ "extraHosts": [
934
+ "your-domain.internal:xxx.xxx.xxx.xxx"
935
+ ],
936
+ "binds": [
937
+ "/path/to/skills:/workspace/skills:ro"
938
+ ],
939
+ "dangerouslyAllowReservedContainerTargets": true,
940
+ "dangerouslyAllowExternalBindSources": true
941
+ },
942
+ "prune": {
943
+ "idleHours": 87600,
944
+ "maxAgeDays": 3650
945
+ }
946
+ }
947
+ }
948
+ },
949
+ "skills": {
950
+ "allowBundled": ["_none_"],
951
+ "load": {
952
+ "extraDirs": ["/path/to/skills"],
953
+ "watch": true,
954
+ "watchDebounceMs": 250
955
+ }
956
+ }
957
+ }
958
+ ```
959
+
960
+ ### 配置说明
961
+
962
+ | 配置项 | 说明 |
963
+ |--------|------|
964
+ | `sandbox.mode` | 沙箱模式:`all` 所有操作都走沙箱 |
965
+ | `sandbox.workspaceAccess` | 工作区访问权限:`rw` 读写 |
966
+ | `sandbox.scope` | 沙箱作用域:`agent` 每个 Agent 独立沙箱 |
967
+ | `sandbox.docker.image` | 沙箱使用的 Docker 镜像 |
968
+ | `sandbox.docker.readOnlyRoot` | 是否只读根文件系统 |
969
+ | `sandbox.docker.network` | 网络模式:`bridge` 桥接网络 |
970
+ | `sandbox.docker.binds` | 挂载目录:将宿主机 skills 目录映射到沙箱内 `/workspace/skills`(只读) |
971
+ | `sandbox.docker.extraHosts` | 添加额外 hosts,解决内网服务域名解析 |
972
+ | `sandbox.docker.dangerouslyAllowReservedContainerTargets` | 允许容器访问保留目标 |
973
+ | `sandbox.docker.dangerouslyAllowExternalBindSources` | 允许外部绑定源 |
974
+ | `sandbox.prune.idleHours` | 空闲容器清理时间(小时) |
975
+ | `sandbox.prune.maxAgeDays` | 容器最大存活天数 |
976
+ | `skills.allowBundled` | 允许的内置 skills(`["_none_"]` 表示禁用所有内置) |
977
+ | `skills.load.extraDirs` | 自定义 skills 加载目录 |
978
+ | `skills.load.watch` | 启用热加载,修改 skill 无需重启 |
979
+ | `skills.load.watchDebounceMs` | 热加载防抖时间(毫秒) |
980
+
981
+ ### 使用流程
982
+
983
+ 1. 在宿主机创建自定义 skill 目录
984
+ 2. 配置 `binds` 将目录映射到沙箱
985
+ 3. 在 `skills.load.extraDirs` 指定加载路径
986
+ 4. Agent 在沙箱中可通过 `/workspace/skills` 访问自定义 skills
987
+ 5. 使用 `/skill` 命令查看和管理 skills
package/dynamic-agent.js CHANGED
@@ -8,25 +8,39 @@
8
8
  /**
9
9
  * Build a deterministic agent id for dm/group contexts.
10
10
  *
11
+ * When running in multi-account mode the accountId is embedded as a
12
+ * namespace segment so each account's conversations stay isolated:
13
+ * default → wecom-dm-{peerId} (backward compatible)
14
+ * "sales" → wecom-sales-dm-{peerId}
15
+ *
11
16
  * @param {string} chatType - "dm" or "group"
12
17
  * @param {string} peerId - user id or group id
18
+ * @param {string} [accountId] - optional account namespace ("default" is omitted)
13
19
  * @returns {string} agentId
14
20
  */
15
- export function generateAgentId(chatType, peerId) {
21
+ export function generateAgentId(chatType, peerId, accountId) {
16
22
  const sanitizedId = String(peerId)
17
23
  .toLowerCase()
18
24
  .replace(/[^a-z0-9_-]/g, "_");
25
+ // Only embed the account prefix for non-default accounts so existing
26
+ // single-account deployments keep identical agent ids (zero breaking change).
27
+ const ns = accountId && accountId !== "default" ? `${accountId}-` : "";
19
28
  if (chatType === "group") {
20
- return `wecom-group-${sanitizedId}`;
29
+ return `wecom-${ns}group-${sanitizedId}`;
21
30
  }
22
- return `wecom-dm-${sanitizedId}`;
31
+ return `wecom-${ns}dm-${sanitizedId}`;
23
32
  }
24
33
 
25
34
  /**
26
35
  * Resolve runtime dynamic-agent settings from config.
36
+ *
37
+ * Accepts either the full openclaw config (legacy) or a per-account wecom
38
+ * config block directly (multi-account). Detection: if the object has
39
+ * `channels.wecom`, unwrap it; otherwise treat the object itself as the
40
+ * wecom account config.
27
41
  */
28
42
  export function getDynamicAgentConfig(config) {
29
- const wecom = config?.channels?.wecom || {};
43
+ const wecom = config?.channels?.wecom ?? config ?? {};
30
44
  return {
31
45
  enabled: wecom.dynamicAgents?.enabled !== false,
32
46
  dmCreateAgent: wecom.dm?.createAgentOnFirstMessage !== false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,7 +23,10 @@
23
23
  "openclaw": "*"
24
24
  },
25
25
  "scripts": {
26
- "test": "node --test tests/*.test.js"
26
+ "test": "npm run test:unit",
27
+ "test:unit": "node --test tests/*.test.js",
28
+ "test:e2e": "node --test tests/e2e/*.e2e.test.js",
29
+ "test:e2e:ali-ai": "bash tests/e2e/run-ali-ai.sh"
27
30
  },
28
31
  "openclaw": {
29
32
  "extensions": [