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