**新增提醒技能** - 新增 skills/qqbot-cron/SKILL.md 定时提醒技能定义 - 支持一次性提醒(--at)和周期性提醒(--cron) - 支持设置、查询、取消提醒操作 **主动消息系统** - 新增 src/proactive.ts 主动消息发送模块 - 新增 src/known-users.ts 已知用户管理 - 新增 src/session-store.ts 会话存储 - 支持主动向用户/群组发送消息 **工具脚本** - 新增 scripts/proactive-api-server.ts 主动消息API服务
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
/**
|
||
* 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();
|