@llblab/pi-telegram 0.24.6 → 0.24.7

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-leader.ts CHANGED
@@ -112,7 +112,10 @@ export interface TelegramBusFollowerTargetProvisionerDeps {
112
112
  export interface TelegramBusFollowerDisconnectHandlerDeps {
113
113
  topicTargetStore: Pick<
114
114
  Threads.TelegramTopicTargetStore,
115
- "markOfflineByInstanceId" | "persist"
115
+ | "markStaleByTarget"
116
+ | "persist"
117
+ | "upsertPendingCleanup"
118
+ | "removePendingCleanup"
116
119
  >;
117
120
  callApi: <TResponse>(
118
121
  method: string,
@@ -155,6 +158,7 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
155
158
  TelegramBusLeaderRuntimeDeps<TContext>,
156
159
  | "callApi"
157
160
  | "onFollowerDisconnected"
161
+ | "onFollowerConfirmedDead"
158
162
  | "provisionFollowerTarget"
159
163
  | "provisionLeaderTarget"
160
164
  | "recordRuntimeEvent"
@@ -212,6 +216,9 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
212
216
  onFollowerDisconnected: createTelegramBusFollowerDisconnectHandler({
213
217
  ...provisionerPorts,
214
218
  }),
219
+ onFollowerConfirmedDead: createTelegramBusFollowerConfirmedDeadHandler({
220
+ ...provisionerPorts,
221
+ }),
215
222
  provisionFollowerTarget: createTelegramBusFollowerTargetProvisioner({
216
223
  ...provisionerPorts,
217
224
  }),
@@ -259,9 +266,14 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
259
266
  getNowMs?: () => number;
260
267
  followerPruneIntervalMs?: number;
261
268
  followerStaleAfterMs?: number;
269
+ isFollowerProcessAlive?: (pid: number) => boolean;
270
+ shouldCleanupConfirmedDeadFollower?: () => Promise<boolean> | boolean;
262
271
  onFollowerDisconnected?: (
263
272
  follower: TelegramBusFollowerView,
264
273
  ) => Promise<void> | void;
274
+ onFollowerConfirmedDead?: (
275
+ follower: TelegramBusFollowerView,
276
+ ) => Promise<void> | void;
265
277
  recordRuntimeEvent?: (
266
278
  category: string,
267
279
  error: unknown,
@@ -678,8 +690,9 @@ export function createTelegramBusFollowerTargetProvisioner(
678
690
  };
679
691
  }
680
692
 
681
- export function createTelegramBusFollowerDisconnectHandler(
693
+ function createTelegramBusFollowerCleanupHandler(
682
694
  deps: TelegramBusFollowerDisconnectHandlerDeps,
695
+ trigger: "graceful-disconnect" | "confirmed-dead",
683
696
  ): (follower: TelegramBusFollowerView) => Promise<void> {
684
697
  return async (follower) => {
685
698
  const target = follower.target;
@@ -688,14 +701,33 @@ export function createTelegramBusFollowerDisconnectHandler(
688
701
  if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
689
702
  throw new Error("Follower disconnect cleanup requires leader ownership.");
690
703
  }
704
+ if (!follower.registrationGeneration) {
705
+ throw new Error(
706
+ "Follower disconnect cleanup requires an exact registration generation.",
707
+ );
708
+ }
709
+ const intent: ThreadReconciler.TelegramThreadCleanupIntent = {
710
+ id: `cleanup:${follower.instanceId}:${follower.registrationGeneration}:${target.chatId}:${target.threadId}`,
711
+ owner: "manual-follower",
712
+ instanceId: follower.instanceId,
713
+ runtimeGeneration: follower.registrationGeneration,
714
+ ...(follower.profileKey ? { profileKey: follower.profileKey } : {}),
715
+ target: { chatId: target.chatId, threadId: target.threadId },
716
+ requestedAtMs: (deps.getNowMs ?? Date.now)(),
717
+ };
718
+ deps.topicTargetStore.upsertPendingCleanup(intent);
719
+ await deps.topicTargetStore.persist();
691
720
  const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
692
- ThreadReconciler.planDisconnectedInstanceThreadCleanup({
693
- target: { chatId: target.chatId, threadId: target.threadId },
694
- instanceId: follower.instanceId,
695
- leaderEpoch,
721
+ ThreadReconciler.planThreadReconciliation({
722
+ nowMs: (deps.getNowMs ?? Date.now)(),
723
+ currentLeaderEpoch: leaderEpoch,
724
+ records: [],
725
+ pendingCleanups: [intent],
696
726
  }),
697
727
  {
698
728
  callApi: deps.callApi,
729
+ markStaleByTarget: deps.topicTargetStore.markStaleByTarget,
730
+ removeCleanupIntentById: deps.topicTargetStore.removePendingCleanup,
699
731
  persist: deps.topicTargetStore.persist,
700
732
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
701
733
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -703,7 +735,7 @@ export function createTelegramBusFollowerDisconnectHandler(
703
735
  );
704
736
  if (cleanup.incompleteActions?.length) {
705
737
  throw new Error(
706
- "Telegram follower thread deletion was not confirmed; reconnect the leader and retry /telegram-disconnect.",
738
+ "Telegram follower thread deletion was not confirmed; reconnect the leader to retry cleanup.",
707
739
  );
708
740
  }
709
741
  if (
@@ -712,24 +744,45 @@ export function createTelegramBusFollowerDisconnectHandler(
712
744
  ) {
713
745
  throw new Error("Follower disconnect cleanup lost leader ownership.");
714
746
  }
715
- const changed =
716
- deps.topicTargetStore.markOfflineByInstanceId(follower.instanceId) > 0;
717
- if (changed) await deps.topicTargetStore.persist();
718
747
  deps.setSyncState(
719
748
  Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
720
749
  nowMs: (deps.getNowMs ?? Date.now)(),
721
- action: "manual-follower-disconnect",
750
+ action:
751
+ trigger === "confirmed-dead"
752
+ ? "manual-follower-confirmed-dead"
753
+ : "manual-follower-disconnect",
722
754
  }),
723
755
  );
724
- deps.recordRuntimeEvent("bus", "Telegram bus follower disconnected", {
725
- phase: "follower-disconnect",
726
- instanceId: follower.instanceId,
727
- chatId: target.chatId,
728
- threadId: target.threadId,
729
- });
756
+ deps.recordRuntimeEvent(
757
+ "bus",
758
+ trigger === "confirmed-dead"
759
+ ? "Confirmed-dead Telegram bus follower thread cleaned up"
760
+ : "Telegram bus follower disconnected",
761
+ {
762
+ phase:
763
+ trigger === "confirmed-dead"
764
+ ? "follower-confirmed-dead-cleanup"
765
+ : "follower-disconnect",
766
+ instanceId: follower.instanceId,
767
+ chatId: target.chatId,
768
+ threadId: target.threadId,
769
+ },
770
+ );
730
771
  };
731
772
  }
732
773
 
774
+ export function createTelegramBusFollowerDisconnectHandler(
775
+ deps: TelegramBusFollowerDisconnectHandlerDeps,
776
+ ): (follower: TelegramBusFollowerView) => Promise<void> {
777
+ return createTelegramBusFollowerCleanupHandler(deps, "graceful-disconnect");
778
+ }
779
+
780
+ export function createTelegramBusFollowerConfirmedDeadHandler(
781
+ deps: TelegramBusFollowerDisconnectHandlerDeps,
782
+ ): (follower: TelegramBusFollowerView) => Promise<void> {
783
+ return createTelegramBusFollowerCleanupHandler(deps, "confirmed-dead");
784
+ }
785
+
733
786
  export function createTelegramBusLeaderTargetProvisioner<TContext>(
734
787
  deps: TelegramBusLeaderTargetProvisionerDeps<TContext>,
735
788
  ): (ctx: TContext) => Promise<void> {
@@ -741,6 +794,23 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
741
794
  "Telegram leader target provisioning requires ownership.",
742
795
  );
743
796
  }
797
+ await deps.topicTargetStore.load();
798
+ const pendingCleanupPlan = ThreadReconciler.planThreadReconciliation({
799
+ nowMs: getNowMs(),
800
+ currentLeaderEpoch: leaderEpoch,
801
+ previousState: deps.getThreadReconciliationMachineState?.(),
802
+ records: deps.topicTargetStore.list(),
803
+ pendingCleanups: deps.topicTargetStore.listPendingCleanups(),
804
+ });
805
+ deps.recordThreadReconciliationPlan?.(pendingCleanupPlan);
806
+ await ThreadReconciler.applyThreadReconciliationPlan(pendingCleanupPlan, {
807
+ callApi: deps.callApi,
808
+ markStaleByTarget: deps.topicTargetStore.markStaleByTarget,
809
+ removeCleanupIntentById: deps.topicTargetStore.removePendingCleanup,
810
+ persist: deps.topicTargetStore.persist,
811
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
812
+ recordRuntimeEvent: deps.recordRuntimeEvent,
813
+ });
744
814
  deps.onProvisioningStart?.();
745
815
  let ownTarget: Threads.TelegramOwnTopicProvisionResult | undefined;
746
816
  try {
@@ -858,6 +928,33 @@ export function createTelegramBusLeaderApiProxy(
858
928
  };
859
929
  }
860
930
 
931
+ type TelegramBusFollowerMutationRunner = <T>(
932
+ follower: { instanceId: string; profileKey?: string },
933
+ operation: () => Promise<T>,
934
+ ) => Promise<T>;
935
+
936
+ function createTelegramBusFollowerMutationRunner(): TelegramBusFollowerMutationRunner {
937
+ const tails = new Map<string, Promise<void>>();
938
+ return async (follower, operation) => {
939
+ const key = follower.profileKey
940
+ ? `profile:${follower.profileKey}`
941
+ : `instance:${follower.instanceId}`;
942
+ const previous = tails.get(key);
943
+ let release!: () => void;
944
+ const current = new Promise<void>((resolve) => {
945
+ release = resolve;
946
+ });
947
+ tails.set(key, current);
948
+ if (previous) await previous;
949
+ try {
950
+ return await operation();
951
+ } finally {
952
+ release();
953
+ if (tails.get(key) === current) tails.delete(key);
954
+ }
955
+ };
956
+ }
957
+
861
958
  export function createTelegramBusLeaderEnvelopeHandler(deps: {
862
959
  followerRegistry: TelegramBusFollowerRegistry;
863
960
  authSecret?: string;
@@ -883,38 +980,13 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
883
980
  follower: TelegramBusFollowerView,
884
981
  ) => Promise<void> | void;
885
982
  getCurrentLeaderEpoch?: () => number | string | undefined;
983
+ runFollowerMutation?: TelegramBusFollowerMutationRunner;
886
984
  }): (
887
985
  envelope: TelegramBusEnvelope,
888
986
  ) => Promise<TelegramBusEnvelope> | TelegramBusEnvelope {
889
987
  const getNowMs = deps.getNowMs ?? Date.now;
890
- const followerMutationTails = new Map<string, Promise<void>>();
891
- const getFollowerMutationKey = (follower: {
892
- instanceId: string;
893
- profileKey?: string;
894
- }): string =>
895
- follower.profileKey
896
- ? `profile:${follower.profileKey}`
897
- : `instance:${follower.instanceId}`;
898
- const runFollowerMutation = async <T>(
899
- mutationKey: string,
900
- operation: () => Promise<T>,
901
- ): Promise<T> => {
902
- const previous = followerMutationTails.get(mutationKey);
903
- let release!: () => void;
904
- const current = new Promise<void>((resolve) => {
905
- release = resolve;
906
- });
907
- followerMutationTails.set(mutationKey, current);
908
- if (previous) await previous;
909
- try {
910
- return await operation();
911
- } finally {
912
- release();
913
- if (followerMutationTails.get(mutationKey) === current) {
914
- followerMutationTails.delete(mutationKey);
915
- }
916
- }
917
- };
988
+ const runFollowerMutation =
989
+ deps.runFollowerMutation ?? createTelegramBusFollowerMutationRunner();
918
990
  const forwardToFollower = async (
919
991
  envelope: Extract<
920
992
  TelegramBusEnvelope,
@@ -977,9 +1049,7 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
977
1049
  }
978
1050
  switch (envelope.kind) {
979
1051
  case "follower.register": {
980
- return runFollowerMutation(
981
- getFollowerMutationKey(envelope.registration),
982
- async () => {
1052
+ return runFollowerMutation(envelope.registration, async () => {
983
1053
  try {
984
1054
  if (!envelope.registration.registrationGeneration) {
985
1055
  throw new Error(
@@ -1041,9 +1111,7 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1041
1111
  case "follower.disconnect": {
1042
1112
  const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
1043
1113
  return runFollowerMutation(
1044
- getFollowerMutationKey(
1045
- registeredFollower ?? { instanceId: envelope.instanceId },
1046
- ),
1114
+ registeredFollower ?? { instanceId: envelope.instanceId },
1047
1115
  async () => {
1048
1116
  const follower = deps.followerRegistry.get(envelope.instanceId);
1049
1117
  if (!follower) {
@@ -1368,6 +1436,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
1368
1436
  const getNowMs = deps.getNowMs ?? Date.now;
1369
1437
  const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
1370
1438
  const followerStaleAfterMs = deps.followerStaleAfterMs ?? 5000;
1439
+ const runFollowerMutation = createTelegramBusFollowerMutationRunner();
1371
1440
  let pruneInterval: ReturnType<typeof setInterval> | undefined;
1372
1441
  const stopPruning = () => {
1373
1442
  if (!pruneInterval) return;
@@ -1387,14 +1456,87 @@ export function createTelegramBusLeaderRuntime<TContext>(
1387
1456
  followerStaleAfterMs,
1388
1457
  );
1389
1458
  for (const follower of removed) {
1390
- deps.recordRuntimeEvent?.(
1391
- "bus",
1392
- "Telegram bus follower heartbeat stale; preserving thread binding",
1393
- {
1394
- phase: "follower-pruned",
1459
+ let processConfirmedDead = false;
1460
+ if (follower.pid !== undefined && deps.isFollowerProcessAlive) {
1461
+ try {
1462
+ processConfirmedDead = !deps.isFollowerProcessAlive(follower.pid);
1463
+ } catch (error) {
1464
+ deps.recordRuntimeEvent?.("bus", error, {
1465
+ phase: "follower-process-liveness",
1466
+ instanceId: follower.instanceId,
1467
+ pid: follower.pid,
1468
+ });
1469
+ }
1470
+ }
1471
+ if (!processConfirmedDead) {
1472
+ deps.recordRuntimeEvent?.(
1473
+ "bus",
1474
+ "Telegram bus follower heartbeat stale; preserving thread binding",
1475
+ {
1476
+ phase: "follower-pruned",
1477
+ instanceId: follower.instanceId,
1478
+ processLiveness:
1479
+ follower.pid === undefined || !deps.isFollowerProcessAlive
1480
+ ? "unknown"
1481
+ : "alive-or-unknown",
1482
+ },
1483
+ );
1484
+ continue;
1485
+ }
1486
+ let cleanupEnabled = false;
1487
+ try {
1488
+ cleanupEnabled =
1489
+ (await deps.shouldCleanupConfirmedDeadFollower?.()) ?? false;
1490
+ } catch (error) {
1491
+ deps.recordRuntimeEvent?.("bus", error, {
1492
+ phase: "follower-confirmed-dead-cleanup-policy",
1395
1493
  instanceId: follower.instanceId,
1396
- },
1397
- );
1494
+ pid: follower.pid,
1495
+ });
1496
+ }
1497
+ if (!cleanupEnabled || !deps.onFollowerConfirmedDead) {
1498
+ deps.recordRuntimeEvent?.(
1499
+ "bus",
1500
+ "Telegram bus follower process confirmed dead; preserving thread binding",
1501
+ {
1502
+ phase: "follower-confirmed-dead-preserved",
1503
+ instanceId: follower.instanceId,
1504
+ pid: follower.pid,
1505
+ cleanupEnabled,
1506
+ },
1507
+ );
1508
+ continue;
1509
+ }
1510
+ try {
1511
+ await runFollowerMutation(follower, async () => {
1512
+ const replacement = deps.followerRegistry.list().find((candidate) =>
1513
+ follower.profileKey
1514
+ ? candidate.profileKey === follower.profileKey
1515
+ : candidate.instanceId === follower.instanceId,
1516
+ );
1517
+ if (replacement) {
1518
+ deps.recordRuntimeEvent?.(
1519
+ "bus",
1520
+ "Telegram bus follower replaced before confirmed-dead cleanup; preserving thread binding",
1521
+ {
1522
+ phase: "follower-confirmed-dead-replaced",
1523
+ instanceId: follower.instanceId,
1524
+ replacementInstanceId: replacement.instanceId,
1525
+ },
1526
+ );
1527
+ return;
1528
+ }
1529
+ await deps.onFollowerConfirmedDead!(follower);
1530
+ });
1531
+ } catch (error) {
1532
+ deps.recordRuntimeEvent?.("bus", error, {
1533
+ phase: "follower-confirmed-dead-cleanup",
1534
+ instanceId: follower.instanceId,
1535
+ pid: follower.pid,
1536
+ chatId: follower.target?.chatId,
1537
+ threadId: follower.target?.threadId,
1538
+ });
1539
+ }
1398
1540
  }
1399
1541
  };
1400
1542
  const startPruning = () => {
@@ -1423,14 +1565,17 @@ export function createTelegramBusLeaderRuntime<TContext>(
1423
1565
  provisionFollowerTarget: deps.provisionFollowerTarget,
1424
1566
  onFollowerDisconnected: deps.onFollowerDisconnected,
1425
1567
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
1568
+ runFollowerMutation,
1426
1569
  }),
1427
1570
  });
1428
1571
  return {
1429
1572
  startPolling: async (ctx) => {
1573
+ // Replay durable cleanup before publishing the follower endpoint so a
1574
+ // replacement registration cannot reclaim a target while it is deleted.
1575
+ await deps.provisionLeaderTarget?.(ctx);
1430
1576
  await localServer.start();
1431
1577
  startPruning();
1432
1578
  try {
1433
- await deps.provisionLeaderTarget?.(ctx);
1434
1579
  await deps.startPolling(ctx);
1435
1580
  } catch (error) {
1436
1581
  stopPruning();
package/lib/bus.ts CHANGED
@@ -218,6 +218,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
218
218
  "deleteForumTopic",
219
219
  "deleteMessage",
220
220
  "editForumTopic",
221
+ "editMessageReplyMarkup",
221
222
  "editMessageText",
222
223
  "sendChatAction",
223
224
  "sendMessage",
@@ -296,7 +297,11 @@ export function isTelegramFollowerApiCallAllowed(input: {
296
297
  const body = input.args[1] as Record<string, unknown>;
297
298
  return body.message_thread_id === undefined && isTargetChatScoped(body);
298
299
  }
299
- if (apiMethod === "deleteMessage" || apiMethod === "editMessageText") {
300
+ if (
301
+ apiMethod === "deleteMessage" ||
302
+ apiMethod === "editMessageReplyMarkup" ||
303
+ apiMethod === "editMessageText"
304
+ ) {
300
305
  if (!isTargetMessageScoped(input.args[1])) return false;
301
306
  const body = input.args[1] as Record<string, unknown>;
302
307
  const messageId =
package/lib/config.ts CHANGED
@@ -885,7 +885,7 @@ export function createTelegramAutomaticThreadCleanupResolver(
885
885
  await loadLatestTelegramConfig(configStore);
886
886
  if (configStore.didLastLoadRecoverInvalidConfig?.()) {
887
887
  throw new Error(
888
- "Automatic thread cleanup setting is unavailable after invalid Telegram config recovery.",
888
+ "Thread cleanup setting is unavailable after invalid Telegram config recovery.",
889
889
  );
890
890
  }
891
891
  return createTelegramAutomaticThreadCleanupChecker(configStore)();
package/lib/keyboard.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  export interface TelegramInlineKeyboardButton {
8
8
  text: string;
9
9
  callback_data: string;
10
+ style?: "danger" | "success" | "primary";
10
11
  }
11
12
 
12
13
  export interface TelegramInlineKeyboardMarkup {
@@ -133,7 +133,7 @@ export interface TelegramSettingsMenuRuntimeDeps<
133
133
 
134
134
  export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
135
135
  export const AUTOMATIC_THREAD_CLEANUP_SETTINGS_TITLE =
136
- "<b>🧹 Automatic thread cleanup:</b>";
136
+ "<b>🧹 Thread cleanup:</b>";
137
137
  export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>📌 Proactive push:</b>";
138
138
  export const DRAFT_PREVIEWS_SETTINGS_TITLE = "<b>📝 Draft previews:</b>";
139
139
  export const ASSISTANT_RENDERING_SETTINGS_TITLE =
@@ -172,7 +172,7 @@ export function buildAutomaticThreadCleanupSettingsText(
172
172
  "Delete this Pi instance's Telegram tab when Pi quits normally.",
173
173
  "",
174
174
  "<code>-</code> <code>on</code> (default): delete the bound thread and release Telegram authority on graceful quit.",
175
- "<code>-</code> <code>off</code>: preserve the tab as a restart hint; manual /telegram-disconnect still confirms and deletes it.",
175
+ "<code>-</code> <code>off</code>: preserve the tab as a restart hint; manual <code>/telegram-disconnect</code> still confirms and deletes it.",
176
176
  ].join("\n");
177
177
  }
178
178
 
@@ -290,7 +290,7 @@ export function buildTelegramSettingsMenuReplyMarkup(
290
290
  rows.push(
291
291
  [
292
292
  {
293
- text: `🧹 Auto thread cleanup: ${automaticThreadCleanupEnabled ? "on" : "off"}`,
293
+ text: `🧹 Thread cleanup: ${automaticThreadCleanupEnabled ? "on" : "off"}`,
294
294
  callback_data: "settings:open:automatic-thread-cleanup",
295
295
  },
296
296
  ],
@@ -669,7 +669,7 @@ export async function handleTelegramSettingsMenuCallbackAction(
669
669
  await updateAutomaticThreadCleanupSettingsMessage(deps);
670
670
  await deps.answerCallbackQuery(
671
671
  callbackQueryId,
672
- `Automatic thread cleanup ${enabled ? "enabled" : "disabled"}`,
672
+ `Thread cleanup ${enabled ? "enabled" : "disabled"}`,
673
673
  );
674
674
  return true;
675
675
  }
@@ -709,8 +709,7 @@ export function createTelegramSettingsMenuRuntime<
709
709
  getVoiceReplyMode: deps.getVoiceReplyMode,
710
710
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
711
711
  getTimeInjectionMode: deps.getTimeInjectionMode,
712
- isAutomaticThreadCleanupEnabled:
713
- deps.isAutomaticThreadCleanupEnabled,
712
+ isAutomaticThreadCleanupEnabled: deps.isAutomaticThreadCleanupEnabled,
714
713
  sendSettingsMenu: (state, text, replyMarkup) =>
715
714
  deps.sendInteractiveMessage(
716
715
  state.chatId,
@@ -733,8 +732,7 @@ export function createTelegramSettingsMenuRuntime<
733
732
  getVoiceReplyMode: deps.getVoiceReplyMode,
734
733
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
735
734
  getTimeInjectionMode: deps.getTimeInjectionMode,
736
- isAutomaticThreadCleanupEnabled:
737
- deps.isAutomaticThreadCleanupEnabled,
735
+ isAutomaticThreadCleanupEnabled: deps.isAutomaticThreadCleanupEnabled,
738
736
  updateSettingsMessage: (text, replyMarkup) =>
739
737
  deps.editInteractiveMessage(
740
738
  state.chatId,
@@ -777,15 +775,13 @@ export function createTelegramSettingsMenuRuntime<
777
775
  getVoiceReplyMode: deps.getVoiceReplyMode,
778
776
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
779
777
  getTimeInjectionMode: deps.getTimeInjectionMode,
780
- isAutomaticThreadCleanupEnabled:
781
- deps.isAutomaticThreadCleanupEnabled,
778
+ isAutomaticThreadCleanupEnabled: deps.isAutomaticThreadCleanupEnabled,
782
779
  setProactivePushEnabled: deps.setProactivePushEnabled,
783
780
  setDraftPreviewsEnabled: deps.setDraftPreviewsEnabled,
784
781
  setAssistantRenderingMode: deps.setAssistantRenderingMode,
785
782
  setVoiceReplyMode: deps.setVoiceReplyMode,
786
783
  setTimeInjectionMode: deps.setTimeInjectionMode,
787
- setAutomaticThreadCleanupEnabled:
788
- deps.setAutomaticThreadCleanupEnabled,
784
+ setAutomaticThreadCleanupEnabled: deps.setAutomaticThreadCleanupEnabled,
789
785
  updateSettingsMessage: (text, replyMarkup) =>
790
786
  deps.editInteractiveMessage(
791
787
  state.chatId,
@@ -51,6 +51,7 @@ export interface TelegramButtonCallbackQuery {
51
51
  message_id?: number;
52
52
  message_thread_id?: number;
53
53
  chat?: { id?: number };
54
+ reply_markup?: TelegramOutboundButtonMarkup;
54
55
  };
55
56
  }
56
57
 
@@ -66,7 +67,12 @@ export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
66
67
  query: TelegramButtonCallbackQuery,
67
68
  action: TelegramOutboundButtonAction,
68
69
  ctx: TContext,
69
- ) => void;
70
+ ) => boolean | void;
71
+ editMessageReplyMarkup?: (
72
+ chatId: number,
73
+ messageId: number,
74
+ replyMarkup: TelegramOutboundButtonMarkup,
75
+ ) => Promise<void>;
70
76
  }
71
77
 
72
78
  function nowMs(): number {
@@ -203,6 +209,21 @@ export function createTelegramButtonPromptTurn(options: {
203
209
  };
204
210
  }
205
211
 
212
+ export function markTelegramButtonSelected(
213
+ replyMarkup: TelegramOutboundButtonMarkup,
214
+ callbackData: string,
215
+ ): TelegramOutboundButtonMarkup | undefined {
216
+ let matched = false;
217
+ const inlineKeyboard = replyMarkup.inline_keyboard.map((row) =>
218
+ row.map((button) => {
219
+ if (button.callback_data !== callbackData) return { ...button };
220
+ matched = true;
221
+ return { ...button, style: "success" as const };
222
+ }),
223
+ );
224
+ return matched ? { inline_keyboard: inlineKeyboard } : undefined;
225
+ }
226
+
206
227
  export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
207
228
  query: TelegramButtonCallbackQuery,
208
229
  ctx: TContext,
@@ -225,7 +246,18 @@ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
225
246
  return true;
226
247
  }
227
248
 
228
- deps.enqueueButtonPrompt(query, action, ctx);
249
+ const enqueued = deps.enqueueButtonPrompt(query, action, ctx);
250
+ if (enqueued === false) {
251
+ await deps.answerCallbackQuery(query.id, "Already queued.");
252
+ return true;
253
+ }
254
+ const selectedMarkup =
255
+ query.data && query.message?.reply_markup
256
+ ? markTelegramButtonSelected(query.message.reply_markup, query.data)
257
+ : undefined;
258
+ if (selectedMarkup && deps.editMessageReplyMarkup) {
259
+ await deps.editMessageReplyMarkup(chatId, messageId, selectedMarkup);
260
+ }
229
261
  await deps.answerCallbackQuery(query.id, "Queued.");
230
262
  return true;
231
263
  }
package/lib/outbound.ts CHANGED
@@ -817,6 +817,7 @@ export {
817
817
  createTelegramButtonPromptTurn,
818
818
  createTelegramButtonReplyPlanner,
819
819
  handleTelegramButtonCallbackQuery,
820
+ markTelegramButtonSelected,
820
821
  planTelegramButtonReply,
821
822
  type TelegramButtonActionStore,
822
823
  type TelegramButtonCallbackHandlerDeps,
package/lib/routing.ts CHANGED
@@ -608,6 +608,11 @@ export interface TelegramInboundRouteRuntimeDeps<
608
608
  mode: "markdown" | "html" | "plain",
609
609
  replyMarkup: Menu.TelegramReplyMarkup,
610
610
  ) => Promise<void>;
611
+ editMessageReplyMarkup?: (
612
+ chatId: number,
613
+ messageId: number,
614
+ replyMarkup: OutboundHandlers.TelegramOutboundButtonMarkup,
615
+ ) => Promise<void>;
611
616
  sendInteractiveMessage?: (
612
617
  chatId: number,
613
618
  text: string,
@@ -1448,11 +1453,28 @@ export function createTelegramInboundRouteRuntime<
1448
1453
  {
1449
1454
  resolveAction: deps.buttonActionStore.resolve,
1450
1455
  answerCallbackQuery: deps.answerCallbackQuery,
1456
+ editMessageReplyMarkup: deps.editMessageReplyMarkup
1457
+ ? async (chatId, messageId, replyMarkup) => {
1458
+ try {
1459
+ await deps.editMessageReplyMarkup?.(
1460
+ chatId,
1461
+ messageId,
1462
+ replyMarkup,
1463
+ );
1464
+ } catch (error) {
1465
+ deps.recordRuntimeEvent?.("telegram", error, {
1466
+ phase: "button-selection-mark",
1467
+ chatId,
1468
+ messageId,
1469
+ });
1470
+ }
1471
+ }
1472
+ : undefined,
1451
1473
  enqueueButtonPrompt: (buttonQuery, action, context) => {
1452
1474
  const chatId = buttonQuery.message?.chat?.id;
1453
1475
  const messageId = buttonQuery.message?.message_id;
1454
1476
  if (typeof chatId !== "number" || typeof messageId !== "number")
1455
- return;
1477
+ return false;
1456
1478
  const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder();
1457
1479
  const turn = OutboundHandlers.createTelegramButtonPromptTurn({
1458
1480
  chatId,
@@ -1471,10 +1493,11 @@ export function createTelegramInboundRouteRuntime<
1471
1493
  deps.telegramQueueStore.getQueuedItems(),
1472
1494
  turn,
1473
1495
  );
1474
- if (!result.appended) return;
1496
+ if (!result.appended) return false;
1475
1497
  deps.telegramQueueStore.setQueuedItems(result.items);
1476
1498
  deps.updateStatus(context);
1477
1499
  requestDispatchNextQueuedTelegramTurn(context);
1500
+ return true;
1478
1501
  },
1479
1502
  },
1480
1503
  );