@astrofoundry/pi-astro 0.22.3 → 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.
- package/extensions/astro-discord/admin.ts +77 -0
- package/extensions/astro-discord/commands.ts +46 -19
- package/extensions/astro-discord/config.ts +67 -8
- package/extensions/astro-discord/discord.test.ts +81 -18
- package/extensions/astro-discord/index.ts +113 -16
- package/extensions/astro-discord/setup.ts +57 -19
- package/package.json +1 -1
- package/specialists/README.md +2 -2
|
@@ -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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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,15 +4,18 @@ 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 {
|
|
8
|
-
import {
|
|
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";
|
|
11
|
+
import type { RunResult } from "../astro-subagents/child.ts";
|
|
12
|
+
import { failureReply } from "./index.ts";
|
|
10
13
|
import { DiscordRest } from "./rest.ts";
|
|
11
14
|
|
|
12
15
|
const ID = "123456789012345678";
|
|
13
16
|
const OTHER = "223456789012345678";
|
|
14
17
|
const THIRD = "323456789012345678";
|
|
15
|
-
const valid = { applicationId: ID, guildId: ID,
|
|
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 };
|
|
16
19
|
|
|
17
20
|
describe("config", () => {
|
|
18
21
|
it("validates ids and shapes", () => {
|
|
@@ -20,7 +23,11 @@ describe("config", () => {
|
|
|
20
23
|
expect(validateConfig(valid, errors)).toEqual(valid);
|
|
21
24
|
expect(errors).toEqual([]);
|
|
22
25
|
expect(validateConfig({ ...valid, guildId: "abc" }, errors)).toBeNull();
|
|
23
|
-
expect(validateConfig({ ...valid,
|
|
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"]);
|
|
24
31
|
expect(validateConfig({ ...valid, access: { "Bad Name": [ID] } }, [])).toBeNull();
|
|
25
32
|
expect(validateConfig({ ...valid, approvalTimeoutMinutes: 0 }, [])).toBeNull();
|
|
26
33
|
expect(validateConfig({ ...valid, access: undefined }, [])).toMatchObject({ access: {} });
|
|
@@ -79,22 +86,67 @@ describe("chunk", () => {
|
|
|
79
86
|
});
|
|
80
87
|
|
|
81
88
|
describe("commands", () => {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
expect(parseCommand(
|
|
86
|
-
expect(parseCommand(
|
|
87
|
-
expect(parseCommand("
|
|
88
|
-
expect(parseCommand("
|
|
89
|
-
expect(parseCommand("
|
|
90
|
-
expect(parseCommand("
|
|
91
|
-
expect(parseCommand("",
|
|
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" });
|
|
92
101
|
});
|
|
93
102
|
|
|
94
|
-
it("
|
|
95
|
-
expect(
|
|
96
|
-
expect(
|
|
97
|
-
expect(
|
|
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/);
|
|
98
150
|
});
|
|
99
151
|
});
|
|
100
152
|
|
|
@@ -147,6 +199,17 @@ describe("rest", () => {
|
|
|
147
199
|
});
|
|
148
200
|
});
|
|
149
201
|
|
|
202
|
+
describe("failure reply", () => {
|
|
203
|
+
it("names the reason and keeps stderr as an attachment", () => {
|
|
204
|
+
const base: RunResult = { agent: "astro.dns", agentSource: "bundled", task: "t", exitCode: 1, messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 } };
|
|
205
|
+
expect(failureReply({ ...base, errorMessage: "timed out after 30 min" })).toEqual({ text: "timed out after 30 min" });
|
|
206
|
+
expect(failureReply({ ...base, stderr: "noise\n" })).toEqual({ text: "the specialist process exited with code 1", attachment: "noise" });
|
|
207
|
+
expect(failureReply({ ...base, stopReason: "aborted" as RunResult["stopReason"] }).text).toBe("the run was stopped");
|
|
208
|
+
const withWords = failureReply({ ...base, messages: [{ role: "assistant", content: [{ type: "text", text: "I asked for approval." }] } as never] });
|
|
209
|
+
expect(withWords.text).toContain("Last words of the specialist:\nI asked for approval.");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
150
213
|
describe("approval server", () => {
|
|
151
214
|
it("creates tickets, reports decisions, expires, and rejects bad tokens", async () => {
|
|
152
215
|
const tickets: Ticket[] = [];
|
|
@@ -2,17 +2,52 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
|
|
3
3
|
import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
|
|
4
4
|
import { type AgentConfig, defaultDirs, discoverAgents, resolveSkills } from "../astro-subagents/agents.ts";
|
|
5
|
-
import { type DispatchDefaults, childRemaining, currentDepth,
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
|
13
14
|
const REACTION = { running: "⏳", waiting: "🔒", done: "✅", failed: "❌" } as const;
|
|
14
15
|
/** Answers longer than this go as a file attachment with a short summary. */
|
|
15
16
|
const ATTACHMENT_THRESHOLD = 6000;
|
|
17
|
+
/** Upper bound for one specialist run started from chat; the wrappers' own timeouts are shorter. */
|
|
18
|
+
const TASK_TIMEOUT_MINUTES = 30;
|
|
19
|
+
|
|
20
|
+
interface ToolCallPart {
|
|
21
|
+
type?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
arguments?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One log line per new message: tool calls with their arguments, tool results, and assistant text lengths. */
|
|
27
|
+
function describeProgress(result: RunResult, seen: number): { lines: string[]; seen: number } {
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
for (const message of result.messages.slice(seen)) {
|
|
30
|
+
if (message.role === "assistant") {
|
|
31
|
+
for (const part of message.content as ToolCallPart[]) {
|
|
32
|
+
if (part.type === "toolCall") lines.push(`tool ${part.name ?? "?"} ${JSON.stringify(part.arguments ?? {}).slice(0, 300)}`);
|
|
33
|
+
else if (part.type === "text") lines.push(`assistant text ${((part as { text?: string }).text ?? "").length} chars`);
|
|
34
|
+
}
|
|
35
|
+
} else if (message.role === "toolResult") {
|
|
36
|
+
const { toolName, isError } = message as { toolName?: string; isError?: boolean };
|
|
37
|
+
lines.push(`result ${toolName ?? "?"}${isError ? " (error)" : ""}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { lines, seen: result.messages.length };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reply for a failed run: the reason in one line, the specialist's last words if any, raw stderr only as an attachment. */
|
|
44
|
+
export function failureReply(result: RunResult): { text: string; attachment?: string } {
|
|
45
|
+
const reason = result.errorMessage?.trim() || (result.stopReason === "aborted" ? "the run was stopped" : `the specialist process exited with code ${result.exitCode}`);
|
|
46
|
+
const lastWords = finalOutput(result.messages).trim();
|
|
47
|
+
const text = lastWords.length > 0 ? `${reason}\n\nLast words of the specialist:\n${lastWords}` : reason;
|
|
48
|
+
const stderr = result.stderr.trim();
|
|
49
|
+
return stderr.length > 0 ? { text, attachment: stderr } : { text };
|
|
50
|
+
}
|
|
16
51
|
|
|
17
52
|
interface IncomingMessage {
|
|
18
53
|
id: string;
|
|
@@ -20,6 +55,7 @@ interface IncomingMessage {
|
|
|
20
55
|
guild_id?: string;
|
|
21
56
|
content?: string;
|
|
22
57
|
author?: { id: string; bot?: boolean };
|
|
58
|
+
referenced_message?: { author?: { id: string } } | null;
|
|
23
59
|
}
|
|
24
60
|
|
|
25
61
|
interface ComponentInteraction {
|
|
@@ -70,6 +106,8 @@ class Bridge {
|
|
|
70
106
|
private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
|
|
71
107
|
private config: DiscordConfig;
|
|
72
108
|
private botUserId = "";
|
|
109
|
+
/** Thread id to parent channel id; null marks a plain channel. */
|
|
110
|
+
private readonly parents = new Map<string, string | null>();
|
|
73
111
|
private readonly ctx: ExtensionContext;
|
|
74
112
|
private readonly token: string;
|
|
75
113
|
|
|
@@ -108,7 +146,7 @@ class Bridge {
|
|
|
108
146
|
},
|
|
109
147
|
});
|
|
110
148
|
gateway.start();
|
|
111
|
-
log(`connected as ${me.username} (${me.id}); channels ${this.config.
|
|
149
|
+
log(`connected as ${me.username} (${me.id}); channels ${Object.keys(this.config.channels).join(", ")}; specialists ${this.specialists().names.join(", ")}`);
|
|
112
150
|
}
|
|
113
151
|
|
|
114
152
|
private async dispatch(event: string, data: unknown): Promise<void> {
|
|
@@ -120,17 +158,54 @@ class Bridge {
|
|
|
120
158
|
}
|
|
121
159
|
}
|
|
122
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
|
+
|
|
123
178
|
private async onMessage(message: IncomingMessage): Promise<void> {
|
|
124
|
-
if (!message.author || message.author.bot
|
|
179
|
+
if (!message.author || message.author.bot) return;
|
|
125
180
|
this.refreshConfig();
|
|
181
|
+
const channel = await this.settingsFor(message.channel_id);
|
|
182
|
+
if (!channel) return;
|
|
126
183
|
const { names, agents } = this.specialists();
|
|
127
|
-
const
|
|
128
|
-
if (command.kind === "none") return;
|
|
184
|
+
const present = channelSpecialists(channel.settings, names);
|
|
129
185
|
const userId = message.author.id;
|
|
130
|
-
const
|
|
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
|
+
}
|
|
131
206
|
if (allowed.length === 0) return;
|
|
132
207
|
if (command.kind === "help") {
|
|
133
|
-
await this.rest.createMessage(message.channel_id, { content: helpText(allowed,
|
|
208
|
+
await this.rest.createMessage(message.channel_id, { content: helpText(allowed, present, owner, channel.settings.trigger), replyTo: message.id });
|
|
134
209
|
return;
|
|
135
210
|
}
|
|
136
211
|
if (command.kind === "status") {
|
|
@@ -138,6 +213,10 @@ class Bridge {
|
|
|
138
213
|
await this.rest.createMessage(message.channel_id, { content: lines.length > 0 ? lines.join("\n") : "Nothing is running.", replyTo: message.id });
|
|
139
214
|
return;
|
|
140
215
|
}
|
|
216
|
+
if (command.kind === "needs-prefix") {
|
|
217
|
+
await this.rest.createMessage(message.channel_id, { content: needsPrefixText(allowed), replyTo: message.id });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
141
220
|
if (!canUse(this.config, userId, command.specialist)) return;
|
|
142
221
|
const agent = agents.find((a) => a.name === `astro.${command.specialist}`);
|
|
143
222
|
if (!agent) return;
|
|
@@ -162,21 +241,35 @@ class Bridge {
|
|
|
162
241
|
const { depth, remaining } = currentDepth();
|
|
163
242
|
let failed = true;
|
|
164
243
|
let output: string;
|
|
244
|
+
let attachment: string | undefined;
|
|
245
|
+
let seen = 0;
|
|
165
246
|
try {
|
|
166
247
|
const result = await runAgent({
|
|
167
|
-
agent,
|
|
248
|
+
agent: { ...agent, timeoutMinutes: agent.timeoutMinutes ?? TASK_TIMEOUT_MINUTES },
|
|
168
249
|
skills,
|
|
169
250
|
task: taskText,
|
|
170
251
|
cwd: process.cwd(),
|
|
171
252
|
depth,
|
|
172
253
|
remaining: childRemaining(remaining, agent),
|
|
173
254
|
defaults,
|
|
174
|
-
|
|
255
|
+
// The child must not become a second Discord bridge.
|
|
256
|
+
env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
|
|
175
257
|
extraSystemPrompt: CHANNEL_RULES,
|
|
258
|
+
onUpdate: (partial) => {
|
|
259
|
+
const progress = describeProgress(partial, seen);
|
|
260
|
+
seen = progress.seen;
|
|
261
|
+
for (const line of progress.lines) log(`astro.${task.specialist}: ${line}`);
|
|
262
|
+
},
|
|
176
263
|
});
|
|
177
264
|
failed = isFailed(result);
|
|
178
|
-
|
|
179
|
-
|
|
265
|
+
if (failed) {
|
|
266
|
+
const reply = failureReply(result);
|
|
267
|
+
output = reply.text;
|
|
268
|
+
attachment = reply.attachment;
|
|
269
|
+
} else {
|
|
270
|
+
output = finalOutput(result.messages) || "(no output)";
|
|
271
|
+
}
|
|
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`);
|
|
180
273
|
} catch (err) {
|
|
181
274
|
output = err instanceof Error ? err.message : String(err);
|
|
182
275
|
} finally {
|
|
@@ -184,10 +277,14 @@ class Bridge {
|
|
|
184
277
|
}
|
|
185
278
|
await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
|
|
186
279
|
await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
|
|
187
|
-
await this.post(task, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}
|
|
280
|
+
await this.post(task, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
|
|
188
281
|
}
|
|
189
282
|
|
|
190
|
-
private async post(task: ActiveTask, text: string): Promise<void> {
|
|
283
|
+
private async post(task: ActiveTask, text: string, attachment?: string): Promise<void> {
|
|
284
|
+
if (attachment !== undefined) {
|
|
285
|
+
await this.rest.createMessage(task.channelId, { content: text.slice(0, 1900), replyTo: task.messageId, file: { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
191
288
|
if (text.length > ATTACHMENT_THRESHOLD) {
|
|
192
289
|
const summary = `${text.slice(0, 1200).trim()}\n… full answer attached (${text.length} characters).`;
|
|
193
290
|
await this.rest.createMessage(task.channelId, { content: summary, replyTo: task.messageId, file: { name: `astro-${task.specialist}-${Date.now()}.md`, content: text } });
|
|
@@ -252,7 +349,7 @@ class Bridge {
|
|
|
252
349
|
* the headless LaunchAgent host sets; interactive sessions never connect.
|
|
253
350
|
*/
|
|
254
351
|
export default function astroDiscord(pi: ExtensionAPI): void {
|
|
255
|
-
if (process.env[ACTIVATION_ENV] !== "1") return;
|
|
352
|
+
if (process.env[ACTIVATION_ENV] !== "1" || process.env[AGENT_ENV]) return;
|
|
256
353
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
257
354
|
const loaded = loadConfig();
|
|
258
355
|
if (!loaded.config) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,
|
|
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
package/specialists/README.md
CHANGED
|
@@ -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
|
-
-
|
|
109
|
-
- Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply.
|
|
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.
|