pi-braid-telegram 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1248 @@
1
+ /**
2
+ * Inbound long-poll loop — the backward channel.
3
+ *
4
+ * Pulls updates via getUpdates with a persisted offset, filters to private
5
+ * 1:1 chats from the allow-list, and hands accepted owner text messages to an
6
+ * `onOwnerMessage` callback (wired to pi.sendUserMessage in index.ts — the
7
+ * pi-core equivalent of agent-zero's `context.communicate(UserMessage(...))`).
8
+ *
9
+ * Message acceptance (mirrors agent-zero helpers/handler.py::_is_allowed):
10
+ * - must be a `message` update (not edited/channel_post)
11
+ * - chat must be `private` (the bot receives all private 1:1 chats)
12
+ * - must carry non-empty `text` OR a non-empty `photo` array OR a usable
13
+ * `voice`/`document` object (photo/voice/document inbound — the optional
14
+ * `caption` is carried; accepted media messages are downloaded to the
15
+ * media dir and delivered with the absolute path)
16
+ * - `from.id` must be in the allow-list; when the allow-list is empty and
17
+ * `bootstrap` is on, the first valid private sender (text, photo, voice, or
18
+ * document) is auto-allowed (owner chat id env OR first-message bootstrap).
19
+ *
20
+ * Offset semantics — confirm-on-read (Telegram Bot API behavior):
21
+ * The persisted offset advances to `max-seen update_id + 1` once a batch has
22
+ * been read, so Telegram never redelivers the same batch. Telegram offsets are
23
+ * monotonic and in-order — they cannot skip a failed update without dropping
24
+ * it — so accepted updates whose injection FAILS are moved to an in-memory
25
+ * retry buffer and retried on subsequent cycles (up to `maxRetries`), never
26
+ * lost. Rejected/foreign updates are fully dropped (no retry) so a spammer
27
+ * cannot wedge the queue.
28
+ *
29
+ * The poller is decoupled from pi-core: it only calls `onOwnerMessage` /
30
+ * `onError` callbacks, so every behavioral rule is unit-testable against a
31
+ * fake fetch with no pi runtime and no network.
32
+ */
33
+
34
+ import { randomUUID } from "node:crypto";
35
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
36
+ import { dirname } from "node:path";
37
+ import { defaultStateFilePath, warnUnderivableRoot } from "./state-paths.js";
38
+ import {
39
+ getUpdates,
40
+ getMe,
41
+ sendMessage,
42
+ type TelegramFetch,
43
+ type TelegramUpdate,
44
+ type TelegramCallbackQuery,
45
+ } from "./telegram-client.js";
46
+ import type { PauseState } from "./pause-control.js";
47
+ import {
48
+ hasPhoto,
49
+ materializePhoto,
50
+ pickPhotoVariant,
51
+ formatPhotoText,
52
+ } from "./photo.js";
53
+ import {
54
+ hasVoice,
55
+ getVoiceFileId,
56
+ materializeVoice,
57
+ formatVoiceText,
58
+ type TelegramVoice,
59
+ } from "./voice.js";
60
+ import {
61
+ hasDocument,
62
+ materializeDocument,
63
+ formatDocumentText,
64
+ type TelegramDocument,
65
+ } from "./document.js";
66
+ import {
67
+ transcribeVoiceReliable,
68
+ isSttConfigured,
69
+ type SttConfig,
70
+ } from "./transcribe.js";
71
+
72
+ export interface IncomingOwnerMessage {
73
+ updateId: number;
74
+ messageId: number;
75
+ fromId: number;
76
+ username?: string;
77
+ firstName?: string;
78
+ chatId: number;
79
+ chatType: string;
80
+ text: string;
81
+ /** Present for photo messages (absent for plain-text). */
82
+ photo?: {
83
+ fileId: string;
84
+ /** Absolute path of the saved file (when the download succeeded). */
85
+ filePath?: string;
86
+ caption?: string;
87
+ /** True when the photo bytes were saved to the media dir. */
88
+ downloaded: boolean;
89
+ /** Download/save failure note (degraded delivery, never dropped). */
90
+ error?: string;
91
+ };
92
+ /** Present for voice messages (absent for plain-text). */
93
+ voice?: {
94
+ fileId: string;
95
+ /** Absolute path of the saved file (when the download succeeded). */
96
+ filePath?: string;
97
+ caption?: string;
98
+ /** True when the voice bytes were saved to the media dir. */
99
+ downloaded: boolean;
100
+ /** Recognized transcript (when STT succeeded). */
101
+ transcript?: string;
102
+ /** Degradation note (download or transcription failure, never dropped). */
103
+ error?: string;
104
+ };
105
+ /** Present for document messages (absent for plain-text). */
106
+ document?: {
107
+ fileId: string;
108
+ /** Original client-side file_name (when Telegram provided one). */
109
+ name?: string;
110
+ /** Absolute path of the saved file (when the download succeeded). */
111
+ path?: string;
112
+ /** Declared file size in bytes (when Telegram provided it). */
113
+ size?: number;
114
+ /** Declared MIME type (when Telegram provided it). */
115
+ mimeType?: string;
116
+ caption?: string;
117
+ /** True when the document bytes were saved to the media dir. */
118
+ downloaded: boolean;
119
+ /** Refusal/download/save failure note (degraded delivery, never dropped). */
120
+ error?: string;
121
+ };
122
+ receivedAt: number;
123
+ }
124
+
125
+ /**
126
+ * Tunnel liveness transition emitted by the poller (watchdog canary).
127
+ *
128
+ * The poller owns the counter (consecutive getUpdates failures); the core
129
+ * watchdog subscribes to the transition and surfaces the two whispers
130
+ * (down + recovery) via sendTrackedMessage. No network is involved here —
131
+ * this is a pure local signal derived from the getUpdates result.
132
+ */
133
+ export interface WatchdogTunnelStatusEvent {
134
+ /** "down" when N consecutive fetch-failures trip the threshold; "up" on recovery. */
135
+ status: "down" | "up";
136
+ /** Consecutive getUpdates failures at the transition (0 on the "up" event). */
137
+ failures: number;
138
+ /** Last getUpdates error text (present on "down"). */
139
+ lastError?: string;
140
+ /** Ready-to-deliver whisper body (down: diagnose; up: recovery). */
141
+ message: string;
142
+ }
143
+
144
+ /** Media-extraction context threaded into extractIncomingMessage (photo/voice/document inbound). */
145
+ export interface IncomingMediaContext {
146
+ token?: string;
147
+ fetchFn?: TelegramFetch;
148
+ baseUrl?: string;
149
+ mediaDir?: string;
150
+ /** STT (speech-to-text) config — absent/empty means voice is delivered untranscribed. */
151
+ stt?: SttConfig;
152
+ }
153
+
154
+ export interface InboundPollerOptions {
155
+ token: string;
156
+ /** Initially allowed sender ids or @usernames (owner chat id env or empty). */
157
+ allowedUsers: Array<number | string>;
158
+ /** Path to the JSON state file where the confirmed offset is persisted. */
159
+ stateFile?: string;
160
+ /** Injectable fetch (tests use a recorded-fixture fake). */
161
+ fetchFn?: TelegramFetch;
162
+ /** Directory where downloaded photo/voice/document bytes are saved. Default `<braidDir>/telegram/media`. */
163
+ mediaDir?: string;
164
+ /** STT (speech-to-text) config for voice transcription (optional). */
165
+ stt?: SttConfig;
166
+ /** Poll cadence in ms. Default 1500. */
167
+ intervalMs?: number;
168
+ /** Auto-allow the first private text sender when the allow-list is empty. */
169
+ bootstrap?: boolean;
170
+ /** Max injection attempts for an accepted message before it is dropped. */
171
+ maxRetries?: number;
172
+ /**
173
+ * Called on each tunnel liveness transition (down at the failure threshold,
174
+ * up on recovery). Injectable so the fixture tests can assert transitions
175
+ * with zero network; telegram/index.ts wires it to the cross-package
176
+ * watchdog hub (watchdog-tunnel.ts) that the core watchdog subscribes to.
177
+ */
178
+ onTunnelStatusChange?: (event: WatchdogTunnelStatusEvent) => void;
179
+ /** Consecutive getUpdates failures that trip the tunnel-down event. Default 3. */
180
+ tunnelFailureThreshold?: number;
181
+ /** Called for each accepted owner message. May be async; reject to requeue. */
182
+ onOwnerMessage: (message: IncomingOwnerMessage) => void | Promise<void>;
183
+ /**
184
+ * Called for each callback_query update (inline-keyboard tap).
185
+ * The handler owns answerCallbackQuery + editMessageText; the poller only
186
+ * routes ALLOW-LISTED taps here (foreign taps are dropped — never a turn).
187
+ * May be async; reject to requeue. Absent → callback updates are dropped.
188
+ */
189
+ onCallbackQuery?: (update: TelegramUpdate) => void | Promise<void>;
190
+ /**
191
+ * Pause-gate state reader: checked on NON-callback
192
+ * Owner messages BEFORE delivery — when paused the message is NOT injected
193
+ * (onOwnerMessage is never called → no sendUserMessage turn) and the owner
194
+ * gets ONE «⏸ пауза» ack per paused period. Callback_query taps are NEVER
195
+ * gated (they are the owner's resume path). The orchestrator (index.ts)
196
+ * injects the real getPauseState so the gate is live in production; the
197
+ * default "never paused" keeps the poller deterministic without wiring or
198
+ * new config keys.
199
+ */
200
+ getPauseState?: PauseStateReader;
201
+ /** Text for the once-per-paused-period ack. Defaults to PAUSED_INBOUND_ACK_TEXT. */
202
+ pauseAckText?: string;
203
+ /** Non-fatal error sink (network blips, injection failures). */
204
+ onError?: (error: unknown) => void;
205
+ /** Optional logger; defaults to a no-op. */
206
+ log?: (
207
+ level: "debug" | "info" | "warn" | "error",
208
+ message: string,
209
+ ...rest: unknown[]
210
+ ) => void;
211
+ }
212
+
213
+ /** PollerState persisted to the state file (survive restarts). */
214
+ export interface PollerState {
215
+ offset: number;
216
+ }
217
+
218
+ export interface PollOnceResult {
219
+ ok: boolean;
220
+ error?: string;
221
+ fetched: number;
222
+ /** Number accepted and handed to onOwnerMessage this cycle. */
223
+ accepted: number;
224
+ /** Number dropped by policy (foreign/ignored/non-text). */
225
+ rejected: number;
226
+ /** Messages held by the pause gate this cycle (paused → acked, not injected). */
227
+ paused: number;
228
+ /** Callback_query updates handled this cycle. */
229
+ callbacks: number;
230
+ /** Callback_query updates dropped (foreign sender or no handler). */
231
+ callbackRejected: number;
232
+ /** Updates whose injection failed this cycle and moved to the retry buffer. */
233
+ requeued: number;
234
+ /** Retry-buffer messages that succeeded this cycle. */
235
+ retried: number;
236
+ /** Newly bootstrap-allowed sender ids this cycle. */
237
+ bootstrapped: number[];
238
+ /** Offset now persisted. */
239
+ offset: number;
240
+ }
241
+
242
+ export const DEFAULT_POLL_TIMEOUT_SECONDS = 50; // long-poll socket timeout (Telegram caps at 50s; fewer reconnect windows = less getUpdates conflict surface)
243
+ export const DEFAULT_POLL_INTERVAL_MS = 1500;
244
+
245
+ /** Consecutive getUpdates failures that trip the tunnel-down transition (watchdog canary). */
246
+ export const DEFAULT_TUNNEL_FAILURE_THRESHOLD = 3;
247
+
248
+ /** Quiet window for repeating poll-cycle errors. */
249
+ export const ERROR_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes
250
+
251
+ export type MessageVerdict =
252
+ | "owner"
253
+ | "bootstrap"
254
+ | "foreign"
255
+ | "non_message"
256
+ | "non_private"
257
+ | "non_text";
258
+
259
+ /** Pause-state reader for the pause gate. */
260
+ export type PauseStateReader = (stateFile?: string) => Promise<PauseState>;
261
+
262
+ /**
263
+ * Ack text for the once-per-paused-period reply: the owner is told the agent
264
+ * is paused and that a ▶️ Resume tap on the control keyboard resumes. Kept
265
+ * short so it reads as a toast-style ack («⏸ пауза»), not a control panel.
266
+ */
267
+ export const PAUSED_INBOUND_ACK_TEXT =
268
+ "«⏸ пауза» — агент на паузе. Нажми ▶️ Resume на клавиатуре, чтобы возобновить.";
269
+
270
+ // Repeated-error throttle
271
+ //
272
+ // The poll loop re-poll every ~1.5s, so a persistent getUpdates failure (e.g.
273
+ // a conflicting consumer holding the only long-poll slot, or a transient
274
+ // network blip) would log a fresh console.error every cycle and wreck the TUI.
275
+ // After the first loud warn (full detail), repeats produce NO log call at all
276
+ // (zero per-cycle output, no counter line, no partial re-render) — even a
277
+ // per-cycle `debug` line renders in the live pi TUI, and its cursor-escape
278
+ // sequences corrupt the user input field with artifacts. The error
279
+ // re-promotes to warn at most once per window, and a successful poll later
280
+ // emits one info recovery line. The `onError` sink is gated on the SAME loud
281
+ // occurrences so the consumer's own error log (e.g. `inbound poll error: ...`)
282
+ // does not fire per cycle either.
283
+
284
+ /** Poller log callback shape (matches InboundPollerOptions.log). */
285
+ export type PollLogLevel = "debug" | "info" | "warn" | "error";
286
+ export type PollLog = (
287
+ level: PollLogLevel,
288
+ message: string,
289
+ ...rest: unknown[]
290
+ ) => void;
291
+
292
+ export interface ThrottledLogger {
293
+ /**
294
+ * Record one occurrence of a repeating error. The first occurrence is
295
+ * emitted at `level` with full detail; consecutive repeats within `windowMs`
296
+ * produce NO log output at all (zero per-cycle lines), until
297
+ * `windowMs` has elapsed since the last loud emission — then the error
298
+ * re-promotes to `level` exactly once. Returns true when this occurrence
299
+ * was the LOUD one; callers use the return value to gate side-effecting
300
+ * sinks (e.g. `onError`) so they stay quiet too.
301
+ */
302
+ occurrence(level: PollLogLevel, message: string): boolean;
303
+ /** Consecutive occurrences in the current burst (0 when clean/reset). */
304
+ streak(): number;
305
+ /** Clear the error state after a successful poll (recovery). */
306
+ reset(): void;
307
+ }
308
+
309
+ /**
310
+ * Build a small per-source rate limiter. `windowMs` and `now` are injectable
311
+ * so tests can shrink the window and advance a fake clock instead of waiting
312
+ * out five real minutes. At most ONE line is ever emitted per `windowMs`: the
313
+ * first occurrence is loud with full detail, repeats inside the window are
314
+ * completely silent (no debug, no counter) so per-cycle output
315
+ * cannot corrupt the TUI, and a still-failing source re-warns once when the
316
+ * window elapses. `streak()` exposes the consecutive-error count so a caller
317
+ * can emit a single recovery line once the error clears; `reset()` clears the
318
+ * state after recovery. State resets itself once no occurrence has been seen
319
+ * for a full window, so a later burst starts loud again.
320
+ */
321
+ export function createThrottledLogger(
322
+ log: PollLog,
323
+ windowMs: number = ERROR_THROTTLE_MS,
324
+ now: () => number = Date.now,
325
+ ): ThrottledLogger {
326
+ let seen = false;
327
+ let lastAt = 0;
328
+ let lastLoudAt = 0;
329
+ let streak = 0;
330
+
331
+ return {
332
+ occurrence(level, message) {
333
+ const t = now();
334
+ const burstAgedOut = !seen || t - lastAt >= windowMs;
335
+ const windowSinceLoud = !seen || t - lastLoudAt >= windowMs;
336
+ streak = burstAgedOut ? 1 : streak + 1;
337
+ lastAt = t;
338
+ if (burstAgedOut || windowSinceLoud) {
339
+ seen = true;
340
+ lastLoudAt = t;
341
+ log(level, message);
342
+ return true;
343
+ }
344
+ // Quiet repeat: ZERO log output — the first loud warn stands and
345
+ // the TUI gets no per-cycle line, counter, or partial re-render.
346
+ return false;
347
+ },
348
+ streak() {
349
+ return streak;
350
+ },
351
+ reset() {
352
+ seen = false;
353
+ lastAt = 0;
354
+ lastLoudAt = 0;
355
+ streak = 0;
356
+ },
357
+ };
358
+ }
359
+
360
+ // Pure acceptance logic (unit-tested directly)
361
+
362
+ /**
363
+ * Classify a single update against the allow-list + bootstrap policy.
364
+ * Returns the reason-independent verdict used by test assertions.
365
+ */
366
+ export function classifyUpdate(
367
+ update: TelegramUpdate,
368
+ allowedUsers: Array<number | string>,
369
+ allowListEmptyAndBootstrapping: boolean,
370
+ ): MessageVerdict {
371
+ const message = update.message;
372
+ if (!message) return "non_message";
373
+ if (!message.chat || message.chat.type !== "private") return "non_private";
374
+ const text = typeof message.text === "string" ? message.text.trim() : "";
375
+ // Photo/voice/document messages (non-empty `photo` / usable `voice` or
376
+ // `document`, optional caption) classify by sender like text — no
377
+ // `message.text` is required for a media message.
378
+ if (
379
+ !text &&
380
+ !hasPhoto(message) &&
381
+ !hasVoice(message) &&
382
+ !hasDocument(message)
383
+ )
384
+ return "non_text";
385
+ const from = message.from;
386
+ if (!from || typeof from.id !== "number") return "foreign";
387
+ if (isAllowed(from, allowedUsers)) return "owner";
388
+ if (allowListEmptyAndBootstrapping) return "bootstrap";
389
+ return "foreign";
390
+ }
391
+
392
+ /**
393
+ * True when the sender (id or @username string) is in the allow-list.
394
+ * Mirrors agent-zero `_is_allowed` for plain numeric-username entries.
395
+ */
396
+ export function isAllowed(
397
+ from: { id: number; username?: string },
398
+ allowedUsers: Array<number | string>,
399
+ ): boolean {
400
+ for (const entry of allowedUsers) {
401
+ const s = String(entry).trim();
402
+ if (s.startsWith("@")) {
403
+ if (from.username && `@${from.username}` === s) return true;
404
+ } else if (/^\d+$/.test(s)) {
405
+ if (Number(s) === from.id) return true;
406
+ } else if (
407
+ from.username &&
408
+ from.username.toLowerCase() === s.toLowerCase()
409
+ ) {
410
+ return true;
411
+ }
412
+ }
413
+ return false;
414
+ }
415
+
416
+ /**
417
+ * Extract an accepted message into the shape handed to consumers (shared by
418
+ * the background poller and telegram-inbox). Plain-text messages map 1:1.
419
+ * Photo messages (no text, non-empty `photo`) download the largest variant to
420
+ * the media dir and deliver the absolute path (caption carried); on any
421
+ * download/save failure they degrade to the file_id + error note in `text` —
422
+ * the update is NEVER dropped. Voice messages (no text, non-empty `voice`)
423
+ * download the note, save it, and — when `media.stt.apiKey` is configured —
424
+ * transcribe it via the OpenAI-compatible STT endpoint, delivering the
425
+ * transcript; without STT the saved path is delivered with an "unavailable"
426
+ * note (still a successful receive); on any download failure they degrade to
427
+ * the file_id + error note — NEVER dropped. Document messages (a usable
428
+ * `document` object — with or without text) download the file, save it as
429
+ * `<mediaDir>/<update_id>.<safe-ext from file_name>` (over-limit sizes are
430
+ * refused with an explicit reason), and deliver the path + metadata; on any
431
+ * failure they degrade to the file_id + error note — NEVER dropped. A
432
+ * text+document message carries BOTH: the original text plus the document
433
+ * line. When `media.token` is absent (tests without a token) media messages
434
+ * degrade without a download.
435
+ */
436
+ export async function extractIncomingMessage(
437
+ update: TelegramUpdate,
438
+ media: IncomingMediaContext = {},
439
+ ): Promise<IncomingOwnerMessage> {
440
+ const message = update.message!;
441
+ const base = {
442
+ updateId: update.update_id,
443
+ messageId: message.message_id,
444
+ fromId: message.from!.id,
445
+ username: message.from!.username,
446
+ firstName: message.from!.first_name,
447
+ chatId: message.chat.id,
448
+ chatType: message.chat.type,
449
+ receivedAt: message.date * 1000,
450
+ };
451
+ const text = typeof message.text === "string" ? message.text.trim() : "";
452
+ const caption =
453
+ typeof message.caption === "string" && message.caption.trim()
454
+ ? message.caption.trim()
455
+ : undefined;
456
+ const senderLabel = base.username ? `@${base.username}` : `id ${base.fromId}`;
457
+
458
+ if (!text && hasPhoto(message)) {
459
+ const variant = pickPhotoVariant(message.photo);
460
+ if (variant) {
461
+ const fileId = variant.file_id;
462
+ const result = media.token
463
+ ? await materializePhoto({
464
+ token: media.token,
465
+ updateId: update.update_id,
466
+ photo: variant,
467
+ fetchFn: media.fetchFn,
468
+ baseUrl: media.baseUrl,
469
+ mediaDir: media.mediaDir,
470
+ })
471
+ : {
472
+ ok: false as const,
473
+ fileId,
474
+ error: "no telegram token for media download",
475
+ };
476
+ if (result.ok) {
477
+ return {
478
+ ...base,
479
+ text: formatPhotoText({
480
+ senderLabel,
481
+ filePath: result.filePath,
482
+ caption,
483
+ }),
484
+ photo: {
485
+ fileId: result.fileId,
486
+ filePath: result.filePath,
487
+ caption,
488
+ downloaded: true,
489
+ },
490
+ };
491
+ }
492
+ return {
493
+ ...base,
494
+ text: formatPhotoText({
495
+ senderLabel,
496
+ fileId: result.fileId,
497
+ error: result.error,
498
+ caption,
499
+ }),
500
+ photo: {
501
+ fileId: result.fileId,
502
+ caption,
503
+ downloaded: false,
504
+ error: result.error,
505
+ },
506
+ };
507
+ }
508
+ // No usable variant (missing file_id) — deliver the raw file_id if any.
509
+ const rawFileId = Array.isArray(message.photo)
510
+ ? (message.photo[0] as { file_id?: string } | undefined)?.file_id
511
+ : undefined;
512
+ return {
513
+ ...base,
514
+ text: formatPhotoText({
515
+ senderLabel,
516
+ fileId: rawFileId,
517
+ error: "no usable photo variant",
518
+ caption,
519
+ }),
520
+ photo: rawFileId
521
+ ? {
522
+ fileId: rawFileId,
523
+ caption,
524
+ downloaded: false,
525
+ error: "no usable photo variant",
526
+ }
527
+ : undefined,
528
+ };
529
+ }
530
+
531
+ if (!text && hasVoice(message)) {
532
+ const fileId = getVoiceFileId(message.voice);
533
+ if (fileId) {
534
+ const result = media.token
535
+ ? await materializeVoice({
536
+ token: media.token,
537
+ updateId: update.update_id,
538
+ voice: message.voice as TelegramVoice,
539
+ fetchFn: media.fetchFn,
540
+ baseUrl: media.baseUrl,
541
+ mediaDir: media.mediaDir,
542
+ })
543
+ : {
544
+ ok: false as const,
545
+ fileId,
546
+ error: "no telegram token for media download",
547
+ };
548
+ if (result.ok) {
549
+ const voiceBase = {
550
+ fileId: result.fileId,
551
+ filePath: result.filePath,
552
+ caption,
553
+ downloaded: true,
554
+ };
555
+ if (isSttConfigured(media.stt)) {
556
+ // Retry orchestrator: bounded to two rounds — the first on raw
557
+ // bytes, the second ffmpeg-amplified when the first fails or comes
558
+ // back empty and normalization is enabled (stt config carries the
559
+ // gate and the key pool; counters live in stt-keys.ts).
560
+ const tr = await transcribeVoiceReliable({
561
+ bytes: result.bytes,
562
+ baseUrl: media.stt?.baseUrl,
563
+ apiKey: media.stt?.apiKey,
564
+ model: media.stt?.model,
565
+ language: media.stt?.language,
566
+ mode: media.stt?.mode,
567
+ fetchFn: media.fetchFn,
568
+ });
569
+ if (tr.ok) {
570
+ return {
571
+ ...base,
572
+ text: formatVoiceText({
573
+ senderLabel,
574
+ filePath: result.filePath,
575
+ caption,
576
+ transcript: tr.text,
577
+ }),
578
+ voice: { ...voiceBase, transcript: tr.text },
579
+ };
580
+ }
581
+ return {
582
+ ...base,
583
+ text: formatVoiceText({
584
+ senderLabel,
585
+ filePath: result.filePath,
586
+ caption,
587
+ transcriptError: `failed: ${tr.error}`,
588
+ }),
589
+ voice: { ...voiceBase, error: `transcription failed: ${tr.error}` },
590
+ };
591
+ }
592
+ // No STT configured — deliver the saved path with the "unavailable"
593
+ // note (still a successful receive, never dropped).
594
+ return {
595
+ ...base,
596
+ text: formatVoiceText({
597
+ senderLabel,
598
+ filePath: result.filePath,
599
+ caption,
600
+ transcriptError: "unavailable — set BRAID_TG_STT_API_KEY",
601
+ }),
602
+ voice: voiceBase,
603
+ };
604
+ }
605
+ return {
606
+ ...base,
607
+ text: formatVoiceText({
608
+ senderLabel,
609
+ fileId: result.fileId,
610
+ error: result.error,
611
+ caption,
612
+ }),
613
+ voice: {
614
+ fileId: result.fileId,
615
+ caption,
616
+ downloaded: false,
617
+ error: result.error,
618
+ },
619
+ };
620
+ }
621
+ // No usable file_id — deliver the degraded note (never dropped).
622
+ return {
623
+ ...base,
624
+ text: formatVoiceText({
625
+ senderLabel,
626
+ error: "no usable voice file_id",
627
+ caption,
628
+ }),
629
+ };
630
+ }
631
+
632
+ if (hasDocument(message)) {
633
+ // Document inbound: a document may ride WITH text (both carried) or be
634
+ // document-only. Voice/audio/photo/video_note messages never reach this
635
+ // branch (distinct Telegram fields, handled above).
636
+ const document = message.document as TelegramDocument;
637
+ const fileId = document.file_id;
638
+ const name =
639
+ typeof document.file_name === "string" && document.file_name
640
+ ? document.file_name
641
+ : undefined;
642
+ const size =
643
+ typeof document.file_size === "number" ? document.file_size : undefined;
644
+ const mimeType =
645
+ typeof document.mime_type === "string" ? document.mime_type : undefined;
646
+ const result = media.token
647
+ ? await materializeDocument({
648
+ token: media.token,
649
+ updateId: update.update_id,
650
+ document,
651
+ fetchFn: media.fetchFn,
652
+ baseUrl: media.baseUrl,
653
+ mediaDir: media.mediaDir,
654
+ })
655
+ : {
656
+ ok: false as const,
657
+ fileId,
658
+ error: "no telegram token for media download",
659
+ };
660
+ const docText = result.ok
661
+ ? formatDocumentText({
662
+ senderLabel,
663
+ filePath: result.filePath,
664
+ name,
665
+ caption,
666
+ })
667
+ : formatDocumentText({
668
+ senderLabel,
669
+ fileId: result.fileId,
670
+ name,
671
+ error: result.error,
672
+ caption,
673
+ });
674
+ return {
675
+ ...base,
676
+ text: text ? `${text}\n${docText}` : docText,
677
+ document: {
678
+ fileId,
679
+ name,
680
+ size,
681
+ mimeType,
682
+ caption,
683
+ ...(result.ok
684
+ ? { path: result.filePath, downloaded: true as const }
685
+ : { downloaded: false as const, error: result.error }),
686
+ },
687
+ };
688
+ }
689
+
690
+ return { ...base, text };
691
+ }
692
+
693
+ // State file (offset persistence across restarts)
694
+
695
+ /** Default state file. Null when no absolute braid root resolves: a poller
696
+ * without offset persistence would re-deliver, so createInboundPoller
697
+ * refuses to build on it. */
698
+ export function defaultStateFile(): string | null {
699
+ return defaultStateFilePath();
700
+ }
701
+
702
+ export async function loadState(stateFile: string): Promise<PollerState> {
703
+ try {
704
+ const raw = await readFile(stateFile, "utf8");
705
+ const parsed = JSON.parse(raw) as Partial<PollerState>;
706
+ const offset = Number(parsed?.offset);
707
+ return { offset: Number.isFinite(offset) && offset >= 0 ? offset : 0 };
708
+ } catch {
709
+ return { offset: 0 };
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Persist the offset ATOMICALLY (write-temp-rename, same rationale as
715
+ * saveInboundQueue/savePauseState). `loadState` below falls back to offset 0 on
716
+ * anything it cannot parse, and offset 0 means the WHOLE backlog is redelivered
717
+ * — so a truncate-then-write window (a crash inside it, a reader sampling it)
718
+ * silently destroys exactly the guarantee this file exists to provide. The temp
719
+ * name is unique per write: a shared temp made concurrent savers fail each
720
+ * other's rename (ENOENT), while unique temps all land and the last rename wins
721
+ * with a whole snapshot. Two code paths save this file — the poll loop and the
722
+ * `telegram_inbox` tool — so overlapping savers are reachable.
723
+ */
724
+ export async function saveState(
725
+ stateFile: string,
726
+ state: PollerState,
727
+ ): Promise<void> {
728
+ const dir = dirname(stateFile);
729
+ try {
730
+ await mkdir(dir, { recursive: true });
731
+ } catch {
732
+ // parent may already exist and be non-writable-required; write will surface real errors
733
+ }
734
+ const tmp = `${stateFile}.${randomUUID()}.tmp`;
735
+ await writeFile(
736
+ tmp,
737
+ JSON.stringify({ offset: state.offset }, null, 2) + "\n",
738
+ "utf8",
739
+ );
740
+ await rename(tmp, stateFile);
741
+ }
742
+
743
+ // Poller
744
+
745
+ export interface InboundPoller {
746
+ start(): void;
747
+ stop(): void;
748
+ isRunning(): boolean;
749
+ /** Resolves once the persisted offset has been loaded into state. */
750
+ waitReady(): Promise<void>;
751
+ /** Perform one poll cycle (test seam; also used internally by the loop). */
752
+ pollOnce(): Promise<PollOnceResult>;
753
+ getState(): PollerState;
754
+ /**
755
+ * Current tunnel liveness: "up" / "down" once the first poll has run
756
+ * (consecutive-failure counter), "unknown" before any poll attempt.
757
+ */
758
+ getTunnelStatus(): { status: "up" | "down" | "unknown"; failures: number };
759
+ }
760
+
761
+ /** Format the tunnel-down whisper body (watchdog canary). */
762
+ export function formatTunnelDownMessage(
763
+ failures: number,
764
+ lastError?: string,
765
+ ): string {
766
+ const err =
767
+ lastError && lastError.trim() ? lastError.trim() : "unknown error";
768
+ return (
769
+ `Telegram tunnel DOWN: ${failures} consecutive getUpdates failures (${err}). ` +
770
+ `The remote canary is offline — diagnose the tunnel/poller before relying on remote operations.`
771
+ );
772
+ }
773
+
774
+ /** Format the tunnel recovery whisper body (watchdog canary). */
775
+ export function formatTunnelRecoveryMessage(priorFailures: number): string {
776
+ return (
777
+ `Telegram tunnel recovered after ${priorFailures} consecutive getUpdates failure` +
778
+ `${priorFailures === 1 ? "" : "s"} — the remote channel is back online.`
779
+ );
780
+ }
781
+
782
+ export function createInboundPoller(
783
+ options: InboundPollerOptions,
784
+ ): InboundPoller {
785
+ const {
786
+ token,
787
+ allowedUsers,
788
+ fetchFn = globalThis.fetch,
789
+ mediaDir,
790
+ intervalMs = DEFAULT_POLL_INTERVAL_MS,
791
+ bootstrap = true,
792
+ onOwnerMessage,
793
+ onCallbackQuery,
794
+ getPauseState = async () => ({ paused: false, updatedAt: 0 }),
795
+ pauseAckText = PAUSED_INBOUND_ACK_TEXT,
796
+ onError,
797
+ log = () => {},
798
+ } = options;
799
+ const resolvedStateFile = options.stateFile ?? defaultStateFile();
800
+ if (resolvedStateFile === null) {
801
+ warnUnderivableRoot("inbound poller refused");
802
+ throw new Error(
803
+ "braid telegram state file is underivable (BRAID_HOME unset and no absolute home); refusing to start the inbound poller without offset persistence",
804
+ );
805
+ }
806
+ const stateFile: string = resolvedStateFile;
807
+
808
+ const allowList: Array<number | string> = [...allowedUsers];
809
+ const maxRetries = options.maxRetries ?? 5;
810
+ // Media-extraction context: token + fetch + media dir + stt config threaded
811
+ // to extractIncomingMessage so accepted photo/voice messages download,
812
+ // save, and (voice) transcribe.
813
+ const mediaCtx: IncomingMediaContext = {
814
+ token,
815
+ fetchFn,
816
+ mediaDir,
817
+ stt: options.stt,
818
+ };
819
+ // One throttler for the repeating getUpdates-failure source:
820
+ // first failure logs at warn with full detail, then consecutive identical
821
+ // failures are COMPLETELY SILENT for ERROR_THROTTLE_MS (zero per-cycle
822
+ // output), re-loud once per window; a successful poll resets it and can emit
823
+ // one recovery info line (see pollCycle).
824
+ const getUpdatesFailure = createThrottledLogger(log);
825
+ const onTunnelStatusChange = options.onTunnelStatusChange;
826
+ const tunnelThreshold =
827
+ options.tunnelFailureThreshold ?? DEFAULT_TUNNEL_FAILURE_THRESHOLD;
828
+ // Tunnel liveness: consecutive getUpdates failure counter independent of the
829
+ // 5-min log throttle (it counts EVERY consecutive failure, reset on success).
830
+ // tunnelDown flips once the counter crosses the threshold and flips back on
831
+ // the first success — the down/up transition fires onTunnelStatusChange.
832
+ let tunnelProbed = false;
833
+ let consecutiveTunnelFailures = 0;
834
+ let tunnelDown = false;
835
+ let lastTunnelError: string | undefined;
836
+ const retryQueue: Array<{
837
+ update: TelegramUpdate;
838
+ attempts: number;
839
+ kind: "message" | "callback";
840
+ }> = [];
841
+
842
+ function emitTunnelStatus(
843
+ status: "down" | "up",
844
+ failures: number,
845
+ lastError: string | undefined,
846
+ message: string,
847
+ ): void {
848
+ // Last-writer-wins: the poller is the ONE emitter of tunnel transitions.
849
+ tunnelProbed = true;
850
+ onTunnelStatusChange?.({ status, failures, lastError, message });
851
+ }
852
+
853
+ /**
854
+ * Tunnel liveness proven up: reset the failure counter and emit exactly one
855
+ * "up" recovery event when a down state was open (one event per outage).
856
+ * Shared by the successful-poll path and the getMe-liveness path so both
857
+ * recovery sources emit identically.
858
+ */
859
+ function markTunnelUp(): void {
860
+ if (tunnelDown) {
861
+ const priorFailures = consecutiveTunnelFailures;
862
+ const lastErrorSnapshot = lastTunnelError;
863
+ tunnelDown = false;
864
+ consecutiveTunnelFailures = 0;
865
+ lastTunnelError = undefined;
866
+ emitTunnelStatus(
867
+ "up",
868
+ 0,
869
+ lastErrorSnapshot,
870
+ formatTunnelRecoveryMessage(priorFailures),
871
+ );
872
+ } else {
873
+ consecutiveTunnelFailures = 0;
874
+ tunnelProbed = true;
875
+ }
876
+ }
877
+ let state: PollerState = { offset: 0 };
878
+ const bootstrapped: number[] = [];
879
+ // Pause gate: `pauseAckUpdatedAt` records the
880
+ // updatedAt of the paused period the last «⏸ пауза» ack was sent for (0 =
881
+ // never). A paused message is acked exactly once per paused period — the
882
+ // flag's updatedAt changes on every resume/re-pause transition, so the next
883
+ // paused period re-acks while messages inside the same period are held
884
+ // silently (no reply-storm). Not persisted: a poller restart just re-acks
885
+ // once (a fresh session), which is the desired behavior.
886
+ let pauseAckUpdatedAt = 0;
887
+ let running = false;
888
+ let timer: ReturnType<typeof setTimeout> | undefined;
889
+ // Load the persisted offset eagerly so the first poll (and any direct
890
+ // pollOnce call) deterministically starts from the saved offset.
891
+ const readyPromise: Promise<void> = loadState(stateFile).then((loaded) => {
892
+ state = loaded;
893
+ });
894
+
895
+ async function drainRetryQueue(): Promise<{
896
+ retried: number;
897
+ dropped: number;
898
+ }> {
899
+ let retried = 0;
900
+ let dropped = 0;
901
+ for (let i = retryQueue.length - 1; i >= 0; i--) {
902
+ const entry = retryQueue[i];
903
+ try {
904
+ if (entry.kind === "callback") {
905
+ // Callback taps are NEVER pause-gated — they are the owner's resume
906
+ // path. The gate applies to non-callback delivery.
907
+ await onCallbackQuery?.(entry.update);
908
+ } else {
909
+ // Pause gate applies to queued messages too: while paused a retried
910
+ // owner message stays queued (never injected) until unpaused. No ack
911
+ // here — the initial attempt already passed the gate, so the ack (if
912
+ // any) was handled at acceptance time.
913
+ const pause = await getPauseState();
914
+ if (pause.paused) {
915
+ log(
916
+ "debug",
917
+ `telegram: retry held while paused (update ${entry.update.update_id})`,
918
+ );
919
+ continue;
920
+ }
921
+ await onOwnerMessage(
922
+ await extractIncomingMessage(entry.update, mediaCtx),
923
+ );
924
+ }
925
+ retried++;
926
+ log("debug", `telegram: retried update ${entry.update.update_id}`);
927
+ retryQueue.splice(i, 1);
928
+ } catch (error) {
929
+ entry.attempts++;
930
+ if (entry.attempts >= maxRetries) {
931
+ dropped++;
932
+ retryQueue.splice(i, 1);
933
+ log(
934
+ "error",
935
+ `telegram: dropped update ${entry.update.update_id} after ${entry.attempts} failed injections: ${String(error)}`,
936
+ );
937
+ onError?.(error);
938
+ } else {
939
+ log(
940
+ "warn",
941
+ `telegram: retry ${entry.attempts}/${maxRetries} failed for update ${entry.update.update_id}: ${String(error)}`,
942
+ );
943
+ }
944
+ }
945
+ }
946
+ return { retried, dropped };
947
+ }
948
+
949
+ async function pollCycle(): Promise<PollOnceResult> {
950
+ await readyPromise;
951
+ // 1. Retry previously failed accepted messages from the local buffer first.
952
+ const { retried } = await drainRetryQueue();
953
+
954
+ // 2. Long-poll the next batch.
955
+ const res = await getUpdates(
956
+ token,
957
+ state.offset,
958
+ DEFAULT_POLL_TIMEOUT_SECONDS,
959
+ {
960
+ fetch: fetchFn,
961
+ },
962
+ );
963
+ if (!res.ok) {
964
+ // Loud on the first occurrence (and at most once per throttle window),
965
+ // utter SILENCE on consecutive repeats — no debug, no counter, so
966
+ // the TUI never sees a per-cycle line while the error is sustained.
967
+ const loud = getUpdatesFailure.occurrence(
968
+ "warn",
969
+ `getUpdates failed (offset ${state.offset}): ${res.error ?? "unknown"}`,
970
+ );
971
+ // Gate the consumer error sink on the loud occurrence too, so the
972
+ // index.ts `inbound poll error: ...` warn does not fire every cycle.
973
+ if (loud) {
974
+ onError?.(
975
+ new Error(res.error ?? `getUpdates failed (offset ${state.offset})`),
976
+ );
977
+ }
978
+ // Watchdog tunnel counter — getMe-based liveness (standing backlog
979
+ // Real fix after the gate-widening mitigation).
980
+ // A getUpdates failure is a TUNNEL failure only when the Telegram API
981
+ // itself is unreachable or rejects the token. A "Conflict: terminated
982
+ // by other getUpdates request" comes from a SECOND getUpdates consumer
983
+ // holding the singleton long-poll slot (sibling process poller, agentic
984
+ // inbox) — which PROVES the API is up and the tunnel is healthy; the
985
+ // old behavior counted it as a tunnel failure and barked
986
+ // tunnel-down at healthy pollers. getMe never takes the getUpdates
987
+ // slot, so it is the honest liveness probe: getMe ok → the failure is
988
+ // a conflict/transient getUpdates issue, not an outage (reset the
989
+ // counter, recover an open down state); getMe failed too → genuine
990
+ // outage, counting proceeds below.
991
+ const liveness = await getMe(token, { fetch: fetchFn });
992
+ if (liveness.ok) {
993
+ markTunnelUp();
994
+ return {
995
+ ok: false,
996
+ error: res.error,
997
+ fetched: 0,
998
+ accepted: 0,
999
+ rejected: 0,
1000
+ paused: 0,
1001
+ callbacks: 0,
1002
+ callbackRejected: 0,
1003
+ requeued: 0,
1004
+ retried,
1005
+ bootstrapped: [],
1006
+ offset: state.offset,
1007
+ };
1008
+ }
1009
+ consecutiveTunnelFailures++;
1010
+ if (res.error !== undefined) lastTunnelError = String(res.error);
1011
+ if (!tunnelDown && consecutiveTunnelFailures >= tunnelThreshold) {
1012
+ tunnelDown = true;
1013
+ emitTunnelStatus(
1014
+ "down",
1015
+ consecutiveTunnelFailures,
1016
+ lastTunnelError,
1017
+ formatTunnelDownMessage(consecutiveTunnelFailures, lastTunnelError),
1018
+ );
1019
+ } else {
1020
+ tunnelProbed = true;
1021
+ }
1022
+ return {
1023
+ ok: false,
1024
+ error: res.error,
1025
+ fetched: 0,
1026
+ accepted: 0,
1027
+ rejected: 0,
1028
+ paused: 0,
1029
+ callbacks: 0,
1030
+ callbackRejected: 0,
1031
+ requeued: 0,
1032
+ retried,
1033
+ bootstrapped: [],
1034
+ offset: state.offset,
1035
+ };
1036
+ }
1037
+
1038
+ // Sustained getUpdates error just cleared: emit ONE info recovery line,
1039
+ // then reset the throttler so a later burst starts loud again.
1040
+ const errored = getUpdatesFailure.streak();
1041
+ if (errored > 0) {
1042
+ log(
1043
+ "info",
1044
+ `telegram getUpdates recovered after ${errored} error${errored === 1 ? "" : "s"}`,
1045
+ );
1046
+ getUpdatesFailure.reset();
1047
+ }
1048
+
1049
+ // Watchdog tunnel recovery: a tunnel that was DOWN emits exactly one
1050
+ // "up" recovery event on its first successful poll; the counter resets
1051
+ // so the next outage starts counting from zero. While healthy, the
1052
+ // counter stays reset silently (no per-cycle output).
1053
+ markTunnelUp();
1054
+
1055
+ let accepted = 0;
1056
+ let rejected = 0;
1057
+ let paused = 0;
1058
+ let callbacks = 0;
1059
+ let callbackRejected = 0;
1060
+ let requeued = 0;
1061
+ let maxBatchId = -1;
1062
+ const newlyBootstrapped: number[] = [];
1063
+
1064
+ for (const update of res.updates) {
1065
+ maxBatchId = Math.max(maxBatchId, update.update_id);
1066
+
1067
+ // Callback_query updates (inline-keyboard taps) are routed
1068
+ // to onCallbackQuery — NEVER to onOwnerMessage (they must not trigger a
1069
+ // turn). Only allow-listed senders may control; foreign taps are dropped
1070
+ // (offset still advances, confirm-on-read). No handler → dropped.
1071
+ if (update.callback_query) {
1072
+ const cq = update.callback_query as TelegramCallbackQuery;
1073
+ const from = cq?.from;
1074
+ if (onCallbackQuery && from && isAllowed(from, allowList)) {
1075
+ try {
1076
+ await onCallbackQuery(update);
1077
+ callbacks++;
1078
+ } catch (error) {
1079
+ requeued++;
1080
+ retryQueue.push({ update, attempts: 0, kind: "callback" });
1081
+ log(
1082
+ "warn",
1083
+ `telegram: callback handling failed for update ${update.update_id}: ${String(error)}`,
1084
+ );
1085
+ onError?.(error);
1086
+ }
1087
+ } else {
1088
+ callbackRejected++;
1089
+ }
1090
+ continue;
1091
+ }
1092
+
1093
+ const verdict = classifyUpdate(
1094
+ update,
1095
+ allowList,
1096
+ allowList.length === 0 && bootstrap,
1097
+ );
1098
+
1099
+ if (verdict === "owner" || verdict === "bootstrap") {
1100
+ if (verdict === "bootstrap") {
1101
+ const id = update.message?.from?.id;
1102
+ if (typeof id === "number" && !allowList.includes(id)) {
1103
+ allowList.push(id);
1104
+ bootstrapped.push(id);
1105
+ newlyBootstrapped.push(id);
1106
+ log(
1107
+ "info",
1108
+ `telegram: auto-allowed sender ${id} (first-message bootstrap)`,
1109
+ );
1110
+ }
1111
+ }
1112
+ const incoming = await extractIncomingMessage(update, mediaCtx);
1113
+
1114
+ // Pause gate
1115
+ // NON-callback owner messages check the persisted pause flag BEFORE
1116
+ // injection. While paused the message is NOT forwarded (no
1117
+ // onOwnerMessage → no pi.sendUserMessage turn); the owner gets ONE
1118
+ // «⏸ пауза» ack per paused period (reply-storm guard), keyed on the
1119
+ // paused period's `updatedAt` so resume/re-pause transitions re-arm
1120
+ // it. Callback_query taps are NEVER gated — that is the resume path.
1121
+ const pause = await getPauseState();
1122
+ if (pause.paused) {
1123
+ paused++;
1124
+ if (pause.updatedAt !== pauseAckUpdatedAt) {
1125
+ pauseAckUpdatedAt = pause.updatedAt;
1126
+ const ack = await sendMessage(
1127
+ token,
1128
+ incoming.chatId,
1129
+ pauseAckText,
1130
+ {
1131
+ fetch: fetchFn,
1132
+ },
1133
+ );
1134
+ if (ack.ok) {
1135
+ log(
1136
+ "info",
1137
+ `telegram: paused — inbound from chat ${incoming.chatId} held; «⏸ пауза» ack sent (msg ${ack.messageId})`,
1138
+ );
1139
+ } else {
1140
+ log(
1141
+ "warn",
1142
+ `telegram: paused — inbound held; pause-ack send failed: ${ack.error ?? "unknown"}`,
1143
+ );
1144
+ }
1145
+ }
1146
+ log(
1147
+ "debug",
1148
+ `telegram: paused — message ${update.update_id} held (not delivered to the agent)`,
1149
+ );
1150
+ continue;
1151
+ }
1152
+
1153
+ try {
1154
+ await onOwnerMessage(incoming);
1155
+ accepted++;
1156
+ } catch (error) {
1157
+ // Injection failed => keep the message in the retry buffer so it is
1158
+ // retried next cycle. The Telegram offset still advances (it is
1159
+ // monotonic and cannot skip an update without dropping it).
1160
+ requeued++;
1161
+ retryQueue.push({ update, attempts: 0, kind: "message" });
1162
+ log(
1163
+ "warn",
1164
+ `telegram: injection failed for update ${update.update_id}: ${String(error)}`,
1165
+ );
1166
+ onError?.(error);
1167
+ }
1168
+ } else {
1169
+ // non_message / non_private / non_text / foreign — drop and confirm.
1170
+ rejected++;
1171
+ }
1172
+ }
1173
+
1174
+ // 3. Confirm-on-read: the whole batch was read, so advance the persisted
1175
+ // offset to max-seen + 1 (Telegram never redelivers it).
1176
+ if (maxBatchId >= 0 && maxBatchId + 1 > state.offset) {
1177
+ state.offset = maxBatchId + 1;
1178
+ try {
1179
+ await saveState(stateFile, state);
1180
+ } catch (error) {
1181
+ log("warn", `telegram: failed to persist offset: ${String(error)}`);
1182
+ }
1183
+ }
1184
+
1185
+ return {
1186
+ ok: true,
1187
+ fetched: res.updates.length,
1188
+ accepted,
1189
+ rejected,
1190
+ paused,
1191
+ callbacks,
1192
+ callbackRejected,
1193
+ requeued,
1194
+ retried,
1195
+ bootstrapped: newlyBootstrapped,
1196
+ offset: state.offset,
1197
+ };
1198
+ }
1199
+
1200
+ function tick(): void {
1201
+ if (!running) return;
1202
+ // Fire-and-forget: the chain's .finally() schedules the next tick, so no
1203
+ // handle needs to be retained (nothing reads it).
1204
+ void pollCycle()
1205
+ .catch((error) => {
1206
+ log("error", `telegram poll crash: ${String(error)}`);
1207
+ onError?.(error);
1208
+ })
1209
+ .finally(() => {
1210
+ if (running) {
1211
+ timer = setTimeout(tick, intervalMs);
1212
+ }
1213
+ });
1214
+ }
1215
+
1216
+ return {
1217
+ start(): void {
1218
+ if (running) return;
1219
+ running = true;
1220
+ log(
1221
+ "info",
1222
+ `telegram inbound poller started (allow-list: ${allowList.length}, state: ${stateFile}, interval: ${intervalMs}ms)`,
1223
+ );
1224
+ void readyPromise.then(() => {
1225
+ log("debug", `telegram: loaded persisted offset ${state.offset}`);
1226
+ tick();
1227
+ });
1228
+ },
1229
+ stop(): void {
1230
+ running = false;
1231
+ if (timer) clearTimeout(timer);
1232
+ timer = undefined;
1233
+ log("info", "telegram inbound poller stopped");
1234
+ },
1235
+ isRunning(): boolean {
1236
+ return running;
1237
+ },
1238
+ waitReady: () => readyPromise,
1239
+ pollOnce: pollCycle,
1240
+ getState: () => ({ ...state }),
1241
+ getTunnelStatus: () => {
1242
+ if (!tunnelProbed) return { status: "unknown", failures: 0 };
1243
+ return tunnelDown
1244
+ ? { status: "down", failures: consecutiveTunnelFailures }
1245
+ : { status: "up", failures: 0 };
1246
+ },
1247
+ };
1248
+ }