@llblab/pi-telegram 0.52.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/AGENTS.md +9 -7
  2. package/BACKLOG.md +1 -18
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +7 -11
  5. package/api/commands.ts +3 -0
  6. package/dist/api/commands.d.ts +1 -1
  7. package/dist/lib/activity-verbosity.d.ts +1 -5
  8. package/dist/lib/activity-verbosity.js +15 -42
  9. package/dist/lib/activity.d.ts +9 -0
  10. package/dist/lib/activity.js +39 -10
  11. package/dist/lib/agent-messages.js +7 -3
  12. package/dist/lib/bindings.d.ts +43 -5
  13. package/dist/lib/bindings.js +163 -20
  14. package/dist/lib/bus-api.d.ts +12 -0
  15. package/dist/lib/bus-api.js +182 -79
  16. package/dist/lib/bus-follower.d.ts +111 -31
  17. package/dist/lib/bus-follower.js +1324 -359
  18. package/dist/lib/bus-leader.d.ts +41 -91
  19. package/dist/lib/bus-leader.js +969 -637
  20. package/dist/lib/bus-transport.js +16 -9
  21. package/dist/lib/bus.d.ts +215 -18
  22. package/dist/lib/bus.js +1048 -327
  23. package/dist/lib/channel-posts.d.ts +5 -0
  24. package/dist/lib/channel-posts.js +434 -261
  25. package/dist/lib/command-templates.d.ts +26 -5
  26. package/dist/lib/command-templates.js +92 -176
  27. package/dist/lib/commands.d.ts +168 -82
  28. package/dist/lib/commands.js +1409 -291
  29. package/dist/lib/config.d.ts +1 -8
  30. package/dist/lib/config.js +34 -76
  31. package/dist/lib/delivery.d.ts +2 -2
  32. package/dist/lib/delivery.js +16 -16
  33. package/dist/lib/extension.js +639 -171
  34. package/dist/lib/generative-app-worker.mjs +19 -7
  35. package/dist/lib/generative-apps.d.ts +0 -5
  36. package/dist/lib/generative-apps.js +61 -49
  37. package/dist/lib/inbound.d.ts +5 -0
  38. package/dist/lib/inbound.js +35 -59
  39. package/dist/lib/journal.d.ts +33 -3
  40. package/dist/lib/journal.js +2063 -782
  41. package/dist/lib/lifecycle.d.ts +2 -6
  42. package/dist/lib/lifecycle.js +75 -58
  43. package/dist/lib/locks.d.ts +29 -3
  44. package/dist/lib/locks.js +289 -108
  45. package/dist/lib/logging.js +50 -14
  46. package/dist/lib/media.d.ts +22 -1
  47. package/dist/lib/media.js +46 -28
  48. package/dist/lib/menu-model.d.ts +19 -5
  49. package/dist/lib/menu-model.js +53 -11
  50. package/dist/lib/menu-queue.d.ts +5 -4
  51. package/dist/lib/menu-queue.js +45 -63
  52. package/dist/lib/menu-settings.d.ts +5 -4
  53. package/dist/lib/menu-settings.js +114 -78
  54. package/dist/lib/menu-status.d.ts +1 -1
  55. package/dist/lib/menu-status.js +8 -25
  56. package/dist/lib/menu-thinking.d.ts +6 -1
  57. package/dist/lib/menu-thinking.js +14 -21
  58. package/dist/lib/menu.d.ts +8 -61
  59. package/dist/lib/menu.js +92 -164
  60. package/dist/lib/model.d.ts +1 -1
  61. package/dist/lib/model.js +2 -16
  62. package/dist/lib/outbound-attachments.d.ts +6 -27
  63. package/dist/lib/outbound-attachments.js +45 -38
  64. package/dist/lib/outbound-buttons.js +22 -16
  65. package/dist/lib/outbound-markup.d.ts +0 -2
  66. package/dist/lib/outbound-markup.js +51 -64
  67. package/dist/lib/outbound-voice.js +6 -3
  68. package/dist/lib/outbound.d.ts +6 -6
  69. package/dist/lib/outbound.js +61 -51
  70. package/dist/lib/ownership.js +6 -2
  71. package/dist/lib/paths.d.ts +5 -0
  72. package/dist/lib/paths.js +37 -21
  73. package/dist/lib/pi.d.ts +2 -0
  74. package/dist/lib/pi.js +8 -1
  75. package/dist/lib/polling.d.ts +21 -37
  76. package/dist/lib/polling.js +59 -50
  77. package/dist/lib/preview.js +39 -19
  78. package/dist/lib/process-identity.d.ts +24 -0
  79. package/dist/lib/process-identity.js +133 -0
  80. package/dist/lib/prompts.js +2 -7
  81. package/dist/lib/queue.d.ts +14 -16
  82. package/dist/lib/queue.js +249 -126
  83. package/dist/lib/recovery.d.ts +2 -83
  84. package/dist/lib/recovery.js +20 -274
  85. package/dist/lib/replies.d.ts +16 -24
  86. package/dist/lib/replies.js +99 -116
  87. package/dist/lib/routing.d.ts +120 -7
  88. package/dist/lib/routing.js +4667 -1152
  89. package/dist/lib/runtime.d.ts +1 -11
  90. package/dist/lib/sections.d.ts +4 -29
  91. package/dist/lib/sections.js +7 -20
  92. package/dist/lib/skills.js +4 -3
  93. package/dist/lib/status.js +86 -57
  94. package/dist/lib/sync.d.ts +1 -3
  95. package/dist/lib/sync.js +67 -43
  96. package/dist/lib/target.d.ts +4 -0
  97. package/dist/lib/target.js +18 -0
  98. package/dist/lib/telegram-api.d.ts +18 -13
  99. package/dist/lib/telegram-api.js +148 -53
  100. package/dist/lib/text-groups.js +4 -39
  101. package/dist/lib/thread-cleanup-manager.d.ts +6 -27
  102. package/dist/lib/thread-cleanup-manager.js +369 -186
  103. package/dist/lib/thread-display.d.ts +1 -2
  104. package/dist/lib/thread-display.js +58 -38
  105. package/dist/lib/thread-naming.d.ts +28 -8
  106. package/dist/lib/thread-naming.js +175 -66
  107. package/dist/lib/thread-reconciler.d.ts +42 -16
  108. package/dist/lib/thread-reconciler.js +90 -50
  109. package/dist/lib/threads.d.ts +82 -20
  110. package/dist/lib/threads.js +2247 -855
  111. package/dist/lib/time-injection.js +1 -2
  112. package/dist/lib/turns.js +1 -19
  113. package/dist/lib/updates.d.ts +169 -80
  114. package/dist/lib/updates.js +2418 -641
  115. package/dist/lib/voice.d.ts +7 -22
  116. package/dist/lib/voice.js +7 -22
  117. package/dist/lib/wire.js +1 -1
  118. package/dist/lib/workspace-admission.d.ts +3 -1
  119. package/dist/lib/workspace-admission.js +425 -211
  120. package/dist/lib/workspace-identity.js +8 -13
  121. package/dist/lib/workspace-retirement.d.ts +1 -1
  122. package/dist/lib/workspace-retirement.js +328 -140
  123. package/dist/lib/workspace-slots.js +8 -4
  124. package/dist/package.json +1 -1
  125. package/docs/architecture.md +31 -29
  126. package/docs/inbound.md +1 -1
  127. package/docs/multi-instance-bus.md +15 -3
  128. package/docs/outbound.md +1 -1
  129. package/docs/public-api.md +29 -1
  130. package/docs/ui-style.md +25 -8
  131. package/lib/activity-verbosity.ts +59 -82
  132. package/lib/activity.ts +146 -102
  133. package/lib/agent-messages.ts +12 -5
  134. package/lib/bindings.ts +389 -82
  135. package/lib/bus-api.ts +263 -102
  136. package/lib/bus-follower.ts +2440 -601
  137. package/lib/bus-leader.ts +2209 -1290
  138. package/lib/bus-transport.ts +46 -12
  139. package/lib/bus.ts +1673 -444
  140. package/lib/channel-posts.ts +848 -327
  141. package/lib/command-templates.ts +152 -265
  142. package/lib/commands.ts +2398 -555
  143. package/lib/config.ts +177 -170
  144. package/lib/delivery.ts +41 -29
  145. package/lib/extension.ts +1206 -433
  146. package/lib/generative-app-worker.mjs +19 -7
  147. package/lib/generative-apps.ts +324 -211
  148. package/lib/inbound.ts +95 -97
  149. package/lib/journal.ts +5311 -1763
  150. package/lib/keyboard.ts +1 -3
  151. package/lib/lifecycle.ts +128 -78
  152. package/lib/locks.ts +677 -203
  153. package/lib/logging.ts +147 -58
  154. package/lib/media.ts +82 -43
  155. package/lib/menu-model.ts +85 -18
  156. package/lib/menu-queue.ts +88 -129
  157. package/lib/menu-settings.ts +260 -155
  158. package/lib/menu-status.ts +20 -60
  159. package/lib/menu-thinking.ts +41 -56
  160. package/lib/menu.ts +112 -285
  161. package/lib/model.ts +8 -22
  162. package/lib/outbound-attachments.ts +156 -125
  163. package/lib/outbound-buttons.ts +76 -49
  164. package/lib/outbound-markup.ts +96 -94
  165. package/lib/outbound-voice.ts +33 -27
  166. package/lib/outbound.ts +125 -125
  167. package/lib/ownership.ts +19 -10
  168. package/lib/paths.ts +148 -49
  169. package/lib/pi.ts +35 -15
  170. package/lib/polling.ts +144 -147
  171. package/lib/preview.ts +93 -40
  172. package/lib/process-identity.ts +164 -0
  173. package/lib/prompts.ts +4 -11
  174. package/lib/queue.ts +511 -300
  175. package/lib/recovery.ts +50 -454
  176. package/lib/rendering.ts +1 -3
  177. package/lib/replies.ts +248 -209
  178. package/lib/routing.ts +8509 -2186
  179. package/lib/runtime.ts +1 -12
  180. package/lib/sections.ts +24 -89
  181. package/lib/setup.ts +1 -3
  182. package/lib/skills.ts +9 -4
  183. package/lib/status.ts +215 -110
  184. package/lib/sync.ts +448 -361
  185. package/lib/target.ts +22 -3
  186. package/lib/telegram-api.ts +302 -127
  187. package/lib/text-groups.ts +5 -39
  188. package/lib/thread-cleanup-manager.ts +833 -331
  189. package/lib/thread-display.ts +213 -97
  190. package/lib/thread-naming.ts +232 -87
  191. package/lib/thread-reconciler.ts +155 -72
  192. package/lib/threads.ts +5318 -1845
  193. package/lib/time-injection.ts +1 -4
  194. package/lib/turns.ts +15 -28
  195. package/lib/updates.ts +5184 -1612
  196. package/lib/voice.ts +7 -35
  197. package/lib/wire.ts +1 -1
  198. package/lib/workspace-admission.ts +996 -416
  199. package/lib/workspace-identity.ts +14 -17
  200. package/lib/workspace-retirement.ts +878 -376
  201. package/lib/workspace-slots.ts +22 -13
  202. package/package.json +1 -1
  203. package/scripts/check-downgrade.mjs +0 -472
  204. 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 { escapeHtml } from "./rendering.ts";
17
- import { formatTelegramConnectionFailure, type TelegramBridgeStatusLineOptions } from "./status.ts";
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) => `• ${escapeHtml(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
- formatTelegramInformationHeading(
240
- getTelegramCommandEmoji("compact"),
241
- "Compaction started.",
242
- );
243
- const TELEGRAM_COMPACTION_COMPLETED_TEXT =
244
- formatTelegramInformationHeading("✅", "Compaction completed.");
245
- export const TELEGRAM_COMPACTION_STARTED_MARKDOWN =
246
- `**${formatTelegramCommandEmojiPrefix("compact")}Compaction started.**`;
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
- command: "start",
261
- description: formatTelegramBotCommandDescription(
262
- "start",
263
- "Open menu / Pair bridge",
264
- ),
265
- },
266
- {
267
- command: "compact",
268
- description: formatTelegramBotCommandDescription(
269
- "compact",
270
- "Compact current session",
271
- ),
272
- },
273
- {
274
- command: "new",
275
- description: formatTelegramBotCommandDescription(
276
- "new",
277
- "Start a new session",
278
- ),
279
- },
280
- {
281
- command: "continue",
282
- description: formatTelegramBotCommandDescription(
283
- "continue",
284
- "Queue continue prompt",
285
- ),
286
- },
287
- {
288
- command: "next",
289
- description: formatTelegramBotCommandDescription(
290
- "next",
291
- "Force next turn",
292
- ),
293
- },
294
- {
295
- command: "abort",
296
- description: formatTelegramBotCommandDescription("abort", "Abort Pi"),
297
- },
298
- {
299
- command: "stop",
300
- description: formatTelegramBotCommandDescription(
301
- "stop",
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 deps.setMyCommands(TELEGRAM_BOT_COMMANDS);
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 deps.setMyCommands([...TELEGRAM_BOT_COMMANDS, ...extensionCommands]);
572
+ await setMyCommands([...TELEGRAM_BOT_COMMANDS, ...extensionCommands]);
344
573
  return;
345
574
  }
346
- await deps.setMyCommands([
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
- ): () => Promise<void> {
356
- let pending: Promise<void> | undefined;
357
- return () => {
358
- if (pending) return pending;
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(deps).finally(() => {
361
- if (pending === request) pending = undefined;
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 = request;
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: (ctx: ExtensionCommandContext, profileName?: string) => Promise<void>;
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?: (ctx: ExtensionCommandContext, isCurrent: () => boolean) => Promise<void>;
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) { current = reset; },
443
- async reset(target) {
444
- return current
445
- ? current(target)
446
- : { ok: false, message: "Thread display name reset is unavailable." };
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
- if (!current) {
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
- return current(target, threadName);
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 || deps.getSessionGeneration?.() === sessionGeneration) &&
815
+ (sessionGeneration === undefined ||
816
+ deps.getSessionGeneration?.() === sessionGeneration) &&
523
817
  deps.isContextCurrent?.(ctx) !== false;
524
818
  if (!isCurrent()) return;
525
- if (args.trim().split(/\s+/).some((word) => /^as=/i.test(word))) {
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(ctx, profileName, isCurrent);
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) ?? deps.reloadConfig());
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
- let recoveryUsed = false;
565
- const startWithRecovery = async (
868
+ const startPolling = async (
566
869
  options: TelegramBridgeCommandStartPollingOptions,
567
- ): Promise<void | (TelegramBridgeCommandStartPollingResult & { notice?: string })> => {
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
- if (!deps.recoverPollingStart || recoveryUsed) throw error;
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 startWithRecovery({ forceFreshLeaderThread: true });
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 startWithRecovery({ force: true, forceFreshLeaderThread: true });
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) deps.recordConnectionEvent?.(result.message, "connect-refused");
621
- ctx.ui.notify(result.notice ?? formatTelegramConnectionFailure(result.message), "warning");
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 || deps.getSessionGeneration?.() === generation) &&
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("Telegram disconnect incomplete; keep Pi open. Check /telegram-status --debug.", "warning");
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
- export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorderPort {
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?: { target?: { chatId: number; threadId?: number } },
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: (message: TMessage, ctx: TContext) => Promise<void>;
939
- openModelMenu: (message: TMessage, ctx: TContext) => Promise<void>;
940
- openSettingsMenu: (message: TMessage, ctx: TContext) => Promise<void>;
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?: { parseMode?: "HTML" },
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: (options: {
993
- chatId: number;
994
- target?: { chatId: number; threadId?: number };
995
- replyToMessageId: number;
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
- TelegramCommandControlQueueRuntimeDeps<TContext> &
1032
- Omit<TelegramCommandTargetRuntimeDeps<TContext>, "enqueueControlItem">;
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: createTelegramCommandControlQueueRuntime({
1042
- createControlItem: deps.createControlItem,
1043
- appendControlItem: deps.appendControlItem,
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
- await deps.sendTextReply(target.chatId, target.replyToMessageId, text, {
1134
- ...options,
1135
- target,
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 TelegramCommandOrPromptRuntimeDeps<TMessage, TContext> {
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
- > extends TelegramRuntimeEventRecorderPort {
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: (message: TMessage, ctx: TContext) => Promise<void>;
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: (message: TMessage, ctx: TContext) => Promise<void>;
1218
- openThinkingMenu: (message: TMessage, ctx: TContext) => Promise<void>;
1219
- openQueueMenu: (message: TMessage, ctx: TContext) => Promise<void>;
1220
- openSettingsMenu?: (message: TMessage, ctx: TContext) => Promise<void>;
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
- ) => Promise<void>;
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: () => Promise<void>;
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?: { parseMode?: "HTML" },
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
- export const TELEGRAM_APP_MENU_INTRO_HTML = [
1245
- "<b>Pi Telegram</b>",
1246
- "",
1247
- `${formatTelegramCommandEmojiPrefix("start")}/start — Open menu / Pair bridge`,
1248
- `${formatTelegramCommandEmojiPrefix("compact")}/compact — Compact current session`,
1249
- `${formatTelegramCommandEmojiPrefix("new")}/new — Start a new session`,
1250
- `${formatTelegramCommandEmojiPrefix("continue")}/continue — Queue continue prompt`,
1251
- `${formatTelegramCommandEmojiPrefix("next")}/next — Force next turn`,
1252
- `${formatTelegramCommandEmojiPrefix("abort")}/abort — Abort Pi`,
1253
- `${formatTelegramCommandEmojiPrefix("stop")}/stop — Abort Pi & Clear queue`,
1254
- ].join("\n");
1255
-
1256
- function escapeTelegramCommandMenuHtml(text: string): string {
1257
- return text
1258
- .replace(/&/g, "&amp;")
1259
- .replace(/</g, "&lt;")
1260
- .replace(/>/g, "&gt;");
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) => `🧩 /${escapeTelegramCommandMenuHtml(template.command)}`)
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 = `${escapeTelegramCommandMenuHtml(command.emoji ?? "")} /${escapeTelegramCommandMenuHtml(command.name)}`;
1623
+ const prefix = `${escapeHtml(command.emoji ?? "")} /${escapeHtml(command.name)}`;
1277
1624
  if (!command.description) return prefix;
1278
- return `${prefix} — ${escapeTelegramCommandMenuHtml(command.description)}`;
1625
+ return `${prefix} — ${escapeHtml(command.description)}`;
1279
1626
  });
1280
1627
  }
1281
1628
 
1282
1629
  function buildTelegramAppMenuIntroHtml(): string {
1283
- const extensionLines = buildTelegramExtensionCommandMenuLines();
1284
- if (extensionLines.length === 0) return TELEGRAM_APP_MENU_INTRO_HTML;
1285
- return [
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 deps.sendInteractiveMessage(
1553
- target.chatId,
1913
+ await sendTelegramConfirmation(
1914
+ target,
1915
+ deps,
1554
1916
  getTelegramNewConfirmationHtml(),
1555
- "html",
1556
1917
  buildTelegramNewConfirmationReplyMarkup(),
1557
- target.threadId !== undefined
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, "⌛ Interactive message expired.");
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 deps.sendInteractiveMessage(
1611
- target.chatId,
1970
+ await sendTelegramConfirmation(
1971
+ target,
1972
+ deps,
1612
1973
  getTelegramCompactConfirmationHtml(),
1613
- "html",
1614
1974
  buildTelegramCompactConfirmationReplyMarkup(),
1615
- target.threadId !== undefined
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, "⌛ Interactive message expired.");
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 extends TelegramRuntimeEventRecorderPort {
1665
- isIdle: () => boolean;
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 (!isTelegramStaleContextError(error)) throw error;
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
- return async (
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(commandName, message, ctx, deps, commandArgs);
2367
+ return handleTelegramCommandRuntime(
2368
+ commandName,
2369
+ message,
2370
+ ctx,
2371
+ deps,
2372
+ commandArgs,
2373
+ );
1972
2374
  };
1973
- }
1974
-
1975
- export function createTelegramCommandOrPromptRuntime<TMessage, TContext>(
1976
- deps: TelegramCommandOrPromptRuntimeDeps<TMessage, TContext>,
1977
- ) {
1978
- return {
1979
- dispatchMessages: async (
1980
- messages: TMessage[],
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
- ): Promise<void> => {
1983
- const firstMessage = messages[0];
1984
- if (!firstMessage) return;
1985
- if (deps.shouldIgnoreMessages?.(messages)) return;
1986
- deps.assertExecutionCurrent?.(firstMessage);
1987
- if (await deps.consumeThreadNameInput?.(messages, ctx)) {
1988
- deps.assertExecutionCurrent?.(firstMessage);
1989
- return;
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
- const command = parseTelegramCommand(deps.extractRawText(messages));
1992
- const handled = await deps.handleCommand(
1993
- command?.name,
1994
- firstMessage,
1995
- ctx,
1996
- command?.args,
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
- deps.assertExecutionCurrent?.(firstMessage);
1999
- if (handled) return;
2000
- if (command && deps.executeExtensionCommand) {
2001
- const handledByExtension = await deps.executeExtensionCommand(
2002
- command,
2003
- messages[0]!,
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
- deps.assertExecutionCurrent?.(firstMessage);
2007
- if (handledByExtension) return;
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
- if (command?.name && deps.expandPromptTemplateCommand) {
2010
- const expanded = deps.expandPromptTemplateCommand(
2011
- command.name,
2012
- command.args,
2013
- );
2014
- if (expanded !== undefined) {
2015
- deps.assertExecutionCurrent?.(firstMessage);
2016
- await deps.enqueueTurn(
2017
- [
2018
- deps.replaceMessageText(firstMessage, expanded),
2019
- ...messages.slice(1),
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
- return;
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
- deps.assertExecutionCurrent?.(firstMessage);
2027
- await deps.enqueueTurn(messages, ctx);
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
- isContextActive?: (ctx: TContext) => boolean;
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: async (nextMessage, _commandCtx, requestedName) => {
2102
- const threadName = requestedName.trim();
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
- if (
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
- "menu-render",
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: (intent: TelegramSessionReplacementIntent) =>
2399
- Promise<{ ok: boolean; retryable?: boolean; message?: string }>;
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 = deps.sleep ?? ((delayMs) =>
2414
- new Promise<void>((resolve) => setTimeout(resolve, delayMs)));
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) return "stale";
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: (ctx: TContext) => TelegramSessionReplacementSettlementDeps | undefined;
2438
- onResult?: (result: "none" | "settled" | "expired" | "failed" | "stale") => void;
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: () => currentGeneration === generation &&
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
- cwd: string; sessionId?: string; slot?: string; threadName?: string;
2467
- manualThreadName?: string; target: { chatId: number; threadId: number };
2468
- } | undefined;
2469
- getSessionReplacementIntent: () => TelegramSessionReplacementIntent | undefined;
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 => deps.recordRuntimeEvent?.("new-session", error);
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 = result === "success" ? "<b>🆕 New session started.</b>"
2516
- : result === "cancelled" ? "<b>🚫 New session cancelled.</b>"
2517
- : "<b>⚠️ New session failed.</b>";
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) { return sendTerminalResult(target, result); },
4320
+ notifyResult(target, result) {
4321
+ return sendTerminalResult(target, result);
4322
+ },
2531
4323
  async prepareReplacement(ctx, updateId, target) {
2532
- const follower = !deps.ownsPersistence() && typeof target.threadId === "number" &&
2533
- deps.follower?.isRegisteredFor(target)
2534
- ? deps.follower
2535
- : undefined;
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 = typeof target.threadId === "number"
2541
- ? deps.store.getWorkspaceBindingByTarget(target)
2542
- : undefined;
2543
- if (typeof target.threadId === "number" &&
2544
- (!binding || binding.cwd !== ctx.cwd || binding.sessionId !== sessionId)) {
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 (!await (follower
2564
- ? follower.requestSessionReplacement("publish", intent)
2565
- : deps.store.commitSessionReplacementIntent(intent, deps.ownsPersistence))) {
2566
- throw new Error("Telegram session replacement intent was not persisted.");
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 = createTelegramSessionReplacementSettlementRuntime<Pi.ExtensionContext>({
2572
- resolve(ctx) {
2573
- const sessionId = ctx.sessionManager?.getSessionId?.();
2574
- if (!sessionId) return undefined;
2575
- return {
2576
- async getIntent() { await deps.store.refresh?.(); return deps.store.getSessionReplacementIntent(); },
2577
- hasSuccessorContinuity(intent) {
2578
- if (intent.continuity === "classic-chat") return true;
2579
- if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
2580
- intent.cwd) return false;
2581
- // A follower successor claims only after its own re-registration is live.
2582
- return intent.sourceInstanceId === undefined || deps.ownsPersistence() ||
2583
- deps.follower?.isRegisteredFor(intent.target) === true;
2584
- },
2585
- editSuccess(intent) { return deps.sendResult(intent.target, "<b>🆕 New session started.</b>"); },
2586
- async clearIntent(intent) {
2587
- if (intent.sourceInstanceId === undefined || deps.ownsPersistence()) {
2588
- return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence);
2589
- }
2590
- return await deps.follower?.requestSessionReplacement("settle", intent) ?? false;
2591
- },
2592
- profileName: deps.getProfileName() ?? "default",
2593
- cwd: ctx.cwd,
2594
- sessionId,
2595
- };
2596
- },
2597
- onResult(result) {
2598
- if (result === "expired" || result === "failed") {
2599
- report(new Error(`Telegram session replacement successor settlement ${result}.`));
2600
- }
2601
- },
2602
- onError: report,
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
- chatId: number;
2630
- threadId?: number;
2631
- messageId: number;
2632
- } | undefined;
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?.(ctx, action.updateId, action.target);
4497
+ await deps.prepareReplacement?.(
4498
+ ctx,
4499
+ action.updateId,
4500
+ action.target,
4501
+ );
2661
4502
  const result = await ctx.newSession();
2662
- if (result.cancelled) await deps.notifyResult(action.target, "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) return false;
4515
+ if (pendingUpdateId !== undefined || pendingAction !== undefined)
4516
+ return false;
2674
4517
  pendingUpdateId = updateId;
2675
4518
  pendingTarget = { ...target };
2676
4519
  return true;