@llblab/pi-telegram 0.21.1 → 0.22.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 +6 -4
- package/CHANGELOG.md +474 -452
- package/docs/architecture.md +12 -10
- package/docs/delivery.md +2 -1
- package/docs/locks.md +9 -5
- package/docs/multi-instance-bus.md +5 -5
- package/index.ts +311 -633
- package/lib/bindings.ts +59 -21
- package/lib/bus-api.ts +12 -2
- package/lib/bus-follower.ts +327 -83
- package/lib/bus-leader.ts +201 -121
- package/lib/bus.ts +345 -37
- package/lib/config.ts +168 -28
- package/lib/delivery.ts +148 -61
- package/lib/lifecycle.ts +199 -8
- package/lib/locks.ts +569 -56
- package/lib/logs.ts +178 -19
- package/lib/media.ts +79 -24
- package/lib/model.ts +5 -10
- package/lib/ownership.ts +150 -6
- package/lib/polling.ts +156 -7
- package/lib/preview.ts +15 -3
- package/lib/queue.ts +167 -20
- package/lib/routing.ts +68 -12
- package/lib/sync.ts +105 -32
- package/lib/telegram-api.ts +86 -10
- package/lib/text-groups.ts +71 -15
- package/lib/thread-reconciler.ts +49 -36
- package/lib/threads.ts +505 -118
- package/package.json +1 -1
package/lib/sync.ts
CHANGED
|
@@ -30,8 +30,7 @@ export interface TelegramLeaderThreadSyncDeps {
|
|
|
30
30
|
getRandom?: () => number;
|
|
31
31
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
32
32
|
getThreadReconciliationMachineState?: () =>
|
|
33
|
-
|
|
34
|
-
| undefined;
|
|
33
|
+
ThreadReconciler.ThreadReconciliationMachineState | undefined;
|
|
35
34
|
recordThreadReconciliationPlan?: (
|
|
36
35
|
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
37
36
|
) => void;
|
|
@@ -67,8 +66,7 @@ export interface TelegramTopicLifecycleSyncDeps {
|
|
|
67
66
|
isTopicProvisioningActive?: () => boolean;
|
|
68
67
|
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
69
68
|
getThreadReconciliationMachineState?: () =>
|
|
70
|
-
|
|
71
|
-
| undefined;
|
|
69
|
+
ThreadReconciler.ThreadReconciliationMachineState | undefined;
|
|
72
70
|
recordThreadReconciliationPlan?: (
|
|
73
71
|
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
74
72
|
) => void;
|
|
@@ -83,8 +81,9 @@ export type TelegramTopicLifecycleSyncHandler<TMessage = unknown> = (
|
|
|
83
81
|
lifecycle: TelegramTopicLifecycleSyncUpdate<TMessage>,
|
|
84
82
|
) => Promise<void>;
|
|
85
83
|
|
|
86
|
-
export interface TelegramObservedTopicLifecycleSyncDeps<
|
|
87
|
-
|
|
84
|
+
export interface TelegramObservedTopicLifecycleSyncDeps<
|
|
85
|
+
TSyncState,
|
|
86
|
+
> extends TelegramTopicLifecycleSyncDeps {
|
|
88
87
|
getSyncState: () => TSyncState;
|
|
89
88
|
setSyncState: (state: TSyncState) => void;
|
|
90
89
|
getNowMs?: () => number;
|
|
@@ -122,6 +121,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
|
|
|
122
121
|
body: Record<string, unknown>,
|
|
123
122
|
) => Promise<TResponse>;
|
|
124
123
|
getLeaderTarget: () => TelegramTarget | undefined;
|
|
124
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
125
125
|
clearLeaderTarget: () => void;
|
|
126
126
|
getSyncState: () => TSyncState;
|
|
127
127
|
setSyncState: (state: TSyncState) => void;
|
|
@@ -134,11 +134,9 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
|
|
|
134
134
|
getNowMs?: () => number;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
export function markTelegramConfigSyncChange<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
options?: { nowMs?: number },
|
|
141
|
-
): TSyncState {
|
|
137
|
+
export function markTelegramConfigSyncChange<
|
|
138
|
+
TSyncState extends TelegramSyncState,
|
|
139
|
+
>(state: TSyncState, action: string, options?: { nowMs?: number }): TSyncState {
|
|
142
140
|
const nowMs = options?.nowMs ?? Date.now();
|
|
143
141
|
let nextState = markTelegramSyncSliceFresh(state, "pairing", {
|
|
144
142
|
nowMs,
|
|
@@ -163,10 +161,18 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
163
161
|
if (currentRecord?.target.threadId) {
|
|
164
162
|
const isManualFollower = currentRecord.owner?.kind === "manual-follower";
|
|
165
163
|
if (!isManualFollower) {
|
|
164
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
165
|
+
const stillOwnsLeaderEpoch = () =>
|
|
166
|
+
!deps.getCurrentLeaderEpoch ||
|
|
167
|
+
(leaderEpoch !== undefined &&
|
|
168
|
+
deps.getCurrentLeaderEpoch() === leaderEpoch);
|
|
166
169
|
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
167
170
|
ThreadReconciler.planDisconnectedInstanceThreadCleanup({
|
|
168
|
-
target: currentRecord.target as TelegramTarget & {
|
|
171
|
+
target: currentRecord.target as TelegramTarget & {
|
|
172
|
+
threadId: number;
|
|
173
|
+
},
|
|
169
174
|
instanceId: deps.instanceId,
|
|
175
|
+
leaderEpoch,
|
|
170
176
|
}),
|
|
171
177
|
{
|
|
172
178
|
callApi(method, body) {
|
|
@@ -175,11 +181,17 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
175
181
|
persist() {
|
|
176
182
|
return deps.topicTargetStore.persist();
|
|
177
183
|
},
|
|
184
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
178
185
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
179
186
|
},
|
|
180
187
|
);
|
|
181
|
-
if (
|
|
188
|
+
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
|
|
189
|
+
const offlineChanged =
|
|
190
|
+
deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0;
|
|
191
|
+
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
|
|
192
|
+
if (offlineChanged) {
|
|
182
193
|
await deps.topicTargetStore.persist();
|
|
194
|
+
if (!stillOwnsLeaderEpoch()) return deps.stopPolling();
|
|
183
195
|
}
|
|
184
196
|
}
|
|
185
197
|
const leaderTarget = deps.getLeaderTarget();
|
|
@@ -305,15 +317,27 @@ export async function recoverStaleTelegramTopicApiError<
|
|
|
305
317
|
export async function ensureTelegramLeaderThreadBinding(
|
|
306
318
|
deps: TelegramLeaderThreadSyncDeps,
|
|
307
319
|
): Promise<TelegramOwnTopicProvisionResult | undefined> {
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
320
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
321
|
+
const assertLeaderEpoch = (phase: string): void => {
|
|
322
|
+
if (
|
|
323
|
+
deps.getCurrentLeaderEpoch &&
|
|
324
|
+
(leaderEpoch === undefined ||
|
|
325
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch)
|
|
326
|
+
) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Telegram leader thread binding lost ownership (${phase}).`,
|
|
315
329
|
);
|
|
316
|
-
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
assertLeaderEpoch("start");
|
|
333
|
+
await deps.topicTargetStore.load();
|
|
334
|
+
assertLeaderEpoch("after-load");
|
|
335
|
+
const priorTargets = deps.topicTargetStore.list().filter((record) => {
|
|
336
|
+
return (
|
|
337
|
+
record.instanceId === deps.instanceId &&
|
|
338
|
+
(record.status === "active" || record.status === "starting")
|
|
339
|
+
);
|
|
340
|
+
});
|
|
317
341
|
// Short-circuit: when the instance already has an active thread and we are not
|
|
318
342
|
// force-freshing, reuse it without re-provisioning. A thread belongs to the
|
|
319
343
|
// live instance binding, not to one transient Pi session lifecycle.
|
|
@@ -330,6 +354,7 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
330
354
|
slot: record.slot,
|
|
331
355
|
},
|
|
332
356
|
);
|
|
357
|
+
assertLeaderEpoch("before-reuse");
|
|
333
358
|
return {
|
|
334
359
|
target: record.target,
|
|
335
360
|
slot: record.slot ?? "A",
|
|
@@ -360,6 +385,7 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
360
385
|
}
|
|
361
386
|
if (forcedUnnamedStale) await deps.topicTargetStore.persist();
|
|
362
387
|
}
|
|
388
|
+
assertLeaderEpoch("before-provision");
|
|
363
389
|
const ownTarget = await provisionOwnBusTopic({
|
|
364
390
|
getAllowedUserId: deps.getAllowedUserId,
|
|
365
391
|
instanceId: deps.instanceId,
|
|
@@ -375,6 +401,7 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
375
401
|
getRandom: deps.getRandom,
|
|
376
402
|
recordEvent: deps.recordEvent,
|
|
377
403
|
});
|
|
404
|
+
assertLeaderEpoch("after-provision");
|
|
378
405
|
if (!ownTarget) return undefined;
|
|
379
406
|
const replacementPlan = ThreadReconciler.planThreadReconciliation({
|
|
380
407
|
nowMs: Date.now(),
|
|
@@ -404,7 +431,9 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
404
431
|
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
405
432
|
recordRuntimeEvent: deps.recordEvent,
|
|
406
433
|
});
|
|
434
|
+
assertLeaderEpoch("before-final-persist");
|
|
407
435
|
await deps.topicTargetStore.persist();
|
|
436
|
+
assertLeaderEpoch("after-final-persist");
|
|
408
437
|
return ownTarget;
|
|
409
438
|
}
|
|
410
439
|
|
|
@@ -454,6 +483,53 @@ export function createUnknownTelegramSyncState(): TelegramSyncState {
|
|
|
454
483
|
) as TelegramSyncState;
|
|
455
484
|
}
|
|
456
485
|
|
|
486
|
+
export interface TelegramSyncStateRuntime {
|
|
487
|
+
getState(): TelegramSyncState;
|
|
488
|
+
setState(state: TelegramSyncState): void;
|
|
489
|
+
markConfigChange(action: string): void;
|
|
490
|
+
markSliceFresh(
|
|
491
|
+
slice: TelegramSyncSlice,
|
|
492
|
+
options: { nowMs: number; action: string },
|
|
493
|
+
): void;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function createTelegramSyncStateRuntime(
|
|
497
|
+
initialState = createUnknownTelegramSyncState(),
|
|
498
|
+
): TelegramSyncStateRuntime {
|
|
499
|
+
let state = initialState;
|
|
500
|
+
return {
|
|
501
|
+
getState: () => state,
|
|
502
|
+
setState(nextState) {
|
|
503
|
+
state = nextState;
|
|
504
|
+
},
|
|
505
|
+
markConfigChange(action) {
|
|
506
|
+
state = markTelegramConfigSyncChange(state, action);
|
|
507
|
+
},
|
|
508
|
+
markSliceFresh(slice, options) {
|
|
509
|
+
state = markTelegramSyncSliceFresh(state, slice, options);
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export interface TelegramProvisioningActivityRuntime {
|
|
515
|
+
isActive(): boolean;
|
|
516
|
+
start(): void;
|
|
517
|
+
end(): void;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function createTelegramProvisioningActivityRuntime(): TelegramProvisioningActivityRuntime {
|
|
521
|
+
let activeCount = 0;
|
|
522
|
+
return {
|
|
523
|
+
isActive: () => activeCount > 0,
|
|
524
|
+
start() {
|
|
525
|
+
activeCount += 1;
|
|
526
|
+
},
|
|
527
|
+
end() {
|
|
528
|
+
activeCount = Math.max(0, activeCount - 1);
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
457
533
|
const RECONCILE_TRIGGERS = new Set<TelegramSyncTrigger>([
|
|
458
534
|
"startup",
|
|
459
535
|
"reload",
|
|
@@ -518,9 +594,8 @@ export function createTelegramObservedTopicLifecycleSyncHandler<
|
|
|
518
594
|
>(
|
|
519
595
|
deps: TelegramObservedTopicLifecycleSyncDeps<TSyncState>,
|
|
520
596
|
): TelegramTopicLifecycleSyncHandler<TMessage> {
|
|
521
|
-
const syncTopicLifecycle =
|
|
522
|
-
deps
|
|
523
|
-
);
|
|
597
|
+
const syncTopicLifecycle =
|
|
598
|
+
createTelegramTopicLifecycleSyncHandler<TMessage>(deps);
|
|
524
599
|
return async (lifecycle) => {
|
|
525
600
|
const nowMs = deps.getNowMs ?? Date.now;
|
|
526
601
|
deps.setSyncState(
|
|
@@ -580,14 +655,12 @@ export function createTelegramTopicLifecycleSyncHandler<TMessage = unknown>(
|
|
|
580
655
|
const changed = result.changed;
|
|
581
656
|
if (lifecycle.kind === "created" && deps.isBusEnabled()) {
|
|
582
657
|
const target = lifecycle.target;
|
|
583
|
-
const isKnownInRecords = deps.topicTargetStore
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
);
|
|
590
|
-
});
|
|
658
|
+
const isKnownInRecords = deps.topicTargetStore.list().some((record) => {
|
|
659
|
+
return (
|
|
660
|
+
record.target.chatId === target.chatId &&
|
|
661
|
+
record.target.threadId === target.threadId
|
|
662
|
+
);
|
|
663
|
+
});
|
|
591
664
|
const isKnownInReservations = deps.topicTargetStore
|
|
592
665
|
.listReservations()
|
|
593
666
|
.some((reservation) => {
|
package/lib/telegram-api.ts
CHANGED
|
@@ -273,6 +273,7 @@ interface TelegramApiResponse<T> {
|
|
|
273
273
|
export interface TelegramApiCallOptions {
|
|
274
274
|
signal?: AbortSignal;
|
|
275
275
|
maxAttempts?: number;
|
|
276
|
+
retrySafety?: "safe" | "non-idempotent";
|
|
276
277
|
retryBaseDelayMs?: number;
|
|
277
278
|
sleep?: (ms: number) => Promise<void>;
|
|
278
279
|
}
|
|
@@ -449,6 +450,32 @@ function sanitizeFileName(name: string): string {
|
|
|
449
450
|
return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
450
451
|
}
|
|
451
452
|
|
|
453
|
+
class TelegramApiMalformedSuccessError extends Error {
|
|
454
|
+
constructor(method: string, detail: string) {
|
|
455
|
+
super(`Telegram API ${method} ${detail}`);
|
|
456
|
+
this.name = "TelegramApiMalformedSuccessError";
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export class TelegramApiCommitUnknownError extends Error {
|
|
461
|
+
readonly kind = "commit-unknown" as const;
|
|
462
|
+
readonly method: string;
|
|
463
|
+
override readonly cause: unknown;
|
|
464
|
+
|
|
465
|
+
constructor(method: string, cause: unknown) {
|
|
466
|
+
super(`Telegram API ${method} may have committed before transport failed.`);
|
|
467
|
+
this.name = "TelegramApiCommitUnknownError";
|
|
468
|
+
this.method = method;
|
|
469
|
+
this.cause = cause;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function isTelegramApiCommitUnknownError(
|
|
474
|
+
error: unknown,
|
|
475
|
+
): error is TelegramApiCommitUnknownError {
|
|
476
|
+
return error instanceof TelegramApiCommitUnknownError;
|
|
477
|
+
}
|
|
478
|
+
|
|
452
479
|
class TelegramApiHttpError extends Error {
|
|
453
480
|
readonly status: number | undefined;
|
|
454
481
|
readonly retryAfterSeconds: number | undefined;
|
|
@@ -469,6 +496,30 @@ export function isTelegramMessageNotModifiedError(error: unknown): boolean {
|
|
|
469
496
|
);
|
|
470
497
|
}
|
|
471
498
|
|
|
499
|
+
const TELEGRAM_RETRY_SAFE_METHODS = new Set([
|
|
500
|
+
"answerCallbackQuery",
|
|
501
|
+
"closeForumTopic",
|
|
502
|
+
"deleteForumTopic",
|
|
503
|
+
"deleteMessage",
|
|
504
|
+
"deleteWebhook",
|
|
505
|
+
"editForumTopic",
|
|
506
|
+
"editMessageCaption",
|
|
507
|
+
"editMessageReplyMarkup",
|
|
508
|
+
"editMessageText",
|
|
509
|
+
"getChat",
|
|
510
|
+
"getFile",
|
|
511
|
+
"getMe",
|
|
512
|
+
"getUpdates",
|
|
513
|
+
"sendChatAction",
|
|
514
|
+
"sendMessageDraft",
|
|
515
|
+
"sendRichMessageDraft",
|
|
516
|
+
"setMyCommands",
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
export function isTelegramApiMethodRetrySafe(method: string): boolean {
|
|
520
|
+
return TELEGRAM_RETRY_SAFE_METHODS.has(method);
|
|
521
|
+
}
|
|
522
|
+
|
|
472
523
|
function isRetryableTelegramApiError(error: unknown): boolean {
|
|
473
524
|
return (
|
|
474
525
|
error instanceof TelegramApiHttpError &&
|
|
@@ -579,22 +630,23 @@ async function parseTelegramApiResponse<TResponse>(
|
|
|
579
630
|
Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
|
|
580
631
|
);
|
|
581
632
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
);
|
|
633
|
+
if (!data) {
|
|
634
|
+
throw new TelegramApiMalformedSuccessError(method, "returned invalid JSON");
|
|
635
|
+
}
|
|
636
|
+
return data;
|
|
588
637
|
}
|
|
589
638
|
|
|
590
639
|
function unwrapTelegramApiResult<TResponse>(
|
|
591
640
|
method: string,
|
|
592
641
|
data: TelegramApiResponse<TResponse>,
|
|
593
642
|
): TResponse {
|
|
594
|
-
if (
|
|
643
|
+
if (data.ok && data.result === undefined) {
|
|
644
|
+
throw new TelegramApiMalformedSuccessError(method, "returned no result");
|
|
645
|
+
}
|
|
646
|
+
if (!data.ok) {
|
|
595
647
|
throw new Error(data.description || `Telegram API ${method} failed`);
|
|
596
648
|
}
|
|
597
|
-
return data.result;
|
|
649
|
+
return data.result as TResponse;
|
|
598
650
|
}
|
|
599
651
|
|
|
600
652
|
function getTelegramNetworkFamilyPolicy(
|
|
@@ -754,11 +806,13 @@ async function telegramFetch(
|
|
|
754
806
|
|
|
755
807
|
async function callTelegramTransportRequest(
|
|
756
808
|
request: (family?: TelegramNetworkFamily) => Promise<Response>,
|
|
809
|
+
allowFallback = true,
|
|
757
810
|
): Promise<Response> {
|
|
758
811
|
const policy = getTelegramNetworkFamilyPolicy();
|
|
759
812
|
if (policy === "auto") return request();
|
|
760
813
|
const family = getTelegramNetworkFamily(policy);
|
|
761
814
|
if (family) return request(family);
|
|
815
|
+
if (!allowFallback) return request();
|
|
762
816
|
try {
|
|
763
817
|
return await request();
|
|
764
818
|
} catch (error) {
|
|
@@ -864,6 +918,10 @@ async function callTelegramWithRetry<TResponse>(
|
|
|
864
918
|
request: (family?: TelegramNetworkFamily) => Promise<Response>,
|
|
865
919
|
options: TelegramApiCallOptions | undefined,
|
|
866
920
|
): Promise<TResponse> {
|
|
921
|
+
const retrySafe =
|
|
922
|
+
options?.retrySafety === "safe" ||
|
|
923
|
+
(options?.retrySafety !== "non-idempotent" &&
|
|
924
|
+
isTelegramApiMethodRetrySafe(method));
|
|
867
925
|
const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
|
|
868
926
|
const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
|
|
869
927
|
const sleep = options?.sleep ?? sleepTelegramRetry;
|
|
@@ -872,14 +930,32 @@ async function callTelegramWithRetry<TResponse>(
|
|
|
872
930
|
return unwrapTelegramApiResult(
|
|
873
931
|
method,
|
|
874
932
|
await parseTelegramApiResponse<TResponse>(
|
|
875
|
-
await callTelegramTransportRequest(request),
|
|
933
|
+
await callTelegramTransportRequest(request, retrySafe),
|
|
876
934
|
method,
|
|
877
935
|
),
|
|
878
936
|
);
|
|
879
937
|
} catch (error) {
|
|
880
|
-
|
|
938
|
+
const retryable = isRetryableTelegramApiError(error);
|
|
939
|
+
if (!retrySafe) {
|
|
940
|
+
if (error instanceof TelegramApiHttpError && error.status === 429) {
|
|
941
|
+
if (attempt >= maxAttempts - 1) throw error;
|
|
942
|
+
await sleep(
|
|
943
|
+
getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs),
|
|
944
|
+
);
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (
|
|
948
|
+
error instanceof TelegramApiMalformedSuccessError ||
|
|
949
|
+
isTelegramTransportFailure(error) ||
|
|
950
|
+
(error instanceof TelegramApiHttpError &&
|
|
951
|
+
error.status !== undefined &&
|
|
952
|
+
error.status >= 500)
|
|
953
|
+
) {
|
|
954
|
+
throw new TelegramApiCommitUnknownError(method, error);
|
|
955
|
+
}
|
|
881
956
|
throw error;
|
|
882
957
|
}
|
|
958
|
+
if (attempt >= maxAttempts - 1 || !retryable) throw error;
|
|
883
959
|
await sleep(getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs));
|
|
884
960
|
}
|
|
885
961
|
}
|
package/lib/text-groups.ts
CHANGED
|
@@ -22,14 +22,22 @@ export interface TelegramTextGroupState<TMessage, TContext = unknown> {
|
|
|
22
22
|
messages: TMessage[];
|
|
23
23
|
context?: TContext;
|
|
24
24
|
flushTimer?: ReturnType<typeof setTimeout>;
|
|
25
|
+
dispatching?: boolean;
|
|
26
|
+
suspended?: boolean;
|
|
27
|
+
reschedule?: () => void;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export interface TelegramTextGroupController<TMessage, TContext = unknown> {
|
|
28
31
|
queueMessage: (options: {
|
|
29
32
|
message: TMessage;
|
|
30
33
|
context: TContext;
|
|
31
|
-
dispatchMessages: (
|
|
34
|
+
dispatchMessages: (
|
|
35
|
+
messages: TMessage[],
|
|
36
|
+
ctx: TContext,
|
|
37
|
+
) => unknown | Promise<unknown>;
|
|
32
38
|
}) => boolean;
|
|
39
|
+
suspend: () => void;
|
|
40
|
+
resume: (context: TContext) => void;
|
|
33
41
|
clear: () => void;
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -71,9 +79,10 @@ function getTelegramTextGroupKey(
|
|
|
71
79
|
if (message.media_group_id) return undefined;
|
|
72
80
|
if (!message.from || message.from.is_bot) return undefined;
|
|
73
81
|
if (typeof message.text !== "string") return undefined;
|
|
74
|
-
const threadKey =
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
const threadKey =
|
|
83
|
+
typeof message.message_thread_id === "number"
|
|
84
|
+
? `thread:${message.message_thread_id}`
|
|
85
|
+
: "private";
|
|
77
86
|
return `${message.chat.id}:${threadKey}:${message.from.id}`;
|
|
78
87
|
}
|
|
79
88
|
|
|
@@ -114,7 +123,10 @@ export function queueTelegramTextGroupMessage<
|
|
|
114
123
|
minSplitLength: number;
|
|
115
124
|
setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
116
125
|
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
|
|
117
|
-
dispatchMessages: (
|
|
126
|
+
dispatchMessages: (
|
|
127
|
+
messages: TMessage[],
|
|
128
|
+
ctx: TContext,
|
|
129
|
+
) => unknown | Promise<unknown>;
|
|
118
130
|
}): boolean {
|
|
119
131
|
const key = getTelegramTextGroupKey(options.message);
|
|
120
132
|
if (!key) return false;
|
|
@@ -129,14 +141,45 @@ export function queueTelegramTextGroupMessage<
|
|
|
129
141
|
const state = existing ?? { messages: [] };
|
|
130
142
|
state.messages.push(options.message);
|
|
131
143
|
state.context = options.context;
|
|
144
|
+
const scheduleDispatch = (): void => {
|
|
145
|
+
if (state.suspended) return;
|
|
146
|
+
state.flushTimer = options.setTimer(() => {
|
|
147
|
+
state.flushTimer = undefined;
|
|
148
|
+
const queued = options.groups.get(key);
|
|
149
|
+
if (!queued || queued.context === undefined) return;
|
|
150
|
+
if (queued.dispatching) {
|
|
151
|
+
scheduleDispatch();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const dispatchedMessages = [...queued.messages];
|
|
155
|
+
const dispatchedIds = new Set(
|
|
156
|
+
dispatchedMessages.map((message) => message.message_id),
|
|
157
|
+
);
|
|
158
|
+
queued.dispatching = true;
|
|
159
|
+
void Promise.resolve(
|
|
160
|
+
options.dispatchMessages(dispatchedMessages, queued.context),
|
|
161
|
+
).then(
|
|
162
|
+
() => {
|
|
163
|
+
if (options.groups.get(key) !== queued) return;
|
|
164
|
+
queued.messages = queued.messages.filter(
|
|
165
|
+
(message) => !dispatchedIds.has(message.message_id),
|
|
166
|
+
);
|
|
167
|
+
queued.dispatching = false;
|
|
168
|
+
if (queued.messages.length === 0) options.groups.delete(key);
|
|
169
|
+
else if (!queued.flushTimer) scheduleDispatch();
|
|
170
|
+
},
|
|
171
|
+
() => {
|
|
172
|
+
if (options.groups.get(key) !== queued) return;
|
|
173
|
+
queued.dispatching = false;
|
|
174
|
+
if (!queued.flushTimer) scheduleDispatch();
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}, options.debounceMs);
|
|
178
|
+
state.flushTimer.unref?.();
|
|
179
|
+
};
|
|
180
|
+
state.reschedule = scheduleDispatch;
|
|
132
181
|
if (state.flushTimer) options.clearTimer(state.flushTimer);
|
|
133
|
-
|
|
134
|
-
const queued = options.groups.get(key);
|
|
135
|
-
options.groups.delete(key);
|
|
136
|
-
if (!queued || queued.context === undefined) return;
|
|
137
|
-
options.dispatchMessages(queued.messages, queued.context);
|
|
138
|
-
}, options.debounceMs);
|
|
139
|
-
state.flushTimer.unref?.();
|
|
182
|
+
scheduleDispatch();
|
|
140
183
|
options.groups.set(key, state);
|
|
141
184
|
return true;
|
|
142
185
|
}
|
|
@@ -168,6 +211,20 @@ export function createTelegramTextGroupController<
|
|
|
168
211
|
clearTimer,
|
|
169
212
|
dispatchMessages,
|
|
170
213
|
}),
|
|
214
|
+
suspend: () => {
|
|
215
|
+
for (const state of groups.values()) {
|
|
216
|
+
state.suspended = true;
|
|
217
|
+
if (state.flushTimer) clearTimer(state.flushTimer);
|
|
218
|
+
state.flushTimer = undefined;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
resume: (context) => {
|
|
222
|
+
for (const state of groups.values()) {
|
|
223
|
+
state.context = context;
|
|
224
|
+
state.suspended = false;
|
|
225
|
+
if (!state.dispatching && !state.flushTimer) state.reschedule?.();
|
|
226
|
+
}
|
|
227
|
+
},
|
|
171
228
|
clear: () => {
|
|
172
229
|
for (const state of groups.values()) {
|
|
173
230
|
if (state.flushTimer) clearTimer(state.flushTimer);
|
|
@@ -190,9 +247,8 @@ export function createTelegramTextGroupDispatchRuntime<
|
|
|
190
247
|
const queuedTextGroup = deps.textGroups.queueMessage({
|
|
191
248
|
message,
|
|
192
249
|
context: ctx,
|
|
193
|
-
dispatchMessages: (messages, queuedCtx) =>
|
|
194
|
-
|
|
195
|
-
},
|
|
250
|
+
dispatchMessages: (messages, queuedCtx) =>
|
|
251
|
+
deps.dispatchMessages(messages, queuedCtx),
|
|
196
252
|
});
|
|
197
253
|
if (queuedTextGroup) return;
|
|
198
254
|
await deps.dispatchSingleMessage(message, ctx);
|