feat: add QQ Bot channel plugins
This commit is contained in:
parent
6372242da7
commit
f85fcc1674
183
extensions/qqbot/README.md
Normal file
183
extensions/qqbot/README.md
Normal file
@ -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
|
||||
<img width="1852" height="1082" alt="image" src="https://github.com/user-attachments/assets/a16d582b-708c-473e-b3a2-e0c4c503a0c8" />
|
||||
|
||||
## 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
|
||||
185
extensions/qqbot/README.md.zh
Normal file
185
extensions/qqbot/README.md.zh
Normal file
@ -0,0 +1,185 @@
|
||||
# QQ Bot Channel Plugin for Moltbot
|
||||
|
||||
QQ 官方机器人 API 的 Moltbot 渠道插件,支持 C2C 私聊、群聊 @消息、频道消息。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多场景支持**:C2C 单聊、QQ 群 @消息、频道公开消息、频道私信
|
||||
- **自动重连**:WebSocket 断连后自动重连,支持 Session Resume
|
||||
- **消息去重**:自动管理 `msg_seq`,支持对同一消息多次回复
|
||||
- **系统提示词**:可配置自定义系统提示词注入到 AI 请求
|
||||
- **错误提示**:AI 无响应时自动提示用户检查配置
|
||||
|
||||
## 使用示例:
|
||||
<img width="1852" height="1082" alt="image" src="https://github.com/user-attachments/assets/a16d582b-708c-473e-b3a2-e0c4c503a0c8" />
|
||||
|
||||
|
||||
## 安装
|
||||
|
||||
在插件目录下执行:
|
||||
|
||||
```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
|
||||
9
extensions/qqbot/clawdbot.plugin.json
Normal file
9
extensions/qqbot/clawdbot.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "qqbot",
|
||||
"channels": ["qqbot"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
24
extensions/qqbot/index.ts
Normal file
24
extensions/qqbot/index.ts
Normal file
@ -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";
|
||||
28
extensions/qqbot/package.json
Normal file
28
extensions/qqbot/package.json
Normal file
@ -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": "*"
|
||||
}
|
||||
}
|
||||
62
extensions/qqbot/scripts/upgrade.sh
Executable file
62
extensions/qqbot/scripts/upgrade.sh
Executable file
@ -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"
|
||||
190
extensions/qqbot/src/api.ts
Normal file
190
extensions/qqbot/src/api.ts
Normal file
@ -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<string> {
|
||||
// 检查缓存,提前 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<string, number>();
|
||||
|
||||
/**
|
||||
* 获取并递增消息序号
|
||||
*/
|
||||
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<T = unknown>(
|
||||
accessToken: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
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<string> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
132
extensions/qqbot/src/channel.ts
Normal file
132
extensions/qqbot/src/channel.ts
Normal file
@ -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<ResolvedQQBotAccount> = {
|
||||
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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
154
extensions/qqbot/src/config.ts
Normal file
154
extensions/qqbot/src/config.ts
Normal file
@ -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<string, QQBotAccountConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有 QQBot 账户 ID
|
||||
*/
|
||||
export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
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;
|
||||
}
|
||||
532
extensions/qqbot/src/gateway.ts
Normal file
532
extensions/qqbot/src/gateway.ts
Normal file
@ -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<void> {
|
||||
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<typeof setInterval> | 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<unknown>) => {
|
||||
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<typeof setTimeout> | null = null;
|
||||
|
||||
const timeoutPromise = new Promise<void>((_, 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());
|
||||
});
|
||||
}
|
||||
246
extensions/qqbot/src/onboarding.ts
Normal file
246
extensions/qqbot/src/onboarding.ts
Normal file
@ -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<string, {
|
||||
enabled?: boolean;
|
||||
appId?: string;
|
||||
clientSecret?: string;
|
||||
clientSecretFile?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析默认账户 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<ChannelOnboardingStatus> => {
|
||||
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<ChannelOnboardingResult> => {
|
||||
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
|
||||
const moltbotCfg = cfg as MoltbotConfig;
|
||||
|
||||
const qqbotOverride = (accountOverrides as Record<string, string>).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,
|
||||
};
|
||||
114
extensions/qqbot/src/outbound.ts
Normal file
114
extensions/qqbot/src/outbound.ts
Normal file
@ -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<OutboundResult> {
|
||||
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<OutboundResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
14
extensions/qqbot/src/runtime.ts
Normal file
14
extensions/qqbot/src/runtime.ts
Normal file
@ -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;
|
||||
}
|
||||
115
extensions/qqbot/src/types.ts
Normal file
115
extensions/qqbot/src/types.ts
Normal file
@ -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;
|
||||
}
|
||||
16
extensions/qqbot/tsconfig.json
Normal file
16
extensions/qqbot/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user