@gakr-gakr/qqbot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +56 -0
- package/autobot.plugin.json +167 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +33 -0
- package/package.json +64 -0
- package/runtime-api.ts +9 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/skills/qqbot-channel/SKILL.md +262 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +37 -0
- package/skills/qqbot-remind/SKILL.md +153 -0
- package/src/bridge/approval/capability.ts +225 -0
- package/src/bridge/approval/handler-runtime.ts +204 -0
- package/src/bridge/bootstrap.ts +135 -0
- package/src/bridge/channel-entry.ts +18 -0
- package/src/bridge/commands/framework-context-adapter.ts +60 -0
- package/src/bridge/commands/framework-registration.ts +66 -0
- package/src/bridge/commands/from-parser.ts +60 -0
- package/src/bridge/commands/result-dispatcher.ts +76 -0
- package/src/bridge/config-shared.ts +132 -0
- package/src/bridge/config.ts +176 -0
- package/src/bridge/gateway.ts +178 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +164 -0
- package/src/bridge/setup/finalize.ts +144 -0
- package/src/bridge/setup/surface.ts +34 -0
- package/src/bridge/tools/channel.ts +58 -0
- package/src/bridge/tools/index.ts +15 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +399 -0
- package/src/config-schema.ts +84 -0
- package/src/engine/access/index.ts +2 -0
- package/src/engine/access/resolve-policy.ts +30 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +2 -0
- package/src/engine/adapter/audio.port.ts +27 -0
- package/src/engine/adapter/commands.port.ts +22 -0
- package/src/engine/adapter/history.port.ts +52 -0
- package/src/engine/adapter/index.ts +76 -0
- package/src/engine/adapter/mention-gate.port.ts +50 -0
- package/src/engine/adapter/types.ts +38 -0
- package/src/engine/api/api-client.ts +212 -0
- package/src/engine/api/media-chunked.ts +644 -0
- package/src/engine/api/media.ts +218 -0
- package/src/engine/api/messages.ts +293 -0
- package/src/engine/api/retry.ts +217 -0
- package/src/engine/api/routes.ts +95 -0
- package/src/engine/api/token.ts +277 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +341 -0
- package/src/engine/commands/builtin/register-all.ts +17 -0
- package/src/engine/commands/builtin/register-approve.ts +201 -0
- package/src/engine/commands/builtin/register-basic.ts +95 -0
- package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
- package/src/engine/commands/builtin/register-logs.ts +20 -0
- package/src/engine/commands/builtin/register-streaming.ts +138 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +88 -0
- package/src/engine/commands/slash-command-handler.ts +168 -0
- package/src/engine/commands/slash-command-test-support.ts +39 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +202 -0
- package/src/engine/config/credential-backup.ts +108 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.ts +227 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/gateway/active-cfg.ts +52 -0
- package/src/engine/gateway/codec.ts +47 -0
- package/src/engine/gateway/constants.ts +117 -0
- package/src/engine/gateway/event-dispatcher.ts +177 -0
- package/src/engine/gateway/gateway-connection.ts +356 -0
- package/src/engine/gateway/gateway.ts +267 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +82 -0
- package/src/engine/gateway/inbound-pipeline.ts +171 -0
- package/src/engine/gateway/interaction-handler.ts +345 -0
- package/src/engine/gateway/message-queue.ts +404 -0
- package/src/engine/gateway/outbound-dispatch.ts +590 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +99 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +223 -0
- package/src/engine/gateway/stages/index.ts +18 -0
- package/src/engine/gateway/stages/quote-stage.ts +113 -0
- package/src/engine/gateway/stages/refidx-stage.ts +62 -0
- package/src/engine/gateway/stages/stub-contexts.ts +77 -0
- package/src/engine/gateway/types.ts +230 -0
- package/src/engine/gateway/typing-keepalive.ts +102 -0
- package/src/engine/gateway/ws-client.ts +16 -0
- package/src/engine/group/activation.ts +88 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.ts +114 -0
- package/src/engine/group/message-gating.ts +108 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +210 -0
- package/src/engine/messaging/media-type-detect.ts +27 -0
- package/src/engine/messaging/outbound-audio-port.ts +38 -0
- package/src/engine/messaging/outbound-deliver.ts +810 -0
- package/src/engine/messaging/outbound-media-send.ts +658 -0
- package/src/engine/messaging/outbound-reply.ts +27 -0
- package/src/engine/messaging/outbound-result-helpers.ts +54 -0
- package/src/engine/messaging/outbound-types.ts +47 -0
- package/src/engine/messaging/outbound.ts +485 -0
- package/src/engine/messaging/reply-dispatcher.ts +597 -0
- package/src/engine/messaging/reply-limiter.ts +164 -0
- package/src/engine/messaging/sender.ts +741 -0
- package/src/engine/messaging/streaming-c2c.ts +1192 -0
- package/src/engine/messaging/streaming-media-send.ts +544 -0
- package/src/engine/messaging/target-parser.ts +104 -0
- package/src/engine/ref/format-message-ref.ts +142 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +211 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +138 -0
- package/src/engine/session/session-store.ts +207 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.ts +525 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +93 -0
- package/src/engine/utils/file-utils.ts +215 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.ts +77 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.ts +157 -0
- package/src/engine/utils/platform.ts +265 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.ts +103 -0
- package/src/engine/utils/text-parsing.ts +155 -0
- package/src/engine/utils/upload-cache.ts +96 -0
- package/src/engine/utils/voice-text.ts +15 -0
- package/src/exec-approvals.ts +237 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/secret-contract.ts +82 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a message_reference (from msg_elements[0]) into text for model context.
|
|
3
|
+
*
|
|
4
|
+
* This handles the cache-miss path: when a user quotes a message we haven't
|
|
5
|
+
* cached in the ref-index store, we fall back to the msg_elements[0] data
|
|
6
|
+
* pushed by the QQ platform.
|
|
7
|
+
*
|
|
8
|
+
* The heavy lifting (attachment download, STT, etc.) is delegated to an
|
|
9
|
+
* injected `AttachmentProcessor` so this module stays framework-agnostic.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { EngineLogger } from "../types.js";
|
|
13
|
+
import { parseFaceTags, buildAttachmentSummaries } from "../utils/text-parsing.js";
|
|
14
|
+
import { formatRefEntryForAgent } from "./format-ref-entry.js";
|
|
15
|
+
import type { RefAttachmentSummary } from "./types.js";
|
|
16
|
+
|
|
17
|
+
// ============ Injected dependency ============
|
|
18
|
+
|
|
19
|
+
/** Attachment download & voice transcription — injected from the outer layer. */
|
|
20
|
+
export interface AttachmentProcessor {
|
|
21
|
+
processAttachments(
|
|
22
|
+
attachments:
|
|
23
|
+
| Array<{
|
|
24
|
+
content_type: string;
|
|
25
|
+
url: string;
|
|
26
|
+
filename?: string;
|
|
27
|
+
height?: number;
|
|
28
|
+
width?: number;
|
|
29
|
+
size?: number;
|
|
30
|
+
voice_wav_url?: string;
|
|
31
|
+
asr_refer_text?: string;
|
|
32
|
+
}>
|
|
33
|
+
| undefined,
|
|
34
|
+
ctx: { appId: string; peerId?: string; cfg: unknown; log?: EngineLogger },
|
|
35
|
+
): Promise<{
|
|
36
|
+
attachmentInfo: string;
|
|
37
|
+
voiceTranscripts: string[];
|
|
38
|
+
voiceTranscriptSources: string[];
|
|
39
|
+
attachmentLocalPaths: Array<string | null>;
|
|
40
|
+
}>;
|
|
41
|
+
|
|
42
|
+
formatVoiceText(voiceTranscripts: string[]): string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============ Public API ============
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Format a quoted message reference into human-readable text for model context.
|
|
49
|
+
*
|
|
50
|
+
* This mirrors the independent version's `formatMessageReferenceForAgent` —
|
|
51
|
+
* processing attachments (download + STT) and combining them with parsed text.
|
|
52
|
+
*
|
|
53
|
+
* @param ref - The msg_elements[0] data from the QQ push event.
|
|
54
|
+
* @param ctx - Context containing appId, peerId, config, and logger.
|
|
55
|
+
* @param processor - Injected attachment processor (download + voice transcription).
|
|
56
|
+
*/
|
|
57
|
+
export async function formatMessageReferenceForAgent(
|
|
58
|
+
ref:
|
|
59
|
+
| {
|
|
60
|
+
content?: string;
|
|
61
|
+
attachments?: Array<{
|
|
62
|
+
content_type: string;
|
|
63
|
+
url: string;
|
|
64
|
+
filename?: string;
|
|
65
|
+
height?: number;
|
|
66
|
+
width?: number;
|
|
67
|
+
size?: number;
|
|
68
|
+
voice_wav_url?: string;
|
|
69
|
+
asr_refer_text?: string;
|
|
70
|
+
}>;
|
|
71
|
+
}
|
|
72
|
+
| undefined,
|
|
73
|
+
ctx: {
|
|
74
|
+
appId: string;
|
|
75
|
+
peerId?: string;
|
|
76
|
+
cfg: unknown;
|
|
77
|
+
log?: EngineLogger;
|
|
78
|
+
},
|
|
79
|
+
processor: AttachmentProcessor,
|
|
80
|
+
): Promise<string> {
|
|
81
|
+
if (!ref) {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Process attachments (download images, transcribe voice, etc.)
|
|
86
|
+
const processed = await processor.processAttachments(ref.attachments, ctx);
|
|
87
|
+
const { attachmentInfo, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } =
|
|
88
|
+
processed;
|
|
89
|
+
|
|
90
|
+
// Format voice transcript text
|
|
91
|
+
const voiceText = processor.formatVoiceText(voiceTranscripts);
|
|
92
|
+
|
|
93
|
+
// Parse QQ face tags into readable text
|
|
94
|
+
const parsedContent = parseFaceTags(ref.content ?? "");
|
|
95
|
+
|
|
96
|
+
// Combine text content with voice transcript and attachment info
|
|
97
|
+
const userContent = voiceText
|
|
98
|
+
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
|
|
99
|
+
: parsedContent + attachmentInfo;
|
|
100
|
+
|
|
101
|
+
// Build attachment summaries and inject voice transcripts
|
|
102
|
+
const attSummaries = buildAttachmentSummaries(
|
|
103
|
+
ref.attachments as Array<{
|
|
104
|
+
content_type: string;
|
|
105
|
+
url: string;
|
|
106
|
+
filename?: string;
|
|
107
|
+
voice_wav_url?: string;
|
|
108
|
+
}>,
|
|
109
|
+
attachmentLocalPaths,
|
|
110
|
+
);
|
|
111
|
+
if (attSummaries && voiceTranscripts.length > 0) {
|
|
112
|
+
let voiceIdx = 0;
|
|
113
|
+
for (const att of attSummaries) {
|
|
114
|
+
if (att.type === "voice" && voiceIdx < voiceTranscripts.length) {
|
|
115
|
+
att.transcript = voiceTranscripts[voiceIdx];
|
|
116
|
+
if (voiceIdx < voiceTranscriptSources.length) {
|
|
117
|
+
att.transcriptSource = voiceTranscriptSources[
|
|
118
|
+
voiceIdx
|
|
119
|
+
] as RefAttachmentSummary["transcriptSource"];
|
|
120
|
+
}
|
|
121
|
+
voiceIdx++;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Format using the same function as the cache-hit path
|
|
127
|
+
const refEntry = {
|
|
128
|
+
content: userContent.trim(),
|
|
129
|
+
senderId: "",
|
|
130
|
+
timestamp: Date.now(),
|
|
131
|
+
attachments: attSummaries,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const formattedAttachments = formatRefEntryForAgent(refEntry);
|
|
135
|
+
// If formatRefEntryForAgent already includes the content, use it directly.
|
|
136
|
+
// Otherwise combine manually.
|
|
137
|
+
if (formattedAttachments !== "[empty message]") {
|
|
138
|
+
return formattedAttachments;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return userContent.trim() || "";
|
|
142
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a ref-index entry into text suitable for model context.
|
|
3
|
+
*
|
|
4
|
+
* Delegates all attachment rendering to the shared
|
|
5
|
+
* `utils/attachment-tags.ts::renderAttachmentTags` (with `mode: "ref"`)
|
|
6
|
+
* so the quoted-message preview and the current-message history use
|
|
7
|
+
* identical wording for identical attachment types.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { renderAttachmentTags } from "../utils/attachment-tags.js";
|
|
11
|
+
import type { RefIndexEntry } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** Format a ref-index entry into text suitable for model context. */
|
|
14
|
+
export function formatRefEntryForAgent(entry: RefIndexEntry): string {
|
|
15
|
+
const parts: string[] = [];
|
|
16
|
+
|
|
17
|
+
if (entry.content.trim()) {
|
|
18
|
+
parts.push(entry.content);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const attachmentTags = renderAttachmentTags(entry.attachments, { mode: "ref" });
|
|
22
|
+
if (attachmentTags) {
|
|
23
|
+
parts.push(attachmentTags);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return parts.join(" ") || "[empty message]";
|
|
27
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ref-index store — JSONL file-based store for message reference index.
|
|
3
|
+
*
|
|
4
|
+
* Migrated from src/ref-index-store.ts. Dependencies are only Node.js
|
|
5
|
+
* built-ins + log + platform (both zero plugin-sdk).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { appendRegularFileSync, replaceFileAtomicSync } from "autobot/plugin-sdk/security-runtime";
|
|
11
|
+
import { formatErrorMessage } from "../utils/format.js";
|
|
12
|
+
import { debugLog, debugError } from "../utils/log.js";
|
|
13
|
+
import { getQQBotDataDir, getQQBotDataPath } from "../utils/platform.js";
|
|
14
|
+
import type { RefIndexEntry } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// Re-export types and format function for convenience.
|
|
17
|
+
export type { RefIndexEntry, RefAttachmentSummary } from "./types.js";
|
|
18
|
+
export { formatRefEntryForAgent } from "./format-ref-entry.js";
|
|
19
|
+
|
|
20
|
+
const MAX_ENTRIES = 50000;
|
|
21
|
+
const TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
const COMPACT_THRESHOLD_RATIO = 2;
|
|
23
|
+
|
|
24
|
+
interface RefIndexLine {
|
|
25
|
+
k: string;
|
|
26
|
+
v: RefIndexEntry;
|
|
27
|
+
t: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cache: Map<string, RefIndexEntry & { createdAt: number }> | null = null;
|
|
31
|
+
let totalLinesOnDisk = 0;
|
|
32
|
+
|
|
33
|
+
function getRefIndexFile(): string {
|
|
34
|
+
return path.join(getQQBotDataPath("data"), "ref-index.jsonl");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadFromFile(): Map<string, RefIndexEntry & { createdAt: number }> {
|
|
38
|
+
if (cache !== null) {
|
|
39
|
+
return cache;
|
|
40
|
+
}
|
|
41
|
+
cache = new Map();
|
|
42
|
+
totalLinesOnDisk = 0;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const refIndexFile = getRefIndexFile();
|
|
46
|
+
if (!fs.existsSync(refIndexFile)) {
|
|
47
|
+
return cache;
|
|
48
|
+
}
|
|
49
|
+
const raw = fs.readFileSync(refIndexFile, "utf-8");
|
|
50
|
+
const lines = raw.split("\n");
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
let expired = 0;
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (!trimmed) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
totalLinesOnDisk++;
|
|
60
|
+
try {
|
|
61
|
+
const entry = JSON.parse(trimmed) as RefIndexLine;
|
|
62
|
+
if (!entry.k || !entry.v || !entry.t) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (now - entry.t > TTL_MS) {
|
|
66
|
+
expired++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
cache.set(entry.k, { ...entry.v, createdAt: entry.t });
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
debugLog(
|
|
73
|
+
`[ref-index-store] Loaded ${cache.size} entries from ${totalLinesOnDisk} lines (${expired} expired)`,
|
|
74
|
+
);
|
|
75
|
+
if (shouldCompact()) {
|
|
76
|
+
compactFile();
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
debugError(`[ref-index-store] Failed to load: ${formatErrorMessage(err)}`);
|
|
80
|
+
cache = new Map();
|
|
81
|
+
}
|
|
82
|
+
return cache;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ensureDir(): void {
|
|
86
|
+
getQQBotDataDir("data");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function appendLine(line: RefIndexLine): void {
|
|
90
|
+
try {
|
|
91
|
+
ensureDir();
|
|
92
|
+
appendRegularFileSync({ filePath: getRefIndexFile(), content: JSON.stringify(line) + "\n" });
|
|
93
|
+
totalLinesOnDisk++;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
debugError(`[ref-index-store] Failed to append: ${formatErrorMessage(err)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function shouldCompact(): boolean {
|
|
100
|
+
return (
|
|
101
|
+
!!cache && totalLinesOnDisk > cache.size * COMPACT_THRESHOLD_RATIO && totalLinesOnDisk > 1000
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function compactFile(): void {
|
|
106
|
+
if (!cache) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const before = totalLinesOnDisk;
|
|
110
|
+
try {
|
|
111
|
+
ensureDir();
|
|
112
|
+
const refIndexFile = getRefIndexFile();
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
for (const [key, entry] of cache) {
|
|
115
|
+
lines.push(
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
k: key,
|
|
118
|
+
v: {
|
|
119
|
+
content: entry.content,
|
|
120
|
+
senderId: entry.senderId,
|
|
121
|
+
senderName: entry.senderName,
|
|
122
|
+
timestamp: entry.timestamp,
|
|
123
|
+
isBot: entry.isBot,
|
|
124
|
+
attachments: entry.attachments,
|
|
125
|
+
},
|
|
126
|
+
t: entry.createdAt,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
replaceFileAtomicSync({
|
|
131
|
+
filePath: refIndexFile,
|
|
132
|
+
content: `${lines.join("\n")}\n`,
|
|
133
|
+
tempPrefix: ".qqbot-ref-index",
|
|
134
|
+
});
|
|
135
|
+
totalLinesOnDisk = cache.size;
|
|
136
|
+
debugLog(`[ref-index-store] Compacted: ${before} lines → ${totalLinesOnDisk} lines`);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
debugError(`[ref-index-store] Compact failed: ${formatErrorMessage(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function evictIfNeeded(): void {
|
|
143
|
+
if (!cache || cache.size < MAX_ENTRIES) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
for (const [key, entry] of cache) {
|
|
148
|
+
if (now - entry.createdAt > TTL_MS) {
|
|
149
|
+
cache.delete(key);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (cache.size >= MAX_ENTRIES) {
|
|
153
|
+
const sorted = [...cache.entries()].toSorted((a, b) => a[1].createdAt - b[1].createdAt);
|
|
154
|
+
const toRemove = sorted.slice(0, cache.size - MAX_ENTRIES + 1000);
|
|
155
|
+
for (const [key] of toRemove) {
|
|
156
|
+
cache.delete(key);
|
|
157
|
+
}
|
|
158
|
+
debugLog(`[ref-index-store] Evicted ${toRemove.length} oldest entries`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Persist a refIdx mapping for one message. */
|
|
163
|
+
export function setRefIndex(refIdx: string, entry: RefIndexEntry): void {
|
|
164
|
+
const store = loadFromFile();
|
|
165
|
+
evictIfNeeded();
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
store.set(refIdx, { ...entry, createdAt: now });
|
|
168
|
+
appendLine({
|
|
169
|
+
k: refIdx,
|
|
170
|
+
v: {
|
|
171
|
+
content: entry.content,
|
|
172
|
+
senderId: entry.senderId,
|
|
173
|
+
senderName: entry.senderName,
|
|
174
|
+
timestamp: entry.timestamp,
|
|
175
|
+
isBot: entry.isBot,
|
|
176
|
+
attachments: entry.attachments,
|
|
177
|
+
},
|
|
178
|
+
t: now,
|
|
179
|
+
});
|
|
180
|
+
if (shouldCompact()) {
|
|
181
|
+
compactFile();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Look up one quoted message by refIdx. */
|
|
186
|
+
export function getRefIndex(refIdx: string): RefIndexEntry | null {
|
|
187
|
+
const store = loadFromFile();
|
|
188
|
+
const entry = store.get(refIdx);
|
|
189
|
+
if (!entry) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (Date.now() - entry.createdAt > TTL_MS) {
|
|
193
|
+
store.delete(refIdx);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
content: entry.content,
|
|
198
|
+
senderId: entry.senderId,
|
|
199
|
+
senderName: entry.senderName,
|
|
200
|
+
timestamp: entry.timestamp,
|
|
201
|
+
isBot: entry.isBot,
|
|
202
|
+
attachments: entry.attachments,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Compact the store before process exit when needed. */
|
|
207
|
+
export function flushRefIndex(): void {
|
|
208
|
+
if (cache && shouldCompact()) {
|
|
209
|
+
compactFile();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ref-index types shared between both plugin versions.
|
|
3
|
+
*
|
|
4
|
+
* These types define the structure of quoted-message metadata
|
|
5
|
+
* persisted by the ref-index store.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Summary stored for one quoted message. */
|
|
9
|
+
export interface RefIndexEntry {
|
|
10
|
+
content: string;
|
|
11
|
+
senderId: string;
|
|
12
|
+
senderName?: string;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
isBot?: boolean;
|
|
15
|
+
attachments?: RefAttachmentSummary[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Attachment summary persisted alongside a ref index entry. */
|
|
19
|
+
export interface RefAttachmentSummary {
|
|
20
|
+
type: "image" | "voice" | "video" | "file" | "unknown";
|
|
21
|
+
filename?: string;
|
|
22
|
+
contentType?: string;
|
|
23
|
+
transcript?: string;
|
|
24
|
+
transcriptSource?: "stt" | "asr" | "tts" | "fallback";
|
|
25
|
+
localPath?: string;
|
|
26
|
+
url?: string;
|
|
27
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known user tracking — JSON file-based store.
|
|
3
|
+
*
|
|
4
|
+
* Migrated from src/known-users.ts. Dependencies are only Node.js
|
|
5
|
+
* built-ins + log + platform (both zero plugin-sdk).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { privateFileStoreSync } from "autobot/plugin-sdk/security-runtime";
|
|
10
|
+
import type { ChatScope } from "../types.js";
|
|
11
|
+
import { formatErrorMessage } from "../utils/format.js";
|
|
12
|
+
import { debugLog, debugError } from "../utils/log.js";
|
|
13
|
+
import { getQQBotDataDir, getQQBotDataPath } from "../utils/platform.js";
|
|
14
|
+
|
|
15
|
+
/** Persisted record for a user who has interacted with the bot. */
|
|
16
|
+
interface KnownUser {
|
|
17
|
+
openid: string;
|
|
18
|
+
type: ChatScope;
|
|
19
|
+
nickname?: string;
|
|
20
|
+
groupOpenid?: string;
|
|
21
|
+
accountId: string;
|
|
22
|
+
firstSeenAt: number;
|
|
23
|
+
lastSeenAt: number;
|
|
24
|
+
interactionCount: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let usersCache: Map<string, KnownUser> | null = null;
|
|
28
|
+
const SAVE_THROTTLE_MS = 5000;
|
|
29
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
let isDirty = false;
|
|
31
|
+
|
|
32
|
+
function ensureDir(): void {
|
|
33
|
+
getQQBotDataDir("data");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getKnownUsersFile(): string {
|
|
37
|
+
return path.join(getQQBotDataPath("data"), "known-users.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeUserKey(user: Partial<KnownUser>): string {
|
|
41
|
+
const base = `${user.accountId}:${user.type}:${user.openid}`;
|
|
42
|
+
return user.type === "group" && user.groupOpenid ? `${base}:${user.groupOpenid}` : base;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadUsersFromFile(): Map<string, KnownUser> {
|
|
46
|
+
if (usersCache !== null) {
|
|
47
|
+
return usersCache;
|
|
48
|
+
}
|
|
49
|
+
usersCache = new Map();
|
|
50
|
+
try {
|
|
51
|
+
const knownUsersFile = getKnownUsersFile();
|
|
52
|
+
const users = privateFileStoreSync(path.dirname(knownUsersFile)).readJsonIfExists<KnownUser[]>(
|
|
53
|
+
path.basename(knownUsersFile),
|
|
54
|
+
);
|
|
55
|
+
if (users) {
|
|
56
|
+
for (const user of users) {
|
|
57
|
+
usersCache.set(makeUserKey(user), user);
|
|
58
|
+
}
|
|
59
|
+
debugLog(`[known-users] Loaded ${usersCache.size} users`);
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
debugError(`[known-users] Failed to load users: ${formatErrorMessage(err)}`);
|
|
63
|
+
usersCache = new Map();
|
|
64
|
+
}
|
|
65
|
+
return usersCache;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function saveUsersToFile(): void {
|
|
69
|
+
if (!isDirty || saveTimer) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
saveTimer = setTimeout(() => {
|
|
73
|
+
saveTimer = null;
|
|
74
|
+
doSaveUsersToFile();
|
|
75
|
+
}, SAVE_THROTTLE_MS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function doSaveUsersToFile(): void {
|
|
79
|
+
if (!usersCache || !isDirty) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
ensureDir();
|
|
84
|
+
const filePath = getKnownUsersFile();
|
|
85
|
+
privateFileStoreSync(path.dirname(filePath)).writeJson(
|
|
86
|
+
path.basename(filePath),
|
|
87
|
+
Array.from(usersCache.values()),
|
|
88
|
+
);
|
|
89
|
+
isDirty = false;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
debugError(`[known-users] Failed to save users: ${formatErrorMessage(err)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Flush pending writes immediately, typically during shutdown. */
|
|
96
|
+
export function flushKnownUsers(): void {
|
|
97
|
+
if (saveTimer) {
|
|
98
|
+
clearTimeout(saveTimer);
|
|
99
|
+
saveTimer = null;
|
|
100
|
+
}
|
|
101
|
+
doSaveUsersToFile();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Record a known user whenever a message is received. */
|
|
105
|
+
export function recordKnownUser(user: {
|
|
106
|
+
openid: string;
|
|
107
|
+
type: ChatScope;
|
|
108
|
+
nickname?: string;
|
|
109
|
+
groupOpenid?: string;
|
|
110
|
+
accountId: string;
|
|
111
|
+
}): void {
|
|
112
|
+
const cache = loadUsersFromFile();
|
|
113
|
+
const key = makeUserKey(user);
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const existing = cache.get(key);
|
|
116
|
+
|
|
117
|
+
if (existing) {
|
|
118
|
+
existing.lastSeenAt = now;
|
|
119
|
+
existing.interactionCount++;
|
|
120
|
+
if (user.nickname && user.nickname !== existing.nickname) {
|
|
121
|
+
existing.nickname = user.nickname;
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
cache.set(key, {
|
|
125
|
+
openid: user.openid,
|
|
126
|
+
type: user.type,
|
|
127
|
+
nickname: user.nickname,
|
|
128
|
+
groupOpenid: user.groupOpenid,
|
|
129
|
+
accountId: user.accountId,
|
|
130
|
+
firstSeenAt: now,
|
|
131
|
+
lastSeenAt: now,
|
|
132
|
+
interactionCount: 1,
|
|
133
|
+
});
|
|
134
|
+
debugLog(`[known-users] New user: ${user.openid} (${user.type})`);
|
|
135
|
+
}
|
|
136
|
+
isDirty = true;
|
|
137
|
+
saveUsersToFile();
|
|
138
|
+
}
|