@llblab/pi-telegram 0.54.3 → 0.55.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/AGENTS.md +4 -2
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +5 -3
- package/dist/lib/activity.d.ts +5 -1
- package/dist/lib/activity.js +31 -2
- package/dist/lib/bindings.d.ts +5 -1
- package/dist/lib/bindings.js +9 -2
- package/dist/lib/bus-api.d.ts +2 -2
- package/dist/lib/bus-api.js +7 -2
- package/dist/lib/bus-follower.d.ts +49 -3
- package/dist/lib/bus-follower.js +313 -146
- package/dist/lib/bus-leader.d.ts +3 -0
- package/dist/lib/bus-leader.js +99 -18
- package/dist/lib/bus.d.ts +5 -3
- package/dist/lib/bus.js +13 -10
- package/dist/lib/delivery.d.ts +4 -0
- package/dist/lib/delivery.js +48 -15
- package/dist/lib/extension.js +56 -6
- package/dist/lib/lifecycle.d.ts +68 -0
- package/dist/lib/lifecycle.js +178 -58
- package/dist/lib/machine.d.ts +23 -0
- package/dist/lib/machine.js +37 -0
- package/dist/lib/media.d.ts +114 -28
- package/dist/lib/media.js +292 -119
- package/dist/lib/model.d.ts +54 -15
- package/dist/lib/model.js +98 -53
- package/dist/lib/polling.js +2 -0
- package/dist/lib/preview.d.ts +128 -113
- package/dist/lib/preview.js +448 -366
- package/dist/lib/queue.d.ts +2 -2
- package/dist/lib/queue.js +3 -3
- package/dist/lib/routing.d.ts +14 -2
- package/dist/lib/routing.js +546 -207
- package/dist/lib/runtime.d.ts +77 -11
- package/dist/lib/runtime.js +174 -92
- package/dist/lib/sections.js +0 -1
- package/dist/lib/telegram-api.d.ts +11 -9
- package/dist/lib/telegram-api.js +53 -4
- package/dist/lib/text-groups.d.ts +0 -26
- package/dist/lib/text-groups.js +51 -98
- package/dist/lib/thread-display.js +14 -6
- package/dist/lib/threads.d.ts +20 -5
- package/dist/lib/threads.js +111 -32
- package/dist/lib/turns.d.ts +2 -0
- package/dist/lib/turns.js +6 -1
- package/dist/lib/updates.d.ts +11 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +16 -15
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +18 -8
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +8 -6
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +13 -1
- package/lib/bus-api.ts +11 -3
- package/lib/bus-follower.ts +417 -203
- package/lib/bus-leader.ts +88 -14
- package/lib/bus.ts +17 -15
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +59 -9
- package/lib/lifecycle.ts +229 -55
- package/lib/machine.ts +60 -0
- package/lib/media.ts +438 -172
- package/lib/model.ts +169 -74
- package/lib/polling.ts +2 -0
- package/lib/preview.ts +715 -566
- package/lib/queue.ts +5 -5
- package/lib/routing.ts +644 -283
- package/lib/runtime.ts +260 -104
- package/lib/sections.ts +0 -2
- package/lib/telegram-api.ts +61 -8
- package/lib/text-groups.ts +75 -136
- package/lib/thread-display.ts +15 -4
- package/lib/threads.ts +130 -38
- package/lib/turns.ts +8 -1
- package/lib/updates.ts +58 -21
- package/package.json +1 -1
package/lib/runtime.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import * as Machine from "./machine.ts";
|
|
8
|
+
|
|
7
9
|
const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 3_000;
|
|
8
10
|
const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
|
|
9
11
|
|
|
@@ -23,10 +25,6 @@ export interface TelegramRuntimeLifecycleFlags {
|
|
|
23
25
|
export interface TelegramBridgeRuntimeState
|
|
24
26
|
extends TelegramRuntimeQueueCounters, TelegramRuntimeLifecycleFlags {
|
|
25
27
|
abortHandler?: () => void;
|
|
26
|
-
typingInterval?: ReturnType<typeof setInterval>;
|
|
27
|
-
typingInFlight?: Promise<void>;
|
|
28
|
-
typingLoopDeps?: TelegramTypingLoopDeps;
|
|
29
|
-
typingLoopKey?: string;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
export interface TelegramRuntimeQueuePort {
|
|
@@ -66,7 +64,8 @@ export interface TelegramRuntimeAbortPort {
|
|
|
66
64
|
export interface TelegramRuntimeTypingPort {
|
|
67
65
|
start: (deps: TelegramTypingLoopDeps) => boolean;
|
|
68
66
|
stop: () => boolean;
|
|
69
|
-
|
|
67
|
+
/** Waits for the in-flight action, bounded so a never-settling request cannot hold shutdown. */
|
|
68
|
+
waitForIdle: (timeoutMs?: number) => Promise<void>;
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
export interface TelegramBridgeRuntime {
|
|
@@ -92,6 +91,7 @@ export function createTelegramBridgeRuntimeState(): TelegramBridgeRuntimeState {
|
|
|
92
91
|
|
|
93
92
|
export function createTelegramBridgeRuntime(
|
|
94
93
|
state = createTelegramBridgeRuntimeState(),
|
|
94
|
+
typing = createTelegramTypingLoop(),
|
|
95
95
|
): TelegramBridgeRuntime {
|
|
96
96
|
return {
|
|
97
97
|
state,
|
|
@@ -132,11 +132,7 @@ export function createTelegramBridgeRuntime(
|
|
|
132
132
|
getHandler: () => getTelegramAbortHandler(state),
|
|
133
133
|
abortTurn: () => abortTelegramTurn(state),
|
|
134
134
|
},
|
|
135
|
-
typing
|
|
136
|
-
start: (deps) => startTelegramTypingLoop(state, deps),
|
|
137
|
-
stop: () => stopTelegramTypingLoop(state),
|
|
138
|
-
waitForIdle: () => waitForTelegramTypingLoopIdle(state),
|
|
139
|
-
},
|
|
135
|
+
typing,
|
|
140
136
|
};
|
|
141
137
|
}
|
|
142
138
|
|
|
@@ -302,15 +298,6 @@ export interface TelegramTypingLoopTarget {
|
|
|
302
298
|
threadId?: number;
|
|
303
299
|
}
|
|
304
300
|
|
|
305
|
-
function getTelegramTypingLoopThreadParams(
|
|
306
|
-
target: TelegramTypingLoopTarget | undefined,
|
|
307
|
-
): { message_thread_id?: number } | undefined {
|
|
308
|
-
const threadId = target?.threadId;
|
|
309
|
-
return Number.isInteger(threadId)
|
|
310
|
-
? { message_thread_id: threadId }
|
|
311
|
-
: undefined;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
301
|
export interface TelegramTypingLoopDeps {
|
|
315
302
|
chatId: number | undefined;
|
|
316
303
|
target?: TelegramTypingLoopTarget;
|
|
@@ -361,11 +348,13 @@ export interface TelegramTypingLoopStarterDeps<
|
|
|
361
348
|
getDefaultChatId: () => number | undefined;
|
|
362
349
|
sendTypingAction: (
|
|
363
350
|
chatId: number,
|
|
364
|
-
options?: { message_thread_id?: number },
|
|
351
|
+
options?: { message_thread_id?: number; assertAuthority?: () => void },
|
|
365
352
|
) => Promise<unknown>;
|
|
366
353
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
367
354
|
isContextActive?: (ctx: TContext) => boolean;
|
|
355
|
+
getSessionGeneration?: () => number;
|
|
368
356
|
isTransportAvailable?: () => boolean;
|
|
357
|
+
isThreadedMode?: () => boolean;
|
|
369
358
|
getTransportAuthority?: () => string | number | undefined;
|
|
370
359
|
intervalMs?: number;
|
|
371
360
|
}
|
|
@@ -378,30 +367,43 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
378
367
|
options?: { target?: TelegramTypingLoopTarget },
|
|
379
368
|
) => boolean {
|
|
380
369
|
return (ctx, chatId, options) => {
|
|
370
|
+
const sessionGeneration = deps.getSessionGeneration?.();
|
|
381
371
|
const transportAuthority = deps.getTransportAuthority?.();
|
|
372
|
+
const isContextCurrent = (): boolean => deps.isContextActive?.(ctx) !== false &&
|
|
373
|
+
(!deps.getSessionGeneration || Object.is(deps.getSessionGeneration(), sessionGeneration));
|
|
382
374
|
const hasTransport = (): boolean =>
|
|
383
375
|
deps.getTransportAuthority
|
|
384
376
|
? transportAuthority !== undefined &&
|
|
385
377
|
Object.is(deps.getTransportAuthority(), transportAuthority)
|
|
386
378
|
: deps.isTransportAvailable?.() !== false;
|
|
387
|
-
|
|
379
|
+
const target = options?.target ? { ...options.target } : undefined;
|
|
380
|
+
const canContinue = (): boolean => isContextCurrent() && hasTransport() &&
|
|
381
|
+
(deps.isThreadedMode?.() !== true ||
|
|
382
|
+
(target?.threadId !== undefined && Number.isSafeInteger(target.threadId) && target.threadId > 0));
|
|
383
|
+
// Queue dispatch precedes agent_start: only an explicit destination permits Threaded presence, never chat fallback.
|
|
384
|
+
if (!canContinue()) return false;
|
|
388
385
|
let active = true;
|
|
389
386
|
return deps.typing.start({
|
|
390
387
|
chatId: chatId ?? deps.getDefaultChatId(),
|
|
391
|
-
target
|
|
388
|
+
target,
|
|
392
389
|
intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
|
|
393
390
|
sendTypingAction: async (targetChatId, actionOptions) => {
|
|
394
391
|
if (!active) return;
|
|
395
|
-
if (!
|
|
392
|
+
if (!canContinue()) {
|
|
396
393
|
deps.typing.stop();
|
|
397
394
|
return;
|
|
398
395
|
}
|
|
399
396
|
try {
|
|
400
|
-
await deps.sendTypingAction(targetChatId,
|
|
397
|
+
await deps.sendTypingAction(targetChatId, {
|
|
398
|
+
...actionOptions,
|
|
399
|
+
// This stays process-local; adapters recheck it before HTTP/IPC issuance, not just loop ticks.
|
|
400
|
+
assertAuthority() {
|
|
401
|
+
if (!active || !canContinue()) throw new Error("Typing context authority changed.");
|
|
402
|
+
},
|
|
403
|
+
});
|
|
401
404
|
} catch (error) {
|
|
402
|
-
if (deps.isContextActive?.(ctx) === false) return;
|
|
403
405
|
if (!active) return;
|
|
404
|
-
if (!
|
|
406
|
+
if (!canContinue()) {
|
|
405
407
|
deps.typing.stop();
|
|
406
408
|
return;
|
|
407
409
|
}
|
|
@@ -414,7 +416,7 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
414
416
|
}
|
|
415
417
|
}
|
|
416
418
|
},
|
|
417
|
-
shouldContinue:
|
|
419
|
+
shouldContinue: canContinue,
|
|
418
420
|
onStopped: () => {
|
|
419
421
|
active = false;
|
|
420
422
|
},
|
|
@@ -427,85 +429,239 @@ function getTelegramTypingLoopKey(deps: TelegramTypingLoopDeps): string {
|
|
|
427
429
|
return `${deps.chatId ?? 0}:${Number.isInteger(threadId) ? threadId : "all"}`;
|
|
428
430
|
}
|
|
429
431
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
432
|
+
/** The whole typing-loop state; only `transitionTelegramTypingLoop` produces a new one. */
|
|
433
|
+
export interface TelegramTypingMachineState {
|
|
434
|
+
/** The loop the interval serves; its callbacks live in the shell under `id`. A loop exists exactly while ticking. */
|
|
435
|
+
readonly loop: {
|
|
436
|
+
readonly id: number;
|
|
437
|
+
readonly key: string;
|
|
438
|
+
readonly chatId: number;
|
|
439
|
+
readonly threadParams?: { readonly message_thread_id: number };
|
|
440
|
+
} | null;
|
|
441
|
+
/** The action in flight; a tick waits for it, and replacing or stopping the loop forgets it. */
|
|
442
|
+
readonly inFlight: number | null;
|
|
443
|
+
readonly nextSendId: number;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export type TelegramTypingEvent =
|
|
447
|
+
| {
|
|
448
|
+
type: "started";
|
|
449
|
+
/** Shell-assigned, so the shell can hold the caller's callbacks before effects run. */
|
|
450
|
+
loopId: number;
|
|
451
|
+
chatId: number | undefined;
|
|
452
|
+
key: string;
|
|
453
|
+
threadParams?: { message_thread_id: number };
|
|
454
|
+
intervalMs: number;
|
|
455
|
+
/** The caller's `shouldContinue`, sampled at dispatch. */
|
|
456
|
+
continues: boolean;
|
|
455
457
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
458
|
+
| { type: "ticked"; continues: boolean }
|
|
459
|
+
| { type: "sendSettled"; sendId: number }
|
|
460
|
+
| { type: "stopRequested" };
|
|
461
|
+
|
|
462
|
+
export type TelegramTypingEffect =
|
|
463
|
+
| {
|
|
464
|
+
type: "send";
|
|
465
|
+
loopId: number;
|
|
466
|
+
sendId: number;
|
|
467
|
+
chatId: number;
|
|
468
|
+
threadParams?: { message_thread_id: number };
|
|
469
|
+
}
|
|
470
|
+
| { type: "armInterval"; intervalMs: number }
|
|
471
|
+
| { type: "cancelInterval" }
|
|
472
|
+
| { type: "loopEnded"; loopId: number };
|
|
473
|
+
|
|
474
|
+
export interface TelegramTypingStep extends Machine.TelegramMachineStep<
|
|
475
|
+
TelegramTypingMachineState,
|
|
476
|
+
TelegramTypingEffect
|
|
477
|
+
> {
|
|
478
|
+
/** `start`: a loop began or moved to a new target. `stop`: a running loop ended. */
|
|
479
|
+
result: boolean;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function createTelegramTypingMachineState(): TelegramTypingMachineState {
|
|
483
|
+
return { loop: null, inFlight: null, nextSendId: 0 };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const typingStep = (
|
|
487
|
+
state: TelegramTypingMachineState,
|
|
488
|
+
result: boolean,
|
|
489
|
+
...effects: TelegramTypingEffect[]
|
|
490
|
+
): TelegramTypingStep => ({ state, effects, result });
|
|
491
|
+
|
|
492
|
+
function stopTypingLoop(state: TelegramTypingMachineState): TelegramTypingStep {
|
|
493
|
+
if (!state.loop) return typingStep(state, false);
|
|
494
|
+
return typingStep(
|
|
495
|
+
{ ...state, loop: null, inFlight: null },
|
|
496
|
+
true,
|
|
497
|
+
{ type: "cancelInterval" },
|
|
498
|
+
{ type: "loopEnded", loopId: state.loop.id },
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** One action per tick, never two at once; a caller that stops continuing ends the loop instead. */
|
|
503
|
+
function sendTypingAction(
|
|
504
|
+
state: TelegramTypingMachineState,
|
|
505
|
+
continues: boolean,
|
|
506
|
+
): TelegramTypingStep {
|
|
507
|
+
const loop = state.loop;
|
|
508
|
+
if (!loop) return typingStep(state, false);
|
|
509
|
+
if (!continues) return stopTypingLoop(state);
|
|
510
|
+
if (state.inFlight !== null) return typingStep(state, false);
|
|
511
|
+
const sendId = state.nextSendId + 1;
|
|
512
|
+
return typingStep({ ...state, inFlight: sendId, nextSendId: sendId }, false, {
|
|
513
|
+
type: "send",
|
|
514
|
+
loopId: loop.id,
|
|
515
|
+
sendId,
|
|
516
|
+
chatId: loop.chatId,
|
|
517
|
+
...(loop.threadParams ? { threadParams: loop.threadParams } : {}),
|
|
518
|
+
});
|
|
477
519
|
}
|
|
478
520
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
521
|
+
/**
|
|
522
|
+
* Pure typing-loop transition. Every start replaces the caller's callbacks, even on the same target, and forgets the
|
|
523
|
+
* old in-flight action; only a new target sends at once on a running loop, whose interval keeps its phase.
|
|
524
|
+
*/
|
|
525
|
+
export function transitionTelegramTypingLoop(
|
|
526
|
+
state: TelegramTypingMachineState,
|
|
527
|
+
event: TelegramTypingEvent,
|
|
528
|
+
): TelegramTypingStep {
|
|
529
|
+
switch (event.type) {
|
|
530
|
+
case "started": {
|
|
531
|
+
if (event.chatId === undefined || event.chatId === 0)
|
|
532
|
+
return typingStep(state, false);
|
|
533
|
+
const previous = state.loop;
|
|
534
|
+
const replaced: TelegramTypingEffect[] = previous
|
|
535
|
+
? [{ type: "loopEnded", loopId: previous.id }]
|
|
536
|
+
: [];
|
|
537
|
+
const next: TelegramTypingMachineState = {
|
|
538
|
+
...state,
|
|
539
|
+
inFlight: previous ? null : state.inFlight,
|
|
540
|
+
loop: {
|
|
541
|
+
id: event.loopId,
|
|
542
|
+
key: event.key,
|
|
543
|
+
chatId: event.chatId,
|
|
544
|
+
...(event.threadParams ? { threadParams: event.threadParams } : {}),
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
if (previous?.key === event.key)
|
|
548
|
+
return typingStep(next, false, ...replaced);
|
|
549
|
+
if (previous) {
|
|
550
|
+
const sent = sendTypingAction(next, event.continues);
|
|
551
|
+
return typingStep(sent.state, true, ...replaced, ...sent.effects);
|
|
552
|
+
}
|
|
553
|
+
// A fresh loop always arms its interval; a caller that already stopped continuing ends it on the first tick.
|
|
554
|
+
const sent = event.continues
|
|
555
|
+
? sendTypingAction(next, true)
|
|
556
|
+
: typingStep(next, false);
|
|
557
|
+
return typingStep(sent.state, true, ...sent.effects, {
|
|
558
|
+
type: "armInterval",
|
|
559
|
+
intervalMs: event.intervalMs,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
case "ticked":
|
|
563
|
+
return sendTypingAction(state, event.continues);
|
|
564
|
+
case "sendSettled":
|
|
565
|
+
return typingStep(
|
|
566
|
+
state.inFlight === event.sendId ? { ...state, inFlight: null } : state,
|
|
567
|
+
false,
|
|
568
|
+
);
|
|
569
|
+
case "stopRequested":
|
|
570
|
+
return stopTypingLoop(state);
|
|
571
|
+
}
|
|
491
572
|
}
|
|
492
573
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
574
|
+
/**
|
|
575
|
+
* The shell around `transitionTelegramTypingLoop`: it holds each loop's callbacks, samples `shouldContinue` into
|
|
576
|
+
* events, owns the interval and turns every action outcome into `sendSettled`.
|
|
577
|
+
*/
|
|
578
|
+
export function createTelegramTypingLoop(): TelegramRuntimeTypingPort {
|
|
579
|
+
const callbacks = new Map<number, TelegramTypingLoopDeps>();
|
|
580
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
581
|
+
let loopIds = 0;
|
|
582
|
+
const continues = (loopId: number | undefined): boolean =>
|
|
583
|
+
loopId === undefined
|
|
584
|
+
? false
|
|
585
|
+
: callbacks.get(loopId)?.shouldContinue?.() !== false;
|
|
586
|
+
const machine = Machine.createTelegramMachineRunner<
|
|
587
|
+
TelegramTypingMachineState,
|
|
588
|
+
TelegramTypingEvent,
|
|
589
|
+
TelegramTypingEffect,
|
|
590
|
+
TelegramTypingStep
|
|
591
|
+
>({
|
|
592
|
+
initial: createTelegramTypingMachineState(),
|
|
593
|
+
transition: transitionTelegramTypingLoop,
|
|
594
|
+
run(effect) {
|
|
595
|
+
switch (effect.type) {
|
|
596
|
+
case "send": {
|
|
597
|
+
const send = callbacks.get(effect.loopId)?.sendTypingAction;
|
|
598
|
+
// Failures belong to the caller's action; the loop only needs to know the action is over.
|
|
599
|
+
void Promise.resolve()
|
|
600
|
+
.then(() => send?.(effect.chatId, effect.threadParams))
|
|
601
|
+
.then(
|
|
602
|
+
() => undefined,
|
|
603
|
+
() => undefined,
|
|
604
|
+
)
|
|
605
|
+
.then(() =>
|
|
606
|
+
machine.dispatch({ type: "sendSettled", sendId: effect.sendId }),
|
|
607
|
+
);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
case "armInterval":
|
|
611
|
+
interval = setInterval(
|
|
612
|
+
() =>
|
|
613
|
+
machine.dispatch({
|
|
614
|
+
type: "ticked",
|
|
615
|
+
continues: continues(machine.state.loop?.id),
|
|
616
|
+
}),
|
|
617
|
+
effect.intervalMs,
|
|
618
|
+
);
|
|
619
|
+
interval.unref?.();
|
|
620
|
+
return;
|
|
621
|
+
case "cancelInterval":
|
|
622
|
+
clearInterval(interval);
|
|
623
|
+
interval = undefined;
|
|
624
|
+
return;
|
|
625
|
+
case "loopEnded": {
|
|
626
|
+
const ended = callbacks.get(effect.loopId);
|
|
627
|
+
callbacks.delete(effect.loopId);
|
|
628
|
+
ended?.onStopped?.();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
return {
|
|
635
|
+
start(deps) {
|
|
636
|
+
const loopId = ++loopIds;
|
|
637
|
+
callbacks.set(loopId, deps);
|
|
638
|
+
const threadId = deps.target?.threadId;
|
|
639
|
+
const started = machine.dispatch({
|
|
640
|
+
type: "started",
|
|
641
|
+
loopId,
|
|
642
|
+
chatId: deps.chatId,
|
|
643
|
+
key: getTelegramTypingLoopKey(deps),
|
|
644
|
+
...(Number.isInteger(threadId)
|
|
645
|
+
? { threadParams: { message_thread_id: threadId! } }
|
|
646
|
+
: {}),
|
|
647
|
+
intervalMs: deps.intervalMs,
|
|
648
|
+
continues: continues(loopId),
|
|
649
|
+
});
|
|
650
|
+
if (machine.state.loop?.id !== loopId) callbacks.delete(loopId);
|
|
651
|
+
return started.result;
|
|
652
|
+
},
|
|
653
|
+
stop: () => machine.dispatch({ type: "stopRequested" }).result,
|
|
654
|
+
async waitForIdle(timeoutMs = TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS) {
|
|
655
|
+
if (machine.state.inFlight === null) return;
|
|
656
|
+
const idle = machine.waitFor((state) => state.inFlight === null);
|
|
657
|
+
await Promise.race([
|
|
658
|
+
idle,
|
|
659
|
+
timeoutMs <= 0
|
|
660
|
+
? Promise.resolve()
|
|
661
|
+
: new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
|
662
|
+
]);
|
|
663
|
+
},
|
|
664
|
+
};
|
|
509
665
|
}
|
|
510
666
|
|
|
511
667
|
export function createTelegramContextAbortHandlerSetter<
|
|
@@ -568,7 +724,7 @@ export interface TelegramPromptDispatchRuntime<TContext> {
|
|
|
568
724
|
chatId?: number,
|
|
569
725
|
options?: { target?: TelegramTypingLoopTarget },
|
|
570
726
|
) => boolean | void;
|
|
571
|
-
onPromptDispatchStart: (ctx: TContext, chatId?: number) => void;
|
|
727
|
+
onPromptDispatchStart: (ctx: TContext, chatId?: number, target?: TelegramTypingLoopTarget) => void;
|
|
572
728
|
onPromptDispatchFailure: (ctx: TContext, message: string) => void;
|
|
573
729
|
}
|
|
574
730
|
|
|
@@ -592,9 +748,9 @@ export function createTelegramPromptDispatchLifecycle<TContext>(
|
|
|
592
748
|
deps: TelegramPromptDispatchLifecycleDeps<TContext>,
|
|
593
749
|
) {
|
|
594
750
|
return {
|
|
595
|
-
onPromptDispatchStart: (ctx: TContext, chatId?: number): void => {
|
|
751
|
+
onPromptDispatchStart: (ctx: TContext, chatId?: number, target?: TelegramTypingLoopTarget): void => {
|
|
596
752
|
deps.lifecycle.setDispatchPending(true);
|
|
597
|
-
deps.startTypingLoop(ctx, chatId);
|
|
753
|
+
deps.startTypingLoop(ctx, chatId, { target });
|
|
598
754
|
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
|
|
599
755
|
category: "dispatch",
|
|
600
756
|
phase: "status-update",
|
package/lib/sections.ts
CHANGED
package/lib/telegram-api.ts
CHANGED
|
@@ -540,7 +540,22 @@ function parseTelegramApiTargetInteger(value: unknown): number | undefined {
|
|
|
540
540
|
: undefined;
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
-
|
|
543
|
+
/** Optional drain hints are not transport outcomes or request authority. */
|
|
544
|
+
function captureApiDrainObserver(capture?: () => (() => void) | undefined): () => void {
|
|
545
|
+
try {
|
|
546
|
+
const notify = capture?.();
|
|
547
|
+
return () => {
|
|
548
|
+
try {
|
|
549
|
+
const emitted: unknown = notify?.();
|
|
550
|
+
if (emitted) void Promise.resolve(emitted).catch(() => undefined);
|
|
551
|
+
} catch { /* Observer failure is inert. */ }
|
|
552
|
+
};
|
|
553
|
+
} catch { return () => {}; }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function createTelegramApiTargetActivityRuntime(deps: {
|
|
557
|
+
captureDrain?: () => (() => void) | undefined;
|
|
558
|
+
} = {}): TelegramApiTargetActivityRuntime {
|
|
544
559
|
const pending = new Map<
|
|
545
560
|
string,
|
|
546
561
|
{
|
|
@@ -580,18 +595,19 @@ export function createTelegramApiTargetActivityRuntime(): TelegramApiTargetActiv
|
|
|
580
595
|
} else {
|
|
581
596
|
pendingChats.set(chatId, (pendingChats.get(chatId) ?? 0) + 1);
|
|
582
597
|
}
|
|
598
|
+
const notifyDrained = captureApiDrainObserver(deps.captureDrain);
|
|
583
599
|
let completed = false;
|
|
584
600
|
return () => {
|
|
585
601
|
if (completed) return;
|
|
586
602
|
completed = true;
|
|
587
603
|
if (!key) {
|
|
588
604
|
const count = pendingChats.get(chatId);
|
|
589
|
-
if (!count || count <= 1) pendingChats.delete(chatId);
|
|
605
|
+
if (!count || count <= 1) { pendingChats.delete(chatId); notifyDrained(); }
|
|
590
606
|
else pendingChats.set(chatId, count - 1);
|
|
591
607
|
return;
|
|
592
608
|
}
|
|
593
609
|
const current = pending.get(key);
|
|
594
|
-
if (!current || current.count <= 1) pending.delete(key);
|
|
610
|
+
if (!current || current.count <= 1) { pending.delete(key); notifyDrained(); }
|
|
595
611
|
else current.count -= 1;
|
|
596
612
|
};
|
|
597
613
|
},
|
|
@@ -824,6 +840,11 @@ export interface TelegramBridgeApiRuntimeDeps {
|
|
|
824
840
|
chatActionMaxGates?: number;
|
|
825
841
|
}
|
|
826
842
|
|
|
843
|
+
/** Typing issuance guard is local authority, never Bot API body or IPC data. */
|
|
844
|
+
export interface TelegramTypingActionOptions extends Pick<TelegramApiCallOptions, "assertAuthority"> {
|
|
845
|
+
message_thread_id?: number;
|
|
846
|
+
}
|
|
847
|
+
|
|
827
848
|
export interface TelegramBridgeApiRuntime extends Pick<
|
|
828
849
|
TelegramApiClient,
|
|
829
850
|
"call" | "callMultipart"
|
|
@@ -849,7 +870,7 @@ export interface TelegramBridgeApiRuntime extends Pick<
|
|
|
849
870
|
) => Promise<boolean>;
|
|
850
871
|
sendTypingAction: (
|
|
851
872
|
chatId: number,
|
|
852
|
-
options?:
|
|
873
|
+
options?: TelegramTypingActionOptions,
|
|
853
874
|
) => Promise<unknown>;
|
|
854
875
|
sendRecordVoiceAction: (
|
|
855
876
|
chatId: number,
|
|
@@ -1874,12 +1895,12 @@ export function createTelegramChatActionSender<TAction extends string>(
|
|
|
1874
1895
|
sendChatAction: (
|
|
1875
1896
|
chatId: number,
|
|
1876
1897
|
action: TAction,
|
|
1877
|
-
options?:
|
|
1898
|
+
options?: TelegramTypingActionOptions,
|
|
1878
1899
|
) => Promise<unknown>,
|
|
1879
1900
|
action: TAction,
|
|
1880
1901
|
): (
|
|
1881
1902
|
chatId: number,
|
|
1882
|
-
options?:
|
|
1903
|
+
options?: TelegramTypingActionOptions,
|
|
1883
1904
|
) => Promise<unknown> {
|
|
1884
1905
|
return (chatId, options) => sendChatAction(chatId, action, options);
|
|
1885
1906
|
}
|
|
@@ -1958,6 +1979,7 @@ export type TelegramWorkspaceThreadDeletionTransport = (
|
|
|
1958
1979
|
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
1959
1980
|
getBotToken: () => string | undefined;
|
|
1960
1981
|
getBotScope?: () => string | undefined;
|
|
1982
|
+
isThreadedMode?: () => boolean;
|
|
1961
1983
|
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
|
1962
1984
|
captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
|
|
1963
1985
|
targetActivity?: TelegramApiTargetActivityRuntime;
|
|
@@ -1969,6 +1991,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
|
1969
1991
|
} {
|
|
1970
1992
|
const client = createTelegramApiClient(deps.getBotToken, {
|
|
1971
1993
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1994
|
+
isThreadedMode: deps.isThreadedMode,
|
|
1972
1995
|
});
|
|
1973
1996
|
const admittedClient = deps.workspaceAdmission
|
|
1974
1997
|
? createTelegramApiWorkspaceAdmissionClient(
|
|
@@ -2114,6 +2137,8 @@ export function createTelegramBridgeApiRuntime(
|
|
|
2114
2137
|
.then(() =>
|
|
2115
2138
|
deps.client.call<TResponse>(method, body, {
|
|
2116
2139
|
...options,
|
|
2140
|
+
// Presence is replaceable, not a queued body to replay after its session/loop owner ends.
|
|
2141
|
+
...(body.action === "typing" ? { maxAttempts: 1, retrySafety: "non-idempotent" as const } : {}),
|
|
2117
2142
|
retryRateLimit: false,
|
|
2118
2143
|
}),
|
|
2119
2144
|
)
|
|
@@ -2270,7 +2295,7 @@ export function createTelegramBridgeApiRuntime(
|
|
|
2270
2295
|
...(options?.message_thread_id !== undefined
|
|
2271
2296
|
? { message_thread_id: options.message_thread_id }
|
|
2272
2297
|
: {}),
|
|
2273
|
-
}),
|
|
2298
|
+
}, { assertAuthority: options?.assertAuthority }),
|
|
2274
2299
|
"typing",
|
|
2275
2300
|
),
|
|
2276
2301
|
sendRecordVoiceAction: createTelegramChatActionSender(
|
|
@@ -2405,10 +2430,34 @@ export function createTelegramBridgeApiRuntime(
|
|
|
2405
2430
|
*/
|
|
2406
2431
|
export function createTelegramApiClient(
|
|
2407
2432
|
getBotToken: () => string | undefined,
|
|
2408
|
-
options: TelegramAnswerCallbackQueryOptions & { now?: () => number } = {},
|
|
2433
|
+
options: TelegramAnswerCallbackQueryOptions & { now?: () => number; isThreadedMode?: () => boolean } = {},
|
|
2409
2434
|
): TelegramApiClient {
|
|
2410
2435
|
const now = options.now ?? Date.now;
|
|
2411
2436
|
const recordRuntimeEvent = options.recordRuntimeEvent;
|
|
2437
|
+
const isThreadedMode = options.isThreadedMode;
|
|
2438
|
+
const fenceOutput = (
|
|
2439
|
+
method: string,
|
|
2440
|
+
body: Record<string, unknown>,
|
|
2441
|
+
callOptions?: TelegramApiCallOptions,
|
|
2442
|
+
): TelegramApiCallOptions | undefined => {
|
|
2443
|
+
if (!isThreadedMode || !/^(send[A-Z]|copyMessages?$|forwardMessages?$)/.test(method)) return callOptions;
|
|
2444
|
+
const assertAuthority = () => {
|
|
2445
|
+
const chatId = typeof body.chat_id === "number" || typeof body.chat_id === "string"
|
|
2446
|
+
? Number(body.chat_id) : undefined;
|
|
2447
|
+
const thread = body.message_thread_id;
|
|
2448
|
+
const threadId = typeof thread === "number" || (typeof thread === "string" && /^[1-9][0-9]*$/.test(thread))
|
|
2449
|
+
? Number(thread) : undefined;
|
|
2450
|
+
// Business connections and channels are distinct from the bot's private-chat All namespace.
|
|
2451
|
+
if (
|
|
2452
|
+
isThreadedMode() && chatId !== undefined && Number.isSafeInteger(chatId) && chatId > 0 &&
|
|
2453
|
+
!(typeof body.business_connection_id === "string" && body.business_connection_id.length > 0) &&
|
|
2454
|
+
(threadId === undefined || !Number.isSafeInteger(threadId) || threadId <= 0)
|
|
2455
|
+
) throw new Error("Telegram All is input-only in Threaded Mode; an explicit Thread target is required.");
|
|
2456
|
+
callOptions?.assertAuthority?.();
|
|
2457
|
+
};
|
|
2458
|
+
assertAuthority();
|
|
2459
|
+
return { ...callOptions, assertAuthority };
|
|
2460
|
+
};
|
|
2412
2461
|
const draftRetryNotBeforeByTarget = new Map<string, number>();
|
|
2413
2462
|
return {
|
|
2414
2463
|
call: async <TResponse>(
|
|
@@ -2416,6 +2465,8 @@ export function createTelegramApiClient(
|
|
|
2416
2465
|
body: Record<string, unknown>,
|
|
2417
2466
|
options?: TelegramApiCallOptions,
|
|
2418
2467
|
): Promise<TResponse> => {
|
|
2468
|
+
body = { ...body };
|
|
2469
|
+
options = fenceOutput(method, body, options);
|
|
2419
2470
|
const token = getBotToken();
|
|
2420
2471
|
// Cooldown keys retain only the public bot-id prefix, not the credential.
|
|
2421
2472
|
const botId = token?.match(/^(\d+):/)?.[1];
|
|
@@ -2486,6 +2537,8 @@ export function createTelegramApiClient(
|
|
|
2486
2537
|
fileName,
|
|
2487
2538
|
options,
|
|
2488
2539
|
) => {
|
|
2540
|
+
fields = { ...fields };
|
|
2541
|
+
options = fenceOutput(method, fields, options);
|
|
2489
2542
|
return callTelegramMultipart(
|
|
2490
2543
|
getBotToken(),
|
|
2491
2544
|
method,
|