mixdog 0.9.17 → 0.9.18

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.
@@ -1,106 +1,106 @@
1
- # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
- # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
- # boot, double-init refusal, hostile-env survival.
4
- #
5
- # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
-
7
- $ErrorActionPreference = 'Stop'
8
-
9
- $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
- $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
- $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
- $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
- $PgVer = '16.4'
14
- $PgvectorVer = '0.8.2'
15
-
16
- $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
- $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
-
19
- $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
-
21
- try {
22
- Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
-
24
- Write-Host "==> Test 1: HEAD reaches release asset"
25
- $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
- Write-Host " HTTP $($resp.StatusCode)"
27
-
28
- Write-Host "==> Test 2: full download + sha256"
29
- $TarPath = Join-Path $Work $Asset
30
- Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
- $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
- Write-Host " sha256=$sha"
33
-
34
- Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
- $CorruptPath = "$Work\corrupt.tar.gz"
36
- Copy-Item $TarPath $CorruptPath
37
- $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
- $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
- $stream.Position = 102400
40
- $stream.Write($bytes, 0, 1)
41
- $stream.Close()
42
- $CorruptDir = "$Work\corrupt"
43
- New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
- $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
- if (-not $TarProc.WaitForExit(15000)) {
46
- Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
- Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
- } elseif ($TarProc.ExitCode -eq 0) {
49
- Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
- } else {
51
- Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
- }
53
-
54
- Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
- $FreshDir = "$Work\fresh"
56
- New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
- & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
- if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
-
60
- $PgBin = "$FreshDir\bin"
61
- $Data = "$FreshDir\pgdata"
62
- $Log = "$FreshDir\pg.log"
63
- $Port = 55897
64
-
65
- # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
- $SavedPath = $env:PATH
67
- $SavedPgRoot = $env:PGROOT
68
- $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
- $env:PGROOT = $null
70
- $env:PGDATA = $null
71
-
72
- try {
73
- & "$PgBin\postgres.exe" --version
74
- if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
- & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
- if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
- & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
- if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
-
80
- try {
81
- & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
- if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
- $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
- if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
- Write-Host " PASS: fresh-extract boot + vector extension"
86
- } finally {
87
- & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
- }
89
-
90
- Write-Host "==> Test 5: second initdb on initialized dir refused"
91
- $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
- if ($rc -eq 0) {
93
- Write-Host " WARN: second initdb succeeded (unexpected)"
94
- } else {
95
- Write-Host " PASS: second initdb refused (exit $rc)"
96
- }
97
- } finally {
98
- $env:PATH = $SavedPath
99
- $env:PGROOT = $SavedPgRoot
100
- }
101
-
102
- Write-Host "==> All negative-path smokes: PASS"
103
- }
104
- finally {
105
- Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
- }
1
+ # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
+ # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
+ # boot, double-init refusal, hostile-env survival.
4
+ #
5
+ # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
+
7
+ $ErrorActionPreference = 'Stop'
8
+
9
+ $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
+ $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
+ $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
+ $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
+ $PgVer = '16.4'
14
+ $PgvectorVer = '0.8.2'
15
+
16
+ $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
+ $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
+
19
+ $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
+
21
+ try {
22
+ Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
+
24
+ Write-Host "==> Test 1: HEAD reaches release asset"
25
+ $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
+ Write-Host " HTTP $($resp.StatusCode)"
27
+
28
+ Write-Host "==> Test 2: full download + sha256"
29
+ $TarPath = Join-Path $Work $Asset
30
+ Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
+ $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
+ Write-Host " sha256=$sha"
33
+
34
+ Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
+ $CorruptPath = "$Work\corrupt.tar.gz"
36
+ Copy-Item $TarPath $CorruptPath
37
+ $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
+ $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
+ $stream.Position = 102400
40
+ $stream.Write($bytes, 0, 1)
41
+ $stream.Close()
42
+ $CorruptDir = "$Work\corrupt"
43
+ New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
+ $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
+ if (-not $TarProc.WaitForExit(15000)) {
46
+ Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
+ Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
+ } elseif ($TarProc.ExitCode -eq 0) {
49
+ Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
+ } else {
51
+ Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
+ }
53
+
54
+ Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
+ $FreshDir = "$Work\fresh"
56
+ New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
+ & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
+ if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
+
60
+ $PgBin = "$FreshDir\bin"
61
+ $Data = "$FreshDir\pgdata"
62
+ $Log = "$FreshDir\pg.log"
63
+ $Port = 55897
64
+
65
+ # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
+ $SavedPath = $env:PATH
67
+ $SavedPgRoot = $env:PGROOT
68
+ $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
+ $env:PGROOT = $null
70
+ $env:PGDATA = $null
71
+
72
+ try {
73
+ & "$PgBin\postgres.exe" --version
74
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
+ & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
+ & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
+ if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
+
80
+ try {
81
+ & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
+ $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
+ if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
+ Write-Host " PASS: fresh-extract boot + vector extension"
86
+ } finally {
87
+ & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
+ }
89
+
90
+ Write-Host "==> Test 5: second initdb on initialized dir refused"
91
+ $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
+ if ($rc -eq 0) {
93
+ Write-Host " WARN: second initdb succeeded (unexpected)"
94
+ } else {
95
+ Write-Host " PASS: second initdb refused (exit $rc)"
96
+ }
97
+ } finally {
98
+ $env:PATH = $SavedPath
99
+ $env:PGROOT = $SavedPgRoot
100
+ }
101
+
102
+ Write-Host "==> All negative-path smokes: PASS"
103
+ }
104
+ finally {
105
+ Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
+ }
@@ -85,4 +85,4 @@ for (const target of ['heavy-worker', 'worker']) {
85
85
  console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
86
86
  console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
87
87
  console.log(` same-path re-reads >=3x: ${rereads}`);
88
- }
88
+ }
@@ -1,3 +1,3 @@
1
- # Channels
2
-
3
- - Channel features are handled by the runtime.
1
+ # Channels
2
+
3
+ - Channel features are handled by the runtime.
@@ -5,13 +5,13 @@ import { join, sep } from "path";
5
5
 
6
6
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
7
7
 
8
- async function downloadSingleAttachment(att, inboxDir) {
8
+ async function downloadSingleAttachment(att, inboxDir, { timeoutMs = 180_000 } = {}) {
9
9
  if (att.size > MAX_ATTACHMENT_BYTES) {
10
10
  throw new Error(
11
11
  `attachment too large: ${(att.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
12
12
  );
13
13
  }
14
- const res = await fetch(att.url, { signal: AbortSignal.timeout(180_000) });
14
+ const res = await fetch(att.url, { signal: AbortSignal.timeout(timeoutMs) });
15
15
  if (!res.ok) {
16
16
  throw new Error(`attachment download failed: HTTP ${res.status}`);
17
17
  }
@@ -212,13 +212,23 @@ async function awaitLogin(self) {
212
212
  resolve();
213
213
  });
214
214
  });
215
+ // Observe the ready timer from the start: login() can hang past the 30s
216
+ // timer (gateway reconnect), and a rejection on an un-awaited readyPromise
217
+ // becomes a process-global unhandledRejection → fatal crash path
218
+ // (channels/index.mjs unhandledRejection → _fatalCrash → exit(1)). Racing
219
+ // login with readyPromise keeps the rejection inside this connect() chain,
220
+ // which every caller already catches non-fatally.
221
+ const loginPromise = self.client.login(self.token);
222
+ // A late login settlement after the timeout raced ahead must not itself
223
+ // become a second unhandled rejection.
224
+ loginPromise.catch(() => {});
215
225
  try {
216
- await self.client.login(self.token);
226
+ await Promise.race([loginPromise, readyPromise]);
227
+ await readyPromise;
217
228
  } catch (err) {
218
229
  clearTimeout(readyTimeout);
219
230
  throw err;
220
231
  }
221
- await readyPromise;
222
232
  }
223
233
 
224
234
  export {
@@ -23,6 +23,14 @@ import { normalizeAccess, safeAttName } from "./discord-access.mjs";
23
23
  import { MAX_ATTACHMENT_BYTES, downloadSingleAttachment } from "./discord-attachments.mjs";
24
24
  import * as gateway from "./discord-gateway.mjs";
25
25
  const MAX_CHUNK_LIMIT = MAX_DISCORD_MESSAGE;
26
+ // Per-attempt cap for a single discord.js fetch/send. Without it a wedged
27
+ // socket (network blip, "other side closed") leaves the await pending forever,
28
+ // which upstream leaves the forwarder's `sending` flag stuck and silences all
29
+ // outbound. On timeout we treat the client as dead and reset+reconnect it.
30
+ const SEND_ATTEMPT_TIMEOUT_MS = 30_000;
31
+ // After a timed-out send is reset, cap how long we wait for the original
32
+ // (abandoned) promise to actually settle before giving up with unknown outcome.
33
+ const SETTLE_CAP_MS = 15_000;
26
34
  const RECENT_SENT_CAP = 200;
27
35
  class DiscordBackend {
28
36
  name = "discord";
@@ -74,11 +82,57 @@ class DiscordBackend {
74
82
  if (this.client) this.client.destroy();
75
83
  this._connectPromise = null;
76
84
  }
85
+ // ── Self-heal helpers ──────────────────────────────────────────────
86
+ /** Race a discord.js op-promise against a per-attempt timeout so a wedged
87
+ * socket can't hang the outbound path. On timeout the rejection carries
88
+ * `_timeout` so the caller knows the underlying op is still in flight (and
89
+ * must be settled/adopted, not blindly retried → duplicate). */
90
+ _withTimeout(promise, label) {
91
+ let timer;
92
+ const timeout = new Promise((_, reject) => {
93
+ timer = setTimeout(() => {
94
+ const e = new Error(`${label} timed out after ${SEND_ATTEMPT_TIMEOUT_MS}ms`);
95
+ e._timeout = true;
96
+ reject(e);
97
+ }, SEND_ATTEMPT_TIMEOUT_MS);
98
+ });
99
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
100
+ }
101
+ /** True when an error means the client is dead (aborted, "other side
102
+ * closed", or our per-attempt timeout) and must be reset+reconnected. */
103
+ _shouldResetClient(err) {
104
+ if (err?.name === "AbortError" || err?.code === "ABORT_ERR") return true;
105
+ const msg = String(err?.message || err || "").toLowerCase();
106
+ return msg.includes("aborted") || msg.includes("other side closed") ||
107
+ msg.includes("socket hang up") || msg.includes("econnreset") || msg.includes("timed out");
108
+ }
109
+ /** Destroy the wedged client and rebuild it via connect() so the next
110
+ * fetch/send (and the forwarder's queue-head-preserving retry) hits a fresh
111
+ * gateway/REST client. Serialized behind a single in-flight reset so
112
+ * concurrent callers await the SAME reconnect instead of racing multiple
113
+ * destroy()/login() cycles. */
114
+ async _resetClient() {
115
+ if (this._resetPromise) return this._resetPromise;
116
+ this._resetPromise = (async () => {
117
+ try { this.client?.destroy?.(); } catch {}
118
+ this.client = null;
119
+ this._connectPromise = null;
120
+ await this.connect();
121
+ })().finally(() => { this._resetPromise = null; });
122
+ return this._resetPromise;
123
+ }
124
+ /** Health-check the gateway; reset+reconnect if the ws is not READY. Called
125
+ * by the owner re-claim self-heal path. (discord.js Status.Ready === 0) */
126
+ async healthCheck() {
127
+ if (this.client && this.client.ws?.status === 0) return;
128
+ await this._resetClient();
129
+ }
77
130
  resetSendCount() {
78
131
  this.sendCount = 0;
79
132
  }
80
133
  startTyping(channelId) {
81
134
  this.stopTyping(channelId);
135
+ if (!this.client) return;
82
136
  const ch = this.client.channels.cache.get(channelId);
83
137
  if (ch && "sendTyping" in ch) {
84
138
  void ch.sendTyping().catch(() => {
@@ -172,13 +226,38 @@ class DiscordBackend {
172
226
  // resumes at this chunk index instead of re-sending 0..i-1.
173
227
  let attempt = 0;
174
228
  for (;;) {
229
+ const sendPromise = ch.send(payload);
175
230
  try {
176
- const sent = await ch.send(payload);
231
+ const sent = await this._withTimeout(sendPromise, "discord send");
177
232
  this.noteSent(sent.id);
178
233
  sentIds.push(sent.id);
179
234
  break;
180
235
  } catch (err) {
181
236
  attempt++;
237
+ if (err?._timeout) {
238
+ // Watchdog abandoned the send but the socket may still deliver.
239
+ // Destroy the client to tear down the in-flight REST socket so the
240
+ // ORIGINAL promise settles, then AWAIT + adopt its real outcome:
241
+ // delivered → done (no retry → no duplicate); else requeue against
242
+ // the fresh client via a resume token.
243
+ try { await this._resetClient(); } catch {}
244
+ // Cap the settlement wait: destroy() should abort the in-flight
245
+ // socket, but never hang here if it doesn't. On unknown outcome
246
+ // throw retryable (duplicate risk accepted over a permanent hang).
247
+ sendPromise.catch(() => {}); // swallow a late rejection
248
+ const settled = await Promise.race([
249
+ sendPromise.then((v) => ({ ok: true, v }), () => ({ ok: false })),
250
+ new Promise((r) => setTimeout(() => r({ ok: false }), SETTLE_CAP_MS))
251
+ ]);
252
+ if (settled.ok) {
253
+ this.noteSent(settled.v.id);
254
+ sentIds.push(settled.v.id);
255
+ break;
256
+ }
257
+ const e = err instanceof Error ? err : new Error(String(err));
258
+ e.resumeToken = { hash: contentHash, nextChunkIdx: i, sentIds: [...sentIds], prefixed: applyPrefix, limit };
259
+ throw e;
260
+ }
182
261
  const status = err?.status ?? err?.code ?? err?.httpStatus;
183
262
  // Classify: PERMANENT = a 4xx client error (unknown channel/message,
184
263
  // missing access/permissions, 404 …) that will never succeed on
@@ -192,7 +271,15 @@ class DiscordBackend {
192
271
  e.permanent = true;
193
272
  throw e;
194
273
  }
195
- if (attempt >= 3) {
274
+ // Dead client (abort / "other side closed" / per-attempt timeout):
275
+ // destroy + reconnect so the forwarder's transient-retry path hits a
276
+ // fresh client, and throw a resume token now instead of burning the
277
+ // remaining attempts on a stale channel handle bound to the old client.
278
+ const dead = this._shouldResetClient(err);
279
+ if (dead) {
280
+ try { await this._resetClient(); } catch {}
281
+ }
282
+ if (dead || attempt >= 3) {
196
283
  // Attach an opaque resume token pointing at the chunk that failed
197
284
  // (i). sentIds holds every chunk already delivered (including any
198
285
  // seeded from a prior token).
@@ -301,13 +388,13 @@ class DiscordBackend {
301
388
  const msg = await ch.messages.fetch(messageId);
302
389
  await msg.delete();
303
390
  }
304
- async downloadAttachment(chatId, messageId) {
391
+ async downloadAttachment(chatId, messageId, opts = {}) {
305
392
  const ch = await this.fetchAllowedChannel(chatId);
306
393
  const msg = await ch.messages.fetch(messageId);
307
394
  if (msg.attachments.size === 0) return [];
308
395
  const results = [];
309
396
  for (const att of msg.attachments.values()) {
310
- const path = await this.downloadSingleAttachment(att);
397
+ const path = await this.downloadSingleAttachment(att, opts);
311
398
  results.push({
312
399
  id: att.id,
313
400
  path,
@@ -526,7 +613,10 @@ class DiscordBackend {
526
613
  }
527
614
  // ── Channel helpers ────────────────────────────────────────────────
528
615
  async fetchTextChannel(id) {
529
- const ch = await this.client.channels.fetch(id);
616
+ if (!this.client) await this.connect();
617
+ const p = this.client.channels.fetch(id);
618
+ p.catch(() => {}); // if the watchdog wins, don't leak an unhandled rejection
619
+ const ch = await this._withTimeout(p, "discord channels.fetch");
530
620
  if (!ch || !ch.isTextBased()) {
531
621
  throw new Error(`channel ${id} not found or not text-based`);
532
622
  }
@@ -571,8 +661,8 @@ class DiscordBackend {
571
661
  throw new Error(`refusing to send channel state: ${f}`);
572
662
  }
573
663
  }
574
- async downloadSingleAttachment(att) {
575
- return downloadSingleAttachment(att, this.inboxDir);
664
+ async downloadSingleAttachment(att, opts = {}) {
665
+ return downloadSingleAttachment(att, this.inboxDir, opts);
576
666
  }
577
667
  }
578
668
  export {