@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.
package/dist/index.d.ts CHANGED
@@ -73,6 +73,11 @@ type ChannelMessage = {
73
73
  originalName?: string;
74
74
  content?: string;
75
75
  source?: MessageSource;
76
+ } | {
77
+ type: 'upload_rejected';
78
+ sessionId: string;
79
+ reason: string;
80
+ withText: boolean;
76
81
  } | {
77
82
  type: 'sessions_list';
78
83
  sessions: SessionInfo[];
@@ -323,11 +328,13 @@ declare class TelegramBot {
323
328
  private choiceTokens;
324
329
  private choiceMessages;
325
330
  getSessions?: () => SessionInfo[];
326
- private pendingMessages;
331
+ private latestSelection;
332
+ private selections;
327
333
  constructor(config: TelegramConfig);
328
334
  private get apiUrl();
329
335
  /** Send a notification message to Telegram */
330
336
  sendNotification(sessionId: string, _sessionLabel: string, title: string, message: string): Promise<void>;
337
+ private fitNotification;
331
338
  /** Send a text message to the configured chat */
332
339
  private sendMessage;
333
340
  /** Start long polling for incoming messages */
@@ -340,6 +347,7 @@ declare class TelegramBot {
340
347
  private deliverToSession;
341
348
  private deliverPhotoToSession;
342
349
  private deliverPhotoToSessionByFileId;
350
+ private photoNotDelivered;
343
351
  private pendingSession;
344
352
  private getLabel;
345
353
  /** Send a permission request with inline buttons */
@@ -352,6 +360,8 @@ declare class TelegramBot {
352
360
  private handleCallbackQuery;
353
361
  private handleSessionSelectCallback;
354
362
  private answerCallbackQuery;
363
+ private expire;
364
+ private removeButtons;
355
365
  private editMessageText;
356
366
  private escHtml;
357
367
  private static readonly LANG_BY_EXT;
package/dist/index.js CHANGED
@@ -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));
@@ -1615,7 +1699,7 @@ var HubServer = class {
1615
1699
  channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1616
1700
  }
1617
1701
  } else if (msg.type === "image_upload") {
1618
- this.handleImageUpload(msg);
1702
+ this.handleImageUpload(ws, msg);
1619
1703
  } else if (msg.type === "permission_response") {
1620
1704
  if (this.forwardPermissionResponse(msg)) {
1621
1705
  const verdict = msg.choiceId !== void 0 ? `choice ${msg.choiceId}` : msg.behavior;
@@ -1666,20 +1750,31 @@ var HubServer = class {
1666
1750
  }
1667
1751
  }
1668
1752
  }
1669
- handleImageUpload(msg) {
1753
+ handleImageUpload(ws, msg) {
1670
1754
  const { sessionId, imageData, mimeType, originalName, content } = msg;
1755
+ const reject = (reason) => {
1756
+ logger.warn(`Image upload rejected for ${sessionId}: ${reason}`);
1757
+ const rejected = { type: "upload_rejected", sessionId, reason, withText: typeof content === "string" && content.trim().length > 0 };
1758
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(rejected));
1759
+ };
1760
+ const channelWs = this.channelSockets.get(sessionId);
1761
+ if (!channelWs || channelWs.readyState !== WebSocket.OPEN) {
1762
+ reject("the session is not connected");
1763
+ return;
1764
+ }
1671
1765
  if (!this.localChannels.has(sessionId)) {
1672
- logger.warn(`Image upload rejected: session ${sessionId} is not local`);
1766
+ reject("this session is on another PC; images can only go to sessions on the hub's PC");
1673
1767
  return;
1674
1768
  }
1675
- const channelWs = this.channelSockets.get(sessionId);
1676
- if (!channelWs || channelWs.readyState !== WebSocket.OPEN) return;
1677
1769
  const allowedTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
1678
- if (!allowedTypes.includes(mimeType)) return;
1770
+ if (!allowedTypes.includes(mimeType)) {
1771
+ reject("only PNG, JPEG, GIF and WebP images are supported");
1772
+ return;
1773
+ }
1679
1774
  const base64Data = imageData.replace(/^data:image\/\w+;base64,/, "");
1680
1775
  const buffer = Buffer.from(base64Data, "base64");
1681
1776
  if (buffer.length > 10 * 1024 * 1024) {
1682
- logger.warn("Image upload rejected: exceeds 10MB");
1777
+ reject("the image is larger than 10 MB");
1683
1778
  return;
1684
1779
  }
1685
1780
  fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -1864,7 +1959,7 @@ var HubServer = class {
1864
1959
  logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1865
1960
  return;
1866
1961
  }
1867
- this.codexSupervisor = new CodexSupervisor(script);
1962
+ this.codexSupervisor = new CodexSupervisor(script, { env: adapterEnv(process.env, { host: this.host, port: this.port }) });
1868
1963
  this.codexSupervisor.start();
1869
1964
  logger.info("Codex adapter started");
1870
1965
  }