@sunnoy/wecom 1.6.2 → 1.7.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 +207 -170
- package/package.json +2 -1
- package/think-parser.js +119 -0
- package/webhook.js +6 -1
- package/wecom/agent-inbound.js +16 -0
- package/wecom/channel-plugin.js +31 -7
- package/wecom/http-handler-state.js +23 -0
- package/wecom/http-handler.js +30 -6
- package/wecom/inbound-processor.js +9 -10
- package/wecom/outbound-delivery.js +4 -112
- package/wecom/stream-utils.js +14 -0
package/README.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
`openclaw-plugin-wecom` 是一个专为 [OpenClaw](https://github.com/openclaw/openclaw) 框架开发的企业微信(WeCom)集成插件。它允许你将强大的 AI 能力无缝接入企业微信,支持 AI 机器人模式和自建应用模式,并具备多层消息投递回退机制。
|
|
4
4
|
|
|
5
|
+
## 目录导航
|
|
6
|
+
|
|
7
|
+
### 快速开始
|
|
8
|
+
- [核心特性](#核心特性)
|
|
9
|
+
- [前置要求](#前置要求)
|
|
10
|
+
- [安装](#安装)
|
|
11
|
+
- [运行测试](#运行测试)
|
|
12
|
+
- [运行真实 E2E 测试(远程 OpenClaw)](#运行真实-e2e-测试远程-openclaw)
|
|
13
|
+
|
|
14
|
+
### 配置与接入
|
|
15
|
+
- [配置](#配置)
|
|
16
|
+
- [配置说明](#配置说明)
|
|
17
|
+
- [企业微信后台配置](#企业微信后台配置)
|
|
18
|
+
- [方式一:创建 AI 机器人 (Bot 模式)](#方式一创建-ai-机器人-bot-模式)
|
|
19
|
+
- [方式二:创建自建应用 (Agent 模式)](#方式二创建自建应用-agent-模式)
|
|
20
|
+
- [方式三:配置群机器人 (Webhook 模式)](#方式三配置群机器人-webhook-模式)
|
|
21
|
+
|
|
22
|
+
### 能力与路由
|
|
23
|
+
- [三种模式消息能力对比](#三种模式消息能力对比)
|
|
24
|
+
- [支持的消息类型](#支持的消息类型)
|
|
25
|
+
- [流式回复能力](#流式回复能力)
|
|
26
|
+
- [管理员用户](#管理员用户)
|
|
27
|
+
- [动态 Agent 路由](#动态-agent-路由)
|
|
28
|
+
- [支持的目标格式](#支持的目标格式)
|
|
29
|
+
- [指令白名单](#指令白名单)
|
|
30
|
+
- [消息防抖合并](#消息防抖合并)
|
|
31
|
+
|
|
32
|
+
### 运维与参考
|
|
33
|
+
- [常见问题 (FAQ)](#常见问题-faq)
|
|
34
|
+
- [项目结构](#项目结构)
|
|
35
|
+
- [贡献规范](#贡献规范)
|
|
36
|
+
- [开源协议](#开源协议)
|
|
37
|
+
- [配置示例参考](#配置示例参考)
|
|
38
|
+
- [自定义 Skills 配合沙箱使用实践](#自定义-skills-配合沙箱使用实践)
|
|
39
|
+
|
|
5
40
|
## 核心特性
|
|
6
41
|
|
|
7
42
|
### 消息模式支持
|
|
@@ -9,6 +44,12 @@
|
|
|
9
44
|
- **自建应用模式 (Agent Mode)**: 支持企业微信自建应用,可处理 XML 格式的回调消息,支持收发消息、上传下载媒体文件。
|
|
10
45
|
- **Webhook Bot 模式**: 支持通过 Webhook 发送消息到群聊,适用于群通知场景。
|
|
11
46
|
|
|
47
|
+
### 流式回复增强
|
|
48
|
+
- **Markdown 格式支持**: 流式回复的 `content` 字段支持常见 Markdown 格式,包括加粗、斜体、代码块、列表、标题、链接等,企业微信客户端会自动渲染。
|
|
49
|
+
- **思考过程展示**: 当 LLM 回复包含 `<think>...</think>` 标签时,插件自动解析并通过 `thinking_content` 字段在客户端展示可折叠的思考过程。
|
|
50
|
+
- **被动回复思考模式**: 首次被动回复即启用思考模式 UI(通过 `thinking_content` 字段),用户无需等待即可看到模型正在思考的状态。
|
|
51
|
+
- **图片混合回复**: 支持在最终回复(`finish=true`)时包含 `msgtype` 为 `image` 的 `msg_item`,流式过程中图片会排队等待最终发送。
|
|
52
|
+
|
|
12
53
|
### 智能消息投递
|
|
13
54
|
- **四层投递回退机制**: 确保消息可靠送达
|
|
14
55
|
1. **流式通道**: 优先通过活跃流式通道发送
|
|
@@ -282,6 +323,131 @@ Webhook Bot 用于向群聊发送通知消息。
|
|
|
282
323
|
- 每个群聊可添加多个机器人
|
|
283
324
|
- Webhook 地址请妥善保管,避免泄露
|
|
284
325
|
|
|
326
|
+
## 三种模式消息能力对比
|
|
327
|
+
|
|
328
|
+
企业微信提供了三种不同的接入方式,每种方式在私聊和群聊场景下的消息收发能力不同:
|
|
329
|
+
|
|
330
|
+
### 能力矩阵
|
|
331
|
+
|
|
332
|
+
| 能力 | Bot 模式 (AI 机器人) | Agent 模式 (自建应用) | Webhook 模式 (群机器人) |
|
|
333
|
+
|------|---------------------|---------------------|----------------------|
|
|
334
|
+
| **私聊接收** | ✅ JSON 回调 | ✅ XML 回调 | ❌ 不支持 |
|
|
335
|
+
| **私聊被动回复** | ✅ 流式 stream | ✅ 同步回复 | ❌ 不支持 |
|
|
336
|
+
| **私聊主动发送** | ❌ 不支持 | ✅ 应用消息 API | ❌ 不支持 |
|
|
337
|
+
| **群聊接收** | ✅ @提及 JSON 回调 | ✅ @提及 XML 回调 | ❌ 不支持 |
|
|
338
|
+
| **群聊被动回复** | ✅ 流式 stream | ✅ 同步回复 | ❌ 不支持 |
|
|
339
|
+
| **群聊主动发送** | ❌ 不支持 | ✅ 应用消息 API | ✅ Webhook URL |
|
|
340
|
+
| **流式回复** | ✅ 打字机效果 | ❌ 仅完整消息 | ❌ 仅完整消息 |
|
|
341
|
+
| **思考过程展示** | ✅ thinking_content | ❌ | ❌ |
|
|
342
|
+
| **媒体发送** | ✅ msg_item (图片) | ✅ API 上传 (图片/文件) | ✅ base64/upload |
|
|
343
|
+
| **Markdown** | ✅ stream content | ✅ Markdown 消息类型 | ✅ Markdown 消息类型 |
|
|
344
|
+
|
|
345
|
+
### 各模式详细说明
|
|
346
|
+
|
|
347
|
+
#### Bot 模式 (AI 机器人)
|
|
348
|
+
|
|
349
|
+
> 📖 [企业微信 AI 机器人开发指南](https://developer.work.weixin.qq.com/document/path/101039)
|
|
350
|
+
|
|
351
|
+
**消息接收机制**:企业微信将用户消息以 **JSON 格式**通过 HTTP POST 回调到配置的 URL。支持私聊消息和群聊中 @提及机器人的消息。
|
|
352
|
+
|
|
353
|
+
**消息回复机制**:采用**流式分片(streaming)**回复。收到回调后立即返回 `stream_id`,后续通过 `stream_refresh` 轮询接口推送增量内容。客户端展示打字机效果。
|
|
354
|
+
|
|
355
|
+
- **被动回复**:用户发消息 → 回调触发 → 流式回复(支持文本、Markdown、图片、思考过程)
|
|
356
|
+
- **主动发送**:❌ 不支持。AI 机器人没有主动发送 API,只能在收到消息后回复
|
|
357
|
+
- **适用场景**:实时对话、问答,流式体验好
|
|
358
|
+
|
|
359
|
+
#### Agent 模式 (自建应用)
|
|
360
|
+
|
|
361
|
+
> 📖 [企业微信自建应用开发指南](https://developer.work.weixin.qq.com/document/path/90226)
|
|
362
|
+
> 📖 [应用消息发送 API](https://developer.work.weixin.qq.com/document/path/90236)
|
|
363
|
+
|
|
364
|
+
**消息接收机制**:企业微信将用户消息以 **XML 格式**通过 HTTP POST 回调到配置的 URL。支持私聊和群聊消息,以及图片、语音、文件等多种消息类型。
|
|
365
|
+
|
|
366
|
+
**消息回复机制**:
|
|
367
|
+
- **被动回复**:在回调响应中直接返回 XML 格式回复(需在 5 秒内响应)
|
|
368
|
+
- **主动发送**:通过[应用消息 API](https://developer.work.weixin.qq.com/document/path/90236) 可主动向用户发送文本、图片、文件、Markdown 等消息。支持指定 `touser`(用户)、`toparty`(部门)、`totag`(标签)
|
|
369
|
+
|
|
370
|
+
- **适用场景**:需要主动推送的场景(异步任务完成通知、定时报告),需要收发文件的场景
|
|
371
|
+
|
|
372
|
+
#### Webhook 模式 (群机器人)
|
|
373
|
+
|
|
374
|
+
> 📖 [企业微信群机器人配置说明](https://developer.work.weixin.qq.com/document/path/99110)
|
|
375
|
+
|
|
376
|
+
**消息发送机制**:通过 HTTP POST 请求向 Webhook URL 发送消息。支持文本、Markdown、图片(base64)、文件(需先上传获取 media_id)。
|
|
377
|
+
|
|
378
|
+
- **接收消息**:❌ 不支持。Webhook 仅为单向发送通道
|
|
379
|
+
- **主动发送**:✅ 向 Webhook URL POST 即可发送到群聊
|
|
380
|
+
- **适用场景**:单向通知(告警、日报)、定时推送
|
|
381
|
+
|
|
382
|
+
### Webhook 消息发送方式
|
|
383
|
+
|
|
384
|
+
Webhook 配置好后(见[方式三](#方式三配置群机器人-webhook-模式)),有以下方式发送消息:
|
|
385
|
+
|
|
386
|
+
#### CLI 直接发送
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
openclaw message send --channel wecom --to "webhook:ops-group" "服务已恢复正常"
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
#### Agent 处理后投递到群
|
|
393
|
+
|
|
394
|
+
让 agent 处理消息后将回复发到 webhook 群:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
openclaw agent --agent myagent \
|
|
398
|
+
--message "帮我总结今天的监控告警" \
|
|
399
|
+
--deliver \
|
|
400
|
+
--reply-channel wecom \
|
|
401
|
+
--reply-to "webhook:ops-group"
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
#### Heartbeat 定时推送(推荐)
|
|
405
|
+
|
|
406
|
+
在 agent 配置中添加 heartbeat,自动定时触发并将回复发到 webhook 群:
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
{
|
|
410
|
+
"id": "report-agent",
|
|
411
|
+
"heartbeat": {
|
|
412
|
+
"every": "1h",
|
|
413
|
+
"target": "webhook:ops-group",
|
|
414
|
+
"prompt": "请总结最新的系统监控状态",
|
|
415
|
+
"activeHours": {
|
|
416
|
+
"start": "09:00",
|
|
417
|
+
"end": "18:00",
|
|
418
|
+
"timezone": "Asia/Shanghai"
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
- `every` — 触发间隔(如 `30m`, `1h`, `6h`)
|
|
425
|
+
- `target` — 回复目标,`webhook:` 前缀加配置中的 webhook 名称
|
|
426
|
+
- `prompt` — 每次触发时给 agent 的提示语
|
|
427
|
+
- `activeHours` — 可选,限制只在工作时间段内触发
|
|
428
|
+
|
|
429
|
+
#### 系统 Crontab 定时发送
|
|
430
|
+
|
|
431
|
+
```bash
|
|
432
|
+
# crontab -e
|
|
433
|
+
# 每天早上9点发送日报
|
|
434
|
+
0 9 * * * openclaw agent --agent report-agent --message "生成今日晨报" --deliver --reply-channel wecom --reply-to "webhook:ops-group"
|
|
435
|
+
|
|
436
|
+
# 每小时发送监控摘要
|
|
437
|
+
0 * * * * openclaw message send --channel wecom --to "webhook:monitor-group" "$(curl -s http://localhost:9090/api/v1/alerts | jq -r '.data.alerts | length') 条活跃告警"
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### 模式选择建议
|
|
441
|
+
|
|
442
|
+
| 需求 | 推荐模式 |
|
|
443
|
+
|------|---------|
|
|
444
|
+
| 实时对话,流式打字机体验 | **Bot 模式** |
|
|
445
|
+
| 双向对话 + 主动推送 + 文件处理 | **Agent 模式** |
|
|
446
|
+
| 仅需向群聊推送通知 | **Webhook 模式** |
|
|
447
|
+
| 同时需要对话和群通知 | **Bot/Agent 模式 + Webhook 模式** 组合使用 |
|
|
448
|
+
|
|
449
|
+
> 💡 **三种模式可以同时启用**。例如:Bot 模式处理日常对话,Webhook 模式负责定时推送通知到群。配置时在同一个 `channels.wecom` 下同时填写 `token`/`encodingAesKey`(Bot)、`agent`(Agent)和 `webhooks`(Webhook)即可。
|
|
450
|
+
|
|
285
451
|
## 支持的消息类型
|
|
286
452
|
|
|
287
453
|
| 类型 | 方向 | 说明 |
|
|
@@ -294,6 +460,46 @@ Webhook Bot 用于向群聊发送通知消息。
|
|
|
294
460
|
| 位置 (location) | 收 | 位置分享(转换为文本描述) |
|
|
295
461
|
| 链接 (link) | 收 | 分享链接(提取标题、描述、URL 为文本) |
|
|
296
462
|
|
|
463
|
+
## 流式回复能力
|
|
464
|
+
|
|
465
|
+
### Markdown 格式
|
|
466
|
+
|
|
467
|
+
流式回复的 `content` 字段支持以下 Markdown 格式,企业微信客户端会自动渲染:
|
|
468
|
+
|
|
469
|
+
| 格式 | 语法 | 示例 |
|
|
470
|
+
|------|------|------|
|
|
471
|
+
| 加粗 | `**text**` | **加粗文本** |
|
|
472
|
+
| 斜体 | `*text*` | *斜体文本* |
|
|
473
|
+
| 行内代码 | `` `code` `` | `code` |
|
|
474
|
+
| 代码块 | ` ```lang ... ``` ` | 多行代码 |
|
|
475
|
+
| 列表 | `- item` / `1. item` | 有序/无序列表 |
|
|
476
|
+
| 标题 | `# H1` / `## H2` | 各级标题 |
|
|
477
|
+
| 链接 | `[text](url)` | 超链接 |
|
|
478
|
+
|
|
479
|
+
### 思考过程展示(Thinking Mode)
|
|
480
|
+
|
|
481
|
+
当 LLM 模型(如 DeepSeek、QwQ 等支持思考模式的模型)在回复中输出 `<think>...</think>` 标签时,插件会自动:
|
|
482
|
+
|
|
483
|
+
1. **解析** `<think>` 标签,将思考内容与可见内容分离
|
|
484
|
+
2. **映射** 思考内容到企业微信流式回复的 `thinking_content` 字段
|
|
485
|
+
3. **展示** 企业微信客户端会以可折叠的方式显示模型的思考过程
|
|
486
|
+
|
|
487
|
+
**流式处理说明:**
|
|
488
|
+
- 被动回复(首次同步响应)立即启用思考模式 UI,显示「思考中...」
|
|
489
|
+
- 当检测到未闭合的 `<think>` 标签(流式输出中),`thinking_content` 持续更新
|
|
490
|
+
- `</think>` 闭合后,思考内容固定,后续内容显示为可见回复
|
|
491
|
+
- 代码块内的 `<think>` 标签不会被解析(避免误匹配)
|
|
492
|
+
|
|
493
|
+
**支持的标签变体:** `<think>`, `<thinking>`, `<thought>`(均不区分大小写)
|
|
494
|
+
|
|
495
|
+
### 图片回复
|
|
496
|
+
|
|
497
|
+
图片通过 `msg_item` 以 base64 编码在流式回复结束时发送:
|
|
498
|
+
|
|
499
|
+
- 仅在 `finish=true`(最终回复)时包含 `msgtype` 为 `image` 的 `msg_item`
|
|
500
|
+
- 流式过程中生成的图片会排队,待回复完成后一次性发送
|
|
501
|
+
- 单张图片最大 2MB,支持 JPG/PNG 格式,每条消息最多 10 张
|
|
502
|
+
|
|
297
503
|
## 管理员用户
|
|
298
504
|
|
|
299
505
|
管理员用户默认可以绕过指令白名单限制。若希望管理员用户同时跳过动态 Agent 路由(直接路由到主 Agent),可开启 `dynamicAgents.adminBypass`。
|
|
@@ -715,6 +921,7 @@ openclaw-plugin-wecom/
|
|
|
715
921
|
├── logger.js # 日志模块
|
|
716
922
|
├── utils.js # 工具函数(TTL 缓存、消息去重)
|
|
717
923
|
├── stream-manager.js # 流式回复管理
|
|
924
|
+
├── think-parser.js # 思考标签解析(<think> 标签分离)
|
|
718
925
|
├── image-processor.js # 图片编码/校验(msg_item)
|
|
719
926
|
├── webhook.js # 企业微信 Bot 模式 HTTP 通信处理
|
|
720
927
|
├── dynamic-agent.js # 动态 Agent 分配逻辑
|
|
@@ -760,176 +967,6 @@ openclaw-plugin-wecom/
|
|
|
760
967
|
|
|
761
968
|
本项目采用 [ISC License](./LICENSE) 协议。
|
|
762
969
|
|
|
763
|
-
## 配置示例参考
|
|
764
|
-
|
|
765
|
-
以下是一个生产环境的脱敏配置示例,供参考:
|
|
766
|
-
|
|
767
|
-
```json
|
|
768
|
-
{
|
|
769
|
-
"meta": {
|
|
770
|
-
"lastTouchedVersion": "2026.2.25",
|
|
771
|
-
"lastTouchedAt": "2026-02-28T03:14:11.564Z"
|
|
772
|
-
},
|
|
773
|
-
"wizard": {
|
|
774
|
-
"lastRunAt": "2026-02-26T09:29:04.028Z",
|
|
775
|
-
"lastRunVersion": "2026.2.25",
|
|
776
|
-
"lastRunCommand": "onboard",
|
|
777
|
-
"lastRunMode": "local"
|
|
778
|
-
},
|
|
779
|
-
"logging": {
|
|
780
|
-
"level": "info",
|
|
781
|
-
"consoleLevel": "debug",
|
|
782
|
-
"consoleStyle": "pretty"
|
|
783
|
-
},
|
|
784
|
-
"models": {
|
|
785
|
-
"mode": "merge",
|
|
786
|
-
"providers": {
|
|
787
|
-
"bailian": {
|
|
788
|
-
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
|
789
|
-
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxx",
|
|
790
|
-
"api": "openai-completions",
|
|
791
|
-
"models": [
|
|
792
|
-
{ "id": "qwen3.5-plus", "name": "qwen3.5-plus", "reasoning": false, "input": ["text", "image"], "contextWindow": 1000000, "maxTokens": 65536 },
|
|
793
|
-
{ "id": "MiniMax-M2.5", "name": "MiniMax-M2.5", "reasoning": false, "input": ["text"], "contextWindow": 1000000, "maxTokens": 65536 },
|
|
794
|
-
{ "id": "glm-5", "name": "glm-5", "reasoning": false, "input": ["text"], "contextWindow": 202752, "maxTokens": 16384 },
|
|
795
|
-
{ "id": "glm-4.7", "name": "glm-4.7", "reasoning": false, "input": ["text"], "contextWindow": 202752, "maxTokens": 16384 },
|
|
796
|
-
{ "id": "kimi-k2.5", "name": "kimi-k2.5", "reasoning": false, "input": ["text", "image"], "contextWindow": 262144, "maxTokens": 32768 }
|
|
797
|
-
]
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
},
|
|
801
|
-
"agents": {
|
|
802
|
-
"defaults": {
|
|
803
|
-
"model": { "primary": "bailian/kimi-k2.5" },
|
|
804
|
-
"models": {
|
|
805
|
-
"bailian/qwen3.5-plus": {},
|
|
806
|
-
"bailian/MiniMax-M2.5": {},
|
|
807
|
-
"bailian/glm-5": {},
|
|
808
|
-
"bailian/glm-4.7": {},
|
|
809
|
-
"bailian/kimi-k2.5": {}
|
|
810
|
-
},
|
|
811
|
-
"workspace": "/path/to/workspace",
|
|
812
|
-
"userTimezone": "Asia/Shanghai",
|
|
813
|
-
"timeFormat": "24",
|
|
814
|
-
"compaction": {
|
|
815
|
-
"mode": "safeguard",
|
|
816
|
-
"reserveTokensFloor": 20000,
|
|
817
|
-
"memoryFlush": {
|
|
818
|
-
"enabled": true,
|
|
819
|
-
"softThresholdTokens": 4000
|
|
820
|
-
}
|
|
821
|
-
},
|
|
822
|
-
"thinkingDefault": "medium",
|
|
823
|
-
"verboseDefault": "on",
|
|
824
|
-
"heartbeat": {
|
|
825
|
-
"every": "10m",
|
|
826
|
-
"target": "last",
|
|
827
|
-
"directPolicy": "allow"
|
|
828
|
-
},
|
|
829
|
-
"sandbox": {
|
|
830
|
-
"mode": "all",
|
|
831
|
-
"workspaceAccess": "rw",
|
|
832
|
-
"scope": "agent",
|
|
833
|
-
"docker": {
|
|
834
|
-
"image": "your-registry.com/openclaw-agent:v2026.x.x",
|
|
835
|
-
"readOnlyRoot": false,
|
|
836
|
-
"network": "bridge",
|
|
837
|
-
"extraHosts": [
|
|
838
|
-
"your-domain.internal:xxx.xxx.xxx.xxx"
|
|
839
|
-
],
|
|
840
|
-
"binds": [
|
|
841
|
-
"/path/to/skills:/workspace/skills:ro"
|
|
842
|
-
],
|
|
843
|
-
"dangerouslyAllowReservedContainerTargets": true,
|
|
844
|
-
"dangerouslyAllowExternalBindSources": true
|
|
845
|
-
},
|
|
846
|
-
"prune": {
|
|
847
|
-
"idleHours": 87600,
|
|
848
|
-
"maxAgeDays": 3650
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
},
|
|
852
|
-
"list": [
|
|
853
|
-
{ "id": "main" },
|
|
854
|
-
{ "id": "wecom-dm-xxxxxx" }
|
|
855
|
-
]
|
|
856
|
-
},
|
|
857
|
-
"commands": {
|
|
858
|
-
"native": "auto",
|
|
859
|
-
"nativeSkills": "auto",
|
|
860
|
-
"restart": true,
|
|
861
|
-
"ownerDisplay": "raw"
|
|
862
|
-
},
|
|
863
|
-
"session": {
|
|
864
|
-
"dmScope": "per-channel-peer"
|
|
865
|
-
},
|
|
866
|
-
"hooks": {
|
|
867
|
-
"internal": {
|
|
868
|
-
"enabled": true,
|
|
869
|
-
"entries": {
|
|
870
|
-
"boot-md": { "enabled": true },
|
|
871
|
-
"command-logger": { "enabled": true },
|
|
872
|
-
"session-memory": { "enabled": true },
|
|
873
|
-
"bootstrap-extra-files": { "enabled": true }
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
},
|
|
877
|
-
"channels": {
|
|
878
|
-
"wecom": {
|
|
879
|
-
"enabled": true,
|
|
880
|
-
"token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
881
|
-
"encodingAesKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
882
|
-
"commands": {
|
|
883
|
-
"enabled": true,
|
|
884
|
-
"allowlist": ["/help", "/commands", "/status", "/context", "/whoami", "/new", "/compact", "/stop", "/reset", "/usage", "/think", "/thinking", "/t", "/verbose", "/v", "/reasoning", "/reason", "/model", "/models", "/skill"]
|
|
885
|
-
},
|
|
886
|
-
"dynamicAgents": { "enabled": true },
|
|
887
|
-
"dm": { "createAgentOnFirstMessage": true },
|
|
888
|
-
"groupChat": { "enabled": true, "requireMention": true },
|
|
889
|
-
"adminUsers": ["admin_userid"],
|
|
890
|
-
"workspaceTemplate": "/path/to/workspace-template",
|
|
891
|
-
"agent": {
|
|
892
|
-
"corpId": "wwxxxxxxxxxxxxxxxx",
|
|
893
|
-
"corpSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
894
|
-
"agentId": 1000002,
|
|
895
|
-
"token": "xxxxxxxxxxxxxxx",
|
|
896
|
-
"encodingAesKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
},
|
|
900
|
-
"gateway": {
|
|
901
|
-
"port": 18789,
|
|
902
|
-
"mode": "local",
|
|
903
|
-
"bind": "lan",
|
|
904
|
-
"controlUi": {
|
|
905
|
-
"dangerouslyAllowHostHeaderOriginFallback": true,
|
|
906
|
-
"allowInsecureAuth": true
|
|
907
|
-
},
|
|
908
|
-
"auth": {
|
|
909
|
-
"mode": "token",
|
|
910
|
-
"token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
911
|
-
},
|
|
912
|
-
"tailscale": {
|
|
913
|
-
"mode": "off",
|
|
914
|
-
"resetOnExit": false
|
|
915
|
-
}
|
|
916
|
-
},
|
|
917
|
-
"skills": {
|
|
918
|
-
"allowBundled": ["_none_"],
|
|
919
|
-
"load": {
|
|
920
|
-
"extraDirs": ["/path/to/skills"],
|
|
921
|
-
"watch": true,
|
|
922
|
-
"watchDebounceMs": 250
|
|
923
|
-
},
|
|
924
|
-
"install": { "nodeManager": "npm" }
|
|
925
|
-
},
|
|
926
|
-
"plugins": {
|
|
927
|
-
"allow": ["wecom"],
|
|
928
|
-
"entries": { "wecom": { "enabled": true } }
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
```
|
|
932
|
-
|
|
933
970
|
## 自定义 Skills 配合沙箱使用实践
|
|
934
971
|
|
|
935
972
|
OpenClaw 支持自定义 Skills 并通过沙箱(Docker)隔离执行,以下是生产环境的实践配置:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sunnoy/wecom",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"LICENSE",
|
|
16
16
|
"CONTRIBUTING.md",
|
|
17
17
|
"stream-manager.js",
|
|
18
|
+
"think-parser.js",
|
|
18
19
|
"utils.js",
|
|
19
20
|
"webhook.js",
|
|
20
21
|
"openclaw.plugin.json"
|
package/think-parser.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse <think>...</think> tags from LLM output.
|
|
3
|
+
*
|
|
4
|
+
* Separates content into visible text and thinking process for WeCom's
|
|
5
|
+
* thinking_content stream field. Handles streaming (unclosed tags) and
|
|
6
|
+
* ignores tags inside code blocks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const QUICK_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought)\b/i;
|
|
10
|
+
const THINK_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought)\b[^<>]*>/gi;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Find code regions (``` blocks and `inline`) to avoid processing think tags
|
|
14
|
+
* that appear inside code.
|
|
15
|
+
* @param {string} text
|
|
16
|
+
* @returns {Array<[number, number]>}
|
|
17
|
+
*/
|
|
18
|
+
function findCodeRegions(text) {
|
|
19
|
+
const regions = [];
|
|
20
|
+
// Fenced code blocks (triple backtick).
|
|
21
|
+
const blockRe = /```[\s\S]*?```/g;
|
|
22
|
+
for (const m of text.matchAll(blockRe)) {
|
|
23
|
+
regions.push([m.index, m.index + m[0].length]);
|
|
24
|
+
}
|
|
25
|
+
// Inline code (single backtick, same line).
|
|
26
|
+
const inlineRe = /`[^`\n]+`/g;
|
|
27
|
+
for (const m of text.matchAll(inlineRe)) {
|
|
28
|
+
if (!isInsideRegion(m.index, regions)) {
|
|
29
|
+
regions.push([m.index, m.index + m[0].length]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return regions;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {number} pos
|
|
37
|
+
* @param {Array<[number, number]>} regions
|
|
38
|
+
*/
|
|
39
|
+
function isInsideRegion(pos, regions) {
|
|
40
|
+
for (const [start, end] of regions) {
|
|
41
|
+
if (pos >= start && pos < end) return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse thinking content from text that may contain <think>...</think> tags.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} text - Raw accumulated stream text
|
|
50
|
+
* @returns {{ visibleContent: string, thinkingContent: string, isThinking: boolean }}
|
|
51
|
+
* - visibleContent: text with think blocks removed (for content field)
|
|
52
|
+
* - thinkingContent: concatenated thinking text (for thinking_content field)
|
|
53
|
+
* - isThinking: true when an unclosed <think> tag is present (streaming)
|
|
54
|
+
*/
|
|
55
|
+
export function parseThinkingContent(text) {
|
|
56
|
+
if (!text) {
|
|
57
|
+
return { visibleContent: "", thinkingContent: "", isThinking: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fast path: no think tags at all.
|
|
61
|
+
if (!QUICK_TAG_RE.test(text)) {
|
|
62
|
+
return { visibleContent: text, thinkingContent: "", isThinking: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const codeRegions = findCodeRegions(text);
|
|
66
|
+
|
|
67
|
+
const visibleParts = [];
|
|
68
|
+
const thinkingParts = [];
|
|
69
|
+
let lastIndex = 0;
|
|
70
|
+
let inThinking = false;
|
|
71
|
+
|
|
72
|
+
THINK_TAG_RE.lastIndex = 0;
|
|
73
|
+
for (const match of text.matchAll(THINK_TAG_RE)) {
|
|
74
|
+
const idx = match.index;
|
|
75
|
+
const isClose = match[1] === "/";
|
|
76
|
+
|
|
77
|
+
// Skip tags inside code blocks.
|
|
78
|
+
if (isInsideRegion(idx, codeRegions)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const segment = text.slice(lastIndex, idx);
|
|
83
|
+
|
|
84
|
+
if (!inThinking) {
|
|
85
|
+
if (!isClose) {
|
|
86
|
+
// Opening <think>: preceding text is visible.
|
|
87
|
+
visibleParts.push(segment);
|
|
88
|
+
inThinking = true;
|
|
89
|
+
} else {
|
|
90
|
+
// Stray </think> without opening: treat as visible text.
|
|
91
|
+
visibleParts.push(segment);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
if (isClose) {
|
|
95
|
+
// Closing </think>: text since opening is thinking content.
|
|
96
|
+
thinkingParts.push(segment);
|
|
97
|
+
inThinking = false;
|
|
98
|
+
}
|
|
99
|
+
// Nested or duplicate opening tag inside thinking: ignore.
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lastIndex = idx + match[0].length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Remaining text after the last tag.
|
|
106
|
+
const remaining = text.slice(lastIndex);
|
|
107
|
+
if (inThinking) {
|
|
108
|
+
// Unclosed <think>: remaining text is part of thinking (streaming state).
|
|
109
|
+
thinkingParts.push(remaining);
|
|
110
|
+
} else {
|
|
111
|
+
visibleParts.push(remaining);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
visibleContent: visibleParts.join("").trim(),
|
|
116
|
+
thinkingContent: thinkingParts.join("\n").trim(),
|
|
117
|
+
isThinking: inThinking,
|
|
118
|
+
};
|
|
119
|
+
}
|
package/webhook.js
CHANGED
|
@@ -435,7 +435,12 @@ export class WecomWebhook {
|
|
|
435
435
|
content: content,
|
|
436
436
|
};
|
|
437
437
|
|
|
438
|
-
//
|
|
438
|
+
// Thinking content for model reasoning display (collapsible in WeCom client).
|
|
439
|
+
if (options.thinkingContent) {
|
|
440
|
+
stream.thinking_content = options.thinkingContent;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Optional mixed media list (images are valid only on finished responses).
|
|
439
444
|
if (options.msgItem && options.msgItem.length > 0) {
|
|
440
445
|
stream.msg_item = options.msgItem;
|
|
441
446
|
}
|
package/wecom/agent-inbound.js
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
|
|
44
44
|
const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
|
|
45
45
|
const recentAgentMsgIds = new Map();
|
|
46
|
+
const AGENT_INBOUND_ALLOWED_MSG_TYPES = new Set(["text", "image", "voice", "video", "file"]);
|
|
46
47
|
|
|
47
48
|
function rememberAgentMsgId(msgId) {
|
|
48
49
|
const now = Date.now();
|
|
@@ -157,6 +158,21 @@ async function handleMessageCallback(req, res, crypto, agentConfig, config, acco
|
|
|
157
158
|
const msgId = extractMsgId(msg);
|
|
158
159
|
const content = extractContent(msg);
|
|
159
160
|
|
|
161
|
+
// White-list inbound message types to prevent event callbacks
|
|
162
|
+
// (for example subscribe/unsubscribe) from triggering LLM replies.
|
|
163
|
+
if (!AGENT_INBOUND_ALLOWED_MSG_TYPES.has(msgType)) {
|
|
164
|
+
logger.info("[agent-inbound] unsupported msgType ignored", {
|
|
165
|
+
msgType,
|
|
166
|
+
fromUser,
|
|
167
|
+
chatId: chatId || "N/A",
|
|
168
|
+
msgId: msgId || "N/A",
|
|
169
|
+
contentPreview: content.substring(0, 100),
|
|
170
|
+
});
|
|
171
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
172
|
+
res.end("success");
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
160
176
|
// Deduplication
|
|
161
177
|
if (msgId) {
|
|
162
178
|
if (!rememberAgentMsgId(msgId)) {
|
package/wecom/channel-plugin.js
CHANGED
|
@@ -282,12 +282,20 @@ export const wecomChannelPlugin = {
|
|
|
282
282
|
// `to` format: "wecom:userid" or "userid".
|
|
283
283
|
const userId = to.replace(/^wecom:/, "");
|
|
284
284
|
|
|
285
|
-
// Prefer stream
|
|
285
|
+
// Prefer async-context stream only when it is still writable.
|
|
286
|
+
// If the context stream already finished (common with concurrent messages),
|
|
287
|
+
// fall back to the latest recoverable active stream for this user/group.
|
|
286
288
|
const ctx = streamContext.getStore();
|
|
287
|
-
const
|
|
289
|
+
const ctxStreamId = ctx?.streamId ?? null;
|
|
290
|
+
const ctxStream = ctxStreamId ? streamManager.getStream(ctxStreamId) : null;
|
|
291
|
+
const canUseCtxStream = !!(ctxStreamId && ctxStream && !ctxStream.finished);
|
|
292
|
+
const streamId = canUseCtxStream ? ctxStreamId : resolveRecoverableStream(userId);
|
|
293
|
+
const streamObj = streamId ? streamManager.getStream(streamId) : null;
|
|
294
|
+
const hasStream = streamId ? streamManager.hasStream(streamId) : false;
|
|
295
|
+
const finished = streamObj?.finished ?? true;
|
|
288
296
|
|
|
289
297
|
// Layer 1: Active stream (normal path)
|
|
290
|
-
if (streamId &&
|
|
298
|
+
if (streamId && hasStream && !finished) {
|
|
291
299
|
logger.debug("Appending outbound text to stream", {
|
|
292
300
|
userId,
|
|
293
301
|
streamId,
|
|
@@ -303,6 +311,19 @@ export const wecomChannelPlugin = {
|
|
|
303
311
|
};
|
|
304
312
|
}
|
|
305
313
|
|
|
314
|
+
// Log stream miss details for debugging concurrent-message issues.
|
|
315
|
+
logger.warn("WeCom sendText: Layer 1 stream miss", {
|
|
316
|
+
userId,
|
|
317
|
+
streamId: streamId ?? null,
|
|
318
|
+
hasStream,
|
|
319
|
+
finished,
|
|
320
|
+
hasAsyncContext: !!ctx,
|
|
321
|
+
ctxStreamId,
|
|
322
|
+
canUseCtxStream,
|
|
323
|
+
ctxStreamKey: ctx?.streamKey ?? null,
|
|
324
|
+
textPreview: text.substring(0, 50),
|
|
325
|
+
});
|
|
326
|
+
|
|
306
327
|
// Layer 2: Fallback via response_url
|
|
307
328
|
// response_url is valid for 1 hour and can be used only once.
|
|
308
329
|
// responseUrls is keyed by streamKey (fromUser for DM, chatId for group).
|
|
@@ -312,7 +333,7 @@ export const wecomChannelPlugin = {
|
|
|
312
333
|
const response = await wecomFetch(saved.url, {
|
|
313
334
|
method: "POST",
|
|
314
335
|
headers: { "Content-Type": "application/json" },
|
|
315
|
-
body: JSON.stringify({ msgtype: "
|
|
336
|
+
body: JSON.stringify({ msgtype: "markdown", markdown: { content: text } }),
|
|
316
337
|
});
|
|
317
338
|
const responseBody = await response.text().catch(() => "");
|
|
318
339
|
const result = parseResponseUrlResult(response, responseBody);
|
|
@@ -400,11 +421,14 @@ export const wecomChannelPlugin = {
|
|
|
400
421
|
sendMedia: async ({ cfg: _cfg, to, text, mediaUrl, accountId: _accountId }) => {
|
|
401
422
|
const userId = to.replace(/^wecom:/, "");
|
|
402
423
|
|
|
403
|
-
// Prefer stream
|
|
424
|
+
// Prefer async-context stream only when it is still writable.
|
|
404
425
|
const ctx = streamContext.getStore();
|
|
405
|
-
const
|
|
426
|
+
const ctxStreamId = ctx?.streamId ?? null;
|
|
427
|
+
const ctxStream = ctxStreamId ? streamManager.getStream(ctxStreamId) : null;
|
|
428
|
+
const canUseCtxStream = !!(ctxStreamId && ctxStream && !ctxStream.finished);
|
|
429
|
+
const streamId = canUseCtxStream ? ctxStreamId : resolveRecoverableStream(userId);
|
|
406
430
|
|
|
407
|
-
if (streamId && streamManager.hasStream(streamId)) {
|
|
431
|
+
if (streamId && streamManager.hasStream(streamId) && !streamManager.getStream(streamId)?.finished) {
|
|
408
432
|
// Check if mediaUrl is a local path (sandbox: prefix or absolute path)
|
|
409
433
|
const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
|
|
410
434
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared flag that tracks whether the legacy wildcard HTTP handler was
|
|
3
|
+
* successfully registered via api.registerHttpHandler().
|
|
4
|
+
*
|
|
5
|
+
* When true, gateway.startAccount must NOT also register via
|
|
6
|
+
* registerPluginHttpRoute — the latter places the path into
|
|
7
|
+
* registry.httpRoutes which causes shouldEnforceGatewayAuthForPluginPath
|
|
8
|
+
* → isRegisteredPluginHttpRoutePath to return true → gateway auth
|
|
9
|
+
* enforcement runs → WeCom webhook callbacks (which carry msg_signature,
|
|
10
|
+
* not Bearer tokens) get blocked with 401.
|
|
11
|
+
*
|
|
12
|
+
* This module is intentionally separate from index.js to avoid circular
|
|
13
|
+
* ESM imports (index.js ↔ wecom/channel-plugin.js).
|
|
14
|
+
*/
|
|
15
|
+
let _wildcardHttpHandlerRegistered = false;
|
|
16
|
+
|
|
17
|
+
export function markWildcardHttpHandlerRegistered() {
|
|
18
|
+
_wildcardHttpHandlerRegistered = true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isWildcardHttpHandlerRegistered() {
|
|
22
|
+
return _wildcardHttpHandlerRegistered;
|
|
23
|
+
}
|
package/wecom/http-handler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
2
|
import { logger } from "../logger.js";
|
|
3
3
|
import { streamManager } from "../stream-manager.js";
|
|
4
|
+
import { parseThinkingContent } from "../think-parser.js";
|
|
4
5
|
import { WecomWebhook } from "../webhook.js";
|
|
5
6
|
import { handleAgentInbound } from "./agent-inbound.js";
|
|
6
7
|
import { extractLeadingSlashCommand, isHighPriorityCommand } from "./commands.js";
|
|
@@ -151,9 +152,16 @@ async function handleWecomRequest(req, res, targets, query, path) {
|
|
|
151
152
|
streamManager.createStream(streamId);
|
|
152
153
|
streamManager.appendStream(streamId, THINKING_PLACEHOLDER);
|
|
153
154
|
|
|
154
|
-
// Passive reply: return stream id
|
|
155
|
-
//
|
|
156
|
-
const streamResponse = webhook.buildStreamResponse(
|
|
155
|
+
// Passive reply: return stream id with thinking_content so the WeCom
|
|
156
|
+
// client shows the collapsible "thinking" UI while the LLM processes.
|
|
157
|
+
const streamResponse = webhook.buildStreamResponse(
|
|
158
|
+
streamId,
|
|
159
|
+
"",
|
|
160
|
+
false,
|
|
161
|
+
timestamp,
|
|
162
|
+
nonce,
|
|
163
|
+
{ thinkingContent: THINKING_PLACEHOLDER },
|
|
164
|
+
);
|
|
157
165
|
|
|
158
166
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
159
167
|
res.end(streamResponse);
|
|
@@ -266,15 +274,31 @@ async function handleWecomRequest(req, res, targets, query, path) {
|
|
|
266
274
|
}
|
|
267
275
|
}
|
|
268
276
|
|
|
277
|
+
// Parse thinking tags from accumulated content so WeCom can display
|
|
278
|
+
// the model's reasoning in a collapsible section.
|
|
279
|
+
const { visibleContent, thinkingContent, isThinking } =
|
|
280
|
+
parseThinkingContent(stream.content);
|
|
281
|
+
|
|
282
|
+
// While the model is still thinking (unclosed <think> tag) and there is
|
|
283
|
+
// no visible content yet, keep showing the placeholder in thinking_content.
|
|
284
|
+
const effectiveThinking =
|
|
285
|
+
thinkingContent || (isThinking ? THINKING_PLACEHOLDER : "");
|
|
286
|
+
|
|
269
287
|
// Return current stream payload.
|
|
270
288
|
const streamResponse = webhook.buildStreamResponse(
|
|
271
289
|
streamId,
|
|
272
|
-
|
|
290
|
+
visibleContent,
|
|
273
291
|
stream.finished,
|
|
274
292
|
timestamp,
|
|
275
293
|
nonce,
|
|
276
|
-
|
|
277
|
-
|
|
294
|
+
{
|
|
295
|
+
// Pass msgItem only when stream is finished and has images.
|
|
296
|
+
...(stream.finished && stream.msgItem.length > 0
|
|
297
|
+
? { msgItem: stream.msgItem }
|
|
298
|
+
: {}),
|
|
299
|
+
// Include thinking content when available.
|
|
300
|
+
...(effectiveThinking ? { thinkingContent: effectiveThinking } : {}),
|
|
301
|
+
},
|
|
278
302
|
);
|
|
279
303
|
|
|
280
304
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -443,7 +443,7 @@ export async function processInboundMessage({
|
|
|
443
443
|
}
|
|
444
444
|
unregisterActiveStream(streamKey, streamId);
|
|
445
445
|
}
|
|
446
|
-
},
|
|
446
|
+
}, 200); // short grace for I/O flush; dispatcher is already done
|
|
447
447
|
};
|
|
448
448
|
|
|
449
449
|
// Dispatch reply with AI processing.
|
|
@@ -482,14 +482,6 @@ export async function processInboundMessage({
|
|
|
482
482
|
});
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
// Mark stream meta when main response is done.
|
|
486
|
-
if (streamId && (info.kind === "final" || info.kind === "block")) {
|
|
487
|
-
streamMeta.set(streamId, {
|
|
488
|
-
mainResponseDone: true,
|
|
489
|
-
doneAt: Date.now(),
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
|
|
493
485
|
// Schedule / reset stream close timer if dispatch already returned.
|
|
494
486
|
if (streamId && dispatchDone) {
|
|
495
487
|
scheduleStreamClose();
|
|
@@ -503,10 +495,17 @@ export async function processInboundMessage({
|
|
|
503
495
|
});
|
|
504
496
|
});
|
|
505
497
|
|
|
506
|
-
// Dispatch returned
|
|
498
|
+
// Dispatch returned — the entire LLM turn (all blocks, tool calls,
|
|
499
|
+
// and final payloads) is complete. Mark mainResponseDone now so the
|
|
500
|
+
// idle-close timer in http-handler only starts after there is truly
|
|
501
|
+
// no more content to deliver.
|
|
507
502
|
dispatchDone = true;
|
|
508
503
|
|
|
509
504
|
if (streamId) {
|
|
505
|
+
streamMeta.set(streamId, {
|
|
506
|
+
mainResponseDone: true,
|
|
507
|
+
doneAt: Date.now(),
|
|
508
|
+
});
|
|
510
509
|
const stream = streamManager.getStream(streamId);
|
|
511
510
|
if (!stream || stream.finished) {
|
|
512
511
|
unregisterActiveStream(streamKey, streamId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { logger } from "../logger.js";
|
|
4
4
|
import { streamManager } from "../stream-manager.js";
|
|
@@ -109,8 +109,8 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
// Handle absolute-path MEDIA lines manually; OpenClaw rejects these paths upstream.
|
|
112
|
-
// Match
|
|
113
|
-
const mediaRegex =
|
|
112
|
+
// Match only line-start MEDIA directives to align with upstream OpenClaw.
|
|
113
|
+
const mediaRegex = /^MEDIA:\s*(.+?)$/gm;
|
|
114
114
|
const mediaMatches = [];
|
|
115
115
|
let match;
|
|
116
116
|
while ((match = mediaRegex.exec(text)) !== null) {
|
|
@@ -312,114 +312,6 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
316
|
-
// Auto-detect /workspace/… file paths in LLM reply text.
|
|
317
|
-
// The sandbox container mounts /workspace → host ~/.openclaw/workspace-{agentId}.
|
|
318
|
-
// When the LLM mentions a file path like "/workspace/report.pdf", we resolve
|
|
319
|
-
// the host-side path, verify the file exists, and send it via Agent DM.
|
|
320
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
321
|
-
if (effectiveAgentId && processedText) {
|
|
322
|
-
// Match /workspace/ paths (non-greedy: stop at whitespace, quotes, backticks,
|
|
323
|
-
// angle brackets, parentheses, or end of string).
|
|
324
|
-
const workspacePathRegex = /\/workspace\/[^\s"'`<>()]+/g;
|
|
325
|
-
const detectedPaths = [];
|
|
326
|
-
let wpMatch;
|
|
327
|
-
while ((wpMatch = workspacePathRegex.exec(processedText)) !== null) {
|
|
328
|
-
const rawPath = wpMatch[0]
|
|
329
|
-
// Strip trailing punctuation that is likely not part of the filename.
|
|
330
|
-
.replace(/[.,;:!?。,;:!?)》」』\]]+$/, "");
|
|
331
|
-
if (rawPath.length > "/workspace/".length) {
|
|
332
|
-
detectedPaths.push(rawPath);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (detectedPaths.length > 0) {
|
|
337
|
-
const workspaceDir = resolveAgentWorkspaceDirLocal(effectiveAgentId);
|
|
338
|
-
const agentCfgAuto = resolveAgentConfig();
|
|
339
|
-
const imageExtsAuto = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
|
|
340
|
-
|
|
341
|
-
for (const wsPath of detectedPaths) {
|
|
342
|
-
// /workspace/foo.pdf → hostDir/foo.pdf (with traversal guard)
|
|
343
|
-
const hostPath = resolveWorkspaceHostPathSafe({
|
|
344
|
-
workspaceDir,
|
|
345
|
-
workspacePath: wsPath,
|
|
346
|
-
});
|
|
347
|
-
if (!hostPath) {
|
|
348
|
-
processedText = processedText.replace(wsPath, "⚠️ 检测到不安全的 /workspace/ 路径,已拒绝发送");
|
|
349
|
-
logger.warn("Auto-detect: rejected unsafe /workspace/ path", {
|
|
350
|
-
streamId,
|
|
351
|
-
wsPath,
|
|
352
|
-
workspaceDir,
|
|
353
|
-
});
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
const filename = basename(hostPath);
|
|
357
|
-
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
358
|
-
|
|
359
|
-
// Skip image files — they are handled by the stream msg_item mechanism.
|
|
360
|
-
if (imageExtsAuto.has(ext)) continue;
|
|
361
|
-
|
|
362
|
-
// Check the path exists on host and is a regular file.
|
|
363
|
-
try {
|
|
364
|
-
const st = await stat(hostPath);
|
|
365
|
-
if (!st.isFile()) {
|
|
366
|
-
logger.debug("Auto-detect: path is not a regular file, skipping", {
|
|
367
|
-
wsPath,
|
|
368
|
-
hostPath,
|
|
369
|
-
});
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
} catch {
|
|
373
|
-
logger.debug("Auto-detect: workspace file not found on host, skipping", {
|
|
374
|
-
wsPath,
|
|
375
|
-
hostPath,
|
|
376
|
-
});
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// File exists on host — send via Agent DM.
|
|
381
|
-
if (agentCfgAuto && senderId) {
|
|
382
|
-
try {
|
|
383
|
-
const hint = await uploadAndSendFile({
|
|
384
|
-
hostPath,
|
|
385
|
-
filename,
|
|
386
|
-
agent: agentCfgAuto,
|
|
387
|
-
senderId,
|
|
388
|
-
streamId,
|
|
389
|
-
});
|
|
390
|
-
// Replace the path mention in text with a delivery hint.
|
|
391
|
-
// Also strip any preceding "MEDIA:" prefix if the LLM wrote "MEDIA:/workspace/…".
|
|
392
|
-
const escapedPath = wsPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
393
|
-
const withMediaPrefix = new RegExp(`MEDIA:\\s*${escapedPath}`, "g");
|
|
394
|
-
if (withMediaPrefix.test(processedText)) {
|
|
395
|
-
processedText = processedText.replace(withMediaPrefix, hint);
|
|
396
|
-
} else {
|
|
397
|
-
processedText = processedText.replace(wsPath, hint);
|
|
398
|
-
}
|
|
399
|
-
logger.info("Auto-detect: sent workspace file via Agent DM", {
|
|
400
|
-
streamId,
|
|
401
|
-
wsPath,
|
|
402
|
-
hostPath,
|
|
403
|
-
filename,
|
|
404
|
-
senderId,
|
|
405
|
-
});
|
|
406
|
-
} catch (autoErr) {
|
|
407
|
-
processedText = processedText.replace(
|
|
408
|
-
wsPath,
|
|
409
|
-
`⚠️ 文件「${filename}」发送失败:${autoErr.message}`,
|
|
410
|
-
);
|
|
411
|
-
logger.error("Auto-detect: failed to send workspace file via Agent DM", {
|
|
412
|
-
streamId,
|
|
413
|
-
wsPath,
|
|
414
|
-
hostPath,
|
|
415
|
-
error: autoErr.message,
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
315
|
// All outbound content is sent via stream updates.
|
|
424
316
|
if (!processedText.trim()) {
|
|
425
317
|
logger.debug("WeCom: empty block after processing, skipping stream update");
|
|
@@ -482,7 +374,7 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
|
|
|
482
374
|
const response = await wecomFetch(saved.url, {
|
|
483
375
|
method: "POST",
|
|
484
376
|
headers: { "Content-Type": "application/json" },
|
|
485
|
-
body: JSON.stringify({ msgtype: "
|
|
377
|
+
body: JSON.stringify({ msgtype: "markdown", markdown: { content: processedText } }),
|
|
486
378
|
});
|
|
487
379
|
const responseBody = await response.text().catch(() => "");
|
|
488
380
|
const result = parseResponseUrlResult(response, responseBody);
|
package/wecom/stream-utils.js
CHANGED
|
@@ -26,6 +26,12 @@ export function registerActiveStream(streamKey, streamId) {
|
|
|
26
26
|
activeStreamHistory.set(streamKey, deduped);
|
|
27
27
|
activeStreams.set(streamKey, streamId);
|
|
28
28
|
lastStreamByKey.set(streamKey, streamId);
|
|
29
|
+
logger.info("registerActiveStream", {
|
|
30
|
+
streamKey,
|
|
31
|
+
streamId,
|
|
32
|
+
historySize: deduped.length,
|
|
33
|
+
history: deduped,
|
|
34
|
+
});
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export function unregisterActiveStream(streamKey, streamId) {
|
|
@@ -38,6 +44,7 @@ export function unregisterActiveStream(streamKey, streamId) {
|
|
|
38
44
|
if (activeStreams.get(streamKey) === streamId) {
|
|
39
45
|
activeStreams.delete(streamKey);
|
|
40
46
|
}
|
|
47
|
+
logger.info("unregisterActiveStream (empty history)", { streamKey, streamId });
|
|
41
48
|
return;
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -45,11 +52,18 @@ export function unregisterActiveStream(streamKey, streamId) {
|
|
|
45
52
|
if (remaining.length === 0) {
|
|
46
53
|
activeStreamHistory.delete(streamKey);
|
|
47
54
|
activeStreams.delete(streamKey);
|
|
55
|
+
logger.info("unregisterActiveStream (last stream)", { streamKey, streamId });
|
|
48
56
|
return;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
activeStreamHistory.set(streamKey, remaining);
|
|
52
60
|
activeStreams.set(streamKey, remaining[remaining.length - 1]);
|
|
61
|
+
logger.info("unregisterActiveStream", {
|
|
62
|
+
streamKey,
|
|
63
|
+
streamId,
|
|
64
|
+
remainingSize: remaining.length,
|
|
65
|
+
remaining,
|
|
66
|
+
});
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
export function resolveActiveStream(streamKey) {
|