@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/README.md +46 -146
- package/README.zh.md +46 -146
- package/bin/qqbot-cli.js +6 -6
- package/dist/AI/345/210/233/346/226/260/345/272/224/347/224/250/345/245/226_/347/224/263/346/212/245/344/271/246.md +211 -0
- package/dist/src/gateway.js +109 -92
- package/dist/src/slash-commands.d.ts +48 -0
- package/dist/src/slash-commands.js +212 -0
- package/dist/src/utils/audio-convert.d.ts +0 -6
- package/dist/src/utils/audio-convert.js +0 -89
- package/package.json +1 -1
- package/scripts/{upgrade.sh → cleanup-legacy-plugins.sh} +3 -3
- package/scripts/set-markdown.sh +20 -20
- package/scripts/upgrade-via-npm.sh +204 -0
- package/scripts/{upgrade-and-run.sh → upgrade-via-source.sh} +60 -44
- package/src/api.ts +104 -24
- package/src/channel.ts +2 -1
- package/src/gateway.ts +229 -33
- package/src/image-server.ts +5 -2
- package/src/outbound.ts +32 -26
- package/src/ref-index-store.ts +358 -0
- package/src/types.ts +6 -0
- package/src/utils/platform.ts +16 -2
- package/scripts/draw_arch.py +0 -174
- package/scripts/npm-upgrade.sh +0 -120
- package/scripts/pull-latest.sh +0 -316
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/utils/platform.ts
CHANGED
|
@@ -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
|
-
|
|
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 绝对路径
|