@llblab/pi-telegram 0.54.4 → 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 +11 -0
- package/README.md +5 -3
- package/dist/lib/activity.d.ts +5 -1
- package/dist/lib/activity.js +31 -2
- package/dist/lib/bindings.d.ts +3 -1
- package/dist/lib/bindings.js +8 -2
- package/dist/lib/bus-api.d.ts +2 -2
- package/dist/lib/bus-api.js +7 -2
- package/dist/lib/bus-follower.d.ts +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 +48 -6
- package/dist/lib/lifecycle.d.ts +68 -0
- package/dist/lib/lifecycle.js +178 -58
- package/dist/lib/machine.d.ts +23 -0
- package/dist/lib/machine.js +37 -0
- package/dist/lib/media.d.ts +114 -28
- package/dist/lib/media.js +292 -119
- package/dist/lib/model.d.ts +54 -15
- package/dist/lib/model.js +98 -53
- package/dist/lib/polling.js +2 -0
- package/dist/lib/preview.d.ts +128 -113
- package/dist/lib/preview.js +448 -366
- package/dist/lib/queue.d.ts +2 -2
- package/dist/lib/queue.js +3 -3
- package/dist/lib/routing.d.ts +6 -1
- package/dist/lib/routing.js +334 -210
- package/dist/lib/runtime.d.ts +77 -11
- package/dist/lib/runtime.js +174 -92
- package/dist/lib/sections.js +0 -1
- package/dist/lib/telegram-api.d.ts +11 -9
- package/dist/lib/telegram-api.js +53 -4
- package/dist/lib/text-groups.d.ts +0 -26
- package/dist/lib/text-groups.js +51 -98
- package/dist/lib/thread-display.js +14 -6
- package/dist/lib/threads.d.ts +18 -5
- package/dist/lib/threads.js +107 -30
- package/dist/lib/turns.d.ts +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 +12 -11
- 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 +2 -2
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +9 -1
- package/lib/bus-api.ts +11 -3
- package/lib/bus-follower.ts +417 -203
- package/lib/bus-leader.ts +88 -14
- package/lib/bus.ts +17 -15
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +51 -9
- package/lib/lifecycle.ts +229 -55
- package/lib/machine.ts +60 -0
- package/lib/media.ts +438 -172
- package/lib/model.ts +169 -74
- package/lib/polling.ts +2 -0
- package/lib/preview.ts +715 -566
- package/lib/queue.ts +5 -5
- package/lib/routing.ts +325 -238
- package/lib/runtime.ts +260 -104
- package/lib/sections.ts +0 -2
- package/lib/telegram-api.ts +61 -8
- package/lib/text-groups.ts +75 -136
- package/lib/thread-display.ts +15 -4
- package/lib/threads.ts +120 -36
- package/lib/turns.ts +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[],
|
|
@@ -603,7 +599,7 @@ type TelegramLiveRebindSelectedCommandReference = {
|
|
|
603
599
|
selectedCommand: Bus.TelegramBusSelectedCommandInput;
|
|
604
600
|
};
|
|
605
601
|
|
|
606
|
-
/** 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. */
|
|
607
603
|
export function createTelegramLiveRebindCoordinator(input: {
|
|
608
604
|
request: Threads.TelegramWorkspaceRestoreRequest;
|
|
609
605
|
/** Captures caller source/reference, admission, profile, transport and Pi lifetime. */
|
|
@@ -647,6 +643,8 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
647
643
|
/** Explicit staged singleton command branch; absence preserves ordinary live input behavior. */
|
|
648
644
|
selectedCommand?: Bus.TelegramBusSelectedCommandInput;
|
|
649
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>;
|
|
650
648
|
/** Restore moves the tab to its new owner: once applied, show that owner's title (best-effort, never blocking). */
|
|
651
649
|
retitle?: (isCurrent: () => boolean) => Promise<void>;
|
|
652
650
|
recordRuntimeEvent?: (
|
|
@@ -662,6 +660,7 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
662
660
|
const leader = input.leader && { ...input.leader },
|
|
663
661
|
follower = input.follower,
|
|
664
662
|
followerRun = follower?.run;
|
|
663
|
+
const beforeCleanup = input.cleanupBeforeRelease;
|
|
665
664
|
const selectedCommand =
|
|
666
665
|
follower?.selectedCommand && structuredClone(follower.selectedCommand),
|
|
667
666
|
held = selectedCommand;
|
|
@@ -783,6 +782,8 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
783
782
|
peerReleaseIssued = false,
|
|
784
783
|
donorSettlementAttempted = false,
|
|
785
784
|
continuationIssued = false,
|
|
785
|
+
cleanupBeforeReleaseIssued = false,
|
|
786
|
+
cleanupBeforeReleaseDecided = false,
|
|
786
787
|
released = false,
|
|
787
788
|
advancing = false;
|
|
788
789
|
let preparedSource: Bus.TelegramBusPreparedCommandSource | undefined;
|
|
@@ -836,6 +837,13 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
836
837
|
generation: recipient!.generation,
|
|
837
838
|
bindingKey: recipient!.bindingKey,
|
|
838
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
|
+
};
|
|
839
847
|
const releaseFollower = async (
|
|
840
848
|
intent: Threads.TelegramWorkspaceLiveRebindIntent,
|
|
841
849
|
): Promise<"unknown" | "released"> => {
|
|
@@ -864,6 +872,9 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
864
872
|
)
|
|
865
873
|
return "unknown";
|
|
866
874
|
}
|
|
875
|
+
const accepted = acceptInput(intent);
|
|
876
|
+
if (!accepted) return "unknown";
|
|
877
|
+
intent = accepted;
|
|
867
878
|
donorSettlementAttempted = true;
|
|
868
879
|
if (preparation?.settleTransferred(() => canonical(intent)) !== "settled")
|
|
869
880
|
return "unknown";
|
|
@@ -874,8 +885,7 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
874
885
|
const intent = retained();
|
|
875
886
|
if (
|
|
876
887
|
!intent ||
|
|
877
|
-
intent
|
|
878
|
-
intent.cleanup ||
|
|
888
|
+
!Threads.isTelegramLiveRebindInputAuthorized(intent) ||
|
|
879
889
|
!canonical(intent) ||
|
|
880
890
|
!leader?.isApplied(intent)
|
|
881
891
|
)
|
|
@@ -883,16 +893,14 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
883
893
|
const completions = messages.map(
|
|
884
894
|
Updates.inspectTelegramDeferredSourceCompletion,
|
|
885
895
|
);
|
|
886
|
-
|
|
896
|
+
const completed = completions.every(
|
|
887
897
|
(completion, index) =>
|
|
888
898
|
completion && isDeepStrictEqual(completion, sources[index]),
|
|
889
899
|
) &&
|
|
890
900
|
current() &&
|
|
891
901
|
canonical(intent) &&
|
|
892
|
-
leader.isApplied(intent) &&
|
|
893
|
-
|
|
894
|
-
? "released"
|
|
895
|
-
: "unknown";
|
|
902
|
+
leader.isApplied(intent) && current();
|
|
903
|
+
return completed && acceptInput(intent) ? "released" : "unknown";
|
|
896
904
|
};
|
|
897
905
|
return {
|
|
898
906
|
/** Correlation only; a later cleanup attempt rereads and revalidates the canonical row itself. */
|
|
@@ -926,8 +934,7 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
926
934
|
const intent = retained();
|
|
927
935
|
if (
|
|
928
936
|
!intent ||
|
|
929
|
-
intent
|
|
930
|
-
intent.cleanup ||
|
|
937
|
+
!Threads.isTelegramLiveRebindInputAuthorized(intent) ||
|
|
931
938
|
!canonical(intent) ||
|
|
932
939
|
!leader?.isApplied(intent) ||
|
|
933
940
|
!leader.observeAdmission?.() ||
|
|
@@ -936,6 +943,7 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
936
943
|
!leader.isApplied(intent)
|
|
937
944
|
)
|
|
938
945
|
return "unknown";
|
|
946
|
+
if (!acceptInput(intent)) return "unknown";
|
|
939
947
|
released = true;
|
|
940
948
|
return "released";
|
|
941
949
|
}
|
|
@@ -1001,23 +1009,24 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
1001
1009
|
generation: recipient!.generation,
|
|
1002
1010
|
},
|
|
1003
1011
|
authority,
|
|
1012
|
+
beforeCleanup ? { holdInput: true } : undefined,
|
|
1004
1013
|
);
|
|
1005
1014
|
} else if (!commitIssued) return "protected";
|
|
1006
1015
|
if (
|
|
1007
1016
|
!intent ||
|
|
1008
1017
|
!sourceSaved() ||
|
|
1009
1018
|
!canonical(intent) ||
|
|
1010
|
-
intent.phase === "finished" ||
|
|
1011
|
-
intent.cleanup
|
|
1019
|
+
(intent.phase === "finished" && !cleanupBeforeReleaseIssued) ||
|
|
1020
|
+
(intent.cleanup && intent.inputRelease === undefined)
|
|
1012
1021
|
)
|
|
1013
1022
|
return "protected";
|
|
1014
1023
|
// A released peer may already have completed its sources; only its retained settlement can reobserve that outcome.
|
|
1015
1024
|
if (recipient!.kind === "follower" && peerReleaseIssued)
|
|
1016
|
-
return intent
|
|
1025
|
+
return Threads.isTelegramLiveRebindInputAuthorized(intent)
|
|
1017
1026
|
? await releaseFollower(intent)
|
|
1018
1027
|
: "protected";
|
|
1019
1028
|
const mode =
|
|
1020
|
-
applyIssued || intent.phase === "released" ? "inspect" : "apply";
|
|
1029
|
+
applyIssued || intent.phase === "released" || intent.phase === "finished" ? "inspect" : "apply";
|
|
1021
1030
|
applyIssued = true;
|
|
1022
1031
|
if (recipient!.kind === "leader") {
|
|
1023
1032
|
await leader!.apply(structuredClone(intent), mode, current);
|
|
@@ -1064,11 +1073,27 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
1064
1073
|
}
|
|
1065
1074
|
if (
|
|
1066
1075
|
!intent ||
|
|
1067
|
-
intent.phase !== "released" ||
|
|
1068
|
-
intent.cleanup ||
|
|
1076
|
+
(intent.phase !== "released" && !(intent.phase === "finished" && intent.inputRelease === "held")) ||
|
|
1077
|
+
(intent.cleanup && intent.inputRelease === undefined) ||
|
|
1069
1078
|
!canonical(intent)
|
|
1070
1079
|
)
|
|
1071
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
|
+
}
|
|
1072
1097
|
if (recipient!.kind === "leader") {
|
|
1073
1098
|
const expected = intent;
|
|
1074
1099
|
if (
|
|
@@ -1078,14 +1103,21 @@ export function createTelegramLiveRebindCoordinator(input: {
|
|
|
1078
1103
|
)
|
|
1079
1104
|
return "protected";
|
|
1080
1105
|
continuationIssued = true;
|
|
1081
|
-
const canContinue = () =>
|
|
1082
|
-
|
|
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
|
+
};
|
|
1083
1112
|
if (leader!.complete) {
|
|
1084
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";
|
|
1085
1116
|
return observeCommandCompletion();
|
|
1086
1117
|
}
|
|
1087
1118
|
if (!(await leader!.continue!(messages, canContinue)))
|
|
1088
1119
|
return "unknown";
|
|
1120
|
+
if (!acceptInput(intent)) return "unknown";
|
|
1089
1121
|
} else return await releaseFollower(intent);
|
|
1090
1122
|
if (!current()) return "unknown";
|
|
1091
1123
|
released = true;
|
|
@@ -1519,6 +1551,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1519
1551
|
hasWorkspaceRestoreAuthority?: () => boolean;
|
|
1520
1552
|
/** Exact live root authority; capability advertisement alone cannot replace owned profile/session/recipient fences. */
|
|
1521
1553
|
hasWorkspaceLiveRebindAuthority?: () => boolean;
|
|
1554
|
+
canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
|
|
1522
1555
|
/** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
|
|
1523
1556
|
inspectTemporaryThreadSources?: (
|
|
1524
1557
|
target: Queue.TelegramQueueTarget,
|
|
@@ -1819,6 +1852,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1819
1852
|
>["canPrepareHeldCommand"];
|
|
1820
1853
|
/** Agent-settled hint: wakes waiting leader cleanup retries early; every readiness gate is still sampled fresh. */
|
|
1821
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;
|
|
1822
1857
|
/** Fresh body-free recipient observation only; never reconstructs an attempt or grants cleanup. */
|
|
1823
1858
|
observeLiveRebindLeaderWork(
|
|
1824
1859
|
intent: Threads.TelegramWorkspaceLiveRebindIntent,
|
|
@@ -2825,24 +2860,42 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2825
2860
|
target: { chatId: number; threadId: number },
|
|
2826
2861
|
isCurrent: () => boolean,
|
|
2827
2862
|
): Promise<number | undefined> => {
|
|
2828
|
-
const
|
|
2829
|
-
if (
|
|
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
|
+
};
|
|
2830
2871
|
try {
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
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;
|
|
2843
2896
|
pending.allTabCopiesForwarded = true;
|
|
2844
2897
|
await deleteAllTabCopies(pending);
|
|
2845
|
-
return
|
|
2898
|
+
return copyIds[0];
|
|
2846
2899
|
} catch (error) {
|
|
2847
2900
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
2848
2901
|
phase: "temporary-thread-source-forward",
|
|
@@ -4774,8 +4827,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
4774
4827
|
.listLiveRebindings()
|
|
4775
4828
|
.find((value) => value.request.operationId === request.operationId);
|
|
4776
4829
|
if (
|
|
4777
|
-
intent
|
|
4778
|
-
!intent.cleanup &&
|
|
4830
|
+
intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
|
|
4779
4831
|
isDeepStrictEqual(intent.request, request) &&
|
|
4780
4832
|
intent.operatorUserId === operatorUserId &&
|
|
4781
4833
|
isDeepStrictEqual(intent.executor, {
|
|
@@ -5188,6 +5240,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5188
5240
|
name ||
|
|
5189
5241
|
confirmation ||
|
|
5190
5242
|
extension;
|
|
5243
|
+
const preDispatchCleanup =
|
|
5244
|
+
deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
|
|
5191
5245
|
const createCoordinator = () =>
|
|
5192
5246
|
createTelegramLiveRebindCoordinator({
|
|
5193
5247
|
request,
|
|
@@ -5198,25 +5252,55 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5198
5252
|
retitle: deps.callApi
|
|
5199
5253
|
? async (isCurrent) => {
|
|
5200
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!;
|
|
5201
5259
|
const slot = binding.slot ?? "";
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
)
|
|
5211
|
-
}
|
|
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.");
|
|
5212
5283
|
}
|
|
5213
5284
|
: undefined,
|
|
5214
5285
|
authority: {
|
|
5215
5286
|
executor: { instanceId, leaderEpoch: String(epoch) },
|
|
5216
5287
|
operatorUserId,
|
|
5217
|
-
isCurrent: current,
|
|
5288
|
+
isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
|
|
5218
5289
|
},
|
|
5219
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
|
+
} : {}),
|
|
5220
5304
|
follower: isLeader
|
|
5221
5305
|
? undefined
|
|
5222
5306
|
: {
|
|
@@ -5258,9 +5342,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5258
5342
|
instanceId,
|
|
5259
5343
|
leaderEpoch: String(epoch),
|
|
5260
5344
|
}) &&
|
|
5261
|
-
!intent.cleanup &&
|
|
5262
|
-
(intent.phase === "rebound" ||
|
|
5263
|
-
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) &&
|
|
5264
5348
|
isDeepStrictEqual(intent.recipient, {
|
|
5265
5349
|
kind: recipient.kind,
|
|
5266
5350
|
instanceId: recipient.instanceId,
|
|
@@ -5320,6 +5404,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5320
5404
|
});
|
|
5321
5405
|
},
|
|
5322
5406
|
isApplied,
|
|
5407
|
+
|
|
5323
5408
|
...(completeCommand
|
|
5324
5409
|
? {
|
|
5325
5410
|
complete(
|
|
@@ -5512,11 +5597,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5512
5597
|
intent: Threads.TelegramWorkspaceLiveRebindIntent,
|
|
5513
5598
|
ctx: TContext,
|
|
5514
5599
|
mode: "issue" | "follower-issue",
|
|
5600
|
+
admittedOrigin?: () => boolean,
|
|
5515
5601
|
): Promise<TelegramLiveRebindCleanupIssue | undefined>;
|
|
5516
5602
|
async function inspectLiveRebindRecipient(
|
|
5517
5603
|
intent: Threads.TelegramWorkspaceLiveRebindIntent,
|
|
5518
5604
|
ctx: TContext,
|
|
5519
5605
|
mode: "work" | "cleanup" | "follower-cleanup" | "issue" | "follower-issue",
|
|
5606
|
+
admittedOrigin?: () => boolean,
|
|
5520
5607
|
): Promise<
|
|
5521
5608
|
| Bus.TelegramBusLiveRebindWorkObservation
|
|
5522
5609
|
| ThreadReconciler.TelegramLiveRebindCleanupPreparation
|
|
@@ -5544,8 +5631,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5544
5631
|
!run ||
|
|
5545
5632
|
(issueMode && !callApi) ||
|
|
5546
5633
|
deps.isContextActive?.(ctx) !== true
|
|
5547
|
-
)
|
|
5634
|
+
) {
|
|
5548
5635
|
return undefined;
|
|
5636
|
+
}
|
|
5549
5637
|
try {
|
|
5550
5638
|
const expected = structuredClone(intent),
|
|
5551
5639
|
{ request, recipient, executor } = expected;
|
|
@@ -5651,6 +5739,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5651
5739
|
);
|
|
5652
5740
|
};
|
|
5653
5741
|
const current = () =>
|
|
5742
|
+
(!admittedOrigin || admittedOrigin()) &&
|
|
5654
5743
|
deps.hasWorkspaceLiveRebindAuthority?.() === true &&
|
|
5655
5744
|
deps.isContextActive?.(ctx) === true &&
|
|
5656
5745
|
deps.workspaceRestoreRecipient === ports &&
|
|
@@ -5701,8 +5790,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5701
5790
|
};
|
|
5702
5791
|
const sample = async () => {
|
|
5703
5792
|
assertCanonical();
|
|
5704
|
-
if (cleanupMode
|
|
5705
|
-
|
|
5793
|
+
if (cleanupMode) {
|
|
5794
|
+
const protectedTarget = protection.call(threads, expected);
|
|
5795
|
+
if (protectedTarget !== false) return undefined;
|
|
5796
|
+
}
|
|
5706
5797
|
const target = { ...request.binding.target };
|
|
5707
5798
|
let value: Bus.TelegramBusLiveRebindWorkState | undefined;
|
|
5708
5799
|
if (followerMode) {
|
|
@@ -5711,7 +5802,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5711
5802
|
instanceId: recipient.instanceId,
|
|
5712
5803
|
sessionId: recipient.sessionId,
|
|
5713
5804
|
recipientBindingKey: recipientJournalKey!,
|
|
5714
|
-
mode: "observe",
|
|
5805
|
+
mode: expected.inputRelease === "held" ? "observe-held" : "observe",
|
|
5715
5806
|
sourceUpdateIds: [...request.source.updateIds],
|
|
5716
5807
|
slot: request.binding.slot!,
|
|
5717
5808
|
target: { ...request.target },
|
|
@@ -5766,12 +5857,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5766
5857
|
);
|
|
5767
5858
|
assertCanonical();
|
|
5768
5859
|
if (cleanupMode) {
|
|
5860
|
+
const targetProtected = protection.call(threads, expected);
|
|
5769
5861
|
const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
|
|
5770
5862
|
operationId: request.operationId,
|
|
5771
5863
|
oldTarget: request.binding.target,
|
|
5772
5864
|
recipientTarget: request.target,
|
|
5773
5865
|
leaderEpoch: executor.leaderEpoch,
|
|
5774
|
-
targetProtected
|
|
5866
|
+
targetProtected,
|
|
5775
5867
|
work,
|
|
5776
5868
|
});
|
|
5777
5869
|
assertCanonical();
|
|
@@ -5794,21 +5886,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5794
5886
|
if (!current()) return undefined;
|
|
5795
5887
|
if (issueMode) {
|
|
5796
5888
|
// One admission spans the fresh sample, durable issue marker, single deletion and terminal record.
|
|
5797
|
-
|
|
5798
|
-
{
|
|
5799
|
-
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
5800
|
-
operationKind: "workspace.live-rebind-cleanup",
|
|
5801
|
-
scopes: [{ kind: "profile" }],
|
|
5802
|
-
},
|
|
5803
|
-
async (): Promise<TelegramLiveRebindCleanupIssue | undefined> => {
|
|
5889
|
+
const execute = async (): Promise<TelegramLiveRebindCleanupIssue | undefined> => {
|
|
5804
5890
|
const prepared = await sample();
|
|
5805
5891
|
if (
|
|
5806
5892
|
!prepared ||
|
|
5807
5893
|
prepared.status !== "prepared" ||
|
|
5808
5894
|
!current() ||
|
|
5809
5895
|
protection.call(threads, expected) !== false
|
|
5810
|
-
)
|
|
5896
|
+
) {
|
|
5811
5897
|
return { status: "not-ready" };
|
|
5898
|
+
}
|
|
5812
5899
|
const authority = {
|
|
5813
5900
|
executor: structuredClone(executor),
|
|
5814
5901
|
operatorUserId: expected.operatorUserId,
|
|
@@ -5819,21 +5906,19 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5819
5906
|
"issue-cleanup",
|
|
5820
5907
|
authority,
|
|
5821
5908
|
);
|
|
5822
|
-
if (!grant)
|
|
5909
|
+
if (!grant) {
|
|
5910
|
+
return undefined;
|
|
5911
|
+
}
|
|
5823
5912
|
const assertGranted = () => assertCanonical(grant);
|
|
5824
5913
|
const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(
|
|
5825
5914
|
prepared,
|
|
5826
5915
|
{
|
|
5827
5916
|
assertCurrent: assertGranted,
|
|
5828
|
-
deleteTopic: (target) =>
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
message_thread_id: target.threadId,
|
|
5834
|
-
},
|
|
5835
|
-
{ maxAttempts: 1, assertAuthority: assertGranted },
|
|
5836
|
-
),
|
|
5917
|
+
deleteTopic: (target) => callApi!(
|
|
5918
|
+
"deleteForumTopic",
|
|
5919
|
+
{ chat_id: target.chatId, message_thread_id: target.threadId },
|
|
5920
|
+
{ maxAttempts: 1, assertAuthority: assertGranted },
|
|
5921
|
+
),
|
|
5837
5922
|
classifyFailure: classifyLiveRebindCleanupFailure,
|
|
5838
5923
|
},
|
|
5839
5924
|
);
|
|
@@ -5851,8 +5936,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5851
5936
|
cleanup,
|
|
5852
5937
|
recorded,
|
|
5853
5938
|
};
|
|
5854
|
-
|
|
5855
|
-
|
|
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);
|
|
5856
5945
|
}
|
|
5857
5946
|
const observation = await run(
|
|
5858
5947
|
{
|
|
@@ -5905,8 +5994,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5905
5994
|
string,
|
|
5906
5995
|
ReturnType<typeof setTimeout> | "running"
|
|
5907
5996
|
>();
|
|
5908
|
-
//
|
|
5909
|
-
const liveRebindCleanupWakers = new Map<string, () => void>();
|
|
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
|
+
};
|
|
5910
6013
|
/** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
|
|
5911
6014
|
const terminalizeUnissuedLiveRebindCleanup = async (
|
|
5912
6015
|
operationId: string,
|
|
@@ -5967,25 +6070,48 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5967
6070
|
ctx: TContext,
|
|
5968
6071
|
startedAtMs = Date.now(),
|
|
5969
6072
|
attempt = 0,
|
|
6073
|
+
current = captureLiveRebindCleanupScope(ctx),
|
|
5970
6074
|
): void => {
|
|
5971
6075
|
const pacing = deps.liveRebindCleanupSchedule;
|
|
5972
6076
|
const chain = liveRebindCleanupChains.get(operationId);
|
|
5973
6077
|
if (
|
|
5974
6078
|
!pacing ||
|
|
5975
6079
|
(attempt === 0 ? chain !== undefined : chain !== "running") ||
|
|
5976
|
-
|
|
6080
|
+
!current()
|
|
5977
6081
|
) {
|
|
5978
|
-
if (attempt > 0 && chain === "running")
|
|
6082
|
+
if (attempt > 0 && chain === "running") {
|
|
5979
6083
|
liveRebindCleanupChains.delete(operationId);
|
|
6084
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
6085
|
+
}
|
|
5980
6086
|
return;
|
|
5981
6087
|
}
|
|
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
|
+
};
|
|
5982
6107
|
const attemptNow = () => {
|
|
5983
|
-
liveRebindCleanupWakers.delete(operationId);
|
|
5984
6108
|
if (liveRebindCleanupChains.get(operationId) !== timer) return;
|
|
6109
|
+
pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
|
|
5985
6110
|
liveRebindCleanupChains.set(operationId, "running");
|
|
6111
|
+
if (role === "leader") liveRebindCleanupWakers.set(operationId, wake);
|
|
5986
6112
|
let continued = false;
|
|
5987
6113
|
const task = (async () => {
|
|
5988
|
-
if (
|
|
6114
|
+
if (!current()) return;
|
|
5989
6115
|
const intent = deps
|
|
5990
6116
|
.getWorkspaceRestoreStore?.()
|
|
5991
6117
|
?.listLiveRebindings()
|
|
@@ -5998,11 +6124,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
5998
6124
|
: await issueLiveRebindFollowerCleanup(intent, ctx);
|
|
5999
6125
|
if (
|
|
6000
6126
|
result?.status === "finished" ||
|
|
6001
|
-
|
|
6127
|
+
!current()
|
|
6002
6128
|
)
|
|
6003
6129
|
return;
|
|
6004
|
-
if (Date.now() - startedAtMs >= pacing.windowMs)
|
|
6130
|
+
if (Date.now() - startedAtMs >= pacing.windowMs) {
|
|
6005
6131
|
return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
|
|
6132
|
+
}
|
|
6006
6133
|
continued = true;
|
|
6007
6134
|
scheduleLiveRebindCleanup(
|
|
6008
6135
|
operationId,
|
|
@@ -6010,7 +6137,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6010
6137
|
ctx,
|
|
6011
6138
|
startedAtMs,
|
|
6012
6139
|
attempt + 1,
|
|
6140
|
+
current,
|
|
6013
6141
|
);
|
|
6142
|
+
if (pendingWake) liveRebindCleanupWakers.get(operationId)?.(pendingWake);
|
|
6014
6143
|
})()
|
|
6015
6144
|
.catch((error) =>
|
|
6016
6145
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -6021,8 +6150,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6021
6150
|
if (
|
|
6022
6151
|
!continued &&
|
|
6023
6152
|
liveRebindCleanupChains.get(operationId) === "running"
|
|
6024
|
-
)
|
|
6153
|
+
) {
|
|
6025
6154
|
liveRebindCleanupChains.delete(operationId);
|
|
6155
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
6156
|
+
}
|
|
6026
6157
|
});
|
|
6027
6158
|
restoreSettlementTasks.add(task);
|
|
6028
6159
|
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
@@ -6034,18 +6165,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6034
6165
|
timer.unref?.();
|
|
6035
6166
|
liveRebindCleanupChains.set(operationId, timer);
|
|
6036
6167
|
// The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
|
|
6037
|
-
if (role === "leader" && attempt > 0)
|
|
6038
|
-
liveRebindCleanupWakers.set(operationId, () => {
|
|
6039
|
-
if (liveRebindCleanupChains.get(operationId) !== timer) return;
|
|
6040
|
-
clearTimeout(timer);
|
|
6041
|
-
attemptNow();
|
|
6042
|
-
});
|
|
6168
|
+
if (role === "leader" && attempt > 0) liveRebindCleanupWakers.set(operationId, wake);
|
|
6043
6169
|
};
|
|
6044
|
-
|
|
6045
|
-
const onSessionSettled = (ctx: TContext): void => {
|
|
6170
|
+
const wakeLiveRebindCleanup = (ctx: TContext, reason: "session-settled" | "delivery-drained"): void => {
|
|
6046
6171
|
if (deps.isContextActive?.(ctx) !== true) return;
|
|
6047
|
-
for (const wake of [...liveRebindCleanupWakers.values()]) wake();
|
|
6172
|
+
for (const wake of [...liveRebindCleanupWakers.values()]) wake(reason);
|
|
6048
6173
|
};
|
|
6174
|
+
const onSessionSettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "session-settled");
|
|
6175
|
+
const onDeliverySettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "delivery-drained");
|
|
6049
6176
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
6050
6177
|
? async (input: {
|
|
6051
6178
|
operationId: string;
|
|
@@ -8695,10 +8822,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8695
8822
|
// Keep the captured plan even if the source owner's issued selection result is lost.
|
|
8696
8823
|
pending.liveRebind ??= selection;
|
|
8697
8824
|
if (!(await selectPendingDestination())) return true;
|
|
8825
|
+
const operationId = selection.coordinator.operationId;
|
|
8698
8826
|
if (current()) outcome = await selection.coordinator.advance();
|
|
8699
8827
|
// Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
|
|
8700
8828
|
// still be pending while every attempt samples current work afresh.
|
|
8701
|
-
const operationId = selection.coordinator.operationId;
|
|
8702
8829
|
const row =
|
|
8703
8830
|
outcome === "protected"
|
|
8704
8831
|
? undefined
|
|
@@ -8706,12 +8833,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8706
8833
|
.getWorkspaceRestoreStore?.()
|
|
8707
8834
|
?.listLiveRebindings()
|
|
8708
8835
|
.find((value) => value.request.operationId === operationId);
|
|
8709
|
-
if (row?.phase === "released" && row.cleanup === undefined
|
|
8836
|
+
if (row?.phase === "released" && row.cleanup === undefined &&
|
|
8837
|
+
(row.inputRelease === undefined || row.inputRelease === "accepted")) {
|
|
8710
8838
|
scheduleLiveRebindCleanup(
|
|
8711
8839
|
operationId,
|
|
8712
8840
|
selection.record.owner?.kind === "leader" ? "leader" : "follower",
|
|
8713
8841
|
ctx,
|
|
8714
8842
|
);
|
|
8843
|
+
}
|
|
8715
8844
|
if (outcome === "released")
|
|
8716
8845
|
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
8717
8846
|
} catch (error) {
|
|
@@ -8729,12 +8858,24 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8729
8858
|
);
|
|
8730
8859
|
return true;
|
|
8731
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());
|
|
8732
8870
|
// The tab now belongs to the rebound thread; only the one-shot chooser message goes.
|
|
8733
8871
|
await finalizePendingReroute(
|
|
8734
8872
|
parsed.rerouteId,
|
|
8735
8873
|
pending,
|
|
8736
8874
|
query,
|
|
8737
|
-
|
|
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"
|
|
8738
8879
|
? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
|
|
8739
8880
|
: selection.extensionCommand
|
|
8740
8881
|
? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
|
|
@@ -10288,15 +10429,23 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10288
10429
|
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
10289
10430
|
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
10290
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
|
+
*/
|
|
10291
10436
|
const sendAllTabTemporaryInputChooser = async (
|
|
10292
10437
|
command: Commands.ParsedTelegramCommand | undefined,
|
|
10293
10438
|
commandText: string,
|
|
10294
|
-
|
|
10439
|
+
messages: readonly TMessage[],
|
|
10295
10440
|
ctx: TContext,
|
|
10296
10441
|
): Promise<boolean> => {
|
|
10297
|
-
const
|
|
10298
|
-
|
|
10299
|
-
|
|
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 };
|
|
10300
10449
|
const execution = Updates.getTelegramUpdateExecutionFence(message);
|
|
10301
10450
|
const cap = captureTemporaryThreadAuthority(
|
|
10302
10451
|
ctx,
|
|
@@ -10307,15 +10456,20 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10307
10456
|
!deps.runWorkspaceOperation ||
|
|
10308
10457
|
!deps.callApi ||
|
|
10309
10458
|
updateId === undefined ||
|
|
10310
|
-
|
|
10311
|
-
|
|
10312
|
-
|
|
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
|
+
) ||
|
|
10313
10466
|
!cap.isCurrent()
|
|
10314
10467
|
)
|
|
10315
10468
|
return false;
|
|
10316
10469
|
const { store, operatorUserId, journalBindingKey, epoch, authority } = cap,
|
|
10317
10470
|
current = cap.isCurrent;
|
|
10318
10471
|
const source = { journalBindingKey, updateId };
|
|
10472
|
+
group.journalBindingKey = journalBindingKey;
|
|
10319
10473
|
const find = () =>
|
|
10320
10474
|
store
|
|
10321
10475
|
.listTemporaryThreads()
|
|
@@ -10352,7 +10506,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10352
10506
|
);
|
|
10353
10507
|
if (native) {
|
|
10354
10508
|
const adopted = store.registerImplicitTemporaryThread(
|
|
10355
|
-
|
|
10509
|
+
group,
|
|
10356
10510
|
native.implicit.target,
|
|
10357
10511
|
randomBytes(16).toString("hex"),
|
|
10358
10512
|
{
|
|
@@ -10385,6 +10539,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10385
10539
|
source,
|
|
10386
10540
|
randomBytes(16).toString("hex"),
|
|
10387
10541
|
authority,
|
|
10542
|
+
group,
|
|
10388
10543
|
);
|
|
10389
10544
|
entry = reservation?.entry;
|
|
10390
10545
|
if (!reservation?.reserved || !current()) return;
|
|
@@ -10425,19 +10580,20 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10425
10580
|
throw new Error(
|
|
10426
10581
|
"Temporary Thread authority changed; the input remains retryable.",
|
|
10427
10582
|
);
|
|
10428
|
-
|
|
10429
|
-
|
|
10430
|
-
text
|
|
10431
|
-
|
|
10432
|
-
|
|
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];
|
|
10433
10594
|
if (entry.phase !== "created" || !entry.target) {
|
|
10434
|
-
Updates.reportTelegramUpdateDeferred(
|
|
10435
|
-
|
|
10436
|
-
message.chat.id,
|
|
10437
|
-
message.message_id,
|
|
10438
|
-
"<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.",
|
|
10439
|
-
{ parseMode: "HTML" },
|
|
10440
|
-
);
|
|
10595
|
+
for (const input of held) Updates.reportTelegramUpdateDeferred(input);
|
|
10596
|
+
deps.recordRuntimeEvent?.("routing", new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR), { phase: "temporary-thread-unconfirmed" });
|
|
10441
10597
|
return true;
|
|
10442
10598
|
}
|
|
10443
10599
|
const target = entry.target;
|
|
@@ -10446,23 +10602,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10446
10602
|
deps.getLiveThreadTargets?.(),
|
|
10447
10603
|
);
|
|
10448
10604
|
const rerouteId = storePendingUnboundReroute(
|
|
10449
|
-
|
|
10605
|
+
held,
|
|
10450
10606
|
command ? "command" : "prompt",
|
|
10451
10607
|
target,
|
|
10452
10608
|
);
|
|
10453
10609
|
const pending = pendingUnboundReroutes.get(rerouteId)!;
|
|
10454
10610
|
pending.temporaryThread = entry;
|
|
10455
10611
|
pending.allTabCopies = getTelegramAllTabSourceMessageIds(
|
|
10456
|
-
|
|
10612
|
+
messages,
|
|
10457
10613
|
target.chatId,
|
|
10458
10614
|
);
|
|
10459
|
-
Updates.reportTelegramUpdateDeferred(
|
|
10615
|
+
for (const input of held) Updates.reportTelegramUpdateDeferred(input);
|
|
10460
10616
|
// Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
|
|
10461
10617
|
if (
|
|
10462
10618
|
deps.editInteractiveMessage &&
|
|
10463
|
-
|
|
10464
|
-
|
|
10465
|
-
journalBindingKey,
|
|
10619
|
+
held.every((input) =>
|
|
10620
|
+
Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey),
|
|
10466
10621
|
)
|
|
10467
10622
|
) {
|
|
10468
10623
|
pending.abandonment = {
|
|
@@ -10499,96 +10654,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10499
10654
|
}
|
|
10500
10655
|
return true;
|
|
10501
10656
|
};
|
|
10502
|
-
|
|
10503
|
-
|
|
10504
|
-
|
|
10505
|
-
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
): Promise<boolean> => {
|
|
10511
|
-
if (!deps.threadStore) return false;
|
|
10512
|
-
const records = deps.threadStore.list();
|
|
10513
|
-
const activeRecords = getTelegramRoutableThreadRecords(
|
|
10514
|
-
records,
|
|
10515
|
-
deps.getLiveThreadTargets?.(),
|
|
10516
|
-
);
|
|
10517
|
-
if (activeRecords.length === 0) return false;
|
|
10518
|
-
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
10519
|
-
...message,
|
|
10520
|
-
text: commandText,
|
|
10521
|
-
caption: undefined,
|
|
10522
|
-
} as TMessage);
|
|
10523
|
-
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
10524
|
-
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
10525
|
-
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
10526
|
-
pendingUnboundReroutes.get(rerouteId)!.rootChooserText = text;
|
|
10527
|
-
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
10528
|
-
rerouteId,
|
|
10529
|
-
activeRecords,
|
|
10530
|
-
{
|
|
10531
|
-
canRestore: typeof message.message_thread_id === "number",
|
|
10532
|
-
getDisplayTitle: deps.getDisplayTitle,
|
|
10533
|
-
},
|
|
10534
|
-
);
|
|
10535
|
-
let chooserId: number | undefined;
|
|
10536
|
-
try {
|
|
10537
|
-
if (deps.sendInteractiveMessage) {
|
|
10538
|
-
chooserId = await deps.sendInteractiveMessage(
|
|
10539
|
-
message.chat.id,
|
|
10540
|
-
text,
|
|
10541
|
-
"html",
|
|
10542
|
-
replyMarkup,
|
|
10543
|
-
options.target || options.replyToSource
|
|
10544
|
-
? {
|
|
10545
|
-
...(options.target ? { target: options.target } : {}),
|
|
10546
|
-
...(options.replyToSource
|
|
10547
|
-
? { replyToMessageId: message.message_id }
|
|
10548
|
-
: {}),
|
|
10549
|
-
}
|
|
10550
|
-
: undefined,
|
|
10551
|
-
);
|
|
10552
|
-
} else if (deps.callApi) {
|
|
10553
|
-
const chooser = await deps.callApi<{ message_id?: number }>(
|
|
10554
|
-
"sendMessage",
|
|
10555
|
-
{
|
|
10556
|
-
chat_id: message.chat.id,
|
|
10557
|
-
text,
|
|
10558
|
-
parse_mode: "HTML",
|
|
10559
|
-
reply_markup: replyMarkup,
|
|
10560
|
-
...(typeof options.target?.threadId === "number"
|
|
10561
|
-
? { message_thread_id: options.target.threadId }
|
|
10562
|
-
: {}),
|
|
10563
|
-
...(options.replyToSource
|
|
10564
|
-
? {
|
|
10565
|
-
reply_parameters: {
|
|
10566
|
-
message_id: message.message_id,
|
|
10567
|
-
allow_sending_without_reply: true,
|
|
10568
|
-
},
|
|
10569
|
-
}
|
|
10570
|
-
: {}),
|
|
10571
|
-
},
|
|
10572
|
-
);
|
|
10573
|
-
chooserId = chooser?.message_id;
|
|
10574
|
-
} else {
|
|
10575
|
-
chooserId = await deps.sendTextReply(
|
|
10576
|
-
message.chat.id,
|
|
10577
|
-
message.message_id,
|
|
10578
|
-
text,
|
|
10579
|
-
{
|
|
10580
|
-
parseMode: "HTML",
|
|
10581
|
-
target: options.target,
|
|
10582
|
-
},
|
|
10583
|
-
);
|
|
10584
|
-
}
|
|
10585
|
-
} catch (error) {
|
|
10586
|
-
removePendingReroute(rerouteId);
|
|
10587
|
-
throw error;
|
|
10588
|
-
}
|
|
10589
|
-
rememberRerouteChooser(rerouteId, chooserId);
|
|
10590
|
-
Updates.reportTelegramUpdateCompleted(commandMessage);
|
|
10591
|
-
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);
|
|
10592
10665
|
};
|
|
10593
10666
|
const dispatchCommandOrPrompt =
|
|
10594
10667
|
Commands.createTelegramCommandOrPromptDispatcher<TMessage, TContext>({
|
|
@@ -11164,6 +11237,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11164
11237
|
entry.source.journalBindingKey ===
|
|
11165
11238
|
deps.getAdmissionJournalBinding?.(),
|
|
11166
11239
|
) === true;
|
|
11240
|
+
const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
|
|
11167
11241
|
// Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
|
|
11168
11242
|
if (
|
|
11169
11243
|
command &&
|
|
@@ -11173,30 +11247,48 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11173
11247
|
)
|
|
11174
11248
|
return;
|
|
11175
11249
|
if (
|
|
11176
|
-
|
|
11177
|
-
(bindings.length > 0 || presented) &&
|
|
11250
|
+
needsThread &&
|
|
11178
11251
|
typeof message.text === "string" &&
|
|
11179
11252
|
message.text.trim() &&
|
|
11180
11253
|
(await sendAllTabTemporaryInputChooser(
|
|
11181
11254
|
command,
|
|
11182
11255
|
text,
|
|
11183
|
-
message as TMessage,
|
|
11256
|
+
[message as TMessage],
|
|
11184
11257
|
ctx,
|
|
11185
11258
|
))
|
|
11186
11259
|
)
|
|
11187
11260
|
return;
|
|
11188
|
-
|
|
11189
|
-
|
|
11190
|
-
|
|
11191
|
-
|
|
11192
|
-
|
|
11193
|
-
|
|
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);
|
|
11194
11277
|
return;
|
|
11195
11278
|
}
|
|
11279
|
+
if (
|
|
11280
|
+
await sendAllTabTemporaryInputChooser(
|
|
11281
|
+
undefined,
|
|
11282
|
+
text,
|
|
11283
|
+
[message as TMessage],
|
|
11284
|
+
ctx,
|
|
11285
|
+
)
|
|
11286
|
+
)
|
|
11287
|
+
return;
|
|
11196
11288
|
}
|
|
11197
|
-
if (
|
|
11289
|
+
if (needsThread) {
|
|
11198
11290
|
const probeTarget = bindings[0]?.target;
|
|
11199
|
-
if (probeTarget?.threadId && deps.callApi) {
|
|
11291
|
+
if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
|
|
11200
11292
|
try {
|
|
11201
11293
|
await deps.callApi("sendChatAction", {
|
|
11202
11294
|
chat_id: probeTarget.chatId,
|
|
@@ -11205,8 +11297,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11205
11297
|
});
|
|
11206
11298
|
} catch (error) {
|
|
11207
11299
|
if (
|
|
11208
|
-
Threads.isTelegramTopicModeUnavailableError(error)
|
|
11209
|
-
Threads.isTelegramTopicTargetStaleError(error)
|
|
11300
|
+
Threads.isTelegramTopicModeUnavailableError(error)
|
|
11210
11301
|
) {
|
|
11211
11302
|
deps.threadStore.setBotState({
|
|
11212
11303
|
threadMode: "disabled",
|
|
@@ -11226,12 +11317,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11226
11317
|
});
|
|
11227
11318
|
}
|
|
11228
11319
|
}
|
|
11229
|
-
|
|
11230
|
-
message.chat.id,
|
|
11231
|
-
message.message_id,
|
|
11232
|
-
"This bot is in threaded multi-instance mode. Send prompts in a bound Pi thread tab so they route to the right instance.",
|
|
11233
|
-
);
|
|
11234
|
-
return;
|
|
11320
|
+
throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
|
|
11235
11321
|
}
|
|
11236
11322
|
}
|
|
11237
11323
|
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
@@ -11663,6 +11749,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
11663
11749
|
prepareHeldCommand: commandHandler.prepareHeldCommand,
|
|
11664
11750
|
canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
|
|
11665
11751
|
onSessionSettled,
|
|
11752
|
+
onDeliverySettled,
|
|
11666
11753
|
observeLiveRebindLeaderWork,
|
|
11667
11754
|
prepareLiveRebindLeaderCleanup,
|
|
11668
11755
|
prepareLiveRebindFollowerCleanup,
|