@ryantest/openclaw-qqbot 0.0.3 → 1.6.6-alpha.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 +2 -15
- package/README.zh.md +3 -16
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +69 -34
- package/dist/src/api.d.ts +105 -1
- package/dist/src/api.js +164 -15
- package/dist/src/channel.js +13 -0
- package/dist/src/config.js +3 -10
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +450 -248
- package/dist/src/image-server.d.ts +27 -8
- package/dist/src/image-server.js +179 -71
- package/dist/src/inbound-attachments.d.ts +3 -1
- package/dist/src/inbound-attachments.js +28 -14
- package/dist/src/outbound-deliver.js +77 -148
- package/dist/src/outbound.d.ts +6 -4
- package/dist/src/outbound.js +266 -442
- package/dist/src/reply-dispatcher.js +4 -4
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/slash-commands.js +277 -32
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/streaming.d.ts +244 -0
- package/dist/src/streaming.js +907 -0
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +101 -0
- package/dist/src/types.js +17 -1
- package/dist/src/update-checker.js +2 -8
- package/dist/src/utils/audio-convert.d.ts +9 -0
- package/dist/src/utils/audio-convert.js +51 -0
- package/dist/src/utils/chunked-upload.d.ts +59 -0
- package/dist/src/utils/chunked-upload.js +289 -0
- package/dist/src/utils/file-utils.d.ts +7 -1
- package/dist/src/utils/file-utils.js +24 -2
- package/dist/src/utils/media-send.d.ts +147 -0
- package/dist/src/utils/media-send.js +434 -0
- package/dist/src/utils/pkg-version.d.ts +5 -0
- package/dist/src/utils/pkg-version.js +51 -0
- package/dist/src/utils/ssrf-guard.d.ts +25 -0
- package/dist/src/utils/ssrf-guard.js +91 -0
- package/node_modules/ws/index.js +15 -6
- package/node_modules/ws/lib/permessage-deflate.js +6 -6
- package/node_modules/ws/lib/websocket-server.js +5 -5
- package/node_modules/ws/lib/websocket.js +6 -6
- package/node_modules/ws/package.json +4 -3
- package/node_modules/ws/wrapper.mjs +14 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +11 -22
- package/scripts/postinstall-link-sdk.js +113 -0
- package/scripts/upgrade-via-npm.ps1 +161 -6
- package/scripts/upgrade-via-npm.sh +311 -104
- package/scripts/upgrade-via-source.sh +117 -0
- package/skills/qqbot-media/SKILL.md +9 -5
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +76 -35
- package/src/api.ts +284 -12
- package/src/channel.ts +12 -0
- package/src/config.ts +3 -10
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +277 -67
- package/src/image-server.ts +213 -77
- package/src/inbound-attachments.ts +32 -15
- package/src/outbound-deliver.ts +77 -157
- package/src/outbound.ts +304 -451
- package/src/reply-dispatcher.ts +4 -4
- package/src/request-context.ts +39 -0
- package/src/slash-commands.ts +303 -33
- package/src/startup-greeting.ts +35 -13
- package/src/streaming.ts +1096 -0
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +111 -0
- package/src/update-checker.ts +2 -7
- package/src/utils/audio-convert.ts +56 -0
- package/src/utils/chunked-upload.ts +419 -0
- package/src/utils/file-utils.ts +28 -2
- package/src/utils/media-send.ts +563 -0
- package/src/utils/pkg-version.ts +54 -0
- package/src/utils/ssrf-guard.ts +102 -0
- package/clawdbot.plugin.json +0 -16
- package/dist/src/user-messages.d.ts +0 -8
- package/dist/src/user-messages.js +0 -8
- package/moltbot.plugin.json +0 -16
- package/scripts/upgrade-via-alt-pkg.sh +0 -307
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
- package/src/gateway.log +0 -43
- package/src/openclaw-2026-03-21.log +0 -3729
- package/src/user-messages.ts +0 -7
package/src/reply-dispatcher.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ResolvedQQBotAccount } from "./types.js";
|
|
|
3
3
|
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage } from "./api.js";
|
|
4
4
|
import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type MediaPayload } from "./utils/payload.js";
|
|
5
5
|
import { resolveTTSConfig, textToSilk, formatDuration } from "./utils/audio-convert.js";
|
|
6
|
-
import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize } from "./utils/file-utils.js";
|
|
6
|
+
import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize, getMaxUploadSize } from "./utils/file-utils.js";
|
|
7
7
|
import { getQQBotDataDir, normalizePath, sanitizeFileName } from "./utils/platform.js";
|
|
8
8
|
|
|
9
9
|
export interface MessageTarget {
|
|
@@ -157,7 +157,7 @@ async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Pro
|
|
|
157
157
|
log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
|
|
158
158
|
return;
|
|
159
159
|
}
|
|
160
|
-
const imgSzCheck = checkFileSize(imageUrl);
|
|
160
|
+
const imgSzCheck = checkFileSize(imageUrl, getMaxUploadSize(1)); // IMAGE = 1
|
|
161
161
|
if (!imgSzCheck.ok) {
|
|
162
162
|
log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
|
|
163
163
|
return;
|
|
@@ -259,7 +259,7 @@ async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Pro
|
|
|
259
259
|
if (!(await fileExistsAsync(videoPath))) {
|
|
260
260
|
throw new Error(`视频文件不存在: ${videoPath}`);
|
|
261
261
|
}
|
|
262
|
-
const vPaySzCheck = checkFileSize(videoPath);
|
|
262
|
+
const vPaySzCheck = checkFileSize(videoPath, getMaxUploadSize(2)); // VIDEO = 2
|
|
263
263
|
if (!vPaySzCheck.ok) {
|
|
264
264
|
throw new Error(vPaySzCheck.error!);
|
|
265
265
|
}
|
|
@@ -311,7 +311,7 @@ async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Prom
|
|
|
311
311
|
if (!(await fileExistsAsync(filePath))) {
|
|
312
312
|
throw new Error(`文件不存在: ${filePath}`);
|
|
313
313
|
}
|
|
314
|
-
const fPaySzCheck = checkFileSize(filePath);
|
|
314
|
+
const fPaySzCheck = checkFileSize(filePath, getMaxUploadSize(4)); // FILE = 4
|
|
315
315
|
if (!fPaySzCheck.ok) {
|
|
316
316
|
throw new Error(fPaySzCheck.error!);
|
|
317
317
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求级上下文(基于 AsyncLocalStorage)
|
|
3
|
+
*
|
|
4
|
+
* 解决并发消息下工具获取当前会话信息的竞态问题。
|
|
5
|
+
* gateway 在处理每条入站消息时通过 runWithRequestContext() 建立作用域,
|
|
6
|
+
* 作用域内的所有异步代码(包括 AI agent 调用、tool execute)
|
|
7
|
+
* 都能通过 getRequestContext() 安全地拿到当前请求的上下文。
|
|
8
|
+
*/
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
|
|
11
|
+
export interface RequestContext {
|
|
12
|
+
/** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
|
|
13
|
+
target: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 在请求级作用域中执行回调。
|
|
20
|
+
* 作用域内所有同步/异步代码都能通过 getRequestContext() 获取上下文。
|
|
21
|
+
*/
|
|
22
|
+
export function runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T {
|
|
23
|
+
return asyncLocalStorage.run(ctx, fn);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 获取当前请求的上下文,不存在时返回 undefined。
|
|
28
|
+
*/
|
|
29
|
+
export function getRequestContext(): RequestContext | undefined {
|
|
30
|
+
return asyncLocalStorage.getStore();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 获取当前请求的投递目标地址。
|
|
35
|
+
* 便捷方法,等价于 getRequestContext()?.target。
|
|
36
|
+
*/
|
|
37
|
+
export function getRequestTarget(): string | undefined {
|
|
38
|
+
return asyncLocalStorage.getStore()?.target;
|
|
39
|
+
}
|
package/src/slash-commands.ts
CHANGED
|
@@ -20,16 +20,10 @@ import { getUpdateInfo, checkVersionExists } from "./update-checker.js";
|
|
|
20
20
|
import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js";
|
|
21
21
|
import { saveCredentialBackup } from "./credential-backup.js";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
let PLUGIN_VERSION = "unknown";
|
|
27
|
-
try {
|
|
28
|
-
const pkg = require("../package.json");
|
|
29
|
-
PLUGIN_VERSION = pkg.version ?? "unknown";
|
|
30
|
-
} catch {
|
|
31
|
-
// fallback
|
|
32
|
-
}
|
|
26
|
+
let PLUGIN_VERSION = getPackageVersion(import.meta.url);
|
|
33
27
|
|
|
34
28
|
// 获取 openclaw 框架版本(缓存结果,只执行一次)
|
|
35
29
|
let _frameworkVersion: string | null = null;
|
|
@@ -333,9 +327,14 @@ registerCommand({
|
|
|
333
327
|
`列出所有可用的 QQBot 插件内置指令及其简要说明。`,
|
|
334
328
|
`使用 /指令名 ? 可查看某条指令的详细用法。`,
|
|
335
329
|
].join("\n"),
|
|
336
|
-
handler: () => {
|
|
330
|
+
handler: (ctx) => {
|
|
331
|
+
// 群聊场景排除仅限私聊的指令
|
|
332
|
+
const GROUP_EXCLUDED_COMMANDS = new Set(["bot-upgrade", "bot-clear-storage"]);
|
|
333
|
+
const isGroup = ctx.type === "group";
|
|
334
|
+
|
|
337
335
|
const lines = [`### QQBot插件内置调试指令`, ``];
|
|
338
336
|
for (const [name, cmd] of commands) {
|
|
337
|
+
if (isGroup && GROUP_EXCLUDED_COMMANDS.has(name)) continue;
|
|
339
338
|
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
|
340
339
|
}
|
|
341
340
|
lines.push(``, `> 插件版本 v${PLUGIN_VERSION}`);
|
|
@@ -343,7 +342,7 @@ registerCommand({
|
|
|
343
342
|
},
|
|
344
343
|
});
|
|
345
344
|
|
|
346
|
-
const DEFAULT_UPGRADE_URL = "https://
|
|
345
|
+
const DEFAULT_UPGRADE_URL = "https://docs.qq.com/doc/DSGxOZk1oVnVKVkpq";
|
|
347
346
|
|
|
348
347
|
function saveUpgradeGreetingTarget(accountId: string, appId: string, openid: string): void {
|
|
349
348
|
const safeAccountId = accountId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
@@ -666,6 +665,38 @@ function findPowerShell(): string | null {
|
|
|
666
665
|
return null;
|
|
667
666
|
}
|
|
668
667
|
|
|
668
|
+
/**
|
|
669
|
+
* 将升级脚本复制到临时位置,避免升级过程中插件目录被清理后脚本丢失。
|
|
670
|
+
* 返回临时脚本路径,失败返回 null。
|
|
671
|
+
*/
|
|
672
|
+
function copyScriptToTemp(scriptPath: string): string | null {
|
|
673
|
+
try {
|
|
674
|
+
const ext = path.extname(scriptPath);
|
|
675
|
+
const tmpDir = path.join(getHomeDir(), ".openclaw", ".qqbot-upgrade-tmp");
|
|
676
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
677
|
+
const tmpScript = path.join(tmpDir, `upgrade-via-npm${ext}`);
|
|
678
|
+
fs.copyFileSync(scriptPath, tmpScript);
|
|
679
|
+
if (!isWindows()) {
|
|
680
|
+
fs.chmodSync(tmpScript, 0o755);
|
|
681
|
+
}
|
|
682
|
+
return tmpScript;
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 清理临时升级脚本目录
|
|
690
|
+
*/
|
|
691
|
+
function cleanupTempScript(): void {
|
|
692
|
+
try {
|
|
693
|
+
const tmpDir = path.join(getHomeDir(), ".openclaw", ".qqbot-upgrade-tmp");
|
|
694
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
695
|
+
} catch {
|
|
696
|
+
// 非关键,静默忽略
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
669
700
|
/**
|
|
670
701
|
* 执行热更新:执行脚本(--no-restart) → 立即触发 gateway restart
|
|
671
702
|
*
|
|
@@ -675,10 +706,15 @@ function findPowerShell(): string | null {
|
|
|
675
706
|
* - 新进程启动时 getStartupGreeting() 检测到版本变更,自动通知管理员
|
|
676
707
|
*
|
|
677
708
|
* Windows 使用 PowerShell 执行 .ps1 脚本,Mac/Linux 使用 bash 执行 .sh 脚本。
|
|
709
|
+
*
|
|
710
|
+
* 安全机制:脚本会被复制到临时目录再执行,避免升级过程中插件目录被操作导致脚本自身丢失。
|
|
678
711
|
*/
|
|
679
712
|
function fireHotUpgrade(targetVersion?: string): HotUpgradeStartResult {
|
|
680
|
-
const
|
|
681
|
-
if (!
|
|
713
|
+
const originalScriptPath = getUpgradeScriptPath();
|
|
714
|
+
if (!originalScriptPath) return { ok: false, reason: "no-script" };
|
|
715
|
+
|
|
716
|
+
// 将脚本复制到临时位置,避免升级过程中脚本被删除
|
|
717
|
+
const scriptPath = copyScriptToTemp(originalScriptPath) || originalScriptPath;
|
|
682
718
|
|
|
683
719
|
const cli = findCli();
|
|
684
720
|
if (!cli) return { ok: false, reason: "no-cli" };
|
|
@@ -705,7 +741,7 @@ function fireHotUpgrade(targetVersion?: string): HotUpgradeStartResult {
|
|
|
705
741
|
shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : [])];
|
|
706
742
|
}
|
|
707
743
|
|
|
708
|
-
console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}`);
|
|
744
|
+
console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath} (original: ${originalScriptPath}), cli=${cli}, target=${targetVersion || "latest"}`);
|
|
709
745
|
|
|
710
746
|
// 异步执行升级脚本
|
|
711
747
|
execFile(shell, shellArgs, {
|
|
@@ -717,6 +753,7 @@ function fireHotUpgrade(targetVersion?: string): HotUpgradeStartResult {
|
|
|
717
753
|
console.error(`[qqbot] fireHotUpgrade: script failed: ${error.message}`);
|
|
718
754
|
if (stdout) console.error(`[qqbot] fireHotUpgrade: stdout: ${stdout.slice(0, 2000)}`);
|
|
719
755
|
if (_stderr) console.error(`[qqbot] fireHotUpgrade: stderr: ${_stderr.slice(0, 2000)}`);
|
|
756
|
+
cleanupTempScript();
|
|
720
757
|
_upgrading = false;
|
|
721
758
|
return;
|
|
722
759
|
}
|
|
@@ -728,12 +765,16 @@ function fireHotUpgrade(targetVersion?: string): HotUpgradeStartResult {
|
|
|
728
765
|
const newVersion = versionMatch?.[1];
|
|
729
766
|
if (newVersion === "unknown") {
|
|
730
767
|
console.error(`[qqbot] fireHotUpgrade: script output QQBOT_NEW_VERSION=unknown, aborting restart`);
|
|
768
|
+
cleanupTempScript();
|
|
731
769
|
_upgrading = false;
|
|
732
770
|
return;
|
|
733
771
|
}
|
|
734
772
|
|
|
735
773
|
console.log(`[qqbot] fireHotUpgrade: new version=${newVersion || "(not detected)"}, triggering restart...`);
|
|
736
774
|
|
|
775
|
+
// 脚本执行成功,清理临时脚本副本
|
|
776
|
+
cleanupTempScript();
|
|
777
|
+
|
|
737
778
|
// 文件替换成功,在 restart 之前把 source 从 path 切换为 npm,
|
|
738
779
|
// 确保新进程启动时读到的是 npm source,不会被本地源码覆盖。
|
|
739
780
|
switchPluginSourceToNpm();
|
|
@@ -794,7 +835,11 @@ function fireHotUpgrade(targetVersion?: string): HotUpgradeStartResult {
|
|
|
794
835
|
/**
|
|
795
836
|
* /bot-upgrade — 统一升级入口
|
|
796
837
|
*
|
|
797
|
-
*
|
|
838
|
+
* upgradeMode 开关:
|
|
839
|
+
* - "doc"(默认):只展示升级指引文档,不执行热更新
|
|
840
|
+
* - "hot-reload":执行 npm 升级脚本进行热更新
|
|
841
|
+
*
|
|
842
|
+
* 热更新模式下的产品流程:
|
|
798
843
|
* /bot-upgrade — 展示版本信息+确认按钮(不直接升级)
|
|
799
844
|
* /bot-upgrade --latest — 确认升级到最新版本
|
|
800
845
|
* /bot-upgrade --version X — 升级到指定版本
|
|
@@ -804,21 +849,54 @@ let _upgrading = false; // 升级锁
|
|
|
804
849
|
|
|
805
850
|
registerCommand({
|
|
806
851
|
name: "bot-upgrade",
|
|
807
|
-
description: "
|
|
852
|
+
description: "检查更新并查看升级指引",
|
|
808
853
|
usage: [
|
|
809
|
-
`/bot-upgrade
|
|
810
|
-
`/bot-upgrade --latest
|
|
811
|
-
`/bot-upgrade --version X
|
|
812
|
-
`/bot-upgrade --force
|
|
813
|
-
``,
|
|
814
|
-
`⚠️ 仅在私聊中可用。升级过程约 30~60 秒,期间服务短暂不可用。`,
|
|
815
|
-
``,
|
|
816
|
-
`环境要求:`,
|
|
817
|
-
` - 操作系统:macOS / Linux / Windows`,
|
|
818
|
-
` - OpenClaw 框架版本 ≥ ${UPGRADE_REQUIREMENTS.minFrameworkVersion}`,
|
|
819
|
-
` - Node.js ≥ v${UPGRADE_REQUIREMENTS.minNodeVersion}`,
|
|
854
|
+
`/bot-upgrade 检查是否有新版本`,
|
|
855
|
+
`/bot-upgrade --latest 确认升级到最新版本(需 upgradeMode=hot-reload)`,
|
|
856
|
+
`/bot-upgrade --version X 升级到指定版本(需 upgradeMode=hot-reload)`,
|
|
857
|
+
`/bot-upgrade --force 强制重新安装当前版本(需 upgradeMode=hot-reload)`,
|
|
820
858
|
].join("\n"),
|
|
821
859
|
handler: async (ctx) => {
|
|
860
|
+
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
861
|
+
const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
|
|
862
|
+
const args = ctx.args.trim();
|
|
863
|
+
const info = await getUpdateInfo();
|
|
864
|
+
|
|
865
|
+
const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
|
|
866
|
+
|
|
867
|
+
// ── doc 模式(默认):只展示升级指引,不执行热更新 ──
|
|
868
|
+
if (upgradeMode !== "hot-reload") {
|
|
869
|
+
if (info.checkedAt === 0) {
|
|
870
|
+
return `⏳ 版本检查中,请稍后再试`;
|
|
871
|
+
}
|
|
872
|
+
if (info.error) {
|
|
873
|
+
return [
|
|
874
|
+
`❌ 主机网络访问异常,无法检查更新`,
|
|
875
|
+
``,
|
|
876
|
+
`查看升级指引:[点击查看](${url})`,
|
|
877
|
+
].join("\n");
|
|
878
|
+
}
|
|
879
|
+
if (!info.hasUpdate) {
|
|
880
|
+
return [
|
|
881
|
+
`✅ 当前已是最新版本 v${PLUGIN_VERSION}`,
|
|
882
|
+
``,
|
|
883
|
+
`项目地址:[GitHub](${GITHUB_URL})`,
|
|
884
|
+
].join("\n");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return [
|
|
888
|
+
`🆕 发现新版本`,
|
|
889
|
+
``,
|
|
890
|
+
`当前版本:**v${PLUGIN_VERSION}**`,
|
|
891
|
+
`最新版本:**v${info.latest}**`,
|
|
892
|
+
``,
|
|
893
|
+
`📖 升级指引:[点击查看](${url})`,
|
|
894
|
+
`🌟 官方 GitHub 仓库:[点击前往](${GITHUB_URL})`,
|
|
895
|
+
].join("\n");
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ── hot-reload 模式:执行热更新 ──
|
|
899
|
+
|
|
822
900
|
// 升级相关指令仅在私聊中可用
|
|
823
901
|
if (ctx.type !== "c2c") {
|
|
824
902
|
return `💡 请在私聊中使用此指令`;
|
|
@@ -829,10 +907,6 @@ registerCommand({
|
|
|
829
907
|
return `⏳ 正在升级中,请稍候...`;
|
|
830
908
|
}
|
|
831
909
|
|
|
832
|
-
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
833
|
-
const args = ctx.args.trim();
|
|
834
|
-
const info = await getUpdateInfo();
|
|
835
|
-
|
|
836
910
|
let isForce = false;
|
|
837
911
|
let isLatest = false;
|
|
838
912
|
let versionArg: string | undefined;
|
|
@@ -870,8 +944,6 @@ registerCommand({
|
|
|
870
944
|
}
|
|
871
945
|
}
|
|
872
946
|
|
|
873
|
-
const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
|
|
874
|
-
|
|
875
947
|
// ── 无参数(也没有 --latest / --version / --force):只展示信息+确认按钮 ──
|
|
876
948
|
if (!versionArg && !isLatest && !isForce) {
|
|
877
949
|
if (info.checkedAt === 0) {
|
|
@@ -893,7 +965,7 @@ registerCommand({
|
|
|
893
965
|
return lines.join("\n");
|
|
894
966
|
}
|
|
895
967
|
|
|
896
|
-
// 有新版本:展示信息 +
|
|
968
|
+
// 有新版本:展示信息 + 确认按钮
|
|
897
969
|
return [
|
|
898
970
|
`🆕 发现新版本`,
|
|
899
971
|
``,
|
|
@@ -1267,6 +1339,204 @@ registerCommand({
|
|
|
1267
1339
|
},
|
|
1268
1340
|
});
|
|
1269
1341
|
|
|
1342
|
+
// ============ /bot-clear-storage ============
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* 扫描指定目录下的所有文件,递归统计。
|
|
1346
|
+
* 返回按文件大小降序排列的文件列表。
|
|
1347
|
+
*/
|
|
1348
|
+
function scanDirectoryFiles(dirPath: string): { filePath: string; size: number }[] {
|
|
1349
|
+
const files: { filePath: string; size: number }[] = [];
|
|
1350
|
+
if (!fs.existsSync(dirPath)) return files;
|
|
1351
|
+
|
|
1352
|
+
const walk = (dir: string) => {
|
|
1353
|
+
let entries: fs.Dirent[];
|
|
1354
|
+
try {
|
|
1355
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1356
|
+
} catch {
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
for (const entry of entries) {
|
|
1360
|
+
const fullPath = path.join(dir, entry.name);
|
|
1361
|
+
if (entry.isDirectory()) {
|
|
1362
|
+
walk(fullPath);
|
|
1363
|
+
} else if (entry.isFile()) {
|
|
1364
|
+
try {
|
|
1365
|
+
const stat = fs.statSync(fullPath);
|
|
1366
|
+
files.push({ filePath: fullPath, size: stat.size });
|
|
1367
|
+
} catch {
|
|
1368
|
+
// 跳过无法访问的文件
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
walk(dirPath);
|
|
1375
|
+
// 按大小降序排列
|
|
1376
|
+
files.sort((a, b) => b.size - a.size);
|
|
1377
|
+
return files;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** 格式化文件大小为人类可读形式 */
|
|
1381
|
+
function formatBytes(bytes: number): string {
|
|
1382
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1383
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1384
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1385
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* /bot-clear-storage — 清理通过 QQBot 对话产生的文件以及下载的资源
|
|
1390
|
+
*
|
|
1391
|
+
* 仅在私聊(c2c)中可用。
|
|
1392
|
+
* --force 执行时删除整个 appId 目录下的所有文件(不区分用户 openid)。
|
|
1393
|
+
*
|
|
1394
|
+
* 产品流程:
|
|
1395
|
+
* /bot-clear-storage — 扫描并列出当前 appId 下的文件,展示确认按钮
|
|
1396
|
+
* /bot-clear-storage --force — 确认执行删除
|
|
1397
|
+
*/
|
|
1398
|
+
registerCommand({
|
|
1399
|
+
name: "bot-clear-storage",
|
|
1400
|
+
description: "清理通过QQBot对话产生的文件以及下载的资源(保存在 OpenClaw 运行环境的主机上)",
|
|
1401
|
+
usage: [
|
|
1402
|
+
`/bot-clear-storage`,
|
|
1403
|
+
``,
|
|
1404
|
+
`扫描当前机器人产生的下载文件并列出明细。`,
|
|
1405
|
+
`确认后执行删除,释放主机磁盘空间。`,
|
|
1406
|
+
``,
|
|
1407
|
+
`/bot-clear-storage --force 确认执行清理`,
|
|
1408
|
+
``,
|
|
1409
|
+
`⚠️ 仅在私聊中可用。`,
|
|
1410
|
+
].join("\n"),
|
|
1411
|
+
handler: (ctx) => {
|
|
1412
|
+
const { appId, type } = ctx;
|
|
1413
|
+
|
|
1414
|
+
// 仅私聊可用
|
|
1415
|
+
if (type !== "c2c") {
|
|
1416
|
+
return `💡 请在私聊中使用此指令`;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
const isForce = ctx.args.trim() === "--force";
|
|
1420
|
+
|
|
1421
|
+
// 删除粒度为 appId 目录(不区分用户 openid)
|
|
1422
|
+
// 路径: downloads/{appId}/
|
|
1423
|
+
const targetDir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", "downloads", appId);
|
|
1424
|
+
const displayDir = `~/.openclaw/media/qqbot/downloads/${appId}`;
|
|
1425
|
+
|
|
1426
|
+
if (!isForce) {
|
|
1427
|
+
// ── 第一步:扫描并展示文件列表 ──
|
|
1428
|
+
const files = scanDirectoryFiles(targetDir);
|
|
1429
|
+
|
|
1430
|
+
if (files.length === 0) {
|
|
1431
|
+
return [
|
|
1432
|
+
`✅ 当前没有需要清理的文件`,
|
|
1433
|
+
``,
|
|
1434
|
+
`目录 \`${displayDir}\` 为空或不存在。`,
|
|
1435
|
+
].join("\n");
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
1439
|
+
const MAX_DISPLAY = 10;
|
|
1440
|
+
|
|
1441
|
+
const lines: string[] = [
|
|
1442
|
+
`即将清理 \`${displayDir}\` 目录下所有文件,总共 ${files.length} 个文件,占用磁盘存储空间 ${formatBytes(totalSize)}。`,
|
|
1443
|
+
``,
|
|
1444
|
+
`目录文件概况:`,
|
|
1445
|
+
];
|
|
1446
|
+
|
|
1447
|
+
// 展示前 MAX_DISPLAY 个(按大小降序)
|
|
1448
|
+
const displayFiles = files.slice(0, MAX_DISPLAY);
|
|
1449
|
+
for (const f of displayFiles) {
|
|
1450
|
+
const relativePath = path.relative(targetDir, f.filePath);
|
|
1451
|
+
// 在 Windows 上统一用 / 分隔显示
|
|
1452
|
+
const displayName = relativePath.replace(/\\/g, "/");
|
|
1453
|
+
lines.push(`${displayName} (${formatBytes(f.size)})`, ``, ``);
|
|
1454
|
+
}
|
|
1455
|
+
if (files.length > MAX_DISPLAY) {
|
|
1456
|
+
lines.push(`...[合计:${files.length} 个文件(${formatBytes(totalSize)})]`, ``);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
lines.push(
|
|
1460
|
+
``,
|
|
1461
|
+
`---`,
|
|
1462
|
+
``,
|
|
1463
|
+
`确认清理后,上述保存在 OpenClaw 运行主机磁盘上的文件将永久删除,后续对话过程中AI无法再找回相关文件。`,
|
|
1464
|
+
`‼️ 点击指令确认删除`,
|
|
1465
|
+
`<qqbot-cmd-enter text="/bot-clear-storage --force" />`,
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
return lines.join("\n");
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// ── 第二步:--force 执行删除 ──
|
|
1472
|
+
const files = scanDirectoryFiles(targetDir);
|
|
1473
|
+
|
|
1474
|
+
if (files.length === 0) {
|
|
1475
|
+
return `✅ 目录已为空,无需清理`;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
let deletedCount = 0;
|
|
1479
|
+
let deletedSize = 0;
|
|
1480
|
+
let failedCount = 0;
|
|
1481
|
+
|
|
1482
|
+
for (const f of files) {
|
|
1483
|
+
try {
|
|
1484
|
+
fs.unlinkSync(f.filePath);
|
|
1485
|
+
deletedCount++;
|
|
1486
|
+
deletedSize += f.size;
|
|
1487
|
+
} catch {
|
|
1488
|
+
failedCount++;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// 尝试清理空目录(递归删除空子目录)
|
|
1493
|
+
try {
|
|
1494
|
+
removeEmptyDirs(targetDir);
|
|
1495
|
+
} catch {
|
|
1496
|
+
// 非关键,静默忽略
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
if (failedCount === 0) {
|
|
1500
|
+
return [
|
|
1501
|
+
`✅ 清理成功`,
|
|
1502
|
+
``,
|
|
1503
|
+
`已删除 ${deletedCount} 个文件,释放 ${formatBytes(deletedSize)} 磁盘空间。`,
|
|
1504
|
+
].join("\n");
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
return [
|
|
1508
|
+
`⚠️ 部分清理完成`,
|
|
1509
|
+
``,
|
|
1510
|
+
`已删除 ${deletedCount} 个文件(${formatBytes(deletedSize)}),${failedCount} 个文件删除失败。`,
|
|
1511
|
+
].join("\n");
|
|
1512
|
+
},
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
/** 递归删除空目录(从叶子向上清理) */
|
|
1516
|
+
function removeEmptyDirs(dirPath: string): void {
|
|
1517
|
+
if (!fs.existsSync(dirPath)) return;
|
|
1518
|
+
let entries: fs.Dirent[];
|
|
1519
|
+
try {
|
|
1520
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
1521
|
+
} catch {
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
for (const entry of entries) {
|
|
1525
|
+
if (entry.isDirectory()) {
|
|
1526
|
+
removeEmptyDirs(path.join(dirPath, entry.name));
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
// 重新读取,如果目录已空则删除
|
|
1530
|
+
try {
|
|
1531
|
+
const remaining = fs.readdirSync(dirPath);
|
|
1532
|
+
if (remaining.length === 0) {
|
|
1533
|
+
fs.rmdirSync(dirPath);
|
|
1534
|
+
}
|
|
1535
|
+
} catch {
|
|
1536
|
+
// 目录可能正在被使用,跳过
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1270
1540
|
// ============ 匹配入口 ============
|
|
1271
1541
|
|
|
1272
1542
|
/**
|
package/src/startup-greeting.ts
CHANGED
|
@@ -7,9 +7,20 @@ import path from "node:path";
|
|
|
7
7
|
import { getQQBotDataDir } from "./utils/platform.js";
|
|
8
8
|
import { getPluginVersion } from "./slash-commands.js";
|
|
9
9
|
|
|
10
|
-
const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
|
|
11
10
|
const STARTUP_GREETING_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
|
|
12
11
|
|
|
12
|
+
function safeName(id: string): string {
|
|
13
|
+
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 按 accountId+appId 区分的 marker 文件路径 */
|
|
17
|
+
function getMarkerFile(accountId: string, appId: string): string {
|
|
18
|
+
return path.join(getQQBotDataDir("data"), `startup-marker-${safeName(accountId)}-${safeName(appId)}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 旧版全局 marker 路径(兼容迁移) */
|
|
22
|
+
const LEGACY_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
|
|
23
|
+
|
|
13
24
|
export function getFirstLaunchGreetingText(): string {
|
|
14
25
|
return `Haha,我的'灵魂'已上线,随时等你吩咐。`;
|
|
15
26
|
}
|
|
@@ -27,21 +38,32 @@ export type StartupMarkerData = {
|
|
|
27
38
|
lastFailureVersion?: string;
|
|
28
39
|
};
|
|
29
40
|
|
|
30
|
-
export function readStartupMarker(): StartupMarkerData {
|
|
41
|
+
export function readStartupMarker(accountId: string, appId: string): StartupMarkerData {
|
|
31
42
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
// 1. 新版 per-bot 路径优先
|
|
44
|
+
const file = getMarkerFile(accountId, appId);
|
|
45
|
+
if (fs.existsSync(file)) {
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8")) as StartupMarkerData;
|
|
34
47
|
return data || {};
|
|
35
48
|
}
|
|
49
|
+
// 2. fallback 旧版全局 marker(兼容迁移)
|
|
50
|
+
if (fs.existsSync(LEGACY_MARKER_FILE)) {
|
|
51
|
+
const data = JSON.parse(fs.readFileSync(LEGACY_MARKER_FILE, "utf8")) as StartupMarkerData;
|
|
52
|
+
if (data) {
|
|
53
|
+
// 自动迁移:写到新路径
|
|
54
|
+
writeStartupMarker(accountId, appId, data);
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
36
58
|
} catch {
|
|
37
59
|
// 文件损坏或不存在,视为无 marker
|
|
38
60
|
}
|
|
39
61
|
return {};
|
|
40
62
|
}
|
|
41
63
|
|
|
42
|
-
export function writeStartupMarker(data: StartupMarkerData): void {
|
|
64
|
+
export function writeStartupMarker(accountId: string, appId: string, data: StartupMarkerData): void {
|
|
43
65
|
try {
|
|
44
|
-
fs.writeFileSync(
|
|
66
|
+
fs.writeFileSync(getMarkerFile(accountId, appId), JSON.stringify(data) + "\n");
|
|
45
67
|
} catch {
|
|
46
68
|
// ignore
|
|
47
69
|
}
|
|
@@ -54,9 +76,9 @@ export function writeStartupMarker(data: StartupMarkerData): void {
|
|
|
54
76
|
* - 同版本 → 不发送
|
|
55
77
|
* - 同版本近期失败 → 冷却期内不重试
|
|
56
78
|
*/
|
|
57
|
-
export function getStartupGreetingPlan(): { shouldSend: boolean; greeting?: string; version: string; reason?: string } {
|
|
79
|
+
export function getStartupGreetingPlan(accountId: string, appId: string): { shouldSend: boolean; greeting?: string; version: string; reason?: string } {
|
|
58
80
|
const currentVersion = getPluginVersion();
|
|
59
|
-
const marker = readStartupMarker();
|
|
81
|
+
const marker = readStartupMarker(accountId, appId);
|
|
60
82
|
|
|
61
83
|
if (marker.version === currentVersion) {
|
|
62
84
|
return { shouldSend: false, version: currentVersion, reason: "same-version" };
|
|
@@ -77,19 +99,19 @@ export function getStartupGreetingPlan(): { shouldSend: boolean; greeting?: stri
|
|
|
77
99
|
return { shouldSend: true, greeting, version: currentVersion };
|
|
78
100
|
}
|
|
79
101
|
|
|
80
|
-
export function markStartupGreetingSent(version: string): void {
|
|
81
|
-
writeStartupMarker({
|
|
102
|
+
export function markStartupGreetingSent(accountId: string, appId: string, version: string): void {
|
|
103
|
+
writeStartupMarker(accountId, appId, {
|
|
82
104
|
version,
|
|
83
105
|
startedAt: new Date().toISOString(),
|
|
84
106
|
greetedAt: new Date().toISOString(),
|
|
85
107
|
});
|
|
86
108
|
}
|
|
87
109
|
|
|
88
|
-
export function markStartupGreetingFailed(version: string, reason: string): void {
|
|
89
|
-
const marker = readStartupMarker();
|
|
110
|
+
export function markStartupGreetingFailed(accountId: string, appId: string, version: string, reason: string): void {
|
|
111
|
+
const marker = readStartupMarker(accountId, appId);
|
|
90
112
|
// 同版本已有失败记录时,不覆盖 lastFailureAt,避免冷却期被无限续期
|
|
91
113
|
const shouldPreserveTimestamp = marker.lastFailureVersion === version && marker.lastFailureAt;
|
|
92
|
-
writeStartupMarker({
|
|
114
|
+
writeStartupMarker(accountId, appId, {
|
|
93
115
|
...marker,
|
|
94
116
|
lastFailureVersion: version,
|
|
95
117
|
lastFailureAt: shouldPreserveTimestamp ? marker.lastFailureAt! : new Date().toISOString(),
|