@zxh126/pi-qqbot 2.4.1 → 2.6.2
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.en.md +611 -0
- package/{CHANGELOG.md → CHANGELOG.zh-CN.md} +81 -0
- package/LICENSE +21 -21
- package/README.md +22 -9
- package/approver.ts +2 -2
- package/billing.ts +579 -579
- package/constants.ts +2 -0
- package/docs/README.zh-CN.md +19 -9
- package/docs/accounts.md +88 -88
- package/index.ts +131 -20
- package/lock.ts +237 -237
- package/logger.ts +58 -58
- package/package.json +4 -3
- 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 +67 -5
- 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
|
@@ -34,6 +34,7 @@ import fs from "node:fs";
|
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
|
|
36
36
|
import { PI_AGENT_DIR } from "./constants";
|
|
37
|
+
import { buildPickerKeyboard, type InlineKeyboard } from "./qq/keyboard";
|
|
37
38
|
|
|
38
39
|
export interface SlashCommandText {
|
|
39
40
|
/** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
|
|
@@ -132,6 +133,33 @@ update: { sub: "update", needsIdle: true, hint: "在线更新 pi-qqbot 并重载
|
|
|
132
133
|
sendfile: { sub: "sendfile", needsArgs: true, hint: "把本机文件发送到你的 QQ" },
|
|
133
134
|
};
|
|
134
135
|
|
|
136
|
+
/**
|
|
137
|
+
* `/qq <sub> [args]` → 对应的 TAKEOVER 条目。
|
|
138
|
+
*
|
|
139
|
+
* `/qq` 自己也是**扩展命令**,所以直接发 `/qq reload` 会先命中 passthrough 分支:既不查
|
|
140
|
+
* `commands.allow*`,也不查 `needsIdle`(等于绕过 `/reload`、`/quit` 的门禁),回执还会
|
|
141
|
+
* 显示成「已派发 /qq 给 pi。」把参数吞掉。路由前先用这张表把子命令翻回内置命令名,
|
|
142
|
+
* 复用同一套判定。多词 sub(`list tree` / `list fork` / `list resume`)先整体匹配,再退回首词。
|
|
143
|
+
*/
|
|
144
|
+
export function bridgeGate(args: string): { name: string; takeover: Takeover } | undefined {
|
|
145
|
+
const lower = args.trim().toLowerCase();
|
|
146
|
+
if (!lower) return undefined;
|
|
147
|
+
const entries = Object.entries(TAKEOVER) as [string, Takeover][];
|
|
148
|
+
const exact = entries.find(([, item]) => item.sub.toLowerCase() === lower);
|
|
149
|
+
if (exact) return { name: exact[0], takeover: exact[1] };
|
|
150
|
+
const first = lower.split(/\s+/)[0]!;
|
|
151
|
+
const byFirst = entries.find(([, item]) => item.sub.split(/\s+/)[0]!.toLowerCase() === first);
|
|
152
|
+
return byFirst ? { name: byFirst[0], takeover: byFirst[1] } : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 命令被禁用时该提示改哪个配置项 */
|
|
156
|
+
export function gateConfigKey(name: string): string {
|
|
157
|
+
if (name === "reload") return "allowReload";
|
|
158
|
+
if (name === "quit") return "allowQuit";
|
|
159
|
+
if (name === "update") return "allowUpdate";
|
|
160
|
+
return "allowSession";
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
/**
|
|
136
164
|
* `/qq <sub>` 里会**换掉运行时**的动作。
|
|
137
165
|
*
|
|
@@ -234,6 +262,11 @@ const allowed = (cfg: CommandConfig, name: string): boolean => {
|
|
|
234
262
|
return cfg.allowSession;
|
|
235
263
|
};
|
|
236
264
|
|
|
265
|
+
/** 回执/日志里展示命令原文:太长就截断,别把 QQ 消息撑爆,也别只显示首段把参数吞掉 */
|
|
266
|
+
function previewCommand(raw: string, max = 80): string {
|
|
267
|
+
return raw.length > max ? `${raw.slice(0, max)}…` : raw;
|
|
268
|
+
}
|
|
269
|
+
|
|
237
270
|
/** Route one slash message. Returns "text" when it should be treated as chat. */
|
|
238
271
|
export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteResult> {
|
|
239
272
|
// 0. `!` / `!!`:主机前端的 shell 快捷方式,扩展 API 够不到 → 明确回执,别当聊天丢给模型
|
|
@@ -248,6 +281,36 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
|
|
|
248
281
|
if (!parsed) return "text";
|
|
249
282
|
const { name } = parsed;
|
|
250
283
|
|
|
284
|
+
// 0.5 `/qq …`:桥接命令自身是扩展命令,落到下面的 passthrough 会**绕开** `commands.allow*`
|
|
285
|
+
// 与 `needsIdle`(默认配置下 QQ 侧发 `/qq quit` 就能关掉主机),且通用回执只显示 `/qq`。
|
|
286
|
+
// 这里提前分流:按内置命令的门禁判定,自毁类**先回执再派发**(动作会换掉运行时)。
|
|
287
|
+
if (name === "qq") {
|
|
288
|
+
const shown = previewCommand(parsed.raw);
|
|
289
|
+
const gate = bridgeGate(parsed.args);
|
|
290
|
+
if (!gate) {
|
|
291
|
+
deps.log("[cmd] 派发 /qq 桥接命令:", shown);
|
|
292
|
+
deps.dispatch(parsed.raw);
|
|
293
|
+
return "dispatched";
|
|
294
|
+
}
|
|
295
|
+
const builtin = gate.name;
|
|
296
|
+
const takeover = gate.takeover;
|
|
297
|
+
if (!allowed(deps.cfg, builtin)) {
|
|
298
|
+
deps.log("[cmd] 拒绝 /qq 子命令:", shown, "配置 commands." + gateConfigKey(builtin));
|
|
299
|
+
await deps.reply(`/${builtin} 在 QQ 侧被禁用(配置 commands.${gateConfigKey(builtin)})。`);
|
|
300
|
+
return "handled";
|
|
301
|
+
}
|
|
302
|
+
if (takeover.needsIdle && deps.busy) {
|
|
303
|
+
await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${builtin}。`);
|
|
304
|
+
return "handled";
|
|
305
|
+
}
|
|
306
|
+
deps.log("[cmd] 派发 /qq 桥接命令:", shown, "gate=", builtin);
|
|
307
|
+
if (takeover.selfDestroying) {
|
|
308
|
+
await deps.reply(`已请求 /${builtin}(${takeover.hint})…`);
|
|
309
|
+
}
|
|
310
|
+
deps.dispatch(parsed.raw);
|
|
311
|
+
return "dispatched";
|
|
312
|
+
}
|
|
313
|
+
|
|
251
314
|
// 0. 输出只走 TUI 通道的扩展命令:桥接或明确回执(必须早于 passthrough)
|
|
252
315
|
const uiOnly = UI_ONLY[name];
|
|
253
316
|
if (uiOnly && deps.listPiCommands().some((c) => c.name.toLowerCase() === name)) {
|
|
@@ -274,7 +337,7 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
|
|
|
274
337
|
} else if (result === "rejected") {
|
|
275
338
|
await deps.reply(`队列已满,/${name} 没有排上。发 /queue 查看队列,或 /cancel 清空后重发。`);
|
|
276
339
|
} else {
|
|
277
|
-
await deps.reply(`已派发
|
|
340
|
+
await deps.reply(`已派发 ${previewCommand(parsed.raw)} 给 pi。`);
|
|
278
341
|
}
|
|
279
342
|
return "dispatched";
|
|
280
343
|
}
|
|
@@ -284,8 +347,7 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
|
|
|
284
347
|
const takeover = TAKEOVER[name];
|
|
285
348
|
if (takeover) {
|
|
286
349
|
if (!allowed(deps.cfg, name)) {
|
|
287
|
-
|
|
288
|
-
await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gate})。`);
|
|
350
|
+
await deps.reply(`/${name} 在 QQ 侧被禁用(配置 commands.${gateConfigKey(name)})。`);
|
|
289
351
|
return "handled";
|
|
290
352
|
}
|
|
291
353
|
if (takeover.needsIdle && deps.busy) {
|
|
@@ -664,7 +726,7 @@ export async function runBridge(
|
|
|
664
726
|
arg: string,
|
|
665
727
|
ctx: any,
|
|
666
728
|
pi: any,
|
|
667
|
-
reply: (text: string) => void,
|
|
729
|
+
reply: (text: string, keyboard?: InlineKeyboard) => void,
|
|
668
730
|
store?: BridgeStore,
|
|
669
731
|
): Promise<void> {
|
|
670
732
|
const level = String(sub ?? "").toLowerCase();
|
|
@@ -718,7 +780,7 @@ export async function runBridge(
|
|
|
718
780
|
return;
|
|
719
781
|
}
|
|
720
782
|
const picker: PickerState = { kind, items, openid: store?.pickerOwner ?? null, createdAt: Date.now() };
|
|
721
|
-
|
|
783
|
+
|
|
722
784
|
reply(formatPicker(picker));
|
|
723
785
|
return;
|
|
724
786
|
}
|
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
|
+
}
|