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,707 @@
|
|
|
1
|
+
import { readMessengerArtifacts } from "./artifacts";
|
|
2
|
+
import { resolveMessengerToken } from "./config";
|
|
3
|
+
import { MessengerHttpError, messengerFetchJson } from "./http";
|
|
4
|
+
import {
|
|
5
|
+
isRecord,
|
|
6
|
+
type MessengerAdapter,
|
|
7
|
+
type MessengerMessage,
|
|
8
|
+
type MessengerPlatformConfig,
|
|
9
|
+
type MessengerTransportDeps,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
export const DISCORD_API = "https://discord.com/api/v10";
|
|
13
|
+
export const DISCORD_GATEWAY_INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
|
|
14
|
+
const PERMANENT_CLOSE_CODES: Record<number, true> = {
|
|
15
|
+
4004: true,
|
|
16
|
+
4010: true,
|
|
17
|
+
4011: true,
|
|
18
|
+
4012: true,
|
|
19
|
+
4013: true,
|
|
20
|
+
4014: true,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Discord strips boundary whitespace. Invisible guards preserve it in message content;
|
|
24
|
+
// they are part of the delivered text, and consume one UTF-16 unit each.
|
|
25
|
+
const DISCORD_WHITESPACE = /^[\s\u0085\u001c-\u001f]$/u;
|
|
26
|
+
const DISCORD_BOUNDARY_GUARD = "\u200b";
|
|
27
|
+
// SUPPRESS_EMBEDS | SUPPRESS_NOTIFICATIONS: a status line never unfurls links or pings on creation.
|
|
28
|
+
const DISCORD_ACTIVITY_FLAGS = (1 << 2) | (1 << 12);
|
|
29
|
+
|
|
30
|
+
function splitDiscordText(text: string): { chunks: string[]; needsAttachment: boolean } {
|
|
31
|
+
const chunks: string[] = [];
|
|
32
|
+
let needsAttachment = false;
|
|
33
|
+
for (let start = 0; start < text.length; ) {
|
|
34
|
+
const leadingGuard = DISCORD_WHITESPACE.test(text[start]!) ? DISCORD_BOUNDARY_GUARD : "";
|
|
35
|
+
let fallback = 0;
|
|
36
|
+
let end = Math.min(start + 2_000 - leadingGuard.length, text.length);
|
|
37
|
+
for (; end > start; end--) {
|
|
38
|
+
const last = text.charCodeAt(end - 1);
|
|
39
|
+
if (end < text.length && last >= 0xd800 && last <= 0xdbff) continue;
|
|
40
|
+
const trailingWhitespace = DISCORD_WHITESPACE.test(text[end - 1]!);
|
|
41
|
+
if (end - start + leadingGuard.length + Number(trailingWhitespace) > 2_000) continue;
|
|
42
|
+
if (!fallback) fallback = end;
|
|
43
|
+
if (end === text.length || (!trailingWhitespace && !DISCORD_WHITESPACE.test(text[end]!))) break;
|
|
44
|
+
}
|
|
45
|
+
if (end === start) end = fallback;
|
|
46
|
+
const chunk = text.slice(start, end);
|
|
47
|
+
const trailingGuard = DISCORD_WHITESPACE.test(chunk[chunk.length - 1]!) ? DISCORD_BOUNDARY_GUARD : "";
|
|
48
|
+
if (leadingGuard || trailingGuard) needsAttachment = true;
|
|
49
|
+
chunks.push(`${leadingGuard}${chunk}${trailingGuard}`);
|
|
50
|
+
start = end;
|
|
51
|
+
}
|
|
52
|
+
return { chunks, needsAttachment };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class DiscordConfigurationError extends Error {}
|
|
56
|
+
|
|
57
|
+
export interface DiscordClock {
|
|
58
|
+
/** Schedule once and return an idempotent cancellation function. */
|
|
59
|
+
schedule(callback: () => void, delayMs: number): () => void;
|
|
60
|
+
random(): number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DiscordTransportDeps extends MessengerTransportDeps {
|
|
64
|
+
clock?: DiscordClock;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const defaultClock: DiscordClock = {
|
|
68
|
+
schedule(callback, delayMs) {
|
|
69
|
+
const timer = setTimeout(callback, delayMs);
|
|
70
|
+
return () => clearTimeout(timer);
|
|
71
|
+
},
|
|
72
|
+
random: Math.random,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function waitForReconnect(delayMs: number, signal: AbortSignal, clock: DiscordClock): Promise<void> {
|
|
76
|
+
if (signal.aborted) return Promise.resolve();
|
|
77
|
+
const result = Promise.withResolvers<void>();
|
|
78
|
+
const finish = (): void => {
|
|
79
|
+
cancel();
|
|
80
|
+
signal.removeEventListener("abort", finish);
|
|
81
|
+
result.resolve();
|
|
82
|
+
};
|
|
83
|
+
const cancel = clock.schedule(finish, delayMs);
|
|
84
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
85
|
+
if (signal.aborted) finish();
|
|
86
|
+
return result.promise;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface DiscordSession {
|
|
90
|
+
id?: string;
|
|
91
|
+
url?: string;
|
|
92
|
+
sequence: number | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function discordGatewayUrl(value: unknown): string {
|
|
96
|
+
let url: URL;
|
|
97
|
+
try {
|
|
98
|
+
if (typeof value !== "string") throw new Error();
|
|
99
|
+
url = new URL(value);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new DiscordConfigurationError("Discord returned an invalid Gateway URL");
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
url.protocol !== "wss:" ||
|
|
105
|
+
!/^gateway(?:-[a-z0-9-]+)?\.discord\.gg$/.test(url.hostname) ||
|
|
106
|
+
url.username ||
|
|
107
|
+
url.password ||
|
|
108
|
+
url.port ||
|
|
109
|
+
url.hash ||
|
|
110
|
+
url.pathname !== "/"
|
|
111
|
+
) {
|
|
112
|
+
throw new DiscordConfigurationError("Discord returned an untrusted Gateway URL");
|
|
113
|
+
}
|
|
114
|
+
url.search = "?v=10&encoding=json";
|
|
115
|
+
return url.href;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clearSession(session: DiscordSession): void {
|
|
119
|
+
session.id = undefined;
|
|
120
|
+
session.url = undefined;
|
|
121
|
+
session.sequence = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeMessage(value: unknown, accountId: string): MessengerMessage | undefined {
|
|
125
|
+
if (!isRecord(value) || !isRecord(value.author)) return;
|
|
126
|
+
if (value.author.bot || value.webhook_id || value.author.id === accountId) return;
|
|
127
|
+
if (
|
|
128
|
+
typeof value.id !== "string" ||
|
|
129
|
+
!value.id ||
|
|
130
|
+
typeof value.channel_id !== "string" ||
|
|
131
|
+
!value.channel_id ||
|
|
132
|
+
(value.guild_id !== undefined && (typeof value.guild_id !== "string" || !value.guild_id)) ||
|
|
133
|
+
typeof value.author.id !== "string" ||
|
|
134
|
+
!value.author.id ||
|
|
135
|
+
typeof value.content !== "string" ||
|
|
136
|
+
(!value.content.trim() && !(Array.isArray(value.attachments) && value.attachments.length > 0)) ||
|
|
137
|
+
(value.type !== undefined && value.type !== 0 && value.type !== 19)
|
|
138
|
+
)
|
|
139
|
+
return;
|
|
140
|
+
const mentioned =
|
|
141
|
+
Array.isArray(value.mentions) && value.mentions.some(user => isRecord(user) && user.id === accountId);
|
|
142
|
+
const text = value.content.replaceAll(`<@${accountId}>`, "").replaceAll(`<@!${accountId}>`, "").trim();
|
|
143
|
+
const attachments = Array.isArray(value.attachments)
|
|
144
|
+
? value.attachments.map(item =>
|
|
145
|
+
isRecord(item) &&
|
|
146
|
+
typeof item.url === "string" &&
|
|
147
|
+
typeof item.filename === "string" &&
|
|
148
|
+
typeof item.size === "number"
|
|
149
|
+
? { url: item.url, filename: item.filename, size: item.size }
|
|
150
|
+
: { url: "", filename: "", size: -1 },
|
|
151
|
+
)
|
|
152
|
+
: [];
|
|
153
|
+
if (!text && attachments.length === 0) return;
|
|
154
|
+
return {
|
|
155
|
+
platform: "discord",
|
|
156
|
+
accountId,
|
|
157
|
+
channelId: value.channel_id,
|
|
158
|
+
senderId: value.author.id,
|
|
159
|
+
messageId: value.id,
|
|
160
|
+
text,
|
|
161
|
+
...(typeof value.guild_id === "string" && value.guild_id ? { guildId: value.guild_id } : {}),
|
|
162
|
+
direct: value.guild_id === undefined,
|
|
163
|
+
attachments,
|
|
164
|
+
mentioned,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function connectGateway(
|
|
169
|
+
url: string,
|
|
170
|
+
token: string,
|
|
171
|
+
accountId: string,
|
|
172
|
+
session: DiscordSession,
|
|
173
|
+
onMessage: (message: MessengerMessage) => Promise<void>,
|
|
174
|
+
signal: AbortSignal,
|
|
175
|
+
WebSocketImpl: typeof WebSocket,
|
|
176
|
+
clock: DiscordClock,
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
if (signal.aborted) return Promise.resolve();
|
|
179
|
+
const result = Promise.withResolvers<void>();
|
|
180
|
+
let socket: WebSocket;
|
|
181
|
+
try {
|
|
182
|
+
socket = new WebSocketImpl(url);
|
|
183
|
+
} catch {
|
|
184
|
+
clearSession(session);
|
|
185
|
+
return Promise.resolve();
|
|
186
|
+
}
|
|
187
|
+
let finished = false;
|
|
188
|
+
let hello = false;
|
|
189
|
+
let established = false;
|
|
190
|
+
let acknowledged = true;
|
|
191
|
+
let cancelHeartbeat: (() => void) | undefined;
|
|
192
|
+
let cancelHandshake: (() => void) | undefined;
|
|
193
|
+
|
|
194
|
+
const finish = (error?: Error): void => {
|
|
195
|
+
if (finished) return;
|
|
196
|
+
finished = true;
|
|
197
|
+
if (!established) clearSession(session);
|
|
198
|
+
cancelHeartbeat?.();
|
|
199
|
+
cancelHandshake?.();
|
|
200
|
+
signal.removeEventListener("abort", abort);
|
|
201
|
+
socket.removeEventListener("message", receive);
|
|
202
|
+
socket.removeEventListener("close", close);
|
|
203
|
+
socket.removeEventListener("error", failed);
|
|
204
|
+
try {
|
|
205
|
+
socket.close(signal.aborted ? 1000 : 4000);
|
|
206
|
+
} catch {
|
|
207
|
+
/* already closed */
|
|
208
|
+
}
|
|
209
|
+
if (error) result.reject(error);
|
|
210
|
+
else result.resolve();
|
|
211
|
+
};
|
|
212
|
+
const abort = (): void => finish();
|
|
213
|
+
const failed = (): void => finish();
|
|
214
|
+
const send = (op: number, d: unknown): void => {
|
|
215
|
+
if (finished) return;
|
|
216
|
+
try {
|
|
217
|
+
socket.send(JSON.stringify({ op, d }));
|
|
218
|
+
} catch {
|
|
219
|
+
finish();
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const heartbeat = (): void => {
|
|
223
|
+
acknowledged = false;
|
|
224
|
+
send(1, session.sequence);
|
|
225
|
+
};
|
|
226
|
+
const close = (event: CloseEvent): void => {
|
|
227
|
+
if (Object.hasOwn(PERMANENT_CLOSE_CODES, event.code)) {
|
|
228
|
+
const reason =
|
|
229
|
+
event.code === 4004
|
|
230
|
+
? "authentication failed"
|
|
231
|
+
: event.code === 4013 || event.code === 4014
|
|
232
|
+
? "message intents are invalid or not enabled"
|
|
233
|
+
: "Gateway configuration is unsupported";
|
|
234
|
+
finish(new DiscordConfigurationError(`Discord ${reason} (Gateway ${event.code})`));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if ([1000, 1001, 4003, 4007, 4009].includes(event.code)) clearSession(session);
|
|
238
|
+
finish();
|
|
239
|
+
};
|
|
240
|
+
const receive = (event: MessageEvent): void => {
|
|
241
|
+
if (finished || signal.aborted) return;
|
|
242
|
+
let packet: unknown;
|
|
243
|
+
try {
|
|
244
|
+
if (typeof event.data !== "string") return;
|
|
245
|
+
packet = JSON.parse(event.data);
|
|
246
|
+
} catch {
|
|
247
|
+
finish();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!isRecord(packet)) return;
|
|
251
|
+
if (packet.op === 10) {
|
|
252
|
+
if (hello) return;
|
|
253
|
+
if (
|
|
254
|
+
!isRecord(packet.d) ||
|
|
255
|
+
typeof packet.d.heartbeat_interval !== "number" ||
|
|
256
|
+
!Number.isFinite(packet.d.heartbeat_interval) ||
|
|
257
|
+
packet.d.heartbeat_interval <= 0 ||
|
|
258
|
+
packet.d.heartbeat_interval > 300_000
|
|
259
|
+
) {
|
|
260
|
+
finish(new DiscordConfigurationError("Discord returned an invalid heartbeat interval"));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
hello = true;
|
|
264
|
+
const interval = packet.d.heartbeat_interval;
|
|
265
|
+
const tick = (): void => {
|
|
266
|
+
if (finished) return;
|
|
267
|
+
if (!acknowledged) {
|
|
268
|
+
finish();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
heartbeat();
|
|
272
|
+
if (!finished) cancelHeartbeat = clock.schedule(tick, interval);
|
|
273
|
+
};
|
|
274
|
+
cancelHeartbeat = clock.schedule(tick, clock.random() * interval);
|
|
275
|
+
if (session.id && session.sequence !== null) {
|
|
276
|
+
send(6, { token, session_id: session.id, seq: session.sequence });
|
|
277
|
+
} else {
|
|
278
|
+
send(2, {
|
|
279
|
+
token,
|
|
280
|
+
intents: DISCORD_GATEWAY_INTENTS,
|
|
281
|
+
properties: { os: process.platform, browser: "jeo", device: "jeo" },
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
} else if (packet.op === 11) {
|
|
285
|
+
acknowledged = true;
|
|
286
|
+
} else if (packet.op === 1) {
|
|
287
|
+
send(1, session.sequence);
|
|
288
|
+
} else if (packet.op === 7 || packet.op === 9) {
|
|
289
|
+
if (packet.op === 9 && packet.d !== true) clearSession(session);
|
|
290
|
+
finish();
|
|
291
|
+
} else if (packet.op === 0) {
|
|
292
|
+
if (typeof packet.s === "number" && Number.isSafeInteger(packet.s)) session.sequence = packet.s;
|
|
293
|
+
if (packet.t === "READY") {
|
|
294
|
+
if (!isRecord(packet.d) || typeof packet.d.session_id !== "string" || !packet.d.session_id) {
|
|
295
|
+
finish(new DiscordConfigurationError("Discord returned an invalid Gateway session"));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
session.url = discordGatewayUrl(packet.d.resume_gateway_url);
|
|
300
|
+
} catch {
|
|
301
|
+
finish(new DiscordConfigurationError("Discord returned an untrusted resume Gateway URL"));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
session.id = packet.d.session_id;
|
|
305
|
+
established = true;
|
|
306
|
+
cancelHandshake?.();
|
|
307
|
+
} else if (packet.t === "RESUMED") {
|
|
308
|
+
established = true;
|
|
309
|
+
cancelHandshake?.();
|
|
310
|
+
} else if (packet.t === "MESSAGE_CREATE") {
|
|
311
|
+
const message = normalizeMessage(packet.d, accountId);
|
|
312
|
+
if (message) {
|
|
313
|
+
void Promise.resolve()
|
|
314
|
+
.then(() => {
|
|
315
|
+
if (!signal.aborted) return onMessage(message);
|
|
316
|
+
})
|
|
317
|
+
.catch(() => {});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
socket.addEventListener("message", receive);
|
|
323
|
+
socket.addEventListener("close", close);
|
|
324
|
+
socket.addEventListener("error", failed);
|
|
325
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
326
|
+
cancelHandshake = clock.schedule(() => finish(), 35_000);
|
|
327
|
+
if (signal.aborted) finish();
|
|
328
|
+
return result.promise;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function createDiscordAdapter(
|
|
332
|
+
config: MessengerPlatformConfig,
|
|
333
|
+
env: Record<string, string | undefined> = process.env,
|
|
334
|
+
deps: DiscordTransportDeps = {},
|
|
335
|
+
): MessengerAdapter {
|
|
336
|
+
const token = resolveMessengerToken(config.tokenEnv, env)?.trim();
|
|
337
|
+
const clock = deps.clock ?? defaultClock;
|
|
338
|
+
const createdThreads = new Map<string, { parentChannelId: string; senderId: string }>();
|
|
339
|
+
const rememberThread = (threadId: string, parentChannelId: string, senderId: string): void => {
|
|
340
|
+
createdThreads.set(threadId, { parentChannelId, senderId });
|
|
341
|
+
if (createdThreads.size > 10_000) {
|
|
342
|
+
const oldest = createdThreads.keys().next().value;
|
|
343
|
+
if (oldest !== undefined) createdThreads.delete(oldest);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
const activityMessages = new Map<string, string>();
|
|
347
|
+
const headers = (): Record<string, string> => {
|
|
348
|
+
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
349
|
+
return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
|
|
350
|
+
};
|
|
351
|
+
return {
|
|
352
|
+
platform: "discord",
|
|
353
|
+
async start(onMessage, signal) {
|
|
354
|
+
const session: DiscordSession = { sequence: null };
|
|
355
|
+
let accountId: string | undefined;
|
|
356
|
+
let initialUrl: string | undefined;
|
|
357
|
+
let delay = 5_000;
|
|
358
|
+
while (!signal.aborted) {
|
|
359
|
+
let retryDelay = delay;
|
|
360
|
+
try {
|
|
361
|
+
if (!accountId) {
|
|
362
|
+
const user = await messengerFetchJson(
|
|
363
|
+
`${DISCORD_API}/users/@me`,
|
|
364
|
+
{ headers: headers() },
|
|
365
|
+
signal,
|
|
366
|
+
deps.fetch,
|
|
367
|
+
);
|
|
368
|
+
if (signal.aborted) return;
|
|
369
|
+
if (!isRecord(user) || typeof user.id !== "string" || !user.id || user.bot !== true) {
|
|
370
|
+
throw new DiscordConfigurationError("Discord credentials must identify a bot account");
|
|
371
|
+
}
|
|
372
|
+
accountId = user.id;
|
|
373
|
+
}
|
|
374
|
+
if (!initialUrl) {
|
|
375
|
+
const gateway = await messengerFetchJson(
|
|
376
|
+
`${DISCORD_API}/gateway/bot`,
|
|
377
|
+
{ headers: headers() },
|
|
378
|
+
signal,
|
|
379
|
+
deps.fetch,
|
|
380
|
+
);
|
|
381
|
+
if (!isRecord(gateway))
|
|
382
|
+
throw new DiscordConfigurationError("Discord returned invalid Gateway discovery");
|
|
383
|
+
if (isRecord(gateway.session_start_limit) && gateway.session_start_limit.remaining === 0) {
|
|
384
|
+
throw new DiscordConfigurationError("Discord Gateway session start limit is exhausted");
|
|
385
|
+
}
|
|
386
|
+
initialUrl = discordGatewayUrl(gateway.url);
|
|
387
|
+
}
|
|
388
|
+
const deliver = async (message: MessengerMessage): Promise<void> => {
|
|
389
|
+
const thread = createdThreads.get(message.channelId);
|
|
390
|
+
if (thread && thread.senderId === message.senderId) {
|
|
391
|
+
await onMessage({
|
|
392
|
+
...message,
|
|
393
|
+
threadId: message.channelId,
|
|
394
|
+
parentChannelId: thread.parentChannelId,
|
|
395
|
+
});
|
|
396
|
+
} else if (
|
|
397
|
+
!message.direct &&
|
|
398
|
+
config.allowedUserIds.includes(message.senderId) &&
|
|
399
|
+
config.allowedChannelIds &&
|
|
400
|
+
(!message.mentioned || !config.allowedChannelIds.includes(message.channelId))
|
|
401
|
+
) {
|
|
402
|
+
try {
|
|
403
|
+
const channel = await messengerFetchJson(
|
|
404
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
405
|
+
{ headers: headers() },
|
|
406
|
+
signal,
|
|
407
|
+
deps.fetch,
|
|
408
|
+
);
|
|
409
|
+
if (
|
|
410
|
+
isRecord(channel) &&
|
|
411
|
+
channel.id === message.channelId &&
|
|
412
|
+
typeof channel.type === "number" &&
|
|
413
|
+
[10, 11, 12].includes(channel.type) &&
|
|
414
|
+
typeof channel.parent_id === "string" &&
|
|
415
|
+
channel.parent_id
|
|
416
|
+
) {
|
|
417
|
+
if (!message.mentioned) {
|
|
418
|
+
const originalId = typeof channel.name === "string" ? /^Request (\d{1,20})$/.exec(channel.name)?.[1] : undefined;
|
|
419
|
+
if (channel.owner_id !== accountId || !originalId ||
|
|
420
|
+
(!config.allowedChannelIds.includes(channel.parent_id) && !config.allowedChannelIds.includes(message.channelId))) return;
|
|
421
|
+
const original = await messengerFetchJson(
|
|
422
|
+
`${DISCORD_API}/channels/${encodeURIComponent(channel.parent_id)}/messages/${originalId}`,
|
|
423
|
+
{ headers: headers() }, signal, deps.fetch,
|
|
424
|
+
);
|
|
425
|
+
if (!isRecord(original) || original.id !== originalId || original.channel_id !== channel.parent_id ||
|
|
426
|
+
!isRecord(original.author) || original.author.id !== message.senderId || original.author.bot === true || original.webhook_id) return;
|
|
427
|
+
rememberThread(message.channelId, channel.parent_id, message.senderId);
|
|
428
|
+
}
|
|
429
|
+
await onMessage({
|
|
430
|
+
...message,
|
|
431
|
+
threadId: message.channelId,
|
|
432
|
+
parentChannelId: channel.parent_id,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
/* ignore thread lookup failure */
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
await onMessage(message);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
await connectGateway(
|
|
443
|
+
session.url ?? initialUrl,
|
|
444
|
+
token!,
|
|
445
|
+
accountId,
|
|
446
|
+
session,
|
|
447
|
+
deliver,
|
|
448
|
+
signal,
|
|
449
|
+
deps.WebSocket ?? WebSocket,
|
|
450
|
+
clock,
|
|
451
|
+
);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (signal.aborted) return;
|
|
454
|
+
if (error instanceof DiscordConfigurationError) throw error;
|
|
455
|
+
if (
|
|
456
|
+
error instanceof MessengerHttpError &&
|
|
457
|
+
error.status >= 400 &&
|
|
458
|
+
error.status < 500 &&
|
|
459
|
+
error.status !== 429
|
|
460
|
+
) {
|
|
461
|
+
throw new DiscordConfigurationError(
|
|
462
|
+
`Discord authentication or configuration failed (HTTP ${error.status})`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (error instanceof MessengerHttpError && error.retryAfterMs !== undefined) {
|
|
466
|
+
retryDelay = Math.max(retryDelay, error.retryAfterMs);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (signal.aborted) return;
|
|
470
|
+
try {
|
|
471
|
+
await waitForReconnect(retryDelay, signal, clock);
|
|
472
|
+
} catch {
|
|
473
|
+
if (signal.aborted) return;
|
|
474
|
+
throw new Error("Discord reconnect wait failed");
|
|
475
|
+
}
|
|
476
|
+
delay = Math.min(delay * 2, 30_000);
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
async prepare(message, signal) {
|
|
480
|
+
if (message.direct) return message;
|
|
481
|
+
if (message.threadId) {
|
|
482
|
+
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId, message.senderId);
|
|
483
|
+
return message;
|
|
484
|
+
}
|
|
485
|
+
const channel = await messengerFetchJson(
|
|
486
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
487
|
+
{ headers: headers() },
|
|
488
|
+
signal,
|
|
489
|
+
deps.fetch,
|
|
490
|
+
);
|
|
491
|
+
if (!isRecord(channel) || typeof channel.type !== "number")
|
|
492
|
+
throw new Error("Discord channel type unavailable");
|
|
493
|
+
if ([10, 11, 12].includes(channel.type)) {
|
|
494
|
+
if (typeof channel.parent_id !== "string" || !channel.parent_id)
|
|
495
|
+
throw new Error("Discord thread parent unavailable");
|
|
496
|
+
rememberThread(message.channelId, channel.parent_id, message.senderId);
|
|
497
|
+
return { ...message, threadId: message.channelId, parentChannelId: channel.parent_id };
|
|
498
|
+
}
|
|
499
|
+
if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
|
|
500
|
+
const thread = await messengerFetchJson(
|
|
501
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/threads`,
|
|
502
|
+
{ method: "POST", headers: headers(), body: JSON.stringify({ name: `Request ${message.messageId}` }) },
|
|
503
|
+
signal,
|
|
504
|
+
deps.fetch,
|
|
505
|
+
);
|
|
506
|
+
if (!isRecord(thread) || typeof thread.id !== "string" || !thread.id)
|
|
507
|
+
throw new Error("Discord thread creation returned no ID");
|
|
508
|
+
rememberThread(thread.id, message.channelId, message.senderId);
|
|
509
|
+
return { ...message, threadId: thread.id };
|
|
510
|
+
},
|
|
511
|
+
async status(message, state, signal) {
|
|
512
|
+
const base = `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
|
|
513
|
+
const processing = encodeURIComponent("⏳");
|
|
514
|
+
if (state !== "processing") {
|
|
515
|
+
try {
|
|
516
|
+
await messengerFetchJson(
|
|
517
|
+
`${base}/${processing}/@me`,
|
|
518
|
+
{ method: "DELETE", headers: headers() },
|
|
519
|
+
signal,
|
|
520
|
+
deps.fetch,
|
|
521
|
+
);
|
|
522
|
+
} catch {
|
|
523
|
+
/* best-effort reaction removal */
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const emoji = state === "processing" ? "⏳" : state === "success" ? "✅" : "❌";
|
|
527
|
+
await messengerFetchJson(
|
|
528
|
+
`${base}/${encodeURIComponent(emoji)}/@me`,
|
|
529
|
+
{ method: "PUT", headers: headers() },
|
|
530
|
+
signal,
|
|
531
|
+
deps.fetch,
|
|
532
|
+
);
|
|
533
|
+
},
|
|
534
|
+
async activity(message, text, signal) {
|
|
535
|
+
const channelId = message.threadId ?? message.channelId;
|
|
536
|
+
const key = JSON.stringify([channelId, message.messageId]);
|
|
537
|
+
const messages = `${DISCORD_API}/channels/${encodeURIComponent(channelId)}/messages`;
|
|
538
|
+
const statusId = activityMessages.get(key);
|
|
539
|
+
if (text === undefined) {
|
|
540
|
+
if (!statusId) return;
|
|
541
|
+
try {
|
|
542
|
+
await messengerFetchJson(
|
|
543
|
+
`${messages}/${encodeURIComponent(statusId)}`,
|
|
544
|
+
{ method: "DELETE", headers: headers() },
|
|
545
|
+
signal,
|
|
546
|
+
deps.fetch,
|
|
547
|
+
);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
550
|
+
}
|
|
551
|
+
activityMessages.delete(key);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const content = `⏳ ${text}`;
|
|
555
|
+
if (statusId) {
|
|
556
|
+
try {
|
|
557
|
+
await messengerFetchJson(
|
|
558
|
+
`${messages}/${encodeURIComponent(statusId)}`,
|
|
559
|
+
{
|
|
560
|
+
method: "PATCH",
|
|
561
|
+
headers: headers(),
|
|
562
|
+
body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
|
|
563
|
+
},
|
|
564
|
+
signal,
|
|
565
|
+
deps.fetch,
|
|
566
|
+
);
|
|
567
|
+
return;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
570
|
+
activityMessages.delete(key);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const created = await messengerFetchJson(
|
|
574
|
+
messages,
|
|
575
|
+
{
|
|
576
|
+
method: "POST",
|
|
577
|
+
headers: headers(),
|
|
578
|
+
body: JSON.stringify({ content, allowed_mentions: { parse: [] }, flags: DISCORD_ACTIVITY_FLAGS }),
|
|
579
|
+
},
|
|
580
|
+
signal,
|
|
581
|
+
deps.fetch,
|
|
582
|
+
);
|
|
583
|
+
if (!isRecord(created) || typeof created.id !== "string" || !created.id)
|
|
584
|
+
throw new Error("Discord activity message returned no ID");
|
|
585
|
+
activityMessages.set(key, created.id);
|
|
586
|
+
},
|
|
587
|
+
async send(message, text, signal) {
|
|
588
|
+
let files: { name: string; data: Uint8Array }[] = [];
|
|
589
|
+
if (config.artifactDir) {
|
|
590
|
+
try {
|
|
591
|
+
const result = await readMessengerArtifacts(config.artifactDir, message, text);
|
|
592
|
+
text = result.text;
|
|
593
|
+
files = result.files;
|
|
594
|
+
} catch {
|
|
595
|
+
await messengerFetchJson(
|
|
596
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
597
|
+
{
|
|
598
|
+
method: "POST",
|
|
599
|
+
headers: headers(),
|
|
600
|
+
body: JSON.stringify({
|
|
601
|
+
content: "File delivery failed: check the output path and size (maximum 8 MiB).",
|
|
602
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
603
|
+
}),
|
|
604
|
+
},
|
|
605
|
+
signal,
|
|
606
|
+
deps.fetch,
|
|
607
|
+
);
|
|
608
|
+
throw new Error("Discord artifact delivery failed");
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const { chunks, needsAttachment } = splitDiscordText(text);
|
|
612
|
+
for (let index = 0; index < chunks.length + Number(needsAttachment); index++) {
|
|
613
|
+
for (let attempt = 0; ; attempt++) {
|
|
614
|
+
try {
|
|
615
|
+
const attachment = index === chunks.length;
|
|
616
|
+
const requestHeaders = headers();
|
|
617
|
+
let body: string | FormData;
|
|
618
|
+
if (attachment) {
|
|
619
|
+
delete requestHeaders["Content-Type"];
|
|
620
|
+
const form = new FormData();
|
|
621
|
+
form.append(
|
|
622
|
+
"payload_json",
|
|
623
|
+
JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }),
|
|
624
|
+
);
|
|
625
|
+
form.append("files[0]", new Blob([text], { type: "text/plain; charset=utf-8" }), "response.txt");
|
|
626
|
+
body = form;
|
|
627
|
+
} else {
|
|
628
|
+
body = JSON.stringify({
|
|
629
|
+
content: chunks[index],
|
|
630
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
631
|
+
...(index === 0 &&
|
|
632
|
+
message.messageId &&
|
|
633
|
+
(!message.threadId || message.threadId === message.channelId)
|
|
634
|
+
? { message_reference: { message_id: message.messageId, fail_if_not_exists: false } }
|
|
635
|
+
: {}),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
await messengerFetchJson(
|
|
639
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
640
|
+
{
|
|
641
|
+
method: "POST",
|
|
642
|
+
headers: requestHeaders,
|
|
643
|
+
body,
|
|
644
|
+
},
|
|
645
|
+
signal,
|
|
646
|
+
deps.fetch,
|
|
647
|
+
);
|
|
648
|
+
break;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (error instanceof MessengerHttpError && error.status === 429 && attempt < 3 && !signal?.aborted) {
|
|
651
|
+
try {
|
|
652
|
+
await waitForReconnect(
|
|
653
|
+
error.retryAfterMs ?? 1_000,
|
|
654
|
+
signal ?? new AbortController().signal,
|
|
655
|
+
clock,
|
|
656
|
+
);
|
|
657
|
+
if (!signal?.aborted) continue;
|
|
658
|
+
} catch {
|
|
659
|
+
/* stop retry on cancellation */
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
for (const file of files) {
|
|
667
|
+
const requestHeaders = headers();
|
|
668
|
+
delete requestHeaders["Content-Type"];
|
|
669
|
+
const form = new FormData();
|
|
670
|
+
form.append("payload_json", JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }));
|
|
671
|
+
form.append("files[0]", new Blob([file.data]), file.name);
|
|
672
|
+
for (let attempt = 0; ; attempt++) {
|
|
673
|
+
try {
|
|
674
|
+
await messengerFetchJson(
|
|
675
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
676
|
+
{ method: "POST", headers: requestHeaders, body: form },
|
|
677
|
+
signal,
|
|
678
|
+
deps.fetch,
|
|
679
|
+
);
|
|
680
|
+
break;
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (error instanceof MessengerHttpError && error.status === 429 && attempt < 3 && !signal?.aborted) {
|
|
683
|
+
await waitForReconnect(error.retryAfterMs ?? 1_000, signal ?? new AbortController().signal, clock);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (!signal?.aborted) {
|
|
687
|
+
await messengerFetchJson(
|
|
688
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
689
|
+
{
|
|
690
|
+
method: "POST",
|
|
691
|
+
headers: headers(),
|
|
692
|
+
body: JSON.stringify({
|
|
693
|
+
content: "File upload failed; some files may not have been delivered.",
|
|
694
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
695
|
+
}),
|
|
696
|
+
},
|
|
697
|
+
signal,
|
|
698
|
+
deps.fetch,
|
|
699
|
+
).catch(() => {});
|
|
700
|
+
}
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|