feat(qqbot): 流式消息输出与架构重构
**流式消息** - 新增 StreamSender 类,支持流式消息分片发送 - 实现消息队列异步处理,防止阻塞心跳 - 支持 C2C/Group 流式消息 **架构重构** - 移除 clawdbot/moltbot 旧配置,统一为 qqbot - 新增 upgrade-and-run.sh 一键升级脚本 - 重构 api/channel/gateway/outbound 模块 - 新增富媒体消息发送接口
This commit is contained in:
212
README.md
212
README.md
@@ -1,212 +0,0 @@
|
|||||||
# QQ Bot Channel Plugin for Moltbot
|
|
||||||
|
|
||||||
QQ 开放平台Bot API 的 Moltbot 渠道插件,支持 C2C 私聊、群聊 @消息、频道消息。
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
- **多场景支持**:C2C 单聊、QQ 群 @消息、频道公开消息、频道私信
|
|
||||||
- **自动重连**:WebSocket 断连后自动重连,支持 Session Resume
|
|
||||||
- **消息去重**:自动管理 `msg_seq`,支持对同一消息多次回复
|
|
||||||
- **系统提示词**:可配置自定义系统提示词注入到 AI 请求
|
|
||||||
- **错误提示**:AI 无响应时自动提示用户检查配置
|
|
||||||
|
|
||||||
## 使用示例:
|
|
||||||
<img width="1852" height="1082" alt="image" src="https://github.com/user-attachments/assets/a16d582b-708c-473e-b3a2-e0c4c503a0c8" />
|
|
||||||
|
|
||||||
## 版本更新
|
|
||||||
<img width="1902" height="448" alt="Clipboard_Screenshot_1769739939" src="https://github.com/user-attachments/assets/d6f37458-900c-4de9-8fdc-f8e6bf5c7ee5" />
|
|
||||||
|
|
||||||
### 1.3.0(即将更新)
|
|
||||||
- 支持回复图片等功能
|
|
||||||
|
|
||||||
### 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
|
|
||||||
clawdbot plugins install . # 这一步会有点久,需要安装一些依赖。稍微耐心等待一下,尤其是小内存机器
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置
|
|
||||||
|
|
||||||
### 1. 获取 QQ 机器人凭证
|
|
||||||
|
|
||||||
1. 访问 [QQ 开放平台](https://q.qq.com/)
|
|
||||||
2. 创建机器人应用
|
|
||||||
3. 获取 `AppID` 和 `AppSecret`(ClientSecret)
|
|
||||||
4. Token 格式为 `AppID:AppSecret`,例如 `102146862:Xjv7JVhu7KXkxANbp3HVjxCRgvAPeuAQ`
|
|
||||||
|
|
||||||
### 2. 添加配置
|
|
||||||
|
|
||||||
#### 方式一:交互式配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot channels add
|
|
||||||
# 选择 qqbot,按提示输入 Token
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 方式二:命令行配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot channels add --channel qqbot --token "AppID:AppSecret"
|
|
||||||
```
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot channels add --channel qqbot --token "102146862:xxxxxxxx"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 手动编辑配置(可选)
|
|
||||||
|
|
||||||
也可以直接编辑 `~/.clawdbot/clawdbot.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"qqbot": {
|
|
||||||
"enabled": true,
|
|
||||||
"appId": "你的AppID",
|
|
||||||
"clientSecret": "你的AppSecret",
|
|
||||||
"systemPrompt": "你是一个友好的助手"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 配置项说明
|
|
||||||
|
|
||||||
| 配置项 | 类型 | 必填 | 说明 |
|
|
||||||
|--------|------|------|------|
|
|
||||||
| `appId` | string | 是 | QQ 机器人 AppID |
|
|
||||||
| `clientSecret` | string | 是* | AppSecret,与 `clientSecretFile` 二选一 |
|
|
||||||
| `clientSecretFile` | string | 是* | AppSecret 文件路径 |
|
|
||||||
| `enabled` | boolean | 否 | 是否启用,默认 `true` |
|
|
||||||
| `name` | string | 否 | 账户显示名称 |
|
|
||||||
| `systemPrompt` | string | 否 | 自定义系统提示词 |
|
|
||||||
|
|
||||||
## 支持的消息类型
|
|
||||||
|
|
||||||
| 事件类型 | 说明 | Intent |
|
|
||||||
|----------|------|--------|
|
|
||||||
| `C2C_MESSAGE_CREATE` | C2C 单聊消息 | `1 << 25` |
|
|
||||||
| `GROUP_AT_MESSAGE_CREATE` | 群聊 @机器人消息 | `1 << 25` |
|
|
||||||
| `AT_MESSAGE_CREATE` | 频道 @机器人消息 | `1 << 30` |
|
|
||||||
| `DIRECT_MESSAGE_CREATE` | 频道私信 | `1 << 12` |
|
|
||||||
|
|
||||||
## 使用
|
|
||||||
|
|
||||||
### 启动
|
|
||||||
|
|
||||||
后台启动
|
|
||||||
```bash
|
|
||||||
clawdbot gateway restart
|
|
||||||
```
|
|
||||||
|
|
||||||
前台启动, 方便试试查看日志
|
|
||||||
```bash
|
|
||||||
clawdbot gateway --port 18789 --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### CLI 配置向导
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot onboard
|
|
||||||
# 选择 QQ Bot 进行交互式配置
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **消息回复限制**:QQ 官方 API 限制每条消息最多回复 5 次,超时 60 分钟
|
|
||||||
2. **URL 限制**:QQ 平台不允许消息中包含 URL,插件已内置提示词限制
|
|
||||||
3. **群消息**:需要在群内 @机器人 才能触发回复
|
|
||||||
4. **沙箱模式**:新创建的机器人默认在沙箱模式,需要添加测试用户
|
|
||||||
|
|
||||||
## 升级
|
|
||||||
|
|
||||||
如果需要升级插件,先运行升级脚本清理旧版本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "qqbot",
|
|
||||||
"channels": ["qqbot"],
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
index.ts
11
index.ts
@@ -1,12 +1,15 @@
|
|||||||
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
import { qqbotPlugin } from "./src/channel.js";
|
import { qqbotPlugin } from "./src/channel.js";
|
||||||
import { setQQBotRuntime } from "./src/runtime.js";
|
import { setQQBotRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
id: "qqbot",
|
id: "qqbot",
|
||||||
name: "QQ Bot",
|
name: "QQ Bot (Stream)",
|
||||||
description: "QQ Bot channel plugin",
|
description: "QQ Bot channel plugin with streaming message support",
|
||||||
register(api: MoltbotPluginApi) {
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: OpenClawPluginApi) {
|
||||||
setQQBotRuntime(api.runtime);
|
setQQBotRuntime(api.runtime);
|
||||||
api.registerChannel({ plugin: qqbotPlugin });
|
api.registerChannel({ plugin: qqbotPlugin });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "qqbot",
|
|
||||||
"channels": ["qqbot"],
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2789
package-lock.json
generated
2789
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,17 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "qqbot",
|
"name": "@openclaw/qqbot",
|
||||||
"version": "1.2.3",
|
"version": "2026.1.31",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"clawdbot": {
|
"description": "OpenClaw QQ Bot channel plugin with streaming message support",
|
||||||
"extensions": ["./index.ts"]
|
|
||||||
},
|
|
||||||
"moltbot": {
|
|
||||||
"extensions": ["./index.ts"]
|
|
||||||
},
|
|
||||||
"openclaw": {
|
"openclaw": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc || true",
|
"build": "tsc || true",
|
||||||
@@ -27,11 +24,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/ws": "^8.5.0",
|
"@types/ws": "^8.5.0",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"clawdbot": "*",
|
|
||||||
"moltbot": "*",
|
|
||||||
"openclaw": "*"
|
"openclaw": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,62 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# QQBot 插件升级脚本
|
# QQBot 插件升级脚本
|
||||||
# 用于清理旧版本插件并重新安装
|
# 用于清理旧版本插件并重新安装
|
||||||
# 兼容 clawdbot 和 openclaw 两种安装
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
OPENCLAW_DIR="$HOME/.openclaw"
|
||||||
|
CONFIG_FILE="$OPENCLAW_DIR/openclaw.json"
|
||||||
|
EXTENSION_DIR="$OPENCLAW_DIR/extensions/qqbot"
|
||||||
|
|
||||||
echo "=== QQBot 插件升级脚本 ==="
|
echo "=== QQBot 插件升级脚本 ==="
|
||||||
|
|
||||||
# 检测使用的是 clawdbot 还是 openclaw
|
# 1. 删除旧的扩展目录
|
||||||
detect_installation() {
|
if [ -d "$EXTENSION_DIR" ]; then
|
||||||
if [ -d "$HOME/.clawdbot" ]; then
|
echo "删除旧版本插件: $EXTENSION_DIR"
|
||||||
echo "clawdbot"
|
rm -rf "$EXTENSION_DIR"
|
||||||
elif [ -d "$HOME/.openclaw" ]; then
|
else
|
||||||
echo "openclaw"
|
echo "未找到旧版本插件目录,跳过删除"
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 清理指定目录的函数
|
|
||||||
cleanup_installation() {
|
|
||||||
local APP_NAME="$1"
|
|
||||||
local APP_DIR="$HOME/.$APP_NAME"
|
|
||||||
local CONFIG_FILE="$APP_DIR/$APP_NAME.json"
|
|
||||||
local EXTENSION_DIR="$APP_DIR/extensions/qqbot"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo ">>> 处理 $APP_NAME 安装..."
|
|
||||||
|
|
||||||
# 1. 删除旧的扩展目录
|
|
||||||
if [ -d "$EXTENSION_DIR" ]; then
|
|
||||||
echo "删除旧版本插件: $EXTENSION_DIR"
|
|
||||||
rm -rf "$EXTENSION_DIR"
|
|
||||||
else
|
|
||||||
echo "未找到旧版本插件目录,跳过删除"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. 清理配置文件中的 qqbot 相关字段
|
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
|
||||||
echo "清理配置文件中的 qqbot 字段..."
|
|
||||||
|
|
||||||
# 使用 node 处理 JSON(比 jq 更可靠处理复杂结构)
|
|
||||||
node -e "
|
|
||||||
const fs = require('fs');
|
|
||||||
const config = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8'));
|
|
||||||
|
|
||||||
// 删除 channels.qqbot
|
|
||||||
if (config.channels && config.channels.qqbot) {
|
|
||||||
delete config.channels.qqbot;
|
|
||||||
console.log(' - 已删除 channels.qqbot');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除 plugins.entries.qqbot
|
|
||||||
if (config.plugins && config.plugins.entries && config.plugins.entries.qqbot) {
|
|
||||||
delete config.plugins.entries.qqbot;
|
|
||||||
console.log(' - 已删除 plugins.entries.qqbot');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除 plugins.installs.qqbot
|
|
||||||
if (config.plugins && config.plugins.installs && config.plugins.installs.qqbot) {
|
|
||||||
delete config.plugins.installs.qqbot;
|
|
||||||
console.log(' - 已删除 plugins.installs.qqbot');
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync('$CONFIG_FILE', JSON.stringify(config, null, 2));
|
|
||||||
console.log('配置文件已更新');
|
|
||||||
"
|
|
||||||
else
|
|
||||||
echo "未找到配置文件: $CONFIG_FILE"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 检测并处理所有可能的安装
|
|
||||||
FOUND_INSTALLATION=""
|
|
||||||
|
|
||||||
# 检查 clawdbot
|
|
||||||
if [ -d "$HOME/.clawdbot" ]; then
|
|
||||||
cleanup_installation "clawdbot"
|
|
||||||
FOUND_INSTALLATION="clawdbot"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查 openclaw
|
# 2. 清理配置文件中的 qqbot 相关字段
|
||||||
if [ -d "$HOME/.openclaw" ]; then
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
cleanup_installation "openclaw"
|
echo "清理配置文件中的 qqbot 字段..."
|
||||||
FOUND_INSTALLATION="openclaw"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 如果都没找到
|
# 使用 node 处理 JSON(比 jq 更可靠处理复杂结构)
|
||||||
if [ -z "$FOUND_INSTALLATION" ]; then
|
node -e "
|
||||||
echo "未找到 clawdbot 或 openclaw 安装目录"
|
const fs = require('fs');
|
||||||
echo "请确认已安装 clawdbot 或 openclaw"
|
const config = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8'));
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 使用检测到的安装类型作为命令
|
// 删除 channels.qqbot
|
||||||
CMD="$FOUND_INSTALLATION"
|
if (config.channels && config.channels.qqbot) {
|
||||||
|
delete config.channels.qqbot;
|
||||||
|
console.log(' - 已删除 channels.qqbot');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 plugins.entries.qqbot
|
||||||
|
if (config.plugins && config.plugins.entries && config.plugins.entries.qqbot) {
|
||||||
|
delete config.plugins.entries.qqbot;
|
||||||
|
console.log(' - 已删除 plugins.entries.qqbot');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 plugins.installs.qqbot
|
||||||
|
if (config.plugins && config.plugins.installs && config.plugins.installs.qqbot) {
|
||||||
|
delete config.plugins.installs.qqbot;
|
||||||
|
console.log(' - 已删除 plugins.installs.qqbot');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync('$CONFIG_FILE', JSON.stringify(config, null, 2));
|
||||||
|
console.log('配置文件已更新');
|
||||||
|
"
|
||||||
|
else
|
||||||
|
echo "未找到配置文件: $CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 清理完成 ==="
|
echo "=== 清理完成 ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "接下来请执行以下命令重新安装插件:"
|
echo "接下来请执行以下命令重新安装插件:"
|
||||||
echo " cd /path/to/qqbot"
|
echo " cd /path/to/qqbot"
|
||||||
echo " $CMD plugins install ."
|
echo " openclaw plugins install ."
|
||||||
echo " $CMD channels add --channel qqbot --token \"AppID:AppSecret\""
|
echo " openclaw channels add --channel qqbot --token \"AppID:AppSecret\""
|
||||||
echo " $CMD gateway restart"
|
echo " openclaw gateway restart"
|
||||||
220
src/api.ts
220
src/api.ts
@@ -1,10 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* QQ Bot API 鉴权和请求封装
|
* QQ Bot API 鉴权和请求封装(支持流式消息)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { StreamState, type StreamConfig } from "./types.js";
|
||||||
|
|
||||||
const API_BASE = "https://api.sgroup.qq.com";
|
const API_BASE = "https://api.sgroup.qq.com";
|
||||||
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||||
|
|
||||||
|
// 运行时配置
|
||||||
|
let currentMarkdownSupport = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 API 配置
|
||||||
|
* @param options.markdownSupport - 是否支持 markdown 消息
|
||||||
|
*/
|
||||||
|
export function initApiConfig(options: { markdownSupport?: boolean }): void {
|
||||||
|
currentMarkdownSupport = options.markdownSupport === true; // 默认为 false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前是否支持 markdown
|
||||||
|
*/
|
||||||
|
export function isMarkdownSupport(): boolean {
|
||||||
|
return currentMarkdownSupport;
|
||||||
|
}
|
||||||
|
|
||||||
let cachedToken: { token: string; expiresAt: number } | null = null;
|
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,21 +36,44 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
|||||||
return cachedToken.token;
|
return cachedToken.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(TOKEN_URL, {
|
response = await fetch(TOKEN_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: requestHeaders,
|
||||||
body: JSON.stringify({ appId, clientSecret }),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(`[qqbot-api] <<< Network error:`, err);
|
||||||
throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(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 data: { access_token?: string; expires_in?: number };
|
||||||
|
let rawBody: string;
|
||||||
try {
|
try {
|
||||||
data = (await response.json()) as { access_token?: string; expires_in?: number };
|
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 };
|
||||||
} catch (err) {
|
} 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)}`);
|
throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,29 +136,50 @@ export async function apiRequest<T = unknown>(
|
|||||||
body?: unknown
|
body?: unknown
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = `${API_BASE}${path}`;
|
const url = `${API_BASE}${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `QQBot ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
const options: RequestInit = {
|
const options: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers,
|
||||||
Authorization: `QQBot ${accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
options.body = JSON.stringify(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;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url, options);
|
res = await fetch(url, options);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(`[qqbot-api] <<< Network error:`, err);
|
||||||
throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(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 data: T;
|
||||||
|
let rawBody: string;
|
||||||
try {
|
try {
|
||||||
data = (await res.json()) as T;
|
rawBody = await res.text();
|
||||||
|
console.log(`[qqbot-api] <<< Body:`, rawBody);
|
||||||
|
data = JSON.parse(rawBody) as T;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(`[qqbot-api] <<< Parse error:`, err);
|
||||||
throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,26 +199,98 @@ export async function getGatewayUrl(accessToken: string): Promise<string> {
|
|||||||
return data.url;
|
return data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 流式消息发送接口 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 C2C 单聊消息
|
* 流式消息响应
|
||||||
|
*/
|
||||||
|
export interface StreamMessageResponse {
|
||||||
|
id: string;
|
||||||
|
timestamp: number | string;
|
||||||
|
/** 流式消息ID,用于后续分片 */
|
||||||
|
stream_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建流式消息体
|
||||||
|
* 根据 markdownSupport 配置决定消息格式:
|
||||||
|
* - markdown 模式: { markdown: { content }, msg_type: 2 }
|
||||||
|
* - 纯文本模式: { content, msg_type: 0 }
|
||||||
|
*/
|
||||||
|
function buildStreamBody(
|
||||||
|
content: string,
|
||||||
|
msgId: string | undefined,
|
||||||
|
msgSeq: number,
|
||||||
|
stream?: StreamConfig
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
body.stream = {
|
||||||
|
state: stream.state,
|
||||||
|
index: stream.index,
|
||||||
|
...(stream.id ? { id: stream.id } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 C2C 单聊消息(支持流式)
|
||||||
*/
|
*/
|
||||||
export async function sendC2CMessage(
|
export async function sendC2CMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
openid: string,
|
openid: string,
|
||||||
content: string,
|
content: string,
|
||||||
msgId?: string
|
msgId?: string,
|
||||||
): Promise<{ id: string; timestamp: number }> {
|
stream?: StreamConfig
|
||||||
|
): Promise<StreamMessageResponse> {
|
||||||
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
|
const body = buildStreamBody(content, msgId, msgSeq, stream);
|
||||||
content,
|
|
||||||
msg_type: 0,
|
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||||
msg_seq: msgSeq,
|
|
||||||
...(msgId ? { msg_id: msgId } : {}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送频道消息
|
* 发送 C2C 输入状态提示(告知用户机器人正在输入)
|
||||||
|
*/
|
||||||
|
export async function sendC2CInputNotify(
|
||||||
|
accessToken: string,
|
||||||
|
openid: string,
|
||||||
|
msgId?: string,
|
||||||
|
inputSecond: number = 60
|
||||||
|
): Promise<void> {
|
||||||
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||||
|
const body = {
|
||||||
|
msg_type: 6,
|
||||||
|
input_notify: {
|
||||||
|
input_type: 1,
|
||||||
|
input_second: inputSecond,
|
||||||
|
},
|
||||||
|
msg_seq: msgSeq,
|
||||||
|
...(msgId ? { msg_id: msgId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送频道消息(不支持流式)
|
||||||
*/
|
*/
|
||||||
export async function sendChannelMessage(
|
export async function sendChannelMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -169,21 +305,19 @@ export async function sendChannelMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送群聊消息
|
* 发送群聊消息(支持流式)
|
||||||
*/
|
*/
|
||||||
export async function sendGroupMessage(
|
export async function sendGroupMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
groupOpenid: string,
|
groupOpenid: string,
|
||||||
content: string,
|
content: string,
|
||||||
msgId?: string
|
msgId?: string,
|
||||||
): Promise<{ id: string; timestamp: string }> {
|
stream?: StreamConfig
|
||||||
|
): Promise<StreamMessageResponse> {
|
||||||
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
|
const body = buildStreamBody(content, msgId, msgSeq, stream);
|
||||||
content,
|
|
||||||
msg_type: 0,
|
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
||||||
msg_seq: msgSeq,
|
|
||||||
...(msgId ? { msg_id: msgId } : {}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,11 +372,6 @@ export interface UploadMediaResponse {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传富媒体文件到 C2C 单聊
|
* 上传富媒体文件到 C2C 单聊
|
||||||
* @param accessToken 访问令牌
|
|
||||||
* @param openid 用户 openid
|
|
||||||
* @param fileType 文件类型
|
|
||||||
* @param url 媒体资源 URL
|
|
||||||
* @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送)
|
|
||||||
*/
|
*/
|
||||||
export async function uploadC2CMedia(
|
export async function uploadC2CMedia(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -260,11 +389,6 @@ export async function uploadC2CMedia(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传富媒体文件到群聊
|
* 上传富媒体文件到群聊
|
||||||
* @param accessToken 访问令牌
|
|
||||||
* @param groupOpenid 群 openid
|
|
||||||
* @param fileType 文件类型
|
|
||||||
* @param url 媒体资源 URL
|
|
||||||
* @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送)
|
|
||||||
*/
|
*/
|
||||||
export async function uploadGroupMedia(
|
export async function uploadGroupMedia(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -282,11 +406,6 @@ export async function uploadGroupMedia(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 C2C 单聊富媒体消息
|
* 发送 C2C 单聊富媒体消息
|
||||||
* @param accessToken 访问令牌
|
|
||||||
* @param openid 用户 openid
|
|
||||||
* @param fileInfo 从 uploadC2CMedia 获取的 file_info
|
|
||||||
* @param msgId 被动回复时需要的消息 ID
|
|
||||||
* @param content 可选的文字内容
|
|
||||||
*/
|
*/
|
||||||
export async function sendC2CMediaMessage(
|
export async function sendC2CMediaMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -307,11 +426,6 @@ export async function sendC2CMediaMessage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送群聊富媒体消息
|
* 发送群聊富媒体消息
|
||||||
* @param accessToken 访问令牌
|
|
||||||
* @param groupOpenid 群 openid
|
|
||||||
* @param fileInfo 从 uploadGroupMedia 获取的 file_info
|
|
||||||
* @param msgId 被动回复时需要的消息 ID
|
|
||||||
* @param content 可选的文字内容
|
|
||||||
*/
|
*/
|
||||||
export async function sendGroupMediaMessage(
|
export async function sendGroupMediaMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -332,11 +446,6 @@ export async function sendGroupMediaMessage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送带图片的 C2C 单聊消息(封装上传+发送)
|
* 发送带图片的 C2C 单聊消息(封装上传+发送)
|
||||||
* @param accessToken 访问令牌
|
|
||||||
* @param openid 用户 openid
|
|
||||||
* @param imageUrl 图片 URL
|
|
||||||
* @param msgId 被动回复时需要的消息 ID
|
|
||||||
* @param content 可选的文字内容
|
|
||||||
*/
|
*/
|
||||||
export async function sendC2CImageMessage(
|
export async function sendC2CImageMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -353,11 +462,6 @@ export async function sendC2CImageMessage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送带图片的群聊消息(封装上传+发送)
|
* 发送带图片的群聊消息(封装上传+发送)
|
||||||
* @param accessToken 访问令牌
|
|
||||||
* @param groupOpenid 群 openid
|
|
||||||
* @param imageUrl 图片 URL
|
|
||||||
* @param msgId 被动回复时需要的消息 ID
|
|
||||||
* @param content 可选的文字内容
|
|
||||||
*/
|
*/
|
||||||
export async function sendGroupImageMessage(
|
export async function sendGroupImageMessage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
|||||||
202
src/channel.ts
202
src/channel.ts
@@ -1,27 +1,73 @@
|
|||||||
import type { ChannelPlugin } from "clawdbot/plugin-sdk";
|
import {
|
||||||
|
type ChannelPlugin,
|
||||||
|
type OpenClawConfig,
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
deleteAccountFromConfigSection,
|
||||||
|
setAccountEnabledInConfigSection,
|
||||||
|
} from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
import type { ResolvedQQBotAccount } from "./types.js";
|
import type { ResolvedQQBotAccount } from "./types.js";
|
||||||
import { listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig } from "./config.js";
|
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
|
||||||
import { sendText } from "./outbound.js";
|
import { sendText, sendMedia, createStreamSender, sendTextStream, StreamSender } from "./outbound.js";
|
||||||
import { startGateway } from "./gateway.js";
|
import { startGateway } from "./gateway.js";
|
||||||
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
||||||
|
import { getQQBotRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_ID = "default";
|
/**
|
||||||
|
* 简单的文本分块函数
|
||||||
|
* QQ Bot 使用流式消息时,不需要预先分块,而是在发送时逐步累积
|
||||||
|
* 但框架可能调用此函数来预分块长文本
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||||
id: "qqbot",
|
id: "qqbot",
|
||||||
meta: {
|
meta: {
|
||||||
id: "qqbot",
|
id: "qqbot",
|
||||||
label: "QQ Bot",
|
label: "QQ Bot (Stream)",
|
||||||
selectionLabel: "QQ Bot",
|
selectionLabel: "QQ Bot (Stream)",
|
||||||
docsPath: "/docs/channels/qqbot",
|
docsPath: "/docs/channels/qqbot",
|
||||||
blurb: "Connect to QQ via official QQ Bot API",
|
blurb: "Connect to QQ via official QQ Bot API with streaming message support",
|
||||||
order: 50,
|
order: 50,
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
chatTypes: ["direct", "group"],
|
chatTypes: ["direct", "group"],
|
||||||
media: false,
|
media: true,
|
||||||
reactions: false,
|
reactions: false,
|
||||||
threads: false,
|
threads: false,
|
||||||
|
/**
|
||||||
|
* blockStreaming: true 表示该 Channel 支持块流式
|
||||||
|
* 框架会收集流式响应,然后通过 deliver 回调发送
|
||||||
|
*/
|
||||||
|
blockStreaming: true,
|
||||||
},
|
},
|
||||||
reload: { configPrefixes: ["channels.qqbot"] },
|
reload: { configPrefixes: ["channels.qqbot"] },
|
||||||
// CLI onboarding wizard
|
// CLI onboarding wizard
|
||||||
@@ -29,7 +75,24 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
|
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
|
||||||
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
|
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
|
||||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
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"],
|
||||||
|
}),
|
||||||
isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
|
isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||||
@@ -40,6 +103,16 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
// 新增:规范化账户 ID
|
||||||
|
resolveAccountId: ({ accountId }) => accountId?.trim().toLowerCase() || DEFAULT_ACCOUNT_ID,
|
||||||
|
// 新增:应用账户名称
|
||||||
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
|
applyAccountNameToChannelSection({
|
||||||
|
cfg,
|
||||||
|
channelKey: "qqbot",
|
||||||
|
accountId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
validateInput: ({ input }) => {
|
validateInput: ({ input }) => {
|
||||||
if (!input.token && !input.tokenFile && !input.useEnv) {
|
if (!input.token && !input.tokenFile && !input.useEnv) {
|
||||||
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
||||||
@@ -67,8 +140,22 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 新增:消息目标解析
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: (target) => {
|
||||||
|
// 支持格式: qqbot:openid, qqbot:group:xxx, openid, group:xxx
|
||||||
|
const normalized = target.replace(/^qqbot:/i, "");
|
||||||
|
return { ok: true, to: normalized };
|
||||||
|
},
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: (id) => /^[A-F0-9]{32}$/i.test(id) || id.startsWith("group:") || id.startsWith("channel:"),
|
||||||
|
hint: "<openid> or group:<groupOpenid>",
|
||||||
|
},
|
||||||
|
},
|
||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
|
chunker: chunkText,
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
||||||
const account = resolveQQBotAccount(cfg, accountId);
|
const account = resolveQQBotAccount(cfg, accountId);
|
||||||
@@ -79,12 +166,21 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
error: result.error ? new Error(result.error) : undefined,
|
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, mediaUrl, accountId, replyToId, account });
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
messageId: result.messageId,
|
||||||
|
error: result.error ? new Error(result.error) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
startAccount: async (ctx) => {
|
startAccount: async (ctx) => {
|
||||||
const { account, abortSignal, log, cfg } = ctx;
|
const { account, abortSignal, log, cfg } = ctx;
|
||||||
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Starting gateway`);
|
log?.info(`[qqbot:${account.accountId}] Starting gateway (stream-enabled)`);
|
||||||
|
|
||||||
await startGateway({
|
await startGateway({
|
||||||
account,
|
account,
|
||||||
@@ -109,6 +205,46 @@ 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];
|
||||||
|
if (entry && "clientSecret" in entry) {
|
||||||
|
delete entry.clientSecret;
|
||||||
|
cleared = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (Object.keys(entry).length === 0) {
|
||||||
|
delete accounts[accountId];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed && nextQQBot) {
|
||||||
|
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
|
||||||
|
await getQQBotRuntime().config.writeConfigFile(nextCfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
|
||||||
|
const loggedOut = resolved.secretSource === "none";
|
||||||
|
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
|
||||||
|
|
||||||
|
return { cleared, envToken, loggedOut };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
defaultRuntime: {
|
defaultRuntime: {
|
||||||
@@ -117,7 +253,18 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
connected: false,
|
connected: false,
|
||||||
lastConnectedAt: null,
|
lastConnectedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
lastInboundAt: null,
|
||||||
|
lastOutboundAt: null,
|
||||||
},
|
},
|
||||||
|
// 新增:构建通道摘要
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
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 }) => ({
|
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||||
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||||
name: account?.name,
|
name: account?.name,
|
||||||
@@ -128,6 +275,41 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
connected: runtime?.connected ?? false,
|
connected: runtime?.connected ?? false,
|
||||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||||
lastError: runtime?.lastError ?? null,
|
lastError: runtime?.lastError ?? null,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出流式消息工具函数,供外部使用
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* ```typescript
|
||||||
|
* import { createStreamSender } from "qqbot";
|
||||||
|
*
|
||||||
|
* // 创建流式发送器
|
||||||
|
* const sender = createStreamSender(account, "group:xxx", replyMsgId);
|
||||||
|
*
|
||||||
|
* // 发送第一个分片 (state=1, index=0, id="")
|
||||||
|
* await sender.send("Hello, ", false);
|
||||||
|
*
|
||||||
|
* // 发送中间分片 (state=1, index=1, id=从上次响应获取)
|
||||||
|
* await sender.send("Hello, this is ", false);
|
||||||
|
*
|
||||||
|
* // 发送最后分片并结束 (state=10, index=2)
|
||||||
|
* await sender.end("Hello, this is a streaming message!");
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 或使用 AsyncGenerator:
|
||||||
|
* ```typescript
|
||||||
|
* async function* generateText() {
|
||||||
|
* yield "Hello, ";
|
||||||
|
* yield "this is ";
|
||||||
|
* yield "a streaming message!";
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* await sendTextStream(ctx, generateText());
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export { createStreamSender, sendTextStream, StreamSender };
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
|
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
|
||||||
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_ID = "default";
|
export const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
interface MoltbotConfig {
|
|
||||||
channels?: {
|
|
||||||
qqbot?: QQBotChannelConfig;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QQBotChannelConfig extends QQBotAccountConfig {
|
interface QQBotChannelConfig extends QQBotAccountConfig {
|
||||||
accounts?: Record<string, QQBotAccountConfig>;
|
accounts?: Record<string, QQBotAccountConfig>;
|
||||||
@@ -17,9 +10,9 @@ interface QQBotChannelConfig extends QQBotAccountConfig {
|
|||||||
/**
|
/**
|
||||||
* 列出所有 QQBot 账户 ID
|
* 列出所有 QQBot 账户 ID
|
||||||
*/
|
*/
|
||||||
export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
|
export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
const qqbot = cfg.channels?.qqbot;
|
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||||
|
|
||||||
if (qqbot?.appId) {
|
if (qqbot?.appId) {
|
||||||
ids.add(DEFAULT_ACCOUNT_ID);
|
ids.add(DEFAULT_ACCOUNT_ID);
|
||||||
@@ -36,15 +29,34 @@ export function listQQBotAccountIds(cfg: MoltbotConfig): string[] {
|
|||||||
return Array.from(ids);
|
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 账户配置
|
* 解析 QQBot 账户配置
|
||||||
*/
|
*/
|
||||||
export function resolveQQBotAccount(
|
export function resolveQQBotAccount(
|
||||||
cfg: MoltbotConfig,
|
cfg: OpenClawConfig,
|
||||||
accountId?: string | null
|
accountId?: string | null
|
||||||
): ResolvedQQBotAccount {
|
): ResolvedQQBotAccount {
|
||||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const qqbot = cfg.channels?.qqbot;
|
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||||
|
|
||||||
// 基础配置
|
// 基础配置
|
||||||
let accountConfig: QQBotAccountConfig = {};
|
let accountConfig: QQBotAccountConfig = {};
|
||||||
@@ -64,6 +76,7 @@ export function resolveQQBotAccount(
|
|||||||
allowFrom: qqbot?.allowFrom,
|
allowFrom: qqbot?.allowFrom,
|
||||||
systemPrompt: qqbot?.systemPrompt,
|
systemPrompt: qqbot?.systemPrompt,
|
||||||
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||||
|
markdownSupport: qqbot?.markdownSupport,
|
||||||
};
|
};
|
||||||
appId = qqbot?.appId ?? "";
|
appId = qqbot?.appId ?? "";
|
||||||
} else {
|
} else {
|
||||||
@@ -99,6 +112,7 @@ export function resolveQQBotAccount(
|
|||||||
secretSource,
|
secretSource,
|
||||||
systemPrompt: accountConfig.systemPrompt,
|
systemPrompt: accountConfig.systemPrompt,
|
||||||
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
||||||
|
markdownSupport: accountConfig.markdownSupport,
|
||||||
config: accountConfig,
|
config: accountConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,10 +121,10 @@ export function resolveQQBotAccount(
|
|||||||
* 应用账户配置
|
* 应用账户配置
|
||||||
*/
|
*/
|
||||||
export function applyQQBotAccountConfig(
|
export function applyQQBotAccountConfig(
|
||||||
cfg: MoltbotConfig,
|
cfg: OpenClawConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
|
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
|
||||||
): MoltbotConfig {
|
): OpenClawConfig {
|
||||||
const next = { ...cfg };
|
const next = { ...cfg };
|
||||||
|
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
|||||||
309
src/gateway.ts
309
src/gateway.ts
@@ -1,9 +1,11 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
||||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
|
import { StreamState } from "./types.js";
|
||||||
|
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, sendC2CInputNotify } from "./api.js";
|
||||||
import { getQQBotRuntime } from "./runtime.js";
|
import { getQQBotRuntime } from "./runtime.js";
|
||||||
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
||||||
|
import { createStreamSender } from "./outbound.js";
|
||||||
|
|
||||||
// QQ Bot intents - 按权限级别分组
|
// QQ Bot intents - 按权限级别分组
|
||||||
const INTENTS = {
|
const INTENTS = {
|
||||||
@@ -50,6 +52,11 @@ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765
|
|||||||
// 使用绝对路径,确保文件保存和读取使用同一目录
|
// 使用绝对路径,确保文件保存和读取使用同一目录
|
||||||
const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-images");
|
const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-images");
|
||||||
|
|
||||||
|
// 流式消息配置
|
||||||
|
const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒)
|
||||||
|
const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符)
|
||||||
|
const STREAM_KEEPALIVE_INTERVAL = 8000; // 流式心跳间隔(毫秒),需要在 10 秒内发送
|
||||||
|
|
||||||
export interface GatewayContext {
|
export interface GatewayContext {
|
||||||
account: ResolvedQQBotAccount;
|
account: ResolvedQQBotAccount;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
@@ -90,6 +97,7 @@ async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: st
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动 Gateway WebSocket 连接(带自动重连)
|
* 启动 Gateway WebSocket 连接(带自动重连)
|
||||||
|
* 支持流式消息发送
|
||||||
*/
|
*/
|
||||||
export async function startGateway(ctx: GatewayContext): Promise<void> {
|
export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||||
const { account, abortSignal, cfg, onReady, onError, log } = ctx;
|
const { account, abortSignal, cfg, onReady, onError, log } = ctx;
|
||||||
@@ -98,6 +106,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化 API 配置(markdown 支持)
|
||||||
|
initApiConfig({
|
||||||
|
markdownSupport: account.markdownSupport,
|
||||||
|
});
|
||||||
|
log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport !== false}`);
|
||||||
|
|
||||||
// 如果配置了公网 URL,启动图床服务器
|
// 如果配置了公网 URL,启动图床服务器
|
||||||
let imageServerBaseUrl: string | null = null;
|
let imageServerBaseUrl: string | null = null;
|
||||||
if (account.imageServerBaseUrl) {
|
if (account.imageServerBaseUrl) {
|
||||||
@@ -218,6 +232,17 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对于 C2C 消息,先发送输入状态提示用户机器人正在输入
|
||||||
|
if (event.type === "c2c") {
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(account.appId, account.clientSecret);
|
||||||
|
await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to send input notify: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pluginRuntime.channel.activity.record({
|
pluginRuntime.channel.activity.record({
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -242,7 +267,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
|
||||||
// 组装消息体,添加系统提示词
|
// 组装消息体,添加系统提示词
|
||||||
let builtinPrompt = "由于平台限制,你的回复中不可以包含任何URL。";
|
let builtinPrompt = "";
|
||||||
|
|
||||||
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
||||||
if (imageServerBaseUrl) {
|
if (imageServerBaseUrl) {
|
||||||
@@ -309,7 +334,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
|
|
||||||
const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
|
const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
|
||||||
: event.type === "group" ? `qqbot:group:${event.groupOpenid}`
|
: event.type === "group" ? `qqbot:group:${event.groupOpenid}`
|
||||||
: `qqbot:${event.senderId}`;
|
: `qqbot:c2c:${event.senderId}`;
|
||||||
const toAddress = fromAddress;
|
const toAddress = fromAddress;
|
||||||
|
|
||||||
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
|
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
|
||||||
@@ -386,22 +411,178 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
}, responseTimeout);
|
}, responseTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ 流式消息发送器 ============
|
||||||
|
// 确定发送目标
|
||||||
|
const targetTo = event.type === "c2c" ? event.senderId
|
||||||
|
: event.type === "group" ? `group:${event.groupOpenid}`
|
||||||
|
: `channel:${event.channelId}`;
|
||||||
|
|
||||||
|
// 判断是否支持流式(仅 c2c 支持,群聊不支持流式)
|
||||||
|
const supportsStream = event.type === "c2c";
|
||||||
|
|
||||||
|
// 创建流式发送器
|
||||||
|
const streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null;
|
||||||
|
let streamBuffer = ""; // 累积的全部文本(用于记录完整内容)
|
||||||
|
let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量)
|
||||||
|
let lastStreamSendTime = 0; // 上次流式发送时间
|
||||||
|
let streamStarted = false; // 是否已开始流式发送
|
||||||
|
let streamEnded = false; // 流式是否已结束
|
||||||
|
let sendingLock = false; // 发送锁,防止并发发送
|
||||||
|
let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累)
|
||||||
|
let keepaliveTimer: ReturnType<typeof setTimeout> | null = null; // 心跳定时器
|
||||||
|
|
||||||
|
// 清理心跳定时器
|
||||||
|
const clearKeepalive = () => {
|
||||||
|
if (keepaliveTimer) {
|
||||||
|
clearTimeout(keepaliveTimer);
|
||||||
|
keepaliveTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置心跳定时器(每次发送后调用)
|
||||||
|
const resetKeepalive = () => {
|
||||||
|
clearKeepalive();
|
||||||
|
if (streamSender && streamStarted && !streamEnded) {
|
||||||
|
keepaliveTimer = setTimeout(async () => {
|
||||||
|
// 10 秒内没有新消息,发送空分片保持连接
|
||||||
|
if (!streamEnded && !sendingLock) {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Sending keepalive empty chunk`);
|
||||||
|
sendingLock = true;
|
||||||
|
try {
|
||||||
|
// 发送空内容
|
||||||
|
await streamSender!.send("", false);
|
||||||
|
lastStreamSendTime = Date.now();
|
||||||
|
resetKeepalive(); // 继续下一个心跳
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`);
|
||||||
|
} finally {
|
||||||
|
sendingLock = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, STREAM_KEEPALIVE_INTERVAL);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 流式发送函数 - 用于 onPartialReply 实时发送(增量模式)
|
||||||
|
// markdown 分片需要以 \n 结尾
|
||||||
|
const sendStreamChunk = async (text: string, isEnd: boolean): Promise<boolean> => {
|
||||||
|
if (!streamSender || streamEnded) return false;
|
||||||
|
|
||||||
|
// markdown 分片需要以 \n 结尾(除非是空内容或结束标记)
|
||||||
|
let contentToSend = text;
|
||||||
|
if (isEnd && contentToSend && !contentToSend.endsWith("\n") && !isEnd) {
|
||||||
|
contentToSend = contentToSend + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await streamSender.send(contentToSend, isEnd);
|
||||||
|
if (result.error) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Stream send error: ${result.error}`);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] Stream chunk sent, index: ${streamSender.getContext().index - 1}, isEnd: ${isEnd}, text: "${text.slice(0, 50)}..."`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnd) {
|
||||||
|
streamEnded = true;
|
||||||
|
clearKeepalive();
|
||||||
|
} else {
|
||||||
|
// 发送成功后重置心跳
|
||||||
|
resetKeepalive();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行一次流式发送(带锁保护)
|
||||||
|
const doStreamSend = async (fullText: string, forceEnd: boolean = false): Promise<void> => {
|
||||||
|
// 如果正在发送,记录待发送的完整文本,稍后处理
|
||||||
|
if (sendingLock) {
|
||||||
|
pendingFullText = fullText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendingLock = true;
|
||||||
|
try {
|
||||||
|
// 发送当前增量
|
||||||
|
if (fullText.length > lastSentLength) {
|
||||||
|
const increment = fullText.slice(lastSentLength);
|
||||||
|
const success = await sendStreamChunk(increment, forceEnd);
|
||||||
|
if (success) {
|
||||||
|
lastSentLength = fullText.length;
|
||||||
|
lastStreamSendTime = Date.now();
|
||||||
|
streamStarted = true;
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`);
|
||||||
|
}
|
||||||
|
} else if (forceEnd && !streamEnded) {
|
||||||
|
// 没有新内容但需要结束
|
||||||
|
await sendStreamChunk("", true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sendingLock = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理在锁定期间积累的内容
|
||||||
|
if (pendingFullText && pendingFullText.length > lastSentLength && !streamEnded) {
|
||||||
|
const pending = pendingFullText;
|
||||||
|
pendingFullText = "";
|
||||||
|
// 递归发送积累的内容(不强制结束)
|
||||||
|
await doStreamSend(pending, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// onPartialReply 回调 - 实时接收 AI 生成的文本(payload.text 是累积的全文)
|
||||||
|
const handlePartialReply = async (payload: { text?: string }) => {
|
||||||
|
if (!streamSender || streamEnded) {
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullText = payload.text ?? "";
|
||||||
|
if (!fullText) {
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: empty text`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 始终更新累积缓冲区(即使不发送,也要记录最新内容)
|
||||||
|
streamBuffer = fullText;
|
||||||
|
hasResponse = true;
|
||||||
|
|
||||||
|
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}`);
|
||||||
|
|
||||||
|
// 如果没有新内容,跳过
|
||||||
|
if (fullText.length <= lastSentLength) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
// 控制发送频率:首次发送或间隔超过阈值
|
||||||
|
if (!streamStarted || now - lastStreamSendTime >= STREAM_CHUNK_INTERVAL) {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] handlePartialReply: sending stream chunk, length=${fullText.length}`);
|
||||||
|
await doStreamSend(fullText, false);
|
||||||
|
} else {
|
||||||
|
// 不到发送时间,但记录待发送内容,确保最终会被发送
|
||||||
|
pendingFullText = fullText;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
responsePrefix: messagesConfig.responsePrefix,
|
responsePrefix: messagesConfig.responsePrefix,
|
||||||
deliver: async (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }) => {
|
deliver: async (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, info: { kind: string }) => {
|
||||||
hasResponse = true;
|
hasResponse = true;
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
log?.info(`[qqbot:${account.accountId}] deliver called, payload keys: ${Object.keys(payload).join(", ")}`);
|
log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
|
||||||
|
|
||||||
let replyText = payload.text ?? "";
|
let replyText = payload.text ?? "";
|
||||||
|
|
||||||
|
// 更新 streamBuffer,确保最终内容不会丢失
|
||||||
|
if (replyText.length > streamBuffer.length) {
|
||||||
|
streamBuffer = replyText;
|
||||||
|
}
|
||||||
|
|
||||||
// 收集所有图片路径
|
// 收集所有图片路径
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
|
|
||||||
@@ -430,70 +611,52 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有文本也没有图片,跳过
|
// 提取文本中的各种图片格式
|
||||||
if (!replyText.trim() && imageUrls.length === 0) {
|
// 0. 提取 MEDIA: 前缀的本地文件路径
|
||||||
log?.info(`[qqbot:${account.accountId}] Empty reply, skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0. 提取 MEDIA: 前缀的本地文件路径(从文本中)
|
|
||||||
const mediaPathRegex = /MEDIA:([^\s\n]+)/gi;
|
const mediaPathRegex = /MEDIA:([^\s\n]+)/gi;
|
||||||
const mediaMatches = [...replyText.matchAll(mediaPathRegex)];
|
const mediaMatches = [...replyText.matchAll(mediaPathRegex)];
|
||||||
|
|
||||||
for (const match of mediaMatches) {
|
for (const match of mediaMatches) {
|
||||||
const localPath = match[1];
|
const localPath = match[1];
|
||||||
if (localPath && imageServerBaseUrl) {
|
if (localPath && imageServerBaseUrl) {
|
||||||
// 将本地文件复制到图床
|
|
||||||
try {
|
try {
|
||||||
const savedUrl = saveImageFromPath(localPath);
|
const savedUrl = saveImageFromPath(localPath);
|
||||||
if (savedUrl) {
|
if (savedUrl) {
|
||||||
imageUrls.push(savedUrl);
|
imageUrls.push(savedUrl);
|
||||||
log?.info(`[qqbot:${account.accountId}] Saved local image to server: ${localPath}`);
|
log?.info(`[qqbot:${account.accountId}] Saved local image to server: ${localPath}`);
|
||||||
} else {
|
|
||||||
log?.error(`[qqbot:${account.accountId}] Failed to save local image (not found or not image): ${localPath}`);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error(`[qqbot:${account.accountId}] Failed to save local image: ${err}`);
|
log?.error(`[qqbot:${account.accountId}] Failed to save local image: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 从文本中移除 MEDIA: 行
|
|
||||||
replyText = replyText.replace(match[0], "").trim();
|
replyText = replyText.replace(match[0], "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0.5. 提取本地绝对文件路径(/path/to/image.png 或 /path/to/image_123_png 格式)
|
// 0.5. 提取本地绝对文件路径
|
||||||
// 支持标准扩展名和下划线替换后的扩展名
|
|
||||||
const localPathRegex = /(\/[^\s\n]+?(?:\.(?:png|jpg|jpeg|gif|webp)|_(?:png|jpg|jpeg|gif|webp)(?:\s|$)))/gi;
|
const localPathRegex = /(\/[^\s\n]+?(?:\.(?:png|jpg|jpeg|gif|webp)|_(?:png|jpg|jpeg|gif|webp)(?:\s|$)))/gi;
|
||||||
const localPathMatches = [...replyText.matchAll(localPathRegex)];
|
const localPathMatches = [...replyText.matchAll(localPathRegex)];
|
||||||
|
|
||||||
for (const match of localPathMatches) {
|
for (const match of localPathMatches) {
|
||||||
let localPath = match[1].trim();
|
let localPath = match[1].trim();
|
||||||
if (localPath && imageServerBaseUrl) {
|
if (localPath && imageServerBaseUrl) {
|
||||||
// 如果是下划线格式的扩展名,转换回点格式
|
|
||||||
localPath = localPath.replace(/_(?=(?:png|jpg|jpeg|gif|webp)$)/, ".");
|
localPath = localPath.replace(/_(?=(?:png|jpg|jpeg|gif|webp)$)/, ".");
|
||||||
try {
|
try {
|
||||||
const savedUrl = saveImageFromPath(localPath);
|
const savedUrl = saveImageFromPath(localPath);
|
||||||
if (savedUrl) {
|
if (savedUrl) {
|
||||||
imageUrls.push(savedUrl);
|
imageUrls.push(savedUrl);
|
||||||
log?.info(`[qqbot:${account.accountId}] Saved local path image to server: ${localPath}`);
|
log?.info(`[qqbot:${account.accountId}] Saved local path image to server: ${localPath}`);
|
||||||
} else {
|
|
||||||
log?.error(`[qqbot:${account.accountId}] Local path not found or not image: ${localPath}`);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error(`[qqbot:${account.accountId}] Failed to save local path image: ${err}`);
|
log?.error(`[qqbot:${account.accountId}] Failed to save local path image: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 从文本中移除本地路径
|
|
||||||
replyText = replyText.replace(match[0], "").trim();
|
replyText = replyText.replace(match[0], "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 提取 base64 图片(data:image/xxx;base64,...)
|
// 1. 提取 base64 图片
|
||||||
const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
|
const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
|
||||||
const base64Matches = [...replyText.matchAll(base64ImageRegex)];
|
const base64Matches = [...replyText.matchAll(base64ImageRegex)];
|
||||||
|
|
||||||
for (const match of base64Matches) {
|
for (const match of base64Matches) {
|
||||||
const dataUrl = match[2] || match[3];
|
const dataUrl = match[2] || match[3];
|
||||||
if (dataUrl && imageServerBaseUrl) {
|
if (dataUrl && imageServerBaseUrl) {
|
||||||
// 将 base64 保存到本地图床
|
|
||||||
try {
|
try {
|
||||||
const savedUrl = saveImage(dataUrl);
|
const savedUrl = saveImage(dataUrl);
|
||||||
imageUrls.push(savedUrl);
|
imageUrls.push(savedUrl);
|
||||||
@@ -502,42 +665,37 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
log?.error(`[qqbot:${account.accountId}] Failed to save base64 image: ${err}`);
|
log?.error(`[qqbot:${account.accountId}] Failed to save base64 image: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 从文本中移除 base64
|
|
||||||
replyText = replyText.replace(match[0], "").trim();
|
replyText = replyText.replace(match[0], "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 提取 URL 图片(Markdown 格式或纯 URL)
|
// 2. 提取 URL 图片
|
||||||
const imageUrlRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s)]*)?)\)|(?<![(\[])(https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?)/gi;
|
const imageUrlRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s)]*)?)\)|(?<![(\[])(https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?)/gi;
|
||||||
const urlMatches = [...replyText.matchAll(imageUrlRegex)];
|
const urlMatches = [...replyText.matchAll(imageUrlRegex)];
|
||||||
|
|
||||||
for (const match of urlMatches) {
|
for (const match of urlMatches) {
|
||||||
// match[2] 是 Markdown 格式的 URL,match[3] 是纯 URL
|
|
||||||
const url = match[2] || match[3];
|
const url = match[2] || match[3];
|
||||||
if (url) {
|
if (url) {
|
||||||
imageUrls.push(url);
|
imageUrls.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从文本中移除图片 URL,避免被 QQ 拦截
|
// 从文本中移除图片 URL
|
||||||
let textWithoutImages = replyText;
|
let textWithoutImages = replyText;
|
||||||
for (const match of urlMatches) {
|
for (const match of urlMatches) {
|
||||||
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理剩余文本中的 URL 点号(只有在没有图片的情况下才替换,避免误伤)
|
// 处理剩余文本中的 URL 点号(只有在没有图片的情况下才替换)
|
||||||
const hasImages = imageUrls.length > 0;
|
const hasImages = imageUrls.length > 0;
|
||||||
let hasReplacement = false;
|
if (!hasImages && textWithoutImages) {
|
||||||
if (!hasImages) {
|
|
||||||
const originalText = textWithoutImages;
|
const originalText = textWithoutImages;
|
||||||
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
||||||
hasReplacement = textWithoutImages !== originalText;
|
if (textWithoutImages !== originalText && textWithoutImages.trim()) {
|
||||||
if (hasReplacement && textWithoutImages.trim()) {
|
|
||||||
textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 先发送图片(如果有)
|
// 发送图片(如果有)
|
||||||
for (const imageUrl of imageUrls) {
|
for (const imageUrl of imageUrls) {
|
||||||
try {
|
try {
|
||||||
await sendWithTokenRetry(async (token) => {
|
await sendWithTokenRetry(async (token) => {
|
||||||
@@ -546,29 +704,24 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
} else if (event.type === "group" && event.groupOpenid) {
|
} else if (event.type === "group" && event.groupOpenid) {
|
||||||
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
||||||
}
|
}
|
||||||
// 频道消息暂不支持富媒体,跳过图片
|
|
||||||
});
|
});
|
||||||
log?.info(`[qqbot:${account.accountId}] Sent image: ${imageUrl.slice(0, 50)}...`);
|
log?.info(`[qqbot:${account.accountId}] Sent image: ${imageUrl.slice(0, 50)}...`);
|
||||||
} catch (imgErr) {
|
} catch (imgErr) {
|
||||||
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
||||||
// 图片发送失败时,显示错误信息而不是 URL
|
|
||||||
const errMsg = String(imgErr).slice(0, 200);
|
|
||||||
textWithoutImages = `[图片发送失败: ${errMsg}]\n${textWithoutImages}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再发送文本(如果有)
|
// 只有频道和群聊消息(不支持流式)在 deliver 中发送文本
|
||||||
if (textWithoutImages.trim()) {
|
// c2c 的文本通过 onPartialReply 流式发送
|
||||||
|
if (!supportsStream && textWithoutImages.trim()) {
|
||||||
await sendWithTokenRetry(async (token) => {
|
await sendWithTokenRetry(async (token) => {
|
||||||
if (event.type === "c2c") {
|
if (event.type === "group" && event.groupOpenid) {
|
||||||
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
|
|
||||||
} else if (event.type === "group" && event.groupOpenid) {
|
|
||||||
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
||||||
} else if (event.channelId) {
|
} else if (event.channelId) {
|
||||||
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
log?.info(`[qqbot:${account.accountId}] Sent text reply`);
|
log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type}, non-stream)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginRuntime.channel.activity.record({
|
pluginRuntime.channel.activity.record({
|
||||||
@@ -587,6 +740,28 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理心跳定时器
|
||||||
|
clearKeepalive();
|
||||||
|
|
||||||
|
// 如果在流式模式中出错,发送结束标记(增量模式)
|
||||||
|
if (streamSender && !streamEnded && streamBuffer) {
|
||||||
|
try {
|
||||||
|
// 等待发送锁释放
|
||||||
|
while (sendingLock) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
// 发送剩余增量 + 错误标记
|
||||||
|
const remainingIncrement = streamBuffer.slice(lastSentLength);
|
||||||
|
const errorIncrement = remainingIncrement + "\n\n[生成中断]";
|
||||||
|
await streamSender.end(errorIncrement);
|
||||||
|
streamEnded = true;
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Stream ended due to error`);
|
||||||
|
} catch (endErr) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to end stream: ${endErr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送错误提示给用户,显示完整错误信息
|
// 发送错误提示给用户,显示完整错误信息
|
||||||
const errMsg = String(err);
|
const errMsg = String(err);
|
||||||
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
||||||
@@ -597,13 +772,47 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
replyOptions: {},
|
replyOptions: {
|
||||||
|
// 使用 onPartialReply 实现真正的流式消息
|
||||||
|
// 这个回调在 AI 生成过程中被实时调用
|
||||||
|
onPartialReply: supportsStream ? handlePartialReply : undefined,
|
||||||
|
// 禁用 block streaming,因为我们用 onPartialReply 实现更实时的流式
|
||||||
|
disableBlockStreaming: supportsStream,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 等待分发完成或超时
|
// 等待分发完成或超时
|
||||||
try {
|
try {
|
||||||
await Promise.race([dispatchPromise, timeoutPromise]);
|
await Promise.race([dispatchPromise, timeoutPromise]);
|
||||||
|
|
||||||
|
// 清理心跳定时器
|
||||||
|
clearKeepalive();
|
||||||
|
|
||||||
|
// 分发完成后,如果使用了流式且有内容,发送结束标记
|
||||||
|
if (streamSender && !streamEnded) {
|
||||||
|
// 等待发送锁释放
|
||||||
|
while (sendingLock) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保所有待发送内容都发送出去
|
||||||
|
// 优先使用 pendingFullText,因为它可能包含最新的完整文本
|
||||||
|
const finalFullText = pendingFullText && pendingFullText.length > streamBuffer.length
|
||||||
|
? pendingFullText
|
||||||
|
: streamBuffer;
|
||||||
|
|
||||||
|
// 计算剩余未发送的增量内容
|
||||||
|
const remainingIncrement = finalFullText.slice(lastSentLength);
|
||||||
|
if (remainingIncrement || streamStarted) {
|
||||||
|
// 有剩余内容或者已开始流式,都需要发送结束标记
|
||||||
|
await streamSender.end(remainingIncrement);
|
||||||
|
streamEnded = true;
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total: ${finalFullText.length} chars, chunks: ${streamSender.getContext().index}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 清理心跳定时器
|
||||||
|
clearKeepalive();
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* QQBot CLI Onboarding Adapter
|
* QQBot CLI Onboarding Adapter
|
||||||
*
|
*
|
||||||
* 提供 moltbot onboard 命令的交互式配置支持
|
* 提供 openclaw onboard 命令的交互式配置支持
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
ChannelOnboardingAdapter,
|
ChannelOnboardingAdapter,
|
||||||
@@ -9,20 +9,11 @@ import type {
|
|||||||
ChannelOnboardingStatusContext,
|
ChannelOnboardingStatusContext,
|
||||||
ChannelOnboardingConfigureContext,
|
ChannelOnboardingConfigureContext,
|
||||||
ChannelOnboardingResult,
|
ChannelOnboardingResult,
|
||||||
} from "clawdbot/plugin-sdk";
|
OpenClawConfig,
|
||||||
import { listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
|
} from "openclaw/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount } from "./config.js";
|
||||||
const DEFAULT_ACCOUNT_ID = "default";
|
|
||||||
|
|
||||||
// 内部类型(避免循环依赖)
|
|
||||||
interface MoltbotConfig {
|
|
||||||
channels?: {
|
|
||||||
qqbot?: QQBotChannelConfig;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 内部类型(用于类型安全)
|
||||||
interface QQBotChannelConfig {
|
interface QQBotChannelConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
@@ -43,7 +34,7 @@ interface QQBotChannelConfig {
|
|||||||
/**
|
/**
|
||||||
* 解析默认账户 ID
|
* 解析默认账户 ID
|
||||||
*/
|
*/
|
||||||
function resolveDefaultQQBotAccountId(cfg: MoltbotConfig): string {
|
function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
|
||||||
const ids = listQQBotAccountIds(cfg);
|
const ids = listQQBotAccountIds(cfg);
|
||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
@@ -56,31 +47,30 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
|
|
||||||
getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
|
getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
|
||||||
const { cfg } = ctx;
|
const { cfg } = ctx;
|
||||||
const configured = listQQBotAccountIds(cfg as MoltbotConfig).some((accountId) => {
|
const configured = listQQBotAccountIds(cfg).some((accountId) => {
|
||||||
const account = resolveQQBotAccount(cfg as MoltbotConfig, accountId);
|
const account = resolveQQBotAccount(cfg, accountId);
|
||||||
return Boolean(account.appId && account.clientSecret);
|
return Boolean(account.appId && account.clientSecret);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: "qqbot" as any,
|
channel: "qqbot" as any,
|
||||||
configured,
|
configured,
|
||||||
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
|
statusLines: [`QQ Bot (Stream): ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
|
||||||
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊",
|
selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊(流式消息)",
|
||||||
quickstartScore: configured ? 1 : 20,
|
quickstartScore: configured ? 1 : 20,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
|
configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
|
||||||
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
|
const { cfg, prompter, accountOverrides, shouldPromptAccountIds } = ctx;
|
||||||
const moltbotCfg = cfg as MoltbotConfig;
|
|
||||||
|
|
||||||
const qqbotOverride = (accountOverrides as Record<string, string>).qqbot?.trim();
|
const qqbotOverride = (accountOverrides as Record<string, string>).qqbot?.trim();
|
||||||
const defaultAccountId = resolveDefaultQQBotAccountId(moltbotCfg);
|
const defaultAccountId = resolveDefaultQQBotAccountId(cfg);
|
||||||
let accountId = qqbotOverride ?? defaultAccountId;
|
let accountId = qqbotOverride ?? defaultAccountId;
|
||||||
|
|
||||||
// 是否需要提示选择账户
|
// 是否需要提示选择账户
|
||||||
if (shouldPromptAccountIds && !qqbotOverride) {
|
if (shouldPromptAccountIds && !qqbotOverride) {
|
||||||
const existingIds = listQQBotAccountIds(moltbotCfg);
|
const existingIds = listQQBotAccountIds(cfg);
|
||||||
if (existingIds.length > 1) {
|
if (existingIds.length > 1) {
|
||||||
accountId = await prompter.select({
|
accountId = await prompter.select({
|
||||||
message: "选择 QQBot 账户",
|
message: "选择 QQBot 账户",
|
||||||
@@ -93,7 +83,7 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = moltbotCfg;
|
let next = cfg;
|
||||||
const resolvedAccount = resolveQQBotAccount(next, accountId);
|
const resolvedAccount = resolveQQBotAccount(next, accountId);
|
||||||
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
|
const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
|
||||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
@@ -115,8 +105,10 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
"4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
|
"4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
|
||||||
"",
|
"",
|
||||||
"文档: https://bot.q.qq.com/wiki/",
|
"文档: https://bot.q.qq.com/wiki/",
|
||||||
|
"",
|
||||||
|
"此版本支持流式消息发送!",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"QQ Bot 配置",
|
"QQ Bot (Stream) 配置",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +233,8 @@ export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
disable: (cfg) => ({
|
disable: (cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
channels: {
|
channels: {
|
||||||
...(cfg as MoltbotConfig).channels,
|
...(cfg as OpenClawConfig).channels,
|
||||||
qqbot: { ...(cfg as MoltbotConfig).channels?.qqbot, enabled: false },
|
qqbot: { ...(cfg as OpenClawConfig).channels?.qqbot, enabled: false },
|
||||||
},
|
},
|
||||||
}) as any,
|
}) as any,
|
||||||
};
|
};
|
||||||
|
|||||||
353
src/outbound.ts
353
src/outbound.ts
@@ -1,4 +1,9 @@
|
|||||||
import type { ResolvedQQBotAccount } from "./types.js";
|
/**
|
||||||
|
* QQ Bot 消息发送模块(支持流式消息)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ResolvedQQBotAccount, StreamContext } from "./types.js";
|
||||||
|
import { StreamState } from "./types.js";
|
||||||
import {
|
import {
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
sendC2CMessage,
|
sendC2CMessage,
|
||||||
@@ -6,6 +11,9 @@ import {
|
|||||||
sendGroupMessage,
|
sendGroupMessage,
|
||||||
sendProactiveC2CMessage,
|
sendProactiveC2CMessage,
|
||||||
sendProactiveGroupMessage,
|
sendProactiveGroupMessage,
|
||||||
|
sendC2CImageMessage,
|
||||||
|
sendGroupImageMessage,
|
||||||
|
type StreamMessageResponse,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
export interface OutboundContext {
|
export interface OutboundContext {
|
||||||
@@ -16,11 +24,164 @@ export interface OutboundContext {
|
|||||||
account: ResolvedQQBotAccount;
|
account: ResolvedQQBotAccount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaOutboundContext extends OutboundContext {
|
||||||
|
mediaUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutboundResult {
|
export interface OutboundResult {
|
||||||
channel: string;
|
channel: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
timestamp?: string | number;
|
timestamp?: string | number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** 流式消息ID,用于后续分片 */
|
||||||
|
streamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式消息发送器
|
||||||
|
* 用于管理一个完整的流式消息会话
|
||||||
|
*/
|
||||||
|
export class StreamSender {
|
||||||
|
private context: StreamContext;
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private targetType: "c2c" | "group" | "channel";
|
||||||
|
private targetId: string;
|
||||||
|
private msgId?: string;
|
||||||
|
private account: ResolvedQQBotAccount;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
account: ResolvedQQBotAccount,
|
||||||
|
to: string,
|
||||||
|
replyToId?: string | null
|
||||||
|
) {
|
||||||
|
this.account = account;
|
||||||
|
this.msgId = replyToId ?? undefined;
|
||||||
|
this.context = {
|
||||||
|
index: 0,
|
||||||
|
streamId: "",
|
||||||
|
ended: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析目标地址
|
||||||
|
const target = parseTarget(to);
|
||||||
|
this.targetType = target.type;
|
||||||
|
this.targetId = target.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送流式消息分片
|
||||||
|
* @param text 分片内容
|
||||||
|
* @param isEnd 是否是最后一个分片
|
||||||
|
* @returns 发送结果
|
||||||
|
*/
|
||||||
|
async send(text: string, isEnd = false): Promise<OutboundResult> {
|
||||||
|
if (this.context.ended) {
|
||||||
|
return { channel: "qqbot", error: "Stream already ended" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.account.appId || !this.account.clientSecret) {
|
||||||
|
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取或复用 accessToken
|
||||||
|
if (!this.accessToken) {
|
||||||
|
this.accessToken = await getAccessToken(this.account.appId, this.account.clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamConfig = {
|
||||||
|
state: isEnd ? StreamState.END : StreamState.STREAMING,
|
||||||
|
index: this.context.index,
|
||||||
|
id: this.context.streamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: StreamMessageResponse;
|
||||||
|
|
||||||
|
if (this.targetType === "c2c") {
|
||||||
|
result = await sendC2CMessage(
|
||||||
|
this.accessToken,
|
||||||
|
this.targetId,
|
||||||
|
text,
|
||||||
|
this.msgId,
|
||||||
|
streamConfig
|
||||||
|
);
|
||||||
|
} else if (this.targetType === "group") {
|
||||||
|
// 群聊不支持流式,直接发送普通消息
|
||||||
|
const groupResult = await sendGroupMessage(
|
||||||
|
this.accessToken,
|
||||||
|
this.targetId,
|
||||||
|
text,
|
||||||
|
this.msgId
|
||||||
|
// 不传 streamConfig
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
messageId: groupResult.id,
|
||||||
|
timestamp: groupResult.timestamp
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 频道不支持流式,直接发送普通消息
|
||||||
|
const channelResult = await sendChannelMessage(
|
||||||
|
this.accessToken,
|
||||||
|
this.targetId,
|
||||||
|
text,
|
||||||
|
this.msgId
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
messageId: channelResult.id,
|
||||||
|
timestamp: channelResult.timestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新流式上下文
|
||||||
|
// 第一次发送后,服务端会返回 stream_id(或在 id 字段中),后续需要带上
|
||||||
|
if (this.context.index === 0 && result.stream_id) {
|
||||||
|
this.context.streamId = result.stream_id;
|
||||||
|
} else if (this.context.index === 0 && result.id && !this.context.streamId) {
|
||||||
|
// 某些情况下 stream_id 可能在 id 字段返回
|
||||||
|
this.context.streamId = result.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.index++;
|
||||||
|
|
||||||
|
if (isEnd) {
|
||||||
|
this.context.ended = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel: "qqbot",
|
||||||
|
messageId: result.id,
|
||||||
|
timestamp: result.timestamp,
|
||||||
|
streamId: this.context.streamId,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { channel: "qqbot", error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束流式消息
|
||||||
|
* @param text 最后一个分片的内容(可选)
|
||||||
|
*/
|
||||||
|
async end(text?: string): Promise<OutboundResult> {
|
||||||
|
return this.send(text ?? "", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前流式上下文状态
|
||||||
|
*/
|
||||||
|
getContext(): Readonly<StreamContext> {
|
||||||
|
return { ...this.context };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已结束
|
||||||
|
*/
|
||||||
|
isEnded(): boolean {
|
||||||
|
return this.context.ended;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,26 +193,32 @@ export interface OutboundResult {
|
|||||||
* - 纯数字 -> 频道
|
* - 纯数字 -> 频道
|
||||||
*/
|
*/
|
||||||
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
|
function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
|
||||||
if (to.startsWith("group:")) {
|
// 去掉 qqbot: 前缀
|
||||||
return { type: "group", id: to.slice(6) };
|
let id = to.replace(/^qqbot:/i, "");
|
||||||
|
|
||||||
|
if (id.startsWith("c2c:")) {
|
||||||
|
return { type: "c2c", id: id.slice(4) };
|
||||||
}
|
}
|
||||||
if (to.startsWith("channel:")) {
|
if (id.startsWith("group:")) {
|
||||||
return { type: "channel", id: to.slice(8) };
|
return { type: "group", id: id.slice(6) };
|
||||||
}
|
}
|
||||||
// openid 通常是 32 位十六进制
|
if (id.startsWith("channel:")) {
|
||||||
if (/^[A-F0-9]{32}$/i.test(to)) {
|
return { type: "channel", id: id.slice(8) };
|
||||||
return { type: "c2c", id: to };
|
|
||||||
}
|
}
|
||||||
// 默认当作频道 ID
|
// 默认当作 c2c(私聊)
|
||||||
return { type: "channel", id: to };
|
return { type: "c2c", id };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送文本消息(被动回复,需要 replyToId)
|
* 发送文本消息
|
||||||
|
* - 有 replyToId: 被动回复,无配额限制
|
||||||
|
* - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群)
|
||||||
*/
|
*/
|
||||||
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||||
const { to, text, replyToId, account } = ctx;
|
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));
|
||||||
|
|
||||||
if (!account.appId || !account.clientSecret) {
|
if (!account.appId || !account.clientSecret) {
|
||||||
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||||
}
|
}
|
||||||
@@ -59,15 +226,32 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|||||||
try {
|
try {
|
||||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||||
const target = parseTarget(to);
|
const target = parseTarget(to);
|
||||||
|
console.log("[qqbot] sendText target:", JSON.stringify(target));
|
||||||
|
|
||||||
|
// 如果没有 replyToId,使用主动发送接口
|
||||||
|
if (!replyToId) {
|
||||||
|
if (target.type === "c2c") {
|
||||||
|
const result = await sendProactiveC2CMessage(accessToken, target.id, text);
|
||||||
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
|
} else if (target.type === "group") {
|
||||||
|
const result = await sendProactiveGroupMessage(accessToken, target.id, text);
|
||||||
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
|
} else {
|
||||||
|
// 频道暂不支持主动消息
|
||||||
|
const result = await sendChannelMessage(accessToken, target.id, text);
|
||||||
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有 replyToId,使用被动回复接口
|
||||||
if (target.type === "c2c") {
|
if (target.type === "c2c") {
|
||||||
const result = await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
|
||||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
} else if (target.type === "group") {
|
} else if (target.type === "group") {
|
||||||
const result = await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
|
||||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
} else {
|
} else {
|
||||||
const result = await sendChannelMessage(accessToken, target.id, text, replyToId ?? undefined);
|
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
|
||||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -76,6 +260,69 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式发送文本消息
|
||||||
|
*
|
||||||
|
* @param ctx 发送上下文
|
||||||
|
* @param textGenerator 异步文本生成器,每次 yield 一个分片
|
||||||
|
* @returns 最终发送结果
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* async function* generateText() {
|
||||||
|
* yield "Hello, ";
|
||||||
|
* yield "this is ";
|
||||||
|
* yield "a streaming ";
|
||||||
|
* yield "message!";
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const result = await sendTextStream(ctx, generateText());
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function sendTextStream(
|
||||||
|
ctx: OutboundContext,
|
||||||
|
textGenerator: AsyncIterable<string>
|
||||||
|
): Promise<OutboundResult> {
|
||||||
|
const { to, replyToId, account } = ctx;
|
||||||
|
|
||||||
|
const sender = new StreamSender(account, to, replyToId);
|
||||||
|
let lastResult: OutboundResult = { channel: "qqbot" };
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of textGenerator) {
|
||||||
|
buffer += chunk;
|
||||||
|
|
||||||
|
// 发送当前分片
|
||||||
|
lastResult = await sender.send(buffer, false);
|
||||||
|
|
||||||
|
if (lastResult.error) {
|
||||||
|
return lastResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送结束标记
|
||||||
|
lastResult = await sender.end(buffer);
|
||||||
|
|
||||||
|
return lastResult;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { channel: "qqbot", error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建流式消息发送器
|
||||||
|
* 提供更细粒度的控制
|
||||||
|
*/
|
||||||
|
export function createStreamSender(
|
||||||
|
account: ResolvedQQBotAccount,
|
||||||
|
to: string,
|
||||||
|
replyToId?: string | null
|
||||||
|
): StreamSender {
|
||||||
|
return new StreamSender(account, to, replyToId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群)
|
* 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群)
|
||||||
*
|
*
|
||||||
@@ -112,3 +359,81 @@ export async function sendProactiveMessage(
|
|||||||
return { channel: "qqbot", error: message };
|
return { channel: "qqbot", error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送富媒体消息(图片)
|
||||||
|
*
|
||||||
|
* @param ctx - 发送上下文,包含 mediaUrl
|
||||||
|
* @returns 发送结果
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const result = await sendMedia({
|
||||||
|
* to: "group:xxx",
|
||||||
|
* text: "这是图片说明",
|
||||||
|
* mediaUrl: "https://example.com/image.png",
|
||||||
|
* account,
|
||||||
|
* replyToId: msgId,
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
||||||
|
const { to, text, mediaUrl, replyToId, account } = 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" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||||
|
const target = parseTarget(to);
|
||||||
|
|
||||||
|
// 先发送图片
|
||||||
|
let imageResult: { id: string; timestamp: number | string };
|
||||||
|
if (target.type === "c2c") {
|
||||||
|
imageResult = await sendC2CImageMessage(
|
||||||
|
accessToken,
|
||||||
|
target.id,
|
||||||
|
mediaUrl,
|
||||||
|
replyToId ?? undefined,
|
||||||
|
undefined // content 参数,图片消息不支持同时带文本
|
||||||
|
);
|
||||||
|
} else if (target.type === "group") {
|
||||||
|
imageResult = await sendGroupImageMessage(
|
||||||
|
accessToken,
|
||||||
|
target.id,
|
||||||
|
mediaUrl,
|
||||||
|
replyToId ?? undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 频道暂不支持富媒体消息,只发送文本 + URL
|
||||||
|
const textWithUrl = text ? `${text}\n${mediaUrl}` : mediaUrl;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
let runtime: PluginRuntime | null = null;
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
|||||||
38
src/types.ts
38
src/types.ts
@@ -21,6 +21,8 @@ export interface ResolvedQQBotAccount {
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
/** 图床服务器公网地址 */
|
/** 图床服务器公网地址 */
|
||||||
imageServerBaseUrl?: string;
|
imageServerBaseUrl?: string;
|
||||||
|
/** 是否支持 markdown 消息 */
|
||||||
|
markdownSupport?: boolean;
|
||||||
config: QQBotAccountConfig;
|
config: QQBotAccountConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@ export interface QQBotAccountConfig {
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
|
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
|
||||||
imageServerBaseUrl?: string;
|
imageServerBaseUrl?: string;
|
||||||
|
/** 是否支持 markdown 消息,默认 true */
|
||||||
|
markdownSupport?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,3 +121,37 @@ export interface WSPayload {
|
|||||||
s?: number;
|
s?: number;
|
||||||
t?: string;
|
t?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式消息状态
|
||||||
|
*/
|
||||||
|
export enum StreamState {
|
||||||
|
/** 流式消息开始/进行中 */
|
||||||
|
STREAMING = 1,
|
||||||
|
/** 流式消息结束 */
|
||||||
|
END = 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式消息配置
|
||||||
|
*/
|
||||||
|
export interface StreamConfig {
|
||||||
|
/** 流式状态: 1=开始/进行中, 10=结束 */
|
||||||
|
state: StreamState;
|
||||||
|
/** 分片索引,从0开始 */
|
||||||
|
index: number;
|
||||||
|
/** 流式消息ID,第一次发送为空,后续需要带上服务端返回的ID */
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式消息发送上下文
|
||||||
|
*/
|
||||||
|
export interface StreamContext {
|
||||||
|
/** 当前分片索引 */
|
||||||
|
index: number;
|
||||||
|
/** 流式消息ID(首次发送后由服务端返回) */
|
||||||
|
streamId: string;
|
||||||
|
/** 是否已结束 */
|
||||||
|
ended: boolean;
|
||||||
|
}
|
||||||
|
|||||||
41
upgrade-and-run.sh
Executable file
41
upgrade-and-run.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# QQBot 一键更新并启动脚本
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
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,可通过环境变量 QQBOT_TOKEN 覆盖
|
||||||
|
QQBOT_TOKEN="${QQBOT_TOKEN:-xxx:xxx}"
|
||||||
|
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