@mono-agent/telegram-adapter 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bot.js CHANGED
@@ -1,10 +1,64 @@
1
1
  import { isAgentResponseCancelledError } from "@mono-agent/agent-contracts";
2
2
  import { run } from "@grammyjs/runner";
3
3
  import { Bot } from "grammy";
4
- import { DEFAULT_MESSAGES, buildAgentRequest, finishSafely, resolveErrorText, } from "./adapter.js";
4
+ import { DEFAULT_MESSAGES, buildAgentRequest, downloadTelegramAttachments, finishSafely, mergeTelegramMessageInputs, normalizeTelegramMessageInput, resolveErrorText, } from "./adapter.js";
5
5
  import { createGrammyTelegramApi } from "./grammy-client.js";
6
6
  import { TelegramMessageStream, } from "./message-stream.js";
7
7
  const DEFAULT_INITIAL_STATUS_TEXT = "Thinking…";
8
+ // Quiet window after the last album message before we flush the group as one
9
+ // request. Telegram sends album parts back-to-back (sub-second), so ~1s is safe.
10
+ const DEFAULT_ALBUM_AGGREGATION_DELAY_MS = 1000;
11
+ // Mirrors the harness LiveSessionManager's DEFAULT_MAX_PENDING_PER_CONVERSATION:
12
+ // the per-chat admission queue rejects past this depth so a flood of same-chat
13
+ // messages cannot grow the queue unbounded.
14
+ const DEFAULT_ADMISSION_QUEUE_MAX_DEPTH = 100;
15
+ /**
16
+ * Thrown synchronously by {@link SerialQueue.run} when the queue is already at
17
+ * its depth cap. The bot catches this sentinel to answer with the busy reply
18
+ * instead of admitting an unbounded backlog.
19
+ */
20
+ export class SerialQueueFullError extends Error {
21
+ code = "serial_queue_full";
22
+ constructor(maxDepth) {
23
+ super(`Per-chat admission queue is full (max ${maxDepth} pending).`);
24
+ this.name = "SerialQueueFullError";
25
+ }
26
+ }
27
+ function isSerialQueueFullError(error) {
28
+ return error instanceof SerialQueueFullError;
29
+ }
30
+ /**
31
+ * Minimal per-conversation serial queue: each submitted task runs only after the
32
+ * previous one settles, preserving arrival order. A task's failure does not
33
+ * poison the queue (the chain swallows it; the caller still sees the rejection).
34
+ *
35
+ * The queue is bounded by {@link maxDepth}: once `depth` reaches the cap, `run`
36
+ * rejects synchronously with a {@link SerialQueueFullError} BEFORE incrementing
37
+ * or chaining, so an over-cap task never enters the chain (mirroring the harness
38
+ * LiveSessionManager's maxPendingPerConversation rejection).
39
+ */
40
+ export class SerialQueue {
41
+ tail = Promise.resolve();
42
+ depth = 0;
43
+ maxDepth;
44
+ constructor(maxDepth = DEFAULT_ADMISSION_QUEUE_MAX_DEPTH) {
45
+ this.maxDepth = maxDepth;
46
+ }
47
+ run(task) {
48
+ if (this.depth >= this.maxDepth) {
49
+ return Promise.reject(new SerialQueueFullError(this.maxDepth));
50
+ }
51
+ this.depth += 1;
52
+ const result = this.tail.then(() => task());
53
+ this.tail = result.then(() => undefined, () => undefined);
54
+ void result.then(() => { this.depth -= 1; }, () => { this.depth -= 1; });
55
+ return result;
56
+ }
57
+ /** True when no task is queued or running. */
58
+ get idle() {
59
+ return this.depth === 0;
60
+ }
61
+ }
8
62
  /**
9
63
  * Build a grammY bot that routes authorized text messages to an agent responder.
10
64
  *
@@ -12,10 +66,12 @@ const DEFAULT_INITIAL_STATUS_TEXT = "Thinking…";
12
66
  * Middleware order is: authorization gate → `/start` `/help` `/cancel` commands →
13
67
  * agent run handler (`message:text`) → unsupported fallback (other messages).
14
68
  *
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.
69
+ * Concurrency is NOT rejected per chat. Every message is handed to the responder,
70
+ * which routes through the runtime harness; the harness serializes per
71
+ * conversation (queue-after-turn follow-ups answered on the warm session). For
72
+ * each in-flight message the bot tracks an `AbortController` in a per-chat set so
73
+ * `/cancel` can abort every live turn for the chat (in addition to clearing
74
+ * queued follow-ups via `responder.cancel`).
19
75
  */
20
76
  export function createTelegramBot(options) {
21
77
  const allowAllChats = options.allowAllChats === true;
@@ -26,9 +82,87 @@ export function createTelegramBot(options) {
26
82
  const messages = { ...DEFAULT_MESSAGES, ...options.messages };
27
83
  const logger = options.logger;
28
84
  const initialStatusText = options.stream?.initialStatusText ?? DEFAULT_INITIAL_STATUS_TEXT;
29
- const activeRuns = new Map();
85
+ // Per-chat set of AbortControllers for in-flight messages. The runtime harness
86
+ // serializes turns per conversation, so we never reject concurrent messages —
87
+ // we only track them so `/cancel` can abort every live turn for the chat.
88
+ const activeControllers = new Map();
89
+ /**
90
+ * Create and register a fresh AbortController for a chat BEFORE the turn is
91
+ * admitted to the per-chat queue. Registering eagerly means a /cancel can abort
92
+ * a message that is still parked behind an earlier run (the controller would
93
+ * otherwise not exist until the queue reached its runAgentTurn).
94
+ */
95
+ function registerController(chatId) {
96
+ const key = String(chatId);
97
+ const controller = new AbortController();
98
+ const controllers = activeControllers.get(key);
99
+ if (controllers === undefined) {
100
+ activeControllers.set(key, new Set([controller]));
101
+ }
102
+ else {
103
+ controllers.add(controller);
104
+ }
105
+ return controller;
106
+ }
107
+ /** Remove a controller from a chat's active set (no-op if already gone). */
108
+ function unregisterController(chatId, controller) {
109
+ const key = String(chatId);
110
+ const set = activeControllers.get(key);
111
+ if (set !== undefined) {
112
+ set.delete(controller);
113
+ if (set.size === 0) {
114
+ activeControllers.delete(key);
115
+ }
116
+ }
117
+ }
118
+ // Per-conversation admission queue. grammY (via @grammyjs/runner) dispatches
119
+ // updates concurrently, and the pre-respond work (status + attachment download)
120
+ // is variable latency, so without this a later text-only message could reach
121
+ // responder.respond() (and the harness FIFO) before an earlier still-downloading
122
+ // media message in the same chat — and many same-chat downloads would run
123
+ // unbounded. We serialize runAgentTurn per chat to preserve arrival order and
124
+ // bound concurrent same-chat downloads. /cancel stays out-of-band (it never
125
+ // enters this queue, so a queued turn can never block cancellation).
126
+ const admissionQueues = new Map();
127
+ // Set in stop() so a pending album timer (or a late update) cannot fire a turn
128
+ // after the channel was torn down. Mirrors the slack/whatsapp adapters.
129
+ let stopped = false;
130
+ const albumBuffers = new Map();
131
+ const noopAlbumWork = () => Promise.resolve();
132
+ const albumDelayMs = options.albumAggregationDelayMs ?? DEFAULT_ALBUM_AGGREGATION_DELAY_MS;
133
+ const cancelChat = (chatId) => {
134
+ const conversationId = `telegram:${String(chatId)}`;
135
+ // Clear queued follow-ups (and signal the harness to abort the in-flight
136
+ // turn) first, then abort every controller we are tracking for this chat.
137
+ options.responder.cancel?.(conversationId);
138
+ const controllers = activeControllers.get(String(chatId));
139
+ if (controllers !== undefined) {
140
+ for (const controller of controllers) {
141
+ controller.abort(new Error("Cancelled by Telegram user."));
142
+ }
143
+ }
144
+ // Drop any album still buffering for this chat so /cancel does not leave it
145
+ // to fire a turn after the user asked to stop. Settle the reserved admission
146
+ // slot with a no-op so the parked admit() task does not hang the per-chat
147
+ // queue. (Its eager controller was just aborted in the loop above.)
148
+ for (const [key, buffer] of albumBuffers) {
149
+ if (key.startsWith(`${String(chatId)}:`)) {
150
+ clearTimeout(buffer.timer);
151
+ albumBuffers.delete(key);
152
+ buffer.ready.resolve(noopAlbumWork);
153
+ unregisterController(chatId, buffer.controller);
154
+ }
155
+ }
156
+ };
30
157
  const bot = options.botFactory?.(options.botToken) ?? new Bot(options.botToken);
31
158
  const sender = createGrammyTelegramApi(bot.api);
159
+ const fileDownloader = options.fileDownloaderFactory?.(bot, options.botToken) ??
160
+ createDefaultFileDownloader(bot, options.botToken, {
161
+ ...(options.attachments?.downloadTimeoutMs !== undefined
162
+ ? { downloadTimeoutMs: options.attachments.downloadTimeoutMs }
163
+ : {}),
164
+ ...(logger !== undefined ? { logger } : {}),
165
+ });
32
166
  const isAuthorized = (chatId) => chatId !== undefined && (allowAllChats || allowedChatIds.has(String(chatId)));
33
167
  bot.use(async (ctx, next) => {
34
168
  const chatId = ctx.chat?.id;
@@ -50,40 +184,218 @@ export function createTelegramBot(options) {
50
184
  bot.command("cancel", async (ctx) => {
51
185
  const chatId = ctx.chat?.id;
52
186
  if (chatId !== undefined) {
53
- activeRuns.get(String(chatId))?.controller.abort(new Error("Cancelled by Telegram user."));
187
+ cancelChat(chatId);
54
188
  }
55
189
  await ctx.reply(messages.cancelledText);
56
190
  });
57
- bot.on("message:text", async (ctx) => {
58
- await handleAgentMessage(ctx);
59
- });
191
+ // A single handler for every message type so all messages reach the per-chat
192
+ // admission queue at the same middleware depth. Separate per-type handlers sit
193
+ // at different filter positions, and grammY yields a microtask per non-matching
194
+ // filter — so a later text message (matched by the first `message:text` filter)
195
+ // could overtake an earlier document/photo (filtered one step further) before
196
+ // admission, breaking arrival order. handleAgentMessage routes supported types
197
+ // and replies unsupportedText for the rest (normalizeTelegramMessageInput
198
+ // returns undefined), so a single `message` handler covers both cases.
60
199
  bot.on("message", async (ctx) => {
61
- await ctx.reply(messages.unsupportedText);
200
+ await handleAgentMessage(ctx);
62
201
  });
202
+ /**
203
+ * Run a turn through the per-chat admission queue so same-chat turns serialize
204
+ * (preserving harness FIFO arrival order and bounding concurrent same-chat
205
+ * downloads). Cross-chat concurrency is preserved because queues are keyed per
206
+ * chat id (the same key /cancel uses for activeControllers).
207
+ *
208
+ * The queue is bounded: a same-chat flood past the depth cap is rejected by
209
+ * SerialQueue.run BEFORE the task enters the chain, so the over-cap message is
210
+ * never admitted. On that rejected path the task body never runs — its eager
211
+ * controller (and, for an album, its reserved slot) never reach the cleanup in
212
+ * runAgentTurn/flushAlbum — so the caller supplies an `onReject` callback to
213
+ * settle/unregister those eagerly-created resources, after which we reply with
214
+ * the busy terminal instead of admitting an unbounded backlog.
215
+ */
216
+ async function admit(chatId, task, onReject) {
217
+ const key = String(chatId);
218
+ let queue = admissionQueues.get(key);
219
+ if (queue === undefined) {
220
+ queue = new SerialQueue();
221
+ admissionQueues.set(key, queue);
222
+ }
223
+ try {
224
+ await queue.run(task);
225
+ }
226
+ catch (error) {
227
+ if (isSerialQueueFullError(error)) {
228
+ await onReject?.();
229
+ return;
230
+ }
231
+ throw error;
232
+ }
233
+ finally {
234
+ if (queue.idle && admissionQueues.get(key) === queue) {
235
+ admissionQueues.delete(key);
236
+ }
237
+ }
238
+ }
63
239
  async function handleAgentMessage(ctx) {
240
+ if (stopped) {
241
+ return;
242
+ }
64
243
  const message = ctx.message;
65
244
  const chatId = ctx.chat?.id;
66
245
  if (message === undefined || chatId === undefined) {
67
246
  return;
68
247
  }
69
- const text = (message.text ?? "").trim();
70
- if (text.length === 0) {
248
+ const telegramMessage = message;
249
+ // A multi-photo/video album arrives as several messages sharing a
250
+ // media_group_id; buffer them and flush once so the agent sees one request
251
+ // with every attachment instead of N single-attachment turns.
252
+ const groupId = telegramMessage.media_group_id;
253
+ if (typeof groupId === "string" && groupId.length > 0) {
254
+ bufferAlbumMessage(ctx, chatId, groupId, telegramMessage);
255
+ return;
256
+ }
257
+ const captionCommand = controlCommandFromCaption(telegramMessage, ctx.me.username);
258
+ if (captionCommand !== undefined) {
259
+ await handleControlCommand(ctx, captionCommand);
260
+ return;
261
+ }
262
+ const input = normalizeTelegramMessageInput(telegramMessage);
263
+ if (input === undefined) {
71
264
  await ctx.reply(messages.unsupportedText);
72
265
  return;
73
266
  }
74
- const key = String(chatId);
75
- if (activeRuns.has(key)) {
267
+ // Register the controller before admission so /cancel can abort this message
268
+ // even while it is still parked behind an earlier same-chat run.
269
+ const controller = registerController(chatId);
270
+ await admit(chatId, () => runAgentTurn(ctx, telegramMessage, input, controller),
271
+ // Over-cap: the task was rejected before entering the queue, so runAgentTurn
272
+ // (and its finally) never ran. Unregister the eagerly created controller so
273
+ // it does not leak in activeControllers, then reply with the busy terminal.
274
+ async () => {
275
+ unregisterController(chatId, controller);
76
276
  await ctx.reply(messages.busyText);
277
+ });
278
+ }
279
+ function bufferAlbumMessage(ctx, chatId, groupId, message) {
280
+ const key = `${String(chatId)}:${groupId}`;
281
+ const schedule = () => {
282
+ const timer = setTimeout(() => {
283
+ void flushAlbum(key);
284
+ }, albumDelayMs);
285
+ timer.unref?.();
286
+ return timer;
287
+ };
288
+ const existing = albumBuffers.get(key);
289
+ if (existing === undefined) {
290
+ // First part of a new album: reserve a per-chat admission slot NOW so a
291
+ // later same-chat message cannot overtake the album. Register the eager
292
+ // controller (so /cancel can abort a parked album) and admit a task that
293
+ // blocks on `ready.promise` — flushAlbum settles it with the real work
294
+ // after the quiet window. The slot blocks only on the album timer, which
295
+ // fires independently of queue progress, so there is no deadlock.
296
+ const controller = registerController(chatId);
297
+ const ready = createDeferred();
298
+ const timer = schedule();
299
+ albumBuffers.set(key, { ctx, messages: [message], timer, controller, ready });
300
+ void admit(chatId, async () => {
301
+ const work = await ready.promise;
302
+ await work();
303
+ },
304
+ // Over-cap: the reserved album slot was rejected before entering the
305
+ // queue, so flushAlbum's later run/settle never executes for it. Drop the
306
+ // buffered album (clear its timer, remove it, settle the deferred), and
307
+ // unregister the eager controller so it does not leak; then reply busy.
308
+ async () => {
309
+ const buffered = albumBuffers.get(key);
310
+ if (buffered !== undefined) {
311
+ clearTimeout(buffered.timer);
312
+ albumBuffers.delete(key);
313
+ buffered.ready.resolve(noopAlbumWork);
314
+ }
315
+ else {
316
+ ready.resolve(noopAlbumWork);
317
+ }
318
+ unregisterController(chatId, controller);
319
+ await ctx.reply(messages.busyText);
320
+ });
77
321
  return;
78
322
  }
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);
323
+ existing.messages.push(message);
324
+ clearTimeout(existing.timer);
325
+ existing.timer = schedule();
326
+ }
327
+ async function flushAlbum(key) {
328
+ const buffer = albumBuffers.get(key);
329
+ if (buffer === undefined) {
330
+ return;
331
+ }
332
+ albumBuffers.delete(key);
333
+ const { ctx, messages: parts, controller, ready } = buffer;
334
+ const albumChatId = ctx.chat?.id;
335
+ // The reserved admission slot is parked on `ready.promise`. EVERY exit below
336
+ // must settle it (with real work or a no-op) or the per-chat queue hangs
337
+ // forever. On a no-op exit, the eager controller never reaches runAgentTurn's
338
+ // finally, so unregister it here to avoid leaking it in activeControllers; on
339
+ // the run path it is reused (do not re-register) and runAgentTurn owns cleanup.
340
+ const settleAsNoop = () => {
341
+ ready.resolve(noopAlbumWork);
342
+ if (albumChatId !== undefined) {
343
+ unregisterController(albumChatId, controller);
344
+ }
345
+ };
346
+ if (stopped) {
347
+ settleAsNoop();
348
+ return;
349
+ }
350
+ // A control command in any album caption controls the chat. The album itself
351
+ // does not run, so settle the reserved slot with a no-op.
352
+ for (const part of parts) {
353
+ const command = controlCommandFromCaption(part, ctx.me.username);
354
+ if (command !== undefined) {
355
+ settleAsNoop();
356
+ await handleControlCommand(ctx, command);
357
+ return;
358
+ }
359
+ }
360
+ const primary = parts[0];
361
+ const input = mergeTelegramMessageInputs(parts);
362
+ if (primary === undefined || input === undefined) {
363
+ settleAsNoop();
364
+ await ctx.reply(messages.unsupportedText);
365
+ return;
366
+ }
367
+ // Fill the reserved slot with the real run so the album executes in its
368
+ // arrival-order position (a later same-chat text admitted after this album
369
+ // started buffering lands behind this slot).
370
+ ready.resolve(() => runAgentTurn(ctx, primary, input, controller));
371
+ }
372
+ async function runAgentTurn(ctx, message, input, controller) {
373
+ const chatId = message.chat.id;
374
+ // The AbortController is created and registered in activeControllers by the
375
+ // caller BEFORE admission, so a /cancel can abort a message still parked in the
376
+ // per-chat queue (the controller would otherwise not exist until the queue
377
+ // reached this run). This function owns unregistering it in the finally below.
378
+ // Download attachment bytes (best-effort) before handing the request to the
379
+ // responder. Failures skip the attachment; the run proceeds regardless. The
380
+ // download is tied to this message's abort signal.
381
+ let resolvedAttachments = [];
382
+ if (input.attachments.length > 0 && !controller.signal.aborted) {
383
+ const downloadOptions = {
384
+ ...options.attachments,
385
+ ...(logger !== undefined ? { logger } : {}),
386
+ };
387
+ resolvedAttachments = await downloadTelegramAttachments(input.attachments, fileDownloader, controller.signal, downloadOptions);
388
+ }
389
+ const request = buildAgentRequest(ctx.update, message, input, controller.signal, resolvedAttachments);
85
390
  const stream = new TelegramMessageStream(buildStreamOptions(chatId, message.message_id, controller.signal));
86
391
  try {
392
+ // Parked-then-cancelled: /cancel aborted this controller while the message
393
+ // waited behind an earlier same-chat run. Bail before any responder call so a
394
+ // queued message is genuinely cancelled (not run on the warm session later).
395
+ if (controller.signal.aborted) {
396
+ await finishSafely(stream, messages.cancelledText, logger);
397
+ return;
398
+ }
87
399
  try {
88
400
  await stream.status(initialStatusText);
89
401
  }
@@ -133,17 +445,33 @@ export function createTelegramBot(options) {
133
445
  }
134
446
  }
135
447
  finally {
136
- if (activeRuns.get(key) === activeRun) {
137
- activeRuns.delete(key);
138
- }
448
+ unregisterController(chatId, controller);
139
449
  }
140
450
  }
451
+ async function handleControlCommand(ctx, command) {
452
+ if (command === "start") {
453
+ await ctx.reply(messages.welcomeText);
454
+ return;
455
+ }
456
+ if (command === "help") {
457
+ await ctx.reply(messages.helpText);
458
+ return;
459
+ }
460
+ const chatId = ctx.chat?.id;
461
+ if (chatId !== undefined) {
462
+ cancelChat(chatId);
463
+ }
464
+ await ctx.reply(messages.cancelledText);
465
+ }
141
466
  function buildStreamOptions(chatId, replyToMessageId, signal) {
142
467
  const streamOptions = {
143
468
  api: sender,
144
469
  chatId,
145
470
  replyToMessageId,
146
471
  abortSignal: signal,
472
+ // Default to "typing…" + final-answer-only delivery (no streamed interim
473
+ // edits); a tuning override can restore interim streaming.
474
+ finalOnly: options.stream?.finalOnly ?? true,
147
475
  };
148
476
  const tuning = options.stream;
149
477
  if (tuning?.initialStatusText !== undefined) {
@@ -167,6 +495,9 @@ export function createTelegramBot(options) {
167
495
  if (tuning?.showThoughts !== undefined) {
168
496
  streamOptions.showThoughts = tuning.showThoughts;
169
497
  }
498
+ if (tuning?.showHints !== undefined) {
499
+ streamOptions.showHints = tuning.showHints;
500
+ }
170
501
  if (tuning?.formatMarkdown !== undefined) {
171
502
  streamOptions.formatMarkdown = tuning.formatMarkdown;
172
503
  }
@@ -178,10 +509,23 @@ export function createTelegramBot(options) {
178
509
  let runnerHandle;
179
510
  return {
180
511
  bot,
512
+ activeControllerCount() {
513
+ let total = 0;
514
+ for (const set of activeControllers.values()) {
515
+ total += set.size;
516
+ }
517
+ return total;
518
+ },
181
519
  async start() {
182
520
  if (runnerHandle?.isRunning() === true) {
183
521
  return;
184
522
  }
523
+ // Re-arm message handling on a genuine (re)start: a prior stop() latches
524
+ // `stopped = true`, and handleAgentMessage/flushAlbum early-return while it
525
+ // is set. Resetting here (after the already-running no-op guard, before any
526
+ // update can be dispatched by the new runner) means a restart actually
527
+ // handles messages again instead of silently dropping every one.
528
+ stopped = false;
185
529
  if ((options.deleteWebhookOnStart ?? true) === true) {
186
530
  await bot.api.deleteWebhook({ drop_pending_updates: options.dropPendingUpdates ?? false });
187
531
  }
@@ -197,6 +541,18 @@ export function createTelegramBot(options) {
197
541
  });
198
542
  },
199
543
  async stop() {
544
+ // Guard the timer/late-update paths first: a pending album timer must not
545
+ // flush a turn after teardown. Clear every outstanding album timer and drop
546
+ // the buffers (mirrors cancelChat's per-chat cleanup, but for all chats).
547
+ stopped = true;
548
+ for (const buffer of albumBuffers.values()) {
549
+ clearTimeout(buffer.timer);
550
+ // Settle the reserved admission slot so the parked admit() task does not
551
+ // hang the per-chat queue after teardown (no turn fires: stopped guard +
552
+ // no-op work).
553
+ buffer.ready.resolve(noopAlbumWork);
554
+ }
555
+ albumBuffers.clear();
200
556
  if (runnerHandle?.isRunning() === true) {
201
557
  await runnerHandle.stop();
202
558
  }
@@ -211,4 +567,158 @@ export function createTelegramBot(options) {
211
567
  function errorMessage(error) {
212
568
  return error instanceof Error ? error.message : String(error);
213
569
  }
570
+ /** A promise plus its resolver, for an externally-settled deferred value. */
571
+ function createDeferred() {
572
+ let resolve = () => undefined;
573
+ const promise = new Promise((innerResolve) => {
574
+ resolve = innerResolve;
575
+ });
576
+ return { promise, resolve };
577
+ }
578
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;
579
+ /**
580
+ * Default {@link TelegramFileDownloader}: resolve a `file_id` to a `file_path`
581
+ * via `bot.api.getFile`, then download it from the Telegram file URL
582
+ * (`https://api.telegram.org/file/bot<token>/<file_path>`) with `fetch`. Both
583
+ * calls honor the request abort signal.
584
+ *
585
+ * The download is hardened against oversized/stale-`file_size` bodies: a
586
+ * `Content-Length` header over the cap is rejected before reading the body, and
587
+ * the body is streamed with a running byte counter that cancels the reader the
588
+ * moment the cap is exceeded (so the whole payload is never buffered first). A
589
+ * `downloadTimeoutMs` timer is composed with the run signal so a stalled
590
+ * transfer is bounded by a dedicated timeout, not just the overall run.
591
+ */
592
+ function createDefaultFileDownloader(bot, token, options) {
593
+ const timeoutMs = options?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
594
+ return {
595
+ async resolveFilePath(fileId, signal) {
596
+ const file = await bot.api.getFile(fileId, signal);
597
+ return file.file_path;
598
+ },
599
+ async download(filePath, signal, maxBytes) {
600
+ const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
601
+ const { signal: fetchSignal, cleanup } = composeDownloadSignal(signal, timeoutMs);
602
+ try {
603
+ const response = await fetch(url, fetchSignal === undefined ? {} : { signal: fetchSignal });
604
+ if (!response.ok) {
605
+ throw new Error(`Telegram file download failed with status ${response.status}.`);
606
+ }
607
+ // Early skip: a declared Content-Length over the cap means we never read
608
+ // the body at all (the adapter turns the throw into a logged skip).
609
+ if (maxBytes !== undefined) {
610
+ const declared = Number.parseInt(response.headers.get("content-length") ?? "", 10);
611
+ if (Number.isFinite(declared) && declared > maxBytes) {
612
+ throw new Error("Telegram file exceeded the configured byte cap (Content-Length).");
613
+ }
614
+ }
615
+ return await readBodyWithCap(response, maxBytes);
616
+ }
617
+ finally {
618
+ cleanup();
619
+ }
620
+ },
621
+ };
622
+ }
623
+ /**
624
+ * Compose a `downloadTimeoutMs` timer with the run abort signal into a single
625
+ * signal for `fetch`. The returned `cleanup` clears the timer (and detaches the
626
+ * forwarding listener) in every path so no timer leaks/keeps the loop alive.
627
+ */
628
+ function composeDownloadSignal(externalSignal, timeoutMs) {
629
+ const shouldUseTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0;
630
+ if (!shouldUseTimeout) {
631
+ if (externalSignal === undefined) {
632
+ return { cleanup: () => undefined };
633
+ }
634
+ return { signal: externalSignal, cleanup: () => undefined };
635
+ }
636
+ const controller = new AbortController();
637
+ const timer = setTimeout(() => {
638
+ controller.abort(new Error("Telegram file download timed out."));
639
+ }, timeoutMs);
640
+ timer.unref?.();
641
+ const forwardAbort = () => {
642
+ controller.abort(externalSignal?.reason);
643
+ };
644
+ if (externalSignal !== undefined) {
645
+ if (externalSignal.aborted) {
646
+ forwardAbort();
647
+ }
648
+ else {
649
+ externalSignal.addEventListener("abort", forwardAbort, { once: true });
650
+ }
651
+ }
652
+ return {
653
+ signal: controller.signal,
654
+ cleanup: () => {
655
+ clearTimeout(timer);
656
+ externalSignal?.removeEventListener("abort", forwardAbort);
657
+ },
658
+ };
659
+ }
660
+ /**
661
+ * Read a response body into a single `Uint8Array`, rejecting once `maxBytes` is
662
+ * exceeded. Streams the body where possible so an oversized file is abandoned
663
+ * without buffering the whole thing; falls back to an `arrayBuffer()` read (still
664
+ * cap-checked) when the runtime exposes no readable stream.
665
+ */
666
+ async function readBodyWithCap(response, maxBytes) {
667
+ const cap = maxBytes !== undefined && Number.isFinite(maxBytes) && maxBytes >= 0 ? maxBytes : undefined;
668
+ const body = response.body;
669
+ if (body === null || typeof body.getReader !== "function") {
670
+ const buffer = await response.arrayBuffer();
671
+ const bytes = new Uint8Array(buffer);
672
+ if (cap !== undefined && bytes.byteLength > cap) {
673
+ throw new Error("Telegram file exceeded the configured byte cap.");
674
+ }
675
+ return bytes;
676
+ }
677
+ const reader = body.getReader();
678
+ const chunks = [];
679
+ let total = 0;
680
+ try {
681
+ for (;;) {
682
+ const { done, value } = await reader.read();
683
+ if (done) {
684
+ break;
685
+ }
686
+ if (value === undefined) {
687
+ continue;
688
+ }
689
+ total += value.byteLength;
690
+ if (cap !== undefined && total > cap) {
691
+ await reader.cancel();
692
+ throw new Error("Telegram file exceeded the configured byte cap.");
693
+ }
694
+ chunks.push(value);
695
+ }
696
+ }
697
+ finally {
698
+ reader.releaseLock?.();
699
+ }
700
+ const out = new Uint8Array(total);
701
+ let offset = 0;
702
+ for (const chunk of chunks) {
703
+ out.set(chunk, offset);
704
+ offset += chunk.byteLength;
705
+ }
706
+ return out;
707
+ }
708
+ function controlCommandFromCaption(message, botUsername) {
709
+ if (message.text !== undefined || message.caption === undefined) {
710
+ return undefined;
711
+ }
712
+ const match = message.caption.trim().match(/^\/([A-Za-z0-9_]+)(?:@([A-Za-z0-9_]+))?(?:\s|$)/u);
713
+ const command = match?.[1]?.toLowerCase();
714
+ const target = match?.[2]?.toLowerCase();
715
+ if (target !== undefined) {
716
+ if (botUsername === undefined || target !== botUsername.toLowerCase()) {
717
+ return undefined;
718
+ }
719
+ }
720
+ return command === "start" || command === "help" || command === "cancel"
721
+ ? command
722
+ : undefined;
723
+ }
214
724
  //# sourceMappingURL=bot.js.map