@marshulll/openclaw-wecom 0.1.27 → 0.1.28

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.en.md CHANGED
@@ -96,7 +96,7 @@ Install guide: `docs/INSTALL.md`
96
96
  - Example: `/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
97
97
  - Natural language also works: "send me this file image-xxx.jpg" (default match in `media.tempDir`)
98
98
  - Search scope keywords: `桌面` → `~/Desktop`, `下载` → `~/Downloads`, `临时` → `media.tempDir`
99
- - If multiple matches are found, a list will be returned for confirmation
99
+ - If multiple matches are found, a list will be returned for confirmation; reply "more" to paginate
100
100
 
101
101
  ## Proactive send (App mode)
102
102
  Push endpoint path: `{webhookPath}/push` (e.g. `/wecom/app/push`).
@@ -137,5 +137,6 @@ Media (file/image/voice/video): use `mediaUrl` or `mediaBase64`. You can also se
137
137
  - Dev doc: `docs/TECHNICAL.md`
138
138
  - Install: `docs/INSTALL.md`
139
139
  - Examples: `docs/wecom.config.example.json` / `docs/wecom.config.full.example.json`
140
+ - Testing: `docs/TESTING.md`
140
141
 
141
142
  Recommendation: use **separate webhookPath** for Bot and App (e.g. `/wecom/bot` and `/wecom/app`) for clearer debugging and fewer callback mix-ups.
package/README.md CHANGED
@@ -98,7 +98,7 @@ openclaw gateway restart
98
98
  - 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
99
99
  - 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
100
100
  - 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
101
- - 多文件会先返回列表,回复“全部”或序号再发送
101
+ - 多文件会先返回列表,回复“全部”或序号再发送;回复“更多”可翻页
102
102
 
103
103
  ## 主动消息(App 模式)
104
104
  主动推送接口路径为:`{webhookPath}/push`(例如 `/wecom/app/push`)。
@@ -139,3 +139,4 @@ curl -X POST "https://你的域名/wecom/app/push" \
139
139
  - 开发文档:`docs/TECHNICAL.md`
140
140
  - 安装配置:`docs/INSTALL.md`
141
141
  - 配置示例:`docs/wecom.config.example.json` / `docs/wecom.config.full.example.json`
142
+ - 测试清单:`docs/TESTING.md`
package/README.zh.md CHANGED
@@ -98,7 +98,7 @@ openclaw gateway restart
98
98
  - 示例:`/sendfile /tmp/openclaw-wecom /home/shu/Desktop/report.pdf`
99
99
  - 也支持自然语言:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
100
100
  - 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
101
- - 多文件会先返回列表,回复“全部”或序号再发送
101
+ - 多文件会先返回列表,回复“全部”或序号再发送;回复“更多”可翻页
102
102
 
103
103
  ## 主动消息(App 模式)
104
104
  主动推送接口路径为:`{webhookPath}/push`(例如 `/wecom/app/push`)。
@@ -139,3 +139,4 @@ curl -X POST "https://你的域名/wecom/app/push" \
139
139
  - 开发文档:`docs/TECHNICAL.md`
140
140
  - 安装配置:`docs/INSTALL.md`
141
141
  - 配置示例:`docs/wecom.config.example.json` / `docs/wecom.config.full.example.json`
142
+ - 测试清单:`docs/TESTING.md`
package/docs/INSTALL.md CHANGED
@@ -134,7 +134,7 @@ openclaw gateway restart
134
134
  - 目录会自动打包为 zip 再发送
135
135
  - 自然语言也可触发:`把这个文件发给我 image-xxx.jpg`(默认仅在 `media.tempDir` 内匹配)
136
136
  - 搜索范围关键词:`桌面` → `~/Desktop`,`下载` → `~/Downloads`,`临时` → `media.tempDir`
137
- - 如匹配多个文件,会返回列表让你确认(回复“全部”或序号)
137
+ - 如匹配多个文件,会返回列表让你确认(回复“全部”或序号;回复“更多”翻页)
138
138
 
139
139
  示例:
140
140
  ```
@@ -0,0 +1,30 @@
1
+ # 测试清单(手动)
2
+
3
+ > 用于快速回归核对,不要求自动化。
4
+
5
+ ## Bot 模式(/wecom/bot)
6
+ - 文本:收到文本后能正常回复
7
+ - 图片:能识别/描述(如开启视觉)或至少回复“已收到图片”
8
+ - 语音:如有识别文本则直接处理,否则走语音文件
9
+ - 视频:能返回视频概述(启用 `media.auto.video` 时)
10
+ - 文件:提示已保存路径并可用 Read 工具读取
11
+
12
+ ## App 模式(/wecom/app)
13
+ - 文本:正常回复
14
+ - 图片/语音/视频/文件:同上,确认保存路径与识别结果
15
+ - 群聊:`chatId` 有效,能在群聊回复
16
+
17
+ ## /sendfile(App 模式)
18
+ - 单文件绝对路径发送
19
+ - 多文件路径发送(含空格路径)
20
+ - 目录自动打包 zip
21
+ - 自然语言:唯一命中直接发送,多命中返回列表,回复“全部/序号”生效
22
+ - 多页列表:回复“更多”可翻页
23
+
24
+ ## 主动推送(App 模式)
25
+ - `/wecom/app/push` 文本
26
+ - `/wecom/app/push` 媒体(file/image/voice/video)
27
+
28
+ ## 大文件与异常
29
+ - 超过 `media.maxBytes` 的媒体会提示“过大”
30
+ - 未配置 App 凭据时,Bot 媒体提示更明确
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -56,6 +56,7 @@
56
56
  "files": [
57
57
  "wecom/**",
58
58
  "docs/INSTALL.md",
59
+ "docs/TESTING.md",
59
60
  "docs/wecom.config.example.json",
60
61
  "docs/wecom.config.full.example.json",
61
62
  "openclaw.plugin.json",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshulll/openclaw-wecom",
3
- "version": "0.1.5",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom channel plugin (intelligent bot + internal app)",
6
6
  "author": "OpenClaw",
@@ -41,5 +41,5 @@
41
41
  "channel",
42
42
  "plugin"
43
43
  ],
44
- "license": "ISC"
44
+ "license": "MIT"
45
45
  }
@@ -0,0 +1,73 @@
1
+ import { readdir, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import type { WecomWebhookTarget } from "./monitor.js";
6
+
7
+ const cleanupExecuted = new Set<string>();
8
+
9
+ export function resolveExtFromContentType(contentType: string, fallback: string): string {
10
+ if (!contentType) return fallback;
11
+ if (contentType.includes("png")) return "png";
12
+ if (contentType.includes("gif")) return "gif";
13
+ if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
14
+ if (contentType.includes("mp4")) return "mp4";
15
+ if (contentType.includes("amr")) return "amr";
16
+ if (contentType.includes("wav")) return "wav";
17
+ if (contentType.includes("mp3")) return "mp3";
18
+ return fallback;
19
+ }
20
+
21
+ export async function cleanupMediaDir(
22
+ dir: string,
23
+ retentionHours?: number,
24
+ cleanupOnStart?: boolean,
25
+ ): Promise<void> {
26
+ if (cleanupOnStart === false) return;
27
+ if (!retentionHours || retentionHours <= 0) return;
28
+ if (cleanupExecuted.has(dir)) return;
29
+ cleanupExecuted.add(dir);
30
+ const cutoff = Date.now() - retentionHours * 3600 * 1000;
31
+ try {
32
+ const entries = await readdir(dir);
33
+ await Promise.all(entries.map(async (entry) => {
34
+ const full = join(dir, entry);
35
+ try {
36
+ const info = await stat(full);
37
+ if (info.isFile() && info.mtimeMs < cutoff) {
38
+ await rm(full, { force: true });
39
+ }
40
+ } catch {
41
+ // ignore
42
+ }
43
+ }));
44
+ } catch {
45
+ // ignore
46
+ }
47
+ }
48
+
49
+ export function resolveMediaTempDir(target: WecomWebhookTarget): string {
50
+ return target.account.config.media?.tempDir?.trim()
51
+ || join(tmpdir(), "openclaw-wecom");
52
+ }
53
+
54
+ export function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
55
+ const maxBytes = target.account.config.media?.maxBytes;
56
+ return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
57
+ }
58
+
59
+ export function resolveMediaRetentionMs(target: WecomWebhookTarget): number | undefined {
60
+ const hours = target.account.config.media?.retentionHours;
61
+ return typeof hours === "number" && hours > 0 ? hours * 3600 * 1000 : undefined;
62
+ }
63
+
64
+ export function sanitizeFilename(name: string, fallback: string): string {
65
+ const base = name.split(/[/\\\\]/).pop() ?? "";
66
+ const trimmed = base.trim();
67
+ const safe = trimmed
68
+ .replace(/[^\w.\-() ]+/g, "_")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ const finalName = safe.slice(0, 120);
72
+ return finalName || fallback;
73
+ }
@@ -61,6 +61,7 @@ class RateLimiter {
61
61
 
62
62
  const accessTokenCaches = new Map<string, WecomTokenState>();
63
63
  const apiLimiter = new RateLimiter({ maxConcurrent: 3, minInterval: 200 });
64
+ export const MEDIA_TOO_LARGE_ERROR = "MEDIA_TOO_LARGE";
64
65
 
65
66
  function ensureAppConfig(account: ResolvedWecomAccount): { corpId: string; corpSecret: string; agentId: number } {
66
67
  const corpId = account.corpId ?? "";
@@ -106,6 +107,23 @@ async function fetchWithRetry(account: ResolvedWecomAccount, input: RequestInfo
106
107
  throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
107
108
  }
108
109
 
110
+ function resolveContentLength(res: Response): number | null {
111
+ const raw = res.headers.get("content-length");
112
+ if (!raw) return null;
113
+ const value = Number(raw);
114
+ return Number.isFinite(value) && value > 0 ? value : null;
115
+ }
116
+
117
+ function ensureNotTooLarge(res: Response, maxBytes?: number): void {
118
+ if (!maxBytes || maxBytes <= 0) return;
119
+ const length = resolveContentLength(res);
120
+ if (length && length > maxBytes) {
121
+ const err = new Error(`${MEDIA_TOO_LARGE_ERROR}: content-length ${length} > limit ${maxBytes}`);
122
+ (err as { code?: string }).code = MEDIA_TOO_LARGE_ERROR;
123
+ throw err;
124
+ }
125
+ }
126
+
109
127
  export async function getWecomAccessToken(account: ResolvedWecomAccount): Promise<string> {
110
128
  const { corpId, corpSecret } = ensureAppConfig(account);
111
129
  const cacheKey = corpId;
@@ -335,8 +353,9 @@ export async function sendWecomFile(params: {
335
353
  export async function downloadWecomMedia(params: {
336
354
  account: ResolvedWecomAccount;
337
355
  mediaId: string;
356
+ maxBytes?: number;
338
357
  }): Promise<{ buffer: Buffer; contentType: string } > {
339
- const { account, mediaId } = params;
358
+ const { account, mediaId, maxBytes } = params;
340
359
  const accessToken = await getWecomAccessToken(account);
341
360
  const mediaUrl = `https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=${encodeURIComponent(accessToken)}&media_id=${encodeURIComponent(mediaId)}`;
342
361
 
@@ -346,6 +365,7 @@ export async function downloadWecomMedia(params: {
346
365
  }
347
366
 
348
367
  const contentType = res.headers.get("content-type") || "";
368
+ ensureNotTooLarge(res, maxBytes);
349
369
  if (contentType.includes("application/json")) {
350
370
  const json = await res.json();
351
371
  throw new Error(`WeCom media download failed: ${JSON.stringify(json)}`);
@@ -355,11 +375,16 @@ export async function downloadWecomMedia(params: {
355
375
  return { buffer, contentType };
356
376
  }
357
377
 
358
- export async function fetchMediaFromUrl(url: string, account?: ResolvedWecomAccount): Promise<{ buffer: Buffer; contentType: string } > {
378
+ export async function fetchMediaFromUrl(
379
+ url: string,
380
+ account?: ResolvedWecomAccount,
381
+ maxBytes?: number,
382
+ ): Promise<{ buffer: Buffer; contentType: string } > {
359
383
  const res = account ? await fetchWithRetry(account, url) : await fetch(url);
360
384
  if (!res.ok) {
361
385
  throw new Error(`Failed to fetch media from URL: ${res.status}`);
362
386
  }
387
+ ensureNotTooLarge(res, maxBytes);
363
388
  const buffer = Buffer.from(await res.arrayBuffer());
364
389
  const contentType = res.headers.get("content-type") || "application/octet-stream";
365
390
  return { buffer, contentType };
@@ -1,8 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import crypto from "node:crypto";
3
3
  import { XMLParser } from "fast-xml-parser";
4
- import { appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
- import { homedir, tmpdir } from "node:os";
4
+ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
6
  import { basename, dirname, extname, join } from "node:path";
7
7
 
8
8
  import type { WecomWebhookTarget } from "./monitor.js";
@@ -19,7 +19,25 @@ import {
19
19
  transcribeAudioWithOpenAI,
20
20
  } from "./media-auto.js";
21
21
  import { describeImageWithVision, resolveVisionConfig } from "./media-vision.js";
22
- import { downloadWecomMedia, fetchMediaFromUrl, sendWecomFile, sendWecomImage, sendWecomText, sendWecomVideo, sendWecomVoice, uploadWecomMedia } from "./wecom-api.js";
22
+ import {
23
+ MEDIA_TOO_LARGE_ERROR,
24
+ downloadWecomMedia,
25
+ fetchMediaFromUrl,
26
+ sendWecomFile,
27
+ sendWecomImage,
28
+ sendWecomText,
29
+ sendWecomVideo,
30
+ sendWecomVoice,
31
+ uploadWecomMedia,
32
+ } from "./wecom-api.js";
33
+ import {
34
+ cleanupMediaDir,
35
+ resolveExtFromContentType,
36
+ resolveMediaMaxBytes,
37
+ resolveMediaRetentionMs,
38
+ resolveMediaTempDir,
39
+ sanitizeFilename,
40
+ } from "./media-utils.js";
23
41
 
24
42
  const xmlParser = new XMLParser({
25
43
  ignoreAttributes: false,
@@ -138,6 +156,7 @@ function resolveSendIntervalMs(target: WecomWebhookTarget): number {
138
156
  type PendingSendList = {
139
157
  items: { name: string; path: string }[];
140
158
  dirLabel: string;
159
+ offset: number;
141
160
  createdAt: number;
142
161
  expiresAt: number;
143
162
  };
@@ -145,6 +164,7 @@ type PendingSendList = {
145
164
  const pendingSendLists = new Map<string, PendingSendList>();
146
165
  const PENDING_TTL_MS = 10 * 60 * 1000;
147
166
  const MAX_LIST_PREVIEW = 30;
167
+ const LIST_MORE_PATTERN = /(更多|下一页|下页|继续|下一批|more|next)/i;
148
168
 
149
169
  function pendingKey(fromUser: string, chatId?: string): string {
150
170
  return chatId ? `${fromUser}::${chatId}` : fromUser;
@@ -214,6 +234,21 @@ function parseSelection(text: string, items: { name: string; path: string }[]):
214
234
  return picked.length > 0 ? picked : null;
215
235
  }
216
236
 
237
+ function buildPendingListText(pending: PendingSendList): { text: string; hasMore: boolean } {
238
+ const start = Math.max(0, pending.offset);
239
+ const total = pending.items.length;
240
+ const slice = pending.items.slice(start, start + MAX_LIST_PREVIEW);
241
+ const preview = slice
242
+ .map((item, idx) => `${start + idx + 1}. ${item.name}`)
243
+ .join("\n");
244
+ const hasMore = start + MAX_LIST_PREVIEW < total;
245
+ const tail = hasMore
246
+ ? `\n…共 ${total} 个文件,回复“更多”查看下一页。`
247
+ : `\n共 ${total} 个文件。`;
248
+ const text = `在${pending.dirLabel}找到 ${total} 个文件:\n${preview}${tail}\n\n回复“全部”或“1 3 5”或直接发送具体文件名。`;
249
+ return { text, hasMore };
250
+ }
251
+
217
252
  async function tryHandleNaturalFileSend(params: {
218
253
  target: WecomWebhookTarget;
219
254
  text: string;
@@ -227,6 +262,27 @@ async function tryHandleNaturalFileSend(params: {
227
262
  const key = pendingKey(fromUser, chatId);
228
263
  const pending = pendingSendLists.get(key);
229
264
  if (pending) {
265
+ if (LIST_MORE_PATTERN.test(text)) {
266
+ const nextOffset = pending.offset + MAX_LIST_PREVIEW;
267
+ if (nextOffset >= pending.items.length) {
268
+ await sendWecomText({
269
+ account: target.account,
270
+ toUser: fromUser,
271
+ chatId: isGroup ? chatId : undefined,
272
+ text: "已经是最后一页了。",
273
+ });
274
+ return true;
275
+ }
276
+ pending.offset = nextOffset;
277
+ const { text: listText } = buildPendingListText(pending);
278
+ await sendWecomText({
279
+ account: target.account,
280
+ toUser: fromUser,
281
+ chatId: isGroup ? chatId : undefined,
282
+ text: listText,
283
+ });
284
+ return true;
285
+ }
230
286
  const selection = parseSelection(text, pending.items);
231
287
  if (selection) {
232
288
  pendingSendLists.delete(key);
@@ -305,21 +361,19 @@ async function tryHandleNaturalFileSend(params: {
305
361
  return true;
306
362
  }
307
363
 
308
- const preview = resolved.slice(0, MAX_LIST_PREVIEW)
309
- .map((item, idx) => `${idx + 1}. ${item.name}`)
310
- .join("\n");
311
- const tail = resolved.length > MAX_LIST_PREVIEW ? `\n…共 ${resolved.length} 个文件` : "";
312
364
  pendingSendLists.set(key, {
313
365
  items: resolved,
314
366
  dirLabel: searchDir.label,
367
+ offset: 0,
315
368
  createdAt: Date.now(),
316
369
  expiresAt: Date.now() + PENDING_TTL_MS,
317
370
  });
371
+ const { text: listText } = buildPendingListText(pendingSendLists.get(key)!);
318
372
  await sendWecomText({
319
373
  account: target.account,
320
374
  toUser: fromUser,
321
375
  chatId: isGroup ? chatId : undefined,
322
- text: `在${searchDir.label}找到 ${resolved.length} 个文件:\n${preview}${tail}\n\n回复“全部”或“1 3 5”或直接发送具体文件名。`,
376
+ text: listText,
323
377
  });
324
378
  return true;
325
379
  }
@@ -406,62 +460,6 @@ function isTextCommand(text: string): boolean {
406
460
  return text.trim().startsWith("/");
407
461
  }
408
462
 
409
- function resolveExtFromContentType(contentType: string, fallback: string): string {
410
- if (!contentType) return fallback;
411
- if (contentType.includes("png")) return "png";
412
- if (contentType.includes("gif")) return "gif";
413
- if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
414
- if (contentType.includes("mp4")) return "mp4";
415
- if (contentType.includes("amr")) return "amr";
416
- if (contentType.includes("wav")) return "wav";
417
- if (contentType.includes("mp3")) return "mp3";
418
- return fallback;
419
- }
420
-
421
- const cleanupExecuted = new Set<string>();
422
-
423
- async function cleanupMediaDir(
424
- dir: string,
425
- retentionHours?: number,
426
- cleanupOnStart?: boolean,
427
- ): Promise<void> {
428
- if (cleanupOnStart === false) return;
429
- if (!retentionHours || retentionHours <= 0) return;
430
- if (cleanupExecuted.has(dir)) return;
431
- cleanupExecuted.add(dir);
432
- const cutoff = Date.now() - retentionHours * 3600 * 1000;
433
- try {
434
- const entries = await readdir(dir);
435
- await Promise.all(entries.map(async (entry) => {
436
- const full = join(dir, entry);
437
- try {
438
- const info = await stat(full);
439
- if (info.isFile() && info.mtimeMs < cutoff) {
440
- await rm(full, { force: true });
441
- }
442
- } catch {
443
- // ignore
444
- }
445
- }));
446
- } catch {
447
- // ignore
448
- }
449
- }
450
-
451
- function resolveMediaTempDir(target: WecomWebhookTarget): string {
452
- return target.account.config.media?.tempDir?.trim()
453
- || join(tmpdir(), "openclaw-wecom");
454
- }
455
-
456
- function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
457
- const maxBytes = target.account.config.media?.maxBytes;
458
- return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
459
- }
460
-
461
- function resolveMediaRetentionMs(target: WecomWebhookTarget): number | undefined {
462
- const hours = target.account.config.media?.retentionHours;
463
- return typeof hours === "number" && hours > 0 ? hours * 3600 * 1000 : undefined;
464
- }
465
463
 
466
464
  function normalizeMediaType(raw?: string): "image" | "voice" | "video" | "file" | null {
467
465
  if (!raw) return null;
@@ -477,6 +475,18 @@ function pickString(...values: unknown[]): string {
477
475
  return "";
478
476
  }
479
477
 
478
+ function isMediaTooLargeError(err: unknown): boolean {
479
+ if (!err) return false;
480
+ if (typeof err === "string") return err.includes(MEDIA_TOO_LARGE_ERROR);
481
+ if (typeof err === "object") {
482
+ const anyErr = err as { code?: string; message?: string };
483
+ if (anyErr.code === MEDIA_TOO_LARGE_ERROR) return true;
484
+ if (typeof anyErr.message === "string" && anyErr.message.includes(MEDIA_TOO_LARGE_ERROR)) return true;
485
+ if (typeof anyErr.message === "string" && anyErr.message.toLowerCase().includes("media too large")) return true;
486
+ }
487
+ return false;
488
+ }
489
+
480
490
  function resolveContentTypeFromExt(ext: string): string {
481
491
  const value = ext.toLowerCase();
482
492
  if (value === "png") return "image/png";
@@ -588,7 +598,7 @@ async function loadOutboundMedia(params: {
588
598
  contentType = resolveContentTypeFromExt(ext);
589
599
  }
590
600
  } else if (spec.url) {
591
- const media = await fetchMediaFromUrl(spec.url, params.account);
601
+ const media = await fetchMediaFromUrl(spec.url, params.account, params.maxBytes);
592
602
  buffer = media.buffer;
593
603
  if (!contentType) contentType = media.contentType;
594
604
  }
@@ -603,17 +613,6 @@ async function loadOutboundMedia(params: {
603
613
  return { buffer, contentType: contentType || resolveContentTypeFromExt(ext), type, filename: safeName };
604
614
  }
605
615
 
606
- function sanitizeFilename(name: string, fallback: string): string {
607
- const base = name.split(/[/\\\\]/).pop() ?? "";
608
- const trimmed = base.trim();
609
- const safe = trimmed
610
- .replace(/[^\w.\-() ]+/g, "_")
611
- .replace(/\s+/g, " ")
612
- .trim();
613
- const finalName = safe.slice(0, 120);
614
- return finalName || fallback;
615
- }
616
-
617
616
  function hashKey(input: string): string {
618
617
  return crypto.createHash("sha1").update(input).digest("hex");
619
618
  }
@@ -849,8 +848,8 @@ async function processAppMessage(params: {
849
848
  logVerbose(target, `app voice cache hit: ${cached.path}`);
850
849
  messageText = "[用户发送了一条语音消息]\n\n请根据语音内容回复用户。";
851
850
  } else {
852
- const media = await downloadWecomMedia({ account: target.account, mediaId });
853
851
  const maxBytes = resolveMediaMaxBytes(target);
852
+ const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
854
853
  if (maxBytes && media.buffer.length > maxBytes) {
855
854
  messageText = "[语音消息过大,未处理]\n\n请发送更短的语音消息。";
856
855
  } else {
@@ -885,7 +884,9 @@ async function processAppMessage(params: {
885
884
  }
886
885
  } catch (err) {
887
886
  target.runtime.error?.(`wecom app voice download failed: ${String(err)}`);
888
- messageText = "[用户发送了一条语音消息,但下载失败]\n\n请告诉用户语音处理暂时不可用。";
887
+ messageText = isMediaTooLargeError(err)
888
+ ? "[语音消息过大,未处理]\n\n请发送更短的语音消息。"
889
+ : "[用户发送了一条语音消息,但下载失败]\n\n请告诉用户语音处理暂时不可用。";
889
890
  }
890
891
  } else {
891
892
  messageText = "[用户发送了一条语音消息]\n\n请告诉用户语音处理暂时不可用。";
@@ -896,6 +897,7 @@ async function processAppMessage(params: {
896
897
  if (msgType === "image") {
897
898
  const mediaId = String(msgObj?.MediaId ?? "");
898
899
  const picUrl = String(msgObj?.PicUrl ?? "");
900
+ const maxBytes = resolveMediaMaxBytes(target);
899
901
  try {
900
902
  const cacheKey = buildMediaCacheKey({ mediaId, url: picUrl });
901
903
  const cached = await getCachedMedia(cacheKey, retentionMs);
@@ -911,11 +913,11 @@ async function processAppMessage(params: {
911
913
  let buffer: Buffer | null = null;
912
914
  let contentType = "";
913
915
  if (mediaId) {
914
- const media = await downloadWecomMedia({ account: target.account, mediaId });
916
+ const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
915
917
  buffer = media.buffer;
916
918
  contentType = media.contentType;
917
919
  } else if (picUrl) {
918
- const media = await fetchMediaFromUrl(picUrl, target.account);
920
+ const media = await fetchMediaFromUrl(picUrl, target.account, maxBytes);
919
921
  buffer = media.buffer;
920
922
  contentType = media.contentType;
921
923
  }
@@ -973,7 +975,9 @@ async function processAppMessage(params: {
973
975
  }
974
976
  } catch (err) {
975
977
  target.runtime.error?.(`wecom app image download failed: ${String(err)}`);
976
- messageText = "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
978
+ messageText = isMediaTooLargeError(err)
979
+ ? "[图片过大,未处理]\n\n请发送更小的图片。"
980
+ : "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。";
977
981
  }
978
982
  }
979
983
 
@@ -995,47 +999,49 @@ async function processAppMessage(params: {
995
999
  logVerbose(target, `app video cache hit: ${cached.path}`);
996
1000
  messageText = "[用户发送了一个视频文件]\n\n请根据视频内容回复用户。";
997
1001
  } else {
998
- const media = await downloadWecomMedia({ account: target.account, mediaId });
999
- const maxBytes = resolveMediaMaxBytes(target);
1000
- if (maxBytes && media.buffer.length > maxBytes) {
1001
- messageText = "[视频过大,未处理]\n\n请发送更小的视频。";
1002
- } else {
1003
- const ext = resolveExtFromContentType(media.contentType, "mp4");
1004
- const tempDir = resolveMediaTempDir(target);
1005
- await mkdir(tempDir, { recursive: true });
1006
- await cleanupMediaDir(
1007
- tempDir,
1008
- target.account.config.media?.retentionHours,
1009
- target.account.config.media?.cleanupOnStart,
1010
- );
1011
- const tempVideoPath = join(tempDir, `video-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
1012
- await writeFile(tempVideoPath, media.buffer);
1013
- const mimeType = media.contentType || "video/mp4";
1014
- mediaContext = { type: "video", path: tempVideoPath, mimeType };
1015
- storeCachedMedia(cacheKey, {
1016
- path: tempVideoPath,
1017
- type: "video",
1018
- mimeType,
1019
- createdAt: Date.now(),
1020
- size: media.buffer.length,
1021
- });
1022
- logVerbose(target, `app video saved (${media.buffer.length} bytes): ${tempVideoPath}`);
1023
- const videoCfg = resolveAutoVideoConfig(target.account.config);
1024
- const summary = videoCfg
1025
- ? await summarizeVideoWithVision({
1026
- cfg: videoCfg,
1027
- account: target.account.config,
1028
- videoPath: tempVideoPath,
1029
- })
1030
- : null;
1031
- messageText = summary
1032
- ? `[用户发送了一个视频文件]\n\n[视频画面概述]\n${summary}\n\n请根据视频内容回复用户。`
1033
- : "[用户发送了一个视频文件]\n\n请根据视频内容回复用户。";
1034
- }
1002
+ const maxBytes = resolveMediaMaxBytes(target);
1003
+ const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
1004
+ if (maxBytes && media.buffer.length > maxBytes) {
1005
+ messageText = "[视频过大,未处理]\n\n请发送更小的视频。";
1006
+ } else {
1007
+ const ext = resolveExtFromContentType(media.contentType, "mp4");
1008
+ const tempDir = resolveMediaTempDir(target);
1009
+ await mkdir(tempDir, { recursive: true });
1010
+ await cleanupMediaDir(
1011
+ tempDir,
1012
+ target.account.config.media?.retentionHours,
1013
+ target.account.config.media?.cleanupOnStart,
1014
+ );
1015
+ const tempVideoPath = join(tempDir, `video-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
1016
+ await writeFile(tempVideoPath, media.buffer);
1017
+ const mimeType = media.contentType || "video/mp4";
1018
+ mediaContext = { type: "video", path: tempVideoPath, mimeType };
1019
+ storeCachedMedia(cacheKey, {
1020
+ path: tempVideoPath,
1021
+ type: "video",
1022
+ mimeType,
1023
+ createdAt: Date.now(),
1024
+ size: media.buffer.length,
1025
+ });
1026
+ logVerbose(target, `app video saved (${media.buffer.length} bytes): ${tempVideoPath}`);
1027
+ const videoCfg = resolveAutoVideoConfig(target.account.config);
1028
+ const summary = videoCfg
1029
+ ? await summarizeVideoWithVision({
1030
+ cfg: videoCfg,
1031
+ account: target.account.config,
1032
+ videoPath: tempVideoPath,
1033
+ })
1034
+ : null;
1035
+ messageText = summary
1036
+ ? `[用户发送了一个视频文件]\n\n[视频画面概述]\n${summary}\n\n请根据视频内容回复用户。`
1037
+ : "[用户发送了一个视频文件]\n\n请根据视频内容回复用户。";
1038
+ }
1035
1039
  }
1036
1040
  } catch (err) {
1037
1041
  target.runtime.error?.(`wecom app video download failed: ${String(err)}`);
1038
- messageText = "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。";
1042
+ messageText = isMediaTooLargeError(err)
1043
+ ? "[视频过大,未处理]\n\n请发送更小的视频。"
1044
+ : "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。";
1039
1045
  }
1040
1046
  }
1041
1047
  }
@@ -1059,44 +1065,46 @@ async function processAppMessage(params: {
1059
1065
  ? `[用户发送了一个文件: ${cachedName},已保存到: ${cached.path}]\n\n[文件内容预览]\n${preview}\n\n如需更多内容请使用 Read 工具。`
1060
1066
  : `[用户发送了一个文件: ${cachedName},已保存到: ${cached.path}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`;
1061
1067
  } else {
1062
- const media = await downloadWecomMedia({ account: target.account, mediaId });
1063
- const maxBytes = resolveMediaMaxBytes(target);
1064
- if (maxBytes && media.buffer.length > maxBytes) {
1065
- messageText = "[文件过大,未处理]\n\n请发送更小的文件。";
1066
- } else {
1067
- const ext = fileName.includes(".") ? fileName.split(".").pop() : resolveExtFromContentType(media.contentType, "bin");
1068
- const tempDir = resolveMediaTempDir(target);
1069
- await mkdir(tempDir, { recursive: true });
1070
- await cleanupMediaDir(
1071
- tempDir,
1072
- target.account.config.media?.retentionHours,
1073
- target.account.config.media?.cleanupOnStart,
1074
- );
1075
- const safeName = sanitizeFilename(fileName, `file-${Date.now()}.${ext}`);
1076
- const tempFilePath = join(tempDir, safeName);
1077
- await writeFile(tempFilePath, media.buffer);
1078
- const mimeType = media.contentType || "application/octet-stream";
1079
- mediaContext = { type: "file", path: tempFilePath, mimeType };
1080
- storeCachedMedia(cacheKey, {
1081
- path: tempFilePath,
1082
- type: "file",
1083
- mimeType,
1084
- createdAt: Date.now(),
1085
- size: media.buffer.length,
1086
- });
1087
- logVerbose(target, `app file saved (${media.buffer.length} bytes): ${tempFilePath}`);
1088
- const fileCfg = resolveAutoFileConfig(target.account.config);
1089
- const preview = fileCfg
1090
- ? await extractFileTextPreview({ path: tempFilePath, mimeType, cfg: fileCfg })
1091
- : null;
1092
- messageText = preview
1093
- ? `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n[文件内容预览]\n${preview}\n\n如需更多内容请使用 Read 工具。`
1094
- : `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`;
1095
- }
1068
+ const maxBytes = resolveMediaMaxBytes(target);
1069
+ const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
1070
+ if (maxBytes && media.buffer.length > maxBytes) {
1071
+ messageText = "[文件过大,未处理]\n\n请发送更小的文件。";
1072
+ } else {
1073
+ const ext = fileName.includes(".") ? fileName.split(".").pop() : resolveExtFromContentType(media.contentType, "bin");
1074
+ const tempDir = resolveMediaTempDir(target);
1075
+ await mkdir(tempDir, { recursive: true });
1076
+ await cleanupMediaDir(
1077
+ tempDir,
1078
+ target.account.config.media?.retentionHours,
1079
+ target.account.config.media?.cleanupOnStart,
1080
+ );
1081
+ const safeName = sanitizeFilename(fileName, `file-${Date.now()}.${ext}`);
1082
+ const tempFilePath = join(tempDir, safeName);
1083
+ await writeFile(tempFilePath, media.buffer);
1084
+ const mimeType = media.contentType || "application/octet-stream";
1085
+ mediaContext = { type: "file", path: tempFilePath, mimeType };
1086
+ storeCachedMedia(cacheKey, {
1087
+ path: tempFilePath,
1088
+ type: "file",
1089
+ mimeType,
1090
+ createdAt: Date.now(),
1091
+ size: media.buffer.length,
1092
+ });
1093
+ logVerbose(target, `app file saved (${media.buffer.length} bytes): ${tempFilePath}`);
1094
+ const fileCfg = resolveAutoFileConfig(target.account.config);
1095
+ const preview = fileCfg
1096
+ ? await extractFileTextPreview({ path: tempFilePath, mimeType, cfg: fileCfg })
1097
+ : null;
1098
+ messageText = preview
1099
+ ? `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n[文件内容预览]\n${preview}\n\n如需更多内容请使用 Read 工具。`
1100
+ : `[用户发送了一个文件: ${safeName},已保存到: ${tempFilePath}]\n\n请使用 Read 工具查看这个文件的内容并回复用户。`;
1101
+ }
1096
1102
  }
1097
1103
  } catch (err) {
1098
1104
  target.runtime.error?.(`wecom app file download failed: ${String(err)}`);
1099
- messageText = "[用户发送了一个文件,但下载失败]\n\n请告诉用户文件处理暂时不可用。";
1105
+ messageText = isMediaTooLargeError(err)
1106
+ ? "[文件过大,未处理]\n\n请发送更小的文件。"
1107
+ : "[用户发送了一个文件,但下载失败]\n\n请告诉用户文件处理暂时不可用。";
1100
1108
  }
1101
1109
  }
1102
1110
  }
@@ -1,7 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import crypto from "node:crypto";
3
- import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
3
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
4
  import { basename, extname, join } from "node:path";
6
5
 
7
6
  import type { PluginRuntime } from "openclaw/plugin-sdk";
@@ -10,6 +9,7 @@ import type { WecomWebhookTarget } from "./monitor.js";
10
9
  import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
11
10
  import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature } from "./crypto.js";
12
11
  import {
12
+ MEDIA_TOO_LARGE_ERROR,
13
13
  downloadWecomMedia,
14
14
  fetchMediaFromUrl,
15
15
  sendWecomFile,
@@ -28,6 +28,14 @@ import {
28
28
  summarizeVideoWithVision,
29
29
  transcribeAudioWithOpenAI,
30
30
  } from "./media-auto.js";
31
+ import {
32
+ cleanupMediaDir,
33
+ resolveExtFromContentType,
34
+ resolveMediaMaxBytes,
35
+ resolveMediaRetentionMs,
36
+ resolveMediaTempDir,
37
+ sanitizeFilename,
38
+ } from "./media-utils.js";
31
39
 
32
40
  const STREAM_TTL_MS = 10 * 60 * 1000;
33
41
  const STREAM_MAX_BYTES = 20_480;
@@ -36,7 +44,6 @@ const DEDUPE_TTL_MS = 2 * 60 * 1000;
36
44
  const DEDUPE_MAX_ENTRIES = 2_000;
37
45
  const MEDIA_CACHE_MAX_ENTRIES = 200;
38
46
 
39
- const cleanupExecuted = new Set<string>();
40
47
  const mediaCache = new Map<string, { entry: InboundMedia; createdAt: number; size: number; summary?: string }>();
41
48
 
42
49
  type StreamState = {
@@ -111,66 +118,6 @@ function truncateUtf8Bytes(text: string, maxBytes: number): string {
111
118
  return slice.toString("utf8");
112
119
  }
113
120
 
114
- function resolveExtFromContentType(contentType: string, fallback: string): string {
115
- if (!contentType) return fallback;
116
- if (contentType.includes("png")) return "png";
117
- if (contentType.includes("gif")) return "gif";
118
- if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
119
- if (contentType.includes("mp4")) return "mp4";
120
- if (contentType.includes("amr")) return "amr";
121
- if (contentType.includes("wav")) return "wav";
122
- if (contentType.includes("mp3")) return "mp3";
123
- return fallback;
124
- }
125
-
126
- async function cleanupMediaDir(
127
- dir: string,
128
- retentionHours?: number,
129
- cleanupOnStart?: boolean,
130
- ): Promise<void> {
131
- if (cleanupOnStart === false) return;
132
- if (!retentionHours || retentionHours <= 0) return;
133
- if (cleanupExecuted.has(dir)) return;
134
- cleanupExecuted.add(dir);
135
- const cutoff = Date.now() - retentionHours * 3600 * 1000;
136
- try {
137
- const entries = await readdir(dir);
138
- await Promise.all(entries.map(async (entry) => {
139
- const full = join(dir, entry);
140
- try {
141
- const info = await stat(full);
142
- if (info.isFile() && info.mtimeMs < cutoff) {
143
- await rm(full, { force: true });
144
- }
145
- } catch {
146
- // ignore
147
- }
148
- }));
149
- } catch {
150
- // ignore
151
- }
152
- }
153
-
154
- function resolveMediaTempDir(target: WecomWebhookTarget): string {
155
- return target.account.config.media?.tempDir?.trim()
156
- || join(tmpdir(), "openclaw-wecom");
157
- }
158
-
159
- function resolveMediaMaxBytes(target: WecomWebhookTarget): number | undefined {
160
- const maxBytes = target.account.config.media?.maxBytes;
161
- return typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined;
162
- }
163
-
164
- function sanitizeFilename(name: string, fallback: string): string {
165
- const base = name.split(/[/\\\\]/).pop() ?? "";
166
- const trimmed = base.trim();
167
- const safe = trimmed
168
- .replace(/[^\w.\-() ]+/g, "_")
169
- .replace(/\s+/g, " ")
170
- .trim();
171
- const finalName = safe.slice(0, 120);
172
- return finalName || fallback;
173
- }
174
121
 
175
122
  function jsonOk(res: ServerResponse, body: unknown): void {
176
123
  res.statusCode = 200;
@@ -503,6 +450,18 @@ function pickString(...values: unknown[]): string {
503
450
  return "";
504
451
  }
505
452
 
453
+ function isMediaTooLargeError(err: unknown): boolean {
454
+ if (!err) return false;
455
+ if (typeof err === "string") return err.includes(MEDIA_TOO_LARGE_ERROR);
456
+ if (typeof err === "object") {
457
+ const anyErr = err as { code?: string; message?: string };
458
+ if (anyErr.code === MEDIA_TOO_LARGE_ERROR) return true;
459
+ if (typeof anyErr.message === "string" && anyErr.message.includes(MEDIA_TOO_LARGE_ERROR)) return true;
460
+ if (typeof anyErr.message === "string" && anyErr.message.toLowerCase().includes("media too large")) return true;
461
+ }
462
+ return false;
463
+ }
464
+
506
465
  function resolveBotMediaUrl(msg: any, msgtype: "image" | "voice" | "video" | "file"): string {
507
466
  if (!msg || typeof msg !== "object") return "";
508
467
  const block = msg[msgtype] ?? {};
@@ -628,9 +587,15 @@ async function buildBotMediaMessage(params: {
628
587
  : "[file]";
629
588
 
630
589
  if (!url && !base64 && !mediaId) return { text: fallbackLabel };
590
+ const hasAppCreds = Boolean(target.account.corpId && target.account.corpSecret && target.account.agentId);
591
+ if (!url && !base64 && mediaId && !hasAppCreds) {
592
+ return {
593
+ text: "[用户发送了媒体,但当前仅配置 Bot]\n\n未配置 App 凭据,无法下载或识别媒体内容。请补充 corpId/corpSecret/agentId。",
594
+ };
595
+ }
631
596
 
632
597
  try {
633
- const cacheKey = buildMediaCacheKey({ url, base64 });
598
+ const cacheKey = buildMediaCacheKey({ url, base64, mediaId });
634
599
  const cached = await getCachedMedia(cacheKey, resolveMediaRetentionMs(target));
635
600
  if (cached) {
636
601
  if (msgtype === "image" && cached.summary) {
@@ -660,6 +625,7 @@ async function buildBotMediaMessage(params: {
660
625
 
661
626
  let buffer: Buffer | null = null;
662
627
  let contentType = "";
628
+ const maxBytes = resolveMediaMaxBytes(target);
663
629
  if (base64) {
664
630
  const parsed = parseBase64Input(base64);
665
631
  buffer = Buffer.from(parsed.data, "base64");
@@ -671,18 +637,17 @@ async function buildBotMediaMessage(params: {
671
637
  else contentType = "application/octet-stream";
672
638
  }
673
639
  } else if (url) {
674
- const media = await fetchMediaFromUrl(url, target.account);
640
+ const media = await fetchMediaFromUrl(url, target.account, maxBytes);
675
641
  buffer = media.buffer;
676
642
  contentType = media.contentType;
677
- } else if (mediaId && target.account.corpId && target.account.corpSecret && target.account.agentId) {
678
- const media = await downloadWecomMedia({ account: target.account, mediaId });
643
+ } else if (mediaId && hasAppCreds) {
644
+ const media = await downloadWecomMedia({ account: target.account, mediaId, maxBytes });
679
645
  buffer = media.buffer;
680
646
  contentType = media.contentType;
681
647
  }
682
648
 
683
649
  if (!buffer) return { text: fallbackLabel };
684
650
 
685
- const maxBytes = resolveMediaMaxBytes(target);
686
651
  if (maxBytes && buffer.length > maxBytes) {
687
652
  if (msgtype === "image") return { text: "[图片过大,未处理]\n\n请发送更小的图片。" };
688
653
  if (msgtype === "voice") return { text: "[语音消息过大,未处理]\n\n请发送更短的语音消息。" };
@@ -798,6 +763,12 @@ async function buildBotMediaMessage(params: {
798
763
  return { text: fallbackLabel };
799
764
  } catch (err) {
800
765
  target.runtime.error?.(`wecom bot ${msgtype} download failed: ${String(err)}`);
766
+ if (isMediaTooLargeError(err)) {
767
+ if (msgtype === "image") return { text: "[图片过大,未处理]\n\n请发送更小的图片。" };
768
+ if (msgtype === "voice") return { text: "[语音消息过大,未处理]\n\n请发送更短的语音消息。" };
769
+ if (msgtype === "video") return { text: "[视频过大,未处理]\n\n请发送更小的视频。" };
770
+ return { text: "[文件过大,未处理]\n\n请发送更小的文件。" };
771
+ }
801
772
  if (msgtype === "image") return { text: "[用户发送了一张图片,但下载失败]\n\n请告诉用户图片处理暂时不可用。" };
802
773
  if (msgtype === "voice") return { text: "[用户发送了一条语音消息,但下载失败]\n\n请告诉用户语音处理暂时不可用。" };
803
774
  if (msgtype === "video") return { text: "[用户发送了一个视频,但下载失败]\n\n请告诉用户视频处理暂时不可用。" };
@@ -991,7 +962,7 @@ async function loadOutboundMedia(params: {
991
962
  contentType = resolveContentTypeFromExt(ext);
992
963
  }
993
964
  } else if (spec.url) {
994
- const media = await fetchMediaFromUrl(spec.url, params.account);
965
+ const media = await fetchMediaFromUrl(spec.url, params.account, params.maxBytes);
995
966
  buffer = media.buffer;
996
967
  if (!contentType) contentType = media.contentType;
997
968
  }
@@ -1014,16 +985,12 @@ function mediaSentLabel(type: string): string {
1014
985
  return "[已发送媒体]";
1015
986
  }
1016
987
 
1017
- function resolveMediaRetentionMs(target: WecomWebhookTarget): number | undefined {
1018
- const hours = target.account.config.media?.retentionHours;
1019
- return typeof hours === "number" && hours > 0 ? hours * 3600 * 1000 : undefined;
1020
- }
1021
-
1022
988
  function hashCacheKey(input: string): string {
1023
989
  return crypto.createHash("sha1").update(input).digest("hex");
1024
990
  }
1025
991
 
1026
- function buildMediaCacheKey(params: { url?: string; base64?: string }): string | null {
992
+ function buildMediaCacheKey(params: { url?: string; base64?: string; mediaId?: string }): string | null {
993
+ if (params.mediaId) return `media:${params.mediaId}`;
1027
994
  if (params.url) return `url:${hashCacheKey(params.url)}`;
1028
995
  if (params.base64) return `b64:${hashCacheKey(params.base64)}`;
1029
996
  return null;