@llblab/pi-telegram 0.54.4 → 0.55.1
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 +16 -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 +50 -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 +7 -3
- package/dist/lib/bus.js +24 -16
- 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 +7 -2
- package/dist/lib/routing.js +585 -229
- 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 +6 -0
- package/dist/lib/turns.js +12 -1
- package/dist/lib/updates.d.ts +13 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +14 -11
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +20 -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 +30 -18
- 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 +614 -257
- 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 +15 -1
- package/lib/updates.ts +60 -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);
|
|
@@ -1288,13 +1331,20 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1288
1331
|
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
1289
1332
|
},
|
|
1290
1333
|
});
|
|
1291
|
-
const cloneTelegramMessagesForThread = (messages, threadId) => {
|
|
1292
|
-
return messages.map((message) =>
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1334
|
+
const cloneTelegramMessagesForThread = (messages, threadId, copies) => {
|
|
1335
|
+
return messages.map((message) => {
|
|
1336
|
+
const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
1337
|
+
const copyId = copies && ids.length === 1 ? copies.get(ids[0]) : undefined;
|
|
1338
|
+
if (copies && (!Number.isSafeInteger(copyId) || copyId <= 0))
|
|
1339
|
+
throw new Error("Reroute destination copy identity is unavailable.");
|
|
1340
|
+
const routed = {
|
|
1341
|
+
...message,
|
|
1342
|
+
message_id: copyId ?? 0,
|
|
1343
|
+
message_thread_id: threadId,
|
|
1344
|
+
reply_to_message: undefined,
|
|
1345
|
+
};
|
|
1346
|
+
return Updates.carryTelegramUpdateExecutionFence(message, copies ? Turns.withTelegramTurnReplyAnchor(routed) : routed);
|
|
1347
|
+
});
|
|
1298
1348
|
};
|
|
1299
1349
|
/** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
|
|
1300
1350
|
const hasPendingRerouteForTarget = (target, own) => [...pendingUnboundReroutes.values()].some((pending) => pending.sourceTarget.chatId === target.chatId &&
|
|
@@ -1560,10 +1610,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1560
1610
|
return !acceptedWorkClear;
|
|
1561
1611
|
};
|
|
1562
1612
|
/** Best-effort, once consumed: All keeps no copy of an input whose routing tab carried it; failure is only recorded. */
|
|
1563
|
-
const deleteAllTabMessages = async (chatId, messageIds) => {
|
|
1613
|
+
const deleteAllTabMessages = async (chatId, messageIds, isCurrent) => {
|
|
1564
1614
|
if (!deps.deleteMessage)
|
|
1565
1615
|
return;
|
|
1566
1616
|
for (const messageId of messageIds) {
|
|
1617
|
+
if (isCurrent?.() === false)
|
|
1618
|
+
return;
|
|
1567
1619
|
try {
|
|
1568
1620
|
await deps.deleteMessage(chatId, messageId);
|
|
1569
1621
|
}
|
|
@@ -1577,13 +1629,14 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1577
1629
|
}
|
|
1578
1630
|
};
|
|
1579
1631
|
/** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
|
|
1580
|
-
const deleteAllTabCopies = (pending) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? []);
|
|
1632
|
+
const deleteAllTabCopies = (pending, isCurrent) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? [], isCurrent);
|
|
1581
1633
|
/**
|
|
1582
1634
|
* Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
|
|
1583
1635
|
* holds its forwarded copy. Without one it stays where the operator can still read it.
|
|
1584
1636
|
*/
|
|
1585
1637
|
const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
|
|
1586
|
-
if (pending.allTabCopiesForwarded
|
|
1638
|
+
if (pending.allTabCopiesForwarded &&
|
|
1639
|
+
isDeepStrictEqual(pending.allTabCopiesForwarded.target, pending.sourceTarget))
|
|
1587
1640
|
return deleteAllTabCopies(pending);
|
|
1588
1641
|
pending.allTabCopies?.splice(0);
|
|
1589
1642
|
return Promise.resolve();
|
|
@@ -1594,31 +1647,112 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1594
1647
|
* any failure keeps the original where the operator can still read it.
|
|
1595
1648
|
*/
|
|
1596
1649
|
const moveAllTabSourceIntoTab = async (pending, target, isCurrent) => {
|
|
1597
|
-
const
|
|
1598
|
-
if (
|
|
1650
|
+
const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
|
|
1651
|
+
if (messageIds.length === 0 || !deps.callApi || !isCurrent())
|
|
1599
1652
|
return;
|
|
1653
|
+
const copyTarget = { ...target };
|
|
1654
|
+
const route = {
|
|
1655
|
+
chat_id: copyTarget.chatId,
|
|
1656
|
+
message_thread_id: copyTarget.threadId,
|
|
1657
|
+
from_chat_id: pending.sourceTarget.chatId,
|
|
1658
|
+
disable_notification: true,
|
|
1659
|
+
};
|
|
1660
|
+
const options = {
|
|
1661
|
+
maxAttempts: 1,
|
|
1662
|
+
retrySafety: "non-idempotent",
|
|
1663
|
+
assertAuthority() {
|
|
1664
|
+
if (!isCurrent())
|
|
1665
|
+
throw new Error("Temporary Thread source forwarding authority changed.");
|
|
1666
|
+
},
|
|
1667
|
+
};
|
|
1600
1668
|
try {
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
const copyId = copy?.message_id;
|
|
1609
|
-
if (typeof copyId !== "number" || !Number.isSafeInteger(copyId))
|
|
1669
|
+
// An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
|
|
1670
|
+
const copies = messageIds.length === 1
|
|
1671
|
+
? [
|
|
1672
|
+
await deps.callApi("forwardMessage", { ...route, message_id: messageIds[0] }, options),
|
|
1673
|
+
]
|
|
1674
|
+
: await deps.callApi("forwardMessages", { ...route, message_ids: messageIds }, options);
|
|
1675
|
+
if (!isCurrent())
|
|
1610
1676
|
return;
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1677
|
+
const copyIds = Array.isArray(copies)
|
|
1678
|
+
? copies.map((copy) => copy?.message_id)
|
|
1679
|
+
: [];
|
|
1680
|
+
if (copyIds.length !== messageIds.length ||
|
|
1681
|
+
!copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
|
|
1682
|
+
new Set(copyIds).size !== copyIds.length)
|
|
1683
|
+
return;
|
|
1684
|
+
pending.allTabCopiesForwarded = {
|
|
1685
|
+
target: copyTarget,
|
|
1686
|
+
sourceMessageIds: messageIds,
|
|
1687
|
+
messageIds: copyIds,
|
|
1688
|
+
};
|
|
1689
|
+
await deleteAllTabCopies(pending, isCurrent);
|
|
1690
|
+
if (!isCurrent())
|
|
1691
|
+
return;
|
|
1692
|
+
return pending.allTabCopiesForwarded.messageIds[0];
|
|
1614
1693
|
}
|
|
1615
1694
|
catch (error) {
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1695
|
+
if (isCurrent())
|
|
1696
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
1697
|
+
phase: "temporary-thread-source-forward",
|
|
1698
|
+
});
|
|
1619
1699
|
return;
|
|
1620
1700
|
}
|
|
1621
1701
|
};
|
|
1702
|
+
/** Read original source addresses, not warm target rewrites; unknown copy provenance never grants a Reroute. */
|
|
1703
|
+
const resolvePendingRerouteForwardSource = (pending) => {
|
|
1704
|
+
try {
|
|
1705
|
+
const originals = pending.messages.map((message) => {
|
|
1706
|
+
const snapshot = Updates.inspectTelegramDeferredSourceSnapshot(message);
|
|
1707
|
+
const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
1708
|
+
const original = snapshot?.update.message;
|
|
1709
|
+
if (!snapshot ||
|
|
1710
|
+
ids.length !== 1 ||
|
|
1711
|
+
snapshot.source.updateId !== ids[0] ||
|
|
1712
|
+
snapshot.update.update_id !== ids[0] ||
|
|
1713
|
+
Object.keys(snapshot.update).some((key) => key !== "update_id" && key !== "message") ||
|
|
1714
|
+
snapshot.source.journalBindingKey !== deps.getAdmissionJournalBinding?.() ||
|
|
1715
|
+
!original ||
|
|
1716
|
+
original.chat?.type !== "private" ||
|
|
1717
|
+
original.chat.id !== pending.sourceTarget.chatId ||
|
|
1718
|
+
original.from?.id !== deps.configStore.getAllowedUserId() ||
|
|
1719
|
+
original.from?.is_bot !== false ||
|
|
1720
|
+
!Number.isSafeInteger(original.message_id) ||
|
|
1721
|
+
original.message_id <= 0)
|
|
1722
|
+
return undefined;
|
|
1723
|
+
return { message: original, updateId: ids[0] };
|
|
1724
|
+
});
|
|
1725
|
+
if (originals.length === 0 || originals.some((source) => !source))
|
|
1726
|
+
return;
|
|
1727
|
+
originals.sort((a, b) => a.message.message_id - b.message.message_id);
|
|
1728
|
+
const sourceMessageIds = originals.map((source) => source.message.message_id);
|
|
1729
|
+
const sourceUpdateIds = originals.map((source) => source.updateId);
|
|
1730
|
+
if (new Set(sourceMessageIds).size !== sourceMessageIds.length ||
|
|
1731
|
+
new Set(sourceUpdateIds).size !== sourceUpdateIds.length)
|
|
1732
|
+
return;
|
|
1733
|
+
const copied = pending.allTabCopiesForwarded;
|
|
1734
|
+
if (copied) {
|
|
1735
|
+
if (!isDeepStrictEqual(copied.target, pending.sourceTarget) ||
|
|
1736
|
+
!isDeepStrictEqual(copied.sourceMessageIds, sourceMessageIds) ||
|
|
1737
|
+
copied.messageIds.length !== sourceMessageIds.length ||
|
|
1738
|
+
!copied.messageIds.every((id) => Number.isSafeInteger(id) && id > 0) ||
|
|
1739
|
+
new Set(copied.messageIds).size !== copied.messageIds.length)
|
|
1740
|
+
return;
|
|
1741
|
+
return { chatId: copied.target.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...copied.messageIds] };
|
|
1742
|
+
}
|
|
1743
|
+
const nativeOriginals = originals.every((source) => typeof pending.sourceTarget.threadId === "number" &&
|
|
1744
|
+
source.message.message_thread_id === pending.sourceTarget.threadId);
|
|
1745
|
+
const retainedAllOriginals = pending.allTabSourceRetained === true &&
|
|
1746
|
+
originals.every((source) => source.message.message_thread_id === undefined) &&
|
|
1747
|
+
isDeepStrictEqual([...(pending.allTabCopies ?? [])].sort((a, b) => a - b), sourceMessageIds);
|
|
1748
|
+
if (nativeOriginals || retainedAllOriginals)
|
|
1749
|
+
return { chatId: pending.sourceTarget.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...sourceMessageIds] };
|
|
1750
|
+
}
|
|
1751
|
+
catch (error) {
|
|
1752
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-source-proof" });
|
|
1753
|
+
}
|
|
1754
|
+
return undefined;
|
|
1755
|
+
};
|
|
1622
1756
|
const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
|
|
1623
1757
|
const chatId = query.message?.chat?.id;
|
|
1624
1758
|
const messageId = query.message?.message_id;
|
|
@@ -3019,8 +3153,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3019
3153
|
const intent = store
|
|
3020
3154
|
.listLiveRebindings()
|
|
3021
3155
|
.find((value) => value.request.operationId === request.operationId);
|
|
3022
|
-
if (intent
|
|
3023
|
-
!intent.cleanup &&
|
|
3156
|
+
if (intent && Threads.isTelegramLiveRebindInputAuthorized(intent) &&
|
|
3024
3157
|
isDeepStrictEqual(intent.request, request) &&
|
|
3025
3158
|
intent.operatorUserId === operatorUserId &&
|
|
3026
3159
|
isDeepStrictEqual(intent.executor, {
|
|
@@ -3289,6 +3422,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3289
3422
|
name ||
|
|
3290
3423
|
confirmation ||
|
|
3291
3424
|
extension;
|
|
3425
|
+
const preDispatchCleanup = deps.runWorkspaceOperation !== undefined && deps.canUsePreDispatchRebind?.(recipient) === true;
|
|
3292
3426
|
const createCoordinator = () => createTelegramLiveRebindCoordinator({
|
|
3293
3427
|
request,
|
|
3294
3428
|
messages: selection.messages,
|
|
@@ -3299,21 +3433,56 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3299
3433
|
? async (isCurrent) => {
|
|
3300
3434
|
if (!isCurrent())
|
|
3301
3435
|
return;
|
|
3436
|
+
const expected = threads.getWorkspaceBindingByTarget(target, binding.sessionId);
|
|
3437
|
+
if (!expected || expected.bindingKey !== binding.bindingKey || expected.slot !== binding.slot)
|
|
3438
|
+
throw new Error("Live-rebind title binding is unavailable.");
|
|
3439
|
+
const callApi = deps.callApi;
|
|
3302
3440
|
const slot = binding.slot ?? "";
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3441
|
+
const resolveTitle = () => deps.getDisplayTitle?.(expected.target) ??
|
|
3442
|
+
ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot);
|
|
3443
|
+
const title = resolveTitle();
|
|
3444
|
+
const bindingCurrent = (frame) => isCurrent() && deps.callApi === callApi &&
|
|
3445
|
+
isDeepStrictEqual(threads.getWorkspaceBindingByTarget(frame.target, frame.sessionId), frame);
|
|
3446
|
+
const assertTitle = () => {
|
|
3447
|
+
if (!bindingCurrent(expected) || resolveTitle() !== title)
|
|
3448
|
+
throw new Error("Live-rebind title authority changed.");
|
|
3449
|
+
};
|
|
3450
|
+
assertTitle();
|
|
3451
|
+
const renamed = await callApi.call(deps, "editForumTopic", {
|
|
3452
|
+
chat_id: expected.target.chatId,
|
|
3453
|
+
message_thread_id: expected.target.threadId,
|
|
3454
|
+
name: title,
|
|
3455
|
+
}, { maxAttempts: 1, assertAuthority: assertTitle });
|
|
3456
|
+
assertTitle();
|
|
3457
|
+
if (renamed !== true || !threads.setWorkspaceDisplayTitle(expected, title))
|
|
3458
|
+
throw new Error("Live-rebind title acknowledgement is unconfirmed.");
|
|
3459
|
+
const acknowledged = { ...expected, displayTitle: title };
|
|
3460
|
+
await threads.persist(() => bindingCurrent(acknowledged));
|
|
3461
|
+
if (!bindingCurrent(acknowledged))
|
|
3462
|
+
throw new Error("Live-rebind title publication authority changed.");
|
|
3309
3463
|
}
|
|
3310
3464
|
: undefined,
|
|
3311
3465
|
authority: {
|
|
3312
3466
|
executor: { instanceId, leaderEpoch: String(epoch) },
|
|
3313
3467
|
operatorUserId,
|
|
3314
|
-
isCurrent: current,
|
|
3468
|
+
isCurrent: () => current() && (!preDispatchCleanup || deps.canUsePreDispatchRebind?.(recipient) === true),
|
|
3315
3469
|
},
|
|
3316
3470
|
getRecipient,
|
|
3471
|
+
...(preDispatchCleanup ? {
|
|
3472
|
+
async cleanupBeforeRelease(intent, isCurrent) {
|
|
3473
|
+
const delay = deps.liveRebindCleanupSchedule?.delaysMs[0] ?? 1_000;
|
|
3474
|
+
await new Promise(resolve => { const timer = setTimeout(resolve, delay); timer.unref?.(); });
|
|
3475
|
+
if (!isCurrent())
|
|
3476
|
+
return false;
|
|
3477
|
+
const result = await inspectLiveRebindRecipient(intent, ctx, isLeader ? "issue" : "follower-issue", isCurrent);
|
|
3478
|
+
if (!isCurrent())
|
|
3479
|
+
return false;
|
|
3480
|
+
// A session-replacing producer cannot retain an old-session cleanup grant across its gateway.
|
|
3481
|
+
if (result?.status === "not-ready" && selection.confirmationCommand?.name === "new")
|
|
3482
|
+
return !!store.advanceLiveRebind(intent, "protect-old-target", { executor: intent.executor, operatorUserId, isCurrent });
|
|
3483
|
+
return result?.status === "not-ready" || result?.status === "finished" && result.recorded;
|
|
3484
|
+
},
|
|
3485
|
+
} : {}),
|
|
3317
3486
|
follower: isLeader
|
|
3318
3487
|
? undefined
|
|
3319
3488
|
: {
|
|
@@ -3344,9 +3513,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3344
3513
|
instanceId,
|
|
3345
3514
|
leaderEpoch: String(epoch),
|
|
3346
3515
|
}) &&
|
|
3347
|
-
!intent.cleanup &&
|
|
3348
|
-
(intent.phase === "rebound" ||
|
|
3349
|
-
intent.phase === "
|
|
3516
|
+
(!intent.cleanup || intent.inputRelease !== undefined && intent.cleanup !== "issued") &&
|
|
3517
|
+
(intent.phase === "rebound" || intent.phase === "released" ||
|
|
3518
|
+
intent.phase === "finished" && intent.inputRelease !== undefined) &&
|
|
3350
3519
|
isDeepStrictEqual(intent.recipient, {
|
|
3351
3520
|
kind: recipient.kind,
|
|
3352
3521
|
instanceId: recipient.instanceId,
|
|
@@ -3520,7 +3689,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3520
3689
|
},
|
|
3521
3690
|
};
|
|
3522
3691
|
};
|
|
3523
|
-
async function inspectLiveRebindRecipient(intent, ctx, mode) {
|
|
3692
|
+
async function inspectLiveRebindRecipient(intent, ctx, mode, admittedOrigin) {
|
|
3524
3693
|
const ports = deps.workspaceRestoreRecipient, threads = deps.threadStore, store = deps.getWorkspaceRestoreStore?.();
|
|
3525
3694
|
const followerMode = mode === "follower-cleanup" || mode === "follower-issue", cleanupMode = mode !== "work";
|
|
3526
3695
|
const issueMode = mode === "issue" || mode === "follower-issue", callApi = deps.callApi;
|
|
@@ -3532,8 +3701,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3532
3701
|
(followerMode ? !peer || !peerRun || !peerJournal : !collector) ||
|
|
3533
3702
|
!run ||
|
|
3534
3703
|
(issueMode && !callApi) ||
|
|
3535
|
-
deps.isContextActive?.(ctx) !== true)
|
|
3704
|
+
deps.isContextActive?.(ctx) !== true) {
|
|
3536
3705
|
return undefined;
|
|
3706
|
+
}
|
|
3537
3707
|
try {
|
|
3538
3708
|
const expected = structuredClone(intent), { request, recipient, executor } = expected;
|
|
3539
3709
|
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), generation = deps.getSessionGeneration?.();
|
|
@@ -3623,7 +3793,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3623
3793
|
peerJournal.call(peer, live) === recipientJournalKey &&
|
|
3624
3794
|
isDeepStrictEqual(findReference(), reference));
|
|
3625
3795
|
};
|
|
3626
|
-
const current = () =>
|
|
3796
|
+
const current = () => (!admittedOrigin || admittedOrigin()) &&
|
|
3797
|
+
deps.hasWorkspaceLiveRebindAuthority?.() === true &&
|
|
3627
3798
|
deps.isContextActive?.(ctx) === true &&
|
|
3628
3799
|
deps.workspaceRestoreRecipient === ports &&
|
|
3629
3800
|
(followerMode || ports.observeLeaderWork === collector) &&
|
|
@@ -3668,8 +3839,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3668
3839
|
};
|
|
3669
3840
|
const sample = async () => {
|
|
3670
3841
|
assertCanonical();
|
|
3671
|
-
if (cleanupMode
|
|
3672
|
-
|
|
3842
|
+
if (cleanupMode) {
|
|
3843
|
+
const protectedTarget = protection.call(threads, expected);
|
|
3844
|
+
if (protectedTarget !== false)
|
|
3845
|
+
return undefined;
|
|
3846
|
+
}
|
|
3673
3847
|
const target = { ...request.binding.target };
|
|
3674
3848
|
let value;
|
|
3675
3849
|
if (followerMode) {
|
|
@@ -3678,7 +3852,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3678
3852
|
instanceId: recipient.instanceId,
|
|
3679
3853
|
sessionId: recipient.sessionId,
|
|
3680
3854
|
recipientBindingKey: recipientJournalKey,
|
|
3681
|
-
mode: "observe",
|
|
3855
|
+
mode: expected.inputRelease === "held" ? "observe-held" : "observe",
|
|
3682
3856
|
sourceUpdateIds: [...request.source.updateIds],
|
|
3683
3857
|
slot: request.binding.slot,
|
|
3684
3858
|
target: { ...request.target },
|
|
@@ -3720,12 +3894,13 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3720
3894
|
throw new Error("Live-rebind work sample is unavailable or changed target.");
|
|
3721
3895
|
assertCanonical();
|
|
3722
3896
|
if (cleanupMode) {
|
|
3897
|
+
const targetProtected = protection.call(threads, expected);
|
|
3723
3898
|
const prepared = ThreadReconciler.prepareLiveRebindThreadCleanup({
|
|
3724
3899
|
operationId: request.operationId,
|
|
3725
3900
|
oldTarget: request.binding.target,
|
|
3726
3901
|
recipientTarget: request.target,
|
|
3727
3902
|
leaderEpoch: executor.leaderEpoch,
|
|
3728
|
-
targetProtected
|
|
3903
|
+
targetProtected,
|
|
3729
3904
|
work,
|
|
3730
3905
|
});
|
|
3731
3906
|
assertCanonical();
|
|
@@ -3749,32 +3924,27 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3749
3924
|
return undefined;
|
|
3750
3925
|
if (issueMode) {
|
|
3751
3926
|
// 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 () => {
|
|
3927
|
+
const execute = async () => {
|
|
3757
3928
|
const prepared = await sample();
|
|
3758
3929
|
if (!prepared ||
|
|
3759
3930
|
prepared.status !== "prepared" ||
|
|
3760
3931
|
!current() ||
|
|
3761
|
-
protection.call(threads, expected) !== false)
|
|
3932
|
+
protection.call(threads, expected) !== false) {
|
|
3762
3933
|
return { status: "not-ready" };
|
|
3934
|
+
}
|
|
3763
3935
|
const authority = {
|
|
3764
3936
|
executor: structuredClone(executor),
|
|
3765
3937
|
operatorUserId: expected.operatorUserId,
|
|
3766
3938
|
isCurrent: current,
|
|
3767
3939
|
};
|
|
3768
3940
|
const grant = store.advanceLiveRebind(expected, "issue-cleanup", authority);
|
|
3769
|
-
if (!grant)
|
|
3941
|
+
if (!grant) {
|
|
3770
3942
|
return undefined;
|
|
3943
|
+
}
|
|
3771
3944
|
const assertGranted = () => assertCanonical(grant);
|
|
3772
3945
|
const cleanup = await ThreadReconciler.issueLiveRebindThreadCleanup(prepared, {
|
|
3773
3946
|
assertCurrent: assertGranted,
|
|
3774
|
-
deleteTopic: (target) => callApi("deleteForumTopic", {
|
|
3775
|
-
chat_id: target.chatId,
|
|
3776
|
-
message_thread_id: target.threadId,
|
|
3777
|
-
}, { maxAttempts: 1, assertAuthority: assertGranted }),
|
|
3947
|
+
deleteTopic: (target) => callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, assertAuthority: assertGranted }),
|
|
3778
3948
|
classifyFailure: classifyLiveRebindCleanupFailure,
|
|
3779
3949
|
});
|
|
3780
3950
|
let recorded = false;
|
|
@@ -3792,7 +3962,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3792
3962
|
cleanup,
|
|
3793
3963
|
recorded,
|
|
3794
3964
|
};
|
|
3795
|
-
}
|
|
3965
|
+
};
|
|
3966
|
+
// Pre-dispatch calls are private to the chooser callback already holding the same profile admission/gate.
|
|
3967
|
+
return admittedOrigin ? await execute() : await run({
|
|
3968
|
+
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
3969
|
+
operationKind: "workspace.live-rebind-cleanup", scopes: [{ kind: "profile" }],
|
|
3970
|
+
}, execute);
|
|
3796
3971
|
}
|
|
3797
3972
|
const observation = await run({
|
|
3798
3973
|
operationId: `live-${mode}-${randomBytes(16).toString("hex")}`,
|
|
@@ -3826,8 +4001,26 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3826
4001
|
const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
|
|
3827
4002
|
// One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
|
|
3828
4003
|
const liveRebindCleanupChains = new Map();
|
|
3829
|
-
//
|
|
4004
|
+
// One hint slot per existing leader chain; a running attempt retains at most one coalesced recheck.
|
|
3830
4005
|
const liveRebindCleanupWakers = new Map();
|
|
4006
|
+
const captureLiveRebindCleanupScope = (ctx) => {
|
|
4007
|
+
try {
|
|
4008
|
+
const generation = deps.getSessionGeneration?.(), instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.(), store = deps.getWorkspaceRestoreStore?.(), operator = deps.configStore.getAllowedUserId();
|
|
4009
|
+
return () => {
|
|
4010
|
+
try {
|
|
4011
|
+
return deps.isContextActive?.(ctx) === true && deps.getSessionGeneration?.() === generation &&
|
|
4012
|
+
deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
|
|
4013
|
+
deps.getWorkspaceRestoreStore?.() === store && deps.configStore.getAllowedUserId() === operator;
|
|
4014
|
+
}
|
|
4015
|
+
catch {
|
|
4016
|
+
return false;
|
|
4017
|
+
}
|
|
4018
|
+
};
|
|
4019
|
+
}
|
|
4020
|
+
catch {
|
|
4021
|
+
return () => false;
|
|
4022
|
+
}
|
|
4023
|
+
};
|
|
3831
4024
|
/** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
|
|
3832
4025
|
const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
|
|
3833
4026
|
const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
|
|
@@ -3868,24 +4061,53 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3868
4061
|
* a finished outcome or an inactive session stops, and the window ends in `not-issued` so no released row becomes a
|
|
3869
4062
|
* permanent rebinding blocker. Waiting timers never join settlement; only a running attempt does.
|
|
3870
4063
|
*/
|
|
3871
|
-
const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0) => {
|
|
4064
|
+
const scheduleLiveRebindCleanup = (operationId, role, ctx, startedAtMs = Date.now(), attempt = 0, current = captureLiveRebindCleanupScope(ctx)) => {
|
|
3872
4065
|
const pacing = deps.liveRebindCleanupSchedule;
|
|
3873
4066
|
const chain = liveRebindCleanupChains.get(operationId);
|
|
3874
4067
|
if (!pacing ||
|
|
3875
4068
|
(attempt === 0 ? chain !== undefined : chain !== "running") ||
|
|
3876
|
-
|
|
3877
|
-
if (attempt > 0 && chain === "running")
|
|
4069
|
+
!current()) {
|
|
4070
|
+
if (attempt > 0 && chain === "running") {
|
|
3878
4071
|
liveRebindCleanupChains.delete(operationId);
|
|
4072
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
4073
|
+
}
|
|
3879
4074
|
return;
|
|
3880
4075
|
}
|
|
4076
|
+
let pendingWake;
|
|
4077
|
+
const wake = (reason) => {
|
|
4078
|
+
if (!current())
|
|
4079
|
+
return;
|
|
4080
|
+
if (liveRebindCleanupChains.get(operationId) === "running") {
|
|
4081
|
+
pendingWake = reason;
|
|
4082
|
+
return;
|
|
4083
|
+
}
|
|
4084
|
+
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
4085
|
+
return;
|
|
4086
|
+
if (reason === "delivery-drained") {
|
|
4087
|
+
let idle;
|
|
4088
|
+
try {
|
|
4089
|
+
idle = deps.isIdle?.(ctx);
|
|
4090
|
+
}
|
|
4091
|
+
catch { /* A missing idle observation is not a reason to hurry cleanup. */ }
|
|
4092
|
+
if (idle !== true) {
|
|
4093
|
+
// A streamed technical task can drain between deltas while Pi still runs. Keep one hint, not a polling loop.
|
|
4094
|
+
pendingWake = reason;
|
|
4095
|
+
return;
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
clearTimeout(timer);
|
|
4099
|
+
attemptNow();
|
|
4100
|
+
};
|
|
3881
4101
|
const attemptNow = () => {
|
|
3882
|
-
liveRebindCleanupWakers.delete(operationId);
|
|
3883
4102
|
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
3884
4103
|
return;
|
|
4104
|
+
pendingWake = undefined; // This fresh timer/settlement attempt consumes any previously deferred hint.
|
|
3885
4105
|
liveRebindCleanupChains.set(operationId, "running");
|
|
4106
|
+
if (role === "leader")
|
|
4107
|
+
liveRebindCleanupWakers.set(operationId, wake);
|
|
3886
4108
|
let continued = false;
|
|
3887
4109
|
const task = (async () => {
|
|
3888
|
-
if (
|
|
4110
|
+
if (!current())
|
|
3889
4111
|
return;
|
|
3890
4112
|
const intent = deps
|
|
3891
4113
|
.getWorkspaceRestoreStore?.()
|
|
@@ -3897,20 +4119,25 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3897
4119
|
? await issueLiveRebindLeaderCleanup(intent, ctx)
|
|
3898
4120
|
: await issueLiveRebindFollowerCleanup(intent, ctx);
|
|
3899
4121
|
if (result?.status === "finished" ||
|
|
3900
|
-
|
|
4122
|
+
!current())
|
|
3901
4123
|
return;
|
|
3902
|
-
if (Date.now() - startedAtMs >= pacing.windowMs)
|
|
4124
|
+
if (Date.now() - startedAtMs >= pacing.windowMs) {
|
|
3903
4125
|
return terminalizeUnissuedLiveRebindCleanup(operationId, ctx);
|
|
4126
|
+
}
|
|
3904
4127
|
continued = true;
|
|
3905
|
-
scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1);
|
|
4128
|
+
scheduleLiveRebindCleanup(operationId, role, ctx, startedAtMs, attempt + 1, current);
|
|
4129
|
+
if (pendingWake)
|
|
4130
|
+
liveRebindCleanupWakers.get(operationId)?.(pendingWake);
|
|
3906
4131
|
})()
|
|
3907
4132
|
.catch((error) => deps.recordRuntimeEvent?.("routing", error, {
|
|
3908
4133
|
phase: "live-rebind-cleanup-schedule",
|
|
3909
4134
|
}))
|
|
3910
4135
|
.finally(() => {
|
|
3911
4136
|
if (!continued &&
|
|
3912
|
-
liveRebindCleanupChains.get(operationId) === "running")
|
|
4137
|
+
liveRebindCleanupChains.get(operationId) === "running") {
|
|
3913
4138
|
liveRebindCleanupChains.delete(operationId);
|
|
4139
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
4140
|
+
}
|
|
3914
4141
|
});
|
|
3915
4142
|
restoreSettlementTasks.add(task);
|
|
3916
4143
|
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
@@ -3920,19 +4147,32 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3920
4147
|
liveRebindCleanupChains.set(operationId, timer);
|
|
3921
4148
|
// The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
|
|
3922
4149
|
if (role === "leader" && attempt > 0)
|
|
3923
|
-
liveRebindCleanupWakers.set(operationId,
|
|
3924
|
-
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
3925
|
-
return;
|
|
3926
|
-
clearTimeout(timer);
|
|
3927
|
-
attemptNow();
|
|
3928
|
-
});
|
|
4150
|
+
liveRebindCleanupWakers.set(operationId, wake);
|
|
3929
4151
|
};
|
|
3930
|
-
|
|
3931
|
-
const onSessionSettled = (ctx) => {
|
|
4152
|
+
const wakeLiveRebindCleanup = (ctx, reason) => {
|
|
3932
4153
|
if (deps.isContextActive?.(ctx) !== true)
|
|
3933
4154
|
return;
|
|
3934
4155
|
for (const wake of [...liveRebindCleanupWakers.values()])
|
|
3935
|
-
wake();
|
|
4156
|
+
wake(reason);
|
|
4157
|
+
};
|
|
4158
|
+
const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
|
|
4159
|
+
const onDeliverySettled = (ctx) => {
|
|
4160
|
+
wakeLiveRebindCleanup(ctx, "delivery-drained");
|
|
4161
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
4162
|
+
if (!cap?.isCurrent())
|
|
4163
|
+
return;
|
|
4164
|
+
try {
|
|
4165
|
+
for (const entry of cap.store.listTemporaryThreads()) {
|
|
4166
|
+
if (!cap.isCurrent())
|
|
4167
|
+
return;
|
|
4168
|
+
if (entry.phase === "created" && entry.target && !entry.cleanupIssued &&
|
|
4169
|
+
Threads.isTelegramTemporaryThreadFullyResolved(entry))
|
|
4170
|
+
scheduleTemporaryThreadCleanup(entry.target, ctx);
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
catch (error) {
|
|
4174
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-drain" });
|
|
4175
|
+
}
|
|
3936
4176
|
};
|
|
3937
4177
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
3938
4178
|
? async (input) => {
|
|
@@ -4389,6 +4629,92 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4389
4629
|
return false;
|
|
4390
4630
|
}
|
|
4391
4631
|
};
|
|
4632
|
+
/** Publish display copies under the native one-shot grant; positive output is not input/source settlement. */
|
|
4633
|
+
const publishPendingRerouteCopies = async (pending, record, ctx, assertExecutionCurrent, foreign) => {
|
|
4634
|
+
const source = resolvePendingRerouteForwardSource(pending);
|
|
4635
|
+
const target = { ...record.target };
|
|
4636
|
+
const recordIdentity = (value) => value && ({
|
|
4637
|
+
target: value.target, profileKey: value.profileKey, owner: value.owner,
|
|
4638
|
+
instanceId: value.instanceId, slot: value.slot, createdAtMs: value.createdAtMs,
|
|
4639
|
+
});
|
|
4640
|
+
const bindingIdentity = () => deps.threadStore.listWorkspaceBindings()
|
|
4641
|
+
.filter((value) => isDeepStrictEqual(value.target, target))
|
|
4642
|
+
.map((value) => ({ bindingKey: value.bindingKey, sessionId: value.sessionId, sessionKey: value.sessionKey,
|
|
4643
|
+
cwd: value.cwd, workspaceKey: value.workspaceKey, instanceSlot: value.instanceSlot, slot: value.slot,
|
|
4644
|
+
target: value.target }));
|
|
4645
|
+
const expectedRecord = structuredClone(recordIdentity(record));
|
|
4646
|
+
const expectedBindings = structuredClone(bindingIdentity());
|
|
4647
|
+
const ownership = deps.getTargetOwnership?.(target);
|
|
4648
|
+
if (!source || !deps.callApi || expectedBindings.length > 1 ||
|
|
4649
|
+
(foreign && (!ownership || ownership.instanceId !== record.instanceId ||
|
|
4650
|
+
!ownership.ownerGeneration || !ownership.recipientBindingKey)))
|
|
4651
|
+
return;
|
|
4652
|
+
const expectedOwnership = ownership && structuredClone(ownership);
|
|
4653
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => {
|
|
4654
|
+
try {
|
|
4655
|
+
assertExecutionCurrent();
|
|
4656
|
+
const current = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.())
|
|
4657
|
+
.find((value) => isDeepStrictEqual(value.target, target));
|
|
4658
|
+
return isDeepStrictEqual(recordIdentity(current), expectedRecord) &&
|
|
4659
|
+
isDeepStrictEqual(bindingIdentity(), expectedBindings) &&
|
|
4660
|
+
(!foreign || Bus.isTelegramBusForwardOwnershipCurrent(expectedOwnership, deps.getTargetOwnership?.(target)));
|
|
4661
|
+
}
|
|
4662
|
+
catch {
|
|
4663
|
+
return false;
|
|
4664
|
+
}
|
|
4665
|
+
});
|
|
4666
|
+
if (!cap?.isCurrent())
|
|
4667
|
+
return;
|
|
4668
|
+
const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
4669
|
+
if (!temporary || !issueTemporaryThreadForward(pending, ctx))
|
|
4670
|
+
return;
|
|
4671
|
+
pending.phase = { kind: "forward-unknown" };
|
|
4672
|
+
const assertRecipientCurrent = () => {
|
|
4673
|
+
assertExecutionCurrent();
|
|
4674
|
+
if (!cap.isCurrent())
|
|
4675
|
+
throw new Error("Reroute publication recipient authority changed.");
|
|
4676
|
+
};
|
|
4677
|
+
const assertPublicationCurrent = () => {
|
|
4678
|
+
assertRecipientCurrent();
|
|
4679
|
+
const entry = cap.store.listTemporaryThreads().find((value) => value.token === temporary.token);
|
|
4680
|
+
const ids = [...source.sourceUpdateIds].sort((a, b) => a - b);
|
|
4681
|
+
if (!entry || entry.phase !== "created" || entry.cleanupIssued ||
|
|
4682
|
+
entry.operatorUserId !== cap.operatorUserId ||
|
|
4683
|
+
!isDeepStrictEqual(entry.executor, cap.authority.executor) ||
|
|
4684
|
+
!isDeepStrictEqual(entry.target, pending.sourceTarget) ||
|
|
4685
|
+
!entry.forwardedInputs?.some((input) => input.journalBindingKey === cap.journalBindingKey &&
|
|
4686
|
+
isDeepStrictEqual(input.updateIds, ids)) ||
|
|
4687
|
+
!isDeepStrictEqual(resolvePendingRerouteForwardSource(pending), source))
|
|
4688
|
+
throw new Error("Reroute publication source authority changed.");
|
|
4689
|
+
};
|
|
4690
|
+
try {
|
|
4691
|
+
assertPublicationCurrent();
|
|
4692
|
+
// Bot API requires ascending source IDs. Preserve the source-UID association even if copy IDs reorder them.
|
|
4693
|
+
const order = source.messageIds.map((_, index) => index)
|
|
4694
|
+
.sort((a, b) => source.messageIds[a] - source.messageIds[b]);
|
|
4695
|
+
const messageIds = order.map((index) => source.messageIds[index]);
|
|
4696
|
+
const body = { chat_id: target.chatId, message_thread_id: target.threadId,
|
|
4697
|
+
from_chat_id: source.chatId, disable_notification: true };
|
|
4698
|
+
const options = { maxAttempts: 1, retrySafety: "non-idempotent", assertAuthority: assertPublicationCurrent };
|
|
4699
|
+
const copies = messageIds.length === 1
|
|
4700
|
+
? [await deps.callApi("forwardMessage", { ...body, message_id: messageIds[0] }, options)]
|
|
4701
|
+
: await deps.callApi("forwardMessages", { ...body, message_ids: messageIds }, options);
|
|
4702
|
+
assertPublicationCurrent();
|
|
4703
|
+
const copyIds = Array.isArray(copies) ? copies.map((copy) => copy?.message_id) : [];
|
|
4704
|
+
if (copyIds.length !== messageIds.length ||
|
|
4705
|
+
!copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
|
|
4706
|
+
new Set(copyIds).size !== copyIds.length)
|
|
4707
|
+
return;
|
|
4708
|
+
pending.rerouteCopies = { target, bySourceUpdateId: new Map(order.map((index, position) => [source.sourceUpdateIds[index], copyIds[position]])) };
|
|
4709
|
+
pending.phase = { kind: "selected" };
|
|
4710
|
+
return assertRecipientCurrent;
|
|
4711
|
+
}
|
|
4712
|
+
catch (error) {
|
|
4713
|
+
if (cap.isCurrent())
|
|
4714
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-destination-forward" });
|
|
4715
|
+
return;
|
|
4716
|
+
}
|
|
4717
|
+
};
|
|
4392
4718
|
const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent, recordForwardAcceptance) => {
|
|
4393
4719
|
assertExecutionCurrent?.();
|
|
4394
4720
|
const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
|
|
@@ -4402,11 +4728,14 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4402
4728
|
!liveOwnership.recipientBindingKey)
|
|
4403
4729
|
return false;
|
|
4404
4730
|
const ownership = structuredClone(liveOwnership);
|
|
4405
|
-
const
|
|
4731
|
+
const copies = pending.rerouteCopies;
|
|
4732
|
+
const published = !!copies && isDeepStrictEqual(copies.target, target);
|
|
4733
|
+
const messages = cloneTelegramMessagesForThread(pending.messages, threadId, published ? copies.bySourceUpdateId : undefined);
|
|
4406
4734
|
// Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
|
|
4407
4735
|
// even after restart. Any refusal or publication failure sends nothing and keeps the input held.
|
|
4408
4736
|
if (!recordForwardAcceptance &&
|
|
4409
4737
|
isTemporaryReroute(pending) &&
|
|
4738
|
+
!published &&
|
|
4410
4739
|
!issueTemporaryThreadForward(pending, ctx)) {
|
|
4411
4740
|
if (isTemporaryThreadForwardIssued(pending))
|
|
4412
4741
|
pending.phase = { kind: "forward-unknown" };
|
|
@@ -4418,6 +4747,10 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4418
4747
|
message,
|
|
4419
4748
|
ownership,
|
|
4420
4749
|
ctx,
|
|
4750
|
+
assertAuthority() {
|
|
4751
|
+
assertExecutionCurrent?.();
|
|
4752
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
4753
|
+
},
|
|
4421
4754
|
})));
|
|
4422
4755
|
assertExecutionCurrent?.();
|
|
4423
4756
|
if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership))
|
|
@@ -4576,6 +4909,17 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4576
4909
|
if (retry)
|
|
4577
4910
|
restoreSettlementTasks.add(task);
|
|
4578
4911
|
runTemporaryThreadCleanup(token, cap, reconcileExpiry)
|
|
4912
|
+
.then(() => {
|
|
4913
|
+
if (!cap.isCurrent())
|
|
4914
|
+
return;
|
|
4915
|
+
const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
|
|
4916
|
+
// A follower ACK can precede recipient receipt disposal. Reobserve a protected, unissued tab;
|
|
4917
|
+
// elapsed time never grants deletion, and an issued/unknown delete can never enter this path.
|
|
4918
|
+
if (retained?.phase === "created" && !retained.cleanupIssued &&
|
|
4919
|
+
Threads.isTelegramTemporaryThreadFullyResolved(retained) &&
|
|
4920
|
+
cap.store.inspectTemporaryThreadTarget(retained, cap.authority)?.kind === "temporary" && cap.isCurrent())
|
|
4921
|
+
queueTemporaryThreadCleanup(token, cap, 1000, reconcileExpiry, true);
|
|
4922
|
+
})
|
|
4579
4923
|
.catch((error) => {
|
|
4580
4924
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
4581
4925
|
phase: "temporary-thread-cleanup",
|
|
@@ -5101,7 +5445,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5101
5445
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
5102
5446
|
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
|
|
5103
5447
|
continue;
|
|
5104
|
-
movedIntoTab ||= pending.allTabCopiesForwarded
|
|
5448
|
+
movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
|
|
5105
5449
|
removePendingReroute(id);
|
|
5106
5450
|
if (signal.aborted ||
|
|
5107
5451
|
deps.isContextActive?.(ctx) !== true ||
|
|
@@ -5837,19 +6181,21 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5837
6181
|
pending.liveRebind ??= selection;
|
|
5838
6182
|
if (!(await selectPendingDestination()))
|
|
5839
6183
|
return true;
|
|
6184
|
+
const operationId = selection.coordinator.operationId;
|
|
5840
6185
|
if (current())
|
|
5841
6186
|
outcome = await selection.coordinator.advance();
|
|
5842
6187
|
// Cleanup readiness follows the canonical released row, not source disposal: a detached command ACK may
|
|
5843
6188
|
// still be pending while every attempt samples current work afresh.
|
|
5844
|
-
const operationId = selection.coordinator.operationId;
|
|
5845
6189
|
const row = outcome === "protected"
|
|
5846
6190
|
? undefined
|
|
5847
6191
|
: deps
|
|
5848
6192
|
.getWorkspaceRestoreStore?.()
|
|
5849
6193
|
?.listLiveRebindings()
|
|
5850
6194
|
.find((value) => value.request.operationId === operationId);
|
|
5851
|
-
if (row?.phase === "released" && row.cleanup === undefined
|
|
6195
|
+
if (row?.phase === "released" && row.cleanup === undefined &&
|
|
6196
|
+
(row.inputRelease === undefined || row.inputRelease === "accepted")) {
|
|
5852
6197
|
scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
|
|
6198
|
+
}
|
|
5853
6199
|
if (outcome === "released")
|
|
5854
6200
|
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5855
6201
|
}
|
|
@@ -5866,28 +6212,46 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5866
6212
|
await deps.answerCallbackQuery(query.id, "Live rebind is unconfirmed; this choice never replays issued input");
|
|
5867
6213
|
return true;
|
|
5868
6214
|
}
|
|
6215
|
+
const cleanupRow = deps.getWorkspaceRestoreStore?.()?.listLiveRebindings().find(value => value.request.operationId === selection.coordinator?.operationId);
|
|
6216
|
+
const cleanupResult = cleanupRow?.inputRelease && cleanupRow.cleanup !== undefined
|
|
6217
|
+
? cleanupRow.cleanup === "confirmed" ? "old-Thread removal confirmed"
|
|
6218
|
+
: cleanupRow.cleanup === "unknown" || cleanupRow.cleanup === "issued" ? "old-Thread removal unconfirmed; deletion will not repeat"
|
|
6219
|
+
: cleanupRow.cleanup === "failed" ? "old-Thread removal failed" : "old-Thread removal was not issued"
|
|
6220
|
+
: undefined;
|
|
6221
|
+
const commandDisposed = !!(selection.extensionCommand && selection.extensionKind !== "generated-prompt" || selection.nameCommand ||
|
|
6222
|
+
selection.confirmationCommand || selection.helpCommand || selection.stopCommand || selection.nextCommand ||
|
|
6223
|
+
selection.menuCommand || selection.abortCommand || selection.coordinator?.selectedCommandReference());
|
|
5869
6224
|
// 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
|
-
|
|
6225
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, cleanupResult
|
|
6226
|
+
? commandDisposed ? `Command source disposal confirmed; ${cleanupResult}; delivery and follow-on effects remain independent`
|
|
6227
|
+
: `Input queued in the rebound Thread; ${cleanupResult}`
|
|
6228
|
+
: selection.extensionKind === "generated-prompt"
|
|
6229
|
+
? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
|
|
6230
|
+
: selection.extensionCommand
|
|
6231
|
+
? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
|
|
6232
|
+
: selection.nameCommand
|
|
6233
|
+
? selection.nameCommand.args.trim()
|
|
6234
|
+
? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
|
|
6235
|
+
: "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
|
|
6236
|
+
: selection.confirmationCommand
|
|
6237
|
+
? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
|
|
6238
|
+
: selection.helpCommand
|
|
6239
|
+
? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
|
|
6240
|
+
: selection.stopCommand
|
|
6241
|
+
? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
|
|
6242
|
+
: selection.nextCommand
|
|
6243
|
+
? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
|
|
6244
|
+
: selection.menuCommand || selection.abortCommand
|
|
6245
|
+
? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
|
|
6246
|
+
: selection.coordinator?.selectedCommandReference()
|
|
6247
|
+
? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
|
|
6248
|
+
: "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
|
|
6249
|
+
return true;
|
|
6250
|
+
}
|
|
6251
|
+
if (!parsed.useNewSlot &&
|
|
6252
|
+
isTemporaryReroute(pending) &&
|
|
6253
|
+
!resolvePendingRerouteForwardSource(pending)) {
|
|
6254
|
+
await deps.answerCallbackQuery(query.id, "Source copy unavailable; not routing");
|
|
5891
6255
|
return true;
|
|
5892
6256
|
}
|
|
5893
6257
|
if (!(await selectPendingDestination()))
|
|
@@ -5973,10 +6337,22 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5973
6337
|
await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
|
|
5974
6338
|
return true;
|
|
5975
6339
|
}
|
|
6340
|
+
let assertRerouteCurrent = assertExecutionCurrent;
|
|
6341
|
+
if (isTemporaryReroute(pending)) {
|
|
6342
|
+
const confirmed = await publishPendingRerouteCopies(pending, record, ctx, assertExecutionCurrent, !!record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord);
|
|
6343
|
+
if (!confirmed) {
|
|
6344
|
+
await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
|
|
6345
|
+
? "Publication unconfirmed; not resending"
|
|
6346
|
+
: "Publication unavailable; input kept");
|
|
6347
|
+
return true;
|
|
6348
|
+
}
|
|
6349
|
+
assertRerouteCurrent = confirmed;
|
|
6350
|
+
}
|
|
6351
|
+
assertRerouteCurrent();
|
|
5976
6352
|
if (record.instanceId &&
|
|
5977
6353
|
record.instanceId !== currentInstanceId &&
|
|
5978
6354
|
!isCurrentLeaderRecord) {
|
|
5979
|
-
const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx,
|
|
6355
|
+
const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertRerouteCurrent);
|
|
5980
6356
|
if (!allForwarded) {
|
|
5981
6357
|
await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
|
|
5982
6358
|
? "Delivery unconfirmed; not resending"
|
|
@@ -5987,11 +6363,16 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5987
6363
|
}
|
|
5988
6364
|
// Local handlers and queue admission consume the same one-time group grant as follower forwarding.
|
|
5989
6365
|
if (isTemporaryReroute(pending) &&
|
|
6366
|
+
!pending.rerouteCopies &&
|
|
5990
6367
|
!issueTemporaryThreadForward(pending, ctx)) {
|
|
5991
6368
|
await deps.answerCallbackQuery(query.id, "Delivery unconfirmed; not resending");
|
|
5992
6369
|
return true;
|
|
5993
6370
|
}
|
|
5994
|
-
|
|
6371
|
+
const localMessages = pending.rerouteCopies
|
|
6372
|
+
? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
|
|
6373
|
+
: reroutedMessages;
|
|
6374
|
+
await dispatchPendingRerouteMessages(pending, localMessages, ctx);
|
|
6375
|
+
assertRerouteCurrent();
|
|
5995
6376
|
pending.messages = [];
|
|
5996
6377
|
return completeRoutedReroute();
|
|
5997
6378
|
};
|
|
@@ -7023,23 +7404,34 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7023
7404
|
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
7024
7405
|
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
7025
7406
|
*/
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7407
|
+
/**
|
|
7408
|
+
* Opens (or adopts) the routing tab for input sent from All. One text message may carry a command; anything else,
|
|
7409
|
+
* a voice note, a file or a coalesced album, is held untouched as one prompt.
|
|
7410
|
+
*/
|
|
7411
|
+
const sendAllTabTemporaryInputChooser = async (command, commandText, messages, ctx) => {
|
|
7412
|
+
const message = messages[0];
|
|
7413
|
+
if (!message)
|
|
7414
|
+
return false;
|
|
7415
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds([
|
|
7416
|
+
...messages,
|
|
7417
|
+
]).sort((a, b) => a - b);
|
|
7418
|
+
const updateId = updateIds[0];
|
|
7419
|
+
const group = { journalBindingKey: "", updateIds };
|
|
7030
7420
|
const execution = Updates.getTelegramUpdateExecutionFence(message);
|
|
7031
7421
|
const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
|
|
7032
7422
|
if (!cap ||
|
|
7033
7423
|
!deps.runWorkspaceOperation ||
|
|
7034
7424
|
!deps.callApi ||
|
|
7035
7425
|
updateId === undefined ||
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7426
|
+
updateIds.length !== messages.length ||
|
|
7427
|
+
messages.some((held) => held.chat.type !== "private" ||
|
|
7428
|
+
held.chat.id !== cap.operatorUserId ||
|
|
7429
|
+
held.from?.id !== cap.operatorUserId) ||
|
|
7039
7430
|
!cap.isCurrent())
|
|
7040
7431
|
return false;
|
|
7041
7432
|
const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
|
|
7042
7433
|
const source = { journalBindingKey, updateId };
|
|
7434
|
+
group.journalBindingKey = journalBindingKey;
|
|
7043
7435
|
const find = () => store
|
|
7044
7436
|
.listTemporaryThreads()
|
|
7045
7437
|
.find((entry) => entry.source.journalBindingKey === journalBindingKey &&
|
|
@@ -7065,7 +7457,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7065
7457
|
// the input itself to All: adopt that tab rather than leaving it behind next to a second one.
|
|
7066
7458
|
const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
|
|
7067
7459
|
if (native) {
|
|
7068
|
-
const adopted = store.registerImplicitTemporaryThread(
|
|
7460
|
+
const adopted = store.registerImplicitTemporaryThread(group, native.implicit.target, randomBytes(16).toString("hex"), {
|
|
7069
7461
|
...authority,
|
|
7070
7462
|
isCurrent: () => current() &&
|
|
7071
7463
|
implicitThreadCreations.get(native.key) === native.implicit,
|
|
@@ -7090,7 +7482,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7090
7482
|
return;
|
|
7091
7483
|
}
|
|
7092
7484
|
}
|
|
7093
|
-
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
|
|
7485
|
+
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority, group);
|
|
7094
7486
|
entry = reservation?.entry;
|
|
7095
7487
|
if (!reservation?.reserved || !current())
|
|
7096
7488
|
return;
|
|
@@ -7120,26 +7512,33 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7120
7512
|
});
|
|
7121
7513
|
if (!current() || !entry)
|
|
7122
7514
|
throw new Error("Temporary Thread authority changed; the input remains retryable.");
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7515
|
+
// Only a lone text message is rewritten for command detection; media keeps its caption and payload.
|
|
7516
|
+
const held = messages.length === 1 && typeof message.text === "string"
|
|
7517
|
+
? [
|
|
7518
|
+
Updates.carryTelegramUpdateExecutionFence(message, {
|
|
7519
|
+
...message,
|
|
7520
|
+
text: commandText,
|
|
7521
|
+
caption: undefined,
|
|
7522
|
+
}),
|
|
7523
|
+
]
|
|
7524
|
+
: [...messages];
|
|
7128
7525
|
if (entry.phase !== "created" || !entry.target) {
|
|
7129
|
-
|
|
7130
|
-
|
|
7526
|
+
for (const input of held)
|
|
7527
|
+
Updates.reportTelegramUpdateDeferred(input);
|
|
7528
|
+
deps.recordRuntimeEvent?.("routing", new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR), { phase: "temporary-thread-unconfirmed" });
|
|
7131
7529
|
return true;
|
|
7132
7530
|
}
|
|
7133
7531
|
const target = entry.target;
|
|
7134
7532
|
const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
|
|
7135
|
-
const rerouteId = storePendingUnboundReroute(
|
|
7533
|
+
const rerouteId = storePendingUnboundReroute(held, command ? "command" : "prompt", target);
|
|
7136
7534
|
const pending = pendingUnboundReroutes.get(rerouteId);
|
|
7137
7535
|
pending.temporaryThread = entry;
|
|
7138
|
-
pending.allTabCopies = getTelegramAllTabSourceMessageIds(
|
|
7139
|
-
|
|
7536
|
+
pending.allTabCopies = getTelegramAllTabSourceMessageIds(messages, target.chatId);
|
|
7537
|
+
for (const input of held)
|
|
7538
|
+
Updates.reportTelegramUpdateDeferred(input);
|
|
7140
7539
|
// Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
|
|
7141
7540
|
if (deps.editInteractiveMessage &&
|
|
7142
|
-
Updates.supportsTelegramDeferredAbandonment(
|
|
7541
|
+
held.every((input) => Updates.supportsTelegramDeferredAbandonment(input, journalBindingKey))) {
|
|
7143
7542
|
pending.abandonment = {
|
|
7144
7543
|
ownerUserId: operatorUserId,
|
|
7145
7544
|
journalBindingKey,
|
|
@@ -7150,6 +7549,14 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7150
7549
|
const tabCopyId = openedNow
|
|
7151
7550
|
? await moveAllTabSourceIntoTab(pending, target, current)
|
|
7152
7551
|
: undefined;
|
|
7552
|
+
if (!current()) {
|
|
7553
|
+
removePendingReroute(rerouteId);
|
|
7554
|
+
throw new Error("Temporary Thread source presentation authority changed.");
|
|
7555
|
+
}
|
|
7556
|
+
if (openedNow &&
|
|
7557
|
+
!pending.allTabCopiesForwarded &&
|
|
7558
|
+
pending.allTabCopies.length === held.length)
|
|
7559
|
+
pending.allTabSourceRetained = true;
|
|
7153
7560
|
try {
|
|
7154
7561
|
if (!deps.sendInteractiveMessage)
|
|
7155
7562
|
throw new Error("Temporary Thread chooser publication is unavailable.");
|
|
@@ -7168,72 +7575,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7168
7575
|
}
|
|
7169
7576
|
return true;
|
|
7170
7577
|
};
|
|
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;
|
|
7578
|
+
/** A coalesced album from All requires one routing tab; failure cannot answer in All or discard its source. */
|
|
7579
|
+
const routeAllTabAlbum = async (messages, ctx) => {
|
|
7580
|
+
if (await sendAllTabTemporaryInputChooser(undefined, "", messages, ctx))
|
|
7581
|
+
return;
|
|
7582
|
+
throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
|
|
7237
7583
|
};
|
|
7238
7584
|
const dispatchCommandOrPrompt = Commands.createTelegramCommandOrPromptDispatcher({
|
|
7239
7585
|
extractRawText: Media.extractFirstTelegramMessageText,
|
|
@@ -7680,28 +8026,39 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7680
8026
|
.some((entry) => entry.source.updateId === sourceUpdateId &&
|
|
7681
8027
|
entry.source.journalBindingKey ===
|
|
7682
8028
|
deps.getAdmissionJournalBinding?.()) === true;
|
|
8029
|
+
const needsThread = deps.threadStore.getBotState().threadMode === "enabled" || bindings.length > 0 || presented;
|
|
7683
8030
|
// Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
|
|
7684
8031
|
if (command &&
|
|
7685
8032
|
command.name !== "thread" &&
|
|
7686
8033
|
!presented &&
|
|
7687
8034
|
isTelegramAllTabCommandExpired(message))
|
|
7688
8035
|
return;
|
|
7689
|
-
if (
|
|
7690
|
-
(bindings.length > 0 || presented) &&
|
|
8036
|
+
if (needsThread &&
|
|
7691
8037
|
typeof message.text === "string" &&
|
|
7692
8038
|
message.text.trim() &&
|
|
7693
|
-
(await sendAllTabTemporaryInputChooser(command, text, message, ctx)))
|
|
8039
|
+
(await sendAllTabTemporaryInputChooser(command, text, [message], ctx)))
|
|
7694
8040
|
return;
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
8041
|
+
// Voice notes, files, stickers and albums from All get the same routing tab as text, whether or not the
|
|
8042
|
+
// client opened a tab for them; an album first gathers into one input so it opens one tab.
|
|
8043
|
+
if (!command &&
|
|
8044
|
+
needsThread &&
|
|
8045
|
+
typeof message.text !== "string" &&
|
|
8046
|
+
Media.hasTelegramMessagesPromptContent([message])) {
|
|
8047
|
+
if (message.media_group_id) {
|
|
8048
|
+
deps.mediaGroupRuntime.queueMessage({
|
|
8049
|
+
message: message,
|
|
8050
|
+
context: ctx,
|
|
8051
|
+
dispatchMessages: (messages, queuedCtx) => routeAllTabAlbum(messages, queuedCtx ?? ctx),
|
|
8052
|
+
});
|
|
8053
|
+
Updates.reportTelegramUpdateDeferred(message);
|
|
7699
8054
|
return;
|
|
7700
8055
|
}
|
|
8056
|
+
if (await sendAllTabTemporaryInputChooser(undefined, text, [message], ctx))
|
|
8057
|
+
return;
|
|
7701
8058
|
}
|
|
7702
|
-
if (
|
|
8059
|
+
if (needsThread) {
|
|
7703
8060
|
const probeTarget = bindings[0]?.target;
|
|
7704
|
-
if (probeTarget?.threadId && deps.callApi) {
|
|
8061
|
+
if (!command && !text.startsWith("/") && probeTarget?.threadId && deps.callApi) {
|
|
7705
8062
|
try {
|
|
7706
8063
|
await deps.callApi("sendChatAction", {
|
|
7707
8064
|
chat_id: probeTarget.chatId,
|
|
@@ -7710,8 +8067,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7710
8067
|
});
|
|
7711
8068
|
}
|
|
7712
8069
|
catch (error) {
|
|
7713
|
-
if (Threads.isTelegramTopicModeUnavailableError(error)
|
|
7714
|
-
Threads.isTelegramTopicTargetStaleError(error)) {
|
|
8070
|
+
if (Threads.isTelegramTopicModeUnavailableError(error)) {
|
|
7715
8071
|
deps.threadStore.setBotState({
|
|
7716
8072
|
threadMode: "disabled",
|
|
7717
8073
|
updatedAtMs: Date.now(),
|
|
@@ -7729,8 +8085,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7729
8085
|
});
|
|
7730
8086
|
}
|
|
7731
8087
|
}
|
|
7732
|
-
|
|
7733
|
-
return;
|
|
8088
|
+
throw new Error(TELEGRAM_THREADED_ROUTING_UNAVAILABLE_ERROR);
|
|
7734
8089
|
}
|
|
7735
8090
|
}
|
|
7736
8091
|
await textDispatch.handleMessage(message, ctx);
|
|
@@ -8032,6 +8387,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
8032
8387
|
prepareHeldCommand: commandHandler.prepareHeldCommand,
|
|
8033
8388
|
canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
|
|
8034
8389
|
onSessionSettled,
|
|
8390
|
+
onDeliverySettled,
|
|
8035
8391
|
observeLiveRebindLeaderWork,
|
|
8036
8392
|
prepareLiveRebindLeaderCleanup,
|
|
8037
8393
|
prepareLiveRebindFollowerCleanup,
|