@astrofoundry/pi-astro 0.21.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/extensions/astro-discord/approvals.ts +124 -0
- package/extensions/astro-discord/chunk.ts +50 -0
- package/extensions/astro-discord/commands.ts +36 -0
- package/extensions/astro-discord/config.ts +108 -0
- package/extensions/astro-discord/discord.test.ts +173 -0
- package/extensions/astro-discord/gateway.ts +212 -0
- package/extensions/astro-discord/index.ts +275 -0
- package/extensions/astro-discord/rest.ts +102 -0
- package/extensions/astro-discord/setup.ts +175 -0
- package/extensions/astro-subagents/child.ts +8 -3
- package/extensions/specialist-gate/approvals.test.ts +47 -0
- package/extensions/specialist-gate/index.ts +84 -0
- package/package.json +1 -1
- package/specialists/AGENTS.md +5 -1
- package/specialists/README.md +10 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/** GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT (privileged, enabled in the developer portal). */
|
|
2
|
+
export const INTENTS = (1 << 0) | (1 << 9) | (1 << 15);
|
|
3
|
+
|
|
4
|
+
export interface GatewayPayload {
|
|
5
|
+
op: number;
|
|
6
|
+
d?: unknown;
|
|
7
|
+
s?: number | null;
|
|
8
|
+
t?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GatewayState {
|
|
12
|
+
sessionId: string | null;
|
|
13
|
+
resumeUrl: string | null;
|
|
14
|
+
seq: number | null;
|
|
15
|
+
heartbeatMs: number | null;
|
|
16
|
+
/** True until the heartbeat ACK arrives; two misses mean the socket is dead. */
|
|
17
|
+
awaitingAck: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GatewayHandlers {
|
|
21
|
+
dispatch: (event: string, data: unknown) => void;
|
|
22
|
+
log: (line: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function initialState(): GatewayState {
|
|
26
|
+
return { sessionId: null, resumeUrl: null, seq: null, heartbeatMs: null, awaitingAck: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type GatewayAction =
|
|
30
|
+
| { kind: "identify" }
|
|
31
|
+
| { kind: "resume" }
|
|
32
|
+
| { kind: "heartbeat"; interval: number }
|
|
33
|
+
| { kind: "heartbeat-now" }
|
|
34
|
+
| { kind: "reconnect"; resume: boolean }
|
|
35
|
+
| { kind: "none" };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure reducer over gateway payloads: updates the session state and names
|
|
39
|
+
* the action the connection should take. Keeps the socket handling small.
|
|
40
|
+
*/
|
|
41
|
+
export function reduce(state: GatewayState, payload: GatewayPayload, handlers: GatewayHandlers): GatewayAction {
|
|
42
|
+
if (typeof payload.s === "number") state.seq = payload.s;
|
|
43
|
+
switch (payload.op) {
|
|
44
|
+
case 10: {
|
|
45
|
+
const hello = payload.d as { heartbeat_interval?: number };
|
|
46
|
+
state.heartbeatMs = typeof hello.heartbeat_interval === "number" ? hello.heartbeat_interval : 41_250;
|
|
47
|
+
return state.sessionId ? { kind: "resume" } : { kind: "identify" };
|
|
48
|
+
}
|
|
49
|
+
case 11:
|
|
50
|
+
state.awaitingAck = false;
|
|
51
|
+
return { kind: "none" };
|
|
52
|
+
case 1:
|
|
53
|
+
return { kind: "heartbeat-now" };
|
|
54
|
+
case 7:
|
|
55
|
+
return { kind: "reconnect", resume: true };
|
|
56
|
+
case 9:
|
|
57
|
+
return { kind: "reconnect", resume: payload.d === true };
|
|
58
|
+
case 0: {
|
|
59
|
+
if (payload.t === "READY") {
|
|
60
|
+
const ready = payload.d as { session_id?: string; resume_gateway_url?: string };
|
|
61
|
+
state.sessionId = ready.session_id ?? null;
|
|
62
|
+
state.resumeUrl = ready.resume_gateway_url ?? null;
|
|
63
|
+
handlers.log("gateway ready");
|
|
64
|
+
} else if (payload.t === "RESUMED") {
|
|
65
|
+
handlers.log("gateway resumed");
|
|
66
|
+
}
|
|
67
|
+
if (payload.t) handlers.dispatch(payload.t, payload.d);
|
|
68
|
+
return { kind: "none" };
|
|
69
|
+
}
|
|
70
|
+
default:
|
|
71
|
+
return { kind: "none" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Close codes after which Discord says not to reconnect. */
|
|
76
|
+
export const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
|
|
77
|
+
|
|
78
|
+
export interface GatewayClientOptions {
|
|
79
|
+
token: string;
|
|
80
|
+
gatewayUrl: string;
|
|
81
|
+
handlers: GatewayHandlers;
|
|
82
|
+
intents?: number;
|
|
83
|
+
/** WebSocket constructor, injectable for tests. */
|
|
84
|
+
socketFactory?: (url: string) => WebSocket;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One gateway connection with heartbeat, identify, resume, and reconnect
|
|
89
|
+
* with backoff. `start()` returns once the first connection is attempted;
|
|
90
|
+
* `stop()` closes for good.
|
|
91
|
+
*/
|
|
92
|
+
export class GatewayClient {
|
|
93
|
+
private readonly state = initialState();
|
|
94
|
+
private socket: WebSocket | null = null;
|
|
95
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
96
|
+
private stopped = false;
|
|
97
|
+
private attempts = 0;
|
|
98
|
+
|
|
99
|
+
constructor(private readonly options: GatewayClientOptions) {}
|
|
100
|
+
|
|
101
|
+
start(): void {
|
|
102
|
+
this.stopped = false;
|
|
103
|
+
this.connect(false);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stop(): void {
|
|
107
|
+
this.stopped = true;
|
|
108
|
+
this.clearHeartbeat();
|
|
109
|
+
this.socket?.close(1000);
|
|
110
|
+
this.socket = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private url(resume: boolean): string {
|
|
114
|
+
const base = resume && this.state.resumeUrl ? this.state.resumeUrl : this.options.gatewayUrl;
|
|
115
|
+
return `${base}${base.includes("?") ? "&" : "?"}v=10&encoding=json`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private send(payload: GatewayPayload): void {
|
|
119
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(payload));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private clearHeartbeat(): void {
|
|
123
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
124
|
+
this.heartbeat = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private connect(resume: boolean): void {
|
|
128
|
+
if (this.stopped) return;
|
|
129
|
+
if (!resume) {
|
|
130
|
+
this.state.sessionId = null;
|
|
131
|
+
this.state.seq = null;
|
|
132
|
+
}
|
|
133
|
+
const factory = this.options.socketFactory ?? ((url: string) => new WebSocket(url));
|
|
134
|
+
const socket = factory(this.url(resume));
|
|
135
|
+
this.socket = socket;
|
|
136
|
+
socket.addEventListener("message", (event: MessageEvent) => {
|
|
137
|
+
let payload: GatewayPayload;
|
|
138
|
+
try {
|
|
139
|
+
payload = JSON.parse(String(event.data)) as GatewayPayload;
|
|
140
|
+
} catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.act(reduce(this.state, payload, this.options.handlers));
|
|
144
|
+
});
|
|
145
|
+
socket.addEventListener("close", (event: CloseEvent) => {
|
|
146
|
+
this.clearHeartbeat();
|
|
147
|
+
if (this.stopped) return;
|
|
148
|
+
if (FATAL_CLOSE_CODES.has(event.code)) {
|
|
149
|
+
this.options.handlers.log(`gateway closed with fatal code ${event.code}: ${event.reason}; not reconnecting`);
|
|
150
|
+
this.stopped = true;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.options.handlers.log(`gateway closed (${event.code}); reconnecting`);
|
|
154
|
+
this.scheduleReconnect(true);
|
|
155
|
+
});
|
|
156
|
+
socket.addEventListener("error", () => {
|
|
157
|
+
this.options.handlers.log("gateway socket error");
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private scheduleReconnect(resume: boolean): void {
|
|
162
|
+
if (this.stopped) return;
|
|
163
|
+
this.attempts++;
|
|
164
|
+
const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.attempts, 6));
|
|
165
|
+
setTimeout(() => this.connect(resume && this.state.sessionId !== null), delay).unref();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private act(action: GatewayAction): void {
|
|
169
|
+
switch (action.kind) {
|
|
170
|
+
case "identify":
|
|
171
|
+
this.attempts = 0;
|
|
172
|
+
this.startHeartbeat();
|
|
173
|
+
this.send({
|
|
174
|
+
op: 2,
|
|
175
|
+
d: { token: this.options.token, intents: this.options.intents ?? INTENTS, properties: { os: process.platform, browser: "astro-discord", device: "astro-discord" } },
|
|
176
|
+
});
|
|
177
|
+
break;
|
|
178
|
+
case "resume":
|
|
179
|
+
this.attempts = 0;
|
|
180
|
+
this.startHeartbeat();
|
|
181
|
+
this.send({ op: 6, d: { token: this.options.token, session_id: this.state.sessionId, seq: this.state.seq } });
|
|
182
|
+
break;
|
|
183
|
+
case "heartbeat-now":
|
|
184
|
+
this.send({ op: 1, d: this.state.seq });
|
|
185
|
+
break;
|
|
186
|
+
case "reconnect":
|
|
187
|
+
this.clearHeartbeat();
|
|
188
|
+
this.socket?.close(4000);
|
|
189
|
+
if (!action.resume) this.state.sessionId = null;
|
|
190
|
+
this.scheduleReconnect(action.resume);
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private startHeartbeat(): void {
|
|
198
|
+
this.clearHeartbeat();
|
|
199
|
+
const interval = this.state.heartbeatMs ?? 41_250;
|
|
200
|
+
this.state.awaitingAck = false;
|
|
201
|
+
this.heartbeat = setInterval(() => {
|
|
202
|
+
if (this.state.awaitingAck) {
|
|
203
|
+
this.options.handlers.log("heartbeat not acknowledged; reconnecting");
|
|
204
|
+
this.act({ kind: "reconnect", resume: true });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.state.awaitingAck = true;
|
|
208
|
+
this.send({ op: 1, d: this.state.seq });
|
|
209
|
+
}, interval);
|
|
210
|
+
this.heartbeat.unref();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
|
|
3
|
+
import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
|
|
4
|
+
import { type AgentConfig, defaultDirs, discoverAgents, resolveSkills } from "../astro-subagents/agents.ts";
|
|
5
|
+
import { type DispatchDefaults, childRemaining, currentDepth, isFailed, resultOutput, runAgent } from "../astro-subagents/child.ts";
|
|
6
|
+
import { ApprovalServer, type Ticket } from "./approvals.ts";
|
|
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";
|
|
10
|
+
import { GatewayClient } from "./gateway.ts";
|
|
11
|
+
import { DiscordRest, type MessageComponent } from "./rest.ts";
|
|
12
|
+
|
|
13
|
+
const REACTION = { running: "⏳", waiting: "🔒", done: "✅", failed: "❌" } as const;
|
|
14
|
+
/** Answers longer than this go as a file attachment with a short summary. */
|
|
15
|
+
const ATTACHMENT_THRESHOLD = 6000;
|
|
16
|
+
|
|
17
|
+
interface IncomingMessage {
|
|
18
|
+
id: string;
|
|
19
|
+
channel_id: string;
|
|
20
|
+
guild_id?: string;
|
|
21
|
+
content?: string;
|
|
22
|
+
author?: { id: string; bot?: boolean };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ComponentInteraction {
|
|
26
|
+
id: string;
|
|
27
|
+
token: string;
|
|
28
|
+
type: number;
|
|
29
|
+
channel_id?: string;
|
|
30
|
+
message?: { id: string };
|
|
31
|
+
data?: { custom_id?: string; component_type?: number };
|
|
32
|
+
member?: { user?: { id: string } };
|
|
33
|
+
user?: { id: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ActiveTask {
|
|
37
|
+
specialist: string;
|
|
38
|
+
channelId: string;
|
|
39
|
+
messageId: string;
|
|
40
|
+
userId: string;
|
|
41
|
+
startedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function log(line: string): void {
|
|
45
|
+
process.stderr.write(`[astro-discord ${new Date().toISOString()}] ${line}\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function approvalButtons(ticketId: string, disabled = false): MessageComponent[] {
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
type: 1,
|
|
52
|
+
components: [
|
|
53
|
+
{ type: 2, style: 3, label: "Approve", custom_id: `apr:${ticketId}:approve`, disabled },
|
|
54
|
+
{ type: 2, style: 4, label: "Deny", custom_id: `apr:${ticketId}:deny`, disabled },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function code(text: string): string {
|
|
61
|
+
return `\`${text.replace(/`/g, "'")}\``;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bridge state for one headless Pi host connected to Discord. */
|
|
65
|
+
class Bridge {
|
|
66
|
+
private readonly rest: DiscordRest;
|
|
67
|
+
private readonly approvals: ApprovalServer;
|
|
68
|
+
private readonly queues = new Map<string, Promise<void>>();
|
|
69
|
+
private readonly active = new Map<string, ActiveTask>();
|
|
70
|
+
private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
|
|
71
|
+
private config: DiscordConfig;
|
|
72
|
+
private botUserId = "";
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly ctx: ExtensionContext,
|
|
76
|
+
config: DiscordConfig,
|
|
77
|
+
private readonly token: string,
|
|
78
|
+
) {
|
|
79
|
+
this.config = config;
|
|
80
|
+
this.rest = new DiscordRest(token);
|
|
81
|
+
this.approvals = new ApprovalServer({ onTicket: (t) => void this.askApproval(t), onExpired: (t) => void this.expireApproval(t) }, config.approvalTimeoutMinutes * 60_000);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Specialists configured for this host, as short names. */
|
|
85
|
+
private specialists(): { names: string[]; agents: AgentConfig[] } {
|
|
86
|
+
const gate = loadSpecialists().config;
|
|
87
|
+
const agents = discoverAgents(process.cwd(), "user", defaultDirs()).agents;
|
|
88
|
+
const names = (gate?.specialists ?? []).map((s) => s.agent.replace(/^astro\./, "")).filter((short) => agents.some((a) => a.name === `astro.${short}`));
|
|
89
|
+
return { names, agents };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private refreshConfig(): void {
|
|
93
|
+
const loaded = loadConfig();
|
|
94
|
+
if (loaded.config) this.config = loaded.config;
|
|
95
|
+
else log(`config not reloaded: ${loaded.errors.join("; ")}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async start(): Promise<void> {
|
|
99
|
+
const me = await this.rest.currentUser();
|
|
100
|
+
this.botUserId = me.id;
|
|
101
|
+
await this.approvals.listen();
|
|
102
|
+
const gateway = new GatewayClient({
|
|
103
|
+
token: this.token,
|
|
104
|
+
gatewayUrl: await this.rest.gatewayUrl(),
|
|
105
|
+
handlers: {
|
|
106
|
+
dispatch: (event, data) => void this.dispatch(event, data),
|
|
107
|
+
log,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
gateway.start();
|
|
111
|
+
log(`connected as ${me.username} (${me.id}); channels ${this.config.channelIds.join(", ")}; specialists ${this.specialists().names.join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async dispatch(event: string, data: unknown): Promise<void> {
|
|
115
|
+
try {
|
|
116
|
+
if (event === "MESSAGE_CREATE") await this.onMessage(data as IncomingMessage);
|
|
117
|
+
else if (event === "INTERACTION_CREATE") await this.onInteraction(data as ComponentInteraction);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
log(`${event} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async onMessage(message: IncomingMessage): Promise<void> {
|
|
124
|
+
if (!message.author || message.author.bot || !this.config.channelIds.includes(message.channel_id)) return;
|
|
125
|
+
this.refreshConfig();
|
|
126
|
+
const { names, agents } = this.specialists();
|
|
127
|
+
const command = parseCommand(message.content ?? "", this.botUserId, names);
|
|
128
|
+
if (command.kind === "none") return;
|
|
129
|
+
const userId = message.author.id;
|
|
130
|
+
const allowed = allowedSpecialists(this.config, userId, names);
|
|
131
|
+
if (allowed.length === 0) return;
|
|
132
|
+
if (command.kind === "help") {
|
|
133
|
+
await this.rest.createMessage(message.channel_id, { content: helpText(allowed, isOwner(this.config, userId)), replyTo: message.id });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (command.kind === "status") {
|
|
137
|
+
const lines = [...this.active.values()].map((t) => `• ${t.specialist} for <@${t.userId}>, running ${Math.round((Date.now() - t.startedAt) / 1000)} s`);
|
|
138
|
+
await this.rest.createMessage(message.channel_id, { content: lines.length > 0 ? lines.join("\n") : "Nothing is running.", replyTo: message.id });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!canUse(this.config, userId, command.specialist)) return;
|
|
142
|
+
const agent = agents.find((a) => a.name === `astro.${command.specialist}`);
|
|
143
|
+
if (!agent) return;
|
|
144
|
+
const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: Date.now() };
|
|
145
|
+
const previous = this.queues.get(command.specialist) ?? Promise.resolve();
|
|
146
|
+
const next = previous.then(() => this.runTask(agent, command.task, task)).catch((err) => log(`run failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
147
|
+
this.queues.set(command.specialist, next);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async runTask(agent: AgentConfig, taskText: string, task: ActiveTask): Promise<void> {
|
|
151
|
+
task.startedAt = Date.now();
|
|
152
|
+
this.active.set(task.specialist, task);
|
|
153
|
+
await this.rest.addReaction(task.channelId, task.messageId, REACTION.running);
|
|
154
|
+
log(`run astro.${task.specialist} for ${task.userId}: ${taskText.slice(0, 200)}`);
|
|
155
|
+
const dirs = defaultDirs();
|
|
156
|
+
const { skills, missing } = resolveSkills(agent.skills, dirs);
|
|
157
|
+
if (missing.length > 0) log(`${agent.name}: skill(s) not found: ${missing.join(", ")}`);
|
|
158
|
+
const defaults: DispatchDefaults = {
|
|
159
|
+
model: this.ctx.model ? `${this.ctx.model.provider}/${this.ctx.model.id}` : undefined,
|
|
160
|
+
thinkingLevel: this.ctx.thinkingLevel,
|
|
161
|
+
};
|
|
162
|
+
const { depth, remaining } = currentDepth();
|
|
163
|
+
let failed = true;
|
|
164
|
+
let output: string;
|
|
165
|
+
try {
|
|
166
|
+
const result = await runAgent({
|
|
167
|
+
agent,
|
|
168
|
+
skills,
|
|
169
|
+
task: taskText,
|
|
170
|
+
cwd: process.cwd(),
|
|
171
|
+
depth,
|
|
172
|
+
remaining: childRemaining(remaining, agent),
|
|
173
|
+
defaults,
|
|
174
|
+
env: { [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
|
|
175
|
+
extraSystemPrompt: CHANNEL_RULES,
|
|
176
|
+
});
|
|
177
|
+
failed = isFailed(result);
|
|
178
|
+
output = resultOutput(result);
|
|
179
|
+
log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns, cost ${result.usage.cost.toFixed(4)}`);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
output = err instanceof Error ? err.message : String(err);
|
|
182
|
+
} finally {
|
|
183
|
+
this.active.delete(task.specialist);
|
|
184
|
+
}
|
|
185
|
+
await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
|
|
186
|
+
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}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async post(task: ActiveTask, text: string): Promise<void> {
|
|
191
|
+
if (text.length > ATTACHMENT_THRESHOLD) {
|
|
192
|
+
const summary = `${text.slice(0, 1200).trim()}\n… full answer attached (${text.length} characters).`;
|
|
193
|
+
await this.rest.createMessage(task.channelId, { content: summary, replyTo: task.messageId, file: { name: `astro-${task.specialist}-${Date.now()}.md`, content: text } });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const chunks = chunkMessage(text);
|
|
197
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
198
|
+
await this.rest.createMessage(task.channelId, { content: chunks[i], replyTo: i === 0 ? task.messageId : undefined });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async askApproval(ticket: Ticket): Promise<void> {
|
|
203
|
+
const short = ticket.agent.replace(/^astro\./, "");
|
|
204
|
+
const task = this.active.get(short);
|
|
205
|
+
if (!task) {
|
|
206
|
+
log(`approval ${ticket.id} for ${ticket.agent} has no active task; denying`);
|
|
207
|
+
this.approvals.decide(ticket.id, "deny", "bridge");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await this.rest.addReaction(task.channelId, task.messageId, REACTION.waiting).catch(() => undefined);
|
|
211
|
+
const owners = this.config.owners.map((id) => `<@${id}>`).join(" ");
|
|
212
|
+
const content = `🔒 **${short}** wants to run ${code(ticket.args.join(" "))} for <@${task.userId}>. ${owners}: approve or deny within ${this.config.approvalTimeoutMinutes} min.`;
|
|
213
|
+
const posted = await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, components: approvalButtons(ticket.id) });
|
|
214
|
+
this.approvalMessages.set(ticket.id, { channelId: posted.channel_id, messageId: posted.id, userMessage: task });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async expireApproval(ticket: Ticket): Promise<void> {
|
|
218
|
+
const posted = this.approvalMessages.get(ticket.id);
|
|
219
|
+
if (!posted) return;
|
|
220
|
+
this.approvalMessages.delete(ticket.id);
|
|
221
|
+
await this.rest.editMessage(posted.channelId, posted.messageId, `⌛ No decision within ${this.config.approvalTimeoutMinutes} min; ${code(ticket.args.join(" "))} was not run.`).catch(() => undefined);
|
|
222
|
+
await this.rest.removeOwnReaction(posted.userMessage.channelId, posted.userMessage.messageId, REACTION.waiting).catch(() => undefined);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async onInteraction(interaction: ComponentInteraction): Promise<void> {
|
|
226
|
+
if (interaction.type !== 3) return;
|
|
227
|
+
const match = /^apr:([0-9a-f-]{36}):(approve|deny)$/.exec(interaction.data?.custom_id ?? "");
|
|
228
|
+
if (!match) return;
|
|
229
|
+
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
|
|
230
|
+
this.refreshConfig();
|
|
231
|
+
if (!isOwner(this.config, userId)) {
|
|
232
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, "Only owners decide approvals.");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const decision = match[2] as "approve" | "deny";
|
|
236
|
+
const ticket = this.approvals.decide(match[1], decision, userId);
|
|
237
|
+
if (!ticket) {
|
|
238
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, "This request was already decided or has expired.");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const posted = this.approvalMessages.get(ticket.id);
|
|
242
|
+
this.approvalMessages.delete(ticket.id);
|
|
243
|
+
const verdict = decision === "approve" ? `✅ Approved by <@${userId}>` : `⛔ Denied by <@${userId}>`;
|
|
244
|
+
await this.rest.updateInteractionMessage(interaction.id, interaction.token, `${verdict}: ${code(ticket.args.join(" "))}`);
|
|
245
|
+
if (posted) await this.rest.removeOwnReaction(posted.userMessage.channelId, posted.userMessage.messageId, REACTION.waiting).catch(() => undefined);
|
|
246
|
+
log(`approval ${ticket.id} ${decision} by ${userId}: ${ticket.args.join(" ")}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Discord bridge to the specialists. Inert unless ASTRO_DISCORD=1, which only
|
|
252
|
+
* the headless LaunchAgent host sets; interactive sessions never connect.
|
|
253
|
+
*/
|
|
254
|
+
export default function astroDiscord(pi: ExtensionAPI): void {
|
|
255
|
+
if (process.env[ACTIVATION_ENV] !== "1") return;
|
|
256
|
+
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
257
|
+
const loaded = loadConfig();
|
|
258
|
+
if (!loaded.config) {
|
|
259
|
+
for (const error of loaded.errors) log(`config error: ${error}`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
let token: string;
|
|
263
|
+
try {
|
|
264
|
+
token = loadToken();
|
|
265
|
+
} catch (err) {
|
|
266
|
+
log(`token error: ${err instanceof Error ? err.message : String(err)}`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await new Bridge(ctx, loaded.config, token).start();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log(`start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export const API = "https://discord.com/api/v10";
|
|
2
|
+
|
|
3
|
+
export interface MessageComponent {
|
|
4
|
+
type: number;
|
|
5
|
+
components?: MessageComponent[];
|
|
6
|
+
style?: number;
|
|
7
|
+
label?: string;
|
|
8
|
+
custom_id?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CreateMessage {
|
|
13
|
+
content: string;
|
|
14
|
+
replyTo?: string;
|
|
15
|
+
components?: MessageComponent[];
|
|
16
|
+
/** One optional text attachment. */
|
|
17
|
+
file?: { name: string; content: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DiscordMessage {
|
|
21
|
+
id: string;
|
|
22
|
+
channel_id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RateLimit {
|
|
26
|
+
retry_after?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Minimal Discord REST client on `fetch`. Every call retries once after a
|
|
31
|
+
* 429 using the returned `retry_after`; anything else non-2xx throws.
|
|
32
|
+
*/
|
|
33
|
+
export class DiscordRest {
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly token: string,
|
|
36
|
+
private readonly fetchImpl: typeof fetch = fetch,
|
|
37
|
+
private readonly base = API,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
private async call(method: string, path: string, body?: BodyInit, contentType?: string, attempt = 0): Promise<Response> {
|
|
41
|
+
const headers: Record<string, string> = { Authorization: `Bot ${this.token}`, "User-Agent": "DiscordBot (https://github.com/astrofoundry/pi-astro, astro-discord)" };
|
|
42
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
43
|
+
const response = await this.fetchImpl(`${this.base}${path}`, { method, headers, body });
|
|
44
|
+
if (response.status === 429 && attempt === 0) {
|
|
45
|
+
const limit = (await response.json().catch(() => ({}))) as RateLimit;
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, Math.ceil((limit.retry_after ?? 1) * 1000)));
|
|
47
|
+
return this.call(method, path, body, contentType, 1);
|
|
48
|
+
}
|
|
49
|
+
if (!response.ok) throw new Error(`Discord ${method} ${path} answered ${response.status}: ${(await response.text()).slice(0, 300)}`);
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async createMessage(channelId: string, message: CreateMessage): Promise<DiscordMessage> {
|
|
54
|
+
const payload: Record<string, unknown> = { content: message.content, allowed_mentions: { parse: [] } };
|
|
55
|
+
if (message.replyTo) payload.message_reference = { message_id: message.replyTo, fail_if_not_exists: false };
|
|
56
|
+
if (message.components) payload.components = message.components;
|
|
57
|
+
if (message.file) {
|
|
58
|
+
const form = new FormData();
|
|
59
|
+
payload.attachments = [{ id: 0, filename: message.file.name }];
|
|
60
|
+
form.append("payload_json", JSON.stringify(payload));
|
|
61
|
+
form.append("files[0]", new Blob([message.file.content], { type: "text/plain" }), message.file.name);
|
|
62
|
+
return (await this.call("POST", `/channels/${channelId}/messages`, form)).json() as Promise<DiscordMessage>;
|
|
63
|
+
}
|
|
64
|
+
return (await this.call("POST", `/channels/${channelId}/messages`, JSON.stringify(payload), "application/json")).json() as Promise<DiscordMessage>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async editMessage(channelId: string, messageId: string, content: string, components: MessageComponent[] = []): Promise<void> {
|
|
68
|
+
await this.call("PATCH", `/channels/${channelId}/messages/${messageId}`, JSON.stringify({ content, components, allowed_mentions: { parse: [] } }), "application/json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async addReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
|
|
72
|
+
await this.call("PUT", `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async removeOwnReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
|
|
76
|
+
await this.call("DELETE", `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Answers a component interaction by editing the message it came from (callback type 7). */
|
|
80
|
+
async updateInteractionMessage(interactionId: string, interactionToken: string, content: string, components: MessageComponent[] = []): Promise<void> {
|
|
81
|
+
await this.call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, JSON.stringify({ type: 7, data: { content, components, allowed_mentions: { parse: [] } } }), "application/json");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Ephemeral reply to an interaction (callback type 4 with the EPHEMERAL flag). */
|
|
85
|
+
async ephemeralReply(interactionId: string, interactionToken: string, content: string): Promise<void> {
|
|
86
|
+
await this.call("POST", `/interactions/${interactionId}/${interactionToken}/callback`, JSON.stringify({ type: 4, data: { content, flags: 64, allowed_mentions: { parse: [] } } }), "application/json");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async gatewayUrl(): Promise<string> {
|
|
90
|
+
const data = (await (await this.call("GET", "/gateway/bot")).json()) as { url?: string };
|
|
91
|
+
if (typeof data.url !== "string") throw new Error("Discord did not return a gateway url");
|
|
92
|
+
return data.url;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async currentUser(): Promise<{ id: string; username: string }> {
|
|
96
|
+
return (await this.call("GET", "/users/@me")).json() as Promise<{ id: string; username: string }>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async channel(channelId: string): Promise<{ id: string; name?: string; guild_id?: string; type: number }> {
|
|
100
|
+
return (await this.call("GET", `/channels/${channelId}`)).json() as Promise<{ id: string; name?: string; guild_id?: string; type: number }>;
|
|
101
|
+
}
|
|
102
|
+
}
|