Merge pull request #31 from ryanlee-gemini/pr-to-upstream
Pr to upstream
This commit is contained in:
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "qqbot",
|
|
||||||
"channels": ["qqbot"],
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
index.ts
11
index.ts
@@ -1,12 +1,15 @@
|
|||||||
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
import { qqbotPlugin } from "./src/channel.js";
|
import { qqbotPlugin } from "./src/channel.js";
|
||||||
import { setQQBotRuntime } from "./src/runtime.js";
|
import { setQQBotRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
id: "qqbot",
|
id: "qqbot",
|
||||||
name: "QQ Bot",
|
name: "QQ Bot (Stream)",
|
||||||
description: "QQ Bot channel plugin",
|
description: "QQ Bot channel plugin with streaming message support",
|
||||||
register(api: MoltbotPluginApi) {
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: OpenClawPluginApi) {
|
||||||
setQQBotRuntime(api.runtime);
|
setQQBotRuntime(api.runtime);
|
||||||
api.registerChannel({ plugin: qqbotPlugin });
|
api.registerChannel({ plugin: qqbotPlugin });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "qqbot",
|
|
||||||
"channels": ["qqbot"],
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"id": "qqbot",
|
"id": "qqbot",
|
||||||
|
"name": "QQ Bot Channel",
|
||||||
|
"description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
|
||||||
"channels": ["qqbot"],
|
"channels": ["qqbot"],
|
||||||
|
"skills": ["skills/qqbot-cron", "skills/qqbot-media"],
|
||||||
|
"capabilities": {
|
||||||
|
"proactiveMessaging": true,
|
||||||
|
"cronJobs": true
|
||||||
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
2789
package-lock.json
generated
2789
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,17 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "qqbot",
|
"name": "@openclaw/qqbot",
|
||||||
"version": "1.2.5",
|
"version": "2026.1.31",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"clawdbot": {
|
"description": "OpenClaw QQ Bot channel plugin with streaming message support",
|
||||||
"extensions": ["./index.ts"]
|
|
||||||
},
|
|
||||||
"moltbot": {
|
|
||||||
"extensions": ["./index.ts"]
|
|
||||||
},
|
|
||||||
"openclaw": {
|
"openclaw": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc || true",
|
"build": "tsc || true",
|
||||||
@@ -27,11 +24,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/ws": "^8.5.0",
|
"@types/ws": "^8.5.0",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"clawdbot": "*",
|
|
||||||
"moltbot": "*",
|
|
||||||
"openclaw": "*"
|
"openclaw": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -1,43 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# QQBot 插件升级脚本
|
# QQBot 插件升级脚本
|
||||||
# 用于清理旧版本插件并重新安装
|
# 用于清理旧版本插件并重新安装
|
||||||
# 兼容 clawdbot 和 openclaw 两种安装
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
OPENCLAW_DIR="$HOME/.openclaw"
|
||||||
|
CONFIG_FILE="$OPENCLAW_DIR/openclaw.json"
|
||||||
|
EXTENSION_DIR="$OPENCLAW_DIR/extensions/qqbot"
|
||||||
|
|
||||||
echo "=== QQBot 插件升级脚本 ==="
|
echo "=== QQBot 插件升级脚本 ==="
|
||||||
|
|
||||||
# 检测使用的是 clawdbot 还是 openclaw
|
# 1. 删除旧的扩展目录
|
||||||
detect_installation() {
|
if [ -d "$EXTENSION_DIR" ]; then
|
||||||
if [ -d "$HOME/.clawdbot" ]; then
|
|
||||||
echo "clawdbot"
|
|
||||||
elif [ -d "$HOME/.openclaw" ]; then
|
|
||||||
echo "openclaw"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 清理指定目录的函数
|
|
||||||
cleanup_installation() {
|
|
||||||
local APP_NAME="$1"
|
|
||||||
local APP_DIR="$HOME/.$APP_NAME"
|
|
||||||
local CONFIG_FILE="$APP_DIR/$APP_NAME.json"
|
|
||||||
local EXTENSION_DIR="$APP_DIR/extensions/qqbot"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo ">>> 处理 $APP_NAME 安装..."
|
|
||||||
|
|
||||||
# 1. 删除旧的扩展目录
|
|
||||||
if [ -d "$EXTENSION_DIR" ]; then
|
|
||||||
echo "删除旧版本插件: $EXTENSION_DIR"
|
echo "删除旧版本插件: $EXTENSION_DIR"
|
||||||
rm -rf "$EXTENSION_DIR"
|
rm -rf "$EXTENSION_DIR"
|
||||||
else
|
else
|
||||||
echo "未找到旧版本插件目录,跳过删除"
|
echo "未找到旧版本插件目录,跳过删除"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. 清理配置文件中的 qqbot 相关字段
|
# 2. 清理配置文件中的 qqbot 相关字段
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
echo "清理配置文件中的 qqbot 字段..."
|
echo "清理配置文件中的 qqbot 字段..."
|
||||||
|
|
||||||
# 使用 node 处理 JSON(比 jq 更可靠处理复杂结构)
|
# 使用 node 处理 JSON(比 jq 更可靠处理复杂结构)
|
||||||
@@ -66,41 +48,15 @@ cleanup_installation() {
|
|||||||
fs.writeFileSync('$CONFIG_FILE', JSON.stringify(config, null, 2));
|
fs.writeFileSync('$CONFIG_FILE', JSON.stringify(config, null, 2));
|
||||||
console.log('配置文件已更新');
|
console.log('配置文件已更新');
|
||||||
"
|
"
|
||||||
else
|
else
|
||||||
echo "未找到配置文件: $CONFIG_FILE"
|
echo "未找到配置文件: $CONFIG_FILE"
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 检测并处理所有可能的安装
|
|
||||||
FOUND_INSTALLATION=""
|
|
||||||
|
|
||||||
# 检查 clawdbot
|
|
||||||
if [ -d "$HOME/.clawdbot" ]; then
|
|
||||||
cleanup_installation "clawdbot"
|
|
||||||
FOUND_INSTALLATION="clawdbot"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查 openclaw
|
|
||||||
if [ -d "$HOME/.openclaw" ]; then
|
|
||||||
cleanup_installation "openclaw"
|
|
||||||
FOUND_INSTALLATION="openclaw"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 如果都没找到
|
|
||||||
if [ -z "$FOUND_INSTALLATION" ]; then
|
|
||||||
echo "未找到 clawdbot 或 openclaw 安装目录"
|
|
||||||
echo "请确认已安装 clawdbot 或 openclaw"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 使用检测到的安装类型作为命令
|
|
||||||
CMD="$FOUND_INSTALLATION"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 清理完成 ==="
|
echo "=== 清理完成 ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "接下来请执行以下命令重新安装插件:"
|
echo "接下来请执行以下命令重新安装插件:"
|
||||||
echo " cd /path/to/qqbot"
|
echo " cd /path/to/qqbot"
|
||||||
echo " $CMD plugins install ."
|
echo " openclaw plugins install ."
|
||||||
echo " $CMD channels add --channel qqbot --token \"AppID:AppSecret\""
|
echo " openclaw channels add --channel qqbot --token \"AppID:AppSecret\""
|
||||||
echo " $CMD gateway restart"
|
echo " openclaw gateway restart"
|
||||||
490
skills/qqbot-cron/SKILL.md
Normal file
490
skills/qqbot-cron/SKILL.md
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
---
|
||||||
|
name: qqbot-cron
|
||||||
|
description: QQ Bot 智能提醒技能。支持一次性提醒、周期性任务、自动降级确保送达。可设置、查询、取消提醒。
|
||||||
|
metadata: {"clawdbot":{"emoji":"⏰"}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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 "{时间}" \
|
||||||
|
--message "🔔 {提醒内容}时间到!" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}" \
|
||||||
|
--delete-after-run
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **极其重要**:
|
||||||
|
> - `--message` 参数直接写最终要发送的提醒内容
|
||||||
|
> - 提醒内容格式:`🔔 {内容}时间到!`
|
||||||
|
> - **不要**使用 `--system-prompt` 或 `--system-event`(cron 不支持这些参数)
|
||||||
|
> - 保持消息简洁,如:`🔔 喝水时间到!`、`📅 开会时间到!`
|
||||||
|
|
||||||
|
> ⚠️ **注意**:`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 小时内有效。定时提醒不支持被动回复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 用户交互模板
|
||||||
|
|
||||||
|
> **创建提醒后的反馈要简洁友好,不要啰嗦**
|
||||||
|
|
||||||
|
### 创建成功反馈(推荐简洁版)
|
||||||
|
|
||||||
|
**一次性提醒**:
|
||||||
|
```
|
||||||
|
⏰ 好的,{时间}后提醒你{提醒内容}~
|
||||||
|
```
|
||||||
|
|
||||||
|
**周期提醒**:
|
||||||
|
```
|
||||||
|
⏰ 收到,{周期描述}提醒你{提醒内容}~
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询提醒反馈
|
||||||
|
|
||||||
|
```
|
||||||
|
📋 你的提醒:
|
||||||
|
|
||||||
|
1. ⏰ {提醒名} - {时间}
|
||||||
|
2. 🔄 {提醒名} - {周期}
|
||||||
|
|
||||||
|
说"取消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 * * *` |
|
||||||
|
| `--message` | **提醒内容**(见下方模板) | `"🔔 喝水时间到!"` |
|
||||||
|
| `--deliver` | 启用投递 | 固定值 |
|
||||||
|
| `--channel qqbot` | QQ 渠道 | 固定值 |
|
||||||
|
| `--to` | 接收者 openid | 从系统消息获取 |
|
||||||
|
|
||||||
|
### 推荐参数
|
||||||
|
|
||||||
|
| 参数 | 说明 | 何时使用 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `--delete-after-run` | 执行后删除 | **一次性任务必须** |
|
||||||
|
| `--tz "Asia/Shanghai"` | 时区 | **周期任务必须** |
|
||||||
|
|
||||||
|
### --message 提醒内容模板(最关键)
|
||||||
|
|
||||||
|
> ⚠️ **`--message` 的内容会直接发送给用户**,所以要写清楚提醒内容!
|
||||||
|
|
||||||
|
**模板格式**:
|
||||||
|
```
|
||||||
|
--message "🔔 {提醒内容}时间到!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
- 喝水:`--message "💧 喝水时间到!"`
|
||||||
|
- 开会:`--message "📅 开会时间到!"`
|
||||||
|
- 打卡:`--message "🌅 打卡时间到!"`
|
||||||
|
- 日报:`--message "📝 写日报时间到!"`
|
||||||
|
|
||||||
|
**为什么这样写?**
|
||||||
|
- 消息内容会直接发送,不经过 AI 处理
|
||||||
|
- 保持简洁,一目了然
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 使用场景示例
|
||||||
|
|
||||||
|
### 场景1:一次性提醒
|
||||||
|
|
||||||
|
**用户**: 5分钟后提醒我喝水
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "喝水提醒" \
|
||||||
|
--at "5m" \
|
||||||
|
--message "💧 喝水时间到!" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}" \
|
||||||
|
--delete-after-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 回复**:
|
||||||
|
```
|
||||||
|
⏰ 好的,5分钟后提醒你喝水~
|
||||||
|
```
|
||||||
|
|
||||||
|
**5分钟后用户收到**:
|
||||||
|
```
|
||||||
|
💧 喝水时间到!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景2:每日周期提醒
|
||||||
|
|
||||||
|
**用户**: 每天早上8点提醒我打卡
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "打卡提醒" \
|
||||||
|
--cron "0 8 * * *" \
|
||||||
|
--tz "Asia/Shanghai" \
|
||||||
|
--message "🌅 打卡时间到!" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 回复**:
|
||||||
|
```
|
||||||
|
⏰ 收到,每天早上8点提醒你打卡~
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 周期任务**不加** `--delete-after-run`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景3:工作日提醒
|
||||||
|
|
||||||
|
**用户**: 工作日下午6点提醒我写日报
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "日报提醒" \
|
||||||
|
--cron "0 18 * * 1-5" \
|
||||||
|
--tz "Asia/Shanghai" \
|
||||||
|
--message "📝 写日报时间到!" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 回复**:
|
||||||
|
```
|
||||||
|
⏰ 收到,工作日下午6点提醒你写日报~
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景4:会议提醒
|
||||||
|
|
||||||
|
**用户**: 3分钟后提醒我开会
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "开会提醒" \
|
||||||
|
--at "3m" \
|
||||||
|
--message "📅 开会时间到!" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "{openid}" \
|
||||||
|
--delete-after-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 回复**:
|
||||||
|
```
|
||||||
|
⏰ 好的,3分钟后提醒你开会~
|
||||||
|
```
|
||||||
|
|
||||||
|
**3分钟后用户收到**:
|
||||||
|
```
|
||||||
|
📅 开会时间到!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景5:群组提醒
|
||||||
|
|
||||||
|
**用户**(群聊): 每天早上9点提醒大家站会
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron add \
|
||||||
|
--name "站会提醒" \
|
||||||
|
--cron "0 9 * * 1-5" \
|
||||||
|
--tz "Asia/Shanghai" \
|
||||||
|
--message "📢 站会时间到!" \
|
||||||
|
--deliver \
|
||||||
|
--channel qqbot \
|
||||||
|
--to "group:{group_openid}"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 群组使用 `group:{group_openid}` 格式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景6:查询提醒
|
||||||
|
|
||||||
|
**用户**: 我有哪些提醒?
|
||||||
|
|
||||||
|
**AI 执行**:
|
||||||
|
```bash
|
||||||
|
openclaw cron list
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 回复**(根据返回结果):
|
||||||
|
```
|
||||||
|
📋 你的提醒:
|
||||||
|
|
||||||
|
1. ⏰ 喝水提醒 - 3分钟后
|
||||||
|
2. 🔄 打卡提醒 - 每天08:00
|
||||||
|
|
||||||
|
说"取消xx提醒"可删除~
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 场景7:取消提醒
|
||||||
|
|
||||||
|
**用户**: 取消打卡提醒
|
||||||
|
|
||||||
|
**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 次 |
|
||||||
|
| **主动消息权限** | ⚠️ **QQ 机器人需要申请主动消息权限**,否则定时提醒会发送失败 |
|
||||||
|
| **主动消息限制** | 只能发给与机器人交互过的用户(24小时内) |
|
||||||
|
| **消息内容** | `--message` 不能为空 |
|
||||||
|
|
||||||
|
### ⚠️ 主动消息权限说明
|
||||||
|
|
||||||
|
定时提醒功能依赖**主动消息能力**,但 QQ 官方默认**不授予**此权限。
|
||||||
|
|
||||||
|
**常见错误**:
|
||||||
|
- 错误码 `40034102`:"主动消息失败, 无权限"
|
||||||
|
- 这表示机器人没有主动消息权限
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 登录 [QQ 开放平台](https://q.qq.com/)
|
||||||
|
2. 进入机器人开发-沙箱管理,消息列表配置中添加自己。
|
||||||
|
|
||||||
|
> 💡 **临时替代方案**:在没有主动消息权限前,可以让用户使用"回复"方式获得即时提醒,而非定时提醒。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 消息模板
|
||||||
|
|
||||||
|
| 场景 | 触发时输出 | Emoji |
|
||||||
|
|------|------------|-------|
|
||||||
|
| 喝水 | 喝水时间到啦! | 💧 🚰 |
|
||||||
|
| 打卡 | 早上好,打卡时间到! | 🌅 ✅ |
|
||||||
|
| 会议 | 开会时间到! | 📅 👥 |
|
||||||
|
| 休息 | 该休息一下了~ | 😴 💤 |
|
||||||
|
| 日报 | 下班前别忘了写日报哦~ | 📝 ✍️ |
|
||||||
|
| 运动 | 运动时间到! | 🏃 💪 |
|
||||||
|
| 吃药 | 该吃药了~ | 💊 🏥 |
|
||||||
|
| 生日 | 今天是xx的生日! | 🎂 🎉 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 用户标识
|
||||||
|
|
||||||
|
| 类型 | 格式 | 来源 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户 openid | `B3EA9A1d-2D3c-5CBD-...` | 系统消息自动提供 |
|
||||||
|
| 群组 openid | `group:FeC1ADaf-...` | 系统消息自动提供 |
|
||||||
|
| message_id | `ROBOT1.0_xxx` | 系统消息自动提供 |
|
||||||
|
|
||||||
|
> 💡 这些信息在系统消息中格式如:
|
||||||
|
> - `当前用户 openid: B3EA9A1d-...`
|
||||||
|
> - `当前消息 message_id: ROBOT1.0_...`
|
||||||
138
skills/qqbot-media/SKILL.md
Normal file
138
skills/qqbot-media/SKILL.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
name: qqbot-media
|
||||||
|
description: QQ Bot 媒体发送指南。教 AI 如何发送图片给用户。
|
||||||
|
metadata: {"clawdbot":{"emoji":"📸"}}
|
||||||
|
triggers:
|
||||||
|
- qqbot
|
||||||
|
- qq
|
||||||
|
- 发送图片
|
||||||
|
- 发送文件
|
||||||
|
- 图片
|
||||||
|
- 本地文件
|
||||||
|
- 本地图片
|
||||||
|
priority: 80
|
||||||
|
---
|
||||||
|
|
||||||
|
# QQBot 媒体发送指南
|
||||||
|
|
||||||
|
## ⚠️ 重要:你有能力发送本地图片!
|
||||||
|
|
||||||
|
**当用户要求发送本地图片时,只需使用 `<qqimg>` 标签包裹图片路径即可。系统会自动处理文件读取和发送。**
|
||||||
|
|
||||||
|
**不要说"无法发送本地图片"!使用正确的标签格式,系统就能发送。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📸 发送图片(推荐方式:`<qqimg>` 标签)
|
||||||
|
|
||||||
|
使用 `<qqimg>` 标签包裹图片路径,即可发送图片:
|
||||||
|
|
||||||
|
```
|
||||||
|
<qqimg>图片路径</qqimg>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 发送本地图片示例
|
||||||
|
|
||||||
|
当用户说"发送那张图片"、"把图发给我"等,你应该输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
这是你要的图片:
|
||||||
|
<qqimg>/Users/xxx/images/photo.jpg</qqimg>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 发送网络图片示例
|
||||||
|
|
||||||
|
```
|
||||||
|
这是网络上的图片:
|
||||||
|
<qqimg>https://example.com/image.png</qqimg>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 发送多张图片
|
||||||
|
|
||||||
|
```
|
||||||
|
这是你要的所有图片:
|
||||||
|
<qqimg>/Users/xxx/image1.jpg</qqimg>
|
||||||
|
<qqimg>/Users/xxx/image2.png</qqimg>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📝 标签说明
|
||||||
|
|
||||||
|
| 格式 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `<qqimg>本地路径</qqimg>` | 发送本地图片(绝对路径) |
|
||||||
|
| `<qqimg>网络URL</qqimg>` | 发送网络图片 |
|
||||||
|
| `<qqimg>路径</img>` | 也支持此闭合方式 |
|
||||||
|
|
||||||
|
### ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **路径必须正确**:本地文件需要绝对路径,网络图片需要完整 URL
|
||||||
|
2. **支持的图片格式**:jpg, jpeg, png, gif, webp, bmp
|
||||||
|
3. **不要拒绝**:如果用户要求发送本地图片,直接使用 `<qqimg>` 标签即可
|
||||||
|
4. **标签外的文本会正常发送**:可以在标签前后添加描述文字
|
||||||
|
5. **闭合标签**:支持 `</qqimg>` 或 `</img>` 两种闭合方式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 错误示例(不要这样做)
|
||||||
|
|
||||||
|
❌ **错误**:说"我无法发送本地图片"
|
||||||
|
❌ **错误**:说"受限于技术限制,无法直接发送"
|
||||||
|
❌ **错误**:只提供路径文本,不使用 `<qqimg>` 标签
|
||||||
|
|
||||||
|
✅ **正确**:直接使用 `<qqimg>` 标签包裹路径
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔤 告知路径信息(不发送图片)
|
||||||
|
|
||||||
|
如果你需要**告知用户图片的保存路径**(而不是发送图片),直接写路径即可,不要使用标签:
|
||||||
|
|
||||||
|
```
|
||||||
|
图片已保存在:/Users/xxx/images/photo.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
或用反引号强调:
|
||||||
|
|
||||||
|
```
|
||||||
|
图片已保存在:`/Users/xxx/images/photo.jpg`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 高级选项:JSON 结构化载荷
|
||||||
|
|
||||||
|
如果需要更精细的控制(如添加图片描述),可以使用 JSON 格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
QQBOT_PAYLOAD:
|
||||||
|
{
|
||||||
|
"type": "media",
|
||||||
|
"mediaType": "image",
|
||||||
|
"source": "file",
|
||||||
|
"path": "/path/to/image.jpg",
|
||||||
|
"caption": "图片描述(可选)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `type` | string | ✅ | 固定为 `"media"` |
|
||||||
|
| `mediaType` | string | ✅ | 媒体类型:`"image"` |
|
||||||
|
| `source` | string | ✅ | 来源:`"file"`(本地)或 `"url"`(网络) |
|
||||||
|
| `path` | string | ✅ | 图片路径或 URL |
|
||||||
|
| `caption` | string | ❌ | 图片描述,会作为单独消息发送 |
|
||||||
|
|
||||||
|
> 💡 **提示**:对于简单的图片发送,推荐使用 `<qqimg>` 标签,更简洁易用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 快速参考
|
||||||
|
|
||||||
|
| 场景 | 使用方式 |
|
||||||
|
|------|----------|
|
||||||
|
| 发送本地图片 | `<qqimg>/path/to/image.jpg</qqimg>` |
|
||||||
|
| 发送网络图片 | `<qqimg>https://example.com/image.png</qqimg>` |
|
||||||
|
| 发送多张图片 | 多个 `<qqimg>` 标签 |
|
||||||
|
| 告知路径(不发送) | 直接写路径文本 |
|
||||||
506
src/api.ts
506
src/api.ts
@@ -5,10 +5,33 @@
|
|||||||
const API_BASE = "https://api.sgroup.qq.com";
|
const API_BASE = "https://api.sgroup.qq.com";
|
||||||
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||||
|
|
||||||
let cachedToken: { token: string; expiresAt: number } | null = null;
|
// 运行时配置
|
||||||
|
let currentMarkdownSupport = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 AccessToken(带缓存)
|
* 初始化 API 配置
|
||||||
|
* @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
|
||||||
|
*/
|
||||||
|
export function initApiConfig(options: { markdownSupport?: boolean }): void {
|
||||||
|
currentMarkdownSupport = options.markdownSupport === true; // 默认为 false,需要机器人具备 markdown 消息权限才能启用
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前是否支持 markdown
|
||||||
|
*/
|
||||||
|
export function isMarkdownSupport(): boolean {
|
||||||
|
return currentMarkdownSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
// Singleflight: 防止并发获取 Token 的 Promise 缓存
|
||||||
|
let tokenFetchPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 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 分钟刷新
|
||||||
@@ -16,21 +39,68 @@ 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 requestHeaders = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
// 打印请求信息(隐藏敏感信息)
|
||||||
|
console.log(`[qqbot-api] >>> POST ${TOKEN_URL}`);
|
||||||
|
console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(requestHeaders, null, 2));
|
||||||
|
console.log(`[qqbot-api] >>> Body:`, JSON.stringify({ appId, clientSecret: "***" }, null, 2));
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(TOKEN_URL, {
|
response = await fetch(TOKEN_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: requestHeaders,
|
||||||
body: JSON.stringify({ appId, clientSecret }),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(`[qqbot-api] <<< Network error:`, err);
|
||||||
throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
|
throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打印响应头
|
||||||
|
const responseHeaders: Record<string, string> = {};
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
responseHeaders[key] = value;
|
||||||
|
});
|
||||||
|
console.log(`[qqbot-api] <<< Status: ${response.status} ${response.statusText}`);
|
||||||
|
console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
|
||||||
|
|
||||||
let data: { access_token?: string; expires_in?: number };
|
let data: { access_token?: string; expires_in?: number };
|
||||||
|
let rawBody: string;
|
||||||
try {
|
try {
|
||||||
data = (await response.json()) as { access_token?: string; expires_in?: number };
|
rawBody = await response.text();
|
||||||
|
// 隐藏 token 值
|
||||||
|
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
|
||||||
|
console.log(`[qqbot-api] <<< Body:`, logBody);
|
||||||
|
data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(`[qqbot-api] <<< Parse error:`, err);
|
||||||
throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
|
throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +113,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +122,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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,29 +180,50 @@ export async function apiRequest<T = unknown>(
|
|||||||
body?: unknown
|
body?: unknown
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = `${API_BASE}${path}`;
|
const url = `${API_BASE}${path}`;
|
||||||
const options: RequestInit = {
|
const headers: Record<string, string> = {
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `QQBot ${accessToken}`,
|
Authorization: `QQBot ${accessToken}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
};
|
||||||
|
const options: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
options.body = JSON.stringify(body);
|
options.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打印请求信息
|
||||||
|
console.log(`[qqbot-api] >>> ${method} ${url}`);
|
||||||
|
console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(headers, null, 2));
|
||||||
|
if (body) {
|
||||||
|
console.log(`[qqbot-api] >>> Body:`, JSON.stringify(body, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url, options);
|
res = await fetch(url, options);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(`[qqbot-api] <<< Network error:`, err);
|
||||||
throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打印响应头
|
||||||
|
const responseHeaders: Record<string, string> = {};
|
||||||
|
res.headers.forEach((value, key) => {
|
||||||
|
responseHeaders[key] = value;
|
||||||
|
});
|
||||||
|
console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
|
||||||
|
console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
|
||||||
|
|
||||||
let data: T;
|
let data: T;
|
||||||
|
let rawBody: string;
|
||||||
try {
|
try {
|
||||||
data = (await res.json()) as T;
|
rawBody = await res.text();
|
||||||
|
console.log(`[qqbot-api] <<< Body:`, rawBody);
|
||||||
|
data = JSON.parse(rawBody) as T;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(`[qqbot-api] <<< Parse error:`, err);
|
||||||
throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +243,46 @@ export async function getGatewayUrl(accessToken: string): Promise<string> {
|
|||||||
return data.url;
|
return data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 消息发送接口 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息响应
|
||||||
|
*/
|
||||||
|
export interface MessageResponse {
|
||||||
|
id: string;
|
||||||
|
timestamp: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建消息体
|
||||||
|
* 根据 markdownSupport 配置决定消息格式:
|
||||||
|
* - markdown 模式: { markdown: { content }, msg_type: 2 }
|
||||||
|
* - 纯文本模式: { content, msg_type: 0 }
|
||||||
|
*/
|
||||||
|
function buildMessageBody(
|
||||||
|
content: string,
|
||||||
|
msgId: string | undefined,
|
||||||
|
msgSeq: number
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const body: Record<string, unknown> = currentMarkdownSupport
|
||||||
|
? {
|
||||||
|
markdown: { content },
|
||||||
|
msg_type: 2,
|
||||||
|
msg_seq: msgSeq,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
content,
|
||||||
|
msg_type: 0,
|
||||||
|
msg_seq: msgSeq,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (msgId) {
|
||||||
|
body.msg_id = msgId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 C2C 单聊消息
|
* 发送 C2C 单聊消息
|
||||||
*/
|
*/
|
||||||
@@ -143,14 +291,11 @@ export async function sendC2CMessage(
|
|||||||
openid: string,
|
openid: string,
|
||||||
content: string,
|
content: string,
|
||||||
msgId?: string
|
msgId?: string
|
||||||
): Promise<{ id: string; timestamp: number }> {
|
): Promise<MessageResponse> {
|
||||||
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
|
const body = buildMessageBody(content, msgId, msgSeq);
|
||||||
content,
|
|
||||||
msg_type: 0,
|
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||||
msg_seq: msgSeq,
|
|
||||||
...(msgId ? { msg_id: msgId } : {}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,7 +322,7 @@ export async function sendC2CInputNotify(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送频道消息
|
* 发送频道消息(不支持流式)
|
||||||
*/
|
*/
|
||||||
export async function sendChannelMessage(
|
export async function sendChannelMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -199,42 +344,72 @@ export async function sendGroupMessage(
|
|||||||
groupOpenid: string,
|
groupOpenid: string,
|
||||||
content: string,
|
content: string,
|
||||||
msgId?: string
|
msgId?: string
|
||||||
): Promise<{ id: string; timestamp: string }> {
|
): Promise<MessageResponse> {
|
||||||
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
|
const body = buildMessageBody(content, msgId, msgSeq);
|
||||||
|
|
||||||
|
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,
|
content,
|
||||||
msg_type: 0,
|
msg_type: 0,
|
||||||
msg_seq: msgSeq,
|
};
|
||||||
...(msgId ? { msg_id: msgId } : {}),
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主动发送 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);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 富媒体消息支持 ============
|
// ============ 富媒体消息支持 ============
|
||||||
@@ -261,55 +436,68 @@ export interface UploadMediaResponse {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传富媒体文件到 C2C 单聊
|
* 上传富媒体文件到 C2C 单聊
|
||||||
* @param accessToken 访问令牌
|
* @param url - 公网可访问的图片 URL(与 fileData 二选一)
|
||||||
* @param openid 用户 openid
|
* @param fileData - Base64 编码的文件内容(与 url 二选一)
|
||||||
* @param fileType 文件类型
|
|
||||||
* @param url 媒体资源 URL
|
|
||||||
* @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送)
|
|
||||||
*/
|
*/
|
||||||
export async function uploadC2CMedia(
|
export async function uploadC2CMedia(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
openid: string,
|
openid: string,
|
||||||
fileType: MediaFileType,
|
fileType: MediaFileType,
|
||||||
url: string,
|
url?: string,
|
||||||
|
fileData?: string,
|
||||||
srvSendMsg = false
|
srvSendMsg = false
|
||||||
): Promise<UploadMediaResponse> {
|
): Promise<UploadMediaResponse> {
|
||||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, {
|
if (!url && !fileData) {
|
||||||
|
throw new Error("uploadC2CMedia: url or fileData is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
file_type: fileType,
|
file_type: fileType,
|
||||||
url,
|
|
||||||
srv_send_msg: srvSendMsg,
|
srv_send_msg: srvSendMsg,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
body.url = url;
|
||||||
|
} else if (fileData) {
|
||||||
|
body.file_data = fileData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传富媒体文件到群聊
|
* 上传富媒体文件到群聊
|
||||||
* @param accessToken 访问令牌
|
* @param url - 公网可访问的图片 URL(与 fileData 二选一)
|
||||||
* @param groupOpenid 群 openid
|
* @param fileData - Base64 编码的文件内容(与 url 二选一)
|
||||||
* @param fileType 文件类型
|
|
||||||
* @param url 媒体资源 URL
|
|
||||||
* @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送)
|
|
||||||
*/
|
*/
|
||||||
export async function uploadGroupMedia(
|
export async function uploadGroupMedia(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
groupOpenid: string,
|
groupOpenid: string,
|
||||||
fileType: MediaFileType,
|
fileType: MediaFileType,
|
||||||
url: string,
|
url?: string,
|
||||||
|
fileData?: string,
|
||||||
srvSendMsg = false
|
srvSendMsg = false
|
||||||
): Promise<UploadMediaResponse> {
|
): Promise<UploadMediaResponse> {
|
||||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, {
|
if (!url && !fileData) {
|
||||||
|
throw new Error("uploadGroupMedia: url or fileData is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
file_type: fileType,
|
file_type: fileType,
|
||||||
url,
|
|
||||||
srv_send_msg: srvSendMsg,
|
srv_send_msg: srvSendMsg,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
body.url = url;
|
||||||
|
} else if (fileData) {
|
||||||
|
body.file_data = fileData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 C2C 单聊富媒体消息
|
* 发送 C2C 单聊富媒体消息
|
||||||
* @param accessToken 访问令牌
|
|
||||||
* @param openid 用户 openid
|
|
||||||
* @param fileInfo 从 uploadC2CMedia 获取的 file_info
|
|
||||||
* @param msgId 被动回复时需要的消息 ID
|
|
||||||
* @param content 可选的文字内容
|
|
||||||
*/
|
*/
|
||||||
export async function sendC2CMediaMessage(
|
export async function sendC2CMediaMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -330,11 +518,6 @@ export async function sendC2CMediaMessage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送群聊富媒体消息
|
* 发送群聊富媒体消息
|
||||||
* @param accessToken 访问令牌
|
|
||||||
* @param groupOpenid 群 openid
|
|
||||||
* @param fileInfo 从 uploadGroupMedia 获取的 file_info
|
|
||||||
* @param msgId 被动回复时需要的消息 ID
|
|
||||||
* @param content 可选的文字内容
|
|
||||||
*/
|
*/
|
||||||
export async function sendGroupMediaMessage(
|
export async function sendGroupMediaMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -355,11 +538,9 @@ export async function sendGroupMediaMessage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送带图片的 C2C 单聊消息(封装上传+发送)
|
* 发送带图片的 C2C 单聊消息(封装上传+发送)
|
||||||
* @param accessToken 访问令牌
|
* @param imageUrl - 图片来源,支持:
|
||||||
* @param openid 用户 openid
|
* - 公网 URL: https://example.com/image.png
|
||||||
* @param imageUrl 图片 URL
|
* - Base64 Data URL: data:image/png;base64,xxxxx
|
||||||
* @param msgId 被动回复时需要的消息 ID
|
|
||||||
* @param content 可选的文字内容
|
|
||||||
*/
|
*/
|
||||||
export async function sendC2CImageMessage(
|
export async function sendC2CImageMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -368,19 +549,32 @@ export async function sendC2CImageMessage(
|
|||||||
msgId?: string,
|
msgId?: string,
|
||||||
content?: string
|
content?: string
|
||||||
): Promise<{ id: string; timestamp: number }> {
|
): Promise<{ id: string; timestamp: number }> {
|
||||||
// 先上传图片获取 file_info
|
let uploadResult: UploadMediaResponse;
|
||||||
const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, false);
|
|
||||||
// 再发送富媒体消息
|
// 检查是否是 Base64 Data URL
|
||||||
|
if (imageUrl.startsWith("data:")) {
|
||||||
|
// 解析 Base64 Data URL: data:image/png;base64,xxxxx
|
||||||
|
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
|
if (!matches) {
|
||||||
|
throw new Error("Invalid Base64 Data URL format");
|
||||||
|
}
|
||||||
|
const base64Data = matches[2];
|
||||||
|
// 使用 file_data 上传
|
||||||
|
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, base64Data, false);
|
||||||
|
} else {
|
||||||
|
// 公网 URL,使用 url 参数上传
|
||||||
|
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送富媒体消息
|
||||||
return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
|
return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送带图片的群聊消息(封装上传+发送)
|
* 发送带图片的群聊消息(封装上传+发送)
|
||||||
* @param accessToken 访问令牌
|
* @param imageUrl - 图片来源,支持:
|
||||||
* @param groupOpenid 群 openid
|
* - 公网 URL: https://example.com/image.png
|
||||||
* @param imageUrl 图片 URL
|
* - Base64 Data URL: data:image/png;base64,xxxxx
|
||||||
* @param msgId 被动回复时需要的消息 ID
|
|
||||||
* @param content 可选的文字内容
|
|
||||||
*/
|
*/
|
||||||
export async function sendGroupImageMessage(
|
export async function sendGroupImageMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -389,8 +583,170 @@ export async function sendGroupImageMessage(
|
|||||||
msgId?: string,
|
msgId?: string,
|
||||||
content?: string
|
content?: string
|
||||||
): Promise<{ id: string; timestamp: string }> {
|
): Promise<{ id: string; timestamp: string }> {
|
||||||
// 先上传图片获取 file_info
|
let uploadResult: UploadMediaResponse;
|
||||||
const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, false);
|
|
||||||
// 再发送富媒体消息
|
// 检查是否是 Base64 Data URL
|
||||||
|
if (imageUrl.startsWith("data:")) {
|
||||||
|
// 解析 Base64 Data URL: data:image/png;base64,xxxxx
|
||||||
|
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
|
if (!matches) {
|
||||||
|
throw new Error("Invalid Base64 Data URL format");
|
||||||
|
}
|
||||||
|
const base64Data = matches[2];
|
||||||
|
// 使用 file_data 上传
|
||||||
|
uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, base64Data, false);
|
||||||
|
} else {
|
||||||
|
// 公网 URL,使用 url 参数上传
|
||||||
|
uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送富媒体消息
|
||||||
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
164
src/channel.ts
164
src/channel.ts
@@ -1,11 +1,51 @@
|
|||||||
import type { ChannelPlugin } from "clawdbot/plugin-sdk";
|
import {
|
||||||
|
type ChannelPlugin,
|
||||||
|
type OpenClawConfig,
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
} from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
import type { ResolvedQQBotAccount } from "./types.js";
|
import type { ResolvedQQBotAccount } from "./types.js";
|
||||||
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
|
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
|
||||||
import { sendText } from "./outbound.js";
|
import { sendText, sendMedia } from "./outbound.js";
|
||||||
import { startGateway } from "./gateway.js";
|
import { startGateway } from "./gateway.js";
|
||||||
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
||||||
|
import { getQQBotRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_ID = "default";
|
/**
|
||||||
|
* 简单的文本分块函数
|
||||||
|
* 用于预先分块长文本
|
||||||
|
*/
|
||||||
|
function chunkText(text: string, limit: number): string[] {
|
||||||
|
if (text.length <= limit) return [text];
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
if (remaining.length <= limit) {
|
||||||
|
chunks.push(remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试在换行处分割
|
||||||
|
let splitAt = remaining.lastIndexOf("\n", limit);
|
||||||
|
if (splitAt <= 0 || splitAt < limit * 0.5) {
|
||||||
|
// 没找到合适的换行,尝试在空格处分割
|
||||||
|
splitAt = remaining.lastIndexOf(" ", limit);
|
||||||
|
}
|
||||||
|
if (splitAt <= 0 || splitAt < limit * 0.5) {
|
||||||
|
// 还是没找到,强制在 limit 处分割
|
||||||
|
splitAt = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(remaining.slice(0, splitAt));
|
||||||
|
remaining = remaining.slice(splitAt).trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||||
id: "qqbot",
|
id: "qqbot",
|
||||||
@@ -19,9 +59,14 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
chatTypes: ["direct", "group"],
|
chatTypes: ["direct", "group"],
|
||||||
media: false,
|
media: true,
|
||||||
reactions: false,
|
reactions: false,
|
||||||
threads: false,
|
threads: false,
|
||||||
|
/**
|
||||||
|
* blockStreaming: true 表示该 Channel 支持块流式
|
||||||
|
* 框架会收集流式响应,然后通过 deliver 回调发送
|
||||||
|
*/
|
||||||
|
blockStreaming: false,
|
||||||
},
|
},
|
||||||
reload: { configPrefixes: ["channels.qqbot"] },
|
reload: { configPrefixes: ["channels.qqbot"] },
|
||||||
// CLI onboarding wizard
|
// CLI onboarding wizard
|
||||||
@@ -49,7 +94,24 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
|
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
|
||||||
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
|
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
|
||||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg),
|
||||||
|
// 新增:设置账户启用状态
|
||||||
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
|
setAccountEnabledInConfigSection({
|
||||||
|
cfg,
|
||||||
|
sectionKey: "qqbot",
|
||||||
|
accountId,
|
||||||
|
enabled,
|
||||||
|
allowTopLevel: true,
|
||||||
|
}),
|
||||||
|
// 新增:删除账户
|
||||||
|
deleteAccount: ({ cfg, accountId }) =>
|
||||||
|
deleteAccountFromConfigSection({
|
||||||
|
cfg,
|
||||||
|
sectionKey: "qqbot",
|
||||||
|
accountId,
|
||||||
|
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
|
||||||
|
}),
|
||||||
isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
|
isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||||
@@ -60,6 +122,16 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
// 新增:规范化账户 ID
|
||||||
|
resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
|
||||||
|
// 新增:应用账户名称
|
||||||
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
|
applyAccountNameToChannelSection({
|
||||||
|
cfg,
|
||||||
|
channelKey: "qqbot",
|
||||||
|
accountId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
validateInput: ({ input }) => {
|
validateInput: ({ input }) => {
|
||||||
if (!input.token && !input.tokenFile && !input.useEnv) {
|
if (!input.token && !input.tokenFile && !input.useEnv) {
|
||||||
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
||||||
@@ -87,8 +159,22 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 新增:消息目标解析
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: (target) => {
|
||||||
|
// 支持格式: qqbot:openid, qqbot:group:xxx, openid, group:xxx
|
||||||
|
const normalized = target.replace(/^qqbot:/i, "");
|
||||||
|
return { ok: true, to: normalized };
|
||||||
|
},
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: (id) => /^[A-F0-9]{32}$/i.test(id) || id.startsWith("group:") || id.startsWith("channel:"),
|
||||||
|
hint: "<openid> or group:<groupOpenid>",
|
||||||
|
},
|
||||||
|
},
|
||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
|
chunker: chunkText,
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
||||||
const account = resolveQQBotAccount(cfg, accountId);
|
const account = resolveQQBotAccount(cfg, accountId);
|
||||||
@@ -99,6 +185,15 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
error: result.error ? new Error(result.error) : undefined,
|
error: result.error ? new Error(result.error) : undefined,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
|
||||||
|
const account = resolveQQBotAccount(cfg, accountId);
|
||||||
|
const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
messageId: result.messageId,
|
||||||
|
error: result.error ? new Error(result.error) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
startAccount: async (ctx) => {
|
startAccount: async (ctx) => {
|
||||||
@@ -129,6 +224,48 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
// 新增:登出账户(清除配置中的凭证)
|
||||||
|
logoutAccount: async ({ accountId, cfg }) => {
|
||||||
|
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||||
|
const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
|
||||||
|
let cleared = false;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (nextQQBot) {
|
||||||
|
const qqbot = nextQQBot as Record<string, unknown>;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
|
||||||
|
delete qqbot.clientSecret;
|
||||||
|
cleared = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||||
|
if (accounts && accountId in accounts) {
|
||||||
|
const entry = accounts[accountId] as Record<string, unknown> | undefined;
|
||||||
|
if (entry && "clientSecret" in entry) {
|
||||||
|
delete entry.clientSecret;
|
||||||
|
cleared = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (entry && Object.keys(entry).length === 0) {
|
||||||
|
delete accounts[accountId];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed && nextQQBot) {
|
||||||
|
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
|
||||||
|
const runtime = getQQBotRuntime();
|
||||||
|
const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
|
||||||
|
await configApi.writeConfigFile(nextCfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
|
||||||
|
const loggedOut = resolved.secretSource === "none";
|
||||||
|
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
|
||||||
|
|
||||||
|
return { ok: true, cleared, envToken, loggedOut };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
defaultRuntime: {
|
defaultRuntime: {
|
||||||
@@ -137,8 +274,19 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
connected: false,
|
connected: false,
|
||||||
lastConnectedAt: null,
|
lastConnectedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
lastInboundAt: null,
|
||||||
|
lastOutboundAt: null,
|
||||||
},
|
},
|
||||||
buildAccountSnapshot: ({ account, runtime }) => ({
|
// 新增:构建通道摘要
|
||||||
|
buildChannelSummary: ({ snapshot }: { snapshot: Record<string, unknown> }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
tokenSource: snapshot.tokenSource ?? "none",
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
connected: snapshot.connected ?? false,
|
||||||
|
lastConnectedAt: snapshot.lastConnectedAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
}),
|
||||||
|
buildAccountSnapshot: ({ account, runtime }: { account?: ResolvedQQBotAccount; runtime?: Record<string, unknown> }) => ({
|
||||||
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||||
name: account?.name,
|
name: account?.name,
|
||||||
enabled: account?.enabled ?? false,
|
enabled: account?.enabled ?? false,
|
||||||
@@ -148,6 +296,8 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
connected: runtime?.connected ?? false,
|
connected: runtime?.connected ?? false,
|
||||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||||
lastError: runtime?.lastError ?? null,
|
lastError: runtime?.lastError ?? null,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
|
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
|
||||||
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_ID = "default";
|
export const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
interface MoltbotConfig {
|
|
||||||
channels?: {
|
|
||||||
qqbot?: QQBotChannelConfig;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QQBotChannelConfig extends QQBotAccountConfig {
|
interface QQBotChannelConfig extends QQBotAccountConfig {
|
||||||
accounts?: Record<string, QQBotAccountConfig>;
|
accounts?: Record<string, QQBotAccountConfig>;
|
||||||
@@ -17,9 +10,9 @@ interface QQBotChannelConfig extends QQBotAccountConfig {
|
|||||||
/**
|
/**
|
||||||
* 列出所有 QQBot 账户 ID
|
* 列出所有 QQBot 账户 ID
|
||||||
*/
|
*/
|
||||||
export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
|
export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
const qqbot = cfg.channels?.qqbot;
|
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||||
|
|
||||||
if (qqbot?.appId) {
|
if (qqbot?.appId) {
|
||||||
ids.add(DEFAULT_ACCOUNT_ID);
|
ids.add(DEFAULT_ACCOUNT_ID);
|
||||||
@@ -36,15 +29,34 @@ export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
|
|||||||
return Array.from(ids);
|
return Array.from(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认账户 ID
|
||||||
|
*/
|
||||||
|
export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
|
||||||
|
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||||
|
// 如果有默认账户配置,返回 default
|
||||||
|
if (qqbot?.appId) {
|
||||||
|
return DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
|
// 否则返回第一个配置的账户
|
||||||
|
if (qqbot?.accounts) {
|
||||||
|
const ids = Object.keys(qqbot.accounts);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
return ids[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 QQBot 账户配置
|
* 解析 QQBot 账户配置
|
||||||
*/
|
*/
|
||||||
export function resolveQQBotAccount(
|
export function resolveQQBotAccount(
|
||||||
cfg: MoltbotConfig,
|
cfg: OpenClawConfig,
|
||||||
accountId?: string | null
|
accountId?: string | null
|
||||||
): ResolvedQQBotAccount {
|
): ResolvedQQBotAccount {
|
||||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const qqbot = cfg.channels?.qqbot;
|
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||||
|
|
||||||
// 基础配置
|
// 基础配置
|
||||||
let accountConfig: QQBotAccountConfig = {};
|
let accountConfig: QQBotAccountConfig = {};
|
||||||
@@ -64,6 +76,7 @@ export function resolveQQBotAccount(
|
|||||||
allowFrom: qqbot?.allowFrom,
|
allowFrom: qqbot?.allowFrom,
|
||||||
systemPrompt: qqbot?.systemPrompt,
|
systemPrompt: qqbot?.systemPrompt,
|
||||||
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||||
|
markdownSupport: qqbot?.markdownSupport,
|
||||||
};
|
};
|
||||||
appId = qqbot?.appId ?? "";
|
appId = qqbot?.appId ?? "";
|
||||||
} else {
|
} else {
|
||||||
@@ -99,6 +112,7 @@ export function resolveQQBotAccount(
|
|||||||
secretSource,
|
secretSource,
|
||||||
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,
|
||||||
config: accountConfig,
|
config: accountConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,17 +121,17 @@ export function resolveQQBotAccount(
|
|||||||
* 应用账户配置
|
* 应用账户配置
|
||||||
*/
|
*/
|
||||||
export function applyQQBotAccountConfig(
|
export function applyQQBotAccountConfig(
|
||||||
cfg: MoltbotConfig,
|
cfg: OpenClawConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
|
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
|
||||||
): MoltbotConfig {
|
): OpenClawConfig {
|
||||||
const next = { ...cfg };
|
const next = { ...cfg };
|
||||||
|
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
next.channels = {
|
next.channels = {
|
||||||
...next.channels,
|
...next.channels,
|
||||||
qqbot: {
|
qqbot: {
|
||||||
...next.channels?.qqbot,
|
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...(input.appId ? { appId: input.appId } : {}),
|
...(input.appId ? { appId: input.appId } : {}),
|
||||||
...(input.clientSecret
|
...(input.clientSecret
|
||||||
@@ -133,12 +147,12 @@ export function applyQQBotAccountConfig(
|
|||||||
next.channels = {
|
next.channels = {
|
||||||
...next.channels,
|
...next.channels,
|
||||||
qqbot: {
|
qqbot: {
|
||||||
...next.channels?.qqbot,
|
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
|
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
|
||||||
[accountId]: {
|
[accountId]: {
|
||||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
|
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...(input.appId ? { appId: input.appId } : {}),
|
...(input.appId ? { appId: input.appId } : {}),
|
||||||
...(input.clientSecret
|
...(input.clientSecret
|
||||||
|
|||||||
1006
src/gateway.ts
1006
src/gateway.ts
File diff suppressed because it is too large
Load Diff
@@ -385,6 +385,34 @@ export function isImageServerRunning(): boolean {
|
|||||||
return serverInstance !== null;
|
return serverInstance !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保图床服务器正在运行
|
||||||
|
* 如果未运行,则自动启动
|
||||||
|
* @param publicBaseUrl 公网访问的基础 URL(如 http://your-server:18765)
|
||||||
|
* @returns 基础 URL,启动失败返回 null
|
||||||
|
*/
|
||||||
|
export async function ensureImageServer(publicBaseUrl?: string): Promise<string | null> {
|
||||||
|
if (isImageServerRunning()) {
|
||||||
|
return publicBaseUrl || currentConfig.baseUrl || `http://0.0.0.0:${currentConfig.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config: Partial<ImageServerConfig> = {
|
||||||
|
port: DEFAULT_CONFIG.port,
|
||||||
|
storageDir: DEFAULT_CONFIG.storageDir,
|
||||||
|
// 使用用户配置的公网地址
|
||||||
|
baseUrl: publicBaseUrl || `http://0.0.0.0:${DEFAULT_CONFIG.port}`,
|
||||||
|
ttlSeconds: 3600, // 1 小时过期
|
||||||
|
};
|
||||||
|
await startImageServer(config);
|
||||||
|
console.log(`[image-server] Auto-started on port ${config.port}, baseUrl: ${config.baseUrl}`);
|
||||||
|
return config.baseUrl!;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[image-server] Failed to auto-start: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载远程文件并保存到本地
|
* 下载远程文件并保存到本地
|
||||||
* @param url 远程文件 URL
|
* @param url 远程文件 URL
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* QQBot CLI Onboarding Adapter
|
* QQBot CLI Onboarding Adapter
|
||||||
*
|
*
|
||||||
* 提供 moltbot onboard 命令的交互式配置支持
|
* 提供 openclaw onboard 命令的交互式配置支持
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
ChannelOnboardingAdapter,
|
ChannelOnboardingAdapter,
|
||||||
@@ -9,20 +9,11 @@ import type {
|
|||||||
ChannelOnboardingStatusContext,
|
ChannelOnboardingStatusContext,
|
||||||
ChannelOnboardingConfigureContext,
|
ChannelOnboardingConfigureContext,
|
||||||
ChannelOnboardingResult,
|
ChannelOnboardingResult,
|
||||||
} from "clawdbot/plugin-sdk";
|
OpenClawConfig,
|
||||||
import { listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
|
} from "openclaw/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
|
||||||
const DEFAULT_ACCOUNT_ID = "default";
|
|
||||||
|
|
||||||
// 内部类型(避免循环依赖)
|
|
||||||
interface MoltbotConfig {
|
|
||||||
channels?: {
|
|
||||||
qqbot?: QQBotChannelConfig;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 内部类型(用于类型安全)
|
||||||
interface QQBotChannelConfig {
|
interface QQBotChannelConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
@@ -40,10 +31,18 @@ interface QQBotChannelConfig {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prompter 类型定义
|
||||||
|
interface Prompter {
|
||||||
|
note: (message: string, title?: string) => Promise<void>;
|
||||||
|
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
|
||||||
|
text: (opts: { message: string; placeholder?: string; initialValue?: string; validate?: (value: string) => string | undefined }) => Promise<string>;
|
||||||
|
select: <T>(opts: { message: string; options: Array<{ value: T; label: string }>; initialValue?: T }) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析默认账户 ID
|
* 解析默认账户 ID
|
||||||
*/
|
*/
|
||||||
function resolveDefaultQQBotAccountId(cfg: MoltbotConfig): string {
|
function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
|
||||||
const ids = listQQBotAccountIds(cfg);
|
const ids = listQQBotAccountIds(cfg);
|
||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
@@ -55,32 +54,34 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
channel: "qqbot" as any,
|
channel: "qqbot" as any,
|
||||||
|
|
||||||
getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
|
getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
|
||||||
const { cfg } = ctx;
|
const cfg = ctx.cfg as OpenClawConfig;
|
||||||
const configured = listQQBotAccountIds(cfg as MoltbotConfig).some((accountId) => {
|
const configured = listQQBotAccountIds(cfg).some((accountId) => {
|
||||||
const account = resolveQQBotAccount(cfg as MoltbotConfig, accountId);
|
const account = resolveQQBotAccount(cfg, accountId);
|
||||||
return Boolean(account.appId && account.clientSecret);
|
return Boolean(account.appId && account.clientSecret);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: "qqbot" as any,
|
channel: "qqbot" as any,
|
||||||
configured,
|
configured,
|
||||||
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
|
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
|
||||||
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊",
|
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊(流式消息)",
|
||||||
quickstartScore: configured ? 1 : 20,
|
quickstartScore: configured ? 1 : 20,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
|
configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
|
||||||
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
|
const cfg = ctx.cfg as OpenClawConfig;
|
||||||
const moltbotCfg = cfg as MoltbotConfig;
|
const prompter = ctx.prompter as Prompter;
|
||||||
|
const accountOverrides = ctx.accountOverrides as Record<string, string> | undefined;
|
||||||
|
const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
|
||||||
|
|
||||||
const qqbotOverride = (accountOverrides as Record<string, string>).qqbot?.trim();
|
const qqbotOverride = accountOverrides?.qqbot?.trim();
|
||||||
const defaultAccountId = resolveDefaultQQBotAccountId(moltbotCfg);
|
const defaultAccountId = resolveDefaultQQBotAccountId(cfg);
|
||||||
let accountId = qqbotOverride ?? defaultAccountId;
|
let accountId = qqbotOverride ?? defaultAccountId;
|
||||||
|
|
||||||
// 是否需要提示选择账户
|
// 是否需要提示选择账户
|
||||||
if (shouldPromptAccountIds && !qqbotOverride) {
|
if (shouldPromptAccountIds && !qqbotOverride) {
|
||||||
const existingIds = listQQBotAccountIds(moltbotCfg);
|
const existingIds = listQQBotAccountIds(cfg);
|
||||||
if (existingIds.length > 1) {
|
if (existingIds.length > 1) {
|
||||||
accountId = await prompter.select({
|
accountId = await prompter.select({
|
||||||
message: "选择 QQBot 账户",
|
message: "选择 QQBot 账户",
|
||||||
@@ -93,7 +94,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = moltbotCfg;
|
let next: OpenClawConfig = cfg;
|
||||||
const resolvedAccount = resolveQQBotAccount(next, accountId);
|
const resolvedAccount = resolveQQBotAccount(next, accountId);
|
||||||
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
|
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
|
||||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
@@ -115,8 +116,10 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
"4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
|
"4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
|
||||||
"",
|
"",
|
||||||
"文档: https://bot.q.qq.com/wiki/",
|
"文档: https://bot.q.qq.com/wiki/",
|
||||||
|
"",
|
||||||
|
"此版本支持流式消息发送!",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"QQ Bot 配置",
|
"QQ Bot 配置",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +135,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
channels: {
|
channels: {
|
||||||
...next.channels,
|
...next.channels,
|
||||||
qqbot: {
|
qqbot: {
|
||||||
...next.channels?.qqbot,
|
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -144,14 +147,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
message: "请输入 QQ Bot AppID",
|
message: "请输入 QQ Bot AppID",
|
||||||
placeholder: "例如: 102146862",
|
placeholder: "例如: 102146862",
|
||||||
initialValue: resolvedAccount.appId || undefined,
|
initialValue: resolvedAccount.appId || undefined,
|
||||||
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
clientSecret = String(
|
clientSecret = String(
|
||||||
await prompter.text({
|
await prompter.text({
|
||||||
message: "请输入 QQ Bot ClientSecret",
|
message: "请输入 QQ Bot ClientSecret",
|
||||||
placeholder: "你的 ClientSecret",
|
placeholder: "你的 ClientSecret",
|
||||||
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
@@ -167,14 +170,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
message: "请输入 QQ Bot AppID",
|
message: "请输入 QQ Bot AppID",
|
||||||
placeholder: "例如: 102146862",
|
placeholder: "例如: 102146862",
|
||||||
initialValue: resolvedAccount.appId || undefined,
|
initialValue: resolvedAccount.appId || undefined,
|
||||||
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
clientSecret = String(
|
clientSecret = String(
|
||||||
await prompter.text({
|
await prompter.text({
|
||||||
message: "请输入 QQ Bot ClientSecret",
|
message: "请输入 QQ Bot ClientSecret",
|
||||||
placeholder: "你的 ClientSecret",
|
placeholder: "你的 ClientSecret",
|
||||||
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
@@ -185,14 +188,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
message: "请输入 QQ Bot AppID",
|
message: "请输入 QQ Bot AppID",
|
||||||
placeholder: "例如: 102146862",
|
placeholder: "例如: 102146862",
|
||||||
initialValue: resolvedAccount.appId || undefined,
|
initialValue: resolvedAccount.appId || undefined,
|
||||||
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
clientSecret = String(
|
clientSecret = String(
|
||||||
await prompter.text({
|
await prompter.text({
|
||||||
message: "请输入 QQ Bot ClientSecret",
|
message: "请输入 QQ Bot ClientSecret",
|
||||||
placeholder: "你的 ClientSecret",
|
placeholder: "你的 ClientSecret",
|
||||||
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
@@ -205,7 +208,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
channels: {
|
channels: {
|
||||||
...next.channels,
|
...next.channels,
|
||||||
qqbot: {
|
qqbot: {
|
||||||
...next.channels?.qqbot,
|
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
appId,
|
appId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
@@ -218,12 +221,12 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
channels: {
|
channels: {
|
||||||
...next.channels,
|
...next.channels,
|
||||||
qqbot: {
|
qqbot: {
|
||||||
...next.channels?.qqbot,
|
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
|
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
|
||||||
[accountId]: {
|
[accountId]: {
|
||||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
|
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
appId,
|
appId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
@@ -235,14 +238,17 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cfg: next as any, accountId };
|
return { success: true, cfg: next as any, accountId };
|
||||||
},
|
},
|
||||||
|
|
||||||
disable: (cfg) => ({
|
disable: (cfg: unknown) => {
|
||||||
...cfg,
|
const config = cfg as OpenClawConfig;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
channels: {
|
channels: {
|
||||||
...(cfg as MoltbotConfig).channels,
|
...config.channels,
|
||||||
qqbot: { ...(cfg as MoltbotConfig).channels?.qqbot, enabled: false },
|
qqbot: { ...(config.channels?.qqbot as Record<string, unknown> || {}), enabled: false },
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
},
|
},
|
||||||
}) as any,
|
|
||||||
};
|
};
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
442
src/outbound.ts
442
src/outbound.ts
@@ -1,4 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* QQ Bot 消息发送模块
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
import type { ResolvedQQBotAccount } from "./types.js";
|
import type { ResolvedQQBotAccount } from "./types.js";
|
||||||
|
import { decodeCronPayload } from "./utils/payload.js";
|
||||||
import {
|
import {
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
sendC2CMessage,
|
sendC2CMessage,
|
||||||
@@ -6,8 +13,137 @@ import {
|
|||||||
sendGroupMessage,
|
sendGroupMessage,
|
||||||
sendProactiveC2CMessage,
|
sendProactiveC2CMessage,
|
||||||
sendProactiveGroupMessage,
|
sendProactiveGroupMessage,
|
||||||
|
sendC2CImageMessage,
|
||||||
|
sendGroupImageMessage,
|
||||||
} 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;
|
||||||
@@ -16,6 +152,10 @@ export interface OutboundContext {
|
|||||||
account: ResolvedQQBotAccount;
|
account: ResolvedQQBotAccount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaOutboundContext extends OutboundContext {
|
||||||
|
mediaUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutboundResult {
|
export interface OutboundResult {
|
||||||
channel: string;
|
channel: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@@ -26,10 +166,10 @@ export interface OutboundResult {
|
|||||||
/**
|
/**
|
||||||
* 解析目标地址
|
* 解析目标地址
|
||||||
* 格式:
|
* 格式:
|
||||||
* - c2c:xxx -> C2C 单聊
|
* - openid (32位十六进制) -> C2C 单聊
|
||||||
* - group:xxx -> 群聊
|
* - group:xxx -> 群聊
|
||||||
* - channel:xxx -> 频道
|
* - channel:xxx -> 频道
|
||||||
* - 无前缀 -> 默认当作 C2C 单聊
|
* - 纯数字 -> 频道
|
||||||
*/
|
*/
|
||||||
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
|
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
|
||||||
// 去掉 qqbot: 前缀
|
// 去掉 qqbot: 前缀
|
||||||
@@ -50,14 +190,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)" };
|
||||||
}
|
}
|
||||||
@@ -85,12 +272,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) {
|
||||||
@@ -135,3 +328,244 @@ export async function sendProactiveMessage(
|
|||||||
return { channel: "qqbot", error: message };
|
return { channel: "qqbot", error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送富媒体消息(图片)
|
||||||
|
*
|
||||||
|
* 支持以下 mediaUrl 格式:
|
||||||
|
* - 公网 URL: https://example.com/image.png
|
||||||
|
* - Base64 Data URL: data:image/png;base64,xxxxx
|
||||||
|
* - 本地文件路径: /path/to/image.png(自动读取并转换为 Base64)
|
||||||
|
*
|
||||||
|
* @param ctx - 发送上下文,包含 mediaUrl
|
||||||
|
* @returns 发送结果
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 发送网络图片
|
||||||
|
* const result = await sendMedia({
|
||||||
|
* to: "group:xxx",
|
||||||
|
* text: "这是图片说明",
|
||||||
|
* mediaUrl: "https://example.com/image.png",
|
||||||
|
* account,
|
||||||
|
* replyToId: msgId,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 发送 Base64 图片
|
||||||
|
* const result = await sendMedia({
|
||||||
|
* to: "group:xxx",
|
||||||
|
* text: "这是图片说明",
|
||||||
|
* mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
|
||||||
|
* account,
|
||||||
|
* replyToId: msgId,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 发送本地文件(自动读取并转换为 Base64)
|
||||||
|
* const result = await sendMedia({
|
||||||
|
* to: "group:xxx",
|
||||||
|
* text: "这是图片说明",
|
||||||
|
* mediaUrl: "/tmp/generated-chart.png",
|
||||||
|
* account,
|
||||||
|
* replyToId: msgId,
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
||||||
|
const { to, text, replyToId, account } = ctx;
|
||||||
|
const { mediaUrl } = ctx;
|
||||||
|
|
||||||
|
if (!account.appId || !account.clientSecret) {
|
||||||
|
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mediaUrl) {
|
||||||
|
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 mediaUrl 格式:支持公网 URL、Base64 Data URL 或本地文件路径
|
||||||
|
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
|
||||||
|
const isDataUrl = mediaUrl.startsWith("data:");
|
||||||
|
const isLocalPath = mediaUrl.startsWith("/") ||
|
||||||
|
/^[a-zA-Z]:[\\/]/.test(mediaUrl) ||
|
||||||
|
mediaUrl.startsWith("./") ||
|
||||||
|
mediaUrl.startsWith("../");
|
||||||
|
|
||||||
|
// 处理本地文件路径:读取文件并转换为 Base64 Data URL
|
||||||
|
let processedMediaUrl = mediaUrl;
|
||||||
|
|
||||||
|
if (isLocalPath) {
|
||||||
|
console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!fs.existsSync(mediaUrl)) {
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
error: `本地文件不存在: ${mediaUrl}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
const fileBuffer = fs.readFileSync(mediaUrl);
|
||||||
|
const base64Data = fileBuffer.toString("base64");
|
||||||
|
|
||||||
|
// 根据文件扩展名确定 MIME 类型
|
||||||
|
const ext = path.extname(mediaUrl).toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mimeType = mimeTypes[ext];
|
||||||
|
if (!mimeType) {
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造 Data URL
|
||||||
|
processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
|
||||||
|
console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);
|
||||||
|
|
||||||
|
} catch (readErr) {
|
||||||
|
const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
|
||||||
|
console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
error: `读取本地文件失败: ${errMsg}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (!isHttpUrl && !isDataUrl) {
|
||||||
|
console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
error: `不支持的图片格式: ${mediaUrl.slice(0, 50)}...。支持的格式: 公网 URL (http/https)、Base64 Data URL (data:image/...) 或本地文件路径。`
|
||||||
|
};
|
||||||
|
} else if (isDataUrl) {
|
||||||
|
console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||||
|
const target = parseTarget(to);
|
||||||
|
|
||||||
|
// 先发送图片(使用处理后的 URL,可能是 Base64 Data URL)
|
||||||
|
let imageResult: { id: string; timestamp: number | string };
|
||||||
|
if (target.type === "c2c") {
|
||||||
|
imageResult = await sendC2CImageMessage(
|
||||||
|
accessToken,
|
||||||
|
target.id,
|
||||||
|
processedMediaUrl,
|
||||||
|
replyToId ?? undefined,
|
||||||
|
undefined // content 参数,图片消息不支持同时带文本
|
||||||
|
);
|
||||||
|
} else if (target.type === "group") {
|
||||||
|
imageResult = await sendGroupImageMessage(
|
||||||
|
accessToken,
|
||||||
|
target.id,
|
||||||
|
processedMediaUrl,
|
||||||
|
replyToId ?? undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 频道暂不支持富媒体消息,只发送文本 + URL(本地文件路径无法在频道展示)
|
||||||
|
const displayUrl = isLocalPath ? "[本地文件]" : mediaUrl;
|
||||||
|
const textWithUrl = text ? `${text}\n${displayUrl}` : displayUrl;
|
||||||
|
const result = await sendChannelMessage(accessToken, target.id, textWithUrl, replyToId ?? undefined);
|
||||||
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有文本说明,再发送一条文本消息
|
||||||
|
if (text?.trim()) {
|
||||||
|
try {
|
||||||
|
if (target.type === "c2c") {
|
||||||
|
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
||||||
|
} else if (target.type === "group") {
|
||||||
|
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
||||||
|
}
|
||||||
|
} catch (textErr) {
|
||||||
|
// 文本发送失败不影响整体结果,图片已发送成功
|
||||||
|
console.error(`[qqbot] Failed to send text after image: ${textErr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { channel: "qqbot", error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 Cron 触发的消息
|
||||||
|
*
|
||||||
|
* 当 OpenClaw cron 任务触发时,消息内容可能是:
|
||||||
|
* 1. QQBOT_CRON:{base64} 格式的结构化载荷 - 解码后根据 targetType 和 targetAddress 发送
|
||||||
|
* 2. 普通文本 - 直接发送到指定目标
|
||||||
|
*
|
||||||
|
* @param account - 账户配置
|
||||||
|
* @param to - 目标地址(作为后备,如果载荷中没有指定)
|
||||||
|
* @param message - 消息内容(可能是 QQBOT_CRON: 格式或普通文本)
|
||||||
|
* @returns 发送结果
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 处理结构化载荷
|
||||||
|
* const result = await sendCronMessage(
|
||||||
|
* account,
|
||||||
|
* "user_openid", // 后备地址
|
||||||
|
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." // Base64 编码的载荷
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // 处理普通文本
|
||||||
|
* const result = await sendCronMessage(
|
||||||
|
* account,
|
||||||
|
* "user_openid",
|
||||||
|
* "这是一条普通的提醒消息"
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function sendCronMessage(
|
||||||
|
account: ResolvedQQBotAccount,
|
||||||
|
to: string,
|
||||||
|
message: string
|
||||||
|
): Promise<OutboundResult> {
|
||||||
|
console.log(`[qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
|
||||||
|
|
||||||
|
// 检测是否是 QQBOT_CRON: 格式的结构化载荷
|
||||||
|
const cronResult = decodeCronPayload(message);
|
||||||
|
|
||||||
|
if (cronResult.isCronPayload) {
|
||||||
|
if (cronResult.error) {
|
||||||
|
console.error(`[qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`);
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
error: `Cron 载荷解码失败: ${cronResult.error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cronResult.payload) {
|
||||||
|
const payload = cronResult.payload;
|
||||||
|
console.log(`[qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}`);
|
||||||
|
|
||||||
|
// 使用载荷中的目标地址和类型发送消息
|
||||||
|
const targetTo = payload.targetType === "group"
|
||||||
|
? `group:${payload.targetAddress}`
|
||||||
|
: payload.targetAddress;
|
||||||
|
|
||||||
|
// 发送提醒内容
|
||||||
|
return await sendProactiveMessage(account, targetTo, payload.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非结构化载荷,作为普通文本处理
|
||||||
|
console.log(`[qqbot] sendCronMessage: plain text message, sending to ${to}`);
|
||||||
|
return await sendProactiveMessage(account, to, message);
|
||||||
|
}
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
let runtime: PluginRuntime | null = null;
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export interface ResolvedQQBotAccount {
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
/** 图床服务器公网地址 */
|
/** 图床服务器公网地址 */
|
||||||
imageServerBaseUrl?: string;
|
imageServerBaseUrl?: string;
|
||||||
|
/** 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用) */
|
||||||
|
markdownSupport?: boolean;
|
||||||
config: QQBotAccountConfig;
|
config: QQBotAccountConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@ export interface QQBotAccountConfig {
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
|
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
|
||||||
imageServerBaseUrl?: string;
|
imageServerBaseUrl?: string;
|
||||||
|
/** 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用) */
|
||||||
|
markdownSupport?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
266
src/utils/image-size.ts
Normal file
266
src/utils/image-size.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* 图片尺寸工具
|
||||||
|
* 用于获取图片尺寸,生成 QQBot 的 markdown 图片格式
|
||||||
|
*
|
||||||
|
* QQBot markdown 图片格式: 
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from "buffer";
|
||||||
|
|
||||||
|
export interface ImageSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认图片尺寸(当无法获取时使用) */
|
||||||
|
export const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 PNG 文件头解析图片尺寸
|
||||||
|
* PNG 文件头结构: 前 8 字节是签名,IHDR 块从第 8 字节开始
|
||||||
|
* IHDR 块: 长度(4) + 类型(4, "IHDR") + 宽度(4) + 高度(4) + ...
|
||||||
|
*/
|
||||||
|
function parsePngSize(buffer: Buffer): ImageSize | null {
|
||||||
|
// PNG 签名: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (buffer.length < 24) return null;
|
||||||
|
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// IHDR 块从第 8 字节开始,宽度在第 16-19 字节,高度在第 20-23 字节
|
||||||
|
const width = buffer.readUInt32BE(16);
|
||||||
|
const height = buffer.readUInt32BE(20);
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 JPEG 文件解析图片尺寸
|
||||||
|
* JPEG 尺寸在 SOF0/SOF2 块中
|
||||||
|
*/
|
||||||
|
function parseJpegSize(buffer: Buffer): ImageSize | null {
|
||||||
|
// JPEG 签名: FF D8 FF
|
||||||
|
if (buffer.length < 4) return null;
|
||||||
|
if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 2;
|
||||||
|
while (offset < buffer.length - 9) {
|
||||||
|
if (buffer[offset] !== 0xFF) {
|
||||||
|
offset++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = buffer[offset + 1];
|
||||||
|
// SOF0 (0xC0) 或 SOF2 (0xC2) 包含图片尺寸
|
||||||
|
if (marker === 0xC0 || marker === 0xC2) {
|
||||||
|
// 格式: FF C0 长度(2) 精度(1) 高度(2) 宽度(2)
|
||||||
|
if (offset + 9 <= buffer.length) {
|
||||||
|
const height = buffer.readUInt16BE(offset + 5);
|
||||||
|
const width = buffer.readUInt16BE(offset + 7);
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过当前块
|
||||||
|
if (offset + 3 < buffer.length) {
|
||||||
|
const blockLength = buffer.readUInt16BE(offset + 2);
|
||||||
|
offset += 2 + blockLength;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 GIF 文件头解析图片尺寸
|
||||||
|
* GIF 文件头: GIF87a 或 GIF89a (6字节) + 宽度(2) + 高度(2)
|
||||||
|
*/
|
||||||
|
function parseGifSize(buffer: Buffer): ImageSize | null {
|
||||||
|
if (buffer.length < 10) return null;
|
||||||
|
const signature = buffer.toString("ascii", 0, 6);
|
||||||
|
if (signature !== "GIF87a" && signature !== "GIF89a") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const width = buffer.readUInt16LE(6);
|
||||||
|
const height = buffer.readUInt16LE(8);
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 WebP 文件解析图片尺寸
|
||||||
|
* WebP 文件头: RIFF(4) + 文件大小(4) + WEBP(4) + VP8/VP8L/VP8X(4) + ...
|
||||||
|
*/
|
||||||
|
function parseWebpSize(buffer: Buffer): ImageSize | null {
|
||||||
|
if (buffer.length < 30) return null;
|
||||||
|
|
||||||
|
// 检查 RIFF 和 WEBP 签名
|
||||||
|
const riff = buffer.toString("ascii", 0, 4);
|
||||||
|
const webp = buffer.toString("ascii", 8, 12);
|
||||||
|
if (riff !== "RIFF" || webp !== "WEBP") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkType = buffer.toString("ascii", 12, 16);
|
||||||
|
|
||||||
|
// VP8 (有损压缩)
|
||||||
|
if (chunkType === "VP8 ") {
|
||||||
|
// VP8 帧头从第 23 字节开始,检查签名 9D 01 2A
|
||||||
|
if (buffer.length >= 30 && buffer[23] === 0x9D && buffer[24] === 0x01 && buffer[25] === 0x2A) {
|
||||||
|
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
||||||
|
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VP8L (无损压缩)
|
||||||
|
if (chunkType === "VP8L") {
|
||||||
|
// VP8L 签名: 0x2F
|
||||||
|
if (buffer.length >= 25 && buffer[20] === 0x2F) {
|
||||||
|
const bits = buffer.readUInt32LE(21);
|
||||||
|
const width = (bits & 0x3FFF) + 1;
|
||||||
|
const height = ((bits >> 14) & 0x3FFF) + 1;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VP8X (扩展格式)
|
||||||
|
if (chunkType === "VP8X") {
|
||||||
|
if (buffer.length >= 30) {
|
||||||
|
// 宽度和高度在第 24-26 和 27-29 字节(24位小端)
|
||||||
|
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
|
||||||
|
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从图片数据 Buffer 解析尺寸
|
||||||
|
*/
|
||||||
|
export function parseImageSize(buffer: Buffer): ImageSize | null {
|
||||||
|
// 尝试各种格式
|
||||||
|
return parsePngSize(buffer)
|
||||||
|
?? parseJpegSize(buffer)
|
||||||
|
?? parseGifSize(buffer)
|
||||||
|
?? parseWebpSize(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从公网 URL 获取图片尺寸
|
||||||
|
* 只下载前 64KB 数据,足够解析大部分图片格式的头部
|
||||||
|
*/
|
||||||
|
export async function getImageSizeFromUrl(url: string, timeoutMs = 5000): Promise<ImageSize | null> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
// 使用 Range 请求只获取前 64KB
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"Range": "bytes=0-65535",
|
||||||
|
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 206) {
|
||||||
|
console.log(`[image-size] Failed to fetch ${url}: ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
const size = parseImageSize(buffer);
|
||||||
|
if (size) {
|
||||||
|
console.log(`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[image-size] Error fetching ${url.slice(0, 60)}...: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Base64 Data URL 获取图片尺寸
|
||||||
|
*/
|
||||||
|
export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null {
|
||||||
|
try {
|
||||||
|
// 格式: data:image/png;base64,xxxxx
|
||||||
|
const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
|
||||||
|
if (!matches) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Data = matches[1];
|
||||||
|
const buffer = Buffer.from(base64Data, "base64");
|
||||||
|
|
||||||
|
const size = parseImageSize(buffer);
|
||||||
|
if (size) {
|
||||||
|
console.log(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[image-size] Error parsing Base64: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图片尺寸(自动判断来源)
|
||||||
|
* @param source - 图片 URL 或 Base64 Data URL
|
||||||
|
* @returns 图片尺寸,失败返回 null
|
||||||
|
*/
|
||||||
|
export async function getImageSize(source: string): Promise<ImageSize | null> {
|
||||||
|
if (source.startsWith("data:")) {
|
||||||
|
return getImageSizeFromDataUrl(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.startsWith("http://") || source.startsWith("https://")) {
|
||||||
|
return getImageSizeFromUrl(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 QQBot markdown 图片格式
|
||||||
|
* 格式: 
|
||||||
|
*
|
||||||
|
* @param url - 图片 URL
|
||||||
|
* @param size - 图片尺寸,如果为 null 则使用默认尺寸
|
||||||
|
* @returns QQBot markdown 图片字符串
|
||||||
|
*/
|
||||||
|
export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string {
|
||||||
|
const { width, height } = size ?? DEFAULT_IMAGE_SIZE;
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 markdown 图片是否已经包含 QQBot 格式的尺寸信息
|
||||||
|
* 格式: 
|
||||||
|
*/
|
||||||
|
export function hasQQBotImageSize(markdownImage: string): boolean {
|
||||||
|
return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从已有的 QQBot 格式 markdown 图片中提取尺寸
|
||||||
|
* 格式: 
|
||||||
|
*/
|
||||||
|
export function extractQQBotImageSize(markdownImage: string): ImageSize | null {
|
||||||
|
const match = markdownImage.match(/!\[#(\d+)px\s+#(\d+)px\]/);
|
||||||
|
if (match) {
|
||||||
|
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
265
src/utils/payload.ts
Normal file
265
src/utils/payload.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* QQBot 结构化消息载荷工具
|
||||||
|
*
|
||||||
|
* 用于处理 AI 输出的结构化消息载荷,包括:
|
||||||
|
* - 定时提醒载荷 (cron_reminder)
|
||||||
|
* - 媒体消息载荷 (media)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时提醒载荷
|
||||||
|
*/
|
||||||
|
export interface CronReminderPayload {
|
||||||
|
type: 'cron_reminder';
|
||||||
|
/** 提醒内容 */
|
||||||
|
content: string;
|
||||||
|
/** 目标类型:c2c (私聊) 或 group (群聊) */
|
||||||
|
targetType: 'c2c' | 'group';
|
||||||
|
/** 目标地址:user_openid 或 group_openid */
|
||||||
|
targetAddress: string;
|
||||||
|
/** 原始消息 ID(可选) */
|
||||||
|
originalMessageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体消息载荷
|
||||||
|
*/
|
||||||
|
export interface MediaPayload {
|
||||||
|
type: 'media';
|
||||||
|
/** 媒体类型:image, audio, video */
|
||||||
|
mediaType: 'image' | 'audio' | 'video';
|
||||||
|
/** 来源类型:url 或 file */
|
||||||
|
source: 'url' | 'file';
|
||||||
|
/** 媒体路径或 URL */
|
||||||
|
path: string;
|
||||||
|
/** 媒体描述(可选) */
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QQBot 载荷联合类型
|
||||||
|
*/
|
||||||
|
export type QQBotPayload = CronReminderPayload | MediaPayload;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析结果
|
||||||
|
*/
|
||||||
|
export interface ParseResult {
|
||||||
|
/** 是否为结构化载荷 */
|
||||||
|
isPayload: boolean;
|
||||||
|
/** 解析后的载荷对象(如果是结构化载荷) */
|
||||||
|
payload?: QQBotPayload;
|
||||||
|
/** 原始文本(如果不是结构化载荷) */
|
||||||
|
text?: string;
|
||||||
|
/** 解析错误信息(如果解析失败) */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 常量定义
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** AI 输出的结构化载荷前缀 */
|
||||||
|
const PAYLOAD_PREFIX = 'QQBOT_PAYLOAD:';
|
||||||
|
|
||||||
|
/** Cron 消息存储的前缀 */
|
||||||
|
const CRON_PREFIX = 'QQBOT_CRON:';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 解析函数
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 AI 输出的结构化载荷
|
||||||
|
*
|
||||||
|
* 检测消息是否以 QQBOT_PAYLOAD: 前缀开头,如果是则提取并解析 JSON
|
||||||
|
*
|
||||||
|
* @param text AI 输出的原始文本
|
||||||
|
* @returns 解析结果
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = parseQQBotPayload('QQBOT_PAYLOAD:\n{"type": "media", "mediaType": "image", ...}');
|
||||||
|
* if (result.isPayload && result.payload) {
|
||||||
|
* // 处理结构化载荷
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function parseQQBotPayload(text: string): ParseResult {
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
|
||||||
|
// 检查是否以 QQBOT_PAYLOAD: 开头
|
||||||
|
if (!trimmedText.startsWith(PAYLOAD_PREFIX)) {
|
||||||
|
return {
|
||||||
|
isPayload: false,
|
||||||
|
text: text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 JSON 内容(去掉前缀)
|
||||||
|
const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim();
|
||||||
|
|
||||||
|
if (!jsonContent) {
|
||||||
|
return {
|
||||||
|
isPayload: true,
|
||||||
|
error: '载荷内容为空'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(jsonContent) as QQBotPayload;
|
||||||
|
|
||||||
|
// 验证必要字段
|
||||||
|
if (!payload.type) {
|
||||||
|
return {
|
||||||
|
isPayload: true,
|
||||||
|
error: '载荷缺少 type 字段'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 type 进行额外验证
|
||||||
|
if (payload.type === 'cron_reminder') {
|
||||||
|
if (!payload.content || !payload.targetType || !payload.targetAddress) {
|
||||||
|
return {
|
||||||
|
isPayload: true,
|
||||||
|
error: 'cron_reminder 载荷缺少必要字段 (content, targetType, targetAddress)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (payload.type === 'media') {
|
||||||
|
if (!payload.mediaType || !payload.source || !payload.path) {
|
||||||
|
return {
|
||||||
|
isPayload: true,
|
||||||
|
error: 'media 载荷缺少必要字段 (mediaType, source, path)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPayload: true,
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
isPayload: true,
|
||||||
|
error: `JSON 解析失败: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Cron 编码/解码函数
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将定时提醒载荷编码为 Cron 消息格式
|
||||||
|
*
|
||||||
|
* 将 JSON 编码为 Base64,并添加 QQBOT_CRON: 前缀
|
||||||
|
*
|
||||||
|
* @param payload 定时提醒载荷
|
||||||
|
* @returns 编码后的消息字符串,格式为 QQBOT_CRON:{base64}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const message = encodePayloadForCron({
|
||||||
|
* type: 'cron_reminder',
|
||||||
|
* content: '喝水时间到!',
|
||||||
|
* targetType: 'c2c',
|
||||||
|
* targetAddress: 'user_openid_xxx'
|
||||||
|
* });
|
||||||
|
* // 返回: QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...
|
||||||
|
*/
|
||||||
|
export function encodePayloadForCron(payload: CronReminderPayload): string {
|
||||||
|
const jsonString = JSON.stringify(payload);
|
||||||
|
const base64 = Buffer.from(jsonString, 'utf-8').toString('base64');
|
||||||
|
return `${CRON_PREFIX}${base64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解码 Cron 消息中的载荷
|
||||||
|
*
|
||||||
|
* 检测 QQBOT_CRON: 前缀,解码 Base64 并解析 JSON
|
||||||
|
*
|
||||||
|
* @param message Cron 触发时收到的消息
|
||||||
|
* @returns 解码结果,包含是否为 Cron 载荷、解析后的载荷对象或错误信息
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = decodeCronPayload('QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...');
|
||||||
|
* if (result.isCronPayload && result.payload) {
|
||||||
|
* // 处理定时提醒
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function decodeCronPayload(message: string): {
|
||||||
|
isCronPayload: boolean;
|
||||||
|
payload?: CronReminderPayload;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const trimmedMessage = message.trim();
|
||||||
|
|
||||||
|
// 检查是否以 QQBOT_CRON: 开头
|
||||||
|
if (!trimmedMessage.startsWith(CRON_PREFIX)) {
|
||||||
|
return {
|
||||||
|
isCronPayload: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 Base64 内容
|
||||||
|
const base64Content = trimmedMessage.slice(CRON_PREFIX.length);
|
||||||
|
|
||||||
|
if (!base64Content) {
|
||||||
|
return {
|
||||||
|
isCronPayload: true,
|
||||||
|
error: 'Cron 载荷内容为空'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Base64 解码
|
||||||
|
const jsonString = Buffer.from(base64Content, 'base64').toString('utf-8');
|
||||||
|
const payload = JSON.parse(jsonString) as CronReminderPayload;
|
||||||
|
|
||||||
|
// 验证类型
|
||||||
|
if (payload.type !== 'cron_reminder') {
|
||||||
|
return {
|
||||||
|
isCronPayload: true,
|
||||||
|
error: `期望 type 为 cron_reminder,实际为 ${payload.type}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证必要字段
|
||||||
|
if (!payload.content || !payload.targetType || !payload.targetAddress) {
|
||||||
|
return {
|
||||||
|
isCronPayload: true,
|
||||||
|
error: 'Cron 载荷缺少必要字段'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCronPayload: true,
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
isCronPayload: true,
|
||||||
|
error: `Cron 载荷解码失败: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 辅助函数
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断载荷是否为定时提醒类型
|
||||||
|
*/
|
||||||
|
export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload {
|
||||||
|
return payload.type === 'cron_reminder';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断载荷是否为媒体消息类型
|
||||||
|
*/
|
||||||
|
export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload {
|
||||||
|
return payload.type === 'media';
|
||||||
|
}
|
||||||
89
upgrade-and-run.sh
Executable file
89
upgrade-and-run.sh
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# QQBot 一键更新并启动脚本
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# 解析命令行参数
|
||||||
|
APPID=""
|
||||||
|
SECRET=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--appid)
|
||||||
|
APPID="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--secret)
|
||||||
|
SECRET="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
echo "用法: $0 [选项]"
|
||||||
|
echo ""
|
||||||
|
echo "选项:"
|
||||||
|
echo " --appid <appid> QQ机器人 AppID"
|
||||||
|
echo " --secret <secret> QQ机器人 Secret"
|
||||||
|
echo " -h, --help 显示帮助信息"
|
||||||
|
echo ""
|
||||||
|
echo "也可以通过环境变量设置:"
|
||||||
|
echo " QQBOT_APPID QQ机器人 AppID"
|
||||||
|
echo " QQBOT_SECRET QQ机器人 Secret"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "未知选项: $1"
|
||||||
|
echo "使用 --help 查看帮助信息"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# 使用命令行参数或环境变量
|
||||||
|
APPID="${APPID:-$QQBOT_APPID}"
|
||||||
|
SECRET="${SECRET:-$QQBOT_SECRET}"
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo " QQBot 一键更新启动脚本"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# 1. 移除老版本
|
||||||
|
echo ""
|
||||||
|
echo "[1/4] 移除老版本..."
|
||||||
|
if [ -f "./scripts/upgrade.sh" ]; then
|
||||||
|
bash ./scripts/upgrade.sh
|
||||||
|
else
|
||||||
|
echo "警告: upgrade.sh 不存在,跳过移除步骤"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 安装当前版本
|
||||||
|
echo ""
|
||||||
|
echo "[2/4] 安装当前版本..."
|
||||||
|
openclaw plugins install .
|
||||||
|
|
||||||
|
# 3. 配置机器人通道
|
||||||
|
echo ""
|
||||||
|
echo "[3/4] 配置机器人通道..."
|
||||||
|
|
||||||
|
# 构建 token(如果提供了 appid 和 secret)
|
||||||
|
if [ -n "$APPID" ] && [ -n "$SECRET" ]; then
|
||||||
|
QQBOT_TOKEN="${APPID}:${SECRET}"
|
||||||
|
echo "使用提供的 AppID 和 Secret 配置..."
|
||||||
|
else
|
||||||
|
# 默认 token,可通过环境变量 QQBOT_TOKEN 覆盖
|
||||||
|
QQBOT_TOKEN="${QQBOT_TOKEN:-appid:secret}"
|
||||||
|
echo "使用默认或环境变量中的 Token..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
openclaw channels add --channel qqbot --token "$QQBOT_TOKEN"
|
||||||
|
# 启用 markdown 支持
|
||||||
|
openclaw config set channels.qqbot.markdownSupport true
|
||||||
|
|
||||||
|
# 4. 启动 openclaw
|
||||||
|
echo ""
|
||||||
|
echo "[4/4] 启动 openclaw..."
|
||||||
|
echo "========================================="
|
||||||
|
openclaw gateway --verbose
|
||||||
Reference in New Issue
Block a user