@tencent-connect/openclaw-qqbot 1.6.0-alpha.3 → 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/dist/src/api.js +8 -3
- package/dist/src/gateway.js +90 -47
- package/dist/src/slash-commands.js +16 -23
- package/dist/src/types.d.ts +1 -1
- package/dist/src/update-checker.d.ts +6 -0
- package/dist/src/update-checker.js +14 -0
- package/package.json +1 -1
- package/scripts/cleanup-legacy-plugins.sh +1 -1
- package/scripts/upgrade-via-npm.sh +84 -3
- package/skills/qqbot-cron/SKILL.md +8 -0
- package/src/api.ts +8 -3
- package/src/gateway.ts +89 -44
- package/src/slash-commands.ts +19 -25
- package/src/types.ts +1 -1
- package/src/update-checker.ts +18 -0
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/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
|
/**
|
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
9
|
import { matchSlashCommand, getPluginVersion } from "./slash-commands.js";
|
|
10
|
-
import { triggerUpdateCheck } from "./update-checker.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";
|
|
@@ -266,30 +266,24 @@ const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.j
|
|
|
266
266
|
/**
|
|
267
267
|
* 判断是否为首次安装或版本更新,返回对应的问候语。
|
|
268
268
|
* - 首次安装 / 版本变更 → "Haha,我的'灵魂'已上线,随时等你吩咐。"
|
|
269
|
-
* -
|
|
270
|
-
* - 短时间内重复重启(60s 内) → null(跳过,避免刷屏)
|
|
269
|
+
* - 普通重启(同版本) → null(不发送)
|
|
271
270
|
*/
|
|
272
271
|
function getStartupGreeting() {
|
|
273
272
|
const currentVersion = getPluginVersion();
|
|
274
273
|
let isFirstOrUpdated = true;
|
|
275
|
-
let lastGreetedAt = 0;
|
|
276
274
|
try {
|
|
277
275
|
if (fs.existsSync(STARTUP_MARKER_FILE)) {
|
|
278
276
|
const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8"));
|
|
279
277
|
if (data.version === currentVersion) {
|
|
280
278
|
isFirstOrUpdated = false;
|
|
281
279
|
}
|
|
282
|
-
if (data.greetedAt) {
|
|
283
|
-
lastGreetedAt = new Date(data.greetedAt).getTime() || 0;
|
|
284
|
-
}
|
|
285
280
|
}
|
|
286
281
|
}
|
|
287
282
|
catch {
|
|
288
283
|
// 文件损坏或不存在,视为首次
|
|
289
284
|
}
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
if (!isFirstOrUpdated && lastGreetedAt > 0 && Date.now() - lastGreetedAt < GREETING_DEBOUNCE_MS) {
|
|
285
|
+
// 普通重启(同版本)不发送问候语
|
|
286
|
+
if (!isFirstOrUpdated) {
|
|
293
287
|
return null;
|
|
294
288
|
}
|
|
295
289
|
// 更新 marker 文件
|
|
@@ -303,9 +297,7 @@ function getStartupGreeting() {
|
|
|
303
297
|
catch {
|
|
304
298
|
// ignore
|
|
305
299
|
}
|
|
306
|
-
return
|
|
307
|
-
? `Haha,我的'灵魂'已上线,随时等你吩咐。`
|
|
308
|
-
: `我重新登上了,有事随时找我。`;
|
|
300
|
+
return `Haha,我的'灵魂'已上线,随时等你吩咐。`;
|
|
309
301
|
}
|
|
310
302
|
/**
|
|
311
303
|
* 启动 Gateway WebSocket 连接(带自动重连)
|
|
@@ -325,6 +317,34 @@ export async function startGateway(ctx) {
|
|
|
325
317
|
}
|
|
326
318
|
// 后台版本检查(detached 子进程,零阻塞)
|
|
327
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
|
+
});
|
|
328
348
|
// 初始化 API 配置(markdown 支持)
|
|
329
349
|
initApiConfig({
|
|
330
350
|
markdownSupport: account.markdownSupport,
|
|
@@ -399,7 +419,47 @@ export async function startGateway(ctx) {
|
|
|
399
419
|
let shouldRefreshToken = false; // 下次连接是否需要刷新 token
|
|
400
420
|
// 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
|
|
401
421
|
// health-monitor 重连不会重新初始化为 true
|
|
402
|
-
|
|
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
|
+
/** 异步发送启动问候语(仅发给管理员) */
|
|
403
463
|
const sendStartupGreetings = (trigger) => {
|
|
404
464
|
(async () => {
|
|
405
465
|
try {
|
|
@@ -408,39 +468,22 @@ export async function startGateway(ctx) {
|
|
|
408
468
|
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (debounced, trigger=${trigger})`);
|
|
409
469
|
return;
|
|
410
470
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
try {
|
|
416
|
-
await sendProactiveC2CMessage(token, user.openid, greeting);
|
|
417
|
-
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to c2c:${user.openid}`);
|
|
418
|
-
}
|
|
419
|
-
catch (err) {
|
|
420
|
-
log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to c2c:${user.openid}: ${err}`);
|
|
421
|
-
}
|
|
422
|
-
await new Promise(r => setTimeout(r, 500));
|
|
423
|
-
}
|
|
424
|
-
const groups = listKnownUsers({ accountId: account.accountId, type: "group" });
|
|
425
|
-
const sentGroups = new Set();
|
|
426
|
-
for (const user of groups) {
|
|
427
|
-
const gid = user.groupOpenid;
|
|
428
|
-
if (!gid || sentGroups.has(gid))
|
|
429
|
-
continue;
|
|
430
|
-
sentGroups.add(gid);
|
|
431
|
-
try {
|
|
432
|
-
await sendProactiveGroupMessage(token, gid, greeting);
|
|
433
|
-
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to group:${gid}`);
|
|
434
|
-
}
|
|
435
|
-
catch (err) {
|
|
436
|
-
log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to group:${gid}: ${err}`);
|
|
437
|
-
}
|
|
438
|
-
await new Promise(r => setTimeout(r, 500));
|
|
471
|
+
const adminId = resolveAdminOpenId();
|
|
472
|
+
if (!adminId) {
|
|
473
|
+
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (no admin or known user)`);
|
|
474
|
+
return;
|
|
439
475
|
}
|
|
440
|
-
log?.info(`[qqbot:${account.accountId}]
|
|
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}`);
|
|
441
484
|
}
|
|
442
485
|
catch (err) {
|
|
443
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send startup
|
|
486
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send startup greeting: ${err}`);
|
|
444
487
|
}
|
|
445
488
|
})();
|
|
446
489
|
};
|
|
@@ -1042,10 +1085,10 @@ export async function startGateway(ctx) {
|
|
|
1042
1085
|
const staticParts = [
|
|
1043
1086
|
`[QQBot] to=${qualifiedTarget}`,
|
|
1044
1087
|
];
|
|
1045
|
-
// TTS 能力声明:仅在启用时告知 AI
|
|
1088
|
+
// TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
|
|
1046
1089
|
// STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
|
|
1047
1090
|
if (hasTTS)
|
|
1048
|
-
staticParts.push("
|
|
1091
|
+
staticParts.push("语音合成已启用");
|
|
1049
1092
|
const staticInstruction = staticParts.join(" | ");
|
|
1050
1093
|
// 静态指引作为 systemPrompts 的首项注入
|
|
1051
1094
|
systemPrompts.unshift(staticInstruction);
|
|
@@ -67,13 +67,13 @@ registerCommand({
|
|
|
67
67
|
const now = Date.now();
|
|
68
68
|
const eventTime = new Date(ctx.eventTimestamp).getTime();
|
|
69
69
|
if (isNaN(eventTime)) {
|
|
70
|
-
return
|
|
70
|
+
return `✅ pong!`;
|
|
71
71
|
}
|
|
72
72
|
const totalMs = now - eventTime;
|
|
73
73
|
const qqToPlugin = ctx.receivedAt - eventTime;
|
|
74
74
|
const pluginProcess = now - ctx.receivedAt;
|
|
75
75
|
const lines = [
|
|
76
|
-
|
|
76
|
+
`✅ pong!`,
|
|
77
77
|
``,
|
|
78
78
|
`⏱ 延迟: ${totalMs}ms`,
|
|
79
79
|
` ├ 网络传输: ${qqToPlugin}ms`,
|
|
@@ -91,23 +91,20 @@ registerCommand({
|
|
|
91
91
|
handler: () => {
|
|
92
92
|
const frameworkVersion = getFrameworkVersion();
|
|
93
93
|
const lines = [
|
|
94
|
-
|
|
95
|
-
`🤖
|
|
94
|
+
`🦞框架版本:${frameworkVersion}`,
|
|
95
|
+
`🤖QQBot 插件版本:v${PLUGIN_VERSION}`,
|
|
96
96
|
];
|
|
97
97
|
const info = getUpdateInfo();
|
|
98
98
|
if (info.checkedAt === 0) {
|
|
99
|
-
// 尚未检查过
|
|
100
99
|
lines.push(`⏳ 版本检查中...`);
|
|
101
100
|
}
|
|
102
101
|
else if (info.error) {
|
|
103
102
|
lines.push(`⚠️ 版本检查失败`);
|
|
104
103
|
}
|
|
105
104
|
else if (info.hasUpdate && info.latest) {
|
|
106
|
-
lines.push(
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
lines.push(`✅ 当前已是最新版本`);
|
|
105
|
+
lines.push(`🆕最新可用版本:v${info.latest},点击 <qqbot-cmd-input text="/qqbot-upgrade" show="/qqbot-upgrade"/> 查看升级指引`);
|
|
110
106
|
}
|
|
107
|
+
lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
|
|
111
108
|
return lines.join("\n");
|
|
112
109
|
},
|
|
113
110
|
});
|
|
@@ -118,18 +115,15 @@ registerCommand({
|
|
|
118
115
|
name: "qqbot-help",
|
|
119
116
|
description: "查看所有指令以及用途",
|
|
120
117
|
handler: () => {
|
|
121
|
-
const lines = [
|
|
118
|
+
const lines = [`### QQBot插件内置调试指令`, ``];
|
|
122
119
|
for (const [name, cmd] of commands) {
|
|
123
|
-
lines.push(
|
|
120
|
+
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
|
124
121
|
}
|
|
122
|
+
lines.push(``, `> 插件版本 v${PLUGIN_VERSION}`);
|
|
125
123
|
return lines.join("\n");
|
|
126
124
|
},
|
|
127
125
|
});
|
|
128
126
|
const DEFAULT_UPGRADE_URL = "https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg";
|
|
129
|
-
/** 升级说明文本 */
|
|
130
|
-
function getUpgradeGuide(url) {
|
|
131
|
-
return `📖 升级指引:\n${url}`;
|
|
132
|
-
}
|
|
133
127
|
/**
|
|
134
128
|
* /qqbot-upgrade — 查看版本更新状态 + 升级指引
|
|
135
129
|
*/
|
|
@@ -140,31 +134,30 @@ registerCommand({
|
|
|
140
134
|
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
141
135
|
const info = getUpdateInfo();
|
|
142
136
|
const lines = [];
|
|
137
|
+
lines.push(`📌当前版本:v${PLUGIN_VERSION}`);
|
|
143
138
|
if (info.checkedAt === 0) {
|
|
144
|
-
lines.push(`🤖 当前版本: v${PLUGIN_VERSION}`);
|
|
145
139
|
lines.push(`⏳ 版本检查中,请稍后再试`);
|
|
146
140
|
}
|
|
147
141
|
else if (info.error) {
|
|
148
|
-
lines.push(`🤖 当前版本: v${PLUGIN_VERSION}`);
|
|
149
142
|
lines.push(`⚠️ 版本检查失败`);
|
|
150
143
|
}
|
|
151
144
|
else if (info.hasUpdate && info.latest) {
|
|
152
|
-
lines.push(
|
|
153
|
-
lines.push(`🆕 最新版本: v${info.latest}`);
|
|
145
|
+
lines.push(`🆕最新可用版本:v${info.latest}`);
|
|
154
146
|
}
|
|
155
147
|
else {
|
|
156
|
-
lines.push(`✅
|
|
148
|
+
lines.push(`✅ 当前已是最新版本`);
|
|
157
149
|
}
|
|
158
|
-
lines.push(
|
|
150
|
+
lines.push(`⬆️升级指引:[点击查看](${url})`);
|
|
151
|
+
lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
|
|
159
152
|
return lines.join("\n");
|
|
160
153
|
},
|
|
161
154
|
});
|
|
162
155
|
/**
|
|
163
|
-
* /qqbot-logs —
|
|
156
|
+
* /qqbot-logs — 导出本地日志文件
|
|
164
157
|
*/
|
|
165
158
|
registerCommand({
|
|
166
159
|
name: "qqbot-logs",
|
|
167
|
-
description: "
|
|
160
|
+
description: "导出本地日志文件",
|
|
168
161
|
handler: () => {
|
|
169
162
|
const homeDir = process.env.HOME || "~";
|
|
170
163
|
const logDir = path.join(homeDir, ".openclaw", "logs");
|
package/dist/src/types.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export interface QQBotAccountConfig {
|
|
|
59
59
|
urlDirectUpload?: boolean;
|
|
60
60
|
/**
|
|
61
61
|
* /qqbot-upgrade 指令返回的升级指引网址
|
|
62
|
-
* 默认: https://
|
|
62
|
+
* 默认: https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg
|
|
63
63
|
*/
|
|
64
64
|
upgradeUrl?: string;
|
|
65
65
|
}
|
|
@@ -12,6 +12,11 @@ export interface UpdateInfo {
|
|
|
12
12
|
checkedAt: number;
|
|
13
13
|
error?: string;
|
|
14
14
|
}
|
|
15
|
+
type UpdateFoundCallback = (info: UpdateInfo) => void;
|
|
16
|
+
/**
|
|
17
|
+
* 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
|
|
18
|
+
*/
|
|
19
|
+
export declare function onUpdateFound(cb: UpdateFoundCallback): void;
|
|
15
20
|
export declare function triggerUpdateCheck(log?: {
|
|
16
21
|
info: (msg: string) => void;
|
|
17
22
|
error: (msg: string) => void;
|
|
@@ -19,3 +24,4 @@ export declare function triggerUpdateCheck(log?: {
|
|
|
19
24
|
}): void;
|
|
20
25
|
export declare function getUpdateInfo(): UpdateInfo;
|
|
21
26
|
export declare function formatUpdateNotice(info: UpdateInfo): string;
|
|
27
|
+
export {};
|
|
@@ -24,6 +24,15 @@ let _lastInfo = {
|
|
|
24
24
|
checkedAt: 0,
|
|
25
25
|
};
|
|
26
26
|
let _checking = false;
|
|
27
|
+
/** 已通知过的版本号,避免同一版本重复推送 */
|
|
28
|
+
let _notifiedVersion = null;
|
|
29
|
+
let _onUpdateFound = null;
|
|
30
|
+
/**
|
|
31
|
+
* 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
|
|
32
|
+
*/
|
|
33
|
+
export function onUpdateFound(cb) {
|
|
34
|
+
_onUpdateFound = cb;
|
|
35
|
+
}
|
|
27
36
|
export function triggerUpdateCheck(log) {
|
|
28
37
|
if (_checking)
|
|
29
38
|
return;
|
|
@@ -55,6 +64,11 @@ export function triggerUpdateCheck(log) {
|
|
|
55
64
|
_lastInfo = { current: CURRENT_VERSION, latest: compareTarget, hasUpdate, checkedAt: now };
|
|
56
65
|
if (hasUpdate) {
|
|
57
66
|
log?.info?.(`[qqbot:update-checker] new version available: ${compareTarget} (current: ${CURRENT_VERSION})`);
|
|
67
|
+
// 首次发现该版本时触发回调
|
|
68
|
+
if (_onUpdateFound && compareTarget !== _notifiedVersion) {
|
|
69
|
+
_notifiedVersion = compareTarget;
|
|
70
|
+
_onUpdateFound(_lastInfo);
|
|
71
|
+
}
|
|
58
72
|
}
|
|
59
73
|
}
|
|
60
74
|
catch (parseErr) {
|
package/package.json
CHANGED
|
@@ -120,7 +120,7 @@ CMD="$FOUND_INSTALLATION"
|
|
|
120
120
|
echo ""
|
|
121
121
|
echo "=== 清理完成 ==="
|
|
122
122
|
echo ""
|
|
123
|
-
echo "
|
|
123
|
+
echo "接下来将执行以下命令重新安装插件:"
|
|
124
124
|
echo " cd /path/to/openclaw-qqbot"
|
|
125
125
|
echo " $CMD plugins install ."
|
|
126
126
|
echo " $CMD channels add --channel qqbot --token \"appid:appsecret\""
|
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
# qqbot 通过 npm 包升级(纯文件操作版本)
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# 默认只做文件替换,不修改 openclaw.json 配置文件。
|
|
6
|
+
# 但如果提供了 --appid/--secret 参数(首次安装场景),
|
|
7
|
+
# 则在文件安装完成后自动写入通道配置。
|
|
8
8
|
#
|
|
9
9
|
# 用法:
|
|
10
10
|
# upgrade-via-npm.sh # 升级到 latest(默认)
|
|
11
11
|
# upgrade-via-npm.sh --version <version> # 升级到指定版本
|
|
12
12
|
# upgrade-via-npm.sh --self-version # 升级到当前仓库 package.json 版本
|
|
13
|
+
# upgrade-via-npm.sh --appid <appid> --secret <secret> # 首次安装时配置 appid/secret
|
|
13
14
|
|
|
14
15
|
set -eo pipefail
|
|
15
16
|
|
|
16
17
|
PKG_NAME="@tencent-connect/openclaw-qqbot"
|
|
17
18
|
INSTALL_SRC=""
|
|
19
|
+
APPID=""
|
|
20
|
+
SECRET=""
|
|
18
21
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
19
22
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
20
23
|
|
|
@@ -37,6 +40,14 @@ print_usage() {
|
|
|
37
40
|
else
|
|
38
41
|
echo " upgrade-via-npm.sh --self-version # 升级到当前仓库版本"
|
|
39
42
|
fi
|
|
43
|
+
echo ""
|
|
44
|
+
echo " --appid <appid> QQ机器人 appid(首次安装时必填)"
|
|
45
|
+
echo " --secret <secret> QQ机器人 secret(首次安装时必填)"
|
|
46
|
+
echo ""
|
|
47
|
+
echo "也可以通过环境变量设置:"
|
|
48
|
+
echo " QQBOT_APPID QQ机器人 appid"
|
|
49
|
+
echo " QQBOT_SECRET QQ机器人 secret"
|
|
50
|
+
echo " QQBOT_TOKEN QQ机器人 token (appid:secret)"
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
while [[ $# -gt 0 ]]; do
|
|
@@ -56,6 +67,16 @@ while [[ $# -gt 0 ]]; do
|
|
|
56
67
|
INSTALL_SRC="${PKG_NAME}@${LOCAL_VERSION}"
|
|
57
68
|
shift 1
|
|
58
69
|
;;
|
|
70
|
+
--appid)
|
|
71
|
+
[ -z "$2" ] && echo "❌ --appid 需要参数" && exit 1
|
|
72
|
+
APPID="$2"
|
|
73
|
+
shift 2
|
|
74
|
+
;;
|
|
75
|
+
--secret)
|
|
76
|
+
[ -z "$2" ] && echo "❌ --secret 需要参数" && exit 1
|
|
77
|
+
SECRET="$2"
|
|
78
|
+
shift 2
|
|
79
|
+
;;
|
|
59
80
|
-h|--help)
|
|
60
81
|
print_usage
|
|
61
82
|
exit 0
|
|
@@ -65,6 +86,14 @@ while [[ $# -gt 0 ]]; do
|
|
|
65
86
|
done
|
|
66
87
|
INSTALL_SRC="${INSTALL_SRC:-${PKG_NAME}@latest}"
|
|
67
88
|
|
|
89
|
+
# 环境变量 fallback
|
|
90
|
+
APPID="${APPID:-$QQBOT_APPID}"
|
|
91
|
+
SECRET="${SECRET:-$QQBOT_SECRET}"
|
|
92
|
+
if [ -z "$APPID" ] && [ -z "$SECRET" ] && [ -n "$QQBOT_TOKEN" ]; then
|
|
93
|
+
APPID="${QQBOT_TOKEN%%:*}"
|
|
94
|
+
SECRET="${QQBOT_TOKEN#*:}"
|
|
95
|
+
fi
|
|
96
|
+
|
|
68
97
|
# 检测 CLI(仅用于确定 extensions 目录路径)
|
|
69
98
|
CMD=""
|
|
70
99
|
for name in openclaw clawdbot moltbot; do
|
|
@@ -172,3 +201,55 @@ echo ""
|
|
|
172
201
|
echo "==========================================="
|
|
173
202
|
echo " ✅ 文件安装完成"
|
|
174
203
|
echo "==========================================="
|
|
204
|
+
|
|
205
|
+
# [4/4] 配置 appid/secret(仅在提供了参数时执行)
|
|
206
|
+
if [ -n "$APPID" ] && [ -n "$SECRET" ]; then
|
|
207
|
+
echo ""
|
|
208
|
+
echo "[配置] 写入 qqbot 通道配置..."
|
|
209
|
+
DESIRED_TOKEN="${APPID}:${SECRET}"
|
|
210
|
+
|
|
211
|
+
# 读取当前已有的 token
|
|
212
|
+
CURRENT_TOKEN=""
|
|
213
|
+
for _app in openclaw clawdbot moltbot; do
|
|
214
|
+
_cfg="$HOME/.$_app/$_app.json"
|
|
215
|
+
if [ -f "$_cfg" ]; then
|
|
216
|
+
CURRENT_TOKEN=$(node -e "
|
|
217
|
+
const cfg = JSON.parse(require('fs').readFileSync('$_cfg', 'utf8'));
|
|
218
|
+
const keys = ['qqbot', 'openclaw-qqbot', 'openclaw-qq'];
|
|
219
|
+
for (const key of keys) {
|
|
220
|
+
const ch = cfg.channels && cfg.channels[key];
|
|
221
|
+
if (!ch) continue;
|
|
222
|
+
if (ch.token) { process.stdout.write(ch.token); process.exit(0); }
|
|
223
|
+
if (ch.appId && ch.clientSecret) { process.stdout.write(ch.appId + ':' + ch.clientSecret); process.exit(0); }
|
|
224
|
+
}
|
|
225
|
+
" 2>/dev/null || true)
|
|
226
|
+
[ -n "$CURRENT_TOKEN" ] && break
|
|
227
|
+
fi
|
|
228
|
+
done
|
|
229
|
+
|
|
230
|
+
if [ "$CURRENT_TOKEN" = "$DESIRED_TOKEN" ]; then
|
|
231
|
+
echo " ✅ 当前配置已是目标值,跳过写入"
|
|
232
|
+
elif $CMD channels add --channel qqbot --token "$DESIRED_TOKEN" 2>&1; then
|
|
233
|
+
echo " ✅ 通道配置写入成功"
|
|
234
|
+
else
|
|
235
|
+
echo " ⚠️ $CMD channels add 失败,尝试直接编辑配置文件..."
|
|
236
|
+
CONFIG_FILE="$HOME/.$CMD/$CMD.json"
|
|
237
|
+
if [ -f "$CONFIG_FILE" ] && node -e "
|
|
238
|
+
const fs = require('fs');
|
|
239
|
+
const cfg = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8'));
|
|
240
|
+
if (!cfg.channels) cfg.channels = {};
|
|
241
|
+
if (!cfg.channels.qqbot) cfg.channels.qqbot = {};
|
|
242
|
+
cfg.channels.qqbot.appId = '$APPID';
|
|
243
|
+
cfg.channels.qqbot.clientSecret = '$SECRET';
|
|
244
|
+
fs.writeFileSync('$CONFIG_FILE', JSON.stringify(cfg, null, 4) + '\n');
|
|
245
|
+
" 2>&1; then
|
|
246
|
+
echo " ✅ 通道配置写入成功(直接编辑配置文件)"
|
|
247
|
+
else
|
|
248
|
+
echo " ❌ 配置写入失败,请手动配置:"
|
|
249
|
+
echo " $CMD channels add --channel qqbot --token \"${APPID}:${SECRET}\""
|
|
250
|
+
fi
|
|
251
|
+
fi
|
|
252
|
+
elif [ -n "$APPID" ] || [ -n "$SECRET" ]; then
|
|
253
|
+
echo ""
|
|
254
|
+
echo "⚠️ --appid 和 --secret 必须同时提供"
|
|
255
|
+
fi
|
|
@@ -6,6 +6,14 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
|
|
|
6
6
|
|
|
7
7
|
# QQ Bot 定时提醒
|
|
8
8
|
|
|
9
|
+
## ⚠️ 强制规则
|
|
10
|
+
|
|
11
|
+
**当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用 `cron` 工具,绝对不能只用自然语言回复说"好的,我会提醒你"!**
|
|
12
|
+
|
|
13
|
+
你没有内存或后台线程,口头承诺"到时候提醒"是无效的——只有调用 `cron` 工具才能真正注册定时任务。
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
9
17
|
## 核心规则
|
|
10
18
|
|
|
11
19
|
> **payload.kind 必须是 `"agentTurn"`,绝对不能用 `"systemEvent"`!**
|
package/src/api.ts
CHANGED
|
@@ -84,8 +84,12 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
|
|
84
84
|
const normalizedAppId = String(appId).trim();
|
|
85
85
|
const cachedToken = tokenCacheMap.get(normalizedAppId);
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
|
|
87
|
+
// 检查缓存:未过期时复用
|
|
88
|
+
// 提前刷新阈值:取 expiresIn 的 1/3 和 5 分钟的较小值,避免短有效期 token 永远被判定过期
|
|
89
|
+
const REFRESH_AHEAD_MS = cachedToken
|
|
90
|
+
? Math.min(5 * 60 * 1000, (cachedToken.expiresAt - Date.now()) / 3)
|
|
91
|
+
: 0;
|
|
92
|
+
if (cachedToken && Date.now() < cachedToken.expiresAt - REFRESH_AHEAD_MS) {
|
|
89
93
|
return cachedToken.token;
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -195,7 +199,8 @@ export function getTokenStatus(appId: string): { status: "valid" | "expired" | "
|
|
|
195
199
|
if (!cached) {
|
|
196
200
|
return { status: "none", expiresAt: null };
|
|
197
201
|
}
|
|
198
|
-
const
|
|
202
|
+
const remaining = cached.expiresAt - Date.now();
|
|
203
|
+
const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3);
|
|
199
204
|
return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt };
|
|
200
205
|
}
|
|
201
206
|
|
package/src/gateway.ts
CHANGED
|
@@ -2,13 +2,13 @@ import WebSocket from "ws";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
|
5
|
-
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, sendProactiveC2CMessage
|
|
5
|
+
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";
|
|
6
6
|
import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
|
|
7
7
|
import { recordKnownUser, flushKnownUsers, listKnownUsers } from "./known-users.js";
|
|
8
8
|
import { getQQBotRuntime } from "./runtime.js";
|
|
9
9
|
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
|
|
10
10
|
import { matchSlashCommand, getPluginVersion, type SlashCommandContext, type SlashCommandFileResult, type QueueSnapshot } from "./slash-commands.js";
|
|
11
|
-
import { triggerUpdateCheck } from "./update-checker.js";
|
|
11
|
+
import { triggerUpdateCheck, onUpdateFound, formatUpdateNotice } from "./update-checker.js";
|
|
12
12
|
import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
|
13
13
|
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
|
|
14
14
|
import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
|
|
@@ -355,13 +355,11 @@ const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.j
|
|
|
355
355
|
/**
|
|
356
356
|
* 判断是否为首次安装或版本更新,返回对应的问候语。
|
|
357
357
|
* - 首次安装 / 版本变更 → "Haha,我的'灵魂'已上线,随时等你吩咐。"
|
|
358
|
-
* -
|
|
359
|
-
* - 短时间内重复重启(60s 内) → null(跳过,避免刷屏)
|
|
358
|
+
* - 普通重启(同版本) → null(不发送)
|
|
360
359
|
*/
|
|
361
360
|
function getStartupGreeting(): string | null {
|
|
362
361
|
const currentVersion = getPluginVersion();
|
|
363
362
|
let isFirstOrUpdated = true;
|
|
364
|
-
let lastGreetedAt = 0;
|
|
365
363
|
|
|
366
364
|
try {
|
|
367
365
|
if (fs.existsSync(STARTUP_MARKER_FILE)) {
|
|
@@ -369,17 +367,13 @@ function getStartupGreeting(): string | null {
|
|
|
369
367
|
if (data.version === currentVersion) {
|
|
370
368
|
isFirstOrUpdated = false;
|
|
371
369
|
}
|
|
372
|
-
if (data.greetedAt) {
|
|
373
|
-
lastGreetedAt = new Date(data.greetedAt).getTime() || 0;
|
|
374
|
-
}
|
|
375
370
|
}
|
|
376
371
|
} catch {
|
|
377
372
|
// 文件损坏或不存在,视为首次
|
|
378
373
|
}
|
|
379
374
|
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
if (!isFirstOrUpdated && lastGreetedAt > 0 && Date.now() - lastGreetedAt < GREETING_DEBOUNCE_MS) {
|
|
375
|
+
// 普通重启(同版本)不发送问候语
|
|
376
|
+
if (!isFirstOrUpdated) {
|
|
383
377
|
return null;
|
|
384
378
|
}
|
|
385
379
|
|
|
@@ -394,9 +388,7 @@ function getStartupGreeting(): string | null {
|
|
|
394
388
|
// ignore
|
|
395
389
|
}
|
|
396
390
|
|
|
397
|
-
return
|
|
398
|
-
? `Haha,我的'灵魂'已上线,随时等你吩咐。`
|
|
399
|
-
: `我重新登上了,有事随时找我。`;
|
|
391
|
+
return `Haha,我的'灵魂'已上线,随时等你吩咐。`;
|
|
400
392
|
}
|
|
401
393
|
|
|
402
394
|
/**
|
|
@@ -421,6 +413,33 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
421
413
|
// 后台版本检查(detached 子进程,零阻塞)
|
|
422
414
|
triggerUpdateCheck(log);
|
|
423
415
|
|
|
416
|
+
// 注册新版本通知回调:仅发给管理员,带防抖
|
|
417
|
+
let lastUpdateNotifyAt = 0;
|
|
418
|
+
const UPDATE_NOTIFY_DEBOUNCE_MS = 5 * 60 * 1000; // 5 分钟内不重复通知
|
|
419
|
+
onUpdateFound(async (info) => {
|
|
420
|
+
try {
|
|
421
|
+
// 防抖:避免短时间内重复推送
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
if (now - lastUpdateNotifyAt < UPDATE_NOTIFY_DEBOUNCE_MS) {
|
|
424
|
+
log?.debug?.(`[qqbot:${account.accountId}] Update notification debounced`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const notice = formatUpdateNotice(info);
|
|
428
|
+
if (!notice) return;
|
|
429
|
+
const adminId = resolveAdminOpenId();
|
|
430
|
+
if (!adminId) {
|
|
431
|
+
log?.debug?.(`[qqbot:${account.accountId}] No admin or known user to send update notification`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
435
|
+
await sendProactiveC2CMessage(token, adminId, notice);
|
|
436
|
+
lastUpdateNotifyAt = Date.now();
|
|
437
|
+
log?.info(`[qqbot:${account.accountId}] Sent update notification to admin: ${adminId}`);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
log?.debug?.(`[qqbot:${account.accountId}] Failed to send update notification to admin: ${err}`);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
424
443
|
// 初始化 API 配置(markdown 支持)
|
|
425
444
|
initApiConfig({
|
|
426
445
|
markdownSupport: account.markdownSupport,
|
|
@@ -498,7 +517,47 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
498
517
|
// 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
|
|
499
518
|
// health-monitor 重连不会重新初始化为 true
|
|
500
519
|
|
|
501
|
-
|
|
520
|
+
const ADMIN_MARKER_FILE = path.join(getQQBotDataDir("data"), `admin-${account.accountId}.json`);
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 读取已持久化的管理员 openid
|
|
524
|
+
*/
|
|
525
|
+
const loadAdminOpenId = (): string | undefined => {
|
|
526
|
+
try {
|
|
527
|
+
if (fs.existsSync(ADMIN_MARKER_FILE)) {
|
|
528
|
+
const data = JSON.parse(fs.readFileSync(ADMIN_MARKER_FILE, "utf8"));
|
|
529
|
+
if (data.openid) return data.openid;
|
|
530
|
+
}
|
|
531
|
+
} catch { /* 文件损坏视为无 */ }
|
|
532
|
+
return undefined;
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* 将管理员 openid 持久化到文件
|
|
537
|
+
*/
|
|
538
|
+
const saveAdminOpenId = (openid: string): void => {
|
|
539
|
+
try {
|
|
540
|
+
fs.writeFileSync(ADMIN_MARKER_FILE, JSON.stringify({ openid, savedAt: new Date().toISOString() }));
|
|
541
|
+
} catch { /* ignore */ }
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* 解析管理员 openid:
|
|
546
|
+
* 1. 优先读持久化文件(稳定)
|
|
547
|
+
* 2. fallback 取第一个私聊用户,并写入文件锁定
|
|
548
|
+
*/
|
|
549
|
+
const resolveAdminOpenId = (): string | undefined => {
|
|
550
|
+
const saved = loadAdminOpenId();
|
|
551
|
+
if (saved) return saved;
|
|
552
|
+
const first = listKnownUsers({ accountId: account.accountId, type: "c2c", sortBy: "firstSeenAt", sortOrder: "asc", limit: 1 })[0]?.openid;
|
|
553
|
+
if (first) {
|
|
554
|
+
saveAdminOpenId(first);
|
|
555
|
+
log?.info(`[qqbot:${account.accountId}] Auto-detected admin openid: ${first} (persisted)`);
|
|
556
|
+
}
|
|
557
|
+
return first;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
/** 异步发送启动问候语(仅发给管理员) */
|
|
502
561
|
const sendStartupGreetings = (trigger: "READY" | "RESUMED") => {
|
|
503
562
|
(async () => {
|
|
504
563
|
try {
|
|
@@ -507,35 +566,21 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
507
566
|
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (debounced, trigger=${trigger})`);
|
|
508
567
|
return;
|
|
509
568
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
try {
|
|
515
|
-
await sendProactiveC2CMessage(token, user.openid, greeting);
|
|
516
|
-
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to c2c:${user.openid}`);
|
|
517
|
-
} catch (err) {
|
|
518
|
-
log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to c2c:${user.openid}: ${err}`);
|
|
519
|
-
}
|
|
520
|
-
await new Promise(r => setTimeout(r, 500));
|
|
521
|
-
}
|
|
522
|
-
const groups = listKnownUsers({ accountId: account.accountId, type: "group" });
|
|
523
|
-
const sentGroups = new Set<string>();
|
|
524
|
-
for (const user of groups) {
|
|
525
|
-
const gid = user.groupOpenid;
|
|
526
|
-
if (!gid || sentGroups.has(gid)) continue;
|
|
527
|
-
sentGroups.add(gid);
|
|
528
|
-
try {
|
|
529
|
-
await sendProactiveGroupMessage(token, gid, greeting);
|
|
530
|
-
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to group:${gid}`);
|
|
531
|
-
} catch (err) {
|
|
532
|
-
log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to group:${gid}: ${err}`);
|
|
533
|
-
}
|
|
534
|
-
await new Promise(r => setTimeout(r, 500));
|
|
569
|
+
const adminId = resolveAdminOpenId();
|
|
570
|
+
if (!adminId) {
|
|
571
|
+
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (no admin or known user)`);
|
|
572
|
+
return;
|
|
535
573
|
}
|
|
536
|
-
log?.info(`[qqbot:${account.accountId}]
|
|
574
|
+
log?.info(`[qqbot:${account.accountId}] Sending startup greeting to admin (trigger=${trigger}): "${greeting}"`);
|
|
575
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
576
|
+
const GREETING_TIMEOUT_MS = 10_000;
|
|
577
|
+
await Promise.race([
|
|
578
|
+
sendProactiveC2CMessage(token, adminId, greeting),
|
|
579
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Startup greeting send timeout (10s)")), GREETING_TIMEOUT_MS)),
|
|
580
|
+
]);
|
|
581
|
+
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to admin: ${adminId}`);
|
|
537
582
|
} catch (err) {
|
|
538
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send startup
|
|
583
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send startup greeting: ${err}`);
|
|
539
584
|
}
|
|
540
585
|
})();
|
|
541
586
|
};
|
|
@@ -1187,9 +1232,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1187
1232
|
const staticParts: string[] = [
|
|
1188
1233
|
`[QQBot] to=${qualifiedTarget}`,
|
|
1189
1234
|
];
|
|
1190
|
-
// TTS 能力声明:仅在启用时告知 AI
|
|
1235
|
+
// TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
|
|
1191
1236
|
// STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
|
|
1192
|
-
if (hasTTS) staticParts.push("
|
|
1237
|
+
if (hasTTS) staticParts.push("语音合成已启用");
|
|
1193
1238
|
const staticInstruction = staticParts.join(" | ");
|
|
1194
1239
|
|
|
1195
1240
|
// 静态指引作为 systemPrompts 的首项注入
|
package/src/slash-commands.ts
CHANGED
|
@@ -136,13 +136,13 @@ registerCommand({
|
|
|
136
136
|
const now = Date.now();
|
|
137
137
|
const eventTime = new Date(ctx.eventTimestamp).getTime();
|
|
138
138
|
if (isNaN(eventTime)) {
|
|
139
|
-
return
|
|
139
|
+
return `✅ pong!`;
|
|
140
140
|
}
|
|
141
141
|
const totalMs = now - eventTime;
|
|
142
142
|
const qqToPlugin = ctx.receivedAt - eventTime;
|
|
143
143
|
const pluginProcess = now - ctx.receivedAt;
|
|
144
144
|
const lines = [
|
|
145
|
-
|
|
145
|
+
`✅ pong!`,
|
|
146
146
|
``,
|
|
147
147
|
`⏱ 延迟: ${totalMs}ms`,
|
|
148
148
|
` ├ 网络传输: ${qqToPlugin}ms`,
|
|
@@ -161,20 +161,18 @@ registerCommand({
|
|
|
161
161
|
handler: () => {
|
|
162
162
|
const frameworkVersion = getFrameworkVersion();
|
|
163
163
|
const lines = [
|
|
164
|
-
|
|
165
|
-
`🤖
|
|
164
|
+
`🦞框架版本:${frameworkVersion}`,
|
|
165
|
+
`🤖QQBot 插件版本:v${PLUGIN_VERSION}`,
|
|
166
166
|
];
|
|
167
167
|
const info = getUpdateInfo();
|
|
168
168
|
if (info.checkedAt === 0) {
|
|
169
|
-
// 尚未检查过
|
|
170
169
|
lines.push(`⏳ 版本检查中...`);
|
|
171
170
|
} else if (info.error) {
|
|
172
171
|
lines.push(`⚠️ 版本检查失败`);
|
|
173
|
-
} else
|
|
174
|
-
lines.push(
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
}
|
|
172
|
+
} else if (info.hasUpdate && info.latest) {
|
|
173
|
+
lines.push(`🆕最新可用版本:v${info.latest},点击 <qqbot-cmd-input text="/qqbot-upgrade" show="/qqbot-upgrade"/> 查看升级指引`);
|
|
174
|
+
}
|
|
175
|
+
lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
|
|
178
176
|
return lines.join("\n");
|
|
179
177
|
},
|
|
180
178
|
});
|
|
@@ -186,21 +184,17 @@ registerCommand({
|
|
|
186
184
|
name: "qqbot-help",
|
|
187
185
|
description: "查看所有指令以及用途",
|
|
188
186
|
handler: () => {
|
|
189
|
-
const lines = [
|
|
187
|
+
const lines = [`### QQBot插件内置调试指令`, ``];
|
|
190
188
|
for (const [name, cmd] of commands) {
|
|
191
|
-
lines.push(
|
|
189
|
+
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
|
192
190
|
}
|
|
191
|
+
lines.push(``, `> 插件版本 v${PLUGIN_VERSION}`);
|
|
193
192
|
return lines.join("\n");
|
|
194
193
|
},
|
|
195
194
|
});
|
|
196
195
|
|
|
197
196
|
const DEFAULT_UPGRADE_URL = "https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg";
|
|
198
197
|
|
|
199
|
-
/** 升级说明文本 */
|
|
200
|
-
function getUpgradeGuide(url: string): string {
|
|
201
|
-
return `📖 升级指引:\n${url}`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
198
|
/**
|
|
205
199
|
* /qqbot-upgrade — 查看版本更新状态 + 升级指引
|
|
206
200
|
*/
|
|
@@ -212,30 +206,30 @@ registerCommand({
|
|
|
212
206
|
const info = getUpdateInfo();
|
|
213
207
|
const lines: string[] = [];
|
|
214
208
|
|
|
209
|
+
lines.push(`📌当前版本:v${PLUGIN_VERSION}`);
|
|
210
|
+
|
|
215
211
|
if (info.checkedAt === 0) {
|
|
216
|
-
lines.push(`🤖 当前版本: v${PLUGIN_VERSION}`);
|
|
217
212
|
lines.push(`⏳ 版本检查中,请稍后再试`);
|
|
218
213
|
} else if (info.error) {
|
|
219
|
-
lines.push(`🤖 当前版本: v${PLUGIN_VERSION}`);
|
|
220
214
|
lines.push(`⚠️ 版本检查失败`);
|
|
221
215
|
} else if (info.hasUpdate && info.latest) {
|
|
222
|
-
lines.push(
|
|
223
|
-
lines.push(`🆕 最新版本: v${info.latest}`);
|
|
216
|
+
lines.push(`🆕最新可用版本:v${info.latest}`);
|
|
224
217
|
} else {
|
|
225
|
-
lines.push(`✅
|
|
218
|
+
lines.push(`✅ 当前已是最新版本`);
|
|
226
219
|
}
|
|
227
220
|
|
|
228
|
-
lines.push(
|
|
221
|
+
lines.push(`⬆️升级指引:[点击查看](${url})`);
|
|
222
|
+
lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
|
|
229
223
|
return lines.join("\n");
|
|
230
224
|
},
|
|
231
225
|
});
|
|
232
226
|
|
|
233
227
|
/**
|
|
234
|
-
* /qqbot-logs —
|
|
228
|
+
* /qqbot-logs — 导出本地日志文件
|
|
235
229
|
*/
|
|
236
230
|
registerCommand({
|
|
237
231
|
name: "qqbot-logs",
|
|
238
|
-
description: "
|
|
232
|
+
description: "导出本地日志文件",
|
|
239
233
|
handler: () => {
|
|
240
234
|
const homeDir = process.env.HOME || "~";
|
|
241
235
|
const logDir = path.join(homeDir, ".openclaw", "logs");
|
package/src/types.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface QQBotAccountConfig {
|
|
|
61
61
|
urlDirectUpload?: boolean;
|
|
62
62
|
/**
|
|
63
63
|
* /qqbot-upgrade 指令返回的升级指引网址
|
|
64
|
-
* 默认: https://
|
|
64
|
+
* 默认: https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg
|
|
65
65
|
*/
|
|
66
66
|
upgradeUrl?: string;
|
|
67
67
|
}
|
package/src/update-checker.ts
CHANGED
|
@@ -38,6 +38,19 @@ let _lastInfo: UpdateInfo = {
|
|
|
38
38
|
|
|
39
39
|
let _checking = false;
|
|
40
40
|
|
|
41
|
+
/** 已通知过的版本号,避免同一版本重复推送 */
|
|
42
|
+
let _notifiedVersion: string | null = null;
|
|
43
|
+
|
|
44
|
+
type UpdateFoundCallback = (info: UpdateInfo) => void;
|
|
45
|
+
let _onUpdateFound: UpdateFoundCallback | null = null;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
|
|
49
|
+
*/
|
|
50
|
+
export function onUpdateFound(cb: UpdateFoundCallback): void {
|
|
51
|
+
_onUpdateFound = cb;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function triggerUpdateCheck(log?: {
|
|
42
55
|
info: (msg: string) => void;
|
|
43
56
|
error: (msg: string) => void;
|
|
@@ -77,6 +90,11 @@ export function triggerUpdateCheck(log?: {
|
|
|
77
90
|
_lastInfo = { current: CURRENT_VERSION, latest: compareTarget, hasUpdate, checkedAt: now };
|
|
78
91
|
if (hasUpdate) {
|
|
79
92
|
log?.info?.(`[qqbot:update-checker] new version available: ${compareTarget} (current: ${CURRENT_VERSION})`);
|
|
93
|
+
// 首次发现该版本时触发回调
|
|
94
|
+
if (_onUpdateFound && compareTarget !== _notifiedVersion) {
|
|
95
|
+
_notifiedVersion = compareTarget;
|
|
96
|
+
_onUpdateFound(_lastInfo);
|
|
97
|
+
}
|
|
80
98
|
}
|
|
81
99
|
} catch (parseErr) {
|
|
82
100
|
_lastInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: now, error: String(parseErr) };
|