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:
1041
console.md
Normal file
1041
console.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,14 @@
|
|||||||
{
|
{
|
||||||
"id": "qqbot",
|
"id": "qqbot",
|
||||||
|
"name": "QQ Bot Channel",
|
||||||
|
"description": "QQ Bot channel plugin with streaming message support, cron jobs, and proactive messaging",
|
||||||
"channels": ["qqbot"],
|
"channels": ["qqbot"],
|
||||||
|
"skills": ["qqbot-cron"],
|
||||||
|
"capabilities": {
|
||||||
|
"proactiveMessaging": true,
|
||||||
|
"cronJobs": true,
|
||||||
|
"streamingMessages": true
|
||||||
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
346
scripts/proactive-api-server.ts
Normal file
346
scripts/proactive-api-server.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* QQBot 主动消息 HTTP API 服务
|
||||||
|
*
|
||||||
|
* 提供 RESTful API 用于:
|
||||||
|
* 1. 发送主动消息
|
||||||
|
* 2. 查询已知用户
|
||||||
|
* 3. 广播消息
|
||||||
|
*
|
||||||
|
* 启动方式:
|
||||||
|
* npx ts-node scripts/proactive-api-server.ts --port 3721
|
||||||
|
*
|
||||||
|
* API 端点:
|
||||||
|
* POST /send - 发送主动消息
|
||||||
|
* GET /users - 列出已知用户
|
||||||
|
* GET /users/stats - 获取用户统计
|
||||||
|
* POST /broadcast - 广播消息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as http from "node:http";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as url from "node:url";
|
||||||
|
import {
|
||||||
|
sendProactiveMessageDirect,
|
||||||
|
listKnownUsers,
|
||||||
|
getKnownUsersStats,
|
||||||
|
getKnownUser,
|
||||||
|
broadcastMessage,
|
||||||
|
} from "../src/proactive.js";
|
||||||
|
import type { ResolvedQQBotAccount } from "../src/types.js";
|
||||||
|
|
||||||
|
// 默认端口
|
||||||
|
const DEFAULT_PORT = 3721;
|
||||||
|
|
||||||
|
// 从配置文件加载账户信息
|
||||||
|
function loadAccount(accountId = "default"): ResolvedQQBotAccount | null {
|
||||||
|
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 优先从环境变量获取
|
||||||
|
const envAppId = process.env.QQBOT_APP_ID;
|
||||||
|
const envClientSecret = process.env.QQBOT_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
if (envAppId && envClientSecret) {
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
appId: envAppId,
|
||||||
|
clientSecret: envClientSecret,
|
||||||
|
enabled: true,
|
||||||
|
secretSource: "env",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||||
|
const qqbot = config.channels?.qqbot;
|
||||||
|
|
||||||
|
if (!qqbot) {
|
||||||
|
if (envAppId && envClientSecret) {
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
appId: envAppId,
|
||||||
|
clientSecret: envClientSecret,
|
||||||
|
enabled: true,
|
||||||
|
secretSource: "env",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析账户配置
|
||||||
|
if (accountId === "default") {
|
||||||
|
return {
|
||||||
|
accountId: "default",
|
||||||
|
appId: qqbot.appId || envAppId,
|
||||||
|
clientSecret: qqbot.clientSecret || envClientSecret,
|
||||||
|
enabled: qqbot.enabled ?? true,
|
||||||
|
secretSource: qqbot.clientSecret ? "config" : "env",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountConfig = qqbot.accounts?.[accountId];
|
||||||
|
if (accountConfig) {
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
appId: accountConfig.appId || qqbot.appId || envAppId,
|
||||||
|
clientSecret: accountConfig.clientSecret || qqbot.clientSecret || envClientSecret,
|
||||||
|
enabled: accountConfig.enabled ?? true,
|
||||||
|
secretSource: accountConfig.clientSecret ? "config" : "env",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置(用于 broadcastMessage)
|
||||||
|
function loadConfig(): Record<string, unknown> {
|
||||||
|
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析请求体
|
||||||
|
async function parseBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(body ? JSON.parse(body) : {});
|
||||||
|
} catch {
|
||||||
|
resolve({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送 JSON 响应
|
||||||
|
function sendJson(res: http.ServerResponse, statusCode: number, data: unknown) {
|
||||||
|
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理请求
|
||||||
|
async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||||
|
const parsedUrl = url.parse(req.url || "", true);
|
||||||
|
const pathname = parsedUrl.pathname || "/";
|
||||||
|
const method = req.method || "GET";
|
||||||
|
const query = parsedUrl.query;
|
||||||
|
|
||||||
|
// CORS 支持
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
|
||||||
|
if (method === "OPTIONS") {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${new Date().toISOString()}] ${method} ${pathname}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// POST /send - 发送主动消息
|
||||||
|
if (pathname === "/send" && method === "POST") {
|
||||||
|
const body = await parseBody(req);
|
||||||
|
const { to, text, type = "c2c", accountId = "default" } = body as {
|
||||||
|
to?: string;
|
||||||
|
text?: string;
|
||||||
|
type?: "c2c" | "group";
|
||||||
|
accountId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!to || !text) {
|
||||||
|
sendJson(res, 400, { error: "Missing required fields: to, text" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = loadAccount(accountId);
|
||||||
|
if (!account) {
|
||||||
|
sendJson(res, 500, { error: "Failed to load account configuration" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendProactiveMessageDirect(account, to, text, type);
|
||||||
|
sendJson(res, result.success ? 200 : 500, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /users - 列出已知用户
|
||||||
|
if (pathname === "/users" && method === "GET") {
|
||||||
|
const type = query.type as "c2c" | "group" | "channel" | undefined;
|
||||||
|
const accountId = query.accountId as string | undefined;
|
||||||
|
const limit = query.limit ? parseInt(query.limit as string, 10) : undefined;
|
||||||
|
|
||||||
|
const users = listKnownUsers({ type, accountId, limit });
|
||||||
|
sendJson(res, 200, { total: users.length, users });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /users/stats - 获取用户统计
|
||||||
|
if (pathname === "/users/stats" && method === "GET") {
|
||||||
|
const accountId = query.accountId as string | undefined;
|
||||||
|
const stats = getKnownUsersStats(accountId);
|
||||||
|
sendJson(res, 200, stats);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /users/:openid - 获取单个用户
|
||||||
|
if (pathname.startsWith("/users/") && method === "GET" && pathname !== "/users/stats") {
|
||||||
|
const openid = pathname.slice("/users/".length);
|
||||||
|
const type = (query.type as string) || "c2c";
|
||||||
|
const accountId = (query.accountId as string) || "default";
|
||||||
|
|
||||||
|
const user = getKnownUser(type, openid, accountId);
|
||||||
|
if (user) {
|
||||||
|
sendJson(res, 200, user);
|
||||||
|
} else {
|
||||||
|
sendJson(res, 404, { error: "User not found" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /broadcast - 广播消息
|
||||||
|
if (pathname === "/broadcast" && method === "POST") {
|
||||||
|
const body = await parseBody(req);
|
||||||
|
const { text, type = "c2c", accountId, limit } = body as {
|
||||||
|
text?: string;
|
||||||
|
type?: "c2c" | "group";
|
||||||
|
accountId?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
sendJson(res, 400, { error: "Missing required field: text" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const result = await broadcastMessage(text, cfg as any, { type, accountId, limit });
|
||||||
|
sendJson(res, 200, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET / - API 文档
|
||||||
|
if (pathname === "/" && method === "GET") {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
name: "QQBot Proactive Message API",
|
||||||
|
version: "1.0.0",
|
||||||
|
endpoints: {
|
||||||
|
"POST /send": {
|
||||||
|
description: "发送主动消息",
|
||||||
|
body: {
|
||||||
|
to: "目标 openid (必需)",
|
||||||
|
text: "消息内容 (必需)",
|
||||||
|
type: "消息类型: c2c | group (默认 c2c)",
|
||||||
|
accountId: "账户 ID (默认 default)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"GET /users": {
|
||||||
|
description: "列出已知用户",
|
||||||
|
query: {
|
||||||
|
type: "过滤类型: c2c | group | channel",
|
||||||
|
accountId: "过滤账户 ID",
|
||||||
|
limit: "限制返回数量",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"GET /users/stats": {
|
||||||
|
description: "获取用户统计",
|
||||||
|
query: {
|
||||||
|
accountId: "过滤账户 ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"GET /users/:openid": {
|
||||||
|
description: "获取单个用户信息",
|
||||||
|
query: {
|
||||||
|
type: "用户类型 (默认 c2c)",
|
||||||
|
accountId: "账户 ID (默认 default)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"POST /broadcast": {
|
||||||
|
description: "广播消息给所有已知用户",
|
||||||
|
body: {
|
||||||
|
text: "消息内容 (必需)",
|
||||||
|
type: "消息类型: c2c | group (默认 c2c)",
|
||||||
|
accountId: "账户 ID",
|
||||||
|
limit: "限制发送数量",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notes: [
|
||||||
|
"只有曾经与机器人交互过的用户才能收到主动消息",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404
|
||||||
|
sendJson(res, 404, { error: "Not found" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error handling request: ${err}`);
|
||||||
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析命令行参数获取端口
|
||||||
|
function getPort(): number {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === "--port" && args[i + 1]) {
|
||||||
|
return parseInt(args[i + 1], 10) || DEFAULT_PORT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseInt(process.env.PROACTIVE_API_PORT || "", 10) || DEFAULT_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
function main() {
|
||||||
|
const port = getPort();
|
||||||
|
|
||||||
|
const server = http.createServer(handleRequest);
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`
|
||||||
|
╔═══════════════════════════════════════════════════════════════╗
|
||||||
|
║ QQBot Proactive Message API Server ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════╣
|
||||||
|
║ Server running at: http://localhost:${port.toString().padEnd(25)}║
|
||||||
|
║ ║
|
||||||
|
║ Endpoints: ║
|
||||||
|
║ GET / - API documentation ║
|
||||||
|
║ POST /send - Send proactive message ║
|
||||||
|
║ GET /users - List known users ║
|
||||||
|
║ GET /users/stats - Get user statistics ║
|
||||||
|
║ POST /broadcast - Broadcast message ║
|
||||||
|
║ ║
|
||||||
|
║ Example: ║
|
||||||
|
║ curl -X POST http://localhost:${port}/send \\ ║
|
||||||
|
║ -H "Content-Type: application/json" \\ ║
|
||||||
|
║ -d '{"to":"openid","text":"Hello!"}' ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 优雅关闭
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
console.log("\nShutting down...");
|
||||||
|
server.close(() => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
273
scripts/send-proactive.ts
Normal file
273
scripts/send-proactive.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env npx ts-node
|
||||||
|
/**
|
||||||
|
* QQBot 主动消息 CLI 工具
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* # 发送私聊消息
|
||||||
|
* npx ts-node scripts/send-proactive.ts --to "用户openid" --text "你好!"
|
||||||
|
*
|
||||||
|
* # 发送群聊消息
|
||||||
|
* npx ts-node scripts/send-proactive.ts --to "群组openid" --type group --text "群公告"
|
||||||
|
*
|
||||||
|
* # 列出已知用户
|
||||||
|
* npx ts-node scripts/send-proactive.ts --list
|
||||||
|
*
|
||||||
|
* # 列出群聊用户
|
||||||
|
* npx ts-node scripts/send-proactive.ts --list --type group
|
||||||
|
*
|
||||||
|
* # 广播消息
|
||||||
|
* npx ts-node scripts/send-proactive.ts --broadcast --text "系统公告" --type c2c --limit 10
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
sendProactiveMessageDirect,
|
||||||
|
listKnownUsers,
|
||||||
|
getKnownUsersStats,
|
||||||
|
broadcastMessage,
|
||||||
|
} from "../src/proactive.js";
|
||||||
|
import type { ResolvedQQBotAccount } from "../src/types.js";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
// 解析命令行参数
|
||||||
|
function parseArgs(): Record<string, string | boolean> {
|
||||||
|
const args: Record<string, string | boolean> = {};
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg.startsWith("--")) {
|
||||||
|
const key = arg.slice(2);
|
||||||
|
const nextArg = argv[i + 1];
|
||||||
|
if (nextArg && !nextArg.startsWith("--")) {
|
||||||
|
args[key] = nextArg;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
args[key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从配置文件加载账户信息
|
||||||
|
function loadAccount(accountId = "default"): ResolvedQQBotAccount | null {
|
||||||
|
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
// 尝试从环境变量获取
|
||||||
|
const appId = process.env.QQBOT_APP_ID;
|
||||||
|
const clientSecret = process.env.QQBOT_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (appId && clientSecret) {
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
appId,
|
||||||
|
clientSecret,
|
||||||
|
enabled: true,
|
||||||
|
secretSource: "env",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("配置文件不存在且环境变量未设置");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||||
|
const qqbot = config.channels?.qqbot;
|
||||||
|
|
||||||
|
if (!qqbot) {
|
||||||
|
console.error("配置中没有 qqbot 配置");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析账户配置
|
||||||
|
if (accountId === "default") {
|
||||||
|
return {
|
||||||
|
accountId: "default",
|
||||||
|
appId: qqbot.appId || process.env.QQBOT_APP_ID,
|
||||||
|
clientSecret: qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET,
|
||||||
|
enabled: qqbot.enabled ?? true,
|
||||||
|
secretSource: qqbot.clientSecret ? "config" : "env",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountConfig = qqbot.accounts?.[accountId];
|
||||||
|
if (accountConfig) {
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
appId: accountConfig.appId || qqbot.appId || process.env.QQBOT_APP_ID,
|
||||||
|
clientSecret: accountConfig.clientSecret || qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET,
|
||||||
|
enabled: accountConfig.enabled ?? true,
|
||||||
|
secretSource: accountConfig.clientSecret ? "config" : "env",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`账户 ${accountId} 不存在`);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`加载配置失败: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs();
|
||||||
|
|
||||||
|
// 显示帮助
|
||||||
|
if (args.help || args.h) {
|
||||||
|
console.log(`
|
||||||
|
QQBot 主动消息 CLI 工具
|
||||||
|
|
||||||
|
用法:
|
||||||
|
npx ts-node scripts/send-proactive.ts [选项]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
--to <openid> 目标用户或群组的 openid
|
||||||
|
--text <message> 要发送的消息内容
|
||||||
|
--type <type> 消息类型: c2c (私聊) 或 group (群聊),默认 c2c
|
||||||
|
--account <id> 账户 ID,默认 default
|
||||||
|
|
||||||
|
--list 列出已知用户
|
||||||
|
--stats 显示用户统计
|
||||||
|
--broadcast 广播消息给所有已知用户
|
||||||
|
--limit <n> 限制数量
|
||||||
|
|
||||||
|
--help, -h 显示帮助
|
||||||
|
|
||||||
|
示例:
|
||||||
|
# 发送私聊消息
|
||||||
|
npx ts-node scripts/send-proactive.ts --to "0Eda5EA7-xxx" --text "你好!"
|
||||||
|
|
||||||
|
# 发送群聊消息
|
||||||
|
npx ts-node scripts/send-proactive.ts --to "A1B2C3D4" --type group --text "群公告"
|
||||||
|
|
||||||
|
# 列出最近 10 个私聊用户
|
||||||
|
npx ts-node scripts/send-proactive.ts --list --type c2c --limit 10
|
||||||
|
|
||||||
|
# 广播消息
|
||||||
|
npx ts-node scripts/send-proactive.ts --broadcast --text "系统公告" --limit 5
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = (args.account as string) || "default";
|
||||||
|
const type = (args.type as "c2c" | "group") || "c2c";
|
||||||
|
const limit = args.limit ? parseInt(args.limit as string, 10) : undefined;
|
||||||
|
|
||||||
|
// 列出已知用户
|
||||||
|
if (args.list) {
|
||||||
|
const users = listKnownUsers({
|
||||||
|
type: args.type as "c2c" | "group" | "channel" | undefined,
|
||||||
|
accountId: args.account as string | undefined,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log("没有已知用户");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n已知用户列表 (共 ${users.length} 个):\n`);
|
||||||
|
console.log("类型\t\tOpenID\t\t\t\t\t\t昵称\t\t最后交互时间");
|
||||||
|
console.log("─".repeat(100));
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const lastTime = new Date(user.lastInteractionAt).toLocaleString();
|
||||||
|
console.log(`${user.type}\t\t${user.openid.slice(0, 20)}...\t${user.nickname || "-"}\t\t${lastTime}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示统计
|
||||||
|
if (args.stats) {
|
||||||
|
const stats = getKnownUsersStats(args.account as string | undefined);
|
||||||
|
console.log(`\n用户统计:`);
|
||||||
|
console.log(` 总计: ${stats.total}`);
|
||||||
|
console.log(` 私聊: ${stats.c2c}`);
|
||||||
|
console.log(` 群聊: ${stats.group}`);
|
||||||
|
console.log(` 频道: ${stats.channel}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广播消息
|
||||||
|
if (args.broadcast) {
|
||||||
|
if (!args.text) {
|
||||||
|
console.error("请指定消息内容 (--text)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置用于广播
|
||||||
|
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
|
||||||
|
let cfg: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
console.log(`\n开始广播消息...\n`);
|
||||||
|
const result = await broadcastMessage(args.text as string, cfg as any, {
|
||||||
|
type,
|
||||||
|
accountId,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n广播完成:`);
|
||||||
|
console.log(` 发送总数: ${result.total}`);
|
||||||
|
console.log(` 成功: ${result.success}`);
|
||||||
|
console.log(` 失败: ${result.failed}`);
|
||||||
|
|
||||||
|
if (result.failed > 0) {
|
||||||
|
console.log(`\n失败详情:`);
|
||||||
|
for (const r of result.results) {
|
||||||
|
if (!r.result.success) {
|
||||||
|
console.log(` ${r.to}: ${r.result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送单条消息
|
||||||
|
if (args.to && args.text) {
|
||||||
|
const account = loadAccount(accountId);
|
||||||
|
if (!account) {
|
||||||
|
console.error("无法加载账户配置");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n发送消息...`);
|
||||||
|
console.log(` 目标: ${args.to}`);
|
||||||
|
console.log(` 类型: ${type}`);
|
||||||
|
console.log(` 内容: ${args.text}`);
|
||||||
|
|
||||||
|
const result = await sendProactiveMessageDirect(
|
||||||
|
account,
|
||||||
|
args.to as string,
|
||||||
|
args.text as string,
|
||||||
|
type
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`\n✅ 发送成功!`);
|
||||||
|
console.log(` 消息ID: ${result.messageId}`);
|
||||||
|
console.log(` 时间戳: ${result.timestamp}`);
|
||||||
|
} else {
|
||||||
|
console.log(`\n❌ 发送失败: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有有效参数
|
||||||
|
console.error("请指定操作。使用 --help 查看帮助。");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`执行失败: ${err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
476
skills/qqbot-cron/SKILL.md
Normal file
476
skills/qqbot-cron/SKILL.md
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
---
|
||||||
|
name: qqbot-cron
|
||||||
|
description: QQ Bot 智能提醒技能。支持一次性提醒、周期性任务、自动降级确保送达。可设置、查询、取消提醒。
|
||||||
|
metadata: {"clawdbot":{"emoji":"⏰","requires":{"channels":["qqbot"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# QQ Bot 智能提醒
|
||||||
|
|
||||||
|
让 AI 帮用户设置、管理定时提醒,支持私聊和群聊。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 AI 决策指南
|
||||||
|
|
||||||
|
> **本节专为 AI 理解设计,帮助快速决策**
|
||||||
|
|
||||||
|
### 用户意图识别
|
||||||
|
|
||||||
|
| 用户说法 | 意图 | 执行动作 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| "5分钟后提醒我喝水" | 创建提醒 | `openclaw cron add` |
|
||||||
|
| "每天8点提醒我打卡" | 创建周期提醒 | `openclaw cron add --cron` |
|
||||||
|
| "我有哪些提醒" | 查询提醒 | `openclaw cron list` |
|
||||||
|
| "取消喝水提醒" | 删除提醒 | `openclaw cron remove` |
|
||||||
|
| "修改提醒时间" | 删除+重建 | 先 remove 再 add |
|
||||||
|
| "提醒我" (无时间) | **需追问** | 询问具体时间 |
|
||||||
|
|
||||||
|
### 必须追问的情况
|
||||||
|
|
||||||
|
当用户说法**缺少以下信息**时,**必须追问**:
|
||||||
|
|
||||||
|
1. **没有时间**:"提醒我喝水" → 询问"请问什么时候提醒你?"
|
||||||
|
2. **时间模糊**:"晚点提醒我" → 询问"具体几点呢?"
|
||||||
|
3. **周期不明**:"定期提醒我" → 询问"多久一次?每天?每周?"
|
||||||
|
|
||||||
|
### 无需追问可直接执行
|
||||||
|
|
||||||
|
| 用户说法 | 理解为 |
|
||||||
|
|----------|--------|
|
||||||
|
| "5分钟后" | `--at 5m` |
|
||||||
|
| "半小时后" | `--at 30m` |
|
||||||
|
| "1小时后" | `--at 1h` |
|
||||||
|
| "明天早上8点" | `--at 2026-02-02T08:00:00+08:00` |
|
||||||
|
| "每天早上8点" | `--cron "0 8 * * *"` |
|
||||||
|
| "工作日9点" | `--cron "0 9 * * 1-5"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 命令速查
|
||||||
|
|
||||||
|
### 创建提醒(完整模板)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "{任务名}" \
|
||||||
|
--at "{时间}" \
|
||||||
|
--session isolated \
|
||||||
|
--system-event '{"type":"reminder","user_openid":"{openid}","user_name":"{用户名称}","original_message_id":"{message_id}","reminder_content":"{提醒内容}","created_at":"{当前时间ISO格式}"}' \
|
||||||
|
--message "{消息内容}" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}" \
|
||||||
|
--delete-after-run
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **`--system-event` 说明**:用于存储用户上下文信息,提醒触发时 AI 可以获取这些信息来提供更个性化的提醒。
|
||||||
|
|
||||||
|
> ⚠️ **注意**:`cron add` 命令不支持 `--reply-to` 参数。提醒消息将作为主动消息直接发送给用户。
|
||||||
|
|
||||||
|
### 查询提醒列表
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw cron list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除提醒
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw cron remove {jobId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 立即发送消息(主动消息)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw message send \
|
||||||
|
--channel qqbot \
|
||||||
|
--target "{openid}" \
|
||||||
|
--message "{消息内容}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 立即发送消息(被动回复)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw message send \
|
||||||
|
--channel qqbot \
|
||||||
|
--target "{openid}" \
|
||||||
|
--reply-to "{message_id}" \
|
||||||
|
--message "{消息内容}"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **注意**:`--reply-to` 仅在 `message send` 命令中支持,且 message_id 必须在 1 小时内有效。定时提醒不支持被动回复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 用户交互模板
|
||||||
|
|
||||||
|
> **创建提醒后,必须给用户反馈确认**
|
||||||
|
|
||||||
|
### 创建成功反馈
|
||||||
|
|
||||||
|
**一次性提醒**:
|
||||||
|
```
|
||||||
|
✅ 提醒已设置!
|
||||||
|
|
||||||
|
📝 内容:{提醒内容}
|
||||||
|
⏰ 时间:{具体时间}
|
||||||
|
|
||||||
|
到时候我会准时提醒你~
|
||||||
|
```
|
||||||
|
|
||||||
|
**周期提醒**:
|
||||||
|
```
|
||||||
|
✅ 周期提醒已设置!
|
||||||
|
|
||||||
|
📝 内容:{提醒内容}
|
||||||
|
🔄 周期:{周期描述,如"每天早上8:00"}
|
||||||
|
|
||||||
|
提醒会持续生效,说"取消xx提醒"可停止~
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询提醒反馈
|
||||||
|
|
||||||
|
```
|
||||||
|
📋 你当前的提醒:
|
||||||
|
|
||||||
|
1. ⏰ 喝水提醒 - 5分钟后 (一次性)
|
||||||
|
2. 🔄 打卡提醒 - 每天08:00 (周期)
|
||||||
|
3. 🔄 日报提醒 - 工作日18:00 (周期)
|
||||||
|
|
||||||
|
说"取消xx提醒"可删除~
|
||||||
|
```
|
||||||
|
|
||||||
|
### 无提醒时反馈
|
||||||
|
|
||||||
|
```
|
||||||
|
📋 你当前没有设置任何提醒
|
||||||
|
|
||||||
|
说"5分钟后提醒我xxx"可创建提醒~
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除成功反馈
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 已取消"{提醒名称}"提醒
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏱️ 时间格式
|
||||||
|
|
||||||
|
### 相对时间(--at)
|
||||||
|
|
||||||
|
> ⚠️ **不要加 + 号!** 用 `5m` 而不是 `+5m`
|
||||||
|
|
||||||
|
| 用户说法 | 参数值 |
|
||||||
|
|----------|--------|
|
||||||
|
| 5分钟后 | `5m` |
|
||||||
|
| 半小时后 | `30m` |
|
||||||
|
| 1小时后 | `1h` |
|
||||||
|
| 2小时后 | `2h` |
|
||||||
|
| 明天这时候 | `24h` |
|
||||||
|
|
||||||
|
### 绝对时间(--at)
|
||||||
|
|
||||||
|
| 用户说法 | 参数值 |
|
||||||
|
|----------|--------|
|
||||||
|
| 今天下午3点 | `2026-02-01T15:00:00+08:00` |
|
||||||
|
| 明天早上8点 | `2026-02-02T08:00:00+08:00` |
|
||||||
|
| 2月14日中午 | `2026-02-14T12:00:00+08:00` |
|
||||||
|
|
||||||
|
### Cron 表达式(--cron)
|
||||||
|
|
||||||
|
| 用户说法 | Cron 表达式 | 必须加 `--tz "Asia/Shanghai"` |
|
||||||
|
|----------|-------------|------------------------------|
|
||||||
|
| 每天早上8点 | `0 8 * * *` | ✅ |
|
||||||
|
| 每天晚上10点 | `0 22 * * *` | ✅ |
|
||||||
|
| 每个工作日早上9点 | `0 9 * * 1-5` | ✅ |
|
||||||
|
| 每周一早上9点 | `0 9 * * 1` | ✅ |
|
||||||
|
| 每周末上午10点 | `0 10 * * 0,6` | ✅ |
|
||||||
|
| 每小时整点 | `0 * * * *` | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 参数说明
|
||||||
|
|
||||||
|
### 必填参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `--name` | 任务名,含用户标识 | `"喝水提醒-小明"` |
|
||||||
|
| `--at` / `--cron` | 触发时间(二选一) | `5m` / `0 8 * * *` |
|
||||||
|
| `--session isolated` | 隔离会话 | 固定值 |
|
||||||
|
| `--message` | 消息内容,**不能为空** | `"💧 该喝水啦!"` |
|
||||||
|
| `--deliver` | 启用投递 | 固定值 |
|
||||||
|
| `--channel qqbot` | QQ 渠道 | 固定值 |
|
||||||
|
| `--to` | 接收者 openid | 从系统消息获取 |
|
||||||
|
|
||||||
|
### 推荐参数(用于存储用户上下文)
|
||||||
|
|
||||||
|
| 参数 | 说明 | 何时使用 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `--system-event` | 用户上下文 JSON | **建议所有任务都使用** |
|
||||||
|
| `--delete-after-run` | 执行后删除 | **一次性任务必须** |
|
||||||
|
| `--tz "Asia/Shanghai"` | 时区 | **周期任务必须** |
|
||||||
|
|
||||||
|
### --system-event 字段说明
|
||||||
|
|
||||||
|
`--system-event` 用于存储提醒的上下文信息,格式为 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "reminder",
|
||||||
|
"user_openid": "用户的openid",
|
||||||
|
"user_name": "用户名称(如有)",
|
||||||
|
"original_message_id": "创建提醒时的message_id",
|
||||||
|
"reminder_content": "提醒内容摘要",
|
||||||
|
"created_at": "创建时间ISO格式"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 | 来源 |
|
||||||
|
|------|------|------|
|
||||||
|
| `type` | 事件类型,固定为 `"reminder"` | 固定值 |
|
||||||
|
| `user_openid` | 用户 openid | 从系统消息获取 |
|
||||||
|
| `user_name` | 用户名称 | 从系统消息获取(如有) |
|
||||||
|
| `original_message_id` | 创建时的消息 ID | 从系统消息获取 |
|
||||||
|
| `reminder_content` | 提醒内容摘要 | AI 根据用户请求生成 |
|
||||||
|
| `created_at` | 提醒创建时间 | 当前时间 ISO 格式 |
|
||||||
|
|
||||||
|
> 💡 **为什么需要 `--system-event`?**
|
||||||
|
> - 提醒触发时,AI 可以获取完整的用户上下文
|
||||||
|
> - 便于追踪提醒来源和调试
|
||||||
|
> - 为未来可能的功能扩展预留信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 使用场景示例
|
||||||
|
|
||||||
|
### 场景1:一次性提醒
|
||||||
|
|
||||||
|
**用户**: 5分钟后提醒我喝水
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "喝水提醒-用户" \
|
||||||
|
--at "5m" \
|
||||||
|
--session isolated \
|
||||||
|
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"喝水","created_at":"2026-02-01T16:50:00+08:00"}' \
|
||||||
|
--message "💧 该喝水啦!这是你5分钟前设置的提醒~" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}" \
|
||||||
|
--delete-after-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 回复**:
|
||||||
|
```
|
||||||
|
✅ 提醒已设置!
|
||||||
|
|
||||||
|
📝 内容:喝水
|
||||||
|
⏰ 时间:5分钟后
|
||||||
|
|
||||||
|
到时候我会准时提醒你~
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景2:每日周期提醒
|
||||||
|
|
||||||
|
**用户**: 每天早上8点提醒我打卡
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "打卡提醒-用户" \
|
||||||
|
--cron "0 8 * * *" \
|
||||||
|
--tz "Asia/Shanghai" \
|
||||||
|
--session isolated \
|
||||||
|
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"打卡","created_at":"2026-02-01T16:50:00+08:00"}' \
|
||||||
|
--message "🌅 早上好!记得打卡签到~" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 回复**:
|
||||||
|
```
|
||||||
|
✅ 周期提醒已设置!
|
||||||
|
|
||||||
|
📝 内容:打卡
|
||||||
|
🔄 周期:每天早上 8:00
|
||||||
|
|
||||||
|
提醒会持续生效,说"取消打卡提醒"可停止~
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 周期任务**不加** `--delete-after-run`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景3:工作日提醒
|
||||||
|
|
||||||
|
**用户**: 工作日下午6点提醒我写日报
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "日报提醒-用户" \
|
||||||
|
--cron "0 18 * * 1-5" \
|
||||||
|
--tz "Asia/Shanghai" \
|
||||||
|
--session isolated \
|
||||||
|
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"写日报","created_at":"2026-02-01T16:50:00+08:00"}' \
|
||||||
|
--message "📝 今天的日报别忘了提交!" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景4:群组提醒
|
||||||
|
|
||||||
|
**用户**(群聊): 每天早上9点提醒大家站会
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "站会提醒-群" \
|
||||||
|
--cron "0 9 * * 1-5" \
|
||||||
|
--tz "Asia/Shanghai" \
|
||||||
|
--session isolated \
|
||||||
|
--system-event '{"type":"reminder","user_openid":"group:{group_openid}","original_message_id":"{message_id}","reminder_content":"站会","created_at":"2026-02-01T16:50:00+08:00"}' \
|
||||||
|
--message "📢 各位同事,9点站会时间到!请准时参加~" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "group:{group_openid}"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 群组使用 `group:{group_openid}` 格式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景5:查询提醒
|
||||||
|
|
||||||
|
**用户**: 我有哪些提醒?
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron list
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 回复**(根据返回结果组织):
|
||||||
|
```
|
||||||
|
📋 你当前的提醒:
|
||||||
|
|
||||||
|
1. ⏰ 喝水提醒 - 3分钟后 (一次性)
|
||||||
|
2. 🔄 打卡提醒 - 每天08:00 (周期)
|
||||||
|
|
||||||
|
说"取消xx提醒"可删除~
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景6:取消提醒
|
||||||
|
|
||||||
|
**用户**: 取消打卡提醒
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
1. 先执行 `openclaw cron list` 找到对应任务 ID
|
||||||
|
2. 执行 `openclaw cron remove {jobId}`
|
||||||
|
|
||||||
|
**AI 回复**:
|
||||||
|
```
|
||||||
|
✅ 已取消"打卡提醒"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 消息发送说明
|
||||||
|
|
||||||
|
### 定时提醒(cron add)
|
||||||
|
|
||||||
|
定时提醒**只能发送主动消息**,因为:
|
||||||
|
- 提醒执行时,原始 message_id 通常已超过 1 小时有效期
|
||||||
|
- `openclaw cron add` 命令不支持 `--reply-to` 参数
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 定时任务触发 │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ AI 通过 system-event │
|
||||||
|
│ 获取用户上下文信息 │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 发送主动消息到用户 │
|
||||||
|
│ --channel qqbot │
|
||||||
|
│ --to {openid} │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
↓
|
||||||
|
✅ 用户收到提醒
|
||||||
|
```
|
||||||
|
|
||||||
|
### 即时回复(message send)
|
||||||
|
|
||||||
|
即时消息发送支持被动回复(如果 message_id 有效):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 发送即时消息 │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ 有 --reply-to 且 message_id │
|
||||||
|
│ 在 1 小时内有效? │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
↓ ↓
|
||||||
|
是 否
|
||||||
|
↓ ↓
|
||||||
|
┌───────────────┐ ┌─────────────────┐
|
||||||
|
│ 被动消息回复 │ │ 发送主动消息 │
|
||||||
|
│ (引用原消息) │ │ (直接发送) │
|
||||||
|
└───────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 重要限制
|
||||||
|
|
||||||
|
| 限制 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **message_id 有效期** | 1 小时内有效,超时自动降级 |
|
||||||
|
| **回复次数限制** | 同一 message_id 最多回复 4 次 |
|
||||||
|
| **主动消息限制** | 只能发给与机器人交互过的用户 |
|
||||||
|
| **消息内容** | `--message` 不能为空 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 消息模板
|
||||||
|
|
||||||
|
| 场景 | 模板 | Emoji |
|
||||||
|
|------|------|-------|
|
||||||
|
| 喝水 | 该喝水啦! | 💧 🚰 |
|
||||||
|
| 打卡 | 早上好!记得打卡~ | 🌅 ✅ |
|
||||||
|
| 会议 | xx会议马上开始! | 📅 👥 |
|
||||||
|
| 休息 | 该休息一下了~ | 😴 💤 |
|
||||||
|
| 日报 | 今日日报别忘了~ | 📝 ✍️ |
|
||||||
|
| 运动 | 该运动了! | 🏃 💪 |
|
||||||
|
| 吃药 | 记得按时吃药~ | 💊 🏥 |
|
||||||
|
| 生日 | 今天是xx的生日! | 🎂 🎉 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 用户标识
|
||||||
|
|
||||||
|
| 类型 | 格式 | 来源 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户 openid | `B3EA9A1d-2D3c-5CBD-...` | 系统消息自动提供 |
|
||||||
|
| 群组 openid | `group:FeC1ADaf-...` | 系统消息自动提供 |
|
||||||
|
| message_id | `ROBOT1.0_xxx` | 系统消息自动提供 |
|
||||||
|
|
||||||
|
> 💡 这些信息在系统消息中格式如:
|
||||||
|
> - `当前用户 openid: B3EA9A1d-...`
|
||||||
|
> - `当前消息 message_id: ROBOT1.0_...`
|
||||||
244
src/api.ts
244
src/api.ts
@@ -26,9 +26,14 @@ export function isMarkdownSupport(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cachedToken: { token: string; expiresAt: number } | null = null;
|
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
// Singleflight: 防止并发获取 Token 的 Promise 缓存
|
||||||
|
let tokenFetchPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 AccessToken(带缓存)
|
* 获取 AccessToken(带缓存 + singleflight 并发安全)
|
||||||
|
*
|
||||||
|
* 使用 singleflight 模式:当多个请求同时发现 Token 过期时,
|
||||||
|
* 只有第一个请求会真正去获取新 Token,其他请求复用同一个 Promise。
|
||||||
*/
|
*/
|
||||||
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
|
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
|
||||||
// 检查缓存,提前 5 分钟刷新
|
// 检查缓存,提前 5 分钟刷新
|
||||||
@@ -36,6 +41,30 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
|||||||
return cachedToken.token;
|
return cachedToken.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Singleflight: 如果已有进行中的 Token 获取请求,复用它
|
||||||
|
if (tokenFetchPromise) {
|
||||||
|
console.log(`[qqbot-api] Token fetch in progress, waiting for existing request...`);
|
||||||
|
return tokenFetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 Token 获取 Promise(singleflight 入口)
|
||||||
|
tokenFetchPromise = (async () => {
|
||||||
|
try {
|
||||||
|
return await doFetchToken(appId, clientSecret);
|
||||||
|
} finally {
|
||||||
|
// 无论成功失败,都清除 Promise 缓存
|
||||||
|
tokenFetchPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return tokenFetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际执行 Token 获取的内部函数
|
||||||
|
*/
|
||||||
|
async function doFetchToken(appId: string, clientSecret: string): Promise<string> {
|
||||||
|
|
||||||
const requestBody = { appId, clientSecret };
|
const requestBody = { appId, clientSecret };
|
||||||
const requestHeaders = { "Content-Type": "application/json" };
|
const requestHeaders = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
@@ -86,6 +115,7 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
|||||||
expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
|
expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`);
|
||||||
return cachedToken.token;
|
return cachedToken.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +124,22 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
|||||||
*/
|
*/
|
||||||
export function clearTokenCache(): void {
|
export function clearTokenCache(): void {
|
||||||
cachedToken = null;
|
cachedToken = null;
|
||||||
|
// 注意:不清除 tokenFetchPromise,让进行中的请求完成
|
||||||
|
// 下次调用 getAccessToken 时会自动获取新 Token
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token 缓存状态(用于监控)
|
||||||
|
*/
|
||||||
|
export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
|
||||||
|
if (tokenFetchPromise) {
|
||||||
|
return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null };
|
||||||
|
}
|
||||||
|
if (!cachedToken) {
|
||||||
|
return { status: "none", expiresAt: null };
|
||||||
|
}
|
||||||
|
const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000;
|
||||||
|
return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -320,32 +366,65 @@ export async function sendGroupMessage(
|
|||||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建主动消息请求体
|
||||||
|
* 根据 markdownSupport 配置决定消息格式:
|
||||||
|
* - markdown 模式: { markdown: { content }, msg_type: 2 }
|
||||||
|
* - 纯文本模式: { content, msg_type: 0 }
|
||||||
|
*
|
||||||
|
* 注意:主动消息不支持流式发送
|
||||||
|
*/
|
||||||
|
function buildProactiveMessageBody(content: string): Record<string, unknown> {
|
||||||
|
// 主动消息内容校验(参考 Telegram 机制)
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
throw new Error("主动消息内容不能为空 (markdown.content is empty)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentMarkdownSupport) {
|
||||||
|
return {
|
||||||
|
markdown: { content },
|
||||||
|
msg_type: 2,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
msg_type: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主动发送 C2C 单聊消息(不需要 msg_id,每月限 4 条/用户)
|
* 主动发送 C2C 单聊消息(不需要 msg_id,每月限 4 条/用户)
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* 1. 内容不能为空(对应 markdown.content 字段)
|
||||||
|
* 2. 不支持流式发送
|
||||||
*/
|
*/
|
||||||
export async function sendProactiveC2CMessage(
|
export async function sendProactiveC2CMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
openid: string,
|
openid: string,
|
||||||
content: string
|
content: string
|
||||||
): Promise<{ id: string; timestamp: number }> {
|
): Promise<{ id: string; timestamp: number }> {
|
||||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
|
const body = buildProactiveMessageBody(content);
|
||||||
content,
|
console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`);
|
||||||
msg_type: 0,
|
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主动发送群聊消息(不需要 msg_id,每月限 4 条/群)
|
* 主动发送群聊消息(不需要 msg_id,每月限 4 条/群)
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* 1. 内容不能为空(对应 markdown.content 字段)
|
||||||
|
* 2. 不支持流式发送
|
||||||
*/
|
*/
|
||||||
export async function sendProactiveGroupMessage(
|
export async function sendProactiveGroupMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
groupOpenid: string,
|
groupOpenid: string,
|
||||||
content: string
|
content: string
|
||||||
): Promise<{ id: string; timestamp: string }> {
|
): Promise<{ id: string; timestamp: string }> {
|
||||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
|
const body = buildProactiveMessageBody(content);
|
||||||
content,
|
console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`);
|
||||||
msg_type: 0,
|
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 富媒体消息支持 ============
|
// ============ 富媒体消息支持 ============
|
||||||
@@ -475,3 +554,150 @@ export async function sendGroupImageMessage(
|
|||||||
// 再发送富媒体消息
|
// 再发送富媒体消息
|
||||||
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
|
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 后台 Token 刷新 (P1-1) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台 Token 刷新配置
|
||||||
|
*/
|
||||||
|
interface BackgroundTokenRefreshOptions {
|
||||||
|
/** 提前刷新时间(毫秒,默认 5 分钟) */
|
||||||
|
refreshAheadMs?: number;
|
||||||
|
/** 随机偏移范围(毫秒,默认 0-30 秒) */
|
||||||
|
randomOffsetMs?: number;
|
||||||
|
/** 最小刷新间隔(毫秒,默认 1 分钟) */
|
||||||
|
minRefreshIntervalMs?: number;
|
||||||
|
/** 失败后重试间隔(毫秒,默认 5 秒) */
|
||||||
|
retryDelayMs?: number;
|
||||||
|
/** 日志函数 */
|
||||||
|
log?: {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
error: (msg: string) => void;
|
||||||
|
debug?: (msg: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台刷新状态
|
||||||
|
let backgroundRefreshRunning = false;
|
||||||
|
let backgroundRefreshAbortController: AbortController | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动后台 Token 刷新
|
||||||
|
* 在后台定时刷新 Token,避免请求时才发现过期
|
||||||
|
*
|
||||||
|
* @param appId 应用 ID
|
||||||
|
* @param clientSecret 应用密钥
|
||||||
|
* @param options 配置选项
|
||||||
|
*/
|
||||||
|
export function startBackgroundTokenRefresh(
|
||||||
|
appId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
options?: BackgroundTokenRefreshOptions
|
||||||
|
): void {
|
||||||
|
if (backgroundRefreshRunning) {
|
||||||
|
console.log("[qqbot-api] Background token refresh already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
refreshAheadMs = 5 * 60 * 1000, // 提前 5 分钟刷新
|
||||||
|
randomOffsetMs = 30 * 1000, // 0-30 秒随机偏移
|
||||||
|
minRefreshIntervalMs = 60 * 1000, // 最少 1 分钟后刷新
|
||||||
|
retryDelayMs = 5 * 1000, // 失败后 5 秒重试
|
||||||
|
log,
|
||||||
|
} = options ?? {};
|
||||||
|
|
||||||
|
backgroundRefreshRunning = true;
|
||||||
|
backgroundRefreshAbortController = new AbortController();
|
||||||
|
const signal = backgroundRefreshAbortController.signal;
|
||||||
|
|
||||||
|
const refreshLoop = async () => {
|
||||||
|
log?.info?.("[qqbot-api] Background token refresh started");
|
||||||
|
|
||||||
|
while (!signal.aborted) {
|
||||||
|
try {
|
||||||
|
// 先确保有一个有效 Token
|
||||||
|
await getAccessToken(appId, clientSecret);
|
||||||
|
|
||||||
|
// 计算下次刷新时间
|
||||||
|
if (cachedToken) {
|
||||||
|
const expiresIn = cachedToken.expiresAt - Date.now();
|
||||||
|
// 提前刷新时间 + 随机偏移(避免集群同时刷新)
|
||||||
|
const randomOffset = Math.random() * randomOffsetMs;
|
||||||
|
const refreshIn = Math.max(
|
||||||
|
expiresIn - refreshAheadMs - randomOffset,
|
||||||
|
minRefreshIntervalMs
|
||||||
|
);
|
||||||
|
|
||||||
|
log?.debug?.(
|
||||||
|
`[qqbot-api] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 等待到刷新时间
|
||||||
|
await sleep(refreshIn, signal);
|
||||||
|
} else {
|
||||||
|
// 没有缓存的 Token,等待一段时间后重试
|
||||||
|
log?.debug?.("[qqbot-api] No cached token, retrying soon");
|
||||||
|
await sleep(minRefreshIntervalMs, signal);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
|
||||||
|
// 刷新失败,等待后重试
|
||||||
|
log?.error?.(`[qqbot-api] Background token refresh failed: ${err}`);
|
||||||
|
await sleep(retryDelayMs, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundRefreshRunning = false;
|
||||||
|
log?.info?.("[qqbot-api] Background token refresh stopped");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 异步启动,不阻塞调用者
|
||||||
|
refreshLoop().catch((err) => {
|
||||||
|
backgroundRefreshRunning = false;
|
||||||
|
log?.error?.(`[qqbot-api] Background token refresh crashed: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止后台 Token 刷新
|
||||||
|
*/
|
||||||
|
export function stopBackgroundTokenRefresh(): void {
|
||||||
|
if (backgroundRefreshAbortController) {
|
||||||
|
backgroundRefreshAbortController.abort();
|
||||||
|
backgroundRefreshAbortController = null;
|
||||||
|
}
|
||||||
|
backgroundRefreshRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查后台 Token 刷新是否正在运行
|
||||||
|
*/
|
||||||
|
export function isBackgroundTokenRefreshRunning(): boolean {
|
||||||
|
return backgroundRefreshRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可中断的 sleep 函数
|
||||||
|
*/
|
||||||
|
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Aborted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Aborted"));
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function resolveQQBotAccount(
|
|||||||
systemPrompt: qqbot?.systemPrompt,
|
systemPrompt: qqbot?.systemPrompt,
|
||||||
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||||
markdownSupport: qqbot?.markdownSupport,
|
markdownSupport: qqbot?.markdownSupport,
|
||||||
|
streamEnabled: qqbot?.streamEnabled,
|
||||||
};
|
};
|
||||||
appId = qqbot?.appId ?? "";
|
appId = qqbot?.appId ?? "";
|
||||||
} else {
|
} else {
|
||||||
@@ -113,6 +114,7 @@ export function resolveQQBotAccount(
|
|||||||
systemPrompt: accountConfig.systemPrompt,
|
systemPrompt: accountConfig.systemPrompt,
|
||||||
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
||||||
markdownSupport: accountConfig.markdownSupport,
|
markdownSupport: accountConfig.markdownSupport,
|
||||||
|
streamEnabled: accountConfig.streamEnabled,
|
||||||
config: accountConfig,
|
config: accountConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
450
src/gateway.ts
450
src/gateway.ts
@@ -2,7 +2,9 @@ import WebSocket from "ws";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
||||||
import { StreamState } from "./types.js";
|
import { StreamState } from "./types.js";
|
||||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, sendC2CInputNotify } from "./api.js";
|
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } from "./api.js";
|
||||||
|
import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
|
||||||
|
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
||||||
import { getQQBotRuntime } from "./runtime.js";
|
import { getQQBotRuntime } from "./runtime.js";
|
||||||
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
||||||
import { createStreamSender } from "./outbound.js";
|
import { createStreamSender } from "./outbound.js";
|
||||||
@@ -55,7 +57,79 @@ const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process
|
|||||||
// 流式消息配置
|
// 流式消息配置
|
||||||
const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒)
|
const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒)
|
||||||
const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符)
|
const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符)
|
||||||
const STREAM_KEEPALIVE_INTERVAL = 8000; // 流式心跳间隔(毫秒),需要在 10 秒内发送
|
const STREAM_KEEPALIVE_FIRST_DELAY = 3000; // 首次状态保持延迟(毫秒),openclaw 3s 内未回复时发送
|
||||||
|
const STREAM_KEEPALIVE_GAP = 10000; // 状态保持消息之间的间隔(毫秒)
|
||||||
|
const STREAM_KEEPALIVE_MAX_PER_CHUNK = 2; // 每 2 个消息分片之间最多发送的状态保持消息数量
|
||||||
|
const STREAM_MAX_DURATION = 3 * 60 * 1000; // 流式消息最大持续时间(毫秒),超过 3 分钟自动结束
|
||||||
|
|
||||||
|
// 消息队列配置(异步处理,防止阻塞心跳)
|
||||||
|
const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度
|
||||||
|
const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值
|
||||||
|
|
||||||
|
// ============ 消息回复限流器 ============
|
||||||
|
// 同一 message_id 1小时内最多回复 4 次,超过1小时需降级为主动消息
|
||||||
|
const MESSAGE_REPLY_LIMIT = 4;
|
||||||
|
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
|
||||||
|
|
||||||
|
interface MessageReplyRecord {
|
||||||
|
count: number;
|
||||||
|
firstReplyAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageReplyTracker = new Map<string, MessageReplyRecord>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以回复该消息(限流检查)
|
||||||
|
* @param messageId 消息ID
|
||||||
|
* @returns { allowed: boolean, remaining: number } allowed=是否允许回复,remaining=剩余次数
|
||||||
|
*/
|
||||||
|
function checkMessageReplyLimit(messageId: string): { allowed: boolean; remaining: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = messageReplyTracker.get(messageId);
|
||||||
|
|
||||||
|
// 清理过期记录(定期清理,避免内存泄漏)
|
||||||
|
if (messageReplyTracker.size > 10000) {
|
||||||
|
for (const [id, rec] of messageReplyTracker) {
|
||||||
|
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||||
|
messageReplyTracker.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||||
|
messageReplyTracker.delete(messageId);
|
||||||
|
return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否超过限制
|
||||||
|
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
||||||
|
return { allowed: remaining > 0, remaining: Math.max(0, remaining) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次消息回复
|
||||||
|
* @param messageId 消息ID
|
||||||
|
*/
|
||||||
|
function recordMessageReply(messageId: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = messageReplyTracker.get(messageId);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||||
|
} else {
|
||||||
|
// 检查是否过期,过期则重新计数
|
||||||
|
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||||
|
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||||
|
} else {
|
||||||
|
record.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayContext {
|
export interface GatewayContext {
|
||||||
account: ResolvedQQBotAccount;
|
account: ResolvedQQBotAccount;
|
||||||
@@ -70,6 +144,22 @@ export interface GatewayContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息队列项类型(用于异步处理消息,防止阻塞心跳)
|
||||||
|
*/
|
||||||
|
interface QueuedMessage {
|
||||||
|
type: "c2c" | "guild" | "dm" | "group";
|
||||||
|
senderId: string;
|
||||||
|
senderName?: string;
|
||||||
|
content: string;
|
||||||
|
messageId: string;
|
||||||
|
timestamp: string;
|
||||||
|
channelId?: string;
|
||||||
|
guildId?: string;
|
||||||
|
groupOpenid?: string;
|
||||||
|
attachments?: Array<{ content_type: string; url: string; filename?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动图床服务器
|
* 启动图床服务器
|
||||||
*/
|
*/
|
||||||
@@ -137,6 +227,74 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
let intentLevelIndex = 0; // 当前尝试的权限级别索引
|
let intentLevelIndex = 0; // 当前尝试的权限级别索引
|
||||||
let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
|
let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
|
||||||
|
|
||||||
|
// ============ P1-2: 尝试从持久化存储恢复 Session ============
|
||||||
|
const savedSession = loadSession(account.accountId);
|
||||||
|
if (savedSession) {
|
||||||
|
sessionId = savedSession.sessionId;
|
||||||
|
lastSeq = savedSession.lastSeq;
|
||||||
|
intentLevelIndex = savedSession.intentLevelIndex;
|
||||||
|
lastSuccessfulIntentLevel = savedSession.intentLevelIndex;
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 消息队列(异步处理,防止阻塞心跳) ============
|
||||||
|
const messageQueue: QueuedMessage[] = [];
|
||||||
|
let messageProcessorRunning = false;
|
||||||
|
let messagesProcessed = 0; // 统计已处理消息数
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将消息加入队列(非阻塞)
|
||||||
|
*/
|
||||||
|
const enqueueMessage = (msg: QueuedMessage): void => {
|
||||||
|
if (messageQueue.length >= MESSAGE_QUEUE_SIZE) {
|
||||||
|
// 队列满了,丢弃最旧的消息
|
||||||
|
const dropped = messageQueue.shift();
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Message queue full, dropping oldest message from ${dropped?.senderId}`);
|
||||||
|
}
|
||||||
|
if (messageQueue.length >= MESSAGE_QUEUE_WARN_THRESHOLD) {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Message queue size: ${messageQueue.length}/${MESSAGE_QUEUE_SIZE}`);
|
||||||
|
}
|
||||||
|
messageQueue.push(msg);
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] Message enqueued, queue size: ${messageQueue.length}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动消息处理循环(独立于 WS 消息循环)
|
||||||
|
*/
|
||||||
|
const startMessageProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
|
||||||
|
if (messageProcessorRunning) return;
|
||||||
|
messageProcessorRunning = true;
|
||||||
|
|
||||||
|
const processLoop = async () => {
|
||||||
|
while (!isAborted) {
|
||||||
|
if (messageQueue.length === 0) {
|
||||||
|
// 队列为空,等待一小段时间
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = messageQueue.shift()!;
|
||||||
|
try {
|
||||||
|
await handleMessageFn(msg);
|
||||||
|
messagesProcessed++;
|
||||||
|
} catch (err) {
|
||||||
|
// 捕获处理异常,防止影响队列循环
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Message processor error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageProcessorRunning = false;
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Message processor stopped`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 异步启动,不阻塞调用者
|
||||||
|
processLoop().catch(err => {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Message processor crashed: ${err}`);
|
||||||
|
messageProcessorRunning = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Message processor started`);
|
||||||
|
};
|
||||||
|
|
||||||
abortSignal.addEventListener("abort", () => {
|
abortSignal.addEventListener("abort", () => {
|
||||||
isAborted = true;
|
isAborted = true;
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
@@ -144,6 +302,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
}
|
}
|
||||||
cleanup();
|
cleanup();
|
||||||
|
// P1-1: 停止后台 Token 刷新
|
||||||
|
stopBackgroundTokenRefresh();
|
||||||
|
// P1-3: 保存已知用户数据
|
||||||
|
flushKnownUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@@ -232,16 +394,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对于 C2C 消息,先发送输入状态提示用户机器人正在输入
|
// 流式消息开关(默认启用,仅 c2c 支持)
|
||||||
if (event.type === "c2c") {
|
const streamEnabled = account.streamEnabled !== false;
|
||||||
try {
|
log?.debug?.(`[qqbot:${account.accountId}] Stream enabled: ${streamEnabled}`);
|
||||||
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
||||||
await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
|
|
||||||
} catch (err) {
|
|
||||||
log?.error(`[qqbot:${account.accountId}] Failed to send input notify: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginRuntime.channel.activity.record({
|
pluginRuntime.channel.activity.record({
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
@@ -269,6 +424,51 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
// 组装消息体,添加系统提示词
|
// 组装消息体,添加系统提示词
|
||||||
let builtinPrompt = "";
|
let builtinPrompt = "";
|
||||||
|
|
||||||
|
// ============ 用户标识信息(用于定时提醒和主动消息) ============
|
||||||
|
const isGroupChat = event.type === "group";
|
||||||
|
const targetAddress = isGroupChat ? `group:${event.groupOpenid}` : event.senderId;
|
||||||
|
|
||||||
|
builtinPrompt += `
|
||||||
|
【当前用户信息】
|
||||||
|
- 用户 openid: ${event.senderId}
|
||||||
|
- 用户昵称: ${event.senderName || "未知"}
|
||||||
|
- 消息类型: ${isGroupChat ? "群聊" : "私聊"}
|
||||||
|
- 当前消息 message_id: ${event.messageId}${isGroupChat ? `
|
||||||
|
- 群组 group_openid: ${event.groupOpenid}` : ""}
|
||||||
|
|
||||||
|
【定时提醒能力】
|
||||||
|
你可以帮助用户设置定时提醒。使用 openclaw cron 命令:
|
||||||
|
|
||||||
|
示例:5分钟后提醒用户喝水
|
||||||
|
\`\`\`bash
|
||||||
|
openclaw cron add \\
|
||||||
|
--name "提醒喝水-${event.senderName || "用户"}" \\
|
||||||
|
--at "5m" \\
|
||||||
|
--session isolated \\
|
||||||
|
--message "💧 该喝水啦!" \\
|
||||||
|
--deliver \\
|
||||||
|
--channel qqbot \\
|
||||||
|
--to "${targetAddress}" \\
|
||||||
|
--reply-to "${event.messageId}" \\
|
||||||
|
--delete-after-run
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
关键参数说明:
|
||||||
|
- \`--to\`: 目标地址(当前用户: ${targetAddress})
|
||||||
|
- \`--reply-to\`: 回复消息ID(当前消息: ${event.messageId},使提醒能引用原消息)
|
||||||
|
- \`--at\`: 一次性定时任务的触发时间
|
||||||
|
- 相对时间格式:数字+单位,如 \`5m\`(5分钟)、\`1h\`(1小时)、\`2d\`(2天)【注意:不要加 + 号】
|
||||||
|
- 绝对时间格式:ISO 8601 带时区,如 \`2026-02-01T14:00:00+08:00\`
|
||||||
|
- \`--cron\`: 周期性任务(如 \`0 8 * * *\` 每天早上8点)
|
||||||
|
- \`--tz "Asia/Shanghai"\`: 周期任务务必设置时区
|
||||||
|
- \`--delete-after-run\`: 一次性任务必须添加此参数
|
||||||
|
- \`--message\`: 消息内容(必填,不能为空!对应 QQ API 的 markdown.content 字段)
|
||||||
|
|
||||||
|
⚠️ 重要注意事项:
|
||||||
|
1. --at 参数格式:相对时间用 \`5m\`、\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式
|
||||||
|
2. 定时提醒消息不支持流式发送,命令中不要添加 --stream 参数
|
||||||
|
3. --message 参数必须有实际内容,不能为空字符串`;
|
||||||
|
|
||||||
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
||||||
if (imageServerBaseUrl) {
|
if (imageServerBaseUrl) {
|
||||||
builtinPrompt += `
|
builtinPrompt += `
|
||||||
@@ -400,7 +600,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
|
|
||||||
// 追踪是否有响应
|
// 追踪是否有响应
|
||||||
let hasResponse = false;
|
let hasResponse = false;
|
||||||
const responseTimeout = 30000; // 30秒超时
|
const responseTimeout = 60000; // 60秒超时(1分钟)
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||||
@@ -417,19 +617,25 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
: event.type === "group" ? `group:${event.groupOpenid}`
|
: event.type === "group" ? `group:${event.groupOpenid}`
|
||||||
: `channel:${event.channelId}`;
|
: `channel:${event.channelId}`;
|
||||||
|
|
||||||
// 判断是否支持流式(仅 c2c 支持,群聊不支持流式)
|
// 判断是否支持流式(仅 c2c 支持,群聊不支持流式,且需要开关启用)
|
||||||
const supportsStream = event.type === "c2c";
|
const supportsStream = event.type === "c2c" && streamEnabled;
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Stream support: ${supportsStream} (type=${event.type}, enabled=${streamEnabled})`);
|
||||||
|
|
||||||
// 创建流式发送器
|
// 创建流式发送器
|
||||||
const streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null;
|
const streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null;
|
||||||
let streamBuffer = ""; // 累积的全部文本(用于记录完整内容)
|
let streamBuffer = ""; // 累积的全部文本(用于记录完整内容)
|
||||||
let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量)
|
let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量)
|
||||||
|
let lastSentText = ""; // 上次发送时的完整文本(用于检测新段落)
|
||||||
|
let currentSegmentStart = 0; // 当前段落在 streamBuffer 中的起始位置
|
||||||
let lastStreamSendTime = 0; // 上次流式发送时间
|
let lastStreamSendTime = 0; // 上次流式发送时间
|
||||||
let streamStarted = false; // 是否已开始流式发送
|
let streamStarted = false; // 是否已开始流式发送
|
||||||
let streamEnded = false; // 流式是否已结束
|
let streamEnded = false; // 流式是否已结束
|
||||||
|
let streamStartTime = 0; // 流式消息开始时间(用于超时检查)
|
||||||
let sendingLock = false; // 发送锁,防止并发发送
|
let sendingLock = false; // 发送锁,防止并发发送
|
||||||
let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累)
|
let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累)
|
||||||
let keepaliveTimer: ReturnType<typeof setTimeout> | null = null; // 心跳定时器
|
let keepaliveTimer: ReturnType<typeof setTimeout> | null = null; // 心跳定时器
|
||||||
|
let keepaliveCountSinceLastChunk = 0; // 自上次分片以来发送的状态保持消息数量
|
||||||
|
let lastChunkSendTime = 0; // 上次分片发送时间(用于判断是否需要发送状态保持)
|
||||||
|
|
||||||
// 清理心跳定时器
|
// 清理心跳定时器
|
||||||
const clearKeepalive = () => {
|
const clearKeepalive = () => {
|
||||||
@@ -440,26 +646,78 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 重置心跳定时器(每次发送后调用)
|
// 重置心跳定时器(每次发送后调用)
|
||||||
const resetKeepalive = () => {
|
// isContentChunk: 是否为内容分片(非状态保持消息)
|
||||||
|
const resetKeepalive = (isContentChunk: boolean = false) => {
|
||||||
clearKeepalive();
|
clearKeepalive();
|
||||||
|
|
||||||
|
// 如果是内容分片,重置状态保持计数器和时间
|
||||||
|
if (isContentChunk) {
|
||||||
|
keepaliveCountSinceLastChunk = 0;
|
||||||
|
lastChunkSendTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
if (streamSender && streamStarted && !streamEnded) {
|
if (streamSender && streamStarted && !streamEnded) {
|
||||||
|
// 计算下次状态保持消息的延迟时间
|
||||||
|
// - 首次:3s(STREAM_KEEPALIVE_FIRST_DELAY)
|
||||||
|
// - 后续:10s(STREAM_KEEPALIVE_GAP)
|
||||||
|
const delay = keepaliveCountSinceLastChunk === 0
|
||||||
|
? STREAM_KEEPALIVE_FIRST_DELAY
|
||||||
|
: STREAM_KEEPALIVE_GAP;
|
||||||
|
|
||||||
keepaliveTimer = setTimeout(async () => {
|
keepaliveTimer = setTimeout(async () => {
|
||||||
// 10 秒内没有新消息,发送空分片保持连接
|
// 检查流式消息是否超时(超过 3 分钟自动结束)
|
||||||
|
const elapsed = Date.now() - streamStartTime;
|
||||||
|
if (elapsed >= STREAM_MAX_DURATION) {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Stream timeout after ${Math.round(elapsed / 1000)}s, auto ending stream`);
|
||||||
|
if (!streamEnded && !sendingLock) {
|
||||||
|
sendingLock = true;
|
||||||
|
try {
|
||||||
|
// 发送结束标记
|
||||||
|
await streamSender!.send("", true);
|
||||||
|
streamEnded = true;
|
||||||
|
clearKeepalive();
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Stream auto-end failed: ${err}`);
|
||||||
|
} finally {
|
||||||
|
sendingLock = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return; // 超时后不再继续心跳
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已达到每2个分片之间的最大状态保持消息数量
|
||||||
|
if (keepaliveCountSinceLastChunk >= STREAM_KEEPALIVE_MAX_PER_CHUNK) {
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] Max keepalive reached (${keepaliveCountSinceLastChunk}/${STREAM_KEEPALIVE_MAX_PER_CHUNK}), waiting for next content chunk`);
|
||||||
|
// 不再发送状态保持,但继续监控超时
|
||||||
|
resetKeepalive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查距上次分片是否超过 3s
|
||||||
|
const timeSinceLastChunk = Date.now() - lastChunkSendTime;
|
||||||
|
if (timeSinceLastChunk < STREAM_KEEPALIVE_FIRST_DELAY) {
|
||||||
|
// 还未到发送状态保持的时机,继续等待
|
||||||
|
resetKeepalive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送状态保持消息
|
||||||
if (!streamEnded && !sendingLock) {
|
if (!streamEnded && !sendingLock) {
|
||||||
log?.info(`[qqbot:${account.accountId}] Sending keepalive empty chunk`);
|
log?.info(`[qqbot:${account.accountId}] Sending keepalive #${keepaliveCountSinceLastChunk + 1} (elapsed: ${Math.round(elapsed / 1000)}s, since chunk: ${Math.round(timeSinceLastChunk / 1000)}s)`);
|
||||||
sendingLock = true;
|
sendingLock = true;
|
||||||
try {
|
try {
|
||||||
// 发送空内容
|
// 发送空内容
|
||||||
await streamSender!.send("", false);
|
await streamSender!.send("", false);
|
||||||
lastStreamSendTime = Date.now();
|
lastStreamSendTime = Date.now();
|
||||||
resetKeepalive(); // 继续下一个心跳
|
keepaliveCountSinceLastChunk++;
|
||||||
|
resetKeepalive(false); // 继续下一个状态保持(非内容分片)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`);
|
log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
sendingLock = false;
|
sendingLock = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, STREAM_KEEPALIVE_INTERVAL);
|
}, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -486,8 +744,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
streamEnded = true;
|
streamEnded = true;
|
||||||
clearKeepalive();
|
clearKeepalive();
|
||||||
} else {
|
} else {
|
||||||
// 发送成功后重置心跳
|
// 发送成功后重置心跳,如果是有内容的分片则重置计数器
|
||||||
resetKeepalive();
|
const isContentChunk = text.length > 0;
|
||||||
|
resetKeepalive(isContentChunk);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -505,11 +764,17 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
// 发送当前增量
|
// 发送当前增量
|
||||||
if (fullText.length > lastSentLength) {
|
if (fullText.length > lastSentLength) {
|
||||||
const increment = fullText.slice(lastSentLength);
|
const increment = fullText.slice(lastSentLength);
|
||||||
|
// 首次发送前,先设置流式状态和开始时间
|
||||||
|
if (!streamStarted) {
|
||||||
|
streamStarted = true;
|
||||||
|
streamStartTime = Date.now();
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Stream started, max duration: ${STREAM_MAX_DURATION / 1000}s`);
|
||||||
|
}
|
||||||
const success = await sendStreamChunk(increment, forceEnd);
|
const success = await sendStreamChunk(increment, forceEnd);
|
||||||
if (success) {
|
if (success) {
|
||||||
lastSentLength = fullText.length;
|
lastSentLength = fullText.length;
|
||||||
|
lastSentText = fullText; // 记录完整发送文本,用于检测新段落
|
||||||
lastStreamSendTime = Date.now();
|
lastStreamSendTime = Date.now();
|
||||||
streamStarted = true;
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`);
|
log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`);
|
||||||
}
|
}
|
||||||
} else if (forceEnd && !streamEnded) {
|
} else if (forceEnd && !streamEnded) {
|
||||||
@@ -530,6 +795,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// onPartialReply 回调 - 实时接收 AI 生成的文本(payload.text 是累积的全文)
|
// onPartialReply 回调 - 实时接收 AI 生成的文本(payload.text 是累积的全文)
|
||||||
|
// 注意:agent 在一次对话中可能产生多个回复段落(如思考、工具调用后继续回复)
|
||||||
|
// 每个新段落的 text 会从头开始累积,需要检测并处理
|
||||||
const handlePartialReply = async (payload: { text?: string }) => {
|
const handlePartialReply = async (payload: { text?: string }) => {
|
||||||
if (!streamSender || streamEnded) {
|
if (!streamSender || streamEnded) {
|
||||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`);
|
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`);
|
||||||
@@ -542,11 +809,39 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 始终更新累积缓冲区(即使不发送,也要记录最新内容)
|
|
||||||
streamBuffer = fullText;
|
|
||||||
hasResponse = true;
|
hasResponse = true;
|
||||||
|
|
||||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}`);
|
// 检测是否是新段落:
|
||||||
|
// 1. lastSentText 不为空(说明已经发送过内容)
|
||||||
|
// 2. 当前文本不是以 lastSentText 开头(说明不是同一段落的增量)
|
||||||
|
// 3. 当前文本长度小于 lastSentLength(说明文本被重置了)
|
||||||
|
const isNewSegment = lastSentText.length > 0 &&
|
||||||
|
(fullText.length < lastSentLength || !fullText.startsWith(lastSentText.slice(0, Math.min(10, lastSentText.length))));
|
||||||
|
|
||||||
|
if (isNewSegment) {
|
||||||
|
// 新段落开始,将之前的内容追加到 streamBuffer,并重置发送位置
|
||||||
|
log?.info(`[qqbot:${account.accountId}] New segment detected! lastSentLength=${lastSentLength}, newTextLength=${fullText.length}, lastSentText="${lastSentText.slice(0, 20)}...", newText="${fullText.slice(0, 20)}..."`);
|
||||||
|
|
||||||
|
// 记录当前段落在 streamBuffer 中的起始位置
|
||||||
|
currentSegmentStart = streamBuffer.length;
|
||||||
|
|
||||||
|
// 追加换行分隔符(如果前面有内容且不以换行结尾)
|
||||||
|
if (streamBuffer.length > 0 && !streamBuffer.endsWith("\n")) {
|
||||||
|
streamBuffer += "\n\n";
|
||||||
|
currentSegmentStart = streamBuffer.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置发送位置,从新段落开始发送
|
||||||
|
lastSentLength = 0;
|
||||||
|
lastSentText = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前段落内容到 streamBuffer
|
||||||
|
// streamBuffer = 之前的段落内容 + 当前段落的完整内容
|
||||||
|
const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart);
|
||||||
|
streamBuffer = beforeCurrentSegment + fullText;
|
||||||
|
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}, streamBuffer.length=${streamBuffer.length}, isNewSegment=${isNewSegment}`);
|
||||||
|
|
||||||
// 如果没有新内容,跳过
|
// 如果没有新内容,跳过
|
||||||
if (fullText.length <= lastSentLength) return;
|
if (fullText.length <= lastSentLength) return;
|
||||||
@@ -578,9 +873,15 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
|
|
||||||
let replyText = payload.text ?? "";
|
let replyText = payload.text ?? "";
|
||||||
|
|
||||||
// 更新 streamBuffer,确保最终内容不会丢失
|
// 更新当前段落内容到 streamBuffer
|
||||||
if (replyText.length > streamBuffer.length) {
|
// deliver 中的 replyText 是当前段落的完整文本
|
||||||
streamBuffer = replyText;
|
if (replyText.length > 0) {
|
||||||
|
const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart);
|
||||||
|
const newStreamBuffer = beforeCurrentSegment + replyText;
|
||||||
|
if (newStreamBuffer.length > streamBuffer.length) {
|
||||||
|
streamBuffer = newStreamBuffer;
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] deliver: updated streamBuffer, replyText=${replyText.length}, total=${streamBuffer.length}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收集所有图片路径
|
// 收集所有图片路径
|
||||||
@@ -796,18 +1097,18 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 确保所有待发送内容都发送出去
|
// 确保所有待发送内容都发送出去
|
||||||
// 优先使用 pendingFullText,因为它可能包含最新的完整文本
|
// 当前段落的最新完整文本
|
||||||
const finalFullText = pendingFullText && pendingFullText.length > streamBuffer.length
|
const currentSegmentText = pendingFullText && pendingFullText.length > (streamBuffer.length - currentSegmentStart)
|
||||||
? pendingFullText
|
? pendingFullText
|
||||||
: streamBuffer;
|
: streamBuffer.slice(currentSegmentStart);
|
||||||
|
|
||||||
// 计算剩余未发送的增量内容
|
// 计算当前段落剩余未发送的增量内容
|
||||||
const remainingIncrement = finalFullText.slice(lastSentLength);
|
const remainingIncrement = currentSegmentText.slice(lastSentLength);
|
||||||
if (remainingIncrement || streamStarted) {
|
if (remainingIncrement || streamStarted) {
|
||||||
// 有剩余内容或者已开始流式,都需要发送结束标记
|
// 有剩余内容或者已开始流式,都需要发送结束标记
|
||||||
await streamSender.end(remainingIncrement);
|
await streamSender.end(remainingIncrement);
|
||||||
streamEnded = true;
|
streamEnded = true;
|
||||||
log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total: ${finalFullText.length} chars, chunks: ${streamSender.getContext().index}`);
|
log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total streamBuffer: ${streamBuffer.length} chars, chunks: ${streamSender.getContext().index}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -832,6 +1133,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
isConnecting = false; // 连接完成,释放锁
|
isConnecting = false; // 连接完成,释放锁
|
||||||
reconnectAttempts = 0; // 连接成功,重置重试计数
|
reconnectAttempts = 0; // 连接成功,重置重试计数
|
||||||
lastConnectTime = Date.now(); // 记录连接时间
|
lastConnectTime = Date.now(); // 记录连接时间
|
||||||
|
// 启动消息处理器(异步处理,防止阻塞心跳)
|
||||||
|
startMessageProcessor(handleMessage);
|
||||||
|
// P1-1: 启动后台 Token 刷新
|
||||||
|
startBackgroundTokenRefresh(account.appId, account.clientSecret, {
|
||||||
|
log: log as { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("message", async (data) => {
|
ws.on("message", async (data) => {
|
||||||
@@ -840,7 +1147,20 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
const payload = JSON.parse(rawData) as WSPayload;
|
const payload = JSON.parse(rawData) as WSPayload;
|
||||||
const { op, d, s, t } = payload;
|
const { op, d, s, t } = payload;
|
||||||
|
|
||||||
if (s) lastSeq = s;
|
if (s) {
|
||||||
|
lastSeq = s;
|
||||||
|
// P1-2: 更新持久化存储中的 lastSeq(节流保存)
|
||||||
|
if (sessionId) {
|
||||||
|
saveSession({
|
||||||
|
sessionId,
|
||||||
|
lastSeq,
|
||||||
|
lastConnectedAt: lastConnectTime,
|
||||||
|
intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
|
||||||
|
accountId: account.accountId,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
|
log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
|
||||||
|
|
||||||
@@ -894,12 +1214,39 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
lastSuccessfulIntentLevel = intentLevelIndex;
|
lastSuccessfulIntentLevel = intentLevelIndex;
|
||||||
const successLevel = INTENT_LEVELS[intentLevelIndex];
|
const successLevel = INTENT_LEVELS[intentLevelIndex];
|
||||||
log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
|
log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
|
||||||
|
// P1-2: 保存新的 Session 状态
|
||||||
|
saveSession({
|
||||||
|
sessionId,
|
||||||
|
lastSeq,
|
||||||
|
lastConnectedAt: Date.now(),
|
||||||
|
intentLevelIndex,
|
||||||
|
accountId: account.accountId,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
});
|
||||||
onReady?.(d);
|
onReady?.(d);
|
||||||
} else if (t === "RESUMED") {
|
} else if (t === "RESUMED") {
|
||||||
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
||||||
|
// P1-2: 更新 Session 连接时间
|
||||||
|
if (sessionId) {
|
||||||
|
saveSession({
|
||||||
|
sessionId,
|
||||||
|
lastSeq,
|
||||||
|
lastConnectedAt: Date.now(),
|
||||||
|
intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
|
||||||
|
accountId: account.accountId,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (t === "C2C_MESSAGE_CREATE") {
|
} else if (t === "C2C_MESSAGE_CREATE") {
|
||||||
const event = d as C2CMessageEvent;
|
const event = d as C2CMessageEvent;
|
||||||
await handleMessage({
|
// P1-3: 记录已知用户
|
||||||
|
recordKnownUser({
|
||||||
|
openid: event.author.user_openid,
|
||||||
|
type: "c2c",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
// 使用消息队列异步处理,防止阻塞心跳
|
||||||
|
enqueueMessage({
|
||||||
type: "c2c",
|
type: "c2c",
|
||||||
senderId: event.author.user_openid,
|
senderId: event.author.user_openid,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
@@ -909,7 +1256,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
});
|
});
|
||||||
} else if (t === "AT_MESSAGE_CREATE") {
|
} else if (t === "AT_MESSAGE_CREATE") {
|
||||||
const event = d as GuildMessageEvent;
|
const event = d as GuildMessageEvent;
|
||||||
await handleMessage({
|
// P1-3: 记录已知用户(频道用户)
|
||||||
|
recordKnownUser({
|
||||||
|
openid: event.author.id,
|
||||||
|
type: "c2c", // 频道用户按 c2c 类型存储
|
||||||
|
nickname: event.author.username,
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
enqueueMessage({
|
||||||
type: "guild",
|
type: "guild",
|
||||||
senderId: event.author.id,
|
senderId: event.author.id,
|
||||||
senderName: event.author.username,
|
senderName: event.author.username,
|
||||||
@@ -922,7 +1276,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
});
|
});
|
||||||
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
||||||
const event = d as GuildMessageEvent;
|
const event = d as GuildMessageEvent;
|
||||||
await handleMessage({
|
// P1-3: 记录已知用户(频道私信用户)
|
||||||
|
recordKnownUser({
|
||||||
|
openid: event.author.id,
|
||||||
|
type: "c2c",
|
||||||
|
nickname: event.author.username,
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
enqueueMessage({
|
||||||
type: "dm",
|
type: "dm",
|
||||||
senderId: event.author.id,
|
senderId: event.author.id,
|
||||||
senderName: event.author.username,
|
senderName: event.author.username,
|
||||||
@@ -934,7 +1295,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
});
|
});
|
||||||
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
||||||
const event = d as GroupMessageEvent;
|
const event = d as GroupMessageEvent;
|
||||||
await handleMessage({
|
// P1-3: 记录已知用户(群组用户)
|
||||||
|
recordKnownUser({
|
||||||
|
openid: event.author.member_openid,
|
||||||
|
type: "group",
|
||||||
|
groupOpenid: event.group_openid,
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
enqueueMessage({
|
||||||
type: "group",
|
type: "group",
|
||||||
senderId: event.author.member_openid,
|
senderId: event.author.member_openid,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
@@ -964,6 +1332,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
if (!canResume) {
|
if (!canResume) {
|
||||||
sessionId = null;
|
sessionId = null;
|
||||||
lastSeq = null;
|
lastSeq = null;
|
||||||
|
// P1-2: 清除持久化的 Session
|
||||||
|
clearSession(account.accountId);
|
||||||
|
|
||||||
// 尝试降级到下一个权限级别
|
// 尝试降级到下一个权限级别
|
||||||
if (intentLevelIndex < INTENT_LEVELS.length - 1) {
|
if (intentLevelIndex < INTENT_LEVELS.length - 1) {
|
||||||
|
|||||||
358
src/known-users.ts
Normal file
358
src/known-users.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* 已知用户存储
|
||||||
|
* 记录与机器人交互过的所有用户
|
||||||
|
* 支持主动消息和批量通知功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// 已知用户信息接口
|
||||||
|
export interface KnownUser {
|
||||||
|
/** 用户 openid(唯一标识) */
|
||||||
|
openid: string;
|
||||||
|
/** 消息类型:私聊用户 / 群组 */
|
||||||
|
type: "c2c" | "group";
|
||||||
|
/** 用户昵称(如有) */
|
||||||
|
nickname?: string;
|
||||||
|
/** 群组 openid(如果是群消息) */
|
||||||
|
groupOpenid?: string;
|
||||||
|
/** 关联的机器人账户 ID */
|
||||||
|
accountId: string;
|
||||||
|
/** 首次交互时间戳 */
|
||||||
|
firstSeenAt: number;
|
||||||
|
/** 最后交互时间戳 */
|
||||||
|
lastSeenAt: number;
|
||||||
|
/** 交互次数 */
|
||||||
|
interactionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储文件路径
|
||||||
|
const KNOWN_USERS_DIR = path.join(
|
||||||
|
process.env.HOME || "/tmp",
|
||||||
|
"clawd",
|
||||||
|
"qqbot-data"
|
||||||
|
);
|
||||||
|
|
||||||
|
const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json");
|
||||||
|
|
||||||
|
// 内存缓存
|
||||||
|
let usersCache: Map<string, KnownUser> | null = null;
|
||||||
|
|
||||||
|
// 写入节流配置
|
||||||
|
const SAVE_THROTTLE_MS = 5000; // 5秒写入一次
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let isDirty = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保目录存在
|
||||||
|
*/
|
||||||
|
function ensureDir(): void {
|
||||||
|
if (!fs.existsSync(KNOWN_USERS_DIR)) {
|
||||||
|
fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件加载用户数据到缓存
|
||||||
|
*/
|
||||||
|
function loadUsersFromFile(): Map<string, KnownUser> {
|
||||||
|
if (usersCache !== null) {
|
||||||
|
return usersCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
usersCache = new Map();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
||||||
|
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
||||||
|
const users = JSON.parse(data) as KnownUser[];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
// 使用复合键:accountId + type + openid(群组还要加 groupOpenid)
|
||||||
|
const key = makeUserKey(user);
|
||||||
|
usersCache.set(key, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[known-users] Loaded ${usersCache.size} users from file`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[known-users] Failed to load users: ${err}`);
|
||||||
|
usersCache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
return usersCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存用户数据到文件(节流版本)
|
||||||
|
*/
|
||||||
|
function saveUsersToFile(): void {
|
||||||
|
if (!isDirty) return;
|
||||||
|
|
||||||
|
if (saveTimer) {
|
||||||
|
return; // 已有定时器在等待
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
saveTimer = null;
|
||||||
|
doSaveUsersToFile();
|
||||||
|
}, SAVE_THROTTLE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际执行保存
|
||||||
|
*/
|
||||||
|
function doSaveUsersToFile(): void {
|
||||||
|
if (!usersCache || !isDirty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureDir();
|
||||||
|
const users = Array.from(usersCache.values());
|
||||||
|
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
|
||||||
|
isDirty = false;
|
||||||
|
console.log(`[known-users] Saved ${users.length} users to file`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[known-users] Failed to save users: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制立即保存(用于进程退出前)
|
||||||
|
*/
|
||||||
|
export function flushKnownUsers(): void {
|
||||||
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = null;
|
||||||
|
}
|
||||||
|
doSaveUsersToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户唯一键
|
||||||
|
*/
|
||||||
|
function makeUserKey(user: Partial<KnownUser>): string {
|
||||||
|
const base = `${user.accountId}:${user.type}:${user.openid}`;
|
||||||
|
if (user.type === "group" && user.groupOpenid) {
|
||||||
|
return `${base}:${user.groupOpenid}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录已知用户(收到消息时调用)
|
||||||
|
* @param user 用户信息(部分字段)
|
||||||
|
*/
|
||||||
|
export function recordKnownUser(user: {
|
||||||
|
openid: string;
|
||||||
|
type: "c2c" | "group";
|
||||||
|
nickname?: string;
|
||||||
|
groupOpenid?: string;
|
||||||
|
accountId: string;
|
||||||
|
}): void {
|
||||||
|
const cache = loadUsersFromFile();
|
||||||
|
const key = makeUserKey(user);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const existing = cache.get(key);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// 更新已存在的用户
|
||||||
|
existing.lastSeenAt = now;
|
||||||
|
existing.interactionCount++;
|
||||||
|
if (user.nickname && user.nickname !== existing.nickname) {
|
||||||
|
existing.nickname = user.nickname;
|
||||||
|
}
|
||||||
|
console.log(`[known-users] Updated user ${user.openid}, interactions: ${existing.interactionCount}`);
|
||||||
|
} else {
|
||||||
|
// 新用户
|
||||||
|
const newUser: KnownUser = {
|
||||||
|
openid: user.openid,
|
||||||
|
type: user.type,
|
||||||
|
nickname: user.nickname,
|
||||||
|
groupOpenid: user.groupOpenid,
|
||||||
|
accountId: user.accountId,
|
||||||
|
firstSeenAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
interactionCount: 1,
|
||||||
|
};
|
||||||
|
cache.set(key, newUser);
|
||||||
|
console.log(`[known-users] New user recorded: ${user.openid} (${user.type})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDirty = true;
|
||||||
|
saveUsersToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个用户信息
|
||||||
|
* @param accountId 机器人账户 ID
|
||||||
|
* @param openid 用户 openid
|
||||||
|
* @param type 消息类型
|
||||||
|
* @param groupOpenid 群组 openid(可选)
|
||||||
|
*/
|
||||||
|
export function getKnownUser(
|
||||||
|
accountId: string,
|
||||||
|
openid: string,
|
||||||
|
type: "c2c" | "group" = "c2c",
|
||||||
|
groupOpenid?: string
|
||||||
|
): KnownUser | undefined {
|
||||||
|
const cache = loadUsersFromFile();
|
||||||
|
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
||||||
|
return cache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出所有已知用户
|
||||||
|
* @param options 筛选选项
|
||||||
|
*/
|
||||||
|
export function listKnownUsers(options?: {
|
||||||
|
/** 筛选特定机器人账户的用户 */
|
||||||
|
accountId?: string;
|
||||||
|
/** 筛选消息类型 */
|
||||||
|
type?: "c2c" | "group";
|
||||||
|
/** 最近活跃时间(毫秒,如 86400000 表示最近 24 小时) */
|
||||||
|
activeWithin?: number;
|
||||||
|
/** 返回数量限制 */
|
||||||
|
limit?: number;
|
||||||
|
/** 排序方式 */
|
||||||
|
sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount";
|
||||||
|
/** 排序方向 */
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
}): KnownUser[] {
|
||||||
|
const cache = loadUsersFromFile();
|
||||||
|
let users = Array.from(cache.values());
|
||||||
|
|
||||||
|
// 筛选
|
||||||
|
if (options?.accountId) {
|
||||||
|
users = users.filter(u => u.accountId === options.accountId);
|
||||||
|
}
|
||||||
|
if (options?.type) {
|
||||||
|
users = users.filter(u => u.type === options.type);
|
||||||
|
}
|
||||||
|
if (options?.activeWithin) {
|
||||||
|
const cutoff = Date.now() - options.activeWithin;
|
||||||
|
users = users.filter(u => u.lastSeenAt >= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
const sortBy = options?.sortBy ?? "lastSeenAt";
|
||||||
|
const sortOrder = options?.sortOrder ?? "desc";
|
||||||
|
users.sort((a, b) => {
|
||||||
|
const aVal = a[sortBy] ?? 0;
|
||||||
|
const bVal = b[sortBy] ?? 0;
|
||||||
|
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 限制数量
|
||||||
|
if (options?.limit && options.limit > 0) {
|
||||||
|
users = users.slice(0, options.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户统计信息
|
||||||
|
* @param accountId 机器人账户 ID(可选,不传则返回所有账户的统计)
|
||||||
|
*/
|
||||||
|
export function getKnownUsersStats(accountId?: string): {
|
||||||
|
totalUsers: number;
|
||||||
|
c2cUsers: number;
|
||||||
|
groupUsers: number;
|
||||||
|
activeIn24h: number;
|
||||||
|
activeIn7d: number;
|
||||||
|
} {
|
||||||
|
let users = listKnownUsers({ accountId });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const day = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: users.length,
|
||||||
|
c2cUsers: users.filter(u => u.type === "c2c").length,
|
||||||
|
groupUsers: users.filter(u => u.type === "group").length,
|
||||||
|
activeIn24h: users.filter(u => now - u.lastSeenAt < day).length,
|
||||||
|
activeIn7d: users.filter(u => now - u.lastSeenAt < 7 * day).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户记录
|
||||||
|
* @param accountId 机器人账户 ID
|
||||||
|
* @param openid 用户 openid
|
||||||
|
* @param type 消息类型
|
||||||
|
* @param groupOpenid 群组 openid(可选)
|
||||||
|
*/
|
||||||
|
export function removeKnownUser(
|
||||||
|
accountId: string,
|
||||||
|
openid: string,
|
||||||
|
type: "c2c" | "group" = "c2c",
|
||||||
|
groupOpenid?: string
|
||||||
|
): boolean {
|
||||||
|
const cache = loadUsersFromFile();
|
||||||
|
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
||||||
|
|
||||||
|
if (cache.has(key)) {
|
||||||
|
cache.delete(key);
|
||||||
|
isDirty = true;
|
||||||
|
saveUsersToFile();
|
||||||
|
console.log(`[known-users] Removed user ${openid}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有用户记录
|
||||||
|
* @param accountId 机器人账户 ID(可选,不传则清除所有)
|
||||||
|
*/
|
||||||
|
export function clearKnownUsers(accountId?: string): number {
|
||||||
|
const cache = loadUsersFromFile();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (accountId) {
|
||||||
|
// 只清除指定账户的用户
|
||||||
|
for (const [key, user] of cache.entries()) {
|
||||||
|
if (user.accountId === accountId) {
|
||||||
|
cache.delete(key);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 清除所有
|
||||||
|
count = cache.size;
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
isDirty = true;
|
||||||
|
doSaveUsersToFile(); // 立即保存
|
||||||
|
console.log(`[known-users] Cleared ${count} users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的所有群组(某用户在哪些群里交互过)
|
||||||
|
* @param accountId 机器人账户 ID
|
||||||
|
* @param openid 用户 openid
|
||||||
|
*/
|
||||||
|
export function getUserGroups(accountId: string, openid: string): string[] {
|
||||||
|
const users = listKnownUsers({ accountId, type: "group" });
|
||||||
|
return users
|
||||||
|
.filter(u => u.openid === openid && u.groupOpenid)
|
||||||
|
.map(u => u.groupOpenid!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群组的所有成员
|
||||||
|
* @param accountId 机器人账户 ID
|
||||||
|
* @param groupOpenid 群组 openid
|
||||||
|
*/
|
||||||
|
export function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[] {
|
||||||
|
return listKnownUsers({ accountId, type: "group" })
|
||||||
|
.filter(u => u.groupOpenid === groupOpenid);
|
||||||
|
}
|
||||||
483
src/openclaw-plugin-sdk.d.ts
vendored
Normal file
483
src/openclaw-plugin-sdk.d.ts
vendored
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* OpenClaw Plugin SDK 类型声明
|
||||||
|
*
|
||||||
|
* 此文件为 openclaw/plugin-sdk 模块提供 TypeScript 类型声明
|
||||||
|
* 仅包含本项目实际使用的类型和函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare module "openclaw/plugin-sdk" {
|
||||||
|
// ============ 配置类型 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenClaw 主配置对象
|
||||||
|
*/
|
||||||
|
export interface OpenClawConfig {
|
||||||
|
/** 频道配置 */
|
||||||
|
channels?: {
|
||||||
|
qqbot?: unknown;
|
||||||
|
telegram?: unknown;
|
||||||
|
discord?: unknown;
|
||||||
|
slack?: unknown;
|
||||||
|
whatsapp?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
/** 其他配置字段 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 插件运行时 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel Activity 接口
|
||||||
|
*/
|
||||||
|
export interface ChannelActivity {
|
||||||
|
record?: (...args: unknown[]) => void;
|
||||||
|
recordActivity?: (key: string, data?: unknown) => void;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel Routing 接口
|
||||||
|
*/
|
||||||
|
export interface ChannelRouting {
|
||||||
|
resolveAgentRoute?: (...args: unknown[]) => unknown;
|
||||||
|
resolveSenderAndSession?: (options: unknown) => unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel Reply 接口
|
||||||
|
*/
|
||||||
|
export interface ChannelReply {
|
||||||
|
handleIncomingMessage?: (options: unknown) => Promise<unknown>;
|
||||||
|
formatInboundEnvelope?: (...args: unknown[]) => unknown;
|
||||||
|
finalizeInboundContext?: (...args: unknown[]) => unknown;
|
||||||
|
resolveEnvelopeFormatOptions?: (...args: unknown[]) => unknown;
|
||||||
|
handleAutoReply?: (...args: unknown[]) => Promise<unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel 接口(用于 PluginRuntime)
|
||||||
|
* 注意:这是一个宽松的类型定义,实际 SDK 中的类型更复杂
|
||||||
|
*/
|
||||||
|
export interface ChannelInterface {
|
||||||
|
recordInboundSession?: (options: unknown) => void;
|
||||||
|
handleIncomingMessage?: (options: unknown) => Promise<unknown>;
|
||||||
|
activity?: ChannelActivity;
|
||||||
|
routing?: ChannelRouting;
|
||||||
|
reply?: ChannelReply;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件运行时接口
|
||||||
|
* 注意:channel 属性设为 any 是因为 SDK 内部类型非常复杂,
|
||||||
|
* 且会随 SDK 版本变化。实际使用时 SDK 会提供正确的运行时类型。
|
||||||
|
*/
|
||||||
|
export interface PluginRuntime {
|
||||||
|
/** 获取当前配置 */
|
||||||
|
getConfig(): OpenClawConfig;
|
||||||
|
/** 更新配置 */
|
||||||
|
setConfig(config: OpenClawConfig): void;
|
||||||
|
/** 获取数据目录路径 */
|
||||||
|
getDataDir(): string;
|
||||||
|
/** Channel 接口 - 使用 any 类型以兼容 SDK 内部复杂类型 */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
channel?: any;
|
||||||
|
/** 日志函数 */
|
||||||
|
log: {
|
||||||
|
info: (message: string, ...args: unknown[]) => void;
|
||||||
|
warn: (message: string, ...args: unknown[]) => void;
|
||||||
|
error: (message: string, ...args: unknown[]) => void;
|
||||||
|
debug: (message: string, ...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
/** 其他运行时方法 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 插件 API ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenClaw 插件 API
|
||||||
|
*/
|
||||||
|
export interface OpenClawPluginApi {
|
||||||
|
/** 运行时实例 */
|
||||||
|
runtime: PluginRuntime;
|
||||||
|
/** 注册频道 */
|
||||||
|
registerChannel<TAccount = unknown>(options: { plugin: ChannelPlugin<TAccount> }): void;
|
||||||
|
/** 其他 API 方法 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 插件配置 Schema ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空的插件配置 Schema
|
||||||
|
*/
|
||||||
|
export function emptyPluginConfigSchema(): unknown;
|
||||||
|
|
||||||
|
// ============ 频道插件 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频道插件 Meta 信息
|
||||||
|
*/
|
||||||
|
export interface ChannelPluginMeta {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
selectionLabel?: string;
|
||||||
|
docsPath?: string;
|
||||||
|
blurb?: string;
|
||||||
|
order?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频道插件能力配置
|
||||||
|
*/
|
||||||
|
export interface ChannelPluginCapabilities {
|
||||||
|
chatTypes?: ("direct" | "group" | "channel")[];
|
||||||
|
media?: boolean;
|
||||||
|
reactions?: boolean;
|
||||||
|
threads?: boolean;
|
||||||
|
blockStreaming?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账户描述
|
||||||
|
*/
|
||||||
|
export interface AccountDescription {
|
||||||
|
accountId: string;
|
||||||
|
name?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
tokenSource?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频道插件配置接口(泛型)
|
||||||
|
*/
|
||||||
|
export interface ChannelPluginConfig<TAccount> {
|
||||||
|
listAccountIds: (cfg: OpenClawConfig) => string[];
|
||||||
|
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount;
|
||||||
|
defaultAccountId: (cfg: OpenClawConfig) => string;
|
||||||
|
setAccountEnabled?: (ctx: { cfg: OpenClawConfig; accountId: string; enabled: boolean }) => OpenClawConfig;
|
||||||
|
deleteAccount?: (ctx: { cfg: OpenClawConfig; accountId: string }) => OpenClawConfig;
|
||||||
|
isConfigured?: (account: TAccount | undefined) => boolean;
|
||||||
|
describeAccount?: (account: TAccount | undefined) => AccountDescription;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup 输入参数(扩展类型以支持 QQBot 特定字段)
|
||||||
|
*/
|
||||||
|
export interface SetupInput {
|
||||||
|
token?: string;
|
||||||
|
tokenFile?: string;
|
||||||
|
useEnv?: boolean;
|
||||||
|
name?: string;
|
||||||
|
imageServerBaseUrl?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频道插件 Setup 接口
|
||||||
|
*/
|
||||||
|
export interface ChannelPluginSetup {
|
||||||
|
resolveAccountId?: (ctx: { accountId?: string }) => string;
|
||||||
|
applyAccountName?: (ctx: { cfg: OpenClawConfig; accountId: string; name: string }) => OpenClawConfig;
|
||||||
|
validateInput?: (ctx: { input: SetupInput }) => string | null;
|
||||||
|
applyConfig?: (ctx: { cfg: OpenClawConfig; accountId: string; input: SetupInput }) => OpenClawConfig;
|
||||||
|
applyAccountConfig?: (ctx: { cfg: OpenClawConfig; accountId: string; input: SetupInput }) => OpenClawConfig;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息目标解析结果
|
||||||
|
*/
|
||||||
|
export interface NormalizeTargetResult {
|
||||||
|
ok: boolean;
|
||||||
|
to?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标解析器
|
||||||
|
*/
|
||||||
|
export interface TargetResolver {
|
||||||
|
looksLikeId?: (id: string) => boolean;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频道插件 Messaging 接口
|
||||||
|
*/
|
||||||
|
export interface ChannelPluginMessaging {
|
||||||
|
normalizeTarget?: (target: string) => NormalizeTargetResult;
|
||||||
|
targetResolver?: TargetResolver;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送文本结果
|
||||||
|
*/
|
||||||
|
export interface SendTextResult {
|
||||||
|
channel: string;
|
||||||
|
messageId?: string;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送文本上下文
|
||||||
|
*/
|
||||||
|
export interface SendTextContext {
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
accountId?: string;
|
||||||
|
replyToId?: string;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送媒体上下文
|
||||||
|
*/
|
||||||
|
export interface SendMediaContext {
|
||||||
|
to: string;
|
||||||
|
text?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
accountId?: string;
|
||||||
|
replyToId?: string;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频道插件 Outbound 接口
|
||||||
|
*/
|
||||||
|
export interface ChannelPluginOutbound {
|
||||||
|
deliveryMode?: "direct" | "queued";
|
||||||
|
chunker?: (text: string, limit: number) => string[];
|
||||||
|
chunkerMode?: "markdown" | "plain";
|
||||||
|
textChunkLimit?: number;
|
||||||
|
sendText?: (ctx: SendTextContext) => Promise<SendTextResult>;
|
||||||
|
sendMedia?: (ctx: SendMediaContext) => Promise<SendTextResult>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账户状态
|
||||||
|
*/
|
||||||
|
export interface AccountStatus {
|
||||||
|
running?: boolean;
|
||||||
|
connected?: boolean;
|
||||||
|
lastConnectedAt?: number;
|
||||||
|
lastError?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway 启动上下文
|
||||||
|
*/
|
||||||
|
export interface GatewayStartContext<TAccount = unknown> {
|
||||||
|
account: TAccount;
|
||||||
|
accountId: string;
|
||||||
|
abortSignal: AbortSignal;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
log?: {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
error: (msg: string) => void;
|
||||||
|
debug: (msg: string) => void;
|
||||||
|
};
|
||||||
|
getStatus: () => AccountStatus;
|
||||||
|
setStatus: (status: AccountStatus) => void;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway 登出上下文
|
||||||
|
*/
|
||||||
|
export interface GatewayLogoutContext {
|
||||||
|
accountId: string;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway 登出结果
|
||||||
|
*/
|
||||||
|
export interface GatewayLogoutResult {
|
||||||
|
ok: boolean;
|
||||||
|
cleared: boolean;
|
||||||
|
updatedConfig?: OpenClawConfig;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频道插件 Gateway 接口
|
||||||
|
*/
|
||||||
|
export interface ChannelPluginGateway<TAccount = unknown> {
|
||||||
|
startAccount?: (ctx: GatewayStartContext<TAccount>) => Promise<void>;
|
||||||
|
logoutAccount?: (ctx: GatewayLogoutContext) => Promise<GatewayLogoutResult>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 频道插件接口(泛型)
|
||||||
|
*/
|
||||||
|
export interface ChannelPlugin<TAccount = unknown> {
|
||||||
|
/** 插件 ID */
|
||||||
|
id: string;
|
||||||
|
/** 插件 Meta 信息 */
|
||||||
|
meta?: ChannelPluginMeta;
|
||||||
|
/** 插件版本 */
|
||||||
|
version?: string;
|
||||||
|
/** 插件能力 */
|
||||||
|
capabilities?: ChannelPluginCapabilities;
|
||||||
|
/** 重载配置 */
|
||||||
|
reload?: { configPrefixes?: string[] };
|
||||||
|
/** Onboarding 适配器 */
|
||||||
|
onboarding?: ChannelOnboardingAdapter;
|
||||||
|
/** 配置方法 */
|
||||||
|
config?: ChannelPluginConfig<TAccount>;
|
||||||
|
/** Setup 方法 */
|
||||||
|
setup?: ChannelPluginSetup;
|
||||||
|
/** Messaging 配置 */
|
||||||
|
messaging?: ChannelPluginMessaging;
|
||||||
|
/** Outbound 配置 */
|
||||||
|
outbound?: ChannelPluginOutbound;
|
||||||
|
/** Gateway 配置 */
|
||||||
|
gateway?: ChannelPluginGateway<TAccount>;
|
||||||
|
/** 启动函数 */
|
||||||
|
start?: (runtime: PluginRuntime) => void | Promise<void>;
|
||||||
|
/** 停止函数 */
|
||||||
|
stop?: () => void | Promise<void>;
|
||||||
|
/** deliver 函数 - 发送消息 */
|
||||||
|
deliver?: (ctx: unknown) => Promise<unknown>;
|
||||||
|
/** 其他插件属性 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Onboarding 类型 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding 状态结果
|
||||||
|
*/
|
||||||
|
export interface ChannelOnboardingStatus {
|
||||||
|
channel?: string;
|
||||||
|
configured: boolean;
|
||||||
|
statusLines?: string[];
|
||||||
|
selectionHint?: string;
|
||||||
|
quickstartScore?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding 状态字符串枚举(部分 API 使用)
|
||||||
|
*/
|
||||||
|
export type ChannelOnboardingStatusString =
|
||||||
|
| "not-configured"
|
||||||
|
| "configured"
|
||||||
|
| "connected"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding 状态上下文
|
||||||
|
*/
|
||||||
|
export interface ChannelOnboardingStatusContext {
|
||||||
|
/** 当前配置 */
|
||||||
|
config: OpenClawConfig;
|
||||||
|
/** 账户 ID */
|
||||||
|
accountId?: string;
|
||||||
|
/** Prompter */
|
||||||
|
prompter?: unknown;
|
||||||
|
/** 其他上下文 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding 配置上下文
|
||||||
|
*/
|
||||||
|
export interface ChannelOnboardingConfigureContext {
|
||||||
|
/** 当前配置 */
|
||||||
|
config: OpenClawConfig;
|
||||||
|
/** 账户 ID */
|
||||||
|
accountId?: string;
|
||||||
|
/** 输入参数 */
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
/** Prompter */
|
||||||
|
prompter?: unknown;
|
||||||
|
/** 其他上下文 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding 结果
|
||||||
|
*/
|
||||||
|
export interface ChannelOnboardingResult {
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean;
|
||||||
|
/** 更新后的配置 */
|
||||||
|
config?: OpenClawConfig;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** 消息 */
|
||||||
|
message?: string;
|
||||||
|
/** 其他结果字段 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding 适配器接口
|
||||||
|
*/
|
||||||
|
export interface ChannelOnboardingAdapter {
|
||||||
|
/** 获取状态 */
|
||||||
|
getStatus?: (ctx: ChannelOnboardingStatusContext) => ChannelOnboardingStatus | Promise<ChannelOnboardingStatus>;
|
||||||
|
/** 配置函数 */
|
||||||
|
configure?: (ctx: ChannelOnboardingConfigureContext) => ChannelOnboardingResult | Promise<ChannelOnboardingResult>;
|
||||||
|
/** 其他适配器方法 */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 配置辅助函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将账户名称应用到频道配置段
|
||||||
|
*/
|
||||||
|
export function applyAccountNameToChannelSection(ctx: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
channelKey: string;
|
||||||
|
accountId: string;
|
||||||
|
name: string;
|
||||||
|
}): OpenClawConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从配置段删除账户
|
||||||
|
*/
|
||||||
|
export function deleteAccountFromConfigSection(ctx: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sectionKey: string;
|
||||||
|
accountId: string;
|
||||||
|
clearBaseFields?: string[];
|
||||||
|
}): OpenClawConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置账户启用状态
|
||||||
|
*/
|
||||||
|
export function setAccountEnabledInConfigSection(ctx: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
sectionKey: string;
|
||||||
|
accountId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
allowTopLevel?: boolean;
|
||||||
|
}): OpenClawConfig;
|
||||||
|
|
||||||
|
// ============ 其他导出 ============
|
||||||
|
|
||||||
|
/** 默认账户 ID 常量 */
|
||||||
|
export const DEFAULT_ACCOUNT_ID: string;
|
||||||
|
|
||||||
|
/** 规范化账户 ID */
|
||||||
|
export function normalizeAccountId(accountId: string | undefined | null): string;
|
||||||
|
}
|
||||||
184
src/outbound.ts
184
src/outbound.ts
@@ -16,6 +16,133 @@ import {
|
|||||||
type StreamMessageResponse,
|
type StreamMessageResponse,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
|
// ============ 消息回复限流器 ============
|
||||||
|
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
||||||
|
const MESSAGE_REPLY_LIMIT = 4;
|
||||||
|
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
|
||||||
|
|
||||||
|
interface MessageReplyRecord {
|
||||||
|
count: number;
|
||||||
|
firstReplyAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageReplyTracker = new Map<string, MessageReplyRecord>();
|
||||||
|
|
||||||
|
/** 限流检查结果 */
|
||||||
|
export interface ReplyLimitResult {
|
||||||
|
/** 是否允许被动回复 */
|
||||||
|
allowed: boolean;
|
||||||
|
/** 剩余被动回复次数 */
|
||||||
|
remaining: number;
|
||||||
|
/** 是否需要降级为主动消息(超期或超过次数) */
|
||||||
|
shouldFallbackToProactive: boolean;
|
||||||
|
/** 降级原因 */
|
||||||
|
fallbackReason?: "expired" | "limit_exceeded";
|
||||||
|
/** 提示消息 */
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以回复该消息(限流检查)
|
||||||
|
* @param messageId 消息ID
|
||||||
|
* @returns ReplyLimitResult 限流检查结果
|
||||||
|
*/
|
||||||
|
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = messageReplyTracker.get(messageId);
|
||||||
|
|
||||||
|
// 清理过期记录(定期清理,避免内存泄漏)
|
||||||
|
if (messageReplyTracker.size > 10000) {
|
||||||
|
for (const [id, rec] of messageReplyTracker) {
|
||||||
|
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||||
|
messageReplyTracker.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新消息,首次回复
|
||||||
|
if (!record) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: MESSAGE_REPLY_LIMIT,
|
||||||
|
shouldFallbackToProactive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否超过1小时(message_id 过期)
|
||||||
|
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||||
|
// 超过1小时,被动回复不可用,需要降级为主动消息
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
shouldFallbackToProactive: true,
|
||||||
|
fallbackReason: "expired",
|
||||||
|
message: `消息已超过1小时有效期,将使用主动消息发送`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否超过回复次数限制
|
||||||
|
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
shouldFallbackToProactive: true,
|
||||||
|
fallbackReason: "limit_exceeded",
|
||||||
|
message: `该消息已达到1小时内最大回复次数(${MESSAGE_REPLY_LIMIT}次),将使用主动消息发送`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining,
|
||||||
|
shouldFallbackToProactive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次消息回复
|
||||||
|
* @param messageId 消息ID
|
||||||
|
*/
|
||||||
|
export function recordMessageReply(messageId: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = messageReplyTracker.get(messageId);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||||
|
} else {
|
||||||
|
// 检查是否过期,过期则重新计数
|
||||||
|
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||||
|
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||||
|
} else {
|
||||||
|
record.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息回复统计信息
|
||||||
|
*/
|
||||||
|
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } {
|
||||||
|
let totalReplies = 0;
|
||||||
|
for (const record of messageReplyTracker.values()) {
|
||||||
|
totalReplies += record.count;
|
||||||
|
}
|
||||||
|
return { trackedMessages: messageReplyTracker.size, totalReplies };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息回复限制配置(供外部查询)
|
||||||
|
*/
|
||||||
|
export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } {
|
||||||
|
return {
|
||||||
|
limit: MESSAGE_REPLY_LIMIT,
|
||||||
|
ttlMs: MESSAGE_REPLY_TTL,
|
||||||
|
ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutboundContext {
|
export interface OutboundContext {
|
||||||
to: string;
|
to: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -211,14 +338,61 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送文本消息
|
* 发送文本消息
|
||||||
* - 有 replyToId: 被动回复,无配额限制
|
* - 有 replyToId: 被动回复,1小时内最多回复4次
|
||||||
* - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群)
|
* - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群)
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* 1. 主动消息(无 replyToId)必须有消息内容,不支持流式发送
|
||||||
|
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
|
||||||
*/
|
*/
|
||||||
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||||
const { to, text, replyToId, account } = ctx;
|
const { to, text, account } = ctx;
|
||||||
|
let { replyToId } = ctx;
|
||||||
|
let fallbackToProactive = false;
|
||||||
|
|
||||||
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
|
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
|
||||||
|
|
||||||
|
// ============ 消息回复限流检查 ============
|
||||||
|
// 如果有 replyToId,检查是否可以被动回复
|
||||||
|
if (replyToId) {
|
||||||
|
const limitCheck = checkMessageReplyLimit(replyToId);
|
||||||
|
|
||||||
|
if (!limitCheck.allowed) {
|
||||||
|
// 检查是否需要降级为主动消息
|
||||||
|
if (limitCheck.shouldFallbackToProactive) {
|
||||||
|
console.warn(`[qqbot] sendText: 被动回复不可用,降级为主动消息 - ${limitCheck.message}`);
|
||||||
|
fallbackToProactive = true;
|
||||||
|
replyToId = null; // 清除 replyToId,改为主动消息
|
||||||
|
} else {
|
||||||
|
// 不应该发生,但作为保底
|
||||||
|
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
error: limitCheck.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[qqbot] sendText: 消息 ${replyToId} 剩余被动回复次数: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 主动消息校验(参考 Telegram 机制) ============
|
||||||
|
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
|
||||||
|
if (!replyToId) {
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
error: "主动消息必须有内容 (--message 参数不能为空)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (fallbackToProactive) {
|
||||||
|
console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!account.appId || !account.clientSecret) {
|
if (!account.appId || !account.clientSecret) {
|
||||||
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||||
}
|
}
|
||||||
@@ -246,12 +420,18 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|||||||
// 有 replyToId,使用被动回复接口
|
// 有 replyToId,使用被动回复接口
|
||||||
if (target.type === "c2c") {
|
if (target.type === "c2c") {
|
||||||
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
|
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
|
||||||
|
// 记录回复次数
|
||||||
|
recordMessageReply(replyToId);
|
||||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
} else if (target.type === "group") {
|
} else if (target.type === "group") {
|
||||||
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
|
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
|
||||||
|
// 记录回复次数
|
||||||
|
recordMessageReply(replyToId);
|
||||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
} else {
|
} else {
|
||||||
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
|
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
|
||||||
|
// 记录回复次数
|
||||||
|
recordMessageReply(replyToId);
|
||||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
528
src/proactive.ts
Normal file
528
src/proactive.ts
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot 主动发送消息模块
|
||||||
|
*
|
||||||
|
* 该模块提供以下能力:
|
||||||
|
* 1. 记录已知用户(曾与机器人交互过的用户)
|
||||||
|
* 2. 主动发送消息给用户或群组
|
||||||
|
* 3. 查询已知用户列表
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import type { ResolvedQQBotAccount } from "./types.js";
|
||||||
|
|
||||||
|
// ============ 类型定义(本地) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已知用户信息
|
||||||
|
*/
|
||||||
|
export interface KnownUser {
|
||||||
|
type: "c2c" | "group" | "channel";
|
||||||
|
openid: string;
|
||||||
|
accountId: string;
|
||||||
|
nickname?: string;
|
||||||
|
firstInteractionAt: number;
|
||||||
|
lastInteractionAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动发送消息选项
|
||||||
|
*/
|
||||||
|
export interface ProactiveSendOptions {
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
type?: "c2c" | "group" | "channel";
|
||||||
|
imageUrl?: string;
|
||||||
|
accountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动发送消息结果
|
||||||
|
*/
|
||||||
|
export interface ProactiveSendResult {
|
||||||
|
success: boolean;
|
||||||
|
messageId?: string;
|
||||||
|
timestamp?: number | string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出已知用户选项
|
||||||
|
*/
|
||||||
|
export interface ListKnownUsersOptions {
|
||||||
|
type?: "c2c" | "group" | "channel";
|
||||||
|
accountId?: string;
|
||||||
|
sortByLastInteraction?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
sendProactiveC2CMessage,
|
||||||
|
sendProactiveGroupMessage,
|
||||||
|
sendChannelMessage,
|
||||||
|
sendC2CImageMessage,
|
||||||
|
sendGroupImageMessage,
|
||||||
|
} from "./api.js";
|
||||||
|
import { resolveQQBotAccount } from "./config.js";
|
||||||
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
|
// ============ 用户存储管理 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已知用户存储
|
||||||
|
* 使用简单的 JSON 文件存储,保存在 clawd 目录下
|
||||||
|
*/
|
||||||
|
const STORAGE_DIR = path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-data");
|
||||||
|
const KNOWN_USERS_FILE = path.join(STORAGE_DIR, "known-users.json");
|
||||||
|
|
||||||
|
// 内存缓存
|
||||||
|
let knownUsersCache: Map<string, KnownUser> | null = null;
|
||||||
|
let cacheLastModified = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保存储目录存在
|
||||||
|
*/
|
||||||
|
function ensureStorageDir(): void {
|
||||||
|
if (!fs.existsSync(STORAGE_DIR)) {
|
||||||
|
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户唯一键
|
||||||
|
*/
|
||||||
|
function getUserKey(type: string, openid: string, accountId: string): string {
|
||||||
|
return `${accountId}:${type}:${openid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件加载已知用户
|
||||||
|
*/
|
||||||
|
function loadKnownUsers(): Map<string, KnownUser> {
|
||||||
|
if (knownUsersCache !== null) {
|
||||||
|
// 检查文件是否被修改
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(KNOWN_USERS_FILE);
|
||||||
|
if (stat.mtimeMs <= cacheLastModified) {
|
||||||
|
return knownUsersCache;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 文件不存在,使用缓存
|
||||||
|
return knownUsersCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = new Map<string, KnownUser>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
||||||
|
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
||||||
|
const parsed = JSON.parse(data) as KnownUser[];
|
||||||
|
for (const user of parsed) {
|
||||||
|
const key = getUserKey(user.type, user.openid, user.accountId);
|
||||||
|
users.set(key, user);
|
||||||
|
}
|
||||||
|
cacheLastModified = fs.statSync(KNOWN_USERS_FILE).mtimeMs;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[qqbot:proactive] Failed to load known users: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
knownUsersCache = users;
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存已知用户到文件
|
||||||
|
*/
|
||||||
|
function saveKnownUsers(users: Map<string, KnownUser>): void {
|
||||||
|
try {
|
||||||
|
ensureStorageDir();
|
||||||
|
const data = Array.from(users.values());
|
||||||
|
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
||||||
|
cacheLastModified = Date.now();
|
||||||
|
knownUsersCache = users;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[qqbot:proactive] Failed to save known users: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一个已知用户(当收到用户消息时调用)
|
||||||
|
*
|
||||||
|
* @param user - 用户信息
|
||||||
|
*/
|
||||||
|
export function recordKnownUser(user: Omit<KnownUser, "firstInteractionAt">): void {
|
||||||
|
const users = loadKnownUsers();
|
||||||
|
const key = getUserKey(user.type, user.openid, user.accountId);
|
||||||
|
|
||||||
|
const existing = users.get(key);
|
||||||
|
const now = user.lastInteractionAt || Date.now();
|
||||||
|
|
||||||
|
users.set(key, {
|
||||||
|
...user,
|
||||||
|
lastInteractionAt: now,
|
||||||
|
firstInteractionAt: existing?.firstInteractionAt ?? now,
|
||||||
|
// 更新昵称(如果有新的)
|
||||||
|
nickname: user.nickname || existing?.nickname,
|
||||||
|
});
|
||||||
|
|
||||||
|
saveKnownUsers(users);
|
||||||
|
console.log(`[qqbot:proactive] Recorded user: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取一个已知用户
|
||||||
|
*
|
||||||
|
* @param type - 用户类型
|
||||||
|
* @param openid - 用户 openid
|
||||||
|
* @param accountId - 账户 ID
|
||||||
|
*/
|
||||||
|
export function getKnownUser(type: string, openid: string, accountId: string): KnownUser | undefined {
|
||||||
|
const users = loadKnownUsers();
|
||||||
|
const key = getUserKey(type, openid, accountId);
|
||||||
|
return users.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出已知用户
|
||||||
|
*
|
||||||
|
* @param options - 过滤选项
|
||||||
|
*/
|
||||||
|
export function listKnownUsers(options?: ListKnownUsersOptions): KnownUser[] {
|
||||||
|
const users = loadKnownUsers();
|
||||||
|
let result = Array.from(users.values());
|
||||||
|
|
||||||
|
// 过滤类型
|
||||||
|
if (options?.type) {
|
||||||
|
result = result.filter(u => u.type === options.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤账户
|
||||||
|
if (options?.accountId) {
|
||||||
|
result = result.filter(u => u.accountId === options.accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
if (options?.sortByLastInteraction !== false) {
|
||||||
|
result.sort((a, b) => b.lastInteractionAt - a.lastInteractionAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制数量
|
||||||
|
if (options?.limit && options.limit > 0) {
|
||||||
|
result = result.slice(0, options.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一个已知用户
|
||||||
|
*
|
||||||
|
* @param type - 用户类型
|
||||||
|
* @param openid - 用户 openid
|
||||||
|
* @param accountId - 账户 ID
|
||||||
|
*/
|
||||||
|
export function removeKnownUser(type: string, openid: string, accountId: string): boolean {
|
||||||
|
const users = loadKnownUsers();
|
||||||
|
const key = getUserKey(type, openid, accountId);
|
||||||
|
const deleted = users.delete(key);
|
||||||
|
if (deleted) {
|
||||||
|
saveKnownUsers(users);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有已知用户
|
||||||
|
*
|
||||||
|
* @param accountId - 可选,只清除指定账户的用户
|
||||||
|
*/
|
||||||
|
export function clearKnownUsers(accountId?: string): number {
|
||||||
|
const users = loadKnownUsers();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (accountId) {
|
||||||
|
for (const [key, user] of users) {
|
||||||
|
if (user.accountId === accountId) {
|
||||||
|
users.delete(key);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
count = users.size;
|
||||||
|
users.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
saveKnownUsers(users);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 主动发送消息 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动发送消息(带配置解析)
|
||||||
|
* 注意:与 outbound.ts 中的 sendProactiveMessage 不同,这个函数接受 OpenClawConfig 并自动解析账户
|
||||||
|
*
|
||||||
|
* @param options - 发送选项
|
||||||
|
* @param cfg - OpenClaw 配置
|
||||||
|
* @returns 发送结果
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 发送私聊消息
|
||||||
|
* const result = await sendProactive({
|
||||||
|
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4", // 用户 openid
|
||||||
|
* text: "你好!这是一条主动消息",
|
||||||
|
* type: "c2c",
|
||||||
|
* }, cfg);
|
||||||
|
*
|
||||||
|
* // 发送群聊消息
|
||||||
|
* const result = await sendProactive({
|
||||||
|
* to: "A1B2C3D4E5F6A7B8", // 群组 openid
|
||||||
|
* text: "群公告:今天有活动",
|
||||||
|
* type: "group",
|
||||||
|
* }, cfg);
|
||||||
|
*
|
||||||
|
* // 发送带图片的消息
|
||||||
|
* const result = await sendProactive({
|
||||||
|
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4",
|
||||||
|
* text: "看看这张图片",
|
||||||
|
* imageUrl: "https://example.com/image.png",
|
||||||
|
* type: "c2c",
|
||||||
|
* }, cfg);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function sendProactive(
|
||||||
|
options: ProactiveSendOptions,
|
||||||
|
cfg: OpenClawConfig
|
||||||
|
): Promise<ProactiveSendResult> {
|
||||||
|
const { to, text, type = "c2c", imageUrl, accountId = "default" } = options;
|
||||||
|
|
||||||
|
// 解析账户配置
|
||||||
|
const account = resolveQQBotAccount(cfg, accountId);
|
||||||
|
|
||||||
|
if (!account.appId || !account.clientSecret) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "QQBot not configured (missing appId or clientSecret)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||||
|
|
||||||
|
// 如果有图片,先发送图片
|
||||||
|
if (imageUrl) {
|
||||||
|
try {
|
||||||
|
if (type === "c2c") {
|
||||||
|
await sendC2CImageMessage(accessToken, to, imageUrl, undefined, undefined);
|
||||||
|
} else if (type === "group") {
|
||||||
|
await sendGroupImageMessage(accessToken, to, imageUrl, undefined, undefined);
|
||||||
|
}
|
||||||
|
console.log(`[qqbot:proactive] Sent image to ${type}:${to}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[qqbot:proactive] Failed to send image: ${err}`);
|
||||||
|
// 图片发送失败不影响文本发送
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送文本消息
|
||||||
|
let result: { id: string; timestamp: number | string };
|
||||||
|
|
||||||
|
if (type === "c2c") {
|
||||||
|
result = await sendProactiveC2CMessage(accessToken, to, text);
|
||||||
|
} else if (type === "group") {
|
||||||
|
result = await sendProactiveGroupMessage(accessToken, to, text);
|
||||||
|
} else if (type === "channel") {
|
||||||
|
// 频道消息需要 channel_id,这里暂时不支持主动发送
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Channel proactive messages are not supported. Please use group or c2c.",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Unknown message type: ${type}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: result.id,
|
||||||
|
timestamp: result.timestamp,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[qqbot:proactive] Failed to send message: ${message}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量发送主动消息
|
||||||
|
*
|
||||||
|
* @param recipients - 接收者列表(openid 数组)
|
||||||
|
* @param text - 消息内容
|
||||||
|
* @param type - 消息类型
|
||||||
|
* @param cfg - OpenClaw 配置
|
||||||
|
* @param accountId - 账户 ID
|
||||||
|
* @returns 发送结果列表
|
||||||
|
*/
|
||||||
|
export async function sendBulkProactiveMessage(
|
||||||
|
recipients: string[],
|
||||||
|
text: string,
|
||||||
|
type: "c2c" | "group",
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
accountId = "default"
|
||||||
|
): Promise<Array<{ to: string; result: ProactiveSendResult }>> {
|
||||||
|
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
|
||||||
|
|
||||||
|
for (const to of recipients) {
|
||||||
|
const result = await sendProactive({ to, text, type, accountId }, cfg);
|
||||||
|
results.push({ to, result });
|
||||||
|
|
||||||
|
// 添加延迟,避免频率限制
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息给所有已知用户
|
||||||
|
*
|
||||||
|
* @param text - 消息内容
|
||||||
|
* @param cfg - OpenClaw 配置
|
||||||
|
* @param options - 过滤选项
|
||||||
|
* @returns 发送结果统计
|
||||||
|
*/
|
||||||
|
export async function broadcastMessage(
|
||||||
|
text: string,
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
options?: {
|
||||||
|
type?: "c2c" | "group";
|
||||||
|
accountId?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{ to: string; result: ProactiveSendResult }>;
|
||||||
|
}> {
|
||||||
|
const users = listKnownUsers({
|
||||||
|
type: options?.type,
|
||||||
|
accountId: options?.accountId,
|
||||||
|
limit: options?.limit,
|
||||||
|
sortByLastInteraction: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 过滤掉频道用户(不支持主动发送)
|
||||||
|
const validUsers = users.filter(u => u.type === "c2c" || u.type === "group");
|
||||||
|
|
||||||
|
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const user of validUsers) {
|
||||||
|
const result = await sendProactive({
|
||||||
|
to: user.openid,
|
||||||
|
text,
|
||||||
|
type: user.type as "c2c" | "group",
|
||||||
|
accountId: user.accountId,
|
||||||
|
}, cfg);
|
||||||
|
|
||||||
|
results.push({ to: user.openid, result });
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
success++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加延迟,避免频率限制
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: validUsers.length,
|
||||||
|
success,
|
||||||
|
failed,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 辅助函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据账户配置直接发送主动消息(不需要 cfg)
|
||||||
|
*
|
||||||
|
* @param account - 已解析的账户配置
|
||||||
|
* @param to - 目标 openid
|
||||||
|
* @param text - 消息内容
|
||||||
|
* @param type - 消息类型
|
||||||
|
*/
|
||||||
|
export async function sendProactiveMessageDirect(
|
||||||
|
account: ResolvedQQBotAccount,
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
type: "c2c" | "group" = "c2c"
|
||||||
|
): Promise<ProactiveSendResult> {
|
||||||
|
if (!account.appId || !account.clientSecret) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "QQBot not configured (missing appId or clientSecret)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||||
|
|
||||||
|
let result: { id: string; timestamp: number | string };
|
||||||
|
|
||||||
|
if (type === "c2c") {
|
||||||
|
result = await sendProactiveC2CMessage(accessToken, to, text);
|
||||||
|
} else {
|
||||||
|
result = await sendProactiveGroupMessage(accessToken, to, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: result.id,
|
||||||
|
timestamp: result.timestamp,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已知用户统计
|
||||||
|
*/
|
||||||
|
export function getKnownUsersStats(accountId?: string): {
|
||||||
|
total: number;
|
||||||
|
c2c: number;
|
||||||
|
group: number;
|
||||||
|
channel: number;
|
||||||
|
} {
|
||||||
|
const users = listKnownUsers({ accountId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: users.length,
|
||||||
|
c2c: users.filter(u => u.type === "c2c").length,
|
||||||
|
group: users.filter(u => u.type === "group").length,
|
||||||
|
channel: users.filter(u => u.type === "channel").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ export interface ResolvedQQBotAccount {
|
|||||||
imageServerBaseUrl?: string;
|
imageServerBaseUrl?: string;
|
||||||
/** 是否支持 markdown 消息 */
|
/** 是否支持 markdown 消息 */
|
||||||
markdownSupport?: boolean;
|
markdownSupport?: boolean;
|
||||||
|
/** 是否启用流式消息(仅 c2c 私聊支持),默认 true */
|
||||||
|
streamEnabled?: boolean;
|
||||||
config: QQBotAccountConfig;
|
config: QQBotAccountConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +45,8 @@ export interface QQBotAccountConfig {
|
|||||||
imageServerBaseUrl?: string;
|
imageServerBaseUrl?: string;
|
||||||
/** 是否支持 markdown 消息,默认 true */
|
/** 是否支持 markdown 消息,默认 true */
|
||||||
markdownSupport?: boolean;
|
markdownSupport?: boolean;
|
||||||
|
/** 是否启用流式消息,默认 true(仅 c2c 私聊支持) */
|
||||||
|
streamEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ openclaw plugins install .
|
|||||||
echo ""
|
echo ""
|
||||||
echo "[3/4] 配置机器人通道..."
|
echo "[3/4] 配置机器人通道..."
|
||||||
# 默认 token,可通过环境变量 QQBOT_TOKEN 覆盖
|
# 默认 token,可通过环境变量 QQBOT_TOKEN 覆盖
|
||||||
QQBOT_TOKEN="${QQBOT_TOKEN:-xxx:xxx}"
|
QQBOT_TOKEN="${QQBOT_TOKEN:-102831906:tOtPvS0Y7gGqR2eGtXBqVBrYFxfO8sdO}"
|
||||||
openclaw channels add --channel qqbot --token "$QQBOT_TOKEN"
|
openclaw channels add --channel qqbot --token "$QQBOT_TOKEN"
|
||||||
# 启用 markdown 支持
|
# 启用 markdown 支持
|
||||||
openclaw config set channels.qqbot.markdownSupport true
|
openclaw config set channels.qqbot.markdownSupport true
|
||||||
|
|||||||
Reference in New Issue
Block a user