@mono-agent/telegram-adapter 0.3.0 β†’ 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bot.js CHANGED
@@ -1,10 +1,112 @@
1
+ import { stat, readFile, unlink } from "node:fs/promises";
2
+ import { Agent as HttpAgent } from "node:http";
3
+ import { Agent as HttpsAgent } from "node:https";
4
+ import { isAbsolute } from "node:path";
1
5
  import { isAgentResponseCancelledError } from "@mono-agent/agent-contracts";
2
6
  import { run } from "@grammyjs/runner";
3
7
  import { Bot } from "grammy";
4
- import { DEFAULT_MESSAGES, buildAgentRequest, finishSafely, resolveErrorText, } from "./adapter.js";
8
+ import { DEFAULT_MESSAGES, buildAgentRequest, downloadTelegramAttachments, finishSafely, mergeTelegramMessageInputs, normalizeTelegramMessageInput, resolveErrorText, } from "./adapter.js";
9
+ import { isTelegramAskCallbackData } from "./ask.js";
5
10
  import { createGrammyTelegramApi } from "./grammy-client.js";
6
11
  import { TelegramMessageStream, } from "./message-stream.js";
7
12
  const DEFAULT_INITIAL_STATUS_TEXT = "Thinking…";
13
+ // Quiet window after the last album message before we flush the group as one
14
+ // request. Telegram sends album parts back-to-back (sub-second), so ~1s is safe.
15
+ const DEFAULT_ALBUM_AGGREGATION_DELAY_MS = 1000;
16
+ // Lifecycle reaction emojis (when `reactions` is enabled): πŸ‘€ while the agent
17
+ // works, πŸ‘ on success, πŸ‘Ž on failure. Constrained to Telegram's allowed reaction
18
+ // set β€” βœ…/❌ are NOT valid bot reactions, so the closest allowed emojis are used.
19
+ const REACTION_WORKING = "πŸ‘€";
20
+ const REACTION_DONE = "πŸ‘";
21
+ const REACTION_ERROR = "πŸ‘Ž";
22
+ // Bound on the in-memory set of already-answered callback keys so a long-running
23
+ // bot cannot grow it unbounded. A double-tap on an old question past this many
24
+ // distinct answered questions would simply re-run (acceptable, very rare).
25
+ const CALLBACK_DEDUPE_MAX = 200;
26
+ // grammY's Api client default `timeoutSeconds` is 500 (8m20s overall HTTP
27
+ // timeout). A half-open socket (after a network blip or host sleep) therefore
28
+ // hangs ~8 minutes before getUpdates errors. Cap the overall HTTP timeout at 50s
29
+ // so a dead long-poll is detected quickly and the auto-restart monitor can act.
30
+ const DEFAULT_API_TIMEOUT_SECONDS = 50;
31
+ // Long-poll timeout passed to the runner's getUpdates fetch (seconds). Shorter
32
+ // than the 50s client cap so a normal long-poll completes within the HTTP
33
+ // timeout; a stalled socket then fails fast instead of hanging.
34
+ const DEFAULT_LONG_POLL_TIMEOUT_SECONDS = 30;
35
+ // The runner self-retries transient getUpdates errors with exponential backoff
36
+ // for up to this window before its task rejects and the monitor takes over.
37
+ const DEFAULT_RUNNER_MAX_RETRY_TIME_MS = 15_000;
38
+ // Auto-restart backoff bounds for the polling monitor (mirrors slack-adapter's
39
+ // socket-mode reconnect loop): start at 500ms, double on each consecutive
40
+ // failed restart, cap at 30s, reset to the initial delay after a clean restart.
41
+ const DEFAULT_RESTART_INITIAL_BACKOFF_MS = 500;
42
+ const DEFAULT_RESTART_MAX_BACKOFF_MS = 30_000;
43
+ // A restarted runner that stays up this long is treated as a clean restart, so
44
+ // the backoff resets to the initial delay. A runner that crashes again before
45
+ // this window keeps growing the backoff (avoids hammering a flapping connection).
46
+ const DEFAULT_RESTART_STABILITY_MS = 30_000;
47
+ // Poll-liveness watchdog: if no getUpdates call has RESOLVED within this window
48
+ // the runner is force-restarted, even though its task() never rejected. grammY's
49
+ // runner self-retries getUpdates internally, so a degraded connection can stop
50
+ // delivering updates WITHOUT the task rejecting β€” the crash-based auto-restart
51
+ // then never fires and the bot goes silently deaf. 120s comfortably clears the
52
+ // 30s long-poll heartbeat (DEFAULT_LONG_POLL_TIMEOUT_SECONDS) so a normal
53
+ // idle poll never trips it. Set pollWatchdogMs <= 0 to disable.
54
+ const DEFAULT_POLL_WATCHDOG_MS = 120_000;
55
+ // Cap the startup deleteWebhook call so a flaky network cannot block boot: the
56
+ // app awaits start() before reporting ready, and an unbounded deleteWebhook can
57
+ // hang ~50s (the Api client timeout), past the launcher's readiness deadline.
58
+ const DEFAULT_DELETE_WEBHOOK_TIMEOUT_MS = 5_000;
59
+ // Mirrors the harness LiveSessionManager's DEFAULT_MAX_PENDING_PER_CONVERSATION:
60
+ // the per-chat admission queue rejects past this depth so a flood of same-chat
61
+ // messages cannot grow the queue unbounded.
62
+ const DEFAULT_ADMISSION_QUEUE_MAX_DEPTH = 100;
63
+ /**
64
+ * Thrown synchronously by {@link SerialQueue.run} when the queue is already at
65
+ * its depth cap. The bot catches this sentinel to answer with the busy reply
66
+ * instead of admitting an unbounded backlog.
67
+ */
68
+ export class SerialQueueFullError extends Error {
69
+ code = "serial_queue_full";
70
+ constructor(maxDepth) {
71
+ super(`Per-chat admission queue is full (max ${maxDepth} pending).`);
72
+ this.name = "SerialQueueFullError";
73
+ }
74
+ }
75
+ function isSerialQueueFullError(error) {
76
+ return error instanceof SerialQueueFullError;
77
+ }
78
+ /**
79
+ * Minimal per-conversation serial queue: each submitted task runs only after the
80
+ * previous one settles, preserving arrival order. A task's failure does not
81
+ * poison the queue (the chain swallows it; the caller still sees the rejection).
82
+ *
83
+ * The queue is bounded by {@link maxDepth}: once `depth` reaches the cap, `run`
84
+ * rejects synchronously with a {@link SerialQueueFullError} BEFORE incrementing
85
+ * or chaining, so an over-cap task never enters the chain (mirroring the harness
86
+ * LiveSessionManager's maxPendingPerConversation rejection).
87
+ */
88
+ export class SerialQueue {
89
+ tail = Promise.resolve();
90
+ depth = 0;
91
+ maxDepth;
92
+ constructor(maxDepth = DEFAULT_ADMISSION_QUEUE_MAX_DEPTH) {
93
+ this.maxDepth = maxDepth;
94
+ }
95
+ run(task) {
96
+ if (this.depth >= this.maxDepth) {
97
+ return Promise.reject(new SerialQueueFullError(this.maxDepth));
98
+ }
99
+ this.depth += 1;
100
+ const result = this.tail.then(() => task());
101
+ this.tail = result.then(() => undefined, () => undefined);
102
+ void result.then(() => { this.depth -= 1; }, () => { this.depth -= 1; });
103
+ return result;
104
+ }
105
+ /** True when no task is queued or running. */
106
+ get idle() {
107
+ return this.depth === 0;
108
+ }
109
+ }
8
110
  /**
9
111
  * Build a grammY bot that routes authorized text messages to an agent responder.
10
112
  *
@@ -12,10 +114,12 @@ const DEFAULT_INITIAL_STATUS_TEXT = "Thinking…";
12
114
  * Middleware order is: authorization gate β†’ `/start` `/help` `/cancel` commands β†’
13
115
  * agent run handler (`message:text`) β†’ unsupported fallback (other messages).
14
116
  *
15
- * Per-chat concurrency is guarded by an `activeRuns` map: a run is recorded
16
- * synchronously at handler entry, so a second message arriving for the same chat
17
- * mid-run gets a "busy" reply rather than starting a competing run. The long run
18
- * is intentionally NOT sequentialized, which is what makes "busy" reachable.
117
+ * Concurrency is NOT rejected per chat. Every message is handed to the responder,
118
+ * which routes through the runtime harness; the harness serializes per
119
+ * conversation (queue-after-turn follow-ups answered on the warm session). For
120
+ * each in-flight message the bot tracks an `AbortController` in a per-chat set so
121
+ * `/cancel` can abort every live turn for the chat (in addition to clearing
122
+ * queued follow-ups via `responder.cancel`).
19
123
  */
20
124
  export function createTelegramBot(options) {
21
125
  const allowAllChats = options.allowAllChats === true;
@@ -26,10 +130,138 @@ export function createTelegramBot(options) {
26
130
  const messages = { ...DEFAULT_MESSAGES, ...options.messages };
27
131
  const logger = options.logger;
28
132
  const initialStatusText = options.stream?.initialStatusText ?? DEFAULT_INITIAL_STATUS_TEXT;
29
- const activeRuns = new Map();
30
- const bot = options.botFactory?.(options.botToken) ?? new Bot(options.botToken);
133
+ // Per-chat set of AbortControllers for in-flight messages. The runtime harness
134
+ // serializes turns per conversation, so we never reject concurrent messages β€”
135
+ // we only track them so `/cancel` can abort every live turn for the chat.
136
+ const activeControllers = new Map();
137
+ /**
138
+ * Create and register a fresh AbortController for a chat BEFORE the turn is
139
+ * admitted to the per-chat queue. Registering eagerly means a /cancel can abort
140
+ * a message that is still parked behind an earlier run (the controller would
141
+ * otherwise not exist until the queue reached its runAgentTurn).
142
+ */
143
+ function registerController(chatId) {
144
+ const key = String(chatId);
145
+ const controller = new AbortController();
146
+ const controllers = activeControllers.get(key);
147
+ if (controllers === undefined) {
148
+ activeControllers.set(key, new Set([controller]));
149
+ }
150
+ else {
151
+ controllers.add(controller);
152
+ }
153
+ return controller;
154
+ }
155
+ /** Remove a controller from a chat's active set (no-op if already gone). */
156
+ function unregisterController(chatId, controller) {
157
+ const key = String(chatId);
158
+ const set = activeControllers.get(key);
159
+ if (set !== undefined) {
160
+ set.delete(controller);
161
+ if (set.size === 0) {
162
+ activeControllers.delete(key);
163
+ }
164
+ }
165
+ }
166
+ // Per-conversation admission queue. grammY (via @grammyjs/runner) dispatches
167
+ // updates concurrently, and the pre-respond work (status + attachment download)
168
+ // is variable latency, so without this a later text-only message could reach
169
+ // responder.respond() (and the harness FIFO) before an earlier still-downloading
170
+ // media message in the same chat β€” and many same-chat downloads would run
171
+ // unbounded. We serialize runAgentTurn per chat to preserve arrival order and
172
+ // bound concurrent same-chat downloads. /cancel stays out-of-band (it never
173
+ // enters this queue, so a queued turn can never block cancellation).
174
+ const admissionQueues = new Map();
175
+ // Set in stop() so a pending album timer (or a late update) cannot fire a turn
176
+ // after the channel was torn down. Mirrors the slack/whatsapp adapters.
177
+ let stopped = false;
178
+ const albumBuffers = new Map();
179
+ const noopAlbumWork = () => Promise.resolve();
180
+ const albumDelayMs = options.albumAggregationDelayMs ?? DEFAULT_ALBUM_AGGREGATION_DELAY_MS;
181
+ const cancelChat = (chatId) => {
182
+ const conversationId = `telegram:${String(chatId)}`;
183
+ // Fail any pending ask first so a tool blocked on ask_user returns
184
+ // "cancelled by user" instead of waiting out its timeout.
185
+ options.pendingAsks?.cancel(conversationId);
186
+ // Clear queued follow-ups (and signal the harness to abort the in-flight
187
+ // turn) first, then abort every controller we are tracking for this chat.
188
+ options.responder.cancel?.(conversationId);
189
+ const controllers = activeControllers.get(String(chatId));
190
+ if (controllers !== undefined) {
191
+ for (const controller of controllers) {
192
+ controller.abort(new Error("Cancelled by Telegram user."));
193
+ }
194
+ }
195
+ // Drop any album still buffering for this chat so /cancel does not leave it
196
+ // to fire a turn after the user asked to stop. Settle the reserved admission
197
+ // slot with a no-op so the parked admit() task does not hang the per-chat
198
+ // queue. (Its eager controller was just aborted in the loop above.)
199
+ for (const [key, buffer] of albumBuffers) {
200
+ if (key.startsWith(`${String(chatId)}:`)) {
201
+ clearTimeout(buffer.timer);
202
+ albumBuffers.delete(key);
203
+ buffer.ready.resolve(noopAlbumWork);
204
+ unregisterController(chatId, buffer.controller);
205
+ }
206
+ }
207
+ };
208
+ // Cap the Api client's overall HTTP timeout so a half-open getUpdates socket
209
+ // fails in ~50s instead of grammY's 500s default β€” see DEFAULT_API_TIMEOUT_SECONDS.
210
+ // The botFactory test seam owns full Bot construction, so the cap (and the
211
+ // optional IPv4/IPv6 transport pin + apiRoot) is applied only on the default path.
212
+ const { client: clientOptions, agent: transportAgent } = buildTelegramBotClientOptions({
213
+ ...(options.apiRoot === undefined ? {} : { apiRoot: options.apiRoot }),
214
+ ...(options.transport?.ipFamily === undefined ? {} : { ipFamily: options.transport.ipFamily }),
215
+ });
216
+ const bot = options.botFactory?.(options.botToken) ??
217
+ new Bot(options.botToken, { client: clientOptions });
218
+ // Poll-liveness heartbeat: stamp the time each getUpdates call RESOLVED. The
219
+ // watchdog uses this to detect a runner that has gone silently deaf (connected
220
+ // but no longer delivering) and force-restart it. Installed last so it is the
221
+ // OUTERMOST transformer (grammY runs the most-recently-installed first), so it
222
+ // observes every getUpdates resolution even beneath a test-injected transformer.
223
+ let lastPollMs = Date.now();
224
+ bot.api.config.use(async (prev, method, payload, signal) => {
225
+ const result = await prev(method, payload, signal);
226
+ if (method === "getUpdates") {
227
+ lastPollMs = Date.now();
228
+ }
229
+ return result;
230
+ });
31
231
  const sender = createGrammyTelegramApi(bot.api);
232
+ const fileDownloader = options.fileDownloaderFactory?.(bot, options.botToken) ??
233
+ createDefaultFileDownloader(bot, options.botToken, {
234
+ ...(options.attachments?.downloadTimeoutMs !== undefined
235
+ ? { downloadTimeoutMs: options.attachments.downloadTimeoutMs }
236
+ : {}),
237
+ ...(options.apiRoot === undefined ? {} : { apiRoot: options.apiRoot }),
238
+ ...(logger !== undefined ? { logger } : {}),
239
+ });
32
240
  const isAuthorized = (chatId) => chatId !== undefined && (allowAllChats || allowedChatIds.has(String(chatId)));
241
+ const reactions = options.reactions;
242
+ /**
243
+ * Set (or clear, when `emoji` is undefined) the bot's reaction on a message.
244
+ * Per-state gating is the caller's job; this only no-ops when the sender lacks
245
+ * the method, and swallows a failure (e.g. missing permission) so it never
246
+ * affects the turn. Telegram constrains reactions to a fixed emoji set.
247
+ */
248
+ async function applyReaction(chatId, messageId, emoji) {
249
+ if (sender.setMessageReaction === undefined) {
250
+ return;
251
+ }
252
+ try {
253
+ await sender.setMessageReaction({
254
+ chat_id: chatId,
255
+ message_id: messageId,
256
+ reaction: emoji === undefined ? [] : [{ type: "emoji", emoji }],
257
+ });
258
+ }
259
+ catch (error) {
260
+ logger?.debug?.("Telegram setMessageReaction failed (best-effort).", {
261
+ error: errorMessage(error),
262
+ });
263
+ }
264
+ }
33
265
  bot.use(async (ctx, next) => {
34
266
  const chatId = ctx.chat?.id;
35
267
  if (chatId === undefined) {
@@ -50,40 +282,336 @@ export function createTelegramBot(options) {
50
282
  bot.command("cancel", async (ctx) => {
51
283
  const chatId = ctx.chat?.id;
52
284
  if (chatId !== undefined) {
53
- activeRuns.get(String(chatId))?.controller.abort(new Error("Cancelled by Telegram user."));
285
+ cancelChat(chatId);
54
286
  }
55
287
  await ctx.reply(messages.cancelledText);
56
288
  });
57
- bot.on("message:text", async (ctx) => {
58
- await handleAgentMessage(ctx);
59
- });
289
+ // Custom config-driven commands. Registered before the catch-all `message`
290
+ // handler so a `/cmd` is dispatched here (and never falls through to the agent
291
+ // as raw text). A command with a `prompt` runs it as a turn through the same
292
+ // per-chat queue as a typed message; a prompt-less command is menu-only and
293
+ // just echoes its description. Built-in start/help/cancel are reserved (config
294
+ // validation rejects them), so these never shadow a built-in.
295
+ const customCommands = options.commands ?? [];
296
+ for (const command of customCommands) {
297
+ const prompt = command.prompt;
298
+ bot.command(command.command, async (ctx) => {
299
+ const chatId = ctx.chat?.id;
300
+ if (chatId === undefined) {
301
+ return;
302
+ }
303
+ if (prompt === undefined) {
304
+ await ctx.reply(command.description);
305
+ return;
306
+ }
307
+ await notify(chatId, prompt);
308
+ });
309
+ }
310
+ // Inline-keyboard callbacks (telegram_ask). Subscribed only when enabled (the
311
+ // default `allowedUpdates` then also includes `callback_query`). The auth gate
312
+ // above already blocks unauthorized chats; we re-check defensively. On a tap we
313
+ // resolve the chosen LABEL from the tapped message's own keyboard (so no
314
+ // cross-process state is needed), answer the callback promptly, strip the
315
+ // buttons, and re-run the choice as a turn on the same conversation β€” exactly
316
+ // like a typed reply, on the warm session.
317
+ const callbacksEnabled = options.callbacksEnabled === true;
318
+ const answeredCallbacks = new Set();
319
+ const rememberAnswered = (key) => {
320
+ answeredCallbacks.add(key);
321
+ if (answeredCallbacks.size > CALLBACK_DEDUPE_MAX) {
322
+ const oldest = answeredCallbacks.values().next().value;
323
+ if (oldest !== undefined) {
324
+ answeredCallbacks.delete(oldest);
325
+ }
326
+ }
327
+ };
328
+ async function answerCallbackQuietly(ctx, text) {
329
+ try {
330
+ await ctx.answerCallbackQuery(text === undefined ? undefined : { text });
331
+ }
332
+ catch (error) {
333
+ logger?.debug?.("Telegram answerCallbackQuery failed (best-effort).", {
334
+ error: errorMessage(error),
335
+ });
336
+ }
337
+ }
338
+ if (callbacksEnabled) {
339
+ bot.on("callback_query:data", async (ctx) => {
340
+ if (stopped) {
341
+ return;
342
+ }
343
+ const chatId = ctx.chat?.id;
344
+ const data = ctx.callbackQuery.data;
345
+ if (chatId === undefined || !isAuthorized(chatId) || !isTelegramAskCallbackData(data)) {
346
+ await answerCallbackQuietly(ctx);
347
+ return;
348
+ }
349
+ const messageId = ctx.callbackQuery.message?.message_id;
350
+ const dedupeKey = `${String(chatId)}:${messageId ?? "?"}`;
351
+ if (answeredCallbacks.has(dedupeKey)) {
352
+ await answerCallbackQuietly(ctx, "Already recorded.");
353
+ return;
354
+ }
355
+ // Claim synchronously (no await before this) so a near-simultaneous second
356
+ // tap on the same question de-dupes instead of running a second turn.
357
+ rememberAnswered(dedupeKey);
358
+ const label = labelForCallbackData(ctx.callbackQuery.message?.reply_markup, data);
359
+ await answerCallbackQuietly(ctx, label === undefined ? undefined : `You chose: ${label}`);
360
+ if (label === undefined) {
361
+ return;
362
+ }
363
+ // Strip the keyboard so the question cannot be answered twice (best-effort).
364
+ try {
365
+ await ctx.editMessageReplyMarkup();
366
+ }
367
+ catch (error) {
368
+ logger?.debug?.("Telegram editMessageReplyMarkup failed after callback (best-effort).", {
369
+ error: errorMessage(error),
370
+ });
371
+ }
372
+ const question = ctx.callbackQuery.message?.text;
373
+ const syntheticText = question !== undefined && question.trim().length > 0
374
+ ? `Re: "${question.trim()}" β€” I chose: ${label}`
375
+ : `I chose: ${label}`;
376
+ await notify(chatId, syntheticText);
377
+ });
378
+ }
379
+ // A single handler for every message type so all messages reach the per-chat
380
+ // admission queue at the same middleware depth. Separate per-type handlers sit
381
+ // at different filter positions, and grammY yields a microtask per non-matching
382
+ // filter β€” so a later text message (matched by the first `message:text` filter)
383
+ // could overtake an earlier document/photo (filtered one step further) before
384
+ // admission, breaking arrival order. handleAgentMessage routes supported types
385
+ // and replies unsupportedText for the rest (normalizeTelegramMessageInput
386
+ // returns undefined), so a single `message` handler covers both cases.
60
387
  bot.on("message", async (ctx) => {
61
- await ctx.reply(messages.unsupportedText);
388
+ await handleAgentMessage(ctx);
62
389
  });
390
+ /**
391
+ * Run a turn through the per-chat admission queue so same-chat turns serialize
392
+ * (preserving harness FIFO arrival order and bounding concurrent same-chat
393
+ * downloads). Cross-chat concurrency is preserved because queues are keyed per
394
+ * chat id (the same key /cancel uses for activeControllers).
395
+ *
396
+ * The queue is bounded: a same-chat flood past the depth cap is rejected by
397
+ * SerialQueue.run BEFORE the task enters the chain, so the over-cap message is
398
+ * never admitted. On that rejected path the task body never runs β€” its eager
399
+ * controller (and, for an album, its reserved slot) never reach the cleanup in
400
+ * runAgentTurn/flushAlbum β€” so the caller supplies an `onReject` callback to
401
+ * settle/unregister those eagerly-created resources, after which we reply with
402
+ * the busy terminal instead of admitting an unbounded backlog.
403
+ */
404
+ async function admit(chatId, task, onReject) {
405
+ const key = String(chatId);
406
+ let queue = admissionQueues.get(key);
407
+ if (queue === undefined) {
408
+ queue = new SerialQueue();
409
+ admissionQueues.set(key, queue);
410
+ }
411
+ try {
412
+ await queue.run(task);
413
+ }
414
+ catch (error) {
415
+ if (isSerialQueueFullError(error)) {
416
+ await onReject?.();
417
+ return;
418
+ }
419
+ throw error;
420
+ }
421
+ finally {
422
+ if (queue.idle && admissionQueues.get(key) === queue) {
423
+ admissionQueues.delete(key);
424
+ }
425
+ }
426
+ }
63
427
  async function handleAgentMessage(ctx) {
428
+ if (stopped) {
429
+ return;
430
+ }
64
431
  const message = ctx.message;
65
432
  const chatId = ctx.chat?.id;
66
433
  if (message === undefined || chatId === undefined) {
67
434
  return;
68
435
  }
69
- const text = (message.text ?? "").trim();
70
- if (text.length === 0) {
436
+ const telegramMessage = message;
437
+ // A multi-photo/video album arrives as several messages sharing a
438
+ // media_group_id; buffer them and flush once so the agent sees one request
439
+ // with every attachment instead of N single-attachment turns.
440
+ const groupId = telegramMessage.media_group_id;
441
+ if (typeof groupId === "string" && groupId.length > 0) {
442
+ bufferAlbumMessage(ctx, chatId, groupId, telegramMessage);
443
+ return;
444
+ }
445
+ const captionCommand = controlCommandFromCaption(telegramMessage, ctx.me.username);
446
+ if (captionCommand !== undefined) {
447
+ await handleControlCommand(ctx, captionCommand);
448
+ return;
449
+ }
450
+ // A plain-text reply while an ask is pending is that ask's ANSWER. It must be
451
+ // consumed BEFORE admission: the asking turn holds this chat's queue slot, so
452
+ // queueing the reply as a turn would deadlock it behind the very tool call
453
+ // waiting for it. Media always passes through, and slash-prefixed text is
454
+ // left to the (unknown-)command path so an ask can never eat a command.
455
+ if (options.pendingAsks !== undefined &&
456
+ typeof telegramMessage.text === "string" &&
457
+ telegramMessage.text.trim().length > 0 &&
458
+ !telegramMessage.text.trimStart().startsWith("/")) {
459
+ const consumed = await options.pendingAsks.tryResolve(`telegram:${String(chatId)}`, telegramMessage.text);
460
+ if (consumed) {
461
+ await applyReaction(chatId, telegramMessage.message_id, "πŸ‘");
462
+ return;
463
+ }
464
+ }
465
+ const input = normalizeTelegramMessageInput(telegramMessage);
466
+ if (input === undefined) {
71
467
  await ctx.reply(messages.unsupportedText);
72
468
  return;
73
469
  }
74
- const key = String(chatId);
75
- if (activeRuns.has(key)) {
470
+ // Register the controller before admission so /cancel can abort this message
471
+ // even while it is still parked behind an earlier same-chat run.
472
+ const controller = registerController(chatId);
473
+ await admit(chatId, () => runAgentTurn(ctx, telegramMessage, input, controller),
474
+ // Over-cap: the task was rejected before entering the queue, so runAgentTurn
475
+ // (and its finally) never ran. Unregister the eagerly created controller so
476
+ // it does not leak in activeControllers, then reply with the busy terminal.
477
+ async () => {
478
+ unregisterController(chatId, controller);
76
479
  await ctx.reply(messages.busyText);
480
+ });
481
+ }
482
+ function bufferAlbumMessage(ctx, chatId, groupId, message) {
483
+ const key = `${String(chatId)}:${groupId}`;
484
+ const schedule = () => {
485
+ const timer = setTimeout(() => {
486
+ void flushAlbum(key);
487
+ }, albumDelayMs);
488
+ timer.unref?.();
489
+ return timer;
490
+ };
491
+ const existing = albumBuffers.get(key);
492
+ if (existing === undefined) {
493
+ // First part of a new album: reserve a per-chat admission slot NOW so a
494
+ // later same-chat message cannot overtake the album. Register the eager
495
+ // controller (so /cancel can abort a parked album) and admit a task that
496
+ // blocks on `ready.promise` β€” flushAlbum settles it with the real work
497
+ // after the quiet window. The slot blocks only on the album timer, which
498
+ // fires independently of queue progress, so there is no deadlock.
499
+ const controller = registerController(chatId);
500
+ const ready = createDeferred();
501
+ const timer = schedule();
502
+ albumBuffers.set(key, { ctx, messages: [message], timer, controller, ready });
503
+ void admit(chatId, async () => {
504
+ const work = await ready.promise;
505
+ await work();
506
+ },
507
+ // Over-cap: the reserved album slot was rejected before entering the
508
+ // queue, so flushAlbum's later run/settle never executes for it. Drop the
509
+ // buffered album (clear its timer, remove it, settle the deferred), and
510
+ // unregister the eager controller so it does not leak; then reply busy.
511
+ async () => {
512
+ const buffered = albumBuffers.get(key);
513
+ if (buffered !== undefined) {
514
+ clearTimeout(buffered.timer);
515
+ albumBuffers.delete(key);
516
+ buffered.ready.resolve(noopAlbumWork);
517
+ }
518
+ else {
519
+ ready.resolve(noopAlbumWork);
520
+ }
521
+ unregisterController(chatId, controller);
522
+ await ctx.reply(messages.busyText);
523
+ });
77
524
  return;
78
525
  }
79
- // Record the run synchronously, before any await, so a concurrent message for
80
- // the same chat observes it and gets the "busy" reply above.
81
- const controller = new AbortController();
82
- const activeRun = { controller };
83
- activeRuns.set(key, activeRun);
84
- const request = buildAgentRequest(ctx.update, message, text, controller.signal);
526
+ existing.messages.push(message);
527
+ clearTimeout(existing.timer);
528
+ existing.timer = schedule();
529
+ }
530
+ async function flushAlbum(key) {
531
+ const buffer = albumBuffers.get(key);
532
+ if (buffer === undefined) {
533
+ return;
534
+ }
535
+ albumBuffers.delete(key);
536
+ const { ctx, messages: parts, controller, ready } = buffer;
537
+ const albumChatId = ctx.chat?.id;
538
+ // The reserved admission slot is parked on `ready.promise`. EVERY exit below
539
+ // must settle it (with real work or a no-op) or the per-chat queue hangs
540
+ // forever. On a no-op exit, the eager controller never reaches runAgentTurn's
541
+ // finally, so unregister it here to avoid leaking it in activeControllers; on
542
+ // the run path it is reused (do not re-register) and runAgentTurn owns cleanup.
543
+ const settleAsNoop = () => {
544
+ ready.resolve(noopAlbumWork);
545
+ if (albumChatId !== undefined) {
546
+ unregisterController(albumChatId, controller);
547
+ }
548
+ };
549
+ if (stopped) {
550
+ settleAsNoop();
551
+ return;
552
+ }
553
+ // A control command in any album caption controls the chat. The album itself
554
+ // does not run, so settle the reserved slot with a no-op.
555
+ for (const part of parts) {
556
+ const command = controlCommandFromCaption(part, ctx.me.username);
557
+ if (command !== undefined) {
558
+ settleAsNoop();
559
+ await handleControlCommand(ctx, command);
560
+ return;
561
+ }
562
+ }
563
+ const primary = parts[0];
564
+ const input = mergeTelegramMessageInputs(parts);
565
+ if (primary === undefined || input === undefined) {
566
+ settleAsNoop();
567
+ await ctx.reply(messages.unsupportedText);
568
+ return;
569
+ }
570
+ // Fill the reserved slot with the real run so the album executes in its
571
+ // arrival-order position (a later same-chat text admitted after this album
572
+ // started buffering lands behind this slot).
573
+ ready.resolve(() => runAgentTurn(ctx, primary, input, controller));
574
+ }
575
+ async function runAgentTurn(ctx, message, input, controller) {
576
+ const chatId = message.chat.id;
577
+ // The AbortController is created and registered in activeControllers by the
578
+ // caller BEFORE admission, so a /cancel can abort a message still parked in the
579
+ // per-chat queue (the controller would otherwise not exist until the queue
580
+ // reached this run). This function owns unregistering it in the finally below.
581
+ // Download attachment bytes (best-effort) before handing the request to the
582
+ // responder. Failures skip the attachment; the run proceeds regardless. The
583
+ // download is tied to this message's abort signal.
584
+ let resolvedAttachments = [];
585
+ if (input.attachments.length > 0 && !controller.signal.aborted) {
586
+ const downloadOptions = {
587
+ ...options.attachments,
588
+ ...(logger !== undefined ? { logger } : {}),
589
+ };
590
+ resolvedAttachments = await downloadTelegramAttachments(input.attachments, fileDownloader, controller.signal, downloadOptions);
591
+ }
592
+ const request = buildAgentRequest(ctx.update, message, input, controller.signal, resolvedAttachments);
85
593
  const stream = new TelegramMessageStream(buildStreamOptions(chatId, message.message_id, controller.signal));
594
+ // Tracks the lifecycle reaction to apply on teardown. Defaults to "error" so
595
+ // an unexpected throw still lands on the πŸ‘Ž reaction.
596
+ let reactionOutcome = "error";
597
+ // Whether we set the working πŸ‘€, so a terminal state with its own reaction
598
+ // disabled can CLEAR it rather than leave it lingering on the message.
599
+ let workingReacted = false;
86
600
  try {
601
+ // Parked-then-cancelled: /cancel aborted this controller while the message
602
+ // waited behind an earlier same-chat run. Bail before any responder call so a
603
+ // queued message is genuinely cancelled (not run on the warm session later).
604
+ if (controller.signal.aborted) {
605
+ reactionOutcome = "cancelled";
606
+ await finishSafely(stream, messages.cancelledText, logger);
607
+ return;
608
+ }
609
+ // Acknowledge receipt with the working reaction before the (slower) status
610
+ // post + agent run, so the user sees the bot picked up the message at once.
611
+ if (reactions?.working === true) {
612
+ await applyReaction(chatId, message.message_id, REACTION_WORKING);
613
+ workingReacted = true;
614
+ }
87
615
  try {
88
616
  await stream.status(initialStatusText);
89
617
  }
@@ -93,6 +621,7 @@ export function createTelegramBot(options) {
93
621
  });
94
622
  }
95
623
  if (controller.signal.aborted) {
624
+ reactionOutcome = "cancelled";
96
625
  await finishSafely(stream, messages.cancelledText, logger);
97
626
  return;
98
627
  }
@@ -102,6 +631,7 @@ export function createTelegramBot(options) {
102
631
  }
103
632
  catch (error) {
104
633
  if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
634
+ reactionOutcome = "cancelled";
105
635
  await finishSafely(stream, messages.cancelledText, logger);
106
636
  return;
107
637
  }
@@ -116,14 +646,17 @@ export function createTelegramBot(options) {
116
646
  return;
117
647
  }
118
648
  if (controller.signal.aborted) {
649
+ reactionOutcome = "cancelled";
119
650
  await finishSafely(stream, messages.cancelledText, logger);
120
651
  return;
121
652
  }
122
653
  try {
123
654
  await stream.finish(response.text);
655
+ reactionOutcome = "done";
124
656
  }
125
657
  catch (error) {
126
658
  if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
659
+ reactionOutcome = "cancelled";
127
660
  return;
128
661
  }
129
662
  // The AI run succeeded; a delivery failure is degraded, never an error.
@@ -133,18 +666,189 @@ export function createTelegramBot(options) {
133
666
  }
134
667
  }
135
668
  finally {
136
- if (activeRuns.get(key) === activeRun) {
137
- activeRuns.delete(key);
669
+ // Apply the terminal reaction: πŸ‘ on success / πŸ‘Ž on failure when that state
670
+ // is enabled; otherwise (or on cancel) clear the working πŸ‘€ if we set one, so
671
+ // a disabled terminal state never leaves the message marked "working".
672
+ if (reactions !== undefined) {
673
+ const terminalEnabled = reactionOutcome === "done"
674
+ ? reactions.done
675
+ : reactionOutcome === "error"
676
+ ? reactions.error
677
+ : false;
678
+ if (terminalEnabled) {
679
+ await applyReaction(chatId, message.message_id, reactionOutcome === "done" ? REACTION_DONE : REACTION_ERROR);
680
+ }
681
+ else if (workingReacted) {
682
+ await applyReaction(chatId, message.message_id, undefined);
683
+ }
684
+ }
685
+ unregisterController(chatId, controller);
686
+ }
687
+ }
688
+ /**
689
+ * Run a proactive (externally triggered) turn on a chat: a cron/webhook nudge
690
+ * routed here so the message becomes a REAL turn on this chat's own harness
691
+ * (same session + history + per-chat queue as inbound messages), delivered
692
+ * through the normal stream. No inbound message, so the request carries
693
+ * sentinel ids and the stream posts top-level (no reply-to). Best-effort: a
694
+ * failed or empty turn posts nothing rather than an unprompted error.
695
+ */
696
+ async function runProactiveTurn(chatId, text, controller, silent) {
697
+ try {
698
+ if (controller.signal.aborted) {
699
+ return { delivered: false, reason: "cancelled" };
700
+ }
701
+ const request = {
702
+ conversationId: `telegram:${String(chatId)}`,
703
+ chatId,
704
+ messageId: 0,
705
+ updateId: 0,
706
+ text,
707
+ abortSignal: controller.signal,
708
+ metadata: {
709
+ telegram: {
710
+ updateId: 0,
711
+ chat: { id: chatId },
712
+ message: { id: 0 },
713
+ },
714
+ },
715
+ };
716
+ const stream = new TelegramMessageStream(buildStreamOptions(chatId, undefined, controller.signal, silent));
717
+ let response;
718
+ try {
719
+ response = await options.responder.respond(request, stream);
720
+ }
721
+ catch (error) {
722
+ if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
723
+ return { delivered: false, reason: "cancelled" };
724
+ }
725
+ logger?.error?.("Telegram proactive notify failed.", { error: errorMessage(error) });
726
+ return { delivered: false, reason: "responder failed" };
727
+ }
728
+ const answer = response.text;
729
+ if (controller.signal.aborted) {
730
+ return { delivered: false, reason: "cancelled" };
138
731
  }
732
+ if (answer === undefined || answer.trim().length === 0) {
733
+ return { delivered: false, reason: "agent produced no answer" };
734
+ }
735
+ try {
736
+ await stream.finish(answer);
737
+ }
738
+ catch (error) {
739
+ if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
740
+ return { delivered: false, reason: "cancelled" };
741
+ }
742
+ // The AI run succeeded; a delivery failure is degraded, never an error.
743
+ logger?.error?.("Telegram proactive delivery failed after a successful AI run.", {
744
+ error: errorMessage(error),
745
+ });
746
+ return { delivered: false, reason: "delivery failed" };
747
+ }
748
+ return { delivered: true };
749
+ }
750
+ finally {
751
+ unregisterController(chatId, controller);
139
752
  }
140
753
  }
141
- function buildStreamOptions(chatId, replyToMessageId, signal) {
754
+ /**
755
+ * Deliver `text` VERBATIM to `chatId`: post it unchanged through the normal
756
+ * stream with NO model call (the producing cron/webhook run already wrote the
757
+ * message), then record it to the chat's durable history via the responder so a
758
+ * later reply resumes with it in context. Serialized through the per-chat queue
759
+ * by {@link notify}. Best-effort: a history-record failure never fails an
760
+ * already-delivered post.
761
+ */
762
+ async function runVerbatimDelivery(chatId, text, controller, silent) {
763
+ try {
764
+ if (controller.signal.aborted) {
765
+ return { delivered: false, reason: "cancelled" };
766
+ }
767
+ if (text.trim().length === 0) {
768
+ return { delivered: false, reason: "empty notification" };
769
+ }
770
+ const stream = new TelegramMessageStream(buildStreamOptions(chatId, undefined, controller.signal, silent));
771
+ try {
772
+ await stream.finish(text);
773
+ }
774
+ catch (error) {
775
+ if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
776
+ return { delivered: false, reason: "cancelled" };
777
+ }
778
+ logger?.error?.("Telegram verbatim notify delivery failed.", { error: errorMessage(error) });
779
+ return { delivered: false, reason: "delivery failed" };
780
+ }
781
+ try {
782
+ await options.responder.deliverVerbatim?.(`telegram:${String(chatId)}`, text);
783
+ }
784
+ catch (error) {
785
+ logger?.warn?.("Telegram verbatim notify history record failed.", { error: errorMessage(error) });
786
+ }
787
+ return { delivered: true };
788
+ }
789
+ finally {
790
+ unregisterController(chatId, controller);
791
+ }
792
+ }
793
+ /**
794
+ * Deliver a proactive notification to `chatId` by running it as a turn on this
795
+ * chat through the same per-chat admission queue as inbound messages, so it
796
+ * serializes with live traffic on the same conversation.
797
+ */
798
+ async function notify(chatId, text, notifyOptions) {
799
+ if (stopped) {
800
+ return { delivered: false, reason: "adapter stopped" };
801
+ }
802
+ const controller = registerController(chatId);
803
+ const silent = notifyOptions?.silent === true;
804
+ // `admit` returns void, so capture the run's outcome in a closure variable.
805
+ // It defaults to the queue-full reason and is only overwritten when the task
806
+ // actually runs (an over-cap rejection settles via onReject, leaving it).
807
+ let outcome = { delivered: false, reason: "chat at concurrency cap" };
808
+ await admit(chatId, async () => {
809
+ outcome = notifyOptions?.verbatim === true
810
+ ? await runVerbatimDelivery(chatId, text, controller, silent)
811
+ : await runProactiveTurn(chatId, text, controller, silent);
812
+ }, () => {
813
+ unregisterController(chatId, controller);
814
+ logger?.warn?.("Telegram proactive notify dropped: chat is at its concurrency cap.", {
815
+ chatId: String(chatId),
816
+ });
817
+ });
818
+ return outcome;
819
+ }
820
+ async function handleControlCommand(ctx, command) {
821
+ if (command === "start") {
822
+ await ctx.reply(messages.welcomeText);
823
+ return;
824
+ }
825
+ if (command === "help") {
826
+ await ctx.reply(messages.helpText);
827
+ return;
828
+ }
829
+ const chatId = ctx.chat?.id;
830
+ if (chatId !== undefined) {
831
+ cancelChat(chatId);
832
+ }
833
+ await ctx.reply(messages.cancelledText);
834
+ }
835
+ function buildStreamOptions(chatId, replyToMessageId, signal, silent = false) {
142
836
  const streamOptions = {
143
837
  api: sender,
144
838
  chatId,
145
- replyToMessageId,
146
839
  abortSignal: signal,
840
+ // Default to "typing…" + final-answer-only delivery (no streamed interim
841
+ // edits); a tuning override can restore interim streaming.
842
+ finalOnly: options.stream?.finalOnly ?? true,
147
843
  };
844
+ if (silent) {
845
+ streamOptions.silent = true;
846
+ }
847
+ // Proactive notifications have no inbound message to reply to, so the caller
848
+ // may omit replyToMessageId (a top-level send rather than a threaded reply).
849
+ if (replyToMessageId !== undefined) {
850
+ streamOptions.replyToMessageId = replyToMessageId;
851
+ }
148
852
  const tuning = options.stream;
149
853
  if (tuning?.initialStatusText !== undefined) {
150
854
  streamOptions.initialStatusText = tuning.initialStatusText;
@@ -167,6 +871,9 @@ export function createTelegramBot(options) {
167
871
  if (tuning?.showThoughts !== undefined) {
168
872
  streamOptions.showThoughts = tuning.showThoughts;
169
873
  }
874
+ if (tuning?.showHints !== undefined) {
875
+ streamOptions.showHints = tuning.showHints;
876
+ }
170
877
  if (tuning?.formatMarkdown !== undefined) {
171
878
  streamOptions.formatMarkdown = tuning.formatMarkdown;
172
879
  }
@@ -176,39 +883,584 @@ export function createTelegramBot(options) {
176
883
  return streamOptions;
177
884
  }
178
885
  let runnerHandle;
886
+ // Pending auto-restart timer (set while backing off after a polling crash).
887
+ // Cleared by stop() and before each restart so at most one restart is queued.
888
+ let restartTimer;
889
+ // Fires once a restarted runner has stayed up for the stability window, at
890
+ // which point the backoff resets to the initial delay. Cleared on the next
891
+ // crash/restart and on stop().
892
+ let stabilityTimer;
893
+ // Current restart backoff (ms). Doubles on each consecutive crash that recurs
894
+ // before the stability window; resets to the initial delay on a clean restart.
895
+ let restartBackoffMs = DEFAULT_RESTART_INITIAL_BACKOFF_MS;
896
+ // Poll-liveness watchdog. Lifetime-scoped (armed at first spawn, cleared on
897
+ // stop()) so it spans crash/backoff/restart cycles. `pollWatchdogMs <= 0`
898
+ // disables it. `watchdogRestarting` prevents a second tick from stacking
899
+ // another stop()/spawn while a forced restart is still settling.
900
+ const pollWatchdogMs = options.pollWatchdogMs ?? DEFAULT_POLL_WATCHDOG_MS;
901
+ let pollWatchdogTimer;
902
+ let watchdogRestarting = false;
903
+ // True once polling has crashed and not yet recovered. Gates onPollingRecovered
904
+ // so it fires only after a real crash→recovery cycle, never on the initial start.
905
+ let pollingDegraded = false;
906
+ /** Arm the lifetime-scoped poll-liveness watchdog (idempotent, no-op if disabled). */
907
+ function startPollWatchdog() {
908
+ if (pollWatchdogMs <= 0 || pollWatchdogTimer !== undefined) {
909
+ return;
910
+ }
911
+ // Check a few times per window so a stall is caught within ~1/3 of it.
912
+ const checkMs = Math.max(1_000, Math.floor(pollWatchdogMs / 3));
913
+ pollWatchdogTimer = setInterval(() => {
914
+ checkPollLiveness();
915
+ }, checkMs);
916
+ pollWatchdogTimer.unref?.();
917
+ }
918
+ function clearPollWatchdog() {
919
+ if (pollWatchdogTimer !== undefined) {
920
+ clearInterval(pollWatchdogTimer);
921
+ pollWatchdogTimer = undefined;
922
+ }
923
+ }
924
+ /**
925
+ * Force-restart the current runner if no getUpdates has resolved within the
926
+ * watchdog window. This covers the case the crash monitor cannot: grammY's
927
+ * runner self-retries getUpdates internally, so a degraded connection can stop
928
+ * delivering updates WITHOUT the task rejecting. We only act on a runner that
929
+ * reports running (a crashed/stopped one is handled by the crash monitor), and
930
+ * we go through a clean stop() + respawn so the crash monitor is not tripped.
931
+ */
932
+ function checkPollLiveness() {
933
+ if (stopped || watchdogRestarting) {
934
+ return;
935
+ }
936
+ const current = runnerHandle;
937
+ if (current?.isRunning() !== true) {
938
+ return;
939
+ }
940
+ const stalledMs = Date.now() - lastPollMs;
941
+ if (stalledMs <= pollWatchdogMs) {
942
+ return;
943
+ }
944
+ watchdogRestarting = true;
945
+ // Reset the window up front so the replacement runner gets a full grace
946
+ // period and a slow stop() cannot let a later tick re-trigger.
947
+ lastPollMs = Date.now();
948
+ logger?.warn?.("Telegram poll liveness stalled; force-restarting the runner.", {
949
+ stalledMs,
950
+ thresholdMs: pollWatchdogMs,
951
+ });
952
+ void Promise.resolve(current.stop())
953
+ .catch(() => undefined)
954
+ .finally(() => {
955
+ watchdogRestarting = false;
956
+ // Only respawn if nothing else swapped/stopped the runner meanwhile. A
957
+ // watchdog restart is a clean recovery, so reset the backoff (mirrors a
958
+ // stable restart) rather than inheriting the crash backoff.
959
+ if (!stopped && runnerHandle === current) {
960
+ restartBackoffMs = DEFAULT_RESTART_INITIAL_BACKOFF_MS;
961
+ spawnRunnerWithMonitor();
962
+ }
963
+ });
964
+ }
965
+ /**
966
+ * Spawn a runner and attach the crash monitor. The runner's task rejects when
967
+ * long polling dies (e.g. getUpdates ETIMEDOUT/EADDRNOTAVAIL after a network
968
+ * blip or host sleep). Without auto-restart the runner just stops and the bot
969
+ * goes silent until a full process restart β€” so on a crash (while not stopped)
970
+ * we recreate the runner via the factory and re-attach the monitor, with
971
+ * exponential backoff. Mirrors slack-adapter's socket-mode reconnect loop.
972
+ */
973
+ function spawnRunnerWithMonitor() {
974
+ if (stabilityTimer !== undefined) {
975
+ clearTimeout(stabilityTimer);
976
+ stabilityTimer = undefined;
977
+ }
978
+ // Give the (re)spawned runner a full watchdog window before it can be judged
979
+ // stalled, and ensure the lifetime-scoped watchdog is armed.
980
+ lastPollMs = Date.now();
981
+ startPollWatchdog();
982
+ runnerHandle = (options.runnerFactory ?? defaultRunnerFactory)(bot);
983
+ const spawned = runnerHandle;
984
+ // A runner that stays up for the stability window counts as a clean restart:
985
+ // reset the backoff so a LATER, unrelated crash starts from the initial delay
986
+ // again. A runner that crashes before this window keeps the grown backoff so
987
+ // a flapping connection is not hammered.
988
+ stabilityTimer = setTimeout(() => {
989
+ stabilityTimer = undefined;
990
+ if (!stopped && runnerHandle === spawned) {
991
+ restartBackoffMs = DEFAULT_RESTART_INITIAL_BACKOFF_MS;
992
+ // This runner stayed up past the stability window. If it followed a crash,
993
+ // polling has recovered β€” tell the host so it can clear a "degraded" state.
994
+ if (pollingDegraded) {
995
+ pollingDegraded = false;
996
+ options.onPollingRecovered?.();
997
+ }
998
+ }
999
+ }, DEFAULT_RESTART_STABILITY_MS);
1000
+ stabilityTimer.unref?.();
1001
+ // Only a REJECTION is a crash: grammY's runner task rejects when long polling
1002
+ // dies (getUpdates ETIMEDOUT/EADDRNOTAVAIL). A clean resolution means the
1003
+ // runner was stopped deliberately (stop() / a host-driven stop), so it is NOT
1004
+ // auto-restarted β€” matching the original .catch-only handling.
1005
+ runnerHandle.task?.()?.catch((error) => { onPollingCrashed(error); });
1006
+ }
1007
+ /**
1008
+ * Handle a runner task REJECTION: long polling crashed. If the adapter is
1009
+ * stopped this is the expected teardown path (no-op). Otherwise surface it
1010
+ * (logger + onPollingError) and schedule a backoff restart so the bot recovers
1011
+ * instead of going silent until a full process restart.
1012
+ */
1013
+ function onPollingCrashed(error) {
1014
+ // The runner is no longer up, so cancel the pending stability reset: the
1015
+ // backoff must keep growing if this crash recurs before a runner stays up.
1016
+ if (stabilityTimer !== undefined) {
1017
+ clearTimeout(stabilityTimer);
1018
+ stabilityTimer = undefined;
1019
+ }
1020
+ if (stopped) {
1021
+ return;
1022
+ }
1023
+ if (!pollingDegraded) {
1024
+ logger?.error?.("Telegram polling stopped with an error; scheduling restart.", {
1025
+ error: errorMessage(error),
1026
+ restartInMs: restartBackoffMs,
1027
+ });
1028
+ // Mark degraded so the stability-window callback fires onPollingRecovered once a
1029
+ // restarted runner stays up. The adapter always restarts (capped backoff), so a
1030
+ // crash is "degraded, recovering" to the host β€” never terminal.
1031
+ pollingDegraded = true;
1032
+ }
1033
+ options.onPollingError?.(error);
1034
+ scheduleRestart();
1035
+ }
1036
+ /** Schedule a single backoff restart, growing the backoff for the next attempt. */
1037
+ function scheduleRestart() {
1038
+ if (restartTimer !== undefined) {
1039
+ return;
1040
+ }
1041
+ const delay = restartBackoffMs;
1042
+ // Grow the backoff now so a restart that itself crashes before resetting (via
1043
+ // a healthy spawn) backs off further next time.
1044
+ restartBackoffMs = Math.min(DEFAULT_RESTART_MAX_BACKOFF_MS, restartBackoffMs * 2);
1045
+ restartTimer = setTimeout(() => {
1046
+ restartTimer = undefined;
1047
+ if (stopped) {
1048
+ return;
1049
+ }
1050
+ try {
1051
+ spawnRunnerWithMonitor();
1052
+ }
1053
+ catch (error) {
1054
+ // The factory threw synchronously (e.g. transient construction failure):
1055
+ // back off and try again rather than giving up.
1056
+ logger?.error?.("Telegram polling restart failed; backing off.", {
1057
+ error: errorMessage(error),
1058
+ });
1059
+ scheduleRestart();
1060
+ }
1061
+ }, delay);
1062
+ // Never let the restart timer keep the process alive on its own.
1063
+ restartTimer.unref?.();
1064
+ }
1065
+ // Tool-progress status messages, keyed `chat:key` β†’ message_id for edit-in-place.
1066
+ const statusMessages = new Map();
179
1067
  return {
180
1068
  bot,
1069
+ notify,
1070
+ async post(chatId, text) {
1071
+ await sender.sendMessage({ chat_id: chatId, text });
1072
+ },
1073
+ async postStatus(chatId, text, statusOptions) {
1074
+ const key = `${String(chatId)}:${statusOptions.key}`;
1075
+ try {
1076
+ const existing = statusMessages.get(key);
1077
+ if (existing === undefined) {
1078
+ const sent = await sender.sendMessage({ chat_id: chatId, text });
1079
+ statusMessages.set(key, sent.message_id);
1080
+ }
1081
+ else {
1082
+ await sender.editMessageText({ chat_id: chatId, message_id: existing, text });
1083
+ }
1084
+ }
1085
+ catch (error) {
1086
+ // Best-effort by contract: a lost progress edit must never fail the
1087
+ // reporting tool (e.g. "message is not modified" on identical text).
1088
+ logger?.debug?.("Telegram postStatus failed (best-effort).", {
1089
+ error: errorMessage(error),
1090
+ });
1091
+ }
1092
+ finally {
1093
+ if (statusOptions.state !== "working") {
1094
+ statusMessages.delete(key);
1095
+ }
1096
+ }
1097
+ },
1098
+ activeControllerCount() {
1099
+ let total = 0;
1100
+ for (const set of activeControllers.values()) {
1101
+ total += set.size;
1102
+ }
1103
+ return total;
1104
+ },
181
1105
  async start() {
182
1106
  if (runnerHandle?.isRunning() === true) {
183
1107
  return;
184
1108
  }
1109
+ // Re-arm message handling on a genuine (re)start: a prior stop() latches
1110
+ // `stopped = true`, and handleAgentMessage/flushAlbum early-return while it
1111
+ // is set. Resetting here (after the already-running no-op guard, before any
1112
+ // update can be dispatched by the new runner) means a restart actually
1113
+ // handles messages again instead of silently dropping every one.
1114
+ stopped = false;
1115
+ restartBackoffMs = DEFAULT_RESTART_INITIAL_BACKOFF_MS;
1116
+ // Clear any crash flag left over from a previous session: onPollingRecovered
1117
+ // must only fire for a crash→recovery within THIS run, never for a stale
1118
+ // crash that preceded a stop()/start() cycle.
1119
+ pollingDegraded = false;
185
1120
  if ((options.deleteWebhookOnStart ?? true) === true) {
186
- await bot.api.deleteWebhook({ drop_pending_updates: options.dropPendingUpdates ?? false });
187
- }
188
- runnerHandle = (options.runnerFactory ?? defaultRunnerFactory)(bot);
189
- // Surface a late polling crash to the shared logger and the host's
190
- // onPollingError callback without leaving an unhandled rejection; stop()
191
- // still settles the runner independently.
192
- runnerHandle.task?.()?.catch((error) => {
193
- logger?.error?.("Telegram polling stopped with an error.", {
194
- error: errorMessage(error),
195
- });
196
- options.onPollingError?.(error);
197
- });
1121
+ // Bound + best-effort: the host awaits start() before reporting ready, so
1122
+ // an unbounded deleteWebhook over a flaky network could hang ~50s (the Api
1123
+ // client timeout) and blow past the launcher's readiness deadline. Cap it
1124
+ // and never reject so boot proceeds. This is safe for a polling bot with no
1125
+ // webhook configured (the call is a no-op). NOTE: if a webhook genuinely IS
1126
+ // set and this call is skipped/times out, getUpdates returns 409 and the
1127
+ // runner crash-restarts on a backoff β€” the backoff path does NOT re-issue
1128
+ // deleteWebhook, so polling only resumes once the webhook is cleared (a
1129
+ // later full start(), or its natural expiry). Deployments that use webhooks
1130
+ // should not rely on this fallback.
1131
+ try {
1132
+ // grammY types `signal` with the abort-controller shim, not the global
1133
+ // AbortSignal; the runtime value is identical (cf. grammy-client.ts).
1134
+ const timeoutSignal = AbortSignal.timeout(options.deleteWebhookTimeoutMs ?? DEFAULT_DELETE_WEBHOOK_TIMEOUT_MS);
1135
+ await bot.api.deleteWebhook({ drop_pending_updates: options.dropPendingUpdates ?? false }, timeoutSignal);
1136
+ }
1137
+ catch (error) {
1138
+ logger?.warn?.("Telegram deleteWebhook failed or timed out at startup; continuing to poll.", {
1139
+ error: errorMessage(error),
1140
+ });
1141
+ }
1142
+ }
1143
+ // Register the command menu (setMyCommands) when custom commands are
1144
+ // configured: the built-in help/cancel plus each custom command, scoped to
1145
+ // private chats. Best-effort + bounded so a flaky network can't stall boot
1146
+ // (the menu is cosmetic); skipped entirely with no custom commands so an
1147
+ // existing deployment sees no menu it never asked for.
1148
+ if (customCommands.length > 0) {
1149
+ const menu = [
1150
+ { command: "help", description: "How to use this agent" },
1151
+ { command: "cancel", description: "Stop the current response" },
1152
+ ...customCommands.map((command) => ({
1153
+ command: command.command,
1154
+ description: command.description,
1155
+ })),
1156
+ ];
1157
+ try {
1158
+ const timeoutSignal = AbortSignal.timeout(options.deleteWebhookTimeoutMs ?? DEFAULT_DELETE_WEBHOOK_TIMEOUT_MS);
1159
+ await bot.api.setMyCommands(menu, { scope: { type: "all_private_chats" } }, timeoutSignal);
1160
+ }
1161
+ catch (error) {
1162
+ logger?.warn?.("Telegram setMyCommands failed or timed out at startup; continuing.", {
1163
+ error: errorMessage(error),
1164
+ });
1165
+ }
1166
+ }
1167
+ // Spawn the runner with the auto-restart monitor attached: a late polling
1168
+ // crash is surfaced (logger + onPollingError) AND triggers a backoff
1169
+ // restart instead of leaving the bot silent. stop() settles the runner and
1170
+ // cancels any pending restart independently.
1171
+ spawnRunnerWithMonitor();
198
1172
  },
199
1173
  async stop() {
1174
+ // Guard the timer/late-update paths first: a pending album timer must not
1175
+ // flush a turn after teardown. Clear every outstanding album timer and drop
1176
+ // the buffers (mirrors cancelChat's per-chat cleanup, but for all chats).
1177
+ stopped = true;
1178
+ // Cancel any pending auto-restart so a backoff timer cannot resurrect the
1179
+ // runner after shutdown. The `stopped` flag also short-circuits the monitor
1180
+ // and the timer callback, so a restart in flight when stop() runs is a no-op.
1181
+ if (restartTimer !== undefined) {
1182
+ clearTimeout(restartTimer);
1183
+ restartTimer = undefined;
1184
+ }
1185
+ if (stabilityTimer !== undefined) {
1186
+ clearTimeout(stabilityTimer);
1187
+ stabilityTimer = undefined;
1188
+ }
1189
+ clearPollWatchdog();
1190
+ for (const buffer of albumBuffers.values()) {
1191
+ clearTimeout(buffer.timer);
1192
+ // Settle the reserved admission slot so the parked admit() task does not
1193
+ // hang the per-chat queue after teardown (no turn fires: stopped guard +
1194
+ // no-op work).
1195
+ buffer.ready.resolve(noopAlbumWork);
1196
+ }
1197
+ albumBuffers.clear();
200
1198
  if (runnerHandle?.isRunning() === true) {
201
1199
  await runnerHandle.stop();
202
1200
  }
203
1201
  runnerHandle = undefined;
1202
+ // Close any in-flight socket on the family-pinned agent (no-op when the
1203
+ // default dual-stack transport is used and no agent was created).
1204
+ transportAgent?.destroy();
204
1205
  },
205
1206
  };
206
1207
  function defaultRunnerFactory(target) {
207
- const allowed = [...(options.allowedUpdates ?? ["message"])];
208
- return run(target, { runner: { fetch: { allowed_updates: allowed } } });
1208
+ const defaultAllowed = callbacksEnabled ? ["message", "callback_query"] : ["message"];
1209
+ const allowed = [...(options.allowedUpdates ?? defaultAllowed)];
1210
+ return run(target, {
1211
+ runner: {
1212
+ // Self-retry transient getUpdates errors (network blips) with exponential
1213
+ // backoff before the task rejects and the monitor restarts the runner.
1214
+ retryInterval: "exponential",
1215
+ maxRetryTime: DEFAULT_RUNNER_MAX_RETRY_TIME_MS,
1216
+ fetch: {
1217
+ allowed_updates: allowed,
1218
+ // Bound the long-poll below the Api client HTTP timeout so a stalled
1219
+ // socket fails fast instead of hanging on grammY's 500s default.
1220
+ timeout: DEFAULT_LONG_POLL_TIMEOUT_SECONDS,
1221
+ },
1222
+ },
1223
+ });
209
1224
  }
210
1225
  }
211
1226
  function errorMessage(error) {
212
1227
  return error instanceof Error ? error.message : String(error);
213
1228
  }
1229
+ /** A promise plus its resolver, for an externally-settled deferred value. */
1230
+ function createDeferred() {
1231
+ let resolve = () => undefined;
1232
+ const promise = new Promise((innerResolve) => {
1233
+ resolve = innerResolve;
1234
+ });
1235
+ return { promise, resolve };
1236
+ }
1237
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;
1238
+ /**
1239
+ * Build the grammY client options for the default Bot construction. Extracted
1240
+ * (and exported) so the apiRoot/agent interplay is unit-testable β€” the botFactory
1241
+ * test seam otherwise owns the whole construction.
1242
+ *
1243
+ * grammY's node platform fetches with node-fetch, which rejects an agent whose
1244
+ * protocol mismatches the URL β€” so the family-locked keep-alive-off agent (see
1245
+ * the ipFamily rationale on {@link CreateTelegramBotOptions.transport}) must be
1246
+ * an `http.Agent` when the apiRoot is plain http (a loopback self-hosted server)
1247
+ * and an `https.Agent` otherwise.
1248
+ */
1249
+ export function buildTelegramBotClientOptions(options) {
1250
+ const client = { timeoutSeconds: DEFAULT_API_TIMEOUT_SECONDS };
1251
+ if (options.apiRoot !== undefined) {
1252
+ client.apiRoot = options.apiRoot;
1253
+ }
1254
+ if (options.ipFamily === undefined) {
1255
+ return { client };
1256
+ }
1257
+ const agentOptions = { family: options.ipFamily, keepAlive: false };
1258
+ const agent = options.apiRoot?.startsWith("http://") === true
1259
+ ? new HttpAgent(agentOptions)
1260
+ : new HttpsAgent(agentOptions);
1261
+ client.baseFetchConfig = { ...client.baseFetchConfig, agent };
1262
+ return { client, agent };
1263
+ }
1264
+ /**
1265
+ * Default {@link TelegramFileDownloader}: resolve a `file_id` to a `file_path`
1266
+ * via `bot.api.getFile`, then download it from the Telegram file URL
1267
+ * (`https://api.telegram.org/file/bot<token>/<file_path>`) with `fetch`. Both
1268
+ * calls honor the request abort signal.
1269
+ *
1270
+ * The download is hardened against oversized/stale-`file_size` bodies: a
1271
+ * `Content-Length` header over the cap is rejected before reading the body, and
1272
+ * the body is streamed with a running byte counter that cancels the reader the
1273
+ * moment the cap is exceeded (so the whole payload is never buffered first). A
1274
+ * `downloadTimeoutMs` timer is composed with the run signal so a stalled
1275
+ * transfer is bounded by a dedicated timeout, not just the overall run.
1276
+ */
1277
+ function createDefaultFileDownloader(bot, token, options) {
1278
+ const timeoutMs = options?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
1279
+ return {
1280
+ async resolveFilePath(fileId, signal) {
1281
+ const file = await bot.api.getFile(fileId, signal);
1282
+ return file.file_path;
1283
+ },
1284
+ async download(filePath, signal, maxBytes) {
1285
+ // A `--local` self-hosted server downloads the file itself during getFile
1286
+ // and returns an ABSOLUTE path; the /file/ HTTP route is unavailable in
1287
+ // that mode, so the bytes are read straight from disk. Hosted and
1288
+ // non-local self-hosted servers return relative paths served over HTTP.
1289
+ if (isAbsolute(filePath)) {
1290
+ return await readLocalTelegramFile(filePath, signal, maxBytes, options?.logger);
1291
+ }
1292
+ const url = `${options?.apiRoot ?? "https://api.telegram.org"}/file/bot${token}/${filePath}`;
1293
+ const { signal: fetchSignal, cleanup } = composeDownloadSignal(signal, timeoutMs);
1294
+ try {
1295
+ const response = await fetch(url, fetchSignal === undefined ? {} : { signal: fetchSignal });
1296
+ if (!response.ok) {
1297
+ throw new Error(`Telegram file download failed with status ${response.status}.`);
1298
+ }
1299
+ // Early skip: a declared Content-Length over the cap means we never read
1300
+ // the body at all (the adapter turns the throw into a logged skip).
1301
+ if (maxBytes !== undefined) {
1302
+ const declared = Number.parseInt(response.headers.get("content-length") ?? "", 10);
1303
+ if (Number.isFinite(declared) && declared > maxBytes) {
1304
+ throw new Error("Telegram file exceeded the configured byte cap (Content-Length).");
1305
+ }
1306
+ }
1307
+ return await readBodyWithCap(response, maxBytes);
1308
+ }
1309
+ finally {
1310
+ cleanup();
1311
+ }
1312
+ },
1313
+ };
1314
+ }
1315
+ /**
1316
+ * Read a `--local` Bot API server file from disk. The stat-before-read is the
1317
+ * local analog of the Content-Length early-skip (the declared file_size in the
1318
+ * update can be stale). A consumed read deletes the daemon's copy: the daemon
1319
+ * keeps downloads for up to ~25h, the harness persists its own copy into the
1320
+ * attachments dir before the model sees it, and getFile re-downloads on demand β€”
1321
+ * so the daemon file is a drained cache. Skip paths (over-cap, missing) never
1322
+ * delete.
1323
+ */
1324
+ async function readLocalTelegramFile(filePath, signal, maxBytes, logger) {
1325
+ let size;
1326
+ try {
1327
+ size = (await stat(filePath)).size;
1328
+ }
1329
+ catch (error) {
1330
+ if (error.code === "ENOENT") {
1331
+ throw new Error("Telegram local file is missing (expired from the Bot API server cache?).");
1332
+ }
1333
+ throw error;
1334
+ }
1335
+ if (maxBytes !== undefined && size > maxBytes) {
1336
+ throw new Error("Telegram file exceeded the configured byte cap (local file size).");
1337
+ }
1338
+ const bytes = await readFile(filePath, signal === undefined ? {} : { signal });
1339
+ await unlink(filePath).catch((error) => {
1340
+ logger?.debug?.("Telegram local file cleanup failed (best-effort).", {
1341
+ error: error instanceof Error ? error.message : String(error),
1342
+ });
1343
+ });
1344
+ return new Uint8Array(bytes);
1345
+ }
1346
+ /**
1347
+ * Compose a `downloadTimeoutMs` timer with the run abort signal into a single
1348
+ * signal for `fetch`. The returned `cleanup` clears the timer (and detaches the
1349
+ * forwarding listener) in every path so no timer leaks/keeps the loop alive.
1350
+ */
1351
+ function composeDownloadSignal(externalSignal, timeoutMs) {
1352
+ const shouldUseTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0;
1353
+ if (!shouldUseTimeout) {
1354
+ if (externalSignal === undefined) {
1355
+ return { cleanup: () => undefined };
1356
+ }
1357
+ return { signal: externalSignal, cleanup: () => undefined };
1358
+ }
1359
+ const controller = new AbortController();
1360
+ const timer = setTimeout(() => {
1361
+ controller.abort(new Error("Telegram file download timed out."));
1362
+ }, timeoutMs);
1363
+ timer.unref?.();
1364
+ const forwardAbort = () => {
1365
+ controller.abort(externalSignal?.reason);
1366
+ };
1367
+ if (externalSignal !== undefined) {
1368
+ if (externalSignal.aborted) {
1369
+ forwardAbort();
1370
+ }
1371
+ else {
1372
+ externalSignal.addEventListener("abort", forwardAbort, { once: true });
1373
+ }
1374
+ }
1375
+ return {
1376
+ signal: controller.signal,
1377
+ cleanup: () => {
1378
+ clearTimeout(timer);
1379
+ externalSignal?.removeEventListener("abort", forwardAbort);
1380
+ },
1381
+ };
1382
+ }
1383
+ /**
1384
+ * Read a response body into a single `Uint8Array`, rejecting once `maxBytes` is
1385
+ * exceeded. Streams the body where possible so an oversized file is abandoned
1386
+ * without buffering the whole thing; falls back to an `arrayBuffer()` read (still
1387
+ * cap-checked) when the runtime exposes no readable stream.
1388
+ */
1389
+ async function readBodyWithCap(response, maxBytes) {
1390
+ const cap = maxBytes !== undefined && Number.isFinite(maxBytes) && maxBytes >= 0 ? maxBytes : undefined;
1391
+ const body = response.body;
1392
+ if (body === null || typeof body.getReader !== "function") {
1393
+ const buffer = await response.arrayBuffer();
1394
+ const bytes = new Uint8Array(buffer);
1395
+ if (cap !== undefined && bytes.byteLength > cap) {
1396
+ throw new Error("Telegram file exceeded the configured byte cap.");
1397
+ }
1398
+ return bytes;
1399
+ }
1400
+ const reader = body.getReader();
1401
+ const chunks = [];
1402
+ let total = 0;
1403
+ try {
1404
+ for (;;) {
1405
+ const { done, value } = await reader.read();
1406
+ if (done) {
1407
+ break;
1408
+ }
1409
+ if (value === undefined) {
1410
+ continue;
1411
+ }
1412
+ total += value.byteLength;
1413
+ if (cap !== undefined && total > cap) {
1414
+ await reader.cancel();
1415
+ throw new Error("Telegram file exceeded the configured byte cap.");
1416
+ }
1417
+ chunks.push(value);
1418
+ }
1419
+ }
1420
+ finally {
1421
+ reader.releaseLock?.();
1422
+ }
1423
+ const out = new Uint8Array(total);
1424
+ let offset = 0;
1425
+ for (const chunk of chunks) {
1426
+ out.set(chunk, offset);
1427
+ offset += chunk.byteLength;
1428
+ }
1429
+ return out;
1430
+ }
1431
+ /**
1432
+ * Resolve the label of the button whose `callback_data` matches `data` by reading
1433
+ * the tapped message's own inline keyboard. Returns undefined when the keyboard or
1434
+ * a matching button is absent. Pure so it can be unit-tested directly.
1435
+ */
1436
+ function labelForCallbackData(replyMarkup, data) {
1437
+ const keyboard = replyMarkup?.inline_keyboard;
1438
+ if (!Array.isArray(keyboard)) {
1439
+ return undefined;
1440
+ }
1441
+ for (const row of keyboard) {
1442
+ for (const button of row) {
1443
+ if (button?.callback_data === data && typeof button.text === "string") {
1444
+ return button.text;
1445
+ }
1446
+ }
1447
+ }
1448
+ return undefined;
1449
+ }
1450
+ function controlCommandFromCaption(message, botUsername) {
1451
+ if (message.text !== undefined || message.caption === undefined) {
1452
+ return undefined;
1453
+ }
1454
+ const match = message.caption.trim().match(/^\/([A-Za-z0-9_]+)(?:@([A-Za-z0-9_]+))?(?:\s|$)/u);
1455
+ const command = match?.[1]?.toLowerCase();
1456
+ const target = match?.[2]?.toLowerCase();
1457
+ if (target !== undefined) {
1458
+ if (botUsername === undefined || target !== botUsername.toLowerCase()) {
1459
+ return undefined;
1460
+ }
1461
+ }
1462
+ return command === "start" || command === "help" || command === "cancel"
1463
+ ? command
1464
+ : undefined;
1465
+ }
214
1466
  //# sourceMappingURL=bot.js.map