@llblab/pi-telegram 0.54.3 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -2
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +5 -3
- package/dist/lib/activity.d.ts +5 -1
- package/dist/lib/activity.js +31 -2
- package/dist/lib/bindings.d.ts +5 -1
- package/dist/lib/bindings.js +9 -2
- package/dist/lib/bus-api.d.ts +2 -2
- package/dist/lib/bus-api.js +7 -2
- package/dist/lib/bus-follower.d.ts +49 -3
- package/dist/lib/bus-follower.js +313 -146
- package/dist/lib/bus-leader.d.ts +3 -0
- package/dist/lib/bus-leader.js +99 -18
- package/dist/lib/bus.d.ts +5 -3
- package/dist/lib/bus.js +13 -10
- package/dist/lib/delivery.d.ts +4 -0
- package/dist/lib/delivery.js +48 -15
- package/dist/lib/extension.js +56 -6
- package/dist/lib/lifecycle.d.ts +68 -0
- package/dist/lib/lifecycle.js +178 -58
- package/dist/lib/machine.d.ts +23 -0
- package/dist/lib/machine.js +37 -0
- package/dist/lib/media.d.ts +114 -28
- package/dist/lib/media.js +292 -119
- package/dist/lib/model.d.ts +54 -15
- package/dist/lib/model.js +98 -53
- package/dist/lib/polling.js +2 -0
- package/dist/lib/preview.d.ts +128 -113
- package/dist/lib/preview.js +448 -366
- package/dist/lib/queue.d.ts +2 -2
- package/dist/lib/queue.js +3 -3
- package/dist/lib/routing.d.ts +14 -2
- package/dist/lib/routing.js +546 -207
- package/dist/lib/runtime.d.ts +77 -11
- package/dist/lib/runtime.js +174 -92
- package/dist/lib/sections.js +0 -1
- package/dist/lib/telegram-api.d.ts +11 -9
- package/dist/lib/telegram-api.js +53 -4
- package/dist/lib/text-groups.d.ts +0 -26
- package/dist/lib/text-groups.js +51 -98
- package/dist/lib/thread-display.js +14 -6
- package/dist/lib/threads.d.ts +20 -5
- package/dist/lib/threads.js +111 -32
- package/dist/lib/turns.d.ts +2 -0
- package/dist/lib/turns.js +6 -1
- package/dist/lib/updates.d.ts +11 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +16 -15
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +18 -8
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +8 -6
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +13 -1
- package/lib/bus-api.ts +11 -3
- package/lib/bus-follower.ts +417 -203
- package/lib/bus-leader.ts +88 -14
- package/lib/bus.ts +17 -15
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +59 -9
- package/lib/lifecycle.ts +229 -55
- package/lib/machine.ts +60 -0
- package/lib/media.ts +438 -172
- package/lib/model.ts +169 -74
- package/lib/polling.ts +2 -0
- package/lib/preview.ts +715 -566
- package/lib/queue.ts +5 -5
- package/lib/routing.ts +644 -283
- package/lib/runtime.ts +260 -104
- package/lib/sections.ts +0 -2
- package/lib/telegram-api.ts +61 -8
- package/lib/text-groups.ts +75 -136
- package/lib/thread-display.ts +15 -4
- package/lib/threads.ts +130 -38
- package/lib/turns.ts +8 -1
- package/lib/updates.ts +58 -21
- package/package.json +1 -1
package/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;
|
|
@@ -302,7 +297,8 @@ function formatTelegramUnboundRerouteChooserText(command) {
|
|
|
302
297
|
return `<b>🔀 Reroute ${formatTelegramRouteSubject(command)} to:</b>`;
|
|
303
298
|
}
|
|
304
299
|
function formatTelegramUnboundRerouteRestoreChooserText(command) {
|
|
305
|
-
|
|
300
|
+
const subject = command ? ` for ${formatTelegramRouteSubject(command)}` : "";
|
|
301
|
+
return `<b>🔁 Restore a Pi into this tab${subject}:</b>`;
|
|
306
302
|
}
|
|
307
303
|
function formatTelegramUnboundTopicGuidance() {
|
|
308
304
|
return [
|
|
@@ -370,17 +366,21 @@ function classifyLiveRebindCleanupFailure(error) {
|
|
|
370
366
|
? "rejected"
|
|
371
367
|
: "unknown";
|
|
372
368
|
}
|
|
373
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. The first
|
|
371
|
+
* delay is not cosmetic: a leader's detached command reply still needs the released, cleanup-free row.
|
|
372
|
+
*/
|
|
374
373
|
export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
|
|
375
374
|
delaysMs: Object.freeze([1_000, 2_000, 5_000, 10_000, 30_000]),
|
|
376
375
|
intervalMs: 60_000,
|
|
377
376
|
windowMs: 15 * 60_000,
|
|
378
377
|
});
|
|
379
|
-
/** 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. */
|
|
380
379
|
export function createTelegramLiveRebindCoordinator(input) {
|
|
381
380
|
const request = structuredClone(input.request), messages = [...input.messages];
|
|
382
381
|
const executor = structuredClone(input.authority.executor), operatorUserId = input.authority.operatorUserId;
|
|
383
382
|
const leader = input.leader && { ...input.leader }, follower = input.follower, followerRun = follower?.run;
|
|
383
|
+
const beforeCleanup = input.cleanupBeforeRelease;
|
|
384
384
|
const selectedCommand = follower?.selectedCommand && structuredClone(follower.selectedCommand), held = selectedCommand;
|
|
385
385
|
const observedRecipient = input.getRecipient(), recipient = observedRecipient && structuredClone(observedRecipient);
|
|
386
386
|
const sources = messages.map(Updates.inspectTelegramDeferredSource);
|
|
@@ -465,7 +465,7 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
465
465
|
return confirmed && current();
|
|
466
466
|
};
|
|
467
467
|
let preparation;
|
|
468
|
-
let saved = false, commitIssued = false, applyIssued = 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;
|
|
469
469
|
let preparedSource;
|
|
470
470
|
const runFollower = async (mode) => {
|
|
471
471
|
if (!input.follower || !recipient || !current())
|
|
@@ -513,6 +513,18 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
513
513
|
generation: recipient.generation,
|
|
514
514
|
bindingKey: recipient.bindingKey,
|
|
515
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
|
+
};
|
|
516
528
|
const releaseFollower = async (intent) => {
|
|
517
529
|
if (!held || !peerReleaseIssued) {
|
|
518
530
|
peerReleaseIssued = true;
|
|
@@ -535,6 +547,10 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
535
547
|
!canonical(intent))
|
|
536
548
|
return "unknown";
|
|
537
549
|
}
|
|
550
|
+
const accepted = acceptInput(intent);
|
|
551
|
+
if (!accepted)
|
|
552
|
+
return "unknown";
|
|
553
|
+
intent = accepted;
|
|
538
554
|
donorSettlementAttempted = true;
|
|
539
555
|
if (preparation?.settleTransferred(() => canonical(intent)) !== "settled")
|
|
540
556
|
return "unknown";
|
|
@@ -544,19 +560,16 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
544
560
|
const observeCommandCompletion = () => {
|
|
545
561
|
const intent = retained();
|
|
546
562
|
if (!intent ||
|
|
547
|
-
intent
|
|
548
|
-
intent.cleanup ||
|
|
563
|
+
!Threads.isTelegramLiveRebindInputAuthorized(intent) ||
|
|
549
564
|
!canonical(intent) ||
|
|
550
565
|
!leader?.isApplied(intent))
|
|
551
566
|
return "unknown";
|
|
552
567
|
const completions = messages.map(Updates.inspectTelegramDeferredSourceCompletion);
|
|
553
|
-
|
|
568
|
+
const completed = completions.every((completion, index) => completion && isDeepStrictEqual(completion, sources[index])) &&
|
|
554
569
|
current() &&
|
|
555
570
|
canonical(intent) &&
|
|
556
|
-
leader.isApplied(intent) &&
|
|
557
|
-
|
|
558
|
-
? "released"
|
|
559
|
-
: "unknown";
|
|
571
|
+
leader.isApplied(intent) && current();
|
|
572
|
+
return completed && acceptInput(intent) ? "released" : "unknown";
|
|
560
573
|
};
|
|
561
574
|
return {
|
|
562
575
|
/** Correlation only; a later cleanup attempt rereads and revalidates the canonical row itself. */
|
|
@@ -589,8 +602,7 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
589
602
|
return observeCommandCompletion();
|
|
590
603
|
const intent = retained();
|
|
591
604
|
if (!intent ||
|
|
592
|
-
intent
|
|
593
|
-
intent.cleanup ||
|
|
605
|
+
!Threads.isTelegramLiveRebindInputAuthorized(intent) ||
|
|
594
606
|
!canonical(intent) ||
|
|
595
607
|
!leader?.isApplied(intent) ||
|
|
596
608
|
!leader.observeAdmission?.() ||
|
|
@@ -598,6 +610,8 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
598
610
|
!canonical(intent) ||
|
|
599
611
|
!leader.isApplied(intent))
|
|
600
612
|
return "unknown";
|
|
613
|
+
if (!acceptInput(intent))
|
|
614
|
+
return "unknown";
|
|
601
615
|
released = true;
|
|
602
616
|
return "released";
|
|
603
617
|
}
|
|
@@ -653,22 +667,22 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
653
667
|
instanceId: recipient.instanceId,
|
|
654
668
|
sessionId: recipient.sessionId,
|
|
655
669
|
generation: recipient.generation,
|
|
656
|
-
}, authority);
|
|
670
|
+
}, authority, beforeCleanup ? { holdInput: true } : undefined);
|
|
657
671
|
}
|
|
658
672
|
else if (!commitIssued)
|
|
659
673
|
return "protected";
|
|
660
674
|
if (!intent ||
|
|
661
675
|
!sourceSaved() ||
|
|
662
676
|
!canonical(intent) ||
|
|
663
|
-
intent.phase === "finished" ||
|
|
664
|
-
intent.cleanup)
|
|
677
|
+
(intent.phase === "finished" && !cleanupBeforeReleaseIssued) ||
|
|
678
|
+
(intent.cleanup && intent.inputRelease === undefined))
|
|
665
679
|
return "protected";
|
|
666
680
|
// A released peer may already have completed its sources; only its retained settlement can reobserve that outcome.
|
|
667
681
|
if (recipient.kind === "follower" && peerReleaseIssued)
|
|
668
|
-
return intent
|
|
682
|
+
return Threads.isTelegramLiveRebindInputAuthorized(intent)
|
|
669
683
|
? await releaseFollower(intent)
|
|
670
684
|
: "protected";
|
|
671
|
-
const mode = applyIssued || intent.phase === "released" ? "inspect" : "apply";
|
|
685
|
+
const mode = applyIssued || intent.phase === "released" || intent.phase === "finished" ? "inspect" : "apply";
|
|
672
686
|
applyIssued = true;
|
|
673
687
|
if (recipient.kind === "leader") {
|
|
674
688
|
await leader.apply(structuredClone(intent), mode, current);
|
|
@@ -687,6 +701,19 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
687
701
|
}
|
|
688
702
|
if (!sourceSaved() || (preparation && !preparation.confirmSaved()))
|
|
689
703
|
return "protected";
|
|
704
|
+
if (input.retitle && !retitleIssued) {
|
|
705
|
+
retitleIssued = true;
|
|
706
|
+
try {
|
|
707
|
+
await input.retitle(current);
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
input.recordRuntimeEvent?.("telegram", error, {
|
|
711
|
+
phase: "live-rebind-title",
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
if (!current() || !canonical(intent))
|
|
715
|
+
return "unknown";
|
|
716
|
+
}
|
|
690
717
|
if (intent.phase === "rebound") {
|
|
691
718
|
const expected = intent;
|
|
692
719
|
input.restoreStore.advanceLiveRebind(expected, "release", recipient.kind === "leader"
|
|
@@ -698,22 +725,55 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
698
725
|
intent = retained();
|
|
699
726
|
}
|
|
700
727
|
if (!intent ||
|
|
701
|
-
intent.phase !== "released" ||
|
|
702
|
-
intent.cleanup ||
|
|
728
|
+
(intent.phase !== "released" && !(intent.phase === "finished" && intent.inputRelease === "held")) ||
|
|
729
|
+
(intent.cleanup && intent.inputRelease === undefined) ||
|
|
703
730
|
!canonical(intent))
|
|
704
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
|
+
}
|
|
705
755
|
if (recipient.kind === "leader") {
|
|
706
756
|
const expected = intent;
|
|
707
757
|
if (!preparation?.beginRelease(() => canonical(expected) && leader.isApplied(expected)))
|
|
708
758
|
return "protected";
|
|
709
759
|
continuationIssued = true;
|
|
710
|
-
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
|
+
};
|
|
711
766
|
if (leader.complete) {
|
|
712
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";
|
|
713
771
|
return observeCommandCompletion();
|
|
714
772
|
}
|
|
715
773
|
if (!(await leader.continue(messages, canContinue)))
|
|
716
774
|
return "unknown";
|
|
775
|
+
if (!acceptInput(intent))
|
|
776
|
+
return "unknown";
|
|
717
777
|
}
|
|
718
778
|
else
|
|
719
779
|
return await releaseFollower(intent);
|
|
@@ -1561,6 +1621,58 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1561
1621
|
};
|
|
1562
1622
|
/** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
|
|
1563
1623
|
const deleteAllTabCopies = (pending) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? []);
|
|
1624
|
+
/**
|
|
1625
|
+
* Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
|
|
1626
|
+
* holds its forwarded copy. Without one it stays where the operator can still read it.
|
|
1627
|
+
*/
|
|
1628
|
+
const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
|
|
1629
|
+
if (pending.allTabCopiesForwarded)
|
|
1630
|
+
return deleteAllTabCopies(pending);
|
|
1631
|
+
pending.allTabCopies?.splice(0);
|
|
1632
|
+
return Promise.resolve();
|
|
1633
|
+
};
|
|
1634
|
+
/**
|
|
1635
|
+
* The Bot API files a threadless All input in All even when a mobile client first draws it in its new tab. One silent
|
|
1636
|
+
* best-effort forward moves it into the routing tab it opened, and only a confirmed copy lets the original leave All;
|
|
1637
|
+
* any failure keeps the original where the operator can still read it.
|
|
1638
|
+
*/
|
|
1639
|
+
const moveAllTabSourceIntoTab = async (pending, target, isCurrent) => {
|
|
1640
|
+
const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
|
|
1641
|
+
if (messageIds.length === 0 || !deps.callApi || !isCurrent())
|
|
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
|
+
};
|
|
1649
|
+
try {
|
|
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)))
|
|
1664
|
+
return;
|
|
1665
|
+
pending.allTabCopiesForwarded = true;
|
|
1666
|
+
await deleteAllTabCopies(pending);
|
|
1667
|
+
return copyIds[0];
|
|
1668
|
+
}
|
|
1669
|
+
catch (error) {
|
|
1670
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
1671
|
+
phase: "temporary-thread-source-forward",
|
|
1672
|
+
});
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1564
1676
|
const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
|
|
1565
1677
|
const chatId = query.message?.chat?.id;
|
|
1566
1678
|
const messageId = query.message?.message_id;
|
|
@@ -1586,6 +1698,26 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1586
1698
|
return false;
|
|
1587
1699
|
}
|
|
1588
1700
|
};
|
|
1701
|
+
/** Choosers are one-shot: a final outcome deletes the menu; only a failed delete leaves an inert, button-free notice. */
|
|
1702
|
+
const retireRerouteChooserMessage = async (chatId, messageId, fallbackText, assertCurrent) => {
|
|
1703
|
+
if (deps.deleteMessage) {
|
|
1704
|
+
try {
|
|
1705
|
+
await deps.deleteMessage(chatId, messageId);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
catch (error) {
|
|
1709
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
1710
|
+
phase: "reroute-chooser-delete",
|
|
1711
|
+
chatId,
|
|
1712
|
+
messageId,
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
assertCurrent?.();
|
|
1716
|
+
}
|
|
1717
|
+
await deps.editInteractiveMessage?.(chatId, messageId, fallbackText, "html", {
|
|
1718
|
+
inline_keyboard: [],
|
|
1719
|
+
});
|
|
1720
|
+
};
|
|
1589
1721
|
const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
|
|
1590
1722
|
if (!target || !deps.threadStore)
|
|
1591
1723
|
return true;
|
|
@@ -1708,8 +1840,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1708
1840
|
continue;
|
|
1709
1841
|
if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
|
|
1710
1842
|
continue;
|
|
1843
|
+
// A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
|
|
1711
1844
|
if (await retireCancellationChooser(id, pending, current))
|
|
1712
|
-
await
|
|
1845
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
1713
1846
|
}
|
|
1714
1847
|
catch (error) {
|
|
1715
1848
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -2239,12 +2372,28 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2239
2372
|
if (!intent)
|
|
2240
2373
|
continue;
|
|
2241
2374
|
}
|
|
2375
|
+
// The chooser follows the success proof, never old-thread cleanup, which may stay protected indefinitely.
|
|
2376
|
+
const retireRestoreChoosers = async () => {
|
|
2377
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2378
|
+
if (pending.workspaceRestore?.operationId !== operationId)
|
|
2379
|
+
continue;
|
|
2380
|
+
pending.phase = { kind: "finalizing", message: "Restored" };
|
|
2381
|
+
if (pending.chooserMessageId === undefined)
|
|
2382
|
+
continue;
|
|
2383
|
+
assertSettlementCurrent();
|
|
2384
|
+
await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", assertSettlementCurrent);
|
|
2385
|
+
assertSettlementCurrent();
|
|
2386
|
+
if (pendingUnboundReroutes.get(id) === pending)
|
|
2387
|
+
removePendingReroute(id);
|
|
2388
|
+
}
|
|
2389
|
+
};
|
|
2242
2390
|
if (intent.routing?.cleanup === undefined) {
|
|
2243
2391
|
if (!intent.request.source.updateIds.every((id) => intent.routing.settlements.some((value) => value.kind !== "queued" && value.updateIds.includes(id))))
|
|
2244
2392
|
continue;
|
|
2245
|
-
// Positive settlement of every selected source is the success proof
|
|
2393
|
+
// Positive settlement of every selected source is the success proof.
|
|
2246
2394
|
await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
|
|
2247
2395
|
assertSettlementCurrent();
|
|
2396
|
+
await retireRestoreChoosers();
|
|
2248
2397
|
if (isRerouteTargetProtected(intent.request.binding.target, intent))
|
|
2249
2398
|
continue;
|
|
2250
2399
|
const grant = store.issueCleanup(intent, settlementAuthority);
|
|
@@ -2265,19 +2414,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2265
2414
|
if (!store.retire(intent, settlementAuthority))
|
|
2266
2415
|
continue;
|
|
2267
2416
|
retired = true;
|
|
2268
|
-
|
|
2269
|
-
if (pending.workspaceRestore?.operationId !== operationId)
|
|
2270
|
-
continue;
|
|
2271
|
-
pending.phase = { kind: "finalizing", message: "Restored" };
|
|
2272
|
-
if (!deps.editInteractiveMessage ||
|
|
2273
|
-
pending.chooserMessageId === undefined)
|
|
2274
|
-
continue;
|
|
2275
|
-
assertSettlementCurrent();
|
|
2276
|
-
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
|
|
2277
|
-
assertSettlementCurrent();
|
|
2278
|
-
if (pendingUnboundReroutes.get(id) === pending)
|
|
2279
|
-
removePendingReroute(id);
|
|
2280
|
-
}
|
|
2417
|
+
await retireRestoreChoosers();
|
|
2281
2418
|
}
|
|
2282
2419
|
}))
|
|
2283
2420
|
.catch((error) => {
|
|
@@ -2936,8 +3073,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2936
3073
|
const intent = store
|
|
2937
3074
|
.listLiveRebindings()
|
|
2938
3075
|
.find((value) => value.request.operationId === request.operationId);
|
|
2939
|
-
if (intent
|
|
2940
|
-
!intent.cleanup &&
|
|
3076
|
+
if (intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
|
|
2941
3077
|
isDeepStrictEqual(intent.request, request) &&
|
|
2942
3078
|
intent.operatorUserId === operatorUserId &&
|
|
2943
3079
|
isDeepStrictEqual(intent.executor, {
|
|
@@ -3206,17 +3342,67 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3206
3342
|
name ||
|
|
3207
3343
|
confirmation ||
|
|
3208
3344
|
extension;
|
|
3345
|
+
const preDispatchCleanup = deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
|
|
3209
3346
|
const createCoordinator = () => createTelegramLiveRebindCoordinator({
|
|
3210
3347
|
request,
|
|
3211
3348
|
messages: selection.messages,
|
|
3212
3349
|
restoreStore: store,
|
|
3213
3350
|
threadStore: threads,
|
|
3351
|
+
// Same title as Workspace Restore: the display-mode title of the restored binding on its new tab.
|
|
3352
|
+
retitle: deps.callApi
|
|
3353
|
+
? async (isCurrent) => {
|
|
3354
|
+
if (!isCurrent())
|
|
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;
|
|
3360
|
+
const slot = binding.slot ?? "";
|
|
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.");
|
|
3383
|
+
}
|
|
3384
|
+
: undefined,
|
|
3214
3385
|
authority: {
|
|
3215
3386
|
executor: { instanceId, leaderEpoch: String(epoch) },
|
|
3216
3387
|
operatorUserId,
|
|
3217
|
-
isCurrent: current,
|
|
3388
|
+
isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
|
|
3218
3389
|
},
|
|
3219
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
|
+
} : {}),
|
|
3220
3406
|
follower: isLeader
|
|
3221
3407
|
? undefined
|
|
3222
3408
|
: {
|
|
@@ -3247,9 +3433,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3247
3433
|
instanceId,
|
|
3248
3434
|
leaderEpoch: String(epoch),
|
|
3249
3435
|
}) &&
|
|
3250
|
-
!intent.cleanup &&
|
|
3251
|
-
(intent.phase === "rebound" ||
|
|
3252
|
-
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) &&
|
|
3253
3439
|
isDeepStrictEqual(intent.recipient, {
|
|
3254
3440
|
kind: recipient.kind,
|
|
3255
3441
|
instanceId: recipient.instanceId,
|
|
@@ -3423,7 +3609,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3423
3609
|
},
|
|
3424
3610
|
};
|
|
3425
3611
|
};
|
|
3426
|
-
async function inspectLiveRebindRecipient(intent, ctx, mode) {
|
|
3612
|
+
async function inspectLiveRebindRecipient(intent, ctx, mode, admittedOrigin) {
|
|
3427
3613
|
const ports = deps.workspaceRestoreRecipient, threads = deps.threadStore, store = deps.getWorkspaceRestoreStore?.();
|
|
3428
3614
|
const followerMode = mode === "follower-cleanup" || mode === "follower-issue", cleanupMode = mode !== "work";
|
|
3429
3615
|
const issueMode = mode === "issue" || mode === "follower-issue", callApi = deps.callApi;
|
|
@@ -3435,8 +3621,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3435
3621
|
(followerMode ? !peer || !peerRun || !peerJournal : !collector) ||
|
|
3436
3622
|
!run ||
|
|
3437
3623
|
(issueMode && !callApi) ||
|
|
3438
|
-
deps.isContextActive?.(ctx) !== true)
|
|
3624
|
+
deps.isContextActive?.(ctx) !== true) {
|
|
3439
3625
|
return undefined;
|
|
3626
|
+
}
|
|
3440
3627
|
try {
|
|
3441
3628
|
const expected = structuredClone(intent), { request, recipient, executor } = expected;
|
|
3442
3629
|
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), generation = deps.getSessionGeneration?.();
|
|
@@ -3526,7 +3713,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3526
3713
|
peerJournal.call(peer, live) === recipientJournalKey &&
|
|
3527
3714
|
isDeepStrictEqual(findReference(), reference));
|
|
3528
3715
|
};
|
|
3529
|
-
const current = () =>
|
|
3716
|
+
const current = () => (!admittedOrigin || admittedOrigin()) &&
|
|
3717
|
+
deps.hasWorkspaceLiveRebindAuthority?.() === true &&
|
|
3530
3718
|
deps.isContextActive?.(ctx) === true &&
|
|
3531
3719
|
deps.workspaceRestoreRecipient === ports &&
|
|
3532
3720
|
(followerMode || ports.observeLeaderWork === collector) &&
|
|
@@ -3571,8 +3759,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3571
3759
|
};
|
|
3572
3760
|
const sample = async () => {
|
|
3573
3761
|
assertCanonical();
|
|
3574
|
-
if (cleanupMode
|
|
3575
|
-
|
|
3762
|
+
if (cleanupMode) {
|
|
3763
|
+
const protectedTarget = protection.call(threads, expected);
|
|
3764
|
+
if (protectedTarget !== false)
|
|
3765
|
+
return undefined;
|
|
3766
|
+
}
|
|
3576
3767
|
const target = { ...request.binding.target };
|
|
3577
3768
|
let value;
|
|
3578
3769
|
if (followerMode) {
|
|
@@ -3581,7 +3772,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3581
3772
|
instanceId: recipient.instanceId,
|
|
3582
3773
|
sessionId: recipient.sessionId,
|
|
3583
3774
|
recipientBindingKey: recipientJournalKey,
|
|
3584
|
-
mode: "observe",
|
|
3775
|
+
mode: expected.inputRelease === "held" ? "observe-held" : "observe",
|
|
3585
3776
|
sourceUpdateIds: [...request.source.updateIds],
|
|
3586
3777
|
slot: request.binding.slot,
|
|
3587
3778
|
target: { ...request.target },
|
|
@@ -3623,12 +3814,13 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3623
3814
|
throw new Error("Live-rebind work sample is unavailable or changed target.");
|
|
3624
3815
|
assertCanonical();
|
|
3625
3816
|
if (cleanupMode) {
|
|
3817
|
+
const targetProtected = protection.call(threads, expected);
|
|
3626
3818
|
const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
|
|
3627
3819
|
operationId: request.operationId,
|
|
3628
3820
|
oldTarget: request.binding.target,
|
|
3629
3821
|
recipientTarget: request.target,
|
|
3630
3822
|
leaderEpoch: executor.leaderEpoch,
|
|
3631
|
-
targetProtected
|
|
3823
|
+
targetProtected,
|
|
3632
3824
|
work,
|
|
3633
3825
|
});
|
|
3634
3826
|
assertCanonical();
|
|
@@ -3652,32 +3844,27 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3652
3844
|
return undefined;
|
|
3653
3845
|
if (issueMode) {
|
|
3654
3846
|
// One admission spans the fresh sample, durable issue marker, single deletion and terminal record.
|
|
3655
|
-
|
|
3656
|
-
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
3657
|
-
operationKind: "workspace.live-rebind-cleanup",
|
|
3658
|
-
scopes: [{ kind: "profile" }],
|
|
3659
|
-
}, async () => {
|
|
3847
|
+
const execute = async () => {
|
|
3660
3848
|
const prepared = await sample();
|
|
3661
3849
|
if (!prepared ||
|
|
3662
3850
|
prepared.status !== "prepared" ||
|
|
3663
3851
|
!current() ||
|
|
3664
|
-
protection.call(threads, expected) !== false)
|
|
3852
|
+
protection.call(threads, expected) !== false) {
|
|
3665
3853
|
return { status: "not-ready" };
|
|
3854
|
+
}
|
|
3666
3855
|
const authority = {
|
|
3667
3856
|
executor: structuredClone(executor),
|
|
3668
3857
|
operatorUserId: expected.operatorUserId,
|
|
3669
3858
|
isCurrent: current,
|
|
3670
3859
|
};
|
|
3671
3860
|
const grant = store.advanceLiveRebind(expected, "issue-cleanup", authority);
|
|
3672
|
-
if (!grant)
|
|
3861
|
+
if (!grant) {
|
|
3673
3862
|
return undefined;
|
|
3863
|
+
}
|
|
3674
3864
|
const assertGranted = () => assertCanonical(grant);
|
|
3675
3865
|
const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(prepared, {
|
|
3676
3866
|
assertCurrent: assertGranted,
|
|
3677
|
-
deleteTopic: (target) => callApi("deleteForumTopic", {
|
|
3678
|
-
chat_id: target.chatId,
|
|
3679
|
-
message_thread_id: target.threadId,
|
|
3680
|
-
}, { maxAttempts: 1, assertAuthority: assertGranted }),
|
|
3867
|
+
deleteTopic: (target) => callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, assertAuthority: assertGranted }),
|
|
3681
3868
|
classifyFailure: classifyLiveRebindCleanupFailure,
|
|
3682
3869
|
});
|
|
3683
3870
|
let recorded = false;
|
|
@@ -3695,7 +3882,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3695
3882
|
cleanup,
|
|
3696
3883
|
recorded,
|
|
3697
3884
|
};
|
|
3698
|
-
}
|
|
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);
|
|
3699
3891
|
}
|
|
3700
3892
|
const observation = await run({
|
|
3701
3893
|
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
@@ -3729,6 +3921,26 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3729
3921
|
const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
|
|
3730
3922
|
// One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
|
|
3731
3923
|
const liveRebindCleanupChains = new Map();
|
|
3924
|
+
// One hint slot per existing leader chain; a running attempt retains at most one coalesced recheck.
|
|
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
|
+
};
|
|
3732
3944
|
/** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
|
|
3733
3945
|
const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
|
|
3734
3946
|
const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
|
|
@@ -3769,23 +3981,53 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3769
3981
|
* a finished outcome or an inactive session stops, and the window ends in `not-issued` so no released row becomes a
|
|
3770
3982
|
* permanent rebinding blocker. Waiting timers never join settlement; only a running attempt does.
|
|
3771
3983
|
*/
|
|
3772
|
-
const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0) => {
|
|
3984
|
+
const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0, current = captureLiveRebindCleanupScope(ctx)) => {
|
|
3773
3985
|
const pacing = deps.liveRebindCleanupSchedule;
|
|
3774
3986
|
const chain = liveRebindCleanupChains.get(operationId);
|
|
3775
3987
|
if (!pacing ||
|
|
3776
3988
|
(attempt === 0 ? chain !== undefined : chain !== "running") ||
|
|
3777
|
-
|
|
3778
|
-
if (attempt > 0 && chain === "running")
|
|
3989
|
+
!current()) {
|
|
3990
|
+
if (attempt > 0 && chain === "running") {
|
|
3779
3991
|
liveRebindCleanupChains.delete(operationId);
|
|
3992
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
3993
|
+
}
|
|
3780
3994
|
return;
|
|
3781
3995
|
}
|
|
3782
|
-
|
|
3996
|
+
let pendingWake;
|
|
3997
|
+
const wake = (reason) => {
|
|
3998
|
+
if (!current())
|
|
3999
|
+
return;
|
|
4000
|
+
if (liveRebindCleanupChains.get(operationId) === "running") {
|
|
4001
|
+
pendingWake = reason;
|
|
4002
|
+
return;
|
|
4003
|
+
}
|
|
3783
4004
|
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
3784
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
|
+
};
|
|
4021
|
+
const attemptNow = () => {
|
|
4022
|
+
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
4023
|
+
return;
|
|
4024
|
+
pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
|
|
3785
4025
|
liveRebindCleanupChains.set(operationId, "running");
|
|
4026
|
+
if (role === "leader")
|
|
4027
|
+
liveRebindCleanupWakers.set(operationId, wake);
|
|
3786
4028
|
let continued = false;
|
|
3787
4029
|
const task = (async () => {
|
|
3788
|
-
if (
|
|
4030
|
+
if (!current())
|
|
3789
4031
|
return;
|
|
3790
4032
|
const intent = deps
|
|
3791
4033
|
.getWorkspaceRestoreStore?.()
|
|
@@ -3797,27 +4039,44 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3797
4039
|
? await issueLiveRebindLeaderCleanup(intent, ctx)
|
|
3798
4040
|
: await issueLiveRebindFollowerCleanup(intent, ctx);
|
|
3799
4041
|
if (result?.status === "finished" ||
|
|
3800
|
-
|
|
4042
|
+
!current())
|
|
3801
4043
|
return;
|
|
3802
|
-
if (Date.now() - startedAtMs >= pacing.windowMs)
|
|
4044
|
+
if (Date.now() - startedAtMs >= pacing.windowMs) {
|
|
3803
4045
|
return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
|
|
4046
|
+
}
|
|
3804
4047
|
continued = true;
|
|
3805
|
-
scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1);
|
|
4048
|
+
scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1, current);
|
|
4049
|
+
if (pendingWake)
|
|
4050
|
+
liveRebindCleanupWakers.get(operationId)?.(pendingWake);
|
|
3806
4051
|
})()
|
|
3807
4052
|
.catch((error) => deps.recordRuntimeEvent?.("routing", error, {
|
|
3808
4053
|
phase: "live-rebind-cleanup-schedule",
|
|
3809
4054
|
}))
|
|
3810
4055
|
.finally(() => {
|
|
3811
4056
|
if (!continued &&
|
|
3812
|
-
liveRebindCleanupChains.get(operationId) === "running")
|
|
4057
|
+
liveRebindCleanupChains.get(operationId) === "running") {
|
|
3813
4058
|
liveRebindCleanupChains.delete(operationId);
|
|
4059
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
4060
|
+
}
|
|
3814
4061
|
});
|
|
3815
4062
|
restoreSettlementTasks.add(task);
|
|
3816
4063
|
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
3817
|
-
}
|
|
4064
|
+
};
|
|
4065
|
+
const timer = setTimeout(attemptNow, pacing.delaysMs[attempt] ?? pacing.intervalMs);
|
|
3818
4066
|
timer.unref?.();
|
|
3819
4067
|
liveRebindCleanupChains.set(operationId, timer);
|
|
4068
|
+
// The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
|
|
4069
|
+
if (role === "leader" && attempt > 0)
|
|
4070
|
+
liveRebindCleanupWakers.set(operationId, wake);
|
|
3820
4071
|
};
|
|
4072
|
+
const wakeLiveRebindCleanup = (ctx, reason) => {
|
|
4073
|
+
if (deps.isContextActive?.(ctx) !== true)
|
|
4074
|
+
return;
|
|
4075
|
+
for (const wake of [...liveRebindCleanupWakers.values()])
|
|
4076
|
+
wake(reason);
|
|
4077
|
+
};
|
|
4078
|
+
const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
|
|
4079
|
+
const onDeliverySettled = (ctx) => wakeLiveRebindCleanup(ctx, "delivery-drained");
|
|
3821
4080
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
3822
4081
|
? async (input) => {
|
|
3823
4082
|
const ports = deps.workspaceRestoreRecipient;
|
|
@@ -4203,17 +4462,25 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4203
4462
|
}
|
|
4204
4463
|
await promptEnqueue(messages, ctx);
|
|
4205
4464
|
};
|
|
4206
|
-
const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
|
|
4465
|
+
const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query), retainRecord = false) => {
|
|
4207
4466
|
assertExecutionCurrent();
|
|
4208
4467
|
const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
|
|
4209
4468
|
assertExecutionCurrent();
|
|
4210
4469
|
if (dismissed) {
|
|
4211
|
-
|
|
4470
|
+
// A retained record no longer matches any chooser, so a late duplicate tap reads as expired.
|
|
4471
|
+
if (retainRecord)
|
|
4472
|
+
pending.chooserMessageId = undefined;
|
|
4473
|
+
else
|
|
4474
|
+
removePendingReroute(rerouteId);
|
|
4212
4475
|
await deps.answerCallbackQuery(query.id, successMessage);
|
|
4213
4476
|
await deleteAllTabCopies(pending);
|
|
4214
4477
|
return;
|
|
4215
4478
|
}
|
|
4216
|
-
pending.phase = {
|
|
4479
|
+
pending.phase = {
|
|
4480
|
+
kind: "finalizing",
|
|
4481
|
+
message: successMessage,
|
|
4482
|
+
...(retainRecord ? { retainRecord: true } : {}),
|
|
4483
|
+
};
|
|
4217
4484
|
await deps.answerCallbackQuery(query.id, `${successMessage}; tap again to clear the menu`);
|
|
4218
4485
|
};
|
|
4219
4486
|
const isTemporaryReroute = (pending) => !!pending.temporaryThread ||
|
|
@@ -4613,10 +4880,10 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4613
4880
|
const retireCancellationChooser = async (id, pending, isCurrent) => {
|
|
4614
4881
|
if (!isPendingRerouteCancelled(pending) ||
|
|
4615
4882
|
!isCurrent() ||
|
|
4616
|
-
!deps.editInteractiveMessage ||
|
|
4883
|
+
(!deps.deleteMessage && !deps.editInteractiveMessage) ||
|
|
4617
4884
|
pending.chooserMessageId === undefined)
|
|
4618
4885
|
return false;
|
|
4619
|
-
await
|
|
4886
|
+
await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>");
|
|
4620
4887
|
if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
|
|
4621
4888
|
return false;
|
|
4622
4889
|
removePendingReroute(id);
|
|
@@ -4973,9 +5240,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4973
5240
|
if (!result)
|
|
4974
5241
|
return;
|
|
4975
5242
|
// Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
|
|
5243
|
+
let movedIntoTab = false;
|
|
4976
5244
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
4977
5245
|
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
|
|
4978
5246
|
continue;
|
|
5247
|
+
movedIntoTab ||= pending.allTabCopiesForwarded === true;
|
|
4979
5248
|
removePendingReroute(id);
|
|
4980
5249
|
if (signal.aborted ||
|
|
4981
5250
|
deps.isContextActive?.(ctx) !== true ||
|
|
@@ -4999,7 +5268,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4999
5268
|
// never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
|
|
5000
5269
|
if (temporary?.target && runtimeCurrent()) {
|
|
5001
5270
|
scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
|
|
5002
|
-
|
|
5271
|
+
// A moved original already left All with its forward; only an unmoved one is deleted here.
|
|
5272
|
+
if (!movedIntoTab)
|
|
5273
|
+
await deleteAllTabMessages(temporary.target.chatId, getTelegramAllTabSourceMessageIds([message], temporary.target.chatId));
|
|
5003
5274
|
}
|
|
5004
5275
|
});
|
|
5005
5276
|
};
|
|
@@ -5448,7 +5719,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5448
5719
|
return true;
|
|
5449
5720
|
}
|
|
5450
5721
|
if (pending.phase.kind === "finalizing") {
|
|
5451
|
-
await finalizePendingReroute(parsed.rerouteId, pending, query, pending.phase.message, assertExecutionCurrent);
|
|
5722
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, pending.phase.message, assertExecutionCurrent, pending.phase.retainRecord === true);
|
|
5452
5723
|
return true;
|
|
5453
5724
|
}
|
|
5454
5725
|
if (pending.liveRebind &&
|
|
@@ -5709,21 +5980,23 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5709
5980
|
pending.liveRebind ??= selection;
|
|
5710
5981
|
if (!(await selectPendingDestination()))
|
|
5711
5982
|
return true;
|
|
5983
|
+
const operationId = selection.coordinator.operationId;
|
|
5712
5984
|
if (current())
|
|
5713
5985
|
outcome = await selection.coordinator.advance();
|
|
5714
5986
|
// Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
|
|
5715
5987
|
// still be pending while every attempt samples current work afresh.
|
|
5716
|
-
const operationId = selection.coordinator.operationId;
|
|
5717
5988
|
const row = outcome === "protected"
|
|
5718
5989
|
? undefined
|
|
5719
5990
|
: deps
|
|
5720
5991
|
.getWorkspaceRestoreStore?.()
|
|
5721
5992
|
?.listLiveRebindings()
|
|
5722
5993
|
.find((value) => value.request.operationId === operationId);
|
|
5723
|
-
if (row?.phase === "released" && row.cleanup === undefined
|
|
5994
|
+
if (row?.phase === "released" && row.cleanup === undefined &&
|
|
5995
|
+
(row.inputRelease === undefined || row.inputRelease === "accepted")) {
|
|
5724
5996
|
scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
|
|
5997
|
+
}
|
|
5725
5998
|
if (outcome === "released")
|
|
5726
|
-
await
|
|
5999
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5727
6000
|
}
|
|
5728
6001
|
catch (error) {
|
|
5729
6002
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -5734,8 +6007,24 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5734
6007
|
selection.isCurrent = undefined;
|
|
5735
6008
|
}
|
|
5736
6009
|
assertExecutionCurrent();
|
|
5737
|
-
|
|
5738
|
-
|
|
6010
|
+
if (outcome !== "released") {
|
|
6011
|
+
await deps.answerCallbackQuery(query.id, "Live rebind is unconfirmed; this choice never replays issued input");
|
|
6012
|
+
return true;
|
|
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());
|
|
6023
|
+
// The tab now belongs to the rebound thread; only the one-shot chooser message goes.
|
|
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"
|
|
5739
6028
|
? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
|
|
5740
6029
|
: selection.extensionCommand
|
|
5741
6030
|
? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
|
|
@@ -5755,8 +6044,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5755
6044
|
? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
|
|
5756
6045
|
: selection.coordinator?.selectedCommandReference()
|
|
5757
6046
|
? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
|
|
5758
|
-
: "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
|
|
5759
|
-
: "Live rebind is unconfirmed; this choice never replays issued input");
|
|
6047
|
+
: "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
|
|
5760
6048
|
return true;
|
|
5761
6049
|
}
|
|
5762
6050
|
if (!(await selectPendingDestination()))
|
|
@@ -5768,6 +6056,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5768
6056
|
messages: [...pending.messages],
|
|
5769
6057
|
});
|
|
5770
6058
|
let active = true;
|
|
6059
|
+
// Delivery proof for the toast only; chooser retirement still waits for the positive settlement that follows.
|
|
6060
|
+
let delivered = false;
|
|
5771
6061
|
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
5772
6062
|
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
5773
6063
|
const admissionScope = deps.getAdmissionScope?.();
|
|
@@ -5818,12 +6108,15 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5818
6108
|
: routed;
|
|
5819
6109
|
await dispatchPendingRerouteMessages(pending, messages, ctx);
|
|
5820
6110
|
assertRecipientCurrent();
|
|
5821
|
-
|
|
6111
|
+
delivered = true;
|
|
6112
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5822
6113
|
return true;
|
|
5823
6114
|
}
|
|
5824
6115
|
const forwarded = await forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
|
|
5825
|
-
if (forwarded)
|
|
5826
|
-
|
|
6116
|
+
if (forwarded) {
|
|
6117
|
+
delivered = true;
|
|
6118
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
6119
|
+
}
|
|
5827
6120
|
return forwarded;
|
|
5828
6121
|
},
|
|
5829
6122
|
});
|
|
@@ -5834,7 +6127,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5834
6127
|
assertExecutionCurrent();
|
|
5835
6128
|
if (!selectionCurrent())
|
|
5836
6129
|
return true;
|
|
5837
|
-
await deps.answerCallbackQuery(query.id, "Restore unconfirmed; not resending");
|
|
6130
|
+
await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
|
|
5838
6131
|
return true;
|
|
5839
6132
|
}
|
|
5840
6133
|
if (record.instanceId &&
|
|
@@ -6856,13 +7149,16 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6856
7149
|
}
|
|
6857
7150
|
return undefined;
|
|
6858
7151
|
};
|
|
6859
|
-
/**
|
|
7152
|
+
/**
|
|
7153
|
+
* A fresh implicit native tab observed just before the All input in the same exact scope: mobile names it after
|
|
7154
|
+
* the input, while other clients may use a generic name, so the immediately preceding creation also qualifies.
|
|
7155
|
+
*/
|
|
6860
7156
|
const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
|
|
6861
7157
|
const text = (message.text ?? message.caption ?? "").trim();
|
|
6862
7158
|
let match;
|
|
6863
7159
|
for (const [key, implicit] of implicitThreadCreations) {
|
|
6864
|
-
if (implicit.name &&
|
|
6865
|
-
|
|
7160
|
+
if (((implicit.name && text.startsWith(implicit.name)) ||
|
|
7161
|
+
implicit.updateId === updateId - 1) &&
|
|
6866
7162
|
implicit.updateId < updateId &&
|
|
6867
7163
|
implicit.createdAtSec > 0 &&
|
|
6868
7164
|
message.date !== undefined &&
|
|
@@ -6884,28 +7180,42 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6884
7180
|
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
6885
7181
|
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
6886
7182
|
*/
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
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 };
|
|
6891
7196
|
const execution = Updates.getTelegramUpdateExecutionFence(message);
|
|
6892
7197
|
const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
|
|
6893
7198
|
if (!cap ||
|
|
6894
7199
|
!deps.runWorkspaceOperation ||
|
|
6895
7200
|
!deps.callApi ||
|
|
6896
7201
|
updateId === undefined ||
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
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) ||
|
|
6900
7206
|
!cap.isCurrent())
|
|
6901
7207
|
return false;
|
|
6902
7208
|
const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
|
|
6903
7209
|
const source = { journalBindingKey, updateId };
|
|
7210
|
+
group.journalBindingKey = journalBindingKey;
|
|
6904
7211
|
const find = () => store
|
|
6905
7212
|
.listTemporaryThreads()
|
|
6906
7213
|
.find((entry) => entry.source.journalBindingKey === journalBindingKey &&
|
|
6907
7214
|
entry.source.updateId === updateId);
|
|
6908
7215
|
let entry;
|
|
7216
|
+
// A tab opened by this call, by the bot or adopted from the client, gets the input's one forwarded copy; re-entry
|
|
7217
|
+
// after a restart finds its tab already prepared and never forwards again.
|
|
7218
|
+
let openedNow = false;
|
|
6909
7219
|
await deps.runWorkspaceOperation({
|
|
6910
7220
|
operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
|
|
6911
7221
|
operationKind: "workspace.temporary-thread",
|
|
@@ -6923,7 +7233,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6923
7233
|
// the input itself to All: adopt that tab rather than leaving it behind next to a second one.
|
|
6924
7234
|
const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
|
|
6925
7235
|
if (native) {
|
|
6926
|
-
const adopted = store.registerImplicitTemporaryThread(
|
|
7236
|
+
const adopted = store.registerImplicitTemporaryThread(group, native.implicit.target, randomBytes(16).toString("hex"), {
|
|
6927
7237
|
...authority,
|
|
6928
7238
|
isCurrent: () => current() &&
|
|
6929
7239
|
implicitThreadCreations.get(native.key) === native.implicit,
|
|
@@ -6931,6 +7241,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6931
7241
|
if (adopted && current()) {
|
|
6932
7242
|
implicitThreadCreations.delete(native.key);
|
|
6933
7243
|
entry = adopted;
|
|
7244
|
+
openedNow = true;
|
|
6934
7245
|
// Adopted native tabs share the routing name; a failed rename never blocks routing.
|
|
6935
7246
|
try {
|
|
6936
7247
|
await deps.callApi("editForumTopic", {
|
|
@@ -6947,7 +7258,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6947
7258
|
return;
|
|
6948
7259
|
}
|
|
6949
7260
|
}
|
|
6950
|
-
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
|
|
7261
|
+
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority, group);
|
|
6951
7262
|
entry = reservation?.entry;
|
|
6952
7263
|
if (!reservation?.reserved || !current())
|
|
6953
7264
|
return;
|
|
@@ -6971,31 +7282,39 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6971
7282
|
!Number.isSafeInteger(threadId) ||
|
|
6972
7283
|
threadId <= 0)
|
|
6973
7284
|
return;
|
|
6974
|
-
entry
|
|
6975
|
-
|
|
7285
|
+
const acknowledged = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority);
|
|
7286
|
+
openedNow = !!acknowledged;
|
|
7287
|
+
entry = acknowledged ?? entry;
|
|
6976
7288
|
});
|
|
6977
7289
|
if (!current() || !entry)
|
|
6978
7290
|
throw new Error("Temporary Thread authority changed; the input remains retryable.");
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
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];
|
|
6984
7301
|
if (entry.phase !== "created" || !entry.target) {
|
|
6985
|
-
|
|
6986
|
-
|
|
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" });
|
|
6987
7305
|
return true;
|
|
6988
7306
|
}
|
|
6989
7307
|
const target = entry.target;
|
|
6990
7308
|
const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
|
|
6991
|
-
const rerouteId = storePendingUnboundReroute(
|
|
7309
|
+
const rerouteId = storePendingUnboundReroute(held, command ? "command" : "prompt", target);
|
|
6992
7310
|
const pending = pendingUnboundReroutes.get(rerouteId);
|
|
6993
7311
|
pending.temporaryThread = entry;
|
|
6994
|
-
pending.allTabCopies = getTelegramAllTabSourceMessageIds(
|
|
6995
|
-
|
|
7312
|
+
pending.allTabCopies = getTelegramAllTabSourceMessageIds(messages, target.chatId);
|
|
7313
|
+
for (const input of held)
|
|
7314
|
+
Updates.reportTelegramUpdateDeferred(input);
|
|
6996
7315
|
// Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
|
|
6997
7316
|
if (deps.editInteractiveMessage &&
|
|
6998
|
-
Updates.supportsTelegramDeferredAbandonment(
|
|
7317
|
+
held.every((input) => Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey))) {
|
|
6999
7318
|
pending.abandonment = {
|
|
7000
7319
|
ownerUserId: operatorUserId,
|
|
7001
7320
|
journalBindingKey,
|
|
@@ -7003,6 +7322,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7003
7322
|
};
|
|
7004
7323
|
}
|
|
7005
7324
|
pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
7325
|
+
const tabCopyId = openedNow
|
|
7326
|
+
? await moveAllTabSourceIntoTab(pending, target, current)
|
|
7327
|
+
: undefined;
|
|
7006
7328
|
try {
|
|
7007
7329
|
if (!deps.sendInteractiveMessage)
|
|
7008
7330
|
throw new Error("Temporary Thread chooser publication is unavailable.");
|
|
@@ -7010,7 +7332,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7010
7332
|
canRestore: true,
|
|
7011
7333
|
canCancel: !!pending.abandonment,
|
|
7012
7334
|
getDisplayTitle: deps.getDisplayTitle,
|
|
7013
|
-
}),
|
|
7335
|
+
}), tabCopyId === undefined
|
|
7336
|
+
? { target }
|
|
7337
|
+
: { target, replyToMessageId: tabCopyId });
|
|
7014
7338
|
rememberRerouteChooser(rerouteId, chooserId);
|
|
7015
7339
|
}
|
|
7016
7340
|
catch (error) {
|
|
@@ -7019,72 +7343,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7019
7343
|
}
|
|
7020
7344
|
return true;
|
|
7021
7345
|
};
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
if (activeRecords.length === 0)
|
|
7028
|
-
return false;
|
|
7029
|
-
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
7030
|
-
...message,
|
|
7031
|
-
text: commandText,
|
|
7032
|
-
caption: undefined,
|
|
7033
|
-
});
|
|
7034
|
-
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
7035
|
-
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
7036
|
-
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
7037
|
-
pendingUnboundReroutes.get(rerouteId).rootChooserText = text;
|
|
7038
|
-
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, {
|
|
7039
|
-
canRestore: typeof message.message_thread_id === "number",
|
|
7040
|
-
getDisplayTitle: deps.getDisplayTitle,
|
|
7041
|
-
});
|
|
7042
|
-
let chooserId;
|
|
7043
|
-
try {
|
|
7044
|
-
if (deps.sendInteractiveMessage) {
|
|
7045
|
-
chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, options.target || options.replyToSource
|
|
7046
|
-
? {
|
|
7047
|
-
...(options.target ? { target: options.target } : {}),
|
|
7048
|
-
...(options.replyToSource
|
|
7049
|
-
? { replyToMessageId: message.message_id }
|
|
7050
|
-
: {}),
|
|
7051
|
-
}
|
|
7052
|
-
: undefined);
|
|
7053
|
-
}
|
|
7054
|
-
else if (deps.callApi) {
|
|
7055
|
-
const chooser = await deps.callApi("sendMessage", {
|
|
7056
|
-
chat_id: message.chat.id,
|
|
7057
|
-
text,
|
|
7058
|
-
parse_mode: "HTML",
|
|
7059
|
-
reply_markup: replyMarkup,
|
|
7060
|
-
...(typeof options.target?.threadId === "number"
|
|
7061
|
-
? { message_thread_id: options.target.threadId }
|
|
7062
|
-
: {}),
|
|
7063
|
-
...(options.replyToSource
|
|
7064
|
-
? {
|
|
7065
|
-
reply_parameters: {
|
|
7066
|
-
message_id: message.message_id,
|
|
7067
|
-
allow_sending_without_reply: true,
|
|
7068
|
-
},
|
|
7069
|
-
}
|
|
7070
|
-
: {}),
|
|
7071
|
-
});
|
|
7072
|
-
chooserId = chooser?.message_id;
|
|
7073
|
-
}
|
|
7074
|
-
else {
|
|
7075
|
-
chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
7076
|
-
parseMode: "HTML",
|
|
7077
|
-
target: options.target,
|
|
7078
|
-
});
|
|
7079
|
-
}
|
|
7080
|
-
}
|
|
7081
|
-
catch (error) {
|
|
7082
|
-
removePendingReroute(rerouteId);
|
|
7083
|
-
throw error;
|
|
7084
|
-
}
|
|
7085
|
-
rememberRerouteChooser(rerouteId, chooserId);
|
|
7086
|
-
Updates.reportTelegramUpdateCompleted(commandMessage);
|
|
7087
|
-
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);
|
|
7088
7351
|
};
|
|
7089
7352
|
const dispatchCommandOrPrompt = Commands.createTelegramCommandOrPromptDispatcher({
|
|
7090
7353
|
extractRawText: Media.extractFirstTelegramMessageText,
|
|
@@ -7311,7 +7574,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7311
7574
|
chatId: lifecycle.target.chatId,
|
|
7312
7575
|
threadId: lifecycle.target.threadId,
|
|
7313
7576
|
},
|
|
7314
|
-
name: "name" in created && typeof created.name === "string"
|
|
7577
|
+
name: "name" in created && typeof created.name === "string"
|
|
7578
|
+
? created.name
|
|
7579
|
+
: "",
|
|
7315
7580
|
createdAtSec: message.date ?? 0,
|
|
7316
7581
|
});
|
|
7317
7582
|
}
|
|
@@ -7529,28 +7794,39 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7529
7794
|
.some((entry) => entry.source.updateId === sourceUpdateId &&
|
|
7530
7795
|
entry.source.journalBindingKey ===
|
|
7531
7796
|
deps.getAdmissionJournalBinding?.()) === true;
|
|
7797
|
+
const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
|
|
7532
7798
|
// Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
|
|
7533
7799
|
if (command &&
|
|
7534
7800
|
command.name !== "thread" &&
|
|
7535
7801
|
!presented &&
|
|
7536
7802
|
isTelegramAllTabCommandExpired(message))
|
|
7537
7803
|
return;
|
|
7538
|
-
if (
|
|
7539
|
-
(bindings.length > 0 || presented) &&
|
|
7804
|
+
if (needsThread &&
|
|
7540
7805
|
typeof message.text === "string" &&
|
|
7541
7806
|
message.text.trim() &&
|
|
7542
|
-
(await sendAllTabTemporaryInputChooser(command, text, message, ctx)))
|
|
7807
|
+
(await sendAllTabTemporaryInputChooser(command, text, [message], ctx)))
|
|
7543
7808
|
return;
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
|
|
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);
|
|
7548
7822
|
return;
|
|
7549
7823
|
}
|
|
7824
|
+
if (await sendAllTabTemporaryInputChooser(undefined, text, [message], ctx))
|
|
7825
|
+
return;
|
|
7550
7826
|
}
|
|
7551
|
-
if (
|
|
7827
|
+
if (needsThread) {
|
|
7552
7828
|
const probeTarget = bindings[0]?.target;
|
|
7553
|
-
if (probeTarget?.threadId && deps.callApi) {
|
|
7829
|
+
if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
|
|
7554
7830
|
try {
|
|
7555
7831
|
await deps.callApi("sendChatAction", {
|
|
7556
7832
|
chat_id: probeTarget.chatId,
|
|
@@ -7559,8 +7835,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7559
7835
|
});
|
|
7560
7836
|
}
|
|
7561
7837
|
catch (error) {
|
|
7562
|
-
if (Threads.isTelegramTopicModeUnavailableError(error)
|
|
7563
|
-
Threads.isTelegramTopicTargetStaleError(error)) {
|
|
7838
|
+
if (Threads.isTelegramTopicModeUnavailableError(error)) {
|
|
7564
7839
|
deps.threadStore.setBotState({
|
|
7565
7840
|
threadMode: "disabled",
|
|
7566
7841
|
updatedAtMs: Date.now(),
|
|
@@ -7578,8 +7853,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7578
7853
|
});
|
|
7579
7854
|
}
|
|
7580
7855
|
}
|
|
7581
|
-
|
|
7582
|
-
return;
|
|
7856
|
+
throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
|
|
7583
7857
|
}
|
|
7584
7858
|
}
|
|
7585
7859
|
await textDispatch.handleMessage(message, ctx);
|
|
@@ -7623,7 +7897,35 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7623
7897
|
threadId: target.threadId,
|
|
7624
7898
|
};
|
|
7625
7899
|
/** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
|
|
7626
|
-
|
|
7900
|
+
// The leader's own Workspace binding follows a reclaimed tab, keeping its slot and name; without it, the tab
|
|
7901
|
+
// would gain a second slot while Restore, labels and titles still pointed at the deleted target.
|
|
7902
|
+
const leaderWorkspaceCwd = typeof ctx.cwd === "string"
|
|
7903
|
+
? ctx.cwd
|
|
7904
|
+
: undefined;
|
|
7905
|
+
const claimLeaderWorkspaceBinding = () => {
|
|
7906
|
+
const sessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
|
|
7907
|
+
if (!leaderWorkspaceCwd || !sessionId || !instanceId)
|
|
7908
|
+
return undefined;
|
|
7909
|
+
const identity = deps.threadStore.claimWorkspaceIdentity(leaderWorkspaceCwd, instanceId, undefined, { existingBindingOnly: true, sessionId });
|
|
7910
|
+
const binding = identity
|
|
7911
|
+
? deps.threadStore.getWorkspaceBinding(identity.cwd, identity.instanceSlot, identity.sessionId)
|
|
7912
|
+
: undefined;
|
|
7913
|
+
if (!binding)
|
|
7914
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId);
|
|
7915
|
+
return binding;
|
|
7916
|
+
};
|
|
7917
|
+
const reclaimUnboundTargetForLeader = async (base, profileKey, slot, threadName, event, details, leaderBinding) => {
|
|
7918
|
+
if (leaderBinding) {
|
|
7919
|
+
const { inactiveSinceMs: _inactiveSinceMs, displayTitle: _displayTitle, ...moved } = leaderBinding;
|
|
7920
|
+
const committed = deps.threadStore.upsertWorkspaceBinding({
|
|
7921
|
+
...moved,
|
|
7922
|
+
target: { ...unboundTarget },
|
|
7923
|
+
updatedAtMs: Date.now(),
|
|
7924
|
+
}, instanceId);
|
|
7925
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId);
|
|
7926
|
+
if (committed)
|
|
7927
|
+
threadName = committed.threadName ?? threadName;
|
|
7928
|
+
}
|
|
7627
7929
|
deps.threadStore.upsert({
|
|
7628
7930
|
...base,
|
|
7629
7931
|
profileKey,
|
|
@@ -7654,6 +7956,33 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7654
7956
|
slot,
|
|
7655
7957
|
profileKey,
|
|
7656
7958
|
});
|
|
7959
|
+
if (leaderBinding && deps.callApi) {
|
|
7960
|
+
const title = deps.getDisplayTitle?.(unboundTarget);
|
|
7961
|
+
if (title)
|
|
7962
|
+
try {
|
|
7963
|
+
await deps.callApi("editForumTopic", {
|
|
7964
|
+
chat_id: unboundTarget.chatId,
|
|
7965
|
+
message_thread_id: unboundTarget.threadId,
|
|
7966
|
+
name: title,
|
|
7967
|
+
});
|
|
7968
|
+
const current = deps
|
|
7969
|
+
.threadStore.listWorkspaceBindings()
|
|
7970
|
+
.find((value) => value.bindingKey === leaderBinding.bindingKey &&
|
|
7971
|
+
isDeepStrictEqual(value.target, unboundTarget));
|
|
7972
|
+
if (current) {
|
|
7973
|
+
deps.threadStore.upsertWorkspaceBinding({
|
|
7974
|
+
...current,
|
|
7975
|
+
displayTitle: title,
|
|
7976
|
+
});
|
|
7977
|
+
await deps.threadStore.persist();
|
|
7978
|
+
}
|
|
7979
|
+
}
|
|
7980
|
+
catch (error) {
|
|
7981
|
+
deps.recordRuntimeEvent?.("bus", error, {
|
|
7982
|
+
phase: "leader-topic-reclaim-title",
|
|
7983
|
+
});
|
|
7984
|
+
}
|
|
7985
|
+
}
|
|
7657
7986
|
await textDispatch.handleMessage(message, ctx);
|
|
7658
7987
|
};
|
|
7659
7988
|
const records = deps.threadStore.list();
|
|
@@ -7754,7 +8083,10 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7754
8083
|
throw error;
|
|
7755
8084
|
}
|
|
7756
8085
|
if (currentLeaderIsStale) {
|
|
7757
|
-
const
|
|
8086
|
+
const leaderBinding = claimLeaderWorkspaceBinding();
|
|
8087
|
+
const slot = deps.threadStore.allocateSlot(leaderProfileKey, leaderBinding?.slot, leaderBinding?.bindingKey);
|
|
8088
|
+
if (leaderBinding && leaderBinding.slot !== slot)
|
|
8089
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId);
|
|
7758
8090
|
if (!slot) {
|
|
7759
8091
|
deps.threadStore.markStaleByTarget(currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.");
|
|
7760
8092
|
await deps.threadStore.persist();
|
|
@@ -7767,7 +8099,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7767
8099
|
await reclaimUnboundTargetForLeader(currentLeaderRecord, leaderProfileKey, slot, threadName, "Bus leader reclaimed stale-current unbound thread", {
|
|
7768
8100
|
phase: "leader-topic-unbound-stale-reclaim",
|
|
7769
8101
|
staleThreadId: currentLeaderRecord.target.threadId,
|
|
7770
|
-
});
|
|
8102
|
+
}, leaderBinding?.slot === slot ? leaderBinding : undefined);
|
|
7771
8103
|
return;
|
|
7772
8104
|
}
|
|
7773
8105
|
}
|
|
@@ -7777,7 +8109,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7777
8109
|
!hasAnyRoutableThread) {
|
|
7778
8110
|
const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey);
|
|
7779
8111
|
const priorLeaderIdentity = deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
|
|
7780
|
-
const
|
|
8112
|
+
const leaderBinding = claimLeaderWorkspaceBinding();
|
|
8113
|
+
const slot = deps.threadStore.allocateSlot(leaderProfileKey, leaderBinding?.slot ??
|
|
8114
|
+
priorLeaderRecord?.slot ??
|
|
8115
|
+
priorLeaderIdentity?.slot, leaderBinding?.bindingKey);
|
|
8116
|
+
if (leaderBinding && leaderBinding.slot !== slot)
|
|
8117
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId);
|
|
7781
8118
|
if (!slot) {
|
|
7782
8119
|
await deps.sendTextReply(target.chatId, message.message_id, TELEGRAM_SLOT_CAPACITY_MESSAGE, { target });
|
|
7783
8120
|
return;
|
|
@@ -7790,7 +8127,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7790
8127
|
identityThreadName ??
|
|
7791
8128
|
ThreadNaming.chooseTelegramThreadName({ slot }) ??
|
|
7792
8129
|
"Pi";
|
|
7793
|
-
await reclaimUnboundTargetForLeader({ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now() }, leaderProfileKey, slot, threadName, "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim" });
|
|
8130
|
+
await reclaimUnboundTargetForLeader({ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now() }, leaderProfileKey, slot, threadName, "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim" }, leaderBinding?.slot === slot ? leaderBinding : undefined);
|
|
7794
8131
|
return;
|
|
7795
8132
|
}
|
|
7796
8133
|
await sendUnboundRerouteChooser(message, ctx);
|
|
@@ -7817,6 +8154,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7817
8154
|
onUpdateCompleted,
|
|
7818
8155
|
prepareHeldCommand: commandHandler.prepareHeldCommand,
|
|
7819
8156
|
canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
|
|
8157
|
+
onSessionSettled,
|
|
8158
|
+
onDeliverySettled,
|
|
7820
8159
|
observeLiveRebindLeaderWork,
|
|
7821
8160
|
prepareLiveRebindLeaderCleanup,
|
|
7822
8161
|
prepareLiveRebindFollowerCleanup,
|