@sunnoy/wecom 2.3.0 → 2.4.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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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);
@@ -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.runtime - OpenClaw runtime (for saveMediaBuffer)
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, runtime, config }) {
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 runtime?.media?.saveMediaBuffer === "function") {
61
- const saved = await runtime.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes, filename);
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: write to OS temp dir
66
- const { tmpdir } = await import("node:os");
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
- tmpdir(),
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 temp path: ${tempPath}`);
82
+ logger.debug(`[CB] Media saved to managed path: ${tempPath}`);
75
83
  return { path: tempPath, contentType };
76
84
  }
@@ -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 webhookSendMarkdown({ url, content: text });
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 webhookSendMarkdown({ url, content: text });
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 agent = resolveAccount(cfg, accountId)?.agentCredentials;
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
 
@@ -618,6 +625,17 @@ export const wecomChannelPlugin = {
618
625
  setConfigProxyUrl(network.egressProxyUrl ?? "");
619
626
  setApiBaseUrl(network.apiBaseUrl ?? "");
620
627
 
628
+ // Callback-only accounts (Agent API) don't need WS monitor.
629
+ // Return a promise that stays alive until the gateway signals shutdown,
630
+ // so the account is marked as running (not exited → no auto-restart).
631
+ if (!ctx.account.botId && !ctx.account.secret) {
632
+ return new Promise((resolve) => {
633
+ if (ctx.abortSignal) {
634
+ ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
635
+ }
636
+ });
637
+ }
638
+
621
639
  return startWsMonitor({
622
640
  account: ctx.account,
623
641
  config: ctx.cfg,
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
- `Ask an administrator to enable the "${category}" MCP category for this bot.`,
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: "WeCom MCP category, such as doc, contact, schedule, todo, meeting.",
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
- // Write the mutated in-memory config directly (same pattern as logoutAccount).
331
- // NOTE: loadConfig() returns runtimeConfigSnapshot in gateway mode the same
332
- // object we already mutated above so a read-modify-write pattern silently
333
- // skips the write (diskChanged=false). Writing directly avoids this.
334
- try {
335
- await configRuntime.writeConfigFile(openclawConfig);
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
- error: writeErr?.message || String(writeErr),
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
 
@@ -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) => {
@@ -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
- };