@llblab/pi-telegram 0.42.3 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bus.ts CHANGED
@@ -1450,18 +1450,25 @@ export function createTelegramBusFollowerThreadRestoreHandler(
1450
1450
  return async ({ record, target, oldTarget }) => {
1451
1451
  if (!record.instanceId) return false;
1452
1452
  const follower = deps.followerRegistry.get(record.instanceId);
1453
- if (!follower) return false;
1453
+ if (!follower?.registrationGeneration || !oldTarget ||
1454
+ follower.target?.chatId !== oldTarget.chatId ||
1455
+ follower.target.threadId !== oldTarget.threadId ||
1456
+ target.chatId !== oldTarget.chatId || target.threadId === oldTarget.threadId) return false;
1454
1457
  const replaced = await deps.followerTargetController.replaceTarget({
1455
1458
  follower,
1456
1459
  target,
1457
1460
  oldTarget,
1458
1461
  reason: "thread-restore",
1459
1462
  });
1460
- if (!replaced) return false;
1463
+ const current = deps.followerRegistry.get(record.instanceId);
1464
+ if (!replaced || !current ||
1465
+ current.registrationGeneration !== follower.registrationGeneration ||
1466
+ current.target?.chatId !== oldTarget.chatId ||
1467
+ current.target.threadId !== oldTarget.threadId) return false;
1461
1468
  deps.followerRegistry.register({
1462
- ...follower,
1469
+ ...current,
1463
1470
  target,
1464
- connectedAtMs: follower.connectedAtMs,
1471
+ connectedAtMs: current.connectedAtMs,
1465
1472
  });
1466
1473
  deps.onRestored?.();
1467
1474
  return true;
package/lib/keyboard.ts CHANGED
@@ -9,11 +9,13 @@ export type TelegramInlineKeyboardButtonStyle =
9
9
  | "success"
10
10
  | "primary";
11
11
 
12
- export interface TelegramInlineKeyboardButton {
12
+ export type TelegramInlineKeyboardButton = {
13
13
  text: string;
14
- callback_data: string;
15
14
  style?: TelegramInlineKeyboardButtonStyle;
16
- }
15
+ } & (
16
+ | { callback_data: string; disabled?: never }
17
+ | { disabled: Record<string, never>; callback_data?: never }
18
+ );
17
19
 
18
20
  export interface TelegramInlineKeyboardMarkup {
19
21
  inline_keyboard: TelegramInlineKeyboardButton[][];
@@ -12,6 +12,8 @@ import type {
12
12
  } from "./keyboard.ts";
13
13
  import {
14
14
  parseTelegramActionPayloadRows,
15
+ parseTelegramButtonPayloadRows,
16
+ replaceTelegramButtonFences,
15
17
  replaceTopLevelHtmlComments,
16
18
  } from "./outbound-markup.ts";
17
19
  import {
@@ -34,6 +36,7 @@ export interface TelegramOutboundButtonAction {
34
36
  prompt: string;
35
37
  binding?: TelegramOutboundButtonBinding;
36
38
  selectedStyle?: TelegramInlineKeyboardButtonStyle;
39
+ disabled?: true;
37
40
  }
38
41
 
39
42
  export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
@@ -115,6 +118,12 @@ function parseTelegramButtonAction(
115
118
  const explicitLabel = getTelegramButtonString(payload, "label");
116
119
  const explicitPrompt = getTelegramButtonString(payload, "prompt");
117
120
  const label = explicitLabel ?? value ?? explicitPrompt;
121
+ if (payload.disabled !== undefined && typeof payload.disabled !== "boolean") {
122
+ return undefined;
123
+ }
124
+ if (payload.disabled === true) {
125
+ return { text: label ?? "", prompt: "", disabled: true };
126
+ }
118
127
  const prompt = explicitPrompt ?? value ?? explicitLabel;
119
128
  if (!label || !prompt) return undefined;
120
129
  const selectedStyle = payload.selected_style;
@@ -159,6 +168,7 @@ export function createTelegramButtonActionStore(
159
168
  return {
160
169
  text: action.text,
161
170
  prompt: action.prompt,
171
+ ...(action.disabled ? { disabled: true as const } : {}),
162
172
  ...(action.binding ? { binding: action.binding } : {}),
163
173
  ...(action.selectedStyle
164
174
  ? { selectedStyle: action.selectedStyle }
@@ -171,33 +181,75 @@ export function createTelegramButtonActionStore(
171
181
  const DEFAULT_TELEGRAM_BUTTON_REPLY_MARKDOWN =
172
182
  "☑️ **Choose an option:**";
173
183
 
184
+ function escapeTelegramRichButtonText(text: string): string {
185
+ return text.replace(/[&<>"'`\\*_\[\]{}$~\r\n]/g, (character) =>
186
+ `&#${character.charCodeAt(0)};`,
187
+ );
188
+ }
189
+
190
+ function renderTelegramRichButtonRow(
191
+ row: TelegramOutboundButtonMarkup["inline_keyboard"][number],
192
+ ): string {
193
+ return `<tg-button-row>${row.map((button) => {
194
+ const attributes = button.disabled
195
+ ? 'type="disabled"'
196
+ : `type="callback_data" data="${escapeTelegramRichButtonText(button.callback_data)}"`;
197
+ return `<tg-button ${attributes}>${escapeTelegramRichButtonText(button.text)}</tg-button>`;
198
+ }).join("")}</tg-button-row>`;
199
+ }
200
+
174
201
  export function planTelegramButtonReply(
175
202
  markdown: string,
176
203
  deps: {
177
204
  registerAction: (action: TelegramOutboundButtonAction) => string;
178
205
  binding?: TelegramOutboundButtonBinding;
206
+ rendering?: "rich" | "html";
179
207
  },
180
208
  ): TelegramButtonReplyPlan {
181
209
  const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
182
- const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
210
+ const buildRows = (
211
+ payloadRows: Record<string, unknown>[][],
212
+ rich: boolean,
213
+ ): TelegramOutboundButtonMarkup["inline_keyboard"] | undefined => {
214
+ const actions = payloadRows.map((row) => row.map(parseTelegramButtonAction));
215
+ if (actions.some((row) => row.some((action) => !action))) return undefined;
216
+ if (rich && actions.some((row) => {
217
+ const projected = row.map((action) => action!.disabled
218
+ ? { text: action!.text || "\u00a0", disabled: {} }
219
+ : { text: action!.text, callback_data: "x".repeat(64) });
220
+ return row.length > 8 || renderTelegramRichButtonRow(projected).length > 32768;
221
+ })) return undefined;
222
+ return actions.map((row) => row.map((action) => action!.disabled
223
+ ? { text: action!.text || "\u00a0", disabled: {} }
224
+ : {
225
+ text: action!.text,
226
+ callback_data: deps.registerAction({
227
+ ...action!,
228
+ ...(deps.binding ? { binding: deps.binding } : {}),
229
+ }),
230
+ }));
231
+ };
232
+ const withRichButtons = replaceTelegramButtonFences(markdown, (payload, closed) => {
233
+ if (!closed) return "";
234
+ const payloadRows = parseTelegramButtonPayloadRows(payload);
235
+ if (!payloadRows) return "";
236
+ const rich = deps.rendering !== "html";
237
+ const rows = buildRows(payloadRows, rich);
238
+ if (!rows) return "";
239
+ if (!rich) {
240
+ keyboard.push(...rows);
241
+ return "";
242
+ }
243
+ return `\n${rows.map(renderTelegramRichButtonRow).join("\n\n")}\n`;
244
+ });
245
+ const stripped = replaceTopLevelHtmlComments(withRichButtons, (comment) => {
183
246
  const command = "telegram_button";
184
247
  const normalizedContent = comment.content.replace(/^\s+/, "").replace(/^!/, "");
185
248
  if (!normalizedContent.startsWith(command)) return comment.raw;
186
249
  const payloadRows = parseTelegramActionPayloadRows(comment, command);
187
250
  if (!payloadRows) return "";
188
- const actionRows = payloadRows.map((payloadRow) =>
189
- payloadRow.map(parseTelegramButtonAction),
190
- );
191
- if (actionRows.some((row) => row.some((action) => !action))) return "";
192
- for (const actionRow of actionRows) {
193
- keyboard.push(actionRow.map((action) => ({
194
- text: action!.text,
195
- callback_data: deps.registerAction({
196
- ...action!,
197
- ...(deps.binding ? { binding: deps.binding } : {}),
198
- }),
199
- })));
200
- }
251
+ const rows = buildRows(payloadRows, false);
252
+ if (rows) keyboard.push(...rows);
201
253
  return "";
202
254
  });
203
255
  const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(stripped);
@@ -254,7 +306,7 @@ export function markTelegramButtonSelected(
254
306
  let matched = false;
255
307
  const inlineKeyboard = replyMarkup.inline_keyboard.map((row) =>
256
308
  row.map((button) => {
257
- if (button.callback_data !== callbackData) return { ...button };
309
+ if (button.disabled || button.callback_data !== callbackData) return { ...button };
258
310
  matched = true;
259
311
  return { ...button, style: selectedStyle };
260
312
  }),
@@ -277,6 +329,11 @@ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
277
329
  return false;
278
330
  }
279
331
 
332
+ if (action.disabled) {
333
+ await deps.answerCallbackQuery(query.id, "Button action unavailable.");
334
+ return true;
335
+ }
336
+
280
337
  const chatId = query.message?.chat?.id;
281
338
  const messageId = query.message?.message_id;
282
339
  if (typeof chatId !== "number" || typeof messageId !== "number") {
@@ -90,6 +90,50 @@ export function collectTopLevelHtmlComments(markdown: string): {
90
90
  return { comments };
91
91
  }
92
92
 
93
+ export function replaceTelegramButtonFences(
94
+ markdown: string,
95
+ replace: (payload: string, closed: boolean) => string,
96
+ ): string {
97
+ let result = "";
98
+ let copied = 0;
99
+ let offset = 0;
100
+ let fence: TelegramTopLevelFenceState | undefined;
101
+ let actionStart: number | undefined;
102
+ let contentStart = 0;
103
+ while (offset < markdown.length) {
104
+ const end = getMarkdownLineEnd(markdown, offset);
105
+ const line = getMarkdownLineText(markdown, offset, end);
106
+ if (fence) {
107
+ if (isTopLevelClosingFence(line, fence)) {
108
+ if (actionStart !== undefined) {
109
+ result += markdown.slice(copied, actionStart);
110
+ result += replace(markdown.slice(contentStart, offset), true) + "\n";
111
+ copied = end;
112
+ actionStart = undefined;
113
+ }
114
+ fence = undefined;
115
+ }
116
+ } else if (line.includes("<!--")) {
117
+ const close = markdown.indexOf("-->", offset + line.indexOf("<!--") + 4);
118
+ if (close < 0) break;
119
+ offset = getMarkdownLineEnd(markdown, close + 3);
120
+ continue;
121
+ } else {
122
+ fence = getTopLevelOpeningFence(line);
123
+ if (fence && /^```telegram_button[ \t]*$/.test(line)) {
124
+ actionStart = offset;
125
+ contentStart = end;
126
+ }
127
+ }
128
+ offset = end;
129
+ }
130
+ if (actionStart !== undefined) {
131
+ result += markdown.slice(copied, actionStart);
132
+ return result + replace(markdown.slice(contentStart), false);
133
+ }
134
+ return result + markdown.slice(copied);
135
+ }
136
+
93
137
  export function replaceTopLevelHtmlComments(
94
138
  markdown: string,
95
139
  replacer: (comment: TelegramTopLevelHtmlComment) => string,
@@ -275,17 +319,26 @@ type TelegramCompactActionPayloadParser = (
275
319
  function parseTelegramButtonCompactActionPayload(
276
320
  atoms: readonly string[],
277
321
  ): Record<string, unknown> | undefined {
278
- const [label, prompt, selectedStyle] = atoms;
322
+ const [label, prompt, selectedStyle, disabled] = atoms;
279
323
  if (atoms.length === 1) return label ? { value: label } : undefined;
280
- if (!prompt) return undefined;
324
+ const isDisabled = disabled === "1" || disabled === "true";
325
+ if (!prompt && !(atoms.length === 4 && isDisabled)) return undefined;
281
326
  const action = label ? { label, prompt } : { prompt };
282
327
  if (atoms.length === 2) return action;
283
328
  if (
284
329
  selectedStyle !== "primary" &&
285
330
  selectedStyle !== "success" &&
286
- selectedStyle !== "danger"
331
+ selectedStyle !== "danger" &&
332
+ !(atoms.length === 4 && selectedStyle === "")
287
333
  ) return undefined;
288
- return { ...action, selected_style: selectedStyle };
334
+ if (atoms.length === 4 && !isDisabled && disabled !== "0" && disabled !== "false") {
335
+ return undefined;
336
+ }
337
+ return {
338
+ ...action,
339
+ ...(selectedStyle ? { selected_style: selectedStyle } : {}),
340
+ ...(atoms.length === 4 ? { disabled: isDisabled } : {}),
341
+ };
289
342
  }
290
343
 
291
344
  function parseTelegramVoiceCompactActionPayload(
@@ -303,7 +356,7 @@ function parseTelegramVoiceCompactActionPayload(
303
356
  function parseTelegramAdaptiveActionPayloadRows(
304
357
  source: string,
305
358
  parseCompactPayload: TelegramCompactActionPayloadParser,
306
- options: { allowTrailing?: boolean } = {},
359
+ options: { allowTrailing?: boolean; maxCompactAtoms?: number } = {},
307
360
  ): { rows: Record<string, unknown>[][]; end: number } | undefined {
308
361
  let offset = 0;
309
362
  const isStructuralWhitespace = (character: string | undefined): boolean =>
@@ -343,7 +396,7 @@ function parseTelegramAdaptiveActionPayloadRows(
343
396
  continue;
344
397
  }
345
398
  if (character === "|") {
346
- if (atomSources.length >= 3) return undefined;
399
+ if (atomSources.length >= (options.maxCompactAtoms ?? 3)) return undefined;
347
400
  atomSources.push([]);
348
401
  offset += 1;
349
402
  continue;
@@ -509,6 +562,16 @@ function isPlausibleTelegramMatrixStart(
509
562
  );
510
563
  }
511
564
 
565
+ export function parseTelegramButtonPayloadRows(
566
+ source: string,
567
+ ): Record<string, unknown>[][] | undefined {
568
+ return parseTelegramAdaptiveActionPayloadRows(
569
+ source.trim(),
570
+ parseTelegramButtonCompactActionPayload,
571
+ { maxCompactAtoms: 4 },
572
+ )?.rows;
573
+ }
574
+
512
575
  export function parseTelegramActionPayloadRows(
513
576
  comment: TelegramTopLevelHtmlComment,
514
577
  command: string,
@@ -533,7 +596,7 @@ export function parseTelegramActionPayloadRows(
533
596
  const parsed = parseTelegramAdaptiveActionPayloadRows(
534
597
  content.slice(offset),
535
598
  parseTelegramButtonCompactActionPayload,
536
- { allowTrailing: true },
599
+ { allowTrailing: true, maxCompactAtoms: 4 },
537
600
  );
538
601
  if (parsed) return parsed.rows;
539
602
  const end = findTelegramStructuredPayloadEnd(content, offset);
@@ -546,8 +609,15 @@ export function parseTelegramActionPayloadRows(
546
609
  "prompt",
547
610
  "value",
548
611
  "selected_style",
612
+ "disabled",
549
613
  ]);
550
- return attributes ? [[attributes]] : undefined;
614
+ if (!attributes) return undefined;
615
+ return [[{
616
+ ...attributes,
617
+ ...(attributes.disabled === "true" || attributes.disabled === "false"
618
+ ? { disabled: attributes.disabled === "true" }
619
+ : {}),
620
+ }]];
551
621
  }
552
622
 
553
623
  export function normalizeMarkdownAfterVoiceExtraction(
@@ -589,7 +659,9 @@ function stripTelegramHtmlCommentBlocks(markdown: string): string {
589
659
  }
590
660
 
591
661
  export function stripTelegramCommentMarkupForPreview(markdown: string): string {
592
- const withoutClosedBlocks = stripTelegramHtmlCommentBlocks(markdown);
662
+ const withoutClosedBlocks = stripTelegramHtmlCommentBlocks(
663
+ replaceTelegramButtonFences(markdown, () => ""),
664
+ );
593
665
  const openBlockIndex =
594
666
  findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
595
667
  const previewMarkdown =
package/lib/outbound.ts CHANGED
@@ -835,6 +835,7 @@ export {
835
835
 
836
836
  export function createTelegramOutboundReplyPlanner(
837
837
  store: Pick<TelegramButtonActionStore, "register">,
838
+ getRenderingMode: () => "rich" | "html" = () => "rich",
838
839
  ): (
839
840
  markdown: string,
840
841
  options?: { binding?: TelegramOutboundButtonBinding },
@@ -842,6 +843,7 @@ export function createTelegramOutboundReplyPlanner(
842
843
  return (markdown, options) => {
843
844
  const buttonReply = planTelegramButtonReply(markdown, {
844
845
  registerAction: store.register,
846
+ rendering: getRenderingMode(),
845
847
  ...(options?.binding ? { binding: options.binding } : {}),
846
848
  });
847
849
 
package/lib/replies.ts CHANGED
@@ -532,7 +532,7 @@ function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
532
532
  function countTelegramNativeMarkdownBlocks(block: string): number {
533
533
  if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1;
534
534
  const lines = block.split("\n").filter((line) => line.trim().length > 0);
535
- if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
535
+ if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\||<tg-button-row>)/.test(line))) {
536
536
  return Math.max(1, lines.length);
537
537
  }
538
538
  return 1;
package/lib/routing.ts CHANGED
@@ -281,19 +281,17 @@ function formatTelegramAllTabMenuChooserText(command: string): string {
281
281
  "",
282
282
  `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.`,
283
283
  "Select the Pi thread that should handle it:",
284
+ "To restore into a new thread, send a plain message in that destination thread first.",
284
285
  ].join("\n");
285
286
  }
286
287
 
287
288
  function buildTelegramUnboundRerouteChooserMarkup(
288
289
  rerouteId: string,
289
290
  records: readonly Threads.TelegramTopicTargetRecord[],
290
- _options: {
291
- currentLeaderProfileKey?: string;
292
- currentInstanceId?: string;
293
- } = {},
291
+ options: { canRestore: boolean },
294
292
  ): Menu.TelegramReplyMarkup {
295
293
  const activeRecords = records.filter((record) => record.status === "active");
296
- const canRestoreAnyLiveThread = activeRecords.length > 0;
294
+ const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
297
295
  const rows = activeRecords.map((record) => [
298
296
  {
299
297
  text: getTelegramRouteThreadButtonLabel(record),
@@ -433,6 +431,7 @@ async function deleteReservedTelegramTopicThroughReconciler(
433
431
  });
434
432
  deps.recordThreadReconciliationPlan?.(plan);
435
433
  await ThreadReconciler.applyThreadReconciliationPlan(plan, {
434
+ isCleanupTargetProtected: Threads.createTelegramCleanupTargetProtection(deps.threadStore),
436
435
  callApi: deps.callApi,
437
436
  markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
438
437
  deps.threadStore?.markStaleByTarget(
@@ -734,6 +733,8 @@ export function createTelegramInboundRouteRuntime<
734
733
  instanceId?: string;
735
734
  };
736
735
  type PendingUnboundReroute = {
736
+ sourceTarget: Queue.TelegramQueueTarget;
737
+ chooserMessageId?: number;
737
738
  messages: TMessage[];
738
739
  createdAtMs: number;
739
740
  dispatchKind: "prompt" | "command";
@@ -842,12 +843,33 @@ export function createTelegramInboundRouteRuntime<
842
843
  nextUnboundRerouteId += 1;
843
844
  const id = nextUnboundRerouteId.toString(36);
844
845
  pendingUnboundReroutes.set(id, {
846
+ sourceTarget: {
847
+ chatId: messages[0]!.chat.id,
848
+ ...(typeof messages[0]!.message_thread_id === "number"
849
+ ? { threadId: messages[0]!.message_thread_id }
850
+ : {}),
851
+ },
845
852
  messages,
846
853
  createdAtMs: Date.now(),
847
854
  dispatchKind,
848
855
  });
849
856
  return id;
850
857
  };
858
+ const rememberRerouteChooser = (id: string, messageId: number | undefined): void => {
859
+ const pending = pendingUnboundReroutes.get(id);
860
+ if (pending) pending.chooserMessageId = messageId;
861
+ };
862
+ const matchesRerouteChooser = (
863
+ pending: PendingUnboundReroute,
864
+ query: TCallbackQuery,
865
+ ): boolean => {
866
+ const message = query.message;
867
+ return !!message && pending.chooserMessageId !== undefined &&
868
+ message.message_id === pending.chooserMessageId &&
869
+ message.chat.id === pending.sourceTarget.chatId &&
870
+ (message.message_thread_id === undefined ||
871
+ message.message_thread_id === pending.sourceTarget.threadId);
872
+ };
851
873
  const pendingUnboundRerouteMediaGroups = new Map<
852
874
  string,
853
875
  {
@@ -951,6 +973,10 @@ export function createTelegramInboundRouteRuntime<
951
973
  assertExecutionCurrent?.();
952
974
  deps.recordThreadReconciliationPlan?.(plan);
953
975
  const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
976
+ isCleanupTargetProtected(target) {
977
+ assertExecutionCurrent?.();
978
+ return isRerouteTargetProtected(target);
979
+ },
954
980
  callApi: deps.callApi,
955
981
  markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
956
982
  deps.threadStore?.markStaleByTarget(
@@ -967,6 +993,14 @@ export function createTelegramInboundRouteRuntime<
967
993
  assertExecutionCurrent?.();
968
994
  return (result.incompleteActions?.length ?? 0) === 0;
969
995
  };
996
+ const isRerouteTargetProtected = (target: Queue.TelegramQueueTarget): boolean => {
997
+ const matches = (candidate: Queue.TelegramQueueTarget): boolean =>
998
+ candidate.chatId === target.chatId && candidate.threadId === target.threadId;
999
+ return (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
1000
+ (deps.threadStore?.list() ?? []).some((record) => matches(record.target)) ||
1001
+ (deps.threadStore?.listReservations() ?? []).some((record) => matches(record.target)) ||
1002
+ (deps.threadStore?.listPendingProvisions() ?? []).some((record) => !!record.target && matches(record.target));
1003
+ };
970
1004
  const dismissRerouteChooserMessage = async (
971
1005
  query: TCallbackQuery,
972
1006
  assertExecutionCurrent?: () => void,
@@ -986,6 +1020,7 @@ export function createTelegramInboundRouteRuntime<
986
1020
  assertExecutionCurrent?.();
987
1021
  return true;
988
1022
  } catch (error) {
1023
+ assertExecutionCurrent?.();
989
1024
  deps.recordRuntimeEvent?.("telegram", error, {
990
1025
  phase: "reroute-chooser-delete",
991
1026
  chatId,
@@ -1103,12 +1138,14 @@ export function createTelegramInboundRouteRuntime<
1103
1138
  pending: PendingUnboundReroute,
1104
1139
  query: TCallbackQuery,
1105
1140
  successMessage: string,
1106
- assertExecutionCurrent?: () => void,
1141
+ assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query),
1107
1142
  ): Promise<void> => {
1143
+ assertExecutionCurrent();
1108
1144
  const dismissed = await dismissRerouteChooserMessage(
1109
1145
  query,
1110
1146
  assertExecutionCurrent,
1111
1147
  );
1148
+ assertExecutionCurrent();
1112
1149
  if (dismissed) {
1113
1150
  pendingUnboundReroutes.delete(rerouteId);
1114
1151
  await deps.answerCallbackQuery(query.id, successMessage);
@@ -1169,6 +1206,8 @@ export function createTelegramInboundRouteRuntime<
1169
1206
  query.data,
1170
1207
  );
1171
1208
  if (!parsed) return false;
1209
+ const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
1210
+ assertExecutionCurrent();
1172
1211
  const chatId = query.message?.chat?.id;
1173
1212
  const messageId = query.message?.message_id;
1174
1213
  const pending = pendingUnboundReroutes.get(parsed.rerouteId);
@@ -1176,12 +1215,18 @@ export function createTelegramInboundRouteRuntime<
1176
1215
  typeof chatId !== "number" ||
1177
1216
  typeof messageId !== "number" ||
1178
1217
  !deps.threadStore ||
1179
- !pending
1218
+ !pending ||
1219
+ !matchesRerouteChooser(pending, query)
1180
1220
  ) {
1181
1221
  await deps.answerCallbackQuery(query.id, "Message route expired.");
1182
1222
  return true;
1183
1223
  }
1224
+ if (pending.sourceTarget.threadId === undefined) {
1225
+ await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
1226
+ return true;
1227
+ }
1184
1228
  await deps.threadStore.load();
1229
+ assertExecutionCurrent();
1185
1230
  const activeRecords = getTelegramRoutableThreadRecords(
1186
1231
  deps.threadStore.list(),
1187
1232
  deps.getLiveThreadTargets?.(),
@@ -1199,19 +1244,17 @@ export function createTelegramInboundRouteRuntime<
1199
1244
  replyMarkup,
1200
1245
  );
1201
1246
  } else if (deps.sendInteractiveMessage) {
1202
- await deps.sendInteractiveMessage(
1247
+ const chooserId = await deps.sendInteractiveMessage(
1203
1248
  chatId,
1204
1249
  formatTelegramUnboundRerouteRestoreChooserText(),
1205
1250
  "html",
1206
1251
  replyMarkup,
1207
- typeof query.message?.message_thread_id === "number"
1208
- ? {
1209
- target: { chatId, threadId: query.message.message_thread_id },
1210
- replyToMessageId: messageId,
1211
- }
1212
- : undefined,
1252
+ { target: pending.sourceTarget, replyToMessageId: messageId },
1213
1253
  );
1254
+ assertExecutionCurrent();
1255
+ rememberRerouteChooser(parsed.rerouteId, chooserId);
1214
1256
  }
1257
+ assertExecutionCurrent();
1215
1258
  await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
1216
1259
  return true;
1217
1260
  };
@@ -1226,7 +1269,8 @@ export function createTelegramInboundRouteRuntime<
1226
1269
  assertExecutionCurrent();
1227
1270
  const chatId = query.message?.chat?.id;
1228
1271
  const pending = pendingUnboundReroutes.get(parsed.rerouteId);
1229
- if (typeof chatId !== "number" || !deps.threadStore || !pending) {
1272
+ if (typeof chatId !== "number" || !deps.threadStore || !pending ||
1273
+ !matchesRerouteChooser(pending, query)) {
1230
1274
  await deps.answerCallbackQuery(query.id, "Message route expired.");
1231
1275
  return true;
1232
1276
  }
@@ -1300,10 +1344,23 @@ export function createTelegramInboundRouteRuntime<
1300
1344
  parsed.threadId,
1301
1345
  );
1302
1346
  const sourceTarget =
1303
- typeof query.message?.message_thread_id === "number"
1304
- ? { chatId, threadId: query.message.message_thread_id }
1347
+ typeof pending.sourceTarget.threadId === "number"
1348
+ ? { chatId, threadId: pending.sourceTarget.threadId }
1305
1349
  : undefined;
1306
- const sourceMessageId = query.message?.message_id;
1350
+ const sourceMessageId = pending.chooserMessageId;
1351
+ if (parsed.useNewSlot && !sourceTarget) {
1352
+ await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
1353
+ return true;
1354
+ }
1355
+ if (parsed.useNewSlot && sourceTarget && record.target.chatId === sourceTarget.chatId &&
1356
+ record.target.threadId === sourceTarget.threadId) {
1357
+ await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
1358
+ return true;
1359
+ }
1360
+ if (parsed.useNewSlot && sourceTarget && isRerouteTargetProtected(sourceTarget)) {
1361
+ await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
1362
+ return true;
1363
+ }
1307
1364
  const currentInstanceId = deps.getCurrentInstanceId?.();
1308
1365
  const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
1309
1366
  const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
@@ -1507,6 +1564,7 @@ export function createTelegramInboundRouteRuntime<
1507
1564
  rerouteConfirmedAtMs: nowMs,
1508
1565
  });
1509
1566
  await deps.threadStore.persist();
1567
+ assertExecutionCurrent();
1510
1568
  deps.setCurrentLeaderIdentity?.({
1511
1569
  target: sourceTarget,
1512
1570
  slot,
@@ -1944,7 +2002,7 @@ export function createTelegramInboundRouteRuntime<
1944
2002
  };
1945
2003
  const sendUnboundRerouteChooserNow = async (
1946
2004
  messages: TMessage[],
1947
- ctx: TContext,
2005
+ _ctx: TContext,
1948
2006
  reportDeferred = true,
1949
2007
  ): Promise<void> => {
1950
2008
  const message = messages[0];
@@ -1988,20 +2046,13 @@ export function createTelegramInboundRouteRuntime<
1988
2046
  const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
1989
2047
  includeGuidance,
1990
2048
  });
1991
- const currentInstanceId = deps.getCurrentInstanceId?.();
1992
2049
  const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
1993
2050
  rerouteId,
1994
2051
  activeRecords,
1995
- {
1996
- currentLeaderProfileKey: getLeaderTopicProfileKey(
1997
- ctx,
1998
- currentInstanceId,
1999
- ),
2000
- currentInstanceId,
2001
- },
2052
+ { canRestore: sourceTarget !== undefined },
2002
2053
  );
2003
2054
  if (deps.sendInteractiveMessage) {
2004
- await deps.sendInteractiveMessage(
2055
+ const chooserId = await deps.sendInteractiveMessage(
2005
2056
  message.chat.id,
2006
2057
  text,
2007
2058
  "html",
@@ -2010,12 +2061,14 @@ export function createTelegramInboundRouteRuntime<
2010
2061
  ? { target: sourceTarget, replyToMessageId: message.message_id }
2011
2062
  : { replyToMessageId: message.message_id },
2012
2063
  );
2064
+ rememberRerouteChooser(rerouteId, chooserId);
2013
2065
  return;
2014
2066
  }
2015
- await deps.sendTextReply(message.chat.id, message.message_id, text, {
2067
+ const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
2016
2068
  parseMode: "HTML",
2017
2069
  target: sourceTarget,
2018
2070
  });
2071
+ rememberRerouteChooser(rerouteId, chooserId);
2019
2072
  };
2020
2073
  const sendUnboundRerouteChooser = async (
2021
2074
  message: TMessage,
@@ -2080,9 +2133,10 @@ export function createTelegramInboundRouteRuntime<
2080
2133
  const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
2081
2134
  rerouteId,
2082
2135
  activeRecords,
2136
+ { canRestore: typeof message.message_thread_id === "number" },
2083
2137
  );
2084
2138
  if (deps.sendInteractiveMessage) {
2085
- await deps.sendInteractiveMessage(
2139
+ const chooserId = await deps.sendInteractiveMessage(
2086
2140
  message.chat.id,
2087
2141
  text,
2088
2142
  "html",
@@ -2096,10 +2150,11 @@ export function createTelegramInboundRouteRuntime<
2096
2150
  }
2097
2151
  : undefined,
2098
2152
  );
2153
+ rememberRerouteChooser(rerouteId, chooserId);
2099
2154
  return true;
2100
2155
  }
2101
2156
  if (deps.callApi) {
2102
- await deps.callApi("sendMessage", {
2157
+ const chooser = await deps.callApi<{ message_id?: number }>("sendMessage", {
2103
2158
  chat_id: message.chat.id,
2104
2159
  text,
2105
2160
  parse_mode: "HTML",
@@ -2116,12 +2171,14 @@ export function createTelegramInboundRouteRuntime<
2116
2171
  }
2117
2172
  : {}),
2118
2173
  });
2174
+ rememberRerouteChooser(rerouteId, chooser?.message_id);
2119
2175
  return true;
2120
2176
  }
2121
- await deps.sendTextReply(message.chat.id, message.message_id, text, {
2177
+ const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
2122
2178
  parseMode: "HTML",
2123
2179
  target: options.target,
2124
2180
  });
2181
+ rememberRerouteChooser(rerouteId, chooserId);
2125
2182
  return true;
2126
2183
  };
2127
2184
  const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime<