@tencent-connect/openclaw-qqbot 1.6.4 → 1.6.5-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.
@@ -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
+ }
@@ -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(),
@@ -1,3 +1,4 @@
1
+ import { getRequestTarget } from "../request-context.js";
1
2
  // ========== JSON Schema ==========
2
3
  const RemindSchema = {
3
4
  type: "object",
@@ -13,8 +14,8 @@ const RemindSchema = {
13
14
  },
14
15
  to: {
15
16
  type: "string",
16
- description: "投递目标地址,取自上下文中 [QQBot] to= 的值。" +
17
- "私聊格式:user_openid,群聊格式:group:group_openid。action=add 时必填。",
17
+ description: "投递目标地址(可选)。系统会自动从当前会话获取,通常无需手动填写。" +
18
+ "私聊格式:qqbot:c2c:user_openid,群聊格式:qqbot:group:group_openid。",
18
19
  },
19
20
  time: {
20
21
  type: "string",
@@ -99,9 +100,8 @@ function generateJobName(content) {
99
100
  /**
100
101
  * 构建一次性提醒的 cron 工具参数
101
102
  */
102
- function buildOnceJob(params, delayMs) {
103
+ function buildOnceJob(params, delayMs, to) {
103
104
  const atMs = Date.now() + delayMs;
104
- const to = params.to;
105
105
  const content = params.content;
106
106
  const name = params.name || generateJobName(content);
107
107
  return {
@@ -125,8 +125,7 @@ function buildOnceJob(params, delayMs) {
125
125
  /**
126
126
  * 构建周期提醒的 cron 工具参数
127
127
  */
128
- function buildCronJob(params) {
129
- const to = params.to;
128
+ function buildCronJob(params, to) {
130
129
  const content = params.content;
131
130
  const name = params.name || generateJobName(content);
132
131
  const tz = params.timezone || "Asia/Shanghai";
@@ -207,8 +206,10 @@ export function registerRemindTool(api) {
207
206
  if (!p.content) {
208
207
  return json({ error: "action=add 时 content(提醒内容)为必填参数" });
209
208
  }
210
- if (!p.to) {
211
- return json({ error: "action=add to(目标地址)为必填参数,取自上下文 [QQBot] to= 的值" });
209
+ // 优先使用 AI 传入的 to,否则自动从请求级上下文获取(AsyncLocalStorage)
210
+ const resolvedTo = p.to || getRequestTarget();
211
+ if (!resolvedTo) {
212
+ return json({ error: "action=add 时无法确定投递目标地址。请稍后重试。" });
212
213
  }
213
214
  if (!p.time) {
214
215
  return json({ error: "action=add 时 time(时间)为必填参数。示例:\"5m\"、\"1h30m\"、\"0 8 * * *\"" });
@@ -216,7 +217,7 @@ export function registerRemindTool(api) {
216
217
  // 判断是 cron 表达式还是相对时间
217
218
  if (isCronExpression(p.time)) {
218
219
  // 周期提醒
219
- const cronJob = buildCronJob(p);
220
+ const cronJob = buildCronJob(p, resolvedTo);
220
221
  return json({
221
222
  _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
222
223
  cronParams: cronJob,
@@ -235,7 +236,7 @@ export function registerRemindTool(api) {
235
236
  if (delayMs < 30_000) {
236
237
  return json({ error: "提醒时间不能少于 30 秒" });
237
238
  }
238
- const onceJob = buildOnceJob(p, delayMs);
239
+ const onceJob = buildOnceJob(p, delayMs, resolvedTo);
239
240
  return json({
240
241
  _instruction: "请立即使用 cron 工具执行以下参数,然后回复用户提醒已设置",
241
242
  cronParams: onceJob,
@@ -68,6 +68,36 @@ export interface QQBotAccountConfig {
68
68
  * - "hot-reload":检测到新版本时直接执行 npm 升级脚本进行热更新
69
69
  */
70
70
  upgradeMode?: "doc" | "hot-reload";
71
+ /**
72
+ * 出站消息合并回复(debounce)配置
73
+ * 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸
74
+ */
75
+ deliverDebounce?: DeliverDebounceConfig;
76
+ }
77
+ /**
78
+ * 出站消息合并回复配置
79
+ */
80
+ export interface DeliverDebounceConfig {
81
+ /**
82
+ * 是否启用合并回复(默认 true)
83
+ */
84
+ enabled?: boolean;
85
+ /**
86
+ * 合并窗口时长(毫秒),在此时间内的连续 deliver 会被合并
87
+ * 默认 1500ms
88
+ */
89
+ windowMs?: number;
90
+ /**
91
+ * 最大等待时长(毫秒),从第一条 deliver 开始计算,超过此时间强制发送
92
+ * 防止持续有新 deliver 导致一直不发送
93
+ * 默认 8000ms
94
+ */
95
+ maxWaitMs?: number;
96
+ /**
97
+ * 合并文本之间的分隔符
98
+ * 默认 "\n\n---\n\n"
99
+ */
100
+ separator?: string;
71
101
  }
72
102
  /**
73
103
  * 音频格式策略:控制哪些格式可跳过转换
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-connect/openclaw-qqbot",
3
- "version": "1.6.4",
3
+ "version": "1.6.5-alpha.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -95,7 +95,7 @@ Write-Host "==========================================="
95
95
  Write-Host ""
96
96
 
97
97
  # [1/3] Download and extract new version
98
- Write-Host "[1/3] Downloading new version..."
98
+ Write-Host "[1/5] Downloading new version..."
99
99
  $TMPDIR_PACK = Join-Path ([System.IO.Path]::GetTempPath()) "qqbot-pack-$([guid]::NewGuid().ToString('N').Substring(0,8))"
100
100
  $EXTRACT_DIR = Join-Path ([System.IO.Path]::GetTempPath()) "qqbot-extract-$([guid]::NewGuid().ToString('N').Substring(0,8))"
101
101
  New-Item -ItemType Directory -Path $TMPDIR_PACK -Force | Out-Null
@@ -172,9 +172,107 @@ try {
172
172
  if (Test-Path $EXTRACT_DIR) { Remove-Item -Recurse -Force $EXTRACT_DIR -ErrorAction SilentlyContinue }
173
173
  }
174
174
 
175
- # [2/3] Replace plugin directory (in-place overwrite to avoid file-lock issues)
175
+ # ── Preflight: validate new package before writing to extensions ──
176
176
  Write-Host ""
177
- Write-Host "[2/3] Replacing plugin directory..."
177
+ Write-Host "[2/5] Preflight checks..."
178
+ $PreflightOK = $true
179
+
180
+ # (a) package.json exists and has version
181
+ $StagingPkg = Join-Path $STAGING_DIR "package.json"
182
+ $StagingVersion = ""
183
+ if (-not (Test-Path $StagingPkg)) {
184
+ Write-Host " [FAIL] New package missing package.json" -ForegroundColor Red
185
+ $PreflightOK = $false
186
+ } else {
187
+ try {
188
+ $spkg = Get-Content $StagingPkg -Raw | ConvertFrom-Json
189
+ $StagingVersion = $spkg.version
190
+ if (-not $StagingVersion) { throw "no version" }
191
+ Write-Host " [OK] Version: $StagingVersion"
192
+ } catch {
193
+ Write-Host " [FAIL] package.json unreadable or missing version" -ForegroundColor Red
194
+ $PreflightOK = $false
195
+ }
196
+ }
197
+
198
+ # (b) Entry file exists
199
+ $EntryFile = ""
200
+ foreach ($candidate in @("dist\index.js", "index.js")) {
201
+ if (Test-Path (Join-Path $STAGING_DIR $candidate)) {
202
+ $EntryFile = $candidate
203
+ break
204
+ }
205
+ }
206
+ if (-not $EntryFile) {
207
+ Write-Host " [FAIL] Missing entry file (dist\index.js or index.js)" -ForegroundColor Red
208
+ $PreflightOK = $false
209
+ } else {
210
+ Write-Host " [OK] Entry file: $EntryFile"
211
+ }
212
+
213
+ # (c) Core directory dist/src
214
+ $CoreSrcDir = Join-Path $STAGING_DIR "dist" "src"
215
+ if (-not (Test-Path $CoreSrcDir)) {
216
+ Write-Host " [FAIL] Missing core directory dist\src\" -ForegroundColor Red
217
+ $PreflightOK = $false
218
+ } else {
219
+ $CoreJsCount = (Get-ChildItem -Path $CoreSrcDir -Filter "*.js" -File -Recurse -ErrorAction SilentlyContinue | Measure-Object).Count
220
+ Write-Host " [OK] dist\src\ contains $CoreJsCount JS files"
221
+ if ($CoreJsCount -lt 5) {
222
+ Write-Host " [FAIL] JS file count too low (expected >= 5, got $CoreJsCount)" -ForegroundColor Red
223
+ $PreflightOK = $false
224
+ }
225
+ }
226
+
227
+ # (d) Critical module files
228
+ $MissingModules = @()
229
+ foreach ($mod in @("dist\src\gateway.js", "dist\src\api.js", "dist\src\admin-resolver.js")) {
230
+ if (-not (Test-Path (Join-Path $STAGING_DIR $mod))) {
231
+ $MissingModules += $mod
232
+ }
233
+ }
234
+ if ($MissingModules.Count -gt 0) {
235
+ Write-Host " [FAIL] Missing critical modules: $($MissingModules -join ', ')" -ForegroundColor Red
236
+ $PreflightOK = $false
237
+ } else {
238
+ Write-Host " [OK] Critical modules intact"
239
+ }
240
+
241
+ # (e) Bundled node_modules health check
242
+ $nmDir = Join-Path $STAGING_DIR "node_modules"
243
+ if (Test-Path $nmDir) {
244
+ $BundledOK = $true
245
+ foreach ($dep in @("ws", "undici")) {
246
+ if (-not (Test-Path (Join-Path $nmDir $dep))) {
247
+ Write-Host " [WARN] Bundled dependency missing: $dep" -ForegroundColor Yellow
248
+ $BundledOK = $false
249
+ }
250
+ }
251
+ if ($BundledOK) {
252
+ Write-Host " [OK] Core bundled dependencies intact"
253
+ }
254
+ }
255
+
256
+ # (f) Version sanity check
257
+ if ($StagingVersion) {
258
+ $StagingMajor = ($StagingVersion -split "\.")[0]
259
+ if ($StagingMajor -eq "0") {
260
+ Write-Host " [WARN] Major version is 0 ($StagingVersion), may not be a production release" -ForegroundColor Yellow
261
+ }
262
+ }
263
+
264
+ # Preflight result
265
+ if (-not $PreflightOK) {
266
+ Write-Host ""
267
+ Write-Host "[ABORT] Preflight checks failed, upgrade cancelled (old version unaffected)" -ForegroundColor Red
268
+ Remove-Item -Recurse -Force $STAGING_DIR -ErrorAction SilentlyContinue
269
+ exit 1
270
+ }
271
+ Write-Host " [OK] All preflight checks passed"
272
+
273
+ # [3/5] Replace plugin directory (in-place overwrite to avoid file-lock issues)
274
+ Write-Host ""
275
+ Write-Host "[3/5] Replacing plugin directory..."
178
276
  $TARGET_DIR = Join-Path $EXTENSIONS_DIR "openclaw-qqbot"
179
277
 
180
278
  if (-not (Test-Path $TARGET_DIR)) {
@@ -217,9 +315,9 @@ foreach ($legacyName in @("qqbot", "openclaw-qq")) {
217
315
  }
218
316
  Write-Host " Installed to: $TARGET_DIR"
219
317
 
220
- # [3/3] Verify installation
318
+ # [4/5] Verify installation
221
319
  Write-Host ""
222
- Write-Host "[3/3] Verifying installation..."
320
+ Write-Host "[4/5] Verifying installation..."
223
321
  $NEW_VERSION = "unknown"
224
322
  try {
225
323
  $newPkgPath = Join-Path $TARGET_DIR "package.json"
@@ -249,7 +347,7 @@ if ($NoRestart) {
249
347
  exit 0
250
348
  }
251
349
 
252
- # [4/4] Configure appid/secret
350
+ # [配置] Configure appid/secret
253
351
  if ($AppId -and $Secret) {
254
352
  Write-Host ""
255
353
  Write-Host "[Config] Writing qqbot channel config..."
@@ -285,11 +383,26 @@ if ($AppId -and $Secret) {
285
383
 
286
384
  # [5/5] Restart gateway
287
385
  Write-Host ""
386
+
387
+ # Manual upgrade: write startup-marker before restart to prevent bot from sending duplicate notification
388
+ if ($NEW_VERSION -and $NEW_VERSION -ne "unknown") {
389
+ $MarkerDir = Join-Path $HOME_DIR ".openclaw" "qqbot" "data"
390
+ if (-not (Test-Path $MarkerDir)) { New-Item -ItemType Directory -Path $MarkerDir -Force | Out-Null }
391
+ $MarkerFile = Join-Path $MarkerDir "startup-marker.json"
392
+ $Now = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
393
+ @{ version = $NEW_VERSION; startedAt = $Now; greetedAt = $Now } | ConvertTo-Json -Compress | Set-Content $MarkerFile -Encoding UTF8
394
+ }
395
+
288
396
  Write-Host "[Restart] Restarting gateway..."
289
397
  try {
290
398
  & $CMD gateway restart 2>&1 | Out-Null
291
399
  if ($LASTEXITCODE -eq 0) {
292
400
  Write-Host " Gateway restarted"
401
+ # Print the same upgrade greeting as bot notification (no need to push via bot in manual upgrade)
402
+ if ($NEW_VERSION -and $NEW_VERSION -ne "unknown") {
403
+ Write-Host ""
404
+ Write-Host "🎉 QQBot 插件已更新至 v${NEW_VERSION},在线等候你的吩咐。"
405
+ }
293
406
  } else { throw "restart failed" }
294
407
  } catch {
295
408
  Write-Host " [WARN] Gateway restart failed, please run manually: $CMD gateway restart" -ForegroundColor Yellow
@@ -114,8 +114,8 @@ echo " qqbot npm 升级: $INSTALL_SRC"
114
114
  echo "==========================================="
115
115
  echo ""
116
116
 
117
- # [1/3] 下载并安装新版本到临时目录
118
- echo "[1/3] 下载新版本..."
117
+ # [1/5] 下载并安装新版本到临时目录
118
+ echo "[1/5] 下载新版本..."
119
119
  TMPDIR_PACK=$(mktemp -d)
120
120
  EXTRACT_DIR=$(mktemp -d)
121
121
  trap "rm -rf '$TMPDIR_PACK' '$EXTRACT_DIR'" EXIT
@@ -166,10 +166,108 @@ fi
166
166
  rm -rf "$TMPDIR_PACK" "$EXTRACT_DIR"
167
167
  cd "$HOME"
168
168
 
169
- # [2/3] 原子替换:使用 mv -T/rename 确保目录切换尽可能原子
169
+ # ── Preflight 检查:在写入 extensions 之前确保新包完整有效 ──
170
+ echo ""
171
+ echo "[2/5] Preflight 检查..."
172
+ PREFLIGHT_OK=true
173
+
174
+ # (a) package.json 存在且可解析,且包含 version 字段
175
+ STAGING_PKG="$STAGING_DIR/package.json"
176
+ if [ ! -f "$STAGING_PKG" ]; then
177
+ echo " ❌ 新包缺少 package.json"
178
+ PREFLIGHT_OK=false
179
+ else
180
+ STAGING_VERSION="$(node -e "
181
+ try {
182
+ const v = JSON.parse(require('fs').readFileSync('$STAGING_PKG', 'utf8')).version;
183
+ if (v) process.stdout.write(String(v));
184
+ } catch {}
185
+ " 2>/dev/null || true)"
186
+ if [ -z "$STAGING_VERSION" ]; then
187
+ echo " ❌ package.json 无法解析或缺少 version 字段"
188
+ PREFLIGHT_OK=false
189
+ else
190
+ echo " ✅ 版本号: $STAGING_VERSION"
191
+ fi
192
+ fi
193
+
194
+ # (b) 入口文件存在(dist/index.js 或 index.js)
195
+ ENTRY_FILE=""
196
+ for candidate in "dist/index.js" "index.js"; do
197
+ if [ -f "$STAGING_DIR/$candidate" ]; then
198
+ ENTRY_FILE="$candidate"
199
+ break
200
+ fi
201
+ done
202
+ if [ -z "$ENTRY_FILE" ]; then
203
+ echo " ❌ 缺少入口文件(dist/index.js 或 index.js)"
204
+ PREFLIGHT_OK=false
205
+ else
206
+ echo " ✅ 入口文件: $ENTRY_FILE"
207
+ fi
208
+
209
+ # (c) 核心目录 dist/src 存在
210
+ if [ ! -d "$STAGING_DIR/dist/src" ]; then
211
+ echo " ❌ 缺少核心目录 dist/src/"
212
+ PREFLIGHT_OK=false
213
+ else
214
+ CORE_JS_COUNT=$(find "$STAGING_DIR/dist/src" -name "*.js" -type f 2>/dev/null | wc -l | tr -d ' ')
215
+ echo " ✅ dist/src/ 包含 ${CORE_JS_COUNT} 个 JS 文件"
216
+ if [ "$CORE_JS_COUNT" -lt 5 ]; then
217
+ echo " ❌ JS 文件数量异常偏少(预期 ≥ 5,实际 ${CORE_JS_COUNT})"
218
+ PREFLIGHT_OK=false
219
+ fi
220
+ fi
221
+
222
+ # (d) 关键模块文件存在
223
+ MISSING_MODULES=""
224
+ for module in "dist/src/gateway.js" "dist/src/api.js" "dist/src/admin-resolver.js"; do
225
+ if [ ! -f "$STAGING_DIR/$module" ]; then
226
+ MISSING_MODULES="$MISSING_MODULES $module"
227
+ fi
228
+ done
229
+ if [ -n "$MISSING_MODULES" ]; then
230
+ echo " ❌ 缺少关键模块:$MISSING_MODULES"
231
+ PREFLIGHT_OK=false
232
+ else
233
+ echo " ✅ 关键模块完整"
234
+ fi
235
+
236
+ # (e) bundled node_modules 健康检查
237
+ if [ -d "$STAGING_DIR/node_modules" ]; then
238
+ BUNDLED_OK=true
239
+ for dep in "ws" "undici"; do
240
+ if [ ! -d "$STAGING_DIR/node_modules/$dep" ]; then
241
+ echo " ⚠️ bundled 依赖缺失: $dep"
242
+ BUNDLED_OK=false
243
+ fi
244
+ done
245
+ if $BUNDLED_OK; then
246
+ echo " ✅ 核心 bundled 依赖完整"
247
+ fi
248
+ fi
249
+
250
+ # (f) 如果有旧版本,检查新版本是否合理(不允许降级到 0.x 等异常版本)
251
+ if [ -n "$STAGING_VERSION" ]; then
252
+ STAGING_MAJOR="$(echo "$STAGING_VERSION" | cut -d. -f1)"
253
+ if [ "$STAGING_MAJOR" = "0" ]; then
254
+ echo " ⚠️ 新版本主版本号为 0($STAGING_VERSION),可能不是正式发布版"
255
+ fi
256
+ fi
257
+
258
+ # 检查结果
259
+ if [ "$PREFLIGHT_OK" != "true" ]; then
260
+ echo ""
261
+ echo "❌ Preflight 检查未通过,中止升级(旧版本未受影响)"
262
+ rm -rf "$STAGING_DIR"
263
+ exit 1
264
+ fi
265
+ echo " ✅ Preflight 检查全部通过"
266
+
267
+ # [3/5] 原子替换:使用 mv -T/rename 确保目录切换尽可能原子
170
268
  # 策略:先把 staging 放到 extensions/ 同级的临时名,再做单次 mv 替换
171
269
  echo ""
172
- echo "[2/3] 原子替换插件目录..."
270
+ echo "[3/5] 原子替换插件目录..."
173
271
  TARGET_DIR="$EXTENSIONS_DIR/openclaw-qqbot"
174
272
  OLD_DIR="$(dirname "$EXTENSIONS_DIR")/.qqbot-upgrade-old"
175
273
 
@@ -199,9 +297,9 @@ for dir_name in qqbot openclaw-qq; do
199
297
  done
200
298
  echo " 已安装到: $TARGET_DIR"
201
299
 
202
- # [3/3] 输出新版本号和升级报告(供调用方解析)
300
+ # [4/5] 输出新版本号和升级报告(供调用方解析)
203
301
  echo ""
204
- echo "[3/3] 验证安装..."
302
+ echo "[4/5] 验证安装..."
205
303
  NEW_VERSION="$(node -e "
206
304
  try {
207
305
  const fs = require('fs');
@@ -239,7 +337,7 @@ fi
239
337
 
240
338
  # 以下步骤仅在非热更新(手动执行)场景中执行
241
339
 
242
- # [4/4] 配置 appid/secret(仅在提供了参数时执行)
340
+ # [配置] appid/secret(仅在提供了参数时执行)
243
341
  if [ -n "$APPID" ] && [ -n "$SECRET" ]; then
244
342
  echo ""
245
343
  echo "[配置] 写入 qqbot 通道配置..."
@@ -293,9 +391,25 @@ fi
293
391
 
294
392
  # [5/5] 重启 gateway 使新版本生效
295
393
  echo ""
394
+
395
+ # 手动升级场景:提前写入 startup-marker,阻止重启后 bot 重复推送升级通知
396
+ # (控制台已打印同款提示语,无需 bot 再发一次)
397
+ if [ -n "$NEW_VERSION" ] && [ "$NEW_VERSION" != "unknown" ]; then
398
+ MARKER_DIR="$HOME/.openclaw/qqbot/data"
399
+ mkdir -p "$MARKER_DIR"
400
+ MARKER_FILE="$MARKER_DIR/startup-marker.json"
401
+ NOW="$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date +%Y-%m-%dT%H:%M:%SZ)"
402
+ echo "{\"version\":\"$NEW_VERSION\",\"startedAt\":\"$NOW\",\"greetedAt\":\"$NOW\"}" > "$MARKER_FILE"
403
+ fi
404
+
296
405
  echo "[重启] 重启 gateway 使新版本生效..."
297
406
  if $CMD gateway restart 2>&1; then
298
407
  echo " ✅ gateway 已重启"
408
+ # 打印与 bot 通知同款的更新提示语(手动升级场景无需通过 bot 推送)
409
+ echo ""
410
+ if [ -n "$NEW_VERSION" ] && [ "$NEW_VERSION" != "unknown" ]; then
411
+ echo "🎉 QQBot 插件已更新至 v${NEW_VERSION},在线等候你的吩咐。"
412
+ fi
299
413
  else
300
414
  echo " ⚠️ gateway 重启失败,请手动执行: $CMD gateway restart"
301
415
  fi
@@ -22,7 +22,7 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
22
22
  |------|------|------|
23
23
  | `action` | 操作类型 | `"add"` / `"list"` / `"remove"` |
24
24
  | `content` | 提醒内容 | `"喝水"` |
25
- | `to` | 目标地址(取自上下文 `[QQBot] to=` 的值) | `"user_openid_xxx"` 或 `"group:group_openid_xxx"` |
25
+ | `to` | 目标地址(可选,系统自动获取,通常无需填写) | |
26
26
  | `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
27
27
  | `jobId` | 任务 ID(仅 remove) | `"xxx"` |
28
28
 
@@ -34,7 +34,7 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
34
34
 
35
35
  用户说:"5分钟后提醒我喝水"
36
36
 
37
- 1. 调用 `qqbot_remind`:`{ "action": "add", "content": "喝水", "to": "xxx", "time": "5m" }`
37
+ 1. 调用 `qqbot_remind`:`{ "action": "add", "content": "喝水", "time": "5m" }`
38
38
  2. 收到返回的 `cronParams` → 立即调用 `cron` 工具,参数为该 `cronParams`
39
39
  3. 回复用户:`⏰ 好的,5分钟后提醒你喝水~`
40
40
 
@@ -56,7 +56,7 @@ metadata: {"openclaw":{"emoji":"⏰","requires":{"config":["channels.qqbot"]}}}
56
56
  | `payload.kind` | `"agentTurn"` | `systemEvent` 不会发 QQ 消息 |
57
57
  | `payload.deliver` | `true` | 否则不投递 |
58
58
  | `payload.channel` | `"qqbot"` | QQ 通道标识 |
59
- | `payload.to` | 用户 openid | 从上下文获取 |
59
+ | `payload.to` | 用户 openid | `To` 字段获取 |
60
60
  | `sessionTarget` | `"isolated"` | 隔离会话避免污染 |
61
61
 
62
62
  > `schedule.atMs` 必须是**绝对毫秒时间戳**(如 `1770733800000`),不支持 `"5m"` 等相对字符串。