jeo-code 0.11.2 → 0.11.3
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 +35 -1
- package/README.ja.md +59 -27
- package/README.ko.md +62 -29
- package/README.md +58 -26
- package/README.zh.md +61 -29
- package/package.json +1 -1
- package/src/agent/config-schema.ts +20 -7
- package/src/agent/debug-session.ts +7 -3
- package/src/agent/engine.ts +46 -102
- package/src/agent/loop-guards.ts +11 -13
- package/src/agent/notify/daemon-control.ts +109 -24
- package/src/agent/notify/discord-api.ts +148 -0
- package/src/agent/notify/discord-daemon.ts +443 -0
- package/src/agent/notify/paths.ts +1 -1
- package/src/agent/notify/session-discovery.ts +16 -0
- package/src/agent/notify/session-endpoint.ts +43 -25
- package/src/agent/notify/slack-api.ts +177 -0
- package/src/agent/notify/slack-daemon.ts +361 -0
- package/src/agent/notify/telegram-daemon.ts +215 -83
- package/src/agent/state.ts +13 -1
- package/src/ai/model-discovery.ts +13 -21
- package/src/autopilot.ts +14 -5
- package/src/cli/runner.ts +4 -4
- package/src/commands/approve.ts +21 -16
- package/src/commands/daemon.ts +5 -3
- package/src/commands/launch/agents-slash.ts +8 -3
- package/src/commands/launch.ts +36 -44
- package/src/commands/notify.ts +215 -83
- package/src/commands/ralplan.ts +2 -1
- package/src/commands/team.ts +17 -12
- package/src/commands/ultragoal.ts +2 -1
|
@@ -15,6 +15,8 @@ import { readGlobalConfig } from "../state";
|
|
|
15
15
|
export interface DaemonLockInfo {
|
|
16
16
|
pid: number;
|
|
17
17
|
startedAt: number;
|
|
18
|
+
purpose?: "daemon" | "pairing";
|
|
19
|
+
ready?: boolean;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export function isPidAlive(pid: number): boolean {
|
|
@@ -152,32 +154,87 @@ export async function readDaemonLock(): Promise<DaemonLockInfo | undefined> {
|
|
|
152
154
|
const raw = await fs.readFile(notifyDaemonLockPath(), "utf-8");
|
|
153
155
|
const parsed = JSON.parse(raw) as Partial<DaemonLockInfo>;
|
|
154
156
|
if (typeof parsed.pid !== "number" || typeof parsed.startedAt !== "number") return undefined;
|
|
155
|
-
|
|
157
|
+
if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || !Number.isFinite(parsed.startedAt)) return undefined;
|
|
158
|
+
return { pid: parsed.pid, startedAt: parsed.startedAt, purpose: parsed.purpose, ready: parsed.ready };
|
|
156
159
|
} catch {
|
|
157
160
|
return undefined;
|
|
158
161
|
}
|
|
159
162
|
}
|
|
160
163
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
async function interruptedRecoveryMessage(): Promise<string | undefined> {
|
|
165
|
+
const guard = `${notifyDaemonLockPath()}.reclaim`;
|
|
166
|
+
const stat = await fs.stat(guard).catch(() => undefined);
|
|
167
|
+
if (stat && Date.now() - stat.mtimeMs >= 30_000) {
|
|
168
|
+
return `Notification lock recovery is blocked by ${guard}. Stop all daemon/pairing processes and verify their owners are gone before manually removing this recovery guard; it is not automatically deleted because that can race a live lock owner.`;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Exclusive process lock shared with Telegram pairing, which also owns getUpdates. */
|
|
174
|
+
export async function acquireDaemonLock(purpose: "daemon" | "pairing" = "daemon"): Promise<{ release: () => Promise<void>; markReady: () => Promise<void> } | undefined> {
|
|
165
175
|
await fs.mkdir(notifyDir(), { recursive: true, mode: 0o700 });
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
176
|
+
const lockPath = notifyDaemonLockPath();
|
|
177
|
+
let contents = JSON.stringify({ pid: process.pid, startedAt: await processStartTimeMs(process.pid) ?? Date.now(), purpose, ready: false, owner: crypto.randomUUID() });
|
|
178
|
+
const create = async () => {
|
|
179
|
+
// Publish a fully written record atomically; a killed writer cannot leave partial JSON.
|
|
180
|
+
const tmp = `${lockPath}.${crypto.randomUUID()}.tmp`;
|
|
181
|
+
try {
|
|
182
|
+
await fs.writeFile(tmp, contents, { mode: 0o600, flag: "wx" });
|
|
183
|
+
await fs.link(tmp, lockPath);
|
|
184
|
+
return true;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
187
|
+
throw error;
|
|
188
|
+
} finally { await fs.unlink(tmp).catch(() => {}); }
|
|
189
|
+
};
|
|
190
|
+
if (!(await create())) {
|
|
191
|
+
// Serialize stale-owner reclamation so a second contender cannot unlink the winner.
|
|
192
|
+
let reclaim: fs.FileHandle;
|
|
193
|
+
try { reclaim = await fs.open(`${lockPath}.reclaim`, "wx", 0o600); }
|
|
194
|
+
catch (error) {
|
|
195
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
196
|
+
const blocked = await interruptedRecoveryMessage();
|
|
197
|
+
if (blocked) throw new Error(blocked);
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const existing = await readDaemonLock();
|
|
204
|
+
if (existing && await isLockOwnerAlive(existing)) return undefined;
|
|
205
|
+
// Recover legacy malformed locks only after a grace period for old writers.
|
|
206
|
+
if (!existing && Date.now() - (await fs.stat(lockPath)).mtimeMs < 30_000) return undefined;
|
|
207
|
+
await fs.unlink(lockPath);
|
|
208
|
+
if (!(await create())) return undefined;
|
|
209
|
+
} finally {
|
|
210
|
+
await reclaim.close();
|
|
211
|
+
await fs.unlink(`${lockPath}.reclaim`).catch(() => {});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
170
214
|
return {
|
|
215
|
+
markReady: async () => {
|
|
216
|
+
if (await fs.readFile(lockPath, "utf-8").catch(() => "") !== contents) throw new Error("Notification daemon lost lock ownership during startup");
|
|
217
|
+
const next = JSON.stringify({ ...JSON.parse(contents), ready: true });
|
|
218
|
+
const tmp = `${lockPath}.${crypto.randomUUID()}.tmp`;
|
|
219
|
+
try {
|
|
220
|
+
await fs.writeFile(tmp, next, { mode: 0o600, flag: "wx" });
|
|
221
|
+
await fs.rename(tmp, lockPath);
|
|
222
|
+
contents = next;
|
|
223
|
+
} finally { await fs.unlink(tmp).catch(() => {}); }
|
|
224
|
+
},
|
|
171
225
|
release: async () => {
|
|
172
|
-
|
|
226
|
+
// An old release closure must not remove a newer owner's lock.
|
|
227
|
+
if (await fs.readFile(lockPath, "utf-8").catch(() => "") === contents) await fs.unlink(lockPath).catch(() => {});
|
|
173
228
|
},
|
|
174
229
|
};
|
|
175
230
|
}
|
|
176
231
|
|
|
177
232
|
export interface DaemonStatus {
|
|
178
|
-
/**
|
|
233
|
+
/** Master toggle and at least one complete, authorized transport. */
|
|
179
234
|
configured: boolean;
|
|
180
235
|
running: boolean;
|
|
236
|
+
pairing?: boolean;
|
|
237
|
+
ready?: boolean;
|
|
181
238
|
/** A lock file exists but its pid is dead — a previous daemon crashed without cleanup. */
|
|
182
239
|
stale: boolean;
|
|
183
240
|
pid?: number;
|
|
@@ -190,7 +247,12 @@ export interface DaemonStatus {
|
|
|
190
247
|
|
|
191
248
|
export async function isNotifyConfigured(): Promise<boolean> {
|
|
192
249
|
const config = await readGlobalConfig();
|
|
193
|
-
|
|
250
|
+
const n = config.notifications;
|
|
251
|
+
return Boolean(n?.enabled && (
|
|
252
|
+
(n.telegram?.botToken && n.telegram.chatId) ||
|
|
253
|
+
(n.discord?.botToken && n.discord.channelId && n.discord.allowedUserIds?.length) ||
|
|
254
|
+
(n.slack?.botToken && n.slack.appToken && n.slack.channelId && n.slack.allowedUserIds?.length)
|
|
255
|
+
));
|
|
194
256
|
}
|
|
195
257
|
|
|
196
258
|
export async function daemonStatus(): Promise<DaemonStatus> {
|
|
@@ -199,7 +261,7 @@ export async function daemonStatus(): Promise<DaemonStatus> {
|
|
|
199
261
|
const { alive, verified } = await inspectLockOwner(lock);
|
|
200
262
|
// Surface the degraded case instead of hiding it: on a host with no `/proc` and no
|
|
201
263
|
// usable `ps`, "running" is an existence-only guess that cannot rule out PID reuse.
|
|
202
|
-
return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
|
|
264
|
+
return { configured, running: alive && lock.purpose !== "pairing", pairing: alive && lock.purpose === "pairing", ready: alive && lock.ready === true, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
|
|
203
265
|
}
|
|
204
266
|
|
|
205
267
|
/** Self-invocation argv for the daemon child (mirrors `memory.ts`'s
|
|
@@ -240,10 +302,11 @@ async function waitFor(predicate: () => Promise<boolean>, timeoutMs: number, ste
|
|
|
240
302
|
return false;
|
|
241
303
|
}
|
|
242
304
|
|
|
243
|
-
export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; pid?: number; message: string }> {
|
|
305
|
+
export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn, waitImpl: (predicate: () => Promise<boolean>, timeoutMs: number) => Promise<boolean> = waitFor): Promise<{ ok: boolean; pid?: number; message: string }> {
|
|
244
306
|
const existing = await readDaemonLock();
|
|
245
307
|
if (existing && (await isLockOwnerAlive(existing))) {
|
|
246
|
-
return { ok:
|
|
308
|
+
if (existing.purpose === "pairing") return { ok: false, message: "Telegram pairing currently owns the notification lock; finish setup first." };
|
|
309
|
+
return { ok: existing.ready === true, pid: existing.pid, message: `daemon ${existing.ready !== true ? "is still initializing" : "already running"} (pid ${existing.pid}); connectivity not checked` };
|
|
247
310
|
}
|
|
248
311
|
// Check BEFORE spawning: an unconfigured daemon exits almost immediately (see
|
|
249
312
|
// `runNotifyDaemonForeground`), which races the readiness poll below and used to
|
|
@@ -252,24 +315,45 @@ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<
|
|
|
252
315
|
return { ok: false, message: "notifications not configured — run 'jeo notify setup' first." };
|
|
253
316
|
}
|
|
254
317
|
|
|
318
|
+
const blocked = await interruptedRecoveryMessage();
|
|
319
|
+
if (blocked) return { ok: false, message: blocked };
|
|
320
|
+
|
|
255
321
|
const cmd = daemonInvocation(process.argv[1], process.execPath, process.cwd());
|
|
256
322
|
spawnImpl(cmd, process.cwd()).unref();
|
|
257
|
-
const ready = await
|
|
323
|
+
const ready = await waitImpl(async () => {
|
|
258
324
|
const lock = await readDaemonLock();
|
|
259
|
-
return Boolean(lock && isPidAlive(lock.pid));
|
|
260
|
-
},
|
|
261
|
-
if (!ready) return { ok: false, message: `daemon did not
|
|
325
|
+
return Boolean(lock && lock.purpose === "daemon" && lock.ready === true && isPidAlive(lock.pid));
|
|
326
|
+
}, 15_000);
|
|
327
|
+
if (!ready) return { ok: false, message: `daemon did not initialize within 15s — check ${notifyDaemonLogPath()}` };
|
|
262
328
|
const lock = await readDaemonLock();
|
|
263
|
-
return { ok: true, pid: lock?.pid, message: `daemon
|
|
329
|
+
return { ok: true, pid: lock?.pid, message: `daemon initialized (pid ${lock?.pid}); use notify health to check platform access` };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function removeStoppedLock(expected: DaemonLockInfo): Promise<void> {
|
|
333
|
+
const lockPath = notifyDaemonLockPath();
|
|
334
|
+
let guard: fs.FileHandle;
|
|
335
|
+
try { guard = await fs.open(`${lockPath}.reclaim`, "wx", 0o600); }
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (["EEXIST", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return;
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const current = await readDaemonLock();
|
|
342
|
+
if (current?.pid === expected.pid && current.startedAt === expected.startedAt && !(await isLockOwnerAlive(current))) await fs.unlink(lockPath).catch(() => {});
|
|
343
|
+
} finally {
|
|
344
|
+
await guard.close();
|
|
345
|
+
await fs.unlink(`${lockPath}.reclaim`).catch(() => {});
|
|
346
|
+
}
|
|
264
347
|
}
|
|
265
348
|
|
|
266
349
|
export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
|
|
267
350
|
const lock = await readDaemonLock();
|
|
268
351
|
const owner = lock ? await inspectLockOwner(lock) : undefined;
|
|
269
352
|
if (!lock || !owner?.alive) {
|
|
270
|
-
|
|
353
|
+
if (lock) await removeStoppedLock(lock);
|
|
271
354
|
return { ok: true, message: "daemon was not running" };
|
|
272
355
|
}
|
|
356
|
+
if (lock.purpose === "pairing") return { ok: false, message: "Telegram pairing is active; cancel setup in its terminal first." };
|
|
273
357
|
try {
|
|
274
358
|
process.kill(lock.pid, "SIGTERM");
|
|
275
359
|
} catch (err) {
|
|
@@ -277,13 +361,14 @@ export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
|
|
|
277
361
|
}
|
|
278
362
|
const stopped = await waitFor(async () => !isPidAlive(lock.pid), 3_000);
|
|
279
363
|
if (!stopped) return { ok: false, message: `daemon (pid ${lock.pid}) did not exit within 3s` };
|
|
280
|
-
await
|
|
364
|
+
await removeStoppedLock(lock);
|
|
281
365
|
const caveat = owner.verified ? "" : " (start time unverifiable on this host — PID reuse could not be ruled out)";
|
|
282
366
|
return { ok: true, message: `daemon stopped (was pid ${lock.pid})${caveat}` };
|
|
283
367
|
}
|
|
284
368
|
|
|
285
|
-
export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; message: string }> {
|
|
286
|
-
const stopRes = await
|
|
369
|
+
export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn, stopImpl: typeof stopDaemon = stopDaemon): Promise<{ ok: boolean; message: string }> {
|
|
370
|
+
const stopRes = await stopImpl();
|
|
371
|
+
if (!stopRes.ok) return stopRes;
|
|
287
372
|
const startRes = await startDaemon(spawnImpl);
|
|
288
373
|
return { ok: startRes.ok, message: `${stopRes.message}; ${startRes.message}` };
|
|
289
374
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { version } from "../../../package.json";
|
|
2
|
+
|
|
3
|
+
/** Native Discord bot REST surface. Tokens are headers only; errors never include response bodies. */
|
|
4
|
+
export interface DiscordApiOptions {
|
|
5
|
+
fetchImpl?: typeof fetch;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
8
|
+
requestTimeoutMs?: number;
|
|
9
|
+
maxRetries?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class DiscordApiError extends Error {
|
|
13
|
+
constructor(message: string, readonly status?: number, readonly retryAfterMs?: number, readonly sentMessages: { id: string }[] = []) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "DiscordApiError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiscordGatewayInfo {
|
|
20
|
+
url: string;
|
|
21
|
+
session_start_limit: { remaining: number; reset_after: number; total: number; max_concurrency: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
25
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
26
|
+
const abort = () => { clearTimeout(timer); reject(new DiscordApiError("Discord request stopped")); };
|
|
27
|
+
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
|
|
28
|
+
if (signal.aborted) abort();
|
|
29
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
30
|
+
return promise;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class DiscordApi {
|
|
34
|
+
private readonly controller = new AbortController();
|
|
35
|
+
private cooldownUntil = 0;
|
|
36
|
+
private fatalStatus: number | undefined;
|
|
37
|
+
private queue = Promise.resolve();
|
|
38
|
+
private queued = 0;
|
|
39
|
+
|
|
40
|
+
constructor(private readonly token: string, private readonly options: DiscordApiOptions = {}) {}
|
|
41
|
+
|
|
42
|
+
stop(): void { this.controller.abort(); }
|
|
43
|
+
|
|
44
|
+
private request<T>(route: string, body?: unknown): Promise<T> {
|
|
45
|
+
if (this.queued >= 128) return Promise.reject(new DiscordApiError("Discord request queue full"));
|
|
46
|
+
this.queued++;
|
|
47
|
+
const result = this.queue.then(() => this.perform<T>(route, body));
|
|
48
|
+
this.queue = result.then(() => {}, () => {}).finally(() => { this.queued--; });
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async perform<T>(route: string, body?: unknown): Promise<T> {
|
|
53
|
+
const now = this.options.now ?? Date.now;
|
|
54
|
+
const retries = Math.max(0, Math.min(this.options.maxRetries ?? 2, 3));
|
|
55
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
56
|
+
if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
|
|
57
|
+
if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
|
|
58
|
+
const wait = this.cooldownUntil - now();
|
|
59
|
+
if (wait > 30_000) throw new DiscordApiError("Discord rate limit cooldown active", 429, wait);
|
|
60
|
+
if (wait > 0) await (this.options.sleep ?? delay)(wait, this.controller.signal);
|
|
61
|
+
if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
|
|
62
|
+
const timeout = AbortSignal.timeout(Math.max(1, Math.min(this.options.requestTimeoutMs ?? 10_000, 30_000)));
|
|
63
|
+
let response: Response;
|
|
64
|
+
let data: unknown;
|
|
65
|
+
try {
|
|
66
|
+
response = await (this.options.fetchImpl ?? fetch)(`https://discord.com/api/v10${route}`, {
|
|
67
|
+
method: body === undefined ? "GET" : "POST",
|
|
68
|
+
headers: { Authorization: `Bot ${this.token}`, "Content-Type": "application/json", "User-Agent": `DiscordBot (https://github.com/akillness/jeo-code, ${version})` },
|
|
69
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
70
|
+
signal: AbortSignal.any([this.controller.signal, timeout]),
|
|
71
|
+
redirect: "error",
|
|
72
|
+
});
|
|
73
|
+
if (response.status === 401 || response.status === 403) this.fatalStatus = response.status;
|
|
74
|
+
data = await response.json().catch(error => {
|
|
75
|
+
if (response.ok) throw error;
|
|
76
|
+
return null; // HTTP denial/cooldown headers remain authoritative without a JSON body.
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
// Never retry an uncertain POST: Discord may already have accepted it.
|
|
80
|
+
if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
|
|
81
|
+
throw new DiscordApiError("Discord request failed or timed out");
|
|
82
|
+
}
|
|
83
|
+
const record = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
|
84
|
+
if (response.status === 429 || response.headers.get("X-RateLimit-Remaining") === "0") {
|
|
85
|
+
const raw = Number(response.status === 429
|
|
86
|
+
? record.retry_after ?? response.headers.get("Retry-After")
|
|
87
|
+
: response.headers.get("X-RateLimit-Reset-After"));
|
|
88
|
+
const ms = Number.isFinite(raw) && raw > 0 ? raw * 1000 : 30_000;
|
|
89
|
+
// One serialized bot lane conservatively honors both route and global cooldowns.
|
|
90
|
+
this.cooldownUntil = Math.max(this.cooldownUntil, now() + ms);
|
|
91
|
+
}
|
|
92
|
+
if (response.status === 429 && attempt < retries) continue;
|
|
93
|
+
if (!response.ok) throw new DiscordApiError(`Discord HTTP ${response.status}`, response.status, Math.max(0, this.cooldownUntil - now()));
|
|
94
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new DiscordApiError("Invalid Discord response");
|
|
95
|
+
return data as T;
|
|
96
|
+
}
|
|
97
|
+
throw new DiscordApiError("Discord retry limit reached");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getMe(): Promise<{ id: string; username: string; bot: boolean }> {
|
|
101
|
+
const me = await this.request<{ id: string; username: string; bot: boolean }>("/users/@me");
|
|
102
|
+
if (!/^\d+$/.test(me.id) || typeof me.id !== "string" || typeof me.username !== "string" || me.bot !== true) {
|
|
103
|
+
throw new DiscordApiError("Discord bot identity required");
|
|
104
|
+
}
|
|
105
|
+
return me;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async getChannel(channelId: string): Promise<{ id: string; type: number }> {
|
|
109
|
+
this.checkId(channelId);
|
|
110
|
+
const channel = await this.request<{ id: string; type: number }>(`/channels/${channelId}`);
|
|
111
|
+
if (channel.id !== channelId || !Number.isInteger(channel.type)) throw new DiscordApiError("Invalid Discord channel");
|
|
112
|
+
return channel;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async getGatewayBot(): Promise<DiscordGatewayInfo> {
|
|
116
|
+
const gateway = await this.request<DiscordGatewayInfo>("/gateway/bot");
|
|
117
|
+
const limit = gateway.session_start_limit;
|
|
118
|
+
if (typeof gateway.url !== "string" || !limit || !Number.isSafeInteger(limit.remaining) || limit.remaining < 0 ||
|
|
119
|
+
!Number.isFinite(limit.reset_after) || limit.reset_after < 0 || !Number.isSafeInteger(limit.total) || limit.total < 1 ||
|
|
120
|
+
!Number.isSafeInteger(limit.max_concurrency) || limit.max_concurrency < 1) throw new DiscordApiError("Invalid Discord gateway response");
|
|
121
|
+
return gateway;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async sendMessage(channelId: string, content: string): Promise<{ id: string }[]> {
|
|
125
|
+
this.checkId(channelId);
|
|
126
|
+
if (!content.trim()) return [];
|
|
127
|
+
if (content.length > 256_000) throw new DiscordApiError("Discord message exceeds relay limit");
|
|
128
|
+
const messages: { id: string }[] = [];
|
|
129
|
+
for (let offset = 0; offset < content.length;) {
|
|
130
|
+
let end = Math.min(offset + 2000, content.length);
|
|
131
|
+
if (end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]!)) end--;
|
|
132
|
+
const message = await this.request<{ id: string }>(`/channels/${channelId}/messages`, {
|
|
133
|
+
content: content.slice(offset, end), allowed_mentions: { parse: [], replied_user: false },
|
|
134
|
+
}).catch(error => {
|
|
135
|
+
if (error instanceof DiscordApiError) throw new DiscordApiError(error.message, error.status, error.retryAfterMs, messages);
|
|
136
|
+
throw new DiscordApiError("Discord delivery failed", undefined, undefined, messages);
|
|
137
|
+
});
|
|
138
|
+
if (typeof message.id !== "string" || !/^\d+$/.test(message.id)) throw new DiscordApiError("Invalid Discord message ID", undefined, undefined, messages);
|
|
139
|
+
messages.push(message);
|
|
140
|
+
offset = end;
|
|
141
|
+
}
|
|
142
|
+
return messages;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private checkId(id: string): void {
|
|
146
|
+
if (typeof id !== "string" || !/^\d{1,20}$/.test(id)) throw new DiscordApiError("Invalid Discord ID");
|
|
147
|
+
}
|
|
148
|
+
}
|