@ryantest/openclaw-qqbot 0.0.1

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.
Files changed (197) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +483 -0
  3. package/README.zh.md +478 -0
  4. package/bin/qqbot-cli.js +243 -0
  5. package/clawdbot.plugin.json +16 -0
  6. package/dist/index.d.ts +17 -0
  7. package/dist/index.js +26 -0
  8. package/dist/src/admin-resolver.d.ts +27 -0
  9. package/dist/src/admin-resolver.js +122 -0
  10. package/dist/src/api.d.ts +156 -0
  11. package/dist/src/api.js +599 -0
  12. package/dist/src/channel.d.ts +11 -0
  13. package/dist/src/channel.js +354 -0
  14. package/dist/src/config.d.ts +25 -0
  15. package/dist/src/config.js +161 -0
  16. package/dist/src/credential-backup.d.ts +31 -0
  17. package/dist/src/credential-backup.js +66 -0
  18. package/dist/src/gateway.d.ts +18 -0
  19. package/dist/src/gateway.js +1265 -0
  20. package/dist/src/image-server.d.ts +68 -0
  21. package/dist/src/image-server.js +462 -0
  22. package/dist/src/inbound-attachments.d.ts +58 -0
  23. package/dist/src/inbound-attachments.js +234 -0
  24. package/dist/src/known-users.d.ts +100 -0
  25. package/dist/src/known-users.js +263 -0
  26. package/dist/src/message-queue.d.ts +50 -0
  27. package/dist/src/message-queue.js +115 -0
  28. package/dist/src/onboarding.d.ts +10 -0
  29. package/dist/src/onboarding.js +203 -0
  30. package/dist/src/outbound-deliver.d.ts +48 -0
  31. package/dist/src/outbound-deliver.js +462 -0
  32. package/dist/src/outbound.d.ts +203 -0
  33. package/dist/src/outbound.js +1102 -0
  34. package/dist/src/proactive.d.ts +170 -0
  35. package/dist/src/proactive.js +399 -0
  36. package/dist/src/ref-index-store.d.ts +70 -0
  37. package/dist/src/ref-index-store.js +273 -0
  38. package/dist/src/reply-dispatcher.d.ts +35 -0
  39. package/dist/src/reply-dispatcher.js +311 -0
  40. package/dist/src/runtime.d.ts +3 -0
  41. package/dist/src/runtime.js +10 -0
  42. package/dist/src/session-store.d.ts +52 -0
  43. package/dist/src/session-store.js +254 -0
  44. package/dist/src/slash-commands.d.ts +71 -0
  45. package/dist/src/slash-commands.js +1179 -0
  46. package/dist/src/startup-greeting.d.ts +30 -0
  47. package/dist/src/startup-greeting.js +78 -0
  48. package/dist/src/stt.d.ts +21 -0
  49. package/dist/src/stt.js +70 -0
  50. package/dist/src/tools/channel.d.ts +16 -0
  51. package/dist/src/tools/channel.js +234 -0
  52. package/dist/src/tools/remind.d.ts +2 -0
  53. package/dist/src/tools/remind.js +247 -0
  54. package/dist/src/types.d.ts +175 -0
  55. package/dist/src/types.js +1 -0
  56. package/dist/src/typing-keepalive.d.ts +27 -0
  57. package/dist/src/typing-keepalive.js +64 -0
  58. package/dist/src/update-checker.d.ts +34 -0
  59. package/dist/src/update-checker.js +166 -0
  60. package/dist/src/user-messages.d.ts +8 -0
  61. package/dist/src/user-messages.js +8 -0
  62. package/dist/src/utils/audio-convert.d.ts +89 -0
  63. package/dist/src/utils/audio-convert.js +704 -0
  64. package/dist/src/utils/file-utils.d.ts +55 -0
  65. package/dist/src/utils/file-utils.js +150 -0
  66. package/dist/src/utils/image-size.d.ts +51 -0
  67. package/dist/src/utils/image-size.js +234 -0
  68. package/dist/src/utils/media-tags.d.ts +14 -0
  69. package/dist/src/utils/media-tags.js +164 -0
  70. package/dist/src/utils/payload.d.ts +112 -0
  71. package/dist/src/utils/payload.js +186 -0
  72. package/dist/src/utils/platform.d.ts +137 -0
  73. package/dist/src/utils/platform.js +390 -0
  74. package/dist/src/utils/text-parsing.d.ts +32 -0
  75. package/dist/src/utils/text-parsing.js +80 -0
  76. package/dist/src/utils/upload-cache.d.ts +34 -0
  77. package/dist/src/utils/upload-cache.js +93 -0
  78. package/index.ts +31 -0
  79. package/moltbot.plugin.json +16 -0
  80. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  81. package/node_modules/@eshaz/web-worker/README.md +134 -0
  82. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  83. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  84. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  85. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  86. package/node_modules/@eshaz/web-worker/node.js +223 -0
  87. package/node_modules/@eshaz/web-worker/package.json +54 -0
  88. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  89. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  90. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  91. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  92. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  93. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  94. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  95. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  96. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  97. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  98. package/node_modules/mpg123-decoder/README.md +265 -0
  99. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  100. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  101. package/node_modules/mpg123-decoder/index.js +8 -0
  102. package/node_modules/mpg123-decoder/package.json +58 -0
  103. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  104. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  105. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  106. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  107. package/node_modules/silk-wasm/LICENSE +21 -0
  108. package/node_modules/silk-wasm/README.md +85 -0
  109. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  110. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  111. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  112. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  113. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  114. package/node_modules/silk-wasm/package.json +39 -0
  115. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  116. package/node_modules/simple-yenc/.prettierignore +1 -0
  117. package/node_modules/simple-yenc/LICENSE +7 -0
  118. package/node_modules/simple-yenc/README.md +163 -0
  119. package/node_modules/simple-yenc/dist/esm.js +1 -0
  120. package/node_modules/simple-yenc/dist/index.js +1 -0
  121. package/node_modules/simple-yenc/package.json +50 -0
  122. package/node_modules/simple-yenc/rollup.config.js +27 -0
  123. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  124. package/node_modules/ws/LICENSE +20 -0
  125. package/node_modules/ws/README.md +548 -0
  126. package/node_modules/ws/browser.js +8 -0
  127. package/node_modules/ws/index.js +13 -0
  128. package/node_modules/ws/lib/buffer-util.js +131 -0
  129. package/node_modules/ws/lib/constants.js +19 -0
  130. package/node_modules/ws/lib/event-target.js +292 -0
  131. package/node_modules/ws/lib/extension.js +203 -0
  132. package/node_modules/ws/lib/limiter.js +55 -0
  133. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  134. package/node_modules/ws/lib/receiver.js +706 -0
  135. package/node_modules/ws/lib/sender.js +602 -0
  136. package/node_modules/ws/lib/stream.js +161 -0
  137. package/node_modules/ws/lib/subprotocol.js +62 -0
  138. package/node_modules/ws/lib/validation.js +152 -0
  139. package/node_modules/ws/lib/websocket-server.js +554 -0
  140. package/node_modules/ws/lib/websocket.js +1393 -0
  141. package/node_modules/ws/package.json +69 -0
  142. package/node_modules/ws/wrapper.mjs +8 -0
  143. package/openclaw.plugin.json +16 -0
  144. package/package.json +76 -0
  145. package/scripts/cleanup-legacy-plugins.sh +124 -0
  146. package/scripts/proactive-api-server.ts +369 -0
  147. package/scripts/send-proactive.ts +293 -0
  148. package/scripts/set-markdown.sh +156 -0
  149. package/scripts/test-sendmedia.ts +116 -0
  150. package/scripts/upgrade-via-alt-pkg.sh +307 -0
  151. package/scripts/upgrade-via-npm.ps1 +296 -0
  152. package/scripts/upgrade-via-npm.sh +301 -0
  153. package/scripts/upgrade-via-source.sh +774 -0
  154. package/skills/qqbot-channel/SKILL.md +263 -0
  155. package/skills/qqbot-channel/references/api_references.md +521 -0
  156. package/skills/qqbot-media/SKILL.md +56 -0
  157. package/skills/qqbot-remind/SKILL.md +149 -0
  158. package/src/admin-resolver.ts +140 -0
  159. package/src/api.ts +819 -0
  160. package/src/bot-logs-2026-03-21T11-21-47(2).txt +46 -0
  161. package/src/channel.ts +381 -0
  162. package/src/config.ts +187 -0
  163. package/src/credential-backup.ts +72 -0
  164. package/src/gateway.log +43 -0
  165. package/src/gateway.ts +1404 -0
  166. package/src/image-server.ts +539 -0
  167. package/src/inbound-attachments.ts +304 -0
  168. package/src/known-users.ts +353 -0
  169. package/src/message-queue.ts +169 -0
  170. package/src/onboarding.ts +274 -0
  171. package/src/openclaw-2026-03-21.log +3729 -0
  172. package/src/openclaw-plugin-sdk.d.ts +522 -0
  173. package/src/outbound-deliver.ts +552 -0
  174. package/src/outbound.ts +1266 -0
  175. package/src/proactive.ts +530 -0
  176. package/src/ref-index-store.ts +357 -0
  177. package/src/reply-dispatcher.ts +334 -0
  178. package/src/runtime.ts +14 -0
  179. package/src/session-store.ts +303 -0
  180. package/src/slash-commands.ts +1305 -0
  181. package/src/startup-greeting.ts +98 -0
  182. package/src/stt.ts +86 -0
  183. package/src/tools/channel.ts +281 -0
  184. package/src/tools/remind.ts +296 -0
  185. package/src/types.ts +183 -0
  186. package/src/typing-keepalive.ts +59 -0
  187. package/src/update-checker.ts +179 -0
  188. package/src/user-messages.ts +7 -0
  189. package/src/utils/audio-convert.ts +803 -0
  190. package/src/utils/file-utils.ts +167 -0
  191. package/src/utils/image-size.ts +266 -0
  192. package/src/utils/media-tags.ts +182 -0
  193. package/src/utils/payload.ts +265 -0
  194. package/src/utils/platform.ts +435 -0
  195. package/src/utils/text-parsing.ts +82 -0
  196. package/src/utils/upload-cache.ts +128 -0
  197. package/tsconfig.json +16 -0
@@ -0,0 +1,1305 @@
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
+ const require = createRequire(import.meta.url);
24
+
25
+ // 读取 package.json 中的版本号
26
+ let PLUGIN_VERSION = "unknown";
27
+ try {
28
+ const pkg = require("../package.json");
29
+ PLUGIN_VERSION = pkg.version ?? "unknown";
30
+ } catch {
31
+ // fallback
32
+ }
33
+
34
+ // 获取 openclaw 框架版本(缓存结果,只执行一次)
35
+ let _frameworkVersion: string | null = null;
36
+ function getFrameworkVersion(): string {
37
+ if (_frameworkVersion !== null) return _frameworkVersion;
38
+ try {
39
+ // 先尝试 PATH 中的 CLI
40
+ // Windows 上 npm 安装的 CLI 通常是 .cmd wrapper,execFileSync 需要 shell:true 才能执行
41
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
42
+ try {
43
+ const out = execFileSync(cli, ["--version"], {
44
+ timeout: 3000, encoding: "utf8",
45
+ ...(isWindows() ? { shell: true } : {}),
46
+ }).trim();
47
+ // 输出格式: "OpenClaw 2026.3.13 (61d171a)"
48
+ if (out) {
49
+ _frameworkVersion = out;
50
+ return _frameworkVersion;
51
+ }
52
+ } catch {
53
+ continue;
54
+ }
55
+ }
56
+ // 尝试 findCli() 找到的完整路径
57
+ const cliPath = findCli();
58
+ if (cliPath) {
59
+ const out = execCliSync(cliPath, ["--version"]);
60
+ if (out) {
61
+ _frameworkVersion = out;
62
+ return _frameworkVersion;
63
+ }
64
+ }
65
+ } catch {
66
+ // fallback
67
+ }
68
+ _frameworkVersion = "unknown";
69
+ return _frameworkVersion;
70
+ }
71
+
72
+ // ============ 热更新兼容性检查 ============
73
+
74
+ /**
75
+ * 热更新可执行的环境要求:
76
+ * - 最低 OpenClaw 框架版本
77
+ * - 支持的操作系统
78
+ * - 最低 Node.js 版本
79
+ */
80
+ const UPGRADE_REQUIREMENTS = {
81
+ /** OpenClaw 最低版本(YYYY.M.D 格式,如 "2026.3.10") */
82
+ minFrameworkVersion: "2026.3.2",
83
+ /** 支持的操作系统列表(process.platform 值) */
84
+ supportedPlatforms: ["darwin", "linux"] as string[],
85
+ /** 最低 Node.js 版本 */
86
+ minNodeVersion: "18.0.0",
87
+ };
88
+
89
+ interface UpgradeCompatResult {
90
+ ok: boolean;
91
+ errors: string[];
92
+ warnings: string[];
93
+ }
94
+
95
+ /**
96
+ * 解析框架版本字符串中的日期版本号
97
+ * 输入示例: "OpenClaw 2026.3.13 (61d171a)" → "2026.3.13"
98
+ */
99
+ function parseFrameworkDateVersion(versionStr: string): string | null {
100
+ const m = versionStr.match(/(\d{4}\.\d{1,2}\.\d{1,2})/);
101
+ return m ? m[1] : null;
102
+ }
103
+
104
+ /**
105
+ * 比较 YYYY.M.D 格式的版本号
106
+ * @returns >0 if a > b, <0 if a < b, 0 if equal
107
+ */
108
+ function compareDateVersions(a: string, b: string): number {
109
+ const pa = a.split(".").map(Number);
110
+ const pb = b.split(".").map(Number);
111
+ for (let i = 0; i < 3; i++) {
112
+ const diff = (pa[i] || 0) - (pb[i] || 0);
113
+ if (diff !== 0) return diff;
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ /**
119
+ * 比较 semver 版本号(简化版,仅比较 major.minor.patch)
120
+ */
121
+ function compareSemver(a: string, b: string): number {
122
+ const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
123
+ const pa = parse(a);
124
+ const pb = parse(b);
125
+ for (let i = 0; i < 3; i++) {
126
+ const diff = (pa[i] || 0) - (pb[i] || 0);
127
+ if (diff !== 0) return diff;
128
+ }
129
+ return 0;
130
+ }
131
+
132
+ /**
133
+ * 检查当前环境是否满足热更新要求
134
+ */
135
+ function checkUpgradeCompatibility(): UpgradeCompatResult {
136
+ const errors: string[] = [];
137
+ const warnings: string[] = [];
138
+ const req = UPGRADE_REQUIREMENTS;
139
+
140
+ // 1. 检查操作系统
141
+ const platform = process.platform;
142
+ if (!req.supportedPlatforms.includes(platform)) {
143
+ const supported = req.supportedPlatforms.map(p => {
144
+ if (p === "darwin") return "macOS";
145
+ if (p === "linux") return "Linux";
146
+ if (p === "win32") return "Windows";
147
+ return p;
148
+ }).join("、");
149
+ const current = platform === "win32" ? "Windows"
150
+ : platform === "darwin" ? "macOS"
151
+ : platform;
152
+ errors.push(`❌ 当前操作系统 **${current}** 不支持热更新(支持:${supported})`);
153
+ }
154
+
155
+ // 2. 检查 OpenClaw 框架版本
156
+ const fwVersion = getFrameworkVersion();
157
+ if (fwVersion === "unknown") {
158
+ // 打包环境(HoldClaw/QQAIO)中 CLI 可能不在 PATH,版本检测会失败,
159
+ // 但 findCli() 的 fallback 仍可能找到 CLI 执行升级,所以只是警告不阻断。
160
+ warnings.push(`⚠️ 无法检测 OpenClaw 框架版本,热更新可能失败`);
161
+ } else {
162
+ const dateVer = parseFrameworkDateVersion(fwVersion);
163
+ if (dateVer && compareDateVersions(dateVer, req.minFrameworkVersion) < 0) {
164
+ errors.push(`❌ OpenClaw 框架版本过低:当前 **${dateVer}**,热更新要求最低 **${req.minFrameworkVersion}**。请先升级框架:\`openclaw upgrade\``);
165
+ }
166
+ }
167
+
168
+ // 3. 检查 Node.js 版本
169
+ const nodeVer = process.version.replace(/^v/, "");
170
+ if (compareSemver(nodeVer, req.minNodeVersion) < 0) {
171
+ errors.push(`❌ Node.js 版本过低:当前 **v${nodeVer}**,热更新要求最低 **v${req.minNodeVersion}**`);
172
+ }
173
+
174
+ // 4. 检查系统架构(arm 等特殊架构提示)
175
+ const arch = process.arch;
176
+ if (arch !== "x64" && arch !== "arm64") {
177
+ warnings.push(`⚠️ 当前 CPU 架构 **${arch}** 未经充分测试,热更新可能存在兼容性问题`);
178
+ }
179
+
180
+ return { ok: errors.length === 0, errors, warnings };
181
+ }
182
+
183
+ // ============ 类型定义 ============
184
+
185
+ /** 斜杠指令上下文(消息元数据 + 运行时状态) */
186
+ export interface SlashCommandContext {
187
+ /** 消息类型 */
188
+ type: "c2c" | "guild" | "dm" | "group";
189
+ /** 发送者 ID */
190
+ senderId: string;
191
+ /** 发送者昵称 */
192
+ senderName?: string;
193
+ /** 消息 ID(用于被动回复) */
194
+ messageId: string;
195
+ /** 开平推送的事件时间戳(ISO 字符串) */
196
+ eventTimestamp: string;
197
+ /** 插件收到消息的本地时间(ms) */
198
+ receivedAt: number;
199
+ /** 原始消息内容 */
200
+ rawContent: string;
201
+ /** 指令参数(去掉指令名后的部分) */
202
+ args: string;
203
+ /** 频道 ID(guild 类型) */
204
+ channelId?: string;
205
+ /** 群 openid(group 类型) */
206
+ groupOpenid?: string;
207
+ /** 账号 ID */
208
+ accountId: string;
209
+ /** Bot App ID */
210
+ appId: string;
211
+ /** 账号配置(供指令读取可配置项) */
212
+ accountConfig?: QQBotAccountConfig;
213
+ /** 当前用户队列状态快照 */
214
+ queueSnapshot: QueueSnapshot;
215
+ }
216
+
217
+ /** 队列状态快照 */
218
+ export interface QueueSnapshot {
219
+ /** 各用户队列中的消息总数 */
220
+ totalPending: number;
221
+ /** 正在并行处理的用户数 */
222
+ activeUsers: number;
223
+ /** 最大并发用户数 */
224
+ maxConcurrentUsers: number;
225
+ /** 当前发送者在队列中的待处理消息数 */
226
+ senderPending: number;
227
+ }
228
+
229
+ /** 斜杠指令返回值:文本、带文件的结果、或 null(不处理) */
230
+ export type SlashCommandResult = string | SlashCommandFileResult | null;
231
+
232
+ /** 带文件的指令结果(先回复文本,再发送文件) */
233
+ export interface SlashCommandFileResult {
234
+ text: string;
235
+ /** 要发送的本地文件路径 */
236
+ filePath: string;
237
+ }
238
+
239
+ /** 斜杠指令定义 */
240
+ interface SlashCommand {
241
+ /** 指令名(不含 /) */
242
+ name: string;
243
+ /** 简要描述 */
244
+ description: string;
245
+ /** 详细用法说明(支持多行),用于 /指令 ? 查询 */
246
+ usage?: string;
247
+ /** 处理函数 */
248
+ handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
249
+ }
250
+
251
+ // ============ 指令注册表 ============
252
+
253
+ const commands: Map<string, SlashCommand> = new Map();
254
+
255
+ function registerCommand(cmd: SlashCommand): void {
256
+ commands.set(cmd.name.toLowerCase(), cmd);
257
+ }
258
+
259
+ // ============ 内置指令 ============
260
+
261
+ /**
262
+ * /bot-ping — 测试当前 openclaw 与 QQ 连接的网络延迟
263
+ */
264
+ registerCommand({
265
+ name: "bot-ping",
266
+ description: "测试当前 openclaw 与 QQ 连接的网络延迟",
267
+ usage: [
268
+ `/bot-ping`,
269
+ ``,
270
+ `测试 OpenClaw 主机与 QQ 服务器之间的网络延迟。`,
271
+ `返回网络传输耗时和插件处理耗时。`,
272
+ ].join("\n"),
273
+ handler: (ctx) => {
274
+ const now = Date.now();
275
+ const eventTime = new Date(ctx.eventTimestamp).getTime();
276
+ if (isNaN(eventTime)) {
277
+ return `✅ pong!`;
278
+ }
279
+ const totalMs = now - eventTime;
280
+ const qqToPlugin = ctx.receivedAt - eventTime;
281
+ const pluginProcess = now - ctx.receivedAt;
282
+ const lines = [
283
+ `✅ pong!`,
284
+ ``,
285
+ `⏱ 延迟: ${totalMs}ms`,
286
+ ` ├ 网络传输: ${qqToPlugin}ms`,
287
+ ` └ 插件处理: ${pluginProcess}ms`,
288
+ ];
289
+ return lines.join("\n");
290
+ },
291
+ });
292
+
293
+ /**
294
+ * /bot-version — 查看插件版本号
295
+ */
296
+ registerCommand({
297
+ name: "bot-version",
298
+ description: "查看插件版本号",
299
+ usage: [
300
+ `/bot-version`,
301
+ ``,
302
+ `查看当前 QQBot 插件版本和 OpenClaw 框架版本。`,
303
+ `同时检查是否有新版本可用。`,
304
+ ].join("\n"),
305
+ handler: async () => {
306
+ const frameworkVersion = getFrameworkVersion();
307
+ const lines = [
308
+ `🦞框架版本:${frameworkVersion}`,
309
+ `🤖QQBot 插件版本:v${PLUGIN_VERSION}`,
310
+ ];
311
+ const info = await getUpdateInfo();
312
+ if (info.checkedAt === 0) {
313
+ lines.push(`⏳ 版本检查中...`);
314
+ } else if (info.error) {
315
+ lines.push(`⚠️ 版本检查失败`);
316
+ } else if (info.hasUpdate && info.latest) {
317
+ lines.push(`🆕最新可用版本:v${info.latest},点击 <qqbot-cmd-input text="/bot-upgrade" show="/bot-upgrade"/> 查看升级指引`);
318
+ }
319
+ lines.push(`🌟官方 GitHub 仓库:[点击前往](https://github.com/tencent-connect/openclaw-qqbot/)`);
320
+ return lines.join("\n");
321
+ },
322
+ });
323
+
324
+ /**
325
+ * /bot-help — 查看所有指令以及用途
326
+ */
327
+ registerCommand({
328
+ name: "bot-help",
329
+ description: "查看所有指令以及用途",
330
+ usage: [
331
+ `/bot-help`,
332
+ ``,
333
+ `列出所有可用的 QQBot 插件内置指令及其简要说明。`,
334
+ `使用 /指令名 ? 可查看某条指令的详细用法。`,
335
+ ].join("\n"),
336
+ handler: () => {
337
+ const lines = [`### QQBot插件内置调试指令`, ``];
338
+ for (const [name, cmd] of commands) {
339
+ lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
340
+ }
341
+ lines.push(``, `> 插件版本 v${PLUGIN_VERSION}`);
342
+ return lines.join("\n");
343
+ },
344
+ });
345
+
346
+ const DEFAULT_UPGRADE_URL = "https://doc.weixin.qq.com/doc/w3_AKEAGQaeACgCNHrh1CbHzTAKtT2gB?scode=AJEAIQdfAAozxFEnLZAKEAGQaeACg";
347
+
348
+ function saveUpgradeGreetingTarget(accountId: string, appId: string, openid: string): void {
349
+ const safeAccountId = accountId.replace(/[^a-zA-Z0-9._-]/g, "_");
350
+ const safeAppId = appId.replace(/[^a-zA-Z0-9._-]/g, "_");
351
+ const filePath = path.join(getQQBotDataDir("data"), `upgrade-greeting-target-${safeAccountId}-${safeAppId}.json`);
352
+ try {
353
+ fs.writeFileSync(filePath, JSON.stringify({
354
+ accountId,
355
+ appId,
356
+ openid,
357
+ savedAt: new Date().toISOString(),
358
+ }) + "\n");
359
+ } catch {
360
+ // ignore
361
+ }
362
+ }
363
+
364
+ // ============ 热更新 ============
365
+
366
+ /**
367
+ * 找到 CLI 命令名或完整路径(openclaw / clawdbot / moltbot)
368
+ *
369
+ * 查找策略:
370
+ * 1. 系统 PATH(where / which)
371
+ * 2. 打包环境(HoldClaw / QQAIO):从当前文件路径向上推断 CLI 位置
372
+ * 3. ~/.openclaw/bin/ 等常见安装路径
373
+ */
374
+ function findCli(): string | null {
375
+ const whichCmd = isWindows() ? "where" : "which";
376
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
377
+ try {
378
+ const out = execFileSync(whichCmd, [cli], { timeout: 3000, encoding: "utf8", stdio: "pipe" }).trim();
379
+ // where 在 Windows 上可能返回多行(多个匹配),取第一行
380
+ const resolved = out.split(/\r?\n/)[0]?.trim();
381
+ return resolved || cli;
382
+ } catch {
383
+ continue;
384
+ }
385
+ }
386
+
387
+ // 打包环境 fallback:从当前文件路径推断 CLI
388
+ // 典型路径: .../gateway/node_modules/openclaw-qqbot/dist/src/slash-commands.js
389
+ // CLI 位于: .../gateway/node_modules/openclaw/openclaw.mjs
390
+ // 或者: .../gateway/node_modules/.bin/openclaw
391
+ try {
392
+ const currentFile = fileURLToPath(import.meta.url);
393
+ const currentDir = path.dirname(currentFile);
394
+
395
+ // 向上查找 node_modules 目录
396
+ let dir = currentDir;
397
+ for (let i = 0; i < 10; i++) {
398
+ const basename = path.basename(dir);
399
+ if (basename === "node_modules") {
400
+ // 检查 .bin 下的 CLI
401
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
402
+ const binName = isWindows() ? `${cli}.cmd` : cli;
403
+ const binPath = path.join(dir, ".bin", binName);
404
+ if (fs.existsSync(binPath)) return binPath;
405
+ }
406
+ // 检查 openclaw/openclaw.mjs(直接通过 node 调用)
407
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
408
+ const mjsPath = path.join(dir, cli, `${cli}.mjs`);
409
+ if (fs.existsSync(mjsPath)) return mjsPath;
410
+ }
411
+ break;
412
+ }
413
+ const parent = path.dirname(dir);
414
+ if (parent === dir) break;
415
+ dir = parent;
416
+ }
417
+ } catch {
418
+ // ignore
419
+ }
420
+
421
+ // ~/.openclaw/bin/ 等常见安装路径
422
+ const homeDir = getHomeDir();
423
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
424
+ const ext = isWindows() ? ".exe" : "";
425
+ const candidates = [
426
+ path.join(homeDir, `.${cli}`, "bin", `${cli}${ext}`),
427
+ path.join(homeDir, `.${cli}`, `${cli}${ext}`),
428
+ ];
429
+ for (const p of candidates) {
430
+ if (fs.existsSync(p)) return p;
431
+ }
432
+ }
433
+
434
+ return null;
435
+ }
436
+
437
+ /**
438
+ * 同步执行 CLI 命令。
439
+ * 当 cliPath 是 .mjs 文件时,自动通过 process.execPath (node) 调用。
440
+ * Windows 上对非完整路径的命令名(如 "openclaw")启用 shell,以兼容 .cmd wrapper。
441
+ */
442
+ function execCliSync(cliPath: string, args: string[]): string | null {
443
+ try {
444
+ if (cliPath.endsWith(".mjs")) {
445
+ return execFileSync(process.execPath, [cliPath, ...args], {
446
+ timeout: 5000, encoding: "utf8", stdio: "pipe",
447
+ }).trim() || null;
448
+ }
449
+ const needsShell = isWindows() && !path.isAbsolute(cliPath) && !cliPath.endsWith(".cmd") && !cliPath.endsWith(".exe");
450
+ return execFileSync(cliPath, args, {
451
+ timeout: 5000, encoding: "utf8", stdio: "pipe",
452
+ ...(needsShell ? { shell: true } : {}),
453
+ }).trim() || null;
454
+ } catch {
455
+ return null;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * 异步执行 CLI 命令。
461
+ * 当 cliPath 是 .mjs 文件时,自动通过 process.execPath (node) 调用。
462
+ * Windows 上对非完整路径的命令名启用 shell,以兼容 .cmd wrapper。
463
+ */
464
+ function execCliAsync(
465
+ cliPath: string,
466
+ args: string[],
467
+ opts: { timeout?: number; env?: NodeJS.ProcessEnv; windowsHide?: boolean },
468
+ cb: (error: Error | null, stdout: string, stderr: string) => void,
469
+ ): void {
470
+ if (cliPath.endsWith(".mjs")) {
471
+ execFile(process.execPath, [cliPath, ...args], opts, cb);
472
+ } else {
473
+ const needsShell = isWindows() && !path.isAbsolute(cliPath) && !cliPath.endsWith(".cmd") && !cliPath.endsWith(".exe");
474
+ execFile(cliPath, args, { ...opts, ...(needsShell ? { shell: true } : {}) }, cb);
475
+ }
476
+ }
477
+
478
+ /**
479
+ * 找到升级脚本路径(兼容源码运行、dist 运行、已安装扩展目录、打包环境)
480
+ * Windows 优先查找 .ps1,Mac/Linux 查找 .sh
481
+ */
482
+ function getUpgradeScriptPath(): string | null {
483
+ const currentFile = fileURLToPath(import.meta.url);
484
+ const currentDir = path.dirname(currentFile);
485
+ const scriptName = isWindows() ? "upgrade-via-npm.ps1" : "upgrade-via-npm.sh";
486
+
487
+ const candidates = [
488
+ // 源码运行: src/slash-commands.ts → ../../scripts/
489
+ // dist 运行: dist/src/slash-commands.js → ../../scripts/
490
+ path.resolve(currentDir, "..", "..", "scripts", scriptName),
491
+ // npm 安装: node_modules/@tencent-connect/openclaw-qqbot/dist/src → ../../scripts
492
+ path.resolve(currentDir, "..", "scripts", scriptName),
493
+ path.resolve(process.cwd(), "scripts", scriptName),
494
+ ];
495
+
496
+ // 向上查找包含 scripts/ 的祖先目录(适应各种嵌套深度的打包环境)
497
+ let dir = currentDir;
498
+ for (let i = 0; i < 6; i++) {
499
+ const candidate = path.join(dir, "scripts", scriptName);
500
+ if (!candidates.includes(candidate)) candidates.push(candidate);
501
+ const parent = path.dirname(dir);
502
+ if (parent === dir) break;
503
+ dir = parent;
504
+ }
505
+
506
+ const homeDir = getHomeDir();
507
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
508
+ candidates.push(path.join(homeDir, `.${cli}`, "extensions", "openclaw-qqbot", "scripts", scriptName));
509
+ }
510
+
511
+ for (const p of candidates) {
512
+ if (fs.existsSync(p)) return p;
513
+ }
514
+
515
+ return null;
516
+ }
517
+
518
+ type HotUpgradeStartResult = {
519
+ ok: boolean;
520
+ reason?: "no-script" | "no-cli" | "no-bash" | "no-powershell";
521
+ };
522
+
523
+ /**
524
+ * 在 Windows 上查找可用的 bash(Git Bash / WSL 等)
525
+ * 仅作为 Windows 上的 fallback(优先使用 PowerShell)
526
+ */
527
+ function findBash(): string | null {
528
+ if (!isWindows()) return "bash";
529
+
530
+ // Git Bash 常见路径
531
+ const candidates = [
532
+ path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"),
533
+ path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
534
+ path.join(process.env.LOCALAPPDATA || "", "Programs", "Git", "bin", "bash.exe"),
535
+ ];
536
+
537
+ for (const p of candidates) {
538
+ if (p && fs.existsSync(p)) return p;
539
+ }
540
+
541
+ // 尝试 PATH 中的 bash
542
+ try {
543
+ execFileSync("where", ["bash"], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
544
+ return "bash";
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * 将 openclaw.json 中的 qqbot 插件 source 从 "path" 切换为 "npm"。
552
+ * 用于热更新场景:从 npm 拉取新版本后,确保 openclaw 不再从本地源码加载。
553
+ *
554
+ * 安全保障:写回配置前验证 channels.qqbot 未丢失,防止竞态写入导致凭证消失。
555
+ */
556
+ function switchPluginSourceToNpm(): void {
557
+ try {
558
+ const homeDir = getHomeDir();
559
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
560
+ const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
561
+ if (!fs.existsSync(cfgPath)) continue;
562
+
563
+ // 读取当前配置(保留原始文本用于回退)
564
+ const raw = fs.readFileSync(cfgPath, "utf8");
565
+
566
+ let cfg: any;
567
+ try {
568
+ cfg = JSON.parse(raw);
569
+ } catch {
570
+ // 配置文件已经是损坏的 JSON,不要继续操作以免加剧问题
571
+ break;
572
+ }
573
+
574
+ const inst = cfg?.plugins?.installs?.["openclaw-qqbot"];
575
+ if (!inst || inst.source === "npm") {
576
+ break; // 无需修改
577
+ }
578
+
579
+ // 记录修改前的完整快照,用于写后校验
580
+ const channelsBefore = JSON.stringify(cfg.channels ?? null);
581
+
582
+ inst.source = "npm";
583
+ delete inst.sourcePath;
584
+ const newRaw = JSON.stringify(cfg, null, 4) + "\n";
585
+
586
+ // 写后校验:重新解析确认整个 JSON 合法且 channels 未被破坏
587
+ let verify: any;
588
+ try {
589
+ verify = JSON.parse(newRaw);
590
+ } catch {
591
+ // stringify 后竟然无法 parse(理论上不会),放弃写入
592
+ break;
593
+ }
594
+ const channelsAfter = JSON.stringify(verify.channels ?? null);
595
+ if (channelsBefore !== channelsAfter) {
596
+ // channels 数据异常,放弃写入
597
+ break;
598
+ }
599
+
600
+ // 原子写入:先写临时文件,再 rename 替换,避免写入中途崩溃导致配置文件损坏
601
+ const tmpPath = cfgPath + ".qqbot-upgrade.tmp";
602
+ fs.writeFileSync(tmpPath, newRaw, { mode: 0o644 });
603
+
604
+ // 再次校验临时文件的完整性
605
+ try {
606
+ JSON.parse(fs.readFileSync(tmpPath, "utf8"));
607
+ } catch {
608
+ // 写入的临时文件不完整,清理后放弃
609
+ try { fs.unlinkSync(tmpPath); } catch {}
610
+ break;
611
+ }
612
+
613
+ fs.renameSync(tmpPath, cfgPath);
614
+ break;
615
+ }
616
+ } catch {
617
+ // 非关键操作,静默忽略
618
+ }
619
+ }
620
+
621
+ /**
622
+ * 热更新前保存当前账户的 appId/secret 到暂存文件。
623
+ * 从 openclaw.json 中直接读取 clientSecret(slash command ctx 中不含 secret)。
624
+ */
625
+ function preUpgradeCredentialBackup(accountId: string, appId: string): void {
626
+ try {
627
+ const homeDir = getHomeDir();
628
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
629
+ const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
630
+ if (!fs.existsSync(cfgPath)) continue;
631
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
632
+ const qqbot = cfg?.channels?.qqbot;
633
+ if (!qqbot) break;
634
+ // 从默认账户或 accounts 子节点中读取 secret
635
+ let secret = "";
636
+ if (accountId === "default" && qqbot.clientSecret) {
637
+ secret = qqbot.clientSecret;
638
+ } else if (qqbot.accounts?.[accountId]?.clientSecret) {
639
+ secret = qqbot.accounts[accountId].clientSecret;
640
+ } else if (qqbot.clientSecret) {
641
+ secret = qqbot.clientSecret;
642
+ }
643
+ if (appId && secret) {
644
+ saveCredentialBackup(accountId, appId, secret);
645
+ }
646
+ break;
647
+ }
648
+ } catch {
649
+ // 非关键操作,静默忽略
650
+ }
651
+ }
652
+
653
+ /**
654
+ * 在 Windows 上查找 PowerShell(pwsh 优先,powershell.exe 兜底)
655
+ */
656
+ function findPowerShell(): string | null {
657
+ // pwsh = PowerShell 7+(跨平台),powershell.exe = Windows 内置 5.1
658
+ for (const ps of ["pwsh", "powershell"]) {
659
+ try {
660
+ execFileSync("where", [ps], { timeout: 3000, encoding: "utf8", stdio: "pipe" });
661
+ return ps;
662
+ } catch {
663
+ continue;
664
+ }
665
+ }
666
+ return null;
667
+ }
668
+
669
+ /**
670
+ * 执行热更新:执行脚本(--no-restart) → 立即触发 gateway restart
671
+ *
672
+ * fire-and-forget 操作:
673
+ * - 异步执行升级脚本(--no-restart / -NoRestart,只做文件替换)
674
+ * - 脚本完成后**立即**触发 gateway restart(当前进程会被杀掉)
675
+ * - 新进程启动时 getStartupGreeting() 检测到版本变更,自动通知管理员
676
+ *
677
+ * Windows 使用 PowerShell 执行 .ps1 脚本,Mac/Linux 使用 bash 执行 .sh 脚本。
678
+ */
679
+ function fireHotUpgrade(targetVersion?: string): HotUpgradeStartResult {
680
+ const scriptPath = getUpgradeScriptPath();
681
+ if (!scriptPath) return { ok: false, reason: "no-script" };
682
+
683
+ const cli = findCli();
684
+ if (!cli) return { ok: false, reason: "no-cli" };
685
+
686
+ let shell: string;
687
+ let shellArgs: string[];
688
+
689
+ if (isWindows()) {
690
+ // Windows: PowerShell 执行 .ps1
691
+ const ps = findPowerShell();
692
+ if (!ps) return { ok: false, reason: "no-powershell" };
693
+ shell = ps;
694
+ shellArgs = [
695
+ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
696
+ "-File", scriptPath,
697
+ "-NoRestart",
698
+ ...(targetVersion ? ["-Version", targetVersion] : []),
699
+ ];
700
+ } else {
701
+ // Mac / Linux: bash 执行 .sh
702
+ const bash = findBash();
703
+ if (!bash) return { ok: false, reason: "no-bash" };
704
+ shell = bash;
705
+ shellArgs = [scriptPath, "--no-restart", ...(targetVersion ? ["--version", targetVersion] : [])];
706
+ }
707
+
708
+ console.log(`[qqbot] fireHotUpgrade: shell=${shell}, script=${scriptPath}, cli=${cli}, target=${targetVersion || "latest"}`);
709
+
710
+ // 异步执行升级脚本
711
+ execFile(shell, shellArgs, {
712
+ timeout: 120_000,
713
+ env: { ...process.env },
714
+ ...(isWindows() ? { windowsHide: true } : {}),
715
+ }, (error, stdout, _stderr) => {
716
+ if (error) {
717
+ console.error(`[qqbot] fireHotUpgrade: script failed: ${error.message}`);
718
+ if (stdout) console.error(`[qqbot] fireHotUpgrade: stdout: ${stdout.slice(0, 2000)}`);
719
+ if (_stderr) console.error(`[qqbot] fireHotUpgrade: stderr: ${_stderr.slice(0, 2000)}`);
720
+ _upgrading = false;
721
+ return;
722
+ }
723
+
724
+ console.log(`[qqbot] fireHotUpgrade: script completed, stdout length=${stdout.length}`);
725
+
726
+ // 从脚本输出中提取版本号,验证文件替换是否成功
727
+ const versionMatch = stdout.match(/QQBOT_NEW_VERSION=(\S+)/);
728
+ const newVersion = versionMatch?.[1];
729
+ if (newVersion === "unknown") {
730
+ console.error(`[qqbot] fireHotUpgrade: script output QQBOT_NEW_VERSION=unknown, aborting restart`);
731
+ _upgrading = false;
732
+ return;
733
+ }
734
+
735
+ console.log(`[qqbot] fireHotUpgrade: new version=${newVersion || "(not detected)"}, triggering restart...`);
736
+
737
+ // 文件替换成功,在 restart 之前把 source 从 path 切换为 npm,
738
+ // 确保新进程启动时读到的是 npm source,不会被本地源码覆盖。
739
+ switchPluginSourceToNpm();
740
+
741
+ if (isWindows()) {
742
+ // Windows: 启动一个分离的 PowerShell 进程来执行 stop → 等待 → start
743
+ // 这样当前 Node 进程被 stop 杀掉后,PowerShell 进程仍能继续执行 start
744
+ // 使用 PowerShell 而非 bat,因为 cli 可能是 .mjs 文件需要通过 node 调用
745
+ const cliInvoke = cli.endsWith(".mjs")
746
+ ? `& '${process.execPath}' '${cli}'`
747
+ : `& '${cli}'`;
748
+ const ps1Content = [
749
+ `Write-Host '[qqbot-upgrade] Stopping gateway...'`,
750
+ `${cliInvoke} gateway stop`,
751
+ `Write-Host '[qqbot-upgrade] Waiting for process to exit...'`,
752
+ `Start-Sleep -Seconds 3`,
753
+ `Write-Host '[qqbot-upgrade] Starting gateway...'`,
754
+ `${cliInvoke} gateway start`,
755
+ `Write-Host '[qqbot-upgrade] Done.'`,
756
+ `Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue`,
757
+ ].join("\r\n");
758
+ const ps1Path = path.join(getHomeDir(), ".openclaw", ".qqbot-restart.ps1");
759
+ const ps = findPowerShell();
760
+ try {
761
+ fs.writeFileSync(ps1Path, ps1Content, "utf8");
762
+ // spawn with detached:true + stdio:"ignore" → 真正的独立进程,不受父进程树终止影响
763
+ const child = spawn(ps || "powershell", [
764
+ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ps1Path,
765
+ ], {
766
+ detached: true,
767
+ stdio: "ignore",
768
+ windowsHide: true,
769
+ });
770
+ child.unref();
771
+ console.log(`[qqbot] fireHotUpgrade: launched detached restart script (pid=${child.pid}): ${ps1Path}`);
772
+ } catch (psErr: any) {
773
+ console.error(`[qqbot] fireHotUpgrade: failed to launch ps1 restart: ${psErr.message}, falling back to direct restart`);
774
+ execCliAsync(cli, ["gateway", "restart"], { timeout: 30_000 }, () => {});
775
+ }
776
+ } else {
777
+ // Mac/Linux: 直接 restart(框架通常以 daemon 模式运行)
778
+ execCliAsync(cli, ["gateway", "restart"], { timeout: 30_000 }, (restartErr) => {
779
+ if (restartErr) {
780
+ console.error(`[qqbot] fireHotUpgrade: restart failed: ${restartErr.message}, trying stop+start fallback`);
781
+ execCliAsync(cli, ["gateway", "stop"], { timeout: 10_000 }, () => {
782
+ setTimeout(() => {
783
+ execCliAsync(cli, ["gateway", "start"], { timeout: 30_000 }, () => {});
784
+ }, 1000);
785
+ });
786
+ }
787
+ });
788
+ }
789
+ });
790
+
791
+ return { ok: true };
792
+ }
793
+
794
+ /**
795
+ * /bot-upgrade — 统一升级入口
796
+ *
797
+ * 产品流程:
798
+ * /bot-upgrade — 展示版本信息+确认按钮(不直接升级)
799
+ * /bot-upgrade --latest — 确认升级到最新版本
800
+ * /bot-upgrade --version X — 升级到指定版本
801
+ * /bot-upgrade --force — 强制升级(即使当前已是最新版)
802
+ */
803
+ let _upgrading = false; // 升级锁
804
+
805
+ registerCommand({
806
+ name: "bot-upgrade",
807
+ description: "检查更新并自动热更",
808
+ usage: [
809
+ `/bot-upgrade 检查是否有新版本(展示信息+确认按钮)`,
810
+ `/bot-upgrade --latest 确认升级到最新版本`,
811
+ `/bot-upgrade --version X 升级到指定版本(如 1.6.5)`,
812
+ `/bot-upgrade --force 强制重新安装当前版本`,
813
+ ``,
814
+ `⚠️ 仅在私聊中可用。升级过程约 30~60 秒,期间服务短暂不可用。`,
815
+ ``,
816
+ `环境要求:`,
817
+ ` - 操作系统:macOS / Linux / Windows`,
818
+ ` - OpenClaw 框架版本 ≥ ${UPGRADE_REQUIREMENTS.minFrameworkVersion}`,
819
+ ` - Node.js ≥ v${UPGRADE_REQUIREMENTS.minNodeVersion}`,
820
+ ].join("\n"),
821
+ handler: async (ctx) => {
822
+ // 升级相关指令仅在私聊中可用
823
+ if (ctx.type !== "c2c") {
824
+ return `💡 请在私聊中使用此指令`;
825
+ }
826
+
827
+ // 升级锁:防止重复触发
828
+ if (_upgrading) {
829
+ return `⏳ 正在升级中,请稍候...`;
830
+ }
831
+
832
+ const url = ctx.accountConfig?.upgradeUrl || DEFAULT_UPGRADE_URL;
833
+ const args = ctx.args.trim();
834
+ const info = await getUpdateInfo();
835
+
836
+ let isForce = false;
837
+ let isLatest = false;
838
+ let versionArg: string | undefined;
839
+ const tokens = args ? args.split(/\s+/).filter(Boolean) : [];
840
+ for (let i = 0; i < tokens.length; i += 1) {
841
+ const t = tokens[i]!;
842
+ if (t === "--force") {
843
+ isForce = true;
844
+ continue;
845
+ }
846
+ if (t === "--latest") {
847
+ isLatest = true;
848
+ continue;
849
+ }
850
+ if (t === "--version") {
851
+ const next = tokens[i + 1];
852
+ if (!next || next.startsWith("--")) {
853
+ return `❌ 参数错误:--version 需要版本号\n\n示例:/bot-upgrade --version 1.6.5`;
854
+ }
855
+ versionArg = next.replace(/^v/, "");
856
+ i += 1;
857
+ continue;
858
+ }
859
+ if (t.startsWith("--version=")) {
860
+ const v = t.slice("--version=".length).trim();
861
+ if (!v) {
862
+ return `❌ 参数错误:--version 需要版本号\n\n示例:/bot-upgrade --version 1.6.5`;
863
+ }
864
+ versionArg = v.replace(/^v/, "");
865
+ continue;
866
+ }
867
+ if (!t.startsWith("--") && !versionArg) {
868
+ versionArg = t.replace(/^v/, "");
869
+ continue;
870
+ }
871
+ }
872
+
873
+ const GITHUB_URL = "https://github.com/tencent-connect/openclaw-qqbot/";
874
+
875
+ // ── 无参数(也没有 --latest / --version / --force):只展示信息+确认按钮 ──
876
+ if (!versionArg && !isLatest && !isForce) {
877
+ if (info.checkedAt === 0) {
878
+ return `⏳ 版本检查中,请稍后再试`;
879
+ }
880
+ if (info.error) {
881
+ return [
882
+ `❌ 主机网络访问异常,无法检查更新`,
883
+ ``,
884
+ `查看手动升级指引:[点击查看](${url})`,
885
+ ].join("\n");
886
+ }
887
+ if (!info.hasUpdate) {
888
+ const lines = [
889
+ `✅ 当前已是最新版本 v${PLUGIN_VERSION}`,
890
+ ``,
891
+ `项目地址:[GitHub](${GITHUB_URL})`,
892
+ ];
893
+ return lines.join("\n");
894
+ }
895
+
896
+ // 有新版本:展示信息 + 确认按钮(同通道:alpha 只展示 alpha,正式版只展示正式版)
897
+ return [
898
+ `🆕 发现新版本`,
899
+ ``,
900
+ `当前版本:**v${PLUGIN_VERSION}**`,
901
+ `最新版本:**v${info.latest}**`,
902
+ ``,
903
+ `升级将重启 Gateway 服务,期间短暂不可用。`,
904
+ `请确认主机网络可正常访问 npm 仓库。`,
905
+ ``,
906
+ `**点击确认升级** <qqbot-cmd-enter text="/bot-upgrade --latest" />`,
907
+ ``,
908
+ `手动升级指引:[点击查看](${url})`,
909
+ `🌟官方 GitHub 仓库:[点击前往](${GITHUB_URL})`,
910
+ ].join("\n");
911
+ }
912
+
913
+ // ── --version 指定版本:先校验版本号是否存在 ──
914
+ if (versionArg) {
915
+ const exists = await checkVersionExists(versionArg);
916
+ if (!exists) {
917
+ return `❌ 版本 ${versionArg} 不存在,请检查版本号`;
918
+ }
919
+
920
+ // 检查是否就是当前版本
921
+ if (versionArg === PLUGIN_VERSION && !isForce) {
922
+ return `✅ 当前已是 v${PLUGIN_VERSION},无需升级`;
923
+ }
924
+ }
925
+
926
+ // ── --latest:检查是否需要升级 ──
927
+ if (isLatest && !versionArg) {
928
+ if (info.checkedAt === 0) {
929
+ return `⏳ 版本检查中,请稍后再试`;
930
+ }
931
+ if (info.error) {
932
+ return [
933
+ `❌ 主机网络访问异常,无法检查更新`,
934
+ ``,
935
+ `查看手动升级指引:[点击查看](${url})`,
936
+ ].join("\n");
937
+ }
938
+ if (!info.hasUpdate && !isForce) {
939
+ return `✅ 当前已是 v${PLUGIN_VERSION},无需升级`;
940
+ }
941
+ }
942
+
943
+ const targetVersion = versionArg || info.latest || undefined;
944
+
945
+ // --force 时如果 targetVersion 等于当前版本,属于强制重装
946
+ const isReinstall = isForce && targetVersion === PLUGIN_VERSION;
947
+
948
+ // ── 环境兼容性检查 ──
949
+ const compat = checkUpgradeCompatibility();
950
+ if (!compat.ok) {
951
+ return [
952
+ `🚫 当前环境不满足热更新要求:`,
953
+ ``,
954
+ ...compat.errors,
955
+ ...(compat.warnings.length ? [``, ...compat.warnings] : []),
956
+ ``,
957
+ `查看手动升级指引:[点击查看](${url})`,
958
+ ].join("\n");
959
+ }
960
+
961
+ // 加锁
962
+ _upgrading = true;
963
+
964
+ // 热更新前保存凭证快照,防止更新过程被打断导致 appId/secret 丢失
965
+ preUpgradeCredentialBackup(ctx.accountId, ctx.appId);
966
+
967
+ // 异步执行升级
968
+ const startResult = fireHotUpgrade(targetVersion);
969
+ if (!startResult.ok) {
970
+ _upgrading = false;
971
+ if (startResult.reason === "no-script") {
972
+ return [
973
+ `❌ 未找到升级脚本,无法执行热更新`,
974
+ ``,
975
+ `查看手动升级指引:[点击查看](${url})`,
976
+ ].join("\n");
977
+ }
978
+ if (startResult.reason === "no-cli") {
979
+ return [
980
+ `❌ 未找到 CLI 工具,无法执行热更新`,
981
+ ``,
982
+ `查看手动升级指引:[点击查看](${url})`,
983
+ ].join("\n");
984
+ }
985
+ if (startResult.reason === "no-powershell") {
986
+ return [
987
+ `❌ 未找到 PowerShell,无法执行热更新`,
988
+ ``,
989
+ `请确认系统中已安装 PowerShell(Windows 10+ 自带)`,
990
+ `查看手动升级指引:[点击查看](${url})`,
991
+ ].join("\n");
992
+ }
993
+ return [
994
+ `❌ 当前环境不支持热更新(需要 bash)`,
995
+ ``,
996
+ `查看手动升级指引:[点击查看](${url})`,
997
+ ].join("\n");
998
+ }
999
+
1000
+ saveUpgradeGreetingTarget(ctx.accountId, ctx.appId, ctx.senderId);
1001
+
1002
+ const resultLines = isReinstall
1003
+ ? [
1004
+ `🔄 正在重新安装 v${PLUGIN_VERSION}...`,
1005
+ ``,
1006
+ `预计 30~60 秒完成,届时会自动通知您`,
1007
+ ]
1008
+ : [
1009
+ `🔄 正在升级...`,
1010
+ ``,
1011
+ `当前版本:v${PLUGIN_VERSION}`,
1012
+ ...(targetVersion ? [`目标版本:v${targetVersion}`] : []),
1013
+ ``,
1014
+ `预计 30~60 秒完成,届时会自动通知您`,
1015
+ ];
1016
+ return resultLines.join("\n");
1017
+ },
1018
+ });
1019
+
1020
+ /**
1021
+ * 从 openclaw.json / clawdbot.json / moltbot.json 的 logging.file 配置中
1022
+ * 提取用户自定义的日志文件路径(直接文件路径,非目录)。
1023
+ */
1024
+ function getConfiguredLogFiles(): string[] {
1025
+ const homeDir = getHomeDir();
1026
+ const files: string[] = [];
1027
+ for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
1028
+ try {
1029
+ const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
1030
+ if (!fs.existsSync(cfgPath)) continue;
1031
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
1032
+ const logFile = cfg?.logging?.file;
1033
+ if (logFile && typeof logFile === "string") {
1034
+ files.push(path.resolve(logFile));
1035
+ }
1036
+ break;
1037
+ } catch {
1038
+ // ignore
1039
+ }
1040
+ }
1041
+ return files;
1042
+ }
1043
+
1044
+ /**
1045
+ * /bot-logs — 导出本地日志文件
1046
+ *
1047
+ * 日志定位策略(兼容腾讯云/各云厂商不同安装路径):
1048
+ * 0. 优先从 openclaw.json 的 logging.file 配置中读取自定义日志路径(最精确)
1049
+ * 1. 使用 *_STATE_DIR 环境变量(OPENCLAW/CLAWDBOT/MOLTBOT)
1050
+ * 2. 扫描常见状态目录:~/.openclaw, ~/.clawdbot, ~/.moltbot 及其 logs 子目录
1051
+ * 3. 扫描 home/cwd/AppData 下名称包含 openclaw/clawdbot/moltbot 的目录
1052
+ * 4. 扫描 /var/log 下的 openclaw/clawdbot/moltbot 目录
1053
+ * 5. 在候选目录中选取最近更新的日志文件(gateway/openclaw/clawdbot/moltbot)
1054
+ */
1055
+ function collectCandidateLogDirs(): string[] {
1056
+ const homeDir = getHomeDir();
1057
+ const dirs = new Set<string>();
1058
+
1059
+ const pushDir = (p?: string) => {
1060
+ if (!p) return;
1061
+ const normalized = path.resolve(p);
1062
+ dirs.add(normalized);
1063
+ };
1064
+
1065
+ const pushStateDir = (stateDir?: string) => {
1066
+ if (!stateDir) return;
1067
+ pushDir(stateDir);
1068
+ pushDir(path.join(stateDir, "logs"));
1069
+ };
1070
+
1071
+ // 0. 从配置文件的 logging.file 提取目录
1072
+ for (const logFile of getConfiguredLogFiles()) {
1073
+ pushDir(path.dirname(logFile));
1074
+ }
1075
+
1076
+ // 1. 环境变量 *_STATE_DIR
1077
+ for (const [key, value] of Object.entries(process.env)) {
1078
+ if (!value) continue;
1079
+ if (/STATE_DIR$/i.test(key) && /(OPENCLAW|CLAWDBOT|MOLTBOT)/i.test(key)) {
1080
+ pushStateDir(value);
1081
+ }
1082
+ }
1083
+
1084
+ // 2. 常见状态目录
1085
+ for (const name of [".openclaw", ".clawdbot", ".moltbot", "openclaw", "clawdbot", "moltbot"]) {
1086
+ pushDir(path.join(homeDir, name));
1087
+ pushDir(path.join(homeDir, name, "logs"));
1088
+ }
1089
+
1090
+ // 3. home/cwd/AppData 下包含 openclaw/clawdbot/moltbot 的子目录
1091
+ const searchRoots = new Set<string>([
1092
+ homeDir,
1093
+ process.cwd(),
1094
+ path.dirname(process.cwd()),
1095
+ ]);
1096
+ if (process.env.APPDATA) searchRoots.add(process.env.APPDATA);
1097
+ if (process.env.LOCALAPPDATA) searchRoots.add(process.env.LOCALAPPDATA);
1098
+
1099
+ for (const root of searchRoots) {
1100
+ try {
1101
+ const entries = fs.readdirSync(root, { withFileTypes: true });
1102
+ for (const entry of entries) {
1103
+ if (!entry.isDirectory()) continue;
1104
+ if (!/(openclaw|clawdbot|moltbot)/i.test(entry.name)) continue;
1105
+ const base = path.join(root, entry.name);
1106
+ pushDir(base);
1107
+ pushDir(path.join(base, "logs"));
1108
+ }
1109
+ } catch {
1110
+ // 无权限或不存在,跳过
1111
+ }
1112
+ }
1113
+
1114
+ // 4. /var/log 下的常见日志目录(Linux 服务器部署场景)
1115
+ if (!isWindows()) {
1116
+ for (const name of ["openclaw", "clawdbot", "moltbot"]) {
1117
+ pushDir(path.join("/var/log", name));
1118
+ }
1119
+ }
1120
+
1121
+ // 5. /tmp 和系统临时目录下的日志(gateway 默认日志路径可能在 /tmp/openclaw/)
1122
+ const tmpRoots = new Set<string>();
1123
+ if (isWindows()) {
1124
+ // Windows: C:\tmp, %TEMP%, %LOCALAPPDATA%\Temp
1125
+ tmpRoots.add("C:\\tmp");
1126
+ if (process.env.TEMP) tmpRoots.add(process.env.TEMP);
1127
+ if (process.env.TMP) tmpRoots.add(process.env.TMP);
1128
+ if (process.env.LOCALAPPDATA) tmpRoots.add(path.join(process.env.LOCALAPPDATA, "Temp"));
1129
+ } else {
1130
+ tmpRoots.add("/tmp");
1131
+ }
1132
+ for (const tmpRoot of tmpRoots) {
1133
+ for (const name of ["openclaw", "clawdbot", "moltbot"]) {
1134
+ pushDir(path.join(tmpRoot, name));
1135
+ }
1136
+ }
1137
+
1138
+ return Array.from(dirs);
1139
+ }
1140
+
1141
+ type LogCandidate = {
1142
+ filePath: string;
1143
+ sourceDir: string;
1144
+ mtimeMs: number;
1145
+ };
1146
+
1147
+ function collectRecentLogFiles(logDirs: string[]): LogCandidate[] {
1148
+ const candidates: LogCandidate[] = [];
1149
+ const dedupe = new Set<string>();
1150
+
1151
+ const pushFile = (filePath: string, sourceDir: string) => {
1152
+ const normalized = path.resolve(filePath);
1153
+ if (dedupe.has(normalized)) return;
1154
+ try {
1155
+ const stat = fs.statSync(normalized);
1156
+ if (!stat.isFile()) return;
1157
+ dedupe.add(normalized);
1158
+ candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs });
1159
+ } catch {
1160
+ // 文件不存在或无权限
1161
+ }
1162
+ };
1163
+
1164
+ // 优先级最高:用户在 openclaw.json logging.file 中显式配置的日志文件
1165
+ for (const logFile of getConfiguredLogFiles()) {
1166
+ pushFile(logFile, path.dirname(logFile));
1167
+ }
1168
+
1169
+ for (const dir of logDirs) {
1170
+ pushFile(path.join(dir, "gateway.log"), dir);
1171
+ pushFile(path.join(dir, "gateway.err.log"), dir);
1172
+ pushFile(path.join(dir, "openclaw.log"), dir);
1173
+ pushFile(path.join(dir, "clawdbot.log"), dir);
1174
+ pushFile(path.join(dir, "moltbot.log"), dir);
1175
+
1176
+ try {
1177
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1178
+ for (const entry of entries) {
1179
+ if (!entry.isFile()) continue;
1180
+ if (!/\.(log|txt)$/i.test(entry.name)) continue;
1181
+ if (!/(gateway|openclaw|clawdbot|moltbot)/i.test(entry.name)) continue;
1182
+ pushFile(path.join(dir, entry.name), dir);
1183
+ }
1184
+ } catch {
1185
+ // 无权限或不存在,跳过
1186
+ }
1187
+ }
1188
+
1189
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
1190
+ return candidates;
1191
+ }
1192
+
1193
+ registerCommand({
1194
+ name: "bot-logs",
1195
+ description: "导出本地日志文件",
1196
+ usage: [
1197
+ `/bot-logs`,
1198
+ ``,
1199
+ `导出最近的 OpenClaw 日志文件(最多 4 个)。`,
1200
+ `每个文件最多保留最后 1000 行,以文件形式返回。`,
1201
+ ].join("\n"),
1202
+ handler: () => {
1203
+ const logDirs = collectCandidateLogDirs();
1204
+ const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
1205
+
1206
+ if (recentFiles.length === 0) {
1207
+ const existingDirs = logDirs.filter(d => { try { return fs.existsSync(d); } catch { return false; } });
1208
+ const searched = existingDirs.length > 0
1209
+ ? existingDirs.map(d => ` • ${d}`).join("\n")
1210
+ : logDirs.slice(0, 6).map(d => ` • ${d}`).join("\n") + (logDirs.length > 6 ? `\n …及其他 ${logDirs.length - 6} 个路径` : "");
1211
+ return [
1212
+ `⚠️ 未找到日志文件`,
1213
+ ``,
1214
+ `已搜索以下${existingDirs.length > 0 ? "已存在的" : ""}路径:`,
1215
+ searched,
1216
+ ``,
1217
+ `💡 如果日志在自定义路径,请在配置文件中添加:`,
1218
+ ` "logging": { "file": "/path/to/your/logfile.log" }`,
1219
+ ].join("\n");
1220
+ }
1221
+
1222
+ const lines: string[] = [];
1223
+ let totalIncluded = 0;
1224
+ let totalOriginal = 0;
1225
+ let truncatedCount = 0;
1226
+ const MAX_LINES_PER_FILE = 1000;
1227
+ for (const logFile of recentFiles) {
1228
+ try {
1229
+ const content = fs.readFileSync(logFile.filePath, "utf8");
1230
+ const allLines = content.split("\n");
1231
+ const totalFileLines = allLines.length;
1232
+ const tail = allLines.slice(-MAX_LINES_PER_FILE);
1233
+ if (tail.length > 0) {
1234
+ const fileName = path.basename(logFile.filePath);
1235
+ lines.push(`\n========== ${fileName} (last ${tail.length} of ${totalFileLines} lines) ==========`);
1236
+ lines.push(`from: ${logFile.sourceDir}`);
1237
+ lines.push(...tail);
1238
+ totalIncluded += tail.length;
1239
+ totalOriginal += totalFileLines;
1240
+ if (totalFileLines > MAX_LINES_PER_FILE) truncatedCount++;
1241
+ }
1242
+ } catch {
1243
+ lines.push(`[读取 ${path.basename(logFile.filePath)} 失败]`);
1244
+ }
1245
+ }
1246
+
1247
+ if (lines.length === 0) {
1248
+ return `⚠️ 找到日志文件但读取失败,请检查文件权限`;
1249
+ }
1250
+
1251
+ const tmpDir = getQQBotDataDir("downloads");
1252
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1253
+ const tmpFile = path.join(tmpDir, `bot-logs-${timestamp}.txt`);
1254
+ fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
1255
+
1256
+ const fileCount = recentFiles.length;
1257
+ const topSources = Array.from(new Set(recentFiles.map(item => item.sourceDir))).slice(0, 3);
1258
+ // 紧凑摘要:N 个日志文件,共 X 行(如有截断则注明)
1259
+ let summaryText = `${fileCount} 个日志文件,共 ${totalIncluded} 行`;
1260
+ if (truncatedCount > 0) {
1261
+ summaryText += `(${truncatedCount} 个文件因过长仅保留最后 ${MAX_LINES_PER_FILE} 行,原始共 ${totalOriginal} 行)`;
1262
+ }
1263
+ return {
1264
+ text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`,
1265
+ filePath: tmpFile,
1266
+ };
1267
+ },
1268
+ });
1269
+
1270
+ // ============ 匹配入口 ============
1271
+
1272
+ /**
1273
+ * 尝试匹配并执行插件级斜杠指令
1274
+ *
1275
+ * @returns 回复文本(匹配成功),null(不匹配,应入队正常处理)
1276
+ */
1277
+ export async function matchSlashCommand(ctx: SlashCommandContext): Promise<SlashCommandResult> {
1278
+ const content = ctx.rawContent.trim();
1279
+ if (!content.startsWith("/")) return null;
1280
+
1281
+ // 解析指令名和参数
1282
+ const spaceIdx = content.indexOf(" ");
1283
+ const cmdName = (spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx)).toLowerCase();
1284
+ const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
1285
+
1286
+ const cmd = commands.get(cmdName);
1287
+ if (!cmd) return null; // 不是插件级指令,交给框架
1288
+
1289
+ // /指令 ? — 返回用法说明
1290
+ if (args === "?") {
1291
+ if (cmd.usage) {
1292
+ return `📖 /${cmd.name} 用法:\n\n${cmd.usage}`;
1293
+ }
1294
+ return `/${cmd.name} — ${cmd.description}`;
1295
+ }
1296
+
1297
+ ctx.args = args;
1298
+ const result = await cmd.handler(ctx);
1299
+ return result;
1300
+ }
1301
+
1302
+ /** 获取插件版本号(供外部使用) */
1303
+ export function getPluginVersion(): string {
1304
+ return PLUGIN_VERSION;
1305
+ }