@mocrane/wecom 2026.3.9 → 2026.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -22
- package/clawdbot.plugin.json +1 -0
- package/index.ts +38 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +7 -4
- package/skills/wecom-contact-lookup/SKILL.md +162 -0
- package/skills/wecom-doc/SKILL.md +363 -0
- package/skills/wecom-doc/references/doc-api.md +224 -0
- package/skills/wecom-doc-manager/SKILL.md +64 -0
- package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
- package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
- package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
- package/skills/wecom-edit-todo/SKILL.md +249 -0
- package/skills/wecom-get-todo-detail/SKILL.md +143 -0
- package/skills/wecom-get-todo-list/SKILL.md +127 -0
- package/skills/wecom-meeting-create/SKILL.md +158 -0
- package/skills/wecom-meeting-create/references/example-full.md +30 -0
- package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
- package/skills/wecom-meeting-create/references/example-security.md +22 -0
- package/skills/wecom-meeting-manage/SKILL.md +136 -0
- package/skills/wecom-meeting-query/SKILL.md +330 -0
- package/skills/wecom-preflight/SKILL.md +141 -0
- package/skills/wecom-schedule/SKILL.md +159 -0
- package/skills/wecom-schedule/references/api-check-availability.md +56 -0
- package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
- package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
- package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
- package/skills/wecom-schedule/references/ref-reminders.md +24 -0
- package/skills/wecom-smartsheet-data/SKILL.md +71 -0
- package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
- package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
- package/skills/wecom-smartsheet-schema/SKILL.md +92 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
- package/src/agent/handler.ts +105 -14
- package/src/channel.ts +7 -4
- package/src/compat/plugin-sdk-shim.ts +152 -0
- package/src/mcp/index.ts +7 -0
- package/src/mcp/schema.ts +108 -0
- package/src/mcp/tool.ts +247 -0
- package/src/mcp/transport.ts +583 -0
- package/src/mcp-config.ts +182 -0
- package/src/media/const.ts +24 -0
- package/src/media/index.ts +15 -0
- package/src/media/uploader.ts +240 -0
- package/src/monitor.ts +362 -40
- package/src/onboarding.ts +45 -6
- package/src/outbound.ts +116 -46
- package/src/timeout.ts +45 -0
- package/src/types/index.ts +1 -0
- package/src/types/message.ts +10 -1
- package/src/ws-adapter.ts +22 -0
package/src/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
|
|
552
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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:
|
|
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(
|