jeopi-cli 16.5.6 → 16.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,382 @@
1
+ import { isRecord, logger } from "jeopi-utils";
2
+ import { MessengerHttpError, messengerFetchJson, splitMessengerText } from "./http";
3
+ import type { MessengerAdapter, MessengerMessage, MessengerPlatformConfig, MessengerTransportDeps } from "./types";
4
+
5
+ // Protocol: https://docs.discord.com/developers/events/gateway
6
+ // Close codes: https://docs.discord.com/developers/topics/opcodes-and-status-codes
7
+ // REST messages: https://docs.discord.com/developers/resources/message#create-message
8
+ const API = "https://discord.com/api/v10";
9
+ const INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
10
+ const PERMANENT_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
11
+
12
+ class DiscordConfigurationError extends Error {}
13
+
14
+ export interface DiscordClock {
15
+ /** Schedule once and return an idempotent cancellation function. */
16
+ schedule(callback: () => void, delayMs: number): () => void;
17
+ random(): number;
18
+ }
19
+
20
+ export interface DiscordTransportDeps extends MessengerTransportDeps {
21
+ clock?: DiscordClock;
22
+ }
23
+
24
+ const defaultClock: DiscordClock = {
25
+ schedule(callback, delayMs) {
26
+ const timer = setTimeout(callback, delayMs);
27
+ return () => clearTimeout(timer);
28
+ },
29
+ random: Math.random,
30
+ };
31
+
32
+ function waitForReconnect(delayMs: number, signal: AbortSignal, clock: DiscordClock): Promise<void> {
33
+ if (signal.aborted) return Promise.resolve();
34
+ const result = Promise.withResolvers<void>();
35
+ const finish = (): void => {
36
+ cancel();
37
+ signal.removeEventListener("abort", finish);
38
+ result.resolve();
39
+ };
40
+ const cancel = clock.schedule(finish, delayMs);
41
+ signal.addEventListener("abort", finish, { once: true });
42
+ if (signal.aborted) finish();
43
+ return result.promise;
44
+ }
45
+
46
+ interface DiscordSession {
47
+ id?: string;
48
+ url?: string;
49
+ sequence: number | null;
50
+ }
51
+
52
+ function gatewayUrl(value: unknown): string {
53
+ let url: URL;
54
+ try {
55
+ if (typeof value !== "string") throw new Error();
56
+ url = new URL(value);
57
+ } catch {
58
+ throw new DiscordConfigurationError("Discord returned an invalid Gateway URL");
59
+ }
60
+ if (
61
+ url.protocol !== "wss:" ||
62
+ !/^gateway(?:-[a-z0-9-]+)?\.discord\.gg$/.test(url.hostname) ||
63
+ url.username ||
64
+ url.password ||
65
+ url.port ||
66
+ url.hash ||
67
+ url.pathname !== "/"
68
+ ) {
69
+ throw new DiscordConfigurationError("Discord returned an untrusted Gateway URL");
70
+ }
71
+ url.search = "?v=10&encoding=json";
72
+ return url.href;
73
+ }
74
+
75
+ function clearSession(session: DiscordSession): void {
76
+ session.id = undefined;
77
+ session.url = undefined;
78
+ session.sequence = null;
79
+ }
80
+
81
+ function normalizeMessage(value: unknown, accountId: string): MessengerMessage | undefined {
82
+ if (!isRecord(value) || !isRecord(value.author)) return;
83
+ if (value.author.bot || value.webhook_id || value.author.id === accountId) return;
84
+ if (
85
+ typeof value.id !== "string" ||
86
+ !value.id ||
87
+ typeof value.channel_id !== "string" ||
88
+ !value.channel_id ||
89
+ typeof value.author.id !== "string" ||
90
+ !value.author.id ||
91
+ typeof value.content !== "string" ||
92
+ !value.content.trim() ||
93
+ (value.type !== undefined && value.type !== 0 && value.type !== 19)
94
+ )
95
+ return;
96
+ const mentioned =
97
+ Array.isArray(value.mentions) && value.mentions.some(user => isRecord(user) && user.id === accountId);
98
+ const text = value.content.replaceAll(`<@${accountId}>`, "").replaceAll(`<@!${accountId}>`, "").trim();
99
+ if (!text) return;
100
+ return {
101
+ platform: "discord",
102
+ accountId,
103
+ channelId: value.channel_id,
104
+ senderId: value.author.id,
105
+ messageId: value.id,
106
+ text,
107
+ direct: value.guild_id === undefined,
108
+ mentioned,
109
+ };
110
+ }
111
+
112
+ function connectGateway(
113
+ url: string,
114
+ token: string,
115
+ accountId: string,
116
+ session: DiscordSession,
117
+ onMessage: (message: MessengerMessage) => Promise<void>,
118
+ signal: AbortSignal,
119
+ WebSocketImpl: typeof WebSocket,
120
+ clock: DiscordClock,
121
+ ): Promise<void> {
122
+ if (signal.aborted) return Promise.resolve();
123
+ const result = Promise.withResolvers<void>();
124
+ let socket: WebSocket;
125
+ try {
126
+ socket = new WebSocketImpl(url);
127
+ } catch {
128
+ clearSession(session);
129
+ return Promise.resolve();
130
+ }
131
+ let finished = false;
132
+ let hello = false;
133
+ let established = false;
134
+ let acknowledged = true;
135
+ let cancelHeartbeat: (() => void) | undefined;
136
+ let cancelHandshake: (() => void) | undefined;
137
+
138
+ const finish = (error?: Error): void => {
139
+ if (finished) return;
140
+ finished = true;
141
+ // A failed resume handshake must fall back to the initial Gateway.
142
+ if (!established) clearSession(session);
143
+ cancelHeartbeat?.();
144
+ cancelHandshake?.();
145
+ signal.removeEventListener("abort", abort);
146
+ socket.removeEventListener("message", receive);
147
+ socket.removeEventListener("close", close);
148
+ socket.removeEventListener("error", failed);
149
+ try {
150
+ // Normal shutdown invalidates the session; reconnects must preserve it.
151
+ socket.close(signal.aborted ? 1000 : 4000);
152
+ } catch {
153
+ // A failed handshake can already have closed the underlying socket.
154
+ }
155
+ if (error) result.reject(error);
156
+ else result.resolve();
157
+ };
158
+ const abort = (): void => finish();
159
+ const failed = (): void => finish();
160
+ const send = (op: number, d: unknown): void => {
161
+ if (finished) return;
162
+ try {
163
+ socket.send(JSON.stringify({ op, d }));
164
+ } catch {
165
+ finish();
166
+ }
167
+ };
168
+ const heartbeat = (): void => {
169
+ acknowledged = false;
170
+ send(1, session.sequence);
171
+ };
172
+ const close = (event: CloseEvent): void => {
173
+ if (PERMANENT_CLOSE_CODES.has(event.code)) {
174
+ const reason =
175
+ event.code === 4004
176
+ ? "authentication failed"
177
+ : event.code === 4013 || event.code === 4014
178
+ ? "message intents are invalid or not enabled"
179
+ : "Gateway configuration is unsupported";
180
+ finish(new DiscordConfigurationError(`Discord ${reason} (Gateway ${event.code})`));
181
+ return;
182
+ }
183
+ if ([1000, 1001, 4003, 4007, 4009].includes(event.code)) clearSession(session);
184
+ finish();
185
+ };
186
+ const receive = (event: MessageEvent): void => {
187
+ if (finished || signal.aborted) return;
188
+ let packet: unknown;
189
+ try {
190
+ if (typeof event.data !== "string") return;
191
+ packet = JSON.parse(event.data);
192
+ } catch {
193
+ finish();
194
+ return;
195
+ }
196
+ if (!isRecord(packet)) return;
197
+ if (packet.op === 10) {
198
+ if (hello) return;
199
+ if (
200
+ !isRecord(packet.d) ||
201
+ typeof packet.d.heartbeat_interval !== "number" ||
202
+ !Number.isFinite(packet.d.heartbeat_interval) ||
203
+ packet.d.heartbeat_interval <= 0 ||
204
+ packet.d.heartbeat_interval > 300_000
205
+ ) {
206
+ finish(new DiscordConfigurationError("Discord returned an invalid heartbeat interval"));
207
+ return;
208
+ }
209
+ hello = true;
210
+ const interval = packet.d.heartbeat_interval;
211
+ const tick = (): void => {
212
+ if (finished) return;
213
+ if (!acknowledged) {
214
+ finish();
215
+ return;
216
+ }
217
+ heartbeat();
218
+ if (!finished) cancelHeartbeat = clock.schedule(tick, interval);
219
+ };
220
+ cancelHeartbeat = clock.schedule(tick, clock.random() * interval);
221
+ if (session.id && session.sequence !== null) {
222
+ send(6, { token, session_id: session.id, seq: session.sequence });
223
+ } else {
224
+ send(2, {
225
+ token,
226
+ intents: INTENTS,
227
+ properties: { os: process.platform, browser: "jeopi", device: "jeopi" },
228
+ });
229
+ }
230
+ } else if (packet.op === 11) {
231
+ acknowledged = true;
232
+ } else if (packet.op === 1) {
233
+ // Unsolicited requests must not shorten the scheduled ACK deadline.
234
+ send(1, session.sequence);
235
+ } else if (packet.op === 7 || packet.op === 9) {
236
+ if (packet.op === 9 && packet.d !== true) clearSession(session);
237
+ finish();
238
+ } else if (packet.op === 0) {
239
+ if (typeof packet.s === "number" && Number.isSafeInteger(packet.s)) session.sequence = packet.s;
240
+ if (packet.t === "READY") {
241
+ if (!isRecord(packet.d) || typeof packet.d.session_id !== "string" || !packet.d.session_id) {
242
+ finish(new DiscordConfigurationError("Discord returned an invalid Gateway session"));
243
+ return;
244
+ }
245
+ try {
246
+ session.url = gatewayUrl(packet.d.resume_gateway_url);
247
+ } catch {
248
+ finish(new DiscordConfigurationError("Discord returned an untrusted resume Gateway URL"));
249
+ return;
250
+ }
251
+ session.id = packet.d.session_id;
252
+ established = true;
253
+ cancelHandshake?.();
254
+ } else if (packet.t === "RESUMED") {
255
+ established = true;
256
+ cancelHandshake?.();
257
+ } else if (packet.t === "MESSAGE_CREATE") {
258
+ const message = normalizeMessage(packet.d, accountId);
259
+ if (message) {
260
+ // The gateway owns authorization, deduplication and its bounded work queue.
261
+ void Promise.resolve()
262
+ .then(() => {
263
+ if (!signal.aborted) return onMessage(message);
264
+ })
265
+ .catch(() => logger.warn("Discord message handler failed"));
266
+ }
267
+ }
268
+ }
269
+ };
270
+ socket.addEventListener("message", receive);
271
+ socket.addEventListener("close", close);
272
+ socket.addEventListener("error", failed);
273
+ signal.addEventListener("abort", abort, { once: true });
274
+ cancelHandshake = clock.schedule(() => finish(), 35_000);
275
+ if (signal.aborted) finish();
276
+ return result.promise;
277
+ }
278
+
279
+ export function createDiscordAdapter(
280
+ config: MessengerPlatformConfig,
281
+ env: Record<string, string | undefined> = process.env,
282
+ deps: DiscordTransportDeps = {},
283
+ ): MessengerAdapter {
284
+ const token = env[config.tokenEnv]?.trim();
285
+ const clock = deps.clock ?? defaultClock;
286
+ const headers = (): Record<string, string> => {
287
+ if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
288
+ return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
289
+ };
290
+ return {
291
+ platform: "discord",
292
+ async start(onMessage, signal) {
293
+ const session: DiscordSession = { sequence: null };
294
+ let accountId: string | undefined;
295
+ let initialUrl: string | undefined;
296
+ let delay = 5_000;
297
+ while (!signal.aborted) {
298
+ let retryDelay = delay;
299
+ try {
300
+ if (!accountId) {
301
+ const user = await messengerFetchJson(`${API}/users/@me`, { headers: headers() }, signal, deps.fetch);
302
+ if (signal.aborted) return;
303
+ if (!isRecord(user) || typeof user.id !== "string" || !user.id || user.bot !== true) {
304
+ throw new DiscordConfigurationError("Discord credentials must identify a bot account");
305
+ }
306
+ accountId = user.id;
307
+ }
308
+ if (!initialUrl) {
309
+ const gateway = await messengerFetchJson(
310
+ `${API}/gateway/bot`,
311
+ { headers: headers() },
312
+ signal,
313
+ deps.fetch,
314
+ );
315
+ if (!isRecord(gateway))
316
+ throw new DiscordConfigurationError("Discord returned invalid Gateway discovery");
317
+ if (isRecord(gateway.session_start_limit) && gateway.session_start_limit.remaining === 0) {
318
+ throw new DiscordConfigurationError("Discord Gateway session start limit is exhausted");
319
+ }
320
+ initialUrl = gatewayUrl(gateway.url);
321
+ }
322
+ await connectGateway(
323
+ session.url ?? initialUrl,
324
+ token!,
325
+ accountId,
326
+ session,
327
+ onMessage,
328
+ signal,
329
+ deps.WebSocket ?? WebSocket,
330
+ clock,
331
+ );
332
+ } catch (error) {
333
+ if (signal.aborted) return;
334
+ if (error instanceof DiscordConfigurationError) throw error;
335
+ if (
336
+ error instanceof MessengerHttpError &&
337
+ error.status >= 400 &&
338
+ error.status < 500 &&
339
+ error.status !== 429
340
+ ) {
341
+ throw new DiscordConfigurationError(
342
+ `Discord authentication or configuration failed (HTTP ${error.status})`,
343
+ );
344
+ }
345
+ if (error instanceof MessengerHttpError && error.retryAfterMs !== undefined) {
346
+ retryDelay = Math.max(retryDelay, error.retryAfterMs);
347
+ }
348
+ logger.warn("Discord connection failed; retrying");
349
+ }
350
+ if (signal.aborted) return;
351
+ try {
352
+ await waitForReconnect(retryDelay, signal, clock);
353
+ } catch {
354
+ if (signal.aborted) return;
355
+ throw new Error("Discord reconnect wait failed");
356
+ }
357
+ delay = Math.min(delay * 2, 30_000);
358
+ }
359
+ },
360
+ async send(message, text, signal) {
361
+ const chunks = splitMessengerText(text, 2_000);
362
+ for (let index = 0; index < chunks.length; index++) {
363
+ await messengerFetchJson(
364
+ `${API}/channels/${encodeURIComponent(message.channelId)}/messages`,
365
+ {
366
+ method: "POST",
367
+ headers: headers(),
368
+ body: JSON.stringify({
369
+ content: chunks[index],
370
+ allowed_mentions: { parse: [], replied_user: false },
371
+ ...(index === 0
372
+ ? { message_reference: { message_id: message.messageId, fail_if_not_exists: false } }
373
+ : {}),
374
+ }),
375
+ },
376
+ signal,
377
+ deps.fetch,
378
+ );
379
+ }
380
+ },
381
+ };
382
+ }
@@ -0,0 +1,153 @@
1
+ import { logger } from "jeopi-utils";
2
+ import type { MessengerAdapter, MessengerConfig, MessengerMessage, MessengerReply } from "./types";
3
+ import { MessengerFatalError } from "./types";
4
+
5
+ const MAX_MESSAGE_LENGTH = 32_000;
6
+ const MAX_QUEUED_MESSAGES = 100;
7
+ const MAX_SEEN_MESSAGES = 10_000;
8
+
9
+ /** Include the sender even in shared threads: remote histories must not cross users. */
10
+ export function messengerConversationKey(message: MessengerMessage): string {
11
+ return JSON.stringify([
12
+ message.platform,
13
+ message.accountId,
14
+ message.channelId,
15
+ message.threadId ?? "",
16
+ message.senderId,
17
+ ]);
18
+ }
19
+
20
+ export function isMessengerMessageAllowed(message: MessengerMessage, config: MessengerConfig): boolean {
21
+ const platform = config.platforms[message.platform];
22
+ return Boolean(
23
+ platform &&
24
+ message.accountId &&
25
+ message.channelId &&
26
+ message.senderId &&
27
+ message.messageId &&
28
+ message.text.trim() &&
29
+ message.text.length <= MAX_MESSAGE_LENGTH &&
30
+ platform.allowedUserIds.includes(message.senderId) &&
31
+ (!platform.allowedChannelIds || platform.allowedChannelIds.includes(message.channelId)) &&
32
+ (message.direct || message.mentioned),
33
+ );
34
+ }
35
+
36
+ interface PendingMessage {
37
+ message: MessengerMessage;
38
+ adapter: MessengerAdapter;
39
+ signal: AbortSignal;
40
+ resolve: () => void;
41
+ }
42
+
43
+ export interface MessengerGateway {
44
+ run(signal: AbortSignal): Promise<void>;
45
+ handle(message: MessengerMessage, signal?: AbortSignal): Promise<void>;
46
+ }
47
+
48
+ export function createMessengerGateway(
49
+ config: MessengerConfig,
50
+ adapters: MessengerAdapter[],
51
+ reply: MessengerReply,
52
+ ): MessengerGateway {
53
+ const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
54
+ if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
55
+ for (const platform of Object.keys(config.platforms)) {
56
+ if (!adapters.some(adapter => adapter.platform === platform))
57
+ throw new Error("Missing configured messenger adapter");
58
+ }
59
+ const seen = new Set<string>();
60
+ const queue: PendingMessage[] = [];
61
+ const lifetime = new AbortController();
62
+ let draining: Promise<void> | undefined;
63
+ let workerActive = false;
64
+ let running = false;
65
+ let fatalError: MessengerFatalError | undefined;
66
+
67
+ async function drain(): Promise<void> {
68
+ while (queue.length > 0) {
69
+ const pending = queue.shift();
70
+ if (!pending) break;
71
+ const { message, adapter, signal, resolve } = pending;
72
+ try {
73
+ if (signal.aborted) continue;
74
+ let text: string;
75
+ try {
76
+ text = await reply(message, signal);
77
+ } catch (error) {
78
+ if (error instanceof MessengerFatalError) {
79
+ fatalError = error;
80
+ lifetime.abort();
81
+ continue;
82
+ }
83
+ if (signal.aborted) continue;
84
+ logger.warn("Messenger agent turn failed", { platform: message.platform });
85
+ text = "The request could not be completed. Please try again.";
86
+ }
87
+ if (!signal.aborted && text.trim()) {
88
+ // A failed send may already have delivered a chunk: never retry the entire reply.
89
+ await adapter.send(message, text, signal);
90
+ }
91
+ } catch {
92
+ if (!signal.aborted) logger.warn("Messenger reply delivery failed", { platform: message.platform });
93
+ } finally {
94
+ resolve();
95
+ }
96
+ }
97
+ workerActive = false;
98
+ }
99
+
100
+ function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
101
+ const adapter = byPlatform.get(message.platform);
102
+ const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
103
+ if (effectiveSignal.aborted || !adapter || !isMessengerMessageAllowed(message, config)) return Promise.resolve();
104
+ const id = JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
105
+ if (seen.has(id) || queue.length >= MAX_QUEUED_MESSAGES) return Promise.resolve();
106
+ seen.add(id);
107
+ if (seen.size > MAX_SEEN_MESSAGES) {
108
+ const oldest = seen.values().next().value;
109
+ if (oldest !== undefined) seen.delete(oldest);
110
+ }
111
+ const { promise, resolve } = Promise.withResolvers<void>();
112
+ queue.push({ message, adapter, signal: effectiveSignal, resolve });
113
+ if (!workerActive) {
114
+ workerActive = true;
115
+ draining = drain();
116
+ }
117
+ return promise;
118
+ }
119
+
120
+ return {
121
+ handle,
122
+ async run(signal) {
123
+ if (running || lifetime.signal.aborted) throw new Error("Messenger gateway cannot be restarted");
124
+ running = true;
125
+ const abort = (): void => lifetime.abort();
126
+ signal.addEventListener("abort", abort, { once: true });
127
+ if (signal.aborted) abort();
128
+ const starts: Promise<void>[] = [];
129
+ try {
130
+ if (lifetime.signal.aborted) return;
131
+ for (const adapter of adapters) {
132
+ starts.push(
133
+ Promise.resolve().then(async () => {
134
+ await adapter.start(message => handle(message), lifetime.signal);
135
+ if (!lifetime.signal.aborted) throw new Error("Messenger transport stopped unexpectedly");
136
+ }),
137
+ );
138
+ }
139
+ await Promise.all(starts);
140
+ if (fatalError) throw fatalError;
141
+ } catch {
142
+ if (fatalError) throw fatalError;
143
+ if (!signal.aborted)
144
+ throw new Error("Messenger connection failed; check provider credentials, permissions and connectivity");
145
+ } finally {
146
+ lifetime.abort();
147
+ await Promise.allSettled(starts);
148
+ await draining;
149
+ signal.removeEventListener("abort", abort);
150
+ }
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,87 @@
1
+ export class MessengerHttpError extends Error {
2
+ constructor(
3
+ readonly status: number,
4
+ readonly retryAfterMs?: number,
5
+ ) {
6
+ super(`Messenger request failed (HTTP ${status})`);
7
+ this.name = "MessengerHttpError";
8
+ }
9
+ }
10
+
11
+ /** Provider limits count UTF-16 units; do not bisect supplementary characters. */
12
+ export function splitMessengerText(text: string, limit: number): string[] {
13
+ if (!Number.isSafeInteger(limit) || limit < 2)
14
+ throw new Error("Message chunk limit must be an integer of at least two");
15
+ const chunks: string[] = [];
16
+ for (let start = 0; start < text.length; ) {
17
+ let end = Math.min(start + limit, text.length);
18
+ const last = text.charCodeAt(end - 1);
19
+ if (end < text.length && last >= 0xd800 && last <= 0xdbff) end--;
20
+ chunks.push(text.slice(start, end));
21
+ start = end;
22
+ }
23
+ return chunks;
24
+ }
25
+
26
+ export async function messengerFetchJson(
27
+ url: string,
28
+ init: RequestInit,
29
+ signal?: AbortSignal,
30
+ fetchImpl: typeof fetch = fetch,
31
+ ): Promise<unknown> {
32
+ const signals = [AbortSignal.timeout(35_000)];
33
+ if (signal) signals.push(signal);
34
+ if (init.signal) signals.push(init.signal);
35
+ const requestSignal = AbortSignal.any(signals);
36
+ try {
37
+ const response = await fetchImpl(url, { ...init, redirect: "error", signal: requestSignal });
38
+ if (!response.ok) {
39
+ let retryAfterMs: number | undefined;
40
+ if (response.status === 429) {
41
+ let seconds = Number(response.headers.get("retry-after"));
42
+ if (!seconds) {
43
+ try {
44
+ const body: unknown = await response.json();
45
+ if (typeof body === "object" && body !== null) {
46
+ if ("retry_after" in body && typeof body.retry_after === "number") {
47
+ seconds = body.retry_after;
48
+ } else if (
49
+ "parameters" in body &&
50
+ typeof body.parameters === "object" &&
51
+ body.parameters !== null &&
52
+ "retry_after" in body.parameters &&
53
+ typeof body.parameters.retry_after === "number"
54
+ ) {
55
+ seconds = body.parameters.retry_after;
56
+ }
57
+ }
58
+ } catch {
59
+ /* Retry metadata is optional; never surface provider response text. */
60
+ }
61
+ }
62
+ // Larger delays overflow native timers into immediate retries.
63
+ if (Number.isFinite(seconds) && seconds > 0) retryAfterMs = Math.min(seconds * 1000, 2_147_483_647);
64
+ }
65
+ if (!response.bodyUsed) await response.body?.cancel();
66
+ throw new MessengerHttpError(response.status, retryAfterMs);
67
+ }
68
+ return await response.json();
69
+ } catch (error) {
70
+ if (error instanceof MessengerHttpError) throw error;
71
+ if (signal?.aborted || init.signal?.aborted) throw new Error("Messenger request cancelled");
72
+ throw new Error("Messenger request failed");
73
+ }
74
+ }
75
+
76
+ export async function waitForMessengerRetry(ms: number, signal: AbortSignal): Promise<void> {
77
+ if (signal.aborted) return;
78
+ const { promise, resolve } = Promise.withResolvers<void>();
79
+ const done = (): void => {
80
+ clearTimeout(timer);
81
+ signal.removeEventListener("abort", done);
82
+ resolve();
83
+ };
84
+ const timer = setTimeout(done, ms);
85
+ signal.addEventListener("abort", done, { once: true });
86
+ await promise;
87
+ }