@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/dist/lib/routing.js
CHANGED
|
@@ -210,6 +210,8 @@ function getTelegramAllTabSourceMessageIds(messages, chatId) {
|
|
|
210
210
|
? [message.message_id]
|
|
211
211
|
: []);
|
|
212
212
|
}
|
|
213
|
+
/** Failure retains the source for recovery; Threaded Mode never emits a fallback answer into All. */
|
|
214
|
+
const TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR = "All is input-only in Threaded Mode; a routing Thread could not be prepared.";
|
|
213
215
|
/** What a route chooser moves: `<code>/name</code>` for a command, otherwise "this message". */
|
|
214
216
|
function formatTelegramRouteSubject(command) {
|
|
215
217
|
return command ? `<code>/${escapeHtml(command)}</code>` : "this message";
|
|
@@ -250,13 +252,6 @@ function formatTelegramRoutingInputPreview(message) {
|
|
|
250
252
|
const chars = Array.from(label);
|
|
251
253
|
return (escapeHtml(chars.slice(0, 80).join("")) + (chars.length > 80 ? "…" : ""));
|
|
252
254
|
}
|
|
253
|
-
function formatTelegramAllTabMenuChooserText(command) {
|
|
254
|
-
return [
|
|
255
|
-
`<b>🚦 Route ${formatTelegramRouteSubject(command)}:</b>`,
|
|
256
|
-
"",
|
|
257
|
-
"<i>To restore a Pi instead, send a message in a new tab.</i>",
|
|
258
|
-
].join("\n");
|
|
259
|
-
}
|
|
260
255
|
function buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, options) {
|
|
261
256
|
const activeRecords = records.filter((record) => record.status === "active");
|
|
262
257
|
const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
|
|
@@ -380,11 +375,12 @@ export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
|
|
|
380
375
|
intervalMs: 60_000,
|
|
381
376
|
windowMs: 15 * 60_000,
|
|
382
377
|
});
|
|
383
|
-
/** One warm live attempt with exact
|
|
378
|
+
/** One warm live attempt with independent leader input/cleanup grants and exact peer donor settlement; no startup recovery or legacy Restore ACK. */
|
|
384
379
|
export function createTelegramLiveRebindCoordinator(input) {
|
|
385
380
|
const request = structuredClone(input.request), messages = [...input.messages];
|
|
386
381
|
const executor = structuredClone(input.authority.executor), operatorUserId = input.authority.operatorUserId;
|
|
387
382
|
const leader = input.leader && { ...input.leader }, follower = input.follower, followerRun = follower?.run;
|
|
383
|
+
const beforeCleanup = input.cleanupBeforeRelease;
|
|
388
384
|
const selectedCommand = follower?.selectedCommand && structuredClone(follower.selectedCommand), held = selectedCommand;
|
|
389
385
|
const observedRecipient = input.getRecipient(), recipient = observedRecipient && structuredClone(observedRecipient);
|
|
390
386
|
const sources = messages.map(Updates.inspectTelegramDeferredSource);
|
|
@@ -469,7 +465,7 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
469
465
|
return confirmed && current();
|
|
470
466
|
};
|
|
471
467
|
let preparation;
|
|
472
|
-
let saved = false, commitIssued = false, applyIssued = false, retitleIssued = false, peerReleaseIssued = false, donorSettlementAttempted = false, continuationIssued = false, released = false, advancing = false;
|
|
468
|
+
let saved = false, commitIssued = false, applyIssued = false, retitleIssued = false, peerReleaseIssued = false, donorSettlementAttempted = false, continuationIssued = false, cleanupBeforeReleaseIssued = false, cleanupBeforeReleaseDecided = false, released = false, advancing = false;
|
|
473
469
|
let preparedSource;
|
|
474
470
|
const runFollower = async (mode) => {
|
|
475
471
|
if (!input.follower || !recipient || !current())
|
|
@@ -517,6 +513,18 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
517
513
|
generation: recipient.generation,
|
|
518
514
|
bindingKey: recipient.bindingKey,
|
|
519
515
|
});
|
|
516
|
+
const acceptInput = (expected) => {
|
|
517
|
+
if (!expected.inputRelease || expected.inputRelease === "accepted")
|
|
518
|
+
return expected;
|
|
519
|
+
let accepted;
|
|
520
|
+
try {
|
|
521
|
+
accepted = input.restoreStore.advanceLiveRebind(expected, "accept-input", authority);
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
accepted = retained();
|
|
525
|
+
}
|
|
526
|
+
return accepted?.inputRelease === "accepted" && canonical(accepted) ? accepted : undefined;
|
|
527
|
+
};
|
|
520
528
|
const releaseFollower = async (intent) => {
|
|
521
529
|
if (!held || !peerReleaseIssued) {
|
|
522
530
|
peerReleaseIssued = true;
|
|
@@ -539,6 +547,10 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
539
547
|
!canonical(intent))
|
|
540
548
|
return "unknown";
|
|
541
549
|
}
|
|
550
|
+
const accepted = acceptInput(intent);
|
|
551
|
+
if (!accepted)
|
|
552
|
+
return "unknown";
|
|
553
|
+
intent = accepted;
|
|
542
554
|
donorSettlementAttempted = true;
|
|
543
555
|
if (preparation?.settleTransferred(() => canonical(intent)) !== "settled")
|
|
544
556
|
return "unknown";
|
|
@@ -548,19 +560,16 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
548
560
|
const observeCommandCompletion = () => {
|
|
549
561
|
const intent = retained();
|
|
550
562
|
if (!intent ||
|
|
551
|
-
intent
|
|
552
|
-
intent.cleanup ||
|
|
563
|
+
!Threads.isTelegramLiveRebindInputAuthorized(intent) ||
|
|
553
564
|
!canonical(intent) ||
|
|
554
565
|
!leader?.isApplied(intent))
|
|
555
566
|
return "unknown";
|
|
556
567
|
const completions = messages.map(Updates.inspectTelegramDeferredSourceCompletion);
|
|
557
|
-
|
|
568
|
+
const completed = completions.every((completion, index) => completion && isDeepStrictEqual(completion, sources[index])) &&
|
|
558
569
|
current() &&
|
|
559
570
|
canonical(intent) &&
|
|
560
|
-
leader.isApplied(intent) &&
|
|
561
|
-
|
|
562
|
-
? "released"
|
|
563
|
-
: "unknown";
|
|
571
|
+
leader.isApplied(intent) && current();
|
|
572
|
+
return completed && acceptInput(intent) ? "released" : "unknown";
|
|
564
573
|
};
|
|
565
574
|
return {
|
|
566
575
|
/** Correlation only; a later cleanup attempt rereads and revalidates the canonical row itself. */
|
|
@@ -593,8 +602,7 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
593
602
|
return observeCommandCompletion();
|
|
594
603
|
const intent = retained();
|
|
595
604
|
if (!intent ||
|
|
596
|
-
intent
|
|
597
|
-
intent.cleanup ||
|
|
605
|
+
!Threads.isTelegramLiveRebindInputAuthorized(intent) ||
|
|
598
606
|
!canonical(intent) ||
|
|
599
607
|
!leader?.isApplied(intent) ||
|
|
600
608
|
!leader.observeAdmission?.() ||
|
|
@@ -602,6 +610,8 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
602
610
|
!canonical(intent) ||
|
|
603
611
|
!leader.isApplied(intent))
|
|
604
612
|
return "unknown";
|
|
613
|
+
if (!acceptInput(intent))
|
|
614
|
+
return "unknown";
|
|
605
615
|
released = true;
|
|
606
616
|
return "released";
|
|
607
617
|
}
|
|
@@ -657,22 +667,22 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
657
667
|
instanceId: recipient.instanceId,
|
|
658
668
|
sessionId: recipient.sessionId,
|
|
659
669
|
generation: recipient.generation,
|
|
660
|
-
}, authority);
|
|
670
|
+
}, authority, beforeCleanup ? { holdInput: true } : undefined);
|
|
661
671
|
}
|
|
662
672
|
else if (!commitIssued)
|
|
663
673
|
return "protected";
|
|
664
674
|
if (!intent ||
|
|
665
675
|
!sourceSaved() ||
|
|
666
676
|
!canonical(intent) ||
|
|
667
|
-
intent.phase === "finished" ||
|
|
668
|
-
intent.cleanup)
|
|
677
|
+
(intent.phase === "finished" && !cleanupBeforeReleaseIssued) ||
|
|
678
|
+
(intent.cleanup && intent.inputRelease === undefined))
|
|
669
679
|
return "protected";
|
|
670
680
|
// A released peer may already have completed its sources; only its retained settlement can reobserve that outcome.
|
|
671
681
|
if (recipient.kind === "follower" && peerReleaseIssued)
|
|
672
|
-
return intent
|
|
682
|
+
return Threads.isTelegramLiveRebindInputAuthorized(intent)
|
|
673
683
|
? await releaseFollower(intent)
|
|
674
684
|
: "protected";
|
|
675
|
-
const mode = applyIssued || intent.phase === "released" ? "inspect" : "apply";
|
|
685
|
+
const mode = applyIssued || intent.phase === "released" || intent.phase === "finished" ? "inspect" : "apply";
|
|
676
686
|
applyIssued = true;
|
|
677
687
|
if (recipient.kind === "leader") {
|
|
678
688
|
await leader.apply(structuredClone(intent), mode, current);
|
|
@@ -715,22 +725,55 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
715
725
|
intent = retained();
|
|
716
726
|
}
|
|
717
727
|
if (!intent ||
|
|
718
|
-
intent.phase !== "released" ||
|
|
719
|
-
intent.cleanup ||
|
|
728
|
+
(intent.phase !== "released" && !(intent.phase === "finished" && intent.inputRelease === "held")) ||
|
|
729
|
+
(intent.cleanup && intent.inputRelease === undefined) ||
|
|
720
730
|
!canonical(intent))
|
|
721
731
|
return "protected";
|
|
732
|
+
if (beforeCleanup) {
|
|
733
|
+
if (!cleanupBeforeReleaseIssued) {
|
|
734
|
+
cleanupBeforeReleaseIssued = true;
|
|
735
|
+
cleanupBeforeReleaseDecided = await beforeCleanup(intent, current);
|
|
736
|
+
}
|
|
737
|
+
intent = retained();
|
|
738
|
+
if (!cleanupBeforeReleaseDecided && intent?.phase !== "finished")
|
|
739
|
+
return "unknown";
|
|
740
|
+
if (!intent || !canonical(intent) ||
|
|
741
|
+
(recipient.kind === "leader" && !leader.isApplied(intent)) ||
|
|
742
|
+
intent.inputRelease !== "held" || intent.cleanup === "issued")
|
|
743
|
+
return "unknown";
|
|
744
|
+
let issued;
|
|
745
|
+
try {
|
|
746
|
+
issued = input.restoreStore.advanceLiveRebind(intent, "issue-input", authority);
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
issued = retained();
|
|
750
|
+
}
|
|
751
|
+
if (!issued || issued.inputRelease !== "issued" || !canonical(issued))
|
|
752
|
+
return "unknown";
|
|
753
|
+
intent = issued;
|
|
754
|
+
}
|
|
722
755
|
if (recipient.kind === "leader") {
|
|
723
756
|
const expected = intent;
|
|
724
757
|
if (!preparation?.beginRelease(() => canonical(expected) && leader.isApplied(expected)))
|
|
725
758
|
return "protected";
|
|
726
759
|
continuationIssued = true;
|
|
727
|
-
const canContinue = () =>
|
|
760
|
+
const canContinue = () => {
|
|
761
|
+
const observed = expected.inputRelease ? retained() : expected;
|
|
762
|
+
return current() && !!observed &&
|
|
763
|
+
(!expected.inputRelease || (observed.inputRelease === "issued" || observed.inputRelease === "accepted")) &&
|
|
764
|
+
canonical(observed) && leader.isApplied(observed);
|
|
765
|
+
};
|
|
728
766
|
if (leader.complete) {
|
|
729
767
|
leader.complete([...messages], canContinue);
|
|
768
|
+
// The detached native producer has taken this one input grant; its source ACK remains a separate observation.
|
|
769
|
+
if (!acceptInput(intent))
|
|
770
|
+
return "unknown";
|
|
730
771
|
return observeCommandCompletion();
|
|
731
772
|
}
|
|
732
773
|
if (!(await leader.continue(messages, canContinue)))
|
|
733
774
|
return "unknown";
|
|
775
|
+
if (!acceptInput(intent))
|
|
776
|
+
return "unknown";
|
|
734
777
|
}
|
|
735
778
|
else
|
|
736
779
|
return await releaseFollower(intent);
|
|
@@ -1594,23 +1637,34 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1594
1637
|
* any failure keeps the original where the operator can still read it.
|
|
1595
1638
|
*/
|
|
1596
1639
|
const moveAllTabSourceIntoTab = async (pending, target, isCurrent) => {
|
|
1597
|
-
const
|
|
1598
|
-
if (
|
|
1640
|
+
const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
|
|
1641
|
+
if (messageIds.length === 0 || !deps.callApi || !isCurrent())
|
|
1599
1642
|
return;
|
|
1643
|
+
const route = {
|
|
1644
|
+
chat_id: target.chatId,
|
|
1645
|
+
message_thread_id: target.threadId,
|
|
1646
|
+
from_chat_id: pending.sourceTarget.chatId,
|
|
1647
|
+
disable_notification: true,
|
|
1648
|
+
};
|
|
1600
1649
|
try {
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1650
|
+
// An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
|
|
1651
|
+
const copies = messageIds.length === 1
|
|
1652
|
+
? [
|
|
1653
|
+
await deps.callApi("forwardMessage", {
|
|
1654
|
+
...route,
|
|
1655
|
+
message_id: messageIds[0],
|
|
1656
|
+
}),
|
|
1657
|
+
]
|
|
1658
|
+
: await deps.callApi("forwardMessages", { ...route, message_ids: messageIds });
|
|
1659
|
+
const copyIds = Array.isArray(copies)
|
|
1660
|
+
? copies.map((copy) => copy?.message_id)
|
|
1661
|
+
: [];
|
|
1662
|
+
if (copyIds.length !== messageIds.length ||
|
|
1663
|
+
!copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id)))
|
|
1610
1664
|
return;
|
|
1611
1665
|
pending.allTabCopiesForwarded = true;
|
|
1612
1666
|
await deleteAllTabCopies(pending);
|
|
1613
|
-
return
|
|
1667
|
+
return copyIds[0];
|
|
1614
1668
|
}
|
|
1615
1669
|
catch (error) {
|
|
1616
1670
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -3019,8 +3073,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3019
3073
|
const intent = store
|
|
3020
3074
|
.listLiveRebindings()
|
|
3021
3075
|
.find((value) => value.request.operationId === request.operationId);
|
|
3022
|
-
if (intent
|
|
3023
|
-
!intent.cleanup &&
|
|
3076
|
+
if (intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
|
|
3024
3077
|
isDeepStrictEqual(intent.request, request) &&
|
|
3025
3078
|
intent.operatorUserId === operatorUserId &&
|
|
3026
3079
|
isDeepStrictEqual(intent.executor, {
|
|
@@ -3289,6 +3342,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3289
3342
|
name ||
|
|
3290
3343
|
confirmation ||
|
|
3291
3344
|
extension;
|
|
3345
|
+
const preDispatchCleanup = deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
|
|
3292
3346
|
const createCoordinator = () => createTelegramLiveRebindCoordinator({
|
|
3293
3347
|
request,
|
|
3294
3348
|
messages: selection.messages,
|
|
@@ -3299,21 +3353,56 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3299
3353
|
? async (isCurrent) => {
|
|
3300
3354
|
if (!isCurrent())
|
|
3301
3355
|
return;
|
|
3356
|
+
const expected = threads.getWorkspaceBindingByTarget(target, binding.sessionId);
|
|
3357
|
+
if (!expected || expected.bindingKey !== binding.bindingKey || expected.slot !== binding.slot)
|
|
3358
|
+
throw new Error("Live-rebind title binding is unavailable.");
|
|
3359
|
+
const callApi = deps.callApi;
|
|
3302
3360
|
const slot = binding.slot ?? "";
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3361
|
+
const resolveTitle = () => deps.getDisplayTitle?.(expected.target) ??
|
|
3362
|
+
ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot);
|
|
3363
|
+
const title = resolveTitle();
|
|
3364
|
+
const bindingCurrent = (frame) => isCurrent() && deps.callApi === callApi &&
|
|
3365
|
+
isDeepStrictEqual(threads.getWorkspaceBindingByTarget(frame.target, frame.sessionId), frame);
|
|
3366
|
+
const assertTitle = () => {
|
|
3367
|
+
if (!bindingCurrent(expected) || resolveTitle() !== title)
|
|
3368
|
+
throw new Error("Live-rebind title authority changed.");
|
|
3369
|
+
};
|
|
3370
|
+
assertTitle();
|
|
3371
|
+
const renamed = await callApi.call(deps, "editForumTopic", {
|
|
3372
|
+
chat_id: expected.target.chatId,
|
|
3373
|
+
message_thread_id: expected.target.threadId,
|
|
3374
|
+
name: title,
|
|
3375
|
+
}, { maxAttempts: 1, assertAuthority: assertTitle });
|
|
3376
|
+
assertTitle();
|
|
3377
|
+
if (renamed !== true || !threads.setWorkspaceDisplayTitle(expected, title))
|
|
3378
|
+
throw new Error("Live-rebind title acknowledgement is unconfirmed.");
|
|
3379
|
+
const acknowledged = { ...expected, displayTitle: title };
|
|
3380
|
+
await threads.persist(() => bindingCurrent(acknowledged));
|
|
3381
|
+
if (!bindingCurrent(acknowledged))
|
|
3382
|
+
throw new Error("Live-rebind title publication authority changed.");
|
|
3309
3383
|
}
|
|
3310
3384
|
: undefined,
|
|
3311
3385
|
authority: {
|
|
3312
3386
|
executor: { instanceId, leaderEpoch: String(epoch) },
|
|
3313
3387
|
operatorUserId,
|
|
3314
|
-
isCurrent: current,
|
|
3388
|
+
isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
|
|
3315
3389
|
},
|
|
3316
3390
|
getRecipient,
|
|
3391
|
+
...(preDispatchCleanup ? {
|
|
3392
|
+
async cleanupBeforeRelease(intent, isCurrent) {
|
|
3393
|
+
const delay = deps.liveRebindCleanupSchedule?.delaysMs[0] ?? 1_000;
|
|
3394
|
+
await new Promise(resolve => { const timer = setTimeout(resolve, delay); timer.unref?.(); });
|
|
3395
|
+
if (!isCurrent())
|
|
3396
|
+
return false;
|
|
3397
|
+
const result = await inspectLiveRebindRecipient(intent, ctx, isLeader ? "issue" : "follower-issue", isCurrent);
|
|
3398
|
+
if (!isCurrent())
|
|
3399
|
+
return false;
|
|
3400
|
+
// A session-replacing producer cannot retain an old-session cleanup grant across its gateway.
|
|
3401
|
+
if (result?.status === "not-ready" && selection.confirmationCommand?.name === "new")
|
|
3402
|
+
return !!store.advanceLiveRebind(intent, "protect-old-target", { executor: intent.executor, operatorUserId, isCurrent });
|
|
3403
|
+
return result?.status === "not-ready" || result?.status === "finished" && result.recorded;
|
|
3404
|
+
},
|
|
3405
|
+
} : {}),
|
|
3317
3406
|
follower: isLeader
|
|
3318
3407
|
? undefined
|
|
3319
3408
|
: {
|
|
@@ -3344,9 +3433,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3344
3433
|
instanceId,
|
|
3345
3434
|
leaderEpoch: String(epoch),
|
|
3346
3435
|
}) &&
|
|
3347
|
-
!intent.cleanup &&
|
|
3348
|
-
(intent.phase === "rebound" ||
|
|
3349
|
-
intent.phase === "
|
|
3436
|
+
(!intent.cleanup || intent.inputRelease !== undefined && intent.cleanup !== "issued") &&
|
|
3437
|
+
(intent.phase === "rebound" || intent.phase === "released" ||
|
|
3438
|
+
intent.phase === "finished" && intent.inputRelease !== undefined) &&
|
|
3350
3439
|
isDeepStrictEqual(intent.recipient, {
|
|
3351
3440
|
kind: recipient.kind,
|
|
3352
3441
|
instanceId: recipient.instanceId,
|
|
@@ -3520,7 +3609,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3520
3609
|
},
|
|
3521
3610
|
};
|
|
3522
3611
|
};
|
|
3523
|
-
async function inspectLiveRebindRecipient(intent, ctx, mode) {
|
|
3612
|
+
async function inspectLiveRebindRecipient(intent, ctx, mode, admittedOrigin) {
|
|
3524
3613
|
const ports = deps.workspaceRestoreRecipient, threads = deps.threadStore, store = deps.getWorkspaceRestoreStore?.();
|
|
3525
3614
|
const followerMode = mode === "follower-cleanup" || mode === "follower-issue", cleanupMode = mode !== "work";
|
|
3526
3615
|
const issueMode = mode === "issue" || mode === "follower-issue", callApi = deps.callApi;
|
|
@@ -3532,8 +3621,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3532
3621
|
(followerMode ? !peer || !peerRun || !peerJournal : !collector) ||
|
|
3533
3622
|
!run ||
|
|
3534
3623
|
(issueMode && !callApi) ||
|
|
3535
|
-
deps.isContextActive?.(ctx) !== true)
|
|
3624
|
+
deps.isContextActive?.(ctx) !== true) {
|
|
3536
3625
|
return undefined;
|
|
3626
|
+
}
|
|
3537
3627
|
try {
|
|
3538
3628
|
const expected = structuredClone(intent), { request, recipient, executor } = expected;
|
|
3539
3629
|
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), generation = deps.getSessionGeneration?.();
|
|
@@ -3623,7 +3713,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3623
3713
|
peerJournal.call(peer, live) === recipientJournalKey &&
|
|
3624
3714
|
isDeepStrictEqual(findReference(), reference));
|
|
3625
3715
|
};
|
|
3626
|
-
const current = () =>
|
|
3716
|
+
const current = () => (!admittedOrigin || admittedOrigin()) &&
|
|
3717
|
+
deps.hasWorkspaceLiveRebindAuthority?.() === true &&
|
|
3627
3718
|
deps.isContextActive?.(ctx) === true &&
|
|
3628
3719
|
deps.workspaceRestoreRecipient === ports &&
|
|
3629
3720
|
(followerMode || ports.observeLeaderWork === collector) &&
|
|
@@ -3668,8 +3759,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3668
3759
|
};
|
|
3669
3760
|
const sample = async () => {
|
|
3670
3761
|
assertCanonical();
|
|
3671
|
-
if (cleanupMode
|
|
3672
|
-
|
|
3762
|
+
if (cleanupMode) {
|
|
3763
|
+
const protectedTarget = protection.call(threads, expected);
|
|
3764
|
+
if (protectedTarget !== false)
|
|
3765
|
+
return undefined;
|
|
3766
|
+
}
|
|
3673
3767
|
const target = { ...request.binding.target };
|
|
3674
3768
|
let value;
|
|
3675
3769
|
if (followerMode) {
|
|
@@ -3678,7 +3772,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3678
3772
|
instanceId: recipient.instanceId,
|
|
3679
3773
|
sessionId: recipient.sessionId,
|
|
3680
3774
|
recipientBindingKey: recipientJournalKey,
|
|
3681
|
-
mode: "observe",
|
|
3775
|
+
mode: expected.inputRelease === "held" ? "observe-held" : "observe",
|
|
3682
3776
|
sourceUpdateIds: [...request.source.updateIds],
|
|
3683
3777
|
slot: request.binding.slot,
|
|
3684
3778
|
target: { ...request.target },
|
|
@@ -3720,12 +3814,13 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3720
3814
|
throw new Error("Live-rebind work sample is unavailable or changed target.");
|
|
3721
3815
|
assertCanonical();
|
|
3722
3816
|
if (cleanupMode) {
|
|
3817
|
+
const targetProtected = protection.call(threads, expected);
|
|
3723
3818
|
const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
|
|
3724
3819
|
operationId: request.operationId,
|
|
3725
3820
|
oldTarget: request.binding.target,
|
|
3726
3821
|
recipientTarget: request.target,
|
|
3727
3822
|
leaderEpoch: executor.leaderEpoch,
|
|
3728
|
-
targetProtected
|
|
3823
|
+
targetProtected,
|
|
3729
3824
|
work,
|
|
3730
3825
|
});
|
|
3731
3826
|
assertCanonical();
|
|
@@ -3749,32 +3844,27 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3749
3844
|
return undefined;
|
|
3750
3845
|
if (issueMode) {
|
|
3751
3846
|
// One admission spans the fresh sample, durable issue marker, single deletion and terminal record.
|
|
3752
|
-
|
|
3753
|
-
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
3754
|
-
operationKind: "workspace.live-rebind-cleanup",
|
|
3755
|
-
scopes: [{ kind: "profile" }],
|
|
3756
|
-
}, async () => {
|
|
3847
|
+
const execute = async () => {
|
|
3757
3848
|
const prepared = await sample();
|
|
3758
3849
|
if (!prepared ||
|
|
3759
3850
|
prepared.status !== "prepared" ||
|
|
3760
3851
|
!current() ||
|
|
3761
|
-
protection.call(threads, expected) !== false)
|
|
3852
|
+
protection.call(threads, expected) !== false) {
|
|
3762
3853
|
return { status: "not-ready" };
|
|
3854
|
+
}
|
|
3763
3855
|
const authority = {
|
|
3764
3856
|
executor: structuredClone(executor),
|
|
3765
3857
|
operatorUserId: expected.operatorUserId,
|
|
3766
3858
|
isCurrent: current,
|
|
3767
3859
|
};
|
|
3768
3860
|
const grant = store.advanceLiveRebind(expected, "issue-cleanup", authority);
|
|
3769
|
-
if (!grant)
|
|
3861
|
+
if (!grant) {
|
|
3770
3862
|
return undefined;
|
|
3863
|
+
}
|
|
3771
3864
|
const assertGranted = () => assertCanonical(grant);
|
|
3772
3865
|
const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(prepared, {
|
|
3773
3866
|
assertCurrent: assertGranted,
|
|
3774
|
-
deleteTopic: (target) => callApi("deleteForumTopic", {
|
|
3775
|
-
chat_id: target.chatId,
|
|
3776
|
-
message_thread_id: target.threadId,
|
|
3777
|
-
}, { maxAttempts: 1, assertAuthority: assertGranted }),
|
|
3867
|
+
deleteTopic: (target) => callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, assertAuthority: assertGranted }),
|
|
3778
3868
|
classifyFailure: classifyLiveRebindCleanupFailure,
|
|
3779
3869
|
});
|
|
3780
3870
|
let recorded = false;
|
|
@@ -3792,7 +3882,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3792
3882
|
cleanup,
|
|
3793
3883
|
recorded,
|
|
3794
3884
|
};
|
|
3795
|
-
}
|
|
3885
|
+
};
|
|
3886
|
+
// Pre-dispatch calls are private to the chooser callback already holding the same profile admission/gate.
|
|
3887
|
+
return admittedOrigin ? await execute() : await run({
|
|
3888
|
+
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
3889
|
+
operationKind: "workspace.live-rebind-cleanup", scopes: [{ kind: "profile" }],
|
|
3890
|
+
}, execute);
|
|
3796
3891
|
}
|
|
3797
3892
|
const observation = await run({
|
|
3798
3893
|
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
@@ -3826,8 +3921,26 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3826
3921
|
const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
|
|
3827
3922
|
// One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
|
|
3828
3923
|
const liveRebindCleanupChains = new Map();
|
|
3829
|
-
//
|
|
3924
|
+
// One hint slot per existing leader chain; a running attempt retains at most one coalesced recheck.
|
|
3830
3925
|
const liveRebindCleanupWakers = new Map();
|
|
3926
|
+
const captureLiveRebindCleanupScope = (ctx) => {
|
|
3927
|
+
try {
|
|
3928
|
+
const generation = deps.getSessionGeneration?.(), instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), store = deps.getWorkspaceRestoreStore?.(), operator = deps.configStore.getAllowedUserId();
|
|
3929
|
+
return () => {
|
|
3930
|
+
try {
|
|
3931
|
+
return deps.isContextActive?.(ctx) === true && deps.getSessionGeneration?.() === generation &&
|
|
3932
|
+
deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
|
|
3933
|
+
deps.getWorkspaceRestoreStore?.() === store && deps.configStore.getAllowedUserId() === operator;
|
|
3934
|
+
}
|
|
3935
|
+
catch {
|
|
3936
|
+
return false;
|
|
3937
|
+
}
|
|
3938
|
+
};
|
|
3939
|
+
}
|
|
3940
|
+
catch {
|
|
3941
|
+
return () => false;
|
|
3942
|
+
}
|
|
3943
|
+
};
|
|
3831
3944
|
/** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
|
|
3832
3945
|
const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
|
|
3833
3946
|
const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
|
|
@@ -3868,24 +3981,53 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3868
3981
|
* a finished outcome or an inactive session stops, and the window ends in `not-issued` so no released row becomes a
|
|
3869
3982
|
* permanent rebinding blocker. Waiting timers never join settlement; only a running attempt does.
|
|
3870
3983
|
*/
|
|
3871
|
-
const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0) => {
|
|
3984
|
+
const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0, current = captureLiveRebindCleanupScope(ctx)) => {
|
|
3872
3985
|
const pacing = deps.liveRebindCleanupSchedule;
|
|
3873
3986
|
const chain = liveRebindCleanupChains.get(operationId);
|
|
3874
3987
|
if (!pacing ||
|
|
3875
3988
|
(attempt === 0 ? chain !== undefined : chain !== "running") ||
|
|
3876
|
-
|
|
3877
|
-
if (attempt > 0 && chain === "running")
|
|
3989
|
+
!current()) {
|
|
3990
|
+
if (attempt > 0 && chain === "running") {
|
|
3878
3991
|
liveRebindCleanupChains.delete(operationId);
|
|
3992
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
3993
|
+
}
|
|
3879
3994
|
return;
|
|
3880
3995
|
}
|
|
3996
|
+
let pendingWake;
|
|
3997
|
+
const wake = (reason) => {
|
|
3998
|
+
if (!current())
|
|
3999
|
+
return;
|
|
4000
|
+
if (liveRebindCleanupChains.get(operationId) === "running") {
|
|
4001
|
+
pendingWake = reason;
|
|
4002
|
+
return;
|
|
4003
|
+
}
|
|
4004
|
+
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
4005
|
+
return;
|
|
4006
|
+
if (reason === "delivery-drained") {
|
|
4007
|
+
let idle;
|
|
4008
|
+
try {
|
|
4009
|
+
idle = deps.isIdle?.(ctx);
|
|
4010
|
+
}
|
|
4011
|
+
catch { /* A missing idle observation is not a reason to hurry cleanup. */ }
|
|
4012
|
+
if (idle !== true) {
|
|
4013
|
+
// A streamed technical task can drain between deltas while Pi still runs. Keep one hint, not a polling loop.
|
|
4014
|
+
pendingWake = reason;
|
|
4015
|
+
return;
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
clearTimeout(timer);
|
|
4019
|
+
attemptNow();
|
|
4020
|
+
};
|
|
3881
4021
|
const attemptNow = () => {
|
|
3882
|
-
liveRebindCleanupWakers.delete(operationId);
|
|
3883
4022
|
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
3884
4023
|
return;
|
|
4024
|
+
pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
|
|
3885
4025
|
liveRebindCleanupChains.set(operationId, "running");
|
|
4026
|
+
if (role === "leader")
|
|
4027
|
+
liveRebindCleanupWakers.set(operationId, wake);
|
|
3886
4028
|
let continued = false;
|
|
3887
4029
|
const task = (async () => {
|
|
3888
|
-
if (
|
|
4030
|
+
if (!current())
|
|
3889
4031
|
return;
|
|
3890
4032
|
const intent = deps
|
|
3891
4033
|
.getWorkspaceRestoreStore?.()
|
|
@@ -3897,20 +4039,25 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3897
4039
|
? await issueLiveRebindLeaderCleanup(intent, ctx)
|
|
3898
4040
|
: await issueLiveRebindFollowerCleanup(intent, ctx);
|
|
3899
4041
|
if (result?.status === "finished" ||
|
|
3900
|
-
|
|
4042
|
+
!current())
|
|
3901
4043
|
return;
|
|
3902
|
-
if (Date.now() - startedAtMs >= pacing.windowMs)
|
|
4044
|
+
if (Date.now() - startedAtMs >= pacing.windowMs) {
|
|
3903
4045
|
return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
|
|
4046
|
+
}
|
|
3904
4047
|
continued = true;
|
|
3905
|
-
scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1);
|
|
4048
|
+
scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1, current);
|
|
4049
|
+
if (pendingWake)
|
|
4050
|
+
liveRebindCleanupWakers.get(operationId)?.(pendingWake);
|
|
3906
4051
|
})()
|
|
3907
4052
|
.catch((error) => deps.recordRuntimeEvent?.("routing", error, {
|
|
3908
4053
|
phase: "live-rebind-cleanup-schedule",
|
|
3909
4054
|
}))
|
|
3910
4055
|
.finally(() => {
|
|
3911
4056
|
if (!continued &&
|
|
3912
|
-
liveRebindCleanupChains.get(operationId) === "running")
|
|
4057
|
+
liveRebindCleanupChains.get(operationId) === "running") {
|
|
3913
4058
|
liveRebindCleanupChains.delete(operationId);
|
|
4059
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
4060
|
+
}
|
|
3914
4061
|
});
|
|
3915
4062
|
restoreSettlementTasks.add(task);
|
|
3916
4063
|
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
@@ -3920,20 +4067,16 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3920
4067
|
liveRebindCleanupChains.set(operationId, timer);
|
|
3921
4068
|
// The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
|
|
3922
4069
|
if (role === "leader" && attempt > 0)
|
|
3923
|
-
liveRebindCleanupWakers.set(operationId,
|
|
3924
|
-
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
3925
|
-
return;
|
|
3926
|
-
clearTimeout(timer);
|
|
3927
|
-
attemptNow();
|
|
3928
|
-
});
|
|
4070
|
+
liveRebindCleanupWakers.set(operationId, wake);
|
|
3929
4071
|
};
|
|
3930
|
-
|
|
3931
|
-
const onSessionSettled = (ctx) => {
|
|
4072
|
+
const wakeLiveRebindCleanup = (ctx, reason) => {
|
|
3932
4073
|
if (deps.isContextActive?.(ctx) !== true)
|
|
3933
4074
|
return;
|
|
3934
4075
|
for (const wake of [...liveRebindCleanupWakers.values()])
|
|
3935
|
-
wake();
|
|
4076
|
+
wake(reason);
|
|
3936
4077
|
};
|
|
4078
|
+
const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
|
|
4079
|
+
const onDeliverySettled = (ctx) => wakeLiveRebindCleanup(ctx, "delivery-drained");
|
|
3937
4080
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
3938
4081
|
? async (input) => {
|
|
3939
4082
|
const ports = deps.workspaceRestoreRecipient;
|
|
@@ -5837,19 +5980,21 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5837
5980
|
pending.liveRebind ??= selection;
|
|
5838
5981
|
if (!(await selectPendingDestination()))
|
|
5839
5982
|
return true;
|
|
5983
|
+
const operationId = selection.coordinator.operationId;
|
|
5840
5984
|
if (current())
|
|
5841
5985
|
outcome = await selection.coordinator.advance();
|
|
5842
5986
|
// Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
|
|
5843
5987
|
// still be pending while every attempt samples current work afresh.
|
|
5844
|
-
const operationId = selection.coordinator.operationId;
|
|
5845
5988
|
const row = outcome === "protected"
|
|
5846
5989
|
? undefined
|
|
5847
5990
|
: deps
|
|
5848
5991
|
.getWorkspaceRestoreStore?.()
|
|
5849
5992
|
?.listLiveRebindings()
|
|
5850
5993
|
.find((value) => value.request.operationId === operationId);
|
|
5851
|
-
if (row?.phase === "released" && row.cleanup === undefined
|
|
5994
|
+
if (row?.phase === "released" && row.cleanup === undefined &&
|
|
5995
|
+
(row.inputRelease === undefined || row.inputRelease === "accepted")) {
|
|
5852
5996
|
scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
|
|
5997
|
+
}
|
|
5853
5998
|
if (outcome === "released")
|
|
5854
5999
|
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5855
6000
|
}
|
|
@@ -5866,28 +6011,40 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5866
6011
|
await deps.answerCallbackQuery(query.id, "Live rebind is unconfirmed; this choice never replays issued input");
|
|
5867
6012
|
return true;
|
|
5868
6013
|
}
|
|
6014
|
+
const cleanupRow = deps.getWorkspaceRestoreStore?.()?.listLiveRebindings().find(value => value.request.operationId === selection.coordinator?.operationId);
|
|
6015
|
+
const cleanupResult = cleanupRow?.inputRelease && cleanupRow.cleanup !== undefined
|
|
6016
|
+
? cleanupRow.cleanup === "confirmed" ? "old-Thread removal confirmed"
|
|
6017
|
+
: cleanupRow.cleanup === "unknown" || cleanupRow.cleanup === "issued" ? "old-Thread removal unconfirmed; deletion will not repeat"
|
|
6018
|
+
: cleanupRow.cleanup === "failed" ? "old-Thread removal failed" : "old-Thread removal was not issued"
|
|
6019
|
+
: undefined;
|
|
6020
|
+
const commandDisposed = !!(selection.extensionCommand && selection.extensionKind !== "generated-prompt" || selection.nameCommand ||
|
|
6021
|
+
selection.confirmationCommand || selection.helpCommand || selection.stopCommand || selection.nextCommand ||
|
|
6022
|
+
selection.menuCommand || selection.abortCommand || selection.coordinator?.selectedCommandReference());
|
|
5869
6023
|
// The tab now belongs to the rebound thread; only the one-shot chooser message goes.
|
|
5870
|
-
await finalizePendingReroute(parsed.rerouteId, pending, query,
|
|
5871
|
-
?
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
6024
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, cleanupResult
|
|
6025
|
+
? commandDisposed ? `Command source disposal confirmed; ${cleanupResult}; delivery and follow-on effects remain independent`
|
|
6026
|
+
: `Input queued in the rebound Thread; ${cleanupResult}`
|
|
6027
|
+
: selection.extensionKind === "generated-prompt"
|
|
6028
|
+
? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
|
|
6029
|
+
: selection.extensionCommand
|
|
6030
|
+
? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
|
|
6031
|
+
: selection.nameCommand
|
|
6032
|
+
? selection.nameCommand.args.trim()
|
|
6033
|
+
? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
|
|
6034
|
+
: "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
|
|
6035
|
+
: selection.confirmationCommand
|
|
6036
|
+
? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
|
|
6037
|
+
: selection.helpCommand
|
|
6038
|
+
? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
|
|
6039
|
+
: selection.stopCommand
|
|
6040
|
+
? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
|
|
6041
|
+
: selection.nextCommand
|
|
6042
|
+
? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
|
|
6043
|
+
: selection.menuCommand || selection.abortCommand
|
|
6044
|
+
? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
|
|
6045
|
+
: selection.coordinator?.selectedCommandReference()
|
|
6046
|
+
? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
|
|
6047
|
+
: "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
|
|
5891
6048
|
return true;
|
|
5892
6049
|
}
|
|
5893
6050
|
if (!(await selectPendingDestination()))
|
|
@@ -7023,23 +7180,34 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7023
7180
|
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
7024
7181
|
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
7025
7182
|
*/
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7183
|
+
/**
|
|
7184
|
+
* Opens (or adopts) the routing tab for input sent from All. One text message may carry a command; anything else,
|
|
7185
|
+
* a voice note, a file or a coalesced album, is held untouched as one prompt.
|
|
7186
|
+
*/
|
|
7187
|
+
const sendAllTabTemporaryInputChooser = async (command, commandText, messages, ctx) => {
|
|
7188
|
+
const message = messages[0];
|
|
7189
|
+
if (!message)
|
|
7190
|
+
return false;
|
|
7191
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds([
|
|
7192
|
+
...messages,
|
|
7193
|
+
]).sort((a, b) => a - b);
|
|
7194
|
+
const updateId = updateIds[0];
|
|
7195
|
+
const group = { journalBindingKey: "", updateIds };
|
|
7030
7196
|
const execution = Updates.getTelegramUpdateExecutionFence(message);
|
|
7031
7197
|
const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
|
|
7032
7198
|
if (!cap ||
|
|
7033
7199
|
!deps.runWorkspaceOperation ||
|
|
7034
7200
|
!deps.callApi ||
|
|
7035
7201
|
updateId === undefined ||
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7202
|
+
updateIds.length !== messages.length ||
|
|
7203
|
+
messages.some((held) => held.chat.type !== "private" ||
|
|
7204
|
+
held.chat.id !== cap.operatorUserId ||
|
|
7205
|
+
held.from?.id !== cap.operatorUserId) ||
|
|
7039
7206
|
!cap.isCurrent())
|
|
7040
7207
|
return false;
|
|
7041
7208
|
const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
|
|
7042
7209
|
const source = { journalBindingKey, updateId };
|
|
7210
|
+
group.journalBindingKey = journalBindingKey;
|
|
7043
7211
|
const find = () => store
|
|
7044
7212
|
.listTemporaryThreads()
|
|
7045
7213
|
.find((entry) => entry.source.journalBindingKey === journalBindingKey &&
|
|
@@ -7065,7 +7233,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7065
7233
|
// the input itself to All: adopt that tab rather than leaving it behind next to a second one.
|
|
7066
7234
|
const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
|
|
7067
7235
|
if (native) {
|
|
7068
|
-
const adopted = store.registerImplicitTemporaryThread(
|
|
7236
|
+
const adopted = store.registerImplicitTemporaryThread(group, native.implicit.target, randomBytes(16).toString("hex"), {
|
|
7069
7237
|
...authority,
|
|
7070
7238
|
isCurrent: () => current() &&
|
|
7071
7239
|
implicitThreadCreations.get(native.key) === native.implicit,
|
|
@@ -7090,7 +7258,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7090
7258
|
return;
|
|
7091
7259
|
}
|
|
7092
7260
|
}
|
|
7093
|
-
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
|
|
7261
|
+
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority, group);
|
|
7094
7262
|
entry = reservation?.entry;
|
|
7095
7263
|
if (!reservation?.reserved || !current())
|
|
7096
7264
|
return;
|
|
@@ -7120,26 +7288,33 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7120
7288
|
});
|
|
7121
7289
|
if (!current() || !entry)
|
|
7122
7290
|
throw new Error("Temporary Thread authority changed; the input remains retryable.");
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7291
|
+
// Only a lone text message is rewritten for command detection; media keeps its caption and payload.
|
|
7292
|
+
const held = messages.length === 1 && typeof message.text === "string"
|
|
7293
|
+
? [
|
|
7294
|
+
Updates.carryTelegramUpdateExecutionFence(message, {
|
|
7295
|
+
...message,
|
|
7296
|
+
text: commandText,
|
|
7297
|
+
caption: undefined,
|
|
7298
|
+
}),
|
|
7299
|
+
]
|
|
7300
|
+
: [...messages];
|
|
7128
7301
|
if (entry.phase !== "created" || !entry.target) {
|
|
7129
|
-
|
|
7130
|
-
|
|
7302
|
+
for (const input of held)
|
|
7303
|
+
Updates.reportTelegramUpdateDeferred(input);
|
|
7304
|
+
deps.recordRuntimeEvent?.("routing", new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR), { phase: "temporary-thread-unconfirmed" });
|
|
7131
7305
|
return true;
|
|
7132
7306
|
}
|
|
7133
7307
|
const target = entry.target;
|
|
7134
7308
|
const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
|
|
7135
|
-
const rerouteId = storePendingUnboundReroute(
|
|
7309
|
+
const rerouteId = storePendingUnboundReroute(held, command ? "command" : "prompt", target);
|
|
7136
7310
|
const pending = pendingUnboundReroutes.get(rerouteId);
|
|
7137
7311
|
pending.temporaryThread = entry;
|
|
7138
|
-
pending.allTabCopies = getTelegramAllTabSourceMessageIds(
|
|
7139
|
-
|
|
7312
|
+
pending.allTabCopies = getTelegramAllTabSourceMessageIds(messages, target.chatId);
|
|
7313
|
+
for (const input of held)
|
|
7314
|
+
Updates.reportTelegramUpdateDeferred(input);
|
|
7140
7315
|
// Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
|
|
7141
7316
|
if (deps.editInteractiveMessage &&
|
|
7142
|
-
Updates.supportsTelegramDeferredAbandonment(
|
|
7317
|
+
held.every((input) => Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey))) {
|
|
7143
7318
|
pending.abandonment = {
|
|
7144
7319
|
ownerUserId: operatorUserId,
|
|
7145
7320
|
journalBindingKey,
|
|
@@ -7168,72 +7343,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7168
7343
|
}
|
|
7169
7344
|
return true;
|
|
7170
7345
|
};
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
if (activeRecords.length === 0)
|
|
7177
|
-
return false;
|
|
7178
|
-
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
7179
|
-
...message,
|
|
7180
|
-
text: commandText,
|
|
7181
|
-
caption: undefined,
|
|
7182
|
-
});
|
|
7183
|
-
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
7184
|
-
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
7185
|
-
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
7186
|
-
pendingUnboundReroutes.get(rerouteId).rootChooserText = text;
|
|
7187
|
-
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, {
|
|
7188
|
-
canRestore: typeof message.message_thread_id === "number",
|
|
7189
|
-
getDisplayTitle: deps.getDisplayTitle,
|
|
7190
|
-
});
|
|
7191
|
-
let chooserId;
|
|
7192
|
-
try {
|
|
7193
|
-
if (deps.sendInteractiveMessage) {
|
|
7194
|
-
chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, options.target || options.replyToSource
|
|
7195
|
-
? {
|
|
7196
|
-
...(options.target ? { target: options.target } : {}),
|
|
7197
|
-
...(options.replyToSource
|
|
7198
|
-
? { replyToMessageId: message.message_id }
|
|
7199
|
-
: {}),
|
|
7200
|
-
}
|
|
7201
|
-
: undefined);
|
|
7202
|
-
}
|
|
7203
|
-
else if (deps.callApi) {
|
|
7204
|
-
const chooser = await deps.callApi("sendMessage", {
|
|
7205
|
-
chat_id: message.chat.id,
|
|
7206
|
-
text,
|
|
7207
|
-
parse_mode: "HTML",
|
|
7208
|
-
reply_markup: replyMarkup,
|
|
7209
|
-
...(typeof options.target?.threadId === "number"
|
|
7210
|
-
? { message_thread_id: options.target.threadId }
|
|
7211
|
-
: {}),
|
|
7212
|
-
...(options.replyToSource
|
|
7213
|
-
? {
|
|
7214
|
-
reply_parameters: {
|
|
7215
|
-
message_id: message.message_id,
|
|
7216
|
-
allow_sending_without_reply: true,
|
|
7217
|
-
},
|
|
7218
|
-
}
|
|
7219
|
-
: {}),
|
|
7220
|
-
});
|
|
7221
|
-
chooserId = chooser?.message_id;
|
|
7222
|
-
}
|
|
7223
|
-
else {
|
|
7224
|
-
chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
7225
|
-
parseMode: "HTML",
|
|
7226
|
-
target: options.target,
|
|
7227
|
-
});
|
|
7228
|
-
}
|
|
7229
|
-
}
|
|
7230
|
-
catch (error) {
|
|
7231
|
-
removePendingReroute(rerouteId);
|
|
7232
|
-
throw error;
|
|
7233
|
-
}
|
|
7234
|
-
rememberRerouteChooser(rerouteId, chooserId);
|
|
7235
|
-
Updates.reportTelegramUpdateCompleted(commandMessage);
|
|
7236
|
-
return true;
|
|
7346
|
+
/** A coalesced album from All requires one routing tab; failure cannot answer in All or discard its source. */
|
|
7347
|
+
const routeAllTabAlbum = async (messages, ctx) => {
|
|
7348
|
+
if (await sendAllTabTemporaryInputChooser(undefined, "", messages, ctx))
|
|
7349
|
+
return;
|
|
7350
|
+
throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
|
|
7237
7351
|
};
|
|
7238
7352
|
const dispatchCommandOrPrompt = Commands.createTelegramCommandOrPromptDispatcher({
|
|
7239
7353
|
extractRawText: Media.extractFirstTelegramMessageText,
|
|
@@ -7680,28 +7794,39 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7680
7794
|
.some((entry) => entry.source.updateId === sourceUpdateId &&
|
|
7681
7795
|
entry.source.journalBindingKey ===
|
|
7682
7796
|
deps.getAdmissionJournalBinding?.()) === true;
|
|
7797
|
+
const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
|
|
7683
7798
|
// Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
|
|
7684
7799
|
if (command &&
|
|
7685
7800
|
command.name !== "thread" &&
|
|
7686
7801
|
!presented &&
|
|
7687
7802
|
isTelegramAllTabCommandExpired(message))
|
|
7688
7803
|
return;
|
|
7689
|
-
if (
|
|
7690
|
-
(bindings.length > 0 || presented) &&
|
|
7804
|
+
if (needsThread &&
|
|
7691
7805
|
typeof message.text === "string" &&
|
|
7692
7806
|
message.text.trim() &&
|
|
7693
|
-
(await sendAllTabTemporaryInputChooser(command, text, message, ctx)))
|
|
7807
|
+
(await sendAllTabTemporaryInputChooser(command, text, [message], ctx)))
|
|
7694
7808
|
return;
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7809
|
+
// Voice notes, files, stickers and albums from All get the same routing tab as text, whether or not the
|
|
7810
|
+
// client opened a tab for them; an album first gathers into one input so it opens one tab.
|
|
7811
|
+
if (!command &&
|
|
7812
|
+
needsThread &&
|
|
7813
|
+
typeof message.text !== "string" &&
|
|
7814
|
+
Media.hasTelegramMessagesPromptContent([message])) {
|
|
7815
|
+
if (message.media_group_id) {
|
|
7816
|
+
deps.mediaGroupRuntime.queueMessage({
|
|
7817
|
+
message: message,
|
|
7818
|
+
context: ctx,
|
|
7819
|
+
dispatchMessages: (messages, queuedCtx) => routeAllTabAlbum(messages, queuedCtx ?? ctx),
|
|
7820
|
+
});
|
|
7821
|
+
Updates.reportTelegramUpdateDeferred(message);
|
|
7699
7822
|
return;
|
|
7700
7823
|
}
|
|
7824
|
+
if (await sendAllTabTemporaryInputChooser(undefined, text, [message], ctx))
|
|
7825
|
+
return;
|
|
7701
7826
|
}
|
|
7702
|
-
if (
|
|
7827
|
+
if (needsThread) {
|
|
7703
7828
|
const probeTarget = bindings[0]?.target;
|
|
7704
|
-
if (probeTarget?.threadId && deps.callApi) {
|
|
7829
|
+
if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
|
|
7705
7830
|
try {
|
|
7706
7831
|
await deps.callApi("sendChatAction", {
|
|
7707
7832
|
chat_id: probeTarget.chatId,
|
|
@@ -7710,8 +7835,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7710
7835
|
});
|
|
7711
7836
|
}
|
|
7712
7837
|
catch (error) {
|
|
7713
|
-
if (Threads.isTelegramTopicModeUnavailableError(error)
|
|
7714
|
-
Threads.isTelegramTopicTargetStaleError(error)) {
|
|
7838
|
+
if (Threads.isTelegramTopicModeUnavailableError(error)) {
|
|
7715
7839
|
deps.threadStore.setBotState({
|
|
7716
7840
|
threadMode: "disabled",
|
|
7717
7841
|
updatedAtMs: Date.now(),
|
|
@@ -7729,8 +7853,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7729
7853
|
});
|
|
7730
7854
|
}
|
|
7731
7855
|
}
|
|
7732
|
-
|
|
7733
|
-
return;
|
|
7856
|
+
throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
|
|
7734
7857
|
}
|
|
7735
7858
|
}
|
|
7736
7859
|
await textDispatch.handleMessage(message, ctx);
|
|
@@ -8032,6 +8155,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
8032
8155
|
prepareHeldCommand: commandHandler.prepareHeldCommand,
|
|
8033
8156
|
canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
|
|
8034
8157
|
onSessionSettled,
|
|
8158
|
+
onDeliverySettled,
|
|
8035
8159
|
observeLiveRebindLeaderWork,
|
|
8036
8160
|
prepareLiveRebindLeaderCleanup,
|
|
8037
8161
|
prepareLiveRebindFollowerCleanup,
|