@marshulll/openclaw-wecom 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import crypto from "node:crypto";
2
3
  import { XMLParser } from "fast-xml-parser";
3
4
  import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
@@ -18,6 +19,18 @@ const xmlParser = new XMLParser({
18
19
  });
19
20
 
20
21
  const MAX_REQUEST_BODY_SIZE = 1024 * 1024;
22
+ const MEDIA_CACHE_MAX_ENTRIES = 200;
23
+
24
+ type MediaCacheEntry = {
25
+ path: string;
26
+ type: "image" | "voice" | "video" | "file";
27
+ mimeType?: string;
28
+ url?: string;
29
+ createdAt: number;
30
+ size: number;
31
+ };
32
+
33
+ const mediaCache = new Map<string, MediaCacheEntry>();
21
34
 
22
35
  function parseIncomingXml(xml: string): Record<string, any> {
23
36
  const obj = xmlParser.parse(xml);
@@ -146,6 +159,11 @@ function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
146
159
  return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
147
160
  }
148
161
 
162
+ function resolveMediaRetentionMs(target: WecomWebhookTarget): number | undefined {
163
+ const hours = target.account.config.media?.retentionHours;
164
+ return typeof hours === "number" && hours > 0 ? hours * 3600 * 1000 : undefined;
165
+ }
166
+
149
167
  function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file" | null {
150
168
  if (!raw) return null;
151
169
  const value = raw.toLowerCase();
@@ -164,6 +182,51 @@ function sanitizeFilename(name: string, fallback: string): string {
164
182
  return finalName || fallback;
165
183
  }
166
184
 
185
+ function hashKey(input: string): string {
186
+ return crypto.createHash("sha1").update(input).digest("hex");
187
+ }
188
+
189
+ function buildMediaCacheKey(params: { mediaId?: string; url?: string }): string | null {
190
+ if (params.mediaId) return `media:${params.mediaId}`;
191
+ if (params.url) return `url:${hashKey(params.url)}`;
192
+ return null;
193
+ }
194
+
195
+ function pruneMediaCache(): void {
196
+ if (mediaCache.size <= MEDIA_CACHE_MAX_ENTRIES) return;
197
+ const entries = Array.from(mediaCache.entries())
198
+ .sort((a, b) => a[1].createdAt - b[1].createdAt);
199
+ const excess = entries.length - MEDIA_CACHE_MAX_ENTRIES;
200
+ for (let i = 0; i < excess; i += 1) {
201
+ mediaCache.delete(entries[i]![0]);
202
+ }
203
+ }
204
+
205
+ async function getCachedMedia(
206
+ key: string | null,
207
+ retentionMs?: number,
208
+ ): Promise<MediaCacheEntry | null> {
209
+ if (!key) return null;
210
+ const entry = mediaCache.get(key);
211
+ if (!entry) return null;
212
+ if (retentionMs && Date.now() - entry.createdAt > retentionMs) {
213
+ mediaCache.delete(key);
214
+ return null;
215
+ }
216
+ try {
217
+ await stat(entry.path);
218
+ } catch {
219
+ mediaCache.delete(key);
220
+ return null;
221
+ }
222
+ return entry;
223
+ }
224
+
225
+ function storeCachedMedia(key: string | null, entry: MediaCacheEntry): void {
226
+ if (!key) return;
227
+ mediaCache.set(key, entry);
228
+ pruneMediaCache();
229
+ }
167
230
 
168
231
  async function startAgentForApp(params: {
169
232
  target: WecomWebhookTarget;
@@ -174,6 +237,7 @@ async function startAgentForApp(params: {
174
237
  media?: {
175
238
  type: "image" | "voice" | "video" | "file";
176
239
  path: string;
240
+ mimeType?: string;
177
241
  url?: string;
178
242
  } | null;
179
243
  }): Promise<void> {
@@ -229,6 +293,9 @@ async function startAgentForApp(params: {
229
293
  if (media?.path) {
230
294
  ctxPayload.MediaPath = media.path;
231
295
  ctxPayload.MediaType = media.type;
296
+ if (media.mimeType) {
297
+ (ctxPayload as any).MediaMimeType = media.mimeType;
298
+ }
232
299
  if (media.url) {
233
300
  ctxPayload.MediaUrl = media.url;
234
301
  }
@@ -331,7 +398,8 @@ async function processAppMessage(params: {
331
398
  if (!fromUser) return;
332
399
 
333
400
  let messageText = "";
334
- let mediaContext: { type: "image" | "voice" | "video" | "file"; path: string; url?: string } | null = null;
401
+ const retentionMs = resolveMediaRetentionMs(target);
402
+ let mediaContext: { type: "image" | "voice" | "video" | "file"; path: string; mimeType?: string; url?: string } | null = null;
335
403
 
336
404
  if (msgType === "text") {
337
405
  messageText = String(msgObj?.Content ?? "");
@@ -345,24 +413,40 @@ async function processAppMessage(params: {
345
413
  const mediaId = String(msgObj?.MediaId ?? "");
346
414
  if (mediaId) {
347
415
  try {
348
- const media = await downloadWecomMedia({ account: target.account, mediaId });
349
- const maxBytes = resolveMediaMaxBytes(target);
350
- if (maxBytes && media.buffer.length > maxBytes) {
351
- messageText = "[语音消息过大,未处理]\n\n请发送更短的语音消息。";
416
+ const cacheKey = buildMediaCacheKey({ mediaId });
417
+ const cached = await getCachedMedia(cacheKey, retentionMs);
418
+ if (cached) {
419
+ mediaContext = { type: cached.type, path: cached.path, mimeType: cached.mimeType, url: cached.url };
420
+ logVerbose(target, `app voice cache hit: ${cached.path}`);
421
+ messageText = "[用户发送了一条语音消息]\n\n请根据语音内容回复用户。";
352
422
  } else {
353
- const ext = resolveExtFromContentType(media.contentType, "amr");
354
- const tempDir = resolveMediaTempDir(target);
355
- await mkdir(tempDir, { recursive: true });
356
- await cleanupMediaDir(
357
- tempDir,
358
- target.account.config.media?.retentionHours,
359
- target.account.config.media?.cleanupOnStart,
360
- );
361
- const tempVoicePath = join(tempDir, `voice-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
362
- await writeFile(tempVoicePath, media.buffer);
363
- mediaContext = { type: "voice", path: tempVoicePath };
364
- logVerbose(target, `app voice saved (${media.buffer.length} bytes): ${tempVoicePath}`);
365
- messageText = `[用户发送了一条语音消息]\n\n请根据语音内容回复用户。`;
423
+ const media = await downloadWecomMedia({ account: target.account, mediaId });
424
+ const maxBytes = resolveMediaMaxBytes(target);
425
+ if (maxBytes && media.buffer.length > maxBytes) {
426
+ messageText = "[语音消息过大,未处理]\n\n请发送更短的语音消息。";
427
+ } else {
428
+ const ext = resolveExtFromContentType(media.contentType, "amr");
429
+ const tempDir = resolveMediaTempDir(target);
430
+ await mkdir(tempDir, { recursive: true });
431
+ await cleanupMediaDir(
432
+ tempDir,
433
+ target.account.config.media?.retentionHours,
434
+ target.account.config.media?.cleanupOnStart,
435
+ );
436
+ const tempVoicePath = join(tempDir, `voice-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
437
+ await writeFile(tempVoicePath, media.buffer);
438
+ const mimeType = media.contentType || "audio/amr";
439
+ mediaContext = { type: "voice", path: tempVoicePath, mimeType };
440
+ storeCachedMedia(cacheKey, {
441
+ path: tempVoicePath,
442
+ type: "voice",
443
+ mimeType,
444
+ createdAt: Date.now(),
445
+ size: media.buffer.length,
446
+ });
447
+ logVerbose(target, `app voice saved (${media.buffer.length} bytes): ${tempVoicePath}`);
448
+ messageText = "[用户发送了一条语音消息]\n\n请根据语音内容回复用户。";
449
+ }
366
450
  }
367
451
  } catch (err) {
368
452
  target.runtime.error?.(`wecom app voice download failed: ${String(err)}`);
@@ -378,39 +462,56 @@ async function processAppMessage(params: {
378
462
  const mediaId = String(msgObj?.MediaId ?? "");
379
463
  const picUrl = String(msgObj?.PicUrl ?? "");
380
464
  try {
381
- let buffer: Buffer | null = null;
382
- let contentType = "";
383
- if (mediaId) {
384
- const media = await downloadWecomMedia({ account: target.account, mediaId });
385
- buffer = media.buffer;
386
- contentType = media.contentType;
387
- } else if (picUrl) {
388
- const media = await fetchMediaFromUrl(picUrl, target.account);
389
- buffer = media.buffer;
390
- contentType = media.contentType;
391
- }
465
+ const cacheKey = buildMediaCacheKey({ mediaId, url: picUrl });
466
+ const cached = await getCachedMedia(cacheKey, retentionMs);
467
+ if (cached) {
468
+ mediaContext = { type: cached.type, path: cached.path, mimeType: cached.mimeType, url: cached.url };
469
+ logVerbose(target, `app image cache hit: ${cached.path}`);
470
+ messageText = "[用户发送了一张图片]\n\n请根据图片内容回复用户。";
471
+ } else {
472
+ let buffer: Buffer | null = null;
473
+ let contentType = "";
474
+ if (mediaId) {
475
+ const media = await downloadWecomMedia({ account: target.account, mediaId });
476
+ buffer = media.buffer;
477
+ contentType = media.contentType;
478
+ } else if (picUrl) {
479
+ const media = await fetchMediaFromUrl(picUrl, target.account);
480
+ buffer = media.buffer;
481
+ contentType = media.contentType;
482
+ }
392
483
 
393
- if (buffer) {
394
- const maxBytes = resolveMediaMaxBytes(target);
395
- if (maxBytes && buffer.length > maxBytes) {
396
- messageText = "[图片过大,未处理]\n\n请发送更小的图片。";
484
+ if (buffer) {
485
+ const maxBytes = resolveMediaMaxBytes(target);
486
+ if (maxBytes && buffer.length > maxBytes) {
487
+ messageText = "[图片过大,未处理]\n\n请发送更小的图片。";
488
+ } else {
489
+ const ext = resolveExtFromContentType(contentType, "jpg");
490
+ const tempDir = resolveMediaTempDir(target);
491
+ await mkdir(tempDir, { recursive: true });
492
+ await cleanupMediaDir(
493
+ tempDir,
494
+ target.account.config.media?.retentionHours,
495
+ target.account.config.media?.cleanupOnStart,
496
+ );
497
+ const tempImagePath = join(tempDir, `image-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
498
+ await writeFile(tempImagePath, buffer);
499
+ const mimeType = contentType || "image/jpeg";
500
+ mediaContext = { type: "image", path: tempImagePath, mimeType, url: picUrl || undefined };
501
+ storeCachedMedia(cacheKey, {
502
+ path: tempImagePath,
503
+ type: "image",
504
+ mimeType,
505
+ url: picUrl || undefined,
506
+ createdAt: Date.now(),
507
+ size: buffer.length,
508
+ });
509
+ logVerbose(target, `app image saved (${buffer.length} bytes): ${tempImagePath}`);
510
+ messageText = "[用户发送了一张图片]\n\n请根据图片内容回复用户。";
511
+ }
397
512
  } else {
398
- const ext = resolveExtFromContentType(contentType, "jpg");
399
- const tempDir = resolveMediaTempDir(target);
400
- await mkdir(tempDir, { recursive: true });
401
- await cleanupMediaDir(
402
- tempDir,
403
- target.account.config.media?.retentionHours,
404
- target.account.config.media?.cleanupOnStart,
405
- );
406
- const tempImagePath = join(tempDir, `image-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
407
- await writeFile(tempImagePath, buffer);
408
- mediaContext = { type: "image", path: tempImagePath, url: picUrl || undefined };
409
- logVerbose(target, `app image saved (${buffer.length} bytes): ${tempImagePath}`);
410
- messageText = "[用户发送了一张图片]\n\n请根据图片内容回复用户。";
513
+ messageText = "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
411
514
  }
412
- } else {
413
- messageText = "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
414
515
  }
415
516
  } catch (err) {
416
517
  target.runtime.error?.(`wecom app image download failed: ${String(err)}`);
@@ -429,7 +530,14 @@ async function processAppMessage(params: {
429
530
  const mediaId = String(msgObj?.MediaId ?? "");
430
531
  if (mediaId) {
431
532
  try {
432
- const media = await downloadWecomMedia({ account: target.account, mediaId });
533
+ const cacheKey = buildMediaCacheKey({ mediaId });
534
+ const cached = await getCachedMedia(cacheKey, retentionMs);
535
+ if (cached) {
536
+ mediaContext = { type: cached.type, path: cached.path, mimeType: cached.mimeType, url: cached.url };
537
+ logVerbose(target, `app video cache hit: ${cached.path}`);
538
+ messageText = "[用户发送了一个视频文件]\n\n请根据视频内容回复用户。";
539
+ } else {
540
+ const media = await downloadWecomMedia({ account: target.account, mediaId });
433
541
  const maxBytes = resolveMediaMaxBytes(target);
434
542
  if (maxBytes && media.buffer.length > maxBytes) {
435
543
  messageText = "[视频过大,未处理]\n\n请发送更小的视频。";
@@ -444,10 +552,19 @@ async function processAppMessage(params: {
444
552
  );
445
553
  const tempVideoPath = join(tempDir, `video-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
446
554
  await writeFile(tempVideoPath, media.buffer);
447
- mediaContext = { type: "video", path: tempVideoPath };
555
+ const mimeType = media.contentType || "video/mp4";
556
+ mediaContext = { type: "video", path: tempVideoPath, mimeType };
557
+ storeCachedMedia(cacheKey, {
558
+ path: tempVideoPath,
559
+ type: "video",
560
+ mimeType,
561
+ createdAt: Date.now(),
562
+ size: media.buffer.length,
563
+ });
448
564
  logVerbose(target, `app video saved (${media.buffer.length} bytes): ${tempVideoPath}`);
449
565
  messageText = "[用户发送了一个视频文件]\n\n请根据视频内容回复用户。";
450
566
  }
567
+ }
451
568
  } catch (err) {
452
569
  target.runtime.error?.(`wecom app video download failed: ${String(err)}`);
453
570
  messageText = "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。";
@@ -460,7 +577,14 @@ async function processAppMessage(params: {
460
577
  const fileName = String(msgObj?.FileName ?? "");
461
578
  if (mediaId) {
462
579
  try {
463
- const media = await downloadWecomMedia({ account: target.account, mediaId });
580
+ const cacheKey = buildMediaCacheKey({ mediaId });
581
+ const cached = await getCachedMedia(cacheKey, retentionMs);
582
+ if (cached) {
583
+ mediaContext = { type: cached.type, path: cached.path, mimeType: cached.mimeType, url: cached.url };
584
+ logVerbose(target, `app file cache hit: ${cached.path}`);
585
+ messageText = `[用户发送了一个文件: ${fileName || "未知文件"}]\n\n请根据文件内容回复用户。`;
586
+ } else {
587
+ const media = await downloadWecomMedia({ account: target.account, mediaId });
464
588
  const maxBytes = resolveMediaMaxBytes(target);
465
589
  if (maxBytes && media.buffer.length > maxBytes) {
466
590
  messageText = "[文件过大,未处理]\n\n请发送更小的文件。";
@@ -476,10 +600,19 @@ async function processAppMessage(params: {
476
600
  const safeName = sanitizeFilename(fileName, `file-${Date.now()}.${ext}`);
477
601
  const tempFilePath = join(tempDir, safeName);
478
602
  await writeFile(tempFilePath, media.buffer);
479
- mediaContext = { type: "file", path: tempFilePath };
603
+ const mimeType = media.contentType || "application/octet-stream";
604
+ mediaContext = { type: "file", path: tempFilePath, mimeType };
605
+ storeCachedMedia(cacheKey, {
606
+ path: tempFilePath,
607
+ type: "file",
608
+ mimeType,
609
+ createdAt: Date.now(),
610
+ size: media.buffer.length,
611
+ });
480
612
  logVerbose(target, `app file saved (${media.buffer.length} bytes): ${tempFilePath}`);
481
613
  messageText = `[用户发送了一个文件: ${safeName}]\n\n请根据文件内容回复用户。`;
482
614
  }
615
+ }
483
616
  } catch (err) {
484
617
  target.runtime.error?.(`wecom app file download failed: ${String(err)}`);
485
618
  messageText = "[用户发送了一个文件,但下载失败]\n\n请告诉用户文件处理暂时不可用。";
@@ -17,8 +17,10 @@ const STREAM_MAX_BYTES = 20_480;
17
17
  const STREAM_MAX_ENTRIES = 500;
18
18
  const DEDUPE_TTL_MS = 2 * 60 * 1000;
19
19
  const DEDUPE_MAX_ENTRIES = 2_000;
20
+ const MEDIA_CACHE_MAX_ENTRIES = 200;
20
21
 
21
22
  const cleanupExecuted = new Set<string>();
23
+ const mediaCache = new Map<string, { entry: InboundMedia; createdAt: number; size: number }>();
22
24
 
23
25
  type StreamState = {
24
26
  streamId: string;
@@ -34,6 +36,7 @@ type StreamState = {
34
36
  type InboundMedia = {
35
37
  path: string;
36
38
  type: string;
39
+ mimeType?: string;
37
40
  url?: string;
38
41
  };
39
42
 
@@ -382,6 +385,9 @@ async function startAgentForStream(params: {
382
385
  if (inbound.media) {
383
386
  ctxPayload.MediaPath = inbound.media.path;
384
387
  ctxPayload.MediaType = inbound.media.type;
388
+ if (inbound.media.mimeType) {
389
+ (ctxPayload as any).MediaMimeType = inbound.media.mimeType;
390
+ }
385
391
  if (inbound.media.url) {
386
392
  ctxPayload.MediaUrl = inbound.media.url;
387
393
  }
@@ -584,6 +590,15 @@ async function buildBotMediaMessage(params: {
584
590
  if (!url && !base64) return { text: fallbackLabel };
585
591
 
586
592
  try {
593
+ const cacheKey = buildMediaCacheKey({ url, base64 });
594
+ const cached = await getCachedMedia(cacheKey, resolveMediaRetentionMs(target));
595
+ if (cached) {
596
+ return {
597
+ text: buildInboundMediaPrompt(msgtype, filename),
598
+ media: cached,
599
+ };
600
+ }
601
+
587
602
  let buffer: Buffer | null = null;
588
603
  let contentType = "";
589
604
  if (base64) {
@@ -629,9 +644,16 @@ async function buildBotMediaMessage(params: {
629
644
  const safeName = sanitizeFilename(filename || "", `file-${Date.now()}.${ext}`);
630
645
  const tempFilePath = join(tempDir, safeName);
631
646
  await writeFile(tempFilePath, buffer);
647
+ const media: InboundMedia = {
648
+ path: tempFilePath,
649
+ type: "file",
650
+ mimeType: contentType || "application/octet-stream",
651
+ url,
652
+ };
653
+ storeCachedMedia(cacheKey, media, buffer.length);
632
654
  return {
633
- text: `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请根据文件内容回复用户。`,
634
- media: { path: tempFilePath, type: contentType || "application/octet-stream", url },
655
+ text: buildInboundMediaPrompt("file", safeName),
656
+ media,
635
657
  };
636
658
  }
637
659
 
@@ -642,21 +664,42 @@ async function buildBotMediaMessage(params: {
642
664
  await writeFile(tempPath, buffer);
643
665
 
644
666
  if (msgtype === "image") {
667
+ const media: InboundMedia = {
668
+ path: tempPath,
669
+ type: "image",
670
+ mimeType: contentType || "image/jpeg",
671
+ url,
672
+ };
673
+ storeCachedMedia(cacheKey, media, buffer.length);
645
674
  return {
646
- text: `[用户发送了一张图片,已保存到: ${tempPath}]\n\n请使用 Read 工具查看这张图片并描述内容。`,
647
- media: { path: tempPath, type: contentType || "image/jpeg", url },
675
+ text: buildInboundMediaPrompt("image"),
676
+ media,
648
677
  };
649
678
  }
650
679
  if (msgtype === "voice") {
680
+ const media: InboundMedia = {
681
+ path: tempPath,
682
+ type: "voice",
683
+ mimeType: contentType || "audio/amr",
684
+ url,
685
+ };
686
+ storeCachedMedia(cacheKey, media, buffer.length);
651
687
  return {
652
- text: `[用户发送了一条语音消息,已保存到: ${tempPath}]\n\n请根据语音内容回复用户。`,
653
- media: { path: tempPath, type: contentType || "audio/amr", url },
688
+ text: buildInboundMediaPrompt("voice"),
689
+ media,
654
690
  };
655
691
  }
656
692
  if (msgtype === "video") {
693
+ const media: InboundMedia = {
694
+ path: tempPath,
695
+ type: "video",
696
+ mimeType: contentType || "video/mp4",
697
+ url,
698
+ };
699
+ storeCachedMedia(cacheKey, media, buffer.length);
657
700
  return {
658
- text: `[用户发送了一个视频文件,已保存到: ${tempPath}]\n\n请根据视频内容回复用户。`,
659
- media: { path: tempPath, type: contentType || "video/mp4", url },
701
+ text: buildInboundMediaPrompt("video"),
702
+ media,
660
703
  };
661
704
  }
662
705
  return { text: fallbackLabel };
@@ -741,6 +784,65 @@ function mediaSentLabel(type: string): string {
741
784
  return "[已发送媒体]";
742
785
  }
743
786
 
787
+ function resolveMediaRetentionMs(target: WecomWebhookTarget): number | undefined {
788
+ const hours = target.account.config.media?.retentionHours;
789
+ return typeof hours === "number" && hours > 0 ? hours * 3600 * 1000 : undefined;
790
+ }
791
+
792
+ function hashCacheKey(input: string): string {
793
+ return crypto.createHash("sha1").update(input).digest("hex");
794
+ }
795
+
796
+ function buildMediaCacheKey(params: { url?: string; base64?: string }): string | null {
797
+ if (params.url) return `url:${hashCacheKey(params.url)}`;
798
+ if (params.base64) return `b64:${hashCacheKey(params.base64)}`;
799
+ return null;
800
+ }
801
+
802
+ function pruneMediaCache(): void {
803
+ if (mediaCache.size <= MEDIA_CACHE_MAX_ENTRIES) return;
804
+ const entries = Array.from(mediaCache.entries())
805
+ .sort((a, b) => a[1].createdAt - b[1].createdAt);
806
+ const excess = entries.length - MEDIA_CACHE_MAX_ENTRIES;
807
+ for (let i = 0; i < excess; i += 1) {
808
+ mediaCache.delete(entries[i]![0]);
809
+ }
810
+ }
811
+
812
+ async function getCachedMedia(
813
+ key: string | null,
814
+ retentionMs?: number,
815
+ ): Promise<InboundMedia | null> {
816
+ if (!key) return null;
817
+ const cached = mediaCache.get(key);
818
+ if (!cached) return null;
819
+ if (retentionMs && Date.now() - cached.createdAt > retentionMs) {
820
+ mediaCache.delete(key);
821
+ return null;
822
+ }
823
+ try {
824
+ await stat(cached.entry.path);
825
+ } catch {
826
+ mediaCache.delete(key);
827
+ return null;
828
+ }
829
+ return cached.entry;
830
+ }
831
+
832
+ function storeCachedMedia(key: string | null, entry: InboundMedia, size: number): void {
833
+ if (!key) return;
834
+ mediaCache.set(key, { entry, createdAt: Date.now(), size });
835
+ pruneMediaCache();
836
+ }
837
+
838
+ function buildInboundMediaPrompt(msgtype: "image" | "voice" | "video" | "file", filename?: string): string {
839
+ if (msgtype === "image") return "[用户发送了一张图片]\n\n请根据图片内容回复用户。";
840
+ if (msgtype === "voice") return "[用户发送了一条语音消息]\n\n请根据语音内容回复用户。";
841
+ if (msgtype === "video") return "[用户发送了一个视频文件]\n\n请根据视频内容回复用户。";
842
+ const label = filename ? `用户发送了一个文件: ${filename}` : "用户发送了一个文件";
843
+ return `[${label}]\n\n请根据文件内容回复用户。`;
844
+ }
845
+
744
846
  function shouldHandleBot(account: ResolvedWecomAccount): boolean {
745
847
  return account.mode === "bot" || account.mode === "both";
746
848
  }