@wrongstack/telegram 0.309.1 → 0.310.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approval-flow.d.ts +53 -0
- package/dist/bot-types.d.ts +56 -0
- package/dist/bot.d.ts +18 -159
- package/dist/inbox.d.ts +48 -0
- package/dist/index.js +425 -419
- package/dist/outbox.d.ts +41 -0
- package/dist/poller.d.ts +48 -0
- package/dist/text-format.d.ts +25 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -262,74 +262,253 @@ var TelegramApiClient = class {
|
|
|
262
262
|
}
|
|
263
263
|
};
|
|
264
264
|
|
|
265
|
-
// src/
|
|
266
|
-
var
|
|
267
|
-
|
|
268
|
-
pollIntervalMs;
|
|
269
|
-
allowedUsers;
|
|
270
|
-
allowedChats;
|
|
265
|
+
// src/approval-flow.ts
|
|
266
|
+
var ApprovalFlow = class {
|
|
267
|
+
callbackWaiters = /* @__PURE__ */ new Map();
|
|
271
268
|
log;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
269
|
+
api;
|
|
270
|
+
inboundDenialReason;
|
|
271
|
+
constructor(deps) {
|
|
272
|
+
this.log = deps.log;
|
|
273
|
+
this.api = deps.api;
|
|
274
|
+
this.inboundDenialReason = deps.inboundDenialReason;
|
|
275
|
+
}
|
|
276
|
+
settleApproval(requestId, state, result) {
|
|
277
|
+
const request = this.callbackWaiters.get(requestId);
|
|
278
|
+
if (request?.state !== "pending") return false;
|
|
279
|
+
request.state = state;
|
|
280
|
+
clearTimeout(request.timer);
|
|
281
|
+
if (request.signal && request.abortHandler) request.signal.removeEventListener("abort", request.abortHandler);
|
|
282
|
+
const leftover = request.pendingCallbacks.splice(0);
|
|
283
|
+
for (const cq of leftover) {
|
|
284
|
+
const notice = state === "expired" ? "Approval request expired" : state === "cancelled" ? "Approval request cancelled" : "Approval request settled";
|
|
285
|
+
void this.answerCallback(cq.id, notice, true);
|
|
286
|
+
}
|
|
287
|
+
this.callbackWaiters.delete(requestId);
|
|
288
|
+
request.resolve(result);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
async dispatchCallback(cq) {
|
|
292
|
+
const key = cq.data ?? "";
|
|
293
|
+
const action = /^approve:([^:]+):(yes|no)$/.exec(key);
|
|
294
|
+
const requestId = action?.[1];
|
|
295
|
+
const request = requestId ? this.callbackWaiters.get(requestId) : void 0;
|
|
296
|
+
const userId = cq.from?.id !== void 0 ? String(cq.from.id) : void 0;
|
|
297
|
+
const chatId = cq.message?.chat.id !== void 0 ? String(cq.message.chat.id) : void 0;
|
|
298
|
+
const denialReason = this.inboundDenialReason(userId, chatId);
|
|
299
|
+
if (denialReason) {
|
|
300
|
+
const identity = denialReason === "user" ? userId ?? "unknown" : chatId ?? "unknown";
|
|
301
|
+
this.log.warn(`Ignoring callback_query from non-allowlisted ${denialReason} ${identity} (data="${key}") \u2014 possible hijack attempt.`);
|
|
302
|
+
await this.answerCallback(cq.id, "\u26D4 Not authorized", true);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!request || !requestId || !action) {
|
|
306
|
+
await this.answerCallback(cq.id, "Approval request unavailable", true);
|
|
307
|
+
this.log.debug(`Unmatched callback_query data="${key}" (no pending approval request)`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (Date.now() >= request.expiresAt) {
|
|
311
|
+
await this.answerCallback(cq.id, "Approval request expired", true);
|
|
312
|
+
this.settleApproval(requestId, "expired", { approved: false, fromUser: "timeout" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (request.promptMessageId === void 0) {
|
|
316
|
+
request.pendingCallbacks.push(cq);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const messageId = cq.message?.message_id;
|
|
320
|
+
const chatType = cq.message?.chat.type;
|
|
321
|
+
const wrongIdentity = userId === void 0 || chatId !== request.expectedChatId || !request.expectedUserIds.has(userId) || messageId !== request.promptMessageId || chatType !== "private" && !request.allowGroup;
|
|
322
|
+
if (wrongIdentity) {
|
|
323
|
+
this.log.warn(`Ignoring callback_query that does not match approval request ${request.requestId} in session ${request.sessionId}.`);
|
|
324
|
+
await this.answerCallback(cq.id, "\u26D4 Not authorized for this approval", true);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const approved = action[2] === "yes";
|
|
328
|
+
const fromUser = cq.from?.username ?? cq.from?.first_name ?? `user:${userId}`;
|
|
329
|
+
const resolved = this.settleApproval(requestId, "resolved", { approved, fromUser, fromUserId: cq.from?.id });
|
|
330
|
+
await this.answerCallback(cq.id, resolved ? approved ? "Approved \u2713" : "Denied \u2717" : "Approval request unavailable", !resolved);
|
|
331
|
+
}
|
|
332
|
+
async answerCallback(callbackQueryId, text, showAlert) {
|
|
333
|
+
try {
|
|
334
|
+
await this.api().answerCallbackQuery(callbackQueryId, text, showAlert, { signal: AbortSignal.timeout(5e3) });
|
|
335
|
+
} catch (err) {
|
|
336
|
+
this.log.debug(`answerCallbackQuery failed: ${err.message}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
awaitApproval(input) {
|
|
340
|
+
if (input.expectedUserIds.length === 0) throw new Error("Telegram approval requires at least one expected user ID.");
|
|
341
|
+
if (this.callbackWaiters.has(input.requestId)) throw new Error(`Telegram approval request ${input.requestId} is already pending.`);
|
|
342
|
+
return new Promise((resolve) => {
|
|
343
|
+
const delayMs = Math.max(0, input.expiresAt - Date.now());
|
|
344
|
+
const timer = setTimeout(() => {
|
|
345
|
+
this.settleApproval(input.requestId, "expired", { approved: false, fromUser: "timeout" });
|
|
346
|
+
}, delayMs);
|
|
347
|
+
const request = { requestId: input.requestId, sessionId: input.sessionId, expectedChatId: String(input.expectedChatId), expectedUserIds: new Set(input.expectedUserIds.map(String)), allowGroup: input.allowGroup, pendingCallbacks: [], expiresAt: input.expiresAt, state: "pending", resolve, timer, signal: input.signal };
|
|
348
|
+
if (input.signal) request.abortHandler = () => {
|
|
349
|
+
this.settleApproval(input.requestId, "cancelled", { approved: false, fromUser: "aborted" });
|
|
350
|
+
};
|
|
351
|
+
this.callbackWaiters.set(input.requestId, request);
|
|
352
|
+
if (input.signal?.aborted) request.abortHandler?.();
|
|
353
|
+
else if (input.signal && request.abortHandler) input.signal.addEventListener("abort", request.abortHandler, { once: true });
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
bindApprovalPrompt(requestId, promptMessageId) {
|
|
357
|
+
const request = this.callbackWaiters.get(requestId);
|
|
358
|
+
if (request?.state !== "pending" || request.promptMessageId !== void 0) return false;
|
|
359
|
+
request.promptMessageId = promptMessageId;
|
|
360
|
+
const pending = request.pendingCallbacks.splice(0);
|
|
361
|
+
for (const callback of pending) void this.dispatchCallback(callback).catch((err) => this.log.debug(`Callback dispatch failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
cancelApproval(requestId, fromUser = "cancelled") {
|
|
365
|
+
return this.settleApproval(requestId, "cancelled", { approved: false, fromUser });
|
|
366
|
+
}
|
|
367
|
+
cancelAll(fromUser) {
|
|
368
|
+
for (const requestId of Array.from(this.callbackWaiters.keys())) this.settleApproval(requestId, "cancelled", { approved: false, fromUser });
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// src/inbox.ts
|
|
373
|
+
var TelegramInbox = class {
|
|
374
|
+
deps;
|
|
375
|
+
// Circular buffer for incoming messages. Public readonly so the white-box
|
|
376
|
+
// suites can seed it directly through `bot.inbox.buffer` without
|
|
377
|
+
// type-asserting past module boundaries.
|
|
378
|
+
buffer = [];
|
|
379
|
+
constructor(deps) {
|
|
380
|
+
this.deps = deps;
|
|
381
|
+
}
|
|
382
|
+
/** Return buffered messages, newest first. Optionally filter by chat. */
|
|
383
|
+
getMessages(opts) {
|
|
384
|
+
let msgs = [...this.buffer].reverse();
|
|
385
|
+
if (opts?.chatId) {
|
|
386
|
+
const cid = String(opts.chatId);
|
|
387
|
+
msgs = msgs.filter((m) => String(m.chatId) === cid);
|
|
388
|
+
}
|
|
389
|
+
const limit = opts?.limit ?? 20;
|
|
390
|
+
return msgs.slice(0, limit);
|
|
391
|
+
}
|
|
392
|
+
/** Drop messages older than or equal to the given message ID from the buffer (optionally scoped to a specific chat). */
|
|
393
|
+
acknowledge(lastMessageId, chatId) {
|
|
394
|
+
const before = this.buffer.length;
|
|
395
|
+
const cid = chatId !== void 0 && chatId !== null && String(chatId).trim() !== "" ? String(chatId).trim() : void 0;
|
|
396
|
+
const remaining = [];
|
|
397
|
+
for (const buffered of this.buffer) {
|
|
398
|
+
if (cid !== void 0) {
|
|
399
|
+
if (String(buffered.chatId) === cid && buffered.messageId <= lastMessageId) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
} else if (buffered.messageId <= lastMessageId) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
remaining.push(buffered);
|
|
406
|
+
}
|
|
407
|
+
this.buffer.length = 0;
|
|
408
|
+
this.buffer.push(...remaining);
|
|
409
|
+
return before - this.buffer.length;
|
|
410
|
+
}
|
|
411
|
+
get bufferCount() {
|
|
412
|
+
return this.buffer.length;
|
|
413
|
+
}
|
|
277
414
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* once; any successful poll resets to the normal cadence.
|
|
415
|
+
* Apply the inbound identity policy to every update type. A non-empty set is
|
|
416
|
+
* a mandatory constraint: missing identity fails closed instead of bypassing
|
|
417
|
+
* the allowlist. An empty set leaves that identity dimension unrestricted.
|
|
418
|
+
* Public because ApprovalFlow consumes the same gate via the bot's wiring.
|
|
283
419
|
*/
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
420
|
+
denialReason(userId, chatId) {
|
|
421
|
+
if (this.deps.allowedChats.size > 0 && (chatId === void 0 || !this.deps.allowedChats.has(chatId))) {
|
|
422
|
+
return "chat";
|
|
423
|
+
}
|
|
424
|
+
if (this.deps.allowedUsers.size > 0 && (userId === void 0 || !this.deps.allowedUsers.has(userId))) {
|
|
425
|
+
return "user";
|
|
426
|
+
}
|
|
427
|
+
return void 0;
|
|
428
|
+
}
|
|
429
|
+
processMessage(msg) {
|
|
430
|
+
const chatId = String(msg.chat.id);
|
|
431
|
+
const userId = msg.from ? String(msg.from.id) : void 0;
|
|
432
|
+
const denialReason = this.denialReason(userId, chatId);
|
|
433
|
+
if (denialReason === "user") {
|
|
434
|
+
this.deps.log.debug(`Ignoring message from user ${userId ?? "unknown"} (not in allowedUsers)`);
|
|
435
|
+
void this.deps.sendNotice(chatId, "\u26D4 You are not authorized to interact with this bot.").catch(
|
|
436
|
+
(err) => this.deps.log.debug(
|
|
437
|
+
`Failed to send denial notice: ${err instanceof Error ? err.message : String(err)}`
|
|
438
|
+
)
|
|
439
|
+
);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (denialReason === "chat") {
|
|
443
|
+
this.deps.log.debug(`Ignoring message from chat ${chatId} (not in allowedChats)`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const incoming = {
|
|
447
|
+
messageId: msg.message_id,
|
|
448
|
+
chatId: msg.chat.id,
|
|
449
|
+
chatType: msg.chat.type,
|
|
450
|
+
userId: msg.from?.id,
|
|
451
|
+
userName: msg.from?.username ?? msg.from?.first_name,
|
|
452
|
+
text: msg.text,
|
|
453
|
+
timestamp: msg.date * 1e3
|
|
454
|
+
};
|
|
455
|
+
this.buffer.push(incoming);
|
|
456
|
+
while (this.buffer.length > this.deps.bufferMax) this.buffer.shift();
|
|
457
|
+
this.deps.onMessage(incoming);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// src/poller.ts
|
|
462
|
+
var Poller = class _Poller {
|
|
463
|
+
api;
|
|
464
|
+
pollIntervalMs;
|
|
465
|
+
log;
|
|
466
|
+
controller;
|
|
289
467
|
offsetStore;
|
|
290
|
-
/** Single-poller election across wstack instances sharing this token. */
|
|
291
468
|
lock;
|
|
292
469
|
standbyRetryMs;
|
|
293
|
-
|
|
470
|
+
pollTimer = null;
|
|
294
471
|
standbyTimer = null;
|
|
295
472
|
standbyAnnounced = false;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
this.
|
|
306
|
-
this.
|
|
307
|
-
this.
|
|
308
|
-
this.
|
|
309
|
-
this.
|
|
310
|
-
this.
|
|
311
|
-
this.
|
|
312
|
-
this.
|
|
313
|
-
this.
|
|
314
|
-
this.
|
|
315
|
-
if (this.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
473
|
+
pollActive = false;
|
|
474
|
+
_startedAt = null;
|
|
475
|
+
offset = 0;
|
|
476
|
+
_conflictStreak = 0;
|
|
477
|
+
static CONFLICT_BACKOFF_AFTER = 3;
|
|
478
|
+
static CONFLICT_POLL_MS = 6e4;
|
|
479
|
+
onCallbackQuery;
|
|
480
|
+
onMessageUpdate;
|
|
481
|
+
constructor(deps) {
|
|
482
|
+
this.api = deps.api;
|
|
483
|
+
this.pollIntervalMs = deps.pollIntervalMs;
|
|
484
|
+
this.log = deps.log;
|
|
485
|
+
this.controller = deps.controller;
|
|
486
|
+
this.offsetStore = deps.offsetStore;
|
|
487
|
+
this.lock = deps.lock;
|
|
488
|
+
this.standbyRetryMs = deps.standbyRetryMs;
|
|
489
|
+
this.onCallbackQuery = deps.onCallbackQuery;
|
|
490
|
+
this.onMessageUpdate = deps.onMessageUpdate;
|
|
491
|
+
if (this.lock) this.lock.onLost = () => this.handleLockLost();
|
|
492
|
+
if (this.offsetStore) void this.loadOffset();
|
|
493
|
+
}
|
|
494
|
+
get active() {
|
|
495
|
+
return this.pollActive;
|
|
496
|
+
}
|
|
497
|
+
get startedAt() {
|
|
498
|
+
return this._startedAt;
|
|
499
|
+
}
|
|
500
|
+
get standby() {
|
|
501
|
+
return this.pollActive && this.lock !== void 0 && !this.lock.held;
|
|
502
|
+
}
|
|
503
|
+
get conflictStreak() {
|
|
504
|
+
return this._conflictStreak;
|
|
321
505
|
}
|
|
322
|
-
// ------------------------------------------------------------------
|
|
323
|
-
// Lifecycle
|
|
324
|
-
// ------------------------------------------------------------------
|
|
325
|
-
/** Start polling for updates. Idempotent. */
|
|
326
506
|
start() {
|
|
327
507
|
if (this.pollActive) return;
|
|
328
508
|
this.pollActive = true;
|
|
329
509
|
this._startedAt = Date.now();
|
|
330
510
|
this.acquireAndPoll();
|
|
331
511
|
}
|
|
332
|
-
/** Stop polling and cancel all in-flight requests. */
|
|
333
512
|
stop() {
|
|
334
513
|
this.pollActive = false;
|
|
335
514
|
this.controller.abort();
|
|
@@ -341,31 +520,13 @@ var TelegramBot = class _TelegramBot {
|
|
|
341
520
|
clearTimeout(this.standbyTimer);
|
|
342
521
|
this.standbyTimer = null;
|
|
343
522
|
}
|
|
344
|
-
for (const requestId of Array.from(this.callbackWaiters.keys())) {
|
|
345
|
-
this.settleApproval(requestId, "cancelled", {
|
|
346
|
-
approved: false,
|
|
347
|
-
fromUser: "shutdown"
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
this.lock?.release();
|
|
351
|
-
this.log.info("Telegram bot stopped");
|
|
352
|
-
}
|
|
353
|
-
/** True when the bot is started but waiting for the poll lock. */
|
|
354
|
-
get standby() {
|
|
355
|
-
return this.pollActive && this.lock !== void 0 && !this.lock.held;
|
|
356
523
|
}
|
|
357
|
-
/**
|
|
358
|
-
* Acquire the poll lock (when configured) and start the poll loop, or
|
|
359
|
-
* stand by and retry until the current holder releases it.
|
|
360
|
-
*/
|
|
361
524
|
acquireAndPoll() {
|
|
362
525
|
if (!this.pollActive) return;
|
|
363
526
|
if (this.lock && !this.lock.tryAcquire()) {
|
|
364
527
|
if (!this.standbyAnnounced) {
|
|
365
528
|
this.standbyAnnounced = true;
|
|
366
|
-
this.log.info(
|
|
367
|
-
"Telegram: another wstack instance is already polling this bot token \u2014 standing by; will take over when it stops."
|
|
368
|
-
);
|
|
529
|
+
this.log.info("Telegram: another wstack instance is already polling this bot token \u2014 standing by; will take over when it stops.");
|
|
369
530
|
}
|
|
370
531
|
this.standbyTimer = setTimeout(() => this.acquireAndPoll(), this.standbyRetryMs);
|
|
371
532
|
this.standbyTimer.unref?.();
|
|
@@ -374,78 +535,100 @@ var TelegramBot = class _TelegramBot {
|
|
|
374
535
|
if (this.standbyAnnounced) {
|
|
375
536
|
this.standbyAnnounced = false;
|
|
376
537
|
this.log.info("Telegram: poll lock acquired \u2014 taking over polling.");
|
|
377
|
-
} else {
|
|
378
|
-
this.log.info(`Telegram bot polling started (${this.api.safeBaseUrl})`);
|
|
379
|
-
}
|
|
538
|
+
} else this.log.info(`Telegram bot polling started (${this.api().safeBaseUrl})`);
|
|
380
539
|
this.schedulePoll();
|
|
381
540
|
}
|
|
382
|
-
/** The lock was stolen while we held it — pause polling and stand by. */
|
|
383
541
|
handleLockLost() {
|
|
384
542
|
if (!this.pollActive) return;
|
|
385
543
|
if (this.pollTimer) {
|
|
386
544
|
clearTimeout(this.pollTimer);
|
|
387
545
|
this.pollTimer = null;
|
|
388
546
|
}
|
|
389
|
-
this.log.warn(
|
|
390
|
-
"Telegram: poll lock lost to another instance \u2014 pausing polling and standing by."
|
|
391
|
-
);
|
|
547
|
+
this.log.warn("Telegram: poll lock lost to another instance \u2014 pausing polling and standing by.");
|
|
392
548
|
this.standbyAnnounced = true;
|
|
393
549
|
this.standbyTimer = setTimeout(() => this.acquireAndPoll(), this.standbyRetryMs);
|
|
394
550
|
this.standbyTimer.unref?.();
|
|
395
551
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
// Buffer — incoming messages the agent can read
|
|
404
|
-
// ------------------------------------------------------------------
|
|
405
|
-
/** Return buffered messages, newest first. Optionally filter by chat. */
|
|
406
|
-
getMessages(opts) {
|
|
407
|
-
let msgs = [...this.buffer].reverse();
|
|
408
|
-
if (opts?.chatId) {
|
|
409
|
-
const cid = String(opts.chatId);
|
|
410
|
-
msgs = msgs.filter((m) => String(m.chatId) === cid);
|
|
411
|
-
}
|
|
412
|
-
const limit = opts?.limit ?? 20;
|
|
413
|
-
return msgs.slice(0, limit);
|
|
552
|
+
schedulePoll() {
|
|
553
|
+
if (!this.pollActive) return;
|
|
554
|
+
if (this.lock && !this.lock.held) return;
|
|
555
|
+
const delay = this._conflictStreak >= _Poller.CONFLICT_BACKOFF_AFTER ? _Poller.CONFLICT_POLL_MS : this.pollIntervalMs;
|
|
556
|
+
this.pollTimer = setTimeout(() => {
|
|
557
|
+
void this.poll().finally(() => this.schedulePoll());
|
|
558
|
+
}, delay);
|
|
414
559
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
560
|
+
async poll() {
|
|
561
|
+
try {
|
|
562
|
+
const updates = await this.api().getUpdates({ offset: this.offset, timeoutSeconds: 10, deadlineMs: 15e3, signal: this.controller.signal });
|
|
563
|
+
this._conflictStreak = 0;
|
|
564
|
+
for (const upd of updates) {
|
|
565
|
+
if (upd.update_id < this.offset) continue;
|
|
566
|
+
if (upd.callback_query) {
|
|
567
|
+
this.onCallbackQuery(upd.callback_query);
|
|
568
|
+
this.offset = upd.update_id + 1;
|
|
423
569
|
continue;
|
|
424
570
|
}
|
|
425
|
-
|
|
426
|
-
|
|
571
|
+
const raw = upd.message ?? upd.edited_message;
|
|
572
|
+
if (!raw?.text) {
|
|
573
|
+
this.offset = upd.update_id + 1;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
this.onMessageUpdate(raw);
|
|
578
|
+
this.offset = upd.update_id + 1;
|
|
579
|
+
} catch (err) {
|
|
580
|
+
this.log.debug(`Telegram processMessage failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (this.offsetStore && updates.length > 0) void this.saveOffset();
|
|
585
|
+
} catch (err) {
|
|
586
|
+
if (err instanceof TelegramNetworkError && err.aborted) return;
|
|
587
|
+
if (err instanceof TelegramBotApiError && err.errorCode === 409) {
|
|
588
|
+
this._conflictStreak++;
|
|
589
|
+
if (this._conflictStreak === _Poller.CONFLICT_BACKOFF_AFTER) this.log.warn(this.lock ? "Telegram: another consumer outside this machine is polling this bot token (HTTP 409) \u2014 backing off to 60s polls. Check other machines/bots using this token, or a registered webhook (deleteWebhook)." : "Telegram: another instance is polling this bot token (HTTP 409) \u2014 backing off to 60s polls until it stops.");
|
|
590
|
+
this.log.debug(`Telegram getUpdates failed: ${err.description}`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
this.log.debug(`Telegram poll error: ${err.message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async loadOffset() {
|
|
597
|
+
if (!this.offsetStore) return;
|
|
598
|
+
try {
|
|
599
|
+
const saved = this.offsetStore.read();
|
|
600
|
+
if (saved !== null) {
|
|
601
|
+
this.offset = saved;
|
|
602
|
+
this.log.debug(`Telegram polling offset restored: ${this.offset}`);
|
|
427
603
|
}
|
|
428
|
-
|
|
604
|
+
} catch {
|
|
429
605
|
}
|
|
430
|
-
this.buffer.length = 0;
|
|
431
|
-
this.buffer.push(...remaining);
|
|
432
|
-
return before - this.buffer.length;
|
|
433
606
|
}
|
|
434
|
-
|
|
435
|
-
|
|
607
|
+
async saveOffset() {
|
|
608
|
+
if (!this.offsetStore) return;
|
|
609
|
+
try {
|
|
610
|
+
this.offsetStore.write(this.offset);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
this.log.debug(`Failed to persist Telegram offset: ${err}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// src/outbox.ts
|
|
618
|
+
var TelegramOutbox = class {
|
|
619
|
+
deps;
|
|
620
|
+
constructor(deps) {
|
|
621
|
+
this.deps = deps;
|
|
436
622
|
}
|
|
437
|
-
// ------------------------------------------------------------------
|
|
438
|
-
// Outgoing — send a message
|
|
439
|
-
// ------------------------------------------------------------------
|
|
440
623
|
async sendMessage(chatId, text, signal) {
|
|
441
|
-
this.log.debug(`Sending Telegram message to ${chatId} (${text.length} chars)`);
|
|
624
|
+
this.deps.log.debug(`Sending Telegram message to ${chatId} (${text.length} chars)`);
|
|
442
625
|
let lastErr;
|
|
443
626
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
444
627
|
try {
|
|
445
628
|
const timeout = AbortSignal.timeout(1e4);
|
|
446
|
-
const result = await this.api.sendMessage(chatId, text, {
|
|
629
|
+
const result = await this.deps.api().sendMessage(chatId, text, {
|
|
447
630
|
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
448
|
-
parseMode: this.getParseMode?.()
|
|
631
|
+
parseMode: this.deps.getParseMode?.()
|
|
449
632
|
});
|
|
450
633
|
return { ok: true, result };
|
|
451
634
|
} catch (err) {
|
|
@@ -453,12 +636,12 @@ var TelegramBot = class _TelegramBot {
|
|
|
453
636
|
const decision = classifyRetry(err, attempt);
|
|
454
637
|
if (!decision.retry) {
|
|
455
638
|
if (attempt > 1)
|
|
456
|
-
this.log.debug(
|
|
639
|
+
this.deps.log.debug(
|
|
457
640
|
`Telegram sendMessage terminal error on attempt ${attempt}, not retrying`
|
|
458
641
|
);
|
|
459
642
|
break;
|
|
460
643
|
}
|
|
461
|
-
this.log.debug(
|
|
644
|
+
this.deps.log.debug(
|
|
462
645
|
`Telegram sendMessage attempt ${attempt} failed, retrying in ${decision.delayMs}ms...`
|
|
463
646
|
);
|
|
464
647
|
await abortableSleep(decision.delayMs, signal);
|
|
@@ -466,13 +649,10 @@ var TelegramBot = class _TelegramBot {
|
|
|
466
649
|
}
|
|
467
650
|
throw lastErr;
|
|
468
651
|
}
|
|
469
|
-
// ------------------------------------------------------------------
|
|
470
|
-
// Outgoing — send a message with an inline keyboard
|
|
471
|
-
// ------------------------------------------------------------------
|
|
472
652
|
/**
|
|
473
653
|
* Send a message that has up to one row of inline buttons (Telegram's
|
|
474
654
|
* `inline_keyboard`). Used by `telegram_approve` to present a
|
|
475
|
-
* yes/no prompt. The keyboard payload is opaque to the
|
|
655
|
+
* yes/no prompt. The keyboard payload is opaque to the outbox — callers
|
|
476
656
|
* pass already-encoded `callback_data` strings (≤ 64 bytes each).
|
|
477
657
|
*/
|
|
478
658
|
async sendMessageWithKeyboard(chatId, text, buttons, signal) {
|
|
@@ -480,9 +660,9 @@ var TelegramBot = class _TelegramBot {
|
|
|
480
660
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
481
661
|
try {
|
|
482
662
|
const timeout = AbortSignal.timeout(1e4);
|
|
483
|
-
const result = await this.api.sendMessageWithKeyboard(chatId, text, buttons, {
|
|
663
|
+
const result = await this.deps.api().sendMessageWithKeyboard(chatId, text, buttons, {
|
|
484
664
|
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
485
|
-
parseMode: this.getParseMode?.()
|
|
665
|
+
parseMode: this.deps.getParseMode?.()
|
|
486
666
|
});
|
|
487
667
|
return { ok: true, result };
|
|
488
668
|
} catch (err) {
|
|
@@ -490,7 +670,7 @@ var TelegramBot = class _TelegramBot {
|
|
|
490
670
|
const decision = classifyRetry(err, attempt);
|
|
491
671
|
if (!decision.retry) {
|
|
492
672
|
if (attempt > 1)
|
|
493
|
-
this.log.debug(
|
|
673
|
+
this.deps.log.debug(
|
|
494
674
|
`Telegram sendMessageWithKeyboard terminal error on attempt ${attempt}, not retrying`
|
|
495
675
|
);
|
|
496
676
|
break;
|
|
@@ -500,9 +680,6 @@ var TelegramBot = class _TelegramBot {
|
|
|
500
680
|
}
|
|
501
681
|
throw lastErr;
|
|
502
682
|
}
|
|
503
|
-
// ------------------------------------------------------------------
|
|
504
|
-
// Health
|
|
505
|
-
// ------------------------------------------------------------------
|
|
506
683
|
async health(signal) {
|
|
507
684
|
const ctrl = new AbortController();
|
|
508
685
|
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
@@ -510,7 +687,7 @@ var TelegramBot = class _TelegramBot {
|
|
|
510
687
|
const timeout = AbortSignal.timeout(5e3);
|
|
511
688
|
const deadline = AbortSignal.any([ctrl.signal, timeout]);
|
|
512
689
|
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
513
|
-
const user = await this.api.getMe({ signal: combined });
|
|
690
|
+
const user = await this.deps.api().getMe({ signal: combined });
|
|
514
691
|
return { ok: true, username: user.username };
|
|
515
692
|
} catch (err) {
|
|
516
693
|
if (err instanceof TelegramBotApiError) return { ok: false, error: err.description };
|
|
@@ -520,318 +697,146 @@ var TelegramBot = class _TelegramBot {
|
|
|
520
697
|
clearTimeout(timer);
|
|
521
698
|
}
|
|
522
699
|
}
|
|
523
|
-
// ------------------------------------------------------------------
|
|
524
|
-
// Polling
|
|
525
|
-
// ------------------------------------------------------------------
|
|
526
|
-
schedulePoll() {
|
|
527
|
-
if (!this.pollActive) return;
|
|
528
|
-
if (this.lock && !this.lock.held) return;
|
|
529
|
-
const delay = this.conflictStreak >= _TelegramBot.CONFLICT_BACKOFF_AFTER ? _TelegramBot.CONFLICT_POLL_MS : this.pollIntervalMs;
|
|
530
|
-
this.pollTimer = setTimeout(() => {
|
|
531
|
-
void this.poll().finally(() => this.schedulePoll());
|
|
532
|
-
}, delay);
|
|
533
|
-
}
|
|
534
|
-
async poll() {
|
|
535
|
-
try {
|
|
536
|
-
const updates = await this.api.getUpdates({
|
|
537
|
-
offset: this.offset,
|
|
538
|
-
timeoutSeconds: 10,
|
|
539
|
-
deadlineMs: 15e3,
|
|
540
|
-
signal: this.controller.signal
|
|
541
|
-
});
|
|
542
|
-
this.conflictStreak = 0;
|
|
543
|
-
for (const upd of updates) {
|
|
544
|
-
if (upd.update_id < this.offset) continue;
|
|
545
|
-
this.offset = upd.update_id + 1;
|
|
546
|
-
if (upd.callback_query) {
|
|
547
|
-
void this.dispatchCallback(upd.callback_query).catch(
|
|
548
|
-
(err) => this.log.debug(
|
|
549
|
-
`Callback dispatch failed: ${err instanceof Error ? err.message : String(err)}`
|
|
550
|
-
)
|
|
551
|
-
);
|
|
552
|
-
continue;
|
|
553
|
-
}
|
|
554
|
-
const raw = upd.message ?? upd.edited_message;
|
|
555
|
-
if (!raw?.text) continue;
|
|
556
|
-
this.processMessage({ ...raw, text: raw.text });
|
|
557
|
-
}
|
|
558
|
-
if (this.offsetStore && updates.length > 0) void this.saveOffset();
|
|
559
|
-
} catch (err) {
|
|
560
|
-
if (err instanceof TelegramNetworkError && err.aborted) return;
|
|
561
|
-
if (err instanceof TelegramBotApiError && err.errorCode === 409) {
|
|
562
|
-
this.conflictStreak++;
|
|
563
|
-
if (this.conflictStreak === _TelegramBot.CONFLICT_BACKOFF_AFTER) {
|
|
564
|
-
this.log.warn(
|
|
565
|
-
this.lock ? "Telegram: another consumer outside this machine is polling this bot token (HTTP 409) \u2014 backing off to 60s polls. Check other machines/bots using this token, or a registered webhook (deleteWebhook)." : "Telegram: another instance is polling this bot token (HTTP 409) \u2014 backing off to 60s polls until it stops."
|
|
566
|
-
);
|
|
567
|
-
}
|
|
568
|
-
this.log.debug(`Telegram getUpdates failed: ${err.description}`);
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
this.log.debug(`Telegram poll error: ${err.message}`);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Apply the inbound identity policy to every update type. A non-empty set is
|
|
576
|
-
* a mandatory constraint: missing identity fails closed instead of bypassing
|
|
577
|
-
* the allowlist. An empty set leaves that identity dimension unrestricted.
|
|
578
|
-
*/
|
|
579
|
-
inboundDenialReason(userId, chatId) {
|
|
580
|
-
if (this.allowedChats.size > 0 && (chatId === void 0 || !this.allowedChats.has(chatId))) {
|
|
581
|
-
return "chat";
|
|
582
|
-
}
|
|
583
|
-
if (this.allowedUsers.size > 0 && (userId === void 0 || !this.allowedUsers.has(userId))) {
|
|
584
|
-
return "user";
|
|
585
|
-
}
|
|
586
|
-
return void 0;
|
|
587
|
-
}
|
|
588
|
-
processMessage(msg) {
|
|
589
|
-
const chatId = String(msg.chat.id);
|
|
590
|
-
const userId = msg.from ? String(msg.from.id) : void 0;
|
|
591
|
-
const denialReason = this.inboundDenialReason(userId, chatId);
|
|
592
|
-
if (denialReason === "user") {
|
|
593
|
-
this.log.debug(`Ignoring message from user ${userId ?? "unknown"} (not in allowedUsers)`);
|
|
594
|
-
void this.sendMessage(chatId, "\u26D4 You are not authorized to interact with this bot.").catch(
|
|
595
|
-
(err) => this.log.debug(
|
|
596
|
-
`Failed to send denial notice: ${err instanceof Error ? err.message : String(err)}`
|
|
597
|
-
)
|
|
598
|
-
);
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
if (denialReason === "chat") {
|
|
602
|
-
this.log.debug(`Ignoring message from chat ${chatId} (not in allowedChats)`);
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
const incoming = {
|
|
606
|
-
messageId: msg.message_id,
|
|
607
|
-
chatId: msg.chat.id,
|
|
608
|
-
chatType: msg.chat.type,
|
|
609
|
-
userId: msg.from?.id,
|
|
610
|
-
userName: msg.from?.username ?? msg.from?.first_name,
|
|
611
|
-
text: msg.text,
|
|
612
|
-
timestamp: msg.date * 1e3
|
|
613
|
-
};
|
|
614
|
-
this.buffer.push(incoming);
|
|
615
|
-
while (this.buffer.length > this.bufferMax) this.buffer.shift();
|
|
616
|
-
this.onMessage(incoming);
|
|
617
|
-
}
|
|
618
|
-
/**
|
|
619
|
-
* Resolve a pending approval request exactly once and record its terminal
|
|
620
|
-
* state before removing it from the live registry.
|
|
621
|
-
*/
|
|
622
|
-
settleApproval(requestId, state, result) {
|
|
623
|
-
const request = this.callbackWaiters.get(requestId);
|
|
624
|
-
if (request?.state !== "pending") return false;
|
|
625
|
-
request.state = state;
|
|
626
|
-
clearTimeout(request.timer);
|
|
627
|
-
if (request.signal && request.abortHandler) {
|
|
628
|
-
request.signal.removeEventListener("abort", request.abortHandler);
|
|
629
|
-
}
|
|
630
|
-
const leftoverCallbacks = request.pendingCallbacks.splice(0);
|
|
631
|
-
for (const cq of leftoverCallbacks) {
|
|
632
|
-
const notice = state === "expired" ? "Approval request expired" : state === "cancelled" ? "Approval request cancelled" : "Approval request settled";
|
|
633
|
-
void this.answerCallback(cq.id, notice, true);
|
|
634
|
-
}
|
|
635
|
-
this.callbackWaiters.delete(requestId);
|
|
636
|
-
request.resolve(result);
|
|
637
|
-
return true;
|
|
638
|
-
}
|
|
639
|
-
async dispatchCallback(cq) {
|
|
640
|
-
const key = cq.data ?? "";
|
|
641
|
-
const action = /^approve:([^:]+):(yes|no)$/.exec(key);
|
|
642
|
-
const requestId = action?.[1];
|
|
643
|
-
const request = requestId ? this.callbackWaiters.get(requestId) : void 0;
|
|
644
|
-
const userId = cq.from?.id !== void 0 ? String(cq.from.id) : void 0;
|
|
645
|
-
const chatId = cq.message?.chat.id !== void 0 ? String(cq.message.chat.id) : void 0;
|
|
646
|
-
const denialReason = this.inboundDenialReason(userId, chatId);
|
|
647
|
-
if (denialReason) {
|
|
648
|
-
const identity = denialReason === "user" ? userId ?? "unknown" : chatId ?? "unknown";
|
|
649
|
-
this.log.warn(
|
|
650
|
-
`Ignoring callback_query from non-allowlisted ${denialReason} ${identity} (data="${key}") \u2014 possible hijack attempt.`
|
|
651
|
-
);
|
|
652
|
-
await this.answerCallback(cq.id, "\u26D4 Not authorized", true);
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
if (!request || !requestId || !action) {
|
|
656
|
-
await this.answerCallback(cq.id, "Approval request unavailable", true);
|
|
657
|
-
this.log.debug(`Unmatched callback_query data="${key}" (no pending approval request)`);
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
if (Date.now() >= request.expiresAt) {
|
|
661
|
-
await this.answerCallback(cq.id, "Approval request expired", true);
|
|
662
|
-
this.settleApproval(requestId, "expired", { approved: false, fromUser: "timeout" });
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
if (request.promptMessageId === void 0) {
|
|
666
|
-
request.pendingCallbacks.push(cq);
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
const messageId = cq.message?.message_id;
|
|
670
|
-
const chatType = cq.message?.chat.type;
|
|
671
|
-
const wrongIdentity = userId === void 0 || chatId !== request.expectedChatId || !request.expectedUserIds.has(userId) || messageId !== request.promptMessageId || chatType !== "private" && !request.allowGroup;
|
|
672
|
-
if (wrongIdentity) {
|
|
673
|
-
this.log.warn(
|
|
674
|
-
`Ignoring callback_query that does not match approval request ${request.requestId} in session ${request.sessionId}.`
|
|
675
|
-
);
|
|
676
|
-
await this.answerCallback(cq.id, "\u26D4 Not authorized for this approval", true);
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
const approved = action[2] === "yes";
|
|
680
|
-
const fromUser = cq.from?.username ?? cq.from?.first_name ?? `user:${userId}`;
|
|
681
|
-
const resolved = this.settleApproval(requestId, "resolved", {
|
|
682
|
-
approved,
|
|
683
|
-
fromUser,
|
|
684
|
-
fromUserId: cq.from?.id
|
|
685
|
-
});
|
|
686
|
-
await this.answerCallback(
|
|
687
|
-
cq.id,
|
|
688
|
-
resolved ? approved ? "Approved \u2713" : "Denied \u2717" : "Approval request unavailable",
|
|
689
|
-
!resolved
|
|
690
|
-
);
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* POST /answerCallbackQuery for a callback. Best-effort: failures are
|
|
694
|
-
* logged at debug and swallowed — the caller's resolve() must not depend
|
|
695
|
-
* on the ack reaching Telegram (the user may get a "loading" spinner if
|
|
696
|
-
* it fails, but the agent's approval flow continues normally).
|
|
697
|
-
*/
|
|
698
|
-
async answerCallback(callbackQueryId, text, showAlert) {
|
|
699
|
-
try {
|
|
700
|
-
await this.api.answerCallbackQuery(callbackQueryId, text, showAlert, {
|
|
701
|
-
signal: AbortSignal.timeout(5e3)
|
|
702
|
-
});
|
|
703
|
-
} catch (err) {
|
|
704
|
-
this.log.debug(`answerCallbackQuery failed: ${err.message}`);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
/**
|
|
708
|
-
* Register one approval request before its prompt is sent. The returned
|
|
709
|
-
* promise owns the request's only timer and resolves on one terminal event.
|
|
710
|
-
*/
|
|
711
|
-
awaitApproval(input) {
|
|
712
|
-
if (input.expectedUserIds.length === 0) {
|
|
713
|
-
throw new Error("Telegram approval requires at least one expected user ID.");
|
|
714
|
-
}
|
|
715
|
-
if (this.callbackWaiters.has(input.requestId)) {
|
|
716
|
-
throw new Error(`Telegram approval request ${input.requestId} is already pending.`);
|
|
717
|
-
}
|
|
718
|
-
return new Promise((resolve) => {
|
|
719
|
-
const delayMs = Math.max(0, input.expiresAt - Date.now());
|
|
720
|
-
const timer = setTimeout(() => {
|
|
721
|
-
this.settleApproval(input.requestId, "expired", {
|
|
722
|
-
approved: false,
|
|
723
|
-
fromUser: "timeout"
|
|
724
|
-
});
|
|
725
|
-
}, delayMs);
|
|
726
|
-
const request = {
|
|
727
|
-
requestId: input.requestId,
|
|
728
|
-
sessionId: input.sessionId,
|
|
729
|
-
expectedChatId: String(input.expectedChatId),
|
|
730
|
-
expectedUserIds: new Set(input.expectedUserIds.map(String)),
|
|
731
|
-
allowGroup: input.allowGroup,
|
|
732
|
-
pendingCallbacks: [],
|
|
733
|
-
expiresAt: input.expiresAt,
|
|
734
|
-
state: "pending",
|
|
735
|
-
resolve,
|
|
736
|
-
timer,
|
|
737
|
-
signal: input.signal
|
|
738
|
-
};
|
|
739
|
-
if (input.signal) {
|
|
740
|
-
request.abortHandler = () => {
|
|
741
|
-
this.settleApproval(input.requestId, "cancelled", {
|
|
742
|
-
approved: false,
|
|
743
|
-
fromUser: "aborted"
|
|
744
|
-
});
|
|
745
|
-
};
|
|
746
|
-
}
|
|
747
|
-
this.callbackWaiters.set(input.requestId, request);
|
|
748
|
-
if (input.signal?.aborted) {
|
|
749
|
-
request.abortHandler?.();
|
|
750
|
-
} else if (input.signal && request.abortHandler) {
|
|
751
|
-
input.signal.addEventListener("abort", request.abortHandler, { once: true });
|
|
752
|
-
}
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
/**
|
|
756
|
-
* Attach the Bot API response's prompt message ID to an existing request.
|
|
757
|
-
* Any callback that arrived during the send is replayed against the fully
|
|
758
|
-
* bound identity without allocating a second waiter or timer.
|
|
759
|
-
*/
|
|
760
|
-
bindApprovalPrompt(requestId, promptMessageId) {
|
|
761
|
-
const request = this.callbackWaiters.get(requestId);
|
|
762
|
-
if (request?.state !== "pending" || request.promptMessageId !== void 0) return false;
|
|
763
|
-
request.promptMessageId = promptMessageId;
|
|
764
|
-
const pending = request.pendingCallbacks.splice(0);
|
|
765
|
-
for (const callback of pending) {
|
|
766
|
-
void this.dispatchCallback(callback).catch(
|
|
767
|
-
(err) => this.log.debug(
|
|
768
|
-
`Callback dispatch failed: ${err instanceof Error ? err.message : String(err)}`
|
|
769
|
-
)
|
|
770
|
-
);
|
|
771
|
-
}
|
|
772
|
-
return true;
|
|
773
|
-
}
|
|
774
|
-
/** Cancel a request that cannot reach a valid terminal callback. */
|
|
775
|
-
cancelApproval(requestId, fromUser = "cancelled") {
|
|
776
|
-
return this.settleApproval(requestId, "cancelled", { approved: false, fromUser });
|
|
777
|
-
}
|
|
778
|
-
async loadOffset() {
|
|
779
|
-
if (!this.offsetStore) return;
|
|
780
|
-
try {
|
|
781
|
-
const saved = this.offsetStore.read();
|
|
782
|
-
if (saved !== null) {
|
|
783
|
-
this.offset = saved;
|
|
784
|
-
this.log.debug(`Telegram polling offset restored: ${this.offset}`);
|
|
785
|
-
}
|
|
786
|
-
} catch {
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
async saveOffset() {
|
|
790
|
-
if (!this.offsetStore) return;
|
|
791
|
-
try {
|
|
792
|
-
this.offsetStore.write(this.offset);
|
|
793
|
-
} catch (err) {
|
|
794
|
-
this.log.debug(`Failed to persist Telegram offset: ${err}`);
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
700
|
};
|
|
701
|
+
|
|
702
|
+
// src/text-format.ts
|
|
798
703
|
var MAX_TELEGRAM_MESSAGE_LENGTH = 4096;
|
|
799
704
|
function truncateForTelegram(text, maxLen = 4e3) {
|
|
800
705
|
const effectiveMaxLen = Math.min(maxLen, MAX_TELEGRAM_MESSAGE_LENGTH);
|
|
801
706
|
if (text.length <= effectiveMaxLen) return text;
|
|
802
707
|
const cutoff = effectiveMaxLen - 30;
|
|
803
708
|
if (cutoff <= 0) return `${text.slice(0, effectiveMaxLen - 1)}\u2026`;
|
|
804
|
-
const
|
|
805
|
-
const paraIdx = text.lastIndexOf("\n\n",
|
|
709
|
+
const paraSearchEnd = effectiveMaxLen - 3;
|
|
710
|
+
const paraIdx = text.lastIndexOf("\n\n", paraSearchEnd);
|
|
806
711
|
if (paraIdx > cutoff) {
|
|
807
712
|
return `${text.slice(0, paraIdx)}
|
|
808
713
|
|
|
809
714
|
\u2026`;
|
|
810
715
|
}
|
|
811
|
-
const
|
|
716
|
+
const nlSearchEnd = effectiveMaxLen - 2;
|
|
717
|
+
const nlIdx = text.lastIndexOf("\n", nlSearchEnd);
|
|
812
718
|
if (nlIdx > cutoff) {
|
|
813
719
|
return `${text.slice(0, nlIdx)}
|
|
814
720
|
\u2026`;
|
|
815
721
|
}
|
|
722
|
+
const sentenceSearchEnd = effectiveMaxLen - 1;
|
|
816
723
|
const sentenceRe = /[.!?](?=\s)/g;
|
|
817
724
|
let match;
|
|
818
725
|
let sentenceIdx = -1;
|
|
819
726
|
match = sentenceRe.exec(text);
|
|
820
727
|
while (match !== null) {
|
|
821
|
-
if (match.index
|
|
822
|
-
if (match.index > cutoff) sentenceIdx = match.index + 1;
|
|
728
|
+
if (match.index + 1 > sentenceSearchEnd) break;
|
|
729
|
+
if (match.index + 1 > cutoff) sentenceIdx = match.index + 1;
|
|
823
730
|
match = sentenceRe.exec(text);
|
|
824
731
|
}
|
|
825
732
|
if (sentenceIdx > cutoff) {
|
|
826
733
|
return `${text.slice(0, sentenceIdx)}\u2026`;
|
|
827
734
|
}
|
|
828
|
-
const
|
|
735
|
+
const spaceSearchEnd = effectiveMaxLen - 2;
|
|
736
|
+
const spaceIdx = text.lastIndexOf(" ", spaceSearchEnd);
|
|
829
737
|
if (spaceIdx > cutoff) {
|
|
830
738
|
return `${text.slice(0, spaceIdx)} \u2026`;
|
|
831
739
|
}
|
|
832
|
-
|
|
740
|
+
const hardSlice = text.slice(0, effectiveMaxLen - 20);
|
|
741
|
+
const hardResult = `${hardSlice}\u2026[+${text.length - hardSlice.length} chars]`;
|
|
742
|
+
if (hardResult.length <= effectiveMaxLen) return hardResult;
|
|
743
|
+
return `${text.slice(0, effectiveMaxLen - 1)}\u2026`;
|
|
833
744
|
}
|
|
834
745
|
|
|
746
|
+
// src/bot.ts
|
|
747
|
+
var TelegramBot = class {
|
|
748
|
+
api;
|
|
749
|
+
log;
|
|
750
|
+
lock;
|
|
751
|
+
approvals;
|
|
752
|
+
poller;
|
|
753
|
+
inbox;
|
|
754
|
+
outbox;
|
|
755
|
+
constructor(opts) {
|
|
756
|
+
this.api = new TelegramApiClient({ token: opts.token });
|
|
757
|
+
this.log = opts.log;
|
|
758
|
+
this.lock = opts.lock;
|
|
759
|
+
this.inbox = new TelegramInbox({
|
|
760
|
+
log: this.log,
|
|
761
|
+
allowedUsers: opts.allowedUsers,
|
|
762
|
+
allowedChats: opts.allowedChats,
|
|
763
|
+
bufferMax: opts.bufferSize,
|
|
764
|
+
onMessage: opts.onMessage,
|
|
765
|
+
sendNotice: (chatId, text) => this.sendMessage(chatId, text)
|
|
766
|
+
});
|
|
767
|
+
this.outbox = new TelegramOutbox({ log: this.log, api: () => this.api, getParseMode: opts.getParseMode });
|
|
768
|
+
this.approvals = new ApprovalFlow({
|
|
769
|
+
log: this.log,
|
|
770
|
+
api: () => this.api,
|
|
771
|
+
inboundDenialReason: (userId, chatId) => this.inbox.denialReason(userId, chatId)
|
|
772
|
+
});
|
|
773
|
+
this.poller = new Poller({
|
|
774
|
+
api: () => this.api,
|
|
775
|
+
pollIntervalMs: opts.pollIntervalSec * 1e3,
|
|
776
|
+
log: this.log,
|
|
777
|
+
controller: new AbortController(),
|
|
778
|
+
offsetStore: opts.offsetStore,
|
|
779
|
+
lock: opts.lock,
|
|
780
|
+
standbyRetryMs: opts.standbyRetryMs ?? 15e3,
|
|
781
|
+
onCallbackQuery: (cq) => {
|
|
782
|
+
void this.approvals.dispatchCallback(cq).catch((err) => this.log.debug(`Callback dispatch failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
783
|
+
},
|
|
784
|
+
onMessageUpdate: (msg) => this.processMessage({ ...msg, text: msg.text })
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
start() {
|
|
788
|
+
this.poller.start();
|
|
789
|
+
}
|
|
790
|
+
stop() {
|
|
791
|
+
this.poller.stop();
|
|
792
|
+
this.approvals.cancelAll("shutdown");
|
|
793
|
+
this.lock?.release();
|
|
794
|
+
this.log.info("Telegram bot stopped");
|
|
795
|
+
}
|
|
796
|
+
get standby() {
|
|
797
|
+
return this.poller.standby;
|
|
798
|
+
}
|
|
799
|
+
get startedAt() {
|
|
800
|
+
return this.poller.startedAt;
|
|
801
|
+
}
|
|
802
|
+
get running() {
|
|
803
|
+
return this.poller.active;
|
|
804
|
+
}
|
|
805
|
+
getMessages(opts) {
|
|
806
|
+
return this.inbox.getMessages(opts);
|
|
807
|
+
}
|
|
808
|
+
acknowledge(lastMessageId, chatId) {
|
|
809
|
+
return this.inbox.acknowledge(lastMessageId, chatId);
|
|
810
|
+
}
|
|
811
|
+
get bufferCount() {
|
|
812
|
+
return this.inbox.bufferCount;
|
|
813
|
+
}
|
|
814
|
+
processMessage(msg) {
|
|
815
|
+
this.inbox.processMessage(msg);
|
|
816
|
+
}
|
|
817
|
+
sendMessage(chatId, text, signal) {
|
|
818
|
+
return this.outbox.sendMessage(chatId, text, signal);
|
|
819
|
+
}
|
|
820
|
+
sendMessageWithKeyboard(chatId, text, buttons, signal) {
|
|
821
|
+
return this.outbox.sendMessageWithKeyboard(chatId, text, buttons, signal);
|
|
822
|
+
}
|
|
823
|
+
async health(signal) {
|
|
824
|
+
return this.outbox.health(signal);
|
|
825
|
+
}
|
|
826
|
+
get callbackWaiters() {
|
|
827
|
+
return this.approvals.callbackWaiters;
|
|
828
|
+
}
|
|
829
|
+
awaitApproval(input) {
|
|
830
|
+
return this.approvals.awaitApproval(input);
|
|
831
|
+
}
|
|
832
|
+
bindApprovalPrompt(requestId, promptMessageId) {
|
|
833
|
+
return this.approvals.bindApprovalPrompt(requestId, promptMessageId);
|
|
834
|
+
}
|
|
835
|
+
cancelApproval(requestId, fromUser = "cancelled") {
|
|
836
|
+
return this.approvals.cancelApproval(requestId, fromUser);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
|
|
835
840
|
// src/config.ts
|
|
836
841
|
import { resolvePluginConfig } from "@wrongstack/core/plugin";
|
|
837
842
|
var PLUGIN_NAME = "telegram";
|
|
@@ -1157,7 +1162,8 @@ var PollLock = class {
|
|
|
1157
1162
|
this.startHeartbeat();
|
|
1158
1163
|
return true;
|
|
1159
1164
|
} catch (err) {
|
|
1160
|
-
|
|
1165
|
+
const code = err.code;
|
|
1166
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") {
|
|
1161
1167
|
return false;
|
|
1162
1168
|
}
|
|
1163
1169
|
}
|
|
@@ -1214,7 +1220,7 @@ var PollLock = class {
|
|
|
1214
1220
|
this.onLost?.();
|
|
1215
1221
|
return;
|
|
1216
1222
|
}
|
|
1217
|
-
const tmp = `${this.lockPath}.${process.pid}.tmp`;
|
|
1223
|
+
const tmp = `${this.lockPath}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
1218
1224
|
try {
|
|
1219
1225
|
const payload = { ...current, heartbeatAt: Date.now() };
|
|
1220
1226
|
writeFileSync(tmp, JSON.stringify(payload));
|
|
@@ -1245,7 +1251,7 @@ var PollLock = class {
|
|
|
1245
1251
|
};
|
|
1246
1252
|
|
|
1247
1253
|
// src/offset-store.ts
|
|
1248
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
1254
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
|
|
1249
1255
|
import {
|
|
1250
1256
|
closeSync,
|
|
1251
1257
|
fsyncSync,
|
|
@@ -1307,7 +1313,7 @@ var OffsetStore = class {
|
|
|
1307
1313
|
write(offset) {
|
|
1308
1314
|
if (!this.path || offset < 0) return;
|
|
1309
1315
|
mkdirSync2(dirname2(this.path), { recursive: true });
|
|
1310
|
-
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
1316
|
+
const tmp = `${this.path}.${process.pid}.${randomUUID2().slice(0, 8)}.tmp`;
|
|
1311
1317
|
const fd = openSync(tmp, "w");
|
|
1312
1318
|
try {
|
|
1313
1319
|
writeSync(fd, JSON.stringify(offset));
|
|
@@ -1482,7 +1488,7 @@ and the \`telegram_send\` tool when no chat_id is specified.`,
|
|
|
1482
1488
|
}
|
|
1483
1489
|
|
|
1484
1490
|
// src/tools/telegram-approve.ts
|
|
1485
|
-
import { randomUUID as
|
|
1491
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1486
1492
|
function makeTelegramApproveTool(opts) {
|
|
1487
1493
|
return {
|
|
1488
1494
|
name: "telegram_approve",
|
|
@@ -1529,7 +1535,7 @@ function makeTelegramApproveTool(opts) {
|
|
|
1529
1535
|
throw new Error("Telegram group approvals require explicit per-user configuration.");
|
|
1530
1536
|
}
|
|
1531
1537
|
const expectedUserIds = configuredUserIds.length > 0 ? configuredUserIds : [String(chatId)];
|
|
1532
|
-
const requestId =
|
|
1538
|
+
const requestId = randomUUID3().slice(0, 16);
|
|
1533
1539
|
const yesKey = `approve:${requestId}:yes`;
|
|
1534
1540
|
const noKey = `approve:${requestId}:no`;
|
|
1535
1541
|
const prompt = scrubTelegramOutboundText(input.prompt);
|