@llblab/pi-kit 0.27.5 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +3 -1
- package/CHANGELOG.md +9 -0
- package/README.md +4 -4
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +6 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +4 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +4 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +26 -13
- package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +3 -1
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +2 -2
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -1
- package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +4 -3
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +25 -12
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +10 -8
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -9
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-telegram/LICENSE +21 -0
- package/node_modules/@llblab/pi-telegram/README.md +15 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +5 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +6 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +54 -56
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +206 -139
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +181 -25
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.d.ts +4 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +28 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +52 -56
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +78 -246
- package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +1 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +23 -26
- package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +0 -23
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +15 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/config.js +12 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +0 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +2 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +255 -61
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -19
- package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +177 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +1115 -249
- package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +100 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +380 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +30 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +129 -72
- package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +28 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/media.js +26 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +0 -11
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +8 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +0 -18
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +18 -18
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +4 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +12 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +1 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +1 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +4 -21
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +0 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +50 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +155 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/preview.d.ts +0 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +3 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +3 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +69 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +29 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +104 -33
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +0 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +73 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +2206 -307
- package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +0 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +44 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/status.js +149 -27
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +0 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +34 -23
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +18 -21
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +32 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +191 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +291 -59
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +1647 -369
- package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +184 -28
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +1032 -114
- package/node_modules/@llblab/pi-telegram/dist/lib/wire.d.ts +12 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +21 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +25 -17
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +95 -20
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.d.ts +20 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +103 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +43 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +148 -52
- package/node_modules/@llblab/pi-telegram/dist/package.json +6 -6
- package/node_modules/@llblab/pi-telegram/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/activity.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +152 -34
- package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/delivery.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +125 -19
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
- package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +5 -9
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +6 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +243 -224
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +178 -31
- package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +30 -8
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +110 -334
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +29 -29
- package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +15 -15
- package/node_modules/@llblab/pi-telegram/lib/config.ts +12 -17
- package/node_modules/@llblab/pi-telegram/lib/delivery.ts +2 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +257 -72
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +0 -22
- package/node_modules/@llblab/pi-telegram/lib/inbound.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +1182 -326
- package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +402 -23
- package/node_modules/@llblab/pi-telegram/lib/logging.ts +151 -101
- package/node_modules/@llblab/pi-telegram/lib/media.ts +51 -9
- package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +8 -8
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +18 -18
- package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +11 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +5 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +1 -10
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +5 -24
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +177 -17
- package/node_modules/@llblab/pi-telegram/lib/pi.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +5 -5
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +3 -3
- package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +67 -9
- package/node_modules/@llblab/pi-telegram/lib/recovery.ts +104 -44
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +1955 -375
- package/node_modules/@llblab/pi-telegram/lib/sections.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/status.ts +156 -36
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +48 -33
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +24 -21
- package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +267 -9
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +3 -1
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +1697 -488
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +1072 -151
- package/node_modules/@llblab/pi-telegram/lib/wire.ts +28 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +97 -47
- package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +147 -0
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +188 -89
- package/node_modules/@llblab/pi-telegram/package.json +6 -6
- package/node_modules/@llblab/pi-telegram/scripts/audit-exports.mjs +100 -0
- package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +80 -43
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +3 -3
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/package.json +3 -3
|
@@ -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,15 @@ 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
|
+
"Select the Pi thread that should handle it, or restore a Pi into this tab:",
|
|
293
|
+
].join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
272
296
|
function formatTelegramAllTabMenuChooserText(command: string): string {
|
|
273
297
|
return [
|
|
274
298
|
"<b>🧵 Choose target thread:</b>",
|
|
@@ -284,34 +308,23 @@ function buildTelegramUnboundRerouteChooserMarkup(
|
|
|
284
308
|
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
285
309
|
options: {
|
|
286
310
|
canRestore: boolean;
|
|
311
|
+
canCancel?: boolean;
|
|
287
312
|
getDisplayTitle?: Threads.TelegramCurrentThreadAssembly["getDisplayTitle"];
|
|
288
313
|
},
|
|
289
314
|
): Menu.TelegramReplyMarkup {
|
|
290
315
|
const activeRecords = records.filter((record) => record.status === "active");
|
|
291
316
|
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
|
-
};
|
|
317
|
+
const rows = activeRecords.length
|
|
318
|
+
? [[{ text: "↪️ Reroute…", callback_data: `${TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX}${rerouteId}` }]] : [];
|
|
319
|
+
if (canRestoreAnyLiveThread) rows.push([{
|
|
320
|
+
text: "🔁 Restore…",
|
|
321
|
+
callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
|
|
322
|
+
}]);
|
|
323
|
+
if (options.canCancel) rows.push([{
|
|
324
|
+
text: "⛔️ Cancel routing",
|
|
325
|
+
callback_data: `${TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX}${rerouteId}`,
|
|
326
|
+
}]);
|
|
327
|
+
return { inline_keyboard: rows };
|
|
315
328
|
}
|
|
316
329
|
|
|
317
330
|
function buildTelegramUnboundRerouteRestoreChooserMarkup(
|
|
@@ -357,26 +370,102 @@ function formatTelegramTargetKey(target: Queue.TelegramQueueTarget): string {
|
|
|
357
370
|
return `${target.chatId}:${target.threadId ?? "all"}`;
|
|
358
371
|
}
|
|
359
372
|
|
|
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
373
|
import * as Threads from "./threads.ts";
|
|
376
374
|
import type { TelegramUser } from "./updates.ts";
|
|
377
375
|
import * as Updates from "./updates.ts";
|
|
378
376
|
import { getTelegramVoiceReplyMode } from "./voice.ts";
|
|
379
377
|
|
|
378
|
+
/** One admitted Restore attempt through recipient readiness, never source dispatch or cleanup. */
|
|
379
|
+
export async function advanceTelegramWorkspaceRestore(input: {
|
|
380
|
+
request: Threads.TelegramWorkspaceRestoreRequest;
|
|
381
|
+
authority: Threads.TelegramWorkspaceRestoreAuthority;
|
|
382
|
+
restoreStore: Threads.TelegramWorkspaceRestore;
|
|
383
|
+
getRecipient: () => Threads.TelegramWorkspaceRestoreRecipient | undefined;
|
|
384
|
+
/** Adapter proves current canonical ownership and authenticates observations; apply needs a fresh issuance. */
|
|
385
|
+
runRecipient: (input: { intent: Threads.TelegramWorkspaceRestoreIntent; mode: "apply" | "inspect"; isCurrent: () => boolean }) =>
|
|
386
|
+
Promise<{ operationId: string; recipient: Threads.TelegramWorkspaceRestoreRecipient;
|
|
387
|
+
target: Threads.TelegramWorkspaceRestoreRequest["target"]; slot: string; ready: boolean } | undefined>;
|
|
388
|
+
/**
|
|
389
|
+
* Recovery for a same-session successor already on the relocated target: it may receive the first grant, but only
|
|
390
|
+
* read-only inspection runs. It never commits a new relocation or applies a target.
|
|
391
|
+
*/
|
|
392
|
+
inspectOnly?: true;
|
|
393
|
+
}): Promise<Threads.TelegramWorkspaceRestoreIntent | undefined> {
|
|
394
|
+
const isCurrent = input.authority.isCurrent.bind(input.authority);
|
|
395
|
+
if (!isCurrent()) return undefined;
|
|
396
|
+
const request = structuredClone(input.request);
|
|
397
|
+
if (!Threads.isTelegramWorkspaceRestoreRequest(request)) return undefined;
|
|
398
|
+
const getRecipient = input.getRecipient;
|
|
399
|
+
const runRecipient = input.runRecipient;
|
|
400
|
+
const observedRecipient = getRecipient();
|
|
401
|
+
if (!Threads.isTelegramWorkspaceRestoreRecipient(observedRecipient) || observedRecipient.sessionId !== request.binding.sessionId) return undefined;
|
|
402
|
+
const recipient = structuredClone(observedRecipient);
|
|
403
|
+
const executor = structuredClone(input.authority.executor);
|
|
404
|
+
const operatorUserId = input.authority.operatorUserId;
|
|
405
|
+
const current = (): boolean => isCurrent() && input.authority.operatorUserId === operatorUserId &&
|
|
406
|
+
isDeepStrictEqual(input.authority.executor, executor) && isDeepStrictEqual(getRecipient(), recipient);
|
|
407
|
+
const authority = { executor, operatorUserId, isCurrent: current };
|
|
408
|
+
const { restoreStore } = input;
|
|
409
|
+
const retained = (): Threads.TelegramWorkspaceRestoreIntent | undefined => {
|
|
410
|
+
if (!current()) return undefined;
|
|
411
|
+
const found = restoreStore.list().find(value => value.request.operationId === request.operationId);
|
|
412
|
+
return current() && found && isDeepStrictEqual(found.request, request) && found.operatorUserId === operatorUserId &&
|
|
413
|
+
isDeepStrictEqual(found.executor, executor) ? found : undefined;
|
|
414
|
+
};
|
|
415
|
+
let intent = retained();
|
|
416
|
+
const predecessor = intent ? undefined : restoreStore.list().find(value => value.request.operationId === request.operationId);
|
|
417
|
+
if (predecessor && current() && isDeepStrictEqual(predecessor.request, request) &&
|
|
418
|
+
predecessor.operatorUserId === operatorUserId && !isDeepStrictEqual(predecessor.executor, executor)) {
|
|
419
|
+
// Adoption transfers executor authority only. Issued grants stay issued, so a successor can merely inspect them.
|
|
420
|
+
try { intent = restoreStore.adopt(predecessor, authority); }
|
|
421
|
+
catch (error) {
|
|
422
|
+
intent = retained();
|
|
423
|
+
if (!intent) throw error;
|
|
424
|
+
}
|
|
425
|
+
if (!current() || !intent) return undefined;
|
|
426
|
+
intent = retained();
|
|
427
|
+
if (!intent) return undefined;
|
|
428
|
+
}
|
|
429
|
+
if (input.inspectOnly && !intent) return undefined;
|
|
430
|
+
if (!intent || intent.phase === "relocated") {
|
|
431
|
+
if ((!input.inspectOnly && recipient.instanceId !== request.owner.instanceId) ||
|
|
432
|
+
recipient.kind !== (request.owner.owner?.kind === "leader" ? "leader" : "follower")) return undefined;
|
|
433
|
+
intent = await restoreStore.commit(request, authority);
|
|
434
|
+
if (!current() || !intent) return undefined;
|
|
435
|
+
}
|
|
436
|
+
let mode: "apply" | "inspect" = "inspect";
|
|
437
|
+
if (intent.phase === "relocated") {
|
|
438
|
+
try {
|
|
439
|
+
const issuance = restoreStore.issueRecipient(intent, recipient, authority);
|
|
440
|
+
if (issuance) { intent = issuance.intent; mode = input.inspectOnly ? "inspect" : "apply"; }
|
|
441
|
+
else intent = retained();
|
|
442
|
+
} catch (error) {
|
|
443
|
+
intent = retained();
|
|
444
|
+
if (intent?.phase !== "recipient-issued") throw error;
|
|
445
|
+
// A retained issued phase is not a fresh grant, even when publication's reply was lost.
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!current() || !intent || !["recipient-issued", "ready"].includes(intent.phase) ||
|
|
449
|
+
(mode === "apply" && !isDeepStrictEqual(intent.recipient, recipient))) return undefined;
|
|
450
|
+
const expected = structuredClone(intent);
|
|
451
|
+
if (!isDeepStrictEqual(retained(), expected)) return undefined;
|
|
452
|
+
const observation = await runRecipient({ intent: structuredClone(expected), mode, isCurrent: current });
|
|
453
|
+
if (!current() || !isDeepStrictEqual(retained(), expected) || !observation || observation.ready !== true ||
|
|
454
|
+
observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
|
|
455
|
+
!isDeepStrictEqual(observation.target, request.target) || observation.slot !== request.binding.slot) return undefined;
|
|
456
|
+
if (expected.phase === "ready" && isDeepStrictEqual(expected.readyRecipient ?? expected.recipient, recipient)) return retained();
|
|
457
|
+
try {
|
|
458
|
+
intent = mode === "inspect" ? restoreStore.confirmInspectedReady(expected, recipient, authority) :
|
|
459
|
+
restoreStore.confirmReady(expected, recipient, authority);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
intent = retained();
|
|
462
|
+
if (intent?.phase !== "ready" || !isDeepStrictEqual(intent.readyRecipient ?? intent.recipient, recipient)) throw error;
|
|
463
|
+
}
|
|
464
|
+
const confirmed = retained();
|
|
465
|
+
return current() && confirmed?.phase === "ready" &&
|
|
466
|
+
isDeepStrictEqual(confirmed.readyRecipient ?? confirmed.recipient, recipient) ? confirmed : undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
380
469
|
async function deleteReservedTelegramTopicThroughReconciler(
|
|
381
470
|
deps: {
|
|
382
471
|
callApi?: <TResponse>(
|
|
@@ -447,6 +536,8 @@ async function deleteReservedTelegramTopicThroughReconciler(
|
|
|
447
536
|
}
|
|
448
537
|
|
|
449
538
|
export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
|
|
539
|
+
/** One user-facing answer for expired or previous-process routing controls. */
|
|
540
|
+
export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "⌛ Routing choice expired.";
|
|
450
541
|
|
|
451
542
|
export function isTelegramAllTabCommandExpired(
|
|
452
543
|
message: { date?: number; message_thread_id?: number },
|
|
@@ -517,6 +608,17 @@ export function createTelegramInboundBusProjectionRuntime(deps: {
|
|
|
517
608
|
};
|
|
518
609
|
}
|
|
519
610
|
|
|
611
|
+
/** Fenced temporary-Thread authority; see `captureTemporaryThreadAuthority`. */
|
|
612
|
+
interface TemporaryThreadAuthority {
|
|
613
|
+
store: Threads.TelegramWorkspaceRestore;
|
|
614
|
+
operatorUserId: number;
|
|
615
|
+
journalBindingKey: string;
|
|
616
|
+
epoch: number | string;
|
|
617
|
+
authority: Threads.TelegramWorkspaceRestoreAuthority;
|
|
618
|
+
isCurrent: () => boolean;
|
|
619
|
+
adopt: (entry: Threads.TelegramTemporaryThreadEntry) => Threads.TelegramTemporaryThreadEntry | undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
520
622
|
export interface TelegramInboundRouteRuntimeDeps<
|
|
521
623
|
TMessage extends TelegramRoutedMessage,
|
|
522
624
|
TCallbackQuery extends TelegramRoutedCallbackQuery,
|
|
@@ -530,6 +632,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
530
632
|
callApi?: <TResponse>(
|
|
531
633
|
method: string,
|
|
532
634
|
body: Record<string, unknown>,
|
|
635
|
+
options?: TelegramApiCallOptions,
|
|
533
636
|
) => Promise<TResponse>;
|
|
534
637
|
getCurrentInstanceId?: () => string | undefined;
|
|
535
638
|
getAdmissionScope?: () => string | undefined;
|
|
@@ -567,11 +670,28 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
567
670
|
TCallbackQuery,
|
|
568
671
|
TMessage
|
|
569
672
|
>;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
673
|
+
getWorkspaceRestoreStore?: () => Threads.TelegramWorkspaceRestore | undefined;
|
|
674
|
+
captureWorkspaceExternalProtection?: ReturnType<typeof createTelegramWorkspaceExternalProtectionCapture>;
|
|
675
|
+
/** Strict committed abandonment plus retained original; shared by Restore and temporary-input cancellation. */
|
|
676
|
+
inspectRestoreSourceAbandonment?: (updateId: number, journalBindingKey: string) => Threads.TelegramTemporaryThreadCancellationEvidence | undefined;
|
|
677
|
+
/** Strict active-journal observation only; a hint or missing source never substitutes for this ACK. */
|
|
678
|
+
inspectRestoreSourceCompletion?: (expected: Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) =>
|
|
679
|
+
(Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) | undefined;
|
|
680
|
+
inspectRestoreQueuedReceipt?: (expected: TelegramUpdateJournalQueuedCompletion & { journalBindingKey: string }) =>
|
|
681
|
+
TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
682
|
+
hasWorkspaceRestoreAuthority?: () => boolean;
|
|
683
|
+
/** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
|
|
684
|
+
inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[]) => readonly number[] | undefined;
|
|
685
|
+
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to 1000 ms. */
|
|
686
|
+
temporaryThreadCleanupDelayMs?: number;
|
|
687
|
+
getSessionGeneration?: () => number;
|
|
688
|
+
workspaceRestoreRecipient?: {
|
|
689
|
+
getSessionId: (ctx: TContext) => string | undefined;
|
|
690
|
+
getCwd: (ctx: TContext) => string | undefined;
|
|
691
|
+
getLeaderIdentity: Threads.TelegramLeaderThreadStateRuntime["getIdentity"];
|
|
692
|
+
followerRegistry: Pick<Bus.TelegramBusFollowerRegistry, "get" | "register">;
|
|
693
|
+
runFollower: ReturnType<typeof Bus.createTelegramBusWorkspaceRestoreController>;
|
|
694
|
+
};
|
|
575
695
|
bridgeRuntime: TelegramBridgeRuntime;
|
|
576
696
|
activeTurnRuntime: Queue.TelegramActiveTurnStore;
|
|
577
697
|
mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage, TContext>;
|
|
@@ -720,6 +840,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
720
840
|
const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
|
|
721
841
|
"allmenu:",
|
|
722
842
|
TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
|
|
843
|
+
TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX,
|
|
723
844
|
"compact:",
|
|
724
845
|
"menu:",
|
|
725
846
|
"model:",
|
|
@@ -755,12 +876,26 @@ export function createTelegramInboundRouteRuntime<
|
|
|
755
876
|
TContext,
|
|
756
877
|
TModel
|
|
757
878
|
>,
|
|
758
|
-
): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> {
|
|
879
|
+
): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> & {
|
|
880
|
+
expireRoutingInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["expireRoutingInput"]>;
|
|
881
|
+
shouldReviewHistoricalInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["shouldReviewHistoricalInput"]>;
|
|
882
|
+
shouldHoldPendingInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["shouldHoldPendingInput"]>;
|
|
883
|
+
forgetPreviousWorld(input: Updates.TelegramHeldSourcePreparation<TContext>,
|
|
884
|
+
captureTransport?: (ctx: TContext) => (() => boolean) | undefined): Promise<{ forgotten: number; deleted: number }>;
|
|
885
|
+
beforeQueueReceiptPublished: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["beforeQueueReceiptPublished"]>;
|
|
886
|
+
onQueueReceiptCommitted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onQueueReceiptCommitted"]>;
|
|
887
|
+
onQueueReceiptCompleted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onQueueReceiptCompleted"]>;
|
|
888
|
+
onUpdateCompleted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onUpdateCompleted"]>;
|
|
889
|
+
onWorkspaceRestoreRecipientObserved(follower: Bus.TelegramBusFollowerView, isCurrent: () => boolean,
|
|
890
|
+
ctx: TContext | undefined): Promise<void> | undefined;
|
|
891
|
+
waitForRestoreSettlement(): Promise<void>;
|
|
892
|
+
} {
|
|
759
893
|
type PendingRerouteCleanup =
|
|
760
894
|
| {
|
|
761
895
|
kind: "unbound";
|
|
762
896
|
target: { chatId: number; threadId: number };
|
|
763
897
|
messageId?: number;
|
|
898
|
+
temporaryThread?: Threads.TelegramTemporaryThreadEntry;
|
|
764
899
|
}
|
|
765
900
|
| {
|
|
766
901
|
kind: "previous-leader";
|
|
@@ -774,16 +909,32 @@ export function createTelegramInboundRouteRuntime<
|
|
|
774
909
|
type PendingUnboundReroute = {
|
|
775
910
|
sourceTarget: Queue.TelegramQueueTarget;
|
|
776
911
|
chooserMessageId?: number;
|
|
912
|
+
rootChooserText?: string;
|
|
777
913
|
messages: TMessage[];
|
|
778
|
-
createdAtMs: number;
|
|
779
914
|
dispatchKind: "prompt" | "command";
|
|
915
|
+
routingOperatorUserId?: number;
|
|
780
916
|
expiresAtMs?: number;
|
|
781
917
|
destinationSelected?: boolean;
|
|
782
918
|
selectionAttempted?: boolean;
|
|
919
|
+
workspaceRestore?: { operationId: string; record: Threads.TelegramTopicTargetRecord; messages: TMessage[] };
|
|
920
|
+
/** The acknowledged tab presenting an All source; `sourceTarget` is this tab, not the original's location. */
|
|
921
|
+
temporaryThread?: Threads.TelegramTemporaryThreadEntry;
|
|
922
|
+
/** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
|
|
923
|
+
temporaryMembership?: true;
|
|
783
924
|
dispatching?: boolean;
|
|
925
|
+
abandonment?: {
|
|
926
|
+
ownerUserId: number;
|
|
927
|
+
journalBindingKey: string;
|
|
928
|
+
leaderEpoch: number | string;
|
|
929
|
+
running?: boolean;
|
|
930
|
+
attempted?: boolean;
|
|
931
|
+
result?: NonNullable<ReturnType<typeof Updates.abandonTelegramDeferredUpdate>>;
|
|
932
|
+
};
|
|
784
933
|
pauseExpiry?: () => void;
|
|
785
934
|
stopExpiry?: () => void;
|
|
786
935
|
cleanup?: PendingRerouteCleanup;
|
|
936
|
+
/** Process-local protection only; restart-safe Forward issuance remains a release gate. */
|
|
937
|
+
foreignForwardIssued?: boolean;
|
|
787
938
|
foreignRetry?: {
|
|
788
939
|
instanceId: string;
|
|
789
940
|
threadId: number;
|
|
@@ -792,8 +943,17 @@ export function createTelegramInboundRouteRuntime<
|
|
|
792
943
|
finalizeMessage?: string;
|
|
793
944
|
};
|
|
794
945
|
const pendingUnboundReroutes = new Map<string, PendingUnboundReroute>();
|
|
946
|
+
const implicitThreadCreations = new Map<string, { ctx: TContext; updateId: number; operatorUserId: number;
|
|
947
|
+
journalBindingKey: string; executor: Threads.TelegramWorkspaceRestoreExecutor; generation: number | undefined; scope: string | undefined }>();
|
|
795
948
|
const guidedUnboundTopicKeys = new Set<string>();
|
|
796
949
|
let nextUnboundRerouteId = 0;
|
|
950
|
+
type RecoverySource = Updates.TelegramDeferredAbandonmentRecoveryPage["sources"][number];
|
|
951
|
+
type CancellationReview = {
|
|
952
|
+
id: string; target: Queue.TelegramQueueTarget; messageId: number; ownerUserId: number; journalBindingKey: string;
|
|
953
|
+
isCurrent: () => boolean; running?: boolean; nextAfterUpdateId?: number; unsupported: boolean;
|
|
954
|
+
sources: Array<RecoverySource & { preview: string; result?: NonNullable<ReturnType<RecoverySource["retry"]>> }>;
|
|
955
|
+
};
|
|
956
|
+
let cancellationReview: CancellationReview | undefined;
|
|
797
957
|
const requestDispatchNextQueuedTelegramTurn = (ctx: TContext): void => {
|
|
798
958
|
deps.dispatchNextQueuedTelegramTurn(ctx);
|
|
799
959
|
if (
|
|
@@ -833,7 +993,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
833
993
|
continue;
|
|
834
994
|
}
|
|
835
995
|
return record.threadName &&
|
|
836
|
-
|
|
996
|
+
ThreadNaming.isTelegramTopicThreadNameValidForSlot(
|
|
837
997
|
record.threadName,
|
|
838
998
|
record.slot,
|
|
839
999
|
)
|
|
@@ -915,35 +1075,34 @@ export function createTelegramInboundRouteRuntime<
|
|
|
915
1075
|
};
|
|
916
1076
|
schedule();
|
|
917
1077
|
};
|
|
918
|
-
const
|
|
919
|
-
|
|
1078
|
+
const prunePendingCommandReroutes = () => {
|
|
1079
|
+
// Deferred prompts and selected routes with cleanup work must retain their control.
|
|
1080
|
+
// Age is not settlement; the chooser capacity limit still bounds retained entries.
|
|
920
1081
|
for (const [id, entry] of pendingUnboundReroutes) {
|
|
921
1082
|
if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
|
|
922
1083
|
expirePendingCommand(id, entry);
|
|
923
|
-
} else if (nowMs - entry.createdAtMs > 30 * 60_000) {
|
|
924
|
-
removePendingReroute(id);
|
|
925
1084
|
}
|
|
926
1085
|
}
|
|
927
1086
|
};
|
|
928
1087
|
const storePendingUnboundReroute = (
|
|
929
1088
|
messages: TMessage[],
|
|
930
1089
|
dispatchKind: "prompt" | "command" = "prompt",
|
|
1090
|
+
presentationTarget?: { chatId: number; threadId: number },
|
|
931
1091
|
): string => {
|
|
932
|
-
|
|
1092
|
+
prunePendingCommandReroutes();
|
|
933
1093
|
if (pendingUnboundReroutes.size >= 100) {
|
|
934
1094
|
throw new Error("Telegram route chooser capacity reached; source remains retryable.");
|
|
935
1095
|
}
|
|
936
1096
|
nextUnboundRerouteId += 1;
|
|
937
1097
|
const id = nextUnboundRerouteId.toString(36);
|
|
938
1098
|
pendingUnboundReroutes.set(id, {
|
|
939
|
-
sourceTarget: {
|
|
1099
|
+
sourceTarget: presentationTarget ? { ...presentationTarget } : {
|
|
940
1100
|
chatId: messages[0]!.chat.id,
|
|
941
1101
|
...(typeof messages[0]!.message_thread_id === "number"
|
|
942
1102
|
? { threadId: messages[0]!.message_thread_id }
|
|
943
1103
|
: {}),
|
|
944
1104
|
},
|
|
945
1105
|
messages,
|
|
946
|
-
createdAtMs: Date.now(),
|
|
947
1106
|
dispatchKind,
|
|
948
1107
|
});
|
|
949
1108
|
const pending = pendingUnboundReroutes.get(id)!;
|
|
@@ -954,6 +1113,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
954
1113
|
const pending = pendingUnboundReroutes.get(id);
|
|
955
1114
|
if (!pending) return;
|
|
956
1115
|
pending.chooserMessageId = messageId;
|
|
1116
|
+
if (Number.isSafeInteger(messageId) && messageId! > 0) {
|
|
1117
|
+
const owner = deps.configStore.getAllowedUserId();
|
|
1118
|
+
if (owner !== undefined && pending.messages.every(source => source.from?.id === owner && !source.from.is_bot && source.chat.type === "private")) {
|
|
1119
|
+
try {
|
|
1120
|
+
const lifetime = Updates.armTelegramRoutingInputs(pending.messages, owner);
|
|
1121
|
+
if (lifetime) pending.routingOperatorUserId = lifetime.operatorUserId;
|
|
1122
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-clock" }); }
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
957
1125
|
if (messageId === undefined || pending.dispatchKind !== "command" ||
|
|
958
1126
|
pending.sourceTarget.threadId !== undefined || pending.selectionAttempted) return;
|
|
959
1127
|
const source = pending.messages[0];
|
|
@@ -1083,18 +1251,49 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1083
1251
|
),
|
|
1084
1252
|
);
|
|
1085
1253
|
};
|
|
1254
|
+
/** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
|
|
1255
|
+
const hasPendingRerouteForTarget = (target: Queue.TelegramQueueTarget, own?: (pending: PendingUnboundReroute) => boolean): boolean =>
|
|
1256
|
+
[...pendingUnboundReroutes.values()].some(pending => pending.sourceTarget.chatId === target.chatId &&
|
|
1257
|
+
pending.sourceTarget.threadId === target.threadId && !own?.(pending));
|
|
1258
|
+
const isOwnTemporaryChooser = (entry: Threads.TelegramTemporaryThreadEntry) => (pending: PendingUnboundReroute): boolean =>
|
|
1259
|
+
pending.temporaryThread?.token === entry.token && isDeepStrictEqual(pending.temporaryThread.source, entry.source);
|
|
1260
|
+
const hasOtherTemporaryThreadReroute = (target: Queue.TelegramQueueTarget, own: Threads.TelegramTemporaryThreadEntry): boolean =>
|
|
1261
|
+
hasPendingRerouteForTarget(target, isOwnTemporaryChooser(own));
|
|
1086
1262
|
const applyThreadCleanupPlan = async (
|
|
1087
1263
|
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
1088
1264
|
assertExecutionCurrent?: () => void,
|
|
1265
|
+
restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
|
|
1266
|
+
temporaryCleanup?: Threads.TelegramTemporaryThreadEntry,
|
|
1089
1267
|
): Promise<boolean> => {
|
|
1090
1268
|
assertExecutionCurrent?.();
|
|
1269
|
+
if ((restoreCleanup && restoreCleanup.routing?.cleanup !== "issued") ||
|
|
1270
|
+
(temporaryCleanup && !temporaryCleanup.cleanupIssued)) return false;
|
|
1271
|
+
let protectedTarget = false, temporaryDeleteConfirmed = false, temporaryTransportUncertain = false;
|
|
1091
1272
|
deps.recordThreadReconciliationPlan?.(plan);
|
|
1092
1273
|
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
1274
|
+
skipCloseBeforeDelete: (temporaryCleanup?.target?.chatId ?? 0) > 0,
|
|
1093
1275
|
isCleanupTargetProtected(target) {
|
|
1094
1276
|
assertExecutionCurrent?.();
|
|
1095
|
-
|
|
1277
|
+
// A tab's first source is not authority over sibling choosers, including unsettled cleanup-only work.
|
|
1278
|
+
const siblingProtected = !!temporaryCleanup && hasOtherTemporaryThreadReroute(target, temporaryCleanup);
|
|
1279
|
+
const lostTemporaryGrant = !!temporaryCleanup && !deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads()
|
|
1280
|
+
.some(entry => isDeepStrictEqual(entry, temporaryCleanup));
|
|
1281
|
+
const protectedNow = temporaryTransportUncertain || lostTemporaryGrant || siblingProtected || isRerouteTargetProtected(target, restoreCleanup, temporaryCleanup);
|
|
1282
|
+
protectedTarget ||= protectedNow;
|
|
1283
|
+
return protectedNow;
|
|
1096
1284
|
},
|
|
1097
|
-
callApi: deps.callApi
|
|
1285
|
+
callApi: temporaryCleanup && deps.callApi
|
|
1286
|
+
? async <TResponse>(method: string, body: Record<string, unknown>): Promise<TResponse> => {
|
|
1287
|
+
try {
|
|
1288
|
+
assertExecutionCurrent?.();
|
|
1289
|
+
const result = await deps.callApi!<TResponse>(method, body, { maxAttempts: 1, retrySafety: "non-idempotent" });
|
|
1290
|
+
assertExecutionCurrent?.();
|
|
1291
|
+
if (result !== true) throw new Error("Temporary Thread cleanup lacks a positive API acknowledgement.");
|
|
1292
|
+
if (method === "deleteForumTopic") temporaryDeleteConfirmed = true;
|
|
1293
|
+
return result;
|
|
1294
|
+
} catch (error) { temporaryTransportUncertain = true; throw error; }
|
|
1295
|
+
}
|
|
1296
|
+
: deps.callApi,
|
|
1098
1297
|
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
|
|
1099
1298
|
deps.threadStore?.markStaleByTarget(
|
|
1100
1299
|
staleTarget,
|
|
@@ -1108,15 +1307,111 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1108
1307
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1109
1308
|
});
|
|
1110
1309
|
assertExecutionCurrent?.();
|
|
1111
|
-
return (result.incompleteActions?.length ?? 0) === 0;
|
|
1310
|
+
return !protectedTarget && (!temporaryCleanup || temporaryDeleteConfirmed) && (result.incompleteActions?.length ?? 0) === 0;
|
|
1311
|
+
};
|
|
1312
|
+
const findCreatedTemporaryThread = (target: Queue.TelegramQueueTarget): Threads.TelegramTemporaryThreadEntry | undefined => {
|
|
1313
|
+
try { return deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
|
|
1314
|
+
catch { return undefined; }
|
|
1112
1315
|
};
|
|
1113
|
-
|
|
1316
|
+
/**
|
|
1317
|
+
* One fenced capture of leader epoch, operator, journal binding and context for a temporary-Thread effect. `isCurrent`
|
|
1318
|
+
* re-reads every captured fact (plus an optional extra fence) at each boundary, so a delayed effect keeps the identity it
|
|
1319
|
+
* was scheduled under; `adopt` re-keys an entry to this executor before a write.
|
|
1320
|
+
*/
|
|
1321
|
+
const captureTemporaryThreadAuthority = (ctx: TContext, extra?: () => boolean): TemporaryThreadAuthority | undefined => {
|
|
1322
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
1323
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1324
|
+
const operatorUserId = deps.configStore.getAllowedUserId(), journalBindingKey = deps.getAdmissionJournalBinding?.();
|
|
1325
|
+
const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.();
|
|
1326
|
+
if (!store || !instanceId || epoch === undefined || operatorUserId === undefined || !journalBindingKey) return undefined;
|
|
1327
|
+
const isCurrent = (): boolean => deps.isContextActive?.(ctx) === true && deps.getCurrentInstanceId?.() === instanceId &&
|
|
1328
|
+
deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
1329
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
|
|
1330
|
+
deps.getAdmissionJournalBinding?.() === journalBindingKey && (extra?.() ?? true);
|
|
1331
|
+
const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent };
|
|
1332
|
+
return { store, operatorUserId, journalBindingKey, epoch, authority, isCurrent,
|
|
1333
|
+
adopt: entry => isDeepStrictEqual(entry.executor, authority.executor) ? entry : store.adoptTemporaryThread(entry, authority) };
|
|
1334
|
+
};
|
|
1335
|
+
const isTemporaryTabTarget = (target: Queue.TelegramQueueTarget | undefined): boolean =>
|
|
1336
|
+
!!target && findCreatedTemporaryThread(target) !== undefined;
|
|
1337
|
+
/** Restore may choose one input only when every other known group is cancelled or a live unassigned chooser that can be retained. */
|
|
1338
|
+
const areTemporaryThreadSiblingsAccountedForRestore = (entry: Threads.TelegramTemporaryThreadEntry, from: PendingUnboundReroute): boolean => {
|
|
1339
|
+
const target = entry.target;
|
|
1340
|
+
if (!target) return false;
|
|
1341
|
+
const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(from.messages) };
|
|
1342
|
+
const inputs = Threads.getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
|
|
1343
|
+
if (!inputs.some(input => isDeepStrictEqual(input, group)) || resolved.some(input => isDeepStrictEqual(input, group))) return false;
|
|
1344
|
+
const others = [...pendingUnboundReroutes.values()].filter(pending => pending !== from &&
|
|
1345
|
+
pending.sourceTarget.chatId === target.chatId && pending.sourceTarget.threadId === target.threadId);
|
|
1346
|
+
const groupOf = (pending: PendingUnboundReroute) => ({ journalBindingKey: entry.source.journalBindingKey,
|
|
1347
|
+
updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) });
|
|
1348
|
+
if (!others.every(pending => !!pending.abandonment && !pending.abandonment.result && !pending.destinationSelected &&
|
|
1349
|
+
!pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage &&
|
|
1350
|
+
!pending.workspaceRestore && !entry.forwardedInputs?.some(input => isDeepStrictEqual(input, groupOf(pending))) &&
|
|
1351
|
+
inputs.some(input => isDeepStrictEqual(input, groupOf(pending))))) return false;
|
|
1352
|
+
return inputs.every(input => isDeepStrictEqual(input, group) || resolved.some(other => isDeepStrictEqual(other, input)) ||
|
|
1353
|
+
others.some(pending => isDeepStrictEqual(groupOf(pending), input)));
|
|
1354
|
+
};
|
|
1355
|
+
const isRerouteTargetProtected = (target: Queue.TelegramQueueTarget, ownRestore?: Threads.TelegramWorkspaceRestoreIntent,
|
|
1356
|
+
ownTemporary?: Threads.TelegramTemporaryThreadEntry, restoreFrom?: PendingUnboundReroute): boolean => {
|
|
1114
1357
|
const matches = (candidate: Queue.TelegramQueueTarget): boolean =>
|
|
1115
1358
|
candidate.chatId === target.chatId && candidate.threadId === target.threadId;
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1359
|
+
const activeTarget = deps.activeTurnRuntime.getTarget();
|
|
1360
|
+
if ((activeTarget && matches(activeTarget)) ||
|
|
1361
|
+
deps.telegramQueueStore.getQueuedItems().some(item => item.target && matches(item.target)) ||
|
|
1362
|
+
(deps.getLiveThreadTargets?.() ?? []).some(matches) ||
|
|
1363
|
+
(deps.threadStore?.list() ?? []).some(record => matches(record.target)) ||
|
|
1364
|
+
(deps.threadStore?.listReservations() ?? []).some(record => matches(record.target)) ||
|
|
1365
|
+
(deps.threadStore?.listPendingProvisions() ?? []).some(record => record.target && matches(record.target))) return true;
|
|
1366
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
1367
|
+
if (deps.getWorkspaceRestoreStore && !store) return true;
|
|
1368
|
+
let temporary: Threads.TelegramTemporaryThreadEntry[];
|
|
1369
|
+
try { temporary = store?.listTemporaryThreads() ?? []; } catch { return true; }
|
|
1370
|
+
// Only the source's own Forward/Cancel may remove its retained tab; adoption may have changed its executor.
|
|
1371
|
+
const ownTab = (entry: Threads.TelegramTemporaryThreadEntry): boolean => !!ownTemporary && entry.token === ownTemporary.token &&
|
|
1372
|
+
isDeepStrictEqual(entry.source, ownTemporary.source) && isDeepStrictEqual(entry.target, ownTemporary.target);
|
|
1373
|
+
if (temporary.some(entry => !!entry.target && matches(entry.target) &&
|
|
1374
|
+
(!ownTab(entry) || (Threads.getTelegramTemporaryThreadInputs(entry).length > 1 &&
|
|
1375
|
+
!Threads.isTelegramTemporaryThreadFullyResolved(entry) &&
|
|
1376
|
+
!(restoreFrom && areTemporaryThreadSiblingsAccountedForRestore(entry, restoreFrom)))))) return true;
|
|
1377
|
+
// Whole-tab cleanup after cancellation also needs a fresh census of retained arrivals no membership has seen.
|
|
1378
|
+
if (!restoreFrom && temporary.some(entry => ownTab(entry) && !!(entry.cancelledInputs?.length || entry.completedInputs?.length))) {
|
|
1379
|
+
try {
|
|
1380
|
+
const required = [...new Set([deps.getAdmissionJournalBinding?.(), ownTemporary!.source.journalBindingKey,
|
|
1381
|
+
...Threads.getTelegramTemporaryThreadInputs(ownTemporary!).map(input => input.journalBindingKey)].filter((key): key is string => !!key))];
|
|
1382
|
+
if (deps.inspectTemporaryThreadSources?.({ chatId: target.chatId, threadId: target.threadId! }, required)?.length !== 0) return true;
|
|
1383
|
+
} catch { return true; }
|
|
1384
|
+
}
|
|
1385
|
+
const retained = store?.list() ?? [];
|
|
1386
|
+
// Exempt only the exact operation's old target. Cleanup itself also requires its issued grant.
|
|
1387
|
+
if (ownRestore && (!matches(ownRestore.request.binding.target) ||
|
|
1388
|
+
!retained.some(intent => isDeepStrictEqual(intent, ownRestore)))) return true;
|
|
1389
|
+
if (retained.some(intent => (matches(intent.request.binding.target) || matches(intent.request.target)) &&
|
|
1390
|
+
!(ownRestore && isDeepStrictEqual(intent, ownRestore) && matches(intent.request.binding.target)))) return true;
|
|
1391
|
+
if (!ownRestore) return false;
|
|
1392
|
+
let acceptedWorkClear = false;
|
|
1393
|
+
try {
|
|
1394
|
+
deps.threadStore?.withWorkspaceRestoreSnapshot(ownRestore, snapshot => {
|
|
1395
|
+
const bindings = snapshot.workspaceBindings?.filter(binding => binding.bindingKey === ownRestore.request.binding.bindingKey) ?? [];
|
|
1396
|
+
if (bindings.length !== 1 || !deps.captureWorkspaceExternalProtection) return undefined;
|
|
1397
|
+
const binding = bindings[0]!;
|
|
1398
|
+
// Relocation does not erase predecessor references or prove where their work executes.
|
|
1399
|
+
const journalBindingKeys = [...new Set([
|
|
1400
|
+
...(ownRestore.request.binding.journalBindingKeys ?? []), ...(binding.journalBindingKeys ?? []),
|
|
1401
|
+
])];
|
|
1402
|
+
const journalSources = [...new Map([
|
|
1403
|
+
...(ownRestore.request.binding.journalSources ?? []), ...(binding.journalSources ?? []),
|
|
1404
|
+
].map(source => [JSON.stringify([source.sessionId, source.recipientBindingKey]), source])).values()];
|
|
1405
|
+
acceptedWorkClear = deps.captureWorkspaceExternalProtection({ ...binding, target: ownRestore.request.binding.target,
|
|
1406
|
+
journalBindingKeys, journalSources },
|
|
1407
|
+
{ requireBindingProvenance: true }).acceptedWork === "clear";
|
|
1408
|
+
return undefined;
|
|
1409
|
+
});
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
acceptedWorkClear = false;
|
|
1412
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-protection" });
|
|
1413
|
+
}
|
|
1414
|
+
return !acceptedWorkClear;
|
|
1120
1415
|
};
|
|
1121
1416
|
const dismissRerouteChooserMessage = async (
|
|
1122
1417
|
query: TCallbackQuery,
|
|
@@ -1151,6 +1446,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1151
1446
|
target: { chatId: number; threadId: number } | undefined,
|
|
1152
1447
|
messageId: number | undefined,
|
|
1153
1448
|
assertExecutionCurrent?: () => void,
|
|
1449
|
+
ownTemporary?: Threads.TelegramTemporaryThreadEntry,
|
|
1154
1450
|
): Promise<boolean> => {
|
|
1155
1451
|
if (!target || !deps.threadStore) return true;
|
|
1156
1452
|
const nowMs = Date.now();
|
|
@@ -1173,11 +1469,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1173
1469
|
},
|
|
1174
1470
|
],
|
|
1175
1471
|
});
|
|
1176
|
-
|
|
1472
|
+
// A temporary grant licenses this tab only, not unrelated cleanup emitted by the general planner.
|
|
1473
|
+
const scoped = ownTemporary ? { ...plan, actions: plan.actions.filter(action =>
|
|
1474
|
+
action.kind === "close-delete-unbound-topic" && isDeepStrictEqual(action.target, target)) } : plan;
|
|
1475
|
+
return applyThreadCleanupPlan(scoped, assertExecutionCurrent, undefined, ownTemporary);
|
|
1177
1476
|
};
|
|
1178
1477
|
const closePreviousLeaderThread = async (
|
|
1179
1478
|
target: { chatId: number; threadId: number } | undefined,
|
|
1180
1479
|
assertExecutionCurrent?: () => void,
|
|
1480
|
+
restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
|
|
1181
1481
|
): Promise<boolean> => {
|
|
1182
1482
|
if (!target || !deps.threadStore) return true;
|
|
1183
1483
|
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -1193,12 +1493,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1193
1493
|
: {}),
|
|
1194
1494
|
},
|
|
1195
1495
|
],
|
|
1196
|
-
}, assertExecutionCurrent);
|
|
1496
|
+
}, assertExecutionCurrent, restoreCleanup);
|
|
1197
1497
|
};
|
|
1198
1498
|
const closeReplacedFollowerThread = async (
|
|
1199
1499
|
target: { chatId: number; threadId: number } | undefined,
|
|
1200
1500
|
instanceId: string | undefined,
|
|
1201
1501
|
assertExecutionCurrent?: () => void,
|
|
1502
|
+
restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
|
|
1202
1503
|
): Promise<boolean> => {
|
|
1203
1504
|
if (!target || !deps.threadStore) return true;
|
|
1204
1505
|
const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -1214,8 +1515,692 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1214
1515
|
: {}),
|
|
1215
1516
|
},
|
|
1216
1517
|
],
|
|
1217
|
-
}, assertExecutionCurrent);
|
|
1518
|
+
}, assertExecutionCurrent, restoreCleanup);
|
|
1519
|
+
};
|
|
1520
|
+
// Worker ACK observers outlive callback admission. Re-enter the existing Workspace gate;
|
|
1521
|
+
// never await this continuation while holding the dispatch invocation's gate.
|
|
1522
|
+
const restoreSettlementTasks = new Set<Promise<void>>();
|
|
1523
|
+
/**
|
|
1524
|
+
* After a Restore into a temporary tab reached positive terminal settlement, every still-unassigned sibling there is retained
|
|
1525
|
+
* privately and cancelled without Pi delivery. Missing proof, authority or abandonment leaves that sibling pending and the
|
|
1526
|
+
* tab's temporary entry protected; selected, queued, running or unknown work is never touched.
|
|
1527
|
+
*/
|
|
1528
|
+
const disposeTemporaryThreadSiblingsAfterRestore = async (intent: Threads.TelegramWorkspaceRestoreIntent, ctx: TContext,
|
|
1529
|
+
restoreCurrent: () => boolean): Promise<void> => {
|
|
1530
|
+
const target = intent.request.target, cap = captureTemporaryThreadAuthority(ctx, restoreCurrent);
|
|
1531
|
+
const entry = findCreatedTemporaryThread(target);
|
|
1532
|
+
if (!cap || !entry || !entry.target || !cap.isCurrent()) return;
|
|
1533
|
+
const operatorUserId = intent.operatorUserId, journalBindingKey = intent.request.source.journalBindingKey;
|
|
1534
|
+
if (cap.operatorUserId !== operatorUserId || cap.journalBindingKey !== journalBindingKey ||
|
|
1535
|
+
entry.operatorUserId !== operatorUserId || entry.source.journalBindingKey !== journalBindingKey) return;
|
|
1536
|
+
const group = { journalBindingKey, updateIds: [...intent.request.source.updateIds] };
|
|
1537
|
+
if (!Threads.getTelegramTemporaryThreadInputs(entry).some(input => isDeepStrictEqual(input, group))) return;
|
|
1538
|
+
for (const [id, pending] of [...pendingUnboundReroutes]) {
|
|
1539
|
+
const cancellation = pending.abandonment, source = pending.messages[0];
|
|
1540
|
+
if (pending.sourceTarget.chatId !== target.chatId || pending.sourceTarget.threadId !== target.threadId ||
|
|
1541
|
+
pending.workspaceRestore?.operationId === intent.request.operationId || !cancellation || !source || cancellation.result ||
|
|
1542
|
+
cancellation.running) continue;
|
|
1543
|
+
const current = (): boolean => cap.isCurrent() &&
|
|
1544
|
+
pendingUnboundReroutes.get(id) === pending && pending.abandonment === cancellation &&
|
|
1545
|
+
!pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry &&
|
|
1546
|
+
!pending.cleanup && !pending.finalizeMessage && !pending.workspaceRestore &&
|
|
1547
|
+
Updates.getTelegramUpdateExecutionFence(source)?.signal.aborted === false &&
|
|
1548
|
+
cancellation.ownerUserId === operatorUserId && cancellation.journalBindingKey === journalBindingKey;
|
|
1549
|
+
if (!current()) continue;
|
|
1550
|
+
cancellation.running = true;
|
|
1551
|
+
try {
|
|
1552
|
+
const result = Updates.abandonTelegramDeferredUpdate(source, { operatorAuthorityId: `telegram-owner:${operatorUserId}`, isCurrent: current });
|
|
1553
|
+
if (!result) continue;
|
|
1554
|
+
cancellation.attempted = true;
|
|
1555
|
+
cancellation.result = result;
|
|
1556
|
+
if (!recordCancelledTemporaryThreadInput(pending, ctx, current)) continue;
|
|
1557
|
+
await retireCancellationChooser(id, pending, current);
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-restore-sibling", rerouteId: id });
|
|
1560
|
+
} finally { cancellation.running = false; }
|
|
1561
|
+
}
|
|
1562
|
+
// The entry is released only after every sibling is gone and the Restore group's completion is the single uncancelled input.
|
|
1563
|
+
if (hasPendingRerouteForTarget(target, pending => pending.workspaceRestore?.operationId === intent.request.operationId) ||
|
|
1564
|
+
!cap.isCurrent()) return;
|
|
1565
|
+
const fresh = findCreatedTemporaryThread(target), owned = fresh && cap.adopt(fresh);
|
|
1566
|
+
if (owned && cap.isCurrent()) cap.store.retireTemporaryThread(owned, cap.authority, group);
|
|
1567
|
+
};
|
|
1568
|
+
const observeRestoreSettlement = (signal:
|
|
1569
|
+
| { kind: "source"; evidence: Threads.TelegramWorkspaceRestoreSourceSettlement }
|
|
1570
|
+
| { kind: "recipient"; follower: Bus.TelegramBusFollowerView; isCurrent: () => boolean },
|
|
1571
|
+
ctx: TContext): Promise<void> | undefined => {
|
|
1572
|
+
if (!deps.hasWorkspaceRestoreAuthority?.() || !deps.runWorkspaceOperation || !deps.getWorkspaceRestoreStore ||
|
|
1573
|
+
!deps.getSessionGeneration || deps.isContextActive?.(ctx) === false) return;
|
|
1574
|
+
const evidence = signal.kind === "source" ? structuredClone(signal.evidence) : undefined;
|
|
1575
|
+
const follower = signal.kind === "recipient" ? structuredClone(signal.follower) : undefined;
|
|
1576
|
+
const recipientCurrent = (): boolean => {
|
|
1577
|
+
if (signal.kind !== "recipient" || !follower) return true;
|
|
1578
|
+
const live = deps.workspaceRestoreRecipient?.followerRegistry.get(follower.instanceId);
|
|
1579
|
+
return signal.isCurrent() && !!live && Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1580
|
+
isDeepStrictEqual({ ...live, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined },
|
|
1581
|
+
{ ...follower, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined });
|
|
1582
|
+
};
|
|
1583
|
+
const recipientMatches = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean => {
|
|
1584
|
+
if (!follower) return true;
|
|
1585
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1586
|
+
return ready?.kind === "follower" && ready.instanceId === follower.instanceId && ready.sessionId === follower.sessionId &&
|
|
1587
|
+
ready.generation === follower.registrationGeneration && !!follower.cwd &&
|
|
1588
|
+
intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
|
|
1589
|
+
intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
|
|
1590
|
+
};
|
|
1591
|
+
// A same-session successor already registered on the relocated target may prove an issued grant by inspection only.
|
|
1592
|
+
const inspectableSuccessor = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean =>
|
|
1593
|
+
!!follower?.registrationGeneration && !!follower.cwd && (intent.phase === "relocated" || intent.phase === "recipient-issued") &&
|
|
1594
|
+
intent.request.owner.owner?.kind === "manual-follower" && follower.sessionId === intent.request.binding.sessionId &&
|
|
1595
|
+
intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
|
|
1596
|
+
intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
|
|
1597
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1598
|
+
const operatorUserId = deps.configStore.getAllowedUserId();
|
|
1599
|
+
const generation = deps.getSessionGeneration(), scope = deps.getAdmissionScope?.();
|
|
1600
|
+
const sourceBinding = deps.getAdmissionJournalBinding?.();
|
|
1601
|
+
if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope ||
|
|
1602
|
+
!sourceBinding || (evidence && evidence.journalBindingKey !== sourceBinding) || !recipientCurrent()) return;
|
|
1603
|
+
// This same-session leader already runs on the relocated target; it may only inspect an issued leader grant.
|
|
1604
|
+
const leaderSessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
|
|
1605
|
+
const leaderCwd = deps.workspaceRestoreRecipient?.getCwd(ctx);
|
|
1606
|
+
const leaderAt = (request: Threads.TelegramWorkspaceRestoreRequest): boolean => {
|
|
1607
|
+
const ports = deps.workspaceRestoreRecipient;
|
|
1608
|
+
const cwd = ports?.getCwd(ctx), local = ports?.getLeaderIdentity();
|
|
1609
|
+
return !!cwd && cwd === leaderCwd && ports?.getSessionId(ctx) === leaderSessionId &&
|
|
1610
|
+
leaderSessionId === request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd &&
|
|
1611
|
+
!!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target);
|
|
1612
|
+
};
|
|
1613
|
+
const observeLeaderRestore = (intent: Threads.TelegramWorkspaceRestoreIntent, recipient: Threads.TelegramWorkspaceRestoreRecipient,
|
|
1614
|
+
isCurrent: () => boolean): Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>> => {
|
|
1615
|
+
const threads = deps.threadStore;
|
|
1616
|
+
if (!threads || !isCurrent() || !leaderAt(intent.request)) return undefined;
|
|
1617
|
+
const { request } = intent;
|
|
1618
|
+
let observation: Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>>;
|
|
1619
|
+
threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
|
|
1620
|
+
if (!isCurrent() || !leaderAt(request)) return;
|
|
1621
|
+
const bindings = (snapshot.workspaceBindings ?? []).filter(value => value.bindingKey === request.binding.bindingKey);
|
|
1622
|
+
const live = bindings[0];
|
|
1623
|
+
const owners = snapshot.threads.filter(value => value.status === "active" &&
|
|
1624
|
+
(value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
|
|
1625
|
+
if (bindings.length !== 1 || live?.sessionId !== request.binding.sessionId || live.cwd !== request.binding.cwd ||
|
|
1626
|
+
live.slot !== request.binding.slot || live.inactiveSinceMs !== undefined || !isDeepStrictEqual(live.target, request.target) ||
|
|
1627
|
+
owners.length !== 1 || owners[0]?.owner?.kind !== "leader" || owners[0].instanceId !== recipient.instanceId ||
|
|
1628
|
+
owners[0].owner.instanceId !== recipient.instanceId || !owners[0].owner.cwd ||
|
|
1629
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) !== request.binding.cwd ||
|
|
1630
|
+
owners[0].profileKey !== request.owner.profileKey || owners[0].slot !== request.binding.slot || !isDeepStrictEqual(owners[0].target, request.target)) return;
|
|
1631
|
+
observation = { operationId: request.operationId, recipient, target: request.target, slot: request.binding.slot!, ready: true };
|
|
1632
|
+
});
|
|
1633
|
+
return isCurrent() && leaderAt(request) ? observation : undefined;
|
|
1634
|
+
};
|
|
1635
|
+
// Same-process lost replies stay with their chooser; this path only serves a restarted leader process.
|
|
1636
|
+
const inspectableLeader = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean => !!evidence &&
|
|
1637
|
+
intent.request.owner.owner?.kind === "leader" && leaderAt(intent.request) &&
|
|
1638
|
+
(intent.phase === "relocated" ? intent.request.owner.instanceId !== instanceId
|
|
1639
|
+
: intent.phase === "recipient-issued" && intent.recipient?.kind === "leader" && intent.recipient.instanceId !== instanceId);
|
|
1640
|
+
const current = (): boolean => deps.hasWorkspaceRestoreAuthority?.() === true && deps.isContextActive?.(ctx) !== false &&
|
|
1641
|
+
deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
1642
|
+
deps.getAdmissionJournalBinding?.() === sourceBinding && deps.getCurrentInstanceId?.() === instanceId &&
|
|
1643
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId && recipientCurrent();
|
|
1644
|
+
const recoveryRecipient = (intent: Threads.TelegramWorkspaceRestoreIntent): Bus.TelegramBusFollowerView | undefined => {
|
|
1645
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1646
|
+
if (!deps.inspectRestoreSourceCompletion || !deps.workspaceRestoreRecipient || intent.phase !== "ready" ||
|
|
1647
|
+
!intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
|
|
1648
|
+
!intent.routing.acceptances?.some(value => value.kind === "forwarded")) return undefined;
|
|
1649
|
+
const live = deps.workspaceRestoreRecipient.followerRegistry.get(follower?.instanceId ?? ready?.instanceId ?? "");
|
|
1650
|
+
return ready?.kind === "follower" && live?.registrationGeneration && live.cwd &&
|
|
1651
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1652
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION) &&
|
|
1653
|
+
live.sessionId === intent.request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
|
|
1654
|
+
live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) ? live : undefined;
|
|
1655
|
+
};
|
|
1656
|
+
const recoveryLeader = (intent: Threads.TelegramWorkspaceRestoreIntent): Threads.TelegramWorkspaceRestoreRecipient | undefined => {
|
|
1657
|
+
const ready = intent.readyRecipient ?? intent.recipient;
|
|
1658
|
+
if (evidence?.kind !== "completed" || !deps.inspectRestoreSourceCompletion || !deps.threadStore ||
|
|
1659
|
+
intent.phase !== "ready" || !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
|
|
1660
|
+
intent.request.owner.owner?.kind !== "leader" || ready?.kind !== "leader" || ready.sessionId !== intent.request.binding.sessionId ||
|
|
1661
|
+
!leaderAt(intent.request) ||
|
|
1662
|
+
!intent.routing.acceptances?.some(value => value.kind === "completed" || value.kind === "queued")) return undefined;
|
|
1663
|
+
return { kind: "leader", instanceId, sessionId: intent.request.binding.sessionId!, generation: String(generation) };
|
|
1664
|
+
};
|
|
1665
|
+
let selected: string[];
|
|
1666
|
+
try {
|
|
1667
|
+
selected = deps.getWorkspaceRestoreStore()?.list().filter(intent => {
|
|
1668
|
+
if (intent.request.source.journalBindingKey !== sourceBinding) return false;
|
|
1669
|
+
if (evidence && intent.request.source.updateIds.some(id => evidence.updateIds.includes(id))) return true;
|
|
1670
|
+
if (((evidence?.kind === "completed" || follower) && recoveryRecipient(intent)) || recoveryLeader(intent)) return true;
|
|
1671
|
+
if (follower && deps.workspaceRestoreRecipient && inspectableSuccessor(intent)) return true;
|
|
1672
|
+
if (deps.threadStore && inspectableLeader(intent)) return true;
|
|
1673
|
+
// Completion rechecks an unsent Restore whose originals may all have been abandoned before its retirement published.
|
|
1674
|
+
if (evidence?.kind === "completed" && deps.inspectRestoreSourceAbandonment && intent.phase === "ready" &&
|
|
1675
|
+
intent.routing === undefined) return true;
|
|
1676
|
+
// Hints only wake fully settled, unissued cleanup. They never settle another source.
|
|
1677
|
+
return (evidence?.kind === "completed" || !!follower) && intent.phase === "ready" && intent.routing?.cleanup === undefined &&
|
|
1678
|
+
recipientMatches(intent) && intent.request.source.updateIds.every(id =>
|
|
1679
|
+
intent.routing?.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)));
|
|
1680
|
+
}).map(intent => intent.request.operationId) ?? [];
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" });
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
if (!selected.length || !current()) return;
|
|
1686
|
+
const task = Promise.resolve().then(() => deps.runWorkspaceOperation!({ operationId: `restore-settlement-${randomBytes(16).toString("hex")}`,
|
|
1687
|
+
operationKind: "workspace.restore-settlement", scopes: [{ kind: "profile" }] }, async () => {
|
|
1688
|
+
if (!current()) return;
|
|
1689
|
+
const store = deps.getWorkspaceRestoreStore!();
|
|
1690
|
+
if (!store) return;
|
|
1691
|
+
const authority: Threads.TelegramWorkspaceRestoreAuthority = {
|
|
1692
|
+
executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
|
|
1693
|
+
const assertCurrent = (): void => { if (!current()) throw new Error("Workspace Restore settlement authority ended."); };
|
|
1694
|
+
// Exact owner abandonment of every original is terminal for an unsent Restore; no cleanup or delivery follows.
|
|
1695
|
+
const abandoned = (value: Threads.TelegramWorkspaceRestoreIntent): boolean => {
|
|
1696
|
+
if (evidence?.kind !== "completed" || !value.request.source.updateIds.length) return false;
|
|
1697
|
+
return value.request.source.updateIds.every(updateId => {
|
|
1698
|
+
const proof = deps.inspectRestoreSourceAbandonment?.(updateId, sourceBinding);
|
|
1699
|
+
assertCurrent();
|
|
1700
|
+
return proof?.journalBindingKey === sourceBinding && proof.updateId === updateId &&
|
|
1701
|
+
proof.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
|
|
1702
|
+
});
|
|
1703
|
+
};
|
|
1704
|
+
for (const operationId of selected) {
|
|
1705
|
+
assertCurrent();
|
|
1706
|
+
let intent = store.list().find(value => value.request.operationId === operationId);
|
|
1707
|
+
let settlementCurrent = current;
|
|
1708
|
+
let settlementCanonicalCurrent: (() => boolean) | undefined;
|
|
1709
|
+
let retired = false;
|
|
1710
|
+
if (follower && intent && inspectableSuccessor(intent) && intent.operatorUserId === operatorUserId) {
|
|
1711
|
+
const ports = deps.workspaceRestoreRecipient!;
|
|
1712
|
+
const successor: Threads.TelegramWorkspaceRestoreRecipient = { kind: "follower", instanceId: follower.instanceId,
|
|
1713
|
+
sessionId: follower.sessionId!, generation: follower.registrationGeneration! };
|
|
1714
|
+
const { request } = intent;
|
|
1715
|
+
// Adoption plus inspect never consumes the original apply grant, delivers input or issues cleanup.
|
|
1716
|
+
await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
|
|
1717
|
+
getRecipient: () => current() ? successor : undefined,
|
|
1718
|
+
async runRecipient(action) {
|
|
1719
|
+
if (action.mode !== "inspect" || !action.isCurrent()) return undefined;
|
|
1720
|
+
return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
|
|
1721
|
+
sessionId: successor.sessionId, slot: request.binding.slot!, target: request.target,
|
|
1722
|
+
oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
|
|
1723
|
+
} });
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
if (intent && inspectableLeader(intent) && intent.operatorUserId === operatorUserId) {
|
|
1727
|
+
const threads = deps.threadStore!;
|
|
1728
|
+
const leader: Threads.TelegramWorkspaceRestoreRecipient = { kind: "leader", instanceId,
|
|
1729
|
+
sessionId: intent.request.binding.sessionId!, generation: String(generation) };
|
|
1730
|
+
const { request } = intent;
|
|
1731
|
+
await threads.load();
|
|
1732
|
+
assertCurrent();
|
|
1733
|
+
await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
|
|
1734
|
+
getRecipient: () => current() && leaderAt(request) ? leader : undefined,
|
|
1735
|
+
async runRecipient(action) {
|
|
1736
|
+
if (action.mode !== "inspect") return undefined;
|
|
1737
|
+
return observeLeaderRestore(action.intent, leader, action.isCurrent);
|
|
1738
|
+
} });
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
if (intent?.phase === "ready" && intent.routing === undefined && intent.operatorUserId === operatorUserId &&
|
|
1742
|
+
intent.request.source.journalBindingKey === sourceBinding && abandoned(intent)) {
|
|
1743
|
+
const owned = isDeepStrictEqual(intent.executor, authority.executor) ? intent : store.adopt(intent, authority);
|
|
1744
|
+
assertCurrent();
|
|
1745
|
+
if (owned) store.retireAbandoned(owned, owned.request.source.updateIds, authority);
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
const liveRecipient = intent && recoveryRecipient(intent), localRecipient = intent && recoveryLeader(intent);
|
|
1749
|
+
if (intent && (liveRecipient || localRecipient) && intent.request.source.journalBindingKey === sourceBinding) {
|
|
1750
|
+
const expected = intent, ports = deps.workspaceRestoreRecipient!;
|
|
1751
|
+
const recoveryCurrent = (): boolean => current() && (localRecipient ? leaderAt(expected.request) && isDeepStrictEqual(
|
|
1752
|
+
recoveryLeader(expected), localRecipient) : isDeepStrictEqual(
|
|
1753
|
+
{ ...recoveryRecipient(expected), lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined },
|
|
1754
|
+
{ ...liveRecipient, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }));
|
|
1755
|
+
// Storage authority callbacks check live fences only; canonical reads cannot re-enter their transaction lock.
|
|
1756
|
+
const recoveryAuthority = { ...authority, isCurrent: recoveryCurrent };
|
|
1757
|
+
settlementCurrent = recoveryCurrent;
|
|
1758
|
+
if (localRecipient) {
|
|
1759
|
+
settlementCanonicalCurrent = () => !!intent && !!observeLeaderRestore(intent, localRecipient, recoveryCurrent);
|
|
1760
|
+
if (!settlementCanonicalCurrent()) continue; // A local target alone cannot authorize executor adoption.
|
|
1761
|
+
}
|
|
1762
|
+
const inspect = (acceptance: Threads.TelegramWorkspaceRestoreSourceAcceptance,
|
|
1763
|
+
operation: Threads.TelegramWorkspaceRestoreIntent): boolean => {
|
|
1764
|
+
const completion = { journalBindingKey: sourceBinding, updateId: acceptance.updateId,
|
|
1765
|
+
sourceSha256: acceptance.sourceSha256,
|
|
1766
|
+
completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(operation, acceptance) };
|
|
1767
|
+
const observed = deps.inspectRestoreSourceCompletion!({ ...completion });
|
|
1768
|
+
if (!recoveryCurrent()) throw new Error("Workspace Restore completion observation authority ended.");
|
|
1769
|
+
return isDeepStrictEqual(observed, completion);
|
|
1770
|
+
};
|
|
1771
|
+
// Inspect proof before adopting: missing/foreign ACKs cannot even re-key the retained operation.
|
|
1772
|
+
const accepted = expected.routing!.acceptances!.filter(value =>
|
|
1773
|
+
(localRecipient ? value.kind === "completed" || value.kind === "queued" : value.kind === "forwarded") && inspect(value, expected));
|
|
1774
|
+
if (accepted.length) {
|
|
1775
|
+
const successor: Threads.TelegramWorkspaceRestoreRecipient = localRecipient ?? { kind: "follower", instanceId: liveRecipient!.instanceId,
|
|
1776
|
+
sessionId: liveRecipient!.sessionId!, generation: liveRecipient!.registrationGeneration! };
|
|
1777
|
+
const { request } = expected;
|
|
1778
|
+
const inspected = await advanceTelegramWorkspaceRestore({ request, authority: recoveryAuthority, restoreStore: store, inspectOnly: true,
|
|
1779
|
+
getRecipient: () => recoveryCurrent() ? successor : undefined,
|
|
1780
|
+
async runRecipient(action) {
|
|
1781
|
+
if (action.mode !== "inspect" || !action.isCurrent()) return undefined;
|
|
1782
|
+
if (localRecipient) return observeLeaderRestore(action.intent, successor, action.isCurrent);
|
|
1783
|
+
return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
|
|
1784
|
+
sessionId: successor.sessionId, slot: request.binding.slot!, target: request.target,
|
|
1785
|
+
oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
|
|
1786
|
+
} });
|
|
1787
|
+
if (!inspected || !recoveryCurrent()) continue;
|
|
1788
|
+
intent = inspected;
|
|
1789
|
+
if (settlementCanonicalCurrent && !settlementCanonicalCurrent()) continue;
|
|
1790
|
+
// The await cannot lend earlier readback; confirm each immutable proof again before publication.
|
|
1791
|
+
const settledIds = new Set(intent.routing!.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
|
|
1792
|
+
const recovered = new Map<string, Threads.TelegramWorkspaceRestoreSourceSettlement>();
|
|
1793
|
+
for (const acceptance of accepted.filter(value => !settledIds.has(value.updateId) && inspect(value, intent!))) {
|
|
1794
|
+
const key = acceptance.kind === "queued" ? JSON.stringify([acceptance.receiptId, acceptance.queueKind]) : "completed";
|
|
1795
|
+
const settlement = recovered.get(key) ?? { journalBindingKey: sourceBinding, updateIds: [],
|
|
1796
|
+
...(acceptance.kind === "queued" ? { kind: "queue-completed" as const, receiptId: acceptance.receiptId, queueKind: acceptance.queueKind }
|
|
1797
|
+
: { kind: "completed" as const }) };
|
|
1798
|
+
settlement.updateIds.push(acceptance.updateId);
|
|
1799
|
+
recovered.set(key, settlement);
|
|
1800
|
+
}
|
|
1801
|
+
for (const settlement of recovered.values()) {
|
|
1802
|
+
intent = store.recordSourceSettlement(intent!, settlement, recoveryAuthority);
|
|
1803
|
+
if (!intent) break;
|
|
1804
|
+
}
|
|
1805
|
+
if (!intent) continue;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
// Source hints carry no registry fence; cleanup must keep the acknowledged recipient exact across awaits.
|
|
1809
|
+
const readyRecipientCurrent = (): boolean => {
|
|
1810
|
+
const ready = intent?.readyRecipient ?? intent?.recipient;
|
|
1811
|
+
if (intent?.phase !== "ready" || ready?.kind !== "follower") return true;
|
|
1812
|
+
const live = deps.workspaceRestoreRecipient?.followerRegistry.get(ready.instanceId);
|
|
1813
|
+
return !!live?.cwd && live.registrationGeneration === ready.generation && live.sessionId === ready.sessionId &&
|
|
1814
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
|
|
1815
|
+
live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) &&
|
|
1816
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
|
|
1817
|
+
Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION);
|
|
1818
|
+
};
|
|
1819
|
+
const settlementAuthority = { ...authority, isCurrent: function () { return settlementCurrent() && readyRecipientCurrent(); } };
|
|
1820
|
+
const assertSettlementCurrent = (): void => {
|
|
1821
|
+
if (!settlementAuthority.isCurrent() || (!retired && settlementCanonicalCurrent && !settlementCanonicalCurrent())) {
|
|
1822
|
+
throw new Error("Workspace Restore settlement authority ended.");
|
|
1823
|
+
}
|
|
1824
|
+
};
|
|
1825
|
+
assertSettlementCurrent();
|
|
1826
|
+
if (!intent || intent.phase !== "ready" || !intent.routing || !isDeepStrictEqual(intent.executor, authority.executor) ||
|
|
1827
|
+
intent.operatorUserId !== operatorUserId || intent.request.source.journalBindingKey !== sourceBinding) continue;
|
|
1828
|
+
const settled = new Set(intent.routing.settlements.flatMap(value => value.updateIds));
|
|
1829
|
+
if (follower && (intent.routing.cleanup !== undefined || !recipientMatches(intent) ||
|
|
1830
|
+
!intent.request.source.updateIds.every(id => settled.has(id)))) continue;
|
|
1831
|
+
const updateIds = evidence?.updateIds.filter(id => intent!.request.source.updateIds.includes(id) && !settled.has(id)) ?? [];
|
|
1832
|
+
if (evidence && updateIds.length) {
|
|
1833
|
+
intent = store.recordSourceSettlement(intent, { ...evidence, updateIds }, settlementAuthority);
|
|
1834
|
+
if (!intent) continue;
|
|
1835
|
+
}
|
|
1836
|
+
if (intent.routing?.cleanup === undefined) {
|
|
1837
|
+
if (!intent.request.source.updateIds.every(id => intent!.routing!.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)))) continue;
|
|
1838
|
+
// Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
|
|
1839
|
+
await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
|
|
1840
|
+
assertSettlementCurrent();
|
|
1841
|
+
if (isRerouteTargetProtected(intent.request.binding.target, intent)) continue;
|
|
1842
|
+
const grant = store.issueCleanup(intent, settlementAuthority);
|
|
1843
|
+
if (!grant) continue;
|
|
1844
|
+
intent = grant.intent;
|
|
1845
|
+
const oldTarget = intent.request.binding.target;
|
|
1846
|
+
const completed = intent.request.owner.owner?.kind === "leader"
|
|
1847
|
+
? await closePreviousLeaderThread(oldTarget, assertSettlementCurrent, intent)
|
|
1848
|
+
: await closeReplacedFollowerThread(oldTarget, intent.request.owner.instanceId, assertSettlementCurrent, intent);
|
|
1849
|
+
assertSettlementCurrent();
|
|
1850
|
+
if (!completed) continue; // Issued uncertainty stays protected, never downgraded or retried.
|
|
1851
|
+
intent = store.recordCleanup(intent, { kind: "completed", target: oldTarget }, settlementAuthority);
|
|
1852
|
+
if (!intent) continue;
|
|
1853
|
+
}
|
|
1854
|
+
if (!store.retire(intent, settlementAuthority)) continue;
|
|
1855
|
+
retired = true;
|
|
1856
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
1857
|
+
if (pending.workspaceRestore?.operationId !== operationId) continue;
|
|
1858
|
+
pending.finalizeMessage = "Message routed.";
|
|
1859
|
+
if (!deps.editInteractiveMessage || pending.chooserMessageId === undefined) continue;
|
|
1860
|
+
assertSettlementCurrent();
|
|
1861
|
+
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
|
|
1862
|
+
"<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
|
|
1863
|
+
assertSettlementCurrent();
|
|
1864
|
+
if (pendingUnboundReroutes.get(id) === pending) removePendingReroute(id);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
})).catch(error => { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" }); });
|
|
1868
|
+
restoreSettlementTasks.add(task);
|
|
1869
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
1870
|
+
return task;
|
|
1871
|
+
};
|
|
1872
|
+
const beforeQueueReceiptPublished: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["beforeQueueReceiptPublished"]> =
|
|
1873
|
+
async (receipt, queueOwner, ctx, isWorkerCurrent) => {
|
|
1874
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
1875
|
+
if (!store) return;
|
|
1876
|
+
const selected = store.list().filter(intent => intent.request.source.journalBindingKey === receipt.journalBindingKey &&
|
|
1877
|
+
intent.request.source.updateIds.some(id => receipt.sourceUpdateIds.includes(id))).map(intent => intent.request.operationId);
|
|
1878
|
+
if (!selected.length) return;
|
|
1879
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
1880
|
+
const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.(), operatorUserId = deps.configStore.getAllowedUserId();
|
|
1881
|
+
const binding = receipt.journalBindingKey, ports = deps.workspaceRestoreRecipient, threads = deps.threadStore;
|
|
1882
|
+
if (!instanceId || epoch === undefined || generation === undefined || !scope || !binding || operatorUserId === undefined ||
|
|
1883
|
+
!ports || !threads || !deps.runWorkspaceOperation || !deps.inspectRestoreQueuedReceipt) {
|
|
1884
|
+
throw new Error("Workspace Restore queued acceptance authority is unavailable.");
|
|
1885
|
+
}
|
|
1886
|
+
const expectedReceipt: TelegramUpdateJournalQueuedCompletion = { queueKind: receipt.queueKind, receiptId: receipt.receiptId,
|
|
1887
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...queueOwner } };
|
|
1888
|
+
const current = (): boolean => isWorkerCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
|
|
1889
|
+
deps.isContextActive?.(ctx) !== false && deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
|
|
1890
|
+
deps.getAdmissionJournalBinding?.() === binding && deps.getCurrentInstanceId?.() === instanceId &&
|
|
1891
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
|
|
1892
|
+
queueOwner.instanceId === instanceId && queueOwner.sessionGeneration === generation;
|
|
1893
|
+
const assertCurrent = (): void => { if (!current()) throw new Error("Workspace Restore queued acceptance authority changed."); };
|
|
1894
|
+
assertCurrent();
|
|
1895
|
+
const recipientGuards: Array<() => boolean> = [];
|
|
1896
|
+
const sourceCompletions: Updates.TelegramQueueSourceCompletion[] = [];
|
|
1897
|
+
await deps.runWorkspaceOperation({ operationId: `restore-queue-acceptance-${randomBytes(16).toString("hex")}`,
|
|
1898
|
+
operationKind: "workspace.restore-queue-acceptance", scopes: [{ kind: "profile" }] }, async () => {
|
|
1899
|
+
assertCurrent();
|
|
1900
|
+
const active = deps.getWorkspaceRestoreStore?.();
|
|
1901
|
+
if (!active) throw new Error("Workspace Restore queued acceptance storage disappeared.");
|
|
1902
|
+
const proof = deps.inspectRestoreQueuedReceipt!({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
|
|
1903
|
+
assertCurrent();
|
|
1904
|
+
if (!proof || !isDeepStrictEqual(proof.receipt, expectedReceipt) || !/^[a-f0-9]{64}$/u.test(proof.queueOwnerSha256) ||
|
|
1905
|
+
proof.sources.length !== receipt.sourceUpdateIds.length || proof.sources.some((source, index) =>
|
|
1906
|
+
source.updateId !== receipt.sourceUpdateIds[index] || !/^[a-f0-9]{64}$/u.test(source.sourceSha256))) {
|
|
1907
|
+
throw new Error("Workspace Restore queued receipt proof is unavailable.");
|
|
1908
|
+
}
|
|
1909
|
+
for (const operationId of selected) {
|
|
1910
|
+
let intent = active.list().find(value => value.request.operationId === operationId);
|
|
1911
|
+
const recipient = intent?.readyRecipient ?? intent?.recipient;
|
|
1912
|
+
if (!intent || intent.phase !== "ready" || !intent.routing || intent.operatorUserId !== operatorUserId ||
|
|
1913
|
+
!isDeepStrictEqual(intent.executor, { instanceId, leaderEpoch: String(epoch) }) ||
|
|
1914
|
+
intent.request.source.journalBindingKey !== binding || recipient?.kind !== "leader" || recipient.instanceId !== instanceId ||
|
|
1915
|
+
recipient.generation !== String(generation) || recipient.sessionId !== intent.request.binding.sessionId) {
|
|
1916
|
+
throw new Error("Workspace Restore queued recipient proof changed.");
|
|
1917
|
+
}
|
|
1918
|
+
const request = intent.request;
|
|
1919
|
+
const liveRecipientCurrent = (): boolean => {
|
|
1920
|
+
const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
|
|
1921
|
+
return current() && !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
|
|
1922
|
+
ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
|
|
1923
|
+
};
|
|
1924
|
+
const recipientCurrent = (): boolean => {
|
|
1925
|
+
if (!liveRecipientCurrent()) return false;
|
|
1926
|
+
let committed = false;
|
|
1927
|
+
threads.withWorkspaceRestoreSnapshot(intent!, snapshot => {
|
|
1928
|
+
const live = snapshot.workspaceBindings?.filter(value => value.bindingKey === request.binding.bindingKey) ?? [];
|
|
1929
|
+
const owners = snapshot.threads.filter(value => value.status === "active" &&
|
|
1930
|
+
(value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
|
|
1931
|
+
const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
|
|
1932
|
+
committed = live.length === 1 && live[0]?.sessionId === recipient.sessionId && live[0].cwd === request.binding.cwd &&
|
|
1933
|
+
live[0].slot === request.binding.slot && live[0].inactiveSinceMs === undefined && isDeepStrictEqual(live[0].target, request.target) &&
|
|
1934
|
+
owners.length === 1 && owners[0]?.owner?.kind === "leader" && owners[0].instanceId === instanceId &&
|
|
1935
|
+
owners[0].owner.instanceId === instanceId && !!owners[0].owner.cwd &&
|
|
1936
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) === request.binding.cwd && owners[0].profileKey === request.owner.profileKey &&
|
|
1937
|
+
owners[0].slot === request.binding.slot && isDeepStrictEqual(owners[0].target, request.target) &&
|
|
1938
|
+
!!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
|
|
1939
|
+
ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
|
|
1940
|
+
});
|
|
1941
|
+
return committed && liveRecipientCurrent();
|
|
1942
|
+
};
|
|
1943
|
+
recipientGuards.push(recipientCurrent);
|
|
1944
|
+
// Storage owns the canonical CAS; its in-transaction authority callback cannot reacquire the snapshot lock.
|
|
1945
|
+
const authority: Threads.TelegramWorkspaceRestoreAuthority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: liveRecipientCurrent };
|
|
1946
|
+
for (const source of proof.sources.filter(value => request.source.updateIds.includes(value.updateId))) {
|
|
1947
|
+
if (!recipientCurrent()) throw new Error("Workspace Restore queued recipient authority changed.");
|
|
1948
|
+
const evidence: Threads.TelegramWorkspaceRestoreSourceAcceptance = { ...source, journalBindingKey: binding, recipient,
|
|
1949
|
+
kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind, queueOwnerSha256: proof.queueOwnerSha256 };
|
|
1950
|
+
let accepted: Threads.TelegramWorkspaceRestoreIntent | undefined, failure: unknown;
|
|
1951
|
+
try { accepted = active.recordSourceAcceptance(intent, evidence, authority); } catch (error) { failure = error; }
|
|
1952
|
+
if (!accepted) accepted = active.list().find(value => value.request.operationId === operationId &&
|
|
1953
|
+
isDeepStrictEqual(value.request, request) && isDeepStrictEqual(value.executor, authority.executor) && value.operatorUserId === operatorUserId &&
|
|
1954
|
+
value.routing?.acceptances?.some(retained => isDeepStrictEqual(retained, evidence)));
|
|
1955
|
+
if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
|
|
1956
|
+
accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) {
|
|
1957
|
+
throw failure ?? new Error("Workspace Restore queued acceptance was not published.");
|
|
1958
|
+
}
|
|
1959
|
+
intent = accepted;
|
|
1960
|
+
sourceCompletions.push({ ...source, journalBindingKey: binding,
|
|
1961
|
+
completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) });
|
|
1962
|
+
if (!recipientCurrent()) throw new Error("Workspace Restore queued recipient authority changed.");
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
const observed = deps.inspectRestoreQueuedReceipt!({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
|
|
1966
|
+
assertCurrent();
|
|
1967
|
+
if (!isDeepStrictEqual(observed, proof)) throw new Error("Workspace Restore queued receipt changed after publication.");
|
|
1968
|
+
});
|
|
1969
|
+
assertCurrent();
|
|
1970
|
+
if (recipientGuards.some(guard => !guard())) throw new Error("Workspace Restore queued recipient changed after admission.");
|
|
1971
|
+
return sourceCompletions.sort((a, b) => a.updateId - b.updateId);
|
|
1972
|
+
};
|
|
1973
|
+
const onQueueReceiptCompleted = (receipt: Updates.TelegramQueueAdmissionReceiptLike, ctx: TContext): void => {
|
|
1974
|
+
// A hint re-enters admission and rereads scoped proof; it never supplies completed command evidence for a queued source.
|
|
1975
|
+
if (!receipt.journalBindingKey) return;
|
|
1976
|
+
observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
|
|
1977
|
+
updateIds: [...receipt.sourceUpdateIds], kind: "completed" } }, ctx);
|
|
1978
|
+
for (const updateId of receipt.sourceUpdateIds) retireCompletedTemporaryThread(updateId, ctx, receipt.journalBindingKey);
|
|
1218
1979
|
};
|
|
1980
|
+
const onQueueReceiptCommitted = (receipt: Updates.TelegramQueueAdmissionReceiptLike, ctx: TContext): void => {
|
|
1981
|
+
if (receipt.journalBindingKey) observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
|
|
1982
|
+
updateIds: [...receipt.sourceUpdateIds], kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind } }, ctx);
|
|
1983
|
+
};
|
|
1984
|
+
// Worker-confirmed source completion is the only evidence for a Forwarded input. It records a durable group fact and
|
|
1985
|
+
// starts the same delayed, fully rechecked cleanup as the last Cancel; deletion itself never happens here.
|
|
1986
|
+
const completedTemporaryInputIds = new Map<string, Set<number>>();
|
|
1987
|
+
const retireCompletedTemporaryThread = (updateId: number, ctx: TContext, journalBindingKey: string): void => {
|
|
1988
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
1989
|
+
if (!cap || !deps.runWorkspaceOperation || cap.journalBindingKey !== journalBindingKey) return;
|
|
1990
|
+
const store = cap.store;
|
|
1991
|
+
const matches = (entry: Threads.TelegramTemporaryThreadEntry) =>
|
|
1992
|
+
entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId;
|
|
1993
|
+
const inGroup = (entry: Threads.TelegramTemporaryThreadEntry) => Threads.getTelegramTemporaryThreadInputs(entry).some(input =>
|
|
1994
|
+
input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
|
|
1995
|
+
let restoreOwned = false;
|
|
1996
|
+
try {
|
|
1997
|
+
if (!store.listTemporaryThreads().some(entry => matches(entry) || inGroup(entry))) return;
|
|
1998
|
+
restoreOwned = store.list().some(intent => intent.request.source.journalBindingKey === journalBindingKey &&
|
|
1999
|
+
intent.request.source.updateIds.includes(updateId));
|
|
2000
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); return; }
|
|
2001
|
+
const task = Promise.resolve().then(() => deps.runWorkspaceOperation!({ operationId: `temporary-thread-retire-${randomBytes(16).toString("hex")}`,
|
|
2002
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
2003
|
+
if (!cap.isCurrent()) return;
|
|
2004
|
+
let entry = store.listTemporaryThreads().find(restoreOwned ? matches : inGroup);
|
|
2005
|
+
if (!entry) return;
|
|
2006
|
+
if (restoreOwned) {
|
|
2007
|
+
// A Restore keeps its own lifecycle: only its source entry may be released, never by Forward bookkeeping.
|
|
2008
|
+
if (entry.target && hasOtherTemporaryThreadReroute(entry.target, entry)) return;
|
|
2009
|
+
entry = cap.adopt(entry);
|
|
2010
|
+
if (entry && cap.isCurrent()) store.retireTemporaryThread(entry, cap.authority);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
const group = Threads.getTelegramTemporaryThreadInputs(entry).find(input =>
|
|
2014
|
+
input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
|
|
2015
|
+
const target = entry.target;
|
|
2016
|
+
if (!group || entry.phase !== "created" || !target) return;
|
|
2017
|
+
if (entry.cancelledInputs?.some(input => isDeepStrictEqual(input, group)) ||
|
|
2018
|
+
entry.completedInputs?.some(input => isDeepStrictEqual(input, group))) return;
|
|
2019
|
+
// Process-local accumulation only: a restart forgets partial groups and the tab stays protected.
|
|
2020
|
+
const seen = completedTemporaryInputIds.get(entry.token) ?? new Set<number>();
|
|
2021
|
+
seen.add(updateId);
|
|
2022
|
+
completedTemporaryInputIds.set(entry.token, seen);
|
|
2023
|
+
if (!group.updateIds.every(id => seen.has(id))) return;
|
|
2024
|
+
entry = cap.adopt(entry);
|
|
2025
|
+
if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInputCompletion(entry, group, cap.authority) || !cap.isCurrent()) return;
|
|
2026
|
+
scheduleTemporaryThreadCleanup(target, ctx);
|
|
2027
|
+
return true;
|
|
2028
|
+
})).then(published => {
|
|
2029
|
+
// A sibling may complete after Restore already settled. Reinspect only after its terminal fact publishes and admission releases.
|
|
2030
|
+
if (published && cap.isCurrent()) observeRestoreSettlement({ kind: "source",
|
|
2031
|
+
evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
|
|
2032
|
+
}).catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); });
|
|
2033
|
+
restoreSettlementTasks.add(task);
|
|
2034
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
2035
|
+
};
|
|
2036
|
+
const onUpdateCompleted = (updateId: number, ctx: TContext, journalBindingKey?: string): void => {
|
|
2037
|
+
if (!journalBindingKey) return;
|
|
2038
|
+
observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
|
|
2039
|
+
retireCompletedTemporaryThread(updateId, ctx, journalBindingKey);
|
|
2040
|
+
};
|
|
2041
|
+
const restoreWorkspace = deps.workspaceRestoreRecipient ? async (input: {
|
|
2042
|
+
operationId: string; record: Threads.TelegramTopicTargetRecord;
|
|
2043
|
+
target: Threads.TelegramTopicTargetRecord["target"]; messages: readonly TMessage[];
|
|
2044
|
+
ctx: TContext; isCurrent: () => boolean;
|
|
2045
|
+
dispatch: (recipient: { kind: "leader" | "follower"; instanceId: string }, isRecipientCurrent: () => boolean,
|
|
2046
|
+
recordForwardAcceptance: (message: TMessage, delivery: Bus.TelegramBusFollowerDeliveryIdentity) => Updates.TelegramDeferredSourceEvidence,
|
|
2047
|
+
recordLocalAcceptance: (message: TMessage) => Updates.TelegramDeferredSourceEvidence) => Promise<boolean>;
|
|
2048
|
+
}) => {
|
|
2049
|
+
const ports = deps.workspaceRestoreRecipient!;
|
|
2050
|
+
const threads = deps.threadStore;
|
|
2051
|
+
if (!threads || !deps.getWorkspaceRestoreStore || !deps.getSessionGeneration ||
|
|
2052
|
+
!deps.hasWorkspaceRestoreAuthority?.() || !input.isCurrent() || input.target.threadId === undefined) return "protected";
|
|
2053
|
+
const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
2054
|
+
const operatorUserId = deps.configStore.getAllowedUserId(), scope = deps.getAdmissionScope?.();
|
|
2055
|
+
const journalBindingKey = deps.getAdmissionJournalBinding?.(), generation = deps.getSessionGeneration();
|
|
2056
|
+
const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(input.messages);
|
|
2057
|
+
if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope || !journalBindingKey || !sourceIds.length) return "protected";
|
|
2058
|
+
const current = (): boolean => input.isCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
|
|
2059
|
+
deps.isContextActive?.(input.ctx) !== false && deps.getSessionGeneration?.() === generation &&
|
|
2060
|
+
deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
|
|
2061
|
+
deps.getAdmissionScope?.() === scope && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
|
|
2062
|
+
deps.configStore.getAllowedUserId() === operatorUserId;
|
|
2063
|
+
await threads.load();
|
|
2064
|
+
if (!current()) return "protected";
|
|
2065
|
+
const store = deps.getWorkspaceRestoreStore();
|
|
2066
|
+
if (!store) return "protected";
|
|
2067
|
+
const retained = store.list().find(value => value.request.operationId === input.operationId);
|
|
2068
|
+
const bindings = threads.listWorkspaceBindings().filter(value => isDeepStrictEqual(value.target, input.record.target));
|
|
2069
|
+
const binding = retained?.request.binding ?? (bindings.length === 1 ? bindings[0] : undefined);
|
|
2070
|
+
if (!binding || !binding.sessionId || !binding.slot) return "protected";
|
|
2071
|
+
const { sessionId, slot } = binding;
|
|
2072
|
+
const request: Threads.TelegramWorkspaceRestoreRequest = { operationId: input.operationId,
|
|
2073
|
+
binding, owner: structuredClone(input.record), target: { chatId: input.target.chatId, threadId: input.target.threadId },
|
|
2074
|
+
source: { journalBindingKey, updateIds: sourceIds } };
|
|
2075
|
+
if (retained && !isDeepStrictEqual(retained.request, request)) return "protected";
|
|
2076
|
+
const getRecipient = (snapshot?: Readonly<Pick<Threads.TelegramTopicTargetFile, "threads" | "workspaceBindings">>): Threads.TelegramWorkspaceRestoreRecipient | undefined => {
|
|
2077
|
+
if (!current()) return undefined;
|
|
2078
|
+
const liveBindings = (snapshot ? snapshot.workspaceBindings ?? [] : threads.listWorkspaceBindings())
|
|
2079
|
+
.filter(value => value.bindingKey === binding.bindingKey);
|
|
2080
|
+
const live = liveBindings[0];
|
|
2081
|
+
if (liveBindings.length !== 1 || live?.sessionId !== binding.sessionId || live.cwd !== binding.cwd || live.slot !== binding.slot ||
|
|
2082
|
+
live.inactiveSinceMs !== undefined || (!isDeepStrictEqual(live.target, binding.target) && !isDeepStrictEqual(live.target, request.target))) return undefined;
|
|
2083
|
+
const records = (snapshot ? snapshot.threads : threads.list()).filter(value => value.status === "active" &&
|
|
2084
|
+
(value.slot === binding.slot || isDeepStrictEqual(value.target, live.target)));
|
|
2085
|
+
const record = records[0];
|
|
2086
|
+
if (records.length !== 1 || record?.slot !== binding.slot || !isDeepStrictEqual(record.target, live.target)) return undefined;
|
|
2087
|
+
if (record.owner?.kind === "leader" && record.instanceId === instanceId) {
|
|
2088
|
+
const cwd = ports.getCwd(input.ctx);
|
|
2089
|
+
if (ports.getSessionId(input.ctx) !== sessionId || !cwd || WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) !== binding.cwd ||
|
|
2090
|
+
!deps.setCurrentLeaderIdentity) return undefined;
|
|
2091
|
+
return { kind: "leader", instanceId, sessionId, generation: String(generation) };
|
|
2092
|
+
}
|
|
2093
|
+
if (record.owner?.kind !== "manual-follower" || !record.instanceId) return undefined;
|
|
2094
|
+
const follower = ports.followerRegistry.get(record.instanceId);
|
|
2095
|
+
if (!follower?.registrationGeneration || !follower.cwd || follower.sessionId !== sessionId ||
|
|
2096
|
+
WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) !== binding.cwd || follower.slot !== slot ||
|
|
2097
|
+
!Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE)) return undefined;
|
|
2098
|
+
return { kind: "follower", instanceId: follower.instanceId, sessionId, generation: follower.registrationGeneration };
|
|
2099
|
+
};
|
|
2100
|
+
const hasCommittedTarget = (): boolean => threads.listWorkspaceBindings().some(value =>
|
|
2101
|
+
value.bindingKey === binding.bindingKey && isDeepStrictEqual(value.target, request.target));
|
|
2102
|
+
const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
|
|
2103
|
+
const ready = await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, getRecipient,
|
|
2104
|
+
async runRecipient(action) {
|
|
2105
|
+
const recipient = getRecipient();
|
|
2106
|
+
if (!recipient || !action.isCurrent() || !hasCommittedTarget()) return undefined;
|
|
2107
|
+
if (recipient.kind === "follower") {
|
|
2108
|
+
const follower = ports.followerRegistry.get(recipient.instanceId);
|
|
2109
|
+
const observation = await ports.runFollower({ operationId: request.operationId, instanceId: recipient.instanceId,
|
|
2110
|
+
sessionId: recipient.sessionId, slot: binding.slot!, target: request.target, oldTarget: binding.target,
|
|
2111
|
+
mode: action.mode, isCurrent: action.isCurrent });
|
|
2112
|
+
if (!action.isCurrent() || !hasCommittedTarget()) return undefined;
|
|
2113
|
+
if (!follower || !observation?.ready) return observation;
|
|
2114
|
+
if (observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
|
|
2115
|
+
!isDeepStrictEqual(observation.target, request.target) || observation.slot !== binding.slot) return undefined;
|
|
2116
|
+
const actual = ports.followerRegistry.get(recipient.instanceId);
|
|
2117
|
+
if (!actual || actual.registrationGeneration !== follower.registrationGeneration || actual.sessionId !== follower.sessionId ||
|
|
2118
|
+
actual.cwd !== follower.cwd || actual.slot !== follower.slot || actual.profileKey !== follower.profileKey ||
|
|
2119
|
+
actual.busSocketPath !== follower.busSocketPath || !isDeepStrictEqual(actual.target, follower.target) ||
|
|
2120
|
+
!isDeepStrictEqual(actual.protocol, follower.protocol)) return undefined;
|
|
2121
|
+
threads.commitWorkspaceRestoreRegistration({ target: request.target, bindingKey: binding.bindingKey, slot: binding.slot }, () => {
|
|
2122
|
+
if (!action.isCurrent()) throw new Error("Workspace Restore recipient authority changed.");
|
|
2123
|
+
ports.followerRegistry.register({ ...actual, target: request.target, connectedAtMs: actual.connectedAtMs });
|
|
2124
|
+
});
|
|
2125
|
+
return observation;
|
|
2126
|
+
}
|
|
2127
|
+
let observation: Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>>;
|
|
2128
|
+
threads.withWorkspaceRestoreSnapshot(action.intent, snapshot => {
|
|
2129
|
+
if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient)) return;
|
|
2130
|
+
const live = snapshot.workspaceBindings?.find(value => value.bindingKey === binding.bindingKey);
|
|
2131
|
+
if (!live || !isDeepStrictEqual(live.target, request.target)) return;
|
|
2132
|
+
const local = ports.getLeaderIdentity();
|
|
2133
|
+
if (!local || local.slot !== binding.slot || (!isDeepStrictEqual(local.target, binding.target) &&
|
|
2134
|
+
!isDeepStrictEqual(local.target, request.target))) return;
|
|
2135
|
+
if (action.mode === "apply" && !isDeepStrictEqual(local.target, request.target)) {
|
|
2136
|
+
if (action.intent.phase !== "recipient-issued" || !action.isCurrent()) return;
|
|
2137
|
+
deps.setCurrentLeaderIdentity!({ target: request.target, slot: binding.slot, threadName: request.owner.threadName });
|
|
2138
|
+
}
|
|
2139
|
+
if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient)) return;
|
|
2140
|
+
const observed = ports.getLeaderIdentity();
|
|
2141
|
+
if (!observed || observed.slot !== binding.slot) return;
|
|
2142
|
+
observation = { operationId: request.operationId, recipient, target: observed.target as typeof request.target,
|
|
2143
|
+
slot: binding.slot!, ready: isDeepStrictEqual(observed.target, request.target) };
|
|
2144
|
+
});
|
|
2145
|
+
return observation;
|
|
2146
|
+
} }).catch(error => {
|
|
2147
|
+
deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-recipient" });
|
|
2148
|
+
return undefined;
|
|
2149
|
+
});
|
|
2150
|
+
if (!ready || !current() || ready.routing) return "protected";
|
|
2151
|
+
const recipient = ready.readyRecipient ?? ready.recipient;
|
|
2152
|
+
const recipientCurrent = (): boolean => {
|
|
2153
|
+
if (!current() || !recipient || !hasCommittedTarget() || !isDeepStrictEqual(getRecipient(), recipient)) return false;
|
|
2154
|
+
const observed = recipient.kind === "leader" ? ports.getLeaderIdentity() : ports.followerRegistry.get(recipient.instanceId);
|
|
2155
|
+
return observed?.slot === slot && isDeepStrictEqual(observed.target, request.target);
|
|
2156
|
+
};
|
|
2157
|
+
if (!recipient || !recipientCurrent()) return "protected";
|
|
2158
|
+
if (deps.callApi) {
|
|
2159
|
+
try {
|
|
2160
|
+
await deps.callApi("editForumTopic", { chat_id: request.target.chatId, message_thread_id: request.target.threadId,
|
|
2161
|
+
name: deps.getDisplayTitle?.(request.target) ?? ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot) });
|
|
2162
|
+
} catch (error) { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-title" }); }
|
|
2163
|
+
}
|
|
2164
|
+
if (!recipientCurrent() || !store.issueRouting(ready, { ...authority, isCurrent: recipientCurrent })) return "protected";
|
|
2165
|
+
const recordAcceptance = (message: TMessage, delivery?: Bus.TelegramBusFollowerDeliveryIdentity): Updates.TelegramDeferredSourceEvidence => {
|
|
2166
|
+
if (!recipientCurrent() || recipient.kind !== (delivery ? "follower" : "leader")) throw new Error("Workspace Restore acceptance authority changed.");
|
|
2167
|
+
const source = Updates.inspectTelegramDeferredSource(message);
|
|
2168
|
+
const follower = ports.followerRegistry.get(recipient.instanceId);
|
|
2169
|
+
const ownership = deps.getTargetOwnership?.(request.target);
|
|
2170
|
+
if (!source || source.journalBindingKey !== journalBindingKey || !request.source.updateIds.includes(source.updateId) ||
|
|
2171
|
+
delivery && (!follower || ownership?.instanceId !== recipient.instanceId || ownership.ownerGeneration !== recipient.generation ||
|
|
2172
|
+
!ownership.recipientBindingKey || delivery.sourceUpdateId !== source.updateId ||
|
|
2173
|
+
delivery.recipientBindingKey !== ownership.recipientBindingKey || delivery.deliveryId !== Bus.createTelegramBusFollowerDeliveryIdentity({
|
|
2174
|
+
kind: "leader.forwardMessage", recipientBindingKey: ownership.recipientBindingKey, sourceUpdateId: source.updateId }).deliveryId) ||
|
|
2175
|
+
!recipientCurrent()) throw new Error("Workspace Restore acceptance source or delivery changed.");
|
|
2176
|
+
const evidence: Threads.TelegramWorkspaceRestoreSourceAcceptance = { ...source, recipient,
|
|
2177
|
+
...(delivery ? { kind: "forwarded", deliveryId: delivery.deliveryId, recipientBindingKey: delivery.recipientBindingKey }
|
|
2178
|
+
: { kind: "completed" }) };
|
|
2179
|
+
const expected = store.list().find(value => value.request.operationId === request.operationId);
|
|
2180
|
+
if (!expected || !isDeepStrictEqual(expected.request, request) || !expected.routing ||
|
|
2181
|
+
!recipientCurrent()) throw new Error("Workspace Restore dispatch proof is unavailable.");
|
|
2182
|
+
let accepted: Threads.TelegramWorkspaceRestoreIntent | undefined;
|
|
2183
|
+
let failure: { error: unknown } | undefined;
|
|
2184
|
+
const acceptanceAuthority = { ...authority, isCurrent: recipientCurrent };
|
|
2185
|
+
try { accepted = store.recordSourceAcceptance(expected, evidence, acceptanceAuthority); }
|
|
2186
|
+
catch (error) { failure = { error }; }
|
|
2187
|
+
if (!recipientCurrent()) throw new Error("Workspace Restore acceptance authority changed.");
|
|
2188
|
+
// A lost rename reply observes only the exact full retained proof; it never resends the accepted message.
|
|
2189
|
+
if (!accepted) {
|
|
2190
|
+
const observed = store.list().find(value => value.request.operationId === request.operationId);
|
|
2191
|
+
if (observed && isDeepStrictEqual(observed.request, request) && isDeepStrictEqual(observed.executor, authority.executor) &&
|
|
2192
|
+
observed.operatorUserId === operatorUserId && observed.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) accepted = observed;
|
|
2193
|
+
}
|
|
2194
|
+
if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
|
|
2195
|
+
accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)) ||
|
|
2196
|
+
!recipientCurrent()) throw failure?.error ?? new Error("Workspace Restore acceptance was not published.");
|
|
2197
|
+
return { ...source, completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) };
|
|
2198
|
+
};
|
|
2199
|
+
try { await input.dispatch(recipient, recipientCurrent, recordAcceptance, message => recordAcceptance(message)); }
|
|
2200
|
+
catch (error) { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-dispatch" }); }
|
|
2201
|
+
// Positive worker ACKs own settlement and cleanup under a subsequent admission.
|
|
2202
|
+
return "protected";
|
|
2203
|
+
} : undefined;
|
|
1219
2204
|
const retryPendingRerouteCleanup = async (
|
|
1220
2205
|
cleanup: PendingRerouteCleanup,
|
|
1221
2206
|
assertExecutionCurrent?: () => void,
|
|
@@ -1225,6 +2210,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1225
2210
|
cleanup.target,
|
|
1226
2211
|
cleanup.messageId,
|
|
1227
2212
|
assertExecutionCurrent,
|
|
2213
|
+
cleanup.temporaryThread,
|
|
1228
2214
|
);
|
|
1229
2215
|
}
|
|
1230
2216
|
if (cleanup.kind === "previous-leader") {
|
|
@@ -1240,15 +2226,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1240
2226
|
| ((messages: TMessage[], ctx: TContext) => Promise<void>)
|
|
1241
2227
|
| undefined;
|
|
1242
2228
|
const dispatchPendingRerouteMessages = async (
|
|
1243
|
-
pending: Pick<PendingUnboundReroute, "dispatchKind" | "sourceTarget">,
|
|
2229
|
+
pending: Pick<PendingUnboundReroute, "dispatchKind" | "sourceTarget" | "temporaryThread">,
|
|
1244
2230
|
messages: TMessage[],
|
|
1245
2231
|
ctx: TContext,
|
|
1246
2232
|
): Promise<void> => {
|
|
1247
2233
|
if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
|
|
1248
2234
|
await dispatchReroutedCommandMessages(messages, ctx);
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
2235
|
+
// Every command chooser deferred its exact original, whether the tab came from Telegram or the bridge.
|
|
2236
|
+
// A queued command retains its separately reported receipt; this cannot settle accepted queue custody.
|
|
2237
|
+
for (const message of messages) Updates.reportTelegramUpdateCompleted(message);
|
|
1252
2238
|
return;
|
|
1253
2239
|
}
|
|
1254
2240
|
await promptEnqueue(messages, ctx);
|
|
@@ -1277,36 +2263,78 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1277
2263
|
`${successMessage} Chooser cleanup is still pending. Try again.`,
|
|
1278
2264
|
);
|
|
1279
2265
|
};
|
|
2266
|
+
const isTemporaryReroute = (pending: PendingUnboundReroute): boolean =>
|
|
2267
|
+
!!pending.temporaryThread || pending.temporaryMembership === true || isTemporaryTabTarget(pending.sourceTarget);
|
|
2268
|
+
const isTemporaryThreadForwardIssued = (pending: PendingUnboundReroute): boolean => {
|
|
2269
|
+
try {
|
|
2270
|
+
const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
2271
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
2272
|
+
if ((entry || pending.temporaryMembership) && !store) return true;
|
|
2273
|
+
const stored = entry && store?.listTemporaryThreads().find(value => value.token === entry.token);
|
|
2274
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
|
|
2275
|
+
return !!stored?.forwardedInputs?.some(input => input.updateIds.some(id => updateIds.includes(id)));
|
|
2276
|
+
} catch { return true; }
|
|
2277
|
+
};
|
|
2278
|
+
/** Publishes the durable one-time Forward fact for this chooser's exact group; false means nothing may be sent. */
|
|
2279
|
+
const issueTemporaryThreadForward = (pending: PendingUnboundReroute, ctx: TContext): boolean => {
|
|
2280
|
+
const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
2281
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
2282
|
+
if (!entry || !cap) return false;
|
|
2283
|
+
const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
|
|
2284
|
+
try {
|
|
2285
|
+
const current = cap.store.listTemporaryThreads().find(value => value.token === entry.token);
|
|
2286
|
+
const adopted = current && cap.isCurrent() ? cap.adopt(current) : undefined;
|
|
2287
|
+
return !!adopted && cap.isCurrent() && !!cap.store.recordTemporaryThreadForwardIssued(adopted, group, cap.authority) && cap.isCurrent();
|
|
2288
|
+
} catch (error) {
|
|
2289
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-forward-issue" });
|
|
2290
|
+
return false;
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
1280
2293
|
const forwardPendingRerouteMessages = async (
|
|
1281
2294
|
pending: PendingUnboundReroute,
|
|
1282
2295
|
instanceId: string,
|
|
1283
2296
|
threadId: number,
|
|
1284
2297
|
ctx: TContext,
|
|
1285
2298
|
assertExecutionCurrent?: () => void,
|
|
2299
|
+
recordForwardAcceptance?: (message: TMessage, delivery: Bus.TelegramBusFollowerDeliveryIdentity) => Updates.TelegramDeferredSourceEvidence,
|
|
1286
2300
|
): Promise<boolean> => {
|
|
1287
2301
|
assertExecutionCurrent?.();
|
|
1288
2302
|
const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
|
|
1289
|
-
if (!forwardMessage) return false;
|
|
2303
|
+
if (!forwardMessage || pending.foreignForwardIssued) return false;
|
|
2304
|
+
const target = { ...pending.sourceTarget, threadId };
|
|
2305
|
+
const liveOwnership = deps.getTargetOwnership?.(target);
|
|
2306
|
+
if (!liveOwnership || liveOwnership.instanceId !== instanceId ||
|
|
2307
|
+
!liveOwnership.ownerGeneration || !liveOwnership.recipientBindingKey) return false;
|
|
2308
|
+
const ownership = structuredClone(liveOwnership);
|
|
1290
2309
|
const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
|
|
2310
|
+
// Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
|
|
2311
|
+
// even after restart. Any refusal or publication failure sends nothing and keeps the input held.
|
|
2312
|
+
if (!recordForwardAcceptance && isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
|
|
2313
|
+
if (isTemporaryThreadForwardIssued(pending)) pending.foreignForwardIssued = true;
|
|
2314
|
+
return false;
|
|
2315
|
+
}
|
|
2316
|
+
if (!recordForwardAcceptance && isTemporaryReroute(pending)) pending.foreignForwardIssued = true;
|
|
1291
2317
|
const outcomes = await Promise.allSettled(
|
|
1292
2318
|
messages.map((message) =>
|
|
1293
2319
|
forwardMessage({
|
|
1294
2320
|
message,
|
|
1295
|
-
ownership
|
|
2321
|
+
ownership,
|
|
1296
2322
|
ctx,
|
|
1297
2323
|
}),
|
|
1298
2324
|
),
|
|
1299
2325
|
);
|
|
1300
2326
|
assertExecutionCurrent?.();
|
|
2327
|
+
if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership)) return false;
|
|
1301
2328
|
pending.messages = pending.messages.filter((_, index) => {
|
|
1302
2329
|
const outcome = outcomes[index];
|
|
1303
2330
|
if (
|
|
1304
2331
|
outcome?.status === "fulfilled" &&
|
|
1305
2332
|
outcome.value.status === "accepted"
|
|
1306
2333
|
) {
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2334
|
+
// Restore publishes acceptance before reporting completion; cleanup still waits for journal disposition.
|
|
2335
|
+
const expectedSource = recordForwardAcceptance?.(pending.messages[index], outcome.value.delivery);
|
|
2336
|
+
assertExecutionCurrent?.();
|
|
2337
|
+
Updates.reportTelegramUpdateCompleted(pending.messages[index], expectedSource);
|
|
1310
2338
|
return false;
|
|
1311
2339
|
}
|
|
1312
2340
|
if (outcome?.status === "rejected") {
|
|
@@ -1319,9 +2347,487 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1319
2347
|
}
|
|
1320
2348
|
return true;
|
|
1321
2349
|
});
|
|
2350
|
+
if (pending.messages.length === 0) pending.foreignForwardIssued = false;
|
|
1322
2351
|
return pending.messages.length === 0;
|
|
1323
2352
|
};
|
|
1324
|
-
const
|
|
2353
|
+
const temporaryCleanupTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; settle: () => void }>();
|
|
2354
|
+
const cancelTemporaryThreadCleanup = (token: string): void => {
|
|
2355
|
+
const scheduled = temporaryCleanupTimers.get(token);
|
|
2356
|
+
if (!scheduled) return;
|
|
2357
|
+
clearTimeout(scheduled.timer);
|
|
2358
|
+
temporaryCleanupTimers.delete(token);
|
|
2359
|
+
scheduled.settle();
|
|
2360
|
+
};
|
|
2361
|
+
/**
|
|
2362
|
+
* One cleanup attempt after the quiet period. Cancellation facts are only a precondition: fresh authority, proof,
|
|
2363
|
+
* absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
|
|
2364
|
+
* durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
|
|
2365
|
+
*/
|
|
2366
|
+
const runTemporaryThreadCleanup = async (token: string, cap: TemporaryThreadAuthority): Promise<void> => {
|
|
2367
|
+
const store = cap.store, inspect = deps.inspectRestoreSourceAbandonment;
|
|
2368
|
+
if (!inspect || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent()) return;
|
|
2369
|
+
await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
|
|
2370
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
2371
|
+
if (!cap.isCurrent()) return;
|
|
2372
|
+
let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
|
|
2373
|
+
const target = entry?.target;
|
|
2374
|
+
if (!entry || !target || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target) ||
|
|
2375
|
+
entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
|
|
2376
|
+
const owner = `telegram-owner:${cap.operatorUserId}`, cancelled = entry.cancelledInputs ?? [];
|
|
2377
|
+
const cancelledCurrent = () => cancelled.every(input => input.updateIds.every(updateId => {
|
|
2378
|
+
const evidence = inspect(updateId, input.journalBindingKey);
|
|
2379
|
+
return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
|
|
2380
|
+
evidence.operatorAuthorityId === owner;
|
|
2381
|
+
}));
|
|
2382
|
+
if (!cancelledCurrent()) return;
|
|
2383
|
+
entry = cap.adopt(entry);
|
|
2384
|
+
if (!entry || !cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry)) return;
|
|
2385
|
+
const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
|
|
2386
|
+
if (!issued || !cap.isCurrent()) return;
|
|
2387
|
+
entry = issued.entry;
|
|
2388
|
+
const issuedEntry = entry;
|
|
2389
|
+
const assertCurrent = (): void => {
|
|
2390
|
+
if (!cap.isCurrent() || !cancelledCurrent() || !store.isTemporaryThreadCleanupCurrent(issuedEntry, cap.authority))
|
|
2391
|
+
throw new Error("Temporary Thread cleanup authority or exact grant evidence changed.");
|
|
2392
|
+
};
|
|
2393
|
+
if (!await closeReroutedUnboundTopic(target, undefined, assertCurrent, entry) || !cap.isCurrent()) return;
|
|
2394
|
+
store.retireTemporaryThread(entry, cap.authority);
|
|
2395
|
+
});
|
|
2396
|
+
};
|
|
2397
|
+
/** Starts the quiet period only when every known group is resolved; a new input or authority change denies it. */
|
|
2398
|
+
const scheduleTemporaryThreadCleanup = (target: Queue.TelegramQueueTarget, ctx: TContext): void => {
|
|
2399
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
2400
|
+
if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target)) return;
|
|
2401
|
+
let entry: Threads.TelegramTemporaryThreadEntry | undefined;
|
|
2402
|
+
try { entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
|
|
2403
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
|
|
2404
|
+
if (!entry || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) ||
|
|
2405
|
+
entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
|
|
2406
|
+
const token = entry.token;
|
|
2407
|
+
cancelTemporaryThreadCleanup(token);
|
|
2408
|
+
const requested = deps.temporaryThreadCleanupDelayMs;
|
|
2409
|
+
const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
|
|
2410
|
+
let settle = (): void => undefined;
|
|
2411
|
+
const task = new Promise<void>(resolve => { settle = resolve; });
|
|
2412
|
+
const timer = setTimeout(() => {
|
|
2413
|
+
if (temporaryCleanupTimers.get(token)?.timer !== timer) return;
|
|
2414
|
+
temporaryCleanupTimers.delete(token);
|
|
2415
|
+
runTemporaryThreadCleanup(token, cap)
|
|
2416
|
+
.catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" }); })
|
|
2417
|
+
.finally(settle);
|
|
2418
|
+
}, delay);
|
|
2419
|
+
timer.unref?.();
|
|
2420
|
+
temporaryCleanupTimers.set(token, { timer, settle });
|
|
2421
|
+
restoreSettlementTasks.add(task);
|
|
2422
|
+
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
2423
|
+
};
|
|
2424
|
+
const recordCancelledTemporaryThreadInput = (pending: PendingUnboundReroute, ctx: TContext, isCurrent: () => boolean): boolean => {
|
|
2425
|
+
const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
|
|
2426
|
+
if (!store || target.threadId === undefined) return !pending.temporaryThread;
|
|
2427
|
+
let entry = store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
|
|
2428
|
+
if (!entry) return !pending.temporaryThread;
|
|
2429
|
+
const cap = captureTemporaryThreadAuthority(ctx, isCurrent), inspect = deps.inspectRestoreSourceAbandonment;
|
|
2430
|
+
if (!cap || !inspect || cap.operatorUserId !== entry.operatorUserId || cap.journalBindingKey !== entry.source.journalBindingKey) return false;
|
|
2431
|
+
const { operatorUserId, journalBindingKey } = cap, current = cap.isCurrent;
|
|
2432
|
+
const input = { journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
|
|
2433
|
+
if (!current()) return false;
|
|
2434
|
+
entry = cap.adopt(entry);
|
|
2435
|
+
if (!entry || !current()) return false;
|
|
2436
|
+
const recorded = store.recordTemporaryThreadInputCancellation(entry, input, cap.authority, updateId => inspect(updateId, journalBindingKey));
|
|
2437
|
+
if (!recorded || !current() || !recorded.cancelledInputs?.some(value => isDeepStrictEqual(value, input))) return false;
|
|
2438
|
+
const observed = store.listTemporaryThreads().find(value => isDeepStrictEqual(value, recorded));
|
|
2439
|
+
return !!observed && input.updateIds.every(updateId => {
|
|
2440
|
+
const evidence = inspect(updateId, journalBindingKey);
|
|
2441
|
+
return evidence?.journalBindingKey === journalBindingKey && evidence.updateId === updateId &&
|
|
2442
|
+
evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
|
|
2443
|
+
}) && current();
|
|
2444
|
+
};
|
|
2445
|
+
const retireCancellationChooser = async (id: string, pending: PendingUnboundReroute, isCurrent: () => boolean): Promise<boolean> => {
|
|
2446
|
+
if (!pending.abandonment?.result || !isCurrent() || !deps.editInteractiveMessage || pending.chooserMessageId === undefined) return false;
|
|
2447
|
+
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
|
|
2448
|
+
"<b>⛔️ Routing cancelled.</b>", "html", { inline_keyboard: [] });
|
|
2449
|
+
if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending) return false;
|
|
2450
|
+
removePendingReroute(id);
|
|
2451
|
+
return true;
|
|
2452
|
+
};
|
|
2453
|
+
const isReviewText = (message: Partial<TMessage> | undefined): boolean => {
|
|
2454
|
+
if (!message || message.chat?.type !== "private" || !Number.isSafeInteger(message.chat.id) ||
|
|
2455
|
+
!Number.isSafeInteger(message.from?.id) || message.from?.is_bot !== false ||
|
|
2456
|
+
!Number.isSafeInteger(message.message_id) || message.message_id! <= 0 ||
|
|
2457
|
+
!Number.isSafeInteger(message.message_thread_id) || message.message_thread_id! <= 0 ||
|
|
2458
|
+
typeof message.text !== "string" || !message.text.trim() || message.text.trim().startsWith("/")) return false;
|
|
2459
|
+
const fields = message as Record<string, unknown>;
|
|
2460
|
+
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);
|
|
2461
|
+
};
|
|
2462
|
+
const reviewMessage = (entry: RecoverySource["original"]): Partial<TMessage> | undefined => {
|
|
2463
|
+
const message = entry.update.message as Partial<TMessage> | undefined;
|
|
2464
|
+
if (entry.state !== "pending" || entry.preApprovalExcluded || entry.inputClaim || entry.inputProvenance ||
|
|
2465
|
+
entry.queueOwner || entry.queueReceiptId || entry.queueHandoff || entry.failure ||
|
|
2466
|
+
Object.keys(entry.update).some(key => key !== "update_id" && key !== "message") ||
|
|
2467
|
+
!isReviewText(message) || Reflect.has(message!, "pi_telegram_source_update_id")) return undefined;
|
|
2468
|
+
return message;
|
|
2469
|
+
};
|
|
2470
|
+
const needsHistoricalReview = (message: Partial<TMessage>): boolean => {
|
|
2471
|
+
if (!deps.threadStore) throw new Error("Historical routing requires a current Thread snapshot.");
|
|
2472
|
+
if (deps.threadStore.getBotState().threadMode === "disabled") return true;
|
|
2473
|
+
return !getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).some(record =>
|
|
2474
|
+
record.target.chatId === message.chat!.id && record.target.threadId === message.message_thread_id);
|
|
2475
|
+
};
|
|
2476
|
+
/** New-world restart: forget previous-instance Restore/temporary state, then one silent delete per positively disposable tab. */
|
|
2477
|
+
const forgetPreviousWorld = async (input: Updates.TelegramHeldSourcePreparation<TContext>,
|
|
2478
|
+
captureTransport?: (ctx: TContext) => (() => boolean) | undefined): Promise<{ forgotten: number; deleted: number }> => {
|
|
2479
|
+
const result = { forgotten: 0, deleted: 0 };
|
|
2480
|
+
const { ctx, signal, journalBindingKey, isCurrent: preparedCurrent } = input;
|
|
2481
|
+
const transport = captureTransport ? captureTransport(ctx) : () => true;
|
|
2482
|
+
const cap = transport && captureTemporaryThreadAuthority(ctx, () => !signal.aborted && preparedCurrent() && transport() &&
|
|
2483
|
+
deps.hasWorkspaceRestoreAuthority?.() === true);
|
|
2484
|
+
const threads = deps.threadStore;
|
|
2485
|
+
if (!cap?.isCurrent() || cap.journalBindingKey !== journalBindingKey || !threads || !deps.runWorkspaceOperation) return result;
|
|
2486
|
+
try {
|
|
2487
|
+
await deps.runWorkspaceOperation({ operationId: `new-world-${randomBytes(16).toString("hex")}`,
|
|
2488
|
+
operationKind: "workspace.forget-previous-world", scopes: [{ kind: "profile" }] }, async () => {
|
|
2489
|
+
if (!cap.isCurrent()) return;
|
|
2490
|
+
await threads.load();
|
|
2491
|
+
if (!cap.isCurrent()) return;
|
|
2492
|
+
const self = cap.authority.executor.instanceId;
|
|
2493
|
+
const disposable = cap.store.listTemporaryThreads().flatMap(entry => entry.operatorUserId === cap.operatorUserId &&
|
|
2494
|
+
entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
|
|
2495
|
+
cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
|
|
2496
|
+
const forgotten = cap.store.forgetPreviousWorld(cap.authority);
|
|
2497
|
+
if (!forgotten) return;
|
|
2498
|
+
result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
|
|
2499
|
+
for (const entry of disposable) {
|
|
2500
|
+
const target = entry.target!;
|
|
2501
|
+
if (!cap.isCurrent() || !deps.callApi) break;
|
|
2502
|
+
await threads.load();
|
|
2503
|
+
const same = (value: { chatId: number; threadId?: number } | undefined) => value?.chatId === target.chatId && value.threadId === target.threadId;
|
|
2504
|
+
if (!cap.isCurrent() || threads.listWorkspaceBindings().some(value => same(value.target)) ||
|
|
2505
|
+
threads.list().some(record => record.status === "active" && same(record.target)) ||
|
|
2506
|
+
cap.store.listTemporaryThreads().some(entry => same(entry.target)) ||
|
|
2507
|
+
cap.store.list().some(({ request }) => same(request.target) || same(request.binding.target))) continue;
|
|
2508
|
+
try {
|
|
2509
|
+
const required = [...new Set([cap.journalBindingKey, entry.source.journalBindingKey,
|
|
2510
|
+
...Threads.getTelegramTemporaryThreadInputs(entry).map(input => input.journalBindingKey)])];
|
|
2511
|
+
if (deps.inspectTemporaryThreadSources?.(target, required)?.length !== 0 || !cap.isCurrent()) continue;
|
|
2512
|
+
// Exactly one attempt; the entry is already forgotten, so failure or silence leaves the tab without retry.
|
|
2513
|
+
if (await deps.callApi<boolean>("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId },
|
|
2514
|
+
{ maxAttempts: 1, retrySafety: "non-idempotent" }) === true) result.deleted++;
|
|
2515
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" }); }
|
|
2516
|
+
}
|
|
2517
|
+
});
|
|
2518
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-forget" }); }
|
|
2519
|
+
return result;
|
|
2520
|
+
};
|
|
2521
|
+
/** Historical temporary sources await the cold policy; warm bound/unknown sources never fall into ordinary dispatch. */
|
|
2522
|
+
const isRecordedTemporaryThreadInputHeld = (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal, historical = false): boolean => {
|
|
2523
|
+
const store = deps.getWorkspaceRestoreStore?.(), binding = deps.getAdmissionJournalBinding?.();
|
|
2524
|
+
if (!store || !binding) return false;
|
|
2525
|
+
const members = store.listTemporaryThreads().filter(temporary =>
|
|
2526
|
+
Threads.getTelegramTemporaryThreadInputs(temporary).some(input => input.journalBindingKey === binding && input.updateIds.includes(entry.updateId)));
|
|
2527
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => !signal.aborted);
|
|
2528
|
+
return members.some(temporary => {
|
|
2529
|
+
if (!cap?.isCurrent()) return true;
|
|
2530
|
+
const observation = store.inspectTemporaryThreadTarget(temporary, cap.authority);
|
|
2531
|
+
return historical || !observation || observation.kind !== "temporary";
|
|
2532
|
+
});
|
|
2533
|
+
};
|
|
2534
|
+
const shouldHoldPendingInput = async (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal): Promise<boolean> => {
|
|
2535
|
+
const binding = deps.getAdmissionJournalBinding?.();
|
|
2536
|
+
const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true && deps.getAdmissionJournalBinding?.() === binding;
|
|
2537
|
+
if (!current()) throw new Error("Temporary Thread source generation ended.");
|
|
2538
|
+
const held = isRecordedTemporaryThreadInputHeld(entry, ctx, signal);
|
|
2539
|
+
if (!current()) throw new Error("Temporary Thread source authority changed.");
|
|
2540
|
+
return held;
|
|
2541
|
+
};
|
|
2542
|
+
const shouldReviewHistoricalInput = async (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal): Promise<boolean | "retain"> => {
|
|
2543
|
+
if (signal.aborted) throw new Error("Historical routing generation ended.");
|
|
2544
|
+
const message = entry.update.message as Partial<TMessage> | undefined;
|
|
2545
|
+
const unsupported = !reviewMessage(entry);
|
|
2546
|
+
const retainable = unsupported && entry.state === "pending" && !entry.preApprovalExcluded && !entry.inputClaim && !entry.inputProvenance &&
|
|
2547
|
+
!entry.queueOwner && !entry.queueReceiptId && !entry.queueHandoff && !entry.failure &&
|
|
2548
|
+
Object.keys(entry.update).every(key => key === "update_id" || key === "message") &&
|
|
2549
|
+
message?.chat?.type === "private" && Number.isSafeInteger(message.chat.id) &&
|
|
2550
|
+
Number.isSafeInteger(message.from?.id) && message.from?.is_bot === false &&
|
|
2551
|
+
Number.isSafeInteger(message.message_id) && message.message_id! > 0 &&
|
|
2552
|
+
Number.isSafeInteger(message.message_thread_id) && message.message_thread_id! > 0 &&
|
|
2553
|
+
!Reflect.has(message, "pi_telegram_source_update_id") && !Reflect.has(message, "business_connection_id");
|
|
2554
|
+
const temporary = isRecordedTemporaryThreadInputHeld(entry, ctx, signal, true);
|
|
2555
|
+
if (!retainable && temporary) return true;
|
|
2556
|
+
if (!retainable && unsupported) return false;
|
|
2557
|
+
if (signal.aborted) throw new Error("Historical routing generation ended.");
|
|
2558
|
+
const epoch = deps.getCurrentLeaderEpoch?.();
|
|
2559
|
+
const binding = deps.getAdmissionJournalBinding?.();
|
|
2560
|
+
const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true &&
|
|
2561
|
+
epoch !== undefined && deps.getCurrentLeaderEpoch?.() === epoch && !!binding && deps.getAdmissionJournalBinding?.() === binding;
|
|
2562
|
+
if (!current() || !deps.threadStore) throw new Error("Historical routing authority is unavailable.");
|
|
2563
|
+
await deps.threadStore.load();
|
|
2564
|
+
if (!current()) throw new Error("Historical routing authority changed.");
|
|
2565
|
+
// Raw unsupported startup originals stay protected even when a bound target outlives forgotten Restore membership.
|
|
2566
|
+
// Holding protects evidence, not sender authorization; owner-bearing sources keep their existing paths.
|
|
2567
|
+
return retainable ? "retain" : needsHistoricalReview(message!);
|
|
2568
|
+
};
|
|
2569
|
+
const recoveryPreview = (source: RecoverySource, owner: number, chatId: number, binding: string): string | undefined => {
|
|
2570
|
+
const entry = source.original;
|
|
2571
|
+
const message = reviewMessage(entry);
|
|
2572
|
+
if (!message || message.chat!.id !== chatId || message.from!.id !== owner) return undefined;
|
|
2573
|
+
for (const pending of pendingUnboundReroutes.values()) {
|
|
2574
|
+
if (pending.abandonment?.journalBindingKey === binding &&
|
|
2575
|
+
Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(entry.updateId) &&
|
|
2576
|
+
// Revoked carriers describe a past attempt, not a live selection veto.
|
|
2577
|
+
// Current claim/entry authority still comes from the worker and journal CAS.
|
|
2578
|
+
pending.messages.some(value => Updates.getTelegramUpdateExecutionFence(value)?.signal.aborted !== true) &&
|
|
2579
|
+
(pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.foreignRetry || pending.cleanup ||
|
|
2580
|
+
pending.abandonment.running)) return undefined;
|
|
2581
|
+
}
|
|
2582
|
+
const text = Array.from(message.text!.replace(/\s+/gu, " ").trim());
|
|
2583
|
+
return escapeHtml(text.slice(0, 80).join("")) + (text.length > 80 ? "…" : "");
|
|
2584
|
+
};
|
|
2585
|
+
const expireRoutingInput = async (source: Updates.TelegramRoutingInputExpirySource, ctx: TContext, signal: AbortSignal): Promise<void> => {
|
|
2586
|
+
const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
|
|
2587
|
+
const message = source.original.update.message as Partial<TMessage> | undefined;
|
|
2588
|
+
const current = () => !signal.aborted && source.isCurrent() && operator !== undefined && epoch !== undefined &&
|
|
2589
|
+
deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
|
|
2590
|
+
deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
|
|
2591
|
+
source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
|
|
2592
|
+
if (!current() || !deps.runWorkspaceOperation) return;
|
|
2593
|
+
await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
|
|
2594
|
+
operationKind: "workspace.expire-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
|
|
2595
|
+
if (!current()) return;
|
|
2596
|
+
const restores = deps.getWorkspaceRestoreStore?.();
|
|
2597
|
+
if (deps.getWorkspaceRestoreStore && !restores) return;
|
|
2598
|
+
if (restores?.list().some(intent => intent.request.source.journalBindingKey === source.journalBindingKey &&
|
|
2599
|
+
intent.request.source.updateIds.includes(source.original.updateId))) return;
|
|
2600
|
+
for (const pending of pendingUnboundReroutes.values()) {
|
|
2601
|
+
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
|
|
2602
|
+
if (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.workspaceRestore || pending.foreignRetry || pending.cleanup || pending.abandonment?.running) return;
|
|
2603
|
+
}
|
|
2604
|
+
const result = source.expire();
|
|
2605
|
+
if (!result) return;
|
|
2606
|
+
// Retire local controls after the source ACK. Telegram UI failure cannot undo retention or trigger deletion.
|
|
2607
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2608
|
+
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
|
|
2609
|
+
removePendingReroute(id);
|
|
2610
|
+
if (signal.aborted || deps.isContextActive?.(ctx) !== true || deps.configStore.getAllowedUserId() !== operator ||
|
|
2611
|
+
deps.getCurrentLeaderEpoch?.() !== epoch || deps.getAdmissionJournalBinding?.() !== source.journalBindingKey) continue;
|
|
2612
|
+
if (deps.editInteractiveMessage && pending.chooserMessageId !== undefined) {
|
|
2613
|
+
try { await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
|
|
2614
|
+
`<b>${TELEGRAM_ROUTING_CHOICE_EXPIRED}</b>`, "html", { inline_keyboard: [] }); }
|
|
2615
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" }); }
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
});
|
|
2619
|
+
};
|
|
2620
|
+
const renderCancellationReview = async (view: CancellationReview, isCurrent: () => boolean): Promise<void> => {
|
|
2621
|
+
if (!isCurrent()) return;
|
|
2622
|
+
const data = (action: string) => `${TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX}${view.id}:${action}`;
|
|
2623
|
+
const rows: Menu.TelegramReplyMarkup["inline_keyboard"] = [[{ text: "⬆️ Main menu", callback_data: "menu:back" }]];
|
|
2624
|
+
const lines = ["<b>☑️ Review pending cancellations:</b>", "",
|
|
2625
|
+
"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."];
|
|
2626
|
+
if (!view.sources.length) lines.push("", "No supported pending cancellations on this page.");
|
|
2627
|
+
for (const [index, source] of view.sources.entries()) {
|
|
2628
|
+
lines.push("", `<b>${index + 1}. ${source.result ? "⛔️ Routing cancelled." : "Protected input"}</b>`, source.preview);
|
|
2629
|
+
if (!source.result) rows.push([{ text: `❌ Finish cancellation ${index + 1}`, callback_data: data(`retry:${index}`) }]);
|
|
2630
|
+
}
|
|
2631
|
+
if (view.unsupported) lines.push("", "Other protected inputs are not available in this view and remain untouched.");
|
|
2632
|
+
if (view.nextAfterUpdateId !== undefined) rows.push([{ text: "🟣 More", callback_data: data("more") }]);
|
|
2633
|
+
rows.push([{ text: "🔄 Refresh", callback_data: data("refresh") }]);
|
|
2634
|
+
await deps.editInteractiveMessage!(view.target.chatId, view.messageId, lines.join("\n"), "html", { inline_keyboard: rows });
|
|
2635
|
+
};
|
|
2636
|
+
const handleCancellationReview = async (query: TCallbackQuery, ctx: TContext): Promise<boolean> => {
|
|
2637
|
+
const matches = (view: CancellationReview) => query.message?.message_id === view.messageId &&
|
|
2638
|
+
query.message.chat.id === view.target.chatId &&
|
|
2639
|
+
(query.message.message_thread_id === undefined || query.message.message_thread_id === view.target.threadId);
|
|
2640
|
+
if (query.data?.startsWith(TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX)) {
|
|
2641
|
+
await deps.answerCallbackQuery(query.id, "🚫 This control is no longer available.");
|
|
2642
|
+
return true;
|
|
2643
|
+
}
|
|
2644
|
+
const ownsReview = query.data?.startsWith(TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX) === true;
|
|
2645
|
+
const prefix = TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX;
|
|
2646
|
+
if (!ownsReview && cancellationReview && matches(cancellationReview)) {
|
|
2647
|
+
if (cancellationReview.running && cancellationReview.isCurrent()) {
|
|
2648
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
|
|
2649
|
+
return true;
|
|
2650
|
+
}
|
|
2651
|
+
cancellationReview = undefined;
|
|
2652
|
+
return false;
|
|
2653
|
+
}
|
|
2654
|
+
if (!ownsReview) return false;
|
|
2655
|
+
const unavailable = async () => { await deps.answerCallbackQuery(query.id, "🚫 Cancellation review expired or unavailable. Open Status again."); return true; };
|
|
2656
|
+
const execution = Updates.getTelegramUpdateExecutionFence(query);
|
|
2657
|
+
const chatId = query.message?.chat.id;
|
|
2658
|
+
const messageId = query.message?.message_id;
|
|
2659
|
+
if (execution?.isCurrent() !== true || deps.isContextActive?.(ctx) !== true ||
|
|
2660
|
+
!deps.editInteractiveMessage || !deps.runWorkspaceOperation || query.message?.chat.type !== "private" ||
|
|
2661
|
+
typeof chatId !== "number" || !Number.isSafeInteger(chatId) ||
|
|
2662
|
+
typeof messageId !== "number" || !Number.isSafeInteger(messageId) || messageId <= 0) return unavailable();
|
|
2663
|
+
const owner = deps.configStore.getAllowedUserId();
|
|
2664
|
+
const journalBindingKey = deps.getAdmissionJournalBinding?.();
|
|
2665
|
+
const epoch = deps.getCurrentLeaderEpoch?.();
|
|
2666
|
+
if (owner === undefined || query.from.id !== owner || query.from.is_bot || !journalBindingKey || epoch === undefined) return unavailable();
|
|
2667
|
+
const current = () => execution.isCurrent() && deps.isContextActive?.(ctx) === true &&
|
|
2668
|
+
deps.configStore.getAllowedUserId() === owner && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
|
|
2669
|
+
deps.getCurrentLeaderEpoch?.() === epoch;
|
|
2670
|
+
const parsed = query.data!.slice(prefix.length).match(/^([a-z0-9]+):(retry:(\d+)|refresh|more)$/);
|
|
2671
|
+
const prior = cancellationReview;
|
|
2672
|
+
const open = query.data === `${prefix}open`;
|
|
2673
|
+
if (!open && (!parsed || !prior || parsed[1] !== prior.id || !matches(prior) || !prior.isCurrent())) return unavailable();
|
|
2674
|
+
if (prior?.running && prior.isCurrent()) {
|
|
2675
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
|
|
2676
|
+
return true;
|
|
2677
|
+
}
|
|
2678
|
+
let view = prior;
|
|
2679
|
+
let selected: CancellationReview["sources"][number] | undefined;
|
|
2680
|
+
try {
|
|
2681
|
+
if (open || parsed?.[2] === "refresh" || parsed?.[2] === "more") {
|
|
2682
|
+
if (parsed?.[2] === "more" && prior?.nextAfterUpdateId === undefined) return unavailable();
|
|
2683
|
+
const next: CancellationReview = { id: randomBytes(16).toString("hex"),
|
|
2684
|
+
target: { chatId, ...(query.message.message_thread_id !== undefined ? { threadId: query.message.message_thread_id } : {}) },
|
|
2685
|
+
messageId, ownerUserId: owner, journalBindingKey, sources: [], unsupported: false,
|
|
2686
|
+
isCurrent: () => cancellationReview === next && current() };
|
|
2687
|
+
cancellationReview = view = next;
|
|
2688
|
+
const page = Updates.inspectTelegramAbandoningUpdates(query, { journalBindingKey, isCurrent: next.isCurrent,
|
|
2689
|
+
...(parsed?.[2] === "more" ? { afterUpdateId: prior!.nextAfterUpdateId } : {}) });
|
|
2690
|
+
if (!page) { if (cancellationReview === next) cancellationReview = undefined; return unavailable(); }
|
|
2691
|
+
for (const source of page.sources) {
|
|
2692
|
+
const preview = recoveryPreview(source, owner, next.target.chatId, journalBindingKey);
|
|
2693
|
+
if (preview === undefined) { next.unsupported = true; continue; }
|
|
2694
|
+
if (next.sources.length === 5) { next.nextAfterUpdateId = next.sources.at(-1)!.original.updateId; break; }
|
|
2695
|
+
next.sources.push({ ...source, preview });
|
|
2696
|
+
}
|
|
2697
|
+
next.nextAfterUpdateId ??= page.nextAfterUpdateId;
|
|
2698
|
+
next.running = true;
|
|
2699
|
+
await renderCancellationReview(next, next.isCurrent);
|
|
2700
|
+
if (next.isCurrent()) await deps.answerCallbackQuery(query.id);
|
|
2701
|
+
} else {
|
|
2702
|
+
if (!view || !parsed?.[3] || !parsed[2].startsWith("retry:")) return unavailable();
|
|
2703
|
+
selected = view.sources[Number(parsed[3])];
|
|
2704
|
+
if (!selected) return unavailable();
|
|
2705
|
+
const activeView = view;
|
|
2706
|
+
const source = selected;
|
|
2707
|
+
const isCurrent = () => current() && activeView.isCurrent() &&
|
|
2708
|
+
recoveryPreview(source, owner, activeView.target.chatId, journalBindingKey) !== undefined;
|
|
2709
|
+
view.running = true;
|
|
2710
|
+
await deps.runWorkspaceOperation({ operationId: `workspace-cancellation-recovery:${query.id}`,
|
|
2711
|
+
operationKind: "workspace.recover-unbound-cancellation", scopes: [{ kind: "profile" }] }, async () => {
|
|
2712
|
+
if (!isCurrent()) return;
|
|
2713
|
+
source.result ??= source.retry({ operatorAuthorityId: `telegram-owner:${owner}`, isCurrent });
|
|
2714
|
+
if (!source.result) { await unavailable(); return; }
|
|
2715
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2716
|
+
if (pending.abandonment?.ownerUserId !== owner || pending.abandonment.journalBindingKey !== journalBindingKey ||
|
|
2717
|
+
!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
|
|
2718
|
+
pending.abandonment.result = source.result;
|
|
2719
|
+
pending.abandonment.attempted = true;
|
|
2720
|
+
try { await retireCancellationChooser(id, pending, isCurrent); }
|
|
2721
|
+
catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "recovered-chooser-cleanup" }); }
|
|
2722
|
+
}
|
|
2723
|
+
await renderCancellationReview(activeView, isCurrent);
|
|
2724
|
+
if (isCurrent()) await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
} catch (error) {
|
|
2728
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation-recovery" });
|
|
2729
|
+
if (current()) await deps.answerCallbackQuery(query.id, selected?.result
|
|
2730
|
+
? "⚠️ Routing cancelled; view update failed. Retry the same button."
|
|
2731
|
+
: "⚠️ Recovery incomplete; inputs stay protected. Check /telegram-status --debug.");
|
|
2732
|
+
} finally { if (view) view.running = false; }
|
|
2733
|
+
return true;
|
|
2734
|
+
};
|
|
2735
|
+
const handleUnboundRerouteCancelCallback = async (query: TCallbackQuery, ctx: TContext): Promise<boolean> => {
|
|
2736
|
+
if (!query.data?.startsWith(TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX)) return false;
|
|
2737
|
+
const rerouteId = query.data.match(/^reroutecancel:([a-z0-9]+)$/)?.[1];
|
|
2738
|
+
const pending = rerouteId ? pendingUnboundReroutes.get(rerouteId) : undefined;
|
|
2739
|
+
const cancellation = pending?.abandonment;
|
|
2740
|
+
const source = pending?.messages[0];
|
|
2741
|
+
const execution = Updates.getTelegramUpdateExecutionFence(query);
|
|
2742
|
+
const sourceExecution = Updates.getTelegramUpdateExecutionFence(source);
|
|
2743
|
+
const isAuthorityCurrent = () => !!cancellation && execution?.isCurrent() === true &&
|
|
2744
|
+
sourceExecution?.signal.aborted === false && deps.isContextActive?.(ctx) === true &&
|
|
2745
|
+
query.from.id === cancellation.ownerUserId && !query.from.is_bot &&
|
|
2746
|
+
deps.configStore.getAllowedUserId() === cancellation.ownerUserId &&
|
|
2747
|
+
deps.getAdmissionJournalBinding?.() === cancellation.journalBindingKey &&
|
|
2748
|
+
deps.getCurrentLeaderEpoch?.() === cancellation.leaderEpoch;
|
|
2749
|
+
const isCurrent = () => isAuthorityCurrent() && !!pending && !!cancellation && !!source &&
|
|
2750
|
+
pendingUnboundReroutes.get(rerouteId!) === pending && pending.abandonment === cancellation &&
|
|
2751
|
+
matchesRerouteChooser(pending, query) && query.message?.chat.type === "private" &&
|
|
2752
|
+
source.from?.id === cancellation.ownerUserId &&
|
|
2753
|
+
pending.messages.length === 1 &&
|
|
2754
|
+
!pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching &&
|
|
2755
|
+
!pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage;
|
|
2756
|
+
if (!isCurrent() || !deps.runWorkspaceOperation || !deps.editInteractiveMessage) {
|
|
2757
|
+
// A chooser from a previous process (new world) or an expired one simply reads as expired.
|
|
2758
|
+
await deps.answerCallbackQuery(query.id, pending ? "🚫 Message route is unavailable for cancellation." : TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
2759
|
+
return true;
|
|
2760
|
+
}
|
|
2761
|
+
if (cancellation!.running) {
|
|
2762
|
+
await deps.answerCallbackQuery(query.id, "⏳ Cancellation is already in progress.");
|
|
2763
|
+
return true;
|
|
2764
|
+
}
|
|
2765
|
+
cancellation!.running = true;
|
|
2766
|
+
try {
|
|
2767
|
+
await deps.runWorkspaceOperation({ operationId: `workspace-reroute-cancel:${query.id}`,
|
|
2768
|
+
operationKind: "workspace.cancel-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
|
|
2769
|
+
if (!isCurrent()) return;
|
|
2770
|
+
if (!cancellation!.result) {
|
|
2771
|
+
const wasAttempted = cancellation!.attempted;
|
|
2772
|
+
cancellation!.attempted = true;
|
|
2773
|
+
const result = Updates.abandonTelegramDeferredUpdate(source, {
|
|
2774
|
+
operatorAuthorityId: `telegram-owner:${cancellation!.ownerUserId}`, isCurrent,
|
|
2775
|
+
});
|
|
2776
|
+
if (!result) {
|
|
2777
|
+
cancellation!.attempted = wasAttempted;
|
|
2778
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message routing cannot be cancelled now.");
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
cancellation!.result = result;
|
|
2782
|
+
}
|
|
2783
|
+
if (!isCurrent()) return;
|
|
2784
|
+
if (!recordCancelledTemporaryThreadInput(pending!, ctx, isCurrent)) {
|
|
2785
|
+
if (isAuthorityCurrent()) await deps.answerCallbackQuery(query.id, "⚠️ Routing cancelled; Thread cleanup remains protected. Retry Cancel routing.");
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
if (await retireCancellationChooser(rerouteId!, pending!, isCurrent) && isAuthorityCurrent()) {
|
|
2789
|
+
scheduleTemporaryThreadCleanup(pending!.sourceTarget, ctx);
|
|
2790
|
+
await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
|
|
2791
|
+
}
|
|
2792
|
+
});
|
|
2793
|
+
} catch (error) {
|
|
2794
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation", rerouteId });
|
|
2795
|
+
if (isCurrent()) await deps.answerCallbackQuery(query.id,
|
|
2796
|
+
cancellation!.result ? "⚠️ Routing cancelled; chooser update failed. Retry Cancel routing." :
|
|
2797
|
+
"⚠️ Cancellation incomplete. Retry Cancel routing.");
|
|
2798
|
+
} finally { cancellation!.running = false; }
|
|
2799
|
+
return true;
|
|
2800
|
+
};
|
|
2801
|
+
const withPendingRerouteSelection = async (
|
|
2802
|
+
rerouteId: string, query: TCallbackQuery, operation: () => Promise<boolean>,
|
|
2803
|
+
): Promise<boolean> => {
|
|
2804
|
+
Updates.assertTelegramUpdateExecutionCurrent(query);
|
|
2805
|
+
const pending = pendingUnboundReroutes.get(rerouteId);
|
|
2806
|
+
if (pending?.foreignForwardIssued && matchesRerouteChooser(pending, query)) {
|
|
2807
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
2808
|
+
return true;
|
|
2809
|
+
}
|
|
2810
|
+
if (pending && matchesRerouteChooser(pending, query) &&
|
|
2811
|
+
(pending.abandonment?.attempted || pending.abandonment?.running || pending.messages.some(message =>
|
|
2812
|
+
Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === false))) {
|
|
2813
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
2814
|
+
return true;
|
|
2815
|
+
}
|
|
2816
|
+
const releases: Array<() => void> = [];
|
|
2817
|
+
try {
|
|
2818
|
+
if (pending && matchesRerouteChooser(pending, query)) {
|
|
2819
|
+
try {
|
|
2820
|
+
for (const message of pending.messages) releases.push(Updates.acquireTelegramUpdateRouting(message));
|
|
2821
|
+
} catch (error) {
|
|
2822
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
|
|
2823
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
|
|
2824
|
+
return true;
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
return await operation();
|
|
2828
|
+
} finally { for (const release of releases) release(); }
|
|
2829
|
+
};
|
|
2830
|
+
const executeUnboundRerouteRestoreMenuCallback = async (
|
|
1325
2831
|
query: TCallbackQuery,
|
|
1326
2832
|
_ctx: TContext,
|
|
1327
2833
|
): Promise<boolean> => {
|
|
@@ -1340,12 +2846,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1340
2846
|
!deps.threadStore ||
|
|
1341
2847
|
!pending ||
|
|
1342
2848
|
!matchesRerouteChooser(pending, query) ||
|
|
2849
|
+
(parsed.root && pending.rootChooserText === undefined) ||
|
|
1343
2850
|
expirePendingCommand(parsed.rerouteId, pending)
|
|
1344
2851
|
) {
|
|
1345
|
-
await deps.answerCallbackQuery(query.id,
|
|
2852
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
1346
2853
|
return true;
|
|
1347
2854
|
}
|
|
1348
|
-
if (pending.sourceTarget.threadId === undefined) {
|
|
2855
|
+
if (parsed.restore && pending.sourceTarget.threadId === undefined) {
|
|
1349
2856
|
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
1350
2857
|
return true;
|
|
1351
2858
|
}
|
|
@@ -1355,23 +2862,36 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1355
2862
|
deps.threadStore.list(),
|
|
1356
2863
|
deps.getLiveThreadTargets?.(),
|
|
1357
2864
|
);
|
|
1358
|
-
const replyMarkup =
|
|
1359
|
-
parsed.rerouteId,
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
2865
|
+
const replyMarkup = parsed.root
|
|
2866
|
+
? buildTelegramUnboundRerouteChooserMarkup(parsed.rerouteId, activeRecords, {
|
|
2867
|
+
canRestore: pending.sourceTarget.threadId !== undefined, canCancel: !!pending.abandonment,
|
|
2868
|
+
getDisplayTitle: deps.getDisplayTitle,
|
|
2869
|
+
})
|
|
2870
|
+
: parsed.restore
|
|
2871
|
+
? buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle)
|
|
2872
|
+
: { inline_keyboard: activeRecords.map(record => [{
|
|
2873
|
+
text: `↪️ ${getTelegramThreadRecordLabel(record, deps.getDisplayTitle)}`,
|
|
2874
|
+
callback_data: formatTelegramUnboundRerouteCallbackData(parsed.rerouteId, record.target.threadId),
|
|
2875
|
+
}]) };
|
|
2876
|
+
const chooserText = parsed.root
|
|
2877
|
+
? pending.rootChooserText!
|
|
2878
|
+
: parsed.restore ? formatTelegramUnboundRerouteRestoreChooserText()
|
|
2879
|
+
: "<b>🧵 Reroute:</b>\n\nChoose the thread that should handle this input:";
|
|
2880
|
+
if (!parsed.root) replyMarkup.inline_keyboard.unshift([{
|
|
2881
|
+
text: "⬆️ Back", callback_data: `${TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX}${parsed.rerouteId}`,
|
|
2882
|
+
}]);
|
|
1363
2883
|
if (deps.editInteractiveMessage) {
|
|
1364
2884
|
await deps.editInteractiveMessage(
|
|
1365
2885
|
chatId,
|
|
1366
2886
|
messageId,
|
|
1367
|
-
|
|
2887
|
+
chooserText,
|
|
1368
2888
|
"html",
|
|
1369
2889
|
replyMarkup,
|
|
1370
2890
|
);
|
|
1371
2891
|
} else if (deps.sendInteractiveMessage) {
|
|
1372
2892
|
const chooserId = await deps.sendInteractiveMessage(
|
|
1373
2893
|
chatId,
|
|
1374
|
-
|
|
2894
|
+
chooserText,
|
|
1375
2895
|
"html",
|
|
1376
2896
|
replyMarkup,
|
|
1377
2897
|
{ target: pending.sourceTarget, replyToMessageId: messageId },
|
|
@@ -1380,7 +2900,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1380
2900
|
rememberRerouteChooser(parsed.rerouteId, chooserId);
|
|
1381
2901
|
}
|
|
1382
2902
|
assertExecutionCurrent();
|
|
1383
|
-
await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
|
|
2903
|
+
await deps.answerCallbackQuery(query.id, parsed.root ? "Choose routing mode." : parsed.restore ? "Choose instance to restore." : "Choose target thread.");
|
|
1384
2904
|
return true;
|
|
1385
2905
|
};
|
|
1386
2906
|
const executeUnboundRerouteCallbackOperation = async (
|
|
@@ -1396,14 +2916,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1396
2916
|
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1397
2917
|
if (typeof chatId !== "number" || !deps.threadStore || !pending ||
|
|
1398
2918
|
!matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
|
|
1399
|
-
await deps.answerCallbackQuery(query.id,
|
|
2919
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
1400
2920
|
return true;
|
|
1401
2921
|
}
|
|
1402
2922
|
await deps.threadStore.load();
|
|
1403
2923
|
assertExecutionCurrent();
|
|
1404
2924
|
if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
|
|
1405
2925
|
expirePendingCommand(parsed.rerouteId, pending)) {
|
|
1406
|
-
await deps.answerCallbackQuery(query.id,
|
|
2926
|
+
await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
|
|
1407
2927
|
return true;
|
|
1408
2928
|
}
|
|
1409
2929
|
if (pending.finalizeMessage) {
|
|
@@ -1416,6 +2936,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1416
2936
|
);
|
|
1417
2937
|
return true;
|
|
1418
2938
|
}
|
|
2939
|
+
if (pending.workspaceRestore && (!restoreWorkspace || !parsed.useNewSlot ||
|
|
2940
|
+
pending.workspaceRestore.record.target.threadId !== parsed.threadId)) {
|
|
2941
|
+
await deps.answerCallbackQuery(query.id, "🚫 Restore is already selected. Use the original Restore choice.");
|
|
2942
|
+
return true;
|
|
2943
|
+
}
|
|
1419
2944
|
if (pending.foreignRetry) {
|
|
1420
2945
|
const retry = pending.foreignRetry;
|
|
1421
2946
|
const allForwarded = await forwardPendingRerouteMessages(
|
|
@@ -1428,7 +2953,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1428
2953
|
if (!allForwarded) {
|
|
1429
2954
|
await deps.answerCallbackQuery(
|
|
1430
2955
|
query.id,
|
|
1431
|
-
"
|
|
2956
|
+
pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
|
|
2957
|
+
"Target thread is unavailable; retrying will send only remaining messages.",
|
|
1432
2958
|
);
|
|
1433
2959
|
return true;
|
|
1434
2960
|
}
|
|
@@ -1457,7 +2983,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1457
2983
|
);
|
|
1458
2984
|
return true;
|
|
1459
2985
|
}
|
|
1460
|
-
|
|
2986
|
+
if (!pending.workspaceRestore && isTemporaryReroute(pending) && isTemporaryThreadForwardIssued(pending)) {
|
|
2987
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
2988
|
+
return true;
|
|
2989
|
+
}
|
|
2990
|
+
const record = pending.workspaceRestore?.record ?? getTelegramRoutableThreadRecords(
|
|
1461
2991
|
deps.threadStore.list(),
|
|
1462
2992
|
deps.getLiveThreadTargets?.(),
|
|
1463
2993
|
).find(
|
|
@@ -1487,137 +3017,92 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1487
3017
|
await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
|
|
1488
3018
|
return true;
|
|
1489
3019
|
}
|
|
1490
|
-
|
|
3020
|
+
// A source's own temporary tab is its Restore destination; only other protection refuses it.
|
|
3021
|
+
if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
|
|
3022
|
+
isRerouteTargetProtected(sourceTarget, undefined, pending.temporaryThread ?? findCreatedTemporaryThread(sourceTarget), pending)) {
|
|
1491
3023
|
await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
|
|
1492
3024
|
return true;
|
|
1493
3025
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
pending.
|
|
3026
|
+
// Restore has one controller. Without it or its negotiated authority, refuse before selection so the chooser keeps
|
|
3027
|
+
// its other routes and Cancel.
|
|
3028
|
+
if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
|
|
3029
|
+
(!restoreWorkspace || deps.hasWorkspaceRestoreAuthority?.() !== true)) {
|
|
3030
|
+
await deps.answerCallbackQuery(query.id, "🚫 Thread restore is unavailable right now. The message stays pending; choose another route or cancel.");
|
|
3031
|
+
return true;
|
|
3032
|
+
}
|
|
1497
3033
|
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1498
3034
|
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
|
-
}
|
|
3035
|
+
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(record, leaderProfileKey, currentInstanceId);
|
|
3036
|
+
if (!parsed.useNewSlot && record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord &&
|
|
3037
|
+
!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
|
|
3038
|
+
if (pending.routingOperatorUserId === undefined) {
|
|
3039
|
+
pending.destinationSelected = true; pending.selectionAttempted = true; pending.pauseExpiry?.();
|
|
1578
3040
|
}
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
3041
|
+
await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
|
|
3042
|
+
return true;
|
|
3043
|
+
}
|
|
3044
|
+
if (pending.routingOperatorUserId !== undefined && (pending.routingOperatorUserId !== query.from.id ||
|
|
3045
|
+
pending.routingOperatorUserId !== deps.configStore.getAllowedUserId())) {
|
|
3046
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route authority changed.");
|
|
3047
|
+
return true;
|
|
3048
|
+
}
|
|
3049
|
+
// Freeze only a validated actual destination, inside the same admission as its effect. Menu browsing,
|
|
3050
|
+
// unavailable recipients and refused Restore capability never turn a waiting source into a selected grant.
|
|
3051
|
+
try {
|
|
3052
|
+
const release = Updates.acquireTelegramUpdateRouting(pending.messages[0], true,
|
|
3053
|
+
Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages));
|
|
3054
|
+
release();
|
|
3055
|
+
} catch (error) {
|
|
3056
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
|
|
3057
|
+
await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
|
|
3058
|
+
return true;
|
|
3059
|
+
}
|
|
3060
|
+
pending.destinationSelected = true;
|
|
3061
|
+
pending.selectionAttempted = true;
|
|
3062
|
+
pending.pauseExpiry?.();
|
|
3063
|
+
if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
|
|
3064
|
+
const selection = pending.workspaceRestore ??= { operationId: `restore-${randomBytes(16).toString("hex")}`,
|
|
3065
|
+
record: structuredClone(record), messages: [...pending.messages] };
|
|
3066
|
+
let active = true;
|
|
3067
|
+
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
3068
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
3069
|
+
const admissionScope = deps.getAdmissionScope?.();
|
|
3070
|
+
const journalBinding = deps.getAdmissionJournalBinding?.();
|
|
3071
|
+
const selectionCurrent = (): boolean => {
|
|
3072
|
+
try { assertExecutionCurrent(); } catch { return false; }
|
|
3073
|
+
return ownerUserId === query.from.id && deps.configStore.getAllowedUserId() === ownerUserId &&
|
|
3074
|
+
deps.getCurrentLeaderEpoch?.() === leaderEpoch && deps.getAdmissionScope?.() === admissionScope &&
|
|
3075
|
+
deps.getAdmissionJournalBinding?.() === journalBinding && deps.isContextActive?.(ctx) !== false &&
|
|
3076
|
+
pendingUnboundReroutes.get(parsed.rerouteId) === pending && pending.workspaceRestore === selection;
|
|
1583
3077
|
};
|
|
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
|
-
);
|
|
3078
|
+
const current = (): boolean => active && selectionCurrent();
|
|
3079
|
+
const assertRestoreCurrent = (): void => {
|
|
3080
|
+
assertExecutionCurrent();
|
|
3081
|
+
if (!current()) throw new Error("Stale Workspace Restore callback.");
|
|
3082
|
+
};
|
|
3083
|
+
try { await restoreWorkspace({ operationId: selection.operationId, record: structuredClone(selection.record),
|
|
3084
|
+
target: { ...sourceTarget }, messages: [...selection.messages], ctx, isCurrent: current,
|
|
3085
|
+
async dispatch(recipient, isRecipientCurrent, recordForwardAcceptance, recordLocalAcceptance) {
|
|
3086
|
+
const assertRecipientCurrent = (): void => {
|
|
3087
|
+
assertRestoreCurrent();
|
|
3088
|
+
if (!isRecipientCurrent()) throw new Error("Workspace Restore recipient authority changed.");
|
|
3089
|
+
};
|
|
3090
|
+
assertRecipientCurrent();
|
|
3091
|
+
if (recipient.kind === "leader") {
|
|
3092
|
+
if (recipient.instanceId !== currentInstanceId) return false;
|
|
3093
|
+
const routed = cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId);
|
|
3094
|
+
const messages = pending.dispatchKind === "command" ? routed.map((message, index) =>
|
|
3095
|
+
Updates.bindTelegramUpdateCompletionAcceptance(message, () => recordLocalAcceptance(pending.messages[index]!))) : routed;
|
|
3096
|
+
await dispatchPendingRerouteMessages(pending, messages, ctx);
|
|
3097
|
+
assertRecipientCurrent();
|
|
3098
|
+
return true;
|
|
3099
|
+
}
|
|
3100
|
+
return forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
|
|
3101
|
+
},
|
|
3102
|
+
}); } finally { active = false; }
|
|
3103
|
+
assertExecutionCurrent();
|
|
3104
|
+
if (!selectionCurrent()) return true;
|
|
3105
|
+
await deps.answerCallbackQuery(query.id, "🚫 Restore is pending; this button will not resend uncertain input.");
|
|
1621
3106
|
return true;
|
|
1622
3107
|
}
|
|
1623
3108
|
if (
|
|
@@ -1625,13 +3110,6 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1625
3110
|
record.instanceId !== currentInstanceId &&
|
|
1626
3111
|
!isCurrentLeaderRecord
|
|
1627
3112
|
) {
|
|
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
3113
|
const allForwarded = await forwardPendingRerouteMessages(
|
|
1636
3114
|
pending,
|
|
1637
3115
|
record.instanceId,
|
|
@@ -1642,14 +3120,17 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1642
3120
|
if (!allForwarded) {
|
|
1643
3121
|
await deps.answerCallbackQuery(
|
|
1644
3122
|
query.id,
|
|
1645
|
-
"
|
|
3123
|
+
pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
|
|
3124
|
+
"Target thread is unavailable; retrying will send only remaining messages.",
|
|
1646
3125
|
);
|
|
1647
3126
|
return true;
|
|
1648
3127
|
}
|
|
1649
|
-
|
|
3128
|
+
// A temporary tab is removed only after every input in it resolves, never by one Forward.
|
|
3129
|
+
const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(
|
|
1650
3130
|
sourceTarget,
|
|
1651
3131
|
sourceMessageId,
|
|
1652
3132
|
assertExecutionCurrent,
|
|
3133
|
+
pending.temporaryThread,
|
|
1653
3134
|
);
|
|
1654
3135
|
if (!cleanupComplete && sourceTarget) {
|
|
1655
3136
|
pending.cleanup = {
|
|
@@ -1658,6 +3139,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1658
3139
|
...(typeof sourceMessageId === "number"
|
|
1659
3140
|
? { messageId: sourceMessageId }
|
|
1660
3141
|
: {}),
|
|
3142
|
+
...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
|
|
1661
3143
|
};
|
|
1662
3144
|
await deps.answerCallbackQuery(
|
|
1663
3145
|
query.id,
|
|
@@ -1671,116 +3153,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1671
3153
|
query,
|
|
1672
3154
|
"Message routed.",
|
|
1673
3155
|
);
|
|
3156
|
+
if (sourceTarget && isTemporaryTabTarget(sourceTarget)) scheduleTemporaryThreadCleanup(sourceTarget, ctx);
|
|
1674
3157
|
return true;
|
|
1675
3158
|
}
|
|
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
|
-
);
|
|
3159
|
+
// Local handlers and queue admission consume the same one-time group grant as follower forwarding.
|
|
3160
|
+
if (isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
|
|
3161
|
+
await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
|
|
1776
3162
|
return true;
|
|
1777
3163
|
}
|
|
1778
3164
|
await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
|
|
1779
3165
|
pending.messages = [];
|
|
1780
|
-
|
|
3166
|
+
// A temporary tab is removed only after every input in it resolves, never by one Forward.
|
|
3167
|
+
const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(
|
|
1781
3168
|
sourceTarget,
|
|
1782
3169
|
sourceMessageId,
|
|
1783
3170
|
assertExecutionCurrent,
|
|
3171
|
+
pending.temporaryThread,
|
|
1784
3172
|
);
|
|
1785
3173
|
if (!cleanupComplete && sourceTarget) {
|
|
1786
3174
|
pending.cleanup = {
|
|
@@ -1789,6 +3177,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1789
3177
|
...(typeof sourceMessageId === "number"
|
|
1790
3178
|
? { messageId: sourceMessageId }
|
|
1791
3179
|
: {}),
|
|
3180
|
+
...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
|
|
1792
3181
|
};
|
|
1793
3182
|
await deps.answerCallbackQuery(
|
|
1794
3183
|
query.id,
|
|
@@ -1802,24 +3191,26 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1802
3191
|
query,
|
|
1803
3192
|
"Message routed.",
|
|
1804
3193
|
);
|
|
3194
|
+
if (sourceTarget && isTemporaryTabTarget(sourceTarget)) scheduleTemporaryThreadCleanup(sourceTarget, ctx);
|
|
1805
3195
|
return true;
|
|
1806
3196
|
};
|
|
1807
|
-
const executeUnboundRerouteCallback = (
|
|
3197
|
+
const executeUnboundRerouteCallback = async (
|
|
1808
3198
|
query: TCallbackQuery,
|
|
1809
3199
|
ctx: TContext,
|
|
1810
3200
|
): Promise<boolean> => {
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
3201
|
+
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
3202
|
+
if (!parsed) return false;
|
|
3203
|
+
return withPendingRerouteSelection(parsed.rerouteId, query, () => {
|
|
3204
|
+
if (!deps.runWorkspaceOperation) return executeUnboundRerouteCallbackOperation(query, ctx);
|
|
3205
|
+
return deps.runWorkspaceOperation(
|
|
3206
|
+
{
|
|
3207
|
+
operationId: `workspace-reroute:${query.id}`,
|
|
3208
|
+
operationKind: "workspace.route-unbound-thread",
|
|
3209
|
+
scopes: [{ kind: "profile" }],
|
|
3210
|
+
},
|
|
3211
|
+
() => executeUnboundRerouteCallbackOperation(query, ctx),
|
|
3212
|
+
);
|
|
3213
|
+
});
|
|
1823
3214
|
};
|
|
1824
3215
|
const handleUnboundRerouteCallback = async (
|
|
1825
3216
|
query: TCallbackQuery,
|
|
@@ -1828,7 +3219,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1828
3219
|
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
1829
3220
|
const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1830
3221
|
if (!parsed || !pending || pending.dispatchKind !== "command" ||
|
|
1831
|
-
pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
|
|
3222
|
+
(pending.sourceTarget.threadId !== undefined && !pending.temporaryThread) || !matchesRerouteChooser(pending, query)) {
|
|
1832
3223
|
return executeUnboundRerouteCallback(query, ctx);
|
|
1833
3224
|
}
|
|
1834
3225
|
if (pending.dispatching) {
|
|
@@ -1855,7 +3246,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1855
3246
|
const assertExecutionCurrent =
|
|
1856
3247
|
Updates.createTelegramUpdateExecutionFenceGuard(query);
|
|
1857
3248
|
assertExecutionCurrent();
|
|
1858
|
-
if (await
|
|
3249
|
+
if (await handleCancellationReview(query, ctx)) return;
|
|
3250
|
+
if (await handleUnboundRerouteCancelCallback(query, ctx)) return;
|
|
3251
|
+
const restore = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
|
|
3252
|
+
if (restore && await withPendingRerouteSelection(restore.rerouteId, query,
|
|
3253
|
+
() => executeUnboundRerouteRestoreMenuCallback(query, ctx))) return;
|
|
1859
3254
|
if (await handleUnboundRerouteCallback(query, ctx)) return;
|
|
1860
3255
|
if (deps.buttonActionStore) {
|
|
1861
3256
|
const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery(
|
|
@@ -2348,9 +3743,55 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2348
3743
|
reportQueueAdmission(messages, turn.admissionReceipts ?? []);
|
|
2349
3744
|
});
|
|
2350
3745
|
};
|
|
3746
|
+
const recordUnboundTemporaryThreadInput = async (messages: TMessage[], ctx: TContext): Promise<boolean> => {
|
|
3747
|
+
const message = messages[0], target = message && Updates.getTelegramMessageTarget(message);
|
|
3748
|
+
const store = deps.getWorkspaceRestoreStore?.();
|
|
3749
|
+
if (!target?.threadId || !store) return false;
|
|
3750
|
+
let found = store.listTemporaryThreads().find(entry => entry.phase === "created" && entry.target &&
|
|
3751
|
+
entry.target.chatId === target.chatId && entry.target.threadId === target.threadId);
|
|
3752
|
+
const key = formatTelegramTargetKey(target), implicit = implicitThreadCreations.get(key);
|
|
3753
|
+
if (!found && !implicit) return false;
|
|
3754
|
+
const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(messages);
|
|
3755
|
+
const operatorUserId = deps.configStore.getAllowedUserId();
|
|
3756
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => messages.every(source =>
|
|
3757
|
+
Updates.getTelegramUpdateExecutionFence(source)?.isCurrent() === true &&
|
|
3758
|
+
source.chat.type === "private" && source.chat.id === target.chatId && source.message_thread_id === target.threadId &&
|
|
3759
|
+
source.from?.id === operatorUserId && source.from?.is_bot === false));
|
|
3760
|
+
if (!found && implicit) {
|
|
3761
|
+
if (updateIds.length !== 1) { implicitThreadCreations.delete(key); return false; }
|
|
3762
|
+
if (!deps.runWorkspaceOperation || !cap || implicit.ctx !== ctx || implicit.operatorUserId !== cap.operatorUserId ||
|
|
3763
|
+
implicit.journalBindingKey !== cap.journalBindingKey || !isDeepStrictEqual(implicit.executor, cap.authority.executor) ||
|
|
3764
|
+
implicit.generation !== deps.getSessionGeneration?.() || implicit.scope !== deps.getAdmissionScope?.() ||
|
|
3765
|
+
!updateIds.length || updateIds.some(id => id <= implicit.updateId) || !cap.isCurrent()) {
|
|
3766
|
+
implicitThreadCreations.delete(key);
|
|
3767
|
+
return false;
|
|
3768
|
+
}
|
|
3769
|
+
found = store.registerImplicitTemporaryThread({ journalBindingKey: cap.journalBindingKey, updateIds },
|
|
3770
|
+
{ chatId: target.chatId, threadId: target.threadId }, randomBytes(16).toString("hex"), {
|
|
3771
|
+
...cap.authority, isCurrent: () => cap.isCurrent() && implicitThreadCreations.get(key) === implicit,
|
|
3772
|
+
});
|
|
3773
|
+
if (!found || !cap.isCurrent()) throw new Error("Implicit Telegram Thread registration was not acknowledged; source remains protected.");
|
|
3774
|
+
implicitThreadCreations.delete(key);
|
|
3775
|
+
}
|
|
3776
|
+
if (!found) return false;
|
|
3777
|
+
cancelTemporaryThreadCleanup(found.token);
|
|
3778
|
+
if (!deps.runWorkspaceOperation || !cap || cap.operatorUserId !== found.operatorUserId ||
|
|
3779
|
+
cap.journalBindingKey !== found.source.journalBindingKey || !updateIds.length || !cap.isCurrent()) {
|
|
3780
|
+
throw new Error("Temporary Thread input membership requires exact current source authority.");
|
|
3781
|
+
}
|
|
3782
|
+
// The caller already holds the profile Workspace admission (the non-reentrant gate): no nested operation here.
|
|
3783
|
+
let entry = store.listTemporaryThreads().find(value => value.token === found.token &&
|
|
3784
|
+
isDeepStrictEqual(value.source, found.source) && isDeepStrictEqual(value.target, target));
|
|
3785
|
+
if (!entry) throw new Error("Temporary Thread input target changed before membership publication.");
|
|
3786
|
+
entry = cap.adopt(entry);
|
|
3787
|
+
if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInput(entry, { journalBindingKey: cap.journalBindingKey, updateIds }, cap.authority) || !cap.isCurrent()) {
|
|
3788
|
+
throw new Error("Temporary Thread input membership was not acknowledged; the source remains protected.");
|
|
3789
|
+
}
|
|
3790
|
+
return true;
|
|
3791
|
+
};
|
|
2351
3792
|
const sendUnboundRerouteChooserNow = async (
|
|
2352
3793
|
messages: TMessage[],
|
|
2353
|
-
|
|
3794
|
+
ctx: TContext,
|
|
2354
3795
|
reportDeferred = true,
|
|
2355
3796
|
): Promise<void> => {
|
|
2356
3797
|
const message = messages[0];
|
|
@@ -2385,19 +3826,44 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2385
3826
|
);
|
|
2386
3827
|
return;
|
|
2387
3828
|
}
|
|
2388
|
-
const
|
|
3829
|
+
const command = messages.length === 1 ? getKnownTelegramAllTabCommand(Media.extractFirstTelegramMessageText(messages).trim()) : undefined;
|
|
3830
|
+
const rerouteId = storePendingUnboundReroute(messages, command ? "command" : "prompt");
|
|
2389
3831
|
if (reportDeferred) {
|
|
2390
3832
|
for (const source of messages) {
|
|
2391
3833
|
Updates.reportTelegramUpdateDeferred(source);
|
|
2392
3834
|
}
|
|
2393
3835
|
}
|
|
2394
|
-
const
|
|
2395
|
-
|
|
2396
|
-
|
|
3836
|
+
const inTemporaryThread = await recordUnboundTemporaryThreadInput(messages, ctx);
|
|
3837
|
+
const pending = pendingUnboundReroutes.get(rerouteId)!;
|
|
3838
|
+
if (inTemporaryThread) pending.temporaryMembership = true;
|
|
3839
|
+
if (messages.length === 1 && sourceTarget &&
|
|
3840
|
+
message.chat.type === "private" && typeof message.text === "string" && message.text.trim() &&
|
|
3841
|
+
deps.runWorkspaceOperation && deps.editInteractiveMessage &&
|
|
3842
|
+
deps.getAdmissionJournalBinding && deps.getCurrentLeaderEpoch &&
|
|
3843
|
+
Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true && deps.isContextActive?.(ctx) === true) {
|
|
3844
|
+
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
3845
|
+
const journalBindingKey = deps.getAdmissionJournalBinding();
|
|
3846
|
+
if (ownerUserId !== undefined && message.from?.id === ownerUserId && journalBindingKey &&
|
|
3847
|
+
Updates.supportsTelegramDeferredAbandonment(message, journalBindingKey)) {
|
|
3848
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch();
|
|
3849
|
+
if (leaderEpoch !== undefined) pending.abandonment = { ownerUserId, journalBindingKey, leaderEpoch };
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
if (command && sourceTarget && !inTemporaryThread && includeGuidance && deps.callApi) {
|
|
3853
|
+
// Telegram can create a private tab before delivering an All command not listed in the bot menu.
|
|
3854
|
+
// Naming that unbound tab is not creation evidence or authority to delete it.
|
|
3855
|
+
Updates.createTelegramUpdateExecutionFenceGuard(message)();
|
|
3856
|
+
try {
|
|
3857
|
+
await deps.callApi("editForumTopic", { chat_id: sourceTarget.chatId, message_thread_id: sourceTarget.threadId, name: `/${command.name}` });
|
|
3858
|
+
} catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-command-title" }); }
|
|
3859
|
+
Updates.createTelegramUpdateExecutionFenceGuard(message)();
|
|
3860
|
+
}
|
|
3861
|
+
const text = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
3862
|
+
pending.rootChooserText = text;
|
|
2397
3863
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
2398
3864
|
rerouteId,
|
|
2399
3865
|
activeRecords,
|
|
2400
|
-
{ canRestore: sourceTarget !== undefined, getDisplayTitle: deps.getDisplayTitle },
|
|
3866
|
+
{ canRestore: sourceTarget !== undefined, canCancel: !!pending.abandonment, getDisplayTitle: deps.getDisplayTitle },
|
|
2401
3867
|
);
|
|
2402
3868
|
if (deps.sendInteractiveMessage) {
|
|
2403
3869
|
const chooserId = await deps.sendInteractiveMessage(
|
|
@@ -2432,7 +3898,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2432
3898
|
const messages = [...(existing?.messages ?? []), message];
|
|
2433
3899
|
const timer = setTimeout(() => {
|
|
2434
3900
|
pendingUnboundRerouteMediaGroups.delete(groupKey);
|
|
2435
|
-
|
|
3901
|
+
// A media group's timer runs outside any request, so it takes the same profile admission as a single message.
|
|
3902
|
+
const send = (): Promise<void> => sendUnboundRerouteChooserNow(messages, ctx, false);
|
|
3903
|
+
const task = deps.runWorkspaceOperation
|
|
3904
|
+
? deps.runWorkspaceOperation({ operationId: `workspace-unbound-group:${groupKey}:${randomBytes(8).toString("hex")}`,
|
|
3905
|
+
operationKind: "workspace.route-unbound-thread", scopes: [{ kind: "profile" }] }, send)
|
|
3906
|
+
: send();
|
|
3907
|
+
void task.catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-media-group-chooser" }); });
|
|
2436
3908
|
}, 1200);
|
|
2437
3909
|
timer.unref?.();
|
|
2438
3910
|
pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
|
|
@@ -2454,6 +3926,83 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2454
3926
|
}
|
|
2455
3927
|
return undefined;
|
|
2456
3928
|
};
|
|
3929
|
+
/**
|
|
3930
|
+
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
3931
|
+
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
3932
|
+
*/
|
|
3933
|
+
const sendAllTabTemporaryInputChooser = async (
|
|
3934
|
+
command: Commands.ParsedTelegramCommand | undefined,
|
|
3935
|
+
commandText: string,
|
|
3936
|
+
message: TMessage,
|
|
3937
|
+
ctx: TContext,
|
|
3938
|
+
): Promise<boolean> => {
|
|
3939
|
+
const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
3940
|
+
const execution = Updates.getTelegramUpdateExecutionFence(message);
|
|
3941
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
|
|
3942
|
+
if (!cap || !deps.runWorkspaceOperation || !deps.callApi || updateId === undefined ||
|
|
3943
|
+
message.chat.type !== "private" || message.chat.id !== cap.operatorUserId || message.from?.id !== cap.operatorUserId ||
|
|
3944
|
+
!cap.isCurrent()) return false;
|
|
3945
|
+
const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
|
|
3946
|
+
const source = { journalBindingKey, updateId };
|
|
3947
|
+
const find = () => store.listTemporaryThreads().find(entry =>
|
|
3948
|
+
entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId);
|
|
3949
|
+
let entry: Threads.TelegramTemporaryThreadEntry | undefined;
|
|
3950
|
+
await deps.runWorkspaceOperation({ operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
|
|
3951
|
+
operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
|
|
3952
|
+
if (!current()) return;
|
|
3953
|
+
let found = find();
|
|
3954
|
+
found = found && cap.adopt(found);
|
|
3955
|
+
if (found) { entry = found; return; }
|
|
3956
|
+
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
|
|
3957
|
+
entry = reservation?.entry;
|
|
3958
|
+
if (!reservation?.reserved || !current()) return;
|
|
3959
|
+
let created: { message_thread_id?: unknown } | undefined;
|
|
3960
|
+
try {
|
|
3961
|
+
// The single creation attempt; any failure leaves `creating` as an unknown outcome, never a retry.
|
|
3962
|
+
created = await deps.callApi!<{ message_thread_id?: unknown }>("createForumTopic",
|
|
3963
|
+
{ chat_id: operatorUserId, name: command ? `/${command.name}` : "New chat" });
|
|
3964
|
+
} catch (error) {
|
|
3965
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-create" });
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3968
|
+
const threadId = created?.message_thread_id;
|
|
3969
|
+
if (!current() || typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return;
|
|
3970
|
+
entry = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority) ?? entry;
|
|
3971
|
+
});
|
|
3972
|
+
if (!current() || !entry) throw new Error("Temporary Thread authority changed; the input remains retryable.");
|
|
3973
|
+
const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
|
|
3974
|
+
...message, text: commandText, caption: undefined } as TMessage);
|
|
3975
|
+
if (entry.phase !== "created" || !entry.target) {
|
|
3976
|
+
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
3977
|
+
await deps.sendTextReply(message.chat.id, message.message_id,
|
|
3978
|
+
"<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.",
|
|
3979
|
+
{ parseMode: "HTML" });
|
|
3980
|
+
return true;
|
|
3981
|
+
}
|
|
3982
|
+
const target = entry.target;
|
|
3983
|
+
const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
|
|
3984
|
+
const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
|
|
3985
|
+
const pending = pendingUnboundReroutes.get(rerouteId)!;
|
|
3986
|
+
pending.temporaryThread = entry;
|
|
3987
|
+
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
3988
|
+
// Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
|
|
3989
|
+
if (deps.editInteractiveMessage && Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
|
|
3990
|
+
pending.abandonment = { ownerUserId: operatorUserId, journalBindingKey, leaderEpoch: epoch };
|
|
3991
|
+
}
|
|
3992
|
+
pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
3993
|
+
try {
|
|
3994
|
+
if (!deps.sendInteractiveMessage) throw new Error("Temporary Thread chooser publication is unavailable.");
|
|
3995
|
+
const chooserId = await deps.sendInteractiveMessage(target.chatId, pending.rootChooserText, "html",
|
|
3996
|
+
buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, { canRestore: true, canCancel: !!pending.abandonment,
|
|
3997
|
+
getDisplayTitle: deps.getDisplayTitle }),
|
|
3998
|
+
{ target });
|
|
3999
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
4000
|
+
} catch (error) {
|
|
4001
|
+
removePendingReroute(rerouteId);
|
|
4002
|
+
throw error;
|
|
4003
|
+
}
|
|
4004
|
+
return true;
|
|
4005
|
+
};
|
|
2457
4006
|
const sendAllTabCommandChooser = async (
|
|
2458
4007
|
command: Commands.ParsedTelegramCommand,
|
|
2459
4008
|
commandText: string,
|
|
@@ -2478,6 +4027,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2478
4027
|
const rerouteId = storePendingUnboundReroute([commandMessage], "command");
|
|
2479
4028
|
Updates.reportTelegramUpdateDeferred(commandMessage);
|
|
2480
4029
|
const text = formatTelegramAllTabMenuChooserText(command.name);
|
|
4030
|
+
pendingUnboundReroutes.get(rerouteId)!.rootChooserText = text;
|
|
2481
4031
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
2482
4032
|
rerouteId,
|
|
2483
4033
|
activeRecords,
|
|
@@ -2749,11 +4299,24 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2749
4299
|
assertExecutionCurrent();
|
|
2750
4300
|
await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
|
|
2751
4301
|
assertExecutionCurrent();
|
|
4302
|
+
const key = formatTelegramTargetKey(lifecycle.target);
|
|
2752
4303
|
if (lifecycle.kind !== "created" || !deps.threadStore) {
|
|
4304
|
+
implicitThreadCreations.delete(key);
|
|
2753
4305
|
return;
|
|
2754
4306
|
}
|
|
2755
4307
|
await deps.threadStore.load();
|
|
2756
4308
|
assertExecutionCurrent();
|
|
4309
|
+
const message = lifecycle.message, created = message.forum_topic_created;
|
|
4310
|
+
const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
4311
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true);
|
|
4312
|
+
if (created && typeof created === "object" && "is_name_implicit" in created && created.is_name_implicit === true &&
|
|
4313
|
+
cap && deps.runWorkspaceOperation && updateId !== undefined && !Updates.isTelegramHistoricalInput(message) &&
|
|
4314
|
+
message.chat.type === "private" && message.chat.id === cap.operatorUserId && message.from?.id === cap.operatorUserId &&
|
|
4315
|
+
message.from.is_bot === false && cap.isCurrent() && implicitThreadCreations.size < 100) {
|
|
4316
|
+
implicitThreadCreations.set(key, { ctx, updateId, operatorUserId: cap.operatorUserId,
|
|
4317
|
+
journalBindingKey: cap.journalBindingKey, executor: structuredClone(cap.authority.executor),
|
|
4318
|
+
generation: deps.getSessionGeneration?.(), scope: deps.getAdmissionScope?.() });
|
|
4319
|
+
} else implicitThreadCreations.delete(key);
|
|
2757
4320
|
};
|
|
2758
4321
|
// Answer the guest query immediately so the agent-end edit can replace the
|
|
2759
4322
|
// early ACK once the turn settles. The ACK is the first placeholder frame and
|
|
@@ -2795,7 +4358,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2795
4358
|
}
|
|
2796
4359
|
assertExecutionCurrent();
|
|
2797
4360
|
}
|
|
2798
|
-
|
|
4361
|
+
// Media messages carry the user's text as a caption.
|
|
4362
|
+
const text = guestMessage.text ?? guestMessage.caption ?? "";
|
|
2799
4363
|
const gm = guestMessage as unknown as Record<string, unknown>;
|
|
2800
4364
|
// Build telegram prefix with guest context
|
|
2801
4365
|
const chatRaw = gm.chat as Record<string, unknown>;
|
|
@@ -2842,10 +4406,15 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2842
4406
|
: "";
|
|
2843
4407
|
// Download files, run inbound handlers
|
|
2844
4408
|
const guestMsg = guestMessage as unknown as Media.TelegramMediaMessage;
|
|
4409
|
+
// Guest message IDs belong to the peer's chat; scope files to that peer so they cannot collide with bot-chat files.
|
|
4410
|
+
const guestScope = resolveTelegramGuestFileScope({ chatType, chat: chatRaw, from: fromRaw, replyFrom: replyFromRaw,
|
|
4411
|
+
guestBotCallerUser, guestBotCallerChat, ownerUserId });
|
|
4412
|
+
const downloadGuestFile: typeof deps.downloadFile = (fileId, fileName, source) =>
|
|
4413
|
+
deps.downloadFile(fileId, fileName, source ? { ...source, scope: guestScope } : source);
|
|
2845
4414
|
const replyFiles = guestMsg.reply_to_message
|
|
2846
4415
|
? await Media.downloadTelegramMessageFiles(
|
|
2847
4416
|
[guestMsg.reply_to_message as Media.TelegramMediaMessage],
|
|
2848
|
-
{ downloadFile:
|
|
4417
|
+
{ downloadFile: downloadGuestFile },
|
|
2849
4418
|
)
|
|
2850
4419
|
: [];
|
|
2851
4420
|
assertExecutionCurrent();
|
|
@@ -2855,7 +4424,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2855
4424
|
: undefined;
|
|
2856
4425
|
assertExecutionCurrent();
|
|
2857
4426
|
const files = await Media.downloadTelegramMessageFiles([guestMsg], {
|
|
2858
|
-
downloadFile:
|
|
4427
|
+
downloadFile: downloadGuestFile,
|
|
2859
4428
|
});
|
|
2860
4429
|
assertExecutionCurrent();
|
|
2861
4430
|
const processed = await deps.inboundHandlerRuntime.process(
|
|
@@ -2941,7 +4510,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2941
4510
|
deps.updateStatus(ctx);
|
|
2942
4511
|
requestDispatchNextQueuedTelegramTurn(ctx);
|
|
2943
4512
|
};
|
|
2944
|
-
|
|
4513
|
+
const runtime = Updates.createTelegramPairedUpdateRuntime<TContext, TUpdate>({
|
|
2945
4514
|
getAllowedUserId: deps.configStore.getAllowedUserId,
|
|
2946
4515
|
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
2947
4516
|
getMessageOwnership: deps.getMessageOwnership,
|
|
@@ -2992,8 +4561,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2992
4561
|
deps.getLiveThreadTargets?.(),
|
|
2993
4562
|
);
|
|
2994
4563
|
const command = getKnownTelegramAllTabCommand(text);
|
|
2995
|
-
|
|
2996
|
-
|
|
4564
|
+
const [sourceUpdateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
4565
|
+
const presented = sourceUpdateId !== undefined && deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().some(entry =>
|
|
4566
|
+
entry.source.updateId === sourceUpdateId && entry.source.journalBindingKey === deps.getAdmissionJournalBinding?.()) === true;
|
|
4567
|
+
// Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
|
|
4568
|
+
if (command && command.name !== "thread" && !presented && isTelegramAllTabCommandExpired(message)) return;
|
|
4569
|
+
if (command?.name !== "thread" && (bindings.length > 0 || presented) &&
|
|
4570
|
+
typeof message.text === "string" && message.text.trim() &&
|
|
4571
|
+
await sendAllTabTemporaryInputChooser(command, text, message as TMessage, ctx)) return;
|
|
2997
4572
|
if (bindings.length > 0 && command && command.name !== "thread") {
|
|
2998
4573
|
if (
|
|
2999
4574
|
await sendAllTabCommandChooser(command, text, message as TMessage, {
|
|
@@ -3058,6 +4633,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3058
4633
|
}
|
|
3059
4634
|
await deps.threadStore.load();
|
|
3060
4635
|
assertExecutionCurrent();
|
|
4636
|
+
if (Updates.isTelegramHistoricalInput(message, entry => reviewMessage(entry) !== undefined) && isReviewText(message as TMessage) &&
|
|
4637
|
+
deps.getCurrentLeaderEpoch?.() !== undefined && needsHistoricalReview(message as TMessage)) {
|
|
4638
|
+
if (!Updates.reportTelegramHistoricalRoutingReview(message)) throw new Error("Historical routing hold is unavailable.");
|
|
4639
|
+
return;
|
|
4640
|
+
}
|
|
3061
4641
|
if (deps.threadStore.getBotState().threadMode === "disabled") {
|
|
3062
4642
|
await textDispatch.handleMessage(message as TMessage, ctx);
|
|
3063
4643
|
return;
|
|
@@ -3181,14 +4761,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3181
4761
|
}
|
|
3182
4762
|
const command = getKnownTelegramAllTabCommand(text);
|
|
3183
4763
|
if (command && hasAnyRoutableThread) {
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
target: { chatId: target.chatId, threadId: target.threadId },
|
|
3187
|
-
replyToSource: true,
|
|
3188
|
-
})
|
|
3189
|
-
) {
|
|
3190
|
-
return;
|
|
3191
|
-
}
|
|
4764
|
+
await sendUnboundRerouteChooser(message as TMessage, ctx);
|
|
4765
|
+
return;
|
|
3192
4766
|
}
|
|
3193
4767
|
if (leaderProfileKey && deps.callApi) {
|
|
3194
4768
|
const currentLeaderRecord = records.find((record) => {
|
|
@@ -3303,7 +4877,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3303
4877
|
}
|
|
3304
4878
|
const identityThreadName =
|
|
3305
4879
|
priorLeaderIdentity?.threadName &&
|
|
3306
|
-
|
|
4880
|
+
ThreadNaming.isTelegramTopicThreadNameValidForSlot(
|
|
3307
4881
|
priorLeaderIdentity.threadName,
|
|
3308
4882
|
slot,
|
|
3309
4883
|
)
|
|
@@ -3312,7 +4886,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3312
4886
|
const threadName =
|
|
3313
4887
|
priorLeaderRecord?.threadName ??
|
|
3314
4888
|
identityThreadName ??
|
|
3315
|
-
|
|
4889
|
+
ThreadNaming.chooseTelegramThreadName({ slot }) ??
|
|
3316
4890
|
"Pi";
|
|
3317
4891
|
deps.threadStore.upsert({
|
|
3318
4892
|
profileKey: leaderProfileKey,
|
|
@@ -3367,6 +4941,12 @@ export function createTelegramInboundRouteRuntime<
|
|
|
3367
4941
|
);
|
|
3368
4942
|
},
|
|
3369
4943
|
});
|
|
4944
|
+
return { ...runtime, expireRoutingInput, shouldReviewHistoricalInput, shouldHoldPendingInput, forgetPreviousWorld, beforeQueueReceiptPublished, onQueueReceiptCommitted, onQueueReceiptCompleted, onUpdateCompleted,
|
|
4945
|
+
onWorkspaceRestoreRecipientObserved(follower, isCurrent, ctx) {
|
|
4946
|
+
if (ctx !== undefined) return observeRestoreSettlement({ kind: "recipient", follower, isCurrent }, ctx);
|
|
4947
|
+
return undefined;
|
|
4948
|
+
},
|
|
4949
|
+
async waitForRestoreSettlement() { await Promise.all([...restoreSettlementTasks]); } };
|
|
3370
4950
|
}
|
|
3371
4951
|
|
|
3372
4952
|
// --- Assistant Output Delivery Authority ---
|