@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
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage } from "./api.js";
3
3
  import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload } from "./utils/payload.js";
4
4
  import { resolveTTSConfig, textToSilk, formatDuration } from "./utils/audio-convert.js";
5
- import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize } from "./utils/file-utils.js";
5
+ import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize, getMaxUploadSize } from "./utils/file-utils.js";
6
6
  import { getQQBotDataDir, normalizePath, sanitizeFileName } from "./utils/platform.js";
7
7
  /**
8
8
  * 带 token 过期重试的消息发送
@@ -121,7 +121,7 @@ async function handleImagePayload(ctx, payload) {
121
121
  log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
122
122
  return;
123
123
  }
124
- const imgSzCheck = checkFileSize(imageUrl);
124
+ const imgSzCheck = checkFileSize(imageUrl, getMaxUploadSize(1)); // IMAGE = 1
125
125
  if (!imgSzCheck.ok) {
126
126
  log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
127
127
  return;
@@ -230,7 +230,7 @@ async function handleVideoPayload(ctx, payload) {
230
230
  if (!(await fileExistsAsync(videoPath))) {
231
231
  throw new Error(`视频文件不存在: ${videoPath}`);
232
232
  }
233
- const vPaySzCheck = checkFileSize(videoPath);
233
+ const vPaySzCheck = checkFileSize(videoPath, getMaxUploadSize(2)); // VIDEO = 2
234
234
  if (!vPaySzCheck.ok) {
235
235
  throw new Error(vPaySzCheck.error);
236
236
  }
@@ -285,7 +285,7 @@ async function handleFilePayload(ctx, payload) {
285
285
  if (!(await fileExistsAsync(filePath))) {
286
286
  throw new Error(`文件不存在: ${filePath}`);
287
287
  }
288
- const fPaySzCheck = checkFileSize(filePath);
288
+ const fPaySzCheck = checkFileSize(filePath, getMaxUploadSize(4)); // FILE = 4
289
289
  if (!fPaySzCheck.ok) {
290
290
  throw new Error(fPaySzCheck.error);
291
291
  }
@@ -0,0 +1,18 @@
1
+ export interface RequestContext {
2
+ /** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
3
+ target: string;
4
+ }
5
+ /**
6
+ * 在请求级作用域中执行回调。
7
+ * 作用域内所有同步/异步代码都能通过 getRequestContext() 获取上下文。
8
+ */
9
+ export declare function runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T;
10
+ /**
11
+ * 获取当前请求的上下文,不存在时返回 undefined。
12
+ */
13
+ export declare function getRequestContext(): RequestContext | undefined;
14
+ /**
15
+ * 获取当前请求的投递目标地址。
16
+ * 便捷方法,等价于 getRequestContext()?.target。
17
+ */
18
+ export declare function getRequestTarget(): string | undefined;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 请求级上下文(基于 AsyncLocalStorage)
3
+ *
4
+ * 解决并发消息下工具获取当前会话信息的竞态问题。
5
+ * gateway 在处理每条入站消息时通过 runWithRequestContext() 建立作用域,
6
+ * 作用域内的所有异步代码(包括 AI agent 调用、tool execute)
7
+ * 都能通过 getRequestContext() 安全地拿到当前请求的上下文。
8
+ */
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ const asyncLocalStorage = new AsyncLocalStorage();
11
+ /**
12
+ * 在请求级作用域中执行回调。
13
+ * 作用域内所有同步/异步代码都能通过 getRequestContext() 获取上下文。
14
+ */
15
+ export function runWithRequestContext(ctx, fn) {
16
+ return asyncLocalStorage.run(ctx, fn);
17
+ }
18
+ /**
19
+ * 获取当前请求的上下文,不存在时返回 undefined。
20
+ */
21
+ export function getRequestContext() {
22
+ return asyncLocalStorage.getStore();
23
+ }
24
+ /**
25
+ * 获取当前请求的投递目标地址。
26
+ * 便捷方法,等价于 getRequestContext()?.target。
27
+ */
28
+ export function getRequestTarget() {
29
+ return asyncLocalStorage.getStore()?.target;
30
+ }
@@ -18,16 +18,9 @@ import { getUpdateInfo, checkVersionExists } from "./update-checker.js";
18
18
  import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js";
19
19
  import { saveCredentialBackup } from "./credential-backup.js";
20
20
  import { fileURLToPath } from "node:url";
21
+ import { getPackageVersion } from "./utils/pkg-version.js";
21
22
  const require = createRequire(import.meta.url);
22
- // 读取 package.json 中的版本号
23
- let PLUGIN_VERSION = "unknown";
24
- try {
25
- const pkg = require("../package.json");
26
- PLUGIN_VERSION = pkg.version ?? "unknown";
27
- }
28
- catch {
29
- // fallback
30
- }
23
+ let PLUGIN_VERSION = getPackageVersion(import.meta.url);
31
24
  // 获取 openclaw 框架版本(缓存结果,只执行一次)
32
25
  let _frameworkVersion = null;
33
26
  function getFrameworkVersion() {
@@ -249,16 +242,21 @@ registerCommand({
249
242
  `列出所有可用的 QQBot 插件内置指令及其简要说明。`,
250
243
  `使用 /指令名 ? 可查看某条指令的详细用法。`,
251
244
  ].join("\n"),
252
- handler: () => {
245
+ handler: (ctx) => {
246
+ // 群聊场景排除仅限私聊的指令
247
+ const GROUP_EXCLUDED_COMMANDS = new Set(["bot-upgrade", "bot-clear-storage"]);
248
+ const isGroup = ctx.type === "group";
253
249
  const lines = [`### QQBot插件内置调试指令`, ``];
254
250
  for (const [name, cmd] of commands) {
251
+ if (isGroup && GROUP_EXCLUDED_COMMANDS.has(name))
252
+ continue;
255
253
  lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
256
254
  }
257
255
  lines.push(``, `> 插件版本 v${PLUGIN_VERSION}`);
258
256
  return lines.join("\n");
259
257
  },
260
258
  });
261
- const DEFAULT_UPGRADE_URL = "https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg";
259
+ const DEFAULT_UPGRADE_URL = "https://docs.qq.com/doc/DSGxOZk1oVnVKVkpq";
262
260
  function saveUpgradeGreetingTarget(accountId, appId, openid) {
263
261
  const safeAccountId = accountId.replace(/[^a-zA-Z0-9._-]/g, "_");
264
262
  const safeAppId = appId.replace(/[^a-zA-Z0-9._-]/g, "_");
@@ -568,6 +566,38 @@ function findPowerShell() {
568
566
  }
569
567
  return null;
570
568
  }
569
+ /**
570
+ * 将升级脚本复制到临时位置,避免升级过程中插件目录被清理后脚本丢失。
571
+ * 返回临时脚本路径,失败返回 null。
572
+ */
573
+ function copyScriptToTemp(scriptPath) {
574
+ try {
575
+ const ext = path.extname(scriptPath);
576
+ const tmpDir = path.join(getHomeDir(), ".openclaw", ".qqbot-upgrade-tmp");
577
+ fs.mkdirSync(tmpDir, { recursive: true });
578
+ const tmpScript = path.join(tmpDir, `upgrade-via-npm${ext}`);
579
+ fs.copyFileSync(scriptPath, tmpScript);
580
+ if (!isWindows()) {
581
+ fs.chmodSync(tmpScript, 0o755);
582
+ }
583
+ return tmpScript;
584
+ }
585
+ catch {
586
+ return null;
587
+ }
588
+ }
589
+ /**
590
+ * 清理临时升级脚本目录
591
+ */
592
+ function cleanupTempScript() {
593
+ try {
594
+ const tmpDir = path.join(getHomeDir(), ".openclaw", ".qqbot-upgrade-tmp");
595
+ fs.rmSync(tmpDir, { recursive: true, force: true });
596
+ }
597
+ catch {
598
+ // 非关键,静默忽略
599
+ }
600
+ }
571
601
  /**
572
602
  * 执行热更新:执行脚本(--no-restart) → 立即触发 gateway restart
573
603
  *
@@ -577,11 +607,15 @@ function findPowerShell() {
577
607
  * - 新进程启动时 getStartupGreeting() 检测到版本变更,自动通知管理员
578
608
  *
579
609
  * Windows 使用 PowerShell 执行 .ps1 脚本,Mac/Linux 使用 bash 执行 .sh 脚本。
610
+ *
611
+ * 安全机制:脚本会被复制到临时目录再执行,避免升级过程中插件目录被操作导致脚本自身丢失。
580
612
  */
581
613
  function fireHotUpgrade(targetVersion) {
582
- const scriptPath = getUpgradeScriptPath();
583
- if (!scriptPath)
614
+ const originalScriptPath = getUpgradeScriptPath();
615
+ if (!originalScriptPath)
584
616
  return { ok: false, reason: "no-script" };
617
+ // 将脚本复制到临时位置,避免升级过程中脚本被删除
618
+ const scriptPath = copyScriptToTemp(originalScriptPath) || originalScriptPath;
585
619
  const cli = findCli();
586
620
  if (!cli)
587
621
  return { ok: false, reason: "no-cli" };
@@ -608,7 +642,7 @@ function fireHotUpgrade(targetVersion) {
608
642
  shell = bash;
609
643
  shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : [])];
610
644
  }
611
- console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}`);
645
+ console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath} (original: ${originalScriptPath}), cli=${cli}, target=${targetVersion || "latest"}`);
612
646
  // 异步执行升级脚本
613
647
  execFile(shell, shellArgs, {
614
648
  timeout: 120_000,
@@ -621,6 +655,7 @@ function fireHotUpgrade(targetVersion) {
621
655
  console.error(`[qqbot] fireHotUpgrade: stdout: ${stdout.slice(0, 2000)}`);
622
656
  if (_stderr)
623
657
  console.error(`[qqbot] fireHotUpgrade: stderr: ${_stderr.slice(0, 2000)}`);
658
+ cleanupTempScript();
624
659
  _upgrading = false;
625
660
  return;
626
661
  }
@@ -630,10 +665,13 @@ function fireHotUpgrade(targetVersion) {
630
665
  const newVersion = versionMatch?.[1];
631
666
  if (newVersion === "unknown") {
632
667
  console.error(`[qqbot] fireHotUpgrade: script output QQBOT_NEW_VERSION=unknown, aborting restart`);
668
+ cleanupTempScript();
633
669
  _upgrading = false;
634
670
  return;
635
671
  }
636
672
  console.log(`[qqbot] fireHotUpgrade: new version=${newVersion || "(not detected)"}, triggering restart...`);
673
+ // 脚本执行成功,清理临时脚本副本
674
+ cleanupTempScript();
637
675
  // 文件替换成功,在 restart 之前把 source 从 path 切换为 npm,
638
676
  // 确保新进程启动时读到的是 npm source,不会被本地源码覆盖。
639
677
  switchPluginSourceToNpm();
@@ -693,7 +731,11 @@ function fireHotUpgrade(targetVersion) {
693
731
  /**
694
732
  * /bot-upgrade — 统一升级入口
695
733
  *
696
- * 产品流程:
734
+ * upgradeMode 开关:
735
+ * - "doc"(默认):只展示升级指引文档,不执行热更新
736
+ * - "hot-reload":执行 npm 升级脚本进行热更新
737
+ *
738
+ * 热更新模式下的产品流程:
697
739
  * /bot-upgrade — 展示版本信息+确认按钮(不直接升级)
698
740
  * /bot-upgrade --latest — 确认升级到最新版本
699
741
  * /bot-upgrade --version X — 升级到指定版本
@@ -702,21 +744,49 @@ function fireHotUpgrade(targetVersion) {
702
744
  let _upgrading = false; // 升级锁
703
745
  registerCommand({
704
746
  name: "bot-upgrade",
705
- description: "检查更新并自动热更",
747
+ description: "检查更新并查看升级指引",
706
748
  usage: [
707
- `/bot-upgrade 检查是否有新版本(展示信息+确认按钮)`,
708
- `/bot-upgrade --latest 确认升级到最新版本`,
709
- `/bot-upgrade --version X 升级到指定版本(如 1.6.5)`,
710
- `/bot-upgrade --force 强制重新安装当前版本`,
711
- ``,
712
- `⚠️ 仅在私聊中可用。升级过程约 30~60 秒,期间服务短暂不可用。`,
713
- ``,
714
- `环境要求:`,
715
- ` - 操作系统:macOS / Linux / Windows`,
716
- ` - OpenClaw 框架版本 ≥ ${UPGRADE_REQUIREMENTS.minFrameworkVersion}`,
717
- ` - Node.js ≥ v${UPGRADE_REQUIREMENTS.minNodeVersion}`,
749
+ `/bot-upgrade 检查是否有新版本`,
750
+ `/bot-upgrade --latest 确认升级到最新版本(需 upgradeMode=hot-reload)`,
751
+ `/bot-upgrade --version X 升级到指定版本(需 upgradeMode=hot-reload)`,
752
+ `/bot-upgrade --force 强制重新安装当前版本(需 upgradeMode=hot-reload)`,
718
753
  ].join("\n"),
719
754
  handler: async (ctx) => {
755
+ const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
756
+ const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
757
+ const args = ctx.args.trim();
758
+ const info = await getUpdateInfo();
759
+ const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
760
+ // ── doc 模式(默认):只展示升级指引,不执行热更新 ──
761
+ if (upgradeMode !== "hot-reload") {
762
+ if (info.checkedAt === 0) {
763
+ return `⏳ 版本检查中,请稍后再试`;
764
+ }
765
+ if (info.error) {
766
+ return [
767
+ `❌ 主机网络访问异常,无法检查更新`,
768
+ ``,
769
+ `查看升级指引:[点击查看](${url})`,
770
+ ].join("\n");
771
+ }
772
+ if (!info.hasUpdate) {
773
+ return [
774
+ `✅ 当前已是最新版本 v${PLUGIN_VERSION}`,
775
+ ``,
776
+ `项目地址:[GitHub](${GITHUB_URL})`,
777
+ ].join("\n");
778
+ }
779
+ return [
780
+ `🆕 发现新版本`,
781
+ ``,
782
+ `当前版本:**v${PLUGIN_VERSION}**`,
783
+ `最新版本:**v${info.latest}**`,
784
+ ``,
785
+ `📖 升级指引:[点击查看](${url})`,
786
+ `🌟 官方 GitHub 仓库:[点击前往](${GITHUB_URL})`,
787
+ ].join("\n");
788
+ }
789
+ // ── hot-reload 模式:执行热更新 ──
720
790
  // 升级相关指令仅在私聊中可用
721
791
  if (ctx.type !== "c2c") {
722
792
  return `💡 请在私聊中使用此指令`;
@@ -725,9 +795,6 @@ registerCommand({
725
795
  if (_upgrading) {
726
796
  return `⏳ 正在升级中,请稍候...`;
727
797
  }
728
- const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
729
- const args = ctx.args.trim();
730
- const info = await getUpdateInfo();
731
798
  let isForce = false;
732
799
  let isLatest = false;
733
800
  let versionArg;
@@ -764,7 +831,6 @@ registerCommand({
764
831
  continue;
765
832
  }
766
833
  }
767
- const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
768
834
  // ── 无参数(也没有 --latest / --version / --force):只展示信息+确认按钮 ──
769
835
  if (!versionArg && !isLatest && !isForce) {
770
836
  if (info.checkedAt === 0) {
@@ -785,7 +851,7 @@ registerCommand({
785
851
  ];
786
852
  return lines.join("\n");
787
853
  }
788
- // 有新版本:展示信息 + 确认按钮(同通道:alpha 只展示 alpha,正式版只展示正式版)
854
+ // 有新版本:展示信息 + 确认按钮
789
855
  return [
790
856
  `🆕 发现新版本`,
791
857
  ``,
@@ -1145,6 +1211,185 @@ registerCommand({
1145
1211
  };
1146
1212
  },
1147
1213
  });
1214
+ // ============ /bot-clear-storage ============
1215
+ /**
1216
+ * 扫描指定目录下的所有文件,递归统计。
1217
+ * 返回按文件大小降序排列的文件列表。
1218
+ */
1219
+ function scanDirectoryFiles(dirPath) {
1220
+ const files = [];
1221
+ if (!fs.existsSync(dirPath))
1222
+ return files;
1223
+ const walk = (dir) => {
1224
+ let entries;
1225
+ try {
1226
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1227
+ }
1228
+ catch {
1229
+ return;
1230
+ }
1231
+ for (const entry of entries) {
1232
+ const fullPath = path.join(dir, entry.name);
1233
+ if (entry.isDirectory()) {
1234
+ walk(fullPath);
1235
+ }
1236
+ else if (entry.isFile()) {
1237
+ try {
1238
+ const stat = fs.statSync(fullPath);
1239
+ files.push({ filePath: fullPath, size: stat.size });
1240
+ }
1241
+ catch {
1242
+ // 跳过无法访问的文件
1243
+ }
1244
+ }
1245
+ }
1246
+ };
1247
+ walk(dirPath);
1248
+ // 按大小降序排列
1249
+ files.sort((a, b) => b.size - a.size);
1250
+ return files;
1251
+ }
1252
+ /** 格式化文件大小为人类可读形式 */
1253
+ function formatBytes(bytes) {
1254
+ if (bytes < 1024)
1255
+ return `${bytes} B`;
1256
+ if (bytes < 1024 * 1024)
1257
+ return `${(bytes / 1024).toFixed(1)} KB`;
1258
+ if (bytes < 1024 * 1024 * 1024)
1259
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1260
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
1261
+ }
1262
+ /**
1263
+ * /bot-clear-storage — 清理通过 QQBot 对话产生的文件以及下载的资源
1264
+ *
1265
+ * 仅在私聊(c2c)中可用。
1266
+ * --force 执行时删除整个 appId 目录下的所有文件(不区分用户 openid)。
1267
+ *
1268
+ * 产品流程:
1269
+ * /bot-clear-storage — 扫描并列出当前 appId 下的文件,展示确认按钮
1270
+ * /bot-clear-storage --force — 确认执行删除
1271
+ */
1272
+ registerCommand({
1273
+ name: "bot-clear-storage",
1274
+ description: "清理通过QQBot对话产生的文件以及下载的资源(保存在 OpenClaw 运行环境的主机上)",
1275
+ usage: [
1276
+ `/bot-clear-storage`,
1277
+ ``,
1278
+ `扫描当前机器人产生的下载文件并列出明细。`,
1279
+ `确认后执行删除,释放主机磁盘空间。`,
1280
+ ``,
1281
+ `/bot-clear-storage --force 确认执行清理`,
1282
+ ``,
1283
+ `⚠️ 仅在私聊中可用。`,
1284
+ ].join("\n"),
1285
+ handler: (ctx) => {
1286
+ const { appId, type } = ctx;
1287
+ // 仅私聊可用
1288
+ if (type !== "c2c") {
1289
+ return `💡 请在私聊中使用此指令`;
1290
+ }
1291
+ const isForce = ctx.args.trim() === "--force";
1292
+ // 删除粒度为 appId 目录(不区分用户 openid)
1293
+ // 路径: downloads/{appId}/
1294
+ const targetDir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", "downloads", appId);
1295
+ const displayDir = `~/.openclaw/media/qqbot/downloads/${appId}`;
1296
+ if (!isForce) {
1297
+ // ── 第一步:扫描并展示文件列表 ──
1298
+ const files = scanDirectoryFiles(targetDir);
1299
+ if (files.length === 0) {
1300
+ return [
1301
+ `✅ 当前没有需要清理的文件`,
1302
+ ``,
1303
+ `目录 \`${displayDir}\` 为空或不存在。`,
1304
+ ].join("\n");
1305
+ }
1306
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
1307
+ const MAX_DISPLAY = 10;
1308
+ const lines = [
1309
+ `即将清理 \`${displayDir}\` 目录下所有文件,总共 ${files.length} 个文件,占用磁盘存储空间 ${formatBytes(totalSize)}。`,
1310
+ ``,
1311
+ `目录文件概况:`,
1312
+ ];
1313
+ // 展示前 MAX_DISPLAY 个(按大小降序)
1314
+ const displayFiles = files.slice(0, MAX_DISPLAY);
1315
+ for (const f of displayFiles) {
1316
+ const relativePath = path.relative(targetDir, f.filePath);
1317
+ // 在 Windows 上统一用 / 分隔显示
1318
+ const displayName = relativePath.replace(/\\/g, "/");
1319
+ lines.push(`${displayName} (${formatBytes(f.size)})`, ``, ``);
1320
+ }
1321
+ if (files.length > MAX_DISPLAY) {
1322
+ lines.push(`...[合计:${files.length} 个文件(${formatBytes(totalSize)})]`, ``);
1323
+ }
1324
+ lines.push(``, `---`, ``, `确认清理后,上述保存在 OpenClaw 运行主机磁盘上的文件将永久删除,后续对话过程中AI无法再找回相关文件。`, `‼️ 点击指令确认删除`, `<qqbot-cmd-enter text="/bot-clear-storage --force" />`);
1325
+ return lines.join("\n");
1326
+ }
1327
+ // ── 第二步:--force 执行删除 ──
1328
+ const files = scanDirectoryFiles(targetDir);
1329
+ if (files.length === 0) {
1330
+ return `✅ 目录已为空,无需清理`;
1331
+ }
1332
+ let deletedCount = 0;
1333
+ let deletedSize = 0;
1334
+ let failedCount = 0;
1335
+ for (const f of files) {
1336
+ try {
1337
+ fs.unlinkSync(f.filePath);
1338
+ deletedCount++;
1339
+ deletedSize += f.size;
1340
+ }
1341
+ catch {
1342
+ failedCount++;
1343
+ }
1344
+ }
1345
+ // 尝试清理空目录(递归删除空子目录)
1346
+ try {
1347
+ removeEmptyDirs(targetDir);
1348
+ }
1349
+ catch {
1350
+ // 非关键,静默忽略
1351
+ }
1352
+ if (failedCount === 0) {
1353
+ return [
1354
+ `✅ 清理成功`,
1355
+ ``,
1356
+ `已删除 ${deletedCount} 个文件,释放 ${formatBytes(deletedSize)} 磁盘空间。`,
1357
+ ].join("\n");
1358
+ }
1359
+ return [
1360
+ `⚠️ 部分清理完成`,
1361
+ ``,
1362
+ `已删除 ${deletedCount} 个文件(${formatBytes(deletedSize)}),${failedCount} 个文件删除失败。`,
1363
+ ].join("\n");
1364
+ },
1365
+ });
1366
+ /** 递归删除空目录(从叶子向上清理) */
1367
+ function removeEmptyDirs(dirPath) {
1368
+ if (!fs.existsSync(dirPath))
1369
+ return;
1370
+ let entries;
1371
+ try {
1372
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
1373
+ }
1374
+ catch {
1375
+ return;
1376
+ }
1377
+ for (const entry of entries) {
1378
+ if (entry.isDirectory()) {
1379
+ removeEmptyDirs(path.join(dirPath, entry.name));
1380
+ }
1381
+ }
1382
+ // 重新读取,如果目录已空则删除
1383
+ try {
1384
+ const remaining = fs.readdirSync(dirPath);
1385
+ if (remaining.length === 0) {
1386
+ fs.rmdirSync(dirPath);
1387
+ }
1388
+ }
1389
+ catch {
1390
+ // 目录可能正在被使用,跳过
1391
+ }
1392
+ }
1148
1393
  // ============ 匹配入口 ============
1149
1394
  /**
1150
1395
  * 尝试匹配并执行插件级斜杠指令
@@ -11,8 +11,8 @@ export type StartupMarkerData = {
11
11
  lastFailureReason?: string;
12
12
  lastFailureVersion?: string;
13
13
  };
14
- export declare function readStartupMarker(): StartupMarkerData;
15
- export declare function writeStartupMarker(data: StartupMarkerData): void;
14
+ export declare function readStartupMarker(accountId: string, appId: string): StartupMarkerData;
15
+ export declare function writeStartupMarker(accountId: string, appId: string, data: StartupMarkerData): void;
16
16
  /**
17
17
  * 判断是否需要发送启动问候:
18
18
  * - 首次启动(无 marker)→ "灵魂已上线"
@@ -20,11 +20,11 @@ export declare function writeStartupMarker(data: StartupMarkerData): void;
20
20
  * - 同版本 → 不发送
21
21
  * - 同版本近期失败 → 冷却期内不重试
22
22
  */
23
- export declare function getStartupGreetingPlan(): {
23
+ export declare function getStartupGreetingPlan(accountId: string, appId: string): {
24
24
  shouldSend: boolean;
25
25
  greeting?: string;
26
26
  version: string;
27
27
  reason?: string;
28
28
  };
29
- export declare function markStartupGreetingSent(version: string): void;
30
- export declare function markStartupGreetingFailed(version: string, reason: string): void;
29
+ export declare function markStartupGreetingSent(accountId: string, appId: string, version: string): void;
30
+ export declare function markStartupGreetingFailed(accountId: string, appId: string, version: string, reason: string): void;
@@ -5,29 +5,48 @@ import * as fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { getQQBotDataDir } from "./utils/platform.js";
7
7
  import { getPluginVersion } from "./slash-commands.js";
8
- const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
9
8
  const STARTUP_GREETING_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
9
+ function safeName(id) {
10
+ return id.replace(/[^a-zA-Z0-9._-]/g, "_");
11
+ }
12
+ /** 按 accountId+appId 区分的 marker 文件路径 */
13
+ function getMarkerFile(accountId, appId) {
14
+ return path.join(getQQBotDataDir("data"), `startup-marker-${safeName(accountId)}-${safeName(appId)}.json`);
15
+ }
16
+ /** 旧版全局 marker 路径(兼容迁移) */
17
+ const LEGACY_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
10
18
  export function getFirstLaunchGreetingText() {
11
19
  return `Haha,我的'灵魂'已上线,随时等你吩咐。`;
12
20
  }
13
21
  export function getUpgradeGreetingText(version) {
14
22
  return `🎉 QQBot 插件已更新至 v${version},在线等候你的吩咐。`;
15
23
  }
16
- export function readStartupMarker() {
24
+ export function readStartupMarker(accountId, appId) {
17
25
  try {
18
- if (fs.existsSync(STARTUP_MARKER_FILE)) {
19
- const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8"));
26
+ // 1. 新版 per-bot 路径优先
27
+ const file = getMarkerFile(accountId, appId);
28
+ if (fs.existsSync(file)) {
29
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
20
30
  return data || {};
21
31
  }
32
+ // 2. fallback 旧版全局 marker(兼容迁移)
33
+ if (fs.existsSync(LEGACY_MARKER_FILE)) {
34
+ const data = JSON.parse(fs.readFileSync(LEGACY_MARKER_FILE, "utf8"));
35
+ if (data) {
36
+ // 自动迁移:写到新路径
37
+ writeStartupMarker(accountId, appId, data);
38
+ return data;
39
+ }
40
+ }
22
41
  }
23
42
  catch {
24
43
  // 文件损坏或不存在,视为无 marker
25
44
  }
26
45
  return {};
27
46
  }
28
- export function writeStartupMarker(data) {
47
+ export function writeStartupMarker(accountId, appId, data) {
29
48
  try {
30
- fs.writeFileSync(STARTUP_MARKER_FILE, JSON.stringify(data) + "\n");
49
+ fs.writeFileSync(getMarkerFile(accountId, appId), JSON.stringify(data) + "\n");
31
50
  }
32
51
  catch {
33
52
  // ignore
@@ -40,9 +59,9 @@ export function writeStartupMarker(data) {
40
59
  * - 同版本 → 不发送
41
60
  * - 同版本近期失败 → 冷却期内不重试
42
61
  */
43
- export function getStartupGreetingPlan() {
62
+ export function getStartupGreetingPlan(accountId, appId) {
44
63
  const currentVersion = getPluginVersion();
45
- const marker = readStartupMarker();
64
+ const marker = readStartupMarker(accountId, appId);
46
65
  if (marker.version === currentVersion) {
47
66
  return { shouldSend: false, version: currentVersion, reason: "same-version" };
48
67
  }
@@ -58,18 +77,18 @@ export function getStartupGreetingPlan() {
58
77
  : getUpgradeGreetingText(currentVersion);
59
78
  return { shouldSend: true, greeting, version: currentVersion };
60
79
  }
61
- export function markStartupGreetingSent(version) {
62
- writeStartupMarker({
80
+ export function markStartupGreetingSent(accountId, appId, version) {
81
+ writeStartupMarker(accountId, appId, {
63
82
  version,
64
83
  startedAt: new Date().toISOString(),
65
84
  greetedAt: new Date().toISOString(),
66
85
  });
67
86
  }
68
- export function markStartupGreetingFailed(version, reason) {
69
- const marker = readStartupMarker();
87
+ export function markStartupGreetingFailed(accountId, appId, version, reason) {
88
+ const marker = readStartupMarker(accountId, appId);
70
89
  // 同版本已有失败记录时,不覆盖 lastFailureAt,避免冷却期被无限续期
71
90
  const shouldPreserveTimestamp = marker.lastFailureVersion === version && marker.lastFailureAt;
72
- writeStartupMarker({
91
+ writeStartupMarker(accountId, appId, {
73
92
  ...marker,
74
93
  lastFailureVersion: version,
75
94
  lastFailureAt: shouldPreserveTimestamp ? marker.lastFailureAt : new Date().toISOString(),