Compare commits
21 Commits
main-merge
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c419d5e6d | ||
|
|
a62df11bc7 | ||
|
|
8ccb231fda | ||
|
|
251a2f0e99 | ||
|
|
1205ecc86e | ||
|
|
8cc6e034bf | ||
|
|
8b4704f649 | ||
|
|
0a6b0fe73a | ||
|
|
611b4f4323 | ||
|
|
0c22a89c6a | ||
|
|
6c0b8058ff | ||
|
|
15ea067d8d | ||
|
|
98c7a598d8 | ||
|
|
4fefa8f15b | ||
|
|
31926bac28 | ||
|
|
7db7b7e2d6 | ||
|
|
d6c55d61f4 | ||
|
|
eda0579750 | ||
|
|
24200f1a5f | ||
|
|
484db3f90c | ||
|
|
efcf627213 |
261
README.md
261
README.md
@@ -1,101 +1,96 @@
|
||||
<div align="center">
|
||||
|
||||
# QQ Bot Channel Plugin for Moltbot
|
||||
|
||||
QQ 开放平台Bot API 的 Moltbot 渠道插件,支持 C2C 私聊、群聊 @消息、频道消息。
|
||||
QQ 开放平台 Bot API 的 Moltbot 渠道插件,支持 C2C 私聊、群聊 @消息、频道消息。
|
||||
|
||||
## 功能特性
|
||||
[](https://www.npmjs.com/package/@sliverp/qqbot)
|
||||
[](./LICENSE)
|
||||
[](https://bot.q.qq.com/wiki/)
|
||||
[](https://github.com/sliverp/moltbot)
|
||||
[](https://nodejs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
|
||||
- **多场景支持**:C2C 单聊、QQ 群 @消息、频道公开消息、频道私信
|
||||
- **自动重连**:WebSocket 断连后自动重连,支持 Session Resume
|
||||
- **消息去重**:自动管理 `msg_seq`,支持对同一消息多次回复
|
||||
- **系统提示词**:可配置自定义系统提示词注入到 AI 请求
|
||||
- **错误提示**:AI 无响应时自动提示用户检查配置
|
||||
</div>
|
||||
|
||||
## 使用示例:
|
||||
<img width="952" height="582" alt="image" src="https://github.com/user-attachments/assets/a16d582b-708c-473e-b3a2-e0c4c503a0c8" />
|
||||
---
|
||||
|
||||
## 版本更新
|
||||
**使用`openclaw plugins list`来产看插件版本,建议使用最新版的版本**
|
||||
<img width="902" height="248" alt="Clipboard_Screenshot_1769739939" src="https://github.com/user-attachments/assets/d6f37458-900c-4de9-8fdc-f8e6bf5c7ee5" />
|
||||
## ⭐ Star 趋势
|
||||
|
||||
### 1.3.0(即将更新)
|
||||
- 支持图片收发等功能
|
||||
<img width="924" height="428" alt="Clipboard_Screenshot_1770112572" src="https://github.com/user-attachments/assets/80f38ae9-dc40-4545-ad17-e7e254064cf4" />
|
||||
<!-- 在这里放置 Star 数量变化图 -->
|
||||
|
||||
- 支持定时任务到时后主动推送
|
||||
<img width="930" height="288" alt="Clipboard_Screenshot_1770112539" src="https://github.com/user-attachments/assets/9674cda0-91e9-4860-8dcc-bc50007862a2" />
|
||||
---
|
||||
|
||||
## 📸 使用示例
|
||||
|
||||
### 1.2.5-2026.02.02
|
||||
- 解除URL发送限制,现在可以直接在私聊发送URL
|
||||
<img width="886" height="276" alt="Clipboard_Screenshot_1770092858" src="https://github.com/user-attachments/assets/c660949e-28a5-4e5f-abc2-77f0a2c67bad" />
|
||||
- 更新Bot正在输入中状态
|
||||
<img width="740" height="212" alt="Clipboard_Screenshot_1770091969" src="https://github.com/user-attachments/assets/47835c4b-ccd2-4782-aaa6-b873cb58f7d7" />
|
||||
- 提供主动推送能力(目前AI还不知道怎么调用主动推送,相关完整Skill能力将在后续版本更新)
|
||||
- 优化一些已知问题
|
||||
- 优化未收到未收到大模型响应时的提示信息
|
||||
<img width="600" alt="使用示例" src="https://github.com/user-attachments/assets/6f1704ab-584b-497e-8937-96f84ce2958f" />
|
||||
|
||||
---
|
||||
|
||||
### 1.2.2-2026.01.31
|
||||
- 支持发送文件
|
||||
- 支持openclaw、moltbot命令行
|
||||
- 修复[health]检查提示: [health] refresh failed: Cannot read properties of undefined (reading 'appId')的问题(不影响使用)
|
||||
- 修复文件发送后clawdbot无法读取的问题
|
||||
## ✨ 功能特性
|
||||
|
||||
### 1.2.1
|
||||
- 解决了长时间使用会断联的问题
|
||||
- 解决了频繁重连的问题
|
||||
- 增加了大模型调用失败后的提示消息
|
||||
- 🔒 **多场景支持** - C2C 私聊、群聊 @消息、频道消息、频道私信
|
||||
- 🖼️ **富媒体消息** - 支持图片收发、文件发送
|
||||
- ⏰ **定时推送** - 支持定时任务到时后主动推送
|
||||
- 🔗 **URL 无限制** - 私聊可直接发送 URL
|
||||
- ⌨️ **输入状态** - Bot 正在输入中状态提示
|
||||
- 🔄 **热更新** - 支持 npm 方式安装和热更新
|
||||
- 📝 **Markdown** - 支持 Markdown 格式(即将更新)
|
||||
|
||||
---
|
||||
|
||||
### 1.1.0
|
||||
- 解决了一些url会被拦截的问题
|
||||
- 解决了多轮消息会发送失败的问题
|
||||
- 修复了部分图片无法接受的问题
|
||||
- 增加支持onboard的方式配置AppId 和 AppSecret
|
||||
## 📦 安装
|
||||
|
||||
### 方式一:腾讯云 Lighthouse 镜像(最简单)
|
||||
|
||||
## 安装
|
||||
[](https://cloud.tencent.com/product/lighthouse)
|
||||
|
||||
在插件目录下执行:
|
||||
直接使用预装好的腾讯云 Lighthouse 镜像,开箱即用,无需手动安装配置。
|
||||
|
||||
### 方式二:npm 安装(推荐)
|
||||
|
||||
```bash
|
||||
openclaw plugins install @sliverp/qqbot@1.3.7
|
||||
```
|
||||
|
||||
### 方式三:源码安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sliverp/qqbot.git && cd qqbot
|
||||
clawdbot plugins install . # 这一步会有点久,需要安装一些依赖。稍微耐心等待一下,尤其是小内存机器
|
||||
clawdbot plugins install .
|
||||
```
|
||||
|
||||
## 配置
|
||||
> 💡 安装过程需要一些时间,尤其是小内存机器,请耐心等待
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置
|
||||
|
||||
### 1. 获取 QQ 机器人凭证
|
||||
|
||||
1. 访问 [QQ 开放平台](https://q.qq.com/)
|
||||
2. 创建机器人应用
|
||||
3. 获取 `AppID` 和 `AppSecret`(ClientSecret)
|
||||
4. Token 格式为 `AppID:AppSecret`,例如 `102146862:Xjv7JVhu7KXkxANbp3HVjxCRgvAPeuAQ`
|
||||
4. Token 格式:`AppID:AppSecret`
|
||||
|
||||
### 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`:
|
||||
编辑 `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -103,27 +98,28 @@ clawdbot channels add --channel qqbot --token "102146862:xxxxxxxx"
|
||||
"qqbot": {
|
||||
"enabled": true,
|
||||
"appId": "你的AppID",
|
||||
"clientSecret": "你的AppSecret",
|
||||
"systemPrompt": "你是一个友好的助手"
|
||||
"clientSecret": "你的AppSecret"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 配置项说明
|
||||
## 📋 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `appId` | string | 是 | QQ 机器人 AppID |
|
||||
| `clientSecret` | string | 是* | AppSecret,与 `clientSecretFile` 二选一 |
|
||||
| `clientSecretFile` | string | 是* | AppSecret 文件路径 |
|
||||
| `enabled` | boolean | 否 | 是否启用,默认 `true` |
|
||||
| `name` | string | 否 | 账户显示名称 |
|
||||
| `systemPrompt` | string | 否 | 自定义系统提示词 |
|
||||
| `appId` | string | ✅ | QQ 机器人 AppID |
|
||||
| `clientSecret` | string | ✅* | AppSecret,与 `clientSecretFile` 二选一 |
|
||||
| `clientSecretFile` | string | ✅* | AppSecret 文件路径 |
|
||||
| `enabled` | boolean | ❌ | 是否启用,默认 `true` |
|
||||
| `name` | string | ❌ | 账户显示名称 |
|
||||
| `systemPrompt` | string | ❌ | 自定义系统提示词 |
|
||||
|
||||
## 支持的消息类型
|
||||
---
|
||||
|
||||
## 📨 支持的消息类型
|
||||
|
||||
| 事件类型 | 说明 | Intent |
|
||||
|----------|------|--------|
|
||||
@@ -132,17 +128,17 @@ clawdbot channels add --channel qqbot --token "102146862:xxxxxxxx"
|
||||
| `AT_MESSAGE_CREATE` | 频道 @机器人消息 | `1 << 30` |
|
||||
| `DIRECT_MESSAGE_CREATE` | 频道私信 | `1 << 12` |
|
||||
|
||||
## 使用
|
||||
---
|
||||
|
||||
### 启动
|
||||
## 🚀 使用
|
||||
|
||||
### 启动服务
|
||||
|
||||
后台启动
|
||||
```bash
|
||||
# 后台启动
|
||||
clawdbot gateway restart
|
||||
```
|
||||
|
||||
前台启动, 方便试试查看日志
|
||||
```bash
|
||||
# 前台启动(查看日志)
|
||||
clawdbot gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
@@ -153,23 +149,35 @@ clawdbot onboard
|
||||
# 选择 QQ Bot 进行交互式配置
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
---
|
||||
|
||||
1. **群消息**:需要在群内 @机器人 才能触发回复
|
||||
2. **沙箱模式**:新创建的机器人默认在沙箱模式,需要添加测试用户
|
||||
## ⚠️ 注意事项
|
||||
|
||||
## 升级
|
||||
- **群消息**:需要在群内 @机器人 才能触发回复
|
||||
- **沙箱模式**:新创建的机器人默认在沙箱模式,需要添加测试用户
|
||||
|
||||
如果需要升级插件,先运行升级脚本清理旧版本:
|
||||
---
|
||||
|
||||
## 🔄 升级
|
||||
|
||||
### npm 热更新
|
||||
|
||||
```bash
|
||||
npx -y @sliverp/qqbot@1.3.7 upgrade
|
||||
```
|
||||
|
||||
> 热更新后无需重新配置 AppId 和 AppSecret
|
||||
|
||||
### 源码热更新
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sliverp/qqbot.git && cd qqbot
|
||||
|
||||
# 运行升级脚本(清理旧版本和配置)
|
||||
# 运行升级脚本
|
||||
bash ./scripts/upgrade.sh
|
||||
|
||||
# 重新安装插件
|
||||
clawdbot plugins install . # 这一步会有点久,需要安装一些依赖。稍微耐心等待一下,尤其是小内存机器
|
||||
# 重新安装
|
||||
clawdbot plugins install .
|
||||
|
||||
# 重新配置
|
||||
clawdbot channels add --channel qqbot --token "AppID:AppSecret"
|
||||
@@ -178,49 +186,82 @@ 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
|
||||
```
|
||||
<details>
|
||||
<summary><b>v1.4.0(即将更新)</b></summary>
|
||||
|
||||
## 相关链接
|
||||
- 支持 Markdown 格式
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>v1.3.0 - 2026.02.03</b></summary>
|
||||
|
||||
- ✨ 支持图片收发等功能
|
||||
- ✨ 支持定时任务到时后主动推送
|
||||
- ✨ 支持使用 npm 等方式安装和升级
|
||||
- 🐛 优化一些已知问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>v1.2.5 - 2026.02.02</b></summary>
|
||||
|
||||
- ✨ 解除 URL 发送限制
|
||||
- ✨ 更新 Bot 正在输入中状态
|
||||
- ✨ 提供主动推送能力
|
||||
- 🐛 优化一些已知问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>v1.2.2 - 2026.01.31</b></summary>
|
||||
|
||||
- ✨ 支持发送文件
|
||||
- ✨ 支持 openclaw、moltbot 命令行
|
||||
- 🐛 修复 health 检查提示问题
|
||||
- 🐛 修复文件发送后 clawdbot 无法读取的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>v1.2.1</b></summary>
|
||||
|
||||
- 🐛 解决长时间使用会断联的问题
|
||||
- 🐛 解决频繁重连的问题
|
||||
- ✨ 增加大模型调用失败后的提示消息
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>v1.1.0</b></summary>
|
||||
|
||||
- 🐛 解决 URL 被拦截的问题
|
||||
- 🐛 解决多轮消息发送失败的问题
|
||||
- 🐛 修复部分图片无法接收的问题
|
||||
- ✨ 增加支持 onboard 配置方式
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [QQ 机器人官方文档](https://bot.q.qq.com/wiki/)
|
||||
- [QQ 开放平台](https://q.qq.com/)
|
||||
- [API v2 文档](https://bot.q.qq.com/wiki/develop/api-v2/)
|
||||
|
||||
## License
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
227
bin/qqbot-cli.js
Normal file
227
bin/qqbot-cli.js
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* QQBot CLI - 用于升级和管理 QQBot 插件
|
||||
*
|
||||
* 用法:
|
||||
* npx @sliverp/qqbot upgrade # 升级插件
|
||||
* npx @sliverp/qqbot install # 安装插件
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// 获取包的根目录
|
||||
const PKG_ROOT = join(__dirname, '..');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
// 检测使用的是 clawdbot 还是 openclaw
|
||||
function detectInstallation() {
|
||||
const home = homedir();
|
||||
if (existsSync(join(home, '.openclaw'))) {
|
||||
return 'openclaw';
|
||||
}
|
||||
if (existsSync(join(home, '.clawdbot'))) {
|
||||
return 'clawdbot';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 清理旧版本插件,返回旧的 qqbot 配置
|
||||
function cleanupInstallation(appName) {
|
||||
const home = homedir();
|
||||
const appDir = join(home, `.${appName}`);
|
||||
const configFile = join(appDir, `${appName}.json`);
|
||||
const extensionDir = join(appDir, 'extensions', 'qqbot');
|
||||
|
||||
let oldQqbotConfig = null;
|
||||
|
||||
console.log(`\n>>> 处理 ${appName} 安装...`);
|
||||
|
||||
// 1. 先读取旧的 qqbot 配置
|
||||
if (existsSync(configFile)) {
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configFile, 'utf8'));
|
||||
if (config.channels?.qqbot) {
|
||||
oldQqbotConfig = { ...config.channels.qqbot };
|
||||
console.log('已保存旧的 qqbot 配置');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('读取配置文件失败:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 删除旧的扩展目录
|
||||
if (existsSync(extensionDir)) {
|
||||
console.log(`删除旧版本插件: ${extensionDir}`);
|
||||
rmSync(extensionDir, { recursive: true, force: true });
|
||||
} else {
|
||||
console.log('未找到旧版本插件目录,跳过删除');
|
||||
}
|
||||
|
||||
// 3. 清理配置文件中的 qqbot 相关字段
|
||||
if (existsSync(configFile)) {
|
||||
console.log('清理配置文件中的 qqbot 字段...');
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configFile, 'utf8'));
|
||||
|
||||
// 删除 channels.qqbot
|
||||
if (config.channels?.qqbot) {
|
||||
delete config.channels.qqbot;
|
||||
console.log(' - 已删除 channels.qqbot');
|
||||
}
|
||||
|
||||
// 删除 plugins.entries.qqbot
|
||||
if (config.plugins?.entries?.qqbot) {
|
||||
delete config.plugins.entries.qqbot;
|
||||
console.log(' - 已删除 plugins.entries.qqbot');
|
||||
}
|
||||
|
||||
// 删除 plugins.installs.qqbot
|
||||
if (config.plugins?.installs?.qqbot) {
|
||||
delete config.plugins.installs.qqbot;
|
||||
console.log(' - 已删除 plugins.installs.qqbot');
|
||||
}
|
||||
|
||||
writeFileSync(configFile, JSON.stringify(config, null, 2));
|
||||
console.log('配置文件已更新');
|
||||
} catch (err) {
|
||||
console.error('清理配置文件失败:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log(`未找到配置文件: ${configFile}`);
|
||||
}
|
||||
|
||||
return oldQqbotConfig;
|
||||
}
|
||||
|
||||
// 执行命令并继承 stdio
|
||||
function runCommand(cmd, args = []) {
|
||||
try {
|
||||
execSync([cmd, ...args].join(' '), { stdio: 'inherit' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 升级命令
|
||||
function upgrade() {
|
||||
console.log('=== QQBot 插件升级脚本 ===');
|
||||
|
||||
let foundInstallation = null;
|
||||
let savedConfig = null;
|
||||
const home = homedir();
|
||||
|
||||
// 检查 openclaw
|
||||
if (existsSync(join(home, '.openclaw'))) {
|
||||
savedConfig = cleanupInstallation('openclaw');
|
||||
foundInstallation = 'openclaw';
|
||||
}
|
||||
|
||||
// 检查 clawdbot
|
||||
if (existsSync(join(home, '.clawdbot'))) {
|
||||
const clawdbotConfig = cleanupInstallation('clawdbot');
|
||||
if (!savedConfig) savedConfig = clawdbotConfig;
|
||||
foundInstallation = 'clawdbot';
|
||||
}
|
||||
|
||||
if (!foundInstallation) {
|
||||
console.log('\n未找到 clawdbot 或 openclaw 安装目录');
|
||||
console.log('请确认已安装 clawdbot 或 openclaw');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n=== 清理完成 ===');
|
||||
|
||||
// 自动安装插件
|
||||
console.log('\n[1/2] 安装新版本插件...');
|
||||
runCommand(foundInstallation, ['plugins', 'install', '@sliverp/qqbot']);
|
||||
|
||||
// 自动配置通道(使用保存的 appId 和 clientSecret)
|
||||
console.log('\n[2/2] 配置机器人通道...');
|
||||
if (savedConfig?.appId && savedConfig?.clientSecret) {
|
||||
const token = `${savedConfig.appId}:${savedConfig.clientSecret}`;
|
||||
console.log(`使用已保存的配置: appId=${savedConfig.appId}`);
|
||||
runCommand(foundInstallation, ['channels', 'add', '--channel', 'qqbot', '--token', `"${token}"`]);
|
||||
|
||||
// 恢复其他配置项(如 markdownSupport)
|
||||
if (savedConfig.markdownSupport !== undefined) {
|
||||
runCommand(foundInstallation, ['config', 'set', 'channels.qqbot.markdownSupport', String(savedConfig.markdownSupport)]);
|
||||
}
|
||||
} else {
|
||||
console.log('未找到已保存的 qqbot 配置,请手动配置:');
|
||||
console.log(` ${foundInstallation} channels add --channel qqbot --token "AppID:AppSecret"`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n=== 升级完成 ===');
|
||||
console.log(`\n可以运行以下命令前台运行启动机器人:`);
|
||||
console.log(` ${foundInstallation} gateway stop && ${foundInstallation} gateway --port 18789 --verbose`);
|
||||
}
|
||||
|
||||
// 安装命令
|
||||
function install() {
|
||||
console.log('=== QQBot 插件安装 ===');
|
||||
|
||||
const cmd = detectInstallation();
|
||||
if (!cmd) {
|
||||
console.log('未找到 clawdbot 或 openclaw 安装');
|
||||
console.log('请先安装 openclaw 或 clawdbot');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n使用 ${cmd} 安装插件...`);
|
||||
runCommand(cmd, ['plugins', 'install', '@sliverp/qqbot']);
|
||||
|
||||
console.log('\n=== 安装完成 ===');
|
||||
console.log('\n请配置机器人通道:');
|
||||
console.log(` ${cmd} channels add --channel qqbot --token "AppID:AppSecret"`);
|
||||
}
|
||||
|
||||
// 显示帮助
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
QQBot CLI - QQ机器人插件管理工具
|
||||
|
||||
用法:
|
||||
npx @sliverp/qqbot <命令>
|
||||
|
||||
命令:
|
||||
upgrade 清理旧版本插件(升级前执行)
|
||||
install 安装插件到 openclaw/clawdbot
|
||||
|
||||
示例:
|
||||
npx @sliverp/qqbot upgrade
|
||||
npx @sliverp/qqbot install
|
||||
`);
|
||||
}
|
||||
|
||||
// 主入口
|
||||
switch (command) {
|
||||
case 'upgrade':
|
||||
upgrade();
|
||||
break;
|
||||
case 'install':
|
||||
install();
|
||||
break;
|
||||
case '-h':
|
||||
case '--help':
|
||||
case 'help':
|
||||
showHelp();
|
||||
break;
|
||||
default:
|
||||
if (command) {
|
||||
console.log(`未知命令: ${command}`);
|
||||
}
|
||||
showHelp();
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
1153
package-lock.json
generated
1153
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,9 +1,22 @@
|
||||
{
|
||||
"name": "qqbot",
|
||||
"version": "1.3.0",
|
||||
"name": "@sliverp/qqbot",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"qqbot": "./bin/qqbot-cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"src",
|
||||
"index.ts",
|
||||
"tsconfig.json",
|
||||
"openclaw.plugin.json",
|
||||
"clawdbot.plugin.json",
|
||||
"moltbot.plugin.json"
|
||||
],
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
@@ -33,5 +46,6 @@
|
||||
"clawdbot": "*",
|
||||
"moltbot": "*",
|
||||
"openclaw": "*"
|
||||
}
|
||||
},
|
||||
"homepage": "https://github.com/sliverp/qqbot"
|
||||
}
|
||||
|
||||
@@ -120,6 +120,20 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
configured: Boolean(account?.appId && account?.clientSecret),
|
||||
tokenSource: account?.secretSource,
|
||||
}),
|
||||
// 关键:解析 allowFrom 配置,用于命令授权
|
||||
resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
const allowFrom = account.config?.allowFrom ?? [];
|
||||
console.log(`[qqbot] resolveAllowFrom: accountId=${accountId}, allowFrom=${JSON.stringify(allowFrom)}`);
|
||||
return allowFrom.map((entry: string | number) => String(entry));
|
||||
},
|
||||
// 格式化 allowFrom 条目(移除 qqbot: 前缀,统一大写)
|
||||
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
|
||||
allowFrom
|
||||
.map((entry: string | number) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry: string) => entry.replace(/^qqbot:/i, ""))
|
||||
.map((entry: string) => entry.toUpperCase()), // QQ openid 是大写的
|
||||
},
|
||||
setup: {
|
||||
// 新增:规范化账户 ID
|
||||
@@ -159,18 +173,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
});
|
||||
},
|
||||
},
|
||||
// 新增:消息目标解析
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
// 支持格式: qqbot:openid, qqbot:group:xxx, openid, group:xxx
|
||||
const normalized = target.replace(/^qqbot:/i, "");
|
||||
return { ok: true, to: normalized };
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (id) => /^[A-F0-9]{32}$/i.test(id) || id.startsWith("group:") || id.startsWith("channel:"),
|
||||
hint: "<openid> or group:<groupOpenid>",
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
|
||||
@@ -76,7 +76,7 @@ export function resolveQQBotAccount(
|
||||
allowFrom: qqbot?.allowFrom,
|
||||
systemPrompt: qqbot?.systemPrompt,
|
||||
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||
markdownSupport: qqbot?.markdownSupport,
|
||||
markdownSupport: qqbot?.markdownSupport ?? true,
|
||||
};
|
||||
appId = qqbot?.appId ?? "";
|
||||
} else {
|
||||
@@ -128,11 +128,16 @@ export function applyQQBotAccountConfig(
|
||||
const next = { ...cfg };
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
// 如果没有设置过 allowFrom,默认设置为 ["*"]
|
||||
const existingConfig = (next.channels?.qqbot as QQBotChannelConfig) || {};
|
||||
const allowFrom = existingConfig.allowFrom ?? ["*"];
|
||||
|
||||
next.channels = {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||
enabled: true,
|
||||
allowFrom,
|
||||
...(input.appId ? { appId: input.appId } : {}),
|
||||
...(input.clientSecret
|
||||
? { clientSecret: input.clientSecret }
|
||||
@@ -144,6 +149,10 @@ export function applyQQBotAccountConfig(
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// 如果没有设置过 allowFrom,默认设置为 ["*"]
|
||||
const existingAccountConfig = (next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {};
|
||||
const allowFrom = existingAccountConfig.allowFrom ?? ["*"];
|
||||
|
||||
next.channels = {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
@@ -154,6 +163,7 @@ export function applyQQBotAccountConfig(
|
||||
[accountId]: {
|
||||
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
|
||||
enabled: true,
|
||||
allowFrom,
|
||||
...(input.appId ? { appId: input.appId } : {}),
|
||||
...(input.clientSecret
|
||||
? { clientSecret: input.clientSecret }
|
||||
|
||||
113
src/gateway.ts
113
src/gateway.ts
@@ -2,7 +2,7 @@ import WebSocket from "ws";
|
||||
import path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } from "./api.js";
|
||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify } from "./api.js";
|
||||
import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
|
||||
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
@@ -401,7 +401,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
groupOpenid?: string;
|
||||
attachments?: Array<{ content_type: string; url: string; filename?: string }>;
|
||||
}) => {
|
||||
log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`);
|
||||
log?.info(`[qqbot:${account.accountId}] Received Event: ${{event}}`);
|
||||
if (event.attachments?.length) {
|
||||
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
||||
}
|
||||
@@ -412,6 +412,13 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
try{
|
||||
await sendC2CInputNotify(accessToken, event.senderId, event.messageId, 60);
|
||||
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
|
||||
}catch(err){
|
||||
log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
|
||||
}
|
||||
|
||||
const isGroup = event.type === "guild" || event.type === "group";
|
||||
const peerId = event.type === "guild" ? `channel:${event.channelId}`
|
||||
: event.type === "group" ? `group:${event.groupOpenid}`
|
||||
@@ -445,13 +452,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
- 群组 group_openid: ${event.groupOpenid}` : ""}
|
||||
|
||||
【定时提醒能力】
|
||||
你可以帮助用户设置定时提醒。使用 openclaw cron 命令:
|
||||
你可以帮助用户设置定时提醒。使用exec工具来来运行bash命令 openclaw cron 命令而不是直接使用 "cron" 工具:
|
||||
|
||||
示例:5分钟后提醒用户喝水
|
||||
\`\`\`bash
|
||||
openclaw cron add \\
|
||||
--name "提醒喝水-${event.senderName || "用户"}" \\
|
||||
--at "5m" \\
|
||||
--session isolated \\
|
||||
--message "💧 该喝水啦!" \\
|
||||
--deliver \\
|
||||
--channel qqbot \\
|
||||
@@ -468,6 +476,7 @@ openclaw cron add \\
|
||||
- \`--tz "Asia/Shanghai"\`: 周期任务务必设置时区
|
||||
- \`--delete-after-run\`: 一次性任务必须添加此参数
|
||||
- \`--message\`: 消息内容(必填,不能为空!这是定时提醒触发时直接发送给用户的内容)
|
||||
- \`--session isolated\` 独立会话任务
|
||||
|
||||
⚠️ 重要注意事项:
|
||||
1. --at 参数格式:相对时间用 \`5m\`、\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式
|
||||
@@ -490,7 +499,8 @@ openclaw cron add \\
|
||||
⚠️ 注意:
|
||||
- 必须使用 <qqimg>路径</qqimg> 格式
|
||||
- 本地路径必须是绝对路径,支持 png、jpg、jpeg、gif、webp 格式
|
||||
- 图片文件/URL 必须有效,否则发送失败`;
|
||||
- 图片文件/URL 必须有效,否则发送失败
|
||||
- Markdown格式下,也必须使用该方式发送图片`;
|
||||
|
||||
const systemPrompts = [builtinPrompt];
|
||||
if (account.systemPrompt) {
|
||||
@@ -566,10 +576,15 @@ openclaw cron add \\
|
||||
}
|
||||
|
||||
const userContent = event.content + attachmentInfo;
|
||||
const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`;
|
||||
let messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`;
|
||||
|
||||
if(userContent.startsWith("/")){ // 保留Openclaw原始命令
|
||||
messageBody = userContent
|
||||
}
|
||||
log?.info(`[qqbot:${account.accountId}] messageBody: ${messageBody}`);
|
||||
|
||||
const body = pluginRuntime.channel.reply.formatInboundEnvelope({
|
||||
channel: "QQBot",
|
||||
channel: "qqbot",
|
||||
from: event.senderName ?? event.senderId,
|
||||
timestamp: new Date(event.timestamp).getTime(),
|
||||
body: messageBody,
|
||||
@@ -588,6 +603,14 @@ openclaw cron add \\
|
||||
: `qqbot:c2c:${event.senderId}`;
|
||||
const toAddress = fromAddress;
|
||||
|
||||
// 计算命令授权状态
|
||||
// allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
|
||||
const allowFromList = account.config?.allowFrom ?? [];
|
||||
const allowAll = allowFromList.length === 0 || allowFromList.some((entry: string) => entry === "*");
|
||||
const commandAuthorized = allowAll || allowFromList.some((entry: string) =>
|
||||
entry.toUpperCase() === event.senderId.toUpperCase()
|
||||
);
|
||||
|
||||
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: event.content,
|
||||
@@ -608,6 +631,7 @@ openclaw cron add \\
|
||||
QQChannelId: event.channelId,
|
||||
QQGuildId: event.guildId,
|
||||
QQGroupOpenid: event.groupOpenid,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
});
|
||||
|
||||
// 发送消息的辅助函数,带 token 过期重试
|
||||
@@ -808,7 +832,7 @@ openclaw cron add \\
|
||||
log?.info(`[qqbot:${account.accountId}] Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to send image from <qqimg>: ${err}`);
|
||||
await sendErrorMessage(`发送图片失败: ${err}`);
|
||||
await sendErrorMessage(`图片发送失败,图片似乎不存在哦,图片路径:${imagePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1055,6 +1079,68 @@ openclaw cron add \\
|
||||
// 这些标记可能被 AI 错误地学习并输出
|
||||
textWithoutImages = filterInternalMarkers(textWithoutImages);
|
||||
|
||||
// ============ 提取并处理 <qqimg> 标签中的本地图片 ============
|
||||
// 在所有发送逻辑前,先检测 <qqimg> 标签,提取本地图片并通过富媒体 API 发送
|
||||
// 然后从文本中移除这些标签
|
||||
const qqimgRegexInMarkdown = /<qqimg>([^<>]+)<\/(?:qqimg|img)>/gi;
|
||||
const qqimgMatchesInText = [...textWithoutImages.matchAll(qqimgRegexInMarkdown)];
|
||||
const localImagesFromQqimg: string[] = []; // 存储需要通过富媒体 API 发送的本地图片
|
||||
|
||||
if (qqimgMatchesInText.length > 0) {
|
||||
log?.info(`[qqbot:${account.accountId}] Found ${qqimgMatchesInText.length} <qqimg> tag(s) in text, processing...`);
|
||||
|
||||
for (const match of qqimgMatchesInText) {
|
||||
const imagePath = match[1]?.trim();
|
||||
if (!imagePath) continue;
|
||||
|
||||
// 从文本中移除 <qqimg> 标签
|
||||
textWithoutImages = textWithoutImages.replace(match[0], "");
|
||||
|
||||
// 判断是本地文件还是 URL
|
||||
const isLocalPath = imagePath.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(imagePath);
|
||||
const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://");
|
||||
|
||||
if (isLocalPath) {
|
||||
// 本地文件:转换为 Base64 Data URL
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
log?.error(`[qqbot:${account.accountId}] <qqimg> Image file not found: ${imagePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(imagePath);
|
||||
const base64Data = fileBuffer.toString("base64");
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
const mimeType = mimeTypes[ext];
|
||||
if (!mimeType) {
|
||||
log?.error(`[qqbot:${account.accountId}] <qqimg> Unsupported image format: ${ext}`);
|
||||
continue;
|
||||
}
|
||||
const imageUrl = `data:${mimeType};base64,${base64Data}`;
|
||||
localImagesFromQqimg.push(imageUrl);
|
||||
log?.info(`[qqbot:${account.accountId}] <qqimg> Converted local image to Base64 (size: ${fileBuffer.length} bytes): ${imagePath}`);
|
||||
} catch (readErr) {
|
||||
log?.error(`[qqbot:${account.accountId}] <qqimg> Failed to read image file: ${readErr}`);
|
||||
}
|
||||
} else if (isHttpUrl) {
|
||||
// HTTP URL:添加到图片列表
|
||||
imageUrls.push(imagePath);
|
||||
log?.info(`[qqbot:${account.accountId}] <qqimg> Added HTTP URL to imageUrls: ${imagePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理多余的空行
|
||||
textWithoutImages = textWithoutImages.replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
// 根据模式处理图片
|
||||
if (useMarkdown) {
|
||||
// ============ Markdown 模式 ============
|
||||
@@ -1064,7 +1150,7 @@ openclaw cron add \\
|
||||
|
||||
// 分离图片:公网 URL vs Base64/本地文件
|
||||
const httpImageUrls: string[] = []; // 公网 URL,用于 Markdown 嵌入
|
||||
const base64ImageUrls: string[] = []; // Base64,用于富媒体 API
|
||||
const base64ImageUrls: string[] = [...localImagesFromQqimg]; // Base64,用于富媒体 API(包含从 <qqimg> 提取的本地图片)
|
||||
|
||||
for (const url of imageUrls) {
|
||||
if (url.startsWith("data:image/")) {
|
||||
@@ -1185,12 +1271,7 @@ openclaw cron add \\
|
||||
for (const match of bareUrlMatches) {
|
||||
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 处理文本中的 URL 点号(防止被 QQ 解析为链接)
|
||||
if (textWithoutImages) {
|
||||
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 发送图片(通过富媒体 API)
|
||||
for (const imageUrl of imageUrls) {
|
||||
@@ -1253,7 +1334,9 @@ openclaw cron add \\
|
||||
}
|
||||
},
|
||||
},
|
||||
replyOptions: {},
|
||||
replyOptions: {
|
||||
disableBlockStreaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 等待分发完成或超时
|
||||
|
||||
@@ -21,6 +21,7 @@ interface QQBotChannelConfig {
|
||||
clientSecretFile?: string;
|
||||
name?: string;
|
||||
imageServerBaseUrl?: string;
|
||||
allowFrom?: string[];
|
||||
accounts?: Record<string, {
|
||||
enabled?: boolean;
|
||||
appId?: string;
|
||||
@@ -28,6 +29,7 @@ interface QQBotChannelConfig {
|
||||
clientSecretFile?: string;
|
||||
name?: string;
|
||||
imageServerBaseUrl?: string;
|
||||
allowFrom?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -137,6 +139,7 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||
enabled: true,
|
||||
allowFrom: resolvedAccount.config?.allowFrom ?? ["*"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -200,6 +203,9 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
).trim();
|
||||
}
|
||||
|
||||
// 默认允许所有人执行命令(用户无感知)
|
||||
const allowFrom: string[] = resolvedAccount.config?.allowFrom ?? ["*"];
|
||||
|
||||
// 应用配置
|
||||
if (appId && clientSecret) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
@@ -212,6 +218,7 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
enabled: true,
|
||||
appId,
|
||||
clientSecret,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -230,6 +237,7 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
enabled: true,
|
||||
appId,
|
||||
clientSecret,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user