@llblab/pi-telegram 0.54.3 → 0.55.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 +4 -2
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +21 -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 +5 -1
- package/dist/lib/bindings.js +9 -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 +49 -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 +5 -3
- package/dist/lib/bus.js +13 -10
- package/dist/lib/delivery.d.ts +4 -0
- package/dist/lib/delivery.js +48 -15
- package/dist/lib/extension.js +56 -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 +14 -2
- package/dist/lib/routing.js +546 -207
- 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 +20 -5
- package/dist/lib/threads.js +111 -32
- package/dist/lib/turns.d.ts +2 -0
- package/dist/lib/turns.js +6 -1
- package/dist/lib/updates.d.ts +11 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +16 -15
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +18 -8
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +8 -6
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +13 -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 +17 -15
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +59 -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 +644 -283
- 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 +130 -38
- package/lib/turns.ts +8 -1
- package/lib/updates.ts +58 -21
- package/package.json +1 -1
package/lib/routing.ts
CHANGED
|
@@ -335,6 +335,10 @@ function getTelegramAllTabSourceMessageIds(
|
|
|
335
335
|
);
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
/** Failure retains the source for recovery; Threaded Mode never emits a fallback answer into All. */
|
|
339
|
+
const TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR =
|
|
340
|
+
"All is input-only in Threaded Mode; a routing Thread could not be prepared.";
|
|
341
|
+
|
|
338
342
|
/** What a route chooser moves: `<code>/name</code>` for a command, otherwise "this message". */
|
|
339
343
|
function formatTelegramRouteSubject(command?: string): string {
|
|
340
344
|
return command ? `<code>/${escapeHtml(command)}</code>` : "this message";
|
|
@@ -387,14 +391,6 @@ function formatTelegramRoutingInputPreview(message: object): string {
|
|
|
387
391
|
);
|
|
388
392
|
}
|
|
389
393
|
|
|
390
|
-
function formatTelegramAllTabMenuChooserText(command: string): string {
|
|
391
|
-
return [
|
|
392
|
-
`<b>🚦 Route ${formatTelegramRouteSubject(command)}:</b>`,
|
|
393
|
-
"",
|
|
394
|
-
"<i>To restore a Pi instead, send a message in a new tab.</i>",
|
|
395
|
-
].join("\n");
|
|
396
|
-
}
|
|
397
|
-
|
|
398
394
|
function buildTelegramUnboundRerouteChooserMarkup(
|
|
399
395
|
rerouteId: string,
|
|
400
396
|
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
@@ -462,7 +458,8 @@ function formatTelegramUnboundRerouteChooserText(command?: string): string {
|
|
|
462
458
|
function formatTelegramUnboundRerouteRestoreChooserText(
|
|
463
459
|
command?: string,
|
|
464
460
|
): string {
|
|
465
|
-
|
|
461
|
+
const subject = command ? ` for ${formatTelegramRouteSubject(command)}` : "";
|
|
462
|
+
return `<b>🔁 Restore a Pi into this tab${subject}:</b>`;
|
|
466
463
|
}
|
|
467
464
|
|
|
468
465
|
function formatTelegramUnboundTopicGuidance(): string {
|
|
@@ -585,7 +582,10 @@ function classifyLiveRebindCleanupFailure(
|
|
|
585
582
|
: "unknown";
|
|
586
583
|
}
|
|
587
584
|
|
|
588
|
-
/**
|
|
585
|
+
/**
|
|
586
|
+
* Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. The first
|
|
587
|
+
* delay is not cosmetic: a leader's detached command reply still needs the released, cleanup-free row.
|
|
588
|
+
*/
|
|
589
589
|
export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
|
|
590
590
|
delaysMs: Object.freeze([1_000, 2_000, 5_000, 10_000, 30_000]),
|
|
591
591
|
intervalMs: 60_000,
|
|
@@ -599,7 +599,7 @@ type TelegramLiveRebindSelectedCommandReference = {
|
|
|
599
599
|
selectedCommand: Bus.TelegramBusSelectedCommandInput;
|
|
600
600
|
};
|
|
601
601
|
|
|
602
|
-
/** One warm live attempt with exact
|
|
602
|
+
/** One warm live attempt with independent leader input/cleanup grants and exact peer donor settlement; no startup recovery or legacy Restore ACK. */
|
|
603
603
|
export function createTelegramLiveRebindCoordinator(input: {
|
|
604
604
|
request: Threads.TelegramWorkspaceRestoreRequest;
|
|
605
605
|
/** Captures caller source/reference, admission, profile, transport and Pi lifetime. */
|
|
@@ -643,6 +643,10 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
643
643
|
/** Explicit staged singleton command branch; absence preserves ordinary live input behavior. */
|
|
644
644
|
selectedCommand?: Bus.TelegramBusSelectedCommandInput;
|
|
645
645
|
};
|
|
646
|
+
/** Existing profile admission spans the applied recipient, fresh cleanup decision and one native input grant. */
|
|
647
|
+
cleanupBeforeRelease?(intent: Threads.TelegramWorkspaceLiveRebindIntent, isCurrent: () => boolean): Promise<boolean>;
|
|
648
|
+
/** Restore moves the tab to its new owner: once applied, show that owner's title (best-effort, never blocking). */
|
|
649
|
+
retitle?: (isCurrent: () => boolean) => Promise<void>;
|
|
646
650
|
recordRuntimeEvent?: (
|
|
647
651
|
category: string,
|
|
648
652
|
error: unknown,
|
|
@@ -656,6 +660,7 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
656
660
|
const leader = input.leader && { ...input.leader },
|
|
657
661
|
follower = input.follower,
|
|
658
662
|
followerRun = follower?.run;
|
|
663
|
+
const beforeCleanup = input.cleanupBeforeRelease;
|
|
659
664
|
const selectedCommand =
|
|
660
665
|
follower?.selectedCommand && structuredClone(follower.selectedCommand),
|
|
661
666
|
held = selectedCommand;
|
|
@@ -773,9 +778,12 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
773
778
|
let saved = false,
|
|
774
779
|
commitIssued = false,
|
|
775
780
|
applyIssued = false,
|
|
781
|
+
retitleIssued = false,
|
|
776
782
|
peerReleaseIssued = false,
|
|
777
783
|
donorSettlementAttempted = false,
|
|
778
784
|
continuationIssued = false,
|
|
785
|
+
cleanupBeforeReleaseIssued = false,
|
|
786
|
+
cleanupBeforeReleaseDecided = false,
|
|
779
787
|
released = false,
|
|
780
788
|
advancing = false;
|
|
781
789
|
let preparedSource: Bus.TelegramBusPreparedCommandSource | undefined;
|
|
@@ -829,6 +837,13 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
829
837
|
generation: recipient!.generation,
|
|
830
838
|
bindingKey: recipient!.bindingKey,
|
|
831
839
|
});
|
|
840
|
+
const acceptInput = (expected: Threads.TelegramWorkspaceLiveRebindIntent) => {
|
|
841
|
+
if (!expected.inputRelease || expected.inputRelease === "accepted") return expected;
|
|
842
|
+
let accepted: Threads.TelegramWorkspaceLiveRebindIntent | undefined;
|
|
843
|
+
try { accepted = input.restoreStore.advanceLiveRebind(expected, "accept-input", authority); }
|
|
844
|
+
catch { accepted = retained(); }
|
|
845
|
+
return accepted?.inputRelease === "accepted" && canonical(accepted) ? accepted : undefined;
|
|
846
|
+
};
|
|
832
847
|
const releaseFollower = async (
|
|
833
848
|
intent: Threads.TelegramWorkspaceLiveRebindIntent,
|
|
834
849
|
): Promise<"unknown" | "released"> => {
|
|
@@ -857,6 +872,9 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
857
872
|
)
|
|
858
873
|
return "unknown";
|
|
859
874
|
}
|
|
875
|
+
const accepted = acceptInput(intent);
|
|
876
|
+
if (!accepted) return "unknown";
|
|
877
|
+
intent = accepted;
|
|
860
878
|
donorSettlementAttempted = true;
|
|
861
879
|
if (preparation?.settleTransferred(() => canonical(intent)) !== "settled")
|
|
862
880
|
return "unknown";
|
|
@@ -867,8 +885,7 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
867
885
|
const intent = retained();
|
|
868
886
|
if (
|
|
869
887
|
!intent ||
|
|
870
|
-
intent
|
|
871
|
-
intent.cleanup ||
|
|
888
|
+
!Threads.isTelegramLiveRebindInputAuthorized(intent) ||
|
|
872
889
|
!canonical(intent) ||
|
|
873
890
|
!leader?.isApplied(intent)
|
|
874
891
|
)
|
|
@@ -876,16 +893,14 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
876
893
|
const completions = messages.map(
|
|
877
894
|
Updates.inspectTelegramDeferredSourceCompletion,
|
|
878
895
|
);
|
|
879
|
-
|
|
896
|
+
const completed = completions.every(
|
|
880
897
|
(completion, index) =>
|
|
881
898
|
completion && isDeepStrictEqual(completion, sources[index]),
|
|
882
899
|
) &&
|
|
883
900
|
current() &&
|
|
884
901
|
canonical(intent) &&
|
|
885
|
-
leader.isApplied(intent) &&
|
|
886
|
-
|
|
887
|
-
? "released"
|
|
888
|
-
: "unknown";
|
|
902
|
+
leader.isApplied(intent) && current();
|
|
903
|
+
return completed && acceptInput(intent) ? "released" : "unknown";
|
|
889
904
|
};
|
|
890
905
|
return {
|
|
891
906
|
/** Correlation only; a later cleanup attempt rereads and revalidates the canonical row itself. */
|
|
@@ -919,8 +934,7 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
919
934
|
const intent = retained();
|
|
920
935
|
if (
|
|
921
936
|
!intent ||
|
|
922
|
-
intent
|
|
923
|
-
intent.cleanup ||
|
|
937
|
+
!Threads.isTelegramLiveRebindInputAuthorized(intent) ||
|
|
924
938
|
!canonical(intent) ||
|
|
925
939
|
!leader?.isApplied(intent) ||
|
|
926
940
|
!leader.observeAdmission?.() ||
|
|
@@ -929,6 +943,7 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
929
943
|
!leader.isApplied(intent)
|
|
930
944
|
)
|
|
931
945
|
return "unknown";
|
|
946
|
+
if (!acceptInput(intent)) return "unknown";
|
|
932
947
|
released = true;
|
|
933
948
|
return "released";
|
|
934
949
|
}
|
|
@@ -994,23 +1009,24 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
994
1009
|
generation: recipient!.generation,
|
|
995
1010
|
},
|
|
996
1011
|
authority,
|
|
1012
|
+
beforeCleanup ? { holdInput: true } : undefined,
|
|
997
1013
|
);
|
|
998
1014
|
} else if (!commitIssued) return "protected";
|
|
999
1015
|
if (
|
|
1000
1016
|
!intent ||
|
|
1001
1017
|
!sourceSaved() ||
|
|
1002
1018
|
!canonical(intent) ||
|
|
1003
|
-
intent.phase === "finished" ||
|
|
1004
|
-
intent.cleanup
|
|
1019
|
+
(intent.phase === "finished" && !cleanupBeforeReleaseIssued) ||
|
|
1020
|
+
(intent.cleanup && intent.inputRelease === undefined)
|
|
1005
1021
|
)
|
|
1006
1022
|
return "protected";
|
|
1007
1023
|
// A released peer may already have completed its sources; only its retained settlement can reobserve that outcome.
|
|
1008
1024
|
if (recipient!.kind === "follower" && peerReleaseIssued)
|
|
1009
|
-
return intent
|
|
1025
|
+
return Threads.isTelegramLiveRebindInputAuthorized(intent)
|
|
1010
1026
|
? await releaseFollower(intent)
|
|
1011
1027
|
: "protected";
|
|
1012
1028
|
const mode =
|
|
1013
|
-
applyIssued || intent.phase === "released" ? "inspect" : "apply";
|
|
1029
|
+
applyIssued || intent.phase === "released" || intent.phase === "finished" ? "inspect" : "apply";
|
|
1014
1030
|
applyIssued = true;
|
|
1015
1031
|
if (recipient!.kind === "leader") {
|
|
1016
1032
|
await leader!.apply(structuredClone(intent), mode, current);
|
|
@@ -1030,6 +1046,17 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
1030
1046
|
}
|
|
1031
1047
|
if (!sourceSaved() || (preparation && !preparation.confirmSaved()))
|
|
1032
1048
|
return "protected";
|
|
1049
|
+
if (input.retitle && !retitleIssued) {
|
|
1050
|
+
retitleIssued = true;
|
|
1051
|
+
try {
|
|
1052
|
+
await input.retitle(current);
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
input.recordRuntimeEvent?.("telegram", error, {
|
|
1055
|
+
phase: "live-rebind-title",
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
if (!current() || !canonical(intent)) return "unknown";
|
|
1059
|
+
}
|
|
1033
1060
|
if (intent.phase === "rebound") {
|
|
1034
1061
|
const expected = intent;
|
|
1035
1062
|
input.restoreStore.advanceLiveRebind(
|
|
@@ -1046,11 +1073,27 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
1046
1073
|
}
|
|
1047
1074
|
if (
|
|
1048
1075
|
!intent ||
|
|
1049
|
-
intent.phase !== "released" ||
|
|
1050
|
-
intent.cleanup ||
|
|
1076
|
+
(intent.phase !== "released" && !(intent.phase === "finished" && intent.inputRelease === "held")) ||
|
|
1077
|
+
(intent.cleanup && intent.inputRelease === undefined) ||
|
|
1051
1078
|
!canonical(intent)
|
|
1052
1079
|
)
|
|
1053
1080
|
return "protected";
|
|
1081
|
+
if (beforeCleanup) {
|
|
1082
|
+
if (!cleanupBeforeReleaseIssued) {
|
|
1083
|
+
cleanupBeforeReleaseIssued = true;
|
|
1084
|
+
cleanupBeforeReleaseDecided = await beforeCleanup(intent, current);
|
|
1085
|
+
}
|
|
1086
|
+
intent = retained();
|
|
1087
|
+
if (!cleanupBeforeReleaseDecided && intent?.phase !== "finished") return "unknown";
|
|
1088
|
+
if (!intent || !canonical(intent) ||
|
|
1089
|
+
(recipient!.kind === "leader" && !leader!.isApplied(intent)) ||
|
|
1090
|
+
intent.inputRelease !== "held" || intent.cleanup === "issued") return "unknown";
|
|
1091
|
+
let issued: Threads.TelegramWorkspaceLiveRebindIntent | undefined;
|
|
1092
|
+
try { issued = input.restoreStore.advanceLiveRebind(intent, "issue-input", authority); }
|
|
1093
|
+
catch { issued = retained(); }
|
|
1094
|
+
if (!issued || issued.inputRelease !== "issued" || !canonical(issued)) return "unknown";
|
|
1095
|
+
intent = issued;
|
|
1096
|
+
}
|
|
1054
1097
|
if (recipient!.kind === "leader") {
|
|
1055
1098
|
const expected = intent;
|
|
1056
1099
|
if (
|
|
@@ -1060,14 +1103,21 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
1060
1103
|
)
|
|
1061
1104
|
return "protected";
|
|
1062
1105
|
continuationIssued = true;
|
|
1063
|
-
const canContinue = () =>
|
|
1064
|
-
|
|
1106
|
+
const canContinue = () => {
|
|
1107
|
+
const observed = expected.inputRelease ? retained() : expected;
|
|
1108
|
+
return current() && !!observed &&
|
|
1109
|
+
(!expected.inputRelease || (observed.inputRelease === "issued" || observed.inputRelease === "accepted")) &&
|
|
1110
|
+
canonical(observed) && leader!.isApplied(observed);
|
|
1111
|
+
};
|
|
1065
1112
|
if (leader!.complete) {
|
|
1066
1113
|
leader!.complete([...messages], canContinue);
|
|
1114
|
+
// The detached native producer has taken this one input grant; its source ACK remains a separate observation.
|
|
1115
|
+
if (!acceptInput(intent)) return "unknown";
|
|
1067
1116
|
return observeCommandCompletion();
|
|
1068
1117
|
}
|
|
1069
1118
|
if (!(await leader!.continue!(messages, canContinue)))
|
|
1070
1119
|
return "unknown";
|
|
1120
|
+
if (!acceptInput(intent)) return "unknown";
|
|
1071
1121
|
} else return await releaseFollower(intent);
|
|
1072
1122
|
if (!current()) return "unknown";
|
|
1073
1123
|
released = true;
|
|
@@ -1501,6 +1551,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1501
1551
|
hasWorkspaceRestoreAuthority?: () => boolean;
|
|
1502
1552
|
/** Exact live root authority; capability advertisement alone cannot replace owned profile/session/recipient fences. */
|
|
1503
1553
|
hasWorkspaceLiveRebindAuthority?: () => boolean;
|
|
1554
|
+
canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
|
|
1504
1555
|
/** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
|
|
1505
1556
|
inspectTemporaryThreadSources?: (
|
|
1506
1557
|
target: Queue.TelegramQueueTarget,
|
|
@@ -1799,6 +1850,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1799
1850
|
TContext
|
|
1800
1851
|
>
|
|
1801
1852
|
>["canPrepareHeldCommand"];
|
|
1853
|
+
/** Agent-settled hint: wakes waiting leader cleanup retries early; every readiness gate is still sampled fresh. */
|
|
1854
|
+
onSessionSettled(ctx: TContext): void;
|
|
1855
|
+
/** Lifetime-fenced hint from drained publication/activity/delivery/API owners; never a cleanup grant. */
|
|
1856
|
+
onDeliverySettled(ctx: TContext): void;
|
|
1802
1857
|
/** Fresh body-free recipient observation only; never reconstructs an attempt or grants cleanup. */
|
|
1803
1858
|
observeLiveRebindLeaderWork(
|
|
1804
1859
|
intent: Threads.TelegramWorkspaceLiveRebindIntent,
|
|
@@ -1888,6 +1943,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1888
1943
|
temporaryThread?: Threads.TelegramTemporaryThreadEntry;
|
|
1889
1944
|
/** Source copies left in All (a menu-picked command); deleted once, when the input is consumed. */
|
|
1890
1945
|
allTabCopies?: number[];
|
|
1946
|
+
/** The tab holds a forwarded copy of the threadless original, which then left All at once. */
|
|
1947
|
+
allTabCopiesForwarded?: true;
|
|
1891
1948
|
/** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
|
|
1892
1949
|
temporaryMembership?: true;
|
|
1893
1950
|
/** Re-entrancy lock for one command route in flight; orthogonal to `phase`. */
|
|
@@ -1918,7 +1975,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1918
1975
|
/** Some Forward outcome is unknown; process-local, so restart-safe issuance remains a release gate. */
|
|
1919
1976
|
| { kind: "forward-unknown" }
|
|
1920
1977
|
| { kind: "cleanup"; cleanup: PendingRerouteCleanup }
|
|
1921
|
-
|
|
1978
|
+
/** `retainRecord`: a live rebind's chooser-scoped command reference outlives its deleted menu. */
|
|
1979
|
+
| { kind: "finalizing"; message: string; retainRecord?: true };
|
|
1922
1980
|
/** The command a chooser routes, read from its own source exactly as the root heading named it. */
|
|
1923
1981
|
const getPendingRerouteCommandName = (
|
|
1924
1982
|
pending: PendingUnboundReroute,
|
|
@@ -2781,6 +2839,70 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2781
2839
|
pending.sourceTarget.chatId,
|
|
2782
2840
|
pending.allTabCopies?.splice(0) ?? [],
|
|
2783
2841
|
);
|
|
2842
|
+
/**
|
|
2843
|
+
* Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
|
|
2844
|
+
* holds its forwarded copy. Without one it stays where the operator can still read it.
|
|
2845
|
+
*/
|
|
2846
|
+
const releaseAllTabCopiesIntoSurvivingTab = (
|
|
2847
|
+
pending: PendingUnboundReroute,
|
|
2848
|
+
): Promise<void> => {
|
|
2849
|
+
if (pending.allTabCopiesForwarded) return deleteAllTabCopies(pending);
|
|
2850
|
+
pending.allTabCopies?.splice(0);
|
|
2851
|
+
return Promise.resolve();
|
|
2852
|
+
};
|
|
2853
|
+
/**
|
|
2854
|
+
* The Bot API files a threadless All input in All even when a mobile client first draws it in its new tab. One silent
|
|
2855
|
+
* best-effort forward moves it into the routing tab it opened, and only a confirmed copy lets the original leave All;
|
|
2856
|
+
* any failure keeps the original where the operator can still read it.
|
|
2857
|
+
*/
|
|
2858
|
+
const moveAllTabSourceIntoTab = async (
|
|
2859
|
+
pending: PendingUnboundReroute,
|
|
2860
|
+
target: { chatId: number; threadId: number },
|
|
2861
|
+
isCurrent: () => boolean,
|
|
2862
|
+
): Promise<number | undefined> => {
|
|
2863
|
+
const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
|
|
2864
|
+
if (messageIds.length === 0 || !deps.callApi || !isCurrent()) return;
|
|
2865
|
+
const route = {
|
|
2866
|
+
chat_id: target.chatId,
|
|
2867
|
+
message_thread_id: target.threadId,
|
|
2868
|
+
from_chat_id: pending.sourceTarget.chatId,
|
|
2869
|
+
disable_notification: true,
|
|
2870
|
+
};
|
|
2871
|
+
try {
|
|
2872
|
+
// An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
|
|
2873
|
+
const copies =
|
|
2874
|
+
messageIds.length === 1
|
|
2875
|
+
? [
|
|
2876
|
+
await deps.callApi<{ message_id?: unknown }>("forwardMessage", {
|
|
2877
|
+
...route,
|
|
2878
|
+
message_id: messageIds[0],
|
|
2879
|
+
}),
|
|
2880
|
+
]
|
|
2881
|
+
: await deps.callApi<Array<{ message_id?: unknown }>>(
|
|
2882
|
+
"forwardMessages",
|
|
2883
|
+
{ ...route, message_ids: messageIds },
|
|
2884
|
+
);
|
|
2885
|
+
const copyIds = Array.isArray(copies)
|
|
2886
|
+
? copies.map((copy) => copy?.message_id)
|
|
2887
|
+
: [];
|
|
2888
|
+
if (
|
|
2889
|
+
copyIds.length !== messageIds.length ||
|
|
2890
|
+
!copyIds.every(
|
|
2891
|
+
(id): id is number =>
|
|
2892
|
+
typeof id === "number" && Number.isSafeInteger(id),
|
|
2893
|
+
)
|
|
2894
|
+
)
|
|
2895
|
+
return;
|
|
2896
|
+
pending.allTabCopiesForwarded = true;
|
|
2897
|
+
await deleteAllTabCopies(pending);
|
|
2898
|
+
return copyIds[0];
|
|
2899
|
+
} catch (error) {
|
|
2900
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
2901
|
+
phase: "temporary-thread-source-forward",
|
|
2902
|
+
});
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
};
|
|
2784
2906
|
const dismissRerouteChooserMessage = async (
|
|
2785
2907
|
query: TCallbackQuery,
|
|
2786
2908
|
assertExecutionCurrent?: () => void,
|
|
@@ -2810,6 +2932,36 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2810
2932
|
return false;
|
|
2811
2933
|
}
|
|
2812
2934
|
};
|
|
2935
|
+
/** Choosers are one-shot: a final outcome deletes the menu; only a failed delete leaves an inert, button-free notice. */
|
|
2936
|
+
const retireRerouteChooserMessage = async (
|
|
2937
|
+
chatId: number,
|
|
2938
|
+
messageId: number,
|
|
2939
|
+
fallbackText: string,
|
|
2940
|
+
assertCurrent?: () => void,
|
|
2941
|
+
): Promise<void> => {
|
|
2942
|
+
if (deps.deleteMessage) {
|
|
2943
|
+
try {
|
|
2944
|
+
await deps.deleteMessage(chatId, messageId);
|
|
2945
|
+
return;
|
|
2946
|
+
} catch (error) {
|
|
2947
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
2948
|
+
phase: "reroute-chooser-delete",
|
|
2949
|
+
chatId,
|
|
2950
|
+
messageId,
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
assertCurrent?.();
|
|
2954
|
+
}
|
|
2955
|
+
await deps.editInteractiveMessage?.(
|
|
2956
|
+
chatId,
|
|
2957
|
+
messageId,
|
|
2958
|
+
fallbackText,
|
|
2959
|
+
"html",
|
|
2960
|
+
{
|
|
2961
|
+
inline_keyboard: [],
|
|
2962
|
+
},
|
|
2963
|
+
);
|
|
2964
|
+
};
|
|
2813
2965
|
const closeReroutedUnboundTopic = async (
|
|
2814
2966
|
target: { chatId: number; threadId: number } | undefined,
|
|
2815
2967
|
messageId: number | undefined,
|
|
@@ -2975,8 +3127,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2975
3127
|
continue;
|
|
2976
3128
|
if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
|
|
2977
3129
|
continue;
|
|
3130
|
+
// A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
|
|
2978
3131
|
if (await retireCancellationChooser(id, pending, current))
|
|
2979
|
-
await
|
|
3132
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
2980
3133
|
} catch (error) {
|
|
2981
3134
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
2982
3135
|
phase: "temporary-thread-restore-sibling",
|
|
@@ -3706,6 +3859,25 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3706
3859
|
);
|
|
3707
3860
|
if (!intent) continue;
|
|
3708
3861
|
}
|
|
3862
|
+
// The chooser follows the success proof, never old-thread cleanup, which may stay protected indefinitely.
|
|
3863
|
+
const retireRestoreChoosers = async (): Promise<void> => {
|
|
3864
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
3865
|
+
if (pending.workspaceRestore?.operationId !== operationId)
|
|
3866
|
+
continue;
|
|
3867
|
+
pending.phase = { kind: "finalizing", message: "Restored" };
|
|
3868
|
+
if (pending.chooserMessageId === undefined) continue;
|
|
3869
|
+
assertSettlementCurrent();
|
|
3870
|
+
await retireRerouteChooserMessage(
|
|
3871
|
+
pending.sourceTarget.chatId,
|
|
3872
|
+
pending.chooserMessageId,
|
|
3873
|
+
"<b>✅ Message routed.</b>",
|
|
3874
|
+
assertSettlementCurrent,
|
|
3875
|
+
);
|
|
3876
|
+
assertSettlementCurrent();
|
|
3877
|
+
if (pendingUnboundReroutes.get(id) === pending)
|
|
3878
|
+
removePendingReroute(id);
|
|
3879
|
+
}
|
|
3880
|
+
};
|
|
3709
3881
|
if (intent.routing?.cleanup === undefined) {
|
|
3710
3882
|
if (
|
|
3711
3883
|
!intent.request.source.updateIds.every((id) =>
|
|
@@ -3716,13 +3888,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3716
3888
|
)
|
|
3717
3889
|
)
|
|
3718
3890
|
continue;
|
|
3719
|
-
// Positive settlement of every selected source is the success proof
|
|
3891
|
+
// Positive settlement of every selected source is the success proof.
|
|
3720
3892
|
await disposeTemporaryThreadSiblingsAfterRestore(
|
|
3721
3893
|
intent,
|
|
3722
3894
|
ctx,
|
|
3723
3895
|
settlementAuthority.isCurrent,
|
|
3724
3896
|
);
|
|
3725
3897
|
assertSettlementCurrent();
|
|
3898
|
+
await retireRestoreChoosers();
|
|
3726
3899
|
if (
|
|
3727
3900
|
isRerouteTargetProtected(
|
|
3728
3901
|
intent.request.binding.target,
|
|
@@ -3758,27 +3931,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3758
3931
|
}
|
|
3759
3932
|
if (!store.retire(intent, settlementAuthority)) continue;
|
|
3760
3933
|
retired = true;
|
|
3761
|
-
|
|
3762
|
-
if (pending.workspaceRestore?.operationId !== operationId)
|
|
3763
|
-
continue;
|
|
3764
|
-
pending.phase = { kind: "finalizing", message: "Restored" };
|
|
3765
|
-
if (
|
|
3766
|
-
!deps.editInteractiveMessage ||
|
|
3767
|
-
pending.chooserMessageId === undefined
|
|
3768
|
-
)
|
|
3769
|
-
continue;
|
|
3770
|
-
assertSettlementCurrent();
|
|
3771
|
-
await deps.editInteractiveMessage(
|
|
3772
|
-
pending.sourceTarget.chatId,
|
|
3773
|
-
pending.chooserMessageId,
|
|
3774
|
-
"<b>✅ Message routed.</b>",
|
|
3775
|
-
"html",
|
|
3776
|
-
{ inline_keyboard: [] },
|
|
3777
|
-
);
|
|
3778
|
-
assertSettlementCurrent();
|
|
3779
|
-
if (pendingUnboundReroutes.get(id) === pending)
|
|
3780
|
-
removePendingReroute(id);
|
|
3781
|
-
}
|
|
3934
|
+
await retireRestoreChoosers();
|
|
3782
3935
|
}
|
|
3783
3936
|
},
|
|
3784
3937
|
),
|
|
@@ -4674,8 +4827,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
4674
4827
|
.listLiveRebindings()
|
|
4675
4828
|
.find((value) => value.request.operationId === request.operationId);
|
|
4676
4829
|
if (
|
|
4677
|
-
intent
|
|
4678
|
-
!intent.cleanup &&
|
|
4830
|
+
intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
|
|
4679
4831
|
isDeepStrictEqual(intent.request, request) &&
|
|
4680
4832
|
intent.operatorUserId === operatorUserId &&
|
|
4681
4833
|
isDeepStrictEqual(intent.executor, {
|
|
@@ -5088,18 +5240,67 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5088
5240
|
name ||
|
|
5089
5241
|
confirmation ||
|
|
5090
5242
|
extension;
|
|
5243
|
+
const preDispatchCleanup =
|
|
5244
|
+
deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
|
|
5091
5245
|
const createCoordinator = () =>
|
|
5092
5246
|
createTelegramLiveRebindCoordinator({
|
|
5093
5247
|
request,
|
|
5094
5248
|
messages: selection.messages,
|
|
5095
5249
|
restoreStore: store,
|
|
5096
5250
|
threadStore: threads,
|
|
5251
|
+
// Same title as Workspace Restore: the display-mode title of the restored binding on its new tab.
|
|
5252
|
+
retitle: deps.callApi
|
|
5253
|
+
? async (isCurrent) => {
|
|
5254
|
+
if (!isCurrent()) return;
|
|
5255
|
+
const expected = threads.getWorkspaceBindingByTarget(target, binding.sessionId);
|
|
5256
|
+
if (!expected || expected.bindingKey !== binding.bindingKey || expected.slot !== binding.slot)
|
|
5257
|
+
throw new Error("Live-rebind title binding is unavailable.");
|
|
5258
|
+
const callApi = deps.callApi!;
|
|
5259
|
+
const slot = binding.slot ?? "";
|
|
5260
|
+
const resolveTitle = () => deps.getDisplayTitle?.(expected.target) ??
|
|
5261
|
+
ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot);
|
|
5262
|
+
const title = resolveTitle();
|
|
5263
|
+
const bindingCurrent = (frame: Threads.TelegramWorkspaceThreadBinding) =>
|
|
5264
|
+
isCurrent() && deps.callApi === callApi &&
|
|
5265
|
+
isDeepStrictEqual(threads.getWorkspaceBindingByTarget(frame.target, frame.sessionId), frame);
|
|
5266
|
+
const assertTitle = () => {
|
|
5267
|
+
if (!bindingCurrent(expected) || resolveTitle() !== title)
|
|
5268
|
+
throw new Error("Live-rebind title authority changed.");
|
|
5269
|
+
};
|
|
5270
|
+
assertTitle();
|
|
5271
|
+
const renamed = await callApi.call(deps, "editForumTopic", {
|
|
5272
|
+
chat_id: expected.target.chatId,
|
|
5273
|
+
message_thread_id: expected.target.threadId,
|
|
5274
|
+
name: title,
|
|
5275
|
+
}, { maxAttempts: 1, assertAuthority: assertTitle });
|
|
5276
|
+
assertTitle();
|
|
5277
|
+
if (renamed !== true || !threads.setWorkspaceDisplayTitle(expected, title))
|
|
5278
|
+
throw new Error("Live-rebind title acknowledgement is unconfirmed.");
|
|
5279
|
+
const acknowledged = { ...expected, displayTitle: title };
|
|
5280
|
+
await threads.persist(() => bindingCurrent(acknowledged));
|
|
5281
|
+
if (!bindingCurrent(acknowledged))
|
|
5282
|
+
throw new Error("Live-rebind title publication authority changed.");
|
|
5283
|
+
}
|
|
5284
|
+
: undefined,
|
|
5097
5285
|
authority: {
|
|
5098
5286
|
executor: { instanceId, leaderEpoch: String(epoch) },
|
|
5099
5287
|
operatorUserId,
|
|
5100
|
-
isCurrent: current,
|
|
5288
|
+
isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
|
|
5101
5289
|
},
|
|
5102
5290
|
getRecipient,
|
|
5291
|
+
...(preDispatchCleanup ? {
|
|
5292
|
+
async cleanupBeforeRelease(intent: Threads.TelegramWorkspaceLiveRebindIntent, isCurrent: () => boolean) {
|
|
5293
|
+
const delay = deps.liveRebindCleanupSchedule?.delaysMs[0] ?? 1_000;
|
|
5294
|
+
await new Promise<void>(resolve => { const timer = setTimeout(resolve, delay); timer.unref?.(); });
|
|
5295
|
+
if (!isCurrent()) return false;
|
|
5296
|
+
const result = await inspectLiveRebindRecipient(intent, ctx, isLeader ? "issue" : "follower-issue", isCurrent);
|
|
5297
|
+
if (!isCurrent()) return false;
|
|
5298
|
+
// A session-replacing producer cannot retain an old-session cleanup grant across its gateway.
|
|
5299
|
+
if (result?.status === "not-ready" && selection.confirmationCommand?.name === "new")
|
|
5300
|
+
return !!store.advanceLiveRebind(intent, "protect-old-target", { executor: intent.executor, operatorUserId, isCurrent });
|
|
5301
|
+
return result?.status === "not-ready" || result?.status === "finished" && result.recorded;
|
|
5302
|
+
},
|
|
5303
|
+
} : {}),
|
|
5103
5304
|
follower: isLeader
|
|
5104
5305
|
? undefined
|
|
5105
5306
|
: {
|
|
@@ -5141,9 +5342,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5141
5342
|
instanceId,
|
|
5142
5343
|
leaderEpoch: String(epoch),
|
|
5143
5344
|
}) &&
|
|
5144
|
-
!intent.cleanup &&
|
|
5145
|
-
(intent.phase === "rebound" ||
|
|
5146
|
-
intent.phase === "
|
|
5345
|
+
(!intent.cleanup || intent.inputRelease !== undefined && intent.cleanup !== "issued") &&
|
|
5346
|
+
(intent.phase === "rebound" || intent.phase === "released" ||
|
|
5347
|
+
intent.phase === "finished" && intent.inputRelease !== undefined) &&
|
|
5147
5348
|
isDeepStrictEqual(intent.recipient, {
|
|
5148
5349
|
kind: recipient.kind,
|
|
5149
5350
|
instanceId: recipient.instanceId,
|
|
@@ -5203,6 +5404,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5203
5404
|
});
|
|
5204
5405
|
},
|
|
5205
5406
|
isApplied,
|
|
5407
|
+
|
|
5206
5408
|
...(completeCommand
|
|
5207
5409
|
? {
|
|
5208
5410
|
complete(
|
|
@@ -5395,11 +5597,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5395
5597
|
intent: Threads.TelegramWorkspaceLiveRebindIntent,
|
|
5396
5598
|
ctx: TContext,
|
|
5397
5599
|
mode: "issue" | "follower-issue",
|
|
5600
|
+
admittedOrigin?: () => boolean,
|
|
5398
5601
|
): Promise<TelegramLiveRebindCleanupIssue | undefined>;
|
|
5399
5602
|
async function inspectLiveRebindRecipient(
|
|
5400
5603
|
intent: Threads.TelegramWorkspaceLiveRebindIntent,
|
|
5401
5604
|
ctx: TContext,
|
|
5402
5605
|
mode: "work" | "cleanup" | "follower-cleanup" | "issue" | "follower-issue",
|
|
5606
|
+
admittedOrigin?: () => boolean,
|
|
5403
5607
|
): Promise<
|
|
5404
5608
|
| Bus.TelegramBusLiveRebindWorkObservation
|
|
5405
5609
|
| ThreadReconciler.TelegramLiveRebindCleanupPreparation
|
|
@@ -5427,8 +5631,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5427
5631
|
!run ||
|
|
5428
5632
|
(issueMode && !callApi) ||
|
|
5429
5633
|
deps.isContextActive?.(ctx) !== true
|
|
5430
|
-
)
|
|
5634
|
+
) {
|
|
5431
5635
|
return undefined;
|
|
5636
|
+
}
|
|
5432
5637
|
try {
|
|
5433
5638
|
const expected = structuredClone(intent),
|
|
5434
5639
|
{ request, recipient, executor } = expected;
|
|
@@ -5534,6 +5739,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5534
5739
|
);
|
|
5535
5740
|
};
|
|
5536
5741
|
const current = () =>
|
|
5742
|
+
(!admittedOrigin || admittedOrigin()) &&
|
|
5537
5743
|
deps.hasWorkspaceLiveRebindAuthority?.() === true &&
|
|
5538
5744
|
deps.isContextActive?.(ctx) === true &&
|
|
5539
5745
|
deps.workspaceRestoreRecipient === ports &&
|
|
@@ -5584,8 +5790,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5584
5790
|
};
|
|
5585
5791
|
const sample = async () => {
|
|
5586
5792
|
assertCanonical();
|
|
5587
|
-
if (cleanupMode
|
|
5588
|
-
|
|
5793
|
+
if (cleanupMode) {
|
|
5794
|
+
const protectedTarget = protection.call(threads, expected);
|
|
5795
|
+
if (protectedTarget !== false) return undefined;
|
|
5796
|
+
}
|
|
5589
5797
|
const target = { ...request.binding.target };
|
|
5590
5798
|
let value: Bus.TelegramBusLiveRebindWorkState | undefined;
|
|
5591
5799
|
if (followerMode) {
|
|
@@ -5594,7 +5802,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5594
5802
|
instanceId: recipient.instanceId,
|
|
5595
5803
|
sessionId: recipient.sessionId,
|
|
5596
5804
|
recipientBindingKey: recipientJournalKey!,
|
|
5597
|
-
mode: "observe",
|
|
5805
|
+
mode: expected.inputRelease === "held" ? "observe-held" : "observe",
|
|
5598
5806
|
sourceUpdateIds: [...request.source.updateIds],
|
|
5599
5807
|
slot: request.binding.slot!,
|
|
5600
5808
|
target: { ...request.target },
|
|
@@ -5649,12 +5857,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5649
5857
|
);
|
|
5650
5858
|
assertCanonical();
|
|
5651
5859
|
if (cleanupMode) {
|
|
5860
|
+
const targetProtected = protection.call(threads, expected);
|
|
5652
5861
|
const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
|
|
5653
5862
|
operationId: request.operationId,
|
|
5654
5863
|
oldTarget: request.binding.target,
|
|
5655
5864
|
recipientTarget: request.target,
|
|
5656
5865
|
leaderEpoch: executor.leaderEpoch,
|
|
5657
|
-
targetProtected
|
|
5866
|
+
targetProtected,
|
|
5658
5867
|
work,
|
|
5659
5868
|
});
|
|
5660
5869
|
assertCanonical();
|
|
@@ -5677,21 +5886,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5677
5886
|
if (!current()) return undefined;
|
|
5678
5887
|
if (issueMode) {
|
|
5679
5888
|
// One admission spans the fresh sample, durable issue marker, single deletion and terminal record.
|
|
5680
|
-
|
|
5681
|
-
{
|
|
5682
|
-
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
5683
|
-
operationKind: "workspace.live-rebind-cleanup",
|
|
5684
|
-
scopes: [{ kind: "profile" }],
|
|
5685
|
-
},
|
|
5686
|
-
async (): Promise<TelegramLiveRebindCleanupIssue | undefined> => {
|
|
5889
|
+
const execute = async (): Promise<TelegramLiveRebindCleanupIssue | undefined> => {
|
|
5687
5890
|
const prepared = await sample();
|
|
5688
5891
|
if (
|
|
5689
5892
|
!prepared ||
|
|
5690
5893
|
prepared.status !== "prepared" ||
|
|
5691
5894
|
!current() ||
|
|
5692
5895
|
protection.call(threads, expected) !== false
|
|
5693
|
-
)
|
|
5896
|
+
) {
|
|
5694
5897
|
return { status: "not-ready" };
|
|
5898
|
+
}
|
|
5695
5899
|
const authority = {
|
|
5696
5900
|
executor: structuredClone(executor),
|
|
5697
5901
|
operatorUserId: expected.operatorUserId,
|
|
@@ -5702,21 +5906,19 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5702
5906
|
"issue-cleanup",
|
|
5703
5907
|
authority,
|
|
5704
5908
|
);
|
|
5705
|
-
if (!grant)
|
|
5909
|
+
if (!grant) {
|
|
5910
|
+
return undefined;
|
|
5911
|
+
}
|
|
5706
5912
|
const assertGranted = () => assertCanonical(grant);
|
|
5707
5913
|
const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(
|
|
5708
5914
|
prepared,
|
|
5709
5915
|
{
|
|
5710
5916
|
assertCurrent: assertGranted,
|
|
5711
|
-
deleteTopic: (target) =>
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
message_thread_id: target.threadId,
|
|
5717
|
-
},
|
|
5718
|
-
{ maxAttempts: 1, assertAuthority: assertGranted },
|
|
5719
|
-
),
|
|
5917
|
+
deleteTopic: (target) => callApi!(
|
|
5918
|
+
"deleteForumTopic",
|
|
5919
|
+
{ chat_id: target.chatId, message_thread_id: target.threadId },
|
|
5920
|
+
{ maxAttempts: 1, assertAuthority: assertGranted },
|
|
5921
|
+
),
|
|
5720
5922
|
classifyFailure: classifyLiveRebindCleanupFailure,
|
|
5721
5923
|
},
|
|
5722
5924
|
);
|
|
@@ -5734,8 +5936,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5734
5936
|
cleanup,
|
|
5735
5937
|
recorded,
|
|
5736
5938
|
};
|
|
5737
|
-
|
|
5738
|
-
|
|
5939
|
+
};
|
|
5940
|
+
// Pre-dispatch calls are private to the chooser callback already holding the same profile admission/gate.
|
|
5941
|
+
return admittedOrigin ? await execute() : await run({
|
|
5942
|
+
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
5943
|
+
operationKind: "workspace.live-rebind-cleanup", scopes: [{ kind: "profile" }],
|
|
5944
|
+
}, execute);
|
|
5739
5945
|
}
|
|
5740
5946
|
const observation = await run(
|
|
5741
5947
|
{
|
|
@@ -5788,6 +5994,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5788
5994
|
string,
|
|
5789
5995
|
ReturnType<typeof setTimeout> | "running"
|
|
5790
5996
|
>();
|
|
5997
|
+
// One hint slot per existing leader chain; a running attempt retains at most one coalesced recheck.
|
|
5998
|
+
const liveRebindCleanupWakers = new Map<string, (reason: "session-settled" | "delivery-drained") => void>();
|
|
5999
|
+
const captureLiveRebindCleanupScope = (ctx: TContext): (() => boolean) => {
|
|
6000
|
+
try {
|
|
6001
|
+
const generation = deps.getSessionGeneration?.(), instanceId = deps.getCurrentInstanceId?.(),
|
|
6002
|
+
epoch = deps.getCurrentLeaderEpoch?.(), store = deps.getWorkspaceRestoreStore?.(),
|
|
6003
|
+
operator = deps.configStore.getAllowedUserId();
|
|
6004
|
+
return () => {
|
|
6005
|
+
try {
|
|
6006
|
+
return deps.isContextActive?.(ctx) === true && deps.getSessionGeneration?.() === generation &&
|
|
6007
|
+
deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
|
|
6008
|
+
deps.getWorkspaceRestoreStore?.() === store && deps.configStore.getAllowedUserId() === operator;
|
|
6009
|
+
} catch { return false; }
|
|
6010
|
+
};
|
|
6011
|
+
} catch { return () => false; }
|
|
6012
|
+
};
|
|
5791
6013
|
/** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
|
|
5792
6014
|
const terminalizeUnissuedLiveRebindCleanup = async (
|
|
5793
6015
|
operationId: string,
|
|
@@ -5848,24 +6070,48 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5848
6070
|
ctx: TContext,
|
|
5849
6071
|
startedAtMs = Date.now(),
|
|
5850
6072
|
attempt = 0,
|
|
6073
|
+
current = captureLiveRebindCleanupScope(ctx),
|
|
5851
6074
|
): void => {
|
|
5852
6075
|
const pacing = deps.liveRebindCleanupSchedule;
|
|
5853
6076
|
const chain = liveRebindCleanupChains.get(operationId);
|
|
5854
6077
|
if (
|
|
5855
6078
|
!pacing ||
|
|
5856
6079
|
(attempt === 0 ? chain !== undefined : chain !== "running") ||
|
|
5857
|
-
|
|
6080
|
+
!current()
|
|
5858
6081
|
) {
|
|
5859
|
-
if (attempt > 0 && chain === "running")
|
|
6082
|
+
if (attempt > 0 && chain === "running") {
|
|
5860
6083
|
liveRebindCleanupChains.delete(operationId);
|
|
6084
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
6085
|
+
}
|
|
5861
6086
|
return;
|
|
5862
6087
|
}
|
|
5863
|
-
|
|
6088
|
+
let pendingWake: "session-settled" | "delivery-drained" | undefined;
|
|
6089
|
+
const wake = (reason: "session-settled" | "delivery-drained") => {
|
|
6090
|
+
if (!current()) return;
|
|
6091
|
+
if (liveRebindCleanupChains.get(operationId) === "running") {
|
|
6092
|
+
pendingWake = reason; return;
|
|
6093
|
+
}
|
|
6094
|
+
if (liveRebindCleanupChains.get(operationId) !== timer) return;
|
|
6095
|
+
if (reason === "delivery-drained") {
|
|
6096
|
+
let idle: unknown;
|
|
6097
|
+
try { idle = deps.isIdle?.(ctx); } catch { /* A missing idle observation is not a reason to hurry cleanup. */ }
|
|
6098
|
+
if (idle !== true) {
|
|
6099
|
+
// A streamed technical task can drain between deltas while Pi still runs. Keep one hint, not a polling loop.
|
|
6100
|
+
pendingWake = reason;
|
|
6101
|
+
return;
|
|
6102
|
+
}
|
|
6103
|
+
}
|
|
6104
|
+
clearTimeout(timer);
|
|
6105
|
+
attemptNow();
|
|
6106
|
+
};
|
|
6107
|
+
const attemptNow = () => {
|
|
5864
6108
|
if (liveRebindCleanupChains.get(operationId) !== timer) return;
|
|
6109
|
+
pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
|
|
5865
6110
|
liveRebindCleanupChains.set(operationId, "running");
|
|
6111
|
+
if (role === "leader") liveRebindCleanupWakers.set(operationId, wake);
|
|
5866
6112
|
let continued = false;
|
|
5867
6113
|
const task = (async () => {
|
|
5868
|
-
if (
|
|
6114
|
+
if (!current()) return;
|
|
5869
6115
|
const intent = deps
|
|
5870
6116
|
.getWorkspaceRestoreStore?.()
|
|
5871
6117
|
?.listLiveRebindings()
|
|
@@ -5878,11 +6124,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5878
6124
|
: await issueLiveRebindFollowerCleanup(intent, ctx);
|
|
5879
6125
|
if (
|
|
5880
6126
|
result?.status === "finished" ||
|
|
5881
|
-
|
|
6127
|
+
!current()
|
|
5882
6128
|
)
|
|
5883
6129
|
return;
|
|
5884
|
-
if (Date.now() - startedAtMs >= pacing.windowMs)
|
|
6130
|
+
if (Date.now() - startedAtMs >= pacing.windowMs) {
|
|
5885
6131
|
return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
|
|
6132
|
+
}
|
|
5886
6133
|
continued = true;
|
|
5887
6134
|
scheduleLiveRebindCleanup(
|
|
5888
6135
|
operationId,
|
|
@@ -5890,7 +6137,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5890
6137
|
ctx,
|
|
5891
6138
|
startedAtMs,
|
|
5892
6139
|
attempt + 1,
|
|
6140
|
+
current,
|
|
5893
6141
|
);
|
|
6142
|
+
if (pendingWake) liveRebindCleanupWakers.get(operationId)?.(pendingWake);
|
|
5894
6143
|
})()
|
|
5895
6144
|
.catch((error) =>
|
|
5896
6145
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -5901,15 +6150,29 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5901
6150
|
if (
|
|
5902
6151
|
!continued &&
|
|
5903
6152
|
liveRebindCleanupChains.get(operationId) === "running"
|
|
5904
|
-
)
|
|
6153
|
+
) {
|
|
5905
6154
|
liveRebindCleanupChains.delete(operationId);
|
|
6155
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
6156
|
+
}
|
|
5906
6157
|
});
|
|
5907
6158
|
restoreSettlementTasks.add(task);
|
|
5908
6159
|
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
5909
|
-
}
|
|
6160
|
+
};
|
|
6161
|
+
const timer = setTimeout(
|
|
6162
|
+
attemptNow,
|
|
6163
|
+
pacing.delaysMs[attempt] ?? pacing.intervalMs,
|
|
6164
|
+
);
|
|
5910
6165
|
timer.unref?.();
|
|
5911
6166
|
liveRebindCleanupChains.set(operationId, timer);
|
|
6167
|
+
// The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
|
|
6168
|
+
if (role === "leader" && attempt > 0) liveRebindCleanupWakers.set(operationId, wake);
|
|
5912
6169
|
};
|
|
6170
|
+
const wakeLiveRebindCleanup = (ctx: TContext, reason: "session-settled" | "delivery-drained"): void => {
|
|
6171
|
+
if (deps.isContextActive?.(ctx) !== true) return;
|
|
6172
|
+
for (const wake of [...liveRebindCleanupWakers.values()]) wake(reason);
|
|
6173
|
+
};
|
|
6174
|
+
const onSessionSettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "session-settled");
|
|
6175
|
+
const onDeliverySettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "delivery-drained");
|
|
5913
6176
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
5914
6177
|
? async (input: {
|
|
5915
6178
|
operationId: string;
|
|
@@ -6446,6 +6709,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6446
6709
|
assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(
|
|
6447
6710
|
query,
|
|
6448
6711
|
),
|
|
6712
|
+
retainRecord = false,
|
|
6449
6713
|
): Promise<void> => {
|
|
6450
6714
|
assertExecutionCurrent();
|
|
6451
6715
|
const dismissed = await dismissRerouteChooserMessage(
|
|
@@ -6454,12 +6718,18 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6454
6718
|
);
|
|
6455
6719
|
assertExecutionCurrent();
|
|
6456
6720
|
if (dismissed) {
|
|
6457
|
-
|
|
6721
|
+
// A retained record no longer matches any chooser, so a late duplicate tap reads as expired.
|
|
6722
|
+
if (retainRecord) pending.chooserMessageId = undefined;
|
|
6723
|
+
else removePendingReroute(rerouteId);
|
|
6458
6724
|
await deps.answerCallbackQuery(query.id, successMessage);
|
|
6459
6725
|
await deleteAllTabCopies(pending);
|
|
6460
6726
|
return;
|
|
6461
6727
|
}
|
|
6462
|
-
pending.phase = {
|
|
6728
|
+
pending.phase = {
|
|
6729
|
+
kind: "finalizing",
|
|
6730
|
+
message: successMessage,
|
|
6731
|
+
...(retainRecord ? { retainRecord: true as const } : {}),
|
|
6732
|
+
};
|
|
6463
6733
|
await deps.answerCallbackQuery(
|
|
6464
6734
|
query.id,
|
|
6465
6735
|
`${successMessage}; tap again to clear the menu`,
|
|
@@ -7017,16 +7287,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7017
7287
|
if (
|
|
7018
7288
|
!isPendingRerouteCancelled(pending) ||
|
|
7019
7289
|
!isCurrent() ||
|
|
7020
|
-
!deps.editInteractiveMessage ||
|
|
7290
|
+
(!deps.deleteMessage && !deps.editInteractiveMessage) ||
|
|
7021
7291
|
pending.chooserMessageId === undefined
|
|
7022
7292
|
)
|
|
7023
7293
|
return false;
|
|
7024
|
-
await
|
|
7294
|
+
await retireRerouteChooserMessage(
|
|
7025
7295
|
pending.sourceTarget.chatId,
|
|
7026
7296
|
pending.chooserMessageId,
|
|
7027
7297
|
"<b>⛔️ Routing cancelled.</b>",
|
|
7028
|
-
"html",
|
|
7029
|
-
{ inline_keyboard: [] },
|
|
7030
7298
|
);
|
|
7031
7299
|
if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
|
|
7032
7300
|
return false;
|
|
@@ -7523,6 +7791,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7523
7791
|
const result = source.expire();
|
|
7524
7792
|
if (!result) return;
|
|
7525
7793
|
// Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
|
|
7794
|
+
let movedIntoTab = false;
|
|
7526
7795
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
7527
7796
|
if (
|
|
7528
7797
|
!Updates.collectTelegramAdmissionSourceUpdateIds(
|
|
@@ -7530,6 +7799,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7530
7799
|
).includes(source.original.updateId)
|
|
7531
7800
|
)
|
|
7532
7801
|
continue;
|
|
7802
|
+
movedIntoTab ||= pending.allTabCopiesForwarded === true;
|
|
7533
7803
|
removePendingReroute(id);
|
|
7534
7804
|
if (
|
|
7535
7805
|
signal.aborted ||
|
|
@@ -7562,13 +7832,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7562
7832
|
// never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
|
|
7563
7833
|
if (temporary?.target && runtimeCurrent()) {
|
|
7564
7834
|
scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
|
|
7565
|
-
|
|
7566
|
-
|
|
7567
|
-
|
|
7568
|
-
[message!],
|
|
7835
|
+
// A moved original already left All with its forward; only an unmoved one is deleted here.
|
|
7836
|
+
if (!movedIntoTab)
|
|
7837
|
+
await deleteAllTabMessages(
|
|
7569
7838
|
temporary.target.chatId,
|
|
7570
|
-
|
|
7571
|
-
|
|
7839
|
+
getTelegramAllTabSourceMessageIds(
|
|
7840
|
+
[message!],
|
|
7841
|
+
temporary.target.chatId,
|
|
7842
|
+
),
|
|
7843
|
+
);
|
|
7572
7844
|
}
|
|
7573
7845
|
},
|
|
7574
7846
|
);
|
|
@@ -8172,6 +8444,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8172
8444
|
query,
|
|
8173
8445
|
pending.phase.message,
|
|
8174
8446
|
assertExecutionCurrent,
|
|
8447
|
+
pending.phase.retainRecord === true,
|
|
8175
8448
|
);
|
|
8176
8449
|
return true;
|
|
8177
8450
|
}
|
|
@@ -8549,10 +8822,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8549
8822
|
// Keep the captured plan even if the source owner's issued selection result is lost.
|
|
8550
8823
|
pending.liveRebind ??= selection;
|
|
8551
8824
|
if (!(await selectPendingDestination())) return true;
|
|
8825
|
+
const operationId = selection.coordinator.operationId;
|
|
8552
8826
|
if (current()) outcome = await selection.coordinator.advance();
|
|
8553
8827
|
// Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
|
|
8554
8828
|
// still be pending while every attempt samples current work afresh.
|
|
8555
|
-
const operationId = selection.coordinator.operationId;
|
|
8556
8829
|
const row =
|
|
8557
8830
|
outcome === "protected"
|
|
8558
8831
|
? undefined
|
|
@@ -8560,13 +8833,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8560
8833
|
.getWorkspaceRestoreStore?.()
|
|
8561
8834
|
?.listLiveRebindings()
|
|
8562
8835
|
.find((value) => value.request.operationId === operationId);
|
|
8563
|
-
if (row?.phase === "released" && row.cleanup === undefined
|
|
8836
|
+
if (row?.phase === "released" && row.cleanup === undefined &&
|
|
8837
|
+
(row.inputRelease === undefined || row.inputRelease === "accepted")) {
|
|
8564
8838
|
scheduleLiveRebindCleanup(
|
|
8565
8839
|
operationId,
|
|
8566
8840
|
selection.record.owner?.kind === "leader" ? "leader" : "follower",
|
|
8567
8841
|
ctx,
|
|
8568
8842
|
);
|
|
8569
|
-
|
|
8843
|
+
}
|
|
8844
|
+
if (outcome === "released")
|
|
8845
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
8570
8846
|
} catch (error) {
|
|
8571
8847
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
8572
8848
|
phase: "live-rebind-chooser",
|
|
@@ -8575,31 +8851,53 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8575
8851
|
selection.isCurrent = undefined;
|
|
8576
8852
|
}
|
|
8577
8853
|
assertExecutionCurrent();
|
|
8578
|
-
|
|
8579
|
-
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
:
|
|
8854
|
+
if (outcome !== "released") {
|
|
8855
|
+
await deps.answerCallbackQuery(
|
|
8856
|
+
query.id,
|
|
8857
|
+
"Live rebind is unconfirmed; this choice never replays issued input",
|
|
8858
|
+
);
|
|
8859
|
+
return true;
|
|
8860
|
+
}
|
|
8861
|
+
const cleanupRow = deps.getWorkspaceRestoreStore?.()?.listLiveRebindings().find(value => value.request.operationId === selection.coordinator?.operationId);
|
|
8862
|
+
const cleanupResult = cleanupRow?.inputRelease && cleanupRow.cleanup !== undefined
|
|
8863
|
+
? cleanupRow.cleanup === "confirmed" ? "old-Thread removal confirmed"
|
|
8864
|
+
: cleanupRow.cleanup === "unknown" || cleanupRow.cleanup === "issued" ? "old-Thread removal unconfirmed; deletion will not repeat"
|
|
8865
|
+
: cleanupRow.cleanup === "failed" ? "old-Thread removal failed" : "old-Thread removal was not issued"
|
|
8866
|
+
: undefined;
|
|
8867
|
+
const commandDisposed = !!(selection.extensionCommand && selection.extensionKind !== "generated-prompt" || selection.nameCommand ||
|
|
8868
|
+
selection.confirmationCommand || selection.helpCommand || selection.stopCommand || selection.nextCommand ||
|
|
8869
|
+
selection.menuCommand || selection.abortCommand || selection.coordinator?.selectedCommandReference());
|
|
8870
|
+
// The tab now belongs to the rebound thread; only the one-shot chooser message goes.
|
|
8871
|
+
await finalizePendingReroute(
|
|
8872
|
+
parsed.rerouteId,
|
|
8873
|
+
pending,
|
|
8874
|
+
query,
|
|
8875
|
+
cleanupResult
|
|
8876
|
+
? commandDisposed ? `Command source disposal confirmed; ${cleanupResult}; delivery and follow-on effects remain independent`
|
|
8877
|
+
: `Input queued in the rebound Thread; ${cleanupResult}`
|
|
8878
|
+
: selection.extensionKind === "generated-prompt"
|
|
8879
|
+
? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
|
|
8880
|
+
: selection.extensionCommand
|
|
8881
|
+
? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
|
|
8882
|
+
: selection.nameCommand
|
|
8883
|
+
? selection.nameCommand.args.trim()
|
|
8884
|
+
? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
|
|
8885
|
+
: "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
|
|
8886
|
+
: selection.confirmationCommand
|
|
8887
|
+
? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
|
|
8888
|
+
: selection.helpCommand
|
|
8889
|
+
? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
|
|
8890
|
+
: selection.stopCommand
|
|
8891
|
+
? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
|
|
8892
|
+
: selection.nextCommand
|
|
8893
|
+
? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
|
|
8894
|
+
: selection.menuCommand || selection.abortCommand
|
|
8895
|
+
? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
|
|
8896
|
+
: selection.coordinator?.selectedCommandReference()
|
|
8897
|
+
? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
|
|
8898
|
+
: "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed",
|
|
8899
|
+
assertExecutionCurrent,
|
|
8900
|
+
true,
|
|
8603
8901
|
);
|
|
8604
8902
|
return true;
|
|
8605
8903
|
}
|
|
@@ -8611,6 +8909,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8611
8909
|
messages: [...pending.messages],
|
|
8612
8910
|
});
|
|
8613
8911
|
let active = true;
|
|
8912
|
+
// Delivery proof for the toast only; chooser retirement still waits for the positive settlement that follows.
|
|
8913
|
+
let delivered = false;
|
|
8614
8914
|
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
8615
8915
|
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
8616
8916
|
const admissionScope = deps.getAdmissionScope?.();
|
|
@@ -8676,7 +8976,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8676
8976
|
: routed;
|
|
8677
8977
|
await dispatchPendingRerouteMessages(pending, messages, ctx);
|
|
8678
8978
|
assertRecipientCurrent();
|
|
8679
|
-
|
|
8979
|
+
delivered = true;
|
|
8980
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
8680
8981
|
return true;
|
|
8681
8982
|
}
|
|
8682
8983
|
const forwarded = await forwardPendingRerouteMessages(
|
|
@@ -8687,7 +8988,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8687
8988
|
assertRecipientCurrent,
|
|
8688
8989
|
recordForwardAcceptance,
|
|
8689
8990
|
);
|
|
8690
|
-
if (forwarded)
|
|
8991
|
+
if (forwarded) {
|
|
8992
|
+
delivered = true;
|
|
8993
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
8994
|
+
}
|
|
8691
8995
|
return forwarded;
|
|
8692
8996
|
},
|
|
8693
8997
|
});
|
|
@@ -8698,7 +9002,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8698
9002
|
if (!selectionCurrent()) return true;
|
|
8699
9003
|
await deps.answerCallbackQuery(
|
|
8700
9004
|
query.id,
|
|
8701
|
-
"Restore unconfirmed; not resending",
|
|
9005
|
+
delivered ? "Restored" : "Restore unconfirmed; not resending",
|
|
8702
9006
|
);
|
|
8703
9007
|
return true;
|
|
8704
9008
|
}
|
|
@@ -10080,7 +10384,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10080
10384
|
}
|
|
10081
10385
|
return undefined;
|
|
10082
10386
|
};
|
|
10083
|
-
/**
|
|
10387
|
+
/**
|
|
10388
|
+
* A fresh implicit native tab observed just before the All input in the same exact scope: mobile names it after
|
|
10389
|
+
* the input, while other clients may use a generic name, so the immediately preceding creation also qualifies.
|
|
10390
|
+
*/
|
|
10084
10391
|
const findImplicitCreationForAllInput = (
|
|
10085
10392
|
message: TMessage,
|
|
10086
10393
|
updateId: number,
|
|
@@ -10098,8 +10405,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10098
10405
|
| undefined;
|
|
10099
10406
|
for (const [key, implicit] of implicitThreadCreations) {
|
|
10100
10407
|
if (
|
|
10101
|
-
implicit.name &&
|
|
10102
|
-
|
|
10408
|
+
((implicit.name && text.startsWith(implicit.name)) ||
|
|
10409
|
+
implicit.updateId === updateId - 1) &&
|
|
10103
10410
|
implicit.updateId < updateId &&
|
|
10104
10411
|
implicit.createdAtSec > 0 &&
|
|
10105
10412
|
message.date !== undefined &&
|
|
@@ -10122,15 +10429,23 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10122
10429
|
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
10123
10430
|
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
10124
10431
|
*/
|
|
10432
|
+
/**
|
|
10433
|
+
* Opens (or adopts) the routing tab for input sent from All. One text message may carry a command; anything else,
|
|
10434
|
+
* a voice note, a file or a coalesced album, is held untouched as one prompt.
|
|
10435
|
+
*/
|
|
10125
10436
|
const sendAllTabTemporaryInputChooser = async (
|
|
10126
10437
|
command: Commands.ParsedTelegramCommand | undefined,
|
|
10127
10438
|
commandText: string,
|
|
10128
|
-
|
|
10439
|
+
messages: readonly TMessage[],
|
|
10129
10440
|
ctx: TContext,
|
|
10130
10441
|
): Promise<boolean> => {
|
|
10131
|
-
const
|
|
10132
|
-
|
|
10133
|
-
|
|
10442
|
+
const message = messages[0];
|
|
10443
|
+
if (!message) return false;
|
|
10444
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds([
|
|
10445
|
+
...messages,
|
|
10446
|
+
]).sort((a, b) => a - b);
|
|
10447
|
+
const updateId = updateIds[0];
|
|
10448
|
+
const group = { journalBindingKey: "", updateIds };
|
|
10134
10449
|
const execution = Updates.getTelegramUpdateExecutionFence(message);
|
|
10135
10450
|
const cap = captureTemporaryThreadAuthority(
|
|
10136
10451
|
ctx,
|
|
@@ -10141,15 +10456,20 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10141
10456
|
!deps.runWorkspaceOperation ||
|
|
10142
10457
|
!deps.callApi ||
|
|
10143
10458
|
updateId === undefined ||
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10459
|
+
updateIds.length !== messages.length ||
|
|
10460
|
+
messages.some(
|
|
10461
|
+
(held) =>
|
|
10462
|
+
held.chat.type !== "private" ||
|
|
10463
|
+
held.chat.id !== cap.operatorUserId ||
|
|
10464
|
+
held.from?.id !== cap.operatorUserId,
|
|
10465
|
+
) ||
|
|
10147
10466
|
!cap.isCurrent()
|
|
10148
10467
|
)
|
|
10149
10468
|
return false;
|
|
10150
10469
|
const { store, operatorUserId, journalBindingKey, epoch, authority } = cap,
|
|
10151
10470
|
current = cap.isCurrent;
|
|
10152
10471
|
const source = { journalBindingKey, updateId };
|
|
10472
|
+
group.journalBindingKey = journalBindingKey;
|
|
10153
10473
|
const find = () =>
|
|
10154
10474
|
store
|
|
10155
10475
|
.listTemporaryThreads()
|
|
@@ -10159,6 +10479,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10159
10479
|
entry.source.updateId === updateId,
|
|
10160
10480
|
);
|
|
10161
10481
|
let entry: Threads.TelegramTemporaryThreadEntry | undefined;
|
|
10482
|
+
// A tab opened by this call, by the bot or adopted from the client, gets the input's one forwarded copy; re-entry
|
|
10483
|
+
// after a restart finds its tab already prepared and never forwards again.
|
|
10484
|
+
let openedNow = false;
|
|
10162
10485
|
await deps.runWorkspaceOperation(
|
|
10163
10486
|
{
|
|
10164
10487
|
operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
|
|
@@ -10175,10 +10498,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10175
10498
|
}
|
|
10176
10499
|
// Mobile clients sending from All first create a native tab named after the input, then deliver
|
|
10177
10500
|
// the input itself to All: adopt that tab rather than leaving it behind next to a second one.
|
|
10178
|
-
const native = findImplicitCreationForAllInput(
|
|
10501
|
+
const native = findImplicitCreationForAllInput(
|
|
10502
|
+
message,
|
|
10503
|
+
updateId,
|
|
10504
|
+
cap,
|
|
10505
|
+
ctx,
|
|
10506
|
+
);
|
|
10179
10507
|
if (native) {
|
|
10180
10508
|
const adopted = store.registerImplicitTemporaryThread(
|
|
10181
|
-
|
|
10509
|
+
group,
|
|
10182
10510
|
native.implicit.target,
|
|
10183
10511
|
randomBytes(16).toString("hex"),
|
|
10184
10512
|
{
|
|
@@ -10191,6 +10519,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10191
10519
|
if (adopted && current()) {
|
|
10192
10520
|
implicitThreadCreations.delete(native.key);
|
|
10193
10521
|
entry = adopted;
|
|
10522
|
+
openedNow = true;
|
|
10194
10523
|
// Adopted native tabs share the routing name; a failed rename never blocks routing.
|
|
10195
10524
|
try {
|
|
10196
10525
|
await deps.callApi!("editForumTopic", {
|
|
@@ -10210,6 +10539,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10210
10539
|
source,
|
|
10211
10540
|
randomBytes(16).toString("hex"),
|
|
10212
10541
|
authority,
|
|
10542
|
+
group,
|
|
10213
10543
|
);
|
|
10214
10544
|
entry = reservation?.entry;
|
|
10215
10545
|
if (!reservation?.reserved || !current()) return;
|
|
@@ -10237,31 +10567,33 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10237
10567
|
threadId <= 0
|
|
10238
10568
|
)
|
|
10239
10569
|
return;
|
|
10240
|
-
|
|
10241
|
-
|
|
10242
|
-
|
|
10243
|
-
|
|
10244
|
-
|
|
10245
|
-
|
|
10570
|
+
const acknowledged = store.acknowledgeTemporaryThread(
|
|
10571
|
+
reservation.entry,
|
|
10572
|
+
{ chatId: operatorUserId, threadId },
|
|
10573
|
+
authority,
|
|
10574
|
+
);
|
|
10575
|
+
openedNow = !!acknowledged;
|
|
10576
|
+
entry = acknowledged ?? entry;
|
|
10246
10577
|
},
|
|
10247
10578
|
);
|
|
10248
10579
|
if (!current() || !entry)
|
|
10249
10580
|
throw new Error(
|
|
10250
10581
|
"Temporary Thread authority changed; the input remains retryable.",
|
|
10251
10582
|
);
|
|
10252
|
-
|
|
10253
|
-
|
|
10254
|
-
text
|
|
10255
|
-
|
|
10256
|
-
|
|
10583
|
+
// Only a lone text message is rewritten for command detection; media keeps its caption and payload.
|
|
10584
|
+
const held: TMessage[] =
|
|
10585
|
+
messages.length === 1 && typeof message.text === "string"
|
|
10586
|
+
? [
|
|
10587
|
+
Updates.carryTelegramUpdateExecutionFence(message, {
|
|
10588
|
+
...message,
|
|
10589
|
+
text: commandText,
|
|
10590
|
+
caption: undefined,
|
|
10591
|
+
} as TMessage),
|
|
10592
|
+
]
|
|
10593
|
+
: [...messages];
|
|
10257
10594
|
if (entry.phase !== "created" || !entry.target) {
|
|
10258
|
-
Updates.reportTelegramUpdateDeferred(
|
|
10259
|
-
|
|
10260
|
-
message.chat.id,
|
|
10261
|
-
message.message_id,
|
|
10262
|
-
"<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.",
|
|
10263
|
-
{ parseMode: "HTML" },
|
|
10264
|
-
);
|
|
10595
|
+
for (const input of held) Updates.reportTelegramUpdateDeferred(input);
|
|
10596
|
+
deps.recordRuntimeEvent?.("routing", new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR), { phase: "temporary-thread-unconfirmed" });
|
|
10265
10597
|
return true;
|
|
10266
10598
|
}
|
|
10267
10599
|
const target = entry.target;
|
|
@@ -10270,23 +10602,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10270
10602
|
deps.getLiveThreadTargets?.(),
|
|
10271
10603
|
);
|
|
10272
10604
|
const rerouteId = storePendingUnboundReroute(
|
|
10273
|
-
|
|
10605
|
+
held,
|
|
10274
10606
|
command ? "command" : "prompt",
|
|
10275
10607
|
target,
|
|
10276
10608
|
);
|
|
10277
10609
|
const pending = pendingUnboundReroutes.get(rerouteId)!;
|
|
10278
10610
|
pending.temporaryThread = entry;
|
|
10279
10611
|
pending.allTabCopies = getTelegramAllTabSourceMessageIds(
|
|
10280
|
-
|
|
10612
|
+
messages,
|
|
10281
10613
|
target.chatId,
|
|
10282
10614
|
);
|
|
10283
|
-
Updates.reportTelegramUpdateDeferred(
|
|
10615
|
+
for (const input of held) Updates.reportTelegramUpdateDeferred(input);
|
|
10284
10616
|
// Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
|
|
10285
10617
|
if (
|
|
10286
10618
|
deps.editInteractiveMessage &&
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
journalBindingKey,
|
|
10619
|
+
held.every((input) =>
|
|
10620
|
+
Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey),
|
|
10290
10621
|
)
|
|
10291
10622
|
) {
|
|
10292
10623
|
pending.abandonment = {
|
|
@@ -10298,6 +10629,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10298
10629
|
pending.rootChooserText = formatTelegramTemporaryThreadChooserText(
|
|
10299
10630
|
command?.name,
|
|
10300
10631
|
);
|
|
10632
|
+
const tabCopyId = openedNow
|
|
10633
|
+
? await moveAllTabSourceIntoTab(pending, target, current)
|
|
10634
|
+
: undefined;
|
|
10301
10635
|
try {
|
|
10302
10636
|
if (!deps.sendInteractiveMessage)
|
|
10303
10637
|
throw new Error("Temporary Thread chooser publication is unavailable.");
|
|
@@ -10309,7 +10643,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10309
10643
|
canCancel: !!pending.abandonment,
|
|
10310
10644
|
getDisplayTitle: deps.getDisplayTitle,
|
|
10311
10645
|
}),
|
|
10312
|
-
|
|
10646
|
+
tabCopyId === undefined
|
|
10647
|
+
? { target }
|
|
10648
|
+
: { target, replyToMessageId: tabCopyId },
|
|
10313
10649
|
);
|
|
10314
10650
|
rememberRerouteChooser(rerouteId, chooserId);
|
|
10315
10651
|
} catch (error) {
|
|
@@ -10318,96 +10654,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10318
10654
|
}
|
|
10319
10655
|
return true;
|
|
10320
10656
|
};
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
10324
|
-
|
|
10325
|
-
|
|
10326
|
-
|
|
10327
|
-
|
|
10328
|
-
|
|
10329
|
-
): Promise<boolean> => {
|
|
10330
|
-
if (!deps.threadStore) return false;
|
|
10331
|
-
const records = deps.threadStore.list();
|
|
10332
|
-
const activeRecords = getTelegramRoutableThreadRecords(
|
|
10333
|
-
records,
|
|
10334
|
-
deps.getLiveThreadTargets?.(),
|
|
10335
|
-
);
|
|
10336
|
-
if (activeRecords.length === 0) return false;
|
|
10337
|
-
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
10338
|
-
...message,
|
|
10339
|
-
text: commandText,
|
|
10340
|
-
caption: undefined,
|
|
10341
|
-
} as TMessage);
|
|
10342
|
-
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
10343
|
-
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
10344
|
-
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
10345
|
-
pendingUnboundReroutes.get(rerouteId)!.rootChooserText = text;
|
|
10346
|
-
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
10347
|
-
rerouteId,
|
|
10348
|
-
activeRecords,
|
|
10349
|
-
{
|
|
10350
|
-
canRestore: typeof message.message_thread_id === "number",
|
|
10351
|
-
getDisplayTitle: deps.getDisplayTitle,
|
|
10352
|
-
},
|
|
10353
|
-
);
|
|
10354
|
-
let chooserId: number | undefined;
|
|
10355
|
-
try {
|
|
10356
|
-
if (deps.sendInteractiveMessage) {
|
|
10357
|
-
chooserId = await deps.sendInteractiveMessage(
|
|
10358
|
-
message.chat.id,
|
|
10359
|
-
text,
|
|
10360
|
-
"html",
|
|
10361
|
-
replyMarkup,
|
|
10362
|
-
options.target || options.replyToSource
|
|
10363
|
-
? {
|
|
10364
|
-
...(options.target ? { target: options.target } : {}),
|
|
10365
|
-
...(options.replyToSource
|
|
10366
|
-
? { replyToMessageId: message.message_id }
|
|
10367
|
-
: {}),
|
|
10368
|
-
}
|
|
10369
|
-
: undefined,
|
|
10370
|
-
);
|
|
10371
|
-
} else if (deps.callApi) {
|
|
10372
|
-
const chooser = await deps.callApi<{ message_id?: number }>(
|
|
10373
|
-
"sendMessage",
|
|
10374
|
-
{
|
|
10375
|
-
chat_id: message.chat.id,
|
|
10376
|
-
text,
|
|
10377
|
-
parse_mode: "HTML",
|
|
10378
|
-
reply_markup: replyMarkup,
|
|
10379
|
-
...(typeof options.target?.threadId === "number"
|
|
10380
|
-
? { message_thread_id: options.target.threadId }
|
|
10381
|
-
: {}),
|
|
10382
|
-
...(options.replyToSource
|
|
10383
|
-
? {
|
|
10384
|
-
reply_parameters: {
|
|
10385
|
-
message_id: message.message_id,
|
|
10386
|
-
allow_sending_without_reply: true,
|
|
10387
|
-
},
|
|
10388
|
-
}
|
|
10389
|
-
: {}),
|
|
10390
|
-
},
|
|
10391
|
-
);
|
|
10392
|
-
chooserId = chooser?.message_id;
|
|
10393
|
-
} else {
|
|
10394
|
-
chooserId = await deps.sendTextReply(
|
|
10395
|
-
message.chat.id,
|
|
10396
|
-
message.message_id,
|
|
10397
|
-
text,
|
|
10398
|
-
{
|
|
10399
|
-
parseMode: "HTML",
|
|
10400
|
-
target: options.target,
|
|
10401
|
-
},
|
|
10402
|
-
);
|
|
10403
|
-
}
|
|
10404
|
-
} catch (error) {
|
|
10405
|
-
removePendingReroute(rerouteId);
|
|
10406
|
-
throw error;
|
|
10407
|
-
}
|
|
10408
|
-
rememberRerouteChooser(rerouteId, chooserId);
|
|
10409
|
-
Updates.reportTelegramUpdateCompleted(commandMessage);
|
|
10410
|
-
return true;
|
|
10657
|
+
/** A coalesced album from All requires one routing tab; failure cannot answer in All or discard its source. */
|
|
10658
|
+
const routeAllTabAlbum = async (
|
|
10659
|
+
messages: TMessage[],
|
|
10660
|
+
ctx: TContext,
|
|
10661
|
+
): Promise<void> => {
|
|
10662
|
+
if (await sendAllTabTemporaryInputChooser(undefined, "", messages, ctx))
|
|
10663
|
+
return;
|
|
10664
|
+
throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
|
|
10411
10665
|
};
|
|
10412
10666
|
const dispatchCommandOrPrompt =
|
|
10413
10667
|
Commands.createTelegramCommandOrPromptDispatcher<TMessage, TContext>({
|
|
@@ -10705,7 +10959,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10705
10959
|
chatId: lifecycle.target.chatId,
|
|
10706
10960
|
threadId: lifecycle.target.threadId!,
|
|
10707
10961
|
},
|
|
10708
|
-
name:
|
|
10962
|
+
name:
|
|
10963
|
+
"name" in created && typeof created.name === "string"
|
|
10964
|
+
? created.name
|
|
10965
|
+
: "",
|
|
10709
10966
|
createdAtSec: message.date ?? 0,
|
|
10710
10967
|
});
|
|
10711
10968
|
} else implicitThreadCreations.delete(key);
|
|
@@ -10980,6 +11237,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10980
11237
|
entry.source.journalBindingKey ===
|
|
10981
11238
|
deps.getAdmissionJournalBinding?.(),
|
|
10982
11239
|
) === true;
|
|
11240
|
+
const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
|
|
10983
11241
|
// Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
|
|
10984
11242
|
if (
|
|
10985
11243
|
command &&
|
|
@@ -10989,30 +11247,48 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10989
11247
|
)
|
|
10990
11248
|
return;
|
|
10991
11249
|
if (
|
|
10992
|
-
|
|
10993
|
-
(bindings.length > 0 || presented) &&
|
|
11250
|
+
needsThread &&
|
|
10994
11251
|
typeof message.text === "string" &&
|
|
10995
11252
|
message.text.trim() &&
|
|
10996
11253
|
(await sendAllTabTemporaryInputChooser(
|
|
10997
11254
|
command,
|
|
10998
11255
|
text,
|
|
10999
|
-
message as TMessage,
|
|
11256
|
+
[message as TMessage],
|
|
11000
11257
|
ctx,
|
|
11001
11258
|
))
|
|
11002
11259
|
)
|
|
11003
11260
|
return;
|
|
11004
|
-
|
|
11005
|
-
|
|
11006
|
-
|
|
11007
|
-
|
|
11008
|
-
|
|
11009
|
-
|
|
11261
|
+
// Voice notes, files, stickers and albums from All get the same routing tab as text, whether or not the
|
|
11262
|
+
// client opened a tab for them; an album first gathers into one input so it opens one tab.
|
|
11263
|
+
if (
|
|
11264
|
+
!command &&
|
|
11265
|
+
needsThread &&
|
|
11266
|
+
typeof message.text !== "string" &&
|
|
11267
|
+
Media.hasTelegramMessagesPromptContent([message as TMessage])
|
|
11268
|
+
) {
|
|
11269
|
+
if (message.media_group_id) {
|
|
11270
|
+
deps.mediaGroupRuntime.queueMessage({
|
|
11271
|
+
message: message as TMessage,
|
|
11272
|
+
context: ctx,
|
|
11273
|
+
dispatchMessages: (messages, queuedCtx) =>
|
|
11274
|
+
routeAllTabAlbum(messages, queuedCtx ?? ctx),
|
|
11275
|
+
});
|
|
11276
|
+
Updates.reportTelegramUpdateDeferred(message as TMessage);
|
|
11010
11277
|
return;
|
|
11011
11278
|
}
|
|
11279
|
+
if (
|
|
11280
|
+
await sendAllTabTemporaryInputChooser(
|
|
11281
|
+
undefined,
|
|
11282
|
+
text,
|
|
11283
|
+
[message as TMessage],
|
|
11284
|
+
ctx,
|
|
11285
|
+
)
|
|
11286
|
+
)
|
|
11287
|
+
return;
|
|
11012
11288
|
}
|
|
11013
|
-
if (
|
|
11289
|
+
if (needsThread) {
|
|
11014
11290
|
const probeTarget = bindings[0]?.target;
|
|
11015
|
-
if (probeTarget?.threadId && deps.callApi) {
|
|
11291
|
+
if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
|
|
11016
11292
|
try {
|
|
11017
11293
|
await deps.callApi("sendChatAction", {
|
|
11018
11294
|
chat_id: probeTarget.chatId,
|
|
@@ -11021,8 +11297,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11021
11297
|
});
|
|
11022
11298
|
} catch (error) {
|
|
11023
11299
|
if (
|
|
11024
|
-
Threads.isTelegramTopicModeUnavailableError(error)
|
|
11025
|
-
Threads.isTelegramTopicTargetStaleError(error)
|
|
11300
|
+
Threads.isTelegramTopicModeUnavailableError(error)
|
|
11026
11301
|
) {
|
|
11027
11302
|
deps.threadStore.setBotState({
|
|
11028
11303
|
threadMode: "disabled",
|
|
@@ -11042,12 +11317,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11042
11317
|
});
|
|
11043
11318
|
}
|
|
11044
11319
|
}
|
|
11045
|
-
|
|
11046
|
-
message.chat.id,
|
|
11047
|
-
message.message_id,
|
|
11048
|
-
"This bot is in threaded multi-instance mode. Send prompts in a bound Pi thread tab so they route to the right instance.",
|
|
11049
|
-
);
|
|
11050
|
-
return;
|
|
11320
|
+
throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
|
|
11051
11321
|
}
|
|
11052
11322
|
}
|
|
11053
11323
|
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
@@ -11097,6 +11367,33 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11097
11367
|
threadId: target.threadId,
|
|
11098
11368
|
};
|
|
11099
11369
|
/** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
|
|
11370
|
+
// The leader's own Workspace binding follows a reclaimed tab, keeping its slot and name; without it, the tab
|
|
11371
|
+
// would gain a second slot while Restore, labels and titles still pointed at the deleted target.
|
|
11372
|
+
const leaderWorkspaceCwd =
|
|
11373
|
+
typeof (ctx as { cwd?: unknown }).cwd === "string"
|
|
11374
|
+
? (ctx as { cwd: string }).cwd
|
|
11375
|
+
: undefined;
|
|
11376
|
+
const claimLeaderWorkspaceBinding = ():
|
|
11377
|
+
Threads.TelegramWorkspaceThreadBinding | undefined => {
|
|
11378
|
+
const sessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
|
|
11379
|
+
if (!leaderWorkspaceCwd || !sessionId || !instanceId)
|
|
11380
|
+
return undefined;
|
|
11381
|
+
const identity = deps.threadStore!.claimWorkspaceIdentity(
|
|
11382
|
+
leaderWorkspaceCwd,
|
|
11383
|
+
instanceId,
|
|
11384
|
+
undefined,
|
|
11385
|
+
{ existingBindingOnly: true, sessionId },
|
|
11386
|
+
);
|
|
11387
|
+
const binding = identity
|
|
11388
|
+
? deps.threadStore!.getWorkspaceBinding(
|
|
11389
|
+
identity.cwd,
|
|
11390
|
+
identity.instanceSlot,
|
|
11391
|
+
identity.sessionId,
|
|
11392
|
+
)
|
|
11393
|
+
: undefined;
|
|
11394
|
+
if (!binding) deps.threadStore!.releaseWorkspaceClaim(instanceId);
|
|
11395
|
+
return binding;
|
|
11396
|
+
};
|
|
11100
11397
|
const reclaimUnboundTargetForLeader = async (
|
|
11101
11398
|
base: Partial<Threads.TelegramTopicTargetRecord> &
|
|
11102
11399
|
Pick<Threads.TelegramTopicTargetRecord, "createdAtMs">,
|
|
@@ -11105,7 +11402,25 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11105
11402
|
threadName: string,
|
|
11106
11403
|
event: string,
|
|
11107
11404
|
details: Record<string, unknown>,
|
|
11405
|
+
leaderBinding?: Threads.TelegramWorkspaceThreadBinding,
|
|
11108
11406
|
): Promise<void> => {
|
|
11407
|
+
if (leaderBinding) {
|
|
11408
|
+
const {
|
|
11409
|
+
inactiveSinceMs: _inactiveSinceMs,
|
|
11410
|
+
displayTitle: _displayTitle,
|
|
11411
|
+
...moved
|
|
11412
|
+
} = leaderBinding;
|
|
11413
|
+
const committed = deps.threadStore!.upsertWorkspaceBinding(
|
|
11414
|
+
{
|
|
11415
|
+
...moved,
|
|
11416
|
+
target: { ...unboundTarget },
|
|
11417
|
+
updatedAtMs: Date.now(),
|
|
11418
|
+
},
|
|
11419
|
+
instanceId,
|
|
11420
|
+
);
|
|
11421
|
+
deps.threadStore!.releaseWorkspaceClaim(instanceId!);
|
|
11422
|
+
if (committed) threadName = committed.threadName ?? threadName;
|
|
11423
|
+
}
|
|
11109
11424
|
deps.threadStore!.upsert({
|
|
11110
11425
|
...base,
|
|
11111
11426
|
profileKey,
|
|
@@ -11137,6 +11452,35 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11137
11452
|
slot,
|
|
11138
11453
|
profileKey,
|
|
11139
11454
|
});
|
|
11455
|
+
if (leaderBinding && deps.callApi) {
|
|
11456
|
+
const title = deps.getDisplayTitle?.(unboundTarget);
|
|
11457
|
+
if (title)
|
|
11458
|
+
try {
|
|
11459
|
+
await deps.callApi("editForumTopic", {
|
|
11460
|
+
chat_id: unboundTarget.chatId,
|
|
11461
|
+
message_thread_id: unboundTarget.threadId,
|
|
11462
|
+
name: title,
|
|
11463
|
+
});
|
|
11464
|
+
const current = deps
|
|
11465
|
+
.threadStore!.listWorkspaceBindings()
|
|
11466
|
+
.find(
|
|
11467
|
+
(value) =>
|
|
11468
|
+
value.bindingKey === leaderBinding.bindingKey &&
|
|
11469
|
+
isDeepStrictEqual(value.target, unboundTarget),
|
|
11470
|
+
);
|
|
11471
|
+
if (current) {
|
|
11472
|
+
deps.threadStore!.upsertWorkspaceBinding({
|
|
11473
|
+
...current,
|
|
11474
|
+
displayTitle: title,
|
|
11475
|
+
});
|
|
11476
|
+
await deps.threadStore!.persist();
|
|
11477
|
+
}
|
|
11478
|
+
} catch (error) {
|
|
11479
|
+
deps.recordRuntimeEvent?.("bus", error, {
|
|
11480
|
+
phase: "leader-topic-reclaim-title",
|
|
11481
|
+
});
|
|
11482
|
+
}
|
|
11483
|
+
}
|
|
11140
11484
|
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
11141
11485
|
};
|
|
11142
11486
|
const records = deps.threadStore.list();
|
|
@@ -11277,7 +11621,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11277
11621
|
if (!currentLeaderIsStale) throw error;
|
|
11278
11622
|
}
|
|
11279
11623
|
if (currentLeaderIsStale) {
|
|
11280
|
-
const
|
|
11624
|
+
const leaderBinding = claimLeaderWorkspaceBinding();
|
|
11625
|
+
const slot = deps.threadStore.allocateSlot(
|
|
11626
|
+
leaderProfileKey,
|
|
11627
|
+
leaderBinding?.slot,
|
|
11628
|
+
leaderBinding?.bindingKey,
|
|
11629
|
+
);
|
|
11630
|
+
if (leaderBinding && leaderBinding.slot !== slot)
|
|
11631
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId!);
|
|
11281
11632
|
if (!slot) {
|
|
11282
11633
|
deps.threadStore.markStaleByTarget(
|
|
11283
11634
|
currentLeaderRecord.target,
|
|
@@ -11313,6 +11664,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11313
11664
|
phase: "leader-topic-unbound-stale-reclaim",
|
|
11314
11665
|
staleThreadId: currentLeaderRecord.target.threadId,
|
|
11315
11666
|
},
|
|
11667
|
+
leaderBinding?.slot === slot ? leaderBinding : undefined,
|
|
11316
11668
|
);
|
|
11317
11669
|
return;
|
|
11318
11670
|
}
|
|
@@ -11327,10 +11679,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11327
11679
|
deps.threadStore.getByProfileKey(leaderProfileKey);
|
|
11328
11680
|
const priorLeaderIdentity =
|
|
11329
11681
|
deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
|
|
11682
|
+
const leaderBinding = claimLeaderWorkspaceBinding();
|
|
11330
11683
|
const slot = deps.threadStore.allocateSlot(
|
|
11331
11684
|
leaderProfileKey,
|
|
11332
|
-
|
|
11685
|
+
leaderBinding?.slot ??
|
|
11686
|
+
priorLeaderRecord?.slot ??
|
|
11687
|
+
priorLeaderIdentity?.slot,
|
|
11688
|
+
leaderBinding?.bindingKey,
|
|
11333
11689
|
);
|
|
11690
|
+
if (leaderBinding && leaderBinding.slot !== slot)
|
|
11691
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId!);
|
|
11334
11692
|
if (!slot) {
|
|
11335
11693
|
await deps.sendTextReply(
|
|
11336
11694
|
target.chatId,
|
|
@@ -11360,6 +11718,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11360
11718
|
threadName,
|
|
11361
11719
|
"Bus leader reclaimed unbound thread",
|
|
11362
11720
|
{ phase: "leader-topic-reclaim" },
|
|
11721
|
+
leaderBinding?.slot === slot ? leaderBinding : undefined,
|
|
11363
11722
|
);
|
|
11364
11723
|
return;
|
|
11365
11724
|
}
|
|
@@ -11389,6 +11748,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11389
11748
|
onUpdateCompleted,
|
|
11390
11749
|
prepareHeldCommand: commandHandler.prepareHeldCommand,
|
|
11391
11750
|
canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
|
|
11751
|
+
onSessionSettled,
|
|
11752
|
+
onDeliverySettled,
|
|
11392
11753
|
observeLiveRebindLeaderWork,
|
|
11393
11754
|
prepareLiveRebindLeaderCleanup,
|
|
11394
11755
|
prepareLiveRebindFollowerCleanup,
|