@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 +5 -5
- package/agents/media.md +24 -0
- package/extensions/astro-discord/commands.ts +1 -0
- package/extensions/astro-discord/discord.test.ts +80 -1
- package/extensions/astro-discord/gateway.ts +9 -2
- package/extensions/astro-discord/index.ts +184 -16
- package/extensions/astro-discord/lifecycle.test.ts +7 -0
- package/extensions/astro-discord/media.test.ts +244 -0
- package/extensions/astro-discord/media.ts +312 -0
- package/extensions/astro-discord/open-work.ts +2 -1
- package/extensions/astro-discord/rest.ts +38 -0
- package/extensions/specialist-gate/approvals.test.ts +2 -0
- package/extensions/specialist-gate/index.ts +1 -0
- package/package.json +1 -1
- package/skills/media/SKILL.md +60 -0
- package/specialists/AGENTS.md +7 -5
- package/specialists/README.md +35 -6
- package/specialists/entry/specialist-cli.sh +3 -2
- package/specialists/entry/specialist-install.sh +459 -0
- package/specialists/entry/specialist-install.test.ts +95 -0
- package/specialists/install/install.sh +2 -2
- package/specialists/media/run.ts +268 -0
- package/specialists/wrappers.test.ts +122 -2
|
@@ -0,0 +1,244 @@
|
|
|
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: "unpause" }])).toEqual({ view: "downloads", args: ["sabnzbd", "resume"] });
|
|
66
|
+
expect(commandArgs("downloads", [{ name: "action", type: 3, value: "delete" }])).toBeNull();
|
|
67
|
+
expect(commandArgs("ban", [])).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("reads the wrapper's JSON defensively", () => {
|
|
71
|
+
expect(parseMediaList({ movies: [entry(1)], series: [], warnings: ["Radarr unreachable", 3] })).toEqual({ movies: [entry(1)], series: [], warnings: ["Radarr unreachable"] });
|
|
72
|
+
expect(parseMediaList({ movies: [{ rank: "1" }], series: [] })).toBeNull();
|
|
73
|
+
expect(parseMediaList({ results: [] })).toBeNull();
|
|
74
|
+
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 });
|
|
75
|
+
expect(parseAddReport({ results: [], sabnzbd_paused: "no" })).toEqual({ results: [], sabnzbd_paused: null });
|
|
76
|
+
expect(parseAddReport(null)).toBeNull();
|
|
77
|
+
expect(parseQueueState({ sabnzbd_paused: false })).toBe(false);
|
|
78
|
+
expect(parseQueueState({})).toBeNull();
|
|
79
|
+
expect(queueLine(true)).toBe("SABnzbd paused: yes");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("media list layout", () => {
|
|
84
|
+
it("writes trendarr's Discord line without the library flag", () => {
|
|
85
|
+
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");
|
|
86
|
+
expect(entryLine(entry(2, { title: "Masterplan", imdb_rating: null, popularity: 222 }), "trending")).toBe("2. [Masterplan (2026)](<https://www.imdb.com/title/tt1002/>) — 🔥222");
|
|
87
|
+
expect(entryLine(entry(3, { title: "A*B_[x]", year: null, imdb_id: null }), "search")).toBe("3. A\\*B\\_\\[x\\] — 7.1");
|
|
88
|
+
expect(entryLine(entry(4, { imdb_rating: null }), "search")).toBe("4. [Title 4 (2026)](<https://www.imdb.com/title/tt1004/>)");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("puts an Add, In library, or No IMDb ID button beside each entry", () => {
|
|
92
|
+
expect(entryButton("movie", entry(1))).toEqual({ type: 2, style: 1, label: "Add", custom_id: "media:add:movie:tt1001:1" });
|
|
93
|
+
expect(entryButton("series", entry(2, { in_library: null }))).toMatchObject({ label: "Add", custom_id: "media:add:series:tt1002:2" });
|
|
94
|
+
expect(entryButton("movie", entry(3, { in_library: true }))).toMatchObject({ label: "In library", disabled: true, custom_id: "media:lib:movie:tt1003:3" });
|
|
95
|
+
expect(entryButton("movie", entry(4, { imdb_id: null }))).toMatchObject({ label: "No IMDb ID", disabled: true });
|
|
96
|
+
expect(entryButton("movie", entry(5, { imdb_id: "nm123" }))).toMatchObject({ label: "No IMDb ID", disabled: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("builds one message per list within Discord's limits", () => {
|
|
100
|
+
const many = Array.from({ length: 14 }, (_, i) => entry(i + 1));
|
|
101
|
+
const message = listMessage("movie", many, "trending", undefined, ["Radarr unreachable — in_library unknown"]);
|
|
102
|
+
expect(message[0]).toEqual({ type: 10, content: "**🎬 Movies** · trending this week" });
|
|
103
|
+
expect(message.filter((c) => c.type === 9)).toHaveLength(LIST_ENTRIES);
|
|
104
|
+
expect(countComponents(message)).toBe(1 + LIST_ENTRIES * 3 + 1);
|
|
105
|
+
expect(countComponents(message)).toBeLessThanOrEqual(40);
|
|
106
|
+
expect(message.at(-1)).toEqual({ type: 10, content: "-# ⚠️ Radarr unreachable — in_library unknown" });
|
|
107
|
+
const ids = message.flatMap((c) => (c.accessory?.custom_id ? [c.accessory.custom_id] : []));
|
|
108
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
109
|
+
expect(ids.every((id) => id.length <= 100)).toBe(true);
|
|
110
|
+
expect(listMessage("series", [], "search", "x_y", [])).toEqual([{ type: 10, content: "**📺 Series** · search “x\\_y”" }, { type: 10, content: "_none_" }]);
|
|
111
|
+
expect(IS_COMPONENTS_V2).toBe(32768);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("media add buttons", () => {
|
|
116
|
+
const message: MessageComponent[] = [
|
|
117
|
+
{ type: 10, id: 1, content: "**🎬 Movies** · trending this week" },
|
|
118
|
+
{ 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" } },
|
|
119
|
+
{ 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" } },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
it("parses only Add buttons", () => {
|
|
123
|
+
expect(parseAddButton("media:add:movie:tt1:1")).toEqual({ kind: "movie", imdbId: "tt1" });
|
|
124
|
+
expect(parseAddButton("media:add:series:tt0903747:10")).toEqual({ kind: "series", imdbId: "tt0903747" });
|
|
125
|
+
expect(parseAddButton("media:done:movie:tt1:1")).toBeNull();
|
|
126
|
+
expect(parseAddButton("media:add:movie:tt1;rm:1")).toBeNull();
|
|
127
|
+
expect(parseAddButton("media:add:music:tt1:1")).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("turns each add result into a button state and a private note", () => {
|
|
131
|
+
const report = (status: string, paused: boolean | null = false, detail: string | null = null) => ({ results: [{ imdb_id: "tt1", title: "Hope", status, detail }], sabnzbd_paused: paused });
|
|
132
|
+
expect(addOutcome(report("added"), "movie", "tt1")).toEqual({ label: "Added", style: 3, disabled: true });
|
|
133
|
+
expect(addOutcome(report("added", true), "movie", "tt1").note).toMatch(/downloads are paused/);
|
|
134
|
+
expect(addOutcome(report("already_exists"), "movie", "tt1")).toEqual({ label: "In library", style: 2, disabled: true });
|
|
135
|
+
expect(addOutcome(report("not_found"), "series", "tt1")).toMatchObject({ label: "Not found", disabled: true, note: expect.stringMatching(/Sonarr/) });
|
|
136
|
+
expect(addOutcome(report("error", false, "quality profile missing"), "movie", "tt1")).toMatchObject({ label: "Retry", disabled: false, note: "Not added: quality profile missing." });
|
|
137
|
+
expect(addOutcome({ results: [], sabnzbd_paused: null }, "movie", "tt1")).toMatchObject({ label: "Retry", disabled: false, note: "Not added: no result for this ID." });
|
|
138
|
+
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/) });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("replaces only the clicked button and keeps component ids", () => {
|
|
142
|
+
expect(hasActiveButton(message, "media:add:movie:tt1:1")).toBe(true);
|
|
143
|
+
const next = applyOutcome(message, "media:add:movie:tt1:1", { label: "Added", style: 3, disabled: true });
|
|
144
|
+
expect(next[1].accessory).toEqual({ type: 2, id: 4, style: 3, label: "Added", custom_id: "media:done:movie:tt1:1", disabled: true });
|
|
145
|
+
expect(next[2]).toEqual(message[2]);
|
|
146
|
+
expect(next[0]).toEqual(message[0]);
|
|
147
|
+
expect(hasActiveButton(next, "media:add:movie:tt1:1")).toBe(false);
|
|
148
|
+
expect(hasActiveButton(next, "media:add:movie:tt2:2")).toBe(true);
|
|
149
|
+
const retry = applyOutcome(message, "media:add:movie:tt2:2", { label: "Retry", style: 4, disabled: false });
|
|
150
|
+
expect(retry[2].accessory).toMatchObject({ label: "Retry", custom_id: "media:add:movie:tt2:2", disabled: false });
|
|
151
|
+
expect(hasActiveButton(retry, "media:add:movie:tt2:2")).toBe(true);
|
|
152
|
+
expect(message[1].accessory?.label).toBe("Add");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("media access", () => {
|
|
157
|
+
const config = validateConfig(
|
|
158
|
+
{
|
|
159
|
+
applicationId: OWNER,
|
|
160
|
+
guildId: OWNER,
|
|
161
|
+
channels: { [ADMIN_CHANNEL]: { trigger: "always", specialists: "*" }, [MEDIA_CHANNEL]: { trigger: "always", specialists: ["media"] }, [OTHER_CHANNEL]: { trigger: "always", specialists: ["arcane"] } },
|
|
162
|
+
adminChannelId: ADMIN_CHANNEL,
|
|
163
|
+
owners: [OWNER],
|
|
164
|
+
access: { media: [FRIEND] },
|
|
165
|
+
approvalTimeoutMinutes: 30,
|
|
166
|
+
},
|
|
167
|
+
[],
|
|
168
|
+
)!;
|
|
169
|
+
const names = ["arcane", "media"];
|
|
170
|
+
|
|
171
|
+
it("answers owners and access.media users in channels with the media specialist", () => {
|
|
172
|
+
expect(mediaAccess(config, config.channels[MEDIA_CHANNEL], names, OWNER)).toBe("ok");
|
|
173
|
+
expect(mediaAccess(config, config.channels[MEDIA_CHANNEL], names, FRIEND)).toBe("ok");
|
|
174
|
+
expect(mediaAccess(config, config.channels[MEDIA_CHANNEL], names, STRANGER)).toBe("user");
|
|
175
|
+
expect(mediaAccess(config, config.channels[OTHER_CHANNEL], names, OWNER)).toBe("channel");
|
|
176
|
+
expect(mediaAccess(config, null, names, OWNER)).toBe("channel");
|
|
177
|
+
expect(mediaAccess(config, config.channels[MEDIA_CHANNEL], ["arcane"], OWNER)).toBe("channel");
|
|
178
|
+
expect(mediaPointer(config, names)).toBe(`Media commands answer in <#${ADMIN_CHANNEL}>, <#${MEDIA_CHANNEL}>.`);
|
|
179
|
+
expect(mediaPointer(config, ["arcane"])).toBe("No channel has the media specialist yet.");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("mentions the slash commands in help where media may be used", () => {
|
|
183
|
+
expect(helpText(["media"], ["media"], false, "always", false)).toContain("/trending");
|
|
184
|
+
expect(helpText(["arcane"], ["arcane"], true, "always", false)).not.toContain("/trending");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("keyed queue", () => {
|
|
189
|
+
it("runs tasks of one key in order and keeps going after a failure", async () => {
|
|
190
|
+
const queue = new KeyedQueue();
|
|
191
|
+
const order: string[] = [];
|
|
192
|
+
let release: () => void = () => undefined;
|
|
193
|
+
const gate = new Promise<void>((resolve) => {
|
|
194
|
+
release = resolve;
|
|
195
|
+
});
|
|
196
|
+
const first = queue.run("m1", async () => {
|
|
197
|
+
await gate;
|
|
198
|
+
order.push("first");
|
|
199
|
+
throw new Error("boom");
|
|
200
|
+
});
|
|
201
|
+
const second = queue.run("m1", async () => {
|
|
202
|
+
order.push("second");
|
|
203
|
+
});
|
|
204
|
+
const other = queue.run("m2", async () => {
|
|
205
|
+
order.push("other");
|
|
206
|
+
});
|
|
207
|
+
await other;
|
|
208
|
+
expect(order).toEqual(["other"]);
|
|
209
|
+
release();
|
|
210
|
+
await expect(first).rejects.toThrow("boom");
|
|
211
|
+
await second;
|
|
212
|
+
expect(order).toEqual(["other", "first", "second"]);
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
214
|
+
expect(queue.size).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("media rest calls", () => {
|
|
219
|
+
it("defers, edits the original response, follows up, and registers commands", async () => {
|
|
220
|
+
const calls: { url: string; init: RequestInit }[] = [];
|
|
221
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
222
|
+
calls.push({ url: String(url), init: init ?? {} });
|
|
223
|
+
return new Response(JSON.stringify({ id: "m1", channel_id: "c1" }), { status: 200 });
|
|
224
|
+
}) as unknown as typeof fetch;
|
|
225
|
+
const rest = new DiscordRest("tok", fetchImpl);
|
|
226
|
+
await rest.deferReply("i1", "t1");
|
|
227
|
+
await rest.deferUpdate("i2", "t2");
|
|
228
|
+
await rest.editOriginal("app", "t1", { content: null, flags: IS_COMPONENTS_V2, components: [{ type: 10, content: "x" }] });
|
|
229
|
+
await rest.followUp("app", "t1", { content: "note", flags: 64 });
|
|
230
|
+
await rest.overwriteGlobalCommands("app", MEDIA_COMMANDS);
|
|
231
|
+
expect(calls.map((c) => `${c.init.method} ${c.url}`)).toEqual([
|
|
232
|
+
"POST https://discord.com/api/v10/interactions/i1/t1/callback",
|
|
233
|
+
"POST https://discord.com/api/v10/interactions/i2/t2/callback",
|
|
234
|
+
"PATCH https://discord.com/api/v10/webhooks/app/t1/messages/@original",
|
|
235
|
+
"POST https://discord.com/api/v10/webhooks/app/t1",
|
|
236
|
+
"PUT https://discord.com/api/v10/applications/app/commands",
|
|
237
|
+
]);
|
|
238
|
+
expect(JSON.parse(calls[0].init.body as string)).toEqual({ type: 5 });
|
|
239
|
+
expect(JSON.parse(calls[1].init.body as string)).toEqual({ type: 6 });
|
|
240
|
+
expect(JSON.parse(calls[2].init.body as string)).toEqual({ content: null, flags: 32768, components: [{ type: 10, content: "x" }], allowed_mentions: { parse: [] } });
|
|
241
|
+
expect(JSON.parse(calls[3].init.body as string)).toEqual({ content: "note", flags: 64, allowed_mentions: { parse: [] } });
|
|
242
|
+
expect((JSON.parse(calls[4].init.body as string) as { name: string }[]).map((c) => c.name)).toEqual(["trending", "search", "downloads"]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { type ChannelSettings, type DiscordConfig, canUse, channelSpecialists } from "./config.ts";
|
|
2
|
+
import type { MessageComponent } from "./rest.ts";
|
|
3
|
+
|
|
4
|
+
/** The specialist behind the slash commands and Add buttons. */
|
|
5
|
+
export const MEDIA = "media";
|
|
6
|
+
/** Message flag for layout components (Section, Text Display); content and embeds are unavailable with it. */
|
|
7
|
+
export const IS_COMPONENTS_V2 = 1 << 15;
|
|
8
|
+
/** One list message: a header, ten sections of three components each, and a warning line stay within Discord's 40 components. */
|
|
9
|
+
export const LIST_ENTRIES = 10;
|
|
10
|
+
|
|
11
|
+
export type MediaKind = "movie" | "series";
|
|
12
|
+
|
|
13
|
+
export interface MediaEntry {
|
|
14
|
+
rank: number;
|
|
15
|
+
title: string;
|
|
16
|
+
year: number | null;
|
|
17
|
+
imdb_rating: string | null;
|
|
18
|
+
imdb_id: string | null;
|
|
19
|
+
in_library: boolean | null;
|
|
20
|
+
popularity?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MediaList {
|
|
24
|
+
movies: MediaEntry[];
|
|
25
|
+
series: MediaEntry[];
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AddResult {
|
|
30
|
+
imdb_id: string;
|
|
31
|
+
title: string | null;
|
|
32
|
+
status: string;
|
|
33
|
+
detail: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AddReport {
|
|
37
|
+
results: AddResult[];
|
|
38
|
+
sabnzbd_paused: boolean | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STRING = 3;
|
|
42
|
+
const INTEGER = 4;
|
|
43
|
+
const NUMBER = 10;
|
|
44
|
+
const GUILD_CONTEXT = 0;
|
|
45
|
+
const TYPE_CHOICES = [
|
|
46
|
+
{ name: "movies", value: "movies" },
|
|
47
|
+
{ name: "series", value: "series" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** Slash commands of the bridge, registered as the application's global command list. */
|
|
51
|
+
export const MEDIA_COMMANDS = [
|
|
52
|
+
{
|
|
53
|
+
name: "trending",
|
|
54
|
+
type: 1,
|
|
55
|
+
description: "Trending movies and series this week, with Add buttons",
|
|
56
|
+
contexts: [GUILD_CONTEXT],
|
|
57
|
+
options: [
|
|
58
|
+
{ type: STRING, name: "type", description: "Only movies or only series", choices: TYPE_CHOICES },
|
|
59
|
+
{ type: NUMBER, name: "min_rating", description: "Drop movies below this IMDb rating (default 6.5, 0 keeps all)", min_value: 0, max_value: 10 },
|
|
60
|
+
{ type: INTEGER, name: "count", description: "Entries per list (default 10)", min_value: 1, max_value: LIST_ENTRIES },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "search",
|
|
65
|
+
type: 1,
|
|
66
|
+
description: "Search movies and series by title, with Add buttons",
|
|
67
|
+
contexts: [GUILD_CONTEXT],
|
|
68
|
+
options: [
|
|
69
|
+
{ type: STRING, name: "title", description: "Full or partial title", required: true, min_length: 1, max_length: 100 },
|
|
70
|
+
{ type: STRING, name: "type", description: "Only movies or only series", choices: TYPE_CHOICES },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "downloads",
|
|
75
|
+
type: 1,
|
|
76
|
+
description: "SABnzbd queue: status, pause, or resume (unpause)",
|
|
77
|
+
contexts: [GUILD_CONTEXT],
|
|
78
|
+
options: [
|
|
79
|
+
{
|
|
80
|
+
type: STRING,
|
|
81
|
+
name: "action",
|
|
82
|
+
description: "What to do with the download queue",
|
|
83
|
+
required: true,
|
|
84
|
+
choices: [
|
|
85
|
+
{ name: "status", value: "status" },
|
|
86
|
+
{ name: "pause", value: "pause" },
|
|
87
|
+
{ name: "resume", value: "resume" },
|
|
88
|
+
{ name: "unpause", value: "unpause" },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
export interface CommandOption {
|
|
96
|
+
name: string;
|
|
97
|
+
type: number;
|
|
98
|
+
value?: string | number | boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type MediaCommand =
|
|
102
|
+
| { view: "trending"; args: string[]; only?: "movies" | "series" }
|
|
103
|
+
| { view: "search"; args: string[]; only?: "movies" | "series"; query: string }
|
|
104
|
+
| { view: "downloads"; args: string[] };
|
|
105
|
+
|
|
106
|
+
function value(options: readonly CommandOption[], name: string): string | number | undefined {
|
|
107
|
+
const found = options.find((o) => o.name === name)?.value;
|
|
108
|
+
return typeof found === "string" || typeof found === "number" ? found : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listType(options: readonly CommandOption[]): "movies" | "series" | undefined {
|
|
112
|
+
const type = value(options, "type");
|
|
113
|
+
return type === "movies" || type === "series" ? type : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Wrapper arguments for one slash command, or null for a command this bridge does not own. */
|
|
117
|
+
export function commandArgs(name: string, options: readonly CommandOption[]): MediaCommand | null {
|
|
118
|
+
const only = listType(options);
|
|
119
|
+
const typeArgs = only ? ["--type", only] : [];
|
|
120
|
+
if (name === "trending") {
|
|
121
|
+
const count = value(options, "count");
|
|
122
|
+
const minRating = value(options, "min_rating");
|
|
123
|
+
const entries = typeof count === "number" && Number.isInteger(count) && count >= 1 && count <= LIST_ENTRIES ? count : LIST_ENTRIES;
|
|
124
|
+
const rating = typeof minRating === "number" ? ["--min-rating", String(minRating)] : [];
|
|
125
|
+
return { view: "trending", args: ["trending", "--max-entries", String(entries), ...typeArgs, ...rating], ...(only ? { only } : {}) };
|
|
126
|
+
}
|
|
127
|
+
if (name === "search") {
|
|
128
|
+
const title = value(options, "title");
|
|
129
|
+
if (typeof title !== "string" || title.trim().length === 0) return null;
|
|
130
|
+
return { view: "search", args: ["search", title.trim(), "--max-entries", String(LIST_ENTRIES), ...typeArgs], query: title.trim(), ...(only ? { only } : {}) };
|
|
131
|
+
}
|
|
132
|
+
if (name === "downloads") {
|
|
133
|
+
const action = value(options, "action");
|
|
134
|
+
if (action !== "status" && action !== "pause" && action !== "resume" && action !== "unpause") return null;
|
|
135
|
+
return { view: "downloads", args: ["sabnzbd", action === "unpause" ? "resume" : action] };
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isEntry(value: unknown): value is MediaEntry {
|
|
141
|
+
const e = value as Partial<MediaEntry> | null;
|
|
142
|
+
return !!e && typeof e.rank === "number" && typeof e.title === "string";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The `trending` or `search` JSON of the media wrapper, or null when it is not one. */
|
|
146
|
+
export function parseMediaList(value: unknown): MediaList | null {
|
|
147
|
+
const r = value as { movies?: unknown; series?: unknown; warnings?: unknown } | null;
|
|
148
|
+
if (!r || !Array.isArray(r.movies) || !Array.isArray(r.series) || !r.movies.every(isEntry) || !r.series.every(isEntry)) return null;
|
|
149
|
+
const warnings = Array.isArray(r.warnings) ? r.warnings.filter((w): w is string => typeof w === "string") : [];
|
|
150
|
+
return { movies: r.movies, series: r.series, warnings };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseAddReport(value: unknown): AddReport | null {
|
|
154
|
+
const r = value as { results?: unknown; sabnzbd_paused?: unknown } | null;
|
|
155
|
+
if (!r || !Array.isArray(r.results)) return null;
|
|
156
|
+
const results = r.results.filter((x): x is AddResult => typeof (x as AddResult | null)?.imdb_id === "string" && typeof (x as AddResult).status === "string");
|
|
157
|
+
return { results, sabnzbd_paused: typeof r.sabnzbd_paused === "boolean" ? r.sabnzbd_paused : null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseQueueState(value: unknown): boolean | null {
|
|
161
|
+
const paused = (value as { sabnzbd_paused?: unknown } | null)?.sabnzbd_paused;
|
|
162
|
+
return typeof paused === "boolean" ? paused : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const IMDB_ID = /^tt\d{1,12}$/;
|
|
166
|
+
|
|
167
|
+
function escapeDiscord(text: string): string {
|
|
168
|
+
return text.replace(/([\\*_~`|[\]])/g, "\\$1");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Title with year, linked to IMDb without a preview, as trendarr's `--discord` output writes it. */
|
|
172
|
+
function titleLink(entry: MediaEntry): string {
|
|
173
|
+
const label = escapeDiscord(`${entry.title}${entry.year === null ? "" : ` (${entry.year})`}`);
|
|
174
|
+
return entry.imdb_id !== null && IMDB_ID.test(entry.imdb_id) ? `[${label}](<https://www.imdb.com/title/${entry.imdb_id}/>)` : label;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** trendarr's `--discord` line without the library flag, which the button beside it shows. */
|
|
178
|
+
export function entryLine(entry: MediaEntry, view: "trending" | "search"): string {
|
|
179
|
+
if (view === "trending") {
|
|
180
|
+
const rating = entry.imdb_rating === null ? "" : `${entry.imdb_rating} · `;
|
|
181
|
+
return `${entry.rank}. ${titleLink(entry)} — ${rating}🔥${Math.round(entry.popularity ?? 0)}`;
|
|
182
|
+
}
|
|
183
|
+
return `${entry.rank}. ${titleLink(entry)}${entry.imdb_rating === null ? "" : ` — ${entry.imdb_rating}`}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const PRIMARY = 1;
|
|
187
|
+
const SECONDARY = 2;
|
|
188
|
+
const SUCCESS = 3;
|
|
189
|
+
const DANGER = 4;
|
|
190
|
+
|
|
191
|
+
export function entryButton(kind: MediaKind, entry: MediaEntry): MessageComponent {
|
|
192
|
+
const id = entry.imdb_id !== null && IMDB_ID.test(entry.imdb_id) ? entry.imdb_id : null;
|
|
193
|
+
if (id === null) return { type: 2, style: SECONDARY, label: "No IMDb ID", custom_id: `media:none:${kind}:${entry.rank}`, disabled: true };
|
|
194
|
+
if (entry.in_library === true) return { type: 2, style: SECONDARY, label: "In library", custom_id: `media:lib:${kind}:${id}:${entry.rank}`, disabled: true };
|
|
195
|
+
// `in_library: null` means the library could not be read; trendarr's add answers already_exists if it is there.
|
|
196
|
+
return { type: 2, style: PRIMARY, label: "Add", custom_id: `media:add:${kind}:${id}:${entry.rank}` };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One Components V2 message: header, one section per entry with its button, and trendarr's warnings. */
|
|
200
|
+
export function listMessage(kind: MediaKind, entries: readonly MediaEntry[], view: "trending" | "search", query: string | undefined, warnings: readonly string[]): MessageComponent[] {
|
|
201
|
+
const title = kind === "movie" ? "**🎬 Movies**" : "**📺 Series**";
|
|
202
|
+
const scope = view === "trending" ? "trending this week" : `search “${escapeDiscord(query ?? "")}”`;
|
|
203
|
+
const components: MessageComponent[] = [{ type: 10, content: `${title} · ${scope}` }];
|
|
204
|
+
if (entries.length === 0) components.push({ type: 10, content: "_none_" });
|
|
205
|
+
for (const entry of entries.slice(0, LIST_ENTRIES)) {
|
|
206
|
+
components.push({ type: 9, components: [{ type: 10, content: entryLine(entry, view) }], accessory: entryButton(kind, entry) });
|
|
207
|
+
}
|
|
208
|
+
if (warnings.length > 0) components.push({ type: 10, content: `-# ⚠️ ${warnings.join(" · ").slice(0, 500)}` });
|
|
209
|
+
return components;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Every component in a message, nested ones and section accessories included. */
|
|
213
|
+
export function countComponents(components: readonly MessageComponent[]): number {
|
|
214
|
+
return components.reduce((n, c) => n + 1 + countComponents(c.components ?? []) + (c.accessory ? countComponents([c.accessory]) : 0), 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const ADD_ID = /^media:add:(movie|series):(tt\d{1,12}):\d{1,3}$/;
|
|
218
|
+
|
|
219
|
+
export function parseAddButton(customId: string): { kind: MediaKind; imdbId: string } | null {
|
|
220
|
+
const m = ADD_ID.exec(customId);
|
|
221
|
+
return m ? { kind: m[1] as MediaKind, imdbId: m[2] } : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface Outcome {
|
|
225
|
+
label: string;
|
|
226
|
+
style: number;
|
|
227
|
+
/** A finished button stays disabled; a failed one can be clicked again. */
|
|
228
|
+
disabled: boolean;
|
|
229
|
+
/** Shown only to the user who clicked. */
|
|
230
|
+
note?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What the clicked button turns into after the add, from the wrapper's answer or its failure text. */
|
|
234
|
+
export function addOutcome(report: AddReport | null, kind: MediaKind, imdbId: string, failure?: string): Outcome {
|
|
235
|
+
if (!report) return { label: "Retry", style: DANGER, disabled: false, note: `Not added: ${failure ?? "the media check failed"}.` };
|
|
236
|
+
const result = report.results.find((r) => r.imdb_id === imdbId);
|
|
237
|
+
const name = result?.title ? `**${escapeDiscord(result.title)}**` : imdbId;
|
|
238
|
+
if (result?.status === "added") {
|
|
239
|
+
return { label: "Added", style: SUCCESS, disabled: true, ...(report.sabnzbd_paused === true ? { note: `Added ${name}; the search started, but downloads are paused. \`/downloads resume\` starts them.` } : {}) };
|
|
240
|
+
}
|
|
241
|
+
if (result?.status === "already_exists") return { label: "In library", style: SECONDARY, disabled: true };
|
|
242
|
+
if (result?.status === "not_found") return { label: "Not found", style: DANGER, disabled: true, note: `${name} was not found ${kind === "movie" ? "in Radarr" : "on TMDB or in Sonarr"}; nothing was added.` };
|
|
243
|
+
return { label: "Retry", style: DANGER, disabled: false, note: `Not added: ${result?.detail ?? (result ? result.status : "no result for this ID")}.` };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function mapButtons(components: readonly MessageComponent[], customId: string, replace: (button: MessageComponent) => MessageComponent): MessageComponent[] {
|
|
247
|
+
return components.map((c) => {
|
|
248
|
+
if (c.type === 2 && c.custom_id === customId) return replace(c);
|
|
249
|
+
const next: MessageComponent = { ...c };
|
|
250
|
+
if (c.components) next.components = mapButtons(c.components, customId, replace);
|
|
251
|
+
if (c.accessory) next.accessory = mapButtons([c.accessory], customId, replace)[0];
|
|
252
|
+
return next;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** True while the message still shows that button enabled, so a second click on it does nothing. */
|
|
257
|
+
export function hasActiveButton(components: readonly MessageComponent[], customId: string): boolean {
|
|
258
|
+
return components.some((c) => (c.type === 2 && c.custom_id === customId && c.disabled !== true) || hasActiveButton(c.components ?? [], customId) || (c.accessory ? hasActiveButton([c.accessory], customId) : false));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The message components with only the clicked button replaced by its outcome. */
|
|
262
|
+
export function applyOutcome(components: readonly MessageComponent[], customId: string, outcome: Outcome): MessageComponent[] {
|
|
263
|
+
return mapButtons(components, customId, (button) => ({
|
|
264
|
+
...button,
|
|
265
|
+
style: outcome.style,
|
|
266
|
+
label: outcome.label,
|
|
267
|
+
disabled: outcome.disabled,
|
|
268
|
+
custom_id: outcome.disabled ? customId.replace(/^media:add:/, "media:done:") : customId,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type MediaAccess = "ok" | "channel" | "user";
|
|
273
|
+
|
|
274
|
+
/** Media commands answer where the media specialist is present and for owners or users listed under `access.media`. */
|
|
275
|
+
export function mediaAccess(config: DiscordConfig, settings: ChannelSettings | null, configured: readonly string[], userId: string): MediaAccess {
|
|
276
|
+
if (!settings || !channelSpecialists(settings, configured).includes(MEDIA)) return "channel";
|
|
277
|
+
return canUse(config, userId, MEDIA) ? "ok" : "user";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function mediaPointer(config: DiscordConfig, configured: readonly string[]): string {
|
|
281
|
+
const channels = Object.entries(config.channels)
|
|
282
|
+
.filter(([, settings]) => channelSpecialists(settings, configured).includes(MEDIA))
|
|
283
|
+
.map(([id]) => `<#${id}>`);
|
|
284
|
+
return channels.length > 0 ? `Media commands answer in ${channels.join(", ")}.` : "No channel has the media specialist yet.";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function queueLine(paused: boolean): string {
|
|
288
|
+
return `SABnzbd paused: ${paused ? "yes" : "no"}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Runs tasks for one key strictly one after another; a failure does not stop
|
|
293
|
+
* the next. Button clicks on the same message go through one key, so each
|
|
294
|
+
* edit starts from the state the previous one left.
|
|
295
|
+
*/
|
|
296
|
+
export class KeyedQueue {
|
|
297
|
+
private readonly tails = new Map<string, Promise<void>>();
|
|
298
|
+
|
|
299
|
+
run(key: string, task: () => Promise<void>): Promise<void> {
|
|
300
|
+
const next = (this.tails.get(key) ?? Promise.resolve()).then(task);
|
|
301
|
+
const tail = next.catch(() => undefined);
|
|
302
|
+
this.tails.set(key, tail);
|
|
303
|
+
void tail.then(() => {
|
|
304
|
+
if (this.tails.get(key) === tail) this.tails.delete(key);
|
|
305
|
+
});
|
|
306
|
+
return next;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
get size(): number {
|
|
310
|
+
return this.tails.size;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -6,7 +6,8 @@ import { configDir } from "./config.ts";
|
|
|
6
6
|
export interface OpenTask {
|
|
7
7
|
specialist: string;
|
|
8
8
|
channelId: string;
|
|
9
|
-
|
|
9
|
+
/** Absent for a slash command or button click, which has no user message to answer. */
|
|
10
|
+
messageId?: string;
|
|
10
11
|
placeholderId?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -2,13 +2,26 @@ export const API = "https://discord.com/api/v10";
|
|
|
2
2
|
|
|
3
3
|
export interface MessageComponent {
|
|
4
4
|
type: number;
|
|
5
|
+
/** Set by Discord on every component it returns; kept unchanged when a message is edited. */
|
|
6
|
+
id?: number;
|
|
5
7
|
components?: MessageComponent[];
|
|
8
|
+
/** Text of a Text Display. */
|
|
9
|
+
content?: string;
|
|
10
|
+
/** The button or thumbnail beside a Section's text. */
|
|
11
|
+
accessory?: MessageComponent;
|
|
6
12
|
style?: number;
|
|
7
13
|
label?: string;
|
|
8
14
|
custom_id?: string;
|
|
9
15
|
disabled?: boolean;
|
|
10
16
|
}
|
|
11
17
|
|
|
18
|
+
/** A message body for interaction webhooks: plain `content`, or `components` with the IS_COMPONENTS_V2 flag. */
|
|
19
|
+
export interface WebhookMessage {
|
|
20
|
+
content?: string | null;
|
|
21
|
+
components?: MessageComponent[];
|
|
22
|
+
flags?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
export interface CreateMessage {
|
|
13
26
|
content: string;
|
|
14
27
|
replyTo?: string;
|
|
@@ -99,6 +112,31 @@ export class DiscordRest {
|
|
|
99
112
|
await this.call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, JSON.stringify({ type: 4, data: { content, flags: 64, allowed_mentions: { parse: [] } } }), "application/json");
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
/** Acknowledges a slash command; Discord shows "thinking" until the original response is edited (callback type 5). */
|
|
116
|
+
async deferReply(interactionId: string, interactionToken: string): Promise<void> {
|
|
117
|
+
await this.call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, JSON.stringify({ type: 5 }), "application/json");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Acknowledges a component click without a loading state; the message is edited later through the original response (callback type 6). */
|
|
121
|
+
async deferUpdate(interactionId: string, interactionToken: string): Promise<void> {
|
|
122
|
+
await this.call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, JSON.stringify({ type: 6 }), "application/json");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Edits the original response: the deferred reply of a command, or the message of a clicked component. */
|
|
126
|
+
async editOriginal(applicationId: string, interactionToken: string, message: WebhookMessage): Promise<void> {
|
|
127
|
+
await this.call("PATCH", `/webhooks/${applicationId}/${interactionToken}/messages/@original`, JSON.stringify({ ...message, allowed_mentions: { parse: [] } }), "application/json");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Another message for the same interaction; with flags 64 only the user who triggered it sees it. */
|
|
131
|
+
async followUp(applicationId: string, interactionToken: string, message: WebhookMessage): Promise<DiscordMessage> {
|
|
132
|
+
return (await this.call("POST", `/webhooks/${applicationId}/${interactionToken}`, JSON.stringify({ ...message, allowed_mentions: { parse: [] } }), "application/json")).json() as Promise<DiscordMessage>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Replaces the application's global slash commands with this list. */
|
|
136
|
+
async overwriteGlobalCommands(applicationId: string, commands: readonly unknown[]): Promise<void> {
|
|
137
|
+
await this.call("PUT", `/applications/${applicationId}/commands`, JSON.stringify(commands), "application/json");
|
|
138
|
+
}
|
|
139
|
+
|
|
102
140
|
async gatewayUrl(): Promise<string> {
|
|
103
141
|
const data = (await (await this.call("GET", "/gateway/bot")).json()) as { url?: string };
|
|
104
142
|
if (typeof data.url !== "string") throw new Error("Discord did not return a gateway url");
|
|
@@ -29,6 +29,8 @@ describe("approvals", () => {
|
|
|
29
29
|
expect(isRisky("inference", ["hermes-restart", "api"])).toBe(true);
|
|
30
30
|
expect(isRisky("backup", ["restic-snapshots"])).toBe(false);
|
|
31
31
|
expect(isRisky("backup", ["vzdump-run", "107"])).toBe(true);
|
|
32
|
+
expect(isRisky("media", ["add-movies", "tt1160419"])).toBe(false);
|
|
33
|
+
expect(isRisky("media", ["sabnzbd", "pause"])).toBe(false);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
36
|
it("creates a ticket and polls until decided", async () => {
|
|
@@ -35,6 +35,7 @@ export const RISKY: Readonly<Record<string, readonly RegExp[]>> = {
|
|
|
35
35
|
backup: [/^(vzdump-run|host-backup-run|arcane-backup-run|gcs-run|restic-check-run|restic-restore|restore-clean)\b/],
|
|
36
36
|
proxmox: [/^(set|snapshot-create|start)\b/],
|
|
37
37
|
inference: [/^(nexus-restart|nexus-start|hermes-restart)\b/],
|
|
38
|
+
// media has no entry: trendarr cannot delete anything, and adds and queue toggles run on the caller's request.
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
export function isRisky(service: string, args: readonly string[]): boolean {
|