@llblab/pi-kit 0.27.6 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +2 -5
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/AGENTS.md +10 -8
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -9
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-telegram/LICENSE +21 -0
- package/node_modules/@llblab/pi-telegram/README.md +15 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +5 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +6 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +54 -56
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +206 -139
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +181 -25
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.d.ts +4 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +28 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +52 -56
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +78 -246
- package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +1 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +23 -26
- package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +0 -23
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +15 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/config.js +12 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +0 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +2 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +255 -61
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -19
- package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +177 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +1115 -249
- package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +100 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +380 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +30 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +129 -72
- package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +28 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/media.js +26 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +0 -11
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +8 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +0 -18
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +18 -18
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +4 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +12 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +1 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +1 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +4 -21
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +0 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +50 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +155 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/preview.d.ts +0 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +3 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +3 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +69 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +29 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +104 -33
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +0 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +73 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +2206 -307
- package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +44 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/status.js +149 -27
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +34 -23
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +18 -21
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +32 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +191 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +291 -59
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +1647 -369
- package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +184 -28
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +1032 -114
- package/node_modules/@llblab/pi-telegram/dist/lib/wire.d.ts +12 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +21 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +25 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +95 -20
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.d.ts +20 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +103 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +43 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +148 -52
- package/node_modules/@llblab/pi-telegram/dist/package.json +6 -6
- package/node_modules/@llblab/pi-telegram/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/activity.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +152 -34
- package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/delivery.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +125 -19
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
- package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +5 -9
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +6 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +243 -224
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +178 -31
- package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +30 -8
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +110 -334
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +29 -29
- package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +15 -15
- package/node_modules/@llblab/pi-telegram/lib/config.ts +12 -17
- package/node_modules/@llblab/pi-telegram/lib/delivery.ts +2 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +257 -72
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +0 -22
- package/node_modules/@llblab/pi-telegram/lib/inbound.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +1182 -326
- package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +402 -23
- package/node_modules/@llblab/pi-telegram/lib/logging.ts +151 -101
- package/node_modules/@llblab/pi-telegram/lib/media.ts +51 -9
- package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +8 -8
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +18 -18
- package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +11 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +5 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +1 -10
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +5 -24
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +177 -17
- package/node_modules/@llblab/pi-telegram/lib/pi.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +3 -3
- package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +67 -9
- package/node_modules/@llblab/pi-telegram/lib/recovery.ts +104 -44
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +1955 -375
- package/node_modules/@llblab/pi-telegram/lib/sections.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/status.ts +156 -36
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +48 -33
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +24 -21
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +267 -9
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +3 -1
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1697 -488
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1072 -151
- package/node_modules/@llblab/pi-telegram/lib/wire.ts +28 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +97 -47
- package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +147 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +188 -89
- package/node_modules/@llblab/pi-telegram/package.json +6 -6
- package/node_modules/@llblab/pi-telegram/scripts/audit-exports.mjs +100 -0
- package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +80 -43
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +3 -3
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/package.json +2 -2
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* Zones: telegram inbound, orchestration, queue/menu/command composition
|
|
4
4
|
* Wires authorized updates into menus, commands, media grouping, and prompt queueing, and owns exact assistant-output target/route authority capture
|
|
5
5
|
*/
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
6
7
|
import { readFile } from "node:fs/promises";
|
|
7
8
|
import { basename, dirname } from "node:path";
|
|
9
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
10
|
import * as Bus from "./bus.js";
|
|
9
11
|
import * as Commands from "./commands.js";
|
|
10
12
|
import * as Media from "./media.js";
|
|
@@ -17,6 +19,7 @@ import * as TextGroups from "./text-groups.js";
|
|
|
17
19
|
import * as ThreadNaming from "./thread-naming.js";
|
|
18
20
|
import * as ThreadReconciler from "./thread-reconciler.js";
|
|
19
21
|
import * as Turns from "./turns.js";
|
|
22
|
+
import * as WorkspaceIdentity from "./workspace-identity.js";
|
|
20
23
|
function formatTelegramPromptPeer(peer) {
|
|
21
24
|
if (!peer)
|
|
22
25
|
return undefined;
|
|
@@ -63,6 +66,21 @@ export function resolveTelegramGuestPromptPeer(input) {
|
|
|
63
66
|
}
|
|
64
67
|
return undefined;
|
|
65
68
|
}
|
|
69
|
+
/** Stable file scope of the remote Guest Mode peer: username, else numeric id; never the bot's own scope. */
|
|
70
|
+
export function resolveTelegramGuestFileScope(input) {
|
|
71
|
+
const candidates = input.chatType !== "private" ? [input.chat]
|
|
72
|
+
: [input.from, input.chat, input.guestBotCallerUser, input.guestBotCallerChat, input.replyFrom];
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
if (!candidate || (input.chatType === "private" &&
|
|
75
|
+
(isTelegramPromptOwnerPeer(candidate, input.ownerUserId) || isTelegramPromptBotPeer(candidate))))
|
|
76
|
+
continue;
|
|
77
|
+
if (typeof candidate.username === "string" && candidate.username.length > 0)
|
|
78
|
+
return candidate.username;
|
|
79
|
+
if (typeof candidate.id === "number" && Number.isSafeInteger(candidate.id))
|
|
80
|
+
return String(Math.abs(candidate.id));
|
|
81
|
+
}
|
|
82
|
+
return "guest";
|
|
83
|
+
}
|
|
66
84
|
function appendTelegramSourceAttachmentSection(text, from, files, outputs = []) {
|
|
67
85
|
if (files.length === 0 && outputs.length === 0)
|
|
68
86
|
return text;
|
|
@@ -117,7 +135,12 @@ function escapeHtml(text) {
|
|
|
117
135
|
}
|
|
118
136
|
const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
|
|
119
137
|
const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
|
|
138
|
+
const TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX = "reroutemenu:";
|
|
139
|
+
const TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX = "rerouteroot:";
|
|
120
140
|
const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
|
|
141
|
+
const TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX = "reroutecancel:";
|
|
142
|
+
const TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX = "reroutecancel:review:";
|
|
143
|
+
const TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX = "reroutecancel:history:";
|
|
121
144
|
const TELEGRAM_SLOT_CAPACITY_MESSAGE = "No Telegram instance slot is available. Automatic reclamation is disabled for safety.";
|
|
122
145
|
function formatTelegramUnboundRerouteCallbackData(rerouteId, threadId) {
|
|
123
146
|
return `${TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
|
|
@@ -129,9 +152,9 @@ function formatTelegramUnboundRerouteNewSlotCallbackData(rerouteId, threadId) {
|
|
|
129
152
|
return `${TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
|
|
130
153
|
}
|
|
131
154
|
function parseTelegramUnboundRerouteRestoreMenuCallbackData(data) {
|
|
132
|
-
const match = data?.match(/^rerouterestore:([a-z0-9]+)$/);
|
|
133
|
-
const rerouteId = match?.[
|
|
134
|
-
return rerouteId ? { rerouteId } : undefined;
|
|
155
|
+
const match = data?.match(/^(rerouterestore|reroutemenu|rerouteroot):([a-z0-9]+)$/);
|
|
156
|
+
const rerouteId = match?.[2];
|
|
157
|
+
return rerouteId ? { rerouteId, restore: match?.[1] === "rerouterestore", root: match?.[1] === "rerouteroot" } : undefined;
|
|
135
158
|
}
|
|
136
159
|
function parseTelegramUnboundRerouteCallbackData(data) {
|
|
137
160
|
const match = data?.match(/^(reroute|reroutenew):([a-z0-9]+):(\d+)$/);
|
|
@@ -145,17 +168,11 @@ function parseTelegramUnboundRerouteCallbackData(data) {
|
|
|
145
168
|
function getTelegramThreadRecordLabel(record, getDisplayTitle) {
|
|
146
169
|
return getDisplayTitle?.(record.target) ?? getRestoredThreadName(record, record.slot ?? "");
|
|
147
170
|
}
|
|
148
|
-
function getNextTelegramSlotPreference(slot) {
|
|
149
|
-
if (!slot || !/^[A-Z]$/.test(slot))
|
|
150
|
-
return undefined;
|
|
151
|
-
const index = slot.charCodeAt(0) - "A".charCodeAt(0);
|
|
152
|
-
return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26));
|
|
153
|
-
}
|
|
154
171
|
function getRestoredThreadName(record, slot) {
|
|
155
172
|
return record.threadName &&
|
|
156
|
-
|
|
173
|
+
ThreadNaming.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
|
|
157
174
|
? record.threadName
|
|
158
|
-
: (
|
|
175
|
+
: (ThreadNaming.chooseTelegramThreadName({ slot }) ?? "Pi");
|
|
159
176
|
}
|
|
160
177
|
function isTelegramLiveThreadTarget(record, liveTargets) {
|
|
161
178
|
if (!liveTargets)
|
|
@@ -167,6 +184,14 @@ function getTelegramRoutableThreadRecords(records, liveTargets) {
|
|
|
167
184
|
return records.filter((record) => record.status === "active" &&
|
|
168
185
|
isTelegramLiveThreadTarget(record, liveTargets));
|
|
169
186
|
}
|
|
187
|
+
function formatTelegramTemporaryThreadChooserText(command) {
|
|
188
|
+
return [
|
|
189
|
+
"<b>🧵 Choose target thread:</b>",
|
|
190
|
+
"",
|
|
191
|
+
command ? `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.` : "Choose where to send your message.",
|
|
192
|
+
"Select the Pi thread that should handle it, or restore a Pi into this tab:",
|
|
193
|
+
].join("\n");
|
|
194
|
+
}
|
|
170
195
|
function formatTelegramAllTabMenuChooserText(command) {
|
|
171
196
|
return [
|
|
172
197
|
"<b>🧵 Choose target thread:</b>",
|
|
@@ -179,25 +204,19 @@ function formatTelegramAllTabMenuChooserText(command) {
|
|
|
179
204
|
function buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, options) {
|
|
180
205
|
const activeRecords = records.filter((record) => record.status === "active");
|
|
181
206
|
const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
|
|
182
|
-
const rows = activeRecords.
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
|
|
196
|
-
},
|
|
197
|
-
],
|
|
198
|
-
]
|
|
199
|
-
: rows,
|
|
200
|
-
};
|
|
207
|
+
const rows = activeRecords.length
|
|
208
|
+
? [[{ text: "↪️ Reroute…", callback_data: `${TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX}${rerouteId}` }]] : [];
|
|
209
|
+
if (canRestoreAnyLiveThread)
|
|
210
|
+
rows.push([{
|
|
211
|
+
text: "🔁 Restore…",
|
|
212
|
+
callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
|
|
213
|
+
}]);
|
|
214
|
+
if (options.canCancel)
|
|
215
|
+
rows.push([{
|
|
216
|
+
text: "⛔️ Cancel routing",
|
|
217
|
+
callback_data: `${TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX}${rerouteId}`,
|
|
218
|
+
}]);
|
|
219
|
+
return { inline_keyboard: rows };
|
|
201
220
|
}
|
|
202
221
|
function buildTelegramUnboundRerouteRestoreChooserMarkup(rerouteId, records, getDisplayTitle) {
|
|
203
222
|
return {
|
|
@@ -231,20 +250,109 @@ function formatTelegramUnboundTopicGuidance() {
|
|
|
231
250
|
function formatTelegramTargetKey(target) {
|
|
232
251
|
return `${target.chatId}:${target.threadId ?? "all"}`;
|
|
233
252
|
}
|
|
234
|
-
function formatTelegramUnboundRerouteChooserText(_records, options = {}) {
|
|
235
|
-
const rerouteText = [
|
|
236
|
-
"<b>🧵 Choose target thread:</b>",
|
|
237
|
-
"",
|
|
238
|
-
"Your message is still in this Telegram thread.",
|
|
239
|
-
"Select the Pi thread that should handle it:",
|
|
240
|
-
].join("\n");
|
|
241
|
-
return options.includeGuidance === false
|
|
242
|
-
? rerouteText
|
|
243
|
-
: [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
|
|
244
|
-
}
|
|
245
253
|
import * as Threads from "./threads.js";
|
|
246
254
|
import * as Updates from "./updates.js";
|
|
247
255
|
import { getTelegramVoiceReplyMode } from "./voice.js";
|
|
256
|
+
/** One admitted Restore attempt through recipient readiness, never source dispatch or cleanup. */
|
|
257
|
+
export async function advanceTelegramWorkspaceRestore(input) {
|
|
258
|
+
const isCurrent = input.authority.isCurrent.bind(input.authority);
|
|
259
|
+
if (!isCurrent())
|
|
260
|
+
return undefined;
|
|
261
|
+
const request = structuredClone(input.request);
|
|
262
|
+
if (!Threads.isTelegramWorkspaceRestoreRequest(request))
|
|
263
|
+
return undefined;
|
|
264
|
+
const getRecipient = input.getRecipient;
|
|
265
|
+
const runRecipient = input.runRecipient;
|
|
266
|
+
const observedRecipient = getRecipient();
|
|
267
|
+
if (!Threads.isTelegramWorkspaceRestoreRecipient(observedRecipient) || observedRecipient.sessionId !== request.binding.sessionId)
|
|
268
|
+
return undefined;
|
|
269
|
+
const recipient = structuredClone(observedRecipient);
|
|
270
|
+
const executor = structuredClone(input.authority.executor);
|
|
271
|
+
const operatorUserId = input.authority.operatorUserId;
|
|
272
|
+
const current = () => isCurrent() && input.authority.operatorUserId === operatorUserId &&
|
|
273
|
+
isDeepStrictEqual(input.authority.executor, executor) && isDeepStrictEqual(getRecipient(), recipient);
|
|
274
|
+
const authority = { executor, operatorUserId, isCurrent: current };
|
|
275
|
+
const { restoreStore } = input;
|
|
276
|
+
const retained = () => {
|
|
277
|
+
if (!current())
|
|
278
|
+
return undefined;
|
|
279
|
+
const found = restoreStore.list().find(value => value.request.operationId === request.operationId);
|
|
280
|
+
return current() && found && isDeepStrictEqual(found.request, request) && found.operatorUserId === operatorUserId &&
|
|
281
|
+
isDeepStrictEqual(found.executor, executor) ? found : undefined;
|
|
282
|
+
};
|
|
283
|
+
let intent = retained();
|
|
284
|
+
const predecessor = intent ? undefined : restoreStore.list().find(value => value.request.operationId === request.operationId);
|
|
285
|
+
if (predecessor && current() && isDeepStrictEqual(predecessor.request, request) &&
|
|
286
|
+
predecessor.operatorUserId === operatorUserId && !isDeepStrictEqual(predecessor.executor, executor)) {
|
|
287
|
+
// Adoption transfers executor authority only. Issued grants stay issued, so a successor can merely inspect them.
|
|
288
|
+
try {
|
|
289
|
+
intent = restoreStore.adopt(predecessor, authority);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
intent = retained();
|
|
293
|
+
if (!intent)
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
if (!current() || !intent)
|
|
297
|
+
return undefined;
|
|
298
|
+
intent = retained();
|
|
299
|
+
if (!intent)
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
if (input.inspectOnly && !intent)
|
|
303
|
+
return undefined;
|
|
304
|
+
if (!intent || intent.phase === "relocated") {
|
|
305
|
+
if ((!input.inspectOnly && recipient.instanceId !== request.owner.instanceId) ||
|
|
306
|
+
recipient.kind !== (request.owner.owner?.kind === "leader" ? "leader" : "follower"))
|
|
307
|
+
return undefined;
|
|
308
|
+
intent = await restoreStore.commit(request, authority);
|
|
309
|
+
if (!current() || !intent)
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
let mode = "inspect";
|
|
313
|
+
if (intent.phase === "relocated") {
|
|
314
|
+
try {
|
|
315
|
+
const issuance = restoreStore.issueRecipient(intent, recipient, authority);
|
|
316
|
+
if (issuance) {
|
|
317
|
+
intent = issuance.intent;
|
|
318
|
+
mode = input.inspectOnly ? "inspect" : "apply";
|
|
319
|
+
}
|
|
320
|
+
else
|
|
321
|
+
intent = retained();
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
intent = retained();
|
|
325
|
+
if (intent?.phase !== "recipient-issued")
|
|
326
|
+
throw error;
|
|
327
|
+
// A retained issued phase is not a fresh grant, even when publication's reply was lost.
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (!current() || !intent || !["recipient-issued", "ready"].includes(intent.phase) ||
|
|
331
|
+
(mode === "apply" && !isDeepStrictEqual(intent.recipient, recipient)))
|
|
332
|
+
return undefined;
|
|
333
|
+
const expected = structuredClone(intent);
|
|
334
|
+
if (!isDeepStrictEqual(retained(), expected))
|
|
335
|
+
return undefined;
|
|
336
|
+
const observation = await runRecipient({ intent: structuredClone(expected), mode, isCurrent: current });
|
|
337
|
+
if (!current() || !isDeepStrictEqual(retained(), expected) || !observation || observation.ready !== true ||
|
|
338
|
+
observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
|
|
339
|
+
!isDeepStrictEqual(observation.target, request.target) || observation.slot !== request.binding.slot)
|
|
340
|
+
return undefined;
|
|
341
|
+
if (expected.phase === "ready" && isDeepStrictEqual(expected.readyRecipient ?? expected.recipient, recipient))
|
|
342
|
+
return retained();
|
|
343
|
+
try {
|
|
344
|
+
intent = mode === "inspect" ? restoreStore.confirmInspectedReady(expected, recipient, authority) :
|
|
345
|
+
restoreStore.confirmReady(expected, recipient, authority);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
intent = retained();
|
|
349
|
+
if (intent?.phase !== "ready" || !isDeepStrictEqual(intent.readyRecipient ?? intent.recipient, recipient))
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
const confirmed = retained();
|
|
353
|
+
return current() && confirmed?.phase === "ready" &&
|
|
354
|
+
isDeepStrictEqual(confirmed.readyRecipient ?? confirmed.recipient, recipient) ? confirmed : undefined;
|
|
355
|
+
}
|
|
248
356
|
async function deleteReservedTelegramTopicThroughReconciler(deps, target, messageId) {
|
|
249
357
|
if (!deps.threadStore)
|
|
250
358
|
return false;
|
|
@@ -280,6 +388,8 @@ async function deleteReservedTelegramTopicThroughReconciler(deps, target, messag
|
|
|
280
388
|
return plan.actions.some((action) => action.kind === "close-delete-reserved-topic");
|
|
281
389
|
}
|
|
282
390
|
export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
|
|
391
|
+
/** One user-facing answer for expired or previous-process routing controls. */
|
|
392
|
+
export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "⌛ Routing choice expired.";
|
|
283
393
|
export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
|
|
284
394
|
return message.message_thread_id === undefined &&
|
|
285
395
|
typeof message.date === "number" && Number.isFinite(message.date) &&
|
|
@@ -319,6 +429,7 @@ export function createTelegramInboundBusProjectionRuntime(deps) {
|
|
|
319
429
|
const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
|
|
320
430
|
"allmenu:",
|
|
321
431
|
TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
|
|
432
|
+
TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX,
|
|
322
433
|
"compact:",
|
|
323
434
|
"menu:",
|
|
324
435
|
"model:",
|
|
@@ -335,8 +446,10 @@ function isTelegramOwnedCallbackData(data) {
|
|
|
335
446
|
}
|
|
336
447
|
export function createTelegramInboundRouteRuntime(deps) {
|
|
337
448
|
const pendingUnboundReroutes = new Map();
|
|
449
|
+
const implicitThreadCreations = new Map();
|
|
338
450
|
const guidedUnboundTopicKeys = new Set();
|
|
339
451
|
let nextUnboundRerouteId = 0;
|
|
452
|
+
let cancellationReview;
|
|
340
453
|
const requestDispatchNextQueuedTelegramTurn = (ctx) => {
|
|
341
454
|
deps.dispatchNextQueuedTelegramTurn(ctx);
|
|
342
455
|
if (deps.requestDeferredDispatchNextQueuedTelegramTurn &&
|
|
@@ -368,7 +481,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
368
481
|
continue;
|
|
369
482
|
}
|
|
370
483
|
return record.threadName &&
|
|
371
|
-
|
|
484
|
+
ThreadNaming.isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot)
|
|
372
485
|
? record.threadName
|
|
373
486
|
: getRestoredThreadName(record, record.slot ?? "");
|
|
374
487
|
}
|
|
@@ -449,33 +562,30 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
449
562
|
};
|
|
450
563
|
schedule();
|
|
451
564
|
};
|
|
452
|
-
const
|
|
453
|
-
|
|
565
|
+
const prunePendingCommandReroutes = () => {
|
|
566
|
+
// Deferred prompts and selected routes with cleanup work must retain their control.
|
|
567
|
+
// Age is not settlement; the chooser capacity limit still bounds retained entries.
|
|
454
568
|
for (const [id, entry] of pendingUnboundReroutes) {
|
|
455
569
|
if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
|
|
456
570
|
expirePendingCommand(id, entry);
|
|
457
571
|
}
|
|
458
|
-
else if (nowMs - entry.createdAtMs > 30 * 60_000) {
|
|
459
|
-
removePendingReroute(id);
|
|
460
|
-
}
|
|
461
572
|
}
|
|
462
573
|
};
|
|
463
|
-
const storePendingUnboundReroute = (messages, dispatchKind = "prompt") => {
|
|
464
|
-
|
|
574
|
+
const storePendingUnboundReroute = (messages, dispatchKind = "prompt", presentationTarget) => {
|
|
575
|
+
prunePendingCommandReroutes();
|
|
465
576
|
if (pendingUnboundReroutes.size >= 100) {
|
|
466
577
|
throw new Error("Telegram route chooser capacity reached; source remains retryable.");
|
|
467
578
|
}
|
|
468
579
|
nextUnboundRerouteId += 1;
|
|
469
580
|
const id = nextUnboundRerouteId.toString(36);
|
|
470
581
|
pendingUnboundReroutes.set(id, {
|
|
471
|
-
sourceTarget: {
|
|
582
|
+
sourceTarget: presentationTarget ? { ...presentationTarget } : {
|
|
472
583
|
chatId: messages[0].chat.id,
|
|
473
584
|
...(typeof messages[0].message_thread_id === "number"
|
|
474
585
|
? { threadId: messages[0].message_thread_id }
|
|
475
586
|
: {}),
|
|
476
587
|
},
|
|
477
588
|
messages,
|
|
478
|
-
createdAtMs: Date.now(),
|
|
479
589
|
dispatchKind,
|
|
480
590
|
});
|
|
481
591
|
const pending = pendingUnboundReroutes.get(id);
|
|
@@ -487,6 +597,19 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
487
597
|
if (!pending)
|
|
488
598
|
return;
|
|
489
599
|
pending.chooserMessageId = messageId;
|
|
600
|
+
if (Number.isSafeInteger(messageId) && messageId > 0) {
|
|
601
|
+
const owner = deps.configStore.getAllowedUserId();
|
|
602
|
+
if (owner !== undefined && pending.messages.every(source => source.from?.id === owner && !source.from.is_bot && source.chat.type === "private")) {
|
|
603
|
+
try {
|
|
604
|
+
const lifetime = Updates.armTelegramRoutingInputs(pending.messages, owner);
|
|
605
|
+
if (lifetime)
|
|
606
|
+
pending.routingOperatorUserId = lifetime.operatorUserId;
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-clock" });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
490
613
|
if (messageId === undefined || pending.dispatchKind !== "command" ||
|
|
491
614
|
pending.sourceTarget.threadId !== undefined || pending.selectionAttempted)
|
|
492
615
|
return;
|
|
@@ -587,15 +710,48 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
587
710
|
reply_to_message: undefined,
|
|
588
711
|
}));
|
|
589
712
|
};
|
|
590
|
-
|
|
713
|
+
/** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
|
|
714
|
+
const hasPendingRerouteForTarget = (target, own) => [...pendingUnboundReroutes.values()].some(pending => pending.sourceTarget.chatId === target.chatId &&
|
|
715
|
+
pending.sourceTarget.threadId === target.threadId && !own?.(pending));
|
|
716
|
+
const isOwnTemporaryChooser = (entry) => (pending) => pending.temporaryThread?.token === entry.token && isDeepStrictEqual(pending.temporaryThread.source, entry.source);
|
|
717
|
+
const hasOtherTemporaryThreadReroute = (target, own) => hasPendingRerouteForTarget(target, isOwnTemporaryChooser(own));
|
|
718
|
+
const applyThreadCleanupPlan = async (plan, assertExecutionCurrent, restoreCleanup, temporaryCleanup) => {
|
|
591
719
|
assertExecutionCurrent?.();
|
|
720
|
+
if ((restoreCleanup && restoreCleanup.routing?.cleanup !== "issued") ||
|
|
721
|
+
(temporaryCleanup && !temporaryCleanup.cleanupIssued))
|
|
722
|
+
return false;
|
|
723
|
+
let protectedTarget = false, temporaryDeleteConfirmed = false, temporaryTransportUncertain = false;
|
|
592
724
|
deps.recordThreadReconciliationPlan?.(plan);
|
|
593
725
|
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
726
|
+
skipCloseBeforeDelete: (temporaryCleanup?.target?.chatId ?? 0) > 0,
|
|
594
727
|
isCleanupTargetProtected(target) {
|
|
595
728
|
assertExecutionCurrent?.();
|
|
596
|
-
|
|
729
|
+
// A tab's first source is not authority over sibling choosers, including unsettled cleanup-only work.
|
|
730
|
+
const siblingProtected = !!temporaryCleanup && hasOtherTemporaryThreadReroute(target, temporaryCleanup);
|
|
731
|
+
const lostTemporaryGrant = !!temporaryCleanup && !deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads()
|
|
732
|
+
.some(entry => isDeepStrictEqual(entry, temporaryCleanup));
|
|
733
|
+
const protectedNow = temporaryTransportUncertain || lostTemporaryGrant || siblingProtected || isRerouteTargetProtected(target, restoreCleanup, temporaryCleanup);
|
|
734
|
+
protectedTarget ||= protectedNow;
|
|
735
|
+
return protectedNow;
|
|
597
736
|
},
|
|
598
|
-
callApi: deps.callApi
|
|
737
|
+
callApi: temporaryCleanup && deps.callApi
|
|
738
|
+
? async (method, body) => {
|
|
739
|
+
try {
|
|
740
|
+
assertExecutionCurrent?.();
|
|
741
|
+
const result = await deps.callApi(method, body, { maxAttempts: 1, retrySafety: "non-idempotent" });
|
|
742
|
+
assertExecutionCurrent?.();
|
|
743
|
+
if (result !== true)
|
|
744
|
+
throw new Error("Temporary Thread cleanup lacks a positive API acknowledgement.");
|
|
745
|
+
if (method === "deleteForumTopic")
|
|
746
|
+
temporaryDeleteConfirmed = true;
|
|
747
|
+
return result;
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
temporaryTransportUncertain = true;
|
|
751
|
+
throw error;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
: deps.callApi,
|
|
599
755
|
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) => deps.threadStore?.markStaleByTarget(staleTarget, syncStatus, lastSyncError) ?? false,
|
|
600
756
|
persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
|
|
601
757
|
removePendingProvisionById: (id) => deps.threadStore?.removePendingProvision(id) ?? false,
|
|
@@ -603,14 +759,132 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
603
759
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
604
760
|
});
|
|
605
761
|
assertExecutionCurrent?.();
|
|
606
|
-
return (result.incompleteActions?.length ?? 0) === 0;
|
|
762
|
+
return !protectedTarget && (!temporaryCleanup || temporaryDeleteConfirmed) && (result.incompleteActions?.length ?? 0) === 0;
|
|
763
|
+
};
|
|
764
|
+
const findCreatedTemporaryThread = (target) => {
|
|
765
|
+
try {
|
|
766
|
+
return deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
/**
|
|
773
|
+
* One fenced capture of leader epoch, operator, journal binding and context for a temporary-Thread effect. `isCurrent`
|
|
774
|
+
* re-reads every captured fact (plus an optional extra fence) at each boundary, so a delayed effect keeps the identity it
|
|
775
|
+
* was scheduled under; `adopt` re-keys an entry to this executor before a write.
|
|
776
|
+
*/
|
|
777
|
+
const captureTemporaryThreadAuthority = (ctx, extra) => {
|
|
778
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
779
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
780
|
+
const operatorUserId = deps.configStore.getAllowedUserId(), journalBindingKey = deps.getAdmissionJournalBinding?.();
|
|
781
|
+
const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.();
|
|
782
|
+
if (!store || !instanceId || epoch === undefined || operatorUserId === undefined || !journalBindingKey)
|
|
783
|
+
return undefined;
|
|
784
|
+
const isCurrent = () => deps.isContextActive?.(ctx) === true && deps.getCurrentInstanceId?.() === instanceId &&
|
|
785
|
+
deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
786
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
|
|
787
|
+
deps.getAdmissionJournalBinding?.() === journalBindingKey && (extra?.() ?? true);
|
|
788
|
+
const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent };
|
|
789
|
+
return { store, operatorUserId, journalBindingKey, epoch, authority, isCurrent,
|
|
790
|
+
adopt: entry => isDeepStrictEqual(entry.executor, authority.executor) ? entry : store.adoptTemporaryThread(entry, authority) };
|
|
791
|
+
};
|
|
792
|
+
const isTemporaryTabTarget = (target) => !!target && findCreatedTemporaryThread(target) !== undefined;
|
|
793
|
+
/** Restore may choose one input only when every other known group is cancelled or a live unassigned chooser that can be retained. */
|
|
794
|
+
const areTemporaryThreadSiblingsAccountedForRestore = (entry, from) => {
|
|
795
|
+
const target = entry.target;
|
|
796
|
+
if (!target)
|
|
797
|
+
return false;
|
|
798
|
+
const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(from.messages) };
|
|
799
|
+
const inputs = Threads.getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
|
|
800
|
+
if (!inputs.some(input => isDeepStrictEqual(input, group)) || resolved.some(input => isDeepStrictEqual(input, group)))
|
|
801
|
+
return false;
|
|
802
|
+
const others = [...pendingUnboundReroutes.values()].filter(pending => pending !== from &&
|
|
803
|
+
pending.sourceTarget.chatId === target.chatId && pending.sourceTarget.threadId === target.threadId);
|
|
804
|
+
const groupOf = (pending) => ({ journalBindingKey: entry.source.journalBindingKey,
|
|
805
|
+
updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) });
|
|
806
|
+
if (!others.every(pending => !!pending.abandonment && !pending.abandonment.result && !pending.destinationSelected &&
|
|
807
|
+
!pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage &&
|
|
808
|
+
!pending.workspaceRestore && !entry.forwardedInputs?.some(input => isDeepStrictEqual(input, groupOf(pending))) &&
|
|
809
|
+
inputs.some(input => isDeepStrictEqual(input, groupOf(pending)))))
|
|
810
|
+
return false;
|
|
811
|
+
return inputs.every(input => isDeepStrictEqual(input, group) || resolved.some(other => isDeepStrictEqual(other, input)) ||
|
|
812
|
+
others.some(pending => isDeepStrictEqual(groupOf(pending), input)));
|
|
607
813
|
};
|
|
608
|
-
const isRerouteTargetProtected = (target) => {
|
|
814
|
+
const isRerouteTargetProtected = (target, ownRestore, ownTemporary, restoreFrom) => {
|
|
609
815
|
const matches = (candidate) => candidate.chatId === target.chatId && candidate.threadId === target.threadId;
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
(deps.
|
|
816
|
+
const activeTarget = deps.activeTurnRuntime.getTarget();
|
|
817
|
+
if ((activeTarget && matches(activeTarget)) ||
|
|
818
|
+
deps.telegramQueueStore.getQueuedItems().some(item => item.target && matches(item.target)) ||
|
|
819
|
+
(deps.getLiveThreadTargets?.() ?? []).some(matches) ||
|
|
820
|
+
(deps.threadStore?.list() ?? []).some(record => matches(record.target)) ||
|
|
821
|
+
(deps.threadStore?.listReservations() ?? []).some(record => matches(record.target)) ||
|
|
822
|
+
(deps.threadStore?.listPendingProvisions() ?? []).some(record => record.target && matches(record.target)))
|
|
823
|
+
return true;
|
|
824
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
825
|
+
if (deps.getWorkspaceRestoreStore && !store)
|
|
826
|
+
return true;
|
|
827
|
+
let temporary;
|
|
828
|
+
try {
|
|
829
|
+
temporary = store?.listTemporaryThreads() ?? [];
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
// Only the source's own Forward/Cancel may remove its retained tab; adoption may have changed its executor.
|
|
835
|
+
const ownTab = (entry) => !!ownTemporary && entry.token === ownTemporary.token &&
|
|
836
|
+
isDeepStrictEqual(entry.source, ownTemporary.source) && isDeepStrictEqual(entry.target, ownTemporary.target);
|
|
837
|
+
if (temporary.some(entry => !!entry.target && matches(entry.target) &&
|
|
838
|
+
(!ownTab(entry) || (Threads.getTelegramTemporaryThreadInputs(entry).length > 1 &&
|
|
839
|
+
!Threads.isTelegramTemporaryThreadFullyResolved(entry) &&
|
|
840
|
+
!(restoreFrom && areTemporaryThreadSiblingsAccountedForRestore(entry, restoreFrom))))))
|
|
841
|
+
return true;
|
|
842
|
+
// Whole-tab cleanup after cancellation also needs a fresh census of retained arrivals no membership has seen.
|
|
843
|
+
if (!restoreFrom && temporary.some(entry => ownTab(entry) && !!(entry.cancelledInputs?.length || entry.completedInputs?.length))) {
|
|
844
|
+
try {
|
|
845
|
+
const required = [...new Set([deps.getAdmissionJournalBinding?.(), ownTemporary.source.journalBindingKey,
|
|
846
|
+
...Threads.getTelegramTemporaryThreadInputs(ownTemporary).map(input => input.journalBindingKey)].filter((key) => !!key))];
|
|
847
|
+
if (deps.inspectTemporaryThreadSources?.({ chatId: target.chatId, threadId: target.threadId }, required)?.length !== 0)
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const retained = store?.list() ?? [];
|
|
855
|
+
// Exempt only the exact operation's old target. Cleanup itself also requires its issued grant.
|
|
856
|
+
if (ownRestore && (!matches(ownRestore.request.binding.target) ||
|
|
857
|
+
!retained.some(intent => isDeepStrictEqual(intent, ownRestore))))
|
|
858
|
+
return true;
|
|
859
|
+
if (retained.some(intent => (matches(intent.request.binding.target) || matches(intent.request.target)) &&
|
|
860
|
+
!(ownRestore && isDeepStrictEqual(intent, ownRestore) && matches(intent.request.binding.target))))
|
|
861
|
+
return true;
|
|
862
|
+
if (!ownRestore)
|
|
863
|
+
return false;
|
|
864
|
+
let acceptedWorkClear = false;
|
|
865
|
+
try {
|
|
866
|
+
deps.threadStore?.withWorkspaceRestoreSnapshot(ownRestore, snapshot => {
|
|
867
|
+
const bindings = snapshot.workspaceBindings?.filter(binding => binding.bindingKey === ownRestore.request.binding.bindingKey) ?? [];
|
|
868
|
+
if (bindings.length !== 1 || !deps.captureWorkspaceExternalProtection)
|
|
869
|
+
return undefined;
|
|
870
|
+
const binding = bindings[0];
|
|
871
|
+
// Relocation does not erase predecessor references or prove where their work executes.
|
|
872
|
+
const journalBindingKeys = [...new Set([
|
|
873
|
+
...(ownRestore.request.binding.journalBindingKeys ?? []), ...(binding.journalBindingKeys ?? []),
|
|
874
|
+
])];
|
|
875
|
+
const journalSources = [...new Map([
|
|
876
|
+
...(ownRestore.request.binding.journalSources ?? []), ...(binding.journalSources ?? []),
|
|
877
|
+
].map(source => [JSON.stringify([source.sessionId, source.recipientBindingKey]), source])).values()];
|
|
878
|
+
acceptedWorkClear = deps.captureWorkspaceExternalProtection({ ...binding, target: ownRestore.request.binding.target,
|
|
879
|
+
journalBindingKeys, journalSources }, { requireBindingProvenance: true }).acceptedWork === "clear";
|
|
880
|
+
return undefined;
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
catch (error) {
|
|
884
|
+
acceptedWorkClear = false;
|
|
885
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-protection" });
|
|
886
|
+
}
|
|
887
|
+
return !acceptedWorkClear;
|
|
614
888
|
};
|
|
615
889
|
const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
|
|
616
890
|
const chatId = query.message?.chat?.id;
|
|
@@ -637,7 +911,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
637
911
|
return false;
|
|
638
912
|
}
|
|
639
913
|
};
|
|
640
|
-
const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent) => {
|
|
914
|
+
const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
|
|
641
915
|
if (!target || !deps.threadStore)
|
|
642
916
|
return true;
|
|
643
917
|
const nowMs = Date.now();
|
|
@@ -660,9 +934,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
660
934
|
},
|
|
661
935
|
],
|
|
662
936
|
});
|
|
663
|
-
|
|
937
|
+
// A temporary grant licenses this tab only, not unrelated cleanup emitted by the general planner.
|
|
938
|
+
const scoped = ownTemporary ? { ...plan, actions: plan.actions.filter(action => action.kind === "close-delete-unbound-topic" && isDeepStrictEqual(action.target, target)) } : plan;
|
|
939
|
+
return applyThreadCleanupPlan(scoped, assertExecutionCurrent, undefined, ownTemporary);
|
|
664
940
|
};
|
|
665
|
-
const closePreviousLeaderThread = async (target, assertExecutionCurrent) => {
|
|
941
|
+
const closePreviousLeaderThread = async (target, assertExecutionCurrent, restoreCleanup) => {
|
|
666
942
|
if (!target || !deps.threadStore)
|
|
667
943
|
return true;
|
|
668
944
|
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -678,9 +954,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
678
954
|
: {}),
|
|
679
955
|
},
|
|
680
956
|
],
|
|
681
|
-
}, assertExecutionCurrent);
|
|
957
|
+
}, assertExecutionCurrent, restoreCleanup);
|
|
682
958
|
};
|
|
683
|
-
const closeReplacedFollowerThread = async (target, instanceId, assertExecutionCurrent) => {
|
|
959
|
+
const closeReplacedFollowerThread = async (target, instanceId, assertExecutionCurrent, restoreCleanup) => {
|
|
684
960
|
if (!target || !deps.threadStore)
|
|
685
961
|
return true;
|
|
686
962
|
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -696,11 +972,807 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
696
972
|
: {}),
|
|
697
973
|
},
|
|
698
974
|
],
|
|
699
|
-
}, assertExecutionCurrent);
|
|
975
|
+
}, assertExecutionCurrent, restoreCleanup);
|
|
976
|
+
};
|
|
977
|
+
// Worker ACK observers outlive callback admission. Re-enter the existing Workspace gate;
|
|
978
|
+
// never await this continuation while holding the dispatch invocation's gate.
|
|
979
|
+
const restoreSettlementTasks = new Set();
|
|
980
|
+
/**
|
|
981
|
+
* After a Restore into a temporary tab reached positive terminal settlement, every still-unassigned sibling there is retained
|
|
982
|
+
* privately and cancelled without Pi delivery. Missing proof, authority or abandonment leaves that sibling pending and the
|
|
983
|
+
* tab's temporary entry protected; selected, queued, running or unknown work is never touched.
|
|
984
|
+
*/
|
|
985
|
+
const disposeTemporaryThreadSiblingsAfterRestore = async (intent, ctx, restoreCurrent) => {
|
|
986
|
+
const target = intent.request.target, cap = captureTemporaryThreadAuthority(ctx, restoreCurrent);
|
|
987
|
+
const entry = findCreatedTemporaryThread(target);
|
|
988
|
+
if (!cap || !entry || !entry.target || !cap.isCurrent())
|
|
989
|
+
return;
|
|
990
|
+
const operatorUserId = intent.operatorUserId, journalBindingKey = intent.request.source.journalBindingKey;
|
|
991
|
+
if (cap.operatorUserId !== operatorUserId || cap.journalBindingKey !== journalBindingKey ||
|
|
992
|
+
entry.operatorUserId !== operatorUserId || entry.source.journalBindingKey !== journalBindingKey)
|
|
993
|
+
return;
|
|
994
|
+
const group = { journalBindingKey, updateIds: [...intent.request.source.updateIds] };
|
|
995
|
+
if (!Threads.getTelegramTemporaryThreadInputs(entry).some(input => isDeepStrictEqual(input, group)))
|
|
996
|
+
return;
|
|
997
|
+
for (const [id, pending] of [...pendingUnboundReroutes]) {
|
|
998
|
+
const cancellation = pending.abandonment, source = pending.messages[0];
|
|
999
|
+
if (pending.sourceTarget.chatId !== target.chatId || pending.sourceTarget.threadId !== target.threadId ||
|
|
1000
|
+
pending.workspaceRestore?.operationId === intent.request.operationId || !cancellation || !source || cancellation.result ||
|
|
1001
|
+
cancellation.running)
|
|
1002
|
+
continue;
|
|
1003
|
+
const current = () => cap.isCurrent() &&
|
|
1004
|
+
pendingUnboundReroutes.get(id) === pending && pending.abandonment === cancellation &&
|
|
1005
|
+
!pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry &&
|
|
1006
|
+
!pending.cleanup && !pending.finalizeMessage && !pending.workspaceRestore &&
|
|
1007
|
+
Updates.getTelegramUpdateExecutionFence(source)?.signal.aborted === false &&
|
|
1008
|
+
cancellation.ownerUserId === operatorUserId && cancellation.journalBindingKey === journalBindingKey;
|
|
1009
|
+
if (!current())
|
|
1010
|
+
continue;
|
|
1011
|
+
cancellation.running = true;
|
|
1012
|
+
try {
|
|
1013
|
+
const result = Updates.abandonTelegramDeferredUpdate(source, { operatorAuthorityId: `telegram-owner:${operatorUserId}`, isCurrent: current });
|
|
1014
|
+
if (!result)
|
|
1015
|
+
continue;
|
|
1016
|
+
cancellation.attempted = true;
|
|
1017
|
+
cancellation.result = result;
|
|
1018
|
+
if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
|
|
1019
|
+
continue;
|
|
1020
|
+
await retireCancellationChooser(id, pending, current);
|
|
1021
|
+
}
|
|
1022
|
+
catch (error) {
|
|
1023
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-restore-sibling", rerouteId: id });
|
|
1024
|
+
}
|
|
1025
|
+
finally {
|
|
1026
|
+
cancellation.running = false;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
// The entry is released only after every sibling is gone and the Restore group's completion is the single uncancelled input.
|
|
1030
|
+
if (hasPendingRerouteForTarget(target, pending => pending.workspaceRestore?.operationId === intent.request.operationId) ||
|
|
1031
|
+
!cap.isCurrent())
|
|
1032
|
+
return;
|
|
1033
|
+
const fresh = findCreatedTemporaryThread(target), owned = fresh && cap.adopt(fresh);
|
|
1034
|
+
if (owned && cap.isCurrent())
|
|
1035
|
+
cap.store.retireTemporaryThread(owned, cap.authority, group);
|
|
1036
|
+
};
|
|
1037
|
+
const observeRestoreSettlement = (signal, ctx) => {
|
|
1038
|
+
if (!deps.hasWorkspaceRestoreAuthority?.() || !deps.runWorkspaceOperation || !deps.getWorkspaceRestoreStore ||
|
|
1039
|
+
!deps.getSessionGeneration || deps.isContextActive?.(ctx) === false)
|
|
1040
|
+
return;
|
|
1041
|
+
const evidence = signal.kind === "source" ? structuredClone(signal.evidence) : undefined;
|
|
1042
|
+
const follower = signal.kind === "recipient" ? structuredClone(signal.follower) : undefined;
|
|
1043
|
+
const recipientCurrent = () => {
|
|
1044
|
+
if (signal.kind !== "recipient" || !follower)
|
|
1045
|
+
return true;
|
|
1046
|
+
const live = deps.workspaceRestoreRecipient?.followerRegistry.get(follower.instanceId);
|
|
1047
|
+
return signal.isCurrent() && !!live && Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1048
|
+
isDeepStrictEqual({ ...live, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }, { ...follower, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined });
|
|
1049
|
+
};
|
|
1050
|
+
const recipientMatches = (intent) => {
|
|
1051
|
+
if (!follower)
|
|
1052
|
+
return true;
|
|
1053
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1054
|
+
return ready?.kind === "follower" && ready.instanceId === follower.instanceId && ready.sessionId === follower.sessionId &&
|
|
1055
|
+
ready.generation === follower.registrationGeneration && !!follower.cwd &&
|
|
1056
|
+
intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
|
|
1057
|
+
intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
|
|
1058
|
+
};
|
|
1059
|
+
// A same-session successor already registered on the relocated target may prove an issued grant by inspection only.
|
|
1060
|
+
const inspectableSuccessor = (intent) => !!follower?.registrationGeneration && !!follower.cwd && (intent.phase === "relocated" || intent.phase === "recipient-issued") &&
|
|
1061
|
+
intent.request.owner.owner?.kind === "manual-follower" && follower.sessionId === intent.request.binding.sessionId &&
|
|
1062
|
+
intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
|
|
1063
|
+
intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
|
|
1064
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1065
|
+
const operatorUserId = deps.configStore.getAllowedUserId();
|
|
1066
|
+
const generation = deps.getSessionGeneration(), scope = deps.getAdmissionScope?.();
|
|
1067
|
+
const sourceBinding = deps.getAdmissionJournalBinding?.();
|
|
1068
|
+
if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope ||
|
|
1069
|
+
!sourceBinding || (evidence && evidence.journalBindingKey !== sourceBinding) || !recipientCurrent())
|
|
1070
|
+
return;
|
|
1071
|
+
// This same-session leader already runs on the relocated target; it may only inspect an issued leader grant.
|
|
1072
|
+
const leaderSessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
|
|
1073
|
+
const leaderCwd = deps.workspaceRestoreRecipient?.getCwd(ctx);
|
|
1074
|
+
const leaderAt = (request) => {
|
|
1075
|
+
const ports = deps.workspaceRestoreRecipient;
|
|
1076
|
+
const cwd = ports?.getCwd(ctx), local = ports?.getLeaderIdentity();
|
|
1077
|
+
return !!cwd && cwd === leaderCwd && ports?.getSessionId(ctx) === leaderSessionId &&
|
|
1078
|
+
leaderSessionId === request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd &&
|
|
1079
|
+
!!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target);
|
|
1080
|
+
};
|
|
1081
|
+
const observeLeaderRestore = (intent, recipient, isCurrent) => {
|
|
1082
|
+
const threads = deps.threadStore;
|
|
1083
|
+
if (!threads || !isCurrent() || !leaderAt(intent.request))
|
|
1084
|
+
return undefined;
|
|
1085
|
+
const { request } = intent;
|
|
1086
|
+
let observation;
|
|
1087
|
+
threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
|
|
1088
|
+
if (!isCurrent() || !leaderAt(request))
|
|
1089
|
+
return;
|
|
1090
|
+
const bindings = (snapshot.workspaceBindings ?? []).filter(value => value.bindingKey === request.binding.bindingKey);
|
|
1091
|
+
const live = bindings[0];
|
|
1092
|
+
const owners = snapshot.threads.filter(value => value.status === "active" &&
|
|
1093
|
+
(value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
|
|
1094
|
+
if (bindings.length !== 1 || live?.sessionId !== request.binding.sessionId || live.cwd !== request.binding.cwd ||
|
|
1095
|
+
live.slot !== request.binding.slot || live.inactiveSinceMs !== undefined || !isDeepStrictEqual(live.target, request.target) ||
|
|
1096
|
+
owners.length !== 1 || owners[0]?.owner?.kind !== "leader" || owners[0].instanceId !== recipient.instanceId ||
|
|
1097
|
+
owners[0].owner.instanceId !== recipient.instanceId || !owners[0].owner.cwd ||
|
|
1098
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) !== request.binding.cwd ||
|
|
1099
|
+
owners[0].profileKey !== request.owner.profileKey || owners[0].slot !== request.binding.slot || !isDeepStrictEqual(owners[0].target, request.target))
|
|
1100
|
+
return;
|
|
1101
|
+
observation = { operationId: request.operationId, recipient, target: request.target, slot: request.binding.slot, ready: true };
|
|
1102
|
+
});
|
|
1103
|
+
return isCurrent() && leaderAt(request) ? observation : undefined;
|
|
1104
|
+
};
|
|
1105
|
+
// Same-process lost replies stay with their chooser; this path only serves a restarted leader process.
|
|
1106
|
+
const inspectableLeader = (intent) => !!evidence &&
|
|
1107
|
+
intent.request.owner.owner?.kind === "leader" && leaderAt(intent.request) &&
|
|
1108
|
+
(intent.phase === "relocated" ? intent.request.owner.instanceId !== instanceId
|
|
1109
|
+
: intent.phase === "recipient-issued" && intent.recipient?.kind === "leader" && intent.recipient.instanceId !== instanceId);
|
|
1110
|
+
const current = () => deps.hasWorkspaceRestoreAuthority?.() === true && deps.isContextActive?.(ctx) !== false &&
|
|
1111
|
+
deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
1112
|
+
deps.getAdmissionJournalBinding?.() === sourceBinding && deps.getCurrentInstanceId?.() === instanceId &&
|
|
1113
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId && recipientCurrent();
|
|
1114
|
+
const recoveryRecipient = (intent) => {
|
|
1115
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1116
|
+
if (!deps.inspectRestoreSourceCompletion || !deps.workspaceRestoreRecipient || intent.phase !== "ready" ||
|
|
1117
|
+
!intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
|
|
1118
|
+
!intent.routing.acceptances?.some(value => value.kind === "forwarded"))
|
|
1119
|
+
return undefined;
|
|
1120
|
+
const live = deps.workspaceRestoreRecipient.followerRegistry.get(follower?.instanceId ?? ready?.instanceId ?? "");
|
|
1121
|
+
return ready?.kind === "follower" && live?.registrationGeneration && live.cwd &&
|
|
1122
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1123
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION) &&
|
|
1124
|
+
live.sessionId === intent.request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
|
|
1125
|
+
live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) ? live : undefined;
|
|
1126
|
+
};
|
|
1127
|
+
const recoveryLeader = (intent) => {
|
|
1128
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1129
|
+
if (evidence?.kind !== "completed" || !deps.inspectRestoreSourceCompletion || !deps.threadStore ||
|
|
1130
|
+
intent.phase !== "ready" || !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
|
|
1131
|
+
intent.request.owner.owner?.kind !== "leader" || ready?.kind !== "leader" || ready.sessionId !== intent.request.binding.sessionId ||
|
|
1132
|
+
!leaderAt(intent.request) ||
|
|
1133
|
+
!intent.routing.acceptances?.some(value => value.kind === "completed" || value.kind === "queued"))
|
|
1134
|
+
return undefined;
|
|
1135
|
+
return { kind: "leader", instanceId, sessionId: intent.request.binding.sessionId, generation: String(generation) };
|
|
1136
|
+
};
|
|
1137
|
+
let selected;
|
|
1138
|
+
try {
|
|
1139
|
+
selected = deps.getWorkspaceRestoreStore()?.list().filter(intent => {
|
|
1140
|
+
if (intent.request.source.journalBindingKey !== sourceBinding)
|
|
1141
|
+
return false;
|
|
1142
|
+
if (evidence && intent.request.source.updateIds.some(id => evidence.updateIds.includes(id)))
|
|
1143
|
+
return true;
|
|
1144
|
+
if (((evidence?.kind === "completed" || follower) && recoveryRecipient(intent)) || recoveryLeader(intent))
|
|
1145
|
+
return true;
|
|
1146
|
+
if (follower && deps.workspaceRestoreRecipient && inspectableSuccessor(intent))
|
|
1147
|
+
return true;
|
|
1148
|
+
if (deps.threadStore && inspectableLeader(intent))
|
|
1149
|
+
return true;
|
|
1150
|
+
// Completion rechecks an unsent Restore whose originals may all have been abandoned before its retirement published.
|
|
1151
|
+
if (evidence?.kind === "completed" && deps.inspectRestoreSourceAbandonment && intent.phase === "ready" &&
|
|
1152
|
+
intent.routing === undefined)
|
|
1153
|
+
return true;
|
|
1154
|
+
// Hints only wake fully settled, unissued cleanup. They never settle another source.
|
|
1155
|
+
return (evidence?.kind === "completed" || !!follower) && intent.phase === "ready" && intent.routing?.cleanup === undefined &&
|
|
1156
|
+
recipientMatches(intent) && intent.request.source.updateIds.every(id => intent.routing?.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)));
|
|
1157
|
+
}).map(intent => intent.request.operationId) ?? [];
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" });
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (!selected.length || !current())
|
|
1164
|
+
return;
|
|
1165
|
+
const task = Promise.resolve().then(() => deps.runWorkspaceOperation({ operationId: `restore-settlement-${randomBytes(16).toString("hex")}`,
|
|
1166
|
+
operationKind: "workspace.restore-settlement", scopes: [{ kind: "profile" }] }, async () => {
|
|
1167
|
+
if (!current())
|
|
1168
|
+
return;
|
|
1169
|
+
const store = deps.getWorkspaceRestoreStore();
|
|
1170
|
+
if (!store)
|
|
1171
|
+
return;
|
|
1172
|
+
const authority = {
|
|
1173
|
+
executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current
|
|
1174
|
+
};
|
|
1175
|
+
const assertCurrent = () => { if (!current())
|
|
1176
|
+
throw new Error("Workspace Restore settlement authority ended."); };
|
|
1177
|
+
// Exact owner abandonment of every original is terminal for an unsent Restore; no cleanup or delivery follows.
|
|
1178
|
+
const abandoned = (value) => {
|
|
1179
|
+
if (evidence?.kind !== "completed" || !value.request.source.updateIds.length)
|
|
1180
|
+
return false;
|
|
1181
|
+
return value.request.source.updateIds.every(updateId => {
|
|
1182
|
+
const proof = deps.inspectRestoreSourceAbandonment?.(updateId, sourceBinding);
|
|
1183
|
+
assertCurrent();
|
|
1184
|
+
return proof?.journalBindingKey === sourceBinding && proof.updateId === updateId &&
|
|
1185
|
+
proof.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
|
|
1186
|
+
});
|
|
1187
|
+
};
|
|
1188
|
+
for (const operationId of selected) {
|
|
1189
|
+
assertCurrent();
|
|
1190
|
+
let intent = store.list().find(value => value.request.operationId === operationId);
|
|
1191
|
+
let settlementCurrent = current;
|
|
1192
|
+
let settlementCanonicalCurrent;
|
|
1193
|
+
let retired = false;
|
|
1194
|
+
if (follower && intent && inspectableSuccessor(intent) && intent.operatorUserId === operatorUserId) {
|
|
1195
|
+
const ports = deps.workspaceRestoreRecipient;
|
|
1196
|
+
const successor = { kind: "follower", instanceId: follower.instanceId,
|
|
1197
|
+
sessionId: follower.sessionId, generation: follower.registrationGeneration };
|
|
1198
|
+
const { request } = intent;
|
|
1199
|
+
// Adoption plus inspect never consumes the original apply grant, delivers input or issues cleanup.
|
|
1200
|
+
await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
|
|
1201
|
+
getRecipient: () => current() ? successor : undefined,
|
|
1202
|
+
async runRecipient(action) {
|
|
1203
|
+
if (action.mode !== "inspect" || !action.isCurrent())
|
|
1204
|
+
return undefined;
|
|
1205
|
+
return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
|
|
1206
|
+
sessionId: successor.sessionId, slot: request.binding.slot, target: request.target,
|
|
1207
|
+
oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
|
|
1208
|
+
} });
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
if (intent && inspectableLeader(intent) && intent.operatorUserId === operatorUserId) {
|
|
1212
|
+
const threads = deps.threadStore;
|
|
1213
|
+
const leader = { kind: "leader", instanceId,
|
|
1214
|
+
sessionId: intent.request.binding.sessionId, generation: String(generation) };
|
|
1215
|
+
const { request } = intent;
|
|
1216
|
+
await threads.load();
|
|
1217
|
+
assertCurrent();
|
|
1218
|
+
await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
|
|
1219
|
+
getRecipient: () => current() && leaderAt(request) ? leader : undefined,
|
|
1220
|
+
async runRecipient(action) {
|
|
1221
|
+
if (action.mode !== "inspect")
|
|
1222
|
+
return undefined;
|
|
1223
|
+
return observeLeaderRestore(action.intent, leader, action.isCurrent);
|
|
1224
|
+
} });
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (intent?.phase === "ready" && intent.routing === undefined && intent.operatorUserId === operatorUserId &&
|
|
1228
|
+
intent.request.source.journalBindingKey === sourceBinding && abandoned(intent)) {
|
|
1229
|
+
const owned = isDeepStrictEqual(intent.executor, authority.executor) ? intent : store.adopt(intent, authority);
|
|
1230
|
+
assertCurrent();
|
|
1231
|
+
if (owned)
|
|
1232
|
+
store.retireAbandoned(owned, owned.request.source.updateIds, authority);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
const liveRecipient = intent && recoveryRecipient(intent), localRecipient = intent && recoveryLeader(intent);
|
|
1236
|
+
if (intent && (liveRecipient || localRecipient) && intent.request.source.journalBindingKey === sourceBinding) {
|
|
1237
|
+
const expected = intent, ports = deps.workspaceRestoreRecipient;
|
|
1238
|
+
const recoveryCurrent = () => current() && (localRecipient ? leaderAt(expected.request) && isDeepStrictEqual(recoveryLeader(expected), localRecipient) : isDeepStrictEqual({ ...recoveryRecipient(expected), lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }, { ...liveRecipient, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }));
|
|
1239
|
+
// Storage authority callbacks check live fences only; canonical reads cannot re-enter their transaction lock.
|
|
1240
|
+
const recoveryAuthority = { ...authority, isCurrent: recoveryCurrent };
|
|
1241
|
+
settlementCurrent = recoveryCurrent;
|
|
1242
|
+
if (localRecipient) {
|
|
1243
|
+
settlementCanonicalCurrent = () => !!intent && !!observeLeaderRestore(intent, localRecipient, recoveryCurrent);
|
|
1244
|
+
if (!settlementCanonicalCurrent())
|
|
1245
|
+
continue; // A local target alone cannot authorize executor adoption.
|
|
1246
|
+
}
|
|
1247
|
+
const inspect = (acceptance, operation) => {
|
|
1248
|
+
const completion = { journalBindingKey: sourceBinding, updateId: acceptance.updateId,
|
|
1249
|
+
sourceSha256: acceptance.sourceSha256,
|
|
1250
|
+
completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(operation, acceptance) };
|
|
1251
|
+
const observed = deps.inspectRestoreSourceCompletion({ ...completion });
|
|
1252
|
+
if (!recoveryCurrent())
|
|
1253
|
+
throw new Error("Workspace Restore completion observation authority ended.");
|
|
1254
|
+
return isDeepStrictEqual(observed, completion);
|
|
1255
|
+
};
|
|
1256
|
+
// Inspect proof before adopting: missing/foreign ACKs cannot even re-key the retained operation.
|
|
1257
|
+
const accepted = expected.routing.acceptances.filter(value => (localRecipient ? value.kind === "completed" || value.kind === "queued" : value.kind === "forwarded") && inspect(value, expected));
|
|
1258
|
+
if (accepted.length) {
|
|
1259
|
+
const successor = localRecipient ?? { kind: "follower", instanceId: liveRecipient.instanceId,
|
|
1260
|
+
sessionId: liveRecipient.sessionId, generation: liveRecipient.registrationGeneration };
|
|
1261
|
+
const { request } = expected;
|
|
1262
|
+
const inspected = await advanceTelegramWorkspaceRestore({ request, authority: recoveryAuthority, restoreStore: store, inspectOnly: true,
|
|
1263
|
+
getRecipient: () => recoveryCurrent() ? successor : undefined,
|
|
1264
|
+
async runRecipient(action) {
|
|
1265
|
+
if (action.mode !== "inspect" || !action.isCurrent())
|
|
1266
|
+
return undefined;
|
|
1267
|
+
if (localRecipient)
|
|
1268
|
+
return observeLeaderRestore(action.intent, successor, action.isCurrent);
|
|
1269
|
+
return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
|
|
1270
|
+
sessionId: successor.sessionId, slot: request.binding.slot, target: request.target,
|
|
1271
|
+
oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
|
|
1272
|
+
} });
|
|
1273
|
+
if (!inspected || !recoveryCurrent())
|
|
1274
|
+
continue;
|
|
1275
|
+
intent = inspected;
|
|
1276
|
+
if (settlementCanonicalCurrent && !settlementCanonicalCurrent())
|
|
1277
|
+
continue;
|
|
1278
|
+
// The await cannot lend earlier readback; confirm each immutable proof again before publication.
|
|
1279
|
+
const settledIds = new Set(intent.routing.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
|
|
1280
|
+
const recovered = new Map();
|
|
1281
|
+
for (const acceptance of accepted.filter(value => !settledIds.has(value.updateId) && inspect(value, intent))) {
|
|
1282
|
+
const key = acceptance.kind === "queued" ? JSON.stringify([acceptance.receiptId, acceptance.queueKind]) : "completed";
|
|
1283
|
+
const settlement = recovered.get(key) ?? { journalBindingKey: sourceBinding, updateIds: [],
|
|
1284
|
+
...(acceptance.kind === "queued" ? { kind: "queue-completed", receiptId: acceptance.receiptId, queueKind: acceptance.queueKind }
|
|
1285
|
+
: { kind: "completed" }) };
|
|
1286
|
+
settlement.updateIds.push(acceptance.updateId);
|
|
1287
|
+
recovered.set(key, settlement);
|
|
1288
|
+
}
|
|
1289
|
+
for (const settlement of recovered.values()) {
|
|
1290
|
+
intent = store.recordSourceSettlement(intent, settlement, recoveryAuthority);
|
|
1291
|
+
if (!intent)
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
if (!intent)
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
// Source hints carry no registry fence; cleanup must keep the acknowledged recipient exact across awaits.
|
|
1299
|
+
const readyRecipientCurrent = () => {
|
|
1300
|
+
const ready = intent?.readyRecipient ?? intent?.recipient;
|
|
1301
|
+
if (intent?.phase !== "ready" || ready?.kind !== "follower")
|
|
1302
|
+
return true;
|
|
1303
|
+
const live = deps.workspaceRestoreRecipient?.followerRegistry.get(ready.instanceId);
|
|
1304
|
+
return !!live?.cwd && live.registrationGeneration === ready.generation && live.sessionId === ready.sessionId &&
|
|
1305
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
|
|
1306
|
+
live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) &&
|
|
1307
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1308
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION);
|
|
1309
|
+
};
|
|
1310
|
+
const settlementAuthority = { ...authority, isCurrent: function () { return settlementCurrent() && readyRecipientCurrent(); } };
|
|
1311
|
+
const assertSettlementCurrent = () => {
|
|
1312
|
+
if (!settlementAuthority.isCurrent() || (!retired && settlementCanonicalCurrent && !settlementCanonicalCurrent())) {
|
|
1313
|
+
throw new Error("Workspace Restore settlement authority ended.");
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
assertSettlementCurrent();
|
|
1317
|
+
if (!intent || intent.phase !== "ready" || !intent.routing || !isDeepStrictEqual(intent.executor, authority.executor) ||
|
|
1318
|
+
intent.operatorUserId !== operatorUserId || intent.request.source.journalBindingKey !== sourceBinding)
|
|
1319
|
+
continue;
|
|
1320
|
+
const settled = new Set(intent.routing.settlements.flatMap(value => value.updateIds));
|
|
1321
|
+
if (follower && (intent.routing.cleanup !== undefined || !recipientMatches(intent) ||
|
|
1322
|
+
!intent.request.source.updateIds.every(id => settled.has(id))))
|
|
1323
|
+
continue;
|
|
1324
|
+
const updateIds = evidence?.updateIds.filter(id => intent.request.source.updateIds.includes(id) && !settled.has(id)) ?? [];
|
|
1325
|
+
if (evidence && updateIds.length) {
|
|
1326
|
+
intent = store.recordSourceSettlement(intent, { ...evidence, updateIds }, settlementAuthority);
|
|
1327
|
+
if (!intent)
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (intent.routing?.cleanup === undefined) {
|
|
1331
|
+
if (!intent.request.source.updateIds.every(id => intent.routing.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id))))
|
|
1332
|
+
continue;
|
|
1333
|
+
// Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
|
|
1334
|
+
await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
|
|
1335
|
+
assertSettlementCurrent();
|
|
1336
|
+
if (isRerouteTargetProtected(intent.request.binding.target, intent))
|
|
1337
|
+
continue;
|
|
1338
|
+
const grant = store.issueCleanup(intent, settlementAuthority);
|
|
1339
|
+
if (!grant)
|
|
1340
|
+
continue;
|
|
1341
|
+
intent = grant.intent;
|
|
1342
|
+
const oldTarget = intent.request.binding.target;
|
|
1343
|
+
const completed = intent.request.owner.owner?.kind === "leader"
|
|
1344
|
+
? await closePreviousLeaderThread(oldTarget, assertSettlementCurrent, intent)
|
|
1345
|
+
: await closeReplacedFollowerThread(oldTarget, intent.request.owner.instanceId, assertSettlementCurrent, intent);
|
|
1346
|
+
assertSettlementCurrent();
|
|
1347
|
+
if (!completed)
|
|
1348
|
+
continue; // Issued uncertainty stays protected, never downgraded or retried.
|
|
1349
|
+
intent = store.recordCleanup(intent, { kind: "completed", target: oldTarget }, settlementAuthority);
|
|
1350
|
+
if (!intent)
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
if (!store.retire(intent, settlementAuthority))
|
|
1354
|
+
continue;
|
|
1355
|
+
retired = true;
|
|
1356
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
1357
|
+
if (pending.workspaceRestore?.operationId !== operationId)
|
|
1358
|
+
continue;
|
|
1359
|
+
pending.finalizeMessage = "Message routed.";
|
|
1360
|
+
if (!deps.editInteractiveMessage || pending.chooserMessageId === undefined)
|
|
1361
|
+
continue;
|
|
1362
|
+
assertSettlementCurrent();
|
|
1363
|
+
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
|
|
1364
|
+
assertSettlementCurrent();
|
|
1365
|
+
if (pendingUnboundReroutes.get(id) === pending)
|
|
1366
|
+
removePendingReroute(id);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
})).catch(error => { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" }); });
|
|
1370
|
+
restoreSettlementTasks.add(task);
|
|
1371
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
1372
|
+
return task;
|
|
1373
|
+
};
|
|
1374
|
+
const beforeQueueReceiptPublished = async (receipt, queueOwner, ctx, isWorkerCurrent) => {
|
|
1375
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
1376
|
+
if (!store)
|
|
1377
|
+
return;
|
|
1378
|
+
const selected = store.list().filter(intent => intent.request.source.journalBindingKey === receipt.journalBindingKey &&
|
|
1379
|
+
intent.request.source.updateIds.some(id => receipt.sourceUpdateIds.includes(id))).map(intent => intent.request.operationId);
|
|
1380
|
+
if (!selected.length)
|
|
1381
|
+
return;
|
|
1382
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1383
|
+
const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.(), operatorUserId = deps.configStore.getAllowedUserId();
|
|
1384
|
+
const binding = receipt.journalBindingKey, ports = deps.workspaceRestoreRecipient, threads = deps.threadStore;
|
|
1385
|
+
if (!instanceId || epoch === undefined || generation === undefined || !scope || !binding || operatorUserId === undefined ||
|
|
1386
|
+
!ports || !threads || !deps.runWorkspaceOperation || !deps.inspectRestoreQueuedReceipt) {
|
|
1387
|
+
throw new Error("Workspace Restore queued acceptance authority is unavailable.");
|
|
1388
|
+
}
|
|
1389
|
+
const expectedReceipt = { queueKind: receipt.queueKind, receiptId: receipt.receiptId,
|
|
1390
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...queueOwner } };
|
|
1391
|
+
const current = () => isWorkerCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
|
|
1392
|
+
deps.isContextActive?.(ctx) !== false && deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
1393
|
+
deps.getAdmissionJournalBinding?.() === binding && deps.getCurrentInstanceId?.() === instanceId &&
|
|
1394
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
|
|
1395
|
+
queueOwner.instanceId === instanceId && queueOwner.sessionGeneration === generation;
|
|
1396
|
+
const assertCurrent = () => { if (!current())
|
|
1397
|
+
throw new Error("Workspace Restore queued acceptance authority changed."); };
|
|
1398
|
+
assertCurrent();
|
|
1399
|
+
const recipientGuards = [];
|
|
1400
|
+
const sourceCompletions = [];
|
|
1401
|
+
await deps.runWorkspaceOperation({ operationId: `restore-queue-acceptance-${randomBytes(16).toString("hex")}`,
|
|
1402
|
+
operationKind: "workspace.restore-queue-acceptance", scopes: [{ kind: "profile" }] }, async () => {
|
|
1403
|
+
assertCurrent();
|
|
1404
|
+
const active = deps.getWorkspaceRestoreStore?.();
|
|
1405
|
+
if (!active)
|
|
1406
|
+
throw new Error("Workspace Restore queued acceptance storage disappeared.");
|
|
1407
|
+
const proof = deps.inspectRestoreQueuedReceipt({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
|
|
1408
|
+
assertCurrent();
|
|
1409
|
+
if (!proof || !isDeepStrictEqual(proof.receipt, expectedReceipt) || !/^[a-f0-9]{64}$/u.test(proof.queueOwnerSha256) ||
|
|
1410
|
+
proof.sources.length !== receipt.sourceUpdateIds.length || proof.sources.some((source, index) => source.updateId !== receipt.sourceUpdateIds[index] || !/^[a-f0-9]{64}$/u.test(source.sourceSha256))) {
|
|
1411
|
+
throw new Error("Workspace Restore queued receipt proof is unavailable.");
|
|
1412
|
+
}
|
|
1413
|
+
for (const operationId of selected) {
|
|
1414
|
+
let intent = active.list().find(value => value.request.operationId === operationId);
|
|
1415
|
+
const recipient = intent?.readyRecipient ?? intent?.recipient;
|
|
1416
|
+
if (!intent || intent.phase !== "ready" || !intent.routing || intent.operatorUserId !== operatorUserId ||
|
|
1417
|
+
!isDeepStrictEqual(intent.executor, { instanceId, leaderEpoch: String(epoch) }) ||
|
|
1418
|
+
intent.request.source.journalBindingKey !== binding || recipient?.kind !== "leader" || recipient.instanceId !== instanceId ||
|
|
1419
|
+
recipient.generation !== String(generation) || recipient.sessionId !== intent.request.binding.sessionId) {
|
|
1420
|
+
throw new Error("Workspace Restore queued recipient proof changed.");
|
|
1421
|
+
}
|
|
1422
|
+
const request = intent.request;
|
|
1423
|
+
const liveRecipientCurrent = () => {
|
|
1424
|
+
const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
|
|
1425
|
+
return current() && !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
|
|
1426
|
+
ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
|
|
1427
|
+
};
|
|
1428
|
+
const recipientCurrent = () => {
|
|
1429
|
+
if (!liveRecipientCurrent())
|
|
1430
|
+
return false;
|
|
1431
|
+
let committed = false;
|
|
1432
|
+
threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
|
|
1433
|
+
const live = snapshot.workspaceBindings?.filter(value => value.bindingKey === request.binding.bindingKey) ?? [];
|
|
1434
|
+
const owners = snapshot.threads.filter(value => value.status === "active" &&
|
|
1435
|
+
(value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
|
|
1436
|
+
const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
|
|
1437
|
+
committed = live.length === 1 && live[0]?.sessionId === recipient.sessionId && live[0].cwd === request.binding.cwd &&
|
|
1438
|
+
live[0].slot === request.binding.slot && live[0].inactiveSinceMs === undefined && isDeepStrictEqual(live[0].target, request.target) &&
|
|
1439
|
+
owners.length === 1 && owners[0]?.owner?.kind === "leader" && owners[0].instanceId === instanceId &&
|
|
1440
|
+
owners[0].owner.instanceId === instanceId && !!owners[0].owner.cwd &&
|
|
1441
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) === request.binding.cwd && owners[0].profileKey === request.owner.profileKey &&
|
|
1442
|
+
owners[0].slot === request.binding.slot && isDeepStrictEqual(owners[0].target, request.target) &&
|
|
1443
|
+
!!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
|
|
1444
|
+
ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
|
|
1445
|
+
});
|
|
1446
|
+
return committed && liveRecipientCurrent();
|
|
1447
|
+
};
|
|
1448
|
+
recipientGuards.push(recipientCurrent);
|
|
1449
|
+
// Storage owns the canonical CAS; its in-transaction authority callback cannot reacquire the snapshot lock.
|
|
1450
|
+
const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: liveRecipientCurrent };
|
|
1451
|
+
for (const source of proof.sources.filter(value => request.source.updateIds.includes(value.updateId))) {
|
|
1452
|
+
if (!recipientCurrent())
|
|
1453
|
+
throw new Error("Workspace Restore queued recipient authority changed.");
|
|
1454
|
+
const evidence = { ...source, journalBindingKey: binding, recipient,
|
|
1455
|
+
kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind, queueOwnerSha256: proof.queueOwnerSha256 };
|
|
1456
|
+
let accepted, failure;
|
|
1457
|
+
try {
|
|
1458
|
+
accepted = active.recordSourceAcceptance(intent, evidence, authority);
|
|
1459
|
+
}
|
|
1460
|
+
catch (error) {
|
|
1461
|
+
failure = error;
|
|
1462
|
+
}
|
|
1463
|
+
if (!accepted)
|
|
1464
|
+
accepted = active.list().find(value => value.request.operationId === operationId &&
|
|
1465
|
+
isDeepStrictEqual(value.request, request) && isDeepStrictEqual(value.executor, authority.executor) && value.operatorUserId === operatorUserId &&
|
|
1466
|
+
value.routing?.acceptances?.some(retained => isDeepStrictEqual(retained, evidence)));
|
|
1467
|
+
if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
|
|
1468
|
+
accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) {
|
|
1469
|
+
throw failure ?? new Error("Workspace Restore queued acceptance was not published.");
|
|
1470
|
+
}
|
|
1471
|
+
intent = accepted;
|
|
1472
|
+
sourceCompletions.push({ ...source, journalBindingKey: binding,
|
|
1473
|
+
completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) });
|
|
1474
|
+
if (!recipientCurrent())
|
|
1475
|
+
throw new Error("Workspace Restore queued recipient authority changed.");
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const observed = deps.inspectRestoreQueuedReceipt({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
|
|
1479
|
+
assertCurrent();
|
|
1480
|
+
if (!isDeepStrictEqual(observed, proof))
|
|
1481
|
+
throw new Error("Workspace Restore queued receipt changed after publication.");
|
|
1482
|
+
});
|
|
1483
|
+
assertCurrent();
|
|
1484
|
+
if (recipientGuards.some(guard => !guard()))
|
|
1485
|
+
throw new Error("Workspace Restore queued recipient changed after admission.");
|
|
1486
|
+
return sourceCompletions.sort((a, b) => a.updateId - b.updateId);
|
|
1487
|
+
};
|
|
1488
|
+
const onQueueReceiptCompleted = (receipt, ctx) => {
|
|
1489
|
+
// A hint re-enters admission and rereads scoped proof; it never supplies completed command evidence for a queued source.
|
|
1490
|
+
if (!receipt.journalBindingKey)
|
|
1491
|
+
return;
|
|
1492
|
+
observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
|
|
1493
|
+
updateIds: [...receipt.sourceUpdateIds], kind: "completed" } }, ctx);
|
|
1494
|
+
for (const updateId of receipt.sourceUpdateIds)
|
|
1495
|
+
retireCompletedTemporaryThread(updateId, ctx, receipt.journalBindingKey);
|
|
1496
|
+
};
|
|
1497
|
+
const onQueueReceiptCommitted = (receipt, ctx) => {
|
|
1498
|
+
if (receipt.journalBindingKey)
|
|
1499
|
+
observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
|
|
1500
|
+
updateIds: [...receipt.sourceUpdateIds], kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind } }, ctx);
|
|
1501
|
+
};
|
|
1502
|
+
// Worker-confirmed source completion is the only evidence for a Forwarded input. It records a durable group fact and
|
|
1503
|
+
// starts the same delayed, fully rechecked cleanup as the last Cancel; deletion itself never happens here.
|
|
1504
|
+
const completedTemporaryInputIds = new Map();
|
|
1505
|
+
const retireCompletedTemporaryThread = (updateId, ctx, journalBindingKey) => {
|
|
1506
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
1507
|
+
if (!cap || !deps.runWorkspaceOperation || cap.journalBindingKey !== journalBindingKey)
|
|
1508
|
+
return;
|
|
1509
|
+
const store = cap.store;
|
|
1510
|
+
const matches = (entry) => entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId;
|
|
1511
|
+
const inGroup = (entry) => Threads.getTelegramTemporaryThreadInputs(entry).some(input => input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
|
|
1512
|
+
let restoreOwned = false;
|
|
1513
|
+
try {
|
|
1514
|
+
if (!store.listTemporaryThreads().some(entry => matches(entry) || inGroup(entry)))
|
|
1515
|
+
return;
|
|
1516
|
+
restoreOwned = store.list().some(intent => intent.request.source.journalBindingKey === journalBindingKey &&
|
|
1517
|
+
intent.request.source.updateIds.includes(updateId));
|
|
1518
|
+
}
|
|
1519
|
+
catch (error) {
|
|
1520
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" });
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
const task = Promise.resolve().then(() => deps.runWorkspaceOperation({ operationId: `temporary-thread-retire-${randomBytes(16).toString("hex")}`,
|
|
1524
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
1525
|
+
if (!cap.isCurrent())
|
|
1526
|
+
return;
|
|
1527
|
+
let entry = store.listTemporaryThreads().find(restoreOwned ? matches : inGroup);
|
|
1528
|
+
if (!entry)
|
|
1529
|
+
return;
|
|
1530
|
+
if (restoreOwned) {
|
|
1531
|
+
// A Restore keeps its own lifecycle: only its source entry may be released, never by Forward bookkeeping.
|
|
1532
|
+
if (entry.target && hasOtherTemporaryThreadReroute(entry.target, entry))
|
|
1533
|
+
return;
|
|
1534
|
+
entry = cap.adopt(entry);
|
|
1535
|
+
if (entry && cap.isCurrent())
|
|
1536
|
+
store.retireTemporaryThread(entry, cap.authority);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const group = Threads.getTelegramTemporaryThreadInputs(entry).find(input => input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
|
|
1540
|
+
const target = entry.target;
|
|
1541
|
+
if (!group || entry.phase !== "created" || !target)
|
|
1542
|
+
return;
|
|
1543
|
+
if (entry.cancelledInputs?.some(input => isDeepStrictEqual(input, group)) ||
|
|
1544
|
+
entry.completedInputs?.some(input => isDeepStrictEqual(input, group)))
|
|
1545
|
+
return;
|
|
1546
|
+
// Process-local accumulation only: a restart forgets partial groups and the tab stays protected.
|
|
1547
|
+
const seen = completedTemporaryInputIds.get(entry.token) ?? new Set();
|
|
1548
|
+
seen.add(updateId);
|
|
1549
|
+
completedTemporaryInputIds.set(entry.token, seen);
|
|
1550
|
+
if (!group.updateIds.every(id => seen.has(id)))
|
|
1551
|
+
return;
|
|
1552
|
+
entry = cap.adopt(entry);
|
|
1553
|
+
if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInputCompletion(entry, group, cap.authority) || !cap.isCurrent())
|
|
1554
|
+
return;
|
|
1555
|
+
scheduleTemporaryThreadCleanup(target, ctx);
|
|
1556
|
+
return true;
|
|
1557
|
+
})).then(published => {
|
|
1558
|
+
// A sibling may complete after Restore already settled. Reinspect only after its terminal fact publishes and admission releases.
|
|
1559
|
+
if (published && cap.isCurrent())
|
|
1560
|
+
observeRestoreSettlement({ kind: "source",
|
|
1561
|
+
evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
|
|
1562
|
+
}).catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); });
|
|
1563
|
+
restoreSettlementTasks.add(task);
|
|
1564
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
1565
|
+
};
|
|
1566
|
+
const onUpdateCompleted = (updateId, ctx, journalBindingKey) => {
|
|
1567
|
+
if (!journalBindingKey)
|
|
1568
|
+
return;
|
|
1569
|
+
observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
|
|
1570
|
+
retireCompletedTemporaryThread(updateId, ctx, journalBindingKey);
|
|
700
1571
|
};
|
|
1572
|
+
const restoreWorkspace = deps.workspaceRestoreRecipient ? async (input) => {
|
|
1573
|
+
const ports = deps.workspaceRestoreRecipient;
|
|
1574
|
+
const threads = deps.threadStore;
|
|
1575
|
+
if (!threads || !deps.getWorkspaceRestoreStore || !deps.getSessionGeneration ||
|
|
1576
|
+
!deps.hasWorkspaceRestoreAuthority?.() || !input.isCurrent() || input.target.threadId === undefined)
|
|
1577
|
+
return "protected";
|
|
1578
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1579
|
+
const operatorUserId = deps.configStore.getAllowedUserId(), scope = deps.getAdmissionScope?.();
|
|
1580
|
+
const journalBindingKey = deps.getAdmissionJournalBinding?.(), generation = deps.getSessionGeneration();
|
|
1581
|
+
const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(input.messages);
|
|
1582
|
+
if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope || !journalBindingKey || !sourceIds.length)
|
|
1583
|
+
return "protected";
|
|
1584
|
+
const current = () => input.isCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
|
|
1585
|
+
deps.isContextActive?.(input.ctx) !== false && deps.getSessionGeneration?.() === generation &&
|
|
1586
|
+
deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
|
|
1587
|
+
deps.getAdmissionScope?.() === scope && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
|
|
1588
|
+
deps.configStore.getAllowedUserId() === operatorUserId;
|
|
1589
|
+
await threads.load();
|
|
1590
|
+
if (!current())
|
|
1591
|
+
return "protected";
|
|
1592
|
+
const store = deps.getWorkspaceRestoreStore();
|
|
1593
|
+
if (!store)
|
|
1594
|
+
return "protected";
|
|
1595
|
+
const retained = store.list().find(value => value.request.operationId === input.operationId);
|
|
1596
|
+
const bindings = threads.listWorkspaceBindings().filter(value => isDeepStrictEqual(value.target, input.record.target));
|
|
1597
|
+
const binding = retained?.request.binding ?? (bindings.length === 1 ? bindings[0] : undefined);
|
|
1598
|
+
if (!binding || !binding.sessionId || !binding.slot)
|
|
1599
|
+
return "protected";
|
|
1600
|
+
const { sessionId, slot } = binding;
|
|
1601
|
+
const request = { operationId: input.operationId,
|
|
1602
|
+
binding, owner: structuredClone(input.record), target: { chatId: input.target.chatId, threadId: input.target.threadId },
|
|
1603
|
+
source: { journalBindingKey, updateIds: sourceIds } };
|
|
1604
|
+
if (retained && !isDeepStrictEqual(retained.request, request))
|
|
1605
|
+
return "protected";
|
|
1606
|
+
const getRecipient = (snapshot) => {
|
|
1607
|
+
if (!current())
|
|
1608
|
+
return undefined;
|
|
1609
|
+
const liveBindings = (snapshot ? snapshot.workspaceBindings ?? [] : threads.listWorkspaceBindings())
|
|
1610
|
+
.filter(value => value.bindingKey === binding.bindingKey);
|
|
1611
|
+
const live = liveBindings[0];
|
|
1612
|
+
if (liveBindings.length !== 1 || live?.sessionId !== binding.sessionId || live.cwd !== binding.cwd || live.slot !== binding.slot ||
|
|
1613
|
+
live.inactiveSinceMs !== undefined || (!isDeepStrictEqual(live.target, binding.target) && !isDeepStrictEqual(live.target, request.target)))
|
|
1614
|
+
return undefined;
|
|
1615
|
+
const records = (snapshot ? snapshot.threads : threads.list()).filter(value => value.status === "active" &&
|
|
1616
|
+
(value.slot === binding.slot || isDeepStrictEqual(value.target, live.target)));
|
|
1617
|
+
const record = records[0];
|
|
1618
|
+
if (records.length !== 1 || record?.slot !== binding.slot || !isDeepStrictEqual(record.target, live.target))
|
|
1619
|
+
return undefined;
|
|
1620
|
+
if (record.owner?.kind === "leader" && record.instanceId === instanceId) {
|
|
1621
|
+
const cwd = ports.getCwd(input.ctx);
|
|
1622
|
+
if (ports.getSessionId(input.ctx) !== sessionId || !cwd || WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) !== binding.cwd ||
|
|
1623
|
+
!deps.setCurrentLeaderIdentity)
|
|
1624
|
+
return undefined;
|
|
1625
|
+
return { kind: "leader", instanceId, sessionId, generation: String(generation) };
|
|
1626
|
+
}
|
|
1627
|
+
if (record.owner?.kind !== "manual-follower" || !record.instanceId)
|
|
1628
|
+
return undefined;
|
|
1629
|
+
const follower = ports.followerRegistry.get(record.instanceId);
|
|
1630
|
+
if (!follower?.registrationGeneration || !follower.cwd || follower.sessionId !== sessionId ||
|
|
1631
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) !== binding.cwd || follower.slot !== slot ||
|
|
1632
|
+
!Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE))
|
|
1633
|
+
return undefined;
|
|
1634
|
+
return { kind: "follower", instanceId: follower.instanceId, sessionId, generation: follower.registrationGeneration };
|
|
1635
|
+
};
|
|
1636
|
+
const hasCommittedTarget = () => threads.listWorkspaceBindings().some(value => value.bindingKey === binding.bindingKey && isDeepStrictEqual(value.target, request.target));
|
|
1637
|
+
const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
|
|
1638
|
+
const ready = await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, getRecipient,
|
|
1639
|
+
async runRecipient(action) {
|
|
1640
|
+
const recipient = getRecipient();
|
|
1641
|
+
if (!recipient || !action.isCurrent() || !hasCommittedTarget())
|
|
1642
|
+
return undefined;
|
|
1643
|
+
if (recipient.kind === "follower") {
|
|
1644
|
+
const follower = ports.followerRegistry.get(recipient.instanceId);
|
|
1645
|
+
const observation = await ports.runFollower({ operationId: request.operationId, instanceId: recipient.instanceId,
|
|
1646
|
+
sessionId: recipient.sessionId, slot: binding.slot, target: request.target, oldTarget: binding.target,
|
|
1647
|
+
mode: action.mode, isCurrent: action.isCurrent });
|
|
1648
|
+
if (!action.isCurrent() || !hasCommittedTarget())
|
|
1649
|
+
return undefined;
|
|
1650
|
+
if (!follower || !observation?.ready)
|
|
1651
|
+
return observation;
|
|
1652
|
+
if (observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
|
|
1653
|
+
!isDeepStrictEqual(observation.target, request.target) || observation.slot !== binding.slot)
|
|
1654
|
+
return undefined;
|
|
1655
|
+
const actual = ports.followerRegistry.get(recipient.instanceId);
|
|
1656
|
+
if (!actual || actual.registrationGeneration !== follower.registrationGeneration || actual.sessionId !== follower.sessionId ||
|
|
1657
|
+
actual.cwd !== follower.cwd || actual.slot !== follower.slot || actual.profileKey !== follower.profileKey ||
|
|
1658
|
+
actual.busSocketPath !== follower.busSocketPath || !isDeepStrictEqual(actual.target, follower.target) ||
|
|
1659
|
+
!isDeepStrictEqual(actual.protocol, follower.protocol))
|
|
1660
|
+
return undefined;
|
|
1661
|
+
threads.commitWorkspaceRestoreRegistration({ target: request.target, bindingKey: binding.bindingKey, slot: binding.slot }, () => {
|
|
1662
|
+
if (!action.isCurrent())
|
|
1663
|
+
throw new Error("Workspace Restore recipient authority changed.");
|
|
1664
|
+
ports.followerRegistry.register({ ...actual, target: request.target, connectedAtMs: actual.connectedAtMs });
|
|
1665
|
+
});
|
|
1666
|
+
return observation;
|
|
1667
|
+
}
|
|
1668
|
+
let observation;
|
|
1669
|
+
threads.withWorkspaceRestoreSnapshot(action.intent, snapshot => {
|
|
1670
|
+
if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient))
|
|
1671
|
+
return;
|
|
1672
|
+
const live = snapshot.workspaceBindings?.find(value => value.bindingKey === binding.bindingKey);
|
|
1673
|
+
if (!live || !isDeepStrictEqual(live.target, request.target))
|
|
1674
|
+
return;
|
|
1675
|
+
const local = ports.getLeaderIdentity();
|
|
1676
|
+
if (!local || local.slot !== binding.slot || (!isDeepStrictEqual(local.target, binding.target) &&
|
|
1677
|
+
!isDeepStrictEqual(local.target, request.target)))
|
|
1678
|
+
return;
|
|
1679
|
+
if (action.mode === "apply" && !isDeepStrictEqual(local.target, request.target)) {
|
|
1680
|
+
if (action.intent.phase !== "recipient-issued" || !action.isCurrent())
|
|
1681
|
+
return;
|
|
1682
|
+
deps.setCurrentLeaderIdentity({ target: request.target, slot: binding.slot, threadName: request.owner.threadName });
|
|
1683
|
+
}
|
|
1684
|
+
if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient))
|
|
1685
|
+
return;
|
|
1686
|
+
const observed = ports.getLeaderIdentity();
|
|
1687
|
+
if (!observed || observed.slot !== binding.slot)
|
|
1688
|
+
return;
|
|
1689
|
+
observation = { operationId: request.operationId, recipient, target: observed.target,
|
|
1690
|
+
slot: binding.slot, ready: isDeepStrictEqual(observed.target, request.target) };
|
|
1691
|
+
});
|
|
1692
|
+
return observation;
|
|
1693
|
+
} }).catch(error => {
|
|
1694
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-recipient" });
|
|
1695
|
+
return undefined;
|
|
1696
|
+
});
|
|
1697
|
+
if (!ready || !current() || ready.routing)
|
|
1698
|
+
return "protected";
|
|
1699
|
+
const recipient = ready.readyRecipient ?? ready.recipient;
|
|
1700
|
+
const recipientCurrent = () => {
|
|
1701
|
+
if (!current() || !recipient || !hasCommittedTarget() || !isDeepStrictEqual(getRecipient(), recipient))
|
|
1702
|
+
return false;
|
|
1703
|
+
const observed = recipient.kind === "leader" ? ports.getLeaderIdentity() : ports.followerRegistry.get(recipient.instanceId);
|
|
1704
|
+
return observed?.slot === slot && isDeepStrictEqual(observed.target, request.target);
|
|
1705
|
+
};
|
|
1706
|
+
if (!recipient || !recipientCurrent())
|
|
1707
|
+
return "protected";
|
|
1708
|
+
if (deps.callApi) {
|
|
1709
|
+
try {
|
|
1710
|
+
await deps.callApi("editForumTopic", { chat_id: request.target.chatId, message_thread_id: request.target.threadId,
|
|
1711
|
+
name: deps.getDisplayTitle?.(request.target) ?? ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot) });
|
|
1712
|
+
}
|
|
1713
|
+
catch (error) {
|
|
1714
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-title" });
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
if (!recipientCurrent() || !store.issueRouting(ready, { ...authority, isCurrent: recipientCurrent }))
|
|
1718
|
+
return "protected";
|
|
1719
|
+
const recordAcceptance = (message, delivery) => {
|
|
1720
|
+
if (!recipientCurrent() || recipient.kind !== (delivery ? "follower" : "leader"))
|
|
1721
|
+
throw new Error("Workspace Restore acceptance authority changed.");
|
|
1722
|
+
const source = Updates.inspectTelegramDeferredSource(message);
|
|
1723
|
+
const follower = ports.followerRegistry.get(recipient.instanceId);
|
|
1724
|
+
const ownership = deps.getTargetOwnership?.(request.target);
|
|
1725
|
+
if (!source || source.journalBindingKey !== journalBindingKey || !request.source.updateIds.includes(source.updateId) ||
|
|
1726
|
+
delivery && (!follower || ownership?.instanceId !== recipient.instanceId || ownership.ownerGeneration !== recipient.generation ||
|
|
1727
|
+
!ownership.recipientBindingKey || delivery.sourceUpdateId !== source.updateId ||
|
|
1728
|
+
delivery.recipientBindingKey !== ownership.recipientBindingKey || delivery.deliveryId !== Bus.createTelegramBusFollowerDeliveryIdentity({
|
|
1729
|
+
kind: "leader.forwardMessage", recipientBindingKey: ownership.recipientBindingKey, sourceUpdateId: source.updateId
|
|
1730
|
+
}).deliveryId) ||
|
|
1731
|
+
!recipientCurrent())
|
|
1732
|
+
throw new Error("Workspace Restore acceptance source or delivery changed.");
|
|
1733
|
+
const evidence = { ...source, recipient,
|
|
1734
|
+
...(delivery ? { kind: "forwarded", deliveryId: delivery.deliveryId, recipientBindingKey: delivery.recipientBindingKey }
|
|
1735
|
+
: { kind: "completed" }) };
|
|
1736
|
+
const expected = store.list().find(value => value.request.operationId === request.operationId);
|
|
1737
|
+
if (!expected || !isDeepStrictEqual(expected.request, request) || !expected.routing ||
|
|
1738
|
+
!recipientCurrent())
|
|
1739
|
+
throw new Error("Workspace Restore dispatch proof is unavailable.");
|
|
1740
|
+
let accepted;
|
|
1741
|
+
let failure;
|
|
1742
|
+
const acceptanceAuthority = { ...authority, isCurrent: recipientCurrent };
|
|
1743
|
+
try {
|
|
1744
|
+
accepted = store.recordSourceAcceptance(expected, evidence, acceptanceAuthority);
|
|
1745
|
+
}
|
|
1746
|
+
catch (error) {
|
|
1747
|
+
failure = { error };
|
|
1748
|
+
}
|
|
1749
|
+
if (!recipientCurrent())
|
|
1750
|
+
throw new Error("Workspace Restore acceptance authority changed.");
|
|
1751
|
+
// A lost rename reply observes only the exact full retained proof; it never resends the accepted message.
|
|
1752
|
+
if (!accepted) {
|
|
1753
|
+
const observed = store.list().find(value => value.request.operationId === request.operationId);
|
|
1754
|
+
if (observed && isDeepStrictEqual(observed.request, request) && isDeepStrictEqual(observed.executor, authority.executor) &&
|
|
1755
|
+
observed.operatorUserId === operatorUserId && observed.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)))
|
|
1756
|
+
accepted = observed;
|
|
1757
|
+
}
|
|
1758
|
+
if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
|
|
1759
|
+
accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)) ||
|
|
1760
|
+
!recipientCurrent())
|
|
1761
|
+
throw failure?.error ?? new Error("Workspace Restore acceptance was not published.");
|
|
1762
|
+
return { ...source, completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) };
|
|
1763
|
+
};
|
|
1764
|
+
try {
|
|
1765
|
+
await input.dispatch(recipient, recipientCurrent, recordAcceptance, message => recordAcceptance(message));
|
|
1766
|
+
}
|
|
1767
|
+
catch (error) {
|
|
1768
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-dispatch" });
|
|
1769
|
+
}
|
|
1770
|
+
// Positive worker ACKs own settlement and cleanup under a subsequent admission.
|
|
1771
|
+
return "protected";
|
|
1772
|
+
} : undefined;
|
|
701
1773
|
const retryPendingRerouteCleanup = async (cleanup, assertExecutionCurrent) => {
|
|
702
1774
|
if (cleanup.kind === "unbound") {
|
|
703
|
-
return closeReroutedUnboundTopic(cleanup.target, cleanup.messageId, assertExecutionCurrent);
|
|
1775
|
+
return closeReroutedUnboundTopic(cleanup.target, cleanup.messageId, assertExecutionCurrent, cleanup.temporaryThread);
|
|
704
1776
|
}
|
|
705
1777
|
if (cleanup.kind === "previous-leader") {
|
|
706
1778
|
return closePreviousLeaderThread(cleanup.target, assertExecutionCurrent);
|
|
@@ -711,60 +1783,701 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
711
1783
|
const dispatchPendingRerouteMessages = async (pending, messages, ctx) => {
|
|
712
1784
|
if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
|
|
713
1785
|
await dispatchReroutedCommandMessages(messages, ctx);
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1786
|
+
// Every command chooser deferred its exact original, whether the tab came from Telegram or the bridge.
|
|
1787
|
+
// A queued command retains its separately reported receipt; this cannot settle accepted queue custody.
|
|
1788
|
+
for (const message of messages)
|
|
1789
|
+
Updates.reportTelegramUpdateCompleted(message);
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
await promptEnqueue(messages, ctx);
|
|
1793
|
+
};
|
|
1794
|
+
const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
|
|
1795
|
+
assertExecutionCurrent();
|
|
1796
|
+
const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
|
|
1797
|
+
assertExecutionCurrent();
|
|
1798
|
+
if (dismissed) {
|
|
1799
|
+
removePendingReroute(rerouteId);
|
|
1800
|
+
await deps.answerCallbackQuery(query.id, successMessage);
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
pending.finalizeMessage = successMessage;
|
|
1804
|
+
await deps.answerCallbackQuery(query.id, `${successMessage} Chooser cleanup is still pending. Try again.`);
|
|
1805
|
+
};
|
|
1806
|
+
const isTemporaryReroute = (pending) => !!pending.temporaryThread || pending.temporaryMembership === true || isTemporaryTabTarget(pending.sourceTarget);
|
|
1807
|
+
const isTemporaryThreadForwardIssued = (pending) => {
|
|
1808
|
+
try {
|
|
1809
|
+
const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
1810
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
1811
|
+
if ((entry || pending.temporaryMembership) && !store)
|
|
1812
|
+
return true;
|
|
1813
|
+
const stored = entry && store?.listTemporaryThreads().find(value => value.token === entry.token);
|
|
1814
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
|
|
1815
|
+
return !!stored?.forwardedInputs?.some(input => input.updateIds.some(id => updateIds.includes(id)));
|
|
1816
|
+
}
|
|
1817
|
+
catch {
|
|
1818
|
+
return true;
|
|
1819
|
+
}
|
|
1820
|
+
};
|
|
1821
|
+
/** Publishes the durable one-time Forward fact for this chooser's exact group; false means nothing may be sent. */
|
|
1822
|
+
const issueTemporaryThreadForward = (pending, ctx) => {
|
|
1823
|
+
const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
1824
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
1825
|
+
if (!entry || !cap)
|
|
1826
|
+
return false;
|
|
1827
|
+
const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
|
|
1828
|
+
try {
|
|
1829
|
+
const current = cap.store.listTemporaryThreads().find(value => value.token === entry.token);
|
|
1830
|
+
const adopted = current && cap.isCurrent() ? cap.adopt(current) : undefined;
|
|
1831
|
+
return !!adopted && cap.isCurrent() && !!cap.store.recordTemporaryThreadForwardIssued(adopted, group, cap.authority) && cap.isCurrent();
|
|
1832
|
+
}
|
|
1833
|
+
catch (error) {
|
|
1834
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-forward-issue" });
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent, recordForwardAcceptance) => {
|
|
1839
|
+
assertExecutionCurrent?.();
|
|
1840
|
+
const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
|
|
1841
|
+
if (!forwardMessage || pending.foreignForwardIssued)
|
|
1842
|
+
return false;
|
|
1843
|
+
const target = { ...pending.sourceTarget, threadId };
|
|
1844
|
+
const liveOwnership = deps.getTargetOwnership?.(target);
|
|
1845
|
+
if (!liveOwnership || liveOwnership.instanceId !== instanceId ||
|
|
1846
|
+
!liveOwnership.ownerGeneration || !liveOwnership.recipientBindingKey)
|
|
1847
|
+
return false;
|
|
1848
|
+
const ownership = structuredClone(liveOwnership);
|
|
1849
|
+
const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
|
|
1850
|
+
// Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
|
|
1851
|
+
// even after restart. Any refusal or publication failure sends nothing and keeps the input held.
|
|
1852
|
+
if (!recordForwardAcceptance && isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
|
|
1853
|
+
if (isTemporaryThreadForwardIssued(pending))
|
|
1854
|
+
pending.foreignForwardIssued = true;
|
|
1855
|
+
return false;
|
|
1856
|
+
}
|
|
1857
|
+
if (!recordForwardAcceptance && isTemporaryReroute(pending))
|
|
1858
|
+
pending.foreignForwardIssued = true;
|
|
1859
|
+
const outcomes = await Promise.allSettled(messages.map((message) => forwardMessage({
|
|
1860
|
+
message,
|
|
1861
|
+
ownership,
|
|
1862
|
+
ctx,
|
|
1863
|
+
})));
|
|
1864
|
+
assertExecutionCurrent?.();
|
|
1865
|
+
if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership))
|
|
1866
|
+
return false;
|
|
1867
|
+
pending.messages = pending.messages.filter((_, index) => {
|
|
1868
|
+
const outcome = outcomes[index];
|
|
1869
|
+
if (outcome?.status === "fulfilled" &&
|
|
1870
|
+
outcome.value.status === "accepted") {
|
|
1871
|
+
// Restore publishes acceptance before reporting completion; cleanup still waits for journal disposition.
|
|
1872
|
+
const expectedSource = recordForwardAcceptance?.(pending.messages[index], outcome.value.delivery);
|
|
1873
|
+
assertExecutionCurrent?.();
|
|
1874
|
+
Updates.reportTelegramUpdateCompleted(pending.messages[index], expectedSource);
|
|
1875
|
+
return false;
|
|
1876
|
+
}
|
|
1877
|
+
if (outcome?.status === "rejected") {
|
|
1878
|
+
deps.recordRuntimeEvent?.("bus", outcome.reason, {
|
|
1879
|
+
phase: "reroute-foreign-forward",
|
|
1880
|
+
instanceId,
|
|
1881
|
+
threadId,
|
|
1882
|
+
messageIndex: index,
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
return true;
|
|
1886
|
+
});
|
|
1887
|
+
if (pending.messages.length === 0)
|
|
1888
|
+
pending.foreignForwardIssued = false;
|
|
1889
|
+
return pending.messages.length === 0;
|
|
1890
|
+
};
|
|
1891
|
+
const temporaryCleanupTimers = new Map();
|
|
1892
|
+
const cancelTemporaryThreadCleanup = (token) => {
|
|
1893
|
+
const scheduled = temporaryCleanupTimers.get(token);
|
|
1894
|
+
if (!scheduled)
|
|
1895
|
+
return;
|
|
1896
|
+
clearTimeout(scheduled.timer);
|
|
1897
|
+
temporaryCleanupTimers.delete(token);
|
|
1898
|
+
scheduled.settle();
|
|
1899
|
+
};
|
|
1900
|
+
/**
|
|
1901
|
+
* One cleanup attempt after the quiet period. Cancellation facts are only a precondition: fresh authority, proof,
|
|
1902
|
+
* absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
|
|
1903
|
+
* durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
|
|
1904
|
+
*/
|
|
1905
|
+
const runTemporaryThreadCleanup = async (token, cap) => {
|
|
1906
|
+
const store = cap.store, inspect = deps.inspectRestoreSourceAbandonment;
|
|
1907
|
+
if (!inspect || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent())
|
|
1908
|
+
return;
|
|
1909
|
+
await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
|
|
1910
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
1911
|
+
if (!cap.isCurrent())
|
|
1912
|
+
return;
|
|
1913
|
+
let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
|
|
1914
|
+
const target = entry?.target;
|
|
1915
|
+
if (!entry || !target || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target) ||
|
|
1916
|
+
entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
|
|
1917
|
+
return;
|
|
1918
|
+
const owner = `telegram-owner:${cap.operatorUserId}`, cancelled = entry.cancelledInputs ?? [];
|
|
1919
|
+
const cancelledCurrent = () => cancelled.every(input => input.updateIds.every(updateId => {
|
|
1920
|
+
const evidence = inspect(updateId, input.journalBindingKey);
|
|
1921
|
+
return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
|
|
1922
|
+
evidence.operatorAuthorityId === owner;
|
|
1923
|
+
}));
|
|
1924
|
+
if (!cancelledCurrent())
|
|
1925
|
+
return;
|
|
1926
|
+
entry = cap.adopt(entry);
|
|
1927
|
+
if (!entry || !cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry))
|
|
1928
|
+
return;
|
|
1929
|
+
const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
|
|
1930
|
+
if (!issued || !cap.isCurrent())
|
|
1931
|
+
return;
|
|
1932
|
+
entry = issued.entry;
|
|
1933
|
+
const issuedEntry = entry;
|
|
1934
|
+
const assertCurrent = () => {
|
|
1935
|
+
if (!cap.isCurrent() || !cancelledCurrent() || !store.isTemporaryThreadCleanupCurrent(issuedEntry, cap.authority))
|
|
1936
|
+
throw new Error("Temporary Thread cleanup authority or exact grant evidence changed.");
|
|
1937
|
+
};
|
|
1938
|
+
if (!await closeReroutedUnboundTopic(target, undefined, assertCurrent, entry) || !cap.isCurrent())
|
|
1939
|
+
return;
|
|
1940
|
+
store.retireTemporaryThread(entry, cap.authority);
|
|
1941
|
+
});
|
|
1942
|
+
};
|
|
1943
|
+
/** Starts the quiet period only when every known group is resolved; a new input or authority change denies it. */
|
|
1944
|
+
const scheduleTemporaryThreadCleanup = (target, ctx) => {
|
|
1945
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
1946
|
+
if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target))
|
|
1947
|
+
return;
|
|
1948
|
+
let entry;
|
|
1949
|
+
try {
|
|
1950
|
+
entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
|
|
1951
|
+
}
|
|
1952
|
+
catch (error) {
|
|
1953
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" });
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
if (!entry || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) ||
|
|
1957
|
+
entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
|
|
1958
|
+
return;
|
|
1959
|
+
const token = entry.token;
|
|
1960
|
+
cancelTemporaryThreadCleanup(token);
|
|
1961
|
+
const requested = deps.temporaryThreadCleanupDelayMs;
|
|
1962
|
+
const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
|
|
1963
|
+
let settle = () => undefined;
|
|
1964
|
+
const task = new Promise(resolve => { settle = resolve; });
|
|
1965
|
+
const timer = setTimeout(() => {
|
|
1966
|
+
if (temporaryCleanupTimers.get(token)?.timer !== timer)
|
|
1967
|
+
return;
|
|
1968
|
+
temporaryCleanupTimers.delete(token);
|
|
1969
|
+
runTemporaryThreadCleanup(token, cap)
|
|
1970
|
+
.catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" }); })
|
|
1971
|
+
.finally(settle);
|
|
1972
|
+
}, delay);
|
|
1973
|
+
timer.unref?.();
|
|
1974
|
+
temporaryCleanupTimers.set(token, { timer, settle });
|
|
1975
|
+
restoreSettlementTasks.add(task);
|
|
1976
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
1977
|
+
};
|
|
1978
|
+
const recordCancelledTemporaryThreadInput = (pending, ctx, isCurrent) => {
|
|
1979
|
+
const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
|
|
1980
|
+
if (!store || target.threadId === undefined)
|
|
1981
|
+
return !pending.temporaryThread;
|
|
1982
|
+
let entry = store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
|
|
1983
|
+
if (!entry)
|
|
1984
|
+
return !pending.temporaryThread;
|
|
1985
|
+
const cap = captureTemporaryThreadAuthority(ctx, isCurrent), inspect = deps.inspectRestoreSourceAbandonment;
|
|
1986
|
+
if (!cap || !inspect || cap.operatorUserId !== entry.operatorUserId || cap.journalBindingKey !== entry.source.journalBindingKey)
|
|
1987
|
+
return false;
|
|
1988
|
+
const { operatorUserId, journalBindingKey } = cap, current = cap.isCurrent;
|
|
1989
|
+
const input = { journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
|
|
1990
|
+
if (!current())
|
|
1991
|
+
return false;
|
|
1992
|
+
entry = cap.adopt(entry);
|
|
1993
|
+
if (!entry || !current())
|
|
1994
|
+
return false;
|
|
1995
|
+
const recorded = store.recordTemporaryThreadInputCancellation(entry, input, cap.authority, updateId => inspect(updateId, journalBindingKey));
|
|
1996
|
+
if (!recorded || !current() || !recorded.cancelledInputs?.some(value => isDeepStrictEqual(value, input)))
|
|
1997
|
+
return false;
|
|
1998
|
+
const observed = store.listTemporaryThreads().find(value => isDeepStrictEqual(value, recorded));
|
|
1999
|
+
return !!observed && input.updateIds.every(updateId => {
|
|
2000
|
+
const evidence = inspect(updateId, journalBindingKey);
|
|
2001
|
+
return evidence?.journalBindingKey === journalBindingKey && evidence.updateId === updateId &&
|
|
2002
|
+
evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
|
|
2003
|
+
}) && current();
|
|
2004
|
+
};
|
|
2005
|
+
const retireCancellationChooser = async (id, pending, isCurrent) => {
|
|
2006
|
+
if (!pending.abandonment?.result || !isCurrent() || !deps.editInteractiveMessage || pending.chooserMessageId === undefined)
|
|
2007
|
+
return false;
|
|
2008
|
+
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>", "html", { inline_keyboard: [] });
|
|
2009
|
+
if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
|
|
2010
|
+
return false;
|
|
2011
|
+
removePendingReroute(id);
|
|
2012
|
+
return true;
|
|
2013
|
+
};
|
|
2014
|
+
const isReviewText = (message) => {
|
|
2015
|
+
if (!message || message.chat?.type !== "private" || !Number.isSafeInteger(message.chat.id) ||
|
|
2016
|
+
!Number.isSafeInteger(message.from?.id) || message.from?.is_bot !== false ||
|
|
2017
|
+
!Number.isSafeInteger(message.message_id) || message.message_id <= 0 ||
|
|
2018
|
+
!Number.isSafeInteger(message.message_thread_id) || message.message_thread_id <= 0 ||
|
|
2019
|
+
typeof message.text !== "string" || !message.text.trim() || message.text.trim().startsWith("/"))
|
|
2020
|
+
return false;
|
|
2021
|
+
const fields = message;
|
|
2022
|
+
return ["media_group_id", "business_connection_id", "photo", "video", "audio", "voice", "document", "animation", "sticker", "contact", "location", "venue", "poll", "dice", "story"].every(key => fields[key] === undefined);
|
|
2023
|
+
};
|
|
2024
|
+
const reviewMessage = (entry) => {
|
|
2025
|
+
const message = entry.update.message;
|
|
2026
|
+
if (entry.state !== "pending" || entry.preApprovalExcluded || entry.inputClaim || entry.inputProvenance ||
|
|
2027
|
+
entry.queueOwner || entry.queueReceiptId || entry.queueHandoff || entry.failure ||
|
|
2028
|
+
Object.keys(entry.update).some(key => key !== "update_id" && key !== "message") ||
|
|
2029
|
+
!isReviewText(message) || Reflect.has(message, "pi_telegram_source_update_id"))
|
|
2030
|
+
return undefined;
|
|
2031
|
+
return message;
|
|
2032
|
+
};
|
|
2033
|
+
const needsHistoricalReview = (message) => {
|
|
2034
|
+
if (!deps.threadStore)
|
|
2035
|
+
throw new Error("Historical routing requires a current Thread snapshot.");
|
|
2036
|
+
if (deps.threadStore.getBotState().threadMode === "disabled")
|
|
2037
|
+
return true;
|
|
2038
|
+
return !getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).some(record => record.target.chatId === message.chat.id && record.target.threadId === message.message_thread_id);
|
|
2039
|
+
};
|
|
2040
|
+
/** New-world restart: forget previous-instance Restore/temporary state, then one silent delete per positively disposable tab. */
|
|
2041
|
+
const forgetPreviousWorld = async (input, captureTransport) => {
|
|
2042
|
+
const result = { forgotten: 0, deleted: 0 };
|
|
2043
|
+
const { ctx, signal, journalBindingKey, isCurrent: preparedCurrent } = input;
|
|
2044
|
+
const transport = captureTransport ? captureTransport(ctx) : () => true;
|
|
2045
|
+
const cap = transport && captureTemporaryThreadAuthority(ctx, () => !signal.aborted && preparedCurrent() && transport() &&
|
|
2046
|
+
deps.hasWorkspaceRestoreAuthority?.() === true);
|
|
2047
|
+
const threads = deps.threadStore;
|
|
2048
|
+
if (!cap?.isCurrent() || cap.journalBindingKey !== journalBindingKey || !threads || !deps.runWorkspaceOperation)
|
|
2049
|
+
return result;
|
|
2050
|
+
try {
|
|
2051
|
+
await deps.runWorkspaceOperation({ operationId: `new-world-${randomBytes(16).toString("hex")}`,
|
|
2052
|
+
operationKind: "workspace.forget-previous-world", scopes: [{ kind: "profile" }] }, async () => {
|
|
2053
|
+
if (!cap.isCurrent())
|
|
2054
|
+
return;
|
|
2055
|
+
await threads.load();
|
|
2056
|
+
if (!cap.isCurrent())
|
|
2057
|
+
return;
|
|
2058
|
+
const self = cap.authority.executor.instanceId;
|
|
2059
|
+
const disposable = cap.store.listTemporaryThreads().flatMap(entry => entry.operatorUserId === cap.operatorUserId &&
|
|
2060
|
+
entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
|
|
2061
|
+
cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
|
|
2062
|
+
const forgotten = cap.store.forgetPreviousWorld(cap.authority);
|
|
2063
|
+
if (!forgotten)
|
|
2064
|
+
return;
|
|
2065
|
+
result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
|
|
2066
|
+
for (const entry of disposable) {
|
|
2067
|
+
const target = entry.target;
|
|
2068
|
+
if (!cap.isCurrent() || !deps.callApi)
|
|
2069
|
+
break;
|
|
2070
|
+
await threads.load();
|
|
2071
|
+
const same = (value) => value?.chatId === target.chatId && value.threadId === target.threadId;
|
|
2072
|
+
if (!cap.isCurrent() || threads.listWorkspaceBindings().some(value => same(value.target)) ||
|
|
2073
|
+
threads.list().some(record => record.status === "active" && same(record.target)) ||
|
|
2074
|
+
cap.store.listTemporaryThreads().some(entry => same(entry.target)) ||
|
|
2075
|
+
cap.store.list().some(({ request }) => same(request.target) || same(request.binding.target)))
|
|
2076
|
+
continue;
|
|
2077
|
+
try {
|
|
2078
|
+
const required = [...new Set([cap.journalBindingKey, entry.source.journalBindingKey,
|
|
2079
|
+
...Threads.getTelegramTemporaryThreadInputs(entry).map(input => input.journalBindingKey)])];
|
|
2080
|
+
if (deps.inspectTemporaryThreadSources?.(target, required)?.length !== 0 || !cap.isCurrent())
|
|
2081
|
+
continue;
|
|
2082
|
+
// Exactly one attempt; the entry is already forgotten, so failure or silence leaves the tab without retry.
|
|
2083
|
+
if (await deps.callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, retrySafety: "non-idempotent" }) === true)
|
|
2084
|
+
result.deleted++;
|
|
2085
|
+
}
|
|
2086
|
+
catch (error) {
|
|
2087
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" });
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
catch (error) {
|
|
2093
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-forget" });
|
|
2094
|
+
}
|
|
2095
|
+
return result;
|
|
2096
|
+
};
|
|
2097
|
+
/** Historical temporary sources await the cold policy; warm bound/unknown sources never fall into ordinary dispatch. */
|
|
2098
|
+
const isRecordedTemporaryThreadInputHeld = (entry, ctx, signal, historical = false) => {
|
|
2099
|
+
const store = deps.getWorkspaceRestoreStore?.(), binding = deps.getAdmissionJournalBinding?.();
|
|
2100
|
+
if (!store || !binding)
|
|
2101
|
+
return false;
|
|
2102
|
+
const members = store.listTemporaryThreads().filter(temporary => Threads.getTelegramTemporaryThreadInputs(temporary).some(input => input.journalBindingKey === binding && input.updateIds.includes(entry.updateId)));
|
|
2103
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => !signal.aborted);
|
|
2104
|
+
return members.some(temporary => {
|
|
2105
|
+
if (!cap?.isCurrent())
|
|
2106
|
+
return true;
|
|
2107
|
+
const observation = store.inspectTemporaryThreadTarget(temporary, cap.authority);
|
|
2108
|
+
return historical || !observation || observation.kind !== "temporary";
|
|
2109
|
+
});
|
|
2110
|
+
};
|
|
2111
|
+
const shouldHoldPendingInput = async (entry, ctx, signal) => {
|
|
2112
|
+
const binding = deps.getAdmissionJournalBinding?.();
|
|
2113
|
+
const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true && deps.getAdmissionJournalBinding?.() === binding;
|
|
2114
|
+
if (!current())
|
|
2115
|
+
throw new Error("Temporary Thread source generation ended.");
|
|
2116
|
+
const held = isRecordedTemporaryThreadInputHeld(entry, ctx, signal);
|
|
2117
|
+
if (!current())
|
|
2118
|
+
throw new Error("Temporary Thread source authority changed.");
|
|
2119
|
+
return held;
|
|
2120
|
+
};
|
|
2121
|
+
const shouldReviewHistoricalInput = async (entry, ctx, signal) => {
|
|
2122
|
+
if (signal.aborted)
|
|
2123
|
+
throw new Error("Historical routing generation ended.");
|
|
2124
|
+
const message = entry.update.message;
|
|
2125
|
+
const unsupported = !reviewMessage(entry);
|
|
2126
|
+
const retainable = unsupported && entry.state === "pending" && !entry.preApprovalExcluded && !entry.inputClaim && !entry.inputProvenance &&
|
|
2127
|
+
!entry.queueOwner && !entry.queueReceiptId && !entry.queueHandoff && !entry.failure &&
|
|
2128
|
+
Object.keys(entry.update).every(key => key === "update_id" || key === "message") &&
|
|
2129
|
+
message?.chat?.type === "private" && Number.isSafeInteger(message.chat.id) &&
|
|
2130
|
+
Number.isSafeInteger(message.from?.id) && message.from?.is_bot === false &&
|
|
2131
|
+
Number.isSafeInteger(message.message_id) && message.message_id > 0 &&
|
|
2132
|
+
Number.isSafeInteger(message.message_thread_id) && message.message_thread_id > 0 &&
|
|
2133
|
+
!Reflect.has(message, "pi_telegram_source_update_id") && !Reflect.has(message, "business_connection_id");
|
|
2134
|
+
const temporary = isRecordedTemporaryThreadInputHeld(entry, ctx, signal, true);
|
|
2135
|
+
if (!retainable && temporary)
|
|
2136
|
+
return true;
|
|
2137
|
+
if (!retainable && unsupported)
|
|
2138
|
+
return false;
|
|
2139
|
+
if (signal.aborted)
|
|
2140
|
+
throw new Error("Historical routing generation ended.");
|
|
2141
|
+
const epoch = deps.getCurrentLeaderEpoch?.();
|
|
2142
|
+
const binding = deps.getAdmissionJournalBinding?.();
|
|
2143
|
+
const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true &&
|
|
2144
|
+
epoch !== undefined && deps.getCurrentLeaderEpoch?.() === epoch && !!binding && deps.getAdmissionJournalBinding?.() === binding;
|
|
2145
|
+
if (!current() || !deps.threadStore)
|
|
2146
|
+
throw new Error("Historical routing authority is unavailable.");
|
|
2147
|
+
await deps.threadStore.load();
|
|
2148
|
+
if (!current())
|
|
2149
|
+
throw new Error("Historical routing authority changed.");
|
|
2150
|
+
// Raw unsupported startup originals stay protected even when a bound target outlives forgotten Restore membership.
|
|
2151
|
+
// Holding protects evidence, not sender authorization; owner-bearing sources keep their existing paths.
|
|
2152
|
+
return retainable ? "retain" : needsHistoricalReview(message);
|
|
2153
|
+
};
|
|
2154
|
+
const recoveryPreview = (source, owner, chatId, binding) => {
|
|
2155
|
+
const entry = source.original;
|
|
2156
|
+
const message = reviewMessage(entry);
|
|
2157
|
+
if (!message || message.chat.id !== chatId || message.from.id !== owner)
|
|
2158
|
+
return undefined;
|
|
2159
|
+
for (const pending of pendingUnboundReroutes.values()) {
|
|
2160
|
+
if (pending.abandonment?.journalBindingKey === binding &&
|
|
2161
|
+
Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(entry.updateId) &&
|
|
2162
|
+
// Revoked carriers describe a past attempt, not a live selection veto.
|
|
2163
|
+
// Current claim/entry authority still comes from the worker and journal CAS.
|
|
2164
|
+
pending.messages.some(value => Updates.getTelegramUpdateExecutionFence(value)?.signal.aborted !== true) &&
|
|
2165
|
+
(pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.foreignRetry || pending.cleanup ||
|
|
2166
|
+
pending.abandonment.running))
|
|
2167
|
+
return undefined;
|
|
2168
|
+
}
|
|
2169
|
+
const text = Array.from(message.text.replace(/\s+/gu, " ").trim());
|
|
2170
|
+
return escapeHtml(text.slice(0, 80).join("")) + (text.length > 80 ? "…" : "");
|
|
2171
|
+
};
|
|
2172
|
+
const expireRoutingInput = async (source, ctx, signal) => {
|
|
2173
|
+
const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
2174
|
+
const message = source.original.update.message;
|
|
2175
|
+
const current = () => !signal.aborted && source.isCurrent() && operator !== undefined && epoch !== undefined &&
|
|
2176
|
+
deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
|
|
2177
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
|
|
2178
|
+
source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
|
|
2179
|
+
if (!current() || !deps.runWorkspaceOperation)
|
|
2180
|
+
return;
|
|
2181
|
+
await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
|
|
2182
|
+
operationKind: "workspace.expire-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
|
|
2183
|
+
if (!current())
|
|
2184
|
+
return;
|
|
2185
|
+
const restores = deps.getWorkspaceRestoreStore?.();
|
|
2186
|
+
if (deps.getWorkspaceRestoreStore && !restores)
|
|
2187
|
+
return;
|
|
2188
|
+
if (restores?.list().some(intent => intent.request.source.journalBindingKey === source.journalBindingKey &&
|
|
2189
|
+
intent.request.source.updateIds.includes(source.original.updateId)))
|
|
2190
|
+
return;
|
|
2191
|
+
for (const pending of pendingUnboundReroutes.values()) {
|
|
2192
|
+
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
|
|
2193
|
+
continue;
|
|
2194
|
+
if (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.workspaceRestore || pending.foreignRetry || pending.cleanup || pending.abandonment?.running)
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
const result = source.expire();
|
|
2198
|
+
if (!result)
|
|
2199
|
+
return;
|
|
2200
|
+
// Retire local controls after the source ACK. Telegram UI failure cannot undo retention or trigger deletion.
|
|
2201
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2202
|
+
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
|
|
2203
|
+
continue;
|
|
2204
|
+
removePendingReroute(id);
|
|
2205
|
+
if (signal.aborted || deps.isContextActive?.(ctx) !== true || deps.configStore.getAllowedUserId() !== operator ||
|
|
2206
|
+
deps.getCurrentLeaderEpoch?.() !== epoch || deps.getAdmissionJournalBinding?.() !== source.journalBindingKey)
|
|
2207
|
+
continue;
|
|
2208
|
+
if (deps.editInteractiveMessage && pending.chooserMessageId !== undefined) {
|
|
2209
|
+
try {
|
|
2210
|
+
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, `<b>${TELEGRAM_ROUTING_CHOICE_EXPIRED}</b>`, "html", { inline_keyboard: [] });
|
|
2211
|
+
}
|
|
2212
|
+
catch (error) {
|
|
2213
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
717
2216
|
}
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
await promptEnqueue(messages, ctx);
|
|
2217
|
+
});
|
|
721
2218
|
};
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
|
|
725
|
-
assertExecutionCurrent();
|
|
726
|
-
if (dismissed) {
|
|
727
|
-
removePendingReroute(rerouteId);
|
|
728
|
-
await deps.answerCallbackQuery(query.id, successMessage);
|
|
2219
|
+
const renderCancellationReview = async (view, isCurrent) => {
|
|
2220
|
+
if (!isCurrent())
|
|
729
2221
|
return;
|
|
2222
|
+
const data = (action) => `${TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX}${view.id}:${action}`;
|
|
2223
|
+
const rows = [[{ text: "⬆️ Main menu", callback_data: "menu:back" }]];
|
|
2224
|
+
const lines = ["<b>☑️ Review pending cancellations:</b>", "",
|
|
2225
|
+
"Finish stopping routing retries to retain the original privately. No new delivery is started; previously accepted work may continue. No Telegram message or Thread is deleted."];
|
|
2226
|
+
if (!view.sources.length)
|
|
2227
|
+
lines.push("", "No supported pending cancellations on this page.");
|
|
2228
|
+
for (const [index, source] of view.sources.entries()) {
|
|
2229
|
+
lines.push("", `<b>${index + 1}. ${source.result ? "⛔️ Routing cancelled." : "Protected input"}</b>`, source.preview);
|
|
2230
|
+
if (!source.result)
|
|
2231
|
+
rows.push([{ text: `❌ Finish cancellation ${index + 1}`, callback_data: data(`retry:${index}`) }]);
|
|
730
2232
|
}
|
|
731
|
-
|
|
732
|
-
|
|
2233
|
+
if (view.unsupported)
|
|
2234
|
+
lines.push("", "Other protected inputs are not available in this view and remain untouched.");
|
|
2235
|
+
if (view.nextAfterUpdateId !== undefined)
|
|
2236
|
+
rows.push([{ text: "🟣 More", callback_data: data("more") }]);
|
|
2237
|
+
rows.push([{ text: "🔄 Refresh", callback_data: data("refresh") }]);
|
|
2238
|
+
await deps.editInteractiveMessage(view.target.chatId, view.messageId, lines.join("\n"), "html", { inline_keyboard: rows });
|
|
733
2239
|
};
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
2240
|
+
const handleCancellationReview = async (query, ctx) => {
|
|
2241
|
+
const matches = (view) => query.message?.message_id === view.messageId &&
|
|
2242
|
+
query.message.chat.id === view.target.chatId &&
|
|
2243
|
+
(query.message.message_thread_id === undefined || query.message.message_thread_id === view.target.threadId);
|
|
2244
|
+
if (query.data?.startsWith(TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX)) {
|
|
2245
|
+
await deps.answerCallbackQuery(query.id, "🚫 This control is no longer available.");
|
|
2246
|
+
return true;
|
|
2247
|
+
}
|
|
2248
|
+
const ownsReview = query.data?.startsWith(TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX) === true;
|
|
2249
|
+
const prefix = TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX;
|
|
2250
|
+
if (!ownsReview && cancellationReview && matches(cancellationReview)) {
|
|
2251
|
+
if (cancellationReview.running && cancellationReview.isCurrent()) {
|
|
2252
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
|
|
2253
|
+
return true;
|
|
2254
|
+
}
|
|
2255
|
+
cancellationReview = undefined;
|
|
738
2256
|
return false;
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
2257
|
+
}
|
|
2258
|
+
if (!ownsReview)
|
|
2259
|
+
return false;
|
|
2260
|
+
const unavailable = async () => { await deps.answerCallbackQuery(query.id, "🚫 Cancellation review expired or unavailable. Open Status again."); return true; };
|
|
2261
|
+
const execution = Updates.getTelegramUpdateExecutionFence(query);
|
|
2262
|
+
const chatId = query.message?.chat.id;
|
|
2263
|
+
const messageId = query.message?.message_id;
|
|
2264
|
+
if (execution?.isCurrent() !== true || deps.isContextActive?.(ctx) !== true ||
|
|
2265
|
+
!deps.editInteractiveMessage || !deps.runWorkspaceOperation || query.message?.chat.type !== "private" ||
|
|
2266
|
+
typeof chatId !== "number" || !Number.isSafeInteger(chatId) ||
|
|
2267
|
+
typeof messageId !== "number" || !Number.isSafeInteger(messageId) || messageId <= 0)
|
|
2268
|
+
return unavailable();
|
|
2269
|
+
const owner = deps.configStore.getAllowedUserId();
|
|
2270
|
+
const journalBindingKey = deps.getAdmissionJournalBinding?.();
|
|
2271
|
+
const epoch = deps.getCurrentLeaderEpoch?.();
|
|
2272
|
+
if (owner === undefined || query.from.id !== owner || query.from.is_bot || !journalBindingKey || epoch === undefined)
|
|
2273
|
+
return unavailable();
|
|
2274
|
+
const current = () => execution.isCurrent() && deps.isContextActive?.(ctx) === true &&
|
|
2275
|
+
deps.configStore.getAllowedUserId() === owner && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
|
|
2276
|
+
deps.getCurrentLeaderEpoch?.() === epoch;
|
|
2277
|
+
const parsed = query.data.slice(prefix.length).match(/^([a-z0-9]+):(retry:(\d+)|refresh|more)$/);
|
|
2278
|
+
const prior = cancellationReview;
|
|
2279
|
+
const open = query.data === `${prefix}open`;
|
|
2280
|
+
if (!open && (!parsed || !prior || parsed[1] !== prior.id || !matches(prior) || !prior.isCurrent()))
|
|
2281
|
+
return unavailable();
|
|
2282
|
+
if (prior?.running && prior.isCurrent()) {
|
|
2283
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
|
|
2284
|
+
return true;
|
|
2285
|
+
}
|
|
2286
|
+
let view = prior;
|
|
2287
|
+
let selected;
|
|
2288
|
+
try {
|
|
2289
|
+
if (open || parsed?.[2] === "refresh" || parsed?.[2] === "more") {
|
|
2290
|
+
if (parsed?.[2] === "more" && prior?.nextAfterUpdateId === undefined)
|
|
2291
|
+
return unavailable();
|
|
2292
|
+
const next = { id: randomBytes(16).toString("hex"),
|
|
2293
|
+
target: { chatId, ...(query.message.message_thread_id !== undefined ? { threadId: query.message.message_thread_id } : {}) },
|
|
2294
|
+
messageId, ownerUserId: owner, journalBindingKey, sources: [], unsupported: false,
|
|
2295
|
+
isCurrent: () => cancellationReview === next && current() };
|
|
2296
|
+
cancellationReview = view = next;
|
|
2297
|
+
const page = Updates.inspectTelegramAbandoningUpdates(query, { journalBindingKey, isCurrent: next.isCurrent,
|
|
2298
|
+
...(parsed?.[2] === "more" ? { afterUpdateId: prior.nextAfterUpdateId } : {}) });
|
|
2299
|
+
if (!page) {
|
|
2300
|
+
if (cancellationReview === next)
|
|
2301
|
+
cancellationReview = undefined;
|
|
2302
|
+
return unavailable();
|
|
752
2303
|
}
|
|
753
|
-
|
|
2304
|
+
for (const source of page.sources) {
|
|
2305
|
+
const preview = recoveryPreview(source, owner, next.target.chatId, journalBindingKey);
|
|
2306
|
+
if (preview === undefined) {
|
|
2307
|
+
next.unsupported = true;
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
if (next.sources.length === 5) {
|
|
2311
|
+
next.nextAfterUpdateId = next.sources.at(-1).original.updateId;
|
|
2312
|
+
break;
|
|
2313
|
+
}
|
|
2314
|
+
next.sources.push({ ...source, preview });
|
|
2315
|
+
}
|
|
2316
|
+
next.nextAfterUpdateId ??= page.nextAfterUpdateId;
|
|
2317
|
+
next.running = true;
|
|
2318
|
+
await renderCancellationReview(next, next.isCurrent);
|
|
2319
|
+
if (next.isCurrent())
|
|
2320
|
+
await deps.answerCallbackQuery(query.id);
|
|
754
2321
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
2322
|
+
else {
|
|
2323
|
+
if (!view || !parsed?.[3] || !parsed[2].startsWith("retry:"))
|
|
2324
|
+
return unavailable();
|
|
2325
|
+
selected = view.sources[Number(parsed[3])];
|
|
2326
|
+
if (!selected)
|
|
2327
|
+
return unavailable();
|
|
2328
|
+
const activeView = view;
|
|
2329
|
+
const source = selected;
|
|
2330
|
+
const isCurrent = () => current() && activeView.isCurrent() &&
|
|
2331
|
+
recoveryPreview(source, owner, activeView.target.chatId, journalBindingKey) !== undefined;
|
|
2332
|
+
view.running = true;
|
|
2333
|
+
await deps.runWorkspaceOperation({ operationId: `workspace-cancellation-recovery:${query.id}`,
|
|
2334
|
+
operationKind: "workspace.recover-unbound-cancellation", scopes: [{ kind: "profile" }] }, async () => {
|
|
2335
|
+
if (!isCurrent())
|
|
2336
|
+
return;
|
|
2337
|
+
source.result ??= source.retry({ operatorAuthorityId: `telegram-owner:${owner}`, isCurrent });
|
|
2338
|
+
if (!source.result) {
|
|
2339
|
+
await unavailable();
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2343
|
+
if (pending.abandonment?.ownerUserId !== owner || pending.abandonment.journalBindingKey !== journalBindingKey ||
|
|
2344
|
+
!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
|
|
2345
|
+
continue;
|
|
2346
|
+
pending.abandonment.result = source.result;
|
|
2347
|
+
pending.abandonment.attempted = true;
|
|
2348
|
+
try {
|
|
2349
|
+
await retireCancellationChooser(id, pending, isCurrent);
|
|
2350
|
+
}
|
|
2351
|
+
catch (error) {
|
|
2352
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "recovered-chooser-cleanup" });
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
await renderCancellationReview(activeView, isCurrent);
|
|
2356
|
+
if (isCurrent())
|
|
2357
|
+
await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
|
|
761
2358
|
});
|
|
762
2359
|
}
|
|
2360
|
+
}
|
|
2361
|
+
catch (error) {
|
|
2362
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation-recovery" });
|
|
2363
|
+
if (current())
|
|
2364
|
+
await deps.answerCallbackQuery(query.id, selected?.result
|
|
2365
|
+
? "⚠️ Routing cancelled; view update failed. Retry the same button."
|
|
2366
|
+
: "⚠️ Recovery incomplete; inputs stay protected. Check /telegram-status --debug.");
|
|
2367
|
+
}
|
|
2368
|
+
finally {
|
|
2369
|
+
if (view)
|
|
2370
|
+
view.running = false;
|
|
2371
|
+
}
|
|
2372
|
+
return true;
|
|
2373
|
+
};
|
|
2374
|
+
const handleUnboundRerouteCancelCallback = async (query, ctx) => {
|
|
2375
|
+
if (!query.data?.startsWith(TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX))
|
|
2376
|
+
return false;
|
|
2377
|
+
const rerouteId = query.data.match(/^reroutecancel:([a-z0-9]+)$/)?.[1];
|
|
2378
|
+
const pending = rerouteId ? pendingUnboundReroutes.get(rerouteId) : undefined;
|
|
2379
|
+
const cancellation = pending?.abandonment;
|
|
2380
|
+
const source = pending?.messages[0];
|
|
2381
|
+
const execution = Updates.getTelegramUpdateExecutionFence(query);
|
|
2382
|
+
const sourceExecution = Updates.getTelegramUpdateExecutionFence(source);
|
|
2383
|
+
const isAuthorityCurrent = () => !!cancellation && execution?.isCurrent() === true &&
|
|
2384
|
+
sourceExecution?.signal.aborted === false && deps.isContextActive?.(ctx) === true &&
|
|
2385
|
+
query.from.id === cancellation.ownerUserId && !query.from.is_bot &&
|
|
2386
|
+
deps.configStore.getAllowedUserId() === cancellation.ownerUserId &&
|
|
2387
|
+
deps.getAdmissionJournalBinding?.() === cancellation.journalBindingKey &&
|
|
2388
|
+
deps.getCurrentLeaderEpoch?.() === cancellation.leaderEpoch;
|
|
2389
|
+
const isCurrent = () => isAuthorityCurrent() && !!pending && !!cancellation && !!source &&
|
|
2390
|
+
pendingUnboundReroutes.get(rerouteId) === pending && pending.abandonment === cancellation &&
|
|
2391
|
+
matchesRerouteChooser(pending, query) && query.message?.chat.type === "private" &&
|
|
2392
|
+
source.from?.id === cancellation.ownerUserId &&
|
|
2393
|
+
pending.messages.length === 1 &&
|
|
2394
|
+
!pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching &&
|
|
2395
|
+
!pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage;
|
|
2396
|
+
if (!isCurrent() || !deps.runWorkspaceOperation || !deps.editInteractiveMessage) {
|
|
2397
|
+
// A chooser from a previous process (new world) or an expired one simply reads as expired.
|
|
2398
|
+
await deps.answerCallbackQuery(query.id, pending ? "🚫 Message route is unavailable for cancellation." : TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
763
2399
|
return true;
|
|
764
|
-
}
|
|
765
|
-
|
|
2400
|
+
}
|
|
2401
|
+
if (cancellation.running) {
|
|
2402
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is already in progress.");
|
|
2403
|
+
return true;
|
|
2404
|
+
}
|
|
2405
|
+
cancellation.running = true;
|
|
2406
|
+
try {
|
|
2407
|
+
await deps.runWorkspaceOperation({ operationId: `workspace-reroute-cancel:${query.id}`,
|
|
2408
|
+
operationKind: "workspace.cancel-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
|
|
2409
|
+
if (!isCurrent())
|
|
2410
|
+
return;
|
|
2411
|
+
if (!cancellation.result) {
|
|
2412
|
+
const wasAttempted = cancellation.attempted;
|
|
2413
|
+
cancellation.attempted = true;
|
|
2414
|
+
const result = Updates.abandonTelegramDeferredUpdate(source, {
|
|
2415
|
+
operatorAuthorityId: `telegram-owner:${cancellation.ownerUserId}`, isCurrent,
|
|
2416
|
+
});
|
|
2417
|
+
if (!result) {
|
|
2418
|
+
cancellation.attempted = wasAttempted;
|
|
2419
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message routing cannot be cancelled now.");
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
cancellation.result = result;
|
|
2423
|
+
}
|
|
2424
|
+
if (!isCurrent())
|
|
2425
|
+
return;
|
|
2426
|
+
if (!recordCancelledTemporaryThreadInput(pending, ctx, isCurrent)) {
|
|
2427
|
+
if (isAuthorityCurrent())
|
|
2428
|
+
await deps.answerCallbackQuery(query.id, "⚠️ Routing cancelled; Thread cleanup remains protected. Retry Cancel routing.");
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
if (await retireCancellationChooser(rerouteId, pending, isCurrent) && isAuthorityCurrent()) {
|
|
2432
|
+
scheduleTemporaryThreadCleanup(pending.sourceTarget, ctx);
|
|
2433
|
+
await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
|
|
2434
|
+
}
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
catch (error) {
|
|
2438
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation", rerouteId });
|
|
2439
|
+
if (isCurrent())
|
|
2440
|
+
await deps.answerCallbackQuery(query.id, cancellation.result ? "⚠️ Routing cancelled; chooser update failed. Retry Cancel routing." :
|
|
2441
|
+
"⚠️ Cancellation incomplete. Retry Cancel routing.");
|
|
2442
|
+
}
|
|
2443
|
+
finally {
|
|
2444
|
+
cancellation.running = false;
|
|
2445
|
+
}
|
|
2446
|
+
return true;
|
|
2447
|
+
};
|
|
2448
|
+
const withPendingRerouteSelection = async (rerouteId, query, operation) => {
|
|
2449
|
+
Updates.assertTelegramUpdateExecutionCurrent(query);
|
|
2450
|
+
const pending = pendingUnboundReroutes.get(rerouteId);
|
|
2451
|
+
if (pending?.foreignForwardIssued && matchesRerouteChooser(pending, query)) {
|
|
2452
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
2453
|
+
return true;
|
|
2454
|
+
}
|
|
2455
|
+
if (pending && matchesRerouteChooser(pending, query) &&
|
|
2456
|
+
(pending.abandonment?.attempted || pending.abandonment?.running || pending.messages.some(message => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === false))) {
|
|
2457
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
2458
|
+
return true;
|
|
2459
|
+
}
|
|
2460
|
+
const releases = [];
|
|
2461
|
+
try {
|
|
2462
|
+
if (pending && matchesRerouteChooser(pending, query)) {
|
|
2463
|
+
try {
|
|
2464
|
+
for (const message of pending.messages)
|
|
2465
|
+
releases.push(Updates.acquireTelegramUpdateRouting(message));
|
|
2466
|
+
}
|
|
2467
|
+
catch (error) {
|
|
2468
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
|
|
2469
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
|
|
2470
|
+
return true;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
return await operation();
|
|
2474
|
+
}
|
|
2475
|
+
finally {
|
|
2476
|
+
for (const release of releases)
|
|
2477
|
+
release();
|
|
2478
|
+
}
|
|
766
2479
|
};
|
|
767
|
-
const
|
|
2480
|
+
const executeUnboundRerouteRestoreMenuCallback = async (query, _ctx) => {
|
|
768
2481
|
const parsed = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
|
|
769
2482
|
if (!parsed)
|
|
770
2483
|
return false;
|
|
@@ -778,28 +2491,47 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
778
2491
|
!deps.threadStore ||
|
|
779
2492
|
!pending ||
|
|
780
2493
|
!matchesRerouteChooser(pending, query) ||
|
|
2494
|
+
(parsed.root && pending.rootChooserText === undefined) ||
|
|
781
2495
|
expirePendingCommand(parsed.rerouteId, pending)) {
|
|
782
|
-
await deps.answerCallbackQuery(query.id,
|
|
2496
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
783
2497
|
return true;
|
|
784
2498
|
}
|
|
785
|
-
if (pending.sourceTarget.threadId === undefined) {
|
|
2499
|
+
if (parsed.restore && pending.sourceTarget.threadId === undefined) {
|
|
786
2500
|
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
787
2501
|
return true;
|
|
788
2502
|
}
|
|
789
2503
|
await deps.threadStore.load();
|
|
790
2504
|
assertExecutionCurrent();
|
|
791
2505
|
const activeRecords = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.());
|
|
792
|
-
const replyMarkup =
|
|
2506
|
+
const replyMarkup = parsed.root
|
|
2507
|
+
? buildTelegramUnboundRerouteChooserMarkup(parsed.rerouteId, activeRecords, {
|
|
2508
|
+
canRestore: pending.sourceTarget.threadId !== undefined, canCancel: !!pending.abandonment,
|
|
2509
|
+
getDisplayTitle: deps.getDisplayTitle,
|
|
2510
|
+
})
|
|
2511
|
+
: parsed.restore
|
|
2512
|
+
? buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle)
|
|
2513
|
+
: { inline_keyboard: activeRecords.map(record => [{
|
|
2514
|
+
text: `↪️ ${getTelegramThreadRecordLabel(record, deps.getDisplayTitle)}`,
|
|
2515
|
+
callback_data: formatTelegramUnboundRerouteCallbackData(parsed.rerouteId, record.target.threadId),
|
|
2516
|
+
}]) };
|
|
2517
|
+
const chooserText = parsed.root
|
|
2518
|
+
? pending.rootChooserText
|
|
2519
|
+
: parsed.restore ? formatTelegramUnboundRerouteRestoreChooserText()
|
|
2520
|
+
: "<b>🧵 Reroute:</b>\n\nChoose the thread that should handle this input:";
|
|
2521
|
+
if (!parsed.root)
|
|
2522
|
+
replyMarkup.inline_keyboard.unshift([{
|
|
2523
|
+
text: "⬆️ Back", callback_data: `${TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX}${parsed.rerouteId}`,
|
|
2524
|
+
}]);
|
|
793
2525
|
if (deps.editInteractiveMessage) {
|
|
794
|
-
await deps.editInteractiveMessage(chatId, messageId,
|
|
2526
|
+
await deps.editInteractiveMessage(chatId, messageId, chooserText, "html", replyMarkup);
|
|
795
2527
|
}
|
|
796
2528
|
else if (deps.sendInteractiveMessage) {
|
|
797
|
-
const chooserId = await deps.sendInteractiveMessage(chatId,
|
|
2529
|
+
const chooserId = await deps.sendInteractiveMessage(chatId, chooserText, "html", replyMarkup, { target: pending.sourceTarget, replyToMessageId: messageId });
|
|
798
2530
|
assertExecutionCurrent();
|
|
799
2531
|
rememberRerouteChooser(parsed.rerouteId, chooserId);
|
|
800
2532
|
}
|
|
801
2533
|
assertExecutionCurrent();
|
|
802
|
-
await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
|
|
2534
|
+
await deps.answerCallbackQuery(query.id, parsed.root ? "Choose routing mode." : parsed.restore ? "Choose instance to restore." : "Choose target thread.");
|
|
803
2535
|
return true;
|
|
804
2536
|
};
|
|
805
2537
|
const executeUnboundRerouteCallbackOperation = async (query, ctx) => {
|
|
@@ -812,25 +2544,31 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
812
2544
|
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
813
2545
|
if (typeof chatId !== "number" || !deps.threadStore || !pending ||
|
|
814
2546
|
!matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
|
|
815
|
-
await deps.answerCallbackQuery(query.id,
|
|
2547
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
816
2548
|
return true;
|
|
817
2549
|
}
|
|
818
2550
|
await deps.threadStore.load();
|
|
819
2551
|
assertExecutionCurrent();
|
|
820
2552
|
if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
|
|
821
2553
|
expirePendingCommand(parsed.rerouteId, pending)) {
|
|
822
|
-
await deps.answerCallbackQuery(query.id,
|
|
2554
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
823
2555
|
return true;
|
|
824
2556
|
}
|
|
825
2557
|
if (pending.finalizeMessage) {
|
|
826
2558
|
await finalizePendingReroute(parsed.rerouteId, pending, query, pending.finalizeMessage, assertExecutionCurrent);
|
|
827
2559
|
return true;
|
|
828
2560
|
}
|
|
2561
|
+
if (pending.workspaceRestore && (!restoreWorkspace || !parsed.useNewSlot ||
|
|
2562
|
+
pending.workspaceRestore.record.target.threadId !== parsed.threadId)) {
|
|
2563
|
+
await deps.answerCallbackQuery(query.id, "🚫 Restore is already selected. Use the original Restore choice.");
|
|
2564
|
+
return true;
|
|
2565
|
+
}
|
|
829
2566
|
if (pending.foreignRetry) {
|
|
830
2567
|
const retry = pending.foreignRetry;
|
|
831
2568
|
const allForwarded = await forwardPendingRerouteMessages(pending, retry.instanceId, retry.threadId, ctx, assertExecutionCurrent);
|
|
832
2569
|
if (!allForwarded) {
|
|
833
|
-
await deps.answerCallbackQuery(query.id, "
|
|
2570
|
+
await deps.answerCallbackQuery(query.id, pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
|
|
2571
|
+
"Target thread is unavailable; retrying will send only remaining messages.");
|
|
834
2572
|
return true;
|
|
835
2573
|
}
|
|
836
2574
|
pending.foreignRetry = undefined;
|
|
@@ -847,7 +2585,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
847
2585
|
await finalizePendingReroute(parsed.rerouteId, pending, query, "Thread cleanup completed.", assertExecutionCurrent);
|
|
848
2586
|
return true;
|
|
849
2587
|
}
|
|
850
|
-
|
|
2588
|
+
if (!pending.workspaceRestore && isTemporaryReroute(pending) && isTemporaryThreadForwardIssued(pending)) {
|
|
2589
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
2590
|
+
return true;
|
|
2591
|
+
}
|
|
2592
|
+
const record = pending.workspaceRestore?.record ?? getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).find((candidate) => candidate.target.chatId === chatId &&
|
|
851
2593
|
candidate.target.threadId === parsed.threadId);
|
|
852
2594
|
if (!record) {
|
|
853
2595
|
await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
|
|
@@ -867,113 +2609,120 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
867
2609
|
await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
|
|
868
2610
|
return true;
|
|
869
2611
|
}
|
|
870
|
-
|
|
2612
|
+
// A source's own temporary tab is its Restore destination; only other protection refuses it.
|
|
2613
|
+
if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
|
|
2614
|
+
isRerouteTargetProtected(sourceTarget, undefined, pending.temporaryThread ?? findCreatedTemporaryThread(sourceTarget), pending)) {
|
|
871
2615
|
await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
|
|
872
2616
|
return true;
|
|
873
2617
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
pending.
|
|
2618
|
+
// Restore has one controller. Without it or its negotiated authority, refuse before selection so the chooser keeps
|
|
2619
|
+
// its other routes and Cancel.
|
|
2620
|
+
if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
|
|
2621
|
+
(!restoreWorkspace || deps.hasWorkspaceRestoreAuthority?.() !== true)) {
|
|
2622
|
+
await deps.answerCallbackQuery(query.id, "🚫 Thread restore is unavailable right now. The message stays pending; choose another route or cancel.");
|
|
2623
|
+
return true;
|
|
2624
|
+
}
|
|
877
2625
|
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
878
2626
|
const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
|
|
879
2627
|
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(record, leaderProfileKey, currentInstanceId);
|
|
880
|
-
if (parsed.useNewSlot && !isCurrentLeaderRecord
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
!deps.replaceFollowerThreadTarget ||
|
|
887
|
-
!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
888
|
-
await deps.answerCallbackQuery(query.id, "Follower thread restore is not available yet.");
|
|
889
|
-
return true;
|
|
890
|
-
}
|
|
891
|
-
assertExecutionCurrent();
|
|
892
|
-
const replaced = await deps.replaceFollowerThreadTarget({
|
|
893
|
-
record,
|
|
894
|
-
target: sourceTarget,
|
|
895
|
-
oldTarget: record.target,
|
|
896
|
-
});
|
|
897
|
-
assertExecutionCurrent();
|
|
898
|
-
if (!replaced) {
|
|
899
|
-
await deps.answerCallbackQuery(query.id, "Follower thread is unavailable.");
|
|
900
|
-
return true;
|
|
2628
|
+
if (!parsed.useNewSlot && record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord &&
|
|
2629
|
+
!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
2630
|
+
if (pending.routingOperatorUserId === undefined) {
|
|
2631
|
+
pending.destinationSelected = true;
|
|
2632
|
+
pending.selectionAttempted = true;
|
|
2633
|
+
pending.pauseExpiry?.();
|
|
901
2634
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
deps.
|
|
907
|
-
deps.
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
});
|
|
918
|
-
await deps.
|
|
919
|
-
|
|
920
|
-
|
|
2635
|
+
await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
|
|
2636
|
+
return true;
|
|
2637
|
+
}
|
|
2638
|
+
if (pending.routingOperatorUserId !== undefined && (pending.routingOperatorUserId !== query.from.id ||
|
|
2639
|
+
pending.routingOperatorUserId !== deps.configStore.getAllowedUserId())) {
|
|
2640
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route authority changed.");
|
|
2641
|
+
return true;
|
|
2642
|
+
}
|
|
2643
|
+
// Freeze only a validated actual destination, inside the same admission as its effect. Menu browsing,
|
|
2644
|
+
// unavailable recipients and refused Restore capability never turn a waiting source into a selected grant.
|
|
2645
|
+
try {
|
|
2646
|
+
const release = Updates.acquireTelegramUpdateRouting(pending.messages[0], true, Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages));
|
|
2647
|
+
release();
|
|
2648
|
+
}
|
|
2649
|
+
catch (error) {
|
|
2650
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
|
|
2651
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
|
|
2652
|
+
return true;
|
|
2653
|
+
}
|
|
2654
|
+
pending.destinationSelected = true;
|
|
2655
|
+
pending.selectionAttempted = true;
|
|
2656
|
+
pending.pauseExpiry?.();
|
|
2657
|
+
if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
|
|
2658
|
+
const selection = pending.workspaceRestore ??= { operationId: `restore-${randomBytes(16).toString("hex")}`,
|
|
2659
|
+
record: structuredClone(record), messages: [...pending.messages] };
|
|
2660
|
+
let active = true;
|
|
2661
|
+
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
2662
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
2663
|
+
const admissionScope = deps.getAdmissionScope?.();
|
|
2664
|
+
const journalBinding = deps.getAdmissionJournalBinding?.();
|
|
2665
|
+
const selectionCurrent = () => {
|
|
921
2666
|
try {
|
|
922
2667
|
assertExecutionCurrent();
|
|
923
|
-
await deps.callApi("editForumTopic", {
|
|
924
|
-
chat_id: sourceTarget.chatId,
|
|
925
|
-
message_thread_id: sourceTarget.threadId,
|
|
926
|
-
name: deps.getDisplayTitle?.(sourceTarget) ??
|
|
927
|
-
Threads.getTelegramTopicTitleForThreadName(threadName, slot),
|
|
928
|
-
});
|
|
929
|
-
assertExecutionCurrent();
|
|
930
2668
|
}
|
|
931
|
-
catch
|
|
932
|
-
|
|
933
|
-
phase: "follower-topic-reroute-restore-rename",
|
|
934
|
-
chatId: sourceTarget.chatId,
|
|
935
|
-
threadId: sourceTarget.threadId,
|
|
936
|
-
slot: record.slot,
|
|
937
|
-
});
|
|
2669
|
+
catch {
|
|
2670
|
+
return false;
|
|
938
2671
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
instanceId: record.instanceId,
|
|
2672
|
+
return ownerUserId === query.from.id && deps.configStore.getAllowedUserId() === ownerUserId &&
|
|
2673
|
+
deps.getCurrentLeaderEpoch?.() === leaderEpoch && deps.getAdmissionScope?.() === admissionScope &&
|
|
2674
|
+
deps.getAdmissionJournalBinding?.() === journalBinding && deps.isContextActive?.(ctx) !== false &&
|
|
2675
|
+
pendingUnboundReroutes.get(parsed.rerouteId) === pending && pending.workspaceRestore === selection;
|
|
944
2676
|
};
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
await
|
|
953
|
-
|
|
2677
|
+
const current = () => active && selectionCurrent();
|
|
2678
|
+
const assertRestoreCurrent = () => {
|
|
2679
|
+
assertExecutionCurrent();
|
|
2680
|
+
if (!current())
|
|
2681
|
+
throw new Error("Stale Workspace Restore callback.");
|
|
2682
|
+
};
|
|
2683
|
+
try {
|
|
2684
|
+
await restoreWorkspace({ operationId: selection.operationId, record: structuredClone(selection.record),
|
|
2685
|
+
target: { ...sourceTarget }, messages: [...selection.messages], ctx, isCurrent: current,
|
|
2686
|
+
async dispatch(recipient, isRecipientCurrent, recordForwardAcceptance, recordLocalAcceptance) {
|
|
2687
|
+
const assertRecipientCurrent = () => {
|
|
2688
|
+
assertRestoreCurrent();
|
|
2689
|
+
if (!isRecipientCurrent())
|
|
2690
|
+
throw new Error("Workspace Restore recipient authority changed.");
|
|
2691
|
+
};
|
|
2692
|
+
assertRecipientCurrent();
|
|
2693
|
+
if (recipient.kind === "leader") {
|
|
2694
|
+
if (recipient.instanceId !== currentInstanceId)
|
|
2695
|
+
return false;
|
|
2696
|
+
const routed = cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId);
|
|
2697
|
+
const messages = pending.dispatchKind === "command" ? routed.map((message, index) => Updates.bindTelegramUpdateCompletionAcceptance(message, () => recordLocalAcceptance(pending.messages[index]))) : routed;
|
|
2698
|
+
await dispatchPendingRerouteMessages(pending, messages, ctx);
|
|
2699
|
+
assertRecipientCurrent();
|
|
2700
|
+
return true;
|
|
2701
|
+
}
|
|
2702
|
+
return forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
|
|
2703
|
+
},
|
|
2704
|
+
});
|
|
954
2705
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
pending.cleanup = cleanup;
|
|
958
|
-
await deps.answerCallbackQuery(query.id, "Thread restored, but old-thread cleanup is still pending. Try again.");
|
|
959
|
-
return true;
|
|
2706
|
+
finally {
|
|
2707
|
+
active = false;
|
|
960
2708
|
}
|
|
961
|
-
|
|
2709
|
+
assertExecutionCurrent();
|
|
2710
|
+
if (!selectionCurrent())
|
|
2711
|
+
return true;
|
|
2712
|
+
await deps.answerCallbackQuery(query.id, "🚫 Restore is pending; this button will not resend uncertain input.");
|
|
962
2713
|
return true;
|
|
963
2714
|
}
|
|
964
2715
|
if (record.instanceId &&
|
|
965
2716
|
record.instanceId !== currentInstanceId &&
|
|
966
2717
|
!isCurrentLeaderRecord) {
|
|
967
|
-
if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
968
|
-
await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
|
|
969
|
-
return true;
|
|
970
|
-
}
|
|
971
2718
|
const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertExecutionCurrent);
|
|
972
2719
|
if (!allForwarded) {
|
|
973
|
-
await deps.answerCallbackQuery(query.id, "
|
|
2720
|
+
await deps.answerCallbackQuery(query.id, pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
|
|
2721
|
+
"Target thread is unavailable; retrying will send only remaining messages.");
|
|
974
2722
|
return true;
|
|
975
2723
|
}
|
|
976
|
-
|
|
2724
|
+
// A temporary tab is removed only after every input in it resolves, never by one Forward.
|
|
2725
|
+
const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent, pending.temporaryThread);
|
|
977
2726
|
if (!cleanupComplete && sourceTarget) {
|
|
978
2727
|
pending.cleanup = {
|
|
979
2728
|
kind: "unbound",
|
|
@@ -981,88 +2730,25 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
981
2730
|
...(typeof sourceMessageId === "number"
|
|
982
2731
|
? { messageId: sourceMessageId }
|
|
983
2732
|
: {}),
|
|
2733
|
+
...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
|
|
984
2734
|
};
|
|
985
2735
|
await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
|
|
986
2736
|
return true;
|
|
987
2737
|
}
|
|
988
2738
|
await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
|
|
2739
|
+
if (sourceTarget && isTemporaryTabTarget(sourceTarget))
|
|
2740
|
+
scheduleTemporaryThreadCleanup(sourceTarget, ctx);
|
|
989
2741
|
return true;
|
|
990
2742
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
(record.target.chatId !== sourceTarget.chatId ||
|
|
995
|
-
record.target.threadId !== sourceTarget.threadId)) {
|
|
996
|
-
const slot = deps.threadStore.allocateSlot(leaderProfileKey ?? record.profileKey, getNextTelegramSlotPreference(record.slot), undefined, { excludeCurrentRecord: true });
|
|
997
|
-
if (!slot) {
|
|
998
|
-
await deps.answerCallbackQuery(query.id, TELEGRAM_SLOT_CAPACITY_MESSAGE);
|
|
999
|
-
return true;
|
|
1000
|
-
}
|
|
1001
|
-
deps.threadStore.markStaleByTarget(record.target, "deleted", "Current leader thread was replaced by a new-slot reroute source.");
|
|
1002
|
-
const nowMs = Date.now();
|
|
1003
|
-
const threadName = getRestoredThreadName(record, slot);
|
|
1004
|
-
deps.threadStore.upsert({
|
|
1005
|
-
...record,
|
|
1006
|
-
target: sourceTarget,
|
|
1007
|
-
status: "active",
|
|
1008
|
-
updatedAtMs: nowMs,
|
|
1009
|
-
threadName,
|
|
1010
|
-
instanceId: currentInstanceId,
|
|
1011
|
-
slot,
|
|
1012
|
-
lastReconcileAction: "reroute-new-slot",
|
|
1013
|
-
rerouteConfirmedAtMs: nowMs,
|
|
1014
|
-
});
|
|
1015
|
-
await deps.threadStore.persist();
|
|
1016
|
-
assertExecutionCurrent();
|
|
1017
|
-
deps.setCurrentLeaderIdentity?.({
|
|
1018
|
-
target: sourceTarget,
|
|
1019
|
-
slot,
|
|
1020
|
-
threadName,
|
|
1021
|
-
});
|
|
1022
|
-
if (deps.callApi) {
|
|
1023
|
-
try {
|
|
1024
|
-
assertExecutionCurrent();
|
|
1025
|
-
await deps.callApi("editForumTopic", {
|
|
1026
|
-
chat_id: sourceTarget.chatId,
|
|
1027
|
-
message_thread_id: sourceTarget.threadId,
|
|
1028
|
-
name: deps.getDisplayTitle?.(sourceTarget) ??
|
|
1029
|
-
Threads.getTelegramTopicTitleForThreadName(threadName, slot),
|
|
1030
|
-
});
|
|
1031
|
-
assertExecutionCurrent();
|
|
1032
|
-
}
|
|
1033
|
-
catch (renameError) {
|
|
1034
|
-
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
1035
|
-
phase: "leader-topic-reroute-reclaim-rename",
|
|
1036
|
-
chatId: sourceTarget.chatId,
|
|
1037
|
-
threadId: sourceTarget.threadId,
|
|
1038
|
-
slot,
|
|
1039
|
-
});
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
deps.recordRuntimeEvent?.("bus", "Bus leader reclaimed reroute source thread", {
|
|
1043
|
-
phase: "leader-topic-reroute-reclaim",
|
|
1044
|
-
chatId: sourceTarget.chatId,
|
|
1045
|
-
threadId: sourceTarget.threadId,
|
|
1046
|
-
staleThreadId: record.target.threadId,
|
|
1047
|
-
slot,
|
|
1048
|
-
});
|
|
1049
|
-
await dispatchPendingRerouteMessages(pending, cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId), ctx);
|
|
1050
|
-
pending.messages = [];
|
|
1051
|
-
const cleanupComplete = await closePreviousLeaderThread(record.target, assertExecutionCurrent);
|
|
1052
|
-
if (!cleanupComplete) {
|
|
1053
|
-
pending.cleanup = {
|
|
1054
|
-
kind: "previous-leader",
|
|
1055
|
-
target: record.target,
|
|
1056
|
-
};
|
|
1057
|
-
await deps.answerCallbackQuery(query.id, "Thread restored, but old-thread cleanup is still pending. Try again.");
|
|
1058
|
-
return true;
|
|
1059
|
-
}
|
|
1060
|
-
await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
|
|
2743
|
+
// Local handlers and queue admission consume the same one-time group grant as follower forwarding.
|
|
2744
|
+
if (isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
|
|
2745
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
1061
2746
|
return true;
|
|
1062
2747
|
}
|
|
1063
2748
|
await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
|
|
1064
2749
|
pending.messages = [];
|
|
1065
|
-
|
|
2750
|
+
// A temporary tab is removed only after every input in it resolves, never by one Forward.
|
|
2751
|
+
const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent, pending.temporaryThread);
|
|
1066
2752
|
if (!cleanupComplete && sourceTarget) {
|
|
1067
2753
|
pending.cleanup = {
|
|
1068
2754
|
kind: "unbound",
|
|
@@ -1070,29 +2756,35 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1070
2756
|
...(typeof sourceMessageId === "number"
|
|
1071
2757
|
? { messageId: sourceMessageId }
|
|
1072
2758
|
: {}),
|
|
2759
|
+
...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
|
|
1073
2760
|
};
|
|
1074
2761
|
await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
|
|
1075
2762
|
return true;
|
|
1076
2763
|
}
|
|
1077
2764
|
await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
|
|
2765
|
+
if (sourceTarget && isTemporaryTabTarget(sourceTarget))
|
|
2766
|
+
scheduleTemporaryThreadCleanup(sourceTarget, ctx);
|
|
1078
2767
|
return true;
|
|
1079
2768
|
};
|
|
1080
|
-
const executeUnboundRerouteCallback = (query, ctx) => {
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
return
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
2769
|
+
const executeUnboundRerouteCallback = async (query, ctx) => {
|
|
2770
|
+
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
2771
|
+
if (!parsed)
|
|
2772
|
+
return false;
|
|
2773
|
+
return withPendingRerouteSelection(parsed.rerouteId, query, () => {
|
|
2774
|
+
if (!deps.runWorkspaceOperation)
|
|
2775
|
+
return executeUnboundRerouteCallbackOperation(query, ctx);
|
|
2776
|
+
return deps.runWorkspaceOperation({
|
|
2777
|
+
operationId: `workspace-reroute:${query.id}`,
|
|
2778
|
+
operationKind: "workspace.route-unbound-thread",
|
|
2779
|
+
scopes: [{ kind: "profile" }],
|
|
2780
|
+
}, () => executeUnboundRerouteCallbackOperation(query, ctx));
|
|
2781
|
+
});
|
|
1090
2782
|
};
|
|
1091
2783
|
const handleUnboundRerouteCallback = async (query, ctx) => {
|
|
1092
2784
|
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
1093
2785
|
const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1094
2786
|
if (!parsed || !pending || pending.dispatchKind !== "command" ||
|
|
1095
|
-
pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
|
|
2787
|
+
(pending.sourceTarget.threadId !== undefined && !pending.temporaryThread) || !matchesRerouteChooser(pending, query)) {
|
|
1096
2788
|
return executeUnboundRerouteCallback(query, ctx);
|
|
1097
2789
|
}
|
|
1098
2790
|
if (pending.dispatching) {
|
|
@@ -1116,7 +2808,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1116
2808
|
const callbackHandler = async (query, ctx) => {
|
|
1117
2809
|
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
|
|
1118
2810
|
assertExecutionCurrent();
|
|
1119
|
-
if (await
|
|
2811
|
+
if (await handleCancellationReview(query, ctx))
|
|
2812
|
+
return;
|
|
2813
|
+
if (await handleUnboundRerouteCancelCallback(query, ctx))
|
|
2814
|
+
return;
|
|
2815
|
+
const restore = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
|
|
2816
|
+
if (restore && await withPendingRerouteSelection(restore.rerouteId, query, () => executeUnboundRerouteRestoreMenuCallback(query, ctx)))
|
|
1120
2817
|
return;
|
|
1121
2818
|
if (await handleUnboundRerouteCallback(query, ctx))
|
|
1122
2819
|
return;
|
|
@@ -1514,7 +3211,59 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1514
3211
|
reportQueueAdmission(messages, turn.admissionReceipts ?? []);
|
|
1515
3212
|
});
|
|
1516
3213
|
};
|
|
1517
|
-
const
|
|
3214
|
+
const recordUnboundTemporaryThreadInput = async (messages, ctx) => {
|
|
3215
|
+
const message = messages[0], target = message && Updates.getTelegramMessageTarget(message);
|
|
3216
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
3217
|
+
if (!target?.threadId || !store)
|
|
3218
|
+
return false;
|
|
3219
|
+
let found = store.listTemporaryThreads().find(entry => entry.phase === "created" && entry.target &&
|
|
3220
|
+
entry.target.chatId === target.chatId && entry.target.threadId === target.threadId);
|
|
3221
|
+
const key = formatTelegramTargetKey(target), implicit = implicitThreadCreations.get(key);
|
|
3222
|
+
if (!found && !implicit)
|
|
3223
|
+
return false;
|
|
3224
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(messages);
|
|
3225
|
+
const operatorUserId = deps.configStore.getAllowedUserId();
|
|
3226
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => messages.every(source => Updates.getTelegramUpdateExecutionFence(source)?.isCurrent() === true &&
|
|
3227
|
+
source.chat.type === "private" && source.chat.id === target.chatId && source.message_thread_id === target.threadId &&
|
|
3228
|
+
source.from?.id === operatorUserId && source.from?.is_bot === false));
|
|
3229
|
+
if (!found && implicit) {
|
|
3230
|
+
if (updateIds.length !== 1) {
|
|
3231
|
+
implicitThreadCreations.delete(key);
|
|
3232
|
+
return false;
|
|
3233
|
+
}
|
|
3234
|
+
if (!deps.runWorkspaceOperation || !cap || implicit.ctx !== ctx || implicit.operatorUserId !== cap.operatorUserId ||
|
|
3235
|
+
implicit.journalBindingKey !== cap.journalBindingKey || !isDeepStrictEqual(implicit.executor, cap.authority.executor) ||
|
|
3236
|
+
implicit.generation !== deps.getSessionGeneration?.() || implicit.scope !== deps.getAdmissionScope?.() ||
|
|
3237
|
+
!updateIds.length || updateIds.some(id => id <= implicit.updateId) || !cap.isCurrent()) {
|
|
3238
|
+
implicitThreadCreations.delete(key);
|
|
3239
|
+
return false;
|
|
3240
|
+
}
|
|
3241
|
+
found = store.registerImplicitTemporaryThread({ journalBindingKey: cap.journalBindingKey, updateIds }, { chatId: target.chatId, threadId: target.threadId }, randomBytes(16).toString("hex"), {
|
|
3242
|
+
...cap.authority, isCurrent: () => cap.isCurrent() && implicitThreadCreations.get(key) === implicit,
|
|
3243
|
+
});
|
|
3244
|
+
if (!found || !cap.isCurrent())
|
|
3245
|
+
throw new Error("Implicit Telegram Thread registration was not acknowledged; source remains protected.");
|
|
3246
|
+
implicitThreadCreations.delete(key);
|
|
3247
|
+
}
|
|
3248
|
+
if (!found)
|
|
3249
|
+
return false;
|
|
3250
|
+
cancelTemporaryThreadCleanup(found.token);
|
|
3251
|
+
if (!deps.runWorkspaceOperation || !cap || cap.operatorUserId !== found.operatorUserId ||
|
|
3252
|
+
cap.journalBindingKey !== found.source.journalBindingKey || !updateIds.length || !cap.isCurrent()) {
|
|
3253
|
+
throw new Error("Temporary Thread input membership requires exact current source authority.");
|
|
3254
|
+
}
|
|
3255
|
+
// The caller already holds the profile Workspace admission (the non-reentrant gate): no nested operation here.
|
|
3256
|
+
let entry = store.listTemporaryThreads().find(value => value.token === found.token &&
|
|
3257
|
+
isDeepStrictEqual(value.source, found.source) && isDeepStrictEqual(value.target, target));
|
|
3258
|
+
if (!entry)
|
|
3259
|
+
throw new Error("Temporary Thread input target changed before membership publication.");
|
|
3260
|
+
entry = cap.adopt(entry);
|
|
3261
|
+
if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInput(entry, { journalBindingKey: cap.journalBindingKey, updateIds }, cap.authority) || !cap.isCurrent()) {
|
|
3262
|
+
throw new Error("Temporary Thread input membership was not acknowledged; the source remains protected.");
|
|
3263
|
+
}
|
|
3264
|
+
return true;
|
|
3265
|
+
};
|
|
3266
|
+
const sendUnboundRerouteChooserNow = async (messages, ctx, reportDeferred = true) => {
|
|
1518
3267
|
const message = messages[0];
|
|
1519
3268
|
if (!message || !deps.threadStore)
|
|
1520
3269
|
return;
|
|
@@ -1540,16 +3289,46 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1540
3289
|
.join("\n\n"), { parseMode: "HTML", target: sourceTarget });
|
|
1541
3290
|
return;
|
|
1542
3291
|
}
|
|
1543
|
-
const
|
|
3292
|
+
const command = messages.length === 1 ? getKnownTelegramAllTabCommand(Media.extractFirstTelegramMessageText(messages).trim()) : undefined;
|
|
3293
|
+
const rerouteId = storePendingUnboundReroute(messages, command ? "command" : "prompt");
|
|
1544
3294
|
if (reportDeferred) {
|
|
1545
3295
|
for (const source of messages) {
|
|
1546
3296
|
Updates.reportTelegramUpdateDeferred(source);
|
|
1547
3297
|
}
|
|
1548
3298
|
}
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
3299
|
+
const inTemporaryThread = await recordUnboundTemporaryThreadInput(messages, ctx);
|
|
3300
|
+
const pending = pendingUnboundReroutes.get(rerouteId);
|
|
3301
|
+
if (inTemporaryThread)
|
|
3302
|
+
pending.temporaryMembership = true;
|
|
3303
|
+
if (messages.length === 1 && sourceTarget &&
|
|
3304
|
+
message.chat.type === "private" && typeof message.text === "string" && message.text.trim() &&
|
|
3305
|
+
deps.runWorkspaceOperation && deps.editInteractiveMessage &&
|
|
3306
|
+
deps.getAdmissionJournalBinding && deps.getCurrentLeaderEpoch &&
|
|
3307
|
+
Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true && deps.isContextActive?.(ctx) === true) {
|
|
3308
|
+
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
3309
|
+
const journalBindingKey = deps.getAdmissionJournalBinding();
|
|
3310
|
+
if (ownerUserId !== undefined && message.from?.id === ownerUserId && journalBindingKey &&
|
|
3311
|
+
Updates.supportsTelegramDeferredAbandonment(message, journalBindingKey)) {
|
|
3312
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch();
|
|
3313
|
+
if (leaderEpoch !== undefined)
|
|
3314
|
+
pending.abandonment = { ownerUserId, journalBindingKey, leaderEpoch };
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
if (command && sourceTarget && !inTemporaryThread && includeGuidance && deps.callApi) {
|
|
3318
|
+
// Telegram can create a private tab before delivering an All command not listed in the bot menu.
|
|
3319
|
+
// Naming that unbound tab is not creation evidence or authority to delete it.
|
|
3320
|
+
Updates.createTelegramUpdateExecutionFenceGuard(message)();
|
|
3321
|
+
try {
|
|
3322
|
+
await deps.callApi("editForumTopic", { chat_id: sourceTarget.chatId, message_thread_id: sourceTarget.threadId, name: `/${command.name}` });
|
|
3323
|
+
}
|
|
3324
|
+
catch (error) {
|
|
3325
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-command-title" });
|
|
3326
|
+
}
|
|
3327
|
+
Updates.createTelegramUpdateExecutionFenceGuard(message)();
|
|
3328
|
+
}
|
|
3329
|
+
const text = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
3330
|
+
pending.rootChooserText = text;
|
|
3331
|
+
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: sourceTarget !== undefined, canCancel: !!pending.abandonment, getDisplayTitle: deps.getDisplayTitle });
|
|
1553
3332
|
if (deps.sendInteractiveMessage) {
|
|
1554
3333
|
const chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, sourceTarget
|
|
1555
3334
|
? { target: sourceTarget, replyToMessageId: message.message_id }
|
|
@@ -1575,7 +3354,13 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1575
3354
|
const messages = [...(existing?.messages ?? []), message];
|
|
1576
3355
|
const timer = setTimeout(() => {
|
|
1577
3356
|
pendingUnboundRerouteMediaGroups.delete(groupKey);
|
|
1578
|
-
|
|
3357
|
+
// A media group's timer runs outside any request, so it takes the same profile admission as a single message.
|
|
3358
|
+
const send = () => sendUnboundRerouteChooserNow(messages, ctx, false);
|
|
3359
|
+
const task = deps.runWorkspaceOperation
|
|
3360
|
+
? deps.runWorkspaceOperation({ operationId: `workspace-unbound-group:${groupKey}:${randomBytes(8).toString("hex")}`,
|
|
3361
|
+
operationKind: "workspace.route-unbound-thread", scopes: [{ kind: "profile" }] }, send)
|
|
3362
|
+
: send();
|
|
3363
|
+
void task.catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-media-group-chooser" }); });
|
|
1579
3364
|
}, 1200);
|
|
1580
3365
|
timer.unref?.();
|
|
1581
3366
|
pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
|
|
@@ -1594,6 +3379,84 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1594
3379
|
}
|
|
1595
3380
|
return undefined;
|
|
1596
3381
|
};
|
|
3382
|
+
/**
|
|
3383
|
+
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
3384
|
+
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
3385
|
+
*/
|
|
3386
|
+
const sendAllTabTemporaryInputChooser = async (command, commandText, message, ctx) => {
|
|
3387
|
+
const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
3388
|
+
const execution = Updates.getTelegramUpdateExecutionFence(message);
|
|
3389
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
|
|
3390
|
+
if (!cap || !deps.runWorkspaceOperation || !deps.callApi || updateId === undefined ||
|
|
3391
|
+
message.chat.type !== "private" || message.chat.id !== cap.operatorUserId || message.from?.id !== cap.operatorUserId ||
|
|
3392
|
+
!cap.isCurrent())
|
|
3393
|
+
return false;
|
|
3394
|
+
const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
|
|
3395
|
+
const source = { journalBindingKey, updateId };
|
|
3396
|
+
const find = () => store.listTemporaryThreads().find(entry => entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId);
|
|
3397
|
+
let entry;
|
|
3398
|
+
await deps.runWorkspaceOperation({ operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
|
|
3399
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
3400
|
+
if (!current())
|
|
3401
|
+
return;
|
|
3402
|
+
let found = find();
|
|
3403
|
+
found = found && cap.adopt(found);
|
|
3404
|
+
if (found) {
|
|
3405
|
+
entry = found;
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
|
|
3409
|
+
entry = reservation?.entry;
|
|
3410
|
+
if (!reservation?.reserved || !current())
|
|
3411
|
+
return;
|
|
3412
|
+
let created;
|
|
3413
|
+
try {
|
|
3414
|
+
// The single creation attempt; any failure leaves `creating` as an unknown outcome, never a retry.
|
|
3415
|
+
created = await deps.callApi("createForumTopic", { chat_id: operatorUserId, name: command ? `/${command.name}` : "New chat" });
|
|
3416
|
+
}
|
|
3417
|
+
catch (error) {
|
|
3418
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-create" });
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
const threadId = created?.message_thread_id;
|
|
3422
|
+
if (!current() || typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0)
|
|
3423
|
+
return;
|
|
3424
|
+
entry = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority) ?? entry;
|
|
3425
|
+
});
|
|
3426
|
+
if (!current() || !entry)
|
|
3427
|
+
throw new Error("Temporary Thread authority changed; the input remains retryable.");
|
|
3428
|
+
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
3429
|
+
...message, text: commandText, caption: undefined
|
|
3430
|
+
});
|
|
3431
|
+
if (entry.phase !== "created" || !entry.target) {
|
|
3432
|
+
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
3433
|
+
await deps.sendTextReply(message.chat.id, message.message_id, "<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.", { parseMode: "HTML" });
|
|
3434
|
+
return true;
|
|
3435
|
+
}
|
|
3436
|
+
const target = entry.target;
|
|
3437
|
+
const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
|
|
3438
|
+
const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
|
|
3439
|
+
const pending = pendingUnboundReroutes.get(rerouteId);
|
|
3440
|
+
pending.temporaryThread = entry;
|
|
3441
|
+
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
3442
|
+
// Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
|
|
3443
|
+
if (deps.editInteractiveMessage && Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
|
|
3444
|
+
pending.abandonment = { ownerUserId: operatorUserId, journalBindingKey, leaderEpoch: epoch };
|
|
3445
|
+
}
|
|
3446
|
+
pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
3447
|
+
try {
|
|
3448
|
+
if (!deps.sendInteractiveMessage)
|
|
3449
|
+
throw new Error("Temporary Thread chooser publication is unavailable.");
|
|
3450
|
+
const chooserId = await deps.sendInteractiveMessage(target.chatId, pending.rootChooserText, "html", buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, { canRestore: true, canCancel: !!pending.abandonment,
|
|
3451
|
+
getDisplayTitle: deps.getDisplayTitle }), { target });
|
|
3452
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
3453
|
+
}
|
|
3454
|
+
catch (error) {
|
|
3455
|
+
removePendingReroute(rerouteId);
|
|
3456
|
+
throw error;
|
|
3457
|
+
}
|
|
3458
|
+
return true;
|
|
3459
|
+
};
|
|
1597
3460
|
const sendAllTabCommandChooser = async (command, commandText, message, options = {}) => {
|
|
1598
3461
|
if (!deps.threadStore)
|
|
1599
3462
|
return false;
|
|
@@ -1609,6 +3472,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1609
3472
|
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
1610
3473
|
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
1611
3474
|
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
3475
|
+
pendingUnboundReroutes.get(rerouteId).rootChooserText = text;
|
|
1612
3476
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: typeof message.message_thread_id === "number", getDisplayTitle: deps.getDisplayTitle });
|
|
1613
3477
|
let chooserId;
|
|
1614
3478
|
try {
|
|
@@ -1802,11 +3666,26 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1802
3666
|
assertExecutionCurrent();
|
|
1803
3667
|
await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
|
|
1804
3668
|
assertExecutionCurrent();
|
|
3669
|
+
const key = formatTelegramTargetKey(lifecycle.target);
|
|
1805
3670
|
if (lifecycle.kind !== "created" || !deps.threadStore) {
|
|
3671
|
+
implicitThreadCreations.delete(key);
|
|
1806
3672
|
return;
|
|
1807
3673
|
}
|
|
1808
3674
|
await deps.threadStore.load();
|
|
1809
3675
|
assertExecutionCurrent();
|
|
3676
|
+
const message = lifecycle.message, created = message.forum_topic_created;
|
|
3677
|
+
const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
3678
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true);
|
|
3679
|
+
if (created && typeof created === "object" && "is_name_implicit" in created && created.is_name_implicit === true &&
|
|
3680
|
+
cap && deps.runWorkspaceOperation && updateId !== undefined && !Updates.isTelegramHistoricalInput(message) &&
|
|
3681
|
+
message.chat.type === "private" && message.chat.id === cap.operatorUserId && message.from?.id === cap.operatorUserId &&
|
|
3682
|
+
message.from.is_bot === false && cap.isCurrent() && implicitThreadCreations.size < 100) {
|
|
3683
|
+
implicitThreadCreations.set(key, { ctx, updateId, operatorUserId: cap.operatorUserId,
|
|
3684
|
+
journalBindingKey: cap.journalBindingKey, executor: structuredClone(cap.authority.executor),
|
|
3685
|
+
generation: deps.getSessionGeneration?.(), scope: deps.getAdmissionScope?.() });
|
|
3686
|
+
}
|
|
3687
|
+
else
|
|
3688
|
+
implicitThreadCreations.delete(key);
|
|
1810
3689
|
};
|
|
1811
3690
|
// Answer the guest query immediately so the agent-end edit can replace the
|
|
1812
3691
|
// early ACK once the turn settles. The ACK is the first placeholder frame and
|
|
@@ -1836,7 +3715,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1836
3715
|
}
|
|
1837
3716
|
assertExecutionCurrent();
|
|
1838
3717
|
}
|
|
1839
|
-
|
|
3718
|
+
// Media messages carry the user's text as a caption.
|
|
3719
|
+
const text = guestMessage.text ?? guestMessage.caption ?? "";
|
|
1840
3720
|
const gm = guestMessage;
|
|
1841
3721
|
// Build telegram prefix with guest context
|
|
1842
3722
|
const chatRaw = gm.chat;
|
|
@@ -1878,8 +3758,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1878
3758
|
: "";
|
|
1879
3759
|
// Download files, run inbound handlers
|
|
1880
3760
|
const guestMsg = guestMessage;
|
|
3761
|
+
// Guest message IDs belong to the peer's chat; scope files to that peer so they cannot collide with bot-chat files.
|
|
3762
|
+
const guestScope = resolveTelegramGuestFileScope({ chatType, chat: chatRaw, from: fromRaw, replyFrom: replyFromRaw,
|
|
3763
|
+
guestBotCallerUser, guestBotCallerChat, ownerUserId });
|
|
3764
|
+
const downloadGuestFile = (fileId, fileName, source) => deps.downloadFile(fileId, fileName, source ? { ...source, scope: guestScope } : source);
|
|
1881
3765
|
const replyFiles = guestMsg.reply_to_message
|
|
1882
|
-
? await Media.downloadTelegramMessageFiles([guestMsg.reply_to_message], { downloadFile:
|
|
3766
|
+
? await Media.downloadTelegramMessageFiles([guestMsg.reply_to_message], { downloadFile: downloadGuestFile })
|
|
1883
3767
|
: [];
|
|
1884
3768
|
assertExecutionCurrent();
|
|
1885
3769
|
const processedReply = replyFiles.length > 0
|
|
@@ -1887,7 +3771,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1887
3771
|
: undefined;
|
|
1888
3772
|
assertExecutionCurrent();
|
|
1889
3773
|
const files = await Media.downloadTelegramMessageFiles([guestMsg], {
|
|
1890
|
-
downloadFile:
|
|
3774
|
+
downloadFile: downloadGuestFile,
|
|
1891
3775
|
});
|
|
1892
3776
|
assertExecutionCurrent();
|
|
1893
3777
|
const processed = await deps.inboundHandlerRuntime.process(files, text, ctx);
|
|
@@ -1957,7 +3841,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1957
3841
|
deps.updateStatus(ctx);
|
|
1958
3842
|
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
1959
3843
|
};
|
|
1960
|
-
|
|
3844
|
+
const runtime = Updates.createTelegramPairedUpdateRuntime({
|
|
1961
3845
|
getAllowedUserId: deps.configStore.getAllowedUserId,
|
|
1962
3846
|
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
1963
3847
|
getMessageOwnership: deps.getMessageOwnership,
|
|
@@ -1999,8 +3883,14 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1999
3883
|
const records = deps.threadStore.list();
|
|
2000
3884
|
const bindings = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
|
|
2001
3885
|
const command = getKnownTelegramAllTabCommand(text);
|
|
2002
|
-
|
|
2003
|
-
|
|
3886
|
+
const [sourceUpdateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
3887
|
+
const presented = sourceUpdateId !== undefined && deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().some(entry => entry.source.updateId === sourceUpdateId && entry.source.journalBindingKey === deps.getAdmissionJournalBinding?.()) === true;
|
|
3888
|
+
// Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
|
|
3889
|
+
if (command && command.name !== "thread" && !presented && isTelegramAllTabCommandExpired(message))
|
|
3890
|
+
return;
|
|
3891
|
+
if (command?.name !== "thread" && (bindings.length > 0 || presented) &&
|
|
3892
|
+
typeof message.text === "string" && message.text.trim() &&
|
|
3893
|
+
await sendAllTabTemporaryInputChooser(command, text, message, ctx))
|
|
2004
3894
|
return;
|
|
2005
3895
|
if (bindings.length > 0 && command && command.name !== "thread") {
|
|
2006
3896
|
if (await sendAllTabCommandChooser(command, text, message, {
|
|
@@ -2057,6 +3947,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2057
3947
|
}
|
|
2058
3948
|
await deps.threadStore.load();
|
|
2059
3949
|
assertExecutionCurrent();
|
|
3950
|
+
if (Updates.isTelegramHistoricalInput(message, entry => reviewMessage(entry) !== undefined) && isReviewText(message) &&
|
|
3951
|
+
deps.getCurrentLeaderEpoch?.() !== undefined && needsHistoricalReview(message)) {
|
|
3952
|
+
if (!Updates.reportTelegramHistoricalRoutingReview(message))
|
|
3953
|
+
throw new Error("Historical routing hold is unavailable.");
|
|
3954
|
+
return;
|
|
3955
|
+
}
|
|
2060
3956
|
if (deps.threadStore.getBotState().threadMode === "disabled") {
|
|
2061
3957
|
await textDispatch.handleMessage(message, ctx);
|
|
2062
3958
|
return;
|
|
@@ -2140,12 +4036,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2140
4036
|
}
|
|
2141
4037
|
const command = getKnownTelegramAllTabCommand(text);
|
|
2142
4038
|
if (command && hasAnyRoutableThread) {
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
replyToSource: true,
|
|
2146
|
-
})) {
|
|
2147
|
-
return;
|
|
2148
|
-
}
|
|
4039
|
+
await sendUnboundRerouteChooser(message, ctx);
|
|
4040
|
+
return;
|
|
2149
4041
|
}
|
|
2150
4042
|
if (leaderProfileKey && deps.callApi) {
|
|
2151
4043
|
const currentLeaderRecord = records.find((record) => {
|
|
@@ -2231,12 +4123,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2231
4123
|
return;
|
|
2232
4124
|
}
|
|
2233
4125
|
const identityThreadName = priorLeaderIdentity?.threadName &&
|
|
2234
|
-
|
|
4126
|
+
ThreadNaming.isTelegramTopicThreadNameValidForSlot(priorLeaderIdentity.threadName, slot)
|
|
2235
4127
|
? priorLeaderIdentity.threadName
|
|
2236
4128
|
: undefined;
|
|
2237
4129
|
const threadName = priorLeaderRecord?.threadName ??
|
|
2238
4130
|
identityThreadName ??
|
|
2239
|
-
|
|
4131
|
+
ThreadNaming.chooseTelegramThreadName({ slot }) ??
|
|
2240
4132
|
"Pi";
|
|
2241
4133
|
deps.threadStore.upsert({
|
|
2242
4134
|
profileKey: leaderProfileKey,
|
|
@@ -2284,6 +4176,13 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2284
4176
|
}, operation);
|
|
2285
4177
|
},
|
|
2286
4178
|
});
|
|
4179
|
+
return { ...runtime, expireRoutingInput, shouldReviewHistoricalInput, shouldHoldPendingInput, forgetPreviousWorld, beforeQueueReceiptPublished, onQueueReceiptCommitted, onQueueReceiptCompleted, onUpdateCompleted,
|
|
4180
|
+
onWorkspaceRestoreRecipientObserved(follower, isCurrent, ctx) {
|
|
4181
|
+
if (ctx !== undefined)
|
|
4182
|
+
return observeRestoreSettlement({ kind: "recipient", follower, isCurrent }, ctx);
|
|
4183
|
+
return undefined;
|
|
4184
|
+
},
|
|
4185
|
+
async waitForRestoreSettlement() { await Promise.all([...restoreSettlementTasks]); } };
|
|
2287
4186
|
}
|
|
2288
4187
|
export function createTelegramAssistantOutputAuthorityRuntime(deps) {
|
|
2289
4188
|
const getCurrentTarget = () => {
|