@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/lib/routing.ts
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
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
6
|
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
7
8
|
import { readFile } from "node:fs/promises";
|
|
8
9
|
import { basename, dirname } from "node:path";
|
|
10
|
+
import { isDeepStrictEqual } from "node:util";
|
|
9
11
|
import * as Bus from "./bus.ts";
|
|
10
12
|
import * as Commands from "./commands.ts";
|
|
11
13
|
import type { TelegramConfigStore } from "./config.ts";
|
|
12
14
|
import type { TelegramInboundHandlerRuntime } from "./inbound.ts";
|
|
15
|
+
import type { TelegramUpdateJournalQueuedCompletion, TelegramUpdateJournalQueuedReceiptEvidence } from "./journal.ts";
|
|
13
16
|
import * as Media from "./media.ts";
|
|
14
17
|
import * as Menu from "./menu.ts";
|
|
15
18
|
import * as Model from "./model.ts";
|
|
@@ -19,7 +22,7 @@ import * as Queue from "./queue.ts";
|
|
|
19
22
|
import * as Replies from "./replies.ts";
|
|
20
23
|
import type { TelegramBridgeRuntime } from "./runtime.ts";
|
|
21
24
|
import type { TelegramSectionRegistry } from "./sections.ts";
|
|
22
|
-
import type { TelegramInputRichMessage } from "./telegram-api.ts";
|
|
25
|
+
import type { TelegramApiCallOptions, TelegramInputRichMessage } from "./telegram-api.ts";
|
|
23
26
|
import * as TextGroups from "./text-groups.ts";
|
|
24
27
|
import * as ThreadNaming from "./thread-naming.ts";
|
|
25
28
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
@@ -28,6 +31,8 @@ import type {
|
|
|
28
31
|
TelegramTopicTargetRecord,
|
|
29
32
|
} from "./threads.ts";
|
|
30
33
|
import * as Turns from "./turns.ts";
|
|
34
|
+
import * as WorkspaceIdentity from "./workspace-identity.ts";
|
|
35
|
+
import type { createTelegramWorkspaceExternalProtectionCapture } from "./workspace-retirement.ts";
|
|
31
36
|
|
|
32
37
|
interface TelegramPromptPeerView {
|
|
33
38
|
id?: unknown;
|
|
@@ -106,6 +111,19 @@ export function resolveTelegramGuestPromptPeer(input: {
|
|
|
106
111
|
return undefined;
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
/** Stable file scope of the remote Guest Mode peer: username, else numeric id; never the bot's own scope. */
|
|
115
|
+
export function resolveTelegramGuestFileScope(input: Parameters<typeof resolveTelegramGuestPromptPeer>[0]): string {
|
|
116
|
+
const candidates = input.chatType !== "private" ? [input.chat]
|
|
117
|
+
: [input.from, input.chat, input.guestBotCallerUser, input.guestBotCallerChat, input.replyFrom];
|
|
118
|
+
for (const candidate of candidates) {
|
|
119
|
+
if (!candidate || (input.chatType === "private" &&
|
|
120
|
+
(isTelegramPromptOwnerPeer(candidate, input.ownerUserId) || isTelegramPromptBotPeer(candidate)))) continue;
|
|
121
|
+
if (typeof candidate.username === "string" && candidate.username.length > 0) return candidate.username;
|
|
122
|
+
if (typeof candidate.id === "number" && Number.isSafeInteger(candidate.id)) return String(Math.abs(candidate.id));
|
|
123
|
+
}
|
|
124
|
+
return "guest";
|
|
125
|
+
}
|
|
126
|
+
|
|
109
127
|
function appendTelegramSourceAttachmentSection(
|
|
110
128
|
text: string,
|
|
111
129
|
from: string | undefined,
|
|
@@ -177,7 +195,12 @@ function escapeHtml(text: string): string {
|
|
|
177
195
|
|
|
178
196
|
const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
|
|
179
197
|
const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
|
|
198
|
+
const TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX = "reroutemenu:";
|
|
199
|
+
const TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX = "rerouteroot:";
|
|
180
200
|
const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
|
|
201
|
+
const TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX = "reroutecancel:";
|
|
202
|
+
const TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX = "reroutecancel:review:";
|
|
203
|
+
const TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX = "reroutecancel:history:";
|
|
181
204
|
const TELEGRAM_SLOT_CAPACITY_MESSAGE =
|
|
182
205
|
"No Telegram instance slot is available. Automatic reclamation is disabled for safety.";
|
|
183
206
|
|
|
@@ -203,10 +226,10 @@ function formatTelegramUnboundRerouteNewSlotCallbackData(
|
|
|
203
226
|
|
|
204
227
|
function parseTelegramUnboundRerouteRestoreMenuCallbackData(
|
|
205
228
|
data: string | undefined,
|
|
206
|
-
): { rerouteId: string } | undefined {
|
|
207
|
-
const match = data?.match(/^rerouterestore:([a-z0-9]+)$/);
|
|
208
|
-
const rerouteId = match?.[
|
|
209
|
-
return rerouteId ? { rerouteId } : undefined;
|
|
229
|
+
): { rerouteId: string; restore: boolean; root: boolean } | undefined {
|
|
230
|
+
const match = data?.match(/^(rerouterestore|reroutemenu|rerouteroot):([a-z0-9]+)$/);
|
|
231
|
+
const rerouteId = match?.[2];
|
|
232
|
+
return rerouteId ? { rerouteId, restore: match?.[1] === "rerouterestore", root: match?.[1] === "rerouteroot" } : undefined;
|
|
210
233
|
}
|
|
211
234
|
|
|
212
235
|
function parseTelegramUnboundRerouteCallbackData(
|
|
@@ -228,22 +251,14 @@ function getTelegramThreadRecordLabel(
|
|
|
228
251
|
return getDisplayTitle?.(record.target) ?? getRestoredThreadName(record, record.slot ?? "");
|
|
229
252
|
}
|
|
230
253
|
|
|
231
|
-
function getNextTelegramSlotPreference(
|
|
232
|
-
slot: string | undefined,
|
|
233
|
-
): string | undefined {
|
|
234
|
-
if (!slot || !/^[A-Z]$/.test(slot)) return undefined;
|
|
235
|
-
const index = slot.charCodeAt(0) - "A".charCodeAt(0);
|
|
236
|
-
return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26));
|
|
237
|
-
}
|
|
238
|
-
|
|
239
254
|
function getRestoredThreadName(
|
|
240
255
|
record: Threads.TelegramTopicTargetRecord,
|
|
241
256
|
slot: string,
|
|
242
257
|
): string {
|
|
243
258
|
return record.threadName &&
|
|
244
|
-
|
|
259
|
+
ThreadNaming.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
|
|
245
260
|
? record.threadName
|
|
246
|
-
: (
|
|
261
|
+
: (ThreadNaming.chooseTelegramThreadName({ slot }) ?? "Pi");
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
function isTelegramLiveThreadTarget(
|
|
@@ -269,6 +284,16 @@ function getTelegramRoutableThreadRecords(
|
|
|
269
284
|
);
|
|
270
285
|
}
|
|
271
286
|
|
|
287
|
+
function formatTelegramTemporaryThreadChooserText(command?: string): string {
|
|
288
|
+
return [
|
|
289
|
+
"<b>🧵 Choose target thread:</b>",
|
|
290
|
+
"",
|
|
291
|
+
command ? `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.` : "Choose where to send your message.",
|
|
292
|
+
"Unaccepted routing expires after 60 minutes; disposable tabs are removed when clear. Accepted work and restored Threads are kept.",
|
|
293
|
+
"Select the Pi thread that should handle it, or restore a Pi into this tab:",
|
|
294
|
+
].join("\n");
|
|
295
|
+
}
|
|
296
|
+
|
|
272
297
|
function formatTelegramAllTabMenuChooserText(command: string): string {
|
|
273
298
|
return [
|
|
274
299
|
"<b>🧵 Choose target thread:</b>",
|
|
@@ -284,34 +309,23 @@ function buildTelegramUnboundRerouteChooserMarkup(
|
|
|
284
309
|
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
285
310
|
options: {
|
|
286
311
|
canRestore: boolean;
|
|
312
|
+
canCancel?: boolean;
|
|
287
313
|
getDisplayTitle?: Threads.TelegramCurrentThreadAssembly["getDisplayTitle"];
|
|
288
314
|
},
|
|
289
315
|
): Menu.TelegramReplyMarkup {
|
|
290
316
|
const activeRecords = records.filter((record) => record.status === "active");
|
|
291
317
|
const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
|
|
292
|
-
const rows = activeRecords.
|
|
293
|
-
{
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
? [
|
|
304
|
-
...rows,
|
|
305
|
-
[
|
|
306
|
-
{
|
|
307
|
-
text: "🔁 Replace/restore thread…",
|
|
308
|
-
callback_data:
|
|
309
|
-
formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
|
|
310
|
-
},
|
|
311
|
-
],
|
|
312
|
-
]
|
|
313
|
-
: rows,
|
|
314
|
-
};
|
|
318
|
+
const rows = activeRecords.length
|
|
319
|
+
? [[{ text: "↪️ Reroute…", callback_data: `${TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX}${rerouteId}` }]] : [];
|
|
320
|
+
if (canRestoreAnyLiveThread) rows.push([{
|
|
321
|
+
text: "🔁 Restore…",
|
|
322
|
+
callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
|
|
323
|
+
}]);
|
|
324
|
+
if (options.canCancel) rows.push([{
|
|
325
|
+
text: "⛔️ Cancel routing",
|
|
326
|
+
callback_data: `${TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX}${rerouteId}`,
|
|
327
|
+
}]);
|
|
328
|
+
return { inline_keyboard: rows };
|
|
315
329
|
}
|
|
316
330
|
|
|
317
331
|
function buildTelegramUnboundRerouteRestoreChooserMarkup(
|
|
@@ -357,26 +371,102 @@ function formatTelegramTargetKey(target: Queue.TelegramQueueTarget): string {
|
|
|
357
371
|
return `${target.chatId}:${target.threadId ?? "all"}`;
|
|
358
372
|
}
|
|
359
373
|
|
|
360
|
-
function formatTelegramUnboundRerouteChooserText(
|
|
361
|
-
_records: readonly Threads.TelegramTopicTargetRecord[],
|
|
362
|
-
options: { includeGuidance?: boolean } = {},
|
|
363
|
-
): string {
|
|
364
|
-
const rerouteText = [
|
|
365
|
-
"<b>🧵 Choose target thread:</b>",
|
|
366
|
-
"",
|
|
367
|
-
"Your message is still in this Telegram thread.",
|
|
368
|
-
"Select the Pi thread that should handle it:",
|
|
369
|
-
].join("\n");
|
|
370
|
-
return options.includeGuidance === false
|
|
371
|
-
? rerouteText
|
|
372
|
-
: [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
|
|
373
|
-
}
|
|
374
|
-
|
|
375
374
|
import * as Threads from "./threads.ts";
|
|
376
375
|
import type { TelegramUser } from "./updates.ts";
|
|
377
376
|
import * as Updates from "./updates.ts";
|
|
378
377
|
import { getTelegramVoiceReplyMode } from "./voice.ts";
|
|
379
378
|
|
|
379
|
+
/** One admitted Restore attempt through recipient readiness, never source dispatch or cleanup. */
|
|
380
|
+
export async function advanceTelegramWorkspaceRestore(input: {
|
|
381
|
+
request: Threads.TelegramWorkspaceRestoreRequest;
|
|
382
|
+
authority: Threads.TelegramWorkspaceRestoreAuthority;
|
|
383
|
+
restoreStore: Threads.TelegramWorkspaceRestore;
|
|
384
|
+
getRecipient: () => Threads.TelegramWorkspaceRestoreRecipient | undefined;
|
|
385
|
+
/** Adapter proves current canonical ownership and authenticates observations; apply needs a fresh issuance. */
|
|
386
|
+
runRecipient: (input: { intent: Threads.TelegramWorkspaceRestoreIntent; mode: "apply" | "inspect"; isCurrent: () => boolean }) =>
|
|
387
|
+
Promise<{ operationId: string; recipient: Threads.TelegramWorkspaceRestoreRecipient;
|
|
388
|
+
target: Threads.TelegramWorkspaceRestoreRequest["target"]; slot: string; ready: boolean } | undefined>;
|
|
389
|
+
/**
|
|
390
|
+
* Recovery for a same-session successor already on the relocated target: it may receive the first grant, but only
|
|
391
|
+
* read-only inspection runs. It never commits a new relocation or applies a target.
|
|
392
|
+
*/
|
|
393
|
+
inspectOnly?: true;
|
|
394
|
+
}): Promise<Threads.TelegramWorkspaceRestoreIntent | undefined> {
|
|
395
|
+
const isCurrent = input.authority.isCurrent.bind(input.authority);
|
|
396
|
+
if (!isCurrent()) return undefined;
|
|
397
|
+
const request = structuredClone(input.request);
|
|
398
|
+
if (!Threads.isTelegramWorkspaceRestoreRequest(request)) return undefined;
|
|
399
|
+
const getRecipient = input.getRecipient;
|
|
400
|
+
const runRecipient = input.runRecipient;
|
|
401
|
+
const observedRecipient = getRecipient();
|
|
402
|
+
if (!Threads.isTelegramWorkspaceRestoreRecipient(observedRecipient) || observedRecipient.sessionId !== request.binding.sessionId) return undefined;
|
|
403
|
+
const recipient = structuredClone(observedRecipient);
|
|
404
|
+
const executor = structuredClone(input.authority.executor);
|
|
405
|
+
const operatorUserId = input.authority.operatorUserId;
|
|
406
|
+
const current = (): boolean => isCurrent() && input.authority.operatorUserId === operatorUserId &&
|
|
407
|
+
isDeepStrictEqual(input.authority.executor, executor) && isDeepStrictEqual(getRecipient(), recipient);
|
|
408
|
+
const authority = { executor, operatorUserId, isCurrent: current };
|
|
409
|
+
const { restoreStore } = input;
|
|
410
|
+
const retained = (): Threads.TelegramWorkspaceRestoreIntent | undefined => {
|
|
411
|
+
if (!current()) return undefined;
|
|
412
|
+
const found = restoreStore.list().find(value => value.request.operationId === request.operationId);
|
|
413
|
+
return current() && found && isDeepStrictEqual(found.request, request) && found.operatorUserId === operatorUserId &&
|
|
414
|
+
isDeepStrictEqual(found.executor, executor) ? found : undefined;
|
|
415
|
+
};
|
|
416
|
+
let intent = retained();
|
|
417
|
+
const predecessor = intent ? undefined : restoreStore.list().find(value => value.request.operationId === request.operationId);
|
|
418
|
+
if (predecessor && current() && isDeepStrictEqual(predecessor.request, request) &&
|
|
419
|
+
predecessor.operatorUserId === operatorUserId && !isDeepStrictEqual(predecessor.executor, executor)) {
|
|
420
|
+
// Adoption transfers executor authority only. Issued grants stay issued, so a successor can merely inspect them.
|
|
421
|
+
try { intent = restoreStore.adopt(predecessor, authority); }
|
|
422
|
+
catch (error) {
|
|
423
|
+
intent = retained();
|
|
424
|
+
if (!intent) throw error;
|
|
425
|
+
}
|
|
426
|
+
if (!current() || !intent) return undefined;
|
|
427
|
+
intent = retained();
|
|
428
|
+
if (!intent) return undefined;
|
|
429
|
+
}
|
|
430
|
+
if (input.inspectOnly && !intent) return undefined;
|
|
431
|
+
if (!intent || intent.phase === "relocated") {
|
|
432
|
+
if ((!input.inspectOnly && recipient.instanceId !== request.owner.instanceId) ||
|
|
433
|
+
recipient.kind !== (request.owner.owner?.kind === "leader" ? "leader" : "follower")) return undefined;
|
|
434
|
+
intent = await restoreStore.commit(request, authority);
|
|
435
|
+
if (!current() || !intent) return undefined;
|
|
436
|
+
}
|
|
437
|
+
let mode: "apply" | "inspect" = "inspect";
|
|
438
|
+
if (intent.phase === "relocated") {
|
|
439
|
+
try {
|
|
440
|
+
const issuance = restoreStore.issueRecipient(intent, recipient, authority);
|
|
441
|
+
if (issuance) { intent = issuance.intent; mode = input.inspectOnly ? "inspect" : "apply"; }
|
|
442
|
+
else intent = retained();
|
|
443
|
+
} catch (error) {
|
|
444
|
+
intent = retained();
|
|
445
|
+
if (intent?.phase !== "recipient-issued") throw error;
|
|
446
|
+
// A retained issued phase is not a fresh grant, even when publication's reply was lost.
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!current() || !intent || !["recipient-issued", "ready"].includes(intent.phase) ||
|
|
450
|
+
(mode === "apply" && !isDeepStrictEqual(intent.recipient, recipient))) return undefined;
|
|
451
|
+
const expected = structuredClone(intent);
|
|
452
|
+
if (!isDeepStrictEqual(retained(), expected)) return undefined;
|
|
453
|
+
const observation = await runRecipient({ intent: structuredClone(expected), mode, isCurrent: current });
|
|
454
|
+
if (!current() || !isDeepStrictEqual(retained(), expected) || !observation || observation.ready !== true ||
|
|
455
|
+
observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
|
|
456
|
+
!isDeepStrictEqual(observation.target, request.target) || observation.slot !== request.binding.slot) return undefined;
|
|
457
|
+
if (expected.phase === "ready" && isDeepStrictEqual(expected.readyRecipient ?? expected.recipient, recipient)) return retained();
|
|
458
|
+
try {
|
|
459
|
+
intent = mode === "inspect" ? restoreStore.confirmInspectedReady(expected, recipient, authority) :
|
|
460
|
+
restoreStore.confirmReady(expected, recipient, authority);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
intent = retained();
|
|
463
|
+
if (intent?.phase !== "ready" || !isDeepStrictEqual(intent.readyRecipient ?? intent.recipient, recipient)) throw error;
|
|
464
|
+
}
|
|
465
|
+
const confirmed = retained();
|
|
466
|
+
return current() && confirmed?.phase === "ready" &&
|
|
467
|
+
isDeepStrictEqual(confirmed.readyRecipient ?? confirmed.recipient, recipient) ? confirmed : undefined;
|
|
468
|
+
}
|
|
469
|
+
|
|
380
470
|
async function deleteReservedTelegramTopicThroughReconciler(
|
|
381
471
|
deps: {
|
|
382
472
|
callApi?: <TResponse>(
|
|
@@ -447,6 +537,8 @@ async function deleteReservedTelegramTopicThroughReconciler(
|
|
|
447
537
|
}
|
|
448
538
|
|
|
449
539
|
export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
|
|
540
|
+
/** One user-facing answer for expired or previous-process routing controls. */
|
|
541
|
+
export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "⌛ Routing choice expired.";
|
|
450
542
|
|
|
451
543
|
export function isTelegramAllTabCommandExpired(
|
|
452
544
|
message: { date?: number; message_thread_id?: number },
|
|
@@ -517,6 +609,17 @@ export function createTelegramInboundBusProjectionRuntime(deps: {
|
|
|
517
609
|
};
|
|
518
610
|
}
|
|
519
611
|
|
|
612
|
+
/** Fenced temporary-Thread authority; see `captureTemporaryThreadAuthority`. */
|
|
613
|
+
interface TemporaryThreadAuthority {
|
|
614
|
+
store: Threads.TelegramWorkspaceRestore;
|
|
615
|
+
operatorUserId: number;
|
|
616
|
+
journalBindingKey: string;
|
|
617
|
+
epoch: number | string;
|
|
618
|
+
authority: Threads.TelegramWorkspaceRestoreAuthority;
|
|
619
|
+
isCurrent: () => boolean;
|
|
620
|
+
adopt: (entry: Threads.TelegramTemporaryThreadEntry) => Threads.TelegramTemporaryThreadEntry | undefined;
|
|
621
|
+
}
|
|
622
|
+
|
|
520
623
|
export interface TelegramInboundRouteRuntimeDeps<
|
|
521
624
|
TMessage extends TelegramRoutedMessage,
|
|
522
625
|
TCallbackQuery extends TelegramRoutedCallbackQuery,
|
|
@@ -530,6 +633,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
530
633
|
callApi?: <TResponse>(
|
|
531
634
|
method: string,
|
|
532
635
|
body: Record<string, unknown>,
|
|
636
|
+
options?: TelegramApiCallOptions,
|
|
533
637
|
) => Promise<TResponse>;
|
|
534
638
|
getCurrentInstanceId?: () => string | undefined;
|
|
535
639
|
getAdmissionScope?: () => string | undefined;
|
|
@@ -567,11 +671,29 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
567
671
|
TCallbackQuery,
|
|
568
672
|
TMessage
|
|
569
673
|
>;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
674
|
+
getWorkspaceRestoreStore?: () => Threads.TelegramWorkspaceRestore | undefined;
|
|
675
|
+
captureWorkspaceExternalProtection?: ReturnType<typeof createTelegramWorkspaceExternalProtectionCapture>;
|
|
676
|
+
/** Strict committed abandonment plus retained original; shared by Restore and temporary-input cancellation. */
|
|
677
|
+
inspectRestoreSourceAbandonment?: (updateId: number, journalBindingKey: string) => Threads.TelegramTemporaryThreadCancellationEvidence | undefined;
|
|
678
|
+
inspectRoutingInputGroupExpiry?: (input: Threads.TelegramTemporaryThreadInput) => readonly Threads.TelegramTemporaryThreadCancellationEvidence[] | undefined;
|
|
679
|
+
/** Strict active-journal observation only; a hint or missing source never substitutes for this ACK. */
|
|
680
|
+
inspectRestoreSourceCompletion?: (expected: Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) =>
|
|
681
|
+
(Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) | undefined;
|
|
682
|
+
inspectRestoreQueuedReceipt?: (expected: TelegramUpdateJournalQueuedCompletion & { journalBindingKey: string }) =>
|
|
683
|
+
TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
684
|
+
hasWorkspaceRestoreAuthority?: () => boolean;
|
|
685
|
+
/** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
|
|
686
|
+
inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[]) => readonly number[] | undefined;
|
|
687
|
+
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to 1000 ms. */
|
|
688
|
+
temporaryThreadCleanupDelayMs?: number;
|
|
689
|
+
getSessionGeneration?: () => number;
|
|
690
|
+
workspaceRestoreRecipient?: {
|
|
691
|
+
getSessionId: (ctx: TContext) => string | undefined;
|
|
692
|
+
getCwd: (ctx: TContext) => string | undefined;
|
|
693
|
+
getLeaderIdentity: Threads.TelegramLeaderThreadStateRuntime["getIdentity"];
|
|
694
|
+
followerRegistry: Pick<Bus.TelegramBusFollowerRegistry, "get" | "register">;
|
|
695
|
+
runFollower: ReturnType<typeof Bus.createTelegramBusWorkspaceRestoreController>;
|
|
696
|
+
};
|
|
575
697
|
bridgeRuntime: TelegramBridgeRuntime;
|
|
576
698
|
activeTurnRuntime: Queue.TelegramActiveTurnStore;
|
|
577
699
|
mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage, TContext>;
|
|
@@ -720,6 +842,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
720
842
|
const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
|
|
721
843
|
"allmenu:",
|
|
722
844
|
TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
|
|
845
|
+
TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX,
|
|
723
846
|
"compact:",
|
|
724
847
|
"menu:",
|
|
725
848
|
"model:",
|
|
@@ -755,12 +878,26 @@ export function createTelegramInboundRouteRuntime<
|
|
|
755
878
|
TContext,
|
|
756
879
|
TModel
|
|
757
880
|
>,
|
|
758
|
-
): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> {
|
|
881
|
+
): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> & {
|
|
882
|
+
expireRoutingInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["expireRoutingInput"]>;
|
|
883
|
+
shouldReviewHistoricalInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["shouldReviewHistoricalInput"]>;
|
|
884
|
+
shouldHoldPendingInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["shouldHoldPendingInput"]>;
|
|
885
|
+
forgetPreviousWorld(input: Updates.TelegramHeldSourcePreparation<TContext>,
|
|
886
|
+
captureTransport?: (ctx: TContext) => (() => boolean) | undefined): Promise<{ forgotten: number; deleted: number }>;
|
|
887
|
+
beforeQueueReceiptPublished: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["beforeQueueReceiptPublished"]>;
|
|
888
|
+
onQueueReceiptCommitted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onQueueReceiptCommitted"]>;
|
|
889
|
+
onQueueReceiptCompleted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onQueueReceiptCompleted"]>;
|
|
890
|
+
onUpdateCompleted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onUpdateCompleted"]>;
|
|
891
|
+
onWorkspaceRestoreRecipientObserved(follower: Bus.TelegramBusFollowerView, isCurrent: () => boolean,
|
|
892
|
+
ctx: TContext | undefined): Promise<void> | undefined;
|
|
893
|
+
waitForRestoreSettlement(): Promise<void>;
|
|
894
|
+
} {
|
|
759
895
|
type PendingRerouteCleanup =
|
|
760
896
|
| {
|
|
761
897
|
kind: "unbound";
|
|
762
898
|
target: { chatId: number; threadId: number };
|
|
763
899
|
messageId?: number;
|
|
900
|
+
temporaryThread?: Threads.TelegramTemporaryThreadEntry;
|
|
764
901
|
}
|
|
765
902
|
| {
|
|
766
903
|
kind: "previous-leader";
|
|
@@ -774,16 +911,32 @@ export function createTelegramInboundRouteRuntime<
|
|
|
774
911
|
type PendingUnboundReroute = {
|
|
775
912
|
sourceTarget: Queue.TelegramQueueTarget;
|
|
776
913
|
chooserMessageId?: number;
|
|
914
|
+
rootChooserText?: string;
|
|
777
915
|
messages: TMessage[];
|
|
778
|
-
createdAtMs: number;
|
|
779
916
|
dispatchKind: "prompt" | "command";
|
|
917
|
+
routingOperatorUserId?: number;
|
|
780
918
|
expiresAtMs?: number;
|
|
781
919
|
destinationSelected?: boolean;
|
|
782
920
|
selectionAttempted?: boolean;
|
|
921
|
+
workspaceRestore?: { operationId: string; record: Threads.TelegramTopicTargetRecord; messages: TMessage[] };
|
|
922
|
+
/** The acknowledged tab presenting an All source; `sourceTarget` is this tab, not the original's location. */
|
|
923
|
+
temporaryThread?: Threads.TelegramTemporaryThreadEntry;
|
|
924
|
+
/** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
|
|
925
|
+
temporaryMembership?: true;
|
|
783
926
|
dispatching?: boolean;
|
|
927
|
+
abandonment?: {
|
|
928
|
+
ownerUserId: number;
|
|
929
|
+
journalBindingKey: string;
|
|
930
|
+
leaderEpoch: number | string;
|
|
931
|
+
running?: boolean;
|
|
932
|
+
attempted?: boolean;
|
|
933
|
+
result?: NonNullable<ReturnType<typeof Updates.abandonTelegramDeferredUpdate>>;
|
|
934
|
+
};
|
|
784
935
|
pauseExpiry?: () => void;
|
|
785
936
|
stopExpiry?: () => void;
|
|
786
937
|
cleanup?: PendingRerouteCleanup;
|
|
938
|
+
/** Process-local protection only; restart-safe Forward issuance remains a release gate. */
|
|
939
|
+
foreignForwardIssued?: boolean;
|
|
787
940
|
foreignRetry?: {
|
|
788
941
|
instanceId: string;
|
|
789
942
|
threadId: number;
|
|
@@ -792,8 +945,17 @@ export function createTelegramInboundRouteRuntime<
|
|
|
792
945
|
finalizeMessage?: string;
|
|
793
946
|
};
|
|
794
947
|
const pendingUnboundReroutes = new Map<string, PendingUnboundReroute>();
|
|
948
|
+
const implicitThreadCreations = new Map<string, { ctx: TContext; updateId: number; operatorUserId: number;
|
|
949
|
+
journalBindingKey: string; executor: Threads.TelegramWorkspaceRestoreExecutor; generation: number | undefined; scope: string | undefined }>();
|
|
795
950
|
const guidedUnboundTopicKeys = new Set<string>();
|
|
796
951
|
let nextUnboundRerouteId = 0;
|
|
952
|
+
type RecoverySource = Updates.TelegramDeferredAbandonmentRecoveryPage["sources"][number];
|
|
953
|
+
type CancellationReview = {
|
|
954
|
+
id: string; target: Queue.TelegramQueueTarget; messageId: number; ownerUserId: number; journalBindingKey: string;
|
|
955
|
+
isCurrent: () => boolean; running?: boolean; nextAfterUpdateId?: number; unsupported: boolean;
|
|
956
|
+
sources: Array<RecoverySource & { preview: string; result?: NonNullable<ReturnType<RecoverySource["retry"]>> }>;
|
|
957
|
+
};
|
|
958
|
+
let cancellationReview: CancellationReview | undefined;
|
|
797
959
|
const requestDispatchNextQueuedTelegramTurn = (ctx: TContext): void => {
|
|
798
960
|
deps.dispatchNextQueuedTelegramTurn(ctx);
|
|
799
961
|
if (
|
|
@@ -833,7 +995,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
833
995
|
continue;
|
|
834
996
|
}
|
|
835
997
|
return record.threadName &&
|
|
836
|
-
|
|
998
|
+
ThreadNaming.isTelegramTopicThreadNameValidForSlot(
|
|
837
999
|
record.threadName,
|
|
838
1000
|
record.slot,
|
|
839
1001
|
)
|
|
@@ -915,35 +1077,34 @@ export function createTelegramInboundRouteRuntime<
|
|
|
915
1077
|
};
|
|
916
1078
|
schedule();
|
|
917
1079
|
};
|
|
918
|
-
const
|
|
919
|
-
|
|
1080
|
+
const prunePendingCommandReroutes = () => {
|
|
1081
|
+
// Deferred prompts and selected routes with cleanup work must retain their control.
|
|
1082
|
+
// Age is not settlement; the chooser capacity limit still bounds retained entries.
|
|
920
1083
|
for (const [id, entry] of pendingUnboundReroutes) {
|
|
921
1084
|
if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
|
|
922
1085
|
expirePendingCommand(id, entry);
|
|
923
|
-
} else if (nowMs - entry.createdAtMs > 30 * 60_000) {
|
|
924
|
-
removePendingReroute(id);
|
|
925
1086
|
}
|
|
926
1087
|
}
|
|
927
1088
|
};
|
|
928
1089
|
const storePendingUnboundReroute = (
|
|
929
1090
|
messages: TMessage[],
|
|
930
1091
|
dispatchKind: "prompt" | "command" = "prompt",
|
|
1092
|
+
presentationTarget?: { chatId: number; threadId: number },
|
|
931
1093
|
): string => {
|
|
932
|
-
|
|
1094
|
+
prunePendingCommandReroutes();
|
|
933
1095
|
if (pendingUnboundReroutes.size >= 100) {
|
|
934
1096
|
throw new Error("Telegram route chooser capacity reached; source remains retryable.");
|
|
935
1097
|
}
|
|
936
1098
|
nextUnboundRerouteId += 1;
|
|
937
1099
|
const id = nextUnboundRerouteId.toString(36);
|
|
938
1100
|
pendingUnboundReroutes.set(id, {
|
|
939
|
-
sourceTarget: {
|
|
1101
|
+
sourceTarget: presentationTarget ? { ...presentationTarget } : {
|
|
940
1102
|
chatId: messages[0]!.chat.id,
|
|
941
1103
|
...(typeof messages[0]!.message_thread_id === "number"
|
|
942
1104
|
? { threadId: messages[0]!.message_thread_id }
|
|
943
1105
|
: {}),
|
|
944
1106
|
},
|
|
945
1107
|
messages,
|
|
946
|
-
createdAtMs: Date.now(),
|
|
947
1108
|
dispatchKind,
|
|
948
1109
|
});
|
|
949
1110
|
const pending = pendingUnboundReroutes.get(id)!;
|
|
@@ -954,6 +1115,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
954
1115
|
const pending = pendingUnboundReroutes.get(id);
|
|
955
1116
|
if (!pending) return;
|
|
956
1117
|
pending.chooserMessageId = messageId;
|
|
1118
|
+
if (Number.isSafeInteger(messageId) && messageId! > 0) {
|
|
1119
|
+
const owner = deps.configStore.getAllowedUserId();
|
|
1120
|
+
if (owner !== undefined && pending.messages.every(source => source.from?.id === owner && !source.from.is_bot && source.chat.type === "private")) {
|
|
1121
|
+
try {
|
|
1122
|
+
const lifetime = Updates.armTelegramRoutingInputs(pending.messages, owner);
|
|
1123
|
+
if (lifetime) pending.routingOperatorUserId = lifetime.operatorUserId;
|
|
1124
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-clock" }); }
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
957
1127
|
if (messageId === undefined || pending.dispatchKind !== "command" ||
|
|
958
1128
|
pending.sourceTarget.threadId !== undefined || pending.selectionAttempted) return;
|
|
959
1129
|
const source = pending.messages[0];
|
|
@@ -1083,18 +1253,49 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1083
1253
|
),
|
|
1084
1254
|
);
|
|
1085
1255
|
};
|
|
1256
|
+
/** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
|
|
1257
|
+
const hasPendingRerouteForTarget = (target: Queue.TelegramQueueTarget, own?: (pending: PendingUnboundReroute) => boolean): boolean =>
|
|
1258
|
+
[...pendingUnboundReroutes.values()].some(pending => pending.sourceTarget.chatId === target.chatId &&
|
|
1259
|
+
pending.sourceTarget.threadId === target.threadId && !own?.(pending));
|
|
1260
|
+
const isOwnTemporaryChooser = (entry: Threads.TelegramTemporaryThreadEntry) => (pending: PendingUnboundReroute): boolean =>
|
|
1261
|
+
pending.temporaryThread?.token === entry.token && isDeepStrictEqual(pending.temporaryThread.source, entry.source);
|
|
1262
|
+
const hasOtherTemporaryThreadReroute = (target: Queue.TelegramQueueTarget, own: Threads.TelegramTemporaryThreadEntry): boolean =>
|
|
1263
|
+
hasPendingRerouteForTarget(target, isOwnTemporaryChooser(own));
|
|
1086
1264
|
const applyThreadCleanupPlan = async (
|
|
1087
1265
|
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
1088
1266
|
assertExecutionCurrent?: () => void,
|
|
1267
|
+
restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
|
|
1268
|
+
temporaryCleanup?: Threads.TelegramTemporaryThreadEntry,
|
|
1089
1269
|
): Promise<boolean> => {
|
|
1090
1270
|
assertExecutionCurrent?.();
|
|
1271
|
+
if ((restoreCleanup && restoreCleanup.routing?.cleanup !== "issued") ||
|
|
1272
|
+
(temporaryCleanup && !temporaryCleanup.cleanupIssued)) return false;
|
|
1273
|
+
let protectedTarget = false, temporaryDeleteConfirmed = false, temporaryTransportUncertain = false;
|
|
1091
1274
|
deps.recordThreadReconciliationPlan?.(plan);
|
|
1092
1275
|
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
1276
|
+
skipCloseBeforeDelete: (temporaryCleanup?.target?.chatId ?? 0) > 0,
|
|
1093
1277
|
isCleanupTargetProtected(target) {
|
|
1094
1278
|
assertExecutionCurrent?.();
|
|
1095
|
-
|
|
1279
|
+
// A tab's first source is not authority over sibling choosers, including unsettled cleanup-only work.
|
|
1280
|
+
const siblingProtected = !!temporaryCleanup && hasOtherTemporaryThreadReroute(target, temporaryCleanup);
|
|
1281
|
+
const lostTemporaryGrant = !!temporaryCleanup && !deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads()
|
|
1282
|
+
.some(entry => isDeepStrictEqual(entry, temporaryCleanup));
|
|
1283
|
+
const protectedNow = temporaryTransportUncertain || lostTemporaryGrant || siblingProtected || isRerouteTargetProtected(target, restoreCleanup, temporaryCleanup);
|
|
1284
|
+
protectedTarget ||= protectedNow;
|
|
1285
|
+
return protectedNow;
|
|
1096
1286
|
},
|
|
1097
|
-
callApi: deps.callApi
|
|
1287
|
+
callApi: temporaryCleanup && deps.callApi
|
|
1288
|
+
? async <TResponse>(method: string, body: Record<string, unknown>): Promise<TResponse> => {
|
|
1289
|
+
try {
|
|
1290
|
+
assertExecutionCurrent?.();
|
|
1291
|
+
const result = await deps.callApi!<TResponse>(method, body, { maxAttempts: 1, retrySafety: "non-idempotent" });
|
|
1292
|
+
assertExecutionCurrent?.();
|
|
1293
|
+
if (result !== true) throw new Error("Temporary Thread cleanup lacks a positive API acknowledgement.");
|
|
1294
|
+
if (method === "deleteForumTopic") temporaryDeleteConfirmed = true;
|
|
1295
|
+
return result;
|
|
1296
|
+
} catch (error) { temporaryTransportUncertain = true; throw error; }
|
|
1297
|
+
}
|
|
1298
|
+
: deps.callApi,
|
|
1098
1299
|
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
|
|
1099
1300
|
deps.threadStore?.markStaleByTarget(
|
|
1100
1301
|
staleTarget,
|
|
@@ -1108,15 +1309,111 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1108
1309
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1109
1310
|
});
|
|
1110
1311
|
assertExecutionCurrent?.();
|
|
1111
|
-
return (result.incompleteActions?.length ?? 0) === 0;
|
|
1312
|
+
return !protectedTarget && (!temporaryCleanup || temporaryDeleteConfirmed) && (result.incompleteActions?.length ?? 0) === 0;
|
|
1313
|
+
};
|
|
1314
|
+
const findCreatedTemporaryThread = (target: Queue.TelegramQueueTarget): Threads.TelegramTemporaryThreadEntry | undefined => {
|
|
1315
|
+
try { return deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
|
|
1316
|
+
catch { return undefined; }
|
|
1317
|
+
};
|
|
1318
|
+
/**
|
|
1319
|
+
* One fenced capture of leader epoch, operator, journal binding and context for a temporary-Thread effect. `isCurrent`
|
|
1320
|
+
* re-reads every captured fact (plus an optional extra fence) at each boundary, so a delayed effect keeps the identity it
|
|
1321
|
+
* was scheduled under; `adopt` re-keys an entry to this executor before a write.
|
|
1322
|
+
*/
|
|
1323
|
+
const captureTemporaryThreadAuthority = (ctx: TContext, extra?: () => boolean): TemporaryThreadAuthority | undefined => {
|
|
1324
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
1325
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1326
|
+
const operatorUserId = deps.configStore.getAllowedUserId(), journalBindingKey = deps.getAdmissionJournalBinding?.();
|
|
1327
|
+
const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.();
|
|
1328
|
+
if (!store || !instanceId || epoch === undefined || operatorUserId === undefined || !journalBindingKey) return undefined;
|
|
1329
|
+
const isCurrent = (): boolean => deps.isContextActive?.(ctx) === true && deps.getCurrentInstanceId?.() === instanceId &&
|
|
1330
|
+
deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
1331
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
|
|
1332
|
+
deps.getAdmissionJournalBinding?.() === journalBindingKey && (extra?.() ?? true);
|
|
1333
|
+
const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent };
|
|
1334
|
+
return { store, operatorUserId, journalBindingKey, epoch, authority, isCurrent,
|
|
1335
|
+
adopt: entry => isDeepStrictEqual(entry.executor, authority.executor) ? entry : store.adoptTemporaryThread(entry, authority) };
|
|
1112
1336
|
};
|
|
1113
|
-
const
|
|
1337
|
+
const isTemporaryTabTarget = (target: Queue.TelegramQueueTarget | undefined): boolean =>
|
|
1338
|
+
!!target && findCreatedTemporaryThread(target) !== undefined;
|
|
1339
|
+
/** Restore may choose one input only when every other known group is cancelled or a live unassigned chooser that can be retained. */
|
|
1340
|
+
const areTemporaryThreadSiblingsAccountedForRestore = (entry: Threads.TelegramTemporaryThreadEntry, from: PendingUnboundReroute): boolean => {
|
|
1341
|
+
const target = entry.target;
|
|
1342
|
+
if (!target) return false;
|
|
1343
|
+
const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(from.messages) };
|
|
1344
|
+
const inputs = Threads.getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
|
|
1345
|
+
if (!inputs.some(input => isDeepStrictEqual(input, group)) || resolved.some(input => isDeepStrictEqual(input, group))) return false;
|
|
1346
|
+
const others = [...pendingUnboundReroutes.values()].filter(pending => pending !== from &&
|
|
1347
|
+
pending.sourceTarget.chatId === target.chatId && pending.sourceTarget.threadId === target.threadId);
|
|
1348
|
+
const groupOf = (pending: PendingUnboundReroute) => ({ journalBindingKey: entry.source.journalBindingKey,
|
|
1349
|
+
updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) });
|
|
1350
|
+
if (!others.every(pending => !!pending.abandonment && !pending.abandonment.result && !pending.destinationSelected &&
|
|
1351
|
+
!pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage &&
|
|
1352
|
+
!pending.workspaceRestore && !entry.forwardedInputs?.some(input => isDeepStrictEqual(input, groupOf(pending))) &&
|
|
1353
|
+
inputs.some(input => isDeepStrictEqual(input, groupOf(pending))))) return false;
|
|
1354
|
+
return inputs.every(input => isDeepStrictEqual(input, group) || resolved.some(other => isDeepStrictEqual(other, input)) ||
|
|
1355
|
+
others.some(pending => isDeepStrictEqual(groupOf(pending), input)));
|
|
1356
|
+
};
|
|
1357
|
+
const isRerouteTargetProtected = (target: Queue.TelegramQueueTarget, ownRestore?: Threads.TelegramWorkspaceRestoreIntent,
|
|
1358
|
+
ownTemporary?: Threads.TelegramTemporaryThreadEntry, restoreFrom?: PendingUnboundReroute): boolean => {
|
|
1114
1359
|
const matches = (candidate: Queue.TelegramQueueTarget): boolean =>
|
|
1115
1360
|
candidate.chatId === target.chatId && candidate.threadId === target.threadId;
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1361
|
+
const activeTarget = deps.activeTurnRuntime.getTarget();
|
|
1362
|
+
if ((activeTarget && matches(activeTarget)) ||
|
|
1363
|
+
deps.telegramQueueStore.getQueuedItems().some(item => item.target && matches(item.target)) ||
|
|
1364
|
+
(deps.getLiveThreadTargets?.() ?? []).some(matches) ||
|
|
1365
|
+
(deps.threadStore?.list() ?? []).some(record => matches(record.target)) ||
|
|
1366
|
+
(deps.threadStore?.listReservations() ?? []).some(record => matches(record.target)) ||
|
|
1367
|
+
(deps.threadStore?.listPendingProvisions() ?? []).some(record => record.target && matches(record.target))) return true;
|
|
1368
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
1369
|
+
if (deps.getWorkspaceRestoreStore && !store) return true;
|
|
1370
|
+
let temporary: Threads.TelegramTemporaryThreadEntry[];
|
|
1371
|
+
try { temporary = store?.listTemporaryThreads() ?? []; } catch { return true; }
|
|
1372
|
+
// Only the source's own Forward/Cancel may remove its retained tab; adoption may have changed its executor.
|
|
1373
|
+
const ownTab = (entry: Threads.TelegramTemporaryThreadEntry): boolean => !!ownTemporary && entry.token === ownTemporary.token &&
|
|
1374
|
+
isDeepStrictEqual(entry.source, ownTemporary.source) && isDeepStrictEqual(entry.target, ownTemporary.target);
|
|
1375
|
+
if (temporary.some(entry => !!entry.target && matches(entry.target) &&
|
|
1376
|
+
(!ownTab(entry) || (Threads.getTelegramTemporaryThreadInputs(entry).length > 1 &&
|
|
1377
|
+
!Threads.isTelegramTemporaryThreadFullyResolved(entry) &&
|
|
1378
|
+
!(restoreFrom && areTemporaryThreadSiblingsAccountedForRestore(entry, restoreFrom)))))) return true;
|
|
1379
|
+
// Whole-tab cleanup after cancellation also needs a fresh census of retained arrivals no membership has seen.
|
|
1380
|
+
if (!restoreFrom && temporary.some(entry => ownTab(entry) && !!(entry.cancelledInputs?.length || entry.completedInputs?.length))) {
|
|
1381
|
+
try {
|
|
1382
|
+
const required = [...new Set([deps.getAdmissionJournalBinding?.(), ownTemporary!.source.journalBindingKey,
|
|
1383
|
+
...Threads.getTelegramTemporaryThreadInputs(ownTemporary!).map(input => input.journalBindingKey)].filter((key): key is string => !!key))];
|
|
1384
|
+
if (deps.inspectTemporaryThreadSources?.({ chatId: target.chatId, threadId: target.threadId! }, required)?.length !== 0) return true;
|
|
1385
|
+
} catch { return true; }
|
|
1386
|
+
}
|
|
1387
|
+
const retained = store?.list() ?? [];
|
|
1388
|
+
// Exempt only the exact operation's old target. Cleanup itself also requires its issued grant.
|
|
1389
|
+
if (ownRestore && (!matches(ownRestore.request.binding.target) ||
|
|
1390
|
+
!retained.some(intent => isDeepStrictEqual(intent, ownRestore)))) return true;
|
|
1391
|
+
if (retained.some(intent => (matches(intent.request.binding.target) || matches(intent.request.target)) &&
|
|
1392
|
+
!(ownRestore && isDeepStrictEqual(intent, ownRestore) && matches(intent.request.binding.target)))) return true;
|
|
1393
|
+
if (!ownRestore) return false;
|
|
1394
|
+
let acceptedWorkClear = false;
|
|
1395
|
+
try {
|
|
1396
|
+
deps.threadStore?.withWorkspaceRestoreSnapshot(ownRestore, snapshot => {
|
|
1397
|
+
const bindings = snapshot.workspaceBindings?.filter(binding => binding.bindingKey === ownRestore.request.binding.bindingKey) ?? [];
|
|
1398
|
+
if (bindings.length !== 1 || !deps.captureWorkspaceExternalProtection) return undefined;
|
|
1399
|
+
const binding = bindings[0]!;
|
|
1400
|
+
// Relocation does not erase predecessor references or prove where their work executes.
|
|
1401
|
+
const journalBindingKeys = [...new Set([
|
|
1402
|
+
...(ownRestore.request.binding.journalBindingKeys ?? []), ...(binding.journalBindingKeys ?? []),
|
|
1403
|
+
])];
|
|
1404
|
+
const journalSources = [...new Map([
|
|
1405
|
+
...(ownRestore.request.binding.journalSources ?? []), ...(binding.journalSources ?? []),
|
|
1406
|
+
].map(source => [JSON.stringify([source.sessionId, source.recipientBindingKey]), source])).values()];
|
|
1407
|
+
acceptedWorkClear = deps.captureWorkspaceExternalProtection({ ...binding, target: ownRestore.request.binding.target,
|
|
1408
|
+
journalBindingKeys, journalSources },
|
|
1409
|
+
{ requireBindingProvenance: true }).acceptedWork === "clear";
|
|
1410
|
+
return undefined;
|
|
1411
|
+
});
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
acceptedWorkClear = false;
|
|
1414
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-protection" });
|
|
1415
|
+
}
|
|
1416
|
+
return !acceptedWorkClear;
|
|
1120
1417
|
};
|
|
1121
1418
|
const dismissRerouteChooserMessage = async (
|
|
1122
1419
|
query: TCallbackQuery,
|
|
@@ -1151,6 +1448,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1151
1448
|
target: { chatId: number; threadId: number } | undefined,
|
|
1152
1449
|
messageId: number | undefined,
|
|
1153
1450
|
assertExecutionCurrent?: () => void,
|
|
1451
|
+
ownTemporary?: Threads.TelegramTemporaryThreadEntry,
|
|
1154
1452
|
): Promise<boolean> => {
|
|
1155
1453
|
if (!target || !deps.threadStore) return true;
|
|
1156
1454
|
const nowMs = Date.now();
|
|
@@ -1173,11 +1471,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1173
1471
|
},
|
|
1174
1472
|
],
|
|
1175
1473
|
});
|
|
1176
|
-
|
|
1474
|
+
// A temporary grant licenses this tab only, not unrelated cleanup emitted by the general planner.
|
|
1475
|
+
const scoped = ownTemporary ? { ...plan, actions: plan.actions.filter(action =>
|
|
1476
|
+
action.kind === "close-delete-unbound-topic" && isDeepStrictEqual(action.target, target)) } : plan;
|
|
1477
|
+
return applyThreadCleanupPlan(scoped, assertExecutionCurrent, undefined, ownTemporary);
|
|
1177
1478
|
};
|
|
1178
1479
|
const closePreviousLeaderThread = async (
|
|
1179
1480
|
target: { chatId: number; threadId: number } | undefined,
|
|
1180
1481
|
assertExecutionCurrent?: () => void,
|
|
1482
|
+
restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
|
|
1181
1483
|
): Promise<boolean> => {
|
|
1182
1484
|
if (!target || !deps.threadStore) return true;
|
|
1183
1485
|
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -1193,12 +1495,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1193
1495
|
: {}),
|
|
1194
1496
|
},
|
|
1195
1497
|
],
|
|
1196
|
-
}, assertExecutionCurrent);
|
|
1498
|
+
}, assertExecutionCurrent, restoreCleanup);
|
|
1197
1499
|
};
|
|
1198
1500
|
const closeReplacedFollowerThread = async (
|
|
1199
1501
|
target: { chatId: number; threadId: number } | undefined,
|
|
1200
1502
|
instanceId: string | undefined,
|
|
1201
1503
|
assertExecutionCurrent?: () => void,
|
|
1504
|
+
restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
|
|
1202
1505
|
): Promise<boolean> => {
|
|
1203
1506
|
if (!target || !deps.threadStore) return true;
|
|
1204
1507
|
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -1214,8 +1517,692 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1214
1517
|
: {}),
|
|
1215
1518
|
},
|
|
1216
1519
|
],
|
|
1217
|
-
}, assertExecutionCurrent);
|
|
1520
|
+
}, assertExecutionCurrent, restoreCleanup);
|
|
1521
|
+
};
|
|
1522
|
+
// Worker ACK observers outlive callback admission. Re-enter the existing Workspace gate;
|
|
1523
|
+
// never await this continuation while holding the dispatch invocation's gate.
|
|
1524
|
+
const restoreSettlementTasks = new Set<Promise<void>>();
|
|
1525
|
+
/**
|
|
1526
|
+
* After a Restore into a temporary tab reached positive terminal settlement, every still-unassigned sibling there is retained
|
|
1527
|
+
* privately and cancelled without Pi delivery. Missing proof, authority or abandonment leaves that sibling pending and the
|
|
1528
|
+
* tab's temporary entry protected; selected, queued, running or unknown work is never touched.
|
|
1529
|
+
*/
|
|
1530
|
+
const disposeTemporaryThreadSiblingsAfterRestore = async (intent: Threads.TelegramWorkspaceRestoreIntent, ctx: TContext,
|
|
1531
|
+
restoreCurrent: () => boolean): Promise<void> => {
|
|
1532
|
+
const target = intent.request.target, cap = captureTemporaryThreadAuthority(ctx, restoreCurrent);
|
|
1533
|
+
const entry = findCreatedTemporaryThread(target);
|
|
1534
|
+
if (!cap || !entry || !entry.target || !cap.isCurrent()) return;
|
|
1535
|
+
const operatorUserId = intent.operatorUserId, journalBindingKey = intent.request.source.journalBindingKey;
|
|
1536
|
+
if (cap.operatorUserId !== operatorUserId || cap.journalBindingKey !== journalBindingKey ||
|
|
1537
|
+
entry.operatorUserId !== operatorUserId || entry.source.journalBindingKey !== journalBindingKey) return;
|
|
1538
|
+
const group = { journalBindingKey, updateIds: [...intent.request.source.updateIds] };
|
|
1539
|
+
if (!Threads.getTelegramTemporaryThreadInputs(entry).some(input => isDeepStrictEqual(input, group))) return;
|
|
1540
|
+
for (const [id, pending] of [...pendingUnboundReroutes]) {
|
|
1541
|
+
const cancellation = pending.abandonment, source = pending.messages[0];
|
|
1542
|
+
if (pending.sourceTarget.chatId !== target.chatId || pending.sourceTarget.threadId !== target.threadId ||
|
|
1543
|
+
pending.workspaceRestore?.operationId === intent.request.operationId || !cancellation || !source || cancellation.result ||
|
|
1544
|
+
cancellation.running) continue;
|
|
1545
|
+
const current = (): boolean => cap.isCurrent() &&
|
|
1546
|
+
pendingUnboundReroutes.get(id) === pending && pending.abandonment === cancellation &&
|
|
1547
|
+
!pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry &&
|
|
1548
|
+
!pending.cleanup && !pending.finalizeMessage && !pending.workspaceRestore &&
|
|
1549
|
+
Updates.getTelegramUpdateExecutionFence(source)?.signal.aborted === false &&
|
|
1550
|
+
cancellation.ownerUserId === operatorUserId && cancellation.journalBindingKey === journalBindingKey;
|
|
1551
|
+
if (!current()) continue;
|
|
1552
|
+
cancellation.running = true;
|
|
1553
|
+
try {
|
|
1554
|
+
const result = Updates.abandonTelegramDeferredUpdate(source, { operatorAuthorityId: `telegram-owner:${operatorUserId}`, isCurrent: current });
|
|
1555
|
+
if (!result) continue;
|
|
1556
|
+
cancellation.attempted = true;
|
|
1557
|
+
cancellation.result = result;
|
|
1558
|
+
if (!recordCancelledTemporaryThreadInput(pending, ctx, current)) continue;
|
|
1559
|
+
await retireCancellationChooser(id, pending, current);
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-restore-sibling", rerouteId: id });
|
|
1562
|
+
} finally { cancellation.running = false; }
|
|
1563
|
+
}
|
|
1564
|
+
// The entry is released only after every sibling is gone and the Restore group's completion is the single uncancelled input.
|
|
1565
|
+
if (hasPendingRerouteForTarget(target, pending => pending.workspaceRestore?.operationId === intent.request.operationId) ||
|
|
1566
|
+
!cap.isCurrent()) return;
|
|
1567
|
+
const fresh = findCreatedTemporaryThread(target), owned = fresh && cap.adopt(fresh);
|
|
1568
|
+
if (owned && cap.isCurrent()) cap.store.retireTemporaryThread(owned, cap.authority, group);
|
|
1569
|
+
};
|
|
1570
|
+
const observeRestoreSettlement = (signal:
|
|
1571
|
+
| { kind: "source"; evidence: Threads.TelegramWorkspaceRestoreSourceSettlement }
|
|
1572
|
+
| { kind: "recipient"; follower: Bus.TelegramBusFollowerView; isCurrent: () => boolean },
|
|
1573
|
+
ctx: TContext): Promise<void> | undefined => {
|
|
1574
|
+
if (!deps.hasWorkspaceRestoreAuthority?.() || !deps.runWorkspaceOperation || !deps.getWorkspaceRestoreStore ||
|
|
1575
|
+
!deps.getSessionGeneration || deps.isContextActive?.(ctx) === false) return;
|
|
1576
|
+
const evidence = signal.kind === "source" ? structuredClone(signal.evidence) : undefined;
|
|
1577
|
+
const follower = signal.kind === "recipient" ? structuredClone(signal.follower) : undefined;
|
|
1578
|
+
const recipientCurrent = (): boolean => {
|
|
1579
|
+
if (signal.kind !== "recipient" || !follower) return true;
|
|
1580
|
+
const live = deps.workspaceRestoreRecipient?.followerRegistry.get(follower.instanceId);
|
|
1581
|
+
return signal.isCurrent() && !!live && Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1582
|
+
isDeepStrictEqual({ ...live, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined },
|
|
1583
|
+
{ ...follower, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined });
|
|
1584
|
+
};
|
|
1585
|
+
const recipientMatches = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean => {
|
|
1586
|
+
if (!follower) return true;
|
|
1587
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1588
|
+
return ready?.kind === "follower" && ready.instanceId === follower.instanceId && ready.sessionId === follower.sessionId &&
|
|
1589
|
+
ready.generation === follower.registrationGeneration && !!follower.cwd &&
|
|
1590
|
+
intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
|
|
1591
|
+
intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
|
|
1592
|
+
};
|
|
1593
|
+
// A same-session successor already registered on the relocated target may prove an issued grant by inspection only.
|
|
1594
|
+
const inspectableSuccessor = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean =>
|
|
1595
|
+
!!follower?.registrationGeneration && !!follower.cwd && (intent.phase === "relocated" || intent.phase === "recipient-issued") &&
|
|
1596
|
+
intent.request.owner.owner?.kind === "manual-follower" && follower.sessionId === intent.request.binding.sessionId &&
|
|
1597
|
+
intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
|
|
1598
|
+
intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
|
|
1599
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1600
|
+
const operatorUserId = deps.configStore.getAllowedUserId();
|
|
1601
|
+
const generation = deps.getSessionGeneration(), scope = deps.getAdmissionScope?.();
|
|
1602
|
+
const sourceBinding = deps.getAdmissionJournalBinding?.();
|
|
1603
|
+
if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope ||
|
|
1604
|
+
!sourceBinding || (evidence && evidence.journalBindingKey !== sourceBinding) || !recipientCurrent()) return;
|
|
1605
|
+
// This same-session leader already runs on the relocated target; it may only inspect an issued leader grant.
|
|
1606
|
+
const leaderSessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
|
|
1607
|
+
const leaderCwd = deps.workspaceRestoreRecipient?.getCwd(ctx);
|
|
1608
|
+
const leaderAt = (request: Threads.TelegramWorkspaceRestoreRequest): boolean => {
|
|
1609
|
+
const ports = deps.workspaceRestoreRecipient;
|
|
1610
|
+
const cwd = ports?.getCwd(ctx), local = ports?.getLeaderIdentity();
|
|
1611
|
+
return !!cwd && cwd === leaderCwd && ports?.getSessionId(ctx) === leaderSessionId &&
|
|
1612
|
+
leaderSessionId === request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd &&
|
|
1613
|
+
!!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target);
|
|
1614
|
+
};
|
|
1615
|
+
const observeLeaderRestore = (intent: Threads.TelegramWorkspaceRestoreIntent, recipient: Threads.TelegramWorkspaceRestoreRecipient,
|
|
1616
|
+
isCurrent: () => boolean): Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>> => {
|
|
1617
|
+
const threads = deps.threadStore;
|
|
1618
|
+
if (!threads || !isCurrent() || !leaderAt(intent.request)) return undefined;
|
|
1619
|
+
const { request } = intent;
|
|
1620
|
+
let observation: Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>>;
|
|
1621
|
+
threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
|
|
1622
|
+
if (!isCurrent() || !leaderAt(request)) return;
|
|
1623
|
+
const bindings = (snapshot.workspaceBindings ?? []).filter(value => value.bindingKey === request.binding.bindingKey);
|
|
1624
|
+
const live = bindings[0];
|
|
1625
|
+
const owners = snapshot.threads.filter(value => value.status === "active" &&
|
|
1626
|
+
(value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
|
|
1627
|
+
if (bindings.length !== 1 || live?.sessionId !== request.binding.sessionId || live.cwd !== request.binding.cwd ||
|
|
1628
|
+
live.slot !== request.binding.slot || live.inactiveSinceMs !== undefined || !isDeepStrictEqual(live.target, request.target) ||
|
|
1629
|
+
owners.length !== 1 || owners[0]?.owner?.kind !== "leader" || owners[0].instanceId !== recipient.instanceId ||
|
|
1630
|
+
owners[0].owner.instanceId !== recipient.instanceId || !owners[0].owner.cwd ||
|
|
1631
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) !== request.binding.cwd ||
|
|
1632
|
+
owners[0].profileKey !== request.owner.profileKey || owners[0].slot !== request.binding.slot || !isDeepStrictEqual(owners[0].target, request.target)) return;
|
|
1633
|
+
observation = { operationId: request.operationId, recipient, target: request.target, slot: request.binding.slot!, ready: true };
|
|
1634
|
+
});
|
|
1635
|
+
return isCurrent() && leaderAt(request) ? observation : undefined;
|
|
1636
|
+
};
|
|
1637
|
+
// Same-process lost replies stay with their chooser; this path only serves a restarted leader process.
|
|
1638
|
+
const inspectableLeader = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean => !!evidence &&
|
|
1639
|
+
intent.request.owner.owner?.kind === "leader" && leaderAt(intent.request) &&
|
|
1640
|
+
(intent.phase === "relocated" ? intent.request.owner.instanceId !== instanceId
|
|
1641
|
+
: intent.phase === "recipient-issued" && intent.recipient?.kind === "leader" && intent.recipient.instanceId !== instanceId);
|
|
1642
|
+
const current = (): boolean => deps.hasWorkspaceRestoreAuthority?.() === true && deps.isContextActive?.(ctx) !== false &&
|
|
1643
|
+
deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
1644
|
+
deps.getAdmissionJournalBinding?.() === sourceBinding && deps.getCurrentInstanceId?.() === instanceId &&
|
|
1645
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId && recipientCurrent();
|
|
1646
|
+
const recoveryRecipient = (intent: Threads.TelegramWorkspaceRestoreIntent): Bus.TelegramBusFollowerView | undefined => {
|
|
1647
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1648
|
+
if (!deps.inspectRestoreSourceCompletion || !deps.workspaceRestoreRecipient || intent.phase !== "ready" ||
|
|
1649
|
+
!intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
|
|
1650
|
+
!intent.routing.acceptances?.some(value => value.kind === "forwarded")) return undefined;
|
|
1651
|
+
const live = deps.workspaceRestoreRecipient.followerRegistry.get(follower?.instanceId ?? ready?.instanceId ?? "");
|
|
1652
|
+
return ready?.kind === "follower" && live?.registrationGeneration && live.cwd &&
|
|
1653
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1654
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION) &&
|
|
1655
|
+
live.sessionId === intent.request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
|
|
1656
|
+
live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) ? live : undefined;
|
|
1657
|
+
};
|
|
1658
|
+
const recoveryLeader = (intent: Threads.TelegramWorkspaceRestoreIntent): Threads.TelegramWorkspaceRestoreRecipient | undefined => {
|
|
1659
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1660
|
+
if (evidence?.kind !== "completed" || !deps.inspectRestoreSourceCompletion || !deps.threadStore ||
|
|
1661
|
+
intent.phase !== "ready" || !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
|
|
1662
|
+
intent.request.owner.owner?.kind !== "leader" || ready?.kind !== "leader" || ready.sessionId !== intent.request.binding.sessionId ||
|
|
1663
|
+
!leaderAt(intent.request) ||
|
|
1664
|
+
!intent.routing.acceptances?.some(value => value.kind === "completed" || value.kind === "queued")) return undefined;
|
|
1665
|
+
return { kind: "leader", instanceId, sessionId: intent.request.binding.sessionId!, generation: String(generation) };
|
|
1666
|
+
};
|
|
1667
|
+
let selected: string[];
|
|
1668
|
+
try {
|
|
1669
|
+
selected = deps.getWorkspaceRestoreStore()?.list().filter(intent => {
|
|
1670
|
+
if (intent.request.source.journalBindingKey !== sourceBinding) return false;
|
|
1671
|
+
if (evidence && intent.request.source.updateIds.some(id => evidence.updateIds.includes(id))) return true;
|
|
1672
|
+
if (((evidence?.kind === "completed" || follower) && recoveryRecipient(intent)) || recoveryLeader(intent)) return true;
|
|
1673
|
+
if (follower && deps.workspaceRestoreRecipient && inspectableSuccessor(intent)) return true;
|
|
1674
|
+
if (deps.threadStore && inspectableLeader(intent)) return true;
|
|
1675
|
+
// Completion rechecks an unsent Restore whose originals may all have been abandoned before its retirement published.
|
|
1676
|
+
if (evidence?.kind === "completed" && deps.inspectRestoreSourceAbandonment && intent.phase === "ready" &&
|
|
1677
|
+
intent.routing === undefined) return true;
|
|
1678
|
+
// Hints only wake fully settled, unissued cleanup. They never settle another source.
|
|
1679
|
+
return (evidence?.kind === "completed" || !!follower) && intent.phase === "ready" && intent.routing?.cleanup === undefined &&
|
|
1680
|
+
recipientMatches(intent) && intent.request.source.updateIds.every(id =>
|
|
1681
|
+
intent.routing?.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)));
|
|
1682
|
+
}).map(intent => intent.request.operationId) ?? [];
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" });
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
if (!selected.length || !current()) return;
|
|
1688
|
+
const task = Promise.resolve().then(() => deps.runWorkspaceOperation!({ operationId: `restore-settlement-${randomBytes(16).toString("hex")}`,
|
|
1689
|
+
operationKind: "workspace.restore-settlement", scopes: [{ kind: "profile" }] }, async () => {
|
|
1690
|
+
if (!current()) return;
|
|
1691
|
+
const store = deps.getWorkspaceRestoreStore!();
|
|
1692
|
+
if (!store) return;
|
|
1693
|
+
const authority: Threads.TelegramWorkspaceRestoreAuthority = {
|
|
1694
|
+
executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
|
|
1695
|
+
const assertCurrent = (): void => { if (!current()) throw new Error("Workspace Restore settlement authority ended."); };
|
|
1696
|
+
// Exact owner abandonment of every original is terminal for an unsent Restore; no cleanup or delivery follows.
|
|
1697
|
+
const abandoned = (value: Threads.TelegramWorkspaceRestoreIntent): boolean => {
|
|
1698
|
+
if (evidence?.kind !== "completed" || !value.request.source.updateIds.length) return false;
|
|
1699
|
+
return value.request.source.updateIds.every(updateId => {
|
|
1700
|
+
const proof = deps.inspectRestoreSourceAbandonment?.(updateId, sourceBinding);
|
|
1701
|
+
assertCurrent();
|
|
1702
|
+
return proof?.journalBindingKey === sourceBinding && proof.updateId === updateId &&
|
|
1703
|
+
proof.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
|
|
1704
|
+
});
|
|
1705
|
+
};
|
|
1706
|
+
for (const operationId of selected) {
|
|
1707
|
+
assertCurrent();
|
|
1708
|
+
let intent = store.list().find(value => value.request.operationId === operationId);
|
|
1709
|
+
let settlementCurrent = current;
|
|
1710
|
+
let settlementCanonicalCurrent: (() => boolean) | undefined;
|
|
1711
|
+
let retired = false;
|
|
1712
|
+
if (follower && intent && inspectableSuccessor(intent) && intent.operatorUserId === operatorUserId) {
|
|
1713
|
+
const ports = deps.workspaceRestoreRecipient!;
|
|
1714
|
+
const successor: Threads.TelegramWorkspaceRestoreRecipient = { kind: "follower", instanceId: follower.instanceId,
|
|
1715
|
+
sessionId: follower.sessionId!, generation: follower.registrationGeneration! };
|
|
1716
|
+
const { request } = intent;
|
|
1717
|
+
// Adoption plus inspect never consumes the original apply grant, delivers input or issues cleanup.
|
|
1718
|
+
await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
|
|
1719
|
+
getRecipient: () => current() ? successor : undefined,
|
|
1720
|
+
async runRecipient(action) {
|
|
1721
|
+
if (action.mode !== "inspect" || !action.isCurrent()) return undefined;
|
|
1722
|
+
return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
|
|
1723
|
+
sessionId: successor.sessionId, slot: request.binding.slot!, target: request.target,
|
|
1724
|
+
oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
|
|
1725
|
+
} });
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
if (intent && inspectableLeader(intent) && intent.operatorUserId === operatorUserId) {
|
|
1729
|
+
const threads = deps.threadStore!;
|
|
1730
|
+
const leader: Threads.TelegramWorkspaceRestoreRecipient = { kind: "leader", instanceId,
|
|
1731
|
+
sessionId: intent.request.binding.sessionId!, generation: String(generation) };
|
|
1732
|
+
const { request } = intent;
|
|
1733
|
+
await threads.load();
|
|
1734
|
+
assertCurrent();
|
|
1735
|
+
await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
|
|
1736
|
+
getRecipient: () => current() && leaderAt(request) ? leader : undefined,
|
|
1737
|
+
async runRecipient(action) {
|
|
1738
|
+
if (action.mode !== "inspect") return undefined;
|
|
1739
|
+
return observeLeaderRestore(action.intent, leader, action.isCurrent);
|
|
1740
|
+
} });
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
if (intent?.phase === "ready" && intent.routing === undefined && intent.operatorUserId === operatorUserId &&
|
|
1744
|
+
intent.request.source.journalBindingKey === sourceBinding && abandoned(intent)) {
|
|
1745
|
+
const owned = isDeepStrictEqual(intent.executor, authority.executor) ? intent : store.adopt(intent, authority);
|
|
1746
|
+
assertCurrent();
|
|
1747
|
+
if (owned) store.retireAbandoned(owned, owned.request.source.updateIds, authority);
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
const liveRecipient = intent && recoveryRecipient(intent), localRecipient = intent && recoveryLeader(intent);
|
|
1751
|
+
if (intent && (liveRecipient || localRecipient) && intent.request.source.journalBindingKey === sourceBinding) {
|
|
1752
|
+
const expected = intent, ports = deps.workspaceRestoreRecipient!;
|
|
1753
|
+
const recoveryCurrent = (): boolean => current() && (localRecipient ? leaderAt(expected.request) && isDeepStrictEqual(
|
|
1754
|
+
recoveryLeader(expected), localRecipient) : isDeepStrictEqual(
|
|
1755
|
+
{ ...recoveryRecipient(expected), lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined },
|
|
1756
|
+
{ ...liveRecipient, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }));
|
|
1757
|
+
// Storage authority callbacks check live fences only; canonical reads cannot re-enter their transaction lock.
|
|
1758
|
+
const recoveryAuthority = { ...authority, isCurrent: recoveryCurrent };
|
|
1759
|
+
settlementCurrent = recoveryCurrent;
|
|
1760
|
+
if (localRecipient) {
|
|
1761
|
+
settlementCanonicalCurrent = () => !!intent && !!observeLeaderRestore(intent, localRecipient, recoveryCurrent);
|
|
1762
|
+
if (!settlementCanonicalCurrent()) continue; // A local target alone cannot authorize executor adoption.
|
|
1763
|
+
}
|
|
1764
|
+
const inspect = (acceptance: Threads.TelegramWorkspaceRestoreSourceAcceptance,
|
|
1765
|
+
operation: Threads.TelegramWorkspaceRestoreIntent): boolean => {
|
|
1766
|
+
const completion = { journalBindingKey: sourceBinding, updateId: acceptance.updateId,
|
|
1767
|
+
sourceSha256: acceptance.sourceSha256,
|
|
1768
|
+
completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(operation, acceptance) };
|
|
1769
|
+
const observed = deps.inspectRestoreSourceCompletion!({ ...completion });
|
|
1770
|
+
if (!recoveryCurrent()) throw new Error("Workspace Restore completion observation authority ended.");
|
|
1771
|
+
return isDeepStrictEqual(observed, completion);
|
|
1772
|
+
};
|
|
1773
|
+
// Inspect proof before adopting: missing/foreign ACKs cannot even re-key the retained operation.
|
|
1774
|
+
const accepted = expected.routing!.acceptances!.filter(value =>
|
|
1775
|
+
(localRecipient ? value.kind === "completed" || value.kind === "queued" : value.kind === "forwarded") && inspect(value, expected));
|
|
1776
|
+
if (accepted.length) {
|
|
1777
|
+
const successor: Threads.TelegramWorkspaceRestoreRecipient = localRecipient ?? { kind: "follower", instanceId: liveRecipient!.instanceId,
|
|
1778
|
+
sessionId: liveRecipient!.sessionId!, generation: liveRecipient!.registrationGeneration! };
|
|
1779
|
+
const { request } = expected;
|
|
1780
|
+
const inspected = await advanceTelegramWorkspaceRestore({ request, authority: recoveryAuthority, restoreStore: store, inspectOnly: true,
|
|
1781
|
+
getRecipient: () => recoveryCurrent() ? successor : undefined,
|
|
1782
|
+
async runRecipient(action) {
|
|
1783
|
+
if (action.mode !== "inspect" || !action.isCurrent()) return undefined;
|
|
1784
|
+
if (localRecipient) return observeLeaderRestore(action.intent, successor, action.isCurrent);
|
|
1785
|
+
return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
|
|
1786
|
+
sessionId: successor.sessionId, slot: request.binding.slot!, target: request.target,
|
|
1787
|
+
oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
|
|
1788
|
+
} });
|
|
1789
|
+
if (!inspected || !recoveryCurrent()) continue;
|
|
1790
|
+
intent = inspected;
|
|
1791
|
+
if (settlementCanonicalCurrent && !settlementCanonicalCurrent()) continue;
|
|
1792
|
+
// The await cannot lend earlier readback; confirm each immutable proof again before publication.
|
|
1793
|
+
const settledIds = new Set(intent.routing!.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
|
|
1794
|
+
const recovered = new Map<string, Threads.TelegramWorkspaceRestoreSourceSettlement>();
|
|
1795
|
+
for (const acceptance of accepted.filter(value => !settledIds.has(value.updateId) && inspect(value, intent!))) {
|
|
1796
|
+
const key = acceptance.kind === "queued" ? JSON.stringify([acceptance.receiptId, acceptance.queueKind]) : "completed";
|
|
1797
|
+
const settlement = recovered.get(key) ?? { journalBindingKey: sourceBinding, updateIds: [],
|
|
1798
|
+
...(acceptance.kind === "queued" ? { kind: "queue-completed" as const, receiptId: acceptance.receiptId, queueKind: acceptance.queueKind }
|
|
1799
|
+
: { kind: "completed" as const }) };
|
|
1800
|
+
settlement.updateIds.push(acceptance.updateId);
|
|
1801
|
+
recovered.set(key, settlement);
|
|
1802
|
+
}
|
|
1803
|
+
for (const settlement of recovered.values()) {
|
|
1804
|
+
intent = store.recordSourceSettlement(intent!, settlement, recoveryAuthority);
|
|
1805
|
+
if (!intent) break;
|
|
1806
|
+
}
|
|
1807
|
+
if (!intent) continue;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
// Source hints carry no registry fence; cleanup must keep the acknowledged recipient exact across awaits.
|
|
1811
|
+
const readyRecipientCurrent = (): boolean => {
|
|
1812
|
+
const ready = intent?.readyRecipient ?? intent?.recipient;
|
|
1813
|
+
if (intent?.phase !== "ready" || ready?.kind !== "follower") return true;
|
|
1814
|
+
const live = deps.workspaceRestoreRecipient?.followerRegistry.get(ready.instanceId);
|
|
1815
|
+
return !!live?.cwd && live.registrationGeneration === ready.generation && live.sessionId === ready.sessionId &&
|
|
1816
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
|
|
1817
|
+
live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) &&
|
|
1818
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1819
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION);
|
|
1820
|
+
};
|
|
1821
|
+
const settlementAuthority = { ...authority, isCurrent: function () { return settlementCurrent() && readyRecipientCurrent(); } };
|
|
1822
|
+
const assertSettlementCurrent = (): void => {
|
|
1823
|
+
if (!settlementAuthority.isCurrent() || (!retired && settlementCanonicalCurrent && !settlementCanonicalCurrent())) {
|
|
1824
|
+
throw new Error("Workspace Restore settlement authority ended.");
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
assertSettlementCurrent();
|
|
1828
|
+
if (!intent || intent.phase !== "ready" || !intent.routing || !isDeepStrictEqual(intent.executor, authority.executor) ||
|
|
1829
|
+
intent.operatorUserId !== operatorUserId || intent.request.source.journalBindingKey !== sourceBinding) continue;
|
|
1830
|
+
const settled = new Set(intent.routing.settlements.flatMap(value => value.updateIds));
|
|
1831
|
+
if (follower && (intent.routing.cleanup !== undefined || !recipientMatches(intent) ||
|
|
1832
|
+
!intent.request.source.updateIds.every(id => settled.has(id)))) continue;
|
|
1833
|
+
const updateIds = evidence?.updateIds.filter(id => intent!.request.source.updateIds.includes(id) && !settled.has(id)) ?? [];
|
|
1834
|
+
if (evidence && updateIds.length) {
|
|
1835
|
+
intent = store.recordSourceSettlement(intent, { ...evidence, updateIds }, settlementAuthority);
|
|
1836
|
+
if (!intent) continue;
|
|
1837
|
+
}
|
|
1838
|
+
if (intent.routing?.cleanup === undefined) {
|
|
1839
|
+
if (!intent.request.source.updateIds.every(id => intent!.routing!.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)))) continue;
|
|
1840
|
+
// Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
|
|
1841
|
+
await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
|
|
1842
|
+
assertSettlementCurrent();
|
|
1843
|
+
if (isRerouteTargetProtected(intent.request.binding.target, intent)) continue;
|
|
1844
|
+
const grant = store.issueCleanup(intent, settlementAuthority);
|
|
1845
|
+
if (!grant) continue;
|
|
1846
|
+
intent = grant.intent;
|
|
1847
|
+
const oldTarget = intent.request.binding.target;
|
|
1848
|
+
const completed = intent.request.owner.owner?.kind === "leader"
|
|
1849
|
+
? await closePreviousLeaderThread(oldTarget, assertSettlementCurrent, intent)
|
|
1850
|
+
: await closeReplacedFollowerThread(oldTarget, intent.request.owner.instanceId, assertSettlementCurrent, intent);
|
|
1851
|
+
assertSettlementCurrent();
|
|
1852
|
+
if (!completed) continue; // Issued uncertainty stays protected, never downgraded or retried.
|
|
1853
|
+
intent = store.recordCleanup(intent, { kind: "completed", target: oldTarget }, settlementAuthority);
|
|
1854
|
+
if (!intent) continue;
|
|
1855
|
+
}
|
|
1856
|
+
if (!store.retire(intent, settlementAuthority)) continue;
|
|
1857
|
+
retired = true;
|
|
1858
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
1859
|
+
if (pending.workspaceRestore?.operationId !== operationId) continue;
|
|
1860
|
+
pending.finalizeMessage = "Message routed.";
|
|
1861
|
+
if (!deps.editInteractiveMessage || pending.chooserMessageId === undefined) continue;
|
|
1862
|
+
assertSettlementCurrent();
|
|
1863
|
+
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
|
|
1864
|
+
"<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
|
|
1865
|
+
assertSettlementCurrent();
|
|
1866
|
+
if (pendingUnboundReroutes.get(id) === pending) removePendingReroute(id);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
})).catch(error => { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" }); });
|
|
1870
|
+
restoreSettlementTasks.add(task);
|
|
1871
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
1872
|
+
return task;
|
|
1873
|
+
};
|
|
1874
|
+
const beforeQueueReceiptPublished: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["beforeQueueReceiptPublished"]> =
|
|
1875
|
+
async (receipt, queueOwner, ctx, isWorkerCurrent) => {
|
|
1876
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
1877
|
+
if (!store) return;
|
|
1878
|
+
const selected = store.list().filter(intent => intent.request.source.journalBindingKey === receipt.journalBindingKey &&
|
|
1879
|
+
intent.request.source.updateIds.some(id => receipt.sourceUpdateIds.includes(id))).map(intent => intent.request.operationId);
|
|
1880
|
+
if (!selected.length) return;
|
|
1881
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1882
|
+
const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.(), operatorUserId = deps.configStore.getAllowedUserId();
|
|
1883
|
+
const binding = receipt.journalBindingKey, ports = deps.workspaceRestoreRecipient, threads = deps.threadStore;
|
|
1884
|
+
if (!instanceId || epoch === undefined || generation === undefined || !scope || !binding || operatorUserId === undefined ||
|
|
1885
|
+
!ports || !threads || !deps.runWorkspaceOperation || !deps.inspectRestoreQueuedReceipt) {
|
|
1886
|
+
throw new Error("Workspace Restore queued acceptance authority is unavailable.");
|
|
1887
|
+
}
|
|
1888
|
+
const expectedReceipt: TelegramUpdateJournalQueuedCompletion = { queueKind: receipt.queueKind, receiptId: receipt.receiptId,
|
|
1889
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...queueOwner } };
|
|
1890
|
+
const current = (): boolean => isWorkerCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
|
|
1891
|
+
deps.isContextActive?.(ctx) !== false && deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
1892
|
+
deps.getAdmissionJournalBinding?.() === binding && deps.getCurrentInstanceId?.() === instanceId &&
|
|
1893
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
|
|
1894
|
+
queueOwner.instanceId === instanceId && queueOwner.sessionGeneration === generation;
|
|
1895
|
+
const assertCurrent = (): void => { if (!current()) throw new Error("Workspace Restore queued acceptance authority changed."); };
|
|
1896
|
+
assertCurrent();
|
|
1897
|
+
const recipientGuards: Array<() => boolean> = [];
|
|
1898
|
+
const sourceCompletions: Updates.TelegramQueueSourceCompletion[] = [];
|
|
1899
|
+
await deps.runWorkspaceOperation({ operationId: `restore-queue-acceptance-${randomBytes(16).toString("hex")}`,
|
|
1900
|
+
operationKind: "workspace.restore-queue-acceptance", scopes: [{ kind: "profile" }] }, async () => {
|
|
1901
|
+
assertCurrent();
|
|
1902
|
+
const active = deps.getWorkspaceRestoreStore?.();
|
|
1903
|
+
if (!active) throw new Error("Workspace Restore queued acceptance storage disappeared.");
|
|
1904
|
+
const proof = deps.inspectRestoreQueuedReceipt!({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
|
|
1905
|
+
assertCurrent();
|
|
1906
|
+
if (!proof || !isDeepStrictEqual(proof.receipt, expectedReceipt) || !/^[a-f0-9]{64}$/u.test(proof.queueOwnerSha256) ||
|
|
1907
|
+
proof.sources.length !== receipt.sourceUpdateIds.length || proof.sources.some((source, index) =>
|
|
1908
|
+
source.updateId !== receipt.sourceUpdateIds[index] || !/^[a-f0-9]{64}$/u.test(source.sourceSha256))) {
|
|
1909
|
+
throw new Error("Workspace Restore queued receipt proof is unavailable.");
|
|
1910
|
+
}
|
|
1911
|
+
for (const operationId of selected) {
|
|
1912
|
+
let intent = active.list().find(value => value.request.operationId === operationId);
|
|
1913
|
+
const recipient = intent?.readyRecipient ?? intent?.recipient;
|
|
1914
|
+
if (!intent || intent.phase !== "ready" || !intent.routing || intent.operatorUserId !== operatorUserId ||
|
|
1915
|
+
!isDeepStrictEqual(intent.executor, { instanceId, leaderEpoch: String(epoch) }) ||
|
|
1916
|
+
intent.request.source.journalBindingKey !== binding || recipient?.kind !== "leader" || recipient.instanceId !== instanceId ||
|
|
1917
|
+
recipient.generation !== String(generation) || recipient.sessionId !== intent.request.binding.sessionId) {
|
|
1918
|
+
throw new Error("Workspace Restore queued recipient proof changed.");
|
|
1919
|
+
}
|
|
1920
|
+
const request = intent.request;
|
|
1921
|
+
const liveRecipientCurrent = (): boolean => {
|
|
1922
|
+
const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
|
|
1923
|
+
return current() && !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
|
|
1924
|
+
ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
|
|
1925
|
+
};
|
|
1926
|
+
const recipientCurrent = (): boolean => {
|
|
1927
|
+
if (!liveRecipientCurrent()) return false;
|
|
1928
|
+
let committed = false;
|
|
1929
|
+
threads.withWorkspaceRestoreSnapshot(intent!, snapshot => {
|
|
1930
|
+
const live = snapshot.workspaceBindings?.filter(value => value.bindingKey === request.binding.bindingKey) ?? [];
|
|
1931
|
+
const owners = snapshot.threads.filter(value => value.status === "active" &&
|
|
1932
|
+
(value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
|
|
1933
|
+
const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
|
|
1934
|
+
committed = live.length === 1 && live[0]?.sessionId === recipient.sessionId && live[0].cwd === request.binding.cwd &&
|
|
1935
|
+
live[0].slot === request.binding.slot && live[0].inactiveSinceMs === undefined && isDeepStrictEqual(live[0].target, request.target) &&
|
|
1936
|
+
owners.length === 1 && owners[0]?.owner?.kind === "leader" && owners[0].instanceId === instanceId &&
|
|
1937
|
+
owners[0].owner.instanceId === instanceId && !!owners[0].owner.cwd &&
|
|
1938
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) === request.binding.cwd && owners[0].profileKey === request.owner.profileKey &&
|
|
1939
|
+
owners[0].slot === request.binding.slot && isDeepStrictEqual(owners[0].target, request.target) &&
|
|
1940
|
+
!!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
|
|
1941
|
+
ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
|
|
1942
|
+
});
|
|
1943
|
+
return committed && liveRecipientCurrent();
|
|
1944
|
+
};
|
|
1945
|
+
recipientGuards.push(recipientCurrent);
|
|
1946
|
+
// Storage owns the canonical CAS; its in-transaction authority callback cannot reacquire the snapshot lock.
|
|
1947
|
+
const authority: Threads.TelegramWorkspaceRestoreAuthority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: liveRecipientCurrent };
|
|
1948
|
+
for (const source of proof.sources.filter(value => request.source.updateIds.includes(value.updateId))) {
|
|
1949
|
+
if (!recipientCurrent()) throw new Error("Workspace Restore queued recipient authority changed.");
|
|
1950
|
+
const evidence: Threads.TelegramWorkspaceRestoreSourceAcceptance = { ...source, journalBindingKey: binding, recipient,
|
|
1951
|
+
kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind, queueOwnerSha256: proof.queueOwnerSha256 };
|
|
1952
|
+
let accepted: Threads.TelegramWorkspaceRestoreIntent | undefined, failure: unknown;
|
|
1953
|
+
try { accepted = active.recordSourceAcceptance(intent, evidence, authority); } catch (error) { failure = error; }
|
|
1954
|
+
if (!accepted) accepted = active.list().find(value => value.request.operationId === operationId &&
|
|
1955
|
+
isDeepStrictEqual(value.request, request) && isDeepStrictEqual(value.executor, authority.executor) && value.operatorUserId === operatorUserId &&
|
|
1956
|
+
value.routing?.acceptances?.some(retained => isDeepStrictEqual(retained, evidence)));
|
|
1957
|
+
if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
|
|
1958
|
+
accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) {
|
|
1959
|
+
throw failure ?? new Error("Workspace Restore queued acceptance was not published.");
|
|
1960
|
+
}
|
|
1961
|
+
intent = accepted;
|
|
1962
|
+
sourceCompletions.push({ ...source, journalBindingKey: binding,
|
|
1963
|
+
completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) });
|
|
1964
|
+
if (!recipientCurrent()) throw new Error("Workspace Restore queued recipient authority changed.");
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
const observed = deps.inspectRestoreQueuedReceipt!({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
|
|
1968
|
+
assertCurrent();
|
|
1969
|
+
if (!isDeepStrictEqual(observed, proof)) throw new Error("Workspace Restore queued receipt changed after publication.");
|
|
1970
|
+
});
|
|
1971
|
+
assertCurrent();
|
|
1972
|
+
if (recipientGuards.some(guard => !guard())) throw new Error("Workspace Restore queued recipient changed after admission.");
|
|
1973
|
+
return sourceCompletions.sort((a, b) => a.updateId - b.updateId);
|
|
1974
|
+
};
|
|
1975
|
+
const onQueueReceiptCompleted = (receipt: Updates.TelegramQueueAdmissionReceiptLike, ctx: TContext): void => {
|
|
1976
|
+
// A hint re-enters admission and rereads scoped proof; it never supplies completed command evidence for a queued source.
|
|
1977
|
+
if (!receipt.journalBindingKey) return;
|
|
1978
|
+
observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
|
|
1979
|
+
updateIds: [...receipt.sourceUpdateIds], kind: "completed" } }, ctx);
|
|
1980
|
+
for (const updateId of receipt.sourceUpdateIds) retireCompletedTemporaryThread(updateId, ctx, receipt.journalBindingKey);
|
|
1981
|
+
};
|
|
1982
|
+
const onQueueReceiptCommitted = (receipt: Updates.TelegramQueueAdmissionReceiptLike, ctx: TContext): void => {
|
|
1983
|
+
if (receipt.journalBindingKey) observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
|
|
1984
|
+
updateIds: [...receipt.sourceUpdateIds], kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind } }, ctx);
|
|
1985
|
+
};
|
|
1986
|
+
// Worker-confirmed source completion is the only evidence for a Forwarded input. It records a durable group fact and
|
|
1987
|
+
// starts the same delayed, fully rechecked cleanup as the last Cancel; deletion itself never happens here.
|
|
1988
|
+
const completedTemporaryInputIds = new Map<string, Set<number>>();
|
|
1989
|
+
const retireCompletedTemporaryThread = (updateId: number, ctx: TContext, journalBindingKey: string): void => {
|
|
1990
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
1991
|
+
if (!cap || !deps.runWorkspaceOperation || cap.journalBindingKey !== journalBindingKey) return;
|
|
1992
|
+
const store = cap.store;
|
|
1993
|
+
const matches = (entry: Threads.TelegramTemporaryThreadEntry) =>
|
|
1994
|
+
entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId;
|
|
1995
|
+
const inGroup = (entry: Threads.TelegramTemporaryThreadEntry) => Threads.getTelegramTemporaryThreadInputs(entry).some(input =>
|
|
1996
|
+
input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
|
|
1997
|
+
let restoreOwned = false;
|
|
1998
|
+
try {
|
|
1999
|
+
if (!store.listTemporaryThreads().some(entry => matches(entry) || inGroup(entry))) return;
|
|
2000
|
+
restoreOwned = store.list().some(intent => intent.request.source.journalBindingKey === journalBindingKey &&
|
|
2001
|
+
intent.request.source.updateIds.includes(updateId));
|
|
2002
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); return; }
|
|
2003
|
+
const task = Promise.resolve().then(() => deps.runWorkspaceOperation!({ operationId: `temporary-thread-retire-${randomBytes(16).toString("hex")}`,
|
|
2004
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
2005
|
+
if (!cap.isCurrent()) return;
|
|
2006
|
+
let entry = store.listTemporaryThreads().find(restoreOwned ? matches : inGroup);
|
|
2007
|
+
if (!entry) return;
|
|
2008
|
+
if (restoreOwned) {
|
|
2009
|
+
// A Restore keeps its own lifecycle: only its source entry may be released, never by Forward bookkeeping.
|
|
2010
|
+
if (entry.target && hasOtherTemporaryThreadReroute(entry.target, entry)) return;
|
|
2011
|
+
entry = cap.adopt(entry);
|
|
2012
|
+
if (entry && cap.isCurrent()) store.retireTemporaryThread(entry, cap.authority);
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
const group = Threads.getTelegramTemporaryThreadInputs(entry).find(input =>
|
|
2016
|
+
input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
|
|
2017
|
+
const target = entry.target;
|
|
2018
|
+
if (!group || entry.phase !== "created" || !target) return;
|
|
2019
|
+
if (entry.cancelledInputs?.some(input => isDeepStrictEqual(input, group)) ||
|
|
2020
|
+
entry.completedInputs?.some(input => isDeepStrictEqual(input, group))) return;
|
|
2021
|
+
// Process-local accumulation only: a restart forgets partial groups and the tab stays protected.
|
|
2022
|
+
const seen = completedTemporaryInputIds.get(entry.token) ?? new Set<number>();
|
|
2023
|
+
seen.add(updateId);
|
|
2024
|
+
completedTemporaryInputIds.set(entry.token, seen);
|
|
2025
|
+
if (!group.updateIds.every(id => seen.has(id))) return;
|
|
2026
|
+
entry = cap.adopt(entry);
|
|
2027
|
+
if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInputCompletion(entry, group, cap.authority) || !cap.isCurrent()) return;
|
|
2028
|
+
scheduleTemporaryThreadCleanup(target, ctx);
|
|
2029
|
+
return true;
|
|
2030
|
+
})).then(published => {
|
|
2031
|
+
// A sibling may complete after Restore already settled. Reinspect only after its terminal fact publishes and admission releases.
|
|
2032
|
+
if (published && cap.isCurrent()) observeRestoreSettlement({ kind: "source",
|
|
2033
|
+
evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
|
|
2034
|
+
}).catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); });
|
|
2035
|
+
restoreSettlementTasks.add(task);
|
|
2036
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
1218
2037
|
};
|
|
2038
|
+
const onUpdateCompleted = (updateId: number, ctx: TContext, journalBindingKey?: string): void => {
|
|
2039
|
+
if (!journalBindingKey) return;
|
|
2040
|
+
observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
|
|
2041
|
+
retireCompletedTemporaryThread(updateId, ctx, journalBindingKey);
|
|
2042
|
+
};
|
|
2043
|
+
const restoreWorkspace = deps.workspaceRestoreRecipient ? async (input: {
|
|
2044
|
+
operationId: string; record: Threads.TelegramTopicTargetRecord;
|
|
2045
|
+
target: Threads.TelegramTopicTargetRecord["target"]; messages: readonly TMessage[];
|
|
2046
|
+
ctx: TContext; isCurrent: () => boolean;
|
|
2047
|
+
dispatch: (recipient: { kind: "leader" | "follower"; instanceId: string }, isRecipientCurrent: () => boolean,
|
|
2048
|
+
recordForwardAcceptance: (message: TMessage, delivery: Bus.TelegramBusFollowerDeliveryIdentity) => Updates.TelegramDeferredSourceEvidence,
|
|
2049
|
+
recordLocalAcceptance: (message: TMessage) => Updates.TelegramDeferredSourceEvidence) => Promise<boolean>;
|
|
2050
|
+
}) => {
|
|
2051
|
+
const ports = deps.workspaceRestoreRecipient!;
|
|
2052
|
+
const threads = deps.threadStore;
|
|
2053
|
+
if (!threads || !deps.getWorkspaceRestoreStore || !deps.getSessionGeneration ||
|
|
2054
|
+
!deps.hasWorkspaceRestoreAuthority?.() || !input.isCurrent() || input.target.threadId === undefined) return "protected";
|
|
2055
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
2056
|
+
const operatorUserId = deps.configStore.getAllowedUserId(), scope = deps.getAdmissionScope?.();
|
|
2057
|
+
const journalBindingKey = deps.getAdmissionJournalBinding?.(), generation = deps.getSessionGeneration();
|
|
2058
|
+
const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(input.messages);
|
|
2059
|
+
if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope || !journalBindingKey || !sourceIds.length) return "protected";
|
|
2060
|
+
const current = (): boolean => input.isCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
|
|
2061
|
+
deps.isContextActive?.(input.ctx) !== false && deps.getSessionGeneration?.() === generation &&
|
|
2062
|
+
deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
|
|
2063
|
+
deps.getAdmissionScope?.() === scope && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
|
|
2064
|
+
deps.configStore.getAllowedUserId() === operatorUserId;
|
|
2065
|
+
await threads.load();
|
|
2066
|
+
if (!current()) return "protected";
|
|
2067
|
+
const store = deps.getWorkspaceRestoreStore();
|
|
2068
|
+
if (!store) return "protected";
|
|
2069
|
+
const retained = store.list().find(value => value.request.operationId === input.operationId);
|
|
2070
|
+
const bindings = threads.listWorkspaceBindings().filter(value => isDeepStrictEqual(value.target, input.record.target));
|
|
2071
|
+
const binding = retained?.request.binding ?? (bindings.length === 1 ? bindings[0] : undefined);
|
|
2072
|
+
if (!binding || !binding.sessionId || !binding.slot) return "protected";
|
|
2073
|
+
const { sessionId, slot } = binding;
|
|
2074
|
+
const request: Threads.TelegramWorkspaceRestoreRequest = { operationId: input.operationId,
|
|
2075
|
+
binding, owner: structuredClone(input.record), target: { chatId: input.target.chatId, threadId: input.target.threadId },
|
|
2076
|
+
source: { journalBindingKey, updateIds: sourceIds } };
|
|
2077
|
+
if (retained && !isDeepStrictEqual(retained.request, request)) return "protected";
|
|
2078
|
+
const getRecipient = (snapshot?: Readonly<Pick<Threads.TelegramTopicTargetFile, "threads" | "workspaceBindings">>): Threads.TelegramWorkspaceRestoreRecipient | undefined => {
|
|
2079
|
+
if (!current()) return undefined;
|
|
2080
|
+
const liveBindings = (snapshot ? snapshot.workspaceBindings ?? [] : threads.listWorkspaceBindings())
|
|
2081
|
+
.filter(value => value.bindingKey === binding.bindingKey);
|
|
2082
|
+
const live = liveBindings[0];
|
|
2083
|
+
if (liveBindings.length !== 1 || live?.sessionId !== binding.sessionId || live.cwd !== binding.cwd || live.slot !== binding.slot ||
|
|
2084
|
+
live.inactiveSinceMs !== undefined || (!isDeepStrictEqual(live.target, binding.target) && !isDeepStrictEqual(live.target, request.target))) return undefined;
|
|
2085
|
+
const records = (snapshot ? snapshot.threads : threads.list()).filter(value => value.status === "active" &&
|
|
2086
|
+
(value.slot === binding.slot || isDeepStrictEqual(value.target, live.target)));
|
|
2087
|
+
const record = records[0];
|
|
2088
|
+
if (records.length !== 1 || record?.slot !== binding.slot || !isDeepStrictEqual(record.target, live.target)) return undefined;
|
|
2089
|
+
if (record.owner?.kind === "leader" && record.instanceId === instanceId) {
|
|
2090
|
+
const cwd = ports.getCwd(input.ctx);
|
|
2091
|
+
if (ports.getSessionId(input.ctx) !== sessionId || !cwd || WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) !== binding.cwd ||
|
|
2092
|
+
!deps.setCurrentLeaderIdentity) return undefined;
|
|
2093
|
+
return { kind: "leader", instanceId, sessionId, generation: String(generation) };
|
|
2094
|
+
}
|
|
2095
|
+
if (record.owner?.kind !== "manual-follower" || !record.instanceId) return undefined;
|
|
2096
|
+
const follower = ports.followerRegistry.get(record.instanceId);
|
|
2097
|
+
if (!follower?.registrationGeneration || !follower.cwd || follower.sessionId !== sessionId ||
|
|
2098
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) !== binding.cwd || follower.slot !== slot ||
|
|
2099
|
+
!Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE)) return undefined;
|
|
2100
|
+
return { kind: "follower", instanceId: follower.instanceId, sessionId, generation: follower.registrationGeneration };
|
|
2101
|
+
};
|
|
2102
|
+
const hasCommittedTarget = (): boolean => threads.listWorkspaceBindings().some(value =>
|
|
2103
|
+
value.bindingKey === binding.bindingKey && isDeepStrictEqual(value.target, request.target));
|
|
2104
|
+
const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
|
|
2105
|
+
const ready = await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, getRecipient,
|
|
2106
|
+
async runRecipient(action) {
|
|
2107
|
+
const recipient = getRecipient();
|
|
2108
|
+
if (!recipient || !action.isCurrent() || !hasCommittedTarget()) return undefined;
|
|
2109
|
+
if (recipient.kind === "follower") {
|
|
2110
|
+
const follower = ports.followerRegistry.get(recipient.instanceId);
|
|
2111
|
+
const observation = await ports.runFollower({ operationId: request.operationId, instanceId: recipient.instanceId,
|
|
2112
|
+
sessionId: recipient.sessionId, slot: binding.slot!, target: request.target, oldTarget: binding.target,
|
|
2113
|
+
mode: action.mode, isCurrent: action.isCurrent });
|
|
2114
|
+
if (!action.isCurrent() || !hasCommittedTarget()) return undefined;
|
|
2115
|
+
if (!follower || !observation?.ready) return observation;
|
|
2116
|
+
if (observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
|
|
2117
|
+
!isDeepStrictEqual(observation.target, request.target) || observation.slot !== binding.slot) return undefined;
|
|
2118
|
+
const actual = ports.followerRegistry.get(recipient.instanceId);
|
|
2119
|
+
if (!actual || actual.registrationGeneration !== follower.registrationGeneration || actual.sessionId !== follower.sessionId ||
|
|
2120
|
+
actual.cwd !== follower.cwd || actual.slot !== follower.slot || actual.profileKey !== follower.profileKey ||
|
|
2121
|
+
actual.busSocketPath !== follower.busSocketPath || !isDeepStrictEqual(actual.target, follower.target) ||
|
|
2122
|
+
!isDeepStrictEqual(actual.protocol, follower.protocol)) return undefined;
|
|
2123
|
+
threads.commitWorkspaceRestoreRegistration({ target: request.target, bindingKey: binding.bindingKey, slot: binding.slot }, () => {
|
|
2124
|
+
if (!action.isCurrent()) throw new Error("Workspace Restore recipient authority changed.");
|
|
2125
|
+
ports.followerRegistry.register({ ...actual, target: request.target, connectedAtMs: actual.connectedAtMs });
|
|
2126
|
+
});
|
|
2127
|
+
return observation;
|
|
2128
|
+
}
|
|
2129
|
+
let observation: Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>>;
|
|
2130
|
+
threads.withWorkspaceRestoreSnapshot(action.intent, snapshot => {
|
|
2131
|
+
if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient)) return;
|
|
2132
|
+
const live = snapshot.workspaceBindings?.find(value => value.bindingKey === binding.bindingKey);
|
|
2133
|
+
if (!live || !isDeepStrictEqual(live.target, request.target)) return;
|
|
2134
|
+
const local = ports.getLeaderIdentity();
|
|
2135
|
+
if (!local || local.slot !== binding.slot || (!isDeepStrictEqual(local.target, binding.target) &&
|
|
2136
|
+
!isDeepStrictEqual(local.target, request.target))) return;
|
|
2137
|
+
if (action.mode === "apply" && !isDeepStrictEqual(local.target, request.target)) {
|
|
2138
|
+
if (action.intent.phase !== "recipient-issued" || !action.isCurrent()) return;
|
|
2139
|
+
deps.setCurrentLeaderIdentity!({ target: request.target, slot: binding.slot, threadName: request.owner.threadName });
|
|
2140
|
+
}
|
|
2141
|
+
if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient)) return;
|
|
2142
|
+
const observed = ports.getLeaderIdentity();
|
|
2143
|
+
if (!observed || observed.slot !== binding.slot) return;
|
|
2144
|
+
observation = { operationId: request.operationId, recipient, target: observed.target as typeof request.target,
|
|
2145
|
+
slot: binding.slot!, ready: isDeepStrictEqual(observed.target, request.target) };
|
|
2146
|
+
});
|
|
2147
|
+
return observation;
|
|
2148
|
+
} }).catch(error => {
|
|
2149
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-recipient" });
|
|
2150
|
+
return undefined;
|
|
2151
|
+
});
|
|
2152
|
+
if (!ready || !current() || ready.routing) return "protected";
|
|
2153
|
+
const recipient = ready.readyRecipient ?? ready.recipient;
|
|
2154
|
+
const recipientCurrent = (): boolean => {
|
|
2155
|
+
if (!current() || !recipient || !hasCommittedTarget() || !isDeepStrictEqual(getRecipient(), recipient)) return false;
|
|
2156
|
+
const observed = recipient.kind === "leader" ? ports.getLeaderIdentity() : ports.followerRegistry.get(recipient.instanceId);
|
|
2157
|
+
return observed?.slot === slot && isDeepStrictEqual(observed.target, request.target);
|
|
2158
|
+
};
|
|
2159
|
+
if (!recipient || !recipientCurrent()) return "protected";
|
|
2160
|
+
if (deps.callApi) {
|
|
2161
|
+
try {
|
|
2162
|
+
await deps.callApi("editForumTopic", { chat_id: request.target.chatId, message_thread_id: request.target.threadId,
|
|
2163
|
+
name: deps.getDisplayTitle?.(request.target) ?? ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot) });
|
|
2164
|
+
} catch (error) { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-title" }); }
|
|
2165
|
+
}
|
|
2166
|
+
if (!recipientCurrent() || !store.issueRouting(ready, { ...authority, isCurrent: recipientCurrent })) return "protected";
|
|
2167
|
+
const recordAcceptance = (message: TMessage, delivery?: Bus.TelegramBusFollowerDeliveryIdentity): Updates.TelegramDeferredSourceEvidence => {
|
|
2168
|
+
if (!recipientCurrent() || recipient.kind !== (delivery ? "follower" : "leader")) throw new Error("Workspace Restore acceptance authority changed.");
|
|
2169
|
+
const source = Updates.inspectTelegramDeferredSource(message);
|
|
2170
|
+
const follower = ports.followerRegistry.get(recipient.instanceId);
|
|
2171
|
+
const ownership = deps.getTargetOwnership?.(request.target);
|
|
2172
|
+
if (!source || source.journalBindingKey !== journalBindingKey || !request.source.updateIds.includes(source.updateId) ||
|
|
2173
|
+
delivery && (!follower || ownership?.instanceId !== recipient.instanceId || ownership.ownerGeneration !== recipient.generation ||
|
|
2174
|
+
!ownership.recipientBindingKey || delivery.sourceUpdateId !== source.updateId ||
|
|
2175
|
+
delivery.recipientBindingKey !== ownership.recipientBindingKey || delivery.deliveryId !== Bus.createTelegramBusFollowerDeliveryIdentity({
|
|
2176
|
+
kind: "leader.forwardMessage", recipientBindingKey: ownership.recipientBindingKey, sourceUpdateId: source.updateId }).deliveryId) ||
|
|
2177
|
+
!recipientCurrent()) throw new Error("Workspace Restore acceptance source or delivery changed.");
|
|
2178
|
+
const evidence: Threads.TelegramWorkspaceRestoreSourceAcceptance = { ...source, recipient,
|
|
2179
|
+
...(delivery ? { kind: "forwarded", deliveryId: delivery.deliveryId, recipientBindingKey: delivery.recipientBindingKey }
|
|
2180
|
+
: { kind: "completed" }) };
|
|
2181
|
+
const expected = store.list().find(value => value.request.operationId === request.operationId);
|
|
2182
|
+
if (!expected || !isDeepStrictEqual(expected.request, request) || !expected.routing ||
|
|
2183
|
+
!recipientCurrent()) throw new Error("Workspace Restore dispatch proof is unavailable.");
|
|
2184
|
+
let accepted: Threads.TelegramWorkspaceRestoreIntent | undefined;
|
|
2185
|
+
let failure: { error: unknown } | undefined;
|
|
2186
|
+
const acceptanceAuthority = { ...authority, isCurrent: recipientCurrent };
|
|
2187
|
+
try { accepted = store.recordSourceAcceptance(expected, evidence, acceptanceAuthority); }
|
|
2188
|
+
catch (error) { failure = { error }; }
|
|
2189
|
+
if (!recipientCurrent()) throw new Error("Workspace Restore acceptance authority changed.");
|
|
2190
|
+
// A lost rename reply observes only the exact full retained proof; it never resends the accepted message.
|
|
2191
|
+
if (!accepted) {
|
|
2192
|
+
const observed = store.list().find(value => value.request.operationId === request.operationId);
|
|
2193
|
+
if (observed && isDeepStrictEqual(observed.request, request) && isDeepStrictEqual(observed.executor, authority.executor) &&
|
|
2194
|
+
observed.operatorUserId === operatorUserId && observed.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) accepted = observed;
|
|
2195
|
+
}
|
|
2196
|
+
if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
|
|
2197
|
+
accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)) ||
|
|
2198
|
+
!recipientCurrent()) throw failure?.error ?? new Error("Workspace Restore acceptance was not published.");
|
|
2199
|
+
return { ...source, completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) };
|
|
2200
|
+
};
|
|
2201
|
+
try { await input.dispatch(recipient, recipientCurrent, recordAcceptance, message => recordAcceptance(message)); }
|
|
2202
|
+
catch (error) { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-dispatch" }); }
|
|
2203
|
+
// Positive worker ACKs own settlement and cleanup under a subsequent admission.
|
|
2204
|
+
return "protected";
|
|
2205
|
+
} : undefined;
|
|
1219
2206
|
const retryPendingRerouteCleanup = async (
|
|
1220
2207
|
cleanup: PendingRerouteCleanup,
|
|
1221
2208
|
assertExecutionCurrent?: () => void,
|
|
@@ -1225,6 +2212,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1225
2212
|
cleanup.target,
|
|
1226
2213
|
cleanup.messageId,
|
|
1227
2214
|
assertExecutionCurrent,
|
|
2215
|
+
cleanup.temporaryThread,
|
|
1228
2216
|
);
|
|
1229
2217
|
}
|
|
1230
2218
|
if (cleanup.kind === "previous-leader") {
|
|
@@ -1240,15 +2228,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1240
2228
|
| ((messages: TMessage[], ctx: TContext) => Promise<void>)
|
|
1241
2229
|
| undefined;
|
|
1242
2230
|
const dispatchPendingRerouteMessages = async (
|
|
1243
|
-
pending: Pick<PendingUnboundReroute, "dispatchKind" | "sourceTarget">,
|
|
2231
|
+
pending: Pick<PendingUnboundReroute, "dispatchKind" | "sourceTarget" | "temporaryThread">,
|
|
1244
2232
|
messages: TMessage[],
|
|
1245
2233
|
ctx: TContext,
|
|
1246
2234
|
): Promise<void> => {
|
|
1247
2235
|
if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
|
|
1248
2236
|
await dispatchReroutedCommandMessages(messages, ctx);
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
2237
|
+
// Every command chooser deferred its exact original, whether the tab came from Telegram or the bridge.
|
|
2238
|
+
// A queued command retains its separately reported receipt; this cannot settle accepted queue custody.
|
|
2239
|
+
for (const message of messages) Updates.reportTelegramUpdateCompleted(message);
|
|
1252
2240
|
return;
|
|
1253
2241
|
}
|
|
1254
2242
|
await promptEnqueue(messages, ctx);
|
|
@@ -1277,36 +2265,78 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1277
2265
|
`${successMessage} Chooser cleanup is still pending. Try again.`,
|
|
1278
2266
|
);
|
|
1279
2267
|
};
|
|
2268
|
+
const isTemporaryReroute = (pending: PendingUnboundReroute): boolean =>
|
|
2269
|
+
!!pending.temporaryThread || pending.temporaryMembership === true || isTemporaryTabTarget(pending.sourceTarget);
|
|
2270
|
+
const isTemporaryThreadForwardIssued = (pending: PendingUnboundReroute): boolean => {
|
|
2271
|
+
try {
|
|
2272
|
+
const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
2273
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
2274
|
+
if ((entry || pending.temporaryMembership) && !store) return true;
|
|
2275
|
+
const stored = entry && store?.listTemporaryThreads().find(value => value.token === entry.token);
|
|
2276
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
|
|
2277
|
+
return !!stored?.forwardedInputs?.some(input => input.updateIds.some(id => updateIds.includes(id)));
|
|
2278
|
+
} catch { return true; }
|
|
2279
|
+
};
|
|
2280
|
+
/** Publishes the durable one-time Forward fact for this chooser's exact group; false means nothing may be sent. */
|
|
2281
|
+
const issueTemporaryThreadForward = (pending: PendingUnboundReroute, ctx: TContext): boolean => {
|
|
2282
|
+
const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
2283
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
2284
|
+
if (!entry || !cap) return false;
|
|
2285
|
+
const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
|
|
2286
|
+
try {
|
|
2287
|
+
const current = cap.store.listTemporaryThreads().find(value => value.token === entry.token);
|
|
2288
|
+
const adopted = current && cap.isCurrent() ? cap.adopt(current) : undefined;
|
|
2289
|
+
return !!adopted && cap.isCurrent() && !!cap.store.recordTemporaryThreadForwardIssued(adopted, group, cap.authority) && cap.isCurrent();
|
|
2290
|
+
} catch (error) {
|
|
2291
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-forward-issue" });
|
|
2292
|
+
return false;
|
|
2293
|
+
}
|
|
2294
|
+
};
|
|
1280
2295
|
const forwardPendingRerouteMessages = async (
|
|
1281
2296
|
pending: PendingUnboundReroute,
|
|
1282
2297
|
instanceId: string,
|
|
1283
2298
|
threadId: number,
|
|
1284
2299
|
ctx: TContext,
|
|
1285
2300
|
assertExecutionCurrent?: () => void,
|
|
2301
|
+
recordForwardAcceptance?: (message: TMessage, delivery: Bus.TelegramBusFollowerDeliveryIdentity) => Updates.TelegramDeferredSourceEvidence,
|
|
1286
2302
|
): Promise<boolean> => {
|
|
1287
2303
|
assertExecutionCurrent?.();
|
|
1288
2304
|
const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
|
|
1289
|
-
if (!forwardMessage) return false;
|
|
2305
|
+
if (!forwardMessage || pending.foreignForwardIssued) return false;
|
|
2306
|
+
const target = { ...pending.sourceTarget, threadId };
|
|
2307
|
+
const liveOwnership = deps.getTargetOwnership?.(target);
|
|
2308
|
+
if (!liveOwnership || liveOwnership.instanceId !== instanceId ||
|
|
2309
|
+
!liveOwnership.ownerGeneration || !liveOwnership.recipientBindingKey) return false;
|
|
2310
|
+
const ownership = structuredClone(liveOwnership);
|
|
1290
2311
|
const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
|
|
2312
|
+
// Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
|
|
2313
|
+
// even after restart. Any refusal or publication failure sends nothing and keeps the input held.
|
|
2314
|
+
if (!recordForwardAcceptance && isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
|
|
2315
|
+
if (isTemporaryThreadForwardIssued(pending)) pending.foreignForwardIssued = true;
|
|
2316
|
+
return false;
|
|
2317
|
+
}
|
|
2318
|
+
if (!recordForwardAcceptance && isTemporaryReroute(pending)) pending.foreignForwardIssued = true;
|
|
1291
2319
|
const outcomes = await Promise.allSettled(
|
|
1292
2320
|
messages.map((message) =>
|
|
1293
2321
|
forwardMessage({
|
|
1294
2322
|
message,
|
|
1295
|
-
ownership
|
|
2323
|
+
ownership,
|
|
1296
2324
|
ctx,
|
|
1297
2325
|
}),
|
|
1298
2326
|
),
|
|
1299
2327
|
);
|
|
1300
2328
|
assertExecutionCurrent?.();
|
|
2329
|
+
if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership)) return false;
|
|
1301
2330
|
pending.messages = pending.messages.filter((_, index) => {
|
|
1302
2331
|
const outcome = outcomes[index];
|
|
1303
2332
|
if (
|
|
1304
2333
|
outcome?.status === "fulfilled" &&
|
|
1305
2334
|
outcome.value.status === "accepted"
|
|
1306
2335
|
) {
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2336
|
+
// Restore publishes acceptance before reporting completion; cleanup still waits for journal disposition.
|
|
2337
|
+
const expectedSource = recordForwardAcceptance?.(pending.messages[index], outcome.value.delivery);
|
|
2338
|
+
assertExecutionCurrent?.();
|
|
2339
|
+
Updates.reportTelegramUpdateCompleted(pending.messages[index], expectedSource);
|
|
1310
2340
|
return false;
|
|
1311
2341
|
}
|
|
1312
2342
|
if (outcome?.status === "rejected") {
|
|
@@ -1319,9 +2349,540 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1319
2349
|
}
|
|
1320
2350
|
return true;
|
|
1321
2351
|
});
|
|
2352
|
+
if (pending.messages.length === 0) pending.foreignForwardIssued = false;
|
|
1322
2353
|
return pending.messages.length === 0;
|
|
1323
2354
|
};
|
|
1324
|
-
const
|
|
2355
|
+
const temporaryCleanupTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; settle: () => void }>();
|
|
2356
|
+
const cancelTemporaryThreadCleanup = (token: string): void => {
|
|
2357
|
+
const scheduled = temporaryCleanupTimers.get(token);
|
|
2358
|
+
if (!scheduled) return;
|
|
2359
|
+
clearTimeout(scheduled.timer);
|
|
2360
|
+
temporaryCleanupTimers.delete(token);
|
|
2361
|
+
scheduled.settle();
|
|
2362
|
+
};
|
|
2363
|
+
/**
|
|
2364
|
+
* One cleanup attempt after the quiet period. Cancellation facts are only a precondition: fresh authority, proof,
|
|
2365
|
+
* absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
|
|
2366
|
+
* durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
|
|
2367
|
+
*/
|
|
2368
|
+
const runTemporaryThreadCleanup = async (token: string, cap: TemporaryThreadAuthority, reconcileExpiry: boolean): Promise<void> => {
|
|
2369
|
+
const store = cap.store;
|
|
2370
|
+
if ((!deps.inspectRestoreSourceAbandonment && !deps.inspectRoutingInputGroupExpiry) || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent()) return;
|
|
2371
|
+
await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
|
|
2372
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
2373
|
+
if (!cap.isCurrent()) return;
|
|
2374
|
+
let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
|
|
2375
|
+
const target = entry?.target;
|
|
2376
|
+
if (!entry || !target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
|
|
2377
|
+
entry.source.journalBindingKey !== cap.journalBindingKey) return;
|
|
2378
|
+
const owner = `telegram-owner:${cap.operatorUserId}`;
|
|
2379
|
+
if (reconcileExpiry && deps.inspectRoutingInputGroupExpiry) {
|
|
2380
|
+
for (const group of Threads.getTelegramTemporaryThreadInputs(entry)) {
|
|
2381
|
+
if ([...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])].some(value => isDeepStrictEqual(value, group))) continue;
|
|
2382
|
+
const inspect = (id: number) => deps.inspectRoutingInputGroupExpiry!(group)?.find(value => value.updateId === id);
|
|
2383
|
+
if (!group.updateIds.every(id => inspect(id)?.operatorAuthorityId === owner)) continue;
|
|
2384
|
+
entry = cap.adopt(entry);
|
|
2385
|
+
if (!entry || !cap.isCurrent()) return;
|
|
2386
|
+
let recorded: Threads.TelegramTemporaryThreadEntry | undefined, failure: unknown;
|
|
2387
|
+
try { recorded = store.recordTemporaryThreadInputExpiry(entry, group, cap.authority, inspect); }
|
|
2388
|
+
catch (error) { failure = error; }
|
|
2389
|
+
if (!cap.isCurrent()) return;
|
|
2390
|
+
const retained = store.listTemporaryThreads().find(value => value.token === token);
|
|
2391
|
+
// A lost publication ACK may already have recorded the group or retired a bound tab's temporary frame.
|
|
2392
|
+
if (!retained) return;
|
|
2393
|
+
if (!recorded && !retained.cancelledInputs?.some(value => isDeepStrictEqual(value, group)))
|
|
2394
|
+
throw failure ?? new Error("Chooser expiry metadata was not confirmed.");
|
|
2395
|
+
entry = retained;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
if (!Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target)) return;
|
|
2399
|
+
const cancelled = entry.cancelledInputs ?? [];
|
|
2400
|
+
const cancelledCurrent = () => cancelled.every(input => {
|
|
2401
|
+
const expiry = deps.inspectRoutingInputGroupExpiry?.(input);
|
|
2402
|
+
return input.updateIds.every(updateId => {
|
|
2403
|
+
const evidence = deps.inspectRestoreSourceAbandonment?.(updateId, input.journalBindingKey) ?? expiry?.find(value => value.updateId === updateId);
|
|
2404
|
+
return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId && evidence.operatorAuthorityId === owner;
|
|
2405
|
+
});
|
|
2406
|
+
});
|
|
2407
|
+
if (!cancelledCurrent()) return;
|
|
2408
|
+
entry = cap.adopt(entry);
|
|
2409
|
+
if (!entry || !cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry)) return;
|
|
2410
|
+
const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
|
|
2411
|
+
if (!issued || !cap.isCurrent()) return;
|
|
2412
|
+
entry = issued.entry;
|
|
2413
|
+
const issuedEntry = entry;
|
|
2414
|
+
const assertCurrent = (): void => {
|
|
2415
|
+
if (!cap.isCurrent() || !cancelledCurrent() || !store.isTemporaryThreadCleanupCurrent(issuedEntry, cap.authority))
|
|
2416
|
+
throw new Error("Temporary Thread cleanup authority or exact grant evidence changed.");
|
|
2417
|
+
};
|
|
2418
|
+
if (!await closeReroutedUnboundTopic(target, undefined, assertCurrent, entry) || !cap.isCurrent()) return;
|
|
2419
|
+
store.retireTemporaryThread(entry, cap.authority);
|
|
2420
|
+
});
|
|
2421
|
+
};
|
|
2422
|
+
// Reuse the one timer per tab for body-free metadata retries. Future retry timers do not join settlement waits;
|
|
2423
|
+
// only their running attempt does. No retry is licensed once a destructive grant may have been issued.
|
|
2424
|
+
const queueTemporaryThreadCleanup = (token: string, cap: TemporaryThreadAuthority, delay: number, reconcileExpiry: boolean, retry = false): void => {
|
|
2425
|
+
cancelTemporaryThreadCleanup(token);
|
|
2426
|
+
let settle = (): void => undefined;
|
|
2427
|
+
const task = new Promise<void>(resolve => { settle = resolve; });
|
|
2428
|
+
const timer = setTimeout(() => {
|
|
2429
|
+
if (temporaryCleanupTimers.get(token)?.timer !== timer) return;
|
|
2430
|
+
temporaryCleanupTimers.delete(token);
|
|
2431
|
+
if (retry) restoreSettlementTasks.add(task);
|
|
2432
|
+
runTemporaryThreadCleanup(token, cap, reconcileExpiry)
|
|
2433
|
+
.catch(error => {
|
|
2434
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" });
|
|
2435
|
+
if (!reconcileExpiry || !cap.isCurrent()) return;
|
|
2436
|
+
try {
|
|
2437
|
+
const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
|
|
2438
|
+
if (retained && !retained.cleanupIssued && cap.isCurrent()) queueTemporaryThreadCleanup(token, cap, 60_000, true, true);
|
|
2439
|
+
} catch { /* Unknown grant state never licenses a retry. */ }
|
|
2440
|
+
})
|
|
2441
|
+
.finally(settle);
|
|
2442
|
+
}, delay);
|
|
2443
|
+
timer.unref?.();
|
|
2444
|
+
temporaryCleanupTimers.set(token, { timer, settle });
|
|
2445
|
+
if (!retry) restoreSettlementTasks.add(task);
|
|
2446
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
2447
|
+
};
|
|
2448
|
+
/** Starts the quiet period for resolved groups or body-free expiry reconciliation; fresh input/authority revokes it. */
|
|
2449
|
+
const scheduleTemporaryThreadCleanup = (target: Queue.TelegramQueueTarget, ctx: TContext, reconcileExpiry = false): void => {
|
|
2450
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
2451
|
+
if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target)) return;
|
|
2452
|
+
let entry: Threads.TelegramTemporaryThreadEntry | undefined;
|
|
2453
|
+
try { entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
|
|
2454
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
|
|
2455
|
+
if (!entry || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
|
|
2456
|
+
if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry)) {
|
|
2457
|
+
try { reconcileExpiry = Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group)); }
|
|
2458
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
|
|
2459
|
+
}
|
|
2460
|
+
if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry)) return;
|
|
2461
|
+
const requested = deps.temporaryThreadCleanupDelayMs;
|
|
2462
|
+
const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
|
|
2463
|
+
queueTemporaryThreadCleanup(entry.token, cap, delay, reconcileExpiry);
|
|
2464
|
+
};
|
|
2465
|
+
const recordCancelledTemporaryThreadInput = (pending: PendingUnboundReroute, ctx: TContext, isCurrent: () => boolean): boolean => {
|
|
2466
|
+
const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
|
|
2467
|
+
if (!store || target.threadId === undefined) return !pending.temporaryThread;
|
|
2468
|
+
let entry = store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
|
|
2469
|
+
if (!entry) return !pending.temporaryThread;
|
|
2470
|
+
const cap = captureTemporaryThreadAuthority(ctx, isCurrent), inspect = deps.inspectRestoreSourceAbandonment;
|
|
2471
|
+
if (!cap || !inspect || cap.operatorUserId !== entry.operatorUserId || cap.journalBindingKey !== entry.source.journalBindingKey) return false;
|
|
2472
|
+
const { operatorUserId, journalBindingKey } = cap, current = cap.isCurrent;
|
|
2473
|
+
const input = { journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
|
|
2474
|
+
if (!current()) return false;
|
|
2475
|
+
entry = cap.adopt(entry);
|
|
2476
|
+
if (!entry || !current()) return false;
|
|
2477
|
+
const recorded = store.recordTemporaryThreadInputCancellation(entry, input, cap.authority, updateId => inspect(updateId, journalBindingKey));
|
|
2478
|
+
if (!recorded || !current() || !recorded.cancelledInputs?.some(value => isDeepStrictEqual(value, input))) return false;
|
|
2479
|
+
const observed = store.listTemporaryThreads().find(value => isDeepStrictEqual(value, recorded));
|
|
2480
|
+
return !!observed && input.updateIds.every(updateId => {
|
|
2481
|
+
const evidence = inspect(updateId, journalBindingKey);
|
|
2482
|
+
return evidence?.journalBindingKey === journalBindingKey && evidence.updateId === updateId &&
|
|
2483
|
+
evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
|
|
2484
|
+
}) && current();
|
|
2485
|
+
};
|
|
2486
|
+
const retireCancellationChooser = async (id: string, pending: PendingUnboundReroute, isCurrent: () => boolean): Promise<boolean> => {
|
|
2487
|
+
if (!pending.abandonment?.result || !isCurrent() || !deps.editInteractiveMessage || pending.chooserMessageId === undefined) return false;
|
|
2488
|
+
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
|
|
2489
|
+
"<b>⛔️ Routing cancelled.</b>", "html", { inline_keyboard: [] });
|
|
2490
|
+
if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending) return false;
|
|
2491
|
+
removePendingReroute(id);
|
|
2492
|
+
return true;
|
|
2493
|
+
};
|
|
2494
|
+
const isReviewText = (message: Partial<TMessage> | undefined): boolean => {
|
|
2495
|
+
if (!message || message.chat?.type !== "private" || !Number.isSafeInteger(message.chat.id) ||
|
|
2496
|
+
!Number.isSafeInteger(message.from?.id) || message.from?.is_bot !== false ||
|
|
2497
|
+
!Number.isSafeInteger(message.message_id) || message.message_id! <= 0 ||
|
|
2498
|
+
!Number.isSafeInteger(message.message_thread_id) || message.message_thread_id! <= 0 ||
|
|
2499
|
+
typeof message.text !== "string" || !message.text.trim() || message.text.trim().startsWith("/")) return false;
|
|
2500
|
+
const fields = message as Record<string, unknown>;
|
|
2501
|
+
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);
|
|
2502
|
+
};
|
|
2503
|
+
const reviewMessage = (entry: RecoverySource["original"]): Partial<TMessage> | undefined => {
|
|
2504
|
+
const message = entry.update.message as Partial<TMessage> | undefined;
|
|
2505
|
+
if (entry.state !== "pending" || entry.preApprovalExcluded || entry.inputClaim || entry.inputProvenance ||
|
|
2506
|
+
entry.queueOwner || entry.queueReceiptId || entry.queueHandoff || entry.failure ||
|
|
2507
|
+
Object.keys(entry.update).some(key => key !== "update_id" && key !== "message") ||
|
|
2508
|
+
!isReviewText(message) || Reflect.has(message!, "pi_telegram_source_update_id")) return undefined;
|
|
2509
|
+
return message;
|
|
2510
|
+
};
|
|
2511
|
+
const needsHistoricalReview = (message: Partial<TMessage>): boolean => {
|
|
2512
|
+
if (!deps.threadStore) throw new Error("Historical routing requires a current Thread snapshot.");
|
|
2513
|
+
if (deps.threadStore.getBotState().threadMode === "disabled") return true;
|
|
2514
|
+
return !getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).some(record =>
|
|
2515
|
+
record.target.chatId === message.chat!.id && record.target.threadId === message.message_thread_id);
|
|
2516
|
+
};
|
|
2517
|
+
/** New-world restart: forget previous-instance Restore/temporary state, then one silent delete per positively disposable tab. */
|
|
2518
|
+
const forgetPreviousWorld = async (input: Updates.TelegramHeldSourcePreparation<TContext>,
|
|
2519
|
+
captureTransport?: (ctx: TContext) => (() => boolean) | undefined): Promise<{ forgotten: number; deleted: number }> => {
|
|
2520
|
+
const result = { forgotten: 0, deleted: 0 };
|
|
2521
|
+
const { ctx, signal, journalBindingKey, isCurrent: preparedCurrent } = input;
|
|
2522
|
+
const transport = captureTransport ? captureTransport(ctx) : () => true;
|
|
2523
|
+
const cap = transport && captureTemporaryThreadAuthority(ctx, () => !signal.aborted && preparedCurrent() && transport() &&
|
|
2524
|
+
deps.hasWorkspaceRestoreAuthority?.() === true);
|
|
2525
|
+
const threads = deps.threadStore;
|
|
2526
|
+
if (!cap?.isCurrent() || cap.journalBindingKey !== journalBindingKey || !threads || !deps.runWorkspaceOperation) return result;
|
|
2527
|
+
try {
|
|
2528
|
+
await deps.runWorkspaceOperation({ operationId: `new-world-${randomBytes(16).toString("hex")}`,
|
|
2529
|
+
operationKind: "workspace.forget-previous-world", scopes: [{ kind: "profile" }] }, async () => {
|
|
2530
|
+
if (!cap.isCurrent()) return;
|
|
2531
|
+
await threads.load();
|
|
2532
|
+
if (!cap.isCurrent()) return;
|
|
2533
|
+
const self = cap.authority.executor.instanceId;
|
|
2534
|
+
const entries = cap.store.listTemporaryThreads();
|
|
2535
|
+
const preserved = entries.filter(entry => entry.operatorUserId === cap.operatorUserId && entry.phase === "created" &&
|
|
2536
|
+
cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" &&
|
|
2537
|
+
Threads.getTelegramTemporaryThreadInputs(entry).some(source => source.journalBindingKey === journalBindingKey &&
|
|
2538
|
+
source.updateIds.some(id => input.routingSourceIds?.includes(id)))).map(entry => entry.token);
|
|
2539
|
+
const disposable = entries.flatMap(entry => entry.operatorUserId === cap.operatorUserId && !preserved.includes(entry.token) &&
|
|
2540
|
+
entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
|
|
2541
|
+
cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
|
|
2542
|
+
const forgotten = cap.store.forgetPreviousWorld(cap.authority, preserved);
|
|
2543
|
+
if (!forgotten) return;
|
|
2544
|
+
result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
|
|
2545
|
+
for (const entry of disposable) {
|
|
2546
|
+
const target = entry.target!;
|
|
2547
|
+
if (!cap.isCurrent() || !deps.callApi) break;
|
|
2548
|
+
await threads.load();
|
|
2549
|
+
const same = (value: { chatId: number; threadId?: number } | undefined) => value?.chatId === target.chatId && value.threadId === target.threadId;
|
|
2550
|
+
if (!cap.isCurrent() || threads.listWorkspaceBindings().some(value => same(value.target)) ||
|
|
2551
|
+
threads.list().some(record => record.status === "active" && same(record.target)) ||
|
|
2552
|
+
cap.store.listTemporaryThreads().some(entry => same(entry.target)) ||
|
|
2553
|
+
cap.store.list().some(({ request }) => same(request.target) || same(request.binding.target))) continue;
|
|
2554
|
+
try {
|
|
2555
|
+
const required = [...new Set([cap.journalBindingKey, entry.source.journalBindingKey,
|
|
2556
|
+
...Threads.getTelegramTemporaryThreadInputs(entry).map(input => input.journalBindingKey)])];
|
|
2557
|
+
if (deps.inspectTemporaryThreadSources?.(target, required)?.length !== 0 || !cap.isCurrent()) continue;
|
|
2558
|
+
// Exactly one attempt; the entry is already forgotten, so failure or silence leaves the tab without retry.
|
|
2559
|
+
if (await deps.callApi<boolean>("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId },
|
|
2560
|
+
{ maxAttempts: 1, retrySafety: "non-idempotent" }) === true) result.deleted++;
|
|
2561
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" }); }
|
|
2562
|
+
}
|
|
2563
|
+
// Same-instance session replacement may have lost a metadata-only timer after the source was already spent.
|
|
2564
|
+
// Reconstruct from retained tab/source proof under fresh authority, never from a prompt body or an issued delete.
|
|
2565
|
+
for (const entry of cap.store.listTemporaryThreads()) {
|
|
2566
|
+
if (!cap.isCurrent()) break;
|
|
2567
|
+
if (entry.phase !== "created" || !entry.target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
|
|
2568
|
+
entry.source.journalBindingKey !== journalBindingKey) continue;
|
|
2569
|
+
if (Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group)))
|
|
2570
|
+
scheduleTemporaryThreadCleanup(entry.target, ctx, true);
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-forget" }); }
|
|
2574
|
+
return result;
|
|
2575
|
+
};
|
|
2576
|
+
/** Historical temporary sources await the cold policy; warm bound/unknown sources never fall into ordinary dispatch. */
|
|
2577
|
+
const isRecordedTemporaryThreadInputHeld = (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal, historical = false): boolean => {
|
|
2578
|
+
const store = deps.getWorkspaceRestoreStore?.(), binding = deps.getAdmissionJournalBinding?.();
|
|
2579
|
+
if (!store || !binding) return false;
|
|
2580
|
+
const members = store.listTemporaryThreads().filter(temporary =>
|
|
2581
|
+
Threads.getTelegramTemporaryThreadInputs(temporary).some(input => input.journalBindingKey === binding && input.updateIds.includes(entry.updateId)));
|
|
2582
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => !signal.aborted);
|
|
2583
|
+
return members.some(temporary => {
|
|
2584
|
+
if (!cap?.isCurrent()) return true;
|
|
2585
|
+
const observation = store.inspectTemporaryThreadTarget(temporary, cap.authority);
|
|
2586
|
+
return historical || !observation || observation.kind !== "temporary";
|
|
2587
|
+
});
|
|
2588
|
+
};
|
|
2589
|
+
const shouldHoldPendingInput = async (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal): Promise<boolean> => {
|
|
2590
|
+
const binding = deps.getAdmissionJournalBinding?.();
|
|
2591
|
+
const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true && deps.getAdmissionJournalBinding?.() === binding;
|
|
2592
|
+
if (!current()) throw new Error("Temporary Thread source generation ended.");
|
|
2593
|
+
const held = isRecordedTemporaryThreadInputHeld(entry, ctx, signal);
|
|
2594
|
+
if (!current()) throw new Error("Temporary Thread source authority changed.");
|
|
2595
|
+
return held;
|
|
2596
|
+
};
|
|
2597
|
+
const shouldReviewHistoricalInput = async (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal): Promise<boolean | "retain"> => {
|
|
2598
|
+
if (signal.aborted) throw new Error("Historical routing generation ended.");
|
|
2599
|
+
const message = entry.update.message as Partial<TMessage> | undefined;
|
|
2600
|
+
const unsupported = !reviewMessage(entry);
|
|
2601
|
+
const retainable = unsupported && entry.state === "pending" && !entry.preApprovalExcluded && !entry.inputClaim && !entry.inputProvenance &&
|
|
2602
|
+
!entry.queueOwner && !entry.queueReceiptId && !entry.queueHandoff && !entry.failure &&
|
|
2603
|
+
Object.keys(entry.update).every(key => key === "update_id" || key === "message") &&
|
|
2604
|
+
message?.chat?.type === "private" && Number.isSafeInteger(message.chat.id) &&
|
|
2605
|
+
Number.isSafeInteger(message.from?.id) && message.from?.is_bot === false &&
|
|
2606
|
+
Number.isSafeInteger(message.message_id) && message.message_id! > 0 &&
|
|
2607
|
+
Number.isSafeInteger(message.message_thread_id) && message.message_thread_id! > 0 &&
|
|
2608
|
+
!Reflect.has(message, "pi_telegram_source_update_id") && !Reflect.has(message, "business_connection_id");
|
|
2609
|
+
const temporary = isRecordedTemporaryThreadInputHeld(entry, ctx, signal, true);
|
|
2610
|
+
if (!retainable && temporary) return true;
|
|
2611
|
+
if (!retainable && unsupported) return false;
|
|
2612
|
+
if (signal.aborted) throw new Error("Historical routing generation ended.");
|
|
2613
|
+
const epoch = deps.getCurrentLeaderEpoch?.();
|
|
2614
|
+
const binding = deps.getAdmissionJournalBinding?.();
|
|
2615
|
+
const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true &&
|
|
2616
|
+
epoch !== undefined && deps.getCurrentLeaderEpoch?.() === epoch && !!binding && deps.getAdmissionJournalBinding?.() === binding;
|
|
2617
|
+
if (!current() || !deps.threadStore) throw new Error("Historical routing authority is unavailable.");
|
|
2618
|
+
await deps.threadStore.load();
|
|
2619
|
+
if (!current()) throw new Error("Historical routing authority changed.");
|
|
2620
|
+
// Raw unsupported startup originals stay protected even when a bound target outlives forgotten Restore membership.
|
|
2621
|
+
// Holding protects evidence, not sender authorization; owner-bearing sources keep their existing paths.
|
|
2622
|
+
return retainable ? "retain" : needsHistoricalReview(message!);
|
|
2623
|
+
};
|
|
2624
|
+
const recoveryPreview = (source: RecoverySource, owner: number, chatId: number, binding: string): string | undefined => {
|
|
2625
|
+
const entry = source.original;
|
|
2626
|
+
const message = reviewMessage(entry);
|
|
2627
|
+
if (!message || message.chat!.id !== chatId || message.from!.id !== owner) return undefined;
|
|
2628
|
+
for (const pending of pendingUnboundReroutes.values()) {
|
|
2629
|
+
if (pending.abandonment?.journalBindingKey === binding &&
|
|
2630
|
+
Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(entry.updateId) &&
|
|
2631
|
+
// Revoked carriers describe a past attempt, not a live selection veto.
|
|
2632
|
+
// Current claim/entry authority still comes from the worker and journal CAS.
|
|
2633
|
+
pending.messages.some(value => Updates.getTelegramUpdateExecutionFence(value)?.signal.aborted !== true) &&
|
|
2634
|
+
(pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.foreignRetry || pending.cleanup ||
|
|
2635
|
+
pending.abandonment.running)) return undefined;
|
|
2636
|
+
}
|
|
2637
|
+
const text = Array.from(message.text!.replace(/\s+/gu, " ").trim());
|
|
2638
|
+
return escapeHtml(text.slice(0, 80).join("")) + (text.length > 80 ? "…" : "");
|
|
2639
|
+
};
|
|
2640
|
+
const expireRoutingInput = async (source: Updates.TelegramRoutingInputExpirySource, ctx: TContext, signal: AbortSignal): Promise<void> => {
|
|
2641
|
+
const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
2642
|
+
const message = source.original.update.message as Partial<TMessage> | undefined;
|
|
2643
|
+
const runtimeCurrent = () => !signal.aborted && operator !== undefined && epoch !== undefined &&
|
|
2644
|
+
deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
|
|
2645
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
|
|
2646
|
+
source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
|
|
2647
|
+
const current = () => runtimeCurrent() && source.isCurrent();
|
|
2648
|
+
if (!current() || !deps.runWorkspaceOperation) return;
|
|
2649
|
+
await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
|
|
2650
|
+
operationKind: "workspace.expire-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
|
|
2651
|
+
if (!current()) return;
|
|
2652
|
+
const restores = deps.getWorkspaceRestoreStore?.();
|
|
2653
|
+
if (deps.getWorkspaceRestoreStore && !restores) return;
|
|
2654
|
+
const temporary = restores?.listTemporaryThreads().find(entry => Threads.getTelegramTemporaryThreadInputs(entry).some(input =>
|
|
2655
|
+
input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId)));
|
|
2656
|
+
const result = source.expire();
|
|
2657
|
+
if (!result) return;
|
|
2658
|
+
// Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
|
|
2659
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2660
|
+
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
|
|
2661
|
+
removePendingReroute(id);
|
|
2662
|
+
if (signal.aborted || deps.isContextActive?.(ctx) !== true || deps.configStore.getAllowedUserId() !== operator ||
|
|
2663
|
+
deps.getCurrentLeaderEpoch?.() !== epoch || deps.getAdmissionJournalBinding?.() !== source.journalBindingKey) continue;
|
|
2664
|
+
if (deps.editInteractiveMessage && pending.chooserMessageId !== undefined) {
|
|
2665
|
+
try { await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
|
|
2666
|
+
`<b>${TELEGRAM_ROUTING_CHOICE_EXPIRED}</b>`, "html", { inline_keyboard: [] }); }
|
|
2667
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" }); }
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
// The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
|
|
2671
|
+
// never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
|
|
2672
|
+
if (temporary?.target && runtimeCurrent()) scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
|
|
2673
|
+
});
|
|
2674
|
+
};
|
|
2675
|
+
const renderCancellationReview = async (view: CancellationReview, isCurrent: () => boolean): Promise<void> => {
|
|
2676
|
+
if (!isCurrent()) return;
|
|
2677
|
+
const data = (action: string) => `${TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX}${view.id}:${action}`;
|
|
2678
|
+
const rows: Menu.TelegramReplyMarkup["inline_keyboard"] = [[{ text: "⬆️ Main menu", callback_data: "menu:back" }]];
|
|
2679
|
+
const lines = ["<b>☑️ Review pending cancellations:</b>", "",
|
|
2680
|
+
"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."];
|
|
2681
|
+
if (!view.sources.length) lines.push("", "No supported pending cancellations on this page.");
|
|
2682
|
+
for (const [index, source] of view.sources.entries()) {
|
|
2683
|
+
lines.push("", `<b>${index + 1}. ${source.result ? "⛔️ Routing cancelled." : "Protected input"}</b>`, source.preview);
|
|
2684
|
+
if (!source.result) rows.push([{ text: `❌ Finish cancellation ${index + 1}`, callback_data: data(`retry:${index}`) }]);
|
|
2685
|
+
}
|
|
2686
|
+
if (view.unsupported) lines.push("", "Other protected inputs are not available in this view and remain untouched.");
|
|
2687
|
+
if (view.nextAfterUpdateId !== undefined) rows.push([{ text: "🟣 More", callback_data: data("more") }]);
|
|
2688
|
+
rows.push([{ text: "🔄 Refresh", callback_data: data("refresh") }]);
|
|
2689
|
+
await deps.editInteractiveMessage!(view.target.chatId, view.messageId, lines.join("\n"), "html", { inline_keyboard: rows });
|
|
2690
|
+
};
|
|
2691
|
+
const handleCancellationReview = async (query: TCallbackQuery, ctx: TContext): Promise<boolean> => {
|
|
2692
|
+
const matches = (view: CancellationReview) => query.message?.message_id === view.messageId &&
|
|
2693
|
+
query.message.chat.id === view.target.chatId &&
|
|
2694
|
+
(query.message.message_thread_id === undefined || query.message.message_thread_id === view.target.threadId);
|
|
2695
|
+
if (query.data?.startsWith(TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX)) {
|
|
2696
|
+
await deps.answerCallbackQuery(query.id, "🚫 This control is no longer available.");
|
|
2697
|
+
return true;
|
|
2698
|
+
}
|
|
2699
|
+
const ownsReview = query.data?.startsWith(TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX) === true;
|
|
2700
|
+
const prefix = TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX;
|
|
2701
|
+
if (!ownsReview && cancellationReview && matches(cancellationReview)) {
|
|
2702
|
+
if (cancellationReview.running && cancellationReview.isCurrent()) {
|
|
2703
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
|
|
2704
|
+
return true;
|
|
2705
|
+
}
|
|
2706
|
+
cancellationReview = undefined;
|
|
2707
|
+
return false;
|
|
2708
|
+
}
|
|
2709
|
+
if (!ownsReview) return false;
|
|
2710
|
+
const unavailable = async () => { await deps.answerCallbackQuery(query.id, "🚫 Cancellation review expired or unavailable. Open Status again."); return true; };
|
|
2711
|
+
const execution = Updates.getTelegramUpdateExecutionFence(query);
|
|
2712
|
+
const chatId = query.message?.chat.id;
|
|
2713
|
+
const messageId = query.message?.message_id;
|
|
2714
|
+
if (execution?.isCurrent() !== true || deps.isContextActive?.(ctx) !== true ||
|
|
2715
|
+
!deps.editInteractiveMessage || !deps.runWorkspaceOperation || query.message?.chat.type !== "private" ||
|
|
2716
|
+
typeof chatId !== "number" || !Number.isSafeInteger(chatId) ||
|
|
2717
|
+
typeof messageId !== "number" || !Number.isSafeInteger(messageId) || messageId <= 0) return unavailable();
|
|
2718
|
+
const owner = deps.configStore.getAllowedUserId();
|
|
2719
|
+
const journalBindingKey = deps.getAdmissionJournalBinding?.();
|
|
2720
|
+
const epoch = deps.getCurrentLeaderEpoch?.();
|
|
2721
|
+
if (owner === undefined || query.from.id !== owner || query.from.is_bot || !journalBindingKey || epoch === undefined) return unavailable();
|
|
2722
|
+
const current = () => execution.isCurrent() && deps.isContextActive?.(ctx) === true &&
|
|
2723
|
+
deps.configStore.getAllowedUserId() === owner && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
|
|
2724
|
+
deps.getCurrentLeaderEpoch?.() === epoch;
|
|
2725
|
+
const parsed = query.data!.slice(prefix.length).match(/^([a-z0-9]+):(retry:(\d+)|refresh|more)$/);
|
|
2726
|
+
const prior = cancellationReview;
|
|
2727
|
+
const open = query.data === `${prefix}open`;
|
|
2728
|
+
if (!open && (!parsed || !prior || parsed[1] !== prior.id || !matches(prior) || !prior.isCurrent())) return unavailable();
|
|
2729
|
+
if (prior?.running && prior.isCurrent()) {
|
|
2730
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
|
|
2731
|
+
return true;
|
|
2732
|
+
}
|
|
2733
|
+
let view = prior;
|
|
2734
|
+
let selected: CancellationReview["sources"][number] | undefined;
|
|
2735
|
+
try {
|
|
2736
|
+
if (open || parsed?.[2] === "refresh" || parsed?.[2] === "more") {
|
|
2737
|
+
if (parsed?.[2] === "more" && prior?.nextAfterUpdateId === undefined) return unavailable();
|
|
2738
|
+
const next: CancellationReview = { id: randomBytes(16).toString("hex"),
|
|
2739
|
+
target: { chatId, ...(query.message.message_thread_id !== undefined ? { threadId: query.message.message_thread_id } : {}) },
|
|
2740
|
+
messageId, ownerUserId: owner, journalBindingKey, sources: [], unsupported: false,
|
|
2741
|
+
isCurrent: () => cancellationReview === next && current() };
|
|
2742
|
+
cancellationReview = view = next;
|
|
2743
|
+
const page = Updates.inspectTelegramAbandoningUpdates(query, { journalBindingKey, isCurrent: next.isCurrent,
|
|
2744
|
+
...(parsed?.[2] === "more" ? { afterUpdateId: prior!.nextAfterUpdateId } : {}) });
|
|
2745
|
+
if (!page) { if (cancellationReview === next) cancellationReview = undefined; return unavailable(); }
|
|
2746
|
+
for (const source of page.sources) {
|
|
2747
|
+
const preview = recoveryPreview(source, owner, next.target.chatId, journalBindingKey);
|
|
2748
|
+
if (preview === undefined) { next.unsupported = true; continue; }
|
|
2749
|
+
if (next.sources.length === 5) { next.nextAfterUpdateId = next.sources.at(-1)!.original.updateId; break; }
|
|
2750
|
+
next.sources.push({ ...source, preview });
|
|
2751
|
+
}
|
|
2752
|
+
next.nextAfterUpdateId ??= page.nextAfterUpdateId;
|
|
2753
|
+
next.running = true;
|
|
2754
|
+
await renderCancellationReview(next, next.isCurrent);
|
|
2755
|
+
if (next.isCurrent()) await deps.answerCallbackQuery(query.id);
|
|
2756
|
+
} else {
|
|
2757
|
+
if (!view || !parsed?.[3] || !parsed[2].startsWith("retry:")) return unavailable();
|
|
2758
|
+
selected = view.sources[Number(parsed[3])];
|
|
2759
|
+
if (!selected) return unavailable();
|
|
2760
|
+
const activeView = view;
|
|
2761
|
+
const source = selected;
|
|
2762
|
+
const isCurrent = () => current() && activeView.isCurrent() &&
|
|
2763
|
+
recoveryPreview(source, owner, activeView.target.chatId, journalBindingKey) !== undefined;
|
|
2764
|
+
view.running = true;
|
|
2765
|
+
await deps.runWorkspaceOperation({ operationId: `workspace-cancellation-recovery:${query.id}`,
|
|
2766
|
+
operationKind: "workspace.recover-unbound-cancellation", scopes: [{ kind: "profile" }] }, async () => {
|
|
2767
|
+
if (!isCurrent()) return;
|
|
2768
|
+
source.result ??= source.retry({ operatorAuthorityId: `telegram-owner:${owner}`, isCurrent });
|
|
2769
|
+
if (!source.result) { await unavailable(); return; }
|
|
2770
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2771
|
+
if (pending.abandonment?.ownerUserId !== owner || pending.abandonment.journalBindingKey !== journalBindingKey ||
|
|
2772
|
+
!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
|
|
2773
|
+
pending.abandonment.result = source.result;
|
|
2774
|
+
pending.abandonment.attempted = true;
|
|
2775
|
+
try { await retireCancellationChooser(id, pending, isCurrent); }
|
|
2776
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "recovered-chooser-cleanup" }); }
|
|
2777
|
+
}
|
|
2778
|
+
await renderCancellationReview(activeView, isCurrent);
|
|
2779
|
+
if (isCurrent()) await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
} catch (error) {
|
|
2783
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation-recovery" });
|
|
2784
|
+
if (current()) await deps.answerCallbackQuery(query.id, selected?.result
|
|
2785
|
+
? "⚠️ Routing cancelled; view update failed. Retry the same button."
|
|
2786
|
+
: "⚠️ Recovery incomplete; inputs stay protected. Check /telegram-status --debug.");
|
|
2787
|
+
} finally { if (view) view.running = false; }
|
|
2788
|
+
return true;
|
|
2789
|
+
};
|
|
2790
|
+
const handleUnboundRerouteCancelCallback = async (query: TCallbackQuery, ctx: TContext): Promise<boolean> => {
|
|
2791
|
+
if (!query.data?.startsWith(TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX)) return false;
|
|
2792
|
+
const rerouteId = query.data.match(/^reroutecancel:([a-z0-9]+)$/)?.[1];
|
|
2793
|
+
const pending = rerouteId ? pendingUnboundReroutes.get(rerouteId) : undefined;
|
|
2794
|
+
const cancellation = pending?.abandonment;
|
|
2795
|
+
const source = pending?.messages[0];
|
|
2796
|
+
const execution = Updates.getTelegramUpdateExecutionFence(query);
|
|
2797
|
+
const sourceExecution = Updates.getTelegramUpdateExecutionFence(source);
|
|
2798
|
+
const isAuthorityCurrent = () => !!cancellation && execution?.isCurrent() === true &&
|
|
2799
|
+
sourceExecution?.signal.aborted === false && deps.isContextActive?.(ctx) === true &&
|
|
2800
|
+
query.from.id === cancellation.ownerUserId && !query.from.is_bot &&
|
|
2801
|
+
deps.configStore.getAllowedUserId() === cancellation.ownerUserId &&
|
|
2802
|
+
deps.getAdmissionJournalBinding?.() === cancellation.journalBindingKey &&
|
|
2803
|
+
deps.getCurrentLeaderEpoch?.() === cancellation.leaderEpoch;
|
|
2804
|
+
const isCurrent = () => isAuthorityCurrent() && !!pending && !!cancellation && !!source &&
|
|
2805
|
+
pendingUnboundReroutes.get(rerouteId!) === pending && pending.abandonment === cancellation &&
|
|
2806
|
+
matchesRerouteChooser(pending, query) && query.message?.chat.type === "private" &&
|
|
2807
|
+
source.from?.id === cancellation.ownerUserId &&
|
|
2808
|
+
pending.messages.length === 1 &&
|
|
2809
|
+
!pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching &&
|
|
2810
|
+
!pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage;
|
|
2811
|
+
if (!isCurrent() || !deps.runWorkspaceOperation || !deps.editInteractiveMessage) {
|
|
2812
|
+
// A chooser from a previous process (new world) or an expired one simply reads as expired.
|
|
2813
|
+
await deps.answerCallbackQuery(query.id, pending ? "🚫 Message route is unavailable for cancellation." : TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
2814
|
+
return true;
|
|
2815
|
+
}
|
|
2816
|
+
if (cancellation!.running) {
|
|
2817
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is already in progress.");
|
|
2818
|
+
return true;
|
|
2819
|
+
}
|
|
2820
|
+
cancellation!.running = true;
|
|
2821
|
+
try {
|
|
2822
|
+
await deps.runWorkspaceOperation({ operationId: `workspace-reroute-cancel:${query.id}`,
|
|
2823
|
+
operationKind: "workspace.cancel-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
|
|
2824
|
+
if (!isCurrent()) return;
|
|
2825
|
+
if (!cancellation!.result) {
|
|
2826
|
+
const wasAttempted = cancellation!.attempted;
|
|
2827
|
+
cancellation!.attempted = true;
|
|
2828
|
+
const result = Updates.abandonTelegramDeferredUpdate(source, {
|
|
2829
|
+
operatorAuthorityId: `telegram-owner:${cancellation!.ownerUserId}`, isCurrent,
|
|
2830
|
+
});
|
|
2831
|
+
if (!result) {
|
|
2832
|
+
cancellation!.attempted = wasAttempted;
|
|
2833
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message routing cannot be cancelled now.");
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
cancellation!.result = result;
|
|
2837
|
+
}
|
|
2838
|
+
if (!isCurrent()) return;
|
|
2839
|
+
if (!recordCancelledTemporaryThreadInput(pending!, ctx, isCurrent)) {
|
|
2840
|
+
if (isAuthorityCurrent()) await deps.answerCallbackQuery(query.id, "⚠️ Routing cancelled; Thread cleanup remains protected. Retry Cancel routing.");
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
if (await retireCancellationChooser(rerouteId!, pending!, isCurrent) && isAuthorityCurrent()) {
|
|
2844
|
+
scheduleTemporaryThreadCleanup(pending!.sourceTarget, ctx);
|
|
2845
|
+
await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
|
|
2846
|
+
}
|
|
2847
|
+
});
|
|
2848
|
+
} catch (error) {
|
|
2849
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation", rerouteId });
|
|
2850
|
+
if (isCurrent()) await deps.answerCallbackQuery(query.id,
|
|
2851
|
+
cancellation!.result ? "⚠️ Routing cancelled; chooser update failed. Retry Cancel routing." :
|
|
2852
|
+
"⚠️ Cancellation incomplete. Retry Cancel routing.");
|
|
2853
|
+
} finally { cancellation!.running = false; }
|
|
2854
|
+
return true;
|
|
2855
|
+
};
|
|
2856
|
+
const withPendingRerouteSelection = async (
|
|
2857
|
+
rerouteId: string, query: TCallbackQuery, operation: () => Promise<boolean>,
|
|
2858
|
+
): Promise<boolean> => {
|
|
2859
|
+
Updates.assertTelegramUpdateExecutionCurrent(query);
|
|
2860
|
+
const pending = pendingUnboundReroutes.get(rerouteId);
|
|
2861
|
+
if (pending?.foreignForwardIssued && matchesRerouteChooser(pending, query)) {
|
|
2862
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
2863
|
+
return true;
|
|
2864
|
+
}
|
|
2865
|
+
if (pending && matchesRerouteChooser(pending, query) &&
|
|
2866
|
+
(pending.abandonment?.attempted || pending.abandonment?.running || pending.messages.some(message =>
|
|
2867
|
+
Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === false))) {
|
|
2868
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
2869
|
+
return true;
|
|
2870
|
+
}
|
|
2871
|
+
const releases: Array<() => void> = [];
|
|
2872
|
+
try {
|
|
2873
|
+
if (pending && matchesRerouteChooser(pending, query)) {
|
|
2874
|
+
try {
|
|
2875
|
+
for (const message of pending.messages) releases.push(Updates.acquireTelegramUpdateRouting(message));
|
|
2876
|
+
} catch (error) {
|
|
2877
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
|
|
2878
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
|
|
2879
|
+
return true;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
return await operation();
|
|
2883
|
+
} finally { for (const release of releases) release(); }
|
|
2884
|
+
};
|
|
2885
|
+
const executeUnboundRerouteRestoreMenuCallback = async (
|
|
1325
2886
|
query: TCallbackQuery,
|
|
1326
2887
|
_ctx: TContext,
|
|
1327
2888
|
): Promise<boolean> => {
|
|
@@ -1340,12 +2901,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1340
2901
|
!deps.threadStore ||
|
|
1341
2902
|
!pending ||
|
|
1342
2903
|
!matchesRerouteChooser(pending, query) ||
|
|
2904
|
+
(parsed.root && pending.rootChooserText === undefined) ||
|
|
1343
2905
|
expirePendingCommand(parsed.rerouteId, pending)
|
|
1344
2906
|
) {
|
|
1345
|
-
await deps.answerCallbackQuery(query.id,
|
|
2907
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
1346
2908
|
return true;
|
|
1347
2909
|
}
|
|
1348
|
-
if (pending.sourceTarget.threadId === undefined) {
|
|
2910
|
+
if (parsed.restore && pending.sourceTarget.threadId === undefined) {
|
|
1349
2911
|
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
1350
2912
|
return true;
|
|
1351
2913
|
}
|
|
@@ -1355,23 +2917,36 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1355
2917
|
deps.threadStore.list(),
|
|
1356
2918
|
deps.getLiveThreadTargets?.(),
|
|
1357
2919
|
);
|
|
1358
|
-
const replyMarkup =
|
|
1359
|
-
parsed.rerouteId,
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
2920
|
+
const replyMarkup = parsed.root
|
|
2921
|
+
? buildTelegramUnboundRerouteChooserMarkup(parsed.rerouteId, activeRecords, {
|
|
2922
|
+
canRestore: pending.sourceTarget.threadId !== undefined, canCancel: !!pending.abandonment,
|
|
2923
|
+
getDisplayTitle: deps.getDisplayTitle,
|
|
2924
|
+
})
|
|
2925
|
+
: parsed.restore
|
|
2926
|
+
? buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle)
|
|
2927
|
+
: { inline_keyboard: activeRecords.map(record => [{
|
|
2928
|
+
text: `↪️ ${getTelegramThreadRecordLabel(record, deps.getDisplayTitle)}`,
|
|
2929
|
+
callback_data: formatTelegramUnboundRerouteCallbackData(parsed.rerouteId, record.target.threadId),
|
|
2930
|
+
}]) };
|
|
2931
|
+
const chooserText = parsed.root
|
|
2932
|
+
? pending.rootChooserText!
|
|
2933
|
+
: parsed.restore ? formatTelegramUnboundRerouteRestoreChooserText()
|
|
2934
|
+
: "<b>🧵 Reroute:</b>\n\nChoose the thread that should handle this input:";
|
|
2935
|
+
if (!parsed.root) replyMarkup.inline_keyboard.unshift([{
|
|
2936
|
+
text: "⬆️ Back", callback_data: `${TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX}${parsed.rerouteId}`,
|
|
2937
|
+
}]);
|
|
1363
2938
|
if (deps.editInteractiveMessage) {
|
|
1364
2939
|
await deps.editInteractiveMessage(
|
|
1365
2940
|
chatId,
|
|
1366
2941
|
messageId,
|
|
1367
|
-
|
|
2942
|
+
chooserText,
|
|
1368
2943
|
"html",
|
|
1369
2944
|
replyMarkup,
|
|
1370
2945
|
);
|
|
1371
2946
|
} else if (deps.sendInteractiveMessage) {
|
|
1372
2947
|
const chooserId = await deps.sendInteractiveMessage(
|
|
1373
2948
|
chatId,
|
|
1374
|
-
|
|
2949
|
+
chooserText,
|
|
1375
2950
|
"html",
|
|
1376
2951
|
replyMarkup,
|
|
1377
2952
|
{ target: pending.sourceTarget, replyToMessageId: messageId },
|
|
@@ -1380,7 +2955,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1380
2955
|
rememberRerouteChooser(parsed.rerouteId, chooserId);
|
|
1381
2956
|
}
|
|
1382
2957
|
assertExecutionCurrent();
|
|
1383
|
-
await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
|
|
2958
|
+
await deps.answerCallbackQuery(query.id, parsed.root ? "Choose routing mode." : parsed.restore ? "Choose instance to restore." : "Choose target thread.");
|
|
1384
2959
|
return true;
|
|
1385
2960
|
};
|
|
1386
2961
|
const executeUnboundRerouteCallbackOperation = async (
|
|
@@ -1396,14 +2971,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1396
2971
|
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1397
2972
|
if (typeof chatId !== "number" || !deps.threadStore || !pending ||
|
|
1398
2973
|
!matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
|
|
1399
|
-
await deps.answerCallbackQuery(query.id,
|
|
2974
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
1400
2975
|
return true;
|
|
1401
2976
|
}
|
|
1402
2977
|
await deps.threadStore.load();
|
|
1403
2978
|
assertExecutionCurrent();
|
|
1404
2979
|
if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
|
|
1405
2980
|
expirePendingCommand(parsed.rerouteId, pending)) {
|
|
1406
|
-
await deps.answerCallbackQuery(query.id,
|
|
2981
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
1407
2982
|
return true;
|
|
1408
2983
|
}
|
|
1409
2984
|
if (pending.finalizeMessage) {
|
|
@@ -1416,6 +2991,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1416
2991
|
);
|
|
1417
2992
|
return true;
|
|
1418
2993
|
}
|
|
2994
|
+
if (pending.workspaceRestore && (!restoreWorkspace || !parsed.useNewSlot ||
|
|
2995
|
+
pending.workspaceRestore.record.target.threadId !== parsed.threadId)) {
|
|
2996
|
+
await deps.answerCallbackQuery(query.id, "🚫 Restore is already selected. Use the original Restore choice.");
|
|
2997
|
+
return true;
|
|
2998
|
+
}
|
|
1419
2999
|
if (pending.foreignRetry) {
|
|
1420
3000
|
const retry = pending.foreignRetry;
|
|
1421
3001
|
const allForwarded = await forwardPendingRerouteMessages(
|
|
@@ -1428,7 +3008,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1428
3008
|
if (!allForwarded) {
|
|
1429
3009
|
await deps.answerCallbackQuery(
|
|
1430
3010
|
query.id,
|
|
1431
|
-
"
|
|
3011
|
+
pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
|
|
3012
|
+
"Target thread is unavailable; retrying will send only remaining messages.",
|
|
1432
3013
|
);
|
|
1433
3014
|
return true;
|
|
1434
3015
|
}
|
|
@@ -1457,7 +3038,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1457
3038
|
);
|
|
1458
3039
|
return true;
|
|
1459
3040
|
}
|
|
1460
|
-
|
|
3041
|
+
if (!pending.workspaceRestore && isTemporaryReroute(pending) && isTemporaryThreadForwardIssued(pending)) {
|
|
3042
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
3043
|
+
return true;
|
|
3044
|
+
}
|
|
3045
|
+
const record = pending.workspaceRestore?.record ?? getTelegramRoutableThreadRecords(
|
|
1461
3046
|
deps.threadStore.list(),
|
|
1462
3047
|
deps.getLiveThreadTargets?.(),
|
|
1463
3048
|
).find(
|
|
@@ -1487,137 +3072,92 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1487
3072
|
await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
|
|
1488
3073
|
return true;
|
|
1489
3074
|
}
|
|
1490
|
-
|
|
3075
|
+
// A source's own temporary tab is its Restore destination; only other protection refuses it.
|
|
3076
|
+
if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
|
|
3077
|
+
isRerouteTargetProtected(sourceTarget, undefined, pending.temporaryThread ?? findCreatedTemporaryThread(sourceTarget), pending)) {
|
|
1491
3078
|
await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
|
|
1492
3079
|
return true;
|
|
1493
3080
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
pending.
|
|
3081
|
+
// Restore has one controller. Without it or its negotiated authority, refuse before selection so the chooser keeps
|
|
3082
|
+
// its other routes and Cancel.
|
|
3083
|
+
if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
|
|
3084
|
+
(!restoreWorkspace || deps.hasWorkspaceRestoreAuthority?.() !== true)) {
|
|
3085
|
+
await deps.answerCallbackQuery(query.id, "🚫 Thread restore is unavailable right now. The message stays pending; choose another route or cancel.");
|
|
3086
|
+
return true;
|
|
3087
|
+
}
|
|
1497
3088
|
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1498
3089
|
const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
|
|
1499
|
-
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
if (parsed.useNewSlot && !isCurrentLeaderRecord) {
|
|
1505
|
-
if (!record.slot || !/^[A-Z]$/u.test(record.slot)) {
|
|
1506
|
-
await deps.answerCallbackQuery(
|
|
1507
|
-
query.id,
|
|
1508
|
-
"Follower thread slot authority is unavailable.",
|
|
1509
|
-
);
|
|
1510
|
-
return true;
|
|
1511
|
-
}
|
|
1512
|
-
if (
|
|
1513
|
-
!sourceTarget ||
|
|
1514
|
-
!deps.replaceFollowerThreadTarget ||
|
|
1515
|
-
!deps.foreignOwnedUpdateForwarder?.forwardMessage
|
|
1516
|
-
) {
|
|
1517
|
-
await deps.answerCallbackQuery(
|
|
1518
|
-
query.id,
|
|
1519
|
-
"Follower thread restore is not available yet.",
|
|
1520
|
-
);
|
|
1521
|
-
return true;
|
|
1522
|
-
}
|
|
1523
|
-
assertExecutionCurrent();
|
|
1524
|
-
const replaced = await deps.replaceFollowerThreadTarget({
|
|
1525
|
-
record,
|
|
1526
|
-
target: sourceTarget,
|
|
1527
|
-
oldTarget: record.target,
|
|
1528
|
-
});
|
|
1529
|
-
assertExecutionCurrent();
|
|
1530
|
-
if (!replaced) {
|
|
1531
|
-
await deps.answerCallbackQuery(
|
|
1532
|
-
query.id,
|
|
1533
|
-
"Follower thread is unavailable.",
|
|
1534
|
-
);
|
|
1535
|
-
return true;
|
|
1536
|
-
}
|
|
1537
|
-
const nowMs = Date.now();
|
|
1538
|
-
const slot = record.slot;
|
|
1539
|
-
const threadName = getRestoredThreadName(record, slot);
|
|
1540
|
-
assertExecutionCurrent();
|
|
1541
|
-
deps.threadStore.markStaleByTarget(
|
|
1542
|
-
record.target,
|
|
1543
|
-
"deleted",
|
|
1544
|
-
"Follower thread was replaced by restore source.",
|
|
1545
|
-
);
|
|
1546
|
-
deps.threadStore.upsert({
|
|
1547
|
-
...record,
|
|
1548
|
-
target: sourceTarget,
|
|
1549
|
-
status: "active",
|
|
1550
|
-
syncStatus: "open",
|
|
1551
|
-
updatedAtMs: nowMs,
|
|
1552
|
-
threadName,
|
|
1553
|
-
lastSyncObservedAtMs: nowMs,
|
|
1554
|
-
lastReconcileAction: "follower-thread-restore",
|
|
1555
|
-
rerouteConfirmedAtMs: nowMs,
|
|
1556
|
-
});
|
|
1557
|
-
await deps.threadStore.persist();
|
|
1558
|
-
assertExecutionCurrent();
|
|
1559
|
-
if (deps.callApi) {
|
|
1560
|
-
try {
|
|
1561
|
-
assertExecutionCurrent();
|
|
1562
|
-
await deps.callApi("editForumTopic", {
|
|
1563
|
-
chat_id: sourceTarget.chatId,
|
|
1564
|
-
message_thread_id: sourceTarget.threadId,
|
|
1565
|
-
name:
|
|
1566
|
-
deps.getDisplayTitle?.(sourceTarget) ??
|
|
1567
|
-
Threads.getTelegramTopicTitleForThreadName(threadName, slot),
|
|
1568
|
-
});
|
|
1569
|
-
assertExecutionCurrent();
|
|
1570
|
-
} catch (renameError) {
|
|
1571
|
-
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
1572
|
-
phase: "follower-topic-reroute-restore-rename",
|
|
1573
|
-
chatId: sourceTarget.chatId,
|
|
1574
|
-
threadId: sourceTarget.threadId,
|
|
1575
|
-
slot: record.slot,
|
|
1576
|
-
});
|
|
1577
|
-
}
|
|
3090
|
+
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(record, leaderProfileKey, currentInstanceId);
|
|
3091
|
+
if (!parsed.useNewSlot && record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord &&
|
|
3092
|
+
!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
3093
|
+
if (pending.routingOperatorUserId === undefined) {
|
|
3094
|
+
pending.destinationSelected = true; pending.selectionAttempted = true; pending.pauseExpiry?.();
|
|
1578
3095
|
}
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
3096
|
+
await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
|
|
3097
|
+
return true;
|
|
3098
|
+
}
|
|
3099
|
+
if (pending.routingOperatorUserId !== undefined && (pending.routingOperatorUserId !== query.from.id ||
|
|
3100
|
+
pending.routingOperatorUserId !== deps.configStore.getAllowedUserId())) {
|
|
3101
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route authority changed.");
|
|
3102
|
+
return true;
|
|
3103
|
+
}
|
|
3104
|
+
// Freeze only a validated actual destination, inside the same admission as its effect. Menu browsing,
|
|
3105
|
+
// unavailable recipients and refused Restore capability never turn a waiting source into a selected grant.
|
|
3106
|
+
try {
|
|
3107
|
+
const release = Updates.acquireTelegramUpdateRouting(pending.messages[0], true,
|
|
3108
|
+
Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages));
|
|
3109
|
+
release();
|
|
3110
|
+
} catch (error) {
|
|
3111
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
|
|
3112
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
|
|
3113
|
+
return true;
|
|
3114
|
+
}
|
|
3115
|
+
pending.destinationSelected = true;
|
|
3116
|
+
pending.selectionAttempted = true;
|
|
3117
|
+
pending.pauseExpiry?.();
|
|
3118
|
+
if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
|
|
3119
|
+
const selection = pending.workspaceRestore ??= { operationId: `restore-${randomBytes(16).toString("hex")}`,
|
|
3120
|
+
record: structuredClone(record), messages: [...pending.messages] };
|
|
3121
|
+
let active = true;
|
|
3122
|
+
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
3123
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
3124
|
+
const admissionScope = deps.getAdmissionScope?.();
|
|
3125
|
+
const journalBinding = deps.getAdmissionJournalBinding?.();
|
|
3126
|
+
const selectionCurrent = (): boolean => {
|
|
3127
|
+
try { assertExecutionCurrent(); } catch { return false; }
|
|
3128
|
+
return ownerUserId === query.from.id && deps.configStore.getAllowedUserId() === ownerUserId &&
|
|
3129
|
+
deps.getCurrentLeaderEpoch?.() === leaderEpoch && deps.getAdmissionScope?.() === admissionScope &&
|
|
3130
|
+
deps.getAdmissionJournalBinding?.() === journalBinding && deps.isContextActive?.(ctx) !== false &&
|
|
3131
|
+
pendingUnboundReroutes.get(parsed.rerouteId) === pending && pending.workspaceRestore === selection;
|
|
1583
3132
|
};
|
|
1584
|
-
const
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
);
|
|
1613
|
-
return true;
|
|
1614
|
-
}
|
|
1615
|
-
await finalizePendingReroute(
|
|
1616
|
-
parsed.rerouteId,
|
|
1617
|
-
pending,
|
|
1618
|
-
query,
|
|
1619
|
-
"Message routed.",
|
|
1620
|
-
);
|
|
3133
|
+
const current = (): boolean => active && selectionCurrent();
|
|
3134
|
+
const assertRestoreCurrent = (): void => {
|
|
3135
|
+
assertExecutionCurrent();
|
|
3136
|
+
if (!current()) throw new Error("Stale Workspace Restore callback.");
|
|
3137
|
+
};
|
|
3138
|
+
try { await restoreWorkspace({ operationId: selection.operationId, record: structuredClone(selection.record),
|
|
3139
|
+
target: { ...sourceTarget }, messages: [...selection.messages], ctx, isCurrent: current,
|
|
3140
|
+
async dispatch(recipient, isRecipientCurrent, recordForwardAcceptance, recordLocalAcceptance) {
|
|
3141
|
+
const assertRecipientCurrent = (): void => {
|
|
3142
|
+
assertRestoreCurrent();
|
|
3143
|
+
if (!isRecipientCurrent()) throw new Error("Workspace Restore recipient authority changed.");
|
|
3144
|
+
};
|
|
3145
|
+
assertRecipientCurrent();
|
|
3146
|
+
if (recipient.kind === "leader") {
|
|
3147
|
+
if (recipient.instanceId !== currentInstanceId) return false;
|
|
3148
|
+
const routed = cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId);
|
|
3149
|
+
const messages = pending.dispatchKind === "command" ? routed.map((message, index) =>
|
|
3150
|
+
Updates.bindTelegramUpdateCompletionAcceptance(message, () => recordLocalAcceptance(pending.messages[index]!))) : routed;
|
|
3151
|
+
await dispatchPendingRerouteMessages(pending, messages, ctx);
|
|
3152
|
+
assertRecipientCurrent();
|
|
3153
|
+
return true;
|
|
3154
|
+
}
|
|
3155
|
+
return forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
|
|
3156
|
+
},
|
|
3157
|
+
}); } finally { active = false; }
|
|
3158
|
+
assertExecutionCurrent();
|
|
3159
|
+
if (!selectionCurrent()) return true;
|
|
3160
|
+
await deps.answerCallbackQuery(query.id, "🚫 Restore is pending; this button will not resend uncertain input.");
|
|
1621
3161
|
return true;
|
|
1622
3162
|
}
|
|
1623
3163
|
if (
|
|
@@ -1625,13 +3165,6 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1625
3165
|
record.instanceId !== currentInstanceId &&
|
|
1626
3166
|
!isCurrentLeaderRecord
|
|
1627
3167
|
) {
|
|
1628
|
-
if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
1629
|
-
await deps.answerCallbackQuery(
|
|
1630
|
-
query.id,
|
|
1631
|
-
"Open that thread and resend the message there.",
|
|
1632
|
-
);
|
|
1633
|
-
return true;
|
|
1634
|
-
}
|
|
1635
3168
|
const allForwarded = await forwardPendingRerouteMessages(
|
|
1636
3169
|
pending,
|
|
1637
3170
|
record.instanceId,
|
|
@@ -1642,14 +3175,17 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1642
3175
|
if (!allForwarded) {
|
|
1643
3176
|
await deps.answerCallbackQuery(
|
|
1644
3177
|
query.id,
|
|
1645
|
-
"
|
|
3178
|
+
pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
|
|
3179
|
+
"Target thread is unavailable; retrying will send only remaining messages.",
|
|
1646
3180
|
);
|
|
1647
3181
|
return true;
|
|
1648
3182
|
}
|
|
1649
|
-
|
|
3183
|
+
// A temporary tab is removed only after every input in it resolves, never by one Forward.
|
|
3184
|
+
const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(
|
|
1650
3185
|
sourceTarget,
|
|
1651
3186
|
sourceMessageId,
|
|
1652
3187
|
assertExecutionCurrent,
|
|
3188
|
+
pending.temporaryThread,
|
|
1653
3189
|
);
|
|
1654
3190
|
if (!cleanupComplete && sourceTarget) {
|
|
1655
3191
|
pending.cleanup = {
|
|
@@ -1658,6 +3194,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1658
3194
|
...(typeof sourceMessageId === "number"
|
|
1659
3195
|
? { messageId: sourceMessageId }
|
|
1660
3196
|
: {}),
|
|
3197
|
+
...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
|
|
1661
3198
|
};
|
|
1662
3199
|
await deps.answerCallbackQuery(
|
|
1663
3200
|
query.id,
|
|
@@ -1671,116 +3208,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1671
3208
|
query,
|
|
1672
3209
|
"Message routed.",
|
|
1673
3210
|
);
|
|
3211
|
+
if (sourceTarget && isTemporaryTabTarget(sourceTarget)) scheduleTemporaryThreadCleanup(sourceTarget, ctx);
|
|
1674
3212
|
return true;
|
|
1675
3213
|
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
parsed.useNewSlot &&
|
|
1680
|
-
(record.target.chatId !== sourceTarget.chatId ||
|
|
1681
|
-
record.target.threadId !== sourceTarget.threadId)
|
|
1682
|
-
) {
|
|
1683
|
-
const slot = deps.threadStore.allocateSlot(
|
|
1684
|
-
leaderProfileKey ?? record.profileKey,
|
|
1685
|
-
getNextTelegramSlotPreference(record.slot),
|
|
1686
|
-
undefined,
|
|
1687
|
-
{ excludeCurrentRecord: true },
|
|
1688
|
-
);
|
|
1689
|
-
if (!slot) {
|
|
1690
|
-
await deps.answerCallbackQuery(query.id, TELEGRAM_SLOT_CAPACITY_MESSAGE);
|
|
1691
|
-
return true;
|
|
1692
|
-
}
|
|
1693
|
-
deps.threadStore.markStaleByTarget(
|
|
1694
|
-
record.target,
|
|
1695
|
-
"deleted",
|
|
1696
|
-
"Current leader thread was replaced by a new-slot reroute source.",
|
|
1697
|
-
);
|
|
1698
|
-
const nowMs = Date.now();
|
|
1699
|
-
const threadName = getRestoredThreadName(record, slot);
|
|
1700
|
-
deps.threadStore.upsert({
|
|
1701
|
-
...record,
|
|
1702
|
-
target: sourceTarget,
|
|
1703
|
-
status: "active",
|
|
1704
|
-
updatedAtMs: nowMs,
|
|
1705
|
-
threadName,
|
|
1706
|
-
instanceId: currentInstanceId,
|
|
1707
|
-
slot,
|
|
1708
|
-
lastReconcileAction: "reroute-new-slot",
|
|
1709
|
-
rerouteConfirmedAtMs: nowMs,
|
|
1710
|
-
});
|
|
1711
|
-
await deps.threadStore.persist();
|
|
1712
|
-
assertExecutionCurrent();
|
|
1713
|
-
deps.setCurrentLeaderIdentity?.({
|
|
1714
|
-
target: sourceTarget,
|
|
1715
|
-
slot,
|
|
1716
|
-
threadName,
|
|
1717
|
-
});
|
|
1718
|
-
if (deps.callApi) {
|
|
1719
|
-
try {
|
|
1720
|
-
assertExecutionCurrent();
|
|
1721
|
-
await deps.callApi("editForumTopic", {
|
|
1722
|
-
chat_id: sourceTarget.chatId,
|
|
1723
|
-
message_thread_id: sourceTarget.threadId,
|
|
1724
|
-
name:
|
|
1725
|
-
deps.getDisplayTitle?.(sourceTarget) ??
|
|
1726
|
-
Threads.getTelegramTopicTitleForThreadName(threadName, slot),
|
|
1727
|
-
});
|
|
1728
|
-
assertExecutionCurrent();
|
|
1729
|
-
} catch (renameError) {
|
|
1730
|
-
deps.recordRuntimeEvent?.("telegram", renameError, {
|
|
1731
|
-
phase: "leader-topic-reroute-reclaim-rename",
|
|
1732
|
-
chatId: sourceTarget.chatId,
|
|
1733
|
-
threadId: sourceTarget.threadId,
|
|
1734
|
-
slot,
|
|
1735
|
-
});
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
deps.recordRuntimeEvent?.(
|
|
1739
|
-
"bus",
|
|
1740
|
-
"Bus leader reclaimed reroute source thread",
|
|
1741
|
-
{
|
|
1742
|
-
phase: "leader-topic-reroute-reclaim",
|
|
1743
|
-
chatId: sourceTarget.chatId,
|
|
1744
|
-
threadId: sourceTarget.threadId,
|
|
1745
|
-
staleThreadId: record.target.threadId,
|
|
1746
|
-
slot,
|
|
1747
|
-
},
|
|
1748
|
-
);
|
|
1749
|
-
await dispatchPendingRerouteMessages(
|
|
1750
|
-
pending,
|
|
1751
|
-
cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId),
|
|
1752
|
-
ctx,
|
|
1753
|
-
);
|
|
1754
|
-
pending.messages = [];
|
|
1755
|
-
const cleanupComplete = await closePreviousLeaderThread(
|
|
1756
|
-
record.target,
|
|
1757
|
-
assertExecutionCurrent,
|
|
1758
|
-
);
|
|
1759
|
-
if (!cleanupComplete) {
|
|
1760
|
-
pending.cleanup = {
|
|
1761
|
-
kind: "previous-leader",
|
|
1762
|
-
target: record.target,
|
|
1763
|
-
};
|
|
1764
|
-
await deps.answerCallbackQuery(
|
|
1765
|
-
query.id,
|
|
1766
|
-
"Thread restored, but old-thread cleanup is still pending. Try again.",
|
|
1767
|
-
);
|
|
1768
|
-
return true;
|
|
1769
|
-
}
|
|
1770
|
-
await finalizePendingReroute(
|
|
1771
|
-
parsed.rerouteId,
|
|
1772
|
-
pending,
|
|
1773
|
-
query,
|
|
1774
|
-
"Message routed.",
|
|
1775
|
-
);
|
|
3214
|
+
// Local handlers and queue admission consume the same one-time group grant as follower forwarding.
|
|
3215
|
+
if (isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
|
|
3216
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
1776
3217
|
return true;
|
|
1777
3218
|
}
|
|
1778
3219
|
await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
|
|
1779
3220
|
pending.messages = [];
|
|
1780
|
-
|
|
3221
|
+
// A temporary tab is removed only after every input in it resolves, never by one Forward.
|
|
3222
|
+
const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(
|
|
1781
3223
|
sourceTarget,
|
|
1782
3224
|
sourceMessageId,
|
|
1783
3225
|
assertExecutionCurrent,
|
|
3226
|
+
pending.temporaryThread,
|
|
1784
3227
|
);
|
|
1785
3228
|
if (!cleanupComplete && sourceTarget) {
|
|
1786
3229
|
pending.cleanup = {
|
|
@@ -1789,6 +3232,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1789
3232
|
...(typeof sourceMessageId === "number"
|
|
1790
3233
|
? { messageId: sourceMessageId }
|
|
1791
3234
|
: {}),
|
|
3235
|
+
...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
|
|
1792
3236
|
};
|
|
1793
3237
|
await deps.answerCallbackQuery(
|
|
1794
3238
|
query.id,
|
|
@@ -1802,24 +3246,26 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1802
3246
|
query,
|
|
1803
3247
|
"Message routed.",
|
|
1804
3248
|
);
|
|
3249
|
+
if (sourceTarget && isTemporaryTabTarget(sourceTarget)) scheduleTemporaryThreadCleanup(sourceTarget, ctx);
|
|
1805
3250
|
return true;
|
|
1806
3251
|
};
|
|
1807
|
-
const executeUnboundRerouteCallback = (
|
|
3252
|
+
const executeUnboundRerouteCallback = async (
|
|
1808
3253
|
query: TCallbackQuery,
|
|
1809
3254
|
ctx: TContext,
|
|
1810
3255
|
): Promise<boolean> => {
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
3256
|
+
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
3257
|
+
if (!parsed) return false;
|
|
3258
|
+
return withPendingRerouteSelection(parsed.rerouteId, query, () => {
|
|
3259
|
+
if (!deps.runWorkspaceOperation) return executeUnboundRerouteCallbackOperation(query, ctx);
|
|
3260
|
+
return deps.runWorkspaceOperation(
|
|
3261
|
+
{
|
|
3262
|
+
operationId: `workspace-reroute:${query.id}`,
|
|
3263
|
+
operationKind: "workspace.route-unbound-thread",
|
|
3264
|
+
scopes: [{ kind: "profile" }],
|
|
3265
|
+
},
|
|
3266
|
+
() => executeUnboundRerouteCallbackOperation(query, ctx),
|
|
3267
|
+
);
|
|
3268
|
+
});
|
|
1823
3269
|
};
|
|
1824
3270
|
const handleUnboundRerouteCallback = async (
|
|
1825
3271
|
query: TCallbackQuery,
|
|
@@ -1828,7 +3274,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1828
3274
|
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
1829
3275
|
const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1830
3276
|
if (!parsed || !pending || pending.dispatchKind !== "command" ||
|
|
1831
|
-
pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
|
|
3277
|
+
(pending.sourceTarget.threadId !== undefined && !pending.temporaryThread) || !matchesRerouteChooser(pending, query)) {
|
|
1832
3278
|
return executeUnboundRerouteCallback(query, ctx);
|
|
1833
3279
|
}
|
|
1834
3280
|
if (pending.dispatching) {
|
|
@@ -1855,7 +3301,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1855
3301
|
const assertExecutionCurrent =
|
|
1856
3302
|
Updates.createTelegramUpdateExecutionFenceGuard(query);
|
|
1857
3303
|
assertExecutionCurrent();
|
|
1858
|
-
if (await
|
|
3304
|
+
if (await handleCancellationReview(query, ctx)) return;
|
|
3305
|
+
if (await handleUnboundRerouteCancelCallback(query, ctx)) return;
|
|
3306
|
+
const restore = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
|
|
3307
|
+
if (restore && await withPendingRerouteSelection(restore.rerouteId, query,
|
|
3308
|
+
() => executeUnboundRerouteRestoreMenuCallback(query, ctx))) return;
|
|
1859
3309
|
if (await handleUnboundRerouteCallback(query, ctx)) return;
|
|
1860
3310
|
if (deps.buttonActionStore) {
|
|
1861
3311
|
const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery(
|
|
@@ -2348,9 +3798,55 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2348
3798
|
reportQueueAdmission(messages, turn.admissionReceipts ?? []);
|
|
2349
3799
|
});
|
|
2350
3800
|
};
|
|
3801
|
+
const recordUnboundTemporaryThreadInput = async (messages: TMessage[], ctx: TContext): Promise<boolean> => {
|
|
3802
|
+
const message = messages[0], target = message && Updates.getTelegramMessageTarget(message);
|
|
3803
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
3804
|
+
if (!target?.threadId || !store) return false;
|
|
3805
|
+
let found = store.listTemporaryThreads().find(entry => entry.phase === "created" && entry.target &&
|
|
3806
|
+
entry.target.chatId === target.chatId && entry.target.threadId === target.threadId);
|
|
3807
|
+
const key = formatTelegramTargetKey(target), implicit = implicitThreadCreations.get(key);
|
|
3808
|
+
if (!found && !implicit) return false;
|
|
3809
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(messages);
|
|
3810
|
+
const operatorUserId = deps.configStore.getAllowedUserId();
|
|
3811
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => messages.every(source =>
|
|
3812
|
+
Updates.getTelegramUpdateExecutionFence(source)?.isCurrent() === true &&
|
|
3813
|
+
source.chat.type === "private" && source.chat.id === target.chatId && source.message_thread_id === target.threadId &&
|
|
3814
|
+
source.from?.id === operatorUserId && source.from?.is_bot === false));
|
|
3815
|
+
if (!found && implicit) {
|
|
3816
|
+
if (updateIds.length !== 1) { implicitThreadCreations.delete(key); return false; }
|
|
3817
|
+
if (!deps.runWorkspaceOperation || !cap || implicit.ctx !== ctx || implicit.operatorUserId !== cap.operatorUserId ||
|
|
3818
|
+
implicit.journalBindingKey !== cap.journalBindingKey || !isDeepStrictEqual(implicit.executor, cap.authority.executor) ||
|
|
3819
|
+
implicit.generation !== deps.getSessionGeneration?.() || implicit.scope !== deps.getAdmissionScope?.() ||
|
|
3820
|
+
!updateIds.length || updateIds.some(id => id <= implicit.updateId) || !cap.isCurrent()) {
|
|
3821
|
+
implicitThreadCreations.delete(key);
|
|
3822
|
+
return false;
|
|
3823
|
+
}
|
|
3824
|
+
found = store.registerImplicitTemporaryThread({ journalBindingKey: cap.journalBindingKey, updateIds },
|
|
3825
|
+
{ chatId: target.chatId, threadId: target.threadId }, randomBytes(16).toString("hex"), {
|
|
3826
|
+
...cap.authority, isCurrent: () => cap.isCurrent() && implicitThreadCreations.get(key) === implicit,
|
|
3827
|
+
});
|
|
3828
|
+
if (!found || !cap.isCurrent()) throw new Error("Implicit Telegram Thread registration was not acknowledged; source remains protected.");
|
|
3829
|
+
implicitThreadCreations.delete(key);
|
|
3830
|
+
}
|
|
3831
|
+
if (!found) return false;
|
|
3832
|
+
cancelTemporaryThreadCleanup(found.token);
|
|
3833
|
+
if (!deps.runWorkspaceOperation || !cap || cap.operatorUserId !== found.operatorUserId ||
|
|
3834
|
+
cap.journalBindingKey !== found.source.journalBindingKey || !updateIds.length || !cap.isCurrent()) {
|
|
3835
|
+
throw new Error("Temporary Thread input membership requires exact current source authority.");
|
|
3836
|
+
}
|
|
3837
|
+
// The caller already holds the profile Workspace admission (the non-reentrant gate): no nested operation here.
|
|
3838
|
+
let entry = store.listTemporaryThreads().find(value => value.token === found.token &&
|
|
3839
|
+
isDeepStrictEqual(value.source, found.source) && isDeepStrictEqual(value.target, target));
|
|
3840
|
+
if (!entry) throw new Error("Temporary Thread input target changed before membership publication.");
|
|
3841
|
+
entry = cap.adopt(entry);
|
|
3842
|
+
if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInput(entry, { journalBindingKey: cap.journalBindingKey, updateIds }, cap.authority) || !cap.isCurrent()) {
|
|
3843
|
+
throw new Error("Temporary Thread input membership was not acknowledged; the source remains protected.");
|
|
3844
|
+
}
|
|
3845
|
+
return true;
|
|
3846
|
+
};
|
|
2351
3847
|
const sendUnboundRerouteChooserNow = async (
|
|
2352
3848
|
messages: TMessage[],
|
|
2353
|
-
|
|
3849
|
+
ctx: TContext,
|
|
2354
3850
|
reportDeferred = true,
|
|
2355
3851
|
): Promise<void> => {
|
|
2356
3852
|
const message = messages[0];
|
|
@@ -2385,19 +3881,44 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2385
3881
|
);
|
|
2386
3882
|
return;
|
|
2387
3883
|
}
|
|
2388
|
-
const
|
|
3884
|
+
const command = messages.length === 1 ? getKnownTelegramAllTabCommand(Media.extractFirstTelegramMessageText(messages).trim()) : undefined;
|
|
3885
|
+
const rerouteId = storePendingUnboundReroute(messages, command ? "command" : "prompt");
|
|
2389
3886
|
if (reportDeferred) {
|
|
2390
3887
|
for (const source of messages) {
|
|
2391
3888
|
Updates.reportTelegramUpdateDeferred(source);
|
|
2392
3889
|
}
|
|
2393
3890
|
}
|
|
2394
|
-
const
|
|
2395
|
-
|
|
2396
|
-
|
|
3891
|
+
const inTemporaryThread = await recordUnboundTemporaryThreadInput(messages, ctx);
|
|
3892
|
+
const pending = pendingUnboundReroutes.get(rerouteId)!;
|
|
3893
|
+
if (inTemporaryThread) pending.temporaryMembership = true;
|
|
3894
|
+
if (messages.length === 1 && sourceTarget &&
|
|
3895
|
+
message.chat.type === "private" && typeof message.text === "string" && message.text.trim() &&
|
|
3896
|
+
deps.runWorkspaceOperation && deps.editInteractiveMessage &&
|
|
3897
|
+
deps.getAdmissionJournalBinding && deps.getCurrentLeaderEpoch &&
|
|
3898
|
+
Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true && deps.isContextActive?.(ctx) === true) {
|
|
3899
|
+
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
3900
|
+
const journalBindingKey = deps.getAdmissionJournalBinding();
|
|
3901
|
+
if (ownerUserId !== undefined && message.from?.id === ownerUserId && journalBindingKey &&
|
|
3902
|
+
Updates.supportsTelegramDeferredAbandonment(message, journalBindingKey)) {
|
|
3903
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch();
|
|
3904
|
+
if (leaderEpoch !== undefined) pending.abandonment = { ownerUserId, journalBindingKey, leaderEpoch };
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
if (command && sourceTarget && !inTemporaryThread && includeGuidance && deps.callApi) {
|
|
3908
|
+
// Telegram can create a private tab before delivering an All command not listed in the bot menu.
|
|
3909
|
+
// Naming that unbound tab is not creation evidence or authority to delete it.
|
|
3910
|
+
Updates.createTelegramUpdateExecutionFenceGuard(message)();
|
|
3911
|
+
try {
|
|
3912
|
+
await deps.callApi("editForumTopic", { chat_id: sourceTarget.chatId, message_thread_id: sourceTarget.threadId, name: `/${command.name}` });
|
|
3913
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-command-title" }); }
|
|
3914
|
+
Updates.createTelegramUpdateExecutionFenceGuard(message)();
|
|
3915
|
+
}
|
|
3916
|
+
const text = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
3917
|
+
pending.rootChooserText = text;
|
|
2397
3918
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
2398
3919
|
rerouteId,
|
|
2399
3920
|
activeRecords,
|
|
2400
|
-
{ canRestore: sourceTarget !== undefined, getDisplayTitle: deps.getDisplayTitle },
|
|
3921
|
+
{ canRestore: sourceTarget !== undefined, canCancel: !!pending.abandonment, getDisplayTitle: deps.getDisplayTitle },
|
|
2401
3922
|
);
|
|
2402
3923
|
if (deps.sendInteractiveMessage) {
|
|
2403
3924
|
const chooserId = await deps.sendInteractiveMessage(
|
|
@@ -2432,7 +3953,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2432
3953
|
const messages = [...(existing?.messages ?? []), message];
|
|
2433
3954
|
const timer = setTimeout(() => {
|
|
2434
3955
|
pendingUnboundRerouteMediaGroups.delete(groupKey);
|
|
2435
|
-
|
|
3956
|
+
// A media group's timer runs outside any request, so it takes the same profile admission as a single message.
|
|
3957
|
+
const send = (): Promise<void> => sendUnboundRerouteChooserNow(messages, ctx, false);
|
|
3958
|
+
const task = deps.runWorkspaceOperation
|
|
3959
|
+
? deps.runWorkspaceOperation({ operationId: `workspace-unbound-group:${groupKey}:${randomBytes(8).toString("hex")}`,
|
|
3960
|
+
operationKind: "workspace.route-unbound-thread", scopes: [{ kind: "profile" }] }, send)
|
|
3961
|
+
: send();
|
|
3962
|
+
void task.catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-media-group-chooser" }); });
|
|
2436
3963
|
}, 1200);
|
|
2437
3964
|
timer.unref?.();
|
|
2438
3965
|
pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
|
|
@@ -2454,6 +3981,83 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2454
3981
|
}
|
|
2455
3982
|
return undefined;
|
|
2456
3983
|
};
|
|
3984
|
+
/**
|
|
3985
|
+
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
3986
|
+
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
3987
|
+
*/
|
|
3988
|
+
const sendAllTabTemporaryInputChooser = async (
|
|
3989
|
+
command: Commands.ParsedTelegramCommand | undefined,
|
|
3990
|
+
commandText: string,
|
|
3991
|
+
message: TMessage,
|
|
3992
|
+
ctx: TContext,
|
|
3993
|
+
): Promise<boolean> => {
|
|
3994
|
+
const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
3995
|
+
const execution = Updates.getTelegramUpdateExecutionFence(message);
|
|
3996
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
|
|
3997
|
+
if (!cap || !deps.runWorkspaceOperation || !deps.callApi || updateId === undefined ||
|
|
3998
|
+
message.chat.type !== "private" || message.chat.id !== cap.operatorUserId || message.from?.id !== cap.operatorUserId ||
|
|
3999
|
+
!cap.isCurrent()) return false;
|
|
4000
|
+
const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
|
|
4001
|
+
const source = { journalBindingKey, updateId };
|
|
4002
|
+
const find = () => store.listTemporaryThreads().find(entry =>
|
|
4003
|
+
entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId);
|
|
4004
|
+
let entry: Threads.TelegramTemporaryThreadEntry | undefined;
|
|
4005
|
+
await deps.runWorkspaceOperation({ operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
|
|
4006
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
4007
|
+
if (!current()) return;
|
|
4008
|
+
let found = find();
|
|
4009
|
+
found = found && cap.adopt(found);
|
|
4010
|
+
if (found) { entry = found; return; }
|
|
4011
|
+
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
|
|
4012
|
+
entry = reservation?.entry;
|
|
4013
|
+
if (!reservation?.reserved || !current()) return;
|
|
4014
|
+
let created: { message_thread_id?: unknown } | undefined;
|
|
4015
|
+
try {
|
|
4016
|
+
// The single creation attempt; any failure leaves `creating` as an unknown outcome, never a retry.
|
|
4017
|
+
created = await deps.callApi!<{ message_thread_id?: unknown }>("createForumTopic",
|
|
4018
|
+
{ chat_id: operatorUserId, name: command ? `/${command.name}` : "New chat" });
|
|
4019
|
+
} catch (error) {
|
|
4020
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-create" });
|
|
4021
|
+
return;
|
|
4022
|
+
}
|
|
4023
|
+
const threadId = created?.message_thread_id;
|
|
4024
|
+
if (!current() || typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return;
|
|
4025
|
+
entry = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority) ?? entry;
|
|
4026
|
+
});
|
|
4027
|
+
if (!current() || !entry) throw new Error("Temporary Thread authority changed; the input remains retryable.");
|
|
4028
|
+
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
4029
|
+
...message, text: commandText, caption: undefined } as TMessage);
|
|
4030
|
+
if (entry.phase !== "created" || !entry.target) {
|
|
4031
|
+
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
4032
|
+
await deps.sendTextReply(message.chat.id, message.message_id,
|
|
4033
|
+
"<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.",
|
|
4034
|
+
{ parseMode: "HTML" });
|
|
4035
|
+
return true;
|
|
4036
|
+
}
|
|
4037
|
+
const target = entry.target;
|
|
4038
|
+
const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
|
|
4039
|
+
const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
|
|
4040
|
+
const pending = pendingUnboundReroutes.get(rerouteId)!;
|
|
4041
|
+
pending.temporaryThread = entry;
|
|
4042
|
+
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
4043
|
+
// Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
|
|
4044
|
+
if (deps.editInteractiveMessage && Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
|
|
4045
|
+
pending.abandonment = { ownerUserId: operatorUserId, journalBindingKey, leaderEpoch: epoch };
|
|
4046
|
+
}
|
|
4047
|
+
pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
4048
|
+
try {
|
|
4049
|
+
if (!deps.sendInteractiveMessage) throw new Error("Temporary Thread chooser publication is unavailable.");
|
|
4050
|
+
const chooserId = await deps.sendInteractiveMessage(target.chatId, pending.rootChooserText, "html",
|
|
4051
|
+
buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, { canRestore: true, canCancel: !!pending.abandonment,
|
|
4052
|
+
getDisplayTitle: deps.getDisplayTitle }),
|
|
4053
|
+
{ target });
|
|
4054
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
4055
|
+
} catch (error) {
|
|
4056
|
+
removePendingReroute(rerouteId);
|
|
4057
|
+
throw error;
|
|
4058
|
+
}
|
|
4059
|
+
return true;
|
|
4060
|
+
};
|
|
2457
4061
|
const sendAllTabCommandChooser = async (
|
|
2458
4062
|
command: Commands.ParsedTelegramCommand,
|
|
2459
4063
|
commandText: string,
|
|
@@ -2478,6 +4082,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2478
4082
|
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
2479
4083
|
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
2480
4084
|
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
4085
|
+
pendingUnboundReroutes.get(rerouteId)!.rootChooserText = text;
|
|
2481
4086
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
2482
4087
|
rerouteId,
|
|
2483
4088
|
activeRecords,
|
|
@@ -2749,11 +4354,24 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2749
4354
|
assertExecutionCurrent();
|
|
2750
4355
|
await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
|
|
2751
4356
|
assertExecutionCurrent();
|
|
4357
|
+
const key = formatTelegramTargetKey(lifecycle.target);
|
|
2752
4358
|
if (lifecycle.kind !== "created" || !deps.threadStore) {
|
|
4359
|
+
implicitThreadCreations.delete(key);
|
|
2753
4360
|
return;
|
|
2754
4361
|
}
|
|
2755
4362
|
await deps.threadStore.load();
|
|
2756
4363
|
assertExecutionCurrent();
|
|
4364
|
+
const message = lifecycle.message, created = message.forum_topic_created;
|
|
4365
|
+
const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
4366
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true);
|
|
4367
|
+
if (created && typeof created === "object" && "is_name_implicit" in created && created.is_name_implicit === true &&
|
|
4368
|
+
cap && deps.runWorkspaceOperation && updateId !== undefined && !Updates.isTelegramHistoricalInput(message) &&
|
|
4369
|
+
message.chat.type === "private" && message.chat.id === cap.operatorUserId && message.from?.id === cap.operatorUserId &&
|
|
4370
|
+
message.from.is_bot === false && cap.isCurrent() && implicitThreadCreations.size < 100) {
|
|
4371
|
+
implicitThreadCreations.set(key, { ctx, updateId, operatorUserId: cap.operatorUserId,
|
|
4372
|
+
journalBindingKey: cap.journalBindingKey, executor: structuredClone(cap.authority.executor),
|
|
4373
|
+
generation: deps.getSessionGeneration?.(), scope: deps.getAdmissionScope?.() });
|
|
4374
|
+
} else implicitThreadCreations.delete(key);
|
|
2757
4375
|
};
|
|
2758
4376
|
// Answer the guest query immediately so the agent-end edit can replace the
|
|
2759
4377
|
// early ACK once the turn settles. The ACK is the first placeholder frame and
|
|
@@ -2795,7 +4413,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2795
4413
|
}
|
|
2796
4414
|
assertExecutionCurrent();
|
|
2797
4415
|
}
|
|
2798
|
-
|
|
4416
|
+
// Media messages carry the user's text as a caption.
|
|
4417
|
+
const text = guestMessage.text ?? guestMessage.caption ?? "";
|
|
2799
4418
|
const gm = guestMessage as unknown as Record<string, unknown>;
|
|
2800
4419
|
// Build telegram prefix with guest context
|
|
2801
4420
|
const chatRaw = gm.chat as Record<string, unknown>;
|
|
@@ -2842,10 +4461,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2842
4461
|
: "";
|
|
2843
4462
|
// Download files, run inbound handlers
|
|
2844
4463
|
const guestMsg = guestMessage as unknown as Media.TelegramMediaMessage;
|
|
4464
|
+
// Guest message IDs belong to the peer's chat; scope files to that peer so they cannot collide with bot-chat files.
|
|
4465
|
+
const guestScope = resolveTelegramGuestFileScope({ chatType, chat: chatRaw, from: fromRaw, replyFrom: replyFromRaw,
|
|
4466
|
+
guestBotCallerUser, guestBotCallerChat, ownerUserId });
|
|
4467
|
+
const downloadGuestFile: typeof deps.downloadFile = (fileId, fileName, source) =>
|
|
4468
|
+
deps.downloadFile(fileId, fileName, source ? { ...source, scope: guestScope } : source);
|
|
2845
4469
|
const replyFiles = guestMsg.reply_to_message
|
|
2846
4470
|
? await Media.downloadTelegramMessageFiles(
|
|
2847
4471
|
[guestMsg.reply_to_message as Media.TelegramMediaMessage],
|
|
2848
|
-
{ downloadFile:
|
|
4472
|
+
{ downloadFile: downloadGuestFile },
|
|
2849
4473
|
)
|
|
2850
4474
|
: [];
|
|
2851
4475
|
assertExecutionCurrent();
|
|
@@ -2855,7 +4479,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2855
4479
|
: undefined;
|
|
2856
4480
|
assertExecutionCurrent();
|
|
2857
4481
|
const files = await Media.downloadTelegramMessageFiles([guestMsg], {
|
|
2858
|
-
downloadFile:
|
|
4482
|
+
downloadFile: downloadGuestFile,
|
|
2859
4483
|
});
|
|
2860
4484
|
assertExecutionCurrent();
|
|
2861
4485
|
const processed = await deps.inboundHandlerRuntime.process(
|
|
@@ -2941,7 +4565,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2941
4565
|
deps.updateStatus(ctx);
|
|
2942
4566
|
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
2943
4567
|
};
|
|
2944
|
-
|
|
4568
|
+
const runtime = Updates.createTelegramPairedUpdateRuntime<TContext, TUpdate>({
|
|
2945
4569
|
getAllowedUserId: deps.configStore.getAllowedUserId,
|
|
2946
4570
|
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
2947
4571
|
getMessageOwnership: deps.getMessageOwnership,
|
|
@@ -2992,8 +4616,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2992
4616
|
deps.getLiveThreadTargets?.(),
|
|
2993
4617
|
);
|
|
2994
4618
|
const command = getKnownTelegramAllTabCommand(text);
|
|
2995
|
-
|
|
2996
|
-
|
|
4619
|
+
const [sourceUpdateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
4620
|
+
const presented = sourceUpdateId !== undefined && deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().some(entry =>
|
|
4621
|
+
entry.source.updateId === sourceUpdateId && entry.source.journalBindingKey === deps.getAdmissionJournalBinding?.()) === true;
|
|
4622
|
+
// Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
|
|
4623
|
+
if (command && command.name !== "thread" && !presented && isTelegramAllTabCommandExpired(message)) return;
|
|
4624
|
+
if (command?.name !== "thread" && (bindings.length > 0 || presented) &&
|
|
4625
|
+
typeof message.text === "string" && message.text.trim() &&
|
|
4626
|
+
await sendAllTabTemporaryInputChooser(command, text, message as TMessage, ctx)) return;
|
|
2997
4627
|
if (bindings.length > 0 && command && command.name !== "thread") {
|
|
2998
4628
|
if (
|
|
2999
4629
|
await sendAllTabCommandChooser(command, text, message as TMessage, {
|
|
@@ -3058,6 +4688,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3058
4688
|
}
|
|
3059
4689
|
await deps.threadStore.load();
|
|
3060
4690
|
assertExecutionCurrent();
|
|
4691
|
+
if (Updates.isTelegramHistoricalInput(message, entry => reviewMessage(entry) !== undefined) && isReviewText(message as TMessage) &&
|
|
4692
|
+
deps.getCurrentLeaderEpoch?.() !== undefined && needsHistoricalReview(message as TMessage)) {
|
|
4693
|
+
if (!Updates.reportTelegramHistoricalRoutingReview(message)) throw new Error("Historical routing hold is unavailable.");
|
|
4694
|
+
return;
|
|
4695
|
+
}
|
|
3061
4696
|
if (deps.threadStore.getBotState().threadMode === "disabled") {
|
|
3062
4697
|
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
3063
4698
|
return;
|
|
@@ -3181,14 +4816,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3181
4816
|
}
|
|
3182
4817
|
const command = getKnownTelegramAllTabCommand(text);
|
|
3183
4818
|
if (command && hasAnyRoutableThread) {
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
target: { chatId: target.chatId, threadId: target.threadId },
|
|
3187
|
-
replyToSource: true,
|
|
3188
|
-
})
|
|
3189
|
-
) {
|
|
3190
|
-
return;
|
|
3191
|
-
}
|
|
4819
|
+
await sendUnboundRerouteChooser(message as TMessage, ctx);
|
|
4820
|
+
return;
|
|
3192
4821
|
}
|
|
3193
4822
|
if (leaderProfileKey && deps.callApi) {
|
|
3194
4823
|
const currentLeaderRecord = records.find((record) => {
|
|
@@ -3303,7 +4932,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3303
4932
|
}
|
|
3304
4933
|
const identityThreadName =
|
|
3305
4934
|
priorLeaderIdentity?.threadName &&
|
|
3306
|
-
|
|
4935
|
+
ThreadNaming.isTelegramTopicThreadNameValidForSlot(
|
|
3307
4936
|
priorLeaderIdentity.threadName,
|
|
3308
4937
|
slot,
|
|
3309
4938
|
)
|
|
@@ -3312,7 +4941,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3312
4941
|
const threadName =
|
|
3313
4942
|
priorLeaderRecord?.threadName ??
|
|
3314
4943
|
identityThreadName ??
|
|
3315
|
-
|
|
4944
|
+
ThreadNaming.chooseTelegramThreadName({ slot }) ??
|
|
3316
4945
|
"Pi";
|
|
3317
4946
|
deps.threadStore.upsert({
|
|
3318
4947
|
profileKey: leaderProfileKey,
|
|
@@ -3367,6 +4996,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3367
4996
|
);
|
|
3368
4997
|
},
|
|
3369
4998
|
});
|
|
4999
|
+
return { ...runtime, expireRoutingInput, shouldReviewHistoricalInput, shouldHoldPendingInput, forgetPreviousWorld, beforeQueueReceiptPublished, onQueueReceiptCommitted, onQueueReceiptCompleted, onUpdateCompleted,
|
|
5000
|
+
onWorkspaceRestoreRecipientObserved(follower, isCurrent, ctx) {
|
|
5001
|
+
if (ctx !== undefined) return observeRestoreSettlement({ kind: "recipient", follower, isCurrent }, ctx);
|
|
5002
|
+
return undefined;
|
|
5003
|
+
},
|
|
5004
|
+
async waitForRestoreSettlement() { await Promise.all([...restoreSettlementTasks]); } };
|
|
3370
5005
|
}
|
|
3371
5006
|
|
|
3372
5007
|
// --- Assistant Output Delivery Authority ---
|