@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.
- package/README.md +3 -10
- package/README.zh.md +3 -10
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +61 -24
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +281 -253
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +30 -0
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.ps1 +119 -6
- package/scripts/upgrade-via-npm.sh +121 -7
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +67 -25
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +97 -62
- package/src/request-context.ts +39 -0
- package/src/startup-greeting.ts +35 -13
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +31 -0
|
@@ -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
|
-
|
|
19
|
-
|
|
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(
|
|
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(),
|
package/dist/src/tools/remind.js
CHANGED
|
@@ -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: "
|
|
17
|
-
"私聊格式:user_openid,群聊格式:group:group_openid。
|
|
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
|
-
|
|
211
|
-
|
|
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,
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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
|
@@ -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/
|
|
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
|
-
#
|
|
175
|
+
# ── Preflight: validate new package before writing to extensions ──
|
|
176
176
|
Write-Host ""
|
|
177
|
-
Write-Host "[2/
|
|
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
|
-
# [
|
|
318
|
+
# [4/5] Verify installation
|
|
221
319
|
Write-Host ""
|
|
222
|
-
Write-Host "[
|
|
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
|
-
# [
|
|
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/
|
|
118
|
-
echo "[1/
|
|
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
|
-
#
|
|
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 "[
|
|
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
|
-
# [
|
|
300
|
+
# [4/5] 输出新版本号和升级报告(供调用方解析)
|
|
203
301
|
echo ""
|
|
204
|
-
echo "[
|
|
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
|
-
# [
|
|
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` |
|
|
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": "喝水", "
|
|
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"` 等相对字符串。
|