@astrofoundry/pi-astro 0.21.2 → 0.22.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
@@ -68,6 +68,7 @@ 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 `node --disable-warning=ExperimentalWarning <package>/extensions/astro-discord/setup.ts`, which prompts for the bot token and ids, checks them against Discord, and writes the LaunchAgent. See [specialists/README.md](specialists/README.md#discord).
71
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).
72
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.
73
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.
@@ -0,0 +1,124 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { createServer, type Server } from "node:http";
3
+
4
+ export type Decision = "approve" | "deny" | "timeout";
5
+
6
+ export interface Ticket {
7
+ id: string;
8
+ agent: string;
9
+ service: string;
10
+ args: string[];
11
+ createdAt: number;
12
+ decision: Decision | null;
13
+ decidedBy: string | null;
14
+ }
15
+
16
+ export interface ApprovalEvents {
17
+ /** Called once per new ticket so the bridge can ask the operator. */
18
+ onTicket: (ticket: Ticket) => void;
19
+ /** Called when a ticket ran out of time without a decision. */
20
+ onExpired: (ticket: Ticket) => void;
21
+ }
22
+
23
+ /**
24
+ * Loopback HTTP endpoint the specialist gate talks to: `POST /approvals`
25
+ * creates a ticket, `GET /approvals/<id>` reports its decision. Only
26
+ * requests carrying the per-process bearer token are accepted.
27
+ */
28
+ export class ApprovalServer {
29
+ readonly token = randomBytes(24).toString("hex");
30
+ private readonly tickets = new Map<string, Ticket>();
31
+ private server: Server | null = null;
32
+ private port = 0;
33
+
34
+ constructor(
35
+ private readonly events: ApprovalEvents,
36
+ private readonly timeoutMs: number,
37
+ ) {}
38
+
39
+ get url(): string {
40
+ return `http://127.0.0.1:${this.port}`;
41
+ }
42
+
43
+ ticket(id: string): Ticket | undefined {
44
+ return this.tickets.get(id);
45
+ }
46
+
47
+ decide(id: string, decision: Decision, by: string): Ticket | undefined {
48
+ const ticket = this.tickets.get(id);
49
+ if (!ticket || ticket.decision !== null) return undefined;
50
+ ticket.decision = decision;
51
+ ticket.decidedBy = by;
52
+ return ticket;
53
+ }
54
+
55
+ async listen(): Promise<void> {
56
+ const server = createServer((req, res) => {
57
+ const auth = req.headers.authorization ?? "";
58
+ if (auth !== `Bearer ${this.token}`) {
59
+ res.writeHead(401).end();
60
+ return;
61
+ }
62
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
63
+ if (req.method === "POST" && url.pathname === "/approvals") {
64
+ let body = "";
65
+ req.on("data", (chunk: Buffer) => {
66
+ body += chunk.toString("utf-8");
67
+ if (body.length > 65_536) req.destroy();
68
+ });
69
+ req.on("end", () => {
70
+ let parsed: { agent?: unknown; service?: unknown; args?: unknown };
71
+ try {
72
+ parsed = JSON.parse(body) as typeof parsed;
73
+ } catch {
74
+ res.writeHead(400).end();
75
+ return;
76
+ }
77
+ if (typeof parsed.agent !== "string" || typeof parsed.service !== "string" || !Array.isArray(parsed.args) || !parsed.args.every((a) => typeof a === "string")) {
78
+ res.writeHead(400).end();
79
+ return;
80
+ }
81
+ const ticket: Ticket = { id: randomUUID(), agent: parsed.agent, service: parsed.service, args: parsed.args as string[], createdAt: Date.now(), decision: null, decidedBy: null };
82
+ this.tickets.set(ticket.id, ticket);
83
+ setTimeout(() => {
84
+ if (ticket.decision === null) {
85
+ ticket.decision = "timeout";
86
+ ticket.decidedBy = null;
87
+ this.events.onExpired(ticket);
88
+ }
89
+ setTimeout(() => this.tickets.delete(ticket.id), 60_000).unref();
90
+ }, this.timeoutMs).unref();
91
+ this.events.onTicket(ticket);
92
+ res.writeHead(201, { "Content-Type": "application/json" }).end(JSON.stringify({ id: ticket.id }));
93
+ });
94
+ return;
95
+ }
96
+ const match = /^\/approvals\/([0-9a-f-]{36})$/.exec(url.pathname);
97
+ if (req.method === "GET" && match) {
98
+ const ticket = this.tickets.get(match[1]);
99
+ if (!ticket) {
100
+ res.writeHead(404).end();
101
+ return;
102
+ }
103
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ id: ticket.id, decision: ticket.decision }));
104
+ return;
105
+ }
106
+ res.writeHead(404).end();
107
+ });
108
+ await new Promise<void>((resolve, reject) => {
109
+ server.once("error", reject);
110
+ server.listen(0, "127.0.0.1", () => {
111
+ const address = server.address();
112
+ this.port = typeof address === "object" && address ? address.port : 0;
113
+ resolve();
114
+ });
115
+ });
116
+ server.unref();
117
+ this.server = server;
118
+ }
119
+
120
+ close(): void {
121
+ this.server?.close();
122
+ this.server = null;
123
+ }
124
+ }
@@ -0,0 +1,50 @@
1
+ /** Discord's limit for one message body. */
2
+ export const MESSAGE_LIMIT = 2000;
3
+
4
+ /**
5
+ * Splits text into Discord-sized chunks, preferring paragraph and line
6
+ * boundaries and never leaving a code fence open across a chunk.
7
+ */
8
+ export function chunkMessage(text: string, limit = MESSAGE_LIMIT): string[] {
9
+ const trimmed = text.trim();
10
+ if (trimmed.length === 0) return [];
11
+ const chunks: string[] = [];
12
+ let rest = trimmed;
13
+ let openFence: string | null = null;
14
+ while (rest.length > 0) {
15
+ const closing = 4;
16
+ const budget = limit - closing - (openFence ? openFence.length + 1 : 0);
17
+ let piece: string;
18
+ if (rest.length <= budget) {
19
+ piece = rest;
20
+ rest = "";
21
+ } else {
22
+ let cut = rest.lastIndexOf("\n\n", budget);
23
+ if (cut < budget / 2) cut = rest.lastIndexOf("\n", budget);
24
+ if (cut < budget / 2) cut = rest.lastIndexOf(" ", budget);
25
+ if (cut < budget / 2) cut = budget;
26
+ piece = rest.slice(0, cut);
27
+ rest = rest.slice(cut).replace(/^\s+/, "");
28
+ }
29
+ let body = openFence ? `${openFence}\n${piece}` : piece;
30
+ const fenceAfter = fenceState(body, null);
31
+ if (fenceAfter && rest.length > 0) {
32
+ body = `${body}\n\`\`\``;
33
+ }
34
+ openFence = fenceAfter;
35
+ chunks.push(body);
36
+ }
37
+ return chunks;
38
+ }
39
+
40
+ /** The fence line that is open at the end of `text`, or null when balanced. */
41
+ export function fenceState(text: string, initial: string | null): string | null {
42
+ let open = initial;
43
+ for (const line of text.split("\n")) {
44
+ const match = /^(\s*)(```+|~~~+)(.*)$/.exec(line);
45
+ if (!match) continue;
46
+ if (open === null) open = `${match[2]}${match[3].trim()}`;
47
+ else if (line.trim().startsWith(open.slice(0, 3))) open = null;
48
+ }
49
+ return open;
50
+ }
@@ -0,0 +1,36 @@
1
+ export type Command = { kind: "run"; specialist: string; task: string } | { kind: "help" } | { kind: "status" } | { kind: "none" };
2
+
3
+ /**
4
+ * `<specialist> <task>` or `@bot <specialist> <task>` in a watched channel.
5
+ * `help` lists what the sender may use; `status` shows running tasks.
6
+ */
7
+ export function parseCommand(content: string, botUserId: string, specialists: readonly string[]): Command {
8
+ let text = content.trim().replace(new RegExp(`^<@!?${botUserId}>\\s*`), "");
9
+ text = text.replace(/^[!/]/, "");
10
+ if (text.length === 0) return { kind: "none" };
11
+ const [first, ...rest] = text.split(/\s+/);
12
+ const word = first.toLowerCase();
13
+ if (word === "help") return { kind: "help" };
14
+ if (word === "status") return { kind: "status" };
15
+ const specialist = word.replace(/^astro\./, "");
16
+ if (!specialists.includes(specialist)) return { kind: "none" };
17
+ const task = text.slice(first.length).trim();
18
+ if (task.length === 0) return { kind: "none" };
19
+ return { kind: "run", specialist, task: rest.length > 0 ? task : "" };
20
+ }
21
+
22
+ /** Prompt paragraph appended for tasks that arrive from a chat channel. */
23
+ export const CHANNEL_RULES = `## Channel rules
24
+
25
+ This task arrived from a Discord channel and your answer is posted there. Answer in plain text under 1500 characters when possible, facts first, no headings. When the task is ambiguous (which guest, which record, which host), do not act: ask one precise question and stop. Risky calls may wait for an operator's approval; if the tool reports a denial or a missing answer, report it and stop.`;
26
+
27
+ export function helpText(allowed: readonly string[], isOwner: boolean): string {
28
+ if (allowed.length === 0) return "You may not use any specialist here.";
29
+ const lines = [
30
+ "Send `<specialist> <task>` in this channel. Specialists you may use:",
31
+ ...allowed.map((name) => `• \`${name}\``),
32
+ "React ⏳ means running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `status` lists running tasks.",
33
+ ];
34
+ if (isOwner) lines.push("Owners approve risky calls with the buttons the bot posts and may use every specialist.");
35
+ return lines.join("\n");
36
+ }
@@ -0,0 +1,108 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /** Set to "1" on the headless Pi host that should connect to Discord. */
6
+ export const ACTIVATION_ENV = "ASTRO_DISCORD";
7
+
8
+ export interface DiscordConfig {
9
+ applicationId: string;
10
+ guildId: string;
11
+ /** Channels the bot reads; messages elsewhere are ignored. */
12
+ channelIds: string[];
13
+ /** Users who may use every specialist and decide approvals. */
14
+ owners: string[];
15
+ /** Extra users per specialist name (without the astro. prefix). */
16
+ access: Record<string, string[]>;
17
+ approvalTimeoutMinutes: number;
18
+ }
19
+
20
+ const SNOWFLAKE = /^\d{17,20}$/;
21
+ const SPECIALIST = /^[a-z][a-z0-9-]*$/;
22
+
23
+ export function configDir(): string {
24
+ return process.env.ASTRO_DISCORD_DIR ?? join(homedir(), ".config", "astro-discord");
25
+ }
26
+
27
+ export function configPath(): string {
28
+ return join(configDir(), "config.json");
29
+ }
30
+
31
+ export function tokenPath(): string {
32
+ return join(configDir(), "token");
33
+ }
34
+
35
+ function snowflakes(value: unknown, where: string, errors: string[]): string[] {
36
+ if (!Array.isArray(value) || value.length === 0) {
37
+ errors.push(`${where} must be a non-empty array of Discord ids`);
38
+ return [];
39
+ }
40
+ const out: string[] = [];
41
+ for (const item of value) {
42
+ if (typeof item !== "string" || !SNOWFLAKE.test(item)) errors.push(`${where}: ${String(item)} is not a Discord id`);
43
+ else out.push(item);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig | null {
49
+ if (typeof raw !== "object" || raw === null) {
50
+ errors.push("config root is not an object");
51
+ return null;
52
+ }
53
+ const r = raw as Record<string, unknown>;
54
+ for (const key of ["applicationId", "guildId"]) {
55
+ if (typeof r[key] !== "string" || !SNOWFLAKE.test(r[key] as string)) errors.push(`${key} must be a Discord id`);
56
+ }
57
+ const channelIds = snowflakes(r.channelIds, "channelIds", errors);
58
+ const owners = snowflakes(r.owners, "owners", errors);
59
+ const access: Record<string, string[]> = {};
60
+ if (r.access !== undefined) {
61
+ if (typeof r.access !== "object" || r.access === null || Array.isArray(r.access)) errors.push("access must be an object of specialist name to id array");
62
+ else {
63
+ for (const [name, ids] of Object.entries(r.access as Record<string, unknown>)) {
64
+ if (!SPECIALIST.test(name)) errors.push(`access: ${name} is not a specialist name`);
65
+ access[name] = Array.isArray(ids) && ids.length === 0 ? [] : snowflakes(ids, `access.${name}`, errors);
66
+ }
67
+ }
68
+ }
69
+ const timeout = r.approvalTimeoutMinutes;
70
+ if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > 240) errors.push("approvalTimeoutMinutes must be an integer from 1 to 240");
71
+ if (errors.length > 0) return null;
72
+ return { applicationId: r.applicationId as string, guildId: r.guildId as string, channelIds, owners, access, approvalTimeoutMinutes: timeout as number };
73
+ }
74
+
75
+ export function loadConfig(): { config: DiscordConfig | null; errors: string[] } {
76
+ const errors: string[] = [];
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(readFileSync(configPath(), "utf-8"));
80
+ } catch (err) {
81
+ return { config: null, errors: [`cannot read ${configPath()}: ${err instanceof Error ? err.message : String(err)}`] };
82
+ }
83
+ return { config: validateConfig(parsed, errors), errors };
84
+ }
85
+
86
+ /** The bot token; the file must be readable by its owner only. */
87
+ export function loadToken(): string {
88
+ const path = tokenPath();
89
+ const mode = statSync(path).mode;
90
+ if ((mode & 0o077) !== 0) throw new Error(`${path} must be mode 0600`);
91
+ const token = readFileSync(path, "utf-8").trim();
92
+ if (token.length < 50) throw new Error(`${path} does not look like a bot token`);
93
+ return token;
94
+ }
95
+
96
+ export function isOwner(config: DiscordConfig, userId: string): boolean {
97
+ return config.owners.includes(userId);
98
+ }
99
+
100
+ /** Owners may use every specialist; others only those listed for them under `access`. */
101
+ export function canUse(config: DiscordConfig, userId: string, specialist: string): boolean {
102
+ return isOwner(config, userId) || (config.access[specialist] ?? []).includes(userId);
103
+ }
104
+
105
+ /** Specialists a user may address, from the runtime list of `astro.*` agents. */
106
+ export function allowedSpecialists(config: DiscordConfig, userId: string, available: readonly string[]): string[] {
107
+ return available.filter((name) => canUse(config, userId, name));
108
+ }
@@ -0,0 +1,173 @@
1
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { ApprovalServer, type Ticket } from "./approvals.ts";
6
+ import { chunkMessage, fenceState } from "./chunk.ts";
7
+ import { helpText, parseCommand } from "./commands.ts";
8
+ import { allowedSpecialists, canUse, loadConfig, loadToken, validateConfig } from "./config.ts";
9
+ import { INTENTS, initialState, reduce } from "./gateway.ts";
10
+ import { DiscordRest } from "./rest.ts";
11
+
12
+ const ID = "123456789012345678";
13
+ const OTHER = "223456789012345678";
14
+ const THIRD = "323456789012345678";
15
+ const valid = { applicationId: ID, guildId: ID, channelIds: [ID], owners: [ID], access: { dns: [OTHER] }, approvalTimeoutMinutes: 30 };
16
+
17
+ describe("config", () => {
18
+ it("validates ids and shapes", () => {
19
+ const errors: string[] = [];
20
+ expect(validateConfig(valid, errors)).toEqual(valid);
21
+ expect(errors).toEqual([]);
22
+ expect(validateConfig({ ...valid, guildId: "abc" }, errors)).toBeNull();
23
+ expect(validateConfig({ ...valid, channelIds: [] }, [])).toBeNull();
24
+ expect(validateConfig({ ...valid, access: { "Bad Name": [ID] } }, [])).toBeNull();
25
+ expect(validateConfig({ ...valid, approvalTimeoutMinutes: 0 }, [])).toBeNull();
26
+ expect(validateConfig({ ...valid, access: undefined }, [])).toMatchObject({ access: {} });
27
+ });
28
+
29
+ it("grants owners everything and others their listed specialists", () => {
30
+ const config = validateConfig(valid, [])!;
31
+ expect(canUse(config, ID, "proxmox")).toBe(true);
32
+ expect(canUse(config, OTHER, "dns")).toBe(true);
33
+ expect(canUse(config, OTHER, "proxmox")).toBe(false);
34
+ expect(canUse(config, THIRD, "dns")).toBe(false);
35
+ expect(allowedSpecialists(config, OTHER, ["dns", "edge"])).toEqual(["dns"]);
36
+ expect(allowedSpecialists(config, ID, ["dns", "edge"])).toEqual(["dns", "edge"]);
37
+ });
38
+
39
+ describe("files", () => {
40
+ let dir: string;
41
+ beforeEach(() => {
42
+ dir = mkdtempSync(join(tmpdir(), "astro-discord-"));
43
+ process.env.ASTRO_DISCORD_DIR = dir;
44
+ });
45
+ afterEach(() => {
46
+ delete process.env.ASTRO_DISCORD_DIR;
47
+ rmSync(dir, { recursive: true, force: true });
48
+ });
49
+
50
+ it("loads config and refuses a readable token", () => {
51
+ writeFileSync(join(dir, "config.json"), JSON.stringify(valid));
52
+ expect(loadConfig().config).toEqual(valid);
53
+ writeFileSync(join(dir, "token"), `${"x".repeat(60)}\n`, { mode: 0o644 });
54
+ expect(() => loadToken()).toThrow(/0600/);
55
+ chmodSync(join(dir, "token"), 0o600);
56
+ expect(loadToken()).toBe("x".repeat(60));
57
+ writeFileSync(join(dir, "config.json"), "{");
58
+ expect(loadConfig().config).toBeNull();
59
+ });
60
+ });
61
+ });
62
+
63
+ describe("chunk", () => {
64
+ it("splits on paragraphs and keeps fences balanced", () => {
65
+ const text = `${"a".repeat(1500)}\n\n${"b".repeat(1500)}`;
66
+ const chunks = chunkMessage(text);
67
+ expect(chunks).toHaveLength(2);
68
+ expect(chunks[0]).toBe("a".repeat(1500));
69
+ const fenced = "intro\n```json\n" + "x\n".repeat(1500) + "```\nafter";
70
+ const parts = chunkMessage(fenced);
71
+ expect(parts.length).toBeGreaterThan(1);
72
+ for (const part of parts) {
73
+ expect(part.length).toBeLessThanOrEqual(2000);
74
+ expect(fenceState(part, null)).toBeNull();
75
+ }
76
+ expect(parts[1].startsWith("```json\n")).toBe(true);
77
+ expect(chunkMessage(" ")).toEqual([]);
78
+ });
79
+ });
80
+
81
+ describe("commands", () => {
82
+ const specialists = ["dns", "edge"];
83
+ it("parses specialist tasks, mentions, help, and status", () => {
84
+ expect(parseCommand("dns list the zones", ID, specialists)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
85
+ expect(parseCommand(`<@${ID}> edge probe the front door`, ID, specialists)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door" });
86
+ expect(parseCommand("!astro.dns zones", ID, specialists)).toEqual({ kind: "run", specialist: "dns", task: "zones" });
87
+ expect(parseCommand("help", ID, specialists)).toEqual({ kind: "help" });
88
+ expect(parseCommand("Status", ID, specialists)).toEqual({ kind: "status" });
89
+ expect(parseCommand("dns", ID, specialists)).toEqual({ kind: "none" });
90
+ expect(parseCommand("hello everyone", ID, specialists)).toEqual({ kind: "none" });
91
+ expect(parseCommand("", ID, specialists)).toEqual({ kind: "none" });
92
+ });
93
+
94
+ it("lists what a user may use", () => {
95
+ expect(helpText([], false)).toMatch(/may not/);
96
+ expect(helpText(["dns"], true)).toMatch(/`dns`/);
97
+ expect(helpText(["dns"], true)).toMatch(/Owners approve/);
98
+ });
99
+ });
100
+
101
+ describe("gateway reducer", () => {
102
+ it("identifies after hello, resumes with a session, heartbeats, and reconnects", () => {
103
+ const state = initialState();
104
+ const handlers = { dispatch: vi.fn(), log: vi.fn() };
105
+ expect(reduce(state, { op: 10, d: { heartbeat_interval: 1000 } }, handlers)).toEqual({ kind: "identify" });
106
+ expect(state.heartbeatMs).toBe(1000);
107
+ expect(reduce(state, { op: 0, t: "READY", s: 1, d: { session_id: "s1", resume_gateway_url: "wss://r" } }, handlers)).toEqual({ kind: "none" });
108
+ expect(state).toMatchObject({ sessionId: "s1", resumeUrl: "wss://r", seq: 1 });
109
+ expect(handlers.dispatch).toHaveBeenCalledWith("READY", expect.objectContaining({ session_id: "s1" }));
110
+ expect(reduce(state, { op: 0, t: "MESSAGE_CREATE", s: 2, d: { id: "m" } }, handlers)).toEqual({ kind: "none" });
111
+ expect(handlers.dispatch).toHaveBeenLastCalledWith("MESSAGE_CREATE", { id: "m" });
112
+ expect(reduce(state, { op: 1 }, handlers)).toEqual({ kind: "heartbeat-now" });
113
+ state.awaitingAck = true;
114
+ expect(reduce(state, { op: 11 }, handlers)).toEqual({ kind: "none" });
115
+ expect(state.awaitingAck).toBe(false);
116
+ expect(reduce(state, { op: 7 }, handlers)).toEqual({ kind: "reconnect", resume: true });
117
+ expect(reduce(state, { op: 9, d: false }, handlers)).toEqual({ kind: "reconnect", resume: false });
118
+ expect(reduce(state, { op: 10, d: { heartbeat_interval: 1000 } }, handlers)).toEqual({ kind: "resume" });
119
+ expect(INTENTS).toBe(33281);
120
+ });
121
+ });
122
+
123
+ describe("rest", () => {
124
+ it("sends JSON messages, multipart attachments, and retries once after 429", async () => {
125
+ const calls: { url: string; init: RequestInit }[] = [];
126
+ let first = true;
127
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
128
+ calls.push({ url: String(url), init: init ?? {} });
129
+ if (first) {
130
+ first = false;
131
+ return new Response(JSON.stringify({ retry_after: 0.01 }), { status: 429 });
132
+ }
133
+ return new Response(JSON.stringify({ id: "m1", channel_id: "c1" }), { status: 200 });
134
+ }) as unknown as typeof fetch;
135
+ const rest = new DiscordRest("tok", fetchImpl);
136
+ const message = await rest.createMessage("c1", { content: "hi", replyTo: "m0" });
137
+ expect(message.id).toBe("m1");
138
+ expect(calls).toHaveLength(2);
139
+ expect(calls[1].url).toBe("https://discord.com/api/v10/channels/c1/messages");
140
+ expect((calls[1].init.headers as Record<string, string>).Authorization).toBe("Bot tok");
141
+ expect(JSON.parse(calls[1].init.body as string)).toMatchObject({ content: "hi", message_reference: { message_id: "m0" }, allowed_mentions: { parse: [] } });
142
+ await rest.createMessage("c1", { content: "long", file: { name: "a.md", content: "body" } });
143
+ expect(calls[2].init.body).toBeInstanceOf(FormData);
144
+ await rest.addReaction("c1", "m1", "⏳");
145
+ expect(calls[3].url).toBe(`https://discord.com/api/v10/channels/c1/messages/m1/reactions/${encodeURIComponent("⏳")}/@me`);
146
+ expect(calls[3].init.method).toBe("PUT");
147
+ });
148
+ });
149
+
150
+ describe("approval server", () => {
151
+ it("creates tickets, reports decisions, expires, and rejects bad tokens", async () => {
152
+ const tickets: Ticket[] = [];
153
+ const expired: Ticket[] = [];
154
+ const server = new ApprovalServer({ onTicket: (t) => tickets.push(t), onExpired: (t) => expired.push(t) }, 300);
155
+ await server.listen();
156
+ const headers = { Authorization: `Bearer ${server.token}`, "Content-Type": "application/json" };
157
+ expect((await fetch(`${server.url}/approvals`, { method: "POST", headers: { ...headers, Authorization: "Bearer nope" }, body: "{}" })).status).toBe(401);
158
+ expect((await fetch(`${server.url}/approvals`, { method: "POST", headers, body: JSON.stringify({ agent: "astro.dns" }) })).status).toBe(400);
159
+ const created = await fetch(`${server.url}/approvals`, { method: "POST", headers, body: JSON.stringify({ agent: "astro.dns", service: "dns", args: ["technitium", "primary", "zone-delete", "x"] }) });
160
+ expect(created.status).toBe(201);
161
+ const { id } = (await created.json()) as { id: string };
162
+ expect(tickets[0]).toMatchObject({ id, service: "dns", decision: null });
163
+ expect((await (await fetch(`${server.url}/approvals/${id}`, { headers })).json()) as object).toEqual({ id, decision: null });
164
+ expect(server.decide(id, "approve", ID)?.decidedBy).toBe(ID);
165
+ expect(server.decide(id, "deny", ID)).toBeUndefined();
166
+ expect((await (await fetch(`${server.url}/approvals/${id}`, { headers })).json()) as object).toEqual({ id, decision: "approve" });
167
+ const second = (await (await fetch(`${server.url}/approvals`, { method: "POST", headers, body: JSON.stringify({ agent: "astro.edge", service: "edge", args: ["vps", "reboot", "--confirm"] }) })).json()) as { id: string };
168
+ await new Promise((resolve) => setTimeout(resolve, 400));
169
+ expect(server.ticket(second.id)?.decision).toBe("timeout");
170
+ expect(expired.map((t) => t.id)).toEqual([second.id]);
171
+ server.close();
172
+ });
173
+ });