@astrofoundry/pi-astro 0.30.0 → 0.32.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.
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(", ")}.`);
@@ -7,7 +7,7 @@ import { chunkMessage, fenceState } from "./chunk.ts";
7
7
  import { applyAdminCommand, describeConfig } from "./admin.ts";
8
8
  import { helpText, needsPrefixText, parseCommand } from "./commands.ts";
9
9
  import { allowedSpecialists, canUse, channelSpecialists, loadConfig, loadToken, validateConfig } from "./config.ts";
10
- import { INTENTS, initialState, reduce } from "./gateway.ts";
10
+ import { GatewayClient, INTENTS, initialState, reduce } from "./gateway.ts";
11
11
  import type { RunResult } from "../astro-subagents/child.ts";
12
12
  import { failureReply } from "./index.ts";
13
13
  import { DiscordRest } from "./rest.ts";
@@ -181,6 +181,85 @@ describe("gateway reducer", () => {
181
181
  });
182
182
  });
183
183
 
184
+ class FakeSocket {
185
+ readyState = 1;
186
+ sent: { op: number; d?: unknown }[] = [];
187
+ closedWith: number | null = null;
188
+ private readonly listeners = new Map<string, ((event: unknown) => void)[]>();
189
+ addEventListener(type: string, fn: (event: unknown) => void): void {
190
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), fn]);
191
+ }
192
+ send(data: string): void {
193
+ this.sent.push(JSON.parse(data) as { op: number; d?: unknown });
194
+ }
195
+ close(code: number): void {
196
+ this.closedWith = code;
197
+ }
198
+ emit(payload: object): void {
199
+ for (const fn of this.listeners.get("message") ?? []) fn({ data: JSON.stringify(payload) });
200
+ }
201
+ emitClose(code: number): void {
202
+ this.readyState = 3;
203
+ for (const fn of this.listeners.get("close") ?? []) fn({ code, reason: "" });
204
+ }
205
+ }
206
+
207
+ describe("gateway client", () => {
208
+ beforeEach(() => vi.useFakeTimers());
209
+ afterEach(() => vi.useRealTimers());
210
+
211
+ function client() {
212
+ const sockets: FakeSocket[] = [];
213
+ const log = vi.fn();
214
+ const gateway = new GatewayClient({
215
+ token: "t",
216
+ gatewayUrl: "wss://gateway",
217
+ handlers: { dispatch: vi.fn(), log },
218
+ socketFactory: () => {
219
+ const socket = new FakeSocket();
220
+ sockets.push(socket);
221
+ return socket as unknown as WebSocket;
222
+ },
223
+ });
224
+ return { gateway, sockets, log };
225
+ }
226
+
227
+ it("opens exactly one new connection after a reconnect request, and the replaced socket's close changes nothing", () => {
228
+ const { gateway, sockets, log } = client();
229
+ gateway.start();
230
+ sockets[0].emit({ op: 10, d: { heartbeat_interval: 1000 } });
231
+ sockets[0].emit({ op: 0, t: "READY", s: 1, d: { session_id: "s1", resume_gateway_url: "wss://resume" } });
232
+ sockets[0].emit({ op: 7 });
233
+ expect(sockets[0].closedWith).toBe(4000);
234
+ sockets[0].emitClose(4000);
235
+ vi.advanceTimersByTime(5_000);
236
+ expect(sockets).toHaveLength(2);
237
+
238
+ sockets[1].emit({ op: 10, d: { heartbeat_interval: 1000 } });
239
+ expect(sockets[1].sent.at(-1)).toMatchObject({ op: 6, d: { session_id: "s1", seq: 1 } });
240
+ sockets[0].emitClose(1000);
241
+ sockets[0].emit({ op: 7 });
242
+ vi.advanceTimersByTime(1_000);
243
+ expect(sockets).toHaveLength(2);
244
+ expect(sockets[1].sent.at(-1)).toEqual({ op: 1, d: 1 });
245
+ expect(log).not.toHaveBeenCalledWith("gateway closed (1000); reconnecting");
246
+ gateway.stop();
247
+ });
248
+
249
+ it("reconnects and resumes once when the server closes the current socket", () => {
250
+ const { gateway, sockets } = client();
251
+ gateway.start();
252
+ sockets[0].emit({ op: 10, d: { heartbeat_interval: 1000 } });
253
+ sockets[0].emit({ op: 0, t: "READY", s: 3, d: { session_id: "s1", resume_gateway_url: "wss://resume" } });
254
+ sockets[0].emitClose(1000);
255
+ vi.advanceTimersByTime(5_000);
256
+ expect(sockets).toHaveLength(2);
257
+ sockets[1].emit({ op: 10, d: { heartbeat_interval: 1000 } });
258
+ expect(sockets[1].sent.at(-1)).toMatchObject({ op: 6, d: { session_id: "s1", seq: 3 } });
259
+ gateway.stop();
260
+ });
261
+ });
262
+
184
263
  describe("rest", () => {
185
264
  it("sends JSON messages, multipart attachments, and retries once after 429", async () => {
186
265
  const calls: { url: string; init: RequestInit }[] = [];
@@ -137,6 +137,7 @@ export class GatewayClient {
137
137
  const socket = factory(this.url(resume));
138
138
  this.socket = socket;
139
139
  socket.addEventListener("message", (event: MessageEvent) => {
140
+ if (this.socket !== socket) return;
140
141
  let payload: GatewayPayload;
141
142
  try {
142
143
  payload = JSON.parse(String(event.data)) as GatewayPayload;
@@ -146,6 +147,9 @@ export class GatewayClient {
146
147
  this.act(reduce(this.state, payload, this.options.handlers));
147
148
  });
148
149
  socket.addEventListener("close", (event: CloseEvent) => {
150
+ // A socket already replaced by a reconnect must not stop the new one's heartbeat or start another connection.
151
+ if (this.socket !== socket) return;
152
+ this.socket = null;
149
153
  this.clearHeartbeat();
150
154
  if (this.stopped) return;
151
155
  if (FATAL_CLOSE_CODES.has(event.code)) {
@@ -186,12 +190,15 @@ export class GatewayClient {
186
190
  case "heartbeat-now":
187
191
  this.send({ op: 1, d: this.state.seq });
188
192
  break;
189
- case "reconnect":
193
+ case "reconnect": {
194
+ const old = this.socket;
195
+ this.socket = null;
190
196
  this.clearHeartbeat();
191
- this.socket?.close(4000);
197
+ old?.close(4000);
192
198
  if (!action.resume) this.state.sessionId = null;
193
199
  this.scheduleReconnect(action.resume);
194
200
  break;
201
+ }
195
202
  default:
196
203
  break;
197
204
  }
@@ -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/);