@llblab/pi-kit 0.5.0 → 0.5.2

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 (27) hide show
  1. package/CHANGELOG.md +8 -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 +21 -0
  5. package/node_modules/@llblab/pi-telegram/README.md +2 -0
  6. package/node_modules/@llblab/pi-telegram/docs/architecture.md +6 -4
  7. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +5 -1
  8. package/node_modules/@llblab/pi-telegram/docs/outbound.md +19 -3
  9. package/node_modules/@llblab/pi-telegram/index.ts +10 -0
  10. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +43 -25
  11. package/node_modules/@llblab/pi-telegram/lib/activity.ts +60 -1
  12. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +101 -90
  13. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +7 -2
  14. package/node_modules/@llblab/pi-telegram/lib/locks.ts +99 -16
  15. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +18 -7
  16. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +11 -0
  17. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +10 -3
  18. package/node_modules/@llblab/pi-telegram/lib/polling.ts +142 -30
  19. package/node_modules/@llblab/pi-telegram/lib/preview.ts +19 -3
  20. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +8 -6
  21. package/node_modules/@llblab/pi-telegram/lib/queue.ts +101 -66
  22. package/node_modules/@llblab/pi-telegram/lib/routing.ts +192 -58
  23. package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -0
  24. package/node_modules/@llblab/pi-telegram/lib/updates.ts +33 -35
  25. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  26. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +2 -0
  27. package/package.json +2 -2
@@ -871,6 +871,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
871
871
  options.runtimeGeneration ?? allocateTelegramLockRuntimeGeneration();
872
872
  let ownedLockKey: string | undefined;
873
873
  let ownedLock: TelegramLockEntry | undefined;
874
+ let deliveryRevoked = false;
874
875
  const stateOptions = () => ({
875
876
  nowMs: getNowMs(),
876
877
  staleHeartbeatMs: options.staleHeartbeatMs,
@@ -917,7 +918,8 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
917
918
  );
918
919
  if (
919
920
  state.kind === "active-here" &&
920
- hasSameLockOwner(current, expectedOwned)
921
+ hasSameLockOwner(current, expectedOwned) &&
922
+ !deliveryRevoked
921
923
  ) {
922
924
  return {
923
925
  result: {
@@ -958,6 +960,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
958
960
  if (
959
961
  !acquireOptions.election &&
960
962
  (state.kind === "active-here" || state.kind === "active-elsewhere") &&
963
+ !(deliveryRevoked && hasSameLockOwner(current, expectedOwned)) &&
961
964
  (!acquireOptions.force ||
962
965
  !expectedReplacementMatches ||
963
966
  !canReplaceCurrent)
@@ -978,6 +981,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
978
981
  locks[effectiveKey] = lock;
979
982
  ownedLockKey = effectiveKey;
980
983
  ownedLock = lock;
984
+ deliveryRevoked = false;
981
985
  return {
982
986
  result: {
983
987
  ok: true,
@@ -987,8 +991,10 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
987
991
  changed: true,
988
992
  };
989
993
  }),
990
- release: () =>
991
- withLockTransaction(locksPath, (locks) => {
994
+ release: () => {
995
+ // Withdraw local send authority even if the durable release fails.
996
+ deliveryRevoked = true;
997
+ return withLockTransaction(locksPath, (locks) => {
992
998
  const effectiveKey = resolveEffectiveKey();
993
999
  const state = getLockState(
994
1000
  parseTelegramLockEntry(locks[effectiveKey]),
@@ -1008,17 +1014,20 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1008
1014
  ownedLock = undefined;
1009
1015
  }
1010
1016
  return { result: state, changed };
1011
- }),
1017
+ });
1018
+ },
1012
1019
  getState: () => getLockState(readLock(), pid, isAlive, stateOptions()),
1013
1020
  getStatusLabel: () =>
1014
1021
  formatLockState(getLockState(readLock(), pid, isAlive, stateOptions())),
1015
1022
  getOwnedLeaderEpoch: () => {
1023
+ if (deliveryRevoked) return undefined;
1016
1024
  const effectiveKey = resolveEffectiveKey();
1017
1025
  const lock = parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
1018
1026
  const exactOwner = adoptCompatibleOwnedLock(effectiveKey, lock);
1019
1027
  return hasSameLockOwner(lock, exactOwner) ? lock?.leaderEpoch : undefined;
1020
1028
  },
1021
1029
  owns: (ctx) => {
1030
+ if (deliveryRevoked) return false;
1022
1031
  const effectiveKey = resolveEffectiveKey();
1023
1032
  const lock = parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
1024
1033
  return hasSameLockOwner(
@@ -1027,7 +1036,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1027
1036
  );
1028
1037
  },
1029
1038
  commitIfOwned: (commit) =>
1030
- withLockTransaction(locksPath, (locks) => {
1039
+ !deliveryRevoked && withLockTransaction(locksPath, (locks) => {
1031
1040
  const effectiveKey = resolveEffectiveKey();
1032
1041
  const lock = parseTelegramLockEntry(locks[effectiveKey]);
1033
1042
  const exactOwner =
@@ -1043,7 +1052,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1043
1052
  return { result: true, changed: false };
1044
1053
  }),
1045
1054
  refresh: (ctx) =>
1046
- withLockTransaction(locksPath, (locks) => {
1055
+ !deliveryRevoked && withLockTransaction(locksPath, (locks) => {
1047
1056
  const effectiveKey = resolveEffectiveKey();
1048
1057
  const lock = parseTelegramLockEntry(locks[effectiveKey]);
1049
1058
  const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
@@ -1116,6 +1125,7 @@ export interface TelegramLockedPollingRuntime<
1116
1125
  ) => Promise<TelegramLockedPollingStartResult>;
1117
1126
  stop: () => Promise<string>;
1118
1127
  suspend: () => Promise<void>;
1128
+ onPersistentConflict: (ctx: TContext, count: number) => Promise<void>;
1119
1129
  onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
1120
1130
  registerFollowerWithOwner?: (
1121
1131
  ctx: TContext,
@@ -1130,6 +1140,7 @@ export interface TelegramLockedPollingRuntimeDeps<
1130
1140
  lock: TelegramLockRuntime<TContext>;
1131
1141
  hasBotToken: () => boolean;
1132
1142
  canStartPolling?: (ctx: TContext) => boolean;
1143
+ isContextCurrent?: (ctx: TContext) => boolean;
1133
1144
  formatStartBlockedMessage?: (ctx: TContext) => string;
1134
1145
  startPolling: (
1135
1146
  ctx: TContext,
@@ -1142,6 +1153,7 @@ export interface TelegramLockedPollingRuntimeDeps<
1142
1153
  ) => boolean | undefined | Promise<boolean | undefined>;
1143
1154
  stopFollowerRegistration?: () => void;
1144
1155
  onTransportAvailabilityChanged?: () => void;
1156
+ transportMonitor?: { start: (ctx: TContext) => void; stop: () => void };
1145
1157
  updateStatus: (ctx: TContext) => void;
1146
1158
  recordRuntimeEvent?: (
1147
1159
  category: string,
@@ -1164,9 +1176,10 @@ export function createTelegramLockedPollingRuntime<
1164
1176
  let ownershipCheckInterval: ReturnType<typeof setInterval> | undefined;
1165
1177
  let ownershipRefreshInterval: ReturnType<typeof setInterval> | undefined;
1166
1178
  let ownershipStop: Promise<void> | undefined;
1179
+ let activeContext: TContext | undefined;
1167
1180
  let takeoverCandidate: TelegramLockEntry | undefined;
1168
1181
  let sessionAutoStartRun: Promise<void> | undefined;
1169
- let sessionAutoStartGeneration = 0;
1182
+ let pollingGeneration = 0;
1170
1183
  const ownershipCheckMs =
1171
1184
  deps.ownershipCheckMs ?? TELEGRAM_OWNERSHIP_CHECK_MS;
1172
1185
  const ownershipRefreshMs =
@@ -1178,7 +1191,9 @@ export function createTelegramLockedPollingRuntime<
1178
1191
  ownershipRefreshInterval = undefined;
1179
1192
  };
1180
1193
  const suspendPolling = async () => {
1181
- sessionAutoStartGeneration += 1;
1194
+ pollingGeneration += 1;
1195
+ activeContext = undefined;
1196
+ deps.transportMonitor?.stop();
1182
1197
  deps.stopFollowerRegistration?.();
1183
1198
  stopOwnershipWatcher();
1184
1199
  if (sessionAutoStartRun) {
@@ -1192,6 +1207,8 @@ export function createTelegramLockedPollingRuntime<
1192
1207
  };
1193
1208
  const stopAfterOwnershipLoss = () => {
1194
1209
  if (ownershipStop) return;
1210
+ activeContext = undefined;
1211
+ deps.transportMonitor?.stop();
1195
1212
  stopOwnershipWatcher();
1196
1213
  deps.onTransportAvailabilityChanged?.();
1197
1214
  ownershipStop = deps
@@ -1228,7 +1245,10 @@ export function createTelegramLockedPollingRuntime<
1228
1245
  const runOwnedPollingStart = async (
1229
1246
  ctx: TContext,
1230
1247
  options: TelegramLockedPollingStartOptions,
1248
+ isCurrent: () => boolean,
1231
1249
  ): Promise<boolean> => {
1250
+ if (!isCurrent()) return false;
1251
+ activeContext = ctx;
1232
1252
  startOwnershipWatcher(ctx);
1233
1253
  try {
1234
1254
  if (!deps.lock.refresh(snapshotLockContext(ctx))) {
@@ -1236,8 +1256,10 @@ export function createTelegramLockedPollingRuntime<
1236
1256
  return false;
1237
1257
  }
1238
1258
  await options.onAcquired?.();
1259
+ if (!isCurrent()) return false;
1239
1260
  await deps.startPolling(ctx, options);
1240
1261
  } catch (error) {
1262
+ if (!isCurrent()) return false;
1241
1263
  stopOwnershipWatcher();
1242
1264
  try {
1243
1265
  await deps.stopPolling();
@@ -1246,14 +1268,22 @@ export function createTelegramLockedPollingRuntime<
1246
1268
  phase: "startup-rollback",
1247
1269
  });
1248
1270
  }
1271
+ if (!isCurrent()) return false;
1249
1272
  deps.lock.release();
1250
1273
  deps.onTransportAvailabilityChanged?.();
1251
1274
  throw error;
1252
1275
  }
1253
- if (deps.lock.owns(ctx)) return true;
1276
+ if (!isCurrent()) return false;
1277
+ if (deps.lock.owns(ctx)) {
1278
+ if (activeContext !== ctx) return false;
1279
+ deps.transportMonitor?.start(ctx);
1280
+ return true;
1281
+ }
1254
1282
  stopOwnershipWatcher();
1255
1283
  if (ownershipStop) await ownershipStop;
1284
+ if (!isCurrent()) return false;
1256
1285
  await deps.stopPolling();
1286
+ if (!isCurrent()) return false;
1257
1287
  deps.onTransportAvailabilityChanged?.();
1258
1288
  return false;
1259
1289
  };
@@ -1270,6 +1300,17 @@ export function createTelegramLockedPollingRuntime<
1270
1300
  if (!canStartPolling(ctx)) {
1271
1301
  return { ok: false, message: formatStartBlockedMessage(ctx) };
1272
1302
  }
1303
+ const cancelled = {
1304
+ ok: false as const,
1305
+ canTakeover: false as const,
1306
+ message: "Telegram polling startup was cancelled or superseded.",
1307
+ };
1308
+ if (deps.isContextCurrent?.(ctx) === false) return cancelled;
1309
+ const generation = ++pollingGeneration;
1310
+ const isCurrent = () => generation === pollingGeneration &&
1311
+ (deps.isContextCurrent?.(ctx) ?? true);
1312
+ if (ownershipStop) await ownershipStop;
1313
+ if (!isCurrent()) return cancelled;
1273
1314
  let acquired = deps.lock.acquire(ctx, {
1274
1315
  force: options.force,
1275
1316
  expectedOwner:
@@ -1306,6 +1347,7 @@ export function createTelegramLockedPollingRuntime<
1306
1347
  ctx,
1307
1348
  acquired.lock,
1308
1349
  );
1350
+ if (!isCurrent()) return cancelled;
1309
1351
  if (registered) {
1310
1352
  deps.updateStatus(ctx);
1311
1353
  return { ok: true, canTakeover: false };
@@ -1337,13 +1379,15 @@ export function createTelegramLockedPollingRuntime<
1337
1379
  };
1338
1380
  }
1339
1381
  takeoverCandidate = undefined;
1340
- if (!(await runOwnedPollingStart(ctx, options))) {
1382
+ if (!(await runOwnedPollingStart(ctx, options, isCurrent))) {
1383
+ if (!isCurrent()) return cancelled;
1341
1384
  return {
1342
1385
  ok: false,
1343
1386
  canTakeover: false,
1344
1387
  message: "Telegram leadership changed during polling startup.",
1345
1388
  };
1346
1389
  }
1390
+ if (!isCurrent()) return cancelled;
1347
1391
  deps.onTransportAvailabilityChanged?.();
1348
1392
  deps.updateStatus(ctx);
1349
1393
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
@@ -1362,6 +1406,42 @@ export function createTelegramLockedPollingRuntime<
1362
1406
  return "Telegram bridge disconnected.";
1363
1407
  },
1364
1408
  suspend: suspendPolling,
1409
+ onPersistentConflict: async (ctx, count) => {
1410
+ if (activeContext === undefined || ownershipStop) return;
1411
+ if (!(deps.isContextCurrent?.(ctx) ?? activeContext === ctx)) return;
1412
+ activeContext = undefined;
1413
+ pollingGeneration += 1;
1414
+ stopOwnershipWatcher();
1415
+ deps.transportMonitor?.stop();
1416
+ let ownership = "unverifiable";
1417
+ const cleanupErrors: string[] = [];
1418
+ try {
1419
+ ownership = deps.lock.owns(snapshotLockContext(ctx)) ? "owned" : "lost";
1420
+ } catch (error) {
1421
+ cleanupErrors.push(String(error));
1422
+ }
1423
+ try {
1424
+ deps.lock.release();
1425
+ } catch (error) {
1426
+ ownership = "unverifiable";
1427
+ cleanupErrors.push(String(error));
1428
+ }
1429
+ ownershipStop = Promise.resolve()
1430
+ .then(() => deps.stopPolling())
1431
+ .catch((error) => { cleanupErrors.push(String(error)); })
1432
+ .finally(() => {
1433
+ ownershipStop = undefined;
1434
+ deps.recordRuntimeEvent?.("polling", ownership === "lost"
1435
+ ? "Telegram transport stopped: local ownership lost; check for another Pi instance."
1436
+ : "Telegram transport stopped: competing getUpdates client or ownership mismatch.", {
1437
+ phase: "persistent-conflict", count, ownership,
1438
+ ...(cleanupErrors.length ? { cleanupErrors } : {}),
1439
+ });
1440
+ deps.updateStatus(ctx);
1441
+ });
1442
+ deps.onTransportAvailabilityChanged?.();
1443
+ await ownershipStop;
1444
+ },
1365
1445
  onSessionStart: async (_event, ctx) => {
1366
1446
  if (!deps.hasBotToken()) return;
1367
1447
  if (!canStartPolling(ctx)) return;
@@ -1379,15 +1459,18 @@ export function createTelegramLockedPollingRuntime<
1379
1459
  ) {
1380
1460
  return;
1381
1461
  }
1382
- sessionAutoStartGeneration += 1;
1383
- const generation = sessionAutoStartGeneration;
1462
+ if (deps.isContextCurrent?.(ctx) === false) return;
1463
+ const generation = ++pollingGeneration;
1464
+ const isCurrent = () => generation === pollingGeneration &&
1465
+ (deps.isContextCurrent?.(ctx) ?? true);
1384
1466
  const startedAtMs = Date.now();
1385
1467
  deps.recordRuntimeEvent?.("lock", "Telegram auto-start scheduled", {
1386
1468
  phase: "auto-start-scheduled",
1387
1469
  });
1388
1470
  const run = (async () => {
1389
1471
  await new Promise((resolve) => setTimeout(resolve, 0));
1390
- if (generation !== sessionAutoStartGeneration) return;
1472
+ if (ownershipStop) await ownershipStop;
1473
+ if (!isCurrent()) return;
1391
1474
  if (canResumeStaleSameCwd || canHandoffSameProcess) {
1392
1475
  const acquired = deps.lock.acquire(
1393
1476
  ctx,
@@ -1397,9 +1480,9 @@ export function createTelegramLockedPollingRuntime<
1397
1480
  );
1398
1481
  if (!acquired.ok) return;
1399
1482
  }
1400
- if (generation !== sessionAutoStartGeneration) return;
1401
- if (!(await runOwnedPollingStart(ctx, {}))) return;
1402
- if (generation !== sessionAutoStartGeneration) return;
1483
+ if (!isCurrent()) return;
1484
+ if (!(await runOwnedPollingStart(ctx, {}, isCurrent))) return;
1485
+ if (!isCurrent()) return;
1403
1486
  deps.onTransportAvailabilityChanged?.();
1404
1487
  deps.updateStatus(ctx);
1405
1488
  deps.recordRuntimeEvent?.("lock", "Telegram auto-start completed", {
@@ -219,8 +219,9 @@ export function createTelegramRichOutboundAttachmentSender(
219
219
  return async (
220
220
  turn: TelegramQueuedOutboundAttachmentTurnView,
221
221
  markdown: string,
222
- options?: { replyMarkup?: unknown },
222
+ options?: { replyMarkup?: unknown; isDeliveryActive?: () => boolean },
223
223
  ): Promise<boolean> => {
224
+ if (options?.isDeliveryActive?.() === false) return false;
224
225
  const plan = planTelegramRichOutboundAttachment({
225
226
  turn,
226
227
  markdown,
@@ -246,11 +247,13 @@ export function createTelegramRichOutboundAttachmentSender(
246
247
  new Error("Successful Rich media upload omitted message_id."),
247
248
  );
248
249
  }
249
- deps.recordOwnership?.({
250
- chatId: turn.chatId,
251
- messageId,
252
- target: turn.target,
253
- });
250
+ if (options?.isDeliveryActive?.() !== false) {
251
+ deps.recordOwnership?.({
252
+ chatId: turn.chatId,
253
+ messageId,
254
+ target: turn.target,
255
+ });
256
+ }
254
257
  return true;
255
258
  } catch (error) {
256
259
  if (isTelegramRichAttachmentCommitUnknownError(error)) throw error;
@@ -580,6 +583,7 @@ export interface TelegramQueuedOutboundAttachmentDeliveryDeps {
580
583
  ) => void;
581
584
  statPath?: (path: string) => Promise<{ size: number }>;
582
585
  maxAttachmentSizeBytes?: number;
586
+ isDeliveryActive?: () => boolean;
583
587
  }
584
588
 
585
589
  export async function queueTelegramOutboundAttachments(options: {
@@ -925,9 +929,13 @@ export async function sendTelegramOutboundFiles(options: {
925
929
  export function createTelegramQueuedOutboundAttachmentSender(
926
930
  deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
927
931
  ) {
928
- return async (turn: TelegramQueuedOutboundAttachmentTurnView): Promise<void> => {
932
+ return async (
933
+ turn: TelegramQueuedOutboundAttachmentTurnView,
934
+ options?: { isDeliveryActive?: () => boolean },
935
+ ): Promise<void> => {
929
936
  await sendQueuedTelegramOutboundAttachments(turn, {
930
937
  ...deps,
938
+ isDeliveryActive: () => deps.isDeliveryActive?.() !== false && options?.isDeliveryActive?.() !== false,
931
939
  maxAttachmentSizeBytes:
932
940
  deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES,
933
941
  });
@@ -939,9 +947,11 @@ export async function sendQueuedTelegramOutboundAttachments(
939
947
  deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
940
948
  ): Promise<void> {
941
949
  for (const attachment of turn.queuedAttachments) {
950
+ if (deps.isDeliveryActive?.() === false) return;
942
951
  try {
943
952
  if (deps.maxAttachmentSizeBytes !== undefined) {
944
953
  const stats = await (deps.statPath ?? stat)(attachment.path);
954
+ if (deps.isDeliveryActive?.() === false) return;
945
955
  if (stats.size > deps.maxAttachmentSizeBytes) {
946
956
  throw new Error(
947
957
  formatTelegramOutboundAttachmentSizeLimitError(
@@ -971,6 +981,7 @@ export async function sendQueuedTelegramOutboundAttachments(
971
981
  attachment.fileName,
972
982
  );
973
983
  } catch (error) {
984
+ if (deps.isDeliveryActive?.() === false) return;
974
985
  const message = error instanceof Error ? error.message : String(error);
975
986
  deps.recordRuntimeEvent?.("attachment", error, {
976
987
  fileName: attachment.fileName,
@@ -14,6 +14,7 @@ import {
14
14
  type TelegramTarget,
15
15
  } from "./target.ts";
16
16
  import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
17
+ import { isTelegramApiCommitUnknownError } from "./telegram-api.ts";
17
18
 
18
19
  export interface TelegramVoiceReplyTurnView {
19
20
  chatId: number;
@@ -47,6 +48,7 @@ export interface TelegramVoiceReplySenderDeps {
47
48
  ) => Promise<unknown>;
48
49
  sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
49
50
  sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
51
+ isDeliveryActive?: () => boolean;
50
52
  getHandlers?: () => unknown[] | undefined;
51
53
  cwd?: string;
52
54
  tempDir?: string;
@@ -123,9 +125,12 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
123
125
  replyMarkup?: unknown;
124
126
  },
125
127
  ): Promise<void> => {
128
+ if (deps.isDeliveryActive?.() === false) return;
126
129
  const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
127
130
  assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
131
+ if (deps.isDeliveryActive?.() === false) return;
128
132
  await sendVoiceChatAction(deps, turn.chatId);
133
+ if (deps.isDeliveryActive?.() === false) return;
129
134
  const replyParameters = buildVoiceReplyParameters(
130
135
  turn.chatId,
131
136
  options?.replyToPrompt,
@@ -171,6 +176,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
171
176
  ): Promise<void> => {
172
177
  for (const handler of ports.findVoiceHandlers?.(deps.getHandlers?.()) ??
173
178
  []) {
179
+ if (deps.isDeliveryActive?.() === false) return;
174
180
  try {
175
181
  const filePath = await ports.generateVoiceFile?.(text, {
176
182
  lang: options?.lang,
@@ -187,6 +193,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
187
193
  });
188
194
  return;
189
195
  } catch (error) {
196
+ if (isTelegramApiCommitUnknownError(error)) throw error;
190
197
  deps.recordRuntimeEvent?.("voice", error, {
191
198
  phase: "template-handler-send",
192
199
  });
@@ -194,6 +201,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
194
201
  }
195
202
 
196
203
  for (const handler of ports.getProgrammaticVoiceHandlers?.() ?? []) {
204
+ if (deps.isDeliveryActive?.() === false) return;
197
205
  try {
198
206
  const filePath = await handler(text, {
199
207
  lang: options?.lang,
@@ -206,6 +214,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
206
214
  });
207
215
  return;
208
216
  } catch (error) {
217
+ if (isTelegramApiCommitUnknownError(error)) throw error;
209
218
  deps.recordRuntimeEvent?.("voice", error, {
210
219
  phase: "programmatic-handler-send",
211
220
  });
@@ -215,6 +224,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
215
224
  const providers = getTelegramVoiceSynthesisProviders();
216
225
 
217
226
  for (const provider of providers) {
227
+ if (deps.isDeliveryActive?.() === false) return;
218
228
  let voiceFilePath: string | undefined;
219
229
  let originalFilePath: string | undefined;
220
230
 
@@ -252,6 +262,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
252
262
  });
253
263
  return;
254
264
  } catch (error) {
265
+ if (isTelegramApiCommitUnknownError(error)) throw error;
255
266
  deps.recordRuntimeEvent?.("voice", error, { phase: "send" });
256
267
  } finally {
257
268
  if (voiceFilePath && voiceFilePath !== originalFilePath) {
@@ -11,6 +11,7 @@ import { join } from "node:path";
11
11
  import type { TelegramAssistantSegmentEvent } from "./activity.ts";
12
12
  import { resolveTelegramTempDir } from "./paths.ts";
13
13
  import * as Replies from "./replies.ts";
14
+ import { isTelegramApiCommitUnknownError } from "./telegram-api.ts";
14
15
  import type {
15
16
  TelegramEditMessageTextBody,
16
17
  TelegramSendMessageBody,
@@ -137,6 +138,7 @@ export interface TelegramVoiceReplySenderDeps {
137
138
  ) => Promise<unknown>;
138
139
  sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
139
140
  sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
141
+ isDeliveryActive?: () => boolean;
140
142
  getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
141
143
  cwd?: string;
142
144
  tempDir?: string;
@@ -877,15 +879,17 @@ export function createTelegramOutboundReplyPlanner(
877
879
  export function createTelegramOutboundReplyArtifactSender(
878
880
  deps: TelegramVoiceReplySenderDeps,
879
881
  ) {
880
- const sendVoiceReply = createTelegramVoiceReplySender(deps);
881
882
  return async (
882
883
  turn: TelegramVoiceReplyTurnView,
883
884
  plan: Pick<
884
885
  TelegramOutboundReplyPlan,
885
886
  "voiceText" | "voiceReplies" | "lang" | "rate" | "replyMarkup"
886
887
  >,
887
- options?: { replyToPrompt?: boolean },
888
+ options?: { replyToPrompt?: boolean; isDeliveryActive?: () => boolean },
888
889
  ): Promise<void> => {
890
+ const isDeliveryActive = () =>
891
+ deps.isDeliveryActive?.() !== false && options?.isDeliveryActive?.() !== false;
892
+ const sendVoiceReply = createTelegramVoiceReplySender({ ...deps, isDeliveryActive });
889
893
  // Normalize voice replies: either use explicit voiceReplies array or fall back to voiceText
890
894
  const voiceReplies = plan.voiceReplies?.length
891
895
  ? plan.voiceReplies
@@ -896,6 +900,7 @@ export function createTelegramOutboundReplyArtifactSender(
896
900
  let anyDelivered = false;
897
901
 
898
902
  for (const reply of voiceReplies) {
903
+ if (!isDeliveryActive()) return;
899
904
  try {
900
905
  await sendVoiceReply(turn, reply.text, {
901
906
  lang: reply.lang ?? plan.lang,
@@ -905,11 +910,13 @@ export function createTelegramOutboundReplyArtifactSender(
905
910
  replyMarkup: !anyDelivered ? plan.replyMarkup : undefined,
906
911
  });
907
912
  anyDelivered = true;
908
- } catch {
913
+ } catch (error) {
914
+ if (isTelegramApiCommitUnknownError(error)) throw error;
909
915
  // sendVoiceReply already recorded the error; continue to next reply
910
916
  }
911
917
  }
912
918
 
919
+ if (!isDeliveryActive()) return;
913
920
  if (!anyDelivered) {
914
921
  throw new Error(
915
922
  "Failed to send voice reply: every voice synthesis provider failed.",