@sunnoy/wecom 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -5
- package/dynamic-agent.js +5 -1
- package/package.json +2 -1
- package/think-parser.js +119 -0
- package/webhook.js +6 -1
- package/wecom/accounts.js +5 -4
- package/wecom/agent-inbound.js +24 -2
- package/wecom/channel-plugin.js +49 -20
- package/wecom/http-handler.js +30 -6
- package/wecom/inbound-processor.js +10 -3
- package/wecom/outbound-delivery.js +48 -98
- package/wecom/stream-utils.js +14 -0
- package/wecom/workspace-template.js +16 -18
package/README.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
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
|
+
- [动态 Agent 路由](#动态-agent-路由)
|
|
27
|
+
- [支持的目标格式](#支持的目标格式)
|
|
28
|
+
- [指令白名单](#指令白名单)
|
|
29
|
+
- [消息防抖合并](#消息防抖合并)
|
|
30
|
+
|
|
31
|
+
### 运维与参考
|
|
32
|
+
- [常见问题 (FAQ)](#常见问题-faq)
|
|
33
|
+
- [项目结构](#项目结构)
|
|
34
|
+
- [贡献规范](#贡献规范)
|
|
35
|
+
- [开源协议](#开源协议)
|
|
36
|
+
- [配置示例参考](#配置示例参考)
|
|
37
|
+
- [自定义 Skills 配合沙箱使用实践](#自定义-skills-配合沙箱使用实践)
|
|
38
|
+
|
|
5
39
|
## 核心特性
|
|
6
40
|
|
|
7
41
|
### 消息模式支持
|
|
@@ -9,6 +43,12 @@
|
|
|
9
43
|
- **自建应用模式 (Agent Mode)**: 支持企业微信自建应用,可处理 XML 格式的回调消息,支持收发消息、上传下载媒体文件。
|
|
10
44
|
- **Webhook Bot 模式**: 支持通过 Webhook 发送消息到群聊,适用于群通知场景。
|
|
11
45
|
|
|
46
|
+
### 流式回复增强
|
|
47
|
+
- **Markdown 格式支持**: 流式回复的 `content` 字段支持常见 Markdown 格式,包括加粗、斜体、代码块、列表、标题、链接等,企业微信客户端会自动渲染。
|
|
48
|
+
- **思考过程展示**: 当 LLM 回复包含 `<think>...</think>` 标签时,插件自动解析并通过 `thinking_content` 字段在客户端展示可折叠的思考过程。
|
|
49
|
+
- **被动回复思考模式**: 首次被动回复即启用思考模式 UI(通过 `thinking_content` 字段),用户无需等待即可看到模型正在思考的状态。
|
|
50
|
+
- **图片混合回复**: 支持在最终回复(`finish=true`)时包含 `msgtype` 为 `image` 的 `msg_item`,流式过程中图片会排队等待最终发送。
|
|
51
|
+
|
|
12
52
|
### 智能消息投递
|
|
13
53
|
- **四层投递回退机制**: 确保消息可靠送达
|
|
14
54
|
1. **流式通道**: 优先通过活跃流式通道发送
|
|
@@ -21,7 +61,7 @@
|
|
|
21
61
|
### 动态 Agent 与隔离
|
|
22
62
|
- **动态 Agent 管理**: 默认按"每个私聊用户 / 每个群聊"自动创建独立 Agent。每个 Agent 拥有独立的工作区与对话上下文,实现更强的数据隔离。
|
|
23
63
|
- **群聊深度集成**: 支持群聊消息解析,可通过 @提及(At-mention)精准触发机器人响应。
|
|
24
|
-
- **管理员用户**:
|
|
64
|
+
- **管理员用户**: 可配置管理员列表,默认绕过指令白名单;可选开启“绕过动态 Agent 路由”。
|
|
25
65
|
- **指令白名单**: 内置常用指令支持(如 `/new`、`/status`),并提供指令白名单配置功能。
|
|
26
66
|
|
|
27
67
|
### 多媒体支持
|
|
@@ -152,7 +192,7 @@ npm run test:e2e
|
|
|
152
192
|
| `plugins.entries.wecom.enabled` | boolean | 是 | 启用插件 |
|
|
153
193
|
| `channels.wecom.token` | string | 是* | 企业微信机器人 Token (*Bot 模式必填) |
|
|
154
194
|
| `channels.wecom.encodingAesKey` | string | 是* | 消息加密密钥(43 位)(*Bot 模式必填) |
|
|
155
|
-
| `channels.wecom.adminUsers` | array | 否 | 管理员用户 ID
|
|
195
|
+
| `channels.wecom.adminUsers` | array | 否 | 管理员用户 ID 列表(绕过指令白名单) |
|
|
156
196
|
| `channels.wecom.commands.enabled` | boolean | 否 | 是否启用指令白名单过滤(默认 true) |
|
|
157
197
|
| `channels.wecom.commands.allowlist` | array | 否 | 允许的指令白名单 |
|
|
158
198
|
|
|
@@ -163,6 +203,7 @@ npm run test:e2e
|
|
|
163
203
|
| 配置项 | 类型 | 必填 | 说明 |
|
|
164
204
|
|--------|------|------|------|
|
|
165
205
|
| `channels.wecom.dynamicAgents.enabled` | boolean | 否 | 是否启用动态 Agent(默认 true) |
|
|
206
|
+
| `channels.wecom.dynamicAgents.adminBypass` | boolean | 否 | 管理员是否跳过动态 Agent 路由(默认 false) |
|
|
166
207
|
| `channels.wecom.dm.createAgentOnFirstMessage` | boolean | 否 | 私聊时为每个用户创建独立 Agent(默认 true) |
|
|
167
208
|
| `channels.wecom.groupChat.enabled` | boolean | 否 | 是否启用群聊处理(默认 true) |
|
|
168
209
|
| `channels.wecom.groupChat.requireMention` | boolean | 否 | 群聊是否必须 @ 提及才响应(默认 true) |
|
|
@@ -293,15 +334,58 @@ Webhook Bot 用于向群聊发送通知消息。
|
|
|
293
334
|
| 位置 (location) | 收 | 位置分享(转换为文本描述) |
|
|
294
335
|
| 链接 (link) | 收 | 分享链接(提取标题、描述、URL 为文本) |
|
|
295
336
|
|
|
337
|
+
## 流式回复能力
|
|
338
|
+
|
|
339
|
+
### Markdown 格式
|
|
340
|
+
|
|
341
|
+
流式回复的 `content` 字段支持以下 Markdown 格式,企业微信客户端会自动渲染:
|
|
342
|
+
|
|
343
|
+
| 格式 | 语法 | 示例 |
|
|
344
|
+
|------|------|------|
|
|
345
|
+
| 加粗 | `**text**` | **加粗文本** |
|
|
346
|
+
| 斜体 | `*text*` | *斜体文本* |
|
|
347
|
+
| 行内代码 | `` `code` `` | `code` |
|
|
348
|
+
| 代码块 | ` ```lang ... ``` ` | 多行代码 |
|
|
349
|
+
| 列表 | `- item` / `1. item` | 有序/无序列表 |
|
|
350
|
+
| 标题 | `# H1` / `## H2` | 各级标题 |
|
|
351
|
+
| 链接 | `[text](url)` | 超链接 |
|
|
352
|
+
|
|
353
|
+
### 思考过程展示(Thinking Mode)
|
|
354
|
+
|
|
355
|
+
当 LLM 模型(如 DeepSeek、QwQ 等支持思考模式的模型)在回复中输出 `<think>...</think>` 标签时,插件会自动:
|
|
356
|
+
|
|
357
|
+
1. **解析** `<think>` 标签,将思考内容与可见内容分离
|
|
358
|
+
2. **映射** 思考内容到企业微信流式回复的 `thinking_content` 字段
|
|
359
|
+
3. **展示** 企业微信客户端会以可折叠的方式显示模型的思考过程
|
|
360
|
+
|
|
361
|
+
**流式处理说明:**
|
|
362
|
+
- 被动回复(首次同步响应)立即启用思考模式 UI,显示「思考中...」
|
|
363
|
+
- 当检测到未闭合的 `<think>` 标签(流式输出中),`thinking_content` 持续更新
|
|
364
|
+
- `</think>` 闭合后,思考内容固定,后续内容显示为可见回复
|
|
365
|
+
- 代码块内的 `<think>` 标签不会被解析(避免误匹配)
|
|
366
|
+
|
|
367
|
+
**支持的标签变体:** `<think>`, `<thinking>`, `<thought>`(均不区分大小写)
|
|
368
|
+
|
|
369
|
+
### 图片回复
|
|
370
|
+
|
|
371
|
+
图片通过 `msg_item` 以 base64 编码在流式回复结束时发送:
|
|
372
|
+
|
|
373
|
+
- 仅在 `finish=true`(最终回复)时包含 `msgtype` 为 `image` 的 `msg_item`
|
|
374
|
+
- 流式过程中生成的图片会排队,待回复完成后一次性发送
|
|
375
|
+
- 单张图片最大 2MB,支持 JPG/PNG 格式,每条消息最多 10 张
|
|
376
|
+
|
|
296
377
|
## 管理员用户
|
|
297
378
|
|
|
298
|
-
|
|
379
|
+
管理员用户默认可以绕过指令白名单限制。若希望管理员用户同时跳过动态 Agent 路由(直接路由到主 Agent),可开启 `dynamicAgents.adminBypass`。
|
|
299
380
|
|
|
300
381
|
```json
|
|
301
382
|
{
|
|
302
383
|
"channels": {
|
|
303
384
|
"wecom": {
|
|
304
|
-
"adminUsers": ["user1", "user2"]
|
|
385
|
+
"adminUsers": ["user1", "user2"],
|
|
386
|
+
"dynamicAgents": {
|
|
387
|
+
"adminBypass": true
|
|
388
|
+
}
|
|
305
389
|
}
|
|
306
390
|
}
|
|
307
391
|
}
|
|
@@ -322,7 +406,7 @@ Webhook Bot 用于向群聊发送通知消息。
|
|
|
322
406
|
- **多账号群聊**: `wecom-<accountId>-group-<chatId>`
|
|
323
407
|
2. OpenClaw 自动创建/复用对应的 Agent 工作区
|
|
324
408
|
3. 每个用户/群聊拥有独立的对话历史和上下文
|
|
325
|
-
4.
|
|
409
|
+
4. 管理员用户默认参与动态路由;当 `dynamicAgents.adminBypass=true` 时跳过动态路由,直接使用主 Agent
|
|
326
410
|
|
|
327
411
|
### 高级配置
|
|
328
412
|
|
|
@@ -350,6 +434,7 @@ Webhook Bot 用于向群聊发送通知消息。
|
|
|
350
434
|
| 配置项 | 类型 | 默认值 | 说明 |
|
|
351
435
|
|--------|------|--------|------|
|
|
352
436
|
| `dynamicAgents.enabled` | boolean | `true` | 是否启用动态 Agent |
|
|
437
|
+
| `dynamicAgents.adminBypass` | boolean | `false` | 管理员是否跳过动态 Agent 路由 |
|
|
353
438
|
| `dm.createAgentOnFirstMessage` | boolean | `true` | 私聊使用动态 Agent |
|
|
354
439
|
| `groupChat.enabled` | boolean | `true` | 启用群聊处理 |
|
|
355
440
|
| `groupChat.requireMention` | boolean | `true` | 群聊必须 @ 提及才响应 |
|
|
@@ -384,6 +469,7 @@ Webhook Bot 用于向群聊发送通知消息。
|
|
|
384
469
|
"token": "Bot1 的 Token",
|
|
385
470
|
"encodingAesKey": "Bot1 的 EncodingAESKey",
|
|
386
471
|
"adminUsers": ["admin1"],
|
|
472
|
+
"workspaceTemplate": "/path/to/bot1-template",
|
|
387
473
|
"agent": {
|
|
388
474
|
"corpId": "企业 CorpID",
|
|
389
475
|
"corpSecret": "Bot1 应用 Secret",
|
|
@@ -417,6 +503,7 @@ Webhook Bot 用于向群聊发送通知消息。
|
|
|
417
503
|
| 完全兼容 | 旧的单账号配置(`token` 直接写在 `wecom` 下)自动识别为 `default` 账号,无需修改 |
|
|
418
504
|
| Webhook 路径 | 自动按账号分配:`/webhooks/wecom/bot1`、`/webhooks/wecom/bot2` |
|
|
419
505
|
| Agent 回调路径 | 自动按账号分配:`/webhooks/app/bot1`、`/webhooks/app/bot2` |
|
|
506
|
+
| 工作区模板 | 支持按账号自定义:`channels.wecom.<accountId>.workspaceTemplate`(覆盖全局配置) |
|
|
420
507
|
| 动态 Agent ID | 按账号隔离:`wecom-bot1-dm-{userId}`、`wecom-bot2-group-{chatId}` |
|
|
421
508
|
| 冲突检测 | 启动时自动检测重复的 Token 或 Agent ID,避免消息路由错乱 |
|
|
422
509
|
|
|
@@ -708,6 +795,7 @@ openclaw-plugin-wecom/
|
|
|
708
795
|
├── logger.js # 日志模块
|
|
709
796
|
├── utils.js # 工具函数(TTL 缓存、消息去重)
|
|
710
797
|
├── stream-manager.js # 流式回复管理
|
|
798
|
+
├── think-parser.js # 思考标签解析(<think> 标签分离)
|
|
711
799
|
├── image-processor.js # 图片编码/校验(msg_item)
|
|
712
800
|
├── webhook.js # 企业微信 Bot 模式 HTTP 通信处理
|
|
713
801
|
├── dynamic-agent.js # 动态 Agent 分配逻辑
|
package/dynamic-agent.js
CHANGED
|
@@ -47,17 +47,21 @@ export function getDynamicAgentConfig(config) {
|
|
|
47
47
|
groupEnabled: wecom.groupChat?.enabled !== false,
|
|
48
48
|
groupRequireMention: wecom.groupChat?.requireMention !== false,
|
|
49
49
|
groupMentionPatterns: wecom.groupChat?.mentionPatterns || ["@"],
|
|
50
|
+
adminBypass: wecom.dynamicAgents?.adminBypass === true,
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* Decide whether this message context should route to a dynamic agent.
|
|
55
56
|
*/
|
|
56
|
-
export function shouldUseDynamicAgent({ chatType, config }) {
|
|
57
|
+
export function shouldUseDynamicAgent({ chatType, config, senderIsAdmin = false }) {
|
|
57
58
|
const dynamicConfig = getDynamicAgentConfig(config);
|
|
58
59
|
if (!dynamicConfig.enabled) {
|
|
59
60
|
return false;
|
|
60
61
|
}
|
|
62
|
+
if (senderIsAdmin && dynamicConfig.adminBypass) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
61
65
|
if (chatType === "group") {
|
|
62
66
|
return dynamicConfig.groupEnabled;
|
|
63
67
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sunnoy/wecom",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
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/accounts.js
CHANGED
|
@@ -79,16 +79,17 @@ function buildAccount(accountId, accountCfg) {
|
|
|
79
79
|
agent?.corpId && agent?.corpSecret && agent?.agentId && agent?.token && agent?.encodingAesKey,
|
|
80
80
|
);
|
|
81
81
|
|
|
82
|
+
const hasBotTokens = Boolean(accountCfg?.token && accountCfg?.encodingAesKey);
|
|
83
|
+
const defaultPath = accountId === DEFAULT_ACCOUNT_ID ? "/webhooks/wecom" : `/webhooks/wecom/${accountId}`;
|
|
84
|
+
|
|
82
85
|
return {
|
|
83
86
|
accountId,
|
|
84
87
|
name: accountCfg?.name || accountId,
|
|
85
88
|
enabled: accountCfg?.enabled !== false,
|
|
86
|
-
configured:
|
|
89
|
+
configured: hasBotTokens || agentConfigured,
|
|
87
90
|
token: accountCfg?.token || "",
|
|
88
91
|
encodingAesKey: accountCfg?.encodingAesKey || "",
|
|
89
|
-
webhookPath:
|
|
90
|
-
accountCfg?.webhookPath ||
|
|
91
|
-
(accountId === DEFAULT_ACCOUNT_ID ? "/webhooks/wecom" : `/webhooks/wecom/${accountId}`),
|
|
92
|
+
webhookPath: accountCfg?.webhookPath || (hasBotTokens ? defaultPath : ""),
|
|
92
93
|
config: accountCfg || {},
|
|
93
94
|
agentConfigured,
|
|
94
95
|
agentInboundConfigured,
|
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)) {
|
|
@@ -292,12 +308,18 @@ async function processAgentMessage({
|
|
|
292
308
|
|
|
293
309
|
const dynamicConfig = getDynamicAgentConfig(accountCfg);
|
|
294
310
|
const targetAgentId =
|
|
295
|
-
dynamicConfig.enabled
|
|
311
|
+
dynamicConfig.enabled
|
|
312
|
+
&& shouldUseDynamicAgent({
|
|
313
|
+
chatType: peerKind,
|
|
314
|
+
config: accountCfg,
|
|
315
|
+
senderIsAdmin,
|
|
316
|
+
})
|
|
296
317
|
? generateAgentId(peerKind, peerId, accountId)
|
|
297
318
|
: null;
|
|
298
319
|
|
|
320
|
+
const templateDir = accountCfg?.workspaceTemplate || config?.channels?.wecom?.workspaceTemplate;
|
|
299
321
|
if (targetAgentId) {
|
|
300
|
-
await ensureDynamicAgentListed(targetAgentId);
|
|
322
|
+
await ensureDynamicAgentListed(targetAgentId, templateDir);
|
|
301
323
|
logger.debug("[agent-inbound] dynamic agent", { agentId: targetAgentId, peerId });
|
|
302
324
|
}
|
|
303
325
|
|
package/wecom/channel-plugin.js
CHANGED
|
@@ -92,6 +92,11 @@ export const wecomChannelPlugin = {
|
|
|
92
92
|
description: "Enable per-user/per-group agent isolation",
|
|
93
93
|
default: true,
|
|
94
94
|
},
|
|
95
|
+
adminBypass: {
|
|
96
|
+
type: "boolean",
|
|
97
|
+
description: "When true, adminUsers bypass dynamic agent routing and use the default route",
|
|
98
|
+
default: false,
|
|
99
|
+
},
|
|
95
100
|
},
|
|
96
101
|
},
|
|
97
102
|
dm: {
|
|
@@ -277,12 +282,20 @@ export const wecomChannelPlugin = {
|
|
|
277
282
|
// `to` format: "wecom:userid" or "userid".
|
|
278
283
|
const userId = to.replace(/^wecom:/, "");
|
|
279
284
|
|
|
280
|
-
// 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.
|
|
281
288
|
const ctx = streamContext.getStore();
|
|
282
|
-
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;
|
|
283
296
|
|
|
284
297
|
// Layer 1: Active stream (normal path)
|
|
285
|
-
if (streamId &&
|
|
298
|
+
if (streamId && hasStream && !finished) {
|
|
286
299
|
logger.debug("Appending outbound text to stream", {
|
|
287
300
|
userId,
|
|
288
301
|
streamId,
|
|
@@ -298,6 +311,19 @@ export const wecomChannelPlugin = {
|
|
|
298
311
|
};
|
|
299
312
|
}
|
|
300
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
|
+
|
|
301
327
|
// Layer 2: Fallback via response_url
|
|
302
328
|
// response_url is valid for 1 hour and can be used only once.
|
|
303
329
|
// responseUrls is keyed by streamKey (fromUser for DM, chatId for group).
|
|
@@ -307,7 +333,7 @@ export const wecomChannelPlugin = {
|
|
|
307
333
|
const response = await wecomFetch(saved.url, {
|
|
308
334
|
method: "POST",
|
|
309
335
|
headers: { "Content-Type": "application/json" },
|
|
310
|
-
body: JSON.stringify({ msgtype: "
|
|
336
|
+
body: JSON.stringify({ msgtype: "markdown", markdown: { content: text } }),
|
|
311
337
|
});
|
|
312
338
|
const responseBody = await response.text().catch(() => "");
|
|
313
339
|
const result = parseResponseUrlResult(response, responseBody);
|
|
@@ -395,11 +421,14 @@ export const wecomChannelPlugin = {
|
|
|
395
421
|
sendMedia: async ({ cfg: _cfg, to, text, mediaUrl, accountId: _accountId }) => {
|
|
396
422
|
const userId = to.replace(/^wecom:/, "");
|
|
397
423
|
|
|
398
|
-
// Prefer stream
|
|
424
|
+
// Prefer async-context stream only when it is still writable.
|
|
399
425
|
const ctx = streamContext.getStore();
|
|
400
|
-
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);
|
|
401
430
|
|
|
402
|
-
if (streamId && streamManager.hasStream(streamId)) {
|
|
431
|
+
if (streamId && streamManager.hasStream(streamId) && !streamManager.getStream(streamId)?.finished) {
|
|
403
432
|
// Check if mediaUrl is a local path (sandbox: prefix or absolute path)
|
|
404
433
|
const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
|
|
405
434
|
|
|
@@ -720,18 +749,18 @@ export const wecomChannelPlugin = {
|
|
|
720
749
|
});
|
|
721
750
|
}
|
|
722
751
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
752
|
+
let unregister;
|
|
753
|
+
const botPath = account.webhookPath;
|
|
754
|
+
if (botPath) {
|
|
755
|
+
unregister = registerWebhookTarget({
|
|
756
|
+
path: botPath,
|
|
757
|
+
account,
|
|
758
|
+
config: ctx.cfg,
|
|
759
|
+
});
|
|
760
|
+
logger.info("WeCom Bot webhook path active", { path: botPath });
|
|
761
|
+
} else {
|
|
762
|
+
logger.debug("No Bot webhook path for this account, skipping", { accountId: account.accountId });
|
|
763
|
+
}
|
|
735
764
|
|
|
736
765
|
// Register Agent inbound webhook if agent inbound is fully configured.
|
|
737
766
|
let unregisterAgent;
|
|
@@ -773,7 +802,7 @@ export const wecomChannelPlugin = {
|
|
|
773
802
|
clearTimeout(buf.timer);
|
|
774
803
|
}
|
|
775
804
|
messageBuffers.clear();
|
|
776
|
-
unregister();
|
|
805
|
+
if (unregister) unregister();
|
|
777
806
|
if (unregisterAgent) unregisterAgent();
|
|
778
807
|
};
|
|
779
808
|
|
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" });
|
|
@@ -241,12 +241,19 @@ export async function processInboundMessage({
|
|
|
241
241
|
|
|
242
242
|
// Compute deterministic agent target for this conversation.
|
|
243
243
|
const targetAgentId =
|
|
244
|
-
dynamicConfig.enabled
|
|
244
|
+
dynamicConfig.enabled
|
|
245
|
+
&& shouldUseDynamicAgent({
|
|
246
|
+
chatType: peerKind,
|
|
247
|
+
config: account.config,
|
|
248
|
+
senderIsAdmin,
|
|
249
|
+
})
|
|
245
250
|
? generateAgentId(peerKind, peerId, account.accountId)
|
|
246
251
|
: null;
|
|
247
252
|
|
|
253
|
+
// Resolve template directory: per-account or global.
|
|
254
|
+
const templateDir = account.config?.workspaceTemplate || config?.channels?.wecom?.workspaceTemplate;
|
|
248
255
|
if (targetAgentId) {
|
|
249
|
-
await ensureDynamicAgentListed(targetAgentId);
|
|
256
|
+
await ensureDynamicAgentListed(targetAgentId, templateDir);
|
|
250
257
|
logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
|
|
251
258
|
} else if (senderIsAdmin) {
|
|
252
259
|
logger.debug("Admin user, dynamic agent disabled for this chat type; falling back to default route", {
|
|
@@ -436,7 +443,7 @@ export async function processInboundMessage({
|
|
|
436
443
|
}
|
|
437
444
|
unregisterActiveStream(streamKey, streamId);
|
|
438
445
|
}
|
|
439
|
-
},
|
|
446
|
+
}, 200); // short grace for I/O flush; dispatcher is already done
|
|
440
447
|
};
|
|
441
448
|
|
|
442
449
|
// Dispatch reply with AI processing.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFile
|
|
2
|
-
import { basename,
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
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";
|
|
5
5
|
import { agentSendText, agentUploadMedia, agentSendMedia } from "./agent-api.js";
|
|
@@ -19,10 +19,36 @@ const WECOM_MIN_FILE_SIZE = 5;
|
|
|
19
19
|
* ~/.openclaw/workspace-{agentId} on the host. Any path starting with
|
|
20
20
|
* /workspace/ is transparently rewritten when an agentId is available.
|
|
21
21
|
*/
|
|
22
|
+
export function resolveWorkspaceHostPathSafe({ workspaceDir, workspacePath }) {
|
|
23
|
+
const relativePath = String(workspacePath || "").replace(/^\/workspace\/?/, "");
|
|
24
|
+
if (!relativePath) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const hostPath = resolve(workspaceDir, relativePath);
|
|
29
|
+
const rel = relative(workspaceDir, hostPath);
|
|
30
|
+
const escapesWorkspace = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
31
|
+
if (escapesWorkspace) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return hostPath;
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
function resolveHostPath(filePath, effectiveAgentId) {
|
|
23
38
|
if (effectiveAgentId && filePath.startsWith("/workspace/")) {
|
|
24
|
-
const
|
|
25
|
-
const hostPath =
|
|
39
|
+
const workspaceDir = resolveAgentWorkspaceDirLocal(effectiveAgentId);
|
|
40
|
+
const hostPath = resolveWorkspaceHostPathSafe({
|
|
41
|
+
workspaceDir,
|
|
42
|
+
workspacePath: filePath,
|
|
43
|
+
});
|
|
44
|
+
if (!hostPath) {
|
|
45
|
+
logger.warn("Rejected unsafe /workspace/ path outside workspace", {
|
|
46
|
+
sandbox: filePath,
|
|
47
|
+
workspaceDir,
|
|
48
|
+
agentId: effectiveAgentId,
|
|
49
|
+
});
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
26
52
|
logger.debug("Resolved sandbox path to host path", { sandbox: filePath, host: hostPath });
|
|
27
53
|
return hostPath;
|
|
28
54
|
}
|
|
@@ -83,8 +109,8 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
|
|
|
83
109
|
});
|
|
84
110
|
|
|
85
111
|
// Handle absolute-path MEDIA lines manually; OpenClaw rejects these paths upstream.
|
|
86
|
-
// Match
|
|
87
|
-
const mediaRegex =
|
|
112
|
+
// Match only line-start MEDIA directives to align with upstream OpenClaw.
|
|
113
|
+
const mediaRegex = /^MEDIA:\s*(.+?)$/gm;
|
|
88
114
|
const mediaMatches = [];
|
|
89
115
|
let match;
|
|
90
116
|
while ((match = mediaRegex.exec(text)) !== null) {
|
|
@@ -110,6 +136,12 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
|
|
|
110
136
|
for (const media of mediaMatches) {
|
|
111
137
|
// Resolve /workspace/ sandbox paths to host-side paths.
|
|
112
138
|
const resolvedMediaPath = resolveHostPath(media.path, effectiveAgentId);
|
|
139
|
+
if (!resolvedMediaPath) {
|
|
140
|
+
processedText = processedText
|
|
141
|
+
.replace(media.fullMatch, "⚠️ 检测到不安全的 /workspace/ 路径,已拒绝发送")
|
|
142
|
+
.trim();
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
113
145
|
const mediaExt = resolvedMediaPath.split(".").pop()?.toLowerCase() || "";
|
|
114
146
|
if (mediaImageExts.has(mediaExt)) {
|
|
115
147
|
// Image: queue for delivery when stream finishes.
|
|
@@ -177,6 +209,15 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
|
|
|
177
209
|
}
|
|
178
210
|
// Resolve /workspace/ sandbox paths to host-side paths.
|
|
179
211
|
absPath = resolveHostPath(absPath, effectiveAgentId);
|
|
212
|
+
if (!absPath) {
|
|
213
|
+
const unsafeHint = "⚠️ 检测到不安全的 /workspace/ 路径,已拒绝发送";
|
|
214
|
+
if (streamId && streamManager.hasStream(streamId)) {
|
|
215
|
+
streamManager.appendStream(streamId, `\n\n${unsafeHint}`);
|
|
216
|
+
} else {
|
|
217
|
+
processedText = processedText ? `${processedText}\n\n${unsafeHint}` : unsafeHint;
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
180
221
|
|
|
181
222
|
const isLocal = absPath.startsWith("/");
|
|
182
223
|
const mediaFilename = isLocal ? basename(absPath) : (basename(new URL(mediaPath).pathname) || "file");
|
|
@@ -271,97 +312,6 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
|
|
|
271
312
|
}
|
|
272
313
|
}
|
|
273
314
|
|
|
274
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
275
|
-
// Auto-detect /workspace/… file paths in LLM reply text.
|
|
276
|
-
// The sandbox container mounts /workspace → host ~/.openclaw/workspace-{agentId}.
|
|
277
|
-
// When the LLM mentions a file path like "/workspace/report.pdf", we resolve
|
|
278
|
-
// the host-side path, verify the file exists, and send it via Agent DM.
|
|
279
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
280
|
-
if (effectiveAgentId && processedText) {
|
|
281
|
-
// Match /workspace/ paths (non-greedy: stop at whitespace, quotes, backticks,
|
|
282
|
-
// angle brackets, parentheses, or end of string).
|
|
283
|
-
const workspacePathRegex = /\/workspace\/[^\s"'`<>()]+/g;
|
|
284
|
-
const detectedPaths = [];
|
|
285
|
-
let wpMatch;
|
|
286
|
-
while ((wpMatch = workspacePathRegex.exec(processedText)) !== null) {
|
|
287
|
-
const rawPath = wpMatch[0]
|
|
288
|
-
// Strip trailing punctuation that is likely not part of the filename.
|
|
289
|
-
.replace(/[.,;:!?。,;:!?)》」』\]]+$/, "");
|
|
290
|
-
if (rawPath.length > "/workspace/".length) {
|
|
291
|
-
detectedPaths.push(rawPath);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (detectedPaths.length > 0) {
|
|
296
|
-
const workspaceDir = resolveAgentWorkspaceDirLocal(effectiveAgentId);
|
|
297
|
-
const agentCfgAuto = resolveAgentConfig();
|
|
298
|
-
const imageExtsAuto = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
|
|
299
|
-
|
|
300
|
-
for (const wsPath of detectedPaths) {
|
|
301
|
-
// /workspace/foo.pdf → hostDir/foo.pdf
|
|
302
|
-
const relativePath = wsPath.replace(/^\/workspace\/?/, "");
|
|
303
|
-
if (!relativePath) continue;
|
|
304
|
-
const hostPath = join(workspaceDir, relativePath);
|
|
305
|
-
const filename = basename(hostPath);
|
|
306
|
-
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
307
|
-
|
|
308
|
-
// Skip image files — they are handled by the stream msg_item mechanism.
|
|
309
|
-
if (imageExtsAuto.has(ext)) continue;
|
|
310
|
-
|
|
311
|
-
// Check file existence on host.
|
|
312
|
-
try {
|
|
313
|
-
await access(hostPath);
|
|
314
|
-
} catch {
|
|
315
|
-
logger.debug("Auto-detect: workspace file not found on host, skipping", {
|
|
316
|
-
wsPath,
|
|
317
|
-
hostPath,
|
|
318
|
-
});
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// File exists on host — send via Agent DM.
|
|
323
|
-
if (agentCfgAuto && senderId) {
|
|
324
|
-
try {
|
|
325
|
-
const hint = await uploadAndSendFile({
|
|
326
|
-
hostPath,
|
|
327
|
-
filename,
|
|
328
|
-
agent: agentCfgAuto,
|
|
329
|
-
senderId,
|
|
330
|
-
streamId,
|
|
331
|
-
});
|
|
332
|
-
// Replace the path mention in text with a delivery hint.
|
|
333
|
-
// Also strip any preceding "MEDIA:" prefix if the LLM wrote "MEDIA:/workspace/…".
|
|
334
|
-
const escapedPath = wsPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
335
|
-
const withMediaPrefix = new RegExp(`MEDIA:\\s*${escapedPath}`, "g");
|
|
336
|
-
if (withMediaPrefix.test(processedText)) {
|
|
337
|
-
processedText = processedText.replace(withMediaPrefix, hint);
|
|
338
|
-
} else {
|
|
339
|
-
processedText = processedText.replace(wsPath, hint);
|
|
340
|
-
}
|
|
341
|
-
logger.info("Auto-detect: sent workspace file via Agent DM", {
|
|
342
|
-
streamId,
|
|
343
|
-
wsPath,
|
|
344
|
-
hostPath,
|
|
345
|
-
filename,
|
|
346
|
-
senderId,
|
|
347
|
-
});
|
|
348
|
-
} catch (autoErr) {
|
|
349
|
-
processedText = processedText.replace(
|
|
350
|
-
wsPath,
|
|
351
|
-
`⚠️ 文件「${filename}」发送失败:${autoErr.message}`,
|
|
352
|
-
);
|
|
353
|
-
logger.error("Auto-detect: failed to send workspace file via Agent DM", {
|
|
354
|
-
streamId,
|
|
355
|
-
wsPath,
|
|
356
|
-
hostPath,
|
|
357
|
-
error: autoErr.message,
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
315
|
// All outbound content is sent via stream updates.
|
|
366
316
|
if (!processedText.trim()) {
|
|
367
317
|
logger.debug("WeCom: empty block after processing, skipping stream update");
|
|
@@ -424,7 +374,7 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
|
|
|
424
374
|
const response = await wecomFetch(saved.url, {
|
|
425
375
|
method: "POST",
|
|
426
376
|
headers: { "Content-Type": "application/json" },
|
|
427
|
-
body: JSON.stringify({ msgtype: "
|
|
377
|
+
body: JSON.stringify({ msgtype: "markdown", markdown: { content: processedText } }),
|
|
428
378
|
});
|
|
429
379
|
const responseBody = await response.text().catch(() => "");
|
|
430
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) {
|
|
@@ -36,9 +36,13 @@ export function getWorkspaceTemplateDir(config) {
|
|
|
36
36
|
* Copy template files into a newly created agent's workspace directory.
|
|
37
37
|
* Only copies files that don't already exist (writeFileIfMissing semantics).
|
|
38
38
|
* Silently skips if workspaceTemplate is not configured or directory is missing.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} agentId
|
|
41
|
+
* @param {object} config - OpenClaw config
|
|
42
|
+
* @param {string} [overrideTemplateDir] - Optional per-account template directory
|
|
39
43
|
*/
|
|
40
|
-
export function seedAgentWorkspace(agentId, config) {
|
|
41
|
-
const templateDir = getWorkspaceTemplateDir(config);
|
|
44
|
+
export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
|
|
45
|
+
const templateDir = overrideTemplateDir || getWorkspaceTemplateDir(config);
|
|
42
46
|
if (!templateDir) {
|
|
43
47
|
return;
|
|
44
48
|
}
|
|
@@ -114,7 +118,7 @@ export function upsertAgentIdOnlyEntry(cfg, agentId) {
|
|
|
114
118
|
return changed;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
export async function ensureDynamicAgentListed(agentId) {
|
|
121
|
+
export async function ensureDynamicAgentListed(agentId, templateDir) {
|
|
118
122
|
const normalizedId = String(agentId || "")
|
|
119
123
|
.trim()
|
|
120
124
|
.toLowerCase();
|
|
@@ -128,28 +132,22 @@ export async function ensureDynamicAgentListed(agentId) {
|
|
|
128
132
|
return;
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
const queue = getEnsureDynamicAgentWriteQueue()
|
|
135
|
+
const queue = (getEnsureDynamicAgentWriteQueue() || Promise.resolve())
|
|
132
136
|
.then(async () => {
|
|
133
|
-
const
|
|
134
|
-
if (!
|
|
137
|
+
const openclawConfig = getOpenclawConfig();
|
|
138
|
+
if (!openclawConfig || typeof openclawConfig !== "object") {
|
|
135
139
|
return;
|
|
136
140
|
}
|
|
137
141
|
|
|
138
|
-
|
|
142
|
+
// Upsert into memory only. Writing to config file is dangerous and can wipe user settings.
|
|
143
|
+
const changed = upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
|
|
139
144
|
if (changed) {
|
|
140
|
-
|
|
141
|
-
logger.info("WeCom: dynamic agent added to agents.list", { agentId: normalizedId });
|
|
145
|
+
logger.info("WeCom: dynamic agent added to in-memory agents.list", { agentId: normalizedId });
|
|
142
146
|
}
|
|
147
|
+
|
|
143
148
|
// Always attempt seeding so recreated/cleaned dynamic agents can recover
|
|
144
|
-
// template files
|
|
145
|
-
seedAgentWorkspace(normalizedId,
|
|
146
|
-
|
|
147
|
-
// Keep runtime in-memory config aligned to avoid stale reads in this process.
|
|
148
|
-
const openclawConfig = getOpenclawConfig();
|
|
149
|
-
if (openclawConfig && typeof openclawConfig === "object") {
|
|
150
|
-
upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
|
|
151
|
-
setOpenclawConfig(openclawConfig);
|
|
152
|
-
}
|
|
149
|
+
// template files.
|
|
150
|
+
seedAgentWorkspace(normalizedId, openclawConfig, templateDir);
|
|
153
151
|
|
|
154
152
|
getEnsuredDynamicAgentIds().add(normalizedId);
|
|
155
153
|
})
|