From f85fcc16745048044711542aac323529b20c8907 Mon Sep 17 00:00:00 2001 From: sliverp Date: Thu, 29 Jan 2026 17:17:41 +0800 Subject: [PATCH] feat: add QQ Bot channel plugins --- extensions/qqbot/README.md | 183 +++++++++ extensions/qqbot/README.md.zh | 185 +++++++++ extensions/qqbot/clawdbot.plugin.json | 9 + extensions/qqbot/index.ts | 24 ++ extensions/qqbot/package.json | 28 ++ extensions/qqbot/scripts/upgrade.sh | 62 +++ extensions/qqbot/src/api.ts | 190 +++++++++ extensions/qqbot/src/channel.ts | 132 +++++++ extensions/qqbot/src/config.ts | 154 ++++++++ extensions/qqbot/src/gateway.ts | 532 ++++++++++++++++++++++++++ extensions/qqbot/src/onboarding.ts | 246 ++++++++++++ extensions/qqbot/src/outbound.ts | 114 ++++++ extensions/qqbot/src/runtime.ts | 14 + extensions/qqbot/src/types.ts | 115 ++++++ extensions/qqbot/tsconfig.json | 16 + 15 files changed, 2004 insertions(+) create mode 100644 extensions/qqbot/README.md create mode 100644 extensions/qqbot/README.md.zh create mode 100644 extensions/qqbot/clawdbot.plugin.json create mode 100644 extensions/qqbot/index.ts create mode 100644 extensions/qqbot/package.json create mode 100755 extensions/qqbot/scripts/upgrade.sh create mode 100644 extensions/qqbot/src/api.ts create mode 100644 extensions/qqbot/src/channel.ts create mode 100644 extensions/qqbot/src/config.ts create mode 100644 extensions/qqbot/src/gateway.ts create mode 100644 extensions/qqbot/src/onboarding.ts create mode 100644 extensions/qqbot/src/outbound.ts create mode 100644 extensions/qqbot/src/runtime.ts create mode 100644 extensions/qqbot/src/types.ts create mode 100644 extensions/qqbot/tsconfig.json diff --git a/extensions/qqbot/README.md b/extensions/qqbot/README.md new file mode 100644 index 000000000..689cc1289 --- /dev/null +++ b/extensions/qqbot/README.md @@ -0,0 +1,183 @@ + +# QQ Bot Channel Plugin for Moltbot + +A Moltbot channel plugin for the official QQ Robot API, supporting C2C private chats, Group @mentions, and Guild messages. + +## Features + +- **Multi-Scenario Support**: C2C one-on-one chats, QQ Group @mentions, Guild public messages, and Guild direct messages (DMs). +- **Auto-Reconnection**: Automatically reconnects after WebSocket disconnection and supports Session Resume. +- **Message Deduplication**: Automatically manages `msg_seq` and supports multiple replies to the same message. +- **System Prompts**: Configurable custom system prompts injected into AI requests. +- **Error Notifications**: Automatically notifies the user to check the configuration if the AI fails to respond. + +## Usage Example +image + +## Installation + +Run the following command in the plugin directory: + +```bash +clawdbot plugins install . +``` + +## Configuration + +### 1. Obtain QQ Robot Credentials + +1. Visit the [QQ Open Platform](https://q.qq.com/). +2. Create a Robot Application. +3. Get the `AppID` and `AppSecret` (ClientSecret). +4. The Token format is `AppID:AppSecret`, for example: `102146862:Xjv7JVhu7KXkxANbp3HVjxCRgvAPeuAQ`. + +### 2. Add Configuration + +#### Method 1: Interactive Configuration + +```bash +clawdbot channels add +# Select 'qqbot' and enter the Token as prompted +``` + +#### Method 2: Command Line Configuration + +```bash +clawdbot channels add --channel qqbot --token "AppID:AppSecret" +``` + +Example: + +```bash +clawdbot channels add --channel qqbot --token "102146862:xxxxxxxx" +``` + +### 3. Manual Configuration (Optional) + +You can also directly edit `~/.clawdbot/clawdbot.json`: + +```json +{ + "channels": { + "qqbot": { + "enabled": true, + "appId": "YourAppID", + "clientSecret": "YourAppSecret", + "systemPrompt": "You are a friendly assistant" + } + } +} +``` + +## Configuration Options + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `appId` | string | Yes | QQ Robot AppID | +| `clientSecret` | string | Yes* | AppSecret. Choose either this or `clientSecretFile`. | +| `clientSecretFile` | string | Yes* | Path to the AppSecret file. | +| `enabled` | boolean | No | Whether to enable the plugin. Default is `true`. | +| `name` | string | No | Account display name. | +| `systemPrompt` | string | No | Custom system prompt. | + +## Supported Message Types + +| Event Type | Description | Intent | +|------------|-------------|--------| +| `C2C_MESSAGE_CREATE` | C2C One-on-One Message | `1 << 25` | +| `GROUP_AT_MESSAGE_CREATE` | Group Chat @Robot Message | `1 << 25` | +| `AT_MESSAGE_CREATE` | Guild @Robot Message | `1 << 30` | +| `DIRECT_MESSAGE_CREATE` | Guild Direct Message (DM) | `1 << 12` | + +## Usage + +### Startup + +Start in background: +```bash +clawdbot gateway restart +``` + +Start in foreground (convenient for viewing logs): +```bash +clawdbot gateway --port 18789 --verbose +``` + +### CLI Configuration Wizard + +```bash +clawdbot onboard +# Select QQ Bot for interactive configuration +``` + +## Important Notes + +1. **Reply Limitations**: The official QQ API limits replies to a maximum of 5 per message, with a 60-minute timeout. +2. **URL Restrictions**: The QQ platform does not allow URLs in messages; the plugin has built-in prompts to restrict this. +3. **Group Messages**: The robot must be @mentioned in the group to trigger a reply. +4. **Sandbox Mode**: Newly created robots are in sandbox mode by default and require test users to be added. + +## Upgrading + +If you need to upgrade the plugin, run the upgrade script to clean up the old version first: + +```bash +# Run the upgrade script (cleans up old version and config) +./scripts/upgrade.sh + +# Reinstall the plugin +clawdbot plugins install . + +# Reconfigure +clawdbot channels add --channel qqbot --token "AppID:AppSecret" + +# Restart the gateway +clawdbot gateway restart +``` + +The upgrade script will automatically: +- Delete the `~/.clawdbot/extensions/qqbot` directory. +- Clean up `qqbot` related configurations in `clawdbot.json`. + +## Development + +```bash +# Install dependencies +npm install + +# Build +npm run build + +# Watch mode +npm run dev +``` + +## File Structure + +``` +qqbot/ +├── index.ts # Entry file +├── src/ +│ ├── api.ts # QQ Bot API wrapper +│ ├── channel.ts # Channel Plugin definition +│ ├── config.ts # Configuration parsing +│ ├── gateway.ts # WebSocket gateway +│ ├── onboarding.ts # CLI configuration wizard +│ ├── outbound.ts # Outbound message handling +│ ├── runtime.ts # Runtime state +│ └── types.ts # Type definitions +├── scripts/ +│ └── upgrade.sh # Upgrade script +├── package.json +└── tsconfig.json +``` + +## Related Links + +- [QQ Robot Official Documentation](https://bot.q.qq.com/wiki/) +- [QQ Open Platform](https://q.qq.com/) +- [API v2 Documentation](https://bot.q.qq.com/wiki/develop/api-v2/) + +## License + +MIT \ No newline at end of file diff --git a/extensions/qqbot/README.md.zh b/extensions/qqbot/README.md.zh new file mode 100644 index 000000000..c105501d3 --- /dev/null +++ b/extensions/qqbot/README.md.zh @@ -0,0 +1,185 @@ +# QQ Bot Channel Plugin for Moltbot + +QQ 官方机器人 API 的 Moltbot 渠道插件,支持 C2C 私聊、群聊 @消息、频道消息。 + +## 功能特性 + +- **多场景支持**:C2C 单聊、QQ 群 @消息、频道公开消息、频道私信 +- **自动重连**:WebSocket 断连后自动重连,支持 Session Resume +- **消息去重**:自动管理 `msg_seq`,支持对同一消息多次回复 +- **系统提示词**:可配置自定义系统提示词注入到 AI 请求 +- **错误提示**:AI 无响应时自动提示用户检查配置 + +## 使用示例: +image + + +## 安装 + +在插件目录下执行: + +```bash +clawdbot plugins install . +``` + +## 配置 + +### 1. 获取 QQ 机器人凭证 + +1. 访问 [QQ 开放平台](https://q.qq.com/) +2. 创建机器人应用 +3. 获取 `AppID` 和 `AppSecret`(ClientSecret) +4. Token 格式为 `AppID:AppSecret`,例如 `102146862:Xjv7JVhu7KXkxANbp3HVjxCRgvAPeuAQ` + +### 2. 添加配置 + +#### 方式一:交互式配置 + +```bash +clawdbot channels add +# 选择 qqbot,按提示输入 Token +``` + +#### 方式二:命令行配置 + +```bash +clawdbot channels add --channel qqbot --token "AppID:AppSecret" +``` + +示例: + +```bash +clawdbot channels add --channel qqbot --token "102146862:xxxxxxxx" +``` + +### 3. 手动编辑配置(可选) + +也可以直接编辑 `~/.clawdbot/clawdbot.json`: + +```json +{ + "channels": { + "qqbot": { + "enabled": true, + "appId": "你的AppID", + "clientSecret": "你的AppSecret", + "systemPrompt": "你是一个友好的助手" + } + } +} +``` + + + +## 配置项说明 + +| 配置项 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `appId` | string | 是 | QQ 机器人 AppID | +| `clientSecret` | string | 是* | AppSecret,与 `clientSecretFile` 二选一 | +| `clientSecretFile` | string | 是* | AppSecret 文件路径 | +| `enabled` | boolean | 否 | 是否启用,默认 `true` | +| `name` | string | 否 | 账户显示名称 | +| `systemPrompt` | string | 否 | 自定义系统提示词 | + +## 支持的消息类型 + +| 事件类型 | 说明 | Intent | +|----------|------|--------| +| `C2C_MESSAGE_CREATE` | C2C 单聊消息 | `1 << 25` | +| `GROUP_AT_MESSAGE_CREATE` | 群聊 @机器人消息 | `1 << 25` | +| `AT_MESSAGE_CREATE` | 频道 @机器人消息 | `1 << 30` | +| `DIRECT_MESSAGE_CREATE` | 频道私信 | `1 << 12` | + +## 使用 + +### 启动 + +后台启动 +```bash +clawdbot gateway restart +``` + +前台启动, 方便试试查看日志 +```bash +clawdbot gateway --port 18789 --verbose +``` + +### CLI 配置向导 + +```bash +clawdbot onboard +# 选择 QQ Bot 进行交互式配置 +``` + +## 注意事项 + +1. **消息回复限制**:QQ 官方 API 限制每条消息最多回复 5 次,超时 60 分钟 +2. **URL 限制**:QQ 平台不允许消息中包含 URL,插件已内置提示词限制 +3. **群消息**:需要在群内 @机器人 才能触发回复 +4. **沙箱模式**:新创建的机器人默认在沙箱模式,需要添加测试用户 + +## 升级 + +如果需要升级插件,先运行升级脚本清理旧版本: + +```bash +# 运行升级脚本(清理旧版本和配置) +./scripts/upgrade.sh + +# 重新安装插件 +clawdbot plugins install . + +# 重新配置 +clawdbot channels add --channel qqbot --token "AppID:AppSecret" + +# 重启网关 +clawdbot gateway restart +``` + +升级脚本会自动: +- 删除 `~/.clawdbot/extensions/qqbot` 目录 +- 清理 `clawdbot.json` 中的 qqbot 相关配置 + +## 开发 + +```bash +# 安装依赖 +npm install + +# 编译 +npm run build + +# 监听模式 +npm run dev +``` + +## 文件结构 + +``` +qqbot/ +├── index.ts # 入口文件 +├── src/ +│ ├── api.ts # QQ Bot API 封装 +│ ├── channel.ts # Channel Plugin 定义 +│ ├── config.ts # 配置解析 +│ ├── gateway.ts # WebSocket 网关 +│ ├── onboarding.ts # CLI 配置向导 +│ ├── outbound.ts # 出站消息处理 +│ ├── runtime.ts # 运行时状态 +│ └── types.ts # 类型定义 +├── scripts/ +│ └── upgrade.sh # 升级脚本 +├── package.json +└── tsconfig.json +``` + +## 相关链接 + +- [QQ 机器人官方文档](https://bot.q.qq.com/wiki/) +- [QQ 开放平台](https://q.qq.com/) +- [API v2 文档](https://bot.q.qq.com/wiki/develop/api-v2/) + +## License + +MIT diff --git a/extensions/qqbot/clawdbot.plugin.json b/extensions/qqbot/clawdbot.plugin.json new file mode 100644 index 000000000..32017b478 --- /dev/null +++ b/extensions/qqbot/clawdbot.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "qqbot", + "channels": ["qqbot"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/qqbot/index.ts b/extensions/qqbot/index.ts new file mode 100644 index 000000000..8b602fcac --- /dev/null +++ b/extensions/qqbot/index.ts @@ -0,0 +1,24 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { qqbotPlugin } from "./src/channel.js"; +import { setQQBotRuntime } from "./src/runtime.js"; + +const plugin = { + id: "qqbot", + name: "QQ Bot", + description: "QQ Bot channel plugin", + register(api: MoltbotPluginApi) { + setQQBotRuntime(api.runtime); + api.registerChannel({ plugin: qqbotPlugin }); + }, +}; + +export default plugin; + +export { qqbotPlugin } from "./src/channel.js"; +export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js"; +export { qqbotOnboardingAdapter } from "./src/onboarding.js"; +export * from "./src/types.js"; +export * from "./src/api.js"; +export * from "./src/config.js"; +export * from "./src/gateway.js"; +export * from "./src/outbound.js"; diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json new file mode 100644 index 000000000..5ecc1851e --- /dev/null +++ b/extensions/qqbot/package.json @@ -0,0 +1,28 @@ +{ + "name": "qqbot", + "version": "1.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "clawdbot": { + "extensions": ["./index.ts"] + }, + "moltbot": { + "extensions": ["./index.ts"] + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/ws": "^8.5.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "clawdbot": "*" + } +} diff --git a/extensions/qqbot/scripts/upgrade.sh b/extensions/qqbot/scripts/upgrade.sh new file mode 100755 index 000000000..39feff807 --- /dev/null +++ b/extensions/qqbot/scripts/upgrade.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# QQBot 插件升级脚本 +# 用于清理旧版本插件并重新安装 + +set -e + +CLAWDBOT_DIR="$HOME/.clawdbot" +CONFIG_FILE="$CLAWDBOT_DIR/clawdbot.json" +EXTENSION_DIR="$CLAWDBOT_DIR/extensions/qqbot" + +echo "=== QQBot 插件升级脚本 ===" + +# 1. 删除旧的扩展目录 +if [ -d "$EXTENSION_DIR" ]; then + echo "删除旧版本插件: $EXTENSION_DIR" + rm -rf "$EXTENSION_DIR" +else + echo "未找到旧版本插件目录,跳过删除" +fi + +# 2. 清理配置文件中的 qqbot 相关字段 +if [ -f "$CONFIG_FILE" ]; then + echo "清理配置文件中的 qqbot 字段..." + + # 使用 node 处理 JSON(比 jq 更可靠处理复杂结构) + node -e " + const fs = require('fs'); + const config = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8')); + + // 删除 channels.qqbot + if (config.channels && config.channels.qqbot) { + delete config.channels.qqbot; + console.log(' - 已删除 channels.qqbot'); + } + + // 删除 plugins.entries.qqbot + if (config.plugins && config.plugins.entries && config.plugins.entries.qqbot) { + delete config.plugins.entries.qqbot; + console.log(' - 已删除 plugins.entries.qqbot'); + } + + // 删除 plugins.installs.qqbot + if (config.plugins && config.plugins.installs && config.plugins.installs.qqbot) { + delete config.plugins.installs.qqbot; + console.log(' - 已删除 plugins.installs.qqbot'); + } + + fs.writeFileSync('$CONFIG_FILE', JSON.stringify(config, null, 2)); + console.log('配置文件已更新'); + " +else + echo "未找到配置文件: $CONFIG_FILE" +fi + +echo "" +echo "=== 清理完成 ===" +echo "" +echo "接下来请执行以下命令重新安装插件:" +echo " cd /path/to/qqbot" +echo " clawdbot plugins install ." +echo " clawdbot channels add --channel qqbot --token \"AppID:AppSecret\"" +echo " clawdbot gateway restart" diff --git a/extensions/qqbot/src/api.ts b/extensions/qqbot/src/api.ts new file mode 100644 index 000000000..2ce776785 --- /dev/null +++ b/extensions/qqbot/src/api.ts @@ -0,0 +1,190 @@ +/** + * QQ Bot API 鉴权和请求封装 + */ + +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; + +/** + * 获取 AccessToken(带缓存) + */ +export async function getAccessToken(appId: string, clientSecret: string): Promise { + // 检查缓存,提前 5 分钟刷新 + if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) { + return cachedToken.token; + } + + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ appId, clientSecret }), + }); + + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + + if (!data.access_token) { + throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`); + } + + cachedToken = { + token: data.access_token, + expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000, + }; + + return cachedToken.token; +} + +/** + * 清除 Token 缓存 + */ +export function clearTokenCache(): void { + cachedToken = null; +} + +/** + * msg_seq 追踪器 - 用于对同一条消息的多次回复 + * key: msg_id, value: 当前 seq 值 + */ +const msgSeqTracker = new Map(); + +/** + * 获取并递增消息序号 + */ +export function getNextMsgSeq(msgId: string): number { + const current = msgSeqTracker.get(msgId) ?? 0; + const next = current + 1; + msgSeqTracker.set(msgId, next); + + // 清理过期的序号(超过 5 次或 60 分钟后无意义) + // 简单策略:保留最近 1000 条 + if (msgSeqTracker.size > 1000) { + const keys = Array.from(msgSeqTracker.keys()); + for (let i = 0; i < 500; i++) { + msgSeqTracker.delete(keys[i]); + } + } + + return next; +} + +/** + * API 请求封装 + */ +export async function apiRequest( + accessToken: string, + method: string, + path: string, + body?: unknown +): Promise { + const url = `${API_BASE}${path}`; + const options: RequestInit = { + method, + headers: { + Authorization: `QQBot ${accessToken}`, + "Content-Type": "application/json", + }, + }; + + if (body) { + options.body = JSON.stringify(body); + } + + const res = await fetch(url, options); + const data = (await res.json()) as T; + + if (!res.ok) { + const error = data as { message?: string; code?: number }; + throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`); + } + + return data; +} + +/** + * 获取 WebSocket Gateway URL + */ +export async function getGatewayUrl(accessToken: string): Promise { + const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway"); + return data.url; +} + +/** + * 发送 C2C 单聊消息 + */ +export async function sendC2CMessage( + accessToken: string, + openid: string, + content: string, + msgId?: string +): Promise<{ id: string; timestamp: number }> { + 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 } : {}), + }); +} + +/** + * 发送频道消息 + */ +export async function sendChannelMessage( + accessToken: string, + channelId: string, + content: string, + msgId?: string +): Promise<{ id: string; timestamp: string }> { + return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, { + content, + ...(msgId ? { msg_id: msgId } : {}), + }); +} + +/** + * 发送群聊消息 + */ +export async function sendGroupMessage( + accessToken: string, + groupOpenid: string, + content: string, + msgId?: string +): Promise<{ id: string; timestamp: string }> { + 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 } : {}), + }); +} + +/** + * 主动发送 C2C 单聊消息(不需要 msg_id,每月限 4 条/用户) + */ +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, + }); +} + +/** + * 主动发送群聊消息(不需要 msg_id,每月限 4 条/群) + */ +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, + }); +} diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts new file mode 100644 index 000000000..c0ef685d0 --- /dev/null +++ b/extensions/qqbot/src/channel.ts @@ -0,0 +1,132 @@ +import type { ChannelPlugin } from "clawdbot/plugin-sdk"; +import type { ResolvedQQBotAccount } from "./types.js"; +import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js"; +import { sendText } from "./outbound.js"; +import { startGateway } from "./gateway.js"; +import { qqbotOnboardingAdapter } from "./onboarding.js"; + +const DEFAULT_ACCOUNT_ID = "default"; + +export const qqbotPlugin: ChannelPlugin = { + id: "qqbot", + meta: { + id: "qqbot", + label: "QQ Bot", + selectionLabel: "QQ Bot", + docsPath: "/docs/channels/qqbot", + blurb: "Connect to QQ via official QQ Bot API", + order: 50, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: false, + reactions: false, + threads: false, + }, + reload: { configPrefixes: ["channels.qqbot"] }, + // CLI onboarding wizard + onboarding: qqbotOnboardingAdapter, + config: { + listAccountIds: (cfg) => listQQBotAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + isConfigured: (account) => Boolean(account.appId && account.clientSecret), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.appId && account.clientSecret), + tokenSource: account.secretSource, + }), + }, + setup: { + validateInput: ({ input }) => { + if (!input.token && !input.tokenFile && !input.useEnv) { + return "QQBot requires --token (format: appId:clientSecret) or --use-env"; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + let appId = ""; + let clientSecret = ""; + + if (input.token) { + const parts = input.token.split(":"); + if (parts.length === 2) { + appId = parts[0]; + clientSecret = parts[1]; + } + } + + return applyQQBotAccountConfig(cfg, accountId, { + appId, + clientSecret, + clientSecretFile: input.tokenFile, + name: input.name, + }); + }, + }, + outbound: { + deliveryMode: "direct", + textChunkLimit: 2000, + sendText: async ({ to, text, accountId, replyToId, cfg }) => { + const account = resolveQQBotAccount(cfg, accountId); + const result = await sendText({ to, text, accountId, replyToId, account }); + return { + channel: "qqbot", + messageId: result.messageId, + error: result.error ? new Error(result.error) : undefined, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const { account, abortSignal, log, cfg } = ctx; + + log?.info(`[qqbot:${account.accountId}] Starting gateway`); + + await startGateway({ + account, + abortSignal, + cfg, + log, + onReady: () => { + log?.info(`[qqbot:${account.accountId}] Gateway ready`); + ctx.setStatus({ + ...ctx.getStatus(), + running: true, + connected: true, + lastConnectedAt: Date.now(), + }); + }, + onError: (error) => { + log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`); + ctx.setStatus({ + ...ctx.getStatus(), + lastError: error.message, + }); + }, + }); + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + connected: false, + lastConnectedAt: null, + lastError: null, + }, + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.appId && account.clientSecret), + tokenSource: account.secretSource, + running: runtime?.running ?? false, + connected: runtime?.connected ?? false, + lastConnectedAt: runtime?.lastConnectedAt ?? null, + lastError: runtime?.lastError ?? null, + }), + }, +}; diff --git a/extensions/qqbot/src/config.ts b/extensions/qqbot/src/config.ts new file mode 100644 index 000000000..0f1a82f5b --- /dev/null +++ b/extensions/qqbot/src/config.ts @@ -0,0 +1,154 @@ +import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js"; + +const DEFAULT_ACCOUNT_ID = "default"; + +interface MoltbotConfig { + channels?: { + qqbot?: QQBotChannelConfig; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface QQBotChannelConfig extends QQBotAccountConfig { + accounts?: Record; +} + +/** + * 列出所有 QQBot 账户 ID + */ +export function listQQBotAccountIds(cfg: MoltbotConfig): string[] { + const ids = new Set(); + const qqbot = cfg.channels?.qqbot; + + if (qqbot?.appId) { + ids.add(DEFAULT_ACCOUNT_ID); + } + + if (qqbot?.accounts) { + for (const accountId of Object.keys(qqbot.accounts)) { + if (qqbot.accounts[accountId]?.appId) { + ids.add(accountId); + } + } + } + + return Array.from(ids); +} + +/** + * 解析 QQBot 账户配置 + */ +export function resolveQQBotAccount( + cfg: MoltbotConfig, + accountId?: string | null +): ResolvedQQBotAccount { + const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID; + const qqbot = cfg.channels?.qqbot; + + // 基础配置 + let accountConfig: QQBotAccountConfig = {}; + let appId = ""; + let clientSecret = ""; + let secretSource: "config" | "file" | "env" | "none" = "none"; + + if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { + // 默认账户从顶层读取 + accountConfig = { + enabled: qqbot?.enabled, + name: qqbot?.name, + appId: qqbot?.appId, + clientSecret: qqbot?.clientSecret, + clientSecretFile: qqbot?.clientSecretFile, + dmPolicy: qqbot?.dmPolicy, + allowFrom: qqbot?.allowFrom, + systemPrompt: qqbot?.systemPrompt, + }; + appId = qqbot?.appId ?? ""; + } else { + // 命名账户从 accounts 读取 + const account = qqbot?.accounts?.[resolvedAccountId]; + accountConfig = account ?? {}; + appId = account?.appId ?? ""; + } + + // 解析 clientSecret + if (accountConfig.clientSecret) { + clientSecret = accountConfig.clientSecret; + secretSource = "config"; + } else if (accountConfig.clientSecretFile) { + // 从文件读取(运行时处理) + secretSource = "file"; + } else if (process.env.QQBOT_CLIENT_SECRET && resolvedAccountId === DEFAULT_ACCOUNT_ID) { + clientSecret = process.env.QQBOT_CLIENT_SECRET; + secretSource = "env"; + } + + // AppId 也可以从环境变量读取 + if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) { + appId = process.env.QQBOT_APP_ID; + } + + return { + accountId: resolvedAccountId, + name: accountConfig.name, + enabled: accountConfig.enabled !== false, + appId, + clientSecret, + secretSource, + systemPrompt: accountConfig.systemPrompt, + config: accountConfig, + }; +} + +/** + * 应用账户配置 + */ +export function applyQQBotAccountConfig( + cfg: MoltbotConfig, + accountId: string, + input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string } +): MoltbotConfig { + const next = { ...cfg }; + + if (accountId === DEFAULT_ACCOUNT_ID) { + next.channels = { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + ...(input.appId ? { appId: input.appId } : {}), + ...(input.clientSecret + ? { clientSecret: input.clientSecret } + : input.clientSecretFile + ? { clientSecretFile: input.clientSecretFile } + : {}), + ...(input.name ? { name: input.name } : {}), + }, + }; + } else { + next.channels = { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + accounts: { + ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts, + [accountId]: { + ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId], + enabled: true, + ...(input.appId ? { appId: input.appId } : {}), + ...(input.clientSecret + ? { clientSecret: input.clientSecret } + : input.clientSecretFile + ? { clientSecretFile: input.clientSecretFile } + : {}), + ...(input.name ? { name: input.name } : {}), + }, + }, + }, + }; + } + + return next; +} diff --git a/extensions/qqbot/src/gateway.ts b/extensions/qqbot/src/gateway.ts new file mode 100644 index 000000000..e0611b574 --- /dev/null +++ b/extensions/qqbot/src/gateway.ts @@ -0,0 +1,532 @@ +import WebSocket from "ws"; +import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; +import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache } from "./api.js"; +import { getQQBotRuntime } from "./runtime.js"; + +// QQ Bot intents +const INTENTS = { + PUBLIC_GUILD_MESSAGES: 1 << 30, // 频道公开消息 + DIRECT_MESSAGE: 1 << 12, // 频道私信 + GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊 +}; + +// 重连配置 +const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟 +const MAX_RECONNECT_ATTEMPTS = 100; +const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值 +const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开 + +export interface GatewayContext { + account: ResolvedQQBotAccount; + abortSignal: AbortSignal; + cfg: unknown; + onReady?: (data: unknown) => void; + onError?: (error: Error) => void; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; +} + +/** + * 启动 Gateway WebSocket 连接(带自动重连) + */ +export async function startGateway(ctx: GatewayContext): Promise { + const { account, abortSignal, cfg, onReady, onError, log } = ctx; + + if (!account.appId || !account.clientSecret) { + throw new Error("QQBot not configured (missing appId or clientSecret)"); + } + + let reconnectAttempts = 0; + let isAborted = false; + let currentWs: WebSocket | null = null; + let heartbeatInterval: ReturnType | null = null; + let sessionId: string | null = null; + let lastSeq: number | null = null; + let lastConnectTime: number = 0; // 上次连接成功的时间 + let quickDisconnectCount = 0; // 连续快速断开次数 + + abortSignal.addEventListener("abort", () => { + isAborted = true; + cleanup(); + }); + + const cleanup = () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + if (currentWs && (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) { + currentWs.close(); + } + currentWs = null; + }; + + const getReconnectDelay = () => { + const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1); + return RECONNECT_DELAYS[idx]; + }; + + const scheduleReconnect = () => { + if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`); + return; + } + + const delay = getReconnectDelay(); + reconnectAttempts++; + log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); + + setTimeout(() => { + if (!isAborted) { + connect(); + } + }, delay); + }; + + const connect = async () => { + try { + cleanup(); + + // 刷新 token(可能过期了) + clearTokenCache(); + const accessToken = await getAccessToken(account.appId, account.clientSecret); + const gatewayUrl = await getGatewayUrl(accessToken); + + log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`); + + const ws = new WebSocket(gatewayUrl); + currentWs = ws; + + const pluginRuntime = getQQBotRuntime(); + + // 处理收到的消息 + const handleMessage = async (event: { + type: "c2c" | "guild" | "dm" | "group"; + senderId: string; + senderName?: string; + content: string; + messageId: string; + timestamp: string; + channelId?: string; + guildId?: string; + groupOpenid?: string; + attachments?: Array<{ content_type: string; url: string; filename?: string }>; + }) => { + log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`); + if (event.attachments?.length) { + log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`); + } + + pluginRuntime.channel.activity.record({ + channel: "qqbot", + accountId: account.accountId, + direction: "inbound", + }); + + const isGroup = event.type === "guild" || event.type === "group"; + const peerId = event.type === "guild" ? `channel:${event.channelId}` + : event.type === "group" ? `group:${event.groupOpenid}` + : event.senderId; + + const route = pluginRuntime.channel.routing.resolveAgentRoute({ + cfg, + channel: "qqbot", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: peerId, + }, + }); + + const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg); + + // 组装消息体,添加系统提示词 + const builtinPrompt = "由于平台限制,你的回复中不可以包含任何URL"; + const systemPrompts = [builtinPrompt]; + if (account.systemPrompt) { + systemPrompts.push(account.systemPrompt); + } + + // 处理附件(图片等) + let attachmentInfo = ""; + const imageUrls: string[] = []; + if (event.attachments?.length) { + for (const att of event.attachments) { + if (att.content_type?.startsWith("image/")) { + imageUrls.push(att.url); + attachmentInfo += `\n[图片: ${att.url}]`; + } else { + attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}]`; + } + } + } + + const userContent = event.content + attachmentInfo; + const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`; + + const body = pluginRuntime.channel.reply.formatInboundEnvelope({ + channel: "QQBot", + from: event.senderName ?? event.senderId, + timestamp: new Date(event.timestamp).getTime(), + body: messageBody, + chatType: isGroup ? "group" : "direct", + sender: { + id: event.senderId, + name: event.senderName, + }, + envelope: envelopeOptions, + // 传递图片 URL 列表 + ...(imageUrls.length > 0 ? { imageUrls } : {}), + }); + + const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}` + : event.type === "group" ? `qqbot:group:${event.groupOpenid}` + : `qqbot:${event.senderId}`; + const toAddress = fromAddress; + + const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: event.content, + CommandBody: event.content, + From: fromAddress, + To: toAddress, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + SenderId: event.senderId, + SenderName: event.senderName, + Provider: "qqbot", + Surface: "qqbot", + MessageSid: event.messageId, + Timestamp: new Date(event.timestamp).getTime(), + OriginatingChannel: "qqbot", + OriginatingTo: toAddress, + QQChannelId: event.channelId, + QQGuildId: event.guildId, + QQGroupOpenid: event.groupOpenid, + }); + + // 发送消息的辅助函数,带 token 过期重试 + const sendWithTokenRetry = async (sendFn: (token: string) => Promise) => { + try { + const token = await getAccessToken(account.appId, account.clientSecret); + await sendFn(token); + } catch (err) { + const errMsg = String(err); + // 如果是 token 相关错误,清除缓存重试一次 + if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) { + log?.info(`[qqbot:${account.accountId}] Token may be expired, refreshing...`); + clearTokenCache(); + const newToken = await getAccessToken(account.appId, account.clientSecret); + await sendFn(newToken); + } else { + throw err; + } + } + }; + + // 发送错误提示的辅助函数 + const sendErrorMessage = async (errorText: string) => { + try { + await sendWithTokenRetry(async (token) => { + if (event.type === "c2c") { + await sendC2CMessage(token, event.senderId, errorText, event.messageId); + } else if (event.type === "group" && event.groupOpenid) { + await sendGroupMessage(token, event.groupOpenid, errorText, event.messageId); + } else if (event.channelId) { + await sendChannelMessage(token, event.channelId, errorText, event.messageId); + } + }); + } catch (sendErr) { + log?.error(`[qqbot:${account.accountId}] Failed to send error message: ${sendErr}`); + } + }; + + try { + const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId); + + // 追踪是否有响应 + let hasResponse = false; + const responseTimeout = 30000; // 30秒超时 + let timeoutId: ReturnType | null = null; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + if (!hasResponse) { + reject(new Error("Response timeout")); + } + }, responseTimeout); + }); + + const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + responsePrefix: messagesConfig.responsePrefix, + deliver: async (payload: { text?: string }) => { + hasResponse = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + let replyText = payload.text ?? ""; + if (!replyText.trim()) return; + + // 处理回复内容,避免被 QQ 识别为 URL + const originalText = replyText; + + // 把所有可能被识别为 URL 的点替换为下划线 + // 匹配:字母/数字.字母/数字 的模式 + replyText = replyText.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2"); + + const hasReplacement = replyText !== originalText; + if (hasReplacement) { + replyText += "\n\n(由于平台限制,回复中的部分符号已被替换)"; + } + + try { + await sendWithTokenRetry(async (token) => { + if (event.type === "c2c") { + await sendC2CMessage(token, event.senderId, replyText, event.messageId); + } else if (event.type === "group" && event.groupOpenid) { + await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId); + } else if (event.channelId) { + await sendChannelMessage(token, event.channelId, replyText, event.messageId); + } + }); + log?.info(`[qqbot:${account.accountId}] Sent reply`); + + pluginRuntime.channel.activity.record({ + channel: "qqbot", + accountId: account.accountId, + direction: "outbound", + }); + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`); + } + }, + onError: async (err: unknown) => { + log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`); + hasResponse = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + // 发送错误提示给用户 + const errMsg = String(err); + if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) { + await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置"); + } else { + await sendErrorMessage(`[ClawdBot] 处理消息时出错: ${errMsg.slice(0, 100)}`); + } + }, + }, + replyOptions: {}, + }); + + // 等待分发完成或超时 + try { + await Promise.race([dispatchPromise, timeoutPromise]); + } catch (err) { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (!hasResponse) { + log?.error(`[qqbot:${account.accountId}] No response within timeout`); + await sendErrorMessage("[ClawdBot] 未收到响应,请检查大模型 API Key 是否正确配置"); + } + } + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`); + await sendErrorMessage(`[ClawdBot] 处理消息失败: ${String(err).slice(0, 100)}`); + } + }; + + ws.on("open", () => { + log?.info(`[qqbot:${account.accountId}] WebSocket connected`); + reconnectAttempts = 0; // 连接成功,重置重试计数 + lastConnectTime = Date.now(); // 记录连接时间 + }); + + ws.on("message", async (data) => { + try { + const payload = JSON.parse(data.toString()) as WSPayload; + const { op, d, s, t } = payload; + + if (s) lastSeq = s; + + log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`); + + switch (op) { + case 10: // Hello + log?.info(`[qqbot:${account.accountId}] Hello received`); + + // 如果有 session_id,尝试 Resume + if (sessionId && lastSeq !== null) { + log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`); + ws.send(JSON.stringify({ + op: 6, // Resume + d: { + token: `QQBot ${accessToken}`, + session_id: sessionId, + seq: lastSeq, + }, + })); + } else { + // 新连接,发送 Identify + ws.send(JSON.stringify({ + op: 2, + d: { + token: `QQBot ${accessToken}`, + intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C, + shard: [0, 1], + }, + })); + } + + // 启动心跳 + const interval = (d as { heartbeat_interval: number }).heartbeat_interval; + if (heartbeatInterval) clearInterval(heartbeatInterval); + heartbeatInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ op: 1, d: lastSeq })); + log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`); + } + }, interval); + break; + + case 0: // Dispatch + if (t === "READY") { + const readyData = d as { session_id: string }; + sessionId = readyData.session_id; + log?.info(`[qqbot:${account.accountId}] Ready, session: ${sessionId}`); + onReady?.(d); + } else if (t === "RESUMED") { + log?.info(`[qqbot:${account.accountId}] Session resumed`); + } else if (t === "C2C_MESSAGE_CREATE") { + const event = d as C2CMessageEvent; + await handleMessage({ + type: "c2c", + senderId: event.author.user_openid, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + attachments: event.attachments, + }); + } else if (t === "AT_MESSAGE_CREATE") { + const event = d as GuildMessageEvent; + await handleMessage({ + type: "guild", + senderId: event.author.id, + senderName: event.author.username, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + channelId: event.channel_id, + guildId: event.guild_id, + attachments: event.attachments, + }); + } else if (t === "DIRECT_MESSAGE_CREATE") { + const event = d as GuildMessageEvent; + await handleMessage({ + type: "dm", + senderId: event.author.id, + senderName: event.author.username, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + guildId: event.guild_id, + attachments: event.attachments, + }); + } else if (t === "GROUP_AT_MESSAGE_CREATE") { + const event = d as GroupMessageEvent; + await handleMessage({ + type: "group", + senderId: event.author.member_openid, + content: event.content, + messageId: event.id, + timestamp: event.timestamp, + groupOpenid: event.group_openid, + attachments: event.attachments, + }); + } + break; + + case 11: // Heartbeat ACK + log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`); + break; + + case 7: // Reconnect + log?.info(`[qqbot:${account.accountId}] Server requested reconnect`); + cleanup(); + scheduleReconnect(); + break; + + case 9: // Invalid Session + const canResume = d as boolean; + log?.error(`[qqbot:${account.accountId}] Invalid session, can resume: ${canResume}`); + if (!canResume) { + sessionId = null; + lastSeq = null; + } + cleanup(); + scheduleReconnect(); + break; + } + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`); + } + }); + + ws.on("close", (code, reason) => { + log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`); + + // 检测是否是快速断开(连接后很快就断了) + const connectionDuration = Date.now() - lastConnectTime; + if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) { + quickDisconnectCount++; + log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`); + + // 如果连续快速断开超过阈值,清除 session 重新 identify + if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) { + log?.info(`[qqbot:${account.accountId}] Too many quick disconnects, clearing session to re-identify`); + sessionId = null; + lastSeq = null; + quickDisconnectCount = 0; + } + } else { + // 连接持续时间够长,重置计数 + quickDisconnectCount = 0; + } + + cleanup(); + + // 非正常关闭则重连 + if (!isAborted && code !== 1000) { + scheduleReconnect(); + } + }); + + ws.on("error", (err) => { + log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`); + onError?.(err); + }); + + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`); + scheduleReconnect(); + } + }; + + // 开始连接 + await connect(); + + // 等待 abort 信号 + return new Promise((resolve) => { + abortSignal.addEventListener("abort", () => resolve()); + }); +} diff --git a/extensions/qqbot/src/onboarding.ts b/extensions/qqbot/src/onboarding.ts new file mode 100644 index 000000000..189792edb --- /dev/null +++ b/extensions/qqbot/src/onboarding.ts @@ -0,0 +1,246 @@ +/** + * QQBot CLI Onboarding Adapter + * + * 提供 moltbot onboard 命令的交互式配置支持 + */ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingStatus, + 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; +} + +interface QQBotChannelConfig { + enabled?: boolean; + appId?: string; + clientSecret?: string; + clientSecretFile?: string; + name?: string; + accounts?: Record; +} + +/** + * 解析默认账户 ID + */ +function resolveDefaultQQBotAccountId(cfg: MoltbotConfig): string { + const ids = listQQBotAccountIds(cfg); + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +/** + * QQBot Onboarding Adapter + */ +export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = { + channel: "qqbot" as any, + + getStatus: async (ctx: ChannelOnboardingStatusContext): Promise => { + const { cfg } = ctx; + const configured = listQQBotAccountIds(cfg as MoltbotConfig).some((accountId) => { + const account = resolveQQBotAccount(cfg as MoltbotConfig, accountId); + return Boolean(account.appId && account.clientSecret); + }); + + return { + channel: "qqbot" as any, + configured, + statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`], + selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊", + quickstartScore: configured ? 1 : 20, + }; + }, + + configure: async (ctx: ChannelOnboardingConfigureContext): Promise => { + const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx; + const moltbotCfg = cfg as MoltbotConfig; + + const qqbotOverride = (accountOverrides as Record).qqbot?.trim(); + const defaultAccountId = resolveDefaultQQBotAccountId(moltbotCfg); + let accountId = qqbotOverride ?? defaultAccountId; + + // 是否需要提示选择账户 + if (shouldPromptAccountIds && !qqbotOverride) { + const existingIds = listQQBotAccountIds(moltbotCfg); + if (existingIds.length > 1) { + accountId = await prompter.select({ + message: "选择 QQBot 账户", + options: existingIds.map((id) => ({ + value: id, + label: id === DEFAULT_ACCOUNT_ID ? "默认账户" : id, + })), + initialValue: accountId, + }); + } + } + + let next = moltbotCfg; + const resolvedAccount = resolveQQBotAccount(next, accountId); + const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret); + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envAppId = typeof process !== "undefined" ? process.env?.QQBOT_APP_ID?.trim() : undefined; + const envSecret = typeof process !== "undefined" ? process.env?.QQBOT_CLIENT_SECRET?.trim() : undefined; + const canUseEnv = allowEnv && Boolean(envAppId && envSecret); + const hasConfigCredentials = Boolean(resolvedAccount.config.appId && resolvedAccount.config.clientSecret); + + let appId: string | null = null; + let clientSecret: string | null = null; + + // 显示帮助 + if (!accountConfigured) { + await prompter.note( + [ + "1) 打开 QQ 开放平台: https://q.qq.com/", + "2) 创建机器人应用,获取 AppID 和 ClientSecret", + "3) 在「开发设置」中添加沙箱成员(测试阶段)", + "4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET", + "", + "文档: https://bot.q.qq.com/wiki/", + ].join("\n"), + "QQ Bot 配置", + ); + } + + // 检测环境变量 + if (canUseEnv && !hasConfigCredentials) { + const keepEnv = await prompter.confirm({ + message: "检测到环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET,是否使用?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + }, + }, + }; + } else { + // 手动输入 + appId = String( + await prompter.text({ + message: "请输入 QQ Bot AppID", + placeholder: "例如: 102146862", + initialValue: resolvedAccount.appId || undefined, + validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"), + }), + ).trim(); + clientSecret = String( + await prompter.text({ + message: "请输入 QQ Bot ClientSecret", + placeholder: "你的 ClientSecret", + validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"), + }), + ).trim(); + } + } else if (hasConfigCredentials) { + // 已有配置 + const keep = await prompter.confirm({ + message: "QQ Bot 已配置,是否保留当前配置?", + initialValue: true, + }); + if (!keep) { + appId = String( + await prompter.text({ + message: "请输入 QQ Bot AppID", + placeholder: "例如: 102146862", + initialValue: resolvedAccount.appId || undefined, + validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"), + }), + ).trim(); + clientSecret = String( + await prompter.text({ + message: "请输入 QQ Bot ClientSecret", + placeholder: "你的 ClientSecret", + validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"), + }), + ).trim(); + } + } else { + // 没有配置,需要输入 + appId = String( + await prompter.text({ + message: "请输入 QQ Bot AppID", + placeholder: "例如: 102146862", + initialValue: resolvedAccount.appId || undefined, + validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"), + }), + ).trim(); + clientSecret = String( + await prompter.text({ + message: "请输入 QQ Bot ClientSecret", + placeholder: "你的 ClientSecret", + validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"), + }), + ).trim(); + } + + // 应用配置 + if (appId && clientSecret) { + if (accountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + appId, + clientSecret, + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...next.channels, + qqbot: { + ...next.channels?.qqbot, + enabled: true, + accounts: { + ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts, + [accountId]: { + ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId], + enabled: true, + appId, + clientSecret, + }, + }, + }, + }, + }; + } + } + + return { cfg: next as any, accountId }; + }, + + disable: (cfg) => ({ + ...cfg, + channels: { + ...(cfg as MoltbotConfig).channels, + qqbot: { ...(cfg as MoltbotConfig).channels?.qqbot, enabled: false }, + }, + }) as any, +}; diff --git a/extensions/qqbot/src/outbound.ts b/extensions/qqbot/src/outbound.ts new file mode 100644 index 000000000..a8f9809ce --- /dev/null +++ b/extensions/qqbot/src/outbound.ts @@ -0,0 +1,114 @@ +import type { ResolvedQQBotAccount } from "./types.js"; +import { + getAccessToken, + sendC2CMessage, + sendChannelMessage, + sendGroupMessage, + sendProactiveC2CMessage, + sendProactiveGroupMessage, +} from "./api.js"; + +export interface OutboundContext { + to: string; + text: string; + accountId?: string | null; + replyToId?: string | null; + account: ResolvedQQBotAccount; +} + +export interface OutboundResult { + channel: string; + messageId?: string; + timestamp?: string | number; + error?: string; +} + +/** + * 解析目标地址 + * 格式: + * - openid (32位十六进制) -> C2C 单聊 + * - group:xxx -> 群聊 + * - channel:xxx -> 频道 + * - 纯数字 -> 频道 + */ +function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } { + if (to.startsWith("group:")) { + return { type: "group", id: to.slice(6) }; + } + if (to.startsWith("channel:")) { + return { type: "channel", id: to.slice(8) }; + } + // openid 通常是 32 位十六进制 + if (/^[A-F0-9]{32}$/i.test(to)) { + return { type: "c2c", id: to }; + } + // 默认当作频道 ID + return { type: "channel", id: to }; +} + +/** + * 发送文本消息(被动回复,需要 replyToId) + */ +export async function sendText(ctx: OutboundContext): Promise { + const { to, text, replyToId, account } = ctx; + + if (!account.appId || !account.clientSecret) { + return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; + } + + try { + const accessToken = await getAccessToken(account.appId, account.clientSecret); + const target = parseTarget(to); + + if (target.type === "c2c") { + const result = await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined); + return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; + } else if (target.type === "group") { + const result = await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined); + return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; + } else { + const result = await sendChannelMessage(accessToken, target.id, text, replyToId ?? undefined); + return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { channel: "qqbot", error: message }; + } +} + +/** + * 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群) + * + * @param account - 账户配置 + * @param to - 目标地址,格式:openid(单聊)或 group:xxx(群聊) + * @param text - 消息内容 + */ +export async function sendProactiveMessage( + account: ResolvedQQBotAccount, + to: string, + text: string +): Promise { + if (!account.appId || !account.clientSecret) { + return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; + } + + try { + const accessToken = await getAccessToken(account.appId, account.clientSecret); + const target = parseTarget(to); + + if (target.type === "c2c") { + const result = await sendProactiveC2CMessage(accessToken, target.id, text); + return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; + } else if (target.type === "group") { + const result = await sendProactiveGroupMessage(accessToken, target.id, text); + return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; + } else { + // 频道暂不支持主动消息,使用普通发送 + const result = await sendChannelMessage(accessToken, target.id, text); + return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { channel: "qqbot", error: message }; + } +} diff --git a/extensions/qqbot/src/runtime.ts b/extensions/qqbot/src/runtime.ts new file mode 100644 index 000000000..414e19c06 --- /dev/null +++ b/extensions/qqbot/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setQQBotRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getQQBotRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("QQBot runtime not initialized"); + } + return runtime; +} diff --git a/extensions/qqbot/src/types.ts b/extensions/qqbot/src/types.ts new file mode 100644 index 000000000..69e1cc17b --- /dev/null +++ b/extensions/qqbot/src/types.ts @@ -0,0 +1,115 @@ +/** + * QQ Bot 配置类型 + */ +export interface QQBotConfig { + appId: string; + clientSecret?: string; + clientSecretFile?: string; +} + +/** + * 解析后的 QQ Bot 账户 + */ +export interface ResolvedQQBotAccount { + accountId: string; + name?: string; + enabled: boolean; + appId: string; + clientSecret: string; + secretSource: "config" | "file" | "env" | "none"; + /** 系统提示词 */ + systemPrompt?: string; + config: QQBotAccountConfig; +} + +/** + * QQ Bot 账户配置 + */ +export interface QQBotAccountConfig { + enabled?: boolean; + name?: string; + appId?: string; + clientSecret?: string; + clientSecretFile?: string; + dmPolicy?: "open" | "pairing" | "allowlist"; + allowFrom?: string[]; + /** 系统提示词,会添加在用户消息前面 */ + systemPrompt?: string; +} + +/** + * 富媒体附件 + */ +export interface MessageAttachment { + content_type: string; // 如 "image/png" + filename?: string; + height?: number; + width?: number; + size?: number; + url: string; +} + +/** + * C2C 消息事件 + */ +export interface C2CMessageEvent { + author: { + id: string; + union_openid: string; + user_openid: string; + }; + content: string; + id: string; + timestamp: string; + message_scene?: { + source: string; + }; + attachments?: MessageAttachment[]; +} + +/** + * 频道 AT 消息事件 + */ +export interface GuildMessageEvent { + id: string; + channel_id: string; + guild_id: string; + content: string; + timestamp: string; + author: { + id: string; + username?: string; + bot?: boolean; + }; + member?: { + nick?: string; + joined_at?: string; + }; + attachments?: MessageAttachment[]; +} + +/** + * 群聊 AT 消息事件 + */ +export interface GroupMessageEvent { + author: { + id: string; + member_openid: string; + }; + content: string; + id: string; + timestamp: string; + group_id: string; + group_openid: string; + attachments?: MessageAttachment[]; +} + +/** + * WebSocket 事件负载 + */ +export interface WSPayload { + op: number; + d?: unknown; + s?: number; + t?: string; +} diff --git a/extensions/qqbot/tsconfig.json b/extensions/qqbot/tsconfig.json new file mode 100644 index 000000000..fd54667fa --- /dev/null +++ b/extensions/qqbot/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}