@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/media.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { basename, dirname } from "node:path";
8
8
 
9
+ import * as Machine from "./machine.ts";
10
+
9
11
  const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
10
12
  const TELEGRAM_REPLY_CONTEXT_MAX_LENGTH = 1000;
11
13
 
@@ -99,17 +101,6 @@ export interface TelegramMediaGroupMessage {
99
101
  media_group_id?: string;
100
102
  }
101
103
 
102
- export interface TelegramMediaGroupState<TMessage, TContext = unknown> {
103
- messages: TMessage[];
104
- context?: TContext;
105
- flushTimer?: ReturnType<typeof setTimeout>;
106
- dispatching?: boolean;
107
- dispatchPromise?: Promise<void>;
108
- dispatchNow?: () => Promise<void>;
109
- suspended?: boolean;
110
- reschedule?: () => void;
111
- }
112
-
113
104
  export interface TelegramMediaGroupController<
114
105
  TMessage extends TelegramMediaGroupMessage,
115
106
  TContext = unknown,
@@ -490,173 +481,440 @@ export function getTelegramMediaGroupKey(
490
481
  return `${message.chat.id}:${threadKey}:${message.media_group_id}`;
491
482
  }
492
483
 
493
- export function removePendingTelegramMediaGroupMessages<
494
- TMessage extends TelegramMediaGroupMessage,
495
- >(
496
- groups: Map<string, TelegramMediaGroupState<TMessage, unknown>>,
497
- messageIds: number[],
498
- clearTimer: (timer: ReturnType<typeof setTimeout>) => void,
499
- ): number {
500
- if (messageIds.length === 0 || groups.size === 0) return 0;
501
- const deletedMessageIds = new Set(messageIds);
502
- let removedGroups = 0;
503
- for (const [key, state] of groups.entries()) {
504
- if (
505
- !state.messages.some((message) =>
506
- deletedMessageIds.has(message.message_id),
507
- )
508
- ) {
509
- continue;
510
- }
511
- if (state.flushTimer) clearTimer(state.flushTimer);
512
- groups.delete(key);
513
- removedGroups += 1;
514
- }
515
- return removedGroups;
484
+ /** One pending input group; only `transitionTelegramPendingGroups` produces a new one. */
485
+ export interface TelegramPendingGroup<TMessage, TContext, TMeta = undefined> {
486
+ readonly messages: readonly TMessage[];
487
+ readonly context?: TContext;
488
+ /** Domain data carried with the group, such as a text group's forward-comment pairing side. */
489
+ readonly meta?: TMeta;
490
+ readonly timerId: number | null;
491
+ /** The batch in flight; its messages leave the group only once that exact dispatch succeeds. */
492
+ readonly dispatch: {
493
+ readonly id: number;
494
+ readonly messageIds: readonly number[];
495
+ } | null;
496
+ readonly suspended: boolean;
497
+ /** The next batch ends at this message, so later messages wait for the batch after it, whatever is in flight. */
498
+ readonly boundaryId?: number;
516
499
  }
517
500
 
518
- export function queueTelegramMediaGroupMessage<
519
- TMessage extends TelegramMediaGroupMessage,
520
- TContext = unknown,
521
- >(options: {
522
- message: TMessage;
523
- context?: TContext;
524
- groups: Map<string, TelegramMediaGroupState<TMessage, TContext>>;
501
+ export interface TelegramPendingGroupsState<TMessage, TContext, TMeta> {
502
+ readonly groups: ReadonlyMap<
503
+ string,
504
+ TelegramPendingGroup<TMessage, TContext, TMeta>
505
+ >;
506
+ readonly nextTimerId: number;
507
+ readonly nextDispatchId: number;
508
+ }
509
+
510
+ export interface TelegramPendingGroupsConfig {
525
511
  debounceMs: number;
526
- setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
527
- clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
528
- dispatchMessages: (
529
- messages: TMessage[],
530
- ctx?: TContext,
531
- ) => unknown | Promise<unknown>;
532
- }): boolean {
533
- const key = getTelegramMediaGroupKey(options.message);
534
- if (!key) return false;
535
- const existing = options.groups.get(key) ?? { messages: [] };
536
- const duplicateIndex = existing.messages.findIndex(
537
- (message) => message.message_id === options.message.message_id,
538
- );
539
- if (duplicateIndex >= 0) {
540
- existing.messages[duplicateIndex] = options.message;
541
- } else {
542
- existing.messages.push(options.message);
543
- }
544
- existing.context = options.context;
545
- const dispatchQueued = (): Promise<void> => {
546
- existing.flushTimer = undefined;
547
- const state = options.groups.get(key);
548
- if (!state) return Promise.resolve();
549
- if (state.dispatching) return state.dispatchPromise ?? Promise.resolve();
550
- const dispatchedMessages = [...state.messages];
551
- const dispatchedIds = new Set(
552
- dispatchedMessages.map((message) => message.message_id),
553
- );
554
- state.dispatching = true;
555
- const operation = Promise.resolve(
556
- options.dispatchMessages(dispatchedMessages, state.context),
557
- ).then(
558
- () => {
559
- if (options.groups.get(key) !== state) return;
560
- state.messages = state.messages.filter(
561
- (message) => !dispatchedIds.has(message.message_id),
562
- );
563
- state.dispatching = false;
564
- state.dispatchPromise = undefined;
565
- if (state.messages.length === 0) options.groups.delete(key);
566
- else if (!state.flushTimer) scheduleDispatch();
567
- },
568
- (error) => {
569
- if (options.groups.get(key) === state) {
570
- state.dispatching = false;
571
- state.dispatchPromise = undefined;
572
- if (!state.flushTimer) scheduleDispatch();
573
- }
574
- throw error;
512
+ /** A group without a context waits (text) instead of dispatching without one (media). */
513
+ requireContext: boolean;
514
+ }
515
+
516
+ export type TelegramPendingGroupsEvent<TMessage, TContext, TMeta> =
517
+ | {
518
+ type: "queued";
519
+ key: string;
520
+ message: TMessage;
521
+ context?: TContext;
522
+ meta?: TMeta;
523
+ delayMs: number;
524
+ /** `always` restarts the debounce; `ifIdle` arms it only when no timer, dispatch or suspension holds it. */
525
+ rearm: "always" | "ifIdle";
526
+ }
527
+ /** Ends the next batch at the group's current last message; later messages wait for the batch after it. */
528
+ | { type: "batchClosed"; key: string }
529
+ | { type: "timerFired"; key: string; timerId: number }
530
+ | { type: "dispatchSettled"; key: string; dispatchId: number; ok: boolean }
531
+ | { type: "flushRequested"; key: string }
532
+ | { type: "suspended" }
533
+ | { type: "resumed"; context: TContext }
534
+ | { type: "removed"; messageIds: readonly number[] }
535
+ | { type: "keyCleared"; key: string }
536
+ | { type: "cleared" };
537
+
538
+ export type TelegramPendingGroupsEffect<TMessage, TContext> =
539
+ | { type: "armTimer"; key: string; timerId: number; delayMs: number }
540
+ | { type: "cancelTimer"; timerId: number }
541
+ | {
542
+ type: "dispatch";
543
+ key: string;
544
+ dispatchId: number;
545
+ messages: TMessage[];
546
+ context?: TContext;
547
+ };
548
+
549
+ export interface TelegramPendingGroupsStep<
550
+ TMessage,
551
+ TContext,
552
+ TMeta,
553
+ > extends Machine.TelegramMachineStep<
554
+ TelegramPendingGroupsState<TMessage, TContext, TMeta>,
555
+ TelegramPendingGroupsEffect<TMessage, TContext>
556
+ > {
557
+ /** `removed`: how many groups were dropped. */
558
+ result: number;
559
+ }
560
+
561
+ export function createTelegramPendingGroupsState<
562
+ TMessage,
563
+ TContext,
564
+ TMeta = undefined,
565
+ >(): TelegramPendingGroupsState<TMessage, TContext, TMeta> {
566
+ return { groups: new Map(), nextTimerId: 0, nextDispatchId: 0 };
567
+ }
568
+
569
+ /**
570
+ * Pure transition for delayed input groups shared by media albums and split text. A group dispatches one batch at a
571
+ * time; messages that arrive meanwhile wait for the next batch, a failure keeps the batch for a retry, and suspension
572
+ * keeps every admitted message for the replacement context.
573
+ */
574
+ export function transitionTelegramPendingGroups<
575
+ TMessage extends { message_id: number },
576
+ TContext,
577
+ TMeta,
578
+ >(
579
+ state: TelegramPendingGroupsState<TMessage, TContext, TMeta>,
580
+ event: TelegramPendingGroupsEvent<TMessage, TContext, TMeta>,
581
+ config: TelegramPendingGroupsConfig,
582
+ ): TelegramPendingGroupsStep<TMessage, TContext, TMeta> {
583
+ type Group = TelegramPendingGroup<TMessage, TContext, TMeta>;
584
+ type Effect = TelegramPendingGroupsEffect<TMessage, TContext>;
585
+ const groups = new Map(state.groups);
586
+ const effects: Effect[] = [];
587
+ let { nextTimerId, nextDispatchId } = state;
588
+ let result = 0;
589
+ const cancelTimer = (group: Group): Group => {
590
+ if (group.timerId === null) return group;
591
+ effects.push({ type: "cancelTimer", timerId: group.timerId });
592
+ return { ...group, timerId: null };
593
+ };
594
+ const armTimer = (key: string, group: Group, delayMs: number): Group => {
595
+ if (group.suspended) return group;
596
+ nextTimerId += 1;
597
+ effects.push({ type: "armTimer", key, timerId: nextTimerId, delayMs });
598
+ return { ...group, timerId: nextTimerId };
599
+ };
600
+ const startDispatch = (key: string, group: Group): Group => {
601
+ if (group.dispatch) return group;
602
+ if (config.requireContext && group.context === undefined) return group;
603
+ const boundary =
604
+ group.boundaryId === undefined
605
+ ? -1
606
+ : group.messages.findIndex(
607
+ (message) => message.message_id === group.boundaryId,
608
+ );
609
+ const batch =
610
+ boundary < 0
611
+ ? [...group.messages]
612
+ : group.messages.slice(0, boundary + 1);
613
+ nextDispatchId += 1;
614
+ effects.push({
615
+ type: "dispatch",
616
+ key,
617
+ dispatchId: nextDispatchId,
618
+ messages: batch,
619
+ ...(group.context === undefined ? {} : { context: group.context }),
620
+ });
621
+ const { boundaryId: _consumed, ...rest } = group;
622
+ return {
623
+ ...rest,
624
+ dispatch: {
625
+ id: nextDispatchId,
626
+ messageIds: batch.map((message) => message.message_id),
575
627
  },
576
- );
577
- state.dispatchPromise = operation;
578
- return operation;
628
+ };
579
629
  };
580
- const scheduleDispatch = (): void => {
581
- if (existing.suspended) return;
582
- existing.flushTimer = options.setTimer(() => {
583
- void dispatchQueued().catch(() => undefined);
584
- }, options.debounceMs);
585
- existing.flushTimer.unref?.();
630
+ const drop = (key: string, group: Group): void => {
631
+ cancelTimer(group);
632
+ groups.delete(key);
586
633
  };
587
- existing.reschedule = scheduleDispatch;
588
- existing.dispatchNow = dispatchQueued;
589
- if (existing.flushTimer) options.clearTimer(existing.flushTimer);
590
- scheduleDispatch();
591
- options.groups.set(key, existing);
592
- return true;
593
- }
594
-
595
- /** Timer and dispatch lifecycle shared by delayed media-group and text-group coalescing. */
596
- export interface TelegramPendingGroupState<TMessage, TContext> {
597
- messages: TMessage[];
598
- context?: TContext;
599
- flushTimer?: ReturnType<typeof setTimeout>;
600
- dispatching?: boolean;
601
- suspended?: boolean;
602
- reschedule?: () => void;
603
- dispatchNow?: () => Promise<void>;
604
- }
605
-
606
- export interface TelegramPendingGroupLifecycle<TContext> {
634
+ switch (event.type) {
635
+ case "queued": {
636
+ let group: Group = groups.get(event.key) ?? {
637
+ messages: [],
638
+ timerId: null,
639
+ dispatch: null,
640
+ suspended: false,
641
+ };
642
+ const duplicate = group.messages.findIndex(
643
+ (message) => message.message_id === event.message.message_id,
644
+ );
645
+ group = {
646
+ ...group,
647
+ messages:
648
+ duplicate < 0
649
+ ? [...group.messages, event.message]
650
+ : group.messages.map((message, index) =>
651
+ index === duplicate ? event.message : message,
652
+ ),
653
+ context: event.context,
654
+ meta: event.meta,
655
+ };
656
+ const held =
657
+ group.suspended || group.dispatch !== null || group.timerId !== null;
658
+ if (event.rearm === "always" || !held)
659
+ group = armTimer(event.key, cancelTimer(group), event.delayMs);
660
+ groups.set(event.key, group);
661
+ break;
662
+ }
663
+ case "batchClosed": {
664
+ const group = groups.get(event.key);
665
+ const last = group?.messages.at(-1);
666
+ if (group && last)
667
+ groups.set(event.key, { ...group, boundaryId: last.message_id });
668
+ break;
669
+ }
670
+ case "timerFired": {
671
+ const group = groups.get(event.key);
672
+ if (!group || group.timerId !== event.timerId) break;
673
+ groups.set(
674
+ event.key,
675
+ startDispatch(event.key, { ...group, timerId: null }),
676
+ );
677
+ break;
678
+ }
679
+ case "dispatchSettled": {
680
+ const group = groups.get(event.key);
681
+ if (!group || group.dispatch?.id !== event.dispatchId) break;
682
+ const sent = new Set(group.dispatch.messageIds);
683
+ const remaining = event.ok
684
+ ? group.messages.filter((message) => !sent.has(message.message_id))
685
+ : group.messages;
686
+ if (remaining.length === 0) {
687
+ drop(event.key, group);
688
+ break;
689
+ }
690
+ const settled: Group = { ...group, messages: remaining, dispatch: null };
691
+ groups.set(
692
+ event.key,
693
+ settled.timerId === null
694
+ ? armTimer(event.key, settled, config.debounceMs)
695
+ : settled,
696
+ );
697
+ break;
698
+ }
699
+ case "flushRequested": {
700
+ const group = groups.get(event.key);
701
+ if (!group) break;
702
+ groups.set(event.key, startDispatch(event.key, cancelTimer(group)));
703
+ break;
704
+ }
705
+ case "suspended":
706
+ for (const [key, group] of groups)
707
+ groups.set(key, { ...cancelTimer(group), suspended: true });
708
+ break;
709
+ case "resumed":
710
+ for (const [key, group] of groups) {
711
+ const resumed: Group = {
712
+ ...group,
713
+ context: event.context,
714
+ suspended: false,
715
+ };
716
+ groups.set(
717
+ key,
718
+ resumed.dispatch === null && resumed.timerId === null
719
+ ? armTimer(key, resumed, config.debounceMs)
720
+ : resumed,
721
+ );
722
+ }
723
+ break;
724
+ case "removed": {
725
+ const removed = new Set(event.messageIds);
726
+ for (const [key, group] of groups) {
727
+ if (!group.messages.some((message) => removed.has(message.message_id)))
728
+ continue;
729
+ drop(key, group);
730
+ result += 1;
731
+ }
732
+ break;
733
+ }
734
+ case "keyCleared": {
735
+ const group = groups.get(event.key);
736
+ if (group) drop(event.key, group);
737
+ break;
738
+ }
739
+ case "cleared":
740
+ for (const [key, group] of groups) drop(key, group);
741
+ break;
742
+ }
743
+ return { state: { groups, nextTimerId, nextDispatchId }, effects, result };
744
+ }
745
+
746
+ export interface TelegramPendingGroups<TMessage, TContext, TMeta> {
747
+ /** Read-only view of one group; it never grants mutation. */
748
+ get: (
749
+ key: string,
750
+ ) => TelegramPendingGroup<TMessage, TContext, TMeta> | undefined;
751
+ queue: (
752
+ event: Omit<
753
+ Extract<
754
+ TelegramPendingGroupsEvent<TMessage, TContext, TMeta>,
755
+ { type: "queued" }
756
+ >,
757
+ "type"
758
+ > & {
759
+ dispatchMessages: (
760
+ messages: TMessage[],
761
+ ctx?: TContext,
762
+ ) => unknown | Promise<unknown>;
763
+ },
764
+ ) => void;
765
+ clearKey: (key: string) => void;
766
+ closeBatch: (key: string) => void;
767
+ removeMessages: (messageIds: number[]) => number;
768
+ /** Dispatches the group holding this message now; resolves once its batch settles, rejecting if it failed. */
607
769
  flushMessage: (messageId: number) => Promise<boolean>;
608
770
  suspend: () => void;
609
771
  resume: (context: TContext) => void;
610
772
  clear: () => void;
611
773
  }
612
774
 
613
- export function createTelegramPendingGroupLifecycle<
775
+ /**
776
+ * The shell around `transitionTelegramPendingGroups`: it owns timer handles and each key's latest dispatcher, turns
777
+ * every dispatch outcome, including a synchronous throw, into `dispatchSettled`, and keeps each batch's promise for
778
+ * flush callers.
779
+ */
780
+ export function createTelegramPendingGroups<
614
781
  TMessage extends { message_id: number },
615
782
  TContext,
616
- >(
617
- groups: Map<string, TelegramPendingGroupState<TMessage, TContext>>,
618
- clearTimer: (timer: ReturnType<typeof setTimeout>) => void,
619
- ): TelegramPendingGroupLifecycle<TContext> {
620
- return {
621
- async flushMessage(messageId) {
622
- for (const state of groups.values()) {
623
- if (
624
- !state.messages.some((message) => message.message_id === messageId)
625
- ) {
626
- continue;
783
+ TMeta = undefined,
784
+ >(options: {
785
+ debounceMs: number;
786
+ requireContext: boolean;
787
+ setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
788
+ clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
789
+ }): TelegramPendingGroups<TMessage, TContext, TMeta> {
790
+ const config: TelegramPendingGroupsConfig = {
791
+ debounceMs: options.debounceMs,
792
+ requireContext: options.requireContext,
793
+ };
794
+ const timers = new Map<number, ReturnType<typeof setTimeout>>();
795
+ const dispatchers = new Map<
796
+ string,
797
+ (messages: TMessage[], ctx?: TContext) => unknown | Promise<unknown>
798
+ >();
799
+ const batches = new Map<number, Promise<void>>();
800
+ const machine = Machine.createTelegramMachineRunner<
801
+ TelegramPendingGroupsState<TMessage, TContext, TMeta>,
802
+ TelegramPendingGroupsEvent<TMessage, TContext, TMeta>,
803
+ TelegramPendingGroupsEffect<TMessage, TContext>,
804
+ TelegramPendingGroupsStep<TMessage, TContext, TMeta>
805
+ >({
806
+ initial: createTelegramPendingGroupsState<TMessage, TContext, TMeta>(),
807
+ transition: (state, event) =>
808
+ transitionTelegramPendingGroups(state, event, config),
809
+ run(effect) {
810
+ switch (effect.type) {
811
+ case "armTimer": {
812
+ const timer = options.setTimer(() => {
813
+ timers.delete(effect.timerId);
814
+ machine.dispatch({
815
+ type: "timerFired",
816
+ key: effect.key,
817
+ timerId: effect.timerId,
818
+ });
819
+ }, effect.delayMs);
820
+ timer.unref?.();
821
+ timers.set(effect.timerId, timer);
822
+ return;
627
823
  }
628
- if (state.flushTimer) clearTimer(state.flushTimer);
629
- state.flushTimer = undefined;
630
- await state.dispatchNow?.();
631
- if (
632
- state.messages.some((message) => message.message_id === messageId) &&
633
- !state.dispatching
634
- ) {
635
- await state.dispatchNow?.();
824
+ case "cancelTimer": {
825
+ const timer = timers.get(effect.timerId);
826
+ timers.delete(effect.timerId);
827
+ if (timer !== undefined) options.clearTimer(timer);
828
+ return;
829
+ }
830
+ case "dispatch": {
831
+ const settle = (ok: boolean): void => {
832
+ batches.delete(effect.dispatchId);
833
+ machine.dispatch({
834
+ type: "dispatchSettled",
835
+ key: effect.key,
836
+ dispatchId: effect.dispatchId,
837
+ ok,
838
+ });
839
+ };
840
+ let outcome: unknown;
841
+ try {
842
+ // Synchronous on purpose: a fired timer hands its batch to admission in the same turn.
843
+ outcome = dispatchers.get(effect.key)?.(
844
+ effect.messages,
845
+ effect.context,
846
+ );
847
+ } catch (error) {
848
+ outcome = Promise.reject(error);
849
+ }
850
+ const batch = Promise.resolve(outcome).then(
851
+ () => settle(true),
852
+ (error: unknown) => {
853
+ settle(false);
854
+ throw error;
855
+ },
856
+ );
857
+ batch.catch(() => undefined);
858
+ batches.set(effect.dispatchId, batch);
859
+ return;
636
860
  }
637
- return true;
638
861
  }
639
- return false;
640
862
  },
641
- suspend: () => {
642
- for (const state of groups.values()) {
643
- state.suspended = true;
644
- if (state.flushTimer) clearTimer(state.flushTimer);
645
- state.flushTimer = undefined;
863
+ });
864
+ const dispatch = (
865
+ event: TelegramPendingGroupsEvent<TMessage, TContext, TMeta>,
866
+ ): number => {
867
+ const step = machine.dispatch(event);
868
+ for (const key of dispatchers.keys())
869
+ if (!machine.state.groups.has(key)) dispatchers.delete(key);
870
+ return step.result;
871
+ };
872
+ const keyOf = (messageId: number): string | undefined => {
873
+ for (const [key, group] of machine.state.groups)
874
+ if (group.messages.some((message) => message.message_id === messageId))
875
+ return key;
876
+ return undefined;
877
+ };
878
+ const awaitBatch = async (key: string): Promise<void> => {
879
+ const id = machine.state.groups.get(key)?.dispatch?.id;
880
+ if (id !== undefined) await batches.get(id);
881
+ };
882
+ return {
883
+ get: (key) => machine.state.groups.get(key),
884
+ queue({ dispatchMessages, ...event }) {
885
+ dispatchers.set(event.key, dispatchMessages);
886
+ dispatch({ type: "queued", ...event });
887
+ },
888
+ clearKey: (key) => {
889
+ dispatch({ type: "keyCleared", key });
890
+ },
891
+ closeBatch: (key) => {
892
+ dispatch({ type: "batchClosed", key });
893
+ },
894
+ removeMessages: (messageIds) => dispatch({ type: "removed", messageIds }),
895
+ async flushMessage(messageId) {
896
+ const key = keyOf(messageId);
897
+ if (key === undefined) return false;
898
+ dispatch({ type: "flushRequested", key });
899
+ await awaitBatch(key);
900
+ // The batch in flight may have predated this message; send what is left once it settles.
901
+ if (
902
+ keyOf(messageId) === key &&
903
+ !machine.state.groups.get(key)?.dispatch
904
+ ) {
905
+ dispatch({ type: "flushRequested", key });
906
+ await awaitBatch(key);
646
907
  }
908
+ return true;
909
+ },
910
+ suspend: () => {
911
+ dispatch({ type: "suspended" });
647
912
  },
648
913
  resume: (context) => {
649
- for (const state of groups.values()) {
650
- state.context = context;
651
- state.suspended = false;
652
- if (!state.dispatching && !state.flushTimer) state.reschedule?.();
653
- }
914
+ dispatch({ type: "resumed", context });
654
915
  },
655
916
  clear: () => {
656
- for (const state of groups.values()) {
657
- if (state.flushTimer) clearTimer(state.flushTimer);
658
- }
659
- groups.clear();
917
+ dispatch({ type: "cleared" });
660
918
  },
661
919
  };
662
920
  }
@@ -667,27 +925,35 @@ export function createTelegramMediaGroupController<
667
925
  >(
668
926
  options: TelegramMediaGroupControllerOptions = {},
669
927
  ): TelegramMediaGroupController<TMessage, TContext> {
670
- const groups = new Map<string, TelegramMediaGroupState<TMessage, TContext>>();
671
928
  const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
672
- const setTimer =
673
- options.setTimer ??
674
- ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
675
- setTimeout(callback, ms));
676
- const clearTimer = options.clearTimer ?? clearTimeout;
929
+ const groups = createTelegramPendingGroups<TMessage, TContext>({
930
+ debounceMs,
931
+ requireContext: false,
932
+ setTimer:
933
+ options.setTimer ??
934
+ ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
935
+ setTimeout(callback, ms)),
936
+ clearTimer: options.clearTimer ?? clearTimeout,
937
+ });
677
938
  return {
678
- queueMessage: ({ message, context, dispatchMessages }) =>
679
- queueTelegramMediaGroupMessage({
939
+ queueMessage: ({ message, context, dispatchMessages }) => {
940
+ const key = getTelegramMediaGroupKey(message);
941
+ if (!key) return false;
942
+ groups.queue({
943
+ key,
680
944
  message,
681
- context,
682
- groups,
683
- debounceMs,
684
- setTimer,
685
- clearTimer,
945
+ ...(context === undefined ? {} : { context }),
946
+ delayMs: debounceMs,
947
+ rearm: "always",
686
948
  dispatchMessages,
687
- }),
688
- removeMessages: (messageIds) =>
689
- removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
690
- ...createTelegramPendingGroupLifecycle(groups, clearTimer),
949
+ });
950
+ return true;
951
+ },
952
+ removeMessages: groups.removeMessages,
953
+ flushMessage: groups.flushMessage,
954
+ suspend: groups.suspend,
955
+ resume: groups.resume,
956
+ clear: groups.clear,
691
957
  };
692
958
  }
693
959