@llblab/pi-telegram 0.24.2 → 0.24.4

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/routing.ts CHANGED
@@ -164,34 +164,10 @@ function escapeHtml(text: string): string {
164
164
  .replace(/>/g, ">");
165
165
  }
166
166
 
167
- interface TelegramAllTabPendingCommand {
168
- command: Commands.ParsedTelegramCommand;
169
- text: string;
170
- createdAtMs: number;
171
- }
172
-
173
- const TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX = "allmenu:";
174
167
  const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
175
168
  const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
176
169
  const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
177
170
 
178
- function formatTelegramAllTabMenuCallbackData(
179
- commandId: string,
180
- threadId: number,
181
- ): string {
182
- return `${TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX}${commandId}:${threadId}`;
183
- }
184
-
185
- function parseTelegramAllTabMenuCallbackData(
186
- data: string | undefined,
187
- ): { commandId: string; threadId: number } | undefined {
188
- const match = data?.match(/^allmenu:([a-z0-9]+):(\d+)$/);
189
- const commandId = match?.[1];
190
- const threadId = Number(match?.[2]);
191
- if (!commandId || !Number.isSafeInteger(threadId)) return undefined;
192
- return { commandId, threadId };
193
- }
194
-
195
171
  function formatTelegramUnboundRerouteCallbackData(
196
172
  rerouteId: string,
197
173
  threadId: number,
@@ -291,22 +267,6 @@ function getTelegramRoutableThreadRecords(
291
267
  );
292
268
  }
293
269
 
294
- function buildTelegramAllTabMenuChooserMarkup(
295
- commandId: string,
296
- records: readonly Threads.TelegramTopicTargetRecord[],
297
- ): Menu.TelegramReplyMarkup {
298
- const rows = records.map((record) => [
299
- {
300
- text: getTelegramRouteThreadButtonLabel(record),
301
- callback_data: formatTelegramAllTabMenuCallbackData(
302
- commandId,
303
- record.target.threadId,
304
- ),
305
- },
306
- ]);
307
- return { inline_keyboard: rows };
308
- }
309
-
310
270
  function formatTelegramAllTabMenuChooserText(command: string): string {
311
271
  return [
312
272
  "<b>🧵 Choose target thread</b>",
@@ -695,7 +655,7 @@ export interface TelegramInboundRouteRuntimeDeps<
695
655
  }
696
656
 
697
657
  const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
698
- TELEGRAM_ALL_TAB_MENU_CALLBACK_PREFIX,
658
+ "allmenu:",
699
659
  TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
700
660
  "compact:",
701
661
  "menu:",
@@ -732,14 +692,36 @@ export function createTelegramInboundRouteRuntime<
732
692
  TModel
733
693
  >,
734
694
  ): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> {
735
- const pendingUnboundReroutes = new Map<
736
- string,
737
- { messages: TMessage[]; createdAtMs: number }
738
- >();
739
- const pendingAllTabCommands = new Map<string, TelegramAllTabPendingCommand>();
695
+ type PendingRerouteCleanup =
696
+ | {
697
+ kind: "unbound";
698
+ target: { chatId: number; threadId: number };
699
+ messageId?: number;
700
+ }
701
+ | {
702
+ kind: "previous-leader";
703
+ target: { chatId: number; threadId: number };
704
+ }
705
+ | {
706
+ kind: "replaced-follower";
707
+ target: { chatId: number; threadId: number };
708
+ instanceId?: string;
709
+ };
710
+ type PendingUnboundReroute = {
711
+ messages: TMessage[];
712
+ createdAtMs: number;
713
+ dispatchKind: "prompt" | "command";
714
+ cleanup?: PendingRerouteCleanup;
715
+ foreignRetry?: {
716
+ instanceId: string;
717
+ threadId: number;
718
+ cleanup: PendingRerouteCleanup;
719
+ };
720
+ finalizeMessage?: string;
721
+ };
722
+ const pendingUnboundReroutes = new Map<string, PendingUnboundReroute>();
740
723
  const guidedUnboundTopicKeys = new Set<string>();
741
724
  let nextUnboundRerouteId = 0;
742
- let nextAllTabCommandId = 0;
743
725
  const requestDispatchNextQueuedTelegramTurn = (ctx: TContext): void => {
744
726
  deps.dispatchNextQueuedTelegramTurn(ctx);
745
727
  if (
@@ -764,11 +746,18 @@ export function createTelegramInboundRouteRuntime<
764
746
  pendingUnboundReroutes.delete(oldest);
765
747
  }
766
748
  };
767
- const storePendingUnboundReroute = (messages: TMessage[]): string => {
749
+ const storePendingUnboundReroute = (
750
+ messages: TMessage[],
751
+ dispatchKind: "prompt" | "command" = "prompt",
752
+ ): string => {
768
753
  prunePendingUnboundReroutes();
769
754
  nextUnboundRerouteId += 1;
770
755
  const id = nextUnboundRerouteId.toString(36);
771
- pendingUnboundReroutes.set(id, { messages, createdAtMs: Date.now() });
756
+ pendingUnboundReroutes.set(id, {
757
+ messages,
758
+ createdAtMs: Date.now(),
759
+ dispatchKind,
760
+ });
772
761
  return id;
773
762
  };
774
763
  const pendingUnboundRerouteMediaGroups = new Map<
@@ -778,39 +767,6 @@ export function createTelegramInboundRouteRuntime<
778
767
  timer: ReturnType<typeof setTimeout>;
779
768
  }
780
769
  >();
781
- const prunePendingAllTabCommands = () => {
782
- const nowMs = Date.now();
783
- for (const [id, entry] of pendingAllTabCommands) {
784
- if (nowMs - entry.createdAtMs > 30 * 60_000) {
785
- pendingAllTabCommands.delete(id);
786
- }
787
- }
788
- while (pendingAllTabCommands.size > 100) {
789
- const oldest = pendingAllTabCommands.keys().next().value;
790
- if (!oldest) break;
791
- pendingAllTabCommands.delete(oldest);
792
- }
793
- };
794
- const storePendingAllTabCommand = (
795
- command: Commands.ParsedTelegramCommand,
796
- text: string,
797
- ): string => {
798
- prunePendingAllTabCommands();
799
- const bareCommandText = `/${command.name}`;
800
- const id = text.trim() === bareCommandText ? command.name : undefined;
801
- if (id) {
802
- pendingAllTabCommands.set(id, { command, text, createdAtMs: Date.now() });
803
- return id;
804
- }
805
- nextAllTabCommandId += 1;
806
- const generatedId = nextAllTabCommandId.toString(36);
807
- pendingAllTabCommands.set(generatedId, {
808
- command,
809
- text,
810
- createdAtMs: Date.now(),
811
- });
812
- return generatedId;
813
- };
814
770
  const menuCallbackHandler = Menu.createTelegramMenuCallbackHandlerForContext<
815
771
  TCallbackQuery,
816
772
  TContext,
@@ -889,9 +845,9 @@ export function createTelegramInboundRouteRuntime<
889
845
  };
890
846
  const applyThreadCleanupPlan = async (
891
847
  plan: ThreadReconciler.ThreadReconciliationPlan,
892
- ): Promise<void> => {
848
+ ): Promise<boolean> => {
893
849
  deps.recordThreadReconciliationPlan?.(plan);
894
- await ThreadReconciler.applyThreadReconciliationPlan(plan, {
850
+ const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
895
851
  callApi: deps.callApi,
896
852
  markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
897
853
  deps.threadStore?.markStaleByTarget(
@@ -905,10 +861,11 @@ export function createTelegramInboundRouteRuntime<
905
861
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
906
862
  recordRuntimeEvent: deps.recordRuntimeEvent,
907
863
  });
864
+ return (result.incompleteActions?.length ?? 0) === 0;
908
865
  };
909
866
  const dismissRerouteChooserMessage = async (
910
867
  query: TCallbackQuery,
911
- ): Promise<void> => {
868
+ ): Promise<boolean> => {
912
869
  const chatId = query.message?.chat?.id;
913
870
  const messageId = query.message?.message_id;
914
871
  if (
@@ -916,10 +873,11 @@ export function createTelegramInboundRouteRuntime<
916
873
  typeof messageId !== "number" ||
917
874
  !deps.deleteMessage
918
875
  ) {
919
- return;
876
+ return false;
920
877
  }
921
878
  try {
922
879
  await deps.deleteMessage(chatId, messageId);
880
+ return true;
923
881
  } catch (error) {
924
882
  deps.recordRuntimeEvent?.("telegram", error, {
925
883
  phase: "reroute-chooser-delete",
@@ -927,13 +885,14 @@ export function createTelegramInboundRouteRuntime<
927
885
  messageId,
928
886
  threadId: query.message?.message_thread_id,
929
887
  });
888
+ return false;
930
889
  }
931
890
  };
932
891
  const closeReroutedUnboundTopic = async (
933
892
  target: { chatId: number; threadId: number } | undefined,
934
893
  messageId: number | undefined,
935
- ): Promise<void> => {
936
- if (!target || !deps.threadStore) return;
894
+ ): Promise<boolean> => {
895
+ if (!target || !deps.threadStore) return true;
937
896
  const nowMs = Date.now();
938
897
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
939
898
  const plan = ThreadReconciler.planThreadReconciliation({
@@ -954,14 +913,14 @@ export function createTelegramInboundRouteRuntime<
954
913
  },
955
914
  ],
956
915
  });
957
- await applyThreadCleanupPlan(plan);
916
+ return applyThreadCleanupPlan(plan);
958
917
  };
959
918
  const closePreviousLeaderThread = async (
960
919
  target: { chatId: number; threadId: number } | undefined,
961
- ): Promise<void> => {
962
- if (!target || !deps.threadStore) return;
920
+ ): Promise<boolean> => {
921
+ if (!target || !deps.threadStore) return true;
963
922
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
964
- await applyThreadCleanupPlan({
923
+ return applyThreadCleanupPlan({
965
924
  actions: [
966
925
  {
967
926
  kind: "close-delete-previous-leader-topic",
@@ -978,10 +937,10 @@ export function createTelegramInboundRouteRuntime<
978
937
  const closeReplacedFollowerThread = async (
979
938
  target: { chatId: number; threadId: number } | undefined,
980
939
  instanceId: string | undefined,
981
- ): Promise<void> => {
982
- if (!target || !deps.threadStore) return;
940
+ ): Promise<boolean> => {
941
+ if (!target || !deps.threadStore) return true;
983
942
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
984
- await applyThreadCleanupPlan({
943
+ return applyThreadCleanupPlan({
985
944
  actions: [
986
945
  {
987
946
  kind: "close-delete-replaced-follower-topic",
@@ -995,6 +954,82 @@ export function createTelegramInboundRouteRuntime<
995
954
  ],
996
955
  });
997
956
  };
957
+ const retryPendingRerouteCleanup = async (
958
+ cleanup: PendingRerouteCleanup,
959
+ ): Promise<boolean> => {
960
+ if (cleanup.kind === "unbound") {
961
+ return closeReroutedUnboundTopic(cleanup.target, cleanup.messageId);
962
+ }
963
+ if (cleanup.kind === "previous-leader") {
964
+ return closePreviousLeaderThread(cleanup.target);
965
+ }
966
+ return closeReplacedFollowerThread(cleanup.target, cleanup.instanceId);
967
+ };
968
+ let dispatchReroutedCommandMessages:
969
+ | ((messages: TMessage[], ctx: TContext) => Promise<void>)
970
+ | undefined;
971
+ const dispatchPendingRerouteMessages = async (
972
+ pending: { dispatchKind: "prompt" | "command" },
973
+ messages: TMessage[],
974
+ ctx: TContext,
975
+ ): Promise<void> => {
976
+ if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
977
+ await dispatchReroutedCommandMessages(messages, ctx);
978
+ return;
979
+ }
980
+ await promptEnqueue(messages, ctx);
981
+ };
982
+ const finalizePendingReroute = async (
983
+ rerouteId: string,
984
+ pending: PendingUnboundReroute,
985
+ query: TCallbackQuery,
986
+ successMessage: string,
987
+ ): Promise<void> => {
988
+ const dismissed = await dismissRerouteChooserMessage(query);
989
+ if (dismissed) {
990
+ pendingUnboundReroutes.delete(rerouteId);
991
+ await deps.answerCallbackQuery(query.id, successMessage);
992
+ return;
993
+ }
994
+ pending.finalizeMessage = successMessage;
995
+ await deps.answerCallbackQuery(
996
+ query.id,
997
+ `${successMessage} Chooser cleanup is still pending. Try again.`,
998
+ );
999
+ };
1000
+ const forwardPendingRerouteMessages = async (
1001
+ pending: PendingUnboundReroute,
1002
+ instanceId: string,
1003
+ threadId: number,
1004
+ ctx: TContext,
1005
+ ): Promise<boolean> => {
1006
+ const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
1007
+ if (!forwardMessage) return false;
1008
+ const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
1009
+ const outcomes = await Promise.allSettled(
1010
+ messages.map((message) =>
1011
+ forwardMessage({
1012
+ message,
1013
+ ownership: { instanceId },
1014
+ ctx,
1015
+ }),
1016
+ ),
1017
+ );
1018
+ pending.messages = pending.messages.filter((_, index) => {
1019
+ const outcome = outcomes[index];
1020
+ if (outcome?.status === "fulfilled" && outcome.value) return false;
1021
+ if (outcome?.status === "rejected") {
1022
+ deps.recordRuntimeEvent?.("bus", outcome.reason, {
1023
+ phase: "reroute-foreign-forward",
1024
+ instanceId,
1025
+ threadId,
1026
+ messageIndex: index,
1027
+ });
1028
+ }
1029
+ return true;
1030
+ });
1031
+ return pending.messages.length === 0;
1032
+ };
998
1033
  const handleUnboundRerouteRestoreMenuCallback = async (
999
1034
  query: TCallbackQuery,
1000
1035
  _ctx: TContext,
@@ -1062,6 +1097,51 @@ export function createTelegramInboundRouteRuntime<
1062
1097
  return true;
1063
1098
  }
1064
1099
  await deps.threadStore.load();
1100
+ if (pending.finalizeMessage) {
1101
+ await finalizePendingReroute(
1102
+ parsed.rerouteId,
1103
+ pending,
1104
+ query,
1105
+ pending.finalizeMessage,
1106
+ );
1107
+ return true;
1108
+ }
1109
+ if (pending.foreignRetry) {
1110
+ const retry = pending.foreignRetry;
1111
+ const allForwarded = await forwardPendingRerouteMessages(
1112
+ pending,
1113
+ retry.instanceId,
1114
+ retry.threadId,
1115
+ ctx,
1116
+ );
1117
+ if (!allForwarded) {
1118
+ await deps.answerCallbackQuery(
1119
+ query.id,
1120
+ "Target thread is unavailable; retrying will send only remaining messages.",
1121
+ );
1122
+ return true;
1123
+ }
1124
+ pending.foreignRetry = undefined;
1125
+ if (retry.cleanup) pending.cleanup = retry.cleanup;
1126
+ }
1127
+ if (pending.cleanup) {
1128
+ const cleanupComplete = await retryPendingRerouteCleanup(pending.cleanup);
1129
+ if (!cleanupComplete) {
1130
+ await deps.answerCallbackQuery(
1131
+ query.id,
1132
+ "Message routed, but thread cleanup is still pending. Try again.",
1133
+ );
1134
+ return true;
1135
+ }
1136
+ pending.cleanup = undefined;
1137
+ await finalizePendingReroute(
1138
+ parsed.rerouteId,
1139
+ pending,
1140
+ query,
1141
+ "Thread cleanup completed.",
1142
+ );
1143
+ return true;
1144
+ }
1065
1145
  const record = getTelegramRoutableThreadRecords(
1066
1146
  deps.threadStore.list(),
1067
1147
  deps.getLiveThreadTargets?.(),
@@ -1150,27 +1230,43 @@ export function createTelegramInboundRouteRuntime<
1150
1230
  });
1151
1231
  }
1152
1232
  }
1153
- await closeReplacedFollowerThread(record.target, record.instanceId);
1154
- const forwarded = await Promise.all(
1155
- cloneTelegramMessagesForThread(
1156
- pending.messages,
1157
- sourceTarget.threadId,
1158
- ).map((message) =>
1159
- deps.foreignOwnedUpdateForwarder!.forwardMessage!({
1160
- message,
1161
- ownership: { instanceId: record.instanceId! },
1162
- ctx,
1163
- }),
1164
- ),
1233
+ const cleanup: PendingRerouteCleanup = {
1234
+ kind: "replaced-follower",
1235
+ target: record.target,
1236
+ instanceId: record.instanceId,
1237
+ };
1238
+ const allForwarded = await forwardPendingRerouteMessages(
1239
+ pending,
1240
+ record.instanceId!,
1241
+ sourceTarget.threadId,
1242
+ ctx,
1165
1243
  );
1166
- const allForwarded = forwarded.every(Boolean);
1167
- if (allForwarded) {
1168
- pendingUnboundReroutes.delete(parsed.rerouteId);
1169
- await dismissRerouteChooserMessage(query);
1244
+ if (!allForwarded) {
1245
+ pending.foreignRetry = {
1246
+ instanceId: record.instanceId!,
1247
+ threadId: sourceTarget.threadId,
1248
+ cleanup,
1249
+ };
1250
+ await deps.answerCallbackQuery(
1251
+ query.id,
1252
+ "Thread restored; retrying will send only remaining messages before old-thread cleanup.",
1253
+ );
1254
+ return true;
1170
1255
  }
1171
- await deps.answerCallbackQuery(
1172
- query.id,
1173
- allForwarded ? "Message routed." : "Target thread is unavailable.",
1256
+ const cleanupComplete = await retryPendingRerouteCleanup(cleanup);
1257
+ if (!cleanupComplete) {
1258
+ pending.cleanup = cleanup;
1259
+ await deps.answerCallbackQuery(
1260
+ query.id,
1261
+ "Thread restored, but old-thread cleanup is still pending. Try again.",
1262
+ );
1263
+ return true;
1264
+ }
1265
+ await finalizePendingReroute(
1266
+ parsed.rerouteId,
1267
+ pending,
1268
+ query,
1269
+ "Message routed.",
1174
1270
  );
1175
1271
  return true;
1176
1272
  }
@@ -1186,49 +1282,64 @@ export function createTelegramInboundRouteRuntime<
1186
1282
  );
1187
1283
  return true;
1188
1284
  }
1189
- const forwarded = await Promise.all(
1190
- reroutedMessages.map((message) =>
1191
- deps.foreignOwnedUpdateForwarder!.forwardMessage!({
1192
- message,
1193
- ownership: { instanceId: record.instanceId! },
1194
- ctx,
1195
- }),
1196
- ),
1285
+ const allForwarded = await forwardPendingRerouteMessages(
1286
+ pending,
1287
+ record.instanceId,
1288
+ parsed.threadId,
1289
+ ctx,
1197
1290
  );
1198
- const allForwarded = forwarded.every(Boolean);
1199
- if (allForwarded) {
1200
- pendingUnboundReroutes.delete(parsed.rerouteId);
1201
- await dismissRerouteChooserMessage(query);
1202
- await closeReroutedUnboundTopic(sourceTarget, sourceMessageId);
1291
+ if (!allForwarded) {
1292
+ await deps.answerCallbackQuery(
1293
+ query.id,
1294
+ "Target thread is unavailable; retrying will send only remaining messages.",
1295
+ );
1296
+ return true;
1203
1297
  }
1204
- await deps.answerCallbackQuery(
1205
- query.id,
1206
- allForwarded ? "Message routed." : "Target thread is unavailable.",
1298
+ const cleanupComplete = await closeReroutedUnboundTopic(
1299
+ sourceTarget,
1300
+ sourceMessageId,
1301
+ );
1302
+ if (!cleanupComplete && sourceTarget) {
1303
+ pending.cleanup = {
1304
+ kind: "unbound",
1305
+ target: sourceTarget,
1306
+ ...(typeof sourceMessageId === "number"
1307
+ ? { messageId: sourceMessageId }
1308
+ : {}),
1309
+ };
1310
+ await deps.answerCallbackQuery(
1311
+ query.id,
1312
+ "Message routed, but thread cleanup is still pending. Try again.",
1313
+ );
1314
+ return true;
1315
+ }
1316
+ await finalizePendingReroute(
1317
+ parsed.rerouteId,
1318
+ pending,
1319
+ query,
1320
+ "Message routed.",
1207
1321
  );
1208
1322
  return true;
1209
1323
  }
1210
1324
  if (
1211
1325
  sourceTarget &&
1212
1326
  isCurrentLeaderRecord &&
1213
- (parsed.useNewSlot || typeof record.rerouteConfirmedAtMs !== "number") &&
1327
+ parsed.useNewSlot &&
1214
1328
  (record.target.chatId !== sourceTarget.chatId ||
1215
1329
  record.target.threadId !== sourceTarget.threadId)
1216
1330
  ) {
1217
1331
  deps.threadStore.markStaleByTarget(
1218
1332
  record.target,
1219
1333
  "deleted",
1220
- parsed.useNewSlot
1221
- ? "Current leader thread was replaced by a new-slot reroute source."
1222
- : "Current leader thread was replaced by reroute source.",
1334
+ "Current leader thread was replaced by a new-slot reroute source.",
1223
1335
  );
1224
- const slot = parsed.useNewSlot
1225
- ? (deps.threadStore.allocateSlot(
1226
- leaderProfileKey ?? record.profileKey,
1227
- getNextTelegramSlotPreference(record.slot),
1228
- ) ??
1229
- record.slot ??
1230
- "?")
1231
- : (record.slot ?? "?");
1336
+ const slot =
1337
+ deps.threadStore.allocateSlot(
1338
+ leaderProfileKey ?? record.profileKey,
1339
+ getNextTelegramSlotPreference(record.slot),
1340
+ ) ??
1341
+ record.slot ??
1342
+ "?";
1232
1343
  const nowMs = Date.now();
1233
1344
  const threadName = getRestoredThreadName(record, slot);
1234
1345
  deps.threadStore.upsert({
@@ -1239,9 +1350,7 @@ export function createTelegramInboundRouteRuntime<
1239
1350
  threadName,
1240
1351
  instanceId: currentInstanceId,
1241
1352
  slot,
1242
- lastReconcileAction: parsed.useNewSlot
1243
- ? "reroute-new-slot"
1244
- : "reroute-reclaim",
1353
+ lastReconcileAction: "reroute-new-slot",
1245
1354
  rerouteConfirmedAtMs: nowMs,
1246
1355
  });
1247
1356
  await deps.threadStore.persist();
@@ -1272,110 +1381,58 @@ export function createTelegramInboundRouteRuntime<
1272
1381
  slot,
1273
1382
  },
1274
1383
  );
1275
- if (parsed.useNewSlot) {
1276
- await closePreviousLeaderThread(record.target);
1277
- }
1278
- await promptEnqueue(
1384
+ await dispatchPendingRerouteMessages(
1385
+ pending,
1279
1386
  cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId),
1280
1387
  ctx,
1281
1388
  );
1282
- pendingUnboundReroutes.delete(parsed.rerouteId);
1283
- await dismissRerouteChooserMessage(query);
1284
- await deps.answerCallbackQuery(query.id, "Message routed.");
1285
- return true;
1286
- }
1287
- await promptEnqueue(reroutedMessages, ctx);
1288
- pendingUnboundReroutes.delete(parsed.rerouteId);
1289
- await dismissRerouteChooserMessage(query);
1290
- await closeReroutedUnboundTopic(sourceTarget, sourceMessageId);
1291
- await deps.answerCallbackQuery(query.id, "Message routed.");
1292
- return true;
1293
- };
1294
- let dispatchAllTabCommandToTarget:
1295
- | ((
1296
- commandText: string,
1297
- query: TCallbackQuery,
1298
- threadId: number,
1299
- ctx: TContext,
1300
- ) => Promise<void>)
1301
- | undefined;
1302
- const handleAllTabMenuCallback = async (
1303
- query: TCallbackQuery,
1304
- ctx: TContext,
1305
- ): Promise<boolean> => {
1306
- const parsed = parseTelegramAllTabMenuCallbackData(query.data);
1307
- if (!parsed) return false;
1308
- const chatId = query.message?.chat?.id;
1309
- const pending = pendingAllTabCommands.get(parsed.commandId);
1310
- if (typeof chatId !== "number" || !deps.threadStore || !pending) {
1311
- await deps.answerCallbackQuery(query.id, "Thread menu expired.");
1312
- return true;
1313
- }
1314
- await deps.threadStore.load();
1315
- const record = getTelegramRoutableThreadRecords(
1316
- deps.threadStore.list(),
1317
- deps.getLiveThreadTargets?.(),
1318
- ).find(
1319
- (candidate) =>
1320
- candidate.target.chatId === chatId &&
1321
- candidate.target.threadId === parsed.threadId,
1322
- );
1323
- if (!record) {
1324
- await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
1325
- return true;
1326
- }
1327
- const currentInstanceId = deps.getCurrentInstanceId?.();
1328
- const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
1329
- const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
1330
- record,
1331
- leaderProfileKey,
1332
- currentInstanceId,
1333
- );
1334
- if (
1335
- record.instanceId &&
1336
- record.instanceId !== currentInstanceId &&
1337
- !isCurrentLeaderRecord
1338
- ) {
1339
- if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
1389
+ pending.messages = [];
1390
+ const cleanupComplete = await closePreviousLeaderThread(record.target);
1391
+ if (!cleanupComplete) {
1392
+ pending.cleanup = {
1393
+ kind: "previous-leader",
1394
+ target: record.target,
1395
+ };
1340
1396
  await deps.answerCallbackQuery(
1341
1397
  query.id,
1342
- "Open that thread and run /start there.",
1398
+ "Thread restored, but old-thread cleanup is still pending. Try again.",
1343
1399
  );
1344
1400
  return true;
1345
1401
  }
1346
- const forwarded = await deps.foreignOwnedUpdateForwarder.forwardMessage({
1347
- message: {
1348
- ...(query.message ?? {}),
1349
- message_id: query.message?.message_id ?? 0,
1350
- chat: { id: chatId, type: "private" },
1351
- from: query.from,
1352
- message_thread_id: parsed.threadId,
1353
- text: pending.text,
1354
- } as TMessage,
1355
- ownership: { instanceId: record.instanceId },
1356
- ctx,
1357
- });
1358
- if (forwarded) pendingAllTabCommands.delete(parsed.commandId);
1359
- await deps.answerCallbackQuery(
1360
- query.id,
1361
- forwarded
1362
- ? "Opening in target thread."
1363
- : "Target thread is unavailable.",
1402
+ await finalizePendingReroute(
1403
+ parsed.rerouteId,
1404
+ pending,
1405
+ query,
1406
+ "Message routed.",
1364
1407
  );
1365
1408
  return true;
1366
1409
  }
1367
- if (!dispatchAllTabCommandToTarget) {
1368
- await deps.answerCallbackQuery(query.id, "Thread menu expired.");
1410
+ await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
1411
+ pending.messages = [];
1412
+ const cleanupComplete = await closeReroutedUnboundTopic(
1413
+ sourceTarget,
1414
+ sourceMessageId,
1415
+ );
1416
+ if (!cleanupComplete && sourceTarget) {
1417
+ pending.cleanup = {
1418
+ kind: "unbound",
1419
+ target: sourceTarget,
1420
+ ...(typeof sourceMessageId === "number"
1421
+ ? { messageId: sourceMessageId }
1422
+ : {}),
1423
+ };
1424
+ await deps.answerCallbackQuery(
1425
+ query.id,
1426
+ "Message routed, but thread cleanup is still pending. Try again.",
1427
+ );
1369
1428
  return true;
1370
1429
  }
1371
- await dispatchAllTabCommandToTarget(
1372
- pending.text,
1430
+ await finalizePendingReroute(
1431
+ parsed.rerouteId,
1432
+ pending,
1373
1433
  query,
1374
- parsed.threadId,
1375
- ctx,
1434
+ "Message routed.",
1376
1435
  );
1377
- pendingAllTabCommands.delete(parsed.commandId);
1378
- await deps.answerCallbackQuery(query.id, "Opening in target thread.");
1379
1436
  return true;
1380
1437
  };
1381
1438
  const callbackHandler = async (
@@ -1384,7 +1441,6 @@ export function createTelegramInboundRouteRuntime<
1384
1441
  ): Promise<void> => {
1385
1442
  if (await handleUnboundRerouteRestoreMenuCallback(query, ctx)) return;
1386
1443
  if (await handleUnboundRerouteCallback(query, ctx)) return;
1387
- if (await handleAllTabMenuCallback(query, ctx)) return;
1388
1444
  if (deps.buttonActionStore) {
1389
1445
  const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery(
1390
1446
  query,
@@ -1769,10 +1825,15 @@ export function createTelegramInboundRouteRuntime<
1769
1825
  deps.getLiveThreadTargets?.(),
1770
1826
  );
1771
1827
  if (activeRecords.length === 0) return false;
1772
- const commandId = storePendingAllTabCommand(command, commandText);
1828
+ const commandMessage = {
1829
+ ...message,
1830
+ text: commandText,
1831
+ caption: undefined,
1832
+ } as TMessage;
1833
+ const rerouteId = storePendingUnboundReroute([commandMessage], "command");
1773
1834
  const text = formatTelegramAllTabMenuChooserText(command.name);
1774
- const replyMarkup = buildTelegramAllTabMenuChooserMarkup(
1775
- commandId,
1835
+ const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
1836
+ rerouteId,
1776
1837
  activeRecords,
1777
1838
  );
1778
1839
  if (deps.sendInteractiveMessage) {
@@ -1877,24 +1938,8 @@ export function createTelegramInboundRouteRuntime<
1877
1938
  ({ ...message, text, caption: undefined }) as TMessage,
1878
1939
  enqueueTurn: promptEnqueue,
1879
1940
  });
1880
- dispatchAllTabCommandToTarget = async (commandText, query, threadId, ctx) => {
1881
- const chatId = query.message?.chat?.id;
1882
- if (typeof chatId !== "number") return;
1883
- await commandOrPrompt.dispatchMessages(
1884
- [
1885
- {
1886
- ...(query.message ?? {}),
1887
- message_id: 0,
1888
- chat: { id: chatId, type: "private" },
1889
- from: query.from,
1890
- message_thread_id: threadId,
1891
- text: commandText,
1892
- caption: undefined,
1893
- } as TMessage,
1894
- ],
1895
- ctx,
1896
- );
1897
- };
1941
+ dispatchReroutedCommandMessages = (messages, ctx) =>
1942
+ commandOrPrompt.dispatchMessages(messages, ctx);
1898
1943
  const mediaDispatch = Media.createTelegramMediaGroupDispatchRuntime<
1899
1944
  TMessage,
1900
1945
  TContext