zapry-openclaw-plugin 0.0.1

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/src/inbound.ts ADDED
@@ -0,0 +1,3494 @@
1
+ import { ZapryApiClient } from "./api-client.js";
2
+ import { getZapryRuntime, runWithZaprySkillInvocationContext } from "./runtime.js";
3
+ import { sendMessageZapry } from "./send.js";
4
+ import type { ResolvedZapryAccount } from "./types.js";
5
+ import { randomUUID } from "node:crypto";
6
+ import { spawn } from "node:child_process";
7
+ import { promises as fs } from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ type RuntimeLog = {
12
+ info?: (...args: unknown[]) => void;
13
+ warn?: (...args: unknown[]) => void;
14
+ error?: (...args: unknown[]) => void;
15
+ debug?: (...args: unknown[]) => void;
16
+ };
17
+
18
+ type StatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
19
+
20
+ function buildSkillInvocationHeaders(input: {
21
+ senderId?: string;
22
+ messageSid?: string;
23
+ }): Record<string, string> {
24
+ const senderId = String(input.senderId ?? "").trim();
25
+ if (!senderId) {
26
+ throw new Error("missing trusted sender_id for Zapry skill invocation");
27
+ }
28
+
29
+ const headers: Record<string, string> = {
30
+ "X-Zapry-Invocation-Source": "skill",
31
+ "X-Zapry-Request-Sender-Id": senderId,
32
+ };
33
+
34
+ const messageSid = String(input.messageSid ?? "").trim();
35
+ if (messageSid) {
36
+ headers["X-Zapry-Message-Sid"] = messageSid;
37
+ }
38
+
39
+ return headers;
40
+ }
41
+
42
+ type MuteCommandIntent = "mute" | "unmute";
43
+
44
+ type ProcessInboundParams = {
45
+ account: ResolvedZapryAccount;
46
+ cfg?: any;
47
+ runtime?: any;
48
+ update: any;
49
+ statusSink?: StatusSink;
50
+ log?: RuntimeLog;
51
+ };
52
+
53
+ type ParsedInboundMediaKind =
54
+ | "photo"
55
+ | "video"
56
+ | "document"
57
+ | "audio"
58
+ | "voice"
59
+ | "animation";
60
+
61
+ type ParsedInboundMediaItem = {
62
+ kind: ParsedInboundMediaKind;
63
+ fileId?: string;
64
+ fileUniqueId?: string;
65
+ url?: string;
66
+ mimeType?: string;
67
+ fileName?: string;
68
+ fileSize?: number;
69
+ width?: number;
70
+ height?: number;
71
+ duration?: number;
72
+ thumbFileId?: string;
73
+ thumbUrl?: string;
74
+ resolvedFile?: ResolvedInboundFile;
75
+ resolvedThumbFile?: ResolvedInboundFile;
76
+ stagedPath?: string;
77
+ stagedMimeType?: string;
78
+ stageError?: string;
79
+ transcript?: string;
80
+ transcriptError?: string;
81
+ sourceTag?: "video-thumb" | "video-keyframe";
82
+ };
83
+
84
+ type RecentContextItem = {
85
+ message_id: string;
86
+ sender_id: string;
87
+ sender_name?: string;
88
+ type: string;
89
+ text?: string;
90
+ media_url?: string;
91
+ file_id?: string;
92
+ timestamp: number;
93
+ };
94
+
95
+ type EnrichedReplyInfo = {
96
+ messageId?: string;
97
+ text?: string;
98
+ mediaUrl?: string;
99
+ mediaType?: string;
100
+ senderId?: string;
101
+ fileId?: string;
102
+ };
103
+
104
+ type ParsedInboundMessage = {
105
+ sourceText?: string;
106
+ mediaItems: ParsedInboundMediaItem[];
107
+ senderId: string;
108
+ senderName?: string;
109
+ targetUserHints: InboundTargetUserHint[];
110
+ chatId: string;
111
+ chatType?: string;
112
+ isGroup: boolean;
113
+ messageSid: string;
114
+ timestampMs?: number;
115
+ recentContext?: RecentContextItem[];
116
+ enrichedReply?: EnrichedReplyInfo;
117
+ };
118
+
119
+ type InboundTargetUserHint = {
120
+ userId: string;
121
+ username?: string;
122
+ displayName?: string;
123
+ source: "reply_to_message" | "text_mention" | "mention_entity" | "mentioned_users";
124
+ raw?: string;
125
+ };
126
+
127
+ type GroupMemberCandidate = {
128
+ userId: string;
129
+ displayName: string;
130
+ username?: string;
131
+ nick?: string;
132
+ groupNick?: string;
133
+ };
134
+
135
+ type PendingMuteConfirmation = {
136
+ chatId: string;
137
+ senderId: string;
138
+ intent: MuteCommandIntent;
139
+ target: InboundTargetUserHint;
140
+ createdAtMs: number;
141
+ };
142
+
143
+ type ResolvedInboundFile = {
144
+ fileId: string;
145
+ downloadUrl?: string;
146
+ downloadMethod?: string;
147
+ downloadHeaders?: Record<string, string>;
148
+ expiresAt?: string;
149
+ contentType?: string;
150
+ fileSize?: number;
151
+ fileName?: string;
152
+ error?: string;
153
+ };
154
+
155
+ const INBOUND_FILE_RESOLVE_TIMEOUT_MS = 8000;
156
+ const INBOUND_MEDIA_STAGE_TIMEOUT_MS = 15000;
157
+ const INBOUND_AUDIO_TRANSCRIBE_TIMEOUT_MS = 20000;
158
+ const INBOUND_VIDEO_POSTER_TIMEOUT_MS = 12000;
159
+ const INBOUND_VIDEO_KEYFRAME_TIMEOUT_MS = 30000;
160
+ const INBOUND_VIDEO_MAX_KEYFRAMES = 8;
161
+ const PENDING_MUTE_CONFIRMATION_TTL_MS = 3 * 60 * 1000;
162
+ const GROUP_MEMBERS_QUICK_REPLY_LIMIT = 12;
163
+ const INBOUND_MEDIA_MAX_BYTES_BY_KIND: Record<ParsedInboundMediaKind, number> = {
164
+ photo: 25 * 1024 * 1024,
165
+ video: 100 * 1024 * 1024,
166
+ document: 50 * 1024 * 1024,
167
+ audio: 25 * 1024 * 1024,
168
+ voice: 25 * 1024 * 1024,
169
+ animation: 25 * 1024 * 1024,
170
+ };
171
+ const pendingMuteConfirmations = new Map<string, PendingMuteConfirmation>();
172
+
173
+ function looksLikeHttpUrl(value: string): boolean {
174
+ return /^https?:\/\//i.test(value);
175
+ }
176
+
177
+ function asNonEmptyString(value: unknown): string | undefined {
178
+ if (typeof value !== "string") {
179
+ return undefined;
180
+ }
181
+ const trimmed = value.trim();
182
+ return trimmed.length > 0 ? trimmed : undefined;
183
+ }
184
+
185
+ function sameUserIdentity(left: string | undefined, right: string | undefined): boolean {
186
+ const normalizedLeft = String(left ?? "").trim();
187
+ const normalizedRight = String(right ?? "").trim();
188
+ if (!normalizedLeft || !normalizedRight) {
189
+ return false;
190
+ }
191
+ if (normalizedLeft === normalizedRight) {
192
+ return true;
193
+ }
194
+ const leftNum = Number(normalizedLeft);
195
+ const rightNum = Number(normalizedRight);
196
+ return Number.isFinite(leftNum) && Number.isFinite(rightNum) && leftNum === rightNum;
197
+ }
198
+
199
+ function resolveOwnerIdFromBotToken(botToken: string | undefined): string {
200
+ const trimmed = String(botToken ?? "").trim();
201
+ const separatorIdx = trimmed.indexOf(":");
202
+ if (separatorIdx <= 0) {
203
+ return "";
204
+ }
205
+ return trimmed.slice(0, separatorIdx).trim();
206
+ }
207
+
208
+ function buildNonOwnerSkillReplyGuidance(senderIsOwner: boolean): string {
209
+ if (senderIsOwner) {
210
+ return "";
211
+ }
212
+ return [
213
+ "权限规则:当前发起消息的人不是这个机器人的主人。",
214
+ "如果用户要求你调用任何 Zapry 平台能力,例如查询好友列表、查询群/聊天记录、发帖、改名、加好友、群管理、Webhook、资料或技能设置等,你禁止调用 `zapry_action` 或 `zapry_post`。",
215
+ "遇到这类请求时,你必须直接回复且只回复:只能是主人才可以调用",
216
+ "普通闲聊、解释说明、媒体内容理解仍然可以正常回答。",
217
+ ].join("\n");
218
+ }
219
+
220
+ function appendGuidanceBlock(body: string, guidance: string): string {
221
+ const normalizedBody = body.trim();
222
+ const normalizedGuidance = guidance.trim();
223
+ if (!normalizedGuidance) {
224
+ return normalizedBody;
225
+ }
226
+ if (!normalizedBody) {
227
+ return normalizedGuidance;
228
+ }
229
+ return `${normalizedBody}\n\n${normalizedGuidance}`;
230
+ }
231
+
232
+ function parseTimestampMs(value: unknown): number | undefined {
233
+ if (typeof value === "number" && Number.isFinite(value)) {
234
+ // Zapry/Telegram-like payloads usually use second-level timestamps.
235
+ return value < 1_000_000_000_000 ? value * 1000 : value;
236
+ }
237
+ if (typeof value === "string" && value.trim()) {
238
+ const num = Number(value);
239
+ if (Number.isFinite(num)) {
240
+ return num < 1_000_000_000_000 ? num * 1000 : num;
241
+ }
242
+ }
243
+ return undefined;
244
+ }
245
+
246
+ function parseFiniteNumber(value: unknown): number | undefined {
247
+ if (typeof value === "number" && Number.isFinite(value)) {
248
+ return value;
249
+ }
250
+ if (typeof value === "string" && value.trim()) {
251
+ const num = Number(value);
252
+ if (Number.isFinite(num)) {
253
+ return num;
254
+ }
255
+ }
256
+ return undefined;
257
+ }
258
+
259
+ function asNonNegativeInteger(value: unknown): number | undefined {
260
+ const num = parseFiniteNumber(value);
261
+ if (num === undefined) {
262
+ return undefined;
263
+ }
264
+ const int = Math.floor(num);
265
+ return int >= 0 ? int : undefined;
266
+ }
267
+
268
+ function extractTextByEntityRange(
269
+ text: string | undefined,
270
+ offset: unknown,
271
+ length: unknown,
272
+ ): string | undefined {
273
+ if (!text) {
274
+ return undefined;
275
+ }
276
+ const start = asNonNegativeInteger(offset);
277
+ const size = asNonNegativeInteger(length);
278
+ if (start === undefined || size === undefined || size <= 0 || start >= text.length) {
279
+ return undefined;
280
+ }
281
+ const end = Math.min(text.length, start + size);
282
+ return asNonEmptyString(text.slice(start, end));
283
+ }
284
+
285
+ function asRecord(value: unknown): Record<string, unknown> | null {
286
+ if (!value || typeof value !== "object") {
287
+ return null;
288
+ }
289
+ return value as Record<string, unknown>;
290
+ }
291
+
292
+ function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
293
+ const record = asRecord(value);
294
+ if (!record) {
295
+ return undefined;
296
+ }
297
+
298
+ const normalized = Object.entries(record).reduce<Record<string, string>>((acc, [key, rawValue]) => {
299
+ if (typeof rawValue === "string" && rawValue.trim()) {
300
+ acc[key] = rawValue;
301
+ }
302
+ return acc;
303
+ }, {});
304
+
305
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
306
+ }
307
+
308
+ function describeMediaKinds(mediaItems: ParsedInboundMediaItem[]): string {
309
+ const labelsByKind: Record<ParsedInboundMediaKind, string> = {
310
+ photo: "图片",
311
+ video: "视频",
312
+ document: "文件",
313
+ audio: "音频",
314
+ voice: "语音",
315
+ animation: "动图",
316
+ };
317
+
318
+ const labels = Array.from(new Set(mediaItems.map((item) => labelsByKind[item.kind])));
319
+ return labels.join("、");
320
+ }
321
+
322
+ function isAudioLikeMedia(item: ParsedInboundMediaItem): boolean {
323
+ return item.kind === "audio" || item.kind === "voice";
324
+ }
325
+
326
+ function isExplicitTranscriptRequest(text: string | undefined): boolean {
327
+ const normalized = text?.trim().toLowerCase();
328
+ if (!normalized) {
329
+ return false;
330
+ }
331
+ return [
332
+ "转写",
333
+ "转文字",
334
+ "转成文字",
335
+ "听写",
336
+ "逐字",
337
+ "逐字稿",
338
+ "原话",
339
+ "原文",
340
+ "我说了什么",
341
+ "我发送了什么语音",
342
+ "给我转文字",
343
+ "帮我转文字",
344
+ "transcribe",
345
+ "transcript",
346
+ "verbatim",
347
+ ].some((token) => normalized.includes(token));
348
+ }
349
+
350
+ function resolveCommandBody(
351
+ sourceText: string | undefined,
352
+ mediaItems: ParsedInboundMediaItem[],
353
+ transcript?: string,
354
+ targetUserHints: InboundTargetUserHint[] = [],
355
+ ): string {
356
+ const hasMedia = mediaItems.length > 0;
357
+ const hasStagedMedia = mediaItems.some((item) => Boolean(item.stagedPath));
358
+ const hasAudioLikeMedia = mediaItems.some((item) => isAudioLikeMedia(item));
359
+ const hasOnlyAudioLikeMedia = mediaItems.length > 0 && mediaItems.every((item) => isAudioLikeMedia(item));
360
+ const stageErrors = mediaItems
361
+ .map((item) => item.stageError)
362
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0);
363
+ if (hasMedia && !hasStagedMedia && stageErrors.length > 0) {
364
+ const normalizedText = sourceText?.trim();
365
+ const mediaKindText = describeMediaKinds(mediaItems) || "媒体";
366
+ if (normalizedText) {
367
+ return `用户请求:${normalizedText}\n但当前${mediaKindText}下载失败。禁止根据历史上下文猜测媒体内容,只能说明当前无法读取该媒体,并建议用户稍后重试或改用文件/原图方式发送。`;
368
+ }
369
+ return `当前${mediaKindText}下载失败。禁止根据历史上下文猜测媒体内容,只能说明当前无法读取该媒体,并建议用户稍后重试或改用文件/原图方式发送。`;
370
+ }
371
+
372
+ const normalizedText = sourceText?.trim();
373
+ const transcriptRequested = isExplicitTranscriptRequest(normalizedText);
374
+ const moderationIntent = isLikelyModerationIntent(normalizedText);
375
+ const moderationGuidance = (() => {
376
+ if (!moderationIntent) {
377
+ return "";
378
+ }
379
+ const lines: string[] = [
380
+ "群管理执行规则:",
381
+ "- `mute-chat-member` 仅支持 `mute=true/false`,不支持 `until_date` 或时长参数。",
382
+ "- 禁止向用户提供“10分钟/1小时/24小时/永久”等时长选项。",
383
+ ];
384
+ if (targetUserHints.length === 1) {
385
+ lines.push(`- 已解析目标用户 user_id=${targetUserHints[0].userId},可直接执行。`);
386
+ } else if (targetUserHints.length > 1) {
387
+ lines.push("- 已解析到多个候选 user_id,请按用户提及对象选择最匹配的目标:");
388
+ for (const hint of targetUserHints.slice(0, 5)) {
389
+ lines.push(` - ${summarizeTargetUserHint(hint)}`);
390
+ }
391
+ } else {
392
+ lines.push("- 当前未解析到目标 user_id,优先让用户 @目标用户 或回复目标用户消息。");
393
+ }
394
+ return lines.join("\n");
395
+ })();
396
+ const audioGenerationIntent = isLikelyAudioGenerationIntent(normalizedText);
397
+ const audioGenerationGuidance = (() => {
398
+ if (!audioGenerationIntent) {
399
+ return "";
400
+ }
401
+ return [
402
+ "音频生成执行规则:",
403
+ "- 用户要求生成音频/MP3/铃声时,优先调用 `generate-audio`,不要只发口头承诺。",
404
+ "- 在 `generate-audio` 成功前,禁止说“我现在开始制作并几分钟后发你 MP3”。",
405
+ "- 失败时只允许给简短失败说明并建议重试,禁止承诺稍后补发。",
406
+ ].join("\n");
407
+ })();
408
+ const extraGuidance = [moderationGuidance, audioGenerationGuidance].filter(Boolean).join("\n");
409
+ if (normalizedText) {
410
+ if (hasAudioLikeMedia) {
411
+ if (transcript?.trim()) {
412
+ if (transcriptRequested) {
413
+ return `用户要求:${normalizedText}\n当前轮真实转写文本:${transcript}\n用户明确要求转写/转文字时,优先直接输出上述真实转写文本;禁止根据历史上下文或主观猜测改写内容。${extraGuidance ? `\n${extraGuidance}` : ""}`;
414
+ }
415
+ return `用户要求:${normalizedText}\n当前轮真实转写文本(仅供理解,不要默认回显给用户):${transcript}\n请把该转写视为用户本轮真实语音输入,直接回答用户意图;除非用户明确要求转写/逐字稿,否则不要回显转写文本。${extraGuidance ? `\n${extraGuidance}` : ""}`;
416
+ }
417
+ return `${normalizedText}\n当前轮语音/音频尚未拿到真实转写文本。禁止根据用户追问文本、历史上下文或主观猜测编造语音内容;只能明确说明当前无法完成真实转写。${extraGuidance ? `\n${extraGuidance}` : ""}`;
418
+ }
419
+ return extraGuidance ? `${normalizedText}\n${extraGuidance}` : normalizedText;
420
+ }
421
+ if (!mediaItems.length) {
422
+ return "";
423
+ }
424
+ const mediaKindText = describeMediaKinds(mediaItems) || "媒体";
425
+ if (hasOnlyAudioLikeMedia) {
426
+ if (transcript?.trim()) {
427
+ return `请直接处理这条${mediaKindText}消息。当前轮真实转写文本(仅供理解,不要默认回显给用户):${transcript}\n把这段转写视为用户本轮真实输入,直接像普通聊天一样回复其意图;除非用户明确要求转写/逐字稿,否则不要输出转写文本本身。`;
428
+ }
429
+ return `请直接处理这条${mediaKindText}消息。当前轮还没有真实转写文本,禁止根据历史上下文或主观猜测编造音频内容;只能明确说明当前无法完成真实转写,并请求用户重发。`;
430
+ }
431
+ return `请直接查看并分析这条${mediaKindText}消息,优先使用已提供的 file_id 与已附加媒体内容,不要先询问是否需要解析。`;
432
+ }
433
+
434
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
435
+ return new Promise<T>((resolve, reject) => {
436
+ const timer = setTimeout(() => {
437
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
438
+ }, timeoutMs);
439
+
440
+ promise.then(
441
+ (value) => {
442
+ clearTimeout(timer);
443
+ resolve(value);
444
+ },
445
+ (error) => {
446
+ clearTimeout(timer);
447
+ reject(error);
448
+ },
449
+ );
450
+ });
451
+ }
452
+
453
+ function resolveInboundMediaMaxBytes(item: ParsedInboundMediaItem): number {
454
+ const baseline = INBOUND_MEDIA_MAX_BYTES_BY_KIND[item.kind];
455
+ if (item.fileSize !== undefined && item.fileSize > 0) {
456
+ return Math.max(baseline, item.fileSize + 1024 * 1024);
457
+ }
458
+ return baseline;
459
+ }
460
+
461
+ function resolveInboundMediaRuntime(runtime: any): {
462
+ fetchRemoteMedia: (opts: {
463
+ url: string;
464
+ maxBytes?: number;
465
+ requestInit?: RequestInit;
466
+ filePathHint?: string;
467
+ }) => Promise<{ buffer: any; contentType?: string; fileName?: string }>;
468
+ saveMediaBuffer: (
469
+ buffer: any,
470
+ contentType?: string,
471
+ subdir?: string,
472
+ maxBytes?: number,
473
+ originalFilename?: string,
474
+ ) => Promise<{ path: string; contentType?: string }>;
475
+ } | null {
476
+ const fetchRemoteMedia = runtime?.channel?.media?.fetchRemoteMedia;
477
+ const saveMediaBuffer = runtime?.channel?.media?.saveMediaBuffer;
478
+ if (typeof fetchRemoteMedia !== "function" || typeof saveMediaBuffer !== "function") {
479
+ return null;
480
+ }
481
+ return { fetchRemoteMedia, saveMediaBuffer };
482
+ }
483
+
484
+ function sanitizeResolvedInboundFileForAgent(file: ResolvedInboundFile | undefined): ResolvedInboundFile | undefined {
485
+ if (!file) {
486
+ return undefined;
487
+ }
488
+ return {
489
+ fileId: file.fileId,
490
+ contentType: file.contentType,
491
+ fileSize: file.fileSize,
492
+ fileName: file.fileName,
493
+ error: file.error,
494
+ };
495
+ }
496
+
497
+ function sanitizeMediaItemsForAgent(mediaItems: ParsedInboundMediaItem[]): ParsedInboundMediaItem[] {
498
+ return mediaItems.map((item) => ({
499
+ ...item,
500
+ url: item.fileId ? undefined : item.url,
501
+ thumbUrl: item.thumbFileId ? undefined : item.thumbUrl,
502
+ resolvedFile: sanitizeResolvedInboundFileForAgent(item.resolvedFile),
503
+ resolvedThumbFile: sanitizeResolvedInboundFileForAgent(item.resolvedThumbFile),
504
+ stagedPath: undefined,
505
+ stagedMimeType: undefined,
506
+ }));
507
+ }
508
+
509
+ function appendVideoThumbnailMediaItems(mediaItems: ParsedInboundMediaItem[]): ParsedInboundMediaItem[] {
510
+ if (!mediaItems.length) {
511
+ return mediaItems;
512
+ }
513
+
514
+ const expanded: ParsedInboundMediaItem[] = [];
515
+ const seen = new Set<string>();
516
+ const dedupeKey = (item: ParsedInboundMediaItem): string =>
517
+ `${item.kind}|${item.fileId ?? ""}|${item.url ?? ""}|${item.sourceTag ?? ""}`;
518
+
519
+ const appendOnce = (item: ParsedInboundMediaItem): void => {
520
+ const key = dedupeKey(item);
521
+ if (seen.has(key)) {
522
+ return;
523
+ }
524
+ seen.add(key);
525
+ expanded.push(item);
526
+ };
527
+
528
+ for (const item of mediaItems) {
529
+ appendOnce(item);
530
+ if (item.kind !== "video") {
531
+ continue;
532
+ }
533
+
534
+ const thumbUrl = item.resolvedThumbFile?.downloadUrl ?? item.thumbUrl;
535
+ const thumbFileId = item.thumbFileId;
536
+ if (!thumbUrl && !thumbFileId) {
537
+ continue;
538
+ }
539
+
540
+ const thumbMime = item.resolvedThumbFile?.contentType;
541
+ const fallbackName =
542
+ item.resolvedThumbFile?.fileName ??
543
+ (thumbFileId ? `video-thumb-${thumbFileId}.jpg` : undefined);
544
+ appendOnce({
545
+ kind: "photo",
546
+ fileId: thumbFileId,
547
+ url: thumbUrl,
548
+ mimeType: thumbMime,
549
+ fileName: fallbackName,
550
+ fileSize: item.resolvedThumbFile?.fileSize,
551
+ resolvedFile: item.resolvedThumbFile,
552
+ sourceTag: "video-thumb",
553
+ });
554
+ }
555
+
556
+ return expanded;
557
+ }
558
+
559
+ async function runProcessWithTimeout(command: string, args: string[], timeoutMs: number): Promise<void> {
560
+ await new Promise<void>((resolve, reject) => {
561
+ const child = spawn(command, args, {
562
+ stdio: ["ignore", "ignore", "pipe"],
563
+ });
564
+
565
+ let stderr = "";
566
+ child.stderr?.setEncoding("utf8");
567
+ child.stderr?.on("data", (chunk: string) => {
568
+ stderr += String(chunk);
569
+ });
570
+
571
+ const timer = setTimeout(() => {
572
+ child.kill("SIGKILL");
573
+ reject(new Error(`${command} timed out after ${timeoutMs}ms`));
574
+ }, timeoutMs);
575
+
576
+ child.once("error", (err: Error) => {
577
+ clearTimeout(timer);
578
+ reject(err);
579
+ });
580
+
581
+ child.once("exit", (code: number | null) => {
582
+ clearTimeout(timer);
583
+ if (code === 0) {
584
+ resolve();
585
+ return;
586
+ }
587
+ reject(new Error(`${command} failed (${code}): ${stderr.trim() || "no stderr"}`));
588
+ });
589
+ });
590
+ }
591
+
592
+ async function extractVideoPosterPngBuffer(videoPath: string, log?: RuntimeLog): Promise<Buffer | null> {
593
+ if (process.platform !== "darwin") {
594
+ return null;
595
+ }
596
+
597
+ const outputDir = path.join(os.tmpdir(), `openclaw-zapry-video-thumb-${randomUUID()}`);
598
+ try {
599
+ await fs.mkdir(outputDir, { recursive: true });
600
+ await runProcessWithTimeout(
601
+ "/usr/bin/qlmanage",
602
+ ["-t", "-s", "1024", "-o", outputDir, videoPath],
603
+ INBOUND_VIDEO_POSTER_TIMEOUT_MS,
604
+ );
605
+
606
+ const files = await fs.readdir(outputDir);
607
+ const posterFile = files.find((f: string) => f.toLowerCase().endsWith(".png"));
608
+ if (!posterFile) {
609
+ return null;
610
+ }
611
+
612
+ return await fs.readFile(path.join(outputDir, posterFile));
613
+ } catch (error) {
614
+ log?.debug?.(`[zapry] generate video poster failed for ${videoPath}: ${String(error)}`);
615
+ return null;
616
+ } finally {
617
+ await fs.rm(outputDir, { recursive: true, force: true }).catch(() => {});
618
+ }
619
+ }
620
+
621
+ async function getVideoDurationSeconds(videoPath: string): Promise<number | null> {
622
+ return new Promise<number | null>((resolve) => {
623
+ const child = spawn("ffprobe", [
624
+ "-v", "error",
625
+ "-show_entries", "format=duration",
626
+ "-of", "default=noprint_wrappers=1:nokey=1",
627
+ videoPath,
628
+ ], { stdio: ["ignore", "pipe", "ignore"] });
629
+
630
+ let stdout = "";
631
+ child.stdout.setEncoding("utf8");
632
+ child.stdout.on("data", (chunk: string) => { stdout += chunk; });
633
+
634
+ const timer = setTimeout(() => {
635
+ child.kill("SIGKILL");
636
+ resolve(null);
637
+ }, 8000);
638
+
639
+ child.once("error", () => { clearTimeout(timer); resolve(null); });
640
+ child.once("exit", (code: number | null) => {
641
+ clearTimeout(timer);
642
+ if (code !== 0) { resolve(null); return; }
643
+ const dur = parseFloat(stdout.trim());
644
+ resolve(Number.isFinite(dur) && dur > 0 ? dur : null);
645
+ });
646
+ });
647
+ }
648
+
649
+ function computeKeyframeTimestamps(durationSec: number): number[] {
650
+ let count: number;
651
+ if (durationSec < 3) count = 2;
652
+ else if (durationSec < 10) count = 3;
653
+ else if (durationSec < 30) count = 5;
654
+ else if (durationSec < 90) count = 6;
655
+ else count = INBOUND_VIDEO_MAX_KEYFRAMES;
656
+
657
+ count = Math.min(count, INBOUND_VIDEO_MAX_KEYFRAMES);
658
+ const step = durationSec / (count + 1);
659
+ const timestamps: number[] = [];
660
+ for (let i = 1; i <= count; i++) {
661
+ timestamps.push(Math.round(step * i * 100) / 100);
662
+ }
663
+ return timestamps;
664
+ }
665
+
666
+ async function extractSingleFrameJpeg(
667
+ videoPath: string,
668
+ timestampSec: number,
669
+ outputPath: string,
670
+ timeoutMs: number,
671
+ ): Promise<boolean> {
672
+ try {
673
+ await runProcessWithTimeout(
674
+ "ffmpeg",
675
+ ["-ss", String(timestampSec), "-i", videoPath, "-frames:v", "1", "-q:v", "3", "-f", "image2", "-y", outputPath],
676
+ timeoutMs,
677
+ );
678
+ const stat = await fs.stat(outputPath);
679
+ return stat.size > 0;
680
+ } catch {
681
+ return false;
682
+ }
683
+ }
684
+
685
+ async function extractVideoKeyframeBuffers(
686
+ videoPath: string,
687
+ log?: RuntimeLog,
688
+ ): Promise<{ buffer: Buffer; timestampSec: number }[]> {
689
+ const duration = await getVideoDurationSeconds(videoPath);
690
+ if (!duration) {
691
+ const poster = await extractVideoPosterPngBuffer(videoPath, log);
692
+ return poster ? [{ buffer: poster, timestampSec: 0 }] : [];
693
+ }
694
+
695
+ const timestamps = computeKeyframeTimestamps(duration);
696
+ const outputDir = path.join(os.tmpdir(), `openclaw-zapry-keyframes-${randomUUID()}`);
697
+ try {
698
+ await fs.mkdir(outputDir, { recursive: true });
699
+ const perFrameTimeout = Math.max(5000, Math.floor(INBOUND_VIDEO_KEYFRAME_TIMEOUT_MS / timestamps.length));
700
+
701
+ const framePromises = timestamps.map(async (ts, index) => {
702
+ const outPath = path.join(outputDir, `frame-${String(index).padStart(3, "0")}.jpg`);
703
+ const ok = await extractSingleFrameJpeg(videoPath, ts, outPath, perFrameTimeout);
704
+ if (!ok) return null;
705
+ try {
706
+ const buf = await fs.readFile(outPath);
707
+ return buf.length > 0 ? { buffer: buf, timestampSec: ts } : null;
708
+ } catch { return null; }
709
+ });
710
+
711
+ const results = (await Promise.all(framePromises)).filter(
712
+ (r): r is NonNullable<typeof r> => r !== null,
713
+ );
714
+
715
+ if (results.length > 0) return results;
716
+ const poster = await extractVideoPosterPngBuffer(videoPath, log);
717
+ return poster ? [{ buffer: poster, timestampSec: 0 }] : [];
718
+ } catch (error) {
719
+ log?.debug?.(`[zapry] extract video keyframes failed for ${videoPath}: ${String(error)}`);
720
+ const poster = await extractVideoPosterPngBuffer(videoPath, log);
721
+ return poster ? [{ buffer: poster, timestampSec: 0 }] : [];
722
+ } finally {
723
+ await fs.rm(outputDir, { recursive: true, force: true }).catch(() => {});
724
+ }
725
+ }
726
+
727
+ async function appendVideoKeyframeItems(
728
+ runtime: any,
729
+ mediaItems: ParsedInboundMediaItem[],
730
+ log?: RuntimeLog,
731
+ ): Promise<ParsedInboundMediaItem[]> {
732
+ if (!mediaItems.length) return mediaItems;
733
+
734
+ const mediaRuntime = resolveInboundMediaRuntime(runtime);
735
+ if (!mediaRuntime) return mediaItems;
736
+
737
+ const output = [...mediaItems];
738
+ for (const item of mediaItems) {
739
+ if (item.kind !== "video" || !item.stagedPath || item.stageError) continue;
740
+
741
+ const frames = await extractVideoKeyframeBuffers(item.stagedPath, log);
742
+ if (!frames.length) continue;
743
+
744
+ const baseName = path.parse(item.fileName ?? "video").name;
745
+ const isSinglePoster = frames.length === 1 && frames[0].timestampSec === 0;
746
+
747
+ for (const [idx, frame] of frames.entries()) {
748
+ const tag: "video-thumb" | "video-keyframe" = isSinglePoster ? "video-thumb" : "video-keyframe";
749
+ const mime = isSinglePoster ? "image/png" : "image/jpeg";
750
+ const ext = isSinglePoster ? "png" : "jpg";
751
+ const tsLabel = isSinglePoster ? "poster" : `at-${frame.timestampSec}s`;
752
+ const fileName = `${baseName}-keyframe-${idx + 1}-${tsLabel}.${ext}`;
753
+ try {
754
+ const saved = await withTimeout(
755
+ mediaRuntime.saveMediaBuffer(frame.buffer, mime, "inbound", INBOUND_MEDIA_MAX_BYTES_BY_KIND.photo, fileName),
756
+ INBOUND_MEDIA_STAGE_TIMEOUT_MS,
757
+ `save video keyframe ${idx + 1} of ${item.fileId ?? "video"}`,
758
+ );
759
+ output.push({
760
+ kind: "photo",
761
+ mimeType: saved.contentType ?? mime,
762
+ fileName,
763
+ stagedPath: saved.path,
764
+ stagedMimeType: saved.contentType ?? mime,
765
+ sourceTag: tag,
766
+ });
767
+ } catch (error) {
768
+ log?.debug?.(`[zapry] save keyframe ${idx + 1} failed for ${item.fileId ?? "video"}: ${String(error)}`);
769
+ }
770
+ }
771
+ }
772
+
773
+ return output;
774
+ }
775
+
776
+ async function appendVideoPosterFallbackItems(
777
+ runtime: any,
778
+ mediaItems: ParsedInboundMediaItem[],
779
+ log?: RuntimeLog,
780
+ ): Promise<ParsedInboundMediaItem[]> {
781
+ if (!mediaItems.length) {
782
+ return mediaItems;
783
+ }
784
+
785
+ const mediaRuntime = resolveInboundMediaRuntime(runtime);
786
+ if (!mediaRuntime) {
787
+ return mediaItems;
788
+ }
789
+
790
+ const output = [...mediaItems];
791
+ for (const item of mediaItems) {
792
+ if (item.kind !== "video" || !item.stagedPath || item.stageError) {
793
+ continue;
794
+ }
795
+
796
+ const hasRenderableThumb = output.some(
797
+ (candidate) =>
798
+ candidate.kind === "photo" &&
799
+ candidate.sourceTag === "video-thumb" &&
800
+ Boolean(candidate.stagedPath),
801
+ );
802
+ if (hasRenderableThumb) {
803
+ continue;
804
+ }
805
+
806
+ const posterBuffer = await extractVideoPosterPngBuffer(item.stagedPath, log);
807
+ if (!posterBuffer) {
808
+ continue;
809
+ }
810
+
811
+ try {
812
+ const saved = await withTimeout(
813
+ mediaRuntime.saveMediaBuffer(
814
+ posterBuffer,
815
+ "image/png",
816
+ "inbound",
817
+ INBOUND_MEDIA_MAX_BYTES_BY_KIND.photo,
818
+ `${path.parse(item.fileName ?? "video").name}-poster.png`,
819
+ ),
820
+ INBOUND_MEDIA_STAGE_TIMEOUT_MS,
821
+ `save inbound video poster ${item.fileId ?? "video"}`,
822
+ );
823
+ output.push({
824
+ kind: "photo",
825
+ mimeType: saved.contentType ?? "image/png",
826
+ fileName: `${path.parse(item.fileName ?? "video").name}-poster.png`,
827
+ stagedPath: saved.path,
828
+ stagedMimeType: saved.contentType ?? "image/png",
829
+ sourceTag: "video-thumb",
830
+ });
831
+ } catch (error) {
832
+ log?.debug?.(`[zapry] save generated video poster failed for ${item.fileId ?? "video"}: ${String(error)}`);
833
+ }
834
+ }
835
+
836
+ return output;
837
+ }
838
+
839
+ async function whisperTranscribeDirect(
840
+ filePath: string,
841
+ mime?: string,
842
+ log?: RuntimeLog,
843
+ ): Promise<string | undefined> {
844
+ const apiKey = process.env.OPENAI_API_KEY;
845
+ if (!apiKey) {
846
+ log?.warn?.("[zapry] STT direct: OPENAI_API_KEY not set");
847
+ return undefined;
848
+ }
849
+ const model = "whisper-1";
850
+ const fileBuffer = await fs.readFile(filePath);
851
+ const fileName = path.basename(filePath);
852
+ const boundary = `----FormBoundary${randomUUID().replace(/-/g, "")}`;
853
+ const parts: Buffer[] = [];
854
+ const enc = (s: string) => Buffer.from(s, "utf-8");
855
+
856
+ parts.push(enc(`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\n${model}\r\n`));
857
+ parts.push(enc(`--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nzh\r\n`));
858
+ parts.push(enc(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: ${mime || "audio/mp4"}\r\n\r\n`));
859
+ parts.push(fileBuffer);
860
+ parts.push(enc(`\r\n--${boundary}--\r\n`));
861
+
862
+ const body = Buffer.concat(parts);
863
+ const resp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
864
+ method: "POST",
865
+ headers: {
866
+ Authorization: `Bearer ${apiKey}`,
867
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
868
+ },
869
+ body,
870
+ });
871
+ if (!resp.ok) {
872
+ const errText = await resp.text().catch(() => "");
873
+ log?.warn?.(`[zapry] STT direct: API error ${resp.status}: ${errText.slice(0, 200)}`);
874
+ return undefined;
875
+ }
876
+ const json = (await resp.json()) as { text?: string };
877
+ return json.text?.trim() || undefined;
878
+ }
879
+
880
+ function resolveInboundSttRuntime(runtime: any):
881
+ | {
882
+ transcribeAudioFile: (params: {
883
+ filePath: string;
884
+ cfg: any;
885
+ agentDir?: string;
886
+ mime?: string;
887
+ }) => Promise<{ text?: string }>;
888
+ }
889
+ | null {
890
+ const coreTranscribe = runtime?.stt?.transcribeAudioFile;
891
+ if (typeof coreTranscribe === "function") {
892
+ return { transcribeAudioFile: coreTranscribe };
893
+ }
894
+ try {
895
+ const registrationRuntime = getZapryRuntime();
896
+ const fallback = registrationRuntime?.stt?.transcribeAudioFile;
897
+ if (typeof fallback === "function") {
898
+ return { transcribeAudioFile: fallback };
899
+ }
900
+ } catch {
901
+ // registration runtime not available
902
+ }
903
+ if (process.env.OPENAI_API_KEY) {
904
+ return {
905
+ transcribeAudioFile: async (params) => {
906
+ const text = await whisperTranscribeDirect(params.filePath, params.mime);
907
+ return { text };
908
+ },
909
+ };
910
+ }
911
+ return null;
912
+ }
913
+
914
+ async function stageInboundMediaItems(
915
+ runtime: any,
916
+ mediaItems: ParsedInboundMediaItem[],
917
+ log?: RuntimeLog,
918
+ ): Promise<ParsedInboundMediaItem[]> {
919
+ if (!mediaItems.length) {
920
+ return mediaItems;
921
+ }
922
+
923
+ const mediaRuntime = resolveInboundMediaRuntime(runtime);
924
+ if (!mediaRuntime) {
925
+ return mediaItems;
926
+ }
927
+
928
+ return Promise.all(
929
+ mediaItems.map(async (item) => {
930
+ const sourceUrl = item.resolvedFile?.downloadUrl ?? item.url;
931
+ if (!sourceUrl) {
932
+ return item;
933
+ }
934
+
935
+ const maxBytes = resolveInboundMediaMaxBytes(item);
936
+ const requestInit = item.resolvedFile?.downloadHeaders
937
+ ? {
938
+ headers: item.resolvedFile.downloadHeaders,
939
+ }
940
+ : undefined;
941
+
942
+ try {
943
+ const fetched = await withTimeout(
944
+ mediaRuntime.fetchRemoteMedia({
945
+ url: sourceUrl,
946
+ maxBytes,
947
+ requestInit,
948
+ filePathHint: item.fileName ?? item.resolvedFile?.fileName ?? sourceUrl,
949
+ }),
950
+ INBOUND_MEDIA_STAGE_TIMEOUT_MS,
951
+ `stage inbound media ${item.fileId ?? item.kind}`,
952
+ );
953
+
954
+ const saved = await withTimeout(
955
+ mediaRuntime.saveMediaBuffer(
956
+ fetched.buffer,
957
+ fetched.contentType ?? item.mimeType ?? item.resolvedFile?.contentType,
958
+ "inbound",
959
+ maxBytes,
960
+ item.fileName ?? item.resolvedFile?.fileName ?? fetched.fileName,
961
+ ),
962
+ INBOUND_MEDIA_STAGE_TIMEOUT_MS,
963
+ `save inbound media ${item.fileId ?? item.kind}`,
964
+ );
965
+
966
+ return {
967
+ ...item,
968
+ mimeType: item.mimeType ?? fetched.contentType ?? saved.contentType,
969
+ url: item.url ?? sourceUrl,
970
+ // These fields are consumed by OpenClaw media-understanding.
971
+ stagedPath: saved.path,
972
+ stagedMimeType: saved.contentType ?? fetched.contentType ?? item.mimeType,
973
+ stageError: undefined,
974
+ };
975
+ } catch (error) {
976
+ const stageError = String(error);
977
+ log?.warn?.(`[zapry] stage inbound media failed for ${item.fileId ?? item.kind}: ${stageError}`);
978
+ return {
979
+ ...item,
980
+ stageError,
981
+ };
982
+ }
983
+ }),
984
+ );
985
+ }
986
+
987
+ async function transcribeInboundAudioItems(
988
+ runtime: any,
989
+ cfg: any,
990
+ mediaItems: ParsedInboundMediaItem[],
991
+ log?: RuntimeLog,
992
+ ): Promise<{ mediaItems: ParsedInboundMediaItem[]; transcript?: string }> {
993
+ if (!mediaItems.length) {
994
+ return { mediaItems };
995
+ }
996
+ const sttRuntime = resolveInboundSttRuntime(runtime);
997
+ if (!sttRuntime) {
998
+ log?.warn?.("[zapry] STT runtime not available, audio will not be transcribed");
999
+ return { mediaItems };
1000
+ }
1001
+
1002
+ const updated = await Promise.all(
1003
+ mediaItems.map(async (item) => {
1004
+ if (!isAudioLikeMedia(item) || !item.stagedPath || item.stageError) {
1005
+ return item;
1006
+ }
1007
+ try {
1008
+ const mime = item.stagedMimeType ?? item.mimeType ?? item.resolvedFile?.contentType;
1009
+ const result = await withTimeout(
1010
+ sttRuntime.transcribeAudioFile({
1011
+ filePath: item.stagedPath,
1012
+ cfg,
1013
+ mime,
1014
+ }),
1015
+ INBOUND_AUDIO_TRANSCRIBE_TIMEOUT_MS,
1016
+ `transcribe inbound audio ${item.fileId ?? item.kind}`,
1017
+ );
1018
+ let transcript = result?.text?.trim();
1019
+ if (!transcript && item.stagedPath) {
1020
+ log?.warn?.(`[zapry] STT core returned empty, falling back to direct Whisper for ${item.fileId ?? item.kind}`);
1021
+ transcript = await whisperTranscribeDirect(item.stagedPath, mime, log);
1022
+ }
1023
+ if (!transcript) {
1024
+ return {
1025
+ ...item,
1026
+ transcriptError: "empty transcript",
1027
+ };
1028
+ }
1029
+ return {
1030
+ ...item,
1031
+ transcript,
1032
+ transcriptError: undefined,
1033
+ };
1034
+ } catch (error) {
1035
+ const transcriptError = String(error);
1036
+ log?.warn?.(`[zapry] transcribe inbound audio failed for ${item.fileId ?? item.kind}: ${transcriptError}`);
1037
+ return {
1038
+ ...item,
1039
+ transcriptError,
1040
+ };
1041
+ }
1042
+ }),
1043
+ );
1044
+
1045
+ const transcriptItems = updated.filter(
1046
+ (item): item is ParsedInboundMediaItem & { transcript: string } =>
1047
+ isAudioLikeMedia(item) && typeof item.transcript === "string" && item.transcript.trim().length > 0,
1048
+ );
1049
+ if (!transcriptItems.length) {
1050
+ return { mediaItems: updated };
1051
+ }
1052
+
1053
+ const transcript =
1054
+ transcriptItems.length === 1
1055
+ ? transcriptItems[0].transcript
1056
+ : transcriptItems.map((item, index) => `音频${index + 1}:\n${item.transcript}`).join("\n\n");
1057
+ return { mediaItems: updated, transcript };
1058
+ }
1059
+
1060
+ function parseMediaObject(kind: ParsedInboundMediaKind, raw: unknown): ParsedInboundMediaItem | null {
1061
+ if (typeof raw === "string") {
1062
+ const value = raw.trim();
1063
+ if (!value) {
1064
+ return null;
1065
+ }
1066
+ if (looksLikeHttpUrl(value)) {
1067
+ return { kind, url: value };
1068
+ }
1069
+ return { kind, fileId: value };
1070
+ }
1071
+
1072
+ const media = asRecord(raw);
1073
+ if (!media) {
1074
+ return null;
1075
+ }
1076
+ const thumb = asRecord(media.thumb) ?? asRecord(media.thumbnail);
1077
+ const thumbFileId =
1078
+ asNonEmptyString(thumb?.file_id ?? thumb?.fileId) ??
1079
+ asNonEmptyString(
1080
+ media.thumb_file_id ??
1081
+ media.thumbFileId ??
1082
+ media.thumbnail_file_id ??
1083
+ media.thumbnailFileId,
1084
+ );
1085
+ const thumbUrl =
1086
+ asNonEmptyString(thumb?.url ?? thumb?.remotePath) ??
1087
+ asNonEmptyString(
1088
+ media.thumb_url ??
1089
+ media.thumbUrl ??
1090
+ media.thumbnail_url ??
1091
+ media.thumbnailUrl,
1092
+ );
1093
+
1094
+ const item: ParsedInboundMediaItem = {
1095
+ kind,
1096
+ fileId: asNonEmptyString(
1097
+ media.file_id ??
1098
+ media.fileId ??
1099
+ media.id ??
1100
+ media.media_id ??
1101
+ media.mediaId ??
1102
+ media.file_token ??
1103
+ media.fileToken,
1104
+ ),
1105
+ fileUniqueId: asNonEmptyString(media.file_unique_id ?? media.fileUniqueId),
1106
+ url: asNonEmptyString(
1107
+ media.url ??
1108
+ media.remotePath ??
1109
+ media.path ??
1110
+ media.download_url ??
1111
+ media.downloadUrl ??
1112
+ media.file_path ??
1113
+ media.filePath,
1114
+ ),
1115
+ mimeType: asNonEmptyString(media.mime_type ?? media.mimeType),
1116
+ fileName: asNonEmptyString(media.file_name ?? media.fileName ?? media.name),
1117
+ fileSize: parseFiniteNumber(media.file_size ?? media.fileSize),
1118
+ width: parseFiniteNumber(media.width),
1119
+ height: parseFiniteNumber(media.height),
1120
+ duration: parseFiniteNumber(media.duration),
1121
+ thumbFileId,
1122
+ thumbUrl,
1123
+ };
1124
+
1125
+ const hasUsefulField =
1126
+ Boolean(item.fileId) ||
1127
+ Boolean(item.url) ||
1128
+ Boolean(item.fileName) ||
1129
+ Boolean(item.mimeType) ||
1130
+ item.fileSize !== undefined ||
1131
+ item.width !== undefined ||
1132
+ item.height !== undefined ||
1133
+ item.duration !== undefined ||
1134
+ Boolean(item.thumbFileId) ||
1135
+ Boolean(item.thumbUrl);
1136
+ return hasUsefulField ? item : null;
1137
+ }
1138
+
1139
+ function mediaScore(item: ParsedInboundMediaItem): number {
1140
+ const area = (item.width ?? 0) * (item.height ?? 0);
1141
+ return area + (item.fileSize ?? 0);
1142
+ }
1143
+
1144
+ function resolveMediaKindFromValue(value: unknown): ParsedInboundMediaKind | undefined {
1145
+ const normalized = asNonEmptyString(typeof value === "string" ? value : value != null ? String(value) : undefined)?.toLowerCase();
1146
+ if (!normalized) {
1147
+ return undefined;
1148
+ }
1149
+ if (
1150
+ normalized.includes("photo") ||
1151
+ normalized.includes("image") ||
1152
+ normalized.includes("pic")
1153
+ ) {
1154
+ return "photo";
1155
+ }
1156
+ if (normalized.includes("video")) {
1157
+ return "video";
1158
+ }
1159
+ if (normalized.includes("voice") || normalized.includes("ptt")) {
1160
+ return "voice";
1161
+ }
1162
+ if (normalized.includes("audio") || normalized.includes("music")) {
1163
+ return "audio";
1164
+ }
1165
+ if (normalized.includes("animation") || normalized.includes("gif")) {
1166
+ return "animation";
1167
+ }
1168
+ if (normalized.includes("document") || normalized.includes("file")) {
1169
+ return "document";
1170
+ }
1171
+ return undefined;
1172
+ }
1173
+
1174
+ function parseMediaCollectionEntry(entry: unknown): ParsedInboundMediaItem | null {
1175
+ if (typeof entry === "string") {
1176
+ // Unknown media type string, default to document for safe downstream handling.
1177
+ return parseMediaObject("document", entry);
1178
+ }
1179
+ const record = asRecord(entry);
1180
+ if (!record) {
1181
+ return null;
1182
+ }
1183
+ const inferredKind =
1184
+ resolveMediaKindFromValue(record.kind) ??
1185
+ resolveMediaKindFromValue(record.type) ??
1186
+ resolveMediaKindFromValue(record.media_type) ??
1187
+ resolveMediaKindFromValue(record.mediaType) ??
1188
+ resolveMediaKindFromValue(record.mime_type) ??
1189
+ resolveMediaKindFromValue(record.mimeType) ??
1190
+ resolveMediaKindFromValue(record.file_type) ??
1191
+ resolveMediaKindFromValue(record.fileType) ??
1192
+ "document";
1193
+ return parseMediaObject(inferredKind, record);
1194
+ }
1195
+
1196
+ function extractInboundMediaItems(message: Record<string, unknown>): ParsedInboundMediaItem[] {
1197
+ const items: ParsedInboundMediaItem[] = [];
1198
+ const dedupe = new Set<string>();
1199
+ const pushItem = (item: ParsedInboundMediaItem | null): void => {
1200
+ if (!item) {
1201
+ return;
1202
+ }
1203
+ const key = [
1204
+ item.kind,
1205
+ item.fileId ?? "",
1206
+ item.url ?? "",
1207
+ item.fileName ?? "",
1208
+ item.fileUniqueId ?? "",
1209
+ ].join("|");
1210
+ if (dedupe.has(key)) {
1211
+ return;
1212
+ }
1213
+ dedupe.add(key);
1214
+ items.push(item);
1215
+ };
1216
+
1217
+ const rawPhoto =
1218
+ message.photo ??
1219
+ message.Photo ??
1220
+ message.image ??
1221
+ message.Image ??
1222
+ message.picture ??
1223
+ message.Picture;
1224
+ if (Array.isArray(rawPhoto)) {
1225
+ const photoVariants = rawPhoto
1226
+ .map((entry) => parseMediaObject("photo", entry))
1227
+ .filter((entry): entry is ParsedInboundMediaItem => Boolean(entry));
1228
+ if (photoVariants.length > 0) {
1229
+ photoVariants.sort((a, b) => mediaScore(b) - mediaScore(a));
1230
+ pushItem(photoVariants[0]);
1231
+ }
1232
+ } else {
1233
+ pushItem(parseMediaObject("photo", rawPhoto));
1234
+ }
1235
+
1236
+ const directMediaKeys: Array<{ keys: string[]; kind: ParsedInboundMediaKind }> = [
1237
+ { keys: ["video", "Video"], kind: "video" },
1238
+ { keys: ["document", "Document", "file", "File"], kind: "document" },
1239
+ { keys: ["audio", "Audio"], kind: "audio" },
1240
+ { keys: ["voice", "Voice"], kind: "voice" },
1241
+ { keys: ["animation", "Animation", "gif", "Gif"], kind: "animation" },
1242
+ ];
1243
+ for (const { keys, kind } of directMediaKeys) {
1244
+ for (const key of keys) {
1245
+ const parsed = parseMediaObject(kind, message[key]);
1246
+ if (parsed) {
1247
+ pushItem(parsed);
1248
+ break;
1249
+ }
1250
+ }
1251
+ }
1252
+
1253
+ const collectionCandidates = [
1254
+ message.media_items,
1255
+ message.mediaItems,
1256
+ message.MediaItems,
1257
+ message.attachments,
1258
+ message.Attachments,
1259
+ message.files,
1260
+ message.media,
1261
+ ];
1262
+ for (const candidate of collectionCandidates) {
1263
+ if (!Array.isArray(candidate)) {
1264
+ continue;
1265
+ }
1266
+ for (const entry of candidate) {
1267
+ pushItem(parseMediaCollectionEntry(entry));
1268
+ }
1269
+ }
1270
+
1271
+ const singularCandidates = [
1272
+ message.media_item,
1273
+ message.mediaItem,
1274
+ message.attachment,
1275
+ message.Attachment,
1276
+ Array.isArray(message.media) ? undefined : message.media,
1277
+ ];
1278
+ for (const candidate of singularCandidates) {
1279
+ if (candidate === undefined || candidate === null) {
1280
+ continue;
1281
+ }
1282
+ pushItem(parseMediaCollectionEntry(candidate));
1283
+ }
1284
+
1285
+ return items;
1286
+ }
1287
+
1288
+ function resolveUserIdFromRecord(record: Record<string, unknown> | null): string | undefined {
1289
+ if (!record) {
1290
+ return undefined;
1291
+ }
1292
+ const value =
1293
+ record.id ??
1294
+ record.user_id ??
1295
+ record.userId ??
1296
+ record.uid ??
1297
+ record.member_id ??
1298
+ record.memberId;
1299
+ if (value === undefined || value === null) {
1300
+ return undefined;
1301
+ }
1302
+ return asNonEmptyString(String(value));
1303
+ }
1304
+
1305
+ function normalizeUsername(value: unknown): string | undefined {
1306
+ const username = asNonEmptyString(typeof value === "string" ? value : value != null ? String(value) : undefined);
1307
+ if (!username) {
1308
+ return undefined;
1309
+ }
1310
+ return username.replace(/^@+/, "");
1311
+ }
1312
+
1313
+ function resolveDisplayNameFromRecord(record: Record<string, unknown> | null): string | undefined {
1314
+ if (!record) {
1315
+ return undefined;
1316
+ }
1317
+ const fullName =
1318
+ asNonEmptyString(typeof record.name === "string" ? record.name : undefined) ??
1319
+ asNonEmptyString(typeof record.display_name === "string" ? record.display_name : undefined) ??
1320
+ asNonEmptyString(typeof record.displayName === "string" ? record.displayName : undefined) ??
1321
+ asNonEmptyString(typeof record.nick === "string" ? record.nick : undefined) ??
1322
+ asNonEmptyString(typeof record.nickname === "string" ? record.nickname : undefined);
1323
+ if (fullName) {
1324
+ return fullName;
1325
+ }
1326
+
1327
+ const firstName = asNonEmptyString(
1328
+ typeof record.first_name === "string" ? record.first_name : typeof record.firstName === "string" ? record.firstName : undefined,
1329
+ );
1330
+ const lastName = asNonEmptyString(
1331
+ typeof record.last_name === "string" ? record.last_name : typeof record.lastName === "string" ? record.lastName : undefined,
1332
+ );
1333
+ const joined = [firstName, lastName].filter(Boolean).join(" ").trim();
1334
+ return joined || undefined;
1335
+ }
1336
+
1337
+ function extractTargetUserHints(
1338
+ message: Record<string, unknown>,
1339
+ sourceText?: string,
1340
+ ): InboundTargetUserHint[] {
1341
+ const hints = new Map<string, InboundTargetUserHint>();
1342
+ const sourcePriority: Record<InboundTargetUserHint["source"], number> = {
1343
+ reply_to_message: 4,
1344
+ text_mention: 3,
1345
+ mention_entity: 2,
1346
+ mentioned_users: 1,
1347
+ };
1348
+
1349
+ const pushHint = (hint: InboundTargetUserHint): void => {
1350
+ const userId = asNonEmptyString(hint.userId);
1351
+ if (!userId) {
1352
+ return;
1353
+ }
1354
+ const normalizedHint: InboundTargetUserHint = {
1355
+ ...hint,
1356
+ userId,
1357
+ username: normalizeUsername(hint.username),
1358
+ displayName: asNonEmptyString(hint.displayName),
1359
+ raw: asNonEmptyString(hint.raw),
1360
+ };
1361
+ const existing = hints.get(userId);
1362
+ if (!existing) {
1363
+ hints.set(userId, normalizedHint);
1364
+ return;
1365
+ }
1366
+ const shouldUpgradeSource = sourcePriority[normalizedHint.source] > sourcePriority[existing.source];
1367
+ hints.set(userId, {
1368
+ userId,
1369
+ source: shouldUpgradeSource ? normalizedHint.source : existing.source,
1370
+ username: existing.username ?? normalizedHint.username,
1371
+ displayName: existing.displayName ?? normalizedHint.displayName,
1372
+ raw: existing.raw ?? normalizedHint.raw,
1373
+ });
1374
+ };
1375
+
1376
+ const replyMessage = asRecord(message.reply_to_message ?? message.replyToMessage);
1377
+ if (replyMessage) {
1378
+ const replyFrom = asRecord(replyMessage.from ?? replyMessage.sender ?? replyMessage.user);
1379
+ const replyUserId =
1380
+ resolveUserIdFromRecord(replyFrom) ??
1381
+ asNonEmptyString(
1382
+ replyMessage.sender_id != null
1383
+ ? String(replyMessage.sender_id)
1384
+ : replyMessage.from_id != null
1385
+ ? String(replyMessage.from_id)
1386
+ : replyMessage.user_id != null
1387
+ ? String(replyMessage.user_id)
1388
+ : undefined,
1389
+ );
1390
+ if (replyUserId) {
1391
+ pushHint({
1392
+ userId: replyUserId,
1393
+ username: normalizeUsername(replyFrom?.username ?? replyMessage.sender_username),
1394
+ displayName:
1395
+ resolveDisplayNameFromRecord(replyFrom) ??
1396
+ asNonEmptyString(
1397
+ typeof replyMessage.sender_name === "string"
1398
+ ? replyMessage.sender_name
1399
+ : typeof replyMessage.senderName === "string"
1400
+ ? replyMessage.senderName
1401
+ : undefined,
1402
+ ),
1403
+ source: "reply_to_message",
1404
+ raw: "reply_to_message",
1405
+ });
1406
+ }
1407
+ }
1408
+
1409
+ const entities: unknown[] = [];
1410
+ if (Array.isArray(message.entities)) {
1411
+ entities.push(...message.entities);
1412
+ }
1413
+ if (Array.isArray(message.caption_entities)) {
1414
+ entities.push(...message.caption_entities);
1415
+ }
1416
+
1417
+ for (const entityRaw of entities) {
1418
+ const entity = asRecord(entityRaw);
1419
+ if (!entity) {
1420
+ continue;
1421
+ }
1422
+ const type = asNonEmptyString(entity.type)?.toLowerCase();
1423
+ if (type !== "text_mention" && type !== "mention") {
1424
+ continue;
1425
+ }
1426
+ const entityUser = asRecord(entity.user ?? entity.from_user ?? entity.sender ?? entity.member);
1427
+ const entityUserId =
1428
+ resolveUserIdFromRecord(entityUser) ??
1429
+ asNonEmptyString(
1430
+ entity.user_id != null
1431
+ ? String(entity.user_id)
1432
+ : entity.userId != null
1433
+ ? String(entity.userId)
1434
+ : entity.uid != null
1435
+ ? String(entity.uid)
1436
+ : undefined,
1437
+ );
1438
+ if (!entityUserId) {
1439
+ continue;
1440
+ }
1441
+ pushHint({
1442
+ userId: entityUserId,
1443
+ username: normalizeUsername(entityUser?.username),
1444
+ displayName: resolveDisplayNameFromRecord(entityUser),
1445
+ source: type === "text_mention" ? "text_mention" : "mention_entity",
1446
+ raw: extractTextByEntityRange(sourceText, entity.offset, entity.length),
1447
+ });
1448
+ }
1449
+
1450
+ const mentionArrayFields = [
1451
+ "mentioned_users",
1452
+ "mentionedUsers",
1453
+ "mentions",
1454
+ "at_users",
1455
+ "atUsers",
1456
+ "at_list",
1457
+ "atList",
1458
+ ];
1459
+ for (const field of mentionArrayFields) {
1460
+ const rawValue = message[field];
1461
+ if (!Array.isArray(rawValue)) {
1462
+ continue;
1463
+ }
1464
+ for (const entry of rawValue) {
1465
+ const record = asRecord(entry);
1466
+ if (!record) {
1467
+ continue;
1468
+ }
1469
+ const userId =
1470
+ resolveUserIdFromRecord(record) ??
1471
+ asNonEmptyString(record.member != null ? String(record.member) : undefined);
1472
+ if (!userId) {
1473
+ continue;
1474
+ }
1475
+ pushHint({
1476
+ userId,
1477
+ username: normalizeUsername(record.username ?? record.user_name),
1478
+ displayName: resolveDisplayNameFromRecord(record),
1479
+ source: "mentioned_users",
1480
+ raw:
1481
+ asNonEmptyString(typeof record.text === "string" ? record.text : undefined) ??
1482
+ asNonEmptyString(typeof record.name === "string" ? record.name : undefined),
1483
+ });
1484
+ }
1485
+ }
1486
+
1487
+ const mentionSingleFields = [
1488
+ "target_user_id",
1489
+ "targetUserId",
1490
+ "mentioned_user_id",
1491
+ "mentionedUserId",
1492
+ "reply_user_id",
1493
+ "replyUserId",
1494
+ ];
1495
+ for (const field of mentionSingleFields) {
1496
+ const value = message[field];
1497
+ if (value === undefined || value === null) {
1498
+ continue;
1499
+ }
1500
+ const userId = asNonEmptyString(String(value));
1501
+ if (!userId) {
1502
+ continue;
1503
+ }
1504
+ pushHint({
1505
+ userId,
1506
+ source: "mentioned_users",
1507
+ raw: field,
1508
+ });
1509
+ }
1510
+
1511
+ return Array.from(hints.values());
1512
+ }
1513
+
1514
+ function summarizeTargetUserHint(hint: InboundTargetUserHint): string {
1515
+ const parts = [`user_id=${hint.userId}`];
1516
+ if (hint.displayName) {
1517
+ parts.push(`name=${hint.displayName}`);
1518
+ }
1519
+ if (hint.username) {
1520
+ parts.push(`username=@${hint.username}`);
1521
+ }
1522
+ parts.push(`source=${hint.source}`);
1523
+ if (hint.raw) {
1524
+ parts.push(`raw=${hint.raw}`);
1525
+ }
1526
+ return parts.join(" | ");
1527
+ }
1528
+
1529
+ function isLikelyModerationIntent(text: string | undefined): boolean {
1530
+ const normalized = text?.trim().toLowerCase();
1531
+ if (!normalized) {
1532
+ return false;
1533
+ }
1534
+ return [
1535
+ "禁言",
1536
+ "解禁",
1537
+ "踢",
1538
+ "移出群",
1539
+ "封禁",
1540
+ "mute",
1541
+ "unmute",
1542
+ "kick",
1543
+ "ban",
1544
+ "restrict",
1545
+ ].some((token) => normalized.includes(token));
1546
+ }
1547
+
1548
+ function resolveMuteCommandIntent(text: string | undefined): MuteCommandIntent | null {
1549
+ const normalized = text?.trim().toLowerCase();
1550
+ if (!normalized) {
1551
+ return null;
1552
+ }
1553
+ const unmuteTokens = ["解除禁言", "取消禁言", "解禁", "unmute", "解除封禁", "取消封禁"];
1554
+ if (unmuteTokens.some((token) => normalized.includes(token))) {
1555
+ return "unmute";
1556
+ }
1557
+ const muteTokens = ["禁言", "mute", "封禁", "闭麦"];
1558
+ if (muteTokens.some((token) => normalized.includes(token))) {
1559
+ return "mute";
1560
+ }
1561
+ return null;
1562
+ }
1563
+
1564
+ function isLikelyMuteCommandMessage(
1565
+ text: string | undefined,
1566
+ targetUserHints: InboundTargetUserHint[],
1567
+ ): boolean {
1568
+ const normalized = text?.trim().toLowerCase();
1569
+ if (!normalized) {
1570
+ return false;
1571
+ }
1572
+ if (!resolveMuteCommandIntent(normalized)) {
1573
+ return false;
1574
+ }
1575
+ if (targetUserHints.length > 0) {
1576
+ return true;
1577
+ }
1578
+ const discussionTokens = [
1579
+ "体验",
1580
+ "方案",
1581
+ "文档",
1582
+ "规则",
1583
+ "策略",
1584
+ "为什么",
1585
+ "怎么",
1586
+ "支持",
1587
+ "接口",
1588
+ "参数",
1589
+ "提示词",
1590
+ ];
1591
+ if (discussionTokens.some((token) => normalized.includes(token))) {
1592
+ return false;
1593
+ }
1594
+ return [
1595
+ "把",
1596
+ "将",
1597
+ "请",
1598
+ "帮",
1599
+ "麻烦",
1600
+ "执行",
1601
+ "试试",
1602
+ "处理",
1603
+ "@",
1604
+ "/mute",
1605
+ "/unmute",
1606
+ ].some((token) => normalized.includes(token));
1607
+ }
1608
+
1609
+ function buildPendingMuteConfirmationKey(chatId: string, senderId: string): string {
1610
+ return `${chatId}::${senderId}`;
1611
+ }
1612
+
1613
+ function clearPendingMuteConfirmation(chatId: string, senderId: string): void {
1614
+ pendingMuteConfirmations.delete(buildPendingMuteConfirmationKey(chatId, senderId));
1615
+ }
1616
+
1617
+ function getPendingMuteConfirmation(chatId: string, senderId: string): PendingMuteConfirmation | null {
1618
+ const key = buildPendingMuteConfirmationKey(chatId, senderId);
1619
+ const pending = pendingMuteConfirmations.get(key);
1620
+ if (!pending) {
1621
+ return null;
1622
+ }
1623
+ if (Date.now() - pending.createdAtMs > PENDING_MUTE_CONFIRMATION_TTL_MS) {
1624
+ pendingMuteConfirmations.delete(key);
1625
+ return null;
1626
+ }
1627
+ return pending;
1628
+ }
1629
+
1630
+ function setPendingMuteConfirmation(pending: PendingMuteConfirmation): void {
1631
+ pendingMuteConfirmations.set(
1632
+ buildPendingMuteConfirmationKey(pending.chatId, pending.senderId),
1633
+ pending,
1634
+ );
1635
+ }
1636
+
1637
+ function resolveMuteConfirmationDecision(text: string | undefined): "confirm" | "cancel" | null {
1638
+ const normalized = text?.trim().toLowerCase();
1639
+ if (!normalized) {
1640
+ return null;
1641
+ }
1642
+ if (
1643
+ [
1644
+ "取消",
1645
+ "取消操作",
1646
+ "取消执行",
1647
+ "算了",
1648
+ "不是他",
1649
+ "不对",
1650
+ "cancel",
1651
+ "no",
1652
+ ].some((token) => normalized === token || normalized.includes(token))
1653
+ ) {
1654
+ return "cancel";
1655
+ }
1656
+ if (
1657
+ [
1658
+ "确认",
1659
+ "确认执行",
1660
+ "确认禁言",
1661
+ "确认解禁",
1662
+ "确认解除禁言",
1663
+ "执行",
1664
+ "执行吧",
1665
+ "就他",
1666
+ "是他",
1667
+ "yes",
1668
+ "ok",
1669
+ "okay",
1670
+ ].some((token) => normalized === token || normalized.includes(token))
1671
+ ) {
1672
+ return "confirm";
1673
+ }
1674
+ return null;
1675
+ }
1676
+
1677
+ function normalizeLookupToken(value: string | undefined): string {
1678
+ return String(value ?? "")
1679
+ .trim()
1680
+ .toLowerCase()
1681
+ .replace(/^@+/, "")
1682
+ .replace(/[\s"'`“”‘’]/g, "");
1683
+ }
1684
+
1685
+ function sanitizeMuteLookupKeyword(raw: string | undefined): string {
1686
+ let text = String(raw ?? "").trim();
1687
+ if (!text) {
1688
+ return "";
1689
+ }
1690
+ text = text
1691
+ .replace(/^@[^ \t\r\n]+\s*/u, "")
1692
+ .replace(/^[“"‘'`]+|[”"’'`]+$/gu, "")
1693
+ .replace(/[::,,。.!!??]+$/gu, "")
1694
+ .trim();
1695
+
1696
+ let previous = "";
1697
+ while (text && text !== previous) {
1698
+ previous = text;
1699
+ text = text
1700
+ .replace(/^(请|帮|麻烦|把|将|给)\s*/u, "")
1701
+ .replace(/^(本群里|本群内|这个群里|这个群内|这群里|群里|群内|群中的|群中|群里的|群内的)/u, "")
1702
+ .replace(/^(叫做|叫|昵称是|名为)\s*/u, "")
1703
+ .replace(/^(的)+/u, "")
1704
+ .replace(/(的)?(用户|成员|群友|这个人|那个人|这个用户|那个用户|这位|那位|同学|老铁|本人)$/u, "")
1705
+ .replace(/(一下|先|立刻|马上|现在|给我|帮我|行吗|可以吗|谢谢|拜托|哈|啊|呀|吧|呢|嘛|了)$/u, "")
1706
+ .replace(/[::,,。.!!??]+$/gu, "")
1707
+ .trim();
1708
+ }
1709
+ return text;
1710
+ }
1711
+
1712
+ function extractTargetKeywordCandidatesFromMuteText(text: string | undefined): string[] {
1713
+ const normalized = text?.trim();
1714
+ if (!normalized) {
1715
+ return [];
1716
+ }
1717
+ const lower = normalized.toLowerCase();
1718
+ const actionTokens = ["解除禁言", "取消禁言", "解禁", "unmute", "禁言", "mute", "封禁", "闭麦"];
1719
+ const pushCandidate = (bucket: string[], seen: Set<string>, value: string | undefined): void => {
1720
+ const cleaned = sanitizeMuteLookupKeyword(value);
1721
+ const token = normalizeLookupToken(cleaned);
1722
+ if (!cleaned || token.length < 2 || seen.has(token)) {
1723
+ return;
1724
+ }
1725
+ seen.add(token);
1726
+ bucket.push(cleaned);
1727
+ };
1728
+
1729
+ let actionIndex = -1;
1730
+ let actionToken = "";
1731
+ for (const token of actionTokens) {
1732
+ const idx = lower.indexOf(token);
1733
+ if (idx >= 0 && (actionIndex < 0 || idx < actionIndex)) {
1734
+ actionIndex = idx;
1735
+ actionToken = token;
1736
+ }
1737
+ }
1738
+ if (actionIndex < 0) {
1739
+ return [];
1740
+ }
1741
+
1742
+ const candidates: string[] = [];
1743
+ const seen = new Set<string>();
1744
+
1745
+ for (const atMatch of normalized.matchAll(/@([^\s,,。.!!??]+)/gu)) {
1746
+ pushCandidate(candidates, seen, atMatch[1]);
1747
+ }
1748
+ for (const quoted of normalized.matchAll(/[“"‘'`](.+?)[”"’'`]/gu)) {
1749
+ pushCandidate(candidates, seen, quoted[1]);
1750
+ }
1751
+
1752
+ let left = normalized.slice(0, actionIndex).trim();
1753
+ left = left.replace(/^@[^ \t\r\n]+\s+/u, "").trim();
1754
+ const markerIndex = Math.max(left.lastIndexOf("把"), left.lastIndexOf("将"), left.lastIndexOf("给"));
1755
+ if (markerIndex >= 0) {
1756
+ left = left.slice(markerIndex + 1).trim();
1757
+ }
1758
+ left = left.replace(/^(本群里|这个群里|这群里|群里|群中)/, "").trim();
1759
+ if (left.startsWith("@")) {
1760
+ left = left.slice(1).trim();
1761
+ }
1762
+ pushCandidate(candidates, seen, left);
1763
+
1764
+ if (actionToken) {
1765
+ const right = normalized.slice(actionIndex + actionToken.length).trim();
1766
+ pushCandidate(candidates, seen, right);
1767
+ }
1768
+
1769
+ let stripped = normalized;
1770
+ for (const token of actionTokens) {
1771
+ stripped = stripped.replaceAll(token, " ");
1772
+ }
1773
+ pushCandidate(candidates, seen, stripped);
1774
+ return candidates;
1775
+ }
1776
+
1777
+ function extractTargetKeywordFromMuteText(text: string | undefined): string | null {
1778
+ const candidates = extractTargetKeywordCandidatesFromMuteText(text);
1779
+ return candidates[0] ?? null;
1780
+ }
1781
+
1782
+ function toUserIdString(value: unknown): string | undefined {
1783
+ if (value === undefined || value === null) {
1784
+ return undefined;
1785
+ }
1786
+ const normalized = String(value).trim();
1787
+ if (!normalized || normalized === "0") {
1788
+ return undefined;
1789
+ }
1790
+ return normalized;
1791
+ }
1792
+
1793
+ function parseGroupMemberCandidate(
1794
+ rawKey: string | undefined,
1795
+ rawValue: unknown,
1796
+ ): GroupMemberCandidate | null {
1797
+ const record = asRecord(rawValue);
1798
+ if (!record) {
1799
+ return null;
1800
+ }
1801
+
1802
+ const explicitUserId = toUserIdString(record.user_id ?? record.userId ?? record.uid);
1803
+
1804
+ const groupNick = asNonEmptyString(
1805
+ typeof record.Gnick === "string"
1806
+ ? record.Gnick
1807
+ : typeof record.gnick === "string"
1808
+ ? record.gnick
1809
+ : typeof record.group_nick === "string"
1810
+ ? record.group_nick
1811
+ : typeof record.groupNick === "string"
1812
+ ? record.groupNick
1813
+ : undefined,
1814
+ );
1815
+ const nick = asNonEmptyString(
1816
+ typeof record.Nick === "string"
1817
+ ? record.Nick
1818
+ : typeof record.nick === "string"
1819
+ ? record.nick
1820
+ : typeof record.name === "string"
1821
+ ? record.name
1822
+ : typeof record.display_name === "string"
1823
+ ? record.display_name
1824
+ : typeof record.displayName === "string"
1825
+ ? record.displayName
1826
+ : undefined,
1827
+ );
1828
+ const username = normalizeUsername(
1829
+ record.Username ??
1830
+ record.username ??
1831
+ record.user_name ??
1832
+ record.userName,
1833
+ );
1834
+ const userId = toUserIdString(record.Id ?? record.id ?? explicitUserId ?? rawKey);
1835
+ if (!userId) {
1836
+ return null;
1837
+ }
1838
+ if (!explicitUserId && !groupNick && !nick && !username) {
1839
+ return null;
1840
+ }
1841
+ const displayName = groupNick ?? nick ?? (username ? `@${username}` : `用户(${userId})`);
1842
+ return {
1843
+ userId,
1844
+ displayName,
1845
+ username,
1846
+ nick: nick ?? undefined,
1847
+ groupNick: groupNick ?? undefined,
1848
+ };
1849
+ }
1850
+
1851
+ function extractGroupMemberCandidates(payload: unknown): GroupMemberCandidate[] {
1852
+ const byUserId = new Map<string, GroupMemberCandidate>();
1853
+ const visited = new Set<object>();
1854
+
1855
+ const upsertCandidate = (candidate: GroupMemberCandidate | null): void => {
1856
+ if (!candidate) {
1857
+ return;
1858
+ }
1859
+ const existing = byUserId.get(candidate.userId);
1860
+ if (!existing) {
1861
+ byUserId.set(candidate.userId, candidate);
1862
+ return;
1863
+ }
1864
+ byUserId.set(candidate.userId, {
1865
+ ...existing,
1866
+ displayName:
1867
+ existing.displayName.startsWith("用户(") && !candidate.displayName.startsWith("用户(")
1868
+ ? candidate.displayName
1869
+ : existing.displayName,
1870
+ username: existing.username ?? candidate.username,
1871
+ nick: existing.nick ?? candidate.nick,
1872
+ groupNick: existing.groupNick ?? candidate.groupNick,
1873
+ });
1874
+ };
1875
+
1876
+ const parseMemberContainer = (container: unknown): void => {
1877
+ if (Array.isArray(container)) {
1878
+ for (const entry of container) {
1879
+ upsertCandidate(parseGroupMemberCandidate(undefined, entry));
1880
+ }
1881
+ return;
1882
+ }
1883
+ const record = asRecord(container);
1884
+ if (!record) {
1885
+ return;
1886
+ }
1887
+ for (const [rawKey, rawValue] of Object.entries(record)) {
1888
+ upsertCandidate(parseGroupMemberCandidate(rawKey, rawValue));
1889
+ }
1890
+ };
1891
+
1892
+ const walk = (node: unknown, depth: number): void => {
1893
+ if (depth > 5) {
1894
+ return;
1895
+ }
1896
+ const record = asRecord(node);
1897
+ if (!record) {
1898
+ return;
1899
+ }
1900
+ if (visited.has(record)) {
1901
+ return;
1902
+ }
1903
+ visited.add(record);
1904
+
1905
+ upsertCandidate(parseGroupMemberCandidate(undefined, record));
1906
+
1907
+ const membersNodeCandidates = [
1908
+ record.Member,
1909
+ record.member,
1910
+ record.Items,
1911
+ record.items,
1912
+ asRecord(record.Members)?.Member,
1913
+ asRecord(record.Members)?.member,
1914
+ asRecord(record.members)?.Member,
1915
+ asRecord(record.members)?.member,
1916
+ ];
1917
+ for (const candidate of membersNodeCandidates) {
1918
+ parseMemberContainer(candidate);
1919
+ }
1920
+
1921
+ for (const value of Object.values(record)) {
1922
+ if (value && typeof value === "object") {
1923
+ walk(value, depth + 1);
1924
+ }
1925
+ }
1926
+ };
1927
+
1928
+ walk(payload, 0);
1929
+ return Array.from(byUserId.values());
1930
+ }
1931
+
1932
+ function scoreGroupMemberCandidate(
1933
+ candidate: GroupMemberCandidate,
1934
+ keyword: string,
1935
+ senderId: string,
1936
+ ): number {
1937
+ const token = normalizeLookupToken(keyword);
1938
+ if (!token) {
1939
+ return 0;
1940
+ }
1941
+ const names = [
1942
+ candidate.displayName,
1943
+ candidate.groupNick,
1944
+ candidate.nick,
1945
+ candidate.username,
1946
+ candidate.userId,
1947
+ ];
1948
+ let score = 0;
1949
+ for (const rawName of names) {
1950
+ const name = normalizeLookupToken(rawName);
1951
+ if (!name) {
1952
+ continue;
1953
+ }
1954
+ if (name === token) {
1955
+ score = Math.max(score, 120);
1956
+ continue;
1957
+ }
1958
+ if (name.startsWith(token)) {
1959
+ score = Math.max(score, 105);
1960
+ continue;
1961
+ }
1962
+ if (token.startsWith(name) && name.length >= 2) {
1963
+ score = Math.max(score, 95);
1964
+ continue;
1965
+ }
1966
+ if (name.includes(token)) {
1967
+ score = Math.max(score, 85);
1968
+ continue;
1969
+ }
1970
+ if (token.includes(name) && name.length >= 2) {
1971
+ score = Math.max(score, 75);
1972
+ }
1973
+ }
1974
+ if (candidate.userId === senderId) {
1975
+ score -= 20;
1976
+ }
1977
+ return score;
1978
+ }
1979
+
1980
+ function scoreGroupMemberCandidateWithKeywordCandidates(
1981
+ candidate: GroupMemberCandidate,
1982
+ keywords: string[],
1983
+ senderId: string,
1984
+ ): number {
1985
+ let best = 0;
1986
+ for (const keyword of keywords) {
1987
+ best = Math.max(best, scoreGroupMemberCandidate(candidate, keyword, senderId));
1988
+ }
1989
+ return best;
1990
+ }
1991
+
1992
+ function isLikelyGroupMembersQueryMessage(text: string | undefined): boolean {
1993
+ const normalized = text?.trim().toLowerCase();
1994
+ if (!normalized) {
1995
+ return false;
1996
+ }
1997
+ return [
1998
+ "当前群里都有谁",
1999
+ "群里都有谁",
2000
+ "这个群里都有谁",
2001
+ "群成员",
2002
+ "成员列表",
2003
+ "群成员列表",
2004
+ "群里谁在",
2005
+ "who is in this group",
2006
+ "list group members",
2007
+ "member list",
2008
+ ].some((token) => normalized.includes(token));
2009
+ }
2010
+
2011
+ function buildGroupMembersLookupFailureText(errorCode?: number, description?: string): string {
2012
+ const normalizedDesc = description?.toLowerCase() ?? "";
2013
+ if (normalizedDesc.includes("only bot owner can execute zapry skills")) {
2014
+ return "只能是主人才可以调用";
2015
+ }
2016
+ if (errorCode === 403) {
2017
+ return "查询失败:我没有该群的查询权限,请先把我设为管理员后再试。";
2018
+ }
2019
+ if (
2020
+ errorCode === 503 ||
2021
+ normalizedDesc.includes("service unavailable") ||
2022
+ normalizedDesc.includes("connection error")
2023
+ ) {
2024
+ return "查询失败:服务暂时不可用(503),我稍后可以再试一次。";
2025
+ }
2026
+ if (errorCode === 404 || normalizedDesc.includes("not found")) {
2027
+ return "查询失败:暂未找到该群信息,请稍后再试。";
2028
+ }
2029
+ return "查询失败:我暂时拿不到群成员数据,请稍后再试。";
2030
+ }
2031
+
2032
+ function buildGroupMembersQuickReplyText(candidates: GroupMemberCandidate[], total?: number): string {
2033
+ if (!candidates.length) {
2034
+ return "我现在没查到可用的群成员数据,请稍后重试。";
2035
+ }
2036
+ const resolvedTotal =
2037
+ typeof total === "number" && Number.isFinite(total) && total > 0 ? Math.floor(total) : candidates.length;
2038
+ const shown = candidates.slice(0, GROUP_MEMBERS_QUICK_REPLY_LIMIT);
2039
+ const lines: string[] = [];
2040
+ if (resolvedTotal > shown.length) {
2041
+ lines.push(`当前群里共 ${resolvedTotal} 位成员,先给你前 ${shown.length} 位:`);
2042
+ } else {
2043
+ lines.push(`当前群里共 ${resolvedTotal} 位成员:`);
2044
+ }
2045
+ for (const candidate of shown) {
2046
+ const displayName = candidate.displayName?.trim() || `用户(${candidate.userId})`;
2047
+ lines.push(`- ${displayName}(${candidate.userId})`);
2048
+ }
2049
+ if (resolvedTotal > shown.length) {
2050
+ lines.push(`如需继续看剩余成员,回复“继续”或“下一页”。`);
2051
+ }
2052
+ return lines.join("\n");
2053
+ }
2054
+
2055
+ function resolveGroupMembersTotalFromPayload(payload: unknown, fallback: number): number {
2056
+ const record = asRecord(payload);
2057
+ if (!record) {
2058
+ return fallback;
2059
+ }
2060
+ const total = parseFiniteNumber(record.total ?? record.Total ?? record.member_count ?? record.count);
2061
+ if (typeof total === "number" && Number.isFinite(total) && total > 0) {
2062
+ return Math.floor(total);
2063
+ }
2064
+ return fallback;
2065
+ }
2066
+
2067
+ async function queryGroupMembersForQuickReply(params: {
2068
+ client: ZapryApiClient;
2069
+ chatId: string;
2070
+ log?: RuntimeLog;
2071
+ }): Promise<{
2072
+ candidates: GroupMemberCandidate[];
2073
+ total: number;
2074
+ errorCode?: number;
2075
+ description?: string;
2076
+ }> {
2077
+ const memberResp = await params.client.getChatMembers(params.chatId, {
2078
+ page: 1,
2079
+ pageSize: 80,
2080
+ });
2081
+ if (memberResp?.ok) {
2082
+ const candidates = extractGroupMemberCandidates(memberResp.result);
2083
+ return {
2084
+ candidates,
2085
+ total: resolveGroupMembersTotalFromPayload(memberResp.result, candidates.length),
2086
+ };
2087
+ }
2088
+
2089
+ params.log?.warn?.(
2090
+ `[zapry] getChatMembers failed for chat ${params.chatId}: ` +
2091
+ `${memberResp?.description ?? "unknown"}, fallback to getChatAdministrators`,
2092
+ );
2093
+ const adminResp = await params.client.getChatAdministrators(params.chatId);
2094
+ if (adminResp?.ok) {
2095
+ const candidates = extractGroupMemberCandidates(adminResp.result);
2096
+ return {
2097
+ candidates,
2098
+ total: resolveGroupMembersTotalFromPayload(adminResp.result, candidates.length),
2099
+ errorCode: memberResp?.error_code,
2100
+ description: memberResp?.description,
2101
+ };
2102
+ }
2103
+
2104
+ return {
2105
+ candidates: [],
2106
+ total: 0,
2107
+ errorCode: adminResp?.error_code ?? memberResp?.error_code,
2108
+ description: adminResp?.description ?? memberResp?.description,
2109
+ };
2110
+ }
2111
+
2112
+ async function lookupMuteTargetCandidateFromGroupMembers(params: {
2113
+ client: ZapryApiClient;
2114
+ parsed: ParsedInboundMessage;
2115
+ intent: MuteCommandIntent;
2116
+ log?: RuntimeLog;
2117
+ }): Promise<InboundTargetUserHint | null> {
2118
+ const keywordCandidates = extractTargetKeywordCandidatesFromMuteText(params.parsed.sourceText);
2119
+ if (!keywordCandidates.length) {
2120
+ return null;
2121
+ }
2122
+
2123
+ try {
2124
+ const candidateByUserId = new Map<string, GroupMemberCandidate>();
2125
+ const upsertCandidate = (candidate: GroupMemberCandidate): void => {
2126
+ const existing = candidateByUserId.get(candidate.userId);
2127
+ if (!existing) {
2128
+ candidateByUserId.set(candidate.userId, candidate);
2129
+ return;
2130
+ }
2131
+ candidateByUserId.set(candidate.userId, {
2132
+ ...existing,
2133
+ displayName:
2134
+ existing.displayName.startsWith("用户(") && !candidate.displayName.startsWith("用户(")
2135
+ ? candidate.displayName
2136
+ : existing.displayName,
2137
+ username: existing.username ?? candidate.username,
2138
+ nick: existing.nick ?? candidate.nick,
2139
+ groupNick: existing.groupNick ?? candidate.groupNick,
2140
+ });
2141
+ };
2142
+ const appendCandidates = (items: GroupMemberCandidate[]): void => {
2143
+ for (const candidate of items) {
2144
+ upsertCandidate(candidate);
2145
+ }
2146
+ };
2147
+
2148
+ for (const keyword of keywordCandidates) {
2149
+ const memberResp = await params.client.getChatMembers(params.parsed.chatId, {
2150
+ page: 1,
2151
+ pageSize: 80,
2152
+ keyword,
2153
+ });
2154
+ if (!memberResp?.ok) {
2155
+ params.log?.warn?.(
2156
+ `[zapry] getChatMembers keyword lookup failed for chat ${params.parsed.chatId}, ` +
2157
+ `keyword=${keyword}: ${memberResp?.description ?? "unknown"}`,
2158
+ );
2159
+ continue;
2160
+ }
2161
+ appendCandidates(extractGroupMemberCandidates(memberResp.result));
2162
+ if (candidateByUserId.size >= 5) {
2163
+ break;
2164
+ }
2165
+ }
2166
+
2167
+ if (candidateByUserId.size === 0) {
2168
+ // Fallback to full member page when keyword search misses.
2169
+ const fullMemberResp = await params.client.getChatMembers(params.parsed.chatId, {
2170
+ page: 1,
2171
+ pageSize: 100,
2172
+ });
2173
+ if (fullMemberResp?.ok) {
2174
+ appendCandidates(extractGroupMemberCandidates(fullMemberResp.result));
2175
+ } else {
2176
+ params.log?.warn?.(
2177
+ `[zapry] getChatMembers full list failed for chat ${params.parsed.chatId}: ` +
2178
+ `${fullMemberResp?.description ?? "unknown"}, fallback to getChatAdministrators`,
2179
+ );
2180
+ }
2181
+ }
2182
+
2183
+ if (candidateByUserId.size === 0) {
2184
+ const adminResp = await params.client.getChatAdministrators(params.parsed.chatId);
2185
+ if (!adminResp?.ok) {
2186
+ params.log?.warn?.(
2187
+ `[zapry] fallback getChatAdministrators lookup failed for chat ${params.parsed.chatId}: ` +
2188
+ `${adminResp?.description ?? "unknown"}`,
2189
+ );
2190
+ return null;
2191
+ }
2192
+ appendCandidates(extractGroupMemberCandidates(adminResp.result));
2193
+ }
2194
+
2195
+ const candidates = Array.from(candidateByUserId.values());
2196
+ if (!candidates.length) {
2197
+ return null;
2198
+ }
2199
+ const scored = candidates
2200
+ .map((candidate) => ({
2201
+ candidate,
2202
+ score: scoreGroupMemberCandidateWithKeywordCandidates(
2203
+ candidate,
2204
+ keywordCandidates,
2205
+ params.parsed.senderId,
2206
+ ),
2207
+ }))
2208
+ .sort((a, b) => b.score - a.score);
2209
+ const top = scored[0];
2210
+ const second = scored[1];
2211
+ const hasStrongMatch =
2212
+ Boolean(top) &&
2213
+ (top.score >= 80 ||
2214
+ (top.score >= 70 && (!second || top.score - second.score >= 15)));
2215
+ if (!top || !hasStrongMatch) {
2216
+ return null;
2217
+ }
2218
+
2219
+ const resolved = top.candidate;
2220
+ return {
2221
+ userId: resolved.userId,
2222
+ username: resolved.username,
2223
+ displayName: resolved.displayName,
2224
+ source: "mentioned_users",
2225
+ raw: `member_lookup:${keywordCandidates[0]}`,
2226
+ };
2227
+ } catch (error) {
2228
+ params.log?.warn?.(
2229
+ `[zapry] member lookup threw for chat ${params.parsed.chatId}: ${String(error)}`,
2230
+ );
2231
+ return null;
2232
+ }
2233
+ }
2234
+
2235
+ function isLikelyAudioGenerationIntent(text: string | undefined): boolean {
2236
+ const normalized = text?.trim().toLowerCase();
2237
+ if (!normalized) {
2238
+ return false;
2239
+ }
2240
+ return [
2241
+ "生成音频",
2242
+ "做个音频",
2243
+ "做一个音频",
2244
+ "做铃声",
2245
+ "生成铃声",
2246
+ "生成mp3",
2247
+ "输出mp3",
2248
+ "配音",
2249
+ "朗读",
2250
+ "tts",
2251
+ "generate audio",
2252
+ "make audio",
2253
+ "ringtone",
2254
+ "render audio",
2255
+ ].some((token) => normalized.includes(token));
2256
+ }
2257
+
2258
+ function formatMuteTargetLabel(hint: InboundTargetUserHint): string {
2259
+ if (hint.displayName?.trim()) {
2260
+ return `“${hint.displayName.trim()}”`;
2261
+ }
2262
+ if (hint.username?.trim()) {
2263
+ return `@${hint.username.trim()}`;
2264
+ }
2265
+ return `用户(${hint.userId})`;
2266
+ }
2267
+
2268
+ function getMuteActionLabel(intent: MuteCommandIntent): string {
2269
+ return intent === "mute" ? "禁言" : "解除禁言";
2270
+ }
2271
+
2272
+ function buildMuteTargetMissingText(intent: MuteCommandIntent): string {
2273
+ const action = getMuteActionLabel(intent);
2274
+ return `我先查了群成员列表,但还没定位到目标成员。请补充更完整昵称(建议 2 字以上),或直接 @ 目标成员后再发“${action}”。`;
2275
+ }
2276
+
2277
+ function buildMuteCandidateConfirmText(
2278
+ intent: MuteCommandIntent,
2279
+ targetHint: InboundTargetUserHint,
2280
+ ): string {
2281
+ const confirmPhrase = intent === "mute" ? "确认禁言" : "确认解除禁言";
2282
+ const label = formatMuteTargetLabel(targetHint);
2283
+ return `我在本群匹配到候选成员:${label}(user_id: ${targetHint.userId})。回复“${confirmPhrase}”执行,回复“取消”结束。`;
2284
+ }
2285
+
2286
+ function buildMuteCancelText(intent: MuteCommandIntent): string {
2287
+ return `已取消本次${getMuteActionLabel(intent)}操作。`;
2288
+ }
2289
+
2290
+ function buildMuteDisambiguationText(
2291
+ intent: MuteCommandIntent,
2292
+ targetUserHints: InboundTargetUserHint[],
2293
+ ): string {
2294
+ const action = getMuteActionLabel(intent);
2295
+ const candidateText = targetUserHints
2296
+ .slice(0, 3)
2297
+ .map((hint) => formatMuteTargetLabel(hint))
2298
+ .join("、");
2299
+ if (!candidateText) {
2300
+ return `识别到多个候选成员,暂不执行以避免误操作。请直接 @ 目标成员,或回复其消息后再发“${action}”。`;
2301
+ }
2302
+ return `识别到多个候选成员(${candidateText}),暂不执行以避免误操作。请直接 @ 目标成员,或回复其消息后再发“${action}”。`;
2303
+ }
2304
+
2305
+ function buildMuteSuccessText(intent: MuteCommandIntent, targetHint: InboundTargetUserHint): string {
2306
+ const label = formatMuteTargetLabel(targetHint);
2307
+ return intent === "mute" ? `已将${label}禁言。` : `已解除${label}禁言。`;
2308
+ }
2309
+
2310
+ function buildMuteFailureText(intent: MuteCommandIntent, errorCode?: number, description?: string): string {
2311
+ const normalizedDesc = description?.toLowerCase() ?? "";
2312
+ if (normalizedDesc.includes("only bot owner can execute zapry skills")) {
2313
+ return "只能是主人才可以调用";
2314
+ }
2315
+ if (
2316
+ errorCode === 403 ||
2317
+ normalizedDesc.includes("no permission") ||
2318
+ normalizedDesc.includes("permission denied") ||
2319
+ normalizedDesc.includes("forbidden") ||
2320
+ normalizedDesc.includes("\"code\":1000045")
2321
+ ) {
2322
+ return "操作失败:我还没有群管理权限,请先把我设为管理员后重试。";
2323
+ }
2324
+ if (errorCode === 401 || normalizedDesc.includes("unauthorized")) {
2325
+ return "操作失败:身份校验未通过,请稍后重试。";
2326
+ }
2327
+ if (errorCode === 429) {
2328
+ return "操作过于频繁,请稍后再试。";
2329
+ }
2330
+ if (errorCode === 503 || normalizedDesc.includes("service unavailable")) {
2331
+ return "操作失败:服务暂时不可用(503),请稍后重试。";
2332
+ }
2333
+ if (errorCode === 404 || normalizedDesc.includes("not found")) {
2334
+ return "操作失败:未找到目标成员,请重新 @ 该成员后再试。";
2335
+ }
2336
+ return `操作失败:${getMuteActionLabel(intent)}未成功,请稍后重试。`;
2337
+ }
2338
+
2339
+ async function sendModerationQuickReply(params: {
2340
+ account: ResolvedZapryAccount;
2341
+ chatId: string;
2342
+ text: string;
2343
+ statusSink?: StatusSink;
2344
+ log?: RuntimeLog;
2345
+ }): Promise<boolean> {
2346
+ const { account, chatId, text, statusSink, log } = params;
2347
+ const result = await sendMessageZapry(account, `chat:${chatId}`, text);
2348
+ if (!result.ok) {
2349
+ log?.warn?.(
2350
+ `[${account.accountId}] moderation quick reply failed: ${result.error ?? "unknown"}`,
2351
+ );
2352
+ return false;
2353
+ }
2354
+ statusSink?.({ lastOutboundAt: Date.now() });
2355
+ return true;
2356
+ }
2357
+
2358
+ async function executeMuteCommandWithTarget(params: {
2359
+ client: ZapryApiClient;
2360
+ account: ResolvedZapryAccount;
2361
+ parsed: ParsedInboundMessage;
2362
+ intent: MuteCommandIntent;
2363
+ targetHint: InboundTargetUserHint;
2364
+ statusSink?: StatusSink;
2365
+ log?: RuntimeLog;
2366
+ }): Promise<boolean> {
2367
+ const { client, account, parsed, intent, targetHint, statusSink, log } = params;
2368
+ try {
2369
+ const resp = await client.muteChatMember(parsed.chatId, targetHint.userId, intent === "mute");
2370
+ if (resp.ok) {
2371
+ await sendModerationQuickReply({
2372
+ account,
2373
+ chatId: parsed.chatId,
2374
+ text: buildMuteSuccessText(intent, targetHint),
2375
+ statusSink,
2376
+ log,
2377
+ });
2378
+ return true;
2379
+ }
2380
+ log?.warn?.(
2381
+ `[zapry] muteChatMember failed chat=${parsed.chatId} target=${targetHint.userId} ` +
2382
+ `code=${String(resp.error_code ?? "unknown")} desc=${String(resp.description ?? "unknown")}`,
2383
+ );
2384
+ await sendModerationQuickReply({
2385
+ account,
2386
+ chatId: parsed.chatId,
2387
+ text: buildMuteFailureText(intent, resp.error_code, resp.description),
2388
+ statusSink,
2389
+ log,
2390
+ });
2391
+ return true;
2392
+ } catch (error) {
2393
+ log?.warn?.(`[${account.accountId}] mute quick path failed: ${String(error)}`);
2394
+ await sendModerationQuickReply({
2395
+ account,
2396
+ chatId: parsed.chatId,
2397
+ text: buildMuteFailureText(intent),
2398
+ statusSink,
2399
+ log,
2400
+ });
2401
+ return true;
2402
+ }
2403
+ }
2404
+
2405
+ async function tryHandleMuteCommandQuickPath(params: {
2406
+ account: ResolvedZapryAccount;
2407
+ parsed: ParsedInboundMessage;
2408
+ statusSink?: StatusSink;
2409
+ log?: RuntimeLog;
2410
+ }): Promise<boolean> {
2411
+ const { account, parsed, statusSink, log } = params;
2412
+ if (!parsed.isGroup) {
2413
+ return false;
2414
+ }
2415
+ const pending = getPendingMuteConfirmation(parsed.chatId, parsed.senderId);
2416
+ const confirmDecision = resolveMuteConfirmationDecision(parsed.sourceText);
2417
+ if (pending && confirmDecision) {
2418
+ clearPendingMuteConfirmation(parsed.chatId, parsed.senderId);
2419
+ if (confirmDecision === "cancel") {
2420
+ return sendModerationQuickReply({
2421
+ account,
2422
+ chatId: parsed.chatId,
2423
+ text: buildMuteCancelText(pending.intent),
2424
+ statusSink,
2425
+ log,
2426
+ });
2427
+ }
2428
+ const client = new ZapryApiClient(account.config.apiBaseUrl, account.botToken, {
2429
+ defaultHeaders: buildSkillInvocationHeaders({
2430
+ senderId: parsed.senderId,
2431
+ messageSid: parsed.messageSid,
2432
+ }),
2433
+ });
2434
+ return executeMuteCommandWithTarget({
2435
+ client,
2436
+ account,
2437
+ parsed,
2438
+ intent: pending.intent,
2439
+ targetHint: pending.target,
2440
+ statusSink,
2441
+ log,
2442
+ });
2443
+ }
2444
+
2445
+ if (!isLikelyMuteCommandMessage(parsed.sourceText, parsed.targetUserHints)) {
2446
+ return false;
2447
+ }
2448
+ const intent = resolveMuteCommandIntent(parsed.sourceText);
2449
+ if (!intent) {
2450
+ return false;
2451
+ }
2452
+
2453
+ if (pending) {
2454
+ clearPendingMuteConfirmation(parsed.chatId, parsed.senderId);
2455
+ }
2456
+
2457
+ const client = new ZapryApiClient(account.config.apiBaseUrl, account.botToken, {
2458
+ defaultHeaders: buildSkillInvocationHeaders({
2459
+ senderId: parsed.senderId,
2460
+ messageSid: parsed.messageSid,
2461
+ }),
2462
+ });
2463
+ if (parsed.targetUserHints.length === 0) {
2464
+ const lookedUpTarget = await lookupMuteTargetCandidateFromGroupMembers({
2465
+ client,
2466
+ parsed,
2467
+ intent,
2468
+ log,
2469
+ });
2470
+ if (lookedUpTarget) {
2471
+ setPendingMuteConfirmation({
2472
+ chatId: parsed.chatId,
2473
+ senderId: parsed.senderId,
2474
+ intent,
2475
+ target: lookedUpTarget,
2476
+ createdAtMs: Date.now(),
2477
+ });
2478
+ return sendModerationQuickReply({
2479
+ account,
2480
+ chatId: parsed.chatId,
2481
+ text: buildMuteCandidateConfirmText(intent, lookedUpTarget),
2482
+ statusSink,
2483
+ log,
2484
+ });
2485
+ }
2486
+ return sendModerationQuickReply({
2487
+ account,
2488
+ chatId: parsed.chatId,
2489
+ text: buildMuteTargetMissingText(intent),
2490
+ statusSink,
2491
+ log,
2492
+ });
2493
+ }
2494
+
2495
+ if (parsed.targetUserHints.length > 1) {
2496
+ return sendModerationQuickReply({
2497
+ account,
2498
+ chatId: parsed.chatId,
2499
+ text: buildMuteDisambiguationText(intent, parsed.targetUserHints),
2500
+ statusSink,
2501
+ log,
2502
+ });
2503
+ }
2504
+
2505
+ return executeMuteCommandWithTarget({
2506
+ client,
2507
+ account,
2508
+ parsed,
2509
+ intent,
2510
+ targetHint: parsed.targetUserHints[0],
2511
+ statusSink,
2512
+ log,
2513
+ });
2514
+ }
2515
+
2516
+ async function tryHandleGroupMembersQueryQuickPath(params: {
2517
+ account: ResolvedZapryAccount;
2518
+ parsed: ParsedInboundMessage;
2519
+ statusSink?: StatusSink;
2520
+ log?: RuntimeLog;
2521
+ }): Promise<boolean> {
2522
+ const { account, parsed, statusSink, log } = params;
2523
+ if (!parsed.isGroup) {
2524
+ return false;
2525
+ }
2526
+ if (!isLikelyGroupMembersQueryMessage(parsed.sourceText)) {
2527
+ return false;
2528
+ }
2529
+
2530
+ const client = new ZapryApiClient(account.config.apiBaseUrl, account.botToken, {
2531
+ defaultHeaders: buildSkillInvocationHeaders({
2532
+ senderId: parsed.senderId,
2533
+ messageSid: parsed.messageSid,
2534
+ }),
2535
+ });
2536
+ try {
2537
+ const lookup = await queryGroupMembersForQuickReply({
2538
+ client,
2539
+ chatId: parsed.chatId,
2540
+ log,
2541
+ });
2542
+ if (!lookup.candidates.length) {
2543
+ return sendModerationQuickReply({
2544
+ account,
2545
+ chatId: parsed.chatId,
2546
+ text: buildGroupMembersLookupFailureText(lookup.errorCode, lookup.description),
2547
+ statusSink,
2548
+ log,
2549
+ });
2550
+ }
2551
+ return sendModerationQuickReply({
2552
+ account,
2553
+ chatId: parsed.chatId,
2554
+ text: buildGroupMembersQuickReplyText(lookup.candidates, lookup.total),
2555
+ statusSink,
2556
+ log,
2557
+ });
2558
+ } catch (error) {
2559
+ log?.warn?.(`[${account.accountId}] group members quick path failed: ${String(error)}`);
2560
+ return sendModerationQuickReply({
2561
+ account,
2562
+ chatId: parsed.chatId,
2563
+ text: buildGroupMembersLookupFailureText(),
2564
+ statusSink,
2565
+ log,
2566
+ });
2567
+ }
2568
+ }
2569
+
2570
+ export async function tryHandleZapryInboundQuickPaths(params: {
2571
+ account: ResolvedZapryAccount;
2572
+ update: any;
2573
+ statusSink?: StatusSink;
2574
+ log?: RuntimeLog;
2575
+ }): Promise<boolean> {
2576
+ const parsed = parseInboundMessage(params.update);
2577
+ if (!parsed) {
2578
+ return false;
2579
+ }
2580
+ const handledByMuteQuickPath = await tryHandleMuteCommandQuickPath({
2581
+ account: params.account,
2582
+ parsed,
2583
+ statusSink: params.statusSink,
2584
+ log: params.log,
2585
+ });
2586
+ if (handledByMuteQuickPath) {
2587
+ params.statusSink?.({ lastInboundAt: Date.now() });
2588
+ return true;
2589
+ }
2590
+ const handledByGroupMembersQuickPath = await tryHandleGroupMembersQueryQuickPath({
2591
+ account: params.account,
2592
+ parsed,
2593
+ statusSink: params.statusSink,
2594
+ log: params.log,
2595
+ });
2596
+ if (handledByGroupMembersQuickPath) {
2597
+ params.statusSink?.({ lastInboundAt: Date.now() });
2598
+ return true;
2599
+ }
2600
+ return false;
2601
+ }
2602
+
2603
+ function summarizeMediaItem(item: ParsedInboundMediaItem): string {
2604
+ const parts: string[] = [item.kind];
2605
+ if (item.sourceTag === "video-thumb") {
2606
+ parts.push("source=video_thumb");
2607
+ } else if (item.sourceTag === "video-keyframe") {
2608
+ parts.push("source=video_keyframe");
2609
+ }
2610
+ if (item.url) {
2611
+ parts.push(`url=${item.url}`);
2612
+ }
2613
+ if (item.mimeType) {
2614
+ parts.push(`mime=${item.mimeType}`);
2615
+ }
2616
+ if (item.fileName) {
2617
+ parts.push(`name=${item.fileName}`);
2618
+ }
2619
+ if (item.duration !== undefined) {
2620
+ parts.push(`duration=${item.duration}`);
2621
+ }
2622
+ if (item.width !== undefined && item.height !== undefined) {
2623
+ parts.push(`size=${item.width}x${item.height}`);
2624
+ }
2625
+ if (item.fileSize !== undefined) {
2626
+ parts.push(`bytes=${item.fileSize}`);
2627
+ }
2628
+ if (item.resolvedFile?.downloadUrl) {
2629
+ parts.push("resolved=authorized_url");
2630
+ } else if (item.resolvedFile?.error) {
2631
+ parts.push(`resolve_error=${item.resolvedFile.error}`);
2632
+ }
2633
+ if (item.resolvedThumbFile?.downloadUrl) {
2634
+ parts.push("thumb_resolved=authorized_url");
2635
+ }
2636
+ return parts.join(" | ");
2637
+ }
2638
+
2639
+ function formatRecentContextSection(recentContext: RecentContextItem[]): string {
2640
+ const lines: string[] = ["[最近群聊消息上下文]"];
2641
+ lines.push("- 以下是本群最近的消息记录(从新到旧),帮助你理解当前对话背景:");
2642
+ for (const item of recentContext) {
2643
+ const sender = item.sender_name || item.sender_id;
2644
+ const parts: string[] = [`${sender}`];
2645
+ if (item.type !== "text") {
2646
+ parts.push(`[${item.type}]`);
2647
+ }
2648
+ if (item.text) {
2649
+ parts.push(item.text.length > 200 ? item.text.slice(0, 200) + "..." : item.text);
2650
+ }
2651
+ if (item.file_id) {
2652
+ parts.push(`[媒体已作为附件提供]`);
2653
+ }
2654
+ lines.push(` - ${parts.join(" | ")}`);
2655
+ }
2656
+ return lines.join("\n");
2657
+ }
2658
+
2659
+ function formatEnrichedReplySection(reply: EnrichedReplyInfo): string {
2660
+ const lines: string[] = ["[被引用消息内容]"];
2661
+ lines.push("- 用户回复引用了以下消息,请结合该消息内容理解用户意图:");
2662
+ if (reply.senderId) {
2663
+ lines.push(` - 发送者: ${reply.senderId}`);
2664
+ }
2665
+ if (reply.text) {
2666
+ lines.push(` - 文本: ${reply.text}`);
2667
+ }
2668
+ if (reply.fileId) {
2669
+ lines.push(` - 媒体(${reply.mediaType ?? "file"}): [已作为附件提供]`);
2670
+ }
2671
+ return lines.join("\n");
2672
+ }
2673
+
2674
+ function buildInboundBody(
2675
+ sourceText: string | undefined,
2676
+ mediaItems: ParsedInboundMediaItem[],
2677
+ transcript?: string,
2678
+ targetUserHints: InboundTargetUserHint[] = [],
2679
+ recentContext?: RecentContextItem[],
2680
+ enrichedReply?: EnrichedReplyInfo,
2681
+ ): string {
2682
+ const contextSections: string[] = [];
2683
+ if (enrichedReply) {
2684
+ contextSections.push(formatEnrichedReplySection(enrichedReply));
2685
+ }
2686
+ if (recentContext && recentContext.length > 0) {
2687
+ contextSections.push(formatRecentContextSection(recentContext));
2688
+ }
2689
+ const contextBlock = contextSections.length > 0 ? "\n\n" + contextSections.join("\n\n") : "";
2690
+
2691
+ const normalizedText = sourceText?.trim();
2692
+ const transcriptRequested = isExplicitTranscriptRequest(normalizedText);
2693
+ if (!mediaItems.length) {
2694
+ if (!normalizedText && targetUserHints.length === 0) {
2695
+ return contextBlock.trim();
2696
+ }
2697
+ if (targetUserHints.length === 0) {
2698
+ return (normalizedText ?? "") + contextBlock;
2699
+ }
2700
+ const lines: string[] = [];
2701
+ if (normalizedText) {
2702
+ lines.push(normalizedText, "");
2703
+ } else {
2704
+ lines.push("收到一条文本消息。", "");
2705
+ }
2706
+ lines.push("[群管理目标候选]");
2707
+ for (const hint of targetUserHints) {
2708
+ lines.push(`- ${summarizeTargetUserHint(hint)}`);
2709
+ }
2710
+ lines.push(
2711
+ "- 若本轮涉及禁言/移出群,优先使用上述 user_id,避免再次向用户索要 ID。",
2712
+ "- `mute-chat-member` 仅支持 `mute=true/false`,不支持时长参数。",
2713
+ "",
2714
+ "[群管理目标结构化数据]",
2715
+ "```json",
2716
+ JSON.stringify({ targetUsers: targetUserHints }, null, 2),
2717
+ "```",
2718
+ );
2719
+ return lines.join("\n") + contextBlock;
2720
+ }
2721
+ const stageErrors = mediaItems
2722
+ .map((item) => item.stageError)
2723
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0);
2724
+ const hasStagedMedia = mediaItems.some((item) => Boolean(item.stagedPath));
2725
+
2726
+ const lines: string[] = [];
2727
+ if (normalizedText) {
2728
+ lines.push(normalizedText, "");
2729
+ } else {
2730
+ lines.push("收到一条媒体消息。", "");
2731
+ }
2732
+
2733
+ lines.push("[媒体处理约定]");
2734
+ if (normalizedText) {
2735
+ lines.push("- 用户已经给出文字要求,请优先按该要求处理媒体内容。");
2736
+ } else {
2737
+ lines.push("- 用户未附加文字时,默认直接理解并分析媒体内容,不要先追问是否需要解析。");
2738
+ }
2739
+ lines.push("- 媒体文件已下载到本地,请使用 [media attached: ...] 中的本地路径来读取/分析文件内容。");
2740
+ lines.push("- 对于 PDF 文件,使用 pdf 工具并传入 [media attached: ...] 中显示的本地文件路径。");
2741
+ if (mediaItems.length > 0 && mediaItems.every((item) => isAudioLikeMedia(item))) {
2742
+ lines.push("- 若这是纯语音/音频消息,默认本轮直接输出转写;若还能总结,再补 1-3 条要点。");
2743
+ lines.push("- 禁止只回复“我已收到”“我现在开始转写”“稍后给你结果”之类进度说明。");
2744
+ }
2745
+ lines.push("- 只有在媒体本体下载失败或权限失败时,才向用户说明并请求重试。", "");
2746
+
2747
+ if (targetUserHints.length > 0) {
2748
+ lines.push("[群管理目标候选]");
2749
+ for (const hint of targetUserHints) {
2750
+ lines.push(`- ${summarizeTargetUserHint(hint)}`);
2751
+ }
2752
+ lines.push("- 若本轮涉及禁言/移出群,优先使用上述 user_id,避免再次向用户索要 ID。", "");
2753
+ }
2754
+
2755
+ const keyframeItems = mediaItems.filter((item) => item.sourceTag === "video-keyframe");
2756
+ if (keyframeItems.length > 0) {
2757
+ lines.push("[视频关键帧说明]");
2758
+ lines.push(`- 以下 ${keyframeItems.length} 张图片是从视频中按时间均匀抽取的关键帧,已按时间顺序排列。`);
2759
+ lines.push("- 请结合所有关键帧来理解视频的完整内容、场景变化和动作序列。");
2760
+ lines.push("- 关键帧文件名中的 at-Xs 表示该帧在视频中的时间位置(秒)。");
2761
+ lines.push("- 回复时请综合所有帧信息进行整体分析,不要逐帧罗列。", "");
2762
+ }
2763
+
2764
+ if (!hasStagedMedia && stageErrors.length > 0) {
2765
+ lines.push("[媒体下载状态]");
2766
+ lines.push("- 当前媒体本体下载失败,禁止根据历史上下文猜测图片/视频/文件内容。");
2767
+ lines.push("- 只允许向用户说明当前无法读取该媒体,并建议稍后重试或改用文件/原图方式发送。");
2768
+ lines.push("- 最近错误:");
2769
+ for (const err of stageErrors.slice(0, 3)) {
2770
+ lines.push(` - ${err}`);
2771
+ }
2772
+ lines.push("");
2773
+ }
2774
+ const transcriptItems = mediaItems.filter(
2775
+ (item): item is ParsedInboundMediaItem & { transcript: string } =>
2776
+ isAudioLikeMedia(item) && typeof item.transcript === "string" && item.transcript.trim().length > 0,
2777
+ );
2778
+ const transcriptErrors = mediaItems
2779
+ .filter((item) => isAudioLikeMedia(item) && typeof item.transcriptError === "string" && item.transcriptError.trim().length > 0)
2780
+ .map((item) => item.transcriptError as string);
2781
+ if (transcriptItems.length > 0) {
2782
+ if (transcriptRequested) {
2783
+ lines.push("[语音转写结果]");
2784
+ if (transcript?.trim()) {
2785
+ lines.push(transcript.trim());
2786
+ } else {
2787
+ for (const [index, item] of transcriptItems.entries()) {
2788
+ lines.push(`- 音频${index + 1}: ${item.transcript}`);
2789
+ }
2790
+ }
2791
+ lines.push("- 用户明确要求转写/转文字时,可以直接输出上述真实转写文本。", "");
2792
+ } else {
2793
+ lines.push("[内部语音理解]");
2794
+ lines.push("- 以下是真实转写,仅供理解用户意图,默认不要回显给用户:");
2795
+ if (transcript?.trim()) {
2796
+ lines.push(transcript.trim());
2797
+ } else {
2798
+ for (const [index, item] of transcriptItems.entries()) {
2799
+ lines.push(`- 音频${index + 1}: ${item.transcript}`);
2800
+ }
2801
+ }
2802
+ lines.push("- 请把它当成用户本轮真实输入,直接回复其意图。只有用户明确要求“转文字/逐字稿”时,才输出转写文本本身。", "");
2803
+ }
2804
+ } else if (mediaItems.some((item) => isAudioLikeMedia(item) && item.stagedPath) && transcriptErrors.length > 0) {
2805
+ lines.push("[语音转写状态]");
2806
+ lines.push("- 当前未拿到真实转写文本,禁止根据历史上下文或用户追问文本猜测音频内容。");
2807
+ lines.push("- 最近错误:");
2808
+ for (const err of transcriptErrors.slice(0, 3)) {
2809
+ lines.push(` - ${err}`);
2810
+ }
2811
+ lines.push("");
2812
+ }
2813
+
2814
+ lines.push("[媒体信息]");
2815
+ for (const item of mediaItems) {
2816
+ lines.push(`- ${summarizeMediaItem(item)}`);
2817
+ }
2818
+
2819
+ const sanitizedMedia = mediaItems.map(({ fileId, resolvedFile, resolvedThumbFile, ...rest }) => ({
2820
+ ...rest,
2821
+ ...(resolvedFile?.contentType ? { contentType: resolvedFile.contentType } : {}),
2822
+ ...(resolvedFile?.fileSize !== undefined ? { fileSize: resolvedFile.fileSize } : {}),
2823
+ ...(resolvedFile?.fileName ? { fileName: resolvedFile.fileName } : {}),
2824
+ }));
2825
+ const structuredPayload: Record<string, unknown> = { media: sanitizedMedia };
2826
+ if (targetUserHints.length > 0) {
2827
+ structuredPayload.targetUsers = targetUserHints;
2828
+ }
2829
+ lines.push("", "[媒体结构化数据]", "```json", JSON.stringify(structuredPayload, null, 2), "```");
2830
+ return lines.join("\n") + contextBlock;
2831
+ }
2832
+
2833
+ function parseInboundMessage(update: any): ParsedInboundMessage | null {
2834
+ const updateRecord = asRecord(update);
2835
+ let message =
2836
+ asRecord(updateRecord?.message) ??
2837
+ asRecord(updateRecord?.channel_post) ??
2838
+ asRecord(updateRecord?.edited_message) ??
2839
+ asRecord(updateRecord?.edited_channel_post) ??
2840
+ asRecord(asRecord(updateRecord?.callback_query)?.message) ??
2841
+ asRecord(updateRecord?.msg) ??
2842
+ asRecord(updateRecord?.Message);
2843
+ if (!message && updateRecord) {
2844
+ const looksLikeMessageEnvelope =
2845
+ updateRecord.text !== undefined ||
2846
+ updateRecord.caption !== undefined ||
2847
+ updateRecord.photo !== undefined ||
2848
+ updateRecord.Photo !== undefined ||
2849
+ updateRecord.video !== undefined ||
2850
+ updateRecord.Video !== undefined ||
2851
+ updateRecord.document !== undefined ||
2852
+ updateRecord.Document !== undefined ||
2853
+ updateRecord.audio !== undefined ||
2854
+ updateRecord.Audio !== undefined ||
2855
+ updateRecord.voice !== undefined ||
2856
+ updateRecord.Voice !== undefined ||
2857
+ updateRecord.animation !== undefined ||
2858
+ updateRecord.Animation !== undefined ||
2859
+ updateRecord.media !== undefined ||
2860
+ updateRecord.attachments !== undefined ||
2861
+ updateRecord.files !== undefined ||
2862
+ updateRecord.media_items !== undefined ||
2863
+ updateRecord.MediaItems !== undefined;
2864
+ if (looksLikeMessageEnvelope) {
2865
+ message = updateRecord;
2866
+ }
2867
+ }
2868
+ if (!message) {
2869
+ return null;
2870
+ }
2871
+
2872
+ const sourceText =
2873
+ asNonEmptyString(message.text) ??
2874
+ asNonEmptyString(message.Text) ??
2875
+ asNonEmptyString(message.caption) ??
2876
+ asNonEmptyString(message.Caption) ??
2877
+ asNonEmptyString(message.content) ??
2878
+ asNonEmptyString(message.Content) ??
2879
+ asNonEmptyString(message.message) ??
2880
+ asNonEmptyString(message.Message);
2881
+ const mediaItems = extractInboundMediaItems(message);
2882
+ const targetUserHints = extractTargetUserHints(message, sourceText);
2883
+ if (!sourceText && mediaItems.length === 0) {
2884
+ return null;
2885
+ }
2886
+
2887
+ const chat =
2888
+ asRecord(message.chat) ??
2889
+ asRecord(message.Chat) ??
2890
+ asRecord(message.conversation) ??
2891
+ asRecord(message.Conversation) ??
2892
+ {};
2893
+ const from =
2894
+ asRecord(message.from) ??
2895
+ asRecord(message.From) ??
2896
+ asRecord(message.sender) ??
2897
+ asRecord(message.Sender) ??
2898
+ asRecord(message.user) ??
2899
+ {};
2900
+
2901
+ const chatId =
2902
+ asNonEmptyString(chat.id != null ? String(chat.id) : undefined) ??
2903
+ asNonEmptyString(message.chat_id != null ? String(message.chat_id) : undefined) ??
2904
+ asNonEmptyString(message.chatId != null ? String(message.chatId) : undefined) ??
2905
+ asNonEmptyString(message.conversation_id != null ? String(message.conversation_id) : undefined) ??
2906
+ asNonEmptyString(message.conversationId != null ? String(message.conversationId) : undefined) ??
2907
+ asNonEmptyString(message.to_id != null ? String(message.to_id) : undefined) ??
2908
+ asNonEmptyString(message.toId != null ? String(message.toId) : undefined);
2909
+ if (!chatId) {
2910
+ return null;
2911
+ }
2912
+
2913
+ const senderId =
2914
+ asNonEmptyString(from.id != null ? String(from.id) : undefined) ??
2915
+ asNonEmptyString(from.user_id != null ? String(from.user_id) : undefined) ??
2916
+ asNonEmptyString(from.userId != null ? String(from.userId) : undefined) ??
2917
+ asNonEmptyString(message.sender_id != null ? String(message.sender_id) : undefined) ??
2918
+ asNonEmptyString(message.senderId != null ? String(message.senderId) : undefined) ??
2919
+ asNonEmptyString(message.from_id != null ? String(message.from_id) : undefined) ??
2920
+ asNonEmptyString(message.fromId != null ? String(message.fromId) : undefined) ??
2921
+ chatId;
2922
+
2923
+ const senderName =
2924
+ asNonEmptyString(from.name) ??
2925
+ asNonEmptyString(from.username) ??
2926
+ asNonEmptyString(from.first_name) ??
2927
+ asNonEmptyString(message.sender_name);
2928
+
2929
+ const chatType = asNonEmptyString(chat.type);
2930
+ const isGroup =
2931
+ chatType === "group" ||
2932
+ chatType === "supergroup" ||
2933
+ chatType === "channel" ||
2934
+ chatId !== senderId;
2935
+
2936
+ const messageSid = String(
2937
+ message.message_id ?? message.messageId ?? message.id ?? update?.update_id ?? Date.now(),
2938
+ );
2939
+
2940
+ let recentContext: RecentContextItem[] | undefined;
2941
+ const rawRecentContext = message.recent_context ?? message.recentContext;
2942
+ if (Array.isArray(rawRecentContext) && rawRecentContext.length > 0) {
2943
+ recentContext = rawRecentContext.filter(
2944
+ (item: any) => item && typeof item === "object" && item.message_id,
2945
+ ) as RecentContextItem[];
2946
+ if (recentContext.length === 0) recentContext = undefined;
2947
+ }
2948
+
2949
+ let enrichedReply: EnrichedReplyInfo | undefined;
2950
+ const rp = asRecord(message.reply_parameters ?? message.replyParameters);
2951
+ if (rp) {
2952
+ const rpText = asNonEmptyString(rp.text);
2953
+ const rpMediaUrl = asNonEmptyString(rp.media_url ?? rp.mediaUrl);
2954
+ const rpMediaType = asNonEmptyString(rp.media_type ?? rp.mediaType);
2955
+ const rpSenderId = asNonEmptyString(rp.sender_id ?? rp.senderId);
2956
+ const rpMessageId = asNonEmptyString(rp.message_id ?? rp.messageId);
2957
+ const rpFileId = asNonEmptyString(rp.file_id ?? rp.fileId);
2958
+ if (rpText || rpMediaUrl || rpFileId) {
2959
+ enrichedReply = {
2960
+ messageId: rpMessageId,
2961
+ text: rpText,
2962
+ mediaUrl: rpMediaUrl,
2963
+ mediaType: rpMediaType,
2964
+ senderId: rpSenderId,
2965
+ fileId: rpFileId,
2966
+ };
2967
+ }
2968
+ }
2969
+
2970
+ return {
2971
+ sourceText,
2972
+ mediaItems,
2973
+ senderId,
2974
+ senderName,
2975
+ targetUserHints,
2976
+ chatId,
2977
+ chatType,
2978
+ isGroup,
2979
+ messageSid,
2980
+ timestampMs:
2981
+ parseTimestampMs(message.date) ??
2982
+ parseTimestampMs(message.time) ??
2983
+ parseTimestampMs(message.timestamp) ??
2984
+ parseTimestampMs(update?.edit_date) ??
2985
+ parseTimestampMs(update?.date),
2986
+ recentContext,
2987
+ enrichedReply,
2988
+ };
2989
+ }
2990
+
2991
+ async function resolveInboundFile(
2992
+ client: ZapryApiClient,
2993
+ fileId: string,
2994
+ log?: RuntimeLog,
2995
+ ): Promise<ResolvedInboundFile> {
2996
+ try {
2997
+ const response = await withTimeout(
2998
+ client.getFile(fileId),
2999
+ INBOUND_FILE_RESOLVE_TIMEOUT_MS,
3000
+ `resolve inbound file ${fileId}`,
3001
+ );
3002
+ if (!response?.ok) {
3003
+ const error = response?.description?.trim() || "get-file failed";
3004
+ log?.warn?.(`[zapry] resolve inbound file failed for ${fileId}: ${error}`);
3005
+ return { fileId, error };
3006
+ }
3007
+
3008
+ const result = asRecord(response.result);
3009
+ const downloadUrl = asNonEmptyString(result?.download_url ?? result?.file_path);
3010
+ if (!downloadUrl) {
3011
+ const error = "get-file returned no download_url";
3012
+ log?.warn?.(`[zapry] resolve inbound file missing download_url for ${fileId}`);
3013
+ return { fileId, error };
3014
+ }
3015
+
3016
+ return {
3017
+ fileId,
3018
+ downloadUrl,
3019
+ downloadMethod: asNonEmptyString(result?.download_method),
3020
+ downloadHeaders: normalizeStringRecord(result?.download_headers),
3021
+ expiresAt: asNonEmptyString(result?.expires_at),
3022
+ contentType: asNonEmptyString(result?.content_type),
3023
+ fileSize: parseFiniteNumber(result?.file_size),
3024
+ fileName: asNonEmptyString(result?.file_name),
3025
+ };
3026
+ } catch (error) {
3027
+ const message = String(error);
3028
+ log?.warn?.(`[zapry] resolve inbound file threw for ${fileId}: ${message}`);
3029
+ return { fileId, error: message };
3030
+ }
3031
+ }
3032
+
3033
+ const CONTEXT_MEDIA_MAX_ITEMS = 3;
3034
+
3035
+ function contextTypeToMediaKind(type: string): ParsedInboundMediaKind | undefined {
3036
+ switch (type) {
3037
+ case "image": return "photo";
3038
+ case "video": return "video";
3039
+ case "file": return "document";
3040
+ case "audio": return "audio";
3041
+ default: return undefined;
3042
+ }
3043
+ }
3044
+
3045
+ function buildContextMediaItems(
3046
+ recentContext?: RecentContextItem[],
3047
+ enrichedReply?: EnrichedReplyInfo,
3048
+ existingMediaItems?: ParsedInboundMediaItem[],
3049
+ ): ParsedInboundMediaItem[] {
3050
+ const items: ParsedInboundMediaItem[] = [];
3051
+ const existingFileIds = new Set(
3052
+ (existingMediaItems ?? []).map((m) => m.fileId).filter(Boolean),
3053
+ );
3054
+
3055
+ if (enrichedReply?.fileId && !existingFileIds.has(enrichedReply.fileId)) {
3056
+ const kind = contextTypeToMediaKind(enrichedReply.mediaType ?? "file") ?? "document";
3057
+ items.push({ kind, fileId: enrichedReply.fileId });
3058
+ existingFileIds.add(enrichedReply.fileId);
3059
+ }
3060
+
3061
+ if (recentContext?.length) {
3062
+ let added = 0;
3063
+ for (const ctx of recentContext) {
3064
+ if (added >= CONTEXT_MEDIA_MAX_ITEMS) break;
3065
+ if (!ctx.file_id) continue;
3066
+ if (existingFileIds.has(ctx.file_id)) continue;
3067
+ const kind = contextTypeToMediaKind(ctx.type) ?? "document";
3068
+ items.push({ kind, fileId: ctx.file_id });
3069
+ existingFileIds.add(ctx.file_id);
3070
+ added++;
3071
+ }
3072
+ }
3073
+
3074
+ return items;
3075
+ }
3076
+
3077
+ async function enrichInboundMediaItems(
3078
+ account: ResolvedZapryAccount,
3079
+ mediaItems: ParsedInboundMediaItem[],
3080
+ log?: RuntimeLog,
3081
+ ): Promise<ParsedInboundMediaItem[]> {
3082
+ if (!mediaItems.length) {
3083
+ return mediaItems;
3084
+ }
3085
+
3086
+ const client = new ZapryApiClient(account.config.apiBaseUrl, account.botToken);
3087
+ const resolveCache = new Map<string, Promise<ResolvedInboundFile>>();
3088
+ const getResolvedFile = (fileId: string): Promise<ResolvedInboundFile> => {
3089
+ const existing = resolveCache.get(fileId);
3090
+ if (existing) {
3091
+ return existing;
3092
+ }
3093
+ const pending = resolveInboundFile(client, fileId, log);
3094
+ resolveCache.set(fileId, pending);
3095
+ return pending;
3096
+ };
3097
+
3098
+ return Promise.all(
3099
+ mediaItems.map(async (item) => {
3100
+ const resolvedFile = item.fileId ? await getResolvedFile(item.fileId) : undefined;
3101
+ const resolvedThumbFile = item.thumbFileId ? await getResolvedFile(item.thumbFileId) : undefined;
3102
+
3103
+ const enriched: ParsedInboundMediaItem = {
3104
+ ...item,
3105
+ resolvedFile,
3106
+ resolvedThumbFile,
3107
+ };
3108
+
3109
+ if (!enriched.mimeType && resolvedFile?.contentType) {
3110
+ enriched.mimeType = resolvedFile.contentType;
3111
+ }
3112
+ if (enriched.fileSize === undefined && resolvedFile?.fileSize !== undefined) {
3113
+ enriched.fileSize = resolvedFile.fileSize;
3114
+ }
3115
+ if (!enriched.fileName && resolvedFile?.fileName) {
3116
+ enriched.fileName = resolvedFile.fileName;
3117
+ }
3118
+ if (!enriched.url && resolvedFile?.downloadUrl) {
3119
+ enriched.url = resolvedFile.downloadUrl;
3120
+ }
3121
+ if (!enriched.thumbUrl && resolvedThumbFile?.downloadUrl) {
3122
+ enriched.thumbUrl = resolvedThumbFile.downloadUrl;
3123
+ }
3124
+
3125
+ return enriched;
3126
+ }),
3127
+ );
3128
+ }
3129
+
3130
+ function resolveRuntime(explicitRuntime?: any): any | null {
3131
+ const isPluginRuntime = (candidate: any): boolean =>
3132
+ typeof candidate?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher === "function";
3133
+
3134
+ if (isPluginRuntime(explicitRuntime)) {
3135
+ return explicitRuntime;
3136
+ }
3137
+ try {
3138
+ const fallback = getZapryRuntime();
3139
+ return isPluginRuntime(fallback) ? fallback : null;
3140
+ } catch {
3141
+ return null;
3142
+ }
3143
+ }
3144
+
3145
+ function resolveConfig(explicitCfg: any, runtime: any): any {
3146
+ if (explicitCfg) {
3147
+ return explicitCfg;
3148
+ }
3149
+ const loadConfig = runtime?.config?.loadConfig;
3150
+ if (typeof loadConfig === "function") {
3151
+ try {
3152
+ return loadConfig();
3153
+ } catch {
3154
+ return {};
3155
+ }
3156
+ }
3157
+ return {};
3158
+ }
3159
+
3160
+ function resolveStorePath(runtime: any, cfg: any, agentId?: string): string | undefined {
3161
+ const resolver = runtime?.channel?.session?.resolveStorePath;
3162
+ if (typeof resolver !== "function") {
3163
+ return undefined;
3164
+ }
3165
+ try {
3166
+ return resolver(cfg?.session?.store, { agentId });
3167
+ } catch {
3168
+ return undefined;
3169
+ }
3170
+ }
3171
+
3172
+ function resolveRoute(params: {
3173
+ runtime: any;
3174
+ cfg: any;
3175
+ accountId: string;
3176
+ peer: { kind: "group" | "direct"; id: string };
3177
+ }): { agentId: string; accountId: string; sessionKey: string } {
3178
+ const resolver = params.runtime?.channel?.routing?.resolveAgentRoute;
3179
+ if (typeof resolver === "function") {
3180
+ try {
3181
+ const route = resolver({
3182
+ cfg: params.cfg,
3183
+ channel: "zapry",
3184
+ accountId: params.accountId,
3185
+ peer: params.peer,
3186
+ });
3187
+ if (
3188
+ route &&
3189
+ typeof route.agentId === "string" &&
3190
+ typeof route.accountId === "string" &&
3191
+ typeof route.sessionKey === "string"
3192
+ ) {
3193
+ return route;
3194
+ }
3195
+ } catch {
3196
+ // fall through to local fallback
3197
+ }
3198
+ }
3199
+ return {
3200
+ agentId: "main",
3201
+ accountId: params.accountId,
3202
+ sessionKey: `agent:main:zapry:${params.peer.kind}:${params.peer.id}`,
3203
+ };
3204
+ }
3205
+
3206
+ function extractMediaUrls(payload: any): string[] {
3207
+ const urls: string[] = [];
3208
+
3209
+ if (Array.isArray(payload?.mediaUrls)) {
3210
+ for (const item of payload.mediaUrls) {
3211
+ if (typeof item === "string" && item.trim()) {
3212
+ urls.push(item.trim());
3213
+ }
3214
+ }
3215
+ }
3216
+
3217
+ if (typeof payload?.mediaUrl === "string" && payload.mediaUrl.trim()) {
3218
+ urls.push(payload.mediaUrl.trim());
3219
+ }
3220
+
3221
+ return urls;
3222
+ }
3223
+
3224
+ async function deliverZapryReply(params: {
3225
+ runtime: any;
3226
+ cfg: any;
3227
+ account: ResolvedZapryAccount;
3228
+ chatId: string;
3229
+ payload: any;
3230
+ statusSink?: StatusSink;
3231
+ log?: RuntimeLog;
3232
+ }): Promise<void> {
3233
+ const { runtime, cfg, account, chatId, payload, statusSink, log } = params;
3234
+
3235
+ const textRuntime = runtime?.channel?.text;
3236
+ const tableMode =
3237
+ typeof textRuntime?.resolveMarkdownTableMode === "function"
3238
+ ? textRuntime.resolveMarkdownTableMode({
3239
+ cfg,
3240
+ channel: "zapry",
3241
+ accountId: account.accountId,
3242
+ })
3243
+ : "code";
3244
+
3245
+ let text = typeof payload?.text === "string" ? payload.text : "";
3246
+ if (typeof textRuntime?.convertMarkdownTables === "function") {
3247
+ try {
3248
+ text = textRuntime.convertMarkdownTables(text, tableMode);
3249
+ } catch {
3250
+ // Keep original text when table conversion fails.
3251
+ }
3252
+ }
3253
+
3254
+ const mediaUrls = extractMediaUrls(payload);
3255
+ for (const mediaUrl of mediaUrls) {
3256
+ const mediaResult = await sendMessageZapry(account, `chat:${chatId}`, "", { mediaUrl });
3257
+ if (!mediaResult.ok) {
3258
+ log?.warn?.(`[${account.accountId}] Zapry media reply failed: ${mediaResult.error ?? "unknown"}`);
3259
+ continue;
3260
+ }
3261
+ statusSink?.({ lastOutboundAt: Date.now() });
3262
+ }
3263
+
3264
+ if (!text.trim()) {
3265
+ return;
3266
+ }
3267
+
3268
+ const chunkMode =
3269
+ typeof textRuntime?.resolveChunkMode === "function"
3270
+ ? textRuntime.resolveChunkMode(cfg, "zapry", account.accountId)
3271
+ : undefined;
3272
+ const chunks =
3273
+ typeof textRuntime?.chunkMarkdownTextWithMode === "function"
3274
+ ? textRuntime.chunkMarkdownTextWithMode(text, 4096, chunkMode)
3275
+ : [text];
3276
+
3277
+ const normalizedChunks = Array.isArray(chunks) && chunks.length > 0 ? chunks : [text];
3278
+ for (const chunk of normalizedChunks) {
3279
+ if (typeof chunk !== "string" || !chunk.trim()) {
3280
+ continue;
3281
+ }
3282
+ const textResult = await sendMessageZapry(account, `chat:${chatId}`, chunk);
3283
+ if (!textResult.ok) {
3284
+ log?.warn?.(`[${account.accountId}] Zapry text reply failed: ${textResult.error ?? "unknown"}`);
3285
+ continue;
3286
+ }
3287
+ statusSink?.({ lastOutboundAt: Date.now() });
3288
+ }
3289
+ }
3290
+
3291
+ export async function processZapryInboundUpdate(params: ProcessInboundParams): Promise<boolean> {
3292
+ const { account, update, statusSink, log } = params;
3293
+ const parsed = parseInboundMessage(update);
3294
+ if (!parsed) {
3295
+ return false;
3296
+ }
3297
+
3298
+ const handledByMuteQuickPath = await tryHandleMuteCommandQuickPath({
3299
+ account,
3300
+ parsed,
3301
+ statusSink,
3302
+ log,
3303
+ });
3304
+ if (handledByMuteQuickPath) {
3305
+ statusSink?.({ lastInboundAt: Date.now() });
3306
+ return true;
3307
+ }
3308
+
3309
+ const runtime = resolveRuntime(params.runtime);
3310
+ if (!runtime) {
3311
+ return false;
3312
+ }
3313
+
3314
+ const dispatchReply = runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher;
3315
+ if (typeof dispatchReply !== "function") {
3316
+ return false;
3317
+ }
3318
+
3319
+ const cfg = resolveConfig(params.cfg, runtime);
3320
+ const botOwnerId = resolveOwnerIdFromBotToken(account.botToken);
3321
+ const senderIsOwner = sameUserIdentity(parsed.senderId, botOwnerId);
3322
+ const nonOwnerSkillGuidance = buildNonOwnerSkillReplyGuidance(senderIsOwner);
3323
+ const contextMediaItems = buildContextMediaItems(parsed.recentContext, parsed.enrichedReply, parsed.mediaItems);
3324
+ const allMediaItems = [...parsed.mediaItems, ...contextMediaItems];
3325
+ const resolvedMediaItems = await enrichInboundMediaItems(account, allMediaItems, log);
3326
+ const expandedMediaItems = appendVideoThumbnailMediaItems(resolvedMediaItems);
3327
+ const stagedMediaItems = await stageInboundMediaItems(runtime, expandedMediaItems, log);
3328
+ const finalizedMediaItems = await appendVideoKeyframeItems(runtime, stagedMediaItems, log);
3329
+ const { mediaItems, transcript } = await transcribeInboundAudioItems(runtime, cfg, finalizedMediaItems, log);
3330
+ const agentMediaItems = sanitizeMediaItemsForAgent(mediaItems);
3331
+ const rawBody = buildInboundBody(
3332
+ parsed.sourceText,
3333
+ agentMediaItems,
3334
+ transcript,
3335
+ parsed.targetUserHints,
3336
+ parsed.recentContext,
3337
+ parsed.enrichedReply,
3338
+ );
3339
+ const bodyForAgent = appendGuidanceBlock(rawBody, nonOwnerSkillGuidance);
3340
+ if (!bodyForAgent) {
3341
+ return false;
3342
+ }
3343
+ const commandBodyBase = resolveCommandBody(
3344
+ parsed.sourceText,
3345
+ mediaItems,
3346
+ transcript,
3347
+ parsed.targetUserHints,
3348
+ );
3349
+ const commandBody = appendGuidanceBlock(commandBodyBase, nonOwnerSkillGuidance);
3350
+ const stagedMedia = mediaItems.flatMap((item) =>
3351
+ item.stagedPath
3352
+ ? [
3353
+ {
3354
+ path: item.stagedPath,
3355
+ url: item.resolvedFile?.downloadUrl ?? item.url ?? item.stagedPath,
3356
+ mimeType: item.stagedMimeType ?? item.mimeType,
3357
+ },
3358
+ ]
3359
+ : [],
3360
+ );
3361
+ const mediaPaths = stagedMedia.map((item) => item.path);
3362
+ const mediaTypes = stagedMedia.map((item) => item.mimeType);
3363
+
3364
+ const route = resolveRoute({
3365
+ runtime,
3366
+ cfg,
3367
+ accountId: account.accountId,
3368
+ peer: {
3369
+ kind: parsed.isGroup ? "group" : "direct",
3370
+ id: parsed.chatId,
3371
+ },
3372
+ });
3373
+
3374
+ const storePath = resolveStorePath(runtime, cfg, route.agentId);
3375
+ const previousTimestamp =
3376
+ typeof runtime?.channel?.session?.readSessionUpdatedAt === "function" && storePath
3377
+ ? runtime.channel.session.readSessionUpdatedAt({
3378
+ storePath,
3379
+ sessionKey: route.sessionKey,
3380
+ })
3381
+ : undefined;
3382
+
3383
+ const envelopeOptions =
3384
+ typeof runtime?.channel?.reply?.resolveEnvelopeFormatOptions === "function"
3385
+ ? runtime.channel.reply.resolveEnvelopeFormatOptions(cfg)
3386
+ : undefined;
3387
+
3388
+ const fromLabel = parsed.isGroup
3389
+ ? parsed.chatType === "channel"
3390
+ ? `channel:${parsed.chatId}`
3391
+ : `group:${parsed.chatId}`
3392
+ : parsed.senderName || `user:${parsed.senderId}`;
3393
+
3394
+ const body =
3395
+ typeof runtime?.channel?.reply?.formatAgentEnvelope === "function"
3396
+ ? runtime.channel.reply.formatAgentEnvelope({
3397
+ channel: "Zapry",
3398
+ from: fromLabel,
3399
+ timestamp: parsed.timestampMs,
3400
+ previousTimestamp,
3401
+ envelope: envelopeOptions,
3402
+ body: bodyForAgent,
3403
+ })
3404
+ : bodyForAgent;
3405
+
3406
+ const inboundCtxBase = {
3407
+ Body: body,
3408
+ BodyForAgent: bodyForAgent,
3409
+ RawBody: rawBody,
3410
+ CommandBody: commandBody,
3411
+ From: parsed.isGroup ? `zapry:group:${parsed.chatId}` : `zapry:${parsed.senderId}`,
3412
+ To: `zapry:${parsed.chatId}`,
3413
+ SessionKey: route.sessionKey,
3414
+ AccountId: route.accountId,
3415
+ ChatType: parsed.isGroup ? "group" : "direct",
3416
+ ConversationLabel: fromLabel,
3417
+ SenderName: parsed.senderName || undefined,
3418
+ SenderId: parsed.senderId,
3419
+ BotOwnerId: botOwnerId || undefined,
3420
+ SenderIsOwner: senderIsOwner,
3421
+ TargetUserHints: parsed.targetUserHints.length > 0 ? parsed.targetUserHints : undefined,
3422
+ MentionedUserIds:
3423
+ parsed.targetUserHints.length > 0 ? parsed.targetUserHints.map((item) => item.userId) : undefined,
3424
+ TargetUserId:
3425
+ parsed.targetUserHints.length === 1 ? parsed.targetUserHints[0].userId : undefined,
3426
+ Provider: "zapry",
3427
+ Surface: "zapry",
3428
+ MessageSid: parsed.messageSid,
3429
+ Transcript: transcript || undefined,
3430
+ HasMedia: mediaItems.length > 0,
3431
+ MediaItems: agentMediaItems,
3432
+ MediaKinds: agentMediaItems.map((item) => item.kind),
3433
+ MediaPath: mediaPaths[0],
3434
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
3435
+ MediaType: mediaTypes[0],
3436
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
3437
+ OriginatingChannel: "zapry",
3438
+ OriginatingTo: `zapry:${parsed.chatId}`,
3439
+ };
3440
+
3441
+ const ctxPayload =
3442
+ typeof runtime?.channel?.reply?.finalizeInboundContext === "function"
3443
+ ? runtime.channel.reply.finalizeInboundContext(inboundCtxBase)
3444
+ : inboundCtxBase;
3445
+
3446
+ if (typeof runtime?.channel?.session?.recordInboundSession === "function" && storePath) {
3447
+ await runtime.channel.session.recordInboundSession({
3448
+ storePath,
3449
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
3450
+ ctx: ctxPayload,
3451
+ onRecordError: (err: unknown) => {
3452
+ log?.warn?.(`[${account.accountId}] zapry session meta update failed: ${String(err)}`);
3453
+ },
3454
+ });
3455
+ }
3456
+
3457
+ statusSink?.({ lastInboundAt: Date.now() });
3458
+
3459
+ const typingClient = new ZapryApiClient(account.config.apiBaseUrl, account.botToken);
3460
+ typingClient.sendChatAction(parsed.chatId, "typing").catch(() => {});
3461
+
3462
+ await runWithZaprySkillInvocationContext({
3463
+ senderId: String(ctxPayload.SenderId ?? parsed.senderId ?? "").trim(),
3464
+ messageSid: String(ctxPayload.MessageSid ?? parsed.messageSid ?? "").trim(),
3465
+ sessionKey: String(ctxPayload.SessionKey ?? route.sessionKey ?? "").trim(),
3466
+ accountId: account.accountId,
3467
+ chatId: parsed.chatId,
3468
+ }, async () => {
3469
+ await dispatchReply({
3470
+ ctx: ctxPayload,
3471
+ cfg,
3472
+ dispatcherOptions: {
3473
+ deliver: async (payload: any) => {
3474
+ await deliverZapryReply({
3475
+ runtime,
3476
+ cfg,
3477
+ account,
3478
+ chatId: parsed.chatId,
3479
+ payload,
3480
+ statusSink,
3481
+ log,
3482
+ });
3483
+ },
3484
+ onError: (err: unknown, info: { kind?: string }) => {
3485
+ log?.warn?.(
3486
+ `[${account.accountId}] Zapry ${info?.kind ?? "reply"} dispatch failed: ${String(err)}`,
3487
+ );
3488
+ },
3489
+ },
3490
+ });
3491
+ });
3492
+
3493
+ return true;
3494
+ }