mioku-plugin-chat 2.3.1 → 2.4.0

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.
@@ -12,14 +12,51 @@ const execFileAsync = promisify(execFile);
12
12
  const VIDEO_FRAME_COUNT = 5;
13
13
  const VIDEO_FRAME_EXTRACTION_FALLBACK =
14
14
  "用户发送了一个视频,但未能提取画面内容";
15
+ // 体积阈值:超过此大小(10MB)的视频不再整体上传给多模态工作模型,改用抽帧。
16
+ export const VIDEO_FULL_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
15
17
 
16
18
  interface SummaryResult {
17
19
  summary: string;
18
20
  }
19
21
 
22
+ export interface MediaMessageSegment {
23
+ type?: string;
24
+ file?: string;
25
+ path?: string;
26
+ url?: string;
27
+ id?: string;
28
+ data?: {
29
+ file?: string;
30
+ path?: string;
31
+ url?: string;
32
+ id?: string;
33
+ data?: string;
34
+ xml?: string;
35
+ };
36
+ }
37
+
38
+ export function getSegmentSourceCandidates(seg: MediaMessageSegment): string[] {
39
+ return Array.from(
40
+ new Set(
41
+ [
42
+ seg?.file,
43
+ seg?.data?.file,
44
+ seg?.path,
45
+ seg?.data?.path,
46
+ seg?.url,
47
+ seg?.data?.url,
48
+ ]
49
+ .map((v) => (typeof v === "string" ? v.trim() : ""))
50
+ .filter(Boolean),
51
+ ),
52
+ );
53
+ }
54
+
20
55
  export interface MediaSummaryStore {
21
56
  getMediaSummary(key: string): MediaSummaryRecord | null;
22
57
  saveMediaSummary(summary: MediaSummaryRecord): void;
58
+ getMediaSummaryBySource?(sourceKey: string): MediaSummaryRecord | null;
59
+ saveMediaSummarySource?(sourceKey: string, summaryKey: string): void;
23
60
  }
24
61
 
25
62
  export interface HistoryMediaProcessingOptions {
@@ -71,53 +108,8 @@ export async function summarizeHistoryVideo(
71
108
  videoFile.source,
72
109
  videoFile.contentHash,
73
110
  options,
74
- async () => {
75
- const frames = await extractVideoFrames(
76
- videoFile.path,
77
- VIDEO_FRAME_COUNT,
78
- options,
79
- );
80
- if (frames.length === 0) {
81
- getHistoryMediaLogger(options).warn(
82
- "[history-media] Video frame extraction returned 0 frames",
83
- );
84
- return VIDEO_FRAME_EXTRACTION_FALLBACK;
85
- }
86
-
87
- const content: any[] = [
88
- {
89
- type: "text",
90
- text: `These ${frames.length} frames were sampled evenly from a chat video. Summarize the video's likely content in Chinese for chat history context. Mention visible people/objects/actions/text, and keep it concise.`,
91
- },
92
- ...frames.map((frame) => ({
93
- type: "image_url",
94
- image_url: { url: frame, detail: "auto" },
95
- })),
96
- ];
97
-
98
- const response = await runHistoryMediaAIRequest(options, () =>
99
- options.ai!.complete({
100
- model: options.multimodalWorkingModel,
101
- messages: [
102
- {
103
- role: "system",
104
- content:
105
- "You summarize video frames for a chat history. Be factual and concise. If frames are ambiguous, say so.",
106
- },
107
- {
108
- role: "user",
109
- content,
110
- },
111
- ],
112
- temperature: 0.3,
113
- }),
114
- );
115
- if (!response) {
116
- return "";
117
- }
118
-
119
- return normalizeSummary(response.content);
120
- },
111
+ () => summarizeVideoContent(videoFile.path, videoFile.byteSize, options),
112
+ sources,
121
113
  );
122
114
 
123
115
  logMediaSummary(options, "video", result.summary);
@@ -127,30 +119,128 @@ export async function summarizeHistoryVideo(
127
119
  }
128
120
  }
129
121
 
130
- export async function getCachedHistoryVideoTag(
131
- videoSource: string | string[],
122
+ export async function summarizeVideoContent(
123
+ videoPath: string,
124
+ byteSize: number,
132
125
  options: HistoryMediaProcessingOptions,
133
126
  ): Promise<string> {
134
- const sources = normalizeVideoSources(videoSource);
135
- if (sources.length === 0) return "[video]";
136
- try {
137
- const videoFile = await downloadVideoForAnalysis(sources, options);
127
+ // 小视频直接整体喂给多模态工作模型;过大则抽帧避免请求体爆炸。
128
+ if (byteSize <= VIDEO_FULL_UPLOAD_MAX_BYTES) {
138
129
  try {
139
- const summary = getCachedSummaryByHash(
140
- "video",
141
- videoFile.contentHash,
142
- options,
130
+ const summary = await summarizeVideoByFullVideo(videoPath, options);
131
+ if (summary) return summary;
132
+ } catch (err) {
133
+ getHistoryMediaLogger(options).warn(
134
+ `[history-media] Full-video summarization failed, falling back to frames: ${err}`,
143
135
  );
144
- return summary ? `[video:${summary}]` : "[video]";
145
- } finally {
146
- await videoFile.cleanup();
147
136
  }
148
- } catch (err) {
137
+ }
138
+ return summarizeVideoByFrames(videoPath, options);
139
+ }
140
+
141
+ async function summarizeVideoByFullVideo(
142
+ videoPath: string,
143
+ options: HistoryMediaProcessingOptions,
144
+ ): Promise<string> {
145
+ const mimeType = await probeVideoMimeType(videoPath);
146
+ const buffer = await fs.readFile(videoPath);
147
+ const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
148
+
149
+ const content: any[] = [
150
+ {
151
+ type: "text",
152
+ text: "This is a video sent by someone in a chat. Summarize the video's content in Chinese for later use as chat history context: describe the visible people/objects/actions/scenes/on-screen text, and briefly explain what the video is about. Stay factual and concise; state uncertainty honestly when unclear.",
153
+ },
154
+ {
155
+ type: "video_url",
156
+ video_url: { url: dataUrl },
157
+ },
158
+ ];
159
+
160
+ const response = await runHistoryMediaAIRequest(options, () =>
161
+ options.ai!.complete({
162
+ model: options.multimodalWorkingModel,
163
+ messages: [
164
+ {
165
+ role: "system",
166
+ content:
167
+ "You summarize video content for a chat history. Describe only what the video actually shows, objectively and concisely. Do not invent information that is not present.",
168
+ },
169
+ {
170
+ role: "user",
171
+ content,
172
+ },
173
+ ],
174
+ temperature: 0.3,
175
+ }),
176
+ );
177
+ if (!response) return "";
178
+ return normalizeSummary(response.content);
179
+ }
180
+
181
+ async function summarizeVideoByFrames(
182
+ videoPath: string,
183
+ options: HistoryMediaProcessingOptions,
184
+ ): Promise<string> {
185
+ const frames = await extractVideoFrames(
186
+ videoPath,
187
+ VIDEO_FRAME_COUNT,
188
+ options,
189
+ );
190
+ if (frames.length === 0) {
149
191
  getHistoryMediaLogger(options).warn(
150
- `[history-media] Failed to resolve cached video by hash: ${err}`,
192
+ "[history-media] Video frame extraction returned 0 frames",
151
193
  );
152
- return "[video]";
194
+ return VIDEO_FRAME_EXTRACTION_FALLBACK;
153
195
  }
196
+
197
+ const content: any[] = [
198
+ {
199
+ type: "text",
200
+ text: `These ${frames.length} frames were sampled evenly from a video sent in a chat. Summarize the video's likely content in Chinese for later use as chat history context: note the visible people/objects/actions/on-screen text, and infer what the video is about. Stay factual and concise; if the frames are ambiguous or insufficient, say so honestly.`,
201
+ },
202
+ ...frames.map((frame) => ({
203
+ type: "image_url",
204
+ image_url: { url: frame, detail: "auto" },
205
+ })),
206
+ ];
207
+
208
+ const response = await runHistoryMediaAIRequest(options, () =>
209
+ options.ai!.complete({
210
+ model: options.multimodalWorkingModel,
211
+ messages: [
212
+ {
213
+ role: "system",
214
+ content:
215
+ "You summarize video content for a chat history from evenly sampled frames. Describe what the frames plausibly show, objectively and concisely. Do not invent information that is not present.",
216
+ },
217
+ {
218
+ role: "user",
219
+ content,
220
+ },
221
+ ],
222
+ temperature: 0.3,
223
+ }),
224
+ );
225
+ if (!response) return "";
226
+ return normalizeSummary(response.content);
227
+ }
228
+
229
+ export async function getCachedHistoryVideoTag(
230
+ videoSource: string | string[],
231
+ options: HistoryMediaProcessingOptions,
232
+ ): Promise<string> {
233
+ const sources = normalizeVideoSources(videoSource);
234
+ if (sources.length === 0) return "[video]";
235
+
236
+ for (const source of sources) {
237
+ const cached = options.db?.getMediaSummaryBySource?.(source);
238
+ if (cached?.summary && !isFallbackVideoSummary(cached.summary)) {
239
+ return `[video:${cached.summary}]`;
240
+ }
241
+ }
242
+
243
+ return "[video]";
154
244
  }
155
245
 
156
246
  export async function summarizeHistoryForward(
@@ -315,8 +405,16 @@ async function getOrCreateSummary(
315
405
  contentHash: string,
316
406
  options: HistoryMediaProcessingOptions,
317
407
  producer: () => Promise<string>,
408
+ sourceAliases: string[] = [],
318
409
  ): Promise<SummaryResult> {
319
410
  const cacheKey = `${kind}:${contentHash}`;
411
+ const aliases = Array.from(
412
+ new Set(
413
+ [source, ...sourceAliases]
414
+ .map((s) => String(s || "").trim())
415
+ .filter(Boolean),
416
+ ),
417
+ );
320
418
  const cached = options.db?.getMediaSummary(cacheKey);
321
419
  if (cached?.summary) {
322
420
  if (kind === "video") {
@@ -325,9 +423,11 @@ async function getOrCreateSummary(
325
423
  // Old versions cached this probe failure as a valid summary. Ignore it
326
424
  // so the new message can be downloaded and diagnosed again.
327
425
  } else {
426
+ writeMediaSummarySources(options, cacheKey, aliases);
328
427
  return { summary: cached.summary };
329
428
  }
330
429
  } else {
430
+ writeMediaSummarySources(options, cacheKey, aliases);
331
431
  return { summary: cached.summary };
332
432
  }
333
433
  }
@@ -345,6 +445,7 @@ async function getOrCreateSummary(
345
445
  summary,
346
446
  createdAt: Date.now(),
347
447
  });
448
+ writeMediaSummarySources(options, cacheKey, aliases);
348
449
  return { summary };
349
450
  }
350
451
  } catch (err) {
@@ -358,6 +459,17 @@ async function getOrCreateSummary(
358
459
  };
359
460
  }
360
461
 
462
+ function writeMediaSummarySources(
463
+ options: HistoryMediaProcessingOptions,
464
+ summaryKey: string,
465
+ sources: string[],
466
+ ): void {
467
+ if (!options.db?.saveMediaSummarySource) return;
468
+ for (const source of sources) {
469
+ options.db.saveMediaSummarySource!(source, summaryKey);
470
+ }
471
+ }
472
+
361
473
  function logMediaSummary(
362
474
  options: HistoryMediaProcessingOptions,
363
475
  kind: MediaSummaryKind,
@@ -478,6 +590,39 @@ async function probeVideoDuration(videoUrl: string): Promise<number> {
478
590
  return Number.isFinite(duration) && duration > 0 ? duration : 1;
479
591
  }
480
592
 
593
+ export async function probeVideoMimeType(videoPath: string): Promise<string> {
594
+ try {
595
+ const { stdout } = await execFileAsync(
596
+ "ffprobe",
597
+ [
598
+ "-v",
599
+ "error",
600
+ "-show_entries",
601
+ "format=format_name",
602
+ "-of",
603
+ "default=noprint_wrappers=1:nokey=1",
604
+ videoPath,
605
+ ],
606
+ { timeout: 20_000 },
607
+ );
608
+ return mapFormatNameToMimeType(String(stdout).trim());
609
+ } catch {
610
+ return "video/mp4";
611
+ }
612
+ }
613
+
614
+ function mapFormatNameToMimeType(formatName: string): string {
615
+ const names = formatName.split(",").map((s) => s.trim().toLowerCase());
616
+ if (names.some((n) => n === "webm")) return "video/webm";
617
+ if (names.some((n) => n === "matroska" || n === "mkv"))
618
+ return "video/x-matroska";
619
+ if (names.some((n) => n === "avi")) return "video/x-msvideo";
620
+ if (names.some((n) => n === "flv")) return "video/x-flv";
621
+ if (names.some((n) => n === "mov" || n === "mp4" || n === "m4v"))
622
+ return "video/mp4";
623
+ return "video/mp4";
624
+ }
625
+
481
626
  function buildFfmpegInputArgs(input: string, args: string[]): string[] {
482
627
  if (!/^https?:\/\//i.test(input)) return args;
483
628
  const inputIndex = args.findIndex((arg) => arg === input);
@@ -610,20 +755,23 @@ function truncateText(value: string, maxLength: number): string {
610
755
  return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
611
756
  }
612
757
 
613
- async function downloadVideoForAnalysis(
758
+ export async function downloadVideoForAnalysis(
614
759
  sources: string | string[],
615
760
  options: HistoryMediaProcessingOptions,
616
761
  ): Promise<{
617
762
  path: string;
618
763
  source: string;
619
764
  contentHash: string;
765
+ byteSize: number;
620
766
  cleanup: () => Promise<void>;
621
767
  }> {
622
768
  const candidates = normalizeVideoSources(sources);
623
769
  let lastError: unknown;
624
770
 
625
771
  for (const source of candidates) {
626
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mioku-video-src-"));
772
+ const tempDir = await fs.mkdtemp(
773
+ path.join(os.tmpdir(), "mioku-video-src-"),
774
+ );
627
775
  const filePath = path.join(tempDir, "video");
628
776
 
629
777
  try {
@@ -633,6 +781,7 @@ async function downloadVideoForAnalysis(
633
781
  path: filePath,
634
782
  source,
635
783
  contentHash: hashSource(buffer),
784
+ byteSize: buffer.length,
636
785
  cleanup: () => fs.rm(tempDir, { recursive: true, force: true }),
637
786
  };
638
787
  } catch (err) {
@@ -677,11 +826,7 @@ async function readVideoSource(source: string): Promise<Buffer> {
677
826
  function normalizeVideoSources(input: string | string[]): string[] {
678
827
  const raw = Array.isArray(input) ? input : [input];
679
828
  return Array.from(
680
- new Set(
681
- raw
682
- .map((source) => String(source || "").trim())
683
- .filter(Boolean),
684
- ),
829
+ new Set(raw.map((source) => String(source || "").trim()).filter(Boolean)),
685
830
  );
686
831
  }
687
832
 
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, createWriteStream, unlink } from "fs";
9
9
  import * as https from "https";
10
10
  import * as http from "http";
11
11
  import { URL } from "url";
12
+ import { prepareImageUrlsForModel } from "./image-compress";
12
13
 
13
14
  /**
14
15
  * 图片分析结果
@@ -74,27 +75,25 @@ const EMOTION_TAGS = [
74
75
  ];
75
76
 
76
77
  /**
77
- * 计算图片内容的哈希值
78
+ * 计算图片内容的哈希值(仅基于下载到的图片字节;URL 不参与哈希)
78
79
  */
79
- export async function calculateImageHash(url: string): Promise<string> {
80
+ export async function calculateImageHash(url: string): Promise<string | null> {
80
81
  try {
81
- // 下载图片内容
82
82
  const response = await fetch(url);
83
83
  if (!response.ok) {
84
- // 如果下载失败,降级为 URL 哈希
85
- return crypto.createHash("md5").update(url).digest("hex");
84
+ logger.warn(
85
+ `[image-analyzer] Failed to download image for hashing: ${response.status} ${response.statusText}`,
86
+ );
87
+ return null;
86
88
  }
87
89
 
88
90
  const buffer = Buffer.from(await response.arrayBuffer());
89
-
90
- // 基于图片内容计算哈希
91
91
  return crypto.createHash("md5").update(buffer).digest("hex");
92
92
  } catch (err) {
93
93
  logger.warn(
94
- `[image-analyzer] Failed to calculate content hash, using URL hash: ${err}`,
94
+ `[image-analyzer] Failed to download image for hashing: ${err}`,
95
95
  );
96
- // 降级为 URL 哈希
97
- return crypto.createHash("md5").update(url).digest("hex");
96
+ return null;
98
97
  }
99
98
  }
100
99
 
@@ -176,6 +175,8 @@ export async function analyzeImage(
176
175
  }
177
176
  }
178
177
 
178
+ imageUrls = await prepareImageUrlsForModel(imageUrls);
179
+
179
180
  const systemPrompt = `You are an image classification and analysis assistant. Analyze images and return structured information.
180
181
 
181
182
  Instructions:
@@ -336,6 +337,12 @@ export async function processImage(
336
337
  try {
337
338
  // 计算哈希(基于图片内容)
338
339
  const hash = await calculateImageHash(imageUrl);
340
+ if (!hash) {
341
+ logger.warn(
342
+ `[image-analyzer] Skipping analysis, image content unavailable: ${imageUrl}`,
343
+ );
344
+ return null;
345
+ }
339
346
 
340
347
  // 检查是否已存在
341
348
  const existing = db.getImageByHash(hash);
@@ -343,7 +350,13 @@ export async function processImage(
343
350
  logger.info(formatImageRecognitionLog(existing));
344
351
  return existing;
345
352
  }
346
- const analysis = await analyzeImage(ai, imageUrl, model, undefined, options);
353
+ const analysis = await analyzeImage(
354
+ ai,
355
+ imageUrl,
356
+ model,
357
+ undefined,
358
+ options,
359
+ );
347
360
  if (!analysis.success || !analysis.type) {
348
361
  logger.warn(`[image-analyzer] Analysis failed: ${analysis.error}`);
349
362
  return null;
@@ -436,15 +449,11 @@ export async function processImage(
436
449
 
437
450
  /**
438
451
  * 从图片 URL 获取描述标签
439
- * 如果图片已在数据库中,返回 [meme:描述] 或 [image:描述]
440
- * 否则返回 [image]
452
+ * 命中数据库记录时返回 [meme:描述] 或 [image:描述],否则返回 [image]。
453
+ * 收集聊天历史时用它,之前没识别到的图片不会再次触发请求。
441
454
  */
442
- export async function getImageTag(
443
- imageUrl: string,
444
- db: ChatDatabase,
445
- ): Promise<string> {
446
- const hash = await calculateImageHash(imageUrl);
447
- const record = db.getImageByHash(hash);
455
+ export function getImageTag(imageUrl: string, db: ChatDatabase): string {
456
+ const record = db.getImageByUrl(imageUrl);
448
457
 
449
458
  if (record) {
450
459
  return `[${record.type}:${record.description}]`;
@@ -0,0 +1,75 @@
1
+ import sharp from "sharp";
2
+ import { logger } from "mioki";
3
+
4
+ const IMAGE_MAX_BYTES = 1 * 1024 * 1024;
5
+ const COMPRESS_MAX_WIDTH = 1280;
6
+ const COMPRESS_JPEG_QUALITY = 80;
7
+
8
+ const FETCH_HEADERS = {
9
+ "User-Agent":
10
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
11
+ Referer: "https://qq.com/",
12
+ };
13
+
14
+ /**
15
+ * 准备发给模型的图片 URL:体积超过 1MB 时压缩为 JPEG data URL,否则原样返回。
16
+ * data URL(如 GIF 抽帧结果)与非 http(s) 链接直接放行。
17
+ */
18
+ export async function prepareImageUrlForModel(url: string): Promise<string> {
19
+ if (url.startsWith("data:")) return url;
20
+ if (!/^https?:\/\//i.test(url)) return url;
21
+
22
+ const size = await probeImageSize(url);
23
+ if (size !== null && size <= IMAGE_MAX_BYTES) return url;
24
+
25
+ let buffer: Buffer;
26
+ try {
27
+ buffer = await downloadImageBuffer(url);
28
+ } catch (err) {
29
+ logger.warn(`[image-compress] download failed, using original: ${err}`);
30
+ return url;
31
+ }
32
+
33
+ if (buffer.length <= IMAGE_MAX_BYTES) return url;
34
+
35
+ try {
36
+ const compressed = await sharp(buffer)
37
+ .resize({ width: COMPRESS_MAX_WIDTH, withoutEnlargement: true })
38
+ .jpeg({ quality: COMPRESS_JPEG_QUALITY })
39
+ .toBuffer();
40
+ logger.info(
41
+ `[image-compress] compressed ${buffer.length} -> ${compressed.length} bytes`,
42
+ );
43
+ return `data:image/jpeg;base64,${compressed.toString("base64")}`;
44
+ } catch (err) {
45
+ logger.warn(`[image-compress] compress failed, using original: ${err}`);
46
+ return url;
47
+ }
48
+ }
49
+
50
+ export async function prepareImageUrlsForModel(
51
+ urls: string[],
52
+ ): Promise<string[]> {
53
+ return Promise.all(urls.map(prepareImageUrlForModel));
54
+ }
55
+
56
+ async function probeImageSize(url: string): Promise<number | null> {
57
+ try {
58
+ const head = await fetch(url, { method: "HEAD", headers: FETCH_HEADERS });
59
+ if (!head.ok) return null;
60
+ const len = head.headers.get("content-length");
61
+ if (!len) return null;
62
+ const n = Number(len);
63
+ return Number.isFinite(n) ? n : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ async function downloadImageBuffer(url: string): Promise<Buffer> {
70
+ const response = await fetch(url, { headers: FETCH_HEADERS });
71
+ if (!response.ok) {
72
+ throw new Error(`download failed: ${response.status} ${response.statusText}`);
73
+ }
74
+ return Buffer.from(await response.arrayBuffer());
75
+ }
@@ -0,0 +1,91 @@
1
+ import type { AIInstance } from "mioku";
2
+ import type { ChatConfig, MediaSummaryRecord } from "../../types";
3
+ import type { HistoryMediaOptions } from "../../manage/types";
4
+ import {
5
+ getSegmentSourceCandidates,
6
+ type HistoryMediaProcessingOptions,
7
+ type MediaMessageSegment,
8
+ } from "./history-media";
9
+
10
+ export { getSegmentSourceCandidates };
11
+
12
+ export function buildHistoryMediaOptions(
13
+ ai: AIInstance,
14
+ config: ChatConfig,
15
+ ): HistoryMediaOptions {
16
+ return {
17
+ ai,
18
+ workingModel: config.workingModel || config.model,
19
+ multimodalWorkingModel: config.multimodalWorkingModel || config.model,
20
+ };
21
+ }
22
+
23
+ export function buildHistoryMediaProcessingOptions(
24
+ ai: AIInstance,
25
+ config: ChatConfig,
26
+ db: {
27
+ getMediaSummary(key: string): MediaSummaryRecord | null;
28
+ saveMediaSummary(summary: MediaSummaryRecord): void;
29
+ },
30
+ bot: {
31
+ api<T = unknown>(action: string, params?: Record<string, unknown>): Promise<T>;
32
+ },
33
+ groupId: number,
34
+ log: HistoryMediaProcessingOptions["logger"],
35
+ runAIRequest?: <T>(request: () => Promise<T>) => Promise<T | null>,
36
+ ): HistoryMediaProcessingOptions {
37
+ return {
38
+ ...buildHistoryMediaOptions(ai, config),
39
+ db,
40
+ logger: log,
41
+ bot,
42
+ groupId,
43
+ runAIRequest,
44
+ };
45
+ }
46
+
47
+ export function getSegmentUrl(seg: {
48
+ url?: string;
49
+ data?: { url?: string };
50
+ }): string | null {
51
+ const url = seg?.url || seg?.data?.url;
52
+ return typeof url === "string" && url.trim() ? url.trim() : null;
53
+ }
54
+
55
+ export async function getVideoSourceCandidatesFromMessage(
56
+ bot: {
57
+ api(action: string, params?: Record<string, unknown>): Promise<unknown>;
58
+ },
59
+ messageId: number | string | undefined,
60
+ ): Promise<string[]> {
61
+ if (messageId == null) return [];
62
+ const result = (await bot.api("get_msg", { message_id: messageId })) as {
63
+ message?: unknown[];
64
+ data?: { message?: unknown[] };
65
+ };
66
+ const segments = result?.message || result?.data?.message || [];
67
+ if (!Array.isArray(segments)) return [];
68
+ const candidates: string[] = [];
69
+ for (const seg of segments as MediaMessageSegment[]) {
70
+ if (seg?.type !== "video") continue;
71
+ candidates.push(...getSegmentSourceCandidates(seg));
72
+ }
73
+ return candidates;
74
+ }
75
+
76
+ export function getForwardId(seg: {
77
+ id?: unknown;
78
+ data?: { id?: unknown };
79
+ }): string | null {
80
+ return String(seg?.id || seg?.data?.id || "");
81
+ }
82
+
83
+ export function getCardData(seg: { data?: unknown }): string | null {
84
+ const data = seg?.data;
85
+ if (!data) return null;
86
+ return typeof data === "string" ? data : JSON.stringify(data);
87
+ }
88
+
89
+ export function isMediaAnalysisBlocked(config: ChatConfig, userId: number): boolean {
90
+ return Boolean(config.mediaAnalysisBlacklistUsers?.includes(userId));
91
+ }