@zxh126/pi-qqbot 2.4.0 → 2.6.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/CHANGELOG.md +81 -2
- package/LICENSE +21 -21
- package/README.md +25 -8
- package/approver.ts +2 -2
- package/billing.ts +579 -579
- package/constants.ts +2 -0
- package/docs/README.zh-CN.md +22 -8
- package/docs/accounts.md +88 -88
- package/index.ts +148 -23
- package/lock.ts +237 -237
- package/logger.ts +58 -58
- package/package.json +2 -2
- package/progress.ts +176 -176
- package/qq/api.ts +17 -0
- package/qq/auth.ts +35 -35
- package/qq/gateway.ts +20 -3
- package/qq/keyboard.ts +141 -0
- package/queue.ts +120 -120
- package/redact.ts +136 -136
- package/selfupdate.ts +212 -212
- package/slash.ts +63 -2
- package/stream-buffer.ts +22 -22
- package/util/chunk.ts +107 -42
package/selfupdate.ts
CHANGED
|
@@ -1,212 +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
|
-
}
|
|
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
|
+
}
|
package/slash.ts
CHANGED
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
* 3. **built-ins we cannot reach** (`HOST_ONLY`) — reply "run it on the host".
|
|
18
18
|
* 4. **anything else** — treated as ordinary chat text (the caller forwards it).
|
|
19
19
|
*
|
|
20
|
+
* Messages starting with `!` / `!!` are not slash commands but they are handled here too,
|
|
21
|
+
* for the same reason: pi only turns them into shell commands in the **TUI / RPC front end**
|
|
22
|
+
* (`interactive-mode` checks `text.startsWith("!")`, RPC has a `bash` control command), and the
|
|
23
|
+
* extension API exposes no equivalent (`pi.sendUserMessage()` does not dispatch them,
|
|
24
|
+
* `pi.exec()` bypasses approval and session history). Forwarding `!ls` to the model would just
|
|
25
|
+
* confuse it, so the bridge answers with an explicit "not supported in QQ" receipt.
|
|
26
|
+
*
|
|
20
27
|
* Selection-style commands (`/tree`, `/fork`, `/resume`) answer with a numbered list
|
|
21
28
|
* and accept the number as the next message; the pending list lives in `BridgeStore`.
|
|
22
29
|
*
|
|
@@ -27,6 +34,7 @@ import fs from "node:fs";
|
|
|
27
34
|
import path from "node:path";
|
|
28
35
|
|
|
29
36
|
import { PI_AGENT_DIR } from "./constants";
|
|
37
|
+
import { buildPickerKeyboard, type InlineKeyboard } from "./qq/keyboard";
|
|
30
38
|
|
|
31
39
|
export interface SlashCommandText {
|
|
32
40
|
/** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
|
|
@@ -45,6 +53,39 @@ export function parseSlash(text: string): SlashCommandText | null {
|
|
|
45
53
|
return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
export interface BangCommand {
|
|
57
|
+
/** `!` (run + show output to the model) or `!!` (run, keep output out of the context) */
|
|
58
|
+
prefix: "!" | "!!";
|
|
59
|
+
/** whatever followed the prefix */
|
|
60
|
+
command: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `!` / `!!`,全角 `!` / `!!` 也认(中文输入法很容易打出全角) */
|
|
64
|
+
const BANG = /^([!!]{1,2})\s*([\s\S]*\S)\s*$/;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 识别 pi 的 shell 快捷前缀。只有「前缀 + 非空命令」才算,单独一个 `!`、`!!!`、`你好!` 都退回普通文本,
|
|
68
|
+
* 免得把情绪化感叹号当成命令吞掉。
|
|
69
|
+
*/
|
|
70
|
+
export function parseBangCommand(text: string): BangCommand | null {
|
|
71
|
+
const match = BANG.exec(text.trim());
|
|
72
|
+
if (!match) return null;
|
|
73
|
+
const rest = match[2]!.trim();
|
|
74
|
+
// `!!!ls` 这类:第三个别当成命令内容,整体退回普通文本
|
|
75
|
+
if (/^[!!]/.test(rest)) return null;
|
|
76
|
+
return { prefix: match[1]!.replace(/!/g, "!") as "!" | "!!", command: rest };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `!` / `!!` 的 QQ 侧回执:说清为什么不能执行、以及该怎么做 */
|
|
80
|
+
export function bangNotice(cmd: BangCommand): string {
|
|
81
|
+
return [
|
|
82
|
+
`QQ 侧不支持 ${cmd.prefix} 命令,这一条没有执行。`,
|
|
83
|
+
`${cmd.prefix} 是主机 pi 里的 shell 快捷方式${cmd.prefix === "!!" ? "(`!!` 还会把输出排除出模型上下文)" : ""},` +
|
|
84
|
+
"只有 TUI / RPC 前端认识;扩展 API 没有等价能力,桥接不会假装执行一遍(也不会把这条丢给模型去猜)。",
|
|
85
|
+
"想在本机跑命令:直接用中文说需求(例如「列一下当前目录里的仓库」),我会调 bash 工具执行,执行前在 QQ 里向你申请审批。",
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
export type BridgeSub =
|
|
49
90
|
| "reload"
|
|
50
91
|
| "new"
|
|
@@ -92,6 +133,18 @@ update: { sub: "update", needsIdle: true, hint: "在线更新 pi-qqbot 并重载
|
|
|
92
133
|
sendfile: { sub: "sendfile", needsArgs: true, hint: "把本机文件发送到你的 QQ" },
|
|
93
134
|
};
|
|
94
135
|
|
|
136
|
+
/**
|
|
137
|
+
* `/qq <sub>` 里会**换掉运行时**的动作。
|
|
138
|
+
*
|
|
139
|
+
* pi 在它们返回后立刻把旧 `ExtensionCommandContext` 判为 stale:每个 guard getter /
|
|
140
|
+
* 方法都会抛 `This extension ctx is stale after session replacement or reload`。
|
|
141
|
+
* 所以调用方在这几个动作之后不能再碰 ctx(读 `ctx.model` 也算)。
|
|
142
|
+
*
|
|
143
|
+
* `/qq pick` 选 tree / fork / resume 时同样会换运行时,但运行时才知道选的是哪一类,
|
|
144
|
+
* 调用方对它靠 `syncModelFromCtx()` 内部的 try/catch 兜底。
|
|
145
|
+
*/
|
|
146
|
+
export const REPLACES_RUNTIME: ReadonlySet<string> = new Set(["reload", "update", "new", "quit", "clone"]);
|
|
147
|
+
|
|
95
148
|
/** pi built-ins that the extension API cannot reach at all. */
|
|
96
149
|
export const HOST_ONLY: ReadonlySet<string> = new Set([
|
|
97
150
|
"settings",
|
|
@@ -184,6 +237,14 @@ const allowed = (cfg: CommandConfig, name: string): boolean => {
|
|
|
184
237
|
|
|
185
238
|
/** Route one slash message. Returns "text" when it should be treated as chat. */
|
|
186
239
|
export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteResult> {
|
|
240
|
+
// 0. `!` / `!!`:主机前端的 shell 快捷方式,扩展 API 够不到 → 明确回执,别当聊天丢给模型
|
|
241
|
+
const bang = parseBangCommand(text);
|
|
242
|
+
if (bang) {
|
|
243
|
+
deps.log("[cmd] 收到不支持的 shell 前缀:", bang.prefix, bang.command.slice(0, 60));
|
|
244
|
+
await deps.reply(bangNotice(bang));
|
|
245
|
+
return "handled";
|
|
246
|
+
}
|
|
247
|
+
|
|
187
248
|
const parsed = parseSlash(text);
|
|
188
249
|
if (!parsed) return "text";
|
|
189
250
|
const { name } = parsed;
|
|
@@ -604,7 +665,7 @@ export async function runBridge(
|
|
|
604
665
|
arg: string,
|
|
605
666
|
ctx: any,
|
|
606
667
|
pi: any,
|
|
607
|
-
reply: (text: string) => void,
|
|
668
|
+
reply: (text: string, keyboard?: InlineKeyboard) => void,
|
|
608
669
|
store?: BridgeStore,
|
|
609
670
|
): Promise<void> {
|
|
610
671
|
const level = String(sub ?? "").toLowerCase();
|
|
@@ -658,7 +719,7 @@ export async function runBridge(
|
|
|
658
719
|
return;
|
|
659
720
|
}
|
|
660
721
|
const picker: PickerState = { kind, items, openid: store?.pickerOwner ?? null, createdAt: Date.now() };
|
|
661
|
-
|
|
722
|
+
+
|
|
662
723
|
reply(formatPicker(picker));
|
|
663
724
|
return;
|
|
664
725
|
}
|
package/stream-buffer.ts
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
/** 单会话回复文本累积:message_update 的 text_delta 累加,agent_settled 时取走 */
|
|
2
|
-
export class ReplyBuffer {
|
|
3
|
-
private text = "";
|
|
4
|
-
|
|
5
|
-
append(delta: string) {
|
|
6
|
-
if (typeof delta === "string") this.text += delta;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
peek(): string {
|
|
10
|
-
return this.text;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
take(): string {
|
|
14
|
-
const t = this.text;
|
|
15
|
-
this.text = "";
|
|
16
|
-
return t;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
reset() {
|
|
20
|
-
this.text = "";
|
|
21
|
-
}
|
|
22
|
-
}
|
|
1
|
+
/** 单会话回复文本累积:message_update 的 text_delta 累加,agent_settled 时取走 */
|
|
2
|
+
export class ReplyBuffer {
|
|
3
|
+
private text = "";
|
|
4
|
+
|
|
5
|
+
append(delta: string) {
|
|
6
|
+
if (typeof delta === "string") this.text += delta;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
peek(): string {
|
|
10
|
+
return this.text;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
take(): string {
|
|
14
|
+
const t = this.text;
|
|
15
|
+
this.text = "";
|
|
16
|
+
return t;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
reset() {
|
|
20
|
+
this.text = "";
|
|
21
|
+
}
|
|
22
|
+
}
|