@tencent-connect/openclaw-qqbot 1.5.6 → 1.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -146
- package/README.zh.md +46 -146
- package/bin/qqbot-cli.js +6 -6
- package/dist/AI/345/210/233/346/226/260/345/272/224/347/224/250/345/245/226_/347/224/263/346/212/245/344/271/246.md +211 -0
- package/dist/src/gateway.js +109 -92
- package/dist/src/slash-commands.d.ts +48 -0
- package/dist/src/slash-commands.js +212 -0
- package/dist/src/utils/audio-convert.d.ts +0 -6
- package/dist/src/utils/audio-convert.js +0 -89
- package/package.json +1 -1
- package/scripts/{upgrade.sh → cleanup-legacy-plugins.sh} +3 -3
- package/scripts/set-markdown.sh +20 -20
- package/scripts/upgrade-via-npm.sh +204 -0
- package/scripts/{upgrade-and-run.sh → upgrade-via-source.sh} +60 -44
- package/src/api.ts +104 -24
- package/src/channel.ts +2 -1
- package/src/gateway.ts +229 -33
- package/src/image-server.ts +5 -2
- package/src/outbound.ts +32 -26
- package/src/ref-index-store.ts +358 -0
- package/src/types.ts +6 -0
- package/src/utils/platform.ts +16 -2
- package/scripts/draw_arch.py +0 -174
- package/scripts/npm-upgrade.sh +0 -120
- package/scripts/pull-latest.sh +0 -316
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# AI 创新应用奖申报书(OpenClaw QQBot)
|
|
2
|
+
|
|
3
|
+
> 说明:本文按正式申请模板格式撰写,文中带 `【待填】` 的字段请按实际数据替换。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 一、项目综述
|
|
8
|
+
|
|
9
|
+
### 项目背景(300字左右)
|
|
10
|
+
|
|
11
|
+
这波“龙虾”动态放大的并非单一平台热度,而是用户在 IM 通道中即时调用 AI 的普遍需求:大家希望在熟悉的聊天入口里直接完成问答、创作与任务处理,而不是频繁跳转到独立应用。对开发者而言,真正难点不在创意本身,而在把 AI 能力稳定接入高频 IM 场景所需的工程复杂度与稳定性治理成本,导致从“能跑”到“可生产”之间仍有明显落差。
|
|
12
|
+
|
|
13
|
+
OpenClaw 是一个开源 AI 助手框架,已原生支持 Telegram、Discord、Slack、WhatsApp 等国际主流 IM 通道,并深度集成腾讯云 Lighthouse 轻量应用服务器,实现一键云端部署。但在国内高频社交场景中,QQ 这一关键通道此前缺少生产级插件支撑。
|
|
14
|
+
|
|
15
|
+
本项目正是为了补齐这块能力拼图:构建 OpenClaw 生态中首个面向 QQ 平台的生产级通道插件,让开发者通过一行命令即可在腾讯云 Lighthouse 上完成 QQ 机器人的安装、配置与运行,实现 0 门槛接入 QQ 机器人,真正把 AI 服务带到用户最常用的聊天入口。
|
|
16
|
+
|
|
17
|
+
### 技术亮点/创新亮点(重点体现技术的领先性、突破性)
|
|
18
|
+
|
|
19
|
+
**1. 打通 QQ 通道与 OpenClaw 能力生态**
|
|
20
|
+
|
|
21
|
+
OpenClaw 已支持 Telegram、Discord、Slack、WhatsApp 等通道,QQBot 插件补齐了国内高频社交入口。通过 **QQ 用户 → QQBot 通道 → OpenClaw 网关 → 模型/Skill/Tool** 的链路,用户可以在 QQ 会话内直接使用对话、图文、文件与定时任务等能力,形成“同一入口、多种 AI 能力”的一致体验。
|
|
22
|
+
|
|
23
|
+
**2. 多模态消息统一处理引擎**
|
|
24
|
+
|
|
25
|
+
围绕 QQ 实际交互场景,统一支持文本、图片、语音、文件、视频 5 类消息的收发。针对模型输出不稳定的问题,提供统一消息标签的兼容解析与自动纠正(30+ 变体),降低模型侧适配成本。底层结合上传去重缓存、有序队列发送与音频多层降级,保证多媒体链路在高并发下可用。
|
|
26
|
+
|
|
27
|
+
**3. 0 门槛接入 QQ 机器人(腾讯云为示例)**
|
|
28
|
+
|
|
29
|
+
插件与 OpenClaw 框架协同,打通标准化部署与运行链路;以腾讯云 Lighthouse 为示例,开发者在云端完成 OpenClaw 实例后,可通过一行命令完成 QQBot 插件安装、配置和启动。在安全边界内实现 QQ 机器人 0 门槛接入,把创建与接入成本降到接近 0。结合网关侧重连退避、Session Resume 与心跳保活机制,提升长期运行稳定性。
|
|
30
|
+
|
|
31
|
+
**4. 多账号隔离与平滑升级机制**
|
|
32
|
+
|
|
33
|
+
针对多机器人并行场景,重构了按 `appId` 隔离的 Token 生命周期管理,避免并发刷新导致的鉴权冲突。升级层面提供自动备份、历史插件 ID 清理、配置恢复与重启闭环(npm/source 两套脚本),降低升级过程中的配置丢失和服务中断风险。
|
|
34
|
+
|
|
35
|
+
**5. 面向生产的消息处理与降级机制**
|
|
36
|
+
|
|
37
|
+
针对平台时效约束、网络抖动和消息积压等线上常见问题,设计了被动/主动消息切换、按用户串行与跨用户并行结合的队列机制,以及消息序列号与重试兜底策略。目标不是“跑通一次”,而是在真实流量下保持回复连续性、时序一致性和可恢复能力。
|
|
38
|
+
|
|
39
|
+
### 项目成果(简要阐述业务效果、技术指标等)
|
|
40
|
+
|
|
41
|
+
**业务数据(截至 2026 年 3 月):**
|
|
42
|
+
|
|
43
|
+
截至 2026 年 3 月,项目总体接入用户数已达 **33W+**,日新增 **10W+**。
|
|
44
|
+
其中,向 BOT 发消息用户 **25W**(占总接入 **75.7%**),收到 BOT 回复用户 **11W**(占比 **37%**,环比提升 **4pp**)。
|
|
45
|
+
用户与 BOT 的交互规模持续增长:用户→BOT 消息量累计 **450W** 条(人均 **41** 条),BOT→用户消息量累计 **842W** 条(人均 **76.5** 条),同比去年增长 **1000 倍**。
|
|
46
|
+
外部反馈方面,项目获得主流媒体报道 **396** 篇(含环球网、南方都市报等),观点 **100% 中性正面**;全网强相关舆情 **2210** 条,用户正面+中性占比 **94.4%**。
|
|
47
|
+
|
|
48
|
+
**技术指标:**
|
|
49
|
+
|
|
50
|
+
在部署效率上,首次接入耗时从数天开发降至 **1 行命令 / 数分钟**。
|
|
51
|
+
在能力覆盖上,文本、图片、语音、文件、视频 **5 种消息类型全覆盖**,并支持 **30+** 种标签格式变体自动纠正;语音链路提供 **3 级** 降级(STT → ASR → 引导)。
|
|
52
|
+
并发与兼容层面,Token 按 appId 独立生命周期管理,实现 **零竞态**;完全适配 OpenClaw 原生框架,可通过机器人快速打通 QQ 与 OpenClaw 的连接链路;支持单网关 **N 个** QQ 机器人并行独立运行,并支持多种斜杠指令覆盖常见运维与调试场景。
|
|
53
|
+
在稳定性与可靠性上,支持被动→主动降级并实现用户 **零感知** 连续回复;通过 seq 去重机制杜绝消息静默丢失。
|
|
54
|
+
升级与运维方面,支持多种历史插件 ID 变体自动清理迁移;支持问题斜杠指令,可快速追踪链路耗时,协助快速排查问题。
|
|
55
|
+
开源社区指标(GitHub Stars)为 `1k+`。
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 二、具体项目阐述
|
|
60
|
+
|
|
61
|
+
### 1. 问题与动机
|
|
62
|
+
|
|
63
|
+
“龙虾”潮流并没有制造新问题,而是放大并暴露了 AI 在 IM 场景中长期存在的“最后一公里”难题。以 QQ 为代表的高频社交通道里,AI 机器人落地仍存在“会做 Demo、难做生产”的断层,主要体现在四个方面:
|
|
64
|
+
|
|
65
|
+
- **接入复杂度高**:对接 QQ 官方 Bot API 涉及 OAuth2 鉴权、WebSocket 事件流、富媒体上传下载等复杂链路,一个“群里 @ 机器人问问题”的需求往往要经历较长联调周期。
|
|
66
|
+
- **多模态体验不连续**:用户在 QQ 的高频交互并非只有文本,还包括语音、图片、文件和视频,传统方案多停留在文本能力,体验割裂明显。
|
|
67
|
+
- **稳定性门槛高**:网络抖动、平台限流、连接重置、被动回复约束等问题叠加后,容易直接表现为“机器人不回复”。
|
|
68
|
+
- **运维与升级风险大**:历史版本兼容、配置恢复、回滚策略缺失时,升级就会变成高风险操作。
|
|
69
|
+
|
|
70
|
+
**核心判断**:当前瓶颈不在模型能力,而在“最后一公里”通道工程。只有把 QQ 与 OpenClaw 的连接链路做成标准化、低门槛、可持续运维的基础设施,AI 能力才能稳定进入真实社交场景。
|
|
71
|
+
|
|
72
|
+
### 2. 解决方案:OpenClaw QQBot 通道插件
|
|
73
|
+
|
|
74
|
+
#### 2.1 QQ 作为超级入口,连接 OpenClaw 能力生态
|
|
75
|
+
|
|
76
|
+
OpenClaw 采用 `ChannelPlugin` 架构,核心理念是“通道与能力解耦”:模型、Skill、Tool 在框架层统一编排,QQBot 插件负责消息收发与协议适配。因此,QQ 可以成为统一入口,直接连接 OpenClaw 全部能力。
|
|
77
|
+
|
|
78
|
+
**当前已实现价值(已验证):**
|
|
79
|
+
|
|
80
|
+
- **入口统一**:以 QQ 作为统一用户入口,已在原生聊天场景打通文本、语音、文件、视频与定时任务等核心能力。
|
|
81
|
+
- **链路打通**:完成“消息接入 → 模型/Skill/Tool 编排 → 结果回传”的端到端闭环,用户可在单一入口持续完成多类型 AI 交互。
|
|
82
|
+
- **工程可用**:能力接入与通道适配解耦,新增能力可快速上线,显著降低通道侧重复开发成本。
|
|
83
|
+
|
|
84
|
+
**未来生态价值(可扩展):**
|
|
85
|
+
|
|
86
|
+
- **能力可插拔**:同一 QQ 入口可按需切换后端模型,并持续接入新的 Skill / Tool。
|
|
87
|
+
- **通道可复制**:该通道范式可复用到微信、企微等 IM 场景,实现同一能力栈多入口触达。
|
|
88
|
+
- **生态可分发**:第三方 Claw 应用可经由 QQ 对话直接触达用户,形成“QQ 入口 × Claw 能力”的持续分发闭环。
|
|
89
|
+
|
|
90
|
+
#### 2.2 0 门槛接入路径(腾讯云为示例)
|
|
91
|
+
|
|
92
|
+
以腾讯云 Lighthouse 为示例,项目形成“云端基础设施 + AI 框架 + 通道插件”的标准接入路径:开发者完成 OpenClaw 实例后,通过一行命令即可安装、配置并启动 QQBot 插件,在安全边界内实现 0 门槛接入 QQ 机器人。
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
┌─────────────────────────────────────────────────────────┐
|
|
96
|
+
│ QQ 用户(手机/PC) │
|
|
97
|
+
│ 私聊 / 群聊 / 语音 / 图片 / 文件 │
|
|
98
|
+
└──────────────────────────┬──────────────────────────────┘
|
|
99
|
+
│ QQ 官方 Bot API (WebSocket)
|
|
100
|
+
┌──────────────────────────▼──────────────────────────────┐
|
|
101
|
+
│ 腾讯云 Lighthouse 轻量应用服务器 │
|
|
102
|
+
│ ┌──────────────────────────────────────────────────┐ │
|
|
103
|
+
│ │ OpenClaw 框架 │ │
|
|
104
|
+
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
|
|
105
|
+
│ │ │ QQBot 通道 │ │ Telegram │ │ Discord 等 │ │ │
|
|
106
|
+
│ │ │ (本插件) │ │ 通道 │ │ 通道 │ │ │
|
|
107
|
+
│ │ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ │ │
|
|
108
|
+
│ │ └───────────────┼───────────────┘ │ │
|
|
109
|
+
│ │ OpenClaw Gateway │ │
|
|
110
|
+
│ │ ┌───────────────┼───────────────┐ │ │
|
|
111
|
+
│ │ ┌──────▼─────┐ ┌─────▼──────┐ ┌─────▼─────┐ │ │
|
|
112
|
+
│ │ │ AI 模型 │ │ Skills │ │ Tools │ │ │
|
|
113
|
+
│ │ │ (混元/GPT等)│ │ (定时/媒体) │ │ (搜索/API)│ │ │
|
|
114
|
+
│ │ └────────────┘ └────────────┘ └───────────┘ │ │
|
|
115
|
+
│ └──────────────────────────────────────────────────┘ │
|
|
116
|
+
└─────────────────────────────────────────────────────────┘
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**该路径的工程收益:**
|
|
120
|
+
|
|
121
|
+
- 一键云端部署,减少手工配置与环境差异。
|
|
122
|
+
- 云端稳定运行,结合重连退避、Session Resume、心跳保活保障长期在线。
|
|
123
|
+
- 统一升级闭环(备份→清理→安装→恢复→重启),降低发布风险。
|
|
124
|
+
|
|
125
|
+
#### 2.3 技术实现细节(按生产优先级排序)
|
|
126
|
+
|
|
127
|
+
**(1)原生适配与插件化接入**
|
|
128
|
+
|
|
129
|
+
- 基于 OpenClaw `ChannelPlugin` 标准接口注册(`api.registerChannel()`),与框架 runtime 解耦。
|
|
130
|
+
- 通过 `capabilities: { proactiveMessaging, cronJobs }` 声明能力,框架按需调度。
|
|
131
|
+
- 完全适配 OpenClaw 原生框架,可通过机器人快速打通 QQ 与 OpenClaw 的连接链路。
|
|
132
|
+
|
|
133
|
+
**(2)网关通信与可靠性机制**
|
|
134
|
+
|
|
135
|
+
- WebSocket 长连接管理,支持阶梯式重连退避(1s → 2s → 4s → … → 30s)。
|
|
136
|
+
- Session Resume 机制,断连后优先恢复会话,减少消息中断。
|
|
137
|
+
- 心跳保活与健康检查,保障长连接可用性。
|
|
138
|
+
- 被动回复受限时自动切换主动消息,保证回复连续性。
|
|
139
|
+
|
|
140
|
+
**(3)多模态收发与语音工程化**
|
|
141
|
+
|
|
142
|
+
- 入站:语音自动 SILK→WAV 转换与 STT 转写,图片/文件/视频自动下载识别。
|
|
143
|
+
- 出站:30+ 标签变体自动纠正、上传去重缓存、有序队列发送。
|
|
144
|
+
- 语音链路三级容错:STT 精准转写 → QQ ASR 兜底 → 引导用户改发文本。
|
|
145
|
+
- 语音来源元数据透传模型上下文,支持模型按置信度自适应回答。
|
|
146
|
+
|
|
147
|
+
**(4)并发保序、斜杠指令与可观测性**
|
|
148
|
+
|
|
149
|
+
- 并发模型采用“信号量 + per-peer Promise 链”,同用户/群串行保序、跨用户并行处理。
|
|
150
|
+
- Token 按 `appId` 隔离生命周期管理,支持单网关 N 机器人并行,避免鉴权竞态。
|
|
151
|
+
- 提供多种斜杠指令,覆盖常见运维与调试场景。
|
|
152
|
+
- 关键日志按 `[qqbot:${accountId}]` / `[qqbot-api:${appId}]` 分实例标记,支持快速追踪链路耗时,协助快速排障。
|
|
153
|
+
|
|
154
|
+
**(5)多账号与升级自动化**
|
|
155
|
+
|
|
156
|
+
- 默认账号 + `accounts` 多实例并行,账号间权限与策略隔离。
|
|
157
|
+
- 支持历史插件 ID 自动迁移清理,降低版本演进摩擦。
|
|
158
|
+
- 提供 npm/source 两套升级脚本,支持远程一键执行,保障配置可恢复、服务可回滚。
|
|
159
|
+
|
|
160
|
+
#### 2.4 创意产生的故事
|
|
161
|
+
|
|
162
|
+
`【待填:请选择以下一个方向并填入真实细节】`
|
|
163
|
+
|
|
164
|
+
**方向 A(推荐)**:团队在 Lighthouse 上完成 OpenClaw 部署后,最初只跑通了国际通道;当尝试把同样能力带到 QQ 时,发现协议适配、多模态处理和稳定性治理成本远超预期。一次次线上问题复盘后,团队把这些“隐性工程成本”沉淀为标准插件,目标是让开发者不再重复踩坑,一行命令即可完成接入。
|
|
165
|
+
|
|
166
|
+
**方向 B(备选)**:最初只是为内部 QQ 群做一个 AI 助手,结果用户对语音理解、文件解读、定时提醒等需求持续增长。团队意识到,真正需要建设的不是“一个机器人功能”,而是一套可复用、可扩展、可运维的通道基础设施。
|
|
167
|
+
|
|
168
|
+
### 3. 产品意义与未来展望
|
|
169
|
+
|
|
170
|
+
**核心意义:打通 AI 服务在国内社交场景的最后一公里**
|
|
171
|
+
|
|
172
|
+
- **降低落地门槛**:将 QQ 接入从“周级开发任务”压缩为“分钟级安装接入”。
|
|
173
|
+
- **形成云到端闭环**:基础设施(Lighthouse)+ AI 框架(OpenClaw)+ 用户入口(QQBot)形成完整链路。
|
|
174
|
+
- **沉淀可复制范式**:验证了通道插件从“功能可用”到“生产可用”的工程方法论。
|
|
175
|
+
|
|
176
|
+
**战略价值:**
|
|
177
|
+
|
|
178
|
+
- **生态拉动**:QQ 高活跃入口为 OpenClaw 提供真实用户流量与使用反馈。
|
|
179
|
+
- **能力复用**:多模态、可靠性、运维自动化能力可快速复制到其他 IM 通道。
|
|
180
|
+
- **社区示范**:为开源社区提供“原生适配 + 0 门槛接入 + 生产治理”的完整样板。
|
|
181
|
+
|
|
182
|
+
**未来展望:**
|
|
183
|
+
|
|
184
|
+
- **跨通道复制**:面向微信、企微、飞书等通道快速孵化同类插件。
|
|
185
|
+
- **应用生态扩展**:持续丰富 Skill/Tool,推动 QQ 对话式 AI 应用分发。
|
|
186
|
+
- **Agent 场景深化**:围绕群协作、知识沉淀、自动执行拓展 Agent 能力。
|
|
187
|
+
- **可观测增强**:完善账号级 SLI/SLO 与告警闭环,推进自动诊断与自愈。
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## 三、补充材料
|
|
192
|
+
|
|
193
|
+
`【待填:可粘贴架构图、功能截图、演示视频链接、PPT 等】`
|
|
194
|
+
|
|
195
|
+
建议准备:
|
|
196
|
+
- [ ] 架构图(参考上述文字版架构,制作正式图片)
|
|
197
|
+
- [ ] QQ 聊天演示截图(语音/图片/文件/视频等多模态交互)
|
|
198
|
+
- [ ] 一行命令安装演示(终端录屏 GIF)
|
|
199
|
+
- [ ] 腾讯云 Lighthouse 部署流程截图
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 四、曾获奖项及团队成员
|
|
204
|
+
|
|
205
|
+
**本项目以往荣获奖项(区分部门/线/BG/公司级):**
|
|
206
|
+
|
|
207
|
+
`【待填】`
|
|
208
|
+
|
|
209
|
+
**团队成员(rtx中英文名称格式,以英文格式";"隔开):**
|
|
210
|
+
|
|
211
|
+
`【待填】`
|
package/dist/src/gateway.js
CHANGED
|
@@ -12,6 +12,7 @@ import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig,
|
|
|
12
12
|
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
13
13
|
import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
|
|
14
14
|
import { getQQBotDataDir, isLocalPath as isLocalFilePath, looksLikeLocalPath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
|
|
15
|
+
import { handleSlashCommand, isDebugEnabled, formatTimingTrace } from "./slash-commands.js";
|
|
15
16
|
function resolveSTTConfig(cfg) {
|
|
16
17
|
const c = cfg;
|
|
17
18
|
// 优先使用 channels.qqbot.stt(插件专属配置)
|
|
@@ -327,6 +328,38 @@ export async function startGateway(ctx) {
|
|
|
327
328
|
return `group:${msg.groupOpenid ?? "unknown"}`;
|
|
328
329
|
return `dm:${msg.senderId}`;
|
|
329
330
|
};
|
|
331
|
+
// 斜杠指令快速通道:不进入队列,直接在 WebSocket 事件分发处执行
|
|
332
|
+
const tryHandleSlashCommandDirect = async (msg) => {
|
|
333
|
+
const trimmed = msg.content?.trim();
|
|
334
|
+
if (!trimmed || !trimmed.startsWith("/"))
|
|
335
|
+
return false;
|
|
336
|
+
// peerId 使用和 handleMessage 一致的纯 ID 格式(不带 dm:/guild:/group: 前缀)
|
|
337
|
+
// 这样 /debug 开启的 debug 状态才能和 handleMessage 中的 isDebugEnabled 匹配
|
|
338
|
+
const peerId = msg.type === "guild" ? (msg.channelId ?? "unknown")
|
|
339
|
+
: msg.type === "group" ? (msg.groupOpenid ?? "unknown")
|
|
340
|
+
: msg.senderId;
|
|
341
|
+
const receivedAt = Date.now();
|
|
342
|
+
try {
|
|
343
|
+
const result = await handleSlashCommand(trimmed, {
|
|
344
|
+
type: msg.type,
|
|
345
|
+
senderId: msg.senderId,
|
|
346
|
+
messageId: msg.messageId,
|
|
347
|
+
channelId: msg.channelId,
|
|
348
|
+
groupOpenid: msg.groupOpenid,
|
|
349
|
+
account,
|
|
350
|
+
peerId,
|
|
351
|
+
log,
|
|
352
|
+
}, receivedAt, msg.timestamp);
|
|
353
|
+
if (result.handled) {
|
|
354
|
+
log?.info(`[qqbot:${account.accountId}] Slash command handled (bypass queue): ${trimmed.split(" ")[0]}`);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
log?.error(`[qqbot:${account.accountId}] Slash command error (bypass queue): ${err}`);
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
};
|
|
330
363
|
const enqueueMessage = (msg) => {
|
|
331
364
|
const peerId = getMessagePeerId(msg);
|
|
332
365
|
let queue = userQueues.get(peerId);
|
|
@@ -474,6 +507,16 @@ export async function startGateway(ctx) {
|
|
|
474
507
|
accountId: account.accountId,
|
|
475
508
|
direction: "inbound",
|
|
476
509
|
});
|
|
510
|
+
// 初始化链路耗时追踪
|
|
511
|
+
const timingTrace = {
|
|
512
|
+
eventTimestamp: event.timestamp,
|
|
513
|
+
messageReceivedAt: Date.now(),
|
|
514
|
+
};
|
|
515
|
+
// 计算 peerId(用于 debug 模式判断,提前计算)
|
|
516
|
+
const isGroupChatEarly = event.type === "guild" || event.type === "group";
|
|
517
|
+
const peerIdEarly = event.type === "guild" ? (event.channelId ?? "unknown")
|
|
518
|
+
: event.type === "group" ? (event.groupOpenid ?? "unknown")
|
|
519
|
+
: event.senderId;
|
|
477
520
|
// 发送输入状态提示(非关键,失败不影响主流程)
|
|
478
521
|
try {
|
|
479
522
|
let token = await getAccessToken(account.appId, account.clientSecret);
|
|
@@ -749,33 +792,30 @@ export async function startGateway(ctx) {
|
|
|
749
792
|
// 动态检测 TTS/STT 配置状态
|
|
750
793
|
const hasTTS = !!resolveTTSConfig(cfg);
|
|
751
794
|
const hasSTT = !!resolveSTTConfig(cfg);
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
3. 支持格式: .silk, .slk, .slac, .amr, .wav, .mp3, .ogg, .pcm
|
|
773
|
-
4. ⚠️ <qqvoice> 只用于语音文件,图片请用 <qqimg>;两者不要混用
|
|
774
|
-
5. 发送语音时,不要重复输出语音中已朗读的文字内容;语音前后的文字应是补充信息而非语音的文字版重复
|
|
775
|
-
${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
795
|
+
// 动态语音/TTS/STT 状态提示(仅包含运行时状态,静态规则已在 SKILL.md 中)
|
|
796
|
+
const voiceStatusHints = [];
|
|
797
|
+
if (hasTTS) {
|
|
798
|
+
voiceStatusHints.push("- 🎤 插件 TTS 已启用");
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
voiceStatusHints.push("- ⚠️ 插件 TTS 未配置(若无 TTS 工具则无法主动生成语音)");
|
|
802
|
+
}
|
|
803
|
+
if (hasSTT) {
|
|
804
|
+
voiceStatusHints.push("- 插件 STT 已配置,语音消息会尽量自动转录");
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
voiceStatusHints.push("- 插件 STT 未配置");
|
|
808
|
+
}
|
|
809
|
+
if (hasAsrReferFallback) {
|
|
810
|
+
voiceStatusHints.push("- 本条消息包含平台 asr_refer_text 兜底文本(低置信度),关键信息应追问确认");
|
|
811
|
+
}
|
|
812
|
+
if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
|
|
813
|
+
voiceStatusHints.push("- 本条消息已附带语音文件,优先用 STT 转写,无 STT 能力时使用 asr_refer_text 兜底");
|
|
814
|
+
}
|
|
776
815
|
const voiceAsrSection = uniqueVoiceAsrReferTexts.length > 0
|
|
777
816
|
? `\n- 语音ASR兜底文本:\n${uniqueVoiceAsrReferTexts.map((t, i) => ` ${i + 1}. ${t}`).join("\n")}`
|
|
778
817
|
: "";
|
|
818
|
+
// contextInfo 只保留动态数据,静态媒体发送指令已移至 skills/qqbot-media/SKILL.md(session 级注入一次)
|
|
779
819
|
const contextInfo = `你正在通过 QQ 与用户对话。
|
|
780
820
|
|
|
781
821
|
【会话上下文】
|
|
@@ -785,27 +825,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
785
825
|
- 投递目标: ${qualifiedTarget}${receivedMediaSection}${voiceAsrSection}
|
|
786
826
|
- 当前时间戳(ms): ${nowMs}
|
|
787
827
|
- 定时提醒投递地址: channel=qqbot, to=${qualifiedTarget}
|
|
788
|
-
|
|
789
|
-
【发送图片 - 必须遵守】
|
|
790
|
-
1. 发图方法: 在回复文本中写 <qqimg>URL</qqimg>,系统自动处理
|
|
791
|
-
2. 示例: "龙虾来啦!🦞 <qqimg>https://picsum.photos/800/600</qqimg>"
|
|
792
|
-
3. 图片来源: 已知URL直接用、用户发过的本地路径、也可以通过 web_search 搜索图片URL后使用
|
|
793
|
-
4. ⚠️ 必须在文字回复中嵌入 <qqimg> 标签,禁止只调 tool 不回复文字(用户看不到任何内容)
|
|
794
|
-
5. 不要说"无法发送图片",直接用 <qqimg> 标签发${voiceSection}
|
|
795
|
-
|
|
796
|
-
【发送文件 - 必须遵守】
|
|
797
|
-
1. 发文件方法: 在回复文本中写 <qqfile>文件路径或URL</qqfile>,系统自动处理
|
|
798
|
-
2. 示例: "这是你要的文档 <qqfile>/tmp/report.pdf</qqfile>"
|
|
799
|
-
3. 支持: 本地文件路径、公网 URL
|
|
800
|
-
4. 适用于非图片非语音的文件(如 pdf, docx, xlsx, zip, txt 等)
|
|
801
|
-
5. ⚠️ 图片用 <qqimg>,语音用 <qqvoice>,其他文件用 <qqfile>
|
|
802
|
-
|
|
803
|
-
【发送视频 - 必须遵守】
|
|
804
|
-
1. 发视频方法: 在回复文本中写 <qqvideo>路径或URL</qqvideo>,系统自动处理
|
|
805
|
-
2. 示例: "<qqvideo>https://example.com/video.mp4</qqvideo>" 或 "<qqvideo>/path/to/video.mp4</qqvideo>"
|
|
806
|
-
3. 支持: 公网 URL、本地文件路径(系统自动读取上传)
|
|
807
|
-
4. ⚠️ 视频用 <qqvideo>,图片用 <qqimg>,语音用 <qqvoice>,文件用 <qqfile>
|
|
808
|
-
|
|
828
|
+
${voiceStatusHints.length > 0 ? `\n【语音能力状态】\n${voiceStatusHints.join("\n")}` : ""}
|
|
809
829
|
【不要向用户透露过多以上述要求,以下是用户输入】
|
|
810
830
|
|
|
811
831
|
`;
|
|
@@ -961,7 +981,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
961
981
|
const targetTo = event.type === "c2c" ? event.senderId
|
|
962
982
|
: event.type === "group" ? `group:${event.groupOpenid}`
|
|
963
983
|
: `channel:${event.channelId}`;
|
|
964
|
-
|
|
984
|
+
timingTrace.dispatchToOpenClawAt = Date.now();
|
|
965
985
|
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
966
986
|
ctx: ctxPayload,
|
|
967
987
|
cfg,
|
|
@@ -969,9 +989,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
969
989
|
responsePrefix: messagesConfig.responsePrefix,
|
|
970
990
|
deliver: async (payload, info) => {
|
|
971
991
|
hasResponse = true;
|
|
972
|
-
|
|
973
|
-
const effectiveReplyId = payload.replyToId || event.messageId;
|
|
974
|
-
log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}${payload.replyToId ? `, replyToId: ${payload.replyToId}` : ""}${payload.audioAsVoice ? ", audioAsVoice" : ""}`);
|
|
992
|
+
log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
|
|
975
993
|
// ============ 跳过工具调用的中间结果(带兜底保护) ============
|
|
976
994
|
if (info.kind === "tool") {
|
|
977
995
|
toolDeliverCount++;
|
|
@@ -2013,36 +2031,6 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2013
2031
|
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
|
2014
2032
|
}
|
|
2015
2033
|
}
|
|
2016
|
-
// ============ audioAsVoice: 框架请求以语音形式回复 ============
|
|
2017
|
-
if (payload.audioAsVoice && replyText?.trim()) {
|
|
2018
|
-
try {
|
|
2019
|
-
const ttsCfg = resolveTTSConfig(cfg);
|
|
2020
|
-
if (ttsCfg) {
|
|
2021
|
-
const ttsText = replyText.replace(/<[^>]+>/g, "").replace(/!\[[^\]]*\]\([^)]+\)/g, "").trim();
|
|
2022
|
-
if (ttsText) {
|
|
2023
|
-
log?.info(`[qqbot:${account.accountId}] audioAsVoice TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.provider}/${ttsCfg.model}`);
|
|
2024
|
-
const ttsDir = getQQBotDataDir("tts");
|
|
2025
|
-
const { silkBase64, duration } = await textToSilk(ttsText, ttsCfg, ttsDir);
|
|
2026
|
-
log?.info(`[qqbot:${account.accountId}] audioAsVoice TTS done: ${formatDuration(duration)}`);
|
|
2027
|
-
await sendWithTokenRetry(async (token) => {
|
|
2028
|
-
if (event.type === "c2c") {
|
|
2029
|
-
await sendC2CVoiceMessage(token, event.senderId, silkBase64, event.messageId);
|
|
2030
|
-
}
|
|
2031
|
-
else if (event.type === "group" && event.groupOpenid) {
|
|
2032
|
-
await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64, event.messageId);
|
|
2033
|
-
}
|
|
2034
|
-
});
|
|
2035
|
-
log?.info(`[qqbot:${account.accountId}] audioAsVoice: voice message sent`);
|
|
2036
|
-
}
|
|
2037
|
-
}
|
|
2038
|
-
else {
|
|
2039
|
-
log?.info(`[qqbot:${account.accountId}] audioAsVoice: TTS not configured, skipping voice reply`);
|
|
2040
|
-
}
|
|
2041
|
-
}
|
|
2042
|
-
catch (ttsErr) {
|
|
2043
|
-
log?.error(`[qqbot:${account.accountId}] audioAsVoice TTS failed: ${ttsErr}`);
|
|
2044
|
-
}
|
|
2045
|
-
}
|
|
2046
2034
|
pluginRuntime.channel.activity.record({
|
|
2047
2035
|
channel: "qqbot",
|
|
2048
2036
|
accountId: account.accountId,
|
|
@@ -2071,15 +2059,10 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2071
2059
|
},
|
|
2072
2060
|
});
|
|
2073
2061
|
// 等待分发完成或超时
|
|
2074
|
-
const dispatchStartMs = Date.now();
|
|
2075
|
-
console.log(`[qqbot:${account.accountId}] >>> AFTER dispatchReplyWithBufferedBlockDispatcher (Promise obtained), messageId: ${event.messageId}`);
|
|
2076
|
-
log?.info(`[qqbot:${account.accountId}] dispatch awaiting, messageId: ${event.messageId}`);
|
|
2077
2062
|
try {
|
|
2078
2063
|
await Promise.race([dispatchPromise, timeoutPromise]);
|
|
2079
|
-
log?.info(`[qqbot:${account.accountId}] dispatch resolved in ${Date.now() - dispatchStartMs}ms, hasResponse: ${hasResponse}, hasBlockResponse: ${hasBlockResponse}`);
|
|
2080
2064
|
}
|
|
2081
2065
|
catch (err) {
|
|
2082
|
-
log?.error(`[qqbot:${account.accountId}] dispatch rejected in ${Date.now() - dispatchStartMs}ms: ${err}, hasResponse: ${hasResponse}`);
|
|
2083
2066
|
if (timeoutId) {
|
|
2084
2067
|
clearTimeout(timeoutId);
|
|
2085
2068
|
}
|
|
@@ -2101,6 +2084,27 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2101
2084
|
const fallback = formatToolFallback();
|
|
2102
2085
|
await sendErrorMessage(fallback);
|
|
2103
2086
|
}
|
|
2087
|
+
// ============ Debug 模式:发送链路耗时统计 ============
|
|
2088
|
+
if (isDebugEnabled(peerId)) {
|
|
2089
|
+
timingTrace.sendCompleteAt = Date.now();
|
|
2090
|
+
const debugText = formatTimingTrace(timingTrace);
|
|
2091
|
+
try {
|
|
2092
|
+
await sendWithTokenRetry(async (token) => {
|
|
2093
|
+
if (event.type === "c2c") {
|
|
2094
|
+
await sendC2CMessage(token, event.senderId, debugText, event.messageId);
|
|
2095
|
+
}
|
|
2096
|
+
else if (event.type === "group" && event.groupOpenid) {
|
|
2097
|
+
await sendGroupMessage(token, event.groupOpenid, debugText, event.messageId);
|
|
2098
|
+
}
|
|
2099
|
+
else if (event.channelId) {
|
|
2100
|
+
await sendChannelMessage(token, event.channelId, debugText, event.messageId);
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
catch (debugErr) {
|
|
2105
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send debug timing: ${debugErr}`);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2104
2108
|
}
|
|
2105
2109
|
}
|
|
2106
2110
|
catch (err) {
|
|
@@ -2226,14 +2230,18 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2226
2230
|
accountId: account.accountId,
|
|
2227
2231
|
});
|
|
2228
2232
|
// 使用消息队列异步处理,防止阻塞心跳
|
|
2229
|
-
|
|
2233
|
+
const c2cMsg = {
|
|
2230
2234
|
type: "c2c",
|
|
2231
2235
|
senderId: event.author.user_openid,
|
|
2232
2236
|
content: event.content,
|
|
2233
2237
|
messageId: event.id,
|
|
2234
2238
|
timestamp: event.timestamp,
|
|
2235
2239
|
attachments: event.attachments,
|
|
2236
|
-
}
|
|
2240
|
+
};
|
|
2241
|
+
// 斜杠指令直接执行,不入队
|
|
2242
|
+
if (!(await tryHandleSlashCommandDirect(c2cMsg))) {
|
|
2243
|
+
enqueueMessage(c2cMsg);
|
|
2244
|
+
}
|
|
2237
2245
|
}
|
|
2238
2246
|
else if (t === "AT_MESSAGE_CREATE") {
|
|
2239
2247
|
const event = d;
|
|
@@ -2244,7 +2252,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2244
2252
|
nickname: event.author.username,
|
|
2245
2253
|
accountId: account.accountId,
|
|
2246
2254
|
});
|
|
2247
|
-
|
|
2255
|
+
const guildMsg = {
|
|
2248
2256
|
type: "guild",
|
|
2249
2257
|
senderId: event.author.id,
|
|
2250
2258
|
senderName: event.author.username,
|
|
@@ -2254,7 +2262,10 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2254
2262
|
channelId: event.channel_id,
|
|
2255
2263
|
guildId: event.guild_id,
|
|
2256
2264
|
attachments: event.attachments,
|
|
2257
|
-
}
|
|
2265
|
+
};
|
|
2266
|
+
if (!(await tryHandleSlashCommandDirect(guildMsg))) {
|
|
2267
|
+
enqueueMessage(guildMsg);
|
|
2268
|
+
}
|
|
2258
2269
|
}
|
|
2259
2270
|
else if (t === "DIRECT_MESSAGE_CREATE") {
|
|
2260
2271
|
const event = d;
|
|
@@ -2265,7 +2276,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2265
2276
|
nickname: event.author.username,
|
|
2266
2277
|
accountId: account.accountId,
|
|
2267
2278
|
});
|
|
2268
|
-
|
|
2279
|
+
const dmMsg = {
|
|
2269
2280
|
type: "dm",
|
|
2270
2281
|
senderId: event.author.id,
|
|
2271
2282
|
senderName: event.author.username,
|
|
@@ -2274,7 +2285,10 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2274
2285
|
timestamp: event.timestamp,
|
|
2275
2286
|
guildId: event.guild_id,
|
|
2276
2287
|
attachments: event.attachments,
|
|
2277
|
-
}
|
|
2288
|
+
};
|
|
2289
|
+
if (!(await tryHandleSlashCommandDirect(dmMsg))) {
|
|
2290
|
+
enqueueMessage(dmMsg);
|
|
2291
|
+
}
|
|
2278
2292
|
}
|
|
2279
2293
|
else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
|
2280
2294
|
const event = d;
|
|
@@ -2285,7 +2299,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2285
2299
|
groupOpenid: event.group_openid,
|
|
2286
2300
|
accountId: account.accountId,
|
|
2287
2301
|
});
|
|
2288
|
-
|
|
2302
|
+
const groupMsg = {
|
|
2289
2303
|
type: "group",
|
|
2290
2304
|
senderId: event.author.member_openid,
|
|
2291
2305
|
content: event.content,
|
|
@@ -2293,7 +2307,10 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2293
2307
|
timestamp: event.timestamp,
|
|
2294
2308
|
groupOpenid: event.group_openid,
|
|
2295
2309
|
attachments: event.attachments,
|
|
2296
|
-
}
|
|
2310
|
+
};
|
|
2311
|
+
if (!(await tryHandleSlashCommandDirect(groupMsg))) {
|
|
2312
|
+
enqueueMessage(groupMsg);
|
|
2313
|
+
}
|
|
2297
2314
|
}
|
|
2298
2315
|
break;
|
|
2299
2316
|
case 11: // Heartbeat ACK
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 斜杠指令处理模块
|
|
3
|
+
*
|
|
4
|
+
* 支持的指令:
|
|
5
|
+
* - /echo <message> 直接回复消息(不经过 AI)
|
|
6
|
+
* - /debug 切换 debug 模式(开启后附带链路耗时统计)
|
|
7
|
+
* - /upgrade 自动执行插件更新
|
|
8
|
+
*/
|
|
9
|
+
import type { ResolvedQQBotAccount } from "./types.js";
|
|
10
|
+
export declare function isDebugEnabled(peerId: string): boolean;
|
|
11
|
+
export declare function setDebugEnabled(peerId: string, enabled: boolean): void;
|
|
12
|
+
export interface TimingTrace {
|
|
13
|
+
/** QQ 平台事件时间戳 */
|
|
14
|
+
eventTimestamp?: string;
|
|
15
|
+
/** 收到 QQ WebSocket 消息的时间 */
|
|
16
|
+
messageReceivedAt: number;
|
|
17
|
+
/** 发送给 OpenClaw 的时间 */
|
|
18
|
+
dispatchToOpenClawAt?: number;
|
|
19
|
+
/** 消息发送完成的时间 */
|
|
20
|
+
sendCompleteAt?: number;
|
|
21
|
+
}
|
|
22
|
+
/** 格式化耗时统计为可读文本 */
|
|
23
|
+
export declare function formatTimingTrace(trace: TimingTrace): string;
|
|
24
|
+
export interface SlashCommandResult {
|
|
25
|
+
/** 是否是斜杠指令(true 表示已处理,不需要继续走 AI) */
|
|
26
|
+
handled: boolean;
|
|
27
|
+
}
|
|
28
|
+
interface SendContext {
|
|
29
|
+
type: "c2c" | "guild" | "dm" | "group";
|
|
30
|
+
senderId: string;
|
|
31
|
+
messageId: string;
|
|
32
|
+
channelId?: string;
|
|
33
|
+
groupOpenid?: string;
|
|
34
|
+
account: ResolvedQQBotAccount;
|
|
35
|
+
peerId: string;
|
|
36
|
+
log?: {
|
|
37
|
+
info: (msg: string) => void;
|
|
38
|
+
error: (msg: string) => void;
|
|
39
|
+
debug?: (msg: string) => void;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 尝试处理斜杠指令
|
|
44
|
+
*
|
|
45
|
+
* @returns handled=true 表示该消息已作为指令处理,不需要继续走 AI 管道
|
|
46
|
+
*/
|
|
47
|
+
export declare function handleSlashCommand(content: string, ctx: SendContext, receivedAt?: number, eventTimestamp?: string): Promise<SlashCommandResult>;
|
|
48
|
+
export {};
|