@wecom/wecom-openclaw-plugin 2026.3.25 → 2026.3.30-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.
- package/dist/index.cjs.js +1406 -77
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +1406 -77
- package/dist/index.esm.js.map +1 -1
- package/dist/src/const.d.ts +2 -0
- package/dist/src/interface.d.ts +35 -3
- package/dist/src/mcp/interceptors/biz-error.d.ts +11 -0
- package/dist/src/mcp/interceptors/index.d.ts +35 -0
- package/dist/src/mcp/interceptors/msg-media.d.ts +11 -0
- package/dist/src/mcp/interceptors/smartpage-create.d.ts +30 -0
- package/dist/src/mcp/interceptors/smartpage-export.d.ts +17 -0
- package/dist/src/mcp/interceptors/types.d.ts +56 -0
- package/dist/src/message-parser.d.ts +18 -1
- package/dist/src/template-card-parser.d.ts +37 -0
- package/package.json +1 -1
- package/skills/wecom-doc-manager/SKILL.md +83 -4
- package/skills/wecom-doc-manager/references/api-smartpage-create.md +108 -0
- package/skills/wecom-doc-manager/references/api-smartpage-export-task.md +62 -0
- package/skills/wecom-doc-manager/references/api-smartpage-get-export-result.md +74 -0
- package/skills/wecom-schedule/SKILL.md +2 -0
- package/skills/wecom-schedule/references/api-update-schedule.md +2 -0
- package/skills/wecom-send-template-card/SKILL.md +157 -0
- package/skills/wecom-send-template-card/references/api-template-card-types.md +358 -0
package/dist/index.cjs.js
CHANGED
|
@@ -513,6 +513,17 @@ const CMD_ENTER_EVENT_REPLY = "ww_ai_robot_enter_event";
|
|
|
513
513
|
// ============================================================================
|
|
514
514
|
/** WSClient scene 参数:企微 OpenClaw 场景 */
|
|
515
515
|
const SCENE_WECOM_OPENCLAW = 1;
|
|
516
|
+
// ============================================================================
|
|
517
|
+
// 模板卡片配置
|
|
518
|
+
// ============================================================================
|
|
519
|
+
/** 合法的模板卡片 card_type 列表 */
|
|
520
|
+
const VALID_CARD_TYPES = [
|
|
521
|
+
"text_notice",
|
|
522
|
+
"news_notice",
|
|
523
|
+
"button_interaction",
|
|
524
|
+
"vote_interaction",
|
|
525
|
+
"multiple_interaction",
|
|
526
|
+
];
|
|
516
527
|
|
|
517
528
|
/**
|
|
518
529
|
* 企业微信消息内容解析模块
|
|
@@ -523,7 +534,47 @@ const SCENE_WECOM_OPENCLAW = 1;
|
|
|
523
534
|
// 解析函数
|
|
524
535
|
// ============================================================================
|
|
525
536
|
/**
|
|
526
|
-
*
|
|
537
|
+
* 将模板卡片事件回调格式化为可继续路由给大模型的文本。
|
|
538
|
+
*
|
|
539
|
+
* 这样后续 Agent 可以直接从 question_key / option_id 中理解用户的真实选择。
|
|
540
|
+
*/
|
|
541
|
+
function buildTemplateCardEventText(body) {
|
|
542
|
+
const templateCardEvent = body.event?.template_card_event;
|
|
543
|
+
if (body.msgtype !== "event" ||
|
|
544
|
+
body.event?.eventtype !== "template_card_event" ||
|
|
545
|
+
!templateCardEvent) {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
const selectedItems = templateCardEvent.selected_items?.selected_item ?? [];
|
|
549
|
+
const selectedLines = selectedItems.map((item) => {
|
|
550
|
+
const questionKey = item.question_key?.trim() || "unknown_question";
|
|
551
|
+
const optionIds = item.option_ids?.option_id?.filter(Boolean) ?? [];
|
|
552
|
+
return `- ${questionKey}: ${optionIds.length > 0 ? optionIds.join(", ") : "(未选择)"}`;
|
|
553
|
+
});
|
|
554
|
+
const senderUserId = body.from?.userid || "";
|
|
555
|
+
const senderCorpId = body.from?.corpid || "";
|
|
556
|
+
const chatId = body.chatid || senderUserId;
|
|
557
|
+
return [
|
|
558
|
+
"[企业微信模板卡片回调]",
|
|
559
|
+
`event_type(事件类型): template_card_event`,
|
|
560
|
+
body.msgid ? `msgid(消息 id): ${body.msgid}` : undefined,
|
|
561
|
+
body.aibotid ? `aibotid(机器人 id): ${body.aibotid}` : undefined,
|
|
562
|
+
body.chattype ? `chat_type(会话类型): ${body.chattype}` : undefined,
|
|
563
|
+
chatId ? `chat_id(会话 id): ${chatId}` : undefined,
|
|
564
|
+
senderCorpId ? `from.corpid(企业 id): ${senderCorpId}` : undefined,
|
|
565
|
+
senderUserId ? `from.userid(发送人 id): ${senderUserId}` : undefined,
|
|
566
|
+
senderUserId ? `sender_userid(发送人 id): ${senderUserId}` : undefined,
|
|
567
|
+
templateCardEvent.card_type ? `card_type(卡片类型): ${templateCardEvent.card_type}` : undefined,
|
|
568
|
+
templateCardEvent.event_key ? `event_key(事件 key): ${templateCardEvent.event_key}` : undefined,
|
|
569
|
+
templateCardEvent.task_id ? `task_id(任务 id): ${templateCardEvent.task_id}` : undefined,
|
|
570
|
+
selectedLines.length > 0 ? "selected_items(选择项):" : "selected_items(选择项): []",
|
|
571
|
+
...selectedLines,
|
|
572
|
+
]
|
|
573
|
+
.filter((line) => Boolean(line))
|
|
574
|
+
.join("\n");
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* 解析消息内容(支持单条消息、图文混排、事件回调和引用消息)
|
|
527
578
|
* @returns 提取的文本数组、图片URL数组和引用消息内容
|
|
528
579
|
*/
|
|
529
580
|
function parseMessageContent(body) {
|
|
@@ -533,6 +584,14 @@ function parseMessageContent(body) {
|
|
|
533
584
|
const fileUrls = [];
|
|
534
585
|
const fileAesKeys = new Map();
|
|
535
586
|
let quoteContent;
|
|
587
|
+
// 处理模板卡片事件回调
|
|
588
|
+
if (body.msgtype === "event") {
|
|
589
|
+
const eventText = buildTemplateCardEventText(body);
|
|
590
|
+
if (eventText) {
|
|
591
|
+
textParts.push(eventText);
|
|
592
|
+
}
|
|
593
|
+
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
|
|
594
|
+
}
|
|
536
595
|
// 处理图文混排消息
|
|
537
596
|
if (body.msgtype === "mixed" && body.mixed?.msg_item) {
|
|
538
597
|
for (const item of body.mixed.msg_item) {
|
|
@@ -674,6 +733,27 @@ async function sendWeComReply(params) {
|
|
|
674
733
|
runtime.error?.(`[wecom] WSClient not connected, cannot send reply`);
|
|
675
734
|
throw new Error("WSClient not connected");
|
|
676
735
|
}
|
|
736
|
+
const body = frame.body;
|
|
737
|
+
// 事件回调(aibot_event_callback)没有可用于 replyStream 的有效 req_id,
|
|
738
|
+
// 对该场景改用主动发送 sendMessage,避免 846605 invalid req_id。
|
|
739
|
+
if (body.msgtype === "event") {
|
|
740
|
+
// 中间帧(thinking / 流式增量)直接跳过,仅在最终帧主动发一次文本。
|
|
741
|
+
if (!finish) {
|
|
742
|
+
runtime.log?.(`[plugin -> server] skip non-final stream for event callback, streamId=${streamId}`);
|
|
743
|
+
return streamId;
|
|
744
|
+
}
|
|
745
|
+
const chatId = body.chatid || body.from?.userid;
|
|
746
|
+
if (!chatId) {
|
|
747
|
+
throw new Error("Missing chatId for event callback reply");
|
|
748
|
+
}
|
|
749
|
+
await withTimeout(wsClient.sendMessage(chatId, {
|
|
750
|
+
msgtype: "markdown",
|
|
751
|
+
markdown: { content: text },
|
|
752
|
+
}), REPLY_SEND_TIMEOUT_MS, `Event reply send timed out (streamId=${streamId})`);
|
|
753
|
+
runtime.log?.(`[plugin -> server] event-active-send chatId=${chatId}, finish=${finish}`);
|
|
754
|
+
return streamId;
|
|
755
|
+
}
|
|
756
|
+
// 非事件消息,继续使用 replyStream(被动回复)
|
|
677
757
|
// 使用 SDK 的 replyStream 方法发送消息,带超时保护
|
|
678
758
|
try {
|
|
679
759
|
await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
|
|
@@ -1084,6 +1164,640 @@ async function uploadAndSendMedia(options) {
|
|
|
1084
1164
|
}
|
|
1085
1165
|
}
|
|
1086
1166
|
|
|
1167
|
+
/**
|
|
1168
|
+
* 模板卡片解析器
|
|
1169
|
+
*
|
|
1170
|
+
* 从 LLM 回复文本中提取 markdown JSON 代码块,验证其是否为合法的企业微信模板卡片,
|
|
1171
|
+
* 返回提取到的卡片列表和剩余文本。
|
|
1172
|
+
*
|
|
1173
|
+
* 同时提供 maskTemplateCardBlocks 函数,用于在流式中间帧中隐藏正在构建的卡片代码块,
|
|
1174
|
+
* 避免 JSON 源码暴露给终端用户。
|
|
1175
|
+
*/
|
|
1176
|
+
// ============================================================================
|
|
1177
|
+
// LLM 输出字段类型修正
|
|
1178
|
+
// ============================================================================
|
|
1179
|
+
/**
|
|
1180
|
+
* 将 LLM 可能输出的字符串/非法值修正为企业微信 API 要求的整数。
|
|
1181
|
+
* 返回修正后的整数,若无法识别则返回 undefined(由调用方决定是否删除该字段)。
|
|
1182
|
+
*/
|
|
1183
|
+
function coerceToInt(value) {
|
|
1184
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1185
|
+
return Math.round(value);
|
|
1186
|
+
}
|
|
1187
|
+
if (typeof value === "string") {
|
|
1188
|
+
const trimmed = value.trim().toLowerCase();
|
|
1189
|
+
// 纯数字字符串
|
|
1190
|
+
const num = Number(trimmed);
|
|
1191
|
+
if (!Number.isNaN(num) && Number.isFinite(num)) {
|
|
1192
|
+
return Math.round(num);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return undefined;
|
|
1196
|
+
}
|
|
1197
|
+
/** 将 LLM 可能输出的字符串/非法值修正为布尔值 */
|
|
1198
|
+
function coerceToBool(value) {
|
|
1199
|
+
if (typeof value === "boolean")
|
|
1200
|
+
return value;
|
|
1201
|
+
if (typeof value === "string") {
|
|
1202
|
+
const t = value.trim().toLowerCase();
|
|
1203
|
+
if (t === "true" || t === "1" || t === "yes")
|
|
1204
|
+
return true;
|
|
1205
|
+
if (t === "false" || t === "0" || t === "no")
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
if (typeof value === "number")
|
|
1209
|
+
return value !== 0;
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
/** checkbox.mode 的语义别名映射 */
|
|
1213
|
+
const MODE_ALIASES = {
|
|
1214
|
+
single: 0,
|
|
1215
|
+
radio: 0,
|
|
1216
|
+
"单选": 0,
|
|
1217
|
+
multi: 1,
|
|
1218
|
+
multiple: 1,
|
|
1219
|
+
"多选": 1,
|
|
1220
|
+
};
|
|
1221
|
+
/**
|
|
1222
|
+
* 修正 checkbox.mode:
|
|
1223
|
+
* - 0 → 单选,1 → 多选,仅允许这两个值
|
|
1224
|
+
* - 字符串数字 "0"/"1" → 整数
|
|
1225
|
+
* - 语义别名 "multi"/"single" 等 → 对应整数
|
|
1226
|
+
* - 其他正整数(如 2)→ clamp 到 1(多选)
|
|
1227
|
+
* - 无法识别 → 删除(让服务端使用默认值 0)
|
|
1228
|
+
*/
|
|
1229
|
+
function coerceCheckboxMode(value) {
|
|
1230
|
+
let num;
|
|
1231
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1232
|
+
num = Math.round(value);
|
|
1233
|
+
}
|
|
1234
|
+
else if (typeof value === "string") {
|
|
1235
|
+
const trimmed = value.trim().toLowerCase();
|
|
1236
|
+
if (trimmed in MODE_ALIASES)
|
|
1237
|
+
return MODE_ALIASES[trimmed];
|
|
1238
|
+
const parsed = Number(trimmed);
|
|
1239
|
+
if (!Number.isNaN(parsed))
|
|
1240
|
+
num = Math.round(parsed);
|
|
1241
|
+
}
|
|
1242
|
+
if (num === undefined)
|
|
1243
|
+
return undefined;
|
|
1244
|
+
// mode 只允许 0(单选)或 1(多选),超出范围 clamp
|
|
1245
|
+
if (num <= 0)
|
|
1246
|
+
return 0;
|
|
1247
|
+
return 1;
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* 对 LLM 生成的模板卡片 JSON 做字段类型修正,确保符合企业微信 API 的类型要求。
|
|
1251
|
+
*
|
|
1252
|
+
* 修正范围:
|
|
1253
|
+
* - checkbox.mode: uint32(0=单选,1=多选)
|
|
1254
|
+
* - checkbox.disable: bool
|
|
1255
|
+
* - checkbox.option_list[].is_checked: bool
|
|
1256
|
+
* - source.desc_color: int
|
|
1257
|
+
* - quote_area.type: int
|
|
1258
|
+
* - card_action.type: int
|
|
1259
|
+
* - image_text_area.type: int
|
|
1260
|
+
* - horizontal_content_list[].type: int
|
|
1261
|
+
* - jump_list[].type: int
|
|
1262
|
+
* - button_list[].style: int
|
|
1263
|
+
* - button_selection.disable: bool
|
|
1264
|
+
* - select_list[].disable: bool
|
|
1265
|
+
*
|
|
1266
|
+
* 原则:能修就修,修不了就删(让服务端走默认值),绝不阻塞发送。
|
|
1267
|
+
*/
|
|
1268
|
+
function normalizeTemplateCardFields(card, log) {
|
|
1269
|
+
const fixes = [];
|
|
1270
|
+
// ── checkbox ──────────────────────────────────────────────────────────
|
|
1271
|
+
const checkbox = card.checkbox;
|
|
1272
|
+
if (checkbox && typeof checkbox === "object") {
|
|
1273
|
+
// mode
|
|
1274
|
+
if ("mode" in checkbox) {
|
|
1275
|
+
const fixed = coerceCheckboxMode(checkbox.mode);
|
|
1276
|
+
if (fixed !== undefined) {
|
|
1277
|
+
if (checkbox.mode !== fixed) {
|
|
1278
|
+
fixes.push(`checkbox.mode: ${JSON.stringify(checkbox.mode)} → ${fixed}`);
|
|
1279
|
+
}
|
|
1280
|
+
checkbox.mode = fixed;
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
fixes.push(`checkbox.mode: ${JSON.stringify(checkbox.mode)} → (deleted, invalid)`);
|
|
1284
|
+
delete checkbox.mode;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
// disable
|
|
1288
|
+
if ("disable" in checkbox) {
|
|
1289
|
+
const fixed = coerceToBool(checkbox.disable);
|
|
1290
|
+
if (fixed !== undefined && checkbox.disable !== fixed) {
|
|
1291
|
+
fixes.push(`checkbox.disable: ${JSON.stringify(checkbox.disable)} → ${fixed}`);
|
|
1292
|
+
checkbox.disable = fixed;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// option_list[].is_checked
|
|
1296
|
+
if (Array.isArray(checkbox.option_list)) {
|
|
1297
|
+
for (const opt of checkbox.option_list) {
|
|
1298
|
+
if (opt && typeof opt === "object" && "is_checked" in opt) {
|
|
1299
|
+
const fixed = coerceToBool(opt.is_checked);
|
|
1300
|
+
if (fixed !== undefined && opt.is_checked !== fixed) {
|
|
1301
|
+
fixes.push(`checkbox.option_list.is_checked: ${JSON.stringify(opt.is_checked)} → ${fixed}`);
|
|
1302
|
+
opt.is_checked = fixed;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
// ── source.desc_color ────────────────────────────────────────────────
|
|
1309
|
+
const source = card.source;
|
|
1310
|
+
if (source && typeof source === "object" && "desc_color" in source) {
|
|
1311
|
+
const fixed = coerceToInt(source.desc_color);
|
|
1312
|
+
if (fixed !== undefined && source.desc_color !== fixed) {
|
|
1313
|
+
fixes.push(`source.desc_color: ${JSON.stringify(source.desc_color)} → ${fixed}`);
|
|
1314
|
+
source.desc_color = fixed;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
// ── card_action.type ─────────────────────────────────────────────────
|
|
1318
|
+
const cardAction = card.card_action;
|
|
1319
|
+
if (cardAction && typeof cardAction === "object" && "type" in cardAction) {
|
|
1320
|
+
const fixed = coerceToInt(cardAction.type);
|
|
1321
|
+
if (fixed !== undefined && cardAction.type !== fixed) {
|
|
1322
|
+
fixes.push(`card_action.type: ${JSON.stringify(cardAction.type)} → ${fixed}`);
|
|
1323
|
+
cardAction.type = fixed;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
// ── quote_area.type ──────────────────────────────────────────────────
|
|
1327
|
+
const quoteArea = card.quote_area;
|
|
1328
|
+
if (quoteArea && typeof quoteArea === "object" && "type" in quoteArea) {
|
|
1329
|
+
const fixed = coerceToInt(quoteArea.type);
|
|
1330
|
+
if (fixed !== undefined && quoteArea.type !== fixed) {
|
|
1331
|
+
fixes.push(`quote_area.type: ${JSON.stringify(quoteArea.type)} → ${fixed}`);
|
|
1332
|
+
quoteArea.type = fixed;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
// ── image_text_area.type ─────────────────────────────────────────────
|
|
1336
|
+
const imageTextArea = card.image_text_area;
|
|
1337
|
+
if (imageTextArea && typeof imageTextArea === "object" && "type" in imageTextArea) {
|
|
1338
|
+
const fixed = coerceToInt(imageTextArea.type);
|
|
1339
|
+
if (fixed !== undefined && imageTextArea.type !== fixed) {
|
|
1340
|
+
fixes.push(`image_text_area.type: ${JSON.stringify(imageTextArea.type)} → ${fixed}`);
|
|
1341
|
+
imageTextArea.type = fixed;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
// ── horizontal_content_list[].type ───────────────────────────────────
|
|
1345
|
+
if (Array.isArray(card.horizontal_content_list)) {
|
|
1346
|
+
for (const item of card.horizontal_content_list) {
|
|
1347
|
+
if (item && typeof item === "object" && "type" in item) {
|
|
1348
|
+
const fixed = coerceToInt(item.type);
|
|
1349
|
+
if (fixed !== undefined && item.type !== fixed) {
|
|
1350
|
+
fixes.push(`horizontal_content_list.type: ${JSON.stringify(item.type)} → ${fixed}`);
|
|
1351
|
+
item.type = fixed;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
// ── jump_list[].type ─────────────────────────────────────────────────
|
|
1357
|
+
if (Array.isArray(card.jump_list)) {
|
|
1358
|
+
for (const item of card.jump_list) {
|
|
1359
|
+
if (item && typeof item === "object" && "type" in item) {
|
|
1360
|
+
const fixed = coerceToInt(item.type);
|
|
1361
|
+
if (fixed !== undefined && item.type !== fixed) {
|
|
1362
|
+
fixes.push(`jump_list.type: ${JSON.stringify(item.type)} → ${fixed}`);
|
|
1363
|
+
item.type = fixed;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
// ── button_list[].style ──────────────────────────────────────────────
|
|
1369
|
+
if (Array.isArray(card.button_list)) {
|
|
1370
|
+
for (const btn of card.button_list) {
|
|
1371
|
+
if (btn && typeof btn === "object" && "style" in btn) {
|
|
1372
|
+
const fixed = coerceToInt(btn.style);
|
|
1373
|
+
if (fixed !== undefined && btn.style !== fixed) {
|
|
1374
|
+
fixes.push(`button_list.style: ${JSON.stringify(btn.style)} → ${fixed}`);
|
|
1375
|
+
btn.style = fixed;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
// ── button_selection.disable ─────────────────────────────────────────
|
|
1381
|
+
const buttonSelection = card.button_selection;
|
|
1382
|
+
if (buttonSelection && typeof buttonSelection === "object" && "disable" in buttonSelection) {
|
|
1383
|
+
const fixed = coerceToBool(buttonSelection.disable);
|
|
1384
|
+
if (fixed !== undefined && buttonSelection.disable !== fixed) {
|
|
1385
|
+
fixes.push(`button_selection.disable: ${JSON.stringify(buttonSelection.disable)} → ${fixed}`);
|
|
1386
|
+
buttonSelection.disable = fixed;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
// ── select_list[].disable ────────────────────────────────────────────
|
|
1390
|
+
if (Array.isArray(card.select_list)) {
|
|
1391
|
+
for (const sel of card.select_list) {
|
|
1392
|
+
if (sel && typeof sel === "object" && "disable" in sel) {
|
|
1393
|
+
const fixed = coerceToBool(sel.disable);
|
|
1394
|
+
if (fixed !== undefined && sel.disable !== fixed) {
|
|
1395
|
+
fixes.push(`select_list.disable: ${JSON.stringify(sel.disable)} → ${fixed}`);
|
|
1396
|
+
sel.disable = fixed;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (fixes.length > 0) {
|
|
1402
|
+
log?.(`[template-card-parser] normalizeTemplateCardFields: ${fixes.length} fix(es) applied: ${fixes.join("; ")}`);
|
|
1403
|
+
}
|
|
1404
|
+
return card;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* 校验并补全模板卡片的必填字段。
|
|
1408
|
+
*
|
|
1409
|
+
* 在 normalizeTemplateCardFields(类型修正)之后调用,确保卡片结构满足企业微信 API 的必填要求。
|
|
1410
|
+
*
|
|
1411
|
+
* 补全策略:
|
|
1412
|
+
* - task_id:所有卡片统一自动补全(交互型 API 必填,通知型插件也需要用于缓存回写)
|
|
1413
|
+
* - main_title:除 text_notice 外的 4 种卡片 API 必填,自动补 { title: "通知" }
|
|
1414
|
+
* text_notice 要求 main_title.title 与 sub_title_text 至少填一个,缺两个时补 sub_title_text
|
|
1415
|
+
* - card_action:text_notice / news_notice API 必填,自动补 { type: 1, url: "https://work.weixin.qq.com" }
|
|
1416
|
+
* - checkbox:vote_interaction API 必填,无法凭空补全,仅记告警
|
|
1417
|
+
* - submit_button:vote_interaction / multiple_interaction API 必填,自动补 { text: "提交", key: "submit_default" }
|
|
1418
|
+
* - button_list:button_interaction API 必填,无法凭空补全,仅记告警
|
|
1419
|
+
* - select_list:multiple_interaction API 必填,无法凭空补全,仅记告警
|
|
1420
|
+
*/
|
|
1421
|
+
function validateAndFixRequiredFields(card, log) {
|
|
1422
|
+
const cardType = card.card_type;
|
|
1423
|
+
const fixes = [];
|
|
1424
|
+
const warnings = [];
|
|
1425
|
+
// ── task_id(所有卡片:始终确保唯一性) ─────────────────────────────
|
|
1426
|
+
// LLM 可能编造时间戳导致重复,因此无论是否提供了 task_id,
|
|
1427
|
+
// 都提取语义前缀 + 代码追加真实时间戳和随机后缀来保证唯一。
|
|
1428
|
+
const rawTid = (typeof card.task_id === "string" && card.task_id.trim()) ? card.task_id.trim() : "";
|
|
1429
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
1430
|
+
const ts = Date.now();
|
|
1431
|
+
let finalTid;
|
|
1432
|
+
if (rawTid) {
|
|
1433
|
+
// 提取 LLM 的语义前缀:去掉尾部的数字串(LLM 编造的假时间戳)
|
|
1434
|
+
const prefix = rawTid.replace(/_\d{8,}$/, "").replace(/[^a-zA-Z0-9_\-@]/g, "_").slice(0, 80);
|
|
1435
|
+
finalTid = prefix ? `${prefix}_${ts}_${rand}` : `task_${cardType}_${ts}_${rand}`;
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
finalTid = `task_${cardType}_${ts}_${rand}`;
|
|
1439
|
+
}
|
|
1440
|
+
if (finalTid !== rawTid) {
|
|
1441
|
+
fixes.push(`task_id: "${rawTid || "(missing)"}" → "${finalTid}"`);
|
|
1442
|
+
}
|
|
1443
|
+
card.task_id = finalTid;
|
|
1444
|
+
// ── main_title ────────────────────────────────────────────────────────
|
|
1445
|
+
const mainTitle = card.main_title;
|
|
1446
|
+
const hasMainTitle = mainTitle && typeof mainTitle === "object" &&
|
|
1447
|
+
(typeof mainTitle.title === "string" && mainTitle.title.trim());
|
|
1448
|
+
const hasSubTitleText = typeof card.sub_title_text === "string" && card.sub_title_text.trim();
|
|
1449
|
+
switch (cardType) {
|
|
1450
|
+
case "text_notice":
|
|
1451
|
+
// text_notice: main_title.title 和 sub_title_text 至少一个
|
|
1452
|
+
if (!hasMainTitle && !hasSubTitleText) {
|
|
1453
|
+
card.sub_title_text = card.sub_title_text || "通知";
|
|
1454
|
+
fixes.push(`sub_title_text: (missing, no main_title either) → fallback "通知"`);
|
|
1455
|
+
}
|
|
1456
|
+
break;
|
|
1457
|
+
case "news_notice":
|
|
1458
|
+
case "button_interaction":
|
|
1459
|
+
case "vote_interaction":
|
|
1460
|
+
case "multiple_interaction":
|
|
1461
|
+
// 这四种 main_title 为必填
|
|
1462
|
+
if (!mainTitle || typeof mainTitle !== "object") {
|
|
1463
|
+
card.main_title = { title: "通知" };
|
|
1464
|
+
fixes.push(`main_title: (missing) → { title: "通知" }`);
|
|
1465
|
+
}
|
|
1466
|
+
else if (!hasMainTitle) {
|
|
1467
|
+
mainTitle.title = "通知";
|
|
1468
|
+
fixes.push(`main_title.title: (empty) → "通知"`);
|
|
1469
|
+
}
|
|
1470
|
+
break;
|
|
1471
|
+
}
|
|
1472
|
+
// ── card_action(text_notice / news_notice 必填) ──────────────────
|
|
1473
|
+
if (cardType === "text_notice" || cardType === "news_notice") {
|
|
1474
|
+
if (!card.card_action || typeof card.card_action !== "object") {
|
|
1475
|
+
card.card_action = { type: 1, url: "https://work.weixin.qq.com" };
|
|
1476
|
+
fixes.push(`card_action: (missing) → { type: 1, url: "https://work.weixin.qq.com" }`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
// ── submit_button(vote_interaction / multiple_interaction 必填) ──
|
|
1480
|
+
if (cardType === "vote_interaction" || cardType === "multiple_interaction") {
|
|
1481
|
+
if (!card.submit_button || typeof card.submit_button !== "object") {
|
|
1482
|
+
card.submit_button = { text: "提交", key: `submit_${cardType}_${Date.now()}` };
|
|
1483
|
+
fixes.push(`submit_button: (missing) → auto-generated`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
// ── 核心业务字段(无法凭空补全,仅告警) ────────────────────────────
|
|
1487
|
+
if (cardType === "button_interaction") {
|
|
1488
|
+
if (!Array.isArray(card.button_list) || card.button_list.length === 0) {
|
|
1489
|
+
warnings.push(`button_list is missing or empty (required for button_interaction)`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (cardType === "vote_interaction") {
|
|
1493
|
+
if (!card.checkbox || typeof card.checkbox !== "object") {
|
|
1494
|
+
warnings.push(`checkbox is missing (required for vote_interaction)`);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (cardType === "multiple_interaction") {
|
|
1498
|
+
if (!Array.isArray(card.select_list) || card.select_list.length === 0) {
|
|
1499
|
+
warnings.push(`select_list is missing or empty (required for multiple_interaction)`);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if (fixes.length > 0) {
|
|
1503
|
+
log?.(`[template-card-parser] validateAndFixRequiredFields: ${fixes.length} fix(es): ${fixes.join("; ")}`);
|
|
1504
|
+
}
|
|
1505
|
+
if (warnings.length > 0) {
|
|
1506
|
+
log?.(`[template-card-parser] validateAndFixRequiredFields: ${warnings.length} warning(s): ${warnings.join("; ")}`);
|
|
1507
|
+
}
|
|
1508
|
+
return card;
|
|
1509
|
+
}
|
|
1510
|
+
// ============================================================================
|
|
1511
|
+
// 简化格式 → 企微 API 格式转换(vote_interaction / multiple_interaction)
|
|
1512
|
+
// ============================================================================
|
|
1513
|
+
/**
|
|
1514
|
+
* 生成唯一的 question_key / submit_button.key。
|
|
1515
|
+
*/
|
|
1516
|
+
function generateKey(prefix) {
|
|
1517
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
1518
|
+
return `${prefix}_${Date.now()}_${rand}`;
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* 将 vote_interaction 的简化格式转换为企微 API 格式。
|
|
1522
|
+
*
|
|
1523
|
+
* 简化格式字段:
|
|
1524
|
+
* title → main_title.title
|
|
1525
|
+
* description → main_title.desc
|
|
1526
|
+
* options → checkbox.option_list(每个 {id, text} 直接透传)
|
|
1527
|
+
* mode → checkbox.mode(0=单选 1=多选)
|
|
1528
|
+
* submit_text → submit_button.text
|
|
1529
|
+
*
|
|
1530
|
+
* 代码自动生成:checkbox.question_key, submit_button.key
|
|
1531
|
+
*
|
|
1532
|
+
* 如果 LLM 已输出了合法的 API 原始格式(有 checkbox.option_list),则跳过转换直接透传。
|
|
1533
|
+
*/
|
|
1534
|
+
function transformVoteInteraction(card, log) {
|
|
1535
|
+
// 防御性:如果已经是合法 API 格式,跳过
|
|
1536
|
+
const existingCheckbox = card.checkbox;
|
|
1537
|
+
if (existingCheckbox && typeof existingCheckbox === "object" && Array.isArray(existingCheckbox.option_list)) {
|
|
1538
|
+
log?.(`[template-card-parser] transformVoteInteraction: already has checkbox.option_list, skipping transform`);
|
|
1539
|
+
return card;
|
|
1540
|
+
}
|
|
1541
|
+
// 提取 options(简化格式的核心字段)
|
|
1542
|
+
const options = card.options;
|
|
1543
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
1544
|
+
log?.(`[template-card-parser] transformVoteInteraction: no "options" array found, skipping transform`);
|
|
1545
|
+
return card;
|
|
1546
|
+
}
|
|
1547
|
+
log?.(`[template-card-parser] transformVoteInteraction: transforming simplified format → API format`);
|
|
1548
|
+
log?.(`[template-card-parser] transformVoteInteraction: input=${JSON.stringify(card)}`);
|
|
1549
|
+
// ── 构建 main_title ──
|
|
1550
|
+
const title = card.title;
|
|
1551
|
+
const description = card.description;
|
|
1552
|
+
if (title || description) {
|
|
1553
|
+
card.main_title = {
|
|
1554
|
+
...(title ? { title } : {}),
|
|
1555
|
+
...(description ? { desc: description } : {}),
|
|
1556
|
+
};
|
|
1557
|
+
delete card.title;
|
|
1558
|
+
delete card.description;
|
|
1559
|
+
}
|
|
1560
|
+
// ── 构建 checkbox(最多 20 个选项) ──
|
|
1561
|
+
const mode = coerceCheckboxMode(card.mode) ?? 0;
|
|
1562
|
+
const questionKey = generateKey("vote");
|
|
1563
|
+
const clampedOptions = options.slice(0, 20);
|
|
1564
|
+
if (options.length > 20) {
|
|
1565
|
+
log?.(`[template-card-parser] transformVoteInteraction: options count ${options.length} exceeds max 20, clamped to 20`);
|
|
1566
|
+
}
|
|
1567
|
+
card.checkbox = {
|
|
1568
|
+
question_key: questionKey,
|
|
1569
|
+
mode,
|
|
1570
|
+
option_list: clampedOptions.map((opt) => ({
|
|
1571
|
+
id: String(opt.id ?? opt.value ?? `opt_${Math.random().toString(36).slice(2, 6)}`),
|
|
1572
|
+
text: String(opt.text ?? opt.label ?? opt.name ?? ""),
|
|
1573
|
+
})),
|
|
1574
|
+
};
|
|
1575
|
+
delete card.options;
|
|
1576
|
+
delete card.mode;
|
|
1577
|
+
// ── 构建 submit_button ──
|
|
1578
|
+
const submitText = card.submit_text || "提交";
|
|
1579
|
+
card.submit_button = {
|
|
1580
|
+
text: submitText,
|
|
1581
|
+
key: generateKey("submit_vote"),
|
|
1582
|
+
};
|
|
1583
|
+
delete card.submit_text;
|
|
1584
|
+
// ── 清理 LLM 可能杜撰的无效字段 ──
|
|
1585
|
+
delete card.vote_question;
|
|
1586
|
+
delete card.vote_option;
|
|
1587
|
+
delete card.vote_options;
|
|
1588
|
+
log?.(`[template-card-parser] transformVoteInteraction: output=${JSON.stringify(card)}`);
|
|
1589
|
+
return card;
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* 将 multiple_interaction 的简化格式转换为企微 API 格式。
|
|
1593
|
+
*
|
|
1594
|
+
* 简化格式字段:
|
|
1595
|
+
* title → main_title.title
|
|
1596
|
+
* description → main_title.desc
|
|
1597
|
+
* selectors → select_list(每个 {title, options: [{id, text}]} → {question_key, title, option_list})
|
|
1598
|
+
* submit_text → submit_button.text
|
|
1599
|
+
*
|
|
1600
|
+
* 代码自动生成:select_list[].question_key, submit_button.key
|
|
1601
|
+
*
|
|
1602
|
+
* 如果 LLM 已输出了合法的 API 原始格式(有 select_list[0].option_list),则跳过转换直接透传。
|
|
1603
|
+
*/
|
|
1604
|
+
function transformMultipleInteraction(card, log) {
|
|
1605
|
+
// 防御性:如果已经是合法 API 格式,跳过
|
|
1606
|
+
const existingSelectList = card.select_list;
|
|
1607
|
+
if (Array.isArray(existingSelectList) &&
|
|
1608
|
+
existingSelectList.length > 0 &&
|
|
1609
|
+
Array.isArray(existingSelectList[0]?.option_list)) {
|
|
1610
|
+
log?.(`[template-card-parser] transformMultipleInteraction: already has select_list[].option_list, skipping transform`);
|
|
1611
|
+
return card;
|
|
1612
|
+
}
|
|
1613
|
+
// 提取 selectors(简化格式的核心字段)
|
|
1614
|
+
const selectors = card.selectors;
|
|
1615
|
+
if (!Array.isArray(selectors) || selectors.length === 0) {
|
|
1616
|
+
log?.(`[template-card-parser] transformMultipleInteraction: no "selectors" array found, skipping transform`);
|
|
1617
|
+
return card;
|
|
1618
|
+
}
|
|
1619
|
+
log?.(`[template-card-parser] transformMultipleInteraction: transforming simplified format → API format`);
|
|
1620
|
+
log?.(`[template-card-parser] transformMultipleInteraction: input=${JSON.stringify(card)}`);
|
|
1621
|
+
// ── 构建 main_title ──
|
|
1622
|
+
const title = card.title;
|
|
1623
|
+
const description = card.description;
|
|
1624
|
+
if (title || description) {
|
|
1625
|
+
card.main_title = {
|
|
1626
|
+
...(title ? { title } : {}),
|
|
1627
|
+
...(description ? { desc: description } : {}),
|
|
1628
|
+
};
|
|
1629
|
+
delete card.title;
|
|
1630
|
+
delete card.description;
|
|
1631
|
+
}
|
|
1632
|
+
// ── 构建 select_list(最多 3 个选择器,每个最多 10 个选项) ──
|
|
1633
|
+
const clampedSelectors = selectors.slice(0, 3);
|
|
1634
|
+
if (selectors.length > 3) {
|
|
1635
|
+
log?.(`[template-card-parser] transformMultipleInteraction: selectors count ${selectors.length} exceeds max 3, clamped to 3`);
|
|
1636
|
+
}
|
|
1637
|
+
card.select_list = clampedSelectors.map((sel, idx) => {
|
|
1638
|
+
const selectorOptions = (sel.options ?? []).slice(0, 10);
|
|
1639
|
+
return {
|
|
1640
|
+
question_key: generateKey(`sel_${idx}`),
|
|
1641
|
+
title: String(sel.title ?? sel.label ?? `选择${idx + 1}`),
|
|
1642
|
+
option_list: selectorOptions.map((opt) => ({
|
|
1643
|
+
id: String(opt.id ?? opt.value ?? `opt_${Math.random().toString(36).slice(2, 6)}`),
|
|
1644
|
+
text: String(opt.text ?? opt.label ?? opt.name ?? ""),
|
|
1645
|
+
})),
|
|
1646
|
+
};
|
|
1647
|
+
});
|
|
1648
|
+
delete card.selectors;
|
|
1649
|
+
// ── 构建 submit_button ──
|
|
1650
|
+
const submitText = card.submit_text || "提交";
|
|
1651
|
+
card.submit_button = {
|
|
1652
|
+
text: submitText,
|
|
1653
|
+
key: generateKey("submit_multi"),
|
|
1654
|
+
};
|
|
1655
|
+
delete card.submit_text;
|
|
1656
|
+
log?.(`[template-card-parser] transformMultipleInteraction: output=${JSON.stringify(card)}`);
|
|
1657
|
+
return card;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* 对 vote_interaction / multiple_interaction 执行简化格式转换。
|
|
1661
|
+
* 其他 card_type 直接跳过。
|
|
1662
|
+
*/
|
|
1663
|
+
function transformSimplifiedCard(card, log) {
|
|
1664
|
+
const cardType = card.card_type;
|
|
1665
|
+
if (cardType === "vote_interaction") {
|
|
1666
|
+
return transformVoteInteraction(card, log);
|
|
1667
|
+
}
|
|
1668
|
+
if (cardType === "multiple_interaction") {
|
|
1669
|
+
return transformMultipleInteraction(card, log);
|
|
1670
|
+
}
|
|
1671
|
+
return card;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* 匹配 markdown 代码块的正则表达式
|
|
1675
|
+
* 支持 ```json ... ``` 和 ``` ... ``` 两种格式
|
|
1676
|
+
*/
|
|
1677
|
+
const CODE_BLOCK_RE = /```(?:json)?\s*\n([\s\S]*?)\n```/g;
|
|
1678
|
+
/**
|
|
1679
|
+
* 匹配已闭合的代码块(含 card_type 关键词,用于中间帧遮罩)
|
|
1680
|
+
* 与 CODE_BLOCK_RE 相同,但用于 maskTemplateCardBlocks 中单独实例化
|
|
1681
|
+
*/
|
|
1682
|
+
const CLOSED_BLOCK_RE = /```(?:json)?\s*\n([\s\S]*?)\n```/g;
|
|
1683
|
+
/**
|
|
1684
|
+
* 匹配未闭合的代码块尾部(LLM 正在输出中的代码块)
|
|
1685
|
+
* 以 ```json 或 ``` 开头,后面有内容但没有闭合的 ```
|
|
1686
|
+
*/
|
|
1687
|
+
const UNCLOSED_BLOCK_RE = /```(?:json)?\s*\n[\s\S]*$/;
|
|
1688
|
+
/**
|
|
1689
|
+
* 从文本中提取模板卡片 JSON 代码块
|
|
1690
|
+
*
|
|
1691
|
+
* 匹配规则:
|
|
1692
|
+
* 1. 匹配所有 ```json ... ``` 或 ``` ... ``` 格式的代码块
|
|
1693
|
+
* 2. 尝试 JSON.parse 解析代码块内容
|
|
1694
|
+
* 3. 检查解析结果中是否包含合法的 card_type 字段
|
|
1695
|
+
* 4. 合法的卡片从原文中移除,不合法的保留
|
|
1696
|
+
*
|
|
1697
|
+
* @param text - LLM 回复的完整文本
|
|
1698
|
+
* @returns 提取结果,包含卡片列表和剩余文本
|
|
1699
|
+
*/
|
|
1700
|
+
function extractTemplateCards(text, log) {
|
|
1701
|
+
const cards = [];
|
|
1702
|
+
/** 需要从原文中移除的代码块(记录完整匹配内容) */
|
|
1703
|
+
const blocksToRemove = [];
|
|
1704
|
+
log?.(`[template-card-parser] extractTemplateCards called, textLength=${text.length}`);
|
|
1705
|
+
let match;
|
|
1706
|
+
// 重置正则的 lastIndex,确保从头匹配
|
|
1707
|
+
CODE_BLOCK_RE.lastIndex = 0;
|
|
1708
|
+
let blockIndex = 0;
|
|
1709
|
+
while ((match = CODE_BLOCK_RE.exec(text)) !== null) {
|
|
1710
|
+
const fullMatch = match[0];
|
|
1711
|
+
const jsonContent = match[1].trim();
|
|
1712
|
+
blockIndex++;
|
|
1713
|
+
log?.(`[template-card-parser] Found code block #${blockIndex}, length=${fullMatch.length}, preview=${jsonContent.slice(0, 1000)}...`);
|
|
1714
|
+
// 尝试解析 JSON
|
|
1715
|
+
let parsed;
|
|
1716
|
+
try {
|
|
1717
|
+
parsed = JSON.parse(jsonContent);
|
|
1718
|
+
}
|
|
1719
|
+
catch (e) {
|
|
1720
|
+
// JSON 解析失败,保留在原文中
|
|
1721
|
+
log?.(`[template-card-parser] Code block #${blockIndex} JSON parse failed: ${String(e)}`);
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
// 检查是否包含合法的 card_type
|
|
1725
|
+
const cardType = parsed.card_type;
|
|
1726
|
+
if (typeof cardType !== "string" || !VALID_CARD_TYPES.includes(cardType)) {
|
|
1727
|
+
// card_type 不合法,保留在原文中
|
|
1728
|
+
log?.(`[template-card-parser] Code block #${blockIndex} has invalid card_type="${String(cardType)}", skipping`);
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
log?.(`[template-card-parser] Code block #${blockIndex} is valid template card, card_type="${cardType}"`);
|
|
1732
|
+
// vote_interaction / multiple_interaction:简化格式 → API 格式转换
|
|
1733
|
+
transformSimplifiedCard(parsed, log);
|
|
1734
|
+
// 修正 LLM 可能输出的错误字段类型(如 checkbox.mode: "multi" → 1)
|
|
1735
|
+
normalizeTemplateCardFields(parsed, log);
|
|
1736
|
+
// 校验并补全必填字段(如缺失的 task_id、main_title、card_action)
|
|
1737
|
+
validateAndFixRequiredFields(parsed, log);
|
|
1738
|
+
// 合法的模板卡片,收集并标记移除
|
|
1739
|
+
cards.push({
|
|
1740
|
+
cardJson: parsed,
|
|
1741
|
+
cardType,
|
|
1742
|
+
});
|
|
1743
|
+
blocksToRemove.push(fullMatch);
|
|
1744
|
+
}
|
|
1745
|
+
// 从原文中移除已提取的代码块,生成剩余文本
|
|
1746
|
+
let remainingText = text;
|
|
1747
|
+
for (const block of blocksToRemove) {
|
|
1748
|
+
remainingText = remainingText.replace(block, "");
|
|
1749
|
+
}
|
|
1750
|
+
// 清理多余空行(连续 3 个以上换行合并为 2 个)
|
|
1751
|
+
remainingText = remainingText.replace(/\n{3,}/g, "\n\n").trim();
|
|
1752
|
+
log?.(`[template-card-parser] Extraction done: ${cards.length} card(s) found, remainingTextLength=${remainingText.length}`);
|
|
1753
|
+
return { cards, remainingText };
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* 遮罩文本中的模板卡片代码块(用于流式中间帧展示)
|
|
1757
|
+
*
|
|
1758
|
+
* 在 LLM 流式输出过程中,累积文本可能包含:
|
|
1759
|
+
* 1. 已闭合的模板卡片 JSON 代码块 → 替换为友好提示文本
|
|
1760
|
+
* 2. 正在构建中的未闭合代码块 → 截断隐藏,避免 JSON 源码暴露
|
|
1761
|
+
*
|
|
1762
|
+
* 此函数仅做文本替换,不做 JSON 解析验证(中间帧性能优先)。
|
|
1763
|
+
* 只要代码块内容中出现 "card_type" 关键词就认为是模板卡片并遮罩。
|
|
1764
|
+
*
|
|
1765
|
+
* @param text - 当前累积文本
|
|
1766
|
+
* @returns 遮罩后的展示文本
|
|
1767
|
+
*/
|
|
1768
|
+
function maskTemplateCardBlocks(text, log) {
|
|
1769
|
+
let masked = text;
|
|
1770
|
+
let closedMaskCount = 0;
|
|
1771
|
+
let unclosedMasked = false;
|
|
1772
|
+
// 步骤一:处理已闭合的代码块
|
|
1773
|
+
CLOSED_BLOCK_RE.lastIndex = 0;
|
|
1774
|
+
masked = masked.replace(CLOSED_BLOCK_RE, (fullMatch, content) => {
|
|
1775
|
+
// 检查代码块内容是否包含 card_type 关键词
|
|
1776
|
+
if (/["']card_type["']/.test(content)) {
|
|
1777
|
+
closedMaskCount++;
|
|
1778
|
+
return "\n\n📋 *正在生成卡片消息...*\n\n";
|
|
1779
|
+
}
|
|
1780
|
+
// 非模板卡片代码块,保留原样
|
|
1781
|
+
return fullMatch;
|
|
1782
|
+
});
|
|
1783
|
+
// 步骤二:处理未闭合的代码块尾部(LLM 仍在输出中)
|
|
1784
|
+
// 检查是否有以 ``` 开头但没有闭合的代码块
|
|
1785
|
+
const unclosedMatch = UNCLOSED_BLOCK_RE.exec(masked);
|
|
1786
|
+
if (unclosedMatch) {
|
|
1787
|
+
const unclosedContent = unclosedMatch[0];
|
|
1788
|
+
// 如果未闭合部分包含 card_type 关键词,说明正在构建模板卡片 → 截断
|
|
1789
|
+
if (/["']card_type["']/.test(unclosedContent)) {
|
|
1790
|
+
unclosedMasked = true;
|
|
1791
|
+
masked = masked.slice(0, unclosedMatch.index) + "\n\n📋 *正在生成卡片消息...*";
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
// 有遮罩行为时才打日志,避免每帧都刷屏
|
|
1795
|
+
if (closedMaskCount > 0 || unclosedMasked) {
|
|
1796
|
+
log?.(`[template-card-parser] maskTemplateCardBlocks: closedMasked=${closedMaskCount}, unclosedMasked=${unclosedMasked}, textLength=${text.length}, maskedLength=${masked.length}`);
|
|
1797
|
+
}
|
|
1798
|
+
return masked;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1087
1801
|
/**
|
|
1088
1802
|
* 企业微信群组访问控制模块
|
|
1089
1803
|
*
|
|
@@ -1551,6 +2265,144 @@ const PLUGIN_VERSION = getVersion();
|
|
|
1551
2265
|
* - state-manager.ts : 全局状态管理(带 TTL 清理)
|
|
1552
2266
|
* - timeout.ts : 超时工具
|
|
1553
2267
|
*/
|
|
2268
|
+
/**
|
|
2269
|
+
* 去除文本中的 `<think>...</think>` 标签(支持跨行),返回剩余可见文本。
|
|
2270
|
+
* 用于判断大模型回复中是否包含实际用户可见内容(而非仅有 thinking 推理过程)。
|
|
2271
|
+
*/
|
|
2272
|
+
function stripThinkTags(text) {
|
|
2273
|
+
return text;
|
|
2274
|
+
// return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
2275
|
+
}
|
|
2276
|
+
const sentTemplateCardByTaskId = new Map();
|
|
2277
|
+
const TEMPLATE_CARD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
2278
|
+
const TEMPLATE_CARD_CACHE_MAX_SIZE = 300;
|
|
2279
|
+
function getTemplateCardCacheKey(accountId, taskId) {
|
|
2280
|
+
return `${accountId}:${taskId}`;
|
|
2281
|
+
}
|
|
2282
|
+
function pruneTemplateCardCache() {
|
|
2283
|
+
const now = Date.now();
|
|
2284
|
+
for (const [key, entry] of sentTemplateCardByTaskId) {
|
|
2285
|
+
if (now - entry.createdAt >= TEMPLATE_CARD_CACHE_TTL_MS) {
|
|
2286
|
+
sentTemplateCardByTaskId.delete(key);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
if (sentTemplateCardByTaskId.size <= TEMPLATE_CARD_CACHE_MAX_SIZE) {
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
const sortedEntries = [...sentTemplateCardByTaskId.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
2293
|
+
const removeCount = sentTemplateCardByTaskId.size - TEMPLATE_CARD_CACHE_MAX_SIZE;
|
|
2294
|
+
for (const [key] of sortedEntries.slice(0, removeCount)) {
|
|
2295
|
+
sentTemplateCardByTaskId.delete(key);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
function cloneTemplateCard(card) {
|
|
2299
|
+
return JSON.parse(JSON.stringify(card));
|
|
2300
|
+
}
|
|
2301
|
+
function saveTemplateCardToCache(params) {
|
|
2302
|
+
const { accountId, templateCard, runtime } = params;
|
|
2303
|
+
const taskId = templateCard.task_id;
|
|
2304
|
+
if (!taskId) {
|
|
2305
|
+
runtime.log?.("[wecom][template-card] Skip cache: template card has no task_id");
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
sentTemplateCardByTaskId.set(getTemplateCardCacheKey(accountId, taskId), {
|
|
2309
|
+
templateCard: cloneTemplateCard(templateCard),
|
|
2310
|
+
createdAt: Date.now(),
|
|
2311
|
+
});
|
|
2312
|
+
pruneTemplateCardCache();
|
|
2313
|
+
}
|
|
2314
|
+
function getTemplateCardFromCache(accountId, taskId) {
|
|
2315
|
+
pruneTemplateCardCache();
|
|
2316
|
+
const cached = sentTemplateCardByTaskId.get(getTemplateCardCacheKey(accountId, taskId));
|
|
2317
|
+
if (!cached) {
|
|
2318
|
+
return undefined;
|
|
2319
|
+
}
|
|
2320
|
+
return cloneTemplateCard(cached.templateCard);
|
|
2321
|
+
}
|
|
2322
|
+
function buildSelectedOptionMap(templateCardEvent) {
|
|
2323
|
+
const selectedMap = new Map();
|
|
2324
|
+
const selectedItems = templateCardEvent?.selected_items?.selected_item ?? [];
|
|
2325
|
+
for (const item of selectedItems) {
|
|
2326
|
+
const questionKey = item.question_key?.trim();
|
|
2327
|
+
if (!questionKey) {
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
2330
|
+
const optionIds = item.option_ids?.option_id?.filter(Boolean) ?? [];
|
|
2331
|
+
selectedMap.set(questionKey, optionIds);
|
|
2332
|
+
}
|
|
2333
|
+
return selectedMap;
|
|
2334
|
+
}
|
|
2335
|
+
function applySelectedStateToTemplateCard(params) {
|
|
2336
|
+
const { templateCard, selectedMap, templateCardEvent } = params;
|
|
2337
|
+
const nextCard = cloneTemplateCard(templateCard);
|
|
2338
|
+
if (templateCardEvent?.task_id) {
|
|
2339
|
+
nextCard.task_id = templateCardEvent.task_id;
|
|
2340
|
+
}
|
|
2341
|
+
if (templateCardEvent?.card_type) {
|
|
2342
|
+
nextCard.card_type = templateCardEvent.card_type;
|
|
2343
|
+
}
|
|
2344
|
+
// 交互完成后将提交按钮文案更新为已提交,提升用户感知
|
|
2345
|
+
if (nextCard.submit_button?.text) {
|
|
2346
|
+
nextCard.submit_button.text = "已提交";
|
|
2347
|
+
}
|
|
2348
|
+
if (nextCard.checkbox?.question_key) {
|
|
2349
|
+
const selectedIds = selectedMap.get(nextCard.checkbox.question_key) ?? [];
|
|
2350
|
+
nextCard.checkbox.disable = true;
|
|
2351
|
+
if (Array.isArray(nextCard.checkbox.option_list)) {
|
|
2352
|
+
nextCard.checkbox.option_list = nextCard.checkbox.option_list.map((option) => ({
|
|
2353
|
+
...option,
|
|
2354
|
+
is_checked: selectedIds.includes(option.id),
|
|
2355
|
+
}));
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
if (Array.isArray(nextCard.select_list)) {
|
|
2359
|
+
nextCard.select_list = nextCard.select_list.map((selection) => {
|
|
2360
|
+
const selectedIds = selectedMap.get(selection.question_key) ?? [];
|
|
2361
|
+
return {
|
|
2362
|
+
...selection,
|
|
2363
|
+
disable: true,
|
|
2364
|
+
selected_id: selectedIds[0] ?? selection.selected_id,
|
|
2365
|
+
};
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
if (nextCard.button_selection?.question_key) {
|
|
2369
|
+
const selectedIds = selectedMap.get(nextCard.button_selection.question_key) ?? [];
|
|
2370
|
+
nextCard.button_selection.disable = true;
|
|
2371
|
+
if (selectedIds[0]) {
|
|
2372
|
+
nextCard.button_selection.selected_id = selectedIds[0];
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
return nextCard;
|
|
2376
|
+
}
|
|
2377
|
+
async function updateTemplateCardOnEvent(params) {
|
|
2378
|
+
const { frame, accountId, runtime, wsClient } = params;
|
|
2379
|
+
const body = frame.body;
|
|
2380
|
+
const templateCardEvent = body.event?.template_card_event;
|
|
2381
|
+
const taskId = templateCardEvent?.task_id;
|
|
2382
|
+
if (!taskId) {
|
|
2383
|
+
runtime.log?.(`[${accountId}] [template-card-update] Skip update: missing task_id in callback`);
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
const cachedCard = getTemplateCardFromCache(accountId, taskId);
|
|
2387
|
+
if (!cachedCard) {
|
|
2388
|
+
runtime.log?.(`[${accountId}] [template-card-update] Skip update: task_id=${taskId} not found in cache`);
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
const selectedMap = buildSelectedOptionMap(templateCardEvent);
|
|
2392
|
+
const updatedCard = applySelectedStateToTemplateCard({
|
|
2393
|
+
templateCard: cachedCard,
|
|
2394
|
+
selectedMap,
|
|
2395
|
+
templateCardEvent,
|
|
2396
|
+
});
|
|
2397
|
+
await wsClient.updateTemplateCard(frame, updatedCard, [body.from.userid]);
|
|
2398
|
+
runtime.log?.(`[${accountId}] [template-card-update] Updated card by task_id=${taskId}`);
|
|
2399
|
+
// 将更新后的卡片写回缓存,后续多次点击时状态保持一致
|
|
2400
|
+
saveTemplateCardToCache({
|
|
2401
|
+
accountId,
|
|
2402
|
+
templateCard: updatedCard,
|
|
2403
|
+
runtime,
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
1554
2406
|
// ============================================================================
|
|
1555
2407
|
// 媒体本地路径白名单扩展
|
|
1556
2408
|
// ============================================================================
|
|
@@ -1747,6 +2599,7 @@ async function sendMediaBatch(ctx, mediaUrls) {
|
|
|
1747
2599
|
* 替换掉 thinking 动画,否则 thinking 会一直残留。
|
|
1748
2600
|
*
|
|
1749
2601
|
* 关闭策略(按优先级):
|
|
2602
|
+
* 0. [新增] 有模板卡片代码块 → 提取卡片并主动发送,用剩余文本关闭流
|
|
1750
2603
|
* 1. 有可见文本 → 用完整文本关闭
|
|
1751
2604
|
* 2. 有媒体成功发送(通过 deliver 回调) → 用友好提示"文件已发送"
|
|
1752
2605
|
* 3. 媒体发送失败 → 直接用错误摘要替换 thinking
|
|
@@ -1763,8 +2616,40 @@ async function finishThinkingStream(ctx) {
|
|
|
1763
2616
|
const { wsClient, frame, state, runtime } = ctx;
|
|
1764
2617
|
const body = frame.body;
|
|
1765
2618
|
const chatId = body.chatid || body.from.userid;
|
|
2619
|
+
const visibleText = stripThinkTags(state.accumulatedText);
|
|
2620
|
+
// ── 模板卡片检测与发送 ──────────────────────────────────────────────
|
|
2621
|
+
// 在确定 finishText 之前,先检查累积文本中是否包含模板卡片 JSON 代码块。
|
|
2622
|
+
// 若检测到合法卡片,通过 sendMessage 主动发送后,用剩余文本关闭流。
|
|
2623
|
+
if (visibleText) {
|
|
2624
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: visibleText exists, length=${visibleText.length}, running extractTemplateCards...`);
|
|
2625
|
+
const logFn = (...args) => {
|
|
2626
|
+
runtime.log?.(...args);
|
|
2627
|
+
};
|
|
2628
|
+
const { cards, remainingText } = extractTemplateCards(state.accumulatedText, logFn);
|
|
2629
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: extractTemplateCards result — cards=${cards.length}, remainingTextLength=${remainingText.length}`);
|
|
2630
|
+
if (cards.length > 0) {
|
|
2631
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: ${cards.length} card(s) detected, card_types=[${cards.map(c => c.cardType).join(", ")}]`);
|
|
2632
|
+
await sendTemplateCards(ctx, cards);
|
|
2633
|
+
// 用剩余文本关闭流(可能为空)
|
|
2634
|
+
const trimmedRemaining = stripThinkTags(remainingText);
|
|
2635
|
+
const finishText = trimmedRemaining
|
|
2636
|
+
? remainingText
|
|
2637
|
+
: (state.hasTemplateCard ? "📋 卡片消息已发送。" : "");
|
|
2638
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: closing stream with finishText="${finishText.slice(0, 100)}...", hasTemplateCard=${state.hasTemplateCard}`);
|
|
2639
|
+
await sendWeComReply({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
else {
|
|
2644
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: no visibleText, skipping template card extraction`);
|
|
2645
|
+
}
|
|
2646
|
+
// ── 模板卡片检测结束 ────────────────────────────────────────────────
|
|
1766
2647
|
let finishText = state.accumulatedText;
|
|
1767
|
-
if (
|
|
2648
|
+
if (visibleText) {
|
|
2649
|
+
// 有可见文本:用完整文本关闭流(覆盖 thinking 为真实内容)
|
|
2650
|
+
finishText = state.accumulatedText;
|
|
2651
|
+
}
|
|
2652
|
+
else if (state.hasMedia) {
|
|
1768
2653
|
if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
1769
2654
|
// 媒体成功发送:用友好提示告知用户
|
|
1770
2655
|
finishText = finishText ? `${finishText}\n\n${state.mediaErrorSummary}` : state.mediaErrorSummary;
|
|
@@ -1801,6 +2686,41 @@ async function finishThinkingStream(ctx) {
|
|
|
1801
2686
|
}
|
|
1802
2687
|
}
|
|
1803
2688
|
}
|
|
2689
|
+
/**
|
|
2690
|
+
* 逐个发送已提取的模板卡片(通过 wsClient.sendMessage 主动推送)
|
|
2691
|
+
*
|
|
2692
|
+
* 发送失败不阻塞流程,仅记录错误日志。
|
|
2693
|
+
*/
|
|
2694
|
+
async function sendTemplateCards(ctx, cards) {
|
|
2695
|
+
const { wsClient, frame, state, runtime, account } = ctx;
|
|
2696
|
+
const body = frame.body;
|
|
2697
|
+
const chatId = body.chatid || body.from.userid;
|
|
2698
|
+
for (const card of cards) {
|
|
2699
|
+
try {
|
|
2700
|
+
runtime.log?.(`[wecom][template-card] Sending card_type=${card.cardType} to chatId=${chatId}`);
|
|
2701
|
+
const rawTemplateCard = card.cardJson;
|
|
2702
|
+
if (typeof rawTemplateCard.card_type !== "string") {
|
|
2703
|
+
runtime.error?.("[wecom][template-card] Skip sending invalid card: missing card_type");
|
|
2704
|
+
continue;
|
|
2705
|
+
}
|
|
2706
|
+
const templateCard = rawTemplateCard;
|
|
2707
|
+
await wsClient.sendMessage(chatId, {
|
|
2708
|
+
msgtype: "template_card",
|
|
2709
|
+
template_card: templateCard,
|
|
2710
|
+
});
|
|
2711
|
+
state.hasTemplateCard = true;
|
|
2712
|
+
saveTemplateCardToCache({
|
|
2713
|
+
accountId: account.accountId,
|
|
2714
|
+
templateCard,
|
|
2715
|
+
runtime,
|
|
2716
|
+
});
|
|
2717
|
+
runtime.log?.(`[wecom][template-card] Card sent successfully: card_type=${card.cardType}`);
|
|
2718
|
+
}
|
|
2719
|
+
catch (err) {
|
|
2720
|
+
runtime.error?.(`[wecom][template-card] Failed to send card: card_type=${card.cardType}, error=${JSON.stringify(err)}`);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
1804
2724
|
/**
|
|
1805
2725
|
* 路由消息到核心处理流程并处理回复
|
|
1806
2726
|
*/
|
|
@@ -1873,9 +2793,15 @@ async function routeAndDispatchMessage(params) {
|
|
|
1873
2793
|
}
|
|
1874
2794
|
}
|
|
1875
2795
|
// 中间帧:有可见文本时流式更新(流式过期后跳过,等 deliver 完成后主动发送)
|
|
2796
|
+
// 使用 maskTemplateCardBlocks 遮罩正在构建中的模板卡片代码块,
|
|
2797
|
+
// 避免 JSON 源码在流式输出过程中暴露给终端用户
|
|
1876
2798
|
if (info.kind !== "final" && state.accumulatedText && !state.streamExpired) {
|
|
1877
2799
|
try {
|
|
1878
|
-
|
|
2800
|
+
const displayText = maskTemplateCardBlocks(state.accumulatedText, (...args) => runtime.log?.(...args));
|
|
2801
|
+
if (displayText !== state.accumulatedText) {
|
|
2802
|
+
runtime.log?.(`[wecom][template-card] Mid-frame masked: original=${state.accumulatedText.length}chars, masked=${displayText.length}chars`);
|
|
2803
|
+
}
|
|
2804
|
+
await sendWeComReply({ wsClient, frame, text: displayText, runtime, finish: false, streamId: state.streamId });
|
|
1879
2805
|
}
|
|
1880
2806
|
catch (err) {
|
|
1881
2807
|
if (err instanceof StreamExpiredError) {
|
|
@@ -2058,6 +2984,7 @@ function createSdkLogger(runtime, accountId) {
|
|
|
2058
2984
|
async function monitorWeComProvider(options) {
|
|
2059
2985
|
const { account, config, runtime, abortSignal, setStatus } = options;
|
|
2060
2986
|
runtime.log?.(`[${account.accountId}] [${PLUGIN_VERSION}] Initializing WSClient with SDK...`);
|
|
2987
|
+
runtime.error?.(`[${account.accountId}] [diag] monitor boot marker: build=20260325-event-debug-1`);
|
|
2061
2988
|
// 启动消息状态定期清理
|
|
2062
2989
|
startMessageStateCleanup();
|
|
2063
2990
|
return new Promise((resolve, reject) => {
|
|
@@ -2196,7 +3123,7 @@ async function monitorWeComProvider(options) {
|
|
|
2196
3123
|
// runtime.error?.(`[${account.accountId}] Failed to reply enter_check_update: ${String(err)}`);
|
|
2197
3124
|
}
|
|
2198
3125
|
});
|
|
2199
|
-
//
|
|
3126
|
+
// 监听普通消息
|
|
2200
3127
|
wsClient.on("message", async (frame) => {
|
|
2201
3128
|
try {
|
|
2202
3129
|
await processWeComMessage({
|
|
@@ -2211,6 +3138,44 @@ async function monitorWeComProvider(options) {
|
|
|
2211
3138
|
runtime.error?.(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
|
2212
3139
|
}
|
|
2213
3140
|
});
|
|
3141
|
+
// 监听所有事件回调(aibot_event_callback)。
|
|
3142
|
+
// 这里使用通用 event 监听,再按 eventtype 分发,兼容不同 SDK 版本在细分事件名上的差异。
|
|
3143
|
+
wsClient.on("event", async (frame) => {
|
|
3144
|
+
try {
|
|
3145
|
+
const eventBody = frame.body;
|
|
3146
|
+
const eventType = eventBody.event?.eventtype;
|
|
3147
|
+
runtime.log?.(`[${account.accountId}] Received event callback: eventtype=${eventType ?? ""}, msgid=${eventBody.msgid ?? ""}`);
|
|
3148
|
+
runtime.error?.(`[${account.accountId}] [diag] event-listener fired: eventtype=${eventType ?? ""}, msgid=${eventBody.msgid ?? ""}`);
|
|
3149
|
+
if (eventType !== "template_card_event") {
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
const templateCardEvent = eventBody.event?.template_card_event;
|
|
3153
|
+
runtime.log?.(`[${account.accountId}] Received template_card_event: event_key=${templateCardEvent?.event_key ?? ""}, task_id=${templateCardEvent?.task_id ?? ""}`);
|
|
3154
|
+
try {
|
|
3155
|
+
await updateTemplateCardOnEvent({
|
|
3156
|
+
frame,
|
|
3157
|
+
accountId: account.accountId,
|
|
3158
|
+
runtime,
|
|
3159
|
+
wsClient,
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
catch (updateErr) {
|
|
3163
|
+
runtime.error?.(`[${account.accountId}] [template-card-update] Failed to update template card: ${String(updateErr)}`);
|
|
3164
|
+
}
|
|
3165
|
+
await processWeComMessage({
|
|
3166
|
+
frame,
|
|
3167
|
+
account,
|
|
3168
|
+
config,
|
|
3169
|
+
runtime,
|
|
3170
|
+
wsClient,
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
catch (err) {
|
|
3174
|
+
runtime.error?.(`[${account.accountId}] Failed to process template_card_event: ${String(err)}`);
|
|
3175
|
+
}
|
|
3176
|
+
});
|
|
3177
|
+
runtime.log?.(`[${account.accountId}] Event listeners attached: message + event(template_card_event)`);
|
|
3178
|
+
runtime.error?.(`[${account.accountId}] [diag] listeners-ready marker`);
|
|
2214
3179
|
// 启动前预热 reqId 缓存,确保完成后再建立连接,避免 getSync 在预热完成前返回 undefined
|
|
2215
3180
|
warmupReqIdStore(account.accountId, (...args) => runtime.log?.(...args))
|
|
2216
3181
|
.then((count) => {
|
|
@@ -3279,59 +4244,16 @@ function cleanWithDefs(schema, defs, refStack) {
|
|
|
3279
4244
|
}
|
|
3280
4245
|
|
|
3281
4246
|
/**
|
|
3282
|
-
*
|
|
3283
|
-
*
|
|
3284
|
-
* 通过 MCP Streamable HTTP 传输协议调用企业微信 MCP Server,
|
|
3285
|
-
* 提供 list(列出所有工具)和 call(调用工具)两个操作。
|
|
4247
|
+
* 业务错误码检查拦截器
|
|
3286
4248
|
*
|
|
3287
|
-
*
|
|
3288
|
-
*
|
|
3289
|
-
*
|
|
4249
|
+
* 检查 tools/call 返回结果中是否包含需要清理缓存的业务错误码。
|
|
4250
|
+
* MCP Server 可能在正常的 JSON-RPC 响应中返回业务层错误,
|
|
4251
|
+
* 这些错误被包裹在 result.content[].text 中,需要解析后判断。
|
|
3290
4252
|
*
|
|
3291
|
-
*
|
|
3292
|
-
* wecom_mcp list contact
|
|
3293
|
-
* wecom_mcp call contact getContact '{}'
|
|
4253
|
+
* 此拦截器对所有 call 调用生效。
|
|
3294
4254
|
*/
|
|
3295
4255
|
// ============================================================================
|
|
3296
|
-
//
|
|
3297
|
-
// ============================================================================
|
|
3298
|
-
/** 构造统一的文本响应结构 */
|
|
3299
|
-
const textResult = (data) => ({
|
|
3300
|
-
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
3301
|
-
});
|
|
3302
|
-
/** 构造错误响应 */
|
|
3303
|
-
const errorResult = (err) => {
|
|
3304
|
-
// 适配企业微信 API 返回的 { errcode, errmsg } 结构
|
|
3305
|
-
if (err && typeof err === "object" && "errcode" in err) {
|
|
3306
|
-
const { errcode, errmsg } = err;
|
|
3307
|
-
return textResult({ error: errmsg ?? `错误码: ${errcode}`, errcode });
|
|
3308
|
-
}
|
|
3309
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3310
|
-
return textResult({ error: message });
|
|
3311
|
-
};
|
|
3312
|
-
// ============================================================================
|
|
3313
|
-
// list 操作:列出某品类的所有 MCP 工具
|
|
3314
|
-
// ============================================================================
|
|
3315
|
-
const handleList = async (category) => {
|
|
3316
|
-
const result = await sendJsonRpc(category, "tools/list");
|
|
3317
|
-
const tools = result?.tools ?? [];
|
|
3318
|
-
if (tools.length === 0) {
|
|
3319
|
-
return { message: `品类 "${category}" 下暂无可用工具`, tools: [] };
|
|
3320
|
-
}
|
|
3321
|
-
return {
|
|
3322
|
-
category,
|
|
3323
|
-
count: tools.length,
|
|
3324
|
-
tools: tools.map((t) => ({
|
|
3325
|
-
name: t.name,
|
|
3326
|
-
description: t.description ?? "",
|
|
3327
|
-
// 清洗 inputSchema,内联 $ref/$defs 引用并移除 Gemini 不支持的关键词,
|
|
3328
|
-
// 避免 Gemini 模型解析 function response 时报 400 错误
|
|
3329
|
-
inputSchema: t.inputSchema ? cleanSchemaForGemini(t.inputSchema) : undefined,
|
|
3330
|
-
})),
|
|
3331
|
-
};
|
|
3332
|
-
};
|
|
3333
|
-
// ============================================================================
|
|
3334
|
-
// call 操作:调用某品类的某个 MCP 工具
|
|
4256
|
+
// 常量
|
|
3335
4257
|
// ============================================================================
|
|
3336
4258
|
/**
|
|
3337
4259
|
* 需要触发缓存清理的业务错误码集合
|
|
@@ -3342,13 +4264,27 @@ const handleList = async (category) => {
|
|
|
3342
4264
|
* - 850002: 机器人未被授权使用对应能力,需清理缓存以便下次重新拉取配置
|
|
3343
4265
|
*/
|
|
3344
4266
|
const BIZ_CACHE_CLEAR_ERROR_CODES = new Set([850002]);
|
|
4267
|
+
// ============================================================================
|
|
4268
|
+
// 拦截器实现
|
|
4269
|
+
// ============================================================================
|
|
4270
|
+
const bizErrorInterceptor = {
|
|
4271
|
+
name: "biz-error",
|
|
4272
|
+
/** 对所有 call 调用生效 */
|
|
4273
|
+
match: () => true,
|
|
4274
|
+
/** 检查返回结果中的业务错误码,必要时清理缓存 */
|
|
4275
|
+
afterCall(ctx, result) {
|
|
4276
|
+
checkBizErrorAndClearCache(result, ctx.category);
|
|
4277
|
+
// 不修改 result,透传给下一个拦截器
|
|
4278
|
+
return result;
|
|
4279
|
+
},
|
|
4280
|
+
};
|
|
4281
|
+
// ============================================================================
|
|
4282
|
+
// 内部实现
|
|
4283
|
+
// ============================================================================
|
|
3345
4284
|
/**
|
|
3346
4285
|
* 检查 tools/call 的返回结果中是否包含需要清理缓存的业务错误码
|
|
3347
|
-
*
|
|
3348
|
-
* MCP Server 可能在正常的 JSON-RPC 响应中返回业务层错误,
|
|
3349
|
-
* 这些错误被包裹在 result.content[].text 中,需要解析后判断。
|
|
3350
4286
|
*/
|
|
3351
|
-
|
|
4287
|
+
function checkBizErrorAndClearCache(result, category) {
|
|
3352
4288
|
if (!result || typeof result !== "object")
|
|
3353
4289
|
return;
|
|
3354
4290
|
const { content } = result;
|
|
@@ -3369,14 +4305,39 @@ const checkBizErrorAndClearCache = (result, category) => {
|
|
|
3369
4305
|
// text 不是 JSON 格式,跳过
|
|
3370
4306
|
}
|
|
3371
4307
|
}
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
/**
|
|
4311
|
+
* get_msg_media 响应拦截器
|
|
4312
|
+
*
|
|
4313
|
+
* 核心逻辑:
|
|
4314
|
+
* 1. beforeCall: 设置延长的超时时间(120s),因为 base64 数据可达 ~27MB
|
|
4315
|
+
* 2. afterCall: 从 MCP result 的 content[].text 中提取 base64_data,
|
|
4316
|
+
* 解码为 Buffer 并通过 saveMediaBuffer 保存到本地媒体目录,
|
|
4317
|
+
* 替换响应中的 base64_data 为 local_path,避免大模型被 base64 数据消耗 token
|
|
4318
|
+
*/
|
|
4319
|
+
// ============================================================================
|
|
4320
|
+
// 拦截器实现
|
|
4321
|
+
// ============================================================================
|
|
4322
|
+
const mediaInterceptor = {
|
|
4323
|
+
name: "media",
|
|
4324
|
+
/** 仅对 get_msg_media 方法生效 */
|
|
4325
|
+
match: (ctx) => ctx.method === "get_msg_media",
|
|
4326
|
+
/** 设置延长的超时时间 */
|
|
4327
|
+
beforeCall() {
|
|
4328
|
+
return { timeoutMs: MEDIA_DOWNLOAD_TIMEOUT_MS };
|
|
4329
|
+
},
|
|
4330
|
+
/** 拦截响应:base64 → 本地文件 */
|
|
4331
|
+
async afterCall(ctx, result) {
|
|
4332
|
+
return interceptMediaResponse(result);
|
|
4333
|
+
},
|
|
3372
4334
|
};
|
|
3373
4335
|
// ============================================================================
|
|
3374
|
-
//
|
|
4336
|
+
// 内部实现
|
|
3375
4337
|
// ============================================================================
|
|
3376
4338
|
/**
|
|
3377
4339
|
* 拦截 get_msg_media 的 MCP 响应
|
|
3378
4340
|
*
|
|
3379
|
-
* 核心逻辑:
|
|
3380
4341
|
* 1. 从 MCP result 的 content[].text 中提取业务 JSON
|
|
3381
4342
|
* 2. 提取 media_item.base64_data,解码为 Buffer
|
|
3382
4343
|
* 3. 通过 openclaw SDK 的 saveMediaBuffer 保存到本地媒体目录
|
|
@@ -3474,31 +4435,384 @@ async function interceptMediaResponse(result) {
|
|
|
3474
4435
|
}],
|
|
3475
4436
|
};
|
|
3476
4437
|
}
|
|
4438
|
+
|
|
4439
|
+
/**
|
|
4440
|
+
* smartpage_create 请求拦截器
|
|
4441
|
+
*
|
|
4442
|
+
* 核心逻辑:
|
|
4443
|
+
* smartpage_create 的 pages 数组中,每个 page 可能包含 page_filepath 字段
|
|
4444
|
+
* (指向本地 markdown 文件),用于避免在命令行传递大段文本内容。
|
|
4445
|
+
* 本拦截器在 beforeCall 阶段遍历 pages 数组,逐个读取 page_filepath
|
|
4446
|
+
* 指向的本地文件内容,填入 page_content 字段,并移除 page_filepath。
|
|
4447
|
+
*
|
|
4448
|
+
* 入参约定:
|
|
4449
|
+
* wecom_mcp call doc smartpage_create '{
|
|
4450
|
+
* "title": "主页标题",
|
|
4451
|
+
* "pages": [
|
|
4452
|
+
* {"page_title": "页面1", "page_filepath": "/tmp/page1.md", "content_type": "markdown"},
|
|
4453
|
+
* {"page_title": "页面2", "page_filepath": "/tmp/page2.md", "content_type": "markdown"}
|
|
4454
|
+
* ]
|
|
4455
|
+
* }'
|
|
4456
|
+
*
|
|
4457
|
+
* 拦截器行为:
|
|
4458
|
+
* 1. 检测 args.pages 数组
|
|
4459
|
+
* 2. 校验文件大小:单文件不超过 10MB,所有文件总计不超过 20MB
|
|
4460
|
+
* 3. 遍历每个 page,若存在 page_filepath 则读取本地文件内容
|
|
4461
|
+
* 4. 将文件内容填入 page_content 字段,移除 page_filepath
|
|
4462
|
+
* 5. 返回修改后的完整 args
|
|
4463
|
+
*
|
|
4464
|
+
* 传给 MCP Server 的格式:
|
|
4465
|
+
* { "title": "...", "pages": [{"page_title": "...", "page_content": "...", "content_type": "..."}] }
|
|
4466
|
+
*/
|
|
4467
|
+
// ============================================================================
|
|
4468
|
+
// 常量
|
|
4469
|
+
// ============================================================================
|
|
4470
|
+
/** 单个 page_filepath 文件大小上限:10MB */
|
|
4471
|
+
const MAX_SINGLE_FILE_SIZE = 10 * 1024 * 1024;
|
|
4472
|
+
/** 所有 page_filepath 文件总大小上限:20MB */
|
|
4473
|
+
const MAX_TOTAL_FILE_SIZE = 20 * 1024 * 1024;
|
|
4474
|
+
// ============================================================================
|
|
4475
|
+
// 内部辅助函数
|
|
4476
|
+
// ============================================================================
|
|
4477
|
+
/**
|
|
4478
|
+
* 校验所有 page_filepath 的文件大小
|
|
4479
|
+
*
|
|
4480
|
+
* 使用 fs.stat 在读取文件内容之前检查大小,避免超大文件被加载到内存。
|
|
4481
|
+
* - 单文件 > 10MB → 报错
|
|
4482
|
+
* - 所有文件累计 > 20MB → 报错
|
|
4483
|
+
*/
|
|
4484
|
+
async function validateFileSize(pages) {
|
|
4485
|
+
let totalSize = 0;
|
|
4486
|
+
for (let i = 0; i < pages.length; i++) {
|
|
4487
|
+
const filePath = pages[i].page_filepath;
|
|
4488
|
+
if (typeof filePath !== "string" || !filePath)
|
|
4489
|
+
continue;
|
|
4490
|
+
let stat;
|
|
4491
|
+
try {
|
|
4492
|
+
stat = await fs__namespace.stat(filePath);
|
|
4493
|
+
}
|
|
4494
|
+
catch (err) {
|
|
4495
|
+
// stat 失败不在这里处理,留给后续 readFile 阶段抛出更详细的错误
|
|
4496
|
+
continue;
|
|
4497
|
+
}
|
|
4498
|
+
if (stat.size > MAX_SINGLE_FILE_SIZE) {
|
|
4499
|
+
console.error(`[mcp] smartpage_create: pages[${i}] 文件 "${filePath}" ` +
|
|
4500
|
+
`大小 ${(stat.size / 1024 / 1024).toFixed(1)}MB 超过单文件上限 10MB`);
|
|
4501
|
+
throw new Error("内容大小超出限制,无法创建");
|
|
4502
|
+
}
|
|
4503
|
+
totalSize += stat.size;
|
|
4504
|
+
if (totalSize > MAX_TOTAL_FILE_SIZE) {
|
|
4505
|
+
console.error(`[mcp] smartpage_create: 累计文件大小 ${(totalSize / 1024 / 1024).toFixed(1)}MB ` +
|
|
4506
|
+
`超过总上限 20MB(在 pages[${i}] "${filePath}" 处超出)`);
|
|
4507
|
+
throw new Error("内容大小超出限制,无法创建");
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
if (totalSize > 0) {
|
|
4511
|
+
console.log(`[mcp] smartpage_create: 文件大小校验通过,总计 ${(totalSize / 1024 / 1024).toFixed(2)}MB`);
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
/** 异步解析 pages 中的 page_filepath,返回 BeforeCallOptions */
|
|
4515
|
+
async function resolvePages(ctx, pages) {
|
|
4516
|
+
console.log(`[mcp] smartpage_create: 开始解析 ${pages.length} 个 page 的 page_filepath`);
|
|
4517
|
+
// 阶段 1:文件大小校验(stat 阶段,不读内容)
|
|
4518
|
+
await validateFileSize(pages);
|
|
4519
|
+
// 阶段 2:读取文件内容
|
|
4520
|
+
const resolvedPages = await Promise.all(pages.map(async (page, index) => {
|
|
4521
|
+
const filePath = page.page_filepath;
|
|
4522
|
+
if (typeof filePath !== "string" || !filePath) {
|
|
4523
|
+
// 该 page 没有 page_filepath,保留原样(可能已有 page_content)
|
|
4524
|
+
return page;
|
|
4525
|
+
}
|
|
4526
|
+
let fileContent;
|
|
4527
|
+
try {
|
|
4528
|
+
fileContent = await fs__namespace.readFile(filePath, "utf-8");
|
|
4529
|
+
}
|
|
4530
|
+
catch (err) {
|
|
4531
|
+
throw new Error(`smartpage_create: pages[${index}] 无法读取文件 "${filePath}": ${err instanceof Error ? err.message : String(err)}`);
|
|
4532
|
+
}
|
|
4533
|
+
console.log(`[mcp] smartpage_create: pages[${index}] 读取成功 "${filePath}" (${fileContent.length} chars)`);
|
|
4534
|
+
// 构造新的 page 对象:填入 page_content,移除 page_filepath
|
|
4535
|
+
const { page_filepath: _, ...rest } = page;
|
|
4536
|
+
return { ...rest, page_content: fileContent };
|
|
4537
|
+
}));
|
|
4538
|
+
console.log(`[mcp] smartpage_create: 所有 page_filepath 解析完成`);
|
|
4539
|
+
// 返回修改后的完整 args
|
|
4540
|
+
return {
|
|
4541
|
+
args: {
|
|
4542
|
+
...ctx.args,
|
|
4543
|
+
pages: resolvedPages,
|
|
4544
|
+
},
|
|
4545
|
+
};
|
|
4546
|
+
}
|
|
4547
|
+
// ============================================================================
|
|
4548
|
+
// 拦截器实现
|
|
4549
|
+
// ============================================================================
|
|
4550
|
+
const smartpageCreateInterceptor = {
|
|
4551
|
+
name: "smartpage-create",
|
|
4552
|
+
/** 仅对 doc 品类的 smartpage_create 方法生效 */
|
|
4553
|
+
match: (ctx) => ctx.category === "doc" && ctx.method === "smartpage_create",
|
|
4554
|
+
/** 遍历 pages 数组,逐个读取 page_filepath 填入 page_content */
|
|
4555
|
+
beforeCall(ctx) {
|
|
4556
|
+
const pages = ctx.args.pages;
|
|
4557
|
+
if (!Array.isArray(pages) || pages.length === 0) {
|
|
4558
|
+
// 没有 pages 数组,不做拦截
|
|
4559
|
+
return undefined;
|
|
4560
|
+
}
|
|
4561
|
+
// 检查是否有任何 page 包含 page_filepath
|
|
4562
|
+
const hasFilePath = pages.some((p) => typeof p.page_filepath === "string" && p.page_filepath);
|
|
4563
|
+
if (!hasFilePath) {
|
|
4564
|
+
// 所有 page 都没有 page_filepath(可能直接传了 page_content),不做拦截
|
|
4565
|
+
return undefined;
|
|
4566
|
+
}
|
|
4567
|
+
return resolvePages(ctx, pages);
|
|
4568
|
+
},
|
|
4569
|
+
};
|
|
4570
|
+
|
|
4571
|
+
/**
|
|
4572
|
+
* smartpage_get_export_result 响应拦截器
|
|
4573
|
+
*
|
|
4574
|
+
* 核心逻辑:
|
|
4575
|
+
* MCP Server 返回的 smartpage_get_export_result 响应中,当 task_done=true 时
|
|
4576
|
+
* 会包含 content 字段(markdown 文本内容)。该内容可能很大,直接返回给 LLM
|
|
4577
|
+
* 会消耗大量 token。
|
|
4578
|
+
*
|
|
4579
|
+
* 本拦截器在 afterCall 阶段:
|
|
4580
|
+
* 1. 检测 task_done=true 且存在 content 字段
|
|
4581
|
+
* 2. 将 content 保存到本地文件(使用与 msg-media 一致的媒体目录)
|
|
4582
|
+
* 3. 用 content_path(文件路径)替换 content 字段
|
|
4583
|
+
*
|
|
4584
|
+
* 这样 LLM 只看到轻量的文件路径信息,Skill 可通过文件路径读取完整内容。
|
|
4585
|
+
*/
|
|
4586
|
+
// ============================================================================
|
|
4587
|
+
// 拦截器实现
|
|
4588
|
+
// ============================================================================
|
|
4589
|
+
const smartpageExportInterceptor = {
|
|
4590
|
+
name: "smartpage-export",
|
|
4591
|
+
/** 仅对 doc 品类的 smartpage_get_export_result 方法生效 */
|
|
4592
|
+
match: (ctx) => ctx.category === "doc" && ctx.method === "smartpage_get_export_result",
|
|
4593
|
+
/** 拦截响应:将 markdown content 保存为本地文件 */
|
|
4594
|
+
async afterCall(_ctx, result) {
|
|
4595
|
+
return interceptExportResponse(result);
|
|
4596
|
+
},
|
|
4597
|
+
};
|
|
4598
|
+
// ============================================================================
|
|
4599
|
+
// 内部实现
|
|
4600
|
+
// ============================================================================
|
|
4601
|
+
/**
|
|
4602
|
+
* 拦截 smartpage_get_export_result 的 MCP 响应
|
|
4603
|
+
*
|
|
4604
|
+
* 1. 从 MCP result 的 content[].text 中提取业务 JSON
|
|
4605
|
+
* 2. 检测 task_done=true 且存在 content 字段
|
|
4606
|
+
* 3. 将 content(markdown 文本)通过 saveMediaBuffer 保存到本地媒体目录
|
|
4607
|
+
* 4. 构造新响应:移除 content,添加 content_path
|
|
4608
|
+
*/
|
|
4609
|
+
async function interceptExportResponse(result) {
|
|
4610
|
+
// 1. 提取 MCP result 中的 content 数组
|
|
4611
|
+
const content = result?.content;
|
|
4612
|
+
if (!Array.isArray(content))
|
|
4613
|
+
return result;
|
|
4614
|
+
const textItem = content.find((c) => c.type === "text" && typeof c.text === "string");
|
|
4615
|
+
if (!textItem)
|
|
4616
|
+
return result;
|
|
4617
|
+
// 2. 解析业务 JSON
|
|
4618
|
+
let bizData;
|
|
4619
|
+
try {
|
|
4620
|
+
bizData = JSON.parse(textItem.text);
|
|
4621
|
+
}
|
|
4622
|
+
catch {
|
|
4623
|
+
// 非 JSON 格式,原样返回
|
|
4624
|
+
return result;
|
|
4625
|
+
}
|
|
4626
|
+
// 3. 校验:errcode !== 0 或 task_done 不为 true 或无 content 时原样返回
|
|
4627
|
+
if (bizData.errcode !== 0)
|
|
4628
|
+
return result;
|
|
4629
|
+
if (bizData.task_done !== true)
|
|
4630
|
+
return result;
|
|
4631
|
+
if (typeof bizData.content !== "string")
|
|
4632
|
+
return result;
|
|
4633
|
+
const markdownContent = bizData.content;
|
|
4634
|
+
console.log(`[mcp] smartpage_get_export_result: 拦截 content (${markdownContent.length} chars),保存到本地文件`);
|
|
4635
|
+
// 4. 将 markdown 内容通过 saveMediaBuffer 保存到本地媒体目录
|
|
4636
|
+
// 使用 text/markdown 类型,与 msg-media 拦截器保持一致的路径管理
|
|
4637
|
+
const buffer = Buffer.from(markdownContent, "utf-8");
|
|
4638
|
+
const core = getWeComRuntime();
|
|
4639
|
+
const saved = await core.channel.media.saveMediaBuffer(buffer, "text/markdown", "inbound", undefined, // maxBytes: markdown 文本通常不大,使用默认限制
|
|
4640
|
+
"smartpage_export.md");
|
|
4641
|
+
console.log(`[mcp] smartpage_get_export_result: 已保存到 ${saved.path}`);
|
|
4642
|
+
// 5. 构造新响应:移除 content,添加 content_path
|
|
4643
|
+
const newBizData = {
|
|
4644
|
+
errcode: bizData.errcode,
|
|
4645
|
+
errmsg: bizData.errmsg ?? "ok",
|
|
4646
|
+
task_done: true,
|
|
4647
|
+
content_path: saved.path,
|
|
4648
|
+
};
|
|
4649
|
+
// 6. 返回修改后的 MCP result 结构
|
|
4650
|
+
return {
|
|
4651
|
+
content: [{
|
|
4652
|
+
type: "text",
|
|
4653
|
+
text: JSON.stringify(newBizData),
|
|
4654
|
+
}],
|
|
4655
|
+
};
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4658
|
+
/**
|
|
4659
|
+
* MCP call 拦截器注册表与调度入口
|
|
4660
|
+
*
|
|
4661
|
+
* 所有 call 拦截器在此注册,按注册顺序执行。
|
|
4662
|
+
* 新增拦截器只需:
|
|
4663
|
+
* 1. 在 interceptors/ 目录下新建文件,实现 CallInterceptor 接口
|
|
4664
|
+
* 2. 在下方 interceptors 数组中注册
|
|
4665
|
+
*
|
|
4666
|
+
* tool.ts 的 handleCall 无需任何改动。
|
|
4667
|
+
*/
|
|
4668
|
+
// ============================================================================
|
|
4669
|
+
// 拦截器注册表(按注册顺序执行)
|
|
4670
|
+
// ============================================================================
|
|
4671
|
+
const interceptors = [
|
|
4672
|
+
bizErrorInterceptor, // 业务错误码检查(所有 call 生效)
|
|
4673
|
+
mediaInterceptor, // get_msg_media base64 拦截
|
|
4674
|
+
smartpageCreateInterceptor, // smartpage_create 本地文件读取
|
|
4675
|
+
smartpageExportInterceptor, // smartpage_get_export_result content → 本地文件
|
|
4676
|
+
];
|
|
4677
|
+
/**
|
|
4678
|
+
* 收集匹配的 beforeCall 配置,合并后返回
|
|
4679
|
+
*
|
|
4680
|
+
* 合并策略:
|
|
4681
|
+
* - timeoutMs: 取所有拦截器返回值中的最大值
|
|
4682
|
+
* - args: 后注册的拦截器覆盖前者(一般同一调用只有一个拦截器会返回 args)
|
|
4683
|
+
*/
|
|
4684
|
+
async function resolveBeforeCall(ctx) {
|
|
4685
|
+
let mergedTimeoutMs;
|
|
4686
|
+
let mergedArgs;
|
|
4687
|
+
for (const interceptor of interceptors) {
|
|
4688
|
+
if (!interceptor.match(ctx) || !interceptor.beforeCall)
|
|
4689
|
+
continue;
|
|
4690
|
+
const opts = await interceptor.beforeCall(ctx);
|
|
4691
|
+
if (opts?.timeoutMs !== undefined) {
|
|
4692
|
+
mergedTimeoutMs = mergedTimeoutMs === undefined
|
|
4693
|
+
? opts.timeoutMs
|
|
4694
|
+
: Math.max(mergedTimeoutMs, opts.timeoutMs);
|
|
4695
|
+
}
|
|
4696
|
+
if (opts?.args !== undefined) {
|
|
4697
|
+
mergedArgs = opts.args;
|
|
4698
|
+
}
|
|
4699
|
+
}
|
|
4700
|
+
return {
|
|
4701
|
+
options: mergedTimeoutMs !== undefined ? { timeoutMs: mergedTimeoutMs } : undefined,
|
|
4702
|
+
args: mergedArgs,
|
|
4703
|
+
};
|
|
4704
|
+
}
|
|
4705
|
+
/**
|
|
4706
|
+
* 依次执行匹配的 afterCall 拦截器,管道式传递 result
|
|
4707
|
+
*
|
|
4708
|
+
* 前一个拦截器的返回值作为下一个拦截器的输入。
|
|
4709
|
+
* 拦截器若不需要修改 result,应原样返回。
|
|
4710
|
+
*/
|
|
4711
|
+
async function runAfterCall(ctx, result) {
|
|
4712
|
+
let current = result;
|
|
4713
|
+
for (const interceptor of interceptors) {
|
|
4714
|
+
if (!interceptor.match(ctx) || !interceptor.afterCall)
|
|
4715
|
+
continue;
|
|
4716
|
+
current = await interceptor.afterCall(ctx, current);
|
|
4717
|
+
}
|
|
4718
|
+
return current;
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4721
|
+
/**
|
|
4722
|
+
* wecom_mcp — 模拟 MCP 调用的 Agent Tool
|
|
4723
|
+
*
|
|
4724
|
+
* 通过 MCP Streamable HTTP 传输协议调用企业微信 MCP Server,
|
|
4725
|
+
* 提供 list(列出所有工具)和 call(调用工具)两个操作。
|
|
4726
|
+
*
|
|
4727
|
+
* 在 skills 中的使用方式:
|
|
4728
|
+
* wecom_mcp list <category>
|
|
4729
|
+
* wecom_mcp call <category> <method> '<jsonArgs>'
|
|
4730
|
+
*
|
|
4731
|
+
* 示例:
|
|
4732
|
+
* wecom_mcp list contact
|
|
4733
|
+
* wecom_mcp call contact getContact '{}'
|
|
4734
|
+
*/
|
|
4735
|
+
// ============================================================================
|
|
4736
|
+
// 响应构造辅助
|
|
4737
|
+
// ============================================================================
|
|
4738
|
+
/** 构造统一的文本响应结构 */
|
|
4739
|
+
const textResult = (data) => ({
|
|
4740
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
4741
|
+
});
|
|
4742
|
+
/** 构造错误响应 */
|
|
4743
|
+
const errorResult = (err) => {
|
|
4744
|
+
// 适配企业微信 API 返回的 { errcode, errmsg } 结构
|
|
4745
|
+
if (err && typeof err === "object" && "errcode" in err) {
|
|
4746
|
+
const { errcode, errmsg } = err;
|
|
4747
|
+
return textResult({ error: errmsg ?? `错误码: ${errcode}`, errcode });
|
|
4748
|
+
}
|
|
4749
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4750
|
+
return textResult({ error: message });
|
|
4751
|
+
};
|
|
4752
|
+
// ============================================================================
|
|
4753
|
+
// list 操作:列出某品类的所有 MCP 工具
|
|
4754
|
+
// ============================================================================
|
|
4755
|
+
const handleList = async (category) => {
|
|
4756
|
+
const result = await sendJsonRpc(category, "tools/list");
|
|
4757
|
+
const tools = result?.tools ?? [];
|
|
4758
|
+
if (tools.length === 0) {
|
|
4759
|
+
return { message: `品类 "${category}" 下暂无可用工具`, tools: [] };
|
|
4760
|
+
}
|
|
4761
|
+
return {
|
|
4762
|
+
category,
|
|
4763
|
+
count: tools.length,
|
|
4764
|
+
tools: tools.map((t) => ({
|
|
4765
|
+
name: t.name,
|
|
4766
|
+
description: t.description ?? "",
|
|
4767
|
+
// 清洗 inputSchema,内联 $ref/$defs 引用并移除 Gemini 不支持的关键词,
|
|
4768
|
+
// 避免 Gemini 模型解析 function response 时报 400 错误
|
|
4769
|
+
inputSchema: t.inputSchema ? cleanSchemaForGemini(t.inputSchema) : undefined,
|
|
4770
|
+
})),
|
|
4771
|
+
};
|
|
4772
|
+
};
|
|
3477
4773
|
// ============================================================================
|
|
3478
4774
|
// call 操作:调用某品类的某个 MCP 工具
|
|
3479
4775
|
// ============================================================================
|
|
3480
4776
|
const handleCall = async (category, method, args) => {
|
|
4777
|
+
const ctx = { category, method, args };
|
|
3481
4778
|
const callStart = performance.now();
|
|
3482
|
-
|
|
3483
|
-
|
|
4779
|
+
console.log(`[mcp] handleCall ${category}/${method} 入参: ${JSON.stringify(args)}`);
|
|
4780
|
+
// 1. 收集拦截器的 beforeCall 配置(如超时时间、替换 args)
|
|
4781
|
+
const { options, args: resolvedArgs } = await resolveBeforeCall(ctx);
|
|
4782
|
+
const finalArgs = resolvedArgs ?? args;
|
|
4783
|
+
if (resolvedArgs) {
|
|
4784
|
+
console.log(`[mcp] handleCall ${category}/${method} 拦截器替换 args: ${JSON.stringify(resolvedArgs).slice(0, 500)}` +
|
|
4785
|
+
(JSON.stringify(resolvedArgs).length > 500 ? "...(truncated)" : ""));
|
|
4786
|
+
}
|
|
4787
|
+
if (options) {
|
|
4788
|
+
console.log(`[mcp] handleCall ${category}/${method} 拦截器选项: ${JSON.stringify(options)}`);
|
|
4789
|
+
}
|
|
4790
|
+
// 2. 执行 MCP 调用
|
|
3484
4791
|
const result = await sendJsonRpc(category, "tools/call", {
|
|
3485
4792
|
name: method,
|
|
3486
|
-
arguments:
|
|
4793
|
+
arguments: finalArgs,
|
|
3487
4794
|
}, options);
|
|
3488
4795
|
const rpcDone = performance.now();
|
|
3489
4796
|
const rpcMs = (rpcDone - callStart).toFixed(1);
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
4797
|
+
const resultStr = JSON.stringify(result);
|
|
4798
|
+
console.log(`[mcp] handleCall ${category}/${method} MCP 响应 (${rpcMs}ms): ${resultStr.slice(0, 800)}` +
|
|
4799
|
+
(resultStr.length > 800 ? "...(truncated)" : ""));
|
|
4800
|
+
// 3. 管道式执行 afterCall 拦截器(业务错误码检查、响应变换等)
|
|
4801
|
+
const finalResult = await runAfterCall(ctx, result);
|
|
4802
|
+
const totalMs = (performance.now() - callStart).toFixed(1);
|
|
4803
|
+
const interceptMs = (performance.now() - rpcDone).toFixed(1);
|
|
4804
|
+
// 有拦截器处理时打印详细耗时,否则只打印 RPC 耗时
|
|
4805
|
+
if (finalResult !== result) {
|
|
4806
|
+
const finalStr = JSON.stringify(finalResult);
|
|
4807
|
+
console.log(`[mcp] handleCall ${category}/${method} afterCall 变换后 (${interceptMs}ms): ${finalStr.slice(0, 500)}` +
|
|
4808
|
+
(finalStr.length > 500 ? "...(truncated)" : ""));
|
|
3496
4809
|
console.log(`[mcp] handleCall ${category}/${method} 总耗时: ${totalMs}ms` +
|
|
3497
|
-
` (MCP请求: ${rpcMs}ms, 拦截处理: ${
|
|
3498
|
-
|
|
4810
|
+
` (MCP请求: ${rpcMs}ms, 拦截处理: ${interceptMs}ms)`);
|
|
4811
|
+
}
|
|
4812
|
+
else {
|
|
4813
|
+
console.log(`[mcp] handleCall ${category}/${method} 耗时: ${rpcMs}ms`);
|
|
3499
4814
|
}
|
|
3500
|
-
|
|
3501
|
-
return result;
|
|
4815
|
+
return finalResult;
|
|
3502
4816
|
};
|
|
3503
4817
|
// ============================================================================
|
|
3504
4818
|
// 参数解析
|
|
@@ -3568,22 +4882,36 @@ function createWeComMcpTool() {
|
|
|
3568
4882
|
},
|
|
3569
4883
|
async execute(_toolCallId, params) {
|
|
3570
4884
|
const p = params;
|
|
4885
|
+
console.log(`[mcp] execute: action=${p.action}, category=${p.category}` +
|
|
4886
|
+
(p.method ? `, method=${p.method}` : "") +
|
|
4887
|
+
(p.args ? `, args=${typeof p.args === "string" ? p.args : JSON.stringify(p.args)}` : ""));
|
|
3571
4888
|
try {
|
|
4889
|
+
let result;
|
|
3572
4890
|
switch (p.action) {
|
|
3573
4891
|
case "list":
|
|
3574
|
-
|
|
4892
|
+
result = textResult(await handleList(p.category));
|
|
4893
|
+
break;
|
|
3575
4894
|
case "call": {
|
|
3576
4895
|
if (!p.method) {
|
|
3577
|
-
|
|
4896
|
+
result = textResult({ error: "action 为 call 时必须提供 method 参数" });
|
|
4897
|
+
break;
|
|
3578
4898
|
}
|
|
3579
4899
|
const args = parseArgs(p.args);
|
|
3580
|
-
|
|
4900
|
+
result = textResult(await handleCall(p.category, p.method, args));
|
|
4901
|
+
break;
|
|
3581
4902
|
}
|
|
3582
4903
|
default:
|
|
3583
|
-
|
|
4904
|
+
result = textResult({ error: `未知操作类型: ${String(p.action)},支持 list 和 call` });
|
|
3584
4905
|
}
|
|
4906
|
+
console.log(`[mcp] execute: action=${p.action}, category=${p.category}` +
|
|
4907
|
+
(p.method ? `, method=${p.method}` : "") +
|
|
4908
|
+
` → 响应长度=${result.content[0].text.length} chars`);
|
|
4909
|
+
return result;
|
|
3585
4910
|
}
|
|
3586
4911
|
catch (err) {
|
|
4912
|
+
console.error(`[mcp] execute: action=${p.action}, category=${p.category}` +
|
|
4913
|
+
(p.method ? `, method=${p.method}` : "") +
|
|
4914
|
+
` → 异常: ${err instanceof Error ? err.message : String(err)}`);
|
|
3587
4915
|
return errorResult(err);
|
|
3588
4916
|
}
|
|
3589
4917
|
},
|
|
@@ -3613,7 +4941,8 @@ const plugin = {
|
|
|
3613
4941
|
}
|
|
3614
4942
|
return {
|
|
3615
4943
|
appendSystemContext: [
|
|
3616
|
-
"重要:涉及发送图片/视频/语音/文件给用户时,请务必使用 `MEDIA
|
|
4944
|
+
"重要:涉及发送图片/视频/语音/文件给用户时,请务必使用 `MEDIA:` 指令。详见 wecom-send-media 这个 skill(技能)。",
|
|
4945
|
+
"重要:当需要向用户发送结构化卡片消息(如通知、投票、按钮选择等)时,请在回复中直接输出 JSON 代码块(```json ... ```),其中 card_type 字段标明卡片类型。详见 wecom-send-template-card 技能。"
|
|
3617
4946
|
].join("\n"),
|
|
3618
4947
|
};
|
|
3619
4948
|
});
|