@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 +1 -1
- package/wecom/src/wecom-app.ts +185 -52
- package/wecom/src/wecom-bot.ts +110 -8
package/package.json
CHANGED
package/wecom/src/wecom-app.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
351
|
-
|
|
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
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
target
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
} else
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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请告诉用户文件处理暂时不可用。";
|
package/wecom/src/wecom-bot.ts
CHANGED
|
@@ -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:
|
|
634
|
-
media
|
|
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:
|
|
647
|
-
media
|
|
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:
|
|
653
|
-
media
|
|
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:
|
|
659
|
-
media
|
|
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
|
}
|