@astrofoundry/pi-astro 0.30.0 โ†’ 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @astrofoundry/pi-astro
2
2
 
3
- Personal customizations for the [pi coding agent](https://github.com/earendil-works/pi): 25 subagents (8 general-purpose, 8 Flourish-pipeline, 9 homelab specialists) run by the bundled `astro-subagents` extension, a `grimoire` docs tool, a `caveman` compressed-output toggle, an `astro-footer` status bar, 14 skills, and an `astro` theme.
3
+ Personal customizations for the [pi coding agent](https://github.com/earendil-works/pi): 26 subagents (8 general-purpose, 8 Flourish-pipeline, 10 homelab specialists) run by the bundled `astro-subagents` extension, a `grimoire` docs tool, a `caveman` compressed-output toggle, an `astro-footer` status bar, 15 skills, and an `astro` theme.
4
4
 
5
5
  ## Full setup
6
6
 
@@ -68,8 +68,8 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
68
68
  - `multi-edit` - registers the enhanced `edit` tool
69
69
  - `gemini-image` - registers `gemini_image` tool (requires a Gemini API key; prompts and saves on first use)
70
70
  - `security-guard` - blocks/prompts destructive bash commands and sensitive file access; configure at `~/.pi/agent/security-guard.json` (example written on first run); `/security-guard status|reload|test`
71
- - `astro-discord` - Discord access to the specialists: in the configured channels, `<specialist> <task>` runs `astro.<specialist>` and posts the answer as a reply (โณ running, ๐Ÿ”’ waiting for an owner's approval of a risky call, โœ… done, โŒ failed). Owners may use every specialist and approve risky calls with buttons; other users only the specialists listed for them; everyone else is ignored. Inert unless the headless host sets `ASTRO_DISCORD=1`; setup with `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node refuses to strip types inside `node_modules`, hence the copy), which prompts for the bot token and ids, checks them against Discord, and writes the LaunchAgent. See [specialists/README.md](specialists/README.md#discord).
72
- - `specialist-gate` - gives each specialist agent (`astro.arcane`, `astro.identity`, `astro.network`, `astro.dns`, `astro.edge`, `astro.security`, `astro.backup`, `astro.proxmox`, `astro.inference`) its single tool, which runs the matching CLI wrapper as a dedicated macOS user through `sudo`, and blocks every other agent from calling those CLIs or reading their credentials. Inactive until the host has `~/.pi/agent/specialists.json`. `/specialists` prints the status. Host setup is described in [specialists/README.md](specialists/README.md).
71
+ - `astro-discord` - Discord access to the specialists: in the configured channels, `<specialist> <task>` runs `astro.<specialist>` and posts the answer as a reply (โณ running, ๐Ÿ”’ waiting for an owner's approval of a risky call, โœ… done, โŒ failed). Owners may use every specialist and approve risky calls with buttons; other users only the specialists listed for them; everyone else is ignored. Where the `media` specialist is present, `/trending`, `/search`, and `/downloads` answer without a model, with an Add button beside each title. Inert unless the headless host sets `ASTRO_DISCORD=1`; setup with `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node refuses to strip types inside `node_modules`, hence the copy), which prompts for the bot token and ids, checks them against Discord, and writes the LaunchAgent. See [specialists/README.md](specialists/README.md#discord).
72
+ - `specialist-gate` - gives each specialist agent (`astro.arcane`, `astro.identity`, `astro.network`, `astro.dns`, `astro.edge`, `astro.security`, `astro.backup`, `astro.proxmox`, `astro.inference`, `astro.media`) its single tool, which runs the matching CLI wrapper as a dedicated macOS user through `sudo`, and blocks every other agent from calling those CLIs or reading their credentials. Inactive until the host has `~/.pi/agent/specialists.json`. `/specialists` prints the status. Host setup is described in [specialists/README.md](specialists/README.md).
73
73
  - `notify-on-stop` - runs a shell command when the agent finishes a turn (sound, voice, desktop notification). **Default: off.** Enable with `/notify on` (state persists in `~/.pi/agent/notify-on-stop.json`); disable with `/notify off`. macOS default command: plays the Glass system sound and speaks "Agent done" via `say` using the **`Samantha (Enhanced)`** voice. Linux default: `notify-send "pi" "Agent done"`. Override the voice with `PI_STOP_NOTIFY_VOICE=<voice-name>` (macOS only; e.g. `Alex`, `Karen`, `Daniel (Enhanced)`). Replace the full command with `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff && say "Done"'`. Hard-kill (overrides `/notify on`) with `PI_STOP_NOTIFY_OFF=1`. Commands: `/notify [on|off|status|test]`. See [extensions/notify-on-stop/README.md](extensions/notify-on-stop/README.md) for full details.
74
74
  - **macOS voice install (required once for the default):** open **System Settings, Accessibility, Spoken Content, System Voice, Manage Voices...**, expand **English**, check **Samantha (Enhanced)**, click **Done** to download (~500 MB to 1 GB). Verify with `say -v "Samantha (Enhanced)" hi`. If the voice is missing, `say` errors silently and you'll only hear the Glass sound.
75
75
  - `vscode-image` - only active inside VS Code's integrated terminal (`TERM_PROGRAM=vscode`). Switches pi-tui's image output to the **Kitty graphics protocol** so images returned by tools like `gemini_image` render as real pixels instead of the `[Image: ...]` text fallback. **Requires enabling `Terminal > Integrated: Enable Images` in VS Code settings** (off by default); restart the integrated terminal after flipping it. Disable with `PI_VSCODE_IMAGE_OFF=1`. `/vscode-image` prints current status.
@@ -80,11 +80,11 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
80
80
 
81
81
  **Bundled subagents** (call them as `astro.<name>`):
82
82
  - General-purpose: `code-reviewer`, `google-tech-lead`, `spec-writer`, `tester-api`, `tester-ui`, `ui-architect`, `ui-design-system`, `ui-frontend-developer`
83
- - Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health)
83
+ - Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health), `media` (trendarr: trending titles, Radarr and Sonarr adds, SABnzbd pause)
84
84
  - Flourish pipeline: `flourish-input-vision` (image to spec), `flourish-input-narrative` (text to spec), `flourish-input-codebase` (HTML to spec), `flourish-architect` (spec to plan), `flourish-template-developer` (plan to SDK template, needs `@flourish/sdk`), `flourish-example-builder` (template to realistic example), `flourish-preview-runner` (multi-breakpoint preview + screenshots, needs `@flourish/sdk`), `flourish-reviewer` (audit)
85
85
 
86
86
  **Skills** (loadable via `/skill:<name>`):
87
- - `playwright-cli`, `postman-cli`, `raycast-script-creator`, `caveman`, `flourish-chart-spec`, `arcane`, `identity`, `network`, `dns`, `edge`, `security`, `backup`, `proxmox`, `inference` (the last nine are injected into their specialist agents)
87
+ - `playwright-cli`, `postman-cli`, `raycast-script-creator`, `caveman`, `flourish-chart-spec`, `arcane`, `identity`, `network`, `dns`, `edge`, `security`, `backup`, `proxmox`, `inference`, `media` (the last ten are injected into their specialist agents)
88
88
 
89
89
  **Theme:**
90
90
  - `astro` - dark, gold accent, GitHub-dark-inspired
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: media
3
+ description: Manages the movie and series library through the media tool (trendarr), covering weekly trending with IMDb ratings, title search, adds to Radarr and Sonarr, and the SABnzbd queue pause state. Use for what is trending, adding a movie or series by name, and pausing or resuming downloads.
4
+ tools: read, media, subagent
5
+ skills: media
6
+ systemPromptMode: replace
7
+ inheritProjectContext: false
8
+ inheritSkills: false
9
+ maxSubagentDepth: 1
10
+ model: openai-codex/gpt-6-astra
11
+ thinking: xhigh
12
+ ---
13
+ You are the media specialist. The `media` tool, which runs the `trendarr` CLI, is your only way to reach TMDB, OMDb, Radarr, Sonarr, and SABnzbd. You have no shell, no web, and no file access; if a task needs anything else, say so and stop.
14
+
15
+ Rules:
16
+
17
+ - Search before you add, and add only IMDb IDs that a `search` or `trending` result returned in this conversation. Never invent or guess an ID.
18
+ - Resolve the title with the rules in the skill. A named film or series with one clear match: add it without asking. Ask one short question only when several plausible works remain and the choice changes what gets added.
19
+ - Movies go to `add-movies`, series to `add-series`; batch independent IDs in one call.
20
+ - Success needs an explicit `added` or `already_exists` in `results`; `already_exists` means it was already there, not newly added. Report every ID of a batch.
21
+ - Pause or resume downloads only when the task asks for it, then report the returned state. Adding never implies a queue change.
22
+ - Keep replies short: canonical title and year, outcome, and the SABnzbd state an add returned. Mention `/trending` and `/search` when the user would rather click Add buttons.
23
+ - Library settings, quality profiles, Radarr and Sonarr configuration, and deletions are operator tasks; say so and stop.
24
+ - Delegate through `subagent` only to another specialist (`astro.arcane` for the media containers on the Arcane VM) and only for that specialist's own area.
@@ -59,6 +59,7 @@ export function helpText(allowed: readonly string[], present: readonly string[],
59
59
  if (present.length > 1) lines.push(...allowed.map((name) => `โ€ข \`${name}\``));
60
60
  lines.push("Reactions: โณ running, ๐Ÿ”’ waiting for an owner's approval, โœ… done, โŒ failed.");
61
61
  lines.push("A reply to my answer, or another task for the same specialist within 30 minutes, continues that conversation; start the task with `new` for a fresh one.");
62
+ if (allowed.includes("media")) lines.push("Slash commands `/trending`, `/search`, and `/downloads` answer directly, without the agent; an Add button adds that title at once.");
62
63
  const commands = ["`help` this message", "`status` running tasks"];
63
64
  if (canConfig) commands.push("`config show`", "`config channel <#channel> <always|mention> <*|name,name>`", "`config access <specialist> add|remove <@user>`");
64
65
  lines.push(`Commands: ${commands.join(", ")}.`);
@@ -15,6 +15,7 @@ import { type ConversationState, type ResolvedConversation, forgetStale, loadSta
15
15
  import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
16
16
  import { failureMessage, formatDailyReport, isDue, readReportState, writeReportState } from "./daily-report.ts";
17
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";
18
19
  import { cancelledApprovalText, interruptedTaskText, readOpenWork, type OpenWork, type StopReason, writeOpenWork } from "./open-work.ts";
19
20
  import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
20
21
  import { DiscordRest, type MessageComponent } from "./rest.ts";
@@ -70,17 +71,31 @@ interface IncomingMessage {
70
71
  message_snapshots?: { message: QuotedMessage }[];
71
72
  }
72
73
 
73
- interface ComponentInteraction {
74
+ /** A slash command (type 2) or a component click (type 3) as the gateway delivers it. */
75
+ interface Interaction {
74
76
  id: string;
75
77
  token: string;
76
78
  type: number;
77
79
  channel_id?: string;
78
- message?: { id: string };
79
- 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[] };
80
82
  member?: { user?: { id: string } };
81
83
  user?: { id: string };
82
84
  }
83
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
+
84
99
  interface ActiveTask {
85
100
  specialist: string;
86
101
  channelId: string;
@@ -123,6 +138,9 @@ class Bridge {
123
138
  private readonly queues = new Map<string, Promise<void>>();
124
139
  private readonly active = new Map<string, ActiveTask>();
125
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[]>();
126
144
  private gateway: GatewayClient | undefined;
127
145
  private stopping = false;
128
146
  private reportRunning = false;
@@ -162,7 +180,10 @@ class Bridge {
162
180
  private openWork(): OpenWork {
163
181
  return {
164
182
  since: this.since,
165
- 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
+ ],
166
187
  approvals: [...this.approvalMessages.entries()].map(([ticketId, a]) => ({ ticketId, channelId: a.channelId, messageId: a.messageId, userChannelId: a.userMessage.channelId, userMessageId: a.userMessage.messageId })),
167
188
  };
168
189
  }
@@ -185,11 +206,13 @@ class Bridge {
185
206
  }
186
207
  for (const t of work.tasks) {
187
208
  const text = interruptedTaskText(t.specialist, reason);
188
- 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;
189
212
  edits.push(
190
213
  this.rest
191
- .removeOwnReaction(t.channelId, t.messageId, REACTION.running)
192
- .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))
193
216
  .catch(() => undefined),
194
217
  );
195
218
  }
@@ -205,7 +228,9 @@ class Bridge {
205
228
  const work = this.openWork();
206
229
  for (const task of this.active.values()) task.abort?.abort();
207
230
  if (work.tasks.length > 0 || work.approvals.length > 0) log(`shutdown: cancelling ${work.approvals.length} approval(s), interrupting ${work.tasks.length} task(s)`);
208
- 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]);
209
234
  this.approvals.close();
210
235
  writeOpenWork({ since: this.since, tasks: [], approvals: [] });
211
236
  }
@@ -230,6 +255,7 @@ class Bridge {
230
255
  });
231
256
  gateway.start();
232
257
  this.gateway = gateway;
258
+ void this.registerMediaCommands();
233
259
  // Checked every minute; the report itself decides whether today's is due.
234
260
  this.reportTimer = setInterval(() => void this.maybeDailyReport(), 60_000);
235
261
  this.reportTimer.unref();
@@ -240,7 +266,7 @@ class Bridge {
240
266
  private async dispatch(event: string, data: unknown): Promise<void> {
241
267
  try {
242
268
  if (event === "MESSAGE_CREATE") await this.onMessage(data as IncomingMessage);
243
- else if (event === "INTERACTION_CREATE") await this.onInteraction(data as ComponentInteraction);
269
+ else if (event === "INTERACTION_CREATE") await this.onInteraction(data as Interaction);
244
270
  } catch (err) {
245
271
  log(`${event} failed: ${err instanceof Error ? err.message : String(err)}`);
246
272
  }
@@ -349,11 +375,7 @@ class Bridge {
349
375
  log(`daily report ${date}: running arcane update-report`);
350
376
  let message: { content: string; attachment?: string };
351
377
  try {
352
- const stdout = await new Promise<string>((resolve, reject) =>
353
- execFile("/usr/bin/sudo", ["-n", "-u", gate.user, "-H", gate.entry, "arcane", "--caller", "daily-report", "--", "update-report"], { timeout: 15 * 60_000, maxBuffer: 20 * 1024 * 1024 }, (err, out, errOut) =>
354
- err ? reject(new Error(errOut.trim() || err.message)) : resolve(out),
355
- ),
356
- );
378
+ const stdout = await this.runEntry("arcane", "daily-report", ["update-report"], 15 * 60_000);
357
379
  message = formatDailyReport(JSON.parse(stdout), settings.timeZone);
358
380
  } catch (err) {
359
381
  log(`daily report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
@@ -374,6 +396,143 @@ class Bridge {
374
396
  }
375
397
  }
376
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
+
377
536
  /** Replied-to and forwarded messages to show the specialist; a bot message only when the resumed conversation does not already hold it. */
378
537
  private quotesFor(message: IncomingMessage, inContext: (id: string) => boolean): Quote[] {
379
538
  const quotes: Quote[] = [];
@@ -518,9 +677,18 @@ class Bridge {
518
677
  await this.rest.removeOwnReaction(posted.userMessage.channelId, posted.userMessage.messageId, REACTION.waiting).catch(() => undefined);
519
678
  }
520
679
 
521
- 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
+ }
522
685
  if (interaction.type !== 3) return;
523
- 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);
524
692
  if (!match) return;
525
693
  const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
526
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
+ });