@tencent-connect/openclaw-qqbot 1.6.6 → 1.6.7-beta.3

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
@@ -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
@@ -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
+ }
@@ -152,7 +152,7 @@ function checkUpgradeCompatibility() {
152
152
  // 3. 检查 Node.js 版本
153
153
  const nodeVer = process.version.replace(/^v/, "");
154
154
  if (compareSemver(nodeVer, req.minNodeVersion) < 0) {
155
- errors.push(`❌ Node.js 版本过低:当前 **v${nodeVer}**,热更新要求最低 **v${req.minNodeVersion}**`);
155
+ errors.push(`❌ NoVBNde.js 版本过低:当前 **v${nodeVer}**,热更新要求最低 **v${req.minNodeVersion}**`);
156
156
  }
157
157
  // 4. 检查系统架构(arm 等特殊架构提示)
158
158
  const arch = process.arch;
@@ -642,15 +642,24 @@ function cleanupTempScript() {
642
642
  *
643
643
  * 安全机制:脚本会被复制到临时目录再执行,避免升级过程中插件目录被操作导致脚本自身丢失。
644
644
  */
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
- })();
645
+ function fireHotUpgrade(targetVersion, pkg, useLocal) {
646
+ // --local: 直接使用本地脚本,跳过远端下载
647
+ // 默认: 优先从远端下载升级脚本,避免使用本地可能过时的版本
648
+ const scriptPath = useLocal
649
+ ? (() => {
650
+ const local = getUpgradeScriptPath();
651
+ if (!local)
652
+ return null;
653
+ console.log(`[qqbot] fireHotUpgrade: --local specified, using local script: ${local}`);
654
+ return copyScriptToTemp(local) || local;
655
+ })()
656
+ : downloadRemoteUpgradeScript() || (() => {
657
+ const local = getUpgradeScriptPath();
658
+ if (!local)
659
+ return null;
660
+ console.log(`[qqbot] fireHotUpgrade: remote download failed, falling back to local script: ${local}`);
661
+ return copyScriptToTemp(local) || local;
662
+ })();
654
663
  if (!scriptPath)
655
664
  return { ok: false, reason: "no-script" };
656
665
  const cli = findCli();
@@ -669,6 +678,7 @@ function fireHotUpgrade(targetVersion) {
669
678
  "-File", scriptPath,
670
679
  "-NoRestart",
671
680
  ...(targetVersion ? ["-Version", targetVersion] : []),
681
+ ...(pkg ? ["-Pkg", pkg] : []),
672
682
  ];
673
683
  }
674
684
  else {
@@ -677,9 +687,9 @@ function fireHotUpgrade(targetVersion) {
677
687
  if (!bash)
678
688
  return { ok: false, reason: "no-bash" };
679
689
  shell = bash;
680
- shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : [])];
690
+ shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : []), ...(pkg ? ["--pkg", pkg] : [])];
681
691
  }
682
- console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}`);
692
+ console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}, pkg=${pkg || "default"}`);
683
693
  // 异步执行升级脚本
684
694
  execFile(shell, shellArgs, {
685
695
  timeout: 120_000,
@@ -786,11 +796,13 @@ registerCommand({
786
796
  `/bot-upgrade 检查是否有新版本`,
787
797
  `/bot-upgrade --latest 确认升级到最新版本(需 upgradeMode=hot-reload)`,
788
798
  `/bot-upgrade --version X 升级到指定版本(需 upgradeMode=hot-reload)`,
799
+ `/bot-upgrade --pkg scope/name 指定 npm 包(如 ryantest/openclaw-qqbot)`,
789
800
  `/bot-upgrade --force 强制重新安装当前版本(需 upgradeMode=hot-reload)`,
801
+ `/bot-upgrade --local 使用本地升级脚本(跳过远端下载)`,
790
802
  ].join("\n"),
791
803
  handler: async (ctx) => {
792
804
  const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
793
- const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
805
+ const upgradeMode = ctx.accountConfig?.upgradeMode || "hot-reload";
794
806
  const args = ctx.args.trim();
795
807
  const info = await getUpdateInfo();
796
808
  const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
@@ -834,7 +846,9 @@ registerCommand({
834
846
  }
835
847
  let isForce = false;
836
848
  let isLatest = false;
849
+ let isLocal = false;
837
850
  let versionArg;
851
+ let pkgArg;
838
852
  const tokens = args ? args.split(/\s+/).filter(Boolean) : [];
839
853
  for (let i = 0; i < tokens.length; i += 1) {
840
854
  const t = tokens[i];
@@ -846,6 +860,27 @@ registerCommand({
846
860
  isLatest = true;
847
861
  continue;
848
862
  }
863
+ if (t === "--local") {
864
+ isLocal = true;
865
+ continue;
866
+ }
867
+ if (t === "--pkg") {
868
+ const next = tokens[i + 1];
869
+ if (!next || next.startsWith("--")) {
870
+ return `❌ 参数错误:--pkg 需要包名\n\n示例:/bot-upgrade --pkg ryantest/openclaw-qqbot`;
871
+ }
872
+ pkgArg = next;
873
+ i += 1;
874
+ continue;
875
+ }
876
+ if (t.startsWith("--pkg=")) {
877
+ const v = t.slice("--pkg=".length).trim();
878
+ if (!v) {
879
+ return `❌ 参数错误:--pkg 需要包名\n\n示例:/bot-upgrade --pkg ryantest/openclaw-qqbot`;
880
+ }
881
+ pkgArg = v;
882
+ continue;
883
+ }
849
884
  if (t === "--version") {
850
885
  const next = tokens[i + 1];
851
886
  if (!next || next.startsWith("--")) {
@@ -904,9 +939,17 @@ registerCommand({
904
939
  `🌟官方 GitHub 仓库:[点击前往](${GITHUB_URL})`,
905
940
  ].join("\n");
906
941
  }
942
+ // 解析 npm 包名:--pkg 参数 > 配置项 upgradePkg > 默认
943
+ // 支持 "scope/name"(自动补 @)和 "@scope/name" 两种格式
944
+ let upgradePkg = pkgArg || ctx.accountConfig?.upgradePkg;
945
+ if (upgradePkg) {
946
+ upgradePkg = upgradePkg.trim();
947
+ if (!upgradePkg.startsWith("@"))
948
+ upgradePkg = `@${upgradePkg}`;
949
+ }
907
950
  // ── --version 指定版本:先校验版本号是否存在 ──
908
951
  if (versionArg) {
909
- const exists = await checkVersionExists(versionArg);
952
+ const exists = await checkVersionExists(versionArg, upgradePkg);
910
953
  if (!exists) {
911
954
  return `❌ 版本 ${versionArg} 不存在,请检查版本号`;
912
955
  }
@@ -951,7 +994,7 @@ registerCommand({
951
994
  // 热更新前保存凭证快照,防止更新过程被打断导致 appId/secret 丢失
952
995
  preUpgradeCredentialBackup(ctx.accountId, ctx.appId);
953
996
  // 异步执行升级
954
- const startResult = fireHotUpgrade(targetVersion);
997
+ const startResult = fireHotUpgrade(targetVersion, upgradePkg, isLocal);
955
998
  if (!startResult.ok) {
956
999
  _upgrading = false;
957
1000
  if (startResult.reason === "no-script") {
@@ -1,4 +1,4 @@
1
- import { getRequestTarget } from "../request-context.js";
1
+ import { getRequestTarget, getRequestAccountId } from "../request-context.js";
2
2
  // ========== JSON Schema ==========
3
3
  const RemindSchema = {
4
4
  type: "object",
@@ -100,7 +100,7 @@ function generateJobName(content) {
100
100
  /**
101
101
  * 构建一次性提醒的 cron 工具参数
102
102
  */
103
- function buildOnceJob(params, delayMs, to) {
103
+ function buildOnceJob(params, delayMs, to, accountId) {
104
104
  const atMs = Date.now() + delayMs;
105
105
  const content = params.content;
106
106
  const name = params.name || generateJobName(content);
@@ -115,9 +115,12 @@ function buildOnceJob(params, delayMs, to) {
115
115
  payload: {
116
116
  kind: "agentTurn",
117
117
  message: buildReminderPrompt(content),
118
- deliver: true,
118
+ },
119
+ delivery: {
120
+ mode: "announce",
119
121
  channel: "qqbot",
120
122
  to,
123
+ accountId,
121
124
  },
122
125
  },
123
126
  };
@@ -125,7 +128,7 @@ function buildOnceJob(params, delayMs, to) {
125
128
  /**
126
129
  * 构建周期提醒的 cron 工具参数
127
130
  */
128
- function buildCronJob(params, to) {
131
+ function buildCronJob(params, to, accountId) {
129
132
  const content = params.content;
130
133
  const name = params.name || generateJobName(content);
131
134
  const tz = params.timezone || "Asia/Shanghai";
@@ -139,9 +142,12 @@ function buildCronJob(params, to) {
139
142
  payload: {
140
143
  kind: "agentTurn",
141
144
  message: buildReminderPrompt(content),
142
- deliver: true,
145
+ },
146
+ delivery: {
147
+ mode: "announce",
143
148
  channel: "qqbot",
144
149
  to,
150
+ accountId,
145
151
  },
146
152
  },
147
153
  };
@@ -211,15 +217,17 @@ export function registerRemindTool(api) {
211
217
  if (!resolvedTo) {
212
218
  return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
213
219
  }
220
+ // 从请求上下文获取当前账户 ID(多账户场景),fallback 到 "default"
221
+ const resolvedAccountId = getRequestAccountId() || "default";
214
222
  if (!p.time) {
215
223
  return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
216
224
  }
217
225
  // 判断是 cron 表达式还是相对时间
218
226
  if (isCronExpression(p.time)) {
219
227
  // 周期提醒
220
- const cronJob = buildCronJob(p, resolvedTo);
228
+ const cronJob = buildCronJob(p, resolvedTo, resolvedAccountId);
221
229
  return json({
222
- _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
230
+ _instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
223
231
  cronParams: cronJob,
224
232
  summary: `⏰ 周期提醒: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
225
233
  });
@@ -236,9 +244,9 @@ export function registerRemindTool(api) {
236
244
  if (delayMs < 30_000) {
237
245
  return json({ error: "提醒时间不能少于 30 秒" });
238
246
  }
239
- const onceJob = buildOnceJob(p, delayMs, resolvedTo);
247
+ const onceJob = buildOnceJob(p, delayMs, resolvedTo, resolvedAccountId);
240
248
  return json({
241
- _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
249
+ _instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
242
250
  cronParams: onceJob,
243
251
  summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
244
252
  });
@@ -92,10 +92,17 @@ export interface QQBotAccountConfig {
92
92
  upgradeUrl?: string;
93
93
  /**
94
94
  * /bot-upgrade 指令的行为模式
95
- * - "doc":展示升级文档链接(默认,安全模式)
96
- * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
95
+ * - "doc":展示升级文档链接(安全模式)
96
+ * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新(默认)
97
97
  */
98
98
  upgradeMode?: "doc" | "hot-reload";
99
+ /**
100
+ * /bot-upgrade 热更新时使用的 npm 包名
101
+ * 支持 "scope/name"(自动补 @)或 "@scope/name" 格式
102
+ * 默认: "@tencent-connect/openclaw-qqbot"
103
+ * 示例: "ryantest/openclaw-qqbot"
104
+ */
105
+ upgradePkg?: string;
99
106
  /**
100
107
  * 出站消息合并回复(debounce)配置
101
108
  * 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸
@@ -30,5 +30,7 @@ export declare function getUpdateInfo(): Promise<UpdateInfo>;
30
30
  /**
31
31
  * 检查指定版本是否存在于 npm registry
32
32
  * 用于 /bot-upgrade --version 的前置校验
33
+ * @param version 要检查的版本号
34
+ * @param pkgName 可选的包名(如 "@ryantest/openclaw-qqbot"),默认使用内置包名
33
35
  */
34
- export declare function checkVersionExists(version: string): Promise<boolean>;
36
+ export declare function checkVersionExists(version: string, pkgName?: string): Promise<boolean>;
@@ -99,9 +99,12 @@ export async function getUpdateInfo() {
99
99
  /**
100
100
  * 检查指定版本是否存在于 npm registry
101
101
  * 用于 /bot-upgrade --version 的前置校验
102
+ * @param version 要检查的版本号
103
+ * @param pkgName 可选的包名(如 "@ryantest/openclaw-qqbot"),默认使用内置包名
102
104
  */
103
- export async function checkVersionExists(version) {
104
- for (const baseUrl of REGISTRIES) {
105
+ export async function checkVersionExists(version, pkgName) {
106
+ const registries = pkgName ? buildRegistries(pkgName) : REGISTRIES;
107
+ for (const baseUrl of registries) {
105
108
  try {
106
109
  const url = `${baseUrl}/${version}`;
107
110
  const json = await fetchJson(url, 10_000);
@@ -114,6 +117,14 @@ export async function checkVersionExists(version) {
114
117
  }
115
118
  return false;
116
119
  }
120
+ /** 根据自定义包名构建 registry URL 列表 */
121
+ function buildRegistries(pkgName) {
122
+ const encoded = encodeURIComponent(pkgName);
123
+ return [
124
+ `https://registry.npmjs.org/${encoded}`,
125
+ `https://registry.npmmirror.com/${encoded}`,
126
+ ];
127
+ }
117
128
  function compareVersions(a, b) {
118
129
  const parse = (v) => {
119
130
  const clean = v.replace(/^v/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-connect/openclaw-qqbot",
3
- "version": "1.6.6",
3
+ "version": "1.6.7-beta.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,7 +10,7 @@
10
10
  // globally installed openclaw package, allowing Node's native ESM resolver
11
11
  // (used by jiti with tryNative:true for .js files) to find `openclaw/plugin-sdk`.
12
12
 
13
- import { existsSync, symlinkSync, mkdirSync, realpathSync } from "node:fs";
13
+ import { existsSync, lstatSync, symlinkSync, unlinkSync, rmSync, mkdirSync, realpathSync } from "node:fs";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { execSync } from "node:child_process";
@@ -18,17 +18,30 @@ import { execSync } from "node:child_process";
18
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
19
  const pluginRoot = resolve(__dirname, "..");
20
20
 
21
- // Only run when installed under an openclaw-like extensions directory
22
- // (supports openclaw, clawdbot, moltbot, etc.)
23
- if (!pluginRoot.includes("extensions")) {
24
- process.exit(0);
25
- }
26
-
27
21
  const linkTarget = join(pluginRoot, "node_modules", "openclaw");
28
22
 
29
- // Already linked or exists
23
+ // Check if already a valid symlink pointing to a directory with plugin-sdk/core
30
24
  if (existsSync(linkTarget)) {
31
- process.exit(0);
25
+ try {
26
+ const stat = lstatSync(linkTarget);
27
+ if (stat.isSymbolicLink()) {
28
+ // Symlink exists — verify it has plugin-sdk/core
29
+ if (existsSync(join(linkTarget, "plugin-sdk", "core.js"))) {
30
+ process.exit(0);
31
+ }
32
+ // Symlink is stale or points to wrong target, remove and re-create
33
+ unlinkSync(linkTarget);
34
+ } else if (existsSync(join(linkTarget, "plugin-sdk", "core.js"))) {
35
+ // Real directory with correct structure (e.g. npm installed a good version)
36
+ process.exit(0);
37
+ } else {
38
+ // Real directory from npm install but missing plugin-sdk/core — replace with symlink
39
+ rmSync(linkTarget, { recursive: true, force: true });
40
+ }
41
+ } catch {
42
+ // If stat fails, try to remove and re-create
43
+ try { rmSync(linkTarget, { recursive: true, force: true }); } catch {}
44
+ }
32
45
  }
33
46
 
34
47
  // CLI names to try (openclaw and its aliases)
@@ -17,6 +17,7 @@ param(
17
17
  [string]$Secret = "",
18
18
  [switch]$NoRestart,
19
19
  [string]$Tag = "",
20
+ [string]$Pkg = "",
20
21
  [switch]$Help
21
22
  )
22
23
 
@@ -25,6 +26,13 @@ $PKG_NAME = "@tencent-connect/openclaw-qqbot"
25
26
  $SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Definition
26
27
  $PROJECT_DIR = Split-Path -Parent $SCRIPT_DIR
27
28
 
29
+ # -Pkg 覆盖包名(支持 "scope/name" 自动补 @)
30
+ if ($Pkg) {
31
+ $Pkg = $Pkg.Trim()
32
+ if (-not $Pkg.StartsWith("@")) { $Pkg = "@$Pkg" }
33
+ $PKG_NAME = $Pkg
34
+ }
35
+
28
36
  # Read local version
29
37
  $LOCAL_VERSION = ""
30
38
  try {
@@ -41,6 +49,7 @@ if ($Help) {
41
49
  Write-Host " .\upgrade-via-npm.ps1 -Version [version] # upgrade to specific version"
42
50
  Write-Host " .\upgrade-via-npm.ps1 -SelfVersion # upgrade to repo version ($LOCAL_VERSION)"
43
51
  Write-Host ""
52
+ Write-Host " -Pkg [scope/name] Custom npm package (e.g. ryantest/openclaw-qqbot)"
44
53
  Write-Host " -AppId [appid] QQ bot appid (required on first install)"
45
54
  Write-Host " -Secret [secret] QQ bot secret (required on first install)"
46
55
  exit 0
@@ -18,14 +18,21 @@
18
18
 
19
19
  set -eo pipefail
20
20
 
21
- # 异常退出时清理临时配置文件(防止泄露或残留)
21
+ # 异常退出时清理临时文件(防止泄露或残留)
22
22
  cleanup_on_exit() {
23
23
  if [ -n "$TEMP_CONFIG_FILE" ] && [ -f "$TEMP_CONFIG_FILE" ]; then
24
24
  rm -f "$TEMP_CONFIG_FILE" 2>/dev/null || true
25
25
  fi
26
+ # 异常退出时清理本次备份目录(正常流程中备份已被删除或回滚,这里是兜底)
27
+ if [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then
28
+ rm -rf "$BACKUP_DIR" 2>/dev/null || true
29
+ fi
26
30
  }
27
31
  trap cleanup_on_exit EXIT
28
32
 
33
+ # 清理上次升级可能遗留的备份目录(如上次脚本被 kill 等极端情况)
34
+ find "${TMPDIR:-/tmp}" -maxdepth 1 -name ".qqbot-upgrade-backup-*" -exec rm -rf {} + 2>/dev/null || true
35
+
29
36
  PKG_NAME="@tencent-connect/openclaw-qqbot"
30
37
  PLUGIN_ID="openclaw-qqbot"
31
38
  INSTALL_SRC=""
@@ -56,6 +63,7 @@ print_usage() {
56
63
  echo " upgrade-via-npm.sh --self-version # 升级到当前仓库版本"
57
64
  fi
58
65
  echo ""
66
+ echo " --pkg <scope/name> 指定 npm 包名(如 ryantest/openclaw-qqbot)"
59
67
  echo " --appid <appid> QQ机器人 appid(首次安装时必填)"
60
68
  echo " --secret <secret> QQ机器人 secret(首次安装时必填)"
61
69
  echo ""
@@ -69,22 +77,17 @@ while [[ $# -gt 0 ]]; do
69
77
  case "$1" in
70
78
  --tag)
71
79
  [ -z "$2" ] && echo "❌ --tag 需要参数" && exit 1
72
- _ver="${2#v}" # 去掉 v 前缀(npm 版本号不带 v)
73
- TARGET_VERSION="$_ver"
74
- INSTALL_SRC="${PKG_NAME}@$_ver"
80
+ TARGET_VERSION="${2#v}" # 去掉 v 前缀(npm 版本号不带 v)
75
81
  shift 2
76
82
  ;;
77
83
  --version)
78
84
  [ -z "$2" ] && echo "❌ --version 需要参数" && exit 1
79
- _ver="${2#v}" # 去掉 v 前缀(npm 版本号不带 v)
80
- TARGET_VERSION="$_ver"
81
- INSTALL_SRC="${PKG_NAME}@$_ver"
85
+ TARGET_VERSION="${2#v}" # 去掉 v 前缀(npm 版本号不带 v)
82
86
  shift 2
83
87
  ;;
84
88
  --self-version)
85
89
  [ -z "$LOCAL_VERSION" ] && echo "❌ 无法从 package.json 读取版本" && exit 1
86
90
  TARGET_VERSION="$LOCAL_VERSION"
87
- INSTALL_SRC="${PKG_NAME}@${LOCAL_VERSION}"
88
91
  shift 1
89
92
  ;;
90
93
  --appid)
@@ -97,6 +100,14 @@ while [[ $# -gt 0 ]]; do
97
100
  SECRET="$2"
98
101
  shift 2
99
102
  ;;
103
+ --pkg)
104
+ [ -z "$2" ] && echo "❌ --pkg 需要参数" && exit 1
105
+ _pkg="$2"
106
+ # 支持 "scope/name" 自动补 @
107
+ if [[ "$_pkg" != @* ]]; then _pkg="@$_pkg"; fi
108
+ PKG_NAME="$_pkg"
109
+ shift 2
110
+ ;;
100
111
  --no-restart)
101
112
  NO_RESTART=true
102
113
  shift 1
@@ -108,7 +119,12 @@ while [[ $# -gt 0 ]]; do
108
119
  *) echo "未知选项: $1"; print_usage; exit 1 ;;
109
120
  esac
110
121
  done
111
- INSTALL_SRC="${INSTALL_SRC:-${PKG_NAME}@latest}"
122
+ # 参数解析完毕后统一拼接 INSTALL_SRC(确保 --pkg 无论在 --version 前后都能生效)
123
+ if [ -n "$TARGET_VERSION" ]; then
124
+ INSTALL_SRC="${PKG_NAME}@${TARGET_VERSION}"
125
+ else
126
+ INSTALL_SRC="${PKG_NAME}@latest"
127
+ fi
112
128
 
113
129
  # 环境变量 fallback
114
130
  APPID="${APPID:-$QQBOT_APPID}"
@@ -291,7 +307,7 @@ if [ "$UPGRADE_OK" != "true" ]; then
291
307
  # 备份旧目录(而非直接删除),install 失败时可回滚
292
308
  BACKUP_DIR=""
293
309
  if [ -d "$EXTENSIONS_DIR/$PLUGIN_ID" ]; then
294
- BACKUP_DIR="$EXTENSIONS_DIR/.openclaw-qqbot-backup-$$"
310
+ BACKUP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/.qqbot-upgrade-backup-XXXXXX")"
295
311
  mv "$EXTENSIONS_DIR/$PLUGIN_ID" "$BACKUP_DIR"
296
312
  echo " 已备份旧目录: $BACKUP_DIR"
297
313
  fi
@@ -311,8 +327,9 @@ if [ "$UPGRADE_OK" != "true" ]; then
311
327
  rm -rf "$BACKUP_DIR"
312
328
  echo " 已清理旧版备份"
313
329
  fi
314
- # 清理 openclaw CLI install 可能留下的额外 backup 目录
330
+ # 清理 openclaw CLI install 可能留下的额外 backup 目录(extensions 内遗留 + 新路径)
315
331
  find "$EXTENSIONS_DIR" -maxdepth 1 -name ".openclaw-qqbot-backup-*" -exec rm -rf {} + 2>/dev/null || true
332
+ find "${TMPDIR:-/tmp}" -maxdepth 1 -name ".qqbot-upgrade-backup-*" -exec rm -rf {} + 2>/dev/null || true
316
333
  else
317
334
  echo " ❌ install 失败"
318
335
  # 回滚:恢复旧目录
@@ -344,13 +344,13 @@ if [ ! -f "$_INSTALL_DIR/dist/index.js" ] || [ ! -f "$_INSTALL_DIR/preload.cjs"
344
344
  echo "请先解决安装问题后再运行此脚本。"
345
345
  # 恢复 channels.qqbot 后再退出
346
346
  if [ -n "$_QQBOT_CHANNEL_STASH" ] && [ -n "$_STASH_CFG" ] && [ -f "$_STASH_CFG" ]; then
347
- node -e "
348
- const fs = require('fs');
349
- const cfg = JSON.parse(fs.readFileSync('$_STASH_CFG', 'utf8'));
347
+ _STASH="$_QQBOT_CHANNEL_STASH" _CFG="$_STASH_CFG" node -e '
348
+ const fs = require("fs");
349
+ const cfg = JSON.parse(fs.readFileSync(process.env._CFG, "utf8"));
350
350
  if (!cfg.channels) cfg.channels = {};
351
- cfg.channels.qqbot = $_QQBOT_CHANNEL_STASH;
352
- fs.writeFileSync('$_STASH_CFG', JSON.stringify(cfg, null, 4) + '\n');
353
- " 2>/dev/null || true
351
+ cfg.channels.qqbot = JSON.parse(process.env._STASH);
352
+ fs.writeFileSync(process.env._CFG, JSON.stringify(cfg, null, 4) + "\n");
353
+ ' 2>/dev/null || true
354
354
  fi
355
355
  exit 1
356
356
  ;;
@@ -527,6 +527,7 @@ else
527
527
  # 清理 openclaw CLI install 留下的 backup 目录,
528
528
  # 避免 gateway 发现两个同 id 插件不断刷 duplicate plugin id 警告
529
529
  find "$HOME/.openclaw/extensions/" -maxdepth 1 -name ".openclaw-qqbot-backup-*" -exec rm -rf {} + 2>/dev/null || true
530
+ find "${TMPDIR:-/tmp}" -maxdepth 1 -name ".qqbot-upgrade-backup-*" -exec rm -rf {} + 2>/dev/null || true
530
531
 
531
532
  # 恢复 channels.qqbot 完整配置(防止 plugins install 意外覆盖)
532
533
  # 策略:直接用备份完整覆盖回去,确保 groups / env / prompts / allowFrom 等所有用户配置不丢失。
@@ -591,11 +592,11 @@ echo "[4/6] 准备机器人通道配置..."
591
592
  # 注意:channels.qqbot 已被暂存移除,所以从 _QQBOT_CHANNEL_STASH 读取
592
593
  CURRENT_QQBOT_TOKEN=""
593
594
  if [ -n "$_QQBOT_CHANNEL_STASH" ]; then
594
- CURRENT_QQBOT_TOKEN=$(node -e "
595
- const ch = $_QQBOT_CHANNEL_STASH;
595
+ CURRENT_QQBOT_TOKEN=$(_STASH="$_QQBOT_CHANNEL_STASH" node -e '
596
+ const ch = JSON.parse(process.env._STASH);
596
597
  if (ch.token) { process.stdout.write(ch.token); }
597
- else if (ch.appId && ch.clientSecret) { process.stdout.write(ch.appId + ':' + ch.clientSecret); }
598
- " 2>/dev/null || true)
598
+ else if (ch.appId && ch.clientSecret) { process.stdout.write(ch.appId + ":" + ch.clientSecret); }
599
+ ' 2>/dev/null || true)
599
600
  fi
600
601
 
601
602
  DESIRED_QQBOT_TOKEN=""
@@ -786,34 +787,39 @@ case "$start_choice" in
786
787
 
787
788
  if [ -n "$_target_cfg" ]; then
788
789
  # 构建完整的 channels.qqbot 对象(合并暂存配置 + 新 token + markdown)
789
- node -e "
790
- const fs = require('fs');
791
- const cfg = JSON.parse(fs.readFileSync('$_target_cfg', 'utf8'));
790
+ # 通过环境变量传递,避免 JSON 双引号在 node -e "..." 中被 shell 错误解析
791
+ _STASH="$_QQBOT_CHANNEL_STASH" \
792
+ _DESIRED="$DESIRED_QQBOT_TOKEN" \
793
+ _MD="$MARKDOWN_VALUE" \
794
+ _CFG="$_target_cfg" \
795
+ node -e '
796
+ const fs = require("fs");
797
+ const cfg = JSON.parse(fs.readFileSync(process.env._CFG, "utf8"));
792
798
  if (!cfg.channels) cfg.channels = {};
793
799
 
794
800
  // 从暂存恢复基础配置
795
- const stash = '$_QQBOT_CHANNEL_STASH';
801
+ const stash = process.env._STASH;
796
802
  if (stash) {
797
803
  try { cfg.channels.qqbot = JSON.parse(stash); } catch {}
798
804
  }
799
805
  if (!cfg.channels.qqbot) cfg.channels.qqbot = {};
800
806
 
801
807
  // 覆盖 token(如果有新值)
802
- const desired = '$DESIRED_QQBOT_TOKEN';
803
- if (desired && desired.includes(':')) {
804
- const [appId, ...rest] = desired.split(':');
808
+ const desired = process.env._DESIRED;
809
+ if (desired && desired.includes(":")) {
810
+ const [appId, ...rest] = desired.split(":");
805
811
  cfg.channels.qqbot.appId = appId;
806
- cfg.channels.qqbot.clientSecret = rest.join(':');
812
+ cfg.channels.qqbot.clientSecret = rest.join(":");
807
813
  delete cfg.channels.qqbot.token;
808
814
  }
809
815
 
810
816
  // 覆盖 markdown(如果有指定)
811
- const md = '$MARKDOWN_VALUE';
812
- if (md === 'true') cfg.channels.qqbot.markdownSupport = true;
813
- else if (md === 'false') cfg.channels.qqbot.markdownSupport = false;
817
+ const md = process.env._MD;
818
+ if (md === "true") cfg.channels.qqbot.markdownSupport = true;
819
+ else if (md === "false") cfg.channels.qqbot.markdownSupport = false;
814
820
 
815
- fs.writeFileSync('$_target_cfg', JSON.stringify(cfg, null, 4) + '\n');
816
- " 2>/dev/null || true
821
+ fs.writeFileSync(process.env._CFG, JSON.stringify(cfg, null, 4) + "\n");
822
+ ' 2>&1 || echo " ⚠️ 配置写入失败"
817
823
  echo " ✅ 已恢复 channels.qqbot 配置(含 token/markdown)"
818
824
  _need_reload=1
819
825
  fi
@@ -883,13 +889,13 @@ case "$start_choice" in
883
889
  # 注意:下次 gateway 启动可能因 "unknown channel id" 失败,
884
890
  # 需要用户手动 stop → 移除 channels.qqbot → start → 恢复
885
891
  if [ -n "$_QQBOT_CHANNEL_STASH" ] && [ -n "$_STASH_CFG" ] && [ -f "$_STASH_CFG" ]; then
886
- node -e "
887
- const fs = require('fs');
888
- const cfg = JSON.parse(fs.readFileSync('$_STASH_CFG', 'utf8'));
892
+ _STASH="$_QQBOT_CHANNEL_STASH" _CFG="$_STASH_CFG" node -e '
893
+ const fs = require("fs");
894
+ const cfg = JSON.parse(fs.readFileSync(process.env._CFG, "utf8"));
889
895
  if (!cfg.channels) cfg.channels = {};
890
- cfg.channels.qqbot = $_QQBOT_CHANNEL_STASH;
891
- fs.writeFileSync('$_STASH_CFG', JSON.stringify(cfg, null, 4) + '\n');
892
- " 2>/dev/null || true
896
+ cfg.channels.qqbot = JSON.parse(process.env._STASH);
897
+ fs.writeFileSync(process.env._CFG, JSON.stringify(cfg, null, 4) + "\n");
898
+ ' 2>/dev/null || true
893
899
  echo " 已恢复 channels.qqbot 配置"
894
900
  fi
895
901
  echo ""
@@ -26,7 +26,7 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
26
26
  | `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
27
27
  | `jobId` | 任务 ID(仅 remove) | `"xxx"` |
28
28
 
29
- **第二步**:`qqbot_remind` 会返回 `cronParams`,你必须**立即**将 `cronParams` 的值作为参数调用 `cron` 工具。
29
+ **第二步**:`qqbot_remind` 会返回 `cronParams`,你必须**立即**将 `cronParams` 的值**原样**作为参数调用 `cron` 工具。**禁止修改、省略或重组 `cronParams` 中的任何字段**(尤其是 `delivery` 对象中的 `accountId`)。
30
30
 
31
31
  **第三步**:根据 `cron` 工具的返回结果,回复用户。
32
32
 
@@ -54,11 +54,13 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
54
54
  | 字段 | 固定值 | 原因 |
55
55
  |------|--------|------|
56
56
  | `payload.kind` | `"agentTurn"` | `systemEvent` 不会发 QQ 消息 |
57
- | `payload.deliver` | `true` | 否则不投递 |
58
- | `payload.channel` | `"qqbot"` | QQ 通道标识 |
59
- | `payload.to` | 用户 openid | 从 `To` 字段获取 |
57
+ | `delivery.mode` | `"announce"` | 投递模式 |
58
+ | `delivery.channel` | `"qqbot"` | QQ 通道标识 |
59
+ | `delivery.to` | 用户 openid | 从 `To` 字段获取 |
60
60
  | `sessionTarget` | `"isolated"` | 隔离会话避免污染 |
61
61
 
62
+ > `delivery.accountId` 必须填写当前会话的账户 ID(如果已知),以确保多账户场景下消息通过正确的机器人账户发送。
63
+
62
64
  > `schedule.atMs` 必须是**绝对毫秒时间戳**(如 `1770733800000`),不支持 `"5m"` 等相对字符串。
63
65
  > 计算方式:`当前时间戳ms + 延迟毫秒`。
64
66
 
@@ -75,10 +77,13 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
75
77
  "deleteAfterRun": true,
76
78
  "payload": {
77
79
  "kind": "agentTurn",
78
- "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀",
79
- "deliver": true,
80
+ "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀"
81
+ },
82
+ "delivery": {
83
+ "mode": "announce",
80
84
  "channel": "qqbot",
81
- "to": "{openid}"
85
+ "to": "{openid}",
86
+ "accountId": "{accountId}"
82
87
  }
83
88
  }
84
89
  }
@@ -96,16 +101,19 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
96
101
  "wakeMode": "now",
97
102
  "payload": {
98
103
  "kind": "agentTurn",
99
- "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀",
100
- "deliver": true,
104
+ "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀"
105
+ },
106
+ "delivery": {
107
+ "mode": "announce",
101
108
  "channel": "qqbot",
102
- "to": "{openid}"
109
+ "to": "{openid}",
110
+ "accountId": "{accountId}"
103
111
  }
104
112
  }
105
113
  }
106
114
  ```
107
115
 
108
- > 周期任务**不加** `deleteAfterRun`。群聊 `to` 格式为 `"group:{group_openid}"`。
116
+ > 周期任务**不加** `deleteAfterRun`。群聊 `to` 格式为 `"qqbot:group:{group_openid}"`。
109
117
 
110
118
  ---
111
119
 
@@ -147,3 +155,5 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
147
155
  - 周期:`⏰ 收到,{周期}提醒你{内容}~`
148
156
  - 查询无结果:`📋 目前没有提醒哦~ 说"5分钟后提醒我xxx"试试?`
149
157
  - 删除成功:`✅ 已取消"{名称}"`
158
+
159
+ openclaw cron add \ --name "下班提醒" \ --at "2026-03-26T21:42:31+08:00" \ --message "test" \ --to …``
package/src/api.ts CHANGED
@@ -137,7 +137,7 @@ async function doFetchToken(appId: string, clientSecret: string): Promise<string
137
137
  const requestHeaders = { "Content-Type": "application/json", "User-Agent": PLUGIN_USER_AGENT };
138
138
 
139
139
  // 打印请求信息(隐藏敏感信息)
140
- console.log(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL}`);
140
+ console.log(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL} [secret: ${clientSecret.slice(0, 6)}...len=${clientSecret.length}]`);
141
141
 
142
142
  let response: Response;
143
143
  try {
package/src/config.ts CHANGED
@@ -227,7 +227,7 @@ export function resolveQQBotAccount(
227
227
  cfg: OpenClawConfig,
228
228
  accountId?: string | null
229
229
  ): ResolvedQQBotAccount {
230
- const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
230
+ const resolvedAccountId = accountId ?? resolveDefaultQQBotAccountId(cfg);
231
231
  const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
232
232
 
233
233
  // 基础配置
package/src/gateway.ts CHANGED
@@ -1333,7 +1333,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1333
1333
 
1334
1334
  // 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
1335
1335
  // (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
1336
- await runWithRequestContext({ target: qualifiedTarget }, async () => {
1336
+ await runWithRequestContext({ target: qualifiedTarget, accountId: account.accountId }, async () => {
1337
1337
  try {
1338
1338
  const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
1339
1339
 
@@ -11,6 +11,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
11
11
  export interface RequestContext {
12
12
  /** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
13
13
  target: string;
14
+ /** 当前请求的 QQBot 账户 ID(多账户场景) */
15
+ accountId?: string;
14
16
  }
15
17
 
16
18
  const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
@@ -37,3 +39,11 @@ export function getRequestContext(): RequestContext | undefined {
37
39
  export function getRequestTarget(): string | undefined {
38
40
  return asyncLocalStorage.getStore()?.target;
39
41
  }
42
+
43
+ /**
44
+ * 获取当前请求的账户 ID。
45
+ * 便捷方法,等价于 getRequestContext()?.accountId。
46
+ */
47
+ export function getRequestAccountId(): string | undefined {
48
+ return asyncLocalStorage.getStore()?.accountId;
49
+ }
@@ -162,7 +162,7 @@ function checkUpgradeCompatibility(): UpgradeCompatResult {
162
162
  // 3. 检查 Node.js 版本
163
163
  const nodeVer = process.version.replace(/^v/, "");
164
164
  if (compareSemver(nodeVer, req.minNodeVersion) < 0) {
165
- errors.push(`❌ Node.js 版本过低:当前 **v${nodeVer}**,热更新要求最低 **v${req.minNodeVersion}**`);
165
+ errors.push(`❌ NoVBNde.js 版本过低:当前 **v${nodeVer}**,热更新要求最低 **v${req.minNodeVersion}**`);
166
166
  }
167
167
 
168
168
  // 4. 检查系统架构(arm 等特殊架构提示)
@@ -746,14 +746,22 @@ function cleanupTempScript(): void {
746
746
  *
747
747
  * 安全机制:脚本会被复制到临时目录再执行,避免升级过程中插件目录被操作导致脚本自身丢失。
748
748
  */
749
- function fireHotUpgrade(targetVersion?: string): HotUpgradeStartResult {
750
- // 优先从远端下载升级脚本,避免使用本地可能过时的版本
751
- const scriptPath = downloadRemoteUpgradeScript() || (() => {
752
- const local = getUpgradeScriptPath();
753
- if (!local) return null;
754
- console.log(`[qqbot] fireHotUpgrade: remote download failed, falling back to local script: ${local}`);
755
- return copyScriptToTemp(local) || local;
756
- })();
749
+ function fireHotUpgrade(targetVersion?: string, pkg?: string, useLocal?: boolean): HotUpgradeStartResult {
750
+ // --local: 直接使用本地脚本,跳过远端下载
751
+ // 默认: 优先从远端下载升级脚本,避免使用本地可能过时的版本
752
+ const scriptPath = useLocal
753
+ ? (() => {
754
+ const local = getUpgradeScriptPath();
755
+ if (!local) return null;
756
+ console.log(`[qqbot] fireHotUpgrade: --local specified, using local script: ${local}`);
757
+ return copyScriptToTemp(local) || local;
758
+ })()
759
+ : downloadRemoteUpgradeScript() || (() => {
760
+ const local = getUpgradeScriptPath();
761
+ if (!local) return null;
762
+ console.log(`[qqbot] fireHotUpgrade: remote download failed, falling back to local script: ${local}`);
763
+ return copyScriptToTemp(local) || local;
764
+ })();
757
765
  if (!scriptPath) return { ok: false, reason: "no-script" };
758
766
 
759
767
  const cli = findCli();
@@ -772,16 +780,17 @@ function fireHotUpgrade(targetVersion?: string): HotUpgradeStartResult {
772
780
  "-File", scriptPath,
773
781
  "-NoRestart",
774
782
  ...(targetVersion ? ["-Version", targetVersion] : []),
783
+ ...(pkg ? ["-Pkg", pkg] : []),
775
784
  ];
776
785
  } else {
777
786
  // Mac / Linux: bash 执行 .sh
778
787
  const bash = findBash();
779
788
  if (!bash) return { ok: false, reason: "no-bash" };
780
789
  shell = bash;
781
- shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : [])];
790
+ shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : []), ...(pkg ? ["--pkg", pkg] : [])];
782
791
  }
783
792
 
784
- console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}`);
793
+ console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}, pkg=${pkg || "default"}`);
785
794
 
786
795
  // 异步执行升级脚本
787
796
  execFile(shell, shellArgs, {
@@ -894,11 +903,13 @@ registerCommand({
894
903
  `/bot-upgrade 检查是否有新版本`,
895
904
  `/bot-upgrade --latest 确认升级到最新版本(需 upgradeMode=hot-reload)`,
896
905
  `/bot-upgrade --version X 升级到指定版本(需 upgradeMode=hot-reload)`,
906
+ `/bot-upgrade --pkg scope/name 指定 npm 包(如 ryantest/openclaw-qqbot)`,
897
907
  `/bot-upgrade --force 强制重新安装当前版本(需 upgradeMode=hot-reload)`,
908
+ `/bot-upgrade --local 使用本地升级脚本(跳过远端下载)`,
898
909
  ].join("\n"),
899
910
  handler: async (ctx) => {
900
911
  const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
901
- const upgradeMode = ctx.accountConfig?.upgradeMode || "doc";
912
+ const upgradeMode = ctx.accountConfig?.upgradeMode || "hot-reload";
902
913
  const args = ctx.args.trim();
903
914
  const info = await getUpdateInfo();
904
915
 
@@ -949,7 +960,9 @@ registerCommand({
949
960
 
950
961
  let isForce = false;
951
962
  let isLatest = false;
963
+ let isLocal = false;
952
964
  let versionArg: string | undefined;
965
+ let pkgArg: string | undefined;
953
966
  const tokens = args ? args.split(/\s+/).filter(Boolean) : [];
954
967
  for (let i = 0; i < tokens.length; i += 1) {
955
968
  const t = tokens[i]!;
@@ -961,6 +974,27 @@ registerCommand({
961
974
  isLatest = true;
962
975
  continue;
963
976
  }
977
+ if (t === "--local") {
978
+ isLocal = true;
979
+ continue;
980
+ }
981
+ if (t === "--pkg") {
982
+ const next = tokens[i + 1];
983
+ if (!next || next.startsWith("--")) {
984
+ return `❌ 参数错误:--pkg 需要包名\n\n示例:/bot-upgrade --pkg ryantest/openclaw-qqbot`;
985
+ }
986
+ pkgArg = next;
987
+ i += 1;
988
+ continue;
989
+ }
990
+ if (t.startsWith("--pkg=")) {
991
+ const v = t.slice("--pkg=".length).trim();
992
+ if (!v) {
993
+ return `❌ 参数错误:--pkg 需要包名\n\n示例:/bot-upgrade --pkg ryantest/openclaw-qqbot`;
994
+ }
995
+ pkgArg = v;
996
+ continue;
997
+ }
964
998
  if (t === "--version") {
965
999
  const next = tokens[i + 1];
966
1000
  if (!next || next.startsWith("--")) {
@@ -1022,9 +1056,17 @@ registerCommand({
1022
1056
  ].join("\n");
1023
1057
  }
1024
1058
 
1059
+ // 解析 npm 包名:--pkg 参数 > 配置项 upgradePkg > 默认
1060
+ // 支持 "scope/name"(自动补 @)和 "@scope/name" 两种格式
1061
+ let upgradePkg = pkgArg || ctx.accountConfig?.upgradePkg;
1062
+ if (upgradePkg) {
1063
+ upgradePkg = upgradePkg.trim();
1064
+ if (!upgradePkg.startsWith("@")) upgradePkg = `@${upgradePkg}`;
1065
+ }
1066
+
1025
1067
  // ── --version 指定版本:先校验版本号是否存在 ──
1026
1068
  if (versionArg) {
1027
- const exists = await checkVersionExists(versionArg);
1069
+ const exists = await checkVersionExists(versionArg, upgradePkg);
1028
1070
  if (!exists) {
1029
1071
  return `❌ 版本 ${versionArg} 不存在,请检查版本号`;
1030
1072
  }
@@ -1077,7 +1119,7 @@ registerCommand({
1077
1119
  preUpgradeCredentialBackup(ctx.accountId, ctx.appId);
1078
1120
 
1079
1121
  // 异步执行升级
1080
- const startResult = fireHotUpgrade(targetVersion);
1122
+ const startResult = fireHotUpgrade(targetVersion, upgradePkg, isLocal);
1081
1123
  if (!startResult.ok) {
1082
1124
  _upgrading = false;
1083
1125
  if (startResult.reason === "no-script") {
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { getRequestTarget } from "../request-context.js";
2
+ import { getRequestTarget, getRequestAccountId } from "../request-context.js";
3
3
 
4
4
  // ========== 类型定义 ==========
5
5
 
@@ -134,7 +134,7 @@ function generateJobName(content: string): string {
134
134
  /**
135
135
  * 构建一次性提醒的 cron 工具参数
136
136
  */
137
- function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
137
+ function buildOnceJob(params: RemindParams, delayMs: number, to: string, accountId: string) {
138
138
  const atMs = Date.now() + delayMs;
139
139
  const content = params.content!;
140
140
  const name = params.name || generateJobName(content);
@@ -150,9 +150,12 @@ function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
150
150
  payload: {
151
151
  kind: "agentTurn",
152
152
  message: buildReminderPrompt(content),
153
- deliver: true,
153
+ },
154
+ delivery: {
155
+ mode: "announce",
154
156
  channel: "qqbot",
155
157
  to,
158
+ accountId,
156
159
  },
157
160
  },
158
161
  };
@@ -161,7 +164,7 @@ function buildOnceJob(params: RemindParams, delayMs: number, to: string) {
161
164
  /**
162
165
  * 构建周期提醒的 cron 工具参数
163
166
  */
164
- function buildCronJob(params: RemindParams, to: string) {
167
+ function buildCronJob(params: RemindParams, to: string, accountId: string) {
165
168
  const content = params.content!;
166
169
  const name = params.name || generateJobName(content);
167
170
  const tz = params.timezone || "Asia/Shanghai";
@@ -176,9 +179,12 @@ function buildCronJob(params: RemindParams, to: string) {
176
179
  payload: {
177
180
  kind: "agentTurn",
178
181
  message: buildReminderPrompt(content),
179
- deliver: true,
182
+ },
183
+ delivery: {
184
+ mode: "announce",
180
185
  channel: "qqbot",
181
186
  to,
187
+ accountId,
182
188
  },
183
189
  },
184
190
  };
@@ -256,6 +262,8 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
256
262
  if (!resolvedTo) {
257
263
  return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
258
264
  }
265
+ // 从请求上下文获取当前账户 ID(多账户场景),fallback 到 "default"
266
+ const resolvedAccountId = getRequestAccountId() || "default";
259
267
  if (!p.time) {
260
268
  return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
261
269
  }
@@ -263,9 +271,9 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
263
271
  // 判断是 cron 表达式还是相对时间
264
272
  if (isCronExpression(p.time)) {
265
273
  // 周期提醒
266
- const cronJob = buildCronJob(p, resolvedTo);
274
+ const cronJob = buildCronJob(p, resolvedTo, resolvedAccountId);
267
275
  return json({
268
- _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
276
+ _instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
269
277
  cronParams: cronJob,
270
278
  summary: `⏰ 周期提醒: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
271
279
  });
@@ -285,9 +293,9 @@ export function registerRemindTool(api: OpenClawPluginApi): void {
285
293
  return json({ error: "提醒时间不能少于 30 秒" });
286
294
  }
287
295
 
288
- const onceJob = buildOnceJob(p, delayMs, resolvedTo);
296
+ const onceJob = buildOnceJob(p, delayMs, resolvedTo, resolvedAccountId);
289
297
  return json({
290
- _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
298
+ _instruction: "请立即使用 cron 工具,将 cronParams 的值原样作为参数传入(不要修改或省略任何字段,特别是 delivery.accountId),然后回复用户提醒已设置",
291
299
  cronParams: onceJob,
292
300
  summary: `⏰ ${formatDelay(delayMs)}后提醒: "${p.content}"`,
293
301
  });
package/src/types.ts CHANGED
@@ -97,10 +97,17 @@ export interface QQBotAccountConfig {
97
97
  upgradeUrl?: string;
98
98
  /**
99
99
  * /bot-upgrade 指令的行为模式
100
- * - "doc":展示升级文档链接(默认,安全模式)
101
- * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
100
+ * - "doc":展示升级文档链接(安全模式)
101
+ * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新(默认)
102
102
  */
103
103
  upgradeMode?: "doc" | "hot-reload";
104
+ /**
105
+ * /bot-upgrade 热更新时使用的 npm 包名
106
+ * 支持 "scope/name"(自动补 @)或 "@scope/name" 格式
107
+ * 默认: "@tencent-connect/openclaw-qqbot"
108
+ * 示例: "ryantest/openclaw-qqbot"
109
+ */
110
+ upgradePkg?: string;
104
111
  /**
105
112
  * 出站消息合并回复(debounce)配置
106
113
  * 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸
@@ -122,9 +122,12 @@ export async function getUpdateInfo(): Promise<UpdateInfo> {
122
122
  /**
123
123
  * 检查指定版本是否存在于 npm registry
124
124
  * 用于 /bot-upgrade --version 的前置校验
125
+ * @param version 要检查的版本号
126
+ * @param pkgName 可选的包名(如 "@ryantest/openclaw-qqbot"),默认使用内置包名
125
127
  */
126
- export async function checkVersionExists(version: string): Promise<boolean> {
127
- for (const baseUrl of REGISTRIES) {
128
+ export async function checkVersionExists(version: string, pkgName?: string): Promise<boolean> {
129
+ const registries = pkgName ? buildRegistries(pkgName) : REGISTRIES;
130
+ for (const baseUrl of registries) {
128
131
  try {
129
132
  const url = `${baseUrl}/${version}`;
130
133
  const json = await fetchJson(url, 10_000);
@@ -136,6 +139,15 @@ export async function checkVersionExists(version: string): Promise<boolean> {
136
139
  return false;
137
140
  }
138
141
 
142
+ /** 根据自定义包名构建 registry URL 列表 */
143
+ function buildRegistries(pkgName: string): string[] {
144
+ const encoded = encodeURIComponent(pkgName);
145
+ return [
146
+ `https://registry.npmjs.org/${encoded}`,
147
+ `https://registry.npmmirror.com/${encoded}`,
148
+ ];
149
+ }
150
+
139
151
  function compareVersions(a: string, b: string): number {
140
152
  const parse = (v: string) => {
141
153
  const clean = v.replace(/^v/, "");