pi-roundtable 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { AttachmentBuilder, MessageFlags } from "discord.js";
5
+ import { messages } from "../i18n/index.ts";
6
+ import type { Logger } from "../log.ts";
7
+
8
+ /** The most characters of one card's copy text; a longer one gets no copy button. */
9
+ export const COPY_TEXT_LIMIT = 20_000;
10
+ /** How many cards' copy texts are kept by default, the newest. */
11
+ const COPY_KEEP = 100;
12
+ /** How many days a copy text is kept by default. */
13
+ const COPY_KEEP_DAYS = 30;
14
+
15
+ interface Entry {
16
+ text: string;
17
+ at: number;
18
+ }
19
+
20
+ export interface CardCopiesOptions {
21
+ /** A JSON file that keeps the texts across restarts; without it they live in memory only. */
22
+ path?: string;
23
+ /** How many cards' texts are kept, the newest; default `COPY_KEEP`. */
24
+ keep?: number;
25
+ /** How many days a text is kept; default `COPY_KEEP_DAYS`. */
26
+ keepDays?: number;
27
+ logger: Logger;
28
+ /** Replaceable in tests. */
29
+ now?: () => number;
30
+ }
31
+
32
+ /**
33
+ * The text behind an approval card's copy button, by the card's id: the button's custom id holds
34
+ * the id only, so the text is looked up when it is pressed, also after the process restarted when
35
+ * there is a file. Bounded by count and age. The file is written off the hot path, one write at a
36
+ * time and a burst of cards in one, through a temporary file that replaces it whole, readable by
37
+ * the host's user only; one that cannot be read or written is logged and the texts that remain
38
+ * are kept in memory. Discarding a conversation does not remove its cards' texts: they age out.
39
+ */
40
+ export class CardCopies {
41
+ readonly #options: CardCopiesOptions;
42
+ readonly #entries = new Map<string, Entry>();
43
+ #writing: Promise<void> | undefined;
44
+ #dirty = false;
45
+
46
+ constructor(options: CardCopiesOptions) {
47
+ this.#options = options;
48
+ const { path, logger } = options;
49
+ if (!path || !existsSync(path)) return;
50
+ try {
51
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
52
+ if (typeof parsed !== "object" || parsed === null)
53
+ throw new Error("not a JSON object");
54
+ for (const [id, entry] of Object.entries(parsed)) {
55
+ const { text, at } = entry as Partial<Entry>;
56
+ if (typeof text === "string" && typeof at === "number")
57
+ this.#entries.set(id, { text, at });
58
+ }
59
+ this.#prune();
60
+ } catch (error) {
61
+ logger.warn(
62
+ { path, err: error },
63
+ "card copy texts not read; copy buttons of earlier cards will say they are gone",
64
+ );
65
+ }
66
+ }
67
+
68
+ /** Keeps a card's copy text. */
69
+ remember(id: string, text: string): void {
70
+ this.#entries.set(id, { text, at: this.#now() });
71
+ this.#prune();
72
+ this.#schedule();
73
+ }
74
+
75
+ /** A card's copy text; undefined when it was never kept or has aged out. */
76
+ recall(id: string): string | undefined {
77
+ const entry = this.#entries.get(id);
78
+ if (!entry) return undefined;
79
+ if (this.#now() - entry.at > this.#keepMs()) {
80
+ this.#entries.delete(id);
81
+ return undefined;
82
+ }
83
+ return entry.text;
84
+ }
85
+
86
+ /** Resolves once what was remembered so far is on disk; for a host that stops and for tests. */
87
+ async flush(): Promise<void> {
88
+ while (this.#writing) await this.#writing;
89
+ }
90
+
91
+ #now(): number {
92
+ return (this.#options.now ?? Date.now)();
93
+ }
94
+
95
+ #keepMs(): number {
96
+ return (this.#options.keepDays ?? COPY_KEEP_DAYS) * 24 * 60 * 60_000;
97
+ }
98
+
99
+ #prune(): void {
100
+ const now = this.#now();
101
+ const keep = this.#options.keep ?? COPY_KEEP;
102
+ for (const [id, entry] of this.#entries)
103
+ if (now - entry.at > this.#keepMs()) this.#entries.delete(id);
104
+ for (const id of this.#entries.keys()) {
105
+ if (this.#entries.size <= keep) return;
106
+ this.#entries.delete(id);
107
+ }
108
+ }
109
+
110
+ /** Writes the file after the call that asked, once at a time; asks made meanwhile share the next write. */
111
+ #schedule(): void {
112
+ if (!this.#options.path) return;
113
+ this.#dirty = true;
114
+ if (this.#writing) return;
115
+ this.#writing = this.#drain().finally(() => {
116
+ this.#writing = undefined;
117
+ });
118
+ }
119
+
120
+ async #drain(): Promise<void> {
121
+ // Let the caller finish before the entries are serialized.
122
+ await Promise.resolve();
123
+ while (this.#dirty) {
124
+ this.#dirty = false;
125
+ await this.#write();
126
+ }
127
+ }
128
+
129
+ async #write(): Promise<void> {
130
+ const { path, logger } = this.#options;
131
+ if (!path) return;
132
+ try {
133
+ await mkdir(dirname(path), { recursive: true });
134
+ const temporary = `${path}.tmp`;
135
+ await writeFile(
136
+ temporary,
137
+ `${JSON.stringify(Object.fromEntries(this.#entries))}\n`,
138
+ { mode: 0o600 },
139
+ );
140
+ // A temporary file left by an earlier run keeps its mode, so set it.
141
+ await chmod(temporary, 0o600);
142
+ await rename(temporary, path);
143
+ } catch (error) {
144
+ logger.warn({ path, err: error }, "card copy texts not saved");
145
+ }
146
+ }
147
+ }
148
+
149
+ /** Discord's limit for the text of a message. */
150
+ const MESSAGE_LIMIT = 2_000;
151
+ /** A button label's limit. */
152
+ const LABEL_LIMIT = 80;
153
+
154
+ /**
155
+ * The copy button an approval offers: its label and text; none when there is no text, and none,
156
+ * reported through `tooLong`, when the text is over `COPY_TEXT_LIMIT`.
157
+ */
158
+ export function offeredCopy(
159
+ copy: { label?: string; text: string } | undefined,
160
+ tooLong: (chars: number) => void,
161
+ ): { label: string; text: string } | undefined {
162
+ if (!copy || copy.text === "") return undefined;
163
+ if (copy.text.length > COPY_TEXT_LIMIT) {
164
+ tooLong(copy.text.length);
165
+ return undefined;
166
+ }
167
+ const label = copy.label?.trim();
168
+ return {
169
+ label: label ? label.slice(0, LABEL_LIMIT) : messages().cardCopyLabel,
170
+ text: copy.text,
171
+ };
172
+ }
173
+
174
+ /** The reply to a press of a copy button: the text in a code block, or a file when it does not fit one. */
175
+ export function copyReply(text: string) {
176
+ const block = `\`\`\`\n${text}\n\`\`\``;
177
+ const inline = !text.includes("```") && block.length <= MESSAGE_LIMIT;
178
+ return {
179
+ content: inline ? block : messages().cardCopyFile,
180
+ ...(inline
181
+ ? {}
182
+ : {
183
+ files: [
184
+ new AttachmentBuilder(Buffer.from(text, "utf8"), {
185
+ name: "copy.txt",
186
+ }),
187
+ ],
188
+ }),
189
+ flags: MessageFlags.Ephemeral as const,
190
+ allowedMentions: { parse: [] as never[] },
191
+ };
192
+ }
@@ -1,30 +1,64 @@
1
1
  import type { Message } from "discord.js";
2
2
  import type {
3
3
  ChannelContext,
4
+ ChannelContextAttachmentOptions,
4
5
  ChannelContextMessage,
5
6
  ChannelContextOptions,
6
7
  } from "../contract/channel-context.ts";
7
8
  import type { InboundMessage } from "../contract/channels.ts";
8
9
  import { parseChannelKey } from "../contract/surface.ts";
10
+ import { MAX_TURN_IMAGES } from "../domain/attachment.ts";
9
11
  import type { Logger } from "../log.ts";
12
+ import { contextImages, type SkippedImage } from "./context-images.ts";
10
13
  import type { BotIdentity } from "./inbound-message.ts";
11
14
 
12
- export type ChannelContextSettings = Required<ChannelContextOptions>;
15
+ /** The most bytes one image may be: the core's limit for any attachment. */
16
+ const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
17
+
18
+ /** The options with every default filled in, the attachments' too. */
19
+ export type ChannelContextSettings = Required<
20
+ Omit<ChannelContextOptions, "attachments">
21
+ > & { attachments: Required<ChannelContextAttachmentOptions> };
13
22
 
14
23
  /** The defaults of `ChannelContextOptions`. */
15
24
  export const CHANNEL_CONTEXT_DEFAULTS: Readonly<
16
- Required<ChannelContextOptions>
25
+ Omit<Required<ChannelContextOptions>, "attachments"> & {
26
+ attachments: Readonly<Required<ChannelContextAttachmentOptions>>;
27
+ }
17
28
  > = Object.freeze({
18
29
  fetch: 50,
19
30
  keep: 15,
20
31
  similarity: 0.8,
21
32
  messageChars: 500,
22
33
  botMessageChars: 80,
34
+ foreignWebhook: () => false,
35
+ attachments: Object.freeze({ images: 0, maxBytes: 10 * 1024 * 1024 }),
23
36
  });
24
37
 
38
+ /** The host's options, then the call's, over the defaults; the attachments' members merge one by one. */
39
+ function resolvedSettings(
40
+ host: ChannelContextOptions,
41
+ call: ChannelContextOptions,
42
+ ): ChannelContextSettings {
43
+ const merged = {
44
+ ...CHANNEL_CONTEXT_DEFAULTS,
45
+ ...defined(host),
46
+ ...defined(call),
47
+ };
48
+ return {
49
+ ...merged,
50
+ attachments: {
51
+ ...CHANNEL_CONTEXT_DEFAULTS.attachments,
52
+ ...defined(host.attachments ?? {}),
53
+ ...defined(call.attachments ?? {}),
54
+ } as Required<ChannelContextAttachmentOptions>,
55
+ };
56
+ }
57
+
25
58
  /** Throws on a value outside what the configuration accepts: a programming error of the caller. */
26
59
  function assertChannelContextSettings(settings: ChannelContextSettings): void {
27
60
  const { fetch, keep, similarity, messageChars, botMessageChars } = settings;
61
+ const { images, maxBytes } = settings.attachments;
28
62
  const positive = { fetch, keep, messageChars, botMessageChars };
29
63
  for (const [name, value] of Object.entries(positive))
30
64
  if (!Number.isInteger(value) || value < 1)
@@ -35,6 +69,18 @@ function assertChannelContextSettings(settings: ChannelContextSettings): void {
35
69
  throw new RangeError(
36
70
  `channel context option similarity must be between 0 and 1, got ${similarity}`,
37
71
  );
72
+ if (typeof settings.foreignWebhook !== "function")
73
+ throw new TypeError(
74
+ "channel context option foreignWebhook must be a function",
75
+ );
76
+ if (!Number.isInteger(images) || images < 0 || images > MAX_TURN_IMAGES)
77
+ throw new RangeError(
78
+ `channel context option attachments.images must be an integer from 0 to ${MAX_TURN_IMAGES}, got ${images}`,
79
+ );
80
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_IMAGE_BYTES)
81
+ throw new RangeError(
82
+ `channel context option attachments.maxBytes must be an integer from 1 to ${MAX_IMAGE_BYTES}, got ${maxBytes}`,
83
+ );
38
84
  }
39
85
 
40
86
  /** What channel context reads of one fetched message. */
@@ -45,16 +91,28 @@ export interface ContextSourceMessage {
45
91
  bot: boolean;
46
92
  /** The assistant posted it: its bot user, or a webhook of its application such as an agent's voice. */
47
93
  own: boolean;
94
+ /** The webhook it was posted through, when that is a webhook of the assistant's application. */
95
+ webhookId?: string;
96
+ /** The files attached to it. */
97
+ attachments?: readonly ContextSourceAttachment[];
48
98
  /** When it was posted, in epoch milliseconds. */
49
99
  at: number;
50
100
  text: string;
51
101
  }
52
102
 
103
+ /** A file attached to a fetched message, as Discord describes it. */
104
+ export interface ContextSourceAttachment {
105
+ name: string;
106
+ contentType: string;
107
+ size: number;
108
+ url: string;
109
+ }
110
+
53
111
  /** The options that were given: a key set to undefined is the same as a missing one. */
54
- function defined(options: ChannelContextOptions): ChannelContextOptions {
112
+ function defined<T extends object>(options: T): Partial<T> {
55
113
  return Object.fromEntries(
56
114
  Object.entries(options).filter(([, value]) => value !== undefined),
57
- );
115
+ ) as Partial<T>;
58
116
  }
59
117
 
60
118
  function attachmentLabel(contentType: string | null | undefined): string {
@@ -96,8 +154,17 @@ export function contextSourceOf(
96
154
  message.author.username,
97
155
  bot: message.author.bot,
98
156
  own: message.author.id === botId || ownWebhook,
157
+ ...(ownWebhook && message.webhookId
158
+ ? { webhookId: message.webhookId }
159
+ : {}),
99
160
  at: message.createdTimestamp,
100
161
  text: parts.join(" "),
162
+ attachments: [...message.attachments.values()].map((file) => ({
163
+ name: file.name,
164
+ contentType: file.contentType ?? "",
165
+ size: file.size,
166
+ url: file.url,
167
+ })),
101
168
  };
102
169
  }
103
170
 
@@ -126,10 +193,14 @@ function similarity(a: string, b: string): number {
126
193
  return longest / longer.length;
127
194
  }
128
195
 
129
- /** Consecutive messages of one author at least `threshold` alike become the later one. */
196
+ /**
197
+ * Consecutive messages of one author at least `threshold` alike become the later one. A `foreign`
198
+ * message is one of a webhook that carries many people, so it also needs the same name.
199
+ */
130
200
  function merged(
131
201
  messages: readonly ContextSourceMessage[],
132
202
  threshold: number,
203
+ foreign: (message: ContextSourceMessage) => boolean,
133
204
  ): ContextSourceMessage[] {
134
205
  const out: ContextSourceMessage[] = [];
135
206
  for (const message of messages) {
@@ -137,6 +208,7 @@ function merged(
137
208
  if (
138
209
  last &&
139
210
  last.authorId === message.authorId &&
211
+ (!foreign(message) || last.authorName === message.authorName) &&
140
212
  similarity(last.text, message.text) >= threshold
141
213
  )
142
214
  out[out.length - 1] = message;
@@ -155,7 +227,9 @@ export interface ChannelContextSelection {
155
227
  seen: ReadonlySet<string>;
156
228
  /** The owners' user ids. */
157
229
  owners: readonly string[];
158
- settings: ChannelContextSettings;
230
+ /** What the selection reads of the settings; without `foreignWebhook` every webhook of the application is the assistant's. */
231
+ settings: Omit<ChannelContextSettings, "foreignWebhook" | "attachments"> &
232
+ Partial<Pick<ChannelContextSettings, "foreignWebhook">>;
159
233
  }
160
234
 
161
235
  /**
@@ -167,13 +241,26 @@ export function selectChannelContext(
167
241
  fetched: readonly ContextSourceMessage[],
168
242
  { seen, owners, settings }: ChannelContextSelection,
169
243
  ): { messages: ChannelContextMessage[]; window: string[] } {
170
- const ordered = fetched.toSorted((a, b) => a.at - b.at);
171
- const lastOwn = ordered.findLastIndex((message) => message.own);
244
+ // A webhook of the application that the host calls foreign speaks for other people, not for the
245
+ // assistant; one it calls "skip" speaks for no one: its posts are not in the channel as far as the
246
+ // window and the context go.
247
+ const verdict = (message: ContextSourceMessage) =>
248
+ message.webhookId === undefined
249
+ ? undefined
250
+ : settings.foreignWebhook?.(message.webhookId);
251
+ const ordered = fetched
252
+ .filter((message) => verdict(message) !== "skip")
253
+ .toSorted((a, b) => a.at - b.at);
254
+ const foreign = (message: ContextSourceMessage) => verdict(message) === true;
255
+ const assistants = (message: ContextSourceMessage) =>
256
+ message.own && !foreign(message);
257
+ const lastOwn = ordered.findLastIndex(assistants);
172
258
  const window = ordered.slice(lastOwn + 1);
173
259
  // Cut before comparing: the comparison is quadratic in the text lengths.
174
260
  const fresh = window
175
261
  .filter(
176
- (message) => !message.own && !seen.has(message.id) && message.text !== "",
262
+ (message) =>
263
+ !assistants(message) && !seen.has(message.id) && message.text !== "",
177
264
  )
178
265
  .map((message) => ({
179
266
  ...message,
@@ -182,7 +269,9 @@ export function selectChannelContext(
182
269
  message.bot ? settings.botMessageChars : settings.messageChars,
183
270
  ),
184
271
  }));
185
- const kept = merged(fresh, settings.similarity).slice(-settings.keep);
272
+ const kept = merged(fresh, settings.similarity, foreign).slice(
273
+ -settings.keep,
274
+ );
186
275
  return {
187
276
  window: window.map((message) => message.id),
188
277
  messages: kept.map((message) => ({
@@ -193,6 +282,10 @@ export function selectChannelContext(
193
282
  owner: !message.bot && owners.includes(message.authorId),
194
283
  text: message.text,
195
284
  at: new Date(message.at),
285
+ attachments: (message.attachments ?? []).map((file) => ({
286
+ ...file,
287
+ messageId: message.id,
288
+ })),
196
289
  })),
197
290
  };
198
291
  }
@@ -236,6 +329,21 @@ export function formatChannelContext(
236
329
  ].join("\n");
237
330
  }
238
331
 
332
+ /** The messages with a note in the text of each for an image that was asked for and not attached. */
333
+ function withSkipNotes(
334
+ messages: readonly ChannelContextMessage[],
335
+ skipped: readonly SkippedImage[],
336
+ ): ChannelContextMessage[] {
337
+ return messages.map((message) => {
338
+ const notes = skipped
339
+ .filter((image) => image.messageId === message.id)
340
+ .map((image) => `[image not attached: ${image.name}: ${image.reason}]`);
341
+ return notes.length === 0
342
+ ? { ...message }
343
+ : { ...message, text: [message.text, ...notes].join(" ") };
344
+ });
345
+ }
346
+
239
347
  /** How many delivered message ids are remembered per channel, and how many channels. */
240
348
  const SEEN_PER_CHANNEL = 500;
241
349
  const SEEN_CHANNELS = 1_000;
@@ -252,6 +360,8 @@ export interface ChannelContextReaderOptions {
252
360
  /** The host's settings; `false` turns channel context off for every claim. */
253
361
  settings: ChannelContextOptions | false;
254
362
  logger: Logger;
363
+ /** Fetches the images of messages; replaceable in tests. */
364
+ fetchImpl?: typeof fetch;
255
365
  }
256
366
 
257
367
  /**
@@ -280,11 +390,7 @@ export class ChannelContextReader {
280
390
  return undefined;
281
391
  const { surface, id: channelId } = parseChannelKey(message.channel);
282
392
  if (surface !== "discord") return undefined;
283
- const settings = {
284
- ...CHANNEL_CONTEXT_DEFAULTS,
285
- ...defined(host),
286
- ...defined(options),
287
- };
393
+ const settings = resolvedSettings(host, options);
288
394
  assertChannelContextSettings(settings);
289
395
  const seen = this.#seenIn(channelId);
290
396
  seen.add(message.messageId);
@@ -304,7 +410,13 @@ export class ChannelContextReader {
304
410
  for (const id of window) seen.add(id);
305
411
  this.#trim(seen);
306
412
  if (messages.length === 0) return undefined;
307
- return { messages, text: formatChannelContext(messages) };
413
+ const { images, skipped } = await this.#images(messages, settings);
414
+ const noted = withSkipNotes(messages, skipped);
415
+ return {
416
+ messages: noted,
417
+ text: formatChannelContext(noted),
418
+ ...(images.length > 0 ? { images } : {}),
419
+ };
308
420
  } catch (error) {
309
421
  logger.warn(
310
422
  { channel: message.channel, err: error },
@@ -314,6 +426,27 @@ export class ChannelContextReader {
314
426
  }
315
427
  }
316
428
 
429
+ /** The images of the kept messages the settings ask for, the newest first; none when they ask for none. */
430
+ async #images(
431
+ messages: readonly ChannelContextMessage[],
432
+ settings: ChannelContextSettings,
433
+ ) {
434
+ const { images: count, maxBytes } = settings.attachments;
435
+ if (count === 0) return { images: [], skipped: [] };
436
+ const candidates = messages
437
+ .toReversed()
438
+ .flatMap((message) => message.attachments ?? [])
439
+ .filter((file) => file.contentType.startsWith("image/"));
440
+ return contextImages(candidates, {
441
+ count,
442
+ maxBytes,
443
+ logger: this.#options.logger,
444
+ ...(this.#options.fetchImpl
445
+ ? { fetchImpl: this.#options.fetchImpl }
446
+ : {}),
447
+ });
448
+ }
449
+
317
450
  /** The owners' user ids; none, logged, when they cannot be read, since marking them is not worth failing for. */
318
451
  async #owners(): Promise<readonly string[]> {
319
452
  try {
@@ -0,0 +1,164 @@
1
+ import {
2
+ isModelImage,
3
+ prepareImageBytes,
4
+ sniffImageType,
5
+ } from "../attachments/image-prep.ts";
6
+ import type { ChannelContextAttachment } from "../contract/channel-context.ts";
7
+ import type { ModelImage } from "../domain/attachment.ts";
8
+ import type { Logger } from "../log.ts";
9
+
10
+ /** Why an image of a channel message was not attached to the turn; fixed words, never an error's text. */
11
+ export type SkippedReason =
12
+ | "unsupported type"
13
+ | "too large"
14
+ | "download failed"
15
+ | "could not be decoded";
16
+
17
+ export interface SkippedImage {
18
+ messageId: string;
19
+ name: string;
20
+ reason: SkippedReason;
21
+ }
22
+
23
+ /** How long one image may take to download. */
24
+ const FETCH_TIMEOUT_MS = 15_000;
25
+ /** How long all the images of one call may take to download, whatever their number. */
26
+ const DEADLINE_MS = 20_000;
27
+ /** How many images are downloaded at once. */
28
+ const CONCURRENCY = 2;
29
+
30
+ /** The bytes of a response, or undefined once they pass `max`. */
31
+ async function boundedBody(
32
+ response: Response,
33
+ max: number,
34
+ ): Promise<Uint8Array | undefined> {
35
+ if (!response.body) return new Uint8Array();
36
+ const reader = response.body.getReader();
37
+ const chunks: Uint8Array[] = [];
38
+ let size = 0;
39
+ try {
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done) break;
43
+ size += value.byteLength;
44
+ if (size > max) {
45
+ await reader.cancel();
46
+ return undefined;
47
+ }
48
+ chunks.push(value);
49
+ }
50
+ } finally {
51
+ reader.releaseLock();
52
+ }
53
+ const data = new Uint8Array(size);
54
+ let offset = 0;
55
+ for (const chunk of chunks) {
56
+ data.set(chunk, offset);
57
+ offset += chunk.byteLength;
58
+ }
59
+ return data;
60
+ }
61
+
62
+ /**
63
+ * Fetches up to `count` of the candidates' images, in the order given, and prepares them as a
64
+ * turn's own images are (see `prepareImageBytes`). A candidate that is not an image the model
65
+ * accepts (by its declared type, and by its bytes, which give the type it is sent as), is over
66
+ * `maxBytes` (by Discord's size or by what arrives), cannot be fetched, or cannot be decoded is
67
+ * skipped with its reason; a candidate of a type the model does not accept does not count toward
68
+ * `count`, and at most twice `count` are tried. Two are downloaded at once, and all of them share
69
+ * one deadline: the candidates that are still to be fetched when it passes are skipped as
70
+ * undownloadable. Bytes stay in memory and nothing about them is logged.
71
+ */
72
+ export async function contextImages(
73
+ candidates: readonly ChannelContextAttachment[],
74
+ options: {
75
+ count: number;
76
+ maxBytes: number;
77
+ fetchImpl?: typeof fetch;
78
+ logger: Logger;
79
+ /** Replaceable in tests; default 20 seconds. */
80
+ deadlineMs?: number;
81
+ },
82
+ ): Promise<{ images: ModelImage[]; skipped: SkippedImage[] }> {
83
+ const { count } = options;
84
+ const deadline = AbortSignal.timeout(options.deadlineMs ?? DEADLINE_MS);
85
+ const images: ModelImage[] = [];
86
+ const skipped: SkippedImage[] = [];
87
+ const skip = (file: ChannelContextAttachment, reason: SkippedReason) =>
88
+ skipped.push({ messageId: file.messageId, name: file.name, reason });
89
+ let tried = 0;
90
+ let next = 0;
91
+ while (images.length < count && next < candidates.length) {
92
+ // Never start more downloads than there are images still wanted, so none is wasted.
93
+ const batch: ChannelContextAttachment[] = [];
94
+ const room = Math.min(CONCURRENCY, count - images.length);
95
+ while (
96
+ batch.length < room &&
97
+ next < candidates.length &&
98
+ tried < count * 2
99
+ ) {
100
+ const file = candidates[next++] as ChannelContextAttachment;
101
+ if (
102
+ !isModelImage({ contentType: file.contentType }) ||
103
+ !file.url.startsWith("https://")
104
+ ) {
105
+ skip(file, "unsupported type");
106
+ continue;
107
+ }
108
+ tried++;
109
+ batch.push(file);
110
+ }
111
+ if (batch.length === 0) break;
112
+ const fetched = await Promise.all(
113
+ batch.map((file) => oneImage(file, options, deadline)),
114
+ );
115
+ for (const [index, result] of fetched.entries()) {
116
+ const file = batch[index] as ChannelContextAttachment;
117
+ if ("image" in result) images.push(result.image);
118
+ else skip(file, result.skipped);
119
+ }
120
+ }
121
+ return { images, skipped };
122
+ }
123
+
124
+ /** One candidate's image, or why it is skipped. */
125
+ async function oneImage(
126
+ file: ChannelContextAttachment,
127
+ options: {
128
+ maxBytes: number;
129
+ fetchImpl?: typeof fetch;
130
+ logger: Logger;
131
+ },
132
+ deadline: AbortSignal,
133
+ ): Promise<{ image: ModelImage } | { skipped: SkippedReason }> {
134
+ const { maxBytes, logger, fetchImpl = fetch } = options;
135
+ if (file.size > maxBytes) return { skipped: "too large" };
136
+ if (deadline.aborted) return { skipped: "download failed" };
137
+ let data: Uint8Array | undefined;
138
+ try {
139
+ const response = await fetchImpl(file.url, {
140
+ signal: AbortSignal.any([
141
+ deadline,
142
+ AbortSignal.timeout(FETCH_TIMEOUT_MS),
143
+ ]),
144
+ });
145
+ if (!response.ok) {
146
+ await response.body?.cancel();
147
+ return { skipped: "download failed" };
148
+ }
149
+ data = await boundedBody(response, maxBytes);
150
+ } catch (error) {
151
+ logger.warn({ err: error }, "channel context image not fetched");
152
+ return { skipped: "download failed" };
153
+ }
154
+ if (!data) return { skipped: "too large" };
155
+ // The bytes say what it is; Discord's type is a guess from the file's name.
156
+ const type = sniffImageType(data);
157
+ if (!type) return { skipped: "unsupported type" };
158
+ try {
159
+ return { image: await prepareImageBytes(data, type) };
160
+ } catch (error) {
161
+ logger.warn({ err: error }, "channel context image not decoded");
162
+ return { skipped: "could not be decoded" };
163
+ }
164
+ }
@@ -47,6 +47,8 @@ export function press(
47
47
  } = {},
48
48
  ) {
49
49
  const replies: string[] = [];
50
+ /** Everything a reply carried besides its text. */
51
+ const replied: { files?: unknown[]; flags?: unknown }[] = [];
50
52
  const updates: CardPayload[] = [];
51
53
  const modals: unknown[] = [];
52
54
  // SAFETY: the cards read only these members of an interaction.
@@ -65,8 +67,13 @@ export function press(
65
67
  },
66
68
  values: options.values ?? [],
67
69
  fields: { getTextInputValue: () => options.text ?? "" },
68
- reply: async (answer: { content: string }) => {
70
+ reply: async (answer: {
71
+ content: string;
72
+ files?: unknown[];
73
+ flags?: unknown;
74
+ }) => {
69
75
  replies.push(answer.content);
76
+ replied.push(answer);
70
77
  },
71
78
  update: async (payload: CardPayload) => {
72
79
  updates.push(payload);
@@ -76,7 +83,7 @@ export function press(
76
83
  },
77
84
  deferUpdate: async () => undefined,
78
85
  } as unknown as Interaction;
79
- return { interaction, replies, updates, modals };
86
+ return { interaction, replies, replied, updates, modals };
80
87
  }
81
88
 
82
89
  /** Lets a posted card settle. */