@openclaw/qqbot 2026.5.1-beta.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/api.ts +56 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/.boundary-tsc.stamp +1 -0
- package/dist/.boundary-tsc.tsbuildinfo +1 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +814 -0
- package/package.json +66 -0
- package/runtime-api.ts +9 -0
- package/setup-entry.ts +9 -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 +237 -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 +47 -0
- package/src/bridge/commands/from-parser.test.ts +86 -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 +111 -0
- package/src/bridge/gateway.ts +174 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.test.ts +146 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +131 -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.test.ts +124 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +288 -0
- package/src/command-auth.test.ts +62 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +364 -0
- package/src/engine/access/access-control.test.ts +198 -0
- package/src/engine/access/access-control.ts +226 -0
- package/src/engine/access/index.ts +16 -0
- package/src/engine/access/resolve-policy.test.ts +59 -0
- package/src/engine/access/resolve-policy.ts +57 -0
- package/src/engine/access/sender-match.test.ts +60 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +53 -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 +139 -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.test.ts +336 -0
- package/src/engine/api/media-chunked.ts +622 -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 +271 -0
- package/src/engine/approval/index.test.ts +22 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +319 -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 +137 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +48 -0
- package/src/engine/commands/slash-command-handler.ts +146 -0
- package/src/engine/commands/slash-commands-impl.test.ts +8 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +199 -0
- package/src/engine/config/credential-backup.test.ts +88 -0
- package/src/engine/config/credential-backup.ts +107 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.test.ts +234 -0
- package/src/engine/config/group.ts +299 -0
- package/src/engine/config/resolve.test.ts +152 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/engine-import-boundary.test.ts +73 -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 +371 -0
- package/src/engine/gateway/gateway.ts +291 -0
- package/src/engine/gateway/inbound-attachments.test.ts +126 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +195 -0
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
- package/src/engine/gateway/inbound-pipeline.ts +235 -0
- package/src/engine/gateway/interaction-handler.ts +220 -0
- package/src/engine/gateway/message-queue.test.ts +282 -0
- package/src/engine/gateway/message-queue.ts +499 -0
- package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
- package/src/engine/gateway/outbound-dispatch.ts +575 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +132 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.test.ts +77 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +292 -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 +116 -0
- package/src/engine/gateway/types.ts +264 -0
- package/src/engine/gateway/typing-keepalive.ts +79 -0
- package/src/engine/group/activation.test.ts +114 -0
- package/src/engine/group/activation.ts +147 -0
- package/src/engine/group/history.test.ts +314 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.test.ts +141 -0
- package/src/engine/group/mention.ts +197 -0
- package/src/engine/group/message-gating.test.ts +188 -0
- package/src/engine/group/message-gating.ts +216 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +215 -0
- package/src/engine/messaging/media-type-detect.ts +37 -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 +702 -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 +45 -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 +729 -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.test.ts +60 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +224 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +254 -0
- package/src/engine/session/session-store.ts +284 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.test.ts +280 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.test.ts +186 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.test.ts +250 -0
- package/src/engine/utils/audio.ts +585 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +109 -0
- package/src/engine/utils/file-utils.test.ts +72 -0
- package/src/engine/utils/file-utils.ts +225 -0
- package/src/engine/utils/format.test.ts +68 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.test.ts +158 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.test.ts +28 -0
- package/src/engine/utils/log.ts +61 -0
- package/src/engine/utils/media-tags.test.ts +32 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.test.ts +68 -0
- package/src/engine/utils/payload.ts +145 -0
- package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
- package/src/engine/utils/platform.test.ts +148 -0
- package/src/engine/utils/platform.ts +343 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.test.ts +104 -0
- package/src/engine/utils/stt.ts +100 -0
- package/src/engine/utils/text-parsing.test.ts +29 -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 +218 -0
- package/src/manifest-schema.test.ts +56 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,254 @@
|
|
|
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 fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
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
|
+
export 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
|
+
if (fs.existsSync(knownUsersFile)) {
|
|
53
|
+
const data = fs.readFileSync(knownUsersFile, "utf-8");
|
|
54
|
+
const users = JSON.parse(data) as KnownUser[];
|
|
55
|
+
for (const user of users) {
|
|
56
|
+
usersCache.set(makeUserKey(user), user);
|
|
57
|
+
}
|
|
58
|
+
debugLog(`[known-users] Loaded ${usersCache.size} users`);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
debugError(`[known-users] Failed to load users: ${formatErrorMessage(err)}`);
|
|
62
|
+
usersCache = new Map();
|
|
63
|
+
}
|
|
64
|
+
return usersCache;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function saveUsersToFile(): void {
|
|
68
|
+
if (!isDirty || saveTimer) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
saveTimer = setTimeout(() => {
|
|
72
|
+
saveTimer = null;
|
|
73
|
+
doSaveUsersToFile();
|
|
74
|
+
}, SAVE_THROTTLE_MS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function doSaveUsersToFile(): void {
|
|
78
|
+
if (!usersCache || !isDirty) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
ensureDir();
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
getKnownUsersFile(),
|
|
85
|
+
JSON.stringify(Array.from(usersCache.values()), null, 2),
|
|
86
|
+
"utf-8",
|
|
87
|
+
);
|
|
88
|
+
isDirty = false;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
debugError(`[known-users] Failed to save users: ${formatErrorMessage(err)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Flush pending writes immediately, typically during shutdown. */
|
|
95
|
+
export function flushKnownUsers(): void {
|
|
96
|
+
if (saveTimer) {
|
|
97
|
+
clearTimeout(saveTimer);
|
|
98
|
+
saveTimer = null;
|
|
99
|
+
}
|
|
100
|
+
doSaveUsersToFile();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Record a known user whenever a message is received. */
|
|
104
|
+
export function recordKnownUser(user: {
|
|
105
|
+
openid: string;
|
|
106
|
+
type: ChatScope;
|
|
107
|
+
nickname?: string;
|
|
108
|
+
groupOpenid?: string;
|
|
109
|
+
accountId: string;
|
|
110
|
+
}): void {
|
|
111
|
+
const cache = loadUsersFromFile();
|
|
112
|
+
const key = makeUserKey(user);
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const existing = cache.get(key);
|
|
115
|
+
|
|
116
|
+
if (existing) {
|
|
117
|
+
existing.lastSeenAt = now;
|
|
118
|
+
existing.interactionCount++;
|
|
119
|
+
if (user.nickname && user.nickname !== existing.nickname) {
|
|
120
|
+
existing.nickname = user.nickname;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
cache.set(key, {
|
|
124
|
+
openid: user.openid,
|
|
125
|
+
type: user.type,
|
|
126
|
+
nickname: user.nickname,
|
|
127
|
+
groupOpenid: user.groupOpenid,
|
|
128
|
+
accountId: user.accountId,
|
|
129
|
+
firstSeenAt: now,
|
|
130
|
+
lastSeenAt: now,
|
|
131
|
+
interactionCount: 1,
|
|
132
|
+
});
|
|
133
|
+
debugLog(`[known-users] New user: ${user.openid} (${user.type})`);
|
|
134
|
+
}
|
|
135
|
+
isDirty = true;
|
|
136
|
+
saveUsersToFile();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Look up one known user. */
|
|
140
|
+
export function getKnownUser(
|
|
141
|
+
accountId: string,
|
|
142
|
+
openid: string,
|
|
143
|
+
type: ChatScope = "c2c",
|
|
144
|
+
groupOpenid?: string,
|
|
145
|
+
): KnownUser | undefined {
|
|
146
|
+
return loadUsersFromFile().get(makeUserKey({ accountId, openid, type, groupOpenid }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** List known users with optional filtering and sorting. */
|
|
150
|
+
export function listKnownUsers(options?: {
|
|
151
|
+
accountId?: string;
|
|
152
|
+
type?: ChatScope;
|
|
153
|
+
activeWithin?: number;
|
|
154
|
+
limit?: number;
|
|
155
|
+
sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount";
|
|
156
|
+
sortOrder?: "asc" | "desc";
|
|
157
|
+
}): KnownUser[] {
|
|
158
|
+
let users = Array.from(loadUsersFromFile().values());
|
|
159
|
+
if (options?.accountId) {
|
|
160
|
+
users = users.filter((u) => u.accountId === options.accountId);
|
|
161
|
+
}
|
|
162
|
+
if (options?.type) {
|
|
163
|
+
users = users.filter((u) => u.type === options.type);
|
|
164
|
+
}
|
|
165
|
+
if (options?.activeWithin) {
|
|
166
|
+
const cutoff = Date.now() - options.activeWithin;
|
|
167
|
+
users = users.filter((u) => u.lastSeenAt >= cutoff);
|
|
168
|
+
}
|
|
169
|
+
const sortBy = options?.sortBy ?? "lastSeenAt";
|
|
170
|
+
const sortOrder = options?.sortOrder ?? "desc";
|
|
171
|
+
users.sort((a, b) => {
|
|
172
|
+
const aV = a[sortBy] ?? 0;
|
|
173
|
+
const bV = b[sortBy] ?? 0;
|
|
174
|
+
return sortOrder === "asc" ? aV - bV : bV - aV;
|
|
175
|
+
});
|
|
176
|
+
if (options?.limit && options.limit > 0) {
|
|
177
|
+
users = users.slice(0, options.limit);
|
|
178
|
+
}
|
|
179
|
+
return users;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Return summary stats for known users. */
|
|
183
|
+
export function getKnownUsersStats(accountId?: string): {
|
|
184
|
+
totalUsers: number;
|
|
185
|
+
c2cUsers: number;
|
|
186
|
+
groupUsers: number;
|
|
187
|
+
activeIn24h: number;
|
|
188
|
+
activeIn7d: number;
|
|
189
|
+
} {
|
|
190
|
+
const users = listKnownUsers({ accountId });
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const day = 86400000;
|
|
193
|
+
return {
|
|
194
|
+
totalUsers: users.length,
|
|
195
|
+
c2cUsers: users.filter((u) => u.type === "c2c").length,
|
|
196
|
+
groupUsers: users.filter((u) => u.type === "group").length,
|
|
197
|
+
activeIn24h: users.filter((u) => now - u.lastSeenAt < day).length,
|
|
198
|
+
activeIn7d: users.filter((u) => now - u.lastSeenAt < 7 * day).length,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Remove one user record. */
|
|
203
|
+
export function removeKnownUser(
|
|
204
|
+
accountId: string,
|
|
205
|
+
openid: string,
|
|
206
|
+
type: ChatScope = "c2c",
|
|
207
|
+
groupOpenid?: string,
|
|
208
|
+
): boolean {
|
|
209
|
+
const cache = loadUsersFromFile();
|
|
210
|
+
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
|
211
|
+
if (cache.has(key)) {
|
|
212
|
+
cache.delete(key);
|
|
213
|
+
isDirty = true;
|
|
214
|
+
saveUsersToFile();
|
|
215
|
+
debugLog(`[known-users] Removed user ${openid}`);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Clear all user records, optionally scoped to one account. */
|
|
222
|
+
export function clearKnownUsers(accountId?: string): number {
|
|
223
|
+
const cache = loadUsersFromFile();
|
|
224
|
+
let count = 0;
|
|
225
|
+
if (accountId) {
|
|
226
|
+
for (const [key, user] of cache.entries()) {
|
|
227
|
+
if (user.accountId === accountId) {
|
|
228
|
+
cache.delete(key);
|
|
229
|
+
count++;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
count = cache.size;
|
|
234
|
+
cache.clear();
|
|
235
|
+
}
|
|
236
|
+
if (count > 0) {
|
|
237
|
+
isDirty = true;
|
|
238
|
+
doSaveUsersToFile();
|
|
239
|
+
debugLog(`[known-users] Cleared ${count} users`);
|
|
240
|
+
}
|
|
241
|
+
return count;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Return all groups in which a user has interacted. */
|
|
245
|
+
export function getUserGroups(accountId: string, openid: string): string[] {
|
|
246
|
+
return listKnownUsers({ accountId, type: "group" })
|
|
247
|
+
.filter((u) => u.openid === openid && u.groupOpenid)
|
|
248
|
+
.map((u) => u.groupOpenid!);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Return all recorded members for one group. */
|
|
252
|
+
export function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[] {
|
|
253
|
+
return listKnownUsers({ accountId, type: "group" }).filter((u) => u.groupOpenid === groupOpenid);
|
|
254
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway session persistence — JSONL file-based store.
|
|
3
|
+
*
|
|
4
|
+
* Migrated from src/session-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 { formatErrorMessage } from "../utils/format.js";
|
|
11
|
+
import { debugLog, debugError } from "../utils/log.js";
|
|
12
|
+
import { getQQBotDataDir, getQQBotDataPath } from "../utils/platform.js";
|
|
13
|
+
|
|
14
|
+
/** Persisted gateway session state. */
|
|
15
|
+
export interface SessionState {
|
|
16
|
+
sessionId: string | null;
|
|
17
|
+
lastSeq: number | null;
|
|
18
|
+
lastConnectedAt: number;
|
|
19
|
+
intentLevelIndex: number;
|
|
20
|
+
accountId: string;
|
|
21
|
+
savedAt: number;
|
|
22
|
+
appId?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
|
|
26
|
+
const SAVE_THROTTLE_MS = 1000;
|
|
27
|
+
|
|
28
|
+
const throttleState = new Map<
|
|
29
|
+
string,
|
|
30
|
+
{
|
|
31
|
+
pendingState: SessionState | null;
|
|
32
|
+
lastSaveTime: number;
|
|
33
|
+
throttleTimer: ReturnType<typeof setTimeout> | null;
|
|
34
|
+
}
|
|
35
|
+
>();
|
|
36
|
+
|
|
37
|
+
function ensureDir(): void {
|
|
38
|
+
getQQBotDataDir("sessions");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getSessionDir(): string {
|
|
42
|
+
return getQQBotDataPath("sessions");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function encodeAccountIdForFileName(accountId: string): string {
|
|
46
|
+
return Buffer.from(accountId, "utf8").toString("base64url");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getLegacySessionPath(accountId: string): string {
|
|
50
|
+
const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
51
|
+
return path.join(getSessionDir(), `session-${safeId}.json`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getSessionPath(accountId: string): string {
|
|
55
|
+
const encodedId = encodeAccountIdForFileName(accountId);
|
|
56
|
+
return path.join(getSessionDir(), `session-${encodedId}.json`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getCandidateSessionPaths(accountId: string): string[] {
|
|
60
|
+
const primaryPath = getSessionPath(accountId);
|
|
61
|
+
const legacyPath = getLegacySessionPath(accountId);
|
|
62
|
+
return primaryPath === legacyPath ? [primaryPath] : [primaryPath, legacyPath];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isSessionFileName(file: string): boolean {
|
|
66
|
+
return file.startsWith("session-") && file.endsWith(".json");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readSessionStateFile(file: string): { filePath: string; state: SessionState } {
|
|
70
|
+
const filePath = path.join(getSessionDir(), file);
|
|
71
|
+
const data = fs.readFileSync(filePath, "utf-8");
|
|
72
|
+
return { filePath, state: JSON.parse(data) as SessionState };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load a saved session, rejecting expired or mismatched appId entries. */
|
|
76
|
+
export function loadSession(accountId: string, expectedAppId?: string): SessionState | null {
|
|
77
|
+
try {
|
|
78
|
+
let filePath: string | null = null;
|
|
79
|
+
for (const candidatePath of getCandidateSessionPaths(accountId)) {
|
|
80
|
+
if (fs.existsSync(candidatePath)) {
|
|
81
|
+
filePath = candidatePath;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!filePath) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const data = fs.readFileSync(filePath, "utf-8");
|
|
90
|
+
const state = JSON.parse(data) as SessionState;
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
|
|
93
|
+
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
|
94
|
+
debugLog(
|
|
95
|
+
`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`,
|
|
96
|
+
);
|
|
97
|
+
try {
|
|
98
|
+
fs.unlinkSync(filePath);
|
|
99
|
+
} catch {}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (expectedAppId && state.appId && state.appId !== expectedAppId) {
|
|
104
|
+
debugLog(
|
|
105
|
+
`[session-store] appId mismatch for ${accountId}: saved=${state.appId}, current=${expectedAppId}. Discarding stale session.`,
|
|
106
|
+
);
|
|
107
|
+
try {
|
|
108
|
+
fs.unlinkSync(filePath);
|
|
109
|
+
} catch {}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) {
|
|
114
|
+
debugLog(`[session-store] Invalid session data for ${accountId}`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
debugLog(
|
|
119
|
+
`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, appId=${state.appId ?? "unknown"}, age=${Math.round((now - state.savedAt) / 1000)}s`,
|
|
120
|
+
);
|
|
121
|
+
return state;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
debugError(
|
|
124
|
+
`[session-store] Failed to load session for ${accountId}: ${formatErrorMessage(err)}`,
|
|
125
|
+
);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Save session state with throttling. */
|
|
131
|
+
export function saveSession(state: SessionState): void {
|
|
132
|
+
const { accountId } = state;
|
|
133
|
+
let throttle = throttleState.get(accountId);
|
|
134
|
+
if (!throttle) {
|
|
135
|
+
throttle = { pendingState: null, lastSaveTime: 0, throttleTimer: null };
|
|
136
|
+
throttleState.set(accountId, throttle);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const timeSinceLastSave = now - throttle.lastSaveTime;
|
|
141
|
+
|
|
142
|
+
if (timeSinceLastSave >= SAVE_THROTTLE_MS) {
|
|
143
|
+
doSaveSession(state);
|
|
144
|
+
throttle.lastSaveTime = now;
|
|
145
|
+
throttle.pendingState = null;
|
|
146
|
+
if (throttle.throttleTimer) {
|
|
147
|
+
clearTimeout(throttle.throttleTimer);
|
|
148
|
+
throttle.throttleTimer = null;
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
throttle.pendingState = state;
|
|
152
|
+
if (!throttle.throttleTimer) {
|
|
153
|
+
const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
|
|
154
|
+
throttle.throttleTimer = setTimeout(() => {
|
|
155
|
+
const t = throttleState.get(accountId);
|
|
156
|
+
if (t?.pendingState) {
|
|
157
|
+
doSaveSession(t.pendingState);
|
|
158
|
+
t.lastSaveTime = Date.now();
|
|
159
|
+
t.pendingState = null;
|
|
160
|
+
}
|
|
161
|
+
if (t) {
|
|
162
|
+
t.throttleTimer = null;
|
|
163
|
+
}
|
|
164
|
+
}, delay);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function doSaveSession(state: SessionState): void {
|
|
170
|
+
const filePath = getSessionPath(state.accountId);
|
|
171
|
+
const legacyPath = getLegacySessionPath(state.accountId);
|
|
172
|
+
try {
|
|
173
|
+
ensureDir();
|
|
174
|
+
const stateToSave: SessionState = { ...state, savedAt: Date.now() };
|
|
175
|
+
fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
|
|
176
|
+
if (legacyPath !== filePath && fs.existsSync(legacyPath)) {
|
|
177
|
+
fs.unlinkSync(legacyPath);
|
|
178
|
+
}
|
|
179
|
+
debugLog(
|
|
180
|
+
`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`,
|
|
181
|
+
);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
debugError(
|
|
184
|
+
`[session-store] Failed to save session for ${state.accountId}: ${formatErrorMessage(err)}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Clear a saved session and any pending throttle state. */
|
|
190
|
+
export function clearSession(accountId: string): void {
|
|
191
|
+
const throttle = throttleState.get(accountId);
|
|
192
|
+
if (throttle) {
|
|
193
|
+
if (throttle.throttleTimer) {
|
|
194
|
+
clearTimeout(throttle.throttleTimer);
|
|
195
|
+
}
|
|
196
|
+
throttleState.delete(accountId);
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
let cleared = false;
|
|
200
|
+
for (const filePath of getCandidateSessionPaths(accountId)) {
|
|
201
|
+
if (fs.existsSync(filePath)) {
|
|
202
|
+
fs.unlinkSync(filePath);
|
|
203
|
+
cleared = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (cleared) {
|
|
207
|
+
debugLog(`[session-store] Cleared session for ${accountId}`);
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
debugError(
|
|
211
|
+
`[session-store] Failed to clear session for ${accountId}: ${formatErrorMessage(err)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Update only lastSeq on the persisted session. */
|
|
217
|
+
export function updateLastSeq(accountId: string, lastSeq: number): void {
|
|
218
|
+
const existing = loadSession(accountId);
|
|
219
|
+
if (existing?.sessionId) {
|
|
220
|
+
saveSession({ ...existing, lastSeq });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Load all saved sessions from disk. */
|
|
225
|
+
export function getAllSessions(): SessionState[] {
|
|
226
|
+
const sessions = new Map<string, SessionState>();
|
|
227
|
+
try {
|
|
228
|
+
const sessionDir = getSessionDir();
|
|
229
|
+
if (!fs.existsSync(sessionDir)) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
const files = fs.readdirSync(sessionDir);
|
|
233
|
+
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
if (isSessionFileName(file)) {
|
|
236
|
+
try {
|
|
237
|
+
const { state } = readSessionStateFile(file);
|
|
238
|
+
if (typeof state.accountId !== "string" || !state.accountId) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const existing = sessions.get(state.accountId);
|
|
242
|
+
if (!existing || (state.savedAt ?? 0) >= (existing.savedAt ?? 0)) {
|
|
243
|
+
sessions.set(state.accountId, state);
|
|
244
|
+
}
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch {}
|
|
249
|
+
return [...sessions.values()];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Remove expired session files from disk. */
|
|
253
|
+
export function cleanupExpiredSessions(): number {
|
|
254
|
+
let cleaned = 0;
|
|
255
|
+
try {
|
|
256
|
+
const sessionDir = getSessionDir();
|
|
257
|
+
if (!fs.existsSync(sessionDir)) {
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
const files = fs.readdirSync(sessionDir);
|
|
262
|
+
|
|
263
|
+
for (const file of files) {
|
|
264
|
+
if (isSessionFileName(file)) {
|
|
265
|
+
const filePath = path.join(sessionDir, file);
|
|
266
|
+
try {
|
|
267
|
+
const { state } = readSessionStateFile(file);
|
|
268
|
+
|
|
269
|
+
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
|
270
|
+
fs.unlinkSync(filePath);
|
|
271
|
+
cleaned++;
|
|
272
|
+
debugLog(`[session-store] Cleaned expired session: ${file}`);
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
try {
|
|
276
|
+
fs.unlinkSync(filePath);
|
|
277
|
+
cleaned++;
|
|
278
|
+
} catch {}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch {}
|
|
283
|
+
return cleaned;
|
|
284
|
+
}
|