@wecom/wecom-openclaw-plugin 2026.3.30-beta.2 → 2026.3.31-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.
Files changed (99) hide show
  1. package/README.md +251 -4
  2. package/dist/index.d.ts +2 -4
  3. package/dist/index.js +113 -0
  4. package/dist/src/accounts.d.ts +57 -0
  5. package/dist/src/accounts.js +222 -0
  6. package/dist/src/agent/api-client.d.ts +94 -0
  7. package/dist/src/agent/api-client.js +292 -0
  8. package/dist/src/agent/handler.d.ts +63 -0
  9. package/dist/src/agent/handler.js +532 -0
  10. package/dist/src/agent/index.d.ts +5 -0
  11. package/dist/src/agent/index.js +5 -0
  12. package/dist/src/agent/webhook.d.ts +23 -0
  13. package/dist/src/agent/webhook.js +226 -0
  14. package/dist/src/agent/xml.d.ts +21 -0
  15. package/dist/src/agent/xml.js +41 -0
  16. package/dist/src/channel.js +564 -0
  17. package/dist/src/chat-queue.d.ts +31 -0
  18. package/dist/src/chat-queue.js +45 -0
  19. package/dist/src/const.d.ts +46 -0
  20. package/dist/src/const.js +157 -0
  21. package/dist/src/dm-policy.js +84 -0
  22. package/dist/src/group-policy.js +126 -0
  23. package/dist/src/http.d.ts +26 -0
  24. package/dist/src/http.js +93 -0
  25. package/dist/src/interface.d.ts +0 -2
  26. package/dist/src/interface.js +4 -0
  27. package/dist/src/mcp/index.js +6 -0
  28. package/dist/src/mcp/interceptors/biz-error.js +64 -0
  29. package/dist/src/mcp/interceptors/index.js +66 -0
  30. package/dist/src/mcp/interceptors/msg-media.js +133 -0
  31. package/dist/src/mcp/interceptors/smartpage-create.js +132 -0
  32. package/dist/src/mcp/interceptors/smartpage-export.js +87 -0
  33. package/dist/src/mcp/interceptors/types.js +7 -0
  34. package/dist/src/mcp/schema.js +99 -0
  35. package/dist/src/mcp/tool.js +202 -0
  36. package/dist/src/mcp/transport.js +410 -0
  37. package/dist/src/media-handler.js +122 -0
  38. package/dist/src/media-uploader.js +333 -0
  39. package/dist/src/message-parser.js +129 -0
  40. package/dist/src/message-sender.d.ts +18 -0
  41. package/dist/src/message-sender.js +120 -0
  42. package/dist/src/monitor.js +832 -0
  43. package/dist/src/onboarding.js +175 -0
  44. package/dist/src/openclaw-compat.d.ts +49 -0
  45. package/dist/src/openclaw-compat.js +487 -0
  46. package/dist/src/reqid-store.js +83 -0
  47. package/dist/src/runtime.js +10 -0
  48. package/dist/src/shared/command-auth.d.ts +23 -0
  49. package/dist/src/shared/command-auth.js +73 -0
  50. package/dist/src/shared/xml-parser.d.ts +46 -0
  51. package/dist/src/shared/xml-parser.js +210 -0
  52. package/dist/src/state-manager.js +216 -0
  53. package/dist/src/target.d.ts +35 -0
  54. package/dist/src/target.js +67 -0
  55. package/dist/src/template-card-manager.d.ts +55 -0
  56. package/dist/src/template-card-manager.js +205 -0
  57. package/dist/src/template-card-parser.js +647 -0
  58. package/dist/src/timeout.js +36 -0
  59. package/dist/src/types/account.d.ts +29 -0
  60. package/dist/src/types/account.js +4 -0
  61. package/dist/src/types/config.d.ts +80 -0
  62. package/dist/src/types/config.js +7 -0
  63. package/dist/src/types/constants.d.ts +42 -0
  64. package/dist/src/types/constants.js +42 -0
  65. package/dist/src/types/index.d.ts +7 -0
  66. package/dist/src/types/index.js +5 -0
  67. package/dist/src/types/message.d.ts +238 -0
  68. package/dist/src/types/message.js +5 -0
  69. package/dist/src/utils.d.ts +39 -0
  70. package/dist/src/utils.js +70 -0
  71. package/dist/src/version.d.ts +1 -1
  72. package/dist/src/version.js +24 -0
  73. package/dist/src/webhook/command-auth.d.ts +47 -0
  74. package/dist/src/webhook/command-auth.js +96 -0
  75. package/dist/src/webhook/gateway.d.ts +36 -0
  76. package/dist/src/webhook/gateway.js +227 -0
  77. package/dist/src/webhook/handler.d.ts +18 -0
  78. package/dist/src/webhook/handler.js +420 -0
  79. package/dist/src/webhook/helpers.d.ts +156 -0
  80. package/dist/src/webhook/helpers.js +833 -0
  81. package/dist/src/webhook/http.d.ts +26 -0
  82. package/dist/src/webhook/http.js +94 -0
  83. package/dist/src/webhook/index.d.ts +11 -0
  84. package/dist/src/webhook/index.js +14 -0
  85. package/dist/src/webhook/media.d.ts +29 -0
  86. package/dist/src/webhook/media.js +88 -0
  87. package/dist/src/webhook/monitor.d.ts +59 -0
  88. package/dist/src/webhook/monitor.js +1147 -0
  89. package/dist/src/webhook/state.d.ts +220 -0
  90. package/dist/src/webhook/state.js +491 -0
  91. package/dist/src/webhook/target.d.ts +41 -0
  92. package/dist/src/webhook/target.js +132 -0
  93. package/dist/src/webhook/types.d.ts +347 -0
  94. package/dist/src/webhook/types.js +33 -0
  95. package/package.json +15 -7
  96. package/dist/index.cjs.js +0 -4953
  97. package/dist/index.cjs.js.map +0 -1
  98. package/dist/index.esm.js +0 -4926
  99. package/dist/index.esm.js.map +0 -1
@@ -0,0 +1,66 @@
1
+ /**
2
+ * MCP call 拦截器注册表与调度入口
3
+ *
4
+ * 所有 call 拦截器在此注册,按注册顺序执行。
5
+ * 新增拦截器只需:
6
+ * 1. 在 interceptors/ 目录下新建文件,实现 CallInterceptor 接口
7
+ * 2. 在下方 interceptors 数组中注册
8
+ *
9
+ * tool.ts 的 handleCall 无需任何改动。
10
+ */
11
+ import { bizErrorInterceptor } from "./biz-error.js";
12
+ import { mediaInterceptor } from "./msg-media.js";
13
+ import { smartpageCreateInterceptor } from "./smartpage-create.js";
14
+ import { smartpageExportInterceptor } from "./smartpage-export.js";
15
+ // ============================================================================
16
+ // 拦截器注册表(按注册顺序执行)
17
+ // ============================================================================
18
+ const interceptors = [
19
+ bizErrorInterceptor, // 业务错误码检查(所有 call 生效)
20
+ mediaInterceptor, // get_msg_media base64 拦截
21
+ smartpageCreateInterceptor, // smartpage_create 本地文件读取
22
+ smartpageExportInterceptor, // smartpage_get_export_result content → 本地文件
23
+ ];
24
+ /**
25
+ * 收集匹配的 beforeCall 配置,合并后返回
26
+ *
27
+ * 合并策略:
28
+ * - timeoutMs: 取所有拦截器返回值中的最大值
29
+ * - args: 后注册的拦截器覆盖前者(一般同一调用只有一个拦截器会返回 args)
30
+ */
31
+ export async function resolveBeforeCall(ctx) {
32
+ let mergedTimeoutMs;
33
+ let mergedArgs;
34
+ for (const interceptor of interceptors) {
35
+ if (!interceptor.match(ctx) || !interceptor.beforeCall)
36
+ continue;
37
+ const opts = await interceptor.beforeCall(ctx);
38
+ if (opts?.timeoutMs !== undefined) {
39
+ mergedTimeoutMs = mergedTimeoutMs === undefined
40
+ ? opts.timeoutMs
41
+ : Math.max(mergedTimeoutMs, opts.timeoutMs);
42
+ }
43
+ if (opts?.args !== undefined) {
44
+ mergedArgs = opts.args;
45
+ }
46
+ }
47
+ return {
48
+ options: mergedTimeoutMs !== undefined ? { timeoutMs: mergedTimeoutMs } : undefined,
49
+ args: mergedArgs,
50
+ };
51
+ }
52
+ /**
53
+ * 依次执行匹配的 afterCall 拦截器,管道式传递 result
54
+ *
55
+ * 前一个拦截器的返回值作为下一个拦截器的输入。
56
+ * 拦截器若不需要修改 result,应原样返回。
57
+ */
58
+ export async function runAfterCall(ctx, result) {
59
+ let current = result;
60
+ for (const interceptor of interceptors) {
61
+ if (!interceptor.match(ctx) || !interceptor.afterCall)
62
+ continue;
63
+ current = await interceptor.afterCall(ctx, current);
64
+ }
65
+ return current;
66
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * get_msg_media 响应拦截器
3
+ *
4
+ * 核心逻辑:
5
+ * 1. beforeCall: 设置延长的超时时间(120s),因为 base64 数据可达 ~27MB
6
+ * 2. afterCall: 从 MCP result 的 content[].text 中提取 base64_data,
7
+ * 解码为 Buffer 并通过 saveMediaBuffer 保存到本地媒体目录,
8
+ * 替换响应中的 base64_data 为 local_path,避免大模型被 base64 数据消耗 token
9
+ */
10
+ import * as fs from "node:fs/promises";
11
+ import * as path from "node:path";
12
+ import { MEDIA_DOWNLOAD_TIMEOUT_MS } from "../transport.js";
13
+ import { getWeComRuntime } from "../../runtime.js";
14
+ import { detectMime } from "../../openclaw-compat.js";
15
+ // ============================================================================
16
+ // 拦截器实现
17
+ // ============================================================================
18
+ export const mediaInterceptor = {
19
+ name: "media",
20
+ /** 仅对 get_msg_media 方法生效 */
21
+ match: (ctx) => ctx.method === "get_msg_media",
22
+ /** 设置延长的超时时间 */
23
+ beforeCall() {
24
+ return { timeoutMs: MEDIA_DOWNLOAD_TIMEOUT_MS };
25
+ },
26
+ /** 拦截响应:base64 → 本地文件 */
27
+ async afterCall(ctx, result) {
28
+ return interceptMediaResponse(result);
29
+ },
30
+ };
31
+ // ============================================================================
32
+ // 内部实现
33
+ // ============================================================================
34
+ /**
35
+ * 拦截 get_msg_media 的 MCP 响应
36
+ *
37
+ * 1. 从 MCP result 的 content[].text 中提取业务 JSON
38
+ * 2. 提取 media_item.base64_data,解码为 Buffer
39
+ * 3. 通过 openclaw SDK 的 saveMediaBuffer 保存到本地媒体目录
40
+ * 4. 替换响应:移除 base64_data,加入 local_path
41
+ *
42
+ * 这样大模型只看到轻量的文件路径信息,不会被 base64 数据消耗 token。
43
+ */
44
+ async function interceptMediaResponse(result) {
45
+ const t0 = performance.now();
46
+ // 1. 提取 MCP result 中的 content 数组
47
+ const content = result?.content;
48
+ if (!Array.isArray(content))
49
+ return result;
50
+ const textItem = content.find((c) => c.type === "text" && typeof c.text === "string");
51
+ if (!textItem)
52
+ return result;
53
+ // 2. 解析业务 JSON
54
+ let bizData;
55
+ try {
56
+ bizData = JSON.parse(textItem.text);
57
+ }
58
+ catch {
59
+ // 非 JSON 格式,原样返回
60
+ return result;
61
+ }
62
+ // 3. 校验业务响应:errcode !== 0 或无 media_item 时原样返回
63
+ if (bizData.errcode !== 0)
64
+ return result;
65
+ const mediaItem = bizData.media_item;
66
+ if (!mediaItem || typeof mediaItem.base64_data !== "string")
67
+ return result;
68
+ const base64Data = mediaItem.base64_data;
69
+ const mediaName = mediaItem.name;
70
+ const mediaType = mediaItem.type;
71
+ const mediaId = mediaItem.media_id;
72
+ const tParsed = performance.now();
73
+ // 4. 解码 base64 → Buffer
74
+ const buffer = Buffer.from(base64Data, "base64");
75
+ const tDecoded = performance.now();
76
+ // 5. 检测 contentType,并通过 saveMediaBuffer 保存到本地媒体目录
77
+ const contentType = await detectMime({ buffer, filePath: mediaName }) ?? "application/octet-stream";
78
+ const tMimeDetected = performance.now();
79
+ // 企业微信聊天记录附件可达 20MB(文件消息上限),
80
+ // 而 saveMediaBuffer 默认 maxBytes 为 5MB(针对 outbound 场景),
81
+ // 此处显式放宽到 20MB 以支持大文件下载。
82
+ const INBOUND_MAX_BYTES = 20 * 1024 * 1024; // 20MB
83
+ const core = getWeComRuntime();
84
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", INBOUND_MAX_BYTES, // maxBytes: 放宽到 20MB,匹配企业微信文件消息上限
85
+ mediaName);
86
+ // 5.1 补偿:核心库 EXT_BY_MIME 可能缺少某些格式映射(如 audio/amr),
87
+ // 导致保存的文件没有后缀。此处检测并修复。
88
+ const MIME_EXT_PATCH = {
89
+ "audio/amr": ".amr",
90
+ };
91
+ const patchExt = MIME_EXT_PATCH[contentType];
92
+ if (patchExt && !path.extname(saved.path)) {
93
+ const newPath = saved.path + patchExt;
94
+ try {
95
+ await fs.rename(saved.path, newPath);
96
+ saved.path = newPath;
97
+ }
98
+ catch {
99
+ // rename 失败不影响主流程,文件仍可用
100
+ }
101
+ }
102
+ const tSaved = performance.now();
103
+ // 6. 构造精简响应,移除 base64_data,加入本地路径
104
+ const newBizData = {
105
+ errcode: 0,
106
+ errmsg: "ok",
107
+ media_item: {
108
+ media_id: mediaId,
109
+ name: mediaName ?? saved.path.split("/").pop(),
110
+ type: mediaType,
111
+ local_path: saved.path,
112
+ size: buffer.length,
113
+ content_type: saved.contentType,
114
+ },
115
+ };
116
+ const tEnd = performance.now();
117
+ // 耗时日志:各环节耗时(ms)
118
+ console.log(`[mcp] get_msg_media 拦截成功: media_id=${mediaId ?? "unknown"}, ` +
119
+ `type=${mediaType ?? "unknown"}, size=${buffer.length}, saved=${saved.path}\n` +
120
+ ` ⏱ 耗时明细 (总 ${(tEnd - t0).toFixed(1)}ms):\n` +
121
+ ` 解析响应 JSON: ${(tParsed - t0).toFixed(1)}ms\n` +
122
+ ` base64 解码: ${(tDecoded - tParsed).toFixed(1)}ms (${(base64Data.length / 1024).toFixed(0)}KB base64 → ${(buffer.length / 1024).toFixed(0)}KB buffer)\n` +
123
+ ` MIME 检测: ${(tMimeDetected - tDecoded).toFixed(1)}ms (${contentType})\n` +
124
+ ` saveMediaBuffer: ${(tSaved - tMimeDetected).toFixed(1)}ms\n` +
125
+ ` 构造响应: ${(tEnd - tSaved).toFixed(1)}ms`);
126
+ // 7. 返回修改后的 MCP result 结构
127
+ return {
128
+ content: [{
129
+ type: "text",
130
+ text: JSON.stringify(newBizData),
131
+ }],
132
+ };
133
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * smartpage_create 请求拦截器
3
+ *
4
+ * 核心逻辑:
5
+ * smartpage_create 的 pages 数组中,每个 page 可能包含 page_filepath 字段
6
+ * (指向本地 markdown 文件),用于避免在命令行传递大段文本内容。
7
+ * 本拦截器在 beforeCall 阶段遍历 pages 数组,逐个读取 page_filepath
8
+ * 指向的本地文件内容,填入 page_content 字段,并移除 page_filepath。
9
+ *
10
+ * 入参约定:
11
+ * wecom_mcp call doc smartpage_create '{
12
+ * "title": "主页标题",
13
+ * "pages": [
14
+ * {"page_title": "页面1", "page_filepath": "/tmp/page1.md", "content_type": "markdown"},
15
+ * {"page_title": "页面2", "page_filepath": "/tmp/page2.md", "content_type": "markdown"}
16
+ * ]
17
+ * }'
18
+ *
19
+ * 拦截器行为:
20
+ * 1. 检测 args.pages 数组
21
+ * 2. 校验文件大小:单文件不超过 10MB,所有文件总计不超过 20MB
22
+ * 3. 遍历每个 page,若存在 page_filepath 则读取本地文件内容
23
+ * 4. 将文件内容填入 page_content 字段,移除 page_filepath
24
+ * 5. 返回修改后的完整 args
25
+ *
26
+ * 传给 MCP Server 的格式:
27
+ * { "title": "...", "pages": [{"page_title": "...", "page_content": "...", "content_type": "..."}] }
28
+ */
29
+ import * as fs from "node:fs/promises";
30
+ // ============================================================================
31
+ // 常量
32
+ // ============================================================================
33
+ /** 单个 page_filepath 文件大小上限:10MB */
34
+ const MAX_SINGLE_FILE_SIZE = 10 * 1024 * 1024;
35
+ /** 所有 page_filepath 文件总大小上限:20MB */
36
+ const MAX_TOTAL_FILE_SIZE = 20 * 1024 * 1024;
37
+ // ============================================================================
38
+ // 内部辅助函数
39
+ // ============================================================================
40
+ /**
41
+ * 校验所有 page_filepath 的文件大小
42
+ *
43
+ * 使用 fs.stat 在读取文件内容之前检查大小,避免超大文件被加载到内存。
44
+ * - 单文件 > 10MB → 报错
45
+ * - 所有文件累计 > 20MB → 报错
46
+ */
47
+ async function validateFileSize(pages) {
48
+ let totalSize = 0;
49
+ for (let i = 0; i < pages.length; i++) {
50
+ const filePath = pages[i].page_filepath;
51
+ if (typeof filePath !== "string" || !filePath)
52
+ continue;
53
+ let stat;
54
+ try {
55
+ stat = await fs.stat(filePath);
56
+ }
57
+ catch (err) {
58
+ // stat 失败不在这里处理,留给后续 readFile 阶段抛出更详细的错误
59
+ continue;
60
+ }
61
+ if (stat.size > MAX_SINGLE_FILE_SIZE) {
62
+ console.error(`[mcp] smartpage_create: pages[${i}] 文件 "${filePath}" ` +
63
+ `大小 ${(stat.size / 1024 / 1024).toFixed(1)}MB 超过单文件上限 10MB`);
64
+ throw new Error("内容大小超出限制,无法创建");
65
+ }
66
+ totalSize += stat.size;
67
+ if (totalSize > MAX_TOTAL_FILE_SIZE) {
68
+ console.error(`[mcp] smartpage_create: 累计文件大小 ${(totalSize / 1024 / 1024).toFixed(1)}MB ` +
69
+ `超过总上限 20MB(在 pages[${i}] "${filePath}" 处超出)`);
70
+ throw new Error("内容大小超出限制,无法创建");
71
+ }
72
+ }
73
+ if (totalSize > 0) {
74
+ console.log(`[mcp] smartpage_create: 文件大小校验通过,总计 ${(totalSize / 1024 / 1024).toFixed(2)}MB`);
75
+ }
76
+ }
77
+ /** 异步解析 pages 中的 page_filepath,返回 BeforeCallOptions */
78
+ async function resolvePages(ctx, pages) {
79
+ console.log(`[mcp] smartpage_create: 开始解析 ${pages.length} 个 page 的 page_filepath`);
80
+ // 阶段 1:文件大小校验(stat 阶段,不读内容)
81
+ await validateFileSize(pages);
82
+ // 阶段 2:读取文件内容
83
+ const resolvedPages = await Promise.all(pages.map(async (page, index) => {
84
+ const filePath = page.page_filepath;
85
+ if (typeof filePath !== "string" || !filePath) {
86
+ // 该 page 没有 page_filepath,保留原样(可能已有 page_content)
87
+ return page;
88
+ }
89
+ let fileContent;
90
+ try {
91
+ fileContent = await fs.readFile(filePath, "utf-8");
92
+ }
93
+ catch (err) {
94
+ throw new Error(`smartpage_create: pages[${index}] 无法读取文件 "${filePath}": ${err instanceof Error ? err.message : String(err)}`);
95
+ }
96
+ console.log(`[mcp] smartpage_create: pages[${index}] 读取成功 "${filePath}" (${fileContent.length} chars)`);
97
+ // 构造新的 page 对象:填入 page_content,移除 page_filepath
98
+ const { page_filepath: _, ...rest } = page;
99
+ return { ...rest, page_content: fileContent };
100
+ }));
101
+ console.log(`[mcp] smartpage_create: 所有 page_filepath 解析完成`);
102
+ // 返回修改后的完整 args
103
+ return {
104
+ args: {
105
+ ...ctx.args,
106
+ pages: resolvedPages,
107
+ },
108
+ };
109
+ }
110
+ // ============================================================================
111
+ // 拦截器实现
112
+ // ============================================================================
113
+ export const smartpageCreateInterceptor = {
114
+ name: "smartpage-create",
115
+ /** 仅对 doc 品类的 smartpage_create 方法生效 */
116
+ match: (ctx) => ctx.category === "doc" && ctx.method === "smartpage_create",
117
+ /** 遍历 pages 数组,逐个读取 page_filepath 填入 page_content */
118
+ beforeCall(ctx) {
119
+ const pages = ctx.args.pages;
120
+ if (!Array.isArray(pages) || pages.length === 0) {
121
+ // 没有 pages 数组,不做拦截
122
+ return undefined;
123
+ }
124
+ // 检查是否有任何 page 包含 page_filepath
125
+ const hasFilePath = pages.some((p) => typeof p.page_filepath === "string" && p.page_filepath);
126
+ if (!hasFilePath) {
127
+ // 所有 page 都没有 page_filepath(可能直接传了 page_content),不做拦截
128
+ return undefined;
129
+ }
130
+ return resolvePages(ctx, pages);
131
+ },
132
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * smartpage_get_export_result 响应拦截器
3
+ *
4
+ * 核心逻辑:
5
+ * MCP Server 返回的 smartpage_get_export_result 响应中,当 task_done=true 时
6
+ * 会包含 content 字段(markdown 文本内容)。该内容可能很大,直接返回给 LLM
7
+ * 会消耗大量 token。
8
+ *
9
+ * 本拦截器在 afterCall 阶段:
10
+ * 1. 检测 task_done=true 且存在 content 字段
11
+ * 2. 将 content 保存到本地文件(使用与 msg-media 一致的媒体目录)
12
+ * 3. 用 content_path(文件路径)替换 content 字段
13
+ *
14
+ * 这样 LLM 只看到轻量的文件路径信息,Skill 可通过文件路径读取完整内容。
15
+ */
16
+ import { getWeComRuntime } from "../../runtime.js";
17
+ // ============================================================================
18
+ // 拦截器实现
19
+ // ============================================================================
20
+ export const smartpageExportInterceptor = {
21
+ name: "smartpage-export",
22
+ /** 仅对 doc 品类的 smartpage_get_export_result 方法生效 */
23
+ match: (ctx) => ctx.category === "doc" && ctx.method === "smartpage_get_export_result",
24
+ /** 拦截响应:将 markdown content 保存为本地文件 */
25
+ async afterCall(_ctx, result) {
26
+ return interceptExportResponse(result);
27
+ },
28
+ };
29
+ // ============================================================================
30
+ // 内部实现
31
+ // ============================================================================
32
+ /**
33
+ * 拦截 smartpage_get_export_result 的 MCP 响应
34
+ *
35
+ * 1. 从 MCP result 的 content[].text 中提取业务 JSON
36
+ * 2. 检测 task_done=true 且存在 content 字段
37
+ * 3. 将 content(markdown 文本)通过 saveMediaBuffer 保存到本地媒体目录
38
+ * 4. 构造新响应:移除 content,添加 content_path
39
+ */
40
+ async function interceptExportResponse(result) {
41
+ // 1. 提取 MCP result 中的 content 数组
42
+ const content = result?.content;
43
+ if (!Array.isArray(content))
44
+ return result;
45
+ const textItem = content.find((c) => c.type === "text" && typeof c.text === "string");
46
+ if (!textItem)
47
+ return result;
48
+ // 2. 解析业务 JSON
49
+ let bizData;
50
+ try {
51
+ bizData = JSON.parse(textItem.text);
52
+ }
53
+ catch {
54
+ // 非 JSON 格式,原样返回
55
+ return result;
56
+ }
57
+ // 3. 校验:errcode !== 0 或 task_done 不为 true 或无 content 时原样返回
58
+ if (bizData.errcode !== 0)
59
+ return result;
60
+ if (bizData.task_done !== true)
61
+ return result;
62
+ if (typeof bizData.content !== "string")
63
+ return result;
64
+ const markdownContent = bizData.content;
65
+ console.log(`[mcp] smartpage_get_export_result: 拦截 content (${markdownContent.length} chars),保存到本地文件`);
66
+ // 4. 将 markdown 内容通过 saveMediaBuffer 保存到本地媒体目录
67
+ // 使用 text/markdown 类型,与 msg-media 拦截器保持一致的路径管理
68
+ const buffer = Buffer.from(markdownContent, "utf-8");
69
+ const core = getWeComRuntime();
70
+ const saved = await core.channel.media.saveMediaBuffer(buffer, "text/markdown", "inbound", undefined, // maxBytes: markdown 文本通常不大,使用默认限制
71
+ "smartpage_export.md");
72
+ console.log(`[mcp] smartpage_get_export_result: 已保存到 ${saved.path}`);
73
+ // 5. 构造新响应:移除 content,添加 content_path
74
+ const newBizData = {
75
+ errcode: bizData.errcode,
76
+ errmsg: bizData.errmsg ?? "ok",
77
+ task_done: true,
78
+ content_path: saved.path,
79
+ };
80
+ // 6. 返回修改后的 MCP result 结构
81
+ return {
82
+ content: [{
83
+ type: "text",
84
+ text: JSON.stringify(newBizData),
85
+ }],
86
+ };
87
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * MCP call 拦截器类型定义
3
+ *
4
+ * 拦截器用于在 tools/call 调用前后注入特殊逻辑,
5
+ * 如修改超时配置、变换响应结果等,避免在 tool.ts 中堆积 if/else。
6
+ */
7
+ export {};
@@ -0,0 +1,99 @@
1
+ /**
2
+ * MCP Schema 清洗模块
3
+ *
4
+ * 负责内联 $ref/$defs 引用并移除 Gemini 不支持的 JSON Schema 关键词,
5
+ * 防止 Gemini 模型解析 function response 时报 400 错误。
6
+ */
7
+ /** Gemini 不支持的 JSON Schema 关键词 */
8
+ const GEMINI_UNSUPPORTED_KEYWORDS = new Set([
9
+ "patternProperties", "additionalProperties", "$schema", "$id", "$ref", "$defs",
10
+ "definitions", "examples", "minLength", "maxLength", "minimum", "maximum",
11
+ "multipleOf", "pattern", "format", "minItems", "maxItems", "uniqueItems",
12
+ "minProperties", "maxProperties",
13
+ ]);
14
+ /**
15
+ * 清洗 JSON Schema,内联 $ref 引用并移除 Gemini 不支持的关键词,
16
+ * 防止 Gemini 模型解析 function response 时报 400 错误。
17
+ */
18
+ export function cleanSchemaForGemini(schema) {
19
+ if (!schema || typeof schema !== "object")
20
+ return schema;
21
+ if (Array.isArray(schema))
22
+ return schema.map(cleanSchemaForGemini);
23
+ const obj = schema;
24
+ // 收集 $defs/definitions 用于后续 $ref 内联解析
25
+ const defs = {
26
+ ...(obj.$defs && typeof obj.$defs === "object" ? obj.$defs : {}),
27
+ ...(obj.definitions && typeof obj.definitions === "object" ? obj.definitions : {}),
28
+ };
29
+ return cleanWithDefs(obj, defs, new Set());
30
+ }
31
+ function cleanWithDefs(schema, defs, refStack) {
32
+ if (!schema || typeof schema !== "object")
33
+ return schema;
34
+ if (Array.isArray(schema))
35
+ return schema.map((item) => cleanWithDefs(item, defs, refStack));
36
+ const obj = schema;
37
+ // 合并当前层级的 $defs/definitions 到 defs 中
38
+ if (obj.$defs && typeof obj.$defs === "object") {
39
+ Object.assign(defs, obj.$defs);
40
+ }
41
+ if (obj.definitions && typeof obj.definitions === "object") {
42
+ Object.assign(defs, obj.definitions);
43
+ }
44
+ // 处理 $ref 引用:尝试内联解析
45
+ if (typeof obj.$ref === "string") {
46
+ const ref = obj.$ref;
47
+ if (refStack.has(ref))
48
+ return {}; // 防止循环引用
49
+ const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
50
+ if (match && match[1] && defs[match[1]]) {
51
+ const nextStack = new Set(refStack);
52
+ nextStack.add(ref);
53
+ return cleanWithDefs(defs[match[1]], defs, nextStack);
54
+ }
55
+ return {}; // 无法解析的 $ref,返回空对象
56
+ }
57
+ const cleaned = {};
58
+ for (const [key, value] of Object.entries(obj)) {
59
+ if (GEMINI_UNSUPPORTED_KEYWORDS.has(key))
60
+ continue;
61
+ if (key === "const") {
62
+ cleaned.enum = [value];
63
+ continue;
64
+ }
65
+ if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
66
+ cleaned[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [
67
+ k, cleanWithDefs(v, defs, refStack),
68
+ ]));
69
+ }
70
+ else if (key === "items" && value) {
71
+ cleaned[key] = Array.isArray(value)
72
+ ? value.map((item) => cleanWithDefs(item, defs, refStack))
73
+ : cleanWithDefs(value, defs, refStack);
74
+ }
75
+ else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
76
+ // 过滤掉 null 类型的变体
77
+ const nonNull = value.filter((v) => {
78
+ if (!v || typeof v !== "object")
79
+ return true;
80
+ const r = v;
81
+ return r.type !== "null";
82
+ });
83
+ if (nonNull.length === 1) {
84
+ // 只剩一个变体时直接内联
85
+ const single = cleanWithDefs(nonNull[0], defs, refStack);
86
+ if (single && typeof single === "object" && !Array.isArray(single)) {
87
+ Object.assign(cleaned, single);
88
+ }
89
+ }
90
+ else {
91
+ cleaned[key] = nonNull.map((v) => cleanWithDefs(v, defs, refStack));
92
+ }
93
+ }
94
+ else {
95
+ cleaned[key] = value;
96
+ }
97
+ }
98
+ return cleaned;
99
+ }