feat(qqbot): 定时提醒技能与主动消息系统

**新增提醒技能**
- 新增 skills/qqbot-cron/SKILL.md 定时提醒技能定义
- 支持一次性提醒(--at)和周期性提醒(--cron)
- 支持设置、查询、取消提醒操作

**主动消息系统**
- 新增 src/proactive.ts 主动消息发送模块
- 新增 src/known-users.ts 已知用户管理
- 新增 src/session-store.ts 会话存储
- 支持主动向用户/群组发送消息

**工具脚本**
- 新增 scripts/proactive-api-server.ts 主动消息API服务
This commit is contained in:
rianli
2026-02-01 17:10:55 +08:00
parent 50422aac14
commit a3e87f2f37
15 changed files with 4639 additions and 52 deletions

1041
console.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,14 @@
{ {
"id": "qqbot", "id": "qqbot",
"name": "QQ Bot Channel",
"description": "QQ Bot channel plugin with streaming message support, cron jobs, and proactive messaging",
"channels": ["qqbot"], "channels": ["qqbot"],
"skills": ["qqbot-cron"],
"capabilities": {
"proactiveMessaging": true,
"cronJobs": true,
"streamingMessages": true
},
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,

View File

@@ -0,0 +1,346 @@
/**
* QQBot 主动消息 HTTP API 服务
*
* 提供 RESTful API 用于:
* 1. 发送主动消息
* 2. 查询已知用户
* 3. 广播消息
*
* 启动方式:
* npx ts-node scripts/proactive-api-server.ts --port 3721
*
* API 端点:
* POST /send - 发送主动消息
* GET /users - 列出已知用户
* GET /users/stats - 获取用户统计
* POST /broadcast - 广播消息
*/
import * as http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import * as url from "node:url";
import {
sendProactiveMessageDirect,
listKnownUsers,
getKnownUsersStats,
getKnownUser,
broadcastMessage,
} from "../src/proactive.js";
import type { ResolvedQQBotAccount } from "../src/types.js";
// 默认端口
const DEFAULT_PORT = 3721;
// 从配置文件加载账户信息
function loadAccount(accountId = "default"): ResolvedQQBotAccount | null {
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
try {
// 优先从环境变量获取
const envAppId = process.env.QQBOT_APP_ID;
const envClientSecret = process.env.QQBOT_CLIENT_SECRET;
if (!fs.existsSync(configPath)) {
if (envAppId && envClientSecret) {
return {
accountId,
appId: envAppId,
clientSecret: envClientSecret,
enabled: true,
secretSource: "env",
};
}
return null;
}
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const qqbot = config.channels?.qqbot;
if (!qqbot) {
if (envAppId && envClientSecret) {
return {
accountId,
appId: envAppId,
clientSecret: envClientSecret,
enabled: true,
secretSource: "env",
};
}
return null;
}
// 解析账户配置
if (accountId === "default") {
return {
accountId: "default",
appId: qqbot.appId || envAppId,
clientSecret: qqbot.clientSecret || envClientSecret,
enabled: qqbot.enabled ?? true,
secretSource: qqbot.clientSecret ? "config" : "env",
};
}
const accountConfig = qqbot.accounts?.[accountId];
if (accountConfig) {
return {
accountId,
appId: accountConfig.appId || qqbot.appId || envAppId,
clientSecret: accountConfig.clientSecret || qqbot.clientSecret || envClientSecret,
enabled: accountConfig.enabled ?? true,
secretSource: accountConfig.clientSecret ? "config" : "env",
};
}
return null;
} catch {
return null;
}
}
// 加载配置(用于 broadcastMessage
function loadConfig(): Record<string, unknown> {
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
try {
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
}
} catch {}
return {};
}
// 解析请求体
async function parseBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
resolve({});
}
});
});
}
// 发送 JSON 响应
function sendJson(res: http.ServerResponse, statusCode: number, data: unknown) {
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data, null, 2));
}
// 处理请求
async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
const parsedUrl = url.parse(req.url || "", true);
const pathname = parsedUrl.pathname || "/";
const method = req.method || "GET";
const query = parsedUrl.query;
// CORS 支持
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
console.log(`[${new Date().toISOString()}] ${method} ${pathname}`);
try {
// POST /send - 发送主动消息
if (pathname === "/send" && method === "POST") {
const body = await parseBody(req);
const { to, text, type = "c2c", accountId = "default" } = body as {
to?: string;
text?: string;
type?: "c2c" | "group";
accountId?: string;
};
if (!to || !text) {
sendJson(res, 400, { error: "Missing required fields: to, text" });
return;
}
const account = loadAccount(accountId);
if (!account) {
sendJson(res, 500, { error: "Failed to load account configuration" });
return;
}
const result = await sendProactiveMessageDirect(account, to, text, type);
sendJson(res, result.success ? 200 : 500, result);
return;
}
// GET /users - 列出已知用户
if (pathname === "/users" && method === "GET") {
const type = query.type as "c2c" | "group" | "channel" | undefined;
const accountId = query.accountId as string | undefined;
const limit = query.limit ? parseInt(query.limit as string, 10) : undefined;
const users = listKnownUsers({ type, accountId, limit });
sendJson(res, 200, { total: users.length, users });
return;
}
// GET /users/stats - 获取用户统计
if (pathname === "/users/stats" && method === "GET") {
const accountId = query.accountId as string | undefined;
const stats = getKnownUsersStats(accountId);
sendJson(res, 200, stats);
return;
}
// GET /users/:openid - 获取单个用户
if (pathname.startsWith("/users/") && method === "GET" && pathname !== "/users/stats") {
const openid = pathname.slice("/users/".length);
const type = (query.type as string) || "c2c";
const accountId = (query.accountId as string) || "default";
const user = getKnownUser(type, openid, accountId);
if (user) {
sendJson(res, 200, user);
} else {
sendJson(res, 404, { error: "User not found" });
}
return;
}
// POST /broadcast - 广播消息
if (pathname === "/broadcast" && method === "POST") {
const body = await parseBody(req);
const { text, type = "c2c", accountId, limit } = body as {
text?: string;
type?: "c2c" | "group";
accountId?: string;
limit?: number;
};
if (!text) {
sendJson(res, 400, { error: "Missing required field: text" });
return;
}
const cfg = loadConfig();
const result = await broadcastMessage(text, cfg as any, { type, accountId, limit });
sendJson(res, 200, result);
return;
}
// GET / - API 文档
if (pathname === "/" && method === "GET") {
sendJson(res, 200, {
name: "QQBot Proactive Message API",
version: "1.0.0",
endpoints: {
"POST /send": {
description: "发送主动消息",
body: {
to: "目标 openid (必需)",
text: "消息内容 (必需)",
type: "消息类型: c2c | group (默认 c2c)",
accountId: "账户 ID (默认 default)",
},
},
"GET /users": {
description: "列出已知用户",
query: {
type: "过滤类型: c2c | group | channel",
accountId: "过滤账户 ID",
limit: "限制返回数量",
},
},
"GET /users/stats": {
description: "获取用户统计",
query: {
accountId: "过滤账户 ID",
},
},
"GET /users/:openid": {
description: "获取单个用户信息",
query: {
type: "用户类型 (默认 c2c)",
accountId: "账户 ID (默认 default)",
},
},
"POST /broadcast": {
description: "广播消息给所有已知用户",
body: {
text: "消息内容 (必需)",
type: "消息类型: c2c | group (默认 c2c)",
accountId: "账户 ID",
limit: "限制发送数量",
},
},
},
notes: [
"只有曾经与机器人交互过的用户才能收到主动消息",
],
});
return;
}
// 404
sendJson(res, 404, { error: "Not found" });
} catch (err) {
console.error(`Error handling request: ${err}`);
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
}
}
// 解析命令行参数获取端口
function getPort(): number {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
if (args[i] === "--port" && args[i + 1]) {
return parseInt(args[i + 1], 10) || DEFAULT_PORT;
}
}
return parseInt(process.env.PROACTIVE_API_PORT || "", 10) || DEFAULT_PORT;
}
// 启动服务器
function main() {
const port = getPort();
const server = http.createServer(handleRequest);
server.listen(port, () => {
console.log(`
╔═══════════════════════════════════════════════════════════════╗
║ QQBot Proactive Message API Server ║
╠═══════════════════════════════════════════════════════════════╣
║ Server running at: http://localhost:${port.toString().padEnd(25)}
║ ║
║ Endpoints: ║
║ GET / - API documentation ║
║ POST /send - Send proactive message ║
║ GET /users - List known users ║
║ GET /users/stats - Get user statistics ║
║ POST /broadcast - Broadcast message ║
║ ║
║ Example: ║
║ curl -X POST http://localhost:${port}/send \\
║ -H "Content-Type: application/json" \\
║ -d '{"to":"openid","text":"Hello!"}' ║
╚═══════════════════════════════════════════════════════════════╝
`);
});
// 优雅关闭
process.on("SIGINT", () => {
console.log("\nShutting down...");
server.close(() => {
process.exit(0);
});
});
}
main();

273
scripts/send-proactive.ts Normal file
View File

@@ -0,0 +1,273 @@
#!/usr/bin/env npx ts-node
/**
* QQBot 主动消息 CLI 工具
*
* 使用示例:
* # 发送私聊消息
* npx ts-node scripts/send-proactive.ts --to "用户openid" --text "你好!"
*
* # 发送群聊消息
* npx ts-node scripts/send-proactive.ts --to "群组openid" --type group --text "群公告"
*
* # 列出已知用户
* npx ts-node scripts/send-proactive.ts --list
*
* # 列出群聊用户
* npx ts-node scripts/send-proactive.ts --list --type group
*
* # 广播消息
* npx ts-node scripts/send-proactive.ts --broadcast --text "系统公告" --type c2c --limit 10
*/
import {
sendProactiveMessageDirect,
listKnownUsers,
getKnownUsersStats,
broadcastMessage,
} from "../src/proactive.js";
import type { ResolvedQQBotAccount } from "../src/types.js";
import * as fs from "node:fs";
import * as path from "node:path";
// 解析命令行参数
function parseArgs(): Record<string, string | boolean> {
const args: Record<string, string | boolean> = {};
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith("--")) {
const key = arg.slice(2);
const nextArg = argv[i + 1];
if (nextArg && !nextArg.startsWith("--")) {
args[key] = nextArg;
i++;
} else {
args[key] = true;
}
}
}
return args;
}
// 从配置文件加载账户信息
function loadAccount(accountId = "default"): ResolvedQQBotAccount | null {
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
try {
if (!fs.existsSync(configPath)) {
// 尝试从环境变量获取
const appId = process.env.QQBOT_APP_ID;
const clientSecret = process.env.QQBOT_CLIENT_SECRET;
if (appId && clientSecret) {
return {
accountId,
appId,
clientSecret,
enabled: true,
secretSource: "env",
};
}
console.error("配置文件不存在且环境变量未设置");
return null;
}
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const qqbot = config.channels?.qqbot;
if (!qqbot) {
console.error("配置中没有 qqbot 配置");
return null;
}
// 解析账户配置
if (accountId === "default") {
return {
accountId: "default",
appId: qqbot.appId || process.env.QQBOT_APP_ID,
clientSecret: qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET,
enabled: qqbot.enabled ?? true,
secretSource: qqbot.clientSecret ? "config" : "env",
};
}
const accountConfig = qqbot.accounts?.[accountId];
if (accountConfig) {
return {
accountId,
appId: accountConfig.appId || qqbot.appId || process.env.QQBOT_APP_ID,
clientSecret: accountConfig.clientSecret || qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET,
enabled: accountConfig.enabled ?? true,
secretSource: accountConfig.clientSecret ? "config" : "env",
};
}
console.error(`账户 ${accountId} 不存在`);
return null;
} catch (err) {
console.error(`加载配置失败: ${err}`);
return null;
}
}
async function main() {
const args = parseArgs();
// 显示帮助
if (args.help || args.h) {
console.log(`
QQBot 主动消息 CLI 工具
用法:
npx ts-node scripts/send-proactive.ts [选项]
选项:
--to <openid> 目标用户或群组的 openid
--text <message> 要发送的消息内容
--type <type> 消息类型: c2c (私聊) 或 group (群聊),默认 c2c
--account <id> 账户 ID默认 default
--list 列出已知用户
--stats 显示用户统计
--broadcast 广播消息给所有已知用户
--limit <n> 限制数量
--help, -h 显示帮助
示例:
# 发送私聊消息
npx ts-node scripts/send-proactive.ts --to "0Eda5EA7-xxx" --text "你好!"
# 发送群聊消息
npx ts-node scripts/send-proactive.ts --to "A1B2C3D4" --type group --text "群公告"
# 列出最近 10 个私聊用户
npx ts-node scripts/send-proactive.ts --list --type c2c --limit 10
# 广播消息
npx ts-node scripts/send-proactive.ts --broadcast --text "系统公告" --limit 5
`);
return;
}
const accountId = (args.account as string) || "default";
const type = (args.type as "c2c" | "group") || "c2c";
const limit = args.limit ? parseInt(args.limit as string, 10) : undefined;
// 列出已知用户
if (args.list) {
const users = listKnownUsers({
type: args.type as "c2c" | "group" | "channel" | undefined,
accountId: args.account as string | undefined,
limit,
});
if (users.length === 0) {
console.log("没有已知用户");
return;
}
console.log(`\n已知用户列表 (共 ${users.length} 个):\n`);
console.log("类型\t\tOpenID\t\t\t\t\t\t昵称\t\t最后交互时间");
console.log("─".repeat(100));
for (const user of users) {
const lastTime = new Date(user.lastInteractionAt).toLocaleString();
console.log(`${user.type}\t\t${user.openid.slice(0, 20)}...\t${user.nickname || "-"}\t\t${lastTime}`);
}
return;
}
// 显示统计
if (args.stats) {
const stats = getKnownUsersStats(args.account as string | undefined);
console.log(`\n用户统计:`);
console.log(` 总计: ${stats.total}`);
console.log(` 私聊: ${stats.c2c}`);
console.log(` 群聊: ${stats.group}`);
console.log(` 频道: ${stats.channel}`);
return;
}
// 广播消息
if (args.broadcast) {
if (!args.text) {
console.error("请指定消息内容 (--text)");
process.exit(1);
}
// 加载配置用于广播
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
let cfg: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
}
} catch {}
console.log(`\n开始广播消息...\n`);
const result = await broadcastMessage(args.text as string, cfg as any, {
type,
accountId,
limit,
});
console.log(`\n广播完成:`);
console.log(` 发送总数: ${result.total}`);
console.log(` 成功: ${result.success}`);
console.log(` 失败: ${result.failed}`);
if (result.failed > 0) {
console.log(`\n失败详情:`);
for (const r of result.results) {
if (!r.result.success) {
console.log(` ${r.to}: ${r.result.error}`);
}
}
}
return;
}
// 发送单条消息
if (args.to && args.text) {
const account = loadAccount(accountId);
if (!account) {
console.error("无法加载账户配置");
process.exit(1);
}
console.log(`\n发送消息...`);
console.log(` 目标: ${args.to}`);
console.log(` 类型: ${type}`);
console.log(` 内容: ${args.text}`);
const result = await sendProactiveMessageDirect(
account,
args.to as string,
args.text as string,
type
);
if (result.success) {
console.log(`\n✅ 发送成功!`);
console.log(` 消息ID: ${result.messageId}`);
console.log(` 时间戳: ${result.timestamp}`);
} else {
console.log(`\n❌ 发送失败: ${result.error}`);
process.exit(1);
}
return;
}
// 没有有效参数
console.error("请指定操作。使用 --help 查看帮助。");
process.exit(1);
}
main().catch((err) => {
console.error(`执行失败: ${err}`);
process.exit(1);
});

476
skills/qqbot-cron/SKILL.md Normal file
View File

@@ -0,0 +1,476 @@
---
name: qqbot-cron
description: QQ Bot 智能提醒技能。支持一次性提醒、周期性任务、自动降级确保送达。可设置、查询、取消提醒。
metadata: {"clawdbot":{"emoji":"⏰","requires":{"channels":["qqbot"]}}}
---
# QQ Bot 智能提醒
让 AI 帮用户设置、管理定时提醒,支持私聊和群聊。
---
## 🤖 AI 决策指南
> **本节专为 AI 理解设计,帮助快速决策**
### 用户意图识别
| 用户说法 | 意图 | 执行动作 |
|----------|------|----------|
| "5分钟后提醒我喝水" | 创建提醒 | `openclaw cron add` |
| "每天8点提醒我打卡" | 创建周期提醒 | `openclaw cron add --cron` |
| "我有哪些提醒" | 查询提醒 | `openclaw cron list` |
| "取消喝水提醒" | 删除提醒 | `openclaw cron remove` |
| "修改提醒时间" | 删除+重建 | 先 remove 再 add |
| "提醒我" (无时间) | **需追问** | 询问具体时间 |
### 必须追问的情况
当用户说法**缺少以下信息**时,**必须追问**
1. **没有时间**"提醒我喝水" → 询问"请问什么时候提醒你?"
2. **时间模糊**"晚点提醒我" → 询问"具体几点呢?"
3. **周期不明**"定期提醒我" → 询问"多久一次?每天?每周?"
### 无需追问可直接执行
| 用户说法 | 理解为 |
|----------|--------|
| "5分钟后" | `--at 5m` |
| "半小时后" | `--at 30m` |
| "1小时后" | `--at 1h` |
| "明天早上8点" | `--at 2026-02-02T08:00:00+08:00` |
| "每天早上8点" | `--cron "0 8 * * *"` |
| "工作日9点" | `--cron "0 9 * * 1-5"` |
---
## 📋 命令速查
### 创建提醒(完整模板)
```bash
openclaw cron add \
--name "{任务名}" \
--at "{时间}" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"{openid}","user_name":"{用户名称}","original_message_id":"{message_id}","reminder_content":"{提醒内容}","created_at":"{当前时间ISO格式}"}' \
--message "{消息内容}" \
--deliver \
--channel qqbot \
--to "{openid}" \
--delete-after-run
```
> 💡 **`--system-event` 说明**:用于存储用户上下文信息,提醒触发时 AI 可以获取这些信息来提供更个性化的提醒。
> ⚠️ **注意**`cron add` 命令不支持 `--reply-to` 参数。提醒消息将作为主动消息直接发送给用户。
### 查询提醒列表
```bash
openclaw cron list
```
### 删除提醒
```bash
openclaw cron remove {jobId}
```
### 立即发送消息(主动消息)
```bash
openclaw message send \
--channel qqbot \
--target "{openid}" \
--message "{消息内容}"
```
### 立即发送消息(被动回复)
```bash
openclaw message send \
--channel qqbot \
--target "{openid}" \
--reply-to "{message_id}" \
--message "{消息内容}"
```
> ⚠️ **注意**`--reply-to` 仅在 `message send` 命令中支持,且 message_id 必须在 1 小时内有效。定时提醒不支持被动回复。
---
## 💬 用户交互模板
> **创建提醒后,必须给用户反馈确认**
### 创建成功反馈
**一次性提醒**
```
✅ 提醒已设置!
📝 内容:{提醒内容}
⏰ 时间:{具体时间}
到时候我会准时提醒你~
```
**周期提醒**
```
✅ 周期提醒已设置!
📝 内容:{提醒内容}
🔄 周期:{周期描述,如"每天早上8:00"}
提醒会持续生效,说"取消xx提醒"可停止~
```
### 查询提醒反馈
```
📋 你当前的提醒:
1. ⏰ 喝水提醒 - 5分钟后 (一次性)
2. 🔄 打卡提醒 - 每天08:00 (周期)
3. 🔄 日报提醒 - 工作日18:00 (周期)
说"取消xx提醒"可删除~
```
### 无提醒时反馈
```
📋 你当前没有设置任何提醒
说"5分钟后提醒我xxx"可创建提醒~
```
### 删除成功反馈
```
✅ 已取消"{提醒名称}"提醒
```
---
## ⏱️ 时间格式
### 相对时间(--at
> ⚠️ **不要加 + 号!** 用 `5m` 而不是 `+5m`
| 用户说法 | 参数值 |
|----------|--------|
| 5分钟后 | `5m` |
| 半小时后 | `30m` |
| 1小时后 | `1h` |
| 2小时后 | `2h` |
| 明天这时候 | `24h` |
### 绝对时间(--at
| 用户说法 | 参数值 |
|----------|--------|
| 今天下午3点 | `2026-02-01T15:00:00+08:00` |
| 明天早上8点 | `2026-02-02T08:00:00+08:00` |
| 2月14日中午 | `2026-02-14T12:00:00+08:00` |
### Cron 表达式(--cron
| 用户说法 | Cron 表达式 | 必须加 `--tz "Asia/Shanghai"` |
|----------|-------------|------------------------------|
| 每天早上8点 | `0 8 * * *` | ✅ |
| 每天晚上10点 | `0 22 * * *` | ✅ |
| 每个工作日早上9点 | `0 9 * * 1-5` | ✅ |
| 每周一早上9点 | `0 9 * * 1` | ✅ |
| 每周末上午10点 | `0 10 * * 0,6` | ✅ |
| 每小时整点 | `0 * * * *` | ✅ |
---
## 📌 参数说明
### 必填参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `--name` | 任务名,含用户标识 | `"喝水提醒-小明"` |
| `--at` / `--cron` | 触发时间(二选一) | `5m` / `0 8 * * *` |
| `--session isolated` | 隔离会话 | 固定值 |
| `--message` | 消息内容,**不能为空** | `"💧 该喝水啦!"` |
| `--deliver` | 启用投递 | 固定值 |
| `--channel qqbot` | QQ 渠道 | 固定值 |
| `--to` | 接收者 openid | 从系统消息获取 |
### 推荐参数(用于存储用户上下文)
| 参数 | 说明 | 何时使用 |
|------|------|----------|
| `--system-event` | 用户上下文 JSON | **建议所有任务都使用** |
| `--delete-after-run` | 执行后删除 | **一次性任务必须** |
| `--tz "Asia/Shanghai"` | 时区 | **周期任务必须** |
### --system-event 字段说明
`--system-event` 用于存储提醒的上下文信息,格式为 JSON
```json
{
"type": "reminder",
"user_openid": "用户的openid",
"user_name": "用户名称(如有)",
"original_message_id": "创建提醒时的message_id",
"reminder_content": "提醒内容摘要",
"created_at": "创建时间ISO格式"
}
```
| 字段 | 说明 | 来源 |
|------|------|------|
| `type` | 事件类型,固定为 `"reminder"` | 固定值 |
| `user_openid` | 用户 openid | 从系统消息获取 |
| `user_name` | 用户名称 | 从系统消息获取(如有) |
| `original_message_id` | 创建时的消息 ID | 从系统消息获取 |
| `reminder_content` | 提醒内容摘要 | AI 根据用户请求生成 |
| `created_at` | 提醒创建时间 | 当前时间 ISO 格式 |
> 💡 **为什么需要 `--system-event`**
> - 提醒触发时AI 可以获取完整的用户上下文
> - 便于追踪提醒来源和调试
> - 为未来可能的功能扩展预留信息
---
## 🎯 使用场景示例
### 场景1一次性提醒
**用户**: 5分钟后提醒我喝水
**AI 执行**:
```bash
openclaw cron add \
--name "喝水提醒-用户" \
--at "5m" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"喝水","created_at":"2026-02-01T16:50:00+08:00"}' \
--message "💧 该喝水啦这是你5分钟前设置的提醒~" \
--deliver \
--channel qqbot \
--to "{openid}" \
--delete-after-run
```
**AI 回复**:
```
✅ 提醒已设置!
📝 内容:喝水
⏰ 时间5分钟后
到时候我会准时提醒你~
```
---
### 场景2每日周期提醒
**用户**: 每天早上8点提醒我打卡
**AI 执行**:
```bash
openclaw cron add \
--name "打卡提醒-用户" \
--cron "0 8 * * *" \
--tz "Asia/Shanghai" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"打卡","created_at":"2026-02-01T16:50:00+08:00"}' \
--message "🌅 早上好!记得打卡签到~" \
--deliver \
--channel qqbot \
--to "{openid}"
```
**AI 回复**:
```
✅ 周期提醒已设置!
📝 内容:打卡
🔄 周期:每天早上 8:00
提醒会持续生效,说"取消打卡提醒"可停止~
```
> 💡 周期任务**不加** `--delete-after-run`
---
### 场景3工作日提醒
**用户**: 工作日下午6点提醒我写日报
**AI 执行**:
```bash
openclaw cron add \
--name "日报提醒-用户" \
--cron "0 18 * * 1-5" \
--tz "Asia/Shanghai" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"写日报","created_at":"2026-02-01T16:50:00+08:00"}' \
--message "📝 今天的日报别忘了提交!" \
--deliver \
--channel qqbot \
--to "{openid}"
```
---
### 场景4群组提醒
**用户**(群聊): 每天早上9点提醒大家站会
**AI 执行**:
```bash
openclaw cron add \
--name "站会提醒-群" \
--cron "0 9 * * 1-5" \
--tz "Asia/Shanghai" \
--session isolated \
--system-event '{"type":"reminder","user_openid":"group:{group_openid}","original_message_id":"{message_id}","reminder_content":"站会","created_at":"2026-02-01T16:50:00+08:00"}' \
--message "📢 各位同事9点站会时间到请准时参加~" \
--deliver \
--channel qqbot \
--to "group:{group_openid}"
```
> 💡 群组使用 `group:{group_openid}` 格式
---
### 场景5查询提醒
**用户**: 我有哪些提醒?
**AI 执行**:
```bash
openclaw cron list
```
**AI 回复**(根据返回结果组织):
```
📋 你当前的提醒:
1. ⏰ 喝水提醒 - 3分钟后 (一次性)
2. 🔄 打卡提醒 - 每天08:00 (周期)
说"取消xx提醒"可删除~
```
---
### 场景6取消提醒
**用户**: 取消打卡提醒
**AI 执行**:
1. 先执行 `openclaw cron list` 找到对应任务 ID
2. 执行 `openclaw cron remove {jobId}`
**AI 回复**:
```
✅ 已取消"打卡提醒"
```
---
## ⚙️ 消息发送说明
### 定时提醒cron add
定时提醒**只能发送主动消息**,因为:
- 提醒执行时,原始 message_id 通常已超过 1 小时有效期
- `openclaw cron add` 命令不支持 `--reply-to` 参数
```
┌─────────────────────┐
│ 定时任务触发 │
└──────────┬──────────┘
┌─────────────────────┐
│ AI 通过 system-event │
│ 获取用户上下文信息 │
└──────────┬──────────┘
┌─────────────────────┐
│ 发送主动消息到用户 │
│ --channel qqbot │
│ --to {openid} │
└──────────┬──────────┘
✅ 用户收到提醒
```
### 即时回复message send
即时消息发送支持被动回复(如果 message_id 有效):
```
┌─────────────────────┐
│ 发送即时消息 │
└──────────┬──────────┘
┌──────────────────────────────┐
│ 有 --reply-to 且 message_id │
│ 在 1 小时内有效? │
└──────────────────────────────┘
↓ ↓
是 否
↓ ↓
┌───────────────┐ ┌─────────────────┐
│ 被动消息回复 │ │ 发送主动消息 │
│ (引用原消息) │ │ (直接发送) │
└───────────────┘ └─────────────────┘
```
---
## ⚠️ 重要限制
| 限制 | 说明 |
|------|------|
| **message_id 有效期** | 1 小时内有效,超时自动降级 |
| **回复次数限制** | 同一 message_id 最多回复 4 次 |
| **主动消息限制** | 只能发给与机器人交互过的用户 |
| **消息内容** | `--message` 不能为空 |
---
## 📝 消息模板
| 场景 | 模板 | Emoji |
|------|------|-------|
| 喝水 | 该喝水啦! | 💧 🚰 |
| 打卡 | 早上好!记得打卡~ | 🌅 ✅ |
| 会议 | xx会议马上开始 | 📅 👥 |
| 休息 | 该休息一下了~ | 😴 💤 |
| 日报 | 今日日报别忘了~ | 📝 ✍️ |
| 运动 | 该运动了! | 🏃 💪 |
| 吃药 | 记得按时吃药~ | 💊 🏥 |
| 生日 | 今天是xx的生日 | 🎂 🎉 |
---
## 🔧 用户标识
| 类型 | 格式 | 来源 |
|------|------|------|
| 用户 openid | `B3EA9A1d-2D3c-5CBD-...` | 系统消息自动提供 |
| 群组 openid | `group:FeC1ADaf-...` | 系统消息自动提供 |
| message_id | `ROBOT1.0_xxx` | 系统消息自动提供 |
> 💡 这些信息在系统消息中格式如:
> - `当前用户 openid: B3EA9A1d-...`
> - `当前消息 message_id: ROBOT1.0_...`

View File

@@ -26,9 +26,14 @@ export function isMarkdownSupport(): boolean {
} }
let cachedToken: { token: string; expiresAt: number } | null = null; let cachedToken: { token: string; expiresAt: number } | null = null;
// Singleflight: 防止并发获取 Token 的 Promise 缓存
let tokenFetchPromise: Promise<string> | null = null;
/** /**
* 获取 AccessToken带缓存 * 获取 AccessToken带缓存 + singleflight 并发安全
*
* 使用 singleflight 模式:当多个请求同时发现 Token 过期时,
* 只有第一个请求会真正去获取新 Token其他请求复用同一个 Promise。
*/ */
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> { export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
// 检查缓存,提前 5 分钟刷新 // 检查缓存,提前 5 分钟刷新
@@ -36,6 +41,30 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
return cachedToken.token; return cachedToken.token;
} }
// Singleflight: 如果已有进行中的 Token 获取请求,复用它
if (tokenFetchPromise) {
console.log(`[qqbot-api] Token fetch in progress, waiting for existing request...`);
return tokenFetchPromise;
}
// 创建新的 Token 获取 Promisesingleflight 入口)
tokenFetchPromise = (async () => {
try {
return await doFetchToken(appId, clientSecret);
} finally {
// 无论成功失败,都清除 Promise 缓存
tokenFetchPromise = null;
}
})();
return tokenFetchPromise;
}
/**
* 实际执行 Token 获取的内部函数
*/
async function doFetchToken(appId: string, clientSecret: string): Promise<string> {
const requestBody = { appId, clientSecret }; const requestBody = { appId, clientSecret };
const requestHeaders = { "Content-Type": "application/json" }; const requestHeaders = { "Content-Type": "application/json" };
@@ -86,6 +115,7 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000, expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
}; };
console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`);
return cachedToken.token; return cachedToken.token;
} }
@@ -94,6 +124,22 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
*/ */
export function clearTokenCache(): void { export function clearTokenCache(): void {
cachedToken = null; cachedToken = null;
// 注意:不清除 tokenFetchPromise让进行中的请求完成
// 下次调用 getAccessToken 时会自动获取新 Token
}
/**
* 获取 Token 缓存状态(用于监控)
*/
export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
if (tokenFetchPromise) {
return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null };
}
if (!cachedToken) {
return { status: "none", expiresAt: null };
}
const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000;
return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt };
} }
/** /**
@@ -320,32 +366,65 @@ export async function sendGroupMessage(
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body); return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
} }
/**
* 构建主动消息请求体
* 根据 markdownSupport 配置决定消息格式:
* - markdown 模式: { markdown: { content }, msg_type: 2 }
* - 纯文本模式: { content, msg_type: 0 }
*
* 注意:主动消息不支持流式发送
*/
function buildProactiveMessageBody(content: string): Record<string, unknown> {
// 主动消息内容校验(参考 Telegram 机制)
if (!content || content.trim().length === 0) {
throw new Error("主动消息内容不能为空 (markdown.content is empty)");
}
if (currentMarkdownSupport) {
return {
markdown: { content },
msg_type: 2,
};
} else {
return {
content,
msg_type: 0,
};
}
}
/** /**
* 主动发送 C2C 单聊消息(不需要 msg_id每月限 4 条/用户) * 主动发送 C2C 单聊消息(不需要 msg_id每月限 4 条/用户)
*
* 注意:
* 1. 内容不能为空(对应 markdown.content 字段)
* 2. 不支持流式发送
*/ */
export async function sendProactiveC2CMessage( export async function sendProactiveC2CMessage(
accessToken: string, accessToken: string,
openid: string, openid: string,
content: string content: string
): Promise<{ id: string; timestamp: number }> { ): Promise<{ id: string; timestamp: number }> {
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, { const body = buildProactiveMessageBody(content);
content, console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`);
msg_type: 0, return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
});
} }
/** /**
* 主动发送群聊消息(不需要 msg_id每月限 4 条/群) * 主动发送群聊消息(不需要 msg_id每月限 4 条/群)
*
* 注意:
* 1. 内容不能为空(对应 markdown.content 字段)
* 2. 不支持流式发送
*/ */
export async function sendProactiveGroupMessage( export async function sendProactiveGroupMessage(
accessToken: string, accessToken: string,
groupOpenid: string, groupOpenid: string,
content: string content: string
): Promise<{ id: string; timestamp: string }> { ): Promise<{ id: string; timestamp: string }> {
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, { const body = buildProactiveMessageBody(content);
content, console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`);
msg_type: 0, return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
});
} }
// ============ 富媒体消息支持 ============ // ============ 富媒体消息支持 ============
@@ -475,3 +554,150 @@ export async function sendGroupImageMessage(
// 再发送富媒体消息 // 再发送富媒体消息
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content); return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
} }
// ============ 后台 Token 刷新 (P1-1) ============
/**
* 后台 Token 刷新配置
*/
interface BackgroundTokenRefreshOptions {
/** 提前刷新时间(毫秒,默认 5 分钟) */
refreshAheadMs?: number;
/** 随机偏移范围(毫秒,默认 0-30 秒) */
randomOffsetMs?: number;
/** 最小刷新间隔(毫秒,默认 1 分钟) */
minRefreshIntervalMs?: number;
/** 失败后重试间隔(毫秒,默认 5 秒) */
retryDelayMs?: number;
/** 日志函数 */
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
}
// 后台刷新状态
let backgroundRefreshRunning = false;
let backgroundRefreshAbortController: AbortController | null = null;
/**
* 启动后台 Token 刷新
* 在后台定时刷新 Token避免请求时才发现过期
*
* @param appId 应用 ID
* @param clientSecret 应用密钥
* @param options 配置选项
*/
export function startBackgroundTokenRefresh(
appId: string,
clientSecret: string,
options?: BackgroundTokenRefreshOptions
): void {
if (backgroundRefreshRunning) {
console.log("[qqbot-api] Background token refresh already running");
return;
}
const {
refreshAheadMs = 5 * 60 * 1000, // 提前 5 分钟刷新
randomOffsetMs = 30 * 1000, // 0-30 秒随机偏移
minRefreshIntervalMs = 60 * 1000, // 最少 1 分钟后刷新
retryDelayMs = 5 * 1000, // 失败后 5 秒重试
log,
} = options ?? {};
backgroundRefreshRunning = true;
backgroundRefreshAbortController = new AbortController();
const signal = backgroundRefreshAbortController.signal;
const refreshLoop = async () => {
log?.info?.("[qqbot-api] Background token refresh started");
while (!signal.aborted) {
try {
// 先确保有一个有效 Token
await getAccessToken(appId, clientSecret);
// 计算下次刷新时间
if (cachedToken) {
const expiresIn = cachedToken.expiresAt - Date.now();
// 提前刷新时间 + 随机偏移(避免集群同时刷新)
const randomOffset = Math.random() * randomOffsetMs;
const refreshIn = Math.max(
expiresIn - refreshAheadMs - randomOffset,
minRefreshIntervalMs
);
log?.debug?.(
`[qqbot-api] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`
);
// 等待到刷新时间
await sleep(refreshIn, signal);
} else {
// 没有缓存的 Token等待一段时间后重试
log?.debug?.("[qqbot-api] No cached token, retrying soon");
await sleep(minRefreshIntervalMs, signal);
}
} catch (err) {
if (signal.aborted) break;
// 刷新失败,等待后重试
log?.error?.(`[qqbot-api] Background token refresh failed: ${err}`);
await sleep(retryDelayMs, signal);
}
}
backgroundRefreshRunning = false;
log?.info?.("[qqbot-api] Background token refresh stopped");
};
// 异步启动,不阻塞调用者
refreshLoop().catch((err) => {
backgroundRefreshRunning = false;
log?.error?.(`[qqbot-api] Background token refresh crashed: ${err}`);
});
}
/**
* 停止后台 Token 刷新
*/
export function stopBackgroundTokenRefresh(): void {
if (backgroundRefreshAbortController) {
backgroundRefreshAbortController.abort();
backgroundRefreshAbortController = null;
}
backgroundRefreshRunning = false;
}
/**
* 检查后台 Token 刷新是否正在运行
*/
export function isBackgroundTokenRefreshRunning(): boolean {
return backgroundRefreshRunning;
}
/**
* 可中断的 sleep 函数
*/
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
if (signal) {
if (signal.aborted) {
clearTimeout(timer);
reject(new Error("Aborted"));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(new Error("Aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
}
});
}

View File

@@ -77,6 +77,7 @@ export function resolveQQBotAccount(
systemPrompt: qqbot?.systemPrompt, systemPrompt: qqbot?.systemPrompt,
imageServerBaseUrl: qqbot?.imageServerBaseUrl, imageServerBaseUrl: qqbot?.imageServerBaseUrl,
markdownSupport: qqbot?.markdownSupport, markdownSupport: qqbot?.markdownSupport,
streamEnabled: qqbot?.streamEnabled,
}; };
appId = qqbot?.appId ?? ""; appId = qqbot?.appId ?? "";
} else { } else {
@@ -113,6 +114,7 @@ export function resolveQQBotAccount(
systemPrompt: accountConfig.systemPrompt, systemPrompt: accountConfig.systemPrompt,
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL, imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
markdownSupport: accountConfig.markdownSupport, markdownSupport: accountConfig.markdownSupport,
streamEnabled: accountConfig.streamEnabled,
config: accountConfig, config: accountConfig,
}; };
} }

View File

@@ -2,7 +2,9 @@ import WebSocket from "ws";
import path from "node:path"; import path from "node:path";
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
import { StreamState } from "./types.js"; import { StreamState } from "./types.js";
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, sendC2CInputNotify } from "./api.js"; import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } from "./api.js";
import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
import { getQQBotRuntime } from "./runtime.js"; import { getQQBotRuntime } from "./runtime.js";
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js"; import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
import { createStreamSender } from "./outbound.js"; import { createStreamSender } from "./outbound.js";
@@ -55,7 +57,79 @@ const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process
// 流式消息配置 // 流式消息配置
const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒) const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒)
const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符) const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符)
const STREAM_KEEPALIVE_INTERVAL = 8000; // 流式心跳间隔(毫秒),需要在 10 秒内发送 const STREAM_KEEPALIVE_FIRST_DELAY = 3000; // 首次状态保持延迟毫秒openclaw 3s 内未回复时发送
const STREAM_KEEPALIVE_GAP = 10000; // 状态保持消息之间的间隔(毫秒)
const STREAM_KEEPALIVE_MAX_PER_CHUNK = 2; // 每 2 个消息分片之间最多发送的状态保持消息数量
const STREAM_MAX_DURATION = 3 * 60 * 1000; // 流式消息最大持续时间(毫秒),超过 3 分钟自动结束
// 消息队列配置(异步处理,防止阻塞心跳)
const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度
const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值
// ============ 消息回复限流器 ============
// 同一 message_id 1小时内最多回复 4 次超过1小时需降级为主动消息
const MESSAGE_REPLY_LIMIT = 4;
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
interface MessageReplyRecord {
count: number;
firstReplyAt: number;
}
const messageReplyTracker = new Map<string, MessageReplyRecord>();
/**
* 检查是否可以回复该消息(限流检查)
* @param messageId 消息ID
* @returns { allowed: boolean, remaining: number } allowed=是否允许回复remaining=剩余次数
*/
function checkMessageReplyLimit(messageId: string): { allowed: boolean; remaining: number } {
const now = Date.now();
const record = messageReplyTracker.get(messageId);
// 清理过期记录(定期清理,避免内存泄漏)
if (messageReplyTracker.size > 10000) {
for (const [id, rec] of messageReplyTracker) {
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
messageReplyTracker.delete(id);
}
}
}
if (!record) {
return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
}
// 检查是否过期
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
messageReplyTracker.delete(messageId);
return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
}
// 检查是否超过限制
const remaining = MESSAGE_REPLY_LIMIT - record.count;
return { allowed: remaining > 0, remaining: Math.max(0, remaining) };
}
/**
* 记录一次消息回复
* @param messageId 消息ID
*/
function recordMessageReply(messageId: string): void {
const now = Date.now();
const record = messageReplyTracker.get(messageId);
if (!record) {
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
} else {
// 检查是否过期,过期则重新计数
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
} else {
record.count++;
}
}
}
export interface GatewayContext { export interface GatewayContext {
account: ResolvedQQBotAccount; account: ResolvedQQBotAccount;
@@ -70,6 +144,22 @@ export interface GatewayContext {
}; };
} }
/**
* 消息队列项类型(用于异步处理消息,防止阻塞心跳)
*/
interface QueuedMessage {
type: "c2c" | "guild" | "dm" | "group";
senderId: string;
senderName?: string;
content: string;
messageId: string;
timestamp: string;
channelId?: string;
guildId?: string;
groupOpenid?: string;
attachments?: Array<{ content_type: string; url: string; filename?: string }>;
}
/** /**
* 启动图床服务器 * 启动图床服务器
*/ */
@@ -137,6 +227,74 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
let intentLevelIndex = 0; // 当前尝试的权限级别索引 let intentLevelIndex = 0; // 当前尝试的权限级别索引
let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别 let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
// ============ P1-2: 尝试从持久化存储恢复 Session ============
const savedSession = loadSession(account.accountId);
if (savedSession) {
sessionId = savedSession.sessionId;
lastSeq = savedSession.lastSeq;
intentLevelIndex = savedSession.intentLevelIndex;
lastSuccessfulIntentLevel = savedSession.intentLevelIndex;
log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`);
}
// ============ 消息队列(异步处理,防止阻塞心跳) ============
const messageQueue: QueuedMessage[] = [];
let messageProcessorRunning = false;
let messagesProcessed = 0; // 统计已处理消息数
/**
* 将消息加入队列(非阻塞)
*/
const enqueueMessage = (msg: QueuedMessage): void => {
if (messageQueue.length >= MESSAGE_QUEUE_SIZE) {
// 队列满了,丢弃最旧的消息
const dropped = messageQueue.shift();
log?.error(`[qqbot:${account.accountId}] Message queue full, dropping oldest message from ${dropped?.senderId}`);
}
if (messageQueue.length >= MESSAGE_QUEUE_WARN_THRESHOLD) {
log?.info(`[qqbot:${account.accountId}] Message queue size: ${messageQueue.length}/${MESSAGE_QUEUE_SIZE}`);
}
messageQueue.push(msg);
log?.debug?.(`[qqbot:${account.accountId}] Message enqueued, queue size: ${messageQueue.length}`);
};
/**
* 启动消息处理循环(独立于 WS 消息循环)
*/
const startMessageProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
if (messageProcessorRunning) return;
messageProcessorRunning = true;
const processLoop = async () => {
while (!isAborted) {
if (messageQueue.length === 0) {
// 队列为空,等待一小段时间
await new Promise(resolve => setTimeout(resolve, 50));
continue;
}
const msg = messageQueue.shift()!;
try {
await handleMessageFn(msg);
messagesProcessed++;
} catch (err) {
// 捕获处理异常,防止影响队列循环
log?.error(`[qqbot:${account.accountId}] Message processor error: ${err}`);
}
}
messageProcessorRunning = false;
log?.info(`[qqbot:${account.accountId}] Message processor stopped`);
};
// 异步启动,不阻塞调用者
processLoop().catch(err => {
log?.error(`[qqbot:${account.accountId}] Message processor crashed: ${err}`);
messageProcessorRunning = false;
});
log?.info(`[qqbot:${account.accountId}] Message processor started`);
};
abortSignal.addEventListener("abort", () => { abortSignal.addEventListener("abort", () => {
isAborted = true; isAborted = true;
if (reconnectTimer) { if (reconnectTimer) {
@@ -144,6 +302,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
reconnectTimer = null; reconnectTimer = null;
} }
cleanup(); cleanup();
// P1-1: 停止后台 Token 刷新
stopBackgroundTokenRefresh();
// P1-3: 保存已知用户数据
flushKnownUsers();
}); });
const cleanup = () => { const cleanup = () => {
@@ -232,16 +394,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`); log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
} }
// 对于 C2C 消息,先发送输入状态提示用户机器人正在输入 // 流式消息开关(默认启用,仅 c2c 支持)
if (event.type === "c2c") { const streamEnabled = account.streamEnabled !== false;
try { log?.debug?.(`[qqbot:${account.accountId}] Stream enabled: ${streamEnabled}`);
const token = await getAccessToken(account.appId, account.clientSecret);
await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Failed to send input notify: ${err}`);
}
}
pluginRuntime.channel.activity.record({ pluginRuntime.channel.activity.record({
channel: "qqbot", channel: "qqbot",
@@ -269,6 +424,51 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
// 组装消息体,添加系统提示词 // 组装消息体,添加系统提示词
let builtinPrompt = ""; let builtinPrompt = "";
// ============ 用户标识信息(用于定时提醒和主动消息) ============
const isGroupChat = event.type === "group";
const targetAddress = isGroupChat ? `group:${event.groupOpenid}` : event.senderId;
builtinPrompt += `
【当前用户信息】
- 用户 openid: ${event.senderId}
- 用户昵称: ${event.senderName || "未知"}
- 消息类型: ${isGroupChat ? "群聊" : "私聊"}
- 当前消息 message_id: ${event.messageId}${isGroupChat ? `
- 群组 group_openid: ${event.groupOpenid}` : ""}
【定时提醒能力】
你可以帮助用户设置定时提醒。使用 openclaw cron 命令:
示例5分钟后提醒用户喝水
\`\`\`bash
openclaw cron add \\
--name "提醒喝水-${event.senderName || "用户"}" \\
--at "5m" \\
--session isolated \\
--message "💧 该喝水啦!" \\
--deliver \\
--channel qqbot \\
--to "${targetAddress}" \\
--reply-to "${event.messageId}" \\
--delete-after-run
\`\`\`
关键参数说明:
- \`--to\`: 目标地址(当前用户: ${targetAddress}
- \`--reply-to\`: 回复消息ID当前消息: ${event.messageId},使提醒能引用原消息)
- \`--at\`: 一次性定时任务的触发时间
- 相对时间格式:数字+单位,如 \`5m\`5分钟\`1h\`1小时\`2d\`2天【注意不要加 + 号】
- 绝对时间格式ISO 8601 带时区,如 \`2026-02-01T14:00:00+08:00\`
- \`--cron\`: 周期性任务(如 \`0 8 * * *\` 每天早上8点
- \`--tz "Asia/Shanghai"\`: 周期任务务必设置时区
- \`--delete-after-run\`: 一次性任务必须添加此参数
- \`--message\`: 消息内容(必填,不能为空!对应 QQ API 的 markdown.content 字段)
⚠️ 重要注意事项:
1. --at 参数格式:相对时间用 \`5m\`\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式
2. 定时提醒消息不支持流式发送,命令中不要添加 --stream 参数
3. --message 参数必须有实际内容,不能为空字符串`;
// 只有配置了图床公网地址,才告诉 AI 可以发送图片 // 只有配置了图床公网地址,才告诉 AI 可以发送图片
if (imageServerBaseUrl) { if (imageServerBaseUrl) {
builtinPrompt += ` builtinPrompt += `
@@ -400,7 +600,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
// 追踪是否有响应 // 追踪是否有响应
let hasResponse = false; let hasResponse = false;
const responseTimeout = 30000; // 30秒超时 const responseTimeout = 60000; // 60秒超时1分钟
let timeoutId: ReturnType<typeof setTimeout> | null = null; let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<void>((_, reject) => { const timeoutPromise = new Promise<void>((_, reject) => {
@@ -417,19 +617,25 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
: event.type === "group" ? `group:${event.groupOpenid}` : event.type === "group" ? `group:${event.groupOpenid}`
: `channel:${event.channelId}`; : `channel:${event.channelId}`;
// 判断是否支持流式(仅 c2c 支持,群聊不支持流式) // 判断是否支持流式(仅 c2c 支持,群聊不支持流式,且需要开关启用
const supportsStream = event.type === "c2c"; const supportsStream = event.type === "c2c" && streamEnabled;
log?.info(`[qqbot:${account.accountId}] Stream support: ${supportsStream} (type=${event.type}, enabled=${streamEnabled})`);
// 创建流式发送器 // 创建流式发送器
const streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null; const streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null;
let streamBuffer = ""; // 累积的全部文本(用于记录完整内容) let streamBuffer = ""; // 累积的全部文本(用于记录完整内容)
let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量) let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量)
let lastSentText = ""; // 上次发送时的完整文本(用于检测新段落)
let currentSegmentStart = 0; // 当前段落在 streamBuffer 中的起始位置
let lastStreamSendTime = 0; // 上次流式发送时间 let lastStreamSendTime = 0; // 上次流式发送时间
let streamStarted = false; // 是否已开始流式发送 let streamStarted = false; // 是否已开始流式发送
let streamEnded = false; // 流式是否已结束 let streamEnded = false; // 流式是否已结束
let streamStartTime = 0; // 流式消息开始时间(用于超时检查)
let sendingLock = false; // 发送锁,防止并发发送 let sendingLock = false; // 发送锁,防止并发发送
let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累) let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累)
let keepaliveTimer: ReturnType<typeof setTimeout> | null = null; // 心跳定时器 let keepaliveTimer: ReturnType<typeof setTimeout> | null = null; // 心跳定时器
let keepaliveCountSinceLastChunk = 0; // 自上次分片以来发送的状态保持消息数量
let lastChunkSendTime = 0; // 上次分片发送时间(用于判断是否需要发送状态保持)
// 清理心跳定时器 // 清理心跳定时器
const clearKeepalive = () => { const clearKeepalive = () => {
@@ -440,26 +646,78 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}; };
// 重置心跳定时器(每次发送后调用) // 重置心跳定时器(每次发送后调用)
const resetKeepalive = () => { // isContentChunk: 是否为内容分片(非状态保持消息)
const resetKeepalive = (isContentChunk: boolean = false) => {
clearKeepalive(); clearKeepalive();
// 如果是内容分片,重置状态保持计数器和时间
if (isContentChunk) {
keepaliveCountSinceLastChunk = 0;
lastChunkSendTime = Date.now();
}
if (streamSender && streamStarted && !streamEnded) { if (streamSender && streamStarted && !streamEnded) {
// 计算下次状态保持消息的延迟时间
// - 首次3sSTREAM_KEEPALIVE_FIRST_DELAY
// - 后续10sSTREAM_KEEPALIVE_GAP
const delay = keepaliveCountSinceLastChunk === 0
? STREAM_KEEPALIVE_FIRST_DELAY
: STREAM_KEEPALIVE_GAP;
keepaliveTimer = setTimeout(async () => { keepaliveTimer = setTimeout(async () => {
// 10 秒内没有新消息,发送空分片保持连接 // 检查流式消息是否超时(超过 3 分钟自动结束)
const elapsed = Date.now() - streamStartTime;
if (elapsed >= STREAM_MAX_DURATION) {
log?.info(`[qqbot:${account.accountId}] Stream timeout after ${Math.round(elapsed / 1000)}s, auto ending stream`);
if (!streamEnded && !sendingLock) { if (!streamEnded && !sendingLock) {
log?.info(`[qqbot:${account.accountId}] Sending keepalive empty chunk`); sendingLock = true;
try {
// 发送结束标记
await streamSender!.send("", true);
streamEnded = true;
clearKeepalive();
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Stream auto-end failed: ${err}`);
} finally {
sendingLock = false;
}
}
return; // 超时后不再继续心跳
}
// 检查是否已达到每2个分片之间的最大状态保持消息数量
if (keepaliveCountSinceLastChunk >= STREAM_KEEPALIVE_MAX_PER_CHUNK) {
log?.debug?.(`[qqbot:${account.accountId}] Max keepalive reached (${keepaliveCountSinceLastChunk}/${STREAM_KEEPALIVE_MAX_PER_CHUNK}), waiting for next content chunk`);
// 不再发送状态保持,但继续监控超时
resetKeepalive(false);
return;
}
// 检查距上次分片是否超过 3s
const timeSinceLastChunk = Date.now() - lastChunkSendTime;
if (timeSinceLastChunk < STREAM_KEEPALIVE_FIRST_DELAY) {
// 还未到发送状态保持的时机,继续等待
resetKeepalive(false);
return;
}
// 发送状态保持消息
if (!streamEnded && !sendingLock) {
log?.info(`[qqbot:${account.accountId}] Sending keepalive #${keepaliveCountSinceLastChunk + 1} (elapsed: ${Math.round(elapsed / 1000)}s, since chunk: ${Math.round(timeSinceLastChunk / 1000)}s)`);
sendingLock = true; sendingLock = true;
try { try {
// 发送空内容 // 发送空内容
await streamSender!.send("", false); await streamSender!.send("", false);
lastStreamSendTime = Date.now(); lastStreamSendTime = Date.now();
resetKeepalive(); // 继续下一个心跳 keepaliveCountSinceLastChunk++;
resetKeepalive(false); // 继续下一个状态保持(非内容分片)
} catch (err) { } catch (err) {
log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`); log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`);
} finally { } finally {
sendingLock = false; sendingLock = false;
} }
} }
}, STREAM_KEEPALIVE_INTERVAL); }, delay);
} }
}; };
@@ -486,8 +744,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
streamEnded = true; streamEnded = true;
clearKeepalive(); clearKeepalive();
} else { } else {
// 发送成功后重置心跳 // 发送成功后重置心跳,如果是有内容的分片则重置计数器
resetKeepalive(); const isContentChunk = text.length > 0;
resetKeepalive(isContentChunk);
} }
return true; return true;
}; };
@@ -505,11 +764,17 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
// 发送当前增量 // 发送当前增量
if (fullText.length > lastSentLength) { if (fullText.length > lastSentLength) {
const increment = fullText.slice(lastSentLength); const increment = fullText.slice(lastSentLength);
// 首次发送前,先设置流式状态和开始时间
if (!streamStarted) {
streamStarted = true;
streamStartTime = Date.now();
log?.info(`[qqbot:${account.accountId}] Stream started, max duration: ${STREAM_MAX_DURATION / 1000}s`);
}
const success = await sendStreamChunk(increment, forceEnd); const success = await sendStreamChunk(increment, forceEnd);
if (success) { if (success) {
lastSentLength = fullText.length; lastSentLength = fullText.length;
lastSentText = fullText; // 记录完整发送文本,用于检测新段落
lastStreamSendTime = Date.now(); lastStreamSendTime = Date.now();
streamStarted = true;
log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`); log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`);
} }
} else if (forceEnd && !streamEnded) { } else if (forceEnd && !streamEnded) {
@@ -530,6 +795,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}; };
// onPartialReply 回调 - 实时接收 AI 生成的文本payload.text 是累积的全文) // onPartialReply 回调 - 实时接收 AI 生成的文本payload.text 是累积的全文)
// 注意agent 在一次对话中可能产生多个回复段落(如思考、工具调用后继续回复)
// 每个新段落的 text 会从头开始累积,需要检测并处理
const handlePartialReply = async (payload: { text?: string }) => { const handlePartialReply = async (payload: { text?: string }) => {
if (!streamSender || streamEnded) { if (!streamSender || streamEnded) {
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`); log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`);
@@ -542,11 +809,39 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
return; return;
} }
// 始终更新累积缓冲区(即使不发送,也要记录最新内容)
streamBuffer = fullText;
hasResponse = true; hasResponse = true;
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}`); // 检测是否是新段落:
// 1. lastSentText 不为空(说明已经发送过内容)
// 2. 当前文本不是以 lastSentText 开头(说明不是同一段落的增量)
// 3. 当前文本长度小于 lastSentLength说明文本被重置了
const isNewSegment = lastSentText.length > 0 &&
(fullText.length < lastSentLength || !fullText.startsWith(lastSentText.slice(0, Math.min(10, lastSentText.length))));
if (isNewSegment) {
// 新段落开始,将之前的内容追加到 streamBuffer并重置发送位置
log?.info(`[qqbot:${account.accountId}] New segment detected! lastSentLength=${lastSentLength}, newTextLength=${fullText.length}, lastSentText="${lastSentText.slice(0, 20)}...", newText="${fullText.slice(0, 20)}..."`);
// 记录当前段落在 streamBuffer 中的起始位置
currentSegmentStart = streamBuffer.length;
// 追加换行分隔符(如果前面有内容且不以换行结尾)
if (streamBuffer.length > 0 && !streamBuffer.endsWith("\n")) {
streamBuffer += "\n\n";
currentSegmentStart = streamBuffer.length;
}
// 重置发送位置,从新段落开始发送
lastSentLength = 0;
lastSentText = "";
}
// 更新当前段落内容到 streamBuffer
// streamBuffer = 之前的段落内容 + 当前段落的完整内容
const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart);
streamBuffer = beforeCurrentSegment + fullText;
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}, streamBuffer.length=${streamBuffer.length}, isNewSegment=${isNewSegment}`);
// 如果没有新内容,跳过 // 如果没有新内容,跳过
if (fullText.length <= lastSentLength) return; if (fullText.length <= lastSentLength) return;
@@ -578,9 +873,15 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
let replyText = payload.text ?? ""; let replyText = payload.text ?? "";
// 更新 streamBuffer,确保最终内容不会丢失 // 更新当前段落内容到 streamBuffer
if (replyText.length > streamBuffer.length) { // deliver 中的 replyText 是当前段落的完整文本
streamBuffer = replyText; if (replyText.length > 0) {
const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart);
const newStreamBuffer = beforeCurrentSegment + replyText;
if (newStreamBuffer.length > streamBuffer.length) {
streamBuffer = newStreamBuffer;
log?.debug?.(`[qqbot:${account.accountId}] deliver: updated streamBuffer, replyText=${replyText.length}, total=${streamBuffer.length}`);
}
} }
// 收集所有图片路径 // 收集所有图片路径
@@ -796,18 +1097,18 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
} }
// 确保所有待发送内容都发送出去 // 确保所有待发送内容都发送出去
// 优先使用 pendingFullText因为它可能包含最新完整文本 // 当前段落的最新完整文本
const finalFullText = pendingFullText && pendingFullText.length > streamBuffer.length const currentSegmentText = pendingFullText && pendingFullText.length > (streamBuffer.length - currentSegmentStart)
? pendingFullText ? pendingFullText
: streamBuffer; : streamBuffer.slice(currentSegmentStart);
// 计算剩余未发送的增量内容 // 计算当前段落剩余未发送的增量内容
const remainingIncrement = finalFullText.slice(lastSentLength); const remainingIncrement = currentSegmentText.slice(lastSentLength);
if (remainingIncrement || streamStarted) { if (remainingIncrement || streamStarted) {
// 有剩余内容或者已开始流式,都需要发送结束标记 // 有剩余内容或者已开始流式,都需要发送结束标记
await streamSender.end(remainingIncrement); await streamSender.end(remainingIncrement);
streamEnded = true; streamEnded = true;
log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total: ${finalFullText.length} chars, chunks: ${streamSender.getContext().index}`); log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total streamBuffer: ${streamBuffer.length} chars, chunks: ${streamSender.getContext().index}`);
} }
} }
} catch (err) { } catch (err) {
@@ -832,6 +1133,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
isConnecting = false; // 连接完成,释放锁 isConnecting = false; // 连接完成,释放锁
reconnectAttempts = 0; // 连接成功,重置重试计数 reconnectAttempts = 0; // 连接成功,重置重试计数
lastConnectTime = Date.now(); // 记录连接时间 lastConnectTime = Date.now(); // 记录连接时间
// 启动消息处理器(异步处理,防止阻塞心跳)
startMessageProcessor(handleMessage);
// P1-1: 启动后台 Token 刷新
startBackgroundTokenRefresh(account.appId, account.clientSecret, {
log: log as { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void },
});
}); });
ws.on("message", async (data) => { ws.on("message", async (data) => {
@@ -840,7 +1147,20 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
const payload = JSON.parse(rawData) as WSPayload; const payload = JSON.parse(rawData) as WSPayload;
const { op, d, s, t } = payload; const { op, d, s, t } = payload;
if (s) lastSeq = s; if (s) {
lastSeq = s;
// P1-2: 更新持久化存储中的 lastSeq节流保存
if (sessionId) {
saveSession({
sessionId,
lastSeq,
lastConnectedAt: lastConnectTime,
intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
accountId: account.accountId,
savedAt: Date.now(),
});
}
}
log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`); log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
@@ -894,12 +1214,39 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
lastSuccessfulIntentLevel = intentLevelIndex; lastSuccessfulIntentLevel = intentLevelIndex;
const successLevel = INTENT_LEVELS[intentLevelIndex]; const successLevel = INTENT_LEVELS[intentLevelIndex];
log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`); log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
// P1-2: 保存新的 Session 状态
saveSession({
sessionId,
lastSeq,
lastConnectedAt: Date.now(),
intentLevelIndex,
accountId: account.accountId,
savedAt: Date.now(),
});
onReady?.(d); onReady?.(d);
} else if (t === "RESUMED") { } else if (t === "RESUMED") {
log?.info(`[qqbot:${account.accountId}] Session resumed`); log?.info(`[qqbot:${account.accountId}] Session resumed`);
// P1-2: 更新 Session 连接时间
if (sessionId) {
saveSession({
sessionId,
lastSeq,
lastConnectedAt: Date.now(),
intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
accountId: account.accountId,
savedAt: Date.now(),
});
}
} else if (t === "C2C_MESSAGE_CREATE") { } else if (t === "C2C_MESSAGE_CREATE") {
const event = d as C2CMessageEvent; const event = d as C2CMessageEvent;
await handleMessage({ // P1-3: 记录已知用户
recordKnownUser({
openid: event.author.user_openid,
type: "c2c",
accountId: account.accountId,
});
// 使用消息队列异步处理,防止阻塞心跳
enqueueMessage({
type: "c2c", type: "c2c",
senderId: event.author.user_openid, senderId: event.author.user_openid,
content: event.content, content: event.content,
@@ -909,7 +1256,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}); });
} else if (t === "AT_MESSAGE_CREATE") { } else if (t === "AT_MESSAGE_CREATE") {
const event = d as GuildMessageEvent; const event = d as GuildMessageEvent;
await handleMessage({ // P1-3: 记录已知用户(频道用户)
recordKnownUser({
openid: event.author.id,
type: "c2c", // 频道用户按 c2c 类型存储
nickname: event.author.username,
accountId: account.accountId,
});
enqueueMessage({
type: "guild", type: "guild",
senderId: event.author.id, senderId: event.author.id,
senderName: event.author.username, senderName: event.author.username,
@@ -922,7 +1276,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}); });
} else if (t === "DIRECT_MESSAGE_CREATE") { } else if (t === "DIRECT_MESSAGE_CREATE") {
const event = d as GuildMessageEvent; const event = d as GuildMessageEvent;
await handleMessage({ // P1-3: 记录已知用户(频道私信用户)
recordKnownUser({
openid: event.author.id,
type: "c2c",
nickname: event.author.username,
accountId: account.accountId,
});
enqueueMessage({
type: "dm", type: "dm",
senderId: event.author.id, senderId: event.author.id,
senderName: event.author.username, senderName: event.author.username,
@@ -934,7 +1295,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}); });
} else if (t === "GROUP_AT_MESSAGE_CREATE") { } else if (t === "GROUP_AT_MESSAGE_CREATE") {
const event = d as GroupMessageEvent; const event = d as GroupMessageEvent;
await handleMessage({ // P1-3: 记录已知用户(群组用户)
recordKnownUser({
openid: event.author.member_openid,
type: "group",
groupOpenid: event.group_openid,
accountId: account.accountId,
});
enqueueMessage({
type: "group", type: "group",
senderId: event.author.member_openid, senderId: event.author.member_openid,
content: event.content, content: event.content,
@@ -964,6 +1332,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
if (!canResume) { if (!canResume) {
sessionId = null; sessionId = null;
lastSeq = null; lastSeq = null;
// P1-2: 清除持久化的 Session
clearSession(account.accountId);
// 尝试降级到下一个权限级别 // 尝试降级到下一个权限级别
if (intentLevelIndex < INTENT_LEVELS.length - 1) { if (intentLevelIndex < INTENT_LEVELS.length - 1) {

358
src/known-users.ts Normal file
View File

@@ -0,0 +1,358 @@
/**
* 已知用户存储
* 记录与机器人交互过的所有用户
* 支持主动消息和批量通知功能
*/
import fs from "node:fs";
import path from "node:path";
// 已知用户信息接口
export interface KnownUser {
/** 用户 openid唯一标识 */
openid: string;
/** 消息类型:私聊用户 / 群组 */
type: "c2c" | "group";
/** 用户昵称(如有) */
nickname?: string;
/** 群组 openid如果是群消息 */
groupOpenid?: string;
/** 关联的机器人账户 ID */
accountId: string;
/** 首次交互时间戳 */
firstSeenAt: number;
/** 最后交互时间戳 */
lastSeenAt: number;
/** 交互次数 */
interactionCount: number;
}
// 存储文件路径
const KNOWN_USERS_DIR = path.join(
process.env.HOME || "/tmp",
"clawd",
"qqbot-data"
);
const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json");
// 内存缓存
let usersCache: Map<string, KnownUser> | null = null;
// 写入节流配置
const SAVE_THROTTLE_MS = 5000; // 5秒写入一次
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let isDirty = false;
/**
* 确保目录存在
*/
function ensureDir(): void {
if (!fs.existsSync(KNOWN_USERS_DIR)) {
fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true });
}
}
/**
* 从文件加载用户数据到缓存
*/
function loadUsersFromFile(): Map<string, KnownUser> {
if (usersCache !== null) {
return usersCache;
}
usersCache = new Map();
try {
if (fs.existsSync(KNOWN_USERS_FILE)) {
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
const users = JSON.parse(data) as KnownUser[];
for (const user of users) {
// 使用复合键accountId + type + openid群组还要加 groupOpenid
const key = makeUserKey(user);
usersCache.set(key, user);
}
console.log(`[known-users] Loaded ${usersCache.size} users from file`);
}
} catch (err) {
console.error(`[known-users] Failed to load users: ${err}`);
usersCache = new Map();
}
return usersCache;
}
/**
* 保存用户数据到文件(节流版本)
*/
function saveUsersToFile(): void {
if (!isDirty) return;
if (saveTimer) {
return; // 已有定时器在等待
}
saveTimer = setTimeout(() => {
saveTimer = null;
doSaveUsersToFile();
}, SAVE_THROTTLE_MS);
}
/**
* 实际执行保存
*/
function doSaveUsersToFile(): void {
if (!usersCache || !isDirty) return;
try {
ensureDir();
const users = Array.from(usersCache.values());
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
isDirty = false;
console.log(`[known-users] Saved ${users.length} users to file`);
} catch (err) {
console.error(`[known-users] Failed to save users: ${err}`);
}
}
/**
* 强制立即保存(用于进程退出前)
*/
export function flushKnownUsers(): void {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
doSaveUsersToFile();
}
/**
* 生成用户唯一键
*/
function makeUserKey(user: Partial<KnownUser>): string {
const base = `${user.accountId}:${user.type}:${user.openid}`;
if (user.type === "group" && user.groupOpenid) {
return `${base}:${user.groupOpenid}`;
}
return base;
}
/**
* 记录已知用户(收到消息时调用)
* @param user 用户信息(部分字段)
*/
export function recordKnownUser(user: {
openid: string;
type: "c2c" | "group";
nickname?: string;
groupOpenid?: string;
accountId: string;
}): void {
const cache = loadUsersFromFile();
const key = makeUserKey(user);
const now = Date.now();
const existing = cache.get(key);
if (existing) {
// 更新已存在的用户
existing.lastSeenAt = now;
existing.interactionCount++;
if (user.nickname && user.nickname !== existing.nickname) {
existing.nickname = user.nickname;
}
console.log(`[known-users] Updated user ${user.openid}, interactions: ${existing.interactionCount}`);
} else {
// 新用户
const newUser: KnownUser = {
openid: user.openid,
type: user.type,
nickname: user.nickname,
groupOpenid: user.groupOpenid,
accountId: user.accountId,
firstSeenAt: now,
lastSeenAt: now,
interactionCount: 1,
};
cache.set(key, newUser);
console.log(`[known-users] New user recorded: ${user.openid} (${user.type})`);
}
isDirty = true;
saveUsersToFile();
}
/**
* 获取单个用户信息
* @param accountId 机器人账户 ID
* @param openid 用户 openid
* @param type 消息类型
* @param groupOpenid 群组 openid可选
*/
export function getKnownUser(
accountId: string,
openid: string,
type: "c2c" | "group" = "c2c",
groupOpenid?: string
): KnownUser | undefined {
const cache = loadUsersFromFile();
const key = makeUserKey({ accountId, openid, type, groupOpenid });
return cache.get(key);
}
/**
* 列出所有已知用户
* @param options 筛选选项
*/
export function listKnownUsers(options?: {
/** 筛选特定机器人账户的用户 */
accountId?: string;
/** 筛选消息类型 */
type?: "c2c" | "group";
/** 最近活跃时间(毫秒,如 86400000 表示最近 24 小时) */
activeWithin?: number;
/** 返回数量限制 */
limit?: number;
/** 排序方式 */
sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount";
/** 排序方向 */
sortOrder?: "asc" | "desc";
}): KnownUser[] {
const cache = loadUsersFromFile();
let users = Array.from(cache.values());
// 筛选
if (options?.accountId) {
users = users.filter(u => u.accountId === options.accountId);
}
if (options?.type) {
users = users.filter(u => u.type === options.type);
}
if (options?.activeWithin) {
const cutoff = Date.now() - options.activeWithin;
users = users.filter(u => u.lastSeenAt >= cutoff);
}
// 排序
const sortBy = options?.sortBy ?? "lastSeenAt";
const sortOrder = options?.sortOrder ?? "desc";
users.sort((a, b) => {
const aVal = a[sortBy] ?? 0;
const bVal = b[sortBy] ?? 0;
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
});
// 限制数量
if (options?.limit && options.limit > 0) {
users = users.slice(0, options.limit);
}
return users;
}
/**
* 获取用户统计信息
* @param accountId 机器人账户 ID可选不传则返回所有账户的统计
*/
export function getKnownUsersStats(accountId?: string): {
totalUsers: number;
c2cUsers: number;
groupUsers: number;
activeIn24h: number;
activeIn7d: number;
} {
let users = listKnownUsers({ accountId });
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
return {
totalUsers: users.length,
c2cUsers: users.filter(u => u.type === "c2c").length,
groupUsers: users.filter(u => u.type === "group").length,
activeIn24h: users.filter(u => now - u.lastSeenAt < day).length,
activeIn7d: users.filter(u => now - u.lastSeenAt < 7 * day).length,
};
}
/**
* 删除用户记录
* @param accountId 机器人账户 ID
* @param openid 用户 openid
* @param type 消息类型
* @param groupOpenid 群组 openid可选
*/
export function removeKnownUser(
accountId: string,
openid: string,
type: "c2c" | "group" = "c2c",
groupOpenid?: string
): boolean {
const cache = loadUsersFromFile();
const key = makeUserKey({ accountId, openid, type, groupOpenid });
if (cache.has(key)) {
cache.delete(key);
isDirty = true;
saveUsersToFile();
console.log(`[known-users] Removed user ${openid}`);
return true;
}
return false;
}
/**
* 清除所有用户记录
* @param accountId 机器人账户 ID可选不传则清除所有
*/
export function clearKnownUsers(accountId?: string): number {
const cache = loadUsersFromFile();
let count = 0;
if (accountId) {
// 只清除指定账户的用户
for (const [key, user] of cache.entries()) {
if (user.accountId === accountId) {
cache.delete(key);
count++;
}
}
} else {
// 清除所有
count = cache.size;
cache.clear();
}
if (count > 0) {
isDirty = true;
doSaveUsersToFile(); // 立即保存
console.log(`[known-users] Cleared ${count} users`);
}
return count;
}
/**
* 获取用户的所有群组(某用户在哪些群里交互过)
* @param accountId 机器人账户 ID
* @param openid 用户 openid
*/
export function getUserGroups(accountId: string, openid: string): string[] {
const users = listKnownUsers({ accountId, type: "group" });
return users
.filter(u => u.openid === openid && u.groupOpenid)
.map(u => u.groupOpenid!);
}
/**
* 获取群组的所有成员
* @param accountId 机器人账户 ID
* @param groupOpenid 群组 openid
*/
export function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[] {
return listKnownUsers({ accountId, type: "group" })
.filter(u => u.groupOpenid === groupOpenid);
}

483
src/openclaw-plugin-sdk.d.ts vendored Normal file
View 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;
}

View File

@@ -16,6 +16,133 @@ import {
type StreamMessageResponse, type StreamMessageResponse,
} from "./api.js"; } from "./api.js";
// ============ 消息回复限流器 ============
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
const MESSAGE_REPLY_LIMIT = 4;
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
interface MessageReplyRecord {
count: number;
firstReplyAt: number;
}
const messageReplyTracker = new Map<string, MessageReplyRecord>();
/** 限流检查结果 */
export interface ReplyLimitResult {
/** 是否允许被动回复 */
allowed: boolean;
/** 剩余被动回复次数 */
remaining: number;
/** 是否需要降级为主动消息(超期或超过次数) */
shouldFallbackToProactive: boolean;
/** 降级原因 */
fallbackReason?: "expired" | "limit_exceeded";
/** 提示消息 */
message?: string;
}
/**
* 检查是否可以回复该消息(限流检查)
* @param messageId 消息ID
* @returns ReplyLimitResult 限流检查结果
*/
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult {
const now = Date.now();
const record = messageReplyTracker.get(messageId);
// 清理过期记录(定期清理,避免内存泄漏)
if (messageReplyTracker.size > 10000) {
for (const [id, rec] of messageReplyTracker) {
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
messageReplyTracker.delete(id);
}
}
}
// 新消息,首次回复
if (!record) {
return {
allowed: true,
remaining: MESSAGE_REPLY_LIMIT,
shouldFallbackToProactive: false,
};
}
// 检查是否超过1小时message_id 过期)
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
// 超过1小时被动回复不可用需要降级为主动消息
return {
allowed: false,
remaining: 0,
shouldFallbackToProactive: true,
fallbackReason: "expired",
message: `消息已超过1小时有效期将使用主动消息发送`,
};
}
// 检查是否超过回复次数限制
const remaining = MESSAGE_REPLY_LIMIT - record.count;
if (remaining <= 0) {
return {
allowed: false,
remaining: 0,
shouldFallbackToProactive: true,
fallbackReason: "limit_exceeded",
message: `该消息已达到1小时内最大回复次数(${MESSAGE_REPLY_LIMIT}次),将使用主动消息发送`,
};
}
return {
allowed: true,
remaining,
shouldFallbackToProactive: false,
};
}
/**
* 记录一次消息回复
* @param messageId 消息ID
*/
export function recordMessageReply(messageId: string): void {
const now = Date.now();
const record = messageReplyTracker.get(messageId);
if (!record) {
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
} else {
// 检查是否过期,过期则重新计数
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
} else {
record.count++;
}
}
console.log(`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`);
}
/**
* 获取消息回复统计信息
*/
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } {
let totalReplies = 0;
for (const record of messageReplyTracker.values()) {
totalReplies += record.count;
}
return { trackedMessages: messageReplyTracker.size, totalReplies };
}
/**
* 获取消息回复限制配置(供外部查询)
*/
export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } {
return {
limit: MESSAGE_REPLY_LIMIT,
ttlMs: MESSAGE_REPLY_TTL,
ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
};
}
export interface OutboundContext { export interface OutboundContext {
to: string; to: string;
text: string; text: string;
@@ -211,14 +338,61 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin
/** /**
* 发送文本消息 * 发送文本消息
* - 有 replyToId: 被动回复,无配额限制 * - 有 replyToId: 被动回复,1小时内最多回复4次
* - 无 replyToId: 主动发送有配额限制每月4条/用户/群) * - 无 replyToId: 主动发送有配额限制每月4条/用户/群)
*
* 注意:
* 1. 主动消息(无 replyToId必须有消息内容不支持流式发送
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
*/ */
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> { export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
const { to, text, replyToId, account } = ctx; const { to, text, account } = ctx;
let { replyToId } = ctx;
let fallbackToProactive = false;
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2)); console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
// ============ 消息回复限流检查 ============
// 如果有 replyToId检查是否可以被动回复
if (replyToId) {
const limitCheck = checkMessageReplyLimit(replyToId);
if (!limitCheck.allowed) {
// 检查是否需要降级为主动消息
if (limitCheck.shouldFallbackToProactive) {
console.warn(`[qqbot] sendText: 被动回复不可用,降级为主动消息 - ${limitCheck.message}`);
fallbackToProactive = true;
replyToId = null; // 清除 replyToId改为主动消息
} else {
// 不应该发生,但作为保底
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
return {
channel: "qqbot",
error: limitCheck.message
};
}
} else {
console.log(`[qqbot] sendText: 消息 ${replyToId} 剩余被动回复次数: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`);
}
}
// ============ 主动消息校验(参考 Telegram 机制) ============
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
if (!replyToId) {
if (!text || text.trim().length === 0) {
console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
return {
channel: "qqbot",
error: "主动消息必须有内容 (--message 参数不能为空)"
};
}
if (fallbackToProactive) {
console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
} else {
console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
}
}
if (!account.appId || !account.clientSecret) { if (!account.appId || !account.clientSecret) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
} }
@@ -246,12 +420,18 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
// 有 replyToId使用被动回复接口 // 有 replyToId使用被动回复接口
if (target.type === "c2c") { if (target.type === "c2c") {
const result = await sendC2CMessage(accessToken, target.id, text, replyToId); const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else if (target.type === "group") { } else if (target.type === "group") {
const result = await sendGroupMessage(accessToken, target.id, text, replyToId); const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else { } else {
const result = await sendChannelMessage(accessToken, target.id, text, replyToId); const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} }
} catch (err) { } catch (err) {

528
src/proactive.ts Normal file
View File

@@ -0,0 +1,528 @@
/**
* QQ Bot 主动发送消息模块
*
* 该模块提供以下能力:
* 1. 记录已知用户(曾与机器人交互过的用户)
* 2. 主动发送消息给用户或群组
* 3. 查询已知用户列表
*/
import * as fs from "node:fs";
import * as path from "node:path";
import type { ResolvedQQBotAccount } from "./types.js";
// ============ 类型定义(本地) ============
/**
* 已知用户信息
*/
export interface KnownUser {
type: "c2c" | "group" | "channel";
openid: string;
accountId: string;
nickname?: string;
firstInteractionAt: number;
lastInteractionAt: number;
}
/**
* 主动发送消息选项
*/
export interface ProactiveSendOptions {
to: string;
text: string;
type?: "c2c" | "group" | "channel";
imageUrl?: string;
accountId?: string;
}
/**
* 主动发送消息结果
*/
export interface ProactiveSendResult {
success: boolean;
messageId?: string;
timestamp?: number | string;
error?: string;
}
/**
* 列出已知用户选项
*/
export interface ListKnownUsersOptions {
type?: "c2c" | "group" | "channel";
accountId?: string;
sortByLastInteraction?: boolean;
limit?: number;
}
import {
getAccessToken,
sendProactiveC2CMessage,
sendProactiveGroupMessage,
sendChannelMessage,
sendC2CImageMessage,
sendGroupImageMessage,
} from "./api.js";
import { resolveQQBotAccount } from "./config.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
// ============ 用户存储管理 ============
/**
* 已知用户存储
* 使用简单的 JSON 文件存储,保存在 clawd 目录下
*/
const STORAGE_DIR = path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-data");
const KNOWN_USERS_FILE = path.join(STORAGE_DIR, "known-users.json");
// 内存缓存
let knownUsersCache: Map<string, KnownUser> | null = null;
let cacheLastModified = 0;
/**
* 确保存储目录存在
*/
function ensureStorageDir(): void {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
/**
* 生成用户唯一键
*/
function getUserKey(type: string, openid: string, accountId: string): string {
return `${accountId}:${type}:${openid}`;
}
/**
* 从文件加载已知用户
*/
function loadKnownUsers(): Map<string, KnownUser> {
if (knownUsersCache !== null) {
// 检查文件是否被修改
try {
const stat = fs.statSync(KNOWN_USERS_FILE);
if (stat.mtimeMs <= cacheLastModified) {
return knownUsersCache;
}
} catch {
// 文件不存在,使用缓存
return knownUsersCache;
}
}
const users = new Map<string, KnownUser>();
try {
if (fs.existsSync(KNOWN_USERS_FILE)) {
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
const parsed = JSON.parse(data) as KnownUser[];
for (const user of parsed) {
const key = getUserKey(user.type, user.openid, user.accountId);
users.set(key, user);
}
cacheLastModified = fs.statSync(KNOWN_USERS_FILE).mtimeMs;
}
} catch (err) {
console.error(`[qqbot:proactive] Failed to load known users: ${err}`);
}
knownUsersCache = users;
return users;
}
/**
* 保存已知用户到文件
*/
function saveKnownUsers(users: Map<string, KnownUser>): void {
try {
ensureStorageDir();
const data = Array.from(users.values());
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(data, null, 2), "utf-8");
cacheLastModified = Date.now();
knownUsersCache = users;
} catch (err) {
console.error(`[qqbot:proactive] Failed to save known users: ${err}`);
}
}
/**
* 记录一个已知用户(当收到用户消息时调用)
*
* @param user - 用户信息
*/
export function recordKnownUser(user: Omit<KnownUser, "firstInteractionAt">): void {
const users = loadKnownUsers();
const key = getUserKey(user.type, user.openid, user.accountId);
const existing = users.get(key);
const now = user.lastInteractionAt || Date.now();
users.set(key, {
...user,
lastInteractionAt: now,
firstInteractionAt: existing?.firstInteractionAt ?? now,
// 更新昵称(如果有新的)
nickname: user.nickname || existing?.nickname,
});
saveKnownUsers(users);
console.log(`[qqbot:proactive] Recorded user: ${key}`);
}
/**
* 获取一个已知用户
*
* @param type - 用户类型
* @param openid - 用户 openid
* @param accountId - 账户 ID
*/
export function getKnownUser(type: string, openid: string, accountId: string): KnownUser | undefined {
const users = loadKnownUsers();
const key = getUserKey(type, openid, accountId);
return users.get(key);
}
/**
* 列出已知用户
*
* @param options - 过滤选项
*/
export function listKnownUsers(options?: ListKnownUsersOptions): KnownUser[] {
const users = loadKnownUsers();
let result = Array.from(users.values());
// 过滤类型
if (options?.type) {
result = result.filter(u => u.type === options.type);
}
// 过滤账户
if (options?.accountId) {
result = result.filter(u => u.accountId === options.accountId);
}
// 排序
if (options?.sortByLastInteraction !== false) {
result.sort((a, b) => b.lastInteractionAt - a.lastInteractionAt);
}
// 限制数量
if (options?.limit && options.limit > 0) {
result = result.slice(0, options.limit);
}
return result;
}
/**
* 删除一个已知用户
*
* @param type - 用户类型
* @param openid - 用户 openid
* @param accountId - 账户 ID
*/
export function removeKnownUser(type: string, openid: string, accountId: string): boolean {
const users = loadKnownUsers();
const key = getUserKey(type, openid, accountId);
const deleted = users.delete(key);
if (deleted) {
saveKnownUsers(users);
}
return deleted;
}
/**
* 清除所有已知用户
*
* @param accountId - 可选,只清除指定账户的用户
*/
export function clearKnownUsers(accountId?: string): number {
const users = loadKnownUsers();
let count = 0;
if (accountId) {
for (const [key, user] of users) {
if (user.accountId === accountId) {
users.delete(key);
count++;
}
}
} else {
count = users.size;
users.clear();
}
if (count > 0) {
saveKnownUsers(users);
}
return count;
}
// ============ 主动发送消息 ============
/**
* 主动发送消息(带配置解析)
* 注意:与 outbound.ts 中的 sendProactiveMessage 不同,这个函数接受 OpenClawConfig 并自动解析账户
*
* @param options - 发送选项
* @param cfg - OpenClaw 配置
* @returns 发送结果
*
* @example
* ```typescript
* // 发送私聊消息
* const result = await sendProactive({
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4", // 用户 openid
* text: "你好!这是一条主动消息",
* type: "c2c",
* }, cfg);
*
* // 发送群聊消息
* const result = await sendProactive({
* to: "A1B2C3D4E5F6A7B8", // 群组 openid
* text: "群公告:今天有活动",
* type: "group",
* }, cfg);
*
* // 发送带图片的消息
* const result = await sendProactive({
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4",
* text: "看看这张图片",
* imageUrl: "https://example.com/image.png",
* type: "c2c",
* }, cfg);
* ```
*/
export async function sendProactive(
options: ProactiveSendOptions,
cfg: OpenClawConfig
): Promise<ProactiveSendResult> {
const { to, text, type = "c2c", imageUrl, accountId = "default" } = options;
// 解析账户配置
const account = resolveQQBotAccount(cfg, accountId);
if (!account.appId || !account.clientSecret) {
return {
success: false,
error: "QQBot not configured (missing appId or clientSecret)",
};
}
try {
const accessToken = await getAccessToken(account.appId, account.clientSecret);
// 如果有图片,先发送图片
if (imageUrl) {
try {
if (type === "c2c") {
await sendC2CImageMessage(accessToken, to, imageUrl, undefined, undefined);
} else if (type === "group") {
await sendGroupImageMessage(accessToken, to, imageUrl, undefined, undefined);
}
console.log(`[qqbot:proactive] Sent image to ${type}:${to}`);
} catch (err) {
console.error(`[qqbot:proactive] Failed to send image: ${err}`);
// 图片发送失败不影响文本发送
}
}
// 发送文本消息
let result: { id: string; timestamp: number | string };
if (type === "c2c") {
result = await sendProactiveC2CMessage(accessToken, to, text);
} else if (type === "group") {
result = await sendProactiveGroupMessage(accessToken, to, text);
} else if (type === "channel") {
// 频道消息需要 channel_id这里暂时不支持主动发送
return {
success: false,
error: "Channel proactive messages are not supported. Please use group or c2c.",
};
} else {
return {
success: false,
error: `Unknown message type: ${type}`,
};
}
console.log(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`);
return {
success: true,
messageId: result.id,
timestamp: result.timestamp,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[qqbot:proactive] Failed to send message: ${message}`);
return {
success: false,
error: message,
};
}
}
/**
* 批量发送主动消息
*
* @param recipients - 接收者列表openid 数组)
* @param text - 消息内容
* @param type - 消息类型
* @param cfg - OpenClaw 配置
* @param accountId - 账户 ID
* @returns 发送结果列表
*/
export async function sendBulkProactiveMessage(
recipients: string[],
text: string,
type: "c2c" | "group",
cfg: OpenClawConfig,
accountId = "default"
): Promise<Array<{ to: string; result: ProactiveSendResult }>> {
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
for (const to of recipients) {
const result = await sendProactive({ to, text, type, accountId }, cfg);
results.push({ to, result });
// 添加延迟,避免频率限制
await new Promise(resolve => setTimeout(resolve, 500));
}
return results;
}
/**
* 发送消息给所有已知用户
*
* @param text - 消息内容
* @param cfg - OpenClaw 配置
* @param options - 过滤选项
* @returns 发送结果统计
*/
export async function broadcastMessage(
text: string,
cfg: OpenClawConfig,
options?: {
type?: "c2c" | "group";
accountId?: string;
limit?: number;
}
): Promise<{
total: number;
success: number;
failed: number;
results: Array<{ to: string; result: ProactiveSendResult }>;
}> {
const users = listKnownUsers({
type: options?.type,
accountId: options?.accountId,
limit: options?.limit,
sortByLastInteraction: true,
});
// 过滤掉频道用户(不支持主动发送)
const validUsers = users.filter(u => u.type === "c2c" || u.type === "group");
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
let success = 0;
let failed = 0;
for (const user of validUsers) {
const result = await sendProactive({
to: user.openid,
text,
type: user.type as "c2c" | "group",
accountId: user.accountId,
}, cfg);
results.push({ to: user.openid, result });
if (result.success) {
success++;
} else {
failed++;
}
// 添加延迟,避免频率限制
await new Promise(resolve => setTimeout(resolve, 500));
}
return {
total: validUsers.length,
success,
failed,
results,
};
}
// ============ 辅助函数 ============
/**
* 根据账户配置直接发送主动消息(不需要 cfg
*
* @param account - 已解析的账户配置
* @param to - 目标 openid
* @param text - 消息内容
* @param type - 消息类型
*/
export async function sendProactiveMessageDirect(
account: ResolvedQQBotAccount,
to: string,
text: string,
type: "c2c" | "group" = "c2c"
): Promise<ProactiveSendResult> {
if (!account.appId || !account.clientSecret) {
return {
success: false,
error: "QQBot not configured (missing appId or clientSecret)",
};
}
try {
const accessToken = await getAccessToken(account.appId, account.clientSecret);
let result: { id: string; timestamp: number | string };
if (type === "c2c") {
result = await sendProactiveC2CMessage(accessToken, to, text);
} else {
result = await sendProactiveGroupMessage(accessToken, to, text);
}
return {
success: true,
messageId: result.id,
timestamp: result.timestamp,
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* 获取已知用户统计
*/
export function getKnownUsersStats(accountId?: string): {
total: number;
c2c: number;
group: number;
channel: number;
} {
const users = listKnownUsers({ accountId });
return {
total: users.length,
c2c: users.filter(u => u.type === "c2c").length,
group: users.filter(u => u.type === "group").length,
channel: users.filter(u => u.type === "channel").length,
};
}

292
src/session-store.ts Normal file
View 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;
}

View File

@@ -23,6 +23,8 @@ export interface ResolvedQQBotAccount {
imageServerBaseUrl?: string; imageServerBaseUrl?: string;
/** 是否支持 markdown 消息 */ /** 是否支持 markdown 消息 */
markdownSupport?: boolean; markdownSupport?: boolean;
/** 是否启用流式消息(仅 c2c 私聊支持),默认 true */
streamEnabled?: boolean;
config: QQBotAccountConfig; config: QQBotAccountConfig;
} }
@@ -43,6 +45,8 @@ export interface QQBotAccountConfig {
imageServerBaseUrl?: string; imageServerBaseUrl?: string;
/** 是否支持 markdown 消息,默认 true */ /** 是否支持 markdown 消息,默认 true */
markdownSupport?: boolean; markdownSupport?: boolean;
/** 是否启用流式消息,默认 true仅 c2c 私聊支持) */
streamEnabled?: boolean;
} }
/** /**

View File

@@ -29,7 +29,7 @@ openclaw plugins install .
echo "" echo ""
echo "[3/4] 配置机器人通道..." echo "[3/4] 配置机器人通道..."
# 默认 token可通过环境变量 QQBOT_TOKEN 覆盖 # 默认 token可通过环境变量 QQBOT_TOKEN 覆盖
QQBOT_TOKEN="${QQBOT_TOKEN:-xxx:xxx}" QQBOT_TOKEN="${QQBOT_TOKEN:-102831906:tOtPvS0Y7gGqR2eGtXBqVBrYFxfO8sdO}"
openclaw channels add --channel qqbot --token "$QQBOT_TOKEN" openclaw channels add --channel qqbot --token "$QQBOT_TOKEN"
# 启用 markdown 支持 # 启用 markdown 支持
openclaw config set channels.qqbot.markdownSupport true openclaw config set channels.qqbot.markdownSupport true