@astrofoundry/pi-astro 0.22.4 → 0.23.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.
@@ -0,0 +1,77 @@
1
+ import { type ChannelSettings, type DiscordConfig, SNOWFLAKE, SPECIALIST } from "./config.ts";
2
+
3
+ export interface AdminResult {
4
+ reply: string;
5
+ /** Present when the command changed the configuration. */
6
+ config?: DiscordConfig;
7
+ }
8
+
9
+ const USAGE = [
10
+ "config show",
11
+ "config channel <#channel> <always|mention> <*|name,name>",
12
+ "config channel <#channel> remove",
13
+ "config access <specialist> add|remove <@user>",
14
+ ].join("\n");
15
+
16
+ function idOf(token: string | undefined, kind: "channel" | "user"): string | null {
17
+ if (token === undefined) return null;
18
+ const match = kind === "channel" ? /^<#(\d{17,20})>$/.exec(token) : /^<@!?(\d{17,20})>$/.exec(token);
19
+ const id = match ? match[1] : token;
20
+ return SNOWFLAKE.test(id) ? id : null;
21
+ }
22
+
23
+ export function describeConfig(config: DiscordConfig): string {
24
+ const channels = Object.entries(config.channels).map(([id, c]) => `• <#${id}>: ${c.trigger}, ${c.specialists === "*" ? "every specialist" : c.specialists.join(", ")}${id === config.adminChannelId ? " (admin)" : ""}`);
25
+ const access = Object.entries(config.access)
26
+ .filter(([, ids]) => ids.length > 0)
27
+ .map(([name, ids]) => `• ${name}: ${ids.map((id) => `<@${id}>`).join(", ")}`);
28
+ return [
29
+ "Channels:",
30
+ ...channels,
31
+ `Owners: ${config.owners.map((id) => `<@${id}>`).join(", ")}`,
32
+ access.length > 0 ? "Extra access:" : "Extra access: none",
33
+ ...access,
34
+ `Approval timeout: ${config.approvalTimeoutMinutes} min`,
35
+ ].join("\n");
36
+ }
37
+
38
+ /** Applies one owner `config` command to a copy of the configuration. */
39
+ export function applyAdminCommand(config: DiscordConfig, args: readonly string[], configured: readonly string[]): AdminResult {
40
+ const [verb, ...rest] = args;
41
+ if (verb === undefined || verb === "show") return { reply: describeConfig(config) };
42
+ if (verb === "channel") {
43
+ const channelId = idOf(rest[0], "channel");
44
+ if (!channelId) return { reply: `channel id missing.\n${USAGE}` };
45
+ if (rest[1] === "remove") {
46
+ if (!(channelId in config.channels)) return { reply: `<#${channelId}> is not configured.` };
47
+ if (channelId === config.adminChannelId) return { reply: "The admin channel cannot be removed." };
48
+ const channels = { ...config.channels };
49
+ delete channels[channelId];
50
+ return { reply: `Removed <#${channelId}>.`, config: { ...config, channels } };
51
+ }
52
+ if (rest[1] !== "always" && rest[1] !== "mention") return { reply: `trigger must be always or mention.\n${USAGE}` };
53
+ const names = rest[2] === "*" ? "*" : (rest[2] ?? "").split(",").map((n) => n.trim()).filter(Boolean);
54
+ if (names !== "*") {
55
+ if (names.length === 0) return { reply: `specialists missing.\n${USAGE}` };
56
+ const unknown = names.filter((n) => !SPECIALIST.test(n) || !configured.includes(n));
57
+ if (unknown.length > 0) return { reply: `unknown specialist(s): ${unknown.join(", ")}. Configured: ${configured.join(", ")}.` };
58
+ }
59
+ const settings: ChannelSettings = { trigger: rest[1], specialists: names === "*" ? "*" : [...new Set(names)] };
60
+ const verbNow = channelId in config.channels ? "Updated" : "Added";
61
+ return {
62
+ reply: `${verbNow} <#${channelId}>: ${settings.trigger}, ${settings.specialists === "*" ? "every specialist" : settings.specialists.join(", ")}.`,
63
+ config: { ...config, channels: { ...config.channels, [channelId]: settings } },
64
+ };
65
+ }
66
+ if (verb === "access") {
67
+ const [name, action, who] = rest;
68
+ if (name === undefined || !configured.includes(name)) return { reply: `unknown specialist. Configured: ${configured.join(", ")}.` };
69
+ const userId = idOf(who, "user");
70
+ if ((action !== "add" && action !== "remove") || !userId) return { reply: USAGE };
71
+ const current = config.access[name] ?? [];
72
+ const next = action === "add" ? [...new Set([...current, userId])] : current.filter((id) => id !== userId);
73
+ if (next.length === current.length) return { reply: action === "add" ? `<@${userId}> already has ${name}.` : `<@${userId}> did not have ${name}.` };
74
+ return { reply: `${action === "add" ? "Granted" : "Revoked"} ${name} for <@${userId}>.`, config: { ...config, access: { ...config.access, [name]: next } } };
75
+ }
76
+ return { reply: USAGE };
77
+ }
@@ -1,22 +1,46 @@
1
- export type Command = { kind: "run"; specialist: string; task: string } | { kind: "help" } | { kind: "status" } | { kind: "none" };
1
+ import type { Trigger } from "./config.ts";
2
+
3
+ export type Command =
4
+ | { kind: "run"; specialist: string; task: string }
5
+ | { kind: "help" }
6
+ | { kind: "status" }
7
+ | { kind: "config"; args: string[] }
8
+ | { kind: "needs-prefix"; specialists: string[] }
9
+ | { kind: "none" };
10
+
11
+ export interface CommandContext {
12
+ botUserId: string;
13
+ /** Specialists present in this channel that the host has configured. */
14
+ specialists: readonly string[];
15
+ trigger: Trigger;
16
+ /** True when the message replies to one of the bot's messages. */
17
+ repliedToBot: boolean;
18
+ }
2
19
 
3
20
  /**
4
- * `<specialist> <task>` or `@bot <specialist> <task>` in a watched channel.
5
- * `help` lists what the sender may use; `status` shows running tasks.
21
+ * Interprets one channel message. `mention` channels react only when addressed
22
+ * (bot mention or a reply to the bot). A channel with one specialist needs no
23
+ * prefix; with several, an addressed message without a prefix asks for one.
6
24
  */
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" };
25
+ export function parseCommand(content: string, context: CommandContext): Command {
26
+ const mention = new RegExp(`^<@!?${context.botUserId}>\\s*`);
27
+ const addressed = mention.test(content.trim()) || context.repliedToBot;
28
+ const text = content.trim().replace(mention, "").replace(/^[!/]/, "").trim();
29
+ if (context.trigger === "mention" && !addressed) return { kind: "none" };
30
+ if (text.length === 0) return addressed ? { kind: "help" } : { kind: "none" };
11
31
  const [first, ...rest] = text.split(/\s+/);
12
32
  const word = first.toLowerCase();
13
33
  if (word === "help") return { kind: "help" };
14
34
  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 : "" };
35
+ if (word === "config") return { kind: "config", args: rest };
36
+ const named = word.replace(/^astro\./, "");
37
+ if (context.specialists.includes(named)) {
38
+ const task = text.slice(first.length).trim();
39
+ return task.length > 0 ? { kind: "run", specialist: named, task } : { kind: "help" };
40
+ }
41
+ if (context.specialists.length === 1) return { kind: "run", specialist: context.specialists[0], task: text };
42
+ if (addressed) return { kind: "needs-prefix", specialists: [...context.specialists] };
43
+ return { kind: "none" };
20
44
  }
21
45
 
22
46
  /** Prompt paragraph appended for tasks that arrive from a chat channel. */
@@ -24,13 +48,16 @@ export const CHANNEL_RULES = `## Channel rules
24
48
 
25
49
  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
50
 
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
- "Reactmeans running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `status` lists running tasks.",
33
- ];
51
+ export function helpText(allowed: readonly string[], present: readonly string[], isOwner: boolean, trigger: Trigger): string {
52
+ if (allowed.length === 0) return present.length === 0 ? "No specialist is present in this channel." : "You may not use the specialists present in this channel.";
53
+ const how = present.length === 1 ? `Every message${trigger === "mention" ? " that addresses me" : ""} here goes to \`${present[0]}\`.` : `Send \`<specialist> <task>\`${trigger === "mention" ? " after mentioning me" : ""}. Specialists you may use here:`;
54
+ const lines = [how];
55
+ if (present.length > 1) lines.push(...allowed.map((name) => `• \`${name}\``));
56
+ lines.push("Reactions: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `status` lists running tasks.");
34
57
  if (isOwner) lines.push("Owners approve risky calls with the buttons the bot posts and may use every specialist.");
35
58
  return lines.join("\n");
36
59
  }
60
+
61
+ export function needsPrefixText(specialists: readonly string[]): string {
62
+ return `Several specialists live here; start your message with one of: ${specialists.map((s) => `\`${s}\``).join(", ")}.`;
63
+ }
@@ -1,15 +1,26 @@
1
- import { readFileSync, statSync } from "node:fs";
1
+ import { readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  /** Set to "1" on the headless Pi host that should connect to Discord. */
6
6
  export const ACTIVATION_ENV = "ASTRO_DISCORD";
7
7
 
8
+ export type Trigger = "always" | "mention";
9
+
10
+ export interface ChannelSettings {
11
+ /** `always`: every message that names a specialist (or any message when one specialist is present); `mention`: only when addressed. */
12
+ trigger: Trigger;
13
+ /** Specialists available in this channel: every configured one, or a list of short names. */
14
+ specialists: "*" | string[];
15
+ }
16
+
8
17
  export interface DiscordConfig {
9
18
  applicationId: string;
10
19
  guildId: string;
11
- /** Channels the bot reads; messages elsewhere are ignored. */
12
- channelIds: string[];
20
+ /** Channels the bot reads, by id; messages elsewhere are ignored. Threads inherit their parent's entry. */
21
+ channels: Record<string, ChannelSettings>;
22
+ /** The one channel where owners may run `config` commands. */
23
+ adminChannelId: string;
13
24
  /** Users who may use every specialist and decide approvals. */
14
25
  owners: string[];
15
26
  /** Extra users per specialist name (without the astro. prefix). */
@@ -17,8 +28,8 @@ export interface DiscordConfig {
17
28
  approvalTimeoutMinutes: number;
18
29
  }
19
30
 
20
- const SNOWFLAKE = /^\d{17,20}$/;
21
- const SPECIALIST = /^[a-z][a-z0-9-]*$/;
31
+ export const SNOWFLAKE = /^\d{17,20}$/;
32
+ export const SPECIALIST = /^[a-z][a-z0-9-]*$/;
22
33
 
23
34
  export function configDir(): string {
24
35
  return process.env.ASTRO_DISCORD_DIR ?? join(homedir(), ".config", "astro-discord");
@@ -54,7 +65,18 @@ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig |
54
65
  for (const key of ["applicationId", "guildId"]) {
55
66
  if (typeof r[key] !== "string" || !SNOWFLAKE.test(r[key] as string)) errors.push(`${key} must be a Discord id`);
56
67
  }
57
- const channelIds = snowflakes(r.channelIds, "channelIds", errors);
68
+ const channels: Record<string, ChannelSettings> = {};
69
+ if (typeof r.channels !== "object" || r.channels === null || Array.isArray(r.channels) || Object.keys(r.channels).length === 0) {
70
+ errors.push("channels must be a non-empty object of channel id to settings");
71
+ } else {
72
+ for (const [id, raw] of Object.entries(r.channels as Record<string, unknown>)) {
73
+ if (!SNOWFLAKE.test(id)) errors.push(`channels: ${id} is not a Discord id`);
74
+ const settings = validateChannel(raw, `channels.${id}`, errors);
75
+ if (settings) channels[id] = settings;
76
+ }
77
+ }
78
+ if (typeof r.adminChannelId !== "string" || !SNOWFLAKE.test(r.adminChannelId)) errors.push("adminChannelId must be a Discord id");
79
+ else if (!(r.adminChannelId in channels) && errors.length === 0) errors.push("adminChannelId must be one of the configured channels");
58
80
  const owners = snowflakes(r.owners, "owners", errors);
59
81
  const access: Record<string, string[]> = {};
60
82
  if (r.access !== undefined) {
@@ -69,7 +91,44 @@ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig |
69
91
  const timeout = r.approvalTimeoutMinutes;
70
92
  if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > 240) errors.push("approvalTimeoutMinutes must be an integer from 1 to 240");
71
93
  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 };
94
+ return {
95
+ applicationId: r.applicationId as string,
96
+ guildId: r.guildId as string,
97
+ channels,
98
+ adminChannelId: r.adminChannelId as string,
99
+ owners,
100
+ access,
101
+ approvalTimeoutMinutes: timeout as number,
102
+ };
103
+ }
104
+
105
+ export function validateChannel(raw: unknown, where: string, errors: string[]): ChannelSettings | null {
106
+ if (typeof raw !== "object" || raw === null) {
107
+ errors.push(`${where} must be an object`);
108
+ return null;
109
+ }
110
+ const r = raw as Record<string, unknown>;
111
+ if (r.trigger !== "always" && r.trigger !== "mention") errors.push(`${where}.trigger must be always or mention`);
112
+ let specialists: "*" | string[] = "*";
113
+ if (r.specialists !== "*") {
114
+ if (!Array.isArray(r.specialists) || r.specialists.length === 0 || !r.specialists.every((n) => typeof n === "string" && SPECIALIST.test(n))) {
115
+ errors.push(`${where}.specialists must be "*" or a non-empty list of specialist names`);
116
+ } else specialists = [...new Set(r.specialists as string[])];
117
+ }
118
+ return errors.length > 0 ? null : { trigger: r.trigger as Trigger, specialists };
119
+ }
120
+
121
+ /** Writes the configuration back atomically with owner-only permissions. */
122
+ export function saveConfig(config: DiscordConfig): void {
123
+ const path = configPath();
124
+ const tmp = `${path}.tmp`;
125
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
126
+ renameSync(tmp, path);
127
+ }
128
+
129
+ /** Specialists present in a channel, intersected with the ones configured on this host. */
130
+ export function channelSpecialists(settings: ChannelSettings, configured: readonly string[]): string[] {
131
+ return settings.specialists === "*" ? [...configured] : settings.specialists.filter((name) => configured.includes(name));
73
132
  }
74
133
 
75
134
  export function loadConfig(): { config: DiscordConfig | null; errors: string[] } {
@@ -102,7 +161,7 @@ export function canUse(config: DiscordConfig, userId: string, specialist: string
102
161
  return isOwner(config, userId) || (config.access[specialist] ?? []).includes(userId);
103
162
  }
104
163
 
105
- /** Specialists a user may address, from the runtime list of `astro.*` agents. */
164
+ /** Specialists a user may address among those present, from the runtime list of `astro.*` agents. */
106
165
  export function allowedSpecialists(config: DiscordConfig, userId: string, available: readonly string[]): string[] {
107
166
  return available.filter((name) => canUse(config, userId, name));
108
167
  }
@@ -4,8 +4,9 @@ import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { ApprovalServer, type Ticket } from "./approvals.ts";
6
6
  import { chunkMessage, fenceState } from "./chunk.ts";
7
- import { helpText, parseCommand } from "./commands.ts";
8
- import { allowedSpecialists, canUse, loadConfig, loadToken, validateConfig } from "./config.ts";
7
+ import { applyAdminCommand, describeConfig } from "./admin.ts";
8
+ import { helpText, needsPrefixText, parseCommand } from "./commands.ts";
9
+ import { allowedSpecialists, canUse, channelSpecialists, loadConfig, loadToken, validateConfig } from "./config.ts";
9
10
  import { INTENTS, initialState, reduce } from "./gateway.ts";
10
11
  import type { RunResult } from "../astro-subagents/child.ts";
11
12
  import { failureReply } from "./index.ts";
@@ -14,7 +15,7 @@ import { DiscordRest } from "./rest.ts";
14
15
  const ID = "123456789012345678";
15
16
  const OTHER = "223456789012345678";
16
17
  const THIRD = "323456789012345678";
17
- const valid = { applicationId: ID, guildId: ID, channelIds: [ID], owners: [ID], access: { dns: [OTHER] }, approvalTimeoutMinutes: 30 };
18
+ const valid = { applicationId: ID, guildId: ID, channels: { [ID]: { trigger: "always", specialists: "*" }, [OTHER]: { trigger: "mention", specialists: ["arcane"] } }, adminChannelId: ID, owners: [ID], access: { dns: [OTHER] }, approvalTimeoutMinutes: 30 };
18
19
 
19
20
  describe("config", () => {
20
21
  it("validates ids and shapes", () => {
@@ -22,7 +23,11 @@ describe("config", () => {
22
23
  expect(validateConfig(valid, errors)).toEqual(valid);
23
24
  expect(errors).toEqual([]);
24
25
  expect(validateConfig({ ...valid, guildId: "abc" }, errors)).toBeNull();
25
- expect(validateConfig({ ...valid, channelIds: [] }, [])).toBeNull();
26
+ expect(validateConfig({ ...valid, channels: {} }, [])).toBeNull();
27
+ expect(validateConfig({ ...valid, channels: { [ID]: { trigger: "sometimes", specialists: "*" } } }, [])).toBeNull();
28
+ expect(validateConfig({ ...valid, adminChannelId: THIRD }, [])).toBeNull();
29
+ expect(channelSpecialists({ trigger: "always", specialists: "*" }, ["dns", "edge"])).toEqual(["dns", "edge"]);
30
+ expect(channelSpecialists({ trigger: "always", specialists: ["edge", "nope"] }, ["dns", "edge"])).toEqual(["edge"]);
26
31
  expect(validateConfig({ ...valid, access: { "Bad Name": [ID] } }, [])).toBeNull();
27
32
  expect(validateConfig({ ...valid, approvalTimeoutMinutes: 0 }, [])).toBeNull();
28
33
  expect(validateConfig({ ...valid, access: undefined }, [])).toMatchObject({ access: {} });
@@ -81,22 +86,67 @@ describe("chunk", () => {
81
86
  });
82
87
 
83
88
  describe("commands", () => {
84
- const specialists = ["dns", "edge"];
85
- it("parses specialist tasks, mentions, help, and status", () => {
86
- expect(parseCommand("dns list the zones", ID, specialists)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
87
- expect(parseCommand(`<@${ID}> edge probe the front door`, ID, specialists)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door" });
88
- expect(parseCommand("!astro.dns zones", ID, specialists)).toEqual({ kind: "run", specialist: "dns", task: "zones" });
89
- expect(parseCommand("help", ID, specialists)).toEqual({ kind: "help" });
90
- expect(parseCommand("Status", ID, specialists)).toEqual({ kind: "status" });
91
- expect(parseCommand("dns", ID, specialists)).toEqual({ kind: "none" });
92
- expect(parseCommand("hello everyone", ID, specialists)).toEqual({ kind: "none" });
93
- expect(parseCommand("", ID, specialists)).toEqual({ kind: "none" });
89
+ const many = { botUserId: ID, specialists: ["dns", "edge"], trigger: "always" as const, repliedToBot: false };
90
+ const one = { ...many, specialists: ["arcane"] };
91
+ it("parses prefixed tasks, mentions, help, status, and config", () => {
92
+ expect(parseCommand("dns list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
93
+ expect(parseCommand(`<@${ID}> edge probe the front door`, many)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door" });
94
+ expect(parseCommand("!astro.dns zones", many)).toEqual({ kind: "run", specialist: "dns", task: "zones" });
95
+ expect(parseCommand("help", many)).toEqual({ kind: "help" });
96
+ expect(parseCommand("Status", many)).toEqual({ kind: "status" });
97
+ expect(parseCommand("config channel <#1> mention dns", many)).toEqual({ kind: "config", args: ["channel", "<#1>", "mention", "dns"] });
98
+ expect(parseCommand("dns", many)).toEqual({ kind: "help" });
99
+ expect(parseCommand("hello everyone", many)).toEqual({ kind: "none" });
100
+ expect(parseCommand("", many)).toEqual({ kind: "none" });
94
101
  });
95
102
 
96
- it("lists what a user may use", () => {
97
- expect(helpText([], false)).toMatch(/may not/);
98
- expect(helpText(["dns"], true)).toMatch(/`dns`/);
99
- expect(helpText(["dns"], true)).toMatch(/Owners approve/);
103
+ it("needs no prefix with one specialist and asks for one when addressed with several", () => {
104
+ expect(parseCommand("list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects" });
105
+ expect(parseCommand("arcane list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects" });
106
+ expect(parseCommand(`<@${ID}> list the zones`, many)).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
107
+ expect(parseCommand("list the zones", { ...many, repliedToBot: true })).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
108
+ expect(parseCommand(`<@${ID}>`, many)).toEqual({ kind: "help" });
109
+ });
110
+
111
+ it("stays silent in mention channels unless addressed", () => {
112
+ const mention = { ...many, trigger: "mention" as const };
113
+ expect(parseCommand("dns list the zones", mention)).toEqual({ kind: "none" });
114
+ expect(parseCommand(`<@${ID}> dns list the zones`, mention)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
115
+ expect(parseCommand("dns list the zones", { ...mention, repliedToBot: true })).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
116
+ expect(parseCommand("anything", { ...one, trigger: "mention" })).toEqual({ kind: "none" });
117
+ expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything" });
118
+ });
119
+
120
+ it("writes help and prefix texts", () => {
121
+ expect(helpText([], [], false, "always")).toMatch(/No specialist/);
122
+ expect(helpText([], ["dns"], false, "always")).toMatch(/may not/);
123
+ expect(helpText(["arcane"], ["arcane"], false, "mention")).toMatch(/addresses me here goes to `arcane`/);
124
+ expect(helpText(["dns"], ["dns", "edge"], true, "always")).toMatch(/`dns`/);
125
+ expect(needsPrefixText(["dns", "edge"])).toMatch(/`dns`, `edge`/);
126
+ });
127
+ });
128
+
129
+ describe("admin commands", () => {
130
+ const config = validateConfig(valid, [])!;
131
+ const configured = ["dns", "edge", "arcane"];
132
+ it("shows, adds, updates, removes channels and grants access", () => {
133
+ expect(applyAdminCommand(config, ["show"], configured).reply).toContain(`<#${ID}>: always, every specialist (admin)`);
134
+ expect(describeConfig(config)).toContain(`dns: <@${OTHER}>`);
135
+ const added = applyAdminCommand(config, ["channel", `<#${THIRD}>`, "mention", "dns,edge"], configured);
136
+ expect(added.config?.channels[THIRD]).toEqual({ trigger: "mention", specialists: ["dns", "edge"] });
137
+ expect(added.reply).toMatch(/^Added/);
138
+ const updated = applyAdminCommand(added.config!, ["channel", THIRD, "always", "*"], configured);
139
+ expect(updated.config?.channels[THIRD]).toEqual({ trigger: "always", specialists: "*" });
140
+ expect(updated.reply).toMatch(/^Updated/);
141
+ expect(applyAdminCommand(config, ["channel", `<#${THIRD}>`, "mention", "nope"], configured).reply).toMatch(/unknown specialist/);
142
+ expect(applyAdminCommand(config, ["channel", `<#${ID}>`, "remove"], configured)).toEqual({ reply: "The admin channel cannot be removed." });
143
+ expect(applyAdminCommand(updated.config!, ["channel", `<#${THIRD}>`, "remove"], configured).config?.channels[THIRD]).toBeUndefined();
144
+ const granted = applyAdminCommand(config, ["access", "edge", "add", `<@${THIRD}>`], configured);
145
+ expect(granted.config?.access.edge).toEqual([THIRD]);
146
+ expect(applyAdminCommand(granted.config!, ["access", "edge", "add", THIRD], configured).config).toBeUndefined();
147
+ expect(applyAdminCommand(granted.config!, ["access", "edge", "remove", THIRD], configured).config?.access.edge).toEqual([]);
148
+ expect(applyAdminCommand(config, ["access", "nope", "add", THIRD], configured).reply).toMatch(/unknown specialist/);
149
+ expect(applyAdminCommand(config, ["bogus"], configured).reply).toMatch(/config show/);
100
150
  });
101
151
  });
102
152
 
@@ -5,8 +5,9 @@ import { type AgentConfig, defaultDirs, discoverAgents, resolveSkills } from "..
5
5
  import { AGENT_ENV, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
6
6
  import { ApprovalServer, type Ticket } from "./approvals.ts";
7
7
  import { chunkMessage } from "./chunk.ts";
8
- import { CHANNEL_RULES, helpText, parseCommand } from "./commands.ts";
9
- import { ACTIVATION_ENV, type DiscordConfig, allowedSpecialists, canUse, isOwner, loadConfig, loadToken } from "./config.ts";
8
+ import { applyAdminCommand } from "./admin.ts";
9
+ import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
10
+ import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
10
11
  import { GatewayClient } from "./gateway.ts";
11
12
  import { DiscordRest, type MessageComponent } from "./rest.ts";
12
13
 
@@ -54,6 +55,7 @@ interface IncomingMessage {
54
55
  guild_id?: string;
55
56
  content?: string;
56
57
  author?: { id: string; bot?: boolean };
58
+ referenced_message?: { author?: { id: string } } | null;
57
59
  }
58
60
 
59
61
  interface ComponentInteraction {
@@ -104,6 +106,8 @@ class Bridge {
104
106
  private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
105
107
  private config: DiscordConfig;
106
108
  private botUserId = "";
109
+ /** Thread id to parent channel id; null marks a plain channel. */
110
+ private readonly parents = new Map<string, string | null>();
107
111
  private readonly ctx: ExtensionContext;
108
112
  private readonly token: string;
109
113
 
@@ -142,7 +146,7 @@ class Bridge {
142
146
  },
143
147
  });
144
148
  gateway.start();
145
- log(`connected as ${me.username} (${me.id}); channels ${this.config.channelIds.join(", ")}; specialists ${this.specialists().names.join(", ")}`);
149
+ log(`connected as ${me.username} (${me.id}); channels ${Object.keys(this.config.channels).join(", ")}; specialists ${this.specialists().names.join(", ")}`);
146
150
  }
147
151
 
148
152
  private async dispatch(event: string, data: unknown): Promise<void> {
@@ -154,17 +158,54 @@ class Bridge {
154
158
  }
155
159
  }
156
160
 
161
+ /** Settings for a channel or, for a thread, of its parent channel; null when neither is configured. */
162
+ private async settingsFor(channelId: string): Promise<{ id: string; settings: ChannelSettings } | null> {
163
+ const direct = this.config.channels[channelId];
164
+ if (direct) return { id: channelId, settings: direct };
165
+ if (!this.parents.has(channelId)) {
166
+ try {
167
+ const channel = (await this.rest.channel(channelId)) as { parent_id?: string | null };
168
+ this.parents.set(channelId, channel.parent_id ?? null);
169
+ } catch {
170
+ this.parents.set(channelId, null);
171
+ }
172
+ }
173
+ const parent = this.parents.get(channelId);
174
+ const inherited = parent ? this.config.channels[parent] : undefined;
175
+ return parent && inherited ? { id: parent, settings: inherited } : null;
176
+ }
177
+
157
178
  private async onMessage(message: IncomingMessage): Promise<void> {
158
- if (!message.author || message.author.bot || !this.config.channelIds.includes(message.channel_id)) return;
179
+ if (!message.author || message.author.bot) return;
159
180
  this.refreshConfig();
181
+ const channel = await this.settingsFor(message.channel_id);
182
+ if (!channel) return;
160
183
  const { names, agents } = this.specialists();
161
- const command = parseCommand(message.content ?? "", this.botUserId, names);
162
- if (command.kind === "none") return;
184
+ const present = channelSpecialists(channel.settings, names);
163
185
  const userId = message.author.id;
164
- const allowed = allowedSpecialists(this.config, userId, names);
186
+ const command = parseCommand(message.content ?? "", {
187
+ botUserId: this.botUserId,
188
+ specialists: present,
189
+ trigger: channel.settings.trigger,
190
+ repliedToBot: message.referenced_message?.author?.id === this.botUserId,
191
+ });
192
+ if (command.kind === "none") return;
193
+ const allowed = allowedSpecialists(this.config, userId, present);
194
+ const owner = isOwner(this.config, userId);
195
+ if (command.kind === "config") {
196
+ if (!owner || channel.id !== this.config.adminChannelId) return;
197
+ const result = applyAdminCommand(this.config, command.args, names);
198
+ if (result.config) {
199
+ saveConfig(result.config);
200
+ this.config = result.config;
201
+ log(`config changed by ${userId}: ${command.args.join(" ")}`);
202
+ }
203
+ await this.rest.createMessage(message.channel_id, { content: result.reply, replyTo: message.id });
204
+ return;
205
+ }
165
206
  if (allowed.length === 0) return;
166
207
  if (command.kind === "help") {
167
- await this.rest.createMessage(message.channel_id, { content: helpText(allowed, isOwner(this.config, userId)), replyTo: message.id });
208
+ await this.rest.createMessage(message.channel_id, { content: helpText(allowed, present, owner, channel.settings.trigger), replyTo: message.id });
168
209
  return;
169
210
  }
170
211
  if (command.kind === "status") {
@@ -172,6 +213,10 @@ class Bridge {
172
213
  await this.rest.createMessage(message.channel_id, { content: lines.length > 0 ? lines.join("\n") : "Nothing is running.", replyTo: message.id });
173
214
  return;
174
215
  }
216
+ if (command.kind === "needs-prefix") {
217
+ await this.rest.createMessage(message.channel_id, { content: needsPrefixText(allowed), replyTo: message.id });
218
+ return;
219
+ }
175
220
  if (!canUse(this.config, userId, command.specialist)) return;
176
221
  const agent = agents.find((a) => a.name === `astro.${command.specialist}`);
177
222
  if (!agent) return;
@@ -224,7 +269,7 @@ class Bridge {
224
269
  } else {
225
270
  output = finalOutput(result.messages) || "(no output)";
226
271
  }
227
- log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns, model spend ${result.usage.cost.toFixed(4)} USD`);
272
+ log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns, ${result.usage.input + result.usage.output} tokens`);
228
273
  } catch (err) {
229
274
  output = err instanceof Error ? err.message : String(err);
230
275
  } finally {
@@ -13,7 +13,7 @@ import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { createInterface } from "node:readline/promises";
15
15
  import { Writable } from "node:stream";
16
- import { configDir, configPath, tokenPath, validateConfig } from "./config.ts";
16
+ import { type ChannelSettings, configDir, configPath, tokenPath, validateConfig } from "./config.ts";
17
17
  import { DiscordRest } from "./rest.ts";
18
18
 
19
19
  const LABEL = "com.astrofoundry.astro-discord";
@@ -78,26 +78,64 @@ async function main(): Promise<void> {
78
78
  }
79
79
  }
80
80
 
81
- let channelIds: string[];
81
+ const channels: Record<string, ChannelSettings> = {};
82
+ console.log("Channels. For each: its id, then how the bot listens (always, or only when mentioned), then which specialists live there.");
82
83
  for (;;) {
83
- channelIds = await askIds("Channel ID(s) the bot listens in, comma separated: ", 1);
84
- const names: string[] = [];
85
- let ok = true;
86
- for (const id of channelIds) {
87
- try {
88
- const channel = await rest.channel(id);
89
- if (channel.guild_id !== guildId) {
90
- console.log(` channel ${id} is not in that server`);
91
- ok = false;
92
- } else names.push(`#${channel.name ?? id}`);
93
- } catch (err) {
94
- console.log(` channel ${id}: ${err instanceof Error ? err.message : String(err)}`);
95
- ok = false;
84
+ const raw = await ask(Object.keys(channels).length === 0 ? "Channel ID: " : "Another channel ID (Enter to finish): ");
85
+ if (raw.length === 0) {
86
+ if (Object.keys(channels).length > 0) break;
87
+ console.log(" at least one channel is needed");
88
+ continue;
89
+ }
90
+ if (!SNOWFLAKE.test(raw)) {
91
+ console.log(" not a Discord id");
92
+ continue;
93
+ }
94
+ try {
95
+ const channel = await rest.channel(raw);
96
+ if (channel.guild_id !== guildId) {
97
+ console.log(` channel ${raw} is not in that server`);
98
+ continue;
96
99
  }
100
+ console.log(` channel ok: #${channel.name ?? raw}`);
101
+ } catch (err) {
102
+ console.log(` channel ${raw}: ${err instanceof Error ? err.message : String(err)}`);
103
+ continue;
97
104
  }
98
- if (ok) {
99
- console.log(` channels ok: ${names.join(", ")}`);
100
- break;
105
+ let trigger: "always" | "mention" = "always";
106
+ for (;;) {
107
+ const answer = (await ask(" trigger, always or mention [always]: ")).toLowerCase();
108
+ if (answer === "" || answer === "always") break;
109
+ if (answer === "mention") {
110
+ trigger = "mention";
111
+ break;
112
+ }
113
+ console.log(" always or mention");
114
+ }
115
+ let specialists: "*" | string[] = "*";
116
+ for (;;) {
117
+ const answer = await ask(" specialists, * for all or comma separated names (dns,edge,...) [*]: ");
118
+ if (answer === "" || answer === "*") break;
119
+ const names = answer.split(/[\s,]+/).filter(Boolean);
120
+ if (names.every((n) => /^[a-z][a-z0-9-]*$/.test(n))) {
121
+ specialists = [...new Set(names)];
122
+ break;
123
+ }
124
+ console.log(" lowercase names only");
125
+ }
126
+ channels[raw] = { trigger, specialists };
127
+ }
128
+ const channelIds = Object.keys(channels);
129
+ let adminChannelId = channelIds[0];
130
+ if (channelIds.length > 1) {
131
+ for (;;) {
132
+ const answer = await ask(`Admin channel for config commands [${channelIds[0]}]: `);
133
+ if (answer.length === 0) break;
134
+ if (channelIds.includes(answer)) {
135
+ adminChannelId = answer;
136
+ break;
137
+ }
138
+ console.log(" must be one of the channels above");
101
139
  }
102
140
  }
103
141
 
@@ -124,7 +162,7 @@ async function main(): Promise<void> {
124
162
  if (timeoutAnswer.length > 0) timeout = Number(timeoutAnswer);
125
163
 
126
164
  const errors: string[] = [];
127
- const config = validateConfig({ applicationId, guildId, channelIds, owners, access, approvalTimeoutMinutes: timeout }, errors);
165
+ const config = validateConfig({ applicationId, guildId, channels, adminChannelId, owners, access, approvalTimeoutMinutes: timeout }, errors);
128
166
  if (!config) {
129
167
  console.error(`invalid configuration: ${errors.join("; ")}`);
130
168
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -105,8 +105,8 @@ Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller t
105
105
 
106
106
  The `astro-discord` extension gives Discord users access to the specialists from a channel. It runs in a headless Pi host on Cortex (LaunchAgent `com.astrofoundry.astro-discord`, user `cortex`), never in interactive sessions.
107
107
 
108
- - In a configured channel, `dns list the zones` or `@Cortex edge probe the front door` runs the specialist and posts the answer as a reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` lists what you may use; `status` lists running tasks. Long answers arrive as a `.md` attachment.
109
- - Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Edit `~/.config/astro-discord/config.json` on Cortex; changes apply to the next message.
108
+ - Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows what you may use in that channel; `status` lists running tasks. Long answers arrive as a `.md` attachment.
109
+ - Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Owners change the configuration from the admin channel (`adminChannelId`) without a shell: `config show`, `config channel <#channel> <always|mention> <*|dns,edge>`, `config channel <#channel> remove`, `config access <specialist> add|remove <@user>`; the bot writes `~/.config/astro-discord/config.json` on Cortex, which is also editable by hand and re-read on every message. Deny the bot's role View Channel on channels it must never read; that is the boundary the map cannot provide.
110
110
  - Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, Arcane redeploys, service restarts) pauses the specialist and posts Approve/Deny buttons; only owners' clicks count; no decision within `approvalTimeoutMinutes` denies it. The specialist then reports the denial. Ambiguous tasks get a question back instead of an action.
111
111
  - Setup, as `cortex`: `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node does not strip types inside `node_modules`, so the files are copied first) prompts for the bot token and the ids, checks each against Discord, and writes `~/.config/astro-discord/{token,config.json,run.sh}` and the LaunchAgent; it prints the `launchctl bootstrap` line. After a pi-astro update restart the bridge with `launchctl kickstart -k gui/$(id -u)/com.astrofoundry.astro-discord`. Log: `~/Library/Logs/astro-discord.log`.
112
112
  - Discord side: application `Cortex` with a bot, Message Content Intent enabled, invited with scopes `bot` and `applications.commands` and permissions View Channel, Send Messages, Attach Files, Read Message History, Add Reactions.