Files
qqbot/src/session-store.ts
rianli a3e87f2f37 feat(qqbot): 定时提醒技能与主动消息系统
**新增提醒技能**
- 新增 skills/qqbot-cron/SKILL.md 定时提醒技能定义
- 支持一次性提醒(--at)和周期性提醒(--cron)
- 支持设置、查询、取消提醒操作

**主动消息系统**
- 新增 src/proactive.ts 主动消息发送模块
- 新增 src/known-users.ts 已知用户管理
- 新增 src/session-store.ts 会话存储
- 支持主动向用户/群组发送消息

**工具脚本**
- 新增 scripts/proactive-api-server.ts 主动消息API服务
2026-02-02 20:31:14 +08:00

293 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Session 持久化存储
* 将 WebSocket 连接状态sessionId、lastSeq持久化到文件
* 支持进程重启后通过 Resume 机制快速恢复连接
*/
import fs from "node:fs";
import path from "node:path";
// Session 状态接口
export interface SessionState {
/** WebSocket Session ID */
sessionId: string | null;
/** 最后收到的消息序号 */
lastSeq: number | null;
/** 上次连接成功的时间戳 */
lastConnectedAt: number;
/** 上次成功的权限级别索引 */
intentLevelIndex: number;
/** 关联的机器人账户 ID */
accountId: string;
/** 保存时间 */
savedAt: number;
}
// Session 文件目录
const SESSION_DIR = path.join(
process.env.HOME || "/tmp",
"clawd",
"qqbot-data"
);
// Session 过期时间5分钟- Resume 要求在断开后一定时间内恢复
const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
// 写入节流时间(避免频繁写入)
const SAVE_THROTTLE_MS = 1000;
// 每个账户的节流状态
const throttleState = new Map<string, {
pendingState: SessionState | null;
lastSaveTime: number;
throttleTimer: ReturnType<typeof setTimeout> | null;
}>();
/**
* 确保目录存在
*/
function ensureDir(): void {
if (!fs.existsSync(SESSION_DIR)) {
fs.mkdirSync(SESSION_DIR, { recursive: true });
}
}
/**
* 获取 Session 文件路径
*/
function getSessionPath(accountId: string): string {
// 清理 accountId 中的特殊字符
const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
return path.join(SESSION_DIR, `session-${safeId}.json`);
}
/**
* 加载 Session 状态
* @param accountId 账户 ID
* @returns Session 状态,如果不存在或已过期返回 null
*/
export function loadSession(accountId: string): SessionState | null {
const filePath = getSessionPath(accountId);
try {
if (!fs.existsSync(filePath)) {
return null;
}
const data = fs.readFileSync(filePath, "utf-8");
const state = JSON.parse(data) as SessionState;
// 检查是否过期
const now = Date.now();
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
console.log(`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`);
// 删除过期文件
try {
fs.unlinkSync(filePath);
} catch {
// 忽略删除错误
}
return null;
}
// 验证必要字段
if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) {
console.log(`[session-store] Invalid session data for ${accountId}`);
return null;
}
console.log(`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, age=${Math.round((now - state.savedAt) / 1000)}s`);
return state;
} catch (err) {
console.error(`[session-store] Failed to load session for ${accountId}: ${err}`);
return null;
}
}
/**
* 保存 Session 状态(带节流,避免频繁写入)
* @param state Session 状态
*/
export function saveSession(state: SessionState): void {
const { accountId } = state;
// 获取或初始化节流状态
let throttle = throttleState.get(accountId);
if (!throttle) {
throttle = {
pendingState: null,
lastSaveTime: 0,
throttleTimer: null,
};
throttleState.set(accountId, throttle);
}
const now = Date.now();
const timeSinceLastSave = now - throttle.lastSaveTime;
// 如果距离上次保存时间足够长,立即保存
if (timeSinceLastSave >= SAVE_THROTTLE_MS) {
doSaveSession(state);
throttle.lastSaveTime = now;
throttle.pendingState = null;
// 清除待定的节流定时器
if (throttle.throttleTimer) {
clearTimeout(throttle.throttleTimer);
throttle.throttleTimer = null;
}
} else {
// 记录待保存的状态
throttle.pendingState = state;
// 如果没有设置定时器,设置一个
if (!throttle.throttleTimer) {
const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
throttle.throttleTimer = setTimeout(() => {
const t = throttleState.get(accountId);
if (t && t.pendingState) {
doSaveSession(t.pendingState);
t.lastSaveTime = Date.now();
t.pendingState = null;
}
if (t) {
t.throttleTimer = null;
}
}, delay);
}
}
}
/**
* 实际执行保存操作
*/
function doSaveSession(state: SessionState): void {
const filePath = getSessionPath(state.accountId);
try {
ensureDir();
// 更新保存时间
const stateToSave: SessionState = {
...state,
savedAt: Date.now(),
};
fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
console.log(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`);
} catch (err) {
console.error(`[session-store] Failed to save session for ${state.accountId}: ${err}`);
}
}
/**
* 清除 Session 状态
* @param accountId 账户 ID
*/
export function clearSession(accountId: string): void {
const filePath = getSessionPath(accountId);
// 清除节流状态
const throttle = throttleState.get(accountId);
if (throttle) {
if (throttle.throttleTimer) {
clearTimeout(throttle.throttleTimer);
}
throttleState.delete(accountId);
}
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
console.log(`[session-store] Cleared session for ${accountId}`);
}
} catch (err) {
console.error(`[session-store] Failed to clear session for ${accountId}: ${err}`);
}
}
/**
* 更新 lastSeq轻量级更新
* @param accountId 账户 ID
* @param lastSeq 最新的消息序号
*/
export function updateLastSeq(accountId: string, lastSeq: number): void {
const existing = loadSession(accountId);
if (existing && existing.sessionId) {
saveSession({
...existing,
lastSeq,
});
}
}
/**
* 获取所有保存的 Session 状态
*/
export function getAllSessions(): SessionState[] {
const sessions: SessionState[] = [];
try {
ensureDir();
const files = fs.readdirSync(SESSION_DIR);
for (const file of files) {
if (file.startsWith("session-") && file.endsWith(".json")) {
const filePath = path.join(SESSION_DIR, file);
try {
const data = fs.readFileSync(filePath, "utf-8");
const state = JSON.parse(data) as SessionState;
sessions.push(state);
} catch {
// 忽略解析错误
}
}
}
} catch {
// 目录不存在等错误
}
return sessions;
}
/**
* 清理过期的 Session 文件
*/
export function cleanupExpiredSessions(): number {
let cleaned = 0;
try {
ensureDir();
const files = fs.readdirSync(SESSION_DIR);
const now = Date.now();
for (const file of files) {
if (file.startsWith("session-") && file.endsWith(".json")) {
const filePath = path.join(SESSION_DIR, file);
try {
const data = fs.readFileSync(filePath, "utf-8");
const state = JSON.parse(data) as SessionState;
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
fs.unlinkSync(filePath);
cleaned++;
console.log(`[session-store] Cleaned expired session: ${file}`);
}
} catch {
// 忽略解析错误,但也删除损坏的文件
try {
fs.unlinkSync(filePath);
cleaned++;
} catch {
// 忽略
}
}
}
}
} catch {
// 目录不存在等错误
}
return cleaned;
}