@llblab/pi-kit 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  4. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +17 -0
  5. package/node_modules/@llblab/pi-telegram/README.md +3 -3
  6. package/node_modules/@llblab/pi-telegram/api/voice.ts +0 -1
  7. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  8. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
  9. package/node_modules/@llblab/pi-telegram/docs/outbound.md +2 -3
  10. package/node_modules/@llblab/pi-telegram/docs/public-api.md +5 -15
  11. package/node_modules/@llblab/pi-telegram/docs/voice.md +9 -37
  12. package/node_modules/@llblab/pi-telegram/index.ts +30 -7
  13. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +112 -25
  14. package/node_modules/@llblab/pi-telegram/lib/config.ts +38 -46
  15. package/node_modules/@llblab/pi-telegram/lib/journal.ts +107 -2
  16. package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +4 -2
  17. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +15 -11
  18. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +3 -18
  19. package/node_modules/@llblab/pi-telegram/lib/polling.ts +66 -24
  20. package/node_modules/@llblab/pi-telegram/lib/setup.ts +0 -1
  21. package/node_modules/@llblab/pi-telegram/lib/status.ts +2 -2
  22. package/node_modules/@llblab/pi-telegram/lib/updates.ts +11 -4
  23. package/node_modules/@llblab/pi-telegram/lib/voice.ts +7 -31
  24. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  25. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +1 -1
  26. package/package.json +2 -2
@@ -157,6 +157,7 @@ export interface TelegramUpdateJournalEntry {
157
157
  export interface TelegramUpdateJournalFile {
158
158
  version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
159
159
  revision?: number;
160
+ acceptedThroughUpdateId?: number;
160
161
  profile: string;
161
162
  botIdentity: TelegramUpdateJournalBotIdentity;
162
163
  entries: TelegramUpdateJournalEntry[];
@@ -312,6 +313,7 @@ export interface TelegramUpdateJournalStore {
312
313
  read(): TelegramUpdateJournalSnapshot;
313
314
  appendBatch<TUpdate extends TelegramUpdateJournalInput>(
314
315
  updates: readonly TUpdate[],
316
+ acceptedThroughUpdateId?: number,
315
317
  ): TelegramUpdateJournalAppendResult;
316
318
  markQueued(
317
319
  receipt: TelegramUpdateJournalQueueReceipt,
@@ -385,6 +387,7 @@ export interface TelegramUpdateJournalSegment {
385
387
  version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
386
388
  revision: number;
387
389
  previousRevision: number;
390
+ acceptedThroughUpdateId?: number;
388
391
  profile: string;
389
392
  botIdentity: TelegramUpdateJournalBotIdentity;
390
393
  upsertedEntries: TelegramUpdateJournalEntry[];
@@ -975,6 +978,7 @@ function parseJournalFile(
975
978
  !hasOnlyKeys(value, [
976
979
  "version",
977
980
  "revision",
981
+ "acceptedThroughUpdateId",
978
982
  "profile",
979
983
  "botIdentity",
980
984
  "entries",
@@ -983,6 +987,8 @@ function parseJournalFile(
983
987
  !Number.isSafeInteger(value.version) ||
984
988
  (value.revision !== undefined &&
985
989
  (!isSafeNonNegativeInteger(value.revision) || value.revision === 0)) ||
990
+ (value.acceptedThroughUpdateId !== undefined &&
991
+ !isSafeNonNegativeInteger(value.acceptedThroughUpdateId)) ||
986
992
  !isNonEmptyString(value.profile) ||
987
993
  !Array.isArray(value.entries) ||
988
994
  (value.operatorDispositions !== undefined &&
@@ -1009,6 +1015,17 @@ function parseJournalFile(
1009
1015
  );
1010
1016
  }
1011
1017
  }
1018
+ if (
1019
+ entries.length > 0 &&
1020
+ value.acceptedThroughUpdateId !== undefined &&
1021
+ value.acceptedThroughUpdateId < entries.at(-1)!.updateId
1022
+ ) {
1023
+ throw createJournalError(
1024
+ "invalid",
1025
+ path,
1026
+ "has an admission cursor behind its active entries",
1027
+ );
1028
+ }
1012
1029
  const queuedReceipts = new Map<
1013
1030
  string,
1014
1031
  {
@@ -1096,6 +1113,9 @@ function parseJournalFile(
1096
1113
  ...(value.revision !== undefined
1097
1114
  ? { revision: value.revision as number }
1098
1115
  : {}),
1116
+ ...(value.acceptedThroughUpdateId !== undefined
1117
+ ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
1118
+ : {}),
1099
1119
  profile: value.profile,
1100
1120
  botIdentity: validateBotIdentity(value.botIdentity, path),
1101
1121
  entries,
@@ -1113,6 +1133,7 @@ function parseJournalSegment(
1113
1133
  "version",
1114
1134
  "revision",
1115
1135
  "previousRevision",
1136
+ "acceptedThroughUpdateId",
1116
1137
  "profile",
1117
1138
  "botIdentity",
1118
1139
  "upsertedEntries",
@@ -1122,6 +1143,8 @@ function parseJournalSegment(
1122
1143
  value.version !== TELEGRAM_UPDATE_JOURNAL_VERSION ||
1123
1144
  !isSafePositiveInteger(value.revision) ||
1124
1145
  !isSafeNonNegativeInteger(value.previousRevision) ||
1146
+ (value.acceptedThroughUpdateId !== undefined &&
1147
+ !isSafeNonNegativeInteger(value.acceptedThroughUpdateId)) ||
1125
1148
  !isNonEmptyString(value.profile) ||
1126
1149
  !Array.isArray(value.upsertedEntries) ||
1127
1150
  !Array.isArray(value.removedUpdateIds) ||
@@ -1140,6 +1163,18 @@ function parseJournalSegment(
1140
1163
  }
1141
1164
  upsertedIds.add(entry.updateId);
1142
1165
  }
1166
+ if (
1167
+ upsertedEntries.length > 0 &&
1168
+ value.acceptedThroughUpdateId !== undefined &&
1169
+ value.acceptedThroughUpdateId <
1170
+ Math.max(...upsertedEntries.map((entry) => entry.updateId))
1171
+ ) {
1172
+ throw createJournalError(
1173
+ "invalid",
1174
+ path,
1175
+ "has an admission cursor behind its segment upserts",
1176
+ );
1177
+ }
1143
1178
  const removedUpdateIds: number[] = [];
1144
1179
  const removedIds = new Set<number>();
1145
1180
  for (const updateId of value.removedUpdateIds) {
@@ -1162,6 +1197,9 @@ function parseJournalSegment(
1162
1197
  version: TELEGRAM_UPDATE_JOURNAL_VERSION,
1163
1198
  revision: value.revision,
1164
1199
  previousRevision: value.previousRevision,
1200
+ ...(value.acceptedThroughUpdateId !== undefined
1201
+ ? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
1202
+ : {}),
1165
1203
  profile: value.profile,
1166
1204
  botIdentity: validateBotIdentity(value.botIdentity, path),
1167
1205
  upsertedEntries,
@@ -1190,6 +1228,9 @@ function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
1190
1228
  return {
1191
1229
  version: TELEGRAM_UPDATE_JOURNAL_VERSION,
1192
1230
  ...(file.revision !== undefined ? { revision: file.revision } : {}),
1231
+ ...(file.acceptedThroughUpdateId !== undefined
1232
+ ? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
1233
+ : {}),
1193
1234
  profile: file.profile,
1194
1235
  botIdentity: { ...file.botIdentity },
1195
1236
  entries: file.entries.map(cloneEntry),
@@ -1879,6 +1920,17 @@ export function createTelegramUpdateJournalStore(
1879
1920
  `has a revision gap after ${revision}`,
1880
1921
  );
1881
1922
  }
1923
+ if (
1924
+ segment.acceptedThroughUpdateId !== undefined &&
1925
+ file.acceptedThroughUpdateId !== undefined &&
1926
+ segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId
1927
+ ) {
1928
+ throw createJournalError(
1929
+ "invalid",
1930
+ segmentPath,
1931
+ "regresses the admission cursor",
1932
+ );
1933
+ }
1882
1934
  if (
1883
1935
  segment.profile !== storedProfile ||
1884
1936
  !identitiesMatch(segment.botIdentity, storedIdentity)
@@ -1907,6 +1959,11 @@ export function createTelegramUpdateJournalStore(
1907
1959
  {
1908
1960
  version: TELEGRAM_UPDATE_JOURNAL_VERSION,
1909
1961
  revision: segment.revision,
1962
+ ...(segment.acceptedThroughUpdateId !== undefined
1963
+ ? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId }
1964
+ : file.acceptedThroughUpdateId !== undefined
1965
+ ? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
1966
+ : {}),
1910
1967
  profile: storedProfile,
1911
1968
  botIdentity: mergeBotIdentity(file.botIdentity, segment.botIdentity),
1912
1969
  entries: [...entriesById.values()].sort(
@@ -2136,6 +2193,7 @@ export function createTelegramUpdateJournalStore(
2136
2193
  entries: TelegramUpdateJournalEntry[],
2137
2194
  contentChanged: boolean,
2138
2195
  operatorDispositions = current.file.operatorDispositions,
2196
+ acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
2139
2197
  ): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
2140
2198
  const botIdentity = mergeBotIdentity(
2141
2199
  current.file.botIdentity,
@@ -2147,7 +2205,8 @@ export function createTelegramUpdateJournalStore(
2147
2205
  isDeepStrictEqual(
2148
2206
  current.file.operatorDispositions ?? [],
2149
2207
  operatorDispositions ?? [],
2150
- )
2208
+ ) &&
2209
+ current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
2151
2210
  ) {
2152
2211
  return { file: current.file, serializedBytes: current.serializedBytes };
2153
2212
  }
@@ -2156,6 +2215,9 @@ export function createTelegramUpdateJournalStore(
2156
2215
  profile,
2157
2216
  botIdentity,
2158
2217
  entries,
2218
+ ...(acceptedThroughUpdateId !== undefined
2219
+ ? { acceptedThroughUpdateId }
2220
+ : {}),
2159
2221
  ...(operatorDispositions?.length
2160
2222
  ? { operatorDispositions }
2161
2223
  : {}),
@@ -2164,6 +2226,7 @@ export function createTelegramUpdateJournalStore(
2164
2226
  const serializedBytes = assertCapacity(file, serialized);
2165
2227
  const changed =
2166
2228
  contentChanged ||
2229
+ current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
2167
2230
  !isDeepStrictEqual(current.file.botIdentity, file.botIdentity);
2168
2231
  if (!changed) {
2169
2232
  return { file, serializedBytes: current.serializedBytes };
@@ -2193,6 +2256,9 @@ export function createTelegramUpdateJournalStore(
2193
2256
  botIdentity: file.botIdentity,
2194
2257
  upsertedEntries,
2195
2258
  removedUpdateIds,
2259
+ ...(acceptedThroughUpdateId !== undefined
2260
+ ? { acceptedThroughUpdateId }
2261
+ : {}),
2196
2262
  ...(!isDeepStrictEqual(
2197
2263
  current.file.operatorDispositions ?? [],
2198
2264
  operatorDispositions ?? [],
@@ -2260,8 +2326,18 @@ export function createTelegramUpdateJournalStore(
2260
2326
  };
2261
2327
  });
2262
2328
  },
2263
- appendBatch(updates) {
2329
+ appendBatch(updates, requestedAcceptedThroughUpdateId) {
2264
2330
  return runMutation(() => {
2331
+ if (
2332
+ requestedAcceptedThroughUpdateId !== undefined &&
2333
+ !isSafeNonNegativeInteger(requestedAcceptedThroughUpdateId)
2334
+ ) {
2335
+ throw createJournalError(
2336
+ "invalid",
2337
+ path,
2338
+ "received an invalid admission cursor",
2339
+ );
2340
+ }
2265
2341
  const current = readCurrent();
2266
2342
  const normalizedUpdates: TelegramJournaledUpdate[] = [];
2267
2343
  for (const update of updates) {
@@ -2290,6 +2366,8 @@ export function createTelegramUpdateJournalStore(
2290
2366
  const entriesById = new Map(
2291
2367
  current.file.entries.map((entry) => [entry.updateId, entry]),
2292
2368
  );
2369
+ const previousAcceptedThroughUpdateId =
2370
+ current.file.acceptedThroughUpdateId;
2293
2371
  const discardedUpdateIds = new Set(
2294
2372
  (current.file.operatorDispositions ?? [])
2295
2373
  .filter((disposition) => disposition.action === "discard")
@@ -2335,6 +2413,31 @@ export function createTelegramUpdateJournalStore(
2335
2413
  addedUpdateIds.push(entry.updateId);
2336
2414
  }
2337
2415
 
2416
+ const batchLastUpdateId = normalizedUpdates.at(-1)?.update_id;
2417
+ if (
2418
+ requestedAcceptedThroughUpdateId !== undefined &&
2419
+ batchLastUpdateId !== undefined &&
2420
+ requestedAcceptedThroughUpdateId < batchLastUpdateId
2421
+ ) {
2422
+ throw createJournalError(
2423
+ "invalid",
2424
+ path,
2425
+ "received an admission cursor behind its batch",
2426
+ );
2427
+ }
2428
+ if (
2429
+ requestedAcceptedThroughUpdateId !== undefined &&
2430
+ previousAcceptedThroughUpdateId !== undefined &&
2431
+ requestedAcceptedThroughUpdateId < previousAcceptedThroughUpdateId
2432
+ ) {
2433
+ throw createJournalError(
2434
+ "conflict",
2435
+ path,
2436
+ "received a regressing admission cursor",
2437
+ );
2438
+ }
2439
+ const acceptedThroughUpdateId =
2440
+ requestedAcceptedThroughUpdateId ?? previousAcceptedThroughUpdateId;
2338
2441
  const contentChanged = addedUpdateIds.length > 0;
2339
2442
  const published = publishMutation(
2340
2443
  current,
@@ -2344,6 +2447,8 @@ export function createTelegramUpdateJournalStore(
2344
2447
  )
2345
2448
  : current.file.entries,
2346
2449
  contentChanged,
2450
+ current.file.operatorDispositions,
2451
+ acceptedThroughUpdateId,
2347
2452
  );
2348
2453
  return {
2349
2454
  addedUpdateIds,
@@ -103,7 +103,7 @@ export interface TelegramModelMenuRuntimeOptions<
103
103
  }
104
104
 
105
105
  export interface MenuSettingsManager {
106
- reload: () => Promise<void>;
106
+ reload?: () => Promise<void>;
107
107
  flush?: () => Promise<void>;
108
108
  getEnabledModels: () => string[] | undefined;
109
109
  setEnabledModels?: (patterns: string[] | undefined) => void;
@@ -498,7 +498,9 @@ export function createTelegramModelMenuStateBuilder<
498
498
  threadId,
499
499
  activeModel: deps.getActiveModel(ctx),
500
500
  ctx,
501
- reloadSettings: () => settingsManager.reload(),
501
+ reloadSettings: async () => {
502
+ await settingsManager.reload?.();
503
+ },
502
504
  getConfiguredScopedModelPatterns: () =>
503
505
  settingsManager.getEnabledModels(),
504
506
  });
@@ -149,9 +149,7 @@ export const TIME_INJECTION_MODE_SETTINGS_TITLE =
149
149
  "<b>🕒 Time injection mode:</b>";
150
150
  export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>👄 Voice reply mode:</b>";
151
151
 
152
- type TelegramVoiceReplyModeSetting = TelegramVoiceReplyMode | "hidden";
153
-
154
- function getVoiceReplyModeLabel(mode: TelegramVoiceReplyModeSetting): string {
152
+ function getVoiceReplyModeLabel(mode: TelegramVoiceReplyMode): string {
155
153
  return mode;
156
154
  }
157
155
 
@@ -162,8 +160,8 @@ function getTelegramSettingsStateValueLabel(value: string): string {
162
160
  function getVoiceReplyModeSetting(
163
161
  mode: TelegramVoiceReplyMode,
164
162
  configured: boolean,
165
- ): TelegramVoiceReplyModeSetting {
166
- return configured ? mode : "hidden";
163
+ ): TelegramVoiceReplyMode {
164
+ return configured ? mode : "manual";
167
165
  }
168
166
 
169
167
  export function buildTelegramSettingsMenuText(): string {
@@ -246,8 +244,8 @@ export function buildVoiceReplyModeSettingsText(
246
244
  "",
247
245
  "Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
248
246
  "",
249
- "<code>-</code> <code>hidden</code> (default): add no automatic voice context; explicit 'telegram_voice' actions still work.",
250
- "<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows 'hidden' behavior.",
247
+ "<code>-</code> <code>manual</code> (default): add no automatic voice context; explicit 'telegram_voice' actions still work.",
248
+ "<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows 'manual' behavior.",
251
249
  "<code>-</code> <code>always</code>: activate automatic voice delivery for every reply.",
252
250
  ].join("\n");
253
251
  }
@@ -502,7 +500,7 @@ export function buildVoiceReplyModeSettingsReplyMarkup(
502
500
  configured = true,
503
501
  ): TelegramSettingsMenuReplyMarkup {
504
502
  const activeMode = getVoiceReplyModeSetting(mode, configured);
505
- const modes: TelegramVoiceReplyModeSetting[] = ["hidden", "mirror", "always"];
503
+ const modes: TelegramVoiceReplyMode[] = ["manual", "mirror", "always"];
506
504
  return {
507
505
  inline_keyboard: [
508
506
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
@@ -661,12 +659,18 @@ export async function handleTelegramSettingsMenuCallbackAction(
661
659
  }
662
660
  if (data.startsWith("settings:set:voice-reply:")) {
663
661
  const mode = data.slice("settings:set:voice-reply:".length);
664
- if (mode === "hidden" || mode === "mirror" || mode === "always") {
665
- await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
662
+ if (
663
+ mode === "manual" ||
664
+ mode === "hidden" ||
665
+ mode === "mirror" ||
666
+ mode === "always"
667
+ ) {
668
+ const normalizedMode = mode === "hidden" ? "manual" : mode;
669
+ await deps.setVoiceReplyMode(normalizedMode);
666
670
  await updateVoiceReplyModeSettingsMessage(deps);
667
671
  await deps.answerCallbackQuery(
668
672
  callbackQueryId,
669
- `Voice reply mode: ${mode}`,
673
+ `Voice reply mode: ${normalizedMode}`,
670
674
  );
671
675
  return true;
672
676
  }
@@ -100,17 +100,6 @@ async function ensureTelegramVoiceFileFormat(
100
100
  );
101
101
  }
102
102
 
103
- function extractVoiceResult(result: any): {
104
- filePath: string;
105
- transcriptText?: string;
106
- } {
107
- if (typeof result === "string") return { filePath: result };
108
- return {
109
- filePath: result.audioPath,
110
- transcriptText: result.transcriptText,
111
- };
112
- }
113
-
114
103
  async function sendVoiceChatAction(
115
104
  deps: TelegramVoiceReplySenderDeps,
116
105
  chatId: number,
@@ -132,7 +121,6 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
132
121
  options?: {
133
122
  replyToPrompt?: boolean;
134
123
  replyMarkup?: unknown;
135
- transcriptText?: string;
136
124
  },
137
125
  ): Promise<void> => {
138
126
  const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
@@ -148,7 +136,6 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
148
136
  "sendVoice",
149
137
  {
150
138
  chat_id: String(turn.chatId),
151
- ...(options?.transcriptText ? { caption: options.transcriptText } : {}),
152
139
  ...(replyParameters ? { reply_parameters: replyParameters } : {}),
153
140
  ...(turn.target
154
141
  ? Object.fromEntries(
@@ -257,13 +244,11 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
257
244
  continue;
258
245
  }
259
246
 
260
- const { filePath, transcriptText } = extractVoiceResult(providerResult);
261
- voiceFilePath = filePath;
262
- originalFilePath = filePath;
263
- await uploadVoiceFile(turn, filePath, {
247
+ voiceFilePath = providerResult;
248
+ originalFilePath = providerResult;
249
+ await uploadVoiceFile(turn, providerResult, {
264
250
  replyToPrompt: options?.replyToPrompt,
265
251
  replyMarkup: options?.replyMarkup,
266
- transcriptText,
267
252
  });
268
253
  return;
269
254
  } catch (error) {
@@ -8,7 +8,6 @@ type MaybePromise<T> = T | Promise<T>;
8
8
 
9
9
  export interface TelegramPollingConfig {
10
10
  botToken?: string;
11
- lastUpdateId?: number;
12
11
  }
13
12
 
14
13
  export interface TelegramUpdate {
@@ -227,6 +226,7 @@ export interface TelegramPollingAdmissionRuntime<TContext> {
227
226
 
228
227
  export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
229
228
  polling: TelegramPollingController<TContext>;
229
+ prepareStart?: () => MaybePromise<void>;
230
230
  validateStart?: () => void;
231
231
  worker: {
232
232
  onSessionStart: (ctx: TContext) => Promise<void>;
@@ -235,6 +235,7 @@ export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
235
235
  return {
236
236
  isActive: deps.polling.isActive,
237
237
  async start(ctx) {
238
+ await deps.prepareStart?.();
238
239
  deps.validateStart?.();
239
240
  await deps.worker.onSessionStart(ctx);
240
241
  await deps.polling.start(ctx);
@@ -256,7 +257,12 @@ export type TelegramDurablePollingRuntimeAssemblyDeps<
256
257
  "appendUpdateBatch" | "getJournalEntryCount" | "signalUpdateWorker"
257
258
  > & {
258
259
  journal: {
259
- appendBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
260
+ appendBatch: (
261
+ updates: readonly TUpdate[],
262
+ acceptedThroughUpdateId?: number,
263
+ ) => MaybePromise<unknown>;
264
+ getAcceptedThroughUpdateId: () => number | undefined;
265
+ prepareCursorCutover?: () => MaybePromise<void>;
260
266
  getEntryCount: () => number;
261
267
  signalWorker: () => void;
262
268
  getBootstrapEntryCount: () => number;
@@ -274,13 +280,15 @@ export function createTelegramDurablePollingRuntimeAssembly<
274
280
  const controller = createTelegramPollingControllerRuntime({
275
281
  ...deps,
276
282
  appendUpdateBatch: deps.journal.appendBatch,
283
+ getAcceptedThroughUpdateId: deps.journal.getAcceptedThroughUpdateId,
277
284
  getJournalEntryCount: deps.journal.getEntryCount,
278
285
  signalUpdateWorker: deps.journal.signalWorker,
279
286
  });
280
287
  const admission = createTelegramPollingAdmissionRuntime({
281
288
  polling: controller,
289
+ prepareStart: deps.journal.prepareCursorCutover,
282
290
  validateStart() {
283
- if (deps.getConfig().lastUpdateId !== undefined) return;
291
+ if (deps.journal.getAcceptedThroughUpdateId() !== undefined) return;
284
292
  if (deps.journal.getBootstrapEntryCount() === 0) return;
285
293
  throw new TelegramPollingCursorBootstrapError(
286
294
  "Telegram polling cursor is missing while the durable update journal is non-empty.",
@@ -356,6 +364,7 @@ export function createTelegramPollingControllerRuntime<
356
364
  getUpdatesRequestBudgetMs: deps.getUpdatesRequestBudgetMs,
357
365
  persistConfig: deps.persistConfig,
358
366
  appendUpdateBatch: deps.appendUpdateBatch,
367
+ getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
359
368
  getJournalEntryCount: deps.getJournalEntryCount,
360
369
  signalUpdateWorker: deps.signalUpdateWorker,
361
370
  prepareUpdateBatch: deps.prepareUpdateBatch,
@@ -1185,6 +1194,32 @@ export class TelegramPollingCursorBootstrapError extends Error {
1185
1194
  }
1186
1195
  }
1187
1196
 
1197
+ export interface TelegramPollingCursorCutoverDeps {
1198
+ getLegacyCursor: () => number | undefined;
1199
+ readJournal: () => {
1200
+ acceptedThroughUpdateId?: number;
1201
+ entries: readonly { updateId: number }[];
1202
+ };
1203
+ publishJournalCursor: (acceptedThroughUpdateId: number) => MaybePromise<void>;
1204
+ removeLegacyCursor: () => MaybePromise<void>;
1205
+ }
1206
+
1207
+ /** Transfer one legacy config cursor into journal authority before deleting it. */
1208
+ export async function cutOverTelegramPollingCursor(
1209
+ deps: TelegramPollingCursorCutoverDeps,
1210
+ ): Promise<void> {
1211
+ const legacyCursor = deps.getLegacyCursor();
1212
+ if (legacyCursor === undefined) return;
1213
+ const snapshot = deps.readJournal();
1214
+ if (snapshot.acceptedThroughUpdateId === undefined) {
1215
+ const provenEntryCursor = snapshot.entries.at(-1)?.updateId;
1216
+ await deps.publishJournalCursor(
1217
+ Math.max(legacyCursor, provenEntryCursor ?? legacyCursor),
1218
+ );
1219
+ }
1220
+ await deps.removeLegacyCursor();
1221
+ }
1222
+
1188
1223
  export interface TelegramPollingBatchAdmissionResult {
1189
1224
  updateCount: number;
1190
1225
  latestUpdateId?: number;
@@ -1195,7 +1230,11 @@ export interface TelegramPollingBatchAdmissionDeps<
1195
1230
  > extends TelegramRuntimeEventRecorderPort {
1196
1231
  updates: readonly TUpdate[];
1197
1232
  config: TelegramPollingConfig;
1198
- appendBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1233
+ appendBatch: (
1234
+ updates: readonly TUpdate[],
1235
+ acceptedThroughUpdateId?: number,
1236
+ ) => MaybePromise<unknown>;
1237
+ getAcceptedThroughUpdateId?: () => number | undefined;
1199
1238
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
1200
1239
  signalWorker: () => void;
1201
1240
  onPhaseChange?: (
@@ -1229,7 +1268,8 @@ export async function admitTelegramPollingUpdateBatch<
1229
1268
  deps: TelegramPollingBatchAdmissionDeps<TUpdate>,
1230
1269
  ): Promise<TelegramPollingBatchAdmissionResult> {
1231
1270
  if (deps.updates.length === 0) return { updateCount: 0 };
1232
- validateTelegramPollingBatch(deps.updates, deps.config.lastUpdateId);
1271
+ const acceptedThroughUpdateId = deps.getAcceptedThroughUpdateId?.();
1272
+ validateTelegramPollingBatch(deps.updates, acceptedThroughUpdateId);
1233
1273
  const latestUpdateId = getLatestTelegramUpdateId(deps.updates);
1234
1274
  if (latestUpdateId === undefined) return { updateCount: 0 };
1235
1275
  reportTelegramPollingPhase(
@@ -1237,18 +1277,7 @@ export async function admitTelegramPollingUpdateBatch<
1237
1277
  "persisting-journal",
1238
1278
  deps.updates[0]?.update_id,
1239
1279
  );
1240
- await deps.appendBatch(deps.updates);
1241
- reportTelegramPollingPhase(deps, "persisting-offset", latestUpdateId);
1242
- const previousUpdateId = deps.config.lastUpdateId;
1243
- deps.config.lastUpdateId = latestUpdateId;
1244
- try {
1245
- await deps.persistConfig(deps.config);
1246
- } catch (error) {
1247
- if (deps.config.lastUpdateId === latestUpdateId) {
1248
- deps.config.lastUpdateId = previousUpdateId;
1249
- }
1250
- throw error;
1251
- }
1280
+ await deps.appendBatch(deps.updates, latestUpdateId);
1252
1281
  try {
1253
1282
  deps.signalWorker();
1254
1283
  } catch (error) {
@@ -1275,7 +1304,11 @@ export interface TelegramPollLoopDeps<
1275
1304
  ) => Promise<TUpdate[]>;
1276
1305
  getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
1277
1306
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
1278
- appendUpdateBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1307
+ appendUpdateBatch: (
1308
+ updates: readonly TUpdate[],
1309
+ acceptedThroughUpdateId?: number,
1310
+ ) => MaybePromise<unknown>;
1311
+ getAcceptedThroughUpdateId?: () => number | undefined;
1279
1312
  getJournalEntryCount: () => number;
1280
1313
  signalUpdateWorker: () => void;
1281
1314
  prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
@@ -1301,7 +1334,11 @@ export interface TelegramPollLoopRunnerDeps<
1301
1334
  ) => Promise<TUpdate[]>;
1302
1335
  getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
1303
1336
  persistConfig: (config: TelegramPollingConfig) => Promise<void>;
1304
- appendUpdateBatch: (updates: readonly TUpdate[]) => MaybePromise<unknown>;
1337
+ appendUpdateBatch: (
1338
+ updates: readonly TUpdate[],
1339
+ acceptedThroughUpdateId?: number,
1340
+ ) => MaybePromise<unknown>;
1341
+ getAcceptedThroughUpdateId?: () => number | undefined;
1305
1342
  getJournalEntryCount: () => number;
1306
1343
  signalUpdateWorker: () => void;
1307
1344
  prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
@@ -1356,6 +1393,7 @@ export function createTelegramPollLoopRunner<
1356
1393
  getUpdatesRequestBudgetMs: deps.getUpdatesRequestBudgetMs,
1357
1394
  persistConfig: deps.persistConfig,
1358
1395
  appendUpdateBatch: deps.appendUpdateBatch,
1396
+ getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
1359
1397
  getJournalEntryCount: deps.getJournalEntryCount,
1360
1398
  signalUpdateWorker: deps.signalUpdateWorker,
1361
1399
  prepareUpdateBatch: deps.prepareUpdateBatch,
@@ -1494,14 +1532,16 @@ export async function runTelegramPollLoop<
1494
1532
  // ignore
1495
1533
  }
1496
1534
  if (
1497
- deps.config.lastUpdateId === undefined &&
1535
+ deps.getAcceptedThroughUpdateId?.() === undefined &&
1498
1536
  deps.getJournalEntryCount() > 0
1499
1537
  ) {
1500
1538
  throw new TelegramPollingCursorBootstrapError(
1501
1539
  "Telegram polling cursor is missing while the durable update journal is non-empty.",
1502
1540
  );
1503
1541
  }
1504
- if (deps.config.lastUpdateId === undefined) {
1542
+ if (
1543
+ deps.getAcceptedThroughUpdateId?.() === undefined
1544
+ ) {
1505
1545
  try {
1506
1546
  const request = buildTelegramInitialSyncRequest();
1507
1547
  reportTelegramPollingPhase(deps, "long-poll");
@@ -1514,8 +1554,7 @@ export async function runTelegramPollLoop<
1514
1554
  "persisting-offset",
1515
1555
  lastUpdateId,
1516
1556
  );
1517
- deps.config.lastUpdateId = lastUpdateId;
1518
- await deps.persistConfig(deps.config);
1557
+ await deps.appendUpdateBatch([], lastUpdateId);
1519
1558
  deps.recordRuntimeEvent?.(
1520
1559
  "polling",
1521
1560
  new Error("Initialized Telegram cursor without executing history."),
@@ -1538,7 +1577,9 @@ export async function runTelegramPollLoop<
1538
1577
  while (!deps.signal.aborted) {
1539
1578
  try {
1540
1579
  currentUpdateId = undefined;
1541
- const request = buildTelegramLongPollRequest(deps.config.lastUpdateId);
1580
+ const request = buildTelegramLongPollRequest(
1581
+ deps.getAcceptedThroughUpdateId?.(),
1582
+ );
1542
1583
  reportTelegramPollingPhase(deps, "long-poll");
1543
1584
  const updates = await requestTelegramUpdatesWithinBudget(deps, request);
1544
1585
  reportTelegramPollingResponse(deps, updates.length);
@@ -1549,6 +1590,7 @@ export async function runTelegramPollLoop<
1549
1590
  updates,
1550
1591
  config: deps.config,
1551
1592
  appendBatch: deps.appendUpdateBatch,
1593
+ getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
1552
1594
  persistConfig: deps.persistConfig,
1553
1595
  signalWorker: deps.signalUpdateWorker,
1554
1596
  onPhaseChange: deps.onPhaseChange,
@@ -9,7 +9,6 @@ export interface TelegramSetupConfig {
9
9
  botId?: number;
10
10
  botUsername?: string;
11
11
  allowedUserId?: number;
12
- lastUpdateId?: number;
13
12
  }
14
13
 
15
14
  export interface TelegramBotTokenPromptSpec {
@@ -321,7 +321,6 @@ export interface TelegramBridgeStatusConfig {
321
321
  botToken?: string;
322
322
  botUsername?: string;
323
323
  allowedUserId?: number;
324
- lastUpdateId?: number;
325
324
  }
326
325
 
327
326
  export interface TelegramBridgeStatusRuntimeDeps<
@@ -337,6 +336,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
337
336
  isPollingActive: () => boolean;
338
337
  getPollingState?: () => TelegramBridgePollingState;
339
338
  getInboundWorkerState?: () => TelegramBridgeInboundWorkerState | undefined;
339
+ getAcceptedThroughUpdateId?: () => number | undefined;
340
340
  getActiveSourceMessageIds: () => number[] | undefined;
341
341
  hasActiveTurn: () => boolean;
342
342
  hasDispatchPending: () => boolean;
@@ -717,7 +717,7 @@ export function createTelegramBridgeStatusRuntime<
717
717
  ...(deps.getInboundWorkerState
718
718
  ? { inboundWorker: deps.getInboundWorkerState() }
719
719
  : {}),
720
- lastUpdateId: config.lastUpdateId,
720
+ lastUpdateId: deps.getAcceptedThroughUpdateId?.(),
721
721
  activeSourceMessageIds: deps.getActiveSourceMessageIds(),
722
722
  pendingDispatch: deps.hasDispatchPending(),
723
723
  compactionInProgress: deps.isCompactionInProgress(),