Compare commits
2 Commits
main
...
feature/gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d36182beed | ||
|
|
1a5b866f20 |
261
README.bak.md
261
README.bak.md
@@ -1,261 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
# QQ Bot Channel Plugin for Openclaw(Clawdbot/Moltbot)
|
||||
|
||||
QQ 开放平台 Bot API 的 Openclaw 渠道插件,支持 C2C 私聊、群聊 @消息、频道消息。
|
||||
|
||||
[](https://www.npmjs.com/package/@sliverp/qqbot)
|
||||
[](./LICENSE)
|
||||
[](https://bot.q.qq.com/wiki/)
|
||||
[](https://github.com/sliverp/openclaw)
|
||||
[](https://nodejs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📸 使用示例
|
||||
<div align="center">
|
||||
<img width="400" alt="使用示例" src="https://github.com/user-attachments/assets/6f1704ab-584b-497e-8937-96f84ce2958f" />
|
||||
<img width="670" height="396" alt="Clipboard_Screenshot_1770366319" src="https://github.com/user-attachments/assets/e21e9292-fb93-41a7-81fe-39eeefe3b01d" />
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 🔒 **多场景支持** - C2C 私聊、群聊 @消息、频道消息、频道私信
|
||||
- 🖼️ **富媒体消息** - 支持图片收发、文件发送
|
||||
- ⏰ **定时推送** - 支持定时任务到时后主动推送
|
||||
- 🔗 **URL 无限制** - 私聊可直接发送 URL
|
||||
- ⌨️ **输入状态** - Bot 正在输入中状态提示
|
||||
- 🔄 **热更新** - 支持 npm 方式安装和热更新
|
||||
- 📝 **Markdown** - 支持 Markdown 格式
|
||||
- 📝 **Command** - 支持Openclaw原生命令
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star 趋势
|
||||
<div align="center">
|
||||
<img width="666" height="464" alt="star-history-202626 (1)" src="https://github.com/user-attachments/assets/01d123b4-f2a7-45b9-b2ed-b7a344497b4a" />
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📦 安装
|
||||
|
||||
### 方式一:腾讯云 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 .
|
||||
```
|
||||
|
||||
> 💡 安装过程需要一些时间,尤其是小内存机器,请耐心等待
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置
|
||||
|
||||
### 1. 获取 QQ 机器人凭证
|
||||
|
||||
1. 访问 [QQ 开放平台](https://q.qq.com/)
|
||||
2. 创建机器人应用
|
||||
3. 获取 `AppID` 和 `AppSecret`(ClientSecret)
|
||||
4. Token 格式:`AppID:AppSecret`
|
||||
|
||||
### 2. 添加配置
|
||||
|
||||
**交互式配置:**
|
||||
|
||||
```bash
|
||||
clawdbot channels add
|
||||
# 选择 qqbot,按提示输入 Token
|
||||
```
|
||||
|
||||
**命令行配置:**
|
||||
|
||||
```bash
|
||||
clawdbot channels add --channel qqbot --token "AppID:AppSecret"
|
||||
```
|
||||
|
||||
### 3. 手动编辑配置(可选)
|
||||
|
||||
编辑 `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qqbot": {
|
||||
"enabled": true,
|
||||
"appId": "你的AppID",
|
||||
"clientSecret": "你的AppSecret"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🚀 使用
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 后台启动
|
||||
clawdbot gateway restart
|
||||
|
||||
# 前台启动(查看日志)
|
||||
clawdbot gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
### CLI 配置向导
|
||||
|
||||
```bash
|
||||
clawdbot onboard
|
||||
# 选择 QQ Bot 进行交互式配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
- **群消息**:需要在群内 @机器人 才能触发回复
|
||||
- **沙箱模式**:新创建的机器人默认在沙箱模式,需要添加测试用户
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级
|
||||
|
||||
### npm 热更新
|
||||
|
||||
```bash
|
||||
npx -y @sliverp/qqbot@1.3.7 upgrade
|
||||
```
|
||||
|
||||
> 热更新后无需重新配置 AppId 和 AppSecret。该方式Openclaw和Node.js会占用大量内存,小内存机器优先建议使用源码方式热更新
|
||||
|
||||
### 源码热更新
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sliverp/qqbot.git && cd qqbot
|
||||
|
||||
# 运行升级脚本
|
||||
bash ./scripts/upgrade.sh
|
||||
|
||||
# 重新安装
|
||||
clawdbot plugins install .
|
||||
|
||||
# 重新配置
|
||||
clawdbot channels add --channel qqbot --token "AppID:AppSecret"
|
||||
|
||||
# 重启网关
|
||||
clawdbot gateway restart
|
||||
```
|
||||
|
||||
升级脚本会自动清理旧版本和配置。
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📚 版本历史
|
||||
|
||||
<details>
|
||||
<summary><b>v1.4.0</b></summary>
|
||||
|
||||
- 支持 Markdown 格式
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>v1.3.13 - 2026.02.06</b></summary>
|
||||
|
||||
- ✨ 支持Openclawd内置指令“/compact" , "/new"等(注意,/reset等命令有危险性,非常不建议把Bot拉入群聊)
|
||||
- 🐛 修复在一些情况下”正在输入“不生效的问题
|
||||
|
||||
</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
|
||||
|
||||
MIT
|
||||
276
README.md
276
README.md
@@ -1,125 +1,221 @@
|
||||
# QQ
|
||||
# QQ Bot Channel Plugin for Moltbot
|
||||
|
||||
QQ is a widely-used instant messaging platform that provides various communication capabilities such as text, voice, images, and files. It supports collaborative scenarios like group chats and channels, making it suitable for both personal communication and team collaboration.
|
||||
QQ 开放平台Bot API 的 Moltbot 渠道插件,支持 C2C 私聊、群聊 @消息、频道消息。
|
||||
|
||||
This integration method connects OpenClaw with a QQ Bot. It utilizes the platform's long-connection event subscription mechanism to receive message and event callbacks, enabling stable and secure message exchange and automation capability integration without exposing a public webhook address.
|
||||
## 功能特性
|
||||
|
||||
# Step 1: Install the QQ Bot Plugin
|
||||
- **多场景支持**:C2C 单聊、QQ 群 @消息、频道公开消息、频道私信
|
||||
- **自动重连**:WebSocket 断连后自动重连,支持 Session Resume
|
||||
- **消息去重**:自动管理 `msg_seq`,支持对同一消息多次回复
|
||||
- **系统提示词**:可配置自定义系统提示词注入到 AI 请求
|
||||
- **错误提示**:AI 无响应时自动提示用户检查配置
|
||||
|
||||
Install via the OpenClaw plugins command.
|
||||
## 使用示例:
|
||||
<img width="1852" height="1082" alt="image" src="https://github.com/user-attachments/assets/a16d582b-708c-473e-b3a2-e0c4c503a0c8" />
|
||||
|
||||
```
|
||||
openclaw plugins install @sliverp/qqbot@latest
|
||||
```
|
||||
## 版本更新
|
||||
<img width="1902" height="448" alt="Clipboard_Screenshot_1769739939" src="https://github.com/user-attachments/assets/d6f37458-900c-4de9-8fdc-f8e6bf5c7ee5" />
|
||||
|
||||
Install from source code:
|
||||
### 1.3.0(即将更新)
|
||||
- 支持回复图片等功能
|
||||
- 支持定时任务到时后主动推送
|
||||
|
||||
```
|
||||
### 1.2.5
|
||||
- 解除URL发送限制,现在可以直接在私聊发送URL
|
||||
<img width="1786" height="576" alt="Clipboard_Screenshot_1770092858" src="https://github.com/user-attachments/assets/c660949e-28a5-4e5f-abc2-77f0a2c67bad" />
|
||||
- 更新Bot正在输入中状态
|
||||
<img width="1440" height="412" alt="Clipboard_Screenshot_1770091969" src="https://github.com/user-attachments/assets/47835c4b-ccd2-4782-aaa6-b873cb58f7d7" />
|
||||
- 提供主动推送能力(目前AI还不知道怎么调用主动推送,相关完整Skill能力将在后续版本更新)
|
||||
- 优化一些已知问题
|
||||
- 优化未收到未收到大模型响应时的提示信息
|
||||
|
||||
|
||||
### 1.2.2
|
||||
- 支持发送文件
|
||||
- 支持openclaw、moltbot命令行
|
||||
- 修复[health]检查提示: [health] refresh failed: Cannot read properties of undefined (reading 'appId')的问题(不影响使用)
|
||||
- 修复文件发送后clawdbot无法读取的问题
|
||||
|
||||
### 1.2.1
|
||||
- 解决了长时间使用会断联的问题
|
||||
- 解决了频繁重连的问题
|
||||
- 增加了大模型调用失败后的提示消息
|
||||
|
||||
|
||||
### 1.1.0
|
||||
- 解决了一些url会被拦截的问题
|
||||
- 解决了多轮消息会发送失败的问题
|
||||
- 修复了部分图片无法接受的问题
|
||||
- 增加支持onboard的方式配置AppId 和 AppSecret
|
||||
|
||||
|
||||
## 安装
|
||||
|
||||
在插件目录下执行:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sliverp/qqbot.git && cd qqbot
|
||||
openclaw plugins install .
|
||||
clawdbot plugins install . # 这一步会有点久,需要安装一些依赖。稍微耐心等待一下,尤其是小内存机器
|
||||
```
|
||||
|
||||
# Step 2: Create a QQ Bot
|
||||
## 配置
|
||||
|
||||
## 1. Register on the QQ Open Platform
|
||||
### 1. 获取 QQ 机器人凭证
|
||||
|
||||
Go to the official website of the Tencent QQ Open Platform. You cannot log in directly with your personal QQ account by default; you need to register a new QQ Open Platform account.
|
||||
<img width="2140" height="1004" alt="1" src="https://github.com/user-attachments/assets/d76a780c-5040-43fb-ac41-5808f975ae4b" />
|
||||
1. 访问 [QQ 开放平台](https://q.qq.com/)
|
||||
2. 创建机器人应用
|
||||
3. 获取 `AppID` 和 `AppSecret`(ClientSecret)
|
||||
4. Token 格式为 `AppID:AppSecret`,例如 `102146862:Xjv7JVhu7KXkxANbp3HVjxCRgvAPeuAQ`
|
||||
|
||||
After the initial registration, follow the platform's instructions to set up a super administrator.
|
||||
### 2. 添加配置
|
||||
|
||||
<img width="2556" height="1744" alt="2" src="https://github.com/user-attachments/assets/ad0a54d5-6997-4f52-ae8f-bea71aa11c30" />
|
||||
After successfully scanning the QR code with your mobile QQ, proceed to the next step to fill in the relevant entity information.
|
||||
#### 方式一:交互式配置
|
||||
|
||||
Using "Individual" as an example here, follow the prompts to enter your name, ID number, phone number, and verification code, then click continue to proceed to the facial recognition step.
|
||||
<img width="2544" height="1744" alt="3" src="https://github.com/user-attachments/assets/b85c11f8-5627-4e08-b522-b38c4929bcb6" />
|
||||
|
||||
Use your mobile QQ to scan the QR code for facial recognition.
|
||||
<img width="2542" height="1272" alt="4" src="https://github.com/user-attachments/assets/d0db5539-56ef-4189-930f-595348892bef" />
|
||||
|
||||
Once the facial recognition review is approved, you can log in to the QQ Open Platform.
|
||||
<img width="2356" height="1308" alt="5" src="https://github.com/user-attachments/assets/c1875b27-fefc-4a1c-81ef-863da8b15ec6" />
|
||||
|
||||
## 2. Create a QQ Bot
|
||||
|
||||
On the QQ Open Platform's QQ Bot page, you can create a bot.
|
||||
<img width="2334" height="1274" alt="6" src="https://github.com/user-attachments/assets/8389c38d-6662-46d0-ae04-92af374b61ef" />
|
||||
<img width="2316" height="1258" alt="7" src="https://github.com/user-attachments/assets/15cfe57a-0404-4b02-85fe-42a22cf96d01" />
|
||||
|
||||
After the QQ Bot is created, you can select it and click to enter the management page.
|
||||
<img width="3002" height="1536" alt="8" src="https://github.com/user-attachments/assets/7c0c7c69-29db-457f-974a-4aa52ebd7973" />
|
||||
|
||||
On the QQ Bot management page, obtain the current bot's AppID and AppSecret, copy them, and save them to your personal notepad or memo (please ensure data security and do not leak them). They will be needed later in "Step 3: Configuring OpenClaw".
|
||||
|
||||
Note: For security reasons, the QQ Bot's AppSecret is not stored in plain text. If you view it for the first time or forget it, you need to regenerate it.
|
||||
<img width="2970" height="1562" alt="9" src="https://github.com/user-attachments/assets/c7fc3094-2840-4780-a202-47b2c2b74e50" />
|
||||
<img width="1258" height="594" alt="10" src="https://github.com/user-attachments/assets/4445bede-e7d5-4927-9821-039e7ad8f1f5" />
|
||||
|
||||
## 3. Sandbox Configuration
|
||||
|
||||
On the QQ Bot's "Development Management" page, in the "Sandbox Configuration" section, set up private chat (select "Configure in Message List").
|
||||
|
||||
You can configure this according to your own usage scenario, or you can complete the subsequent steps and then return to this step to operate.
|
||||
|
||||
⚠️ Note:
|
||||
The QQ Bot created here does not need to be published and made available to all QQ users. It can be used for personal (sandbox) debugging and experience.
|
||||
The QQ Open Platform does not support "Configuration in QQ Groups" for bots; it only supports private chat with the QQ Bot.
|
||||
<img width="1904" height="801" alt="11" src="https://github.com/user-attachments/assets/f3940a87-aae7-4c89-8f9a-c94fb52cd3ea" />
|
||||
|
||||
Note: When selecting "Configure in Message List", you need to first add members, and then use the QQ scan code of that member to add the bot.
|
||||
<img width="2582" height="484" alt="12" src="https://github.com/user-attachments/assets/5631fe76-2205-4b1e-b463-75fa3a397464" />
|
||||
Note here that after successfully adding a member, you still need to use QQ scan code to add the bot.
|
||||
|
||||
<img width="2286" height="1324" alt="13" src="https://github.com/user-attachments/assets/cbf379be-ef6e-4391-8cb1-67c08aad2d43" />
|
||||
At this point, after adding the bot to your QQ account, you still cannot have a normal conversation with it. You will receive a prompt saying "The bot has gone to Mars, please try again later." This is because the QQ bot has not yet been connected to the OpenClaw application.
|
||||
|
||||
You need to proceed with the following steps to configure the QQ bot's AppID and AppSecret for the OpenClaw application.
|
||||
|
||||
<img width="872" height="1052" alt="14" src="https://github.com/user-attachments/assets/0c02aaf6-6cf9-419c-a6ab-36398d73c5ba" />
|
||||
|
||||
(Optional) You can also add more members by referring to the previous steps: First, add a new member in the member management page, then add the member in the sandbox configuration page. After that, the new member can add this QQ bot by scanning the QR code with QQ.
|
||||
<img width="3006" height="1504" alt="15" src="https://github.com/user-attachments/assets/cecef3a6-0596-4da0-8b92-8d67b8f3cdca" />
|
||||
<img width="2902" height="1394" alt="16" src="https://github.com/user-attachments/assets/eb98ffce-490f-402c-8b0c-af7ede1b1303" />
|
||||
<img width="1306" height="672" alt="17" src="https://github.com/user-attachments/assets/799056e3-82a6-44bc-9e3d-9c840faafa41" />
|
||||
|
||||
# Step 3: Configure OpenClaw
|
||||
|
||||
## Method 1: Configure via Wizard (Recommended)
|
||||
|
||||
Add the qqbot channel and input the AppID and AppSecret obtained in Step 2.
|
||||
|
||||
```
|
||||
openclaw channels add --channel qqbot --token "AppID:AppSecret"
|
||||
```bash
|
||||
clawdbot channels add
|
||||
# 选择 qqbot,按提示输入 Token
|
||||
```
|
||||
|
||||
## Method 2: Configure via Configuration File
|
||||
#### 方式二:命令行配置
|
||||
|
||||
Edit ~/.openclaw/openclaw.json:
|
||||
```bash
|
||||
clawdbot channels add --channel qqbot --token "AppID:AppSecret"
|
||||
```
|
||||
|
||||
``` json
|
||||
示例:
|
||||
|
||||
```bash
|
||||
clawdbot channels add --channel qqbot --token "102146862:xxxxxxxx"
|
||||
```
|
||||
|
||||
### 3. 手动编辑配置(可选)
|
||||
|
||||
也可以直接编辑 `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qqbot": {
|
||||
"enabled": true,
|
||||
"appId": "Your AppID",
|
||||
"clientSecret": "Your AppSecret"
|
||||
"appId": "你的AppID",
|
||||
"clientSecret": "你的AppSecret",
|
||||
"systemPrompt": "你是一个友好的助手"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# Step 4: Start and Test
|
||||
|
||||
## 1. Start the gateway
|
||||
|
||||
```
|
||||
openclaw gateway
|
||||
## 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `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
|
||||
```
|
||||
|
||||
## 2. Chat with the QQbot in QQ
|
||||
前台启动, 方便试试查看日志
|
||||
```bash
|
||||
clawdbot gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
<img width="990" height="984" alt="18" src="https://github.com/user-attachments/assets/b2776c8b-de72-4e37-b34d-e8287ce45de1" />
|
||||
### CLI 配置向导
|
||||
|
||||
```bash
|
||||
clawdbot onboard
|
||||
# 选择 QQ Bot 进行交互式配置
|
||||
```
|
||||
|
||||
# Other Language README
|
||||
[简体中文](README.zh.md)
|
||||
## 注意事项
|
||||
|
||||
1. **群消息**:需要在群内 @机器人 才能触发回复
|
||||
2. **沙箱模式**:新创建的机器人默认在沙箱模式,需要添加测试用户
|
||||
|
||||
## 升级
|
||||
|
||||
如果需要升级插件,先运行升级脚本清理旧版本:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sliverp/qqbot.git && cd qqbot
|
||||
|
||||
# 运行升级脚本(清理旧版本和配置)
|
||||
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
|
||||
|
||||
115
README.zh.md
115
README.zh.md
@@ -1,115 +0,0 @@
|
||||
# QQ
|
||||
QQ 是一款覆盖广泛用户群体的即时通讯平台,提供文字、语音、图片、文件等多种沟通能力,并支持群聊、频道等协作场景,适用于个人交流与团队协同。
|
||||
|
||||
该接入方式可将 OpenClaw 与 QQ Bot 进行连接,通过平台的长连接事件订阅机制接收消息与事件回调,从而在不对外暴露公网 Webhook 地址的情况下实现稳定、安全的消息收发与自动化能力集成。
|
||||
|
||||
# 步骤1:安装QQ Bot插件
|
||||
|
||||
OpenClaw plugins命令安装
|
||||
|
||||
```
|
||||
openclaw plugins install @sliverp/qqbot@latest
|
||||
```
|
||||
|
||||
使用源码安装:
|
||||
|
||||
```
|
||||
git clone https://github.com/sliverp/qqbot.git && cd qqbot
|
||||
openclaw plugins install .
|
||||
```
|
||||
|
||||
# 步骤2:创建QQ Bot
|
||||
## 1.注册QQ开放平台
|
||||
前往腾讯QQ开放平台官网,默认无法使用您的QQ账号直接登录,需要新注册QQ开放平台账号。
|
||||
<img width="2140" height="1004" alt="1" src="https://github.com/user-attachments/assets/d76a780c-5040-43fb-ac41-5808f975ae4b" />
|
||||
|
||||
首次注册之后,可以按照QQ开放平台的指引设置超级管理员。
|
||||
|
||||
<img width="2556" height="1744" alt="2" src="https://github.com/user-attachments/assets/ad0a54d5-6997-4f52-ae8f-bea71aa11c30" />
|
||||
手机QQ扫码成功后,继续下一步填写主体相关信息。
|
||||
|
||||
此处以“个人”为例,按照指引依次输入姓名、身份证号、手机号、验证码,点击继续进入下一步人脸认证。
|
||||
<img width="2544" height="1744" alt="3" src="https://github.com/user-attachments/assets/b85c11f8-5627-4e08-b522-b38c4929bcb6" />
|
||||
|
||||
使用手机QQ扫码进行人脸认证。
|
||||
<img width="2542" height="1272" alt="4" src="https://github.com/user-attachments/assets/d0db5539-56ef-4189-930f-595348892bef" />
|
||||
|
||||
人脸识别审核通过后,即可登录进入QQ开放平台。
|
||||
<img width="2356" height="1308" alt="5" src="https://github.com/user-attachments/assets/c1875b27-fefc-4a1c-81ef-863da8b15ec6" />
|
||||
|
||||
## 2.创建一个QQBot机器人
|
||||
在QQ开放平台的QQ机器人页面,可以创建机器人。
|
||||
<img width="2334" height="1274" alt="6" src="https://github.com/user-attachments/assets/8389c38d-6662-46d0-ae04-92af374b61ef" />
|
||||
<img width="2316" height="1258" alt="7" src="https://github.com/user-attachments/assets/15cfe57a-0404-4b02-85fe-42a22cf96d01" />
|
||||
|
||||
QQ机器人创建完成之后,可选择机器人点击进入管理页面。
|
||||
<img width="3002" height="1536" alt="8" src="https://github.com/user-attachments/assets/7c0c7c69-29db-457f-974a-4aa52ebd7973" />
|
||||
|
||||
在QQ机器人管理页面获取当前机器人的AppID和AppSecret,复制并将其保存到个人记事本或备忘录中(请注意数据安全,勿泄露),后续在“步骤3中配置OpenClaw“中需要使用。
|
||||
|
||||
注意:出于安全考虑,QQ机器人的AppSecret不支持明文保存,首次查看或忘记AppSecret需要重新生成。
|
||||
<img width="2970" height="1562" alt="9" src="https://github.com/user-attachments/assets/c7fc3094-2840-4780-a202-47b2c2b74e50" />
|
||||
<img width="1258" height="594" alt="10" src="https://github.com/user-attachments/assets/4445bede-e7d5-4927-9821-039e7ad8f1f5" />
|
||||
|
||||
## 3.沙箱配置
|
||||
在QQ机器人的“开发管理”页面,在“沙箱配置”中,设置单独聊天(选择“在消息列表配置”)。
|
||||
|
||||
您可以按照自己的使用场景进行配置,也可以完成后续步骤之后再回到本步骤进行操作。
|
||||
|
||||
⚠️ 注意:
|
||||
此处已创建的QQ机器人无需进行发布上架对所有QQ用户公开使用,在开发者私人的(沙箱)调试下使用体验即可。
|
||||
QQ开放平台不支持机器人的“在QQ群配置”操作,只支持单独和QQ机器人聊天。
|
||||
<img width="1904" height="801" alt="11" src="https://github.com/user-attachments/assets/f3940a87-aae7-4c89-8f9a-c94fb52cd3ea" />
|
||||
|
||||
注意:选择“在消息列表配置”时,需要先添加成员,再通过该成员的QQ扫码来添加机器人。
|
||||
<img width="2582" height="484" alt="12" src="https://github.com/user-attachments/assets/5631fe76-2205-4f1e-b463-75fa3a397464" />
|
||||
此处注意添加成员成功之后,还需要使用QQ扫码添加
|
||||
|
||||
<img width="2286" height="1324" alt="13" src="https://github.com/user-attachments/assets/cbf379be-ef6e-4391-8cb1-67c08aad2d43" />
|
||||
此时您的QQ账号添加机器人之后,还不能与该机器人正常进行对话,会提示“该机器人去火星了,稍后再试吧”,因为QQ机器人此时尚未与OpenClaw应用打通。
|
||||
|
||||
您需要继续后面的步骤,为OpenClaw应用配置QQ机器人的AppID和AppSecret。
|
||||
|
||||
<img width="872" height="1052" alt="14" src="https://github.com/user-attachments/assets/0c02aaf6-6cf9-419c-a6ab-36398d73c5ba" />
|
||||
|
||||
(可选)您也可以参考前述步骤添加更多成员:首先在成员管理页面中添加新成员,然后在沙箱配置页面中添加成员,之后新成员即可通过QQ扫码添加该QQ机器人。
|
||||
<img width="3006" height="1504" alt="15" src="https://github.com/user-attachments/assets/cecef3a6-0596-4da0-8b92-8d67b8f3cdca" />
|
||||
<img width="2902" height="1394" alt="16" src="https://github.com/user-attachments/assets/eb98ffce-490f-402c-8b0c-af7ede1b1303" />
|
||||
<img width="1306" height="672" alt="17" src="https://github.com/user-attachments/assets/799056e3-82a6-44bc-9e3d-9c840faafa41" />
|
||||
|
||||
# 步骤3: 配置OpenClaw
|
||||
## 方式一: 通过Wizard配置(推荐)
|
||||
|
||||
添加qqbot channel 并将步骤2中获取的AppID和AppSecret
|
||||
```
|
||||
openclaw channels add --channel qqbot --token "AppID:AppSecret"
|
||||
```
|
||||
## 方式二:通过配置文件配置
|
||||
|
||||
编辑 ~/.openclaw/openclaw.json:
|
||||
``` json
|
||||
{
|
||||
"channels": {
|
||||
"qqbot": {
|
||||
"enabled": true,
|
||||
"appId": "你的AppID",
|
||||
"clientSecret": "你的AppSecret"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 步骤4:启动与测试
|
||||
|
||||
## 1.启动gateway
|
||||
|
||||
```
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
## 2.在QQ中与QQbot 对话
|
||||
|
||||
<img width="990" height="984" alt="18" src="https://github.com/user-attachments/assets/b2776c8b-de72-4e37-b34d-e8287ce45de1" />
|
||||
|
||||
# 其他语言 README
|
||||
[英文](README.md)
|
||||
227
bin/qqbot-cli.js
227
bin/qqbot-cli.js
@@ -1,227 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
{
|
||||
"id": "qqbot",
|
||||
"name": "QQ Bot Channel",
|
||||
"description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
|
||||
"channels": ["qqbot"],
|
||||
"skills": ["skills/qqbot-cron", "skills/qqbot-media"],
|
||||
"capabilities": {
|
||||
"proactiveMessaging": true,
|
||||
"cronJobs": true
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
7
index.ts
7
index.ts
@@ -1,6 +1,4 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
|
||||
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { qqbotPlugin } from "./src/channel.js";
|
||||
import { setQQBotRuntime } from "./src/runtime.js";
|
||||
|
||||
@@ -8,8 +6,7 @@ const plugin = {
|
||||
id: "qqbot",
|
||||
name: "QQ Bot",
|
||||
description: "QQ Bot channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
register(api: MoltbotPluginApi) {
|
||||
setQQBotRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: qqbotPlugin });
|
||||
},
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
{
|
||||
"id": "qqbot",
|
||||
"name": "QQ Bot Channel",
|
||||
"description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
|
||||
"channels": ["qqbot"],
|
||||
"skills": ["skills/qqbot-cron", "skills/qqbot-media"],
|
||||
"capabilities": {
|
||||
"proactiveMessaging": true,
|
||||
"cronJobs": true
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
{
|
||||
"id": "qqbot",
|
||||
"name": "QQ Bot Channel",
|
||||
"description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
|
||||
"channels": ["qqbot"],
|
||||
"skills": ["skills/qqbot-cron", "skills/qqbot-media"],
|
||||
"capabilities": {
|
||||
"proactiveMessaging": true,
|
||||
"cronJobs": true
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
1700
package-lock.json
generated
1700
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,23 +1,9 @@
|
||||
{
|
||||
"name": "@sliverp/qqbot",
|
||||
"version": "1.4.2",
|
||||
"name": "qqbot",
|
||||
"version": "1.2.3",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"qqbot": "./bin/qqbot-cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"src",
|
||||
"skills",
|
||||
"index.ts",
|
||||
"tsconfig.json",
|
||||
"openclaw.plugin.json",
|
||||
"clawdbot.plugin.json",
|
||||
"moltbot.plugin.json"
|
||||
],
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
@@ -41,12 +27,11 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"clawdbot": "*",
|
||||
"moltbot": "*",
|
||||
"openclaw": "*"
|
||||
},
|
||||
"homepage": "https://github.com/sliverp/qqbot"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
/**
|
||||
* QQBot 主动消息 HTTP API 服务
|
||||
*
|
||||
* 提供 RESTful API 用于:
|
||||
* 1. 发送主动消息
|
||||
* 2. 查询已知用户
|
||||
* 3. 广播消息
|
||||
*
|
||||
* 启动方式:
|
||||
* npx ts-node scripts/proactive-api-server.ts --port 3721
|
||||
*
|
||||
* API 端点:
|
||||
* POST /send - 发送主动消息
|
||||
* GET /users - 列出已知用户
|
||||
* GET /users/stats - 获取用户统计
|
||||
* POST /broadcast - 广播消息
|
||||
*/
|
||||
|
||||
import * as http from "node:http";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as url from "node:url";
|
||||
import {
|
||||
sendProactiveMessageDirect,
|
||||
listKnownUsers,
|
||||
getKnownUsersStats,
|
||||
getKnownUser,
|
||||
broadcastMessage,
|
||||
} from "../src/proactive.js";
|
||||
import type { ResolvedQQBotAccount } from "../src/types.js";
|
||||
|
||||
// 默认端口
|
||||
const DEFAULT_PORT = 3721;
|
||||
|
||||
// 从配置文件加载账户信息
|
||||
function loadAccount(accountId = "default"): ResolvedQQBotAccount | null {
|
||||
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
|
||||
|
||||
try {
|
||||
// 优先从环境变量获取
|
||||
const envAppId = process.env.QQBOT_APP_ID;
|
||||
const envClientSecret = process.env.QQBOT_CLIENT_SECRET;
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
if (envAppId && envClientSecret) {
|
||||
return {
|
||||
accountId,
|
||||
appId: envAppId,
|
||||
clientSecret: envClientSecret,
|
||||
enabled: true,
|
||||
secretSource: "env",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const qqbot = config.channels?.qqbot;
|
||||
|
||||
if (!qqbot) {
|
||||
if (envAppId && envClientSecret) {
|
||||
return {
|
||||
accountId,
|
||||
appId: envAppId,
|
||||
clientSecret: envClientSecret,
|
||||
enabled: true,
|
||||
secretSource: "env",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析账户配置
|
||||
if (accountId === "default") {
|
||||
return {
|
||||
accountId: "default",
|
||||
appId: qqbot.appId || envAppId,
|
||||
clientSecret: qqbot.clientSecret || envClientSecret,
|
||||
enabled: qqbot.enabled ?? true,
|
||||
secretSource: qqbot.clientSecret ? "config" : "env",
|
||||
};
|
||||
}
|
||||
|
||||
const accountConfig = qqbot.accounts?.[accountId];
|
||||
if (accountConfig) {
|
||||
return {
|
||||
accountId,
|
||||
appId: accountConfig.appId || qqbot.appId || envAppId,
|
||||
clientSecret: accountConfig.clientSecret || qqbot.clientSecret || envClientSecret,
|
||||
enabled: accountConfig.enabled ?? true,
|
||||
secretSource: accountConfig.clientSecret ? "config" : "env",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载配置(用于 broadcastMessage)
|
||||
function loadConfig(): Record<string, unknown> {
|
||||
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
// 解析请求体
|
||||
async function parseBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 发送 JSON 响应
|
||||
function sendJson(res: http.ServerResponse, statusCode: number, data: unknown) {
|
||||
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// 处理请求
|
||||
async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const parsedUrl = url.parse(req.url || "", true);
|
||||
const pathname = parsedUrl.pathname || "/";
|
||||
const method = req.method || "GET";
|
||||
const query = parsedUrl.query;
|
||||
|
||||
// CORS 支持
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${new Date().toISOString()}] ${method} ${pathname}`);
|
||||
|
||||
try {
|
||||
// POST /send - 发送主动消息
|
||||
if (pathname === "/send" && method === "POST") {
|
||||
const body = await parseBody(req);
|
||||
const { to, text, type = "c2c", accountId = "default" } = body as {
|
||||
to?: string;
|
||||
text?: string;
|
||||
type?: "c2c" | "group";
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
if (!to || !text) {
|
||||
sendJson(res, 400, { error: "Missing required fields: to, text" });
|
||||
return;
|
||||
}
|
||||
|
||||
const account = loadAccount(accountId);
|
||||
if (!account) {
|
||||
sendJson(res, 500, { error: "Failed to load account configuration" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendProactiveMessageDirect(account, to, text, type);
|
||||
sendJson(res, result.success ? 200 : 500, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /users - 列出已知用户
|
||||
if (pathname === "/users" && method === "GET") {
|
||||
const type = query.type as "c2c" | "group" | "channel" | undefined;
|
||||
const accountId = query.accountId as string | undefined;
|
||||
const limit = query.limit ? parseInt(query.limit as string, 10) : undefined;
|
||||
|
||||
const users = listKnownUsers({ type, accountId, limit });
|
||||
sendJson(res, 200, { total: users.length, users });
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /users/stats - 获取用户统计
|
||||
if (pathname === "/users/stats" && method === "GET") {
|
||||
const accountId = query.accountId as string | undefined;
|
||||
const stats = getKnownUsersStats(accountId);
|
||||
sendJson(res, 200, stats);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /users/:openid - 获取单个用户
|
||||
if (pathname.startsWith("/users/") && method === "GET" && pathname !== "/users/stats") {
|
||||
const openid = pathname.slice("/users/".length);
|
||||
const type = (query.type as string) || "c2c";
|
||||
const accountId = (query.accountId as string) || "default";
|
||||
|
||||
const user = getKnownUser(type, openid, accountId);
|
||||
if (user) {
|
||||
sendJson(res, 200, user);
|
||||
} else {
|
||||
sendJson(res, 404, { error: "User not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /broadcast - 广播消息
|
||||
if (pathname === "/broadcast" && method === "POST") {
|
||||
const body = await parseBody(req);
|
||||
const { text, type = "c2c", accountId, limit } = body as {
|
||||
text?: string;
|
||||
type?: "c2c" | "group";
|
||||
accountId?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
if (!text) {
|
||||
sendJson(res, 400, { error: "Missing required field: text" });
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const result = await broadcastMessage(text, cfg as any, { type, accountId, limit });
|
||||
sendJson(res, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET / - API 文档
|
||||
if (pathname === "/" && method === "GET") {
|
||||
sendJson(res, 200, {
|
||||
name: "QQBot Proactive Message API",
|
||||
version: "1.0.0",
|
||||
endpoints: {
|
||||
"POST /send": {
|
||||
description: "发送主动消息",
|
||||
body: {
|
||||
to: "目标 openid (必需)",
|
||||
text: "消息内容 (必需)",
|
||||
type: "消息类型: c2c | group (默认 c2c)",
|
||||
accountId: "账户 ID (默认 default)",
|
||||
},
|
||||
},
|
||||
"GET /users": {
|
||||
description: "列出已知用户",
|
||||
query: {
|
||||
type: "过滤类型: c2c | group | channel",
|
||||
accountId: "过滤账户 ID",
|
||||
limit: "限制返回数量",
|
||||
},
|
||||
},
|
||||
"GET /users/stats": {
|
||||
description: "获取用户统计",
|
||||
query: {
|
||||
accountId: "过滤账户 ID",
|
||||
},
|
||||
},
|
||||
"GET /users/:openid": {
|
||||
description: "获取单个用户信息",
|
||||
query: {
|
||||
type: "用户类型 (默认 c2c)",
|
||||
accountId: "账户 ID (默认 default)",
|
||||
},
|
||||
},
|
||||
"POST /broadcast": {
|
||||
description: "广播消息给所有已知用户",
|
||||
body: {
|
||||
text: "消息内容 (必需)",
|
||||
type: "消息类型: c2c | group (默认 c2c)",
|
||||
accountId: "账户 ID",
|
||||
limit: "限制发送数量",
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: [
|
||||
"只有曾经与机器人交互过的用户才能收到主动消息",
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 404
|
||||
sendJson(res, 404, { error: "Not found" });
|
||||
} catch (err) {
|
||||
console.error(`Error handling request: ${err}`);
|
||||
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// 解析命令行参数获取端口
|
||||
function getPort(): number {
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--port" && args[i + 1]) {
|
||||
return parseInt(args[i + 1], 10) || DEFAULT_PORT;
|
||||
}
|
||||
}
|
||||
return parseInt(process.env.PROACTIVE_API_PORT || "", 10) || DEFAULT_PORT;
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
function main() {
|
||||
const port = getPort();
|
||||
|
||||
const server = http.createServer(handleRequest);
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ QQBot Proactive Message API Server ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ Server running at: http://localhost:${port.toString().padEnd(25)}║
|
||||
║ ║
|
||||
║ Endpoints: ║
|
||||
║ GET / - API documentation ║
|
||||
║ POST /send - Send proactive message ║
|
||||
║ GET /users - List known users ║
|
||||
║ GET /users/stats - Get user statistics ║
|
||||
║ POST /broadcast - Broadcast message ║
|
||||
║ ║
|
||||
║ Example: ║
|
||||
║ curl -X POST http://localhost:${port}/send \\ ║
|
||||
║ -H "Content-Type: application/json" \\ ║
|
||||
║ -d '{"to":"openid","text":"Hello!"}' ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
process.on("SIGINT", () => {
|
||||
console.log("\nShutting down...");
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,273 +0,0 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* QQBot 主动消息 CLI 工具
|
||||
*
|
||||
* 使用示例:
|
||||
* # 发送私聊消息
|
||||
* npx ts-node scripts/send-proactive.ts --to "用户openid" --text "你好!"
|
||||
*
|
||||
* # 发送群聊消息
|
||||
* npx ts-node scripts/send-proactive.ts --to "群组openid" --type group --text "群公告"
|
||||
*
|
||||
* # 列出已知用户
|
||||
* npx ts-node scripts/send-proactive.ts --list
|
||||
*
|
||||
* # 列出群聊用户
|
||||
* npx ts-node scripts/send-proactive.ts --list --type group
|
||||
*
|
||||
* # 广播消息
|
||||
* npx ts-node scripts/send-proactive.ts --broadcast --text "系统公告" --type c2c --limit 10
|
||||
*/
|
||||
|
||||
import {
|
||||
sendProactiveMessageDirect,
|
||||
listKnownUsers,
|
||||
getKnownUsersStats,
|
||||
broadcastMessage,
|
||||
} from "../src/proactive.js";
|
||||
import type { ResolvedQQBotAccount } from "../src/types.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
// 解析命令行参数
|
||||
function parseArgs(): Record<string, string | boolean> {
|
||||
const args: Record<string, string | boolean> = {};
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg.startsWith("--")) {
|
||||
const key = arg.slice(2);
|
||||
const nextArg = argv[i + 1];
|
||||
if (nextArg && !nextArg.startsWith("--")) {
|
||||
args[key] = nextArg;
|
||||
i++;
|
||||
} else {
|
||||
args[key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// 从配置文件加载账户信息
|
||||
function loadAccount(accountId = "default"): ResolvedQQBotAccount | null {
|
||||
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
// 尝试从环境变量获取
|
||||
const appId = process.env.QQBOT_APP_ID;
|
||||
const clientSecret = process.env.QQBOT_CLIENT_SECRET;
|
||||
|
||||
if (appId && clientSecret) {
|
||||
return {
|
||||
accountId,
|
||||
appId,
|
||||
clientSecret,
|
||||
enabled: true,
|
||||
secretSource: "env",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("配置文件不存在且环境变量未设置");
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const qqbot = config.channels?.qqbot;
|
||||
|
||||
if (!qqbot) {
|
||||
console.error("配置中没有 qqbot 配置");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析账户配置
|
||||
if (accountId === "default") {
|
||||
return {
|
||||
accountId: "default",
|
||||
appId: qqbot.appId || process.env.QQBOT_APP_ID,
|
||||
clientSecret: qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET,
|
||||
enabled: qqbot.enabled ?? true,
|
||||
secretSource: qqbot.clientSecret ? "config" : "env",
|
||||
};
|
||||
}
|
||||
|
||||
const accountConfig = qqbot.accounts?.[accountId];
|
||||
if (accountConfig) {
|
||||
return {
|
||||
accountId,
|
||||
appId: accountConfig.appId || qqbot.appId || process.env.QQBOT_APP_ID,
|
||||
clientSecret: accountConfig.clientSecret || qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET,
|
||||
enabled: accountConfig.enabled ?? true,
|
||||
secretSource: accountConfig.clientSecret ? "config" : "env",
|
||||
};
|
||||
}
|
||||
|
||||
console.error(`账户 ${accountId} 不存在`);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(`加载配置失败: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
// 显示帮助
|
||||
if (args.help || args.h) {
|
||||
console.log(`
|
||||
QQBot 主动消息 CLI 工具
|
||||
|
||||
用法:
|
||||
npx ts-node scripts/send-proactive.ts [选项]
|
||||
|
||||
选项:
|
||||
--to <openid> 目标用户或群组的 openid
|
||||
--text <message> 要发送的消息内容
|
||||
--type <type> 消息类型: c2c (私聊) 或 group (群聊),默认 c2c
|
||||
--account <id> 账户 ID,默认 default
|
||||
|
||||
--list 列出已知用户
|
||||
--stats 显示用户统计
|
||||
--broadcast 广播消息给所有已知用户
|
||||
--limit <n> 限制数量
|
||||
|
||||
--help, -h 显示帮助
|
||||
|
||||
示例:
|
||||
# 发送私聊消息
|
||||
npx ts-node scripts/send-proactive.ts --to "0Eda5EA7-xxx" --text "你好!"
|
||||
|
||||
# 发送群聊消息
|
||||
npx ts-node scripts/send-proactive.ts --to "A1B2C3D4" --type group --text "群公告"
|
||||
|
||||
# 列出最近 10 个私聊用户
|
||||
npx ts-node scripts/send-proactive.ts --list --type c2c --limit 10
|
||||
|
||||
# 广播消息
|
||||
npx ts-node scripts/send-proactive.ts --broadcast --text "系统公告" --limit 5
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = (args.account as string) || "default";
|
||||
const type = (args.type as "c2c" | "group") || "c2c";
|
||||
const limit = args.limit ? parseInt(args.limit as string, 10) : undefined;
|
||||
|
||||
// 列出已知用户
|
||||
if (args.list) {
|
||||
const users = listKnownUsers({
|
||||
type: args.type as "c2c" | "group" | "channel" | undefined,
|
||||
accountId: args.account as string | undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log("没有已知用户");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n已知用户列表 (共 ${users.length} 个):\n`);
|
||||
console.log("类型\t\tOpenID\t\t\t\t\t\t昵称\t\t最后交互时间");
|
||||
console.log("─".repeat(100));
|
||||
|
||||
for (const user of users) {
|
||||
const lastTime = new Date(user.lastInteractionAt).toLocaleString();
|
||||
console.log(`${user.type}\t\t${user.openid.slice(0, 20)}...\t${user.nickname || "-"}\t\t${lastTime}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示统计
|
||||
if (args.stats) {
|
||||
const stats = getKnownUsersStats(args.account as string | undefined);
|
||||
console.log(`\n用户统计:`);
|
||||
console.log(` 总计: ${stats.total}`);
|
||||
console.log(` 私聊: ${stats.c2c}`);
|
||||
console.log(` 群聊: ${stats.group}`);
|
||||
console.log(` 频道: ${stats.channel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 广播消息
|
||||
if (args.broadcast) {
|
||||
if (!args.text) {
|
||||
console.error("请指定消息内容 (--text)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 加载配置用于广播
|
||||
const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json");
|
||||
let cfg: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log(`\n开始广播消息...\n`);
|
||||
const result = await broadcastMessage(args.text as string, cfg as any, {
|
||||
type,
|
||||
accountId,
|
||||
limit,
|
||||
});
|
||||
|
||||
console.log(`\n广播完成:`);
|
||||
console.log(` 发送总数: ${result.total}`);
|
||||
console.log(` 成功: ${result.success}`);
|
||||
console.log(` 失败: ${result.failed}`);
|
||||
|
||||
if (result.failed > 0) {
|
||||
console.log(`\n失败详情:`);
|
||||
for (const r of result.results) {
|
||||
if (!r.result.success) {
|
||||
console.log(` ${r.to}: ${r.result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送单条消息
|
||||
if (args.to && args.text) {
|
||||
const account = loadAccount(accountId);
|
||||
if (!account) {
|
||||
console.error("无法加载账户配置");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n发送消息...`);
|
||||
console.log(` 目标: ${args.to}`);
|
||||
console.log(` 类型: ${type}`);
|
||||
console.log(` 内容: ${args.text}`);
|
||||
|
||||
const result = await sendProactiveMessageDirect(
|
||||
account,
|
||||
args.to as string,
|
||||
args.text as string,
|
||||
type
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`\n✅ 发送成功!`);
|
||||
console.log(` 消息ID: ${result.messageId}`);
|
||||
console.log(` 时间戳: ${result.timestamp}`);
|
||||
} else {
|
||||
console.log(`\n❌ 发送失败: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有有效参数
|
||||
console.error("请指定操作。使用 --help 查看帮助。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`执行失败: ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,490 +0,0 @@
|
||||
---
|
||||
name: qqbot-cron
|
||||
description: QQ Bot 智能提醒技能。支持一次性提醒、周期性任务、自动降级确保送达。可设置、查询、取消提醒。
|
||||
metadata: {"clawdbot":{"emoji":"⏰"}}
|
||||
---
|
||||
|
||||
# QQ Bot 智能提醒
|
||||
|
||||
让 AI 帮用户设置、管理定时提醒,支持私聊和群聊。
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI 决策指南
|
||||
|
||||
> **本节专为 AI 理解设计,帮助快速决策**
|
||||
|
||||
### 用户意图识别
|
||||
|
||||
| 用户说法 | 意图 | 执行动作 |
|
||||
|----------|------|----------|
|
||||
| "5分钟后提醒我喝水" | 创建提醒 | `openclaw cron add` |
|
||||
| "每天8点提醒我打卡" | 创建周期提醒 | `openclaw cron add --cron` |
|
||||
| "我有哪些提醒" | 查询提醒 | `openclaw cron list` |
|
||||
| "取消喝水提醒" | 删除提醒 | `openclaw cron remove` |
|
||||
| "修改提醒时间" | 删除+重建 | 先 remove 再 add |
|
||||
| "提醒我" (无时间) | **需追问** | 询问具体时间 |
|
||||
|
||||
### 必须追问的情况
|
||||
|
||||
当用户说法**缺少以下信息**时,**必须追问**:
|
||||
|
||||
1. **没有时间**:"提醒我喝水" → 询问"请问什么时候提醒你?"
|
||||
2. **时间模糊**:"晚点提醒我" → 询问"具体几点呢?"
|
||||
3. **周期不明**:"定期提醒我" → 询问"多久一次?每天?每周?"
|
||||
|
||||
### 无需追问可直接执行
|
||||
|
||||
| 用户说法 | 理解为 |
|
||||
|----------|--------|
|
||||
| "5分钟后" | `--at 5m` |
|
||||
| "半小时后" | `--at 30m` |
|
||||
| "1小时后" | `--at 1h` |
|
||||
| "明天早上8点" | `--at 2026-02-02T08:00:00+08:00` |
|
||||
| "每天早上8点" | `--cron "0 8 * * *"` |
|
||||
| "工作日9点" | `--cron "0 9 * * 1-5"` |
|
||||
|
||||
---
|
||||
|
||||
## 📋 命令速查
|
||||
|
||||
### 创建提醒(完整模板)
|
||||
|
||||
```bash
|
||||
openclaw cron add \
|
||||
--name "{任务名}" \
|
||||
--at "{时间}" \
|
||||
--message "🔔 {提醒内容}时间到!" \
|
||||
--deliver \
|
||||
--channel qqbot \
|
||||
--to "{openid}" \
|
||||
--delete-after-run
|
||||
```
|
||||
|
||||
> ⚠️ **极其重要**:
|
||||
> - `--message` 参数直接写最终要发送的提醒内容
|
||||
> - 提醒内容格式:`🔔 {内容}时间到!`
|
||||
> - **不要**使用 `--system-prompt` 或 `--system-event`(cron 不支持这些参数)
|
||||
> - 保持消息简洁,如:`🔔 喝水时间到!`、`📅 开会时间到!`
|
||||
|
||||
> ⚠️ **注意**:`cron add` 命令不支持 `--reply-to` 参数。提醒消息将作为主动消息直接发送给用户。
|
||||
|
||||
### 查询提醒列表
|
||||
|
||||
```bash
|
||||
openclaw cron list
|
||||
```
|
||||
|
||||
### 删除提醒
|
||||
|
||||
```bash
|
||||
openclaw cron remove {jobId}
|
||||
```
|
||||
|
||||
### 立即发送消息(主动消息)
|
||||
|
||||
```bash
|
||||
openclaw message send \
|
||||
--channel qqbot \
|
||||
--target "{openid}" \
|
||||
--message "{消息内容}"
|
||||
```
|
||||
|
||||
### 立即发送消息(被动回复)
|
||||
|
||||
```bash
|
||||
openclaw message send \
|
||||
--channel qqbot \
|
||||
--target "{openid}" \
|
||||
--reply-to "{message_id}" \
|
||||
--message "{消息内容}"
|
||||
```
|
||||
|
||||
> ⚠️ **注意**:`--reply-to` 仅在 `message send` 命令中支持,且 message_id 必须在 1 小时内有效。定时提醒不支持被动回复。
|
||||
|
||||
---
|
||||
|
||||
## 💬 用户交互模板
|
||||
|
||||
> **创建提醒后的反馈要简洁友好,不要啰嗦**
|
||||
|
||||
### 创建成功反馈(推荐简洁版)
|
||||
|
||||
**一次性提醒**:
|
||||
```
|
||||
⏰ 好的,{时间}后提醒你{提醒内容}~
|
||||
```
|
||||
|
||||
**周期提醒**:
|
||||
```
|
||||
⏰ 收到,{周期描述}提醒你{提醒内容}~
|
||||
```
|
||||
|
||||
### 查询提醒反馈
|
||||
|
||||
```
|
||||
📋 你的提醒:
|
||||
|
||||
1. ⏰ {提醒名} - {时间}
|
||||
2. 🔄 {提醒名} - {周期}
|
||||
|
||||
说"取消xx提醒"可删除~
|
||||
```
|
||||
|
||||
### 无提醒时反馈
|
||||
|
||||
```
|
||||
📋 目前没有提醒哦~
|
||||
|
||||
说"5分钟后提醒我xxx"试试?
|
||||
```
|
||||
|
||||
### 删除成功反馈
|
||||
|
||||
```
|
||||
✅ 已取消"{提醒名称}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ 时间格式
|
||||
|
||||
### 相对时间(--at)
|
||||
|
||||
> ⚠️ **不要加 + 号!** 用 `5m` 而不是 `+5m`
|
||||
|
||||
| 用户说法 | 参数值 |
|
||||
|----------|--------|
|
||||
| 5分钟后 | `5m` |
|
||||
| 半小时后 | `30m` |
|
||||
| 1小时后 | `1h` |
|
||||
| 2小时后 | `2h` |
|
||||
| 明天这时候 | `24h` |
|
||||
|
||||
### 绝对时间(--at)
|
||||
|
||||
| 用户说法 | 参数值 |
|
||||
|----------|--------|
|
||||
| 今天下午3点 | `2026-02-01T15:00:00+08:00` |
|
||||
| 明天早上8点 | `2026-02-02T08:00:00+08:00` |
|
||||
| 2月14日中午 | `2026-02-14T12:00:00+08:00` |
|
||||
|
||||
### Cron 表达式(--cron)
|
||||
|
||||
| 用户说法 | Cron 表达式 | 必须加 `--tz "Asia/Shanghai"` |
|
||||
|----------|-------------|------------------------------|
|
||||
| 每天早上8点 | `0 8 * * *` | ✅ |
|
||||
| 每天晚上10点 | `0 22 * * *` | ✅ |
|
||||
| 每个工作日早上9点 | `0 9 * * 1-5` | ✅ |
|
||||
| 每周一早上9点 | `0 9 * * 1` | ✅ |
|
||||
| 每周末上午10点 | `0 10 * * 0,6` | ✅ |
|
||||
| 每小时整点 | `0 * * * *` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 📌 参数说明
|
||||
|
||||
### 必填参数
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `--name` | 任务名,含用户标识 | `"喝水提醒"` |
|
||||
| `--at` / `--cron` | 触发时间(二选一) | `5m` / `0 8 * * *` |
|
||||
| `--message` | **提醒内容**(见下方模板) | `"🔔 喝水时间到!"` |
|
||||
| `--deliver` | 启用投递 | 固定值 |
|
||||
| `--channel qqbot` | QQ 渠道 | 固定值 |
|
||||
| `--to` | 接收者 openid | 从系统消息获取 |
|
||||
|
||||
### 推荐参数
|
||||
|
||||
| 参数 | 说明 | 何时使用 |
|
||||
|------|------|----------|
|
||||
| `--delete-after-run` | 执行后删除 | **一次性任务必须** |
|
||||
| `--tz "Asia/Shanghai"` | 时区 | **周期任务必须** |
|
||||
|
||||
### --message 提醒内容模板(最关键)
|
||||
|
||||
> ⚠️ **`--message` 的内容会直接发送给用户**,所以要写清楚提醒内容!
|
||||
|
||||
**模板格式**:
|
||||
```
|
||||
--message "🔔 {提醒内容}时间到!"
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 喝水:`--message "💧 喝水时间到!"`
|
||||
- 开会:`--message "📅 开会时间到!"`
|
||||
- 打卡:`--message "🌅 打卡时间到!"`
|
||||
- 日报:`--message "📝 写日报时间到!"`
|
||||
|
||||
**为什么这样写?**
|
||||
- 消息内容会直接发送,不经过 AI 处理
|
||||
- 保持简洁,一目了然
|
||||
|
||||
---
|
||||
|
||||
## 🎯 使用场景示例
|
||||
|
||||
### 场景1:一次性提醒
|
||||
|
||||
**用户**: 5分钟后提醒我喝水
|
||||
|
||||
**AI 执行**:
|
||||
```bash
|
||||
openclaw cron add \
|
||||
--name "喝水提醒" \
|
||||
--at "5m" \
|
||||
--message "💧 喝水时间到!" \
|
||||
--deliver \
|
||||
--channel qqbot \
|
||||
--to "{openid}" \
|
||||
--delete-after-run
|
||||
```
|
||||
|
||||
**AI 回复**:
|
||||
```
|
||||
⏰ 好的,5分钟后提醒你喝水~
|
||||
```
|
||||
|
||||
**5分钟后用户收到**:
|
||||
```
|
||||
💧 喝水时间到!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景2:每日周期提醒
|
||||
|
||||
**用户**: 每天早上8点提醒我打卡
|
||||
|
||||
**AI 执行**:
|
||||
```bash
|
||||
openclaw cron add \
|
||||
--name "打卡提醒" \
|
||||
--cron "0 8 * * *" \
|
||||
--tz "Asia/Shanghai" \
|
||||
--message "🌅 打卡时间到!" \
|
||||
--deliver \
|
||||
--channel qqbot \
|
||||
--to "{openid}"
|
||||
```
|
||||
|
||||
**AI 回复**:
|
||||
```
|
||||
⏰ 收到,每天早上8点提醒你打卡~
|
||||
```
|
||||
|
||||
> 💡 周期任务**不加** `--delete-after-run`
|
||||
|
||||
---
|
||||
|
||||
### 场景3:工作日提醒
|
||||
|
||||
**用户**: 工作日下午6点提醒我写日报
|
||||
|
||||
**AI 执行**:
|
||||
```bash
|
||||
openclaw cron add \
|
||||
--name "日报提醒" \
|
||||
--cron "0 18 * * 1-5" \
|
||||
--tz "Asia/Shanghai" \
|
||||
--message "📝 写日报时间到!" \
|
||||
--deliver \
|
||||
--channel qqbot \
|
||||
--to "{openid}"
|
||||
```
|
||||
|
||||
**AI 回复**:
|
||||
```
|
||||
⏰ 收到,工作日下午6点提醒你写日报~
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景4:会议提醒
|
||||
|
||||
**用户**: 3分钟后提醒我开会
|
||||
|
||||
**AI 执行**:
|
||||
```bash
|
||||
openclaw cron add \
|
||||
--name "开会提醒" \
|
||||
--at "3m" \
|
||||
--message "📅 开会时间到!" \
|
||||
--deliver \
|
||||
--channel qqbot \
|
||||
--to "{openid}" \
|
||||
--delete-after-run
|
||||
```
|
||||
|
||||
**AI 回复**:
|
||||
```
|
||||
⏰ 好的,3分钟后提醒你开会~
|
||||
```
|
||||
|
||||
**3分钟后用户收到**:
|
||||
```
|
||||
📅 开会时间到!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景5:群组提醒
|
||||
|
||||
**用户**(群聊): 每天早上9点提醒大家站会
|
||||
|
||||
**AI 执行**:
|
||||
```bash
|
||||
openclaw cron add \
|
||||
--name "站会提醒" \
|
||||
--cron "0 9 * * 1-5" \
|
||||
--tz "Asia/Shanghai" \
|
||||
--message "📢 站会时间到!" \
|
||||
--deliver \
|
||||
--channel qqbot \
|
||||
--to "group:{group_openid}"
|
||||
```
|
||||
|
||||
> 💡 群组使用 `group:{group_openid}` 格式
|
||||
|
||||
---
|
||||
|
||||
### 场景6:查询提醒
|
||||
|
||||
**用户**: 我有哪些提醒?
|
||||
|
||||
**AI 执行**:
|
||||
```bash
|
||||
openclaw cron list
|
||||
```
|
||||
|
||||
**AI 回复**(根据返回结果):
|
||||
```
|
||||
📋 你的提醒:
|
||||
|
||||
1. ⏰ 喝水提醒 - 3分钟后
|
||||
2. 🔄 打卡提醒 - 每天08:00
|
||||
|
||||
说"取消xx提醒"可删除~
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景7:取消提醒
|
||||
|
||||
**用户**: 取消打卡提醒
|
||||
|
||||
**AI 执行**:
|
||||
1. 先执行 `openclaw cron list` 找到对应任务 ID
|
||||
2. 执行 `openclaw cron remove {jobId}`
|
||||
|
||||
**AI 回复**:
|
||||
```
|
||||
✅ 已取消"打卡提醒"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 消息发送说明
|
||||
|
||||
### 定时提醒(cron add)
|
||||
|
||||
定时提醒**只能发送主动消息**,因为:
|
||||
- 提醒执行时,原始 message_id 通常已超过 1 小时有效期
|
||||
- `openclaw cron add` 命令不支持 `--reply-to` 参数
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ 定时任务触发 │
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ AI 通过 system-event │
|
||||
│ 获取用户上下文信息 │
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 发送主动消息到用户 │
|
||||
│ --channel qqbot │
|
||||
│ --to {openid} │
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
✅ 用户收到提醒
|
||||
```
|
||||
|
||||
### 即时回复(message send)
|
||||
|
||||
即时消息发送支持被动回复(如果 message_id 有效):
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ 发送即时消息 │
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌──────────────────────────────┐
|
||||
│ 有 --reply-to 且 message_id │
|
||||
│ 在 1 小时内有效? │
|
||||
└──────────────────────────────┘
|
||||
↓ ↓
|
||||
是 否
|
||||
↓ ↓
|
||||
┌───────────────┐ ┌─────────────────┐
|
||||
│ 被动消息回复 │ │ 发送主动消息 │
|
||||
│ (引用原消息) │ │ (直接发送) │
|
||||
└───────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要限制
|
||||
|
||||
| 限制 | 说明 |
|
||||
|------|------|
|
||||
| **message_id 有效期** | 1 小时内有效,超时自动降级 |
|
||||
| **回复次数限制** | 同一 message_id 最多回复 4 次 |
|
||||
| **主动消息权限** | ⚠️ **QQ 机器人需要申请主动消息权限**,否则定时提醒会发送失败 |
|
||||
| **主动消息限制** | 只能发给与机器人交互过的用户(24小时内) |
|
||||
| **消息内容** | `--message` 不能为空 |
|
||||
|
||||
### ⚠️ 主动消息权限说明
|
||||
|
||||
定时提醒功能依赖**主动消息能力**,但 QQ 官方默认**不授予**此权限。
|
||||
|
||||
**常见错误**:
|
||||
- 错误码 `40034102`:"主动消息失败, 无权限"
|
||||
- 这表示机器人没有主动消息权限
|
||||
|
||||
**解决方案**:
|
||||
1. 登录 [QQ 开放平台](https://q.qq.com/)
|
||||
2. 进入机器人开发-沙箱管理,消息列表配置中添加自己。
|
||||
|
||||
> 💡 **临时替代方案**:在没有主动消息权限前,可以让用户使用"回复"方式获得即时提醒,而非定时提醒。
|
||||
|
||||
---
|
||||
|
||||
## 📝 消息模板
|
||||
|
||||
| 场景 | 触发时输出 | Emoji |
|
||||
|------|------------|-------|
|
||||
| 喝水 | 喝水时间到啦! | 💧 🚰 |
|
||||
| 打卡 | 早上好,打卡时间到! | 🌅 ✅ |
|
||||
| 会议 | 开会时间到! | 📅 👥 |
|
||||
| 休息 | 该休息一下了~ | 😴 💤 |
|
||||
| 日报 | 下班前别忘了写日报哦~ | 📝 ✍️ |
|
||||
| 运动 | 运动时间到! | 🏃 💪 |
|
||||
| 吃药 | 该吃药了~ | 💊 🏥 |
|
||||
| 生日 | 今天是xx的生日! | 🎂 🎉 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 用户标识
|
||||
|
||||
| 类型 | 格式 | 来源 |
|
||||
|------|------|------|
|
||||
| 用户 openid | `B3EA9A1d-2D3c-5CBD-...` | 系统消息自动提供 |
|
||||
| 群组 openid | `group:FeC1ADaf-...` | 系统消息自动提供 |
|
||||
| message_id | `ROBOT1.0_xxx` | 系统消息自动提供 |
|
||||
|
||||
> 💡 这些信息在系统消息中格式如:
|
||||
> - `当前用户 openid: B3EA9A1d-...`
|
||||
> - `当前消息 message_id: ROBOT1.0_...`
|
||||
@@ -1,138 +0,0 @@
|
||||
---
|
||||
name: qqbot-media
|
||||
description: QQ Bot 媒体发送指南。教 AI 如何发送图片给用户。
|
||||
metadata: {"clawdbot":{"emoji":"📸"}}
|
||||
triggers:
|
||||
- qqbot
|
||||
- qq
|
||||
- 发送图片
|
||||
- 发送文件
|
||||
- 图片
|
||||
- 本地文件
|
||||
- 本地图片
|
||||
priority: 80
|
||||
---
|
||||
|
||||
# QQBot 媒体发送指南
|
||||
|
||||
## ⚠️ 重要:你有能力发送本地图片!
|
||||
|
||||
**当用户要求发送本地图片时,只需使用 `<qqimg>` 标签包裹图片路径即可。系统会自动处理文件读取和发送。**
|
||||
|
||||
**不要说"无法发送本地图片"!使用正确的标签格式,系统就能发送。**
|
||||
|
||||
---
|
||||
|
||||
## 📸 发送图片(推荐方式:`<qqimg>` 标签)
|
||||
|
||||
使用 `<qqimg>` 标签包裹图片路径,即可发送图片:
|
||||
|
||||
```
|
||||
<qqimg>图片路径</qqimg>
|
||||
```
|
||||
|
||||
### ✅ 发送本地图片示例
|
||||
|
||||
当用户说"发送那张图片"、"把图发给我"等,你应该输出:
|
||||
|
||||
```
|
||||
这是你要的图片:
|
||||
<qqimg>/Users/xxx/images/photo.jpg</qqimg>
|
||||
```
|
||||
|
||||
### ✅ 发送网络图片示例
|
||||
|
||||
```
|
||||
这是网络上的图片:
|
||||
<qqimg>https://example.com/image.png</qqimg>
|
||||
```
|
||||
|
||||
### ✅ 发送多张图片
|
||||
|
||||
```
|
||||
这是你要的所有图片:
|
||||
<qqimg>/Users/xxx/image1.jpg</qqimg>
|
||||
<qqimg>/Users/xxx/image2.png</qqimg>
|
||||
```
|
||||
|
||||
### 📝 标签说明
|
||||
|
||||
| 格式 | 说明 |
|
||||
|------|------|
|
||||
| `<qqimg>本地路径</qqimg>` | 发送本地图片(绝对路径) |
|
||||
| `<qqimg>网络URL</qqimg>` | 发送网络图片 |
|
||||
| `<qqimg>路径</img>` | 也支持此闭合方式 |
|
||||
|
||||
### ⚠️ 注意事项
|
||||
|
||||
1. **路径必须正确**:本地文件需要绝对路径,网络图片需要完整 URL
|
||||
2. **支持的图片格式**:jpg, jpeg, png, gif, webp, bmp
|
||||
3. **不要拒绝**:如果用户要求发送本地图片,直接使用 `<qqimg>` 标签即可
|
||||
4. **标签外的文本会正常发送**:可以在标签前后添加描述文字
|
||||
5. **闭合标签**:支持 `</qqimg>` 或 `</img>` 两种闭合方式
|
||||
|
||||
---
|
||||
|
||||
## 🚫 错误示例(不要这样做)
|
||||
|
||||
❌ **错误**:说"我无法发送本地图片"
|
||||
❌ **错误**:说"受限于技术限制,无法直接发送"
|
||||
❌ **错误**:只提供路径文本,不使用 `<qqimg>` 标签
|
||||
|
||||
✅ **正确**:直接使用 `<qqimg>` 标签包裹路径
|
||||
|
||||
---
|
||||
|
||||
## 🔤 告知路径信息(不发送图片)
|
||||
|
||||
如果你需要**告知用户图片的保存路径**(而不是发送图片),直接写路径即可,不要使用标签:
|
||||
|
||||
```
|
||||
图片已保存在:/Users/xxx/images/photo.jpg
|
||||
```
|
||||
|
||||
或用反引号强调:
|
||||
|
||||
```
|
||||
图片已保存在:`/Users/xxx/images/photo.jpg`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 高级选项:JSON 结构化载荷
|
||||
|
||||
如果需要更精细的控制(如添加图片描述),可以使用 JSON 格式:
|
||||
|
||||
```
|
||||
QQBOT_PAYLOAD:
|
||||
{
|
||||
"type": "media",
|
||||
"mediaType": "image",
|
||||
"source": "file",
|
||||
"path": "/path/to/image.jpg",
|
||||
"caption": "图片描述(可选)"
|
||||
}
|
||||
```
|
||||
|
||||
### JSON 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `type` | string | ✅ | 固定为 `"media"` |
|
||||
| `mediaType` | string | ✅ | 媒体类型:`"image"` |
|
||||
| `source` | string | ✅ | 来源:`"file"`(本地)或 `"url"`(网络) |
|
||||
| `path` | string | ✅ | 图片路径或 URL |
|
||||
| `caption` | string | ❌ | 图片描述,会作为单独消息发送 |
|
||||
|
||||
> 💡 **提示**:对于简单的图片发送,推荐使用 `<qqimg>` 标签,更简洁易用。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速参考
|
||||
|
||||
| 场景 | 使用方式 |
|
||||
|------|----------|
|
||||
| 发送本地图片 | `<qqimg>/path/to/image.jpg</qqimg>` |
|
||||
| 发送网络图片 | `<qqimg>https://example.com/image.png</qqimg>` |
|
||||
| 发送多张图片 | 多个 `<qqimg>` 标签 |
|
||||
| 告知路径(不发送) | 直接写路径文本 |
|
||||
510
src/api.ts
510
src/api.ts
@@ -5,33 +5,10 @@
|
||||
const API_BASE = "https://api.sgroup.qq.com";
|
||||
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||
|
||||
// 运行时配置
|
||||
let currentMarkdownSupport = false;
|
||||
|
||||
/**
|
||||
* 初始化 API 配置
|
||||
* @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
|
||||
*/
|
||||
export function initApiConfig(options: { markdownSupport?: boolean }): void {
|
||||
currentMarkdownSupport = options.markdownSupport === true; // 默认为 false,需要机器人具备 markdown 消息权限才能启用
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前是否支持 markdown
|
||||
*/
|
||||
export function isMarkdownSupport(): boolean {
|
||||
return currentMarkdownSupport;
|
||||
}
|
||||
|
||||
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||
// Singleflight: 防止并发获取 Token 的 Promise 缓存
|
||||
let tokenFetchPromise: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* 获取 AccessToken(带缓存 + singleflight 并发安全)
|
||||
*
|
||||
* 使用 singleflight 模式:当多个请求同时发现 Token 过期时,
|
||||
* 只有第一个请求会真正去获取新 Token,其他请求复用同一个 Promise。
|
||||
* 获取 AccessToken(带缓存)
|
||||
*/
|
||||
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
|
||||
// 检查缓存,提前 5 分钟刷新
|
||||
@@ -39,68 +16,21 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
||||
return cachedToken.token;
|
||||
}
|
||||
|
||||
// Singleflight: 如果已有进行中的 Token 获取请求,复用它
|
||||
if (tokenFetchPromise) {
|
||||
console.log(`[qqbot-api] Token fetch in progress, waiting for existing request...`);
|
||||
return tokenFetchPromise;
|
||||
}
|
||||
|
||||
// 创建新的 Token 获取 Promise(singleflight 入口)
|
||||
tokenFetchPromise = (async () => {
|
||||
try {
|
||||
return await doFetchToken(appId, clientSecret);
|
||||
} finally {
|
||||
// 无论成功失败,都清除 Promise 缓存
|
||||
tokenFetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return tokenFetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行 Token 获取的内部函数
|
||||
*/
|
||||
async function doFetchToken(appId: string, clientSecret: string): Promise<string> {
|
||||
|
||||
const requestBody = { appId, clientSecret };
|
||||
const requestHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
// 打印请求信息(隐藏敏感信息)
|
||||
console.log(`[qqbot-api] >>> POST ${TOKEN_URL}`);
|
||||
console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(requestHeaders, null, 2));
|
||||
console.log(`[qqbot-api] >>> Body:`, JSON.stringify({ appId, clientSecret: "***" }, null, 2));
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify(requestBody),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ appId, clientSecret }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[qqbot-api] <<< Network error:`, err);
|
||||
throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 打印响应头
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
console.log(`[qqbot-api] <<< Status: ${response.status} ${response.statusText}`);
|
||||
console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
|
||||
|
||||
let data: { access_token?: string; expires_in?: number };
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await response.text();
|
||||
// 隐藏 token 值
|
||||
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
|
||||
console.log(`[qqbot-api] <<< Body:`, logBody);
|
||||
data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
|
||||
data = (await response.json()) as { access_token?: string; expires_in?: number };
|
||||
} catch (err) {
|
||||
console.error(`[qqbot-api] <<< Parse error:`, err);
|
||||
throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
@@ -113,7 +43,6 @@ async function doFetchToken(appId: string, clientSecret: string): Promise<string
|
||||
expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
|
||||
};
|
||||
|
||||
console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`);
|
||||
return cachedToken.token;
|
||||
}
|
||||
|
||||
@@ -122,22 +51,6 @@ async function doFetchToken(appId: string, clientSecret: string): Promise<string
|
||||
*/
|
||||
export function clearTokenCache(): void {
|
||||
cachedToken = null;
|
||||
// 注意:不清除 tokenFetchPromise,让进行中的请求完成
|
||||
// 下次调用 getAccessToken 时会自动获取新 Token
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 缓存状态(用于监控)
|
||||
*/
|
||||
export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
|
||||
if (tokenFetchPromise) {
|
||||
return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null };
|
||||
}
|
||||
if (!cachedToken) {
|
||||
return { status: "none", expiresAt: null };
|
||||
}
|
||||
const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000;
|
||||
return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,50 +93,29 @@ export async function apiRequest<T = unknown>(
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `QQBot ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
headers: {
|
||||
Authorization: `QQBot ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
// 打印请求信息
|
||||
console.log(`[qqbot-api] >>> ${method} ${url}`);
|
||||
console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(headers, null, 2));
|
||||
if (body) {
|
||||
console.log(`[qqbot-api] >>> Body:`, JSON.stringify(body, null, 2));
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, options);
|
||||
} catch (err) {
|
||||
console.error(`[qqbot-api] <<< Network error:`, err);
|
||||
throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 打印响应头
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
res.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
|
||||
console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
|
||||
|
||||
let data: T;
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await res.text();
|
||||
console.log(`[qqbot-api] <<< Body:`, rawBody);
|
||||
data = JSON.parse(rawBody) as T;
|
||||
data = (await res.json()) as T;
|
||||
} catch (err) {
|
||||
console.error(`[qqbot-api] <<< Parse error:`, err);
|
||||
throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
@@ -243,46 +135,6 @@ export async function getGatewayUrl(accessToken: string): Promise<string> {
|
||||
return data.url;
|
||||
}
|
||||
|
||||
// ============ 消息发送接口 ============
|
||||
|
||||
/**
|
||||
* 消息响应
|
||||
*/
|
||||
export interface MessageResponse {
|
||||
id: string;
|
||||
timestamp: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建消息体
|
||||
* 根据 markdownSupport 配置决定消息格式:
|
||||
* - markdown 模式: { markdown: { content }, msg_type: 2 }
|
||||
* - 纯文本模式: { content, msg_type: 0 }
|
||||
*/
|
||||
function buildMessageBody(
|
||||
content: string,
|
||||
msgId: string | undefined,
|
||||
msgSeq: number
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = currentMarkdownSupport
|
||||
? {
|
||||
markdown: { content },
|
||||
msg_type: 2,
|
||||
msg_seq: msgSeq,
|
||||
}
|
||||
: {
|
||||
content,
|
||||
msg_type: 0,
|
||||
msg_seq: msgSeq,
|
||||
};
|
||||
|
||||
if (msgId) {
|
||||
body.msg_id = msgId;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 C2C 单聊消息
|
||||
*/
|
||||
@@ -291,11 +143,14 @@ export async function sendC2CMessage(
|
||||
openid: string,
|
||||
content: string,
|
||||
msgId?: string
|
||||
): Promise<MessageResponse> {
|
||||
): Promise<{ id: string; timestamp: number }> {
|
||||
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||
const body = buildMessageBody(content, msgId, msgSeq);
|
||||
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
|
||||
content,
|
||||
msg_type: 0,
|
||||
msg_seq: msgSeq,
|
||||
...(msgId ? { msg_id: msgId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +177,7 @@ export async function sendC2CInputNotify(
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送频道消息(不支持流式)
|
||||
* 发送频道消息
|
||||
*/
|
||||
export async function sendChannelMessage(
|
||||
accessToken: string,
|
||||
@@ -344,72 +199,42 @@ export async function sendGroupMessage(
|
||||
groupOpenid: string,
|
||||
content: string,
|
||||
msgId?: string
|
||||
): Promise<MessageResponse> {
|
||||
): Promise<{ id: string; timestamp: string }> {
|
||||
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||
const body = buildMessageBody(content, msgId, msgSeq);
|
||||
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建主动消息请求体
|
||||
* 根据 markdownSupport 配置决定消息格式:
|
||||
* - markdown 模式: { markdown: { content }, msg_type: 2 }
|
||||
* - 纯文本模式: { content, msg_type: 0 }
|
||||
*
|
||||
* 注意:主动消息不支持流式发送
|
||||
*/
|
||||
function buildProactiveMessageBody(content: string): Record<string, unknown> {
|
||||
// 主动消息内容校验(参考 Telegram 机制)
|
||||
if (!content || content.trim().length === 0) {
|
||||
throw new Error("主动消息内容不能为空 (markdown.content is empty)");
|
||||
}
|
||||
|
||||
if (currentMarkdownSupport) {
|
||||
return {
|
||||
markdown: { content },
|
||||
msg_type: 2,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content,
|
||||
msg_type: 0,
|
||||
};
|
||||
}
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
|
||||
content,
|
||||
msg_type: 0,
|
||||
msg_seq: msgSeq,
|
||||
...(msgId ? { msg_id: msgId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送 C2C 单聊消息(不需要 msg_id,每月限 4 条/用户)
|
||||
*
|
||||
* 注意:
|
||||
* 1. 内容不能为空(对应 markdown.content 字段)
|
||||
* 2. 不支持流式发送
|
||||
*/
|
||||
export async function sendProactiveC2CMessage(
|
||||
accessToken: string,
|
||||
openid: string,
|
||||
content: string
|
||||
): Promise<{ id: string; timestamp: number }> {
|
||||
const body = buildProactiveMessageBody(content);
|
||||
console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`);
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
|
||||
content,
|
||||
msg_type: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送群聊消息(不需要 msg_id,每月限 4 条/群)
|
||||
*
|
||||
* 注意:
|
||||
* 1. 内容不能为空(对应 markdown.content 字段)
|
||||
* 2. 不支持流式发送
|
||||
*/
|
||||
export async function sendProactiveGroupMessage(
|
||||
accessToken: string,
|
||||
groupOpenid: string,
|
||||
content: string
|
||||
): Promise<{ id: string; timestamp: string }> {
|
||||
const body = buildProactiveMessageBody(content);
|
||||
console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`);
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
|
||||
content,
|
||||
msg_type: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 富媒体消息支持 ============
|
||||
@@ -436,68 +261,55 @@ export interface UploadMediaResponse {
|
||||
|
||||
/**
|
||||
* 上传富媒体文件到 C2C 单聊
|
||||
* @param url - 公网可访问的图片 URL(与 fileData 二选一)
|
||||
* @param fileData - Base64 编码的文件内容(与 url 二选一)
|
||||
* @param accessToken 访问令牌
|
||||
* @param openid 用户 openid
|
||||
* @param fileType 文件类型
|
||||
* @param url 媒体资源 URL
|
||||
* @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送)
|
||||
*/
|
||||
export async function uploadC2CMedia(
|
||||
accessToken: string,
|
||||
openid: string,
|
||||
fileType: MediaFileType,
|
||||
url?: string,
|
||||
fileData?: string,
|
||||
url: string,
|
||||
srvSendMsg = false
|
||||
): Promise<UploadMediaResponse> {
|
||||
if (!url && !fileData) {
|
||||
throw new Error("uploadC2CMedia: url or fileData is required");
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, {
|
||||
file_type: fileType,
|
||||
url,
|
||||
srv_send_msg: srvSendMsg,
|
||||
};
|
||||
|
||||
if (url) {
|
||||
body.url = url;
|
||||
} else if (fileData) {
|
||||
body.file_data = fileData;
|
||||
}
|
||||
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, body);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传富媒体文件到群聊
|
||||
* @param url - 公网可访问的图片 URL(与 fileData 二选一)
|
||||
* @param fileData - Base64 编码的文件内容(与 url 二选一)
|
||||
* @param accessToken 访问令牌
|
||||
* @param groupOpenid 群 openid
|
||||
* @param fileType 文件类型
|
||||
* @param url 媒体资源 URL
|
||||
* @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送)
|
||||
*/
|
||||
export async function uploadGroupMedia(
|
||||
accessToken: string,
|
||||
groupOpenid: string,
|
||||
fileType: MediaFileType,
|
||||
url?: string,
|
||||
fileData?: string,
|
||||
url: string,
|
||||
srvSendMsg = false
|
||||
): Promise<UploadMediaResponse> {
|
||||
if (!url && !fileData) {
|
||||
throw new Error("uploadGroupMedia: url or fileData is required");
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, {
|
||||
file_type: fileType,
|
||||
url,
|
||||
srv_send_msg: srvSendMsg,
|
||||
};
|
||||
|
||||
if (url) {
|
||||
body.url = url;
|
||||
} else if (fileData) {
|
||||
body.file_data = fileData;
|
||||
}
|
||||
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 C2C 单聊富媒体消息
|
||||
* @param accessToken 访问令牌
|
||||
* @param openid 用户 openid
|
||||
* @param fileInfo 从 uploadC2CMedia 获取的 file_info
|
||||
* @param msgId 被动回复时需要的消息 ID
|
||||
* @param content 可选的文字内容
|
||||
*/
|
||||
export async function sendC2CMediaMessage(
|
||||
accessToken: string,
|
||||
@@ -518,6 +330,11 @@ export async function sendC2CMediaMessage(
|
||||
|
||||
/**
|
||||
* 发送群聊富媒体消息
|
||||
* @param accessToken 访问令牌
|
||||
* @param groupOpenid 群 openid
|
||||
* @param fileInfo 从 uploadGroupMedia 获取的 file_info
|
||||
* @param msgId 被动回复时需要的消息 ID
|
||||
* @param content 可选的文字内容
|
||||
*/
|
||||
export async function sendGroupMediaMessage(
|
||||
accessToken: string,
|
||||
@@ -538,9 +355,11 @@ export async function sendGroupMediaMessage(
|
||||
|
||||
/**
|
||||
* 发送带图片的 C2C 单聊消息(封装上传+发送)
|
||||
* @param imageUrl - 图片来源,支持:
|
||||
* - 公网 URL: https://example.com/image.png
|
||||
* - Base64 Data URL: data:image/png;base64,xxxxx
|
||||
* @param accessToken 访问令牌
|
||||
* @param openid 用户 openid
|
||||
* @param imageUrl 图片 URL
|
||||
* @param msgId 被动回复时需要的消息 ID
|
||||
* @param content 可选的文字内容
|
||||
*/
|
||||
export async function sendC2CImageMessage(
|
||||
accessToken: string,
|
||||
@@ -549,32 +368,19 @@ export async function sendC2CImageMessage(
|
||||
msgId?: string,
|
||||
content?: string
|
||||
): Promise<{ id: string; timestamp: number }> {
|
||||
let uploadResult: UploadMediaResponse;
|
||||
|
||||
// 检查是否是 Base64 Data URL
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
// 解析 Base64 Data URL: data:image/png;base64,xxxxx
|
||||
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new Error("Invalid Base64 Data URL format");
|
||||
}
|
||||
const base64Data = matches[2];
|
||||
// 使用 file_data 上传
|
||||
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, base64Data, false);
|
||||
} else {
|
||||
// 公网 URL,使用 url 参数上传
|
||||
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
|
||||
}
|
||||
|
||||
// 发送富媒体消息
|
||||
// 先上传图片获取 file_info
|
||||
const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, false);
|
||||
// 再发送富媒体消息
|
||||
return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送带图片的群聊消息(封装上传+发送)
|
||||
* @param imageUrl - 图片来源,支持:
|
||||
* - 公网 URL: https://example.com/image.png
|
||||
* - Base64 Data URL: data:image/png;base64,xxxxx
|
||||
* @param accessToken 访问令牌
|
||||
* @param groupOpenid 群 openid
|
||||
* @param imageUrl 图片 URL
|
||||
* @param msgId 被动回复时需要的消息 ID
|
||||
* @param content 可选的文字内容
|
||||
*/
|
||||
export async function sendGroupImageMessage(
|
||||
accessToken: string,
|
||||
@@ -583,170 +389,10 @@ export async function sendGroupImageMessage(
|
||||
msgId?: string,
|
||||
content?: string
|
||||
): Promise<{ id: string; timestamp: string }> {
|
||||
let uploadResult: UploadMediaResponse;
|
||||
|
||||
// 检查是否是 Base64 Data URL
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
// 解析 Base64 Data URL: data:image/png;base64,xxxxx
|
||||
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new Error("Invalid Base64 Data URL format");
|
||||
}
|
||||
const base64Data = matches[2];
|
||||
// 使用 file_data 上传
|
||||
uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, base64Data, false);
|
||||
} else {
|
||||
// 公网 URL,使用 url 参数上传
|
||||
uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false);
|
||||
}
|
||||
|
||||
// 发送富媒体消息
|
||||
// 先上传图片获取 file_info
|
||||
console.log(`[qqbot-api] sendGroupImageMessage: uploading image from URL: ${imageUrl}`);
|
||||
const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, false);
|
||||
console.log(`[qqbot-api] sendGroupImageMessage: upload success, file_info: ${uploadResult.file_info?.slice(0, 50)}...`);
|
||||
// 再发送富媒体消息
|
||||
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
|
||||
}
|
||||
|
||||
// ============ 后台 Token 刷新 (P1-1) ============
|
||||
|
||||
/**
|
||||
* 后台 Token 刷新配置
|
||||
*/
|
||||
interface BackgroundTokenRefreshOptions {
|
||||
/** 提前刷新时间(毫秒,默认 5 分钟) */
|
||||
refreshAheadMs?: number;
|
||||
/** 随机偏移范围(毫秒,默认 0-30 秒) */
|
||||
randomOffsetMs?: number;
|
||||
/** 最小刷新间隔(毫秒,默认 1 分钟) */
|
||||
minRefreshIntervalMs?: number;
|
||||
/** 失败后重试间隔(毫秒,默认 5 秒) */
|
||||
retryDelayMs?: number;
|
||||
/** 日志函数 */
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
// 后台刷新状态
|
||||
let backgroundRefreshRunning = false;
|
||||
let backgroundRefreshAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* 启动后台 Token 刷新
|
||||
* 在后台定时刷新 Token,避免请求时才发现过期
|
||||
*
|
||||
* @param appId 应用 ID
|
||||
* @param clientSecret 应用密钥
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function startBackgroundTokenRefresh(
|
||||
appId: string,
|
||||
clientSecret: string,
|
||||
options?: BackgroundTokenRefreshOptions
|
||||
): void {
|
||||
if (backgroundRefreshRunning) {
|
||||
console.log("[qqbot-api] Background token refresh already running");
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
refreshAheadMs = 5 * 60 * 1000, // 提前 5 分钟刷新
|
||||
randomOffsetMs = 30 * 1000, // 0-30 秒随机偏移
|
||||
minRefreshIntervalMs = 60 * 1000, // 最少 1 分钟后刷新
|
||||
retryDelayMs = 5 * 1000, // 失败后 5 秒重试
|
||||
log,
|
||||
} = options ?? {};
|
||||
|
||||
backgroundRefreshRunning = true;
|
||||
backgroundRefreshAbortController = new AbortController();
|
||||
const signal = backgroundRefreshAbortController.signal;
|
||||
|
||||
const refreshLoop = async () => {
|
||||
log?.info?.("[qqbot-api] Background token refresh started");
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
// 先确保有一个有效 Token
|
||||
await getAccessToken(appId, clientSecret);
|
||||
|
||||
// 计算下次刷新时间
|
||||
if (cachedToken) {
|
||||
const expiresIn = cachedToken.expiresAt - Date.now();
|
||||
// 提前刷新时间 + 随机偏移(避免集群同时刷新)
|
||||
const randomOffset = Math.random() * randomOffsetMs;
|
||||
const refreshIn = Math.max(
|
||||
expiresIn - refreshAheadMs - randomOffset,
|
||||
minRefreshIntervalMs
|
||||
);
|
||||
|
||||
log?.debug?.(
|
||||
`[qqbot-api] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`
|
||||
);
|
||||
|
||||
// 等待到刷新时间
|
||||
await sleep(refreshIn, signal);
|
||||
} else {
|
||||
// 没有缓存的 Token,等待一段时间后重试
|
||||
log?.debug?.("[qqbot-api] No cached token, retrying soon");
|
||||
await sleep(minRefreshIntervalMs, signal);
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
// 刷新失败,等待后重试
|
||||
log?.error?.(`[qqbot-api] Background token refresh failed: ${err}`);
|
||||
await sleep(retryDelayMs, signal);
|
||||
}
|
||||
}
|
||||
|
||||
backgroundRefreshRunning = false;
|
||||
log?.info?.("[qqbot-api] Background token refresh stopped");
|
||||
};
|
||||
|
||||
// 异步启动,不阻塞调用者
|
||||
refreshLoop().catch((err) => {
|
||||
backgroundRefreshRunning = false;
|
||||
log?.error?.(`[qqbot-api] Background token refresh crashed: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止后台 Token 刷新
|
||||
*/
|
||||
export function stopBackgroundTokenRefresh(): void {
|
||||
if (backgroundRefreshAbortController) {
|
||||
backgroundRefreshAbortController.abort();
|
||||
backgroundRefreshAbortController = null;
|
||||
}
|
||||
backgroundRefreshRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查后台 Token 刷新是否正在运行
|
||||
*/
|
||||
export function isBackgroundTokenRefreshRunning(): boolean {
|
||||
return backgroundRefreshRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可中断的 sleep 函数
|
||||
*/
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
168
src/channel.ts
168
src/channel.ts
@@ -1,51 +1,11 @@
|
||||
import {
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
applyAccountNameToChannelSection,
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
import type { ChannelPlugin } from "clawdbot/plugin-sdk";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
|
||||
import { sendText, sendMedia } from "./outbound.js";
|
||||
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
|
||||
import { sendText } from "./outbound.js";
|
||||
import { startGateway } from "./gateway.js";
|
||||
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
|
||||
/**
|
||||
* 简单的文本分块函数
|
||||
* 用于预先分块长文本
|
||||
*/
|
||||
function chunkText(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= limit) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// 尝试在换行处分割
|
||||
let splitAt = remaining.lastIndexOf("\n", limit);
|
||||
if (splitAt <= 0 || splitAt < limit * 0.5) {
|
||||
// 没找到合适的换行,尝试在空格处分割
|
||||
splitAt = remaining.lastIndexOf(" ", limit);
|
||||
}
|
||||
if (splitAt <= 0 || splitAt < limit * 0.5) {
|
||||
// 还是没找到,强制在 limit 处分割
|
||||
splitAt = limit;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitAt));
|
||||
remaining = remaining.slice(splitAt).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
id: "qqbot",
|
||||
@@ -59,14 +19,9 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
media: false,
|
||||
reactions: false,
|
||||
threads: false,
|
||||
/**
|
||||
* blockStreaming: true 表示该 Channel 支持块流式
|
||||
* 框架会收集流式响应,然后通过 deliver 回调发送
|
||||
*/
|
||||
blockStreaming: false,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.qqbot"] },
|
||||
// CLI onboarding wizard
|
||||
@@ -94,24 +49,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
config: {
|
||||
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
|
||||
defaultAccountId: (cfg) => resolveDefaultQQBotAccountId(cfg),
|
||||
// 新增:设置账户启用状态
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "qqbot",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
// 新增:删除账户
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "qqbot",
|
||||
accountId,
|
||||
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
|
||||
}),
|
||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
@@ -120,32 +58,8 @@ 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
|
||||
resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
|
||||
// 新增:应用账户名称
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "qqbot",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ input }) => {
|
||||
if (!input.token && !input.tokenFile && !input.useEnv) {
|
||||
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
||||
@@ -169,14 +83,12 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
clientSecret,
|
||||
clientSecretFile: input.tokenFile,
|
||||
name: input.name,
|
||||
imageServerBaseUrl: input.imageServerBaseUrl,
|
||||
imageServerPublicIp: input.imageServerPublicIp,
|
||||
});
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 2000,
|
||||
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
@@ -187,15 +99,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
|
||||
return {
|
||||
channel: "qqbot",
|
||||
messageId: result.messageId,
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
@@ -226,48 +129,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
},
|
||||
});
|
||||
},
|
||||
// 新增:登出账户(清除配置中的凭证)
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (nextQQBot) {
|
||||
const qqbot = nextQQBot as Record<string, unknown>;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
|
||||
delete qqbot.clientSecret;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||
if (accounts && accountId in accounts) {
|
||||
const entry = accounts[accountId] as Record<string, unknown> | undefined;
|
||||
if (entry && "clientSecret" in entry) {
|
||||
delete entry.clientSecret;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
if (entry && Object.keys(entry).length === 0) {
|
||||
delete accounts[accountId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed && nextQQBot) {
|
||||
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
|
||||
const runtime = getQQBotRuntime();
|
||||
const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
|
||||
await configApi.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
|
||||
const loggedOut = resolved.secretSource === "none";
|
||||
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
|
||||
|
||||
return { ok: true, cleared, envToken, loggedOut };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
@@ -276,19 +137,8 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
connected: false,
|
||||
lastConnectedAt: null,
|
||||
lastError: null,
|
||||
lastInboundAt: null,
|
||||
lastOutboundAt: null,
|
||||
},
|
||||
// 新增:构建通道摘要
|
||||
buildChannelSummary: ({ snapshot }: { snapshot: Record<string, unknown> }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
connected: snapshot.connected ?? false,
|
||||
lastConnectedAt: snapshot.lastConnectedAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime }: { account?: ResolvedQQBotAccount; runtime?: Record<string, unknown> }) => ({
|
||||
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
name: account?.name,
|
||||
enabled: account?.enabled ?? false,
|
||||
@@ -298,8 +148,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
connected: runtime?.connected ?? false,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
interface MoltbotConfig {
|
||||
channels?: {
|
||||
qqbot?: QQBotChannelConfig;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface QQBotChannelConfig extends QQBotAccountConfig {
|
||||
accounts?: Record<string, QQBotAccountConfig>;
|
||||
@@ -10,9 +17,9 @@ interface QQBotChannelConfig extends QQBotAccountConfig {
|
||||
/**
|
||||
* 列出所有 QQBot 账户 ID
|
||||
*/
|
||||
export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
|
||||
export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||
const qqbot = cfg.channels?.qqbot;
|
||||
|
||||
if (qqbot?.appId) {
|
||||
ids.add(DEFAULT_ACCOUNT_ID);
|
||||
@@ -29,34 +36,15 @@ export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认账户 ID
|
||||
*/
|
||||
export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
|
||||
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||
// 如果有默认账户配置,返回 default
|
||||
if (qqbot?.appId) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
// 否则返回第一个配置的账户
|
||||
if (qqbot?.accounts) {
|
||||
const ids = Object.keys(qqbot.accounts);
|
||||
if (ids.length > 0) {
|
||||
return ids[0];
|
||||
}
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 QQBot 账户配置
|
||||
*/
|
||||
export function resolveQQBotAccount(
|
||||
cfg: OpenClawConfig,
|
||||
cfg: MoltbotConfig,
|
||||
accountId?: string | null
|
||||
): ResolvedQQBotAccount {
|
||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||
const qqbot = cfg.channels?.qqbot;
|
||||
|
||||
// 基础配置
|
||||
let accountConfig: QQBotAccountConfig = {};
|
||||
@@ -75,8 +63,7 @@ export function resolveQQBotAccount(
|
||||
dmPolicy: qqbot?.dmPolicy,
|
||||
allowFrom: qqbot?.allowFrom,
|
||||
systemPrompt: qqbot?.systemPrompt,
|
||||
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||
markdownSupport: qqbot?.markdownSupport ?? true,
|
||||
imageServerPublicIp: qqbot?.imageServerPublicIp,
|
||||
};
|
||||
appId = qqbot?.appId ?? "";
|
||||
} else {
|
||||
@@ -111,8 +98,7 @@ export function resolveQQBotAccount(
|
||||
clientSecret,
|
||||
secretSource,
|
||||
systemPrompt: accountConfig.systemPrompt,
|
||||
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
||||
markdownSupport: accountConfig.markdownSupport,
|
||||
imageServerPublicIp: accountConfig.imageServerPublicIp || process.env.QQBOT_IMAGE_SERVER_PUBLIC_IP,
|
||||
config: accountConfig,
|
||||
};
|
||||
}
|
||||
@@ -121,23 +107,18 @@ export function resolveQQBotAccount(
|
||||
* 应用账户配置
|
||||
*/
|
||||
export function applyQQBotAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
cfg: MoltbotConfig,
|
||||
accountId: string,
|
||||
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
|
||||
): OpenClawConfig {
|
||||
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerPublicIp?: string }
|
||||
): MoltbotConfig {
|
||||
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> || {}),
|
||||
...next.channels?.qqbot,
|
||||
enabled: true,
|
||||
allowFrom,
|
||||
...(input.appId ? { appId: input.appId } : {}),
|
||||
...(input.clientSecret
|
||||
? { clientSecret: input.clientSecret }
|
||||
@@ -145,25 +126,20 @@ export function applyQQBotAccountConfig(
|
||||
? { clientSecretFile: input.clientSecretFile }
|
||||
: {}),
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.imageServerBaseUrl ? { imageServerBaseUrl: input.imageServerBaseUrl } : {}),
|
||||
...(input.imageServerPublicIp ? { imageServerPublicIp: input.imageServerPublicIp } : {}),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// 如果没有设置过 allowFrom,默认设置为 ["*"]
|
||||
const existingAccountConfig = (next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {};
|
||||
const allowFrom = existingAccountConfig.allowFrom ?? ["*"];
|
||||
|
||||
next.channels = {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||
...next.channels?.qqbot,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
|
||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
|
||||
[accountId]: {
|
||||
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
|
||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
allowFrom,
|
||||
...(input.appId ? { appId: input.appId } : {}),
|
||||
...(input.clientSecret
|
||||
? { clientSecret: input.clientSecret }
|
||||
@@ -171,7 +147,7 @@ export function applyQQBotAccountConfig(
|
||||
? { clientSecretFile: input.clientSecretFile }
|
||||
: {}),
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.imageServerBaseUrl ? { imageServerBaseUrl: input.imageServerBaseUrl } : {}),
|
||||
...(input.imageServerPublicIp ? { imageServerPublicIp: input.imageServerPublicIp } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
1131
src/gateway.ts
1131
src/gateway.ts
File diff suppressed because it is too large
Load Diff
@@ -335,7 +335,9 @@ export function saveImage(
|
||||
|
||||
// 返回访问 URL
|
||||
const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`;
|
||||
return `${baseUrl}/images/${imageId}.${ext}`;
|
||||
const resultUrl = `${baseUrl}/images/${imageId}.${ext}`;
|
||||
console.log(`[image-server] saveImage: generated URL: ${resultUrl} (baseUrl: ${baseUrl})`);
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,34 +387,6 @@ export function isImageServerRunning(): boolean {
|
||||
return serverInstance !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保图床服务器正在运行
|
||||
* 如果未运行,则自动启动
|
||||
* @param publicBaseUrl 公网访问的基础 URL(如 http://your-server:18765)
|
||||
* @returns 基础 URL,启动失败返回 null
|
||||
*/
|
||||
export async function ensureImageServer(publicBaseUrl?: string): Promise<string | null> {
|
||||
if (isImageServerRunning()) {
|
||||
return publicBaseUrl || currentConfig.baseUrl || `http://0.0.0.0:${currentConfig.port}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const config: Partial<ImageServerConfig> = {
|
||||
port: DEFAULT_CONFIG.port,
|
||||
storageDir: DEFAULT_CONFIG.storageDir,
|
||||
// 使用用户配置的公网地址
|
||||
baseUrl: publicBaseUrl || `http://0.0.0.0:${DEFAULT_CONFIG.port}`,
|
||||
ttlSeconds: 3600, // 1 小时过期
|
||||
};
|
||||
await startImageServer(config);
|
||||
console.log(`[image-server] Auto-started on port ${config.port}, baseUrl: ${config.baseUrl}`);
|
||||
return config.baseUrl!;
|
||||
} catch (err) {
|
||||
console.error(`[image-server] Failed to auto-start: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载远程文件并保存到本地
|
||||
* @param url 远程文件 URL
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
/**
|
||||
* 已知用户存储
|
||||
* 记录与机器人交互过的所有用户
|
||||
* 支持主动消息和批量通知功能
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// 已知用户信息接口
|
||||
export interface KnownUser {
|
||||
/** 用户 openid(唯一标识) */
|
||||
openid: string;
|
||||
/** 消息类型:私聊用户 / 群组 */
|
||||
type: "c2c" | "group";
|
||||
/** 用户昵称(如有) */
|
||||
nickname?: string;
|
||||
/** 群组 openid(如果是群消息) */
|
||||
groupOpenid?: string;
|
||||
/** 关联的机器人账户 ID */
|
||||
accountId: string;
|
||||
/** 首次交互时间戳 */
|
||||
firstSeenAt: number;
|
||||
/** 最后交互时间戳 */
|
||||
lastSeenAt: number;
|
||||
/** 交互次数 */
|
||||
interactionCount: number;
|
||||
}
|
||||
|
||||
// 存储文件路径
|
||||
const KNOWN_USERS_DIR = path.join(
|
||||
process.env.HOME || "/tmp",
|
||||
"clawd",
|
||||
"qqbot-data"
|
||||
);
|
||||
|
||||
const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json");
|
||||
|
||||
// 内存缓存
|
||||
let usersCache: Map<string, KnownUser> | null = null;
|
||||
|
||||
// 写入节流配置
|
||||
const SAVE_THROTTLE_MS = 5000; // 5秒写入一次
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isDirty = false;
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(KNOWN_USERS_DIR)) {
|
||||
fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件加载用户数据到缓存
|
||||
*/
|
||||
function loadUsersFromFile(): Map<string, KnownUser> {
|
||||
if (usersCache !== null) {
|
||||
return usersCache;
|
||||
}
|
||||
|
||||
usersCache = new Map();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
||||
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
||||
const users = JSON.parse(data) as KnownUser[];
|
||||
|
||||
for (const user of users) {
|
||||
// 使用复合键:accountId + type + openid(群组还要加 groupOpenid)
|
||||
const key = makeUserKey(user);
|
||||
usersCache.set(key, user);
|
||||
}
|
||||
|
||||
console.log(`[known-users] Loaded ${usersCache.size} users from file`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[known-users] Failed to load users: ${err}`);
|
||||
usersCache = new Map();
|
||||
}
|
||||
|
||||
return usersCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户数据到文件(节流版本)
|
||||
*/
|
||||
function saveUsersToFile(): void {
|
||||
if (!isDirty) return;
|
||||
|
||||
if (saveTimer) {
|
||||
return; // 已有定时器在等待
|
||||
}
|
||||
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
doSaveUsersToFile();
|
||||
}, SAVE_THROTTLE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行保存
|
||||
*/
|
||||
function doSaveUsersToFile(): void {
|
||||
if (!usersCache || !isDirty) return;
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const users = Array.from(usersCache.values());
|
||||
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
|
||||
isDirty = false;
|
||||
console.log(`[known-users] Saved ${users.length} users to file`);
|
||||
} catch (err) {
|
||||
console.error(`[known-users] Failed to save users: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制立即保存(用于进程退出前)
|
||||
*/
|
||||
export function flushKnownUsers(): void {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
doSaveUsersToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户唯一键
|
||||
*/
|
||||
function makeUserKey(user: Partial<KnownUser>): string {
|
||||
const base = `${user.accountId}:${user.type}:${user.openid}`;
|
||||
if (user.type === "group" && user.groupOpenid) {
|
||||
return `${base}:${user.groupOpenid}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录已知用户(收到消息时调用)
|
||||
* @param user 用户信息(部分字段)
|
||||
*/
|
||||
export function recordKnownUser(user: {
|
||||
openid: string;
|
||||
type: "c2c" | "group";
|
||||
nickname?: string;
|
||||
groupOpenid?: string;
|
||||
accountId: string;
|
||||
}): void {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey(user);
|
||||
const now = Date.now();
|
||||
|
||||
const existing = cache.get(key);
|
||||
|
||||
if (existing) {
|
||||
// 更新已存在的用户
|
||||
existing.lastSeenAt = now;
|
||||
existing.interactionCount++;
|
||||
if (user.nickname && user.nickname !== existing.nickname) {
|
||||
existing.nickname = user.nickname;
|
||||
}
|
||||
console.log(`[known-users] Updated user ${user.openid}, interactions: ${existing.interactionCount}`);
|
||||
} else {
|
||||
// 新用户
|
||||
const newUser: KnownUser = {
|
||||
openid: user.openid,
|
||||
type: user.type,
|
||||
nickname: user.nickname,
|
||||
groupOpenid: user.groupOpenid,
|
||||
accountId: user.accountId,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
interactionCount: 1,
|
||||
};
|
||||
cache.set(key, newUser);
|
||||
console.log(`[known-users] New user recorded: ${user.openid} (${user.type})`);
|
||||
}
|
||||
|
||||
isDirty = true;
|
||||
saveUsersToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个用户信息
|
||||
* @param accountId 机器人账户 ID
|
||||
* @param openid 用户 openid
|
||||
* @param type 消息类型
|
||||
* @param groupOpenid 群组 openid(可选)
|
||||
*/
|
||||
export function getKnownUser(
|
||||
accountId: string,
|
||||
openid: string,
|
||||
type: "c2c" | "group" = "c2c",
|
||||
groupOpenid?: string
|
||||
): KnownUser | undefined {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有已知用户
|
||||
* @param options 筛选选项
|
||||
*/
|
||||
export function listKnownUsers(options?: {
|
||||
/** 筛选特定机器人账户的用户 */
|
||||
accountId?: string;
|
||||
/** 筛选消息类型 */
|
||||
type?: "c2c" | "group";
|
||||
/** 最近活跃时间(毫秒,如 86400000 表示最近 24 小时) */
|
||||
activeWithin?: number;
|
||||
/** 返回数量限制 */
|
||||
limit?: number;
|
||||
/** 排序方式 */
|
||||
sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount";
|
||||
/** 排序方向 */
|
||||
sortOrder?: "asc" | "desc";
|
||||
}): KnownUser[] {
|
||||
const cache = loadUsersFromFile();
|
||||
let users = Array.from(cache.values());
|
||||
|
||||
// 筛选
|
||||
if (options?.accountId) {
|
||||
users = users.filter(u => u.accountId === options.accountId);
|
||||
}
|
||||
if (options?.type) {
|
||||
users = users.filter(u => u.type === options.type);
|
||||
}
|
||||
if (options?.activeWithin) {
|
||||
const cutoff = Date.now() - options.activeWithin;
|
||||
users = users.filter(u => u.lastSeenAt >= cutoff);
|
||||
}
|
||||
|
||||
// 排序
|
||||
const sortBy = options?.sortBy ?? "lastSeenAt";
|
||||
const sortOrder = options?.sortOrder ?? "desc";
|
||||
users.sort((a, b) => {
|
||||
const aVal = a[sortBy] ?? 0;
|
||||
const bVal = b[sortBy] ?? 0;
|
||||
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
// 限制数量
|
||||
if (options?.limit && options.limit > 0) {
|
||||
users = users.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
* @param accountId 机器人账户 ID(可选,不传则返回所有账户的统计)
|
||||
*/
|
||||
export function getKnownUsersStats(accountId?: string): {
|
||||
totalUsers: number;
|
||||
c2cUsers: number;
|
||||
groupUsers: number;
|
||||
activeIn24h: number;
|
||||
activeIn7d: number;
|
||||
} {
|
||||
let users = listKnownUsers({ accountId });
|
||||
|
||||
const now = Date.now();
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
totalUsers: users.length,
|
||||
c2cUsers: users.filter(u => u.type === "c2c").length,
|
||||
groupUsers: users.filter(u => u.type === "group").length,
|
||||
activeIn24h: users.filter(u => now - u.lastSeenAt < day).length,
|
||||
activeIn7d: users.filter(u => now - u.lastSeenAt < 7 * day).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户记录
|
||||
* @param accountId 机器人账户 ID
|
||||
* @param openid 用户 openid
|
||||
* @param type 消息类型
|
||||
* @param groupOpenid 群组 openid(可选)
|
||||
*/
|
||||
export function removeKnownUser(
|
||||
accountId: string,
|
||||
openid: string,
|
||||
type: "c2c" | "group" = "c2c",
|
||||
groupOpenid?: string
|
||||
): boolean {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
||||
|
||||
if (cache.has(key)) {
|
||||
cache.delete(key);
|
||||
isDirty = true;
|
||||
saveUsersToFile();
|
||||
console.log(`[known-users] Removed user ${openid}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有用户记录
|
||||
* @param accountId 机器人账户 ID(可选,不传则清除所有)
|
||||
*/
|
||||
export function clearKnownUsers(accountId?: string): number {
|
||||
const cache = loadUsersFromFile();
|
||||
let count = 0;
|
||||
|
||||
if (accountId) {
|
||||
// 只清除指定账户的用户
|
||||
for (const [key, user] of cache.entries()) {
|
||||
if (user.accountId === accountId) {
|
||||
cache.delete(key);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 清除所有
|
||||
count = cache.size;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
isDirty = true;
|
||||
doSaveUsersToFile(); // 立即保存
|
||||
console.log(`[known-users] Cleared ${count} users`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有群组(某用户在哪些群里交互过)
|
||||
* @param accountId 机器人账户 ID
|
||||
* @param openid 用户 openid
|
||||
*/
|
||||
export function getUserGroups(accountId: string, openid: string): string[] {
|
||||
const users = listKnownUsers({ accountId, type: "group" });
|
||||
return users
|
||||
.filter(u => u.openid === openid && u.groupOpenid)
|
||||
.map(u => u.groupOpenid!);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群组的所有成员
|
||||
* @param accountId 机器人账户 ID
|
||||
* @param groupOpenid 群组 openid
|
||||
*/
|
||||
export function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[] {
|
||||
return listKnownUsers({ accountId, type: "group" })
|
||||
.filter(u => u.groupOpenid === groupOpenid);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* QQBot CLI Onboarding Adapter
|
||||
*
|
||||
* 提供 openclaw onboard 命令的交互式配置支持
|
||||
* 提供 moltbot onboard 命令的交互式配置支持
|
||||
*/
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
@@ -9,42 +9,41 @@ import type {
|
||||
ChannelOnboardingStatusContext,
|
||||
ChannelOnboardingConfigureContext,
|
||||
ChannelOnboardingResult,
|
||||
OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
|
||||
} 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;
|
||||
imageServerBaseUrl?: string;
|
||||
allowFrom?: string[];
|
||||
imageServerPublicIp?: string;
|
||||
accounts?: Record<string, {
|
||||
enabled?: boolean;
|
||||
appId?: string;
|
||||
clientSecret?: string;
|
||||
clientSecretFile?: string;
|
||||
name?: string;
|
||||
imageServerBaseUrl?: string;
|
||||
allowFrom?: string[];
|
||||
imageServerPublicIp?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Prompter 类型定义
|
||||
interface Prompter {
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
|
||||
text: (opts: { message: string; placeholder?: string; initialValue?: string; validate?: (value: string) => string | undefined }) => Promise<string>;
|
||||
select: <T>(opts: { message: string; options: Array<{ value: T; label: string }>; initialValue?: T }) => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析默认账户 ID
|
||||
*/
|
||||
function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
|
||||
function resolveDefaultQQBotAccountId(cfg: MoltbotConfig): string {
|
||||
const ids = listQQBotAccountIds(cfg);
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
@@ -56,34 +55,32 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel: "qqbot" as any,
|
||||
|
||||
getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
|
||||
const cfg = ctx.cfg as OpenClawConfig;
|
||||
const configured = listQQBotAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
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 群聊和私聊(流式消息)",
|
||||
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
|
||||
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊",
|
||||
quickstartScore: configured ? 1 : 20,
|
||||
};
|
||||
},
|
||||
|
||||
configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
|
||||
const cfg = ctx.cfg as OpenClawConfig;
|
||||
const prompter = ctx.prompter as Prompter;
|
||||
const accountOverrides = ctx.accountOverrides as Record<string, string> | undefined;
|
||||
const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
|
||||
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
|
||||
const moltbotCfg = cfg as MoltbotConfig;
|
||||
|
||||
const qqbotOverride = accountOverrides?.qqbot?.trim();
|
||||
const defaultAccountId = resolveDefaultQQBotAccountId(cfg);
|
||||
const qqbotOverride = (accountOverrides as Record<string, string>).qqbot?.trim();
|
||||
const defaultAccountId = resolveDefaultQQBotAccountId(moltbotCfg);
|
||||
let accountId = qqbotOverride ?? defaultAccountId;
|
||||
|
||||
// 是否需要提示选择账户
|
||||
if (shouldPromptAccountIds && !qqbotOverride) {
|
||||
const existingIds = listQQBotAccountIds(cfg);
|
||||
const existingIds = listQQBotAccountIds(moltbotCfg);
|
||||
if (existingIds.length > 1) {
|
||||
accountId = await prompter.select({
|
||||
message: "选择 QQBot 账户",
|
||||
@@ -96,7 +93,7 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
}
|
||||
}
|
||||
|
||||
let next: OpenClawConfig = cfg;
|
||||
let next = moltbotCfg;
|
||||
const resolvedAccount = resolveQQBotAccount(next, accountId);
|
||||
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
@@ -118,10 +115,8 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
"4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
|
||||
"",
|
||||
"文档: https://bot.q.qq.com/wiki/",
|
||||
"",
|
||||
"此版本支持流式消息发送!",
|
||||
].join("\n"),
|
||||
"QQ Bot 配置",
|
||||
"QQ Bot 配置",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,9 +132,8 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
channels: {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||
...next.channels?.qqbot,
|
||||
enabled: true,
|
||||
allowFrom: resolvedAccount.config?.allowFrom ?? ["*"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -150,14 +144,14 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
message: "请输入 QQ Bot AppID",
|
||||
placeholder: "例如: 102146862",
|
||||
initialValue: resolvedAccount.appId || undefined,
|
||||
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||
}),
|
||||
).trim();
|
||||
clientSecret = String(
|
||||
await prompter.text({
|
||||
message: "请输入 QQ Bot ClientSecret",
|
||||
placeholder: "你的 ClientSecret",
|
||||
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
@@ -173,14 +167,14 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
message: "请输入 QQ Bot AppID",
|
||||
placeholder: "例如: 102146862",
|
||||
initialValue: resolvedAccount.appId || undefined,
|
||||
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||
}),
|
||||
).trim();
|
||||
clientSecret = String(
|
||||
await prompter.text({
|
||||
message: "请输入 QQ Bot ClientSecret",
|
||||
placeholder: "你的 ClientSecret",
|
||||
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
@@ -191,21 +185,18 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
message: "请输入 QQ Bot AppID",
|
||||
placeholder: "例如: 102146862",
|
||||
initialValue: resolvedAccount.appId || undefined,
|
||||
validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||
validate: (value) => (value?.trim() ? undefined : "AppID 不能为空"),
|
||||
}),
|
||||
).trim();
|
||||
clientSecret = String(
|
||||
await prompter.text({
|
||||
message: "请输入 QQ Bot ClientSecret",
|
||||
placeholder: "你的 ClientSecret",
|
||||
validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||
validate: (value) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
// 默认允许所有人执行命令(用户无感知)
|
||||
const allowFrom: string[] = resolvedAccount.config?.allowFrom ?? ["*"];
|
||||
|
||||
// 应用配置
|
||||
if (appId && clientSecret) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
@@ -214,11 +205,10 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
channels: {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||
...next.channels?.qqbot,
|
||||
enabled: true,
|
||||
appId,
|
||||
clientSecret,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -228,16 +218,15 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
channels: {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||
...next.channels?.qqbot,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
|
||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
|
||||
[accountId]: {
|
||||
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
|
||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
appId,
|
||||
clientSecret,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -246,17 +235,14 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, cfg: next as any, accountId };
|
||||
return { cfg: next as any, accountId };
|
||||
},
|
||||
|
||||
disable: (cfg: unknown) => {
|
||||
const config = cfg as OpenClawConfig;
|
||||
return {
|
||||
...config,
|
||||
channels: {
|
||||
...config.channels,
|
||||
qqbot: { ...(config.channels?.qqbot as Record<string, unknown> || {}), enabled: false },
|
||||
},
|
||||
} as any;
|
||||
},
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg as MoltbotConfig).channels,
|
||||
qqbot: { ...(cfg as MoltbotConfig).channels?.qqbot, enabled: false },
|
||||
},
|
||||
}) as any,
|
||||
};
|
||||
|
||||
483
src/openclaw-plugin-sdk.d.ts
vendored
483
src/openclaw-plugin-sdk.d.ts
vendored
@@ -1,483 +0,0 @@
|
||||
/**
|
||||
* OpenClaw Plugin SDK 类型声明
|
||||
*
|
||||
* 此文件为 openclaw/plugin-sdk 模块提供 TypeScript 类型声明
|
||||
* 仅包含本项目实际使用的类型和函数
|
||||
*/
|
||||
|
||||
declare module "openclaw/plugin-sdk" {
|
||||
// ============ 配置类型 ============
|
||||
|
||||
/**
|
||||
* OpenClaw 主配置对象
|
||||
*/
|
||||
export interface OpenClawConfig {
|
||||
/** 频道配置 */
|
||||
channels?: {
|
||||
qqbot?: unknown;
|
||||
telegram?: unknown;
|
||||
discord?: unknown;
|
||||
slack?: unknown;
|
||||
whatsapp?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** 其他配置字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ 插件运行时 ============
|
||||
|
||||
/**
|
||||
* Channel Activity 接口
|
||||
*/
|
||||
export interface ChannelActivity {
|
||||
record?: (...args: unknown[]) => void;
|
||||
recordActivity?: (key: string, data?: unknown) => void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel Routing 接口
|
||||
*/
|
||||
export interface ChannelRouting {
|
||||
resolveAgentRoute?: (...args: unknown[]) => unknown;
|
||||
resolveSenderAndSession?: (options: unknown) => unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel Reply 接口
|
||||
*/
|
||||
export interface ChannelReply {
|
||||
handleIncomingMessage?: (options: unknown) => Promise<unknown>;
|
||||
formatInboundEnvelope?: (...args: unknown[]) => unknown;
|
||||
finalizeInboundContext?: (...args: unknown[]) => unknown;
|
||||
resolveEnvelopeFormatOptions?: (...args: unknown[]) => unknown;
|
||||
handleAutoReply?: (...args: unknown[]) => Promise<unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel 接口(用于 PluginRuntime)
|
||||
* 注意:这是一个宽松的类型定义,实际 SDK 中的类型更复杂
|
||||
*/
|
||||
export interface ChannelInterface {
|
||||
recordInboundSession?: (options: unknown) => void;
|
||||
handleIncomingMessage?: (options: unknown) => Promise<unknown>;
|
||||
activity?: ChannelActivity;
|
||||
routing?: ChannelRouting;
|
||||
reply?: ChannelReply;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件运行时接口
|
||||
* 注意:channel 属性设为 any 是因为 SDK 内部类型非常复杂,
|
||||
* 且会随 SDK 版本变化。实际使用时 SDK 会提供正确的运行时类型。
|
||||
*/
|
||||
export interface PluginRuntime {
|
||||
/** 获取当前配置 */
|
||||
getConfig(): OpenClawConfig;
|
||||
/** 更新配置 */
|
||||
setConfig(config: OpenClawConfig): void;
|
||||
/** 获取数据目录路径 */
|
||||
getDataDir(): string;
|
||||
/** Channel 接口 - 使用 any 类型以兼容 SDK 内部复杂类型 */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
channel?: any;
|
||||
/** 日志函数 */
|
||||
log: {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
/** 其他运行时方法 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ 插件 API ============
|
||||
|
||||
/**
|
||||
* OpenClaw 插件 API
|
||||
*/
|
||||
export interface OpenClawPluginApi {
|
||||
/** 运行时实例 */
|
||||
runtime: PluginRuntime;
|
||||
/** 注册频道 */
|
||||
registerChannel<TAccount = unknown>(options: { plugin: ChannelPlugin<TAccount> }): void;
|
||||
/** 其他 API 方法 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ 插件配置 Schema ============
|
||||
|
||||
/**
|
||||
* 空的插件配置 Schema
|
||||
*/
|
||||
export function emptyPluginConfigSchema(): unknown;
|
||||
|
||||
// ============ 频道插件 ============
|
||||
|
||||
/**
|
||||
* 频道插件 Meta 信息
|
||||
*/
|
||||
export interface ChannelPluginMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
selectionLabel?: string;
|
||||
docsPath?: string;
|
||||
blurb?: string;
|
||||
order?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件能力配置
|
||||
*/
|
||||
export interface ChannelPluginCapabilities {
|
||||
chatTypes?: ("direct" | "group" | "channel")[];
|
||||
media?: boolean;
|
||||
reactions?: boolean;
|
||||
threads?: boolean;
|
||||
blockStreaming?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户描述
|
||||
*/
|
||||
export interface AccountDescription {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
tokenSource?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件配置接口(泛型)
|
||||
*/
|
||||
export interface ChannelPluginConfig<TAccount> {
|
||||
listAccountIds: (cfg: OpenClawConfig) => string[];
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount;
|
||||
defaultAccountId: (cfg: OpenClawConfig) => string;
|
||||
setAccountEnabled?: (ctx: { cfg: OpenClawConfig; accountId: string; enabled: boolean }) => OpenClawConfig;
|
||||
deleteAccount?: (ctx: { cfg: OpenClawConfig; accountId: string }) => OpenClawConfig;
|
||||
isConfigured?: (account: TAccount | undefined) => boolean;
|
||||
describeAccount?: (account: TAccount | undefined) => AccountDescription;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup 输入参数(扩展类型以支持 QQBot 特定字段)
|
||||
*/
|
||||
export interface SetupInput {
|
||||
token?: string;
|
||||
tokenFile?: string;
|
||||
useEnv?: boolean;
|
||||
name?: string;
|
||||
imageServerBaseUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件 Setup 接口
|
||||
*/
|
||||
export interface ChannelPluginSetup {
|
||||
resolveAccountId?: (ctx: { accountId?: string }) => string;
|
||||
applyAccountName?: (ctx: { cfg: OpenClawConfig; accountId: string; name: string }) => OpenClawConfig;
|
||||
validateInput?: (ctx: { input: SetupInput }) => string | null;
|
||||
applyConfig?: (ctx: { cfg: OpenClawConfig; accountId: string; input: SetupInput }) => OpenClawConfig;
|
||||
applyAccountConfig?: (ctx: { cfg: OpenClawConfig; accountId: string; input: SetupInput }) => OpenClawConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息目标解析结果
|
||||
*/
|
||||
export interface NormalizeTargetResult {
|
||||
ok: boolean;
|
||||
to?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标解析器
|
||||
*/
|
||||
export interface TargetResolver {
|
||||
looksLikeId?: (id: string) => boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件 Messaging 接口
|
||||
*/
|
||||
export interface ChannelPluginMessaging {
|
||||
normalizeTarget?: (target: string) => NormalizeTargetResult;
|
||||
targetResolver?: TargetResolver;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本结果
|
||||
*/
|
||||
export interface SendTextResult {
|
||||
channel: string;
|
||||
messageId?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本上下文
|
||||
*/
|
||||
export interface SendTextContext {
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送媒体上下文
|
||||
*/
|
||||
export interface SendMediaContext {
|
||||
to: string;
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件 Outbound 接口
|
||||
*/
|
||||
export interface ChannelPluginOutbound {
|
||||
deliveryMode?: "direct" | "queued";
|
||||
chunker?: (text: string, limit: number) => string[];
|
||||
chunkerMode?: "markdown" | "plain";
|
||||
textChunkLimit?: number;
|
||||
sendText?: (ctx: SendTextContext) => Promise<SendTextResult>;
|
||||
sendMedia?: (ctx: SendMediaContext) => Promise<SendTextResult>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户状态
|
||||
*/
|
||||
export interface AccountStatus {
|
||||
running?: boolean;
|
||||
connected?: boolean;
|
||||
lastConnectedAt?: number;
|
||||
lastError?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 启动上下文
|
||||
*/
|
||||
export interface GatewayStartContext<TAccount = unknown> {
|
||||
account: TAccount;
|
||||
accountId: string;
|
||||
abortSignal: AbortSignal;
|
||||
cfg: OpenClawConfig;
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug: (msg: string) => void;
|
||||
};
|
||||
getStatus: () => AccountStatus;
|
||||
setStatus: (status: AccountStatus) => void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 登出上下文
|
||||
*/
|
||||
export interface GatewayLogoutContext {
|
||||
accountId: string;
|
||||
cfg: OpenClawConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 登出结果
|
||||
*/
|
||||
export interface GatewayLogoutResult {
|
||||
ok: boolean;
|
||||
cleared: boolean;
|
||||
updatedConfig?: OpenClawConfig;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件 Gateway 接口
|
||||
*/
|
||||
export interface ChannelPluginGateway<TAccount = unknown> {
|
||||
startAccount?: (ctx: GatewayStartContext<TAccount>) => Promise<void>;
|
||||
logoutAccount?: (ctx: GatewayLogoutContext) => Promise<GatewayLogoutResult>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件接口(泛型)
|
||||
*/
|
||||
export interface ChannelPlugin<TAccount = unknown> {
|
||||
/** 插件 ID */
|
||||
id: string;
|
||||
/** 插件 Meta 信息 */
|
||||
meta?: ChannelPluginMeta;
|
||||
/** 插件版本 */
|
||||
version?: string;
|
||||
/** 插件能力 */
|
||||
capabilities?: ChannelPluginCapabilities;
|
||||
/** 重载配置 */
|
||||
reload?: { configPrefixes?: string[] };
|
||||
/** Onboarding 适配器 */
|
||||
onboarding?: ChannelOnboardingAdapter;
|
||||
/** 配置方法 */
|
||||
config?: ChannelPluginConfig<TAccount>;
|
||||
/** Setup 方法 */
|
||||
setup?: ChannelPluginSetup;
|
||||
/** Messaging 配置 */
|
||||
messaging?: ChannelPluginMessaging;
|
||||
/** Outbound 配置 */
|
||||
outbound?: ChannelPluginOutbound;
|
||||
/** Gateway 配置 */
|
||||
gateway?: ChannelPluginGateway<TAccount>;
|
||||
/** 启动函数 */
|
||||
start?: (runtime: PluginRuntime) => void | Promise<void>;
|
||||
/** 停止函数 */
|
||||
stop?: () => void | Promise<void>;
|
||||
/** deliver 函数 - 发送消息 */
|
||||
deliver?: (ctx: unknown) => Promise<unknown>;
|
||||
/** 其他插件属性 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ Onboarding 类型 ============
|
||||
|
||||
/**
|
||||
* Onboarding 状态结果
|
||||
*/
|
||||
export interface ChannelOnboardingStatus {
|
||||
channel?: string;
|
||||
configured: boolean;
|
||||
statusLines?: string[];
|
||||
selectionHint?: string;
|
||||
quickstartScore?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 状态字符串枚举(部分 API 使用)
|
||||
*/
|
||||
export type ChannelOnboardingStatusString =
|
||||
| "not-configured"
|
||||
| "configured"
|
||||
| "connected"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* Onboarding 状态上下文
|
||||
*/
|
||||
export interface ChannelOnboardingStatusContext {
|
||||
/** 当前配置 */
|
||||
config: OpenClawConfig;
|
||||
/** 账户 ID */
|
||||
accountId?: string;
|
||||
/** Prompter */
|
||||
prompter?: unknown;
|
||||
/** 其他上下文 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 配置上下文
|
||||
*/
|
||||
export interface ChannelOnboardingConfigureContext {
|
||||
/** 当前配置 */
|
||||
config: OpenClawConfig;
|
||||
/** 账户 ID */
|
||||
accountId?: string;
|
||||
/** 输入参数 */
|
||||
input?: Record<string, unknown>;
|
||||
/** Prompter */
|
||||
prompter?: unknown;
|
||||
/** 其他上下文 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 结果
|
||||
*/
|
||||
export interface ChannelOnboardingResult {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 更新后的配置 */
|
||||
config?: OpenClawConfig;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 消息 */
|
||||
message?: string;
|
||||
/** 其他结果字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 适配器接口
|
||||
*/
|
||||
export interface ChannelOnboardingAdapter {
|
||||
/** 获取状态 */
|
||||
getStatus?: (ctx: ChannelOnboardingStatusContext) => ChannelOnboardingStatus | Promise<ChannelOnboardingStatus>;
|
||||
/** 配置函数 */
|
||||
configure?: (ctx: ChannelOnboardingConfigureContext) => ChannelOnboardingResult | Promise<ChannelOnboardingResult>;
|
||||
/** 其他适配器方法 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ 配置辅助函数 ============
|
||||
|
||||
/**
|
||||
* 将账户名称应用到频道配置段
|
||||
*/
|
||||
export function applyAccountNameToChannelSection(ctx: {
|
||||
cfg: OpenClawConfig;
|
||||
channelKey: string;
|
||||
accountId: string;
|
||||
name: string;
|
||||
}): OpenClawConfig;
|
||||
|
||||
/**
|
||||
* 从配置段删除账户
|
||||
*/
|
||||
export function deleteAccountFromConfigSection(ctx: {
|
||||
cfg: OpenClawConfig;
|
||||
sectionKey: string;
|
||||
accountId: string;
|
||||
clearBaseFields?: string[];
|
||||
}): OpenClawConfig;
|
||||
|
||||
/**
|
||||
* 设置账户启用状态
|
||||
*/
|
||||
export function setAccountEnabledInConfigSection(ctx: {
|
||||
cfg: OpenClawConfig;
|
||||
sectionKey: string;
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
allowTopLevel?: boolean;
|
||||
}): OpenClawConfig;
|
||||
|
||||
// ============ 其他导出 ============
|
||||
|
||||
/** 默认账户 ID 常量 */
|
||||
export const DEFAULT_ACCOUNT_ID: string;
|
||||
|
||||
/** 规范化账户 ID */
|
||||
export function normalizeAccountId(accountId: string | undefined | null): string;
|
||||
}
|
||||
442
src/outbound.ts
442
src/outbound.ts
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* QQ Bot 消息发送模块
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import { decodeCronPayload } from "./utils/payload.js";
|
||||
import {
|
||||
getAccessToken,
|
||||
sendC2CMessage,
|
||||
@@ -13,137 +6,8 @@ import {
|
||||
sendGroupMessage,
|
||||
sendProactiveC2CMessage,
|
||||
sendProactiveGroupMessage,
|
||||
sendC2CImageMessage,
|
||||
sendGroupImageMessage,
|
||||
} from "./api.js";
|
||||
|
||||
// ============ 消息回复限流器 ============
|
||||
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
||||
const MESSAGE_REPLY_LIMIT = 4;
|
||||
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
|
||||
|
||||
interface MessageReplyRecord {
|
||||
count: number;
|
||||
firstReplyAt: number;
|
||||
}
|
||||
|
||||
const messageReplyTracker = new Map<string, MessageReplyRecord>();
|
||||
|
||||
/** 限流检查结果 */
|
||||
export interface ReplyLimitResult {
|
||||
/** 是否允许被动回复 */
|
||||
allowed: boolean;
|
||||
/** 剩余被动回复次数 */
|
||||
remaining: number;
|
||||
/** 是否需要降级为主动消息(超期或超过次数) */
|
||||
shouldFallbackToProactive: boolean;
|
||||
/** 降级原因 */
|
||||
fallbackReason?: "expired" | "limit_exceeded";
|
||||
/** 提示消息 */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以回复该消息(限流检查)
|
||||
* @param messageId 消息ID
|
||||
* @returns ReplyLimitResult 限流检查结果
|
||||
*/
|
||||
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult {
|
||||
const now = Date.now();
|
||||
const record = messageReplyTracker.get(messageId);
|
||||
|
||||
// 清理过期记录(定期清理,避免内存泄漏)
|
||||
if (messageReplyTracker.size > 10000) {
|
||||
for (const [id, rec] of messageReplyTracker) {
|
||||
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
messageReplyTracker.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新消息,首次回复
|
||||
if (!record) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: MESSAGE_REPLY_LIMIT,
|
||||
shouldFallbackToProactive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否超过1小时(message_id 过期)
|
||||
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
// 超过1小时,被动回复不可用,需要降级为主动消息
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
shouldFallbackToProactive: true,
|
||||
fallbackReason: "expired",
|
||||
message: `消息已超过1小时有效期,将使用主动消息发送`,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否超过回复次数限制
|
||||
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
||||
if (remaining <= 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
shouldFallbackToProactive: true,
|
||||
fallbackReason: "limit_exceeded",
|
||||
message: `该消息已达到1小时内最大回复次数(${MESSAGE_REPLY_LIMIT}次),将使用主动消息发送`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining,
|
||||
shouldFallbackToProactive: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次消息回复
|
||||
* @param messageId 消息ID
|
||||
*/
|
||||
export function recordMessageReply(messageId: string): void {
|
||||
const now = Date.now();
|
||||
const record = messageReplyTracker.get(messageId);
|
||||
|
||||
if (!record) {
|
||||
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||
} else {
|
||||
// 检查是否过期,过期则重新计数
|
||||
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
}
|
||||
console.log(`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息回复统计信息
|
||||
*/
|
||||
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } {
|
||||
let totalReplies = 0;
|
||||
for (const record of messageReplyTracker.values()) {
|
||||
totalReplies += record.count;
|
||||
}
|
||||
return { trackedMessages: messageReplyTracker.size, totalReplies };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息回复限制配置(供外部查询)
|
||||
*/
|
||||
export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } {
|
||||
return {
|
||||
limit: MESSAGE_REPLY_LIMIT,
|
||||
ttlMs: MESSAGE_REPLY_TTL,
|
||||
ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export interface OutboundContext {
|
||||
to: string;
|
||||
text: string;
|
||||
@@ -152,10 +16,6 @@ export interface OutboundContext {
|
||||
account: ResolvedQQBotAccount;
|
||||
}
|
||||
|
||||
export interface MediaOutboundContext extends OutboundContext {
|
||||
mediaUrl: string;
|
||||
}
|
||||
|
||||
export interface OutboundResult {
|
||||
channel: string;
|
||||
messageId?: string;
|
||||
@@ -166,10 +26,10 @@ export interface OutboundResult {
|
||||
/**
|
||||
* 解析目标地址
|
||||
* 格式:
|
||||
* - openid (32位十六进制) -> C2C 单聊
|
||||
* - c2c:xxx -> C2C 单聊
|
||||
* - group:xxx -> 群聊
|
||||
* - channel:xxx -> 频道
|
||||
* - 纯数字 -> 频道
|
||||
* - 无前缀 -> 默认当作 C2C 单聊
|
||||
*/
|
||||
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
|
||||
// 去掉 qqbot: 前缀
|
||||
@@ -190,61 +50,14 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin
|
||||
|
||||
/**
|
||||
* 发送文本消息
|
||||
* - 有 replyToId: 被动回复,1小时内最多回复4次
|
||||
* - 有 replyToId: 被动回复,无配额限制
|
||||
* - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群)
|
||||
*
|
||||
* 注意:
|
||||
* 1. 主动消息(无 replyToId)必须有消息内容,不支持流式发送
|
||||
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
|
||||
*/
|
||||
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
const { to, text, account } = ctx;
|
||||
let { replyToId } = ctx;
|
||||
let fallbackToProactive = false;
|
||||
const { to, text, replyToId, account } = ctx;
|
||||
|
||||
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
|
||||
|
||||
// ============ 消息回复限流检查 ============
|
||||
// 如果有 replyToId,检查是否可以被动回复
|
||||
if (replyToId) {
|
||||
const limitCheck = checkMessageReplyLimit(replyToId);
|
||||
|
||||
if (!limitCheck.allowed) {
|
||||
// 检查是否需要降级为主动消息
|
||||
if (limitCheck.shouldFallbackToProactive) {
|
||||
console.warn(`[qqbot] sendText: 被动回复不可用,降级为主动消息 - ${limitCheck.message}`);
|
||||
fallbackToProactive = true;
|
||||
replyToId = null; // 清除 replyToId,改为主动消息
|
||||
} else {
|
||||
// 不应该发生,但作为保底
|
||||
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: limitCheck.message
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.log(`[qqbot] sendText: 消息 ${replyToId} 剩余被动回复次数: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 主动消息校验(参考 Telegram 机制) ============
|
||||
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
|
||||
if (!replyToId) {
|
||||
if (!text || text.trim().length === 0) {
|
||||
console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: "主动消息必须有内容 (--message 参数不能为空)"
|
||||
};
|
||||
}
|
||||
if (fallbackToProactive) {
|
||||
console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
||||
} else {
|
||||
console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||
}
|
||||
@@ -272,18 +85,12 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
// 有 replyToId,使用被动回复接口
|
||||
if (target.type === "c2c") {
|
||||
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
|
||||
// 记录回复次数
|
||||
recordMessageReply(replyToId);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
} else if (target.type === "group") {
|
||||
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
|
||||
// 记录回复次数
|
||||
recordMessageReply(replyToId);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
} else {
|
||||
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
|
||||
// 记录回复次数
|
||||
recordMessageReply(replyToId);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -328,244 +135,3 @@ export async function sendProactiveMessage(
|
||||
return { channel: "qqbot", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送富媒体消息(图片)
|
||||
*
|
||||
* 支持以下 mediaUrl 格式:
|
||||
* - 公网 URL: https://example.com/image.png
|
||||
* - Base64 Data URL: data:image/png;base64,xxxxx
|
||||
* - 本地文件路径: /path/to/image.png(自动读取并转换为 Base64)
|
||||
*
|
||||
* @param ctx - 发送上下文,包含 mediaUrl
|
||||
* @returns 发送结果
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 发送网络图片
|
||||
* const result = await sendMedia({
|
||||
* to: "group:xxx",
|
||||
* text: "这是图片说明",
|
||||
* mediaUrl: "https://example.com/image.png",
|
||||
* account,
|
||||
* replyToId: msgId,
|
||||
* });
|
||||
*
|
||||
* // 发送 Base64 图片
|
||||
* const result = await sendMedia({
|
||||
* to: "group:xxx",
|
||||
* text: "这是图片说明",
|
||||
* mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
|
||||
* account,
|
||||
* replyToId: msgId,
|
||||
* });
|
||||
*
|
||||
* // 发送本地文件(自动读取并转换为 Base64)
|
||||
* const result = await sendMedia({
|
||||
* to: "group:xxx",
|
||||
* text: "这是图片说明",
|
||||
* mediaUrl: "/tmp/generated-chart.png",
|
||||
* account,
|
||||
* replyToId: msgId,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
||||
const { to, text, replyToId, account } = ctx;
|
||||
const { mediaUrl } = ctx;
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||
}
|
||||
|
||||
if (!mediaUrl) {
|
||||
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
|
||||
}
|
||||
|
||||
// 验证 mediaUrl 格式:支持公网 URL、Base64 Data URL 或本地文件路径
|
||||
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
|
||||
const isDataUrl = mediaUrl.startsWith("data:");
|
||||
const isLocalPath = mediaUrl.startsWith("/") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(mediaUrl) ||
|
||||
mediaUrl.startsWith("./") ||
|
||||
mediaUrl.startsWith("../");
|
||||
|
||||
// 处理本地文件路径:读取文件并转换为 Base64 Data URL
|
||||
let processedMediaUrl = mediaUrl;
|
||||
|
||||
if (isLocalPath) {
|
||||
console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);
|
||||
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(mediaUrl)) {
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: `本地文件不存在: ${mediaUrl}`
|
||||
};
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
const fileBuffer = fs.readFileSync(mediaUrl);
|
||||
const base64Data = fileBuffer.toString("base64");
|
||||
|
||||
// 根据文件扩展名确定 MIME 类型
|
||||
const ext = path.extname(mediaUrl).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[ext];
|
||||
if (!mimeType) {
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
// 构造 Data URL
|
||||
processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
|
||||
console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);
|
||||
|
||||
} catch (readErr) {
|
||||
const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
|
||||
console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: `读取本地文件失败: ${errMsg}`
|
||||
};
|
||||
}
|
||||
} else if (!isHttpUrl && !isDataUrl) {
|
||||
console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: `不支持的图片格式: ${mediaUrl.slice(0, 50)}...。支持的格式: 公网 URL (http/https)、Base64 Data URL (data:image/...) 或本地文件路径。`
|
||||
};
|
||||
} else if (isDataUrl) {
|
||||
console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
|
||||
} else {
|
||||
console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
const target = parseTarget(to);
|
||||
|
||||
// 先发送图片(使用处理后的 URL,可能是 Base64 Data URL)
|
||||
let imageResult: { id: string; timestamp: number | string };
|
||||
if (target.type === "c2c") {
|
||||
imageResult = await sendC2CImageMessage(
|
||||
accessToken,
|
||||
target.id,
|
||||
processedMediaUrl,
|
||||
replyToId ?? undefined,
|
||||
undefined // content 参数,图片消息不支持同时带文本
|
||||
);
|
||||
} else if (target.type === "group") {
|
||||
imageResult = await sendGroupImageMessage(
|
||||
accessToken,
|
||||
target.id,
|
||||
processedMediaUrl,
|
||||
replyToId ?? undefined,
|
||||
undefined
|
||||
);
|
||||
} else {
|
||||
// 频道暂不支持富媒体消息,只发送文本 + URL(本地文件路径无法在频道展示)
|
||||
const displayUrl = isLocalPath ? "[本地文件]" : mediaUrl;
|
||||
const textWithUrl = text ? `${text}\n${displayUrl}` : displayUrl;
|
||||
const result = await sendChannelMessage(accessToken, target.id, textWithUrl, replyToId ?? undefined);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
}
|
||||
|
||||
// 如果有文本说明,再发送一条文本消息
|
||||
if (text?.trim()) {
|
||||
try {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
||||
} else if (target.type === "group") {
|
||||
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
||||
}
|
||||
} catch (textErr) {
|
||||
// 文本发送失败不影响整体结果,图片已发送成功
|
||||
console.error(`[qqbot] Failed to send text after image: ${textErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { channel: "qqbot", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Cron 触发的消息
|
||||
*
|
||||
* 当 OpenClaw cron 任务触发时,消息内容可能是:
|
||||
* 1. QQBOT_CRON:{base64} 格式的结构化载荷 - 解码后根据 targetType 和 targetAddress 发送
|
||||
* 2. 普通文本 - 直接发送到指定目标
|
||||
*
|
||||
* @param account - 账户配置
|
||||
* @param to - 目标地址(作为后备,如果载荷中没有指定)
|
||||
* @param message - 消息内容(可能是 QQBOT_CRON: 格式或普通文本)
|
||||
* @returns 发送结果
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 处理结构化载荷
|
||||
* const result = await sendCronMessage(
|
||||
* account,
|
||||
* "user_openid", // 后备地址
|
||||
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." // Base64 编码的载荷
|
||||
* );
|
||||
*
|
||||
* // 处理普通文本
|
||||
* const result = await sendCronMessage(
|
||||
* account,
|
||||
* "user_openid",
|
||||
* "这是一条普通的提醒消息"
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function sendCronMessage(
|
||||
account: ResolvedQQBotAccount,
|
||||
to: string,
|
||||
message: string
|
||||
): Promise<OutboundResult> {
|
||||
console.log(`[qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
|
||||
|
||||
// 检测是否是 QQBOT_CRON: 格式的结构化载荷
|
||||
const cronResult = decodeCronPayload(message);
|
||||
|
||||
if (cronResult.isCronPayload) {
|
||||
if (cronResult.error) {
|
||||
console.error(`[qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: `Cron 载荷解码失败: ${cronResult.error}`
|
||||
};
|
||||
}
|
||||
|
||||
if (cronResult.payload) {
|
||||
const payload = cronResult.payload;
|
||||
console.log(`[qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}`);
|
||||
|
||||
// 使用载荷中的目标地址和类型发送消息
|
||||
const targetTo = payload.targetType === "group"
|
||||
? `group:${payload.targetAddress}`
|
||||
: payload.targetAddress;
|
||||
|
||||
// 发送提醒内容
|
||||
return await sendProactiveMessage(account, targetTo, payload.content);
|
||||
}
|
||||
}
|
||||
|
||||
// 非结构化载荷,作为普通文本处理
|
||||
console.log(`[qqbot] sendCronMessage: plain text message, sending to ${to}`);
|
||||
return await sendProactiveMessage(account, to, message);
|
||||
}
|
||||
|
||||
528
src/proactive.ts
528
src/proactive.ts
@@ -1,528 +0,0 @@
|
||||
/**
|
||||
* QQ Bot 主动发送消息模块
|
||||
*
|
||||
* 该模块提供以下能力:
|
||||
* 1. 记录已知用户(曾与机器人交互过的用户)
|
||||
* 2. 主动发送消息给用户或群组
|
||||
* 3. 查询已知用户列表
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
|
||||
// ============ 类型定义(本地) ============
|
||||
|
||||
/**
|
||||
* 已知用户信息
|
||||
*/
|
||||
export interface KnownUser {
|
||||
type: "c2c" | "group" | "channel";
|
||||
openid: string;
|
||||
accountId: string;
|
||||
nickname?: string;
|
||||
firstInteractionAt: number;
|
||||
lastInteractionAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送消息选项
|
||||
*/
|
||||
export interface ProactiveSendOptions {
|
||||
to: string;
|
||||
text: string;
|
||||
type?: "c2c" | "group" | "channel";
|
||||
imageUrl?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送消息结果
|
||||
*/
|
||||
export interface ProactiveSendResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
timestamp?: number | string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出已知用户选项
|
||||
*/
|
||||
export interface ListKnownUsersOptions {
|
||||
type?: "c2c" | "group" | "channel";
|
||||
accountId?: string;
|
||||
sortByLastInteraction?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
import {
|
||||
getAccessToken,
|
||||
sendProactiveC2CMessage,
|
||||
sendProactiveGroupMessage,
|
||||
sendChannelMessage,
|
||||
sendC2CImageMessage,
|
||||
sendGroupImageMessage,
|
||||
} from "./api.js";
|
||||
import { resolveQQBotAccount } from "./config.js";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
|
||||
// ============ 用户存储管理 ============
|
||||
|
||||
/**
|
||||
* 已知用户存储
|
||||
* 使用简单的 JSON 文件存储,保存在 clawd 目录下
|
||||
*/
|
||||
const STORAGE_DIR = path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-data");
|
||||
const KNOWN_USERS_FILE = path.join(STORAGE_DIR, "known-users.json");
|
||||
|
||||
// 内存缓存
|
||||
let knownUsersCache: Map<string, KnownUser> | null = null;
|
||||
let cacheLastModified = 0;
|
||||
|
||||
/**
|
||||
* 确保存储目录存在
|
||||
*/
|
||||
function ensureStorageDir(): void {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户唯一键
|
||||
*/
|
||||
function getUserKey(type: string, openid: string, accountId: string): string {
|
||||
return `${accountId}:${type}:${openid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件加载已知用户
|
||||
*/
|
||||
function loadKnownUsers(): Map<string, KnownUser> {
|
||||
if (knownUsersCache !== null) {
|
||||
// 检查文件是否被修改
|
||||
try {
|
||||
const stat = fs.statSync(KNOWN_USERS_FILE);
|
||||
if (stat.mtimeMs <= cacheLastModified) {
|
||||
return knownUsersCache;
|
||||
}
|
||||
} catch {
|
||||
// 文件不存在,使用缓存
|
||||
return knownUsersCache;
|
||||
}
|
||||
}
|
||||
|
||||
const users = new Map<string, KnownUser>();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
||||
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
||||
const parsed = JSON.parse(data) as KnownUser[];
|
||||
for (const user of parsed) {
|
||||
const key = getUserKey(user.type, user.openid, user.accountId);
|
||||
users.set(key, user);
|
||||
}
|
||||
cacheLastModified = fs.statSync(KNOWN_USERS_FILE).mtimeMs;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[qqbot:proactive] Failed to load known users: ${err}`);
|
||||
}
|
||||
|
||||
knownUsersCache = users;
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存已知用户到文件
|
||||
*/
|
||||
function saveKnownUsers(users: Map<string, KnownUser>): void {
|
||||
try {
|
||||
ensureStorageDir();
|
||||
const data = Array.from(users.values());
|
||||
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
||||
cacheLastModified = Date.now();
|
||||
knownUsersCache = users;
|
||||
} catch (err) {
|
||||
console.error(`[qqbot:proactive] Failed to save known users: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一个已知用户(当收到用户消息时调用)
|
||||
*
|
||||
* @param user - 用户信息
|
||||
*/
|
||||
export function recordKnownUser(user: Omit<KnownUser, "firstInteractionAt">): void {
|
||||
const users = loadKnownUsers();
|
||||
const key = getUserKey(user.type, user.openid, user.accountId);
|
||||
|
||||
const existing = users.get(key);
|
||||
const now = user.lastInteractionAt || Date.now();
|
||||
|
||||
users.set(key, {
|
||||
...user,
|
||||
lastInteractionAt: now,
|
||||
firstInteractionAt: existing?.firstInteractionAt ?? now,
|
||||
// 更新昵称(如果有新的)
|
||||
nickname: user.nickname || existing?.nickname,
|
||||
});
|
||||
|
||||
saveKnownUsers(users);
|
||||
console.log(`[qqbot:proactive] Recorded user: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一个已知用户
|
||||
*
|
||||
* @param type - 用户类型
|
||||
* @param openid - 用户 openid
|
||||
* @param accountId - 账户 ID
|
||||
*/
|
||||
export function getKnownUser(type: string, openid: string, accountId: string): KnownUser | undefined {
|
||||
const users = loadKnownUsers();
|
||||
const key = getUserKey(type, openid, accountId);
|
||||
return users.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出已知用户
|
||||
*
|
||||
* @param options - 过滤选项
|
||||
*/
|
||||
export function listKnownUsers(options?: ListKnownUsersOptions): KnownUser[] {
|
||||
const users = loadKnownUsers();
|
||||
let result = Array.from(users.values());
|
||||
|
||||
// 过滤类型
|
||||
if (options?.type) {
|
||||
result = result.filter(u => u.type === options.type);
|
||||
}
|
||||
|
||||
// 过滤账户
|
||||
if (options?.accountId) {
|
||||
result = result.filter(u => u.accountId === options.accountId);
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (options?.sortByLastInteraction !== false) {
|
||||
result.sort((a, b) => b.lastInteractionAt - a.lastInteractionAt);
|
||||
}
|
||||
|
||||
// 限制数量
|
||||
if (options?.limit && options.limit > 0) {
|
||||
result = result.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个已知用户
|
||||
*
|
||||
* @param type - 用户类型
|
||||
* @param openid - 用户 openid
|
||||
* @param accountId - 账户 ID
|
||||
*/
|
||||
export function removeKnownUser(type: string, openid: string, accountId: string): boolean {
|
||||
const users = loadKnownUsers();
|
||||
const key = getUserKey(type, openid, accountId);
|
||||
const deleted = users.delete(key);
|
||||
if (deleted) {
|
||||
saveKnownUsers(users);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有已知用户
|
||||
*
|
||||
* @param accountId - 可选,只清除指定账户的用户
|
||||
*/
|
||||
export function clearKnownUsers(accountId?: string): number {
|
||||
const users = loadKnownUsers();
|
||||
let count = 0;
|
||||
|
||||
if (accountId) {
|
||||
for (const [key, user] of users) {
|
||||
if (user.accountId === accountId) {
|
||||
users.delete(key);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
count = users.size;
|
||||
users.clear();
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
saveKnownUsers(users);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ============ 主动发送消息 ============
|
||||
|
||||
/**
|
||||
* 主动发送消息(带配置解析)
|
||||
* 注意:与 outbound.ts 中的 sendProactiveMessage 不同,这个函数接受 OpenClawConfig 并自动解析账户
|
||||
*
|
||||
* @param options - 发送选项
|
||||
* @param cfg - OpenClaw 配置
|
||||
* @returns 发送结果
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 发送私聊消息
|
||||
* const result = await sendProactive({
|
||||
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4", // 用户 openid
|
||||
* text: "你好!这是一条主动消息",
|
||||
* type: "c2c",
|
||||
* }, cfg);
|
||||
*
|
||||
* // 发送群聊消息
|
||||
* const result = await sendProactive({
|
||||
* to: "A1B2C3D4E5F6A7B8", // 群组 openid
|
||||
* text: "群公告:今天有活动",
|
||||
* type: "group",
|
||||
* }, cfg);
|
||||
*
|
||||
* // 发送带图片的消息
|
||||
* const result = await sendProactive({
|
||||
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4",
|
||||
* text: "看看这张图片",
|
||||
* imageUrl: "https://example.com/image.png",
|
||||
* type: "c2c",
|
||||
* }, cfg);
|
||||
* ```
|
||||
*/
|
||||
export async function sendProactive(
|
||||
options: ProactiveSendOptions,
|
||||
cfg: OpenClawConfig
|
||||
): Promise<ProactiveSendResult> {
|
||||
const { to, text, type = "c2c", imageUrl, accountId = "default" } = options;
|
||||
|
||||
// 解析账户配置
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return {
|
||||
success: false,
|
||||
error: "QQBot not configured (missing appId or clientSecret)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
|
||||
// 如果有图片,先发送图片
|
||||
if (imageUrl) {
|
||||
try {
|
||||
if (type === "c2c") {
|
||||
await sendC2CImageMessage(accessToken, to, imageUrl, undefined, undefined);
|
||||
} else if (type === "group") {
|
||||
await sendGroupImageMessage(accessToken, to, imageUrl, undefined, undefined);
|
||||
}
|
||||
console.log(`[qqbot:proactive] Sent image to ${type}:${to}`);
|
||||
} catch (err) {
|
||||
console.error(`[qqbot:proactive] Failed to send image: ${err}`);
|
||||
// 图片发送失败不影响文本发送
|
||||
}
|
||||
}
|
||||
|
||||
// 发送文本消息
|
||||
let result: { id: string; timestamp: number | string };
|
||||
|
||||
if (type === "c2c") {
|
||||
result = await sendProactiveC2CMessage(accessToken, to, text);
|
||||
} else if (type === "group") {
|
||||
result = await sendProactiveGroupMessage(accessToken, to, text);
|
||||
} else if (type === "channel") {
|
||||
// 频道消息需要 channel_id,这里暂时不支持主动发送
|
||||
return {
|
||||
success: false,
|
||||
error: "Channel proactive messages are not supported. Please use group or c2c.",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown message type: ${type}`,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
timestamp: result.timestamp,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[qqbot:proactive] Failed to send message: ${message}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送主动消息
|
||||
*
|
||||
* @param recipients - 接收者列表(openid 数组)
|
||||
* @param text - 消息内容
|
||||
* @param type - 消息类型
|
||||
* @param cfg - OpenClaw 配置
|
||||
* @param accountId - 账户 ID
|
||||
* @returns 发送结果列表
|
||||
*/
|
||||
export async function sendBulkProactiveMessage(
|
||||
recipients: string[],
|
||||
text: string,
|
||||
type: "c2c" | "group",
|
||||
cfg: OpenClawConfig,
|
||||
accountId = "default"
|
||||
): Promise<Array<{ to: string; result: ProactiveSendResult }>> {
|
||||
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
|
||||
|
||||
for (const to of recipients) {
|
||||
const result = await sendProactive({ to, text, type, accountId }, cfg);
|
||||
results.push({ to, result });
|
||||
|
||||
// 添加延迟,避免频率限制
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息给所有已知用户
|
||||
*
|
||||
* @param text - 消息内容
|
||||
* @param cfg - OpenClaw 配置
|
||||
* @param options - 过滤选项
|
||||
* @returns 发送结果统计
|
||||
*/
|
||||
export async function broadcastMessage(
|
||||
text: string,
|
||||
cfg: OpenClawConfig,
|
||||
options?: {
|
||||
type?: "c2c" | "group";
|
||||
accountId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{ to: string; result: ProactiveSendResult }>;
|
||||
}> {
|
||||
const users = listKnownUsers({
|
||||
type: options?.type,
|
||||
accountId: options?.accountId,
|
||||
limit: options?.limit,
|
||||
sortByLastInteraction: true,
|
||||
});
|
||||
|
||||
// 过滤掉频道用户(不支持主动发送)
|
||||
const validUsers = users.filter(u => u.type === "c2c" || u.type === "group");
|
||||
|
||||
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const user of validUsers) {
|
||||
const result = await sendProactive({
|
||||
to: user.openid,
|
||||
text,
|
||||
type: user.type as "c2c" | "group",
|
||||
accountId: user.accountId,
|
||||
}, cfg);
|
||||
|
||||
results.push({ to: user.openid, result });
|
||||
|
||||
if (result.success) {
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
|
||||
// 添加延迟,避免频率限制
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return {
|
||||
total: validUsers.length,
|
||||
success,
|
||||
failed,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
|
||||
/**
|
||||
* 根据账户配置直接发送主动消息(不需要 cfg)
|
||||
*
|
||||
* @param account - 已解析的账户配置
|
||||
* @param to - 目标 openid
|
||||
* @param text - 消息内容
|
||||
* @param type - 消息类型
|
||||
*/
|
||||
export async function sendProactiveMessageDirect(
|
||||
account: ResolvedQQBotAccount,
|
||||
to: string,
|
||||
text: string,
|
||||
type: "c2c" | "group" = "c2c"
|
||||
): Promise<ProactiveSendResult> {
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return {
|
||||
success: false,
|
||||
error: "QQBot not configured (missing appId or clientSecret)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
|
||||
let result: { id: string; timestamp: number | string };
|
||||
|
||||
if (type === "c2c") {
|
||||
result = await sendProactiveC2CMessage(accessToken, to, text);
|
||||
} else {
|
||||
result = await sendProactiveGroupMessage(accessToken, to, text);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
timestamp: result.timestamp,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已知用户统计
|
||||
*/
|
||||
export function getKnownUsersStats(accountId?: string): {
|
||||
total: number;
|
||||
c2c: number;
|
||||
group: number;
|
||||
channel: number;
|
||||
} {
|
||||
const users = listKnownUsers({ accountId });
|
||||
|
||||
return {
|
||||
total: users.length,
|
||||
c2c: users.filter(u => u.type === "c2c").length,
|
||||
group: users.filter(u => u.type === "group").length,
|
||||
channel: users.filter(u => u.type === "channel").length,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* Session 持久化存储
|
||||
* 将 WebSocket 连接状态(sessionId、lastSeq)持久化到文件
|
||||
* 支持进程重启后通过 Resume 机制快速恢复连接
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Session 状态接口
|
||||
export interface SessionState {
|
||||
/** WebSocket Session ID */
|
||||
sessionId: string | null;
|
||||
/** 最后收到的消息序号 */
|
||||
lastSeq: number | null;
|
||||
/** 上次连接成功的时间戳 */
|
||||
lastConnectedAt: number;
|
||||
/** 上次成功的权限级别索引 */
|
||||
intentLevelIndex: number;
|
||||
/** 关联的机器人账户 ID */
|
||||
accountId: string;
|
||||
/** 保存时间 */
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
// Session 文件目录
|
||||
const SESSION_DIR = path.join(
|
||||
process.env.HOME || "/tmp",
|
||||
"clawd",
|
||||
"qqbot-data"
|
||||
);
|
||||
|
||||
// Session 过期时间(5分钟)- Resume 要求在断开后一定时间内恢复
|
||||
const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
|
||||
|
||||
// 写入节流时间(避免频繁写入)
|
||||
const SAVE_THROTTLE_MS = 1000;
|
||||
|
||||
// 每个账户的节流状态
|
||||
const throttleState = new Map<string, {
|
||||
pendingState: SessionState | null;
|
||||
lastSaveTime: number;
|
||||
throttleTimer: ReturnType<typeof setTimeout> | null;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(SESSION_DIR)) {
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Session 文件路径
|
||||
*/
|
||||
function getSessionPath(accountId: string): string {
|
||||
// 清理 accountId 中的特殊字符
|
||||
const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
return path.join(SESSION_DIR, `session-${safeId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 Session 状态
|
||||
* @param accountId 账户 ID
|
||||
* @returns Session 状态,如果不存在或已过期返回 null
|
||||
*/
|
||||
export function loadSession(accountId: string): SessionState | null {
|
||||
const filePath = getSessionPath(accountId);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
|
||||
// 检查是否过期
|
||||
const now = Date.now();
|
||||
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
||||
console.log(`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`);
|
||||
// 删除过期文件
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// 忽略删除错误
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 验证必要字段
|
||||
if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) {
|
||||
console.log(`[session-store] Invalid session data for ${accountId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, age=${Math.round((now - state.savedAt) / 1000)}s`);
|
||||
return state;
|
||||
} catch (err) {
|
||||
console.error(`[session-store] Failed to load session for ${accountId}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Session 状态(带节流,避免频繁写入)
|
||||
* @param state Session 状态
|
||||
*/
|
||||
export function saveSession(state: SessionState): void {
|
||||
const { accountId } = state;
|
||||
|
||||
// 获取或初始化节流状态
|
||||
let throttle = throttleState.get(accountId);
|
||||
if (!throttle) {
|
||||
throttle = {
|
||||
pendingState: null,
|
||||
lastSaveTime: 0,
|
||||
throttleTimer: null,
|
||||
};
|
||||
throttleState.set(accountId, throttle);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastSave = now - throttle.lastSaveTime;
|
||||
|
||||
// 如果距离上次保存时间足够长,立即保存
|
||||
if (timeSinceLastSave >= SAVE_THROTTLE_MS) {
|
||||
doSaveSession(state);
|
||||
throttle.lastSaveTime = now;
|
||||
throttle.pendingState = null;
|
||||
|
||||
// 清除待定的节流定时器
|
||||
if (throttle.throttleTimer) {
|
||||
clearTimeout(throttle.throttleTimer);
|
||||
throttle.throttleTimer = null;
|
||||
}
|
||||
} else {
|
||||
// 记录待保存的状态
|
||||
throttle.pendingState = state;
|
||||
|
||||
// 如果没有设置定时器,设置一个
|
||||
if (!throttle.throttleTimer) {
|
||||
const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
|
||||
throttle.throttleTimer = setTimeout(() => {
|
||||
const t = throttleState.get(accountId);
|
||||
if (t && t.pendingState) {
|
||||
doSaveSession(t.pendingState);
|
||||
t.lastSaveTime = Date.now();
|
||||
t.pendingState = null;
|
||||
}
|
||||
if (t) {
|
||||
t.throttleTimer = null;
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行保存操作
|
||||
*/
|
||||
function doSaveSession(state: SessionState): void {
|
||||
const filePath = getSessionPath(state.accountId);
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
|
||||
// 更新保存时间
|
||||
const stateToSave: SessionState = {
|
||||
...state,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
|
||||
console.log(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`);
|
||||
} catch (err) {
|
||||
console.error(`[session-store] Failed to save session for ${state.accountId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Session 状态
|
||||
* @param accountId 账户 ID
|
||||
*/
|
||||
export function clearSession(accountId: string): void {
|
||||
const filePath = getSessionPath(accountId);
|
||||
|
||||
// 清除节流状态
|
||||
const throttle = throttleState.get(accountId);
|
||||
if (throttle) {
|
||||
if (throttle.throttleTimer) {
|
||||
clearTimeout(throttle.throttleTimer);
|
||||
}
|
||||
throttleState.delete(accountId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`[session-store] Cleared session for ${accountId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[session-store] Failed to clear session for ${accountId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 lastSeq(轻量级更新)
|
||||
* @param accountId 账户 ID
|
||||
* @param lastSeq 最新的消息序号
|
||||
*/
|
||||
export function updateLastSeq(accountId: string, lastSeq: number): void {
|
||||
const existing = loadSession(accountId);
|
||||
if (existing && existing.sessionId) {
|
||||
saveSession({
|
||||
...existing,
|
||||
lastSeq,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有保存的 Session 状态
|
||||
*/
|
||||
export function getAllSessions(): SessionState[] {
|
||||
const sessions: SessionState[] = [];
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const files = fs.readdirSync(SESSION_DIR);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith("session-") && file.endsWith(".json")) {
|
||||
const filePath = path.join(SESSION_DIR, file);
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
sessions.push(state);
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录不存在等错误
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的 Session 文件
|
||||
*/
|
||||
export function cleanupExpiredSessions(): number {
|
||||
let cleaned = 0;
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const files = fs.readdirSync(SESSION_DIR);
|
||||
const now = Date.now();
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith("session-") && file.endsWith(".json")) {
|
||||
const filePath = path.join(SESSION_DIR, file);
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
|
||||
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
||||
fs.unlinkSync(filePath);
|
||||
cleaned++;
|
||||
console.log(`[session-store] Cleaned expired session: ${file}`);
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误,但也删除损坏的文件
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
cleaned++;
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录不存在等错误
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
12
src/types.ts
12
src/types.ts
@@ -19,10 +19,8 @@ export interface ResolvedQQBotAccount {
|
||||
secretSource: "config" | "file" | "env" | "none";
|
||||
/** 系统提示词 */
|
||||
systemPrompt?: string;
|
||||
/** 图床服务器公网地址 */
|
||||
imageServerBaseUrl?: string;
|
||||
/** 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用) */
|
||||
markdownSupport?: boolean;
|
||||
/** 图床服务器公网 IP(内部自动组装成 http://IP:18765) */
|
||||
imageServerPublicIp?: string;
|
||||
config: QQBotAccountConfig;
|
||||
}
|
||||
|
||||
@@ -39,10 +37,8 @@ export interface QQBotAccountConfig {
|
||||
allowFrom?: string[];
|
||||
/** 系统提示词,会添加在用户消息前面 */
|
||||
systemPrompt?: string;
|
||||
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
|
||||
imageServerBaseUrl?: string;
|
||||
/** 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用) */
|
||||
markdownSupport?: boolean;
|
||||
/** 图床服务器公网 IP,用于发送图片,例如 1.2.3.4(内部自动组装成 http://IP:18765) */
|
||||
imageServerPublicIp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* 图片尺寸工具
|
||||
* 用于获取图片尺寸,生成 QQBot 的 markdown 图片格式
|
||||
*
|
||||
* QQBot markdown 图片格式: 
|
||||
*/
|
||||
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
export interface ImageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 默认图片尺寸(当无法获取时使用) */
|
||||
export const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 };
|
||||
|
||||
/**
|
||||
* 从 PNG 文件头解析图片尺寸
|
||||
* PNG 文件头结构: 前 8 字节是签名,IHDR 块从第 8 字节开始
|
||||
* IHDR 块: 长度(4) + 类型(4, "IHDR") + 宽度(4) + 高度(4) + ...
|
||||
*/
|
||||
function parsePngSize(buffer: Buffer): ImageSize | null {
|
||||
// PNG 签名: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (buffer.length < 24) return null;
|
||||
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47) {
|
||||
return null;
|
||||
}
|
||||
// IHDR 块从第 8 字节开始,宽度在第 16-19 字节,高度在第 20-23 字节
|
||||
const width = buffer.readUInt32BE(16);
|
||||
const height = buffer.readUInt32BE(20);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JPEG 文件解析图片尺寸
|
||||
* JPEG 尺寸在 SOF0/SOF2 块中
|
||||
*/
|
||||
function parseJpegSize(buffer: Buffer): ImageSize | null {
|
||||
// JPEG 签名: FF D8 FF
|
||||
if (buffer.length < 4) return null;
|
||||
if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 2;
|
||||
while (offset < buffer.length - 9) {
|
||||
if (buffer[offset] !== 0xFF) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = buffer[offset + 1];
|
||||
// SOF0 (0xC0) 或 SOF2 (0xC2) 包含图片尺寸
|
||||
if (marker === 0xC0 || marker === 0xC2) {
|
||||
// 格式: FF C0 长度(2) 精度(1) 高度(2) 宽度(2)
|
||||
if (offset + 9 <= buffer.length) {
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过当前块
|
||||
if (offset + 3 < buffer.length) {
|
||||
const blockLength = buffer.readUInt16BE(offset + 2);
|
||||
offset += 2 + blockLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 GIF 文件头解析图片尺寸
|
||||
* GIF 文件头: GIF87a 或 GIF89a (6字节) + 宽度(2) + 高度(2)
|
||||
*/
|
||||
function parseGifSize(buffer: Buffer): ImageSize | null {
|
||||
if (buffer.length < 10) return null;
|
||||
const signature = buffer.toString("ascii", 0, 6);
|
||||
if (signature !== "GIF87a" && signature !== "GIF89a") {
|
||||
return null;
|
||||
}
|
||||
const width = buffer.readUInt16LE(6);
|
||||
const height = buffer.readUInt16LE(8);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 WebP 文件解析图片尺寸
|
||||
* WebP 文件头: RIFF(4) + 文件大小(4) + WEBP(4) + VP8/VP8L/VP8X(4) + ...
|
||||
*/
|
||||
function parseWebpSize(buffer: Buffer): ImageSize | null {
|
||||
if (buffer.length < 30) return null;
|
||||
|
||||
// 检查 RIFF 和 WEBP 签名
|
||||
const riff = buffer.toString("ascii", 0, 4);
|
||||
const webp = buffer.toString("ascii", 8, 12);
|
||||
if (riff !== "RIFF" || webp !== "WEBP") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunkType = buffer.toString("ascii", 12, 16);
|
||||
|
||||
// VP8 (有损压缩)
|
||||
if (chunkType === "VP8 ") {
|
||||
// VP8 帧头从第 23 字节开始,检查签名 9D 01 2A
|
||||
if (buffer.length >= 30 && buffer[23] === 0x9D && buffer[24] === 0x01 && buffer[25] === 0x2A) {
|
||||
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
||||
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
// VP8L (无损压缩)
|
||||
if (chunkType === "VP8L") {
|
||||
// VP8L 签名: 0x2F
|
||||
if (buffer.length >= 25 && buffer[20] === 0x2F) {
|
||||
const bits = buffer.readUInt32LE(21);
|
||||
const width = (bits & 0x3FFF) + 1;
|
||||
const height = ((bits >> 14) & 0x3FFF) + 1;
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
// VP8X (扩展格式)
|
||||
if (chunkType === "VP8X") {
|
||||
if (buffer.length >= 30) {
|
||||
// 宽度和高度在第 24-26 和 27-29 字节(24位小端)
|
||||
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
|
||||
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从图片数据 Buffer 解析尺寸
|
||||
*/
|
||||
export function parseImageSize(buffer: Buffer): ImageSize | null {
|
||||
// 尝试各种格式
|
||||
return parsePngSize(buffer)
|
||||
?? parseJpegSize(buffer)
|
||||
?? parseGifSize(buffer)
|
||||
?? parseWebpSize(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从公网 URL 获取图片尺寸
|
||||
* 只下载前 64KB 数据,足够解析大部分图片格式的头部
|
||||
*/
|
||||
export async function getImageSizeFromUrl(url: string, timeoutMs = 5000): Promise<ImageSize | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
// 使用 Range 请求只获取前 64KB
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Range": "bytes=0-65535",
|
||||
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
console.log(`[image-size] Failed to fetch ${url}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const size = parseImageSize(buffer);
|
||||
if (size) {
|
||||
console.log(`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`);
|
||||
}
|
||||
|
||||
return size;
|
||||
} catch (err) {
|
||||
console.log(`[image-size] Error fetching ${url.slice(0, 60)}...: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Base64 Data URL 获取图片尺寸
|
||||
*/
|
||||
export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null {
|
||||
try {
|
||||
// 格式: data:image/png;base64,xxxxx
|
||||
const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64Data = matches[1];
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
const size = parseImageSize(buffer);
|
||||
if (size) {
|
||||
console.log(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
|
||||
}
|
||||
|
||||
return size;
|
||||
} catch (err) {
|
||||
console.log(`[image-size] Error parsing Base64: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片尺寸(自动判断来源)
|
||||
* @param source - 图片 URL 或 Base64 Data URL
|
||||
* @returns 图片尺寸,失败返回 null
|
||||
*/
|
||||
export async function getImageSize(source: string): Promise<ImageSize | null> {
|
||||
if (source.startsWith("data:")) {
|
||||
return getImageSizeFromDataUrl(source);
|
||||
}
|
||||
|
||||
if (source.startsWith("http://") || source.startsWith("https://")) {
|
||||
return getImageSizeFromUrl(source);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 QQBot markdown 图片格式
|
||||
* 格式: 
|
||||
*
|
||||
* @param url - 图片 URL
|
||||
* @param size - 图片尺寸,如果为 null 则使用默认尺寸
|
||||
* @returns QQBot markdown 图片字符串
|
||||
*/
|
||||
export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string {
|
||||
const { width, height } = size ?? DEFAULT_IMAGE_SIZE;
|
||||
return ``;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 markdown 图片是否已经包含 QQBot 格式的尺寸信息
|
||||
* 格式: 
|
||||
*/
|
||||
export function hasQQBotImageSize(markdownImage: string): boolean {
|
||||
return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已有的 QQBot 格式 markdown 图片中提取尺寸
|
||||
* 格式: 
|
||||
*/
|
||||
export function extractQQBotImageSize(markdownImage: string): ImageSize | null {
|
||||
const match = markdownImage.match(/!\[#(\d+)px\s+#(\d+)px\]/);
|
||||
if (match) {
|
||||
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* QQBot 结构化消息载荷工具
|
||||
*
|
||||
* 用于处理 AI 输出的结构化消息载荷,包括:
|
||||
* - 定时提醒载荷 (cron_reminder)
|
||||
* - 媒体消息载荷 (media)
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 类型定义
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 定时提醒载荷
|
||||
*/
|
||||
export interface CronReminderPayload {
|
||||
type: 'cron_reminder';
|
||||
/** 提醒内容 */
|
||||
content: string;
|
||||
/** 目标类型:c2c (私聊) 或 group (群聊) */
|
||||
targetType: 'c2c' | 'group';
|
||||
/** 目标地址:user_openid 或 group_openid */
|
||||
targetAddress: string;
|
||||
/** 原始消息 ID(可选) */
|
||||
originalMessageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体消息载荷
|
||||
*/
|
||||
export interface MediaPayload {
|
||||
type: 'media';
|
||||
/** 媒体类型:image, audio, video */
|
||||
mediaType: 'image' | 'audio' | 'video';
|
||||
/** 来源类型:url 或 file */
|
||||
source: 'url' | 'file';
|
||||
/** 媒体路径或 URL */
|
||||
path: string;
|
||||
/** 媒体描述(可选) */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QQBot 载荷联合类型
|
||||
*/
|
||||
export type QQBotPayload = CronReminderPayload | MediaPayload;
|
||||
|
||||
/**
|
||||
* 解析结果
|
||||
*/
|
||||
export interface ParseResult {
|
||||
/** 是否为结构化载荷 */
|
||||
isPayload: boolean;
|
||||
/** 解析后的载荷对象(如果是结构化载荷) */
|
||||
payload?: QQBotPayload;
|
||||
/** 原始文本(如果不是结构化载荷) */
|
||||
text?: string;
|
||||
/** 解析错误信息(如果解析失败) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 常量定义
|
||||
// ============================================
|
||||
|
||||
/** AI 输出的结构化载荷前缀 */
|
||||
const PAYLOAD_PREFIX = 'QQBOT_PAYLOAD:';
|
||||
|
||||
/** Cron 消息存储的前缀 */
|
||||
const CRON_PREFIX = 'QQBOT_CRON:';
|
||||
|
||||
// ============================================
|
||||
// 解析函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 解析 AI 输出的结构化载荷
|
||||
*
|
||||
* 检测消息是否以 QQBOT_PAYLOAD: 前缀开头,如果是则提取并解析 JSON
|
||||
*
|
||||
* @param text AI 输出的原始文本
|
||||
* @returns 解析结果
|
||||
*
|
||||
* @example
|
||||
* const result = parseQQBotPayload('QQBOT_PAYLOAD:\n{"type": "media", "mediaType": "image", ...}');
|
||||
* if (result.isPayload && result.payload) {
|
||||
* // 处理结构化载荷
|
||||
* }
|
||||
*/
|
||||
export function parseQQBotPayload(text: string): ParseResult {
|
||||
const trimmedText = text.trim();
|
||||
|
||||
// 检查是否以 QQBOT_PAYLOAD: 开头
|
||||
if (!trimmedText.startsWith(PAYLOAD_PREFIX)) {
|
||||
return {
|
||||
isPayload: false,
|
||||
text: text
|
||||
};
|
||||
}
|
||||
|
||||
// 提取 JSON 内容(去掉前缀)
|
||||
const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim();
|
||||
|
||||
if (!jsonContent) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: '载荷内容为空'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(jsonContent) as QQBotPayload;
|
||||
|
||||
// 验证必要字段
|
||||
if (!payload.type) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: '载荷缺少 type 字段'
|
||||
};
|
||||
}
|
||||
|
||||
// 根据 type 进行额外验证
|
||||
if (payload.type === 'cron_reminder') {
|
||||
if (!payload.content || !payload.targetType || !payload.targetAddress) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: 'cron_reminder 载荷缺少必要字段 (content, targetType, targetAddress)'
|
||||
};
|
||||
}
|
||||
} else if (payload.type === 'media') {
|
||||
if (!payload.mediaType || !payload.source || !payload.path) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: 'media 载荷缺少必要字段 (mediaType, source, path)'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPayload: true,
|
||||
payload
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: `JSON 解析失败: ${e instanceof Error ? e.message : String(e)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Cron 编码/解码函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 将定时提醒载荷编码为 Cron 消息格式
|
||||
*
|
||||
* 将 JSON 编码为 Base64,并添加 QQBOT_CRON: 前缀
|
||||
*
|
||||
* @param payload 定时提醒载荷
|
||||
* @returns 编码后的消息字符串,格式为 QQBOT_CRON:{base64}
|
||||
*
|
||||
* @example
|
||||
* const message = encodePayloadForCron({
|
||||
* type: 'cron_reminder',
|
||||
* content: '喝水时间到!',
|
||||
* targetType: 'c2c',
|
||||
* targetAddress: 'user_openid_xxx'
|
||||
* });
|
||||
* // 返回: QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...
|
||||
*/
|
||||
export function encodePayloadForCron(payload: CronReminderPayload): string {
|
||||
const jsonString = JSON.stringify(payload);
|
||||
const base64 = Buffer.from(jsonString, 'utf-8').toString('base64');
|
||||
return `${CRON_PREFIX}${base64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 Cron 消息中的载荷
|
||||
*
|
||||
* 检测 QQBOT_CRON: 前缀,解码 Base64 并解析 JSON
|
||||
*
|
||||
* @param message Cron 触发时收到的消息
|
||||
* @returns 解码结果,包含是否为 Cron 载荷、解析后的载荷对象或错误信息
|
||||
*
|
||||
* @example
|
||||
* const result = decodeCronPayload('QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...');
|
||||
* if (result.isCronPayload && result.payload) {
|
||||
* // 处理定时提醒
|
||||
* }
|
||||
*/
|
||||
export function decodeCronPayload(message: string): {
|
||||
isCronPayload: boolean;
|
||||
payload?: CronReminderPayload;
|
||||
error?: string;
|
||||
} {
|
||||
const trimmedMessage = message.trim();
|
||||
|
||||
// 检查是否以 QQBOT_CRON: 开头
|
||||
if (!trimmedMessage.startsWith(CRON_PREFIX)) {
|
||||
return {
|
||||
isCronPayload: false
|
||||
};
|
||||
}
|
||||
|
||||
// 提取 Base64 内容
|
||||
const base64Content = trimmedMessage.slice(CRON_PREFIX.length);
|
||||
|
||||
if (!base64Content) {
|
||||
return {
|
||||
isCronPayload: true,
|
||||
error: 'Cron 载荷内容为空'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Base64 解码
|
||||
const jsonString = Buffer.from(base64Content, 'base64').toString('utf-8');
|
||||
const payload = JSON.parse(jsonString) as CronReminderPayload;
|
||||
|
||||
// 验证类型
|
||||
if (payload.type !== 'cron_reminder') {
|
||||
return {
|
||||
isCronPayload: true,
|
||||
error: `期望 type 为 cron_reminder,实际为 ${payload.type}`
|
||||
};
|
||||
}
|
||||
|
||||
// 验证必要字段
|
||||
if (!payload.content || !payload.targetType || !payload.targetAddress) {
|
||||
return {
|
||||
isCronPayload: true,
|
||||
error: 'Cron 载荷缺少必要字段'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isCronPayload: true,
|
||||
payload
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isCronPayload: true,
|
||||
error: `Cron 载荷解码失败: ${e instanceof Error ? e.message : String(e)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 辅助函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 判断载荷是否为定时提醒类型
|
||||
*/
|
||||
export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload {
|
||||
return payload.type === 'cron_reminder';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断载荷是否为媒体消息类型
|
||||
*/
|
||||
export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload {
|
||||
return payload.type === 'media';
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# QQBot 一键更新并启动脚本
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 解析命令行参数
|
||||
APPID=""
|
||||
SECRET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--appid)
|
||||
APPID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--secret)
|
||||
SECRET="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "用法: $0 [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --appid <appid> QQ机器人 AppID"
|
||||
echo " --secret <secret> QQ机器人 Secret"
|
||||
echo " -h, --help 显示帮助信息"
|
||||
echo ""
|
||||
echo "也可以通过环境变量设置:"
|
||||
echo " QQBOT_APPID QQ机器人 AppID"
|
||||
echo " QQBOT_SECRET QQ机器人 Secret"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知选项: $1"
|
||||
echo "使用 --help 查看帮助信息"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 使用命令行参数或环境变量
|
||||
APPID="${APPID:-$QQBOT_APPID}"
|
||||
SECRET="${SECRET:-$QQBOT_SECRET}"
|
||||
|
||||
echo "========================================="
|
||||
echo " QQBot 一键更新启动脚本"
|
||||
echo "========================================="
|
||||
|
||||
# 1. 移除老版本
|
||||
echo ""
|
||||
echo "[1/4] 移除老版本..."
|
||||
if [ -f "./scripts/upgrade.sh" ]; then
|
||||
bash ./scripts/upgrade.sh
|
||||
else
|
||||
echo "警告: upgrade.sh 不存在,跳过移除步骤"
|
||||
fi
|
||||
|
||||
# 2. 安装当前版本
|
||||
echo ""
|
||||
echo "[2/4] 安装当前版本..."
|
||||
openclaw plugins install .
|
||||
|
||||
# 3. 配置机器人通道
|
||||
echo ""
|
||||
echo "[3/4] 配置机器人通道..."
|
||||
|
||||
# 构建 token(如果提供了 appid 和 secret)
|
||||
if [ -n "$APPID" ] && [ -n "$SECRET" ]; then
|
||||
QQBOT_TOKEN="${APPID}:${SECRET}"
|
||||
echo "使用提供的 AppID 和 Secret 配置..."
|
||||
else
|
||||
# 默认 token,可通过环境变量 QQBOT_TOKEN 覆盖
|
||||
QQBOT_TOKEN="${QQBOT_TOKEN:-appid:secret}"
|
||||
echo "使用默认或环境变量中的 Token..."
|
||||
fi
|
||||
|
||||
openclaw channels add --channel qqbot --token "$QQBOT_TOKEN"
|
||||
# 启用 markdown 支持
|
||||
openclaw config set channels.qqbot.markdownSupport true
|
||||
|
||||
# 4. 启动 openclaw
|
||||
echo ""
|
||||
echo "[4/4] 启动 openclaw..."
|
||||
echo "========================================="
|
||||
openclaw gateway --verbose
|
||||
Reference in New Issue
Block a user