@leoqlin/openclaw-qqbot 1.6.7-alpha1
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/LICENSE +22 -0
- package/README.md +484 -0
- package/README.zh.md +479 -0
- package/bin/qqbot-cli.js +243 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +26 -0
- package/dist/src/admin-resolver.d.ts +33 -0
- package/dist/src/admin-resolver.js +157 -0
- package/dist/src/api.d.ts +301 -0
- package/dist/src/api.js +890 -0
- package/dist/src/channel.d.ts +29 -0
- package/dist/src/channel.js +452 -0
- package/dist/src/config.d.ts +56 -0
- package/dist/src/config.js +278 -0
- package/dist/src/credential-backup.d.ts +31 -0
- package/dist/src/credential-backup.js +66 -0
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.d.ts +18 -0
- package/dist/src/gateway.js +2005 -0
- package/dist/src/group-history.d.ts +136 -0
- package/dist/src/group-history.js +226 -0
- package/dist/src/image-server.d.ts +87 -0
- package/dist/src/image-server.js +570 -0
- package/dist/src/inbound-attachments.d.ts +60 -0
- package/dist/src/inbound-attachments.js +248 -0
- package/dist/src/known-users.d.ts +100 -0
- package/dist/src/known-users.js +263 -0
- package/dist/src/message-gating.d.ts +53 -0
- package/dist/src/message-gating.js +107 -0
- package/dist/src/message-queue.d.ts +89 -0
- package/dist/src/message-queue.js +257 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +203 -0
- package/dist/src/outbound-deliver.d.ts +48 -0
- package/dist/src/outbound-deliver.js +392 -0
- package/dist/src/outbound.d.ts +205 -0
- package/dist/src/outbound.js +938 -0
- package/dist/src/proactive.d.ts +170 -0
- package/dist/src/proactive.js +399 -0
- package/dist/src/ref-index-store.d.ts +101 -0
- package/dist/src/ref-index-store.js +298 -0
- package/dist/src/reply-dispatcher.d.ts +35 -0
- package/dist/src/reply-dispatcher.js +311 -0
- package/dist/src/request-context.d.ts +25 -0
- package/dist/src/request-context.js +37 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/session-store.d.ts +52 -0
- package/dist/src/session-store.js +254 -0
- package/dist/src/slash-commands.d.ts +77 -0
- package/dist/src/slash-commands.js +1866 -0
- package/dist/src/startup-greeting.d.ts +30 -0
- package/dist/src/startup-greeting.js +97 -0
- package/dist/src/streaming.d.ts +247 -0
- package/dist/src/streaming.js +899 -0
- package/dist/src/stt.d.ts +21 -0
- package/dist/src/stt.js +70 -0
- package/dist/src/tools/channel.d.ts +16 -0
- package/dist/src/tools/channel.js +234 -0
- package/dist/src/tools/remind.d.ts +2 -0
- package/dist/src/tools/remind.js +256 -0
- package/dist/src/types.d.ts +367 -0
- package/dist/src/types.js +17 -0
- package/dist/src/typing-keepalive.d.ts +27 -0
- package/dist/src/typing-keepalive.js +64 -0
- package/dist/src/update-checker.d.ts +36 -0
- package/dist/src/update-checker.js +171 -0
- package/dist/src/utils/audio-convert.d.ts +98 -0
- package/dist/src/utils/audio-convert.js +755 -0
- package/dist/src/utils/chunked-upload.d.ts +68 -0
- package/dist/src/utils/chunked-upload.js +341 -0
- package/dist/src/utils/file-utils.d.ts +61 -0
- package/dist/src/utils/file-utils.js +172 -0
- package/dist/src/utils/image-size.d.ts +51 -0
- package/dist/src/utils/image-size.js +234 -0
- package/dist/src/utils/media-send.d.ts +158 -0
- package/dist/src/utils/media-send.js +499 -0
- package/dist/src/utils/media-tags.d.ts +14 -0
- package/dist/src/utils/media-tags.js +165 -0
- package/dist/src/utils/payload.d.ts +112 -0
- package/dist/src/utils/payload.js +186 -0
- package/dist/src/utils/pkg-version.d.ts +5 -0
- package/dist/src/utils/pkg-version.js +61 -0
- package/dist/src/utils/platform.d.ts +137 -0
- package/dist/src/utils/platform.js +390 -0
- package/dist/src/utils/ssrf-guard.d.ts +25 -0
- package/dist/src/utils/ssrf-guard.js +91 -0
- package/dist/src/utils/text-parsing.d.ts +36 -0
- package/dist/src/utils/text-parsing.js +75 -0
- package/dist/src/utils/upload-cache.d.ts +34 -0
- package/dist/src/utils/upload-cache.js +93 -0
- package/index.ts +31 -0
- package/node_modules/@eshaz/web-worker/LICENSE +201 -0
- package/node_modules/@eshaz/web-worker/README.md +134 -0
- package/node_modules/@eshaz/web-worker/browser.js +17 -0
- package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
- package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
- package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
- package/node_modules/@eshaz/web-worker/node.js +223 -0
- package/node_modules/@eshaz/web-worker/package.json +54 -0
- package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
- package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
- package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
- package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
- package/node_modules/mpg123-decoder/README.md +265 -0
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
- package/node_modules/mpg123-decoder/index.js +8 -0
- package/node_modules/mpg123-decoder/package.json +58 -0
- package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
- package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
- package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
- package/node_modules/mpg123-decoder/types.d.ts +30 -0
- package/node_modules/silk-wasm/LICENSE +21 -0
- package/node_modules/silk-wasm/README.md +85 -0
- package/node_modules/silk-wasm/lib/index.cjs +16 -0
- package/node_modules/silk-wasm/lib/index.d.ts +70 -0
- package/node_modules/silk-wasm/lib/index.mjs +16 -0
- package/node_modules/silk-wasm/lib/silk.wasm +0 -0
- package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
- package/node_modules/silk-wasm/package.json +39 -0
- package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
- package/node_modules/simple-yenc/.prettierignore +1 -0
- package/node_modules/simple-yenc/LICENSE +7 -0
- package/node_modules/simple-yenc/README.md +163 -0
- package/node_modules/simple-yenc/dist/esm.js +1 -0
- package/node_modules/simple-yenc/dist/index.js +1 -0
- package/node_modules/simple-yenc/package.json +50 -0
- package/node_modules/simple-yenc/rollup.config.js +27 -0
- package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +22 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +528 -0
- package/node_modules/ws/lib/receiver.js +706 -0
- package/node_modules/ws/lib/sender.js +602 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +554 -0
- package/node_modules/ws/lib/websocket.js +1393 -0
- package/node_modules/ws/package.json +70 -0
- package/node_modules/ws/wrapper.mjs +21 -0
- package/openclaw.plugin.json +17 -0
- package/package.json +70 -0
- package/preload.cjs +33 -0
- package/scripts/cleanup-legacy-plugins.sh +124 -0
- package/scripts/link-sdk-core.cjs +185 -0
- package/scripts/postinstall-link-sdk.js +126 -0
- package/scripts/proactive-api-server.ts +369 -0
- package/scripts/send-proactive.ts +293 -0
- package/scripts/set-markdown.sh +156 -0
- package/scripts/test-sendmedia.ts +116 -0
- package/scripts/upgrade-via-npm.ps1 +460 -0
- package/scripts/upgrade-via-npm.sh +652 -0
- package/scripts/upgrade-via-source.sh +1026 -0
- package/skills/qqbot-channel/SKILL.md +263 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +60 -0
- package/skills/qqbot-remind/SKILL.md +159 -0
- package/src/admin-resolver.ts +181 -0
- package/src/api.ts +1284 -0
- package/src/channel.ts +477 -0
- package/src/config.ts +347 -0
- package/src/credential-backup.ts +72 -0
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +2245 -0
- package/src/group-history.ts +328 -0
- package/src/image-server.ts +675 -0
- package/src/inbound-attachments.ts +321 -0
- package/src/known-users.ts +353 -0
- package/src/message-gating.ts +190 -0
- package/src/message-queue.ts +352 -0
- package/src/onboarding.ts +274 -0
- package/src/openclaw-plugin-sdk.d.ts +587 -0
- package/src/outbound-deliver.ts +473 -0
- package/src/outbound.ts +1131 -0
- package/src/proactive.ts +530 -0
- package/src/ref-index-store.ts +412 -0
- package/src/reply-dispatcher.ts +334 -0
- package/src/request-context.ts +49 -0
- package/src/runtime.ts +14 -0
- package/src/session-store.ts +303 -0
- package/src/slash-commands.ts +2030 -0
- package/src/startup-greeting.ts +120 -0
- package/src/streaming.ts +1077 -0
- package/src/stt.ts +86 -0
- package/src/tools/channel.ts +281 -0
- package/src/tools/remind.ts +308 -0
- package/src/types.ts +391 -0
- package/src/typing-keepalive.ts +59 -0
- package/src/update-checker.ts +186 -0
- package/src/utils/audio-convert.ts +859 -0
- package/src/utils/chunked-upload.ts +483 -0
- package/src/utils/file-utils.ts +193 -0
- package/src/utils/image-size.ts +266 -0
- package/src/utils/media-send.ts +631 -0
- package/src/utils/media-tags.ts +183 -0
- package/src/utils/payload.ts +265 -0
- package/src/utils/pkg-version.ts +64 -0
- package/src/utils/platform.ts +435 -0
- package/src/utils/ssrf-guard.ts +102 -0
- package/src/utils/text-parsing.ts +85 -0
- package/src/utils/upload-cache.ts +128 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,2030 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot 插件级斜杠指令处理器
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* 1. 在消息入队前拦截,匹配到插件级指令后直接回复,不进入 AI 处理队列
|
|
6
|
+
* 2. 不匹配的 "/" 消息照常入队,交给 OpenClaw 框架处理
|
|
7
|
+
* 3. 每个指令通过 SlashCommand 接口注册,易于扩展
|
|
8
|
+
*
|
|
9
|
+
* 时间线追踪:
|
|
10
|
+
* 开平推送时间戳 → 插件收到(Date.now()) → 指令处理完成(Date.now())
|
|
11
|
+
* 从而计算「开平→插件」和「插件处理」两段耗时
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { QQBotAccountConfig } from "./types.js";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { execFileSync, execFile, spawn } from "node:child_process";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import { getUpdateInfo, checkVersionExists } from "./update-checker.js";
|
|
20
|
+
import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js";
|
|
21
|
+
import { saveCredentialBackup } from "./credential-backup.js";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { getPackageVersion } from "./utils/pkg-version.js";
|
|
24
|
+
import { getQQBotRuntime } from "./runtime.js";
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
let PLUGIN_VERSION = getPackageVersion(import.meta.url);
|
|
28
|
+
|
|
29
|
+
// 获取 openclaw 框架版本(不缓存,每次实时获取)
|
|
30
|
+
export function getFrameworkVersion(): string {
|
|
31
|
+
try {
|
|
32
|
+
// 先尝试 PATH 中的 CLI
|
|
33
|
+
// Windows 上 npm 安装的 CLI 通常是 .cmd wrapper,execFileSync 需要 shell:true 才能执行
|
|
34
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
35
|
+
try {
|
|
36
|
+
const out = execFileSync(cli, ["--version"], {
|
|
37
|
+
timeout: 3000, encoding: "utf8",
|
|
38
|
+
...(isWindows() ? { shell: true } : {}),
|
|
39
|
+
}).trim();
|
|
40
|
+
// 输出格式: "OpenClaw 2026.3.13 (61d171a)"
|
|
41
|
+
if (out) {
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// 尝试 findCli() 找到的完整路径
|
|
49
|
+
const cliPath = findCli();
|
|
50
|
+
if (cliPath) {
|
|
51
|
+
const out = execCliSync(cliPath, ["--version"]);
|
|
52
|
+
if (out) {
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// fallback
|
|
58
|
+
}
|
|
59
|
+
return "unknown";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============ 热更新兼容性检查 ============
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 热更新可执行的环境要求:
|
|
66
|
+
* - 最低 OpenClaw 框架版本
|
|
67
|
+
* - 支持的操作系统
|
|
68
|
+
* - 最低 Node.js 版本
|
|
69
|
+
*/
|
|
70
|
+
const UPGRADE_REQUIREMENTS = {
|
|
71
|
+
/** OpenClaw 最低版本(YYYY.M.D 格式,如 "2026.3.10") */
|
|
72
|
+
minFrameworkVersion: "2026.3.2",
|
|
73
|
+
/** 支持的操作系统列表(process.platform 值) */
|
|
74
|
+
supportedPlatforms: ["darwin", "linux"] as string[],
|
|
75
|
+
/** 最低 Node.js 版本 */
|
|
76
|
+
minNodeVersion: "18.0.0",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface UpgradeCompatResult {
|
|
80
|
+
ok: boolean;
|
|
81
|
+
errors: string[];
|
|
82
|
+
warnings: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 解析框架版本字符串中的日期版本号
|
|
87
|
+
* 输入示例: "OpenClaw 2026.3.13 (61d171a)" → "2026.3.13"
|
|
88
|
+
*/
|
|
89
|
+
export function parseFrameworkDateVersion(versionStr: string): string | null {
|
|
90
|
+
const m = versionStr.match(/(\d{4}\.\d{1,2}\.\d{1,2})/);
|
|
91
|
+
return m ? m[1] : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 比较 YYYY.M.D 格式的版本号
|
|
96
|
+
* @returns >0 if a > b, <0 if a < b, 0 if equal
|
|
97
|
+
*/
|
|
98
|
+
function compareDateVersions(a: string, b: string): number {
|
|
99
|
+
const pa = a.split(".").map(Number);
|
|
100
|
+
const pb = b.split(".").map(Number);
|
|
101
|
+
for (let i = 0; i < 3; i++) {
|
|
102
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
103
|
+
if (diff !== 0) return diff;
|
|
104
|
+
}
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 比较 semver 版本号(简化版,仅比较 major.minor.patch)
|
|
110
|
+
*/
|
|
111
|
+
function compareSemver(a: string, b: string): number {
|
|
112
|
+
const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
|
|
113
|
+
const pa = parse(a);
|
|
114
|
+
const pb = parse(b);
|
|
115
|
+
for (let i = 0; i < 3; i++) {
|
|
116
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
117
|
+
if (diff !== 0) return diff;
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 检查当前环境是否满足热更新要求
|
|
124
|
+
*/
|
|
125
|
+
function checkUpgradeCompatibility(): UpgradeCompatResult {
|
|
126
|
+
const errors: string[] = [];
|
|
127
|
+
const warnings: string[] = [];
|
|
128
|
+
const req = UPGRADE_REQUIREMENTS;
|
|
129
|
+
|
|
130
|
+
// 1. 检查操作系统
|
|
131
|
+
const platform = process.platform;
|
|
132
|
+
if (!req.supportedPlatforms.includes(platform)) {
|
|
133
|
+
const supported = req.supportedPlatforms.map(p => {
|
|
134
|
+
if (p === "darwin") return "macOS";
|
|
135
|
+
if (p === "linux") return "Linux";
|
|
136
|
+
if (p === "win32") return "Windows";
|
|
137
|
+
return p;
|
|
138
|
+
}).join("、");
|
|
139
|
+
const current = platform === "win32" ? "Windows"
|
|
140
|
+
: platform === "darwin" ? "macOS"
|
|
141
|
+
: platform;
|
|
142
|
+
errors.push(`❌ 当前操作系统 **${current}** 不支持热更新(支持:${supported})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 2. 检查 OpenClaw 框架版本
|
|
146
|
+
const fwVersion = getFrameworkVersion();
|
|
147
|
+
if (fwVersion === "unknown") {
|
|
148
|
+
// 打包环境(HoldClaw/QQAIO)中 CLI 可能不在 PATH,版本检测会失败,
|
|
149
|
+
// 但 findCli() 的 fallback 仍可能找到 CLI 执行升级,所以只是警告不阻断。
|
|
150
|
+
warnings.push(`⚠️ 无法检测 OpenClaw 框架版本,热更新可能失败`);
|
|
151
|
+
} else {
|
|
152
|
+
const dateVer = parseFrameworkDateVersion(fwVersion);
|
|
153
|
+
if (dateVer && compareDateVersions(dateVer, req.minFrameworkVersion) < 0) {
|
|
154
|
+
errors.push(`❌ OpenClaw 框架版本过低:当前 **${dateVer}**,热更新要求最低 **${req.minFrameworkVersion}**。请先升级框架:\`openclaw upgrade\``);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 3. 检查 Node.js 版本
|
|
159
|
+
const nodeVer = process.version.replace(/^v/, "");
|
|
160
|
+
if (compareSemver(nodeVer, req.minNodeVersion) < 0) {
|
|
161
|
+
errors.push(`❌ Node.js 版本过低:当前 **v${nodeVer}**,热更新要求最低 **v${req.minNodeVersion}**`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 4. 检查系统架构(arm 等特殊架构提示)
|
|
165
|
+
const arch = process.arch;
|
|
166
|
+
if (arch !== "x64" && arch !== "arm64") {
|
|
167
|
+
warnings.push(`⚠️ 当前 CPU 架构 **${arch}** 未经充分测试,热更新可能存在兼容性问题`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============ 类型定义 ============
|
|
174
|
+
|
|
175
|
+
/** 斜杠指令上下文(消息元数据 + 运行时状态) */
|
|
176
|
+
export interface SlashCommandContext {
|
|
177
|
+
/** 消息类型 */
|
|
178
|
+
type: "c2c" | "guild" | "dm" | "group";
|
|
179
|
+
/** 发送者 ID */
|
|
180
|
+
senderId: string;
|
|
181
|
+
/** 发送者昵称 */
|
|
182
|
+
senderName?: string;
|
|
183
|
+
/** 消息 ID(用于被动回复) */
|
|
184
|
+
messageId: string;
|
|
185
|
+
/** 开平推送的事件时间戳(ISO 字符串) */
|
|
186
|
+
eventTimestamp: string;
|
|
187
|
+
/** 插件收到消息的本地时间(ms) */
|
|
188
|
+
receivedAt: number;
|
|
189
|
+
/** 原始消息内容 */
|
|
190
|
+
rawContent: string;
|
|
191
|
+
/** 指令参数(去掉指令名后的部分) */
|
|
192
|
+
args: string;
|
|
193
|
+
/** 频道 ID(guild 类型) */
|
|
194
|
+
channelId?: string;
|
|
195
|
+
/** 群 openid(group 类型) */
|
|
196
|
+
groupOpenid?: string;
|
|
197
|
+
/** 账号 ID */
|
|
198
|
+
accountId: string;
|
|
199
|
+
/** Bot App ID */
|
|
200
|
+
appId: string;
|
|
201
|
+
/** 账号配置(供指令读取可配置项) */
|
|
202
|
+
accountConfig?: QQBotAccountConfig;
|
|
203
|
+
/** 当前用户队列状态快照 */
|
|
204
|
+
queueSnapshot: QueueSnapshot;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 队列状态快照 */
|
|
208
|
+
export interface QueueSnapshot {
|
|
209
|
+
/** 各用户队列中的消息总数 */
|
|
210
|
+
totalPending: number;
|
|
211
|
+
/** 正在并行处理的用户数 */
|
|
212
|
+
activeUsers: number;
|
|
213
|
+
/** 最大并发用户数 */
|
|
214
|
+
maxConcurrentUsers: number;
|
|
215
|
+
/** 当前发送者在队列中的待处理消息数 */
|
|
216
|
+
senderPending: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 斜杠指令返回值:文本、带文件的结果、或 null(不处理) */
|
|
220
|
+
export type SlashCommandResult = string | SlashCommandFileResult | null;
|
|
221
|
+
|
|
222
|
+
/** 带文件的指令结果(先回复文本,再发送文件) */
|
|
223
|
+
export interface SlashCommandFileResult {
|
|
224
|
+
text: string;
|
|
225
|
+
/** 要发送的本地文件路径 */
|
|
226
|
+
filePath: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 斜杠指令定义 */
|
|
230
|
+
interface SlashCommand {
|
|
231
|
+
/** 指令名(不含 /) */
|
|
232
|
+
name: string;
|
|
233
|
+
/** 简要描述 */
|
|
234
|
+
description: string;
|
|
235
|
+
/** 详细用法说明(支持多行),用于 /指令 ? 查询 */
|
|
236
|
+
usage?: string;
|
|
237
|
+
/** 处理函数 */
|
|
238
|
+
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ============ 指令注册表 ============
|
|
242
|
+
|
|
243
|
+
const commands: Map<string, SlashCommand> = new Map();
|
|
244
|
+
|
|
245
|
+
function registerCommand(cmd: SlashCommand): void {
|
|
246
|
+
commands.set(cmd.name.toLowerCase(), cmd);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ============ 内置指令 ============
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* /bot-ping — 测试当前 openclaw 与 QQ 连接的网络延迟
|
|
253
|
+
*/
|
|
254
|
+
registerCommand({
|
|
255
|
+
name: "bot-ping",
|
|
256
|
+
description: "测试当前 openclaw 与 QQ 连接的网络延迟",
|
|
257
|
+
usage: [
|
|
258
|
+
`/bot-ping`,
|
|
259
|
+
``,
|
|
260
|
+
`测试 OpenClaw 主机与 QQ 服务器之间的网络延迟。`,
|
|
261
|
+
`返回网络传输耗时和插件处理耗时。`,
|
|
262
|
+
].join("\n"),
|
|
263
|
+
handler: (ctx) => {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
const eventTime = new Date(ctx.eventTimestamp).getTime();
|
|
266
|
+
if (isNaN(eventTime)) {
|
|
267
|
+
return `✅ pong!`;
|
|
268
|
+
}
|
|
269
|
+
const totalMs = now - eventTime;
|
|
270
|
+
const qqToPlugin = ctx.receivedAt - eventTime;
|
|
271
|
+
const pluginProcess = now - ctx.receivedAt;
|
|
272
|
+
const lines = [
|
|
273
|
+
`✅ pong!`,
|
|
274
|
+
``,
|
|
275
|
+
`⏱ 延迟: ${totalMs}ms`,
|
|
276
|
+
` ├ 网络传输: ${qqToPlugin}ms`,
|
|
277
|
+
` └ 插件处理: ${pluginProcess}ms`,
|
|
278
|
+
];
|
|
279
|
+
return lines.join("\n");
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* /bot-version — 查看插件版本号
|
|
285
|
+
*/
|
|
286
|
+
registerCommand({
|
|
287
|
+
name: "bot-version",
|
|
288
|
+
description: "查看插件版本号",
|
|
289
|
+
usage: [
|
|
290
|
+
`/bot-version`,
|
|
291
|
+
``,
|
|
292
|
+
`查看当前 QQBot 插件版本和 OpenClaw 框架版本。`,
|
|
293
|
+
`同时检查是否有新版本可用。`,
|
|
294
|
+
].join("\n"),
|
|
295
|
+
handler: async () => {
|
|
296
|
+
const frameworkVersion = getFrameworkVersion();
|
|
297
|
+
const lines = [
|
|
298
|
+
`🦞框架版本:${frameworkVersion}`,
|
|
299
|
+
`🤖QQBot 插件版本:v${PLUGIN_VERSION}`,
|
|
300
|
+
];
|
|
301
|
+
const info = await getUpdateInfo();
|
|
302
|
+
if (info.checkedAt === 0) {
|
|
303
|
+
lines.push(`⏳ 版本检查中...`);
|
|
304
|
+
} else if (info.error) {
|
|
305
|
+
lines.push(`⚠️ 版本检查失败`);
|
|
306
|
+
} else if (info.hasUpdate && info.latest) {
|
|
307
|
+
lines.push(`🆕最新可用版本:v${info.latest},点击 <qqbot-cmd-input text="/bot-upgrade" show="/bot-upgrade"/> 查看升级指引`);
|
|
308
|
+
}
|
|
309
|
+
lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
|
|
310
|
+
return lines.join("\n");
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* /bot-help — 查看所有指令以及用途
|
|
316
|
+
*/
|
|
317
|
+
registerCommand({
|
|
318
|
+
name: "bot-help",
|
|
319
|
+
description: "查看所有指令以及用途",
|
|
320
|
+
usage: [
|
|
321
|
+
`/bot-help`,
|
|
322
|
+
``,
|
|
323
|
+
`列出所有可用的 QQBot 插件内置指令及其简要说明。`,
|
|
324
|
+
`使用 /指令名 ? 可查看某条指令的详细用法。`,
|
|
325
|
+
].join("\n"),
|
|
326
|
+
handler: (ctx) => {
|
|
327
|
+
// 群聊场景排除仅限私聊的指令
|
|
328
|
+
const GROUP_EXCLUDED_COMMANDS = new Set(["bot-upgrade", "bot-clear-storage"]);
|
|
329
|
+
const isGroup = ctx.type === "group";
|
|
330
|
+
|
|
331
|
+
const lines = [`### QQBot插件内置调试指令`, ``];
|
|
332
|
+
for (const [name, cmd] of commands) {
|
|
333
|
+
if (isGroup && GROUP_EXCLUDED_COMMANDS.has(name)) continue;
|
|
334
|
+
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
|
335
|
+
}
|
|
336
|
+
lines.push(``, `> 插件版本 v${PLUGIN_VERSION}`);
|
|
337
|
+
return lines.join("\n");
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const DEFAULT_UPGRADE_URL = "https://docs.qq.com/doc/DSGxOZk1oVnVKVkpq";
|
|
342
|
+
|
|
343
|
+
function saveUpgradeGreetingTarget(accountId: string, appId: string, openid: string): void {
|
|
344
|
+
const safeAccountId = accountId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
345
|
+
const safeAppId = appId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
346
|
+
const filePath = path.join(getQQBotDataDir("data"), `upgrade-greeting-target-${safeAccountId}-${safeAppId}.json`);
|
|
347
|
+
try {
|
|
348
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
349
|
+
accountId,
|
|
350
|
+
appId,
|
|
351
|
+
openid,
|
|
352
|
+
savedAt: new Date().toISOString(),
|
|
353
|
+
}) + "\n");
|
|
354
|
+
} catch {
|
|
355
|
+
// ignore
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ============ 热更新 ============
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 找到 CLI 命令名或完整路径(openclaw / clawdbot / moltbot)
|
|
363
|
+
*
|
|
364
|
+
* 查找策略:
|
|
365
|
+
* 1. 系统 PATH(where / which)
|
|
366
|
+
* 2. 打包环境(HoldClaw / QQAIO):从当前文件路径向上推断 CLI 位置
|
|
367
|
+
* 3. ~/.openclaw/bin/ 等常见安装路径
|
|
368
|
+
*/
|
|
369
|
+
function findCli(): string | null {
|
|
370
|
+
const whichCmd = isWindows() ? "where" : "which";
|
|
371
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
372
|
+
try {
|
|
373
|
+
const out = execFileSync(whichCmd, [cli], { timeout: 3000, encoding: "utf8", stdio: "pipe" }).trim();
|
|
374
|
+
// where 在 Windows 上可能返回多行(多个匹配),取第一行
|
|
375
|
+
const resolved = out.split(/\r?\n/)[0]?.trim();
|
|
376
|
+
return resolved || cli;
|
|
377
|
+
} catch {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 打包环境 fallback:从当前文件路径推断 CLI
|
|
383
|
+
// 典型路径: .../gateway/node_modules/openclaw-qqbot/dist/src/slash-commands.js
|
|
384
|
+
// CLI 位于: .../gateway/node_modules/openclaw/openclaw.mjs
|
|
385
|
+
// 或者: .../gateway/node_modules/.bin/openclaw
|
|
386
|
+
try {
|
|
387
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
388
|
+
const currentDir = path.dirname(currentFile);
|
|
389
|
+
|
|
390
|
+
// 向上查找 node_modules 目录
|
|
391
|
+
let dir = currentDir;
|
|
392
|
+
for (let i = 0; i < 10; i++) {
|
|
393
|
+
const basename = path.basename(dir);
|
|
394
|
+
if (basename === "node_modules") {
|
|
395
|
+
// 检查 .bin 下的 CLI
|
|
396
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
397
|
+
const binName = isWindows() ? `${cli}.cmd` : cli;
|
|
398
|
+
const binPath = path.join(dir, ".bin", binName);
|
|
399
|
+
if (fs.existsSync(binPath)) return binPath;
|
|
400
|
+
}
|
|
401
|
+
// 检查 openclaw/openclaw.mjs(直接通过 node 调用)
|
|
402
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
403
|
+
const mjsPath = path.join(dir, cli, `${cli}.mjs`);
|
|
404
|
+
if (fs.existsSync(mjsPath)) return mjsPath;
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
const parent = path.dirname(dir);
|
|
409
|
+
if (parent === dir) break;
|
|
410
|
+
dir = parent;
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
// ignore
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ~/.openclaw/bin/ 等常见安装路径
|
|
417
|
+
const homeDir = getHomeDir();
|
|
418
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
419
|
+
const ext = isWindows() ? ".exe" : "";
|
|
420
|
+
const candidates = [
|
|
421
|
+
path.join(homeDir, `.${cli}`, "bin", `${cli}${ext}`),
|
|
422
|
+
path.join(homeDir, `.${cli}`, `${cli}${ext}`),
|
|
423
|
+
];
|
|
424
|
+
for (const p of candidates) {
|
|
425
|
+
if (fs.existsSync(p)) return p;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 同步执行 CLI 命令。
|
|
434
|
+
* 当 cliPath 是 .mjs 文件时,自动通过 process.execPath (node) 调用。
|
|
435
|
+
* Windows 上对非完整路径的命令名(如 "openclaw")启用 shell,以兼容 .cmd wrapper。
|
|
436
|
+
*/
|
|
437
|
+
function execCliSync(cliPath: string, args: string[]): string | null {
|
|
438
|
+
try {
|
|
439
|
+
if (cliPath.endsWith(".mjs")) {
|
|
440
|
+
return execFileSync(process.execPath, [cliPath, ...args], {
|
|
441
|
+
timeout: 5000, encoding: "utf8", stdio: "pipe",
|
|
442
|
+
}).trim() || null;
|
|
443
|
+
}
|
|
444
|
+
const needsShell = isWindows() && !path.isAbsolute(cliPath) && !cliPath.endsWith(".cmd") && !cliPath.endsWith(".exe");
|
|
445
|
+
return execFileSync(cliPath, args, {
|
|
446
|
+
timeout: 5000, encoding: "utf8", stdio: "pipe",
|
|
447
|
+
...(needsShell ? { shell: true } : {}),
|
|
448
|
+
}).trim() || null;
|
|
449
|
+
} catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 异步执行 CLI 命令。
|
|
456
|
+
* 当 cliPath 是 .mjs 文件时,自动通过 process.execPath (node) 调用。
|
|
457
|
+
* Windows 上对非完整路径的命令名启用 shell,以兼容 .cmd wrapper。
|
|
458
|
+
*/
|
|
459
|
+
function execCliAsync(
|
|
460
|
+
cliPath: string,
|
|
461
|
+
args: string[],
|
|
462
|
+
opts: { timeout?: number; env?: NodeJS.ProcessEnv; windowsHide?: boolean },
|
|
463
|
+
cb: (error: Error | null, stdout: string, stderr: string) => void,
|
|
464
|
+
): void {
|
|
465
|
+
if (cliPath.endsWith(".mjs")) {
|
|
466
|
+
execFile(process.execPath, [cliPath, ...args], opts, cb);
|
|
467
|
+
} else {
|
|
468
|
+
const needsShell = isWindows() && !path.isAbsolute(cliPath) && !cliPath.endsWith(".cmd") && !cliPath.endsWith(".exe");
|
|
469
|
+
execFile(cliPath, args, { ...opts, ...(needsShell ? { shell: true } : {}) }, cb);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 找到升级脚本路径(兼容源码运行、dist 运行、已安装扩展目录、打包环境)
|
|
475
|
+
* Windows 优先查找 .ps1,Mac/Linux 查找 .sh
|
|
476
|
+
*/
|
|
477
|
+
function getUpgradeScriptPath(): string | null {
|
|
478
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
479
|
+
const currentDir = path.dirname(currentFile);
|
|
480
|
+
const scriptName = isWindows() ? "upgrade-via-npm.ps1" : "upgrade-via-npm.sh";
|
|
481
|
+
|
|
482
|
+
const candidates = [
|
|
483
|
+
// 源码运行: src/slash-commands.ts → ../../scripts/
|
|
484
|
+
// dist 运行: dist/src/slash-commands.js → ../../scripts/
|
|
485
|
+
path.resolve(currentDir, "..", "..", "scripts", scriptName),
|
|
486
|
+
// npm 安装: node_modules/@tencent-connect/openclaw-qqbot/dist/src → ../../scripts
|
|
487
|
+
path.resolve(currentDir, "..", "scripts", scriptName),
|
|
488
|
+
path.resolve(process.cwd(), "scripts", scriptName),
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
// 向上查找包含 scripts/ 的祖先目录(适应各种嵌套深度的打包环境)
|
|
492
|
+
let dir = currentDir;
|
|
493
|
+
for (let i = 0; i < 6; i++) {
|
|
494
|
+
const candidate = path.join(dir, "scripts", scriptName);
|
|
495
|
+
if (!candidates.includes(candidate)) candidates.push(candidate);
|
|
496
|
+
const parent = path.dirname(dir);
|
|
497
|
+
if (parent === dir) break;
|
|
498
|
+
dir = parent;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const homeDir = getHomeDir();
|
|
502
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
503
|
+
candidates.push(path.join(homeDir, `.${cli}`, "extensions", "openclaw-qqbot", "scripts", scriptName));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
for (const p of candidates) {
|
|
507
|
+
if (fs.existsSync(p)) return p;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
type HotUpgradeStartResult = {
|
|
514
|
+
ok: boolean;
|
|
515
|
+
reason?: "no-script" | "no-cli" | "no-bash" | "no-powershell";
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 在 Windows 上查找可用的 bash(Git Bash / WSL 等)
|
|
520
|
+
* 仅作为 Windows 上的 fallback(优先使用 PowerShell)
|
|
521
|
+
*/
|
|
522
|
+
function findBash(): string | null {
|
|
523
|
+
if (!isWindows()) return "bash";
|
|
524
|
+
|
|
525
|
+
// Git Bash 常见路径
|
|
526
|
+
const candidates = [
|
|
527
|
+
path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"),
|
|
528
|
+
path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
|
|
529
|
+
path.join(process.env.LOCALAPPDATA || "", "Programs", "Git", "bin", "bash.exe"),
|
|
530
|
+
];
|
|
531
|
+
|
|
532
|
+
for (const p of candidates) {
|
|
533
|
+
if (p && fs.existsSync(p)) return p;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 尝试 PATH 中的 bash
|
|
537
|
+
try {
|
|
538
|
+
execFileSync("where", ["bash"], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
|
|
539
|
+
return "bash";
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 将 openclaw.json 中的 qqbot 插件 source 从 "path" 切换为 "npm"。
|
|
547
|
+
* 用于热更新场景:从 npm 拉取新版本后,确保 openclaw 不再从本地源码加载。
|
|
548
|
+
*
|
|
549
|
+
* 安全保障:写回配置前验证 channels.qqbot 未丢失,防止竞态写入导致凭证消失。
|
|
550
|
+
*/
|
|
551
|
+
function switchPluginSourceToNpm(): void {
|
|
552
|
+
try {
|
|
553
|
+
const homeDir = getHomeDir();
|
|
554
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
555
|
+
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
|
|
556
|
+
if (!fs.existsSync(cfgPath)) continue;
|
|
557
|
+
|
|
558
|
+
// 读取当前配置(保留原始文本用于回退)
|
|
559
|
+
const raw = fs.readFileSync(cfgPath, "utf8");
|
|
560
|
+
|
|
561
|
+
let cfg: any;
|
|
562
|
+
try {
|
|
563
|
+
cfg = JSON.parse(raw);
|
|
564
|
+
} catch {
|
|
565
|
+
// 配置文件已经是损坏的 JSON,不要继续操作以免加剧问题
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const inst = cfg?.plugins?.installs?.["openclaw-qqbot"];
|
|
570
|
+
if (!inst || inst.source === "npm") {
|
|
571
|
+
break; // 无需修改
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 记录修改前的完整快照,用于写后校验
|
|
575
|
+
const channelsBefore = JSON.stringify(cfg.channels ?? null);
|
|
576
|
+
|
|
577
|
+
inst.source = "npm";
|
|
578
|
+
delete inst.sourcePath;
|
|
579
|
+
const newRaw = JSON.stringify(cfg, null, 4) + "\n";
|
|
580
|
+
|
|
581
|
+
// 写后校验:重新解析确认整个 JSON 合法且 channels 未被破坏
|
|
582
|
+
let verify: any;
|
|
583
|
+
try {
|
|
584
|
+
verify = JSON.parse(newRaw);
|
|
585
|
+
} catch {
|
|
586
|
+
// stringify 后竟然无法 parse(理论上不会),放弃写入
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
const channelsAfter = JSON.stringify(verify.channels ?? null);
|
|
590
|
+
if (channelsBefore !== channelsAfter) {
|
|
591
|
+
// channels 数据异常,放弃写入
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 原子写入:先写临时文件,再 rename 替换,避免写入中途崩溃导致配置文件损坏
|
|
596
|
+
const tmpPath = cfgPath + ".qqbot-upgrade.tmp";
|
|
597
|
+
fs.writeFileSync(tmpPath, newRaw, { mode: 0o644 });
|
|
598
|
+
|
|
599
|
+
// 再次校验临时文件的完整性
|
|
600
|
+
try {
|
|
601
|
+
JSON.parse(fs.readFileSync(tmpPath, "utf8"));
|
|
602
|
+
} catch {
|
|
603
|
+
// 写入的临时文件不完整,清理后放弃
|
|
604
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
fs.renameSync(tmpPath, cfgPath);
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
// 非关键操作,静默忽略
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* 热更新前保存当前账户的 appId/secret 到暂存文件。
|
|
618
|
+
* 从 openclaw.json 中直接读取 clientSecret(slash command ctx 中不含 secret)。
|
|
619
|
+
*/
|
|
620
|
+
function preUpgradeCredentialBackup(accountId: string, appId: string): void {
|
|
621
|
+
try {
|
|
622
|
+
const homeDir = getHomeDir();
|
|
623
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
624
|
+
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
|
|
625
|
+
if (!fs.existsSync(cfgPath)) continue;
|
|
626
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
627
|
+
const qqbot = cfg?.channels?.qqbot;
|
|
628
|
+
if (!qqbot) break;
|
|
629
|
+
// 从默认账户或 accounts 子节点中读取 secret
|
|
630
|
+
let secret = "";
|
|
631
|
+
if (accountId === "default" && qqbot.clientSecret) {
|
|
632
|
+
secret = qqbot.clientSecret;
|
|
633
|
+
} else if (qqbot.accounts?.[accountId]?.clientSecret) {
|
|
634
|
+
secret = qqbot.accounts[accountId].clientSecret;
|
|
635
|
+
} else if (qqbot.clientSecret) {
|
|
636
|
+
secret = qqbot.clientSecret;
|
|
637
|
+
}
|
|
638
|
+
if (appId && secret) {
|
|
639
|
+
saveCredentialBackup(accountId, appId, secret);
|
|
640
|
+
}
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
} catch {
|
|
644
|
+
// 非关键操作,静默忽略
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* 在 Windows 上查找 PowerShell(pwsh 优先,powershell.exe 兜底)
|
|
650
|
+
*/
|
|
651
|
+
function findPowerShell(): string | null {
|
|
652
|
+
// pwsh = PowerShell 7+(跨平台),powershell.exe = Windows 内置 5.1
|
|
653
|
+
for (const ps of ["pwsh", "powershell"]) {
|
|
654
|
+
try {
|
|
655
|
+
execFileSync("where", [ps], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
|
|
656
|
+
return ps;
|
|
657
|
+
} catch {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* 将升级脚本复制到临时位置,避免升级过程中插件目录被清理后脚本丢失。
|
|
666
|
+
* 返回临时脚本路径,失败返回 null。
|
|
667
|
+
*/
|
|
668
|
+
function copyScriptToTemp(scriptPath: string): string | null {
|
|
669
|
+
try {
|
|
670
|
+
const ext = path.extname(scriptPath);
|
|
671
|
+
const tmpDir = path.join(getHomeDir(), ".openclaw", ".qqbot-upgrade-tmp");
|
|
672
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
673
|
+
const tmpScript = path.join(tmpDir, `upgrade-via-npm${ext}`);
|
|
674
|
+
fs.copyFileSync(scriptPath, tmpScript);
|
|
675
|
+
if (!isWindows()) {
|
|
676
|
+
fs.chmodSync(tmpScript, 0o755);
|
|
677
|
+
}
|
|
678
|
+
return tmpScript;
|
|
679
|
+
} catch {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const REMOTE_UPGRADE_SCRIPT_URL = "https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh";
|
|
685
|
+
const REMOTE_UPGRADE_SCRIPT_URL_WIN = "https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.ps1";
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 从远端下载升级脚本到临时目录,返回临时脚本路径,失败返回 null。
|
|
689
|
+
*/
|
|
690
|
+
function downloadRemoteUpgradeScript(): string | null {
|
|
691
|
+
try {
|
|
692
|
+
const url = isWindows() ? REMOTE_UPGRADE_SCRIPT_URL_WIN : REMOTE_UPGRADE_SCRIPT_URL;
|
|
693
|
+
const ext = isWindows() ? ".ps1" : ".sh";
|
|
694
|
+
const tmpDir = path.join(getHomeDir(), ".openclaw", ".qqbot-upgrade-tmp");
|
|
695
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
696
|
+
const tmpScript = path.join(tmpDir, `upgrade-via-npm${ext}`);
|
|
697
|
+
|
|
698
|
+
// 使用 curl 同步下载(macOS/Linux/Windows 均内置 curl)
|
|
699
|
+
execFileSync("curl", ["-fsSL", "--max-time", "15", "-o", tmpScript, url], {
|
|
700
|
+
timeout: 20_000,
|
|
701
|
+
stdio: "pipe",
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
if (!fs.existsSync(tmpScript) || fs.statSync(tmpScript).size < 100) {
|
|
705
|
+
console.error(`[qqbot] downloadRemoteUpgradeScript: downloaded file too small or missing`);
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!isWindows()) {
|
|
710
|
+
fs.chmodSync(tmpScript, 0o755);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
console.log(`[qqbot] downloadRemoteUpgradeScript: fetched from ${url} → ${tmpScript}`);
|
|
714
|
+
return tmpScript;
|
|
715
|
+
} catch (e: any) {
|
|
716
|
+
console.error(`[qqbot] downloadRemoteUpgradeScript: failed: ${e.message}`);
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* 清理临时升级脚本目录
|
|
723
|
+
*/
|
|
724
|
+
function cleanupTempScript(): void {
|
|
725
|
+
try {
|
|
726
|
+
const tmpDir = path.join(getHomeDir(), ".openclaw", ".qqbot-upgrade-tmp");
|
|
727
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
728
|
+
} catch {
|
|
729
|
+
// 非关键,静默忽略
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* 执行热更新:执行脚本(--no-restart) → 立即触发 gateway restart
|
|
735
|
+
*
|
|
736
|
+
* fire-and-forget 操作:
|
|
737
|
+
* - 异步执行升级脚本(--no-restart / -NoRestart,只做文件替换)
|
|
738
|
+
* - 脚本完成后**立即**触发 gateway restart(当前进程会被杀掉)
|
|
739
|
+
* - 新进程启动时 getStartupGreeting() 检测到版本变更,自动通知管理员
|
|
740
|
+
*
|
|
741
|
+
* Windows 使用 PowerShell 执行 .ps1 脚本,Mac/Linux 使用 bash 执行 .sh 脚本。
|
|
742
|
+
*
|
|
743
|
+
* 安全机制:脚本会被复制到临时目录再执行,避免升级过程中插件目录被操作导致脚本自身丢失。
|
|
744
|
+
*/
|
|
745
|
+
function fireHotUpgrade(targetVersion?: string, pkg?: string, useLocal?: boolean): HotUpgradeStartResult {
|
|
746
|
+
// --local: 直接使用本地脚本,跳过远端下载
|
|
747
|
+
// 默认: 优先从远端下载升级脚本,避免使用本地可能过时的版本
|
|
748
|
+
const scriptPath = useLocal
|
|
749
|
+
? (() => {
|
|
750
|
+
const local = getUpgradeScriptPath();
|
|
751
|
+
if (!local) return null;
|
|
752
|
+
console.log(`[qqbot] fireHotUpgrade: --local specified, using local script: ${local}`);
|
|
753
|
+
return copyScriptToTemp(local) || local;
|
|
754
|
+
})()
|
|
755
|
+
: downloadRemoteUpgradeScript() || (() => {
|
|
756
|
+
const local = getUpgradeScriptPath();
|
|
757
|
+
if (!local) return null;
|
|
758
|
+
console.log(`[qqbot] fireHotUpgrade: remote download failed, falling back to local script: ${local}`);
|
|
759
|
+
return copyScriptToTemp(local) || local;
|
|
760
|
+
})();
|
|
761
|
+
if (!scriptPath) return { ok: false, reason: "no-script" };
|
|
762
|
+
|
|
763
|
+
const cli = findCli();
|
|
764
|
+
if (!cli) return { ok: false, reason: "no-cli" };
|
|
765
|
+
|
|
766
|
+
let shell: string;
|
|
767
|
+
let shellArgs: string[];
|
|
768
|
+
|
|
769
|
+
if (isWindows()) {
|
|
770
|
+
// Windows: PowerShell 执行 .ps1
|
|
771
|
+
const ps = findPowerShell();
|
|
772
|
+
if (!ps) return { ok: false, reason: "no-powershell" };
|
|
773
|
+
shell = ps;
|
|
774
|
+
shellArgs = [
|
|
775
|
+
"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
|
|
776
|
+
"-File", scriptPath,
|
|
777
|
+
"-NoRestart",
|
|
778
|
+
...(targetVersion ? ["-Version", targetVersion] : []),
|
|
779
|
+
...(pkg ? ["-Pkg", pkg] : []),
|
|
780
|
+
];
|
|
781
|
+
} else {
|
|
782
|
+
// Mac / Linux: bash 执行 .sh
|
|
783
|
+
const bash = findBash();
|
|
784
|
+
if (!bash) return { ok: false, reason: "no-bash" };
|
|
785
|
+
shell = bash;
|
|
786
|
+
shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : []), ...(pkg ? ["--pkg", pkg] : [])];
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}, pkg=${pkg || "default"}`);
|
|
790
|
+
|
|
791
|
+
// ── 兼容 openclaw 3.23+ 配置严格校验 ──
|
|
792
|
+
// openclaw plugins install/update 启动时会校验整个配置文件,
|
|
793
|
+
// 如果 channels.qqbot 已存在但 qqbot 插件尚未加载,校验会报 "unknown channel id: qqbot"。
|
|
794
|
+
//
|
|
795
|
+
// ⚠️ 关键:绝不能直接修改真实的 openclaw.json!
|
|
796
|
+
// gateway 的 config file watcher 会检测到变更并触发 SIGUSR1 重启,
|
|
797
|
+
// 导致当前进程被杀、execFile 回调(restoreConfigAndCleanup)永远不会执行,
|
|
798
|
+
// channels.qqbot 配置就此丢失。
|
|
799
|
+
//
|
|
800
|
+
// 策略:创建临时配置副本(不含 channels.qqbot),通过 OPENCLAW_CONFIG_PATH
|
|
801
|
+
// 环境变量传递给子进程,真实配置文件不受影响。
|
|
802
|
+
// shell 脚本(upgrade-via-npm.sh)内部也有同样的临时配置机制作为双保险。
|
|
803
|
+
const homeDir = getHomeDir();
|
|
804
|
+
const realConfigPath = path.join(homeDir, ".openclaw", "openclaw.json");
|
|
805
|
+
let tempConfigPath: string | null = null;
|
|
806
|
+
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
if (fs.existsSync(realConfigPath)) {
|
|
810
|
+
const cfg = JSON.parse(fs.readFileSync(realConfigPath, "utf8"));
|
|
811
|
+
const needsTempConfig =
|
|
812
|
+
!!(cfg.channels?.qqbot) ||
|
|
813
|
+
!!(cfg.plugins?.entries?.["openclaw-qqbot"]);
|
|
814
|
+
|
|
815
|
+
if (needsTempConfig) {
|
|
816
|
+
// 创建临时配置副本(移除 channels.qqbot 和 plugins.entries.openclaw-qqbot)
|
|
817
|
+
const cleanCfg = JSON.parse(JSON.stringify(cfg)); // deep clone
|
|
818
|
+
if (cleanCfg.channels?.qqbot) {
|
|
819
|
+
delete cleanCfg.channels.qqbot;
|
|
820
|
+
if (Object.keys(cleanCfg.channels).length === 0) delete cleanCfg.channels;
|
|
821
|
+
}
|
|
822
|
+
if (cleanCfg.plugins?.entries?.["openclaw-qqbot"]) {
|
|
823
|
+
delete cleanCfg.plugins.entries["openclaw-qqbot"];
|
|
824
|
+
if (cleanCfg.plugins.entries && Object.keys(cleanCfg.plugins.entries).length === 0) delete cleanCfg.plugins.entries;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const tmpDir = path.join(homeDir, ".openclaw", ".qqbot-upgrade-tmp");
|
|
828
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
829
|
+
tempConfigPath = path.join(tmpDir, "openclaw-tmp.json");
|
|
830
|
+
fs.writeFileSync(tempConfigPath, JSON.stringify(cleanCfg, null, 4) + "\n");
|
|
831
|
+
childEnv.OPENCLAW_CONFIG_PATH = tempConfigPath;
|
|
832
|
+
console.log(`[qqbot] fireHotUpgrade: created temp config without channels.qqbot (OPENCLAW_CONFIG_PATH=${tempConfigPath}), real config untouched`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
} catch (e: any) {
|
|
836
|
+
console.warn(`[qqbot] fireHotUpgrade: failed to create temp config: ${e.message}, proceeding with original`);
|
|
837
|
+
tempConfigPath = null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* 将 openclaw plugins install 写入临时配置的 installs/entries 记录同步回真实配置,
|
|
842
|
+
* 然后清理临时文件。
|
|
843
|
+
*
|
|
844
|
+
* 注意:真实配置中的 channels.qqbot 从未被移除,无需恢复。
|
|
845
|
+
*/
|
|
846
|
+
function syncTempConfigAndCleanup(): void {
|
|
847
|
+
try {
|
|
848
|
+
if (tempConfigPath && fs.existsSync(tempConfigPath) && fs.existsSync(realConfigPath)) {
|
|
849
|
+
const tmp = JSON.parse(fs.readFileSync(tempConfigPath, "utf8"));
|
|
850
|
+
const real = JSON.parse(fs.readFileSync(realConfigPath, "utf8"));
|
|
851
|
+
let changed = false;
|
|
852
|
+
|
|
853
|
+
// 同步 plugins.installs(openclaw plugins install 会写入安装记录)
|
|
854
|
+
if (tmp.plugins?.installs) {
|
|
855
|
+
if (!real.plugins) real.plugins = {};
|
|
856
|
+
real.plugins.installs = { ...(real.plugins.installs || {}), ...tmp.plugins.installs };
|
|
857
|
+
changed = true;
|
|
858
|
+
}
|
|
859
|
+
// 同步 plugins.entries(openclaw plugins install 会写入 entries)
|
|
860
|
+
// 注意:不同步 openclaw-qqbot 自身的 entry,因为插件通过 auto-discover 加载,
|
|
861
|
+
// 显式写入 entries 会导致 "duplicate plugin id" 警告刷屏。
|
|
862
|
+
if (tmp.plugins?.entries) {
|
|
863
|
+
if (!real.plugins) real.plugins = {};
|
|
864
|
+
if (!real.plugins.entries) real.plugins.entries = {};
|
|
865
|
+
for (const [k, v] of Object.entries(tmp.plugins.entries)) {
|
|
866
|
+
if (k === "openclaw-qqbot") continue; // 跳过自身,避免 duplicate
|
|
867
|
+
if (!real.plugins.entries[k]) {
|
|
868
|
+
real.plugins.entries[k] = v;
|
|
869
|
+
changed = true;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (changed) {
|
|
875
|
+
fs.writeFileSync(realConfigPath, JSON.stringify(real, null, 4) + "\n");
|
|
876
|
+
console.log("[qqbot] fireHotUpgrade: synced install/entries records from temp config to real config");
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
} catch (e: any) {
|
|
880
|
+
console.warn(`[qqbot] fireHotUpgrade: failed to sync temp config: ${e.message}`);
|
|
881
|
+
}
|
|
882
|
+
// 清理临时文件
|
|
883
|
+
try { if (tempConfigPath) fs.unlinkSync(tempConfigPath); } catch { /* ignore */ }
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// 异步执行升级脚本
|
|
887
|
+
// 必须显式设置 cwd 为一个确定存在的目录(如 homeDir),
|
|
888
|
+
// 否则子进程继承 gateway 的 cwd,如果该目录在升级过程中被删除/移动,
|
|
889
|
+
// openclaw CLI 启动时 process.cwd() 会报 ENOENT: uv_cwd 错误。
|
|
890
|
+
// 超时设为 5 分钟:openclaw plugins install 需要下载 npm 包,
|
|
891
|
+
// 网络慢时(如国内访问 npm registry)可能需要 2-3 分钟。
|
|
892
|
+
// 120 秒超时会导致脚本被杀但 openclaw CLI 子进程继续运行,
|
|
893
|
+
// 同时 bash 的 cleanup_on_exit 回滚了备份目录,造成 "plugin already exists" 错误。
|
|
894
|
+
const child = execFile(shell, shellArgs, {
|
|
895
|
+
timeout: 300_000,
|
|
896
|
+
cwd: homeDir,
|
|
897
|
+
env: childEnv,
|
|
898
|
+
killSignal: "SIGTERM",
|
|
899
|
+
...(isWindows() ? { windowsHide: true } : {}),
|
|
900
|
+
}, (error, stdout, _stderr) => {
|
|
901
|
+
if (error) {
|
|
902
|
+
console.error(`[qqbot] fireHotUpgrade: script failed: ${error.message}`);
|
|
903
|
+
if (stdout) console.error(`[qqbot] fireHotUpgrade: stdout: ${stdout.slice(0, 2000)}`);
|
|
904
|
+
if (_stderr) console.error(`[qqbot] fireHotUpgrade: stderr: ${_stderr.slice(0, 2000)}`);
|
|
905
|
+
|
|
906
|
+
// 超时时确保子进程树被清理,防止 openclaw plugins install 继续运行
|
|
907
|
+
// 与 cleanup_on_exit 的回滚逻辑冲突(回滚恢复了旧目录,install 又尝试写入)
|
|
908
|
+
if ((error as any).killed || error.message.includes("TIMEOUT")) {
|
|
909
|
+
try {
|
|
910
|
+
// 尝试杀掉子进程树(SIGKILL 确保立即终止)
|
|
911
|
+
child.kill("SIGKILL");
|
|
912
|
+
// 额外尝试通过 pkill 杀掉可能残留的 openclaw plugins install 子进程
|
|
913
|
+
if (!isWindows()) {
|
|
914
|
+
try { execFileSync("pkill", ["-9", "-f", "openclaw.*plugins.*install"], { timeout: 3000, stdio: "pipe" }); } catch { /* ignore */ }
|
|
915
|
+
}
|
|
916
|
+
} catch {
|
|
917
|
+
// 进程可能已退出
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
syncTempConfigAndCleanup();
|
|
922
|
+
cleanupTempScript();
|
|
923
|
+
_upgrading = false;
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
console.log(`[qqbot] fireHotUpgrade: script completed, stdout length=${stdout.length}`);
|
|
928
|
+
|
|
929
|
+
// 从脚本输出中提取版本号,验证文件替换是否成功
|
|
930
|
+
const versionMatch = stdout.match(/QQBOT_NEW_VERSION=(\S+)/);
|
|
931
|
+
const newVersion = versionMatch?.[1];
|
|
932
|
+
if (newVersion === "unknown") {
|
|
933
|
+
console.error(`[qqbot] fireHotUpgrade: script output QQBOT_NEW_VERSION=unknown, aborting restart`);
|
|
934
|
+
syncTempConfigAndCleanup();
|
|
935
|
+
cleanupTempScript();
|
|
936
|
+
_upgrading = false;
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
console.log(`[qqbot] fireHotUpgrade: new version=${newVersion || "(not detected)"}, triggering restart...`);
|
|
941
|
+
|
|
942
|
+
// 脚本执行成功,同步临时配置中的 install 记录并清理
|
|
943
|
+
syncTempConfigAndCleanup();
|
|
944
|
+
cleanupTempScript();
|
|
945
|
+
|
|
946
|
+
// 文件替换成功,在 restart 之前把 source 从 path 切换为 npm,
|
|
947
|
+
// 确保新进程启动时读到的是 npm source,不会被本地源码覆盖。
|
|
948
|
+
switchPluginSourceToNpm();
|
|
949
|
+
|
|
950
|
+
if (isWindows()) {
|
|
951
|
+
// Windows: 启动一个分离的 PowerShell 进程来执行 stop → 等待 → start
|
|
952
|
+
// 这样当前 Node 进程被 stop 杀掉后,PowerShell 进程仍能继续执行 start
|
|
953
|
+
// 使用 PowerShell 而非 bat,因为 cli 可能是 .mjs 文件需要通过 node 调用
|
|
954
|
+
const cliInvoke = cli.endsWith(".mjs")
|
|
955
|
+
? `& '${process.execPath}' '${cli}'`
|
|
956
|
+
: `& '${cli}'`;
|
|
957
|
+
const ps1Content = [
|
|
958
|
+
`Write-Host '[qqbot-upgrade] Stopping gateway...'`,
|
|
959
|
+
`${cliInvoke} gateway stop`,
|
|
960
|
+
`Write-Host '[qqbot-upgrade] Waiting for process to exit...'`,
|
|
961
|
+
`Start-Sleep -Seconds 3`,
|
|
962
|
+
`Write-Host '[qqbot-upgrade] Starting gateway...'`,
|
|
963
|
+
`${cliInvoke} gateway start`,
|
|
964
|
+
`Write-Host '[qqbot-upgrade] Done.'`,
|
|
965
|
+
`Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue`,
|
|
966
|
+
].join("\r\n");
|
|
967
|
+
const ps1Path = path.join(getHomeDir(), ".openclaw", ".qqbot-restart.ps1");
|
|
968
|
+
const ps = findPowerShell();
|
|
969
|
+
try {
|
|
970
|
+
fs.writeFileSync(ps1Path, ps1Content, "utf8");
|
|
971
|
+
// spawn with detached:true + stdio:"ignore" → 真正的独立进程,不受父进程树终止影响
|
|
972
|
+
const child = spawn(ps || "powershell", [
|
|
973
|
+
"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ps1Path,
|
|
974
|
+
], {
|
|
975
|
+
detached: true,
|
|
976
|
+
stdio: "ignore",
|
|
977
|
+
windowsHide: true,
|
|
978
|
+
});
|
|
979
|
+
child.unref();
|
|
980
|
+
console.log(`[qqbot] fireHotUpgrade: launched detached restart script (pid=${child.pid}): ${ps1Path}`);
|
|
981
|
+
} catch (psErr: any) {
|
|
982
|
+
console.error(`[qqbot] fireHotUpgrade: failed to launch ps1 restart: ${psErr.message}, falling back to direct restart`);
|
|
983
|
+
execCliAsync(cli, ["gateway", "restart"], { timeout: 30_000 }, () => {});
|
|
984
|
+
}
|
|
985
|
+
} else {
|
|
986
|
+
// Mac/Linux: 使用 detached shell 脚本执行 stop+start
|
|
987
|
+
//
|
|
988
|
+
// 兼容 openclaw 2026.3.24+ 配置严格校验:
|
|
989
|
+
// gateway restart 时 openclaw 先校验配置(loadConfig)再加载插件。
|
|
990
|
+
// 如果 channels.qqbot 存在但 qqbot channel type 尚未注册,校验会失败。
|
|
991
|
+
// 解决:stop 后临时移除 channels.qqbot → start(插件加载、qqbot type 注册)→ 恢复。
|
|
992
|
+
const cliInvoke = cli.endsWith(".mjs")
|
|
993
|
+
? `"${process.execPath}" "${cli}"`
|
|
994
|
+
: `"${cli}"`;
|
|
995
|
+
const homeDir = getHomeDir();
|
|
996
|
+
const configPath = path.join(homeDir, ".openclaw", "openclaw.json");
|
|
997
|
+
const qqbotChannelBackup = path.join(homeDir, ".openclaw", ".qqbot-channel-backup.json");
|
|
998
|
+
const restartScript = path.join(homeDir, ".openclaw", ".qqbot-restart.sh");
|
|
999
|
+
|
|
1000
|
+
// 先保存 channels.qqbot 到临时文件(在当前进程中,JSON 处理更安全)
|
|
1001
|
+
let hasChannel = false;
|
|
1002
|
+
try {
|
|
1003
|
+
const cfgRaw = fs.readFileSync(configPath, "utf8");
|
|
1004
|
+
const cfg = JSON.parse(cfgRaw);
|
|
1005
|
+
const qqbot = cfg?.channels?.qqbot;
|
|
1006
|
+
if (qqbot) {
|
|
1007
|
+
fs.writeFileSync(qqbotChannelBackup, JSON.stringify(qqbot, null, 2), "utf8");
|
|
1008
|
+
hasChannel = true;
|
|
1009
|
+
}
|
|
1010
|
+
} catch {
|
|
1011
|
+
// 配置文件不存在或 JSON 解析失败,不做处理
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const shContent = `#!/bin/bash
|
|
1015
|
+
# 注意:不使用 set -e,因为 gateway start 失败时仍需恢复 channels.qqbot
|
|
1016
|
+
CLI="${cliInvoke}"
|
|
1017
|
+
CONFIG="${configPath}"
|
|
1018
|
+
BACKUP="${qqbotChannelBackup}"
|
|
1019
|
+
|
|
1020
|
+
# ── 兼容 openclaw 3.23+ 配置严格校验 ──
|
|
1021
|
+
# 所有 openclaw CLI 命令(包括 gateway stop/start)启动时都会 loadConfig 校验配置,
|
|
1022
|
+
# 如果 channels.qqbot 存在但 qqbot 插件尚未加载,校验会报 "unknown channel id: qqbot"。
|
|
1023
|
+
#
|
|
1024
|
+
# 策略:
|
|
1025
|
+
# 1. gateway stop:使用 OPENCLAW_CONFIG_PATH 临时配置(不含 channels.qqbot)
|
|
1026
|
+
# 2. gateway start:先尝试直接启动(真实配置),如果 CLI 校验失败,
|
|
1027
|
+
# 则临时修改真实配置(此时 gateway 已停止,无 config watcher),启动后恢复。
|
|
1028
|
+
# 这样 gateway 进程读取的是完整配置(含 channels.qqbot)。
|
|
1029
|
+
|
|
1030
|
+
# 为 gateway stop 创建临时配置
|
|
1031
|
+
TEMP_RESTART_CONFIG=""
|
|
1032
|
+
if [ -f "$BACKUP" ]; then
|
|
1033
|
+
TEMP_RESTART_CONFIG="\$(mktemp)"
|
|
1034
|
+
node -e "
|
|
1035
|
+
const fs = require('fs');
|
|
1036
|
+
const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
|
|
1037
|
+
if (cfg.channels && cfg.channels.qqbot) {
|
|
1038
|
+
delete cfg.channels.qqbot;
|
|
1039
|
+
if (Object.keys(cfg.channels).length === 0) delete cfg.channels;
|
|
1040
|
+
}
|
|
1041
|
+
if (cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['openclaw-qqbot']) {
|
|
1042
|
+
delete cfg.plugins.entries['openclaw-qqbot'];
|
|
1043
|
+
if (Object.keys(cfg.plugins.entries).length === 0) delete cfg.plugins.entries;
|
|
1044
|
+
}
|
|
1045
|
+
fs.writeFileSync(process.argv[2], JSON.stringify(cfg, null, 4) + '\\n');
|
|
1046
|
+
" "$CONFIG" "$TEMP_RESTART_CONFIG" 2>/dev/null
|
|
1047
|
+
if [ \$? -ne 0 ] || [ ! -s "$TEMP_RESTART_CONFIG" ]; then
|
|
1048
|
+
echo "[qqbot-upgrade] WARNING: failed to create temp config"
|
|
1049
|
+
TEMP_RESTART_CONFIG=""
|
|
1050
|
+
fi
|
|
1051
|
+
fi
|
|
1052
|
+
|
|
1053
|
+
echo "[qqbot-upgrade] Stopping gateway..."
|
|
1054
|
+
if [ -n "$TEMP_RESTART_CONFIG" ]; then
|
|
1055
|
+
OPENCLAW_CONFIG_PATH="$TEMP_RESTART_CONFIG" $CLI gateway stop 2>/dev/null || true
|
|
1056
|
+
else
|
|
1057
|
+
$CLI gateway stop 2>/dev/null || true
|
|
1058
|
+
fi
|
|
1059
|
+
sleep 2
|
|
1060
|
+
|
|
1061
|
+
# 清理临时配置(不再需要)
|
|
1062
|
+
if [ -n "$TEMP_RESTART_CONFIG" ] && [ -f "$TEMP_RESTART_CONFIG" ]; then
|
|
1063
|
+
rm -f "$TEMP_RESTART_CONFIG"
|
|
1064
|
+
fi
|
|
1065
|
+
|
|
1066
|
+
echo "[qqbot-upgrade] Starting gateway..."
|
|
1067
|
+
START_OK=false
|
|
1068
|
+
|
|
1069
|
+
# 先尝试直接启动(使用真实配置,含 channels.qqbot)
|
|
1070
|
+
# 如果 openclaw 版本不做严格校验,或者插件已注册,这会直接成功
|
|
1071
|
+
if $CLI gateway start 2>/dev/null; then
|
|
1072
|
+
START_OK=true
|
|
1073
|
+
echo "[qqbot-upgrade] Gateway started successfully (direct start)"
|
|
1074
|
+
elif [ -f "$BACKUP" ]; then
|
|
1075
|
+
# 直接启动失败(可能是 channels.qqbot 校验失败),
|
|
1076
|
+
# 临时修改真实配置(此时 gateway 已停止,无 config watcher,安全)
|
|
1077
|
+
echo "[qqbot-upgrade] Direct start failed, temporarily removing channels.qqbot from real config..."
|
|
1078
|
+
node -e "
|
|
1079
|
+
const fs = require('fs');
|
|
1080
|
+
const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
|
|
1081
|
+
let changed = false;
|
|
1082
|
+
if (cfg.channels && cfg.channels.qqbot) {
|
|
1083
|
+
delete cfg.channels.qqbot;
|
|
1084
|
+
if (Object.keys(cfg.channels).length === 0) delete cfg.channels;
|
|
1085
|
+
changed = true;
|
|
1086
|
+
}
|
|
1087
|
+
if (cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['openclaw-qqbot']) {
|
|
1088
|
+
delete cfg.plugins.entries['openclaw-qqbot'];
|
|
1089
|
+
if (Object.keys(cfg.plugins.entries).length === 0) delete cfg.plugins.entries;
|
|
1090
|
+
changed = true;
|
|
1091
|
+
}
|
|
1092
|
+
if (changed) {
|
|
1093
|
+
fs.writeFileSync(process.argv[1], JSON.stringify(cfg, null, 4) + '\\n');
|
|
1094
|
+
}
|
|
1095
|
+
" "$CONFIG" 2>/dev/null
|
|
1096
|
+
|
|
1097
|
+
if $CLI gateway start 2>/dev/null; then
|
|
1098
|
+
START_OK=true
|
|
1099
|
+
echo "[qqbot-upgrade] Gateway started successfully (after config fix)"
|
|
1100
|
+
else
|
|
1101
|
+
echo "[qqbot-upgrade] WARNING: gateway start still failed after config fix"
|
|
1102
|
+
fi
|
|
1103
|
+
|
|
1104
|
+
# 等待 gateway 进程启动并加载插件(插件注册 qqbot channel type)
|
|
1105
|
+
echo "[qqbot-upgrade] Waiting for plugin to load (8s)..."
|
|
1106
|
+
sleep 8
|
|
1107
|
+
|
|
1108
|
+
# 恢复 channels.qqbot 到真实配置
|
|
1109
|
+
# gateway 的 config file watcher 会检测到变更并热加载
|
|
1110
|
+
echo "[qqbot-upgrade] Restoring channels.qqbot to real config..."
|
|
1111
|
+
node -e "
|
|
1112
|
+
const fs = require('fs');
|
|
1113
|
+
const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
|
|
1114
|
+
const qqbot = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
|
1115
|
+
if (!cfg.channels) cfg.channels = {};
|
|
1116
|
+
cfg.channels.qqbot = qqbot;
|
|
1117
|
+
// 注意:不写入 plugins.entries.openclaw-qqbot,
|
|
1118
|
+
// 插件通过 auto-discover 加载,显式 entry 会导致 duplicate plugin id 警告。
|
|
1119
|
+
fs.writeFileSync(process.argv[1], JSON.stringify(cfg, null, 4) + '\\n');
|
|
1120
|
+
" "$CONFIG" "$BACKUP" 2>/dev/null
|
|
1121
|
+
rm -f "$BACKUP"
|
|
1122
|
+
echo "[qqbot-upgrade] channels.qqbot restored"
|
|
1123
|
+
else
|
|
1124
|
+
echo "[qqbot-upgrade] WARNING: gateway start failed, no backup to restore"
|
|
1125
|
+
fi
|
|
1126
|
+
|
|
1127
|
+
# 直接启动成功的情况下,清理备份文件
|
|
1128
|
+
if [ "$START_OK" = "true" ] && [ -f "$BACKUP" ]; then
|
|
1129
|
+
rm -f "$BACKUP"
|
|
1130
|
+
fi
|
|
1131
|
+
|
|
1132
|
+
# 如果 start 失败,尝试再次启动
|
|
1133
|
+
if [ "$START_OK" != "true" ]; then
|
|
1134
|
+
echo "[qqbot-upgrade] Retrying gateway start..."
|
|
1135
|
+
sleep 2
|
|
1136
|
+
$CLI gateway start 2>/dev/null || echo "[qqbot-upgrade] WARNING: retry also failed"
|
|
1137
|
+
fi
|
|
1138
|
+
|
|
1139
|
+
# 清理自身
|
|
1140
|
+
rm -f "$0"
|
|
1141
|
+
echo "[qqbot-upgrade] Done."
|
|
1142
|
+
`;
|
|
1143
|
+
try {
|
|
1144
|
+
fs.writeFileSync(restartScript, shContent, { mode: 0o755 });
|
|
1145
|
+
const child = spawn("bash", [restartScript], {
|
|
1146
|
+
detached: true,
|
|
1147
|
+
stdio: "ignore",
|
|
1148
|
+
});
|
|
1149
|
+
child.unref();
|
|
1150
|
+
console.log(`[qqbot] fireHotUpgrade: launched detached restart script (pid=${child.pid}), hasChannel=${hasChannel}`);
|
|
1151
|
+
} catch (shErr: any) {
|
|
1152
|
+
console.error(`[qqbot] fireHotUpgrade: failed to launch restart script: ${shErr.message}, falling back to direct restart`);
|
|
1153
|
+
execCliAsync(cli, ["gateway", "restart"], { timeout: 30_000 }, () => {});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
return { ok: true };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* /bot-upgrade — 统一升级入口
|
|
1163
|
+
*
|
|
1164
|
+
* upgradeMode 开关:
|
|
1165
|
+
* - "doc"(默认):只展示升级指引文档,不执行热更新
|
|
1166
|
+
* - "hot-reload":执行 npm 升级脚本进行热更新
|
|
1167
|
+
*
|
|
1168
|
+
* 热更新模式下的产品流程:
|
|
1169
|
+
* /bot-upgrade — 展示版本信息+确认按钮(不直接升级)
|
|
1170
|
+
* /bot-upgrade --latest — 确认升级到最新版本
|
|
1171
|
+
* /bot-upgrade --version X — 升级到指定版本
|
|
1172
|
+
* /bot-upgrade --force — 强制升级(即使当前已是最新版)
|
|
1173
|
+
*/
|
|
1174
|
+
let _upgrading = false; // 升级锁
|
|
1175
|
+
|
|
1176
|
+
registerCommand({
|
|
1177
|
+
name: "bot-upgrade",
|
|
1178
|
+
description: "检查更新并查看升级指引",
|
|
1179
|
+
usage: [
|
|
1180
|
+
`/bot-upgrade 检查是否有新版本`,
|
|
1181
|
+
`/bot-upgrade --latest 确认升级到最新版本(需 upgradeMode=hot-reload)`,
|
|
1182
|
+
`/bot-upgrade --version X 升级到指定版本(需 upgradeMode=hot-reload)`,
|
|
1183
|
+
`/bot-upgrade --pkg scope/name 指定 npm 包(如 ryantest/openclaw-qqbot)`,
|
|
1184
|
+
`/bot-upgrade --force 强制重新安装当前版本(需 upgradeMode=hot-reload)`,
|
|
1185
|
+
`/bot-upgrade --local 使用本地升级脚本(跳过远端下载)`,
|
|
1186
|
+
].join("\n"),
|
|
1187
|
+
handler: async (ctx) => {
|
|
1188
|
+
const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
|
|
1189
|
+
const upgradeMode = ctx.accountConfig?.upgradeMode || "hot-reload";
|
|
1190
|
+
const args = ctx.args.trim();
|
|
1191
|
+
const info = await getUpdateInfo();
|
|
1192
|
+
|
|
1193
|
+
const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
|
|
1194
|
+
|
|
1195
|
+
// ── doc 模式(默认):只展示升级指引,不执行热更新 ──
|
|
1196
|
+
if (upgradeMode !== "hot-reload") {
|
|
1197
|
+
if (info.checkedAt === 0) {
|
|
1198
|
+
return `⏳ 版本检查中,请稍后再试`;
|
|
1199
|
+
}
|
|
1200
|
+
if (info.error) {
|
|
1201
|
+
return [
|
|
1202
|
+
`❌ 主机网络访问异常,无法检查更新`,
|
|
1203
|
+
``,
|
|
1204
|
+
`查看升级指引:[点击查看](${url})`,
|
|
1205
|
+
].join("\n");
|
|
1206
|
+
}
|
|
1207
|
+
if (!info.hasUpdate) {
|
|
1208
|
+
return [
|
|
1209
|
+
`✅ 当前已是最新版本 v${PLUGIN_VERSION}`,
|
|
1210
|
+
``,
|
|
1211
|
+
`项目地址:[GitHub](${GITHUB_URL})`,
|
|
1212
|
+
].join("\n");
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
return [
|
|
1216
|
+
`🆕 发现新版本`,
|
|
1217
|
+
``,
|
|
1218
|
+
`当前版本:**v${PLUGIN_VERSION}**`,
|
|
1219
|
+
`最新版本:**v${info.latest}**`,
|
|
1220
|
+
``,
|
|
1221
|
+
`📖 升级指引:[点击查看](${url})`,
|
|
1222
|
+
`🌟 官方 GitHub 仓库:[点击前往](${GITHUB_URL})`,
|
|
1223
|
+
].join("\n");
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// ── hot-reload 模式:执行热更新 ──
|
|
1227
|
+
|
|
1228
|
+
// 升级相关指令仅在私聊中可用
|
|
1229
|
+
if (ctx.type !== "c2c") {
|
|
1230
|
+
return `💡 请在私聊中使用此指令`;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// 升级锁:防止重复触发
|
|
1234
|
+
if (_upgrading) {
|
|
1235
|
+
return `⏳ 正在升级中,请稍候...`;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
let isForce = false;
|
|
1239
|
+
let isLatest = false;
|
|
1240
|
+
let isLocal = false;
|
|
1241
|
+
let versionArg: string | undefined;
|
|
1242
|
+
let pkgArg: string | undefined;
|
|
1243
|
+
const tokens = args ? args.split(/\s+/).filter(Boolean) : [];
|
|
1244
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
1245
|
+
const t = tokens[i]!;
|
|
1246
|
+
if (t === "--force") {
|
|
1247
|
+
isForce = true;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (t === "--latest") {
|
|
1251
|
+
isLatest = true;
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
if (t === "--local") {
|
|
1255
|
+
isLocal = true;
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
if (t === "--pkg") {
|
|
1259
|
+
const next = tokens[i + 1];
|
|
1260
|
+
if (!next || next.startsWith("--")) {
|
|
1261
|
+
return `❌ 参数错误:--pkg 需要包名\n\n示例:/bot-upgrade --pkg ryantest/openclaw-qqbot`;
|
|
1262
|
+
}
|
|
1263
|
+
pkgArg = next;
|
|
1264
|
+
i += 1;
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (t.startsWith("--pkg=")) {
|
|
1268
|
+
const v = t.slice("--pkg=".length).trim();
|
|
1269
|
+
if (!v) {
|
|
1270
|
+
return `❌ 参数错误:--pkg 需要包名\n\n示例:/bot-upgrade --pkg ryantest/openclaw-qqbot`;
|
|
1271
|
+
}
|
|
1272
|
+
pkgArg = v;
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
if (t === "--version") {
|
|
1276
|
+
const next = tokens[i + 1];
|
|
1277
|
+
if (!next || next.startsWith("--")) {
|
|
1278
|
+
return `❌ 参数错误:--version 需要版本号\n\n示例:/bot-upgrade --version 1.6.5`;
|
|
1279
|
+
}
|
|
1280
|
+
versionArg = next.replace(/^v/, "");
|
|
1281
|
+
i += 1;
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (t.startsWith("--version=")) {
|
|
1285
|
+
const v = t.slice("--version=".length).trim();
|
|
1286
|
+
if (!v) {
|
|
1287
|
+
return `❌ 参数错误:--version 需要版本号\n\n示例:/bot-upgrade --version 1.6.5`;
|
|
1288
|
+
}
|
|
1289
|
+
versionArg = v.replace(/^v/, "");
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (!t.startsWith("--") && !versionArg) {
|
|
1293
|
+
versionArg = t.replace(/^v/, "");
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// ── 无参数(也没有 --latest / --version / --force):只展示信息+确认按钮 ──
|
|
1299
|
+
if (!versionArg && !isLatest && !isForce) {
|
|
1300
|
+
if (info.checkedAt === 0) {
|
|
1301
|
+
return `⏳ 版本检查中,请稍后再试`;
|
|
1302
|
+
}
|
|
1303
|
+
if (info.error) {
|
|
1304
|
+
return [
|
|
1305
|
+
`❌ 主机网络访问异常,无法检查更新`,
|
|
1306
|
+
``,
|
|
1307
|
+
`查看手动升级指引:[点击查看](${url})`,
|
|
1308
|
+
].join("\n");
|
|
1309
|
+
}
|
|
1310
|
+
if (!info.hasUpdate) {
|
|
1311
|
+
const lines = [
|
|
1312
|
+
`✅ 当前已是最新版本 v${PLUGIN_VERSION}`,
|
|
1313
|
+
``,
|
|
1314
|
+
`项目地址:[GitHub](${GITHUB_URL})`,
|
|
1315
|
+
];
|
|
1316
|
+
return lines.join("\n");
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// 有新版本:展示信息 + 确认按钮
|
|
1320
|
+
return [
|
|
1321
|
+
`🆕 发现新版本`,
|
|
1322
|
+
``,
|
|
1323
|
+
`当前版本:**v${PLUGIN_VERSION}**`,
|
|
1324
|
+
`最新版本:**v${info.latest}**`,
|
|
1325
|
+
``,
|
|
1326
|
+
`升级将重启 Gateway 服务,期间短暂不可用。`,
|
|
1327
|
+
`请确认主机网络可正常访问 npm 仓库。`,
|
|
1328
|
+
``,
|
|
1329
|
+
`**点击确认升级** <qqbot-cmd-enter text="/bot-upgrade --latest" />`,
|
|
1330
|
+
``,
|
|
1331
|
+
`手动升级指引:[点击查看](${url})`,
|
|
1332
|
+
`🌟官方 GitHub 仓库:[点击前往](${GITHUB_URL})`,
|
|
1333
|
+
].join("\n");
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// 解析 npm 包名:--pkg 参数 > 配置项 upgradePkg > 默认
|
|
1337
|
+
// 支持 "scope/name"(自动补 @)和 "@scope/name" 两种格式
|
|
1338
|
+
let upgradePkg = pkgArg || ctx.accountConfig?.upgradePkg;
|
|
1339
|
+
if (upgradePkg) {
|
|
1340
|
+
upgradePkg = upgradePkg.trim();
|
|
1341
|
+
if (!upgradePkg.startsWith("@")) upgradePkg = `@${upgradePkg}`;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ── --version 指定版本:先校验版本号是否存在 ──
|
|
1345
|
+
if (versionArg) {
|
|
1346
|
+
const exists = await checkVersionExists(versionArg, upgradePkg);
|
|
1347
|
+
if (!exists) {
|
|
1348
|
+
return `❌ 版本 ${versionArg} 不存在,请检查版本号`;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// 检查是否就是当前版本
|
|
1352
|
+
if (versionArg === PLUGIN_VERSION && !isForce) {
|
|
1353
|
+
return `✅ 当前已是 v${PLUGIN_VERSION},无需升级`;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// ── --latest:检查是否需要升级 ──
|
|
1358
|
+
if (isLatest && !versionArg) {
|
|
1359
|
+
if (info.checkedAt === 0) {
|
|
1360
|
+
return `⏳ 版本检查中,请稍后再试`;
|
|
1361
|
+
}
|
|
1362
|
+
if (info.error) {
|
|
1363
|
+
return [
|
|
1364
|
+
`❌ 主机网络访问异常,无法检查更新`,
|
|
1365
|
+
``,
|
|
1366
|
+
`查看手动升级指引:[点击查看](${url})`,
|
|
1367
|
+
].join("\n");
|
|
1368
|
+
}
|
|
1369
|
+
if (!info.hasUpdate && !isForce) {
|
|
1370
|
+
return `✅ 当前已是 v${PLUGIN_VERSION},无需升级`;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const targetVersion = versionArg || info.latest || undefined;
|
|
1375
|
+
|
|
1376
|
+
// --force 时如果 targetVersion 等于当前版本,属于强制重装
|
|
1377
|
+
const isReinstall = isForce && targetVersion === PLUGIN_VERSION;
|
|
1378
|
+
|
|
1379
|
+
// ── 环境兼容性检查 ──
|
|
1380
|
+
const compat = checkUpgradeCompatibility();
|
|
1381
|
+
if (!compat.ok) {
|
|
1382
|
+
return [
|
|
1383
|
+
`🚫 当前环境不满足热更新要求:`,
|
|
1384
|
+
``,
|
|
1385
|
+
...compat.errors,
|
|
1386
|
+
...(compat.warnings.length ? [``, ...compat.warnings] : []),
|
|
1387
|
+
``,
|
|
1388
|
+
`查看手动升级指引:[点击查看](${url})`,
|
|
1389
|
+
].join("\n");
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// 加锁
|
|
1393
|
+
_upgrading = true;
|
|
1394
|
+
|
|
1395
|
+
// 热更新前保存凭证快照,防止更新过程被打断导致 appId/secret 丢失
|
|
1396
|
+
preUpgradeCredentialBackup(ctx.accountId, ctx.appId);
|
|
1397
|
+
|
|
1398
|
+
// 异步执行升级
|
|
1399
|
+
const startResult = fireHotUpgrade(targetVersion, upgradePkg, isLocal);
|
|
1400
|
+
if (!startResult.ok) {
|
|
1401
|
+
_upgrading = false;
|
|
1402
|
+
if (startResult.reason === "no-script") {
|
|
1403
|
+
return [
|
|
1404
|
+
`❌ 未找到升级脚本,无法执行热更新`,
|
|
1405
|
+
``,
|
|
1406
|
+
`查看手动升级指引:[点击查看](${url})`,
|
|
1407
|
+
].join("\n");
|
|
1408
|
+
}
|
|
1409
|
+
if (startResult.reason === "no-cli") {
|
|
1410
|
+
return [
|
|
1411
|
+
`❌ 未找到 CLI 工具,无法执行热更新`,
|
|
1412
|
+
``,
|
|
1413
|
+
`查看手动升级指引:[点击查看](${url})`,
|
|
1414
|
+
].join("\n");
|
|
1415
|
+
}
|
|
1416
|
+
if (startResult.reason === "no-powershell") {
|
|
1417
|
+
return [
|
|
1418
|
+
`❌ 未找到 PowerShell,无法执行热更新`,
|
|
1419
|
+
``,
|
|
1420
|
+
`请确认系统中已安装 PowerShell(Windows 10+ 自带)`,
|
|
1421
|
+
`查看手动升级指引:[点击查看](${url})`,
|
|
1422
|
+
].join("\n");
|
|
1423
|
+
}
|
|
1424
|
+
return [
|
|
1425
|
+
`❌ 当前环境不支持热更新(需要 bash)`,
|
|
1426
|
+
``,
|
|
1427
|
+
`查看手动升级指引:[点击查看](${url})`,
|
|
1428
|
+
].join("\n");
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
saveUpgradeGreetingTarget(ctx.accountId, ctx.appId, ctx.senderId);
|
|
1432
|
+
|
|
1433
|
+
const resultLines = isReinstall
|
|
1434
|
+
? [
|
|
1435
|
+
`🔄 正在重新安装 v${PLUGIN_VERSION}...`,
|
|
1436
|
+
``,
|
|
1437
|
+
`预计 30~60 秒完成,届时会自动通知您`,
|
|
1438
|
+
]
|
|
1439
|
+
: [
|
|
1440
|
+
`🔄 正在升级...`,
|
|
1441
|
+
``,
|
|
1442
|
+
`当前版本:v${PLUGIN_VERSION}`,
|
|
1443
|
+
...(targetVersion ? [`目标版本:v${targetVersion}`] : []),
|
|
1444
|
+
``,
|
|
1445
|
+
`预计 30~60 秒完成,届时会自动通知您`,
|
|
1446
|
+
];
|
|
1447
|
+
return resultLines.join("\n");
|
|
1448
|
+
},
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* 从 openclaw.json / clawdbot.json / moltbot.json 的 logging.file 配置中
|
|
1453
|
+
* 提取用户自定义的日志文件路径(直接文件路径,非目录)。
|
|
1454
|
+
*/
|
|
1455
|
+
function getConfiguredLogFiles(): string[] {
|
|
1456
|
+
const homeDir = getHomeDir();
|
|
1457
|
+
const files: string[] = [];
|
|
1458
|
+
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
|
1459
|
+
try {
|
|
1460
|
+
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
|
|
1461
|
+
if (!fs.existsSync(cfgPath)) continue;
|
|
1462
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
1463
|
+
const logFile = cfg?.logging?.file;
|
|
1464
|
+
if (logFile && typeof logFile === "string") {
|
|
1465
|
+
files.push(path.resolve(logFile));
|
|
1466
|
+
}
|
|
1467
|
+
break;
|
|
1468
|
+
} catch {
|
|
1469
|
+
// ignore
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
return files;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* /bot-logs — 导出本地日志文件
|
|
1477
|
+
*
|
|
1478
|
+
* 日志定位策略(兼容腾讯云/各云厂商不同安装路径):
|
|
1479
|
+
* 0. 优先从 openclaw.json 的 logging.file 配置中读取自定义日志路径(最精确)
|
|
1480
|
+
* 1. 使用 *_STATE_DIR 环境变量(OPENCLAW/CLAWDBOT/MOLTBOT)
|
|
1481
|
+
* 2. 扫描常见状态目录:~/.openclaw, ~/.clawdbot, ~/.moltbot 及其 logs 子目录
|
|
1482
|
+
* 3. 扫描 home/cwd/AppData 下名称包含 openclaw/clawdbot/moltbot 的目录
|
|
1483
|
+
* 4. 扫描 /var/log 下的 openclaw/clawdbot/moltbot 目录
|
|
1484
|
+
* 5. 在候选目录中选取最近更新的日志文件(gateway/openclaw/clawdbot/moltbot)
|
|
1485
|
+
*/
|
|
1486
|
+
function collectCandidateLogDirs(): string[] {
|
|
1487
|
+
const homeDir = getHomeDir();
|
|
1488
|
+
const dirs = new Set<string>();
|
|
1489
|
+
|
|
1490
|
+
const pushDir = (p?: string) => {
|
|
1491
|
+
if (!p) return;
|
|
1492
|
+
const normalized = path.resolve(p);
|
|
1493
|
+
dirs.add(normalized);
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
const pushStateDir = (stateDir?: string) => {
|
|
1497
|
+
if (!stateDir) return;
|
|
1498
|
+
pushDir(stateDir);
|
|
1499
|
+
pushDir(path.join(stateDir, "logs"));
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
// 0. 从配置文件的 logging.file 提取目录
|
|
1503
|
+
for (const logFile of getConfiguredLogFiles()) {
|
|
1504
|
+
pushDir(path.dirname(logFile));
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// 1. 环境变量 *_STATE_DIR
|
|
1508
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
1509
|
+
if (!value) continue;
|
|
1510
|
+
if (/STATE_DIR$/i.test(key) && /(OPENCLAW|CLAWDBOT|MOLTBOT)/i.test(key)) {
|
|
1511
|
+
pushStateDir(value);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// 2. 常见状态目录
|
|
1516
|
+
for (const name of [".openclaw", ".clawdbot", ".moltbot", "openclaw", "clawdbot", "moltbot"]) {
|
|
1517
|
+
pushDir(path.join(homeDir, name));
|
|
1518
|
+
pushDir(path.join(homeDir, name, "logs"));
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// 3. home/cwd/AppData 下包含 openclaw/clawdbot/moltbot 的子目录
|
|
1522
|
+
const searchRoots = new Set<string>([
|
|
1523
|
+
homeDir,
|
|
1524
|
+
process.cwd(),
|
|
1525
|
+
path.dirname(process.cwd()),
|
|
1526
|
+
]);
|
|
1527
|
+
if (process.env.APPDATA) searchRoots.add(process.env.APPDATA);
|
|
1528
|
+
if (process.env.LOCALAPPDATA) searchRoots.add(process.env.LOCALAPPDATA);
|
|
1529
|
+
|
|
1530
|
+
for (const root of searchRoots) {
|
|
1531
|
+
try {
|
|
1532
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
1533
|
+
for (const entry of entries) {
|
|
1534
|
+
if (!entry.isDirectory()) continue;
|
|
1535
|
+
if (!/(openclaw|clawdbot|moltbot)/i.test(entry.name)) continue;
|
|
1536
|
+
const base = path.join(root, entry.name);
|
|
1537
|
+
pushDir(base);
|
|
1538
|
+
pushDir(path.join(base, "logs"));
|
|
1539
|
+
}
|
|
1540
|
+
} catch {
|
|
1541
|
+
// 无权限或不存在,跳过
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// 4. /var/log 下的常见日志目录(Linux 服务器部署场景)
|
|
1546
|
+
if (!isWindows()) {
|
|
1547
|
+
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
|
|
1548
|
+
pushDir(path.join("/var/log", name));
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// 5. /tmp 和系统临时目录下的日志(gateway 默认日志路径可能在 /tmp/openclaw/)
|
|
1553
|
+
const tmpRoots = new Set<string>();
|
|
1554
|
+
if (isWindows()) {
|
|
1555
|
+
// Windows: C:\tmp, %TEMP%, %LOCALAPPDATA%\Temp
|
|
1556
|
+
tmpRoots.add("C:\\tmp");
|
|
1557
|
+
if (process.env.TEMP) tmpRoots.add(process.env.TEMP);
|
|
1558
|
+
if (process.env.TMP) tmpRoots.add(process.env.TMP);
|
|
1559
|
+
if (process.env.LOCALAPPDATA) tmpRoots.add(path.join(process.env.LOCALAPPDATA, "Temp"));
|
|
1560
|
+
} else {
|
|
1561
|
+
tmpRoots.add("/tmp");
|
|
1562
|
+
}
|
|
1563
|
+
for (const tmpRoot of tmpRoots) {
|
|
1564
|
+
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
|
|
1565
|
+
pushDir(path.join(tmpRoot, name));
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return Array.from(dirs);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
type LogCandidate = {
|
|
1573
|
+
filePath: string;
|
|
1574
|
+
sourceDir: string;
|
|
1575
|
+
mtimeMs: number;
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
function collectRecentLogFiles(logDirs: string[]): LogCandidate[] {
|
|
1579
|
+
const candidates: LogCandidate[] = [];
|
|
1580
|
+
const dedupe = new Set<string>();
|
|
1581
|
+
|
|
1582
|
+
const pushFile = (filePath: string, sourceDir: string) => {
|
|
1583
|
+
const normalized = path.resolve(filePath);
|
|
1584
|
+
if (dedupe.has(normalized)) return;
|
|
1585
|
+
try {
|
|
1586
|
+
const stat = fs.statSync(normalized);
|
|
1587
|
+
if (!stat.isFile()) return;
|
|
1588
|
+
dedupe.add(normalized);
|
|
1589
|
+
candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs });
|
|
1590
|
+
} catch {
|
|
1591
|
+
// 文件不存在或无权限
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
// 优先级最高:用户在 openclaw.json logging.file 中显式配置的日志文件
|
|
1596
|
+
for (const logFile of getConfiguredLogFiles()) {
|
|
1597
|
+
pushFile(logFile, path.dirname(logFile));
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
for (const dir of logDirs) {
|
|
1601
|
+
pushFile(path.join(dir, "gateway.log"), dir);
|
|
1602
|
+
pushFile(path.join(dir, "gateway.err.log"), dir);
|
|
1603
|
+
pushFile(path.join(dir, "openclaw.log"), dir);
|
|
1604
|
+
pushFile(path.join(dir, "clawdbot.log"), dir);
|
|
1605
|
+
pushFile(path.join(dir, "moltbot.log"), dir);
|
|
1606
|
+
|
|
1607
|
+
try {
|
|
1608
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1609
|
+
for (const entry of entries) {
|
|
1610
|
+
if (!entry.isFile()) continue;
|
|
1611
|
+
if (!/\.(log|txt)$/i.test(entry.name)) continue;
|
|
1612
|
+
if (!/(gateway|openclaw|clawdbot|moltbot)/i.test(entry.name)) continue;
|
|
1613
|
+
pushFile(path.join(dir, entry.name), dir);
|
|
1614
|
+
}
|
|
1615
|
+
} catch {
|
|
1616
|
+
// 无权限或不存在,跳过
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1621
|
+
return candidates;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
registerCommand({
|
|
1625
|
+
name: "bot-logs",
|
|
1626
|
+
description: "导出本地日志文件",
|
|
1627
|
+
usage: [
|
|
1628
|
+
`/bot-logs`,
|
|
1629
|
+
``,
|
|
1630
|
+
`导出最近的 OpenClaw 日志文件(最多 4 个)。`,
|
|
1631
|
+
`每个文件最多保留最后 1000 行,以文件形式返回。`,
|
|
1632
|
+
].join("\n"),
|
|
1633
|
+
handler: () => {
|
|
1634
|
+
const logDirs = collectCandidateLogDirs();
|
|
1635
|
+
const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
|
|
1636
|
+
|
|
1637
|
+
if (recentFiles.length === 0) {
|
|
1638
|
+
const existingDirs = logDirs.filter(d => { try { return fs.existsSync(d); } catch { return false; } });
|
|
1639
|
+
const searched = existingDirs.length > 0
|
|
1640
|
+
? existingDirs.map(d => ` • ${d}`).join("\n")
|
|
1641
|
+
: logDirs.slice(0, 6).map(d => ` • ${d}`).join("\n") + (logDirs.length > 6 ? `\n …及其他 ${logDirs.length - 6} 个路径` : "");
|
|
1642
|
+
return [
|
|
1643
|
+
`⚠️ 未找到日志文件`,
|
|
1644
|
+
``,
|
|
1645
|
+
`已搜索以下${existingDirs.length > 0 ? "已存在的" : ""}路径:`,
|
|
1646
|
+
searched,
|
|
1647
|
+
``,
|
|
1648
|
+
`💡 如果日志在自定义路径,请在配置文件中添加:`,
|
|
1649
|
+
` "logging": { "file": "/path/to/your/logfile.log" }`,
|
|
1650
|
+
].join("\n");
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const lines: string[] = [];
|
|
1654
|
+
let totalIncluded = 0;
|
|
1655
|
+
let totalOriginal = 0;
|
|
1656
|
+
let truncatedCount = 0;
|
|
1657
|
+
const MAX_LINES_PER_FILE = 1000;
|
|
1658
|
+
for (const logFile of recentFiles) {
|
|
1659
|
+
try {
|
|
1660
|
+
const content = fs.readFileSync(logFile.filePath, "utf8");
|
|
1661
|
+
const allLines = content.split("\n");
|
|
1662
|
+
const totalFileLines = allLines.length;
|
|
1663
|
+
const tail = allLines.slice(-MAX_LINES_PER_FILE);
|
|
1664
|
+
if (tail.length > 0) {
|
|
1665
|
+
const fileName = path.basename(logFile.filePath);
|
|
1666
|
+
lines.push(`\n========== ${fileName} (last ${tail.length} of ${totalFileLines} lines) ==========`);
|
|
1667
|
+
lines.push(`from: ${logFile.sourceDir}`);
|
|
1668
|
+
lines.push(...tail);
|
|
1669
|
+
totalIncluded += tail.length;
|
|
1670
|
+
totalOriginal += totalFileLines;
|
|
1671
|
+
if (totalFileLines > MAX_LINES_PER_FILE) truncatedCount++;
|
|
1672
|
+
}
|
|
1673
|
+
} catch {
|
|
1674
|
+
lines.push(`[读取 ${path.basename(logFile.filePath)} 失败]`);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (lines.length === 0) {
|
|
1679
|
+
return `⚠️ 找到日志文件但读取失败,请检查文件权限`;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const tmpDir = getQQBotDataDir("downloads");
|
|
1683
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1684
|
+
const tmpFile = path.join(tmpDir, `bot-logs-${timestamp}.txt`);
|
|
1685
|
+
fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
|
|
1686
|
+
|
|
1687
|
+
const fileCount = recentFiles.length;
|
|
1688
|
+
const topSources = Array.from(new Set(recentFiles.map(item => item.sourceDir))).slice(0, 3);
|
|
1689
|
+
// 紧凑摘要:N 个日志文件,共 X 行(如有截断则注明)
|
|
1690
|
+
let summaryText = `${fileCount} 个日志文件,共 ${totalIncluded} 行`;
|
|
1691
|
+
if (truncatedCount > 0) {
|
|
1692
|
+
summaryText += `(${truncatedCount} 个文件因过长仅保留最后 ${MAX_LINES_PER_FILE} 行,原始共 ${totalOriginal} 行)`;
|
|
1693
|
+
}
|
|
1694
|
+
return {
|
|
1695
|
+
text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`,
|
|
1696
|
+
filePath: tmpFile,
|
|
1697
|
+
};
|
|
1698
|
+
},
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
// ============ /bot-clear-storage ============
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* 扫描指定目录下的所有文件,递归统计。
|
|
1705
|
+
* 返回按文件大小降序排列的文件列表。
|
|
1706
|
+
*/
|
|
1707
|
+
function scanDirectoryFiles(dirPath: string): { filePath: string; size: number }[] {
|
|
1708
|
+
const files: { filePath: string; size: number }[] = [];
|
|
1709
|
+
if (!fs.existsSync(dirPath)) return files;
|
|
1710
|
+
|
|
1711
|
+
const walk = (dir: string) => {
|
|
1712
|
+
let entries: fs.Dirent[];
|
|
1713
|
+
try {
|
|
1714
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1715
|
+
} catch {
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
for (const entry of entries) {
|
|
1719
|
+
const fullPath = path.join(dir, entry.name);
|
|
1720
|
+
if (entry.isDirectory()) {
|
|
1721
|
+
walk(fullPath);
|
|
1722
|
+
} else if (entry.isFile()) {
|
|
1723
|
+
try {
|
|
1724
|
+
const stat = fs.statSync(fullPath);
|
|
1725
|
+
files.push({ filePath: fullPath, size: stat.size });
|
|
1726
|
+
} catch {
|
|
1727
|
+
// 跳过无法访问的文件
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
walk(dirPath);
|
|
1734
|
+
// 按大小降序排列
|
|
1735
|
+
files.sort((a, b) => b.size - a.size);
|
|
1736
|
+
return files;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** 格式化文件大小为人类可读形式 */
|
|
1740
|
+
function formatBytes(bytes: number): string {
|
|
1741
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1742
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1743
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1744
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* /bot-clear-storage — 清理通过 QQBot 对话产生的文件以及下载的资源
|
|
1749
|
+
*
|
|
1750
|
+
* 仅在私聊(c2c)中可用。
|
|
1751
|
+
* --force 执行时删除整个 appId 目录下的所有文件(不区分用户 openid)。
|
|
1752
|
+
*
|
|
1753
|
+
* 产品流程:
|
|
1754
|
+
* /bot-clear-storage — 扫描并列出当前 appId 下的文件,展示确认按钮
|
|
1755
|
+
* /bot-clear-storage --force — 确认执行删除
|
|
1756
|
+
*/
|
|
1757
|
+
registerCommand({
|
|
1758
|
+
name: "bot-clear-storage",
|
|
1759
|
+
description: "清理通过QQBot对话产生的文件以及下载的资源(保存在 OpenClaw 运行环境的主机上)",
|
|
1760
|
+
usage: [
|
|
1761
|
+
`/bot-clear-storage`,
|
|
1762
|
+
``,
|
|
1763
|
+
`扫描当前机器人产生的下载文件并列出明细。`,
|
|
1764
|
+
`确认后执行删除,释放主机磁盘空间。`,
|
|
1765
|
+
``,
|
|
1766
|
+
`/bot-clear-storage --force 确认执行清理`,
|
|
1767
|
+
``,
|
|
1768
|
+
`⚠️ 仅在私聊中可用。`,
|
|
1769
|
+
].join("\n"),
|
|
1770
|
+
handler: (ctx) => {
|
|
1771
|
+
const { appId, type } = ctx;
|
|
1772
|
+
|
|
1773
|
+
// 仅私聊可用
|
|
1774
|
+
if (type !== "c2c") {
|
|
1775
|
+
return `💡 请在私聊中使用此指令`;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const isForce = ctx.args.trim() === "--force";
|
|
1779
|
+
|
|
1780
|
+
// 删除粒度为 appId 目录(不区分用户 openid)
|
|
1781
|
+
// 路径: downloads/{appId}/
|
|
1782
|
+
const targetDir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", "downloads", appId);
|
|
1783
|
+
const displayDir = `~/.openclaw/media/qqbot/downloads/${appId}`;
|
|
1784
|
+
|
|
1785
|
+
if (!isForce) {
|
|
1786
|
+
// ── 第一步:扫描并展示文件列表 ──
|
|
1787
|
+
const files = scanDirectoryFiles(targetDir);
|
|
1788
|
+
|
|
1789
|
+
if (files.length === 0) {
|
|
1790
|
+
return [
|
|
1791
|
+
`✅ 当前没有需要清理的文件`,
|
|
1792
|
+
``,
|
|
1793
|
+
`目录 \`${displayDir}\` 为空或不存在。`,
|
|
1794
|
+
].join("\n");
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
1798
|
+
const MAX_DISPLAY = 10;
|
|
1799
|
+
|
|
1800
|
+
const lines: string[] = [
|
|
1801
|
+
`即将清理 \`${displayDir}\` 目录下所有文件,总共 ${files.length} 个文件,占用磁盘存储空间 ${formatBytes(totalSize)}。`,
|
|
1802
|
+
``,
|
|
1803
|
+
`目录文件概况:`,
|
|
1804
|
+
];
|
|
1805
|
+
|
|
1806
|
+
// 展示前 MAX_DISPLAY 个(按大小降序)
|
|
1807
|
+
const displayFiles = files.slice(0, MAX_DISPLAY);
|
|
1808
|
+
for (const f of displayFiles) {
|
|
1809
|
+
const relativePath = path.relative(targetDir, f.filePath);
|
|
1810
|
+
// 在 Windows 上统一用 / 分隔显示
|
|
1811
|
+
const displayName = relativePath.replace(/\\/g, "/");
|
|
1812
|
+
lines.push(`${displayName} (${formatBytes(f.size)})`, ``, ``);
|
|
1813
|
+
}
|
|
1814
|
+
if (files.length > MAX_DISPLAY) {
|
|
1815
|
+
lines.push(`...[合计:${files.length} 个文件(${formatBytes(totalSize)})]`, ``);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
lines.push(
|
|
1819
|
+
``,
|
|
1820
|
+
`---`,
|
|
1821
|
+
``,
|
|
1822
|
+
`确认清理后,上述保存在 OpenClaw 运行主机磁盘上的文件将永久删除,后续对话过程中AI无法再找回相关文件。`,
|
|
1823
|
+
`‼️ 点击指令确认删除`,
|
|
1824
|
+
`<qqbot-cmd-enter text="/bot-clear-storage --force" />`,
|
|
1825
|
+
);
|
|
1826
|
+
|
|
1827
|
+
return lines.join("\n");
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// ── 第二步:--force 执行删除 ──
|
|
1831
|
+
const files = scanDirectoryFiles(targetDir);
|
|
1832
|
+
|
|
1833
|
+
if (files.length === 0) {
|
|
1834
|
+
return `✅ 目录已为空,无需清理`;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
let deletedCount = 0;
|
|
1838
|
+
let deletedSize = 0;
|
|
1839
|
+
let failedCount = 0;
|
|
1840
|
+
|
|
1841
|
+
for (const f of files) {
|
|
1842
|
+
try {
|
|
1843
|
+
fs.unlinkSync(f.filePath);
|
|
1844
|
+
deletedCount++;
|
|
1845
|
+
deletedSize += f.size;
|
|
1846
|
+
} catch {
|
|
1847
|
+
failedCount++;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// 尝试清理空目录(递归删除空子目录)
|
|
1852
|
+
try {
|
|
1853
|
+
removeEmptyDirs(targetDir);
|
|
1854
|
+
} catch {
|
|
1855
|
+
// 非关键,静默忽略
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
if (failedCount === 0) {
|
|
1859
|
+
return [
|
|
1860
|
+
`✅ 清理成功`,
|
|
1861
|
+
``,
|
|
1862
|
+
`已删除 ${deletedCount} 个文件,释放 ${formatBytes(deletedSize)} 磁盘空间。`,
|
|
1863
|
+
].join("\n");
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
return [
|
|
1867
|
+
`⚠️ 部分清理完成`,
|
|
1868
|
+
``,
|
|
1869
|
+
`已删除 ${deletedCount} 个文件(${formatBytes(deletedSize)}),${failedCount} 个文件删除失败。`,
|
|
1870
|
+
].join("\n");
|
|
1871
|
+
},
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
/** 递归删除空目录(从叶子向上清理) */
|
|
1875
|
+
function removeEmptyDirs(dirPath: string): void {
|
|
1876
|
+
if (!fs.existsSync(dirPath)) return;
|
|
1877
|
+
let entries: fs.Dirent[];
|
|
1878
|
+
try {
|
|
1879
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
1880
|
+
} catch {
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
for (const entry of entries) {
|
|
1884
|
+
if (entry.isDirectory()) {
|
|
1885
|
+
removeEmptyDirs(path.join(dirPath, entry.name));
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
// 重新读取,如果目录已空则删除
|
|
1889
|
+
try {
|
|
1890
|
+
const remaining = fs.readdirSync(dirPath);
|
|
1891
|
+
if (remaining.length === 0) {
|
|
1892
|
+
fs.rmdirSync(dirPath);
|
|
1893
|
+
}
|
|
1894
|
+
} catch {
|
|
1895
|
+
// 目录可能正在被使用,跳过
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// ============ /bot-streaming ============
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* /bot-streaming on|off — 一键开关流式消息
|
|
1903
|
+
*
|
|
1904
|
+
* 直接修改当前账户的 streaming 配置项并持久化到 openclaw.json。
|
|
1905
|
+
* 修改后即时生效(下一条消息起按新配置处理)。
|
|
1906
|
+
*/
|
|
1907
|
+
registerCommand({
|
|
1908
|
+
name: "bot-streaming",
|
|
1909
|
+
description: "一键开关流式消息",
|
|
1910
|
+
usage: [
|
|
1911
|
+
`/bot-streaming on 开启流式消息`,
|
|
1912
|
+
`/bot-streaming off 关闭流式消息`,
|
|
1913
|
+
`/bot-streaming 查看当前流式消息状态`,
|
|
1914
|
+
``,
|
|
1915
|
+
`开启后,AI 的回复会以流式形式逐步显示(打字机效果)。`,
|
|
1916
|
+
`注意:仅 C2C(私聊)支持流式消息。`,
|
|
1917
|
+
].join("\n"),
|
|
1918
|
+
handler: async (ctx) => {
|
|
1919
|
+
// 流式消息仅支持 C2C(私聊),群/频道场景直接提示
|
|
1920
|
+
if (ctx.type !== "c2c") {
|
|
1921
|
+
return `❌ 流式消息仅支持私聊场景,请在私聊中使用 /bot-streaming 指令`;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const arg = ctx.args.trim().toLowerCase();
|
|
1925
|
+
|
|
1926
|
+
// 读取当前 streaming 状态
|
|
1927
|
+
const currentStreaming = ctx.accountConfig?.streaming === true;
|
|
1928
|
+
|
|
1929
|
+
// 无参数:查看当前状态
|
|
1930
|
+
if (!arg) {
|
|
1931
|
+
return [
|
|
1932
|
+
`📡 流式消息状态:${currentStreaming ? "✅ 已开启" : "❌ 已关闭"}`,
|
|
1933
|
+
``,
|
|
1934
|
+
`使用 <qqbot-cmd-input text="/bot-streaming on" show="/bot-streaming on"/> 开启`,
|
|
1935
|
+
`使用 <qqbot-cmd-input text="/bot-streaming off" show="/bot-streaming off"/> 关闭`,
|
|
1936
|
+
].join("\n");
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (arg !== "on" && arg !== "off") {
|
|
1940
|
+
return `❌ 参数错误,请使用 on 或 off\n\n示例:/bot-streaming on`;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
const newStreaming = arg === "on";
|
|
1944
|
+
|
|
1945
|
+
// 如果状态没变,直接返回
|
|
1946
|
+
if (newStreaming === currentStreaming) {
|
|
1947
|
+
return `📡 流式消息已经是${newStreaming ? "开启" : "关闭"}状态,无需操作`;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// 更新配置(参考 handleInteractionCreate 中的配置更新逻辑)
|
|
1951
|
+
try {
|
|
1952
|
+
const runtime = getQQBotRuntime();
|
|
1953
|
+
const configApi = runtime.config as {
|
|
1954
|
+
loadConfig: () => Record<string, unknown>;
|
|
1955
|
+
writeConfigFile: (cfg: unknown) => Promise<void>;
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1958
|
+
const currentCfg = structuredClone(configApi.loadConfig()) as Record<string, unknown>;
|
|
1959
|
+
const qqbot = ((currentCfg.channels ?? {}) as Record<string, unknown>).qqbot as Record<string, unknown> | undefined;
|
|
1960
|
+
|
|
1961
|
+
if (!qqbot) {
|
|
1962
|
+
return `❌ 配置文件中未找到 qqbot 通道配置`;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
const accountId = ctx.accountId;
|
|
1966
|
+
const isNamedAccount = accountId !== "default" && (qqbot.accounts as Record<string, Record<string, unknown>> | undefined)?.[accountId];
|
|
1967
|
+
|
|
1968
|
+
if (isNamedAccount) {
|
|
1969
|
+
// 命名账户:更新 accounts.{accountId}.streaming
|
|
1970
|
+
const accounts = qqbot.accounts as Record<string, Record<string, unknown>>;
|
|
1971
|
+
const acct = accounts[accountId] ?? {};
|
|
1972
|
+
acct.streaming = newStreaming;
|
|
1973
|
+
accounts[accountId] = acct;
|
|
1974
|
+
qqbot.accounts = accounts;
|
|
1975
|
+
} else {
|
|
1976
|
+
// 默认账户:更新 qqbot.streaming
|
|
1977
|
+
qqbot.streaming = newStreaming;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
await configApi.writeConfigFile(currentCfg);
|
|
1981
|
+
|
|
1982
|
+
return [
|
|
1983
|
+
`✅ 流式消息已${newStreaming ? "开启" : "关闭"}`,
|
|
1984
|
+
``,
|
|
1985
|
+
newStreaming
|
|
1986
|
+
? `AI 的回复将以流式形式逐步显示(仅私聊生效)。`
|
|
1987
|
+
: `AI 的回复将恢复为完整发送。`,
|
|
1988
|
+
].join("\n");
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
return `❌ 更新配置失败:${err}`;
|
|
1991
|
+
}
|
|
1992
|
+
},
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
// ============ 匹配入口 ============
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* 尝试匹配并执行插件级斜杠指令
|
|
1999
|
+
*
|
|
2000
|
+
* @returns 回复文本(匹配成功),null(不匹配,应入队正常处理)
|
|
2001
|
+
*/
|
|
2002
|
+
export async function matchSlashCommand(ctx: SlashCommandContext): Promise<SlashCommandResult> {
|
|
2003
|
+
const content = ctx.rawContent.trim();
|
|
2004
|
+
if (!content.startsWith("/")) return null;
|
|
2005
|
+
|
|
2006
|
+
// 解析指令名和参数
|
|
2007
|
+
const spaceIdx = content.indexOf(" ");
|
|
2008
|
+
const cmdName = (spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx)).toLowerCase();
|
|
2009
|
+
const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
|
|
2010
|
+
|
|
2011
|
+
const cmd = commands.get(cmdName);
|
|
2012
|
+
if (!cmd) return null; // 不是插件级指令,交给框架
|
|
2013
|
+
|
|
2014
|
+
// /指令 ? — 返回用法说明
|
|
2015
|
+
if (args === "?") {
|
|
2016
|
+
if (cmd.usage) {
|
|
2017
|
+
return `📖 /${cmd.name} 用法:\n\n${cmd.usage}`;
|
|
2018
|
+
}
|
|
2019
|
+
return `/${cmd.name} — ${cmd.description}`;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
ctx.args = args;
|
|
2023
|
+
const result = await cmd.handler(ctx);
|
|
2024
|
+
return result;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/** 获取插件版本号(供外部使用) */
|
|
2028
|
+
export function getPluginVersion(): string {
|
|
2029
|
+
return PLUGIN_VERSION;
|
|
2030
|
+
}
|