@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.
Files changed (89) hide show
  1. package/README.md +2 -15
  2. package/README.zh.md +3 -16
  3. package/dist/src/admin-resolver.d.ts +12 -6
  4. package/dist/src/admin-resolver.js +69 -34
  5. package/dist/src/api.d.ts +105 -1
  6. package/dist/src/api.js +164 -15
  7. package/dist/src/channel.js +13 -0
  8. package/dist/src/config.js +3 -10
  9. package/dist/src/deliver-debounce.d.ts +74 -0
  10. package/dist/src/deliver-debounce.js +174 -0
  11. package/dist/src/gateway.js +450 -248
  12. package/dist/src/image-server.d.ts +27 -8
  13. package/dist/src/image-server.js +179 -71
  14. package/dist/src/inbound-attachments.d.ts +3 -1
  15. package/dist/src/inbound-attachments.js +28 -14
  16. package/dist/src/outbound-deliver.js +77 -148
  17. package/dist/src/outbound.d.ts +6 -4
  18. package/dist/src/outbound.js +266 -442
  19. package/dist/src/reply-dispatcher.js +4 -4
  20. package/dist/src/request-context.d.ts +18 -0
  21. package/dist/src/request-context.js +30 -0
  22. package/dist/src/slash-commands.js +277 -32
  23. package/dist/src/startup-greeting.d.ts +5 -5
  24. package/dist/src/startup-greeting.js +32 -13
  25. package/dist/src/streaming.d.ts +244 -0
  26. package/dist/src/streaming.js +907 -0
  27. package/dist/src/tools/remind.js +11 -10
  28. package/dist/src/types.d.ts +101 -0
  29. package/dist/src/types.js +17 -1
  30. package/dist/src/update-checker.js +2 -8
  31. package/dist/src/utils/audio-convert.d.ts +9 -0
  32. package/dist/src/utils/audio-convert.js +51 -0
  33. package/dist/src/utils/chunked-upload.d.ts +59 -0
  34. package/dist/src/utils/chunked-upload.js +289 -0
  35. package/dist/src/utils/file-utils.d.ts +7 -1
  36. package/dist/src/utils/file-utils.js +24 -2
  37. package/dist/src/utils/media-send.d.ts +147 -0
  38. package/dist/src/utils/media-send.js +434 -0
  39. package/dist/src/utils/pkg-version.d.ts +5 -0
  40. package/dist/src/utils/pkg-version.js +51 -0
  41. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  42. package/dist/src/utils/ssrf-guard.js +91 -0
  43. package/node_modules/ws/index.js +15 -6
  44. package/node_modules/ws/lib/permessage-deflate.js +6 -6
  45. package/node_modules/ws/lib/websocket-server.js +5 -5
  46. package/node_modules/ws/lib/websocket.js +6 -6
  47. package/node_modules/ws/package.json +4 -3
  48. package/node_modules/ws/wrapper.mjs +14 -1
  49. package/openclaw.plugin.json +1 -0
  50. package/package.json +11 -22
  51. package/scripts/postinstall-link-sdk.js +113 -0
  52. package/scripts/upgrade-via-npm.ps1 +161 -6
  53. package/scripts/upgrade-via-npm.sh +311 -104
  54. package/scripts/upgrade-via-source.sh +117 -0
  55. package/skills/qqbot-media/SKILL.md +9 -5
  56. package/skills/qqbot-remind/SKILL.md +3 -3
  57. package/src/admin-resolver.ts +76 -35
  58. package/src/api.ts +284 -12
  59. package/src/channel.ts +12 -0
  60. package/src/config.ts +3 -10
  61. package/src/deliver-debounce.ts +229 -0
  62. package/src/gateway.ts +277 -67
  63. package/src/image-server.ts +213 -77
  64. package/src/inbound-attachments.ts +32 -15
  65. package/src/outbound-deliver.ts +77 -157
  66. package/src/outbound.ts +304 -451
  67. package/src/reply-dispatcher.ts +4 -4
  68. package/src/request-context.ts +39 -0
  69. package/src/slash-commands.ts +303 -33
  70. package/src/startup-greeting.ts +35 -13
  71. package/src/streaming.ts +1096 -0
  72. package/src/tools/remind.ts +15 -11
  73. package/src/types.ts +111 -0
  74. package/src/update-checker.ts +2 -7
  75. package/src/utils/audio-convert.ts +56 -0
  76. package/src/utils/chunked-upload.ts +419 -0
  77. package/src/utils/file-utils.ts +28 -2
  78. package/src/utils/media-send.ts +563 -0
  79. package/src/utils/pkg-version.ts +54 -0
  80. package/src/utils/ssrf-guard.ts +102 -0
  81. package/clawdbot.plugin.json +0 -16
  82. package/dist/src/user-messages.d.ts +0 -8
  83. package/dist/src/user-messages.js +0 -8
  84. package/moltbot.plugin.json +0 -16
  85. package/scripts/upgrade-via-alt-pkg.sh +0 -307
  86. package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
  87. package/src/gateway.log +0 -43
  88. package/src/openclaw-2026-03-21.log +0 -3729
  89. package/src/user-messages.ts +0 -7
@@ -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
+ }
@@ -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
- // 读取 package.json 中的版本号
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://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg";
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 scriptPath = getUpgradeScriptPath();
681
- if (!scriptPath) return { ok: false, reason: "no-script" };
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 升级到指定版本(如 1.6.5)`,
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
- // 有新版本:展示信息 + 确认按钮(同通道:alpha 只展示 alpha,正式版只展示正式版)
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
  /**
@@ -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
- if (fs.existsSync(STARTUP_MARKER_FILE)) {
33
- const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8")) as StartupMarkerData;
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(STARTUP_MARKER_FILE, JSON.stringify(data) + "\n");
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(),