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
+
+
+## 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 无响应时自动提示用户检查配置
+
+## 使用示例:
+
+
+
+## 安装
+
+在插件目录下执行:
+
+```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"]
+}