@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/README.md +1 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +5 -0
- package/dist/adapter.js.map +1 -1
- package/dist/ask.d.ts +17 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +21 -0
- package/dist/ask.js.map +1 -0
- package/dist/bot.d.ts +141 -1
- package/dist/bot.d.ts.map +1 -1
- package/dist/bot.js +760 -18
- package/dist/bot.js.map +1 -1
- package/dist/config.d.ts +83 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +306 -45
- package/dist/config.js.map +1 -1
- package/dist/grammy-client.d.ts +3 -1
- package/dist/grammy-client.d.ts.map +1 -1
- package/dist/grammy-client.js +50 -3
- package/dist/grammy-client.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/message-stream.d.ts +5 -0
- package/dist/message-stream.d.ts.map +1 -1
- package/dist/message-stream.js +6 -0
- package/dist/message-stream.js.map +1 -1
- package/dist/start.d.ts +39 -1
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +15 -1
- package/dist/start.js.map +1 -1
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -3
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
564
|
-
|
|
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
|
-
|
|
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;
|