@tencent-connect/openclaw-qqbot 1.6.0-alpha.qqchannel → 1.6.0
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 +57 -15
- package/README.zh.md +57 -15
- package/clawdbot.plugin.json +1 -1
- package/dist/index.js +0 -2
- package/dist/src/api.d.ts +0 -9
- package/dist/src/api.js +8 -14
- package/dist/src/gateway.js +180 -52
- package/dist/src/slash-commands.d.ts +9 -1
- package/dist/src/slash-commands.js +134 -132
- package/dist/src/types.d.ts +2 -2
- package/dist/src/update-checker.d.ts +16 -41
- package/dist/src/update-checker.js +105 -230
- package/index.ts +0 -2
- package/moltbot.plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/cleanup-legacy-plugins.sh +1 -1
- package/scripts/upgrade-via-npm.sh +84 -3
- package/scripts/upgrade-via-source.sh +114 -71
- package/skills/qqbot-cron/SKILL.md +8 -0
- package/src/api.ts +8 -20
- package/src/gateway.ts +187 -48
- package/src/openclaw-plugin-sdk.d.ts +0 -39
- package/src/slash-commands.ts +144 -139
- package/src/types.ts +2 -2
- package/src/update-checker.ts +109 -267
- package/dist/scripts/upgrade-via-npm.sh +0 -168
- package/dist/src/message-queue.d.ts +0 -267
- package/dist/src/message-queue.js +0 -558
- package/dist/src/tools/channel.d.ts +0 -16
- package/dist/src/tools/channel.js +0 -234
- package/skills/qqbot-channel/SKILL.md +0 -263
- package/skills/qqbot-channel/references/api_references.md +0 -521
- package/src/tools/channel.ts +0 -281
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
**Connect your AI assistant to QQ — private chat, group chat, and rich media, all in one plugin.**
|
|
12
12
|
|
|
13
|
-
### 🚀 Current Version: `v1.
|
|
13
|
+
### 🚀 Current Version: `v1.6.0`
|
|
14
14
|
|
|
15
15
|
[](./LICENSE)
|
|
16
16
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -99,7 +99,7 @@ If your main model supports vision (e.g. Tencent Hunyuan `hunyuan-vision`), AI c
|
|
|
99
99
|
>
|
|
100
100
|
> **QQBot**: Here you go! 🐱
|
|
101
101
|
|
|
102
|
-
AI
|
|
102
|
+
AI can send images directly. Supports local paths and URLs. Formats: jpg/png/gif/webp/bmp.
|
|
103
103
|
|
|
104
104
|
<img width="360" src="docs/images/4645f2b3a20822b7f8d6664a708529eb_720.jpg" alt="Image Generation Demo" />
|
|
105
105
|
|
|
@@ -109,7 +109,7 @@ AI sends images via `<qqimg>path</qqimg>`. Supports local paths and URLs. Format
|
|
|
109
109
|
>
|
|
110
110
|
> **QQBot**: *(sends a voice message)*
|
|
111
111
|
|
|
112
|
-
AI
|
|
112
|
+
AI can send voice messages directly. Formats: mp3/wav/silk/ogg. No ffmpeg required.
|
|
113
113
|
|
|
114
114
|
<img width="360" src="docs/images/21dce8bfc553ce23d1bd1b270e9c516c.jpg" alt="TTS Voice Demo" />
|
|
115
115
|
|
|
@@ -129,7 +129,7 @@ This capability depends on OpenClaw cron scheduling and proactive messaging. If
|
|
|
129
129
|
>
|
|
130
130
|
> **QQBot**: *(sends a .txt file)*
|
|
131
131
|
|
|
132
|
-
AI
|
|
132
|
+
AI can send files directly. Any format, up to 20MB.
|
|
133
133
|
|
|
134
134
|
<img width="360" src="docs/images/17cada70df90185d45a2d6dd36e92f2f_720.jpg" alt="File Sending Demo" />
|
|
135
135
|
|
|
@@ -139,21 +139,63 @@ AI sends files via `<qqfile>path</qqfile>`. Any format, up to 20MB.
|
|
|
139
139
|
>
|
|
140
140
|
> **QQBot**: *(sends a video)*
|
|
141
141
|
|
|
142
|
-
AI
|
|
142
|
+
AI can send videos directly. Supports local files and URLs.
|
|
143
143
|
|
|
144
144
|
<img width="360" src="docs/images/85d03b8a216f267ab7b2aee248a18a41_720.jpg" alt="Video Sending Demo" />
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
> **Under the hood:** Upload dedup caching, ordered queue delivery, and multi-layer audio format fallback.
|
|
147
147
|
|
|
148
|
-
|
|
149
|
-
|-----|-----------|-------|
|
|
150
|
-
| `<qqimg>path</qqimg>` | Send | jpg/png/gif/webp/bmp, local path or URL |
|
|
151
|
-
| `<qqvoice>path</qqvoice>` | Send | mp3/wav/silk/ogg, no ffmpeg required |
|
|
152
|
-
| `<qqfile>path</qqfile>` | Send | Any format, up to 20MB |
|
|
153
|
-
| `<qqvideo>path</qqvideo>` | Send | Local path or URL |
|
|
154
|
-
| Voice / File / Image | Receive | Auto-transcribe (STT), auto-download, or vision analysis |
|
|
148
|
+
### 🛠️ Slash Commands
|
|
155
149
|
|
|
156
|
-
|
|
150
|
+
The plugin provides built-in slash commands that are intercepted before reaching the AI queue, giving instant responses for diagnostics and management.
|
|
151
|
+
|
|
152
|
+
#### `/qqbot-ping` — Latency Test
|
|
153
|
+
|
|
154
|
+
> **You**: `/qqbot-ping`
|
|
155
|
+
>
|
|
156
|
+
> **QQBot**: ✅ pong!⏱ Latency: 602ms (network: 602ms, plugin: 0ms)
|
|
157
|
+
|
|
158
|
+
Measures end-to-end latency from QQ server push to plugin response, broken down into network transport and plugin processing time.
|
|
159
|
+
|
|
160
|
+
<img width="360" src="docs/images/slash-ping.jpg" alt="Ping Demo" />
|
|
161
|
+
|
|
162
|
+
#### `/qqbot-version` — Version Info
|
|
163
|
+
|
|
164
|
+
> **You**: `/qqbot-version`
|
|
165
|
+
>
|
|
166
|
+
> **QQBot**: 🦞 Framework: OpenClaw 2026.3.13 (61d171a) / 🤖 Plugin: v1.6.0 / 🌟 GitHub repo
|
|
167
|
+
|
|
168
|
+
Shows framework version, plugin version, and a direct link to the official repository.
|
|
169
|
+
|
|
170
|
+
<img width="360" src="docs/images/slash-version.jpg" alt="Version Demo" />
|
|
171
|
+
|
|
172
|
+
#### `/qqbot-help` — Command List
|
|
173
|
+
|
|
174
|
+
> **You**: `/qqbot-help`
|
|
175
|
+
>
|
|
176
|
+
> **QQBot**: Lists all available slash commands with clickable shortcuts.
|
|
177
|
+
|
|
178
|
+
<img width="360" src="docs/images/slash-help.jpg" alt="Help Demo" />
|
|
179
|
+
|
|
180
|
+
#### `/qqbot-upgrade` — Upgrade Guide
|
|
181
|
+
|
|
182
|
+
> **You**: `/qqbot-upgrade`
|
|
183
|
+
>
|
|
184
|
+
> **QQBot**: 📌 Current version / ✅ Up to date / ⬆️ Upgrade guide / 🌟 GitHub repo
|
|
185
|
+
|
|
186
|
+
Shows current version, update status, upgrade guide link, and official repository.
|
|
187
|
+
|
|
188
|
+
<img width="360" src="docs/images/slash-upgrade.jpg" alt="Upgrade Demo" />
|
|
189
|
+
|
|
190
|
+
#### `/qqbot-logs` — Log Export
|
|
191
|
+
|
|
192
|
+
> **You**: `/qqbot-logs`
|
|
193
|
+
>
|
|
194
|
+
> **QQBot**: 📋 Logs packaged (~2000 lines), sending file... *(sends a .txt file)*
|
|
195
|
+
|
|
196
|
+
Exports the last ~2000 lines of gateway logs as a file for quick troubleshooting.
|
|
197
|
+
|
|
198
|
+
<img width="360" src="docs/images/slash-logs.jpg" alt="Logs Demo" />
|
|
157
199
|
|
|
158
200
|
---
|
|
159
201
|
|
|
@@ -379,7 +421,7 @@ STT supports two-level configuration with priority fallback:
|
|
|
379
421
|
- `provider` — references a key in `models.providers` to inherit `baseUrl` and `apiKey`
|
|
380
422
|
- `voice` — voice variant
|
|
381
423
|
- Set `enabled: false` to disable (default: `true`)
|
|
382
|
-
- When configured, AI can
|
|
424
|
+
- When configured, AI can generate and send voice messages
|
|
383
425
|
|
|
384
426
|
---
|
|
385
427
|
|
package/README.zh.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
**让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
|
|
11
11
|
|
|
12
|
-
### 🚀 当前版本: `v1.
|
|
12
|
+
### 🚀 当前版本: `v1.6.0`
|
|
13
13
|
|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -94,7 +94,7 @@ QQ 的引用事件通常只携带索引键(如 `REFIDX_xxx`),不直接返
|
|
|
94
94
|
>
|
|
95
95
|
> **QQBot**:画好啦!一只可爱的简笔小猫咪🐱🎨
|
|
96
96
|
|
|
97
|
-
AI
|
|
97
|
+
AI 可直接发送图片,支持本地文件路径和网络 URL。格式:jpg/png/gif/webp/bmp。
|
|
98
98
|
|
|
99
99
|
<img width="360" src="docs/images/4645f2b3a20822b7f8d6664a708529eb_720.jpg" alt="发图片演示" />
|
|
100
100
|
|
|
@@ -104,7 +104,7 @@ AI 通过 `<qqimg>路径</qqimg>` 发送图片,支持本地文件路径和网
|
|
|
104
104
|
>
|
|
105
105
|
> **QQBot**:*(发送一条语音消息)*
|
|
106
106
|
|
|
107
|
-
AI
|
|
107
|
+
AI 可直接发送语音消息。格式:mp3/wav/silk/ogg,无需安装 ffmpeg。
|
|
108
108
|
|
|
109
109
|
<img width="360" src="docs/images/21dce8bfc553ce23d1bd1b270e9c516c.jpg" alt="发语音演示" />
|
|
110
110
|
|
|
@@ -124,7 +124,7 @@ AI 通过 `<qqvoice>路径</qqvoice>` 发送语音消息。格式:mp3/wav/silk
|
|
|
124
124
|
>
|
|
125
125
|
> **QQBot**:*(发送 .txt 文件)*
|
|
126
126
|
|
|
127
|
-
AI
|
|
127
|
+
AI 可直接发送文件。任意格式,最大 20MB。
|
|
128
128
|
|
|
129
129
|
<img width="360" src="docs/images/17cada70df90185d45a2d6dd36e92f2f_720.jpg" alt="发文件演示" />
|
|
130
130
|
|
|
@@ -134,21 +134,63 @@ AI 通过 `<qqfile>路径</qqfile>` 发送文件。任意格式,最大 20MB。
|
|
|
134
134
|
>
|
|
135
135
|
> **QQBot**:*(发送视频)*
|
|
136
136
|
|
|
137
|
-
AI
|
|
137
|
+
AI 可直接发送视频,支持本地文件和公网 URL。
|
|
138
138
|
|
|
139
139
|
<img width="360" src="docs/images/85d03b8a216f267ab7b2aee248a18a41_720.jpg" alt="发视频演示" />
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
> **底层细节:** 上传去重缓存、有序队列发送、音频格式多层降级。
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|------|------|------|
|
|
145
|
-
| `<qqimg>路径</qqimg>` | 发送 | jpg/png/gif/webp/bmp,本地路径或 URL |
|
|
146
|
-
| `<qqvoice>路径</qqvoice>` | 发送 | mp3/wav/silk/ogg,无需 ffmpeg |
|
|
147
|
-
| `<qqfile>路径</qqfile>` | 发送 | 任意格式,最大 20MB |
|
|
148
|
-
| `<qqvideo>路径</qqvideo>` | 发送 | 本地路径或 URL |
|
|
149
|
-
| 语音 / 文件 / 图片 | 接收 | 自动转录(STT)、自动下载、或视觉分析 |
|
|
143
|
+
### 🛠️ 斜杠指令
|
|
150
144
|
|
|
151
|
-
|
|
145
|
+
插件内置一组斜杠指令,在消息进入 AI 队列前拦截处理,即时响应,用于诊断和管理。
|
|
146
|
+
|
|
147
|
+
#### `/qqbot-ping` — 延迟测试
|
|
148
|
+
|
|
149
|
+
> **你**:`/qqbot-ping`
|
|
150
|
+
>
|
|
151
|
+
> **QQBot**:✅ pong!⏱ 延迟: 602ms(网络传输: 602ms,插件处理: 0ms)
|
|
152
|
+
|
|
153
|
+
测量从 QQ 服务器推送到插件响应的端到端延迟,细分网络传输和插件处理两段耗时。
|
|
154
|
+
|
|
155
|
+
<img width="360" src="docs/images/slash-ping.jpg" alt="Ping 演示" />
|
|
156
|
+
|
|
157
|
+
#### `/qqbot-version` — 版本信息
|
|
158
|
+
|
|
159
|
+
> **你**:`/qqbot-version`
|
|
160
|
+
>
|
|
161
|
+
> **QQBot**:🦞框架版本:OpenClaw 2026.3.13 (61d171a) / 🤖QQBot 插件版本:v1.6.0 / 🌟官方 GitHub 仓库
|
|
162
|
+
|
|
163
|
+
一目了然查看框架版本、插件版本,并可直接跳转官方仓库。
|
|
164
|
+
|
|
165
|
+
<img width="360" src="docs/images/slash-version.jpg" alt="Version 演示" />
|
|
166
|
+
|
|
167
|
+
#### `/qqbot-help` — 指令列表
|
|
168
|
+
|
|
169
|
+
> **你**:`/qqbot-help`
|
|
170
|
+
>
|
|
171
|
+
> **QQBot**:列出所有可用的斜杠指令及说明,指令可点击快速输入。
|
|
172
|
+
|
|
173
|
+
<img width="360" src="docs/images/slash-help.jpg" alt="Help 演示" />
|
|
174
|
+
|
|
175
|
+
#### `/qqbot-upgrade` — 升级指引
|
|
176
|
+
|
|
177
|
+
> **你**:`/qqbot-upgrade`
|
|
178
|
+
>
|
|
179
|
+
> **QQBot**:📌当前版本 / ✅当前已是最新版本 / ⬆️升级指引 / 🌟官方 GitHub 仓库
|
|
180
|
+
|
|
181
|
+
显示当前版本、更新状态、升级文档链接及官方仓库入口。
|
|
182
|
+
|
|
183
|
+
<img width="360" src="docs/images/slash-upgrade.jpg" alt="Upgrade 演示" />
|
|
184
|
+
|
|
185
|
+
#### `/qqbot-logs` — 日志导出
|
|
186
|
+
|
|
187
|
+
> **你**:`/qqbot-logs`
|
|
188
|
+
>
|
|
189
|
+
> **QQBot**:📋 日志已打包(约 2000 行),正在发送文件… *(发送 .txt 文件)*
|
|
190
|
+
|
|
191
|
+
导出最近约 2000 行网关日志为文件,方便快速排查问题。
|
|
192
|
+
|
|
193
|
+
<img width="360" src="docs/images/slash-logs.jpg" alt="Logs 演示" />
|
|
152
194
|
|
|
153
195
|
---
|
|
154
196
|
|
|
@@ -374,7 +416,7 @@ STT 支持两级配置,按优先级查找:
|
|
|
374
416
|
- `provider` — 引用 `models.providers` 中的 key,自动继承 `baseUrl` 和 `apiKey`
|
|
375
417
|
- `voice` — 语音音色
|
|
376
418
|
- 设置 `enabled: false` 可禁用(默认:`true`)
|
|
377
|
-
- 配置后,AI
|
|
419
|
+
- 配置后,AI 可生成并发送语音消息
|
|
378
420
|
|
|
379
421
|
---
|
|
380
422
|
|
package/clawdbot.plugin.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "OpenClaw QQ Bot",
|
|
4
4
|
"description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
|
|
5
5
|
"channels": ["qqbot"],
|
|
6
|
-
"skills": ["skills/qqbot-
|
|
6
|
+
"skills": ["skills/qqbot-cron", "skills/qqbot-media"],
|
|
7
7
|
"capabilities": {
|
|
8
8
|
"proactiveMessaging": true,
|
|
9
9
|
"cronJobs": true
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
2
2
|
import { qqbotPlugin } from "./src/channel.js";
|
|
3
3
|
import { setQQBotRuntime } from "./src/runtime.js";
|
|
4
|
-
import { registerChannelTool } from "./src/tools/channel.js";
|
|
5
4
|
const plugin = {
|
|
6
5
|
id: "openclaw-qqbot",
|
|
7
6
|
name: "QQ Bot",
|
|
@@ -10,7 +9,6 @@ const plugin = {
|
|
|
10
9
|
register(api) {
|
|
11
10
|
setQQBotRuntime(api.runtime);
|
|
12
11
|
api.registerChannel({ plugin: qqbotPlugin });
|
|
13
|
-
registerChannelTool(api);
|
|
14
12
|
},
|
|
15
13
|
};
|
|
16
14
|
export default plugin;
|
package/dist/src/api.d.ts
CHANGED
|
@@ -81,15 +81,6 @@ export declare function sendChannelMessage(accessToken: string, channelId: strin
|
|
|
81
81
|
id: string;
|
|
82
82
|
timestamp: string;
|
|
83
83
|
}>;
|
|
84
|
-
/**
|
|
85
|
-
* 发送频道私信消息
|
|
86
|
-
* @param guildId - 私信会话的 guild_id(由 DIRECT_MESSAGE_CREATE 事件提供)
|
|
87
|
-
* @param msgId - 被动回复时必填
|
|
88
|
-
*/
|
|
89
|
-
export declare function sendDmMessage(accessToken: string, guildId: string, content: string, msgId?: string): Promise<{
|
|
90
|
-
id: string;
|
|
91
|
-
timestamp: string;
|
|
92
|
-
}>;
|
|
93
84
|
export declare function sendGroupMessage(accessToken: string, groupOpenid: string, content: string, msgId?: string): Promise<MessageResponse>;
|
|
94
85
|
export declare function sendProactiveC2CMessage(accessToken: string, openid: string, content: string): Promise<MessageResponse>;
|
|
95
86
|
export declare function sendProactiveGroupMessage(accessToken: string, groupOpenid: string, content: string): Promise<{
|
package/dist/src/api.js
CHANGED
|
@@ -58,8 +58,12 @@ const tokenFetchPromises = new Map();
|
|
|
58
58
|
export async function getAccessToken(appId, clientSecret) {
|
|
59
59
|
const normalizedAppId = String(appId).trim();
|
|
60
60
|
const cachedToken = tokenCacheMap.get(normalizedAppId);
|
|
61
|
-
//
|
|
62
|
-
|
|
61
|
+
// 检查缓存:未过期时复用
|
|
62
|
+
// 提前刷新阈值:取 expiresIn 的 1/3 和 5 分钟的较小值,避免短有效期 token 永远被判定过期
|
|
63
|
+
const REFRESH_AHEAD_MS = cachedToken
|
|
64
|
+
? Math.min(5 * 60 * 1000, (cachedToken.expiresAt - Date.now()) / 3)
|
|
65
|
+
: 0;
|
|
66
|
+
if (cachedToken && Date.now() < cachedToken.expiresAt - REFRESH_AHEAD_MS) {
|
|
63
67
|
return cachedToken.token;
|
|
64
68
|
}
|
|
65
69
|
// Singleflight: 如果当前 appId 已有进行中的 Token 获取请求,复用它
|
|
@@ -159,7 +163,8 @@ export function getTokenStatus(appId) {
|
|
|
159
163
|
if (!cached) {
|
|
160
164
|
return { status: "none", expiresAt: null };
|
|
161
165
|
}
|
|
162
|
-
const
|
|
166
|
+
const remaining = cached.expiresAt - Date.now();
|
|
167
|
+
const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3);
|
|
163
168
|
return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt };
|
|
164
169
|
}
|
|
165
170
|
/**
|
|
@@ -335,17 +340,6 @@ export async function sendChannelMessage(accessToken, channelId, content, msgId)
|
|
|
335
340
|
...(msgId ? { msg_id: msgId } : {}),
|
|
336
341
|
});
|
|
337
342
|
}
|
|
338
|
-
/**
|
|
339
|
-
* 发送频道私信消息
|
|
340
|
-
* @param guildId - 私信会话的 guild_id(由 DIRECT_MESSAGE_CREATE 事件提供)
|
|
341
|
-
* @param msgId - 被动回复时必填
|
|
342
|
-
*/
|
|
343
|
-
export async function sendDmMessage(accessToken, guildId, content, msgId) {
|
|
344
|
-
return apiRequest(accessToken, "POST", `/dms/${guildId}/messages`, {
|
|
345
|
-
content,
|
|
346
|
-
...(msgId ? { msg_id: msgId } : {}),
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
343
|
export async function sendGroupMessage(accessToken, groupOpenid, content, msgId) {
|
|
350
344
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
|
351
345
|
const body = buildMessageBody(content, msgId, msgSeq);
|
package/dist/src/gateway.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
|
-
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, sendProactiveC2CMessage
|
|
4
|
+
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, sendProactiveC2CMessage } from "./api.js";
|
|
5
5
|
import { loadSession, saveSession, clearSession } from "./session-store.js";
|
|
6
6
|
import { recordKnownUser, flushKnownUsers, listKnownUsers } from "./known-users.js";
|
|
7
7
|
import { getQQBotRuntime } from "./runtime.js";
|
|
8
8
|
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex } from "./ref-index-store.js";
|
|
9
|
-
import { matchSlashCommand } from "./slash-commands.js";
|
|
10
|
-
import { triggerUpdateCheck } from "./update-checker.js";
|
|
9
|
+
import { matchSlashCommand, getPluginVersion } from "./slash-commands.js";
|
|
10
|
+
import { triggerUpdateCheck, onUpdateFound, formatUpdateNotice } from "./update-checker.js";
|
|
11
11
|
import { startImageServer, isImageServerRunning, downloadFile } from "./image-server.js";
|
|
12
12
|
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
|
|
13
13
|
import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload } from "./utils/payload.js";
|
|
@@ -258,6 +258,47 @@ async function ensureImageServer(log, publicBaseUrl) {
|
|
|
258
258
|
return null;
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
|
+
// ============ 启动问候语(首次安装/版本更新 vs 普通重启) ============
|
|
262
|
+
// 模块级变量:进程生命周期内只有首次为 true
|
|
263
|
+
// 区分 gateway restart(进程重启)和 health-monitor 断线重连
|
|
264
|
+
let isFirstReadyGlobal = true;
|
|
265
|
+
const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
|
|
266
|
+
/**
|
|
267
|
+
* 判断是否为首次安装或版本更新,返回对应的问候语。
|
|
268
|
+
* - 首次安装 / 版本变更 → "Haha,我的'灵魂'已上线,随时等你吩咐。"
|
|
269
|
+
* - 普通重启(同版本) → null(不发送)
|
|
270
|
+
*/
|
|
271
|
+
function getStartupGreeting() {
|
|
272
|
+
const currentVersion = getPluginVersion();
|
|
273
|
+
let isFirstOrUpdated = true;
|
|
274
|
+
try {
|
|
275
|
+
if (fs.existsSync(STARTUP_MARKER_FILE)) {
|
|
276
|
+
const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8"));
|
|
277
|
+
if (data.version === currentVersion) {
|
|
278
|
+
isFirstOrUpdated = false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// 文件损坏或不存在,视为首次
|
|
284
|
+
}
|
|
285
|
+
// 普通重启(同版本)不发送问候语
|
|
286
|
+
if (!isFirstOrUpdated) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
// 更新 marker 文件
|
|
290
|
+
try {
|
|
291
|
+
fs.writeFileSync(STARTUP_MARKER_FILE, JSON.stringify({
|
|
292
|
+
version: currentVersion,
|
|
293
|
+
startedAt: new Date().toISOString(),
|
|
294
|
+
greetedAt: new Date().toISOString(),
|
|
295
|
+
}) + "\n");
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// ignore
|
|
299
|
+
}
|
|
300
|
+
return `Haha,我的'灵魂'已上线,随时等你吩咐。`;
|
|
301
|
+
}
|
|
261
302
|
/**
|
|
262
303
|
* 启动 Gateway WebSocket 连接(带自动重连)
|
|
263
304
|
* 支持流式消息发送
|
|
@@ -276,6 +317,34 @@ export async function startGateway(ctx) {
|
|
|
276
317
|
}
|
|
277
318
|
// 后台版本检查(detached 子进程,零阻塞)
|
|
278
319
|
triggerUpdateCheck(log);
|
|
320
|
+
// 注册新版本通知回调:仅发给管理员,带防抖
|
|
321
|
+
let lastUpdateNotifyAt = 0;
|
|
322
|
+
const UPDATE_NOTIFY_DEBOUNCE_MS = 5 * 60 * 1000; // 5 分钟内不重复通知
|
|
323
|
+
onUpdateFound(async (info) => {
|
|
324
|
+
try {
|
|
325
|
+
// 防抖:避免短时间内重复推送
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
if (now - lastUpdateNotifyAt < UPDATE_NOTIFY_DEBOUNCE_MS) {
|
|
328
|
+
log?.debug?.(`[qqbot:${account.accountId}] Update notification debounced`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const notice = formatUpdateNotice(info);
|
|
332
|
+
if (!notice)
|
|
333
|
+
return;
|
|
334
|
+
const adminId = resolveAdminOpenId();
|
|
335
|
+
if (!adminId) {
|
|
336
|
+
log?.debug?.(`[qqbot:${account.accountId}] No admin or known user to send update notification`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
340
|
+
await sendProactiveC2CMessage(token, adminId, notice);
|
|
341
|
+
lastUpdateNotifyAt = Date.now();
|
|
342
|
+
log?.info(`[qqbot:${account.accountId}] Sent update notification to admin: ${adminId}`);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
log?.debug?.(`[qqbot:${account.accountId}] Failed to send update notification to admin: ${err}`);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
279
348
|
// 初始化 API 配置(markdown 支持)
|
|
280
349
|
initApiConfig({
|
|
281
350
|
markdownSupport: account.markdownSupport,
|
|
@@ -348,7 +417,76 @@ export async function startGateway(ctx) {
|
|
|
348
417
|
let isConnecting = false; // 防止并发连接
|
|
349
418
|
let reconnectTimer = null; // 重连定时器
|
|
350
419
|
let shouldRefreshToken = false; // 下次连接是否需要刷新 token
|
|
351
|
-
|
|
420
|
+
// 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
|
|
421
|
+
// health-monitor 重连不会重新初始化为 true
|
|
422
|
+
const ADMIN_MARKER_FILE = path.join(getQQBotDataDir("data"), `admin-${account.accountId}.json`);
|
|
423
|
+
/**
|
|
424
|
+
* 读取已持久化的管理员 openid
|
|
425
|
+
*/
|
|
426
|
+
const loadAdminOpenId = () => {
|
|
427
|
+
try {
|
|
428
|
+
if (fs.existsSync(ADMIN_MARKER_FILE)) {
|
|
429
|
+
const data = JSON.parse(fs.readFileSync(ADMIN_MARKER_FILE, "utf8"));
|
|
430
|
+
if (data.openid)
|
|
431
|
+
return data.openid;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch { /* 文件损坏视为无 */ }
|
|
435
|
+
return undefined;
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* 将管理员 openid 持久化到文件
|
|
439
|
+
*/
|
|
440
|
+
const saveAdminOpenId = (openid) => {
|
|
441
|
+
try {
|
|
442
|
+
fs.writeFileSync(ADMIN_MARKER_FILE, JSON.stringify({ openid, savedAt: new Date().toISOString() }));
|
|
443
|
+
}
|
|
444
|
+
catch { /* ignore */ }
|
|
445
|
+
};
|
|
446
|
+
/**
|
|
447
|
+
* 解析管理员 openid:
|
|
448
|
+
* 1. 优先读持久化文件(稳定)
|
|
449
|
+
* 2. fallback 取第一个私聊用户,并写入文件锁定
|
|
450
|
+
*/
|
|
451
|
+
const resolveAdminOpenId = () => {
|
|
452
|
+
const saved = loadAdminOpenId();
|
|
453
|
+
if (saved)
|
|
454
|
+
return saved;
|
|
455
|
+
const first = listKnownUsers({ accountId: account.accountId, type: "c2c", sortBy: "firstSeenAt", sortOrder: "asc", limit: 1 })[0]?.openid;
|
|
456
|
+
if (first) {
|
|
457
|
+
saveAdminOpenId(first);
|
|
458
|
+
log?.info(`[qqbot:${account.accountId}] Auto-detected admin openid: ${first} (persisted)`);
|
|
459
|
+
}
|
|
460
|
+
return first;
|
|
461
|
+
};
|
|
462
|
+
/** 异步发送启动问候语(仅发给管理员) */
|
|
463
|
+
const sendStartupGreetings = (trigger) => {
|
|
464
|
+
(async () => {
|
|
465
|
+
try {
|
|
466
|
+
const greeting = getStartupGreeting();
|
|
467
|
+
if (!greeting) {
|
|
468
|
+
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (debounced, trigger=${trigger})`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const adminId = resolveAdminOpenId();
|
|
472
|
+
if (!adminId) {
|
|
473
|
+
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (no admin or known user)`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
log?.info(`[qqbot:${account.accountId}] Sending startup greeting to admin (trigger=${trigger}): "${greeting}"`);
|
|
477
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
478
|
+
const GREETING_TIMEOUT_MS = 10_000;
|
|
479
|
+
await Promise.race([
|
|
480
|
+
sendProactiveC2CMessage(token, adminId, greeting),
|
|
481
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
|
|
482
|
+
]);
|
|
483
|
+
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to admin: ${adminId}`);
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send startup greeting: ${err}`);
|
|
487
|
+
}
|
|
488
|
+
})();
|
|
489
|
+
};
|
|
352
490
|
// ============ P1-2: 尝试从持久化存储恢复 Session ============
|
|
353
491
|
// 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
|
|
354
492
|
const savedSession = loadSession(account.accountId, account.appId);
|
|
@@ -488,18 +626,41 @@ export async function startGateway(ctx) {
|
|
|
488
626
|
// 命中插件级指令,直接回复
|
|
489
627
|
log?.info(`[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`);
|
|
490
628
|
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
629
|
+
// 解析回复:纯文本 or 带文件的结果
|
|
630
|
+
const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
|
|
631
|
+
const replyText = isFileResult ? reply.text : reply;
|
|
632
|
+
const replyFile = isFileResult ? reply.filePath : null;
|
|
633
|
+
// 先发送文本回复
|
|
491
634
|
if (msg.type === "c2c") {
|
|
492
|
-
await sendC2CMessage(token, msg.senderId,
|
|
635
|
+
await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
|
|
493
636
|
}
|
|
494
637
|
else if (msg.type === "group" && msg.groupOpenid) {
|
|
495
|
-
await sendGroupMessage(token, msg.groupOpenid,
|
|
638
|
+
await sendGroupMessage(token, msg.groupOpenid, replyText, msg.messageId);
|
|
496
639
|
}
|
|
497
640
|
else if (msg.channelId) {
|
|
498
|
-
await sendChannelMessage(token, msg.channelId,
|
|
641
|
+
await sendChannelMessage(token, msg.channelId, replyText, msg.messageId);
|
|
499
642
|
}
|
|
500
643
|
else if (msg.type === "dm") {
|
|
501
|
-
|
|
502
|
-
|
|
644
|
+
await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
|
|
645
|
+
}
|
|
646
|
+
// 如果有文件需要发送
|
|
647
|
+
if (replyFile) {
|
|
648
|
+
try {
|
|
649
|
+
const targetType = msg.type === "group" ? "group" : msg.type === "c2c" || msg.type === "dm" ? "c2c" : "channel";
|
|
650
|
+
const targetId = msg.type === "group" ? (msg.groupOpenid || msg.senderId) : msg.type === "c2c" || msg.type === "dm" ? msg.senderId : (msg.channelId || msg.senderId);
|
|
651
|
+
const mediaCtx = {
|
|
652
|
+
targetType,
|
|
653
|
+
targetId,
|
|
654
|
+
account,
|
|
655
|
+
replyToId: msg.messageId,
|
|
656
|
+
logPrefix: `[qqbot:${account.accountId}]`,
|
|
657
|
+
};
|
|
658
|
+
await sendDocument(mediaCtx, replyFile);
|
|
659
|
+
log?.info(`[qqbot:${account.accountId}] Slash command file sent: ${replyFile}`);
|
|
660
|
+
}
|
|
661
|
+
catch (fileErr) {
|
|
662
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send slash command file: ${fileErr}`);
|
|
663
|
+
}
|
|
503
664
|
}
|
|
504
665
|
}
|
|
505
666
|
catch (err) {
|
|
@@ -924,10 +1085,10 @@ export async function startGateway(ctx) {
|
|
|
924
1085
|
const staticParts = [
|
|
925
1086
|
`[QQBot] to=${qualifiedTarget}`,
|
|
926
1087
|
];
|
|
927
|
-
// TTS 能力声明:仅在启用时告知 AI
|
|
1088
|
+
// TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
|
|
928
1089
|
// STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
|
|
929
1090
|
if (hasTTS)
|
|
930
|
-
staticParts.push("
|
|
1091
|
+
staticParts.push("语音合成已启用");
|
|
931
1092
|
const staticInstruction = staticParts.join(" | ");
|
|
932
1093
|
// 静态指引作为 systemPrompts 的首项注入
|
|
933
1094
|
systemPrompts.unshift(staticInstruction);
|
|
@@ -2196,54 +2357,21 @@ export async function startGateway(ctx) {
|
|
|
2196
2357
|
onReady?.(d);
|
|
2197
2358
|
// 仅 startGateway 后的首次 READY 才发送上线通知
|
|
2198
2359
|
// ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
|
|
2199
|
-
if (!
|
|
2360
|
+
if (!isFirstReadyGlobal) {
|
|
2200
2361
|
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
|
|
2201
2362
|
}
|
|
2202
2363
|
else {
|
|
2203
|
-
|
|
2204
|
-
(
|
|
2205
|
-
try {
|
|
2206
|
-
const greeting = `Haha,我的'灵魂'已上线,随时等你吩咐。`;
|
|
2207
|
-
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
2208
|
-
const users = listKnownUsers({ accountId: account.accountId, type: "c2c" });
|
|
2209
|
-
for (const user of users) {
|
|
2210
|
-
try {
|
|
2211
|
-
await sendProactiveC2CMessage(token, user.openid, greeting);
|
|
2212
|
-
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to c2c:${user.openid}`);
|
|
2213
|
-
}
|
|
2214
|
-
catch (err) {
|
|
2215
|
-
log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to c2c:${user.openid}: ${err}`);
|
|
2216
|
-
}
|
|
2217
|
-
// 避免频率限制
|
|
2218
|
-
await new Promise(r => setTimeout(r, 500));
|
|
2219
|
-
}
|
|
2220
|
-
const groups = listKnownUsers({ accountId: account.accountId, type: "group" });
|
|
2221
|
-
// 群组去重(同一群只发一次)
|
|
2222
|
-
const sentGroups = new Set();
|
|
2223
|
-
for (const user of groups) {
|
|
2224
|
-
const gid = user.groupOpenid;
|
|
2225
|
-
if (!gid || sentGroups.has(gid))
|
|
2226
|
-
continue;
|
|
2227
|
-
sentGroups.add(gid);
|
|
2228
|
-
try {
|
|
2229
|
-
await sendProactiveGroupMessage(token, gid, greeting);
|
|
2230
|
-
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to group:${gid}`);
|
|
2231
|
-
}
|
|
2232
|
-
catch (err) {
|
|
2233
|
-
log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to group:${gid}: ${err}`);
|
|
2234
|
-
}
|
|
2235
|
-
await new Promise(r => setTimeout(r, 500));
|
|
2236
|
-
}
|
|
2237
|
-
log?.info(`[qqbot:${account.accountId}] Startup greetings sent (${users.length} c2c, ${sentGroups.size} groups)`);
|
|
2238
|
-
}
|
|
2239
|
-
catch (err) {
|
|
2240
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send startup greetings: ${err}`);
|
|
2241
|
-
}
|
|
2242
|
-
})();
|
|
2364
|
+
isFirstReadyGlobal = false;
|
|
2365
|
+
sendStartupGreetings("READY");
|
|
2243
2366
|
} // end isFirstReady
|
|
2244
2367
|
}
|
|
2245
2368
|
else if (t === "RESUMED") {
|
|
2246
2369
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
2370
|
+
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
2371
|
+
if (isFirstReadyGlobal) {
|
|
2372
|
+
isFirstReadyGlobal = false;
|
|
2373
|
+
sendStartupGreetings("RESUMED");
|
|
2374
|
+
}
|
|
2247
2375
|
// P1-2: 更新 Session 连接时间
|
|
2248
2376
|
if (sessionId) {
|
|
2249
2377
|
saveSession({
|
|
@@ -51,11 +51,19 @@ export interface QueueSnapshot {
|
|
|
51
51
|
/** 当前发送者在队列中的待处理消息数 */
|
|
52
52
|
senderPending: number;
|
|
53
53
|
}
|
|
54
|
+
/** 斜杠指令返回值:文本、带文件的结果、或 null(不处理) */
|
|
55
|
+
export type SlashCommandResult = string | SlashCommandFileResult | null;
|
|
56
|
+
/** 带文件的指令结果(先回复文本,再发送文件) */
|
|
57
|
+
export interface SlashCommandFileResult {
|
|
58
|
+
text: string;
|
|
59
|
+
/** 要发送的本地文件路径 */
|
|
60
|
+
filePath: string;
|
|
61
|
+
}
|
|
54
62
|
/**
|
|
55
63
|
* 尝试匹配并执行插件级斜杠指令
|
|
56
64
|
*
|
|
57
65
|
* @returns 回复文本(匹配成功),null(不匹配,应入队正常处理)
|
|
58
66
|
*/
|
|
59
|
-
export declare function matchSlashCommand(ctx: SlashCommandContext): Promise<
|
|
67
|
+
export declare function matchSlashCommand(ctx: SlashCommandContext): Promise<SlashCommandResult>;
|
|
60
68
|
/** 获取插件版本号(供外部使用) */
|
|
61
69
|
export declare function getPluginVersion(): string;
|