@zxh126/pi-qqbot 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/selfupdate.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * pi-qqbot「自我更新」:QQ 用户发「更新自己」/ `/update`,插件检查 npm 上的最新版,
3
+ * 用 **pi 自己的机制**(`pi update npm:@zxh126/pi-qqbot`)更新磁盘上的安装目录,
4
+ * 校验版本真的变了的之后,再走 `/qq reload` 重载扩展——不重启宿主进程。
5
+ *
6
+ * 为什么是确定性代码而不是丢给模型做:更新流程必须在「模型不可用」时也能跑,
7
+ * 且涉及写安装目录,不能交给模型自由发挥。
8
+ *
9
+ * 纯逻辑(版本比较、输出解析、流程编排)都走注入,单测不联网、不真跑 pi update。
10
+ */
11
+ import { exec } from "node:child_process";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ export const PACKAGE_NAME = "@zxh126/pi-qqbot";
17
+ export const NPM_REGISTRY = "https://registry.npmjs.org";
18
+ export const NPM_CHECK_TIMEOUT_MS = 30_000;
19
+ export const UPDATE_TIMEOUT_MS = 300_000;
20
+
21
+ /** 包根目录(index.ts / selfupdate.ts 都在包根,package.json 是兄弟文件) */
22
+ export function packageRoot(): string {
23
+ return path.dirname(fileURLToPath(import.meta.url));
24
+ }
25
+
26
+ /** 当前安装的版本;读不到返回 null */
27
+ export function installedVersion(dir: string = packageRoot()): string | null {
28
+ try {
29
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
30
+ return typeof pkg.version === "string" ? pkg.version : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** npm 输出里可能混着 warning,取第一个 semver */
37
+ export function parseVersionOutput(text: string): string | null {
38
+ const match = /(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/.exec(String(text ?? ""));
39
+ return match ? match[1] : null;
40
+ }
41
+
42
+ /** a 更新返回正数;相等返回 0;兼容 prerelease(1.0.0-beta < 1.0.0) */
43
+ export function compareVersions(a: string, b: string): number {
44
+ const parse = (version: string) => {
45
+ const [core, pre] = String(version).split("-");
46
+ return {
47
+ nums: core.split(".").map((part) => Number.parseInt(part, 10) || 0),
48
+ pre: pre ?? null,
49
+ };
50
+ };
51
+ const left = parse(a);
52
+ const right = parse(b);
53
+ for (let index = 0; index < 3; index += 1) {
54
+ const diff = (left.nums[index] ?? 0) - (right.nums[index] ?? 0);
55
+ if (diff !== 0) return diff;
56
+ }
57
+ if (left.pre === null && right.pre === null) return 0;
58
+ if (left.pre === null) return 1;
59
+ if (right.pre === null) return -1;
60
+ return left.pre < right.pre ? -1 : left.pre > right.pre ? 1 : 0;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // 命令执行(可注入;默认真跑)
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface CommandResult {
68
+ code: number;
69
+ stdout: string;
70
+ stderr: string;
71
+ }
72
+
73
+ export type CommandRunner = (command: string, timeoutMs: number) => Promise<CommandResult>;
74
+
75
+ /**
76
+ * 默认执行器:child_process.exec(shell)。命令串全部来自本文件的常量,
77
+ * 没有用户输入参与拼接,无注入面。Windows 上 pi/npm 都是 .cmd shim,必须走 shell。
78
+ */
79
+ export const shellRunner: CommandRunner = (command, timeoutMs) =>
80
+ new Promise((resolve) => {
81
+ exec(
82
+ command,
83
+ { timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" },
84
+ (error, stdout, stderr) => {
85
+ const code =
86
+ error && typeof (error as { code?: unknown }).code === "number"
87
+ ? ((error as { code: number }).code)
88
+ : error
89
+ ? 1
90
+ : 0;
91
+ resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
92
+ },
93
+ );
94
+ });
95
+
96
+ export interface VersionCheckResult {
97
+ ok: boolean;
98
+ version?: string;
99
+ error?: string;
100
+ }
101
+
102
+ /** `npm view <pkg> version`:拿 registry 上的最新版本 */
103
+ export async function checkLatestVersion(
104
+ runner: CommandRunner = shellRunner,
105
+ registry: string = NPM_REGISTRY,
106
+ ): Promise<VersionCheckResult> {
107
+ try {
108
+ const result = await runner(`npm view ${PACKAGE_NAME} version --registry=${registry}`, NPM_CHECK_TIMEOUT_MS);
109
+ const version = parseVersionOutput(result.stdout);
110
+ if (result.code !== 0 || !version) {
111
+ return { ok: false, error: `npm view 失败(exit ${result.code})${result.stderr ? `:${result.stderr.trim().slice(0, 160)}` : ""}` };
112
+ }
113
+ return { ok: true, version };
114
+ } catch (error) {
115
+ return { ok: false, error: String((error as Error)?.message ?? error).slice(0, 160) };
116
+ }
117
+ }
118
+
119
+ export interface UpdateResult {
120
+ ok: boolean;
121
+ code?: number;
122
+ output?: string;
123
+ error?: string;
124
+ }
125
+
126
+ /** `pi update npm:<pkg>`:pi 官方的扩展更新命令(就是用户手动发版验收用的那条) */
127
+ export async function applyUpdate(runner: CommandRunner = shellRunner): Promise<UpdateResult> {
128
+ try {
129
+ const result = await runner(`pi update npm:${PACKAGE_NAME}`, UPDATE_TIMEOUT_MS);
130
+ if (result.code !== 0) {
131
+ const detail = `${result.stderr || result.stdout}`.trim().split(/\r?\n/).slice(-3).join(" | ").slice(0, 300);
132
+ return { ok: false, code: result.code, output: detail, error: `pi update 失败(exit ${result.code})` };
133
+ }
134
+ if (/已是最新|already (?:at the )?(?:latest|up.to.date)|Skipping/i.test(result.stdout) && !/Updated|updated/i.test(result.stdout)) {
135
+ return { ok: false, error: "pi update 认为已是最新(磁盘版本号可能和 registry 不一致),未做变更" };
136
+ }
137
+ return { ok: true, output: result.stdout.trim().split(/\r?\n/).slice(-3).join(" | ").slice(0, 300) };
138
+ } catch (error) {
139
+ return { ok: false, error: String((error as Error)?.message ?? error).slice(0, 160) };
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // 流程编排(依赖注入:QQ 回执 / 更新器 / 重载都从外面给)
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export interface SelfUpdateDeps {
148
+ /** 当前安装版本(通常 = installedVersion()) */
149
+ currentVersion: string | null;
150
+ /** 查 registry 最新版 */
151
+ checkLatest(): Promise<VersionCheckResult>;
152
+ /** 执行 pi update */
153
+ applyUpdate(): Promise<UpdateResult>;
154
+ /** 更新后磁盘上的版本(通常 = installedVersion()) */
155
+ versionAfterUpdate(): string | null;
156
+ /** 给 QQ 用户回话 */
157
+ reply(text: string): Promise<unknown>;
158
+ /** 重载扩展(对应 /qq reload) */
159
+ reload(): Promise<void> | void;
160
+ }
161
+
162
+ export type SelfUpdateOutcome =
163
+ | "already-latest"
164
+ | "updated"
165
+ | "check-failed"
166
+ | "update-failed"
167
+ | "version-mismatch";
168
+
169
+ export async function runSelfUpdate(deps: SelfUpdateDeps): Promise<SelfUpdateOutcome> {
170
+ await deps.reply("正在检查 pi-qqbot 更新…");
171
+ const latest = await deps.checkLatest();
172
+ if (!latest.ok || !latest.version) {
173
+ await deps.reply(`❌ 查询最新版本失败:${latest.error ?? "未知错误"}`);
174
+ return "check-failed";
175
+ }
176
+ const current = deps.currentVersion ?? "0.0.0";
177
+ if (compareVersions(latest.version, current) <= 0) {
178
+ await deps.reply(`✅ 已是最新版本 v${current},无需更新。`);
179
+ return "already-latest";
180
+ }
181
+ await deps.reply(`发现新版本 v${current} → v${latest.version},开始更新(pi update,最多等 5 分钟)…`);
182
+ const updated = await deps.applyUpdate();
183
+ if (!updated.ok) {
184
+ await deps.reply(`❌ 更新失败:${updated.error ?? "未知错误"}${updated.output ? `\n${updated.output}` : ""}`);
185
+ return "update-failed";
186
+ }
187
+ const after = deps.versionAfterUpdate();
188
+ if (after !== latest.version) {
189
+ await deps.reply(
190
+ `⚠️ 更新命令执行完,但磁盘版本是 v${after ?? "?"}(预期 v${latest.version})。先不重载,请人工确认安装目录。`,
191
+ );
192
+ return "version-mismatch";
193
+ }
194
+ await deps.reply(`✅ 已更新到 v${latest.version},正在重载扩展(会话保留,模型上下文不受影响)…`);
195
+ await deps.reload();
196
+ return "updated";
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // 自然语言意图
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /** 「更新自己」「升级一下插件」这类口令;单说「更新」不认(太容易撞车) */
204
+ const RE_UPDATE_SELF = /^(?:帮我)?(?:更新|升级|自更|自我更)(?:新)?(?:一下)?(?:自己|本身|qqbot|pi-?qqbot|插件|扩展)?$/i;
205
+
206
+ export function isUpdateSelfIntent(text: string): boolean {
207
+ const value = String(text ?? "").trim();
208
+ if (!RE_UPDATE_SELF.test(value)) return false;
209
+ // 「更新」「升级」单字不带宾语时不认,太容易撞车;但「自更新」「自我更新」本身带了宾语
210
+ if (/^(?:帮我)?(?:更新|升级)(?:一下)?$/.test(value)) return false;
211
+ return true;
212
+ }