@mocrane/wecom 2026.3.9 → 2026.3.14-beta.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.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "id": "wecom",
3
3
  "channels": ["wecom"],
4
+ "skills": ["./skills"],
4
5
  "configSchema": {
5
6
  "type": "object",
6
7
  "additionalProperties": false,
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "id": "wecom",
3
3
  "channels": ["wecom"],
4
+ "skills": ["./skills"],
4
5
  "configSchema": {
5
6
  "type": "object",
6
7
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mocrane/wecom",
3
- "version": "2026.3.9",
3
+ "version": "2026.3.14-beta.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
6
6
  "main": "index.ts",
@@ -9,6 +9,7 @@
9
9
  "src/",
10
10
  "assets/",
11
11
  "changelog/",
12
+ "skills/",
12
13
  "openclaw.plugin.json",
13
14
  "clawdbot.plugin.json",
14
15
  "README.md",
@@ -44,6 +45,7 @@
44
45
  "dependencies": {
45
46
  "@wecom/aibot-node-sdk": "^1.0.0",
46
47
  "fast-xml-parser": "5.3.4",
48
+ "file-type": "^18.7.0",
47
49
  "undici": "^7.20.0",
48
50
  "zod": "^4.3.6"
49
51
  },
@@ -0,0 +1,363 @@
1
+ ---
2
+ name: wecom-doc
3
+ description: 文档与智能表格操作。当用户提到企业微信文档、创建文档、编辑文档、新建文档、写文档、智能表格时激活。支持文档创建/写入和智能表格的创建及子表/字段/记录写入。注意:所有文档创建和编辑请求都应使用此 skill,不要尝试用其他方式处理文档操作。
4
+ metadata:
5
+ {
6
+ "openclaw":
7
+ {
8
+ "emoji": "📄",
9
+ "always": true,
10
+ "requires":
11
+ {
12
+ "bins": ["mcporter"],
13
+ },
14
+ "install":
15
+ [
16
+ {
17
+ "id": "mcporter",
18
+ "kind": "node",
19
+ "package": "mcporter",
20
+ "bins": ["mcporter"],
21
+ "label": "Install mcporter (npm)",
22
+ },
23
+ ],
24
+ },
25
+ }
26
+ ---
27
+
28
+ # 企业微信文档与智能表格工具
29
+
30
+ 通过 mcporter 调用 wecom-doc MCP server 操作企业微信文档和智能表格。
31
+
32
+ ## 意图处理
33
+
34
+ 当用户说"创建文档"、"新建文档"、"帮我写个文档"等**不指定平台**的请求时,默认使用企业微信文档,无需询问用户使用什么平台。
35
+
36
+ ## 前置检查(每次会话首次操作前必须执行)
37
+
38
+ > **自动续接原则**:前置检查中的任何环节如果需要等待用户确认或操作(安装、配置、授权等),在该操作完成后必须**自动继续执行后续步骤**,最终完成用户的原始请求。不要在中间环节完成后停下来等用户重复发起请求。
39
+
40
+ ### 1. 检查 mcporter 是否安装
41
+
42
+ ```bash
43
+ which mcporter
44
+ ```
45
+
46
+ 如果 `mcporter` **不在 PATH 中**(命令返回空或 not found),**必须原样输出以下提示**(不要修改、不要摘要):
47
+
48
+ > 企业微信文档能力依赖 mcporter 工具,当前尚未安装。需要我帮你安装吗?
49
+
50
+ 输出提示后**停止执行并等待用户回复**。
51
+
52
+ - 如果用户**确认安装**(回复"好"、"是"、"装吧"、"yes"、"ok" 等肯定语义),**立即执行**:
53
+
54
+ ```bash
55
+ npm install -g mcporter
56
+ ```
57
+
58
+ 安装完成后**不要停下来**,直接继续执行后续的「检查 MCP Server 是否配置」步骤,无缝衔接用户原始请求。
59
+
60
+ - 如果用户**拒绝安装**,回复:
61
+
62
+ > 好的,你也可以手动执行 `npm install -g mcporter` 安装后再找我。
63
+
64
+ ### 2. 确保 MCP Server 已配置
65
+
66
+ 确认 mcporter 存在后,先检查配置是否已存在,仅在未配置时才尝试自动配置。
67
+
68
+ **验证配置:**
69
+
70
+ ```bash
71
+ mcporter list wecom-doc --output json
72
+ ```
73
+
74
+ 如果返回正常(包含 tool 列表),说明已配置,**跳过自动配置,直接进入步骤 3**。
75
+
76
+ 如果返回 **server not found**、**unknown server** 或类似错误,执行下方自动配置。
77
+
78
+ **自动配置(仅在未配置时执行):**
79
+
80
+ 读取 wecom 运行时配置文件,该文件由 wecom channel 长连接建立时写入:
81
+
82
+ ```bash
83
+ cat ~/.openclaw/wecomConfig/config.json
84
+ ```
85
+
86
+ 检查 JSON 中是否存在 `mcpConfig.doc` 字段(含 `type` 和 `url`),如果存在则执行:
87
+
88
+ ```bash
89
+ mcporter config add wecom-doc \
90
+ --type "<mcpConfig.doc.type的值>" \
91
+ --url "<mcpConfig.doc.url的值>"
92
+ ```
93
+
94
+ 配置完成后**再次执行** `mcporter list wecom-doc --output json` 验证。如果仍然失败,按下方"MCP Server 未配置"章节处理。
95
+
96
+ > ⚠️ 配置文件不存在或缺少 `mcpConfig.doc` 字段,说明 wecom channel 长连接尚未建立,应引导用户检查 wecom channel 是否正常运行。自动配置失败不应阻断流程,继续引导用户手动配置。
97
+
98
+ ### 3. 获取 Tool 列表
99
+
100
+ `mcporter list` 成功后,返回的每个 tool 包含 `name`、`description`、`inputSchema`。
101
+
102
+ **不要硬编码 tool name 和参数**,据此构造 `mcporter call wecom-doc.<tool> --args '{...}' --output json` 调用。
103
+
104
+ ## docid 管理规则(重要)
105
+
106
+ **仅支持对通过本 skill 创建的文档或智能表格进行编辑。**
107
+
108
+ ### docid 的获取方式
109
+
110
+ docid **只能**通过 `create_doc` 的返回结果获取。创建成功后需要**保存返回的 docid**,后续编辑操作依赖此 ID。
111
+
112
+ ### 不支持从 URL 解析 docid
113
+
114
+ 从文档 URL(如 `https://doc.weixin.qq.com/doc/...`)中**无法解析到可用的 docid**。如果用户提供了文档 URL 并要求编辑,**不要尝试从 URL 中提取 docid**。
115
+
116
+ ### 编辑操作的 docid 校验
117
+
118
+ 当用户请求编辑文档或智能表格时,如果当前会话中**没有**通过 `create_doc` 获取到的 docid,**必须原样输出以下提示**(不要修改、不要摘要):
119
+
120
+ > 仅支持对机器人创建的文档进行编辑
121
+
122
+ ### docid 类型判断
123
+
124
+ | doc_id 前缀 | 类型 | doc_type |
125
+ |-------------|------|----------|
126
+ | `w3_` | 文档 | 3 |
127
+ | `s3_` | 智能表格 | 10 |
128
+
129
+ ## 工作流
130
+
131
+ ### 文档操作流
132
+
133
+ 1. 如需新建文档 → `create_doc`(`doc_type: 3`)→ **保存返回的 `docid`**
134
+ 2. 如需编辑内容 → 先确认当前会话中有通过 `create_doc` 获取的 `docid`,若无则提示"仅支持对机器人创建的文档进行编辑" → `edit_doc_content`(`content_type: 1` 使用 markdown,全量覆写)
135
+
136
+ > `edit_doc_content` 是**全量覆写**操作。如需追加内容,应先了解原有内容再拼接。
137
+
138
+ ### 智能表格操作流
139
+
140
+ 操作层级:**文档(docid)→ 子表(sheet_id)→ 字段(field_id)/ 记录(record_id)**
141
+
142
+ 1. 如需新建智能表 → `create_doc`(`doc_type: 10`)→ **保存返回的 `docid`**
143
+ 2. 如需编辑已有智能表 → 先确认当前会话中有通过 `create_doc` 获取的 `docid`,若无则提示"仅支持对机器人创建的文档进行编辑"
144
+ 3. 查询已有子表 → `smartsheet_get_sheet` → 获取 `sheet_id`
145
+ 4. 如需新建子表 → `smartsheet_add_sheet` → 获取新的 `sheet_id`
146
+ 5. 查询已有字段 → `smartsheet_get_fields` → 获取 `field_id`、`field_title`、`field_type`
147
+ 6. 如需添加字段 → `wedoc_smartsheet_add_fields`
148
+ 7. 如需更新字段 → `wedoc_smartsheet_update_fields`(**不能改变字段类型**)
149
+ 8. 添加数据记录 → `smartsheet_add_records`(values 的 key **必须**使用字段标题 field_title,不能用 field_id)
150
+
151
+ ### 从零创建智能表完整流程
152
+
153
+ ```
154
+ create_doc(doc_type=10) → docid
155
+ └→ smartsheet_add_sheet(docid) → sheet_id
156
+ └→ wedoc_smartsheet_add_fields(docid, sheet_id, fields) → field_ids
157
+ └→ smartsheet_add_records(docid, sheet_id, records)
158
+ ```
159
+
160
+ ### 向已有智能表添加数据流程
161
+
162
+ ```
163
+ smartsheet_get_sheet(docid) → sheet_id
164
+ └→ smartsheet_get_fields(docid, sheet_id) → field_ids + field_titles + field_types
165
+ └→ smartsheet_add_records(docid, sheet_id, records)
166
+ ```
167
+
168
+ > **重要**:添加记录前**必须**先通过 `smartsheet_get_fields` 获取字段信息,确保 `values` 中的 key 和 value 格式正确。
169
+
170
+ ## 业务知识(MCP Schema 中缺失的上下文)
171
+
172
+ 以下信息是 MCP tool 的 inputSchema 中没有的,Agent 构造参数时必须参考。
173
+
174
+ ### FieldType 枚举(16 种)
175
+
176
+ | 类型 | 说明 | 使用场景建议 |
177
+ |------|------|-------------|
178
+ | `FIELD_TYPE_TEXT` | 文本 | 通用文本内容;当用户只提供了成员**姓名**(而非 user_id)时,也应使用 TEXT 而非 USER |
179
+ | `FIELD_TYPE_NUMBER` | 数字 | 数值型数据(金额、数量、评分等) |
180
+ | `FIELD_TYPE_CHECKBOX` | 复选框 | 是/否、完成/未完成等布尔状态 |
181
+ | `FIELD_TYPE_DATE_TIME` | 日期时间 | 日期、截止时间、创建时间等 |
182
+ | `FIELD_TYPE_IMAGE` | 图片 | 需要展示图片的场景 |
183
+ | `FIELD_TYPE_USER` | 成员 | **仅**在明确知道成员 user_id 时使用;若用户只提供了姓名,应使用 TEXT 代替 |
184
+ | `FIELD_TYPE_URL` | 链接 | 网址、外部链接 |
185
+ | `FIELD_TYPE_SELECT` | 多选 | 标签、多分类等允许多选的场景 |
186
+ | `FIELD_TYPE_SINGLE_SELECT` | 单选 | 状态、优先级、严重程度、分类等有固定选项的字段 |
187
+ | `FIELD_TYPE_PROGRESS` | 进度 | 完成进度、完成百分比(值为 0-100 整数) |
188
+ | `FIELD_TYPE_PHONE_NUMBER` | 手机号 | 手机号码 |
189
+ | `FIELD_TYPE_EMAIL` | 邮箱 | 邮箱地址 |
190
+ | `FIELD_TYPE_LOCATION` | 位置 | 地理位置信息 |
191
+ | `FIELD_TYPE_CURRENCY` | 货币 | 金额(带货币符号) |
192
+ | `FIELD_TYPE_PERCENTAGE` | 百分比 | 百分比数值(值为 0~1) |
193
+ | `FIELD_TYPE_BARCODE` | 条码 | 条形码、ISBN 等 |
194
+
195
+ ### FieldType ↔ CellValue 对照表
196
+
197
+ 添加记录(`smartsheet_add_records`)时,`values` 中每个字段的 **key 必须使用字段标题(field_title),不能使用 field_id**。value 必须匹配其字段类型:
198
+
199
+ | 字段类型 | CellValue 格式 | 示例 |
200
+ |---------|---------------|------|
201
+ | `TEXT` | CellTextValue 数组 | `[{"type": "text", "text": "内容"}]` |
202
+ | `NUMBER` | number | `85` |
203
+ | `CHECKBOX` | boolean | `true` |
204
+ | `DATE_TIME` | 日期时间**字符串** | `"2023-01-01 12:00:00"`、`"2023-01-01 12:00"`、`"2023-01-01"` |
205
+ | `URL` | CellUrlValue 数组(限 1 个) | `[{"type": "url", "text": "百度", "link": "https://baidu.com"}]` |
206
+ | `USER` | CellUserValue 数组 | `[{"user_id": "zhangsan"}]` |
207
+ | `IMAGE` | CellImageValue 数组 | `[{"image_url": "https://..."}]`(`id`、`title` 可选) |
208
+ | `SELECT` | Option 数组(多选) | `[{"text": "选项A"}, {"text": "选项B"}]` |
209
+ | `SINGLE_SELECT` | Option 数组(限 1 个) | `[{"text": "选项A"}]` |
210
+ | `PROGRESS` | number(0~100 整数) | `85`(表示 85%) |
211
+ | `CURRENCY` | number | `99.5` |
212
+ | `PERCENTAGE` | number(0~1) | `0.85` |
213
+ | `PHONE_NUMBER` | string | `"13800138000"` |
214
+ | `EMAIL` | string | `"user@example.com"` |
215
+ | `BARCODE` | string | `"978-3-16-148410-0"` |
216
+ | `LOCATION` | CellLocationValue 数组(限 1 个) | `[{"source_type": 1, "id": "xxx", "latitude": "39.9", "longitude": "116.3", "title": "北京"}]` |
217
+
218
+ > **Option 格式说明**:`SINGLE_SELECT`/`SELECT` 的选项支持 `style` 字段(1~27 对应不同颜色),如 `[{"text": "紧急", "style": 1}]`。`style` 为可选字段,不传则使用默认颜色。
219
+
220
+ ### 易错点
221
+
222
+ - `DATE_TIME` 的值是**日期时间字符串**,支持 `"YYYY-MM-DD HH:MM:SS"`(精确到秒)、`"YYYY-MM-DD HH:MM"`(精确到分)、`"YYYY-MM-DD"`(精确到天),系统自动按东八区转换为时间戳,无需手动计算
223
+ - `CellUrlValue` 的链接字段名是 **`link`**,不是 `url`
224
+ - `TEXT` 类型的值**必须**使用数组格式 `[{"type": "text", "text": "内容"}]`,外层方括号不可省略,不能传单个对象 `{"type":"text","text":"内容"}`
225
+ - `SINGLE_SELECT`/`SELECT` 类型的值**必须**使用数组格式 `[{"text": "选项内容"}]`,不能直接传字符串
226
+ - `PROGRESS` 的值范围是 **0~100 整数**(85 = 85%);`PERCENTAGE` 的值范围是 **0~1**(0.85 = 85%),两者不同注意区分
227
+ - `wedoc_smartsheet_update_fields` **不能更改字段类型**,只能改标题和属性
228
+ - `values` 的 key **必须**使用**字段标题**(field_title),**不能**使用 field_id
229
+ - 不可写入的字段类型:创建时间、最后编辑时间、创建人、最后编辑人
230
+
231
+ ## 错误处理
232
+
233
+ ### MCP 错误响应处理(重要)
234
+
235
+ 当 `mcporter call` 返回结果中 `errcode` 不为 0 且 `help_message` 有值时,将 `help_message` 中面向用户的内容输出给用户。
236
+
237
+ **输出原则:**
238
+
239
+ - **完整性**:不截断、不摘要
240
+ - **原样性**:不改写、不重新排版
241
+ - **用户可读性**:只输出面向用户的内容,过滤掉给 Agent 的元指令(如"将以下内容告知用户"等引导语);直接以 markdown 形式输出,不要包裹在代码块中
242
+
243
+ ### 错误码 850001:需要用户提供配置
244
+
245
+ 当 `errcode` 为 `850001` 时:
246
+
247
+ 1. 将 `help_message` 中面向用户的内容**原样输出**(遵循上方输出原则)
248
+ 2. 输出后**等待用户回复**,后续流程与「MCP Server 未配置」的场景 A / B / C 一致。配置成功后**不要停下来**,立即回到「前置检查」的步骤 2 重新验证配置,验证通过后继续执行用户的原始请求。
249
+
250
+ ### MCP Server 未配置
251
+
252
+ 如果 `mcporter call wecom-doc.*` 或 `mcporter list wecom-doc` 返回 **server not found**、**unknown server** 或类似错误,说明 wecom-doc MCP server 尚未配置。
253
+
254
+ 此时需要**检测运行环境**以提供针对性的提示。按以下步骤执行:
255
+
256
+ #### 步骤一:检测是否在 OpenClaw 运行时中
257
+
258
+ 通过检测 `OPENCLAW_SHELL` 环境变量判断当前是否运行在 OpenClaw 中。该变量是 OpenClaw **运行时自动注入到子进程**中的标记,不会因为机器上安装了 openclaw 就存在——只有 skill 确实在 OpenClaw 中被调用时,exec 执行的命令才会携带此变量。
259
+
260
+ ```bash
261
+ echo "OPENCLAW_SHELL=${OPENCLAW_SHELL:-}" && command -v openclaw 2>/dev/null && echo "OPENCLAW_CLI=FOUND" || echo "OPENCLAW_CLI=NOT_FOUND"
262
+ ```
263
+
264
+ 判断规则:
265
+ - **`OPENCLAW_SHELL` 为空**(输出 `OPENCLAW_SHELL=`)→ 当前不在 OpenClaw 运行时中,跳到**「通用提示」**。
266
+ - **`OPENCLAW_SHELL` 非空**(如 `exec`、`tui-local` 等)**且** `OPENCLAW_CLI=FOUND` → 确认在 OpenClaw 中且 CLI 可用,继续步骤二。
267
+ - **`OPENCLAW_SHELL` 非空但 `OPENCLAW_CLI=NOT_FOUND`** → 虽然在 OpenClaw 中但 CLI 不可用,跳到**「通用提示」**。
268
+
269
+ > 为什么不能仅用 `command -v openclaw`:同一台机器可能同时安装了 openclaw 和 claude 等其他 AI 工具。仅检测 CLI 是否存在无法区分"装了 openclaw"和"正在 openclaw 中运行"。`OPENCLAW_SHELL` 是进程级的运行时标记,从根本上解决此问题。
270
+
271
+ #### 步骤二:查询 wecom channel 的 botId 配置
272
+
273
+ ```bash
274
+ openclaw config get channels.wecom.botId 2>&1
275
+ ```
276
+
277
+ - 如果命令返回了**具体的 botId 值**(非空、非报错),保存该值,跳到**「OpenClaw 场景 + 有 botId 提示」**。
278
+ - 如果返回 `Config path not found`、报错或为空,跳到**「通用提示」**。
279
+
280
+ #### 异常容错
281
+
282
+ 如果上述任何命令执行失败(如 exec 工具不可用、命令超时等),一律跳到**「通用提示」**。
283
+
284
+ ---
285
+
286
+ #### OpenClaw 场景 + 有 botId 提示
287
+
288
+ 当确认是 OpenClaw 环境且获取到了 botId 时,**必须原样输出以下提示**(不要修改、不要摘要),将 `XXXX` 替换为实际获取到的 botId 值:
289
+
290
+ > 若你是智能机器人创建者,可以[点击这里](https://work.weixin.qq.com/ai/aiHelper/authorizationPage?str_aibotid=XXXX&from=chat&forceInnerBrowser=1)授权当前机器人文档使用权限;
291
+ > 若你不是机器人创建者,可联系该机器人创建者,前往企业微信「工作台-智能机器人」找到对应机器人进行授权
292
+ > 若你已授权,可将对应机器人文档MCP的接入配置(StreamableHttp URL或者JSON Config)发送给我
293
+
294
+ 输出提示后**等待用户回复**,后续流程与下方「通用提示」的后续流程一致(场景 A / B / C)。配置成功后**不要停下来**,立即回到「前置检查」的步骤 2 重新验证配置,验证通过后继续执行用户的原始请求。
295
+
296
+ ---
297
+
298
+ #### 通用提示
299
+
300
+ 当非 OpenClaw 环境、无法判断环境、或 OpenClaw 环境但未配置 wecom channel / botId 时,**必须原样输出以下提示文案**(不要修改、不要摘要):
301
+
302
+ > 机器人可通过MCP方式调用文档相关能力,当前暂未完成所需配置。请参考以下配置指引:
303
+ >
304
+ > 1. 请前往「企业微信-工作台-智能机器人应用」,以API模式创建机器人(如已创建,可忽略该步骤)
305
+ >
306
+ > 2. 授权该机器人「文档」使用权限。授权后,可自行选择StreamableHttp URL 或 JSON Config 进行配置。
307
+
308
+ 输出提示后**等待用户回复**。用户可能:
309
+
310
+ **场景 A:用户提供了 StreamableHttp URL**
311
+
312
+ 从用户消息中提取 URL,执行:
313
+
314
+ ```bash
315
+ mcporter config add wecom-doc \
316
+ --type streamable-http \
317
+ --url "<用户提供的URL>"
318
+ ```
319
+
320
+ 配置完成后**不要停下来**,立即回到「前置检查」的步骤 2 重新验证配置,验证通过后继续执行用户的原始请求。
321
+
322
+ **场景 B:用户提供了 JSON 配置**
323
+
324
+ 如果用户提供了类似以下格式的 JSON:
325
+
326
+ ```json
327
+ {
328
+ "name": "wecom-doc",
329
+ "type": "streamable-http",
330
+ "url": "http://xxx"
331
+ }
332
+ ```
333
+
334
+ 从 JSON 中提取 `url` 字段,执行:
335
+
336
+ ```bash
337
+ mcporter config add wecom-doc \
338
+ --type streamable-http \
339
+ --url "<从JSON提取的url>"
340
+ ```
341
+
342
+ 配置完成后**不要停下来**,立即回到「前置检查」的步骤 2 重新验证配置,验证通过后继续执行用户的原始请求。
343
+
344
+ **场景 C:用户自行完成了配置**
345
+
346
+ 用户可能在管理后台或其他途径自行完成配置后告知已配置好,此时直接继续执行原来的操作。
347
+
348
+ > **配置检测**:当用户输入的内容包含 URL(如 `http://...`)或 JSON(含 `"type": "streamable-http"`),应判断用户意图是在提供 MCP server 配置信息,自动执行配置命令。
349
+
350
+ ### Daemon 未启动
351
+
352
+ 如果返回 **connection refused** 或 **daemon not running** 错误,提示用户:
353
+
354
+ ```bash
355
+ mcporter daemon start
356
+ ```
357
+
358
+
359
+ ## 注意事项
360
+
361
+ - 所有调用通过 `mcporter call wecom-doc.<tool>` 执行,不要直接调用企业微信 API
362
+ - `create_doc` 返回的 `docid` 需要保存,后续操作依赖此 ID
363
+ - 添加记录前**必须**先 `smartsheet_get_fields` 获取字段元信息
@@ -0,0 +1,224 @@
1
+ # 企业微信文档 API 参考
2
+
3
+ ## 文档类型
4
+
5
+ | doc_type | 类型 | doc_id 前缀 | URL 路径 |
6
+ |----------|------|------------|---------|
7
+ | 3 | 文档 | `w3_` | `/doc/` |
8
+ | 10 | 智能表格 | `s3_` | `/smartsheet/` |
9
+
10
+ ## URL 格式
11
+
12
+ ### 文档
13
+
14
+ ```
15
+ https://doc.weixin.qq.com/doc/{doc_id}?scode=xxx
16
+ ```
17
+
18
+ 示例:
19
+ ```
20
+ https://doc.weixin.qq.com/doc/w3_AMEA4QYkACkCNN7hNRzRzQkaElHbQ?scode=AJEAIQdfAAodYknI73AMEA4QYkACk
21
+ → doc_id = w3_AMEA4QYkACkCNN7hNRzRzQkaElHbQ
22
+ ```
23
+
24
+ ### 智能表格
25
+
26
+ ```
27
+ https://doc.weixin.qq.com/smartsheet/{doc_id}
28
+ ```
29
+
30
+ 示例:
31
+ ```
32
+ https://doc.weixin.qq.com/smartsheet/s3_ATAA_QaoAKQCNIQ6XYeEYQ3q5Rv05
33
+ → doc_id = s3_ATAA_QaoAKQCNIQ6XYeEYQ3q5Rv05
34
+ ```
35
+
36
+ > 始终忽略 `?` 之后的查询参数。
37
+
38
+ ## 智能表格字段类型(FieldType)
39
+
40
+ 完整 16 种类型:
41
+
42
+ | 枚举值 | 说明 |
43
+ |--------|------|
44
+ | `FIELD_TYPE_TEXT` | 文本 |
45
+ | `FIELD_TYPE_NUMBER` | 数字 |
46
+ | `FIELD_TYPE_CHECKBOX` | 复选框 |
47
+ | `FIELD_TYPE_DATE_TIME` | 日期时间 |
48
+ | `FIELD_TYPE_IMAGE` | 图片 |
49
+ | `FIELD_TYPE_USER` | 成员 |
50
+ | `FIELD_TYPE_URL` | 链接 |
51
+ | `FIELD_TYPE_SELECT` | 多选 |
52
+ | `FIELD_TYPE_SINGLE_SELECT` | 单选 |
53
+ | `FIELD_TYPE_PROGRESS` | 进度 |
54
+ | `FIELD_TYPE_PHONE_NUMBER` | 手机号 |
55
+ | `FIELD_TYPE_EMAIL` | 邮箱 |
56
+ | `FIELD_TYPE_LOCATION` | 位置 |
57
+ | `FIELD_TYPE_CURRENCY` | 货币 |
58
+ | `FIELD_TYPE_PERCENTAGE` | 百分比 |
59
+ | `FIELD_TYPE_BARCODE` | 条码 |
60
+
61
+ ## CellValue 类型完整对照
62
+
63
+ ### CellTextValue — 文本字段
64
+
65
+ ```json
66
+ [
67
+ {"type": "text", "text": "普通文本"},
68
+ {"type": "url", "text": "链接文本", "link": "https://example.com"}
69
+ ]
70
+ ```
71
+
72
+ - `type`(必填):`"text"` 或 `"url"`
73
+ - `text`(必填):文本内容
74
+ - `link`(当 type 为 url 时):链接跳转 URL
75
+
76
+ 适用:`FIELD_TYPE_TEXT`
77
+
78
+ ### 数字类 — number
79
+
80
+ 直接传 number 值。
81
+
82
+ ```json
83
+ 85
84
+ ```
85
+
86
+ 适用:`FIELD_TYPE_NUMBER`、`FIELD_TYPE_PROGRESS`、`FIELD_TYPE_CURRENCY`、`FIELD_TYPE_PERCENTAGE`
87
+
88
+ ### 布尔值 — boolean
89
+
90
+ ```json
91
+ true
92
+ ```
93
+
94
+ 适用:`FIELD_TYPE_CHECKBOX`
95
+
96
+ ### 字符串类 — string
97
+
98
+ 直接传字符串。
99
+
100
+ 适用场景:
101
+ - `FIELD_TYPE_DATE_TIME`:毫秒 unix 时间戳字符串,如 `"1672531200000"`
102
+ - `FIELD_TYPE_PHONE_NUMBER`:手机号字符串,如 `"13800138000"`
103
+ - `FIELD_TYPE_EMAIL`:邮箱字符串,如 `"user@example.com"`
104
+ - `FIELD_TYPE_BARCODE`:条码字符串,如 `"978-3-16-148410-0"`
105
+
106
+ ### CellUrlValue — 链接字段
107
+
108
+ ```json
109
+ [{"type": "url", "text": "显示文本", "link": "https://example.com"}]
110
+ ```
111
+
112
+ - `type`(必填):固定 `"url"`
113
+ - `link`(必填):链接跳转 URL
114
+ - `text`(可选):链接显示文本
115
+
116
+ > 注意:字段名是 **`link`** 不是 `url`。数组为预留能力,目前只支持 1 个链接。
117
+
118
+ 适用:`FIELD_TYPE_URL`
119
+
120
+ ### CellUserValue — 成员字段
121
+
122
+ ```json
123
+ [{"user_id": "zhangsan"}]
124
+ ```
125
+
126
+ - `user_id`(必填):成员 ID
127
+
128
+ 适用:`FIELD_TYPE_USER`
129
+
130
+ ### CellImageValue — 图片字段
131
+
132
+ ```json
133
+ [{
134
+ "id": "img1",
135
+ "title": "截图",
136
+ "image_url": "https://...",
137
+ "width": 800,
138
+ "height": 600
139
+ }]
140
+ ```
141
+
142
+ - `id`:图片 ID(自定义)
143
+ - `title`:图片标题
144
+ - `image_url`:图片链接(通过上传图片接口获取)
145
+ - `width` / `height`:图片尺寸
146
+
147
+ 适用:`FIELD_TYPE_IMAGE`
148
+
149
+ ### CellAttachmentValue — 文件字段
150
+
151
+ ```json
152
+ [{
153
+ "name": "文件名",
154
+ "size": 1024,
155
+ "file_ext": "DOC",
156
+ "file_id": "xxx",
157
+ "file_url": "https://...",
158
+ "file_type": "50"
159
+ }]
160
+ ```
161
+
162
+ - `file_ext` 取值:`DOC`、`SHEET`、`SLIDE`、`MIND`、`FLOWCHART`、`SMARTSHEET`、`FORM`,或文件扩展名
163
+ - `file_type` 取值:`Folder`(文件夹)、`Wedrive`(微盘文件)、`30`(收集表)、`50`(文档)、`51`(表格)、`52`(幻灯片)、`54`(思维导图)、`55`(流程图)、`70`(智能表)
164
+
165
+ ### Option — 选项(单选/多选字段)
166
+
167
+ ```json
168
+ [{"text": "选项A", "style": 1}, {"text": "选项B", "style": 5}]
169
+ ```
170
+
171
+ - `text`:选项内容。新增选项时填写,已存在时优先匹配
172
+ - `id`(可选):选项 ID,已存在的选项通过 ID 识别
173
+ - `style`(可选):选项颜色,1-27
174
+
175
+ 适用:`FIELD_TYPE_SELECT`(多选,可传多个)、`FIELD_TYPE_SINGLE_SELECT`(单选,建议传 1 个)
176
+
177
+ ### CellLocationValue — 位置字段
178
+
179
+ ```json
180
+ [{
181
+ "source_type": 1,
182
+ "id": "地点ID",
183
+ "latitude": "39.9042",
184
+ "longitude": "116.4074",
185
+ "title": "北京天安门"
186
+ }]
187
+ ```
188
+
189
+ - `source_type`(必填):固定 `1`(腾讯地图)
190
+ - `id`(必填):地点 ID
191
+ - `latitude`(必填):纬度(字符串)
192
+ - `longitude`(必填):经度(字符串)
193
+ - `title`(必填):地点名称
194
+
195
+ > 数组长度不大于 1。
196
+
197
+ 适用:`FIELD_TYPE_LOCATION`
198
+
199
+ ## 选项样式(Style)
200
+
201
+ 取值 1-27 对应颜色:
202
+
203
+ | 值 | 颜色 | 值 | 颜色 | 值 | 颜色 |
204
+ |----|------|----|------|----|------|
205
+ | 1 | 浅红 | 10 | 浅蓝 | 19 | 浅橙 |
206
+ | 2 | 浅橙 | 11 | 浅蓝 | 20 | 橙 |
207
+ | 3 | 浅天蓝 | 12 | 蓝 | 21 | 浅黄 |
208
+ | 4 | 浅绿 | 13 | 浅天蓝 | 22 | 浅黄 |
209
+ | 5 | 浅紫 | 14 | 天蓝 | 23 | 黄 |
210
+ | 6 | 浅粉红 | 15 | 浅绿 | 24 | 浅紫 |
211
+ | 7 | 浅灰 | 16 | 绿 | 25 | 紫 |
212
+ | 8 | 白 | 17 | 浅红 | 26 | 浅粉红 |
213
+ | 9 | 灰 | 18 | 红 | 27 | 粉红 |
214
+
215
+ ## 限制
216
+
217
+ | 维度 | 限制 |
218
+ |------|------|
219
+ | 文档名称 | 最多 255 字符 |
220
+ | 子表字段数 | 单表最多 150 个 |
221
+ | 记录数 | 单表最多 100,000 行 |
222
+ | 单元格数 | 单表最多 15,000,000 个 |
223
+ | 单次添加记录 | 建议 500 行内 |
224
+ | 不可写入字段 | 创建时间、最后编辑时间、创建人、最后编辑人 |
@@ -0,0 +1,183 @@
1
+ /**
2
+ * MCP 配置拉取与持久化模块
3
+ *
4
+ * 负责:
5
+ * - 通过 WSClient 发送 aibot_get_mcp_config 请求
6
+ * - 解析服务端响应,提取 MCP 配置 (url、type、is_authed)
7
+ * - 将配置写入 ~/.openclaw/wecomConfig/config.json 的 mcpConfig 字段
8
+ */
9
+
10
+ import os from "os";
11
+ import path from "path";
12
+ import type { WSClient } from "@wecom/aibot-node-sdk";
13
+ import { generateReqId } from "@wecom/aibot-node-sdk";
14
+ import {
15
+ readJsonFileWithFallback,
16
+ writeJsonFileAtomically,
17
+ withFileLock,
18
+ } from "openclaw/plugin-sdk";
19
+ import type { WecomRuntimeEnv } from "./monitor/types.js";
20
+ import { withTimeout } from "./timeout.js";
21
+
22
+ // ============================================================================
23
+ // 常量
24
+ // ============================================================================
25
+
26
+ /** 获取 MCP 配置的 WebSocket 命令 */
27
+ const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
28
+
29
+ /** MCP 配置拉取超时时间(毫秒) */
30
+ const MCP_CONFIG_FETCH_TIMEOUT_MS = 15_000;
31
+
32
+ // ============================================================================
33
+ // 类型
34
+ // ============================================================================
35
+
36
+ /**
37
+ * MCP 配置响应体
38
+ */
39
+ export interface McpConfigBody {
40
+ /** MCP Server 的 StreamableHttp URL */
41
+ url: string;
42
+ /** 连接类型,如 "doc" */
43
+ type?: string;
44
+ /** 是否已授权 */
45
+ is_authed?: boolean;
46
+ }
47
+
48
+ // ============================================================================
49
+ // MCP 配置拉取
50
+ // ============================================================================
51
+
52
+ /**
53
+ * 通过 WSClient 发送 aibot_get_mcp_config 命令,获取 MCP 配置
54
+ *
55
+ * @param wsClient - 已认证的 WSClient 实例
56
+ * @returns MCP 配置 (url、type、is_authed)
57
+ * @throws 响应错误码非 0 或缺少 url 字段时抛出错误
58
+ */
59
+ export async function fetchMcpConfig(
60
+ wsClient: WSClient,
61
+ ): Promise<McpConfigBody> {
62
+ const reqId = generateReqId("mcp_config");
63
+
64
+ // 通过 reply 方法发送自定义命令
65
+ const response = await withTimeout(
66
+ wsClient.reply(
67
+ { headers: { req_id: reqId } },
68
+ { biz_type: "doc" },
69
+ MCP_GET_CONFIG_CMD,
70
+ ),
71
+ MCP_CONFIG_FETCH_TIMEOUT_MS,
72
+ `MCP config fetch timed out after ${MCP_CONFIG_FETCH_TIMEOUT_MS}ms`,
73
+ );
74
+
75
+ // 校验响应错误码
76
+ if (response.errcode && response.errcode !== 0) {
77
+ throw new Error(
78
+ `MCP config request failed: errcode=${response.errcode}, errmsg=${response.errmsg ?? "unknown"}`,
79
+ );
80
+ }
81
+
82
+ // 提取并校验 body
83
+ const body = response.body as McpConfigBody | undefined;
84
+ if (!body?.url) {
85
+ throw new Error(
86
+ "MCP config response missing required 'url' field",
87
+ );
88
+ }
89
+
90
+ return {
91
+ url: body.url,
92
+ type: "doc",
93
+ is_authed: body.is_authed,
94
+ };
95
+ }
96
+
97
+ // ============================================================================
98
+ // 配置持久化
99
+ // ============================================================================
100
+
101
+ /**
102
+ * 将 MCP 配置写入 ~/.openclaw/wecomConfig/config.json 的 mcpConfig 字段
103
+ *
104
+ * 使用 OpenClaw SDK 提供的文件锁和原子写入,保证并发安全。
105
+ * 配置格式: { mcpConfig: { [type]: { type, url } } }
106
+ */
107
+ async function saveMcpConfigToPluginJson(
108
+ config: McpConfigBody,
109
+ runtime: WecomRuntimeEnv,
110
+ ): Promise<void> {
111
+ const wecomConfigDir = path.join(os.homedir(), ".openclaw", "wecomConfig");
112
+ const wecomConfigPath = path.join(wecomConfigDir, "config.json");
113
+
114
+ const lockOptions = {
115
+ stale: 60_000,
116
+ retries: {
117
+ retries: 6,
118
+ factor: 1.35,
119
+ minTimeout: 8,
120
+ maxTimeout: 1200,
121
+ randomize: true,
122
+ },
123
+ };
124
+
125
+ await withFileLock(wecomConfigPath, lockOptions, async () => {
126
+ // 读取现有配置(不存在时使用空对象)
127
+ const { value: pluginJson } = await readJsonFileWithFallback<Record<string, unknown>>(
128
+ wecomConfigPath,
129
+ {},
130
+ );
131
+
132
+ // 确保 mcpConfig 字段存在且为对象
133
+ if (!pluginJson.mcpConfig || typeof pluginJson.mcpConfig !== "object") {
134
+ pluginJson.mcpConfig = {};
135
+ }
136
+
137
+ // 使用 type 作为键存储配置
138
+ const typeKey = config.type || "default";
139
+ (pluginJson.mcpConfig as Record<string, unknown>)[typeKey] = {
140
+ type: config.type,
141
+ url: config.url,
142
+ };
143
+
144
+ // 原子写入
145
+ await writeJsonFileAtomically(wecomConfigPath, pluginJson);
146
+
147
+ runtime.log?.(`[WeCom] MCP config saved to ${wecomConfigPath}`);
148
+ });
149
+ }
150
+
151
+ // ============================================================================
152
+ // 组合入口
153
+ // ============================================================================
154
+
155
+ /**
156
+ * 拉取 MCP 配置并持久化到 ~/.openclaw/wecomConfig/config.json
157
+ *
158
+ * 认证成功后调用。失败仅记录日志,不影响 WebSocket 消息正常收发。
159
+ *
160
+ * @param wsClient - 已认证的 WSClient 实例
161
+ * @param accountId - 账户 ID(用于日志)
162
+ * @param runtime - 运行时环境(用于日志)
163
+ */
164
+ export async function fetchAndSaveMcpConfig(
165
+ wsClient: WSClient,
166
+ accountId: string,
167
+ runtime: WecomRuntimeEnv,
168
+ ): Promise<void> {
169
+ try {
170
+ runtime.log?.(`[${accountId}] Fetching MCP config...`);
171
+
172
+ const config = await fetchMcpConfig(wsClient);
173
+ runtime.log?.(
174
+ `[${accountId}] MCP config fetched: url=${config.url}, type=${config.type ?? "N/A"}, is_authed=${config.is_authed ?? "N/A"}`,
175
+ );
176
+
177
+ await saveMcpConfigToPluginJson(config, runtime);
178
+ } catch (err) {
179
+ runtime.error?.(
180
+ `[${accountId}] Failed to fetch/save MCP config: ${String(err)}`,
181
+ );
182
+ }
183
+ }
package/src/monitor.ts CHANGED
@@ -1198,6 +1198,9 @@ async function startAgentForStream(params: {
1198
1198
  const config = target.config;
1199
1199
  const account = target.account;
1200
1200
 
1201
+ // WS 长连接模式标记:跳过 Webhook 专属的 Agent 私信兜底逻辑
1202
+ const isWsMode = Boolean(streamStore.getStream(streamId)?.wsMode);
1203
+
1201
1204
  const userid = resolveWecomSenderUserId(msg) || "unknown";
1202
1205
  const chatType = msg.chattype === "group" ? "group" : "direct";
1203
1206
  const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
@@ -1298,7 +1301,21 @@ async function startAgentForStream(params: {
1298
1301
  return;
1299
1302
  }
1300
1303
 
1301
- // 图片路径都读取失败时,切换到 Agent 私信兜底,并主动结束 Bot 流。
1304
+ // 图片路径都读取失败时的兜底处理
1305
+ if (isWsMode) {
1306
+ // WS 模式:不走 Agent 私信兜底,直接提示错误并结束
1307
+ const fallbackName = imagePaths.length === 1
1308
+ ? (imagePaths[0]!.split("/").pop() || "image")
1309
+ : `${imagePaths.length} 张图片`;
1310
+ streamStore.updateStream(streamId, (s) => {
1311
+ s.finished = true;
1312
+ s.content = `图片读取失败(${fallbackName}),请重试。`;
1313
+ });
1314
+ streamStore.onStreamFinished(streamId);
1315
+ return;
1316
+ }
1317
+
1318
+ // Webhook 模式:切换到 Agent 私信兜底,并主动结束 Bot 流。
1302
1319
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1303
1320
  const agentOk = Boolean(agentCfg);
1304
1321
  const fallbackName = imagePaths.length === 1
@@ -1355,6 +1372,18 @@ async function startAgentForStream(params: {
1355
1372
 
1356
1373
  // 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
1357
1374
  if (otherPaths.length > 0) {
1375
+ if (isWsMode) {
1376
+ // WS 模式:不走 Agent 私信兜底,提示文件路径
1377
+ const filename = otherPaths.length === 1 ? otherPaths[0]!.split("/").pop()! : `${otherPaths.length} 个文件`;
1378
+ streamStore.updateStream(streamId, (s) => {
1379
+ s.finished = true;
1380
+ s.content = `已生成文件(${filename}),文件路径:${otherPaths.join(", ")}`;
1381
+ });
1382
+ streamStore.onStreamFinished(streamId);
1383
+ return;
1384
+ }
1385
+
1386
+ // Webhook 模式:Agent 私信兜底
1358
1387
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1359
1388
  const agentOk = Boolean(agentCfg);
1360
1389
 
@@ -1781,7 +1810,7 @@ async function startAgentForStream(params: {
1781
1810
  const now = Date.now();
1782
1811
  const deadline = current.createdAt + BOT_WINDOW_MS;
1783
1812
  const switchAt = deadline - BOT_SWITCH_MARGIN_MS;
1784
- const nearTimeout = !current.fallbackMode && !current.finished && now >= switchAt;
1813
+ const nearTimeout = !isWsMode && !current.fallbackMode && !current.finished && now >= switchAt;
1785
1814
  if (nearTimeout) {
1786
1815
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1787
1816
  const agentOk = Boolean(agentCfg);
@@ -1840,7 +1869,14 @@ async function startAgentForStream(params: {
1840
1869
  current.images.push({ base64, md5 });
1841
1870
  logVerbose(target, `media: 识别为图片 contentType=${contentType} filename=${filename}`);
1842
1871
  } else {
1843
- // Non-image media: Bot 不支持原样发送(尤其群聊),统一切换到 Agent 私信兜底,并在 Bot 会话里提示用户。
1872
+ // Non-image media: Bot 不支持原样发送(尤其群聊)
1873
+ if (isWsMode) {
1874
+ // WS 模式:不走 Agent 私信兜底,跳过非图片媒体
1875
+ logVerbose(target, `media: WS 模式跳过 Agent 私信兜底 filename=${filename} contentType=${contentType ?? "unknown"}`);
1876
+ continue;
1877
+ }
1878
+
1879
+ // Webhook 模式:统一切换到 Agent 私信兜底,并在 Bot 会话里提示用户。
1844
1880
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1845
1881
  const agentOk = Boolean(agentCfg);
1846
1882
  const alreadySent = current.agentMediaKeys.includes(mediaPath);
@@ -1892,40 +1928,42 @@ async function startAgentForStream(params: {
1892
1928
  }
1893
1929
  } catch (err) {
1894
1930
  target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
1895
- const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1896
- const agentOk = Boolean(agentCfg);
1897
- const fallbackFilename = filename || mediaPath.split("/").pop() || "attachment";
1898
- if (agentCfg && current.userId && !current.agentMediaKeys.includes(mediaPath)) {
1899
- try {
1900
- await sendAgentDmMedia({
1901
- agent: agentCfg,
1931
+ if (!isWsMode) {
1932
+ // Webhook 模式:Agent 私信兜底
1933
+ const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1934
+ const agentOk = Boolean(agentCfg);
1935
+ const fallbackFilename = filename || mediaPath.split("/").pop() || "attachment";
1936
+ if (agentCfg && current.userId && !current.agentMediaKeys.includes(mediaPath)) {
1937
+ try {
1938
+ await sendAgentDmMedia({
1939
+ agent: agentCfg,
1940
+ userId: current.userId,
1941
+ mediaUrlOrPath: mediaPath,
1942
+ contentType,
1943
+ filename: fallbackFilename,
1944
+ });
1945
+ streamStore.updateStream(streamId, (s) => {
1946
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
1947
+ });
1948
+ logVerbose(target, `fallback(error): 媒体处理失败后已通过 Agent 私信发送 user=${current.userId}`);
1949
+ } catch (sendErr) {
1950
+ target.runtime.error?.(`fallback(error): 媒体处理失败后的 Agent 私信发送也失败: ${String(sendErr)}`);
1951
+ }
1952
+ }
1953
+ if (!current.fallbackMode) {
1954
+ const prompt = buildFallbackPrompt({
1955
+ kind: "error",
1956
+ agentConfigured: agentOk,
1902
1957
  userId: current.userId,
1903
- mediaUrlOrPath: mediaPath,
1904
- contentType,
1905
1958
  filename: fallbackFilename,
1959
+ chatType: current.chatType,
1906
1960
  });
1907
1961
  streamStore.updateStream(streamId, (s) => {
1908
- s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
1962
+ s.fallbackMode = "error";
1963
+ s.finished = true;
1964
+ s.content = prompt;
1965
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1909
1966
  });
1910
- logVerbose(target, `fallback(error): 媒体处理失败后已通过 Agent 私信发送 user=${current.userId}`);
1911
- } catch (sendErr) {
1912
- target.runtime.error?.(`fallback(error): 媒体处理失败后的 Agent 私信发送也失败: ${String(sendErr)}`);
1913
- }
1914
- }
1915
- if (!current.fallbackMode) {
1916
- const prompt = buildFallbackPrompt({
1917
- kind: "error",
1918
- agentConfigured: agentOk,
1919
- userId: current.userId,
1920
- filename: fallbackFilename,
1921
- chatType: current.chatType,
1922
- });
1923
- streamStore.updateStream(streamId, (s) => {
1924
- s.fallbackMode = "error";
1925
- s.finished = true;
1926
- s.content = prompt;
1927
- s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1928
- });
1929
1967
  try {
1930
1968
  await sendBotFallbackPromptNow({ streamId, text: prompt });
1931
1969
  logVerbose(target, `fallback(error): 群内提示已推送`);
@@ -1933,6 +1971,9 @@ async function startAgentForStream(params: {
1933
1971
  target.runtime.error?.(`wecom bot fallback prompt push failed (error) streamId=${streamId}: ${String(pushErr)}`);
1934
1972
  }
1935
1973
  }
1974
+ } // end if (!isWsMode)
1975
+ // WS 模式:媒体处理失败时 continue 尝试下一个媒体,Webhook 模式 return 退出
1976
+ if (isWsMode) continue;
1936
1977
  return;
1937
1978
  }
1938
1979
  }
@@ -1982,7 +2023,7 @@ async function startAgentForStream(params: {
1982
2023
 
1983
2024
  // Timeout fallback final delivery (Agent DM): send once after the agent run completes.
1984
2025
  const finishedState = streamStore.getStream(streamId);
1985
- if (finishedState?.fallbackMode === "timeout" && !finishedState.finalDeliveredAt) {
2026
+ if (finishedState?.fallbackMode === "timeout" && !finishedState.finalDeliveredAt && !isWsMode) {
1986
2027
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1987
2028
  if (!agentCfg) {
1988
2029
  // Agent not configured - group prompt already explains the situation.
package/src/timeout.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 超时控制工具模块
3
+ *
4
+ * 为异步操作提供统一的超时保护机制
5
+ */
6
+
7
+ /**
8
+ * 为 Promise 添加超时保护
9
+ *
10
+ * @param promise - 原始 Promise
11
+ * @param timeoutMs - 超时时间(毫秒)
12
+ * @param message - 超时错误消息
13
+ * @returns 带超时保护的 Promise
14
+ */
15
+ export function withTimeout<T>(
16
+ promise: Promise<T>,
17
+ timeoutMs: number,
18
+ message?: string,
19
+ ): Promise<T> {
20
+ if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
21
+ return promise;
22
+ }
23
+
24
+ let timeoutId: ReturnType<typeof setTimeout>;
25
+
26
+ const timeoutPromise = new Promise<never>((_, reject) => {
27
+ timeoutId = setTimeout(() => {
28
+ reject(new TimeoutError(message ?? `Operation timed out after ${timeoutMs}ms`));
29
+ }, timeoutMs);
30
+ });
31
+
32
+ return Promise.race([promise, timeoutPromise]).finally(() => {
33
+ clearTimeout(timeoutId);
34
+ });
35
+ }
36
+
37
+ /**
38
+ * 超时错误类型
39
+ */
40
+ export class TimeoutError extends Error {
41
+ constructor(message: string) {
42
+ super(message);
43
+ this.name = "TimeoutError";
44
+ }
45
+ }
package/src/ws-adapter.ts CHANGED
@@ -32,6 +32,12 @@ import type { WecomRuntimeEnv, WecomWebhookTarget, StreamState } from "./monitor
32
32
  import { shouldProcessBotInboundMessage, buildInboundBody } from "./monitor.js";
33
33
  import { monitorState } from "./monitor/state.js";
34
34
  import { getWecomRuntime } from "./runtime.js";
35
+ import { fetchAndSaveMcpConfig } from "./mcp-config.js";
36
+
37
+ // ─── Constants ─────────────────────────────────────────────────────────
38
+
39
+ /** "思考中" 占位消息,让用户立即看到机器人正在响应 */
40
+ const THINKING_MESSAGE = "<think></think>";
35
41
 
36
42
  // ─── WSClient Instance Registry ────────────────────────────────────────
37
43
 
@@ -265,6 +271,16 @@ function setupMessageHandler(params: {
265
271
  s.wsMode = true;
266
272
  });
267
273
 
274
+ // 立即发送"思考中"占位消息,让用户看到即时反馈
275
+ const sendThinking = (target.account.config as any).sendThinkingMessage ?? true;
276
+ if (sendThinking) {
277
+ wsClient.replyStream(frame, streamId, THINKING_MESSAGE, false).catch((err) => {
278
+ target.runtime.error?.(
279
+ `[${accountId}] ws-thinking: failed to send thinking message: ${String(err)}`,
280
+ );
281
+ });
282
+ }
283
+
268
284
  // 注册流式回复监听器
269
285
  watchStreamReply({
270
286
  wsClient,
@@ -448,6 +464,8 @@ export function startWsClient(params: StartWsClientParams): () => void {
448
464
  });
449
465
  wsClient.on("authenticated", () => {
450
466
  runtime.log?.(`[${accountId}] ws: authenticated successfully`);
467
+ // 认证成功后拉取 MCP 配置(非阻塞,失败仅记日志)
468
+ void fetchAndSaveMcpConfig(wsClient, accountId, runtime);
451
469
  });
452
470
  wsClient.on("disconnected", (reason: string) => {
453
471
  runtime.log?.(`[${accountId}] ws: disconnected - ${reason}`);