cortico-world-image 0.2.0 → 0.3.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.
package/tools/patch.mjs CHANGED
@@ -1,25 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * 「对话页发图」补丁的工具:自测 / 落盘 / 验证 / 重启。
3
+ * QQ 发图路径补丁的工具:自测 / 落盘 / 验证 / 重启。
4
4
  *
5
- * node tools/patch.mjs 在副本上自测(幂等、语法、坏原文必须报失败、还原)
5
+ * node tools/patch.mjs 在副本上自测(幂等、备份、还原、坏原文必须报失败、旧桌宠补丁能还原)
6
6
  * node tools/patch.mjs --apply 打到真实安装上(默认自动找 resources/app)
7
- * node tools/patch.mjs --restart 请控制台重启,让桌宠那一侧的补丁装载
7
+ * node tools/patch.mjs --restart 请控制台重启,让 QQ 扩展那侧的补丁装载
8
8
  * node tools/patch.mjs --backups 看每处补丁与它的 .coopanion-orig 备份
9
9
  * node tools/patch.mjs --restore 按备份还原(撤销补丁;可逆,之后能再打)
10
10
  *
11
+ * 0.3.0 起只改第三方 QQ 扩展的 `src/world.ts`(两处),桌宠包一个字节都不动:
12
+ * 贴图改用官方的 `pet_say.images`。早先版本打进桌宠包的旧补丁,会在每次启动时按备份还原。
13
+ *
11
14
  * 补丁会在每次启动时自动检查/重打(程序自动更新覆写后自愈);
12
15
  * `--restore` 是手动撤销,`--apply` 是手动重打,面板上也有对应按钮。
13
16
  *
14
17
  * `--app <目录>` 指定 `resources/app`,默认从本扩展所在的那份安装往上找;
15
18
  * `--port <端口>` 指定控制台端口,默认读 `<安装>/data/home/companion/config.json` 的 web.port。
16
19
  */
17
- import { spawnSync } from 'node:child_process';
18
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
21
  import { tmpdir } from 'node:os';
20
22
  import { basename, join, resolve } from 'node:path';
21
23
 
22
- import { applyChatImagePatches, consolePort, patchBackupStatus, requestConsoleRestart, resolveAppRoot, restoreChatImagePatches, rulesFor } from '../src/chat-image-patch.js';
24
+ import { applyChatImagePatches, consolePort, patchBackupStatus, patchSpecs, requestConsoleRestart, resolveAppRoot, restoreChatImagePatches, rulesFor } from '../src/chat-image-patch.js';
23
25
 
24
26
  const argv = process.argv.slice(2);
25
27
  const flag = (name) => argv.includes(`--${name}`);
@@ -35,7 +37,7 @@ const wantBackups = flag('backups') || flag('backup');
35
37
  const wantSelfTest = !wantApply && !wantRestart && !wantRestore && !wantBackups;
36
38
 
37
39
  const PACKAGE_DIR = resolve(import.meta.dirname, '..');
38
- const here = (...p) => join(PACKAGE_DIR, ...p);
40
+ const QQ_PKG = 'cortico-world-qq-better-alont1';
39
41
 
40
42
  let pass = 0;
41
43
  const failures = [];
@@ -60,93 +62,20 @@ function pristine(src) {
60
62
  return existsSync(orig) ? orig : src;
61
63
  }
62
64
 
63
- /** esbuild 只做语法解析(不解析依赖、不打包);loader 按扩展名自动认。 */
64
- let syntaxSeq = 0;
65
- function syntaxCheck(appRoot, file, loader) {
66
- const bin = join(appRoot, 'node_modules', '@esbuild', 'win32-x64', 'esbuild.exe');
67
- if (!existsSync(bin)) return { skipped: true };
68
- syntaxSeq += 1;
69
- const out = join(tmpdir(), `coopanion-esbuild-${process.pid}-${syntaxSeq}-${basename(file)}.js`);
70
- const res = spawnSync(bin, [file, '--format=esm', `--outfile=${out}`, '--log-level=warning'], { stdio: ['ignore', 'inherit', 'inherit'] });
71
- try {
72
- rmSync(out, { force: true });
73
- } catch { /* 忽略 */ }
74
- return { code: res.status, signal: res.signal, error: res.error ? String(res.error) : '' };
75
- }
76
-
77
- /**
78
- * 真跑一遍打过补丁的 `tools()` 那句 `return …;`。
79
- *
80
- * 为什么必须真跑:那句 return 的原文是 `.map(…)`(本身就是数组),
81
- * 曾经被包成 `[EXPR, TOOL]` → 嵌套数组,框架读出来的 6 个老工具名全变 null。
82
- * 这里配桩用 node 真执行那句表达式,断言结果是**扁平**数组、每项都有名字、含 pet_show_image。
83
- */
84
- function toolsReturnHarness(worldFile) {
85
- const text = readFileSync(worldFile, 'utf8');
86
- const at = /tools\(\): ToolDef\[\] \{/.exec(text);
87
- if (!at) return { ok: false, reason: '没找到 tools() 方法' };
88
- const seg = text.slice(at.index, at.index + 3000);
89
- const rm = /^([ \t]*)return ([^\n]*);$/m.exec(seg);
90
- if (!rm) return { ok: false, reason: '没找到 tools() 里的 return' };
91
- // 那句 return 会引用方法体里前面声明的局部量(桌宠 0.1.23 起是 `const own = petToolDecls(…)`)。
92
- // 桩里把它们都声明成「两个假工具」的数组:真跑时若还有没桩到的名字,报错才说明补丁真的坏了。
93
- const locals = [...seg.slice(0, rm.index).matchAll(/^[ \t]*const ([A-Za-z_$][\w$]*)\s*=/gm)]
94
- .map((m) => m[1])
95
- .filter((name, i, all) => all.indexOf(name) === i);
96
- const dir = mkdtempSync(join(tmpdir(), 'coopanion-tools-return-'));
97
- const file = join(dir, 'run.mjs');
98
- const report = join(dir, 'out.json');
99
- writeFileSync(
100
- file,
101
- [
102
- "import { writeFileSync } from 'node:fs';",
103
- "const DESKTOP_PET_TOOL_DECLS = [{ name: 'pet_say' }, { name: 'pet_ask' }];",
104
- "const petToolDecls = () => [{ name: 'pet_say' }, { name: 'pet_ask' }];",
105
- 'const handlers = { pet_say: () => {}, pet_ask: () => {}, pet_walk_to: () => {} };',
106
- "const CHAT_IMAGE_TOOL = { name: 'pet_show_image' };",
107
- ...locals.map((name) => `const ${name} = petToolDecls('zh');`),
108
- "const world = { appLanguage: 'zh', cuaOn: false, cua: { tools: () => [] }, showImage: () => ({ text: 'ok' }) };",
109
- `const out = (function () { return ${rm[2]}; }).call(world);`,
110
- 'writeFileSync(process.argv[2], JSON.stringify({',
111
- ' isArray: Array.isArray(out),',
112
- " names: Array.isArray(out) ? out.map((d) => (d && typeof d.name === 'string' ? d.name : null)) : String(out).slice(0, 80),",
113
- '}));',
114
- ].join('\n'),
115
- );
116
- // 用文件而不是管道传结果:沙箱里子进程开管道会 EPERM。
117
- const res = spawnSync(process.execPath, [file, report], { stdio: ['ignore', 'inherit', 'inherit'] });
118
- let out = null;
119
- try {
120
- out = JSON.parse(readFileSync(report, 'utf8'));
121
- } catch { /* 下面统一报错 */ }
122
- rmSync(dir, { recursive: true, force: true });
123
- if (!out) return { ok: false, reason: `桩没跑出结果(exit ${res.status})` };
124
- if (!out.isArray) return { ok: false, reason: `tools() 返回的不是数组:${out.names}` };
125
- const nameless = out.names.filter((n) => !n);
126
- if (nameless.length) return { ok: false, reason: `有 ${nameless.length} 项没名字(工具被嵌套包了一层?):${JSON.stringify(out.names)}` };
127
- if (!out.names.includes('pet_show_image')) return { ok: false, reason: `少了 pet_show_image:${JSON.stringify(out.names)}` };
128
- return { ok: out.names.length >= 3, names: out.names, reason: out.names.length >= 3 ? '' : `工具太少:${JSON.stringify(out.names)}` };
129
- }
130
-
131
- /**
132
- * 副本自测里那棵「假 QQ 扩展」:真安装上是 2400 多行 TS,这里只留补丁要认的那几行
133
- * (类声明、两处相对路径解析、三处工具说明)。自测不进网络、不碰真安装。
134
- */
135
- const QQ_FIXTURE_TS = [
65
+ /** QQ 扩展 `src/world.ts` 里我们认得的那些行(真安装 0.1.17 的写法)。 */
66
+ const QQ_FIXTURE = [
136
67
  "import { existsSync } from 'node:fs';",
137
68
  "import { isAbsolute, join } from 'node:path';",
138
- 'export const qqViewImage = {',
139
- " name: 'qq_view_image',",
140
- " parameters: [{ name: 'url', description: '可选:直接给图片链接(事件里带的图)或本地文件路径;给了就不再翻缓冲。' }],",
141
- '};',
142
- 'export const qqSendImage = {',
143
- " name: 'qq_send_image',",
144
- " description: '给某个会话发一张图。`image` 给本地文件路径或 http(s) 链接(本机下载后再发),二者挑一个。',",
145
- " parameters: [{ name: 'image', description: '本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。' }],",
146
- '};',
69
+ '',
70
+ 'const DECLS = [',
71
+ " { name: 'qq_send_image', description: '给某个会话发一张图。`image` 给本地文件路径或 http(s) 链接(本机下载后再发),二者挑一个。',",
72
+ " parameters: [{ name: 'image', description: '本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。' }] },",
73
+ " { name: 'qq_view_image', parameters: [{ name: 'url', description: '可选:直接给图片链接(事件里带的图)或本地文件路径;给了就不再翻缓冲。' }] },",
74
+ '];',
75
+ '',
147
76
  'export class QQWorld implements World {',
148
77
  " private packageDir = '.';",
149
- ' private config = { memoryDir: "ws" };',
78
+ " private config = { memoryDir: 'ws' };",
150
79
  ' view(url: string) {',
151
80
  " const p = isAbsolute(url) ? url : join(this.packageDir || '.', url);",
152
81
  ' return p;',
@@ -159,309 +88,158 @@ const QQ_FIXTURE_TS = [
159
88
  '',
160
89
  ].join('\n');
161
90
 
162
- /** 把假 QQ 扩展摆进 `<fakeext>/node_modules/<包>/src/world.ts`(配合 COOPANION_EXTENSIONS_DIR)。 */
163
- function writeFakeQQ(fakeext, text = QQ_FIXTURE_TS) {
164
- const dir = join(fakeext, 'node_modules', 'cortico-world-qq-better-alont1', 'src');
165
- mkdirSync(dir, { recursive: true });
166
- const file = join(dir, 'world.ts');
167
- writeFileSync(file, text, 'utf8');
168
- return file;
169
- }
170
-
171
- /**
172
- * 真跑一遍补丁插进 QQ 扩展的那支 `resolveLocalImagePath`:相对路径先落桌宠工作区,
173
- * 工作区没有才退到扩展目录(没有工作区时退回老行为)。用真 fs + 临时目录,不碰安装。
174
- */
175
- function qqResolveHarness(patchedText) {
176
- const m = /\n(\/\*\* coopanion-chat-image[\s\S]*?\n\})/.exec(patchedText);
177
- if (!m) return { ok: false, reason: '抽不出 resolveLocalImagePath' };
178
- // 抽出来的是 TS,node 直接跑要先去掉类型标注(补丁里只有这一处 `: string`)。
179
- const fnJs = m[1].replace(/:\s*string/g, '');
180
- const dir = mkdtempSync(join(tmpdir(), 'coopanion-qq-resolve-'));
181
- const ws = join(dir, 'ws');
182
- const ext = join(dir, 'ext');
183
- mkdirSync(join(ws, 'blobs'), { recursive: true });
184
- mkdirSync(join(ext, 'blobs'), { recursive: true });
185
- writeFileSync(join(ws, 'blobs', 'only-ws.jpg'), 'a');
186
- writeFileSync(join(ext, 'blobs', 'only-ext.jpg'), 'b');
187
- const file = join(dir, 'run.mjs');
188
- const report = join(dir, 'out.json');
189
- writeFileSync(
190
- file,
191
- [
192
- "import { existsSync, writeFileSync } from 'node:fs';",
193
- "import { isAbsolute, join } from 'node:path';",
194
- m[1] && fnJs,
195
- 'writeFileSync(process.argv[2], JSON.stringify({',
196
- " abs: resolveLocalImagePath('C:/somewhere/pic.png', process.argv[3], process.argv[4]),",
197
- " onlyWs: resolveLocalImagePath('blobs/only-ws.jpg', process.argv[3], process.argv[4]),",
198
- " onlyExt: resolveLocalImagePath('blobs/only-ext.jpg', process.argv[3], process.argv[4]),",
199
- " none: resolveLocalImagePath('blobs/nope.jpg', process.argv[3], process.argv[4]),",
200
- " noWs: resolveLocalImagePath('blobs/nope.jpg', '', process.argv[4]),",
201
- '}));',
202
- ].join('\n'),
203
- );
204
- const res = spawnSync(process.execPath, [file, report, ws, ext], { stdio: ['ignore', 'inherit', 'inherit'] });
205
- let out = null;
206
- try {
207
- out = JSON.parse(readFileSync(report, 'utf8'));
208
- } catch { /* 下面统一报错 */ }
209
- if (!out) return { ok: false, reason: `没跑出结果(exit ${res.status})` };
210
- const done = { ok: true, ws, ext, ...out };
211
- rmSync(dir, { recursive: true, force: true });
212
- return done;
91
+ /** 临时搭一份「像 resources/app」的目录:框架标记 + 桌宠包 + 假 QQ 扩展。 */
92
+ function makeFakeApp(root, { qqText = QQ_FIXTURE } = {}) {
93
+ const app = join(root, 'resources', 'app');
94
+ mkdirSync(join(app, 'build', 'cortico', 'src'), { recursive: true });
95
+ writeFileSync(join(app, 'build', 'cortico', 'src', 'paths.ts'), 'export {};\n');
96
+ const petDir = join(app, 'node_modules', 'cortico-world-desktop-pet', 'src');
97
+ mkdirSync(petDir, { recursive: true });
98
+ writeFileSync(join(petDir, 'world.ts'), 'export class DesktopPetWorld {}\n');
99
+ const qqDir = join(app, 'data', 'extensions', 'node_modules', QQ_PKG, 'src');
100
+ mkdirSync(qqDir, { recursive: true });
101
+ const qq = join(qqDir, 'world.ts');
102
+ if (qqText !== null) writeFileSync(qq, qqText);
103
+ return { app, petDir, qq };
213
104
  }
214
105
 
215
- /** 把打过补丁的 `showImage()` 抽出来、真跑:相对路径要落到桌宠工作区上。 */
216
- function showImageHarness(appRoot, worldText) {
217
- const bin = join(appRoot, 'node_modules', '@esbuild', 'win32-x64', 'esbuild.exe');
218
- if (!existsSync(bin)) return { skipped: `找不到 esbuild:${bin}` };
219
- const head = ' private async showImage(args: Record<string, unknown>): Promise<ToolOutcome> {';
220
- const at = worldText.indexOf(head);
221
- if (at < 0) return { ok: false, reason: '抽不到 showImage()(pet-show-image-tool 没打上?)' };
222
- const stop = worldText.indexOf('\n }\n', at);
223
- if (stop < 0) return { ok: false, reason: 'showImage() 的结尾找不到' };
224
- const method = worldText.slice(at, stop + 4);
225
- const sniffHead = 'function sniffImageMime(bytes: Buffer): string | null {';
226
- const sniffAt = worldText.indexOf(sniffHead);
227
- if (sniffAt < 0) return { ok: false, reason: '抽不到 sniffImageMime()' };
228
- const sniffStop = worldText.indexOf('\n}\n', sniffAt);
229
- if (sniffStop < 0) return { ok: false, reason: 'sniffImageMime() 的结尾找不到' };
230
- const sniff = worldText.slice(sniffAt, sniffStop + 2);
231
-
232
- const dir = mkdtempSync(join(tmpdir(), 'coopanion-show-image-'));
233
- const ws = join(dir, 'ws');
234
- const ext = join(dir, 'ext');
235
- mkdirSync(join(ext, 'blobs'), { recursive: true });
236
- const script = join(dir, 'run.mts');
237
- const report = join(dir, 'report.json');
238
- writeFileSync(
239
- script,
240
- `import { mkdirSync, writeFileSync } from 'node:fs';
241
- import { basename, extname, isAbsolute, join } from 'node:path';
242
- import { readFileSync } from 'node:fs';
243
-
244
- type ToolOutcome = { text: string; failed?: boolean };
245
- const IMAGE_MIME_BY_EXT: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
246
- const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
247
-
248
- ${sniff}
249
-
250
- const WS = ${JSON.stringify(ws)};
251
- const EXT = ${JSON.stringify(ext)};
252
-
253
- class Pet {
254
- host: unknown = null;
255
- chat = { size: 0 };
256
- opts: { workspaceDir?: () => string } = {};
257
- constructor(opts: { workspaceDir?: () => string }) { this.opts = opts; }
258
- private async record(..._args: unknown[]): Promise<number | null> { return 1; }
259
- ${method}
106
+ /** 让副本里也能有宿主配置(`worlds.<id>.enabled`)——补丁只为开着的世界打。 */
107
+ function writeWorldSwitch(app, worlds) {
108
+ const dir = join(app, '..', '..', 'data', 'home', 'companion');
109
+ mkdirSync(dir, { recursive: true });
110
+ writeFileSync(join(dir, 'config.json'), `${JSON.stringify({ web: { port: 17788 }, worlds }, null, 2)}\n`);
260
111
  }
261
112
 
262
- const out: Record<string, string> = { ext: EXT };
263
- mkdirSync(join(WS, 'blobs'), { recursive: true });
264
- writeFileSync(join(WS, 'blobs', 'ok.png'), Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(16)]));
265
- const withWs = () => new Pet({ workspaceDir: () => WS });
266
- out.relMissing = (await withWs().showImage({ path: 'blobs/nope.jpg' })).text;
267
- out.relFound = (await withWs().showImage({ path: 'blobs/ok.png' })).text;
268
- out.abs = (await withWs().showImage({ path: join(WS, 'blobs', 'ok.png') })).text;
269
- out.noWs = (await new Pet({}).showImage({ path: 'blobs/ok.png' })).text;
270
- writeFileSync(${JSON.stringify(report)}, JSON.stringify(out, null, 2));
271
- `,
272
- );
273
- // 用 esbuild 编成 JS 再让 node 跑:tsx 起 esbuild 服务要 spawn 管道,沙箱里会 EPERM。
274
- const compiled = join(dir, 'run.mjs');
275
- const built = spawnSync(bin, [script, '--format=esm', `--outfile=${compiled}`, '--log-level=error'], { stdio: ['ignore', 'inherit', 'inherit'] });
276
- if (built.status !== 0) return { ok: false, reason: `esbuild 编不过抽出来的 showImage()(exit ${built.status})` };
277
- const res = spawnSync(process.execPath, [compiled], { stdio: ['ignore', 'inherit', 'inherit'] });
278
- let out = null;
113
+ function selfTest() {
114
+ const work = mkdtempSync(join(tmpdir(), 'image-patch-'));
279
115
  try {
280
- out = JSON.parse(readFileSync(report, 'utf8'));
281
- } catch { /* 下面统一报错 */ }
282
- if (!out) return { ok: false, reason: `node 没跑出结果(exit ${res.status})` };
283
- const done = { ok: true, ws, ext, ...out };
284
- rmSync(dir, { recursive: true, force: true });
285
- return done;
286
- }
287
-
288
- /** 副本上自测:结构照抄一份真安装,只把要改的两个文件(world.ts + definition.ts)复制过去。 */
289
- function selfTest(appRoot) {
290
- head(`自测(副本,源目录 ${appRoot})`);
291
- const work = mkdtempSync(join(tmpdir(), 'coopanion-chat-image-'));
292
- const petSrc = join(appRoot, 'node_modules', 'cortico-world-desktop-pet', 'src');
293
- const petDst = join(work, 'node_modules', 'cortico-world-desktop-pet', 'src');
294
- mkdirSync(petDst, { recursive: true });
295
- mkdirSync(join(work, 'build', 'cortico', 'src'), { recursive: true });
296
- writeFileSync(join(work, 'build', 'cortico', 'src', 'paths.ts'), 'export function repoRoot(): string { return "stub"; }\n');
297
- copyFileSync(pristine(join(petSrc, 'world.ts')), join(petDst, 'world.ts'));
298
- copyFileSync(pristine(join(petSrc, 'definition.ts')), join(petDst, 'definition.ts'));
299
-
300
- // 可选补丁落在第三方 QQ 扩展上(真安装在 `<安装>/data/extensions/…`):
301
- // 这里用 COOPANION_EXTENSIONS_DIR 指到副本里的一棵假扩展,不碰真安装。
302
- const fakeext = join(work, 'fakeext');
303
- const qqFile = writeFakeQQ(fakeext);
304
- process.env.COOPANION_EXTENSIONS_DIR = fakeext;
305
-
306
- const stateDir = join(work, 'state');
307
- const first = applyChatImagePatches(work, { stateDir });
308
- ok(first.appRoot === resolve(work), '认出这份 app', String(first.appRoot));
309
- ok(first.results.length === 8, '一共 8 处补丁(含 2 处可选的 QQ 扩展)', `实际 ${first.results.length}`);
310
- ok(first.failed.length === 0, '全部改写成功', first.results.filter((r) => r.state !== 'patched').map((r) => `${r.id}:${r.state}:${r.error ?? ''}`).join(' / '));
311
- ok(first.patched.length === 8 && first.restartPending === true, '第一轮:8 处新打,并请求重启', JSON.stringify(first.patched));
312
- ok(existsSync(`${qqFile}.coopanion-orig`), 'QQ 扩展也留了原文备份', qqFile);
313
- const qqCheck = syntaxCheck(appRoot, qqFile);
314
- ok(qqCheck.skipped || qqCheck.code === 0, 'QQ 扩展改完能过 TS 语法', JSON.stringify(qqCheck));
315
- const definition = readFileSync(join(petDst, 'definition.ts'), 'utf8');
316
- ok(definition.includes("workspaceDir: () => join(ctx.botDir, 'workspace'),"), 'definition.ts:把桌宠工作区传给世界(装配层那行)', definition.split('\n').filter((l) => l.includes('workspaceDir')).join(' | '));
317
- ok(existsSync(join(petDst, 'definition.ts.coopanion-orig')), 'definition.ts 也留了原文备份');
318
-
319
- const second = applyChatImagePatches(work, { stateDir });
320
- ok(second.patched.length === 0 && second.restartPending === false, '第二轮:幂等,无事可做');
321
- ok(second.results.every((r) => r.state === 'done'), '第二轮:全部认成「已打过」', second.results.map((r) => `${r.id}:${r.state}`).join(' '));
322
-
323
- // 认不出原文(第三方扩展改了写法)时必须报失败、不能瞎改。
324
- const stranger = rulesFor('qq-image-relpaths').apply("export const nope = 1;\n");
325
- ok(Boolean(stranger?.error), 'QQ 扩展换了写法就报失败,不瞎改', JSON.stringify(stranger));
326
- for (const id of ['pet-workspace-option', 'pet-workspace-relpaths', 'pet-workspace-wire']) {
327
- const junk = rulesFor(id).apply("export const nope = 1;\n");
328
- ok(Boolean(junk?.error), `${id} 换了写法就报失败,不瞎改`, JSON.stringify(junk));
329
- }
330
-
331
- // 按备份还原 → 再打一次:可选补丁也要能来回。
332
- const backQQ = restoreChatImagePatches(work, { stateDir });
333
- ok(backQQ.restored.some((id) => id.startsWith('qq-')) && readFileSync(qqFile, 'utf8') === QQ_FIXTURE_TS, 'QQ 扩展能按备份还原回原文', JSON.stringify(backQQ.restored));
334
- const reQQ = applyChatImagePatches(work, { stateDir });
335
- ok(reQQ.patched.includes('qq-image-relpaths') && reQQ.failed.length === 0, '还原之后还能重新打上', JSON.stringify(reQQ.patched));
336
-
337
- const qqRun = qqResolveHarness(readFileSync(qqFile, 'utf8'));
338
- ok(qqRun.ok, '把插入的助手抽出来真跑一遍', qqRun.reason);
339
- if (qqRun.ok) {
340
- ok(qqRun.abs === 'C:/somewhere/pic.png', '绝对路径原样返回', qqRun.abs);
341
- ok(qqRun.onlyWs === join(qqRun.ws, 'blobs', 'only-ws.jpg'), '相对路径落在桌宠工作区', qqRun.onlyWs);
342
- ok(qqRun.onlyExt === join(qqRun.ext, 'blobs', 'only-ext.jpg'), '工作区没有、扩展目录才有 → 退到扩展目录(老行为)', qqRun.onlyExt);
343
- ok(qqRun.none === join(qqRun.ws, 'blobs', 'nope.jpg'), '两边都没有 → 按工作区拼(报错里给出工作区路径)', qqRun.none);
344
- ok(qqRun.noWs === join(qqRun.ext, 'blobs', 'nope.jpg'), '装配层没给工作区时退回老行为', qqRun.noWs);
345
- }
346
- delete process.env.COOPANION_EXTENSIONS_DIR;
347
-
348
- // 自愈的第二幕:自动更新把文件覆写回去 → 再补一遍;已经请求过一轮重启还是缺,就记「卡住」不再请求(防重启死循环)。
349
- const work2 = mkdtempSync(join(tmpdir(), 'coopanion-chat-image-stuck-'));
350
- const pet2 = join(work2, 'node_modules', 'cortico-world-desktop-pet', 'src');
351
- mkdirSync(pet2, { recursive: true });
352
- mkdirSync(join(work2, 'build', 'cortico', 'src'), { recursive: true });
353
- writeFileSync(join(work2, 'build', 'cortico', 'src', 'paths.ts'), 'export function repoRoot(): string { return "stub"; }\n');
354
- copyFileSync(pristine(join(petSrc, 'world.ts')), join(pet2, 'world.ts'));
355
- copyFileSync(pristine(join(petSrc, 'definition.ts')), join(pet2, 'definition.ts'));
356
- const state2 = join(work2, 'state');
357
- const again1 = applyChatImagePatches(work2, { stateDir: state2 });
358
- ok(again1.patched.length === 6 && again1.restartPending === true && again1.stuck === false, '自愈第一幕:打上 6 处并请求一次重启', JSON.stringify({ patched: again1.patched.length, pending: again1.restartPending, stuck: again1.stuck }));
359
- copyFileSync(pristine(join(petSrc, 'world.ts')), join(pet2, 'world.ts'));
360
- const again2 = applyChatImagePatches(work2, { stateDir: state2 });
361
- ok(again2.patched.includes('pet-workspace-option') && again2.failed.length === 0, '自愈第二幕:被覆写后照样补回来', JSON.stringify(again2.patched));
362
- ok(again2.restartPending === false && again2.stuck === true, '自愈第二幕:请求过重启仍缺 → 记卡住、不再重启', JSON.stringify({ pending: again2.restartPending, stuck: again2.stuck }));
363
- const saved2 = JSON.parse(readFileSync(join(state2, '.chat-image-patch.json'), 'utf8'));
364
- ok(saved2.requested === true && saved2.appRoot === resolve(work2) && Array.isArray(saved2.patched) && saved2.patched.includes('pet-workspace-option'), '状态文件记下「请求过重启 + 这次补了哪些」', JSON.stringify(saved2));
365
- rmSync(work2, { recursive: true, force: true });
366
-
367
- // 还原:按 `.coopanion-orig` 把文件覆盖回去(备份留着),随后再自动补一遍照样能补上。
368
- const work3 = mkdtempSync(join(tmpdir(), 'coopanion-chat-image-restore-'));
369
- const pet3 = join(work3, 'node_modules', 'cortico-world-desktop-pet', 'src');
370
- mkdirSync(pet3, { recursive: true });
371
- mkdirSync(join(work3, 'build', 'cortico', 'src'), { recursive: true });
372
- writeFileSync(join(work3, 'build', 'cortico', 'src', 'paths.ts'), 'export function repoRoot(): string { return "stub"; }\n');
373
- copyFileSync(pristine(join(petSrc, 'world.ts')), join(pet3, 'world.ts'));
374
- copyFileSync(pristine(join(petSrc, 'definition.ts')), join(pet3, 'definition.ts'));
375
- const state3 = join(work3, 'state');
376
- const before3 = applyChatImagePatches(work3, { stateDir: state3 });
377
- ok(before3.patched.length === 6 && before3.failed.length === 0, '还原前:6 处都打上了', JSON.stringify({ patched: before3.patched.length, failed: before3.failed }));
378
- const bk3 = patchBackupStatus(work3);
379
- ok(bk3.length === 6 && bk3.every((b) => b.hasFile && b.hasBackup && b.patched && !b.same), '备份状态:6 处都有原文备份、都认成「已打过」', JSON.stringify(bk3.map((b) => `${b.id}:${b.hasBackup}/${b.patched}/${b.same}`)));
380
- const back3 = restoreChatImagePatches(work3, { stateDir: state3 });
381
- // 6 处补丁落在 2 个文件上:每个文件还原一次,同一文件上后面的补丁就变成「本来就是原文」。
382
- ok(back3.failed.length === 0 && back3.restored.length === 2 && back3.results.filter((r) => r.state !== 'absent').every((r) => r.state === 'restored' || r.state === 'same'), '还原:2 个文件各按备份覆盖一次(同一文件上的其余补丁认成 same)', JSON.stringify({ restored: back3.restored, states: back3.results.map((r) => `${r.id}:${r.state}`) }));
383
- ok(readFileSync(join(pet3, 'world.ts'), 'utf8') === readFileSync(pristine(join(petSrc, 'world.ts')), 'utf8'), '还原后 world.ts 与原文逐字一致');
384
- ok(readFileSync(join(pet3, 'definition.ts'), 'utf8') === readFileSync(pristine(join(petSrc, 'definition.ts')), 'utf8'), '还原后 definition.ts 与原文逐字一致');
385
- const bk3b = patchBackupStatus(work3);
386
- ok(bk3b.every((b) => b.hasBackup && b.same && !b.patched), '还原后:备份还在,状态变成「未打」', JSON.stringify(bk3b.map((b) => `${b.id}:${b.hasBackup}/${b.same}/${b.patched}`)));
387
- const saved3 = JSON.parse(readFileSync(join(state3, '.chat-image-patch.json'), 'utf8'));
388
- ok(Array.isArray(saved3.restored) && saved3.restored.length === 2 && saved3.patched.length === 0 && saved3.requested === false, '状态文件记下「还原了哪些」', JSON.stringify({ restored: saved3.restored, patched: saved3.patched, requested: saved3.requested }));
389
- const back3b = restoreChatImagePatches(work3, { stateDir: state3 });
390
- ok(back3b.restored.length === 0 && back3b.results.filter((r) => r.state !== 'absent').every((r) => r.state === 'same'), '再还原一次:认成「本来就是原文」,不重复覆盖');
391
- const after3 = applyChatImagePatches(work3, { stateDir: state3 });
392
- ok(after3.patched.length === 6 && after3.failed.length === 0, '还原之后还能重新打上(还原是可逆的)', JSON.stringify({ patched: after3.patched.length, failed: after3.failed }));
393
- rmSync(work3, { recursive: true, force: true });
394
-
395
- const world = readFileSync(join(petDst, 'world.ts'), 'utf8');
396
- ok(world.includes('pet_show_image') && world.includes('sniffImageMime') && world.includes("tags: ['act']"), 'world.ts:pet_show_image 工具与实现');
397
- ok(world.includes('workspaceDir?: () => string;'), 'world.ts:世界选项多了 workspaceDir(装配层注入)');
398
- ok(world.includes(" let raw = typeof args.path === 'string' ? args.path.trim() : '';"), 'world.ts:路径那行改成 let(不然相对路径拼不上)');
399
- ok(world.includes('raw = join(ws, raw);') && world.includes('this.opts.workspaceDir?.()'), 'world.ts:相对路径真的拼到桌宠工作区上');
400
- ok(world.includes('blobs/xx.jpg'), 'world.ts:工具说明写明可以给相对工作区的路径');
401
- ok(existsSync(join(petDst, 'world.ts.coopanion-orig')), '改写前留了 .coopanion-orig 备份');
402
- ok(readFileSync(join(petDst, 'world.ts.coopanion-orig'), 'utf8') === readFileSync(pristine(join(petSrc, 'world.ts')), 'utf8'), '备份是改写前的原文');
403
- const worldTools = toolsReturnHarness(join(petDst, 'world.ts'));
404
- ok(worldTools.ok, 'tools() 真跑一遍:扁平数组、每项都有名字、含 pet_show_image', worldTools.reason || JSON.stringify(worldTools.names));
405
-
406
- const showRun = showImageHarness(appRoot, world);
407
- if (showRun.skipped) console.log(` .. ${showRun.skipped}`);
408
- else if (!showRun.ok) ok(false, '把 showImage() 抽出来真跑一遍', showRun.reason);
409
- else {
410
- ok(showRun.relMissing.includes(join(showRun.ws, 'blobs', 'nope.jpg')) && !showRun.relMissing.includes(showRun.ext), '相对路径按桌宠工作区解析(不是扩展目录)', showRun.relMissing);
411
- ok(showRun.relFound.includes('没连着对话记录'), '工作区里真有那张图时读得到(走到「没连着对话记录」)', showRun.relFound);
412
- ok(showRun.abs.includes('没连着对话记录'), '绝对路径照旧能用', showRun.abs);
413
- ok(showRun.noWs.includes('相对路径没有工作区可拼'), '装配层没给工作区时明确报错', showRun.noWs);
414
- }
415
-
416
- const s2 = syntaxCheck(appRoot, join(petDst, 'world.ts'), 'ts');
417
- const s4 = syntaxCheck(appRoot, join(petDst, 'definition.ts'), 'ts');
418
- if (s2.skipped) console.log(' .. esbuild 不在,跳过语法检查');
419
- else {
420
- ok(s2.code === 0, 'world.ts 语法通过(esbuild)', s2.error || String(s2.code));
421
- ok(s4.code === 0, 'definition.ts 语法通过(esbuild)', s4.error || String(s4.code));
116
+ head('补丁清单');
117
+ const specs = patchSpecs(join(work, 'resources', 'app'));
118
+ const ids = specs.map((s) => s.id);
119
+ ok(specs.length === 2, '只有 2 处补丁(桌宠包上不再打补丁)', String(specs.length));
120
+ ok(ids.includes('qq-image-relpaths') && ids.includes('qq-image-hints'), '两处都是 QQ 扩展的', ids.join(','));
121
+ ok(specs.every((s) => s.optional === true), '两处都是可选补丁(没装 QQ 扩展不算失败)');
122
+ ok(specs.every((s) => s.file.includes(QQ_PKG) && s.file.endsWith('world.ts')), '都落在 QQ 扩展的 src/world.ts', JSON.stringify(specs.map((s) => s.file)));
123
+ ok(specs.every((s) => s.worldId === 'qqbot'), '两处都挂在 world「qqbot」上(切到官方 QQ 扩展后自动跳过)', JSON.stringify(specs.map((s) => s.worldId)));
124
+ ok(specs.every((s) => typeof s.what === 'string' && s.what.length > 4), '每处都有说明');
125
+
126
+ head('改写规则(单文件)');
127
+ const rel = rulesFor('qq-image-relpaths');
128
+ ok(typeof rel?.done === 'function' && typeof rel?.apply === 'function', 'qq-image-relpaths 有 done/apply');
129
+ ok(Boolean(rel.apply('export const nope = 1;\n')?.error), '认不出原文时报失败,不瞎改');
130
+ const relOut = rel.apply(QQ_FIXTURE);
131
+ ok(!relOut?.error, '打卡片上的原文能改出来', relOut?.error ?? '');
132
+ ok((relOut?.text ?? '').includes('function resolveLocalImagePath(p: string, memoryDir: string, packageDir: string): string {'), '插进了按工作区解析的助手');
133
+ ok((relOut?.text ?? '').includes('const p = resolveLocalImagePath(image, this.config.memoryDir, this.packageDir);'), '发图那行改成按工作区解析');
134
+ ok((relOut?.text ?? '').includes('const p = resolveLocalImagePath(url, this.config.memoryDir, this.packageDir);'), '看图那行改成按工作区解析');
135
+ ok(rel.done(relOut?.text ?? ''), '改完认成「已打过」(幂等)');
136
+ ok((relOut?.text ?? '').includes("import { existsSync } from 'node:fs';") && (relOut?.text ?? '').includes("import { isAbsolute, join } from 'node:path';"), '本来就够的导入不动一行');
137
+
138
+ const hints = rulesFor('qq-image-hints');
139
+ ok(typeof hints?.done === 'function' && typeof hints?.apply === 'function', 'qq-image-hints 有 done/apply');
140
+ ok(Boolean(hints.apply('export const nope = 1;\n')?.error), '一句都认不出来时报失败');
141
+ const hintOut = hints.apply(QQ_FIXTURE);
142
+ ok(!hintOut?.error, '三处说明都能改出来', hintOut?.error ?? '');
143
+ ok((hintOut?.text ?? '').includes('本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg)或 http(s) 链接。'), '发图参数说明改成「绝对路径或相对工作区」');
144
+ ok((hintOut?.text ?? '').includes('或本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg);给了就不再翻缓冲。'), '看图参数说明同步');
145
+ ok((hintOut?.text ?? '').includes('`image` 给本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg)或 http(s) 链接'), '工具总说明同步');
146
+ ok(hints.done(hintOut?.text ?? ''), '改完认成「已打过」(幂等)');
147
+ // 上游把某一句整句换掉:跳过那一处,不算失败(否则每次启动都记一条失败)。
148
+ const reworded = QQ_FIXTURE.replace("'本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。'", "'图片本地路径或链接。'");
149
+ const partial = hints.apply(reworded);
150
+ ok(!partial?.error && (partial?.text ?? '').includes('给了就不再翻缓冲。'), '上游换掉一句时只跳过那一处,不算失败', partial?.error ?? '');
151
+
152
+ head('落盘 / 备份 / 还原(副本)');
153
+ const a1 = makeFakeApp(join(work, 'run1'));
154
+ const first = applyChatImagePatches(a1.app, { stateDir: join(work, 'state1') });
155
+ ok(first.patched.length === 2, '两处都打上了(文件形式)', JSON.stringify(first.results.map((r) => `${r.id}:${r.state}`)));
156
+ ok(first.failed.length === 0, '没有失败项', first.failed.join(','));
157
+ const onDisk = readFileSync(a1.qq, 'utf8');
158
+ ok(onDisk.includes('resolveLocalImagePath(image, this.config.memoryDir'), '磁盘上那份真的改了');
159
+ ok(existsSync(`${a1.qq}.coopanion-orig`) && readFileSync(`${a1.qq}.coopanion-orig`, 'utf8') === QQ_FIXTURE, '改写前留了原文备份(.coopanion-orig)');
160
+ ok(first.restartPending === true, '第一次改过文件 → 请求重启');
161
+
162
+ const second = applyChatImagePatches(a1.app, { stateDir: join(work, 'state1') });
163
+ ok(second.patched.length === 0 && second.failed.length === 0, '再跑一遍是幂等的(0 改动、0 失败)', JSON.stringify({ patched: second.patched, failed: second.failed }));
164
+ ok(second.results.every((r) => r.state === 'done'), '两处都记 done', JSON.stringify(second.results.map((r) => r.state)));
165
+ ok(second.restartPending === false, '没改东西就不请求重启');
166
+
167
+ const rows = patchBackupStatus(a1.app);
168
+ ok(rows.length === 2 && rows.every((r) => r.patched && r.hasBackup && !r.same), '备份状态:两处都「已打 + 有备份 + 与当前不同」', JSON.stringify(rows.map((r) => `${r.id}:${r.patched}/${r.hasBackup}/${r.same}`)));
169
+
170
+ const back = restoreChatImagePatches(a1.app, { stateDir: join(work, 'state1') });
171
+ ok(back.restored.length === 1 && !back.failed.length, '按备份还原了 1 个文件', JSON.stringify({ restored: back.restored, failed: back.failed }));
172
+ ok(readFileSync(a1.qq, 'utf8') === QQ_FIXTURE, '还原后与原文逐字节一致');
173
+ const reapply = applyChatImagePatches(a1.app, { stateDir: join(work, 'state1') });
174
+ ok(reapply.patched.length === 2, '还原之后还能再打回去(可逆)', JSON.stringify(reapply.patched));
175
+
176
+ head('没装 / 改坏了 QQ 扩展');
177
+ const a2 = makeFakeApp(join(work, 'run2'), { qqText: null });
178
+ const noQq = applyChatImagePatches(a2.app, { stateDir: join(work, 'state2') });
179
+ ok(noQq.failed.length === 0 && noQq.results.every((r) => r.state === 'absent'), '没装 QQ 扩展时两处记 absent、不算失败', JSON.stringify(noQq.results.map((r) => `${r.id}:${r.state}`)));
180
+ ok(!noQq.restartPending, '没装 QQ 扩展时不请求重启');
181
+
182
+ const a3 = makeFakeApp(join(work, 'run3'), { qqText: 'export class QQWorld { /* 上游大改 */ }\n' });
183
+ const broken = applyChatImagePatches(a3.app, { stateDir: join(work, 'state3') });
184
+ ok(broken.failed.length === 2, '认不出原文时两处都记失败', JSON.stringify(broken.results.map((r) => `${r.id}:${r.state}`)));
185
+ ok(readFileSync(a3.qq, 'utf8') === 'export class QQWorld { /* 上游大改 */ }\n', '认不出就不动那个文件');
186
+ ok(existsSync(`${a3.qq}.coopanion-orig`) === false, '没改成也就不会留备份');
187
+
188
+ head('旧桌宠补丁的还原(0.3.0)');
189
+ const a4 = makeFakeApp(join(work, 'run4'));
190
+ const petFile = join(a4.petDir, 'world.ts');
191
+ const petPristine = 'export class DesktopPetWorld {}\n';
192
+ writeFileSync(petFile, `${petPristine}/* coopanion-chat-image: pet_show_image 的旧补丁 */\n`);
193
+ writeFileSync(`${petFile}.coopanion-orig`, petPristine);
194
+ const rev = applyChatImagePatches(a4.app, { stateDir: join(work, 'state4') });
195
+ ok(rev.reverted.some((r) => r.reverted), '桌宠包上的旧补丁被还原', JSON.stringify(rev.reverted));
196
+ ok(readFileSync(petFile, 'utf8') === petPristine, '桌宠包回到上游原样(一个字节都没留)');
197
+ ok(rev.restartPending === true, '还原也算「这次改了东西」→ 请求重启');
198
+ const revAgain = applyChatImagePatches(a4.app, { stateDir: join(work, 'state4') });
199
+ ok(revAgain.reverted.every((r) => !r.reverted), '还原过一次之后不再重复还原');
200
+
201
+ head('世界没开时跳过(切到官方 QQ 扩展)');
202
+ const a5 = makeFakeApp(join(work, 'run5'));
203
+ writeWorldSwitch(a5.app, { qqbot: { enabled: false }, qq: { enabled: true } });
204
+ const off = applyChatImagePatches(a5.app, { stateDir: join(work, 'state5') });
205
+ ok(off.results.length === 2 && off.results.every((r) => r.state === 'absent'), 'qqbot 关掉时两处都记 absent', JSON.stringify(off.results.map((r) => `${r.id}:${r.state}`)));
206
+ ok(off.results.every((r) => /worlds\.qqbot\.enabled=false/.test(r.error ?? '')), 'absent 里写明是「世界没开」', JSON.stringify(off.results.map((r) => r.error)));
207
+ ok(!readFileSync(a5.qq, 'utf8').includes('resolveLocalImagePath'), '文件一个字节都没动');
208
+ ok(!existsSync(`${a5.qq}.coopanion-orig`), '也不会平白留一份备份');
209
+ ok(off.restartPending === false, '不请求重启');
210
+ ok(patchBackupStatus(a5.app).length === 0, '状态页的备份行也不显示它');
211
+
212
+ const a6 = makeFakeApp(join(work, 'run6'));
213
+ writeWorldSwitch(a6.app, { qqbot: { enabled: true } });
214
+ const on = applyChatImagePatches(a6.app, { stateDir: join(work, 'state6') });
215
+ ok(on.patched.length === 2, 'qqbot 开着时照常打上', JSON.stringify(on.results.map((r) => `${r.id}:${r.state}`)));
216
+ ok(patchBackupStatus(a6.app).length === 2, '开着时备份状态两行');
217
+
218
+ head('控制台端口');
219
+ const cfgApp = join(work, 'port');
220
+ mkdirSync(join(cfgApp, 'data', 'home', 'companion'), { recursive: true });
221
+ writeFileSync(join(cfgApp, 'data', 'home', 'companion', 'config.json'), JSON.stringify({ web: { port: 18099 } }));
222
+ ok(consolePort({ appRoot: join(cfgApp, 'resources', 'app') }) === 18099, '端口从 <安装>/data/home/companion/config.json 读出来');
223
+ ok(consolePort({ appRoot: join(cfgApp, 'resources', 'app'), explicit: 12345 }) === 12345, '显式 --port 优先');
224
+ ok(consolePort({ appRoot: join(work, 'nope') }) === 17788, '读不到就回默认 17788');
225
+ } finally {
226
+ rmSync(work, { recursive: true, force: true });
422
227
  }
423
-
424
- // 反向用例:原文被改过时必须报失败,而不是硬猜着改。
425
- const bad = join(work, 'bad');
426
- const badPet = join(bad, 'node_modules', 'cortico-world-desktop-pet', 'src');
427
- mkdirSync(badPet, { recursive: true });
428
- mkdirSync(join(bad, 'build', 'cortico', 'src'), { recursive: true });
429
- writeFileSync(join(bad, 'build', 'cortico', 'src', 'paths.ts'), 'export function repoRoot(): string { return "stub"; }\n');
430
- writeFileSync(join(badPet, 'world.ts'), readFileSync(pristine(join(petSrc, 'world.ts')), 'utf8').replace(' private notConnected(tool: string): ToolOutcome {', ' private notConnectedRenamed(tool: string): ToolOutcome {'));
431
- writeFileSync(join(badPet, 'definition.ts'), readFileSync(pristine(join(petSrc, 'definition.ts')), 'utf8'));
432
- const third = applyChatImagePatches(bad, { stateDir: join(bad, 'state') });
433
- ok(third.failed.includes('pet-show-image-tool'), '原文对不上时记失败:pet-show-image-tool', JSON.stringify(third.failed));
434
- ok(third.results.find((r) => r.id === 'pet-show-image-tool')?.state === 'failed' && third.results.find((r) => r.id === 'pet-show-image-tool')?.error, '失败项状态是 failed 并给出原因', JSON.stringify(third.results.find((r) => r.id === 'pet-show-image-tool')));
435
- ok(third.patched.length === 4, '同一份文件里别的补丁照常打上(world.ts 里不依赖 showImage() 的那几条)', JSON.stringify(third.patched));
436
-
437
- // 上一版写坏的 `[EXPR, TOOL]`(嵌套数组)要能被认出来并修成扁平的。
438
- const work4 = mkdtempSync(join(tmpdir(), 'coopanion-chat-image-nested-'));
439
- const pet4 = join(work4, 'node_modules', 'cortico-world-desktop-pet', 'src');
440
- mkdirSync(pet4, { recursive: true });
441
- mkdirSync(join(work4, 'build', 'cortico', 'src'), { recursive: true });
442
- writeFileSync(join(work4, 'build', 'cortico', 'src', 'paths.ts'), 'export function repoRoot(): string { return "stub"; }\n');
443
- copyFileSync(pristine(join(petSrc, 'definition.ts')), join(pet4, 'definition.ts'));
444
- const nestedWorld = (() => {
445
- const src = readFileSync(pristine(join(petSrc, 'world.ts')), 'utf8');
446
- const at4 = /tools\(\): ToolDef\[\] \{/.exec(src);
447
- const seg4 = src.slice(at4.index, at4.index + 3000);
448
- const rm4 = /^([ \t]*)return ([^\n]*);$/m.exec(seg4);
449
- const line = `${rm4[1]}return [${rm4[2]}, { ...CHAT_IMAGE_TOOL, handler: (args) => this.showImage(args) }];`;
450
- return src.slice(0, at4.index + rm4.index) + line + src.slice(at4.index + rm4.index + rm4[0].length);
451
- })();
452
- writeFileSync(join(pet4, 'world.ts'), nestedWorld);
453
- const fixed4 = applyChatImagePatches(work4, { stateDir: join(work4, 'state') });
454
- ok(fixed4.failed.length === 0 && fixed4.results.find((r) => r.id === 'pet-show-image-tool')?.state === 'patched', '旧的嵌套写法认成「没打」并重写(不是当成已完成)', JSON.stringify({ failed: fixed4.failed, state: fixed4.results.find((r) => r.id === 'pet-show-image-tool')?.state }));
455
- const fixedTools = toolsReturnHarness(join(pet4, 'world.ts'));
456
- ok(fixedTools.ok, '旧嵌套写法被修成扁平数组', fixedTools.reason || JSON.stringify(fixedTools.names));
457
- const again4 = applyChatImagePatches(work4, { stateDir: join(work4, 'state') });
458
- ok(again4.patched.length === 0 && again4.failed.length === 0, '修好之后再跑一遍是幂等的', JSON.stringify({ patched: again4.patched, failed: again4.failed }));
459
- rmSync(work4, { recursive: true, force: true });
460
-
461
- rmSync(work, { recursive: true, force: true });
462
228
  return failures.length === 0;
463
229
  }
464
230
 
231
+ /** 真安装上那两处补丁的现状(不改动任何东西,只读)。 */
232
+ function inspectReal(appRoot) {
233
+ const spec = patchSpecs(appRoot)[0];
234
+ if (!spec?.file || !existsSync(spec.file)) return;
235
+ const text = readFileSync(pristine(spec.file), 'utf8');
236
+ head(`真安装上的 QQ 扩展(${basename(spec.file)})`);
237
+ const rel = rulesFor('qq-image-relpaths');
238
+ const hints = rulesFor('qq-image-hints');
239
+ ok(rel.done(text) || rel.apply(text)?.text, 'qq-image-relpaths 认得这份原文(不认识的只是版本变了,记在状态页)');
240
+ ok(hints.done(text) || hints.apply(text)?.text, 'qq-image-hints 认得这份原文');
241
+ }
242
+
465
243
  /** 控制台端口:优先命令行,其次安装里的 config.json。 */
466
244
  function consolePortOf(appRoot) {
467
245
  return consolePort({ appRoot, botDir: join(PACKAGE_DIR, '..', 'data', 'home', 'companion'), explicit: opt('port') });
@@ -471,9 +249,11 @@ function apply(appRoot) {
471
249
  head(`落盘(${appRoot})`);
472
250
  const res = applyChatImagePatches(appRoot, { stateDir: PACKAGE_DIR, log: { info: (m) => console.log(` .. ${m}`), warn: (m) => console.log(` !! ${m}`) } });
473
251
  for (const r of res.results) console.log(` ${r.state.padEnd(8)} ${r.id} ${basename(r.file ?? '')}${r.error ? ` — ${r.error}` : ''}`);
252
+ for (const r of res.reverted ?? []) if (r.reverted) console.log(` 还原 ${basename(r.file)}(旧桌宠补丁)`);
474
253
  ok(res.failed.length === 0, '没有失败项', res.failed.join(', '));
475
- if (res.patched.length) console.log(` 改了 ${res.patched.length} 处;桌宠侧的改动要重启进程才装载${res.restartPending ? '' : '(这次不自动重启)'}`);
476
- else console.log(' 没有要改的:补丁都在。');
254
+ if (res.patched.length || res.reverted.some((r) => r.reverted)) {
255
+ console.log(` 改了 ${res.patched.length} 处;QQ 扩展那侧的改动要重启进程才装载${res.restartPending ? '' : '(这次不自动重启)'}`);
256
+ } else console.log(' 没有要改的:补丁都在。');
477
257
  if (res.stuck) console.log(' !! 上一轮已经为同一处请求过重启,这次仍然缺——先别重启,查文件是不是只读。');
478
258
  return res;
479
259
  }
@@ -482,18 +262,14 @@ function apply(appRoot) {
482
262
  function backups(appRoot) {
483
263
  head(`备份状态(${appRoot})`);
484
264
  const rows = patchBackupStatus(appRoot);
485
- if (!rows.length) return ok(false, '认不出这份安装里的补丁目标文件');
265
+ if (!rows.length) return ok(false, '认不出这份安装里的补丁目标文件(QQ 扩展可能没装)');
486
266
  for (const r of rows) {
487
267
  const state = r.patched ? '已打' : '未打';
488
268
  const b = r.hasBackup ? `备份 ${r.backupBytes}B${r.same ? '(=当前文件)' : `,覆盖于 ${r.backupAt}`}` : '没有备份';
489
269
  console.log(` ${state} ${r.id.padEnd(20)} ${basename(r.file ?? '')} ${r.fileBytes}B ${b}${r.hasFile ? '' : ' ← 文件不在'}`);
490
270
  }
491
- const missing = rows.filter((r) => !r.hasFile);
492
- const noBackup = rows.filter((r) => r.patched && !r.hasBackup);
493
- ok(missing.length === 0, '所有目标文件都在', missing.map((r) => r.id).join(', '));
494
- ok(noBackup.length === 0, '打过补丁的每一处都留着更新下发的原文备份', noBackup.map((r) => r.id).join(', '));
495
- const neverPatched = rows.filter((r) => !r.patched && !r.hasBackup);
496
- if (neverPatched.length) console.log(` .. 还没打过、因此没有备份(正常):${neverPatched.map((r) => r.id).join(', ')}`);
271
+ ok(rows.filter((r) => !r.hasFile).length === 0, '所有目标文件都在', rows.filter((r) => !r.hasFile).map((r) => r.id).join(', '));
272
+ ok(rows.filter((r) => r.patched && !r.hasBackup).length === 0, '打过补丁的每一处都留着更新下发的原文备份');
497
273
  console.log(' 备份就在文件旁边:<文件>.coopanion-orig;还原只是拿它覆盖回去,备份不会删。');
498
274
  return rows;
499
275
  }
@@ -507,19 +283,23 @@ function restore(appRoot) {
507
283
  });
508
284
  for (const r of res.results) console.log(` ${r.state.padEnd(8)} ${r.id} ${basename(r.file ?? '')}${r.error ? ` — ${r.error}` : ''}`);
509
285
  ok(res.failed.length === 0, '没有还原失败的文件', res.failed.join(', '));
510
- if (res.restored.length) console.log(` 还原了 ${res.restored.length} 个文件;重启控制台后桌宠就回到「没有发图功能」的原样。`);
286
+ if (res.restored.length) console.log(` 还原了 ${res.restored.length} 个文件;重启控制台后 QQ 发图回到「只认扩展目录的相对路径」。`);
511
287
  else console.log(' 没有要还原的:文件本身就是更新下发的原文。');
512
288
  return res;
513
289
  }
514
290
 
515
291
  const appRoot = findAppRoot();
516
- if (!appRoot) {
292
+ if (!appRoot && !wantSelfTest) {
517
293
  console.error('找不到 Coopanion 的 resources/app;用 --app <目录> 指定。');
518
294
  process.exit(2);
519
295
  }
520
- console.log(`安装:${appRoot}`);
296
+ if (appRoot) console.log(`安装:${appRoot}`);
521
297
 
522
- if (wantSelfTest) selfTest(appRoot);
298
+ if (wantSelfTest) {
299
+ selfTest();
300
+ if (appRoot) inspectReal(appRoot);
301
+ else console.log('\n(没找到装在附近的 Coopanion,跳过真安装上的只读检查)');
302
+ }
523
303
  if (wantBackups) backups(appRoot);
524
304
  if (wantRestore) restore(appRoot);
525
305
  if (wantApply) apply(appRoot);
@@ -534,5 +314,5 @@ if (wantRestart) {
534
314
  }
535
315
  }
536
316
 
537
- console.log(`\n通过 ${pass} 项,失败 ${failures.length} 项${failures.length ? `:${failures.join(';')}` : ''}`);
317
+ console.log(`\n结果:${pass} 项通过,${failures.length} 项失败`);
538
318
  process.exit(failures.length ? 1 : 0);