@mocrane/wecom 2026.3.14 → 2026.3.20
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 +17 -13
- package/index.ts +37 -0
- package/package.json +4 -3
- package/skills/wecom-contact-lookup/SKILL.md +162 -0
- package/skills/wecom-doc-manager/SKILL.md +64 -0
- package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
- package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
- package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
- package/skills/wecom-edit-todo/SKILL.md +249 -0
- package/skills/wecom-get-todo-detail/SKILL.md +143 -0
- package/skills/wecom-get-todo-list/SKILL.md +127 -0
- package/skills/wecom-meeting-create/SKILL.md +158 -0
- package/skills/wecom-meeting-create/references/example-full.md +30 -0
- package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
- package/skills/wecom-meeting-create/references/example-security.md +22 -0
- package/skills/wecom-meeting-manage/SKILL.md +136 -0
- package/skills/wecom-meeting-query/SKILL.md +330 -0
- package/skills/wecom-preflight/SKILL.md +141 -0
- package/skills/wecom-schedule/SKILL.md +159 -0
- package/skills/wecom-schedule/references/api-check-availability.md +56 -0
- package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
- package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
- package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
- package/skills/wecom-schedule/references/ref-reminders.md +24 -0
- package/skills/wecom-smartsheet-data/SKILL.md +71 -0
- package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
- package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
- package/skills/wecom-smartsheet-schema/SKILL.md +92 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
- package/src/agent/handler.ts +105 -14
- package/src/mcp/index.ts +7 -0
- package/src/mcp/schema.ts +108 -0
- package/src/mcp/tool.ts +226 -0
- package/src/mcp/transport.ts +561 -0
- package/src/media/const.ts +24 -0
- package/src/media/index.ts +15 -0
- package/src/media/uploader.ts +240 -0
- package/src/monitor.ts +293 -12
- package/src/outbound.ts +116 -46
- package/src/types/index.ts +1 -0
- package/src/types/message.ts +10 -1
- package/src/ws-adapter.ts +4 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# 单元格值格式参考
|
|
2
|
+
|
|
3
|
+
在 `smartsheet_add_records` 和 `smartsheet_update_records` 中,`values` 的 key **必须是字段标题(field_title)**,不能使用字段 ID。
|
|
4
|
+
|
|
5
|
+
## 各字段类型的值格式
|
|
6
|
+
|
|
7
|
+
### 1. 文本 (FIELD_TYPE_TEXT)
|
|
8
|
+
|
|
9
|
+
**必须**使用数组格式,外层方括号不可省略:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
"字段标题": [{"type": "text", "text": "内容"}]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### 2. 数字 (NUMBER) / 货币 (CURRENCY) / 百分比 (PERCENTAGE) / 进度 (PROGRESS)
|
|
16
|
+
|
|
17
|
+
直接传数字:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
"金额": 100,
|
|
21
|
+
"完成率": 0.6,
|
|
22
|
+
"进度": 80
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 3. 复选框 (CHECKBOX)
|
|
26
|
+
|
|
27
|
+
直接传布尔值:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
"已完成": true
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 4. 单选 (SINGLE_SELECT) / 多选 (SELECT)
|
|
34
|
+
|
|
35
|
+
**必须**使用数组格式,不能直接传字符串:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
"优先级": [{"text": "高"}],
|
|
39
|
+
"标签": [{"text": "紧急"}, {"text": "重要"}]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
选项可附带 `id`(已存在选项)和 `style`(颜色 1-27)。
|
|
43
|
+
|
|
44
|
+
### 5. 日期时间 (DATE_TIME)
|
|
45
|
+
|
|
46
|
+
传日期时间字符串,系统自动按东八区转换:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
"截止日期": "2026-01-15 14:30:00",
|
|
50
|
+
"创建日期": "2026-01-15"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
支持格式:`YYYY-MM-DD HH:MM:SS`、`YYYY-MM-DD HH:MM`、`YYYY-MM-DD`
|
|
54
|
+
|
|
55
|
+
### 6. 手机号 (PHONE_NUMBER) / 邮箱 (EMAIL) / 条码 (BARCODE)
|
|
56
|
+
|
|
57
|
+
直接传字符串:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
"电话": "13800138000",
|
|
61
|
+
"邮箱": "test@example.com"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 7. 成员 (USER)
|
|
65
|
+
|
|
66
|
+
数组格式,需传 user_id。**user_id 不是姓名**,必须先通过 `wecom-contact-lookup` 技能查找目标人员的 `userid`,再填入此处。
|
|
67
|
+
|
|
68
|
+
具体步骤:先使用 `wecom_mcp` tool 调用 `wecom_mcp call contact get_userlist '{}'` 获取通讯录成员列表,在返回结果中按姓名/别名筛选出目标人员,取其 `userid` 值填入。
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
"负责人": [{"user_id": "zhangsan"}]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
多个成员:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
"负责人": [{"user_id": "zhangsan"}, {"user_id": "lisi"}]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 8. 超链接 (URL)
|
|
81
|
+
|
|
82
|
+
数组格式,目前仅支持一个链接:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
"参考链接": [{"type": "url", "text": "官网", "link": "https://example.com"}]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 9. 图片 (IMAGE)
|
|
89
|
+
|
|
90
|
+
数组格式:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
"封面": [{"image_url": "https://example.com/img.png"}]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 10. 地理位置 (LOCATION)
|
|
97
|
+
|
|
98
|
+
数组格式:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
"地点": [{"source_type": 1, "id": "地点ID", "latitude": "39.9", "longitude": "116.3", "title": "北京"}]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 完整添加记录示例
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"docid": "DOCID",
|
|
109
|
+
"sheet_id": "SHEETID",
|
|
110
|
+
"records": [{
|
|
111
|
+
"values": {
|
|
112
|
+
"任务名称": [{"type": "text", "text": "完成需求文档"}],
|
|
113
|
+
"优先级": [{"text": "高"}],
|
|
114
|
+
"截止日期": "2026-03-20",
|
|
115
|
+
"完成进度": 30,
|
|
116
|
+
"已完成": false
|
|
117
|
+
}
|
|
118
|
+
}]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wecom-smartsheet-schema
|
|
3
|
+
description: 企业微信智能表格结构管理技能。提供子表(Sheet)和字段(Field/列)的增删改查能力。适用场景:(1) 查询智能表格的子表列表 (2) 添加、更新、删除子表 (3) 查询子表的字段/列信息 (4) 添加、更新、删除字段/列。当用户需要管理智能表格的表结构、列定义、子表配置时触发此 Skill。支持通过 docid 或文档 URL 定位文档。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 企业微信智能表格结构管理
|
|
7
|
+
|
|
8
|
+
> `wecom_mcp` 是一个 MCP tool,所有操作通过调用该 tool 完成。
|
|
9
|
+
|
|
10
|
+
> ⚠️ **前置条件**:首次调用 `wecom_mcp` 前,必须按 `wecom-preflight` 技能执行前置条件检查,确保工具已加入白名单。
|
|
11
|
+
|
|
12
|
+
管理智能表格的子表和字段(列)结构。所有接口支持通过 `docid` 或 `url` 二选一定位文档。
|
|
13
|
+
|
|
14
|
+
## 调用方式
|
|
15
|
+
|
|
16
|
+
通过 `wecom_mcp` tool 调用,品类为 `doc`:
|
|
17
|
+
|
|
18
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc <tool_name> '<json_params>'` 调用指定技能
|
|
19
|
+
|
|
20
|
+
## 返回格式说明
|
|
21
|
+
|
|
22
|
+
所有接口返回 JSON 对象,包含以下公共字段:
|
|
23
|
+
|
|
24
|
+
| 字段 | 类型 | 说明 |
|
|
25
|
+
|------|------|------|
|
|
26
|
+
| `errcode` | integer | 返回码,`0` 表示成功,非 `0` 表示失败 |
|
|
27
|
+
| `errmsg` | string | 错误信息,成功时为 `"ok"` |
|
|
28
|
+
|
|
29
|
+
当 `errcode` 不为 `0` 时,说明接口调用失败,可重试 1 次;若仍失败,将 `errcode` 和 `errmsg` 展示给用户。
|
|
30
|
+
|
|
31
|
+
## 子表管理
|
|
32
|
+
|
|
33
|
+
### smartsheet_get_sheet
|
|
34
|
+
|
|
35
|
+
查询文档中所有子表信息,返回 sheet_id、title、类型等。
|
|
36
|
+
|
|
37
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_get_sheet '{"docid": "DOCID"}'`
|
|
38
|
+
|
|
39
|
+
### smartsheet_add_sheet
|
|
40
|
+
|
|
41
|
+
添加空子表。新子表不含视图、记录和字段,需通过其他接口补充。
|
|
42
|
+
|
|
43
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_add_sheet '{"docid": "DOCID", "properties": {"title": "新子表"}}'`
|
|
44
|
+
|
|
45
|
+
**注意**:新建智能表格文档默认已含一个子表,仅需多个子表时调用。
|
|
46
|
+
|
|
47
|
+
### smartsheet_update_sheet
|
|
48
|
+
|
|
49
|
+
修改子表标题。需提供 sheet_id 和新 title。
|
|
50
|
+
|
|
51
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_update_sheet '{"docid": "DOCID", "sheet_id": "SHEETID", "title": "新标题"}'`
|
|
52
|
+
|
|
53
|
+
### smartsheet_delete_sheet
|
|
54
|
+
|
|
55
|
+
永久删除子表,**操作不可逆**。
|
|
56
|
+
|
|
57
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_delete_sheet '{"docid": "DOCID", "sheet_id": "SHEETID"}'`
|
|
58
|
+
|
|
59
|
+
## 字段管理
|
|
60
|
+
|
|
61
|
+
### smartsheet_get_fields
|
|
62
|
+
|
|
63
|
+
查询子表的所有字段信息,返回 field_id、field_title、field_type。
|
|
64
|
+
|
|
65
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_get_fields '{"docid": "DOCID", "sheet_id": "SHEETID"}'`
|
|
66
|
+
|
|
67
|
+
### smartsheet_add_fields
|
|
68
|
+
|
|
69
|
+
向子表添加一个或多个字段。单个子表最多 150 个字段。
|
|
70
|
+
|
|
71
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_add_fields '{"docid": "DOCID", "sheet_id": "SHEETID", "fields": [{"field_title": "任务名称", "field_type": "FIELD_TYPE_TEXT"}]}'`
|
|
72
|
+
|
|
73
|
+
支持的字段类型参见 [字段类型参考](references/field-types.md)。
|
|
74
|
+
|
|
75
|
+
### smartsheet_update_fields
|
|
76
|
+
|
|
77
|
+
更新字段标题。**只能改名,不能改类型**(field_type 必须传原始类型)。field_title 不能更新为原值。
|
|
78
|
+
|
|
79
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_update_fields '{"docid": "DOCID", "sheet_id": "SHEETID", "fields": [{"field_id": "FIELDID", "field_title": "新标题", "field_type": "FIELD_TYPE_TEXT"}]}'`
|
|
80
|
+
|
|
81
|
+
### smartsheet_delete_fields
|
|
82
|
+
|
|
83
|
+
删除一列或多列字段,**操作不可逆**。field_id 可通过 `smartsheet_get_fields` 获取。
|
|
84
|
+
|
|
85
|
+
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_delete_fields '{"docid": "DOCID", "sheet_id": "SHEETID", "field_ids": ["FIELDID"]}'`
|
|
86
|
+
|
|
87
|
+
## 典型工作流
|
|
88
|
+
|
|
89
|
+
1. **了解表结构** → 使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_get_sheet` → 使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_get_fields`
|
|
90
|
+
2. **创建表结构** → `smartsheet_add_sheet` 添加子表 → `smartsheet_add_fields` 定义列
|
|
91
|
+
3. **修改表结构** → `smartsheet_update_fields` 改列名 / `smartsheet_delete_fields` 删列
|
|
92
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# 智能表格字段类型参考
|
|
2
|
+
|
|
3
|
+
## 支持的字段类型
|
|
4
|
+
|
|
5
|
+
| 类型枚举值 | 说明 | 适用场景 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `FIELD_TYPE_TEXT` | 文本 | 名称、标题、描述、负责人姓名等自由文本 |
|
|
8
|
+
| `FIELD_TYPE_NUMBER` | 数字 | 金额、工时、数量等数值 |
|
|
9
|
+
| `FIELD_TYPE_CHECKBOX` | 复选框 | 是否完成等布尔值 |
|
|
10
|
+
| `FIELD_TYPE_DATE_TIME` | 日期时间 | 截止日期、创建时间等 |
|
|
11
|
+
| `FIELD_TYPE_IMAGE` | 图片 | 附件图片 |
|
|
12
|
+
| `FIELD_TYPE_USER` | 用户/成员 | 需传入 user_id;仅在明确知道成员 ID 时使用,若只有姓名应用 TEXT |
|
|
13
|
+
| `FIELD_TYPE_URL` | 链接 | 超链接 |
|
|
14
|
+
| `FIELD_TYPE_SELECT` | 多选 | 标签、分类等可多选的选项 |
|
|
15
|
+
| `FIELD_TYPE_PROGRESS` | 进度 | 完成进度(0-100 整数) |
|
|
16
|
+
| `FIELD_TYPE_PHONE_NUMBER` | 手机号 | 联系电话 |
|
|
17
|
+
| `FIELD_TYPE_EMAIL` | 邮箱 | 电子邮件 |
|
|
18
|
+
| `FIELD_TYPE_SINGLE_SELECT` | 单选 | 状态、优先级、严重程度等有固定选项的字段 |
|
|
19
|
+
| `FIELD_TYPE_LOCATION` | 位置 | 地理位置 |
|
|
20
|
+
| `FIELD_TYPE_CURRENCY` | 货币 | 货币金额 |
|
|
21
|
+
| `FIELD_TYPE_PERCENTAGE` | 百分比 | 比率类数值(完成率、转化率) |
|
|
22
|
+
| `FIELD_TYPE_BARCODE` | 条码 | 条形码/二维码 |
|
|
23
|
+
|
|
24
|
+
## 添加字段示例
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"docid": "DOCID",
|
|
29
|
+
"sheet_id": "SHEETID",
|
|
30
|
+
"fields": [
|
|
31
|
+
{ "field_title": "任务名称", "field_type": "FIELD_TYPE_TEXT" },
|
|
32
|
+
{ "field_title": "优先级", "field_type": "FIELD_TYPE_SINGLE_SELECT" },
|
|
33
|
+
{ "field_title": "截止日期", "field_type": "FIELD_TYPE_DATE_TIME" },
|
|
34
|
+
{ "field_title": "完成进度", "field_type": "FIELD_TYPE_PROGRESS" }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 更新字段注意事项
|
|
40
|
+
|
|
41
|
+
- `smartsheet_update_fields` **只能更新字段标题**,不能更改字段类型
|
|
42
|
+
- `field_type` 必须传字段当前的原始类型
|
|
43
|
+
- `field_title` 不能更新为原值(即不能传与当前相同的标题)
|
package/src/agent/handler.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
extractFileName,
|
|
19
19
|
extractAgentId,
|
|
20
20
|
} from "../shared/xml-parser.js";
|
|
21
|
-
import { sendText, downloadMedia } from "./api-client.js";
|
|
21
|
+
import { sendText, downloadMedia, uploadMedia, sendMedia as sendAgentMedia } from "./api-client.js";
|
|
22
22
|
import { getWecomRuntime } from "../runtime.js";
|
|
23
23
|
import type { WecomAgentInboundMessage } from "../types/index.js";
|
|
24
24
|
import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
|
|
@@ -518,7 +518,9 @@ async function processAgentMessage(params: {
|
|
|
518
518
|
CommandBody: finalContent,
|
|
519
519
|
Attachments: attachments.length > 0 ? attachments : undefined,
|
|
520
520
|
From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
|
|
521
|
-
|
|
521
|
+
// 使用 wecom-agent: 前缀标记 Agent 会话,确保 outbound 路由不会混入 Bot WS 发送路径。
|
|
522
|
+
// resolveWecomTarget 已支持剥离 wecom-agent: 前缀(target.ts L41),解析结果不变。
|
|
523
|
+
To: `wecom-agent:${fromUser}`,
|
|
522
524
|
SessionKey: route.sessionKey,
|
|
523
525
|
AccountId: route.accountId,
|
|
524
526
|
ChatType: isGroup ? "group" : "direct",
|
|
@@ -553,18 +555,107 @@ async function processAgentMessage(params: {
|
|
|
553
555
|
ctx: ctxPayload,
|
|
554
556
|
cfg: config,
|
|
555
557
|
dispatcherOptions: {
|
|
556
|
-
deliver: async (payload: { text?: string }, info: { kind: string }) => {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
558
|
+
deliver: async (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, info: { kind: string }) => {
|
|
559
|
+
let text = payload.text ?? "";
|
|
560
|
+
|
|
561
|
+
// ── 1. 解析 MEDIA: 指令(兜底处理核心 splitMediaFromOutput 未覆盖的边界情况)──
|
|
562
|
+
const mediaDirectivePaths: string[] = [];
|
|
563
|
+
const mediaDirectiveRe = /^MEDIA:\s*`?([^\n`]+?)`?\s*$/gm;
|
|
564
|
+
let _mdMatch: RegExpExecArray | null;
|
|
565
|
+
while ((_mdMatch = mediaDirectiveRe.exec(text)) !== null) {
|
|
566
|
+
let p = (_mdMatch[1] ?? "").trim();
|
|
567
|
+
if (!p) continue;
|
|
568
|
+
if (p.startsWith("~/") || p === "~") {
|
|
569
|
+
const home = process.env.HOME || "/root";
|
|
570
|
+
p = p.replace(/^~/, home);
|
|
571
|
+
}
|
|
572
|
+
if (!mediaDirectivePaths.includes(p)) mediaDirectivePaths.push(p);
|
|
573
|
+
}
|
|
574
|
+
// 从回复文本中移除 MEDIA: 指令行
|
|
575
|
+
if (mediaDirectivePaths.length > 0) {
|
|
576
|
+
text = text.replace(/^MEDIA:\s*`?[^\n`]+?`?\s*$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── 2. 合并所有媒体 URL ──
|
|
580
|
+
const mediaUrls = Array.from(new Set([
|
|
581
|
+
...(payload.mediaUrls || []),
|
|
582
|
+
...(payload.mediaUrl ? [payload.mediaUrl] : []),
|
|
583
|
+
...mediaDirectivePaths,
|
|
584
|
+
]));
|
|
585
|
+
|
|
586
|
+
// ── 3. 发送文本部分 ──
|
|
587
|
+
if (text.trim()) {
|
|
588
|
+
try {
|
|
589
|
+
await sendText({ agent, toUser: fromUser, chatId: undefined, text });
|
|
590
|
+
log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser} (textLen=${text.length})`);
|
|
591
|
+
} catch (err: unknown) {
|
|
592
|
+
const message = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}` : String(err);
|
|
593
|
+
error?.(`[wecom-agent] reply failed: ${message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── 4. 逐个发送媒体文件(通过 Agent API 上传 + 发送)──
|
|
598
|
+
for (const mediaPath of mediaUrls) {
|
|
599
|
+
try {
|
|
600
|
+
const isRemoteUrl = /^https?:\/\//i.test(mediaPath);
|
|
601
|
+
let buf: Buffer;
|
|
602
|
+
let contentType: string;
|
|
603
|
+
let filename: string;
|
|
604
|
+
|
|
605
|
+
if (isRemoteUrl) {
|
|
606
|
+
const res = await fetch(mediaPath, { signal: AbortSignal.timeout(30_000) });
|
|
607
|
+
if (!res.ok) throw new Error(`download failed: ${res.status}`);
|
|
608
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
609
|
+
contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
610
|
+
filename = new URL(mediaPath).pathname.split("/").pop() || "media";
|
|
611
|
+
} else {
|
|
612
|
+
const fs = await import("node:fs/promises");
|
|
613
|
+
const pathModule = await import("node:path");
|
|
614
|
+
buf = await fs.readFile(mediaPath);
|
|
615
|
+
filename = pathModule.basename(mediaPath);
|
|
616
|
+
const ext = pathModule.extname(mediaPath).slice(1).toLowerCase();
|
|
617
|
+
const MIME_MAP: Record<string, string> = {
|
|
618
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
|
|
619
|
+
webp: "image/webp", mp3: "audio/mpeg", wav: "audio/wav", amr: "audio/amr",
|
|
620
|
+
mp4: "video/mp4", mov: "video/quicktime", pdf: "application/pdf",
|
|
621
|
+
doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
622
|
+
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
623
|
+
txt: "text/plain", csv: "text/csv", json: "application/json", zip: "application/zip",
|
|
624
|
+
};
|
|
625
|
+
contentType = MIME_MAP[ext] ?? "application/octet-stream";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// 确定企微媒体类型
|
|
629
|
+
let mediaType: "image" | "voice" | "video" | "file" = "file";
|
|
630
|
+
if (contentType.startsWith("image/")) mediaType = "image";
|
|
631
|
+
else if (contentType.startsWith("audio/")) mediaType = "voice";
|
|
632
|
+
else if (contentType.startsWith("video/")) mediaType = "video";
|
|
633
|
+
|
|
634
|
+
log?.(`[wecom-agent] uploading media: ${filename} (${mediaType}, ${contentType}, ${buf.length} bytes)`);
|
|
635
|
+
|
|
636
|
+
const mediaId = await uploadMedia({ agent, type: mediaType, buffer: buf, filename });
|
|
637
|
+
|
|
638
|
+
await sendAgentMedia({
|
|
639
|
+
agent,
|
|
640
|
+
toUser: fromUser,
|
|
641
|
+
mediaId,
|
|
642
|
+
mediaType,
|
|
643
|
+
...(mediaType === "video" ? { title: filename, description: "" } : {}),
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
log?.(`[wecom-agent] media sent (${info.kind}) to ${fromUser}: ${filename} (${mediaType})`);
|
|
647
|
+
} catch (err: unknown) {
|
|
648
|
+
const message = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}` : String(err);
|
|
649
|
+
error?.(`[wecom-agent] media send failed: ${mediaPath}: ${message}`);
|
|
650
|
+
// 降级:发文本通知用户
|
|
651
|
+
try {
|
|
652
|
+
await sendText({ agent, toUser: fromUser, chatId: undefined, text: `⚠️ 文件发送失败: ${mediaPath.split("/").pop() || mediaPath}\n${message}` });
|
|
653
|
+
} catch { /* ignore */ }
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// 如果既没有文本也没有媒体,不做任何事(防止空回复)
|
|
658
|
+
},
|
|
568
659
|
onError: (err: unknown, info: { kind: string }) => {
|
|
569
660
|
error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
|
|
570
661
|
},
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Schema 清洗模块
|
|
3
|
+
*
|
|
4
|
+
* 负责内联 $ref/$defs 引用并移除 Gemini 不支持的 JSON Schema 关键词,
|
|
5
|
+
* 防止 Gemini 模型解析 function response 时报 400 错误。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Gemini 不支持的 JSON Schema 关键词 */
|
|
9
|
+
const GEMINI_UNSUPPORTED_KEYWORDS = new Set([
|
|
10
|
+
"patternProperties", "additionalProperties", "$schema", "$id", "$ref", "$defs",
|
|
11
|
+
"definitions", "examples", "minLength", "maxLength", "minimum", "maximum",
|
|
12
|
+
"multipleOf", "pattern", "format", "minItems", "maxItems", "uniqueItems",
|
|
13
|
+
"minProperties", "maxProperties",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 清洗 JSON Schema,内联 $ref 引用并移除 Gemini 不支持的关键词,
|
|
18
|
+
* 防止 Gemini 模型解析 function response 时报 400 错误。
|
|
19
|
+
*/
|
|
20
|
+
export function cleanSchemaForGemini(schema: unknown): unknown {
|
|
21
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
22
|
+
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
|
|
23
|
+
|
|
24
|
+
const obj = schema as Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
// 收集 $defs/definitions 用于后续 $ref 内联解析
|
|
27
|
+
const defs: Record<string, unknown> = {
|
|
28
|
+
...(obj.$defs && typeof obj.$defs === "object" ? obj.$defs as Record<string, unknown> : {}),
|
|
29
|
+
...(obj.definitions && typeof obj.definitions === "object" ? obj.definitions as Record<string, unknown> : {}),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return cleanWithDefs(obj, defs, new Set());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cleanWithDefs(
|
|
36
|
+
schema: unknown,
|
|
37
|
+
defs: Record<string, unknown>,
|
|
38
|
+
refStack: Set<string>,
|
|
39
|
+
): unknown {
|
|
40
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
41
|
+
if (Array.isArray(schema)) return schema.map((item) => cleanWithDefs(item, defs, refStack));
|
|
42
|
+
|
|
43
|
+
const obj = schema as Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
// 合并当前层级的 $defs/definitions 到 defs 中
|
|
46
|
+
if (obj.$defs && typeof obj.$defs === "object") {
|
|
47
|
+
Object.assign(defs, obj.$defs as Record<string, unknown>);
|
|
48
|
+
}
|
|
49
|
+
if (obj.definitions && typeof obj.definitions === "object") {
|
|
50
|
+
Object.assign(defs, obj.definitions as Record<string, unknown>);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 处理 $ref 引用:尝试内联解析
|
|
54
|
+
if (typeof obj.$ref === "string") {
|
|
55
|
+
const ref = obj.$ref;
|
|
56
|
+
if (refStack.has(ref)) return {}; // 防止循环引用
|
|
57
|
+
|
|
58
|
+
const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
|
|
59
|
+
if (match && match[1] && defs[match[1]]) {
|
|
60
|
+
const nextStack = new Set(refStack);
|
|
61
|
+
nextStack.add(ref);
|
|
62
|
+
return cleanWithDefs(defs[match[1]], defs, nextStack);
|
|
63
|
+
}
|
|
64
|
+
return {}; // 无法解析的 $ref,返回空对象
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const cleaned: Record<string, unknown> = {};
|
|
68
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
69
|
+
if (GEMINI_UNSUPPORTED_KEYWORDS.has(key)) continue;
|
|
70
|
+
|
|
71
|
+
if (key === "const") {
|
|
72
|
+
cleaned.enum = [value];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
77
|
+
cleaned[key] = Object.fromEntries(
|
|
78
|
+
Object.entries(value as Record<string, unknown>).map(([k, v]) => [
|
|
79
|
+
k, cleanWithDefs(v, defs, refStack),
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
} else if (key === "items" && value) {
|
|
83
|
+
cleaned[key] = Array.isArray(value)
|
|
84
|
+
? value.map((item) => cleanWithDefs(item, defs, refStack))
|
|
85
|
+
: cleanWithDefs(value, defs, refStack);
|
|
86
|
+
} else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
|
|
87
|
+
// 过滤掉 null 类型的变体
|
|
88
|
+
const nonNull = value.filter((v) => {
|
|
89
|
+
if (!v || typeof v !== "object") return true;
|
|
90
|
+
const r = v as Record<string, unknown>;
|
|
91
|
+
return r.type !== "null";
|
|
92
|
+
});
|
|
93
|
+
if (nonNull.length === 1) {
|
|
94
|
+
// 只剩一个变体时直接内联
|
|
95
|
+
const single = cleanWithDefs(nonNull[0], defs, refStack);
|
|
96
|
+
if (single && typeof single === "object" && !Array.isArray(single)) {
|
|
97
|
+
Object.assign(cleaned, single as Record<string, unknown>);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
cleaned[key] = nonNull.map((v) => cleanWithDefs(v, defs, refStack));
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
cleaned[key] = value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return cleaned;
|
|
108
|
+
}
|