@mocrane/wecom 2026.3.8-4 → 2026.3.12
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 +25 -22
- package/clawdbot.plugin.json +1 -0
- package/index.ts +38 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +7 -4
- package/skills/wecom-contact-lookup/SKILL.md +162 -0
- package/skills/wecom-doc/SKILL.md +363 -0
- package/skills/wecom-doc/references/doc-api.md +224 -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/channel.ts +7 -4
- package/src/compat/plugin-sdk-shim.ts +152 -0
- package/src/mcp/index.ts +7 -0
- package/src/mcp/schema.ts +108 -0
- package/src/mcp/tool.ts +247 -0
- package/src/mcp/transport.ts +583 -0
- package/src/mcp-config.ts +182 -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 +362 -40
- package/src/onboarding.ts +45 -6
- package/src/outbound.ts +116 -46
- package/src/timeout.ts +45 -0
- package/src/types/index.ts +1 -0
- package/src/types/message.ts +10 -1
- package/src/ws-adapter.ts +22 -0
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/channel.ts
CHANGED
|
@@ -3,10 +3,11 @@ import type {
|
|
|
3
3
|
ChannelPlugin,
|
|
4
4
|
OpenClawConfig,
|
|
5
5
|
} from "openclaw/plugin-sdk";
|
|
6
|
+
|
|
6
7
|
import {
|
|
7
8
|
deleteAccountFromConfigSection,
|
|
8
9
|
setAccountEnabledInConfigSection,
|
|
9
|
-
} from "
|
|
10
|
+
} from "./compat/plugin-sdk-shim.js";
|
|
10
11
|
|
|
11
12
|
import {
|
|
12
13
|
DEFAULT_ACCOUNT_ID,
|
|
@@ -39,10 +40,12 @@ function normalizeWecomMessagingTarget(raw: string): string | undefined {
|
|
|
39
40
|
return trimmed.replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- onboarding 在 >=3.22 中已重命名为 setupWizard,
|
|
44
|
+
// 但我们仍设置旧字段以兼容 <3.22 版本的 OpenClaw。
|
|
42
45
|
export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
43
46
|
id: "wecom",
|
|
44
47
|
meta,
|
|
45
|
-
onboarding: wecomOnboardingAdapter,
|
|
48
|
+
onboarding: wecomOnboardingAdapter as any,
|
|
46
49
|
setup: {
|
|
47
50
|
resolveAccountId: ({ cfg, accountId }) => {
|
|
48
51
|
return accountId?.trim() || resolveDefaultWecomAccountId(cfg as OpenClawConfig) || DEFAULT_ACCOUNT_ID;
|
|
@@ -111,14 +114,14 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
111
114
|
accountId,
|
|
112
115
|
enabled,
|
|
113
116
|
allowTopLevel: true,
|
|
114
|
-
}),
|
|
117
|
+
}) as OpenClawConfig,
|
|
115
118
|
deleteAccount: ({ cfg, accountId }) =>
|
|
116
119
|
deleteAccountFromConfigSection({
|
|
117
120
|
cfg: cfg as OpenClawConfig,
|
|
118
121
|
sectionKey: "wecom",
|
|
119
122
|
accountId,
|
|
120
123
|
clearBaseFields: ["bot", "agent"],
|
|
121
|
-
}),
|
|
124
|
+
}) as OpenClawConfig,
|
|
122
125
|
isConfigured: (account, cfg) => {
|
|
123
126
|
if (!account.configured) {
|
|
124
127
|
return false;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Plugin SDK 兼容 Shim
|
|
3
|
+
*
|
|
4
|
+
* 为 OpenClaw >= 2026.3.11 提供稳定导出。
|
|
5
|
+
* 所有关键函数在 v2026.3.11+ 的主入口都直接可用。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── 类型重导出 ───
|
|
9
|
+
export type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
10
|
+
export type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
11
|
+
export type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
12
|
+
export type { ChannelPlugin, ChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
13
|
+
export type { ChannelAccountSnapshot } from "openclaw/plugin-sdk";
|
|
14
|
+
export type { ChannelGatewayContext } from "openclaw/plugin-sdk";
|
|
15
|
+
export type { WizardPrompter } from "openclaw/plugin-sdk";
|
|
16
|
+
export type {
|
|
17
|
+
ChannelOutboundAdapter,
|
|
18
|
+
ChannelOutboundContext,
|
|
19
|
+
} from "openclaw/plugin-sdk";
|
|
20
|
+
|
|
21
|
+
// ─── 直接导出主入口的稳定函数 ───
|
|
22
|
+
export {
|
|
23
|
+
emptyPluginConfigSchema,
|
|
24
|
+
deleteAccountFromConfigSection,
|
|
25
|
+
setAccountEnabledInConfigSection,
|
|
26
|
+
promptAccountId,
|
|
27
|
+
} from "openclaw/plugin-sdk";
|
|
28
|
+
|
|
29
|
+
// ─── 常量 ───
|
|
30
|
+
export const DEFAULT_ACCOUNT_ID = "default";
|
|
31
|
+
|
|
32
|
+
// ─── 安全动态导入 ───
|
|
33
|
+
async function tryImport<T>(specifier: string): Promise<T | undefined> {
|
|
34
|
+
try {
|
|
35
|
+
return await import(specifier) as T;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ────────────────────────────────────────────────────
|
|
42
|
+
// readJsonFileWithFallback / writeJsonFileAtomically / withFileLock
|
|
43
|
+
// (仅 mcp-config.ts 需要,函数本身为 async)
|
|
44
|
+
// ────────────────────────────────────────────────────
|
|
45
|
+
type FileLockFn = <T>(
|
|
46
|
+
filePath: string,
|
|
47
|
+
options: unknown,
|
|
48
|
+
fn: () => Promise<T>,
|
|
49
|
+
) => Promise<T>;
|
|
50
|
+
|
|
51
|
+
type ReadJsonFn = <T>(
|
|
52
|
+
filePath: string,
|
|
53
|
+
fallback: T,
|
|
54
|
+
) => Promise<{ value: T; exists: boolean }>;
|
|
55
|
+
|
|
56
|
+
type WriteJsonFn = (filePath: string, value: unknown) => Promise<void>;
|
|
57
|
+
|
|
58
|
+
export type FileIoHelpers = {
|
|
59
|
+
withFileLock: FileLockFn;
|
|
60
|
+
readJsonFileWithFallback: ReadJsonFn;
|
|
61
|
+
writeJsonFileAtomically: WriteJsonFn;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let _fileIo: FileIoHelpers | undefined;
|
|
65
|
+
|
|
66
|
+
export async function resolveFileIoHelpers(): Promise<FileIoHelpers> {
|
|
67
|
+
if (_fileIo) return _fileIo;
|
|
68
|
+
|
|
69
|
+
const jsonStore = await tryImport<Partial<FileIoHelpers>>("openclaw/plugin-sdk/json-store");
|
|
70
|
+
const msteams = await tryImport<{ withFileLock?: FileLockFn }>("openclaw/plugin-sdk/msteams");
|
|
71
|
+
|
|
72
|
+
const readFn = jsonStore?.readJsonFileWithFallback;
|
|
73
|
+
const writeFn = jsonStore?.writeJsonFileAtomically;
|
|
74
|
+
const lockFn = msteams?.withFileLock;
|
|
75
|
+
|
|
76
|
+
if (readFn && writeFn && lockFn) {
|
|
77
|
+
_fileIo = { readJsonFileWithFallback: readFn, writeJsonFileAtomically: writeFn, withFileLock: lockFn };
|
|
78
|
+
return _fileIo;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Node.js 原生回退实现 ──
|
|
82
|
+
const fs = await import("node:fs/promises");
|
|
83
|
+
const nodePath = await import("node:path");
|
|
84
|
+
|
|
85
|
+
const fallbackRead: ReadJsonFn = async <T>(filePath: string, fallback: T) => {
|
|
86
|
+
try {
|
|
87
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
88
|
+
return { value: JSON.parse(raw) as T, exists: true };
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
91
|
+
return { value: fallback, exists: false };
|
|
92
|
+
}
|
|
93
|
+
return { value: fallback, exists: false };
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const fallbackWrite: WriteJsonFn = async (filePath: string, value: unknown) => {
|
|
98
|
+
const dir = nodePath.dirname(filePath);
|
|
99
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
100
|
+
const content = JSON.stringify(value, null, 2) + "\n";
|
|
101
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
102
|
+
await fs.writeFile(tmpPath, content, { mode: 0o600 });
|
|
103
|
+
await fs.rename(tmpPath, filePath);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const fallbackLock: FileLockFn = async <T>(
|
|
107
|
+
_filePath: string,
|
|
108
|
+
_options: unknown,
|
|
109
|
+
fn: () => Promise<T>,
|
|
110
|
+
): Promise<T> => fn();
|
|
111
|
+
|
|
112
|
+
_fileIo = {
|
|
113
|
+
readJsonFileWithFallback: readFn ?? fallbackRead,
|
|
114
|
+
writeJsonFileAtomically: writeFn ?? fallbackWrite,
|
|
115
|
+
withFileLock: lockFn ?? fallbackLock,
|
|
116
|
+
};
|
|
117
|
+
return _fileIo;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ────────────────────────────────────────────────────
|
|
121
|
+
// resolvePromptAccountId (仅 onboarding.ts 需要,异步调用)
|
|
122
|
+
// ────────────────────────────────────────────────────
|
|
123
|
+
type PromptAccountIdFn = (params: {
|
|
124
|
+
cfg: unknown;
|
|
125
|
+
prompter: unknown;
|
|
126
|
+
label: string;
|
|
127
|
+
currentId: string;
|
|
128
|
+
listAccountIds: (cfg: unknown) => string[];
|
|
129
|
+
defaultAccountId: string;
|
|
130
|
+
}) => Promise<string>;
|
|
131
|
+
|
|
132
|
+
let _promptAccountId: PromptAccountIdFn | undefined;
|
|
133
|
+
|
|
134
|
+
export async function resolvePromptAccountId(): Promise<PromptAccountIdFn> {
|
|
135
|
+
if (_promptAccountId) return _promptAccountId;
|
|
136
|
+
|
|
137
|
+
for (const subpath of [
|
|
138
|
+
"openclaw/plugin-sdk/matrix",
|
|
139
|
+
"openclaw/plugin-sdk/channel-setup",
|
|
140
|
+
"openclaw/plugin-sdk/setup",
|
|
141
|
+
]) {
|
|
142
|
+
const mod = await tryImport<{ promptAccountId?: PromptAccountIdFn }>(subpath);
|
|
143
|
+
if (mod?.promptAccountId) {
|
|
144
|
+
_promptAccountId = mod.promptAccountId;
|
|
145
|
+
return _promptAccountId;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 兜底实现
|
|
150
|
+
_promptAccountId = async (params) => params.currentId || params.defaultAccountId;
|
|
151
|
+
return _promptAccountId;
|
|
152
|
+
}
|
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
|
+
}
|