@wrongstack/telegram 0.309.1 → 0.310.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.js CHANGED
@@ -262,74 +262,253 @@ var TelegramApiClient = class {
262
262
  }
263
263
  };
264
264
 
265
- // src/bot.ts
266
- var TelegramBot = class _TelegramBot {
267
- api;
268
- pollIntervalMs;
269
- allowedUsers;
270
- allowedChats;
265
+ // src/approval-flow.ts
266
+ var ApprovalFlow = class {
267
+ callbackWaiters = /* @__PURE__ */ new Map();
271
268
  log;
272
- onMessage;
273
- controller = new AbortController();
274
- pollTimer = null;
275
- pollActive = false;
276
- offset = 0;
269
+ api;
270
+ inboundDenialReason;
271
+ constructor(deps) {
272
+ this.log = deps.log;
273
+ this.api = deps.api;
274
+ this.inboundDenialReason = deps.inboundDenialReason;
275
+ }
276
+ settleApproval(requestId, state, result) {
277
+ const request = this.callbackWaiters.get(requestId);
278
+ if (request?.state !== "pending") return false;
279
+ request.state = state;
280
+ clearTimeout(request.timer);
281
+ if (request.signal && request.abortHandler) request.signal.removeEventListener("abort", request.abortHandler);
282
+ const leftover = request.pendingCallbacks.splice(0);
283
+ for (const cq of leftover) {
284
+ const notice = state === "expired" ? "Approval request expired" : state === "cancelled" ? "Approval request cancelled" : "Approval request settled";
285
+ void this.answerCallback(cq.id, notice, true);
286
+ }
287
+ this.callbackWaiters.delete(requestId);
288
+ request.resolve(result);
289
+ return true;
290
+ }
291
+ async dispatchCallback(cq) {
292
+ const key = cq.data ?? "";
293
+ const action = /^approve:([^:]+):(yes|no)$/.exec(key);
294
+ const requestId = action?.[1];
295
+ const request = requestId ? this.callbackWaiters.get(requestId) : void 0;
296
+ const userId = cq.from?.id !== void 0 ? String(cq.from.id) : void 0;
297
+ const chatId = cq.message?.chat.id !== void 0 ? String(cq.message.chat.id) : void 0;
298
+ const denialReason = this.inboundDenialReason(userId, chatId);
299
+ if (denialReason) {
300
+ const identity = denialReason === "user" ? userId ?? "unknown" : chatId ?? "unknown";
301
+ this.log.warn(`Ignoring callback_query from non-allowlisted ${denialReason} ${identity} (data="${key}") \u2014 possible hijack attempt.`);
302
+ await this.answerCallback(cq.id, "\u26D4 Not authorized", true);
303
+ return;
304
+ }
305
+ if (!request || !requestId || !action) {
306
+ await this.answerCallback(cq.id, "Approval request unavailable", true);
307
+ this.log.debug(`Unmatched callback_query data="${key}" (no pending approval request)`);
308
+ return;
309
+ }
310
+ if (Date.now() >= request.expiresAt) {
311
+ await this.answerCallback(cq.id, "Approval request expired", true);
312
+ this.settleApproval(requestId, "expired", { approved: false, fromUser: "timeout" });
313
+ return;
314
+ }
315
+ if (request.promptMessageId === void 0) {
316
+ request.pendingCallbacks.push(cq);
317
+ return;
318
+ }
319
+ const messageId = cq.message?.message_id;
320
+ const chatType = cq.message?.chat.type;
321
+ const wrongIdentity = userId === void 0 || chatId !== request.expectedChatId || !request.expectedUserIds.has(userId) || messageId !== request.promptMessageId || chatType !== "private" && !request.allowGroup;
322
+ if (wrongIdentity) {
323
+ this.log.warn(`Ignoring callback_query that does not match approval request ${request.requestId} in session ${request.sessionId}.`);
324
+ await this.answerCallback(cq.id, "\u26D4 Not authorized for this approval", true);
325
+ return;
326
+ }
327
+ const approved = action[2] === "yes";
328
+ const fromUser = cq.from?.username ?? cq.from?.first_name ?? `user:${userId}`;
329
+ const resolved = this.settleApproval(requestId, "resolved", { approved, fromUser, fromUserId: cq.from?.id });
330
+ await this.answerCallback(cq.id, resolved ? approved ? "Approved \u2713" : "Denied \u2717" : "Approval request unavailable", !resolved);
331
+ }
332
+ async answerCallback(callbackQueryId, text, showAlert) {
333
+ try {
334
+ await this.api().answerCallbackQuery(callbackQueryId, text, showAlert, { signal: AbortSignal.timeout(5e3) });
335
+ } catch (err) {
336
+ this.log.debug(`answerCallbackQuery failed: ${err.message}`);
337
+ }
338
+ }
339
+ awaitApproval(input) {
340
+ if (input.expectedUserIds.length === 0) throw new Error("Telegram approval requires at least one expected user ID.");
341
+ if (this.callbackWaiters.has(input.requestId)) throw new Error(`Telegram approval request ${input.requestId} is already pending.`);
342
+ return new Promise((resolve) => {
343
+ const delayMs = Math.max(0, input.expiresAt - Date.now());
344
+ const timer = setTimeout(() => {
345
+ this.settleApproval(input.requestId, "expired", { approved: false, fromUser: "timeout" });
346
+ }, delayMs);
347
+ const request = { requestId: input.requestId, sessionId: input.sessionId, expectedChatId: String(input.expectedChatId), expectedUserIds: new Set(input.expectedUserIds.map(String)), allowGroup: input.allowGroup, pendingCallbacks: [], expiresAt: input.expiresAt, state: "pending", resolve, timer, signal: input.signal };
348
+ if (input.signal) request.abortHandler = () => {
349
+ this.settleApproval(input.requestId, "cancelled", { approved: false, fromUser: "aborted" });
350
+ };
351
+ this.callbackWaiters.set(input.requestId, request);
352
+ if (input.signal?.aborted) request.abortHandler?.();
353
+ else if (input.signal && request.abortHandler) input.signal.addEventListener("abort", request.abortHandler, { once: true });
354
+ });
355
+ }
356
+ bindApprovalPrompt(requestId, promptMessageId) {
357
+ const request = this.callbackWaiters.get(requestId);
358
+ if (request?.state !== "pending" || request.promptMessageId !== void 0) return false;
359
+ request.promptMessageId = promptMessageId;
360
+ const pending = request.pendingCallbacks.splice(0);
361
+ for (const callback of pending) void this.dispatchCallback(callback).catch((err) => this.log.debug(`Callback dispatch failed: ${err instanceof Error ? err.message : String(err)}`));
362
+ return true;
363
+ }
364
+ cancelApproval(requestId, fromUser = "cancelled") {
365
+ return this.settleApproval(requestId, "cancelled", { approved: false, fromUser });
366
+ }
367
+ cancelAll(fromUser) {
368
+ for (const requestId of Array.from(this.callbackWaiters.keys())) this.settleApproval(requestId, "cancelled", { approved: false, fromUser });
369
+ }
370
+ };
371
+
372
+ // src/inbox.ts
373
+ var TelegramInbox = class {
374
+ deps;
375
+ // Circular buffer for incoming messages. Public readonly so the white-box
376
+ // suites can seed it directly through `bot.inbox.buffer` without
377
+ // type-asserting past module boundaries.
378
+ buffer = [];
379
+ constructor(deps) {
380
+ this.deps = deps;
381
+ }
382
+ /** Return buffered messages, newest first. Optionally filter by chat. */
383
+ getMessages(opts) {
384
+ let msgs = [...this.buffer].reverse();
385
+ if (opts?.chatId) {
386
+ const cid = String(opts.chatId);
387
+ msgs = msgs.filter((m) => String(m.chatId) === cid);
388
+ }
389
+ const limit = opts?.limit ?? 20;
390
+ return msgs.slice(0, limit);
391
+ }
392
+ /** Drop messages older than or equal to the given message ID from the buffer (optionally scoped to a specific chat). */
393
+ acknowledge(lastMessageId, chatId) {
394
+ const before = this.buffer.length;
395
+ const cid = chatId !== void 0 && chatId !== null && String(chatId).trim() !== "" ? String(chatId).trim() : void 0;
396
+ const remaining = [];
397
+ for (const buffered of this.buffer) {
398
+ if (cid !== void 0) {
399
+ if (String(buffered.chatId) === cid && buffered.messageId <= lastMessageId) {
400
+ continue;
401
+ }
402
+ } else if (buffered.messageId <= lastMessageId) {
403
+ continue;
404
+ }
405
+ remaining.push(buffered);
406
+ }
407
+ this.buffer.length = 0;
408
+ this.buffer.push(...remaining);
409
+ return before - this.buffer.length;
410
+ }
411
+ get bufferCount() {
412
+ return this.buffer.length;
413
+ }
277
414
  /**
278
- * Consecutive HTTP 409 ("another getUpdates in flight") responses. Two
279
- * wstack instances polling the same bot token used to fight at full poll
280
- * speed forever, erroring on every cycle. After CONFLICT_BACKOFF_AFTER
281
- * consecutive conflicts this instance backs off to a slow poll and warns
282
- * once; any successful poll resets to the normal cadence.
415
+ * Apply the inbound identity policy to every update type. A non-empty set is
416
+ * a mandatory constraint: missing identity fails closed instead of bypassing
417
+ * the allowlist. An empty set leaves that identity dimension unrestricted.
418
+ * Public because ApprovalFlow consumes the same gate via the bot's wiring.
283
419
  */
284
- conflictStreak = 0;
285
- static CONFLICT_BACKOFF_AFTER = 3;
286
- static CONFLICT_POLL_MS = 6e4;
287
- _startedAt = null;
288
- /** Typed offset store for atomic polling-cursor persistence. */
420
+ denialReason(userId, chatId) {
421
+ if (this.deps.allowedChats.size > 0 && (chatId === void 0 || !this.deps.allowedChats.has(chatId))) {
422
+ return "chat";
423
+ }
424
+ if (this.deps.allowedUsers.size > 0 && (userId === void 0 || !this.deps.allowedUsers.has(userId))) {
425
+ return "user";
426
+ }
427
+ return void 0;
428
+ }
429
+ processMessage(msg) {
430
+ const chatId = String(msg.chat.id);
431
+ const userId = msg.from ? String(msg.from.id) : void 0;
432
+ const denialReason = this.denialReason(userId, chatId);
433
+ if (denialReason === "user") {
434
+ this.deps.log.debug(`Ignoring message from user ${userId ?? "unknown"} (not in allowedUsers)`);
435
+ void this.deps.sendNotice(chatId, "\u26D4 You are not authorized to interact with this bot.").catch(
436
+ (err) => this.deps.log.debug(
437
+ `Failed to send denial notice: ${err instanceof Error ? err.message : String(err)}`
438
+ )
439
+ );
440
+ return;
441
+ }
442
+ if (denialReason === "chat") {
443
+ this.deps.log.debug(`Ignoring message from chat ${chatId} (not in allowedChats)`);
444
+ return;
445
+ }
446
+ const incoming = {
447
+ messageId: msg.message_id,
448
+ chatId: msg.chat.id,
449
+ chatType: msg.chat.type,
450
+ userId: msg.from?.id,
451
+ userName: msg.from?.username ?? msg.from?.first_name,
452
+ text: msg.text,
453
+ timestamp: msg.date * 1e3
454
+ };
455
+ this.buffer.push(incoming);
456
+ while (this.buffer.length > this.deps.bufferMax) this.buffer.shift();
457
+ this.deps.onMessage(incoming);
458
+ }
459
+ };
460
+
461
+ // src/poller.ts
462
+ var Poller = class _Poller {
463
+ api;
464
+ pollIntervalMs;
465
+ log;
466
+ controller;
289
467
  offsetStore;
290
- /** Single-poller election across wstack instances sharing this token. */
291
468
  lock;
292
469
  standbyRetryMs;
293
- getParseMode;
470
+ pollTimer = null;
294
471
  standbyTimer = null;
295
472
  standbyAnnounced = false;
296
- // Circular buffer for incoming messages
297
- bufferMax;
298
- buffer = [];
299
- // Pending approval requests keyed by request identity, not raw callback
300
- // data. Each request binds both yes/no actions to its originating session,
301
- // target chat, intended users, prompt message, and expiry.
302
- callbackWaiters = /* @__PURE__ */ new Map();
303
- constructor(opts) {
304
- this.api = new TelegramApiClient({ token: opts.token });
305
- this.pollIntervalMs = opts.pollIntervalSec * 1e3;
306
- this.allowedUsers = opts.allowedUsers;
307
- this.allowedChats = opts.allowedChats;
308
- this.bufferMax = opts.bufferSize;
309
- this.log = opts.log;
310
- this.onMessage = opts.onMessage;
311
- this.offsetStore = opts.offsetStore;
312
- this.lock = opts.lock;
313
- this.standbyRetryMs = opts.standbyRetryMs ?? 15e3;
314
- this.getParseMode = opts.getParseMode;
315
- if (this.lock) {
316
- this.lock.onLost = () => this.handleLockLost();
317
- }
318
- if (this.offsetStore) {
319
- void this.loadOffset();
320
- }
473
+ pollActive = false;
474
+ _startedAt = null;
475
+ offset = 0;
476
+ _conflictStreak = 0;
477
+ static CONFLICT_BACKOFF_AFTER = 3;
478
+ static CONFLICT_POLL_MS = 6e4;
479
+ onCallbackQuery;
480
+ onMessageUpdate;
481
+ constructor(deps) {
482
+ this.api = deps.api;
483
+ this.pollIntervalMs = deps.pollIntervalMs;
484
+ this.log = deps.log;
485
+ this.controller = deps.controller;
486
+ this.offsetStore = deps.offsetStore;
487
+ this.lock = deps.lock;
488
+ this.standbyRetryMs = deps.standbyRetryMs;
489
+ this.onCallbackQuery = deps.onCallbackQuery;
490
+ this.onMessageUpdate = deps.onMessageUpdate;
491
+ if (this.lock) this.lock.onLost = () => this.handleLockLost();
492
+ if (this.offsetStore) void this.loadOffset();
493
+ }
494
+ get active() {
495
+ return this.pollActive;
496
+ }
497
+ get startedAt() {
498
+ return this._startedAt;
499
+ }
500
+ get standby() {
501
+ return this.pollActive && this.lock !== void 0 && !this.lock.held;
502
+ }
503
+ get conflictStreak() {
504
+ return this._conflictStreak;
321
505
  }
322
- // ------------------------------------------------------------------
323
- // Lifecycle
324
- // ------------------------------------------------------------------
325
- /** Start polling for updates. Idempotent. */
326
506
  start() {
327
507
  if (this.pollActive) return;
328
508
  this.pollActive = true;
329
509
  this._startedAt = Date.now();
330
510
  this.acquireAndPoll();
331
511
  }
332
- /** Stop polling and cancel all in-flight requests. */
333
512
  stop() {
334
513
  this.pollActive = false;
335
514
  this.controller.abort();
@@ -341,31 +520,13 @@ var TelegramBot = class _TelegramBot {
341
520
  clearTimeout(this.standbyTimer);
342
521
  this.standbyTimer = null;
343
522
  }
344
- for (const requestId of Array.from(this.callbackWaiters.keys())) {
345
- this.settleApproval(requestId, "cancelled", {
346
- approved: false,
347
- fromUser: "shutdown"
348
- });
349
- }
350
- this.lock?.release();
351
- this.log.info("Telegram bot stopped");
352
- }
353
- /** True when the bot is started but waiting for the poll lock. */
354
- get standby() {
355
- return this.pollActive && this.lock !== void 0 && !this.lock.held;
356
523
  }
357
- /**
358
- * Acquire the poll lock (when configured) and start the poll loop, or
359
- * stand by and retry until the current holder releases it.
360
- */
361
524
  acquireAndPoll() {
362
525
  if (!this.pollActive) return;
363
526
  if (this.lock && !this.lock.tryAcquire()) {
364
527
  if (!this.standbyAnnounced) {
365
528
  this.standbyAnnounced = true;
366
- this.log.info(
367
- "Telegram: another wstack instance is already polling this bot token \u2014 standing by; will take over when it stops."
368
- );
529
+ this.log.info("Telegram: another wstack instance is already polling this bot token \u2014 standing by; will take over when it stops.");
369
530
  }
370
531
  this.standbyTimer = setTimeout(() => this.acquireAndPoll(), this.standbyRetryMs);
371
532
  this.standbyTimer.unref?.();
@@ -374,78 +535,100 @@ var TelegramBot = class _TelegramBot {
374
535
  if (this.standbyAnnounced) {
375
536
  this.standbyAnnounced = false;
376
537
  this.log.info("Telegram: poll lock acquired \u2014 taking over polling.");
377
- } else {
378
- this.log.info(`Telegram bot polling started (${this.api.safeBaseUrl})`);
379
- }
538
+ } else this.log.info(`Telegram bot polling started (${this.api().safeBaseUrl})`);
380
539
  this.schedulePoll();
381
540
  }
382
- /** The lock was stolen while we held it — pause polling and stand by. */
383
541
  handleLockLost() {
384
542
  if (!this.pollActive) return;
385
543
  if (this.pollTimer) {
386
544
  clearTimeout(this.pollTimer);
387
545
  this.pollTimer = null;
388
546
  }
389
- this.log.warn(
390
- "Telegram: poll lock lost to another instance \u2014 pausing polling and standing by."
391
- );
547
+ this.log.warn("Telegram: poll lock lost to another instance \u2014 pausing polling and standing by.");
392
548
  this.standbyAnnounced = true;
393
549
  this.standbyTimer = setTimeout(() => this.acquireAndPoll(), this.standbyRetryMs);
394
550
  this.standbyTimer.unref?.();
395
551
  }
396
- get startedAt() {
397
- return this._startedAt;
398
- }
399
- get running() {
400
- return this.pollActive;
401
- }
402
- // ------------------------------------------------------------------
403
- // Buffer — incoming messages the agent can read
404
- // ------------------------------------------------------------------
405
- /** Return buffered messages, newest first. Optionally filter by chat. */
406
- getMessages(opts) {
407
- let msgs = [...this.buffer].reverse();
408
- if (opts?.chatId) {
409
- const cid = String(opts.chatId);
410
- msgs = msgs.filter((m) => String(m.chatId) === cid);
411
- }
412
- const limit = opts?.limit ?? 20;
413
- return msgs.slice(0, limit);
552
+ schedulePoll() {
553
+ if (!this.pollActive) return;
554
+ if (this.lock && !this.lock.held) return;
555
+ const delay = this._conflictStreak >= _Poller.CONFLICT_BACKOFF_AFTER ? _Poller.CONFLICT_POLL_MS : this.pollIntervalMs;
556
+ this.pollTimer = setTimeout(() => {
557
+ void this.poll().finally(() => this.schedulePoll());
558
+ }, delay);
414
559
  }
415
- /** Drop messages older than or equal to the given message ID from the buffer (optionally scoped to a specific chat). */
416
- acknowledge(lastMessageId, chatId) {
417
- const before = this.buffer.length;
418
- const cid = chatId !== void 0 && chatId !== null && String(chatId).trim() !== "" ? String(chatId).trim() : void 0;
419
- const remaining = [];
420
- for (const buffered of this.buffer) {
421
- if (cid !== void 0) {
422
- if (String(buffered.chatId) === cid && buffered.messageId <= lastMessageId) {
560
+ async poll() {
561
+ try {
562
+ const updates = await this.api().getUpdates({ offset: this.offset, timeoutSeconds: 10, deadlineMs: 15e3, signal: this.controller.signal });
563
+ this._conflictStreak = 0;
564
+ for (const upd of updates) {
565
+ if (upd.update_id < this.offset) continue;
566
+ if (upd.callback_query) {
567
+ this.onCallbackQuery(upd.callback_query);
568
+ this.offset = upd.update_id + 1;
423
569
  continue;
424
570
  }
425
- } else if (buffered.messageId <= lastMessageId) {
426
- continue;
571
+ const raw = upd.message ?? upd.edited_message;
572
+ if (!raw?.text) {
573
+ this.offset = upd.update_id + 1;
574
+ continue;
575
+ }
576
+ try {
577
+ this.onMessageUpdate(raw);
578
+ this.offset = upd.update_id + 1;
579
+ } catch (err) {
580
+ this.log.debug(`Telegram processMessage failed: ${err instanceof Error ? err.message : String(err)}`);
581
+ break;
582
+ }
583
+ }
584
+ if (this.offsetStore && updates.length > 0) void this.saveOffset();
585
+ } catch (err) {
586
+ if (err instanceof TelegramNetworkError && err.aborted) return;
587
+ if (err instanceof TelegramBotApiError && err.errorCode === 409) {
588
+ this._conflictStreak++;
589
+ if (this._conflictStreak === _Poller.CONFLICT_BACKOFF_AFTER) this.log.warn(this.lock ? "Telegram: another consumer outside this machine is polling this bot token (HTTP 409) \u2014 backing off to 60s polls. Check other machines/bots using this token, or a registered webhook (deleteWebhook)." : "Telegram: another instance is polling this bot token (HTTP 409) \u2014 backing off to 60s polls until it stops.");
590
+ this.log.debug(`Telegram getUpdates failed: ${err.description}`);
591
+ return;
592
+ }
593
+ this.log.debug(`Telegram poll error: ${err.message}`);
594
+ }
595
+ }
596
+ async loadOffset() {
597
+ if (!this.offsetStore) return;
598
+ try {
599
+ const saved = this.offsetStore.read();
600
+ if (saved !== null) {
601
+ this.offset = saved;
602
+ this.log.debug(`Telegram polling offset restored: ${this.offset}`);
427
603
  }
428
- remaining.push(buffered);
604
+ } catch {
429
605
  }
430
- this.buffer.length = 0;
431
- this.buffer.push(...remaining);
432
- return before - this.buffer.length;
433
606
  }
434
- get bufferCount() {
435
- return this.buffer.length;
607
+ async saveOffset() {
608
+ if (!this.offsetStore) return;
609
+ try {
610
+ this.offsetStore.write(this.offset);
611
+ } catch (err) {
612
+ this.log.debug(`Failed to persist Telegram offset: ${err}`);
613
+ }
614
+ }
615
+ };
616
+
617
+ // src/outbox.ts
618
+ var TelegramOutbox = class {
619
+ deps;
620
+ constructor(deps) {
621
+ this.deps = deps;
436
622
  }
437
- // ------------------------------------------------------------------
438
- // Outgoing — send a message
439
- // ------------------------------------------------------------------
440
623
  async sendMessage(chatId, text, signal) {
441
- this.log.debug(`Sending Telegram message to ${chatId} (${text.length} chars)`);
624
+ this.deps.log.debug(`Sending Telegram message to ${chatId} (${text.length} chars)`);
442
625
  let lastErr;
443
626
  for (let attempt = 1; attempt <= 3; attempt++) {
444
627
  try {
445
628
  const timeout = AbortSignal.timeout(1e4);
446
- const result = await this.api.sendMessage(chatId, text, {
629
+ const result = await this.deps.api().sendMessage(chatId, text, {
447
630
  signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
448
- parseMode: this.getParseMode?.()
631
+ parseMode: this.deps.getParseMode?.()
449
632
  });
450
633
  return { ok: true, result };
451
634
  } catch (err) {
@@ -453,12 +636,12 @@ var TelegramBot = class _TelegramBot {
453
636
  const decision = classifyRetry(err, attempt);
454
637
  if (!decision.retry) {
455
638
  if (attempt > 1)
456
- this.log.debug(
639
+ this.deps.log.debug(
457
640
  `Telegram sendMessage terminal error on attempt ${attempt}, not retrying`
458
641
  );
459
642
  break;
460
643
  }
461
- this.log.debug(
644
+ this.deps.log.debug(
462
645
  `Telegram sendMessage attempt ${attempt} failed, retrying in ${decision.delayMs}ms...`
463
646
  );
464
647
  await abortableSleep(decision.delayMs, signal);
@@ -466,13 +649,10 @@ var TelegramBot = class _TelegramBot {
466
649
  }
467
650
  throw lastErr;
468
651
  }
469
- // ------------------------------------------------------------------
470
- // Outgoing — send a message with an inline keyboard
471
- // ------------------------------------------------------------------
472
652
  /**
473
653
  * Send a message that has up to one row of inline buttons (Telegram's
474
654
  * `inline_keyboard`). Used by `telegram_approve` to present a
475
- * yes/no prompt. The keyboard payload is opaque to the bot — callers
655
+ * yes/no prompt. The keyboard payload is opaque to the outbox — callers
476
656
  * pass already-encoded `callback_data` strings (≤ 64 bytes each).
477
657
  */
478
658
  async sendMessageWithKeyboard(chatId, text, buttons, signal) {
@@ -480,9 +660,9 @@ var TelegramBot = class _TelegramBot {
480
660
  for (let attempt = 1; attempt <= 3; attempt++) {
481
661
  try {
482
662
  const timeout = AbortSignal.timeout(1e4);
483
- const result = await this.api.sendMessageWithKeyboard(chatId, text, buttons, {
663
+ const result = await this.deps.api().sendMessageWithKeyboard(chatId, text, buttons, {
484
664
  signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
485
- parseMode: this.getParseMode?.()
665
+ parseMode: this.deps.getParseMode?.()
486
666
  });
487
667
  return { ok: true, result };
488
668
  } catch (err) {
@@ -490,7 +670,7 @@ var TelegramBot = class _TelegramBot {
490
670
  const decision = classifyRetry(err, attempt);
491
671
  if (!decision.retry) {
492
672
  if (attempt > 1)
493
- this.log.debug(
673
+ this.deps.log.debug(
494
674
  `Telegram sendMessageWithKeyboard terminal error on attempt ${attempt}, not retrying`
495
675
  );
496
676
  break;
@@ -500,9 +680,6 @@ var TelegramBot = class _TelegramBot {
500
680
  }
501
681
  throw lastErr;
502
682
  }
503
- // ------------------------------------------------------------------
504
- // Health
505
- // ------------------------------------------------------------------
506
683
  async health(signal) {
507
684
  const ctrl = new AbortController();
508
685
  const timer = setTimeout(() => ctrl.abort(), 5e3);
@@ -510,7 +687,7 @@ var TelegramBot = class _TelegramBot {
510
687
  const timeout = AbortSignal.timeout(5e3);
511
688
  const deadline = AbortSignal.any([ctrl.signal, timeout]);
512
689
  const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
513
- const user = await this.api.getMe({ signal: combined });
690
+ const user = await this.deps.api().getMe({ signal: combined });
514
691
  return { ok: true, username: user.username };
515
692
  } catch (err) {
516
693
  if (err instanceof TelegramBotApiError) return { ok: false, error: err.description };
@@ -520,318 +697,146 @@ var TelegramBot = class _TelegramBot {
520
697
  clearTimeout(timer);
521
698
  }
522
699
  }
523
- // ------------------------------------------------------------------
524
- // Polling
525
- // ------------------------------------------------------------------
526
- schedulePoll() {
527
- if (!this.pollActive) return;
528
- if (this.lock && !this.lock.held) return;
529
- const delay = this.conflictStreak >= _TelegramBot.CONFLICT_BACKOFF_AFTER ? _TelegramBot.CONFLICT_POLL_MS : this.pollIntervalMs;
530
- this.pollTimer = setTimeout(() => {
531
- void this.poll().finally(() => this.schedulePoll());
532
- }, delay);
533
- }
534
- async poll() {
535
- try {
536
- const updates = await this.api.getUpdates({
537
- offset: this.offset,
538
- timeoutSeconds: 10,
539
- deadlineMs: 15e3,
540
- signal: this.controller.signal
541
- });
542
- this.conflictStreak = 0;
543
- for (const upd of updates) {
544
- if (upd.update_id < this.offset) continue;
545
- this.offset = upd.update_id + 1;
546
- if (upd.callback_query) {
547
- void this.dispatchCallback(upd.callback_query).catch(
548
- (err) => this.log.debug(
549
- `Callback dispatch failed: ${err instanceof Error ? err.message : String(err)}`
550
- )
551
- );
552
- continue;
553
- }
554
- const raw = upd.message ?? upd.edited_message;
555
- if (!raw?.text) continue;
556
- this.processMessage({ ...raw, text: raw.text });
557
- }
558
- if (this.offsetStore && updates.length > 0) void this.saveOffset();
559
- } catch (err) {
560
- if (err instanceof TelegramNetworkError && err.aborted) return;
561
- if (err instanceof TelegramBotApiError && err.errorCode === 409) {
562
- this.conflictStreak++;
563
- if (this.conflictStreak === _TelegramBot.CONFLICT_BACKOFF_AFTER) {
564
- this.log.warn(
565
- this.lock ? "Telegram: another consumer outside this machine is polling this bot token (HTTP 409) \u2014 backing off to 60s polls. Check other machines/bots using this token, or a registered webhook (deleteWebhook)." : "Telegram: another instance is polling this bot token (HTTP 409) \u2014 backing off to 60s polls until it stops."
566
- );
567
- }
568
- this.log.debug(`Telegram getUpdates failed: ${err.description}`);
569
- return;
570
- }
571
- this.log.debug(`Telegram poll error: ${err.message}`);
572
- }
573
- }
574
- /**
575
- * Apply the inbound identity policy to every update type. A non-empty set is
576
- * a mandatory constraint: missing identity fails closed instead of bypassing
577
- * the allowlist. An empty set leaves that identity dimension unrestricted.
578
- */
579
- inboundDenialReason(userId, chatId) {
580
- if (this.allowedChats.size > 0 && (chatId === void 0 || !this.allowedChats.has(chatId))) {
581
- return "chat";
582
- }
583
- if (this.allowedUsers.size > 0 && (userId === void 0 || !this.allowedUsers.has(userId))) {
584
- return "user";
585
- }
586
- return void 0;
587
- }
588
- processMessage(msg) {
589
- const chatId = String(msg.chat.id);
590
- const userId = msg.from ? String(msg.from.id) : void 0;
591
- const denialReason = this.inboundDenialReason(userId, chatId);
592
- if (denialReason === "user") {
593
- this.log.debug(`Ignoring message from user ${userId ?? "unknown"} (not in allowedUsers)`);
594
- void this.sendMessage(chatId, "\u26D4 You are not authorized to interact with this bot.").catch(
595
- (err) => this.log.debug(
596
- `Failed to send denial notice: ${err instanceof Error ? err.message : String(err)}`
597
- )
598
- );
599
- return;
600
- }
601
- if (denialReason === "chat") {
602
- this.log.debug(`Ignoring message from chat ${chatId} (not in allowedChats)`);
603
- return;
604
- }
605
- const incoming = {
606
- messageId: msg.message_id,
607
- chatId: msg.chat.id,
608
- chatType: msg.chat.type,
609
- userId: msg.from?.id,
610
- userName: msg.from?.username ?? msg.from?.first_name,
611
- text: msg.text,
612
- timestamp: msg.date * 1e3
613
- };
614
- this.buffer.push(incoming);
615
- while (this.buffer.length > this.bufferMax) this.buffer.shift();
616
- this.onMessage(incoming);
617
- }
618
- /**
619
- * Resolve a pending approval request exactly once and record its terminal
620
- * state before removing it from the live registry.
621
- */
622
- settleApproval(requestId, state, result) {
623
- const request = this.callbackWaiters.get(requestId);
624
- if (request?.state !== "pending") return false;
625
- request.state = state;
626
- clearTimeout(request.timer);
627
- if (request.signal && request.abortHandler) {
628
- request.signal.removeEventListener("abort", request.abortHandler);
629
- }
630
- const leftoverCallbacks = request.pendingCallbacks.splice(0);
631
- for (const cq of leftoverCallbacks) {
632
- const notice = state === "expired" ? "Approval request expired" : state === "cancelled" ? "Approval request cancelled" : "Approval request settled";
633
- void this.answerCallback(cq.id, notice, true);
634
- }
635
- this.callbackWaiters.delete(requestId);
636
- request.resolve(result);
637
- return true;
638
- }
639
- async dispatchCallback(cq) {
640
- const key = cq.data ?? "";
641
- const action = /^approve:([^:]+):(yes|no)$/.exec(key);
642
- const requestId = action?.[1];
643
- const request = requestId ? this.callbackWaiters.get(requestId) : void 0;
644
- const userId = cq.from?.id !== void 0 ? String(cq.from.id) : void 0;
645
- const chatId = cq.message?.chat.id !== void 0 ? String(cq.message.chat.id) : void 0;
646
- const denialReason = this.inboundDenialReason(userId, chatId);
647
- if (denialReason) {
648
- const identity = denialReason === "user" ? userId ?? "unknown" : chatId ?? "unknown";
649
- this.log.warn(
650
- `Ignoring callback_query from non-allowlisted ${denialReason} ${identity} (data="${key}") \u2014 possible hijack attempt.`
651
- );
652
- await this.answerCallback(cq.id, "\u26D4 Not authorized", true);
653
- return;
654
- }
655
- if (!request || !requestId || !action) {
656
- await this.answerCallback(cq.id, "Approval request unavailable", true);
657
- this.log.debug(`Unmatched callback_query data="${key}" (no pending approval request)`);
658
- return;
659
- }
660
- if (Date.now() >= request.expiresAt) {
661
- await this.answerCallback(cq.id, "Approval request expired", true);
662
- this.settleApproval(requestId, "expired", { approved: false, fromUser: "timeout" });
663
- return;
664
- }
665
- if (request.promptMessageId === void 0) {
666
- request.pendingCallbacks.push(cq);
667
- return;
668
- }
669
- const messageId = cq.message?.message_id;
670
- const chatType = cq.message?.chat.type;
671
- const wrongIdentity = userId === void 0 || chatId !== request.expectedChatId || !request.expectedUserIds.has(userId) || messageId !== request.promptMessageId || chatType !== "private" && !request.allowGroup;
672
- if (wrongIdentity) {
673
- this.log.warn(
674
- `Ignoring callback_query that does not match approval request ${request.requestId} in session ${request.sessionId}.`
675
- );
676
- await this.answerCallback(cq.id, "\u26D4 Not authorized for this approval", true);
677
- return;
678
- }
679
- const approved = action[2] === "yes";
680
- const fromUser = cq.from?.username ?? cq.from?.first_name ?? `user:${userId}`;
681
- const resolved = this.settleApproval(requestId, "resolved", {
682
- approved,
683
- fromUser,
684
- fromUserId: cq.from?.id
685
- });
686
- await this.answerCallback(
687
- cq.id,
688
- resolved ? approved ? "Approved \u2713" : "Denied \u2717" : "Approval request unavailable",
689
- !resolved
690
- );
691
- }
692
- /**
693
- * POST /answerCallbackQuery for a callback. Best-effort: failures are
694
- * logged at debug and swallowed — the caller's resolve() must not depend
695
- * on the ack reaching Telegram (the user may get a "loading" spinner if
696
- * it fails, but the agent's approval flow continues normally).
697
- */
698
- async answerCallback(callbackQueryId, text, showAlert) {
699
- try {
700
- await this.api.answerCallbackQuery(callbackQueryId, text, showAlert, {
701
- signal: AbortSignal.timeout(5e3)
702
- });
703
- } catch (err) {
704
- this.log.debug(`answerCallbackQuery failed: ${err.message}`);
705
- }
706
- }
707
- /**
708
- * Register one approval request before its prompt is sent. The returned
709
- * promise owns the request's only timer and resolves on one terminal event.
710
- */
711
- awaitApproval(input) {
712
- if (input.expectedUserIds.length === 0) {
713
- throw new Error("Telegram approval requires at least one expected user ID.");
714
- }
715
- if (this.callbackWaiters.has(input.requestId)) {
716
- throw new Error(`Telegram approval request ${input.requestId} is already pending.`);
717
- }
718
- return new Promise((resolve) => {
719
- const delayMs = Math.max(0, input.expiresAt - Date.now());
720
- const timer = setTimeout(() => {
721
- this.settleApproval(input.requestId, "expired", {
722
- approved: false,
723
- fromUser: "timeout"
724
- });
725
- }, delayMs);
726
- const request = {
727
- requestId: input.requestId,
728
- sessionId: input.sessionId,
729
- expectedChatId: String(input.expectedChatId),
730
- expectedUserIds: new Set(input.expectedUserIds.map(String)),
731
- allowGroup: input.allowGroup,
732
- pendingCallbacks: [],
733
- expiresAt: input.expiresAt,
734
- state: "pending",
735
- resolve,
736
- timer,
737
- signal: input.signal
738
- };
739
- if (input.signal) {
740
- request.abortHandler = () => {
741
- this.settleApproval(input.requestId, "cancelled", {
742
- approved: false,
743
- fromUser: "aborted"
744
- });
745
- };
746
- }
747
- this.callbackWaiters.set(input.requestId, request);
748
- if (input.signal?.aborted) {
749
- request.abortHandler?.();
750
- } else if (input.signal && request.abortHandler) {
751
- input.signal.addEventListener("abort", request.abortHandler, { once: true });
752
- }
753
- });
754
- }
755
- /**
756
- * Attach the Bot API response's prompt message ID to an existing request.
757
- * Any callback that arrived during the send is replayed against the fully
758
- * bound identity without allocating a second waiter or timer.
759
- */
760
- bindApprovalPrompt(requestId, promptMessageId) {
761
- const request = this.callbackWaiters.get(requestId);
762
- if (request?.state !== "pending" || request.promptMessageId !== void 0) return false;
763
- request.promptMessageId = promptMessageId;
764
- const pending = request.pendingCallbacks.splice(0);
765
- for (const callback of pending) {
766
- void this.dispatchCallback(callback).catch(
767
- (err) => this.log.debug(
768
- `Callback dispatch failed: ${err instanceof Error ? err.message : String(err)}`
769
- )
770
- );
771
- }
772
- return true;
773
- }
774
- /** Cancel a request that cannot reach a valid terminal callback. */
775
- cancelApproval(requestId, fromUser = "cancelled") {
776
- return this.settleApproval(requestId, "cancelled", { approved: false, fromUser });
777
- }
778
- async loadOffset() {
779
- if (!this.offsetStore) return;
780
- try {
781
- const saved = this.offsetStore.read();
782
- if (saved !== null) {
783
- this.offset = saved;
784
- this.log.debug(`Telegram polling offset restored: ${this.offset}`);
785
- }
786
- } catch {
787
- }
788
- }
789
- async saveOffset() {
790
- if (!this.offsetStore) return;
791
- try {
792
- this.offsetStore.write(this.offset);
793
- } catch (err) {
794
- this.log.debug(`Failed to persist Telegram offset: ${err}`);
795
- }
796
- }
797
700
  };
701
+
702
+ // src/text-format.ts
798
703
  var MAX_TELEGRAM_MESSAGE_LENGTH = 4096;
799
704
  function truncateForTelegram(text, maxLen = 4e3) {
800
705
  const effectiveMaxLen = Math.min(maxLen, MAX_TELEGRAM_MESSAGE_LENGTH);
801
706
  if (text.length <= effectiveMaxLen) return text;
802
707
  const cutoff = effectiveMaxLen - 30;
803
708
  if (cutoff <= 0) return `${text.slice(0, effectiveMaxLen - 1)}\u2026`;
804
- const searchEnd = Math.min(text.length, effectiveMaxLen);
805
- const paraIdx = text.lastIndexOf("\n\n", searchEnd);
709
+ const paraSearchEnd = effectiveMaxLen - 3;
710
+ const paraIdx = text.lastIndexOf("\n\n", paraSearchEnd);
806
711
  if (paraIdx > cutoff) {
807
712
  return `${text.slice(0, paraIdx)}
808
713
 
809
714
  \u2026`;
810
715
  }
811
- const nlIdx = text.lastIndexOf("\n", searchEnd);
716
+ const nlSearchEnd = effectiveMaxLen - 2;
717
+ const nlIdx = text.lastIndexOf("\n", nlSearchEnd);
812
718
  if (nlIdx > cutoff) {
813
719
  return `${text.slice(0, nlIdx)}
814
720
  \u2026`;
815
721
  }
722
+ const sentenceSearchEnd = effectiveMaxLen - 1;
816
723
  const sentenceRe = /[.!?](?=\s)/g;
817
724
  let match;
818
725
  let sentenceIdx = -1;
819
726
  match = sentenceRe.exec(text);
820
727
  while (match !== null) {
821
- if (match.index >= searchEnd) break;
822
- if (match.index > cutoff) sentenceIdx = match.index + 1;
728
+ if (match.index + 1 > sentenceSearchEnd) break;
729
+ if (match.index + 1 > cutoff) sentenceIdx = match.index + 1;
823
730
  match = sentenceRe.exec(text);
824
731
  }
825
732
  if (sentenceIdx > cutoff) {
826
733
  return `${text.slice(0, sentenceIdx)}\u2026`;
827
734
  }
828
- const spaceIdx = text.lastIndexOf(" ", searchEnd);
735
+ const spaceSearchEnd = effectiveMaxLen - 2;
736
+ const spaceIdx = text.lastIndexOf(" ", spaceSearchEnd);
829
737
  if (spaceIdx > cutoff) {
830
738
  return `${text.slice(0, spaceIdx)} \u2026`;
831
739
  }
832
- return `${text.slice(0, effectiveMaxLen - 20)}\u2026[+${text.length - effectiveMaxLen + 20} chars]`;
740
+ const hardSlice = text.slice(0, effectiveMaxLen - 20);
741
+ const hardResult = `${hardSlice}\u2026[+${text.length - hardSlice.length} chars]`;
742
+ if (hardResult.length <= effectiveMaxLen) return hardResult;
743
+ return `${text.slice(0, effectiveMaxLen - 1)}\u2026`;
833
744
  }
834
745
 
746
+ // src/bot.ts
747
+ var TelegramBot = class {
748
+ api;
749
+ log;
750
+ lock;
751
+ approvals;
752
+ poller;
753
+ inbox;
754
+ outbox;
755
+ constructor(opts) {
756
+ this.api = new TelegramApiClient({ token: opts.token });
757
+ this.log = opts.log;
758
+ this.lock = opts.lock;
759
+ this.inbox = new TelegramInbox({
760
+ log: this.log,
761
+ allowedUsers: opts.allowedUsers,
762
+ allowedChats: opts.allowedChats,
763
+ bufferMax: opts.bufferSize,
764
+ onMessage: opts.onMessage,
765
+ sendNotice: (chatId, text) => this.sendMessage(chatId, text)
766
+ });
767
+ this.outbox = new TelegramOutbox({ log: this.log, api: () => this.api, getParseMode: opts.getParseMode });
768
+ this.approvals = new ApprovalFlow({
769
+ log: this.log,
770
+ api: () => this.api,
771
+ inboundDenialReason: (userId, chatId) => this.inbox.denialReason(userId, chatId)
772
+ });
773
+ this.poller = new Poller({
774
+ api: () => this.api,
775
+ pollIntervalMs: opts.pollIntervalSec * 1e3,
776
+ log: this.log,
777
+ controller: new AbortController(),
778
+ offsetStore: opts.offsetStore,
779
+ lock: opts.lock,
780
+ standbyRetryMs: opts.standbyRetryMs ?? 15e3,
781
+ onCallbackQuery: (cq) => {
782
+ void this.approvals.dispatchCallback(cq).catch((err) => this.log.debug(`Callback dispatch failed: ${err instanceof Error ? err.message : String(err)}`));
783
+ },
784
+ onMessageUpdate: (msg) => this.processMessage({ ...msg, text: msg.text })
785
+ });
786
+ }
787
+ start() {
788
+ this.poller.start();
789
+ }
790
+ stop() {
791
+ this.poller.stop();
792
+ this.approvals.cancelAll("shutdown");
793
+ this.lock?.release();
794
+ this.log.info("Telegram bot stopped");
795
+ }
796
+ get standby() {
797
+ return this.poller.standby;
798
+ }
799
+ get startedAt() {
800
+ return this.poller.startedAt;
801
+ }
802
+ get running() {
803
+ return this.poller.active;
804
+ }
805
+ getMessages(opts) {
806
+ return this.inbox.getMessages(opts);
807
+ }
808
+ acknowledge(lastMessageId, chatId) {
809
+ return this.inbox.acknowledge(lastMessageId, chatId);
810
+ }
811
+ get bufferCount() {
812
+ return this.inbox.bufferCount;
813
+ }
814
+ processMessage(msg) {
815
+ this.inbox.processMessage(msg);
816
+ }
817
+ sendMessage(chatId, text, signal) {
818
+ return this.outbox.sendMessage(chatId, text, signal);
819
+ }
820
+ sendMessageWithKeyboard(chatId, text, buttons, signal) {
821
+ return this.outbox.sendMessageWithKeyboard(chatId, text, buttons, signal);
822
+ }
823
+ async health(signal) {
824
+ return this.outbox.health(signal);
825
+ }
826
+ get callbackWaiters() {
827
+ return this.approvals.callbackWaiters;
828
+ }
829
+ awaitApproval(input) {
830
+ return this.approvals.awaitApproval(input);
831
+ }
832
+ bindApprovalPrompt(requestId, promptMessageId) {
833
+ return this.approvals.bindApprovalPrompt(requestId, promptMessageId);
834
+ }
835
+ cancelApproval(requestId, fromUser = "cancelled") {
836
+ return this.approvals.cancelApproval(requestId, fromUser);
837
+ }
838
+ };
839
+
835
840
  // src/config.ts
836
841
  import { resolvePluginConfig } from "@wrongstack/core/plugin";
837
842
  var PLUGIN_NAME = "telegram";
@@ -1157,7 +1162,8 @@ var PollLock = class {
1157
1162
  this.startHeartbeat();
1158
1163
  return true;
1159
1164
  } catch (err) {
1160
- if (err.code !== "EEXIST") {
1165
+ const code = err.code;
1166
+ if (code !== "EEXIST" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") {
1161
1167
  return false;
1162
1168
  }
1163
1169
  }
@@ -1214,7 +1220,7 @@ var PollLock = class {
1214
1220
  this.onLost?.();
1215
1221
  return;
1216
1222
  }
1217
- const tmp = `${this.lockPath}.${process.pid}.tmp`;
1223
+ const tmp = `${this.lockPath}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
1218
1224
  try {
1219
1225
  const payload = { ...current, heartbeatAt: Date.now() };
1220
1226
  writeFileSync(tmp, JSON.stringify(payload));
@@ -1245,7 +1251,7 @@ var PollLock = class {
1245
1251
  };
1246
1252
 
1247
1253
  // src/offset-store.ts
1248
- import { createHash as createHash2 } from "node:crypto";
1254
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
1249
1255
  import {
1250
1256
  closeSync,
1251
1257
  fsyncSync,
@@ -1307,7 +1313,7 @@ var OffsetStore = class {
1307
1313
  write(offset) {
1308
1314
  if (!this.path || offset < 0) return;
1309
1315
  mkdirSync2(dirname2(this.path), { recursive: true });
1310
- const tmp = `${this.path}.${process.pid}.tmp`;
1316
+ const tmp = `${this.path}.${process.pid}.${randomUUID2().slice(0, 8)}.tmp`;
1311
1317
  const fd = openSync(tmp, "w");
1312
1318
  try {
1313
1319
  writeSync(fd, JSON.stringify(offset));
@@ -1482,7 +1488,7 @@ and the \`telegram_send\` tool when no chat_id is specified.`,
1482
1488
  }
1483
1489
 
1484
1490
  // src/tools/telegram-approve.ts
1485
- import { randomUUID as randomUUID2 } from "node:crypto";
1491
+ import { randomUUID as randomUUID3 } from "node:crypto";
1486
1492
  function makeTelegramApproveTool(opts) {
1487
1493
  return {
1488
1494
  name: "telegram_approve",
@@ -1529,7 +1535,7 @@ function makeTelegramApproveTool(opts) {
1529
1535
  throw new Error("Telegram group approvals require explicit per-user configuration.");
1530
1536
  }
1531
1537
  const expectedUserIds = configuredUserIds.length > 0 ? configuredUserIds : [String(chatId)];
1532
- const requestId = randomUUID2().slice(0, 16);
1538
+ const requestId = randomUUID3().slice(0, 16);
1533
1539
  const yesKey = `approve:${requestId}:yes`;
1534
1540
  const noKey = `approve:${requestId}:no`;
1535
1541
  const prompt = scrubTelegramOutboundText(input.prompt);