@zxh126/pi-qqbot 2.3.0 → 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/remote.ts CHANGED
@@ -1,81 +1,81 @@
1
- import type { QQBotConfig } from "./config";
2
-
3
- /**
4
- * 「以下对话来自远程用户」提醒。
5
- *
6
- * pi 在锁屏/无人值守的机器上替 QQ 侧的远程用户干活:对方看不见屏幕,也没法点弹窗。
7
- * 所以在每个会话第一次真正把 QQ 消息交给 pi 跑起来时,把一段说明写进系统提示的
8
- * `qq-remote-context` section(排在用户消息之前、QQ 侧不可见),让它从一开始就按
9
- * 「远程 + 可能锁屏」的前提工作(默认离屏渲染,而不是截桌面)。
10
- *
11
- * 注入时机由 index.ts 的 before_agent_start 控制:只有本扩展派发的 QQ 消息真的开跑
12
- * 才注入;仅载入扩展、或本机 TUI 直接对话都不会触发。上下文压缩后由 session_compact 复位。
13
- */
14
-
15
- export interface RemoteConfig {
16
- /** 每会话第一条消息注入一次提醒 */
17
- notice: boolean;
18
- /** 每次来自 QQ 的 run 在系统提示里追加几条规则 */
19
- guidelines: boolean;
20
- /** 追加在默认提醒末尾的自定义补充(留空 = 只用默认文案) */
21
- extra: string;
22
- }
23
-
24
- export const DEFAULT_REMOTE: RemoteConfig = {
25
- notice: true,
26
- guidelines: true,
27
- extra: "",
28
- };
29
-
30
- /** 说明放在系统提示的哪个 section 里(排查会话文件时靠这个 tag 认出来) */
31
- export const REMOTE_NOTICE_SECTION = "qq-remote-context";
32
-
33
- /** 「远程前提作废」更正用的 section tag */
34
- export const LOCAL_HANDOFF_SECTION = "qq-local-handoff";
35
-
36
- /** 配置里的 `remote` 可能缺字段(老配置、测试里手搓的 cfg),这里补齐默认值 */
37
- export function resolveRemoteConfig(cfg: Partial<Pick<QQBotConfig, "remote">> | null | undefined): RemoteConfig {
38
- const raw = cfg?.remote ?? {};
39
- return {
40
- notice: raw.notice ?? DEFAULT_REMOTE.notice,
41
- guidelines: raw.guidelines ?? DEFAULT_REMOTE.guidelines,
42
- extra: String(raw.extra ?? DEFAULT_REMOTE.extra).trim(),
43
- };
44
- }
45
-
46
- /** 首条消息前的说明:说清来源、锁屏风险、离屏渲染要求 */
47
- export function buildRemoteNotice(remote: RemoteConfig): string {
48
- const lines = [
49
- "[pi-qqbot 远程上下文 · 说明,不必回复本条]",
50
- "",
51
- "接下来的对话来自「远程 QQ 私聊用户」,不是本机终端用户。前提:",
52
- "",
53
- "1. 对方看不到本机屏幕、也收不到弹窗;回复应简明扼要。",
54
- "2. 本机可能锁屏或无人值守。默认不要用桌面截屏、屏幕 OCR、UI 自动化、键鼠模拟、带界面的浏览器" +
55
- "(例如 mcp__screenshot、mcp__winui 这类工具):锁屏下它们只会拍到锁屏画面或直接失败。",
56
- "3. 需要图/界面效果时,按项目类型自行选择离屏渲染方案(headless 浏览器渲 HTML、SVG、matplotlib/Pillow 等)," +
57
- "生成 PNG 后用 qq_send_file 发到 QQ。不要为了“确认”先截桌面。",
58
- "4. 只有确实需要判断是否锁屏时,才允许截一次桌面看有没有内容;其余一律以离屏渲染为准。",
59
- "5. 需要人工介入(登录、验证码、实体操作)时直接说明并给替代方案;" +
60
- "危险工具会先发到 QQ 等审批,对方可能不在电脑旁。",
61
- ];
62
- if (remote.extra) lines.push("", remote.extra);
63
- return lines.join("\n");
64
- }
65
-
66
- /** 本机接管后的更正:只在「上一轮来自 QQ」的第一次本机对话注入(只生效一轮) */
67
- export function buildLocalHandoffNotice(): string {
68
- return [
69
- "现在是本机用户在终端直接对话(同一会话内接管),之前「远程 QQ 用户」的前提作废:",
70
- "可以正常截屏 / 看屏幕,回复详略按本机需求来,不需要用 qq_send_file 交付。",
71
- ].join("\n");
72
- }
73
-
74
- /** 系统提示里追加的规则(比首条提醒更短,每次 QQ run 都带上) */
75
- export function buildRemoteGuidelines(): string[] {
76
- return [
77
- "对话来自远程 QQ 用户:对方看不到本机屏幕,回复应简明扼要。",
78
- "本机可能锁屏:默认禁用桌面截屏/屏幕 OCR/UI 自动化/键鼠模拟/带界面浏览器;要图就离屏渲染,再用 qq_send_file 发送。",
79
- "需要本机人工操作时直接说明,不要等待或反复重试。",
80
- ];
81
- }
1
+ import type { QQBotConfig } from "./config";
2
+
3
+ /**
4
+ * 「以下对话来自远程用户」提醒。
5
+ *
6
+ * pi 在锁屏/无人值守的机器上替 QQ 侧的远程用户干活:对方看不见屏幕,也没法点弹窗。
7
+ * 所以在每个会话第一次真正把 QQ 消息交给 pi 跑起来时,把一段说明写进系统提示的
8
+ * `qq-remote-context` section(排在用户消息之前、QQ 侧不可见),让它从一开始就按
9
+ * 「远程 + 可能锁屏」的前提工作(默认离屏渲染,而不是截桌面)。
10
+ *
11
+ * 注入时机由 index.ts 的 before_agent_start 控制:只有本扩展派发的 QQ 消息真的开跑
12
+ * 才注入;仅载入扩展、或本机 TUI 直接对话都不会触发。上下文压缩后由 session_compact 复位。
13
+ */
14
+
15
+ export interface RemoteConfig {
16
+ /** 每会话第一条消息注入一次提醒 */
17
+ notice: boolean;
18
+ /** 每次来自 QQ 的 run 在系统提示里追加几条规则 */
19
+ guidelines: boolean;
20
+ /** 追加在默认提醒末尾的自定义补充(留空 = 只用默认文案) */
21
+ extra: string;
22
+ }
23
+
24
+ export const DEFAULT_REMOTE: RemoteConfig = {
25
+ notice: true,
26
+ guidelines: true,
27
+ extra: "",
28
+ };
29
+
30
+ /** 说明放在系统提示的哪个 section 里(排查会话文件时靠这个 tag 认出来) */
31
+ export const REMOTE_NOTICE_SECTION = "qq-remote-context";
32
+
33
+ /** 「远程前提作废」更正用的 section tag */
34
+ export const LOCAL_HANDOFF_SECTION = "qq-local-handoff";
35
+
36
+ /** 配置里的 `remote` 可能缺字段(老配置、测试里手搓的 cfg),这里补齐默认值 */
37
+ export function resolveRemoteConfig(cfg: Partial<Pick<QQBotConfig, "remote">> | null | undefined): RemoteConfig {
38
+ const raw = cfg?.remote ?? {};
39
+ return {
40
+ notice: raw.notice ?? DEFAULT_REMOTE.notice,
41
+ guidelines: raw.guidelines ?? DEFAULT_REMOTE.guidelines,
42
+ extra: String(raw.extra ?? DEFAULT_REMOTE.extra).trim(),
43
+ };
44
+ }
45
+
46
+ /** 首条消息前的说明:说清来源、锁屏风险、离屏渲染要求 */
47
+ export function buildRemoteNotice(remote: RemoteConfig): string {
48
+ const lines = [
49
+ "[pi-qqbot 远程上下文 · 说明,不必回复本条]",
50
+ "",
51
+ "接下来的对话来自「远程 QQ 私聊用户」,不是本机终端用户。前提:",
52
+ "",
53
+ "1. 对方看不到本机屏幕、也收不到弹窗;回复应简明扼要。",
54
+ "2. 本机可能锁屏或无人值守。默认不要用桌面截屏、屏幕 OCR、UI 自动化、键鼠模拟、带界面的浏览器" +
55
+ "(例如 mcp__screenshot、mcp__winui 这类工具):锁屏下它们只会拍到锁屏画面或直接失败。",
56
+ "3. 需要图/界面效果时,按项目类型自行选择离屏渲染方案(headless 浏览器渲 HTML、SVG、matplotlib/Pillow 等)," +
57
+ "生成 PNG 后用 qq_send_file 发到 QQ。不要为了“确认”先截桌面。",
58
+ "4. 只有确实需要判断是否锁屏时,才允许截一次桌面看有没有内容;其余一律以离屏渲染为准。",
59
+ "5. 需要人工介入(登录、验证码、实体操作)时直接说明并给替代方案;" +
60
+ "危险工具会先发到 QQ 等审批,对方可能不在电脑旁。",
61
+ ];
62
+ if (remote.extra) lines.push("", remote.extra);
63
+ return lines.join("\n");
64
+ }
65
+
66
+ /** 本机接管后的更正:只在「上一轮来自 QQ」的第一次本机对话注入(只生效一轮) */
67
+ export function buildLocalHandoffNotice(): string {
68
+ return [
69
+ "现在是本机用户在终端直接对话(同一会话内接管),之前「远程 QQ 用户」的前提作废:",
70
+ "可以正常截屏 / 看屏幕,回复详略按本机需求来,不需要用 qq_send_file 交付。",
71
+ ].join("\n");
72
+ }
73
+
74
+ /** 系统提示里追加的规则(比首条提醒更短,每次 QQ run 都带上) */
75
+ export function buildRemoteGuidelines(): string[] {
76
+ return [
77
+ "对话来自远程 QQ 用户:对方看不到本机屏幕,回复应简明扼要。",
78
+ "本机可能锁屏:默认禁用桌面截屏/屏幕 OCR/UI 自动化/键鼠模拟/带界面浏览器;要图就离屏渲染,再用 qq_send_file 发送。",
79
+ "需要本机人工操作时直接说明,不要等待或反复重试。",
80
+ ];
81
+ }
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
+ }