@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.
- package/AGENTS.md +4 -2
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +16 -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 +3 -1
- package/dist/lib/bindings.js +8 -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 +50 -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 +7 -3
- package/dist/lib/bus.js +24 -16
- package/dist/lib/delivery.d.ts +4 -0
- package/dist/lib/delivery.js +48 -15
- package/dist/lib/extension.js +48 -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 +7 -2
- package/dist/lib/routing.js +585 -229
- 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 +18 -5
- package/dist/lib/threads.js +107 -30
- package/dist/lib/turns.d.ts +6 -0
- package/dist/lib/turns.js +12 -1
- package/dist/lib/updates.d.ts +13 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +14 -11
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +20 -8
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +2 -2
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +9 -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 +30 -18
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +51 -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 +614 -257
- 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 +120 -36
- package/lib/turns.ts +15 -1
- package/lib/updates.ts +60 -21
- 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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
>
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
setTimer,
|
|
685
|
-
clearTimer,
|
|
945
|
+
...(context === undefined ? {} : { context }),
|
|
946
|
+
delayMs: debounceMs,
|
|
947
|
+
rearm: "always",
|
|
686
948
|
dispatchMessages,
|
|
687
|
-
})
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
|