@delt/claude-alarm 1.2.0 → 1.3.0

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.
@@ -245,6 +245,13 @@ ${requestId}`;
245
245
  // src/hub/telegram.ts
246
246
  var TELEGRAM_API = "https://api.telegram.org/bot";
247
247
  var MAX_CHOICE_MESSAGES = 200;
248
+ var MAX_SELECTIONS = 20;
249
+ var MAX_VISIBLE_CHARS = 4e3;
250
+ var TRUNCATED = "\u2026(truncated)";
251
+ var MAX_PHOTO_BYTES = 10 * 1024 * 1024;
252
+ function visibleLength(html) {
253
+ return html.replace(/<[^>]*>/g, "").replace(/&(?:amp|lt|gt);/g, "&").length;
254
+ }
248
255
  var TelegramBot = class _TelegramBot {
249
256
  config;
250
257
  offset = 0;
@@ -265,9 +272,9 @@ var TelegramBot = class _TelegramBot {
265
272
  choiceMessages = /* @__PURE__ */ new Map();
266
273
  // Callback: get current sessions list
267
274
  getSessions;
268
- // Pending messages for session selection
269
- pendingMessages = /* @__PURE__ */ new Map();
270
- // chatId -> pending
275
+ latestSelection;
276
+ // Keyed by prompt, not by chat: buttons from an older prompt must still send that prompt's message.
277
+ selections = /* @__PURE__ */ new Map();
271
278
  constructor(config) {
272
279
  this.config = config;
273
280
  }
@@ -276,8 +283,7 @@ var TelegramBot = class _TelegramBot {
276
283
  }
277
284
  /** Send a notification message to Telegram */
278
285
  async sendNotification(sessionId, _sessionLabel, title, message) {
279
- const text = `<b>${this.escHtml(title)}</b>
280
- ${this.mdToHtml(message)}`;
286
+ const text = this.fitNotification(title, message);
281
287
  const result = await this.sendMessage(text);
282
288
  if (result?.message_id) {
283
289
  this.messageSessionMap.set(result.message_id, sessionId);
@@ -289,6 +295,25 @@ ${this.mdToHtml(message)}`;
289
295
  }
290
296
  }
291
297
  }
298
+ fitNotification(title, message) {
299
+ const render = (body) => `<b>${this.escHtml(title)}</b>
300
+ ${this.mdToHtml(body)}`;
301
+ const full = render(message);
302
+ if (visibleLength(full) <= MAX_VISIBLE_CHARS) return full;
303
+ const cut = (n) => {
304
+ const end = n > 0 && /[\uD800-\uDBFF]/.test(message[n - 1]) ? n - 1 : n;
305
+ return render(`${message.slice(0, end)}
306
+ ${TRUNCATED}`);
307
+ };
308
+ let lo = 0;
309
+ let hi = message.length;
310
+ while (lo < hi) {
311
+ const mid = Math.ceil((lo + hi) / 2);
312
+ if (visibleLength(cut(mid)) <= MAX_VISIBLE_CHARS) lo = mid;
313
+ else hi = mid - 1;
314
+ }
315
+ return cut(lo);
316
+ }
292
317
  /** Send a text message to the configured chat */
293
318
  async sendMessage(text, replyToMessageId, replyMarkup) {
294
319
  try {
@@ -370,6 +395,13 @@ ${this.mdToHtml(message)}`;
370
395
  const hasPhoto = msg.photo && msg.photo.length > 0;
371
396
  const text = (msg.text || msg.caption || "").trim();
372
397
  if (!text && !hasPhoto) return;
398
+ if (hasPhoto) {
399
+ const size = msg.photo[msg.photo.length - 1].file_size;
400
+ if (size !== void 0 && size > MAX_PHOTO_BYTES) {
401
+ this.photoNotDelivered("it is larger than 10 MB");
402
+ return;
403
+ }
404
+ }
373
405
  if (msg.reply_to_message) {
374
406
  const sessionId = this.messageSessionMap.get(msg.reply_to_message.message_id);
375
407
  if (sessionId) {
@@ -383,18 +415,20 @@ ${this.mdToHtml(message)}`;
383
415
  }
384
416
  if (text) {
385
417
  const selectMatch = text.match(/^\/s_(\d+)$/);
386
- if (selectMatch) {
387
- const pending = this.pendingMessages.get(msg.chat.id);
418
+ const latest = this.latestSelection;
419
+ if (selectMatch && latest !== void 0) {
420
+ const pending = this.selections.get(latest);
388
421
  if (pending) {
389
- this.pendingMessages.delete(msg.chat.id);
422
+ this.selections.delete(latest);
390
423
  const session = this.pendingSession(pending, parseInt(selectMatch[1], 10) - 1);
391
424
  if (session) {
425
+ let delivered = true;
392
426
  if (pending.photoFileId) {
393
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
427
+ delivered = await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
394
428
  } else if (pending.text) {
395
429
  this.deliverToSession(session.id, pending.text);
396
430
  }
397
- this.sendMessage(`Sent to [${this.getLabel(session)}]`);
431
+ if (delivered) this.sendMessage(`Sent to [${this.getLabel(session)}]`);
398
432
  } else {
399
433
  this.sendMessage("Invalid session number.");
400
434
  }
@@ -415,16 +449,19 @@ ${this.mdToHtml(message)}`;
415
449
  }
416
450
  return;
417
451
  }
452
+ const selectionId = randomUUID().replace(/-/g, "").slice(0, 8);
453
+ this.latestSelection = selectionId;
418
454
  const sessionIds = sessions.map((s) => s.id);
419
455
  if (hasPhoto) {
420
456
  const largest = msg.photo[msg.photo.length - 1];
421
- this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text, sessionIds });
457
+ this.selections.set(selectionId, { photoFileId: largest.file_id, caption: text, sessionIds });
422
458
  } else {
423
- this.pendingMessages.set(msg.chat.id, { text, sessionIds });
459
+ this.selections.set(selectionId, { text, sessionIds });
424
460
  }
461
+ while (this.selections.size > MAX_SELECTIONS) this.selections.delete(this.selections.keys().next().value);
425
462
  const buttons = sessions.map((s, i) => ({
426
463
  text: this.getLabel(s),
427
- callback_data: `sess:${i}:${msg.chat.id}`
464
+ callback_data: `sel:${selectionId}:${i}`
428
465
  }));
429
466
  const rows = [];
430
467
  for (let i = 0; i < buttons.length; i += 2) {
@@ -439,24 +476,27 @@ ${this.mdToHtml(message)}`;
439
476
  }
440
477
  async deliverPhotoToSession(sessionId, photos, caption) {
441
478
  const largest = photos[photos.length - 1];
442
- await this.deliverPhotoToSessionByFileId(sessionId, largest.file_id, caption);
479
+ return this.deliverPhotoToSessionByFileId(sessionId, largest.file_id, caption);
443
480
  }
444
481
  async deliverPhotoToSessionByFileId(sessionId, fileId, caption) {
445
482
  try {
446
483
  const fileRes = await fetch(`${this.apiUrl}/getFile?file_id=${fileId}`);
447
- if (!fileRes.ok) {
448
- logger.warn("Failed to get Telegram file info");
449
- return;
484
+ const fileData = fileRes.ok ? await fileRes.json() : void 0;
485
+ if (!fileData?.ok || !fileData.result) {
486
+ this.photoNotDelivered("Telegram did not return the file");
487
+ return false;
450
488
  }
451
- const fileData = await fileRes.json();
452
- if (!fileData.ok) return;
453
489
  const downloadUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${fileData.result.file_path}`;
454
490
  const imgRes = await fetch(downloadUrl);
455
491
  if (!imgRes.ok) {
456
- logger.warn("Failed to download Telegram photo");
457
- return;
492
+ this.photoNotDelivered("the download failed");
493
+ return false;
458
494
  }
459
495
  const buffer = Buffer.from(await imgRes.arrayBuffer());
496
+ if (buffer.length > MAX_PHOTO_BYTES) {
497
+ this.photoNotDelivered("it is larger than 10 MB");
498
+ return false;
499
+ }
460
500
  const ext = fileData.result.file_path.split(".").pop() || "jpg";
461
501
  const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/jpeg";
462
502
  fs.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -472,11 +512,18 @@ ${this.mdToHtml(message)}`;
472
512
  fs.unlinkSync(filePath);
473
513
  } catch {
474
514
  }
475
- }, 5 * 60 * 1e3);
515
+ }, 5 * 60 * 1e3).unref();
516
+ return this.onImageToSession !== void 0;
476
517
  } catch (err) {
477
518
  logger.warn(`Telegram photo download failed: ${err.message}`);
519
+ this.photoNotDelivered("the download failed");
520
+ return false;
478
521
  }
479
522
  }
523
+ photoNotDelivered(reason) {
524
+ logger.warn(`Telegram photo not delivered: ${reason}`);
525
+ void this.sendMessage(`Photo not delivered: ${reason}`);
526
+ }
480
527
  // Buttons are numbered against the list shown when they were sent; Codex sessions come and go, so the live list may have shifted.
481
528
  pendingSession(pending, idx) {
482
529
  const id = pending.sessionIds[idx];
@@ -590,7 +637,7 @@ ${this.escHtml(description)}` : "") + (slice ? `
590
637
  async handleChoiceCallback(query) {
591
638
  const choice = this.choiceTokens.get(query.data.slice("pc:".length));
592
639
  if (!choice) {
593
- await this.answerCallbackQuery(query.id, "Expired");
640
+ await this.expire(query);
594
641
  return;
595
642
  }
596
643
  this.dropChoiceTokens(choice.sessionId, choice.requestId);
@@ -619,10 +666,14 @@ ${this.escHtml(description)}` : "") + (slice ? `
619
666
  async handleCallbackQuery(query) {
620
667
  if (!query.data) return;
621
668
  if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
622
- if (query.data.startsWith("sess:")) {
669
+ if (query.data.startsWith("sel:")) {
623
670
  await this.handleSessionSelectCallback(query);
624
671
  return;
625
672
  }
673
+ if (query.data.startsWith("sess:")) {
674
+ await this.expire(query);
675
+ return;
676
+ }
626
677
  if (query.data.startsWith("pc:")) {
627
678
  await this.handleChoiceCallback(query);
628
679
  return;
@@ -645,19 +696,25 @@ ${label}`);
645
696
  }
646
697
  }
647
698
  async handleSessionSelectCallback(query) {
648
- const parts = query.data.split(":");
649
- if (parts.length < 3) return;
650
- const [, idxStr, chatIdStr] = parts;
651
- const chatId = parseInt(chatIdStr, 10);
652
- const pending = this.pendingMessages.get(chatId);
653
- const session = pending ? this.pendingSession(pending, parseInt(idxStr, 10)) : void 0;
654
- if (!pending || !session) {
699
+ const [, selectionId, idxStr] = query.data.split(":");
700
+ const pending = this.selections.get(selectionId);
701
+ if (!pending) {
702
+ await this.expire(query);
703
+ return;
704
+ }
705
+ const session = this.pendingSession(pending, parseInt(idxStr, 10));
706
+ if (!session) {
655
707
  await this.answerCallbackQuery(query.id, "Session not found");
656
708
  return;
657
709
  }
658
- this.pendingMessages.delete(chatId);
710
+ this.selections.delete(selectionId);
659
711
  if (pending.photoFileId) {
660
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
712
+ const delivered = await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
713
+ if (!delivered) {
714
+ await this.answerCallbackQuery(query.id, "Photo not delivered");
715
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
716
+ return;
717
+ }
661
718
  } else if (pending.text) {
662
719
  this.deliverToSession(session.id, pending.text);
663
720
  }
@@ -677,6 +734,21 @@ ${label}`);
677
734
  logger.warn(`Telegram answerCallbackQuery error: ${err.message}`);
678
735
  }
679
736
  }
737
+ async expire(query) {
738
+ await this.answerCallbackQuery(query.id, "Expired");
739
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
740
+ }
741
+ async removeButtons(chatId, messageId) {
742
+ try {
743
+ await fetch(`${this.apiUrl}/editMessageReplyMarkup`, {
744
+ method: "POST",
745
+ headers: { "Content-Type": "application/json" },
746
+ body: JSON.stringify({ chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } })
747
+ });
748
+ } catch (err) {
749
+ logger.warn(`Telegram editMessageReplyMarkup error: ${err.message}`);
750
+ }
751
+ }
680
752
  async editMessageText(chatId, messageId, text) {
681
753
  try {
682
754
  await fetch(`${this.apiUrl}/editMessageText`, {
@@ -773,19 +845,30 @@ var HEALTHY_RUN_MS = 6e4;
773
845
  function resolveAdapterScript(baseDir) {
774
846
  return [path3.join(baseDir, "..", "codex", "main.js"), path3.join(baseDir, "codex", "main.js")].find((p) => fs2.existsSync(p));
775
847
  }
848
+ function adapterEnv(base, hub) {
849
+ const env = { ...base, CLAUDE_ALARM_HUB_HOST: hub.host, CLAUDE_ALARM_HUB_PORT: String(hub.port) };
850
+ delete env.CLAUDE_ALARM_HUB_TOKEN;
851
+ return env;
852
+ }
776
853
  var CodexSupervisor = class {
777
- constructor(script, spawnFn = spawn, minDelayMs = 2e3, maxDelayMs = 6e4, stopGraceMs = 3e3) {
854
+ constructor(script, opts = {}) {
778
855
  this.script = script;
779
- this.spawnFn = spawnFn;
780
- this.minDelayMs = minDelayMs;
781
- this.maxDelayMs = maxDelayMs;
782
- this.stopGraceMs = stopGraceMs;
783
- this.delay = minDelayMs;
856
+ this.spawnFn = opts.spawnFn ?? spawn;
857
+ this.minDelayMs = opts.minDelayMs ?? 2e3;
858
+ this.maxDelayMs = opts.maxDelayMs ?? 6e4;
859
+ this.stopGraceMs = opts.stopGraceMs ?? 3e3;
860
+ this.env = opts.env;
861
+ this.delay = this.minDelayMs;
784
862
  }
785
863
  child;
786
864
  timer;
787
865
  stopped = true;
788
866
  delay;
867
+ spawnFn;
868
+ minDelayMs;
869
+ maxDelayMs;
870
+ stopGraceMs;
871
+ env;
789
872
  start() {
790
873
  this.stopped = false;
791
874
  this.launch();
@@ -807,7 +890,8 @@ var CodexSupervisor = class {
807
890
  const child = this.spawnFn(process.execPath, [this.script, "--watch-stdin"], {
808
891
  detached: true,
809
892
  stdio: ["pipe", "pipe", "pipe"],
810
- windowsHide: true
893
+ windowsHide: true,
894
+ ...this.env ? { env: this.env } : {}
811
895
  });
812
896
  this.child = child;
813
897
  child.stdout?.on("data", (d) => process.stdout.write(d));
@@ -1591,7 +1675,7 @@ var HubServer = class {
1591
1675
  channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1592
1676
  }
1593
1677
  } else if (msg.type === "image_upload") {
1594
- this.handleImageUpload(msg);
1678
+ this.handleImageUpload(ws, msg);
1595
1679
  } else if (msg.type === "permission_response") {
1596
1680
  if (this.forwardPermissionResponse(msg)) {
1597
1681
  const verdict = msg.choiceId !== void 0 ? `choice ${msg.choiceId}` : msg.behavior;
@@ -1642,20 +1726,31 @@ var HubServer = class {
1642
1726
  }
1643
1727
  }
1644
1728
  }
1645
- handleImageUpload(msg) {
1729
+ handleImageUpload(ws, msg) {
1646
1730
  const { sessionId, imageData, mimeType, originalName, content } = msg;
1731
+ const reject = (reason) => {
1732
+ logger.warn(`Image upload rejected for ${sessionId}: ${reason}`);
1733
+ const rejected = { type: "upload_rejected", sessionId, reason, withText: typeof content === "string" && content.trim().length > 0 };
1734
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(rejected));
1735
+ };
1736
+ const channelWs = this.channelSockets.get(sessionId);
1737
+ if (!channelWs || channelWs.readyState !== WebSocket.OPEN) {
1738
+ reject("the session is not connected");
1739
+ return;
1740
+ }
1647
1741
  if (!this.localChannels.has(sessionId)) {
1648
- logger.warn(`Image upload rejected: session ${sessionId} is not local`);
1742
+ reject("this session is on another PC; images can only go to sessions on the hub's PC");
1649
1743
  return;
1650
1744
  }
1651
- const channelWs = this.channelSockets.get(sessionId);
1652
- if (!channelWs || channelWs.readyState !== WebSocket.OPEN) return;
1653
1745
  const allowedTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
1654
- if (!allowedTypes.includes(mimeType)) return;
1746
+ if (!allowedTypes.includes(mimeType)) {
1747
+ reject("only PNG, JPEG, GIF and WebP images are supported");
1748
+ return;
1749
+ }
1655
1750
  const base64Data = imageData.replace(/^data:image\/\w+;base64,/, "");
1656
1751
  const buffer = Buffer.from(base64Data, "base64");
1657
1752
  if (buffer.length > 10 * 1024 * 1024) {
1658
- logger.warn("Image upload rejected: exceeds 10MB");
1753
+ reject("the image is larger than 10 MB");
1659
1754
  return;
1660
1755
  }
1661
1756
  fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -1840,7 +1935,7 @@ var HubServer = class {
1840
1935
  logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1841
1936
  return;
1842
1937
  }
1843
- this.codexSupervisor = new CodexSupervisor(script);
1938
+ this.codexSupervisor = new CodexSupervisor(script, { env: adapterEnv(process.env, { host: this.host, port: this.port }) });
1844
1939
  this.codexSupervisor.start();
1845
1940
  logger.info("Codex adapter started");
1846
1941
  }