@yanhaidao/wecom 2.3.13 → 2.3.14
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 +148 -0
- package/changelog/v2.3.14.md +48 -0
- package/index.ts +4 -0
- package/package.json +3 -2
- package/src/agent/handler.event-filter.test.ts +15 -5
- package/src/agent/handler.ts +192 -48
- package/src/app/account-runtime.ts +1 -1
- package/src/app/index.ts +4 -0
- package/src/capability/agent/delivery-service.ts +16 -8
- package/src/capability/bot/fallback-delivery.ts +1 -1
- package/src/capability/bot/stream-orchestrator.ts +10 -10
- package/src/capability/doc/client.ts +910 -0
- package/src/capability/doc/schema.ts +1404 -0
- package/src/capability/doc/tool.ts +1165 -0
- package/src/capability/doc/types.ts +408 -0
- package/src/channel.ts +1 -1
- package/src/outbound.ts +5 -5
- package/src/runtime/session-manager.ts +4 -4
- package/src/target.ts +3 -0
- package/src/transport/bot-webhook/active-reply.ts +4 -1
- package/src/transport/bot-ws/inbound.ts +2 -2
- package/src/transport/bot-ws/reply.test.ts +124 -56
- package/src/transport/bot-ws/reply.ts +121 -13
- package/src/transport/bot-ws/sdk-adapter.ts +1 -0
- package/src/types/runtime.ts +3 -0
package/README.md
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
<strong>🚀 企业级多模式 AI 助手接入方案(统一运行时架构)</strong>
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
|
+
<p align="center">
|
|
17
|
+
<strong>🚀 深度适配企业微信原生文档(WeCom Doc):将对话沉淀为企业数字资产 [v2.3.14 重磅]</strong>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
16
20
|
<p align="center">
|
|
17
21
|
<a href="#sec-1">💡 核心价值</a> •
|
|
18
22
|
<a href="#sec-2">📊 模式对比</a> •
|
|
@@ -70,6 +74,13 @@
|
|
|
70
74
|
* **发什么都能看**:支持接收图片、文件 (PDF/Doc/Zip)、语音 (自动转文字)、视频。
|
|
71
75
|
* **要什么都能给**:AI 生成的图表、代码文件、语音回复,均可自动上传并推送到企微。
|
|
72
76
|
|
|
77
|
+
#### 📝 **深度适配企业微信“协作文档” (WeCom Doc)**
|
|
78
|
+
> *基于“协作的本质是信息流动”第一性原理打造,打破只能聊天的开源怪圈,让 AI 真正握住公司级文档库大权。*
|
|
79
|
+
> *特别感谢 [@proyy](https://github.com/proyy) 提供的企业微信文档管理解决方案。*
|
|
80
|
+
* **文档全生命周期**:支持从项目模板自动化创建全新文档、跨越部门重命名/克隆操作。
|
|
81
|
+
* **表格数据精细手术**:告别全量粗暴覆写,支持基于 Range 选区的单元格级精准更新(如:对特定表格内的状态进行实时覆盖)。
|
|
82
|
+
* **安全确权与跨界分析**:不仅能读取、分析现有庞大报表数据,更可用指令动态缩放协作成员的读写安全锁钥。
|
|
83
|
+
|
|
73
84
|
#### 📢 **企业级触达 (Enterprise Reach)**
|
|
74
85
|
* **精准广播**:支持向 **部门 (Party)**、**标签 (Tag)** 或 **外部群** 批量推送消息。
|
|
75
86
|
* **Cronjob 集成**:通过简单的 JSON 配置实现早报推送、日报提醒、服务器报警。
|
|
@@ -93,6 +104,7 @@
|
|
|
93
104
|
| **交互卡片 (A2UI)**| ❌ 不支持 | ✅ **支持** (Button/Select等) | **✅ 支持** |
|
|
94
105
|
| **AI 流式响应** | ✅ **支持** (丝滑打字机) | ❌ 不支持 (全部生成完一次发送) | **✅ 完美支持** |
|
|
95
106
|
| **主动触达 (Cron)**| ❌ 仅被动回复/有限推送 | ✅ **全量推送** (指定人/部门/标签) | **✅ 企业级触达** |
|
|
107
|
+
| **📝 文档/表格管理** | ❌ 不支持 | ⚠️ 需自行开发对接 | **✅ 原生深度适配** (建档/改数据/读表/权限) |
|
|
96
108
|
|
|
97
109
|
---
|
|
98
110
|
|
|
@@ -102,6 +114,19 @@
|
|
|
102
114
|
|
|
103
115
|
> 项目保持高频迭代,核心改进一览:
|
|
104
116
|
|
|
117
|
+
#### v2.3.14(2026-03-14)
|
|
118
|
+
|
|
119
|
+
- 📝 **[重磅功能]** 深度适配企业微信原生「协作文档」,支持通过自然语言自动建文档/表格、单元格级精准修改、跨表数据分析与权限管控。Thanks [@proyy](https://github.com/proyy)。
|
|
120
|
+
- 🛑 **[核心修复]** 彻底修复并发场景下"正在思考..."无限刷屏死循环(`errcode=846608`),引入按会话追踪清理机制。
|
|
121
|
+
- 📡 **[合规修复]** `enter_chat` 等事件不再违规调用流式回复接口,终结 `invalid req_id (846605)` 报错。
|
|
122
|
+
- 🛡 WS 长连接增加 120 秒硬性超时熔断,防止极端情况下占位符永不停止。
|
|
123
|
+
- 🤫 Agent 模式 `enter_agent` / `subscribe` 不再触发大模型生成欢迎语,静默处理,零 Token 消耗。
|
|
124
|
+
|
|
125
|
+
**升级指引:**
|
|
126
|
+
```bash
|
|
127
|
+
openclaw plugins update wecom
|
|
128
|
+
```
|
|
129
|
+
|
|
105
130
|
#### v2.3.13(2026-03-13)
|
|
106
131
|
|
|
107
132
|
- 🛠 **[重要修复]** `Bot WS` 现在会把“引用 + 提问”中的引用内容一起带入 Agent 上下文,不再只保留用户当前这句提问。
|
|
@@ -422,6 +447,19 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
422
447
|
在《二、配置说明》中讲解的 `dynamicAgents`,除了在运行时把会话隔离,它还在内存里做了一套高吞吐状态写入:
|
|
423
448
|
系统会自动以异步、排队、防冲突的队列,将被激活的动态专用助理自动追加写入底层核心系统配置文件 `openclaw.json` (或相应 yaml) 的 `agents.list` 数组中,这就意味着:这套扩容体系是对上层管理员完全**透明且免运维**的,机器人活了,号也就落盘注册好了。
|
|
424
449
|
|
|
450
|
+
### 4.4 📝 Docs 极客级协作资产管控
|
|
451
|
+
从 v2.3.14 起,OpenClaw WeCom 插件已深度集成了企微原生协作文档的管理策略引擎。只需自然语言指令,即可唤醒这一能力:
|
|
452
|
+
|
|
453
|
+
**【典型赋能场景】**
|
|
454
|
+
1. **自动化建档**:对机器人说:“建一个名为『Q1需求追踪』的表格,并把群里的人都加上可写权限。”它将自动调用 `create_doc` 并生成带权限的企微链接。
|
|
455
|
+
2. **移动端碎片修改**:在地铁上吩咐:“把「第二周面试记录」里的 B2 到 B5 单元格全部更新为‘二面通过’。” AI 会精准直击数据块,不干扰他人协同(`spreadsheet.edit_data`)。
|
|
456
|
+
3. **跨表分析**:发给机器人一张总账表的企微链接:“分析这表里的营收列,告诉我哪个部门贡献最大?” AI 原生抓取数据解算输出。
|
|
457
|
+
|
|
458
|
+
> **⚠️ 权限解锁指引**:
|
|
459
|
+
> 要让 AI 解锁该能力,仅在配置文件填入 `agentSecret` 不够。企业微信已将文档在权限面上实施了“物理隔离”。请务必:
|
|
460
|
+
> 1. 进入 [企微管理后台 -> 协作 -> 文档 -> 可调用接口应用白名单](https://work.weixin.qq.com/wework_admin/frame#apps/qykit/proxy/wedoc),勾选赋予你的 OpenClaw 自建应用“通行证”。
|
|
461
|
+
> 2. 在 OpenClaw UI 界面工具集(Tool)勾选所有前缀含有 `doc:` 或 `wecom` 下相关操作单元。
|
|
462
|
+
|
|
425
463
|
---
|
|
426
464
|
|
|
427
465
|
<a id="sec-7"></a>
|
|
@@ -491,6 +529,116 @@ openclaw channels status --deep
|
|
|
491
529
|
|
|
492
530
|
## 七、📮 联系我 与 版本协议
|
|
493
531
|
|
|
532
|
+
### 最近更新
|
|
533
|
+
|
|
534
|
+
近期保持高频迭代,最近版本如下:
|
|
535
|
+
|
|
536
|
+
#### v2.3.12-zh(2026-03-13)by proyy
|
|
537
|
+
|
|
538
|
+
> 本版本 WeCom Doc 功能增强来自 [proyy/wecom](https://github.com/proyy/wecom)。
|
|
539
|
+
|
|
540
|
+
**WeCom Doc 功能模块详细核验报告:**
|
|
541
|
+
|
|
542
|
+
### 📊 功能实现状态总览
|
|
543
|
+
|
|
544
|
+
| 功能模块 | 状态 | 说明 |
|
|
545
|
+
| :--- | :--- | :--- |
|
|
546
|
+
| **管理文档** | ✅ 已实现 | 新建/删除/重命名/信息获取/分享均已就绪 |
|
|
547
|
+
| **管理文档内容** | ✅ 已实现 | 文档内容读写、表格行列操作均已支持 |
|
|
548
|
+
| **智能表格** | ✅ 已实现 | 子表/视图/字段/记录/编组的全生命周期管理 |
|
|
549
|
+
| **设置文档权限** | ✅ 已实现 | 成员权限、安全设置、查看规则均已覆盖 |
|
|
550
|
+
| **管理收集表** | ✅ 已实现 | 创建/编辑/统计/答案读取均已实现 |
|
|
551
|
+
| **高级账号管理** | ✅ 已实现 | 分配/取消/列表查询均已实现 |
|
|
552
|
+
| **素材管理** | ✅ 已实现 | 支持上传图片到文档专用素材库 |
|
|
553
|
+
| **回调通知** | ❌ **未实现** | **当前系统会丢弃文档相关回调事件** |
|
|
554
|
+
| **接收外部数据** | ✅ 已实现 | 已支持向智能表格添加/更新外部记录 |
|
|
555
|
+
|
|
556
|
+
### 📝 逐项详细确认
|
|
557
|
+
|
|
558
|
+
#### 1. 管理文档
|
|
559
|
+
* **新建文档**: `createDoc` (支持文档、表格、智能表格)
|
|
560
|
+
* **重命名文档**: `renameDoc`
|
|
561
|
+
* **删除文档**: `deleteDoc`
|
|
562
|
+
* **获取文档基础信息**: `getDocBaseInfo`
|
|
563
|
+
* **分享文档**: `shareDoc`
|
|
564
|
+
|
|
565
|
+
#### 2. 管理文档内容
|
|
566
|
+
* **编辑文档内容**: `updateDocContent`
|
|
567
|
+
* **获取文档数据**: `getDocContent`
|
|
568
|
+
* **管理表格内容**: `modifySheetProperties`
|
|
569
|
+
* **编辑表格内容**: `editSheetData`
|
|
570
|
+
* **获取表格行列信息**: `getSheetProperties`
|
|
571
|
+
* **获取表格数据**: `getSheetData`
|
|
572
|
+
|
|
573
|
+
#### 3. 管理智能表格内容
|
|
574
|
+
* **添加/删除/更新/查询子表**: `smartTableAddSheet`, `smartTableDeleteSheet` 等
|
|
575
|
+
* **添加/删除/更新/查询视图**: `smartTableAddView`, `smartTableView` 等
|
|
576
|
+
* **添加/删除/更新/查询字段**: `smartTableAddField`, `smartTableUpdateField` 等
|
|
577
|
+
* **添加/删除/更新/查询记录**: `smartTableAddRecords`, `smartTableGetRecords` 等
|
|
578
|
+
* **添加/删除/更新/获取编组**: 已通过 `smartTableUpdateGrouping` 等接口实现
|
|
579
|
+
|
|
580
|
+
#### 4. 设置文档权限
|
|
581
|
+
* **获取文档权限信息**: `getDocAuth`
|
|
582
|
+
* **修改文档查看规则**: `setDocJoinRule`
|
|
583
|
+
* **修改文档通知范围及权限**: `modDocMemberNotifiedScope`, `setDocMemberAuth`
|
|
584
|
+
* **修改文档安全设置**: `setDocSafetySetting`, `modDocSecuritySetting`
|
|
585
|
+
* **管理智能表格内容权限**: 已包含在上述权限接口中 (支持对子表粒度控制)
|
|
586
|
+
|
|
587
|
+
#### 5. 管理收集表
|
|
588
|
+
* **创建/编辑收集表**: `createCollect`, `modifyCollect`
|
|
589
|
+
* **获取收集表信息**: `getFormInfo`
|
|
590
|
+
* **收集表的统计信息查询**: `getFormStatistic`
|
|
591
|
+
* **读取收集表答案**: `getFormAnswer`
|
|
592
|
+
|
|
593
|
+
#### 6. 回调通知 (⚠️ 缺失)
|
|
594
|
+
* 修改文档成员事件
|
|
595
|
+
* 删除文档事件
|
|
596
|
+
* 收集表完成事件
|
|
597
|
+
* 删除收集表事件
|
|
598
|
+
* 修改收集表设置事件
|
|
599
|
+
* 字段变更事件
|
|
600
|
+
* 记录变更事件
|
|
601
|
+
|
|
602
|
+
**原因诊断**: 在 `src/agent/handler.ts` 的 `shouldProcessAgentInboundMessage` 函数中,系统目前**显式过滤**了除 `subscribe`, `enter_agent`, `batch_job_result` 之外的所有事件。文档变更事件(如 `update_doc`, `doc_create` 等)会被视为 `unknown event` 而直接丢弃。
|
|
603
|
+
|
|
604
|
+
#### 7. 接收外部数据到智能表格
|
|
605
|
+
* **概述/添加记录**: `smartTableAddExternalRecords`
|
|
606
|
+
* **更新记录**: `smartTableUpdateExternalRecords`
|
|
607
|
+
|
|
608
|
+
#### 8. 高级功能账号管理 & 素材管理
|
|
609
|
+
* **账号管理**: `assignDocAdvancedAccount`, `cancelDocAdvancedAccount`, `getDocAdvancedAccountList`
|
|
610
|
+
* **上传文档图片**: `uploadDocImage`
|
|
611
|
+
|
|
612
|
+
#### v2.3.11-zh(2026-03-12)by proyy
|
|
613
|
+
|
|
614
|
+
- 全面增加企业微信 WeCom Doc 工具链:文档/表格/智能表格/收集表,以及全面的 CRUD 支持。
|
|
615
|
+
- 增加文档权限与协作者管理 API。
|
|
616
|
+
- 针对分享链接可用性诊断并修复底层返回带下划线 URL 被 markdown 行内截断导致打不开的问题。
|
|
617
|
+
- 针对长文本分段问题进行的 BUG 修复与升级。
|
|
618
|
+
|
|
619
|
+
#### v2.3.11(2026-03-11)
|
|
620
|
+
|
|
621
|
+
- `Bot WS` 升级为即时占位 + 持续保活,降低长思考时的 `invalid req_id`。
|
|
622
|
+
- `streamPlaceholderContent` 统一作用于 `Bot WS` 与 `Bot Webhook`。
|
|
623
|
+
- onboarding 在空配置下也会提供 `default` 账号选项。
|
|
624
|
+
- README 补充多账号共用静态 Agent 时的 session 隔离建议。
|
|
625
|
+
|
|
626
|
+
#### v2.3.10(2026-03-10)
|
|
627
|
+
|
|
628
|
+
- onboarding 默认收敛为 `Bot + WS + 开放私聊`。
|
|
629
|
+
- 修复 `Bot WS` 长文本双重回复问题。
|
|
630
|
+
- 修复首个自定义接入标识时报 `default not found`。
|
|
631
|
+
- Agent 新配置统一使用 `agentSecret`。
|
|
632
|
+
|
|
633
|
+
#### v2.3.9(2026-03-09)
|
|
634
|
+
|
|
635
|
+
- Bot 默认接入改为 `WebSocket`,无需域名更易上手。
|
|
636
|
+
- 完善中文 onboarding,减少重复提示。
|
|
637
|
+
- 恢复 `Bot WS` 流式输出能力。
|
|
638
|
+
- 增强 Agent 回调与发送日志,排障更直接。
|
|
639
|
+
|
|
640
|
+
详细版本记录见 `changelog/v2.3.11.md`、`changelog/v2.3.10.md` 与 `changelog/v2.3.9.md`。
|
|
641
|
+
|
|
494
642
|
微信交流群(扫码入群):
|
|
495
643
|
|
|
496
644
|

|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.14 变更简报
|
|
2
|
+
|
|
3
|
+
> [!TIP]
|
|
4
|
+
> **企业微信“协作文档”原生整合与长连接稳定性修复版本**:`v2.3.14` 将机器人的能力从“陪聊”正式拓展到了“办公协同”。现在,你可以直接在微信群里让机器人去填报销单、建项目表;同时我们也彻底修复了多人并发聊天时机器人“正在思考”卡死刷屏的问题。
|
|
5
|
+
>
|
|
6
|
+
> 📣 **特别感谢** [@proyy](https://github.com/proyy) 提供的企业微信文档管理解决方案,使本版本的 Docs 能力得以落地。
|
|
7
|
+
|
|
8
|
+
## 重磅功能:把聊天直接变成企业文档(用后即得)
|
|
9
|
+
|
|
10
|
+
我们受够了问 AI 一个问题,答案最后只能被新消息顶没。这次更新,我们让机器人获得了操作“企业微信协作文档”的实体权限。
|
|
11
|
+
|
|
12
|
+
### 场景 1:开新项目,不用再求人建表格拉权限了
|
|
13
|
+
- **以前**:你在群里说“准备一下下周的活动追踪表”,然后得切出去打开文档中心,新建表格,再一个个抄名字把大家拉进来。
|
|
14
|
+
- **现在**:直接在群里 `@机器人 帮我建一个名为『春季新品发布会追踪』的表格,并把群里的人都加上可写权限`。机器人建好后会直接在群里甩出一个链接,所有人点开就能直接编辑,全程零切换。
|
|
15
|
+
|
|
16
|
+
### 场景 2:坐在地铁上,照样神操作填报销/跟进数据
|
|
17
|
+
- **以前**:你刚下班,群里有人问“今天 C 组的面试谁通过了?”你只能回答“我等下回家用电脑打开「面试记录表」改一下状态”。全量改动很容易把别人的同行数据覆盖掉。
|
|
18
|
+
- **现在**:你可以直接发语音或者敲字 `@机器人 把「第二周面试记录表」里的 B2 到 B5 单元格全部更新为“二面通过”`。机器人会像操作手术刀一样,只精准替换指定的数据块(`spreadsheet.edit_data`),丝毫不影响文档里的其他公式。
|
|
19
|
+
|
|
20
|
+
### 场景 3:直接让 AI 读懂全公司的数据报表
|
|
21
|
+
- **以前**:你要先自己把表格下载成 Excel,再一段段复制数据发给大模型:“帮我算算这堆数据谁最高”。
|
|
22
|
+
- **现在**:你直接丢给他一个企微文档链接:`@机器人 分析一下这张表里的第一季度数据,告诉我谁的单产最差?`。由于具备了原生读取能力(`get_sheet_range_data`),它会自己去拉取数据查表,给你输出结论。
|
|
23
|
+
|
|
24
|
+
## 💡 Docs 权限配置指引(只需一次)
|
|
25
|
+
|
|
26
|
+
为了让上述场景跑通,光配 `agentSecret` 是不够的。这是因为哪怕是公司自建的机器人,默认也没资格动你们内部的文档资产。你需要:
|
|
27
|
+
1. **去企微后台发“通行证”**:进入 [企业微信后台 -> 协作 -> 文档 -> 可调用接口的应用](https://work.weixin.qq.com/wework_admin/frame#apps/qykit/proxy/wedoc),把你的机器人应用放进白名单。
|
|
28
|
+
2. **在 OpenClaw 里激活能力**:打开 UI 界面 的 `Settings` -> `Agents` -> `Tools`,找到 `wecom`,把带有 `doc` 字眼的所有工具(创建文档、读取表格、改权限等)通通打勾。
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## 核心修复:终结“正在思考...”刷屏噩梦
|
|
32
|
+
除了新功能,这次我们也彻底对长连接(WS)卡死问题做了一次大手术:
|
|
33
|
+
- 🛑 **告别并发刷屏**:群里人多手杂的时候,以前机器人如果处理不过来,不仅不回话,还会满屏一直弹“正在思考...”。现在只要有一条真回复发出来,它会自动把同群里那些卡死、过期的“正在思考”全部瞬间清理干净。
|
|
34
|
+
- 📡 **进群欢迎语报错 846605 终结**:修复了大家常反馈的“有人一进群,后台就疯狂报 invalid req_id”的陈年老 Bug。现在对进群、推卡片等非聊天事件,我们全面切换成了专属接口,彻底避免违规流式推送。
|
|
35
|
+
- 🛡 **120 秒物理断网**:即使模型源站点挂了,我们现在也会在 120 秒内强制熔断提示,绝不让你面对一个无限转圈的加载框。
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
## 验证结果
|
|
39
|
+
- `pnpm test -- src/transport/bot-ws`
|
|
40
|
+
- 所有 WS 回复通道单测已全部通过,尤其是关于 `setInterval` 占位符存活期的验证得到了补全与修复。
|
|
41
|
+
|
|
42
|
+
## 升级指引
|
|
43
|
+
```bash
|
|
44
|
+
openclaw plugins update wecom
|
|
45
|
+
```
|
|
46
|
+
- 无需新增配置,执行上述命令即可一键升级到 `v2.3.14`。
|
|
47
|
+
- 升级后,如果你拉几十个机器人进群并同时发言,曾经那种满屏飘“正在思考...”却无人回答的乱象将不复存在。
|
|
48
|
+
- 如果之前查看后台日志总是看到 `stream message update expired (>6 minutes)` 甚至因为连带引发的 WS 断网重启,本次升级将大幅度平息日志告警红字,稳定长连接状态。
|
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
|
7
7
|
import { handleWecomWebhookRequest } from "./src/monitor.js";
|
|
8
8
|
import { setWecomRuntime } from "./src/runtime.js";
|
|
9
9
|
import { wecomPlugin } from "./src/channel.js";
|
|
10
|
+
import { registerWecomDocTools } from "./src/capability/doc/tool.js";
|
|
10
11
|
|
|
11
12
|
const plugin = {
|
|
12
13
|
id: "wecom",
|
|
@@ -33,6 +34,9 @@ const plugin = {
|
|
|
33
34
|
match: "prefix",
|
|
34
35
|
});
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
// Register WeCom Doc Tools
|
|
39
|
+
registerWecomDocTools(api);
|
|
36
40
|
},
|
|
37
41
|
};
|
|
38
42
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanhaidao/wecom",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket,支持加密媒体解密、Agent 主动发消息与多账号接入",
|
|
6
6
|
"repository": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.2.0",
|
|
53
|
-
"typescript": "^5.9.3"
|
|
53
|
+
"typescript": "^5.9.3",
|
|
54
|
+
"vitest": "^2.1.8"
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -3,22 +3,32 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import { shouldProcessAgentInboundMessage } from "./handler.js";
|
|
4
4
|
|
|
5
5
|
describe("shouldProcessAgentInboundMessage", () => {
|
|
6
|
-
it("
|
|
6
|
+
it("allows enter_agent/subscribe through the filter (handled earlier by static welcome)", () => {
|
|
7
7
|
const enterAgent = shouldProcessAgentInboundMessage({
|
|
8
8
|
msgType: "event",
|
|
9
9
|
eventType: "enter_agent",
|
|
10
10
|
fromUser: "zhangsan",
|
|
11
11
|
});
|
|
12
|
-
expect(enterAgent.shouldProcess).toBe(
|
|
13
|
-
expect(enterAgent.reason).toBe("
|
|
12
|
+
expect(enterAgent.shouldProcess).toBe(true);
|
|
13
|
+
expect(enterAgent.reason).toBe("allowed_event:enter_agent");
|
|
14
14
|
|
|
15
15
|
const subscribe = shouldProcessAgentInboundMessage({
|
|
16
16
|
msgType: "event",
|
|
17
17
|
eventType: "subscribe",
|
|
18
18
|
fromUser: "lisi",
|
|
19
19
|
});
|
|
20
|
-
expect(subscribe.shouldProcess).toBe(
|
|
21
|
-
expect(subscribe.reason).toBe("
|
|
20
|
+
expect(subscribe.shouldProcess).toBe(true);
|
|
21
|
+
expect(subscribe.reason).toBe("allowed_event:subscribe");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("skips unknown event callbacks so they do not create sessions", () => {
|
|
25
|
+
const unknown = shouldProcessAgentInboundMessage({
|
|
26
|
+
msgType: "event",
|
|
27
|
+
eventType: "some_random_event",
|
|
28
|
+
fromUser: "zhangsan",
|
|
29
|
+
});
|
|
30
|
+
expect(unknown.shouldProcess).toBe(false);
|
|
31
|
+
expect(unknown.reason).toBe("event:some_random_event");
|
|
22
32
|
});
|
|
23
33
|
|
|
24
34
|
it("skips system sender callbacks", () => {
|
package/src/agent/handler.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
9
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
10
|
-
import type { ResolvedAgentAccount } from "../types/index.js";
|
|
10
|
+
import type { ResolvedAgentAccount, UnifiedInboundEvent, WecomInboundKind } from "../types/index.js";
|
|
11
11
|
import {
|
|
12
12
|
extractMsgType,
|
|
13
13
|
extractFromUser,
|
|
@@ -22,6 +22,7 @@ import { downloadAgentApiMedia, sendAgentApiText } from "../transport/agent-api/
|
|
|
22
22
|
import { getWecomRuntime } from "../runtime.js";
|
|
23
23
|
import type { WecomAgentInboundMessage } from "../types/index.js";
|
|
24
24
|
import type { TransportSessionPatch } from "../types/index.js";
|
|
25
|
+
import type { WecomAccountRuntime } from "../app/account-runtime.js";
|
|
25
26
|
import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
|
|
26
27
|
import { resolveWecomMediaMaxBytes, shouldRejectWecomDefaultRoute } from "../config/index.js";
|
|
27
28
|
import { buildAgentSessionTarget, generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "../dynamic-agent.js";
|
|
@@ -35,6 +36,23 @@ const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaida
|
|
|
35
36
|
const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
|
|
36
37
|
const recentAgentMsgIds = new Map<string, number>();
|
|
37
38
|
|
|
39
|
+
// Event deduplication (e.g. for ENTER_AGENT/subscribe welcome messages)
|
|
40
|
+
// We only want to send a welcome message once every 5 minutes per user
|
|
41
|
+
const RECENT_EVENT_TTL_MS =3 * 60 * 1000;
|
|
42
|
+
const recentAgentEvents = new Map<string, number>();
|
|
43
|
+
|
|
44
|
+
function rememberAgentEvent(key: string): boolean {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const existing = recentAgentEvents.get(key);
|
|
47
|
+
if (existing && now - existing < RECENT_EVENT_TTL_MS) return false;
|
|
48
|
+
recentAgentEvents.set(key, now);
|
|
49
|
+
// Prune expired
|
|
50
|
+
for (const [k, ts] of recentAgentEvents) {
|
|
51
|
+
if (now - ts >= RECENT_EVENT_TTL_MS) recentAgentEvents.delete(k);
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
function rememberAgentMsgId(msgId: string): boolean {
|
|
39
57
|
const now = Date.now();
|
|
40
58
|
const existing = recentAgentMsgIds.get(msgId);
|
|
@@ -150,6 +168,28 @@ export function shouldProcessAgentInboundMessage(params: {
|
|
|
150
168
|
const eventType = String(params.eventType ?? "").trim().toLowerCase();
|
|
151
169
|
|
|
152
170
|
if (msgType === "event") {
|
|
171
|
+
const allowedEvents = [
|
|
172
|
+
"subscribe",
|
|
173
|
+
"enter_agent",
|
|
174
|
+
"batch_job_result",
|
|
175
|
+
// WeCom Doc events
|
|
176
|
+
"doc_create",
|
|
177
|
+
"doc_delete",
|
|
178
|
+
"doc_content_change",
|
|
179
|
+
"doc_member_change",
|
|
180
|
+
// WeCom Form events
|
|
181
|
+
"wedoc_collect_submit",
|
|
182
|
+
// SmartSheet events
|
|
183
|
+
"smartsheet_record_change",
|
|
184
|
+
"smartsheet_field_change",
|
|
185
|
+
"smartsheet_view_change"
|
|
186
|
+
];
|
|
187
|
+
if (allowedEvents.includes(eventType) || eventType.startsWith("doc_") || eventType.startsWith("wedoc_") || eventType.startsWith("smartsheet_")) {
|
|
188
|
+
return {
|
|
189
|
+
shouldProcess: true,
|
|
190
|
+
reason: `allowed_event:${eventType}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
153
193
|
return {
|
|
154
194
|
shouldProcess: false,
|
|
155
195
|
reason: `event:${eventType || "unknown"}`,
|
|
@@ -241,6 +281,7 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
|
|
|
241
281
|
const chatId = extractChatId(msg);
|
|
242
282
|
const msgId = extractMsgId(msg);
|
|
243
283
|
const eventType = String((msg as Record<string, unknown>).Event ?? "").trim().toLowerCase();
|
|
284
|
+
|
|
244
285
|
if (msgId) {
|
|
245
286
|
const ok = rememberAgentMsgId(msgId);
|
|
246
287
|
if (!ok) {
|
|
@@ -262,6 +303,15 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
|
|
|
262
303
|
return true;
|
|
263
304
|
}
|
|
264
305
|
}
|
|
306
|
+
|
|
307
|
+
// Agent 模式下 enter_agent / subscribe 不做任何处理,静默回 success
|
|
308
|
+
if (msgType === "event" && (eventType === "enter_agent" || eventType === "subscribe")) {
|
|
309
|
+
log?.(`[wecom-agent] ignoring ${eventType} from=${fromUser}; agent does not handle welcome events`);
|
|
310
|
+
res.statusCode = 200;
|
|
311
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
312
|
+
res.end("success");
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
265
315
|
const content = String(extractContent(msg) ?? "");
|
|
266
316
|
|
|
267
317
|
const preview = content.length > 100 ? `${content.slice(0, 100)}…` : content;
|
|
@@ -341,11 +391,41 @@ async function processAgentMessage(params: {
|
|
|
341
391
|
|
|
342
392
|
const isGroup = Boolean(chatId);
|
|
343
393
|
const peerId = isGroup ? chatId! : fromUser;
|
|
394
|
+
const eventType = String(msg.Event ?? "").trim().toLowerCase();
|
|
395
|
+
|
|
396
|
+
const resolveInboundKind = (): WecomInboundKind => {
|
|
397
|
+
if (msgType === "event") {
|
|
398
|
+
if (eventType === "subscribe" || eventType === "enter_agent") return "welcome";
|
|
399
|
+
return "event";
|
|
400
|
+
}
|
|
401
|
+
if (msgType === "image") return "image";
|
|
402
|
+
if (msgType === "voice") return "voice";
|
|
403
|
+
if (msgType === "video") return "video";
|
|
404
|
+
if (msgType === "file") return "file";
|
|
405
|
+
if (msgType === "location") return "location";
|
|
406
|
+
if (msgType === "link") return "link";
|
|
407
|
+
return "text";
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const inboundKind = resolveInboundKind();
|
|
411
|
+
const resolveEventText = (): string => {
|
|
412
|
+
if (inboundKind === "welcome" && agent.config.welcomeText) {
|
|
413
|
+
return agent.config.welcomeText;
|
|
414
|
+
}
|
|
415
|
+
if (msgType === "event") {
|
|
416
|
+
return `[event:${eventType || "unknown"}]`;
|
|
417
|
+
}
|
|
418
|
+
return content;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// BUG FIX: 真正调用 resolveEventText() 获取欢迎语或事件描述
|
|
422
|
+
const resolvedContent = resolveEventText();
|
|
423
|
+
let finalContent = resolvedContent;
|
|
424
|
+
|
|
344
425
|
const mediaMaxBytes = resolveWecomMediaMaxBytes(config);
|
|
345
426
|
|
|
346
427
|
// 处理媒体文件
|
|
347
|
-
const attachments:
|
|
348
|
-
let finalContent = content;
|
|
428
|
+
const attachments: NonNullable<UnifiedInboundEvent["attachments"]> = [];
|
|
349
429
|
let mediaPath: string | undefined;
|
|
350
430
|
let mediaType: string | undefined;
|
|
351
431
|
|
|
@@ -369,9 +449,9 @@ async function processAgentMessage(params: {
|
|
|
369
449
|
const originalExt = path.extname(originalFileName).toLowerCase();
|
|
370
450
|
const normalizedContentType =
|
|
371
451
|
looksText && originalExt === ".md" ? "text/markdown" :
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
452
|
+
looksText && (!contentType || contentType === "application/octet-stream")
|
|
453
|
+
? "text/plain; charset=utf-8"
|
|
454
|
+
: contentType;
|
|
375
455
|
|
|
376
456
|
const ext = extMap[normalizedContentType] || (looksText ? "txt" : "bin");
|
|
377
457
|
const filename = `${mediaId}.${ext}`;
|
|
@@ -400,8 +480,8 @@ async function processAgentMessage(params: {
|
|
|
400
480
|
// 构建附件
|
|
401
481
|
attachments.push({
|
|
402
482
|
name: originalFileName,
|
|
403
|
-
|
|
404
|
-
|
|
483
|
+
contentType: normalizedContentType,
|
|
484
|
+
remoteUrl: pathToFileURL(saved.path).href, // 使用跨平台安全的文件 URL
|
|
405
485
|
});
|
|
406
486
|
|
|
407
487
|
// 更新文本提示
|
|
@@ -510,7 +590,7 @@ async function processAgentMessage(params: {
|
|
|
510
590
|
route.agentId = targetAgentId;
|
|
511
591
|
route.sessionKey = `agent:${targetAgentId}:wecom:${agent.accountId}:${isGroup ? "group" : "dm"}:${peerId}`;
|
|
512
592
|
// 异步添加到 agents.list(不阻塞)
|
|
513
|
-
ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
|
|
593
|
+
ensureDynamicAgentListed(targetAgentId, core).catch(() => { });
|
|
514
594
|
log?.(`[wecom-agent] dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
|
|
515
595
|
}
|
|
516
596
|
// ===== 动态 Agent 路由注入结束 =====
|
|
@@ -602,46 +682,110 @@ const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
|
602
682
|
},
|
|
603
683
|
});
|
|
604
684
|
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
try {
|
|
621
|
-
// 统一策略:Agent 模式在群聊场景默认只私信触发者(避免 wr/wc chatId 86008)
|
|
622
|
-
await sendAgentApiText({ agent, toUser: fromUser, chatId: undefined, text });
|
|
623
|
-
touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
|
|
624
|
-
log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
|
|
625
|
-
} catch (err: unknown) {
|
|
626
|
-
const message = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}` : String(err);
|
|
627
|
-
error?.(`[wecom-agent] reply failed: ${message}`);
|
|
628
|
-
auditSink?.({
|
|
629
|
-
transport: "agent-callback",
|
|
630
|
-
category: "fallback-delivery-failed",
|
|
631
|
-
summary: `agent callback reply failed user=${fromUser} kind=${info.kind}`,
|
|
632
|
-
raw: {
|
|
633
|
-
transport: "agent-callback",
|
|
634
|
-
envelopeType: "xml",
|
|
635
|
-
body: msg,
|
|
636
|
-
},
|
|
637
|
-
error: message,
|
|
638
|
-
});
|
|
639
|
-
} },
|
|
640
|
-
onError: (err: unknown, info: { kind: string }) => {
|
|
641
|
-
error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
|
|
642
|
-
},
|
|
685
|
+
// 5秒无响应自动回复进度提示
|
|
686
|
+
let hasResponseSent = false;
|
|
687
|
+
const processingTimer = setTimeout(async () => {
|
|
688
|
+
if (hasResponseSent) return;
|
|
689
|
+
try {
|
|
690
|
+
await sendAgentApiText({
|
|
691
|
+
agent,
|
|
692
|
+
toUser: fromUser,
|
|
693
|
+
chatId: undefined,
|
|
694
|
+
text: "正在处理中,请稍候..."
|
|
695
|
+
});
|
|
696
|
+
log?.(`[wecom-agent] sent processing notification to ${fromUser}`);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
error?.(`[wecom-agent] failed to send processing notification: ${String(err)}`);
|
|
643
699
|
}
|
|
644
|
-
});
|
|
700
|
+
}, 5000);
|
|
701
|
+
|
|
702
|
+
// 发送队列锁:确保所有 deliver 调用(以及内部的分片发送)严格串行执行
|
|
703
|
+
let messageSendQueue = Promise.resolve();
|
|
704
|
+
|
|
705
|
+
try {
|
|
706
|
+
// 调度回复
|
|
707
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
708
|
+
ctx: ctxPayload,
|
|
709
|
+
cfg: config,
|
|
710
|
+
replyOptions: {
|
|
711
|
+
disableBlockStreaming: true,
|
|
712
|
+
},
|
|
713
|
+
dispatcherOptions: {
|
|
714
|
+
deliver: async (payload: { text?: string }, info: { kind: string }) => {
|
|
715
|
+
const text = payload.text ?? "";
|
|
716
|
+
// 忽略空文本消息
|
|
717
|
+
if (!text || !text.trim()) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 标记已有回复,清除/失效定时器
|
|
722
|
+
hasResponseSent = true;
|
|
723
|
+
clearTimeout(processingTimer);
|
|
724
|
+
|
|
725
|
+
// 将本次发送任务加入队列
|
|
726
|
+
// 即使 deliver 被并发调用,队列中的任务也会按入队顺序串行执行
|
|
727
|
+
const currentTask = async () => {
|
|
728
|
+
const MAX_CHUNK_SIZE = 600;
|
|
729
|
+
// 确保分片顺序发送
|
|
730
|
+
for (let i = 0; i < text.length; i += MAX_CHUNK_SIZE) {
|
|
731
|
+
const chunk = text.slice(i, i + MAX_CHUNK_SIZE);
|
|
732
|
+
|
|
733
|
+
try {
|
|
734
|
+
await sendAgentApiText({ agent, toUser: fromUser, chatId: undefined, text: chunk });
|
|
735
|
+
touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
|
|
736
|
+
log?.(`[wecom-agent] reply chunk delivered (${info.kind}) to ${fromUser}, len=${chunk.length}`);
|
|
737
|
+
|
|
738
|
+
// 强制延时:确保企业微信有足够时间处理顺序
|
|
739
|
+
if (i + MAX_CHUNK_SIZE < text.length) {
|
|
740
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
741
|
+
}
|
|
742
|
+
} catch (err: unknown) {
|
|
743
|
+
const message = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}` : String(err);
|
|
744
|
+
error?.(`[wecom-agent] reply failed: ${message}`);
|
|
745
|
+
auditSink?.({
|
|
746
|
+
transport: "agent-callback",
|
|
747
|
+
category: "fallback-delivery-failed",
|
|
748
|
+
summary: `agent callback reply failed user=${fromUser} kind=${info.kind}`,
|
|
749
|
+
raw: {
|
|
750
|
+
transport: "agent-callback",
|
|
751
|
+
envelopeType: "xml",
|
|
752
|
+
body: msg,
|
|
753
|
+
},
|
|
754
|
+
error: message,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// 不同 Block 之间也增加一点间隔
|
|
760
|
+
if (info.kind !== "final") {
|
|
761
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
// 更新队列链
|
|
766
|
+
// 使用 then 链接,并捕获前一个任务可能的错误,确保当前任务总能执行
|
|
767
|
+
messageSendQueue = messageSendQueue
|
|
768
|
+
.then(() => currentTask())
|
|
769
|
+
.catch((err) => {
|
|
770
|
+
error?.(`[wecom-agent] previous send task failed: ${String(err)}`);
|
|
771
|
+
// 前一个失败不应阻止当前任务,继续尝试执行当前任务
|
|
772
|
+
return currentTask();
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// 等待当前任务完成(保持背压,虽然对于 http callback 模式这可能只是延迟了整体结束时间)
|
|
776
|
+
await messageSendQueue;
|
|
777
|
+
},
|
|
778
|
+
onError: (err: unknown, info: { kind: string }) => {
|
|
779
|
+
clearTimeout(processingTimer);
|
|
780
|
+
error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
|
|
781
|
+
},
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
} finally {
|
|
785
|
+
clearTimeout(processingTimer);
|
|
786
|
+
// 确保所有排队的消息都发完了才退出(虽然对于 HTTP 响应来说,res.end 早就调用了)
|
|
787
|
+
await messageSendQueue;
|
|
788
|
+
}
|
|
645
789
|
}
|
|
646
790
|
|
|
647
791
|
/**
|
|
@@ -30,7 +30,7 @@ export class WecomAccountRuntime {
|
|
|
30
30
|
readonly core: PluginRuntime,
|
|
31
31
|
readonly cfg: OpenClawConfig,
|
|
32
32
|
readonly resolved: ResolvedRuntimeAccount,
|
|
33
|
-
|
|
33
|
+
readonly log: {
|
|
34
34
|
info?: (message: string) => void;
|
|
35
35
|
warn?: (message: string) => void;
|
|
36
36
|
error?: (message: string) => void;
|
package/src/app/index.ts
CHANGED
|
@@ -27,6 +27,10 @@ export function registerAccountRuntime(accountRuntime: WecomAccountRuntime): voi
|
|
|
27
27
|
console.log(`[wecom-runtime] register account=${accountRuntime.account.accountId}`);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export function getAccountRuntime(accountId: string): WecomAccountRuntime | undefined {
|
|
31
|
+
return runtimes.get(accountId);
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
export function getAccountRuntimeSnapshot(accountId: string) {
|
|
31
35
|
return runtimes.get(accountId)?.buildRuntimeStatus();
|
|
32
36
|
}
|