@astrofoundry/pi-astro 0.22.4 → 0.23.1
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 +49 -19
- package/extensions/astro-discord/config.ts +67 -8
- package/extensions/astro-discord/discord.test.ts +70 -18
- package/extensions/astro-discord/index.ts +77 -20
- package/extensions/astro-discord/rest.ts +9 -0
- package/extensions/astro-discord/setup.ts +57 -19
- package/package.json +1 -1
- package/specialists/README.md +2 -2
- package/specialists/lib/pinned.ts +2 -1
|
@@ -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,19 @@ 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, canConfig: boolean): 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.");
|
|
57
|
+
const commands = ["`help` this message", "`status` running tasks"];
|
|
58
|
+
if (canConfig) commands.push("`config show`", "`config channel <#channel> <always|mention> <*|name,name>`", "`config access <specialist> add|remove <@user>`");
|
|
59
|
+
lines.push(`Commands: ${commands.join(", ")}.`);
|
|
34
60
|
if (isOwner) lines.push("Owners approve risky calls with the buttons the bot posts and may use every specialist.");
|
|
35
61
|
return lines.join("\n");
|
|
36
62
|
}
|
|
63
|
+
|
|
64
|
+
export function needsPrefixText(specialists: readonly string[]): string {
|
|
65
|
+
return `Several specialists live here; start your message with one of: ${specialists.map((s) => `\`${s}\``).join(", ")}.`;
|
|
66
|
+
}
|
|
@@ -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,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 {
|
|
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";
|
|
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,
|
|
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,
|
|
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,69 @@ describe("chunk", () => {
|
|
|
81
86
|
});
|
|
82
87
|
|
|
83
88
|
describe("commands", () => {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expect(parseCommand(
|
|
88
|
-
expect(parseCommand(
|
|
89
|
-
expect(parseCommand("
|
|
90
|
-
expect(parseCommand("
|
|
91
|
-
expect(parseCommand("
|
|
92
|
-
expect(parseCommand("
|
|
93
|
-
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" });
|
|
94
101
|
});
|
|
95
102
|
|
|
96
|
-
it("
|
|
97
|
-
expect(
|
|
98
|
-
expect(
|
|
99
|
-
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", false)).toMatch(/No specialist/);
|
|
122
|
+
expect(helpText([], ["dns"], false, "always", false)).toMatch(/may not/);
|
|
123
|
+
expect(helpText(["arcane"], ["arcane"], false, "mention", false)).toMatch(/addresses me here goes to `arcane`/);
|
|
124
|
+
expect(helpText(["dns"], ["dns", "edge"], true, "always", false)).toMatch(/`dns`/);
|
|
125
|
+
expect(helpText(["dns"], ["dns"], true, "always", false)).not.toMatch(/config show/);
|
|
126
|
+
expect(helpText(["dns"], ["dns"], true, "always", true)).toMatch(/`config show`/);
|
|
127
|
+
expect(needsPrefixText(["dns", "edge"])).toMatch(/`dns`, `edge`/);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("admin commands", () => {
|
|
132
|
+
const config = validateConfig(valid, [])!;
|
|
133
|
+
const configured = ["dns", "edge", "arcane"];
|
|
134
|
+
it("shows, adds, updates, removes channels and grants access", () => {
|
|
135
|
+
expect(applyAdminCommand(config, ["show"], configured).reply).toContain(`<#${ID}>: always, every specialist (admin)`);
|
|
136
|
+
expect(describeConfig(config)).toContain(`dns: <@${OTHER}>`);
|
|
137
|
+
const added = applyAdminCommand(config, ["channel", `<#${THIRD}>`, "mention", "dns,edge"], configured);
|
|
138
|
+
expect(added.config?.channels[THIRD]).toEqual({ trigger: "mention", specialists: ["dns", "edge"] });
|
|
139
|
+
expect(added.reply).toMatch(/^Added/);
|
|
140
|
+
const updated = applyAdminCommand(added.config!, ["channel", THIRD, "always", "*"], configured);
|
|
141
|
+
expect(updated.config?.channels[THIRD]).toEqual({ trigger: "always", specialists: "*" });
|
|
142
|
+
expect(updated.reply).toMatch(/^Updated/);
|
|
143
|
+
expect(applyAdminCommand(config, ["channel", `<#${THIRD}>`, "mention", "nope"], configured).reply).toMatch(/unknown specialist/);
|
|
144
|
+
expect(applyAdminCommand(config, ["channel", `<#${ID}>`, "remove"], configured)).toEqual({ reply: "The admin channel cannot be removed." });
|
|
145
|
+
expect(applyAdminCommand(updated.config!, ["channel", `<#${THIRD}>`, "remove"], configured).config?.channels[THIRD]).toBeUndefined();
|
|
146
|
+
const granted = applyAdminCommand(config, ["access", "edge", "add", `<@${THIRD}>`], configured);
|
|
147
|
+
expect(granted.config?.access.edge).toEqual([THIRD]);
|
|
148
|
+
expect(applyAdminCommand(granted.config!, ["access", "edge", "add", THIRD], configured).config).toBeUndefined();
|
|
149
|
+
expect(applyAdminCommand(granted.config!, ["access", "edge", "remove", THIRD], configured).config?.access.edge).toEqual([]);
|
|
150
|
+
expect(applyAdminCommand(config, ["access", "nope", "add", THIRD], configured).reply).toMatch(/unknown specialist/);
|
|
151
|
+
expect(applyAdminCommand(config, ["bogus"], configured).reply).toMatch(/config show/);
|
|
100
152
|
});
|
|
101
153
|
});
|
|
102
154
|
|
|
@@ -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 {
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
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
|
|
162
|
-
if (command.kind === "none") return;
|
|
184
|
+
const present = channelSpecialists(channel.settings, names);
|
|
163
185
|
const userId = message.author.id;
|
|
164
|
-
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
|
+
}
|
|
165
206
|
if (allowed.length === 0) return;
|
|
166
207
|
if (command.kind === "help") {
|
|
167
|
-
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, owner && channel.id === this.config.adminChannelId), 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;
|
|
@@ -185,6 +230,14 @@ class Bridge {
|
|
|
185
230
|
task.startedAt = Date.now();
|
|
186
231
|
this.active.set(task.specialist, task);
|
|
187
232
|
await this.rest.addReaction(task.channelId, task.messageId, REACTION.running);
|
|
233
|
+
const placeholder = await this.rest
|
|
234
|
+
.createMessage(task.channelId, { content: `${REACTION.running} **${task.specialist}** is working on your task, please wait…`, replyTo: task.messageId })
|
|
235
|
+
.then((m) => m.id)
|
|
236
|
+
.catch(() => undefined);
|
|
237
|
+
// Discord clears the typing indicator after about ten seconds; refresh it while the task runs.
|
|
238
|
+
await this.rest.triggerTyping(task.channelId).catch(() => undefined);
|
|
239
|
+
const typing = setInterval(() => void this.rest.triggerTyping(task.channelId).catch(() => undefined), 8000);
|
|
240
|
+
typing.unref();
|
|
188
241
|
log(`run astro.${task.specialist} for ${task.userId}: ${taskText.slice(0, 200)}`);
|
|
189
242
|
const dirs = defaultDirs();
|
|
190
243
|
const { skills, missing } = resolveSkills(agent.skills, dirs);
|
|
@@ -224,30 +277,34 @@ class Bridge {
|
|
|
224
277
|
} else {
|
|
225
278
|
output = finalOutput(result.messages) || "(no output)";
|
|
226
279
|
}
|
|
227
|
-
log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns,
|
|
280
|
+
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
281
|
} catch (err) {
|
|
229
282
|
output = err instanceof Error ? err.message : String(err);
|
|
230
283
|
} finally {
|
|
284
|
+
clearInterval(typing);
|
|
231
285
|
this.active.delete(task.specialist);
|
|
232
286
|
}
|
|
233
287
|
await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
|
|
234
288
|
await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
|
|
235
|
-
await this.
|
|
289
|
+
await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
|
|
236
290
|
}
|
|
237
291
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
await this.rest.createMessage(task.channelId, { content
|
|
292
|
+
/** Turns the "working" placeholder into the answer: edit it in place when it fits, otherwise replace it with chunks or an attachment. */
|
|
293
|
+
private async deliver(task: ActiveTask, placeholder: string | undefined, text: string, attachment?: string): Promise<void> {
|
|
294
|
+
const oversized = attachment !== undefined || text.length > ATTACHMENT_THRESHOLD;
|
|
295
|
+
if (oversized) {
|
|
296
|
+
if (placeholder) await this.rest.deleteMessage(task.channelId, placeholder).catch(() => undefined);
|
|
297
|
+
const file = attachment !== undefined ? { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } : { name: `astro-${task.specialist}-${Date.now()}.md`, content: text };
|
|
298
|
+
const content = attachment !== undefined ? text.slice(0, 1900) : `${text.slice(0, 1200).trim()}\n… full answer attached (${text.length} characters).`;
|
|
299
|
+
await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, file });
|
|
246
300
|
return;
|
|
247
301
|
}
|
|
248
302
|
const chunks = chunkMessage(text);
|
|
249
|
-
|
|
250
|
-
|
|
303
|
+
if (chunks.length === 0) chunks.push(text);
|
|
304
|
+
if (placeholder) await this.rest.editMessage(task.channelId, placeholder, chunks[0]).catch(() => undefined);
|
|
305
|
+
else await this.rest.createMessage(task.channelId, { content: chunks[0], replyTo: task.messageId });
|
|
306
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
307
|
+
await this.rest.createMessage(task.channelId, { content: chunks[i] });
|
|
251
308
|
}
|
|
252
309
|
}
|
|
253
310
|
|
|
@@ -72,6 +72,15 @@ export class DiscordRest {
|
|
|
72
72
|
await this.call("PATCH", `/channels/${channelId}/messages/${messageId}`, JSON.stringify({ content, components, allowed_mentions: { parse: [] } }), "application/json");
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Shows the "Cortex is typing…" indicator for about ten seconds. */
|
|
76
|
+
async triggerTyping(channelId: string): Promise<void> {
|
|
77
|
+
await this.call("POST", `/channels/${channelId}/typing`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async deleteMessage(channelId: string, messageId: string): Promise<void> {
|
|
81
|
+
await this.call("DELETE", `/channels/${channelId}/messages/${messageId}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
75
84
|
async addReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
|
|
76
85
|
await this.call("PUT", `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
|
|
77
86
|
}
|
|
@@ -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.
|
|
@@ -25,7 +25,8 @@ export function pinnedRequest(url: URL, options: PinnedOptions): Promise<PinnedR
|
|
|
25
25
|
return new Promise((resolve, reject) => {
|
|
26
26
|
const req = httpsRequest(
|
|
27
27
|
url,
|
|
28
|
-
|
|
28
|
+
// agent:false forces a new connection per call: a pooled TLS socket returns an empty peer certificate on reuse, which would fail the pin.
|
|
29
|
+
{ method: options.method, rejectUnauthorized: false, headers: options.headers, timeout: options.timeoutMs, agent: false },
|
|
29
30
|
(res) => {
|
|
30
31
|
const socket = res.socket as { getPeerCertificate?: () => { fingerprint256?: string } };
|
|
31
32
|
const fingerprint = socket.getPeerCertificate?.().fingerprint256 ?? "";
|