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.
- package/LICENSE +21 -0
- package/README.md +204 -2
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +60 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +809 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
|
@@ -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
|
+
}
|