@tencent-connect/openclaw-qqbot 1.6.6 → 1.6.7

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 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.6.6`
13
+ ### 🚀 Current Version: `v1.6.7`
14
14
 
15
15
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
16
16
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
@@ -46,6 +46,7 @@ Scan to join the QQ group chat
46
46
  | 📝 **Markdown** | Full Markdown formatting support |
47
47
  | 🛠️ **Commands** | Native OpenClaw command integration |
48
48
  | 💬 **Quoted Context** | Resolve QQ `REFIDX_*` quoted messages and inject quote body into AI context |
49
+ | 📦 **Large File Support** | Auto chunked upload for large files (parallel upload with retry), up to 100 MB |
49
50
 
50
51
  ---
51
52
 
@@ -211,6 +212,10 @@ All commands support a `?` suffix to show usage:
211
212
  >
212
213
  > **QQBot**: 📖 /bot-upgrade usage: …
213
214
 
215
+ #### `/bot-clear-storage` — Clear files generated through QQBot conversations and downloaded resources (stored on the host running OpenClaw)
216
+
217
+ `/bot-clear-storage` lists files generated by the conversation and files in the downloaded resources directory. Use `/bot-clear-storage --force` to confirm deletion.
218
+
214
219
  ---
215
220
 
216
221
  ## 🚀 Getting Started
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  **让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
11
11
 
12
- ### 🚀 当前版本: `v1.6.6`
12
+ ### 🚀 当前版本: `v1.6.7`
13
13
 
14
14
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
15
15
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
@@ -41,6 +41,7 @@
41
41
  | 📝 **Markdown** | 完整支持 Markdown 格式消息 |
42
42
  | 🛠️ **原生命令** | 支持 OpenClaw 原生命令 |
43
43
  | 💬 **引用上下文** | 解析 QQ `REFIDX_*` 引用消息,并将引用内容注入 AI 上下文 |
44
+ | 📦 **大文件支持** | 大文件自动分片并行上传,最大支持 100 MB |
44
45
 
45
46
  ---
46
47
 
@@ -206,6 +207,10 @@ AI 可直接发送视频,支持本地文件和公网 URL。
206
207
  >
207
208
  > **QQBot**:📖 /bot-upgrade 用法:…
208
209
 
210
+ #### `/bot-clear-storage` — 清理通过 QQBot 对话产生的文件以及下载的资源(保存在 OpenClaw 运行环境的主机上)
211
+
212
+ `/bot-clear-storage` 列出对话产生的文件以及下载的资源目录里的文件,使用`/bot-clear-storage -- force`确定删除。
213
+
209
214
  ---
210
215
 
211
216
  ## 🚀 快速开始
package/dist/src/api.js CHANGED
@@ -106,7 +106,7 @@ async function doFetchToken(appId, clientSecret) {
106
106
  const requestBody = { appId, clientSecret };
107
107
  const requestHeaders = { "Content-Type": "application/json", "User-Agent": PLUGIN_USER_AGENT };
108
108
  // 打印请求信息(隐藏敏感信息)
109
- console.log(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL}`);
109
+ console.log(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL} [secret: ${clientSecret.slice(0, 6)}...len=${clientSecret.length}]`);
110
110
  let response;
111
111
  try {
112
112
  response = await fetch(TOKEN_URL, {
@@ -168,7 +168,7 @@ export function resolveDefaultQQBotAccountId(cfg) {
168
168
  * 解析 QQBot 账户配置
169
169
  */
170
170
  export function resolveQQBotAccount(cfg, accountId) {
171
- const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
171
+ const resolvedAccountId = accountId ?? resolveDefaultQQBotAccountId(cfg);
172
172
  const qqbot = cfg.channels?.qqbot;
173
173
  // 基础配置
174
174
  let accountConfig = {};
@@ -1131,7 +1131,7 @@ export async function startGateway(ctx) {
1131
1131
  const sendErrorMessage = (errorText) => sendErrorToTarget(replyCtx, errorText);
1132
1132
  // 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
1133
1133
  // (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
1134
- await runWithRequestContext({ target: qualifiedTarget }, async () => {
1134
+ await runWithRequestContext({ target: qualifiedTarget, accountId: account.accountId }, async () => {
1135
1135
  try {
1136
1136
  const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
1137
1137
  // 追踪是否有响应
@@ -1,6 +1,8 @@
1
1
  export interface RequestContext {
2
2
  /** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
3
3
  target: string;
4
+ /** 当前请求的 QQBot 账户 ID(多账户场景) */
5
+ accountId?: string;
4
6
  }
5
7
  /**
6
8
  * 在请求级作用域中执行回调。
@@ -16,3 +18,8 @@ export declare function getRequestContext(): RequestContext | undefined;
16
18
  * 便捷方法,等价于 getRequestContext()?.target。
17
19
  */
18
20
  export declare function getRequestTarget(): string | undefined;
21
+ /**
22
+ * 获取当前请求的账户 ID。
23
+ * 便捷方法,等价于 getRequestContext()?.accountId。
24
+ */
25
+ export declare function getRequestAccountId(): string | undefined;
@@ -28,3 +28,10 @@ export function getRequestContext() {
28
28
  export function getRequestTarget() {
29
29
  return asyncLocalStorage.getStore()?.target;
30
30
  }
31
+ /**
32
+ * 获取当前请求的账户 ID。
33
+ * 便捷方法,等价于 getRequestContext()?.accountId。
34
+ */
35
+ export function getRequestAccountId() {
36
+ return asyncLocalStorage.getStore()?.accountId;
37
+ }
@@ -21,11 +21,8 @@ import { fileURLToPath } from "node:url";
21
21
  import { getPackageVersion } from "./utils/pkg-version.js";
22
22
  const require = createRequire(import.meta.url);
23
23
  let PLUGIN_VERSION = getPackageVersion(import.meta.url);
24
- // 获取 openclaw 框架版本(缓存结果,只执行一次)
25
- let _frameworkVersion = null;
24
+ // 获取 openclaw 框架版本(不缓存,每次实时获取)
26
25
  export function getFrameworkVersion() {
27
- if (_frameworkVersion !== null)
28
- return _frameworkVersion;
29
26
  try {
30
27
  // 先尝试 PATH 中的 CLI
31
28
  // Windows 上 npm 安装的 CLI 通常是 .cmd wrapper,execFileSync 需要 shell:true 才能执行
@@ -37,8 +34,7 @@ export function getFrameworkVersion() {
37
34
  }).trim();
38
35
  // 输出格式: "OpenClaw 2026.3.13 (61d171a)"
39
36
  if (out) {
40
- _frameworkVersion = out;
41
- return _frameworkVersion;
37
+ return out;
42
38
  }
43
39
  }
44
40
  catch {
@@ -50,16 +46,14 @@ export function getFrameworkVersion() {
50
46
  if (cliPath) {
51
47
  const out = execCliSync(cliPath, ["--version"]);
52
48
  if (out) {
53
- _frameworkVersion = out;
54
- return _frameworkVersion;
49
+ return out;
55
50
  }
56
51
  }
57
52
  }
58
53
  catch {
59
54
  // fallback
60
55
  }
61
- _frameworkVersion = "unknown";
62
- return _frameworkVersion;
56
+ return "unknown";
63
57
  }
64
58
  // ============ 热更新兼容性检查 ============
65
59
  /**
@@ -642,15 +636,24 @@ function cleanupTempScript() {
642
636
  *
643
637
  * 安全机制:脚本会被复制到临时目录再执行,避免升级过程中插件目录被操作导致脚本自身丢失。
644
638
  */
645
- function fireHotUpgrade(targetVersion) {
646
- // 优先从远端下载升级脚本,避免使用本地可能过时的版本
647
- const scriptPath = downloadRemoteUpgradeScript() || (() => {
648
- const local = getUpgradeScriptPath();
649
- if (!local)
650
- return null;
651
- console.log(`[qqbot] fireHotUpgrade: remote download failed, falling back to local script: ${local}`);
652
- return copyScriptToTemp(local) || local;
653
- })();
639
+ function fireHotUpgrade(targetVersion, pkg, useLocal) {
640
+ // --local: 直接使用本地脚本,跳过远端下载
641
+ // 默认: 优先从远端下载升级脚本,避免使用本地可能过时的版本
642
+ const scriptPath = useLocal
643
+ ? (() => {
644
+ const local = getUpgradeScriptPath();
645
+ if (!local)
646
+ return null;
647
+ console.log(`[qqbot] fireHotUpgrade: --local specified, using local script: ${local}`);
648
+ return copyScriptToTemp(local) || local;
649
+ })()
650
+ : downloadRemoteUpgradeScript() || (() => {
651
+ const local = getUpgradeScriptPath();
652
+ if (!local)
653
+ return null;
654
+ console.log(`[qqbot] fireHotUpgrade: remote download failed, falling back to local script: ${local}`);
655
+ return copyScriptToTemp(local) || local;
656
+ })();
654
657
  if (!scriptPath)
655
658
  return { ok: false, reason: "no-script" };
656
659
  const cli = findCli();
@@ -669,6 +672,7 @@ function fireHotUpgrade(targetVersion) {
669
672
  "-File", scriptPath,
670
673
  "-NoRestart",
671
674
  ...(targetVersion ? ["-Version", targetVersion] : []),
675
+ ...(pkg ? ["-Pkg", pkg] : []),
672
676
  ];
673
677
  }
674
678
  else {
@@ -677,13 +681,121 @@ function fireHotUpgrade(targetVersion) {
677
681
  if (!bash)
678
682
  return { ok: false, reason: "no-bash" };
679
683
  shell = bash;
680
- shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : [])];
684
+ shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : []), ...(pkg ? ["--pkg", pkg] : [])];
685
+ }
686
+ console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}, pkg=${pkg || "default"}`);
687
+ // ── 兼容 openclaw 3.23+ 配置严格校验 ──
688
+ // openclaw plugins install/update 启动时会校验整个配置文件,
689
+ // 如果 channels.qqbot 已存在但 qqbot 插件尚未加载,校验会报 "unknown channel id: qqbot"。
690
+ //
691
+ // ⚠️ 关键:绝不能直接修改真实的 openclaw.json!
692
+ // gateway 的 config file watcher 会检测到变更并触发 SIGUSR1 重启,
693
+ // 导致当前进程被杀、execFile 回调(restoreConfigAndCleanup)永远不会执行,
694
+ // channels.qqbot 配置就此丢失。
695
+ //
696
+ // 策略:创建临时配置副本(不含 channels.qqbot),通过 OPENCLAW_CONFIG_PATH
697
+ // 环境变量传递给子进程,真实配置文件不受影响。
698
+ // shell 脚本(upgrade-via-npm.sh)内部也有同样的临时配置机制作为双保险。
699
+ const homeDir = getHomeDir();
700
+ const realConfigPath = path.join(homeDir, ".openclaw", "openclaw.json");
701
+ let tempConfigPath = null;
702
+ const childEnv = { ...process.env };
703
+ try {
704
+ if (fs.existsSync(realConfigPath)) {
705
+ const cfg = JSON.parse(fs.readFileSync(realConfigPath, "utf8"));
706
+ const needsTempConfig = !!(cfg.channels?.qqbot) ||
707
+ !!(cfg.plugins?.entries?.["openclaw-qqbot"]);
708
+ if (needsTempConfig) {
709
+ // 创建临时配置副本(移除 channels.qqbot 和 plugins.entries.openclaw-qqbot)
710
+ const cleanCfg = JSON.parse(JSON.stringify(cfg)); // deep clone
711
+ if (cleanCfg.channels?.qqbot) {
712
+ delete cleanCfg.channels.qqbot;
713
+ if (Object.keys(cleanCfg.channels).length === 0)
714
+ delete cleanCfg.channels;
715
+ }
716
+ if (cleanCfg.plugins?.entries?.["openclaw-qqbot"]) {
717
+ delete cleanCfg.plugins.entries["openclaw-qqbot"];
718
+ if (cleanCfg.plugins.entries && Object.keys(cleanCfg.plugins.entries).length === 0)
719
+ delete cleanCfg.plugins.entries;
720
+ }
721
+ const tmpDir = path.join(homeDir, ".openclaw", ".qqbot-upgrade-tmp");
722
+ fs.mkdirSync(tmpDir, { recursive: true });
723
+ tempConfigPath = path.join(tmpDir, "openclaw-tmp.json");
724
+ fs.writeFileSync(tempConfigPath, JSON.stringify(cleanCfg, null, 4) + "\n");
725
+ childEnv.OPENCLAW_CONFIG_PATH = tempConfigPath;
726
+ console.log(`[qqbot] fireHotUpgrade: created temp config without channels.qqbot (OPENCLAW_CONFIG_PATH=${tempConfigPath}), real config untouched`);
727
+ }
728
+ }
729
+ }
730
+ catch (e) {
731
+ console.warn(`[qqbot] fireHotUpgrade: failed to create temp config: ${e.message}, proceeding with original`);
732
+ tempConfigPath = null;
733
+ }
734
+ /**
735
+ * 将 openclaw plugins install 写入临时配置的 installs/entries 记录同步回真实配置,
736
+ * 然后清理临时文件。
737
+ *
738
+ * 注意:真实配置中的 channels.qqbot 从未被移除,无需恢复。
739
+ */
740
+ function syncTempConfigAndCleanup() {
741
+ try {
742
+ if (tempConfigPath && fs.existsSync(tempConfigPath) && fs.existsSync(realConfigPath)) {
743
+ const tmp = JSON.parse(fs.readFileSync(tempConfigPath, "utf8"));
744
+ const real = JSON.parse(fs.readFileSync(realConfigPath, "utf8"));
745
+ let changed = false;
746
+ // 同步 plugins.installs(openclaw plugins install 会写入安装记录)
747
+ if (tmp.plugins?.installs) {
748
+ if (!real.plugins)
749
+ real.plugins = {};
750
+ real.plugins.installs = { ...(real.plugins.installs || {}), ...tmp.plugins.installs };
751
+ changed = true;
752
+ }
753
+ // 同步 plugins.entries(openclaw plugins install 会写入 entries)
754
+ // 注意:不同步 openclaw-qqbot 自身的 entry,因为插件通过 auto-discover 加载,
755
+ // 显式写入 entries 会导致 "duplicate plugin id" 警告刷屏。
756
+ if (tmp.plugins?.entries) {
757
+ if (!real.plugins)
758
+ real.plugins = {};
759
+ if (!real.plugins.entries)
760
+ real.plugins.entries = {};
761
+ for (const [k, v] of Object.entries(tmp.plugins.entries)) {
762
+ if (k === "openclaw-qqbot")
763
+ continue; // 跳过自身,避免 duplicate
764
+ if (!real.plugins.entries[k]) {
765
+ real.plugins.entries[k] = v;
766
+ changed = true;
767
+ }
768
+ }
769
+ }
770
+ if (changed) {
771
+ fs.writeFileSync(realConfigPath, JSON.stringify(real, null, 4) + "\n");
772
+ console.log("[qqbot] fireHotUpgrade: synced install/entries records from temp config to real config");
773
+ }
774
+ }
775
+ }
776
+ catch (e) {
777
+ console.warn(`[qqbot] fireHotUpgrade: failed to sync temp config: ${e.message}`);
778
+ }
779
+ // 清理临时文件
780
+ try {
781
+ if (tempConfigPath)
782
+ fs.unlinkSync(tempConfigPath);
783
+ }
784
+ catch { /* ignore */ }
681
785
  }
682
- console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}`);
683
786
  // 异步执行升级脚本
684
- execFile(shell, shellArgs, {
685
- timeout: 120_000,
686
- env: { ...process.env },
787
+ // 必须显式设置 cwd 为一个确定存在的目录(如 homeDir),
788
+ // 否则子进程继承 gateway 的 cwd,如果该目录在升级过程中被删除/移动,
789
+ // openclaw CLI 启动时 process.cwd() 会报 ENOENT: uv_cwd 错误。
790
+ // 超时设为 5 分钟:openclaw plugins install 需要下载 npm 包,
791
+ // 网络慢时(如国内访问 npm registry)可能需要 2-3 分钟。
792
+ // 120 秒超时会导致脚本被杀但 openclaw CLI 子进程继续运行,
793
+ // 同时 bash 的 cleanup_on_exit 回滚了备份目录,造成 "plugin already exists" 错误。
794
+ const child = execFile(shell, shellArgs, {
795
+ timeout: 300_000,
796
+ cwd: homeDir,
797
+ env: childEnv,
798
+ killSignal: "SIGTERM",
687
799
  ...(isWindows() ? { windowsHide: true } : {}),
688
800
  }, (error, stdout, _stderr) => {
689
801
  if (error) {
@@ -692,6 +804,25 @@ function fireHotUpgrade(targetVersion) {
692
804
  console.error(`[qqbot] fireHotUpgrade: stdout: ${stdout.slice(0, 2000)}`);
693
805
  if (_stderr)
694
806
  console.error(`[qqbot] fireHotUpgrade: stderr: ${_stderr.slice(0, 2000)}`);
807
+ // 超时时确保子进程树被清理,防止 openclaw plugins install 继续运行
808
+ // 与 cleanup_on_exit 的回滚逻辑冲突(回滚恢复了旧目录,install 又尝试写入)
809
+ if (error.killed || error.message.includes("TIMEOUT")) {
810
+ try {
811
+ // 尝试杀掉子进程树(SIGKILL 确保立即终止)
812
+ child.kill("SIGKILL");
813
+ // 额外尝试通过 pkill 杀掉可能残留的 openclaw plugins install 子进程
814
+ if (!isWindows()) {
815
+ try {
816
+ execFileSync("pkill", ["-9", "-f", "openclaw.*plugins.*install"], { timeout: 3000, stdio: "pipe" });
817
+ }
818
+ catch { /* ignore */ }
819
+ }
820
+ }
821
+ catch {
822
+ // 进程可能已退出
823
+ }
824
+ }
825
+ syncTempConfigAndCleanup();
695
826
  cleanupTempScript();
696
827
  _upgrading = false;
697
828
  return;
@@ -702,12 +833,14 @@ function fireHotUpgrade(targetVersion) {
702
833
  const newVersion = versionMatch?.[1];
703
834
  if (newVersion === "unknown") {
704
835
  console.error(`[qqbot] fireHotUpgrade: script output QQBOT_NEW_VERSION=unknown, aborting restart`);
836
+ syncTempConfigAndCleanup();
705
837
  cleanupTempScript();
706
838
  _upgrading = false;
707
839
  return;
708
840
  }
709
841
  console.log(`[qqbot] fireHotUpgrade: new version=${newVersion || "(not detected)"}, triggering restart...`);
710
- // 脚本执行成功,清理临时脚本副本
842
+ // 脚本执行成功,同步临时配置中的 install 记录并清理
843
+ syncTempConfigAndCleanup();
711
844
  cleanupTempScript();
712
845
  // 文件替换成功,在 restart 之前把 source 从 path 切换为 npm,
713
846
  // 确保新进程启动时读到的是 npm source,不会被本地源码覆盖。
@@ -750,17 +883,175 @@ function fireHotUpgrade(targetVersion) {
750
883
  }
751
884
  }
752
885
  else {
753
- // Mac/Linux: 直接 restart(框架通常以 daemon 模式运行)
754
- execCliAsync(cli, ["gateway", "restart"], { timeout: 30_000 }, (restartErr) => {
755
- if (restartErr) {
756
- console.error(`[qqbot] fireHotUpgrade: restart failed: ${restartErr.message}, trying stop+start fallback`);
757
- execCliAsync(cli, ["gateway", "stop"], { timeout: 10_000 }, () => {
758
- setTimeout(() => {
759
- execCliAsync(cli, ["gateway", "start"], { timeout: 30_000 }, () => { });
760
- }, 1000);
761
- });
886
+ // Mac/Linux: 使用 detached shell 脚本执行 stop+start
887
+ //
888
+ // 兼容 openclaw 2026.3.24+ 配置严格校验:
889
+ // gateway restart openclaw 先校验配置(loadConfig)再加载插件。
890
+ // 如果 channels.qqbot 存在但 qqbot channel type 尚未注册,校验会失败。
891
+ // 解决:stop 后临时移除 channels.qqbot → start(插件加载、qqbot type 注册)→ 恢复。
892
+ const cliInvoke = cli.endsWith(".mjs")
893
+ ? `"${process.execPath}" "${cli}"`
894
+ : `"${cli}"`;
895
+ const homeDir = getHomeDir();
896
+ const configPath = path.join(homeDir, ".openclaw", "openclaw.json");
897
+ const qqbotChannelBackup = path.join(homeDir, ".openclaw", ".qqbot-channel-backup.json");
898
+ const restartScript = path.join(homeDir, ".openclaw", ".qqbot-restart.sh");
899
+ // 先保存 channels.qqbot 到临时文件(在当前进程中,JSON 处理更安全)
900
+ let hasChannel = false;
901
+ try {
902
+ const cfgRaw = fs.readFileSync(configPath, "utf8");
903
+ const cfg = JSON.parse(cfgRaw);
904
+ const qqbot = cfg?.channels?.qqbot;
905
+ if (qqbot) {
906
+ fs.writeFileSync(qqbotChannelBackup, JSON.stringify(qqbot, null, 2), "utf8");
907
+ hasChannel = true;
762
908
  }
763
- });
909
+ }
910
+ catch {
911
+ // 配置文件不存在或 JSON 解析失败,不做处理
912
+ }
913
+ const shContent = `#!/bin/bash
914
+ # 注意:不使用 set -e,因为 gateway start 失败时仍需恢复 channels.qqbot
915
+ CLI="${cliInvoke}"
916
+ CONFIG="${configPath}"
917
+ BACKUP="${qqbotChannelBackup}"
918
+
919
+ # ── 兼容 openclaw 3.23+ 配置严格校验 ──
920
+ # 所有 openclaw CLI 命令(包括 gateway stop/start)启动时都会 loadConfig 校验配置,
921
+ # 如果 channels.qqbot 存在但 qqbot 插件尚未加载,校验会报 "unknown channel id: qqbot"。
922
+ #
923
+ # 策略:
924
+ # 1. gateway stop:使用 OPENCLAW_CONFIG_PATH 临时配置(不含 channels.qqbot)
925
+ # 2. gateway start:先尝试直接启动(真实配置),如果 CLI 校验失败,
926
+ # 则临时修改真实配置(此时 gateway 已停止,无 config watcher),启动后恢复。
927
+ # 这样 gateway 进程读取的是完整配置(含 channels.qqbot)。
928
+
929
+ # 为 gateway stop 创建临时配置
930
+ TEMP_RESTART_CONFIG=""
931
+ if [ -f "$BACKUP" ]; then
932
+ TEMP_RESTART_CONFIG="\$(mktemp)"
933
+ node -e "
934
+ const fs = require('fs');
935
+ const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
936
+ if (cfg.channels && cfg.channels.qqbot) {
937
+ delete cfg.channels.qqbot;
938
+ if (Object.keys(cfg.channels).length === 0) delete cfg.channels;
939
+ }
940
+ if (cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['openclaw-qqbot']) {
941
+ delete cfg.plugins.entries['openclaw-qqbot'];
942
+ if (Object.keys(cfg.plugins.entries).length === 0) delete cfg.plugins.entries;
943
+ }
944
+ fs.writeFileSync(process.argv[2], JSON.stringify(cfg, null, 4) + '\\n');
945
+ " "$CONFIG" "$TEMP_RESTART_CONFIG" 2>/dev/null
946
+ if [ \$? -ne 0 ] || [ ! -s "$TEMP_RESTART_CONFIG" ]; then
947
+ echo "[qqbot-upgrade] WARNING: failed to create temp config"
948
+ TEMP_RESTART_CONFIG=""
949
+ fi
950
+ fi
951
+
952
+ echo "[qqbot-upgrade] Stopping gateway..."
953
+ if [ -n "$TEMP_RESTART_CONFIG" ]; then
954
+ OPENCLAW_CONFIG_PATH="$TEMP_RESTART_CONFIG" $CLI gateway stop 2>/dev/null || true
955
+ else
956
+ $CLI gateway stop 2>/dev/null || true
957
+ fi
958
+ sleep 2
959
+
960
+ # 清理临时配置(不再需要)
961
+ if [ -n "$TEMP_RESTART_CONFIG" ] && [ -f "$TEMP_RESTART_CONFIG" ]; then
962
+ rm -f "$TEMP_RESTART_CONFIG"
963
+ fi
964
+
965
+ echo "[qqbot-upgrade] Starting gateway..."
966
+ START_OK=false
967
+
968
+ # 先尝试直接启动(使用真实配置,含 channels.qqbot)
969
+ # 如果 openclaw 版本不做严格校验,或者插件已注册,这会直接成功
970
+ if $CLI gateway start 2>/dev/null; then
971
+ START_OK=true
972
+ echo "[qqbot-upgrade] Gateway started successfully (direct start)"
973
+ elif [ -f "$BACKUP" ]; then
974
+ # 直接启动失败(可能是 channels.qqbot 校验失败),
975
+ # 临时修改真实配置(此时 gateway 已停止,无 config watcher,安全)
976
+ echo "[qqbot-upgrade] Direct start failed, temporarily removing channels.qqbot from real config..."
977
+ node -e "
978
+ const fs = require('fs');
979
+ const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
980
+ let changed = false;
981
+ if (cfg.channels && cfg.channels.qqbot) {
982
+ delete cfg.channels.qqbot;
983
+ if (Object.keys(cfg.channels).length === 0) delete cfg.channels;
984
+ changed = true;
985
+ }
986
+ if (cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['openclaw-qqbot']) {
987
+ delete cfg.plugins.entries['openclaw-qqbot'];
988
+ if (Object.keys(cfg.plugins.entries).length === 0) delete cfg.plugins.entries;
989
+ changed = true;
990
+ }
991
+ if (changed) {
992
+ fs.writeFileSync(process.argv[1], JSON.stringify(cfg, null, 4) + '\\n');
993
+ }
994
+ " "$CONFIG" 2>/dev/null
995
+
996
+ if $CLI gateway start 2>/dev/null; then
997
+ START_OK=true
998
+ echo "[qqbot-upgrade] Gateway started successfully (after config fix)"
999
+ else
1000
+ echo "[qqbot-upgrade] WARNING: gateway start still failed after config fix"
1001
+ fi
1002
+
1003
+ # 等待 gateway 进程启动并加载插件(插件注册 qqbot channel type)
1004
+ echo "[qqbot-upgrade] Waiting for plugin to load (8s)..."
1005
+ sleep 8
1006
+
1007
+ # 恢复 channels.qqbot 到真实配置
1008
+ # gateway 的 config file watcher 会检测到变更并热加载
1009
+ echo "[qqbot-upgrade] Restoring channels.qqbot to real config..."
1010
+ node -e "
1011
+ const fs = require('fs');
1012
+ const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
1013
+ const qqbot = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1014
+ if (!cfg.channels) cfg.channels = {};
1015
+ cfg.channels.qqbot = qqbot;
1016
+ // 注意:不写入 plugins.entries.openclaw-qqbot,
1017
+ // 插件通过 auto-discover 加载,显式 entry 会导致 duplicate plugin id 警告。
1018
+ fs.writeFileSync(process.argv[1], JSON.stringify(cfg, null, 4) + '\\n');
1019
+ " "$CONFIG" "$BACKUP" 2>/dev/null
1020
+ rm -f "$BACKUP"
1021
+ echo "[qqbot-upgrade] channels.qqbot restored"
1022
+ else
1023
+ echo "[qqbot-upgrade] WARNING: gateway start failed, no backup to restore"
1024
+ fi
1025
+
1026
+ # 直接启动成功的情况下,清理备份文件
1027
+ if [ "$START_OK" = "true" ] && [ -f "$BACKUP" ]; then
1028
+ rm -f "$BACKUP"
1029
+ fi
1030
+
1031
+ # 如果 start 失败,尝试再次启动
1032
+ if [ "$START_OK" != "true" ]; then
1033
+ echo "[qqbot-upgrade] Retrying gateway start..."
1034
+ sleep 2
1035
+ $CLI gateway start 2>/dev/null || echo "[qqbot-upgrade] WARNING: retry also failed"
1036
+ fi
1037
+
1038
+ # 清理自身
1039
+ rm -f "$0"
1040
+ echo "[qqbot-upgrade] Done."
1041
+ `;
1042
+ try {
1043
+ fs.writeFileSync(restartScript, shContent, { mode: 0o755 });
1044
+ const child = spawn("bash", [restartScript], {
1045
+ detached: true,
1046
+ stdio: "ignore",
1047
+ });
1048
+ child.unref();
1049
+ console.log(`[qqbot] fireHotUpgrade: launched detached restart script (pid=${child.pid}), hasChannel=${hasChannel}`);
1050
+ }
1051
+ catch (shErr) {
1052
+ console.error(`[qqbot] fireHotUpgrade: failed to launch restart script: ${shErr.message}, falling back to direct restart`);
1053
+ execCliAsync(cli, ["gateway", "restart"], { timeout: 30_000 }, () => { });
1054
+ }
764
1055
  }
765
1056
  });
766
1057
  return { ok: true };
@@ -786,11 +1077,13 @@ registerCommand({
786
1077
  `/bot-upgrade 检查是否有新版本`,
787
1078
  `/bot-upgrade --latest 确认升级到最新版本(需 upgradeMode=hot-reload)`,
788
1079
  `/bot-upgrade --version X 升级到指定版本(需 upgradeMode=hot-reload)`,
1080
+ `/bot-upgrade --pkg scope/name 指定 npm 包(如 ryantest/openclaw-qqbot)`,
789
1081
  `/bot-upgrade --force 强制重新安装当前版本(需 upgradeMode=hot-reload)`,
1082
+ `/bot-upgrade --local 使用本地升级脚本(跳过远端下载)`,
790
1083
  ].join("\n"),
791
1084
  handler: async (ctx) => {
792
1085
  const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
793
- const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
1086
+ const upgradeMode = ctx.accountConfig?.upgradeMode || "hot-reload";
794
1087
  const args = ctx.args.trim();
795
1088
  const info = await getUpdateInfo();
796
1089
  const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
@@ -834,7 +1127,9 @@ registerCommand({
834
1127
  }
835
1128
  let isForce = false;
836
1129
  let isLatest = false;
1130
+ let isLocal = false;
837
1131
  let versionArg;
1132
+ let pkgArg;
838
1133
  const tokens = args ? args.split(/\s+/).filter(Boolean) : [];
839
1134
  for (let i = 0; i < tokens.length; i += 1) {
840
1135
  const t = tokens[i];
@@ -846,6 +1141,27 @@ registerCommand({
846
1141
  isLatest = true;
847
1142
  continue;
848
1143
  }
1144
+ if (t === "--local") {
1145
+ isLocal = true;
1146
+ continue;
1147
+ }
1148
+ if (t === "--pkg") {
1149
+ const next = tokens[i + 1];
1150
+ if (!next || next.startsWith("--")) {
1151
+ return `❌ 参数错误:--pkg 需要包名\n\n示例:/bot-upgrade --pkg ryantest/openclaw-qqbot`;
1152
+ }
1153
+ pkgArg = next;
1154
+ i += 1;
1155
+ continue;
1156
+ }
1157
+ if (t.startsWith("--pkg=")) {
1158
+ const v = t.slice("--pkg=".length).trim();
1159
+ if (!v) {
1160
+ return `❌ 参数错误:--pkg 需要包名\n\n示例:/bot-upgrade --pkg ryantest/openclaw-qqbot`;
1161
+ }
1162
+ pkgArg = v;
1163
+ continue;
1164
+ }
849
1165
  if (t === "--version") {
850
1166
  const next = tokens[i + 1];
851
1167
  if (!next || next.startsWith("--")) {
@@ -904,9 +1220,17 @@ registerCommand({
904
1220
  `🌟官方 GitHub 仓库:[点击前往](${GITHUB_URL})`,
905
1221
  ].join("\n");
906
1222
  }
1223
+ // 解析 npm 包名:--pkg 参数 > 配置项 upgradePkg > 默认
1224
+ // 支持 "scope/name"(自动补 @)和 "@scope/name" 两种格式
1225
+ let upgradePkg = pkgArg || ctx.accountConfig?.upgradePkg;
1226
+ if (upgradePkg) {
1227
+ upgradePkg = upgradePkg.trim();
1228
+ if (!upgradePkg.startsWith("@"))
1229
+ upgradePkg = `@${upgradePkg}`;
1230
+ }
907
1231
  // ── --version 指定版本:先校验版本号是否存在 ──
908
1232
  if (versionArg) {
909
- const exists = await checkVersionExists(versionArg);
1233
+ const exists = await checkVersionExists(versionArg, upgradePkg);
910
1234
  if (!exists) {
911
1235
  return `❌ 版本 ${versionArg} 不存在,请检查版本号`;
912
1236
  }
@@ -951,7 +1275,7 @@ registerCommand({
951
1275
  // 热更新前保存凭证快照,防止更新过程被打断导致 appId/secret 丢失
952
1276
  preUpgradeCredentialBackup(ctx.accountId, ctx.appId);
953
1277
  // 异步执行升级
954
- const startResult = fireHotUpgrade(targetVersion);
1278
+ const startResult = fireHotUpgrade(targetVersion, upgradePkg, isLocal);
955
1279
  if (!startResult.ok) {
956
1280
  _upgrading = false;
957
1281
  if (startResult.reason === "no-script") {