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服务
This commit is contained in:
292
src/session-store.ts
Normal file
292
src/session-store.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user