@mocrane/wecom 2026.3.14 → 2026.3.19

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 (41) hide show
  1. package/index.ts +37 -0
  2. package/package.json +4 -3
  3. package/skills/wecom-contact-lookup/SKILL.md +162 -0
  4. package/skills/wecom-doc-manager/SKILL.md +64 -0
  5. package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
  6. package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
  7. package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
  8. package/skills/wecom-edit-todo/SKILL.md +249 -0
  9. package/skills/wecom-get-todo-detail/SKILL.md +143 -0
  10. package/skills/wecom-get-todo-list/SKILL.md +127 -0
  11. package/skills/wecom-meeting-create/SKILL.md +158 -0
  12. package/skills/wecom-meeting-create/references/example-full.md +30 -0
  13. package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
  14. package/skills/wecom-meeting-create/references/example-security.md +22 -0
  15. package/skills/wecom-meeting-manage/SKILL.md +136 -0
  16. package/skills/wecom-meeting-query/SKILL.md +330 -0
  17. package/skills/wecom-preflight/SKILL.md +141 -0
  18. package/skills/wecom-schedule/SKILL.md +159 -0
  19. package/skills/wecom-schedule/references/api-check-availability.md +56 -0
  20. package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
  21. package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
  22. package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
  23. package/skills/wecom-schedule/references/ref-reminders.md +24 -0
  24. package/skills/wecom-smartsheet-data/SKILL.md +71 -0
  25. package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
  26. package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
  27. package/skills/wecom-smartsheet-schema/SKILL.md +92 -0
  28. package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
  29. package/src/agent/handler.ts +105 -14
  30. package/src/mcp/index.ts +7 -0
  31. package/src/mcp/schema.ts +108 -0
  32. package/src/mcp/tool.ts +226 -0
  33. package/src/mcp/transport.ts +561 -0
  34. package/src/media/const.ts +24 -0
  35. package/src/media/index.ts +15 -0
  36. package/src/media/uploader.ts +240 -0
  37. package/src/monitor.ts +293 -12
  38. package/src/outbound.ts +116 -46
  39. package/src/types/index.ts +1 -0
  40. package/src/types/message.ts +10 -1
  41. package/src/ws-adapter.ts +4 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * WeCom WS 模式媒体上传工具模块
3
+ *
4
+ * 接收 deliver callback 已加载的 Buffer,执行:
5
+ * detectWeComMediaType → applyFileSizeLimits → wsClient.uploadMedia → wsClient.sendMediaMessage
6
+ *
7
+ * 不含 resolveMediaFile(由 deliver callback 提供 Buffer)。
8
+ */
9
+
10
+ import type { WeComMediaType, WSClient } from "@wecom/aibot-node-sdk";
11
+ import {
12
+ IMAGE_MAX_BYTES,
13
+ VIDEO_MAX_BYTES,
14
+ VOICE_MAX_BYTES,
15
+ ABSOLUTE_MAX_BYTES,
16
+ VOICE_SUPPORTED_MIMES,
17
+ } from "./const.js";
18
+
19
+ // ============================================================================
20
+ // 类型定义
21
+ // ============================================================================
22
+
23
+ /** 文件大小检查结果 */
24
+ export interface FileSizeCheckResult {
25
+ /** 最终确定的企微媒体类型(可能被降级) */
26
+ finalType: WeComMediaType;
27
+ /** 是否需要拒绝(超过绝对限制) */
28
+ shouldReject: boolean;
29
+ /** 拒绝原因(仅 shouldReject=true 时有值) */
30
+ rejectReason?: string;
31
+ /** 是否发生了降级 */
32
+ downgraded: boolean;
33
+ /** 降级说明(仅 downgraded=true 时有值) */
34
+ downgradeNote?: string;
35
+ }
36
+
37
+ /** uploadAndSendMediaBuffer 的参数 */
38
+ export interface UploadAndSendMediaBufferOptions {
39
+ /** WSClient 实例 */
40
+ wsClient: WSClient;
41
+ /** 文件数据(deliver callback 已读取) */
42
+ buffer: Buffer;
43
+ /** MIME 类型(deliver callback 已检测) */
44
+ contentType: string;
45
+ /** 文件名(deliver callback 已提取) */
46
+ fileName: string;
47
+ /** 目标会话 ID(单聊为 userid,群聊为 chatid) */
48
+ chatId: string;
49
+ /** 日志函数 */
50
+ log?: (msg: string) => void;
51
+ /** 错误日志函数 */
52
+ errorLog?: (msg: string) => void;
53
+ }
54
+
55
+ /** uploadAndSendMediaBuffer 的返回结果 */
56
+ export interface UploadAndSendMediaResult {
57
+ /** 是否发送成功 */
58
+ ok: boolean;
59
+ /** 最终的企微媒体类型 */
60
+ finalType?: WeComMediaType;
61
+ /** 是否被拒绝(文件过大) */
62
+ rejected?: boolean;
63
+ /** 拒绝原因 */
64
+ rejectReason?: string;
65
+ /** 是否发生了降级 */
66
+ downgraded?: boolean;
67
+ /** 降级说明 */
68
+ downgradeNote?: string;
69
+ /** 错误信息 */
70
+ error?: string;
71
+ }
72
+
73
+ // ============================================================================
74
+ // MIME → 企微媒体类型映射
75
+ // ============================================================================
76
+
77
+ /**
78
+ * 根据 MIME 类型检测企微媒体类型
79
+ */
80
+ export function detectWeComMediaType(mimeType: string): WeComMediaType {
81
+ const mime = mimeType.toLowerCase();
82
+
83
+ if (mime.startsWith("image/")) return "image";
84
+ if (mime.startsWith("video/")) return "video";
85
+ if (mime.startsWith("audio/") || mime === "application/ogg") return "voice";
86
+
87
+ return "file";
88
+ }
89
+
90
+ // ============================================================================
91
+ // 文件大小检查与降级
92
+ // ============================================================================
93
+
94
+ /**
95
+ * 检查文件大小并执行降级策略
96
+ *
97
+ * 降级规则:
98
+ * - voice 非 AMR 格式 → 降级为 file
99
+ * - image 超过 10MB → 降级为 file
100
+ * - video 超过 10MB → 降级为 file
101
+ * - voice 超过 2MB → 降级为 file
102
+ * - file 超过 20MB → 拒绝发送
103
+ */
104
+ export function applyFileSizeLimits(
105
+ fileSize: number,
106
+ detectedType: WeComMediaType,
107
+ contentType?: string,
108
+ ): FileSizeCheckResult {
109
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
110
+
111
+ // 绝对上限 20MB
112
+ if (fileSize > ABSOLUTE_MAX_BYTES) {
113
+ return {
114
+ finalType: detectedType,
115
+ shouldReject: true,
116
+ rejectReason: `文件大小 ${fileSizeMB}MB 超过企业微信最大限制 20MB,无法发送`,
117
+ downgraded: false,
118
+ };
119
+ }
120
+
121
+ switch (detectedType) {
122
+ case "image":
123
+ if (fileSize > IMAGE_MAX_BYTES) {
124
+ return {
125
+ finalType: "file",
126
+ shouldReject: false,
127
+ downgraded: true,
128
+ downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
129
+ };
130
+ }
131
+ break;
132
+
133
+ case "video":
134
+ if (fileSize > VIDEO_MAX_BYTES) {
135
+ return {
136
+ finalType: "file",
137
+ shouldReject: false,
138
+ downgraded: true,
139
+ downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
140
+ };
141
+ }
142
+ break;
143
+
144
+ case "voice":
145
+ // 企微语音仅支持 AMR 格式
146
+ if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
147
+ return {
148
+ finalType: "file",
149
+ shouldReject: false,
150
+ downgraded: true,
151
+ downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
152
+ };
153
+ }
154
+ if (fileSize > VOICE_MAX_BYTES) {
155
+ return {
156
+ finalType: "file",
157
+ shouldReject: false,
158
+ downgraded: true,
159
+ downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
160
+ };
161
+ }
162
+ break;
163
+
164
+ case "file":
165
+ break;
166
+ }
167
+
168
+ return {
169
+ finalType: detectedType,
170
+ shouldReject: false,
171
+ downgraded: false,
172
+ };
173
+ }
174
+
175
+ // ============================================================================
176
+ // 核心:接收 Buffer → 上传 → 发送
177
+ // ============================================================================
178
+
179
+ /**
180
+ * 接收已有 Buffer,通过 WSClient 上传临时素材并发送媒体消息
181
+ *
182
+ * 流程:detectWeComMediaType → applyFileSizeLimits → uploadMedia → sendMediaMessage
183
+ */
184
+ export async function uploadAndSendMediaBuffer(
185
+ options: UploadAndSendMediaBufferOptions,
186
+ ): Promise<UploadAndSendMediaResult> {
187
+ const { wsClient, buffer, contentType, fileName, chatId, log, errorLog } = options;
188
+
189
+ try {
190
+ // 1. 检测企微媒体类型
191
+ const detectedType = detectWeComMediaType(contentType);
192
+ log?.(`media-upload: type=${detectedType} contentType=${contentType} size=${buffer.length} fileName=${fileName}`);
193
+
194
+ // 2. 文件大小检查与降级
195
+ const sizeCheck = applyFileSizeLimits(buffer.length, detectedType, contentType);
196
+
197
+ if (sizeCheck.shouldReject) {
198
+ errorLog?.(`media-upload: rejected — ${sizeCheck.rejectReason}`);
199
+ return {
200
+ ok: false,
201
+ rejected: true,
202
+ rejectReason: sizeCheck.rejectReason,
203
+ finalType: sizeCheck.finalType,
204
+ };
205
+ }
206
+
207
+ const finalType = sizeCheck.finalType;
208
+
209
+ if (sizeCheck.downgraded) {
210
+ log?.(`media-upload: downgraded ${detectedType}→${finalType} — ${sizeCheck.downgradeNote}`);
211
+ }
212
+
213
+ // 3. 分片上传获取 media_id
214
+ log?.(`media-upload: uploading ${finalType} (${buffer.length} bytes)...`);
215
+ const uploadResult = await wsClient.uploadMedia(buffer, {
216
+ type: finalType,
217
+ filename: fileName,
218
+ });
219
+ log?.(`media-upload: uploaded media_id=${uploadResult.media_id}`);
220
+
221
+ // 4. 通过 sendMediaMessage 主动发送
222
+ const videoOptions = finalType === "video" ? { title: fileName } : undefined;
223
+ await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id, videoOptions);
224
+ log?.(`media-upload: sent to chatId=${chatId} type=${finalType}`);
225
+
226
+ return {
227
+ ok: true,
228
+ finalType,
229
+ downgraded: sizeCheck.downgraded,
230
+ downgradeNote: sizeCheck.downgradeNote,
231
+ };
232
+ } catch (err) {
233
+ const errMsg = String(err);
234
+ errorLog?.(`media-upload: failed — ${errMsg}`);
235
+ return {
236
+ ok: false,
237
+ error: errMsg,
238
+ };
239
+ }
240
+ }
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;
@@ -1252,6 +1293,61 @@ async function startAgentForStream(params: {
1252
1293
 
1253
1294
  // 1) 图片:优先 Bot 群内/原会话交付(被动/流式 msg_item)
1254
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 路径
1255
1351
  const loaded: Array<{ base64: string; md5: string; path: string }> = [];
1256
1352
  for (const p of imagePaths) {
1257
1353
  try {
@@ -1373,11 +1469,54 @@ async function startAgentForStream(params: {
1373
1469
  // 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
1374
1470
  if (otherPaths.length > 0) {
1375
1471
  if (isWsMode) {
1376
- // WS 模式:不走 Agent 私信兜底,提示文件路径
1377
- const filename = otherPaths.length === 1 ? otherPaths[0]!.split("/").pop()! : `${otherPaths.length} 个文件`;
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
+
1378
1517
  streamStore.updateStream(streamId, (s) => {
1379
1518
  s.finished = true;
1380
- s.content = `已生成文件(${filename}),文件路径:${otherPaths.join(", ")}`;
1519
+ s.content = summary;
1381
1520
  });
1382
1521
  streamStore.onStreamFinished(streamId);
1383
1522
  return;
@@ -1469,6 +1608,34 @@ async function startAgentForStream(params: {
1469
1608
  }
1470
1609
  }
1471
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
+
1472
1639
  const route = core.channel.routing.resolveAgentRoute({
1473
1640
  cfg: config,
1474
1641
  channel: "wecom",
@@ -1572,12 +1739,22 @@ async function startAgentForStream(params: {
1572
1739
  const isResetCommand = /^\/(new|reset)(?:\s|$)/i.test(rawBodyNormalized);
1573
1740
  const resetCommandKind = isResetCommand ? (rawBodyNormalized.match(/^\/(new|reset)/i)?.[1]?.toLowerCase() ?? "new") : null;
1574
1741
 
1575
- const attachments = mediaPath ? [{
1742
+ const attachments: Array<{ name: string; mimeType?: string; url: string }> | undefined = mediaPath ? [{
1576
1743
  name: media?.filename || "file",
1577
1744
  mimeType: mediaType,
1578
1745
  url: pathToFileURL(mediaPath).href
1579
1746
  }] : undefined;
1580
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
+
1581
1758
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1582
1759
  Body: body,
1583
1760
  RawBody: rawBody,
@@ -1839,7 +2016,35 @@ async function startAgentForStream(params: {
1839
2016
  return;
1840
2017
  }
1841
2018
 
1842
- 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
+ ]));
1843
2048
  for (const mediaPath of mediaUrls) {
1844
2049
  let contentType: string | undefined;
1845
2050
  let filename = mediaPath.split("/").pop() || "attachment";
@@ -1859,11 +2064,50 @@ async function startAgentForStream(params: {
1859
2064
  buf = await fs.readFile(mediaPath);
1860
2065
  filename = pathModule.basename(mediaPath);
1861
2066
  const ext = pathModule.extname(mediaPath).slice(1).toLowerCase();
1862
- const imageExts: Record<string, string> = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
1863
- contentType = imageExts[ext] ?? "application/octet-stream";
2067
+ contentType = MIME_BY_EXT[ext] ?? "application/octet-stream";
1864
2068
  }
1865
2069
 
1866
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 嵌入方式
1867
2111
  const base64 = buf.toString("base64");
1868
2112
  const md5 = crypto.createHash("md5").update(buf).digest("hex");
1869
2113
  current.images.push({ base64, md5 });
@@ -1871,8 +2115,37 @@ async function startAgentForStream(params: {
1871
2115
  } else {
1872
2116
  // Non-image media: Bot 不支持原样发送(尤其群聊)
1873
2117
  if (isWsMode) {
1874
- // WS 模式:不走 Agent 私信兜底,跳过非图片媒体
1875
- logVerbose(target, `media: WS 模式跳过 Agent 私信兜底 filename=${filename} contentType=${contentType ?? "unknown"}`);
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
+ }
1876
2149
  continue;
1877
2150
  }
1878
2151
 
@@ -2015,7 +2288,13 @@ async function startAgentForStream(params: {
2015
2288
 
2016
2289
  streamStore.updateStream(streamId, (s) => {
2017
2290
  if (!s.content.trim() && !(s.images?.length ?? 0)) {
2018
- 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
+ }
2019
2298
  }
2020
2299
  });
2021
2300
 
@@ -2093,6 +2372,7 @@ function formatQuote(quote: WecomInboundQuote): string {
2093
2372
  }
2094
2373
  if (type === "voice") return `[引用: 语音] ${quote.voice?.content || ""}`;
2095
2374
  if (type === "file") return `[引用: 文件] ${quote.file?.url || ""}`;
2375
+ if (type === "video") return `[引用: 视频] ${quote.video?.url || ""}`;
2096
2376
  return "";
2097
2377
  }
2098
2378
 
@@ -2114,6 +2394,7 @@ export function buildInboundBody(msg: WecomInboundMessage): string {
2114
2394
  } else body = "[mixed]";
2115
2395
  } else if (msgtype === "image") body = `[image] ${(msg as any).image?.url || ""}`;
2116
2396
  else if (msgtype === "file") body = `[file] ${(msg as any).file?.url || ""}`;
2397
+ else if (msgtype === "video") body = `[video] ${(msg as any).video?.url || ""}`;
2117
2398
  else if (msgtype === "event") body = `[event] ${(msg as any).event?.eventtype || ""}`;
2118
2399
  else if (msgtype === "stream") body = `[stream_refresh] ${(msg as any).stream?.id || ""}`;
2119
2400
  else body = msgtype ? `[${msgtype}]` : "";