@astrofoundry/pi-astro 0.29.1 → 0.31.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.
@@ -1,3 +1,4 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { promises as fs } from "node:fs";
2
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
@@ -12,7 +13,9 @@ import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./comman
12
13
  import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
13
14
  import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
14
15
  import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
16
+ import { failureMessage, formatDailyReport, isDue, readReportState, writeReportState } from "./daily-report.ts";
15
17
  import { GatewayClient } from "./gateway.ts";
18
+ import { type CommandOption, IS_COMPONENTS_V2, KeyedQueue, MEDIA, MEDIA_COMMANDS, type MediaKind, type Outcome, addOutcome, applyOutcome, commandArgs, hasActiveButton, listMessage, mediaAccess, mediaPointer, parseAddButton, parseAddReport, parseMediaList, parseQueueState, queueLine } from "./media.ts";
16
19
  import { cancelledApprovalText, interruptedTaskText, readOpenWork, type OpenWork, type StopReason, writeOpenWork } from "./open-work.ts";
17
20
  import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
18
21
  import { DiscordRest, type MessageComponent } from "./rest.ts";
@@ -68,17 +71,31 @@ interface IncomingMessage {
68
71
  message_snapshots?: { message: QuotedMessage }[];
69
72
  }
70
73
 
71
- interface ComponentInteraction {
74
+ /** A slash command (type 2) or a component click (type 3) as the gateway delivers it. */
75
+ interface Interaction {
72
76
  id: string;
73
77
  token: string;
74
78
  type: number;
75
79
  channel_id?: string;
76
- message?: { id: string };
77
- data?: { custom_id?: string; component_type?: number };
80
+ message?: { id: string; components?: MessageComponent[] };
81
+ data?: { custom_id?: string; component_type?: number; name?: string; options?: CommandOption[] };
78
82
  member?: { user?: { id: string } };
79
83
  user?: { id: string };
80
84
  }
81
85
 
86
+ /** A media command or Add click still running; the token lets a shutdown answer it. */
87
+ interface MediaRun {
88
+ channelId: string;
89
+ token: string;
90
+ kind: "command" | "add";
91
+ }
92
+
93
+ /** Media list messages whose buttons changed, newest last; older ones fall back to the clicked message's own state. */
94
+ const MEDIA_MESSAGE_CACHE = 100;
95
+ /** Upper bound for one media wrapper call from Discord; a single add or list takes seconds. */
96
+ const MEDIA_TIMEOUT_MS = 3 * 60_000;
97
+ const EPHEMERAL = 64;
98
+
82
99
  interface ActiveTask {
83
100
  specialist: string;
84
101
  channelId: string;
@@ -121,8 +138,13 @@ class Bridge {
121
138
  private readonly queues = new Map<string, Promise<void>>();
122
139
  private readonly active = new Map<string, ActiveTask>();
123
140
  private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
141
+ private readonly mediaRuns = new Map<string, MediaRun>();
142
+ private readonly mediaClicks = new KeyedQueue();
143
+ private readonly mediaMessages = new Map<string, MessageComponent[]>();
124
144
  private gateway: GatewayClient | undefined;
125
145
  private stopping = false;
146
+ private reportRunning = false;
147
+ private reportTimer: NodeJS.Timeout | undefined;
126
148
  private readonly since = new Date().toISOString();
127
149
  private config: DiscordConfig;
128
150
  private conversations: ConversationState;
@@ -158,7 +180,10 @@ class Bridge {
158
180
  private openWork(): OpenWork {
159
181
  return {
160
182
  since: this.since,
161
- tasks: [...this.active.values()].map((t) => ({ specialist: t.specialist, channelId: t.channelId, messageId: t.messageId, ...(t.placeholder ? { placeholderId: t.placeholder } : {}) })),
183
+ tasks: [
184
+ ...[...this.active.values()].map((t) => ({ specialist: t.specialist, channelId: t.channelId, messageId: t.messageId, ...(t.placeholder ? { placeholderId: t.placeholder } : {}) })),
185
+ ...[...this.mediaRuns.values()].map((r) => ({ specialist: MEDIA, channelId: r.channelId })),
186
+ ],
162
187
  approvals: [...this.approvalMessages.entries()].map(([ticketId, a]) => ({ ticketId, channelId: a.channelId, messageId: a.messageId, userChannelId: a.userMessage.channelId, userMessageId: a.userMessage.messageId })),
163
188
  };
164
189
  }
@@ -181,11 +206,13 @@ class Bridge {
181
206
  }
182
207
  for (const t of work.tasks) {
183
208
  const text = interruptedTaskText(t.specialist, reason);
184
- edits.push((t.placeholderId ? this.rest.editMessage(t.channelId, t.placeholderId, text) : this.rest.createMessage(t.channelId, { content: text, replyTo: t.messageId })).catch(() => undefined));
209
+ edits.push((t.placeholderId ? this.rest.editMessage(t.channelId, t.placeholderId, text) : this.rest.createMessage(t.channelId, { content: text, ...(t.messageId ? { replyTo: t.messageId } : {}) })).catch(() => undefined));
210
+ const messageId = t.messageId;
211
+ if (!messageId) continue;
185
212
  edits.push(
186
213
  this.rest
187
- .removeOwnReaction(t.channelId, t.messageId, REACTION.running)
188
- .then(() => this.rest.addReaction(t.channelId, t.messageId, REACTION.failed))
214
+ .removeOwnReaction(t.channelId, messageId, REACTION.running)
215
+ .then(() => this.rest.addReaction(t.channelId, messageId, REACTION.failed))
189
216
  .catch(() => undefined),
190
217
  );
191
218
  }
@@ -196,11 +223,14 @@ class Bridge {
196
223
  async shutdown(): Promise<void> {
197
224
  if (this.stopping) return;
198
225
  this.stopping = true;
226
+ clearInterval(this.reportTimer);
199
227
  this.gateway?.stop();
200
228
  const work = this.openWork();
201
229
  for (const task of this.active.values()) task.abort?.abort();
202
230
  if (work.tasks.length > 0 || work.approvals.length > 0) log(`shutdown: cancelling ${work.approvals.length} approval(s), interrupting ${work.tasks.length} task(s)`);
203
- await this.closeOpen(work, "restart");
231
+ // Media runs are answered through their own interaction instead of a new channel message.
232
+ const answers = [...this.mediaRuns.values()].map((r) => this.answerInterruptedMedia(r).catch(() => undefined));
233
+ await Promise.all([this.closeOpen({ ...work, tasks: work.tasks.filter((t) => t.messageId !== undefined) }, "restart"), ...answers]);
204
234
  this.approvals.close();
205
235
  writeOpenWork({ since: this.since, tasks: [], approvals: [] });
206
236
  }
@@ -225,13 +255,18 @@ class Bridge {
225
255
  });
226
256
  gateway.start();
227
257
  this.gateway = gateway;
258
+ void this.registerMediaCommands();
259
+ // Checked every minute; the report itself decides whether today's is due.
260
+ this.reportTimer = setInterval(() => void this.maybeDailyReport(), 60_000);
261
+ this.reportTimer.unref();
262
+ void this.maybeDailyReport();
228
263
  log(`connected as ${me.username} (${me.id}); channels ${Object.keys(this.config.channels).join(", ")}; specialists ${this.specialists().names.join(", ")}`);
229
264
  }
230
265
 
231
266
  private async dispatch(event: string, data: unknown): Promise<void> {
232
267
  try {
233
268
  if (event === "MESSAGE_CREATE") await this.onMessage(data as IncomingMessage);
234
- else if (event === "INTERACTION_CREATE") await this.onInteraction(data as ComponentInteraction);
269
+ else if (event === "INTERACTION_CREATE") await this.onInteraction(data as Interaction);
235
270
  } catch (err) {
236
271
  log(`${event} failed: ${err instanceof Error ? err.message : String(err)}`);
237
272
  }
@@ -308,7 +343,8 @@ class Bridge {
308
343
  );
309
344
  if (!conversation.resumed) this.conversations = startConversation(this.conversations, conversation.key, conversation.id, now);
310
345
  saveState(this.conversations);
311
- const quotes = this.quotesFor(message, conversation.resumed);
346
+ const known = conversation.resumed ? (this.conversations.conversations[conversation.key]?.messageIds ?? []) : [];
347
+ const quotes = this.quotesFor(message, (id) => known.includes(id));
312
348
  const images = imageAttachments([message, ...quotes.map((q) => q.message)]);
313
349
  const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: now, conversation, images };
314
350
  const taskText = withQuotes(command.task, quotes);
@@ -317,14 +353,194 @@ class Bridge {
317
353
  this.queues.set(command.specialist, next);
318
354
  }
319
355
 
320
- /** Replied-to and forwarded messages to show the specialist; the bot's own answer only when the conversation holding it is not resumed. */
321
- private quotesFor(message: IncomingMessage, resumed: boolean): Quote[] {
356
+ /**
357
+ * Runs the Arcane version report through the specialist wrapper, without a
358
+ * model, and posts it with the prefix. One message per local date: the date
359
+ * is recorded before the post, so a crash during delivery never repeats it.
360
+ */
361
+ private async maybeDailyReport(): Promise<void> {
362
+ if (this.reportRunning || this.stopping) return;
363
+ this.refreshConfig();
364
+ const settings = this.config.dailyReport;
365
+ if (!settings) return;
366
+ const { due, date } = isDue(settings, readReportState(), new Date());
367
+ if (!due) return;
368
+ const gate = loadSpecialists().config;
369
+ if (!gate) {
370
+ log("daily report: specialists.json missing; no report");
371
+ return;
372
+ }
373
+ this.reportRunning = true;
374
+ try {
375
+ log(`daily report ${date}: running arcane update-report`);
376
+ let message: { content: string; attachment?: string };
377
+ try {
378
+ const stdout = await this.runEntry("arcane", "daily-report", ["update-report"], 15 * 60_000);
379
+ message = formatDailyReport(JSON.parse(stdout), settings.timeZone);
380
+ } catch (err) {
381
+ log(`daily report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
382
+ message = { content: failureMessage() };
383
+ }
384
+ if (this.stopping) return;
385
+ writeReportState({ date, status: "sending" });
386
+ const posted = await this.rest.createMessage(settings.channelId, {
387
+ content: message.content,
388
+ ...(message.attachment ? { file: { name: `arcane-daily-report-${date}.txt`, content: message.attachment } } : {}),
389
+ });
390
+ writeReportState({ date, status: message.content === failureMessage() ? "failed" : "sent", messageId: posted.id });
391
+ log(`daily report ${date}: posted ${posted.id}`);
392
+ } catch (err) {
393
+ log(`daily report ${date}: delivery failed: ${err instanceof Error ? err.message : String(err)}`);
394
+ } finally {
395
+ this.reportRunning = false;
396
+ }
397
+ }
398
+
399
+ /** Runs one specialist wrapper through the sudo entry point, without a model; rejects with the wrapper's stderr. */
400
+ private runEntry(service: string, caller: string, args: string[], timeoutMs: number): Promise<string> {
401
+ const gate = loadSpecialists().config;
402
+ if (!gate) return Promise.reject(new Error("specialists.json is missing"));
403
+ return new Promise<string>((resolve, reject) =>
404
+ execFile("/usr/bin/sudo", ["-n", "-u", gate.user, "-H", gate.entry, service, "--caller", caller, "--", ...args], { timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 }, (err, out, errOut) =>
405
+ err ? reject(new Error(errOut.trim() || err.message)) : resolve(out),
406
+ ),
407
+ );
408
+ }
409
+
410
+ /** Registers `/trending`, `/search`, `/downloads` when the media specialist is configured on this host. */
411
+ private async registerMediaCommands(): Promise<void> {
412
+ if (!this.specialists().names.includes(MEDIA)) return;
413
+ try {
414
+ await this.rest.overwriteGlobalCommands(this.config.applicationId, MEDIA_COMMANDS);
415
+ log(`media: registered ${MEDIA_COMMANDS.map((c) => `/${c.name}`).join(", ")}`);
416
+ } catch (err) {
417
+ log(`media: command registration failed: ${err instanceof Error ? err.message : String(err)}`);
418
+ }
419
+ }
420
+
421
+ private async answerInterruptedMedia(run: MediaRun): Promise<void> {
422
+ const text = interruptedTaskText(MEDIA, "restart");
423
+ if (run.kind === "command") await this.rest.editOriginal(this.config.applicationId, run.token, { content: text });
424
+ else await this.rest.followUp(this.config.applicationId, run.token, { content: text, flags: EPHEMERAL });
425
+ }
426
+
427
+ /** Whether the user may run media commands where the interaction came from; answers privately when not. */
428
+ private async mediaAllowed(interaction: Interaction, userId: string): Promise<boolean> {
429
+ this.refreshConfig();
430
+ const names = this.specialists().names;
431
+ const channel = interaction.channel_id ? await this.settingsFor(interaction.channel_id) : null;
432
+ const access = mediaAccess(this.config, channel?.settings ?? null, names, userId);
433
+ if (access === "ok") return true;
434
+ await this.rest.ephemeralReply(interaction.id, interaction.token, access === "channel" ? mediaPointer(this.config, names) : "You may not use the media specialist.");
435
+ return false;
436
+ }
437
+
438
+ private trackMedia(interaction: Interaction, kind: MediaRun["kind"]): void {
439
+ this.mediaRuns.set(interaction.id, { channelId: interaction.channel_id ?? "", token: interaction.token, kind });
440
+ this.saveOpenWork();
441
+ }
442
+
443
+ private untrackMedia(interaction: Interaction): void {
444
+ if (this.stopping) return;
445
+ this.mediaRuns.delete(interaction.id);
446
+ this.saveOpenWork();
447
+ }
448
+
449
+ /** `/trending`, `/search`, `/downloads`: the wrapper's JSON straight to Discord, no model in between. */
450
+ private async onMediaCommand(interaction: Interaction): Promise<void> {
451
+ const name = interaction.data?.name ?? "";
452
+ const command = commandArgs(name, interaction.data?.options ?? []);
453
+ if (!command) return;
454
+ const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
455
+ if (this.stopping) {
456
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "The bridge is restarting; try again in a minute.");
457
+ return;
458
+ }
459
+ if (!(await this.mediaAllowed(interaction, userId))) return;
460
+ await this.rest.deferReply(interaction.id, interaction.token);
461
+ this.trackMedia(interaction, "command");
462
+ log(`media /${name} for ${userId}: ${command.args.join(" ")}`);
463
+ const app = this.config.applicationId;
464
+ try {
465
+ const output: unknown = JSON.parse(await this.runEntry(MEDIA, `discord-${userId}`, command.args, MEDIA_TIMEOUT_MS));
466
+ if (command.view === "downloads") {
467
+ const paused = parseQueueState(output);
468
+ if (paused === null) throw new Error("the media wrapper returned no queue state");
469
+ await this.rest.editOriginal(app, interaction.token, { content: queueLine(paused) });
470
+ return;
471
+ }
472
+ const list = parseMediaList(output);
473
+ if (!list) throw new Error("the media wrapper returned no list");
474
+ const kinds: MediaKind[] = command.only === "movies" ? ["movie"] : command.only === "series" ? ["series"] : ["movie", "series"];
475
+ const query = command.view === "search" ? command.query : undefined;
476
+ const messages = kinds.map((kind, i) => listMessage(kind, kind === "movie" ? list.movies : list.series, command.view, query, i === kinds.length - 1 ? list.warnings : []));
477
+ await this.rest.editOriginal(app, interaction.token, { content: null, flags: IS_COMPONENTS_V2, components: messages[0] });
478
+ for (const components of messages.slice(1)) await this.rest.followUp(app, interaction.token, { flags: IS_COMPONENTS_V2, components });
479
+ } catch (err) {
480
+ const reason = (err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 300);
481
+ log(`media /${name} failed: ${reason}`);
482
+ await this.rest.editOriginal(app, interaction.token, { content: `❌ /${name} failed: ${reason}` }).catch(() => undefined);
483
+ } finally {
484
+ this.untrackMedia(interaction);
485
+ }
486
+ }
487
+
488
+ private rememberMediaMessage(messageId: string, components: MessageComponent[]): void {
489
+ this.mediaMessages.delete(messageId);
490
+ this.mediaMessages.set(messageId, components);
491
+ while (this.mediaMessages.size > MEDIA_MESSAGE_CACHE) this.mediaMessages.delete(this.mediaMessages.keys().next().value as string);
492
+ }
493
+
494
+ /**
495
+ * An Add click: one add for that ID, then only that button changes to the
496
+ * outcome. Clicks on one message run in order, each from the state the last
497
+ * one left, so a double click adds once and no edit overwrites another.
498
+ */
499
+ private async onMediaButton(interaction: Interaction, customId: string): Promise<void> {
500
+ const target = parseAddButton(customId);
501
+ const message = interaction.message;
502
+ if (!target || !message) {
503
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "This button is no longer active.");
504
+ return;
505
+ }
506
+ const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
507
+ if (this.stopping) {
508
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "The bridge is restarting; try again in a minute.");
509
+ return;
510
+ }
511
+ if (!(await this.mediaAllowed(interaction, userId))) return;
512
+ await this.rest.deferUpdate(interaction.id, interaction.token);
513
+ this.trackMedia(interaction, "add");
514
+ const app = this.config.applicationId;
515
+ await this.mediaClicks
516
+ .run(message.id, async () => {
517
+ const current = this.mediaMessages.get(message.id) ?? message.components ?? [];
518
+ if (!hasActiveButton(current, customId)) return;
519
+ let outcome: Outcome;
520
+ try {
521
+ const stdout = await this.runEntry(MEDIA, `discord-${userId}`, [target.kind === "movie" ? "add-movies" : "add-series", target.imdbId], MEDIA_TIMEOUT_MS);
522
+ outcome = addOutcome(parseAddReport(JSON.parse(stdout)), target.kind, target.imdbId);
523
+ } catch (err) {
524
+ outcome = addOutcome(null, target.kind, target.imdbId, (err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 300));
525
+ }
526
+ const next = applyOutcome(current, customId, outcome);
527
+ await this.rest.editOriginal(app, interaction.token, { components: next });
528
+ this.rememberMediaMessage(message.id, next);
529
+ log(`media add ${target.imdbId} for ${userId}: ${outcome.label}`);
530
+ if (outcome.note) await this.rest.followUp(app, interaction.token, { content: outcome.note, flags: EPHEMERAL });
531
+ })
532
+ .catch((err) => log(`media add ${target.imdbId} failed: ${err instanceof Error ? err.message : String(err)}`))
533
+ .finally(() => this.untrackMedia(interaction));
534
+ }
535
+
536
+ /** Replied-to and forwarded messages to show the specialist; a bot message only when the resumed conversation does not already hold it. */
537
+ private quotesFor(message: IncomingMessage, inContext: (id: string) => boolean): Quote[] {
322
538
  const quotes: Quote[] = [];
323
539
  const replied = message.referenced_message;
324
540
  if (replied) {
325
541
  const mine = replied.author?.id === this.botUserId;
326
542
  if (!mine) quotes.push({ label: `Replied-to message from ${replied.author?.username ?? replied.author?.id ?? "an unknown author"}`, message: replied });
327
- else if (!resumed) quotes.push({ label: "My earlier answer, which this message replies to", message: replied });
543
+ else if (!(replied.id && inContext(replied.id))) quotes.push({ label: "My earlier message, which this message replies to", message: replied });
328
544
  }
329
545
  for (const snapshot of message.message_snapshots ?? []) quotes.push({ label: "Forwarded message", message: snapshot.message });
330
546
  return quotes;
@@ -461,9 +677,18 @@ class Bridge {
461
677
  await this.rest.removeOwnReaction(posted.userMessage.channelId, posted.userMessage.messageId, REACTION.waiting).catch(() => undefined);
462
678
  }
463
679
 
464
- private async onInteraction(interaction: ComponentInteraction): Promise<void> {
680
+ private async onInteraction(interaction: Interaction): Promise<void> {
681
+ if (interaction.type === 2) {
682
+ await this.onMediaCommand(interaction);
683
+ return;
684
+ }
465
685
  if (interaction.type !== 3) return;
466
- const match = /^apr:([0-9a-f-]{36}):(approve|deny)$/.exec(interaction.data?.custom_id ?? "");
686
+ const customId = interaction.data?.custom_id ?? "";
687
+ if (customId.startsWith("media:")) {
688
+ await this.onMediaButton(interaction, customId);
689
+ return;
690
+ }
691
+ const match = /^apr:([0-9a-f-]{36}):(approve|deny)$/.exec(customId);
467
692
  if (!match) return;
468
693
  const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
469
694
  this.refreshConfig();
@@ -28,6 +28,13 @@ describe("open work record", () => {
28
28
  expect(readOpenWork()).toBeNull();
29
29
  });
30
30
 
31
+ it("records a slash command or button click, which has no user message", () => {
32
+ const work = { since: "t", tasks: [{ specialist: "media", channelId: "1" }], approvals: [] };
33
+ writeOpenWork(work);
34
+ expect(existsSync(busyPath())).toBe(true);
35
+ expect(readOpenWork()).toEqual(work);
36
+ });
37
+
31
38
  it("says why an approval was cancelled and a task interrupted", () => {
32
39
  expect(cancelledApprovalText("restart")).toMatch(/bridge restarted before a decision, so nothing was run/);
33
40
  expect(cancelledApprovalText("crash")).toMatch(/stopped unexpectedly/);
@@ -0,0 +1,243 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { helpText } from "./commands.ts";
3
+ import { validateConfig } from "./config.ts";
4
+ import {
5
+ IS_COMPONENTS_V2,
6
+ KeyedQueue,
7
+ LIST_ENTRIES,
8
+ MEDIA_COMMANDS,
9
+ type MediaEntry,
10
+ addOutcome,
11
+ applyOutcome,
12
+ commandArgs,
13
+ countComponents,
14
+ entryButton,
15
+ entryLine,
16
+ hasActiveButton,
17
+ listMessage,
18
+ mediaAccess,
19
+ mediaPointer,
20
+ parseAddButton,
21
+ parseAddReport,
22
+ parseMediaList,
23
+ parseQueueState,
24
+ queueLine,
25
+ } from "./media.ts";
26
+ import { DiscordRest, type MessageComponent } from "./rest.ts";
27
+
28
+ const OWNER = "123456789012345678";
29
+ const FRIEND = "223456789012345678";
30
+ const STRANGER = "323456789012345678";
31
+ const MEDIA_CHANNEL = "423456789012345678";
32
+ const ADMIN_CHANNEL = "523456789012345678";
33
+ const OTHER_CHANNEL = "623456789012345678";
34
+
35
+ function entry(rank: number, extra: Partial<MediaEntry> = {}): MediaEntry {
36
+ return { rank, title: `Title ${rank}`, year: 2026, imdb_rating: "7.1", imdb_id: `tt${1000 + rank}`, in_library: false, popularity: 100.4, ...extra };
37
+ }
38
+
39
+ describe("media slash commands", () => {
40
+ it("declares valid commands", () => {
41
+ for (const command of MEDIA_COMMANDS) {
42
+ expect(command.name).toMatch(/^[-_\p{L}\p{N}]{1,32}$/u);
43
+ expect(command.name).toBe(command.name.toLowerCase());
44
+ expect(command.description.length).toBeLessThanOrEqual(100);
45
+ const required = command.options.map((o) => "required" in o && o.required === true);
46
+ expect(required.indexOf(false) === -1 || required.lastIndexOf(true) < required.indexOf(false)).toBe(true);
47
+ for (const option of command.options) {
48
+ expect(option.name).toMatch(/^[-_\p{L}\p{N}]{1,32}$/u);
49
+ expect(option.description.length).toBeLessThanOrEqual(100);
50
+ }
51
+ }
52
+ });
53
+
54
+ it("maps options to wrapper arguments", () => {
55
+ expect(commandArgs("trending", [])).toEqual({ view: "trending", args: ["trending", "--max-entries", "10"] });
56
+ expect(commandArgs("trending", [{ name: "type", type: 3, value: "series" }, { name: "min_rating", type: 10, value: 0 }, { name: "count", type: 4, value: 5 }])).toEqual({
57
+ view: "trending",
58
+ args: ["trending", "--max-entries", "5", "--type", "series", "--min-rating", "0"],
59
+ only: "series",
60
+ });
61
+ expect(commandArgs("trending", [{ name: "count", type: 4, value: 50 }])?.args).toEqual(["trending", "--max-entries", "10"]);
62
+ expect(commandArgs("search", [{ name: "title", type: 3, value: " dune " }])).toEqual({ view: "search", args: ["search", "dune", "--max-entries", "10"], query: "dune" });
63
+ expect(commandArgs("search", [{ name: "title", type: 3, value: " " }])).toBeNull();
64
+ expect(commandArgs("downloads", [{ name: "action", type: 3, value: "pause" }])).toEqual({ view: "downloads", args: ["sabnzbd", "pause"] });
65
+ expect(commandArgs("downloads", [{ name: "action", type: 3, value: "delete" }])).toBeNull();
66
+ expect(commandArgs("ban", [])).toBeNull();
67
+ });
68
+
69
+ it("reads the wrapper's JSON defensively", () => {
70
+ expect(parseMediaList({ movies: [entry(1)], series: [], warnings: ["Radarr unreachable", 3] })).toEqual({ movies: [entry(1)], series: [], warnings: ["Radarr unreachable"] });
71
+ expect(parseMediaList({ movies: [{ rank: "1" }], series: [] })).toBeNull();
72
+ expect(parseMediaList({ results: [] })).toBeNull();
73
+ expect(parseAddReport({ results: [{ imdb_id: "tt1", title: "X", status: "added", detail: null }, { bad: 1 }], sabnzbd_paused: true })).toEqual({ results: [{ imdb_id: "tt1", title: "X", status: "added", detail: null }], sabnzbd_paused: true });
74
+ expect(parseAddReport({ results: [], sabnzbd_paused: "no" })).toEqual({ results: [], sabnzbd_paused: null });
75
+ expect(parseAddReport(null)).toBeNull();
76
+ expect(parseQueueState({ sabnzbd_paused: false })).toBe(false);
77
+ expect(parseQueueState({})).toBeNull();
78
+ expect(queueLine(true)).toBe("SABnzbd paused: yes");
79
+ });
80
+ });
81
+
82
+ describe("media list layout", () => {
83
+ it("writes trendarr's Discord line without the library flag", () => {
84
+ expect(entryLine(entry(1, { title: "Hope", imdb_rating: "6.9", popularity: 367.6, imdb_id: "tt1" }), "trending")).toBe("1. [Hope (2026)](<https://www.imdb.com/title/tt1/>) — 6.9 · 🔥368");
85
+ expect(entryLine(entry(2, { title: "Masterplan", imdb_rating: null, popularity: 222 }), "trending")).toBe("2. [Masterplan (2026)](<https://www.imdb.com/title/tt1002/>) — 🔥222");
86
+ expect(entryLine(entry(3, { title: "A*B_[x]", year: null, imdb_id: null }), "search")).toBe("3. A\\*B\\_\\[x\\] — 7.1");
87
+ expect(entryLine(entry(4, { imdb_rating: null }), "search")).toBe("4. [Title 4 (2026)](<https://www.imdb.com/title/tt1004/>)");
88
+ });
89
+
90
+ it("puts an Add, In library, or No IMDb ID button beside each entry", () => {
91
+ expect(entryButton("movie", entry(1))).toEqual({ type: 2, style: 1, label: "Add", custom_id: "media:add:movie:tt1001:1" });
92
+ expect(entryButton("series", entry(2, { in_library: null }))).toMatchObject({ label: "Add", custom_id: "media:add:series:tt1002:2" });
93
+ expect(entryButton("movie", entry(3, { in_library: true }))).toMatchObject({ label: "In library", disabled: true, custom_id: "media:lib:movie:tt1003:3" });
94
+ expect(entryButton("movie", entry(4, { imdb_id: null }))).toMatchObject({ label: "No IMDb ID", disabled: true });
95
+ expect(entryButton("movie", entry(5, { imdb_id: "nm123" }))).toMatchObject({ label: "No IMDb ID", disabled: true });
96
+ });
97
+
98
+ it("builds one message per list within Discord's limits", () => {
99
+ const many = Array.from({ length: 14 }, (_, i) => entry(i + 1));
100
+ const message = listMessage("movie", many, "trending", undefined, ["Radarr unreachable — in_library unknown"]);
101
+ expect(message[0]).toEqual({ type: 10, content: "**🎬 Movies** · trending this week" });
102
+ expect(message.filter((c) => c.type === 9)).toHaveLength(LIST_ENTRIES);
103
+ expect(countComponents(message)).toBe(1 + LIST_ENTRIES * 3 + 1);
104
+ expect(countComponents(message)).toBeLessThanOrEqual(40);
105
+ expect(message.at(-1)).toEqual({ type: 10, content: "-# ⚠️ Radarr unreachable — in_library unknown" });
106
+ const ids = message.flatMap((c) => (c.accessory?.custom_id ? [c.accessory.custom_id] : []));
107
+ expect(new Set(ids).size).toBe(ids.length);
108
+ expect(ids.every((id) => id.length <= 100)).toBe(true);
109
+ expect(listMessage("series", [], "search", "x_y", [])).toEqual([{ type: 10, content: "**📺 Series** · search “x\\_y”" }, { type: 10, content: "_none_" }]);
110
+ expect(IS_COMPONENTS_V2).toBe(32768);
111
+ });
112
+ });
113
+
114
+ describe("media add buttons", () => {
115
+ const message: MessageComponent[] = [
116
+ { type: 10, id: 1, content: "**🎬 Movies** · trending this week" },
117
+ { type: 9, id: 2, components: [{ type: 10, id: 3, content: "1. Hope" }], accessory: { type: 2, id: 4, style: 1, label: "Add", custom_id: "media:add:movie:tt1:1" } },
118
+ { type: 9, id: 5, components: [{ type: 10, id: 6, content: "2. Other" }], accessory: { type: 2, id: 7, style: 1, label: "Add", custom_id: "media:add:movie:tt2:2" } },
119
+ ];
120
+
121
+ it("parses only Add buttons", () => {
122
+ expect(parseAddButton("media:add:movie:tt1:1")).toEqual({ kind: "movie", imdbId: "tt1" });
123
+ expect(parseAddButton("media:add:series:tt0903747:10")).toEqual({ kind: "series", imdbId: "tt0903747" });
124
+ expect(parseAddButton("media:done:movie:tt1:1")).toBeNull();
125
+ expect(parseAddButton("media:add:movie:tt1;rm:1")).toBeNull();
126
+ expect(parseAddButton("media:add:music:tt1:1")).toBeNull();
127
+ });
128
+
129
+ it("turns each add result into a button state and a private note", () => {
130
+ const report = (status: string, paused: boolean | null = false, detail: string | null = null) => ({ results: [{ imdb_id: "tt1", title: "Hope", status, detail }], sabnzbd_paused: paused });
131
+ expect(addOutcome(report("added"), "movie", "tt1")).toEqual({ label: "Added", style: 3, disabled: true });
132
+ expect(addOutcome(report("added", true), "movie", "tt1").note).toMatch(/downloads are paused/);
133
+ expect(addOutcome(report("already_exists"), "movie", "tt1")).toEqual({ label: "In library", style: 2, disabled: true });
134
+ expect(addOutcome(report("not_found"), "series", "tt1")).toMatchObject({ label: "Not found", disabled: true, note: expect.stringMatching(/Sonarr/) });
135
+ expect(addOutcome(report("error", false, "quality profile missing"), "movie", "tt1")).toMatchObject({ label: "Retry", disabled: false, note: "Not added: quality profile missing." });
136
+ expect(addOutcome({ results: [], sabnzbd_paused: null }, "movie", "tt1")).toMatchObject({ label: "Retry", disabled: false, note: "Not added: no result for this ID." });
137
+ expect(addOutcome(null, "movie", "tt1", "media: secret RADARR_API_KEY for media is missing")).toMatchObject({ label: "Retry", disabled: false, note: expect.stringMatching(/RADARR_API_KEY/) });
138
+ });
139
+
140
+ it("replaces only the clicked button and keeps component ids", () => {
141
+ expect(hasActiveButton(message, "media:add:movie:tt1:1")).toBe(true);
142
+ const next = applyOutcome(message, "media:add:movie:tt1:1", { label: "Added", style: 3, disabled: true });
143
+ expect(next[1].accessory).toEqual({ type: 2, id: 4, style: 3, label: "Added", custom_id: "media:done:movie:tt1:1", disabled: true });
144
+ expect(next[2]).toEqual(message[2]);
145
+ expect(next[0]).toEqual(message[0]);
146
+ expect(hasActiveButton(next, "media:add:movie:tt1:1")).toBe(false);
147
+ expect(hasActiveButton(next, "media:add:movie:tt2:2")).toBe(true);
148
+ const retry = applyOutcome(message, "media:add:movie:tt2:2", { label: "Retry", style: 4, disabled: false });
149
+ expect(retry[2].accessory).toMatchObject({ label: "Retry", custom_id: "media:add:movie:tt2:2", disabled: false });
150
+ expect(hasActiveButton(retry, "media:add:movie:tt2:2")).toBe(true);
151
+ expect(message[1].accessory?.label).toBe("Add");
152
+ });
153
+ });
154
+
155
+ describe("media access", () => {
156
+ const config = validateConfig(
157
+ {
158
+ applicationId: OWNER,
159
+ guildId: OWNER,
160
+ channels: { [ADMIN_CHANNEL]: { trigger: "always", specialists: "*" }, [MEDIA_CHANNEL]: { trigger: "always", specialists: ["media"] }, [OTHER_CHANNEL]: { trigger: "always", specialists: ["arcane"] } },
161
+ adminChannelId: ADMIN_CHANNEL,
162
+ owners: [OWNER],
163
+ access: { media: [FRIEND] },
164
+ approvalTimeoutMinutes: 30,
165
+ },
166
+ [],
167
+ )!;
168
+ const names = ["arcane", "media"];
169
+
170
+ it("answers owners and access.media users in channels with the media specialist", () => {
171
+ expect(mediaAccess(config, config.channels[MEDIA_CHANNEL], names, OWNER)).toBe("ok");
172
+ expect(mediaAccess(config, config.channels[MEDIA_CHANNEL], names, FRIEND)).toBe("ok");
173
+ expect(mediaAccess(config, config.channels[MEDIA_CHANNEL], names, STRANGER)).toBe("user");
174
+ expect(mediaAccess(config, config.channels[OTHER_CHANNEL], names, OWNER)).toBe("channel");
175
+ expect(mediaAccess(config, null, names, OWNER)).toBe("channel");
176
+ expect(mediaAccess(config, config.channels[MEDIA_CHANNEL], ["arcane"], OWNER)).toBe("channel");
177
+ expect(mediaPointer(config, names)).toBe(`Media commands answer in <#${ADMIN_CHANNEL}>, <#${MEDIA_CHANNEL}>.`);
178
+ expect(mediaPointer(config, ["arcane"])).toBe("No channel has the media specialist yet.");
179
+ });
180
+
181
+ it("mentions the slash commands in help where media may be used", () => {
182
+ expect(helpText(["media"], ["media"], false, "always", false)).toContain("/trending");
183
+ expect(helpText(["arcane"], ["arcane"], true, "always", false)).not.toContain("/trending");
184
+ });
185
+ });
186
+
187
+ describe("keyed queue", () => {
188
+ it("runs tasks of one key in order and keeps going after a failure", async () => {
189
+ const queue = new KeyedQueue();
190
+ const order: string[] = [];
191
+ let release: () => void = () => undefined;
192
+ const gate = new Promise<void>((resolve) => {
193
+ release = resolve;
194
+ });
195
+ const first = queue.run("m1", async () => {
196
+ await gate;
197
+ order.push("first");
198
+ throw new Error("boom");
199
+ });
200
+ const second = queue.run("m1", async () => {
201
+ order.push("second");
202
+ });
203
+ const other = queue.run("m2", async () => {
204
+ order.push("other");
205
+ });
206
+ await other;
207
+ expect(order).toEqual(["other"]);
208
+ release();
209
+ await expect(first).rejects.toThrow("boom");
210
+ await second;
211
+ expect(order).toEqual(["other", "first", "second"]);
212
+ await new Promise((resolve) => setTimeout(resolve, 0));
213
+ expect(queue.size).toBe(0);
214
+ });
215
+ });
216
+
217
+ describe("media rest calls", () => {
218
+ it("defers, edits the original response, follows up, and registers commands", async () => {
219
+ const calls: { url: string; init: RequestInit }[] = [];
220
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
221
+ calls.push({ url: String(url), init: init ?? {} });
222
+ return new Response(JSON.stringify({ id: "m1", channel_id: "c1" }), { status: 200 });
223
+ }) as unknown as typeof fetch;
224
+ const rest = new DiscordRest("tok", fetchImpl);
225
+ await rest.deferReply("i1", "t1");
226
+ await rest.deferUpdate("i2", "t2");
227
+ await rest.editOriginal("app", "t1", { content: null, flags: IS_COMPONENTS_V2, components: [{ type: 10, content: "x" }] });
228
+ await rest.followUp("app", "t1", { content: "note", flags: 64 });
229
+ await rest.overwriteGlobalCommands("app", MEDIA_COMMANDS);
230
+ expect(calls.map((c) => `${c.init.method} ${c.url}`)).toEqual([
231
+ "POST https://discord.com/api/v10/interactions/i1/t1/callback",
232
+ "POST https://discord.com/api/v10/interactions/i2/t2/callback",
233
+ "PATCH https://discord.com/api/v10/webhooks/app/t1/messages/@original",
234
+ "POST https://discord.com/api/v10/webhooks/app/t1",
235
+ "PUT https://discord.com/api/v10/applications/app/commands",
236
+ ]);
237
+ expect(JSON.parse(calls[0].init.body as string)).toEqual({ type: 5 });
238
+ expect(JSON.parse(calls[1].init.body as string)).toEqual({ type: 6 });
239
+ expect(JSON.parse(calls[2].init.body as string)).toEqual({ content: null, flags: 32768, components: [{ type: 10, content: "x" }], allowed_mentions: { parse: [] } });
240
+ expect(JSON.parse(calls[3].init.body as string)).toEqual({ content: "note", flags: 64, allowed_mentions: { parse: [] } });
241
+ expect((JSON.parse(calls[4].init.body as string) as { name: string }[]).map((c) => c.name)).toEqual(["trending", "search", "downloads"]);
242
+ });
243
+ });