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