jeopi-cli 16.6.14 → 16.6.18

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.
@@ -6,9 +6,10 @@ import type { MessengerAdapter, MessengerMessage, MessengerPlatformConfig, Messe
6
6
  // Protocol: https://docs.discord.com/developers/events/gateway
7
7
  // Close codes: https://docs.discord.com/developers/topics/opcodes-and-status-codes
8
8
  // REST messages: https://docs.discord.com/developers/resources/message#create-message
9
- const API = "https://discord.com/api/v10";
10
- const INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
9
+ export const DISCORD_API = "https://discord.com/api/v10";
10
+ export const DISCORD_GATEWAY_INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
11
11
  const PERMANENT_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
12
+ const MAX_CLASSIFICATION_PENDING = 100;
12
13
 
13
14
  // Discord strips boundary whitespace. Invisible guards preserve it in message content;
14
15
  // they are part of the delivered text, and consume one UTF-16 unit each.
@@ -82,7 +83,7 @@ interface DiscordSession {
82
83
  sequence: number | null;
83
84
  }
84
85
 
85
- function gatewayUrl(value: unknown): string {
86
+ export function discordGatewayUrl(value: unknown): string {
86
87
  let url: URL;
87
88
  try {
88
89
  if (typeof value !== "string") throw new Error();
@@ -158,7 +159,7 @@ function connectGateway(
158
159
  token: string,
159
160
  accountId: string,
160
161
  session: DiscordSession,
161
- onMessage: (message: MessengerMessage) => Promise<void>,
162
+ onMessage: (message: MessengerMessage, raw: unknown) => Promise<void>,
162
163
  signal: AbortSignal,
163
164
  WebSocketImpl: typeof WebSocket,
164
165
  clock: DiscordClock,
@@ -267,7 +268,7 @@ function connectGateway(
267
268
  } else {
268
269
  send(2, {
269
270
  token,
270
- intents: INTENTS,
271
+ intents: DISCORD_GATEWAY_INTENTS,
271
272
  properties: { os: process.platform, browser: "jeopi", device: "jeopi" },
272
273
  });
273
274
  }
@@ -287,7 +288,7 @@ function connectGateway(
287
288
  return;
288
289
  }
289
290
  try {
290
- session.url = gatewayUrl(packet.d.resume_gateway_url);
291
+ session.url = discordGatewayUrl(packet.d.resume_gateway_url);
291
292
  } catch {
292
293
  finish(new DiscordConfigurationError("Discord returned an untrusted resume Gateway URL"));
293
294
  return;
@@ -304,7 +305,7 @@ function connectGateway(
304
305
  // The gateway owns authorization, deduplication and its bounded work queue.
305
306
  void Promise.resolve()
306
307
  .then(() => {
307
- if (!signal.aborted) return onMessage(message);
308
+ if (!signal.aborted) return onMessage(message, packet.d);
308
309
  })
309
310
  .catch(() => logger.warn("Discord message handler failed"));
310
311
  }
@@ -328,6 +329,9 @@ export function createDiscordAdapter(
328
329
  const token = env[config.tokenEnv]?.trim();
329
330
  const clock = deps.clock ?? defaultClock;
330
331
  const createdThreads = new Map<string, { parentChannelId: string; senderId: string }>();
332
+ const classificationTails = new Map<string, Promise<void>>();
333
+ const messageClassifications = new Map<string, Promise<void>>();
334
+ const pendingClassifications = new Set<string>();
331
335
  const rememberThread = (threadId: string, parentChannelId: string, senderId: string): void => {
332
336
  createdThreads.set(threadId, { parentChannelId, senderId });
333
337
  if (createdThreads.size > 10_000) {
@@ -351,7 +355,12 @@ export function createDiscordAdapter(
351
355
  let retryDelay = delay;
352
356
  try {
353
357
  if (!accountId) {
354
- const user = await messengerFetchJson(`${API}/users/@me`, { headers: headers() }, signal, deps.fetch);
358
+ const user = await messengerFetchJson(
359
+ `${DISCORD_API}/users/@me`,
360
+ { headers: headers() },
361
+ signal,
362
+ deps.fetch,
363
+ );
355
364
  if (signal.aborted) return;
356
365
  if (!isRecord(user) || typeof user.id !== "string" || !user.id || user.bot !== true) {
357
366
  throw new DiscordConfigurationError("Discord credentials must identify a bot account");
@@ -360,7 +369,7 @@ export function createDiscordAdapter(
360
369
  }
361
370
  if (!initialUrl) {
362
371
  const gateway = await messengerFetchJson(
363
- `${API}/gateway/bot`,
372
+ `${DISCORD_API}/gateway/bot`,
364
373
  { headers: headers() },
365
374
  signal,
366
375
  deps.fetch,
@@ -370,49 +379,159 @@ export function createDiscordAdapter(
370
379
  if (isRecord(gateway.session_start_limit) && gateway.session_start_limit.remaining === 0) {
371
380
  throw new DiscordConfigurationError("Discord Gateway session start limit is exhausted");
372
381
  }
373
- initialUrl = gatewayUrl(gateway.url);
382
+ initialUrl = discordGatewayUrl(gateway.url);
374
383
  }
375
- const deliver = async (message: MessengerMessage): Promise<void> => {
376
- const thread = createdThreads.get(message.channelId);
377
- if (thread && thread.senderId === message.senderId) {
378
- await onMessage({
379
- ...message,
380
- threadId: message.channelId,
381
- parentChannelId: thread.parentChannelId,
382
- });
383
- } else if (
384
- !message.direct &&
385
- message.mentioned &&
386
- config.allowedUserIds.includes(message.senderId) &&
387
- config.allowedChannelIds &&
388
- !config.allowedChannelIds.includes(message.channelId)
389
- ) {
384
+ const lookup = async (url: string): Promise<unknown> => {
385
+ for (let attempt = 0; ; attempt++) {
390
386
  try {
391
- const channel = await messengerFetchJson(
392
- `${API}/channels/${encodeURIComponent(message.channelId)}`,
393
- { headers: headers() },
394
- signal,
395
- deps.fetch,
396
- );
387
+ return await messengerFetchJson(url, { headers: headers() }, signal, deps.fetch);
388
+ } catch (error) {
397
389
  if (
398
- isRecord(channel) &&
399
- typeof channel.type === "number" &&
400
- [10, 11, 12].includes(channel.type) &&
401
- typeof channel.parent_id === "string" &&
402
- channel.parent_id
390
+ !(error instanceof MessengerHttpError) ||
391
+ error.status !== 429 ||
392
+ attempt >= 3 ||
393
+ signal.aborted
403
394
  ) {
404
- await onMessage({
405
- ...message,
406
- threadId: message.channelId,
407
- parentChannelId: channel.parent_id,
408
- });
395
+ throw error;
409
396
  }
397
+ await waitForReconnect(error.retryAfterMs ?? 1_000, signal, clock);
398
+ if (signal.aborted) throw error;
399
+ }
400
+ }
401
+ };
402
+ const isBotReply = async (value: unknown, channelId: string): Promise<boolean> => {
403
+ if (!isRecord(value) || value.type !== 19 || !isRecord(value.message_reference)) return false;
404
+ const reference = value.message_reference;
405
+ if (
406
+ typeof reference.message_id !== "string" ||
407
+ (reference.channel_id && reference.channel_id !== channelId)
408
+ )
409
+ return false;
410
+ let original = value.referenced_message;
411
+ if (original === undefined) {
412
+ try {
413
+ original = await lookup(
414
+ `${DISCORD_API}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(reference.message_id)}`,
415
+ );
410
416
  } catch {
411
- if (!signal.aborted) logger.warn("Discord thread parent lookup failed");
417
+ if (!signal.aborted) logger.warn("Discord reply lookup failed");
418
+ return false;
412
419
  }
413
- } else {
414
- await onMessage(message);
415
420
  }
421
+ return (
422
+ isRecord(original) &&
423
+ original.id === reference.message_id &&
424
+ isRecord(original.author) &&
425
+ original.author.id === accountId
426
+ );
427
+ };
428
+ const forward = (message: MessengerMessage): void => {
429
+ if (signal.aborted) return;
430
+ try {
431
+ void onMessage(message).catch(() => logger.warn("Discord message handler failed"));
432
+ } catch {
433
+ logger.warn("Discord message handler failed");
434
+ }
435
+ };
436
+ const classify = async (message: MessengerMessage, raw: unknown): Promise<void> => {
437
+ if (message.direct || !config.allowedUserIds.includes(message.senderId)) {
438
+ forward(message);
439
+ return;
440
+ }
441
+ if (!message.mentioned && (await isBotReply(raw, message.channelId))) {
442
+ message = { ...message, mentioned: true };
443
+ }
444
+ const thread = createdThreads.get(message.channelId);
445
+ if (thread && thread.senderId === message.senderId) {
446
+ forward({ ...message, threadId: message.channelId, parentChannelId: thread.parentChannelId });
447
+ return;
448
+ }
449
+ try {
450
+ const channel = await lookup(`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`);
451
+ if (
452
+ !isRecord(channel) ||
453
+ typeof channel.type !== "number" ||
454
+ ![10, 11, 12].includes(channel.type) ||
455
+ typeof channel.parent_id !== "string" ||
456
+ !channel.parent_id ||
457
+ (config.allowedChannelIds &&
458
+ !config.allowedChannelIds.includes(message.channelId) &&
459
+ !config.allowedChannelIds.includes(channel.parent_id))
460
+ ) {
461
+ forward(message);
462
+ return;
463
+ }
464
+ if (!message.mentioned && channel.owner_id !== accountId) {
465
+ let before = message.messageId;
466
+ for (;;) {
467
+ const history = await lookup(
468
+ `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages?limit=100&before=${encodeURIComponent(before)}`,
469
+ );
470
+ if (!Array.isArray(history)) return;
471
+ let invoked = false;
472
+ for (const entry of history) {
473
+ if (
474
+ !isRecord(entry) ||
475
+ !isRecord(entry.author) ||
476
+ entry.author.bot ||
477
+ entry.webhook_id ||
478
+ typeof entry.author.id !== "string" ||
479
+ !config.allowedUserIds.includes(entry.author.id)
480
+ )
481
+ continue;
482
+ if (
483
+ (Array.isArray(entry.mentions) &&
484
+ entry.mentions.some(user => isRecord(user) && user.id === accountId)) ||
485
+ (await isBotReply(entry, message.channelId))
486
+ ) {
487
+ invoked = true;
488
+ break;
489
+ }
490
+ }
491
+ if (invoked) break;
492
+ if (history.length < 100) {
493
+ forward(message);
494
+ return;
495
+ }
496
+ const last = history[history.length - 1];
497
+ if (!isRecord(last) || typeof last.id !== "string" || !last.id || last.id === before) return;
498
+ before = last.id;
499
+ }
500
+ }
501
+ forward({ ...message, threadId: message.channelId, parentChannelId: channel.parent_id });
502
+ } catch {
503
+ if (!signal.aborted) logger.warn("Discord thread lookup failed");
504
+ }
505
+ return;
506
+ };
507
+ const deliver = (message: MessengerMessage, raw: unknown): Promise<void> => {
508
+ if (signal.aborted) return Promise.resolve();
509
+ if (message.direct || !config.allowedUserIds.includes(message.senderId)) {
510
+ forward(message);
511
+ return Promise.resolve();
512
+ }
513
+ const key = `${message.channelId}:${message.messageId}`;
514
+ if (pendingClassifications.has(key) || pendingClassifications.size >= MAX_CLASSIFICATION_PENDING) {
515
+ return Promise.resolve();
516
+ }
517
+ pendingClassifications.add(key);
518
+ const previous = Promise.all([
519
+ classificationTails.get(message.channelId),
520
+ messageClassifications.get(message.channelId),
521
+ ]);
522
+ const current = previous
523
+ .then(() => (signal.aborted ? undefined : classify(message, raw)))
524
+ .catch(() => logger.warn("Discord classification failed"));
525
+ classificationTails.set(message.channelId, current);
526
+ messageClassifications.set(message.messageId, current);
527
+ void current.then(() => {
528
+ pendingClassifications.delete(key);
529
+ if (messageClassifications.get(message.messageId) === current)
530
+ messageClassifications.delete(message.messageId);
531
+ if (classificationTails.get(message.channelId) === current)
532
+ classificationTails.delete(message.channelId);
533
+ });
534
+ return current;
416
535
  };
417
536
  await connectGateway(
418
537
  session.url ?? initialUrl,
@@ -459,7 +578,7 @@ export function createDiscordAdapter(
459
578
  return message;
460
579
  }
461
580
  const channel = await messengerFetchJson(
462
- `${API}/channels/${encodeURIComponent(message.channelId)}`,
581
+ `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`,
463
582
  { headers: headers() },
464
583
  signal,
465
584
  deps.fetch,
@@ -474,7 +593,7 @@ export function createDiscordAdapter(
474
593
  }
475
594
  if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
476
595
  const thread = await messengerFetchJson(
477
- `${API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/threads`,
596
+ `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/threads`,
478
597
  { method: "POST", headers: headers(), body: JSON.stringify({ name: `Request ${message.messageId}` }) },
479
598
  signal,
480
599
  deps.fetch,
@@ -485,7 +604,7 @@ export function createDiscordAdapter(
485
604
  return { ...message, threadId: thread.id };
486
605
  },
487
606
  async status(message, state, signal) {
488
- const base = `${API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
607
+ const base = `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
489
608
  const processing = encodeURIComponent("⏳");
490
609
  if (state !== "processing") {
491
610
  try {
@@ -500,17 +619,44 @@ export function createDiscordAdapter(
500
619
  }
501
620
  }
502
621
  const emoji = state === "processing" ? "⏳" : state === "success" ? "✅" : "❌";
622
+ try {
623
+ await messengerFetchJson(
624
+ `${base}/${encodeURIComponent(emoji)}/@me`,
625
+ { method: "PUT", headers: headers() },
626
+ signal,
627
+ deps.fetch,
628
+ );
629
+ } finally {
630
+ if (state === "processing" && signal?.aborted) {
631
+ try {
632
+ await messengerFetchJson(
633
+ `${base}/${processing}/@me`,
634
+ { method: "DELETE", headers: headers() },
635
+ AbortSignal.timeout(3_000),
636
+ deps.fetch,
637
+ );
638
+ } catch {
639
+ logger.warn("Discord late processing reaction removal failed");
640
+ }
641
+ }
642
+ }
643
+ },
644
+ async clearStatus(message, signal) {
645
+ const base = `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
503
646
  await messengerFetchJson(
504
- `${base}/${encodeURIComponent(emoji)}/@me`,
505
- { method: "PUT", headers: headers() },
647
+ `${base}/${encodeURIComponent("⏳")}/@me`,
648
+ { method: "DELETE", headers: headers() },
506
649
  signal,
507
650
  deps.fetch,
508
651
  );
509
652
  },
653
+ releaseActivity(message) {
654
+ activityMessages.delete(JSON.stringify([message.threadId ?? message.channelId, message.messageId]));
655
+ },
510
656
  async activity(message, text, signal) {
511
657
  const channelId = message.threadId ?? message.channelId;
512
658
  const key = JSON.stringify([channelId, message.messageId]);
513
- const messages = `${API}/channels/${encodeURIComponent(channelId)}/messages`;
659
+ const messages = `${DISCORD_API}/channels/${encodeURIComponent(channelId)}/messages`;
514
660
  const statusId = activityMessages.get(key);
515
661
  if (text === undefined) {
516
662
  if (!statusId) return;
@@ -560,6 +706,19 @@ export function createDiscordAdapter(
560
706
  );
561
707
  if (!isRecord(created) || typeof created.id !== "string" || !created.id)
562
708
  throw new Error("Discord activity message returned no ID");
709
+ if (signal?.aborted) {
710
+ try {
711
+ await messengerFetchJson(
712
+ `${messages}/${encodeURIComponent(created.id)}`,
713
+ { method: "DELETE", headers: headers() },
714
+ AbortSignal.timeout(3_000),
715
+ deps.fetch,
716
+ );
717
+ } catch {
718
+ logger.warn("Discord late activity removal failed");
719
+ }
720
+ return;
721
+ }
563
722
  activityMessages.set(key, created.id);
564
723
  },
565
724
  async send(message, text, signal) {
@@ -571,7 +730,7 @@ export function createDiscordAdapter(
571
730
  files = result.files;
572
731
  } catch {
573
732
  await messengerFetchJson(
574
- `${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
733
+ `${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
575
734
  {
576
735
  method: "POST",
577
736
  headers: headers(),
@@ -614,7 +773,7 @@ export function createDiscordAdapter(
614
773
  });
615
774
  }
616
775
  await messengerFetchJson(
617
- `${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
776
+ `${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
618
777
  {
619
778
  method: "POST",
620
779
  headers: requestHeaders,
@@ -655,7 +814,7 @@ export function createDiscordAdapter(
655
814
  for (let attempt = 0; ; attempt++) {
656
815
  try {
657
816
  await messengerFetchJson(
658
- `${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
817
+ `${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
659
818
  { method: "POST", headers: requestHeaders, body: form },
660
819
  signal,
661
820
  deps.fetch,
@@ -668,7 +827,7 @@ export function createDiscordAdapter(
668
827
  }
669
828
  if (!signal?.aborted) {
670
829
  await messengerFetchJson(
671
- `${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
830
+ `${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
672
831
  {
673
832
  method: "POST",
674
833
  headers: headers(),
@@ -141,24 +141,27 @@ function createActivityReporter(
141
141
  closing ??= (async () => {
142
142
  closed = true;
143
143
  wake?.();
144
- await pump;
145
- if (!shown) return;
146
- for (let attempt = 0; ; attempt++) {
147
- try {
148
- await update(undefined);
149
- return;
150
- } catch (error) {
151
- const wait =
152
- error instanceof MessengerHttpError && error.status === 429
153
- ? (error.retryAfterMs ?? 1_000)
154
- : 1_000;
155
- // One bounded retry: removal holds the request queue.
156
- if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
157
- logger.warn("Messenger activity removal failed", { platform: destination.platform });
144
+ try {
145
+ await pump;
146
+ if (!shown) return;
147
+ for (let attempt = 0; ; attempt++) {
148
+ try {
149
+ await update(undefined);
158
150
  return;
151
+ } catch (error) {
152
+ const wait =
153
+ error instanceof MessengerHttpError && error.status === 429
154
+ ? (error.retryAfterMs ?? 1_000)
155
+ : 1_000;
156
+ if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
157
+ logger.warn("Messenger activity removal failed", { platform: destination.platform });
158
+ return;
159
+ }
160
+ await Bun.sleep(wait);
159
161
  }
160
- await Bun.sleep(wait);
161
162
  }
163
+ } finally {
164
+ adapter.releaseActivity?.(destination);
162
165
  }
163
166
  })();
164
167
  return closing;
@@ -183,14 +186,18 @@ export function messengerSessionRoute(cwd: string, message: MessengerMessage): s
183
186
  }
184
187
 
185
188
  /**
186
- * Scheduling heuristic, refined after `prepare`: a top-level server message on a prepare-capable adapter
187
- * (Discord) starts its own reply thread, so it is never held behind queued work; every other message waits for
188
- * earlier requests in its conversation. A follow-up arriving in that new thread is ordered behind the request
189
- * that created it by the prepared-conversation lock in `processRequest`.
189
+ * Discord creates a thread with the original message's ID. Reserve that conversation key before
190
+ * preparing it so follow-ups cannot overtake the request while thread creation is in flight.
190
191
  */
191
192
  function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
192
- if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
193
- return JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
193
+ if (
194
+ message.platform === "discord" &&
195
+ adapter.prepare &&
196
+ !message.direct &&
197
+ !message.threadId &&
198
+ !message.parentChannelId
199
+ ) {
200
+ return messengerConversationKey({ ...message, threadId: message.messageId });
194
201
  }
195
202
  return messengerConversationKey(message);
196
203
  }
@@ -271,6 +278,7 @@ export function createMessengerGateway(
271
278
  let tail: Promise<void> | undefined;
272
279
  let conversation = "";
273
280
  let statusChain = Promise.resolve();
281
+ let processingStarted = false;
274
282
  const status = (state: "processing" | "success" | "failure"): void => {
275
283
  if (!adapter.status) return;
276
284
  statusChain = statusChain.then(async () => {
@@ -281,7 +289,10 @@ export function createMessengerGateway(
281
289
  const onAbort = (): void => stopped.resolve();
282
290
  statusSignal.addEventListener("abort", onAbort, { once: true });
283
291
  statusInFlight++;
284
- const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
292
+ const operation = Promise.resolve().then(() => {
293
+ if (state === "processing") processingStarted = true;
294
+ return adapter.status!(message, state, statusSignal);
295
+ });
285
296
  void operation.then(
286
297
  () => {
287
298
  statusInFlight--;
@@ -345,6 +356,21 @@ export function createMessengerGateway(
345
356
  }
346
357
  } finally {
347
358
  await activity.close();
359
+ if (signal.aborted && processingStarted && adapter.clearStatus && statusInFlight < MAX_STATUS_IN_FLIGHT) {
360
+ statusInFlight++;
361
+ const operation = Promise.resolve().then(() =>
362
+ adapter.clearStatus!(message, AbortSignal.timeout(STATUS_TIMEOUT_MS)),
363
+ );
364
+ void operation.then(
365
+ () => statusInFlight--,
366
+ () => statusInFlight--,
367
+ );
368
+ try {
369
+ await Promise.race([operation, Bun.sleep(STATUS_TIMEOUT_MS)]);
370
+ } catch {
371
+ logger.warn("Messenger cancelled status removal failed", { platform: message.platform });
372
+ }
373
+ }
348
374
  release();
349
375
  if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
350
376
  resolve();
@@ -0,0 +1,122 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { $ } from "bun";
5
+
6
+ /** A per-user background service that keeps one messenger profile's gateway running. */
7
+ export interface MessengerServicePlan {
8
+ /** Stable service name derived from the profile directory. */
9
+ label: string;
10
+ /** Profile directory: working directory and home of the run script and log. */
11
+ directory: string;
12
+ runScript: string;
13
+ logFile: string;
14
+ }
15
+
16
+ export function messengerServicePlan(directory: string, runScript: string): MessengerServicePlan {
17
+ const slug =
18
+ path
19
+ .basename(directory)
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, "-")
22
+ .replace(/^-+|-+$/g, "") || "default";
23
+ return {
24
+ label: `dev.jeopi.messenger.${slug}`,
25
+ directory,
26
+ runScript,
27
+ logFile: path.join(directory, "gateway.log"),
28
+ };
29
+ }
30
+
31
+ function xml(value: string): string {
32
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
33
+ }
34
+
35
+ /** launchd job: starts at login, restarts after a crash (throttled), logs to the profile directory. */
36
+ export function launchdPlist(plan: MessengerServicePlan): string {
37
+ return `<?xml version="1.0" encoding="UTF-8"?>
38
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
39
+ <plist version="1.0">
40
+ <dict>
41
+ <key>Label</key><string>${xml(plan.label)}</string>
42
+ <key>ProgramArguments</key>
43
+ <array><string>/bin/sh</string><string>${xml(plan.runScript)}</string></array>
44
+ <key>WorkingDirectory</key><string>${xml(plan.directory)}</string>
45
+ <key>RunAtLoad</key><true/>
46
+ <key>KeepAlive</key><true/>
47
+ <key>ThrottleInterval</key><integer>30</integer>
48
+ <key>StandardOutPath</key><string>${xml(plan.logFile)}</string>
49
+ <key>StandardErrorPath</key><string>${xml(plan.logFile)}</string>
50
+ </dict>
51
+ </plist>
52
+ `;
53
+ }
54
+
55
+ /** systemd --user unit with the same lifecycle as the launchd job. */
56
+ export function systemdUnit(plan: MessengerServicePlan): string {
57
+ const quoted = (value: string): string => `"${value.replace(/(["\\])/g, "\\$1")}"`;
58
+ return `[Unit]
59
+ Description=jeopi messenger gateway (${plan.label})
60
+ After=network-online.target
61
+
62
+ [Service]
63
+ WorkingDirectory=${quoted(plan.directory)}
64
+ ExecStart=/bin/sh ${quoted(plan.runScript)}
65
+ Restart=always
66
+ RestartSec=30
67
+ StandardOutput=append:${plan.logFile}
68
+ StandardError=append:${plan.logFile}
69
+
70
+ [Install]
71
+ WantedBy=default.target
72
+ `;
73
+ }
74
+
75
+ /**
76
+ * Install (or replace) and start the service, then confirm the platform reports it running.
77
+ * Returns a one-line description of where it was installed.
78
+ */
79
+ export async function installMessengerService(
80
+ plan: MessengerServicePlan,
81
+ platform: NodeJS.Platform = process.platform,
82
+ home: string = os.homedir(),
83
+ ): Promise<string> {
84
+ if (platform === "darwin") {
85
+ const file = path.join(home, "Library", "LaunchAgents", `${plan.label}.plist`);
86
+ await Bun.write(file, launchdPlist(plan));
87
+ const domain = `gui/${process.getuid?.() ?? 0}`;
88
+ await $`launchctl bootout ${domain} ${file}`.quiet().nothrow();
89
+ // bootstrap can fail briefly while a replaced job is still exiting.
90
+ let loaded = false;
91
+ for (let attempt = 0; attempt < 5 && !loaded; attempt++) {
92
+ if (attempt) await Bun.sleep(1_000);
93
+ loaded = (await $`launchctl bootstrap ${domain} ${file}`.quiet().nothrow()).exitCode === 0;
94
+ }
95
+ if (!loaded) throw new Error(`launchctl could not load ${file}`);
96
+ for (let attempt = 0; attempt < 20; attempt++) {
97
+ const status = await $`launchctl print ${domain}/${plan.label}`.quiet().nothrow();
98
+ if (status.exitCode === 0 && /\bstate = running\b/.test(status.text()))
99
+ return `launchd job ${plan.label} (${file})`;
100
+ await Bun.sleep(500);
101
+ }
102
+ throw new Error(`launchd job ${plan.label} did not reach the running state; see ${plan.logFile}`);
103
+ }
104
+ if (platform === "linux") {
105
+ const unit = `${plan.label}.service`;
106
+ const file = path.join(home, ".config", "systemd", "user", unit);
107
+ await fs.mkdir(path.dirname(file), { recursive: true });
108
+ await Bun.write(file, systemdUnit(plan));
109
+ const reload = await $`systemctl --user daemon-reload`.quiet().nothrow();
110
+ const enable = await $`systemctl --user enable ${unit}`.quiet().nothrow();
111
+ const restart = await $`systemctl --user restart ${unit}`.quiet().nothrow();
112
+ if (reload.exitCode !== 0 || enable.exitCode !== 0 || restart.exitCode !== 0) {
113
+ throw new Error(`systemctl --user could not start ${unit}`);
114
+ }
115
+ const active = await $`systemctl --user is-active ${unit}`.quiet().nothrow();
116
+ if (active.text().trim() !== "active") throw new Error(`${unit} is not active; see ${plan.logFile}`);
117
+ return `systemd user unit ${unit} (${file})`;
118
+ }
119
+ throw new Error(
120
+ `Background services are supported on macOS (launchd) and Linux (systemd --user); run ${plan.runScript} under your own process manager`,
121
+ );
122
+ }