@llblab/pi-telegram 0.52.2 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -7
- package/BACKLOG.md +1 -18
- package/CHANGELOG.md +15 -0
- package/README.md +7 -11
- package/api/commands.ts +3 -0
- package/dist/api/commands.d.ts +1 -1
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +15 -42
- package/dist/lib/activity.d.ts +9 -0
- package/dist/lib/activity.js +39 -10
- package/dist/lib/agent-messages.js +7 -3
- package/dist/lib/bindings.d.ts +43 -5
- package/dist/lib/bindings.js +163 -20
- package/dist/lib/bus-api.d.ts +12 -0
- package/dist/lib/bus-api.js +182 -79
- package/dist/lib/bus-follower.d.ts +111 -31
- package/dist/lib/bus-follower.js +1324 -359
- package/dist/lib/bus-leader.d.ts +41 -91
- package/dist/lib/bus-leader.js +969 -637
- package/dist/lib/bus-transport.js +16 -9
- package/dist/lib/bus.d.ts +215 -18
- package/dist/lib/bus.js +1048 -327
- package/dist/lib/channel-posts.d.ts +5 -0
- package/dist/lib/channel-posts.js +434 -261
- package/dist/lib/command-templates.d.ts +26 -5
- package/dist/lib/command-templates.js +92 -176
- package/dist/lib/commands.d.ts +168 -82
- package/dist/lib/commands.js +1409 -291
- package/dist/lib/config.d.ts +1 -8
- package/dist/lib/config.js +34 -76
- package/dist/lib/delivery.d.ts +2 -2
- package/dist/lib/delivery.js +16 -16
- package/dist/lib/extension.js +639 -171
- package/dist/lib/generative-app-worker.mjs +19 -7
- package/dist/lib/generative-apps.d.ts +0 -5
- package/dist/lib/generative-apps.js +61 -49
- package/dist/lib/inbound.d.ts +5 -0
- package/dist/lib/inbound.js +35 -59
- package/dist/lib/journal.d.ts +33 -3
- package/dist/lib/journal.js +2063 -782
- package/dist/lib/lifecycle.d.ts +2 -6
- package/dist/lib/lifecycle.js +75 -58
- package/dist/lib/locks.d.ts +29 -3
- package/dist/lib/locks.js +289 -108
- package/dist/lib/logging.js +50 -14
- package/dist/lib/media.d.ts +22 -1
- package/dist/lib/media.js +46 -28
- package/dist/lib/menu-model.d.ts +19 -5
- package/dist/lib/menu-model.js +53 -11
- package/dist/lib/menu-queue.d.ts +5 -4
- package/dist/lib/menu-queue.js +45 -63
- package/dist/lib/menu-settings.d.ts +5 -4
- package/dist/lib/menu-settings.js +114 -78
- package/dist/lib/menu-status.d.ts +1 -1
- package/dist/lib/menu-status.js +8 -25
- package/dist/lib/menu-thinking.d.ts +6 -1
- package/dist/lib/menu-thinking.js +14 -21
- package/dist/lib/menu.d.ts +8 -61
- package/dist/lib/menu.js +92 -164
- package/dist/lib/model.d.ts +1 -1
- package/dist/lib/model.js +2 -16
- package/dist/lib/outbound-attachments.d.ts +6 -27
- package/dist/lib/outbound-attachments.js +45 -38
- package/dist/lib/outbound-buttons.js +22 -16
- package/dist/lib/outbound-markup.d.ts +0 -2
- package/dist/lib/outbound-markup.js +51 -64
- package/dist/lib/outbound-voice.js +6 -3
- package/dist/lib/outbound.d.ts +6 -6
- package/dist/lib/outbound.js +65 -51
- package/dist/lib/ownership.js +6 -2
- package/dist/lib/paths.d.ts +5 -0
- package/dist/lib/paths.js +37 -21
- package/dist/lib/pi.d.ts +2 -0
- package/dist/lib/pi.js +8 -1
- package/dist/lib/polling.d.ts +21 -37
- package/dist/lib/polling.js +59 -50
- package/dist/lib/preview.js +39 -19
- package/dist/lib/process-identity.d.ts +24 -0
- package/dist/lib/process-identity.js +133 -0
- package/dist/lib/prompts.js +2 -7
- package/dist/lib/queue.d.ts +14 -16
- package/dist/lib/queue.js +252 -127
- package/dist/lib/recovery.d.ts +2 -83
- package/dist/lib/recovery.js +20 -274
- package/dist/lib/replies.d.ts +16 -24
- package/dist/lib/replies.js +99 -116
- package/dist/lib/routing.d.ts +120 -7
- package/dist/lib/routing.js +4667 -1152
- package/dist/lib/runtime.d.ts +1 -11
- package/dist/lib/sections.d.ts +4 -29
- package/dist/lib/sections.js +7 -20
- package/dist/lib/skills.js +4 -3
- package/dist/lib/status.js +86 -57
- package/dist/lib/sync.d.ts +1 -3
- package/dist/lib/sync.js +67 -43
- package/dist/lib/target.d.ts +4 -0
- package/dist/lib/target.js +18 -0
- package/dist/lib/telegram-api.d.ts +18 -13
- package/dist/lib/telegram-api.js +148 -53
- package/dist/lib/text-groups.js +4 -39
- package/dist/lib/thread-cleanup-manager.d.ts +6 -27
- package/dist/lib/thread-cleanup-manager.js +369 -186
- package/dist/lib/thread-display.d.ts +1 -2
- package/dist/lib/thread-display.js +58 -38
- package/dist/lib/thread-naming.d.ts +28 -8
- package/dist/lib/thread-naming.js +175 -66
- package/dist/lib/thread-reconciler.d.ts +42 -16
- package/dist/lib/thread-reconciler.js +90 -50
- package/dist/lib/threads.d.ts +82 -20
- package/dist/lib/threads.js +2247 -855
- package/dist/lib/time-injection.js +1 -2
- package/dist/lib/turns.js +1 -19
- package/dist/lib/updates.d.ts +169 -80
- package/dist/lib/updates.js +2418 -641
- package/dist/lib/voice.d.ts +7 -22
- package/dist/lib/voice.js +7 -22
- package/dist/lib/wire.js +1 -1
- package/dist/lib/workspace-admission.d.ts +3 -1
- package/dist/lib/workspace-admission.js +425 -211
- package/dist/lib/workspace-identity.js +8 -13
- package/dist/lib/workspace-retirement.d.ts +1 -1
- package/dist/lib/workspace-retirement.js +328 -140
- package/dist/lib/workspace-slots.js +8 -4
- package/dist/package.json +1 -1
- package/docs/architecture.md +31 -29
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +15 -3
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +29 -1
- package/docs/ui-style.md +25 -8
- package/lib/activity-verbosity.ts +59 -82
- package/lib/activity.ts +146 -102
- package/lib/agent-messages.ts +12 -5
- package/lib/bindings.ts +389 -82
- package/lib/bus-api.ts +263 -102
- package/lib/bus-follower.ts +2440 -601
- package/lib/bus-leader.ts +2209 -1290
- package/lib/bus-transport.ts +46 -12
- package/lib/bus.ts +1673 -444
- package/lib/channel-posts.ts +848 -327
- package/lib/command-templates.ts +152 -265
- package/lib/commands.ts +2398 -555
- package/lib/config.ts +177 -170
- package/lib/delivery.ts +41 -29
- package/lib/extension.ts +1206 -433
- package/lib/generative-app-worker.mjs +19 -7
- package/lib/generative-apps.ts +324 -211
- package/lib/inbound.ts +95 -97
- package/lib/journal.ts +5311 -1763
- package/lib/keyboard.ts +1 -3
- package/lib/lifecycle.ts +128 -78
- package/lib/locks.ts +677 -203
- package/lib/logging.ts +147 -58
- package/lib/media.ts +82 -43
- package/lib/menu-model.ts +85 -18
- package/lib/menu-queue.ts +88 -129
- package/lib/menu-settings.ts +260 -155
- package/lib/menu-status.ts +20 -60
- package/lib/menu-thinking.ts +41 -56
- package/lib/menu.ts +112 -285
- package/lib/model.ts +8 -22
- package/lib/outbound-attachments.ts +156 -125
- package/lib/outbound-buttons.ts +76 -49
- package/lib/outbound-markup.ts +96 -94
- package/lib/outbound-voice.ts +33 -27
- package/lib/outbound.ts +128 -125
- package/lib/ownership.ts +19 -10
- package/lib/paths.ts +148 -49
- package/lib/pi.ts +35 -15
- package/lib/polling.ts +144 -147
- package/lib/preview.ts +93 -40
- package/lib/process-identity.ts +164 -0
- package/lib/prompts.ts +4 -11
- package/lib/queue.ts +516 -301
- package/lib/recovery.ts +50 -454
- package/lib/rendering.ts +1 -3
- package/lib/replies.ts +248 -209
- package/lib/routing.ts +8509 -2186
- package/lib/runtime.ts +1 -12
- package/lib/sections.ts +24 -89
- package/lib/setup.ts +1 -3
- package/lib/skills.ts +9 -4
- package/lib/status.ts +215 -110
- package/lib/sync.ts +448 -361
- package/lib/target.ts +22 -3
- package/lib/telegram-api.ts +302 -127
- package/lib/text-groups.ts +5 -39
- package/lib/thread-cleanup-manager.ts +833 -331
- package/lib/thread-display.ts +213 -97
- package/lib/thread-naming.ts +232 -87
- package/lib/thread-reconciler.ts +155 -72
- package/lib/threads.ts +5318 -1845
- package/lib/time-injection.ts +1 -4
- package/lib/turns.ts +15 -28
- package/lib/updates.ts +5184 -1612
- package/lib/voice.ts +7 -35
- package/lib/wire.ts +1 -1
- package/lib/workspace-admission.ts +996 -416
- package/lib/workspace-identity.ts +14 -17
- package/lib/workspace-retirement.ts +878 -376
- package/lib/workspace-slots.ts +22 -13
- package/package.json +1 -1
- package/scripts/check-downgrade.mjs +0 -472
- package/scripts/measure-workspace.mjs +0 -108
package/lib/commands.ts
CHANGED
|
@@ -4,31 +4,149 @@
|
|
|
4
4
|
* Owns Telegram slash-command normalization, bot command metadata, pi-side command registration, and command-initiated session replacement orchestration behind runtime ports
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { addAbortListener } from "node:events";
|
|
9
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
10
|
|
|
11
|
+
import type { TelegramActivityPublicationWork } from "./activity.ts";
|
|
9
12
|
import {
|
|
10
13
|
pairTelegramUserIfNeeded,
|
|
11
|
-
type TelegramConfigStore,
|
|
12
14
|
TELEGRAM_DEFAULT_PROFILE_NAME,
|
|
15
|
+
type TelegramConfigStore,
|
|
13
16
|
} from "./config.ts";
|
|
14
17
|
import type * as Pi from "./pi.ts";
|
|
15
18
|
import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import type { TelegramSessionReplacementIntent } from "./threads.ts";
|
|
19
|
+
import { isPiStaleContextError } from "./pi.ts";
|
|
20
|
+
import type { TelegramPromptTemplateCommand } from "./prompt-templates.ts";
|
|
19
21
|
import {
|
|
20
22
|
createTelegramControlItemBuilder,
|
|
21
23
|
createTelegramControlQueueController,
|
|
22
24
|
createTelegramQueueAdmissionReceipt,
|
|
23
25
|
type PendingTelegramControlItem,
|
|
26
|
+
type TelegramControlQueueController,
|
|
27
|
+
type TelegramControlQueueControllerDeps,
|
|
24
28
|
type TelegramQueueAdmissionReceipt,
|
|
25
29
|
} from "./queue.ts";
|
|
30
|
+
import { escapeHtml } from "./rendering.ts";
|
|
31
|
+
import {
|
|
32
|
+
formatTelegramConnectionFailure,
|
|
33
|
+
type TelegramBridgeStatusLineOptions,
|
|
34
|
+
} from "./status.ts";
|
|
35
|
+
import {
|
|
36
|
+
TelegramApiAuthorityError,
|
|
37
|
+
type TelegramApiCallOptions,
|
|
38
|
+
} from "./telegram-api.ts";
|
|
39
|
+
import type { TelegramSessionReplacementIntent } from "./threads.ts";
|
|
40
|
+
import {
|
|
41
|
+
acquireTelegramUpdateRouting,
|
|
42
|
+
carryTelegramUpdateExecutionFence,
|
|
43
|
+
getTelegramUpdateExecutionFence,
|
|
44
|
+
inspectTelegramDeferredSource,
|
|
45
|
+
prepareTelegramDeferredQueueAdmission,
|
|
46
|
+
reportTelegramQueueAdmission,
|
|
47
|
+
type TelegramDeferredQueueAdmissionPreparation,
|
|
48
|
+
type TelegramDeferredSourceCompletionPreparation,
|
|
49
|
+
type TelegramDeferredSourceEvidence,
|
|
50
|
+
type TelegramLiveSourceCompletionReadiness,
|
|
51
|
+
type TelegramQueueAdmissionReceiptLike,
|
|
52
|
+
type TelegramUpdateWorkerRuntimeDeps,
|
|
53
|
+
} from "./updates.ts";
|
|
54
|
+
|
|
55
|
+
export type TelegramHeldCommandName =
|
|
56
|
+
"status" | "abort" | "stop" | "next" | "continue";
|
|
57
|
+
|
|
58
|
+
/** Commands-owned no-fold admission over the ordinary recipient queue; source reporting stays separate from removal. */
|
|
59
|
+
export interface TelegramHeldTurnAdmission {
|
|
60
|
+
assertCurrent(): void;
|
|
61
|
+
report(receipts: readonly TelegramQueueAdmissionReceiptLike[]): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TelegramPreparedHeldCommand {
|
|
65
|
+
readonly source: TelegramDeferredSourceEvidence;
|
|
66
|
+
readonly command: ParsedTelegramCommand;
|
|
67
|
+
bindCarrier(value: unknown): boolean;
|
|
68
|
+
execute(): Promise<boolean>;
|
|
69
|
+
/** Source disposal only, independent of detached delivery and ended source-execution/chooser callbacks. */
|
|
70
|
+
inspectCompletion(): TelegramDeferredSourceEvidence | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Saved-original admission for a held follower plan: fixed target plus independent source and recipient lifetimes. */
|
|
74
|
+
export interface TelegramHeldCommandAdmission {
|
|
75
|
+
target: { chatId: number; threadId: number };
|
|
76
|
+
assertSourceCurrent(): void;
|
|
77
|
+
assertRecipientCurrent(): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Recipient-scoped reply sender; the plan never falls back to ordinary follower API delivery. */
|
|
81
|
+
export interface TelegramHeldCommandReply {
|
|
82
|
+
sendTextReply(
|
|
83
|
+
chatId: number,
|
|
84
|
+
replyToMessageId: number,
|
|
85
|
+
text: string,
|
|
86
|
+
options: {
|
|
87
|
+
parseMode?: "HTML";
|
|
88
|
+
target: { chatId: number; threadId: number };
|
|
89
|
+
assertAuthority: () => void;
|
|
90
|
+
},
|
|
91
|
+
): Promise<unknown>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Captured recipient effects; a supplied but unavailable effect never falls back to ordinary dispatch. */
|
|
95
|
+
export type TelegramHeldCommandEffects<TContext> = Partial<
|
|
96
|
+
TelegramHeldCommandReply &
|
|
97
|
+
Pick<
|
|
98
|
+
TelegramCommandHandlerTargetRuntimeDeps<
|
|
99
|
+
TelegramCommandRuntimeMessage,
|
|
100
|
+
TContext
|
|
101
|
+
>,
|
|
102
|
+
"showStatus"
|
|
103
|
+
>
|
|
104
|
+
>;
|
|
26
105
|
|
|
27
106
|
export interface ParsedTelegramCommand {
|
|
28
107
|
name: string;
|
|
29
108
|
args: string;
|
|
30
109
|
}
|
|
31
110
|
|
|
111
|
+
/** Exact singleton operator-private text command original eligible for a held follower plan. */
|
|
112
|
+
export function isTelegramSelectedHeldOriginal(
|
|
113
|
+
update: unknown,
|
|
114
|
+
target: { chatId: number; threadId: number },
|
|
115
|
+
operator: number | undefined,
|
|
116
|
+
name: string,
|
|
117
|
+
): boolean {
|
|
118
|
+
if (
|
|
119
|
+
!update ||
|
|
120
|
+
typeof update !== "object" ||
|
|
121
|
+
Array.isArray(update) ||
|
|
122
|
+
Object.keys(update).some((key) => !["update_id", "message"].includes(key))
|
|
123
|
+
)
|
|
124
|
+
return false;
|
|
125
|
+
const value = Reflect.get(update, "message");
|
|
126
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
127
|
+
const text = Reflect.get(value, "text"),
|
|
128
|
+
chat = Reflect.get(value, "chat"),
|
|
129
|
+
from = Reflect.get(value, "from");
|
|
130
|
+
return (
|
|
131
|
+
typeof text === "string" &&
|
|
132
|
+
parseTelegramCommand(text)?.name === name &&
|
|
133
|
+
value.caption === undefined &&
|
|
134
|
+
value.media_group_id === undefined &&
|
|
135
|
+
chat?.type === "private" &&
|
|
136
|
+
from?.is_bot === false &&
|
|
137
|
+
from.id === operator &&
|
|
138
|
+
chat.id === operator &&
|
|
139
|
+
Number.isSafeInteger(value.message_id) &&
|
|
140
|
+
value.message_id > 0 &&
|
|
141
|
+
typeof operator === "number" &&
|
|
142
|
+
Number.isSafeInteger(operator) &&
|
|
143
|
+
operator > 0 &&
|
|
144
|
+
target.chatId === operator &&
|
|
145
|
+
Number.isSafeInteger(target.threadId) &&
|
|
146
|
+
target.threadId > 0
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
32
150
|
export interface TelegramBotCommandDefinition {
|
|
33
151
|
command: string;
|
|
34
152
|
description: string;
|
|
@@ -49,6 +167,27 @@ export interface TelegramExtensionCommandContext {
|
|
|
49
167
|
enqueuePrompt: (prompt: string) => Promise<void>;
|
|
50
168
|
}
|
|
51
169
|
|
|
170
|
+
export interface SelectedPreparationInput {
|
|
171
|
+
readonly name: string;
|
|
172
|
+
readonly args: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface SelectedCommandExecution {
|
|
176
|
+
/** Recheck before producer mutations and after awaits; this is a trusted contract, not a sandbox. */
|
|
177
|
+
assertCurrent(): void;
|
|
178
|
+
/** Acceptance of semantic completion only, never a source-removal ACK or delivery/cleanup proof. */
|
|
179
|
+
reportCompleted(): boolean;
|
|
180
|
+
/** Queue detached recipient text without waiting for delivery as semantic completion. */
|
|
181
|
+
reply(text: string): void;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type PreparedSelectedCommand =
|
|
185
|
+
| {
|
|
186
|
+
kind: "command-only";
|
|
187
|
+
execute(ctx: SelectedCommandExecution): void | Promise<void>;
|
|
188
|
+
}
|
|
189
|
+
| { kind: "generated-prompt"; prompt: string };
|
|
190
|
+
|
|
52
191
|
export interface TelegramExtensionCommandRegistration {
|
|
53
192
|
name: string;
|
|
54
193
|
description?: string;
|
|
@@ -56,6 +195,15 @@ export interface TelegramExtensionCommandRegistration {
|
|
|
56
195
|
showInMenu?: boolean;
|
|
57
196
|
emoji?: string;
|
|
58
197
|
handler: (ctx: TelegramExtensionCommandContext) => Promise<void> | void;
|
|
198
|
+
/** Trusted side-effect-free plan preparation for single-message leader selection; ordinary dispatch still uses handler. */
|
|
199
|
+
selected?: {
|
|
200
|
+
prepare(
|
|
201
|
+
input: SelectedPreparationInput,
|
|
202
|
+
):
|
|
203
|
+
| PreparedSelectedCommand
|
|
204
|
+
| undefined
|
|
205
|
+
| Promise<PreparedSelectedCommand | undefined>;
|
|
206
|
+
};
|
|
59
207
|
}
|
|
60
208
|
|
|
61
209
|
interface RegisteredTelegramExtensionCommand {
|
|
@@ -65,6 +213,7 @@ interface RegisteredTelegramExtensionCommand {
|
|
|
65
213
|
showInMenu: boolean;
|
|
66
214
|
emoji?: string;
|
|
67
215
|
handler: TelegramExtensionCommandRegistration["handler"];
|
|
216
|
+
selected?: Readonly<TelegramExtensionCommandRegistration["selected"]>;
|
|
68
217
|
}
|
|
69
218
|
|
|
70
219
|
interface TelegramExtensionCommandRegistry {
|
|
@@ -137,6 +286,9 @@ export function registerTelegramCommand(
|
|
|
137
286
|
showInMenu,
|
|
138
287
|
emoji,
|
|
139
288
|
handler: registration.handler,
|
|
289
|
+
selected: registration.selected
|
|
290
|
+
? Object.freeze({ prepare: registration.selected.prepare })
|
|
291
|
+
: undefined,
|
|
140
292
|
};
|
|
141
293
|
registry.commands.set(name, command);
|
|
142
294
|
return () => {
|
|
@@ -159,6 +311,76 @@ export function findTelegramExtensionCommand(
|
|
|
159
311
|
);
|
|
160
312
|
}
|
|
161
313
|
|
|
314
|
+
/** Preparation only: no execution, admission, source freeze or binding effects. Routing owns those later. */
|
|
315
|
+
export async function prepareTelegramSelectedExtensionCommand(
|
|
316
|
+
command: ParsedTelegramCommand,
|
|
317
|
+
authority: { assertSourceCurrent(): void; assertRecipientCurrent(): void },
|
|
318
|
+
): Promise<
|
|
319
|
+
| {
|
|
320
|
+
plan: Readonly<PreparedSelectedCommand>;
|
|
321
|
+
assertRegistrationCurrent(): void;
|
|
322
|
+
}
|
|
323
|
+
| undefined
|
|
324
|
+
> {
|
|
325
|
+
const registration = findTelegramExtensionCommand(command.name),
|
|
326
|
+
selected = registration?.selected;
|
|
327
|
+
const prepare = selected?.prepare;
|
|
328
|
+
if (!registration || !selected || typeof prepare !== "function")
|
|
329
|
+
return undefined;
|
|
330
|
+
const input = Object.freeze({ name: registration.name, args: command.args });
|
|
331
|
+
const { assertSourceCurrent, assertRecipientCurrent } = authority;
|
|
332
|
+
const assertRegistrationCurrent = () => {
|
|
333
|
+
if (
|
|
334
|
+
findTelegramExtensionCommand(input.name) !== registration ||
|
|
335
|
+
registration.selected !== selected ||
|
|
336
|
+
selected.prepare !== prepare
|
|
337
|
+
)
|
|
338
|
+
throw new Error("Selected extension command registration changed.");
|
|
339
|
+
};
|
|
340
|
+
const assertCurrent = () => {
|
|
341
|
+
assertSourceCurrent();
|
|
342
|
+
assertRecipientCurrent();
|
|
343
|
+
assertRegistrationCurrent();
|
|
344
|
+
};
|
|
345
|
+
try {
|
|
346
|
+
assertCurrent();
|
|
347
|
+
const prepared = await prepare(input);
|
|
348
|
+
assertCurrent();
|
|
349
|
+
if (!prepared || typeof prepared !== "object" || Array.isArray(prepared))
|
|
350
|
+
return undefined;
|
|
351
|
+
const keys = Reflect.ownKeys(prepared),
|
|
352
|
+
kind = prepared.kind;
|
|
353
|
+
let plan: PreparedSelectedCommand;
|
|
354
|
+
if (
|
|
355
|
+
kind === "command-only" &&
|
|
356
|
+
keys.length === 2 &&
|
|
357
|
+
keys.includes("kind") &&
|
|
358
|
+
keys.includes("execute")
|
|
359
|
+
) {
|
|
360
|
+
const execute = prepared.execute;
|
|
361
|
+
if (typeof execute !== "function") return undefined;
|
|
362
|
+
plan = { kind, execute };
|
|
363
|
+
} else if (
|
|
364
|
+
kind === "generated-prompt" &&
|
|
365
|
+
keys.length === 2 &&
|
|
366
|
+
keys.includes("kind") &&
|
|
367
|
+
keys.includes("prompt")
|
|
368
|
+
) {
|
|
369
|
+
const prompt = prepared.prompt;
|
|
370
|
+
if (typeof prompt !== "string" || !prompt.trim()) return undefined;
|
|
371
|
+
plan = { kind, prompt };
|
|
372
|
+
} else return undefined;
|
|
373
|
+
// Producer accessors can invalidate authority while the plan is being copied.
|
|
374
|
+
assertCurrent();
|
|
375
|
+
return Object.freeze({
|
|
376
|
+
plan: Object.freeze(plan),
|
|
377
|
+
assertRegistrationCurrent,
|
|
378
|
+
});
|
|
379
|
+
} catch {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
162
384
|
export function clearTelegramExtensionCommands(): void {
|
|
163
385
|
getOrCreateTelegramCommandRegistry().commands.clear();
|
|
164
386
|
}
|
|
@@ -181,9 +403,7 @@ export const TELEGRAM_COMMAND_EMOJI = {
|
|
|
181
403
|
|
|
182
404
|
export type TelegramCommandEmojiName = keyof typeof TELEGRAM_COMMAND_EMOJI;
|
|
183
405
|
|
|
184
|
-
function getTelegramCommandEmoji(
|
|
185
|
-
command: TelegramCommandEmojiName,
|
|
186
|
-
): string {
|
|
406
|
+
function getTelegramCommandEmoji(command: TelegramCommandEmojiName): string {
|
|
187
407
|
return TELEGRAM_COMMAND_EMOJI[command];
|
|
188
408
|
}
|
|
189
409
|
|
|
@@ -215,11 +435,11 @@ export function formatTelegramInvalidInstanceName(
|
|
|
215
435
|
.split(/;\s+|(?<=\.)\s+(?=[A-Z])/)
|
|
216
436
|
.map((item) => item.trim())
|
|
217
437
|
.filter(Boolean)
|
|
218
|
-
.map((item) => /[.!?]$/.test(item) ? item : `${item}.`)
|
|
438
|
+
.map((item) => (/[.!?]$/.test(item) ? item : `${item}.`))
|
|
219
439
|
.map((item) => item[0]!.toUpperCase() + item.slice(1));
|
|
220
440
|
return [
|
|
221
441
|
"<b>⚠️ Invalid Thread Display Name:</b>\n",
|
|
222
|
-
...items.map((item) =>
|
|
442
|
+
...items.map((item) => `<code>-</code> ${escapeHtml(item)}`),
|
|
223
443
|
].join("\n");
|
|
224
444
|
}
|
|
225
445
|
|
|
@@ -235,15 +455,15 @@ export function formatTelegramAutomaticThreadDisplayNameRestoredHeading(
|
|
|
235
455
|
return `<b>✅ Automatic Thread display name restored as <i>${escapeHtml(name)}</i>.</b>`;
|
|
236
456
|
}
|
|
237
457
|
|
|
238
|
-
const TELEGRAM_COMPACTION_STARTED_TEXT =
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
458
|
+
const TELEGRAM_COMPACTION_STARTED_TEXT = formatTelegramInformationHeading(
|
|
459
|
+
getTelegramCommandEmoji("compact"),
|
|
460
|
+
"Compaction started.",
|
|
461
|
+
);
|
|
462
|
+
const TELEGRAM_COMPACTION_COMPLETED_TEXT = formatTelegramInformationHeading(
|
|
463
|
+
"✅",
|
|
464
|
+
"Compaction completed.",
|
|
465
|
+
);
|
|
466
|
+
export const TELEGRAM_COMPACTION_STARTED_MARKDOWN = `**${formatTelegramCommandEmojiPrefix("compact")}Compaction started.**`;
|
|
247
467
|
export const TELEGRAM_COMPACTION_COMPLETED_MARKDOWN =
|
|
248
468
|
"**✅ Compaction completed.**";
|
|
249
469
|
|
|
@@ -254,55 +474,51 @@ function formatTelegramBotCommandDescription(
|
|
|
254
474
|
return `${formatTelegramCommandEmojiPrefix(command)}${description}`;
|
|
255
475
|
}
|
|
256
476
|
|
|
257
|
-
const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] =
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
"Abort Pi & Clear queue",
|
|
303
|
-
),
|
|
304
|
-
},
|
|
305
|
-
];
|
|
477
|
+
const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] = [
|
|
478
|
+
{
|
|
479
|
+
command: "start",
|
|
480
|
+
description: formatTelegramBotCommandDescription(
|
|
481
|
+
"start",
|
|
482
|
+
"Open menu / Pair bridge",
|
|
483
|
+
),
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
command: "compact",
|
|
487
|
+
description: formatTelegramBotCommandDescription(
|
|
488
|
+
"compact",
|
|
489
|
+
"Compact current session",
|
|
490
|
+
),
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
command: "new",
|
|
494
|
+
description: formatTelegramBotCommandDescription(
|
|
495
|
+
"new",
|
|
496
|
+
"Start a new session",
|
|
497
|
+
),
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
command: "continue",
|
|
501
|
+
description: formatTelegramBotCommandDescription(
|
|
502
|
+
"continue",
|
|
503
|
+
"Queue continue prompt",
|
|
504
|
+
),
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
command: "next",
|
|
508
|
+
description: formatTelegramBotCommandDescription("next", "Force next turn"),
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
command: "abort",
|
|
512
|
+
description: formatTelegramBotCommandDescription("abort", "Abort Pi"),
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
command: "stop",
|
|
516
|
+
description: formatTelegramBotCommandDescription(
|
|
517
|
+
"stop",
|
|
518
|
+
"Abort Pi & Clear queue",
|
|
519
|
+
),
|
|
520
|
+
},
|
|
521
|
+
];
|
|
306
522
|
|
|
307
523
|
export const TELEGRAM_BOT_COMMANDS = TELEGRAM_BUILTIN_BOT_COMMANDS;
|
|
308
524
|
|
|
@@ -325,25 +541,38 @@ export function getTelegramReservedCommandNames(): string[] {
|
|
|
325
541
|
export interface TelegramBotCommandRegistrationDeps {
|
|
326
542
|
setMyCommands: (
|
|
327
543
|
commands: readonly TelegramBotCommandDefinition[],
|
|
544
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
328
545
|
) => Promise<unknown>;
|
|
329
546
|
}
|
|
330
547
|
|
|
331
548
|
export async function registerTelegramBotCommands(
|
|
332
549
|
deps: TelegramBotCommandRegistrationDeps,
|
|
550
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
333
551
|
): Promise<void> {
|
|
552
|
+
const assertAuthority = options?.assertAuthority,
|
|
553
|
+
send = deps.setMyCommands;
|
|
554
|
+
const capturedOptions = assertAuthority ? { assertAuthority } : undefined;
|
|
555
|
+
const setMyCommands = async (
|
|
556
|
+
commands: readonly TelegramBotCommandDefinition[],
|
|
557
|
+
) => {
|
|
558
|
+
assertAuthority?.();
|
|
559
|
+
await send(commands, capturedOptions);
|
|
560
|
+
assertAuthority?.();
|
|
561
|
+
};
|
|
562
|
+
assertAuthority?.();
|
|
334
563
|
const extensionCommands = getVisibleTelegramExtensionBotCommands();
|
|
335
564
|
if (extensionCommands.length === 0) {
|
|
336
|
-
await
|
|
565
|
+
await setMyCommands(TELEGRAM_BOT_COMMANDS);
|
|
337
566
|
return;
|
|
338
567
|
}
|
|
339
568
|
const nextCommandIndex = TELEGRAM_BOT_COMMANDS.findIndex(
|
|
340
569
|
(command) => command.command === "next",
|
|
341
570
|
);
|
|
342
571
|
if (nextCommandIndex === -1) {
|
|
343
|
-
await
|
|
572
|
+
await setMyCommands([...TELEGRAM_BOT_COMMANDS, ...extensionCommands]);
|
|
344
573
|
return;
|
|
345
574
|
}
|
|
346
|
-
await
|
|
575
|
+
await setMyCommands([
|
|
347
576
|
...TELEGRAM_BOT_COMMANDS.slice(0, nextCommandIndex + 1),
|
|
348
577
|
...extensionCommands,
|
|
349
578
|
...TELEGRAM_BOT_COMMANDS.slice(nextCommandIndex + 1),
|
|
@@ -352,15 +581,32 @@ export async function registerTelegramBotCommands(
|
|
|
352
581
|
|
|
353
582
|
export function createTelegramBotCommandRegistrar(
|
|
354
583
|
deps: TelegramBotCommandRegistrationDeps,
|
|
355
|
-
): (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
584
|
+
): (
|
|
585
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
586
|
+
) => Promise<void> {
|
|
587
|
+
// Only an identical local lifetime may share an in-flight sync; unguarded calls remain separate.
|
|
588
|
+
const pending = new Map<
|
|
589
|
+
TelegramApiCallOptions["assertAuthority"],
|
|
590
|
+
Promise<void>
|
|
591
|
+
>();
|
|
592
|
+
return (options) => {
|
|
593
|
+
const assertAuthority = options?.assertAuthority;
|
|
594
|
+
try {
|
|
595
|
+
assertAuthority?.();
|
|
596
|
+
} catch (error) {
|
|
597
|
+
return Promise.reject(error);
|
|
598
|
+
}
|
|
599
|
+
const joined = pending.get(assertAuthority);
|
|
600
|
+
if (joined) return joined;
|
|
359
601
|
let request: Promise<void>;
|
|
360
|
-
request = registerTelegramBotCommands(
|
|
361
|
-
|
|
602
|
+
request = registerTelegramBotCommands(
|
|
603
|
+
deps,
|
|
604
|
+
assertAuthority ? { assertAuthority } : undefined,
|
|
605
|
+
).finally(() => {
|
|
606
|
+
if (pending.get(assertAuthority) === request)
|
|
607
|
+
pending.delete(assertAuthority);
|
|
362
608
|
});
|
|
363
|
-
pending
|
|
609
|
+
pending.set(assertAuthority, request);
|
|
364
610
|
return request;
|
|
365
611
|
};
|
|
366
612
|
}
|
|
@@ -377,13 +623,11 @@ export interface TelegramBridgeCommandStartPollingResult {
|
|
|
377
623
|
owner?: string;
|
|
378
624
|
}
|
|
379
625
|
|
|
380
|
-
export type TelegramPollingStartRecoveryResult =
|
|
381
|
-
| { kind: "unhandled" }
|
|
382
|
-
| { kind: "retry"; message: string }
|
|
383
|
-
| { kind: "blocked"; message: string };
|
|
384
|
-
|
|
385
626
|
export interface TelegramBridgeCommandRegistrationDeps {
|
|
386
|
-
promptForConfig: (
|
|
627
|
+
promptForConfig: (
|
|
628
|
+
ctx: ExtensionCommandContext,
|
|
629
|
+
profileName?: string,
|
|
630
|
+
) => Promise<void>;
|
|
387
631
|
getStatusLines: (options?: TelegramBridgeStatusLineOptions) => string[];
|
|
388
632
|
reloadConfig: () => Promise<void>;
|
|
389
633
|
hasBotToken: () => boolean;
|
|
@@ -396,9 +640,6 @@ export interface TelegramBridgeCommandRegistrationDeps {
|
|
|
396
640
|
| Promise<void | TelegramBridgeCommandStartPollingResult>
|
|
397
641
|
| TelegramBridgeCommandStartPollingResult;
|
|
398
642
|
stopPolling: () => Promise<void | string>;
|
|
399
|
-
recoverPollingStart?: (
|
|
400
|
-
error: unknown,
|
|
401
|
-
) => Promise<TelegramPollingStartRecoveryResult>;
|
|
402
643
|
recordConnectionEvent?: (error: unknown, phase: string) => void;
|
|
403
644
|
getDisconnectThreadName?: () => string | undefined;
|
|
404
645
|
queueAgentConnectionContext?: (connected: boolean) => void;
|
|
@@ -412,7 +653,10 @@ export interface TelegramBridgeCommandRegistrationDeps {
|
|
|
412
653
|
cancel(): void;
|
|
413
654
|
};
|
|
414
655
|
getProfileNames?: () => string[];
|
|
415
|
-
activateDefaultProfileConfig?: (
|
|
656
|
+
activateDefaultProfileConfig?: (
|
|
657
|
+
ctx: ExtensionCommandContext,
|
|
658
|
+
isCurrent: () => boolean,
|
|
659
|
+
) => Promise<void>;
|
|
416
660
|
activateProfileConfig?: (
|
|
417
661
|
ctx: ExtensionCommandContext,
|
|
418
662
|
profileName: string,
|
|
@@ -420,13 +664,17 @@ export interface TelegramBridgeCommandRegistrationDeps {
|
|
|
420
664
|
) => Promise<boolean>;
|
|
421
665
|
}
|
|
422
666
|
|
|
667
|
+
/** Bound adapters must carry the optional recipient guard through their own effect/publication awaits. */
|
|
423
668
|
export type TelegramThreadDisplayNameRenamePort = (
|
|
424
669
|
target: { chatId: number; threadId?: number },
|
|
425
670
|
threadName: string,
|
|
671
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
426
672
|
) => Promise<{ ok: boolean; threadName?: string; message?: string }>;
|
|
427
673
|
|
|
674
|
+
/** Reset adapters must preserve the same optional recipient lifetime as manual rename adapters. */
|
|
428
675
|
export type TelegramThreadDisplayNameResetPort = (
|
|
429
676
|
target: { chatId: number; threadId?: number },
|
|
677
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
430
678
|
) => Promise<{
|
|
431
679
|
ok: boolean;
|
|
432
680
|
threadName?: string;
|
|
@@ -437,13 +685,37 @@ export function createTelegramThreadDisplayNameResetBinding(): {
|
|
|
437
685
|
bind: (reset: TelegramThreadDisplayNameResetPort) => void;
|
|
438
686
|
reset: TelegramThreadDisplayNameResetPort;
|
|
439
687
|
} {
|
|
440
|
-
let current: TelegramThreadDisplayNameResetPort | undefined
|
|
688
|
+
let current: TelegramThreadDisplayNameResetPort | undefined,
|
|
689
|
+
generation = 0;
|
|
441
690
|
return {
|
|
442
|
-
bind(reset) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
691
|
+
bind(reset) {
|
|
692
|
+
current = reset;
|
|
693
|
+
generation++;
|
|
694
|
+
},
|
|
695
|
+
async reset(target, options) {
|
|
696
|
+
const reset = current,
|
|
697
|
+
assertAuthority = options?.assertAuthority,
|
|
698
|
+
capturedGeneration = generation;
|
|
699
|
+
const capturedTarget = {
|
|
700
|
+
chatId: target.chatId,
|
|
701
|
+
...(target.threadId === undefined ? {} : { threadId: target.threadId }),
|
|
702
|
+
};
|
|
703
|
+
if (!reset)
|
|
704
|
+
return {
|
|
705
|
+
ok: false,
|
|
706
|
+
message: "Thread display name reset is unavailable.",
|
|
707
|
+
};
|
|
708
|
+
const assertCurrent = () => {
|
|
709
|
+
assertAuthority?.();
|
|
710
|
+
if (assertAuthority && generation !== capturedGeneration)
|
|
711
|
+
throw new Error("Thread display name reset binding changed.");
|
|
712
|
+
};
|
|
713
|
+
assertCurrent();
|
|
714
|
+
const result = assertAuthority
|
|
715
|
+
? await reset(capturedTarget, { assertAuthority: assertCurrent })
|
|
716
|
+
: await reset(capturedTarget);
|
|
717
|
+
assertCurrent();
|
|
718
|
+
return result;
|
|
447
719
|
},
|
|
448
720
|
};
|
|
449
721
|
}
|
|
@@ -452,19 +724,40 @@ export function createTelegramThreadDisplayNameRenameBinding(): {
|
|
|
452
724
|
bind: (rename: TelegramThreadDisplayNameRenamePort) => void;
|
|
453
725
|
rename: TelegramThreadDisplayNameRenamePort;
|
|
454
726
|
} {
|
|
455
|
-
let current: TelegramThreadDisplayNameRenamePort | undefined
|
|
727
|
+
let current: TelegramThreadDisplayNameRenamePort | undefined,
|
|
728
|
+
generation = 0;
|
|
456
729
|
return {
|
|
457
730
|
bind(rename) {
|
|
458
731
|
current = rename;
|
|
732
|
+
generation++;
|
|
459
733
|
},
|
|
460
|
-
async rename(target, threadName) {
|
|
461
|
-
|
|
734
|
+
async rename(target, threadName, options) {
|
|
735
|
+
const rename = current,
|
|
736
|
+
assertAuthority = options?.assertAuthority,
|
|
737
|
+
capturedGeneration = generation;
|
|
738
|
+
const capturedTarget = {
|
|
739
|
+
chatId: target.chatId,
|
|
740
|
+
...(target.threadId === undefined ? {} : { threadId: target.threadId }),
|
|
741
|
+
};
|
|
742
|
+
if (!rename) {
|
|
462
743
|
return {
|
|
463
744
|
ok: false,
|
|
464
745
|
message: "Thread display naming is unavailable.",
|
|
465
746
|
};
|
|
466
747
|
}
|
|
467
|
-
|
|
748
|
+
const assertCurrent = () => {
|
|
749
|
+
assertAuthority?.();
|
|
750
|
+
if (assertAuthority && generation !== capturedGeneration)
|
|
751
|
+
throw new Error("Thread display naming binding changed.");
|
|
752
|
+
};
|
|
753
|
+
assertCurrent();
|
|
754
|
+
const result = assertAuthority
|
|
755
|
+
? await rename(capturedTarget, threadName, {
|
|
756
|
+
assertAuthority: assertCurrent,
|
|
757
|
+
})
|
|
758
|
+
: await rename(capturedTarget, threadName);
|
|
759
|
+
assertCurrent();
|
|
760
|
+
return result;
|
|
468
761
|
},
|
|
469
762
|
};
|
|
470
763
|
}
|
|
@@ -519,10 +812,16 @@ export function registerTelegramBridgeCommands(
|
|
|
519
812
|
// Pi context getters throw after replacement; check plain intent/generation first.
|
|
520
813
|
const isCurrent = () =>
|
|
521
814
|
(!intentId || deps.connectionIntent?.isActive(intentId) !== false) &&
|
|
522
|
-
(sessionGeneration === undefined ||
|
|
815
|
+
(sessionGeneration === undefined ||
|
|
816
|
+
deps.getSessionGeneration?.() === sessionGeneration) &&
|
|
523
817
|
deps.isContextCurrent?.(ctx) !== false;
|
|
524
818
|
if (!isCurrent()) return;
|
|
525
|
-
if (
|
|
819
|
+
if (
|
|
820
|
+
args
|
|
821
|
+
.trim()
|
|
822
|
+
.split(/\s+/)
|
|
823
|
+
.some((word) => /^as=/i.test(word))
|
|
824
|
+
) {
|
|
526
825
|
ctx.ui.notify(
|
|
527
826
|
"Thread names are configured from Telegram, not from Pi commands.",
|
|
528
827
|
"warning",
|
|
@@ -534,7 +833,11 @@ export function registerTelegramBridgeCommands(
|
|
|
534
833
|
intentId = deps.connectionIntent?.begin(ctx.cwd, profileName);
|
|
535
834
|
try {
|
|
536
835
|
if (profileName && deps.activateProfileConfig) {
|
|
537
|
-
const ok = await deps.activateProfileConfig(
|
|
836
|
+
const ok = await deps.activateProfileConfig(
|
|
837
|
+
ctx,
|
|
838
|
+
profileName,
|
|
839
|
+
isCurrent,
|
|
840
|
+
);
|
|
538
841
|
if (!isCurrent()) return;
|
|
539
842
|
if (!ok) {
|
|
540
843
|
ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
|
|
@@ -543,7 +846,8 @@ export function registerTelegramBridgeCommands(
|
|
|
543
846
|
}
|
|
544
847
|
ctx.ui.notify(`Activated profile "${profileName}".`, "info");
|
|
545
848
|
} else {
|
|
546
|
-
await (deps.activateDefaultProfileConfig?.(ctx, isCurrent) ??
|
|
849
|
+
await (deps.activateDefaultProfileConfig?.(ctx, isCurrent) ??
|
|
850
|
+
deps.reloadConfig());
|
|
547
851
|
if (!isCurrent()) return;
|
|
548
852
|
}
|
|
549
853
|
if (!deps.hasBotToken()) {
|
|
@@ -561,46 +865,17 @@ export function registerTelegramBridgeCommands(
|
|
|
561
865
|
await deps.promptForConfig(ctx, profileName);
|
|
562
866
|
return;
|
|
563
867
|
}
|
|
564
|
-
|
|
565
|
-
const startWithRecovery = async (
|
|
868
|
+
const startPolling = async (
|
|
566
869
|
options: TelegramBridgeCommandStartPollingOptions,
|
|
567
|
-
): Promise<void |
|
|
870
|
+
): Promise<void | TelegramBridgeCommandStartPollingResult> => {
|
|
568
871
|
try {
|
|
569
872
|
return await deps.startPolling(ctx, options);
|
|
570
873
|
} catch (error) {
|
|
571
874
|
if (!isCurrent()) return;
|
|
572
|
-
|
|
573
|
-
deps.recordConnectionEvent?.(error, "polling-start");
|
|
574
|
-
const recovery = await deps.recoverPollingStart(error);
|
|
575
|
-
if (!isCurrent()) return;
|
|
576
|
-
if (recovery.kind === "unhandled") throw error;
|
|
577
|
-
if (recovery.kind === "blocked") {
|
|
578
|
-
return { ok: false, message: recovery.message,
|
|
579
|
-
notice: "Telegram recovery blocked. Check /telegram-status --debug." };
|
|
580
|
-
}
|
|
581
|
-
recoveryUsed = true;
|
|
582
|
-
deps.recordConnectionEvent?.(recovery.message, "recovery");
|
|
583
|
-
try {
|
|
584
|
-
const retry = await deps.startPolling(ctx, options);
|
|
585
|
-
if (!isCurrent()) return;
|
|
586
|
-
if (!retry) return { ok: true, message: "Telegram bridge connected; temporary state recovered." };
|
|
587
|
-
return {
|
|
588
|
-
...retry,
|
|
589
|
-
message: retry.ok
|
|
590
|
-
? "Telegram bridge connected; temporary state recovered."
|
|
591
|
-
: retry.message,
|
|
592
|
-
};
|
|
593
|
-
} catch (error) {
|
|
594
|
-
if (!isCurrent()) return;
|
|
595
|
-
deps.recordConnectionEvent?.(error, "recovery-retry");
|
|
596
|
-
return {
|
|
597
|
-
ok: false,
|
|
598
|
-
notice: "Telegram recovery failed. Restart this Pi instance.",
|
|
599
|
-
};
|
|
600
|
-
}
|
|
875
|
+
throw error;
|
|
601
876
|
}
|
|
602
877
|
};
|
|
603
|
-
let result = await
|
|
878
|
+
let result = await startPolling({ forceFreshLeaderThread: true });
|
|
604
879
|
if (!isCurrent()) return;
|
|
605
880
|
if (result && !result.ok && result.canTakeover) {
|
|
606
881
|
const confirmed = await ctx.ui.confirm(
|
|
@@ -613,12 +888,19 @@ export function registerTelegramBridgeCommands(
|
|
|
613
888
|
deps.updateStatus(ctx);
|
|
614
889
|
return;
|
|
615
890
|
}
|
|
616
|
-
result = await
|
|
891
|
+
result = await startPolling({
|
|
892
|
+
force: true,
|
|
893
|
+
forceFreshLeaderThread: true,
|
|
894
|
+
});
|
|
617
895
|
if (!isCurrent()) return;
|
|
618
896
|
}
|
|
619
897
|
if (result && !result.ok) {
|
|
620
|
-
if (result.message)
|
|
621
|
-
|
|
898
|
+
if (result.message)
|
|
899
|
+
deps.recordConnectionEvent?.(result.message, "connect-refused");
|
|
900
|
+
ctx.ui.notify(
|
|
901
|
+
formatTelegramConnectionFailure(result.message),
|
|
902
|
+
"warning",
|
|
903
|
+
);
|
|
622
904
|
} else if (result?.message) {
|
|
623
905
|
ctx.ui.notify(result.message, "info");
|
|
624
906
|
}
|
|
@@ -639,7 +921,8 @@ export function registerTelegramBridgeCommands(
|
|
|
639
921
|
handler: async (_args, ctx) => {
|
|
640
922
|
const generation = deps.getSessionGeneration?.();
|
|
641
923
|
const isCurrent = () =>
|
|
642
|
-
(generation === undefined ||
|
|
924
|
+
(generation === undefined ||
|
|
925
|
+
deps.getSessionGeneration?.() === generation) &&
|
|
643
926
|
deps.isContextCurrent?.(ctx) !== false;
|
|
644
927
|
if (!isCurrent()) return;
|
|
645
928
|
deps.connectionIntent?.cancel();
|
|
@@ -663,7 +946,10 @@ export function registerTelegramBridgeCommands(
|
|
|
663
946
|
} catch (error) {
|
|
664
947
|
deps.recordConnectionEvent?.(error, "disconnect");
|
|
665
948
|
if (!isCurrent()) return;
|
|
666
|
-
ctx.ui.notify(
|
|
949
|
+
ctx.ui.notify(
|
|
950
|
+
"Telegram disconnect incomplete; keep Pi open. Check /telegram-status --debug.",
|
|
951
|
+
"warning",
|
|
952
|
+
);
|
|
667
953
|
} finally {
|
|
668
954
|
if (isCurrent()) deps.updateStatus(ctx);
|
|
669
955
|
}
|
|
@@ -724,8 +1010,6 @@ export type TelegramCommandAction =
|
|
|
724
1010
|
executionMode: "immediate";
|
|
725
1011
|
};
|
|
726
1012
|
|
|
727
|
-
export type TelegramCommandExecutionMode = "ignored" | "immediate";
|
|
728
|
-
|
|
729
1013
|
export interface TelegramCommandActionDeps<TMessage, TContext> {
|
|
730
1014
|
handleStop: (message: TMessage, ctx: TContext) => Promise<void>;
|
|
731
1015
|
handleName: (message: TMessage, ctx: TContext, name: string) => Promise<void>;
|
|
@@ -772,13 +1056,29 @@ export interface TelegramCompactConfirmationReplyMarkup {
|
|
|
772
1056
|
inline_keyboard: { text: string; callback_data: string }[][];
|
|
773
1057
|
}
|
|
774
1058
|
|
|
775
|
-
|
|
1059
|
+
/** Pi or Telegram work that must drain before a session-level command may replace or compact the session. */
|
|
1060
|
+
export interface TelegramSessionBusyPorts {
|
|
776
1061
|
isIdle: () => boolean;
|
|
777
1062
|
hasPendingMessages: () => boolean;
|
|
778
1063
|
hasActiveTelegramTurn: () => boolean;
|
|
779
1064
|
hasDispatchPending: () => boolean;
|
|
780
1065
|
hasQueuedTelegramItems: () => boolean;
|
|
781
1066
|
isCompactionInProgress: () => boolean;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function isTelegramSessionBusy(ports: TelegramSessionBusyPorts): boolean {
|
|
1070
|
+
return (
|
|
1071
|
+
!ports.isIdle() ||
|
|
1072
|
+
ports.hasPendingMessages() ||
|
|
1073
|
+
ports.hasActiveTelegramTurn() ||
|
|
1074
|
+
ports.hasDispatchPending() ||
|
|
1075
|
+
ports.hasQueuedTelegramItems() ||
|
|
1076
|
+
ports.isCompactionInProgress()
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export interface TelegramCompactCommandDeps
|
|
1081
|
+
extends TelegramRuntimeEventRecorderPort, TelegramSessionBusyPorts {
|
|
782
1082
|
setCompactionInProgress: (inProgress: boolean) => void;
|
|
783
1083
|
updateStatus: () => void;
|
|
784
1084
|
dispatchNextQueuedTelegramTurn: () => void;
|
|
@@ -804,7 +1104,10 @@ export interface TelegramCompactConfirmationDeps {
|
|
|
804
1104
|
text: string,
|
|
805
1105
|
mode: "markdown" | "html" | "plain",
|
|
806
1106
|
replyMarkup: TelegramCompactConfirmationReplyMarkup,
|
|
807
|
-
options?: {
|
|
1107
|
+
options?: {
|
|
1108
|
+
target?: { chatId: number; threadId?: number };
|
|
1109
|
+
assertAuthority?: TelegramApiCallOptions["assertAuthority"];
|
|
1110
|
+
},
|
|
808
1111
|
) => Promise<number | undefined>;
|
|
809
1112
|
}
|
|
810
1113
|
|
|
@@ -863,7 +1166,7 @@ export interface TelegramCommandRuntimeMessage {
|
|
|
863
1166
|
chat: { id: number; type?: string; title?: string };
|
|
864
1167
|
message_id: number;
|
|
865
1168
|
message_thread_id?: number;
|
|
866
|
-
from?: { id?: number };
|
|
1169
|
+
from?: { id?: number; is_bot?: boolean };
|
|
867
1170
|
pi_telegram_source_update_id?: number;
|
|
868
1171
|
}
|
|
869
1172
|
|
|
@@ -900,18 +1203,21 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
|
|
|
900
1203
|
replyToMessageId: number,
|
|
901
1204
|
ctx: TContext,
|
|
902
1205
|
threadId?: number,
|
|
1206
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
903
1207
|
) => Promise<void>;
|
|
904
1208
|
openModelMenu: (
|
|
905
1209
|
chatId: number,
|
|
906
1210
|
replyToMessageId: number,
|
|
907
1211
|
ctx: TContext,
|
|
908
1212
|
threadId?: number,
|
|
1213
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
909
1214
|
) => Promise<void>;
|
|
910
1215
|
openSettingsMenu?: (
|
|
911
1216
|
chatId: number,
|
|
912
1217
|
replyToMessageId: number,
|
|
913
1218
|
ctx: TContext,
|
|
914
1219
|
threadId?: number,
|
|
1220
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
915
1221
|
) => Promise<void>;
|
|
916
1222
|
sendTextReply: (
|
|
917
1223
|
chatId: number,
|
|
@@ -920,6 +1226,7 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
|
|
|
920
1226
|
options?: {
|
|
921
1227
|
parseMode?: "HTML";
|
|
922
1228
|
target?: { chatId: number; threadId?: number };
|
|
1229
|
+
assertAuthority?: TelegramApiCallOptions["assertAuthority"];
|
|
923
1230
|
},
|
|
924
1231
|
) => Promise<unknown>;
|
|
925
1232
|
}
|
|
@@ -935,13 +1242,28 @@ export interface TelegramCommandTargetRuntime<
|
|
|
935
1242
|
statusSummary: string,
|
|
936
1243
|
execute: (ctx: TContext) => Promise<void>,
|
|
937
1244
|
) => void;
|
|
938
|
-
showStatus: (
|
|
939
|
-
|
|
940
|
-
|
|
1245
|
+
showStatus: (
|
|
1246
|
+
message: TMessage,
|
|
1247
|
+
ctx: TContext,
|
|
1248
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1249
|
+
) => Promise<void>;
|
|
1250
|
+
openModelMenu: (
|
|
1251
|
+
message: TMessage,
|
|
1252
|
+
ctx: TContext,
|
|
1253
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1254
|
+
) => Promise<void>;
|
|
1255
|
+
openSettingsMenu: (
|
|
1256
|
+
message: TMessage,
|
|
1257
|
+
ctx: TContext,
|
|
1258
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1259
|
+
) => Promise<void>;
|
|
941
1260
|
sendTextReply: (
|
|
942
1261
|
message: TMessage,
|
|
943
1262
|
text: string,
|
|
944
|
-
options?: {
|
|
1263
|
+
options?: {
|
|
1264
|
+
parseMode?: "HTML";
|
|
1265
|
+
assertAuthority?: TelegramApiCallOptions["assertAuthority"];
|
|
1266
|
+
},
|
|
945
1267
|
) => Promise<void>;
|
|
946
1268
|
}
|
|
947
1269
|
|
|
@@ -958,51 +1280,11 @@ export function getTelegramCommandMessageTarget(
|
|
|
958
1280
|
};
|
|
959
1281
|
}
|
|
960
1282
|
|
|
961
|
-
export interface TelegramCommandControlQueueRuntimeDeps<TContext> {
|
|
962
|
-
createControlItem: (options: {
|
|
963
|
-
chatId: number;
|
|
964
|
-
target?: { chatId: number; threadId?: number };
|
|
965
|
-
replyToMessageId: number;
|
|
966
|
-
controlType: TelegramControlCommandType;
|
|
967
|
-
statusSummary: string;
|
|
968
|
-
admissionReceipts?: TelegramQueueAdmissionReceipt[];
|
|
969
|
-
execute: (ctx: TContext) => Promise<void>;
|
|
970
|
-
}) => PendingTelegramControlItem<TContext>;
|
|
971
|
-
appendControlItem: (
|
|
972
|
-
item: PendingTelegramControlItem<TContext>,
|
|
973
|
-
ctx: TContext,
|
|
974
|
-
) => void;
|
|
975
|
-
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
export function createTelegramCommandControlQueueRuntime<TContext>(
|
|
979
|
-
deps: TelegramCommandControlQueueRuntimeDeps<TContext>,
|
|
980
|
-
): TelegramCommandTargetRuntimeDeps<TContext>["enqueueControlItem"] {
|
|
981
|
-
const controlQueueController = createTelegramControlQueueController({
|
|
982
|
-
appendControlItem: deps.appendControlItem,
|
|
983
|
-
dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
|
|
984
|
-
});
|
|
985
|
-
return createTelegramCommandControlEnqueueAdapter({
|
|
986
|
-
createControlItem: deps.createControlItem,
|
|
987
|
-
enqueueControlItem: controlQueueController.enqueue,
|
|
988
|
-
});
|
|
989
|
-
}
|
|
990
|
-
|
|
991
1283
|
export function createTelegramCommandControlEnqueueAdapter<TContext>(deps: {
|
|
992
|
-
createControlItem:
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
controlType: TelegramControlCommandType;
|
|
997
|
-
statusSummary: string;
|
|
998
|
-
admissionReceipts?: TelegramQueueAdmissionReceipt[];
|
|
999
|
-
execute: (ctx: TContext) => Promise<void>;
|
|
1000
|
-
}) => PendingTelegramControlItem<TContext>;
|
|
1001
|
-
enqueueControlItem: (
|
|
1002
|
-
item: PendingTelegramControlItem<TContext>,
|
|
1003
|
-
ctx: TContext,
|
|
1004
|
-
onQueued?: (item: PendingTelegramControlItem<TContext>) => void,
|
|
1005
|
-
) => void;
|
|
1284
|
+
createControlItem: ReturnType<
|
|
1285
|
+
typeof createTelegramControlItemBuilder<TContext>
|
|
1286
|
+
>;
|
|
1287
|
+
enqueueControlItem: TelegramControlQueueController<TContext>["enqueue"];
|
|
1006
1288
|
}): TelegramCommandTargetRuntimeDeps<TContext>["enqueueControlItem"] {
|
|
1007
1289
|
return (
|
|
1008
1290
|
target,
|
|
@@ -1028,8 +1310,11 @@ export function createTelegramCommandControlEnqueueAdapter<TContext>(deps: {
|
|
|
1028
1310
|
}
|
|
1029
1311
|
|
|
1030
1312
|
export type TelegramCommandTargetQueueRuntimeDeps<TContext> =
|
|
1031
|
-
|
|
1032
|
-
|
|
1313
|
+
TelegramControlQueueControllerDeps<TContext> & {
|
|
1314
|
+
createControlItem: ReturnType<
|
|
1315
|
+
typeof createTelegramControlItemBuilder<TContext>
|
|
1316
|
+
>;
|
|
1317
|
+
} & Omit<TelegramCommandTargetRuntimeDeps<TContext>, "enqueueControlItem">;
|
|
1033
1318
|
|
|
1034
1319
|
export function createTelegramCommandTargetQueueRuntime<
|
|
1035
1320
|
TMessage extends TelegramCommandRuntimeMessage,
|
|
@@ -1037,11 +1322,19 @@ export function createTelegramCommandTargetQueueRuntime<
|
|
|
1037
1322
|
>(
|
|
1038
1323
|
deps: TelegramCommandTargetQueueRuntimeDeps<TContext>,
|
|
1039
1324
|
): TelegramCommandTargetRuntime<TMessage, TContext> {
|
|
1325
|
+
const {
|
|
1326
|
+
createControlItem,
|
|
1327
|
+
appendControlItem,
|
|
1328
|
+
dispatchNextQueuedTelegramTurn,
|
|
1329
|
+
} = deps;
|
|
1330
|
+
const controlQueueController = createTelegramControlQueueController({
|
|
1331
|
+
appendControlItem,
|
|
1332
|
+
dispatchNextQueuedTelegramTurn,
|
|
1333
|
+
});
|
|
1040
1334
|
return createTelegramCommandTargetRuntime({
|
|
1041
|
-
enqueueControlItem:
|
|
1042
|
-
createControlItem
|
|
1043
|
-
|
|
1044
|
-
dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
|
|
1335
|
+
enqueueControlItem: createTelegramCommandControlEnqueueAdapter({
|
|
1336
|
+
createControlItem,
|
|
1337
|
+
enqueueControlItem: controlQueueController.enqueue,
|
|
1045
1338
|
}),
|
|
1046
1339
|
getAdmissionScope: deps.getAdmissionScope,
|
|
1047
1340
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
@@ -1084,30 +1377,30 @@ export function createTelegramCommandTargetRuntime<
|
|
|
1084
1377
|
statusSummary,
|
|
1085
1378
|
execute,
|
|
1086
1379
|
receipt ? [receipt] : undefined,
|
|
1087
|
-
receipt
|
|
1088
|
-
? () => deps.onControlQueued?.(message, receipt)
|
|
1089
|
-
: undefined,
|
|
1380
|
+
receipt ? () => deps.onControlQueued?.(message, receipt) : undefined,
|
|
1090
1381
|
);
|
|
1091
1382
|
},
|
|
1092
|
-
showStatus: (message, ctx) => {
|
|
1383
|
+
showStatus: (message, ctx, options) => {
|
|
1093
1384
|
const target = getTelegramCommandMessageTarget(message);
|
|
1094
1385
|
return deps.showStatus(
|
|
1095
1386
|
target.chatId,
|
|
1096
1387
|
target.replyToMessageId,
|
|
1097
1388
|
ctx,
|
|
1098
1389
|
target.threadId,
|
|
1390
|
+
options,
|
|
1099
1391
|
);
|
|
1100
1392
|
},
|
|
1101
|
-
openModelMenu: (message, ctx) => {
|
|
1393
|
+
openModelMenu: (message, ctx, options) => {
|
|
1102
1394
|
const target = getTelegramCommandMessageTarget(message);
|
|
1103
1395
|
return deps.openModelMenu(
|
|
1104
1396
|
target.chatId,
|
|
1105
1397
|
target.replyToMessageId,
|
|
1106
1398
|
ctx,
|
|
1107
1399
|
target.threadId,
|
|
1400
|
+
options,
|
|
1108
1401
|
);
|
|
1109
1402
|
},
|
|
1110
|
-
openSettingsMenu: async (message, ctx) => {
|
|
1403
|
+
openSettingsMenu: async (message, ctx, options) => {
|
|
1111
1404
|
const target = getTelegramCommandMessageTarget(message);
|
|
1112
1405
|
if (!deps.openSettingsMenu) {
|
|
1113
1406
|
await deps.sendTextReply(
|
|
@@ -1126,19 +1419,22 @@ export function createTelegramCommandTargetRuntime<
|
|
|
1126
1419
|
target.replyToMessageId,
|
|
1127
1420
|
ctx,
|
|
1128
1421
|
target.threadId,
|
|
1422
|
+
options,
|
|
1129
1423
|
);
|
|
1130
1424
|
},
|
|
1131
1425
|
sendTextReply: async (message, text, options) => {
|
|
1132
1426
|
const target = getTelegramCommandMessageTarget(message);
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1427
|
+
const assertAuthority = options?.assertAuthority,
|
|
1428
|
+
send = deps.sendTextReply;
|
|
1429
|
+
const capturedOptions = { ...options, target };
|
|
1430
|
+
assertAuthority?.();
|
|
1431
|
+
await send(target.chatId, target.replyToMessageId, text, capturedOptions);
|
|
1432
|
+
assertAuthority?.();
|
|
1137
1433
|
},
|
|
1138
1434
|
};
|
|
1139
1435
|
}
|
|
1140
1436
|
|
|
1141
|
-
export interface
|
|
1437
|
+
export interface TelegramCommandOrPromptDispatcherDeps<TMessage, TContext> {
|
|
1142
1438
|
extractRawText: (messages: TMessage[]) => string;
|
|
1143
1439
|
shouldIgnoreMessages?: (messages: TMessage[]) => boolean;
|
|
1144
1440
|
consumeThreadNameInput?: (
|
|
@@ -1165,10 +1461,15 @@ export interface TelegramCommandOrPromptRuntimeDeps<TMessage, TContext> {
|
|
|
1165
1461
|
assertExecutionCurrent?: (message: TMessage) => void;
|
|
1166
1462
|
}
|
|
1167
1463
|
|
|
1464
|
+
interface TelegramCommandEffectWorkPort {
|
|
1465
|
+
beginCommandEffectWork?: () => TelegramActivityPublicationWork;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1168
1468
|
export interface TelegramCommandRuntimeDeps<
|
|
1169
1469
|
TMessage extends TelegramCommandRuntimeMessage,
|
|
1170
1470
|
TContext,
|
|
1171
|
-
>
|
|
1471
|
+
>
|
|
1472
|
+
extends TelegramRuntimeEventRecorderPort, TelegramCommandEffectWorkPort {
|
|
1172
1473
|
hasAbortHandler: () => boolean;
|
|
1173
1474
|
clearPendingModelSwitch: () => void;
|
|
1174
1475
|
hasQueuedTelegramItems: () => boolean;
|
|
@@ -1197,6 +1498,18 @@ export interface TelegramCommandRuntimeDeps<
|
|
|
1197
1498
|
) => void;
|
|
1198
1499
|
stopTypingLoop?: () => void;
|
|
1199
1500
|
enqueueContinueTurn: (message: TMessage, ctx: TContext) => Promise<void>;
|
|
1501
|
+
heldTurn?: {
|
|
1502
|
+
enqueue(
|
|
1503
|
+
message: TMessage,
|
|
1504
|
+
ctx: TContext,
|
|
1505
|
+
kind: "continue" | "prompt",
|
|
1506
|
+
admission: TelegramHeldTurnAdmission,
|
|
1507
|
+
): Promise<void>;
|
|
1508
|
+
templates?: {
|
|
1509
|
+
getCommands(): readonly TelegramPromptTemplateCommand[];
|
|
1510
|
+
expand(name: string, args: string): string | undefined;
|
|
1511
|
+
};
|
|
1512
|
+
};
|
|
1200
1513
|
requestNewSession?: (message: TMessage) => void;
|
|
1201
1514
|
compact: (
|
|
1202
1515
|
ctx: TContext,
|
|
@@ -1209,30 +1522,61 @@ export interface TelegramCommandRuntimeDeps<
|
|
|
1209
1522
|
statusSummary: string,
|
|
1210
1523
|
execute: (ctx: TContext) => Promise<void>,
|
|
1211
1524
|
) => void;
|
|
1212
|
-
showStatus: (
|
|
1525
|
+
showStatus: (
|
|
1526
|
+
message: TMessage,
|
|
1527
|
+
ctx: TContext,
|
|
1528
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1529
|
+
) => Promise<void>;
|
|
1530
|
+
/** Supplied adapters must forward recipient authority into issuance and recheck it after awaits. */
|
|
1213
1531
|
handleForumBootstrap?: (
|
|
1214
1532
|
message: TMessage,
|
|
1215
1533
|
ctx: TContext,
|
|
1534
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1216
1535
|
) => Promise<string | undefined>;
|
|
1217
|
-
openModelMenu: (
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1536
|
+
openModelMenu: (
|
|
1537
|
+
message: TMessage,
|
|
1538
|
+
ctx: TContext,
|
|
1539
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1540
|
+
) => Promise<void>;
|
|
1541
|
+
openThinkingMenu: (
|
|
1542
|
+
message: TMessage,
|
|
1543
|
+
ctx: TContext,
|
|
1544
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1545
|
+
) => Promise<void>;
|
|
1546
|
+
openQueueMenu: (
|
|
1547
|
+
message: TMessage,
|
|
1548
|
+
ctx: TContext,
|
|
1549
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1550
|
+
) => Promise<void>;
|
|
1551
|
+
openSettingsMenu?: (
|
|
1552
|
+
message: TMessage,
|
|
1553
|
+
ctx: TContext,
|
|
1554
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1555
|
+
) => Promise<void>;
|
|
1221
1556
|
validateThreadName?: (threadName: string) => string | undefined;
|
|
1222
1557
|
renameCurrentThread?: TelegramThreadDisplayNameRenamePort;
|
|
1223
1558
|
resetCurrentThreadName?: TelegramThreadDisplayNameResetPort;
|
|
1224
1559
|
openThreadNameDialog?: (
|
|
1225
1560
|
message: TMessage,
|
|
1226
1561
|
ctx: TContext,
|
|
1227
|
-
|
|
1562
|
+
admission?: {
|
|
1563
|
+
assertSemanticCurrent(): void;
|
|
1564
|
+
assertRecipientCurrent(): void;
|
|
1565
|
+
},
|
|
1566
|
+
) => Promise<void | { assertPublished(): void }>;
|
|
1228
1567
|
getAllowedUserId: () => number | undefined;
|
|
1229
1568
|
persistAllowedUserId: TelegramConfigStore["persistAllowedUserId"];
|
|
1230
|
-
registerBotCommands: (
|
|
1569
|
+
registerBotCommands: (
|
|
1570
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1571
|
+
) => Promise<void>;
|
|
1231
1572
|
getPromptTemplateCommands?: () => readonly TelegramPromptTemplateMenuCommand[];
|
|
1232
1573
|
sendTextReply: (
|
|
1233
1574
|
message: TMessage,
|
|
1234
1575
|
text: string,
|
|
1235
|
-
options?: {
|
|
1576
|
+
options?: {
|
|
1577
|
+
parseMode?: "HTML";
|
|
1578
|
+
assertAuthority?: TelegramApiCallOptions["assertAuthority"];
|
|
1579
|
+
},
|
|
1236
1580
|
) => Promise<void>;
|
|
1237
1581
|
getActiveTurnReply?: () =>
|
|
1238
1582
|
| ((text: string, options?: { parseMode?: "HTML" }) => Promise<void>)
|
|
@@ -1241,31 +1585,34 @@ export interface TelegramCommandRuntimeDeps<
|
|
|
1241
1585
|
assertExecutionCurrent?: (message: TMessage) => void;
|
|
1242
1586
|
}
|
|
1243
1587
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1588
|
+
/** Pure intro: built-in controls with registered extension lines before the abort controls. */
|
|
1589
|
+
function formatTelegramAppMenuIntroHtml(
|
|
1590
|
+
extensionLines: readonly string[],
|
|
1591
|
+
): string {
|
|
1592
|
+
const line = (name: TelegramCommandEmojiName, label: string) =>
|
|
1593
|
+
`${formatTelegramCommandEmojiPrefix(name)}/${name} — ${label}`;
|
|
1594
|
+
return [
|
|
1595
|
+
"<b>Pi Telegram</b>",
|
|
1596
|
+
"",
|
|
1597
|
+
line("start", "Open menu / Pair bridge"),
|
|
1598
|
+
line("compact", "Compact current session"),
|
|
1599
|
+
line("new", "Start a new session"),
|
|
1600
|
+
line("continue", "Queue continue prompt"),
|
|
1601
|
+
line("next", "Force next turn"),
|
|
1602
|
+
...extensionLines,
|
|
1603
|
+
line("abort", "Abort Pi"),
|
|
1604
|
+
line("stop", "Abort Pi & Clear queue"),
|
|
1605
|
+
].join("\n");
|
|
1261
1606
|
}
|
|
1262
1607
|
|
|
1608
|
+
export const TELEGRAM_APP_MENU_INTRO_HTML = formatTelegramAppMenuIntroHtml([]);
|
|
1609
|
+
|
|
1263
1610
|
function buildTelegramPromptTemplateMenuHtml(
|
|
1264
1611
|
promptTemplates: readonly TelegramPromptTemplateMenuCommand[] = [],
|
|
1265
1612
|
): string {
|
|
1266
1613
|
if (promptTemplates.length === 0) return "";
|
|
1267
1614
|
return promptTemplates
|
|
1268
|
-
.map((template) => `🧩 /${
|
|
1615
|
+
.map((template) => `🧩 /${escapeHtml(template.command)}`)
|
|
1269
1616
|
.join("\n");
|
|
1270
1617
|
}
|
|
1271
1618
|
|
|
@@ -1273,27 +1620,16 @@ function buildTelegramExtensionCommandMenuLines(): string[] {
|
|
|
1273
1620
|
return getTelegramExtensionCommands()
|
|
1274
1621
|
.filter((command) => command.showInMenu)
|
|
1275
1622
|
.map((command) => {
|
|
1276
|
-
const prefix = `${
|
|
1623
|
+
const prefix = `${escapeHtml(command.emoji ?? "")} /${escapeHtml(command.name)}`;
|
|
1277
1624
|
if (!command.description) return prefix;
|
|
1278
|
-
return `${prefix} — ${
|
|
1625
|
+
return `${prefix} — ${escapeHtml(command.description)}`;
|
|
1279
1626
|
});
|
|
1280
1627
|
}
|
|
1281
1628
|
|
|
1282
1629
|
function buildTelegramAppMenuIntroHtml(): string {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
"<b>Pi Telegram</b>",
|
|
1287
|
-
"",
|
|
1288
|
-
`${formatTelegramCommandEmojiPrefix("start")}/start — Open menu / Pair bridge`,
|
|
1289
|
-
`${formatTelegramCommandEmojiPrefix("compact")}/compact — Compact current session`,
|
|
1290
|
-
`${formatTelegramCommandEmojiPrefix("new")}/new — Start a new session`,
|
|
1291
|
-
`${formatTelegramCommandEmojiPrefix("continue")}/continue — Queue continue prompt`,
|
|
1292
|
-
`${formatTelegramCommandEmojiPrefix("next")}/next — Force next turn`,
|
|
1293
|
-
...extensionLines,
|
|
1294
|
-
`${formatTelegramCommandEmojiPrefix("abort")}/abort — Abort Pi`,
|
|
1295
|
-
`${formatTelegramCommandEmojiPrefix("stop")}/stop — Abort Pi & Clear queue`,
|
|
1296
|
-
].join("\n");
|
|
1630
|
+
return formatTelegramAppMenuIntroHtml(
|
|
1631
|
+
buildTelegramExtensionCommandMenuLines(),
|
|
1632
|
+
);
|
|
1297
1633
|
}
|
|
1298
1634
|
|
|
1299
1635
|
export function buildTelegramAppMenuHtml(
|
|
@@ -1379,12 +1715,6 @@ export function buildTelegramCommandAction(
|
|
|
1379
1715
|
return TELEGRAM_COMMAND_ACTIONS[commandName];
|
|
1380
1716
|
}
|
|
1381
1717
|
|
|
1382
|
-
export function getTelegramCommandExecutionMode(
|
|
1383
|
-
action: TelegramCommandAction,
|
|
1384
|
-
): TelegramCommandExecutionMode {
|
|
1385
|
-
return action.executionMode;
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
1718
|
function formatTelegramQueuedTurnCount(count: number): string {
|
|
1389
1719
|
return count === 1 ? "1 queued turn" : `${count} queued turns`;
|
|
1390
1720
|
}
|
|
@@ -1541,6 +1871,36 @@ function buildTelegramNewConfirmationReplyMarkup(): TelegramCompactConfirmationR
|
|
|
1541
1871
|
};
|
|
1542
1872
|
}
|
|
1543
1873
|
|
|
1874
|
+
/** Send one confirmation to an exact copied recipient, with authority checks around the issued request. */
|
|
1875
|
+
async function sendTelegramConfirmation(
|
|
1876
|
+
target: TelegramCommandMessageTarget,
|
|
1877
|
+
deps: TelegramCompactConfirmationDeps,
|
|
1878
|
+
html: string,
|
|
1879
|
+
markup: Parameters<
|
|
1880
|
+
TelegramCompactConfirmationDeps["sendInteractiveMessage"]
|
|
1881
|
+
>[3],
|
|
1882
|
+
assertAuthority?: TelegramApiCallOptions["assertAuthority"],
|
|
1883
|
+
): Promise<void> {
|
|
1884
|
+
const recipientTarget = {
|
|
1885
|
+
chatId: target.chatId,
|
|
1886
|
+
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
1887
|
+
};
|
|
1888
|
+
const options = assertAuthority
|
|
1889
|
+
? { target: recipientTarget, assertAuthority }
|
|
1890
|
+
: target.threadId !== undefined
|
|
1891
|
+
? { target: recipientTarget }
|
|
1892
|
+
: undefined;
|
|
1893
|
+
assertAuthority?.();
|
|
1894
|
+
await deps.sendInteractiveMessage(
|
|
1895
|
+
recipientTarget.chatId,
|
|
1896
|
+
html,
|
|
1897
|
+
"html",
|
|
1898
|
+
markup,
|
|
1899
|
+
options,
|
|
1900
|
+
);
|
|
1901
|
+
assertAuthority?.();
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1544
1904
|
function getTelegramNewConfirmationHtml(): string {
|
|
1545
1905
|
return "<b>Start a new session?</b>";
|
|
1546
1906
|
}
|
|
@@ -1548,15 +1908,14 @@ function getTelegramNewConfirmationHtml(): string {
|
|
|
1548
1908
|
export async function openTelegramNewConfirmation(
|
|
1549
1909
|
target: TelegramCommandMessageTarget,
|
|
1550
1910
|
deps: TelegramCompactConfirmationDeps,
|
|
1911
|
+
assertAuthority?: TelegramApiCallOptions["assertAuthority"],
|
|
1551
1912
|
): Promise<void> {
|
|
1552
|
-
await
|
|
1553
|
-
target
|
|
1913
|
+
await sendTelegramConfirmation(
|
|
1914
|
+
target,
|
|
1915
|
+
deps,
|
|
1554
1916
|
getTelegramNewConfirmationHtml(),
|
|
1555
|
-
"html",
|
|
1556
1917
|
buildTelegramNewConfirmationReplyMarkup(),
|
|
1557
|
-
|
|
1558
|
-
? { target: { chatId: target.chatId, threadId: target.threadId } }
|
|
1559
|
-
: undefined,
|
|
1918
|
+
assertAuthority,
|
|
1560
1919
|
);
|
|
1561
1920
|
}
|
|
1562
1921
|
|
|
@@ -1568,7 +1927,7 @@ export async function handleTelegramNewConfirmationCallback<TContext>(
|
|
|
1568
1927
|
const chatId = query.message?.chat?.id;
|
|
1569
1928
|
const messageId = query.message?.message_id;
|
|
1570
1929
|
if (typeof chatId !== "number" || typeof messageId !== "number") {
|
|
1571
|
-
await deps.answerCallbackQuery(query.id, "
|
|
1930
|
+
await deps.answerCallbackQuery(query.id, "Interactive message expired");
|
|
1572
1931
|
return true;
|
|
1573
1932
|
}
|
|
1574
1933
|
if (query.data === "new:cancel") {
|
|
@@ -1606,15 +1965,14 @@ function getTelegramCompactConfirmationHtml(): string {
|
|
|
1606
1965
|
async function openTelegramCompactConfirmation(
|
|
1607
1966
|
target: TelegramCommandMessageTarget,
|
|
1608
1967
|
deps: TelegramCompactConfirmationDeps,
|
|
1968
|
+
assertAuthority?: TelegramApiCallOptions["assertAuthority"],
|
|
1609
1969
|
): Promise<void> {
|
|
1610
|
-
await
|
|
1611
|
-
target
|
|
1970
|
+
await sendTelegramConfirmation(
|
|
1971
|
+
target,
|
|
1972
|
+
deps,
|
|
1612
1973
|
getTelegramCompactConfirmationHtml(),
|
|
1613
|
-
"html",
|
|
1614
1974
|
buildTelegramCompactConfirmationReplyMarkup(),
|
|
1615
|
-
|
|
1616
|
-
? { target: { chatId: target.chatId, threadId: target.threadId } }
|
|
1617
|
-
: undefined,
|
|
1975
|
+
assertAuthority,
|
|
1618
1976
|
);
|
|
1619
1977
|
}
|
|
1620
1978
|
|
|
@@ -1629,7 +1987,7 @@ export async function handleTelegramCompactConfirmationCallback<TContext>(
|
|
|
1629
1987
|
const chatId = callbackMessage?.chat?.id;
|
|
1630
1988
|
const messageId = callbackMessage?.message_id;
|
|
1631
1989
|
if (typeof chatId !== "number" || typeof messageId !== "number") {
|
|
1632
|
-
await deps.answerCallbackQuery(query.id, "
|
|
1990
|
+
await deps.answerCallbackQuery(query.id, "Interactive message expired");
|
|
1633
1991
|
return true;
|
|
1634
1992
|
}
|
|
1635
1993
|
if (query.data === "compact:cancel") {
|
|
@@ -1661,13 +2019,8 @@ export async function handleTelegramCompactConfirmationCallback<TContext>(
|
|
|
1661
2019
|
return true;
|
|
1662
2020
|
}
|
|
1663
2021
|
|
|
1664
|
-
export interface TelegramNewCommandDeps
|
|
1665
|
-
|
|
1666
|
-
hasPendingMessages: () => boolean;
|
|
1667
|
-
hasActiveTelegramTurn: () => boolean;
|
|
1668
|
-
hasDispatchPending: () => boolean;
|
|
1669
|
-
hasQueuedTelegramItems: () => boolean;
|
|
1670
|
-
isCompactionInProgress: () => boolean;
|
|
2022
|
+
export interface TelegramNewCommandDeps
|
|
2023
|
+
extends TelegramRuntimeEventRecorderPort, TelegramSessionBusyPorts {
|
|
1671
2024
|
requestNewSession?: () => void;
|
|
1672
2025
|
sendTextReply: (
|
|
1673
2026
|
text: string,
|
|
@@ -1678,14 +2031,7 @@ export interface TelegramNewCommandDeps extends TelegramRuntimeEventRecorderPort
|
|
|
1678
2031
|
export async function handleTelegramNewCommand(
|
|
1679
2032
|
deps: TelegramNewCommandDeps,
|
|
1680
2033
|
): Promise<void> {
|
|
1681
|
-
if (
|
|
1682
|
-
!deps.isIdle() ||
|
|
1683
|
-
deps.hasPendingMessages() ||
|
|
1684
|
-
deps.hasActiveTelegramTurn() ||
|
|
1685
|
-
deps.hasDispatchPending() ||
|
|
1686
|
-
deps.hasQueuedTelegramItems() ||
|
|
1687
|
-
deps.isCompactionInProgress()
|
|
1688
|
-
) {
|
|
2034
|
+
if (isTelegramSessionBusy(deps)) {
|
|
1689
2035
|
await deps.sendTextReply(
|
|
1690
2036
|
formatTelegramInformationHeading(
|
|
1691
2037
|
"⏳",
|
|
@@ -1711,14 +2057,7 @@ export async function handleTelegramNewCommand(
|
|
|
1711
2057
|
export async function handleTelegramCompactCommand(
|
|
1712
2058
|
deps: TelegramCompactCommandDeps,
|
|
1713
2059
|
): Promise<void> {
|
|
1714
|
-
if (
|
|
1715
|
-
!deps.isIdle() ||
|
|
1716
|
-
deps.hasPendingMessages() ||
|
|
1717
|
-
deps.hasActiveTelegramTurn() ||
|
|
1718
|
-
deps.hasDispatchPending() ||
|
|
1719
|
-
deps.hasQueuedTelegramItems() ||
|
|
1720
|
-
deps.isCompactionInProgress()
|
|
1721
|
-
) {
|
|
2060
|
+
if (isTelegramSessionBusy(deps)) {
|
|
1722
2061
|
await deps.sendTextReply(
|
|
1723
2062
|
formatTelegramInformationHeading(
|
|
1724
2063
|
"⏳",
|
|
@@ -1778,25 +2117,6 @@ export async function handleTelegramCompactCommand(
|
|
|
1778
2117
|
}
|
|
1779
2118
|
}
|
|
1780
2119
|
|
|
1781
|
-
function isTelegramStaleContextError(error: unknown): boolean {
|
|
1782
|
-
return (
|
|
1783
|
-
error instanceof Error &&
|
|
1784
|
-
(error.message.includes("stale after session") ||
|
|
1785
|
-
error.message.includes("stale ctx"))
|
|
1786
|
-
);
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
export async function handleTelegramStatusCommand<TContext>(deps: {
|
|
1790
|
-
ctx: TContext;
|
|
1791
|
-
showStatus: (ctx: TContext) => Promise<void>;
|
|
1792
|
-
}): Promise<void> {
|
|
1793
|
-
try {
|
|
1794
|
-
await deps.showStatus(deps.ctx);
|
|
1795
|
-
} catch (error) {
|
|
1796
|
-
if (!isTelegramStaleContextError(error)) throw error;
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
2120
|
export async function handleTelegramModelCommand<TContext>(deps: {
|
|
1801
2121
|
ctx: TContext;
|
|
1802
2122
|
openModelMenu: (ctx: TContext) => Promise<void>;
|
|
@@ -1804,7 +2124,7 @@ export async function handleTelegramModelCommand<TContext>(deps: {
|
|
|
1804
2124
|
try {
|
|
1805
2125
|
await deps.openModelMenu(deps.ctx);
|
|
1806
2126
|
} catch (error) {
|
|
1807
|
-
if (!
|
|
2127
|
+
if (!isPiStaleContextError(error)) throw error;
|
|
1808
2128
|
}
|
|
1809
2129
|
}
|
|
1810
2130
|
|
|
@@ -1886,12 +2206,7 @@ export function createTelegramCommandHandlerTargetRuntime<
|
|
|
1886
2206
|
TContext,
|
|
1887
2207
|
>(
|
|
1888
2208
|
deps: TelegramCommandHandlerTargetRuntimeDeps<TMessage, TContext>,
|
|
1889
|
-
):
|
|
1890
|
-
commandName: string | undefined,
|
|
1891
|
-
message: TMessage,
|
|
1892
|
-
ctx: TContext,
|
|
1893
|
-
commandArgs?: string,
|
|
1894
|
-
) => Promise<boolean> {
|
|
2209
|
+
): ReturnType<typeof createTelegramCommandHandler<TMessage, TContext>> {
|
|
1895
2210
|
const commandTargetRuntime = createTelegramCommandTargetQueueRuntime<
|
|
1896
2211
|
TMessage,
|
|
1897
2212
|
TContext
|
|
@@ -1911,6 +2226,7 @@ export function createTelegramCommandHandlerTargetRuntime<
|
|
|
1911
2226
|
sendTextReply: deps.sendTextReply,
|
|
1912
2227
|
});
|
|
1913
2228
|
return createTelegramCommandHandler({
|
|
2229
|
+
assertExecutionCurrent: deps.assertExecutionCurrent,
|
|
1914
2230
|
hasAbortHandler: deps.hasAbortHandler,
|
|
1915
2231
|
clearPendingModelSwitch: deps.clearPendingModelSwitch,
|
|
1916
2232
|
hasQueuedTelegramItems: deps.hasQueuedTelegramItems,
|
|
@@ -1925,15 +2241,16 @@ export function createTelegramCommandHandlerTargetRuntime<
|
|
|
1925
2241
|
setCompactionInProgress: deps.setCompactionInProgress,
|
|
1926
2242
|
updateStatus: deps.updateStatus,
|
|
1927
2243
|
isContextActive: deps.isContextActive,
|
|
2244
|
+
beginCommandEffectWork: deps.beginCommandEffectWork,
|
|
1928
2245
|
dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
|
|
1929
2246
|
requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
|
|
1930
2247
|
markActiveTurnNextAbortAnnouncement:
|
|
1931
2248
|
deps.markActiveTurnNextAbortAnnouncement,
|
|
1932
|
-
cancelNextTransitionAnnouncements:
|
|
1933
|
-
deps.cancelNextTransitionAnnouncements,
|
|
2249
|
+
cancelNextTransitionAnnouncements: deps.cancelNextTransitionAnnouncements,
|
|
1934
2250
|
startTypingLoop: deps.startTypingLoop,
|
|
1935
2251
|
stopTypingLoop: deps.stopTypingLoop,
|
|
1936
2252
|
enqueueContinueTurn: deps.enqueueContinueTurn,
|
|
2253
|
+
heldTurn: deps.heldTurn,
|
|
1937
2254
|
compact: deps.compact,
|
|
1938
2255
|
requestNewSession: deps.requestNewSession,
|
|
1939
2256
|
sendInteractiveMessage: deps.sendInteractiveMessage,
|
|
@@ -1958,74 +2275,1423 @@ export function createTelegramCommandHandlerTargetRuntime<
|
|
|
1958
2275
|
});
|
|
1959
2276
|
}
|
|
1960
2277
|
|
|
2278
|
+
/** Released exact-source admission for one selected command: source/recipient lifetimes and one completion report. */
|
|
2279
|
+
export interface TelegramSelectedCommandAdmission {
|
|
2280
|
+
assertSourceCurrent(): void;
|
|
2281
|
+
/** Independent exact recipient/target lifetime, including inside captured menu transport adapters. */
|
|
2282
|
+
assertRecipientCurrent(): void;
|
|
2283
|
+
reportCompleted(): boolean;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/**
|
|
2287
|
+
* Captures admission ports now and checks source → execution → recipient; `strict` rechecks the source after the
|
|
2288
|
+
* recipient, for owners whose effects outlive one synchronous step.
|
|
2289
|
+
*/
|
|
2290
|
+
function createSelectedSemanticGuard(
|
|
2291
|
+
admission: TelegramSelectedCommandAdmission,
|
|
2292
|
+
assertExecution: () => void,
|
|
2293
|
+
strict: boolean,
|
|
2294
|
+
): () => void {
|
|
2295
|
+
const assertSourceCurrent = admission.assertSourceCurrent,
|
|
2296
|
+
assertRecipientCurrent = admission.assertRecipientCurrent;
|
|
2297
|
+
return () => {
|
|
2298
|
+
assertSourceCurrent();
|
|
2299
|
+
assertExecution();
|
|
2300
|
+
assertRecipientCurrent();
|
|
2301
|
+
if (!strict) return;
|
|
2302
|
+
assertSourceCurrent();
|
|
2303
|
+
assertExecution();
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
/** Wrap one synchronous command effect between semantic checks. */
|
|
2308
|
+
function guardSelectedEffect(assertCurrent: () => void) {
|
|
2309
|
+
return <TArgs extends unknown[], TResult>(
|
|
2310
|
+
effect: (...args: TArgs) => TResult,
|
|
2311
|
+
) =>
|
|
2312
|
+
(...args: TArgs): TResult => {
|
|
2313
|
+
assertCurrent();
|
|
2314
|
+
const result = effect(...args);
|
|
2315
|
+
assertCurrent();
|
|
2316
|
+
return result;
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/** One issuance only: later calls return false without assertions or effects, never replay. */
|
|
2321
|
+
function issueSelectedOnce(
|
|
2322
|
+
assertCurrent: () => void,
|
|
2323
|
+
run: () => Promise<boolean>,
|
|
2324
|
+
): () => Promise<boolean> {
|
|
2325
|
+
let issued = false;
|
|
2326
|
+
return async () => {
|
|
2327
|
+
if (issued) return false;
|
|
2328
|
+
assertCurrent();
|
|
2329
|
+
issued = true;
|
|
2330
|
+
return run();
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/** Body-free reply anchor: chat, message and Thread only, never the original's admission symbols. */
|
|
2335
|
+
function copySelectedReplyAnchor<
|
|
2336
|
+
TMessage extends TelegramCommandRuntimeMessage,
|
|
2337
|
+
>(original: TMessage): TMessage {
|
|
2338
|
+
return {
|
|
2339
|
+
chat: { ...original.chat },
|
|
2340
|
+
message_id: original.message_id,
|
|
2341
|
+
message_thread_id: original.message_thread_id,
|
|
2342
|
+
} as TMessage;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function copySelectedMenuCarrier<
|
|
2346
|
+
TMessage extends TelegramCommandRuntimeMessage,
|
|
2347
|
+
>(original: TMessage): TMessage {
|
|
2348
|
+
const message = Object.fromEntries(
|
|
2349
|
+
Object.entries(original).filter(
|
|
2350
|
+
([key]) => key !== "pi_telegram_source_update_id",
|
|
2351
|
+
),
|
|
2352
|
+
) as TMessage;
|
|
2353
|
+
message.chat = { ...original.chat };
|
|
2354
|
+
return message;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
1961
2357
|
export function createTelegramCommandHandler<
|
|
1962
2358
|
TMessage extends TelegramCommandRuntimeMessage,
|
|
1963
2359
|
TContext,
|
|
1964
2360
|
>(deps: TelegramCommandRuntimeDeps<TMessage, TContext>) {
|
|
1965
|
-
|
|
2361
|
+
const handle = async (
|
|
1966
2362
|
commandName: string | undefined,
|
|
1967
2363
|
message: TMessage,
|
|
1968
2364
|
ctx: TContext,
|
|
1969
2365
|
commandArgs?: string,
|
|
1970
2366
|
): Promise<boolean> => {
|
|
1971
|
-
return handleTelegramCommandRuntime(
|
|
2367
|
+
return handleTelegramCommandRuntime(
|
|
2368
|
+
commandName,
|
|
2369
|
+
message,
|
|
2370
|
+
ctx,
|
|
2371
|
+
deps,
|
|
2372
|
+
commandArgs,
|
|
2373
|
+
);
|
|
1972
2374
|
};
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
2375
|
+
// Shared held-plan core: binds the exact saved carrier once and fences source, completion and owner ports.
|
|
2376
|
+
type HeldExecution = {
|
|
2377
|
+
message: TMessage;
|
|
2378
|
+
carrier?: TMessage;
|
|
2379
|
+
source?: TelegramDeferredSourceEvidence;
|
|
2380
|
+
queue?: TelegramDeferredQueueAdmissionPreparation;
|
|
2381
|
+
command: ParsedTelegramCommand;
|
|
2382
|
+
reportCompleted(): boolean;
|
|
2383
|
+
assertSemantic(): void;
|
|
2384
|
+
assertRecipient: () => void;
|
|
2385
|
+
};
|
|
2386
|
+
type HeldQueueRecord = {
|
|
2387
|
+
ctx: TContext;
|
|
2388
|
+
signal: AbortSignal;
|
|
2389
|
+
receipt: TelegramQueueAdmissionReceiptLike;
|
|
2390
|
+
queue: TelegramDeferredQueueAdmissionPreparation;
|
|
2391
|
+
completionSha256: string;
|
|
2392
|
+
current(): boolean;
|
|
2393
|
+
};
|
|
2394
|
+
const heldQueueReceipts = new Map<string, HeldQueueRecord | null>();
|
|
2395
|
+
const heldQueueOwners = new WeakSet<AbortSignal>();
|
|
2396
|
+
const watchHeldQueueOwner = (signal: AbortSignal) => {
|
|
2397
|
+
if (heldQueueOwners.has(signal)) return;
|
|
2398
|
+
addAbortListener(signal, () => {
|
|
2399
|
+
// Native owner end is irreversible; keep refusal identity, not its context/carrier closures or a fallback grant.
|
|
2400
|
+
for (const [id, record] of heldQueueReceipts)
|
|
2401
|
+
if (record?.signal === signal) heldQueueReceipts.set(id, null);
|
|
2402
|
+
});
|
|
2403
|
+
heldQueueOwners.add(signal);
|
|
2404
|
+
};
|
|
2405
|
+
type HeldPlan = {
|
|
2406
|
+
completion: "before-effect" | "after-semantics" | "queue-receipt";
|
|
2407
|
+
effect?: keyof TelegramHeldCommandEffects<TContext>;
|
|
2408
|
+
template?: true;
|
|
2409
|
+
available?(): boolean;
|
|
2410
|
+
prepare(
|
|
1981
2411
|
ctx: TContext,
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2412
|
+
effects?: TelegramHeldCommandEffects<TContext>,
|
|
2413
|
+
command?: ParsedTelegramCommand,
|
|
2414
|
+
replyDelivery?: "inline" | "detached",
|
|
2415
|
+
):
|
|
2416
|
+
| {
|
|
2417
|
+
captured: typeof deps;
|
|
2418
|
+
portsCurrent(): boolean;
|
|
2419
|
+
run(input: HeldExecution): Promise<boolean>;
|
|
2420
|
+
inspectQueuedCompletion?():
|
|
2421
|
+
TelegramDeferredSourceEvidence | undefined;
|
|
2422
|
+
}
|
|
2423
|
+
| undefined;
|
|
2424
|
+
};
|
|
2425
|
+
const prepareHeldSelectedCommand = (
|
|
2426
|
+
name: string,
|
|
2427
|
+
readiness: TelegramLiveSourceCompletionReadiness,
|
|
2428
|
+
admission: TelegramHeldCommandAdmission,
|
|
2429
|
+
captured: typeof deps,
|
|
2430
|
+
portsCurrent: () => boolean,
|
|
2431
|
+
completion: HeldPlan["completion"],
|
|
2432
|
+
run: (input: HeldExecution) => Promise<boolean>,
|
|
2433
|
+
inspectQueuedCompletion?: () => TelegramDeferredSourceEvidence | undefined,
|
|
2434
|
+
): TelegramPreparedHeldCommand | undefined => {
|
|
2435
|
+
const snapshot = readiness.snapshot,
|
|
2436
|
+
raw = snapshot.update.message,
|
|
2437
|
+
target = { ...admission.target };
|
|
2438
|
+
const bind = readiness.bindCarrier,
|
|
2439
|
+
isCurrent = readiness.isCurrent,
|
|
2440
|
+
assertSource = admission.assertSourceCurrent,
|
|
2441
|
+
assertRecipient = admission.assertRecipientCurrent;
|
|
2442
|
+
if (
|
|
2443
|
+
typeof bind !== "function" ||
|
|
2444
|
+
typeof isCurrent !== "function" ||
|
|
2445
|
+
typeof assertSource !== "function" ||
|
|
2446
|
+
typeof assertRecipient !== "function" ||
|
|
2447
|
+
!isCurrent.call(readiness) ||
|
|
2448
|
+
!raw ||
|
|
2449
|
+
typeof raw !== "object" ||
|
|
2450
|
+
Array.isArray(raw) ||
|
|
2451
|
+
Object.keys(snapshot.update).some(
|
|
2452
|
+
(key) => !["update_id", "message"].includes(key),
|
|
2453
|
+
)
|
|
2454
|
+
)
|
|
2455
|
+
return undefined;
|
|
2456
|
+
const value = raw as Record<string, unknown>,
|
|
2457
|
+
chat = value.chat as TMessage["chat"] | undefined,
|
|
2458
|
+
from = value.from as TMessage["from"] | undefined;
|
|
2459
|
+
if (typeof captured.getAllowedUserId !== "function") return undefined;
|
|
2460
|
+
const command =
|
|
2461
|
+
typeof value.text === "string"
|
|
2462
|
+
? parseTelegramCommand(value.text)
|
|
2463
|
+
: undefined,
|
|
2464
|
+
operator = captured.getAllowedUserId();
|
|
2465
|
+
if (
|
|
2466
|
+
!command ||
|
|
2467
|
+
!chat ||
|
|
2468
|
+
!from ||
|
|
2469
|
+
!isTelegramSelectedHeldOriginal(snapshot.update, target, operator, name)
|
|
2470
|
+
)
|
|
2471
|
+
return undefined;
|
|
2472
|
+
const source = { ...snapshot.source },
|
|
2473
|
+
preparedCommand = { ...command };
|
|
2474
|
+
const message = {
|
|
2475
|
+
...structuredClone(value),
|
|
2476
|
+
chat: { ...chat },
|
|
2477
|
+
message_thread_id: target.threadId,
|
|
2478
|
+
} as TMessage;
|
|
2479
|
+
delete message.pi_telegram_source_update_id;
|
|
2480
|
+
let bound:
|
|
2481
|
+
| {
|
|
2482
|
+
carrier: unknown;
|
|
2483
|
+
completion: TelegramDeferredSourceCompletionPreparation;
|
|
2484
|
+
ports: Pick<
|
|
2485
|
+
TelegramDeferredSourceCompletionPreparation,
|
|
2486
|
+
"isCurrent" | "reportCompleted" | "inspectCompletion"
|
|
2487
|
+
>;
|
|
2488
|
+
}
|
|
2489
|
+
| undefined,
|
|
2490
|
+
issued = false;
|
|
2491
|
+
const ownerCurrent = () =>
|
|
2492
|
+
portsCurrent() &&
|
|
2493
|
+
readiness.bindCarrier === bind &&
|
|
2494
|
+
readiness.isCurrent === isCurrent &&
|
|
2495
|
+
isCurrent.call(readiness) &&
|
|
2496
|
+
captured.getAllowedUserId() === operator;
|
|
2497
|
+
const completionCurrent = () =>
|
|
2498
|
+
!!bound &&
|
|
2499
|
+
Object.entries(bound.ports).every(
|
|
2500
|
+
([key, port]) => Reflect.get(bound!.completion, key) === port,
|
|
2501
|
+
) &&
|
|
2502
|
+
bound.ports.isCurrent.call(bound.completion);
|
|
2503
|
+
const assertSourceCurrent = () => {
|
|
2504
|
+
assertSource.call(admission);
|
|
2505
|
+
if (!ownerCurrent() || !completionCurrent() || !bound)
|
|
2506
|
+
throw new Error(`Held ${name} source owner is unavailable.`);
|
|
2507
|
+
captured.assertExecutionCurrent?.(bound.carrier as TMessage);
|
|
2508
|
+
const current = inspectTelegramDeferredSource(bound.carrier);
|
|
2509
|
+
if (
|
|
2510
|
+
!current ||
|
|
2511
|
+
current.updateId !== source.updateId ||
|
|
2512
|
+
current.journalBindingKey !== source.journalBindingKey ||
|
|
2513
|
+
current.sourceSha256 !== source.sourceSha256 ||
|
|
2514
|
+
!ownerCurrent() ||
|
|
2515
|
+
!completionCurrent()
|
|
2516
|
+
)
|
|
2517
|
+
throw new Error(`Held ${name} original changed.`);
|
|
2518
|
+
};
|
|
2519
|
+
return {
|
|
2520
|
+
get source() {
|
|
2521
|
+
return { ...source };
|
|
2522
|
+
},
|
|
2523
|
+
get command() {
|
|
2524
|
+
return { ...preparedCommand };
|
|
2525
|
+
},
|
|
2526
|
+
bindCarrier(value) {
|
|
2527
|
+
if (!ownerCurrent()) return false;
|
|
2528
|
+
if (bound) return value === bound.carrier && completionCurrent();
|
|
2529
|
+
const completion = bind.call(readiness, value);
|
|
2530
|
+
if (
|
|
2531
|
+
!completion ||
|
|
2532
|
+
completion.source.updateId !== source.updateId ||
|
|
2533
|
+
completion.source.journalBindingKey !== source.journalBindingKey ||
|
|
2534
|
+
completion.source.sourceSha256 !== source.sourceSha256 ||
|
|
2535
|
+
!ownerCurrent()
|
|
2536
|
+
)
|
|
2537
|
+
return false;
|
|
2538
|
+
const ports = {
|
|
2539
|
+
isCurrent: completion.isCurrent,
|
|
2540
|
+
reportCompleted: completion.reportCompleted,
|
|
2541
|
+
inspectCompletion: completion.inspectCompletion,
|
|
2542
|
+
};
|
|
2543
|
+
if (Object.values(ports).some((port) => typeof port !== "function"))
|
|
2544
|
+
return false;
|
|
2545
|
+
bound = { carrier: value, completion, ports };
|
|
2546
|
+
return completionCurrent();
|
|
2547
|
+
},
|
|
2548
|
+
async execute() {
|
|
2549
|
+
if (issued) return false;
|
|
2550
|
+
assertSourceCurrent();
|
|
2551
|
+
assertRecipient.call(admission);
|
|
2552
|
+
assertSourceCurrent();
|
|
2553
|
+
issued = true;
|
|
2554
|
+
if (
|
|
2555
|
+
completion === "before-effect" &&
|
|
2556
|
+
!bound!.ports.reportCompleted.call(bound!.completion)
|
|
2557
|
+
)
|
|
2558
|
+
return false;
|
|
2559
|
+
return run({
|
|
2560
|
+
message,
|
|
2561
|
+
carrier: bound!.carrier as TMessage,
|
|
2562
|
+
source: { ...source },
|
|
2563
|
+
command: { ...preparedCommand },
|
|
2564
|
+
assertRecipient,
|
|
2565
|
+
reportCompleted: () =>
|
|
2566
|
+
bound!.ports.reportCompleted.call(bound!.completion),
|
|
2567
|
+
assertSemantic() {
|
|
2568
|
+
assertSourceCurrent();
|
|
2569
|
+
assertRecipient.call(admission);
|
|
2570
|
+
},
|
|
2571
|
+
});
|
|
2572
|
+
},
|
|
2573
|
+
inspectCompletion() {
|
|
2574
|
+
return ownerCurrent() && completionCurrent()
|
|
2575
|
+
? completion === "queue-receipt"
|
|
2576
|
+
? inspectQueuedCompletion?.()
|
|
2577
|
+
: bound!.ports.inspectCompletion.call(bound!.completion)
|
|
2578
|
+
: undefined;
|
|
2579
|
+
},
|
|
2580
|
+
};
|
|
2581
|
+
};
|
|
2582
|
+
const prepareHeldReplyPlan = (
|
|
2583
|
+
ctx: TContext,
|
|
2584
|
+
effects: TelegramHeldCommandEffects<TContext>,
|
|
2585
|
+
run: (
|
|
2586
|
+
captured: typeof deps,
|
|
2587
|
+
semantic: ReturnType<typeof guardSelectedEffect>,
|
|
2588
|
+
complete: () => void,
|
|
2589
|
+
reply: (text: string, options?: { parseMode?: "HTML" }) => Promise<void>,
|
|
2590
|
+
) => Promise<void>,
|
|
2591
|
+
replyDelivery: "inline" | "detached" = "detached",
|
|
2592
|
+
) => {
|
|
2593
|
+
const send = effects.sendTextReply!,
|
|
2594
|
+
captured = { ...deps };
|
|
2595
|
+
return {
|
|
2596
|
+
captured,
|
|
2597
|
+
portsCurrent: () => effects.sendTextReply === send,
|
|
2598
|
+
async run(input: HeldExecution) {
|
|
2599
|
+
const semantic = guardSelectedEffect(input.assertSemantic);
|
|
2600
|
+
const complete = () => {
|
|
2601
|
+
input.assertSemantic();
|
|
2602
|
+
if (!input.reportCompleted())
|
|
2603
|
+
throw new Error(
|
|
2604
|
+
`Held ${input.command.name} source completion refused.`,
|
|
2605
|
+
);
|
|
2606
|
+
input.assertRecipient();
|
|
2607
|
+
};
|
|
2608
|
+
await run(captured, semantic, complete, async (text, options) => {
|
|
2609
|
+
complete();
|
|
2610
|
+
const deliver = async () => {
|
|
2611
|
+
input.assertRecipient();
|
|
2612
|
+
await send.call(
|
|
2613
|
+
effects,
|
|
2614
|
+
input.message.chat.id,
|
|
2615
|
+
input.message.message_id,
|
|
2616
|
+
text,
|
|
2617
|
+
{
|
|
2618
|
+
...options,
|
|
2619
|
+
target: {
|
|
2620
|
+
chatId: input.message.chat.id,
|
|
2621
|
+
threadId: input.message.message_thread_id!,
|
|
2622
|
+
},
|
|
2623
|
+
assertAuthority: input.assertRecipient,
|
|
2624
|
+
},
|
|
2625
|
+
);
|
|
2626
|
+
input.assertRecipient();
|
|
2627
|
+
};
|
|
2628
|
+
// Transport waiting never changes the already reported semantic completion or licenses another effect.
|
|
2629
|
+
if (replyDelivery === "inline") await deliver();
|
|
2630
|
+
else
|
|
2631
|
+
scheduleTelegramCommandEffect(
|
|
2632
|
+
ctx,
|
|
2633
|
+
input.command.name,
|
|
2634
|
+
"selected-reply",
|
|
2635
|
+
captured,
|
|
2636
|
+
deliver,
|
|
2637
|
+
input.assertRecipient,
|
|
2638
|
+
);
|
|
2639
|
+
});
|
|
2640
|
+
return true;
|
|
2641
|
+
},
|
|
2642
|
+
};
|
|
2643
|
+
};
|
|
2644
|
+
const prepareHeldQueuePlan = (
|
|
2645
|
+
ctx: TContext,
|
|
2646
|
+
template?: {
|
|
2647
|
+
command: ParsedTelegramCommand;
|
|
2648
|
+
expanded: string;
|
|
2649
|
+
isCurrent(): boolean;
|
|
2650
|
+
},
|
|
2651
|
+
) => {
|
|
2652
|
+
const captured = { ...deps },
|
|
2653
|
+
owner = captured.heldTurn!,
|
|
2654
|
+
enqueue = owner.enqueue;
|
|
2655
|
+
let retained: HeldQueueRecord | undefined;
|
|
2656
|
+
const portsCurrent = () =>
|
|
2657
|
+
deps.heldTurn === owner &&
|
|
2658
|
+
owner.enqueue === enqueue &&
|
|
2659
|
+
(!template || template.isCurrent()) &&
|
|
2660
|
+
deps.heldTurn === owner &&
|
|
2661
|
+
owner.enqueue === enqueue;
|
|
2662
|
+
return {
|
|
2663
|
+
captured,
|
|
2664
|
+
portsCurrent,
|
|
2665
|
+
async run(input: HeldExecution) {
|
|
2666
|
+
input.assertSemantic();
|
|
2667
|
+
if (template && !isDeepStrictEqual(input.command, template.command))
|
|
2668
|
+
return false;
|
|
2669
|
+
const { carrier, source } = input;
|
|
2670
|
+
if (!carrier || !source) return false;
|
|
2671
|
+
const signal = getTelegramUpdateExecutionFence(carrier)?.signal;
|
|
2672
|
+
if (!signal || signal.aborted) return false;
|
|
2673
|
+
const queue =
|
|
2674
|
+
input.queue ?? prepareTelegramDeferredQueueAdmission(carrier);
|
|
2675
|
+
if (!queue || !queue.isCurrent()) return false;
|
|
2676
|
+
const ports = {
|
|
2677
|
+
isCurrent: queue.isCurrent,
|
|
2678
|
+
prepareCompletionScope: queue.prepareCompletionScope,
|
|
2679
|
+
inspectCompletion: queue.inspectCompletion,
|
|
2680
|
+
};
|
|
2681
|
+
if (Object.values(ports).some((port) => typeof port !== "function"))
|
|
2682
|
+
return false;
|
|
2683
|
+
const current = () => {
|
|
2684
|
+
if (
|
|
2685
|
+
!portsCurrent() ||
|
|
2686
|
+
!Object.entries(ports).every(
|
|
2687
|
+
([key, value]) => Reflect.get(queue, key) === value,
|
|
2688
|
+
) ||
|
|
2689
|
+
!ports.isCurrent.call(queue)
|
|
2690
|
+
)
|
|
2691
|
+
return false;
|
|
2692
|
+
try {
|
|
2693
|
+
input.assertRecipient();
|
|
2694
|
+
return portsCurrent() && ports.isCurrent.call(queue);
|
|
2695
|
+
} catch {
|
|
2696
|
+
return false;
|
|
2697
|
+
}
|
|
2698
|
+
};
|
|
2699
|
+
const sameTarget =
|
|
2700
|
+
carrier.message_thread_id === input.message.message_thread_id;
|
|
2701
|
+
const message = carryTelegramUpdateExecutionFence(carrier, {
|
|
2702
|
+
...carrier,
|
|
2703
|
+
...input.message,
|
|
2704
|
+
pi_telegram_source_update_id: source.updateId,
|
|
2705
|
+
...(template ? { text: template.expanded, caption: undefined } : {}),
|
|
2706
|
+
...(sameTarget ? {} : { message_id: 0, reply_to_message: undefined }),
|
|
2707
|
+
} as TMessage);
|
|
2708
|
+
const completionSha256 = createHash("sha256")
|
|
2709
|
+
.update(
|
|
2710
|
+
JSON.stringify([
|
|
2711
|
+
"held-command-v1",
|
|
2712
|
+
randomUUID(),
|
|
2713
|
+
source,
|
|
2714
|
+
input.command,
|
|
2715
|
+
getTelegramCommandMessageTarget(message),
|
|
2716
|
+
]),
|
|
2717
|
+
)
|
|
2718
|
+
.digest("hex");
|
|
2719
|
+
const admission: TelegramHeldTurnAdmission = {
|
|
2720
|
+
assertCurrent: input.assertSemantic,
|
|
2721
|
+
report(receipts) {
|
|
2722
|
+
input.assertSemantic();
|
|
2723
|
+
const receipt = receipts[0];
|
|
2724
|
+
if (
|
|
2725
|
+
retained ||
|
|
2726
|
+
receipts.length !== 1 ||
|
|
2727
|
+
!receipt ||
|
|
2728
|
+
receipt.queueKind !== "prompt" ||
|
|
2729
|
+
receipt.journalBindingKey !== source.journalBindingKey ||
|
|
2730
|
+
receipt.sourceUpdateIds.length !== 1 ||
|
|
2731
|
+
receipt.sourceUpdateIds[0] !== source.updateId ||
|
|
2732
|
+
heldQueueReceipts.has(receipt.receiptId) ||
|
|
2733
|
+
!current()
|
|
2734
|
+
)
|
|
2735
|
+
throw new Error(
|
|
2736
|
+
"Held command needs one exact original queue receipt.",
|
|
2737
|
+
);
|
|
2738
|
+
const expected = structuredClone(receipt);
|
|
2739
|
+
retained = {
|
|
2740
|
+
ctx,
|
|
2741
|
+
signal,
|
|
2742
|
+
receipt,
|
|
2743
|
+
queue,
|
|
2744
|
+
completionSha256,
|
|
2745
|
+
current: () => current() && isDeepStrictEqual(receipt, expected),
|
|
2746
|
+
};
|
|
2747
|
+
watchHeldQueueOwner(signal);
|
|
2748
|
+
heldQueueReceipts.set(
|
|
2749
|
+
receipt.receiptId,
|
|
2750
|
+
signal.aborted ? null : retained,
|
|
2751
|
+
);
|
|
2752
|
+
if (!reportTelegramQueueAdmission([carrier], [receipt]))
|
|
2753
|
+
throw new Error("Held command queue admission refused.");
|
|
2754
|
+
},
|
|
2755
|
+
};
|
|
2756
|
+
if (template)
|
|
2757
|
+
await enqueue.call(owner, message, ctx, "prompt", admission);
|
|
2758
|
+
else
|
|
2759
|
+
await handleTelegramContinueCommand(message, ctx, {
|
|
2760
|
+
enqueueContinueTurn: (value, context) =>
|
|
2761
|
+
enqueue.call(owner, value, context, "continue", admission),
|
|
2762
|
+
});
|
|
2763
|
+
return !!retained && current();
|
|
2764
|
+
},
|
|
2765
|
+
inspectQueuedCompletion() {
|
|
2766
|
+
if (!retained || !retained.current()) return undefined;
|
|
2767
|
+
const proof = retained.queue.inspectCompletion(retained.receipt);
|
|
2768
|
+
if (proof && retained.current()) {
|
|
2769
|
+
heldQueueReceipts.delete(retained.receipt.receiptId);
|
|
2770
|
+
return { ...proof };
|
|
2771
|
+
}
|
|
2772
|
+
return undefined;
|
|
2773
|
+
},
|
|
2774
|
+
};
|
|
2775
|
+
};
|
|
2776
|
+
const heldPlans: Record<TelegramHeldCommandName, HeldPlan> = {
|
|
2777
|
+
status: {
|
|
2778
|
+
completion: "before-effect",
|
|
2779
|
+
effect: "showStatus",
|
|
2780
|
+
prepare(ctx, effects) {
|
|
2781
|
+
const scopedStatus = effects?.showStatus;
|
|
2782
|
+
const captured = {
|
|
2783
|
+
...deps,
|
|
2784
|
+
showStatus: scopedStatus
|
|
2785
|
+
? (
|
|
2786
|
+
message: TMessage,
|
|
2787
|
+
context: TContext,
|
|
2788
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
2789
|
+
) => {
|
|
2790
|
+
const address = getTelegramCommandMessageTarget(message);
|
|
2791
|
+
return scopedStatus.call(
|
|
2792
|
+
effects,
|
|
2793
|
+
address.chatId,
|
|
2794
|
+
address.replyToMessageId,
|
|
2795
|
+
context,
|
|
2796
|
+
address.threadId,
|
|
2797
|
+
options,
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
: deps.showStatus,
|
|
2801
|
+
};
|
|
2802
|
+
return {
|
|
2803
|
+
captured,
|
|
2804
|
+
portsCurrent: () => !effects || effects.showStatus === scopedStatus,
|
|
2805
|
+
async run({ message, command, assertRecipient }) {
|
|
2806
|
+
const recipientOptions = { assertAuthority: assertRecipient };
|
|
2807
|
+
return handleTelegramCommandRuntime(
|
|
2808
|
+
command.name,
|
|
2809
|
+
message,
|
|
2810
|
+
ctx,
|
|
2811
|
+
{
|
|
2812
|
+
...captured,
|
|
2813
|
+
assertExecutionCurrent: assertRecipient,
|
|
2814
|
+
showStatus: (value: TMessage, context: TContext) =>
|
|
2815
|
+
captured.showStatus(value, context, recipientOptions),
|
|
2816
|
+
},
|
|
2817
|
+
command.args,
|
|
2818
|
+
);
|
|
2819
|
+
},
|
|
2820
|
+
};
|
|
2821
|
+
},
|
|
2822
|
+
},
|
|
2823
|
+
abort: {
|
|
2824
|
+
completion: "after-semantics",
|
|
2825
|
+
effect: "sendTextReply",
|
|
2826
|
+
prepare(ctx, effects, _command, delivery) {
|
|
2827
|
+
return prepareHeldReplyPlan(
|
|
2828
|
+
ctx,
|
|
2829
|
+
effects!,
|
|
2830
|
+
async (captured, semantic, _complete, reply) => {
|
|
2831
|
+
await handleTelegramAbortCommand({
|
|
2832
|
+
hasAbortHandler: semantic(captured.hasAbortHandler),
|
|
2833
|
+
hasActiveTelegramTurn: semantic(captured.hasActiveTelegramTurn),
|
|
2834
|
+
clearPendingModelSwitch: semantic(
|
|
2835
|
+
captured.clearPendingModelSwitch,
|
|
2836
|
+
),
|
|
2837
|
+
cancelNextTransitionAnnouncements:
|
|
2838
|
+
captured.cancelNextTransitionAnnouncements &&
|
|
2839
|
+
semantic(captured.cancelNextTransitionAnnouncements),
|
|
2840
|
+
abortCurrentTurn: semantic(captured.abortCurrentTurn),
|
|
2841
|
+
setFoldQueuedPromptsIntoHistory: semantic(
|
|
2842
|
+
captured.setFoldQueuedPromptsIntoHistory,
|
|
2843
|
+
),
|
|
2844
|
+
updateStatus: semantic(() => captured.updateStatus(ctx)),
|
|
2845
|
+
sendTextReply: reply,
|
|
2846
|
+
});
|
|
2847
|
+
},
|
|
2848
|
+
delivery,
|
|
2849
|
+
);
|
|
2850
|
+
},
|
|
2851
|
+
},
|
|
2852
|
+
stop: {
|
|
2853
|
+
completion: "after-semantics",
|
|
2854
|
+
effect: "sendTextReply",
|
|
2855
|
+
available: () =>
|
|
2856
|
+
typeof deps.cancelNextTransitionAnnouncements === "function" &&
|
|
2857
|
+
typeof deps.clearQueuedTelegramItems === "function",
|
|
2858
|
+
prepare(ctx, effects, _command, delivery) {
|
|
2859
|
+
return prepareHeldReplyPlan(
|
|
2860
|
+
ctx,
|
|
2861
|
+
effects!,
|
|
2862
|
+
async (captured, semantic, _complete, reply) => {
|
|
2863
|
+
await handleTelegramStopCommand({
|
|
2864
|
+
hasAbortHandler: semantic(captured.hasAbortHandler),
|
|
2865
|
+
clearPendingModelSwitch: semantic(
|
|
2866
|
+
captured.clearPendingModelSwitch,
|
|
2867
|
+
),
|
|
2868
|
+
cancelNextTransitionAnnouncements: semantic(
|
|
2869
|
+
captured.cancelNextTransitionAnnouncements!,
|
|
2870
|
+
),
|
|
2871
|
+
clearQueuedTelegramItems: semantic(() =>
|
|
2872
|
+
captured.clearQueuedTelegramItems(ctx),
|
|
2873
|
+
),
|
|
2874
|
+
setFoldQueuedPromptsIntoHistory: semantic(
|
|
2875
|
+
captured.setFoldQueuedPromptsIntoHistory,
|
|
2876
|
+
),
|
|
2877
|
+
abortCurrentTurn: semantic(captured.abortCurrentTurn),
|
|
2878
|
+
updateStatus: semantic(() => captured.updateStatus(ctx)),
|
|
2879
|
+
sendTextReply: reply,
|
|
2880
|
+
});
|
|
2881
|
+
},
|
|
2882
|
+
delivery,
|
|
2883
|
+
);
|
|
2884
|
+
},
|
|
2885
|
+
},
|
|
2886
|
+
next: {
|
|
2887
|
+
completion: "after-semantics",
|
|
2888
|
+
effect: "sendTextReply",
|
|
2889
|
+
available: () =>
|
|
2890
|
+
typeof deps.requestNextDispatchAnnouncement === "function" &&
|
|
2891
|
+
typeof deps.markActiveTurnNextAbortAnnouncement === "function",
|
|
2892
|
+
prepare(ctx, effects, _command, delivery) {
|
|
2893
|
+
return prepareHeldReplyPlan(
|
|
2894
|
+
ctx,
|
|
2895
|
+
effects!,
|
|
2896
|
+
async (captured, semantic, complete, reply) => {
|
|
2897
|
+
await handleTelegramNextCommand({
|
|
2898
|
+
hasAbortHandler: semantic(captured.hasAbortHandler),
|
|
2899
|
+
isIdle: semantic(() => captured.isIdle(ctx)),
|
|
2900
|
+
hasQueuedItems: semantic(captured.hasQueuedTelegramItems),
|
|
2901
|
+
clearPendingModelSwitch: semantic(
|
|
2902
|
+
captured.clearPendingModelSwitch,
|
|
2903
|
+
),
|
|
2904
|
+
abortCurrentTurn: semantic(captured.abortCurrentTurn),
|
|
2905
|
+
dispatchNextQueuedTurn: semantic(() =>
|
|
2906
|
+
captured.dispatchNextQueuedTelegramTurn(ctx),
|
|
2907
|
+
),
|
|
2908
|
+
requestNextDispatchAnnouncement: semantic(
|
|
2909
|
+
captured.requestNextDispatchAnnouncement!,
|
|
2910
|
+
),
|
|
2911
|
+
markActiveTurnNextAbortAnnouncement: semantic(
|
|
2912
|
+
captured.markActiveTurnNextAbortAnnouncement!,
|
|
2913
|
+
),
|
|
2914
|
+
clearFoldForDispatch: semantic(() =>
|
|
2915
|
+
captured.setFoldQueuedPromptsIntoHistory(false),
|
|
2916
|
+
),
|
|
2917
|
+
updateStatus() {
|
|
2918
|
+
semantic(() => captured.updateStatus(ctx))();
|
|
2919
|
+
complete();
|
|
2920
|
+
},
|
|
2921
|
+
sendTextReply: reply,
|
|
2922
|
+
});
|
|
2923
|
+
},
|
|
2924
|
+
delivery,
|
|
2925
|
+
);
|
|
2926
|
+
},
|
|
2927
|
+
},
|
|
2928
|
+
continue: {
|
|
2929
|
+
completion: "queue-receipt",
|
|
2930
|
+
available: () => typeof deps.heldTurn?.enqueue === "function",
|
|
2931
|
+
prepare(ctx) {
|
|
2932
|
+
return prepareHeldQueuePlan(ctx);
|
|
2933
|
+
},
|
|
2934
|
+
},
|
|
2935
|
+
};
|
|
2936
|
+
const getHeldTemplatePlan = (name: string): HeldPlan | undefined => {
|
|
2937
|
+
const owner = deps.heldTurn,
|
|
2938
|
+
templates = owner?.templates,
|
|
2939
|
+
get = templates?.getCommands,
|
|
2940
|
+
expand = templates?.expand;
|
|
2941
|
+
if (
|
|
2942
|
+
!owner ||
|
|
2943
|
+
!templates ||
|
|
2944
|
+
typeof owner.enqueue !== "function" ||
|
|
2945
|
+
typeof get !== "function" ||
|
|
2946
|
+
typeof expand !== "function" ||
|
|
2947
|
+
getTelegramReservedCommandNames().includes(name) ||
|
|
2948
|
+
findTelegramExtensionCommand(name)
|
|
2949
|
+
)
|
|
2950
|
+
return undefined;
|
|
2951
|
+
const getIdentity = () => {
|
|
2952
|
+
const matching = get
|
|
2953
|
+
.call(templates)
|
|
2954
|
+
.filter((value) => value.command === name);
|
|
2955
|
+
const selected = matching[0];
|
|
2956
|
+
return matching.length === 1 &&
|
|
2957
|
+
selected &&
|
|
2958
|
+
typeof selected.path === "string" &&
|
|
2959
|
+
selected.path.trim()
|
|
2960
|
+
? { command: selected.command, path: selected.path }
|
|
2961
|
+
: undefined;
|
|
2962
|
+
};
|
|
2963
|
+
let identity: ReturnType<typeof getIdentity>;
|
|
2964
|
+
try {
|
|
2965
|
+
identity = getIdentity();
|
|
2966
|
+
} catch {
|
|
2967
|
+
return undefined;
|
|
2968
|
+
}
|
|
2969
|
+
if (!identity) return undefined;
|
|
2970
|
+
const current = () => {
|
|
2971
|
+
if (
|
|
2972
|
+
deps.heldTurn !== owner ||
|
|
2973
|
+
owner.templates !== templates ||
|
|
2974
|
+
templates.getCommands !== get ||
|
|
2975
|
+
templates.expand !== expand ||
|
|
2976
|
+
findTelegramExtensionCommand(name)
|
|
2977
|
+
)
|
|
2978
|
+
return false;
|
|
2979
|
+
try {
|
|
2980
|
+
return (
|
|
2981
|
+
isDeepStrictEqual(getIdentity(), identity) &&
|
|
2982
|
+
deps.heldTurn === owner &&
|
|
2983
|
+
owner.templates === templates &&
|
|
2984
|
+
templates.getCommands === get &&
|
|
2985
|
+
templates.expand === expand &&
|
|
2986
|
+
!findTelegramExtensionCommand(name)
|
|
2987
|
+
);
|
|
2988
|
+
} catch {
|
|
2989
|
+
return false;
|
|
1990
2990
|
}
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2991
|
+
};
|
|
2992
|
+
if (!current()) return undefined;
|
|
2993
|
+
return {
|
|
2994
|
+
completion: "queue-receipt",
|
|
2995
|
+
template: true,
|
|
2996
|
+
prepare(ctx, _effects, command) {
|
|
2997
|
+
if (!command || command.name !== name || !current()) return undefined;
|
|
2998
|
+
const captured = { ...command };
|
|
2999
|
+
let expanded: string | undefined;
|
|
3000
|
+
try {
|
|
3001
|
+
expanded = expand.call(templates, captured.name, captured.args);
|
|
3002
|
+
} catch {
|
|
3003
|
+
return undefined;
|
|
3004
|
+
}
|
|
3005
|
+
if (typeof expanded !== "string" || !current()) return undefined;
|
|
3006
|
+
return prepareHeldQueuePlan(ctx, {
|
|
3007
|
+
command: captured,
|
|
3008
|
+
expanded,
|
|
3009
|
+
isCurrent() {
|
|
3010
|
+
if (!current()) return false;
|
|
3011
|
+
try {
|
|
3012
|
+
return (
|
|
3013
|
+
expand.call(templates, captured.name, captured.args) ===
|
|
3014
|
+
expanded && current()
|
|
3015
|
+
);
|
|
3016
|
+
} catch {
|
|
3017
|
+
return false;
|
|
3018
|
+
}
|
|
3019
|
+
},
|
|
3020
|
+
});
|
|
3021
|
+
},
|
|
3022
|
+
};
|
|
3023
|
+
};
|
|
3024
|
+
const getHeldPlan = (
|
|
3025
|
+
name: string,
|
|
3026
|
+
effects?: TelegramHeldCommandEffects<TContext>,
|
|
3027
|
+
): HeldPlan | undefined => {
|
|
3028
|
+
if (!Object.hasOwn(heldPlans, name)) return getHeldTemplatePlan(name);
|
|
3029
|
+
const plan = heldPlans[name as TelegramHeldCommandName];
|
|
3030
|
+
if (plan.available?.() === false) return undefined;
|
|
3031
|
+
if (
|
|
3032
|
+
(plan.effect &&
|
|
3033
|
+
effects !== undefined &&
|
|
3034
|
+
(!effects || typeof effects[plan.effect] !== "function")) ||
|
|
3035
|
+
(plan.effect === "sendTextReply" && !effects) ||
|
|
3036
|
+
(plan.effect === "showStatus" &&
|
|
3037
|
+
effects === undefined &&
|
|
3038
|
+
typeof deps.showStatus !== "function")
|
|
3039
|
+
)
|
|
3040
|
+
return undefined;
|
|
3041
|
+
return plan;
|
|
3042
|
+
};
|
|
3043
|
+
const prepareSelectedPlan = (
|
|
3044
|
+
command: ParsedTelegramCommand,
|
|
3045
|
+
messages: readonly TMessage[],
|
|
3046
|
+
ctx: TContext,
|
|
3047
|
+
admission: TelegramSelectedCommandAdmission,
|
|
3048
|
+
): (() => Promise<boolean>) | undefined => {
|
|
3049
|
+
const original = messages[0];
|
|
3050
|
+
if (
|
|
3051
|
+
!["status", "abort", "stop", "next"].includes(command.name) ||
|
|
3052
|
+
messages.length !== 1 ||
|
|
3053
|
+
!original ||
|
|
3054
|
+
typeof admission.assertSourceCurrent !== "function" ||
|
|
3055
|
+
typeof admission.assertRecipientCurrent !== "function" ||
|
|
3056
|
+
typeof admission.reportCompleted !== "function"
|
|
3057
|
+
)
|
|
3058
|
+
return undefined;
|
|
3059
|
+
const captured = { ...deps },
|
|
3060
|
+
parsed = { ...command },
|
|
3061
|
+
message =
|
|
3062
|
+
parsed.name === "status"
|
|
3063
|
+
? copySelectedMenuCarrier(original)
|
|
3064
|
+
: copySelectedReplyAnchor(original);
|
|
3065
|
+
const assertRecipientCurrent = admission.assertRecipientCurrent,
|
|
3066
|
+
report = admission.reportCompleted;
|
|
3067
|
+
const effects: TelegramHeldCommandEffects<TContext> | undefined =
|
|
3068
|
+
parsed.name === "status"
|
|
3069
|
+
? undefined
|
|
3070
|
+
: {
|
|
3071
|
+
async sendTextReply(_chat, _anchor, text, options) {
|
|
3072
|
+
await captured.sendTextReply(message, text, {
|
|
3073
|
+
parseMode: options.parseMode,
|
|
3074
|
+
assertAuthority: options.assertAuthority,
|
|
3075
|
+
});
|
|
3076
|
+
},
|
|
3077
|
+
};
|
|
3078
|
+
const plan = getHeldPlan(parsed.name, effects);
|
|
3079
|
+
if (
|
|
3080
|
+
!plan ||
|
|
3081
|
+
plan.completion === "queue-receipt" ||
|
|
3082
|
+
(parsed.name !== "status" && typeof captured.sendTextReply !== "function")
|
|
3083
|
+
)
|
|
3084
|
+
return undefined;
|
|
3085
|
+
const prepared = plan.prepare(ctx, effects, parsed, "inline");
|
|
3086
|
+
if (!prepared) return undefined;
|
|
3087
|
+
const semantic = createSelectedSemanticGuard(
|
|
3088
|
+
admission,
|
|
3089
|
+
() => captured.assertExecutionCurrent?.(original),
|
|
3090
|
+
plan.completion === "before-effect",
|
|
3091
|
+
);
|
|
3092
|
+
const assertCurrent = () => {
|
|
3093
|
+
semantic();
|
|
3094
|
+
if (!prepared.portsCurrent())
|
|
3095
|
+
throw new Error(`Selected ${parsed.name} plan owner is unavailable.`);
|
|
3096
|
+
};
|
|
3097
|
+
return issueSelectedOnce(assertCurrent, async () => {
|
|
3098
|
+
if (plan.completion === "before-effect" && !report.call(admission))
|
|
3099
|
+
return false;
|
|
3100
|
+
return prepared.run({
|
|
3101
|
+
message,
|
|
3102
|
+
command: parsed,
|
|
3103
|
+
assertSemantic: assertCurrent,
|
|
3104
|
+
assertRecipient: assertRecipientCurrent,
|
|
3105
|
+
reportCompleted: () => report.call(admission),
|
|
3106
|
+
});
|
|
3107
|
+
});
|
|
3108
|
+
};
|
|
3109
|
+
const prepareSelectedHelp =
|
|
3110
|
+
(name: "start" | "help") =>
|
|
3111
|
+
(
|
|
3112
|
+
command: ParsedTelegramCommand,
|
|
3113
|
+
messages: readonly TMessage[],
|
|
3114
|
+
ctx: TContext,
|
|
3115
|
+
admission: TelegramSelectedCommandAdmission,
|
|
3116
|
+
): (() => Promise<boolean>) | undefined => {
|
|
3117
|
+
const original = messages[0];
|
|
3118
|
+
if (
|
|
3119
|
+
command.name !== name ||
|
|
3120
|
+
messages.length !== 1 ||
|
|
3121
|
+
!original ||
|
|
3122
|
+
!canPairTelegramUserFromCommandMessage(original) ||
|
|
3123
|
+
original.from?.id === undefined ||
|
|
3124
|
+
original.from.is_bot
|
|
3125
|
+
)
|
|
3126
|
+
return undefined;
|
|
3127
|
+
const captured = { ...deps },
|
|
3128
|
+
userId = original.from.id;
|
|
3129
|
+
if (captured.getAllowedUserId() !== userId) return undefined;
|
|
3130
|
+
const message = {
|
|
3131
|
+
...copySelectedReplyAnchor(original),
|
|
3132
|
+
from: { ...original.from },
|
|
3133
|
+
};
|
|
3134
|
+
const assertRecipientCurrent = admission.assertRecipientCurrent,
|
|
3135
|
+
reportCompleted = admission.reportCompleted;
|
|
3136
|
+
const assertAdmissionCurrent = createSelectedSemanticGuard(
|
|
3137
|
+
admission,
|
|
3138
|
+
() => captured.assertExecutionCurrent?.(original),
|
|
3139
|
+
false,
|
|
1997
3140
|
);
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
3141
|
+
const assertSemanticCurrent = () => {
|
|
3142
|
+
assertAdmissionCurrent();
|
|
3143
|
+
if (captured.getAllowedUserId() !== userId)
|
|
3144
|
+
throw new Error(
|
|
3145
|
+
`Selected ${name} operator authority is unavailable.`,
|
|
3146
|
+
);
|
|
3147
|
+
};
|
|
3148
|
+
return issueSelectedOnce(assertSemanticCurrent, () =>
|
|
3149
|
+
handleTelegramHelpCommand(
|
|
3150
|
+
name,
|
|
3151
|
+
message,
|
|
2004
3152
|
ctx,
|
|
3153
|
+
{
|
|
3154
|
+
...captured,
|
|
3155
|
+
persistAllowedUserId: async () => {
|
|
3156
|
+
throw new Error(
|
|
3157
|
+
`Selected ${name} cannot acquire pairing authority.`,
|
|
3158
|
+
);
|
|
3159
|
+
},
|
|
3160
|
+
},
|
|
3161
|
+
{ assertSemanticCurrent, assertRecipientCurrent, reportCompleted },
|
|
3162
|
+
),
|
|
3163
|
+
);
|
|
3164
|
+
};
|
|
3165
|
+
const prepareSelectedConfirmation =
|
|
3166
|
+
(name: "new" | "compact") =>
|
|
3167
|
+
(
|
|
3168
|
+
command: ParsedTelegramCommand,
|
|
3169
|
+
messages: readonly TMessage[],
|
|
3170
|
+
ctx: TContext,
|
|
3171
|
+
admission: TelegramSelectedCommandAdmission,
|
|
3172
|
+
): (() => Promise<boolean>) | undefined => {
|
|
3173
|
+
const original = messages[0],
|
|
3174
|
+
captured = { ...deps };
|
|
3175
|
+
if (
|
|
3176
|
+
command.name !== name ||
|
|
3177
|
+
messages.length !== 1 ||
|
|
3178
|
+
!original ||
|
|
3179
|
+
!captured.sendInteractiveMessage
|
|
3180
|
+
)
|
|
3181
|
+
return undefined;
|
|
3182
|
+
const target = getTelegramCommandMessageTarget(original);
|
|
3183
|
+
const assertRecipientCurrent = admission.assertRecipientCurrent,
|
|
3184
|
+
reportCompleted = admission.reportCompleted;
|
|
3185
|
+
const openConfirmation =
|
|
3186
|
+
name === "new"
|
|
3187
|
+
? openTelegramNewConfirmation
|
|
3188
|
+
: openTelegramCompactConfirmation;
|
|
3189
|
+
const assertSemanticCurrent = createSelectedSemanticGuard(
|
|
3190
|
+
admission,
|
|
3191
|
+
() => captured.assertExecutionCurrent?.(original),
|
|
3192
|
+
true,
|
|
3193
|
+
);
|
|
3194
|
+
return issueSelectedOnce(assertSemanticCurrent, async () => {
|
|
3195
|
+
if (!reportCompleted()) return false;
|
|
3196
|
+
assertRecipientCurrent();
|
|
3197
|
+
scheduleTelegramCommandEffect(
|
|
3198
|
+
ctx,
|
|
3199
|
+
name,
|
|
3200
|
+
"confirmation-render",
|
|
3201
|
+
captured,
|
|
3202
|
+
() =>
|
|
3203
|
+
openConfirmation(
|
|
3204
|
+
target,
|
|
3205
|
+
{ sendInteractiveMessage: captured.sendInteractiveMessage! },
|
|
3206
|
+
assertRecipientCurrent,
|
|
3207
|
+
),
|
|
3208
|
+
assertRecipientCurrent,
|
|
2005
3209
|
);
|
|
2006
|
-
|
|
2007
|
-
|
|
3210
|
+
return true;
|
|
3211
|
+
});
|
|
3212
|
+
};
|
|
3213
|
+
return Object.assign(handle, {
|
|
3214
|
+
/** Command-only producer execution; Routing must supply the captured plan and released exact-source authority. */
|
|
3215
|
+
prepareSelectedExtensionCommand(
|
|
3216
|
+
prepared: NonNullable<
|
|
3217
|
+
Awaited<ReturnType<typeof prepareTelegramSelectedExtensionCommand>>
|
|
3218
|
+
>,
|
|
3219
|
+
messages: readonly TMessage[],
|
|
3220
|
+
ctx: TContext,
|
|
3221
|
+
admission: TelegramSelectedCommandAdmission,
|
|
3222
|
+
): (() => Promise<void>) | undefined {
|
|
3223
|
+
const original = messages[0],
|
|
3224
|
+
captured = { ...deps },
|
|
3225
|
+
plan = prepared.plan;
|
|
3226
|
+
if (
|
|
3227
|
+
messages.length !== 1 ||
|
|
3228
|
+
!original ||
|
|
3229
|
+
plan.kind !== "command-only" ||
|
|
3230
|
+
typeof captured.sendTextReply !== "function" ||
|
|
3231
|
+
typeof admission.reportCompleted !== "function"
|
|
3232
|
+
)
|
|
3233
|
+
return undefined;
|
|
3234
|
+
const execute = plan.execute,
|
|
3235
|
+
assertRegistrationCurrent = prepared.assertRegistrationCurrent;
|
|
3236
|
+
const assertSourceCurrent = admission.assertSourceCurrent,
|
|
3237
|
+
recipientCurrent = admission.assertRecipientCurrent;
|
|
3238
|
+
const reportCompleted = admission.reportCompleted;
|
|
3239
|
+
// Only a body-free fixed recipient/anchor enters detached delivery, never the original's admission symbols.
|
|
3240
|
+
const message = copySelectedReplyAnchor(original);
|
|
3241
|
+
const assertRecipientCurrent = () => {
|
|
3242
|
+
if (captured.isContextActive?.(ctx) === false)
|
|
3243
|
+
throw new Error("Selected extension context changed.");
|
|
3244
|
+
assertRegistrationCurrent();
|
|
3245
|
+
recipientCurrent();
|
|
3246
|
+
assertRegistrationCurrent();
|
|
3247
|
+
if (captured.isContextActive?.(ctx) === false)
|
|
3248
|
+
throw new Error("Selected extension context changed.");
|
|
3249
|
+
};
|
|
3250
|
+
const assertCurrent = () => {
|
|
3251
|
+
assertSourceCurrent();
|
|
3252
|
+
captured.assertExecutionCurrent?.(original);
|
|
3253
|
+
assertRecipientCurrent();
|
|
3254
|
+
assertSourceCurrent();
|
|
3255
|
+
captured.assertExecutionCurrent?.(original);
|
|
3256
|
+
};
|
|
3257
|
+
try {
|
|
3258
|
+
assertCurrent();
|
|
3259
|
+
} catch {
|
|
3260
|
+
return undefined;
|
|
2008
3261
|
}
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
)
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
3262
|
+
let issued = false,
|
|
3263
|
+
reportAttempted = false;
|
|
3264
|
+
const execution: SelectedCommandExecution = Object.freeze({
|
|
3265
|
+
assertCurrent,
|
|
3266
|
+
reportCompleted() {
|
|
3267
|
+
if (!issued || reportAttempted) return false;
|
|
3268
|
+
// Lost/refused reports never authorize another completion attempt or producer execution.
|
|
3269
|
+
reportAttempted = true;
|
|
3270
|
+
assertCurrent();
|
|
3271
|
+
return reportCompleted();
|
|
3272
|
+
},
|
|
3273
|
+
reply(text: string) {
|
|
3274
|
+
assertRecipientCurrent();
|
|
3275
|
+
if (typeof text !== "string")
|
|
3276
|
+
throw new Error("Selected extension reply requires text.");
|
|
3277
|
+
scheduleTelegramCommandEffect(
|
|
2021
3278
|
ctx,
|
|
3279
|
+
"extension",
|
|
3280
|
+
"selected-reply",
|
|
3281
|
+
captured,
|
|
3282
|
+
() =>
|
|
3283
|
+
captured.sendTextReply(message, text, {
|
|
3284
|
+
assertAuthority: assertRecipientCurrent,
|
|
3285
|
+
}),
|
|
3286
|
+
assertRecipientCurrent,
|
|
2022
3287
|
);
|
|
2023
|
-
|
|
3288
|
+
},
|
|
3289
|
+
});
|
|
3290
|
+
return async () => {
|
|
3291
|
+
if (issued) return;
|
|
3292
|
+
issued = true;
|
|
3293
|
+
assertCurrent();
|
|
3294
|
+
await execute(execution);
|
|
3295
|
+
// Return, rejection and reply delivery are deliberately not semantic completion reports.
|
|
3296
|
+
};
|
|
3297
|
+
},
|
|
3298
|
+
/** Selected help/start require the existing authenticated owner; cold pairing remains ordinary first contact. */
|
|
3299
|
+
prepareSelectedStartCommand: prepareSelectedHelp("start"),
|
|
3300
|
+
prepareSelectedHelpCommand: prepareSelectedHelp("help"),
|
|
3301
|
+
/** Selected new handles only confirmation; the later authenticated callback owns replacement admission. */
|
|
3302
|
+
prepareSelectedNewCommand: prepareSelectedConfirmation("new"),
|
|
3303
|
+
/** Selected compaction handles only confirmation; the later authenticated callback owns actual compaction. */
|
|
3304
|
+
prepareSelectedCompactCommand: prepareSelectedConfirmation("compact"),
|
|
3305
|
+
/** Bare naming completes only after the exact dialog owner confirms current publication, not delivery alone. */
|
|
3306
|
+
prepareSelectedNameDialogCommand(
|
|
3307
|
+
command: ParsedTelegramCommand,
|
|
3308
|
+
messages: readonly TMessage[],
|
|
3309
|
+
ctx: TContext,
|
|
3310
|
+
admission: TelegramSelectedCommandAdmission,
|
|
3311
|
+
): (() => Promise<boolean>) | undefined {
|
|
3312
|
+
const original = messages[0],
|
|
3313
|
+
captured = { ...deps };
|
|
3314
|
+
if (
|
|
3315
|
+
command.name !== "name" ||
|
|
3316
|
+
command.args.trim() ||
|
|
3317
|
+
messages.length !== 1 ||
|
|
3318
|
+
!original ||
|
|
3319
|
+
!captured.openThreadNameDialog
|
|
3320
|
+
)
|
|
3321
|
+
return undefined;
|
|
3322
|
+
const message = copySelectedReplyAnchor(original);
|
|
3323
|
+
const assertSourceCurrent = admission.assertSourceCurrent,
|
|
3324
|
+
assertRecipientCurrent = admission.assertRecipientCurrent;
|
|
3325
|
+
const reportCompleted = admission.reportCompleted;
|
|
3326
|
+
const assertSemanticCurrent = createSelectedSemanticGuard(
|
|
3327
|
+
admission,
|
|
3328
|
+
() => captured.assertExecutionCurrent?.(original),
|
|
3329
|
+
true,
|
|
3330
|
+
);
|
|
3331
|
+
return issueSelectedOnce(assertSemanticCurrent, async () => {
|
|
3332
|
+
const publication = await captured.openThreadNameDialog!(message, ctx, {
|
|
3333
|
+
assertSemanticCurrent,
|
|
3334
|
+
assertRecipientCurrent,
|
|
3335
|
+
});
|
|
3336
|
+
assertSemanticCurrent();
|
|
3337
|
+
const assertPublished = publication?.assertPublished;
|
|
3338
|
+
if (typeof assertPublished !== "function")
|
|
3339
|
+
throw new Error("Selected name dialog publication is unconfirmed.");
|
|
3340
|
+
assertPublished();
|
|
3341
|
+
assertSemanticCurrent();
|
|
3342
|
+
assertPublished();
|
|
3343
|
+
assertSourceCurrent();
|
|
3344
|
+
captured.assertExecutionCurrent?.(original);
|
|
3345
|
+
if (!reportCompleted())
|
|
3346
|
+
throw new Error("Selected name source completion refused.");
|
|
3347
|
+
return true;
|
|
3348
|
+
});
|
|
3349
|
+
},
|
|
3350
|
+
/** Explicit naming retains source authority until its owner result; bare dialogs use their own publication leaf. */
|
|
3351
|
+
prepareSelectedNameCommand(
|
|
3352
|
+
command: ParsedTelegramCommand,
|
|
3353
|
+
messages: readonly TMessage[],
|
|
3354
|
+
ctx: TContext,
|
|
3355
|
+
admission: TelegramSelectedCommandAdmission,
|
|
3356
|
+
): (() => Promise<boolean>) | undefined {
|
|
3357
|
+
const original = messages[0],
|
|
3358
|
+
threadName = command.args.trim(),
|
|
3359
|
+
captured = { ...deps };
|
|
3360
|
+
if (
|
|
3361
|
+
command.name !== "name" ||
|
|
3362
|
+
messages.length !== 1 ||
|
|
3363
|
+
!original ||
|
|
3364
|
+
!threadName ||
|
|
3365
|
+
(/^[A-Z]$/.test(threadName)
|
|
3366
|
+
? !captured.resetCurrentThreadName
|
|
3367
|
+
: !captured.renameCurrentThread)
|
|
3368
|
+
)
|
|
3369
|
+
return undefined;
|
|
3370
|
+
const message = copySelectedReplyAnchor(original);
|
|
3371
|
+
const assertRecipientCurrent = admission.assertRecipientCurrent,
|
|
3372
|
+
reportCompleted = admission.reportCompleted;
|
|
3373
|
+
const assertSemanticCurrent = createSelectedSemanticGuard(
|
|
3374
|
+
admission,
|
|
3375
|
+
() => captured.assertExecutionCurrent?.(original),
|
|
3376
|
+
true,
|
|
3377
|
+
);
|
|
3378
|
+
return issueSelectedOnce(assertSemanticCurrent, async () => {
|
|
3379
|
+
await handleTelegramNameCommand(message, ctx, threadName, captured, {
|
|
3380
|
+
assertSemanticCurrent,
|
|
3381
|
+
assertRecipientCurrent,
|
|
3382
|
+
reportCompleted,
|
|
3383
|
+
});
|
|
3384
|
+
return true;
|
|
3385
|
+
});
|
|
3386
|
+
},
|
|
3387
|
+
/** Genuine leader originals capture their warm queue owner before release; no hold copy or semantic-completion report. */
|
|
3388
|
+
prepareSelectedQueueCommand(
|
|
3389
|
+
command: ParsedTelegramCommand,
|
|
3390
|
+
messages: readonly TMessage[],
|
|
3391
|
+
ctx: TContext,
|
|
3392
|
+
admission: TelegramHeldCommandAdmission,
|
|
3393
|
+
): Omit<TelegramPreparedHeldCommand, "bindCarrier"> | undefined {
|
|
3394
|
+
const original = messages[0],
|
|
3395
|
+
parsed = { ...command };
|
|
3396
|
+
if (
|
|
3397
|
+
messages.length !== 1 ||
|
|
3398
|
+
!original ||
|
|
3399
|
+
typeof admission.assertSourceCurrent !== "function" ||
|
|
3400
|
+
typeof admission.assertRecipientCurrent !== "function"
|
|
3401
|
+
)
|
|
3402
|
+
return undefined;
|
|
3403
|
+
const raw =
|
|
3404
|
+
Reflect.get(original, "text") ?? Reflect.get(original, "caption"),
|
|
3405
|
+
operator = deps.getAllowedUserId();
|
|
3406
|
+
const actual =
|
|
3407
|
+
typeof raw === "string" ? parseTelegramCommand(raw) : undefined;
|
|
3408
|
+
if (
|
|
3409
|
+
!isDeepStrictEqual(actual, parsed) ||
|
|
3410
|
+
typeof operator !== "number" ||
|
|
3411
|
+
original.chat.type !== "private" ||
|
|
3412
|
+
original.chat.id !== operator ||
|
|
3413
|
+
original.from?.id !== operator ||
|
|
3414
|
+
original.from.is_bot ||
|
|
3415
|
+
admission.target.chatId !== operator ||
|
|
3416
|
+
!Number.isSafeInteger(admission.target.threadId) ||
|
|
3417
|
+
admission.target.threadId < 1
|
|
3418
|
+
)
|
|
3419
|
+
return undefined;
|
|
3420
|
+
const plan = getHeldPlan(parsed.name);
|
|
3421
|
+
if (!plan || plan.completion !== "queue-receipt") return undefined;
|
|
3422
|
+
const source = inspectTelegramDeferredSource(original),
|
|
3423
|
+
queue = prepareTelegramDeferredQueueAdmission(original);
|
|
3424
|
+
if (!source || source.completionSha256 || !queue?.isCurrent())
|
|
3425
|
+
return undefined;
|
|
3426
|
+
const prepared = plan.prepare(ctx, undefined, parsed);
|
|
3427
|
+
if (!prepared) return undefined;
|
|
3428
|
+
const target = { ...admission.target },
|
|
3429
|
+
originalProjection = structuredClone(copySelectedMenuCarrier(original));
|
|
3430
|
+
const message = structuredClone(originalProjection);
|
|
3431
|
+
message.message_thread_id = target.threadId;
|
|
3432
|
+
const assertSource = admission.assertSourceCurrent,
|
|
3433
|
+
assertRecipient = admission.assertRecipientCurrent;
|
|
3434
|
+
const assertCurrent = () => {
|
|
3435
|
+
assertSource.call(admission);
|
|
3436
|
+
prepared.captured.assertExecutionCurrent?.(original);
|
|
3437
|
+
assertRecipient.call(admission);
|
|
3438
|
+
if (
|
|
3439
|
+
deps.getAllowedUserId() !== operator ||
|
|
3440
|
+
!queue.isCurrent() ||
|
|
3441
|
+
!prepared.portsCurrent() ||
|
|
3442
|
+
!isDeepStrictEqual(
|
|
3443
|
+
copySelectedMenuCarrier(original),
|
|
3444
|
+
originalProjection,
|
|
3445
|
+
) ||
|
|
3446
|
+
!isDeepStrictEqual(inspectTelegramDeferredSource(original), source)
|
|
3447
|
+
)
|
|
3448
|
+
throw new Error("Selected queue command source/owner changed.");
|
|
3449
|
+
assertSource.call(admission);
|
|
3450
|
+
prepared.captured.assertExecutionCurrent?.(original);
|
|
3451
|
+
};
|
|
3452
|
+
const execute = issueSelectedOnce(assertCurrent, async () => {
|
|
3453
|
+
const release = acquireTelegramUpdateRouting(original);
|
|
3454
|
+
try {
|
|
3455
|
+
assertCurrent();
|
|
3456
|
+
return await prepared.run({
|
|
3457
|
+
message,
|
|
3458
|
+
carrier: original,
|
|
3459
|
+
source: { ...source },
|
|
3460
|
+
queue,
|
|
3461
|
+
command: { ...parsed },
|
|
3462
|
+
assertSemantic: assertCurrent,
|
|
3463
|
+
assertRecipient,
|
|
3464
|
+
reportCompleted() {
|
|
3465
|
+
throw new Error(
|
|
3466
|
+
"Queue admission cannot report semantic source removal.",
|
|
3467
|
+
);
|
|
3468
|
+
},
|
|
3469
|
+
});
|
|
3470
|
+
} finally {
|
|
3471
|
+
release();
|
|
2024
3472
|
}
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
|
|
3473
|
+
});
|
|
3474
|
+
return {
|
|
3475
|
+
get source() {
|
|
3476
|
+
return { ...source };
|
|
3477
|
+
},
|
|
3478
|
+
get command() {
|
|
3479
|
+
return { ...parsed };
|
|
3480
|
+
},
|
|
3481
|
+
execute,
|
|
3482
|
+
inspectCompletion() {
|
|
3483
|
+
try {
|
|
3484
|
+
assertRecipient.call(admission);
|
|
3485
|
+
if (
|
|
3486
|
+
deps.getAllowedUserId() !== operator ||
|
|
3487
|
+
!queue.isCurrent() ||
|
|
3488
|
+
!prepared.portsCurrent()
|
|
3489
|
+
)
|
|
3490
|
+
return undefined;
|
|
3491
|
+
const proof = prepared.inspectQueuedCompletion?.();
|
|
3492
|
+
assertRecipient.call(admission);
|
|
3493
|
+
return deps.getAllowedUserId() === operator &&
|
|
3494
|
+
queue.isCurrent() &&
|
|
3495
|
+
prepared.portsCurrent()
|
|
3496
|
+
? proof
|
|
3497
|
+
: undefined;
|
|
3498
|
+
} catch {
|
|
3499
|
+
return undefined;
|
|
3500
|
+
}
|
|
3501
|
+
},
|
|
3502
|
+
};
|
|
3503
|
+
},
|
|
3504
|
+
/** Selected leaders reuse registry semantics; native source/recipient admission and inline transport remain role-owned. */
|
|
3505
|
+
prepareSelectedCommand: prepareSelectedPlan,
|
|
3506
|
+
/** Existing native publication boundary; unrelated/legacy receipts keep their ordinary owner. */
|
|
3507
|
+
prepareHeldQueueReceipt: ((
|
|
3508
|
+
receipt,
|
|
3509
|
+
queueOwner,
|
|
3510
|
+
context,
|
|
3511
|
+
isWorkerCurrent,
|
|
3512
|
+
) => {
|
|
3513
|
+
const record = heldQueueReceipts.get(receipt.receiptId);
|
|
3514
|
+
if (record === null)
|
|
3515
|
+
throw new Error("Held queue receipt publication authority changed.");
|
|
3516
|
+
if (!record) return undefined;
|
|
3517
|
+
if (
|
|
3518
|
+
context !== record.ctx ||
|
|
3519
|
+
!isWorkerCurrent() ||
|
|
3520
|
+
!record.current() ||
|
|
3521
|
+
!isDeepStrictEqual(record.receipt, receipt)
|
|
3522
|
+
)
|
|
3523
|
+
throw new Error("Held queue receipt publication authority changed.");
|
|
3524
|
+
const scope = record.queue.prepareCompletionScope(
|
|
3525
|
+
record.receipt,
|
|
3526
|
+
queueOwner,
|
|
3527
|
+
record.completionSha256,
|
|
3528
|
+
);
|
|
3529
|
+
if (!scope || !isWorkerCurrent() || !record.current())
|
|
3530
|
+
throw new Error("Held queue receipt original scope is unavailable.");
|
|
3531
|
+
return [scope];
|
|
3532
|
+
}) satisfies NonNullable<
|
|
3533
|
+
TelegramUpdateWorkerRuntimeDeps<TContext>["beforeQueueReceiptPublished"]
|
|
3534
|
+
>,
|
|
3535
|
+
/** Pure registry/effect availability; never reads a source or activates future recipient authority. */
|
|
3536
|
+
canPrepareHeldCommand(
|
|
3537
|
+
name: string,
|
|
3538
|
+
effects?: TelegramHeldCommandEffects<TContext>,
|
|
3539
|
+
): boolean {
|
|
3540
|
+
return getHeldPlan(name, effects) !== undefined;
|
|
2028
3541
|
},
|
|
3542
|
+
/** Compile one registry plan from a saved original; no admission, recipient activation or update-handler replay. */
|
|
3543
|
+
prepareHeldCommand(
|
|
3544
|
+
name: string,
|
|
3545
|
+
readiness: TelegramLiveSourceCompletionReadiness,
|
|
3546
|
+
ctx: TContext,
|
|
3547
|
+
admission: TelegramHeldCommandAdmission,
|
|
3548
|
+
effects?: TelegramHeldCommandEffects<TContext>,
|
|
3549
|
+
): TelegramPreparedHeldCommand | undefined {
|
|
3550
|
+
const plan = getHeldPlan(name, effects);
|
|
3551
|
+
if (!plan) return undefined;
|
|
3552
|
+
const value = plan.template
|
|
3553
|
+
? readiness.snapshot.update.message
|
|
3554
|
+
: undefined;
|
|
3555
|
+
const command =
|
|
3556
|
+
value &&
|
|
3557
|
+
typeof value === "object" &&
|
|
3558
|
+
typeof Reflect.get(value, "text") === "string"
|
|
3559
|
+
? parseTelegramCommand(Reflect.get(value, "text"))
|
|
3560
|
+
: undefined;
|
|
3561
|
+
const prepared = plan.prepare(ctx, effects, command);
|
|
3562
|
+
if (!prepared) return undefined;
|
|
3563
|
+
return prepareHeldSelectedCommand(
|
|
3564
|
+
name,
|
|
3565
|
+
readiness,
|
|
3566
|
+
admission,
|
|
3567
|
+
prepared.captured,
|
|
3568
|
+
prepared.portsCurrent,
|
|
3569
|
+
plan.completion,
|
|
3570
|
+
prepared.run,
|
|
3571
|
+
prepared.inspectQueuedCompletion,
|
|
3572
|
+
);
|
|
3573
|
+
},
|
|
3574
|
+
/** Warm menu-only issuance; completion reporting is not a receipt or a durable removal ACK. */
|
|
3575
|
+
prepareSelectedMenuCommand(
|
|
3576
|
+
command: ParsedTelegramCommand,
|
|
3577
|
+
messages: readonly TMessage[],
|
|
3578
|
+
ctx: TContext,
|
|
3579
|
+
admission: TelegramSelectedCommandAdmission,
|
|
3580
|
+
): (() => Promise<boolean>) | undefined {
|
|
3581
|
+
const action = buildTelegramCommandAction(command.name);
|
|
3582
|
+
if (
|
|
3583
|
+
messages.length !== 1 ||
|
|
3584
|
+
!messages[0] ||
|
|
3585
|
+
!["status", "model", "thinking", "queue", "settings"].includes(
|
|
3586
|
+
action.kind,
|
|
3587
|
+
) ||
|
|
3588
|
+
(action.kind === "settings" && !deps.openSettingsMenu)
|
|
3589
|
+
)
|
|
3590
|
+
return undefined;
|
|
3591
|
+
if (action.kind === "status")
|
|
3592
|
+
return prepareSelectedPlan(command, messages, ctx, admission);
|
|
3593
|
+
const original = messages[0],
|
|
3594
|
+
name = command.name,
|
|
3595
|
+
args = command.args;
|
|
3596
|
+
if (
|
|
3597
|
+
typeof admission.assertSourceCurrent !== "function" ||
|
|
3598
|
+
typeof admission.assertRecipientCurrent !== "function" ||
|
|
3599
|
+
typeof admission.reportCompleted !== "function"
|
|
3600
|
+
)
|
|
3601
|
+
return undefined;
|
|
3602
|
+
const assertSourceCurrent = admission.assertSourceCurrent,
|
|
3603
|
+
assertRecipientCurrent = admission.assertRecipientCurrent;
|
|
3604
|
+
const reportCompleted = admission.reportCompleted,
|
|
3605
|
+
assertExecutionCurrent = deps.assertExecutionCurrent;
|
|
3606
|
+
const assertIssuanceCurrent = () => {
|
|
3607
|
+
assertSourceCurrent.call(admission);
|
|
3608
|
+
assertExecutionCurrent?.call(deps, original);
|
|
3609
|
+
assertRecipientCurrent();
|
|
3610
|
+
// Recipient observations can revoke the source too; never complete from an earlier source sample.
|
|
3611
|
+
assertSourceCurrent.call(admission);
|
|
3612
|
+
assertExecutionCurrent?.call(deps, original);
|
|
3613
|
+
};
|
|
3614
|
+
// Preparation proves branch availability, not the future released recipient's active authority.
|
|
3615
|
+
// Detached rendering must not inherit the original admission/fence or a mutable target carrier.
|
|
3616
|
+
const message = copySelectedMenuCarrier(original);
|
|
3617
|
+
const menuDeps = { ...deps };
|
|
3618
|
+
const recipientOptions = { assertAuthority: assertRecipientCurrent };
|
|
3619
|
+
const recipientDeps = {
|
|
3620
|
+
...deps,
|
|
3621
|
+
assertExecutionCurrent: assertRecipientCurrent,
|
|
3622
|
+
showStatus: (value: TMessage, context: TContext) =>
|
|
3623
|
+
menuDeps.showStatus(value, context, recipientOptions),
|
|
3624
|
+
openModelMenu: (value: TMessage, context: TContext) =>
|
|
3625
|
+
menuDeps.openModelMenu(value, context, recipientOptions),
|
|
3626
|
+
openThinkingMenu: (value: TMessage, context: TContext) =>
|
|
3627
|
+
menuDeps.openThinkingMenu(value, context, recipientOptions),
|
|
3628
|
+
openQueueMenu: (value: TMessage, context: TContext) =>
|
|
3629
|
+
menuDeps.openQueueMenu(value, context, recipientOptions),
|
|
3630
|
+
openSettingsMenu:
|
|
3631
|
+
menuDeps.openSettingsMenu &&
|
|
3632
|
+
((value: TMessage, context: TContext) =>
|
|
3633
|
+
menuDeps.openSettingsMenu!(value, context, recipientOptions)),
|
|
3634
|
+
};
|
|
3635
|
+
return issueSelectedOnce(
|
|
3636
|
+
assertIssuanceCurrent,
|
|
3637
|
+
async () =>
|
|
3638
|
+
reportCompleted.call(admission) &&
|
|
3639
|
+
handleTelegramCommandRuntime(name, message, ctx, recipientDeps, args),
|
|
3640
|
+
);
|
|
3641
|
+
},
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
export function createTelegramCommandOrPromptDispatcher<TMessage, TContext>(
|
|
3646
|
+
deps: TelegramCommandOrPromptDispatcherDeps<TMessage, TContext>,
|
|
3647
|
+
) {
|
|
3648
|
+
const dispatchPromptTemplate = async (
|
|
3649
|
+
command: ParsedTelegramCommand | undefined,
|
|
3650
|
+
messages: TMessage[],
|
|
3651
|
+
ctx: TContext,
|
|
3652
|
+
): Promise<boolean> => {
|
|
3653
|
+
const first = messages[0];
|
|
3654
|
+
if (!first) return false;
|
|
3655
|
+
deps.assertExecutionCurrent?.(first);
|
|
3656
|
+
const expanded =
|
|
3657
|
+
command && deps.expandPromptTemplateCommand?.(command.name, command.args);
|
|
3658
|
+
if (expanded === undefined) return false;
|
|
3659
|
+
deps.assertExecutionCurrent?.(first);
|
|
3660
|
+
const replaced = deps.replaceMessageText(first, expanded);
|
|
3661
|
+
deps.assertExecutionCurrent?.(first);
|
|
3662
|
+
await deps.enqueueTurn([replaced, ...messages.slice(1)], ctx);
|
|
3663
|
+
return true;
|
|
3664
|
+
};
|
|
3665
|
+
return async (messages: TMessage[], ctx: TContext): Promise<void> => {
|
|
3666
|
+
const firstMessage = messages[0];
|
|
3667
|
+
if (!firstMessage) return;
|
|
3668
|
+
if (deps.shouldIgnoreMessages?.(messages)) return;
|
|
3669
|
+
deps.assertExecutionCurrent?.(firstMessage);
|
|
3670
|
+
if (await deps.consumeThreadNameInput?.(messages, ctx)) {
|
|
3671
|
+
deps.assertExecutionCurrent?.(firstMessage);
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
const command = parseTelegramCommand(deps.extractRawText(messages));
|
|
3675
|
+
const handled = await deps.handleCommand(
|
|
3676
|
+
command?.name,
|
|
3677
|
+
firstMessage,
|
|
3678
|
+
ctx,
|
|
3679
|
+
command?.args,
|
|
3680
|
+
);
|
|
3681
|
+
deps.assertExecutionCurrent?.(firstMessage);
|
|
3682
|
+
if (handled) return;
|
|
3683
|
+
if (command && deps.executeExtensionCommand) {
|
|
3684
|
+
const handledByExtension = await deps.executeExtensionCommand(
|
|
3685
|
+
command,
|
|
3686
|
+
messages[0]!,
|
|
3687
|
+
ctx,
|
|
3688
|
+
);
|
|
3689
|
+
deps.assertExecutionCurrent?.(firstMessage);
|
|
3690
|
+
if (handledByExtension) return;
|
|
3691
|
+
}
|
|
3692
|
+
if (await dispatchPromptTemplate(command, messages, ctx)) return;
|
|
3693
|
+
deps.assertExecutionCurrent?.(firstMessage);
|
|
3694
|
+
await deps.enqueueTurn(messages, ctx);
|
|
2029
3695
|
};
|
|
2030
3696
|
}
|
|
2031
3697
|
|
|
@@ -2033,20 +3699,31 @@ function scheduleTelegramCommandEffect<TContext>(
|
|
|
2033
3699
|
ctx: TContext,
|
|
2034
3700
|
command: string,
|
|
2035
3701
|
phase: string,
|
|
2036
|
-
deps: TelegramRuntimeEventRecorderPort &
|
|
2037
|
-
|
|
2038
|
-
|
|
3702
|
+
deps: TelegramRuntimeEventRecorderPort &
|
|
3703
|
+
TelegramCommandEffectWorkPort & {
|
|
3704
|
+
isContextActive?: (ctx: TContext) => boolean;
|
|
3705
|
+
},
|
|
2039
3706
|
effect: () => Promise<void>,
|
|
2040
3707
|
assertExecutionCurrent?: () => void,
|
|
2041
3708
|
): void {
|
|
3709
|
+
const work = deps.beginCommandEffectWork?.();
|
|
3710
|
+
let started = false,
|
|
3711
|
+
outcome: "settled" | "unconfirmed" = "settled";
|
|
2042
3712
|
void Promise.resolve()
|
|
2043
3713
|
.then(async () => {
|
|
2044
3714
|
if (deps.isContextActive?.(ctx) === false) return;
|
|
2045
3715
|
assertExecutionCurrent?.();
|
|
3716
|
+
started = true;
|
|
2046
3717
|
await effect();
|
|
2047
3718
|
assertExecutionCurrent?.();
|
|
2048
3719
|
})
|
|
2049
3720
|
.catch((error) => {
|
|
3721
|
+
// A failed started effect cannot prove non-issuance; never turn its uncertainty into known idle.
|
|
3722
|
+
if (
|
|
3723
|
+
started &&
|
|
3724
|
+
!(error instanceof TelegramApiAuthorityError && !error.requestIssued)
|
|
3725
|
+
)
|
|
3726
|
+
outcome = "unconfirmed";
|
|
2050
3727
|
try {
|
|
2051
3728
|
deps.recordRuntimeEvent?.("telegram-command", error, {
|
|
2052
3729
|
command,
|
|
@@ -2055,7 +3732,201 @@ function scheduleTelegramCommandEffect<TContext>(
|
|
|
2055
3732
|
} catch {
|
|
2056
3733
|
// Effect diagnostics cannot create an unhandled detached Promise.
|
|
2057
3734
|
}
|
|
3735
|
+
})
|
|
3736
|
+
.finally(() => work?.settle(outcome));
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
async function handleTelegramHelpCommand<
|
|
3740
|
+
TMessage extends TelegramCommandRuntimeMessage,
|
|
3741
|
+
TContext,
|
|
3742
|
+
>(
|
|
3743
|
+
commandName: string,
|
|
3744
|
+
message: TMessage,
|
|
3745
|
+
ctx: TContext,
|
|
3746
|
+
deps: Pick<
|
|
3747
|
+
TelegramCommandRuntimeDeps<TMessage, TContext>,
|
|
3748
|
+
| "assertExecutionCurrent"
|
|
3749
|
+
| "getAllowedUserId"
|
|
3750
|
+
| "persistAllowedUserId"
|
|
3751
|
+
| "updateStatus"
|
|
3752
|
+
| "isContextActive"
|
|
3753
|
+
| "handleForumBootstrap"
|
|
3754
|
+
| "sendTextReply"
|
|
3755
|
+
| "showStatus"
|
|
3756
|
+
| "registerBotCommands"
|
|
3757
|
+
| "recordRuntimeEvent"
|
|
3758
|
+
| "beginCommandEffectWork"
|
|
3759
|
+
>,
|
|
3760
|
+
admission?: {
|
|
3761
|
+
assertSemanticCurrent(): void;
|
|
3762
|
+
assertRecipientCurrent(): void;
|
|
3763
|
+
reportCompleted(): boolean;
|
|
3764
|
+
},
|
|
3765
|
+
): Promise<boolean> {
|
|
3766
|
+
const assertSemanticCurrent =
|
|
3767
|
+
admission?.assertSemanticCurrent ??
|
|
3768
|
+
(() => deps.assertExecutionCurrent?.(message));
|
|
3769
|
+
admission?.assertSemanticCurrent();
|
|
3770
|
+
if (
|
|
3771
|
+
message.from?.id !== undefined &&
|
|
3772
|
+
canPairTelegramUserFromCommandMessage(message)
|
|
3773
|
+
) {
|
|
3774
|
+
const allowed = await pairTelegramUserIfNeeded(message.from.id, {
|
|
3775
|
+
allowedUserId: deps.getAllowedUserId(),
|
|
3776
|
+
ctx: undefined,
|
|
3777
|
+
persistAllowedUserId: deps.persistAllowedUserId,
|
|
3778
|
+
updateStatus: () => deps.updateStatus(ctx),
|
|
3779
|
+
assertExecutionCurrent: assertSemanticCurrent,
|
|
2058
3780
|
});
|
|
3781
|
+
if (!allowed) return false;
|
|
3782
|
+
}
|
|
3783
|
+
if (admission) {
|
|
3784
|
+
assertSemanticCurrent();
|
|
3785
|
+
if (!admission.reportCompleted()) return false;
|
|
3786
|
+
admission.assertRecipientCurrent();
|
|
3787
|
+
}
|
|
3788
|
+
const assertEffectCurrent =
|
|
3789
|
+
admission?.assertRecipientCurrent ??
|
|
3790
|
+
(() => deps.assertExecutionCurrent?.(message));
|
|
3791
|
+
const options = admission && {
|
|
3792
|
+
assertAuthority: admission.assertRecipientCurrent,
|
|
3793
|
+
};
|
|
3794
|
+
const isContextActive = () => deps.isContextActive?.(ctx) !== false;
|
|
3795
|
+
scheduleTelegramCommandEffect(
|
|
3796
|
+
ctx,
|
|
3797
|
+
commandName,
|
|
3798
|
+
"menu-render",
|
|
3799
|
+
deps,
|
|
3800
|
+
async () => {
|
|
3801
|
+
let forumBootstrapMessage: string | undefined;
|
|
3802
|
+
if (commandName === "start" && deps.handleForumBootstrap) {
|
|
3803
|
+
forumBootstrapMessage = await deps.handleForumBootstrap(
|
|
3804
|
+
message,
|
|
3805
|
+
ctx,
|
|
3806
|
+
options,
|
|
3807
|
+
);
|
|
3808
|
+
}
|
|
3809
|
+
if (!isContextActive()) return;
|
|
3810
|
+
assertEffectCurrent();
|
|
3811
|
+
if (forumBootstrapMessage) {
|
|
3812
|
+
await deps.sendTextReply(message, forumBootstrapMessage, options);
|
|
3813
|
+
assertEffectCurrent();
|
|
3814
|
+
}
|
|
3815
|
+
if (!isContextActive()) return;
|
|
3816
|
+
assertEffectCurrent();
|
|
3817
|
+
await deps.showStatus(message, ctx, options);
|
|
3818
|
+
},
|
|
3819
|
+
assertEffectCurrent,
|
|
3820
|
+
);
|
|
3821
|
+
scheduleTelegramCommandEffect(
|
|
3822
|
+
ctx,
|
|
3823
|
+
commandName,
|
|
3824
|
+
"bot-command-sync",
|
|
3825
|
+
deps,
|
|
3826
|
+
() => deps.registerBotCommands(options),
|
|
3827
|
+
assertEffectCurrent,
|
|
3828
|
+
);
|
|
3829
|
+
return true;
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
async function handleTelegramNameCommand<
|
|
3833
|
+
TMessage extends TelegramCommandRuntimeMessage,
|
|
3834
|
+
TContext,
|
|
3835
|
+
>(
|
|
3836
|
+
message: TMessage,
|
|
3837
|
+
ctx: TContext,
|
|
3838
|
+
requestedName: string,
|
|
3839
|
+
deps: TelegramCommandRuntimeDeps<TMessage, TContext>,
|
|
3840
|
+
admission?: {
|
|
3841
|
+
assertSemanticCurrent(): void;
|
|
3842
|
+
assertRecipientCurrent(): void;
|
|
3843
|
+
reportCompleted(): boolean;
|
|
3844
|
+
},
|
|
3845
|
+
): Promise<void> {
|
|
3846
|
+
const threadName = requestedName.trim();
|
|
3847
|
+
const reply = async (text: string) => {
|
|
3848
|
+
if (admission) {
|
|
3849
|
+
admission.assertSemanticCurrent();
|
|
3850
|
+
if (!admission.reportCompleted())
|
|
3851
|
+
throw new Error("Selected name source completion refused.");
|
|
3852
|
+
admission.assertRecipientCurrent();
|
|
3853
|
+
await deps.sendTextReply(message, text, {
|
|
3854
|
+
parseMode: "HTML",
|
|
3855
|
+
assertAuthority: admission.assertRecipientCurrent,
|
|
3856
|
+
});
|
|
3857
|
+
admission.assertRecipientCurrent();
|
|
3858
|
+
} else {
|
|
3859
|
+
deps.assertExecutionCurrent?.(message);
|
|
3860
|
+
await deps.sendTextReply(message, text, { parseMode: "HTML" });
|
|
3861
|
+
deps.assertExecutionCurrent?.(message);
|
|
3862
|
+
}
|
|
3863
|
+
};
|
|
3864
|
+
if (!threadName) {
|
|
3865
|
+
if (deps.openThreadNameDialog)
|
|
3866
|
+
await deps.openThreadNameDialog(message, ctx);
|
|
3867
|
+
else
|
|
3868
|
+
await reply(
|
|
3869
|
+
formatTelegramInformationHeading("🏷️", "Usage: /name Navigator"),
|
|
3870
|
+
);
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
if (/^[A-Z]$/.test(threadName) && deps.resetCurrentThreadName) {
|
|
3874
|
+
admission?.assertSemanticCurrent();
|
|
3875
|
+
const target = getTelegramCommandMessageTarget(message);
|
|
3876
|
+
const result = admission
|
|
3877
|
+
? await deps.resetCurrentThreadName(target, {
|
|
3878
|
+
assertAuthority: admission.assertSemanticCurrent,
|
|
3879
|
+
})
|
|
3880
|
+
: await deps.resetCurrentThreadName(target);
|
|
3881
|
+
admission?.assertSemanticCurrent();
|
|
3882
|
+
await reply(
|
|
3883
|
+
result.ok && !result.message
|
|
3884
|
+
? formatTelegramAutomaticThreadDisplayNameRestoredHeading(
|
|
3885
|
+
result.threadName ?? threadName,
|
|
3886
|
+
)
|
|
3887
|
+
: formatTelegramInformationHeading(
|
|
3888
|
+
result.ok ? "✅" : "⚠️",
|
|
3889
|
+
result.message ?? "Thread display name reset failed.",
|
|
3890
|
+
),
|
|
3891
|
+
);
|
|
3892
|
+
return;
|
|
3893
|
+
}
|
|
3894
|
+
admission?.assertSemanticCurrent();
|
|
3895
|
+
const validationError = deps.validateThreadName?.(threadName);
|
|
3896
|
+
admission?.assertSemanticCurrent();
|
|
3897
|
+
if (validationError) {
|
|
3898
|
+
await reply(formatTelegramInvalidInstanceName(validationError));
|
|
3899
|
+
return;
|
|
3900
|
+
}
|
|
3901
|
+
if (!deps.renameCurrentThread) {
|
|
3902
|
+
await reply(
|
|
3903
|
+
formatTelegramInformationHeading(
|
|
3904
|
+
"🚫",
|
|
3905
|
+
"Thread display naming is unavailable.",
|
|
3906
|
+
),
|
|
3907
|
+
);
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
if (admission) admission.assertSemanticCurrent();
|
|
3911
|
+
else deps.assertExecutionCurrent?.(message);
|
|
3912
|
+
const target = getTelegramCommandMessageTarget(message);
|
|
3913
|
+
const result = admission
|
|
3914
|
+
? await deps.renameCurrentThread(target, threadName, {
|
|
3915
|
+
assertAuthority: admission.assertSemanticCurrent,
|
|
3916
|
+
})
|
|
3917
|
+
: await deps.renameCurrentThread(target, threadName);
|
|
3918
|
+
if (admission) admission.assertSemanticCurrent();
|
|
3919
|
+
else deps.assertExecutionCurrent?.(message);
|
|
3920
|
+
await reply(
|
|
3921
|
+
result.ok && !result.message
|
|
3922
|
+
? formatTelegramThreadDisplayNameSavedHeading(
|
|
3923
|
+
result.threadName ?? threadName,
|
|
3924
|
+
)
|
|
3925
|
+
: formatTelegramInformationHeading(
|
|
3926
|
+
result.ok ? "✅" : "⚠️",
|
|
3927
|
+
result.message ?? "Thread display name update failed.",
|
|
3928
|
+
),
|
|
3929
|
+
);
|
|
2059
3930
|
}
|
|
2060
3931
|
|
|
2061
3932
|
async function handleTelegramCommandRuntime<
|
|
@@ -2068,6 +3939,7 @@ async function handleTelegramCommandRuntime<
|
|
|
2068
3939
|
deps: TelegramCommandRuntimeDeps<TMessage, TContext>,
|
|
2069
3940
|
commandArgs = "",
|
|
2070
3941
|
): Promise<boolean> {
|
|
3942
|
+
deps.assertExecutionCurrent?.(message);
|
|
2071
3943
|
const assertExecutionCurrentFor = (nextMessage: TMessage) => (): void =>
|
|
2072
3944
|
deps.assertExecutionCurrent?.(nextMessage);
|
|
2073
3945
|
const sendReplyFor =
|
|
@@ -2098,69 +3970,8 @@ async function handleTelegramCommandRuntime<
|
|
|
2098
3970
|
sendTextReply: sendReplyFor(nextMessage),
|
|
2099
3971
|
});
|
|
2100
3972
|
},
|
|
2101
|
-
handleName:
|
|
2102
|
-
|
|
2103
|
-
if (!threadName) {
|
|
2104
|
-
if (deps.openThreadNameDialog) {
|
|
2105
|
-
await deps.openThreadNameDialog(nextMessage, _commandCtx);
|
|
2106
|
-
} else {
|
|
2107
|
-
await sendReplyFor(nextMessage)(
|
|
2108
|
-
formatTelegramInformationHeading("🏷️", "Usage: /name Navigator"),
|
|
2109
|
-
{ parseMode: "HTML" },
|
|
2110
|
-
);
|
|
2111
|
-
}
|
|
2112
|
-
return;
|
|
2113
|
-
}
|
|
2114
|
-
if (/^[A-Z]$/.test(threadName) && deps.resetCurrentThreadName) {
|
|
2115
|
-
const result = await deps.resetCurrentThreadName(
|
|
2116
|
-
getTelegramCommandMessageTarget(nextMessage),
|
|
2117
|
-
);
|
|
2118
|
-
await sendReplyFor(nextMessage)(
|
|
2119
|
-
result.ok && !result.message
|
|
2120
|
-
? formatTelegramAutomaticThreadDisplayNameRestoredHeading(
|
|
2121
|
-
result.threadName ?? threadName,
|
|
2122
|
-
)
|
|
2123
|
-
: formatTelegramInformationHeading(
|
|
2124
|
-
result.ok ? "✅" : "⚠️",
|
|
2125
|
-
result.message ?? "Thread display name reset failed.",
|
|
2126
|
-
),
|
|
2127
|
-
{ parseMode: "HTML" },
|
|
2128
|
-
);
|
|
2129
|
-
return;
|
|
2130
|
-
}
|
|
2131
|
-
const validationError = deps.validateThreadName?.(threadName);
|
|
2132
|
-
if (validationError) {
|
|
2133
|
-
await sendReplyFor(nextMessage)(
|
|
2134
|
-
formatTelegramInvalidInstanceName(validationError),
|
|
2135
|
-
{ parseMode: "HTML" },
|
|
2136
|
-
);
|
|
2137
|
-
return;
|
|
2138
|
-
}
|
|
2139
|
-
if (!deps.renameCurrentThread) {
|
|
2140
|
-
await sendReplyFor(nextMessage)(
|
|
2141
|
-
formatTelegramInformationHeading("🚫", "Thread display naming is unavailable."),
|
|
2142
|
-
{ parseMode: "HTML" },
|
|
2143
|
-
);
|
|
2144
|
-
return;
|
|
2145
|
-
}
|
|
2146
|
-
deps.assertExecutionCurrent?.(nextMessage);
|
|
2147
|
-
const result = await deps.renameCurrentThread(
|
|
2148
|
-
getTelegramCommandMessageTarget(nextMessage),
|
|
2149
|
-
threadName,
|
|
2150
|
-
);
|
|
2151
|
-
deps.assertExecutionCurrent?.(nextMessage);
|
|
2152
|
-
await sendReplyFor(nextMessage)(
|
|
2153
|
-
result.ok && !result.message
|
|
2154
|
-
? formatTelegramThreadDisplayNameSavedHeading(
|
|
2155
|
-
result.threadName ?? threadName,
|
|
2156
|
-
)
|
|
2157
|
-
: formatTelegramInformationHeading(
|
|
2158
|
-
result.ok ? "✅" : "⚠️",
|
|
2159
|
-
result.message ?? "Thread display name update failed.",
|
|
2160
|
-
),
|
|
2161
|
-
{ parseMode: "HTML" },
|
|
2162
|
-
);
|
|
2163
|
-
},
|
|
3973
|
+
handleName: (nextMessage, commandCtx, requestedName) =>
|
|
3974
|
+
handleTelegramNameCommand(nextMessage, commandCtx, requestedName, deps),
|
|
2164
3975
|
handleAbort: async (nextMessage, commandCtx) => {
|
|
2165
3976
|
await handleTelegramAbortCommand({
|
|
2166
3977
|
hasAbortHandler: deps.hasAbortHandler,
|
|
@@ -2316,50 +4127,11 @@ async function handleTelegramCommandRuntime<
|
|
|
2316
4127
|
}
|
|
2317
4128
|
: undefined,
|
|
2318
4129
|
handleHelp: async (nextMessage, nextCommandName, commandCtx) => {
|
|
2319
|
-
|
|
2320
|
-
nextMessage.from?.id !== undefined &&
|
|
2321
|
-
canPairTelegramUserFromCommandMessage(nextMessage)
|
|
2322
|
-
) {
|
|
2323
|
-
const allowed = await pairTelegramUserIfNeeded(nextMessage.from.id, {
|
|
2324
|
-
allowedUserId: deps.getAllowedUserId(),
|
|
2325
|
-
ctx: undefined,
|
|
2326
|
-
persistAllowedUserId: deps.persistAllowedUserId,
|
|
2327
|
-
updateStatus: updateStatusFor(commandCtx),
|
|
2328
|
-
assertExecutionCurrent: assertExecutionCurrentFor(nextMessage),
|
|
2329
|
-
});
|
|
2330
|
-
if (!allowed) return;
|
|
2331
|
-
}
|
|
2332
|
-
const isContextActive = () =>
|
|
2333
|
-
deps.isContextActive?.(commandCtx) !== false;
|
|
2334
|
-
scheduleTelegramCommandEffect(
|
|
2335
|
-
commandCtx,
|
|
4130
|
+
await handleTelegramHelpCommand(
|
|
2336
4131
|
nextCommandName,
|
|
2337
|
-
|
|
2338
|
-
deps,
|
|
2339
|
-
async () => {
|
|
2340
|
-
let forumBootstrapMessage: string | undefined;
|
|
2341
|
-
if (nextCommandName === "start" && deps.handleForumBootstrap) {
|
|
2342
|
-
forumBootstrapMessage = await deps.handleForumBootstrap(
|
|
2343
|
-
nextMessage,
|
|
2344
|
-
commandCtx,
|
|
2345
|
-
);
|
|
2346
|
-
}
|
|
2347
|
-
if (!isContextActive()) return;
|
|
2348
|
-
if (forumBootstrapMessage) {
|
|
2349
|
-
await deps.sendTextReply(nextMessage, forumBootstrapMessage);
|
|
2350
|
-
}
|
|
2351
|
-
if (!isContextActive()) return;
|
|
2352
|
-
await deps.showStatus(nextMessage, commandCtx);
|
|
2353
|
-
},
|
|
2354
|
-
assertExecutionCurrentFor(nextMessage),
|
|
2355
|
-
);
|
|
2356
|
-
scheduleTelegramCommandEffect(
|
|
4132
|
+
nextMessage,
|
|
2357
4133
|
commandCtx,
|
|
2358
|
-
nextCommandName,
|
|
2359
|
-
"bot-command-sync",
|
|
2360
4134
|
deps,
|
|
2361
|
-
deps.registerBotCommands,
|
|
2362
|
-
assertExecutionCurrentFor(nextMessage),
|
|
2363
4135
|
);
|
|
2364
4136
|
},
|
|
2365
4137
|
},
|
|
@@ -2395,8 +4167,9 @@ export interface TelegramSessionActionRuntimeDeps {
|
|
|
2395
4167
|
export interface TelegramSessionReplacementSettlementDeps {
|
|
2396
4168
|
getIntent: () => Promise<TelegramSessionReplacementIntent | undefined>;
|
|
2397
4169
|
hasSuccessorContinuity: (intent: TelegramSessionReplacementIntent) => boolean;
|
|
2398
|
-
editSuccess: (
|
|
2399
|
-
|
|
4170
|
+
editSuccess: (
|
|
4171
|
+
intent: TelegramSessionReplacementIntent,
|
|
4172
|
+
) => Promise<{ ok: boolean; retryable?: boolean; message?: string }>;
|
|
2400
4173
|
clearIntent: (intent: TelegramSessionReplacementIntent) => Promise<boolean>;
|
|
2401
4174
|
profileName: string | undefined;
|
|
2402
4175
|
cwd: string;
|
|
@@ -2410,18 +4183,20 @@ export async function settleTelegramSessionReplacement(
|
|
|
2410
4183
|
deps: TelegramSessionReplacementSettlementDeps,
|
|
2411
4184
|
): Promise<"none" | "settled" | "expired" | "failed" | "stale"> {
|
|
2412
4185
|
const now = deps.now ?? Date.now;
|
|
2413
|
-
const sleep =
|
|
2414
|
-
|
|
4186
|
+
const sleep =
|
|
4187
|
+
deps.sleep ??
|
|
4188
|
+
((delayMs) => new Promise<void>((resolve) => setTimeout(resolve, delayMs)));
|
|
2415
4189
|
while (deps.isCurrent?.() !== false) {
|
|
2416
4190
|
const intent = await deps.getIntent();
|
|
2417
4191
|
if (!intent || intent.sourceSessionId === deps.sessionId) return "none";
|
|
2418
|
-
if (intent.profileName !== deps.profileName || intent.cwd !== deps.cwd)
|
|
4192
|
+
if (intent.profileName !== deps.profileName || intent.cwd !== deps.cwd)
|
|
4193
|
+
return "stale";
|
|
2419
4194
|
if (now() >= intent.expiresAtMs) return "expired";
|
|
2420
4195
|
if (!deps.hasSuccessorContinuity(intent)) {
|
|
2421
4196
|
await sleep(100);
|
|
2422
4197
|
continue;
|
|
2423
4198
|
}
|
|
2424
|
-
if (!await deps.clearIntent(intent)) return "failed";
|
|
4199
|
+
if (!(await deps.clearIntent(intent))) return "failed";
|
|
2425
4200
|
do {
|
|
2426
4201
|
const delivered = await deps.editSuccess(intent);
|
|
2427
4202
|
if (delivered.ok) return "settled";
|
|
@@ -2434,8 +4209,12 @@ export async function settleTelegramSessionReplacement(
|
|
|
2434
4209
|
}
|
|
2435
4210
|
|
|
2436
4211
|
function createTelegramSessionReplacementSettlementRuntime<TContext>(deps: {
|
|
2437
|
-
resolve: (
|
|
2438
|
-
|
|
4212
|
+
resolve: (
|
|
4213
|
+
ctx: TContext,
|
|
4214
|
+
) => TelegramSessionReplacementSettlementDeps | undefined;
|
|
4215
|
+
onResult?: (
|
|
4216
|
+
result: "none" | "settled" | "expired" | "failed" | "stale",
|
|
4217
|
+
) => void;
|
|
2439
4218
|
onError?: (error: unknown) => void;
|
|
2440
4219
|
}): { onSessionStart: (ctx: TContext) => void } {
|
|
2441
4220
|
let generation = 0;
|
|
@@ -2446,8 +4225,8 @@ function createTelegramSessionReplacementSettlementRuntime<TContext>(deps: {
|
|
|
2446
4225
|
if (!resolved) return;
|
|
2447
4226
|
void settleTelegramSessionReplacement({
|
|
2448
4227
|
...resolved,
|
|
2449
|
-
isCurrent: () =>
|
|
2450
|
-
resolved.isCurrent?.() !== false,
|
|
4228
|
+
isCurrent: () =>
|
|
4229
|
+
currentGeneration === generation && resolved.isCurrent?.() !== false,
|
|
2451
4230
|
}).then(deps.onResult, deps.onError);
|
|
2452
4231
|
},
|
|
2453
4232
|
};
|
|
@@ -2462,11 +4241,18 @@ export interface TelegramSessionActionAssemblyDeps {
|
|
|
2462
4241
|
getWorkspaceBindingByTarget: (
|
|
2463
4242
|
target: { chatId: number; threadId?: number },
|
|
2464
4243
|
sessionId?: string,
|
|
2465
|
-
) =>
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
4244
|
+
) =>
|
|
4245
|
+
| {
|
|
4246
|
+
cwd: string;
|
|
4247
|
+
sessionId?: string;
|
|
4248
|
+
slot?: string;
|
|
4249
|
+
threadName?: string;
|
|
4250
|
+
manualThreadName?: string;
|
|
4251
|
+
target: { chatId: number; threadId: number };
|
|
4252
|
+
}
|
|
4253
|
+
| undefined;
|
|
4254
|
+
getSessionReplacementIntent: () =>
|
|
4255
|
+
TelegramSessionReplacementIntent | undefined;
|
|
2470
4256
|
commitSessionReplacementIntent: (
|
|
2471
4257
|
intent: TelegramSessionReplacementIntent,
|
|
2472
4258
|
isCurrent: () => boolean,
|
|
@@ -2507,14 +4293,18 @@ export function createTelegramSessionActionAssembly(
|
|
|
2507
4293
|
settlement: { onSessionStart: (ctx: Pi.ExtensionContext) => void };
|
|
2508
4294
|
} {
|
|
2509
4295
|
const now = deps.now ?? Date.now;
|
|
2510
|
-
const report = (error: unknown): void =>
|
|
4296
|
+
const report = (error: unknown): void =>
|
|
4297
|
+
deps.recordRuntimeEvent?.("new-session", error);
|
|
2511
4298
|
const sendTerminalResult = async (
|
|
2512
4299
|
target: { chatId: number; threadId?: number },
|
|
2513
4300
|
result: "success" | "cancelled" | "failure",
|
|
2514
4301
|
): Promise<void> => {
|
|
2515
|
-
const text =
|
|
2516
|
-
|
|
2517
|
-
|
|
4302
|
+
const text =
|
|
4303
|
+
result === "success"
|
|
4304
|
+
? "<b>🆕 New session started.</b>"
|
|
4305
|
+
: result === "cancelled"
|
|
4306
|
+
? "<b>🚫 New session cancelled.</b>"
|
|
4307
|
+
: "<b>⚠️ New session failed.</b>";
|
|
2518
4308
|
const deadline = now() + 10_000;
|
|
2519
4309
|
do {
|
|
2520
4310
|
const delivery = await deps.sendResult(target, text);
|
|
@@ -2527,21 +4317,28 @@ export function createTelegramSessionActionAssembly(
|
|
|
2527
4317
|
const action = createTelegramSessionActionRuntime({
|
|
2528
4318
|
registerCommand: deps.registerCommand,
|
|
2529
4319
|
sendUserMessage: deps.sendUserMessage,
|
|
2530
|
-
notifyResult(target, result) {
|
|
4320
|
+
notifyResult(target, result) {
|
|
4321
|
+
return sendTerminalResult(target, result);
|
|
4322
|
+
},
|
|
2531
4323
|
async prepareReplacement(ctx, updateId, target) {
|
|
2532
|
-
const follower =
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
4324
|
+
const follower =
|
|
4325
|
+
!deps.ownsPersistence() &&
|
|
4326
|
+
typeof target.threadId === "number" &&
|
|
4327
|
+
deps.follower?.isRegisteredFor(target)
|
|
4328
|
+
? deps.follower
|
|
4329
|
+
: undefined;
|
|
2536
4330
|
// Follower memory is not authority; reread the leader-published snapshot.
|
|
2537
4331
|
if (follower && deps.store.refresh) await deps.store.refresh();
|
|
2538
4332
|
else await deps.store.load();
|
|
2539
4333
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
2540
|
-
const binding =
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
4334
|
+
const binding =
|
|
4335
|
+
typeof target.threadId === "number"
|
|
4336
|
+
? deps.store.getWorkspaceBindingByTarget(target)
|
|
4337
|
+
: undefined;
|
|
4338
|
+
if (
|
|
4339
|
+
typeof target.threadId === "number" &&
|
|
4340
|
+
(!binding || binding.cwd !== ctx.cwd || binding.sessionId !== sessionId)
|
|
4341
|
+
) {
|
|
2545
4342
|
throw new Error("Telegram session replacement binding is unavailable.");
|
|
2546
4343
|
}
|
|
2547
4344
|
const createdAtMs = now();
|
|
@@ -2554,53 +4351,91 @@ export function createTelegramSessionActionAssembly(
|
|
|
2554
4351
|
target: binding ? { ...binding.target } : { chatId: target.chatId },
|
|
2555
4352
|
messageId: target.messageId,
|
|
2556
4353
|
...(binding?.slot ? { slot: binding.slot } : {}),
|
|
2557
|
-
...(binding?.manualThreadName ?? binding?.threadName
|
|
2558
|
-
? { threadName: binding.manualThreadName ?? binding.threadName }
|
|
4354
|
+
...((binding?.manualThreadName ?? binding?.threadName)
|
|
4355
|
+
? { threadName: binding.manualThreadName ?? binding.threadName }
|
|
4356
|
+
: {}),
|
|
2559
4357
|
createdAtMs,
|
|
2560
4358
|
expiresAtMs: createdAtMs + deps.handoffTtlMs,
|
|
2561
4359
|
...(follower ? { sourceInstanceId: follower.instanceId } : {}),
|
|
2562
4360
|
};
|
|
2563
|
-
if (
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
4361
|
+
if (
|
|
4362
|
+
!(await (follower
|
|
4363
|
+
? follower.requestSessionReplacement("publish", intent)
|
|
4364
|
+
: deps.store.commitSessionReplacementIntent(
|
|
4365
|
+
intent,
|
|
4366
|
+
deps.ownsPersistence,
|
|
4367
|
+
)))
|
|
4368
|
+
) {
|
|
4369
|
+
throw new Error(
|
|
4370
|
+
"Telegram session replacement intent was not persisted.",
|
|
4371
|
+
);
|
|
2567
4372
|
}
|
|
2568
4373
|
},
|
|
2569
4374
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
2570
4375
|
});
|
|
2571
|
-
const settlement =
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
4376
|
+
const settlement =
|
|
4377
|
+
createTelegramSessionReplacementSettlementRuntime<Pi.ExtensionContext>({
|
|
4378
|
+
resolve(ctx) {
|
|
4379
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
4380
|
+
if (!sessionId) return undefined;
|
|
4381
|
+
return {
|
|
4382
|
+
async getIntent() {
|
|
4383
|
+
await deps.store.refresh?.();
|
|
4384
|
+
return deps.store.getSessionReplacementIntent();
|
|
4385
|
+
},
|
|
4386
|
+
hasSuccessorContinuity(intent) {
|
|
4387
|
+
if (intent.continuity === "classic-chat") return true;
|
|
4388
|
+
if (
|
|
4389
|
+
deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)
|
|
4390
|
+
?.cwd !== intent.cwd
|
|
4391
|
+
)
|
|
4392
|
+
return false;
|
|
4393
|
+
// A follower successor claims only after its own re-registration is live.
|
|
4394
|
+
return (
|
|
4395
|
+
intent.sourceInstanceId === undefined ||
|
|
4396
|
+
deps.ownsPersistence() ||
|
|
4397
|
+
deps.follower?.isRegisteredFor(intent.target) === true
|
|
4398
|
+
);
|
|
4399
|
+
},
|
|
4400
|
+
editSuccess(intent) {
|
|
4401
|
+
return deps.sendResult(
|
|
4402
|
+
intent.target,
|
|
4403
|
+
"<b>🆕 New session started.</b>",
|
|
4404
|
+
);
|
|
4405
|
+
},
|
|
4406
|
+
async clearIntent(intent) {
|
|
4407
|
+
if (
|
|
4408
|
+
intent.sourceInstanceId === undefined ||
|
|
4409
|
+
deps.ownsPersistence()
|
|
4410
|
+
) {
|
|
4411
|
+
return deps.store.removeSessionReplacementIntent(
|
|
4412
|
+
intent,
|
|
4413
|
+
deps.ownsPersistence,
|
|
4414
|
+
);
|
|
4415
|
+
}
|
|
4416
|
+
return (
|
|
4417
|
+
(await deps.follower?.requestSessionReplacement(
|
|
4418
|
+
"settle",
|
|
4419
|
+
intent,
|
|
4420
|
+
)) ?? false
|
|
4421
|
+
);
|
|
4422
|
+
},
|
|
4423
|
+
profileName: deps.getProfileName() ?? "default",
|
|
4424
|
+
cwd: ctx.cwd,
|
|
4425
|
+
sessionId,
|
|
4426
|
+
};
|
|
4427
|
+
},
|
|
4428
|
+
onResult(result) {
|
|
4429
|
+
if (result === "expired" || result === "failed") {
|
|
4430
|
+
report(
|
|
4431
|
+
new Error(
|
|
4432
|
+
`Telegram session replacement successor settlement ${result}.`,
|
|
4433
|
+
),
|
|
4434
|
+
);
|
|
4435
|
+
}
|
|
4436
|
+
},
|
|
4437
|
+
onError: report,
|
|
4438
|
+
});
|
|
2604
4439
|
return { action, settlement };
|
|
2605
4440
|
}
|
|
2606
4441
|
|
|
@@ -2625,11 +4460,13 @@ export function createTelegramSessionActionRuntime(
|
|
|
2625
4460
|
deps: TelegramSessionActionRuntimeDeps,
|
|
2626
4461
|
): TelegramSessionActionRuntime {
|
|
2627
4462
|
let pendingUpdateId: number | undefined;
|
|
2628
|
-
let pendingTarget:
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
4463
|
+
let pendingTarget:
|
|
4464
|
+
| {
|
|
4465
|
+
chatId: number;
|
|
4466
|
+
threadId?: number;
|
|
4467
|
+
messageId: number;
|
|
4468
|
+
}
|
|
4469
|
+
| undefined;
|
|
2633
4470
|
let pendingAction: TelegramPendingInternalAction | undefined;
|
|
2634
4471
|
let registered = false;
|
|
2635
4472
|
|
|
@@ -2657,9 +4494,14 @@ export function createTelegramSessionActionRuntime(
|
|
|
2657
4494
|
switch (action.kind) {
|
|
2658
4495
|
case "replace-session":
|
|
2659
4496
|
try {
|
|
2660
|
-
await deps.prepareReplacement?.(
|
|
4497
|
+
await deps.prepareReplacement?.(
|
|
4498
|
+
ctx,
|
|
4499
|
+
action.updateId,
|
|
4500
|
+
action.target,
|
|
4501
|
+
);
|
|
2661
4502
|
const result = await ctx.newSession();
|
|
2662
|
-
if (result.cancelled)
|
|
4503
|
+
if (result.cancelled)
|
|
4504
|
+
await deps.notifyResult(action.target, "cancelled");
|
|
2663
4505
|
} catch (error) {
|
|
2664
4506
|
reportFailure(error);
|
|
2665
4507
|
await deps.notifyResult(action.target, "failure");
|
|
@@ -2670,7 +4512,8 @@ export function createTelegramSessionActionRuntime(
|
|
|
2670
4512
|
});
|
|
2671
4513
|
},
|
|
2672
4514
|
scheduleAfterUpdate(updateId, target) {
|
|
2673
|
-
if (pendingUpdateId !== undefined || pendingAction !== undefined)
|
|
4515
|
+
if (pendingUpdateId !== undefined || pendingAction !== undefined)
|
|
4516
|
+
return false;
|
|
2674
4517
|
pendingUpdateId = updateId;
|
|
2675
4518
|
pendingTarget = { ...target };
|
|
2676
4519
|
return true;
|