@llblab/pi-telegram 0.52.0 → 0.52.2
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 -5
- package/BACKLOG.md +18 -1
- package/CHANGELOG.md +12 -0
- package/README.md +5 -2
- package/dist/lib/bindings.js +1 -0
- package/dist/lib/bus-api.js +4 -3
- package/dist/lib/extension.js +4 -0
- package/dist/lib/journal.d.ts +7 -1
- package/dist/lib/journal.js +72 -11
- package/dist/lib/locks.d.ts +5 -0
- package/dist/lib/locks.js +29 -11
- package/dist/lib/polling.d.ts +2 -0
- package/dist/lib/polling.js +12 -2
- package/dist/lib/queue.d.ts +2 -0
- package/dist/lib/queue.js +25 -3
- package/dist/lib/routing.d.ts +1 -0
- package/dist/lib/routing.js +118 -44
- package/dist/lib/sync.d.ts +5 -0
- package/dist/lib/sync.js +48 -5
- package/dist/lib/telegram-api.d.ts +2 -0
- package/dist/lib/telegram-api.js +22 -1
- package/dist/lib/threads.d.ts +6 -4
- package/dist/lib/threads.js +45 -3
- package/dist/lib/updates.d.ts +11 -4
- package/dist/lib/updates.js +34 -18
- package/dist/package.json +1 -1
- package/docs/architecture.md +14 -8
- package/docs/ui-style.md +1 -1
- package/lib/bindings.ts +1 -0
- package/lib/bus-api.ts +4 -2
- package/lib/extension.ts +5 -0
- package/lib/journal.ts +80 -11
- package/lib/locks.ts +28 -11
- package/lib/polling.ts +21 -2
- package/lib/queue.ts +26 -3
- package/lib/routing.ts +91 -36
- package/lib/sync.ts +47 -5
- package/lib/telegram-api.ts +26 -1
- package/lib/threads.ts +46 -7
- package/lib/updates.ts +43 -21
- package/package.json +1 -1
package/lib/locks.ts
CHANGED
|
@@ -1489,6 +1489,8 @@ export interface TelegramLockedPollingRuntime<
|
|
|
1489
1489
|
options?: TelegramLockedPollingStartOptions,
|
|
1490
1490
|
) => Promise<TelegramLockedPollingStartResult>;
|
|
1491
1491
|
stop: () => Promise<string>;
|
|
1492
|
+
/** Capture one disconnect attempt before cleanup awaits; a newer start revokes it. */
|
|
1493
|
+
captureStop: () => { isCurrent: () => boolean; stop: () => Promise<string> };
|
|
1492
1494
|
suspend: () => Promise<void>;
|
|
1493
1495
|
isSuspended: () => boolean;
|
|
1494
1496
|
/** Fence one owned polling generation; suspension, restart, conflict or lock loss revokes it. */
|
|
@@ -1582,6 +1584,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1582
1584
|
stopOwnershipWatcher();
|
|
1583
1585
|
if (sessionAutoStartRun) {
|
|
1584
1586
|
await sessionAutoStartRun;
|
|
1587
|
+
if (generation !== pollingGeneration) return;
|
|
1585
1588
|
deps.stopFollowerRegistration?.();
|
|
1586
1589
|
}
|
|
1587
1590
|
if (ownershipStop) {
|
|
@@ -1687,6 +1690,20 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1687
1690
|
const formatStartBlockedMessage = (ctx: TContext): string =>
|
|
1688
1691
|
deps.formatStartBlockedMessage?.(ctx) ??
|
|
1689
1692
|
"Telegram polling is unavailable in this Pi run mode.";
|
|
1693
|
+
const stop = async (): Promise<string> => {
|
|
1694
|
+
const generation = pollingGeneration + 1;
|
|
1695
|
+
await suspendPolling();
|
|
1696
|
+
if (generation !== pollingGeneration) throw new Error("Telegram disconnect was superseded by a new connection.");
|
|
1697
|
+
const state = deps.lock.release();
|
|
1698
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1699
|
+
if (state.kind === "active-elsewhere") {
|
|
1700
|
+
return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
|
|
1701
|
+
}
|
|
1702
|
+
if (state.kind === "stale") {
|
|
1703
|
+
return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
|
|
1704
|
+
}
|
|
1705
|
+
return "Telegram bridge disconnected.";
|
|
1706
|
+
};
|
|
1690
1707
|
return {
|
|
1691
1708
|
start: async (ctx, options = {}) => {
|
|
1692
1709
|
if (!deps.hasBotToken()) {
|
|
@@ -1798,17 +1815,17 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1798
1815
|
const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
|
|
1799
1816
|
return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
|
|
1800
1817
|
},
|
|
1801
|
-
stop
|
|
1802
|
-
|
|
1803
|
-
const
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1818
|
+
stop,
|
|
1819
|
+
captureStop() {
|
|
1820
|
+
const generation = pollingGeneration;
|
|
1821
|
+
const isCurrent = () => generation === pollingGeneration;
|
|
1822
|
+
return {
|
|
1823
|
+
isCurrent,
|
|
1824
|
+
async stop() {
|
|
1825
|
+
if (!isCurrent()) throw new Error("Telegram disconnect was superseded by a new connection.");
|
|
1826
|
+
return stop();
|
|
1827
|
+
},
|
|
1828
|
+
};
|
|
1812
1829
|
},
|
|
1813
1830
|
suspend: suspendPolling,
|
|
1814
1831
|
captureTransportAuthority(ctx) {
|
package/lib/polling.ts
CHANGED
|
@@ -25,7 +25,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
|
|
|
25
25
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
|
|
26
26
|
const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
|
|
27
27
|
const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
|
|
28
|
-
const
|
|
28
|
+
const TELEGRAM_POLLING_RETRY_BASE_MS = 1_000;
|
|
29
|
+
const TELEGRAM_POLLING_RETRY_MAX_MS = 30_000;
|
|
29
30
|
const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
|
|
30
31
|
|
|
31
32
|
// Standard Telegram DM polling does not expose ordinary message-deletion events.
|
|
@@ -79,6 +80,17 @@ export class TelegramPersistentGetUpdatesConflictError extends Error {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/** Exponential backoff for non-conflict polling/admission failures. */
|
|
84
|
+
export function getTelegramPollingRetryDelayMs(
|
|
85
|
+
consecutiveFailures: number,
|
|
86
|
+
): number {
|
|
87
|
+
const exponent = Math.max(0, Math.min(consecutiveFailures - 1, 16));
|
|
88
|
+
return Math.min(
|
|
89
|
+
TELEGRAM_POLLING_RETRY_BASE_MS * 2 ** exponent,
|
|
90
|
+
TELEGRAM_POLLING_RETRY_MAX_MS,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
82
94
|
export class TelegramGetUpdatesTimeoutError extends Error {
|
|
83
95
|
readonly timeoutMs: number;
|
|
84
96
|
|
|
@@ -1709,6 +1721,7 @@ export async function runTelegramPollLoop<
|
|
|
1709
1721
|
>(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
|
|
1710
1722
|
if (!deps.config.botToken) return;
|
|
1711
1723
|
let consecutiveGetUpdatesConflicts = 0;
|
|
1724
|
+
let consecutiveFailures = 0;
|
|
1712
1725
|
const retryConflict = async () => {
|
|
1713
1726
|
consecutiveGetUpdatesConflicts += 1;
|
|
1714
1727
|
if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
|
|
@@ -1791,6 +1804,7 @@ export async function runTelegramPollLoop<
|
|
|
1791
1804
|
onPhaseChange: deps.onPhaseChange,
|
|
1792
1805
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1793
1806
|
});
|
|
1807
|
+
consecutiveFailures = 0;
|
|
1794
1808
|
currentUpdateId = undefined;
|
|
1795
1809
|
} catch (error) {
|
|
1796
1810
|
if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
|
|
@@ -1809,8 +1823,13 @@ export async function runTelegramPollLoop<
|
|
|
1809
1823
|
: {}),
|
|
1810
1824
|
});
|
|
1811
1825
|
consecutiveGetUpdatesConflicts = 0;
|
|
1826
|
+
consecutiveFailures += 1;
|
|
1812
1827
|
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
|
1813
|
-
|
|
1828
|
+
// Cap the pause, not retry count: outages must recover without manual reconnect.
|
|
1829
|
+
await deps.sleep(
|
|
1830
|
+
getTelegramPollingRetryDelayMs(consecutiveFailures),
|
|
1831
|
+
deps.signal,
|
|
1832
|
+
);
|
|
1814
1833
|
if (deps.signal.aborted) return;
|
|
1815
1834
|
deps.onStatusReset();
|
|
1816
1835
|
}
|
package/lib/queue.ts
CHANGED
|
@@ -1028,7 +1028,7 @@ export function applyTelegramQueuePromptReactionDisposition<
|
|
|
1028
1028
|
let nextItems = items;
|
|
1029
1029
|
for (const [index, item] of items.entries()) {
|
|
1030
1030
|
if (
|
|
1031
|
-
!isPendingTelegramTurn(item) ||
|
|
1031
|
+
!isPendingTelegramTurn(item) || item.queueLane === "control" ||
|
|
1032
1032
|
!isTelegramQueueItemInMessageScope(item, scope) ||
|
|
1033
1033
|
!item.sourceMessageIds.includes(messageId)
|
|
1034
1034
|
) {
|
|
@@ -2405,6 +2405,7 @@ export interface TelegramQueueMutationRuntimeDeps<
|
|
|
2405
2405
|
TContext,
|
|
2406
2406
|
> extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
|
|
2407
2407
|
ctx: TContext;
|
|
2408
|
+
hasPendingDispatch?: () => boolean;
|
|
2408
2409
|
allocateLaneOrder?: () => number;
|
|
2409
2410
|
onItemsDiscarded?: (
|
|
2410
2411
|
items: readonly TelegramQueueItem<TContext>[],
|
|
@@ -2416,6 +2417,7 @@ export interface TelegramQueueMutationRuntimeDeps<
|
|
|
2416
2417
|
export interface TelegramQueueMutationControllerDeps<
|
|
2417
2418
|
TContext,
|
|
2418
2419
|
> extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
|
|
2420
|
+
hasPendingDispatch?: () => boolean;
|
|
2419
2421
|
allocateLaneOrder?: () => number;
|
|
2420
2422
|
onItemsDiscarded?: (
|
|
2421
2423
|
items: readonly TelegramQueueItem<TContext>[],
|
|
@@ -2739,7 +2741,14 @@ function commitReorderedTelegramQueueItemsRuntime<TContext>(
|
|
|
2739
2741
|
items: TelegramQueueItem<TContext>[],
|
|
2740
2742
|
deps: TelegramQueueMutationRuntimeDeps<TContext>,
|
|
2741
2743
|
): void {
|
|
2742
|
-
deps.
|
|
2744
|
+
const pendingHead = deps.hasPendingDispatch?.() ? deps.getQueuedItems()[0] : undefined;
|
|
2745
|
+
const ordered = [...items].sort(compareTelegramQueueItems);
|
|
2746
|
+
if (pendingHead) {
|
|
2747
|
+
const index = ordered.findIndex(item => item.queueOrder === pendingHead.queueOrder && item.kind === pendingHead.kind &&
|
|
2748
|
+
item.chatId === pendingHead.chatId && item.replyToMessageId === pendingHead.replyToMessageId);
|
|
2749
|
+
if (index > 0) ordered.unshift(ordered.splice(index, 1)[0]!);
|
|
2750
|
+
}
|
|
2751
|
+
deps.setQueuedItems(ordered);
|
|
2743
2752
|
updateTelegramQueueStatusRuntime(deps);
|
|
2744
2753
|
}
|
|
2745
2754
|
|
|
@@ -2817,9 +2826,23 @@ export function applyTelegramQueuePromptReactionDispositionRuntime<TContext>(
|
|
|
2817
2826
|
scope?: TelegramQueueMessageScope,
|
|
2818
2827
|
): boolean {
|
|
2819
2828
|
const queuedItems = deps.getQueuedItems();
|
|
2829
|
+
const suppression = disposition.kind === "reaction-transition" ? disposition.suppressionEmoji :
|
|
2830
|
+
disposition.kind === "suppressed" ? disposition.emoji : disposition.kind === "priority-suppressed" ? disposition.suppressionEmoji : undefined;
|
|
2831
|
+
// Source-addressed control-lane prompts are explicit continuations. Synthetic model-switch turns have no source IDs.
|
|
2832
|
+
// A Pi-owned dispatched head cannot be removed: agent_start must consume that exact item, not its successor.
|
|
2833
|
+
const cancelled = suppression && deps.hasPendingDispatch ? queuedItems.filter((item, index) =>
|
|
2834
|
+
isPendingTelegramTurn(item) && item.queueLane === "control" && isTelegramQueueItemInMessageScope(item, scope) &&
|
|
2835
|
+
item.sourceMessageIds.includes(messageId) && !(index === 0 && deps.hasPendingDispatch!())) : [];
|
|
2836
|
+
if (cancelled.length) {
|
|
2837
|
+
if (cancelled.some(item => (item.admissionReceipts?.length ?? 0) > 0) && !deps.onItemsDiscarded) return false;
|
|
2838
|
+
deps.onItemsDiscarded?.(cancelled, deps.ctx);
|
|
2839
|
+
deps.setQueuedItems(queuedItems.filter(item => !cancelled.includes(item)));
|
|
2840
|
+
updateTelegramQueueStatusRuntime(deps);
|
|
2841
|
+
return true;
|
|
2842
|
+
}
|
|
2820
2843
|
const changesLane = queuedItems.some((item) => {
|
|
2821
2844
|
if (
|
|
2822
|
-
!isPendingTelegramTurn(item) ||
|
|
2845
|
+
!isPendingTelegramTurn(item) || item.queueLane === "control" ||
|
|
2823
2846
|
!isTelegramQueueItemInMessageScope(item, scope) ||
|
|
2824
2847
|
!item.sourceMessageIds.includes(messageId)
|
|
2825
2848
|
) {
|
package/lib/routing.ts
CHANGED
|
@@ -289,6 +289,7 @@ function formatTelegramTemporaryThreadChooserText(command?: string): string {
|
|
|
289
289
|
"<b>🧵 Choose target thread:</b>",
|
|
290
290
|
"",
|
|
291
291
|
command ? `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.` : "Choose where to send your message.",
|
|
292
|
+
"Unaccepted routing expires after 60 minutes; disposable tabs are removed when clear. Accepted work and restored Threads are kept.",
|
|
292
293
|
"Select the Pi thread that should handle it, or restore a Pi into this tab:",
|
|
293
294
|
].join("\n");
|
|
294
295
|
}
|
|
@@ -674,6 +675,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
674
675
|
captureWorkspaceExternalProtection?: ReturnType<typeof createTelegramWorkspaceExternalProtectionCapture>;
|
|
675
676
|
/** Strict committed abandonment plus retained original; shared by Restore and temporary-input cancellation. */
|
|
676
677
|
inspectRestoreSourceAbandonment?: (updateId: number, journalBindingKey: string) => Threads.TelegramTemporaryThreadCancellationEvidence | undefined;
|
|
678
|
+
inspectRoutingInputGroupExpiry?: (input: Threads.TelegramTemporaryThreadInput) => readonly Threads.TelegramTemporaryThreadCancellationEvidence[] | undefined;
|
|
677
679
|
/** Strict active-journal observation only; a hint or missing source never substitutes for this ACK. */
|
|
678
680
|
inspectRestoreSourceCompletion?: (expected: Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) =>
|
|
679
681
|
(Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) | undefined;
|
|
@@ -2363,22 +2365,45 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2363
2365
|
* absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
|
|
2364
2366
|
* durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
|
|
2365
2367
|
*/
|
|
2366
|
-
const runTemporaryThreadCleanup = async (token: string, cap: TemporaryThreadAuthority): Promise<void> => {
|
|
2367
|
-
const store = cap.store
|
|
2368
|
-
if (!
|
|
2368
|
+
const runTemporaryThreadCleanup = async (token: string, cap: TemporaryThreadAuthority, reconcileExpiry: boolean): Promise<void> => {
|
|
2369
|
+
const store = cap.store;
|
|
2370
|
+
if ((!deps.inspectRestoreSourceAbandonment && !deps.inspectRoutingInputGroupExpiry) || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent()) return;
|
|
2369
2371
|
await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
|
|
2370
2372
|
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
2371
2373
|
if (!cap.isCurrent()) return;
|
|
2372
2374
|
let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
|
|
2373
2375
|
const target = entry?.target;
|
|
2374
|
-
if (!entry || !target || entry.cleanupIssued ||
|
|
2375
|
-
entry.
|
|
2376
|
-
const owner = `telegram-owner:${cap.operatorUserId}
|
|
2377
|
-
|
|
2378
|
-
const
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2376
|
+
if (!entry || !target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
|
|
2377
|
+
entry.source.journalBindingKey !== cap.journalBindingKey) return;
|
|
2378
|
+
const owner = `telegram-owner:${cap.operatorUserId}`;
|
|
2379
|
+
if (reconcileExpiry && deps.inspectRoutingInputGroupExpiry) {
|
|
2380
|
+
for (const group of Threads.getTelegramTemporaryThreadInputs(entry)) {
|
|
2381
|
+
if ([...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])].some(value => isDeepStrictEqual(value, group))) continue;
|
|
2382
|
+
const inspect = (id: number) => deps.inspectRoutingInputGroupExpiry!(group)?.find(value => value.updateId === id);
|
|
2383
|
+
if (!group.updateIds.every(id => inspect(id)?.operatorAuthorityId === owner)) continue;
|
|
2384
|
+
entry = cap.adopt(entry);
|
|
2385
|
+
if (!entry || !cap.isCurrent()) return;
|
|
2386
|
+
let recorded: Threads.TelegramTemporaryThreadEntry | undefined, failure: unknown;
|
|
2387
|
+
try { recorded = store.recordTemporaryThreadInputExpiry(entry, group, cap.authority, inspect); }
|
|
2388
|
+
catch (error) { failure = error; }
|
|
2389
|
+
if (!cap.isCurrent()) return;
|
|
2390
|
+
const retained = store.listTemporaryThreads().find(value => value.token === token);
|
|
2391
|
+
// A lost publication ACK may already have recorded the group or retired a bound tab's temporary frame.
|
|
2392
|
+
if (!retained) return;
|
|
2393
|
+
if (!recorded && !retained.cancelledInputs?.some(value => isDeepStrictEqual(value, group)))
|
|
2394
|
+
throw failure ?? new Error("Chooser expiry metadata was not confirmed.");
|
|
2395
|
+
entry = retained;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
if (!Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target)) return;
|
|
2399
|
+
const cancelled = entry.cancelledInputs ?? [];
|
|
2400
|
+
const cancelledCurrent = () => cancelled.every(input => {
|
|
2401
|
+
const expiry = deps.inspectRoutingInputGroupExpiry?.(input);
|
|
2402
|
+
return input.updateIds.every(updateId => {
|
|
2403
|
+
const evidence = deps.inspectRestoreSourceAbandonment?.(updateId, input.journalBindingKey) ?? expiry?.find(value => value.updateId === updateId);
|
|
2404
|
+
return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId && evidence.operatorAuthorityId === owner;
|
|
2405
|
+
});
|
|
2406
|
+
});
|
|
2382
2407
|
if (!cancelledCurrent()) return;
|
|
2383
2408
|
entry = cap.adopt(entry);
|
|
2384
2409
|
if (!entry || !cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry)) return;
|
|
@@ -2394,33 +2419,49 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2394
2419
|
store.retireTemporaryThread(entry, cap.authority);
|
|
2395
2420
|
});
|
|
2396
2421
|
};
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target)) return;
|
|
2401
|
-
let entry: Threads.TelegramTemporaryThreadEntry | undefined;
|
|
2402
|
-
try { entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
|
|
2403
|
-
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
|
|
2404
|
-
if (!entry || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) ||
|
|
2405
|
-
entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
|
|
2406
|
-
const token = entry.token;
|
|
2422
|
+
// Reuse the one timer per tab for body-free metadata retries. Future retry timers do not join settlement waits;
|
|
2423
|
+
// only their running attempt does. No retry is licensed once a destructive grant may have been issued.
|
|
2424
|
+
const queueTemporaryThreadCleanup = (token: string, cap: TemporaryThreadAuthority, delay: number, reconcileExpiry: boolean, retry = false): void => {
|
|
2407
2425
|
cancelTemporaryThreadCleanup(token);
|
|
2408
|
-
const requested = deps.temporaryThreadCleanupDelayMs;
|
|
2409
|
-
const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
|
|
2410
2426
|
let settle = (): void => undefined;
|
|
2411
2427
|
const task = new Promise<void>(resolve => { settle = resolve; });
|
|
2412
2428
|
const timer = setTimeout(() => {
|
|
2413
2429
|
if (temporaryCleanupTimers.get(token)?.timer !== timer) return;
|
|
2414
2430
|
temporaryCleanupTimers.delete(token);
|
|
2415
|
-
|
|
2416
|
-
|
|
2431
|
+
if (retry) restoreSettlementTasks.add(task);
|
|
2432
|
+
runTemporaryThreadCleanup(token, cap, reconcileExpiry)
|
|
2433
|
+
.catch(error => {
|
|
2434
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" });
|
|
2435
|
+
if (!reconcileExpiry || !cap.isCurrent()) return;
|
|
2436
|
+
try {
|
|
2437
|
+
const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
|
|
2438
|
+
if (retained && !retained.cleanupIssued && cap.isCurrent()) queueTemporaryThreadCleanup(token, cap, 60_000, true, true);
|
|
2439
|
+
} catch { /* Unknown grant state never licenses a retry. */ }
|
|
2440
|
+
})
|
|
2417
2441
|
.finally(settle);
|
|
2418
2442
|
}, delay);
|
|
2419
2443
|
timer.unref?.();
|
|
2420
2444
|
temporaryCleanupTimers.set(token, { timer, settle });
|
|
2421
|
-
restoreSettlementTasks.add(task);
|
|
2445
|
+
if (!retry) restoreSettlementTasks.add(task);
|
|
2422
2446
|
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
2423
2447
|
};
|
|
2448
|
+
/** Starts the quiet period for resolved groups or body-free expiry reconciliation; fresh input/authority revokes it. */
|
|
2449
|
+
const scheduleTemporaryThreadCleanup = (target: Queue.TelegramQueueTarget, ctx: TContext, reconcileExpiry = false): void => {
|
|
2450
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
2451
|
+
if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target)) return;
|
|
2452
|
+
let entry: Threads.TelegramTemporaryThreadEntry | undefined;
|
|
2453
|
+
try { entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
|
|
2454
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
|
|
2455
|
+
if (!entry || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
|
|
2456
|
+
if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry)) {
|
|
2457
|
+
try { reconcileExpiry = Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group)); }
|
|
2458
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
|
|
2459
|
+
}
|
|
2460
|
+
if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry)) return;
|
|
2461
|
+
const requested = deps.temporaryThreadCleanupDelayMs;
|
|
2462
|
+
const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
|
|
2463
|
+
queueTemporaryThreadCleanup(entry.token, cap, delay, reconcileExpiry);
|
|
2464
|
+
};
|
|
2424
2465
|
const recordCancelledTemporaryThreadInput = (pending: PendingUnboundReroute, ctx: TContext, isCurrent: () => boolean): boolean => {
|
|
2425
2466
|
const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
|
|
2426
2467
|
if (!store || target.threadId === undefined) return !pending.temporaryThread;
|
|
@@ -2490,10 +2531,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2490
2531
|
await threads.load();
|
|
2491
2532
|
if (!cap.isCurrent()) return;
|
|
2492
2533
|
const self = cap.authority.executor.instanceId;
|
|
2493
|
-
const
|
|
2534
|
+
const entries = cap.store.listTemporaryThreads();
|
|
2535
|
+
const preserved = entries.filter(entry => entry.operatorUserId === cap.operatorUserId && entry.phase === "created" &&
|
|
2536
|
+
cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" &&
|
|
2537
|
+
Threads.getTelegramTemporaryThreadInputs(entry).some(source => source.journalBindingKey === journalBindingKey &&
|
|
2538
|
+
source.updateIds.some(id => input.routingSourceIds?.includes(id)))).map(entry => entry.token);
|
|
2539
|
+
const disposable = entries.flatMap(entry => entry.operatorUserId === cap.operatorUserId && !preserved.includes(entry.token) &&
|
|
2494
2540
|
entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
|
|
2495
2541
|
cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
|
|
2496
|
-
const forgotten = cap.store.forgetPreviousWorld(cap.authority);
|
|
2542
|
+
const forgotten = cap.store.forgetPreviousWorld(cap.authority, preserved);
|
|
2497
2543
|
if (!forgotten) return;
|
|
2498
2544
|
result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
|
|
2499
2545
|
for (const entry of disposable) {
|
|
@@ -2514,6 +2560,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2514
2560
|
{ maxAttempts: 1, retrySafety: "non-idempotent" }) === true) result.deleted++;
|
|
2515
2561
|
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" }); }
|
|
2516
2562
|
}
|
|
2563
|
+
// Same-instance session replacement may have lost a metadata-only timer after the source was already spent.
|
|
2564
|
+
// Reconstruct from retained tab/source proof under fresh authority, never from a prompt body or an issued delete.
|
|
2565
|
+
for (const entry of cap.store.listTemporaryThreads()) {
|
|
2566
|
+
if (!cap.isCurrent()) break;
|
|
2567
|
+
if (entry.phase !== "created" || !entry.target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
|
|
2568
|
+
entry.source.journalBindingKey !== journalBindingKey) continue;
|
|
2569
|
+
if (Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group)))
|
|
2570
|
+
scheduleTemporaryThreadCleanup(entry.target, ctx, true);
|
|
2571
|
+
}
|
|
2517
2572
|
});
|
|
2518
2573
|
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-forget" }); }
|
|
2519
2574
|
return result;
|
|
@@ -2585,25 +2640,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2585
2640
|
const expireRoutingInput = async (source: Updates.TelegramRoutingInputExpirySource, ctx: TContext, signal: AbortSignal): Promise<void> => {
|
|
2586
2641
|
const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
2587
2642
|
const message = source.original.update.message as Partial<TMessage> | undefined;
|
|
2588
|
-
const
|
|
2643
|
+
const runtimeCurrent = () => !signal.aborted && operator !== undefined && epoch !== undefined &&
|
|
2589
2644
|
deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
|
|
2590
2645
|
deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
|
|
2591
2646
|
source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
|
|
2647
|
+
const current = () => runtimeCurrent() && source.isCurrent();
|
|
2592
2648
|
if (!current() || !deps.runWorkspaceOperation) return;
|
|
2593
2649
|
await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
|
|
2594
2650
|
operationKind: "workspace.expire-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
|
|
2595
2651
|
if (!current()) return;
|
|
2596
2652
|
const restores = deps.getWorkspaceRestoreStore?.();
|
|
2597
2653
|
if (deps.getWorkspaceRestoreStore && !restores) return;
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
for (const pending of pendingUnboundReroutes.values()) {
|
|
2601
|
-
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
|
|
2602
|
-
if (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.workspaceRestore || pending.foreignRetry || pending.cleanup || pending.abandonment?.running) return;
|
|
2603
|
-
}
|
|
2654
|
+
const temporary = restores?.listTemporaryThreads().find(entry => Threads.getTelegramTemporaryThreadInputs(entry).some(input =>
|
|
2655
|
+
input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId)));
|
|
2604
2656
|
const result = source.expire();
|
|
2605
2657
|
if (!result) return;
|
|
2606
|
-
//
|
|
2658
|
+
// Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
|
|
2607
2659
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2608
2660
|
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
|
|
2609
2661
|
removePendingReroute(id);
|
|
@@ -2615,6 +2667,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2615
2667
|
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" }); }
|
|
2616
2668
|
}
|
|
2617
2669
|
}
|
|
2670
|
+
// The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
|
|
2671
|
+
// never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
|
|
2672
|
+
if (temporary?.target && runtimeCurrent()) scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
|
|
2618
2673
|
});
|
|
2619
2674
|
};
|
|
2620
2675
|
const renderCancellationReview = async (view: CancellationReview, isCurrent: () => boolean): Promise<void> => {
|
package/lib/sync.ts
CHANGED
|
@@ -176,6 +176,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
|
|
|
176
176
|
) => void;
|
|
177
177
|
runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
|
|
178
178
|
workspaceOperationKind?: string;
|
|
179
|
+
isDisconnectCurrent?: () => boolean;
|
|
179
180
|
getNowMs?: () => number;
|
|
180
181
|
}
|
|
181
182
|
|
|
@@ -270,15 +271,44 @@ export function createTelegramThreadDisconnectAssembly<
|
|
|
270
271
|
>(
|
|
271
272
|
deps: Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> & {
|
|
272
273
|
stopPolling: () => Promise<string>;
|
|
274
|
+
captureStopPolling?: () => { isCurrent: () => boolean; stop: () => Promise<string> };
|
|
273
275
|
suspendPolling: () => Promise<void>;
|
|
274
276
|
},
|
|
275
277
|
): TelegramThreadDisconnectAssembly {
|
|
276
278
|
return {
|
|
277
|
-
disconnect
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
279
|
+
async disconnect() {
|
|
280
|
+
const captured = deps.captureStopPolling?.();
|
|
281
|
+
const stop = captured?.stop ?? deps.stopPolling;
|
|
282
|
+
let stopAttempted = false;
|
|
283
|
+
const disconnect = createTelegramManualThreadDisconnectHandler({
|
|
284
|
+
...deps,
|
|
285
|
+
workspaceOperationKind: "workspace.disconnect-thread",
|
|
286
|
+
isDisconnectCurrent: captured?.isCurrent ?? deps.isDisconnectCurrent,
|
|
287
|
+
stopPolling() {
|
|
288
|
+
stopAttempted = true;
|
|
289
|
+
return stop();
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
return await disconnect();
|
|
294
|
+
} catch (error) {
|
|
295
|
+
// Never retry a stop whose teardown/release outcome may already have committed.
|
|
296
|
+
if (stopAttempted) {
|
|
297
|
+
deps.recordRuntimeEvent("connection", error, { phase: "disconnect-stop" });
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
deps.recordRuntimeEvent("connection", error, { phase: "disconnect-cleanup" });
|
|
301
|
+
let stopped: string;
|
|
302
|
+
try {
|
|
303
|
+
stopped = await stop();
|
|
304
|
+
} catch (stopError) {
|
|
305
|
+
deps.recordRuntimeEvent("connection", stopError, { phase: "disconnect-stop" });
|
|
306
|
+
throw stopError;
|
|
307
|
+
}
|
|
308
|
+
const separator = /[.!?]$/u.test(stopped) ? " " : ". ";
|
|
309
|
+
return `${stopped}${separator}Thread cleanup was not confirmed. Check /telegram-status --debug.`;
|
|
310
|
+
}
|
|
311
|
+
},
|
|
282
312
|
cleanupForSessionRestart:
|
|
283
313
|
createTelegramSessionRestartThreadCleanupHandler({
|
|
284
314
|
...deps,
|
|
@@ -290,7 +320,11 @@ export function createTelegramThreadDisconnectAssembly<
|
|
|
290
320
|
export function createTelegramManualThreadDisconnectHandler<
|
|
291
321
|
TSyncState extends TelegramSyncState,
|
|
292
322
|
>(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
|
|
323
|
+
const assertCurrent = () => {
|
|
324
|
+
if (deps.isDisconnectCurrent?.() === false) throw new Error("Telegram disconnect was superseded by a new connection.");
|
|
325
|
+
};
|
|
293
326
|
const operation = async (): Promise<string> => {
|
|
327
|
+
assertCurrent();
|
|
294
328
|
const currentRecord = deps.getCurrentThreadRecord();
|
|
295
329
|
let cleanupPending = false;
|
|
296
330
|
if (currentRecord?.target.threadId) {
|
|
@@ -302,6 +336,7 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
302
336
|
if (isManualFollower && !ownsLeader) {
|
|
303
337
|
if (deps.disconnectFollowerThread) {
|
|
304
338
|
const disconnected = await deps.disconnectFollowerThread();
|
|
339
|
+
assertCurrent();
|
|
305
340
|
if (!disconnected) {
|
|
306
341
|
throw new Error(
|
|
307
342
|
"Telegram follower thread deletion requires a live leader registration.",
|
|
@@ -329,6 +364,7 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
329
364
|
const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
|
|
330
365
|
deps.topicTargetStore.upsertPendingCleanup(intent);
|
|
331
366
|
await deps.topicTargetStore.persist();
|
|
367
|
+
assertCurrent();
|
|
332
368
|
const cleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
333
369
|
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
334
370
|
currentLeaderEpoch: leaderEpoch,
|
|
@@ -340,9 +376,11 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
340
376
|
{
|
|
341
377
|
isCleanupTargetProtected,
|
|
342
378
|
callApi(method, body) {
|
|
379
|
+
assertCurrent();
|
|
343
380
|
return deps.callApi(method, body);
|
|
344
381
|
},
|
|
345
382
|
markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
|
|
383
|
+
assertCurrent();
|
|
346
384
|
return deps.topicTargetStore.markStaleByTarget(
|
|
347
385
|
targetToMark,
|
|
348
386
|
syncStatus,
|
|
@@ -350,15 +388,18 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
350
388
|
);
|
|
351
389
|
},
|
|
352
390
|
removeCleanupIntentById(id) {
|
|
391
|
+
assertCurrent();
|
|
353
392
|
return deps.topicTargetStore.removePendingCleanup(id);
|
|
354
393
|
},
|
|
355
394
|
persist() {
|
|
395
|
+
assertCurrent();
|
|
356
396
|
return deps.topicTargetStore.persist();
|
|
357
397
|
},
|
|
358
398
|
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
359
399
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
360
400
|
},
|
|
361
401
|
);
|
|
402
|
+
assertCurrent();
|
|
362
403
|
if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
|
|
363
404
|
return "Thread disconnect superseded by a new binding.";
|
|
364
405
|
}
|
|
@@ -378,6 +419,7 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
378
419
|
}) as TSyncState,
|
|
379
420
|
);
|
|
380
421
|
}
|
|
422
|
+
assertCurrent();
|
|
381
423
|
const stopped = await deps.stopPolling();
|
|
382
424
|
return cleanupPending
|
|
383
425
|
? `${stopped} Telegram thread cleanup remains pending for the next leader.`
|
package/lib/telegram-api.ts
CHANGED
|
@@ -38,6 +38,7 @@ function getTelegramApiTempDir(): string {
|
|
|
38
38
|
return resolveTelegramAttachmentsDir();
|
|
39
39
|
}
|
|
40
40
|
const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
const TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS = 6;
|
|
41
42
|
const activeTelegramApiWorkspaceAdmissionOperationIds = new Set<string>();
|
|
42
43
|
const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
|
|
43
44
|
process.env,
|
|
@@ -1106,6 +1107,25 @@ async function writeTelegramDownloadResponse(
|
|
|
1106
1107
|
);
|
|
1107
1108
|
}
|
|
1108
1109
|
|
|
1110
|
+
async function publishTelegramDownload(
|
|
1111
|
+
partPath: string,
|
|
1112
|
+
targetPath: string,
|
|
1113
|
+
signal?: AbortSignal,
|
|
1114
|
+
): Promise<void> {
|
|
1115
|
+
for (let attempt = 0; ; attempt++) {
|
|
1116
|
+
throwIfTelegramApiCallAborted(signal);
|
|
1117
|
+
try {
|
|
1118
|
+
await rename(partPath, targetPath);
|
|
1119
|
+
return;
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
1122
|
+
// Windows may briefly deny replacement while another publisher/scanner holds the file.
|
|
1123
|
+
if (process.platform !== "win32" || (code !== "EPERM" && code !== "EACCES") || attempt + 1 >= TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS) throw error;
|
|
1124
|
+
await sleepTelegramRetry(50 * 2 ** attempt, signal);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1109
1129
|
async function removeTelegramPartialDownload(path: string): Promise<void> {
|
|
1110
1130
|
try {
|
|
1111
1131
|
await unlink(path);
|
|
@@ -1693,7 +1713,7 @@ export async function downloadTelegramFile(
|
|
|
1693
1713
|
partPath,
|
|
1694
1714
|
options?.maxFileSizeBytes,
|
|
1695
1715
|
);
|
|
1696
|
-
await
|
|
1716
|
+
await publishTelegramDownload(partPath, targetPath, options?.signal);
|
|
1697
1717
|
} catch (error) {
|
|
1698
1718
|
await removeTelegramPartialDownload(partPath);
|
|
1699
1719
|
throw error;
|
|
@@ -1701,6 +1721,11 @@ export async function downloadTelegramFile(
|
|
|
1701
1721
|
return targetPath;
|
|
1702
1722
|
}
|
|
1703
1723
|
|
|
1724
|
+
/** Tooltip-like callback answers omit a terminal sentence period; ellipses and other punctuation stay literal. */
|
|
1725
|
+
export function formatTelegramCallbackAnswerText(text: string | undefined): string | undefined {
|
|
1726
|
+
return text?.replace(/(?<!\.)\.\s*$/u, "");
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1704
1729
|
export async function answerTelegramCallbackQuery(
|
|
1705
1730
|
botToken: string | undefined,
|
|
1706
1731
|
callbackQueryId: string,
|