@sliverp/qqbot 1.3.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/README.md +231 -0
- package/clawdbot.plugin.json +16 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +22 -0
- package/dist/src/api.d.ts +194 -0
- package/dist/src/api.js +555 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/channel.js +146 -0
- package/dist/src/config.d.ts +25 -0
- package/dist/src/config.js +148 -0
- package/dist/src/gateway.d.ts +17 -0
- package/dist/src/gateway.js +722 -0
- package/dist/src/image-server.d.ts +62 -0
- package/dist/src/image-server.js +401 -0
- package/dist/src/known-users.d.ts +100 -0
- package/dist/src/known-users.js +264 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +190 -0
- package/dist/src/outbound.d.ts +149 -0
- package/dist/src/outbound.js +476 -0
- package/dist/src/proactive.d.ts +170 -0
- package/dist/src/proactive.js +398 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/session-store.d.ts +49 -0
- package/dist/src/session-store.js +242 -0
- package/dist/src/types.d.ts +116 -0
- package/dist/src/types.js +1 -0
- package/dist/src/utils/image-size.d.ts +51 -0
- package/dist/src/utils/image-size.js +234 -0
- package/dist/src/utils/payload.d.ts +112 -0
- package/dist/src/utils/payload.js +186 -0
- package/index.ts +27 -0
- package/moltbot.plugin.json +16 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +13 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +528 -0
- package/node_modules/ws/lib/receiver.js +706 -0
- package/node_modules/ws/lib/sender.js +602 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +554 -0
- package/node_modules/ws/lib/websocket.js +1393 -0
- package/node_modules/ws/package.json +69 -0
- package/node_modules/ws/wrapper.mjs +8 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +38 -0
- package/qqbot-1.3.0.tgz +0 -0
- package/scripts/proactive-api-server.ts +346 -0
- package/scripts/send-proactive.ts +273 -0
- package/scripts/upgrade.sh +106 -0
- package/skills/qqbot-cron/SKILL.md +490 -0
- package/skills/qqbot-media/SKILL.md +138 -0
- package/src/api.ts +752 -0
- package/src/channel.ts +303 -0
- package/src/config.ts +172 -0
- package/src/gateway.ts +1588 -0
- package/src/image-server.ts +474 -0
- package/src/known-users.ts +358 -0
- package/src/onboarding.ts +254 -0
- package/src/openclaw-plugin-sdk.d.ts +483 -0
- package/src/outbound.ts +571 -0
- package/src/proactive.ts +528 -0
- package/src/runtime.ts +14 -0
- package/src/session-store.ts +292 -0
- package/src/types.ts +123 -0
- package/src/utils/image-size.ts +266 -0
- package/src/utils/payload.ts +265 -0
- package/tsconfig.json +16 -0
- package/upgrade-and-run.sh +89 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 主动发送消息模块
|
|
3
|
+
*
|
|
4
|
+
* 该模块提供以下能力:
|
|
5
|
+
* 1. 记录已知用户(曾与机器人交互过的用户)
|
|
6
|
+
* 2. 主动发送消息给用户或群组
|
|
7
|
+
* 3. 查询已知用户列表
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { getAccessToken, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CImageMessage, sendGroupImageMessage, } from "./api.js";
|
|
12
|
+
import { resolveQQBotAccount } from "./config.js";
|
|
13
|
+
// ============ 用户存储管理 ============
|
|
14
|
+
/**
|
|
15
|
+
* 已知用户存储
|
|
16
|
+
* 使用简单的 JSON 文件存储,保存在 clawd 目录下
|
|
17
|
+
*/
|
|
18
|
+
const STORAGE_DIR = path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-data");
|
|
19
|
+
const KNOWN_USERS_FILE = path.join(STORAGE_DIR, "known-users.json");
|
|
20
|
+
// 内存缓存
|
|
21
|
+
let knownUsersCache = null;
|
|
22
|
+
let cacheLastModified = 0;
|
|
23
|
+
/**
|
|
24
|
+
* 确保存储目录存在
|
|
25
|
+
*/
|
|
26
|
+
function ensureStorageDir() {
|
|
27
|
+
if (!fs.existsSync(STORAGE_DIR)) {
|
|
28
|
+
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 生成用户唯一键
|
|
33
|
+
*/
|
|
34
|
+
function getUserKey(type, openid, accountId) {
|
|
35
|
+
return `${accountId}:${type}:${openid}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 从文件加载已知用户
|
|
39
|
+
*/
|
|
40
|
+
function loadKnownUsers() {
|
|
41
|
+
if (knownUsersCache !== null) {
|
|
42
|
+
// 检查文件是否被修改
|
|
43
|
+
try {
|
|
44
|
+
const stat = fs.statSync(KNOWN_USERS_FILE);
|
|
45
|
+
if (stat.mtimeMs <= cacheLastModified) {
|
|
46
|
+
return knownUsersCache;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// 文件不存在,使用缓存
|
|
51
|
+
return knownUsersCache;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const users = new Map();
|
|
55
|
+
try {
|
|
56
|
+
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
|
57
|
+
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
|
58
|
+
const parsed = JSON.parse(data);
|
|
59
|
+
for (const user of parsed) {
|
|
60
|
+
const key = getUserKey(user.type, user.openid, user.accountId);
|
|
61
|
+
users.set(key, user);
|
|
62
|
+
}
|
|
63
|
+
cacheLastModified = fs.statSync(KNOWN_USERS_FILE).mtimeMs;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error(`[qqbot:proactive] Failed to load known users: ${err}`);
|
|
68
|
+
}
|
|
69
|
+
knownUsersCache = users;
|
|
70
|
+
return users;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 保存已知用户到文件
|
|
74
|
+
*/
|
|
75
|
+
function saveKnownUsers(users) {
|
|
76
|
+
try {
|
|
77
|
+
ensureStorageDir();
|
|
78
|
+
const data = Array.from(users.values());
|
|
79
|
+
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
80
|
+
cacheLastModified = Date.now();
|
|
81
|
+
knownUsersCache = users;
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.error(`[qqbot:proactive] Failed to save known users: ${err}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 记录一个已知用户(当收到用户消息时调用)
|
|
89
|
+
*
|
|
90
|
+
* @param user - 用户信息
|
|
91
|
+
*/
|
|
92
|
+
export function recordKnownUser(user) {
|
|
93
|
+
const users = loadKnownUsers();
|
|
94
|
+
const key = getUserKey(user.type, user.openid, user.accountId);
|
|
95
|
+
const existing = users.get(key);
|
|
96
|
+
const now = user.lastInteractionAt || Date.now();
|
|
97
|
+
users.set(key, {
|
|
98
|
+
...user,
|
|
99
|
+
lastInteractionAt: now,
|
|
100
|
+
firstInteractionAt: existing?.firstInteractionAt ?? now,
|
|
101
|
+
// 更新昵称(如果有新的)
|
|
102
|
+
nickname: user.nickname || existing?.nickname,
|
|
103
|
+
});
|
|
104
|
+
saveKnownUsers(users);
|
|
105
|
+
console.log(`[qqbot:proactive] Recorded user: ${key}`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 获取一个已知用户
|
|
109
|
+
*
|
|
110
|
+
* @param type - 用户类型
|
|
111
|
+
* @param openid - 用户 openid
|
|
112
|
+
* @param accountId - 账户 ID
|
|
113
|
+
*/
|
|
114
|
+
export function getKnownUser(type, openid, accountId) {
|
|
115
|
+
const users = loadKnownUsers();
|
|
116
|
+
const key = getUserKey(type, openid, accountId);
|
|
117
|
+
return users.get(key);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 列出已知用户
|
|
121
|
+
*
|
|
122
|
+
* @param options - 过滤选项
|
|
123
|
+
*/
|
|
124
|
+
export function listKnownUsers(options) {
|
|
125
|
+
const users = loadKnownUsers();
|
|
126
|
+
let result = Array.from(users.values());
|
|
127
|
+
// 过滤类型
|
|
128
|
+
if (options?.type) {
|
|
129
|
+
result = result.filter(u => u.type === options.type);
|
|
130
|
+
}
|
|
131
|
+
// 过滤账户
|
|
132
|
+
if (options?.accountId) {
|
|
133
|
+
result = result.filter(u => u.accountId === options.accountId);
|
|
134
|
+
}
|
|
135
|
+
// 排序
|
|
136
|
+
if (options?.sortByLastInteraction !== false) {
|
|
137
|
+
result.sort((a, b) => b.lastInteractionAt - a.lastInteractionAt);
|
|
138
|
+
}
|
|
139
|
+
// 限制数量
|
|
140
|
+
if (options?.limit && options.limit > 0) {
|
|
141
|
+
result = result.slice(0, options.limit);
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 删除一个已知用户
|
|
147
|
+
*
|
|
148
|
+
* @param type - 用户类型
|
|
149
|
+
* @param openid - 用户 openid
|
|
150
|
+
* @param accountId - 账户 ID
|
|
151
|
+
*/
|
|
152
|
+
export function removeKnownUser(type, openid, accountId) {
|
|
153
|
+
const users = loadKnownUsers();
|
|
154
|
+
const key = getUserKey(type, openid, accountId);
|
|
155
|
+
const deleted = users.delete(key);
|
|
156
|
+
if (deleted) {
|
|
157
|
+
saveKnownUsers(users);
|
|
158
|
+
}
|
|
159
|
+
return deleted;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 清除所有已知用户
|
|
163
|
+
*
|
|
164
|
+
* @param accountId - 可选,只清除指定账户的用户
|
|
165
|
+
*/
|
|
166
|
+
export function clearKnownUsers(accountId) {
|
|
167
|
+
const users = loadKnownUsers();
|
|
168
|
+
let count = 0;
|
|
169
|
+
if (accountId) {
|
|
170
|
+
for (const [key, user] of users) {
|
|
171
|
+
if (user.accountId === accountId) {
|
|
172
|
+
users.delete(key);
|
|
173
|
+
count++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
count = users.size;
|
|
179
|
+
users.clear();
|
|
180
|
+
}
|
|
181
|
+
if (count > 0) {
|
|
182
|
+
saveKnownUsers(users);
|
|
183
|
+
}
|
|
184
|
+
return count;
|
|
185
|
+
}
|
|
186
|
+
// ============ 主动发送消息 ============
|
|
187
|
+
/**
|
|
188
|
+
* 主动发送消息(带配置解析)
|
|
189
|
+
* 注意:与 outbound.ts 中的 sendProactiveMessage 不同,这个函数接受 OpenClawConfig 并自动解析账户
|
|
190
|
+
*
|
|
191
|
+
* @param options - 发送选项
|
|
192
|
+
* @param cfg - OpenClaw 配置
|
|
193
|
+
* @returns 发送结果
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```typescript
|
|
197
|
+
* // 发送私聊消息
|
|
198
|
+
* const result = await sendProactive({
|
|
199
|
+
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4", // 用户 openid
|
|
200
|
+
* text: "你好!这是一条主动消息",
|
|
201
|
+
* type: "c2c",
|
|
202
|
+
* }, cfg);
|
|
203
|
+
*
|
|
204
|
+
* // 发送群聊消息
|
|
205
|
+
* const result = await sendProactive({
|
|
206
|
+
* to: "A1B2C3D4E5F6A7B8", // 群组 openid
|
|
207
|
+
* text: "群公告:今天有活动",
|
|
208
|
+
* type: "group",
|
|
209
|
+
* }, cfg);
|
|
210
|
+
*
|
|
211
|
+
* // 发送带图片的消息
|
|
212
|
+
* const result = await sendProactive({
|
|
213
|
+
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4",
|
|
214
|
+
* text: "看看这张图片",
|
|
215
|
+
* imageUrl: "https://example.com/image.png",
|
|
216
|
+
* type: "c2c",
|
|
217
|
+
* }, cfg);
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
export async function sendProactive(options, cfg) {
|
|
221
|
+
const { to, text, type = "c2c", imageUrl, accountId = "default" } = options;
|
|
222
|
+
// 解析账户配置
|
|
223
|
+
const account = resolveQQBotAccount(cfg, accountId);
|
|
224
|
+
if (!account.appId || !account.clientSecret) {
|
|
225
|
+
return {
|
|
226
|
+
success: false,
|
|
227
|
+
error: "QQBot not configured (missing appId or clientSecret)",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
232
|
+
// 如果有图片,先发送图片
|
|
233
|
+
if (imageUrl) {
|
|
234
|
+
try {
|
|
235
|
+
if (type === "c2c") {
|
|
236
|
+
await sendC2CImageMessage(accessToken, to, imageUrl, undefined, undefined);
|
|
237
|
+
}
|
|
238
|
+
else if (type === "group") {
|
|
239
|
+
await sendGroupImageMessage(accessToken, to, imageUrl, undefined, undefined);
|
|
240
|
+
}
|
|
241
|
+
console.log(`[qqbot:proactive] Sent image to ${type}:${to}`);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
console.error(`[qqbot:proactive] Failed to send image: ${err}`);
|
|
245
|
+
// 图片发送失败不影响文本发送
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// 发送文本消息
|
|
249
|
+
let result;
|
|
250
|
+
if (type === "c2c") {
|
|
251
|
+
result = await sendProactiveC2CMessage(accessToken, to, text);
|
|
252
|
+
}
|
|
253
|
+
else if (type === "group") {
|
|
254
|
+
result = await sendProactiveGroupMessage(accessToken, to, text);
|
|
255
|
+
}
|
|
256
|
+
else if (type === "channel") {
|
|
257
|
+
// 频道消息需要 channel_id,这里暂时不支持主动发送
|
|
258
|
+
return {
|
|
259
|
+
success: false,
|
|
260
|
+
error: "Channel proactive messages are not supported. Please use group or c2c.",
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
return {
|
|
265
|
+
success: false,
|
|
266
|
+
error: `Unknown message type: ${type}`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
console.log(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`);
|
|
270
|
+
return {
|
|
271
|
+
success: true,
|
|
272
|
+
messageId: result.id,
|
|
273
|
+
timestamp: result.timestamp,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
278
|
+
console.error(`[qqbot:proactive] Failed to send message: ${message}`);
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
error: message,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* 批量发送主动消息
|
|
287
|
+
*
|
|
288
|
+
* @param recipients - 接收者列表(openid 数组)
|
|
289
|
+
* @param text - 消息内容
|
|
290
|
+
* @param type - 消息类型
|
|
291
|
+
* @param cfg - OpenClaw 配置
|
|
292
|
+
* @param accountId - 账户 ID
|
|
293
|
+
* @returns 发送结果列表
|
|
294
|
+
*/
|
|
295
|
+
export async function sendBulkProactiveMessage(recipients, text, type, cfg, accountId = "default") {
|
|
296
|
+
const results = [];
|
|
297
|
+
for (const to of recipients) {
|
|
298
|
+
const result = await sendProactive({ to, text, type, accountId }, cfg);
|
|
299
|
+
results.push({ to, result });
|
|
300
|
+
// 添加延迟,避免频率限制
|
|
301
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
302
|
+
}
|
|
303
|
+
return results;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* 发送消息给所有已知用户
|
|
307
|
+
*
|
|
308
|
+
* @param text - 消息内容
|
|
309
|
+
* @param cfg - OpenClaw 配置
|
|
310
|
+
* @param options - 过滤选项
|
|
311
|
+
* @returns 发送结果统计
|
|
312
|
+
*/
|
|
313
|
+
export async function broadcastMessage(text, cfg, options) {
|
|
314
|
+
const users = listKnownUsers({
|
|
315
|
+
type: options?.type,
|
|
316
|
+
accountId: options?.accountId,
|
|
317
|
+
limit: options?.limit,
|
|
318
|
+
sortByLastInteraction: true,
|
|
319
|
+
});
|
|
320
|
+
// 过滤掉频道用户(不支持主动发送)
|
|
321
|
+
const validUsers = users.filter(u => u.type === "c2c" || u.type === "group");
|
|
322
|
+
const results = [];
|
|
323
|
+
let success = 0;
|
|
324
|
+
let failed = 0;
|
|
325
|
+
for (const user of validUsers) {
|
|
326
|
+
const result = await sendProactive({
|
|
327
|
+
to: user.openid,
|
|
328
|
+
text,
|
|
329
|
+
type: user.type,
|
|
330
|
+
accountId: user.accountId,
|
|
331
|
+
}, cfg);
|
|
332
|
+
results.push({ to: user.openid, result });
|
|
333
|
+
if (result.success) {
|
|
334
|
+
success++;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
failed++;
|
|
338
|
+
}
|
|
339
|
+
// 添加延迟,避免频率限制
|
|
340
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
total: validUsers.length,
|
|
344
|
+
success,
|
|
345
|
+
failed,
|
|
346
|
+
results,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// ============ 辅助函数 ============
|
|
350
|
+
/**
|
|
351
|
+
* 根据账户配置直接发送主动消息(不需要 cfg)
|
|
352
|
+
*
|
|
353
|
+
* @param account - 已解析的账户配置
|
|
354
|
+
* @param to - 目标 openid
|
|
355
|
+
* @param text - 消息内容
|
|
356
|
+
* @param type - 消息类型
|
|
357
|
+
*/
|
|
358
|
+
export async function sendProactiveMessageDirect(account, to, text, type = "c2c") {
|
|
359
|
+
if (!account.appId || !account.clientSecret) {
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
error: "QQBot not configured (missing appId or clientSecret)",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
367
|
+
let result;
|
|
368
|
+
if (type === "c2c") {
|
|
369
|
+
result = await sendProactiveC2CMessage(accessToken, to, text);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
result = await sendProactiveGroupMessage(accessToken, to, text);
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
success: true,
|
|
376
|
+
messageId: result.id,
|
|
377
|
+
timestamp: result.timestamp,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
return {
|
|
382
|
+
success: false,
|
|
383
|
+
error: err instanceof Error ? err.message : String(err),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* 获取已知用户统计
|
|
389
|
+
*/
|
|
390
|
+
export function getKnownUsersStats(accountId) {
|
|
391
|
+
const users = listKnownUsers({ accountId });
|
|
392
|
+
return {
|
|
393
|
+
total: users.length,
|
|
394
|
+
c2c: users.filter(u => u.type === "c2c").length,
|
|
395
|
+
group: users.filter(u => u.type === "group").length,
|
|
396
|
+
channel: users.filter(u => u.type === "channel").length,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session 持久化存储
|
|
3
|
+
* 将 WebSocket 连接状态(sessionId、lastSeq)持久化到文件
|
|
4
|
+
* 支持进程重启后通过 Resume 机制快速恢复连接
|
|
5
|
+
*/
|
|
6
|
+
export interface SessionState {
|
|
7
|
+
/** WebSocket Session ID */
|
|
8
|
+
sessionId: string | null;
|
|
9
|
+
/** 最后收到的消息序号 */
|
|
10
|
+
lastSeq: number | null;
|
|
11
|
+
/** 上次连接成功的时间戳 */
|
|
12
|
+
lastConnectedAt: number;
|
|
13
|
+
/** 上次成功的权限级别索引 */
|
|
14
|
+
intentLevelIndex: number;
|
|
15
|
+
/** 关联的机器人账户 ID */
|
|
16
|
+
accountId: string;
|
|
17
|
+
/** 保存时间 */
|
|
18
|
+
savedAt: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 加载 Session 状态
|
|
22
|
+
* @param accountId 账户 ID
|
|
23
|
+
* @returns Session 状态,如果不存在或已过期返回 null
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadSession(accountId: string): SessionState | null;
|
|
26
|
+
/**
|
|
27
|
+
* 保存 Session 状态(带节流,避免频繁写入)
|
|
28
|
+
* @param state Session 状态
|
|
29
|
+
*/
|
|
30
|
+
export declare function saveSession(state: SessionState): void;
|
|
31
|
+
/**
|
|
32
|
+
* 清除 Session 状态
|
|
33
|
+
* @param accountId 账户 ID
|
|
34
|
+
*/
|
|
35
|
+
export declare function clearSession(accountId: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* 更新 lastSeq(轻量级更新)
|
|
38
|
+
* @param accountId 账户 ID
|
|
39
|
+
* @param lastSeq 最新的消息序号
|
|
40
|
+
*/
|
|
41
|
+
export declare function updateLastSeq(accountId: string, lastSeq: number): void;
|
|
42
|
+
/**
|
|
43
|
+
* 获取所有保存的 Session 状态
|
|
44
|
+
*/
|
|
45
|
+
export declare function getAllSessions(): SessionState[];
|
|
46
|
+
/**
|
|
47
|
+
* 清理过期的 Session 文件
|
|
48
|
+
*/
|
|
49
|
+
export declare function cleanupExpiredSessions(): number;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session 持久化存储
|
|
3
|
+
* 将 WebSocket 连接状态(sessionId、lastSeq)持久化到文件
|
|
4
|
+
* 支持进程重启后通过 Resume 机制快速恢复连接
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
// Session 文件目录
|
|
9
|
+
const SESSION_DIR = path.join(process.env.HOME || "/tmp", "clawd", "qqbot-data");
|
|
10
|
+
// Session 过期时间(5分钟)- Resume 要求在断开后一定时间内恢复
|
|
11
|
+
const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
|
|
12
|
+
// 写入节流时间(避免频繁写入)
|
|
13
|
+
const SAVE_THROTTLE_MS = 1000;
|
|
14
|
+
// 每个账户的节流状态
|
|
15
|
+
const throttleState = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* 确保目录存在
|
|
18
|
+
*/
|
|
19
|
+
function ensureDir() {
|
|
20
|
+
if (!fs.existsSync(SESSION_DIR)) {
|
|
21
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 获取 Session 文件路径
|
|
26
|
+
*/
|
|
27
|
+
function getSessionPath(accountId) {
|
|
28
|
+
// 清理 accountId 中的特殊字符
|
|
29
|
+
const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
30
|
+
return path.join(SESSION_DIR, `session-${safeId}.json`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 加载 Session 状态
|
|
34
|
+
* @param accountId 账户 ID
|
|
35
|
+
* @returns Session 状态,如果不存在或已过期返回 null
|
|
36
|
+
*/
|
|
37
|
+
export function loadSession(accountId) {
|
|
38
|
+
const filePath = getSessionPath(accountId);
|
|
39
|
+
try {
|
|
40
|
+
if (!fs.existsSync(filePath)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const data = fs.readFileSync(filePath, "utf-8");
|
|
44
|
+
const state = JSON.parse(data);
|
|
45
|
+
// 检查是否过期
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
|
48
|
+
console.log(`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`);
|
|
49
|
+
// 删除过期文件
|
|
50
|
+
try {
|
|
51
|
+
fs.unlinkSync(filePath);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// 忽略删除错误
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// 验证必要字段
|
|
59
|
+
if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) {
|
|
60
|
+
console.log(`[session-store] Invalid session data for ${accountId}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
console.log(`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, age=${Math.round((now - state.savedAt) / 1000)}s`);
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error(`[session-store] Failed to load session for ${accountId}: ${err}`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 保存 Session 状态(带节流,避免频繁写入)
|
|
73
|
+
* @param state Session 状态
|
|
74
|
+
*/
|
|
75
|
+
export function saveSession(state) {
|
|
76
|
+
const { accountId } = state;
|
|
77
|
+
// 获取或初始化节流状态
|
|
78
|
+
let throttle = throttleState.get(accountId);
|
|
79
|
+
if (!throttle) {
|
|
80
|
+
throttle = {
|
|
81
|
+
pendingState: null,
|
|
82
|
+
lastSaveTime: 0,
|
|
83
|
+
throttleTimer: null,
|
|
84
|
+
};
|
|
85
|
+
throttleState.set(accountId, throttle);
|
|
86
|
+
}
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const timeSinceLastSave = now - throttle.lastSaveTime;
|
|
89
|
+
// 如果距离上次保存时间足够长,立即保存
|
|
90
|
+
if (timeSinceLastSave >= SAVE_THROTTLE_MS) {
|
|
91
|
+
doSaveSession(state);
|
|
92
|
+
throttle.lastSaveTime = now;
|
|
93
|
+
throttle.pendingState = null;
|
|
94
|
+
// 清除待定的节流定时器
|
|
95
|
+
if (throttle.throttleTimer) {
|
|
96
|
+
clearTimeout(throttle.throttleTimer);
|
|
97
|
+
throttle.throttleTimer = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// 记录待保存的状态
|
|
102
|
+
throttle.pendingState = state;
|
|
103
|
+
// 如果没有设置定时器,设置一个
|
|
104
|
+
if (!throttle.throttleTimer) {
|
|
105
|
+
const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
|
|
106
|
+
throttle.throttleTimer = setTimeout(() => {
|
|
107
|
+
const t = throttleState.get(accountId);
|
|
108
|
+
if (t && t.pendingState) {
|
|
109
|
+
doSaveSession(t.pendingState);
|
|
110
|
+
t.lastSaveTime = Date.now();
|
|
111
|
+
t.pendingState = null;
|
|
112
|
+
}
|
|
113
|
+
if (t) {
|
|
114
|
+
t.throttleTimer = null;
|
|
115
|
+
}
|
|
116
|
+
}, delay);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 实际执行保存操作
|
|
122
|
+
*/
|
|
123
|
+
function doSaveSession(state) {
|
|
124
|
+
const filePath = getSessionPath(state.accountId);
|
|
125
|
+
try {
|
|
126
|
+
ensureDir();
|
|
127
|
+
// 更新保存时间
|
|
128
|
+
const stateToSave = {
|
|
129
|
+
...state,
|
|
130
|
+
savedAt: Date.now(),
|
|
131
|
+
};
|
|
132
|
+
fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
|
|
133
|
+
console.log(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.error(`[session-store] Failed to save session for ${state.accountId}: ${err}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 清除 Session 状态
|
|
141
|
+
* @param accountId 账户 ID
|
|
142
|
+
*/
|
|
143
|
+
export function clearSession(accountId) {
|
|
144
|
+
const filePath = getSessionPath(accountId);
|
|
145
|
+
// 清除节流状态
|
|
146
|
+
const throttle = throttleState.get(accountId);
|
|
147
|
+
if (throttle) {
|
|
148
|
+
if (throttle.throttleTimer) {
|
|
149
|
+
clearTimeout(throttle.throttleTimer);
|
|
150
|
+
}
|
|
151
|
+
throttleState.delete(accountId);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
if (fs.existsSync(filePath)) {
|
|
155
|
+
fs.unlinkSync(filePath);
|
|
156
|
+
console.log(`[session-store] Cleared session for ${accountId}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
console.error(`[session-store] Failed to clear session for ${accountId}: ${err}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 更新 lastSeq(轻量级更新)
|
|
165
|
+
* @param accountId 账户 ID
|
|
166
|
+
* @param lastSeq 最新的消息序号
|
|
167
|
+
*/
|
|
168
|
+
export function updateLastSeq(accountId, lastSeq) {
|
|
169
|
+
const existing = loadSession(accountId);
|
|
170
|
+
if (existing && existing.sessionId) {
|
|
171
|
+
saveSession({
|
|
172
|
+
...existing,
|
|
173
|
+
lastSeq,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 获取所有保存的 Session 状态
|
|
179
|
+
*/
|
|
180
|
+
export function getAllSessions() {
|
|
181
|
+
const sessions = [];
|
|
182
|
+
try {
|
|
183
|
+
ensureDir();
|
|
184
|
+
const files = fs.readdirSync(SESSION_DIR);
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
if (file.startsWith("session-") && file.endsWith(".json")) {
|
|
187
|
+
const filePath = path.join(SESSION_DIR, file);
|
|
188
|
+
try {
|
|
189
|
+
const data = fs.readFileSync(filePath, "utf-8");
|
|
190
|
+
const state = JSON.parse(data);
|
|
191
|
+
sessions.push(state);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// 忽略解析错误
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// 目录不存在等错误
|
|
201
|
+
}
|
|
202
|
+
return sessions;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 清理过期的 Session 文件
|
|
206
|
+
*/
|
|
207
|
+
export function cleanupExpiredSessions() {
|
|
208
|
+
let cleaned = 0;
|
|
209
|
+
try {
|
|
210
|
+
ensureDir();
|
|
211
|
+
const files = fs.readdirSync(SESSION_DIR);
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
for (const file of files) {
|
|
214
|
+
if (file.startsWith("session-") && file.endsWith(".json")) {
|
|
215
|
+
const filePath = path.join(SESSION_DIR, file);
|
|
216
|
+
try {
|
|
217
|
+
const data = fs.readFileSync(filePath, "utf-8");
|
|
218
|
+
const state = JSON.parse(data);
|
|
219
|
+
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
|
220
|
+
fs.unlinkSync(filePath);
|
|
221
|
+
cleaned++;
|
|
222
|
+
console.log(`[session-store] Cleaned expired session: ${file}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// 忽略解析错误,但也删除损坏的文件
|
|
227
|
+
try {
|
|
228
|
+
fs.unlinkSync(filePath);
|
|
229
|
+
cleaned++;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// 忽略
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// 目录不存在等错误
|
|
240
|
+
}
|
|
241
|
+
return cleaned;
|
|
242
|
+
}
|