@wecom/wecom-openclaw-plugin 2026.3.25-beta.0 → 2026.3.25-beta.2
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 +790 -18
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +790 -18
- package/dist/index.esm.js.map +1 -1
- package/dist/src/const.d.ts +2 -0
- package/dist/src/interface.d.ts +37 -5
- package/dist/src/message-parser.d.ts +18 -1
- package/dist/src/message-sender.d.ts +11 -0
- package/dist/src/template-card-parser.d.ts +37 -0
- package/package.json +1 -1
- package/skills/wecom-send-template-card/SKILL.md +191 -0
- package/skills/wecom-send-template-card/references/api-template-card-types.md +641 -0
package/dist/index.esm.js
CHANGED
|
@@ -420,8 +420,6 @@ const IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
|
|
|
420
420
|
const FILE_DOWNLOAD_TIMEOUT_MS = 60000;
|
|
421
421
|
/** 消息发送超时时间(毫秒) */
|
|
422
422
|
const REPLY_SEND_TIMEOUT_MS = 15000;
|
|
423
|
-
/** 消息处理总超时时间(毫秒) */
|
|
424
|
-
const MESSAGE_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
|
|
425
423
|
/** WebSocket 心跳间隔(毫秒) */
|
|
426
424
|
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
|
427
425
|
/** WebSocket 连接断开时的最大重连次数 */
|
|
@@ -488,6 +486,17 @@ const CMD_ENTER_EVENT_REPLY = "ww_ai_robot_enter_event";
|
|
|
488
486
|
// ============================================================================
|
|
489
487
|
/** WSClient scene 参数:企微 OpenClaw 场景 */
|
|
490
488
|
const SCENE_WECOM_OPENCLAW = 1;
|
|
489
|
+
// ============================================================================
|
|
490
|
+
// 模板卡片配置
|
|
491
|
+
// ============================================================================
|
|
492
|
+
/** 合法的模板卡片 card_type 列表 */
|
|
493
|
+
const VALID_CARD_TYPES = [
|
|
494
|
+
"text_notice",
|
|
495
|
+
"news_notice",
|
|
496
|
+
"button_interaction",
|
|
497
|
+
"vote_interaction",
|
|
498
|
+
"multiple_interaction",
|
|
499
|
+
];
|
|
491
500
|
|
|
492
501
|
/**
|
|
493
502
|
* 企业微信消息内容解析模块
|
|
@@ -498,7 +507,47 @@ const SCENE_WECOM_OPENCLAW = 1;
|
|
|
498
507
|
// 解析函数
|
|
499
508
|
// ============================================================================
|
|
500
509
|
/**
|
|
501
|
-
*
|
|
510
|
+
* 将模板卡片事件回调格式化为可继续路由给大模型的文本。
|
|
511
|
+
*
|
|
512
|
+
* 这样后续 Agent 可以直接从 question_key / option_id 中理解用户的真实选择。
|
|
513
|
+
*/
|
|
514
|
+
function buildTemplateCardEventText(body) {
|
|
515
|
+
const templateCardEvent = body.event?.template_card_event;
|
|
516
|
+
if (body.msgtype !== "event" ||
|
|
517
|
+
body.event?.eventtype !== "template_card_event" ||
|
|
518
|
+
!templateCardEvent) {
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
const selectedItems = templateCardEvent.selected_items?.selected_item ?? [];
|
|
522
|
+
const selectedLines = selectedItems.map((item) => {
|
|
523
|
+
const questionKey = item.question_key?.trim() || "unknown_question";
|
|
524
|
+
const optionIds = item.option_ids?.option_id?.filter(Boolean) ?? [];
|
|
525
|
+
return `- ${questionKey}: ${optionIds.length > 0 ? optionIds.join(", ") : "(未选择)"}`;
|
|
526
|
+
});
|
|
527
|
+
const senderUserId = body.from?.userid || "";
|
|
528
|
+
const senderCorpId = body.from?.corpid || "";
|
|
529
|
+
const chatId = body.chatid || senderUserId;
|
|
530
|
+
return [
|
|
531
|
+
"[企业微信模板卡片回调]",
|
|
532
|
+
`event_type(事件类型): template_card_event`,
|
|
533
|
+
body.msgid ? `msgid(消息 id): ${body.msgid}` : undefined,
|
|
534
|
+
body.aibotid ? `aibotid(机器人 id): ${body.aibotid}` : undefined,
|
|
535
|
+
body.chattype ? `chat_type(会话类型): ${body.chattype}` : undefined,
|
|
536
|
+
chatId ? `chat_id(会话 id): ${chatId}` : undefined,
|
|
537
|
+
senderCorpId ? `from.corpid(企业 id): ${senderCorpId}` : undefined,
|
|
538
|
+
senderUserId ? `from.userid(发送人 id): ${senderUserId}` : undefined,
|
|
539
|
+
senderUserId ? `sender_userid(发送人 id): ${senderUserId}` : undefined,
|
|
540
|
+
templateCardEvent.card_type ? `card_type(卡片类型): ${templateCardEvent.card_type}` : undefined,
|
|
541
|
+
templateCardEvent.event_key ? `event_key(事件 key): ${templateCardEvent.event_key}` : undefined,
|
|
542
|
+
templateCardEvent.task_id ? `task_id(任务 id): ${templateCardEvent.task_id}` : undefined,
|
|
543
|
+
selectedLines.length > 0 ? "selected_items(选择项):" : "selected_items(选择项): []",
|
|
544
|
+
...selectedLines,
|
|
545
|
+
]
|
|
546
|
+
.filter((line) => Boolean(line))
|
|
547
|
+
.join("\n");
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* 解析消息内容(支持单条消息、图文混排、事件回调和引用消息)
|
|
502
551
|
* @returns 提取的文本数组、图片URL数组和引用消息内容
|
|
503
552
|
*/
|
|
504
553
|
function parseMessageContent(body) {
|
|
@@ -508,6 +557,14 @@ function parseMessageContent(body) {
|
|
|
508
557
|
const fileUrls = [];
|
|
509
558
|
const fileAesKeys = new Map();
|
|
510
559
|
let quoteContent;
|
|
560
|
+
// 处理模板卡片事件回调
|
|
561
|
+
if (body.msgtype === "event") {
|
|
562
|
+
const eventText = buildTemplateCardEventText(body);
|
|
563
|
+
if (eventText) {
|
|
564
|
+
textParts.push(eventText);
|
|
565
|
+
}
|
|
566
|
+
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
|
|
567
|
+
}
|
|
511
568
|
// 处理图文混排消息
|
|
512
569
|
if (body.msgtype === "mixed" && body.mixed?.msg_item) {
|
|
513
570
|
for (const item of body.mixed.msg_item) {
|
|
@@ -614,6 +671,23 @@ class TimeoutError extends Error {
|
|
|
614
671
|
* 负责通过 WSClient 发送回复消息,包含超时保护
|
|
615
672
|
*/
|
|
616
673
|
// ============================================================================
|
|
674
|
+
// 流式过期错误(errcode 846608)
|
|
675
|
+
// ============================================================================
|
|
676
|
+
/** 流式回复超时错误码(>6分钟未更新,服务端拒绝继续流式更新) */
|
|
677
|
+
const STREAM_EXPIRED_ERRCODE = 846608;
|
|
678
|
+
/**
|
|
679
|
+
* 流式回复过期错误
|
|
680
|
+
* 当服务端返回 errcode=846608 时抛出,表示流式消息已超过6分钟无法更新,
|
|
681
|
+
* 调用方需降级为主动发送(sendMessage)方式回复。
|
|
682
|
+
*/
|
|
683
|
+
class StreamExpiredError extends Error {
|
|
684
|
+
constructor(message) {
|
|
685
|
+
super(message ?? `Stream message update expired (errcode=${STREAM_EXPIRED_ERRCODE})`);
|
|
686
|
+
this.errcode = STREAM_EXPIRED_ERRCODE;
|
|
687
|
+
this.name = "StreamExpiredError";
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// ============================================================================
|
|
617
691
|
// 消息发送
|
|
618
692
|
// ============================================================================
|
|
619
693
|
/**
|
|
@@ -632,8 +706,40 @@ async function sendWeComReply(params) {
|
|
|
632
706
|
runtime.error?.(`[wecom] WSClient not connected, cannot send reply`);
|
|
633
707
|
throw new Error("WSClient not connected");
|
|
634
708
|
}
|
|
709
|
+
const body = frame.body;
|
|
710
|
+
// 事件回调(aibot_event_callback)没有可用于 replyStream 的有效 req_id,
|
|
711
|
+
// 对该场景改用主动发送 sendMessage,避免 846605 invalid req_id。
|
|
712
|
+
if (body.msgtype === "event") {
|
|
713
|
+
// 中间帧(thinking / 流式增量)直接跳过,仅在最终帧主动发一次文本。
|
|
714
|
+
if (!finish) {
|
|
715
|
+
runtime.log?.(`[plugin -> server] skip non-final stream for event callback, streamId=${streamId}`);
|
|
716
|
+
return streamId;
|
|
717
|
+
}
|
|
718
|
+
const chatId = body.chatid || body.from?.userid;
|
|
719
|
+
if (!chatId) {
|
|
720
|
+
throw new Error("Missing chatId for event callback reply");
|
|
721
|
+
}
|
|
722
|
+
await withTimeout(wsClient.sendMessage(chatId, {
|
|
723
|
+
msgtype: "markdown",
|
|
724
|
+
markdown: { content: text },
|
|
725
|
+
}), REPLY_SEND_TIMEOUT_MS, `Event reply send timed out (streamId=${streamId})`);
|
|
726
|
+
runtime.log?.(`[plugin -> server] event-active-send chatId=${chatId}, finish=${finish}`);
|
|
727
|
+
return streamId;
|
|
728
|
+
}
|
|
729
|
+
// 非事件消息,继续使用 replyStream(被动回复)
|
|
635
730
|
// 使用 SDK 的 replyStream 方法发送消息,带超时保护
|
|
636
|
-
|
|
731
|
+
try {
|
|
732
|
+
await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
// 服务端返回 846608:流式消息超过6分钟无法更新,需降级为主动发送
|
|
736
|
+
const errMsg = err?.errmsg || err?.message || String(err);
|
|
737
|
+
if (err?.errcode === STREAM_EXPIRED_ERRCODE ||
|
|
738
|
+
errMsg.includes(String(STREAM_EXPIRED_ERRCODE))) {
|
|
739
|
+
throw new StreamExpiredError(errMsg);
|
|
740
|
+
}
|
|
741
|
+
throw err;
|
|
742
|
+
}
|
|
637
743
|
runtime.log?.(`[plugin -> server] streamId=${streamId}, finish=${finish}`);
|
|
638
744
|
return streamId;
|
|
639
745
|
}
|
|
@@ -1031,6 +1137,362 @@ async function uploadAndSendMedia(options) {
|
|
|
1031
1137
|
}
|
|
1032
1138
|
}
|
|
1033
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* 模板卡片解析器
|
|
1142
|
+
*
|
|
1143
|
+
* 从 LLM 回复文本中提取 markdown JSON 代码块,验证其是否为合法的企业微信模板卡片,
|
|
1144
|
+
* 返回提取到的卡片列表和剩余文本。
|
|
1145
|
+
*
|
|
1146
|
+
* 同时提供 maskTemplateCardBlocks 函数,用于在流式中间帧中隐藏正在构建的卡片代码块,
|
|
1147
|
+
* 避免 JSON 源码暴露给终端用户。
|
|
1148
|
+
*/
|
|
1149
|
+
// ============================================================================
|
|
1150
|
+
// LLM 输出字段类型修正
|
|
1151
|
+
// ============================================================================
|
|
1152
|
+
/**
|
|
1153
|
+
* 将 LLM 可能输出的字符串/非法值修正为企业微信 API 要求的整数。
|
|
1154
|
+
* 返回修正后的整数,若无法识别则返回 undefined(由调用方决定是否删除该字段)。
|
|
1155
|
+
*/
|
|
1156
|
+
function coerceToInt(value) {
|
|
1157
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1158
|
+
return Math.round(value);
|
|
1159
|
+
}
|
|
1160
|
+
if (typeof value === "string") {
|
|
1161
|
+
const trimmed = value.trim().toLowerCase();
|
|
1162
|
+
// 纯数字字符串
|
|
1163
|
+
const num = Number(trimmed);
|
|
1164
|
+
if (!Number.isNaN(num) && Number.isFinite(num)) {
|
|
1165
|
+
return Math.round(num);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return undefined;
|
|
1169
|
+
}
|
|
1170
|
+
/** 将 LLM 可能输出的字符串/非法值修正为布尔值 */
|
|
1171
|
+
function coerceToBool(value) {
|
|
1172
|
+
if (typeof value === "boolean")
|
|
1173
|
+
return value;
|
|
1174
|
+
if (typeof value === "string") {
|
|
1175
|
+
const t = value.trim().toLowerCase();
|
|
1176
|
+
if (t === "true" || t === "1" || t === "yes")
|
|
1177
|
+
return true;
|
|
1178
|
+
if (t === "false" || t === "0" || t === "no")
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
if (typeof value === "number")
|
|
1182
|
+
return value !== 0;
|
|
1183
|
+
return undefined;
|
|
1184
|
+
}
|
|
1185
|
+
/** checkbox.mode 的语义别名映射 */
|
|
1186
|
+
const MODE_ALIASES = {
|
|
1187
|
+
single: 0,
|
|
1188
|
+
radio: 0,
|
|
1189
|
+
"单选": 0,
|
|
1190
|
+
multi: 1,
|
|
1191
|
+
multiple: 1,
|
|
1192
|
+
"多选": 1,
|
|
1193
|
+
};
|
|
1194
|
+
/**
|
|
1195
|
+
* 修正 checkbox.mode:
|
|
1196
|
+
* - 整数 → 原样
|
|
1197
|
+
* - 字符串数字 "0"/"1" → 整数
|
|
1198
|
+
* - 语义别名 "multi"/"single" 等 → 对应整数
|
|
1199
|
+
* - 无法识别 → 删除(让服务端使用默认值 0)
|
|
1200
|
+
*/
|
|
1201
|
+
function coerceCheckboxMode(value) {
|
|
1202
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1203
|
+
return Math.round(value);
|
|
1204
|
+
}
|
|
1205
|
+
if (typeof value === "string") {
|
|
1206
|
+
const trimmed = value.trim().toLowerCase();
|
|
1207
|
+
if (trimmed in MODE_ALIASES)
|
|
1208
|
+
return MODE_ALIASES[trimmed];
|
|
1209
|
+
const num = Number(trimmed);
|
|
1210
|
+
if (!Number.isNaN(num))
|
|
1211
|
+
return Math.round(num);
|
|
1212
|
+
}
|
|
1213
|
+
return undefined;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* 对 LLM 生成的模板卡片 JSON 做字段类型修正,确保符合企业微信 API 的类型要求。
|
|
1217
|
+
*
|
|
1218
|
+
* 修正范围:
|
|
1219
|
+
* - checkbox.mode: uint32(0=单选,1=多选)
|
|
1220
|
+
* - checkbox.disable: bool
|
|
1221
|
+
* - checkbox.option_list[].is_checked: bool
|
|
1222
|
+
* - source.desc_color: int
|
|
1223
|
+
* - quote_area.type: int
|
|
1224
|
+
* - card_action.type: int
|
|
1225
|
+
* - image_text_area.type: int
|
|
1226
|
+
* - horizontal_content_list[].type: int
|
|
1227
|
+
* - jump_list[].type: int
|
|
1228
|
+
* - button_list[].style: int
|
|
1229
|
+
* - button_selection.disable: bool
|
|
1230
|
+
* - select_list[].disable: bool
|
|
1231
|
+
*
|
|
1232
|
+
* 原则:能修就修,修不了就删(让服务端走默认值),绝不阻塞发送。
|
|
1233
|
+
*/
|
|
1234
|
+
function normalizeTemplateCardFields(card, log) {
|
|
1235
|
+
const fixes = [];
|
|
1236
|
+
// ── checkbox ──────────────────────────────────────────────────────────
|
|
1237
|
+
const checkbox = card.checkbox;
|
|
1238
|
+
if (checkbox && typeof checkbox === "object") {
|
|
1239
|
+
// mode
|
|
1240
|
+
if ("mode" in checkbox) {
|
|
1241
|
+
const fixed = coerceCheckboxMode(checkbox.mode);
|
|
1242
|
+
if (fixed !== undefined) {
|
|
1243
|
+
if (checkbox.mode !== fixed) {
|
|
1244
|
+
fixes.push(`checkbox.mode: ${JSON.stringify(checkbox.mode)} → ${fixed}`);
|
|
1245
|
+
}
|
|
1246
|
+
checkbox.mode = fixed;
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
fixes.push(`checkbox.mode: ${JSON.stringify(checkbox.mode)} → (deleted, invalid)`);
|
|
1250
|
+
delete checkbox.mode;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// disable
|
|
1254
|
+
if ("disable" in checkbox) {
|
|
1255
|
+
const fixed = coerceToBool(checkbox.disable);
|
|
1256
|
+
if (fixed !== undefined && checkbox.disable !== fixed) {
|
|
1257
|
+
fixes.push(`checkbox.disable: ${JSON.stringify(checkbox.disable)} → ${fixed}`);
|
|
1258
|
+
checkbox.disable = fixed;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
// option_list[].is_checked
|
|
1262
|
+
if (Array.isArray(checkbox.option_list)) {
|
|
1263
|
+
for (const opt of checkbox.option_list) {
|
|
1264
|
+
if (opt && typeof opt === "object" && "is_checked" in opt) {
|
|
1265
|
+
const fixed = coerceToBool(opt.is_checked);
|
|
1266
|
+
if (fixed !== undefined && opt.is_checked !== fixed) {
|
|
1267
|
+
fixes.push(`checkbox.option_list.is_checked: ${JSON.stringify(opt.is_checked)} → ${fixed}`);
|
|
1268
|
+
opt.is_checked = fixed;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
// ── source.desc_color ────────────────────────────────────────────────
|
|
1275
|
+
const source = card.source;
|
|
1276
|
+
if (source && typeof source === "object" && "desc_color" in source) {
|
|
1277
|
+
const fixed = coerceToInt(source.desc_color);
|
|
1278
|
+
if (fixed !== undefined && source.desc_color !== fixed) {
|
|
1279
|
+
fixes.push(`source.desc_color: ${JSON.stringify(source.desc_color)} → ${fixed}`);
|
|
1280
|
+
source.desc_color = fixed;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// ── card_action.type ─────────────────────────────────────────────────
|
|
1284
|
+
const cardAction = card.card_action;
|
|
1285
|
+
if (cardAction && typeof cardAction === "object" && "type" in cardAction) {
|
|
1286
|
+
const fixed = coerceToInt(cardAction.type);
|
|
1287
|
+
if (fixed !== undefined && cardAction.type !== fixed) {
|
|
1288
|
+
fixes.push(`card_action.type: ${JSON.stringify(cardAction.type)} → ${fixed}`);
|
|
1289
|
+
cardAction.type = fixed;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
// ── quote_area.type ──────────────────────────────────────────────────
|
|
1293
|
+
const quoteArea = card.quote_area;
|
|
1294
|
+
if (quoteArea && typeof quoteArea === "object" && "type" in quoteArea) {
|
|
1295
|
+
const fixed = coerceToInt(quoteArea.type);
|
|
1296
|
+
if (fixed !== undefined && quoteArea.type !== fixed) {
|
|
1297
|
+
fixes.push(`quote_area.type: ${JSON.stringify(quoteArea.type)} → ${fixed}`);
|
|
1298
|
+
quoteArea.type = fixed;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
// ── image_text_area.type ─────────────────────────────────────────────
|
|
1302
|
+
const imageTextArea = card.image_text_area;
|
|
1303
|
+
if (imageTextArea && typeof imageTextArea === "object" && "type" in imageTextArea) {
|
|
1304
|
+
const fixed = coerceToInt(imageTextArea.type);
|
|
1305
|
+
if (fixed !== undefined && imageTextArea.type !== fixed) {
|
|
1306
|
+
fixes.push(`image_text_area.type: ${JSON.stringify(imageTextArea.type)} → ${fixed}`);
|
|
1307
|
+
imageTextArea.type = fixed;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// ── horizontal_content_list[].type ───────────────────────────────────
|
|
1311
|
+
if (Array.isArray(card.horizontal_content_list)) {
|
|
1312
|
+
for (const item of card.horizontal_content_list) {
|
|
1313
|
+
if (item && typeof item === "object" && "type" in item) {
|
|
1314
|
+
const fixed = coerceToInt(item.type);
|
|
1315
|
+
if (fixed !== undefined && item.type !== fixed) {
|
|
1316
|
+
fixes.push(`horizontal_content_list.type: ${JSON.stringify(item.type)} → ${fixed}`);
|
|
1317
|
+
item.type = fixed;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
// ── jump_list[].type ─────────────────────────────────────────────────
|
|
1323
|
+
if (Array.isArray(card.jump_list)) {
|
|
1324
|
+
for (const item of card.jump_list) {
|
|
1325
|
+
if (item && typeof item === "object" && "type" in item) {
|
|
1326
|
+
const fixed = coerceToInt(item.type);
|
|
1327
|
+
if (fixed !== undefined && item.type !== fixed) {
|
|
1328
|
+
fixes.push(`jump_list.type: ${JSON.stringify(item.type)} → ${fixed}`);
|
|
1329
|
+
item.type = fixed;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
// ── button_list[].style ──────────────────────────────────────────────
|
|
1335
|
+
if (Array.isArray(card.button_list)) {
|
|
1336
|
+
for (const btn of card.button_list) {
|
|
1337
|
+
if (btn && typeof btn === "object" && "style" in btn) {
|
|
1338
|
+
const fixed = coerceToInt(btn.style);
|
|
1339
|
+
if (fixed !== undefined && btn.style !== fixed) {
|
|
1340
|
+
fixes.push(`button_list.style: ${JSON.stringify(btn.style)} → ${fixed}`);
|
|
1341
|
+
btn.style = fixed;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
// ── button_selection.disable ─────────────────────────────────────────
|
|
1347
|
+
const buttonSelection = card.button_selection;
|
|
1348
|
+
if (buttonSelection && typeof buttonSelection === "object" && "disable" in buttonSelection) {
|
|
1349
|
+
const fixed = coerceToBool(buttonSelection.disable);
|
|
1350
|
+
if (fixed !== undefined && buttonSelection.disable !== fixed) {
|
|
1351
|
+
fixes.push(`button_selection.disable: ${JSON.stringify(buttonSelection.disable)} → ${fixed}`);
|
|
1352
|
+
buttonSelection.disable = fixed;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
// ── select_list[].disable ────────────────────────────────────────────
|
|
1356
|
+
if (Array.isArray(card.select_list)) {
|
|
1357
|
+
for (const sel of card.select_list) {
|
|
1358
|
+
if (sel && typeof sel === "object" && "disable" in sel) {
|
|
1359
|
+
const fixed = coerceToBool(sel.disable);
|
|
1360
|
+
if (fixed !== undefined && sel.disable !== fixed) {
|
|
1361
|
+
fixes.push(`select_list.disable: ${JSON.stringify(sel.disable)} → ${fixed}`);
|
|
1362
|
+
sel.disable = fixed;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (fixes.length > 0) {
|
|
1368
|
+
log?.(`[template-card-parser] normalizeTemplateCardFields: ${fixes.length} fix(es) applied: ${fixes.join("; ")}`);
|
|
1369
|
+
}
|
|
1370
|
+
return card;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* 匹配 markdown 代码块的正则表达式
|
|
1374
|
+
* 支持 ```json ... ``` 和 ``` ... ``` 两种格式
|
|
1375
|
+
*/
|
|
1376
|
+
const CODE_BLOCK_RE = /```(?:json)?\s*\n([\s\S]*?)\n```/g;
|
|
1377
|
+
/**
|
|
1378
|
+
* 匹配已闭合的代码块(含 card_type 关键词,用于中间帧遮罩)
|
|
1379
|
+
* 与 CODE_BLOCK_RE 相同,但用于 maskTemplateCardBlocks 中单独实例化
|
|
1380
|
+
*/
|
|
1381
|
+
const CLOSED_BLOCK_RE = /```(?:json)?\s*\n([\s\S]*?)\n```/g;
|
|
1382
|
+
/**
|
|
1383
|
+
* 匹配未闭合的代码块尾部(LLM 正在输出中的代码块)
|
|
1384
|
+
* 以 ```json 或 ``` 开头,后面有内容但没有闭合的 ```
|
|
1385
|
+
*/
|
|
1386
|
+
const UNCLOSED_BLOCK_RE = /```(?:json)?\s*\n[\s\S]*$/;
|
|
1387
|
+
/**
|
|
1388
|
+
* 从文本中提取模板卡片 JSON 代码块
|
|
1389
|
+
*
|
|
1390
|
+
* 匹配规则:
|
|
1391
|
+
* 1. 匹配所有 ```json ... ``` 或 ``` ... ``` 格式的代码块
|
|
1392
|
+
* 2. 尝试 JSON.parse 解析代码块内容
|
|
1393
|
+
* 3. 检查解析结果中是否包含合法的 card_type 字段
|
|
1394
|
+
* 4. 合法的卡片从原文中移除,不合法的保留
|
|
1395
|
+
*
|
|
1396
|
+
* @param text - LLM 回复的完整文本
|
|
1397
|
+
* @returns 提取结果,包含卡片列表和剩余文本
|
|
1398
|
+
*/
|
|
1399
|
+
function extractTemplateCards(text, log) {
|
|
1400
|
+
const cards = [];
|
|
1401
|
+
/** 需要从原文中移除的代码块(记录完整匹配内容) */
|
|
1402
|
+
const blocksToRemove = [];
|
|
1403
|
+
log?.(`[template-card-parser] extractTemplateCards called, textLength=${text.length}`);
|
|
1404
|
+
let match;
|
|
1405
|
+
// 重置正则的 lastIndex,确保从头匹配
|
|
1406
|
+
CODE_BLOCK_RE.lastIndex = 0;
|
|
1407
|
+
let blockIndex = 0;
|
|
1408
|
+
while ((match = CODE_BLOCK_RE.exec(text)) !== null) {
|
|
1409
|
+
const fullMatch = match[0];
|
|
1410
|
+
const jsonContent = match[1].trim();
|
|
1411
|
+
blockIndex++;
|
|
1412
|
+
log?.(`[template-card-parser] Found code block #${blockIndex}, length=${fullMatch.length}, preview=${jsonContent.slice(0, 1000)}...`);
|
|
1413
|
+
// 尝试解析 JSON
|
|
1414
|
+
let parsed;
|
|
1415
|
+
try {
|
|
1416
|
+
parsed = JSON.parse(jsonContent);
|
|
1417
|
+
}
|
|
1418
|
+
catch (e) {
|
|
1419
|
+
// JSON 解析失败,保留在原文中
|
|
1420
|
+
log?.(`[template-card-parser] Code block #${blockIndex} JSON parse failed: ${String(e)}`);
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
// 检查是否包含合法的 card_type
|
|
1424
|
+
const cardType = parsed.card_type;
|
|
1425
|
+
if (typeof cardType !== "string" || !VALID_CARD_TYPES.includes(cardType)) {
|
|
1426
|
+
// card_type 不合法,保留在原文中
|
|
1427
|
+
log?.(`[template-card-parser] Code block #${blockIndex} has invalid card_type="${String(cardType)}", skipping`);
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
log?.(`[template-card-parser] Code block #${blockIndex} is valid template card, card_type="${cardType}"`);
|
|
1431
|
+
// 修正 LLM 可能输出的错误字段类型(如 checkbox.mode: "multi" → 1)
|
|
1432
|
+
normalizeTemplateCardFields(parsed, log);
|
|
1433
|
+
// 合法的模板卡片,收集并标记移除
|
|
1434
|
+
cards.push({
|
|
1435
|
+
cardJson: parsed,
|
|
1436
|
+
cardType,
|
|
1437
|
+
});
|
|
1438
|
+
blocksToRemove.push(fullMatch);
|
|
1439
|
+
}
|
|
1440
|
+
// 从原文中移除已提取的代码块,生成剩余文本
|
|
1441
|
+
let remainingText = text;
|
|
1442
|
+
for (const block of blocksToRemove) {
|
|
1443
|
+
remainingText = remainingText.replace(block, "");
|
|
1444
|
+
}
|
|
1445
|
+
// 清理多余空行(连续 3 个以上换行合并为 2 个)
|
|
1446
|
+
remainingText = remainingText.replace(/\n{3,}/g, "\n\n").trim();
|
|
1447
|
+
log?.(`[template-card-parser] Extraction done: ${cards.length} card(s) found, remainingTextLength=${remainingText.length}`);
|
|
1448
|
+
return { cards, remainingText };
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* 遮罩文本中的模板卡片代码块(用于流式中间帧展示)
|
|
1452
|
+
*
|
|
1453
|
+
* 在 LLM 流式输出过程中,累积文本可能包含:
|
|
1454
|
+
* 1. 已闭合的模板卡片 JSON 代码块 → 替换为友好提示文本
|
|
1455
|
+
* 2. 正在构建中的未闭合代码块 → 截断隐藏,避免 JSON 源码暴露
|
|
1456
|
+
*
|
|
1457
|
+
* 此函数仅做文本替换,不做 JSON 解析验证(中间帧性能优先)。
|
|
1458
|
+
* 只要代码块内容中出现 "card_type" 关键词就认为是模板卡片并遮罩。
|
|
1459
|
+
*
|
|
1460
|
+
* @param text - 当前累积文本
|
|
1461
|
+
* @returns 遮罩后的展示文本
|
|
1462
|
+
*/
|
|
1463
|
+
function maskTemplateCardBlocks(text, log) {
|
|
1464
|
+
let masked = text;
|
|
1465
|
+
let closedMaskCount = 0;
|
|
1466
|
+
let unclosedMasked = false;
|
|
1467
|
+
// 步骤一:处理已闭合的代码块
|
|
1468
|
+
CLOSED_BLOCK_RE.lastIndex = 0;
|
|
1469
|
+
masked = masked.replace(CLOSED_BLOCK_RE, (fullMatch, content) => {
|
|
1470
|
+
// 检查代码块内容是否包含 card_type 关键词
|
|
1471
|
+
if (/["']card_type["']/.test(content)) {
|
|
1472
|
+
closedMaskCount++;
|
|
1473
|
+
return "\n\n📋 *正在生成卡片消息...*\n\n";
|
|
1474
|
+
}
|
|
1475
|
+
// 非模板卡片代码块,保留原样
|
|
1476
|
+
return fullMatch;
|
|
1477
|
+
});
|
|
1478
|
+
// 步骤二:处理未闭合的代码块尾部(LLM 仍在输出中)
|
|
1479
|
+
// 检查是否有以 ``` 开头但没有闭合的代码块
|
|
1480
|
+
const unclosedMatch = UNCLOSED_BLOCK_RE.exec(masked);
|
|
1481
|
+
if (unclosedMatch) {
|
|
1482
|
+
const unclosedContent = unclosedMatch[0];
|
|
1483
|
+
// 如果未闭合部分包含 card_type 关键词,说明正在构建模板卡片 → 截断
|
|
1484
|
+
if (/["']card_type["']/.test(unclosedContent)) {
|
|
1485
|
+
unclosedMasked = true;
|
|
1486
|
+
masked = masked.slice(0, unclosedMatch.index) + "\n\n📋 *正在生成卡片消息...*";
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
// 有遮罩行为时才打日志,避免每帧都刷屏
|
|
1490
|
+
if (closedMaskCount > 0 || unclosedMasked) {
|
|
1491
|
+
log?.(`[template-card-parser] maskTemplateCardBlocks: closedMasked=${closedMaskCount}, unclosedMasked=${unclosedMasked}, textLength=${text.length}, maskedLength=${masked.length}`);
|
|
1492
|
+
}
|
|
1493
|
+
return masked;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1034
1496
|
/**
|
|
1035
1497
|
* 企业微信群组访问控制模块
|
|
1036
1498
|
*
|
|
@@ -1498,6 +1960,144 @@ const PLUGIN_VERSION = getVersion();
|
|
|
1498
1960
|
* - state-manager.ts : 全局状态管理(带 TTL 清理)
|
|
1499
1961
|
* - timeout.ts : 超时工具
|
|
1500
1962
|
*/
|
|
1963
|
+
/**
|
|
1964
|
+
* 去除文本中的 `<think>...</think>` 标签(支持跨行),返回剩余可见文本。
|
|
1965
|
+
* 用于判断大模型回复中是否包含实际用户可见内容(而非仅有 thinking 推理过程)。
|
|
1966
|
+
*/
|
|
1967
|
+
function stripThinkTags(text) {
|
|
1968
|
+
return text;
|
|
1969
|
+
// return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
1970
|
+
}
|
|
1971
|
+
const sentTemplateCardByTaskId = new Map();
|
|
1972
|
+
const TEMPLATE_CARD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1973
|
+
const TEMPLATE_CARD_CACHE_MAX_SIZE = 300;
|
|
1974
|
+
function getTemplateCardCacheKey(accountId, taskId) {
|
|
1975
|
+
return `${accountId}:${taskId}`;
|
|
1976
|
+
}
|
|
1977
|
+
function pruneTemplateCardCache() {
|
|
1978
|
+
const now = Date.now();
|
|
1979
|
+
for (const [key, entry] of sentTemplateCardByTaskId) {
|
|
1980
|
+
if (now - entry.createdAt >= TEMPLATE_CARD_CACHE_TTL_MS) {
|
|
1981
|
+
sentTemplateCardByTaskId.delete(key);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
if (sentTemplateCardByTaskId.size <= TEMPLATE_CARD_CACHE_MAX_SIZE) {
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const sortedEntries = [...sentTemplateCardByTaskId.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
1988
|
+
const removeCount = sentTemplateCardByTaskId.size - TEMPLATE_CARD_CACHE_MAX_SIZE;
|
|
1989
|
+
for (const [key] of sortedEntries.slice(0, removeCount)) {
|
|
1990
|
+
sentTemplateCardByTaskId.delete(key);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
function cloneTemplateCard(card) {
|
|
1994
|
+
return JSON.parse(JSON.stringify(card));
|
|
1995
|
+
}
|
|
1996
|
+
function saveTemplateCardToCache(params) {
|
|
1997
|
+
const { accountId, templateCard, runtime } = params;
|
|
1998
|
+
const taskId = templateCard.task_id;
|
|
1999
|
+
if (!taskId) {
|
|
2000
|
+
runtime.log?.("[wecom][template-card] Skip cache: template card has no task_id");
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
sentTemplateCardByTaskId.set(getTemplateCardCacheKey(accountId, taskId), {
|
|
2004
|
+
templateCard: cloneTemplateCard(templateCard),
|
|
2005
|
+
createdAt: Date.now(),
|
|
2006
|
+
});
|
|
2007
|
+
pruneTemplateCardCache();
|
|
2008
|
+
}
|
|
2009
|
+
function getTemplateCardFromCache(accountId, taskId) {
|
|
2010
|
+
pruneTemplateCardCache();
|
|
2011
|
+
const cached = sentTemplateCardByTaskId.get(getTemplateCardCacheKey(accountId, taskId));
|
|
2012
|
+
if (!cached) {
|
|
2013
|
+
return undefined;
|
|
2014
|
+
}
|
|
2015
|
+
return cloneTemplateCard(cached.templateCard);
|
|
2016
|
+
}
|
|
2017
|
+
function buildSelectedOptionMap(templateCardEvent) {
|
|
2018
|
+
const selectedMap = new Map();
|
|
2019
|
+
const selectedItems = templateCardEvent?.selected_items?.selected_item ?? [];
|
|
2020
|
+
for (const item of selectedItems) {
|
|
2021
|
+
const questionKey = item.question_key?.trim();
|
|
2022
|
+
if (!questionKey) {
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
const optionIds = item.option_ids?.option_id?.filter(Boolean) ?? [];
|
|
2026
|
+
selectedMap.set(questionKey, optionIds);
|
|
2027
|
+
}
|
|
2028
|
+
return selectedMap;
|
|
2029
|
+
}
|
|
2030
|
+
function applySelectedStateToTemplateCard(params) {
|
|
2031
|
+
const { templateCard, selectedMap, templateCardEvent } = params;
|
|
2032
|
+
const nextCard = cloneTemplateCard(templateCard);
|
|
2033
|
+
if (templateCardEvent?.task_id) {
|
|
2034
|
+
nextCard.task_id = templateCardEvent.task_id;
|
|
2035
|
+
}
|
|
2036
|
+
if (templateCardEvent?.card_type) {
|
|
2037
|
+
nextCard.card_type = templateCardEvent.card_type;
|
|
2038
|
+
}
|
|
2039
|
+
// 交互完成后将提交按钮文案更新为已提交,提升用户感知
|
|
2040
|
+
if (nextCard.submit_button?.text) {
|
|
2041
|
+
nextCard.submit_button.text = "已提交";
|
|
2042
|
+
}
|
|
2043
|
+
if (nextCard.checkbox?.question_key) {
|
|
2044
|
+
const selectedIds = selectedMap.get(nextCard.checkbox.question_key) ?? [];
|
|
2045
|
+
nextCard.checkbox.disable = true;
|
|
2046
|
+
if (Array.isArray(nextCard.checkbox.option_list)) {
|
|
2047
|
+
nextCard.checkbox.option_list = nextCard.checkbox.option_list.map((option) => ({
|
|
2048
|
+
...option,
|
|
2049
|
+
is_checked: selectedIds.includes(option.id),
|
|
2050
|
+
}));
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
if (Array.isArray(nextCard.select_list)) {
|
|
2054
|
+
nextCard.select_list = nextCard.select_list.map((selection) => {
|
|
2055
|
+
const selectedIds = selectedMap.get(selection.question_key) ?? [];
|
|
2056
|
+
return {
|
|
2057
|
+
...selection,
|
|
2058
|
+
disable: true,
|
|
2059
|
+
selected_id: selectedIds[0] ?? selection.selected_id,
|
|
2060
|
+
};
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
if (nextCard.button_selection?.question_key) {
|
|
2064
|
+
const selectedIds = selectedMap.get(nextCard.button_selection.question_key) ?? [];
|
|
2065
|
+
nextCard.button_selection.disable = true;
|
|
2066
|
+
if (selectedIds[0]) {
|
|
2067
|
+
nextCard.button_selection.selected_id = selectedIds[0];
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
return nextCard;
|
|
2071
|
+
}
|
|
2072
|
+
async function updateTemplateCardOnEvent(params) {
|
|
2073
|
+
const { frame, accountId, runtime, wsClient } = params;
|
|
2074
|
+
const body = frame.body;
|
|
2075
|
+
const templateCardEvent = body.event?.template_card_event;
|
|
2076
|
+
const taskId = templateCardEvent?.task_id;
|
|
2077
|
+
if (!taskId) {
|
|
2078
|
+
runtime.log?.(`[${accountId}] [template-card-update] Skip update: missing task_id in callback`);
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
const cachedCard = getTemplateCardFromCache(accountId, taskId);
|
|
2082
|
+
if (!cachedCard) {
|
|
2083
|
+
runtime.log?.(`[${accountId}] [template-card-update] Skip update: task_id=${taskId} not found in cache`);
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const selectedMap = buildSelectedOptionMap(templateCardEvent);
|
|
2087
|
+
const updatedCard = applySelectedStateToTemplateCard({
|
|
2088
|
+
templateCard: cachedCard,
|
|
2089
|
+
selectedMap,
|
|
2090
|
+
templateCardEvent,
|
|
2091
|
+
});
|
|
2092
|
+
await wsClient.updateTemplateCard(frame, updatedCard, [body.from.userid]);
|
|
2093
|
+
runtime.log?.(`[${accountId}] [template-card-update] Updated card by task_id=${taskId}`);
|
|
2094
|
+
// 将更新后的卡片写回缓存,后续多次点击时状态保持一致
|
|
2095
|
+
saveTemplateCardToCache({
|
|
2096
|
+
accountId,
|
|
2097
|
+
templateCard: updatedCard,
|
|
2098
|
+
runtime,
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
1501
2101
|
// ============================================================================
|
|
1502
2102
|
// 媒体本地路径白名单扩展
|
|
1503
2103
|
// ============================================================================
|
|
@@ -1628,7 +2228,7 @@ function buildMessageContext(frame, account, config, text, mediaList, quoteConte
|
|
|
1628
2228
|
* 发送"思考中"消息
|
|
1629
2229
|
*/
|
|
1630
2230
|
async function sendThinkingReply(params) {
|
|
1631
|
-
const { wsClient, frame, streamId, runtime } = params;
|
|
2231
|
+
const { wsClient, frame, streamId, runtime, state } = params;
|
|
1632
2232
|
try {
|
|
1633
2233
|
await sendWeComReply({
|
|
1634
2234
|
wsClient,
|
|
@@ -1640,7 +2240,13 @@ async function sendThinkingReply(params) {
|
|
|
1640
2240
|
});
|
|
1641
2241
|
}
|
|
1642
2242
|
catch (err) {
|
|
1643
|
-
|
|
2243
|
+
if (err instanceof StreamExpiredError && state) {
|
|
2244
|
+
state.streamExpired = true;
|
|
2245
|
+
runtime.log?.(`[wecom] Stream expired during thinking reply, will fallback to proactive send`);
|
|
2246
|
+
}
|
|
2247
|
+
else {
|
|
2248
|
+
runtime.error?.(`[wecom] Failed to send thinking message: ${String(err)}`);
|
|
2249
|
+
}
|
|
1644
2250
|
}
|
|
1645
2251
|
}
|
|
1646
2252
|
/**
|
|
@@ -1654,7 +2260,7 @@ async function sendMediaBatch(ctx, mediaUrls) {
|
|
|
1654
2260
|
const body = frame.body;
|
|
1655
2261
|
const chatId = body.chatid || body.from.userid;
|
|
1656
2262
|
const mediaLocalRoots = await getExtendedMediaLocalRoots(account.config);
|
|
1657
|
-
runtime.log?.(`[wecom][debug] mediaLocalRoots=${JSON.stringify(mediaLocalRoots)}, mediaUrls=${JSON.stringify(mediaUrls)}
|
|
2263
|
+
runtime.log?.(`[wecom][debug] mediaLocalRoots=${JSON.stringify(mediaLocalRoots)}, mediaUrls=${JSON.stringify(mediaUrls)}`);
|
|
1658
2264
|
for (const mediaUrl of mediaUrls) {
|
|
1659
2265
|
const result = await uploadAndSendMedia({
|
|
1660
2266
|
wsClient,
|
|
@@ -1688,6 +2294,7 @@ async function sendMediaBatch(ctx, mediaUrls) {
|
|
|
1688
2294
|
* 替换掉 thinking 动画,否则 thinking 会一直残留。
|
|
1689
2295
|
*
|
|
1690
2296
|
* 关闭策略(按优先级):
|
|
2297
|
+
* 0. [新增] 有模板卡片代码块 → 提取卡片并主动发送,用剩余文本关闭流
|
|
1691
2298
|
* 1. 有可见文本 → 用完整文本关闭
|
|
1692
2299
|
* 2. 有媒体成功发送(通过 deliver 回调) → 用友好提示"文件已发送"
|
|
1693
2300
|
* 3. 媒体发送失败 → 直接用错误摘要替换 thinking
|
|
@@ -1695,11 +2302,49 @@ async function sendMediaBatch(ctx, mediaUrls) {
|
|
|
1695
2302
|
* (agent 可能已通过内置 message 工具直接发送了文件,
|
|
1696
2303
|
* 该路径走 outbound.sendMedia 完全绕过 deliver 回调,
|
|
1697
2304
|
* 所以 state 中无记录,但文件已实际送达)
|
|
2305
|
+
*
|
|
2306
|
+
* 降级策略:
|
|
2307
|
+
* - 当 streamExpired=true(errcode 846608)时,流式通道已不可用(>6分钟),
|
|
2308
|
+
* 改用 wsClient.sendMessage 主动发送完整文本。
|
|
1698
2309
|
*/
|
|
1699
2310
|
async function finishThinkingStream(ctx) {
|
|
1700
2311
|
const { wsClient, frame, state, runtime } = ctx;
|
|
2312
|
+
const body = frame.body;
|
|
2313
|
+
const chatId = body.chatid || body.from.userid;
|
|
2314
|
+
const visibleText = stripThinkTags(state.accumulatedText);
|
|
2315
|
+
// ── 模板卡片检测与发送 ──────────────────────────────────────────────
|
|
2316
|
+
// 在确定 finishText 之前,先检查累积文本中是否包含模板卡片 JSON 代码块。
|
|
2317
|
+
// 若检测到合法卡片,通过 sendMessage 主动发送后,用剩余文本关闭流。
|
|
2318
|
+
if (visibleText) {
|
|
2319
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: visibleText exists, length=${visibleText.length}, running extractTemplateCards...`);
|
|
2320
|
+
const logFn = (...args) => {
|
|
2321
|
+
runtime.log?.(...args);
|
|
2322
|
+
};
|
|
2323
|
+
const { cards, remainingText } = extractTemplateCards(state.accumulatedText, logFn);
|
|
2324
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: extractTemplateCards result — cards=${cards.length}, remainingTextLength=${remainingText.length}`);
|
|
2325
|
+
if (cards.length > 0) {
|
|
2326
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: ${cards.length} card(s) detected, card_types=[${cards.map(c => c.cardType).join(", ")}]`);
|
|
2327
|
+
await sendTemplateCards(ctx, cards);
|
|
2328
|
+
// 用剩余文本关闭流(可能为空)
|
|
2329
|
+
const trimmedRemaining = stripThinkTags(remainingText);
|
|
2330
|
+
const finishText = trimmedRemaining
|
|
2331
|
+
? remainingText
|
|
2332
|
+
: (state.hasTemplateCard ? "📋 卡片消息已发送。" : "✅ 处理完成。");
|
|
2333
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: closing stream with finishText="${finishText.slice(0, 100)}...", hasTemplateCard=${state.hasTemplateCard}`);
|
|
2334
|
+
await sendWeComReply({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
else {
|
|
2339
|
+
runtime.log?.(`[wecom][template-card] finishThinkingStream: no visibleText, skipping template card extraction`);
|
|
2340
|
+
}
|
|
2341
|
+
// ── 模板卡片检测结束 ────────────────────────────────────────────────
|
|
1701
2342
|
let finishText = state.accumulatedText;
|
|
1702
|
-
if (
|
|
2343
|
+
if (visibleText) {
|
|
2344
|
+
// 有可见文本:用完整文本关闭流(覆盖 thinking 为真实内容)
|
|
2345
|
+
finishText = state.accumulatedText;
|
|
2346
|
+
}
|
|
2347
|
+
else if (state.hasMedia) {
|
|
1703
2348
|
if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
1704
2349
|
// 媒体成功发送:用友好提示告知用户
|
|
1705
2350
|
finishText = finishText ? `${finishText}\n\n${state.mediaErrorSummary}` : state.mediaErrorSummary;
|
|
@@ -1712,7 +2357,63 @@ async function finishThinkingStream(ctx) {
|
|
|
1712
2357
|
// finishText = "✅ 处理完成。";
|
|
1713
2358
|
// }
|
|
1714
2359
|
if (finishText) {
|
|
1715
|
-
|
|
2360
|
+
// 尝试流式发送;若已知过期或发送时发现过期,统一降级为主动发送
|
|
2361
|
+
let expired = state.streamExpired;
|
|
2362
|
+
if (!expired) {
|
|
2363
|
+
try {
|
|
2364
|
+
await sendWeComReply({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
|
|
2365
|
+
}
|
|
2366
|
+
catch (err) {
|
|
2367
|
+
if (err instanceof StreamExpiredError) {
|
|
2368
|
+
expired = true;
|
|
2369
|
+
}
|
|
2370
|
+
else {
|
|
2371
|
+
throw err;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
if (expired) {
|
|
2376
|
+
runtime.log?.(`[wecom] Stream expired, sending final text via sendMessage (proactive)`);
|
|
2377
|
+
await wsClient.sendMessage(chatId, {
|
|
2378
|
+
msgtype: "markdown",
|
|
2379
|
+
markdown: { content: finishText },
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* 逐个发送已提取的模板卡片(通过 wsClient.sendMessage 主动推送)
|
|
2386
|
+
*
|
|
2387
|
+
* 发送失败不阻塞流程,仅记录错误日志。
|
|
2388
|
+
*/
|
|
2389
|
+
async function sendTemplateCards(ctx, cards) {
|
|
2390
|
+
const { wsClient, frame, state, runtime, account } = ctx;
|
|
2391
|
+
const body = frame.body;
|
|
2392
|
+
const chatId = body.chatid || body.from.userid;
|
|
2393
|
+
for (const card of cards) {
|
|
2394
|
+
try {
|
|
2395
|
+
runtime.log?.(`[wecom][template-card] Sending card_type=${card.cardType} to chatId=${chatId}`);
|
|
2396
|
+
const rawTemplateCard = card.cardJson;
|
|
2397
|
+
if (typeof rawTemplateCard.card_type !== "string") {
|
|
2398
|
+
runtime.error?.("[wecom][template-card] Skip sending invalid card: missing card_type");
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
const templateCard = rawTemplateCard;
|
|
2402
|
+
await wsClient.sendMessage(chatId, {
|
|
2403
|
+
msgtype: "template_card",
|
|
2404
|
+
template_card: templateCard,
|
|
2405
|
+
});
|
|
2406
|
+
state.hasTemplateCard = true;
|
|
2407
|
+
saveTemplateCardToCache({
|
|
2408
|
+
accountId: account.accountId,
|
|
2409
|
+
templateCard,
|
|
2410
|
+
runtime,
|
|
2411
|
+
});
|
|
2412
|
+
runtime.log?.(`[wecom][template-card] Card sent successfully: card_type=${card.cardType}`);
|
|
2413
|
+
}
|
|
2414
|
+
catch (err) {
|
|
2415
|
+
runtime.error?.(`[wecom][template-card] Failed to send card: card_type=${card.cardType}, error=${JSON.stringify(err)}`);
|
|
2416
|
+
}
|
|
1716
2417
|
}
|
|
1717
2418
|
}
|
|
1718
2419
|
/**
|
|
@@ -1735,11 +2436,25 @@ async function routeAndDispatchMessage(params) {
|
|
|
1735
2436
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1736
2437
|
ctx: ctxPayload,
|
|
1737
2438
|
cfg: config,
|
|
2439
|
+
// replyResolver: async (ctx, opts) => {
|
|
2440
|
+
// const startTime = Date.now();
|
|
2441
|
+
// const TEN_MINUTES = 7 * 60 * 1000;
|
|
2442
|
+
//
|
|
2443
|
+
// console.log('开始输出内容');
|
|
2444
|
+
//
|
|
2445
|
+
// while (Date.now() - startTime < TEN_MINUTES) {
|
|
2446
|
+
// // 每隔一段时间发送一个 block reply
|
|
2447
|
+
// await opts?.onBlockReply?.({ text: `输出内容 ${new Date().toISOString()}` });
|
|
2448
|
+
// await new Promise(resolve => setTimeout(resolve, 30 * 1000)); // 每5秒输出一次
|
|
2449
|
+
// }
|
|
2450
|
+
//
|
|
2451
|
+
// return { text: "10分钟输出完成" }; // 最终回复
|
|
2452
|
+
// },
|
|
1738
2453
|
dispatcherOptions: {
|
|
1739
2454
|
onReplyStart: async () => {
|
|
1740
2455
|
if (!isShowThink && state.streamId && !state.accumulatedText) {
|
|
1741
2456
|
try {
|
|
1742
|
-
await sendThinkingReply({ wsClient, frame, streamId: state.streamId, runtime });
|
|
2457
|
+
await sendThinkingReply({ wsClient, frame, streamId: state.streamId, runtime, state });
|
|
1743
2458
|
}
|
|
1744
2459
|
catch (e) {
|
|
1745
2460
|
runtime.error?.(`[wecom] sendThinkingReply threw err: ${String(e)}`);
|
|
@@ -1772,9 +2487,26 @@ async function routeAndDispatchMessage(params) {
|
|
|
1772
2487
|
runtime.error?.(`[wecom] sendMediaBatch threw: ${errMsg}`);
|
|
1773
2488
|
}
|
|
1774
2489
|
}
|
|
1775
|
-
//
|
|
1776
|
-
|
|
1777
|
-
|
|
2490
|
+
// 中间帧:有可见文本时流式更新(流式过期后跳过,等 deliver 完成后主动发送)
|
|
2491
|
+
// 使用 maskTemplateCardBlocks 遮罩正在构建中的模板卡片代码块,
|
|
2492
|
+
// 避免 JSON 源码在流式输出过程中暴露给终端用户
|
|
2493
|
+
if (info.kind !== "final" && state.accumulatedText && !state.streamExpired) {
|
|
2494
|
+
try {
|
|
2495
|
+
const displayText = maskTemplateCardBlocks(state.accumulatedText, (...args) => runtime.log?.(...args));
|
|
2496
|
+
if (displayText !== state.accumulatedText) {
|
|
2497
|
+
runtime.log?.(`[wecom][template-card] Mid-frame masked: original=${state.accumulatedText.length}chars, masked=${displayText.length}chars`);
|
|
2498
|
+
}
|
|
2499
|
+
await sendWeComReply({ wsClient, frame, text: displayText, runtime, finish: false, streamId: state.streamId });
|
|
2500
|
+
}
|
|
2501
|
+
catch (err) {
|
|
2502
|
+
if (err instanceof StreamExpiredError) {
|
|
2503
|
+
state.streamExpired = true;
|
|
2504
|
+
runtime.log?.(`[wecom] Stream expired during intermediate reply, will fallback to proactive send`);
|
|
2505
|
+
}
|
|
2506
|
+
else {
|
|
2507
|
+
throw err;
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
1778
2510
|
}
|
|
1779
2511
|
},
|
|
1780
2512
|
onError: (err, info) => {
|
|
@@ -1899,7 +2631,7 @@ async function processWeComMessage(params) {
|
|
|
1899
2631
|
const ctxPayload = buildMessageContext(frame, account, config, text, mediaList, quoteContent);
|
|
1900
2632
|
// runtime.log?.(`[plugin -> openclaw] body=${text}, mediaPaths=${JSON.stringify(mediaList.map(m => m.path))}${quoteContent ? `, quote=${quoteContent}` : ''}`);
|
|
1901
2633
|
try {
|
|
1902
|
-
await
|
|
2634
|
+
await routeAndDispatchMessage({
|
|
1903
2635
|
ctxPayload,
|
|
1904
2636
|
config,
|
|
1905
2637
|
account,
|
|
@@ -1908,10 +2640,10 @@ async function processWeComMessage(params) {
|
|
|
1908
2640
|
state,
|
|
1909
2641
|
runtime,
|
|
1910
2642
|
onCleanup: cleanupState,
|
|
1911
|
-
})
|
|
2643
|
+
});
|
|
1912
2644
|
}
|
|
1913
2645
|
catch (err) {
|
|
1914
|
-
runtime.error?.(`[wecom][plugin] Message processing failed
|
|
2646
|
+
runtime.error?.(`[wecom][plugin] Message processing failed: ${String(err)}`);
|
|
1915
2647
|
cleanupState();
|
|
1916
2648
|
}
|
|
1917
2649
|
}
|
|
@@ -1947,6 +2679,7 @@ function createSdkLogger(runtime, accountId) {
|
|
|
1947
2679
|
async function monitorWeComProvider(options) {
|
|
1948
2680
|
const { account, config, runtime, abortSignal, setStatus } = options;
|
|
1949
2681
|
runtime.log?.(`[${account.accountId}] [${PLUGIN_VERSION}] Initializing WSClient with SDK...`);
|
|
2682
|
+
runtime.error?.(`[${account.accountId}] [diag] monitor boot marker: build=20260325-event-debug-1`);
|
|
1950
2683
|
// 启动消息状态定期清理
|
|
1951
2684
|
startMessageStateCleanup();
|
|
1952
2685
|
return new Promise((resolve, reject) => {
|
|
@@ -2085,7 +2818,7 @@ async function monitorWeComProvider(options) {
|
|
|
2085
2818
|
// runtime.error?.(`[${account.accountId}] Failed to reply enter_check_update: ${String(err)}`);
|
|
2086
2819
|
}
|
|
2087
2820
|
});
|
|
2088
|
-
//
|
|
2821
|
+
// 监听普通消息
|
|
2089
2822
|
wsClient.on("message", async (frame) => {
|
|
2090
2823
|
try {
|
|
2091
2824
|
await processWeComMessage({
|
|
@@ -2100,6 +2833,44 @@ async function monitorWeComProvider(options) {
|
|
|
2100
2833
|
runtime.error?.(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
|
2101
2834
|
}
|
|
2102
2835
|
});
|
|
2836
|
+
// 监听所有事件回调(aibot_event_callback)。
|
|
2837
|
+
// 这里使用通用 event 监听,再按 eventtype 分发,兼容不同 SDK 版本在细分事件名上的差异。
|
|
2838
|
+
wsClient.on("event", async (frame) => {
|
|
2839
|
+
try {
|
|
2840
|
+
const eventBody = frame.body;
|
|
2841
|
+
const eventType = eventBody.event?.eventtype;
|
|
2842
|
+
runtime.log?.(`[${account.accountId}] Received event callback: eventtype=${eventType ?? ""}, msgid=${eventBody.msgid ?? ""}`);
|
|
2843
|
+
runtime.error?.(`[${account.accountId}] [diag] event-listener fired: eventtype=${eventType ?? ""}, msgid=${eventBody.msgid ?? ""}`);
|
|
2844
|
+
if (eventType !== "template_card_event") {
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
const templateCardEvent = eventBody.event?.template_card_event;
|
|
2848
|
+
runtime.log?.(`[${account.accountId}] Received template_card_event: event_key=${templateCardEvent?.event_key ?? ""}, task_id=${templateCardEvent?.task_id ?? ""}`);
|
|
2849
|
+
try {
|
|
2850
|
+
await updateTemplateCardOnEvent({
|
|
2851
|
+
frame,
|
|
2852
|
+
accountId: account.accountId,
|
|
2853
|
+
runtime,
|
|
2854
|
+
wsClient,
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
catch (updateErr) {
|
|
2858
|
+
runtime.error?.(`[${account.accountId}] [template-card-update] Failed to update template card: ${String(updateErr)}`);
|
|
2859
|
+
}
|
|
2860
|
+
await processWeComMessage({
|
|
2861
|
+
frame,
|
|
2862
|
+
account,
|
|
2863
|
+
config,
|
|
2864
|
+
runtime,
|
|
2865
|
+
wsClient,
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
catch (err) {
|
|
2869
|
+
runtime.error?.(`[${account.accountId}] Failed to process template_card_event: ${String(err)}`);
|
|
2870
|
+
}
|
|
2871
|
+
});
|
|
2872
|
+
runtime.log?.(`[${account.accountId}] Event listeners attached: message + event(template_card_event)`);
|
|
2873
|
+
runtime.error?.(`[${account.accountId}] [diag] listeners-ready marker`);
|
|
2103
2874
|
// 启动前预热 reqId 缓存,确保完成后再建立连接,避免 getSync 在预热完成前返回 undefined
|
|
2104
2875
|
warmupReqIdStore(account.accountId, (...args) => runtime.log?.(...args))
|
|
2105
2876
|
.then((count) => {
|
|
@@ -3502,7 +4273,8 @@ const plugin = {
|
|
|
3502
4273
|
}
|
|
3503
4274
|
return {
|
|
3504
4275
|
appendSystemContext: [
|
|
3505
|
-
"重要:涉及发送图片/视频/语音/文件给用户时,请务必使用 `MEDIA
|
|
4276
|
+
"重要:涉及发送图片/视频/语音/文件给用户时,请务必使用 `MEDIA:` 指令。详见 wecom-send-media 这个 skill(技能)。",
|
|
4277
|
+
"重要:当需要向用户发送结构化卡片消息(如通知、投票、按钮选择等)时,请在回复中直接输出 JSON 代码块(```json ... ```),其中 card_type 字段标明卡片类型。详见 wecom-send-template-card 技能。"
|
|
3506
4278
|
].join("\n"),
|
|
3507
4279
|
};
|
|
3508
4280
|
});
|