jeo-code 0.12.6 → 0.12.8
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/CHANGELOG.md +15 -0
- package/README.ja.md +3 -1
- package/README.ko.md +3 -1
- package/README.md +5 -3
- package/README.zh.md +3 -1
- package/package.json +1 -1
- package/src/agent/compaction.ts +6 -7
- package/src/agent/engine.ts +6 -2
- package/src/agent/notify/discord-api.ts +125 -28
- package/src/agent/notify/discord-artifacts.ts +126 -0
- package/src/agent/notify/discord-attachments.ts +116 -0
- package/src/agent/notify/discord-daemon.ts +213 -39
- package/src/agent/notify/session-endpoint.ts +127 -5
- package/src/agent/session.ts +7 -5
- package/src/agent/tools.ts +1 -0
- package/src/ai/providers/anthropic.ts +1 -0
- package/src/ai/providers/openai-responses.ts +1 -0
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +4 -0
- package/src/commands/computer.ts +46 -15
- package/src/commands/launch/agy-turn.ts +20 -7
- package/src/commands/launch/confirm.ts +78 -0
- package/src/commands/launch/flags.ts +7 -0
- package/src/commands/launch/input.ts +18 -1
- package/src/commands/launch.ts +320 -40
- package/src/commands/messenger.ts +146 -0
- package/src/commands/notify.ts +3 -0
- package/src/messenger/activity.ts +30 -0
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +145 -0
- package/src/messenger/discord-setup.ts +568 -0
- package/src/messenger/discord.ts +707 -0
- package/src/messenger/gateway.ts +444 -0
- package/src/messenger/http.ts +88 -0
- package/src/messenger/service.ts +122 -0
- package/src/messenger/session-pool.ts +109 -0
- package/src/messenger/slack.ts +333 -0
- package/src/messenger/telegram.ts +241 -0
- package/src/messenger/types.ts +82 -0
- package/src/tui/components/slash.ts +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const MAX_FILES = 10;
|
|
5
|
+
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
6
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
const CDN_HOSTS = ["cdn.discordapp.com", "media.discordapp.net"];
|
|
8
|
+
|
|
9
|
+
interface Attachment {
|
|
10
|
+
filename: string;
|
|
11
|
+
url: string;
|
|
12
|
+
size: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DiscordAttachmentOptions {
|
|
16
|
+
directory: string;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiscordAttachmentStage {
|
|
22
|
+
files: string[];
|
|
23
|
+
cleanup: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Staged files are private and must be removed with cleanup after the request finishes. */
|
|
27
|
+
export async function stageDiscordAttachments(
|
|
28
|
+
attachments: unknown[],
|
|
29
|
+
options: DiscordAttachmentOptions,
|
|
30
|
+
): Promise<DiscordAttachmentStage> {
|
|
31
|
+
if (!Array.isArray(attachments) || attachments.length > MAX_FILES) throw new Error("Invalid Discord attachment count");
|
|
32
|
+
const validated: Attachment[] = [];
|
|
33
|
+
let declaredTotal = 0;
|
|
34
|
+
for (const value of attachments) {
|
|
35
|
+
if (!value || typeof value !== "object") throw new Error("Invalid Discord attachment");
|
|
36
|
+
const item = value as Record<string, unknown>;
|
|
37
|
+
if (typeof item.filename !== "string" || !item.filename || typeof item.url !== "string" ||
|
|
38
|
+
!Number.isSafeInteger(item.size) || (item.size as number) < 0 || (item.size as number) > MAX_FILE_BYTES) {
|
|
39
|
+
throw new Error("Invalid Discord attachment metadata");
|
|
40
|
+
}
|
|
41
|
+
let url: URL;
|
|
42
|
+
try { url = new URL(item.url); } catch { throw new Error("Invalid Discord attachment URL"); }
|
|
43
|
+
if (url.protocol !== "https:" || !CDN_HOSTS.includes(url.hostname) || url.username || url.password || url.port) {
|
|
44
|
+
throw new Error("Untrusted Discord attachment URL");
|
|
45
|
+
}
|
|
46
|
+
declaredTotal += item.size as number;
|
|
47
|
+
if (declaredTotal > MAX_TOTAL_BYTES) throw new Error("Discord attachments exceed total size limit");
|
|
48
|
+
validated.push({ filename: item.filename, url: url.href, size: item.size as number });
|
|
49
|
+
}
|
|
50
|
+
if (options.signal?.aborted) throw new Error("Discord attachment download aborted");
|
|
51
|
+
const directory = options.directory;
|
|
52
|
+
if (typeof directory !== "string" || !isAbsolute(directory) || resolve(directory) !== directory || basename(directory) !== "input") {
|
|
53
|
+
throw new Error("Invalid Discord attachment staging directory");
|
|
54
|
+
}
|
|
55
|
+
const parent = dirname(directory);
|
|
56
|
+
const info = await lstat(parent);
|
|
57
|
+
if (!info.isDirectory() || (info.mode & 0o077) !== 0 ||
|
|
58
|
+
(typeof process.getuid === "function" && info.uid !== process.getuid()) || await realpath(parent) !== parent) {
|
|
59
|
+
throw new Error("Untrusted Discord attachment staging directory");
|
|
60
|
+
}
|
|
61
|
+
await mkdir(directory, { mode: 0o700 });
|
|
62
|
+
const cleanup = () => rm(directory, { recursive: true, force: true });
|
|
63
|
+
try {
|
|
64
|
+
const files: string[] = [];
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const [index, item] of validated.entries()) {
|
|
67
|
+
const name = item.filename.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 180) || "attachment";
|
|
68
|
+
const path = join(directory, `${index + 1}-${name}`);
|
|
69
|
+
const signal = AbortSignal.any([...(options.signal ? [options.signal] : []), AbortSignal.timeout(30_000)]);
|
|
70
|
+
let response: Response;
|
|
71
|
+
try {
|
|
72
|
+
response = await (options.fetchImpl ?? fetch)(item.url, { redirect: "error", signal });
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error("Discord attachment download failed or aborted");
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok || !response.body) {
|
|
77
|
+
await response.body?.cancel().catch(() => {});
|
|
78
|
+
throw new Error("Discord attachment download failed");
|
|
79
|
+
}
|
|
80
|
+
const file = await open(path, "wx", 0o600).catch(async () => {
|
|
81
|
+
await response.body?.cancel().catch(() => {});
|
|
82
|
+
throw new Error("Discord attachment staging failed");
|
|
83
|
+
});
|
|
84
|
+
const reader = response.body.getReader();
|
|
85
|
+
let size = 0;
|
|
86
|
+
try {
|
|
87
|
+
for (;;) {
|
|
88
|
+
if (signal.aborted) throw new Error("Discord attachment download aborted");
|
|
89
|
+
const { done, value } = await reader.read().catch(() => { throw new Error("Discord attachment download failed or aborted"); });
|
|
90
|
+
if (signal.aborted) throw new Error("Discord attachment download aborted");
|
|
91
|
+
if (done) break;
|
|
92
|
+
size += value.byteLength;
|
|
93
|
+
total += value.byteLength;
|
|
94
|
+
if (size > MAX_FILE_BYTES || size > item.size || total > MAX_TOTAL_BYTES) throw new Error("Discord attachment exceeds size limit");
|
|
95
|
+
let offset = 0;
|
|
96
|
+
while (offset < value.byteLength) {
|
|
97
|
+
if (signal.aborted) throw new Error("Discord attachment download aborted");
|
|
98
|
+
const { bytesWritten } = await file.write(value, offset, value.byteLength - offset);
|
|
99
|
+
if (!bytesWritten) throw new Error("Discord attachment write failed");
|
|
100
|
+
offset += bytesWritten;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (size !== item.size) throw new Error("Discord attachment size mismatch");
|
|
104
|
+
} finally {
|
|
105
|
+
await reader.cancel().catch(() => {});
|
|
106
|
+
await file.close();
|
|
107
|
+
}
|
|
108
|
+
files.push(path);
|
|
109
|
+
}
|
|
110
|
+
if (options.signal?.aborted) throw new Error("Discord attachment download aborted");
|
|
111
|
+
return { files, cleanup };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
await cleanup();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -4,6 +4,8 @@ import { DiscordApi, DiscordApiError, type DiscordGatewayInfo } from "./discord-
|
|
|
4
4
|
import { notifySessionsDir } from "./paths";
|
|
5
5
|
import { isPidAlive } from "./daemon-control";
|
|
6
6
|
import { parseSessionEndpoint } from "./session-discovery";
|
|
7
|
+
import { createDiscordArtifactStage, type DiscordArtifactStage } from "./discord-artifacts";
|
|
8
|
+
import { stageDiscordAttachments, type DiscordAttachmentStage } from "./discord-attachments";
|
|
7
9
|
|
|
8
10
|
export interface DiscordDaemonConfig {
|
|
9
11
|
botToken: string;
|
|
@@ -12,11 +14,12 @@ export interface DiscordDaemonConfig {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export interface DiscordDaemonDependencies {
|
|
15
|
-
api?: Pick<DiscordApi, "getMe" | "getChannel" | "getGatewayBot" | "sendMessage" | "stop">;
|
|
17
|
+
api?: Pick<DiscordApi, "getMe" | "getChannel" | "getGatewayBot" | "sendMessage" | "sendFile" | "createThread" | "addReaction" | "removeReaction" | "stop">;
|
|
16
18
|
WebSocketImpl?: typeof WebSocket;
|
|
17
19
|
GatewayWebSocketImpl?: typeof WebSocket;
|
|
18
20
|
readdir?: (dir: string) => Promise<string[]>;
|
|
19
21
|
readFile?: (file: string) => Promise<string>;
|
|
22
|
+
stageAttachments?: typeof stageDiscordAttachments;
|
|
20
23
|
isPidAlive?: (pid: number) => boolean;
|
|
21
24
|
now?: () => number;
|
|
22
25
|
random?: () => number;
|
|
@@ -36,7 +39,10 @@ interface DiscordSession {
|
|
|
36
39
|
ws: WebSocket;
|
|
37
40
|
verified: boolean;
|
|
38
41
|
records: AgentState[];
|
|
42
|
+
controlEnabled: boolean;
|
|
43
|
+
controlGeneration: number;
|
|
39
44
|
}
|
|
45
|
+
interface RemoteResult { ok: boolean; text?: string; artifactsAllowed?: boolean; controlGeneration?: number }
|
|
40
46
|
|
|
41
47
|
function object(value: unknown): value is Record<string, unknown> {
|
|
42
48
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -59,8 +65,14 @@ export class DiscordDaemon {
|
|
|
59
65
|
private readonly api: NonNullable<DiscordDaemonDependencies["api"]>;
|
|
60
66
|
private readonly timers = new Set<Timer>();
|
|
61
67
|
private readonly pending = new Map<string, { sessionId: string; finish: (ok: boolean) => void }>();
|
|
68
|
+
private readonly remote = new Map<string, { sessionId: string; acknowledged: boolean; finish: (result: RemoteResult) => void }>();
|
|
69
|
+
private readonly staging = new Map<AbortController, string>();
|
|
62
70
|
private readonly replies = new Map<string, { sessionId: string; at: number }>();
|
|
63
71
|
private readonly seen = new Set<string>();
|
|
72
|
+
private readonly threads = new Map<string, string>();
|
|
73
|
+
private readonly relayDestinations = new Map<string, string>();
|
|
74
|
+
private readonly relayVersions = new Map<string, number>();
|
|
75
|
+
private channelType = -1;
|
|
64
76
|
private readonly allowed: Set<string>;
|
|
65
77
|
private stopped = true;
|
|
66
78
|
private started = false;
|
|
@@ -121,6 +133,11 @@ export class DiscordDaemon {
|
|
|
121
133
|
const channel = await this.api.getChannel(this.config.channelId);
|
|
122
134
|
if (this.stopped) return;
|
|
123
135
|
if (![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Unsupported Discord channel type");
|
|
136
|
+
this.channelType = channel.type;
|
|
137
|
+
if ([10, 11, 12].includes(channel.type)) {
|
|
138
|
+
if (typeof channel.owner_id !== "string" || !this.allowed.has(channel.owner_id)) throw new Error("Configured thread must have an allowed human owner");
|
|
139
|
+
this.threads.set(channel.id, channel.owner_id);
|
|
140
|
+
}
|
|
124
141
|
this.intents = channel.type === 1 ? 1 << 12 : (1 << 9) | (1 << 15);
|
|
125
142
|
this.gatewayInfo = await this.api.getGatewayBot();
|
|
126
143
|
if (this.stopped) return;
|
|
@@ -139,6 +156,7 @@ export class DiscordDaemon {
|
|
|
139
156
|
|
|
140
157
|
stop(): void {
|
|
141
158
|
this.stopped = true;
|
|
159
|
+
for (const controller of this.staging.keys()) controller.abort();
|
|
142
160
|
for (const timer of this.timers) (this.deps.clearTimeout ?? clearTimeout)(timer);
|
|
143
161
|
this.timers.clear();
|
|
144
162
|
const gateway = this.gateway;
|
|
@@ -148,8 +166,13 @@ export class DiscordDaemon {
|
|
|
148
166
|
this.sessions.clear();
|
|
149
167
|
for (const request of this.pending.values()) request.finish(false);
|
|
150
168
|
this.pending.clear();
|
|
169
|
+
for (const request of this.remote.values()) request.finish({ ok: false });
|
|
170
|
+
this.remote.clear();
|
|
151
171
|
this.replies.clear();
|
|
152
172
|
this.seen.clear();
|
|
173
|
+
this.threads.clear();
|
|
174
|
+
this.relayDestinations.clear();
|
|
175
|
+
this.relayVersions.clear();
|
|
153
176
|
this.api.stop();
|
|
154
177
|
}
|
|
155
178
|
|
|
@@ -293,11 +316,11 @@ export class DiscordDaemon {
|
|
|
293
316
|
try {
|
|
294
317
|
const WS = this.deps.WebSocketImpl ?? WebSocket;
|
|
295
318
|
const ws = new WS(url.href);
|
|
296
|
-
const session: DiscordSession = { sessionId: id, cwd: info.cwd, pid: info.pid, ws, verified: false, records: [] };
|
|
319
|
+
const session: DiscordSession = { sessionId: id, cwd: info.cwd, pid: info.pid, ws, verified: false, records: [], controlEnabled: false, controlGeneration: -1 };
|
|
297
320
|
this.sessions.set(id, session);
|
|
298
321
|
const timeout = this.later(() => { if (!session.verified) { this.disconnectSession(session); try { ws.close(); } catch {} } }, 10_000);
|
|
299
322
|
ws.onmessage = event => {
|
|
300
|
-
if (this.stopped || this.sessions.get(id) !== session || typeof event.data !== "string" || event.data.length > 1_048_576) return;
|
|
323
|
+
if (this.stopped || this.sessions.get(id) !== session || typeof event.data !== "string" || (!session.verified && event.data.length > 1_048_576)) return;
|
|
301
324
|
let frame: unknown;
|
|
302
325
|
try { frame = JSON.parse(event.data); } catch { return; }
|
|
303
326
|
if (!object(frame)) return;
|
|
@@ -320,7 +343,10 @@ export class DiscordDaemon {
|
|
|
320
343
|
private disconnectSession(session: DiscordSession): void {
|
|
321
344
|
if (this.sessions.get(session.sessionId) !== session) return;
|
|
322
345
|
this.sessions.delete(session.sessionId);
|
|
346
|
+
for (const [controller, id] of this.staging) if (id === session.sessionId) controller.abort();
|
|
347
|
+
this.relayDestinations.delete(session.sessionId);
|
|
323
348
|
for (const request of this.pending.values()) if (request.sessionId === session.sessionId) request.finish(false);
|
|
349
|
+
for (const request of this.remote.values()) if (request.sessionId === session.sessionId) request.finish({ ok: false });
|
|
324
350
|
}
|
|
325
351
|
|
|
326
352
|
private onSessionFrame(session: DiscordSession, frame: Record<string, unknown>): void {
|
|
@@ -330,6 +356,19 @@ export class DiscordDaemon {
|
|
|
330
356
|
return;
|
|
331
357
|
}
|
|
332
358
|
if (frame.sessionId !== session.sessionId) return;
|
|
359
|
+
if (frame.type === "discord_control" && typeof frame.enabled === "boolean" && Number.isSafeInteger(frame.generation) && (frame.generation as number) >= session.controlGeneration) {
|
|
360
|
+
if (session.controlGeneration !== frame.generation || !frame.enabled) {
|
|
361
|
+
for (const [controller, id] of this.staging) if (id === session.sessionId) controller.abort();
|
|
362
|
+
}
|
|
363
|
+
session.controlEnabled = frame.enabled;
|
|
364
|
+
session.controlGeneration = frame.generation as number;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (frame.type === "remote_final" && typeof frame.reqId === "string" && typeof frame.ok === "boolean") {
|
|
368
|
+
const request = this.remote.get(frame.reqId);
|
|
369
|
+
if (request?.sessionId === session.sessionId && request.acknowledged) request.finish({ ok: frame.ok && typeof frame.text === "string", text: typeof frame.text === "string" ? frame.text : undefined, artifactsAllowed: frame.artifactsAllowed === true, controlGeneration: typeof frame.controlGeneration === "number" && Number.isSafeInteger(frame.controlGeneration) ? frame.controlGeneration : undefined });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
333
372
|
const header = `[${session.sessionId}]`;
|
|
334
373
|
if (frame.type === "snapshot" && frame.pid === session.pid && Array.isArray(frame.subagents)) {
|
|
335
374
|
const records = frame.subagents.filter((r): r is AgentState => object(r) && typeof r.id === "string" &&
|
|
@@ -339,33 +378,47 @@ export class DiscordDaemon {
|
|
|
339
378
|
for (const record of records) {
|
|
340
379
|
const before = previous.get(record.id);
|
|
341
380
|
if ((!before && record.status === "running") || (before === "running" && record.status !== "running")) {
|
|
342
|
-
this.emit(`${header} ${record.role} '${record.id}' ${record.status}
|
|
381
|
+
this.emit(`${header} ${record.role} '${record.id}' ${record.status}`, session.sessionId);
|
|
343
382
|
}
|
|
344
383
|
}
|
|
345
384
|
session.records = records;
|
|
346
385
|
} else if (frame.type === "identity_header" && typeof frame.repo === "string") {
|
|
347
386
|
this.emit(`${header} ${frame.repo}${typeof frame.branch === "string" ? ` (${frame.branch})` : ""}`, session.sessionId);
|
|
348
|
-
} else if (frame.type === "
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
this.
|
|
387
|
+
} else if (frame.type === "local_relay_control" && typeof frame.destination === "string" && Number.isSafeInteger(frame.generation) && (frame.generation as number) >= 0) {
|
|
388
|
+
const destination = frame.destination;
|
|
389
|
+
this.relayDestinations.delete(session.sessionId);
|
|
390
|
+
const version = (this.relayVersions.get(session.sessionId) ?? 0) + 1;
|
|
391
|
+
this.relayVersions.set(session.sessionId, version);
|
|
392
|
+
const enabled = destination === this.config.channelId;
|
|
393
|
+
if (destination !== "off" && !enabled) this.deps.onError?.("Discord relay destination is not the configured channel");
|
|
394
|
+
void this.request(session, { type: "local_relay", sessionId: session.sessionId, enabled, generation: frame.generation }).then(ok => {
|
|
395
|
+
if (ok && enabled && this.relayVersions.get(session.sessionId) === version && this.sessions.get(session.sessionId) === session) this.relayDestinations.set(session.sessionId, destination);
|
|
396
|
+
});
|
|
397
|
+
} else if (frame.type === "local_final" && typeof frame.text === "string" && frame.text.trim()) {
|
|
398
|
+
const destination = this.relayDestinations.get(session.sessionId);
|
|
399
|
+
if (destination) this.emit(`${header}\n${frame.text}`, session.sessionId, destination, this.relayVersions.get(session.sessionId));
|
|
352
400
|
}
|
|
353
401
|
}
|
|
354
402
|
|
|
355
|
-
private emit(text: string, sessionId?: string):
|
|
356
|
-
if (this.stopped) return;
|
|
357
|
-
if (this.queued >= 128) { this.deps.onError?.("Discord outbound queue full"); return; }
|
|
403
|
+
private emit(text: string, sessionId?: string, destination = this.config.channelId, relayVersion?: number, guard?: () => boolean): Promise<boolean> {
|
|
404
|
+
if (this.stopped || this.queued >= 128) { this.deps.onError?.("Discord outbound queue full or stopped"); return Promise.resolve(false); }
|
|
358
405
|
this.queued++;
|
|
359
|
-
this.
|
|
360
|
-
|
|
361
|
-
|
|
406
|
+
const eligible = () => !this.stopped && (!guard || guard()) && (relayVersion === undefined || (!!sessionId && this.relayVersions.get(sessionId) === relayVersion && this.relayDestinations.get(sessionId) === destination));
|
|
407
|
+
const result = this.outgoing.then(async () => {
|
|
408
|
+
if (!eligible()) return false;
|
|
409
|
+
const messages = await this.api.sendMessage(destination, text, eligible);
|
|
362
410
|
this.rememberReplies(messages, sessionId);
|
|
411
|
+
return true;
|
|
363
412
|
}).catch(error => {
|
|
364
|
-
if (this.stopped)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
413
|
+
if (!this.stopped) {
|
|
414
|
+
if (error instanceof DiscordApiError) this.rememberReplies(error.sentMessages, sessionId);
|
|
415
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
416
|
+
else this.deps.onError?.("Discord notification delivery failed");
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
368
419
|
}).finally(() => { this.queued--; });
|
|
420
|
+
this.outgoing = result.then(() => {});
|
|
421
|
+
return result;
|
|
369
422
|
}
|
|
370
423
|
|
|
371
424
|
private rememberReplies(messages: { id: string }[], sessionId?: string): void {
|
|
@@ -400,44 +453,165 @@ export class DiscordDaemon {
|
|
|
400
453
|
return promise;
|
|
401
454
|
}
|
|
402
455
|
|
|
456
|
+
private remoteRequest(session: DiscordSession, text: string, reqId: string, controlGeneration: number): Promise<RemoteResult> {
|
|
457
|
+
if (this.remote.size >= 128 || this.pending.size >= 128) return Promise.resolve({ ok: false });
|
|
458
|
+
const { promise, resolve } = Promise.withResolvers<RemoteResult>();
|
|
459
|
+
const cancel = () => {
|
|
460
|
+
if (!this.send(session, { type: "remote_cancel", sessionId: session.sessionId, reqId })) {
|
|
461
|
+
try { session.ws.close(); } catch {}
|
|
462
|
+
this.disconnectSession(session);
|
|
463
|
+
}
|
|
464
|
+
finish({ ok: false });
|
|
465
|
+
};
|
|
466
|
+
const timer = this.later(cancel, 15 * 60_000);
|
|
467
|
+
const ackTimer = this.later(cancel, this.deps.ackTimeoutMs ?? 5_000);
|
|
468
|
+
const finish = (result: RemoteResult) => {
|
|
469
|
+
this.clear(timer); this.clear(ackTimer); this.pending.delete(reqId); this.remote.delete(reqId); resolve(result);
|
|
470
|
+
};
|
|
471
|
+
const remote = { sessionId: session.sessionId, acknowledged: false, finish };
|
|
472
|
+
this.remote.set(reqId, remote);
|
|
473
|
+
this.pending.set(reqId, { sessionId: session.sessionId, finish: ok => { this.clear(ackTimer); this.pending.delete(reqId); if (ok) remote.acknowledged = true; else finish({ ok: false }); } });
|
|
474
|
+
if (!this.send(session, { type: "remote_request", sessionId: session.sessionId, reqId, text, staged: true, controlGeneration })) finish({ ok: false });
|
|
475
|
+
return promise;
|
|
476
|
+
}
|
|
477
|
+
|
|
403
478
|
private async handleMessage(message: unknown): Promise<void> {
|
|
404
|
-
if (this.stopped || !object(message) || message.channel_id !==
|
|
479
|
+
if (this.stopped || !object(message) || typeof message.channel_id !== "string" || !/^\d{1,20}$/.test(message.channel_id) ||
|
|
405
480
|
typeof message.id !== "string" || !/^\d{1,20}$/.test(message.id) || !object(message.author) ||
|
|
406
481
|
typeof message.author.id !== "string" || !this.allowed.has(message.author.id) || message.author.id === this.botId ||
|
|
407
482
|
message.author.bot === true || (message.author.bot !== undefined && message.author.bot !== false) ||
|
|
408
483
|
"webhook_id" in message || ![0, 19].includes(message.type as number) ||
|
|
409
|
-
typeof message.content !== "string" || !message.content.trim() || message.content.length > 4_000 || this.seen.has(message.id)) return;
|
|
484
|
+
typeof message.content !== "string" || (!message.content.trim() && !(Array.isArray(message.attachments) && message.attachments.length)) || message.content.length > 4_000 || this.seen.has(message.id)) return;
|
|
410
485
|
this.seen.add(message.id);
|
|
411
486
|
if (this.seen.size > 2048) this.seen.delete(this.seen.values().next().value!);
|
|
412
|
-
const
|
|
487
|
+
const channelId = message.channel_id;
|
|
488
|
+
// Preserve the validated scalar values across the channel lookup await. TypeScript
|
|
489
|
+
// intentionally does not retain narrowing through an await on a mutable object.
|
|
490
|
+
const authorId = message.author.id;
|
|
491
|
+
const messageId = message.id;
|
|
492
|
+
if (channelId !== this.config.channelId && !this.threads.has(channelId)) {
|
|
493
|
+
const channel = await this.api.getChannel(channelId).catch(() => undefined);
|
|
494
|
+
if (!channel || ![10, 11, 12].includes(channel.type) || channel.parent_id !== this.config.channelId || channel.owner_id !== authorId) return;
|
|
495
|
+
this.threads.set(channelId, authorId);
|
|
496
|
+
}
|
|
497
|
+
if ((channelId !== this.config.channelId || [10, 11, 12].includes(this.channelType)) && this.threads.get(channelId) !== authorId) return;
|
|
498
|
+
let text = message.content.trim();
|
|
499
|
+
for (const mention of [`<@${this.botId}>`, `<@!${this.botId}>`]) {
|
|
500
|
+
if (text === mention || text.startsWith(`${mention} `) || text.startsWith(`${mention}\n`)) {
|
|
501
|
+
text = text.slice(mention.length).trimStart();
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (!text && !(Array.isArray(message.attachments) && message.attachments.length)) return;
|
|
506
|
+
let destination = channelId;
|
|
507
|
+
try {
|
|
508
|
+
if (channelId === this.config.channelId && [0, 5].includes(this.channelType)) {
|
|
509
|
+
destination = (await this.api.createThread(channelId, messageId, "jeo reply")).id;
|
|
510
|
+
this.threads.set(destination, authorId);
|
|
511
|
+
}
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
514
|
+
else { this.deps.onError?.("Discord thread creation failed"); void this.emit("Unable to create a reply thread; check bot permissions.", undefined, channelId); }
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const reaction = async (emoji: string, remove = false) => {
|
|
518
|
+
try { await (remove ? this.api.removeReaction(channelId, messageId, emoji) : this.api.addReaction(channelId, messageId, emoji)); }
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
521
|
+
else this.deps.onError?.("Discord reaction failed");
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const progress = reaction("⏳");
|
|
525
|
+
try {
|
|
526
|
+
const ok = await this.handleCommand(message, text, destination);
|
|
527
|
+
void progress.then(() => reaction("⏳", true)).then(() => reaction(ok ? "✅" : "❌"));
|
|
528
|
+
} catch {
|
|
529
|
+
void progress.then(() => reaction("⏳", true)).then(() => reaction("❌"));
|
|
530
|
+
this.deps.onError?.("Discord command relay failed");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private async handleCommand(message: Record<string, unknown>, text: string, destination: string): Promise<boolean> {
|
|
535
|
+
const emit = (content: string, sessionId?: string) => this.emit(content, sessionId, destination);
|
|
536
|
+
const sendPrompt = async (session: DiscordSession, body: string): Promise<boolean> => {
|
|
537
|
+
if (/^[\/!$]/.test(body) || body.includes("\u0003")) {
|
|
538
|
+
await emit("Remote commands are disabled; send ordinary prompt text.");
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
if (!session.controlEnabled) {
|
|
542
|
+
await emit("Discord remote control is disabled in this session.", session.sessionId);
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
const controlGeneration = session.controlGeneration;
|
|
546
|
+
if (this.stopped || this.staging.size >= 8) {
|
|
547
|
+
await emit("Too many requests are being prepared; try again later.", session.sessionId);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
const controller = new AbortController();
|
|
551
|
+
this.staging.set(controller, session.sessionId);
|
|
552
|
+
let output: DiscordArtifactStage | undefined;
|
|
553
|
+
let input: DiscordAttachmentStage | undefined;
|
|
554
|
+
try {
|
|
555
|
+
const reqId = crypto.randomUUID();
|
|
556
|
+
output = await createDiscordArtifactStage(session.sessionId, reqId);
|
|
557
|
+
if (Array.isArray(message.attachments) && message.attachments.length) input = await (this.deps.stageAttachments ?? stageDiscordAttachments)(message.attachments, { directory: path.join(output.requestDirectory, "input"), signal: controller.signal });
|
|
558
|
+
if (controller.signal.aborted || this.sessions.get(session.sessionId) !== session || !session.controlEnabled || session.controlGeneration !== controlGeneration) return false;
|
|
559
|
+
const result = await this.remoteRequest(session, body || "Please inspect the attached files.", reqId, controlGeneration);
|
|
560
|
+
if (!result.ok || result.text === undefined) {
|
|
561
|
+
await emit("Session request failed, was rejected, interrupted, or timed out.", session.sessionId);
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
const eligible = () => !this.stopped && this.sessions.get(session.sessionId) === session && session.controlEnabled && result.controlGeneration !== undefined && session.controlGeneration === result.controlGeneration;
|
|
565
|
+
if (!eligible()) return false;
|
|
566
|
+
const reply = result.text.split(output.requestDirectory).join("[Discord attachment directory]");
|
|
567
|
+
if (!await this.emit(reply, session.sessionId, destination, undefined, eligible)) return false;
|
|
568
|
+
if (result.artifactsAllowed !== true) {
|
|
569
|
+
if (eligible() && (await fs.readdir(output.directory)).length) await this.emit("File delivery was not authorized for this session response.", session.sessionId, destination, undefined, eligible);
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
const files = await output.collect();
|
|
573
|
+
for (const file of files) {
|
|
574
|
+
if (!eligible()) return false;
|
|
575
|
+
const sent = await this.api.sendFile(destination, file.name, file.data, eligible);
|
|
576
|
+
this.rememberReplies(sent, session.sessionId);
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
581
|
+
this.deps.onError?.("Discord attachment processing or delivery failed");
|
|
582
|
+
await emit("Attachment processing or delivery failed; check file size, type, and bot permissions.", session.sessionId);
|
|
583
|
+
return false;
|
|
584
|
+
} finally {
|
|
585
|
+
await input?.cleanup().catch(() => this.deps.onError?.("Discord input cleanup failed"));
|
|
586
|
+
await output?.cleanup().catch(() => this.deps.onError?.("Discord output cleanup failed"));
|
|
587
|
+
this.staging.delete(controller);
|
|
588
|
+
}
|
|
589
|
+
};
|
|
413
590
|
if (text === "/sessions" || text === "/subagents") {
|
|
414
591
|
const sessions = [...this.sessions.values()].filter(s => s.verified && s.ws.readyState === 1);
|
|
415
|
-
|
|
416
|
-
return;
|
|
592
|
+
return emit(sessions.length ? sessions.map(s => `[${s.sessionId}] ${s.cwd}${text === "/subagents" ? s.records.map(r => `\n ${r.id}: ${r.status} ${r.role} — ${r.task}`).join("") : ""}`).join("\n") : "No connected sessions.");
|
|
417
593
|
}
|
|
418
|
-
if (text === "/help")
|
|
594
|
+
if (text === "/help") return emit(HELP);
|
|
419
595
|
const command = /^\/(send|steer|cancel)\s+(\S+)\s+([\s\S]+)$/.exec(text);
|
|
420
596
|
if (command) {
|
|
421
597
|
const session = this.findSession(command[2]!);
|
|
422
|
-
if (!session) {
|
|
598
|
+
if (!session) { await emit("Session not found or prefix ambiguous. Use /sessions and a unique ID."); return false; }
|
|
423
599
|
const body = command[3]!.trim();
|
|
424
|
-
if (command[1] === "send")
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
return;
|
|
600
|
+
if (command[1] === "send") return sendPrompt(session, body);
|
|
601
|
+
const args = command[1] === "steer" ? /^(\S+)\s+([\s\S]+)$/.exec(body) : /^(\S+)$/.exec(body);
|
|
602
|
+
if (!args) { await emit(HELP); return false; }
|
|
603
|
+
if (!session.controlEnabled) { await emit("Discord remote control is disabled in this session.", session.sessionId); return false; }
|
|
604
|
+
const control = { sessionId: session.sessionId, controlGeneration: session.controlGeneration };
|
|
605
|
+
const ok = await this.request(session, command[1] === "steer" ? { ...control, type: "discord_steer", id: args[1], message: args[2] } : { ...control, type: "discord_cancel", ids: [args[1]] });
|
|
606
|
+
const delivered = await emit(ok ? "Session acknowledged command." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId);
|
|
607
|
+
return ok && delivered;
|
|
434
608
|
}
|
|
435
|
-
if (text.startsWith("/")) {
|
|
609
|
+
if (text.startsWith("/")) { await emit(HELP); return false; }
|
|
436
610
|
const reference = object(message.message_reference) ? message.message_reference : undefined;
|
|
437
|
-
const reply = reference && typeof reference.message_id === "string" && (reference.channel_id === undefined || reference.channel_id ===
|
|
611
|
+
const reply = reference && typeof reference.message_id === "string" && (reference.channel_id === undefined || reference.channel_id === message.channel_id)
|
|
438
612
|
? this.replies.get(reference.message_id) : undefined;
|
|
439
613
|
const session = reply && (this.deps.now ?? Date.now)() - reply.at < 86_400_000 ? this.findSession(reply.sessionId) : undefined;
|
|
440
|
-
if (!session) {
|
|
441
|
-
|
|
614
|
+
if (!session) { await emit("Reply target is unknown or expired. Use /send <unique-session> <text> after /sessions."); return false; }
|
|
615
|
+
return sendPrompt(session, text);
|
|
442
616
|
}
|
|
443
617
|
}
|