@llblab/pi-telegram 0.54.4 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +3 -1
  8. package/dist/lib/bindings.js +8 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +50 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +7 -3
  16. package/dist/lib/bus.js +24 -16
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +7 -2
  34. package/dist/lib/routing.js +585 -229
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +6 -0
  46. package/dist/lib/turns.js +12 -1
  47. package/dist/lib/updates.d.ts +13 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +14 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +20 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +30 -18
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +614 -257
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +120 -36
  78. package/lib/turns.ts +15 -1
  79. package/lib/updates.ts +60 -21
  80. 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
- waitForIdle: () => Promise<void>;
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
- if (!hasTransport()) return false;
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: options?.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 (!hasTransport()) {
392
+ if (!canContinue()) {
396
393
  deps.typing.stop();
397
394
  return;
398
395
  }
399
396
  try {
400
- await deps.sendTypingAction(targetChatId, actionOptions);
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 (!hasTransport()) {
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: hasTransport,
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
- export function startTelegramTypingLoop(
431
- state: TelegramBridgeRuntimeState,
432
- deps: TelegramTypingLoopDeps,
433
- ): boolean {
434
- if (deps.chatId === undefined || deps.chatId === 0) return false;
435
- const previousKey = state.typingLoopKey;
436
- const previousDeps = state.typingLoopDeps;
437
- const nextKey = getTelegramTypingLoopKey(deps);
438
- if (previousDeps && previousDeps !== deps) {
439
- previousDeps.onStopped?.();
440
- state.typingInFlight = undefined;
441
- }
442
- state.typingLoopDeps = deps;
443
- state.typingLoopKey = nextKey;
444
- const sendTyping = (): void => {
445
- const activeDeps = state.typingLoopDeps;
446
- if (
447
- !activeDeps ||
448
- activeDeps.chatId === undefined ||
449
- activeDeps.chatId === 0
450
- )
451
- return;
452
- if (activeDeps.shouldContinue?.() === false) {
453
- stopTelegramTypingLoop(state);
454
- return;
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
- if (state.typingInFlight) return;
457
- const targetChatId = activeDeps.chatId;
458
- const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
459
- const typing = Promise.resolve()
460
- .then(() => activeDeps.sendTypingAction(targetChatId, threadParams))
461
- .then(() => undefined)
462
- .catch(() => undefined);
463
- state.typingInFlight = typing;
464
- void typing.finally(() => {
465
- if (state.typingInFlight === typing) state.typingInFlight = undefined;
466
- });
467
- };
468
- if (state.typingInterval) {
469
- if (previousKey === nextKey) return false;
470
- sendTyping();
471
- return true;
472
- }
473
- sendTyping();
474
- state.typingInterval = setInterval(sendTyping, deps.intervalMs);
475
- state.typingInterval.unref?.();
476
- return true;
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
- export function stopTelegramTypingLoop(
480
- state: TelegramBridgeRuntimeState,
481
- ): boolean {
482
- if (!state.typingInterval) return false;
483
- clearInterval(state.typingInterval);
484
- const activeDeps = state.typingLoopDeps;
485
- state.typingInterval = undefined;
486
- state.typingLoopDeps = undefined;
487
- state.typingLoopKey = undefined;
488
- state.typingInFlight = undefined;
489
- activeDeps?.onStopped?.();
490
- return true;
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
- export async function waitForTelegramTypingLoopIdle(
494
- state: TelegramBridgeRuntimeState,
495
- timeoutMs = TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS,
496
- ): Promise<void> {
497
- const inFlight = state.typingInFlight;
498
- if (!inFlight) return;
499
- if (timeoutMs <= 0) {
500
- await Promise.race([inFlight, Promise.resolve()]);
501
- return;
502
- }
503
- await Promise.race([
504
- inFlight,
505
- new Promise<void>((resolve) => {
506
- setTimeout(resolve, timeoutMs);
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
@@ -289,8 +289,6 @@ export function getTelegramSectionDiagnostics(): TelegramSectionDiagnostic[] {
289
289
  return registry ? registry.getDiagnostics() : [];
290
290
  }
291
291
 
292
- // --- Registry ---
293
-
294
292
  const BACK_NAV_ROW = {
295
293
  text: "⬆️ Back",
296
294
  } as const;
@@ -540,7 +540,22 @@ function parseTelegramApiTargetInteger(value: unknown): number | undefined {
540
540
  : undefined;
541
541
  }
542
542
 
543
- export function createTelegramApiTargetActivityRuntime(): TelegramApiTargetActivityRuntime {
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?: { message_thread_id?: number },
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?: { message_thread_id?: number },
1898
+ options?: TelegramTypingActionOptions,
1878
1899
  ) => Promise<unknown>,
1879
1900
  action: TAction,
1880
1901
  ): (
1881
1902
  chatId: number,
1882
- options?: { message_thread_id?: number },
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,