@sunnoy/wecom 2.3.0 → 2.4.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 +35 -0
- package/package.json +1 -1
- package/skills/wecom-msg/SKILL.md +115 -0
- package/skills/wecom-msg/references/api-get-messages.md +74 -0
- package/skills/wecom-msg/references/api-get-msg-chat-list.md +64 -0
- package/skills/wecom-msg/references/api-get-msg-media.md +44 -0
- package/skills/wecom-msg/references/api-send-message.md +49 -0
- package/skills/wecom-send-media/SKILL.md +68 -0
- package/wecom/callback-inbound.js +1 -1
- package/wecom/callback-media.js +17 -9
- package/wecom/channel-plugin.js +14 -7
- package/wecom/mcp-tool.js +18 -2
- package/wecom/workspace-template.js +17 -10
- package/wecom/ws-monitor.js +3 -3
- package/wecom/mcp-config.js +0 -146
package/README.md
CHANGED
|
@@ -150,6 +150,7 @@ npm test
|
|
|
150
150
|
"mentionPatterns": ["@"]
|
|
151
151
|
},
|
|
152
152
|
"workspaceTemplate": "/path/to/template-dir",
|
|
153
|
+
"mediaLocalRoots": ["/tmp/openclaw"],
|
|
153
154
|
"agent": {
|
|
154
155
|
"corpId": "wwxxxxxxxxxxxxxxxx",
|
|
155
156
|
"corpSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
@@ -208,6 +209,7 @@ npm test
|
|
|
208
209
|
| `channels.wecom.groupChat.requireMention` | boolean | 否 | 群聊是否要求 @ 才响应,默认 `true` |
|
|
209
210
|
| `channels.wecom.groupChat.mentionPatterns` | string[] | 否 | 群聊触发前缀,默认 `["@"]` |
|
|
210
211
|
| `channels.wecom.workspaceTemplate` | string | 否 | 动态 Agent 工作区模板目录 |
|
|
212
|
+
| `channels.wecom.mediaLocalRoots` | string[] | 否 | 额外允许被动回复读取的宿主机目录列表。用于放行 `MEDIA:/abs/path` 或 `FILE:/abs/path` 指向的本地文件;默认只允许当前 Agent workspace 和浏览器产物目录。多账号模式下也可配置在 `channels.wecom.<accountId>.mediaLocalRoots`。修改后需重启 Gateway 生效 |
|
|
211
213
|
|
|
212
214
|
### 增强出站配置
|
|
213
215
|
|
|
@@ -247,6 +249,7 @@ Agent 增强出站不需要 `token`、`encodingAesKey`、回调 URL;只有需
|
|
|
247
249
|
"botId": "aib-support-xxx",
|
|
248
250
|
"secret": "secret-support-xxx",
|
|
249
251
|
"dmPolicy": "pairing",
|
|
252
|
+
"mediaLocalRoots": ["/tmp/openclaw"],
|
|
250
253
|
"agent": {
|
|
251
254
|
"corpId": "wwxxxxxxxxxxxxxxxx",
|
|
252
255
|
"corpSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
@@ -377,6 +380,15 @@ Webhook 只负责群通知。
|
|
|
377
380
|
- 最终回复 `finish=true` 时可附带图片 `msg_item`
|
|
378
381
|
- 若最终回复包含 WS 不支持的媒体(文件等),会先文本提示再通过 Agent API 补发
|
|
379
382
|
|
|
383
|
+
本插件会自动解析模型输出中的 `MEDIA:` / `FILE:` 指令,并在回复完成后上传对应文件:
|
|
384
|
+
|
|
385
|
+
- 图片使用 `MEDIA:/abs/path`
|
|
386
|
+
- PDF、音频、视频、压缩包、Office 文档等非图片文件使用 `FILE:/abs/path`
|
|
387
|
+
- 沙箱内当前工作区文件可直接写成 `MEDIA:/workspace/...` 或 `FILE:/workspace/...`
|
|
388
|
+
- 浏览器生成的文件默认允许从 OpenClaw 状态目录下的浏览器媒体目录读取
|
|
389
|
+
- 宿主机其他目录默认不放行;如果需要回复 `/tmp/openclaw/report.pdf` 这类文件,请把其父目录加入 `channels.wecom.mediaLocalRoots`,多账号模式可配在 `channels.wecom.<accountId>.mediaLocalRoots`
|
|
390
|
+
- 更新 `mediaLocalRoots` 后需重启 Gateway 生效
|
|
391
|
+
|
|
380
392
|
### 主动发送与后备策略
|
|
381
393
|
|
|
382
394
|
主动发送分层:
|
|
@@ -557,6 +569,29 @@ openclaw pairing approve wecom <code>
|
|
|
557
569
|
- WS 断连后的 pending 回复通过 Agent API 补发
|
|
558
570
|
- WS 文本主动发送失败时,尝试回退到 Agent
|
|
559
571
|
|
|
572
|
+
### Q: 回复本地文件时提示“没有权限访问路径”怎么办?
|
|
573
|
+
|
|
574
|
+
先确认文件路径是否在允许目录里:
|
|
575
|
+
|
|
576
|
+
- 当前 Agent 工作区文件默认允许,可直接用 `FILE:/workspace/...` 或 `MEDIA:/workspace/...`
|
|
577
|
+
- 浏览器生成的文件默认允许
|
|
578
|
+
- 宿主机其他目录需要把父目录加入 `channels.wecom.mediaLocalRoots`
|
|
579
|
+
- 多账号模式如果只想对某个账号生效,可配置 `channels.wecom.<accountId>.mediaLocalRoots`
|
|
580
|
+
|
|
581
|
+
例如:
|
|
582
|
+
|
|
583
|
+
```json
|
|
584
|
+
{
|
|
585
|
+
"channels": {
|
|
586
|
+
"wecom": {
|
|
587
|
+
"mediaLocalRoots": ["/tmp/openclaw"]
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
修改后重启 Gateway。`v2.2.1+` 已支持把 `mediaLocalRoots` 并入被动回复文件允许目录。
|
|
594
|
+
|
|
560
595
|
### Q: `60020 not allow to access from your ip` 是什么问题?
|
|
561
596
|
|
|
562
597
|
企业微信自建应用 API 的可信 IP 限制。把当前服务器出口 IP 加入企业微信应用的可信 IP 白名单即可。
|
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wecom-msg
|
|
3
|
+
description: 企业微信消息技能。提供会话列表查询、消息记录拉取(支持文本/图片/文件/语音/视频)、多媒体文件获取和文本消息发送能力。当用户需要“查看消息”、”看聊天记录”、”发消息给某人”、”最近有什么消息”、”给群里发消息”、”看看发了什么图片/文件”时触发。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 企业微信消息技能
|
|
7
|
+
|
|
8
|
+
> `wecom_mcp` 是一个 MCP tool,所有操作通过调用该 tool 完成。
|
|
9
|
+
|
|
10
|
+
> ⚠️ **前置条件**:首次调用 `wecom_mcp` 前,必须按 `wecom-preflight` 技能执行前置条件检查,确保工具已加入白名单。
|
|
11
|
+
|
|
12
|
+
> ⚠️ **路径与停止规则**:
|
|
13
|
+
> - 如果要读取本 skill,必须直接使用 `<available_skills>` 或 `skillsSnapshot` 中给出的精确绝对路径。
|
|
14
|
+
> - 不要猜测或改写为 `/data/openclaw/skills/wecom-*`、`/workspace/.openclaw/skills/...`、`/root/.openclaw/workspace-*/.openclaw/skills/...`,也不要用 `exec` + `ls/find` 探路。
|
|
15
|
+
> - 若 `wecom_mcp` 返回 `errcode: 846609` 或 `unsupported mcp biz type`,表示当前 bot 未开通 `msg` category,不是路径、白名单或 sandbox 问题;立即停止继续 `read`、`list`、`find`、memory fallback 探索,直接告知用户当前机器人未开通消息能力。
|
|
16
|
+
|
|
17
|
+
通过 `wecom_mcp call msg <接口名> '<json入参>'` 与企业微信消息系统交互。
|
|
18
|
+
|
|
19
|
+
## 接口列表
|
|
20
|
+
|
|
21
|
+
### 1. 获取会话列表
|
|
22
|
+
|
|
23
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call msg get_msg_chat_list '{"begin_time": "2026-03-11 00:00:00", "end_time": "2026-03-17 23:59:59"}'`
|
|
24
|
+
|
|
25
|
+
按时间范围查询有消息的会话列表,支持分页。详情见 `references/api-get-msg-chat-list.md`。
|
|
26
|
+
|
|
27
|
+
### 2. 拉取聊天记录
|
|
28
|
+
|
|
29
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call msg get_messages '{"chat_type": 1, "chatid": "zhangsan", "begin_time": "2026-03-17 09:00:00", "end_time": "2026-03-17 18:00:00"}'`
|
|
30
|
+
|
|
31
|
+
根据会话类型和会话 ID 拉取指定时间范围内的消息记录,支持分页。详情见 `references/api-get-messages.md`。
|
|
32
|
+
|
|
33
|
+
### 3. 发送文本消息
|
|
34
|
+
|
|
35
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call msg send_message '{"chat_type": 1, "chatid": "zhangsan", "msgtype": "text", "text": {"content": "hello world"}}'`
|
|
36
|
+
|
|
37
|
+
向单聊或群聊发送文本消息。详情见 `references/api-send-message.md`。
|
|
38
|
+
|
|
39
|
+
## 核心规则
|
|
40
|
+
|
|
41
|
+
### 时间范围
|
|
42
|
+
|
|
43
|
+
- 所有时间参数使用 `YYYY-MM-DD HH:mm:ss` 格式。
|
|
44
|
+
- 用户未指定时间时,默认使用最近 7 天。
|
|
45
|
+
- `get_messages` 只支持当前时刻往前 7 天内的数据。
|
|
46
|
+
- 如果用户给出的开始时间早于 7 天窗口,要主动调整到有效范围并明确告知。
|
|
47
|
+
|
|
48
|
+
### 会话定位
|
|
49
|
+
|
|
50
|
+
- 当用户直接提供 `chatid` 时,直接使用。
|
|
51
|
+
- 当用户提供人名或群名而不是 ID 时,先调用 `get_msg_chat_list` 获取候选会话,再按 `chat_name` 在本地筛选。
|
|
52
|
+
- 精确匹配唯一结果时可直接使用。
|
|
53
|
+
- 模糊匹配多个结果时,必须先向用户展示候选项,不要擅自决定。
|
|
54
|
+
- 没有匹配结果时,要明确告知未找到对应会话。
|
|
55
|
+
- `get_msg_chat_list` 返回里没有 `chat_type`,若用户明确说“群”“群聊”“项目群”等,优先用 `chat_type=2`;否则默认 `chat_type=1`。
|
|
56
|
+
|
|
57
|
+
### 消息展示
|
|
58
|
+
|
|
59
|
+
- `get_messages` 当前只按文档约定处理文本消息;遇到非文本字段时,不要编造内容,按原始结构说明。
|
|
60
|
+
- 展示消息时优先把 `userid` 转成可读姓名。
|
|
61
|
+
- 如需做 `userid -> 姓名/别名` 映射,调用 `wecom-contact-lookup` 的 `get_userlist`,并在本地建立映射。
|
|
62
|
+
- 若无法映射,保留 `userid` 原样展示。
|
|
63
|
+
|
|
64
|
+
### 发送规则
|
|
65
|
+
|
|
66
|
+
- `send_message` 仅用于发送文本消息。
|
|
67
|
+
- 用户要求发送图片、PDF、视频、语音或其它文件时,不要误用 `wecom_mcp msg send_message`;应改用本插件原生的 `MEDIA:` / `FILE:` 路径投递能力。
|
|
68
|
+
- 发送消息前必须先做一次面向用户的确认,至少确认发送对象和发送内容。
|
|
69
|
+
- 用户确认前,不要执行 `send_message`。
|
|
70
|
+
|
|
71
|
+
## 典型工作流
|
|
72
|
+
|
|
73
|
+
### 查看最近有哪些会话
|
|
74
|
+
|
|
75
|
+
1. 确定时间范围。
|
|
76
|
+
2. 调用 `get_msg_chat_list`。
|
|
77
|
+
3. 按“会话名称 + 最后消息时间 + 消息数量”整理结果。
|
|
78
|
+
4. 若 `has_more=true`,提示用户还可以继续翻页。
|
|
79
|
+
|
|
80
|
+
### 查看某个聊天对象的记录
|
|
81
|
+
|
|
82
|
+
1. 确定时间范围。
|
|
83
|
+
2. 如需,先通过 `get_msg_chat_list` 定位 `chatid`。
|
|
84
|
+
3. 调用 `get_messages`。
|
|
85
|
+
4. 如需,调用 `wecom-contact-lookup` 获取通讯录,把 `userid` 映射为姓名/别名。
|
|
86
|
+
5. 按时间顺序展示消息。
|
|
87
|
+
6. 若 `next_cursor` 非空,提示还有更多消息可继续查看。
|
|
88
|
+
|
|
89
|
+
### 给某人或某个群发文本
|
|
90
|
+
|
|
91
|
+
1. 如需,先定位 `chatid` 与 `chat_type`。
|
|
92
|
+
2. 向用户确认发送对象和文本内容。
|
|
93
|
+
3. 用户确认后,调用 `send_message`。
|
|
94
|
+
4. 返回发送结果。
|
|
95
|
+
|
|
96
|
+
### 看完消息后代发回复
|
|
97
|
+
|
|
98
|
+
1. 先执行“查看聊天记录”流程。
|
|
99
|
+
2. 从上下文中提取待回复对象和回复内容。
|
|
100
|
+
3. 再执行“给某人或某个群发文本”流程。
|
|
101
|
+
|
|
102
|
+
## 错误处理
|
|
103
|
+
|
|
104
|
+
- 若返回 `tool not allowed`、`unknown tool: wecom_mcp`、`permission denied`,说明问题在宿主机工具放行,不要继续试探,按 `wecom-preflight` 规则处理。
|
|
105
|
+
- 若返回 `unsupported mcp biz type` 或 `errcode: 846609`,说明当前 bot 未开通 `msg` category,不要继续尝试其它 category。
|
|
106
|
+
- 若返回 API 业务错误,直接展示 `errcode` 和 `errmsg`,必要时最多重试 1 次。
|
|
107
|
+
- 若查询结果为空,要明确告知“当前时间范围内没有找到消息/会话”,不要编造。
|
|
108
|
+
|
|
109
|
+
## 快速参考
|
|
110
|
+
|
|
111
|
+
| 接口 | 用途 | 关键输入 | 关键输出 |
|
|
112
|
+
|------|------|----------|----------|
|
|
113
|
+
| `get_msg_chat_list` | 查询时间范围内有消息的会话列表 | `begin_time`, `end_time`, `cursor?` | `chats`, `has_more`, `next_cursor` |
|
|
114
|
+
| `get_messages` | 拉取指定会话消息 | `chat_type`, `chatid`, `begin_time`, `end_time`, `cursor?` | `messages`, `next_cursor` |
|
|
115
|
+
| `send_message` | 发送文本消息 | `chat_type`, `chatid`, `msgtype=text`, `text.content` | `errcode`, `errmsg` |
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# `get_messages` API
|
|
2
|
+
|
|
3
|
+
根据会话类型和会话 ID 拉取指定时间范围内的消息记录。
|
|
4
|
+
|
|
5
|
+
## 参数说明
|
|
6
|
+
|
|
7
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
8
|
+
|------|------|------|------|
|
|
9
|
+
| `chat_type` | integer | 是 | 会话类型,`1` 表示单聊,`2` 表示群聊 |
|
|
10
|
+
| `chatid` | string | 是 | 会话 ID。单聊时通常为 userid,群聊时为群 ID |
|
|
11
|
+
| `begin_time` | string | 是 | 拉取开始时间,格式:`YYYY-MM-DD HH:mm:ss`,仅支持最近 7 天内 |
|
|
12
|
+
| `end_time` | string | 是 | 拉取结束时间,格式:`YYYY-MM-DD HH:mm:ss`,必须大于等于 `begin_time` |
|
|
13
|
+
| `cursor` | string | 否 | 分页游标。首次请求不传,后续传上次响应的 `next_cursor` |
|
|
14
|
+
|
|
15
|
+
## 请求示例
|
|
16
|
+
|
|
17
|
+
单聊:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
wecom_mcp call msg get_messages '{"chat_type":1,"chatid":"zhangsan","begin_time":"2026-03-17 09:00:00","end_time":"2026-03-17 18:00:00"}'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
群聊:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
wecom_mcp call msg get_messages '{"chat_type":2,"chatid":"wrxxxxxxxx","begin_time":"2026-03-17 09:00:00","end_time":"2026-03-17 18:00:00"}'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
分页:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
wecom_mcp call msg get_messages '{"chat_type":1,"chatid":"zhangsan","begin_time":"2026-03-17 09:00:00","end_time":"2026-03-17 18:00:00","cursor":"CURSOR_xxxxxx"}'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 返回字段
|
|
36
|
+
|
|
37
|
+
| 字段 | 类型 | 说明 |
|
|
38
|
+
|------|------|------|
|
|
39
|
+
| `errcode` | integer | 返回码,`0` 表示成功 |
|
|
40
|
+
| `errmsg` | string | 错误信息 |
|
|
41
|
+
| `messages` | array | 消息列表 |
|
|
42
|
+
| `messages[].userid` | string | 发送者 userid |
|
|
43
|
+
| `messages[].send_time` | string | 发送时间,格式:`YYYY-MM-DD HH:mm:ss` |
|
|
44
|
+
| `messages[].msgtype` | string | 消息类型 |
|
|
45
|
+
| `messages[].text.content` | string | 文本消息内容;仅在 `msgtype=text` 时存在 |
|
|
46
|
+
| `next_cursor` | string | 下一页游标;为空表示已拉取完毕 |
|
|
47
|
+
|
|
48
|
+
## 响应示例
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"errcode": 0,
|
|
53
|
+
"errmsg": "ok",
|
|
54
|
+
"messages": [
|
|
55
|
+
{
|
|
56
|
+
"userid": "zhangsan",
|
|
57
|
+
"send_time": "2026-03-17 09:30:00",
|
|
58
|
+
"msgtype": "text",
|
|
59
|
+
"text": {
|
|
60
|
+
"content": "你好"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"userid": "lisi",
|
|
65
|
+
"send_time": "2026-03-17 09:35:00",
|
|
66
|
+
"msgtype": "text",
|
|
67
|
+
"text": {
|
|
68
|
+
"content": "你好,有什么可以帮助你的?"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"next_cursor": "CURSOR_xxxxxx"
|
|
73
|
+
}
|
|
74
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# `get_msg_chat_list` API
|
|
2
|
+
|
|
3
|
+
获取指定时间范围内有消息的会话列表,支持分页查询。
|
|
4
|
+
|
|
5
|
+
## 参数说明
|
|
6
|
+
|
|
7
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
8
|
+
|------|------|------|------|
|
|
9
|
+
| `begin_time` | string | 是 | 拉取开始时间,格式:`YYYY-MM-DD HH:mm:ss` |
|
|
10
|
+
| `end_time` | string | 是 | 拉取结束时间,格式:`YYYY-MM-DD HH:mm:ss` |
|
|
11
|
+
| `cursor` | string | 否 | 分页游标。首次请求不传,后续传上次响应的 `next_cursor` |
|
|
12
|
+
|
|
13
|
+
## 请求示例
|
|
14
|
+
|
|
15
|
+
使用 `wecom_mcp` tool 调用:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
wecom_mcp call msg get_msg_chat_list '{"begin_time":"2026-03-11 00:00:00","end_time":"2026-03-17 23:59:59"}'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
分页请求:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
wecom_mcp call msg get_msg_chat_list '{"begin_time":"2026-03-11 00:00:00","end_time":"2026-03-17 23:59:59","cursor":"NEXT_CURSOR"}'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 返回字段
|
|
28
|
+
|
|
29
|
+
| 字段 | 类型 | 说明 |
|
|
30
|
+
|------|------|------|
|
|
31
|
+
| `errcode` | integer | 返回码,`0` 表示成功 |
|
|
32
|
+
| `errmsg` | string | 错误信息 |
|
|
33
|
+
| `chats` | array | 会话列表 |
|
|
34
|
+
| `chats[].chat_id` | string | 会话 ID |
|
|
35
|
+
| `chats[].chat_name` | string | 会话名称 |
|
|
36
|
+
| `chats[].last_msg_time` | string | 最后一条消息时间,格式:`YYYY-MM-DD HH:mm:ss` |
|
|
37
|
+
| `chats[].msg_count` | integer | 消息数量 |
|
|
38
|
+
| `has_more` | boolean | 是否还有更多数据 |
|
|
39
|
+
| `next_cursor` | string | 下一页游标 |
|
|
40
|
+
|
|
41
|
+
## 响应示例
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"errcode": 0,
|
|
46
|
+
"errmsg": "ok",
|
|
47
|
+
"chats": [
|
|
48
|
+
{
|
|
49
|
+
"chat_id": "zhangsan",
|
|
50
|
+
"chat_name": "张三",
|
|
51
|
+
"last_msg_time": "2026-03-17 15:30:45",
|
|
52
|
+
"msg_count": 128
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"chat_id": "wr123456",
|
|
56
|
+
"chat_name": "项目讨论群",
|
|
57
|
+
"last_msg_time": "2026-03-16 09:12:33",
|
|
58
|
+
"msg_count": 56
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"has_more": true,
|
|
62
|
+
"next_cursor": "NEXT_CURSOR"
|
|
63
|
+
}
|
|
64
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# get_msg_media API
|
|
2
|
+
|
|
3
|
+
获取消息文件内容。根据文件 ID 自动下载文件到本地,返回本地文件路径、文件名称、类型、大小及内容类型。
|
|
4
|
+
|
|
5
|
+
## 参数说明
|
|
6
|
+
|
|
7
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
8
|
+
|------|------|------|------|
|
|
9
|
+
| `media_id` | string | ✅ | 文件 ID,长度 1~256 |
|
|
10
|
+
|
|
11
|
+
## 请求示例
|
|
12
|
+
|
|
13
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call msg get_msg_media '{"media_id": "MEDIAID_xxxxxx"}'`
|
|
14
|
+
|
|
15
|
+
## 返回字段
|
|
16
|
+
|
|
17
|
+
| 字段 | 类型 | 说明 |
|
|
18
|
+
|------|------|------|
|
|
19
|
+
| `errcode` | Integer | 返回码,`0` 表示成功 |
|
|
20
|
+
| `errmsg` | String | 错误信息 |
|
|
21
|
+
| `media_item` | Object | 文件内容 |
|
|
22
|
+
| `media_item.media_id` | String | 文件 ID |
|
|
23
|
+
| `media_item.name` | String | 文件名称 |
|
|
24
|
+
| `media_item.type` | String | 文件类型,`image`-图片,`voice`-语音,`video`-视频,`file`-普通文件 |
|
|
25
|
+
| `media_item.local_path` | String | 文件下载后的本地路径 |
|
|
26
|
+
| `media_item.size` | Integer | 文件大小(字节) |
|
|
27
|
+
| `media_item.content_type` | String | 文件 MIME 类型,如 `image/png`、`application/pdf` 等 |
|
|
28
|
+
|
|
29
|
+
## 响应示例
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"errcode": 0,
|
|
34
|
+
"errmsg": "ok",
|
|
35
|
+
"media_item": {
|
|
36
|
+
"media_id": "MEDIAID_xxxxxx",
|
|
37
|
+
"name": "screenshot.png",
|
|
38
|
+
"type": "image",
|
|
39
|
+
"local_path": "xxx/yyy/screenshot.png",
|
|
40
|
+
"size": 102400,
|
|
41
|
+
"content_type": "image/png"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# `send_message` API
|
|
2
|
+
|
|
3
|
+
向单聊或群聊发送文本消息。
|
|
4
|
+
|
|
5
|
+
## 参数说明
|
|
6
|
+
|
|
7
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
8
|
+
|------|------|------|------|
|
|
9
|
+
| `chat_type` | integer | 是 | 会话类型,`1` 表示单聊,`2` 表示群聊 |
|
|
10
|
+
| `chatid` | string | 是 | 会话 ID。单聊时通常为 userid,群聊时为群 ID |
|
|
11
|
+
| `msgtype` | string | 是 | 消息类型。当前只应使用 `text` |
|
|
12
|
+
| `text` | object | 是 | 文本消息体 |
|
|
13
|
+
| `text.content` | string | 是 | 文本内容,受企业微信消息长度限制 |
|
|
14
|
+
|
|
15
|
+
## 请求示例
|
|
16
|
+
|
|
17
|
+
单聊:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
wecom_mcp call msg send_message '{"chat_type":1,"chatid":"zhangsan","msgtype":"text","text":{"content":"hello world"}}'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
群聊:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
wecom_mcp call msg send_message '{"chat_type":2,"chatid":"wrxxxxxxxx","msgtype":"text","text":{"content":"大家好"}}'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 返回字段
|
|
30
|
+
|
|
31
|
+
| 字段 | 类型 | 说明 |
|
|
32
|
+
|------|------|------|
|
|
33
|
+
| `errcode` | integer | 返回码,`0` 表示成功 |
|
|
34
|
+
| `errmsg` | string | 错误信息 |
|
|
35
|
+
|
|
36
|
+
## 响应示例
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"errcode": 0,
|
|
41
|
+
"errmsg": "ok"
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 使用约束
|
|
46
|
+
|
|
47
|
+
- 在当前插件里,这个接口只应用于文本消息。
|
|
48
|
+
- 发图片、PDF、视频、语音或其它文件时,不要误用这个接口;应改用插件原生的 `MEDIA:` / `FILE:` 路径投递能力。
|
|
49
|
+
- 执行发送前必须先征得用户确认,避免误发。
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wecom-send-media
|
|
3
|
+
description: 通过 MEDIA 指令向用户发送本地文件(图片、视频、语音等文件)。当用户要求发送或分享文件时,或当生成的文件需要交付给用户时使用。仅当通过 wecom 通道通信时使用此技能。
|
|
4
|
+
metadata: {"openclaw":{"emoji":"📤","requires":{"config":["channels.wecom"]}}}
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 发送文件(图片、视频、语音、文件)
|
|
8
|
+
|
|
9
|
+
⚠️ 重要:你有能力发送本地文件!❌ 绝对不要回复"无法发送图片、视频、语音或文件"或类似的措辞!
|
|
10
|
+
|
|
11
|
+
通过 `MEDIA:` 指令将本地文件发送给用户。支持的文件类型为:图片、视频、语音、其它文件等。系统会自动识别文件类型,并以对应的消息格式(图片消息、视频消息、语音消息或文件消息)发送。
|
|
12
|
+
|
|
13
|
+
## 触发条件
|
|
14
|
+
|
|
15
|
+
- 用户要求发送、展示或分享图片、视频、语音或文件
|
|
16
|
+
- 生成了需要交付给用户的图片、视频、语音或文件
|
|
17
|
+
|
|
18
|
+
## 指令语法
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
MEDIA: <文件的绝对路径>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 语法规则
|
|
25
|
+
|
|
26
|
+
1. 如果文件路径前已经有 `MEDIA:` 指令,无需再重复添加,保持原样即可
|
|
27
|
+
2. 如果文件路径前没有 `MEDIA:` 指令,需将 `MEDIA:` 指令置于行首,且与文件路径之间用一个空格分隔
|
|
28
|
+
3. 路径必须为本地文件的**绝对路径**,不支持 URL
|
|
29
|
+
4. 路径含空格时,用反引号包裹:`` MEDIA: `/path/to/my file.png` ``
|
|
30
|
+
5. 每个文件独占一行,禁止在同一行发送多个文件
|
|
31
|
+
6. `MEDIA:` 指令的前后可以附加文字说明,文字与指令各占独立行
|
|
32
|
+
|
|
33
|
+
## 文件存放
|
|
34
|
+
|
|
35
|
+
将生成的文件优先存放至 `~/.openclaw/workspace/` 目录,确保路径可访问。
|
|
36
|
+
|
|
37
|
+
## 文件大小与格式限制
|
|
38
|
+
|
|
39
|
+
| 类型 | 大小上限 | 格式要求 | 备注 |
|
|
40
|
+
|------|----------|----------|------|
|
|
41
|
+
| 图片 | 10 MB | 常见图片格式均可 | openclaw 默认会对图片进行压缩处理 |
|
|
42
|
+
| 视频 | 10 MB | 常见视频格式均可 | |
|
|
43
|
+
| 语音 | 2 MB | **仅支持 AMR 格式**(`.amr`) | |
|
|
44
|
+
| 文件 | 20 MB | 不限 | |
|
|
45
|
+
|
|
46
|
+
⚠️ 重要:请务必**先检查文件大小和格式**是否满足要求。如果不满足要求,请主动告知用户以下“降级与限制处理”措施:
|
|
47
|
+
|
|
48
|
+
- **图片压缩情况**:openclaw 默认会对图片进行压缩处理。如果本地图片(原图)大小超过上限,经过压缩后不一定会超过上限。因此当你检测到本地图片大小超过上限时,务必要告诉用户这个特殊情况。
|
|
49
|
+
- **自动降级**:视频或语音类型的文件,超过各自的大小限制时,系统会自动将其转为文件消息发送,请主动告诉用户。
|
|
50
|
+
- **硬性上限**:文件大小超过 20 MB 时无法发送。请主动告知用户,并尝试压缩或拆分文件后再发送。
|
|
51
|
+
- **语音格式**:语音消息仅支持 AMR 格式(`.amr`)。非 AMR 格式的音频文件将以文件消息的形式发送。
|
|
52
|
+
- **发送耗时**:文件发送涉及系统处理,可能耗时较长。发送含 `MEDIA:` 指令的消息 30 秒后,请主动检查文件是否已成功发送。如果没有请重试。
|
|
53
|
+
|
|
54
|
+
## 示例
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
以下是生成的图表:
|
|
58
|
+
MEDIA: ~/.openclaw/workspace/output.png
|
|
59
|
+
|
|
60
|
+
报告已生成,请查收:
|
|
61
|
+
MEDIA: ~/.openclaw/workspace/report.pdf
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 错误示例
|
|
65
|
+
|
|
66
|
+
- ❌ 错误:说"我无法发送本地图片"
|
|
67
|
+
- ❌ 错误:说"受限于技术限制,无法直接发送"
|
|
68
|
+
- ❌ 错误:说"由于某些问题,我无法直接发送文件"
|
|
@@ -300,7 +300,7 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
|
|
|
300
300
|
agent: account.agentCredentials,
|
|
301
301
|
mediaId,
|
|
302
302
|
type: mediaType === "image" ? "image" : mediaType === "voice" ? "voice" : "file",
|
|
303
|
-
runtime,
|
|
303
|
+
mediaRuntime: core.media ?? runtime?.media,
|
|
304
304
|
config,
|
|
305
305
|
});
|
|
306
306
|
mediaList.push(downloaded);
|
package/wecom/callback-media.js
CHANGED
|
@@ -12,6 +12,12 @@ import { getAccessToken } from "./agent-api.js";
|
|
|
12
12
|
import { wecomFetch } from "./http.js";
|
|
13
13
|
import { AGENT_API_ENDPOINTS, CALLBACK_MEDIA_DOWNLOAD_TIMEOUT_MS } from "./constants.js";
|
|
14
14
|
|
|
15
|
+
function resolveManagedCallbackMediaDir() {
|
|
16
|
+
const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
17
|
+
const stateDir = override || path.join(process.env.HOME || "/tmp", ".openclaw");
|
|
18
|
+
return path.join(stateDir, "media", "wecom");
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
/**
|
|
16
22
|
* Download a WeCom media file (image / voice / file) by MediaId via the
|
|
17
23
|
* self-built app access token and save it through the core media runtime.
|
|
@@ -20,11 +26,11 @@ import { AGENT_API_ENDPOINTS, CALLBACK_MEDIA_DOWNLOAD_TIMEOUT_MS } from "./const
|
|
|
20
26
|
* @param {object} params.agent - { corpId, corpSecret, agentId }
|
|
21
27
|
* @param {string} params.mediaId - WeCom MediaId
|
|
22
28
|
* @param {"image"|"voice"|"file"} params.type - media type hint
|
|
23
|
-
* @param {object} params.
|
|
29
|
+
* @param {object} [params.mediaRuntime] - OpenClaw media runtime (for saveMediaBuffer)
|
|
24
30
|
* @param {object} params.config - OpenClaw config (for mediaMaxMb)
|
|
25
31
|
* @returns {Promise<{ path: string, contentType: string }>}
|
|
26
32
|
*/
|
|
27
|
-
export async function downloadCallbackMedia({ agent, mediaId, type,
|
|
33
|
+
export async function downloadCallbackMedia({ agent, mediaId, type, mediaRuntime, config }) {
|
|
28
34
|
const token = await getAccessToken(agent);
|
|
29
35
|
const url = `${AGENT_API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
30
36
|
|
|
@@ -57,20 +63,22 @@ export async function downloadCallbackMedia({ agent, mediaId, type, runtime, con
|
|
|
57
63
|
(type === "image" ? `${mediaId}.jpg` : type === "voice" ? `${mediaId}.amr` : mediaId);
|
|
58
64
|
|
|
59
65
|
// Save via core media runtime when available
|
|
60
|
-
if (typeof
|
|
61
|
-
const saved = await
|
|
66
|
+
if (typeof mediaRuntime?.saveMediaBuffer === "function") {
|
|
67
|
+
const saved = await mediaRuntime.saveMediaBuffer(buffer, contentType, "inbound", maxBytes, filename);
|
|
62
68
|
return { path: saved.path, contentType: saved.contentType };
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
// Fallback:
|
|
66
|
-
|
|
67
|
-
const { writeFile } = await import("node:fs/promises");
|
|
71
|
+
// Fallback: keep callback media under the managed OpenClaw media root so
|
|
72
|
+
// stageSandboxMedia can safely copy it into the agent sandbox later.
|
|
73
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
68
74
|
const ext = path.extname(filename) || (type === "image" ? ".jpg" : ".bin");
|
|
75
|
+
const mediaDir = resolveManagedCallbackMediaDir();
|
|
69
76
|
const tempPath = path.join(
|
|
70
|
-
|
|
77
|
+
mediaDir,
|
|
71
78
|
`wecom-cb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`,
|
|
72
79
|
);
|
|
80
|
+
await mkdir(mediaDir, { recursive: true, mode: 0o700 });
|
|
73
81
|
await writeFile(tempPath, buffer);
|
|
74
|
-
logger.debug(`[CB] Media saved to
|
|
82
|
+
logger.debug(`[CB] Media saved to managed path: ${tempPath}`);
|
|
75
83
|
return { path: tempPath, contentType };
|
|
76
84
|
}
|
package/wecom/channel-plugin.js
CHANGED
|
@@ -24,7 +24,7 @@ import { wecomOnboardingAdapter } from "./onboarding.js";
|
|
|
24
24
|
import { getAccountTelemetry, recordOutboundActivity } from "./runtime-telemetry.js";
|
|
25
25
|
import { getOpenclawConfig, getRuntime, setOpenclawConfig } from "./state.js";
|
|
26
26
|
import { resolveWecomTarget } from "./target.js";
|
|
27
|
-
import { webhookSendFile, webhookSendImage, webhookSendMarkdown, webhookUploadFile } from "./webhook-bot.js";
|
|
27
|
+
import { webhookSendFile, webhookSendImage, webhookSendMarkdown, webhookSendText, webhookUploadFile } from "./webhook-bot.js";
|
|
28
28
|
import { loadOutboundMediaFromUrl as loadOutboundMediaFromUrlCompat } from "./openclaw-compat.js";
|
|
29
29
|
import {
|
|
30
30
|
CHANNEL_ID,
|
|
@@ -142,7 +142,7 @@ function applyNetworkConfig(cfg, accountId) {
|
|
|
142
142
|
return account;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, preparedMedia }) {
|
|
145
|
+
async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, preparedMedia, replyFormat }) {
|
|
146
146
|
const account = resolveAccount(cfg, accountId);
|
|
147
147
|
const raw = account?.config?.webhooks?.[webhookName];
|
|
148
148
|
const url = raw ? (String(raw).startsWith("http") ? String(raw) : `${getWebhookBotSendUrl()}?key=${raw}`) : null;
|
|
@@ -150,8 +150,13 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
|
|
|
150
150
|
throw new Error(`unknown webhook target: ${webhookName}`);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
const effectiveFormat = replyFormat || account?.agentReplyFormat || "markdown";
|
|
154
|
+
const sendWebhookText = effectiveFormat === "text"
|
|
155
|
+
? (opts) => webhookSendText(opts)
|
|
156
|
+
: (opts) => webhookSendMarkdown(opts);
|
|
157
|
+
|
|
153
158
|
if (!mediaUrl) {
|
|
154
|
-
await
|
|
159
|
+
await sendWebhookText({ url, content: text });
|
|
155
160
|
recordOutboundActivity({ accountId });
|
|
156
161
|
return { channel: CHANNEL_ID, messageId: `wecom-webhook-${Date.now()}` };
|
|
157
162
|
}
|
|
@@ -160,7 +165,7 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
|
|
|
160
165
|
preparedMedia ?? (await loadResolvedMedia(mediaUrl, { accountConfig: account?.config }));
|
|
161
166
|
|
|
162
167
|
if (text) {
|
|
163
|
-
await
|
|
168
|
+
await sendWebhookText({ url, content: text });
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
if (mediaType === "image") {
|
|
@@ -178,15 +183,17 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
|
|
|
178
183
|
return { channel: CHANNEL_ID, messageId: `wecom-webhook-${Date.now()}` };
|
|
179
184
|
}
|
|
180
185
|
|
|
181
|
-
async function sendViaAgent({ cfg, accountId, target, text, mediaUrl, preparedMedia }) {
|
|
182
|
-
const
|
|
186
|
+
async function sendViaAgent({ cfg, accountId, target, text, mediaUrl, preparedMedia, replyFormat }) {
|
|
187
|
+
const account = resolveAccount(cfg, accountId);
|
|
188
|
+
const agent = account?.agentCredentials;
|
|
183
189
|
if (!agent) {
|
|
184
190
|
throw new Error("Agent API is not configured for this account");
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
const effectiveFormat = replyFormat || account?.agentReplyFormat || "markdown";
|
|
187
194
|
if (text) {
|
|
188
195
|
for (const chunk of splitTextByByteLimit(text)) {
|
|
189
|
-
await agentSendText({ agent, ...target, text: chunk });
|
|
196
|
+
await agentSendText({ agent, ...target, text: chunk, format: effectiveFormat });
|
|
190
197
|
}
|
|
191
198
|
}
|
|
192
199
|
|
package/wecom/mcp-tool.js
CHANGED
|
@@ -85,9 +85,15 @@ function normalizeUnsupportedBizTypePayload(payload, category) {
|
|
|
85
85
|
details:
|
|
86
86
|
rawMessage ||
|
|
87
87
|
`unsupported mcp biz type for category "${category}"`,
|
|
88
|
+
note:
|
|
89
|
+
category !== "doc"
|
|
90
|
+
? `Per WeCom official policy, enterprises with >10 people only have access to the "doc" category. ` +
|
|
91
|
+
`Categories like contact, todo, meeting, schedule, msg are only available for small teams (<=10 people).`
|
|
92
|
+
: undefined,
|
|
88
93
|
next_action:
|
|
89
94
|
`Stop retrying category "${category}" with alternate read/list/find paths. ` +
|
|
90
|
-
`
|
|
95
|
+
`Do NOT attempt other categories as a workaround. ` +
|
|
96
|
+
`Inform the user that the "${category}" MCP category is not available for their current bot/enterprise.`,
|
|
91
97
|
};
|
|
92
98
|
}
|
|
93
99
|
|
|
@@ -596,13 +602,22 @@ export function createWeComMcpTool() {
|
|
|
596
602
|
label: "WeCom MCP Tool",
|
|
597
603
|
description: [
|
|
598
604
|
"Calls WeCom MCP servers over Streamable HTTP.",
|
|
605
|
+
"Common official categories: doc, contact, todo, meeting, schedule, msg.",
|
|
606
|
+
"",
|
|
607
|
+
"Category availability depends on enterprise size (WeCom official policy):",
|
|
608
|
+
" - Small teams (<=10 people): all categories (doc, contact, todo, meeting, schedule, msg)",
|
|
609
|
+
" - Enterprises (>10 people): doc only (documents & smart sheets)",
|
|
610
|
+
"If a category returns errcode 846609 / 'unsupported mcp biz type', it is NOT enabled for the current bot — stop retrying immediately.",
|
|
611
|
+
"",
|
|
599
612
|
"Supported actions:",
|
|
600
613
|
" - list: list tools under a category",
|
|
601
614
|
" - call: call one tool under a category",
|
|
602
615
|
"",
|
|
603
616
|
"Examples:",
|
|
604
617
|
" wecom_mcp list contact",
|
|
618
|
+
" wecom_mcp list msg",
|
|
605
619
|
" wecom_mcp call schedule create_schedule '{\"schedule\": {...}}'",
|
|
620
|
+
" wecom_mcp call msg get_messages '{\"chat_type\": 2, \"chatid\": \"GROUP_ID\", \"begin_time\": \"2026-03-17 00:00:00\", \"end_time\": \"2026-03-20 23:59:59\"}'",
|
|
606
621
|
].join("\n"),
|
|
607
622
|
parameters: {
|
|
608
623
|
type: "object",
|
|
@@ -614,7 +629,8 @@ export function createWeComMcpTool() {
|
|
|
614
629
|
},
|
|
615
630
|
category: {
|
|
616
631
|
type: "string",
|
|
617
|
-
description:
|
|
632
|
+
description:
|
|
633
|
+
"WeCom MCP category, such as doc, contact, todo, meeting, schedule, or msg. Actual availability depends on the current bot/runtime.",
|
|
618
634
|
},
|
|
619
635
|
method: {
|
|
620
636
|
type: "string",
|
|
@@ -327,18 +327,25 @@ export async function ensureDynamicAgentListed(agentId, templateDir, baseAgentId
|
|
|
327
327
|
|
|
328
328
|
// Persist to disk so `openclaw agents list` (separate process) can see
|
|
329
329
|
// the dynamic agent and it survives gateway restarts.
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
logger.info("WeCom: dynamic agent persisted to config file", { agentId: normalizedId });
|
|
337
|
-
} catch (writeErr) {
|
|
338
|
-
logger.warn("WeCom: failed to persist dynamic agent to config file", {
|
|
330
|
+
// Safety check: refuse to write if critical config sections are missing,
|
|
331
|
+
// which would indicate the in-memory snapshot is incomplete and writing
|
|
332
|
+
// it would destroy the user's configuration (#136).
|
|
333
|
+
const hasChannels = openclawConfig.channels && typeof openclawConfig.channels === "object";
|
|
334
|
+
if (!hasChannels) {
|
|
335
|
+
logger.warn("WeCom: skipping config write — in-memory config is missing 'channels' section", {
|
|
339
336
|
agentId: normalizedId,
|
|
340
|
-
|
|
337
|
+
keys: Object.keys(openclawConfig),
|
|
341
338
|
});
|
|
339
|
+
} else {
|
|
340
|
+
try {
|
|
341
|
+
await configRuntime.writeConfigFile(openclawConfig);
|
|
342
|
+
logger.info("WeCom: dynamic agent persisted to config file", { agentId: normalizedId });
|
|
343
|
+
} catch (writeErr) {
|
|
344
|
+
logger.warn("WeCom: failed to persist dynamic agent to config file", {
|
|
345
|
+
agentId: normalizedId,
|
|
346
|
+
error: writeErr?.message || String(writeErr),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
342
349
|
}
|
|
343
350
|
}
|
|
344
351
|
|
package/wecom/ws-monitor.js
CHANGED
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
import { setConfigProxyUrl } from "./http.js";
|
|
40
40
|
import { checkDmPolicy } from "./dm-policy.js";
|
|
41
41
|
import { checkGroupPolicy } from "./group-policy.js";
|
|
42
|
-
import { fetchAndSaveMcpConfig } from "./mcp-config.js";
|
|
43
42
|
import {
|
|
44
43
|
clearAccountDisplaced,
|
|
45
44
|
forecastActiveSendQuota,
|
|
@@ -449,6 +448,9 @@ function buildReplyMediaGuidance(config, agentId) {
|
|
|
449
448
|
"For workspace-local images, always use /workspace/... paths when calling image_studio.",
|
|
450
449
|
"Prefer n=1 unless the user explicitly asks for multiple images.",
|
|
451
450
|
"If image_studio returns MEDIA: URLs, treat the image task as completed successfully.",
|
|
451
|
+
"If image_studio returns MEDIA: URLs, do NOT repeat those URLs in the visible reply.",
|
|
452
|
+
"Do NOT embed markdown images, raw image URLs, or OSS links in the text reply after image_studio succeeds.",
|
|
453
|
+
"Instead, tell the user the image will be sent separately, for example: 图片会单独发送,请查收。",
|
|
452
454
|
);
|
|
453
455
|
}
|
|
454
456
|
|
|
@@ -1958,8 +1960,6 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1958
1960
|
clearAccountDisplaced(account.accountId);
|
|
1959
1961
|
setWsClient(account.accountId, wsClient);
|
|
1960
1962
|
|
|
1961
|
-
void fetchAndSaveMcpConfig(wsClient, account.accountId, runtime);
|
|
1962
|
-
|
|
1963
1963
|
// Drain pending replies that failed due to prior WS disconnection.
|
|
1964
1964
|
if (account?.agentCredentials && hasPendingReplies(account.accountId)) {
|
|
1965
1965
|
void flushPendingRepliesViaAgentApi(account).catch((flushError) => {
|
package/wecom/mcp-config.js
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import os from "node:os";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
-
import { generateReqId } from "@wecom/aibot-node-sdk";
|
|
5
|
-
import { logger } from "../logger.js";
|
|
6
|
-
|
|
7
|
-
const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
|
|
8
|
-
const MCP_CONFIG_FETCH_TIMEOUT_MS = 15_000;
|
|
9
|
-
const MCP_CONFIG_KEY = "doc";
|
|
10
|
-
const DEFAULT_MCP_TRANSPORT = "streamable-http";
|
|
11
|
-
|
|
12
|
-
let mcpConfigWriteQueue = Promise.resolve();
|
|
13
|
-
|
|
14
|
-
function withTimeout(promise, timeoutMs, message) {
|
|
15
|
-
if (!timeoutMs || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
16
|
-
return promise;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
let timer = null;
|
|
20
|
-
const timeout = new Promise((_, reject) => {
|
|
21
|
-
timer = setTimeout(() => reject(new Error(message ?? `Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
promise.catch(() => {});
|
|
25
|
-
|
|
26
|
-
return Promise.race([promise, timeout]).finally(() => {
|
|
27
|
-
if (timer) {
|
|
28
|
-
clearTimeout(timer);
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function getWecomConfigPath() {
|
|
34
|
-
return path.join(os.homedir(), ".openclaw", "wecomConfig", "config.json");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function resolveMcpTransport(body = {}) {
|
|
38
|
-
const candidate = String(
|
|
39
|
-
body.transport_type ??
|
|
40
|
-
body.transportType ??
|
|
41
|
-
body.config_type ??
|
|
42
|
-
body.configType ??
|
|
43
|
-
body.type ??
|
|
44
|
-
"",
|
|
45
|
-
)
|
|
46
|
-
.trim()
|
|
47
|
-
.toLowerCase();
|
|
48
|
-
|
|
49
|
-
return candidate || DEFAULT_MCP_TRANSPORT;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function readJsonFile(filePath, fallback = {}) {
|
|
53
|
-
try {
|
|
54
|
-
const raw = await readFile(filePath, "utf8");
|
|
55
|
-
return JSON.parse(raw);
|
|
56
|
-
} catch (error) {
|
|
57
|
-
if (error?.code === "ENOENT") {
|
|
58
|
-
return fallback;
|
|
59
|
-
}
|
|
60
|
-
throw error;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function writeJsonFileAtomically(filePath, value) {
|
|
65
|
-
const dir = path.dirname(filePath);
|
|
66
|
-
await mkdir(dir, { recursive: true });
|
|
67
|
-
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
68
|
-
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
69
|
-
await rename(tempPath, filePath);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export async function fetchMcpConfig(wsClient) {
|
|
73
|
-
if (!wsClient || typeof wsClient.reply !== "function") {
|
|
74
|
-
throw new Error("WS client does not support MCP config requests");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const reqId = generateReqId("mcp_config");
|
|
78
|
-
const response = await withTimeout(
|
|
79
|
-
wsClient.reply({ headers: { req_id: reqId } }, { biz_type: MCP_CONFIG_KEY }, MCP_GET_CONFIG_CMD),
|
|
80
|
-
MCP_CONFIG_FETCH_TIMEOUT_MS,
|
|
81
|
-
`MCP config fetch timed out after ${MCP_CONFIG_FETCH_TIMEOUT_MS}ms`,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
if (response?.errcode && response.errcode !== 0) {
|
|
85
|
-
throw new Error(`MCP config request failed: errcode=${response.errcode}, errmsg=${response.errmsg ?? "unknown"}`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const body = response?.body;
|
|
89
|
-
if (!body?.url) {
|
|
90
|
-
throw new Error("MCP config response missing required 'url' field");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
key: MCP_CONFIG_KEY,
|
|
95
|
-
type: resolveMcpTransport(body),
|
|
96
|
-
url: body.url,
|
|
97
|
-
isAuthed: body.is_authed,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function saveMcpConfig(config, runtime) {
|
|
102
|
-
const configPath = getWecomConfigPath();
|
|
103
|
-
|
|
104
|
-
const saveTask = mcpConfigWriteQueue.then(async () => {
|
|
105
|
-
const current = await readJsonFile(configPath, {});
|
|
106
|
-
if (!current.mcpConfig || typeof current.mcpConfig !== "object") {
|
|
107
|
-
current.mcpConfig = {};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
current.mcpConfig[config.key || MCP_CONFIG_KEY] = {
|
|
111
|
-
type: config.type,
|
|
112
|
-
url: config.url,
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
await writeJsonFileAtomically(configPath, current);
|
|
116
|
-
runtime?.log?.(`[WeCom] MCP config saved to ${configPath}`);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
mcpConfigWriteQueue = saveTask.catch(() => {});
|
|
120
|
-
return saveTask;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export async function fetchAndSaveMcpConfig(wsClient, accountId, runtime) {
|
|
124
|
-
try {
|
|
125
|
-
runtime?.log?.(`[${accountId}] Fetching MCP config...`);
|
|
126
|
-
const config = await fetchMcpConfig(wsClient);
|
|
127
|
-
runtime?.log?.(
|
|
128
|
-
`[${accountId}] MCP config fetched: url=${config.url}, type=${config.type}, is_authed=${config.isAuthed ?? "N/A"}`,
|
|
129
|
-
);
|
|
130
|
-
await saveMcpConfig(config, runtime);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
if (typeof wsClient?.reply !== "function") {
|
|
133
|
-
logger.debug?.(`[${accountId}] Skipping MCP config fetch because WS client has no reply() support`);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
runtime?.error?.(`[${accountId}] Failed to fetch/save MCP config: ${String(error)}`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export const mcpConfigTesting = {
|
|
141
|
-
getWecomConfigPath,
|
|
142
|
-
resolveMcpTransport,
|
|
143
|
-
resetWriteQueue() {
|
|
144
|
-
mcpConfigWriteQueue = Promise.resolve();
|
|
145
|
-
},
|
|
146
|
-
};
|