@mocrane/wecom 2026.3.8-4 → 2026.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +25 -22
  2. package/clawdbot.plugin.json +1 -0
  3. package/index.ts +38 -1
  4. package/openclaw.plugin.json +1 -0
  5. package/package.json +7 -4
  6. package/skills/wecom-contact-lookup/SKILL.md +162 -0
  7. package/skills/wecom-doc/SKILL.md +363 -0
  8. package/skills/wecom-doc/references/doc-api.md +224 -0
  9. package/skills/wecom-doc-manager/SKILL.md +64 -0
  10. package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
  11. package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
  12. package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
  13. package/skills/wecom-edit-todo/SKILL.md +249 -0
  14. package/skills/wecom-get-todo-detail/SKILL.md +143 -0
  15. package/skills/wecom-get-todo-list/SKILL.md +127 -0
  16. package/skills/wecom-meeting-create/SKILL.md +158 -0
  17. package/skills/wecom-meeting-create/references/example-full.md +30 -0
  18. package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
  19. package/skills/wecom-meeting-create/references/example-security.md +22 -0
  20. package/skills/wecom-meeting-manage/SKILL.md +136 -0
  21. package/skills/wecom-meeting-query/SKILL.md +330 -0
  22. package/skills/wecom-preflight/SKILL.md +141 -0
  23. package/skills/wecom-schedule/SKILL.md +159 -0
  24. package/skills/wecom-schedule/references/api-check-availability.md +56 -0
  25. package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
  26. package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
  27. package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
  28. package/skills/wecom-schedule/references/ref-reminders.md +24 -0
  29. package/skills/wecom-smartsheet-data/SKILL.md +71 -0
  30. package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
  31. package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
  32. package/skills/wecom-smartsheet-schema/SKILL.md +92 -0
  33. package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
  34. package/src/agent/handler.ts +105 -14
  35. package/src/channel.ts +7 -4
  36. package/src/compat/plugin-sdk-shim.ts +152 -0
  37. package/src/mcp/index.ts +7 -0
  38. package/src/mcp/schema.ts +108 -0
  39. package/src/mcp/tool.ts +247 -0
  40. package/src/mcp/transport.ts +583 -0
  41. package/src/mcp-config.ts +182 -0
  42. package/src/media/const.ts +24 -0
  43. package/src/media/index.ts +15 -0
  44. package/src/media/uploader.ts +240 -0
  45. package/src/monitor.ts +362 -40
  46. package/src/onboarding.ts +45 -6
  47. package/src/outbound.ts +116 -46
  48. package/src/timeout.ts +45 -0
  49. package/src/types/index.ts +1 -0
  50. package/src/types/message.ts +10 -1
  51. package/src/ws-adapter.ts +22 -0
package/src/monitor.ts CHANGED
@@ -12,6 +12,8 @@ import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, com
12
12
  import { extractEncryptFromXml } from "./crypto/xml.js";
13
13
  import { getWecomRuntime } from "./runtime.js";
14
14
  import { decryptWecomMediaWithMeta } from "./media.js";
15
+ import { uploadAndSendMediaBuffer } from "./media/index.js";
16
+ import { getWsClient } from "./ws-adapter.js";
15
17
  import { WEBHOOK_PATHS, LIMITS as WECOM_LIMITS } from "./types/constants.js";
16
18
  import { handleAgentWebhook } from "./agent/index.js";
17
19
  import { resolveWecomAccount, resolveWecomEgressProxyUrl, resolveWecomMediaMaxBytes, shouldRejectWecomDefaultRoute } from "./config/index.js";
@@ -548,8 +550,10 @@ function extractLocalFilePathsFromText(text: string): string[] {
548
550
  if (!text.trim()) return [];
549
551
 
550
552
  // Conservative: only accept common absolute paths for macOS/Linux hosts.
551
- // This is primarily for send local file style requests (operator/debug usage).
552
- const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>]+)`, "g");
553
+ // This is primarily for "send local file" style requests (operator/debug usage).
554
+ // Exclude CJK characters, CJK punctuation (,。!?;:), and other non-path chars
555
+ // to avoid swallowing trailing Chinese text as part of the path.
556
+ const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>\u3000-\u303F\uFF00-\uFFEF\u4E00-\u9FFF\u3400-\u4DBF]+)`, "g");
553
557
  const found = new Set<string>();
554
558
  let m: RegExpExecArray | null;
555
559
  while ((m = re.exec(text))) {
@@ -958,6 +962,7 @@ export async function processInboundMessage(target: WecomWebhookTarget, msg: Wec
958
962
  const maxBytes = resolveWecomMediaMaxBytes(target.config);
959
963
  const proxyUrl = resolveWecomEgressProxyUrl(target.config);
960
964
 
965
+
961
966
  // 图片消息处理:如果存在 url 且配置了 aesKey,则尝试解密下载
962
967
  if (msgtype === "image") {
963
968
  const url = String((msg as any).image?.url ?? "").trim();
@@ -1028,6 +1033,42 @@ export async function processInboundMessage(target: WecomWebhookTarget, msg: Wec
1028
1033
  }
1029
1034
  }
1030
1035
 
1036
+ // 视频消息处理:与文件消息类似,下载并解密视频
1037
+ if (msgtype === "video") {
1038
+ const url = String((msg as any).video?.url ?? "").trim();
1039
+ const aesKey = globalAesKey || (msg as any).video?.aeskey || "";
1040
+ logVerbose(target, `video: url=${url ? url.substring(0, 80) + "..." : "(empty)"} aesKey=${aesKey ? "(present)" : "(empty)"}`);
1041
+ if (url && aesKey) {
1042
+ try {
1043
+ const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
1044
+ const inferred = inferInboundMediaMeta({
1045
+ kind: "file",
1046
+ buffer: decrypted.buffer,
1047
+ sourceUrl: decrypted.sourceUrl || url,
1048
+ sourceContentType: decrypted.sourceContentType,
1049
+ sourceFilename: decrypted.sourceFilename,
1050
+ explicitFilename: pickBotFileName(msg),
1051
+ });
1052
+ return {
1053
+ body: `[video] 视频文件已保存,文件名: ${inferred.filename}`,
1054
+ media: {
1055
+ buffer: decrypted.buffer,
1056
+ contentType: inferred.contentType,
1057
+ filename: inferred.filename,
1058
+ }
1059
+ };
1060
+ } catch (err) {
1061
+ target.runtime.error?.(
1062
+ `Failed to decrypt inbound video: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
1063
+ );
1064
+ const errorMessage = typeof err === 'object' && err
1065
+ ? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
1066
+ : String(err);
1067
+ return { body: `[video] (decryption failed: ${errorMessage})` };
1068
+ }
1069
+ }
1070
+ }
1071
+
1031
1072
  // Mixed message handling: extract first media if available
1032
1073
  if (msgtype === "mixed") {
1033
1074
  const items = (msg as any).mixed?.msg_item;
@@ -1198,6 +1239,9 @@ async function startAgentForStream(params: {
1198
1239
  const config = target.config;
1199
1240
  const account = target.account;
1200
1241
 
1242
+ // WS 长连接模式标记:跳过 Webhook 专属的 Agent 私信兜底逻辑
1243
+ const isWsMode = Boolean(streamStore.getStream(streamId)?.wsMode);
1244
+
1201
1245
  const userid = resolveWecomSenderUserId(msg) || "unknown";
1202
1246
  const chatType = msg.chattype === "group" ? "group" : "direct";
1203
1247
  const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
@@ -1249,6 +1293,61 @@ async function startAgentForStream(params: {
1249
1293
 
1250
1294
  // 1) 图片:优先 Bot 群内/原会话交付(被动/流式 msg_item)
1251
1295
  if (imagePaths.length > 0 && otherPaths.length === 0) {
1296
+ // WS 模式:走 uploadMedia + sendMediaMessage,避免大图 base64 单帧超限
1297
+ if (isWsMode) {
1298
+ const wsClient = getWsClient(account.accountId);
1299
+ const sentFiles: string[] = [];
1300
+ const failedFiles: string[] = [];
1301
+
1302
+ if (wsClient && chatId && chatId !== "unknown") {
1303
+ for (const p of imagePaths) {
1304
+ try {
1305
+ const buf = await fs.readFile(p);
1306
+ const fname = pathModule.basename(p);
1307
+ const ext = pathModule.extname(p).slice(1).toLowerCase();
1308
+ const mimeMap: Record<string, string> = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
1309
+ const guessedType = mimeMap[ext] ?? "image/png";
1310
+ const result = await uploadAndSendMediaBuffer({
1311
+ wsClient,
1312
+ buffer: buf,
1313
+ contentType: guessedType,
1314
+ fileName: fname,
1315
+ chatId,
1316
+ log: (m) => logVerbose(target, m),
1317
+ errorLog: (m) => target.runtime.error?.(m),
1318
+ });
1319
+ if (result.ok) {
1320
+ sentFiles.push(fname);
1321
+ logVerbose(target, `local-path: WS 图片上传发送成功 path=${p} type=${result.finalType}`);
1322
+ } else {
1323
+ failedFiles.push(fname);
1324
+ logVerbose(target, `local-path: WS 图片上传发送失败 path=${p} reason=${result.rejectReason ?? result.error}`);
1325
+ }
1326
+ } catch (err) {
1327
+ const fname = p.split("/").pop() || p;
1328
+ failedFiles.push(fname);
1329
+ target.runtime.error?.(`local-path: WS 图片读取/发送失败 path=${p}: ${String(err)}`);
1330
+ }
1331
+ }
1332
+ } else {
1333
+ logVerbose(target, `local-path: WS 模式但 WSClient 不可用或缺少 chatId,跳过图片发送`);
1334
+ failedFiles.push(...imagePaths.map((p) => p.split("/").pop() || p));
1335
+ }
1336
+
1337
+ const summary = sentFiles.length > 0
1338
+ ? (sentFiles.length === 1 ? `已发送图片(${sentFiles[0]})` : `已发送 ${sentFiles.length} 张图片`)
1339
+ + (failedFiles.length > 0 ? `(失败:${failedFiles.join(", ")})` : "")
1340
+ : `图片发送失败:${failedFiles.join(", ")}`;
1341
+
1342
+ streamStore.updateStream(streamId, (s) => {
1343
+ s.finished = true;
1344
+ s.content = summary;
1345
+ });
1346
+ streamStore.onStreamFinished(streamId);
1347
+ return;
1348
+ }
1349
+
1350
+ // Webhook 模式:原有 base64 msgItems 路径
1252
1351
  const loaded: Array<{ base64: string; md5: string; path: string }> = [];
1253
1352
  for (const p of imagePaths) {
1254
1353
  try {
@@ -1298,7 +1397,21 @@ async function startAgentForStream(params: {
1298
1397
  return;
1299
1398
  }
1300
1399
 
1301
- // 图片路径都读取失败时,切换到 Agent 私信兜底,并主动结束 Bot 流。
1400
+ // 图片路径都读取失败时的兜底处理
1401
+ if (isWsMode) {
1402
+ // WS 模式:不走 Agent 私信兜底,直接提示错误并结束
1403
+ const fallbackName = imagePaths.length === 1
1404
+ ? (imagePaths[0]!.split("/").pop() || "image")
1405
+ : `${imagePaths.length} 张图片`;
1406
+ streamStore.updateStream(streamId, (s) => {
1407
+ s.finished = true;
1408
+ s.content = `图片读取失败(${fallbackName}),请重试。`;
1409
+ });
1410
+ streamStore.onStreamFinished(streamId);
1411
+ return;
1412
+ }
1413
+
1414
+ // Webhook 模式:切换到 Agent 私信兜底,并主动结束 Bot 流。
1302
1415
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1303
1416
  const agentOk = Boolean(agentCfg);
1304
1417
  const fallbackName = imagePaths.length === 1
@@ -1355,6 +1468,61 @@ async function startAgentForStream(params: {
1355
1468
 
1356
1469
  // 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
1357
1470
  if (otherPaths.length > 0) {
1471
+ if (isWsMode) {
1472
+ // WS 模式:通过 WSClient uploadMedia + sendMediaMessage 发送文件
1473
+ const wsClient = getWsClient(account.accountId);
1474
+ const sentFiles: string[] = [];
1475
+ const failedFiles: string[] = [];
1476
+
1477
+ if (wsClient && chatId && chatId !== "unknown") {
1478
+ for (const p of otherPaths) {
1479
+ try {
1480
+ const fsm = await import("node:fs/promises");
1481
+ const pathModule = await import("node:path");
1482
+ const buf = await fsm.readFile(p);
1483
+ const fname = pathModule.basename(p);
1484
+ const ext = pathModule.extname(p).slice(1).toLowerCase();
1485
+ const guessedType = MIME_BY_EXT[ext] ?? "application/octet-stream";
1486
+ const result = await uploadAndSendMediaBuffer({
1487
+ wsClient,
1488
+ buffer: buf,
1489
+ contentType: guessedType,
1490
+ fileName: fname,
1491
+ chatId,
1492
+ log: (m) => logVerbose(target, m),
1493
+ errorLog: (m) => target.runtime.error?.(m),
1494
+ });
1495
+ if (result.ok) {
1496
+ sentFiles.push(fname);
1497
+ logVerbose(target, `local-path: WS 文件发送成功 path=${p} type=${result.finalType}`);
1498
+ } else {
1499
+ failedFiles.push(fname);
1500
+ logVerbose(target, `local-path: WS 文件发送失败 path=${p} reason=${result.rejectReason ?? result.error}`);
1501
+ }
1502
+ } catch (err) {
1503
+ const fname = p.split("/").pop() || p;
1504
+ failedFiles.push(fname);
1505
+ target.runtime.error?.(`local-path: WS 文件读取/发送失败 path=${p}: ${String(err)}`);
1506
+ }
1507
+ }
1508
+ } else {
1509
+ logVerbose(target, `local-path: WS 模式但 WSClient 不可用或缺少 chatId,跳过文件发送`);
1510
+ failedFiles.push(...otherPaths.map((p) => p.split("/").pop() || p));
1511
+ }
1512
+
1513
+ const summary = sentFiles.length > 0
1514
+ ? `已发送文件:${sentFiles.join(", ")}${failedFiles.length > 0 ? `(失败:${failedFiles.join(", ")})` : ""}`
1515
+ : `文件发送失败:${failedFiles.join(", ")}`;
1516
+
1517
+ streamStore.updateStream(streamId, (s) => {
1518
+ s.finished = true;
1519
+ s.content = summary;
1520
+ });
1521
+ streamStore.onStreamFinished(streamId);
1522
+ return;
1523
+ }
1524
+
1525
+ // Webhook 模式:Agent 私信兜底
1358
1526
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1359
1527
  const agentOk = Boolean(agentCfg);
1360
1528
 
@@ -1440,6 +1608,34 @@ async function startAgentForStream(params: {
1440
1608
  }
1441
1609
  }
1442
1610
 
1611
+ // 3. 如果是视频,尝试用 ffmpeg 提取第一帧作为图片,让 LLM 能"看到"视频内容
1612
+ let videoFirstFramePath: string | undefined;
1613
+ if (mediaPath && mediaType?.startsWith("video/")) {
1614
+ try {
1615
+ const pathModule = await import("node:path");
1616
+ const { execFile } = await import("node:child_process");
1617
+ const { promisify } = await import("node:util");
1618
+ const execFileAsync = promisify(execFile);
1619
+ const framePath = mediaPath.replace(/\.[^.]+$/, "_frame1.jpg");
1620
+ await execFileAsync("ffmpeg", [
1621
+ "-i", mediaPath,
1622
+ "-vframes", "1",
1623
+ "-q:v", "2",
1624
+ "-y",
1625
+ framePath,
1626
+ ], { timeout: 10_000 });
1627
+ // 确认文件存在且非空
1628
+ const fs = await import("node:fs/promises");
1629
+ const stat = await fs.stat(framePath);
1630
+ if (stat.size > 0) {
1631
+ videoFirstFramePath = framePath;
1632
+ logVerbose(target, `video: 提取第一帧成功 ${framePath} (${stat.size} bytes)`);
1633
+ }
1634
+ } catch (err) {
1635
+ logVerbose(target, `video: 提取第一帧失败(ffmpeg 可能不可用): ${String(err)}`);
1636
+ }
1637
+ }
1638
+
1443
1639
  const route = core.channel.routing.resolveAgentRoute({
1444
1640
  cfg: config,
1445
1641
  channel: "wecom",
@@ -1543,12 +1739,22 @@ async function startAgentForStream(params: {
1543
1739
  const isResetCommand = /^\/(new|reset)(?:\s|$)/i.test(rawBodyNormalized);
1544
1740
  const resetCommandKind = isResetCommand ? (rawBodyNormalized.match(/^\/(new|reset)/i)?.[1]?.toLowerCase() ?? "new") : null;
1545
1741
 
1546
- const attachments = mediaPath ? [{
1742
+ const attachments: Array<{ name: string; mimeType?: string; url: string }> | undefined = mediaPath ? [{
1547
1743
  name: media?.filename || "file",
1548
1744
  mimeType: mediaType,
1549
1745
  url: pathToFileURL(mediaPath).href
1550
1746
  }] : undefined;
1551
1747
 
1748
+ // 如果提取到了视频第一帧,追加为附件让 LLM 能看到视频画面
1749
+ if (videoFirstFramePath && attachments) {
1750
+ const pathModule = await import("node:path");
1751
+ attachments.push({
1752
+ name: pathModule.basename(videoFirstFramePath),
1753
+ mimeType: "image/jpeg",
1754
+ url: pathToFileURL(videoFirstFramePath).href,
1755
+ });
1756
+ }
1757
+
1552
1758
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1553
1759
  Body: body,
1554
1760
  RawBody: rawBody,
@@ -1781,7 +1987,7 @@ async function startAgentForStream(params: {
1781
1987
  const now = Date.now();
1782
1988
  const deadline = current.createdAt + BOT_WINDOW_MS;
1783
1989
  const switchAt = deadline - BOT_SWITCH_MARGIN_MS;
1784
- const nearTimeout = !current.fallbackMode && !current.finished && now >= switchAt;
1990
+ const nearTimeout = !isWsMode && !current.fallbackMode && !current.finished && now >= switchAt;
1785
1991
  if (nearTimeout) {
1786
1992
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1787
1993
  const agentOk = Boolean(agentCfg);
@@ -1810,7 +2016,35 @@ async function startAgentForStream(params: {
1810
2016
  return;
1811
2017
  }
1812
2018
 
1813
- const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
2019
+ // ── 解析 LLM 输出文本中的 MEDIA: /path 指令 ──
2020
+ // OpenClaw 核心的 splitMediaFromOutput 通常已提取并剥离 MEDIA: 行,
2021
+ // 此处兜底处理核心未覆盖的边界情况(如旧版本核心、特殊格式等)。
2022
+ const mediaDirectivePaths: string[] = [];
2023
+ const mediaDirectiveRe = /^MEDIA:\s*`?([^\n`]+?)`?\s*$/gm;
2024
+ let _mdMatch: RegExpExecArray | null;
2025
+ while ((_mdMatch = mediaDirectiveRe.exec(text)) !== null) {
2026
+ let p = (_mdMatch[1] ?? "").trim();
2027
+ if (!p) continue;
2028
+ // 展开 ~ 为 HOME 目录
2029
+ if (p.startsWith("~/") || p === "~") {
2030
+ const home = process.env.HOME || "/root";
2031
+ p = p.replace(/^~/, home);
2032
+ }
2033
+ if (!mediaDirectivePaths.includes(p)) {
2034
+ mediaDirectivePaths.push(p);
2035
+ logVerbose(target, `media: 检测到 MEDIA: 指令 path=${p}`);
2036
+ }
2037
+ }
2038
+ // 从回复文本中移除 MEDIA: 指令行,不展示给用户
2039
+ if (mediaDirectivePaths.length > 0) {
2040
+ text = text.replace(/^MEDIA:\s*`?[^\n`]+?`?\s*$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
2041
+ }
2042
+
2043
+ const mediaUrls = Array.from(new Set([
2044
+ ...(payload.mediaUrls || []),
2045
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
2046
+ ...mediaDirectivePaths,
2047
+ ]));
1814
2048
  for (const mediaPath of mediaUrls) {
1815
2049
  let contentType: string | undefined;
1816
2050
  let filename = mediaPath.split("/").pop() || "attachment";
@@ -1830,17 +2064,92 @@ async function startAgentForStream(params: {
1830
2064
  buf = await fs.readFile(mediaPath);
1831
2065
  filename = pathModule.basename(mediaPath);
1832
2066
  const ext = pathModule.extname(mediaPath).slice(1).toLowerCase();
1833
- const imageExts: Record<string, string> = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
1834
- contentType = imageExts[ext] ?? "application/octet-stream";
2067
+ contentType = MIME_BY_EXT[ext] ?? "application/octet-stream";
1835
2068
  }
1836
2069
 
1837
2070
  if (contentType?.startsWith("image/")) {
2071
+ if (isWsMode) {
2072
+ // WS 模式:图片也通过 uploadAndSendMediaBuffer 作为独立 image 消息发送
2073
+ // 避免嵌入流式回复 base64 导致企微客户端可能不显示
2074
+ if (current.agentMediaKeys.includes(mediaPath)) {
2075
+ logVerbose(target, `media: WS 模式跳过已发送的图片 path=${mediaPath}`);
2076
+ continue;
2077
+ }
2078
+ const wsClient = getWsClient(account.accountId);
2079
+ if (wsClient && current.chatId) {
2080
+ const result = await uploadAndSendMediaBuffer({
2081
+ wsClient,
2082
+ buffer: buf,
2083
+ contentType: contentType ?? "image/jpeg",
2084
+ fileName: filename,
2085
+ chatId: current.chatId,
2086
+ log: (m) => logVerbose(target, m),
2087
+ errorLog: (m) => target.runtime.error?.(m),
2088
+ });
2089
+ if (result.ok) {
2090
+ logVerbose(target, `media: WS 图片上传发送成功 type=${result.finalType} filename=${filename}`);
2091
+ streamStore.updateStream(streamId, (s) => {
2092
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
2093
+ });
2094
+ } else {
2095
+ // 降级:如果上传失败,回退到 base64 嵌入方式
2096
+ logVerbose(target, `media: WS 图片上传失败,回退到 base64 嵌入 filename=${filename}`);
2097
+ const base64 = buf.toString("base64");
2098
+ const md5 = crypto.createHash("md5").update(buf).digest("hex");
2099
+ current.images.push({ base64, md5 });
2100
+ }
2101
+ } else {
2102
+ // WSClient 不可用时回退到 base64
2103
+ const base64 = buf.toString("base64");
2104
+ const md5 = crypto.createHash("md5").update(buf).digest("hex");
2105
+ current.images.push({ base64, md5 });
2106
+ logVerbose(target, `media: WS 模式但 WSClient 不可用,回退到 base64 嵌入 filename=${filename}`);
2107
+ }
2108
+ continue;
2109
+ }
2110
+ // 非 WS 模式:保持原有 base64 嵌入方式
1838
2111
  const base64 = buf.toString("base64");
1839
2112
  const md5 = crypto.createHash("md5").update(buf).digest("hex");
1840
2113
  current.images.push({ base64, md5 });
1841
2114
  logVerbose(target, `media: 识别为图片 contentType=${contentType} filename=${filename}`);
1842
2115
  } else {
1843
- // Non-image media: Bot 不支持原样发送(尤其群聊),统一切换到 Agent 私信兜底,并在 Bot 会话里提示用户。
2116
+ // Non-image media: Bot 不支持原样发送(尤其群聊)
2117
+ if (isWsMode) {
2118
+ // 去重:如果这个媒体路径已经发送过,跳过
2119
+ if (current.agentMediaKeys.includes(mediaPath)) {
2120
+ logVerbose(target, `media: WS 模式跳过已发送的媒体 path=${mediaPath}`);
2121
+ continue;
2122
+ }
2123
+ // WS 模式:通过 WSClient uploadMedia + sendMediaMessage 发送非图片媒体
2124
+ const wsClient = getWsClient(account.accountId);
2125
+ if (wsClient && current.chatId) {
2126
+ const result = await uploadAndSendMediaBuffer({
2127
+ wsClient,
2128
+ buffer: buf,
2129
+ contentType: contentType ?? "application/octet-stream",
2130
+ fileName: filename,
2131
+ chatId: current.chatId,
2132
+ log: (m) => logVerbose(target, m),
2133
+ errorLog: (m) => target.runtime.error?.(m),
2134
+ });
2135
+ if (result.ok) {
2136
+ logVerbose(target, `media: WS 上传发送成功 type=${result.finalType}${result.downgraded ? ` (降级: ${result.downgradeNote})` : ""}`);
2137
+ // 记录已发送,防止后续 deliver 调用时重复发送
2138
+ streamStore.updateStream(streamId, (s) => {
2139
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
2140
+ });
2141
+ } else if (result.rejected) {
2142
+ logVerbose(target, `media: 文件被拒绝 ${result.rejectReason}`);
2143
+ } else {
2144
+ target.runtime.error?.(`media: WS 上传发送失败 ${result.error}`);
2145
+ }
2146
+ } else {
2147
+ logVerbose(target, `media: WS 模式但 WSClient 不可用或缺少 chatId,跳过 filename=${filename}`);
2148
+ }
2149
+ continue;
2150
+ }
2151
+
2152
+ // Webhook 模式:统一切换到 Agent 私信兜底,并在 Bot 会话里提示用户。
1844
2153
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1845
2154
  const agentOk = Boolean(agentCfg);
1846
2155
  const alreadySent = current.agentMediaKeys.includes(mediaPath);
@@ -1892,40 +2201,42 @@ async function startAgentForStream(params: {
1892
2201
  }
1893
2202
  } catch (err) {
1894
2203
  target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
1895
- const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1896
- const agentOk = Boolean(agentCfg);
1897
- const fallbackFilename = filename || mediaPath.split("/").pop() || "attachment";
1898
- if (agentCfg && current.userId && !current.agentMediaKeys.includes(mediaPath)) {
1899
- try {
1900
- await sendAgentDmMedia({
1901
- agent: agentCfg,
2204
+ if (!isWsMode) {
2205
+ // Webhook 模式:Agent 私信兜底
2206
+ const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
2207
+ const agentOk = Boolean(agentCfg);
2208
+ const fallbackFilename = filename || mediaPath.split("/").pop() || "attachment";
2209
+ if (agentCfg && current.userId && !current.agentMediaKeys.includes(mediaPath)) {
2210
+ try {
2211
+ await sendAgentDmMedia({
2212
+ agent: agentCfg,
2213
+ userId: current.userId,
2214
+ mediaUrlOrPath: mediaPath,
2215
+ contentType,
2216
+ filename: fallbackFilename,
2217
+ });
2218
+ streamStore.updateStream(streamId, (s) => {
2219
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
2220
+ });
2221
+ logVerbose(target, `fallback(error): 媒体处理失败后已通过 Agent 私信发送 user=${current.userId}`);
2222
+ } catch (sendErr) {
2223
+ target.runtime.error?.(`fallback(error): 媒体处理失败后的 Agent 私信发送也失败: ${String(sendErr)}`);
2224
+ }
2225
+ }
2226
+ if (!current.fallbackMode) {
2227
+ const prompt = buildFallbackPrompt({
2228
+ kind: "error",
2229
+ agentConfigured: agentOk,
1902
2230
  userId: current.userId,
1903
- mediaUrlOrPath: mediaPath,
1904
- contentType,
1905
2231
  filename: fallbackFilename,
2232
+ chatType: current.chatType,
1906
2233
  });
1907
2234
  streamStore.updateStream(streamId, (s) => {
1908
- s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
2235
+ s.fallbackMode = "error";
2236
+ s.finished = true;
2237
+ s.content = prompt;
2238
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1909
2239
  });
1910
- logVerbose(target, `fallback(error): 媒体处理失败后已通过 Agent 私信发送 user=${current.userId}`);
1911
- } catch (sendErr) {
1912
- target.runtime.error?.(`fallback(error): 媒体处理失败后的 Agent 私信发送也失败: ${String(sendErr)}`);
1913
- }
1914
- }
1915
- if (!current.fallbackMode) {
1916
- const prompt = buildFallbackPrompt({
1917
- kind: "error",
1918
- agentConfigured: agentOk,
1919
- userId: current.userId,
1920
- filename: fallbackFilename,
1921
- chatType: current.chatType,
1922
- });
1923
- streamStore.updateStream(streamId, (s) => {
1924
- s.fallbackMode = "error";
1925
- s.finished = true;
1926
- s.content = prompt;
1927
- s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1928
- });
1929
2240
  try {
1930
2241
  await sendBotFallbackPromptNow({ streamId, text: prompt });
1931
2242
  logVerbose(target, `fallback(error): 群内提示已推送`);
@@ -1933,6 +2244,9 @@ async function startAgentForStream(params: {
1933
2244
  target.runtime.error?.(`wecom bot fallback prompt push failed (error) streamId=${streamId}: ${String(pushErr)}`);
1934
2245
  }
1935
2246
  }
2247
+ } // end if (!isWsMode)
2248
+ // WS 模式:媒体处理失败时 continue 尝试下一个媒体,Webhook 模式 return 退出
2249
+ if (isWsMode) continue;
1936
2250
  return;
1937
2251
  }
1938
2252
  }
@@ -1974,7 +2288,13 @@ async function startAgentForStream(params: {
1974
2288
 
1975
2289
  streamStore.updateStream(streamId, (s) => {
1976
2290
  if (!s.content.trim() && !(s.images?.length ?? 0)) {
1977
- s.content = "✅ 已处理完成。";
2291
+ const hasMediaDelivered = (s.agentMediaKeys?.length ?? 0) > 0;
2292
+ const hasFallback = Boolean(s.fallbackMode);
2293
+ if (hasMediaDelivered) {
2294
+ s.content = "✅ 文件已发送。";
2295
+ } else if (!hasFallback) {
2296
+ s.content = "✅ 已处理完成。";
2297
+ }
1978
2298
  }
1979
2299
  });
1980
2300
 
@@ -1982,7 +2302,7 @@ async function startAgentForStream(params: {
1982
2302
 
1983
2303
  // Timeout fallback final delivery (Agent DM): send once after the agent run completes.
1984
2304
  const finishedState = streamStore.getStream(streamId);
1985
- if (finishedState?.fallbackMode === "timeout" && !finishedState.finalDeliveredAt) {
2305
+ if (finishedState?.fallbackMode === "timeout" && !finishedState.finalDeliveredAt && !isWsMode) {
1986
2306
  const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1987
2307
  if (!agentCfg) {
1988
2308
  // Agent not configured - group prompt already explains the situation.
@@ -2052,6 +2372,7 @@ function formatQuote(quote: WecomInboundQuote): string {
2052
2372
  }
2053
2373
  if (type === "voice") return `[引用: 语音] ${quote.voice?.content || ""}`;
2054
2374
  if (type === "file") return `[引用: 文件] ${quote.file?.url || ""}`;
2375
+ if (type === "video") return `[引用: 视频] ${quote.video?.url || ""}`;
2055
2376
  return "";
2056
2377
  }
2057
2378
 
@@ -2073,6 +2394,7 @@ export function buildInboundBody(msg: WecomInboundMessage): string {
2073
2394
  } else body = "[mixed]";
2074
2395
  } else if (msgtype === "image") body = `[image] ${(msg as any).image?.url || ""}`;
2075
2396
  else if (msgtype === "file") body = `[file] ${(msg as any).file?.url || ""}`;
2397
+ else if (msgtype === "video") body = `[video] ${(msg as any).video?.url || ""}`;
2076
2398
  else if (msgtype === "event") body = `[event] ${(msg as any).event?.eventtype || ""}`;
2077
2399
  else if (msgtype === "stream") body = `[stream_refresh] ${(msg as any).stream?.id || ""}`;
2078
2400
  else body = msgtype ? `[${msgtype}]` : "";
package/src/onboarding.ts CHANGED
@@ -4,12 +4,50 @@
4
4
  */
5
5
 
6
6
  import type {
7
- ChannelOnboardingAdapter,
8
- ChannelOnboardingDmPolicy,
9
7
  OpenClawConfig,
10
8
  WizardPrompter,
11
9
  } from "openclaw/plugin-sdk";
12
- import { DEFAULT_ACCOUNT_ID, promptAccountId } from "openclaw/plugin-sdk";
10
+ import {
11
+ DEFAULT_ACCOUNT_ID,
12
+ resolvePromptAccountId,
13
+ } from "./compat/plugin-sdk-shim.js";
14
+
15
+ // ─── 类型兼容 ───
16
+ // v2026.3.2 使用 ChannelOnboardingAdapter / ChannelOnboardingDmPolicy(来自 onboarding-types.ts)
17
+ // v2026.3.22+ 重命名为 ChannelSetupWizardAdapter / ChannelSetupDmPolicy(setup-wizard-types.ts)
18
+ // 且 ChannelPlugin.onboarding → ChannelPlugin.setupWizard
19
+ // 为同时支持新旧版本,此处直接声明本地接口。
20
+ type ChannelOnboardingDmPolicy = {
21
+ label: string;
22
+ channel: string;
23
+ policyKey: string;
24
+ allowFromKey: string;
25
+ getCurrent: (cfg: OpenClawConfig, accountId?: string) => string;
26
+ setPolicy: (cfg: OpenClawConfig, policy: string, accountId?: string) => OpenClawConfig;
27
+ promptAllowFrom?: (params: {
28
+ cfg: OpenClawConfig;
29
+ prompter: WizardPrompter;
30
+ accountId?: string;
31
+ }) => Promise<OpenClawConfig>;
32
+ };
33
+
34
+ type ChannelOnboardingAdapter = {
35
+ channel: string;
36
+ dmPolicy?: ChannelOnboardingDmPolicy;
37
+ getStatus: (ctx: { cfg: OpenClawConfig }) => Promise<{
38
+ channel: string;
39
+ configured: boolean;
40
+ statusLines: string[];
41
+ selectionHint?: string;
42
+ quickstartScore?: number;
43
+ }>;
44
+ configure: (ctx: {
45
+ cfg: OpenClawConfig;
46
+ prompter: WizardPrompter;
47
+ accountOverrides: Record<string, string | undefined>;
48
+ shouldPromptAccountIds: boolean;
49
+ }) => Promise<{ cfg: OpenClawConfig; accountId?: string }>;
50
+ };
13
51
  import { listWecomAccountIds, resolveDefaultWecomAccountId, resolveWecomAccount, resolveWecomAccounts } from "./config/index.js";
14
52
  import type { WecomConfig, WecomBotConfig, WecomAgentConfig, WecomDmConfig, WecomAccountConfig } from "./types/index.js";
15
53
 
@@ -299,12 +337,13 @@ async function resolveOnboardingAccountId(params: {
299
337
  const override = params.accountOverride?.trim();
300
338
  let accountId = override || defaultAccountId;
301
339
  if (!override && params.shouldPromptAccountIds) {
340
+ const promptAccountId = await resolvePromptAccountId();
302
341
  accountId = await promptAccountId({
303
342
  cfg: params.cfg,
304
343
  prompter: params.prompter,
305
344
  label: "WeCom",
306
345
  currentId: accountId,
307
- listAccountIds: (cfg) => listWecomAccountIds(cfg),
346
+ listAccountIds: (cfg) => listWecomAccountIds(cfg as OpenClawConfig),
308
347
  defaultAccountId,
309
348
  });
310
349
  }
@@ -690,9 +729,9 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
690
729
  const account = resolveWecomAccount({ cfg });
691
730
  return (account.bot?.config.dm?.policy ?? "pairing") as "pairing";
692
731
  },
693
- setPolicy: (cfg: OpenClawConfig, policy: "pairing" | "allowlist" | "open" | "disabled") => {
732
+ setPolicy: (cfg: OpenClawConfig, policy: string) => {
694
733
  const accountId = resolveDefaultWecomAccountId(cfg);
695
- return setWecomDmPolicy(cfg, "bot", { policy }, accountId);
734
+ return setWecomDmPolicy(cfg, "bot", { policy: policy as "pairing" | "allowlist" | "open" | "disabled" }, accountId);
696
735
  },
697
736
  promptAllowFrom: async ({ cfg, prompter }: { cfg: OpenClawConfig; prompter: WizardPrompter }) => {
698
737
  const allowFromStr = String(