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