@llblab/pi-telegram 0.20.3 → 0.20.5

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.
@@ -88,6 +88,7 @@ export interface TelegramQueuedOutboundAttachmentView {
88
88
 
89
89
  export interface TelegramOutboundAttachmentQueueTargetView {
90
90
  queuedAttachments: TelegramQueuedOutboundAttachmentView[];
91
+ guestQueryId?: string;
91
92
  }
92
93
 
93
94
  export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
@@ -96,6 +97,42 @@ export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutbou
96
97
  target?: TelegramTarget;
97
98
  }
98
99
 
100
+ export type TelegramGuestCachedAttachmentResult =
101
+ | {
102
+ type: "document";
103
+ id: string;
104
+ title: string;
105
+ document_file_id: string;
106
+ caption?: string;
107
+ }
108
+ | {
109
+ type: "photo";
110
+ id: string;
111
+ photo_file_id: string;
112
+ caption?: string;
113
+ }
114
+ | {
115
+ type: "audio";
116
+ id: string;
117
+ audio_file_id: string;
118
+ caption?: string;
119
+ }
120
+ | {
121
+ type: "voice";
122
+ id: string;
123
+ voice_file_id: string;
124
+ title: string;
125
+ caption?: string;
126
+ };
127
+
128
+ interface TelegramGuestStagingMessage {
129
+ message_id?: number;
130
+ document?: { file_id?: string };
131
+ photo?: Array<{ file_id?: string; file_size?: number }>;
132
+ audio?: { file_id?: string };
133
+ voice?: { file_id?: string };
134
+ }
135
+
99
136
  function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
100
137
  const normalized = path.toLowerCase();
101
138
  return (
@@ -107,6 +144,27 @@ function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
107
144
  );
108
145
  }
109
146
 
147
+ function getTelegramGuestAttachmentTransport(path: string): {
148
+ method: "sendDocument" | "sendPhoto" | "sendAudio" | "sendVoice";
149
+ fileField: "document" | "photo" | "audio" | "voice";
150
+ } {
151
+ const normalized = path.toLowerCase();
152
+ if (
153
+ normalized.endsWith(".jpg") ||
154
+ normalized.endsWith(".jpeg") ||
155
+ normalized.endsWith(".png")
156
+ ) {
157
+ return { method: "sendPhoto", fileField: "photo" };
158
+ }
159
+ if (normalized.endsWith(".ogg") || normalized.endsWith(".opus")) {
160
+ return { method: "sendVoice", fileField: "voice" };
161
+ }
162
+ if (normalized.endsWith(".mp3")) {
163
+ return { method: "sendAudio", fileField: "audio" };
164
+ }
165
+ return { method: "sendDocument", fileField: "document" };
166
+ }
167
+
110
168
  function formatTelegramOutboundAttachmentSizeLimitError(
111
169
  size: number,
112
170
  maxSize: number,
@@ -388,6 +446,14 @@ export async function queueTelegramOutboundAttachments(options: {
388
446
  statPath: options.statPath,
389
447
  });
390
448
  }
449
+ if (
450
+ options.activeTurn.guestQueryId &&
451
+ options.activeTurn.queuedAttachments.length + options.paths.length > 1
452
+ ) {
453
+ throw new Error(
454
+ "Telegram Guest Mode supports one attachment per reply; no attachment was queued",
455
+ );
456
+ }
391
457
  if (
392
458
  options.activeTurn.queuedAttachments.length + options.paths.length >
393
459
  options.maxAttachmentsPerTurn
@@ -414,6 +480,110 @@ export async function queueTelegramOutboundAttachments(options: {
414
480
  };
415
481
  }
416
482
 
483
+ export async function deliverTelegramGuestCachedAttachment(options: {
484
+ guestQueryId: string;
485
+ stagingChatId: number;
486
+ stagingTarget?: TelegramTarget;
487
+ attachment: TelegramQueuedOutboundAttachmentView;
488
+ caption?: string;
489
+ sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
490
+ answerGuestQuery: (
491
+ guestQueryId: string,
492
+ result: TelegramGuestCachedAttachmentResult,
493
+ ) => Promise<void>;
494
+ answerGuestText?: (guestQueryId: string, text: string) => Promise<void>;
495
+ fallbackText?: string;
496
+ deleteMessage: (chatId: number, messageId: number) => Promise<void>;
497
+ recordRuntimeEvent?: TelegramOutboundAttachmentRuntimeEventRecorderPort["recordRuntimeEvent"];
498
+ }): Promise<void> {
499
+ const transport = getTelegramGuestAttachmentTransport(options.attachment.path);
500
+ let stagingMessageId: number | undefined;
501
+ let answerAttempted = false;
502
+ try {
503
+ const message = (await options.sendMultipart(
504
+ transport.method,
505
+ {
506
+ chat_id: String(options.stagingChatId),
507
+ ...getTelegramMultipartTargetFields(options.stagingTarget),
508
+ },
509
+ transport.fileField,
510
+ options.attachment.path,
511
+ options.attachment.fileName,
512
+ )) as TelegramGuestStagingMessage;
513
+ stagingMessageId = message.message_id;
514
+ const caption = options.caption
515
+ ? Array.from(options.caption).slice(0, 1024).join("")
516
+ : undefined;
517
+ let result: TelegramGuestCachedAttachmentResult;
518
+ if (transport.fileField === "photo") {
519
+ const photo = [...(message.photo ?? [])]
520
+ .sort((left, right) => (left.file_size ?? 0) - (right.file_size ?? 0))
521
+ .at(-1);
522
+ if (!photo?.file_id) throw new Error("Guest staging upload returned no photo file_id");
523
+ result = {
524
+ type: "photo",
525
+ id: "attachment-1",
526
+ photo_file_id: photo.file_id,
527
+ ...(caption ? { caption } : {}),
528
+ };
529
+ } else if (transport.fileField === "audio") {
530
+ if (!message.audio?.file_id)
531
+ throw new Error("Guest staging upload returned no audio file_id");
532
+ result = {
533
+ type: "audio",
534
+ id: "attachment-1",
535
+ audio_file_id: message.audio.file_id,
536
+ ...(caption ? { caption } : {}),
537
+ };
538
+ } else if (transport.fileField === "voice") {
539
+ if (!message.voice?.file_id)
540
+ throw new Error("Guest staging upload returned no voice file_id");
541
+ result = {
542
+ type: "voice",
543
+ id: "attachment-1",
544
+ voice_file_id: message.voice.file_id,
545
+ title: options.attachment.fileName,
546
+ ...(caption ? { caption } : {}),
547
+ };
548
+ } else {
549
+ if (!message.document?.file_id)
550
+ throw new Error("Guest staging upload returned no document file_id");
551
+ result = {
552
+ type: "document",
553
+ id: "attachment-1",
554
+ title: options.attachment.fileName,
555
+ document_file_id: message.document.file_id,
556
+ ...(caption ? { caption } : {}),
557
+ };
558
+ }
559
+ answerAttempted = true;
560
+ await options.answerGuestQuery(options.guestQueryId, result);
561
+ } catch (error) {
562
+ if (
563
+ !answerAttempted &&
564
+ options.answerGuestText &&
565
+ options.fallbackText
566
+ ) {
567
+ answerAttempted = true;
568
+ await options.answerGuestText(options.guestQueryId, options.fallbackText);
569
+ } else {
570
+ throw error;
571
+ }
572
+ } finally {
573
+ if (stagingMessageId !== undefined) {
574
+ try {
575
+ await options.deleteMessage(options.stagingChatId, stagingMessageId);
576
+ } catch (error) {
577
+ options.recordRuntimeEvent?.("attachment", error, {
578
+ phase: "guest-staging-cleanup",
579
+ chatId: options.stagingChatId,
580
+ messageId: stagingMessageId,
581
+ });
582
+ }
583
+ }
584
+ }
585
+ }
586
+
417
587
  export async function sendTelegramOutboundMessage(options: {
418
588
  text: string;
419
589
  chatId?: number;
package/lib/paths.ts CHANGED
@@ -76,9 +76,10 @@ export function getTelegramDiagnosticsDisplayPaths(profileName?: string): {
76
76
  logs: string;
77
77
  } {
78
78
  const suffix = getTelegramProfilePathSuffix(profileName);
79
+ const profileSlug = suffix.slice(1);
79
80
  return {
80
81
  state: `~/.pi/agent/tmp/telegram/state${suffix}.json`,
81
- logs: `~/.pi/agent/tmp/telegram/logs${suffix}.jsonl`,
82
+ logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
82
83
  };
83
84
  }
84
85
 
package/lib/queue.ts CHANGED
@@ -899,6 +899,16 @@ export interface TelegramAgentEndRuntimeDeps<
899
899
  options?: { parseMode?: string },
900
900
  ) => Promise<void>;
901
901
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
902
+ sendGuestAttachment?: (
903
+ turn: TTurn,
904
+ attachment: QueuedAttachment,
905
+ caption?: string,
906
+ ) => Promise<void>;
907
+ sendGuestVoiceReply?: (
908
+ turn: TTurn,
909
+ plan: TelegramAgentEndOutboundReplyPlan<TReplyMarkup>,
910
+ caption?: string,
911
+ ) => Promise<void>;
902
912
  planOutboundReply?: (
903
913
  markdown: string,
904
914
  ) => TelegramAgentEndOutboundReplyPlan<TReplyMarkup>;
@@ -958,6 +968,8 @@ export interface TelegramAgentEndHookRuntimeDeps<
958
968
  sendQueuedAttachments: (turn: TTurn) => Promise<void>;
959
969
  answerGuestQuery?: TelegramAgentEndRuntimeDeps<TTurn>["answerGuestQuery"];
960
970
  sendGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestReply"];
971
+ sendGuestAttachment?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestAttachment"];
972
+ sendGuestVoiceReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestVoiceReply"];
961
973
  planOutboundReply?: TelegramAgentEndRuntimeDeps<
962
974
  TTurn,
963
975
  TReplyMarkup
@@ -1083,6 +1095,8 @@ export function createTelegramAgentEndHook<
1083
1095
  sendQueuedAttachments: deps.sendQueuedAttachments,
1084
1096
  answerGuestQuery: deps.answerGuestQuery,
1085
1097
  sendGuestReply: deps.sendGuestReply,
1098
+ sendGuestAttachment: deps.sendGuestAttachment,
1099
+ sendGuestVoiceReply: deps.sendGuestVoiceReply,
1086
1100
  planOutboundReply: deps.planOutboundReply,
1087
1101
  sendOutboundReplyArtifacts: deps.sendOutboundReplyArtifacts,
1088
1102
  getDefaultChatId: deps.getDefaultChatId,
@@ -1181,7 +1195,38 @@ export async function handleTelegramAgentEndRuntime<
1181
1195
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1182
1196
  return;
1183
1197
  }
1184
- if (finalText) {
1198
+ const [guestAttachment] = turn.queuedAttachments;
1199
+ if (guestAttachment && deps.sendGuestAttachment) {
1200
+ try {
1201
+ await deps.sendGuestAttachment(
1202
+ turn,
1203
+ guestAttachment,
1204
+ finalText || undefined,
1205
+ );
1206
+ } catch (error) {
1207
+ deps.recordRuntimeEvent?.("delivery", error, {
1208
+ phase: "guest-attachment",
1209
+ guestQueryId: turn.guestQueryId,
1210
+ });
1211
+ }
1212
+ } else if (
1213
+ outboundReply &&
1214
+ (outboundReply.voiceText || outboundReply.voiceReplies?.length) &&
1215
+ deps.sendGuestVoiceReply
1216
+ ) {
1217
+ try {
1218
+ await deps.sendGuestVoiceReply(
1219
+ turn,
1220
+ outboundReply,
1221
+ finalText || undefined,
1222
+ );
1223
+ } catch (error) {
1224
+ deps.recordRuntimeEvent?.("delivery", error, {
1225
+ phase: "guest-voice",
1226
+ guestQueryId: turn.guestQueryId,
1227
+ });
1228
+ }
1229
+ } else if (finalText) {
1185
1230
  if (deps.sendGuestReply) {
1186
1231
  await deps.sendGuestReply(turn.guestQueryId, finalText);
1187
1232
  } else {
package/lib/setup.ts CHANGED
@@ -197,8 +197,14 @@ export function createTelegramSetupPromptRuntime<
197
197
  promptEditor: (label, value) => ctx.ui.editor(label, value),
198
198
  getMe: deps.getMe,
199
199
  persistConfig: async (config) => {
200
- await deps.persistConfig(config);
200
+ const previousConfig = deps.getConfig();
201
201
  deps.setConfig(config);
202
+ try {
203
+ await deps.persistConfig(config);
204
+ } catch (error) {
205
+ deps.setConfig(previousConfig);
206
+ throw error;
207
+ }
202
208
  },
203
209
  notify: (message, level) => ctx.ui.notify(message, level),
204
210
  startPolling: () => deps.startPolling(ctx),
package/lib/status.ts CHANGED
@@ -181,6 +181,7 @@ export interface TelegramBridgeStatusLineState {
181
181
  hasBotToken?: boolean;
182
182
  botUsername?: string;
183
183
  activeProfileName?: string;
184
+ diagnosticPaths?: { state: string; logs: string };
184
185
  allowedUserId?: number;
185
186
  botThreadMode?: "unknown" | "enabled" | "disabled";
186
187
  botThreadModeUpdatedAtMs?: number;
@@ -259,6 +260,9 @@ export interface TelegramBridgeStatusRuntimeDeps<
259
260
  statusKey?: string;
260
261
  getConfig: () => TelegramBridgeStatusConfig;
261
262
  getActiveProfileName?: () => string | undefined;
263
+ getDiagnosticPaths?: (
264
+ profileName?: string,
265
+ ) => { state: string; logs: string };
262
266
  isPollingActive: () => boolean;
263
267
  getActiveSourceMessageIds: () => number[] | undefined;
264
268
  hasActiveTurn: () => boolean;
@@ -620,10 +624,12 @@ export function createTelegramBridgeStatusRuntime<
620
624
  getBridgeStatusLineState: () => {
621
625
  const config = deps.getConfig();
622
626
  const botThreadMode = deps.getBotThreadMode?.();
627
+ const activeProfileName = deps.getActiveProfileName?.();
623
628
  return {
624
629
  hasBotToken: Boolean(config.botToken),
625
630
  botUsername: config.botUsername,
626
- activeProfileName: deps.getActiveProfileName?.(),
631
+ activeProfileName,
632
+ diagnosticPaths: deps.getDiagnosticPaths?.(activeProfileName),
627
633
  allowedUserId: config.allowedUserId,
628
634
  botThreadMode: botThreadMode?.threadMode,
629
635
  botThreadModeUpdatedAtMs: botThreadMode?.updatedAtMs,
@@ -774,9 +780,6 @@ export function buildTelegramStatusBarText(
774
780
  return `${label} ${theme.fg("warning", "electing")}${queued}`;
775
781
  if (!state.pollingActive && state.busRole !== "follower")
776
782
  return `${theme.fg("accent", "telegram")} ${theme.fg("muted", "disconnected")}${queued}`;
777
- if (state.compactionInProgress) {
778
- return `${label} ${theme.fg("warning", "compacting")}${queued}`;
779
- }
780
783
  if (state.processing) {
781
784
  const processingStatus = state.queuedStatus
782
785
  ? "active"
@@ -1058,19 +1061,18 @@ function buildTelegramBridgeCompactStatusLines(
1058
1061
  ? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
1059
1062
  : ""
1060
1063
  }`;
1061
- const executionState = state.compactionInProgress
1062
- ? "compacting"
1063
- : state.pendingDispatch
1064
- ? "pending dispatch"
1065
- : state.activeSourceMessageIds?.length
1066
- ? "active"
1067
- : "idle";
1064
+ const executionState = state.pendingDispatch
1065
+ ? "pending dispatch"
1066
+ : state.activeSourceMessageIds?.length
1067
+ ? "active"
1068
+ : "idle";
1068
1069
  const profileSuffix = state.activeProfileName
1069
1070
  ? `.${state.activeProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
1070
1071
  : "";
1071
- const diagnosticsPaths = {
1072
+ const profileSlug = profileSuffix.slice(1);
1073
+ const diagnosticsPaths = state.diagnosticPaths ?? {
1072
1074
  state: `~/.pi/agent/tmp/telegram/state${profileSuffix}.json`,
1073
- logs: `~/.pi/agent/tmp/telegram/logs${profileSuffix}.jsonl`,
1075
+ logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
1074
1076
  };
1075
1077
  return [
1076
1078
  "connection:",
@@ -1251,7 +1253,6 @@ function buildContextSummary(
1251
1253
  }
1252
1254
 
1253
1255
  function buildStatusSummary(ctx: TelegramStatusContext): string {
1254
- if (ctx.isCompactionInProgress?.()) return "compacting";
1255
1256
  if (ctx.hasPendingMessages?.()) return "pending";
1256
1257
  if (ctx.isIdle?.() === false) return "active";
1257
1258
  if (ctx.isIdle?.() === true) return "idle";
@@ -287,6 +287,44 @@ export interface TelegramFileDownloadOptions {
287
287
  maxFileSizeBytes?: number;
288
288
  }
289
289
 
290
+ export type TelegramGuestCachedMediaResult =
291
+ | {
292
+ type: "document";
293
+ id: string;
294
+ title: string;
295
+ document_file_id: string;
296
+ caption?: string;
297
+ parse_mode?: string;
298
+ }
299
+ | {
300
+ type: "photo";
301
+ id: string;
302
+ photo_file_id: string;
303
+ caption?: string;
304
+ parse_mode?: string;
305
+ }
306
+ | {
307
+ type: "audio";
308
+ id: string;
309
+ audio_file_id: string;
310
+ caption?: string;
311
+ parse_mode?: string;
312
+ }
313
+ | {
314
+ type: "voice";
315
+ id: string;
316
+ voice_file_id: string;
317
+ title: string;
318
+ caption?: string;
319
+ parse_mode?: string;
320
+ };
321
+
322
+ export interface TelegramAnswerGuestQueryOptions {
323
+ parseMode?: string;
324
+ richMessage?: TelegramInputRichMessage;
325
+ result?: TelegramGuestCachedMediaResult;
326
+ }
327
+
290
328
  export interface TelegramAnswerCallbackQueryOptions {
291
329
  recordRuntimeEvent?: (
292
330
  kind: "api",
@@ -322,7 +360,7 @@ export interface TelegramApiClient {
322
360
  answerGuestQuery?: (
323
361
  guestQueryId: string,
324
362
  text?: string,
325
- options?: { parseMode?: string; richMessage?: TelegramInputRichMessage },
363
+ options?: TelegramAnswerGuestQueryOptions,
326
364
  ) => Promise<void>;
327
365
  }
328
366
 
@@ -401,7 +439,7 @@ export interface TelegramBridgeApiRuntime {
401
439
  answerGuestQuery: (
402
440
  guestQueryId: string,
403
441
  text?: string,
404
- options?: { parseMode?: string; richMessage?: TelegramInputRichMessage },
442
+ options?: TelegramAnswerGuestQueryOptions,
405
443
  ) => Promise<void>;
406
444
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
407
445
  prepareTempDir: () => Promise<number>;
@@ -1312,12 +1350,12 @@ export function createTelegramBridgeApiRuntime(
1312
1350
  answerGuestQuery: (
1313
1351
  guestQueryId: string,
1314
1352
  text: string | undefined,
1315
- options:
1316
- | { parseMode?: string; richMessage?: TelegramInputRichMessage }
1317
- | undefined,
1353
+ options: TelegramAnswerGuestQueryOptions | undefined,
1318
1354
  ) => {
1319
1355
  const body: Record<string, unknown> = { guest_query_id: guestQueryId };
1320
- if (text !== undefined || options?.richMessage) {
1356
+ if (options?.result) {
1357
+ body.result = options.result;
1358
+ } else if (text !== undefined || options?.richMessage) {
1321
1359
  const inputContent: Record<string, unknown> = options?.richMessage
1322
1360
  ? { rich_message: options.richMessage }
1323
1361
  : { message_text: text };
package/lib/threads.ts CHANGED
@@ -183,6 +183,9 @@ export interface TelegramTopicTargetStore {
183
183
  load: () => Promise<void>;
184
184
  persist: () => Promise<void>;
185
185
  list: () => TelegramTopicTargetRecord[];
186
+ getFollowerRecoveryHintByTarget?: (
187
+ target: TelegramTarget,
188
+ ) => { slot?: string; threadName?: string } | undefined;
186
189
  listReservations: () => TelegramThreadReservation[];
187
190
  listPendingProvisions: () => TelegramThreadPendingProvision[];
188
191
  listSyncObservations: () => TelegramTopicSyncObservation[];
@@ -266,6 +269,7 @@ export function reconcileTelegramFreshAllocationCursor(
266
269
  export interface TelegramTopicTargetStoreOptions {
267
270
  path: string | (() => string);
268
271
  getNowMs?: () => number;
272
+ canPersist?: () => boolean;
269
273
  }
270
274
 
271
275
  export interface TelegramTopicTargetProvisionerDeps {
@@ -869,6 +873,54 @@ function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
869
873
  return left.chatId === right.chatId && left.threadId === right.threadId;
870
874
  }
871
875
 
876
+ function getTargetRecoveryHintKey(target: TelegramTarget): string {
877
+ return `${target.chatId}:${target.threadId ?? "private"}`;
878
+ }
879
+
880
+ function parseFollowerRecoveryHints(
881
+ value: unknown,
882
+ ): Map<string, { slot?: string; threadName?: string }> {
883
+ const hints = new Map<string, { slot?: string; threadName?: string }>();
884
+ if (!value || typeof value !== "object" || Array.isArray(value)) return hints;
885
+ const liveRoster = (value as Record<string, unknown>).liveRoster;
886
+ if (!liveRoster || typeof liveRoster !== "object" || Array.isArray(liveRoster))
887
+ return hints;
888
+ const followers = (liveRoster as Record<string, unknown>).busFollowers;
889
+ if (!Array.isArray(followers)) return hints;
890
+ for (const follower of followers) {
891
+ if (!follower || typeof follower !== "object" || Array.isArray(follower))
892
+ continue;
893
+ const record = follower as Record<string, unknown>;
894
+ const target = record.target;
895
+ if (!target || typeof target !== "object" || Array.isArray(target)) continue;
896
+ const targetRecord = target as Record<string, unknown>;
897
+ if (typeof targetRecord.chatId !== "number") continue;
898
+ const normalizedTarget: TelegramTarget = {
899
+ chatId: targetRecord.chatId,
900
+ ...(typeof targetRecord.threadId === "number"
901
+ ? { threadId: targetRecord.threadId }
902
+ : {}),
903
+ };
904
+ const slot =
905
+ typeof targetRecord.slot === "string" && /^[A-Z]$/.test(targetRecord.slot)
906
+ ? targetRecord.slot
907
+ : typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)
908
+ ? record.slot
909
+ : undefined;
910
+ const threadName =
911
+ typeof targetRecord.threadName === "string"
912
+ ? targetRecord.threadName
913
+ : typeof record.threadName === "string"
914
+ ? record.threadName
915
+ : undefined;
916
+ hints.set(getTargetRecoveryHintKey(normalizedTarget), {
917
+ ...(slot ? { slot } : {}),
918
+ ...(threadName ? { threadName } : {}),
919
+ });
920
+ }
921
+ return hints;
922
+ }
923
+
872
924
  function getInstanceProcessKey(
873
925
  instanceId: string | undefined,
874
926
  ): string | undefined {
@@ -905,6 +957,10 @@ export function createTelegramTopicTargetStore(
905
957
  let reservations: TelegramThreadReservation[] = [];
906
958
  let pendingProvisions: TelegramThreadPendingProvision[] = [];
907
959
  let syncObservations: TelegramTopicSyncObservation[] = [];
960
+ let followerRecoveryHints = new Map<
961
+ string,
962
+ { slot?: string; threadName?: string }
963
+ >();
908
964
  let loaded = false;
909
965
  let loadedPath: string | undefined;
910
966
  let dirty = false;
@@ -939,6 +995,7 @@ export function createTelegramTopicTargetStore(
939
995
  reservations = [];
940
996
  pendingProvisions = [];
941
997
  syncObservations = [];
998
+ followerRecoveryHints = new Map();
942
999
  statusSnapshot = {};
943
1000
  loaded = false;
944
1001
  dirty = false;
@@ -955,11 +1012,14 @@ export function createTelegramTopicTargetStore(
955
1012
  reservations = [];
956
1013
  pendingProvisions = [];
957
1014
  syncObservations = [];
1015
+ followerRecoveryHints = new Map();
958
1016
  loaded = true;
959
1017
  return;
960
1018
  }
961
1019
  const content = await readFile(path, "utf8");
962
- const file = parseTopicTargetFile(JSON.parse(content));
1020
+ const rawFile: unknown = JSON.parse(content);
1021
+ const file = parseTopicTargetFile(rawFile);
1022
+ followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
963
1023
  botState = file.bot;
964
1024
  records = new Map(
965
1025
  file.threads.map((record) => [
@@ -993,6 +1053,7 @@ export function createTelegramTopicTargetStore(
993
1053
  target: { ...observation.target },
994
1054
  }));
995
1055
  loaded = true;
1056
+ dirty = false;
996
1057
  };
997
1058
 
998
1059
  return {
@@ -1003,7 +1064,11 @@ export function createTelegramTopicTargetStore(
1003
1064
  async persist() {
1004
1065
  const path = getPath();
1005
1066
  if (loadedPath !== path && !dirty) resetForPath(path);
1006
- if (!loaded && !dirty) await loadFromDisk();
1067
+ if (options.canPersist && !options.canPersist()) {
1068
+ await loadFromDisk();
1069
+ return;
1070
+ }
1071
+ if (!dirty || !loaded) await loadFromDisk();
1007
1072
  await mkdir(dirname(path), { recursive: true });
1008
1073
  const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
1009
1074
  const nowMs = getNowMs();
@@ -1058,6 +1123,10 @@ export function createTelegramTopicTargetStore(
1058
1123
  list() {
1059
1124
  return Array.from(records.values()).map(cloneRecord);
1060
1125
  },
1126
+ getFollowerRecoveryHintByTarget(target) {
1127
+ const hint = followerRecoveryHints.get(getTargetRecoveryHintKey(target));
1128
+ return hint ? { ...hint } : undefined;
1129
+ },
1061
1130
  listReservations() {
1062
1131
  const nowMs = getNowMs();
1063
1132
  return reservations
@@ -1962,6 +2031,48 @@ export async function provisionOwnBusTopic(
1962
2031
  };
1963
2032
  }
1964
2033
 
2034
+ export interface TelegramInstanceThreadIdentityCandidate {
2035
+ target?: TelegramTarget;
2036
+ slot?: string;
2037
+ threadName?: string;
2038
+ }
2039
+
2040
+ export function resolveTelegramInstanceThreadIdentity(options: {
2041
+ target?: TelegramTarget;
2042
+ follower?: TelegramInstanceThreadIdentityCandidate;
2043
+ leader?: TelegramInstanceThreadIdentityCandidate;
2044
+ record?: TelegramTopicTargetRecord;
2045
+ }): TelegramInstanceThreadIdentityCandidate {
2046
+ const targetMatchesCandidate = (
2047
+ candidate: TelegramInstanceThreadIdentityCandidate | undefined,
2048
+ ) => {
2049
+ if (!candidate) return false;
2050
+ if (!options.target) return true;
2051
+ return !!candidate.target && targetMatches(candidate.target, options.target);
2052
+ };
2053
+ const local = targetMatchesCandidate(options.follower)
2054
+ ? options.follower
2055
+ : targetMatchesCandidate(options.leader)
2056
+ ? options.leader
2057
+ : undefined;
2058
+ const record =
2059
+ options.record &&
2060
+ (!options.target || targetMatches(options.record.target, options.target))
2061
+ ? options.record
2062
+ : undefined;
2063
+ return {
2064
+ ...(local?.target ?? record?.target
2065
+ ? { target: local?.target ?? record?.target }
2066
+ : {}),
2067
+ ...(local?.slot ?? record?.slot
2068
+ ? { slot: local?.slot ?? record?.slot }
2069
+ : {}),
2070
+ ...(local?.threadName ?? record?.threadName
2071
+ ? { threadName: local?.threadName ?? record?.threadName }
2072
+ : {}),
2073
+ };
2074
+ }
2075
+
1965
2076
  export function findCurrentTelegramInstanceThreadRecord(options: {
1966
2077
  records: readonly TelegramTopicTargetRecord[];
1967
2078
  instanceId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.20.3",
3
+ "version": "0.20.5",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"