@tencent-connect/openclaw-qqbot 1.5.7 → 1.6.0-alpha.2
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 +9 -2
- package/README.zh.md +7 -2
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.sh +85 -115
- package/scripts/upgrade-via-source.sh +203 -35
- package/skills/qqbot-cron/SKILL.md +46 -423
- package/skills/qqbot-media/SKILL.md +29 -182
- package/src/api.ts +16 -5
- package/src/channel.ts +6 -7
- package/src/gateway.ts +510 -525
- package/src/image-server.ts +72 -10
- package/src/openclaw-plugin-sdk.d.ts +1 -1
- package/src/outbound.ts +571 -611
- package/src/ref-index-store.ts +1 -1
- package/src/slash-commands.ts +425 -0
- package/src/types.ts +18 -1
- package/src/update-checker.ts +102 -0
- package/src/user-messages.ts +73 -0
- package/src/utils/audio-convert.ts +69 -4
- package/src/utils/media-tags.ts +46 -4
- 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 +0 -211
- package/dist/index.d.ts +0 -17
- package/dist/index.js +0 -22
- package/dist/src/api.d.ts +0 -138
- package/dist/src/api.js +0 -525
- package/dist/src/channel.d.ts +0 -3
- package/dist/src/channel.js +0 -337
- package/dist/src/config.d.ts +0 -25
- package/dist/src/config.js +0 -161
- package/dist/src/gateway.d.ts +0 -18
- package/dist/src/gateway.js +0 -2468
- package/dist/src/image-server.d.ts +0 -62
- package/dist/src/image-server.js +0 -401
- package/dist/src/known-users.d.ts +0 -100
- package/dist/src/known-users.js +0 -263
- package/dist/src/onboarding.d.ts +0 -10
- package/dist/src/onboarding.js +0 -203
- package/dist/src/outbound.d.ts +0 -150
- package/dist/src/outbound.js +0 -1175
- package/dist/src/proactive.d.ts +0 -170
- package/dist/src/proactive.js +0 -399
- package/dist/src/runtime.d.ts +0 -3
- package/dist/src/runtime.js +0 -10
- package/dist/src/session-store.d.ts +0 -52
- package/dist/src/session-store.js +0 -254
- package/dist/src/slash-commands.d.ts +0 -48
- package/dist/src/slash-commands.js +0 -212
- package/dist/src/types.d.ts +0 -146
- package/dist/src/types.js +0 -1
- package/dist/src/utils/audio-convert.d.ts +0 -73
- package/dist/src/utils/audio-convert.js +0 -645
- package/dist/src/utils/file-utils.d.ts +0 -46
- package/dist/src/utils/file-utils.js +0 -107
- package/dist/src/utils/image-size.d.ts +0 -51
- package/dist/src/utils/image-size.js +0 -234
- package/dist/src/utils/media-tags.d.ts +0 -14
- package/dist/src/utils/media-tags.js +0 -120
- package/dist/src/utils/payload.d.ts +0 -112
- package/dist/src/utils/payload.js +0 -186
- package/dist/src/utils/platform.d.ts +0 -126
- package/dist/src/utils/platform.js +0 -358
- package/dist/src/utils/upload-cache.d.ts +0 -34
- package/dist/src/utils/upload-cache.js +0 -93
|
@@ -139,11 +139,50 @@ export function formatDuration(durationMs: number): string {
|
|
|
139
139
|
return remainSeconds > 0 ? `${minutes}分${remainSeconds}秒` : `${minutes}分钟`;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
export function isAudioFile(filePath: string): boolean {
|
|
142
|
+
export function isAudioFile(filePath: string, mimeType?: string): boolean {
|
|
143
|
+
// MIME 优先判断(解决无扩展名或扩展名不匹配的问题)
|
|
144
|
+
if (mimeType) {
|
|
145
|
+
if (mimeType === "voice" || mimeType.startsWith("audio/")) return true;
|
|
146
|
+
}
|
|
143
147
|
const ext = path.extname(filePath).toLowerCase();
|
|
144
148
|
return [".silk", ".slk", ".amr", ".wav", ".mp3", ".ogg", ".opus", ".aac", ".flac", ".m4a", ".wma", ".pcm"].includes(ext);
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
/** QQ 平台原生支持的语音 MIME 类型(不需要转码) */
|
|
152
|
+
const QQ_NATIVE_VOICE_MIMES = new Set([
|
|
153
|
+
"audio/silk", "audio/amr", "audio/wav", "audio/wave",
|
|
154
|
+
"audio/x-wav", "audio/mpeg", "audio/mp3",
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
/** QQ 平台原生支持的语音扩展名(不需要转码) */
|
|
158
|
+
const QQ_NATIVE_VOICE_EXTS = new Set([
|
|
159
|
+
".silk", ".slk", ".amr", ".wav", ".mp3",
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 判断语音是否需要转码(参考企微 wecom-app 的 shouldTranscodeWecomVoice)
|
|
164
|
+
*
|
|
165
|
+
* QQ Bot API 原生支持 WAV/MP3/SILK 三种格式,其他格式需要先转码。
|
|
166
|
+
* 使用 MIME + 扩展名双重判断,避免仅靠扩展名导致误判。
|
|
167
|
+
*
|
|
168
|
+
* @param filePath 音频文件路径
|
|
169
|
+
* @param mimeType 可选的 MIME 类型
|
|
170
|
+
* @returns true 表示需要转码,false 表示可以直传
|
|
171
|
+
*/
|
|
172
|
+
export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean {
|
|
173
|
+
// MIME 优先:如果 MIME 是 QQ 原生支持的格式,不需要转码
|
|
174
|
+
if (mimeType && QQ_NATIVE_VOICE_MIMES.has(mimeType.toLowerCase())) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
// 扩展名判断
|
|
178
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
179
|
+
if (QQ_NATIVE_VOICE_EXTS.has(ext)) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
// 是音频但不是原生格式 → 需要转码
|
|
183
|
+
return isAudioFile(filePath, mimeType);
|
|
184
|
+
}
|
|
185
|
+
|
|
147
186
|
// ============ TTS(文字转语音)============
|
|
148
187
|
|
|
149
188
|
export interface TTSConfig {
|
|
@@ -482,24 +521,40 @@ export async function audioFileToSilkBase64(filePath: string, directUploadFormat
|
|
|
482
521
|
* 等待文件就绪(轮询直到文件出现且大小稳定)
|
|
483
522
|
* 用于 TTS 生成后等待文件写入完成
|
|
484
523
|
*
|
|
524
|
+
* 优化策略:
|
|
525
|
+
* - 文件出现后如果持续 0 字节超过 emptyGiveUpMs(默认 10s),快速失败
|
|
526
|
+
* - 文件未出现超过 noFileGiveUpMs(默认 15s),快速失败
|
|
527
|
+
* - 整体超时 timeoutMs 作为最终兜底
|
|
528
|
+
*
|
|
485
529
|
* @param filePath 文件路径
|
|
486
|
-
* @param timeoutMs 最大等待时间(默认
|
|
530
|
+
* @param timeoutMs 最大等待时间(默认 30 秒)
|
|
487
531
|
* @param pollMs 轮询间隔(默认 500ms)
|
|
488
532
|
* @returns 文件大小(字节),超时或文件始终为空返回 0
|
|
489
533
|
*/
|
|
490
|
-
export async function waitForFile(
|
|
534
|
+
export async function waitForFile(
|
|
535
|
+
filePath: string,
|
|
536
|
+
timeoutMs: number = 30000,
|
|
537
|
+
pollMs: number = 500,
|
|
538
|
+
): Promise<number> {
|
|
491
539
|
const start = Date.now();
|
|
492
540
|
let lastSize = -1;
|
|
493
541
|
let stableCount = 0;
|
|
494
542
|
let fileExists = false;
|
|
543
|
+
let fileAppearedAt = 0; // 文件首次出现时间
|
|
495
544
|
let pollCount = 0;
|
|
496
545
|
|
|
546
|
+
// 0 字节文件放弃等待阈值:文件出现后持续空文件超过此时间则快速失败
|
|
547
|
+
const emptyGiveUpMs = 10000;
|
|
548
|
+
// 文件始终不出现的放弃阈值
|
|
549
|
+
const noFileGiveUpMs = 15000;
|
|
550
|
+
|
|
497
551
|
while (Date.now() - start < timeoutMs) {
|
|
498
552
|
pollCount++;
|
|
499
553
|
try {
|
|
500
554
|
const stat = fs.statSync(filePath);
|
|
501
555
|
if (!fileExists) {
|
|
502
556
|
fileExists = true;
|
|
557
|
+
fileAppearedAt = Date.now();
|
|
503
558
|
console.log(`[audio-convert] waitForFile: file appeared (${stat.size} bytes, after ${Date.now() - start}ms): ${path.basename(filePath)}`);
|
|
504
559
|
}
|
|
505
560
|
if (stat.size > 0) {
|
|
@@ -513,9 +568,19 @@ export async function waitForFile(filePath: string, timeoutMs: number = 120000,
|
|
|
513
568
|
stableCount = 0;
|
|
514
569
|
}
|
|
515
570
|
lastSize = stat.size;
|
|
571
|
+
} else {
|
|
572
|
+
// 文件存在但 0 字节:检查是否已超过空文件等待阈值
|
|
573
|
+
if (Date.now() - fileAppearedAt > emptyGiveUpMs) {
|
|
574
|
+
console.error(`[audio-convert] waitForFile: file still empty after ${emptyGiveUpMs}ms, giving up: ${path.basename(filePath)}`);
|
|
575
|
+
return 0;
|
|
576
|
+
}
|
|
516
577
|
}
|
|
517
578
|
} catch {
|
|
518
|
-
//
|
|
579
|
+
// 文件不存在:检查是否已超过无文件等待阈值
|
|
580
|
+
if (!fileExists && Date.now() - start > noFileGiveUpMs) {
|
|
581
|
+
console.error(`[audio-convert] waitForFile: file never appeared after ${noFileGiveUpMs}ms, giving up: ${path.basename(filePath)}`);
|
|
582
|
+
return 0;
|
|
583
|
+
}
|
|
519
584
|
}
|
|
520
585
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
521
586
|
}
|
package/src/utils/media-tags.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { expandTilde } from "./platform.js";
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile"] as const;
|
|
9
|
+
// 标准标签名(qqmedia = 统一标签,系统根据文件扩展名自动路由)
|
|
10
|
+
const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"] as const;
|
|
11
11
|
|
|
12
12
|
// 开头标签别名映射(key 全部小写)
|
|
13
13
|
const TAG_ALIASES: Record<string, typeof VALID_TAGS[number]> = {
|
|
@@ -42,6 +42,16 @@ const TAG_ALIASES: Record<string, typeof VALID_TAGS[number]> = {
|
|
|
42
42
|
"file": "qqfile",
|
|
43
43
|
"doc": "qqfile",
|
|
44
44
|
"document": "qqfile",
|
|
45
|
+
// ---- qqmedia 变体(统一标签,根据扩展名自动路由) ----
|
|
46
|
+
"qq_media": "qqmedia",
|
|
47
|
+
"media": "qqmedia",
|
|
48
|
+
"attachment": "qqmedia",
|
|
49
|
+
"attach": "qqmedia",
|
|
50
|
+
"qqattachment": "qqmedia",
|
|
51
|
+
"qq_attachment": "qqmedia",
|
|
52
|
+
"qqsend": "qqmedia",
|
|
53
|
+
"qq_send": "qqmedia",
|
|
54
|
+
"send": "qqmedia",
|
|
45
55
|
};
|
|
46
56
|
|
|
47
57
|
// 构建所有可识别的标签名列表(标准名 + 别名)
|
|
@@ -51,6 +61,26 @@ ALL_TAG_NAMES.sort((a, b) => b.length - a.length);
|
|
|
51
61
|
|
|
52
62
|
const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|");
|
|
53
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 自闭合属性语法的正则:
|
|
66
|
+
* <qqmedia file="/path/to/file.png" />
|
|
67
|
+
* <qqimg src="/path" />
|
|
68
|
+
* <image file="..." />
|
|
69
|
+
* 支持 file= / src= / path= / url= 属性名,引号可选
|
|
70
|
+
*/
|
|
71
|
+
const SELF_CLOSING_TAG_REGEX = new RegExp(
|
|
72
|
+
"`?" +
|
|
73
|
+
"[<<<]\\s*(" + TAG_NAME_PATTERN + ")" +
|
|
74
|
+
"\\s+(?:file|src|path|url)\\s*=\\s*" +
|
|
75
|
+
"[\"']?" +
|
|
76
|
+
"([^\"'/>>>]+?)" +
|
|
77
|
+
"[\"']?" +
|
|
78
|
+
"\\s*/?" +
|
|
79
|
+
"\\s*[>>>]" +
|
|
80
|
+
"`?",
|
|
81
|
+
"gi"
|
|
82
|
+
);
|
|
83
|
+
|
|
54
84
|
/**
|
|
55
85
|
* 构建一个宽容的正则,能匹配各种畸形标签写法:
|
|
56
86
|
*
|
|
@@ -63,6 +93,7 @@ const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|");
|
|
|
63
93
|
* 6. 中文尖括号:<qqimg>url</qqimg> 或 <qqimg>url</qqimg>
|
|
64
94
|
* 7. 多余引号包裹路径:<qqimg>"path"</qqimg>
|
|
65
95
|
* 8. Markdown 代码块包裹:`<qqimg>path</qqimg>`
|
|
96
|
+
* 9. 自闭合属性语法:<qqmedia file="/path" /> (由 SELF_CLOSING_TAG_REGEX 处理)
|
|
66
97
|
*/
|
|
67
98
|
const FUZZY_MEDIA_TAG_REGEX = new RegExp(
|
|
68
99
|
// 可选 Markdown 行内代码反引号
|
|
@@ -117,12 +148,23 @@ const MULTILINE_TAG_CLEANUP = new RegExp(
|
|
|
117
148
|
* @returns 修正后的文本(如果没有匹配到任何标签则原样返回)
|
|
118
149
|
*/
|
|
119
150
|
export function normalizeMediaTags(text: string): string {
|
|
120
|
-
//
|
|
121
|
-
|
|
151
|
+
// 第 0 步:将自闭合属性语法转换为标准包裹语法
|
|
152
|
+
// <qqmedia file="/path/to/file.png" /> → <qqmedia>/path/to/file.png</qqmedia>
|
|
153
|
+
let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, (_match, rawTag: string, content: string) => {
|
|
154
|
+
const tag = resolveTagName(rawTag);
|
|
155
|
+
const trimmed = content.trim();
|
|
156
|
+
if (!trimmed) return _match;
|
|
157
|
+
const expanded = expandTilde(trimmed);
|
|
158
|
+
return `<${tag}>${expanded}</${tag}>`;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// 第 1 步:将标签内部的换行/回车/制表符压缩为空格
|
|
162
|
+
cleaned = cleaned.replace(MULTILINE_TAG_CLEANUP, (_m, open: string, body: string, close: string) => {
|
|
122
163
|
const flat = body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ");
|
|
123
164
|
return open + flat + close;
|
|
124
165
|
});
|
|
125
166
|
|
|
167
|
+
// 第 2 步:将各种畸形标签统一为标准格式
|
|
126
168
|
return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, (_match, rawTag: string, content: string) => {
|
|
127
169
|
const tag = resolveTagName(rawTag);
|
|
128
170
|
const trimmed = content.trim();
|
|
@@ -1,211 +0,0 @@
|
|
|
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/index.d.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
declare const plugin: {
|
|
3
|
-
id: string;
|
|
4
|
-
name: string;
|
|
5
|
-
description: string;
|
|
6
|
-
configSchema: unknown;
|
|
7
|
-
register(api: OpenClawPluginApi): void;
|
|
8
|
-
};
|
|
9
|
-
export default plugin;
|
|
10
|
-
export { qqbotPlugin } from "./src/channel.js";
|
|
11
|
-
export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js";
|
|
12
|
-
export { qqbotOnboardingAdapter } from "./src/onboarding.js";
|
|
13
|
-
export * from "./src/types.js";
|
|
14
|
-
export * from "./src/api.js";
|
|
15
|
-
export * from "./src/config.js";
|
|
16
|
-
export * from "./src/gateway.js";
|
|
17
|
-
export * from "./src/outbound.js";
|
package/dist/index.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
2
|
-
import { qqbotPlugin } from "./src/channel.js";
|
|
3
|
-
import { setQQBotRuntime } from "./src/runtime.js";
|
|
4
|
-
const plugin = {
|
|
5
|
-
id: "openclaw-qqbot",
|
|
6
|
-
name: "QQ Bot",
|
|
7
|
-
description: "QQ Bot channel plugin",
|
|
8
|
-
configSchema: emptyPluginConfigSchema(),
|
|
9
|
-
register(api) {
|
|
10
|
-
setQQBotRuntime(api.runtime);
|
|
11
|
-
api.registerChannel({ plugin: qqbotPlugin });
|
|
12
|
-
},
|
|
13
|
-
};
|
|
14
|
-
export default plugin;
|
|
15
|
-
export { qqbotPlugin } from "./src/channel.js";
|
|
16
|
-
export { setQQBotRuntime, getQQBotRuntime } from "./src/runtime.js";
|
|
17
|
-
export { qqbotOnboardingAdapter } from "./src/onboarding.js";
|
|
18
|
-
export * from "./src/types.js";
|
|
19
|
-
export * from "./src/api.js";
|
|
20
|
-
export * from "./src/config.js";
|
|
21
|
-
export * from "./src/gateway.js";
|
|
22
|
-
export * from "./src/outbound.js";
|
package/dist/src/api.d.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ Bot API 鉴权和请求封装
|
|
3
|
-
* [修复版] 已重构为支持多实例并发,消除全局变量冲突
|
|
4
|
-
*/
|
|
5
|
-
/**
|
|
6
|
-
* 初始化 API 配置
|
|
7
|
-
* @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
|
|
8
|
-
*/
|
|
9
|
-
export declare function initApiConfig(options: {
|
|
10
|
-
markdownSupport?: boolean;
|
|
11
|
-
}): void;
|
|
12
|
-
/**
|
|
13
|
-
* 获取当前是否支持 markdown
|
|
14
|
-
*/
|
|
15
|
-
export declare function isMarkdownSupport(): boolean;
|
|
16
|
-
/**
|
|
17
|
-
* 获取 AccessToken(带缓存 + singleflight 并发安全)
|
|
18
|
-
*
|
|
19
|
-
* 使用 singleflight 模式:当多个请求同时发现 Token 过期时,
|
|
20
|
-
* 只有第一个请求会真正去获取新 Token,其他请求复用同一个 Promise。
|
|
21
|
-
*
|
|
22
|
-
* 按 appId 隔离,支持多机器人并发请求。
|
|
23
|
-
*/
|
|
24
|
-
export declare function getAccessToken(appId: string, clientSecret: string): Promise<string>;
|
|
25
|
-
/**
|
|
26
|
-
* 清除 Token 缓存
|
|
27
|
-
* @param appId 选填。如果有,只清空特定账号的缓存;如果没有,清空所有账号。
|
|
28
|
-
*/
|
|
29
|
-
export declare function clearTokenCache(appId?: string): void;
|
|
30
|
-
/**
|
|
31
|
-
* 获取 Token 缓存状态(用于监控)
|
|
32
|
-
*/
|
|
33
|
-
export declare function getTokenStatus(appId: string): {
|
|
34
|
-
status: "valid" | "expired" | "refreshing" | "none";
|
|
35
|
-
expiresAt: number | null;
|
|
36
|
-
};
|
|
37
|
-
/**
|
|
38
|
-
* 获取全局唯一的消息序号(范围 0 ~ 65535)
|
|
39
|
-
* 使用毫秒级时间戳低位 + 随机数异或混合,无状态,避免碰撞
|
|
40
|
-
*/
|
|
41
|
-
export declare function getNextMsgSeq(_msgId: string): number;
|
|
42
|
-
/**
|
|
43
|
-
* API 请求封装
|
|
44
|
-
*/
|
|
45
|
-
export declare function apiRequest<T = unknown>(accessToken: string, method: string, path: string, body?: unknown, timeoutMs?: number): Promise<T>;
|
|
46
|
-
export declare function getGatewayUrl(accessToken: string): Promise<string>;
|
|
47
|
-
export interface MessageResponse {
|
|
48
|
-
id: string;
|
|
49
|
-
timestamp: number | string;
|
|
50
|
-
}
|
|
51
|
-
export declare function sendC2CMessage(accessToken: string, openid: string, content: string, msgId?: string): Promise<MessageResponse>;
|
|
52
|
-
export declare function sendC2CInputNotify(accessToken: string, openid: string, msgId?: string, inputSecond?: number): Promise<void>;
|
|
53
|
-
export declare function sendChannelMessage(accessToken: string, channelId: string, content: string, msgId?: string): Promise<{
|
|
54
|
-
id: string;
|
|
55
|
-
timestamp: string;
|
|
56
|
-
}>;
|
|
57
|
-
export declare function sendGroupMessage(accessToken: string, groupOpenid: string, content: string, msgId?: string): Promise<MessageResponse>;
|
|
58
|
-
export declare function sendProactiveC2CMessage(accessToken: string, openid: string, content: string): Promise<{
|
|
59
|
-
id: string;
|
|
60
|
-
timestamp: number;
|
|
61
|
-
}>;
|
|
62
|
-
export declare function sendProactiveGroupMessage(accessToken: string, groupOpenid: string, content: string): Promise<{
|
|
63
|
-
id: string;
|
|
64
|
-
timestamp: string;
|
|
65
|
-
}>;
|
|
66
|
-
export declare enum MediaFileType {
|
|
67
|
-
IMAGE = 1,
|
|
68
|
-
VIDEO = 2,
|
|
69
|
-
VOICE = 3,
|
|
70
|
-
FILE = 4
|
|
71
|
-
}
|
|
72
|
-
export interface UploadMediaResponse {
|
|
73
|
-
file_uuid: string;
|
|
74
|
-
file_info: string;
|
|
75
|
-
ttl: number;
|
|
76
|
-
id?: string;
|
|
77
|
-
}
|
|
78
|
-
export declare function uploadC2CMedia(accessToken: string, openid: string, fileType: MediaFileType, url?: string, fileData?: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
|
|
79
|
-
export declare function uploadGroupMedia(accessToken: string, groupOpenid: string, fileType: MediaFileType, url?: string, fileData?: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
|
|
80
|
-
export declare function sendC2CMediaMessage(accessToken: string, openid: string, fileInfo: string, msgId?: string, content?: string): Promise<{
|
|
81
|
-
id: string;
|
|
82
|
-
timestamp: number;
|
|
83
|
-
}>;
|
|
84
|
-
export declare function sendGroupMediaMessage(accessToken: string, groupOpenid: string, fileInfo: string, msgId?: string, content?: string): Promise<{
|
|
85
|
-
id: string;
|
|
86
|
-
timestamp: string;
|
|
87
|
-
}>;
|
|
88
|
-
export declare function sendC2CImageMessage(accessToken: string, openid: string, imageUrl: string, msgId?: string, content?: string): Promise<{
|
|
89
|
-
id: string;
|
|
90
|
-
timestamp: number;
|
|
91
|
-
}>;
|
|
92
|
-
export declare function sendGroupImageMessage(accessToken: string, groupOpenid: string, imageUrl: string, msgId?: string, content?: string): Promise<{
|
|
93
|
-
id: string;
|
|
94
|
-
timestamp: string;
|
|
95
|
-
}>;
|
|
96
|
-
export declare function sendC2CVoiceMessage(accessToken: string, openid: string, voiceBase64: string, msgId?: string): Promise<{
|
|
97
|
-
id: string;
|
|
98
|
-
timestamp: number;
|
|
99
|
-
}>;
|
|
100
|
-
export declare function sendGroupVoiceMessage(accessToken: string, groupOpenid: string, voiceBase64: string, msgId?: string): Promise<{
|
|
101
|
-
id: string;
|
|
102
|
-
timestamp: string;
|
|
103
|
-
}>;
|
|
104
|
-
export declare function sendC2CFileMessage(accessToken: string, openid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string): Promise<{
|
|
105
|
-
id: string;
|
|
106
|
-
timestamp: number;
|
|
107
|
-
}>;
|
|
108
|
-
export declare function sendGroupFileMessage(accessToken: string, groupOpenid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string): Promise<{
|
|
109
|
-
id: string;
|
|
110
|
-
timestamp: string;
|
|
111
|
-
}>;
|
|
112
|
-
export declare function sendC2CVideoMessage(accessToken: string, openid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string): Promise<{
|
|
113
|
-
id: string;
|
|
114
|
-
timestamp: number;
|
|
115
|
-
}>;
|
|
116
|
-
export declare function sendGroupVideoMessage(accessToken: string, groupOpenid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string): Promise<{
|
|
117
|
-
id: string;
|
|
118
|
-
timestamp: string;
|
|
119
|
-
}>;
|
|
120
|
-
interface BackgroundTokenRefreshOptions {
|
|
121
|
-
refreshAheadMs?: number;
|
|
122
|
-
randomOffsetMs?: number;
|
|
123
|
-
minRefreshIntervalMs?: number;
|
|
124
|
-
retryDelayMs?: number;
|
|
125
|
-
log?: {
|
|
126
|
-
info: (msg: string) => void;
|
|
127
|
-
error: (msg: string) => void;
|
|
128
|
-
debug?: (msg: string) => void;
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
export declare function startBackgroundTokenRefresh(appId: string, clientSecret: string, options?: BackgroundTokenRefreshOptions): void;
|
|
132
|
-
/**
|
|
133
|
-
* 停止后台 Token 刷新
|
|
134
|
-
* @param appId 选填。如果有,仅停止该账号的定时刷新。
|
|
135
|
-
*/
|
|
136
|
-
export declare function stopBackgroundTokenRefresh(appId?: string): void;
|
|
137
|
-
export declare function isBackgroundTokenRefreshRunning(appId?: string): boolean;
|
|
138
|
-
export {};
|