@llblab/pi-telegram 0.55.0 → 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/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/dist/lib/bus-follower.d.ts +1 -0
- package/dist/lib/bus.d.ts +2 -0
- package/dist/lib/bus.js +11 -6
- package/dist/lib/routing.d.ts +1 -1
- package/dist/lib/routing.js +261 -29
- package/dist/lib/turns.d.ts +4 -0
- package/dist/lib/turns.js +6 -0
- package/dist/lib/updates.d.ts +2 -0
- package/dist/package.json +1 -1
- package/docs/architecture.md +3 -1
- package/docs/multi-instance-bus.md +2 -0
- package/lib/bus.ts +13 -3
- package/lib/routing.ts +299 -29
- package/lib/turns.ts +7 -0
- package/lib/updates.ts +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.55.1: Visible Reroute and follower cleanup
|
|
8
|
+
|
|
9
|
+
- `Reroute visibility`: Temporary Reroute forwards text, voice, files and albums into the selected Thread before local/follower admission, then anchors replies to confirmed copies. Source/recipient/generation fences reach HTTP and IPC write/ACK boundaries; one native grant prevents replay after partial/lost ACKs. Unknown cold All copies stay held with Cancel; real native-source recovery remains supported. Journal originals, receipt/group cleanup protections, Restore and cleanup timing are unchanged.
|
|
10
|
+
- `Reroute cleanup`: A follower ACK no longer strands a resolved Routing tab when recipient queue custody blocks its first cleanup check. Metadata-only rechecks and existing delivery-drain wakes wait for fresh source/receipt protection to clear, including retained completed membership after runtime replacement. New input, live bindings, authority loss and unverifiable evidence remain protective; issued or unknown deletion never repeats.
|
|
11
|
+
|
|
7
12
|
## 0.55.0: Native Restore and fenced lifecycles
|
|
8
13
|
|
|
9
14
|
- `Restore cleanup`: Live Restore binds the new tab and decides old-tab cleanup before native leader/follower inputs run. Protected work defers cleanup; session replacement carries no deletion grant, and unknown deletion never repeats. Follower execution targets the rebound tab without rewriting journal originals or quoting the former tab. Exact donor ACKs seal routing clocks and retire the menu. Reader/execution capabilities, source custody and detached replies stay separate; busy hints coalesce.
|
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ Enable the optional capabilities the bridge needs in the [@BotFather](https://t.
|
|
|
143
143
|
| Generative Apps | Install or explicitly replace a reviewed `.mjs` application whose generated JSON button view may mix direct `app::method` actions with ordinary model prompts. | Repeated games, controls, tutors, and adapters compile routine interaction without losing selective model interpretation, explanation, or adaptation. |
|
|
144
144
|
| Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
|
|
145
145
|
| Threaded Mode | Run one leader plus visible follower Pi instances through named private-chat threads. | One bot can host a local multi-instance Pi organism without hidden process spawning. |
|
|
146
|
-
| Reroute and restore | Give text, voice, files and albums sent from `All`, plus unknown threads, explicit reroute and replace/restore choices. |
|
|
146
|
+
| Reroute and restore | Give text, voice, files and albums sent from `All`, plus unknown threads, explicit reroute and replace/restore choices. | Reroute from a routing tab forwards the prompt into the selected Thread before agent admission and anchors replies there; cleanup still requires source ACKs. Restore rebinds the tab and may remove only the replaced old tab after positive cleanup proofs. Blocked cleanup preserves the relocation and protected work. |
|
|
147
147
|
| Extension sections | Add menu sections, commands, status rows, settings, callbacks, and delivery helpers from companion extensions. | `pi-telegram` becomes a platform surface for other Pi extensions. |
|
|
148
148
|
| Runtime diagnostics | Use `/telegram-status` and recent runtime events for connection, role, negotiated bus protocol/build/capabilities, separate polling and inbound-worker progress, journal depth, local/foreign queue ownership, automatic retry waits, transport, and failures. | Compatible build skew, foreign semantic authority, a healthy poller, durable backoff and an infrastructure-blocked worker remain distinguishable without hidden logs. |
|
|
149
149
|
| Safety and ownership | Pair one owner, lock transport, scope targets, and reject fake terminal behavior. | Remote access remains explicit, bounded, and understandable. |
|
|
@@ -388,6 +388,7 @@ export declare function createTelegramBusFollowerClientRuntime<TContext, TReacti
|
|
|
388
388
|
message: TMessage;
|
|
389
389
|
ownership: TelegramBusForwardOwnership;
|
|
390
390
|
ctx: TContext;
|
|
391
|
+
assertAuthority?: () => void;
|
|
391
392
|
}) => Promise<import("./bus.ts").TelegramBusForeignUpdateSettlement>;
|
|
392
393
|
forwardEditedMessage: (input: {
|
|
393
394
|
message: TMessage;
|
package/dist/lib/bus.d.ts
CHANGED
|
@@ -670,6 +670,8 @@ export declare function createTelegramBusForeignOwnedUpdateForwarder<TContext, T
|
|
|
670
670
|
message: TMessage;
|
|
671
671
|
ownership: TelegramBusForwardOwnership;
|
|
672
672
|
ctx: TContext;
|
|
673
|
+
/** Process-local source/recipient lifetime; never part of the wire envelope. */
|
|
674
|
+
assertAuthority?: () => void;
|
|
673
675
|
}) => Promise<TelegramBusForeignUpdateSettlement>;
|
|
674
676
|
forwardEditedMessage: (input: {
|
|
675
677
|
message: TMessage;
|
package/dist/lib/bus.js
CHANGED
|
@@ -971,7 +971,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
|
|
|
971
971
|
sourceReference,
|
|
972
972
|
};
|
|
973
973
|
};
|
|
974
|
-
const send = async (envelope, ownership) => {
|
|
974
|
+
const send = async (envelope, ownership, assertAuthority) => {
|
|
975
975
|
if (deps.validateForwardOwnership &&
|
|
976
976
|
!deps.validateForwardOwnership(ownership))
|
|
977
977
|
return reject({
|
|
@@ -991,10 +991,15 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
|
|
|
991
991
|
socketPath,
|
|
992
992
|
envelope,
|
|
993
993
|
timeoutMs: deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CONTROL_TIMEOUT_MS,
|
|
994
|
-
retry: getTelegramBusTransportRetryPolicy({
|
|
994
|
+
retry: assertAuthority ? { attempts: 1, delayMs: 0 } : getTelegramBusTransportRetryPolicy({
|
|
995
995
|
endpoint: socketPath,
|
|
996
996
|
operation: "operation",
|
|
997
997
|
}),
|
|
998
|
+
...(assertAuthority || deps.validateForwardOwnership ? { assertAuthority() {
|
|
999
|
+
assertAuthority?.();
|
|
1000
|
+
if (deps.validateForwardOwnership && !deps.validateForwardOwnership(ownership))
|
|
1001
|
+
throw new Error("Telegram follower forwarding ownership changed.");
|
|
1002
|
+
} } : {}),
|
|
998
1003
|
});
|
|
999
1004
|
}
|
|
1000
1005
|
catch (error) {
|
|
@@ -1066,7 +1071,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
|
|
|
1066
1071
|
return { status: "accepted", delivery: envelope.delivery };
|
|
1067
1072
|
};
|
|
1068
1073
|
// A recipient already holding the source reference is only woken; it never receives a second payload copy.
|
|
1069
|
-
const forward = (kind, value, ownership, build) => {
|
|
1074
|
+
const forward = (kind, value, ownership, build, assertAuthority) => {
|
|
1070
1075
|
const prepared = prepare(kind, value, ownership);
|
|
1071
1076
|
if ("settlement" in prepared)
|
|
1072
1077
|
return Promise.resolve(prepared.settlement);
|
|
@@ -1078,7 +1083,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
|
|
|
1078
1083
|
};
|
|
1079
1084
|
return send(prepared.sourceReference
|
|
1080
1085
|
? { kind: "leader.wakeInputCustody", ...base, sentAtMs: getNowMs() }
|
|
1081
|
-
: build(base, getNowMs()), ownership);
|
|
1086
|
+
: build(base, getNowMs()), ownership, assertAuthority);
|
|
1082
1087
|
};
|
|
1083
1088
|
return {
|
|
1084
1089
|
forwardCallback: ({ query, ownership }) => forward("leader.forwardCallback", query, ownership, (base, sentAtMs) => ({
|
|
@@ -1093,7 +1098,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
|
|
|
1093
1098
|
reactionUpdate,
|
|
1094
1099
|
sentAtMs,
|
|
1095
1100
|
})),
|
|
1096
|
-
forwardMessage: ({ message, ownership }) => forward("leader.forwardMessage", message, ownership, (base, sentAtMs) => {
|
|
1101
|
+
forwardMessage: ({ message, ownership, assertAuthority }) => forward("leader.forwardMessage", message, ownership, (base, sentAtMs) => {
|
|
1097
1102
|
const forwardCommentBatchPosition = deps.getForwardCommentBatchPosition?.(message);
|
|
1098
1103
|
return {
|
|
1099
1104
|
kind: "leader.forwardMessage",
|
|
@@ -1104,7 +1109,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder(deps) {
|
|
|
1104
1109
|
: {}),
|
|
1105
1110
|
sentAtMs,
|
|
1106
1111
|
};
|
|
1107
|
-
}),
|
|
1112
|
+
}, assertAuthority),
|
|
1108
1113
|
forwardEditedMessage: ({ message, ownership }) => forward("leader.forwardEditedMessage", message, ownership, (base, sentAtMs) => ({
|
|
1109
1114
|
kind: "leader.forwardEditedMessage",
|
|
1110
1115
|
...base,
|
package/dist/lib/routing.d.ts
CHANGED
|
@@ -209,7 +209,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
209
209
|
canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
|
|
210
210
|
/** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
|
|
211
211
|
inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs?: readonly Threads.TelegramTemporaryThreadInput[]) => readonly number[] | undefined;
|
|
212
|
-
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to
|
|
212
|
+
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
|
|
213
213
|
temporaryThreadCleanupDelayMs?: number;
|
|
214
214
|
/** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
|
|
215
215
|
liveRebindCleanupSchedule?: {
|
package/dist/lib/routing.js
CHANGED
|
@@ -1331,13 +1331,20 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1331
1331
|
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
1332
1332
|
},
|
|
1333
1333
|
});
|
|
1334
|
-
const cloneTelegramMessagesForThread = (messages, threadId) => {
|
|
1335
|
-
return messages.map((message) =>
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
+
});
|
|
1341
1348
|
};
|
|
1342
1349
|
/** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
|
|
1343
1350
|
const hasPendingRerouteForTarget = (target, own) => [...pendingUnboundReroutes.values()].some((pending) => pending.sourceTarget.chatId === target.chatId &&
|
|
@@ -1603,10 +1610,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1603
1610
|
return !acceptedWorkClear;
|
|
1604
1611
|
};
|
|
1605
1612
|
/** Best-effort, once consumed: All keeps no copy of an input whose routing tab carried it; failure is only recorded. */
|
|
1606
|
-
const deleteAllTabMessages = async (chatId, messageIds) => {
|
|
1613
|
+
const deleteAllTabMessages = async (chatId, messageIds, isCurrent) => {
|
|
1607
1614
|
if (!deps.deleteMessage)
|
|
1608
1615
|
return;
|
|
1609
1616
|
for (const messageId of messageIds) {
|
|
1617
|
+
if (isCurrent?.() === false)
|
|
1618
|
+
return;
|
|
1610
1619
|
try {
|
|
1611
1620
|
await deps.deleteMessage(chatId, messageId);
|
|
1612
1621
|
}
|
|
@@ -1620,13 +1629,14 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1620
1629
|
}
|
|
1621
1630
|
};
|
|
1622
1631
|
/** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
|
|
1623
|
-
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);
|
|
1624
1633
|
/**
|
|
1625
1634
|
* Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
|
|
1626
1635
|
* holds its forwarded copy. Without one it stays where the operator can still read it.
|
|
1627
1636
|
*/
|
|
1628
1637
|
const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
|
|
1629
|
-
if (pending.allTabCopiesForwarded
|
|
1638
|
+
if (pending.allTabCopiesForwarded &&
|
|
1639
|
+
isDeepStrictEqual(pending.allTabCopiesForwarded.target, pending.sourceTarget))
|
|
1630
1640
|
return deleteAllTabCopies(pending);
|
|
1631
1641
|
pending.allTabCopies?.splice(0);
|
|
1632
1642
|
return Promise.resolve();
|
|
@@ -1640,39 +1650,109 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1640
1650
|
const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
|
|
1641
1651
|
if (messageIds.length === 0 || !deps.callApi || !isCurrent())
|
|
1642
1652
|
return;
|
|
1653
|
+
const copyTarget = { ...target };
|
|
1643
1654
|
const route = {
|
|
1644
|
-
chat_id:
|
|
1645
|
-
message_thread_id:
|
|
1655
|
+
chat_id: copyTarget.chatId,
|
|
1656
|
+
message_thread_id: copyTarget.threadId,
|
|
1646
1657
|
from_chat_id: pending.sourceTarget.chatId,
|
|
1647
1658
|
disable_notification: true,
|
|
1648
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
|
+
};
|
|
1649
1668
|
try {
|
|
1650
1669
|
// An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
|
|
1651
1670
|
const copies = messageIds.length === 1
|
|
1652
1671
|
? [
|
|
1653
|
-
await deps.callApi("forwardMessage", {
|
|
1654
|
-
...route,
|
|
1655
|
-
message_id: messageIds[0],
|
|
1656
|
-
}),
|
|
1672
|
+
await deps.callApi("forwardMessage", { ...route, message_id: messageIds[0] }, options),
|
|
1657
1673
|
]
|
|
1658
|
-
: await deps.callApi("forwardMessages", { ...route, message_ids: messageIds });
|
|
1674
|
+
: await deps.callApi("forwardMessages", { ...route, message_ids: messageIds }, options);
|
|
1675
|
+
if (!isCurrent())
|
|
1676
|
+
return;
|
|
1659
1677
|
const copyIds = Array.isArray(copies)
|
|
1660
1678
|
? copies.map((copy) => copy?.message_id)
|
|
1661
1679
|
: [];
|
|
1662
1680
|
if (copyIds.length !== messageIds.length ||
|
|
1663
|
-
!copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id))
|
|
1681
|
+
!copyIds.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
|
|
1682
|
+
new Set(copyIds).size !== copyIds.length)
|
|
1664
1683
|
return;
|
|
1665
|
-
pending.allTabCopiesForwarded =
|
|
1666
|
-
|
|
1667
|
-
|
|
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];
|
|
1668
1693
|
}
|
|
1669
1694
|
catch (error) {
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1695
|
+
if (isCurrent())
|
|
1696
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
1697
|
+
phase: "temporary-thread-source-forward",
|
|
1698
|
+
});
|
|
1673
1699
|
return;
|
|
1674
1700
|
}
|
|
1675
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
|
+
};
|
|
1676
1756
|
const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
|
|
1677
1757
|
const chatId = query.message?.chat?.id;
|
|
1678
1758
|
const messageId = query.message?.message_id;
|
|
@@ -4076,7 +4156,24 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4076
4156
|
wake(reason);
|
|
4077
4157
|
};
|
|
4078
4158
|
const onSessionSettled = (ctx) => wakeLiveRebindCleanup(ctx, "session-settled");
|
|
4079
|
-
const onDeliverySettled = (ctx) =>
|
|
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
|
+
}
|
|
4176
|
+
};
|
|
4080
4177
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
4081
4178
|
? async (input) => {
|
|
4082
4179
|
const ports = deps.workspaceRestoreRecipient;
|
|
@@ -4532,6 +4629,92 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4532
4629
|
return false;
|
|
4533
4630
|
}
|
|
4534
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
|
+
};
|
|
4535
4718
|
const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent, recordForwardAcceptance) => {
|
|
4536
4719
|
assertExecutionCurrent?.();
|
|
4537
4720
|
const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
|
|
@@ -4545,11 +4728,14 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4545
4728
|
!liveOwnership.recipientBindingKey)
|
|
4546
4729
|
return false;
|
|
4547
4730
|
const ownership = structuredClone(liveOwnership);
|
|
4548
|
-
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);
|
|
4549
4734
|
// Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
|
|
4550
4735
|
// even after restart. Any refusal or publication failure sends nothing and keeps the input held.
|
|
4551
4736
|
if (!recordForwardAcceptance &&
|
|
4552
4737
|
isTemporaryReroute(pending) &&
|
|
4738
|
+
!published &&
|
|
4553
4739
|
!issueTemporaryThreadForward(pending, ctx)) {
|
|
4554
4740
|
if (isTemporaryThreadForwardIssued(pending))
|
|
4555
4741
|
pending.phase = { kind: "forward-unknown" };
|
|
@@ -4561,6 +4747,10 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4561
4747
|
message,
|
|
4562
4748
|
ownership,
|
|
4563
4749
|
ctx,
|
|
4750
|
+
assertAuthority() {
|
|
4751
|
+
assertExecutionCurrent?.();
|
|
4752
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
4753
|
+
},
|
|
4564
4754
|
})));
|
|
4565
4755
|
assertExecutionCurrent?.();
|
|
4566
4756
|
if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership))
|
|
@@ -4719,6 +4909,17 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4719
4909
|
if (retry)
|
|
4720
4910
|
restoreSettlementTasks.add(task);
|
|
4721
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
|
+
})
|
|
4722
4923
|
.catch((error) => {
|
|
4723
4924
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
4724
4925
|
phase: "temporary-thread-cleanup",
|
|
@@ -5244,7 +5445,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5244
5445
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
5245
5446
|
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
|
|
5246
5447
|
continue;
|
|
5247
|
-
movedIntoTab ||= pending.allTabCopiesForwarded
|
|
5448
|
+
movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
|
|
5248
5449
|
removePendingReroute(id);
|
|
5249
5450
|
if (signal.aborted ||
|
|
5250
5451
|
deps.isContextActive?.(ctx) !== true ||
|
|
@@ -6047,6 +6248,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6047
6248
|
: "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
|
|
6048
6249
|
return true;
|
|
6049
6250
|
}
|
|
6251
|
+
if (!parsed.useNewSlot &&
|
|
6252
|
+
isTemporaryReroute(pending) &&
|
|
6253
|
+
!resolvePendingRerouteForwardSource(pending)) {
|
|
6254
|
+
await deps.answerCallbackQuery(query.id, "Source copy unavailable; not routing");
|
|
6255
|
+
return true;
|
|
6256
|
+
}
|
|
6050
6257
|
if (!(await selectPendingDestination()))
|
|
6051
6258
|
return true;
|
|
6052
6259
|
if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
|
|
@@ -6130,10 +6337,22 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6130
6337
|
await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
|
|
6131
6338
|
return true;
|
|
6132
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();
|
|
6133
6352
|
if (record.instanceId &&
|
|
6134
6353
|
record.instanceId !== currentInstanceId &&
|
|
6135
6354
|
!isCurrentLeaderRecord) {
|
|
6136
|
-
const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx,
|
|
6355
|
+
const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertRerouteCurrent);
|
|
6137
6356
|
if (!allForwarded) {
|
|
6138
6357
|
await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
|
|
6139
6358
|
? "Delivery unconfirmed; not resending"
|
|
@@ -6144,11 +6363,16 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6144
6363
|
}
|
|
6145
6364
|
// Local handlers and queue admission consume the same one-time group grant as follower forwarding.
|
|
6146
6365
|
if (isTemporaryReroute(pending) &&
|
|
6366
|
+
!pending.rerouteCopies &&
|
|
6147
6367
|
!issueTemporaryThreadForward(pending, ctx)) {
|
|
6148
6368
|
await deps.answerCallbackQuery(query.id, "Delivery unconfirmed; not resending");
|
|
6149
6369
|
return true;
|
|
6150
6370
|
}
|
|
6151
|
-
|
|
6371
|
+
const localMessages = pending.rerouteCopies
|
|
6372
|
+
? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
|
|
6373
|
+
: reroutedMessages;
|
|
6374
|
+
await dispatchPendingRerouteMessages(pending, localMessages, ctx);
|
|
6375
|
+
assertRerouteCurrent();
|
|
6152
6376
|
pending.messages = [];
|
|
6153
6377
|
return completeRoutedReroute();
|
|
6154
6378
|
};
|
|
@@ -7325,6 +7549,14 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7325
7549
|
const tabCopyId = openedNow
|
|
7326
7550
|
? await moveAllTabSourceIntoTab(pending, target, current)
|
|
7327
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;
|
|
7328
7560
|
try {
|
|
7329
7561
|
if (!deps.sendInteractiveMessage)
|
|
7330
7562
|
throw new Error("Temporary Thread chooser publication is unavailable.");
|
package/dist/lib/turns.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ import { type TelegramVoiceReplyMode } from "./voice.ts";
|
|
|
9
9
|
export declare const TELEGRAM_PREFIX = "[telegram]";
|
|
10
10
|
/** A routed source keeps its unique message/file/group identity, but cannot quote an anchor in its former tab. */
|
|
11
11
|
export declare function withoutTelegramTurnReplyAnchor<TMessage extends object>(message: TMessage): TMessage;
|
|
12
|
+
/** A confirmed same-target copy may replace the former-tab suppression on a warm input projection. */
|
|
13
|
+
export declare function withTelegramTurnReplyAnchor<TMessage extends {
|
|
14
|
+
message_id: number;
|
|
15
|
+
}>(message: TMessage): TMessage;
|
|
12
16
|
export declare const TELEGRAM_GUEST_TURN_NOTE = "[guest] delivery: answer quickly with one concise, self-contained reply";
|
|
13
17
|
export interface TelegramTurnTarget {
|
|
14
18
|
chatId: number;
|
package/dist/lib/turns.js
CHANGED
|
@@ -14,6 +14,12 @@ const REPLY_ANCHOR_SUPPRESSED = Symbol.for("@llblab/pi-telegram:reply-anchor-sup
|
|
|
14
14
|
export function withoutTelegramTurnReplyAnchor(message) {
|
|
15
15
|
return { ...message, [REPLY_ANCHOR_SUPPRESSED]: true };
|
|
16
16
|
}
|
|
17
|
+
/** A confirmed same-target copy may replace the former-tab suppression on a warm input projection. */
|
|
18
|
+
export function withTelegramTurnReplyAnchor(message) {
|
|
19
|
+
if (!Number.isSafeInteger(message.message_id) || message.message_id <= 0)
|
|
20
|
+
throw new Error("Telegram reply anchor requires a positive message ID.");
|
|
21
|
+
return { ...message, [REPLY_ANCHOR_SUPPRESSED]: false };
|
|
22
|
+
}
|
|
17
23
|
export const TELEGRAM_GUEST_TURN_NOTE = "[guest] delivery: answer quickly with one concise, self-contained reply";
|
|
18
24
|
function getTelegramTurnTarget(message) {
|
|
19
25
|
return Number.isInteger(message.message_thread_id)
|
package/dist/lib/updates.d.ts
CHANGED
|
@@ -141,6 +141,8 @@ export interface TelegramForeignOwnedUpdateForwarder<TContext, TReactionUpdate e
|
|
|
141
141
|
message: TMessage;
|
|
142
142
|
ownership: TelegramTargetOwnershipView;
|
|
143
143
|
ctx: TContext;
|
|
144
|
+
/** Local issuance/ACK fence; implementations must not serialize it into payloads. */
|
|
145
|
+
assertAuthority?: () => void;
|
|
144
146
|
}) => Promise<TelegramBusForeignUpdateSettlement> | TelegramBusForeignUpdateSettlement;
|
|
145
147
|
forwardEditedMessage?: (input: {
|
|
146
148
|
message: TMessage;
|
package/dist/package.json
CHANGED
package/docs/architecture.md
CHANGED
|
@@ -477,10 +477,12 @@ With confirmed Threaded Mode, live bindings or a retained temporary entry, a kno
|
|
|
477
477
|
|
|
478
478
|
#### Temporary Thread Multi-Input Lifecycle (Approved Design)
|
|
479
479
|
|
|
480
|
-
Every temporary routing tab carries one fixed routing name ([copy](./ui-style.md#route-chooser)), never its first input, because later prompts may join it: the bot creates its own tabs with that name and renames a native tab once, best-effort, when it adopts it. Commands and prompts share the same mode chooser. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability in that tab, without renaming it or creating another one. Mobile clients sending from `All` first create an implicitly named native tab and then deliver the input without a thread ID, although the client shows it inside that tab: an `All` input whose text starts with the name of such an observation (mobile names the tab after the input), or whose update immediately follows it (generic names such as desktop's `New Chat`), from the same exact scope and created at most ten seconds earlier, registers that native tab as its temporary tab through `registerImplicitTemporaryThread` instead of creating a second one, and renames it once to the routing name. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. The next live input group in that exact target, of any content kind and either one message or an album, registers it through Threads `registerImplicitTemporaryThread` under profile admission, with that group as the entry's first input (its smallest update ID is the source), without calling or fabricating `createForumTopic` (adoption renames it once, best-effort); the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers Reroute, Restore and eligible Cancel; each mode opens its own submenu of live thread targets without dispatching the held input. A submenu Back returns to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Copy, emoji and button layout live in [UI Style](./ui-style.md#route-chooser). Cancel appears only at the root. After cancellation, the immediate cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry. Every kind of input sent from All takes this path: a lone text message may carry a command, while a voice note, file or sticker is held untouched as a prompt, and an album first gathers through the shared pending-group machine into one input whose whole update group the tab records as its creating input (`reserveTemporaryThread` with a group starting at the source update). All is input-only: no assistant answer, chooser, fallback guidance, draft or activity is emitted there. A failed routing-tab creation retains the source, including a coalesced album, rather than silently settling it. A stale Thread does not prove Classic Mode. The transport/API fence rejects private output without a valid top-level Thread, including retained aggregate markers; Classic DM and explicit channel/business namespaces remain separate. No historical All-output cleanup is scheduled. The Bot API files every threadless All input in All, a desktop menu-picked command as well as a mobile input its client first draws inside its new native tab. So a tab opened for such an input (bot-created or adopted, never on restart re-entry) first receives one silent best-effort `forwardMessage` of it, its chooser replies to that copy, and only a confirmed copy lets the bot delete the All original at once: the input moves into its tab on every client. A failed forward keeps the original in All; it is then deleted best-effort when the input is consumed by Forward, Cancel or expiry, which remove the tab too, but never by Restore, which keeps the tab as the Thread and must not leave it without its input. Failure is recorded and never retried. Pending-cancellation recovery deletes nothing, and typed All inputs already live in their own tab and leave with it.
|
|
480
|
+
Every temporary routing tab carries one fixed routing name ([copy](./ui-style.md#route-chooser)), never its first input, because later prompts may join it: the bot creates its own tabs with that name and renames a native tab once, best-effort, when it adopts it. Commands and prompts share the same mode chooser. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability in that tab, without renaming it or creating another one. Mobile clients sending from `All` first create an implicitly named native tab and then deliver the input without a thread ID, although the client shows it inside that tab: an `All` input whose text starts with the name of such an observation (mobile names the tab after the input), or whose update immediately follows it (generic names such as desktop's `New Chat`), from the same exact scope and created at most ten seconds earlier, registers that native tab as its temporary tab through `registerImplicitTemporaryThread` instead of creating a second one, and renames it once to the routing name. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. The next live input group in that exact target, of any content kind and either one message or an album, registers it through Threads `registerImplicitTemporaryThread` under profile admission, with that group as the entry's first input (its smallest update ID is the source), without calling or fabricating `createForumTopic` (adoption renames it once, best-effort); the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers Reroute, Restore and eligible Cancel; each mode opens its own submenu of live thread targets without dispatching the held input. A submenu Back returns to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Copy, emoji and button layout live in [UI Style](./ui-style.md#route-chooser). Cancel appears only at the root. After cancellation, the immediate cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry. Every kind of input sent from All takes this path: a lone text message may carry a command, while a voice note, file or sticker is held untouched as a prompt, and an album first gathers through the shared pending-group machine into one input whose whole update group the tab records as its creating input (`reserveTemporaryThread` with a group starting at the source update). All is input-only: no assistant answer, chooser, fallback guidance, draft or activity is emitted there. A failed routing-tab creation retains the source, including a coalesced album, rather than silently settling it. A stale Thread does not prove Classic Mode. The transport/API fence rejects private output without a valid top-level Thread, including retained aggregate markers; Classic DM and explicit channel/business namespaces remain separate. No historical All-output cleanup is scheduled. The Bot API files every threadless All input in All, a desktop menu-picked command as well as a mobile input its client first draws inside its new native tab. So a tab opened for such an input (bot-created or adopted, never on restart re-entry) first receives one silent best-effort `forwardMessage` of it, its chooser replies to that copy, and only a confirmed copy lets the bot delete the All original at once: the input moves into its tab on every client. That opening forward has one unretried, generation-fenced API attempt. Only a complete set of distinct positive safe-integer copy IDs acknowledged under current authority permits removal of the All originals. The live chooser retains the exact target and full source/copy ID mapping as display provenance, not input acceptance or cleanup authority; this warm mapping is not a cold-recovery grant and never rewrites journal originals. Temporary Reroute resolves its forwarding addresses from fresh worker-owned original snapshots before selecting the destination: exact source binding/update, paired sender, bot-chat message namespace and positive unique original IDs must all match. Complete warm copies take precedence; original All IDs may be reused only when this live opening attempt is known not to have removed them. A revived chooser cannot reconstruct that fact from journal IDs or its saved location. Unknown cold All copies, unavailable readers, partial group proof and synthetic zero IDs refuse Reroute before native issuance, leaving the source waiting with its exact Cancel capability; actual native-Thread originals may still be recovered. Warm target projection is not proof of an original message location. Restore retains its separate held-input path. Ended authority stops subsequent original-removal delegation and chooser publication; already issued requests cannot be unsent. A failed forward keeps the original in All; it is then deleted best-effort when the input is consumed by Forward, Cancel or expiry, which remove the tab too, but never by Restore, which keeps the tab as the Thread and must not leave it without its input. Failure is recorded and never retried. Pending-cancellation recovery deletes nothing, and typed All inputs already live in their own tab and leave with it.
|
|
481
481
|
|
|
482
482
|
This multi-input lifecycle is implemented in the local candidate, not authority for live deletion. It supersedes unconditional per-chooser tab removal. The reject-only guard prevents cleanup/retirement while another known same-tab chooser remains, including unresolved cleanup-only work. `temporaryThreads.inputs` now records bounded append-only journal source groups through the unbound producer before chooser publication; duplicates are read-only, partial/foreign/overlapping groups refuse, and faults retain the source without a new chooser. Cold reads preserve this protection without process-local chooser memory. A group has no readiness or disposition flag, and an absent legacy field does not prove complete coverage. `cancelledInputs` records exact whole known donor groups after either same-journal/operator retained-cancellation evidence or the separate body-free expiry tombstone. Expiry recording may clear a group's unknown Forward issuance or its expired Restore intent, but never mutates a canonical binding, recipient queue or immutable scoped settlement proof. If Restore already bound the tab, expiry releases the temporary frame without deletion. Otherwise fully resolved groups enter the existing one-second quiet-period cleanup. A partially acknowledged known cohort may finish donor disposition when every member is absent from that exact source journal and at least one member has same-operator expiry proof; this is not recipient completion or non-delivery proof. Fresh strict namespace/protection evidence still gates deletion; independent work and bound Threads remain protected. Store publication and returned/retained acknowledgement recheck that proof and operator/executor authority; a lost reply reconciles the exact fact read-only, while missing/foreign/corrupt proof stays protective. The producer composes this into explicit Cancel before temporary cleanup or chooser retirement. Cancelled groups cannot Restore; independent sources remain untouched. More than one known group still blocks cleanup and raw retirement, including all-cancelled membership: these facts grant no deletion. Each successful Cancel retires only its own chooser. If durable membership is fully resolved by exact cancellation/completion facts and no chooser remains, a process-local cleanup is queued with no grace delay (it still waits behind the resolving operation's Workspace admission, which Cancel releases right after its notice, answering the toast and deleting any All copy alongside the cleanup; new input in the tab before it runs cancels it). Its single attempt, under profile admission, rechecks authority, whole-group cancellation/expiry evidence, exact entry CAS, a strict per-Thread journal census (every namespace source must read strictly; any retained update naming the Thread in any state, any of its own groups, and any custody-bearing, failed or non-plain entry anywhere still protect; only an unrelated waiting plain message, edit, callback or reaction elsewhere is exempt, so a chooser held in another tab cannot pin every temporary tab, and so is a plain button tap in this tab, since it carries no input and the in-flight Cancel itself may outlast the quiet period), absence of choosers and the normal reconciler protections. `issueTemporaryThreadCleanup` publishes canonical `cleanupIssued: true` only for an exact fully resolved entry with strict unbound canonical evidence checked inside the publication transaction; the normal target/source guards precede issuance and run again at API boundaries. The reconciler requires that exact retained issued frame and limits its plan to this tab; unrelated expired-provision cleanup never borrows the grant. Close/delete calls disable retries and transport fallback and require a positive acknowledgement. Confirmed current deletion alone permits entry retirement; skip, negative/lost reply, authority loss or post-rename publication interruption retains the marker. Cold reads classify already-issued entries as unknown, new membership refuses, and executor adoption never renews the grant. Missing evidence only refuses; an unpublished grant issues no API call and may still receive its first grant. The quiet period remains process-local, but issued uncertainty survives restart. The census cannot see updates in transit before journal append. Restore from a multi-input tab is allowed only when each other known group is durably cancelled, Forward-completed or a live unselected chooser that supports private abandonment. A previously completed Forward never supplies a new selection or cancellation grant. After positive settlement of every selected source, such siblings are abandoned with cancellation facts and their controls edited to cancelled, then `retireTemporaryThread(expected, authority, completed)` releases the entry (one newly completed selected group, all others durably cancelled or previously Forward-completed). Missing authority/proof or an unproven Restore leaves siblings pending and protected. On startup, a retained journal input recorded in a created temporary tab whose target a Workspace binding now owns is held as historical, never rerouted; an exact saved chooser clock may expire its unclaimed donor attempt without affecting the binding. Live pending sources and due retry-wait sources (including startup retries) use the same fresh membership/binding predicate through `shouldHoldPendingInput` before execution, with source/context/binding/transport checks after classification awaits. The hold grants neither cancellation nor delivery and does not block unrelated inputs. Recipient custody never uses this direct-source classifier. A native production witness admits a recorded command after the worker has already processed an unrelated command, proving it cannot fall through to ordinary bound dispatch. A production retry witness keeps recorded failure metadata intact without command execution, cancellation or deletion. Claimed retries cannot schedule repeated due-time wakes; independent due and future retries remain executable. Real-IPC Forward/Restore sibling witnesses now include recipient journal drain through the real worker and follower router: only the selected message executes, source disposal is observed, and repeated controls do not replay the command. Existing message-ownership composition keeps a locally published chooser's callbacks at its leader publisher after follower rebinding; the native fixture now records publication ownership rather than relying only on target ownership. Four native delivery-reply-loss witnesses cover both routing actions (Forward/Restore), before recipient execution and after worker disposal: the leader source remains unresolved without ACK, independent sibling Cancel preserves the selected group/tab, and a late reply or repeated issued Restore click cannot settle/replay it. Explicit Forward retry after a lost ACK reproduced command duplication when the recipient worker had already disposed its journal record; stable delivery identity alone was not terminal deduplication. The follower's bounded process-local delivery window now acknowledges such a retry without re-execution while it retains that delivery. Ordinary Forward from a temporary tab first publishes a durable per-group `forwardedInputs` issuance fact in the canonical Threads owner under current authority, before either local command handling/prompt preparation/queue admission or follower RPC. Only positively unpublished issuance may receive its first grant; a published fact (even if its reply is ambiguous) is never retried, including after reload, restart or leader replacement. Local repeated selection refuses the retained grant before handler or queue re-entry. Captured All-tab provenance and acknowledged typed-input membership remain protective if the metadata reader disappears; unavailable evidence never falls back to unguarded generic dispatch. Restore retains its separate canonical routing grant. Native local fixtures interrupt source completion before/after commit, prompt preparation and issuance publication/authority; issued sources stay nonterminal, independent siblings remain cancellable and cold restart never manufactures completion or another attempt. A process-local `forward-unknown` chooser phase gates the live chooser. An issued group is refused by repeat Forward, explicit Cancel and Restore (the store also rejects them); before its fixed chooser deadline it keeps sibling accounting protective, while body-free donor expiry or positive `completedInputs` may resolve it; independent siblings remain operable and a restarted chooser cannot resend. Issuance is not proof of delivery, and recipient-side deduplication is limited to the bounded process-local delivery window; the chooser deadline supplies a loss-accepting exit for unclaimed unknown issued groups; generic journal forwarding and non-chooser retry policy are unchanged. Canonical Restore keeps its existing durable grants. Mixed native witnesses prove that a completed Forward allows Restore of another group without replay/recancellation, while an unknown Forward prevents sibling Restore before an intent is created. Prompt Restore leaves a command sibling pending at queue admission, then privately cancels it only after exact scoped worker receipt disposition. Independently Forwarded queued input is neither unassigned nor cancellation-capable: either receipt may dispose first, the other retains custody, and only both positive dispositions release temporary membership while keeping the bound tab/slot. An ordinary queued ACK emits the existing source-completion hint only after whole acknowledged removal and fresh context, owner, generation and originating journal-binding checks; it creates no immutable Restore marker. A published independent Forward group fact wakes pure inspection of already settled Restore only after its Workspace admission releases. The store refuses both Forward facts and cancellation for any source overlapping a retained Restore grant, regardless of target; malformed partial selected groups stay protective. Native ordinary prompt Forward plus sibling Cancel also delays deletion until receipt disposition and fresh all-resolved cleanup. Pi handoff is supplied by the fixture, not observed model/task completion. The retained All-command fixture registers 84 scenarios across creation, Forward, Cancel, cleanup/membership and Restore, including full slots, publication faults, sibling protection, native Unix IPC and recipient worker drain through the real non-reentrant Workspace gate. Full-slot follower cases hold a real Unix response before apply, after apply, and after read-only inspection until client timeout. Fresh same-session registration/context generations reconcile only through inspection; late replies never publish readiness, repeat apply or dispatch, cancel siblings, release independent queued recipient work or delete the tab. Successful continuation retains exact scoped source-disposition proof. A separate cold-successor witness loads fresh stores and a journal binding after an issued command lost acceptance authority, then starts a fresh context/generation/epoch under supplied same-session owner publication. Original command and sibling stay pending, spent routing/canonical facts remain unchanged, and stale Restore/Forward/Cancel controls cannot replay or dispose them. No chooser is reconstructed: the protective hold is proven, not a usable cold recovery surface. Pi effects and startup registration remain supplied adapters/preconditions; actual Pi startup and live behavior were accepted in the operator's 0.52.0 live smoke. Forward completes only its own input: no Forward removes the tab, the worker's completion ACK records a durable `completedInputs` group (process-local accumulation across the group's sources), and when every known group is cancelled or completed the delayed, fully rechecked cleanup removes the tab once. Cancelled and completed groups are disjoint, cannot Restore, and a completed Forward queued prompt holds the tab until its receipt completes. The tab chooser discloses removal only when no other input remains; a failed removal no longer edits the original chooser. Source-only prompt Cancel remains unchanged. The candidate's temporary-tab chooser is the default under the owned leader epoch, not a configurable feature flag. Upgrade all prospective canonical readers/writers before live activation; the metadata (including `forwardProtocol`, `forwardedInputs` and `cleanupIssued`) is not downgrade-safe through older snapshot writers, which could drop issuance facts.
|
|
483
483
|
|
|
484
|
+
Temporary Reroute publishes the prompt in its selected destination before local queue/command admission or follower IPC. The existing exact whole-group `forwardedInputs` grant precedes this one unretried Bot API request as well as native dispatch; complete distinct positive returned IDs provide display/reply anchors, never source acceptance or settlement. `forwardMessages` preserves albums and requires ascending source IDs, so the warm map correlates each destination ID with its immutable original source update ID rather than a routed zero ID or array position. Acknowledged routing copies are preferred; a proven still-retained All original or real native-Thread original may supply the source instead. Before issuance and after awaits, guards recheck source/executor/operator/context/generation/profile, exact issued group, live recipient record and Workspace session binding; followers also retain their exact registration/protocol ownership. API and IPC callbacks stay local. Reroute passes recipient/session authority plus each original execution fence to the existing socket connect/write/ACK checks, with one guarded transport attempt; a stale post-write ACK cannot settle donor custody or undo accepted recipient work. The input's warm projection uses the confirmed copy ID and clears former-tab reply suppression before carrying the original execution fence; donor journal originals remain untouched. A partial/malformed/lost API reply or revoked authority keeps the issued group nonterminal with no output or native retry, even after restart. A lost native IPC ACK similarly retains donor authority and cannot replay an already accepted recipient input or its display copies. Local receipt disposal and exact whole-group foreign source-removal ACKs still own temporary cleanup; neither a visible copy nor model completion grants deletion. A follower donor ACK may precede recipient receipt disposal, so a fully resolved but protected, unissued tab retains one metadata-only one-second recheck and accepts existing session-delivery drain wakes. Every attempt reacquires profile admission and fresh namespace/binding/source/authority proof; time is not a deletion grant. A fresh runtime drain can reobserve retained completed membership without replaying its input. Issued or unknown deletion never repeats. Restore's held-input sequencing and cleanup schedules are unchanged.
|
|
485
|
+
|
|
484
486
|
- `Membership`: A positively source-linked disposable temporary Thread owns no Pi binding or A–Z slot and may contain multiple independent unassigned inputs, each with its own routing controls. Track exact journal source/group membership, including additional inputs, rather than treating the first chooser or a process-local count as the whole Thread. Serialize admission, selection, cancellation, Restore and cleanup through the existing Workspace admission and mutation owners; unreadable, incomplete, selected, accepted, running or unknown-issued evidence blocks deletion.
|
|
485
487
|
- `Per-input Cancel`: Privately retain the exact original and positively commit source-only cancellation before acknowledging `Routing cancelled` and deleting its one-shot chooser message. Other unassigned inputs retain their choosers and custody; cancelling one input neither delivers nor cancels siblings. No separate Cancel-and-delete action or extra confirmation dialog is required, but the chooser must disclose the conditional whole-tab deletion consequence.
|
|
486
488
|
- `Last-resolution cleanup`: Only an explicit Cancel or positive completion of an explicitly issued Forward that resolves the final known group may schedule deletion of the entire still-temporary Telegram tab, including all its messages. The operator-approved grace is now zero, superseding the earlier 1-second and 2–3-second designs: deletion is attempted right after the chooser is deleted. For Last Cancel, scheduling follows positively recorded whole-group cancellation and successful retirement of the final chooser, not the initial button tap. The timer establishes earliest eligibility for one proof-gated cleanup attempt, not an exact deletion time: Workspace admission, fresh protection checks and Bot API latency may delay or refuse it. Actual client timing was accepted in the operator's 0.52.0 live smoke. Mirrored native witnesses use shortened injected delays, not a production-default or Telegram-client timing measurement. This grace is presentation timing, not proof of emptiness or non-delivery. New input or a changed binding/authority cancels the scheduled cleanup. Immediately before issuance, reacquire current Workspace admission, recheck exact operator/profile/session/leader/source/target authority, positive disposable-tab provenance, complete source dispositions and all Thread protection, then publish the canonical one-shot temporary cleanup grant. A restored/bound Thread or any unresolved/unverifiable work remains protected. TTL, startup, silence and a zero chooser count do not manufacture this last-Cancel grant. Failed/unknown deletion cannot undo cancellation or cause automatic repeat issuance.
|
|
@@ -281,6 +281,8 @@ Negotiated identities remain on the live follower registry and appear in `/teleg
|
|
|
281
281
|
|
|
282
282
|
Foreign update forwarding returns an explicit `accepted`, `retryable`, or `terminal-rejected` settlement. Acceptance requires an acknowledgement for the exact request whose receipt contains the expected stable `deliveryId` and source `update_id`; a callback error popup never substitutes for that receipt. Missing or negative acknowledgements, stale registrations, absent follower context, binding rejection, journal admission failure, and missing or mismatched receipts all retain the leader source. Message, edited-message, reaction, and callback paths share this contract. The follower remembers each admitted `deliveryId` in a process-local window (24 hours, at most 4,096 deliveries); a repeated delivery inside it, including after its journal entry completed and was removed, is acknowledged with the same receipt without another append or handler execution. This absorbs a leader retry after a lost acknowledgement; it is bounded at-most-once behavior, not exactly-once across recipient restart or window eviction.
|
|
283
283
|
|
|
284
|
+
Foreign message forwards may carry one process-local `assertAuthority` callback; it is never serialized into the envelope or recipient journal. Guarded Reroute supplies both its captured source/context/session/recipient authority and each original message's execution fence. The existing local client rechecks them before connecting, immediately before the encoded socket write and after reading the ACK; the forwarder also revalidates exact live ownership at those boundaries. A guarded send has one transport attempt with no reconnect replay, while unguarded generic forwarding retains its established retry policy. Before-write refusal issues nothing; after-write refusal cannot unsend recipient work and retains donor custody without accepting the stale ACK. Wire protocol, stable delivery/source identity and recipient receipt validation are unchanged.
|
|
285
|
+
|
|
284
286
|
The delivery id excludes the replaceable runtime instance and registration generation: it derives from envelope kind, source `update_id`, and the stable manual-follower binding. Stored message ownership carries that binding and may rebind to the current authenticated registration after follower replacement, preserving one retry identity while still fencing each attempt by the current generation. A lost acknowledgement retries with the same delivery identity and becomes accepted only when the exact durable receipt returns. Journal deduplication covers retained entries, not lifetime completion history: after ordinary receipt completion removes an entry, cursorless admission can accept that source again. Stable delivery IDs therefore do not by themselves establish exactly-once execution. Owned local IPC evidence reproduces the gap: a proxy drops actual ACK bytes; real forwarding, authenticated receiving, paired admission, journals and admission workers leave the leader source in retry-wait after follower completion. The next attempt reaches an injected authorized callback handler twice in total, then both journals settle. A queued-message variant likewise re-admits the source under a fresh acquisition after a real worker receipt handoff. This proves repeated handler/queue-admission invocation in the composed local pipeline, not actual Pi queue dispatch, live Telegram effects or model execution. Delivery custody, including role-dependent local replay, must be reconciled before a consume-once guarantee can be claimed; the [design counterexamples](./architecture.md#busjournal-design-acceptance) rule out treating origin disappearance as proof of a specific handoff.
|
|
285
287
|
|
|
286
288
|
Transport leadership does not own already-queued semantics. Each queued journal receipt binds the acquiring runtime instance, OS process birth, session generation, and acquisition. A replacement leader or follower process sees another live process's receipt as foreign and cannot replay or settle it; the original process may complete its local Pi queue after transport moves. Startup preserves foreign and legacy unowned receipts. Before a replacement admission worker starts, tri-state pid/process-birth proof: an absent PID or mismatched stable Linux/macOS birth identity permits recovery, while a live matching owner stays `alive` and Windows or inaccessible birth metadata stays `unverifiable`; both non-dead outcomes preserve the receipt may transactionally recover a dead owner's complete receipt to pending, after which replacement replay creates fresh queue authority. Registration publishes the replacement's exact pid/process-birth identity first; a concurrent recovery that observes it returns `owner-alive`, while live or unverifiable owners remain untouched.
|
package/lib/bus.ts
CHANGED
|
@@ -1926,6 +1926,8 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
1926
1926
|
message: TMessage;
|
|
1927
1927
|
ownership: TelegramBusForwardOwnership;
|
|
1928
1928
|
ctx: TContext;
|
|
1929
|
+
/** Process-local source/recipient lifetime; never part of the wire envelope. */
|
|
1930
|
+
assertAuthority?: () => void;
|
|
1929
1931
|
}) => Promise<TelegramBusForeignUpdateSettlement>;
|
|
1930
1932
|
forwardEditedMessage: (input: {
|
|
1931
1933
|
message: TMessage;
|
|
@@ -2057,6 +2059,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
2057
2059
|
const send = async (
|
|
2058
2060
|
envelope: TelegramBusDurableForwardEnvelope,
|
|
2059
2061
|
ownership: TelegramBusForwardOwnership,
|
|
2062
|
+
assertAuthority?: () => void,
|
|
2060
2063
|
): Promise<TelegramBusForeignUpdateSettlement> => {
|
|
2061
2064
|
if (
|
|
2062
2065
|
deps.validateForwardOwnership &&
|
|
@@ -2078,10 +2081,15 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
2078
2081
|
socketPath,
|
|
2079
2082
|
envelope,
|
|
2080
2083
|
timeoutMs: deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CONTROL_TIMEOUT_MS,
|
|
2081
|
-
retry: getTelegramBusTransportRetryPolicy({
|
|
2084
|
+
retry: assertAuthority ? { attempts: 1, delayMs: 0 } : getTelegramBusTransportRetryPolicy({
|
|
2082
2085
|
endpoint: socketPath,
|
|
2083
2086
|
operation: "operation",
|
|
2084
2087
|
}),
|
|
2088
|
+
...(assertAuthority || deps.validateForwardOwnership ? { assertAuthority() {
|
|
2089
|
+
assertAuthority?.();
|
|
2090
|
+
if (deps.validateForwardOwnership && !deps.validateForwardOwnership(ownership))
|
|
2091
|
+
throw new Error("Telegram follower forwarding ownership changed.");
|
|
2092
|
+
} } : {}),
|
|
2085
2093
|
});
|
|
2086
2094
|
} catch (error) {
|
|
2087
2095
|
return reject({
|
|
@@ -2178,6 +2186,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
2178
2186
|
base: ForwardBase,
|
|
2179
2187
|
sentAtMs: number,
|
|
2180
2188
|
) => TelegramBusDurableForwardEnvelope,
|
|
2189
|
+
assertAuthority?: () => void,
|
|
2181
2190
|
): Promise<TelegramBusForeignUpdateSettlement> => {
|
|
2182
2191
|
const prepared = prepare(kind, value, ownership);
|
|
2183
2192
|
if ("settlement" in prepared) return Promise.resolve(prepared.settlement);
|
|
@@ -2192,6 +2201,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
2192
2201
|
? { kind: "leader.wakeInputCustody", ...base, sentAtMs: getNowMs() }
|
|
2193
2202
|
: build(base, getNowMs()),
|
|
2194
2203
|
ownership,
|
|
2204
|
+
assertAuthority,
|
|
2195
2205
|
);
|
|
2196
2206
|
};
|
|
2197
2207
|
return {
|
|
@@ -2214,7 +2224,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
2214
2224
|
sentAtMs,
|
|
2215
2225
|
}),
|
|
2216
2226
|
),
|
|
2217
|
-
forwardMessage: ({ message, ownership }) =>
|
|
2227
|
+
forwardMessage: ({ message, ownership, assertAuthority }) =>
|
|
2218
2228
|
forward("leader.forwardMessage", message, ownership, (base, sentAtMs) => {
|
|
2219
2229
|
const forwardCommentBatchPosition =
|
|
2220
2230
|
deps.getForwardCommentBatchPosition?.(message);
|
|
@@ -2227,7 +2237,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
|
|
|
2227
2237
|
: {}),
|
|
2228
2238
|
sentAtMs,
|
|
2229
2239
|
};
|
|
2230
|
-
}),
|
|
2240
|
+
}, assertAuthority),
|
|
2231
2241
|
forwardEditedMessage: ({ message, ownership }) =>
|
|
2232
2242
|
forward(
|
|
2233
2243
|
"leader.forwardEditedMessage",
|
package/lib/routing.ts
CHANGED
|
@@ -1558,7 +1558,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1558
1558
|
requiredJournalBindingKeys: readonly string[],
|
|
1559
1559
|
ownInputs?: readonly Threads.TelegramTemporaryThreadInput[],
|
|
1560
1560
|
) => readonly number[] | undefined;
|
|
1561
|
-
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to
|
|
1561
|
+
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
|
|
1562
1562
|
temporaryThreadCleanupDelayMs?: number;
|
|
1563
1563
|
/** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
|
|
1564
1564
|
liveRebindCleanupSchedule?: {
|
|
@@ -1943,10 +1943,21 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1943
1943
|
temporaryThread?: Threads.TelegramTemporaryThreadEntry;
|
|
1944
1944
|
/** Source copies left in All (a menu-picked command); deleted once, when the input is consumed. */
|
|
1945
1945
|
allTabCopies?: number[];
|
|
1946
|
-
/**
|
|
1947
|
-
|
|
1946
|
+
/** This live opening attempt did not remove its All originals; replay/revival cannot reconstruct that fact. */
|
|
1947
|
+
allTabSourceRetained?: true;
|
|
1948
|
+
/** Complete warm copy identity; journal originals stay unchanged even after their All messages leave. */
|
|
1949
|
+
allTabCopiesForwarded?: {
|
|
1950
|
+
target: { chatId: number; threadId: number };
|
|
1951
|
+
sourceMessageIds: readonly number[];
|
|
1952
|
+
messageIds: readonly number[];
|
|
1953
|
+
};
|
|
1948
1954
|
/** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
|
|
1949
1955
|
temporaryMembership?: true;
|
|
1956
|
+
/** Confirmed display copies only; the durable Forward fact remains the native one-shot authority. */
|
|
1957
|
+
rerouteCopies?: {
|
|
1958
|
+
target: { chatId: number; threadId: number };
|
|
1959
|
+
bySourceUpdateId: ReadonlyMap<number, number>;
|
|
1960
|
+
};
|
|
1950
1961
|
/** Re-entrancy lock for one command route in flight; orthogonal to `phase`. */
|
|
1951
1962
|
dispatching?: boolean;
|
|
1952
1963
|
abandonment?: {
|
|
@@ -2415,15 +2426,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2415
2426
|
const cloneTelegramMessagesForThread = (
|
|
2416
2427
|
messages: TMessage[],
|
|
2417
2428
|
threadId: number,
|
|
2429
|
+
copies?: ReadonlyMap<number, number>,
|
|
2418
2430
|
): TMessage[] => {
|
|
2419
|
-
return messages.map((message) =>
|
|
2420
|
-
Updates.
|
|
2431
|
+
return messages.map((message) => {
|
|
2432
|
+
const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
2433
|
+
const copyId = copies && ids.length === 1 ? copies.get(ids[0]!) : undefined;
|
|
2434
|
+
if (copies && (!Number.isSafeInteger(copyId) || copyId! <= 0))
|
|
2435
|
+
throw new Error("Reroute destination copy identity is unavailable.");
|
|
2436
|
+
const routed = {
|
|
2421
2437
|
...message,
|
|
2422
|
-
message_id: 0,
|
|
2438
|
+
message_id: copyId ?? 0,
|
|
2423
2439
|
message_thread_id: threadId,
|
|
2424
2440
|
reply_to_message: undefined,
|
|
2425
|
-
} as TMessage
|
|
2426
|
-
|
|
2441
|
+
} as TMessage;
|
|
2442
|
+
return Updates.carryTelegramUpdateExecutionFence(message,
|
|
2443
|
+
copies ? Turns.withTelegramTurnReplyAnchor(routed) : routed);
|
|
2444
|
+
});
|
|
2427
2445
|
};
|
|
2428
2446
|
/** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
|
|
2429
2447
|
const hasPendingRerouteForTarget = (
|
|
@@ -2819,9 +2837,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2819
2837
|
const deleteAllTabMessages = async (
|
|
2820
2838
|
chatId: number,
|
|
2821
2839
|
messageIds: readonly number[],
|
|
2840
|
+
isCurrent?: () => boolean,
|
|
2822
2841
|
): Promise<void> => {
|
|
2823
2842
|
if (!deps.deleteMessage) return;
|
|
2824
2843
|
for (const messageId of messageIds) {
|
|
2844
|
+
if (isCurrent?.() === false) return;
|
|
2825
2845
|
try {
|
|
2826
2846
|
await deps.deleteMessage(chatId, messageId);
|
|
2827
2847
|
} catch (error) {
|
|
@@ -2834,10 +2854,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2834
2854
|
}
|
|
2835
2855
|
};
|
|
2836
2856
|
/** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
|
|
2837
|
-
const deleteAllTabCopies = (
|
|
2857
|
+
const deleteAllTabCopies = (
|
|
2858
|
+
pending: PendingUnboundReroute,
|
|
2859
|
+
isCurrent?: () => boolean,
|
|
2860
|
+
): Promise<void> =>
|
|
2838
2861
|
deleteAllTabMessages(
|
|
2839
2862
|
pending.sourceTarget.chatId,
|
|
2840
2863
|
pending.allTabCopies?.splice(0) ?? [],
|
|
2864
|
+
isCurrent,
|
|
2841
2865
|
);
|
|
2842
2866
|
/**
|
|
2843
2867
|
* Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
|
|
@@ -2846,7 +2870,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2846
2870
|
const releaseAllTabCopiesIntoSurvivingTab = (
|
|
2847
2871
|
pending: PendingUnboundReroute,
|
|
2848
2872
|
): Promise<void> => {
|
|
2849
|
-
if (
|
|
2873
|
+
if (
|
|
2874
|
+
pending.allTabCopiesForwarded &&
|
|
2875
|
+
isDeepStrictEqual(pending.allTabCopiesForwarded.target, pending.sourceTarget)
|
|
2876
|
+
)
|
|
2877
|
+
return deleteAllTabCopies(pending);
|
|
2850
2878
|
pending.allTabCopies?.splice(0);
|
|
2851
2879
|
return Promise.resolve();
|
|
2852
2880
|
};
|
|
@@ -2862,26 +2890,38 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2862
2890
|
): Promise<number | undefined> => {
|
|
2863
2891
|
const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
|
|
2864
2892
|
if (messageIds.length === 0 || !deps.callApi || !isCurrent()) return;
|
|
2893
|
+
const copyTarget = { ...target };
|
|
2865
2894
|
const route = {
|
|
2866
|
-
chat_id:
|
|
2867
|
-
message_thread_id:
|
|
2895
|
+
chat_id: copyTarget.chatId,
|
|
2896
|
+
message_thread_id: copyTarget.threadId,
|
|
2868
2897
|
from_chat_id: pending.sourceTarget.chatId,
|
|
2869
2898
|
disable_notification: true,
|
|
2870
2899
|
};
|
|
2900
|
+
const options = {
|
|
2901
|
+
maxAttempts: 1,
|
|
2902
|
+
retrySafety: "non-idempotent" as const,
|
|
2903
|
+
assertAuthority() {
|
|
2904
|
+
if (!isCurrent())
|
|
2905
|
+
throw new Error("Temporary Thread source forwarding authority changed.");
|
|
2906
|
+
},
|
|
2907
|
+
};
|
|
2871
2908
|
try {
|
|
2872
2909
|
// An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
|
|
2873
2910
|
const copies =
|
|
2874
2911
|
messageIds.length === 1
|
|
2875
2912
|
? [
|
|
2876
|
-
await deps.callApi<{ message_id?: unknown }>(
|
|
2877
|
-
|
|
2878
|
-
message_id: messageIds[0],
|
|
2879
|
-
|
|
2913
|
+
await deps.callApi<{ message_id?: unknown }>(
|
|
2914
|
+
"forwardMessage",
|
|
2915
|
+
{ ...route, message_id: messageIds[0] },
|
|
2916
|
+
options,
|
|
2917
|
+
),
|
|
2880
2918
|
]
|
|
2881
2919
|
: await deps.callApi<Array<{ message_id?: unknown }>>(
|
|
2882
2920
|
"forwardMessages",
|
|
2883
2921
|
{ ...route, message_ids: messageIds },
|
|
2922
|
+
options,
|
|
2884
2923
|
);
|
|
2924
|
+
if (!isCurrent()) return;
|
|
2885
2925
|
const copyIds = Array.isArray(copies)
|
|
2886
2926
|
? copies.map((copy) => copy?.message_id)
|
|
2887
2927
|
: [];
|
|
@@ -2889,20 +2929,88 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2889
2929
|
copyIds.length !== messageIds.length ||
|
|
2890
2930
|
!copyIds.every(
|
|
2891
2931
|
(id): id is number =>
|
|
2892
|
-
typeof id === "number" && Number.isSafeInteger(id),
|
|
2893
|
-
)
|
|
2932
|
+
typeof id === "number" && Number.isSafeInteger(id) && id > 0,
|
|
2933
|
+
) ||
|
|
2934
|
+
new Set(copyIds).size !== copyIds.length
|
|
2894
2935
|
)
|
|
2895
2936
|
return;
|
|
2896
|
-
pending.allTabCopiesForwarded =
|
|
2897
|
-
|
|
2898
|
-
|
|
2937
|
+
pending.allTabCopiesForwarded = {
|
|
2938
|
+
target: copyTarget,
|
|
2939
|
+
sourceMessageIds: messageIds,
|
|
2940
|
+
messageIds: copyIds,
|
|
2941
|
+
};
|
|
2942
|
+
await deleteAllTabCopies(pending, isCurrent);
|
|
2943
|
+
if (!isCurrent()) return;
|
|
2944
|
+
return pending.allTabCopiesForwarded.messageIds[0];
|
|
2899
2945
|
} catch (error) {
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2946
|
+
if (isCurrent())
|
|
2947
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
2948
|
+
phase: "temporary-thread-source-forward",
|
|
2949
|
+
});
|
|
2903
2950
|
return;
|
|
2904
2951
|
}
|
|
2905
2952
|
};
|
|
2953
|
+
/** Read original source addresses, not warm target rewrites; unknown copy provenance never grants a Reroute. */
|
|
2954
|
+
const resolvePendingRerouteForwardSource = (
|
|
2955
|
+
pending: PendingUnboundReroute,
|
|
2956
|
+
): { chatId: number; sourceUpdateIds: number[]; sourceMessageIds: number[]; messageIds: number[] } | undefined => {
|
|
2957
|
+
try {
|
|
2958
|
+
const originals = pending.messages.map((message) => {
|
|
2959
|
+
const snapshot = Updates.inspectTelegramDeferredSourceSnapshot(message);
|
|
2960
|
+
const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
2961
|
+
const original = snapshot?.update.message as TMessage | undefined;
|
|
2962
|
+
if (
|
|
2963
|
+
!snapshot ||
|
|
2964
|
+
ids.length !== 1 ||
|
|
2965
|
+
snapshot.source.updateId !== ids[0] ||
|
|
2966
|
+
snapshot.update.update_id !== ids[0] ||
|
|
2967
|
+
Object.keys(snapshot.update).some((key) => key !== "update_id" && key !== "message") ||
|
|
2968
|
+
snapshot.source.journalBindingKey !== deps.getAdmissionJournalBinding?.() ||
|
|
2969
|
+
!original ||
|
|
2970
|
+
original.chat?.type !== "private" ||
|
|
2971
|
+
original.chat.id !== pending.sourceTarget.chatId ||
|
|
2972
|
+
original.from?.id !== deps.configStore.getAllowedUserId() ||
|
|
2973
|
+
original.from?.is_bot !== false ||
|
|
2974
|
+
!Number.isSafeInteger(original.message_id) ||
|
|
2975
|
+
original.message_id <= 0
|
|
2976
|
+
)
|
|
2977
|
+
return undefined;
|
|
2978
|
+
return { message: original, updateId: ids[0]! };
|
|
2979
|
+
});
|
|
2980
|
+
if (originals.length === 0 || originals.some((source) => !source)) return;
|
|
2981
|
+
originals.sort((a, b) => a!.message.message_id - b!.message.message_id);
|
|
2982
|
+
const sourceMessageIds = originals.map((source) => source!.message.message_id);
|
|
2983
|
+
const sourceUpdateIds = originals.map((source) => source!.updateId);
|
|
2984
|
+
if (
|
|
2985
|
+
new Set(sourceMessageIds).size !== sourceMessageIds.length ||
|
|
2986
|
+
new Set(sourceUpdateIds).size !== sourceUpdateIds.length
|
|
2987
|
+
)
|
|
2988
|
+
return;
|
|
2989
|
+
const copied = pending.allTabCopiesForwarded;
|
|
2990
|
+
if (copied) {
|
|
2991
|
+
if (
|
|
2992
|
+
!isDeepStrictEqual(copied.target, pending.sourceTarget) ||
|
|
2993
|
+
!isDeepStrictEqual(copied.sourceMessageIds, sourceMessageIds) ||
|
|
2994
|
+
copied.messageIds.length !== sourceMessageIds.length ||
|
|
2995
|
+
!copied.messageIds.every((id) => Number.isSafeInteger(id) && id > 0) ||
|
|
2996
|
+
new Set(copied.messageIds).size !== copied.messageIds.length
|
|
2997
|
+
)
|
|
2998
|
+
return;
|
|
2999
|
+
return { chatId: copied.target.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...copied.messageIds] };
|
|
3000
|
+
}
|
|
3001
|
+
const nativeOriginals = originals.every((source) =>
|
|
3002
|
+
typeof pending.sourceTarget.threadId === "number" &&
|
|
3003
|
+
source!.message.message_thread_id === pending.sourceTarget.threadId);
|
|
3004
|
+
const retainedAllOriginals = pending.allTabSourceRetained === true &&
|
|
3005
|
+
originals.every((source) => source!.message.message_thread_id === undefined) &&
|
|
3006
|
+
isDeepStrictEqual([...(pending.allTabCopies ?? [])].sort((a, b) => a - b), sourceMessageIds);
|
|
3007
|
+
if (nativeOriginals || retainedAllOriginals)
|
|
3008
|
+
return { chatId: pending.sourceTarget.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...sourceMessageIds] };
|
|
3009
|
+
} catch (error) {
|
|
3010
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-source-proof" });
|
|
3011
|
+
}
|
|
3012
|
+
return undefined;
|
|
3013
|
+
};
|
|
2906
3014
|
const dismissRerouteChooserMessage = async (
|
|
2907
3015
|
query: TCallbackQuery,
|
|
2908
3016
|
assertExecutionCurrent?: () => void,
|
|
@@ -6172,7 +6280,21 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6172
6280
|
for (const wake of [...liveRebindCleanupWakers.values()]) wake(reason);
|
|
6173
6281
|
};
|
|
6174
6282
|
const onSessionSettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "session-settled");
|
|
6175
|
-
const onDeliverySettled = (ctx: TContext): void =>
|
|
6283
|
+
const onDeliverySettled = (ctx: TContext): void => {
|
|
6284
|
+
wakeLiveRebindCleanup(ctx, "delivery-drained");
|
|
6285
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
6286
|
+
if (!cap?.isCurrent()) return;
|
|
6287
|
+
try {
|
|
6288
|
+
for (const entry of cap.store.listTemporaryThreads()) {
|
|
6289
|
+
if (!cap.isCurrent()) return;
|
|
6290
|
+
if (entry.phase === "created" && entry.target && !entry.cleanupIssued &&
|
|
6291
|
+
Threads.isTelegramTemporaryThreadFullyResolved(entry))
|
|
6292
|
+
scheduleTemporaryThreadCleanup(entry.target, ctx);
|
|
6293
|
+
}
|
|
6294
|
+
} catch (error) {
|
|
6295
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-drain" });
|
|
6296
|
+
}
|
|
6297
|
+
};
|
|
6176
6298
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
6177
6299
|
? async (input: {
|
|
6178
6300
|
operationId: string;
|
|
@@ -6802,6 +6924,97 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6802
6924
|
return false;
|
|
6803
6925
|
}
|
|
6804
6926
|
};
|
|
6927
|
+
/** Publish display copies under the native one-shot grant; positive output is not input/source settlement. */
|
|
6928
|
+
const publishPendingRerouteCopies = async (
|
|
6929
|
+
pending: PendingUnboundReroute,
|
|
6930
|
+
record: Threads.TelegramTopicTargetRecord,
|
|
6931
|
+
ctx: TContext,
|
|
6932
|
+
assertExecutionCurrent: () => void,
|
|
6933
|
+
foreign: boolean,
|
|
6934
|
+
): Promise<(() => void) | undefined> => {
|
|
6935
|
+
const source = resolvePendingRerouteForwardSource(pending);
|
|
6936
|
+
const target = { ...record.target };
|
|
6937
|
+
const recordIdentity = (value: Threads.TelegramTopicTargetRecord | undefined) => value && ({
|
|
6938
|
+
target: value.target, profileKey: value.profileKey, owner: value.owner,
|
|
6939
|
+
instanceId: value.instanceId, slot: value.slot, createdAtMs: value.createdAtMs,
|
|
6940
|
+
});
|
|
6941
|
+
const bindingIdentity = () => deps.threadStore!.listWorkspaceBindings()
|
|
6942
|
+
.filter((value) => isDeepStrictEqual(value.target, target))
|
|
6943
|
+
.map((value) => ({ bindingKey: value.bindingKey, sessionId: value.sessionId, sessionKey: value.sessionKey,
|
|
6944
|
+
cwd: value.cwd, workspaceKey: value.workspaceKey, instanceSlot: value.instanceSlot, slot: value.slot,
|
|
6945
|
+
target: value.target }));
|
|
6946
|
+
const expectedRecord = structuredClone(recordIdentity(record));
|
|
6947
|
+
const expectedBindings = structuredClone(bindingIdentity());
|
|
6948
|
+
const ownership = deps.getTargetOwnership?.(target);
|
|
6949
|
+
if (
|
|
6950
|
+
!source || !deps.callApi || expectedBindings.length > 1 ||
|
|
6951
|
+
(foreign && (!ownership || ownership.instanceId !== record.instanceId ||
|
|
6952
|
+
!ownership.ownerGeneration || !ownership.recipientBindingKey))
|
|
6953
|
+
)
|
|
6954
|
+
return;
|
|
6955
|
+
const expectedOwnership = ownership && structuredClone(ownership);
|
|
6956
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => {
|
|
6957
|
+
try {
|
|
6958
|
+
assertExecutionCurrent();
|
|
6959
|
+
const current = getTelegramRoutableThreadRecords(deps.threadStore!.list(), deps.getLiveThreadTargets?.())
|
|
6960
|
+
.find((value) => isDeepStrictEqual(value.target, target));
|
|
6961
|
+
return isDeepStrictEqual(recordIdentity(current), expectedRecord) &&
|
|
6962
|
+
isDeepStrictEqual(bindingIdentity(), expectedBindings) &&
|
|
6963
|
+
(!foreign || Bus.isTelegramBusForwardOwnershipCurrent(expectedOwnership!, deps.getTargetOwnership?.(target)));
|
|
6964
|
+
} catch { return false; }
|
|
6965
|
+
});
|
|
6966
|
+
if (!cap?.isCurrent()) return;
|
|
6967
|
+
const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
6968
|
+
if (!temporary || !issueTemporaryThreadForward(pending, ctx)) return;
|
|
6969
|
+
pending.phase = { kind: "forward-unknown" };
|
|
6970
|
+
const assertRecipientCurrent = (): void => {
|
|
6971
|
+
assertExecutionCurrent();
|
|
6972
|
+
if (!cap.isCurrent()) throw new Error("Reroute publication recipient authority changed.");
|
|
6973
|
+
};
|
|
6974
|
+
const assertPublicationCurrent = (): void => {
|
|
6975
|
+
assertRecipientCurrent();
|
|
6976
|
+
const entry = cap.store.listTemporaryThreads().find((value) => value.token === temporary.token);
|
|
6977
|
+
const ids = [...source.sourceUpdateIds].sort((a, b) => a - b);
|
|
6978
|
+
if (
|
|
6979
|
+
!entry || entry.phase !== "created" || entry.cleanupIssued ||
|
|
6980
|
+
entry.operatorUserId !== cap.operatorUserId ||
|
|
6981
|
+
!isDeepStrictEqual(entry.executor, cap.authority.executor) ||
|
|
6982
|
+
!isDeepStrictEqual(entry.target, pending.sourceTarget) ||
|
|
6983
|
+
!entry.forwardedInputs?.some((input) => input.journalBindingKey === cap.journalBindingKey &&
|
|
6984
|
+
isDeepStrictEqual(input.updateIds, ids)) ||
|
|
6985
|
+
!isDeepStrictEqual(resolvePendingRerouteForwardSource(pending), source)
|
|
6986
|
+
)
|
|
6987
|
+
throw new Error("Reroute publication source authority changed.");
|
|
6988
|
+
};
|
|
6989
|
+
try {
|
|
6990
|
+
assertPublicationCurrent();
|
|
6991
|
+
// Bot API requires ascending source IDs. Preserve the source-UID association even if copy IDs reorder them.
|
|
6992
|
+
const order = source.messageIds.map((_, index) => index)
|
|
6993
|
+
.sort((a, b) => source.messageIds[a]! - source.messageIds[b]!);
|
|
6994
|
+
const messageIds = order.map((index) => source.messageIds[index]!);
|
|
6995
|
+
const body = { chat_id: target.chatId, message_thread_id: target.threadId,
|
|
6996
|
+
from_chat_id: source.chatId, disable_notification: true };
|
|
6997
|
+
const options = { maxAttempts: 1, retrySafety: "non-idempotent" as const, assertAuthority: assertPublicationCurrent };
|
|
6998
|
+
const copies = messageIds.length === 1
|
|
6999
|
+
? [await deps.callApi<{ message_id?: unknown }>("forwardMessage", { ...body, message_id: messageIds[0] }, options)]
|
|
7000
|
+
: await deps.callApi<Array<{ message_id?: unknown }>>("forwardMessages", { ...body, message_ids: messageIds }, options);
|
|
7001
|
+
assertPublicationCurrent();
|
|
7002
|
+
const copyIds = Array.isArray(copies) ? copies.map((copy) => copy?.message_id) : [];
|
|
7003
|
+
if (
|
|
7004
|
+
copyIds.length !== messageIds.length ||
|
|
7005
|
+
!copyIds.every((id): id is number => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
|
|
7006
|
+
new Set(copyIds).size !== copyIds.length
|
|
7007
|
+
)
|
|
7008
|
+
return;
|
|
7009
|
+
pending.rerouteCopies = { target, bySourceUpdateId: new Map(order.map((index, position) =>
|
|
7010
|
+
[source.sourceUpdateIds[index]!, copyIds[position]!] as const)) };
|
|
7011
|
+
pending.phase = { kind: "selected" };
|
|
7012
|
+
return assertRecipientCurrent;
|
|
7013
|
+
} catch (error) {
|
|
7014
|
+
if (cap.isCurrent()) deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-destination-forward" });
|
|
7015
|
+
return;
|
|
7016
|
+
}
|
|
7017
|
+
};
|
|
6805
7018
|
const forwardPendingRerouteMessages = async (
|
|
6806
7019
|
pending: PendingUnboundReroute,
|
|
6807
7020
|
instanceId: string,
|
|
@@ -6827,12 +7040,19 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6827
7040
|
)
|
|
6828
7041
|
return false;
|
|
6829
7042
|
const ownership = structuredClone(liveOwnership);
|
|
6830
|
-
const
|
|
7043
|
+
const copies = pending.rerouteCopies;
|
|
7044
|
+
const published = !!copies && isDeepStrictEqual(copies.target, target);
|
|
7045
|
+
const messages = cloneTelegramMessagesForThread(
|
|
7046
|
+
pending.messages,
|
|
7047
|
+
threadId,
|
|
7048
|
+
published ? copies.bySourceUpdateId : undefined,
|
|
7049
|
+
);
|
|
6831
7050
|
// Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
|
|
6832
7051
|
// even after restart. Any refusal or publication failure sends nothing and keeps the input held.
|
|
6833
7052
|
if (
|
|
6834
7053
|
!recordForwardAcceptance &&
|
|
6835
7054
|
isTemporaryReroute(pending) &&
|
|
7055
|
+
!published &&
|
|
6836
7056
|
!issueTemporaryThreadForward(pending, ctx)
|
|
6837
7057
|
) {
|
|
6838
7058
|
if (isTemporaryThreadForwardIssued(pending))
|
|
@@ -6847,6 +7067,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6847
7067
|
message,
|
|
6848
7068
|
ownership,
|
|
6849
7069
|
ctx,
|
|
7070
|
+
assertAuthority() {
|
|
7071
|
+
assertExecutionCurrent?.();
|
|
7072
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
7073
|
+
},
|
|
6850
7074
|
}),
|
|
6851
7075
|
),
|
|
6852
7076
|
);
|
|
@@ -7073,6 +7297,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7073
7297
|
temporaryCleanupTimers.delete(token);
|
|
7074
7298
|
if (retry) restoreSettlementTasks.add(task);
|
|
7075
7299
|
runTemporaryThreadCleanup(token, cap, reconcileExpiry)
|
|
7300
|
+
.then(() => {
|
|
7301
|
+
if (!cap.isCurrent()) return;
|
|
7302
|
+
const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
|
|
7303
|
+
// A follower ACK can precede recipient receipt disposal. Reobserve a protected, unissued tab;
|
|
7304
|
+
// elapsed time never grants deletion, and an issued/unknown delete can never enter this path.
|
|
7305
|
+
if (retained?.phase === "created" && !retained.cleanupIssued &&
|
|
7306
|
+
Threads.isTelegramTemporaryThreadFullyResolved(retained) &&
|
|
7307
|
+
cap.store.inspectTemporaryThreadTarget(retained, cap.authority)?.kind === "temporary" && cap.isCurrent())
|
|
7308
|
+
queueTemporaryThreadCleanup(token, cap, 1000, reconcileExpiry, true);
|
|
7309
|
+
})
|
|
7076
7310
|
.catch((error) => {
|
|
7077
7311
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
7078
7312
|
phase: "temporary-thread-cleanup",
|
|
@@ -7799,7 +8033,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7799
8033
|
).includes(source.original.updateId)
|
|
7800
8034
|
)
|
|
7801
8035
|
continue;
|
|
7802
|
-
movedIntoTab ||= pending.allTabCopiesForwarded
|
|
8036
|
+
movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
|
|
7803
8037
|
removePendingReroute(id);
|
|
7804
8038
|
if (
|
|
7805
8039
|
signal.aborted ||
|
|
@@ -8901,6 +9135,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8901
9135
|
);
|
|
8902
9136
|
return true;
|
|
8903
9137
|
}
|
|
9138
|
+
if (
|
|
9139
|
+
!parsed.useNewSlot &&
|
|
9140
|
+
isTemporaryReroute(pending) &&
|
|
9141
|
+
!resolvePendingRerouteForwardSource(pending)
|
|
9142
|
+
) {
|
|
9143
|
+
await deps.answerCallbackQuery(query.id, "Source copy unavailable; not routing");
|
|
9144
|
+
return true;
|
|
9145
|
+
}
|
|
8904
9146
|
if (!(await selectPendingDestination())) return true;
|
|
8905
9147
|
if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
|
|
8906
9148
|
const selection = (pending.workspaceRestore ??= {
|
|
@@ -9006,6 +9248,19 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9006
9248
|
);
|
|
9007
9249
|
return true;
|
|
9008
9250
|
}
|
|
9251
|
+
let assertRerouteCurrent = assertExecutionCurrent;
|
|
9252
|
+
if (isTemporaryReroute(pending)) {
|
|
9253
|
+
const confirmed = await publishPendingRerouteCopies(pending, record, ctx, assertExecutionCurrent,
|
|
9254
|
+
!!record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord);
|
|
9255
|
+
if (!confirmed) {
|
|
9256
|
+
await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
|
|
9257
|
+
? "Publication unconfirmed; not resending"
|
|
9258
|
+
: "Publication unavailable; input kept");
|
|
9259
|
+
return true;
|
|
9260
|
+
}
|
|
9261
|
+
assertRerouteCurrent = confirmed;
|
|
9262
|
+
}
|
|
9263
|
+
assertRerouteCurrent();
|
|
9009
9264
|
if (
|
|
9010
9265
|
record.instanceId &&
|
|
9011
9266
|
record.instanceId !== currentInstanceId &&
|
|
@@ -9016,7 +9271,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9016
9271
|
record.instanceId,
|
|
9017
9272
|
parsed.threadId,
|
|
9018
9273
|
ctx,
|
|
9019
|
-
|
|
9274
|
+
assertRerouteCurrent,
|
|
9020
9275
|
);
|
|
9021
9276
|
if (!allForwarded) {
|
|
9022
9277
|
await deps.answerCallbackQuery(
|
|
@@ -9032,6 +9287,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9032
9287
|
// Local handlers and queue admission consume the same one-time group grant as follower forwarding.
|
|
9033
9288
|
if (
|
|
9034
9289
|
isTemporaryReroute(pending) &&
|
|
9290
|
+
!pending.rerouteCopies &&
|
|
9035
9291
|
!issueTemporaryThreadForward(pending, ctx)
|
|
9036
9292
|
) {
|
|
9037
9293
|
await deps.answerCallbackQuery(
|
|
@@ -9040,7 +9296,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9040
9296
|
);
|
|
9041
9297
|
return true;
|
|
9042
9298
|
}
|
|
9043
|
-
|
|
9299
|
+
const localMessages = pending.rerouteCopies
|
|
9300
|
+
? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
|
|
9301
|
+
: reroutedMessages;
|
|
9302
|
+
await dispatchPendingRerouteMessages(pending, localMessages, ctx);
|
|
9303
|
+
assertRerouteCurrent();
|
|
9044
9304
|
pending.messages = [];
|
|
9045
9305
|
return completeRoutedReroute();
|
|
9046
9306
|
};
|
|
@@ -10632,6 +10892,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10632
10892
|
const tabCopyId = openedNow
|
|
10633
10893
|
? await moveAllTabSourceIntoTab(pending, target, current)
|
|
10634
10894
|
: undefined;
|
|
10895
|
+
if (!current()) {
|
|
10896
|
+
removePendingReroute(rerouteId);
|
|
10897
|
+
throw new Error("Temporary Thread source presentation authority changed.");
|
|
10898
|
+
}
|
|
10899
|
+
if (
|
|
10900
|
+
openedNow &&
|
|
10901
|
+
!pending.allTabCopiesForwarded &&
|
|
10902
|
+
pending.allTabCopies.length === held.length
|
|
10903
|
+
)
|
|
10904
|
+
pending.allTabSourceRetained = true;
|
|
10635
10905
|
try {
|
|
10636
10906
|
if (!deps.sendInteractiveMessage)
|
|
10637
10907
|
throw new Error("Temporary Thread chooser publication is unavailable.");
|
package/lib/turns.ts
CHANGED
|
@@ -51,6 +51,13 @@ export function withoutTelegramTurnReplyAnchor<TMessage extends object>(message:
|
|
|
51
51
|
return { ...message, [REPLY_ANCHOR_SUPPRESSED]: true };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** A confirmed same-target copy may replace the former-tab suppression on a warm input projection. */
|
|
55
|
+
export function withTelegramTurnReplyAnchor<TMessage extends { message_id: number }>(message: TMessage): TMessage {
|
|
56
|
+
if (!Number.isSafeInteger(message.message_id) || message.message_id <= 0)
|
|
57
|
+
throw new Error("Telegram reply anchor requires a positive message ID.");
|
|
58
|
+
return { ...message, [REPLY_ANCHOR_SUPPRESSED]: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
export const TELEGRAM_GUEST_TURN_NOTE =
|
|
55
62
|
"[guest] delivery: answer quickly with one concise, self-contained reply";
|
|
56
63
|
|
package/lib/updates.ts
CHANGED
|
@@ -374,6 +374,8 @@ export interface TelegramForeignOwnedUpdateForwarder<
|
|
|
374
374
|
message: TMessage;
|
|
375
375
|
ownership: TelegramTargetOwnershipView;
|
|
376
376
|
ctx: TContext;
|
|
377
|
+
/** Local issuance/ACK fence; implementations must not serialize it into payloads. */
|
|
378
|
+
assertAuthority?: () => void;
|
|
377
379
|
}) =>
|
|
378
380
|
| Promise<TelegramBusForeignUpdateSettlement>
|
|
379
381
|
| TelegramBusForeignUpdateSettlement;
|