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:
rianli
2026-02-01 17:10:55 +08:00
parent 50422aac14
commit a3e87f2f37
15 changed files with 4639 additions and 52 deletions

View 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
View 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);
});