@yanhaidao/wecom 2.3.260 → 2.4.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/MENU_EVENT_CONF.md +500 -0
  2. package/MENU_EVENT_PLAN.md +440 -0
  3. package/README.md +90 -8
  4. package/UPSTREAM_CONFIG.md +170 -0
  5. package/UPSTREAM_PLAN.md +175 -0
  6. package/changelog/v2.3.27.md +33 -0
  7. package/changelog/v2.4.12.md +37 -0
  8. package/index.test.ts +5 -1
  9. package/package.json +17 -17
  10. package/scripts/wecom/README.md +123 -0
  11. package/scripts/wecom/menu-click-help.js +59 -0
  12. package/scripts/wecom/menu-click-help.py +55 -0
  13. package/src/agent/event-router.test.ts +421 -0
  14. package/src/agent/event-router.ts +272 -0
  15. package/src/agent/handler.event-filter.test.ts +65 -1
  16. package/src/agent/handler.ts +375 -21
  17. package/src/agent/script-runner.ts +186 -0
  18. package/src/agent/test-fixtures/invalid-json-script.mjs +1 -0
  19. package/src/agent/test-fixtures/reply-event-script.mjs +29 -0
  20. package/src/agent/test-fixtures/reply-event-script.py +17 -0
  21. package/src/app/account-runtime.ts +1 -1
  22. package/src/app/index.ts +6 -3
  23. package/src/capability/agent/upstream-delivery-service.ts +96 -0
  24. package/src/capability/bot/sandbox-media.test.ts +221 -0
  25. package/src/capability/bot/sandbox-media.ts +176 -0
  26. package/src/capability/bot/stream-orchestrator.ts +19 -0
  27. package/src/capability/mcp/tool.ts +7 -3
  28. package/src/channel.config.test.ts +33 -0
  29. package/src/channel.meta.test.ts +14 -0
  30. package/src/channel.ts +33 -60
  31. package/src/config/accounts.ts +16 -0
  32. package/src/config/schema.ts +58 -0
  33. package/src/context-store.ts +41 -8
  34. package/src/onboarding.test.ts +42 -24
  35. package/src/onboarding.ts +598 -553
  36. package/src/outbound.test.ts +211 -2
  37. package/src/outbound.ts +340 -81
  38. package/src/runtime/session-manager.test.ts +39 -0
  39. package/src/runtime/session-manager.ts +17 -0
  40. package/src/runtime/source-registry.ts +5 -0
  41. package/src/shared/media-asset.ts +78 -0
  42. package/src/shared/media-service.test.ts +111 -0
  43. package/src/shared/media-service.ts +42 -14
  44. package/src/target.ts +40 -0
  45. package/src/transport/agent-api/client.ts +233 -0
  46. package/src/transport/agent-api/core.ts +101 -5
  47. package/src/transport/agent-api/upstream-delivery.ts +45 -0
  48. package/src/transport/agent-api/upstream-media-upload.ts +70 -0
  49. package/src/transport/agent-api/upstream-reply.ts +43 -0
  50. package/src/transport/bot-ws/media.test.ts +8 -8
  51. package/src/transport/bot-ws/media.ts +51 -2
  52. package/src/transport/bot-ws/sdk-adapter.ts +6 -6
  53. package/src/types/account.ts +2 -0
  54. package/src/types/config.ts +74 -0
  55. package/src/types/message.ts +2 -0
  56. package/src/upstream/index.ts +150 -0
  57. package/src/upstream.test.ts +84 -0
  58. package/vitest.config.ts +15 -4
@@ -0,0 +1,500 @@
1
+ # 企业微信自定义菜单与 Click 事件配置指南
2
+
3
+ ## 概述
4
+
5
+ 本文档介绍如何在 OpenClaw 企业微信插件中配置自定义菜单,并处理 click 类型按钮的事件。
6
+
7
+ ## 创建菜单
8
+
9
+ ### API 接口
10
+
11
+ ```
12
+ POST https://qyapi.weixin.qq.com/cgi-bin/menu/create?access_token=ACCESS_TOKEN&agentid=AGENTID
13
+ ```
14
+
15
+ ### 菜单结构示例
16
+
17
+ ```json
18
+ {
19
+ "button": [
20
+ {
21
+ "type": "click",
22
+ "name": "Python测试",
23
+ "key": "TEST_CLICK_PY"
24
+ },
25
+ {
26
+ "type": "click",
27
+ "name": "Node测试",
28
+ "key": "TEST_CLICK_JS"
29
+ },
30
+ {
31
+ "name": "更多",
32
+ "sub_button": [
33
+ {
34
+ "type": "view",
35
+ "name": "打开网页",
36
+ "url": "https://work.weixin.qq.com"
37
+ },
38
+ {
39
+ "type": "click",
40
+ "name": "菜单信息",
41
+ "key": "MENU_INFO"
42
+ }
43
+ ]
44
+ }
45
+ ]
46
+ }
47
+ ```
48
+
49
+ ### 支持的按钮类型
50
+
51
+ | 类型 | 说明 |
52
+ |------|------|
53
+ | `click` | 点击推事件,触发事件推送 |
54
+ | `view` | 跳转URL,打开网页 |
55
+ | `scancode_push` | 扫码推事件 |
56
+ | `scancode_waitmsg` | 扫码推事件且弹出提示框 |
57
+ | `pic_sysphoto` | 弹出系统拍照发图 |
58
+ | `pic_photo_or_album` | 弹出拍照或者相册发图 |
59
+ | `pic_weixin` | 弹出企业微信相册发图器 |
60
+ | `location_select` | 弹出地理位置选择器 |
61
+ | `view_miniprogram` | 跳转到小程序 |
62
+
63
+ ## 配置事件路由
64
+
65
+ 在 `openclaw.json` 中配置 `eventRouting` 和 `scriptRuntime`:
66
+
67
+ ```json
68
+ {
69
+ "channels": {
70
+ "wecom": {
71
+ "accounts": {
72
+ "your-account": {
73
+ "agent": {
74
+ "eventRouting": {
75
+ "unmatchedAction": "forwardToAgent",
76
+ "routes": [
77
+ {
78
+ "id": "test-click-python",
79
+ "when": {
80
+ "eventType": "click",
81
+ "eventKey": "TEST_CLICK_PY"
82
+ },
83
+ "handler": {
84
+ "type": "python_script",
85
+ "entry": "/path/to/script.py"
86
+ }
87
+ },
88
+ {
89
+ "id": "test-click-js",
90
+ "when": {
91
+ "eventType": "click",
92
+ "eventKey": "TEST_CLICK_JS"
93
+ },
94
+ "handler": {
95
+ "type": "node_script",
96
+ "entry": "/path/to/script.mjs"
97
+ }
98
+ },
99
+ {
100
+ "id": "menu-info-echo",
101
+ "when": {
102
+ "eventType": "click",
103
+ "eventKey": "MENU_INFO"
104
+ },
105
+ "handler": {
106
+ "type": "builtin",
107
+ "name": "echo"
108
+ }
109
+ }
110
+ ]
111
+ },
112
+ "scriptRuntime": {
113
+ "enabled": true,
114
+ "allowPaths": ["/path/to/scripts"],
115
+ "defaultTimeoutMs": 10000,
116
+ "pythonCommand": "python3",
117
+ "nodeCommand": "node"
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ ```
126
+
127
+ ## 事件路由配置说明
128
+
129
+ ### unmatchedAction(未匹配事件的处理方式)
130
+
131
+ 当收到的事件**没有匹配任何路由**时,由 `unmatchedAction` 决定如何处理:
132
+
133
+ - `ignore` - 未匹配的事件直接忽略,不处理也不回复
134
+ - `forwardToAgent` - 未匹配的事件传递给 Agent(AI)处理
135
+
136
+ **注意:** 这个配置只影响**未匹配路由**的事件。如果事件匹配了路由,则由路由的 handler 决定后续行为。
137
+
138
+ ### 路由匹配条件 (when)
139
+
140
+ | 字段 | 说明 |
141
+ |------|------|
142
+ | `eventType` | 事件类型,如 `click`、`change_contact` |
143
+ | `eventKey` | 精确匹配事件 key |
144
+ | `eventKeyPrefix` | 前缀匹配事件 key |
145
+ | `eventKeyPattern` | 正则匹配事件 key |
146
+ | `changeType` | 通讯录变更类型,如 `create_user` |
147
+
148
+ ### Handler 类型
149
+
150
+ | 类型 | 说明 |
151
+ |------|------|
152
+ | `builtin` | 内置处理器,目前支持 `echo` |
153
+ | `node_script` | Node.js 脚本 |
154
+ | `python_script` | Python 脚本 |
155
+
156
+ ### `chainToAgent` 的两个来源
157
+
158
+ `chainToAgent` 现在只表达一个意思:当前事件处理完成后,是否继续进入默认 Agent(AI)流程。
159
+
160
+ 这个开关有两个输入来源,但它们控制的是同一件事,不是两个不同功能:
161
+
162
+ #### 1. Handler 配置里的 `chainToAgent`
163
+
164
+ 在 `openclaw.json` 的 handler 中配置:
165
+
166
+ ```json
167
+ {
168
+ "handler": {
169
+ "type": "python_script",
170
+ "entry": "/path/to/script.py",
171
+ "chainToAgent": true
172
+ }
173
+ }
174
+ ```
175
+
176
+ **特点:**
177
+ - 这是静态配置,适合声明“这条路由处理完后一定继续走 Agent”
178
+ - 只有设为 `true` 才会产生强制效果
179
+ - 设为 `false` 和不写,在当前实现里效果相同,都不会阻止脚本返回 `true`
180
+
181
+ #### 2. 脚本返回里的 `chainToAgent`
182
+
183
+ 脚本通过 stdout 返回 JSON:
184
+
185
+ ```json
186
+ {
187
+ "ok": true,
188
+ "action": "reply_text",
189
+ "reply": {
190
+ "text": "回复内容"
191
+ },
192
+ "chainToAgent": false // 脚本动态决定
193
+ }
194
+ ```
195
+
196
+ **特点:**
197
+ - 这是动态决策,适合脚本根据业务条件决定是否继续走 Agent
198
+ - 当 handler 没有把 `chainToAgent` 设为 `true` 时,以脚本返回为准
199
+ - 如果脚本不返回该字段,默认为 `false`
200
+
201
+ #### 实际合并规则
202
+
203
+ 代码中的最终判断等价于:
204
+
205
+ ```ts
206
+ finalChainToAgent =
207
+ handler.chainToAgent === true || scriptResponse.chainToAgent === true;
208
+ ```
209
+
210
+ 可以把它理解为:
211
+
212
+ - `handler.chainToAgent` 是“静态放行开关”
213
+ - `scriptResponse.chainToAgent` 是“脚本运行后的动态放行结果”
214
+ - 任意一方明确返回 `true`,都会继续进入默认 Agent 流程
215
+ - 两边都不是 `true` 时,才会在当前路由处理后结束
216
+
217
+ #### 行为总结
218
+
219
+ | Handler 配置 | 脚本返回 | 最终行为 |
220
+ |-------------|---------|---------|
221
+ | `true` | `false` | `true` |
222
+ | `true` | `true` | `true` |
223
+ | `false` | `true` | `true` |
224
+ | 未设置 | `true` | `true` |
225
+ | 未设置 / `false` | `false` | `false` |
226
+ | 未设置 / `false` | 未返回 | `false` |
227
+
228
+ **关键点:** 当前实现里不存在“`false` 覆盖 `true`”。只有 `true` 会向上抬高最终结果。
229
+
230
+ #### 推荐做法
231
+
232
+ **场景 1:脚本完全控制**
233
+ - Handler 配置中**不设置** `chainToAgent`
234
+ - 脚本根据需要返回 `true` 或 `false`
235
+
236
+ ```json
237
+ // openclaw.json
238
+ "handler": {
239
+ "type": "python_script",
240
+ "entry": "/path/to/script.py"
241
+ // 不写 chainToAgent
242
+ }
243
+ ```
244
+
245
+ ```python
246
+ # script.py - 动态决定
247
+ if some_condition:
248
+ response["chainToAgent"] = True # 继续 AI 处理
249
+ else:
250
+ response["chainToAgent"] = False # 到此结束
251
+ ```
252
+
253
+ **场景 2:固定继续走 Agent**
254
+ - Handler 配置中设置 `"chainToAgent": true`
255
+ - 此时即使脚本返回 `false`,最终仍会继续进入默认 Agent 流程
256
+
257
+ ```json
258
+ // openclaw.json - 固定走 AI 流程
259
+ "handler": {
260
+ "type": "python_script",
261
+ "entry": "/path/to/script.py",
262
+ "chainToAgent": true
263
+ }
264
+ ```
265
+
266
+ **场景 3:固定不继续走 Agent**
267
+ - 不要依赖 handler 里的 `"chainToAgent": false`
268
+ - 应该保持 handler 不写该字段,并让脚本稳定返回 `false`
269
+
270
+ 也就是说:
271
+
272
+ - 想“固定继续”,可以用 handler 配置 `true`
273
+ - 想“固定停止”,应由脚本返回 `false` 来保证
274
+
275
+ ## 脚本编写规范
276
+
277
+ 脚本通过 `stdin` 接收 JSON 数据,通过 `stdout` 返回 JSON 响应。
278
+
279
+ ### 输入格式 (envelope)
280
+
281
+ ```json
282
+ {
283
+ "version": "1.0",
284
+ "channel": "wecom",
285
+ "accountId": "blue",
286
+ "receivedAt": 1775963707523,
287
+ "message": {
288
+ "msgType": "event",
289
+ "eventType": "click",
290
+ "eventKey": "TEST_CLICK_PY",
291
+ "changeType": null,
292
+ "fromUser": "GuanXiaoPeng",
293
+ "toUser": "corp-id",
294
+ "chatId": null,
295
+ "agentId": 1000015,
296
+ "createTime": 1775963707,
297
+ "msgId": "msg-id",
298
+ "raw": { /* 原始 XML 解析数据 */ }
299
+ },
300
+ "route": {
301
+ "matchedRuleId": "test-click-python",
302
+ "handlerType": "python_script"
303
+ }
304
+ }
305
+ ```
306
+
307
+ ### 输出格式 (response)
308
+
309
+ ```json
310
+ {
311
+ "ok": true,
312
+ "action": "reply_text",
313
+ "reply": {
314
+ "text": "回复内容"
315
+ },
316
+ "chainToAgent": false
317
+ }
318
+ ```
319
+
320
+ ### action 类型
321
+
322
+ - `none` - 不回复
323
+ - `reply_text` - 回复文本消息
324
+
325
+ ### Python 脚本示例
326
+
327
+ ```python
328
+ #!/usr/bin/env python3
329
+ import json
330
+ import sys
331
+
332
+ def main():
333
+ payload = json.load(sys.stdin)
334
+ message = payload.get("message", {})
335
+ event_key = message.get("eventKey") or ""
336
+ from_user = message.get("fromUser", "")
337
+
338
+ response = {
339
+ "ok": True,
340
+ "action": "reply_text",
341
+ "reply": {
342
+ "text": f"收到点击事件: {event_key}\n来自用户: {from_user}"
343
+ },
344
+ "chainToAgent": False
345
+ }
346
+
347
+ json.dump(response, sys.stdout)
348
+
349
+ if __name__ == "__main__":
350
+ main()
351
+ ```
352
+
353
+ ### Node.js 脚本示例
354
+
355
+ ```javascript
356
+ #!/usr/bin/env node
357
+ let raw = "";
358
+ process.stdin.setEncoding("utf8");
359
+
360
+ process.stdin.on("data", (chunk) => {
361
+ raw += chunk;
362
+ });
363
+
364
+ process.stdin.on("end", () => {
365
+ const payload = JSON.parse(raw || "{}");
366
+ const message = payload?.message ?? {};
367
+ const eventKey = message?.eventKey ?? "";
368
+ const fromUser = message?.fromUser ?? "";
369
+
370
+ const response = {
371
+ ok: true,
372
+ action: "reply_text",
373
+ reply: {
374
+ text: `收到点击事件: ${eventKey}\n来自用户: ${fromUser}`
375
+ },
376
+ chainToAgent: false
377
+ };
378
+
379
+ process.stdout.write(JSON.stringify(response));
380
+ });
381
+ ```
382
+
383
+ ## 常见踩坑
384
+
385
+ ### 1. IP 白名单限制
386
+
387
+ **错误信息:**
388
+ ```
389
+ {"errcode": 60020, "errmsg": "not allow to access from your ip"}
390
+ ```
391
+
392
+ **解决方案:**
393
+ - 在企业微信管理后台配置可信 IP 列表
394
+ - 或使用配置的代理服务器
395
+
396
+ ### 2. 菜单名称长度限制
397
+
398
+ **错误信息:**
399
+ ```
400
+ {"errcode": 40058, "errmsg": "button.name exceed max length 16"}
401
+ ```
402
+
403
+ **解决方案:**
404
+ - 一级菜单名称不超过 16 字节(约 16 个英文字符或 8 个中文字符)
405
+ - 子菜单名称不超过 40 字节
406
+ - 避免使用 emoji,会占用更多字节
407
+
408
+ ### 3. 脚本路径未授权
409
+
410
+ **错误信息:**
411
+ ```
412
+ script path is not allowed: /path/to/script.py
413
+ ```
414
+
415
+ **解决方案:**
416
+ - 确保脚本路径在 `scriptRuntime.allowPaths` 配置的目录下
417
+ - 路径必须是绝对路径
418
+
419
+ ### 4. 脚本运行时未启用
420
+
421
+ **错误信息:**
422
+ ```
423
+ script runtime is disabled
424
+ ```
425
+
426
+ **解决方案:**
427
+ - 确保 `scriptRuntime.enabled` 设置为 `true`
428
+
429
+ ### 5. 脚本输出格式错误
430
+
431
+ **错误信息:**
432
+ ```
433
+ script output is not valid JSON
434
+ ```
435
+
436
+ **解决方案:**
437
+ - 确保脚本输出是有效的 JSON 格式
438
+ - 不要输出调试信息到 stdout
439
+ - 错误信息可以输出到 stderr
440
+
441
+ ### 6. 脚本执行超时
442
+
443
+ **错误信息:**
444
+ ```
445
+ script execution timed out after 5000ms
446
+ ```
447
+
448
+ **解决方案:**
449
+ - 增加 `timeoutMs` 配置(handler 级别或 `defaultTimeoutMs` 全局)
450
+ - 优化脚本性能
451
+
452
+ ### 7. Access Token 过期
453
+
454
+ **错误信息:**
455
+ ```
456
+ {"errcode": 42001, "errmsg": "access_token expired"}
457
+ ```
458
+
459
+ **解决方案:**
460
+ - 重新获取 access_token
461
+ - access_token 有效期为 2 小时
462
+
463
+ ### 8. 菜单不显示
464
+
465
+ **可能原因:**
466
+ - 应用未发布(需要发布后才对成员可见)
467
+ - 成员不在应用可见范围内
468
+ - 缓存问题(重新进入应用或等待几分钟)
469
+
470
+ ## 调试技巧
471
+
472
+ ### 1. 查看当前菜单
473
+
474
+ ```bash
475
+ curl "https://qyapi.weixin.qq.com/cgi-bin/menu/get?access_token=TOKEN&agentid=AGENTID"
476
+ ```
477
+
478
+ ### 2. 删除菜单
479
+
480
+ ```bash
481
+ curl "https://qyapi.weixin.qq.com/cgi-bin/menu/delete?access_token=TOKEN&agentid=AGENTID"
482
+ ```
483
+
484
+ ### 3. 本地测试脚本
485
+
486
+ ```bash
487
+ # 准备测试数据
488
+ echo '{"version":"1.0","channel":"wecom","accountId":"blue","message":{"eventType":"click","eventKey":"TEST","fromUser":"test"},"route":{"matchedRuleId":"test","handlerType":"python_script"}}' | python3 script.py
489
+ ```
490
+
491
+ ### 4. 查看 OpenClaw 日志
492
+
493
+ ```bash
494
+ openclaw logs --tail 100
495
+ ```
496
+
497
+ ## 参考文档
498
+
499
+ - [企业微信创建菜单 API](https://developer.work.weixin.qq.com/document/path/90231)
500
+ - [企业微信接收事件推送](https://developer.work.weixin.qq.com/document/path/90240)