mioku-plugin-chat 2.0.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.
- package/README.md +24 -0
- package/configs/base.ts +12 -0
- package/configs/personalization.ts +84 -0
- package/configs/settings.ts +54 -0
- package/context.ts +98 -0
- package/core/base.ts +936 -0
- package/core/chat-engine.ts +779 -0
- package/core/external-skills.ts +94 -0
- package/core/feature-prompts.ts +47 -0
- package/core/media/audio.ts +73 -0
- package/core/media/gif-extractor.ts +118 -0
- package/core/media/history-media.ts +751 -0
- package/core/media/image-analyzer.ts +447 -0
- package/core/media/markdown-message.ts +190 -0
- package/core/multimodal.ts +180 -0
- package/core/prompt.ts +827 -0
- package/core/tools.ts +862 -0
- package/core/web/searxng.ts +184 -0
- package/core/web/web-reader.ts +792 -0
- package/db.ts +849 -0
- package/humanize/emoji-agent.ts +397 -0
- package/humanize/expression.ts +152 -0
- package/humanize/index.ts +40 -0
- package/humanize/memory.ts +131 -0
- package/humanize/planner.ts +249 -0
- package/humanize/topic.ts +205 -0
- package/humanize/utils.ts +28 -0
- package/index.ts +1830 -0
- package/manage/cooldown.ts +445 -0
- package/manage/group-structured-history.ts +195 -0
- package/manage/idle-check.ts +186 -0
- package/manage/queue-processor.ts +228 -0
- package/manage/rate-limiter.ts +312 -0
- package/manage/session.ts +96 -0
- package/manage/skill-session.ts +173 -0
- package/manage/types.ts +138 -0
- package/package.json +51 -0
- package/tsconfig.json +7 -0
- package/types.ts +381 -0
- package/utils/index.ts +9 -0
- package/utils/message.ts +496 -0
- package/utils/queue.ts +186 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import * as fs from "fs/promises";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
import { logger } from "mioki";
|
|
8
|
+
import type { AIInstance } from "mioku";
|
|
9
|
+
import type { MediaSummaryKind, MediaSummaryRecord } from "../../types";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const VIDEO_FRAME_COUNT = 5;
|
|
13
|
+
const VIDEO_FRAME_EXTRACTION_FALLBACK =
|
|
14
|
+
"用户发送了一个视频,但未能提取画面内容";
|
|
15
|
+
|
|
16
|
+
interface SummaryResult {
|
|
17
|
+
summary: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MediaSummaryStore {
|
|
21
|
+
getMediaSummary(key: string): MediaSummaryRecord | null;
|
|
22
|
+
saveMediaSummary(summary: MediaSummaryRecord): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HistoryMediaProcessingOptions {
|
|
26
|
+
ai?: AIInstance;
|
|
27
|
+
workingModel?: string;
|
|
28
|
+
multimodalWorkingModel?: string;
|
|
29
|
+
db?: MediaSummaryStore;
|
|
30
|
+
logger?: {
|
|
31
|
+
info(message: string): void;
|
|
32
|
+
warn(message: string): void;
|
|
33
|
+
error(message: string): void;
|
|
34
|
+
};
|
|
35
|
+
bot?: {
|
|
36
|
+
api<T = any>(action: string, params?: Record<string, any>): Promise<T>;
|
|
37
|
+
};
|
|
38
|
+
groupId?: number;
|
|
39
|
+
runAIRequest?<T>(request: () => Promise<T>): Promise<T | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GroupNoticeHistorySummary {
|
|
43
|
+
userId: number;
|
|
44
|
+
userName: string;
|
|
45
|
+
userRole: string;
|
|
46
|
+
content: string;
|
|
47
|
+
timestamp: number;
|
|
48
|
+
messageId: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runHistoryMediaAIRequest<T>(
|
|
52
|
+
options: HistoryMediaProcessingOptions,
|
|
53
|
+
request: () => Promise<T>,
|
|
54
|
+
): Promise<T | null> {
|
|
55
|
+
return options.runAIRequest ? options.runAIRequest(request) : request();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function summarizeHistoryVideo(
|
|
59
|
+
videoSource: string | string[],
|
|
60
|
+
options: HistoryMediaProcessingOptions,
|
|
61
|
+
): Promise<string> {
|
|
62
|
+
const sources = normalizeVideoSources(videoSource);
|
|
63
|
+
if (sources.length === 0 || !options.ai || !options.multimodalWorkingModel) {
|
|
64
|
+
return "[video]";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const videoFile = await downloadVideoForAnalysis(sources, options);
|
|
68
|
+
try {
|
|
69
|
+
const result = await getOrCreateSummary(
|
|
70
|
+
"video",
|
|
71
|
+
videoFile.source,
|
|
72
|
+
videoFile.contentHash,
|
|
73
|
+
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
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
logMediaSummary(options, "video", result.summary);
|
|
124
|
+
return `[video:${result.summary}]`;
|
|
125
|
+
} finally {
|
|
126
|
+
await videoFile.cleanup();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function getCachedHistoryVideoTag(
|
|
131
|
+
videoSource: string | string[],
|
|
132
|
+
options: HistoryMediaProcessingOptions,
|
|
133
|
+
): Promise<string> {
|
|
134
|
+
const sources = normalizeVideoSources(videoSource);
|
|
135
|
+
if (sources.length === 0) return "[video]";
|
|
136
|
+
try {
|
|
137
|
+
const videoFile = await downloadVideoForAnalysis(sources, options);
|
|
138
|
+
try {
|
|
139
|
+
const summary = getCachedSummaryByHash(
|
|
140
|
+
"video",
|
|
141
|
+
videoFile.contentHash,
|
|
142
|
+
options,
|
|
143
|
+
);
|
|
144
|
+
return summary ? `[video:${summary}]` : "[video]";
|
|
145
|
+
} finally {
|
|
146
|
+
await videoFile.cleanup();
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
getHistoryMediaLogger(options).warn(
|
|
150
|
+
`[history-media] Failed to resolve cached video by hash: ${err}`,
|
|
151
|
+
);
|
|
152
|
+
return "[video]";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function summarizeHistoryForward(
|
|
157
|
+
forwardId: string,
|
|
158
|
+
options: HistoryMediaProcessingOptions,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
const id = forwardId.trim();
|
|
161
|
+
if (!id || !options.bot || !options.ai || !options.workingModel) {
|
|
162
|
+
return "[forward]";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const forwardResult = await options.bot.api("get_forward_msg", { id });
|
|
166
|
+
const text = extractForwardText(forwardResult);
|
|
167
|
+
const source = `forward:${id}`;
|
|
168
|
+
if (!text) {
|
|
169
|
+
const emptyResult = await getOrCreateSummary(
|
|
170
|
+
"forward",
|
|
171
|
+
source,
|
|
172
|
+
hashSource(""),
|
|
173
|
+
options,
|
|
174
|
+
async () => "合并转发消息中没有可读取的文本内容。",
|
|
175
|
+
);
|
|
176
|
+
logMediaSummary(options, "forward", emptyResult.summary);
|
|
177
|
+
return `[forward:${emptyResult.summary}]`;
|
|
178
|
+
}
|
|
179
|
+
const result = await getOrCreateSummary(
|
|
180
|
+
"forward",
|
|
181
|
+
source,
|
|
182
|
+
hashSource(normalizeContentForHash(text)),
|
|
183
|
+
options,
|
|
184
|
+
() =>
|
|
185
|
+
summarizeTextContent(
|
|
186
|
+
"合并转发消息",
|
|
187
|
+
text,
|
|
188
|
+
options.ai!,
|
|
189
|
+
options.workingModel!,
|
|
190
|
+
options,
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
logMediaSummary(options, "forward", result.summary);
|
|
195
|
+
return `[forward:${result.summary}]`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function getCachedHistoryForwardTag(
|
|
199
|
+
forwardId: string,
|
|
200
|
+
options: HistoryMediaProcessingOptions,
|
|
201
|
+
): Promise<string> {
|
|
202
|
+
const id = forwardId.trim();
|
|
203
|
+
if (!id) return "[forward]";
|
|
204
|
+
if (!options.bot) return "[forward]";
|
|
205
|
+
try {
|
|
206
|
+
const forwardResult = await options.bot.api("get_forward_msg", { id });
|
|
207
|
+
const text = extractForwardText(forwardResult);
|
|
208
|
+
const summary = getCachedSummaryByHash(
|
|
209
|
+
"forward",
|
|
210
|
+
hashSource(normalizeContentForHash(text)),
|
|
211
|
+
options,
|
|
212
|
+
);
|
|
213
|
+
return summary ? `[forward:${summary}]` : "[forward]";
|
|
214
|
+
} catch (err) {
|
|
215
|
+
getHistoryMediaLogger(options).warn(
|
|
216
|
+
`[history-media] Failed to resolve cached forward by hash: ${err}`,
|
|
217
|
+
);
|
|
218
|
+
return "[forward]";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function summarizeHistoryCard(
|
|
223
|
+
cardData: string,
|
|
224
|
+
options: HistoryMediaProcessingOptions,
|
|
225
|
+
): Promise<string> {
|
|
226
|
+
const source = cardData.trim();
|
|
227
|
+
if (!source || !options.ai || !options.workingModel) {
|
|
228
|
+
return "[card]";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const normalized = normalizeContentForHash(source);
|
|
232
|
+
const result = await getOrCreateSummary(
|
|
233
|
+
"card",
|
|
234
|
+
source,
|
|
235
|
+
hashSource(normalized),
|
|
236
|
+
options,
|
|
237
|
+
() => {
|
|
238
|
+
const promptContent = extractCardPrompt(source);
|
|
239
|
+
if (promptContent && promptContent.length < 100) {
|
|
240
|
+
return Promise.resolve(promptContent);
|
|
241
|
+
}
|
|
242
|
+
return summarizeTextContent(
|
|
243
|
+
"XML/JSON 卡片消息",
|
|
244
|
+
promptContent || source,
|
|
245
|
+
options.ai!,
|
|
246
|
+
options.workingModel!,
|
|
247
|
+
options,
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
logMediaSummary(options, "card", result.summary);
|
|
253
|
+
return `[card:${result.summary}]`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function getCachedHistoryCardTag(
|
|
257
|
+
cardData: string,
|
|
258
|
+
options: HistoryMediaProcessingOptions,
|
|
259
|
+
): string {
|
|
260
|
+
const source = cardData.trim();
|
|
261
|
+
if (!source) return "[card]";
|
|
262
|
+
const summary = getCachedSummaryByHash(
|
|
263
|
+
"card",
|
|
264
|
+
hashSource(normalizeContentForHash(source)),
|
|
265
|
+
options,
|
|
266
|
+
);
|
|
267
|
+
return summary ? `[card:${summary}]` : "[card]";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function summarizeGroupNotice(
|
|
271
|
+
notice: any,
|
|
272
|
+
options: HistoryMediaProcessingOptions,
|
|
273
|
+
): Promise<GroupNoticeHistorySummary | null> {
|
|
274
|
+
if (!options.groupId || !options.ai || !options.workingModel) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const text = extractNoticeText(notice);
|
|
279
|
+
if (!text) return null;
|
|
280
|
+
const source = `group:${options.groupId}:notice:${text}`;
|
|
281
|
+
const summary = (
|
|
282
|
+
await getOrCreateSummary(
|
|
283
|
+
"notice",
|
|
284
|
+
source,
|
|
285
|
+
hashSource(normalizeContentForHash(text)),
|
|
286
|
+
options,
|
|
287
|
+
() =>
|
|
288
|
+
summarizeTextContent(
|
|
289
|
+
"群公告",
|
|
290
|
+
text,
|
|
291
|
+
options.ai!,
|
|
292
|
+
options.workingModel!,
|
|
293
|
+
options,
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
).summary;
|
|
297
|
+
const sender = extractNoticeSender(notice);
|
|
298
|
+
const content = `发布了一条群公告:[group_notice:${summary}]`;
|
|
299
|
+
logger.info(
|
|
300
|
+
`[history-media] group_notice: ${sender.userName}(${sender.userId}) ${content}`,
|
|
301
|
+
);
|
|
302
|
+
return {
|
|
303
|
+
userId: sender.userId,
|
|
304
|
+
userName: sender.userName,
|
|
305
|
+
userRole: "member",
|
|
306
|
+
content,
|
|
307
|
+
timestamp: normalizeNoticeTimestamp(notice),
|
|
308
|
+
messageId: 0,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function getOrCreateSummary(
|
|
313
|
+
kind: MediaSummaryKind,
|
|
314
|
+
source: string,
|
|
315
|
+
contentHash: string,
|
|
316
|
+
options: HistoryMediaProcessingOptions,
|
|
317
|
+
producer: () => Promise<string>,
|
|
318
|
+
): Promise<SummaryResult> {
|
|
319
|
+
const cacheKey = `${kind}:${contentHash}`;
|
|
320
|
+
const cached = options.db?.getMediaSummary(cacheKey);
|
|
321
|
+
if (cached?.summary) {
|
|
322
|
+
if (kind === "video") {
|
|
323
|
+
warnIfFallbackVideoSummary(options, "content hash cache", cached.summary);
|
|
324
|
+
if (isFallbackVideoSummary(cached.summary)) {
|
|
325
|
+
// Old versions cached this probe failure as a valid summary. Ignore it
|
|
326
|
+
// so the new message can be downloaded and diagnosed again.
|
|
327
|
+
} else {
|
|
328
|
+
return { summary: cached.summary };
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
return { summary: cached.summary };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const summary = normalizeSummary(await producer());
|
|
337
|
+
if (summary) {
|
|
338
|
+
if (kind === "video" && isFallbackVideoSummary(summary)) {
|
|
339
|
+
return { summary };
|
|
340
|
+
}
|
|
341
|
+
options.db?.saveMediaSummary({
|
|
342
|
+
key: cacheKey,
|
|
343
|
+
kind,
|
|
344
|
+
source,
|
|
345
|
+
summary,
|
|
346
|
+
createdAt: Date.now(),
|
|
347
|
+
});
|
|
348
|
+
return { summary };
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
getHistoryMediaLogger(options).warn(
|
|
352
|
+
`[history-media] Failed to summarize ${kind}: ${err}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
summary: kind === "video" ? "用户发送了一个视频。" : "内容暂时无法解析。",
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function logMediaSummary(
|
|
362
|
+
options: HistoryMediaProcessingOptions,
|
|
363
|
+
kind: MediaSummaryKind,
|
|
364
|
+
summary: string,
|
|
365
|
+
): void {
|
|
366
|
+
getHistoryMediaLogger(options).info(`[history-media] ${kind}: ${summary}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function warnIfFallbackVideoSummary(
|
|
370
|
+
options: HistoryMediaProcessingOptions,
|
|
371
|
+
from: string,
|
|
372
|
+
summary: string,
|
|
373
|
+
): void {
|
|
374
|
+
if (!isFallbackVideoSummary(summary)) return;
|
|
375
|
+
getHistoryMediaLogger(options).warn(
|
|
376
|
+
`[history-media] Ignored fallback video summary from ${from}: ${summary}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isFallbackVideoSummary(summary: string): boolean {
|
|
381
|
+
return summary === VIDEO_FRAME_EXTRACTION_FALLBACK;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function getHistoryMediaLogger(options: HistoryMediaProcessingOptions) {
|
|
385
|
+
return options.logger || logger;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function summarizeTextContent(
|
|
389
|
+
label: string,
|
|
390
|
+
text: string,
|
|
391
|
+
ai: AIInstance,
|
|
392
|
+
model: string,
|
|
393
|
+
options: HistoryMediaProcessingOptions,
|
|
394
|
+
): Promise<string> {
|
|
395
|
+
const response = await runHistoryMediaAIRequest(options, () =>
|
|
396
|
+
ai.complete({
|
|
397
|
+
model,
|
|
398
|
+
messages: [
|
|
399
|
+
{
|
|
400
|
+
role: "system",
|
|
401
|
+
content:
|
|
402
|
+
"You summarize non-plain chat content for recent chat history. Output Chinese only, concise and factual. Keep key names, titles, links, amounts, and actions if present.",
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
role: "user",
|
|
406
|
+
content: `${label}原始内容:\n${truncateText(text, 8000)}\n\n请概括成一句适合放进聊天历史的中文摘要。`,
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
temperature: 0.3,
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
if (!response) return "";
|
|
413
|
+
return normalizeSummary(response.content);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function extractVideoFrames(
|
|
417
|
+
videoUrl: string,
|
|
418
|
+
frameCount: number,
|
|
419
|
+
options: HistoryMediaProcessingOptions,
|
|
420
|
+
): Promise<string[]> {
|
|
421
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mioku-video-"));
|
|
422
|
+
try {
|
|
423
|
+
const duration = await probeVideoDuration(videoUrl);
|
|
424
|
+
const timestamps = buildFrameTimestamps(duration, frameCount);
|
|
425
|
+
const frames: string[] = [];
|
|
426
|
+
|
|
427
|
+
for (let i = 0; i < timestamps.length; i += 1) {
|
|
428
|
+
const outputPath = path.join(tempDir, `frame-${i}.jpg`);
|
|
429
|
+
await execFileAsync(
|
|
430
|
+
"ffmpeg",
|
|
431
|
+
buildFfmpegInputArgs(videoUrl, [
|
|
432
|
+
"-hide_banner",
|
|
433
|
+
"-loglevel",
|
|
434
|
+
"error",
|
|
435
|
+
"-ss",
|
|
436
|
+
String(timestamps[i]),
|
|
437
|
+
"-i",
|
|
438
|
+
videoUrl,
|
|
439
|
+
"-frames:v",
|
|
440
|
+
"1",
|
|
441
|
+
"-q:v",
|
|
442
|
+
"2",
|
|
443
|
+
"-y",
|
|
444
|
+
outputPath,
|
|
445
|
+
]),
|
|
446
|
+
{ timeout: 30_000 },
|
|
447
|
+
);
|
|
448
|
+
const buffer = await fs.readFile(outputPath);
|
|
449
|
+
frames.push(`data:image/jpeg;base64,${buffer.toString("base64")}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return frames;
|
|
453
|
+
} catch (err) {
|
|
454
|
+
getHistoryMediaLogger(options).warn(
|
|
455
|
+
`[history-media] Failed to extract video frames: ${err}`,
|
|
456
|
+
);
|
|
457
|
+
return [];
|
|
458
|
+
} finally {
|
|
459
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function probeVideoDuration(videoUrl: string): Promise<number> {
|
|
464
|
+
const { stdout } = await execFileAsync(
|
|
465
|
+
"ffprobe",
|
|
466
|
+
buildFfmpegInputArgs(videoUrl, [
|
|
467
|
+
"-v",
|
|
468
|
+
"error",
|
|
469
|
+
"-show_entries",
|
|
470
|
+
"format=duration",
|
|
471
|
+
"-of",
|
|
472
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
473
|
+
videoUrl,
|
|
474
|
+
]),
|
|
475
|
+
{ timeout: 20_000 },
|
|
476
|
+
);
|
|
477
|
+
const duration = Number(String(stdout).trim());
|
|
478
|
+
return Number.isFinite(duration) && duration > 0 ? duration : 1;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function buildFfmpegInputArgs(input: string, args: string[]): string[] {
|
|
482
|
+
if (!/^https?:\/\//i.test(input)) return args;
|
|
483
|
+
const inputIndex = args.findIndex((arg) => arg === input);
|
|
484
|
+
if (inputIndex < 0) return args;
|
|
485
|
+
return [
|
|
486
|
+
...args.slice(0, inputIndex),
|
|
487
|
+
"-headers",
|
|
488
|
+
"User-Agent: Mozilla/5.0\r\nReferer: https://qq.com/\r\n",
|
|
489
|
+
...args.slice(inputIndex),
|
|
490
|
+
];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function buildFrameTimestamps(duration: number, frameCount: number): number[] {
|
|
494
|
+
if (frameCount <= 1) return [0];
|
|
495
|
+
const safeDuration = Math.max(duration, 1);
|
|
496
|
+
const maxTimestamp = Math.max(0, safeDuration - 0.2);
|
|
497
|
+
return Array.from({ length: frameCount }, (_, index) => {
|
|
498
|
+
if (frameCount === 1) return 0;
|
|
499
|
+
return Math.min(maxTimestamp, (maxTimestamp * index) / (frameCount - 1));
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function extractForwardText(result: any): string {
|
|
504
|
+
const messages =
|
|
505
|
+
result?.messages || result?.data?.messages || result?.data || [];
|
|
506
|
+
const nodes = Array.isArray(messages) ? messages : [];
|
|
507
|
+
const lines: string[] = [];
|
|
508
|
+
|
|
509
|
+
for (const node of nodes) {
|
|
510
|
+
const senderName =
|
|
511
|
+
node.sender?.nickname || node.sender?.card || node.nickname || "unknown";
|
|
512
|
+
const content = extractMessageSegmentsText(node.message || node.content);
|
|
513
|
+
if (content) {
|
|
514
|
+
lines.push(`${senderName}: ${content}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return lines.join("\n");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function extractMessageSegmentsText(message: any): string {
|
|
522
|
+
if (typeof message === "string") return message.trim();
|
|
523
|
+
if (!Array.isArray(message)) return "";
|
|
524
|
+
|
|
525
|
+
const parts: string[] = [];
|
|
526
|
+
for (const seg of message) {
|
|
527
|
+
const type = seg?.type;
|
|
528
|
+
const data = seg?.data || seg;
|
|
529
|
+
if (type === "text") {
|
|
530
|
+
parts.push(String(data.text || seg.text || "").trim());
|
|
531
|
+
} else if (type === "image") {
|
|
532
|
+
parts.push("[image]");
|
|
533
|
+
} else if (type === "video") {
|
|
534
|
+
parts.push("[video]");
|
|
535
|
+
} else if (type === "forward") {
|
|
536
|
+
parts.push("[forward]");
|
|
537
|
+
} else if (type === "json" || type === "xml" || type === "ark") {
|
|
538
|
+
parts.push(String(data.data || data.xml || seg.data || "").trim());
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return parts.filter(Boolean).join(" ");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function extractNoticeText(notice: any): string {
|
|
546
|
+
const values = [
|
|
547
|
+
notice?.message,
|
|
548
|
+
notice?.text,
|
|
549
|
+
notice?.content,
|
|
550
|
+
notice?.title,
|
|
551
|
+
notice?.notice?.message,
|
|
552
|
+
notice?.notice?.content,
|
|
553
|
+
notice?.msg,
|
|
554
|
+
notice?.raw_message,
|
|
555
|
+
extractMessageSegmentsText(notice?.message),
|
|
556
|
+
];
|
|
557
|
+
return values
|
|
558
|
+
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
|
559
|
+
.filter(Boolean)
|
|
560
|
+
.join("\n");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function extractNoticeSender(notice: any): {
|
|
564
|
+
userId: number;
|
|
565
|
+
userName: string;
|
|
566
|
+
} {
|
|
567
|
+
const userId = Number(
|
|
568
|
+
notice?.user_id ||
|
|
569
|
+
notice?.sender_id ||
|
|
570
|
+
notice?.operator_id ||
|
|
571
|
+
notice?.poster_id ||
|
|
572
|
+
notice?.publisher_id ||
|
|
573
|
+
notice?.sender?.user_id ||
|
|
574
|
+
0,
|
|
575
|
+
);
|
|
576
|
+
const userName =
|
|
577
|
+
notice?.sender?.card ||
|
|
578
|
+
notice?.sender?.nickname ||
|
|
579
|
+
notice?.sender_name ||
|
|
580
|
+
notice?.nickname ||
|
|
581
|
+
notice?.publisher_name ||
|
|
582
|
+
notice?.operator_name ||
|
|
583
|
+
(userId ? String(userId) : "unknown");
|
|
584
|
+
return {
|
|
585
|
+
userId: Number.isFinite(userId) ? userId : 0,
|
|
586
|
+
userName: String(userName),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function normalizeNoticeTimestamp(notice: any): number {
|
|
591
|
+
const raw =
|
|
592
|
+
notice?.publish_time ||
|
|
593
|
+
notice?.time ||
|
|
594
|
+
notice?.created_at ||
|
|
595
|
+
notice?.create_time ||
|
|
596
|
+
Date.now();
|
|
597
|
+
const numeric = Number(raw);
|
|
598
|
+
if (!Number.isFinite(numeric)) return Date.now();
|
|
599
|
+
return numeric < 10_000_000_000 ? numeric * 1000 : numeric;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function normalizeSummary(value: string | null | undefined): string {
|
|
603
|
+
return String(value || "")
|
|
604
|
+
.replace(/\s+/g, " ")
|
|
605
|
+
.trim()
|
|
606
|
+
.slice(0, 500);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function truncateText(value: string, maxLength: number): string {
|
|
610
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function downloadVideoForAnalysis(
|
|
614
|
+
sources: string | string[],
|
|
615
|
+
options: HistoryMediaProcessingOptions,
|
|
616
|
+
): Promise<{
|
|
617
|
+
path: string;
|
|
618
|
+
source: string;
|
|
619
|
+
contentHash: string;
|
|
620
|
+
cleanup: () => Promise<void>;
|
|
621
|
+
}> {
|
|
622
|
+
const candidates = normalizeVideoSources(sources);
|
|
623
|
+
let lastError: unknown;
|
|
624
|
+
|
|
625
|
+
for (const source of candidates) {
|
|
626
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mioku-video-src-"));
|
|
627
|
+
const filePath = path.join(tempDir, "video");
|
|
628
|
+
|
|
629
|
+
try {
|
|
630
|
+
const buffer = await readVideoSource(source);
|
|
631
|
+
await fs.writeFile(filePath, buffer);
|
|
632
|
+
return {
|
|
633
|
+
path: filePath,
|
|
634
|
+
source,
|
|
635
|
+
contentHash: hashSource(buffer),
|
|
636
|
+
cleanup: () => fs.rm(tempDir, { recursive: true, force: true }),
|
|
637
|
+
};
|
|
638
|
+
} catch (err) {
|
|
639
|
+
lastError = err;
|
|
640
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
641
|
+
getHistoryMediaLogger(options).warn(
|
|
642
|
+
`[history-media] Failed to read video source ${source}: ${err}`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
throw lastError || new Error("no video source available");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function readVideoSource(source: string): Promise<Buffer> {
|
|
651
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
652
|
+
const response = await fetch(source, {
|
|
653
|
+
headers: {
|
|
654
|
+
"User-Agent":
|
|
655
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
656
|
+
Referer: "https://qq.com/",
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
if (!response.ok) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
`download failed: ${response.status} ${response.statusText}`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
return Buffer.from(await response.arrayBuffer());
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (source.startsWith("file://")) {
|
|
668
|
+
const url = new URL(source);
|
|
669
|
+
return fs.readFile(
|
|
670
|
+
decodeURIComponent(url.pathname).replace(/^\/([a-z]:[\\/])/i, "$1"),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return fs.readFile(source);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function normalizeVideoSources(input: string | string[]): string[] {
|
|
678
|
+
const raw = Array.isArray(input) ? input : [input];
|
|
679
|
+
return Array.from(
|
|
680
|
+
new Set(
|
|
681
|
+
raw
|
|
682
|
+
.map((source) => String(source || "").trim())
|
|
683
|
+
.filter(Boolean),
|
|
684
|
+
),
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function normalizeContentForHash(value: string): string {
|
|
689
|
+
const trimmed = value.trim();
|
|
690
|
+
if (!trimmed) return "";
|
|
691
|
+
|
|
692
|
+
const json = tryParseJson(trimmed);
|
|
693
|
+
if (json.parsed) {
|
|
694
|
+
return stableStringify(json.value);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return trimmed.replace(/\s+/g, " ");
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function extractCardPrompt(source: string): string | null {
|
|
701
|
+
const parsed = tryParseJson(source);
|
|
702
|
+
if (!parsed.parsed) return null;
|
|
703
|
+
const prompt = parsed.value?.prompt;
|
|
704
|
+
return typeof prompt === "string" && prompt.trim() ? prompt.trim() : null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function tryParseJson(
|
|
708
|
+
value: string,
|
|
709
|
+
): { parsed: true; value: any } | { parsed: false } {
|
|
710
|
+
try {
|
|
711
|
+
return { parsed: true, value: JSON.parse(value) };
|
|
712
|
+
} catch {
|
|
713
|
+
return { parsed: false };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function stableStringify(value: any): string {
|
|
718
|
+
if (value === null || typeof value !== "object") {
|
|
719
|
+
return JSON.stringify(value);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (Array.isArray(value)) {
|
|
723
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const entries = Object.keys(value)
|
|
727
|
+
.sort()
|
|
728
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
|
|
729
|
+
return `{${entries.join(",")}}`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function hashSource(value: string | Buffer): string {
|
|
733
|
+
return createHash("sha256").update(value).digest("hex");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function getCachedSummaryByHash(
|
|
737
|
+
kind: MediaSummaryKind,
|
|
738
|
+
contentHash: string,
|
|
739
|
+
options: HistoryMediaProcessingOptions,
|
|
740
|
+
): string | null {
|
|
741
|
+
const cached = options.db?.getMediaSummary(`${kind}:${contentHash}`);
|
|
742
|
+
if (
|
|
743
|
+
kind === "video" &&
|
|
744
|
+
cached?.summary &&
|
|
745
|
+
isFallbackVideoSummary(cached.summary)
|
|
746
|
+
) {
|
|
747
|
+
warnIfFallbackVideoSummary(options, "content hash cache", cached.summary);
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
return cached?.summary || null;
|
|
751
|
+
}
|