@bongos/core 1.21.109 → 1.21.111

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.
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/bug-attachment-recover.js — re-fetch a lost #bugs screenshot from
3
+ // the Discord message it came from, back to its ORIGINAL filename (task 1004667).
4
+ //
5
+ // WHY THIS EXISTS. We used to say a lost re-hosted screenshot "cannot be
6
+ // re-fetched — the Discord CDN URL has expired". Only the URL's signature
7
+ // (`?ex=&is=&hm=`) expires. Discord keeps the attachment for as long as its
8
+ // message exists, and GET /channels/{channel}/messages/{message} with the bot
9
+ // token hands back a freshly-signed URL. Every stored filename already carries
10
+ // the message id — discord-bot.js passes msg.id into `bug-<id>-<rand>.<ext>`,
11
+ // and a numeric snowflake survives the slug filter unchanged — so every "lost"
12
+ // file is still addressable. That false claim is what steered task 1003980 to
13
+ // "accept the loss" without anyone trying this.
14
+ //
15
+ // The file is written under the name the task description ALREADY links. A
16
+ // fresh random name would store the bytes and leave the task's image broken.
17
+ // The download itself goes through persistImageFromUrl — same host allowlist,
18
+ // redirect re-validation, magic-byte sniff and size cap — with only the target
19
+ // name supplied; there is no second download path here.
20
+ //
21
+ // Usage (on the box that holds the store and the bot token):
22
+ // node scripts/gds/bug-attachment-recover.js --missing # every file the audit finds missing
23
+ // node scripts/gds/bug-attachment-recover.js <message-id>... # the missing files from those messages
24
+ // node scripts/gds/bug-attachment-recover.js <filename>... # exactly these names (no task lookup)
25
+ // flags: --channel <id> (default DISCORD_BUGS_CHANNEL_ID, then bugs_channel_id in discord-bot.json)
26
+ // --dry-run (say what would be fetched; write nothing)
27
+ //
28
+ // Matching. A message's k-th described file (in the order the task description
29
+ // links them, which is the order the bot stored them) is fetched from that
30
+ // message's k-th image attachment. Never overwrites a file that is present.
31
+ // Exit 0 when everything asked for is recovered (or nothing was missing), 1 when
32
+ // anything could not be, 2 when it could not even start (no token, no channel).
33
+
34
+ 'use strict';
35
+
36
+ const fs = require('node:fs');
37
+
38
+ const DISCORD_API = 'https://discord.com/api/v10';
39
+ // A Discord snowflake: digits only. Checked before either id goes into a URL.
40
+ const SNOWFLAKE_RE = /^\d{15,25}$/;
41
+ const NAME_RE = /^bug-([a-z0-9]{1,40})-[0-9a-f]{16}\.(?:png|jpg|gif|webp)$/;
42
+
43
+ // PURE — the message id a stored filename carries, or null when the slug is not
44
+ // a snowflake (a name stored with the 'x' fallback, or a hand-made one).
45
+ function messageIdFromName(name) {
46
+ const m = NAME_RE.exec(String(name || ''));
47
+ return m && SNOWFLAKE_RE.test(m[1]) ? m[1] : null;
48
+ }
49
+
50
+ // PURE — group filenames by the message they came from, keeping order within a
51
+ // message (that order is what pairs a name with an attachment). Names with no
52
+ // recoverable message id come back in `unaddressable`.
53
+ function planRecovery(names) {
54
+ const byMessage = new Map();
55
+ const unaddressable = [];
56
+ const seen = new Set();
57
+ for (const name of names || []) {
58
+ if (seen.has(name)) continue;
59
+ seen.add(name);
60
+ const id = messageIdFromName(name);
61
+ if (!id) { unaddressable.push(name); continue; }
62
+ if (!byMessage.has(id)) byMessage.set(id, []);
63
+ byMessage.get(id).push(name);
64
+ }
65
+ return { byMessage, unaddressable };
66
+ }
67
+
68
+ // PURE — the image attachments of a Discord REST message, in order. The same
69
+ // test the bot applied when it first stored them (discord-bot.js
70
+ // extractImageUrls): an image content type, or a width (Discord sets one only
71
+ // on images). REST returns snake_case `content_type`.
72
+ function imageAttachmentUrls(message) {
73
+ const out = [];
74
+ for (const a of (message && message.attachments) || []) {
75
+ const type = a.content_type || a.contentType || '';
76
+ if ((type.startsWith('image/') || a.width != null) && a.url) out.push(a.url);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // Fetch one message and re-host its images under the given names. Returns one
82
+ // { name, ok, reason? } per name; never throws for an expected refusal.
83
+ async function recoverMessage({ channelId, messageId, names, token, fetchImpl = globalThis.fetch, dir, persist }) {
84
+ if (!SNOWFLAKE_RE.test(String(channelId)) || !SNOWFLAKE_RE.test(String(messageId))) {
85
+ return names.map((name) => ({ name, ok: false, reason: 'bad_id' }));
86
+ }
87
+ let resp;
88
+ try {
89
+ resp = await fetchImpl(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, {
90
+ headers: { Authorization: `Bot ${token}` },
91
+ });
92
+ } catch (e) {
93
+ return names.map((name) => ({ name, ok: false, reason: 'fetch_failed', error: e.message }));
94
+ }
95
+ if (!resp || !resp.ok) {
96
+ // 404 is the case that ends recovery: the message (or the channel) is gone.
97
+ const reason = resp && resp.status === 404 ? 'message_gone' : `discord_status_${resp && resp.status}`;
98
+ return names.map((name) => ({ name, ok: false, reason }));
99
+ }
100
+ const urls = imageAttachmentUrls(await resp.json());
101
+ const results = [];
102
+ for (let i = 0; i < names.length; i++) {
103
+ const name = names[i];
104
+ if (!urls[i]) { results.push({ name, ok: false, reason: 'no_attachment_at_index', index: i, images: urls.length }); continue; }
105
+ const r = await persist(urls[i], { file: name, fetchImpl, dir });
106
+ results.push(r.ok ? { name, ok: true, bytes: r.bytes } : { name, ok: false, reason: r.reason });
107
+ }
108
+ return results;
109
+ }
110
+
111
+ function argValue(argv, flag) {
112
+ const i = argv.indexOf(flag);
113
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
114
+ }
115
+
116
+ function loadChannelId(argv) {
117
+ const flag = argValue(argv, '--channel');
118
+ if (flag) return flag;
119
+ if (process.env.DISCORD_BUGS_CHANNEL_ID) return process.env.DISCORD_BUGS_CHANNEL_ID;
120
+ try {
121
+ const raw = require('../../src/module-api').readConfigFileSync('discord-bot.json');
122
+ const cfg = raw ? JSON.parse(raw) : null;
123
+ if (cfg && cfg.bugs_channel_id) return String(cfg.bugs_channel_id);
124
+ } catch (_) { /* not configured */ }
125
+ return null;
126
+ }
127
+
128
+ async function main() {
129
+ const argv = process.argv.slice(2);
130
+ const dryRun = argv.includes('--dry-run');
131
+ const channelArg = argValue(argv, '--channel');
132
+ const positional = argv.filter((a) => !a.startsWith('--') && a !== channelArg);
133
+ const at = require('../../modules/discord/bug-attachments.js');
134
+
135
+ // Which names to recover. Explicit filenames need no task lookup; --missing
136
+ // and message ids need the audit's list of described-but-missing files.
137
+ let names = positional.filter((a) => NAME_RE.test(a));
138
+ const ids = positional.filter((a) => SNOWFLAKE_RE.test(a));
139
+ const junk = positional.filter((a) => !NAME_RE.test(a) && !SNOWFLAKE_RE.test(a));
140
+ if (junk.length) { console.error(`bug-attachment-recover: not a filename or message id: ${junk.join(', ')}`); process.exitCode = 2; return; }
141
+ if (argv.includes('--missing') || ids.length) {
142
+ const audit = require('./bug-attachment-audit.js');
143
+ const r = audit.auditRows(await audit.fetchRows(), (name) => {
144
+ const p = at.resolveSafe(name);
145
+ return Boolean(p) && fs.existsSync(p);
146
+ });
147
+ const missing = r.missing.map((m) => m.name);
148
+ if (argv.includes('--missing')) names.push(...missing);
149
+ for (const id of ids) {
150
+ const mine = missing.filter((n) => messageIdFromName(n) === id);
151
+ if (!mine.length) console.log(` message ${id}: no missing file names it`);
152
+ names.push(...mine);
153
+ }
154
+ }
155
+ if (!positional.length && !argv.includes('--missing')) {
156
+ console.error('usage: bug-attachment-recover.js --missing | <message-id>... | <filename>... [--channel <id>] [--dry-run]');
157
+ process.exitCode = 2;
158
+ return;
159
+ }
160
+
161
+ const { byMessage, unaddressable } = planRecovery(names);
162
+ for (const n of unaddressable) console.log(` skip ${n} (its name carries no Discord message id)`);
163
+ if (!byMessage.size) {
164
+ console.log(' Nothing to recover.');
165
+ if (unaddressable.length) process.exitCode = 1;
166
+ return;
167
+ }
168
+
169
+ const channelId = loadChannelId(argv);
170
+ if (!channelId) { console.error('bug-attachment-recover: no #bugs channel id — pass --channel or set DISCORD_BUGS_CHANNEL_ID'); process.exitCode = 2; return; }
171
+ if (dryRun) {
172
+ for (const [id, list] of byMessage) for (const n of list) console.log(` would fetch ${n} from message ${id} in channel ${channelId}`);
173
+ return;
174
+ }
175
+ const token = require('../../modules/discord/auth.js').loadDiscordBotToken();
176
+ if (!token) { console.error('bug-attachment-recover: no Discord bot token on this box (DISCORD_BOT_TOKEN or discord-bot.json)'); process.exitCode = 2; return; }
177
+
178
+ let failed = unaddressable.length;
179
+ for (const [messageId, list] of byMessage) {
180
+ // Present files are left alone: a re-run after a partial recovery only
181
+ // fetches what is still missing.
182
+ const todo = list.filter((n) => { const p = at.resolveSafe(n); return !(p && fs.existsSync(p)); });
183
+ for (const n of list) if (!todo.includes(n)) console.log(` present ${n}`);
184
+ if (!todo.length) continue;
185
+ const results = await recoverMessage({ channelId, messageId, names: todo, token, persist: at.persistImageFromUrl });
186
+ for (const r of results) {
187
+ if (r.ok) console.log(` restored ${r.name} (${r.bytes} bytes)`);
188
+ else { failed++; console.log(` FAILED ${r.name} (${r.reason})`); }
189
+ }
190
+ }
191
+ if (failed) process.exitCode = 1;
192
+ }
193
+
194
+ if (require.main === module) {
195
+ main().catch((e) => { console.error(`bug-attachment-recover: ${e.message || e}`); process.exitCode = 2; });
196
+ }
197
+
198
+ module.exports = { messageIdFromName, planRecovery, imageAttachmentUrls, recoverMessage, SNOWFLAKE_RE };
@@ -32,6 +32,7 @@
32
32
  //
33
33
  // Run: node scripts/gds/copy-apply.js --batch (a PAGE TWEAK round you hold: every line, task 1004322)
34
34
  // node scripts/gds/copy-apply.js --batch --batch-file round.md --dry-run (a local round; writes nothing)
35
+ // node scripts/gds/copy-apply.js --batch --page <id> --unattended (the auto-pickup worker: exit 5 = wrong page; any non-zero exit releases the round, task 1004678)
35
36
  // node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
36
37
  // node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
37
38
  // node scripts/gds/copy-apply.js --task 1234 (a claim you hold, when you hold more than one)
@@ -518,7 +519,7 @@ async function writeAppliedBlock({ taskId, description, block, api = apiCall })
518
519
 
519
520
  // The --batch command. `deps` is the test seam: every read, write, re-read and API
520
521
  // call can be replaced, so the whole path runs against a fixture round.
521
- async function batchMain({ taskId, batchFile, dryRun, now = () => new Date().toISOString() }, deps = {}) {
522
+ async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () => new Date().toISOString() }, deps = {}) {
522
523
  const readFile = deps.readFile || ((p) => fs.readFileSync(p, 'utf8'));
523
524
  const writeFile = deps.writeFile || ((p, s) => fs.writeFileSync(p, s));
524
525
  const api = deps.api || apiCall;
@@ -526,6 +527,12 @@ async function batchMain({ taskId, batchFile, dryRun, now = () => new Date().toI
526
527
  const loaded = await loadBatch({ taskId, batchFile, readFile, api });
527
528
  if (!loaded.ok) return refuse(loaded);
528
529
  const { batch } = loaded;
530
+ // --page (task 1004678, ADR 0371 D8): the unattended worker names the page it was
531
+ // given, and a round for any other page is refused before a file is touched.
532
+ if (pageId && batch.page_id !== pageId) {
533
+ refuse({ ok: false, code: 'wrong_page', detail: { task: taskId, expected: pageId, round_page: batch.page_id, what_this_means: 'This round belongs to a different page than the one named with --page. Nothing was written.' } });
534
+ return 5;
535
+ }
529
536
  const registry = deps.registry || JSON.parse(readFile(path.join(REPO_ROOT, 'docs', 'copy-registry.json')));
530
537
  const result = applyBatchToSources({ batch, registry, readSource: (rel) => readFile(path.join(REPO_ROOT, rel)) });
531
538
 
@@ -590,7 +597,15 @@ async function main() {
590
597
  taskId = held.taskId;
591
598
  console.log(`copy-apply --batch: the round you are holding — task ${taskId}`);
592
599
  }
593
- return cliExit(await batchMain({ taskId, batchFile, dryRun }));
600
+ // --page <id> --unattended (task 1004678, ADR 0371 D8): never asks, and any exit
601
+ // that is not a clean apply releases the round with a plain note (tweak-halt.js).
602
+ const pageId = arg('--page') || null;
603
+ const code = await batchMain({ taskId, batchFile, dryRun, pageId });
604
+ if (hasFlag('--unattended') && code !== 0 && taskId && !batchFile && !dryRun) {
605
+ const halt = require('./tweak-halt');
606
+ halt.releaseWithNote(taskId, halt.haltForCopyApplyExit(code));
607
+ }
608
+ return cliExit(code);
594
609
  }
595
610
 
596
611
  if (!taskId && !patchFile) {
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/tweak-halt.js — an unattended /tweak stops, releases its round with a
3
+ // plain note, and exits (task 1004678 / BV2.TW19b, ADR 0371 D8).
4
+ //
5
+ // WHY. `/tweak --page <id> --unattended` runs with nobody to ask. Every halt it can
6
+ // meet must end the same way: the round goes back to the queue with a sentence an
7
+ // artist can read, and the worker exits. Writing that once here means the skill,
8
+ // copy-apply's --unattended path and the runner's lane (TW19c) cannot each word a
9
+ // halt differently, or leave one claimed.
10
+ //
11
+ // Run: node scripts/gds/tweak-halt.js <task-id> --code <halt-code>
12
+ //
13
+ // The note is fixed vocabulary per code, never free text from the worker: the
14
+ // worker is a model reading pages an artist wrote, so nothing it says rides out
15
+ // onto the round.
16
+
17
+ 'use strict';
18
+
19
+ const path = require('node:path');
20
+ const { spawnSync } = require('node:child_process');
21
+
22
+ // The halts ADR 0371 D8 names, plus the copy-apply exits that mean "do not ship".
23
+ const HALT_NOTES = Object.freeze({
24
+ claim_refused: 'The automatic apply could not take this round (the claim was refused). It waits for a person.',
25
+ nothing_applied: 'The automatic apply placed none of the lines, so there is nothing to show. Each refused line is recorded on the round.',
26
+ wrong_page: 'The automatic apply was pointed at a different page than this round. Nothing was changed.',
27
+ registry_mismatch: 'The automatic apply wrote the files but the copy registry did not read the new words back, so it stopped before shipping.',
28
+ reread_failed: 'The automatic apply could not re-read the page after applying it, so it stopped before shipping.',
29
+ ship_check_stale: 'The automatic apply stopped because the pre-ship checks named something stale it may not fix by itself.',
30
+ grader_unavailable: 'The quality review could not run, so the automatic apply stopped. The round waits for the next try or a person.',
31
+ grade_failed: 'The quality review did not pass the automatic apply. The round waits for a person.',
32
+ outside_page: 'The automatic apply would have changed a file outside this page, so it stopped.',
33
+ });
34
+
35
+ function haltNote(code) {
36
+ return HALT_NOTES[code] || null;
37
+ }
38
+
39
+ // copy-apply --batch's exit codes, as halts. 0 is not a halt. 2 is "nothing
40
+ // applied" and also every refusal copy-apply names before writing (an unreadable
41
+ // round): in both, nothing reached the page.
42
+ function haltForCopyApplyExit(exit) {
43
+ if (exit === 0) return null;
44
+ return { 2: 'nothing_applied', 3: 'registry_mismatch', 4: 'reread_failed', 5: 'wrong_page' }[exit] || 'nothing_applied';
45
+ }
46
+
47
+ // Release the round with the note. `spawn` is the test seam. Returns the exit.
48
+ function releaseWithNote(taskId, code, { spawn = spawnSync } = {}) {
49
+ const note = haltNote(code);
50
+ if (!note) throw new Error(`unknown halt code: ${code}`);
51
+ const r = spawn(process.execPath, [path.join(__dirname, 'release.js'), String(taskId), '--reason', `[tweak-unattended:${code}] ${note}`], { stdio: 'inherit' });
52
+ return r && typeof r.status === 'number' ? r.status : 1;
53
+ }
54
+
55
+ function main(argv = process.argv.slice(2)) {
56
+ const taskId = argv[0];
57
+ const i = argv.indexOf('--code');
58
+ const code = i >= 0 ? argv[i + 1] : null;
59
+ if (!/^[1-9][0-9]*$/.test(String(taskId || '')) || !haltNote(code)) {
60
+ console.error(`usage: node scripts/gds/tweak-halt.js <task-id> --code <${Object.keys(HALT_NOTES).join('|')}>`);
61
+ return 1;
62
+ }
63
+ console.log(`tweak-halt: task ${taskId} — ${haltNote(code)}`);
64
+ return releaseWithNote(taskId, code);
65
+ }
66
+
67
+ if (require.main === module) process.exitCode = main();
68
+
69
+ module.exports = { HALT_NOTES, haltNote, haltForCopyApplyExit, releaseWithNote };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.109'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.111'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -2,9 +2,10 @@
2
2
  //
3
3
  // WHY THIS FILE EXISTS. Five re-hosted #bugs screenshots — every one this instance
4
4
  // ever stored — disappeared in the 2026-07-05 droplet move, and nobody noticed for
5
- // two months. They cannot be re-fetched: the Discord CDN URL they were copied from
6
- // is signed and expires in about a day, so our copy IS the image. The probe is what
7
- // should have said so in July.
5
+ // two months. The Discord CDN URL they were copied from carries a signature that
6
+ // expires in about a day; the file itself survives while its message does, and
7
+ // bug-attachment-recover.js re-fetches it (task 1004667). The probe is what should
8
+ // have said they were missing in July.
8
9
  //
9
10
  // A probe like this fails by finding nothing, and a clean bill of health looks
10
11
  // identical to working — which is exactly what happened while building it: the
@@ -0,0 +1,128 @@
1
+ // tests/bug_attachment_recover.mjs — re-fetching a lost #bugs screenshot from
2
+ // its Discord message, back to its original filename (task 1004667).
3
+ //
4
+ // WHY THIS FILE EXISTS. The recovery is only worth anything if the file lands
5
+ // under the name the task description already links — a fresh random name stores
6
+ // the bytes and leaves the task's image broken — and if it goes through the same
7
+ // guarded download as the bot (host allowlist, sniff, size cap). Both are pinned
8
+ // here against the REAL persistImageFromUrl with a fake fetch: no network, no
9
+ // Discord, a throwaway store.
10
+ //
11
+ // Run: node tests/bug_attachment_recover.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import fs from 'node:fs/promises';
18
+ const require = createRequire(import.meta.url);
19
+
20
+ const TMP = path.join(os.tmpdir(), `otb-bugrecover-${process.pid}`);
21
+ process.env.BUG_ATTACHMENTS_DIR = TMP;
22
+ const at = require('../modules/discord/bug-attachments.js');
23
+ const rec = require('../scripts/gds/bug-attachment-recover.js');
24
+
25
+ let passed = 0, failed = 0;
26
+ function t(name, fn) {
27
+ return Promise.resolve().then(fn)
28
+ .then(() => { passed++; console.log(` PASS ${name}`); })
29
+ .catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
30
+ }
31
+
32
+ const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
33
+ const CHANNEL = '111111111111111111';
34
+ const MSG = '1234567890123456789';
35
+ const A = `bug-${MSG}-0011223344556677.png`;
36
+ const B = `bug-${MSG}-8899aabbccddeeff.png`;
37
+
38
+ // One fake fetch for both legs: the Discord REST call returns `message`; a CDN
39
+ // URL returns the PNG. Records every URL and header it was asked for.
40
+ function fakeDiscord(message, { status = 200 } = {}) {
41
+ const calls = [];
42
+ const fetchImpl = async (url, opts = {}) => {
43
+ calls.push({ url, opts });
44
+ if (url.startsWith('https://discord.com/api/')) {
45
+ return { ok: status === 200, status, json: async () => message };
46
+ }
47
+ return {
48
+ ok: true, status: 200,
49
+ headers: { get: (k) => (k.toLowerCase() === 'content-type' ? 'image/png' : null) },
50
+ arrayBuffer: async () => PNG.buffer.slice(PNG.byteOffset, PNG.byteOffset + PNG.byteLength),
51
+ };
52
+ };
53
+ return { fetchImpl, calls };
54
+ }
55
+
56
+ const att = (n, extra = {}) => ({ url: `https://cdn.discordapp.com/attachments/${CHANNEL}/${n}/shot.png?ex=1&is=2&hm=3`, content_type: 'image/png', ...extra });
57
+
58
+ console.log('Bug-attachment recovery (task 1004667):');
59
+
60
+ await t('messageIdFromName: reads the snowflake the bot put in the name', () => {
61
+ assert.equal(rec.messageIdFromName(A), MSG);
62
+ assert.equal(rec.messageIdFromName('bug-x-0011223344556677.png'), null, 'the x fallback carries no id');
63
+ assert.equal(rec.messageIdFromName('bug-m42-0011223344556677.png'), null, 'a non-numeric slug is not a message id');
64
+ assert.equal(rec.messageIdFromName('../../etc/passwd'), null);
65
+ });
66
+
67
+ await t('planRecovery: groups by message, keeps link order, dedupes, sets aside the unaddressable', () => {
68
+ const { byMessage, unaddressable } = rec.planRecovery([A, 'bug-x-0011223344556677.png', B, A]);
69
+ assert.deepEqual([...byMessage.entries()], [[MSG, [A, B]]]);
70
+ assert.deepEqual(unaddressable, ['bug-x-0011223344556677.png']);
71
+ });
72
+
73
+ await t('imageAttachmentUrls: images only, in order — the same test the bot stored them by', () => {
74
+ const urls = rec.imageAttachmentUrls({ attachments: [att(1), { url: 'https://cdn.discordapp.com/x.txt', content_type: 'text/plain' }, att(2, { content_type: undefined, width: 10 })] });
75
+ assert.equal(urls.length, 2);
76
+ assert.ok(urls[0].includes('/1/') && urls[1].includes('/2/'));
77
+ });
78
+
79
+ await t('recoverMessage: asks Discord for the message with the bot token, then writes each image under its ORIGINAL name', async () => {
80
+ const { fetchImpl, calls } = fakeDiscord({ attachments: [att(1), att(2)] });
81
+ const out = await rec.recoverMessage({ channelId: CHANNEL, messageId: MSG, names: [A, B], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
82
+ assert.deepEqual(out.map((r) => [r.name, r.ok]), [[A, true], [B, true]]);
83
+ assert.equal(calls[0].url, `https://discord.com/api/v10/channels/${CHANNEL}/messages/${MSG}`);
84
+ assert.equal(calls[0].opts.headers.Authorization, 'Bot tok');
85
+ assert.ok(calls[1].url.includes('/1/') && calls[2].url.includes('/2/'), 'k-th name ← k-th image');
86
+ for (const n of [A, B]) assert.equal((await fs.readFile(path.join(TMP, n))).length, PNG.length);
87
+ const onDisk = (await fs.readdir(TMP)).sort();
88
+ assert.deepEqual(onDisk, [A, B].sort(), 'no extra, randomly-named copy was written');
89
+ });
90
+
91
+ await t('recoverMessage: a deleted message is reported as message_gone, nothing written', async () => {
92
+ await fs.rm(TMP, { recursive: true, force: true });
93
+ const { fetchImpl } = fakeDiscord(null, { status: 404 });
94
+ const out = await rec.recoverMessage({ channelId: CHANNEL, messageId: MSG, names: [A], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
95
+ assert.deepEqual(out.map((r) => r.reason), ['message_gone']);
96
+ });
97
+
98
+ await t('recoverMessage: more names than images → the extra name says so instead of reusing an image', async () => {
99
+ const { fetchImpl } = fakeDiscord({ attachments: [att(1)] });
100
+ const out = await rec.recoverMessage({ channelId: CHANNEL, messageId: MSG, names: [A, B], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
101
+ assert.deepEqual(out.map((r) => r.ok || r.reason), [true, 'no_attachment_at_index']);
102
+ });
103
+
104
+ await t('recoverMessage: a non-snowflake id never reaches a URL', async () => {
105
+ const { fetchImpl, calls } = fakeDiscord({ attachments: [] });
106
+ const out = await rec.recoverMessage({ channelId: '../../users/@me', messageId: MSG, names: [A], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
107
+ assert.deepEqual([out[0].reason, calls.length], ['bad_id', 0]);
108
+ });
109
+
110
+ await t('recoverMessage: the attachment download keeps the CDN host allowlist', async () => {
111
+ await fs.rm(TMP, { recursive: true, force: true });
112
+ const { fetchImpl } = fakeDiscord({ attachments: [{ url: 'https://evil.example/shot.png', content_type: 'image/png' }] });
113
+ const out = await rec.recoverMessage({ channelId: CHANNEL, messageId: MSG, names: [A], token: 'tok', fetchImpl, dir: TMP, persist: at.persistImageFromUrl });
114
+ assert.equal(out[0].reason, 'bad_host');
115
+ });
116
+
117
+ await t('the audit no longer claims a lost file cannot be re-fetched, and points at this tool', async () => {
118
+ const src = await fs.readFile(new URL('../scripts/gds/bug-attachment-audit.js', import.meta.url), 'utf8');
119
+ assert.ok(!/cannot be re-fetched/i.test(src));
120
+ assert.match(src, /bug-attachment-recover\.js --missing/);
121
+ const mod = await fs.readFile(new URL('../modules/discord/bug-attachments.js', import.meta.url), 'utf8');
122
+ assert.match(mod, /What expires in about a day is the URL\s*\n?\s*\/\/\s*SIGNATURE/);
123
+ });
124
+
125
+ await t('cleanup temp dir', async () => { await fs.rm(TMP, { recursive: true, force: true }); });
126
+
127
+ console.log(`\n${passed} passed, ${failed} failed`);
128
+ process.exit(failed ? 1 : 0);
@@ -107,6 +107,36 @@ await t('persistImageFromUrl: valid PNG → written to disk, returns durable url
107
107
  assert.equal(onDisk.length, PNG.length);
108
108
  });
109
109
 
110
+ // ---- task 1004667: a GIVEN filename (the recovery path) ---------------------
111
+ const GIVEN = 'bug-1234567890123456789-0011223344556677.png';
112
+ await t('persistImageFromUrl({file}): writes under EXACTLY the given name', async () => {
113
+ const r = await at.persistImageFromUrl('https://cdn.discordapp.com/a.png', { file: GIVEN, fetchImpl: fakeFetch(PNG), dir: TMP });
114
+ assert.equal(r.ok, true);
115
+ assert.equal(r.file, GIVEN);
116
+ assert.equal((await fs.readFile(path.join(TMP, GIVEN))).length, PNG.length);
117
+ });
118
+ await t('persistImageFromUrl({file}): never overwrites a file that is present', async () => {
119
+ await fs.writeFile(path.join(TMP, GIVEN), 'KEEP');
120
+ const r = await at.persistImageFromUrl('https://cdn.discordapp.com/a.png', { file: GIVEN, fetchImpl: fakeFetch(PNG), dir: TMP });
121
+ assert.deepEqual([r.ok, r.reason], [false, 'exists']);
122
+ assert.equal(await fs.readFile(path.join(TMP, GIVEN), 'utf8'), 'KEEP');
123
+ });
124
+ await t('persistImageFromUrl({file}): an unsafe name is refused before any fetch', async () => {
125
+ let fetched = false;
126
+ const r = await at.persistImageFromUrl('https://cdn.discordapp.com/a.png', { file: '../../evil.png', fetchImpl: async () => { fetched = true; }, dir: TMP });
127
+ assert.deepEqual([r.ok, r.reason, fetched], [false, 'bad_name', false]);
128
+ });
129
+ await t('persistImageFromUrl({file}): bytes of another type than the name says → ext_mismatch, nothing written', async () => {
130
+ const name = 'bug-1234567890123456789-8899aabbccddeeff.jpg';
131
+ const r = await at.persistImageFromUrl('https://cdn.discordapp.com/a.png', { file: name, fetchImpl: fakeFetch(PNG), dir: TMP });
132
+ assert.deepEqual([r.ok, r.reason], [false, 'ext_mismatch']);
133
+ await assert.rejects(fs.access(path.join(TMP, name)));
134
+ });
135
+ await t('persistImageFromUrl({file}): the host allowlist still applies', async () => {
136
+ const r = await at.persistImageFromUrl('https://evil.example/a.png', { file: 'bug-1-8899aabbccddeeff.png', fetchImpl: fakeFetch(PNG), dir: TMP });
137
+ assert.equal(r.reason, 'bad_host');
138
+ });
139
+
110
140
  // ---- L3 (#999): redirect:'manual' + re-validate the Location host ----------
111
141
  // A fetch that returns a 30x with the given Location on the FIRST call, then the
112
142
  // real PNG body on subsequent calls. Records the URLs it was asked to fetch.