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