@mono-agent/telegram-adapter 0.4.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,13 +1,61 @@
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
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…";
8
13
  // Quiet window after the last album message before we flush the group as one
9
14
  // request. Telegram sends album parts back-to-back (sub-second), so ~1s is safe.
10
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;
11
59
  // Mirrors the harness LiveSessionManager's DEFAULT_MAX_PENDING_PER_CONVERSATION:
12
60
  // the per-chat admission queue rejects past this depth so a flood of same-chat
13
61
  // messages cannot grow the queue unbounded.
@@ -132,6 +180,9 @@ export function createTelegramBot(options) {
132
180
  const albumDelayMs = options.albumAggregationDelayMs ?? DEFAULT_ALBUM_AGGREGATION_DELAY_MS;
133
181
  const cancelChat = (chatId) => {
134
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);
135
186
  // Clear queued follow-ups (and signal the harness to abort the in-flight
136
187
  // turn) first, then abort every controller we are tracking for this chat.
137
188
  options.responder.cancel?.(conversationId);
@@ -154,16 +205,63 @@ export function createTelegramBot(options) {
154
205
  }
155
206
  }
156
207
  };
157
- const bot = options.botFactory?.(options.botToken) ?? new Bot(options.botToken);
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
+ });
158
231
  const sender = createGrammyTelegramApi(bot.api);
159
232
  const fileDownloader = options.fileDownloaderFactory?.(bot, options.botToken) ??
160
233
  createDefaultFileDownloader(bot, options.botToken, {
161
234
  ...(options.attachments?.downloadTimeoutMs !== undefined
162
235
  ? { downloadTimeoutMs: options.attachments.downloadTimeoutMs }
163
236
  : {}),
237
+ ...(options.apiRoot === undefined ? {} : { apiRoot: options.apiRoot }),
164
238
  ...(logger !== undefined ? { logger } : {}),
165
239
  });
166
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
+ }
167
265
  bot.use(async (ctx, next) => {
168
266
  const chatId = ctx.chat?.id;
169
267
  if (chatId === undefined) {
@@ -188,6 +286,96 @@ export function createTelegramBot(options) {
188
286
  }
189
287
  await ctx.reply(messages.cancelledText);
190
288
  });
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
+ }
191
379
  // A single handler for every message type so all messages reach the per-chat
192
380
  // admission queue at the same middleware depth. Separate per-type handlers sit
193
381
  // at different filter positions, and grammY yields a microtask per non-matching
@@ -259,6 +447,21 @@ export function createTelegramBot(options) {
259
447
  await handleControlCommand(ctx, captionCommand);
260
448
  return;
261
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
+ }
262
465
  const input = normalizeTelegramMessageInput(telegramMessage);
263
466
  if (input === undefined) {
264
467
  await ctx.reply(messages.unsupportedText);
@@ -388,14 +591,27 @@ export function createTelegramBot(options) {
388
591
  }
389
592
  const request = buildAgentRequest(ctx.update, message, input, controller.signal, resolvedAttachments);
390
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;
391
600
  try {
392
601
  // Parked-then-cancelled: /cancel aborted this controller while the message
393
602
  // waited behind an earlier same-chat run. Bail before any responder call so a
394
603
  // queued message is genuinely cancelled (not run on the warm session later).
395
604
  if (controller.signal.aborted) {
605
+ reactionOutcome = "cancelled";
396
606
  await finishSafely(stream, messages.cancelledText, logger);
397
607
  return;
398
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
+ }
399
615
  try {
400
616
  await stream.status(initialStatusText);
401
617
  }
@@ -405,6 +621,7 @@ export function createTelegramBot(options) {
405
621
  });
406
622
  }
407
623
  if (controller.signal.aborted) {
624
+ reactionOutcome = "cancelled";
408
625
  await finishSafely(stream, messages.cancelledText, logger);
409
626
  return;
410
627
  }
@@ -414,6 +631,7 @@ export function createTelegramBot(options) {
414
631
  }
415
632
  catch (error) {
416
633
  if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
634
+ reactionOutcome = "cancelled";
417
635
  await finishSafely(stream, messages.cancelledText, logger);
418
636
  return;
419
637
  }
@@ -428,14 +646,17 @@ export function createTelegramBot(options) {
428
646
  return;
429
647
  }
430
648
  if (controller.signal.aborted) {
649
+ reactionOutcome = "cancelled";
431
650
  await finishSafely(stream, messages.cancelledText, logger);
432
651
  return;
433
652
  }
434
653
  try {
435
654
  await stream.finish(response.text);
655
+ reactionOutcome = "done";
436
656
  }
437
657
  catch (error) {
438
658
  if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
659
+ reactionOutcome = "cancelled";
439
660
  return;
440
661
  }
441
662
  // The AI run succeeded; a delivery failure is degraded, never an error.
@@ -444,10 +665,158 @@ export function createTelegramBot(options) {
444
665
  });
445
666
  }
446
667
  }
668
+ finally {
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" };
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);
752
+ }
753
+ }
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
+ }
447
789
  finally {
448
790
  unregisterController(chatId, controller);
449
791
  }
450
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
+ }
451
820
  async function handleControlCommand(ctx, command) {
452
821
  if (command === "start") {
453
822
  await ctx.reply(messages.welcomeText);
@@ -463,16 +832,23 @@ export function createTelegramBot(options) {
463
832
  }
464
833
  await ctx.reply(messages.cancelledText);
465
834
  }
466
- function buildStreamOptions(chatId, replyToMessageId, signal) {
835
+ function buildStreamOptions(chatId, replyToMessageId, signal, silent = false) {
467
836
  const streamOptions = {
468
837
  api: sender,
469
838
  chatId,
470
- replyToMessageId,
471
839
  abortSignal: signal,
472
840
  // Default to "typing…" + final-answer-only delivery (no streamed interim
473
841
  // edits); a tuning override can restore interim streaming.
474
842
  finalOnly: options.stream?.finalOnly ?? true,
475
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
+ }
476
852
  const tuning = options.stream;
477
853
  if (tuning?.initialStatusText !== undefined) {
478
854
  streamOptions.initialStatusText = tuning.initialStatusText;
@@ -507,8 +883,218 @@ export function createTelegramBot(options) {
507
883
  return streamOptions;
508
884
  }
509
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();
510
1067
  return {
511
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
+ },
512
1098
  activeControllerCount() {
513
1099
  let total = 0;
514
1100
  for (const set of activeControllers.values()) {
@@ -526,25 +1112,81 @@ export function createTelegramBot(options) {
526
1112
  // update can be dispatched by the new runner) means a restart actually
527
1113
  // handles messages again instead of silently dropping every one.
528
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;
529
1120
  if ((options.deleteWebhookOnStart ?? true) === true) {
530
- await bot.api.deleteWebhook({ drop_pending_updates: options.dropPendingUpdates ?? false });
531
- }
532
- runnerHandle = (options.runnerFactory ?? defaultRunnerFactory)(bot);
533
- // Surface a late polling crash to the shared logger and the host's
534
- // onPollingError callback without leaving an unhandled rejection; stop()
535
- // still settles the runner independently.
536
- runnerHandle.task?.()?.catch((error) => {
537
- logger?.error?.("Telegram polling stopped with an error.", {
538
- error: errorMessage(error),
539
- });
540
- options.onPollingError?.(error);
541
- });
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();
542
1172
  },
543
1173
  async stop() {
544
1174
  // Guard the timer/late-update paths first: a pending album timer must not
545
1175
  // flush a turn after teardown. Clear every outstanding album timer and drop
546
1176
  // the buffers (mirrors cancelChat's per-chat cleanup, but for all chats).
547
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();
548
1190
  for (const buffer of albumBuffers.values()) {
549
1191
  clearTimeout(buffer.timer);
550
1192
  // Settle the reserved admission slot so the parked admit() task does not
@@ -557,11 +1199,28 @@ export function createTelegramBot(options) {
557
1199
  await runnerHandle.stop();
558
1200
  }
559
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();
560
1205
  },
561
1206
  };
562
1207
  function defaultRunnerFactory(target) {
563
- const allowed = [...(options.allowedUpdates ?? ["message"])];
564
- 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
+ });
565
1224
  }
566
1225
  }
567
1226
  function errorMessage(error) {
@@ -576,6 +1235,32 @@ function createDeferred() {
576
1235
  return { promise, resolve };
577
1236
  }
578
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
+ }
579
1264
  /**
580
1265
  * Default {@link TelegramFileDownloader}: resolve a `file_id` to a `file_path`
581
1266
  * via `bot.api.getFile`, then download it from the Telegram file URL
@@ -597,7 +1282,14 @@ function createDefaultFileDownloader(bot, token, options) {
597
1282
  return file.file_path;
598
1283
  },
599
1284
  async download(filePath, signal, maxBytes) {
600
- const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
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}`;
601
1293
  const { signal: fetchSignal, cleanup } = composeDownloadSignal(signal, timeoutMs);
602
1294
  try {
603
1295
  const response = await fetch(url, fetchSignal === undefined ? {} : { signal: fetchSignal });
@@ -620,6 +1312,37 @@ function createDefaultFileDownloader(bot, token, options) {
620
1312
  },
621
1313
  };
622
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
+ }
623
1346
  /**
624
1347
  * Compose a `downloadTimeoutMs` timer with the run abort signal into a single
625
1348
  * signal for `fetch`. The returned `cleanup` clears the timer (and detaches the
@@ -705,6 +1428,25 @@ async function readBodyWithCap(response, maxBytes) {
705
1428
  }
706
1429
  return out;
707
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
+ }
708
1450
  function controlCommandFromCaption(message, botUsername) {
709
1451
  if (message.text !== undefined || message.caption === undefined) {
710
1452
  return undefined;