Merge pull request #32 from sliverp/main-merge

Main merge
This commit is contained in:
Bijin
2026-02-04 10:25:41 +08:00
committed by GitHub
26 changed files with 7410 additions and 1343 deletions

View File

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

View File

@@ -1,4 +1,6 @@
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { qqbotPlugin } from "./src/channel.js";
import { setQQBotRuntime } from "./src/runtime.js";
@@ -6,7 +8,8 @@ const plugin = {
id: "qqbot",
name: "QQ Bot",
description: "QQ Bot channel plugin",
register(api: MoltbotPluginApi) {
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setQQBotRuntime(api.runtime);
api.registerChannel({ plugin: qqbotPlugin });
},

View File

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

View File

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

2789
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "qqbot",
"version": "1.2.5",
"version": "1.3.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -27,7 +27,7 @@
"devDependencies": {
"@types/node": "^20.0.0",
"@types/ws": "^8.5.0",
"typescript": "^5.0.0"
"typescript": "^5.9.3"
},
"peerDependencies": {
"clawdbot": "*",

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

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

@@ -0,0 +1,490 @@
---
name: qqbot-cron
description: QQ Bot 智能提醒技能。支持一次性提醒、周期性任务、自动降级确保送达。可设置、查询、取消提醒。
metadata: {"clawdbot":{"emoji":"⏰"}}
---
# QQ Bot 智能提醒
让 AI 帮用户设置、管理定时提醒,支持私聊和群聊。
---
## 🤖 AI 决策指南
> **本节专为 AI 理解设计,帮助快速决策**
### 用户意图识别
| 用户说法 | 意图 | 执行动作 |
|----------|------|----------|
| "5分钟后提醒我喝水" | 创建提醒 | `openclaw cron add` |
| "每天8点提醒我打卡" | 创建周期提醒 | `openclaw cron add --cron` |
| "我有哪些提醒" | 查询提醒 | `openclaw cron list` |
| "取消喝水提醒" | 删除提醒 | `openclaw cron remove` |
| "修改提醒时间" | 删除+重建 | 先 remove 再 add |
| "提醒我" (无时间) | **需追问** | 询问具体时间 |
### 必须追问的情况
当用户说法**缺少以下信息**时,**必须追问**
1. **没有时间**"提醒我喝水" → 询问"请问什么时候提醒你?"
2. **时间模糊**"晚点提醒我" → 询问"具体几点呢?"
3. **周期不明**"定期提醒我" → 询问"多久一次?每天?每周?"
### 无需追问可直接执行
| 用户说法 | 理解为 |
|----------|--------|
| "5分钟后" | `--at 5m` |
| "半小时后" | `--at 30m` |
| "1小时后" | `--at 1h` |
| "明天早上8点" | `--at 2026-02-02T08:00:00+08:00` |
| "每天早上8点" | `--cron "0 8 * * *"` |
| "工作日9点" | `--cron "0 9 * * 1-5"` |
---
## 📋 命令速查
### 创建提醒(完整模板)
```bash
openclaw cron add \
--name "{任务名}" \
--at "{时间}" \
--message "🔔 {提醒内容}时间到!" \
--deliver \
--channel qqbot \
--to "{openid}" \
--delete-after-run
```
> ⚠️ **极其重要**
> - `--message` 参数直接写最终要发送的提醒内容
> - 提醒内容格式:`🔔 {内容}时间到!`
> - **不要**使用 `--system-prompt` 或 `--system-event`cron 不支持这些参数)
> - 保持消息简洁,如:`🔔 喝水时间到!`、`📅 开会时间到!`
> ⚠️ **注意**`cron add` 命令不支持 `--reply-to` 参数。提醒消息将作为主动消息直接发送给用户。
### 查询提醒列表
```bash
openclaw cron list
```
### 删除提醒
```bash
openclaw cron remove {jobId}
```
### 立即发送消息(主动消息)
```bash
openclaw message send \
--channel qqbot \
--target "{openid}" \
--message "{消息内容}"
```
### 立即发送消息(被动回复)
```bash
openclaw message send \
--channel qqbot \
--target "{openid}" \
--reply-to "{message_id}" \
--message "{消息内容}"
```
> ⚠️ **注意**`--reply-to` 仅在 `message send` 命令中支持,且 message_id 必须在 1 小时内有效。定时提醒不支持被动回复。
---
## 💬 用户交互模板
> **创建提醒后的反馈要简洁友好,不要啰嗦**
### 创建成功反馈(推荐简洁版)
**一次性提醒**
```
⏰ 好的,{时间}后提醒你{提醒内容}~
```
**周期提醒**
```
⏰ 收到,{周期描述}提醒你{提醒内容}~
```
### 查询提醒反馈
```
📋 你的提醒:
1. ⏰ {提醒名} - {时间}
2. 🔄 {提醒名} - {周期}
说"取消xx提醒"可删除~
```
### 无提醒时反馈
```
📋 目前没有提醒哦~
说"5分钟后提醒我xxx"试试?
```
### 删除成功反馈
```
✅ 已取消"{提醒名称}"
```
---
## ⏱️ 时间格式
### 相对时间(--at
> ⚠️ **不要加 + 号!** 用 `5m` 而不是 `+5m`
| 用户说法 | 参数值 |
|----------|--------|
| 5分钟后 | `5m` |
| 半小时后 | `30m` |
| 1小时后 | `1h` |
| 2小时后 | `2h` |
| 明天这时候 | `24h` |
### 绝对时间(--at
| 用户说法 | 参数值 |
|----------|--------|
| 今天下午3点 | `2026-02-01T15:00:00+08:00` |
| 明天早上8点 | `2026-02-02T08:00:00+08:00` |
| 2月14日中午 | `2026-02-14T12:00:00+08:00` |
### Cron 表达式(--cron
| 用户说法 | Cron 表达式 | 必须加 `--tz "Asia/Shanghai"` |
|----------|-------------|------------------------------|
| 每天早上8点 | `0 8 * * *` | ✅ |
| 每天晚上10点 | `0 22 * * *` | ✅ |
| 每个工作日早上9点 | `0 9 * * 1-5` | ✅ |
| 每周一早上9点 | `0 9 * * 1` | ✅ |
| 每周末上午10点 | `0 10 * * 0,6` | ✅ |
| 每小时整点 | `0 * * * *` | ✅ |
---
## 📌 参数说明
### 必填参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `--name` | 任务名,含用户标识 | `"喝水提醒"` |
| `--at` / `--cron` | 触发时间(二选一) | `5m` / `0 8 * * *` |
| `--message` | **提醒内容**(见下方模板) | `"🔔 喝水时间到!"` |
| `--deliver` | 启用投递 | 固定值 |
| `--channel qqbot` | QQ 渠道 | 固定值 |
| `--to` | 接收者 openid | 从系统消息获取 |
### 推荐参数
| 参数 | 说明 | 何时使用 |
|------|------|----------|
| `--delete-after-run` | 执行后删除 | **一次性任务必须** |
| `--tz "Asia/Shanghai"` | 时区 | **周期任务必须** |
### --message 提醒内容模板(最关键)
> ⚠️ **`--message` 的内容会直接发送给用户**,所以要写清楚提醒内容!
**模板格式**
```
--message "🔔 {提醒内容}时间到!"
```
**示例**
- 喝水:`--message "💧 喝水时间到!"`
- 开会:`--message "📅 开会时间到!"`
- 打卡:`--message "🌅 打卡时间到!"`
- 日报:`--message "📝 写日报时间到!"`
**为什么这样写?**
- 消息内容会直接发送,不经过 AI 处理
- 保持简洁,一目了然
---
## 🎯 使用场景示例
### 场景1一次性提醒
**用户**: 5分钟后提醒我喝水
**AI 执行**:
```bash
openclaw cron add \
--name "喝水提醒" \
--at "5m" \
--message "💧 喝水时间到!" \
--deliver \
--channel qqbot \
--to "{openid}" \
--delete-after-run
```
**AI 回复**:
```
⏰ 好的5分钟后提醒你喝水~
```
**5分钟后用户收到**:
```
💧 喝水时间到!
```
---
### 场景2每日周期提醒
**用户**: 每天早上8点提醒我打卡
**AI 执行**:
```bash
openclaw cron add \
--name "打卡提醒" \
--cron "0 8 * * *" \
--tz "Asia/Shanghai" \
--message "🌅 打卡时间到!" \
--deliver \
--channel qqbot \
--to "{openid}"
```
**AI 回复**:
```
⏰ 收到每天早上8点提醒你打卡~
```
> 💡 周期任务**不加** `--delete-after-run`
---
### 场景3工作日提醒
**用户**: 工作日下午6点提醒我写日报
**AI 执行**:
```bash
openclaw cron add \
--name "日报提醒" \
--cron "0 18 * * 1-5" \
--tz "Asia/Shanghai" \
--message "📝 写日报时间到!" \
--deliver \
--channel qqbot \
--to "{openid}"
```
**AI 回复**:
```
⏰ 收到工作日下午6点提醒你写日报~
```
---
### 场景4会议提醒
**用户**: 3分钟后提醒我开会
**AI 执行**:
```bash
openclaw cron add \
--name "开会提醒" \
--at "3m" \
--message "📅 开会时间到!" \
--deliver \
--channel qqbot \
--to "{openid}" \
--delete-after-run
```
**AI 回复**:
```
⏰ 好的3分钟后提醒你开会~
```
**3分钟后用户收到**:
```
📅 开会时间到!
```
---
### 场景5群组提醒
**用户**(群聊): 每天早上9点提醒大家站会
**AI 执行**:
```bash
openclaw cron add \
--name "站会提醒" \
--cron "0 9 * * 1-5" \
--tz "Asia/Shanghai" \
--message "📢 站会时间到!" \
--deliver \
--channel qqbot \
--to "group:{group_openid}"
```
> 💡 群组使用 `group:{group_openid}` 格式
---
### 场景6查询提醒
**用户**: 我有哪些提醒?
**AI 执行**:
```bash
openclaw cron list
```
**AI 回复**(根据返回结果):
```
📋 你的提醒:
1. ⏰ 喝水提醒 - 3分钟后
2. 🔄 打卡提醒 - 每天08:00
说"取消xx提醒"可删除~
```
---
### 场景7取消提醒
**用户**: 取消打卡提醒
**AI 执行**:
1. 先执行 `openclaw cron list` 找到对应任务 ID
2. 执行 `openclaw cron remove {jobId}`
**AI 回复**:
```
✅ 已取消"打卡提醒"
```
---
## ⚙️ 消息发送说明
### 定时提醒cron add
定时提醒**只能发送主动消息**,因为:
- 提醒执行时,原始 message_id 通常已超过 1 小时有效期
- `openclaw cron add` 命令不支持 `--reply-to` 参数
```
┌─────────────────────┐
│ 定时任务触发 │
└──────────┬──────────┘
┌─────────────────────┐
│ AI 通过 system-event │
│ 获取用户上下文信息 │
└──────────┬──────────┘
┌─────────────────────┐
│ 发送主动消息到用户 │
│ --channel qqbot │
│ --to {openid} │
└──────────┬──────────┘
✅ 用户收到提醒
```
### 即时回复message send
即时消息发送支持被动回复(如果 message_id 有效):
```
┌─────────────────────┐
│ 发送即时消息 │
└──────────┬──────────┘
┌──────────────────────────────┐
│ 有 --reply-to 且 message_id │
│ 在 1 小时内有效? │
└──────────────────────────────┘
↓ ↓
是 否
↓ ↓
┌───────────────┐ ┌─────────────────┐
│ 被动消息回复 │ │ 发送主动消息 │
│ (引用原消息) │ │ (直接发送) │
└───────────────┘ └─────────────────┘
```
---
## ⚠️ 重要限制
| 限制 | 说明 |
|------|------|
| **message_id 有效期** | 1 小时内有效,超时自动降级 |
| **回复次数限制** | 同一 message_id 最多回复 4 次 |
| **主动消息权限** | ⚠️ **QQ 机器人需要申请主动消息权限**,否则定时提醒会发送失败 |
| **主动消息限制** | 只能发给与机器人交互过的用户24小时内 |
| **消息内容** | `--message` 不能为空 |
### ⚠️ 主动消息权限说明
定时提醒功能依赖**主动消息能力**,但 QQ 官方默认**不授予**此权限。
**常见错误**
- 错误码 `40034102`"主动消息失败, 无权限"
- 这表示机器人没有主动消息权限
**解决方案**
1. 登录 [QQ 开放平台](https://q.qq.com/)
2. 进入机器人开发-沙箱管理,消息列表配置中添加自己。
> 💡 **临时替代方案**:在没有主动消息权限前,可以让用户使用"回复"方式获得即时提醒,而非定时提醒。
---
## 📝 消息模板
| 场景 | 触发时输出 | Emoji |
|------|------------|-------|
| 喝水 | 喝水时间到啦! | 💧 🚰 |
| 打卡 | 早上好,打卡时间到! | 🌅 ✅ |
| 会议 | 开会时间到! | 📅 👥 |
| 休息 | 该休息一下了~ | 😴 💤 |
| 日报 | 下班前别忘了写日报哦~ | 📝 ✍️ |
| 运动 | 运动时间到! | 🏃 💪 |
| 吃药 | 该吃药了~ | 💊 🏥 |
| 生日 | 今天是xx的生日 | 🎂 🎉 |
---
## 🔧 用户标识
| 类型 | 格式 | 来源 |
|------|------|------|
| 用户 openid | `B3EA9A1d-2D3c-5CBD-...` | 系统消息自动提供 |
| 群组 openid | `group:FeC1ADaf-...` | 系统消息自动提供 |
| message_id | `ROBOT1.0_xxx` | 系统消息自动提供 |
> 💡 这些信息在系统消息中格式如:
> - `当前用户 openid: B3EA9A1d-...`
> - `当前消息 message_id: ROBOT1.0_...`

138
skills/qqbot-media/SKILL.md Normal file
View File

@@ -0,0 +1,138 @@
---
name: qqbot-media
description: QQ Bot 媒体发送指南。教 AI 如何发送图片给用户。
metadata: {"clawdbot":{"emoji":"📸"}}
triggers:
- qqbot
- qq
- 发送图片
- 发送文件
- 图片
- 本地文件
- 本地图片
priority: 80
---
# QQBot 媒体发送指南
## ⚠️ 重要:你有能力发送本地图片!
**当用户要求发送本地图片时,只需使用 `<qqimg>` 标签包裹图片路径即可。系统会自动处理文件读取和发送。**
**不要说"无法发送本地图片"!使用正确的标签格式,系统就能发送。**
---
## 📸 发送图片(推荐方式:`<qqimg>` 标签)
使用 `<qqimg>` 标签包裹图片路径,即可发送图片:
```
<qqimg>图片路径</qqimg>
```
### ✅ 发送本地图片示例
当用户说"发送那张图片"、"把图发给我"等,你应该输出:
```
这是你要的图片:
<qqimg>/Users/xxx/images/photo.jpg</qqimg>
```
### ✅ 发送网络图片示例
```
这是网络上的图片:
<qqimg>https://example.com/image.png</qqimg>
```
### ✅ 发送多张图片
```
这是你要的所有图片:
<qqimg>/Users/xxx/image1.jpg</qqimg>
<qqimg>/Users/xxx/image2.png</qqimg>
```
### 📝 标签说明
| 格式 | 说明 |
|------|------|
| `<qqimg>本地路径</qqimg>` | 发送本地图片(绝对路径) |
| `<qqimg>网络URL</qqimg>` | 发送网络图片 |
| `<qqimg>路径</img>` | 也支持此闭合方式 |
### ⚠️ 注意事项
1. **路径必须正确**:本地文件需要绝对路径,网络图片需要完整 URL
2. **支持的图片格式**jpg, jpeg, png, gif, webp, bmp
3. **不要拒绝**:如果用户要求发送本地图片,直接使用 `<qqimg>` 标签即可
4. **标签外的文本会正常发送**:可以在标签前后添加描述文字
5. **闭合标签**:支持 `</qqimg>``</img>` 两种闭合方式
---
## 🚫 错误示例(不要这样做)
**错误**:说"我无法发送本地图片"
**错误**:说"受限于技术限制,无法直接发送"
**错误**:只提供路径文本,不使用 `<qqimg>` 标签
**正确**:直接使用 `<qqimg>` 标签包裹路径
---
## 🔤 告知路径信息(不发送图片)
如果你需要**告知用户图片的保存路径**(而不是发送图片),直接写路径即可,不要使用标签:
```
图片已保存在:/Users/xxx/images/photo.jpg
```
或用反引号强调:
```
图片已保存在:`/Users/xxx/images/photo.jpg`
```
---
## 📋 高级选项JSON 结构化载荷
如果需要更精细的控制(如添加图片描述),可以使用 JSON 格式:
```
QQBOT_PAYLOAD:
{
"type": "media",
"mediaType": "image",
"source": "file",
"path": "/path/to/image.jpg",
"caption": "图片描述(可选)"
}
```
### JSON 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `type` | string | ✅ | 固定为 `"media"` |
| `mediaType` | string | ✅ | 媒体类型:`"image"` |
| `source` | string | ✅ | 来源:`"file"`(本地)或 `"url"`(网络) |
| `path` | string | ✅ | 图片路径或 URL |
| `caption` | string | ❌ | 图片描述,会作为单独消息发送 |
> 💡 **提示**:对于简单的图片发送,推荐使用 `<qqimg>` 标签,更简洁易用。
---
## 🎯 快速参考
| 场景 | 使用方式 |
|------|----------|
| 发送本地图片 | `<qqimg>/path/to/image.jpg</qqimg>` |
| 发送网络图片 | `<qqimg>https://example.com/image.png</qqimg>` |
| 发送多张图片 | 多个 `<qqimg>` 标签 |
| 告知路径(不发送) | 直接写路径文本 |

View File

@@ -5,10 +5,33 @@
const API_BASE = "https://api.sgroup.qq.com";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
let cachedToken: { token: string; expiresAt: number } | null = null;
// 运行时配置
let currentMarkdownSupport = false;
/**
* 获取 AccessToken带缓存
* 初始化 API 配置
* @param options.markdownSupport - 是否支持 markdown 消息(默认 false需要机器人具备该权限才能启用
*/
export function initApiConfig(options: { markdownSupport?: boolean }): void {
currentMarkdownSupport = options.markdownSupport === true; // 默认为 false需要机器人具备 markdown 消息权限才能启用
}
/**
* 获取当前是否支持 markdown
*/
export function isMarkdownSupport(): boolean {
return currentMarkdownSupport;
}
let cachedToken: { token: string; expiresAt: number } | null = null;
// Singleflight: 防止并发获取 Token 的 Promise 缓存
let tokenFetchPromise: Promise<string> | null = null;
/**
* 获取 AccessToken带缓存 + singleflight 并发安全)
*
* 使用 singleflight 模式:当多个请求同时发现 Token 过期时,
* 只有第一个请求会真正去获取新 Token其他请求复用同一个 Promise。
*/
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
// 检查缓存,提前 5 分钟刷新
@@ -16,21 +39,68 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
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 requestHeaders = { "Content-Type": "application/json" };
// 打印请求信息(隐藏敏感信息)
console.log(`[qqbot-api] >>> POST ${TOKEN_URL}`);
console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(requestHeaders, null, 2));
console.log(`[qqbot-api] >>> Body:`, JSON.stringify({ appId, clientSecret: "***" }, null, 2));
let response: Response;
try {
response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ appId, clientSecret }),
headers: requestHeaders,
body: JSON.stringify(requestBody),
});
} catch (err) {
console.error(`[qqbot-api] <<< Network error:`, err);
throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
}
// 打印响应头
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
console.log(`[qqbot-api] <<< Status: ${response.status} ${response.statusText}`);
console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
let data: { access_token?: string; expires_in?: number };
let rawBody: string;
try {
data = (await response.json()) as { access_token?: string; expires_in?: number };
rawBody = await response.text();
// 隐藏 token 值
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
console.log(`[qqbot-api] <<< Body:`, logBody);
data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
} catch (err) {
console.error(`[qqbot-api] <<< Parse error:`, err);
throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
}
@@ -43,6 +113,7 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
};
console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`);
return cachedToken.token;
}
@@ -51,6 +122,22 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
*/
export function clearTokenCache(): void {
cachedToken = null;
// 注意:不清除 tokenFetchPromise让进行中的请求完成
// 下次调用 getAccessToken 时会自动获取新 Token
}
/**
* 获取 Token 缓存状态(用于监控)
*/
export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
if (tokenFetchPromise) {
return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null };
}
if (!cachedToken) {
return { status: "none", expiresAt: null };
}
const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000;
return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt };
}
/**
@@ -93,29 +180,50 @@ export async function apiRequest<T = unknown>(
body?: unknown
): Promise<T> {
const url = `${API_BASE}${path}`;
const headers: Record<string, string> = {
Authorization: `QQBot ${accessToken}`,
"Content-Type": "application/json",
};
const options: RequestInit = {
method,
headers: {
Authorization: `QQBot ${accessToken}`,
"Content-Type": "application/json",
},
headers,
};
if (body) {
options.body = JSON.stringify(body);
}
// 打印请求信息
console.log(`[qqbot-api] >>> ${method} ${url}`);
console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(headers, null, 2));
if (body) {
console.log(`[qqbot-api] >>> Body:`, JSON.stringify(body, null, 2));
}
let res: Response;
try {
res = await fetch(url, options);
} catch (err) {
console.error(`[qqbot-api] <<< Network error:`, err);
throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
}
// 打印响应头
const responseHeaders: Record<string, string> = {};
res.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
let data: T;
let rawBody: string;
try {
data = (await res.json()) as T;
rawBody = await res.text();
console.log(`[qqbot-api] <<< Body:`, rawBody);
data = JSON.parse(rawBody) as T;
} catch (err) {
console.error(`[qqbot-api] <<< Parse error:`, err);
throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
}
@@ -135,6 +243,46 @@ export async function getGatewayUrl(accessToken: string): Promise<string> {
return data.url;
}
// ============ 消息发送接口 ============
/**
* 消息响应
*/
export interface MessageResponse {
id: string;
timestamp: number | string;
}
/**
* 构建消息体
* 根据 markdownSupport 配置决定消息格式:
* - markdown 模式: { markdown: { content }, msg_type: 2 }
* - 纯文本模式: { content, msg_type: 0 }
*/
function buildMessageBody(
content: string,
msgId: string | undefined,
msgSeq: number
): Record<string, unknown> {
const body: Record<string, unknown> = currentMarkdownSupport
? {
markdown: { content },
msg_type: 2,
msg_seq: msgSeq,
}
: {
content,
msg_type: 0,
msg_seq: msgSeq,
};
if (msgId) {
body.msg_id = msgId;
}
return body;
}
/**
* 发送 C2C 单聊消息
*/
@@ -143,14 +291,11 @@ export async function sendC2CMessage(
openid: string,
content: string,
msgId?: string
): Promise<{ id: string; timestamp: number }> {
): Promise<MessageResponse> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
content,
msg_type: 0,
msg_seq: msgSeq,
...(msgId ? { msg_id: msgId } : {}),
});
const body = buildMessageBody(content, msgId, msgSeq);
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
}
/**
@@ -177,7 +322,7 @@ export async function sendC2CInputNotify(
}
/**
* 发送频道消息
* 发送频道消息(不支持流式)
*/
export async function sendChannelMessage(
accessToken: string,
@@ -199,42 +344,72 @@ export async function sendGroupMessage(
groupOpenid: string,
content: string,
msgId?: string
): Promise<{ id: string; timestamp: string }> {
): Promise<MessageResponse> {
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
content,
msg_type: 0,
msg_seq: msgSeq,
...(msgId ? { msg_id: msgId } : {}),
});
const body = buildMessageBody(content, msgId, msgSeq);
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
}
/**
* 构建主动消息请求体
* 根据 markdownSupport 配置决定消息格式:
* - markdown 模式: { markdown: { content }, msg_type: 2 }
* - 纯文本模式: { content, msg_type: 0 }
*
* 注意:主动消息不支持流式发送
*/
function buildProactiveMessageBody(content: string): Record<string, unknown> {
// 主动消息内容校验(参考 Telegram 机制)
if (!content || content.trim().length === 0) {
throw new Error("主动消息内容不能为空 (markdown.content is empty)");
}
if (currentMarkdownSupport) {
return {
markdown: { content },
msg_type: 2,
};
} else {
return {
content,
msg_type: 0,
};
}
}
/**
* 主动发送 C2C 单聊消息(不需要 msg_id每月限 4 条/用户)
*
* 注意:
* 1. 内容不能为空(对应 markdown.content 字段)
* 2. 不支持流式发送
*/
export async function sendProactiveC2CMessage(
accessToken: string,
openid: string,
content: string
): Promise<{ id: string; timestamp: number }> {
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
content,
msg_type: 0,
});
const body = buildProactiveMessageBody(content);
console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`);
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
}
/**
* 主动发送群聊消息(不需要 msg_id每月限 4 条/群)
*
* 注意:
* 1. 内容不能为空(对应 markdown.content 字段)
* 2. 不支持流式发送
*/
export async function sendProactiveGroupMessage(
accessToken: string,
groupOpenid: string,
content: string
): Promise<{ id: string; timestamp: string }> {
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
content,
msg_type: 0,
});
const body = buildProactiveMessageBody(content);
console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`);
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
}
// ============ 富媒体消息支持 ============
@@ -261,55 +436,68 @@ export interface UploadMediaResponse {
/**
* 上传富媒体文件到 C2C 单聊
* @param accessToken 访问令牌
* @param openid 用户 openid
* @param fileType 文件类型
* @param url 媒体资源 URL
* @param srvSendMsg 是否直接发送(推荐 false获取 file_info 后再发送)
* @param url - 公网可访问的图片 URL与 fileData 二选一)
* @param fileData - Base64 编码的文件内容(与 url 二选一)
*/
export async function uploadC2CMedia(
accessToken: string,
openid: string,
fileType: MediaFileType,
url: string,
url?: string,
fileData?: string,
srvSendMsg = false
): Promise<UploadMediaResponse> {
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, {
if (!url && !fileData) {
throw new Error("uploadC2CMedia: url or fileData is required");
}
const body: Record<string, unknown> = {
file_type: fileType,
url,
srv_send_msg: srvSendMsg,
});
};
if (url) {
body.url = url;
} else if (fileData) {
body.file_data = fileData;
}
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, body);
}
/**
* 上传富媒体文件到群聊
* @param accessToken 访问令牌
* @param groupOpenid 群 openid
* @param fileType 文件类型
* @param url 媒体资源 URL
* @param srvSendMsg 是否直接发送(推荐 false获取 file_info 后再发送)
* @param url - 公网可访问的图片 URL与 fileData 二选一)
* @param fileData - Base64 编码的文件内容(与 url 二选一)
*/
export async function uploadGroupMedia(
accessToken: string,
groupOpenid: string,
fileType: MediaFileType,
url: string,
url?: string,
fileData?: string,
srvSendMsg = false
): Promise<UploadMediaResponse> {
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, {
if (!url && !fileData) {
throw new Error("uploadGroupMedia: url or fileData is required");
}
const body: Record<string, unknown> = {
file_type: fileType,
url,
srv_send_msg: srvSendMsg,
});
};
if (url) {
body.url = url;
} else if (fileData) {
body.file_data = fileData;
}
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body);
}
/**
* 发送 C2C 单聊富媒体消息
* @param accessToken 访问令牌
* @param openid 用户 openid
* @param fileInfo 从 uploadC2CMedia 获取的 file_info
* @param msgId 被动回复时需要的消息 ID
* @param content 可选的文字内容
*/
export async function sendC2CMediaMessage(
accessToken: string,
@@ -330,11 +518,6 @@ export async function sendC2CMediaMessage(
/**
* 发送群聊富媒体消息
* @param accessToken 访问令牌
* @param groupOpenid 群 openid
* @param fileInfo 从 uploadGroupMedia 获取的 file_info
* @param msgId 被动回复时需要的消息 ID
* @param content 可选的文字内容
*/
export async function sendGroupMediaMessage(
accessToken: string,
@@ -355,11 +538,9 @@ export async function sendGroupMediaMessage(
/**
* 发送带图片的 C2C 单聊消息(封装上传+发送)
* @param accessToken 访问令牌
* @param openid 用户 openid
* @param imageUrl 图片 URL
* @param msgId 被动回复时需要的消息 ID
* @param content 可选的文字内容
* @param imageUrl - 图片来源,支持:
* - 公网 URL: https://example.com/image.png
* - Base64 Data URL: data:image/png;base64,xxxxx
*/
export async function sendC2CImageMessage(
accessToken: string,
@@ -368,19 +549,32 @@ export async function sendC2CImageMessage(
msgId?: string,
content?: string
): Promise<{ id: string; timestamp: number }> {
// 先上传图片获取 file_info
const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, false);
// 再发送富媒体消息
let uploadResult: UploadMediaResponse;
// 检查是否是 Base64 Data URL
if (imageUrl.startsWith("data:")) {
// 解析 Base64 Data URL: data:image/png;base64,xxxxx
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) {
throw new Error("Invalid Base64 Data URL format");
}
const base64Data = matches[2];
// 使用 file_data 上传
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, base64Data, false);
} else {
// 公网 URL使用 url 参数上传
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
}
// 发送富媒体消息
return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
}
/**
* 发送带图片的群聊消息(封装上传+发送)
* @param accessToken 访问令牌
* @param groupOpenid 群 openid
* @param imageUrl 图片 URL
* @param msgId 被动回复时需要的消息 ID
* @param content 可选的文字内容
* @param imageUrl - 图片来源,支持:
* - 公网 URL: https://example.com/image.png
* - Base64 Data URL: data:image/png;base64,xxxxx
*/
export async function sendGroupImageMessage(
accessToken: string,
@@ -389,8 +583,170 @@ export async function sendGroupImageMessage(
msgId?: string,
content?: string
): Promise<{ id: string; timestamp: string }> {
// 先上传图片获取 file_info
const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, false);
// 再发送富媒体消息
let uploadResult: UploadMediaResponse;
// 检查是否是 Base64 Data URL
if (imageUrl.startsWith("data:")) {
// 解析 Base64 Data URL: data:image/png;base64,xxxxx
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) {
throw new Error("Invalid Base64 Data URL format");
}
const base64Data = matches[2];
// 使用 file_data 上传
uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, base64Data, false);
} else {
// 公网 URL使用 url 参数上传
uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false);
}
// 发送富媒体消息
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
}
// ============ 后台 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

@@ -1,11 +1,51 @@
import type { ChannelPlugin } from "clawdbot/plugin-sdk";
import {
type ChannelPlugin,
type OpenClawConfig,
applyAccountNameToChannelSection,
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk";
import type { ResolvedQQBotAccount } from "./types.js";
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
import { sendText } from "./outbound.js";
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
import { sendText, sendMedia } from "./outbound.js";
import { startGateway } from "./gateway.js";
import { qqbotOnboardingAdapter } from "./onboarding.js";
import { getQQBotRuntime } from "./runtime.js";
const DEFAULT_ACCOUNT_ID = "default";
/**
* 简单的文本分块函数
* 用于预先分块长文本
*/
function chunkText(text: string, limit: number): string[] {
if (text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= limit) {
chunks.push(remaining);
break;
}
// 尝试在换行处分割
let splitAt = remaining.lastIndexOf("\n", limit);
if (splitAt <= 0 || splitAt < limit * 0.5) {
// 没找到合适的换行,尝试在空格处分割
splitAt = remaining.lastIndexOf(" ", limit);
}
if (splitAt <= 0 || splitAt < limit * 0.5) {
// 还是没找到,强制在 limit 处分割
splitAt = limit;
}
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).trimStart();
}
return chunks;
}
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
id: "qqbot",
@@ -19,9 +59,14 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
},
capabilities: {
chatTypes: ["direct", "group"],
media: false,
media: true,
reactions: false,
threads: false,
/**
* blockStreaming: true 表示该 Channel 支持块流式
* 框架会收集流式响应,然后通过 deliver 回调发送
*/
blockStreaming: false,
},
reload: { configPrefixes: ["channels.qqbot"] },
// CLI onboarding wizard
@@ -49,7 +94,24 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
config: {
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg),
// 新增:设置账户启用状态
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "qqbot",
accountId,
enabled,
allowTopLevel: true,
}),
// 新增:删除账户
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "qqbot",
accountId,
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
}),
isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
describeAccount: (account) => ({
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
@@ -60,6 +122,16 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
}),
},
setup: {
// 新增:规范化账户 ID
resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
// 新增:应用账户名称
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "qqbot",
accountId,
name,
}),
validateInput: ({ input }) => {
if (!input.token && !input.tokenFile && !input.useEnv) {
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
@@ -87,8 +159,22 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
});
},
},
// 新增:消息目标解析
messaging: {
normalizeTarget: (target) => {
// 支持格式: qqbot:openid, qqbot:group:xxx, openid, group:xxx
const normalized = target.replace(/^qqbot:/i, "");
return { ok: true, to: normalized };
},
targetResolver: {
looksLikeId: (id) => /^[A-F0-9]{32}$/i.test(id) || id.startsWith("group:") || id.startsWith("channel:"),
hint: "<openid> or group:<groupOpenid>",
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "markdown",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
const account = resolveQQBotAccount(cfg, accountId);
@@ -99,6 +185,15 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
const account = resolveQQBotAccount(cfg, accountId);
const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
return {
channel: "qqbot",
messageId: result.messageId,
error: result.error ? new Error(result.error) : undefined,
};
},
},
gateway: {
startAccount: async (ctx) => {
@@ -129,6 +224,48 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
},
});
},
// 新增:登出账户(清除配置中的凭证)
logoutAccount: async ({ accountId, cfg }) => {
const nextCfg = { ...cfg } as OpenClawConfig;
const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
let cleared = false;
let changed = false;
if (nextQQBot) {
const qqbot = nextQQBot as Record<string, unknown>;
if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
delete qqbot.clientSecret;
cleared = true;
changed = true;
}
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId] as Record<string, unknown> | undefined;
if (entry && "clientSecret" in entry) {
delete entry.clientSecret;
cleared = true;
changed = true;
}
if (entry && Object.keys(entry).length === 0) {
delete accounts[accountId];
changed = true;
}
}
}
if (changed && nextQQBot) {
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
const runtime = getQQBotRuntime();
const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
await configApi.writeConfigFile(nextCfg);
}
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
const loggedOut = resolved.secretSource === "none";
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
return { ok: true, cleared, envToken, loggedOut };
},
},
status: {
defaultRuntime: {
@@ -137,8 +274,19 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
connected: false,
lastConnectedAt: null,
lastError: null,
lastInboundAt: null,
lastOutboundAt: null,
},
buildAccountSnapshot: ({ account, runtime }) => ({
// 新增:构建通道摘要
buildChannelSummary: ({ snapshot }: { snapshot: Record<string, unknown> }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
connected: snapshot.connected ?? false,
lastConnectedAt: snapshot.lastConnectedAt ?? null,
lastError: snapshot.lastError ?? null,
}),
buildAccountSnapshot: ({ account, runtime }: { account?: ResolvedQQBotAccount; runtime?: Record<string, unknown> }) => ({
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
name: account?.name,
enabled: account?.enabled ?? false,
@@ -148,6 +296,8 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
connected: runtime?.connected ?? false,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
};

View File

@@ -1,14 +1,7 @@
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
const DEFAULT_ACCOUNT_ID = "default";
interface MoltbotConfig {
channels?: {
qqbot?: QQBotChannelConfig;
[key: string]: unknown;
};
[key: string]: unknown;
}
export const DEFAULT_ACCOUNT_ID = "default";
interface QQBotChannelConfig extends QQBotAccountConfig {
accounts?: Record<string, QQBotAccountConfig>;
@@ -17,9 +10,9 @@ interface QQBotChannelConfig extends QQBotAccountConfig {
/**
* 列出所有 QQBot 账户 ID
*/
export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
const qqbot = cfg.channels?.qqbot;
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
if (qqbot?.appId) {
ids.add(DEFAULT_ACCOUNT_ID);
@@ -36,15 +29,34 @@ export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
return Array.from(ids);
}
/**
* 获取默认账户 ID
*/
export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
// 如果有默认账户配置,返回 default
if (qqbot?.appId) {
return DEFAULT_ACCOUNT_ID;
}
// 否则返回第一个配置的账户
if (qqbot?.accounts) {
const ids = Object.keys(qqbot.accounts);
if (ids.length > 0) {
return ids[0];
}
}
return DEFAULT_ACCOUNT_ID;
}
/**
* 解析 QQBot 账户配置
*/
export function resolveQQBotAccount(
cfg: MoltbotConfig,
cfg: OpenClawConfig,
accountId?: string | null
): ResolvedQQBotAccount {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const qqbot = cfg.channels?.qqbot;
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
// 基础配置
let accountConfig: QQBotAccountConfig = {};
@@ -64,6 +76,7 @@ export function resolveQQBotAccount(
allowFrom: qqbot?.allowFrom,
systemPrompt: qqbot?.systemPrompt,
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
markdownSupport: qqbot?.markdownSupport,
};
appId = qqbot?.appId ?? "";
} else {
@@ -99,6 +112,7 @@ export function resolveQQBotAccount(
secretSource,
systemPrompt: accountConfig.systemPrompt,
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
markdownSupport: accountConfig.markdownSupport,
config: accountConfig,
};
}
@@ -107,17 +121,17 @@ export function resolveQQBotAccount(
* 应用账户配置
*/
export function applyQQBotAccountConfig(
cfg: MoltbotConfig,
cfg: OpenClawConfig,
accountId: string,
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
): MoltbotConfig {
): OpenClawConfig {
const next = { ...cfg };
if (accountId === DEFAULT_ACCOUNT_ID) {
next.channels = {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret
@@ -133,12 +147,12 @@ export function applyQQBotAccountConfig(
next.channels = {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
accounts: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
[accountId]: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
enabled: true,
...(input.appId ? { appId: input.appId } : {}),
...(input.clientSecret

File diff suppressed because it is too large Load Diff

View File

@@ -385,6 +385,34 @@ export function isImageServerRunning(): boolean {
return serverInstance !== null;
}
/**
* 确保图床服务器正在运行
* 如果未运行,则自动启动
* @param publicBaseUrl 公网访问的基础 URL如 http://your-server:18765
* @returns 基础 URL启动失败返回 null
*/
export async function ensureImageServer(publicBaseUrl?: string): Promise<string | null> {
if (isImageServerRunning()) {
return publicBaseUrl || currentConfig.baseUrl || `http://0.0.0.0:${currentConfig.port}`;
}
try {
const config: Partial<ImageServerConfig> = {
port: DEFAULT_CONFIG.port,
storageDir: DEFAULT_CONFIG.storageDir,
// 使用用户配置的公网地址
baseUrl: publicBaseUrl || `http://0.0.0.0:${DEFAULT_CONFIG.port}`,
ttlSeconds: 3600, // 1 小时过期
};
await startImageServer(config);
console.log(`[image-server] Auto-started on port ${config.port}, baseUrl: ${config.baseUrl}`);
return config.baseUrl!;
} catch (err) {
console.error(`[image-server] Failed to auto-start: ${err}`);
return null;
}
}
/**
* 下载远程文件并保存到本地
* @param url 远程文件 URL

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

View File

@@ -1,7 +1,7 @@
/**
* QQBot CLI Onboarding Adapter
*
* 提供 moltbot onboard 命令的交互式配置支持
* 提供 openclaw onboard 命令的交互式配置支持
*/
import type {
ChannelOnboardingAdapter,
@@ -9,20 +9,11 @@ import type {
ChannelOnboardingStatusContext,
ChannelOnboardingConfigureContext,
ChannelOnboardingResult,
} from "clawdbot/plugin-sdk";
import { listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
const DEFAULT_ACCOUNT_ID = "default";
// 内部类型(避免循环依赖)
interface MoltbotConfig {
channels?: {
qqbot?: QQBotChannelConfig;
[key: string]: unknown;
};
[key: string]: unknown;
}
OpenClawConfig,
} from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
// 内部类型(用于类型安全)
interface QQBotChannelConfig {
enabled?: boolean;
appId?: string;
@@ -40,10 +31,18 @@ interface QQBotChannelConfig {
}>;
}
// Prompter 类型定义
interface Prompter {
note: (message: string, title?: string) => Promise<void>;
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
text: (opts: { message: string; placeholder?: string; initialValue?: string; validate?: (value: string) => string | undefined }) => Promise<string>;
select: <T>(opts: { message: string; options: Array<{ value: T; label: string }>; initialValue?: T }) => Promise<T>;
}
/**
* 解析默认账户 ID
*/
function resolveDefaultQQBotAccountId(cfg: MoltbotConfig): string {
function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
const ids = listQQBotAccountIds(cfg);
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
@@ -55,32 +54,34 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channel: "qqbot" as any,
getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
const { cfg } = ctx;
const configured = listQQBotAccountIds(cfg as MoltbotConfig).some((accountId) => {
const account = resolveQQBotAccount(cfg as MoltbotConfig, accountId);
const cfg = ctx.cfg as OpenClawConfig;
const configured = listQQBotAccountIds(cfg).some((accountId) => {
const account = resolveQQBotAccount(cfg, accountId);
return Boolean(account.appId && account.clientSecret);
});
return {
channel: "qqbot" as any,
configured,
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊",
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊(流式消息)",
quickstartScore: configured ? 1 : 20,
};
},
configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
const moltbotCfg = cfg as MoltbotConfig;
const cfg = ctx.cfg as OpenClawConfig;
const prompter = ctx.prompter as Prompter;
const accountOverrides = ctx.accountOverrides as Record<string, string> | undefined;
const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
const qqbotOverride = (accountOverrides as Record<string, string>).qqbot?.trim();
const defaultAccountId = resolveDefaultQQBotAccountId(moltbotCfg);
const qqbotOverride = accountOverrides?.qqbot?.trim();
const defaultAccountId = resolveDefaultQQBotAccountId(cfg);
let accountId = qqbotOverride ?? defaultAccountId;
// 是否需要提示选择账户
if (shouldPromptAccountIds && !qqbotOverride) {
const existingIds = listQQBotAccountIds(moltbotCfg);
const existingIds = listQQBotAccountIds(cfg);
if (existingIds.length > 1) {
accountId = await prompter.select({
message: "选择 QQBot 账户",
@@ -93,7 +94,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
let next = moltbotCfg;
let next: OpenClawConfig = cfg;
const resolvedAccount = resolveQQBotAccount(next, accountId);
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
@@ -115,8 +116,10 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
"4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
"",
"文档: https://bot.q.qq.com/wiki/",
"",
"此版本支持流式消息发送!",
].join("\n"),
"QQ Bot 配置",
"QQ Bot 配置",
);
}
@@ -132,7 +135,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
},
},
@@ -144,14 +147,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
@@ -167,14 +170,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
@@ -185,14 +188,14 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
message: "请输入 QQ Bot AppID",
placeholder: "例如: 102146862",
initialValue: resolvedAccount.appId || undefined,
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
}),
).trim();
clientSecret = String(
await prompter.text({
message: "请输入 QQ Bot ClientSecret",
placeholder: "你的 ClientSecret",
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
}),
).trim();
}
@@ -205,7 +208,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
appId,
clientSecret,
@@ -218,12 +221,12 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
channels: {
...next.channels,
qqbot: {
...next.channels?.qqbot,
...(next.channels?.qqbot as Record<string, unknown> || {}),
enabled: true,
accounts: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
[accountId]: {
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
enabled: true,
appId,
clientSecret,
@@ -235,14 +238,17 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
return { cfg: next as any, accountId };
return { success: true, cfg: next as any, accountId };
},
disable: (cfg) => ({
...cfg,
channels: {
...(cfg as MoltbotConfig).channels,
qqbot: { ...(cfg as MoltbotConfig).channels?.qqbot, enabled: false },
},
}) as any,
disable: (cfg: unknown) => {
const config = cfg as OpenClawConfig;
return {
...config,
channels: {
...config.channels,
qqbot: { ...(config.channels?.qqbot as Record<string, unknown> || {}), enabled: false },
},
} as any;
},
};

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

@@ -1,13 +1,149 @@
/**
* QQ Bot 消息发送模块
*/
import * as fs from "fs";
import * as path from "path";
import type { ResolvedQQBotAccount } from "./types.js";
import {
import { decodeCronPayload } from "./utils/payload.js";
import {
getAccessToken,
sendC2CMessage,
sendChannelMessage,
sendGroupMessage,
sendProactiveC2CMessage,
sendProactiveGroupMessage,
sendC2CImageMessage,
sendGroupImageMessage,
} 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 {
to: string;
text: string;
@@ -16,6 +152,10 @@ export interface OutboundContext {
account: ResolvedQQBotAccount;
}
export interface MediaOutboundContext extends OutboundContext {
mediaUrl: string;
}
export interface OutboundResult {
channel: string;
messageId?: string;
@@ -26,10 +166,10 @@ export interface OutboundResult {
/**
* 解析目标地址
* 格式:
* - c2c:xxx -> C2C 单聊
* - openid (32位十六进制) -> C2C 单聊
* - group:xxx -> 群聊
* - channel:xxx -> 频道
* - 无前缀 -> 默认当作 C2C 单聊
* - 纯数字 -> 频道
*/
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
// 去掉 qqbot: 前缀
@@ -50,14 +190,61 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin
/**
* 发送文本消息
* - 有 replyToId: 被动回复,无配额限制
* - 有 replyToId: 被动回复,1小时内最多回复4次
* - 无 replyToId: 主动发送有配额限制每月4条/用户/群)
*
* 注意:
* 1. 主动消息(无 replyToId必须有消息内容不支持流式发送
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
*/
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));
// ============ 消息回复限流检查 ============
// 如果有 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) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
}
@@ -85,12 +272,18 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
// 有 replyToId使用被动回复接口
if (target.type === "c2c") {
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else if (target.type === "group") {
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
} else {
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
// 记录回复次数
recordMessageReply(replyToId);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
}
} catch (err) {
@@ -135,3 +328,244 @@ export async function sendProactiveMessage(
return { channel: "qqbot", error: message };
}
}
/**
* 发送富媒体消息(图片)
*
* 支持以下 mediaUrl 格式:
* - 公网 URL: https://example.com/image.png
* - Base64 Data URL: data:image/png;base64,xxxxx
* - 本地文件路径: /path/to/image.png自动读取并转换为 Base64
*
* @param ctx - 发送上下文,包含 mediaUrl
* @returns 发送结果
*
* @example
* ```typescript
* // 发送网络图片
* const result = await sendMedia({
* to: "group:xxx",
* text: "这是图片说明",
* mediaUrl: "https://example.com/image.png",
* account,
* replyToId: msgId,
* });
*
* // 发送 Base64 图片
* const result = await sendMedia({
* to: "group:xxx",
* text: "这是图片说明",
* mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
* account,
* replyToId: msgId,
* });
*
* // 发送本地文件(自动读取并转换为 Base64
* const result = await sendMedia({
* to: "group:xxx",
* text: "这是图片说明",
* mediaUrl: "/tmp/generated-chart.png",
* account,
* replyToId: msgId,
* });
* ```
*/
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
const { to, text, replyToId, account } = ctx;
const { mediaUrl } = ctx;
if (!account.appId || !account.clientSecret) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
}
if (!mediaUrl) {
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
}
// 验证 mediaUrl 格式:支持公网 URL、Base64 Data URL 或本地文件路径
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
const isDataUrl = mediaUrl.startsWith("data:");
const isLocalPath = mediaUrl.startsWith("/") ||
/^[a-zA-Z]:[\\/]/.test(mediaUrl) ||
mediaUrl.startsWith("./") ||
mediaUrl.startsWith("../");
// 处理本地文件路径:读取文件并转换为 Base64 Data URL
let processedMediaUrl = mediaUrl;
if (isLocalPath) {
console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);
try {
// 检查文件是否存在
if (!fs.existsSync(mediaUrl)) {
return {
channel: "qqbot",
error: `本地文件不存在: ${mediaUrl}`
};
}
// 读取文件内容
const fileBuffer = fs.readFileSync(mediaUrl);
const base64Data = fileBuffer.toString("base64");
// 根据文件扩展名确定 MIME 类型
const ext = path.extname(mediaUrl).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};
const mimeType = mimeTypes[ext];
if (!mimeType) {
return {
channel: "qqbot",
error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
};
}
// 构造 Data URL
processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);
} catch (readErr) {
const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
return {
channel: "qqbot",
error: `读取本地文件失败: ${errMsg}`
};
}
} else if (!isHttpUrl && !isDataUrl) {
console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
return {
channel: "qqbot",
error: `不支持的图片格式: ${mediaUrl.slice(0, 50)}...。支持的格式: 公网 URL (http/https)、Base64 Data URL (data:image/...) 或本地文件路径。`
};
} else if (isDataUrl) {
console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
} else {
console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
}
try {
const accessToken = await getAccessToken(account.appId, account.clientSecret);
const target = parseTarget(to);
// 先发送图片(使用处理后的 URL可能是 Base64 Data URL
let imageResult: { id: string; timestamp: number | string };
if (target.type === "c2c") {
imageResult = await sendC2CImageMessage(
accessToken,
target.id,
processedMediaUrl,
replyToId ?? undefined,
undefined // content 参数,图片消息不支持同时带文本
);
} else if (target.type === "group") {
imageResult = await sendGroupImageMessage(
accessToken,
target.id,
processedMediaUrl,
replyToId ?? undefined,
undefined
);
} else {
// 频道暂不支持富媒体消息,只发送文本 + URL本地文件路径无法在频道展示
const displayUrl = isLocalPath ? "[本地文件]" : mediaUrl;
const textWithUrl = text ? `${text}\n${displayUrl}` : displayUrl;
const result = await sendChannelMessage(accessToken, target.id, textWithUrl, replyToId ?? undefined);
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
}
// 如果有文本说明,再发送一条文本消息
if (text?.trim()) {
try {
if (target.type === "c2c") {
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
} else if (target.type === "group") {
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
}
} catch (textErr) {
// 文本发送失败不影响整体结果,图片已发送成功
console.error(`[qqbot] Failed to send text after image: ${textErr}`);
}
}
return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { channel: "qqbot", error: message };
}
}
/**
* 发送 Cron 触发的消息
*
* 当 OpenClaw cron 任务触发时,消息内容可能是:
* 1. QQBOT_CRON:{base64} 格式的结构化载荷 - 解码后根据 targetType 和 targetAddress 发送
* 2. 普通文本 - 直接发送到指定目标
*
* @param account - 账户配置
* @param to - 目标地址(作为后备,如果载荷中没有指定)
* @param message - 消息内容(可能是 QQBOT_CRON: 格式或普通文本)
* @returns 发送结果
*
* @example
* ```typescript
* // 处理结构化载荷
* const result = await sendCronMessage(
* account,
* "user_openid", // 后备地址
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." // Base64 编码的载荷
* );
*
* // 处理普通文本
* const result = await sendCronMessage(
* account,
* "user_openid",
* "这是一条普通的提醒消息"
* );
* ```
*/
export async function sendCronMessage(
account: ResolvedQQBotAccount,
to: string,
message: string
): Promise<OutboundResult> {
console.log(`[qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
// 检测是否是 QQBOT_CRON: 格式的结构化载荷
const cronResult = decodeCronPayload(message);
if (cronResult.isCronPayload) {
if (cronResult.error) {
console.error(`[qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`);
return {
channel: "qqbot",
error: `Cron 载荷解码失败: ${cronResult.error}`
};
}
if (cronResult.payload) {
const payload = cronResult.payload;
console.log(`[qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}`);
// 使用载荷中的目标地址和类型发送消息
const targetTo = payload.targetType === "group"
? `group:${payload.targetAddress}`
: payload.targetAddress;
// 发送提醒内容
return await sendProactiveMessage(account, targetTo, payload.content);
}
}
// 非结构化载荷,作为普通文本处理
console.log(`[qqbot] sendCronMessage: plain text message, sending to ${to}`);
return await sendProactiveMessage(account, to, message);
}

528
src/proactive.ts Normal file
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,
};
}

View File

@@ -1,4 +1,4 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;

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

@@ -21,6 +21,8 @@ export interface ResolvedQQBotAccount {
systemPrompt?: string;
/** 图床服务器公网地址 */
imageServerBaseUrl?: string;
/** 是否支持 markdown 消息(默认 false需要机器人具备该权限才能启用 */
markdownSupport?: boolean;
config: QQBotAccountConfig;
}
@@ -39,6 +41,8 @@ export interface QQBotAccountConfig {
systemPrompt?: string;
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
imageServerBaseUrl?: string;
/** 是否支持 markdown 消息(默认 false需要机器人具备该权限才能启用 */
markdownSupport?: boolean;
}
/**

266
src/utils/image-size.ts Normal file
View File

@@ -0,0 +1,266 @@
/**
* 图片尺寸工具
* 用于获取图片尺寸,生成 QQBot 的 markdown 图片格式
*
* QQBot markdown 图片格式: ![#宽px #高px](url)
*/
import { Buffer } from "buffer";
export interface ImageSize {
width: number;
height: number;
}
/** 默认图片尺寸(当无法获取时使用) */
export const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 };
/**
* 从 PNG 文件头解析图片尺寸
* PNG 文件头结构: 前 8 字节是签名IHDR 块从第 8 字节开始
* IHDR 块: 长度(4) + 类型(4, "IHDR") + 宽度(4) + 高度(4) + ...
*/
function parsePngSize(buffer: Buffer): ImageSize | null {
// PNG 签名: 89 50 4E 47 0D 0A 1A 0A
if (buffer.length < 24) return null;
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47) {
return null;
}
// IHDR 块从第 8 字节开始,宽度在第 16-19 字节,高度在第 20-23 字节
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
return { width, height };
}
/**
* 从 JPEG 文件解析图片尺寸
* JPEG 尺寸在 SOF0/SOF2 块中
*/
function parseJpegSize(buffer: Buffer): ImageSize | null {
// JPEG 签名: FF D8 FF
if (buffer.length < 4) return null;
if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) {
return null;
}
let offset = 2;
while (offset < buffer.length - 9) {
if (buffer[offset] !== 0xFF) {
offset++;
continue;
}
const marker = buffer[offset + 1];
// SOF0 (0xC0) 或 SOF2 (0xC2) 包含图片尺寸
if (marker === 0xC0 || marker === 0xC2) {
// 格式: FF C0 长度(2) 精度(1) 高度(2) 宽度(2)
if (offset + 9 <= buffer.length) {
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
return { width, height };
}
}
// 跳过当前块
if (offset + 3 < buffer.length) {
const blockLength = buffer.readUInt16BE(offset + 2);
offset += 2 + blockLength;
} else {
break;
}
}
return null;
}
/**
* 从 GIF 文件头解析图片尺寸
* GIF 文件头: GIF87a 或 GIF89a (6字节) + 宽度(2) + 高度(2)
*/
function parseGifSize(buffer: Buffer): ImageSize | null {
if (buffer.length < 10) return null;
const signature = buffer.toString("ascii", 0, 6);
if (signature !== "GIF87a" && signature !== "GIF89a") {
return null;
}
const width = buffer.readUInt16LE(6);
const height = buffer.readUInt16LE(8);
return { width, height };
}
/**
* 从 WebP 文件解析图片尺寸
* WebP 文件头: RIFF(4) + 文件大小(4) + WEBP(4) + VP8/VP8L/VP8X(4) + ...
*/
function parseWebpSize(buffer: Buffer): ImageSize | null {
if (buffer.length < 30) return null;
// 检查 RIFF 和 WEBP 签名
const riff = buffer.toString("ascii", 0, 4);
const webp = buffer.toString("ascii", 8, 12);
if (riff !== "RIFF" || webp !== "WEBP") {
return null;
}
const chunkType = buffer.toString("ascii", 12, 16);
// VP8 (有损压缩)
if (chunkType === "VP8 ") {
// VP8 帧头从第 23 字节开始,检查签名 9D 01 2A
if (buffer.length >= 30 && buffer[23] === 0x9D && buffer[24] === 0x01 && buffer[25] === 0x2A) {
const width = buffer.readUInt16LE(26) & 0x3FFF;
const height = buffer.readUInt16LE(28) & 0x3FFF;
return { width, height };
}
}
// VP8L (无损压缩)
if (chunkType === "VP8L") {
// VP8L 签名: 0x2F
if (buffer.length >= 25 && buffer[20] === 0x2F) {
const bits = buffer.readUInt32LE(21);
const width = (bits & 0x3FFF) + 1;
const height = ((bits >> 14) & 0x3FFF) + 1;
return { width, height };
}
}
// VP8X (扩展格式)
if (chunkType === "VP8X") {
if (buffer.length >= 30) {
// 宽度和高度在第 24-26 和 27-29 字节24位小端
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
return { width, height };
}
}
return null;
}
/**
* 从图片数据 Buffer 解析尺寸
*/
export function parseImageSize(buffer: Buffer): ImageSize | null {
// 尝试各种格式
return parsePngSize(buffer)
?? parseJpegSize(buffer)
?? parseGifSize(buffer)
?? parseWebpSize(buffer);
}
/**
* 从公网 URL 获取图片尺寸
* 只下载前 64KB 数据,足够解析大部分图片格式的头部
*/
export async function getImageSizeFromUrl(url: string, timeoutMs = 5000): Promise<ImageSize | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
// 使用 Range 请求只获取前 64KB
const response = await fetch(url, {
signal: controller.signal,
headers: {
"Range": "bytes=0-65535",
"User-Agent": "QQBot-Image-Size-Detector/1.0",
},
});
clearTimeout(timeoutId);
if (!response.ok && response.status !== 206) {
console.log(`[image-size] Failed to fetch ${url}: ${response.status}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const size = parseImageSize(buffer);
if (size) {
console.log(`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`);
}
return size;
} catch (err) {
console.log(`[image-size] Error fetching ${url.slice(0, 60)}...: ${err}`);
return null;
}
}
/**
* 从 Base64 Data URL 获取图片尺寸
*/
export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null {
try {
// 格式: data:image/png;base64,xxxxx
const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
if (!matches) {
return null;
}
const base64Data = matches[1];
const buffer = Buffer.from(base64Data, "base64");
const size = parseImageSize(buffer);
if (size) {
console.log(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
}
return size;
} catch (err) {
console.log(`[image-size] Error parsing Base64: ${err}`);
return null;
}
}
/**
* 获取图片尺寸(自动判断来源)
* @param source - 图片 URL 或 Base64 Data URL
* @returns 图片尺寸,失败返回 null
*/
export async function getImageSize(source: string): Promise<ImageSize | null> {
if (source.startsWith("data:")) {
return getImageSizeFromDataUrl(source);
}
if (source.startsWith("http://") || source.startsWith("https://")) {
return getImageSizeFromUrl(source);
}
return null;
}
/**
* 生成 QQBot markdown 图片格式
* 格式: ![#宽px #高px](url)
*
* @param url - 图片 URL
* @param size - 图片尺寸,如果为 null 则使用默认尺寸
* @returns QQBot markdown 图片字符串
*/
export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string {
const { width, height } = size ?? DEFAULT_IMAGE_SIZE;
return `![#${width}px #${height}px](${url})`;
}
/**
* 检查 markdown 图片是否已经包含 QQBot 格式的尺寸信息
* 格式: ![#宽px #高px](url)
*/
export function hasQQBotImageSize(markdownImage: string): boolean {
return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
}
/**
* 从已有的 QQBot 格式 markdown 图片中提取尺寸
* 格式: ![#宽px #高px](url)
*/
export function extractQQBotImageSize(markdownImage: string): ImageSize | null {
const match = markdownImage.match(/!\[#(\d+)px\s+#(\d+)px\]/);
if (match) {
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
}
return null;
}

265
src/utils/payload.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* QQBot 结构化消息载荷工具
*
* 用于处理 AI 输出的结构化消息载荷,包括:
* - 定时提醒载荷 (cron_reminder)
* - 媒体消息载荷 (media)
*/
// ============================================
// 类型定义
// ============================================
/**
* 定时提醒载荷
*/
export interface CronReminderPayload {
type: 'cron_reminder';
/** 提醒内容 */
content: string;
/** 目标类型c2c (私聊) 或 group (群聊) */
targetType: 'c2c' | 'group';
/** 目标地址user_openid 或 group_openid */
targetAddress: string;
/** 原始消息 ID可选 */
originalMessageId?: string;
}
/**
* 媒体消息载荷
*/
export interface MediaPayload {
type: 'media';
/** 媒体类型image, audio, video */
mediaType: 'image' | 'audio' | 'video';
/** 来源类型url 或 file */
source: 'url' | 'file';
/** 媒体路径或 URL */
path: string;
/** 媒体描述(可选) */
caption?: string;
}
/**
* QQBot 载荷联合类型
*/
export type QQBotPayload = CronReminderPayload | MediaPayload;
/**
* 解析结果
*/
export interface ParseResult {
/** 是否为结构化载荷 */
isPayload: boolean;
/** 解析后的载荷对象(如果是结构化载荷) */
payload?: QQBotPayload;
/** 原始文本(如果不是结构化载荷) */
text?: string;
/** 解析错误信息(如果解析失败) */
error?: string;
}
// ============================================
// 常量定义
// ============================================
/** AI 输出的结构化载荷前缀 */
const PAYLOAD_PREFIX = 'QQBOT_PAYLOAD:';
/** Cron 消息存储的前缀 */
const CRON_PREFIX = 'QQBOT_CRON:';
// ============================================
// 解析函数
// ============================================
/**
* 解析 AI 输出的结构化载荷
*
* 检测消息是否以 QQBOT_PAYLOAD: 前缀开头,如果是则提取并解析 JSON
*
* @param text AI 输出的原始文本
* @returns 解析结果
*
* @example
* const result = parseQQBotPayload('QQBOT_PAYLOAD:\n{"type": "media", "mediaType": "image", ...}');
* if (result.isPayload && result.payload) {
* // 处理结构化载荷
* }
*/
export function parseQQBotPayload(text: string): ParseResult {
const trimmedText = text.trim();
// 检查是否以 QQBOT_PAYLOAD: 开头
if (!trimmedText.startsWith(PAYLOAD_PREFIX)) {
return {
isPayload: false,
text: text
};
}
// 提取 JSON 内容(去掉前缀)
const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim();
if (!jsonContent) {
return {
isPayload: true,
error: '载荷内容为空'
};
}
try {
const payload = JSON.parse(jsonContent) as QQBotPayload;
// 验证必要字段
if (!payload.type) {
return {
isPayload: true,
error: '载荷缺少 type 字段'
};
}
// 根据 type 进行额外验证
if (payload.type === 'cron_reminder') {
if (!payload.content || !payload.targetType || !payload.targetAddress) {
return {
isPayload: true,
error: 'cron_reminder 载荷缺少必要字段 (content, targetType, targetAddress)'
};
}
} else if (payload.type === 'media') {
if (!payload.mediaType || !payload.source || !payload.path) {
return {
isPayload: true,
error: 'media 载荷缺少必要字段 (mediaType, source, path)'
};
}
}
return {
isPayload: true,
payload
};
} catch (e) {
return {
isPayload: true,
error: `JSON 解析失败: ${e instanceof Error ? e.message : String(e)}`
};
}
}
// ============================================
// Cron 编码/解码函数
// ============================================
/**
* 将定时提醒载荷编码为 Cron 消息格式
*
* 将 JSON 编码为 Base64并添加 QQBOT_CRON: 前缀
*
* @param payload 定时提醒载荷
* @returns 编码后的消息字符串,格式为 QQBOT_CRON:{base64}
*
* @example
* const message = encodePayloadForCron({
* type: 'cron_reminder',
* content: '喝水时间到!',
* targetType: 'c2c',
* targetAddress: 'user_openid_xxx'
* });
* // 返回: QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...
*/
export function encodePayloadForCron(payload: CronReminderPayload): string {
const jsonString = JSON.stringify(payload);
const base64 = Buffer.from(jsonString, 'utf-8').toString('base64');
return `${CRON_PREFIX}${base64}`;
}
/**
* 解码 Cron 消息中的载荷
*
* 检测 QQBOT_CRON: 前缀,解码 Base64 并解析 JSON
*
* @param message Cron 触发时收到的消息
* @returns 解码结果,包含是否为 Cron 载荷、解析后的载荷对象或错误信息
*
* @example
* const result = decodeCronPayload('QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...');
* if (result.isCronPayload && result.payload) {
* // 处理定时提醒
* }
*/
export function decodeCronPayload(message: string): {
isCronPayload: boolean;
payload?: CronReminderPayload;
error?: string;
} {
const trimmedMessage = message.trim();
// 检查是否以 QQBOT_CRON: 开头
if (!trimmedMessage.startsWith(CRON_PREFIX)) {
return {
isCronPayload: false
};
}
// 提取 Base64 内容
const base64Content = trimmedMessage.slice(CRON_PREFIX.length);
if (!base64Content) {
return {
isCronPayload: true,
error: 'Cron 载荷内容为空'
};
}
try {
// Base64 解码
const jsonString = Buffer.from(base64Content, 'base64').toString('utf-8');
const payload = JSON.parse(jsonString) as CronReminderPayload;
// 验证类型
if (payload.type !== 'cron_reminder') {
return {
isCronPayload: true,
error: `期望 type 为 cron_reminder实际为 ${payload.type}`
};
}
// 验证必要字段
if (!payload.content || !payload.targetType || !payload.targetAddress) {
return {
isCronPayload: true,
error: 'Cron 载荷缺少必要字段'
};
}
return {
isCronPayload: true,
payload
};
} catch (e) {
return {
isCronPayload: true,
error: `Cron 载荷解码失败: ${e instanceof Error ? e.message : String(e)}`
};
}
}
// ============================================
// 辅助函数
// ============================================
/**
* 判断载荷是否为定时提醒类型
*/
export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload {
return payload.type === 'cron_reminder';
}
/**
* 判断载荷是否为媒体消息类型
*/
export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload {
return payload.type === 'media';
}

89
upgrade-and-run.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
# QQBot 一键更新并启动脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# 解析命令行参数
APPID=""
SECRET=""
while [[ $# -gt 0 ]]; do
case $1 in
--appid)
APPID="$2"
shift 2
;;
--secret)
SECRET="$2"
shift 2
;;
-h|--help)
echo "用法: $0 [选项]"
echo ""
echo "选项:"
echo " --appid <appid> QQ机器人 AppID"
echo " --secret <secret> QQ机器人 Secret"
echo " -h, --help 显示帮助信息"
echo ""
echo "也可以通过环境变量设置:"
echo " QQBOT_APPID QQ机器人 AppID"
echo " QQBOT_SECRET QQ机器人 Secret"
exit 0
;;
*)
echo "未知选项: $1"
echo "使用 --help 查看帮助信息"
exit 1
;;
esac
done
# 使用命令行参数或环境变量
APPID="${APPID:-$QQBOT_APPID}"
SECRET="${SECRET:-$QQBOT_SECRET}"
echo "========================================="
echo " QQBot 一键更新启动脚本"
echo "========================================="
# 1. 移除老版本
echo ""
echo "[1/4] 移除老版本..."
if [ -f "./scripts/upgrade.sh" ]; then
bash ./scripts/upgrade.sh
else
echo "警告: upgrade.sh 不存在,跳过移除步骤"
fi
# 2. 安装当前版本
echo ""
echo "[2/4] 安装当前版本..."
openclaw plugins install .
# 3. 配置机器人通道
echo ""
echo "[3/4] 配置机器人通道..."
# 构建 token如果提供了 appid 和 secret
if [ -n "$APPID" ] && [ -n "$SECRET" ]; then
QQBOT_TOKEN="${APPID}:${SECRET}"
echo "使用提供的 AppID 和 Secret 配置..."
else
# 默认 token可通过环境变量 QQBOT_TOKEN 覆盖
QQBOT_TOKEN="${QQBOT_TOKEN:-appid:secret}"
echo "使用默认或环境变量中的 Token..."
fi
openclaw channels add --channel qqbot --token "$QQBOT_TOKEN"
# 启用 markdown 支持
openclaw config set channels.qqbot.markdownSupport true
# 4. 启动 openclaw
echo ""
echo "[4/4] 启动 openclaw..."
echo "========================================="
openclaw gateway --verbose