@tencent-connect/openclaw-qqbot 1.5.6 → 1.5.7

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/outbound.ts CHANGED
@@ -170,6 +170,8 @@ export interface OutboundResult {
170
170
  messageId?: string;
171
171
  timestamp?: string | number;
172
172
  error?: string;
173
+ /** 出站消息的引用索引(ext_info.ref_idx),供引用消息缓存使用 */
174
+ refIdx?: string;
173
175
  }
174
176
 
175
177
  /**
@@ -398,27 +400,27 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
398
400
  if (target.type === "c2c") {
399
401
  const result = await sendC2CMessage(accessToken, target.id, item.content, replyToId);
400
402
  recordMessageReply(replyToId);
401
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
403
+ lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
402
404
  } else if (target.type === "group") {
403
405
  const result = await sendGroupMessage(accessToken, target.id, item.content, replyToId);
404
406
  recordMessageReply(replyToId);
405
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
407
+ lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
406
408
  } else {
407
409
  const result = await sendChannelMessage(accessToken, target.id, item.content, replyToId);
408
410
  recordMessageReply(replyToId);
409
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
411
+ lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
410
412
  }
411
413
  } else {
412
414
  // 主动消息
413
415
  if (target.type === "c2c") {
414
416
  const result = await sendProactiveC2CMessage(accessToken, target.id, item.content);
415
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
417
+ lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
416
418
  } else if (target.type === "group") {
417
419
  const result = await sendProactiveGroupMessage(accessToken, target.id, item.content);
418
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
420
+ lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
419
421
  } else {
420
422
  const result = await sendChannelMessage(accessToken, target.id, item.content);
421
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
423
+ lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
422
424
  }
423
425
  }
424
426
  console.log(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
@@ -458,7 +460,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
458
460
 
459
461
  // 发送图片
460
462
  if (target.type === "c2c") {
461
- const result = await sendC2CImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined);
463
+ const result = await sendC2CImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined, undefined, isHttpUrl ? undefined : imagePath);
462
464
  lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
463
465
  } else if (target.type === "group") {
464
466
  const result = await sendGroupImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined);
@@ -559,7 +561,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
559
561
  console.log(`[qqbot] sendText: Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
560
562
 
561
563
  if (target.type === "c2c") {
562
- const result = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
564
+ const result = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined, undefined, videoPath);
563
565
  lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
564
566
  } else if (target.type === "group") {
565
567
  const result = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
@@ -615,7 +617,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
615
617
  console.log(`[qqbot] sendText: Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
616
618
 
617
619
  if (target.type === "c2c") {
618
- const result = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
620
+ const result = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName, filePath);
619
621
  lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
620
622
  } else if (target.type === "group") {
621
623
  const result = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
@@ -665,17 +667,19 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
665
667
 
666
668
  // 如果没有 replyToId,使用主动发送接口
667
669
  if (!replyToId) {
670
+ let outResult: OutboundResult;
668
671
  if (target.type === "c2c") {
669
672
  const result = await sendProactiveC2CMessage(accessToken, target.id, text);
670
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
673
+ outResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
671
674
  } else if (target.type === "group") {
672
675
  const result = await sendProactiveGroupMessage(accessToken, target.id, text);
673
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
676
+ outResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
674
677
  } else {
675
678
  // 频道暂不支持主动消息
676
679
  const result = await sendChannelMessage(accessToken, target.id, text);
677
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
680
+ outResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
678
681
  }
682
+ return outResult;
679
683
  }
680
684
 
681
685
  // 有 replyToId,使用被动回复接口
@@ -683,17 +687,17 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
683
687
  const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
684
688
  // 记录回复次数
685
689
  recordMessageReply(replyToId);
686
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
690
+ return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
687
691
  } else if (target.type === "group") {
688
692
  const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
689
693
  // 记录回复次数
690
694
  recordMessageReply(replyToId);
691
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
695
+ return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
692
696
  } else {
693
697
  const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
694
698
  // 记录回复次数
695
699
  recordMessageReply(replyToId);
696
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
700
+ return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
697
701
  }
698
702
  } catch (err) {
699
703
  const message = err instanceof Error ? err.message : String(err);
@@ -731,23 +735,25 @@ export async function sendProactiveMessage(
731
735
  const target = parseTarget(to);
732
736
  console.log(`[${timestamp}] [qqbot] sendProactiveMessage: target parsed, type=${target.type}, id=${target.id}`);
733
737
 
738
+ let outResult: OutboundResult;
734
739
  if (target.type === "c2c") {
735
740
  console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive C2C message to user=${target.id}`);
736
741
  const result = await sendProactiveC2CMessage(accessToken, target.id, text);
737
742
  console.log(`[${timestamp}] [qqbot] sendProactiveMessage: proactive C2C message sent successfully, messageId=${result.id}`);
738
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
743
+ outResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
739
744
  } else if (target.type === "group") {
740
745
  console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive group message to group=${target.id}`);
741
746
  const result = await sendProactiveGroupMessage(accessToken, target.id, text);
742
747
  console.log(`[${timestamp}] [qqbot] sendProactiveMessage: proactive group message sent successfully, messageId=${result.id}`);
743
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
748
+ outResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
744
749
  } else {
745
750
  // 频道暂不支持主动消息,使用普通发送
746
751
  console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending channel message to channel=${target.id}`);
747
752
  const result = await sendChannelMessage(accessToken, target.id, text);
748
753
  console.log(`[${timestamp}] [qqbot] sendProactiveMessage: channel message sent successfully, messageId=${result.id}`);
749
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
754
+ outResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
750
755
  }
756
+ return outResult;
751
757
  } catch (err) {
752
758
  const errorMessage = err instanceof Error ? err.message : String(err);
753
759
  console.error(`[${timestamp}] [qqbot] sendProactiveMessage: error: ${errorMessage}`);
@@ -901,7 +907,7 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResu
901
907
  let imageResult: { id: string; timestamp: number | string };
902
908
  if (target.type === "c2c") {
903
909
  imageResult = await sendC2CImageMessage(
904
- accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined
910
+ accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined, isLocalPath ? mediaUrl : undefined
905
911
  );
906
912
  } else if (target.type === "group") {
907
913
  imageResult = await sendGroupImageMessage(
@@ -926,7 +932,7 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResu
926
932
  }
927
933
  }
928
934
 
929
- return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp };
935
+ return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp, refIdx: (imageResult as any).ext_info?.ref_idx };
930
936
  } catch (err) {
931
937
  const message = err instanceof Error ? err.message : String(err);
932
938
  return { channel: "qqbot", error: message };
@@ -1003,7 +1009,7 @@ async function sendVoiceFile(ctx: MediaOutboundContext): Promise<OutboundResult>
1003
1009
  }
1004
1010
 
1005
1011
  console.log(`[qqbot] sendVoiceFile: voice message sent`);
1006
- return { channel: "qqbot", messageId: voiceResult.id, timestamp: voiceResult.timestamp };
1012
+ return { channel: "qqbot", messageId: voiceResult.id, timestamp: voiceResult.timestamp, refIdx: (voiceResult as any).ext_info?.ref_idx };
1007
1013
  } catch (err) {
1008
1014
  const message = err instanceof Error ? err.message : String(err);
1009
1015
  console.error(`[qqbot] sendVoiceFile: failed: ${message}`);
@@ -1065,7 +1071,7 @@ async function sendVideoUrl(ctx: MediaOutboundContext): Promise<OutboundResult>
1065
1071
  }
1066
1072
 
1067
1073
  console.log(`[qqbot] sendVideoUrl: video message sent`);
1068
- return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp };
1074
+ return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp, refIdx: (videoResult as any).ext_info?.ref_idx };
1069
1075
  } catch (err) {
1070
1076
  const message = err instanceof Error ? err.message : String(err);
1071
1077
  console.error(`[qqbot] sendVideoUrl: failed: ${message}`);
@@ -1106,7 +1112,7 @@ async function sendVideoFile(ctx: MediaOutboundContext): Promise<OutboundResult>
1106
1112
 
1107
1113
  let videoResult: { id: string; timestamp: number | string };
1108
1114
  if (target.type === "c2c") {
1109
- videoResult = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
1115
+ videoResult = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined, undefined, mediaUrl);
1110
1116
  } else if (target.type === "group") {
1111
1117
  videoResult = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
1112
1118
  } else {
@@ -1128,7 +1134,7 @@ async function sendVideoFile(ctx: MediaOutboundContext): Promise<OutboundResult>
1128
1134
  }
1129
1135
 
1130
1136
  console.log(`[qqbot] sendVideoFile: video message sent`);
1131
- return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp };
1137
+ return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp, refIdx: (videoResult as any).ext_info?.ref_idx };
1132
1138
  } catch (err) {
1133
1139
  const message = err instanceof Error ? err.message : String(err);
1134
1140
  console.error(`[qqbot] sendVideoFile: failed: ${message}`);
@@ -1191,7 +1197,7 @@ async function sendDocumentFile(ctx: MediaOutboundContext): Promise<OutboundResu
1191
1197
  console.log(`[qqbot] sendDocumentFile: read local file (${formatFileSize(fileBuffer.length)}), uploading...`);
1192
1198
 
1193
1199
  if (target.type === "c2c") {
1194
- fileResult = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
1200
+ fileResult = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName, mediaUrl);
1195
1201
  } else if (target.type === "group") {
1196
1202
  fileResult = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
1197
1203
  } else {
@@ -1214,7 +1220,7 @@ async function sendDocumentFile(ctx: MediaOutboundContext): Promise<OutboundResu
1214
1220
  }
1215
1221
 
1216
1222
  console.log(`[qqbot] sendDocumentFile: file message sent`);
1217
- return { channel: "qqbot", messageId: fileResult.id, timestamp: fileResult.timestamp };
1223
+ return { channel: "qqbot", messageId: fileResult.id, timestamp: fileResult.timestamp, refIdx: (fileResult as any).ext_info?.ref_idx };
1218
1224
  } catch (err) {
1219
1225
  const message = err instanceof Error ? err.message : String(err);
1220
1226
  console.error(`[qqbot] sendDocumentFile: failed: ${message}`);
@@ -0,0 +1,358 @@
1
+ /**
2
+ * QQ Bot 引用索引持久化存储
3
+ *
4
+ * QQ Bot 使用 REFIDX_xxx 索引体系做引用消息,
5
+ * 入站事件只有索引值,无 API 可回查内容。
6
+ * 采用 内存缓存 + JSONL 追加写持久化 方案,确保重启后历史引用仍可命中。
7
+ *
8
+ * 存储位置:~/.openclaw/qqbot/data/ref-index.jsonl
9
+ *
10
+ * 每行格式:{"k":"REFIDX_xxx","v":{...},"t":1709000000}
11
+ * - k = refIdx 键
12
+ * - v = 消息数据
13
+ * - t = 写入时间(用于 TTL 淘汰和 compact)
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { getQQBotDataDir } from "./utils/platform.js";
19
+
20
+ // ============ 存储的消息摘要 ============
21
+
22
+ export interface RefIndexEntry {
23
+ /** 消息文本内容摘要 */
24
+ content: string;
25
+ /** 发送者 ID */
26
+ senderId: string;
27
+ /** 发送者名称 */
28
+ senderName?: string;
29
+ /** 消息时间戳 (ms) */
30
+ timestamp: number;
31
+ /** 是否是 bot 发出的消息 */
32
+ isBot?: boolean;
33
+ /** 附件摘要(图片/语音/视频/文件等) */
34
+ attachments?: RefAttachmentSummary[];
35
+ }
36
+
37
+ /** 附件摘要:存本地路径、在线 URL 和类型描述 */
38
+ export interface RefAttachmentSummary {
39
+ /** 附件类型 */
40
+ type: "image" | "voice" | "video" | "file" | "unknown";
41
+ /** 文件名(如有) */
42
+ filename?: string;
43
+ /** MIME 类型 */
44
+ contentType?: string;
45
+ /** 语音转录文本(入站:STT/ASR识别结果;出站:TTS原文本) */
46
+ transcript?: string;
47
+ /** 语音转录来源:stt=本地STT、asr=QQ官方ASR、tts=TTS原文本、fallback=兜底文案 */
48
+ transcriptSource?: "stt" | "asr" | "tts" | "fallback";
49
+ /** 已下载到本地的文件路径(持久化后可供引用时访问) */
50
+ localPath?: string;
51
+ /** 在线来源 URL(公网图片/文件等) */
52
+ url?: string;
53
+ }
54
+
55
+ // ============ 配置 ============
56
+
57
+ const STORAGE_DIR = getQQBotDataDir("data");
58
+ const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl");
59
+ const MAX_CONTENT_LENGTH = 500; // 存储的消息内容最大字符数
60
+ const MAX_ENTRIES = 50000; // 内存中最大缓存条目数
61
+ const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天
62
+ const COMPACT_THRESHOLD_RATIO = 2; // 文件行数超过有效条目 N 倍时 compact
63
+
64
+ // ============ JSONL 行格式 ============
65
+
66
+ interface RefIndexLine {
67
+ /** refIdx 键 */
68
+ k: string;
69
+ /** 消息数据 */
70
+ v: RefIndexEntry;
71
+ /** 写入时间 (ms) */
72
+ t: number;
73
+ }
74
+
75
+ // ============ 内存缓存 ============
76
+
77
+ let cache: Map<string, RefIndexEntry & { _createdAt: number }> | null = null;
78
+ let totalLinesOnDisk = 0; // 磁盘文件总行数(含过期 / 被覆盖的)
79
+
80
+ /**
81
+ * 从 JSONL 文件加载到内存(懒加载,首次访问时触发)
82
+ */
83
+ function loadFromFile(): Map<string, RefIndexEntry & { _createdAt: number }> {
84
+ if (cache !== null) return cache;
85
+
86
+ cache = new Map();
87
+ totalLinesOnDisk = 0;
88
+
89
+ try {
90
+ if (!fs.existsSync(REF_INDEX_FILE)) {
91
+ return cache;
92
+ }
93
+
94
+ const raw = fs.readFileSync(REF_INDEX_FILE, "utf-8");
95
+ const lines = raw.split("\n");
96
+ const now = Date.now();
97
+ let expired = 0;
98
+
99
+ for (const line of lines) {
100
+ const trimmed = line.trim();
101
+ if (!trimmed) continue;
102
+ totalLinesOnDisk++;
103
+
104
+ try {
105
+ const entry = JSON.parse(trimmed) as RefIndexLine;
106
+ if (!entry.k || !entry.v || !entry.t) continue;
107
+
108
+ // 跳过过期条目
109
+ if (now - entry.t > TTL_MS) {
110
+ expired++;
111
+ continue;
112
+ }
113
+
114
+ cache.set(entry.k, {
115
+ ...entry.v,
116
+ _createdAt: entry.t,
117
+ });
118
+ } catch {
119
+ // 跳过损坏的行
120
+ }
121
+ }
122
+
123
+ console.log(
124
+ `[ref-index-store] Loaded ${cache.size} entries from ${totalLinesOnDisk} lines (${expired} expired)`,
125
+ );
126
+
127
+ // 启动时检查是否需要 compact
128
+ if (shouldCompact()) {
129
+ compactFile();
130
+ }
131
+ } catch (err) {
132
+ console.error(`[ref-index-store] Failed to load: ${err}`);
133
+ cache = new Map();
134
+ }
135
+
136
+ return cache;
137
+ }
138
+
139
+ // ============ JSONL 追加写入 ============
140
+
141
+ /**
142
+ * 追加一行到 JSONL 文件
143
+ */
144
+ function appendLine(line: RefIndexLine): void {
145
+ try {
146
+ ensureDir();
147
+ fs.appendFileSync(REF_INDEX_FILE, JSON.stringify(line) + "\n", "utf-8");
148
+ totalLinesOnDisk++;
149
+ } catch (err) {
150
+ console.error(`[ref-index-store] Failed to append: ${err}`);
151
+ }
152
+ }
153
+
154
+ function ensureDir(): void {
155
+ if (!fs.existsSync(STORAGE_DIR)) {
156
+ fs.mkdirSync(STORAGE_DIR, { recursive: true });
157
+ }
158
+ }
159
+
160
+ // ============ Compact:重写文件,去除过期和被覆盖的条目 ============
161
+
162
+ function shouldCompact(): boolean {
163
+ if (!cache) return false;
164
+ // 文件行数远超有效条目数时 compact
165
+ return totalLinesOnDisk > cache.size * COMPACT_THRESHOLD_RATIO && totalLinesOnDisk > 1000;
166
+ }
167
+
168
+ function compactFile(): void {
169
+ if (!cache) return;
170
+
171
+ const before = totalLinesOnDisk;
172
+ try {
173
+ ensureDir();
174
+ const tmpPath = REF_INDEX_FILE + ".tmp";
175
+ const lines: string[] = [];
176
+
177
+ for (const [key, entry] of cache) {
178
+ const line: RefIndexLine = {
179
+ k: key,
180
+ v: {
181
+ content: entry.content,
182
+ senderId: entry.senderId,
183
+ senderName: entry.senderName,
184
+ timestamp: entry.timestamp,
185
+ isBot: entry.isBot,
186
+ attachments: entry.attachments,
187
+ },
188
+ t: entry._createdAt,
189
+ };
190
+ lines.push(JSON.stringify(line));
191
+ }
192
+
193
+ fs.writeFileSync(tmpPath, lines.join("\n") + "\n", "utf-8");
194
+ fs.renameSync(tmpPath, REF_INDEX_FILE);
195
+ totalLinesOnDisk = cache.size;
196
+ console.log(`[ref-index-store] Compacted: ${before} lines → ${totalLinesOnDisk} lines`);
197
+ } catch (err) {
198
+ console.error(`[ref-index-store] Compact failed: ${err}`);
199
+ }
200
+ }
201
+
202
+ // ============ 溢出淘汰 ============
203
+
204
+ function evictIfNeeded(): void {
205
+ if (!cache || cache.size < MAX_ENTRIES) return;
206
+
207
+ const now = Date.now();
208
+ // 第一轮:清理过期
209
+ for (const [key, entry] of cache) {
210
+ if (now - entry._createdAt > TTL_MS) {
211
+ cache.delete(key);
212
+ }
213
+ }
214
+
215
+ // 第二轮:仍超限,按时间删最旧
216
+ if (cache.size >= MAX_ENTRIES) {
217
+ const sorted = [...cache.entries()].sort((a, b) => a[1]._createdAt - b[1]._createdAt);
218
+ const toRemove = sorted.slice(0, cache.size - MAX_ENTRIES + 1000);
219
+ for (const [key] of toRemove) {
220
+ cache.delete(key);
221
+ }
222
+ console.log(`[ref-index-store] Evicted ${toRemove.length} oldest entries`);
223
+ }
224
+ }
225
+
226
+ // ============ 公共 API ============
227
+
228
+ /**
229
+ * 存储一条消息的 refIdx 映射
230
+ */
231
+ export function setRefIndex(refIdx: string, entry: RefIndexEntry): void {
232
+ const store = loadFromFile();
233
+ evictIfNeeded();
234
+
235
+ const now = Date.now();
236
+ store.set(refIdx, {
237
+ content: entry.content.slice(0, MAX_CONTENT_LENGTH),
238
+ senderId: entry.senderId,
239
+ senderName: entry.senderName,
240
+ timestamp: entry.timestamp,
241
+ isBot: entry.isBot,
242
+ attachments: entry.attachments,
243
+ _createdAt: now,
244
+ });
245
+
246
+ // 追加写入 JSONL
247
+ appendLine({
248
+ k: refIdx,
249
+ v: {
250
+ content: entry.content.slice(0, MAX_CONTENT_LENGTH),
251
+ senderId: entry.senderId,
252
+ senderName: entry.senderName,
253
+ timestamp: entry.timestamp,
254
+ isBot: entry.isBot,
255
+ attachments: entry.attachments,
256
+ },
257
+ t: now,
258
+ });
259
+
260
+ // 检查是否需要 compact
261
+ if (shouldCompact()) {
262
+ compactFile();
263
+ }
264
+ }
265
+
266
+ /**
267
+ * 查找被引用消息
268
+ */
269
+ export function getRefIndex(refIdx: string): RefIndexEntry | null {
270
+ const store = loadFromFile();
271
+ const entry = store.get(refIdx);
272
+ if (!entry) return null;
273
+
274
+ // 检查过期
275
+ if (Date.now() - entry._createdAt > TTL_MS) {
276
+ store.delete(refIdx);
277
+ return null;
278
+ }
279
+
280
+ return {
281
+ content: entry.content,
282
+ senderId: entry.senderId,
283
+ senderName: entry.senderName,
284
+ timestamp: entry.timestamp,
285
+ isBot: entry.isBot,
286
+ attachments: entry.attachments,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * 将引用消息内容格式化为人类可读的描述(供 AI 上下文注入)
292
+ */
293
+ export function formatRefEntryForAgent(entry: RefIndexEntry): string {
294
+ const parts: string[] = [];
295
+
296
+ // 文本内容
297
+ if (entry.content.trim()) {
298
+ parts.push(entry.content);
299
+ }
300
+
301
+ // 附件描述
302
+ if (entry.attachments?.length) {
303
+ for (const att of entry.attachments) {
304
+ const sourceHint = att.localPath ? ` (${att.localPath})` : att.url ? ` (${att.url})` : "";
305
+ switch (att.type) {
306
+ case "image":
307
+ parts.push(`[图片${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
308
+ break;
309
+ case "voice":
310
+ if (att.transcript) {
311
+ const sourceMap = { stt: "本地识别", asr: "官方识别", tts: "TTS原文", fallback: "兜底文案" };
312
+ const sourceTag = att.transcriptSource ? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}` : "";
313
+ parts.push(`[语音消息(内容: "${att.transcript}"${sourceTag})${sourceHint}]`);
314
+ } else {
315
+ parts.push(`[语音消息${sourceHint}]`);
316
+ }
317
+ break;
318
+ case "video":
319
+ parts.push(`[视频${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
320
+ break;
321
+ case "file":
322
+ parts.push(`[文件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
323
+ break;
324
+ default:
325
+ parts.push(`[附件${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
326
+ }
327
+ }
328
+ }
329
+
330
+ return parts.join(" ") || "[空消息]";
331
+ }
332
+
333
+ /**
334
+ * 进程退出前强制 compact(确保数据一致性)
335
+ */
336
+ export function flushRefIndex(): void {
337
+ if (cache && shouldCompact()) {
338
+ compactFile();
339
+ }
340
+ }
341
+
342
+ /**
343
+ * 缓存统计(调试用)
344
+ */
345
+ export function getRefIndexStats(): {
346
+ size: number;
347
+ maxEntries: number;
348
+ totalLinesOnDisk: number;
349
+ filePath: string;
350
+ } {
351
+ const store = loadFromFile();
352
+ return {
353
+ size: store.size,
354
+ maxEntries: MAX_ENTRIES,
355
+ totalLinesOnDisk,
356
+ filePath: REF_INDEX_FILE,
357
+ };
358
+ }
package/src/types.ts CHANGED
@@ -102,6 +102,8 @@ export interface C2CMessageEvent {
102
102
  timestamp: string;
103
103
  message_scene?: {
104
104
  source: string;
105
+ /** ext 数组,可能包含 ref_msg_idx=REFIDX_xxx(引用的消息)和 msg_idx=REFIDX_xxx(自身索引) */
106
+ ext?: string[];
105
107
  };
106
108
  attachments?: MessageAttachment[];
107
109
  }
@@ -140,6 +142,10 @@ export interface GroupMessageEvent {
140
142
  timestamp: string;
141
143
  group_id: string;
142
144
  group_openid: string;
145
+ message_scene?: {
146
+ source: string;
147
+ ext?: string[];
148
+ };
143
149
  attachments?: MessageAttachment[];
144
150
  }
145
151
 
@@ -105,11 +105,22 @@ export function expandTilde(p: string): string {
105
105
  }
106
106
 
107
107
  /**
108
- * 对路径进行完整的规范化处理:展开波浪线 + 去除首尾空白
108
+ * 对路径进行完整的规范化处理:剥离 file:// 前缀 + 展开波浪线 + 去除首尾空白
109
109
  * 所有文件操作前应通过此函数处理用户输入的路径
110
110
  */
111
111
  export function normalizePath(p: string): string {
112
- return expandTilde(p.trim());
112
+ let result = p.trim();
113
+ // 剥离 file:// 协议前缀: file:///Users/... → /Users/...
114
+ if (result.startsWith("file://")) {
115
+ result = result.slice("file://".length);
116
+ // 处理 URL 编码(file:// 路径中空格等字符可能被编码)
117
+ try {
118
+ result = decodeURIComponent(result);
119
+ } catch {
120
+ // decodeURIComponent 失败时保留原样
121
+ }
122
+ }
123
+ return expandTilde(result);
113
124
  }
114
125
 
115
126
  // ============ 文件名 UTF-8 规范化 ============
@@ -163,6 +174,7 @@ export function sanitizeFileName(name: string): string {
163
174
  * - Windows 绝对路径: C:\..., D:/..., \\server\share
164
175
  * - 相对路径: ./file, ../file
165
176
  * - 波浪线路径: ~/Desktop/file.png
177
+ * - file:// 协议: file:///Users/..., file:///home/...
166
178
  *
167
179
  * 不匹配:
168
180
  * - http:// / https:// URL
@@ -170,6 +182,8 @@ export function sanitizeFileName(name: string): string {
170
182
  */
171
183
  export function isLocalPath(p: string): boolean {
172
184
  if (!p) return false;
185
+ // file:// 协议(本地文件 URI)
186
+ if (p.startsWith("file://")) return true;
173
187
  // 波浪线路径(Mac/Linux 用户常用)
174
188
  if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) return true;
175
189
  // Unix 绝对路径