@llblab/pi-kit 0.34.3 → 0.35.1

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 (50) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +3 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -3
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +6 -1
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  7. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +12 -8
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +39 -23
  12. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  13. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  14. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +6 -7
  15. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +6 -8
  16. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +40 -45
  17. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  21. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  22. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  23. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  26. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  27. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +10 -5
  28. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +19 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  30. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +41 -14
  31. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  32. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  33. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +5 -3
  34. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  35. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -3
  36. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +14 -8
  37. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  38. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +45 -24
  39. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  40. package/node_modules/@llblab/pi-telegram/lib/config.ts +11 -17
  41. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -55
  42. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  43. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  44. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  45. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  46. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  47. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +22 -5
  48. package/node_modules/@llblab/pi-telegram/lib/threads.ts +54 -18
  49. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  50. package/package.json +2 -2
@@ -2281,6 +2281,8 @@ export interface TelegramSelectedCommandAdmission {
2281
2281
  /** Independent exact recipient/target lifetime, including inside captured menu transport adapters. */
2282
2282
  assertRecipientCurrent(): void;
2283
2283
  reportCompleted(): boolean;
2284
+ /** Optional recipient release barrier after acceptance; rejection suppresses effects without replay. */
2285
+ beforeEffects?(): Promise<void>;
2284
2286
  }
2285
2287
 
2286
2288
  /**
@@ -3068,7 +3070,8 @@ export function createTelegramCommandHandler<
3068
3070
  ? copySelectedMenuCarrier(original)
3069
3071
  : copySelectedReplyAnchor(original);
3070
3072
  const assertRecipientCurrent = admission.assertRecipientCurrent,
3071
- report = admission.reportCompleted;
3073
+ report = admission.reportCompleted,
3074
+ beforeEffects = admission.beforeEffects;
3072
3075
  const effects: TelegramHeldCommandEffects<TContext> | undefined =
3073
3076
  parsed.name === "status"
3074
3077
  ? undefined
@@ -3100,8 +3103,11 @@ export function createTelegramCommandHandler<
3100
3103
  throw new Error(`Selected ${parsed.name} plan owner is unavailable.`);
3101
3104
  };
3102
3105
  return issueSelectedOnce(assertCurrent, async () => {
3103
- if (plan.completion === "before-effect" && !report.call(admission))
3104
- return false;
3106
+ if (plan.completion === "before-effect") {
3107
+ if (!report.call(admission)) return false;
3108
+ if (beforeEffects) await beforeEffects();
3109
+ assertRecipientCurrent();
3110
+ }
3105
3111
  return prepared.run({
3106
3112
  message,
3107
3113
  command: parsed,
@@ -3137,7 +3143,8 @@ export function createTelegramCommandHandler<
3137
3143
  from: { ...original.from },
3138
3144
  };
3139
3145
  const assertRecipientCurrent = admission.assertRecipientCurrent,
3140
- reportCompleted = admission.reportCompleted;
3146
+ reportCompleted = admission.reportCompleted,
3147
+ beforeEffects = admission.beforeEffects;
3141
3148
  const assertAdmissionCurrent = createSelectedSemanticGuard(
3142
3149
  admission,
3143
3150
  () => captured.assertExecutionCurrent?.(original),
@@ -3163,7 +3170,7 @@ export function createTelegramCommandHandler<
3163
3170
  );
3164
3171
  },
3165
3172
  },
3166
- { assertSemanticCurrent, assertRecipientCurrent, reportCompleted },
3173
+ { assertSemanticCurrent, assertRecipientCurrent, reportCompleted, beforeEffects },
3167
3174
  ),
3168
3175
  );
3169
3176
  };
@@ -3607,6 +3614,7 @@ export function createTelegramCommandHandler<
3607
3614
  const assertSourceCurrent = admission.assertSourceCurrent,
3608
3615
  assertRecipientCurrent = admission.assertRecipientCurrent;
3609
3616
  const reportCompleted = admission.reportCompleted,
3617
+ beforeEffects = admission.beforeEffects,
3610
3618
  assertExecutionCurrent = deps.assertExecutionCurrent;
3611
3619
  const assertIssuanceCurrent = () => {
3612
3620
  assertSourceCurrent.call(admission);
@@ -3639,9 +3647,12 @@ export function createTelegramCommandHandler<
3639
3647
  };
3640
3648
  return issueSelectedOnce(
3641
3649
  assertIssuanceCurrent,
3642
- async () =>
3643
- reportCompleted.call(admission) &&
3644
- handleTelegramCommandRuntime(name, message, ctx, recipientDeps, args),
3650
+ async () => {
3651
+ if (!reportCompleted.call(admission)) return false;
3652
+ if (beforeEffects) await beforeEffects();
3653
+ assertRecipientCurrent();
3654
+ return handleTelegramCommandRuntime(name, message, ctx, recipientDeps, args);
3655
+ },
3645
3656
  );
3646
3657
  },
3647
3658
  });
@@ -3766,6 +3777,7 @@ async function handleTelegramHelpCommand<
3766
3777
  assertSemanticCurrent(): void;
3767
3778
  assertRecipientCurrent(): void;
3768
3779
  reportCompleted(): boolean;
3780
+ beforeEffects?(): Promise<void>;
3769
3781
  },
3770
3782
  ): Promise<boolean> {
3771
3783
  const assertSemanticCurrent =
@@ -3788,6 +3800,7 @@ async function handleTelegramHelpCommand<
3788
3800
  if (admission) {
3789
3801
  assertSemanticCurrent();
3790
3802
  if (!admission.reportCompleted()) return false;
3803
+ if (admission.beforeEffects) await admission.beforeEffects();
3791
3804
  admission.assertRecipientCurrent();
3792
3805
  }
3793
3806
  const assertEffectCurrent =
@@ -146,12 +146,11 @@ const TELEGRAM_THREAD_DISPLAY_MODES: readonly TelegramThreadDisplayMode[] = [
146
146
  ];
147
147
 
148
148
  export function resolveTelegramThreadDisplayMode(
149
- config: Pick<TelegramConfig, "threadDisplayMode">,
149
+ config: Pick<TelegramConfig, "threads">,
150
150
  ): TelegramThreadDisplayMode {
151
- return TELEGRAM_THREAD_DISPLAY_MODES.includes(
152
- config.threadDisplayMode as TelegramThreadDisplayMode,
153
- )
154
- ? (config.threadDisplayMode as TelegramThreadDisplayMode)
151
+ const mode = config.threads?.displayMode;
152
+ return TELEGRAM_THREAD_DISPLAY_MODES.includes(mode as TelegramThreadDisplayMode)
153
+ ? (mode as TelegramThreadDisplayMode)
155
154
  : "letters";
156
155
  }
157
156
 
@@ -173,8 +172,9 @@ export async function setTelegramThreadDisplayMode(
173
172
  "Telegram Thread display setting lost its configured profile.",
174
173
  );
175
174
  }
175
+ const latest = store.get();
176
176
  await store.persist(
177
- { ...store.get(), threadDisplayMode: mode },
177
+ { ...latest, threads: { ...latest.threads, displayMode: mode } },
178
178
  { isCurrent: current },
179
179
  );
180
180
  if (!current())
@@ -196,8 +196,6 @@ export interface TelegramConfig {
196
196
  botId?: number;
197
197
  /** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
198
198
  allowedUserId?: number;
199
- /** Effective view; persisted under profiles.<name>. */
200
- threadDisplayMode?: TelegramThreadDisplayMode;
201
199
  inboundHandlers?: TelegramInboundHandlerConfig[];
202
200
  attachmentHandlers?: TelegramInboundHandlerConfig[];
203
201
  outboundHandlers?: TelegramOutboundHandlerConfig[];
@@ -223,23 +221,24 @@ export interface TelegramConfig {
223
221
  threads?: {
224
222
  /** Delete this instance's bound Telegram thread on graceful Pi quit. */
225
223
  automaticCleanup?: boolean;
224
+ /** Automatic Thread title strategy shared by every profile; absent or invalid resolves to `letters`. */
225
+ displayMode?: TelegramThreadDisplayMode;
226
226
  };
227
227
  /** Canonical bot/session profiles, including profiles.default. */
228
228
  profiles?: Record<string, TelegramBotProfile>;
229
229
  }
230
230
 
231
231
  /**
232
- * Per-profile bot/session identity and Thread display preference.
232
+ * Per-profile bot identity and paired owner only.
233
233
  * Stored under `profiles.<name>` in telegram.json.
234
- * Shared bridge settings (inboundHandlers, outboundHandlers, voice, time,
235
- * assistant) stay at the top level.
234
+ * Shared bridge settings (handlers, assistant, voice, time, threads) stay at
235
+ * the top level and never leak into profiles.
236
236
  */
237
237
  export interface TelegramBotProfile {
238
238
  botToken: string;
239
239
  botUsername?: string;
240
240
  botId?: number;
241
241
  allowedUserId?: number;
242
- threadDisplayMode?: TelegramThreadDisplayMode;
243
242
  }
244
243
 
245
244
  interface TelegramLegacyCursorCarrier {
@@ -510,9 +509,6 @@ function pickTelegramRootProfileFields(
510
509
  ...(config.allowedUserId !== undefined
511
510
  ? { allowedUserId: config.allowedUserId }
512
511
  : {}),
513
- ...(config.threadDisplayMode !== undefined
514
- ? { threadDisplayMode: config.threadDisplayMode }
515
- : {}),
516
512
  ...(legacyCursor !== undefined ? { lastUpdateId: legacyCursor } : {}),
517
513
  };
518
514
  }
@@ -523,7 +519,6 @@ function omitTelegramRootProfileFields(config: TelegramConfig): TelegramConfig {
523
519
  botUsername: _botUsername,
524
520
  botId: _botId,
525
521
  allowedUserId: _allowedUserId,
526
- threadDisplayMode: _threadDisplayMode,
527
522
  lastUpdateId: _lastUpdateId,
528
523
  ...sharedConfig
529
524
  } = config as TelegramConfig & TelegramLegacyCursorCarrier;
@@ -561,7 +556,6 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
561
556
  "botUsername",
562
557
  "botId",
563
558
  "allowedUserId",
564
- "threadDisplayMode",
565
559
  "lastUpdateId",
566
560
  ].some((field) => Object.hasOwn(config, field));
567
561
  if (!hasLegacyRootProfile) {
@@ -1302,6 +1302,32 @@ export default function (pi: Pi.ExtensionAPI) {
1302
1302
  delivery: deliveryLifecycleRuntime,
1303
1303
  api: telegramApiTargetActivityRuntime,
1304
1304
  });
1305
+ const prepareTemporaryThreadJournalInspection = function (
1306
+ target: Targets.TelegramTarget,
1307
+ requiredJournalBindingKeys: readonly string[],
1308
+ ownInputs?: readonly Threads.TelegramTemporaryThreadInput[],
1309
+ ): Parameters<typeof Journal.isTelegramThreadCleanupJournalNamespaceClear>[0] | undefined {
1310
+ const binding = resolveTelegramUpdateJournalBinding();
1311
+ const botToken = configStore.getBotToken();
1312
+ if (!binding || !botToken) return undefined;
1313
+ const profile = configStore.getActiveProfileName() ?? "default";
1314
+ const botIdentity = Journal.createTelegramUpdateJournalBotIdentity({ botToken, botId: getTelegramBotId() });
1315
+ return {
1316
+ directory: Paths.resolveTelegramTempDir(), profile, botIdentity,
1317
+ pollingPath: telegramLeaderJournalPath.resolve(configStore.getActiveProfileName()),
1318
+ requiredJournalBindingKeys: [binding.recoveryKey, ...requiredJournalBindingKeys],
1319
+ ...(target.threadId !== undefined && ownInputs
1320
+ ? { cleanup: { target: { chatId: target.chatId, threadId: target.threadId }, ownInputs } } : {}),
1321
+ limits: { maxDirectoryEntries: 10_000, maxFiles: 4096, maxBytes: 64 * 1024 * 1024,
1322
+ maxEntries: 10_000, maxWork: 100_000 },
1323
+ withSourceReference(path, operation) {
1324
+ return telegramJournalReferenceRegistry.withReference({
1325
+ referenceClass: "operator-disposition",
1326
+ recoveryKey: Journal.createTelegramUpdateJournalBindingKey({ path, profileName: profile, botIdentity }),
1327
+ }, operation);
1328
+ },
1329
+ };
1330
+ };
1305
1331
  const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime({
1306
1332
  configStore,
1307
1333
  callApi: callTelegramApi,
@@ -1407,64 +1433,25 @@ export default function (pi: Pi.ExtensionAPI) {
1407
1433
  },
1408
1434
  );
1409
1435
  },
1410
- inspectTemporaryThreadSources(
1411
- target,
1412
- requiredJournalBindingKeys,
1413
- ownInputs,
1414
- ) {
1415
- const binding = resolveTelegramUpdateJournalBinding();
1416
- const botToken = configStore.getBotToken();
1417
- if (!binding || !botToken) return undefined;
1418
- const profile = configStore.getActiveProfileName() ?? "default";
1419
- const botIdentity = Journal.createTelegramUpdateJournalBotIdentity({
1420
- botToken,
1421
- botId: getTelegramBotId(),
1422
- });
1436
+ inspectTemporaryThreadSources(target, requiredJournalBindingKeys, ownInputs) {
1437
+ const input = prepareTemporaryThreadJournalInspection(target, requiredJournalBindingKeys, ownInputs);
1438
+ if (!input) return undefined;
1423
1439
  return withTelegramJournalSourceSerialization(function () {
1424
- return Journal.isTelegramThreadCleanupJournalNamespaceClear({
1425
- directory: Paths.resolveTelegramTempDir(),
1426
- profile,
1427
- botIdentity,
1428
- pollingPath: telegramLeaderJournalPath.resolve(
1429
- configStore.getActiveProfileName(),
1430
- ),
1431
- requiredJournalBindingKeys: [
1432
- binding.recoveryKey,
1433
- ...requiredJournalBindingKeys,
1434
- ],
1435
- ...(target.threadId !== undefined && ownInputs
1436
- ? {
1437
- cleanup: {
1438
- target: { chatId: target.chatId, threadId: target.threadId },
1439
- ownInputs,
1440
- },
1441
- }
1442
- : {}),
1443
- limits: {
1444
- maxDirectoryEntries: 10_000,
1445
- maxFiles: 4096,
1446
- maxBytes: 64 * 1024 * 1024,
1447
- maxEntries: 10_000,
1448
- maxWork: 100_000,
1449
- },
1450
- withSourceReference(path, operation) {
1451
- return telegramJournalReferenceRegistry.withReference(
1452
- {
1453
- referenceClass: "operator-disposition",
1454
- recoveryKey: Journal.createTelegramUpdateJournalBindingKey({
1455
- path,
1456
- profileName: profile,
1457
- botIdentity,
1458
- }),
1459
- },
1460
- operation,
1461
- );
1462
- },
1463
- })
1464
- ? []
1465
- : undefined;
1440
+ return Journal.isTelegramThreadCleanupJournalNamespaceClear(input) ? [] : undefined;
1466
1441
  });
1467
1442
  },
1443
+ recoverTemporaryThreadDeadQueueOwners(target, requiredJournalBindingKeys, ownInputs, isCurrent) {
1444
+ const input = prepareTemporaryThreadJournalInspection(target, requiredJournalBindingKeys, ownInputs);
1445
+ if (!input || !isCurrent()) return 0;
1446
+ return Journal.recoverTelegramThreadCleanupDeadQueueOwners({
1447
+ ...input, isCurrent, withSourceSerialization: withTelegramJournalSourceSerialization,
1448
+ recoveryOwner: {
1449
+ instanceId: telegramInstanceId, processId: telegramProcessId,
1450
+ processBirthId: telegramQueueProcessBirthId,
1451
+ sessionGeneration: telegramSessionContextStore.getGeneration(),
1452
+ },
1453
+ });
1454
+ },
1468
1455
  hasWorkspaceRestoreAuthority() {
1469
1456
  return (
1470
1457
  lockRuntime.owns() &&
@@ -3071,6 +3058,7 @@ export default function (pi: Pi.ExtensionAPI) {
3071
3058
  deleteMessage: deleteTelegramMessage,
3072
3059
  sendGuestReply,
3073
3060
  editGuestReply,
3061
+ editGuestInlineMessage: telegramApiRuntime.editGuestInlineMessage,
3074
3062
  stopGuestPlaceholder: guestPlaceholderRuntime.stop,
3075
3063
  finalizeMarkdownPreview,
3076
3064
  preparePreviewDelivery,
@@ -3741,6 +3741,86 @@ export function isTelegramThreadCleanupJournalNamespaceClear(
3741
3741
  );
3742
3742
  }
3743
3743
 
3744
+ /** Demand-driven recovery only; caller holds temporary-cleanup admission. Never a deletion grant. */
3745
+ export function recoverTelegramThreadCleanupDeadQueueOwners(
3746
+ input: Parameters<typeof isTelegramThreadCleanupJournalNamespaceClear>[0] & {
3747
+ recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
3748
+ isCurrent: () => boolean;
3749
+ withSourceSerialization: NonNullable<TelegramUpdateJournalStoreOptions["withSourceSerialization"]>;
3750
+ getQueueProcessLiveness?: TelegramUpdateJournalStoreOptions["getQueueProcessLiveness"];
3751
+ },
3752
+ ): number {
3753
+ return input.withSourceSerialization(() => {
3754
+ const assertCurrent = () => {
3755
+ if (!input.isCurrent()) throw new Error("Temporary cleanup queue recovery lost authority.");
3756
+ };
3757
+ assertCurrent();
3758
+ if (!input.cleanup) return 0;
3759
+ // Validate exact references and complete namespace coverage before considering any mutation.
3760
+ if (isTelegramThreadCleanupJournalNamespaceClear(input)) return 0;
3761
+ const namespace = inspectTelegramSessionJournalNamespace(input);
3762
+ if (namespace.retainedInputs?.some(value => value.state !== "committed") ||
3763
+ input.requiredJournalBindingKeys.some(key => !namespace.sources.some(source =>
3764
+ source.path === getTelegramUpdateJournalBindingPath(key) && source.evidence.kind === "present"))) return 0;
3765
+ const plans: Array<{ path: string; recovery: TelegramUpdateJournalDeadQueueOwnerRecoveryInput }> = [];
3766
+ for (const source of namespace.sources) {
3767
+ if (source.evidence.kind !== "present") continue;
3768
+ const file = source.evidence.file;
3769
+ if (file.version !== TELEGRAM_UPDATE_JOURNAL_VERSION) continue;
3770
+ const groups = new Map<string, TelegramUpdateJournalEntry[]>();
3771
+ for (const entry of file.entries) {
3772
+ if (!entry.queueReceiptId) continue;
3773
+ const group = groups.get(entry.queueReceiptId) ?? [];
3774
+ group.push(entry);
3775
+ groups.set(entry.queueReceiptId, group);
3776
+ }
3777
+ for (const group of groups.values()) {
3778
+ const entry = group[0]!;
3779
+ if (entry.state !== "queued" || !entry.queueReceiptId || !entry.queueOwner ||
3780
+ !entry.queueKind || entry.queueHandoff || entry.inputClaim) continue;
3781
+ if (group.some(value => value.state !== "queued" || value.queueHandoff || value.inputClaim ||
3782
+ value.queueKind !== entry.queueKind || !isDeepStrictEqual(value.queueOwner, entry.queueOwner))) continue;
3783
+ plans.push({ path: source.path, recovery: {
3784
+ queueKind: entry.queueKind, receiptId: entry.queueReceiptId,
3785
+ sourceUpdateIds: group.map(value => value.updateId).sort((a, b) => a - b),
3786
+ deadOwner: cloneJournalQueueOwner(entry.queueOwner), recoveryOwner: { ...input.recoveryOwner },
3787
+ } });
3788
+ }
3789
+ }
3790
+ const liveness = input.getQueueProcessLiveness ?? getTelegramProcessLiveness;
3791
+ const dead = plans.filter(plan => {
3792
+ assertCurrent();
3793
+ const result = liveness(plan.recovery.deadOwner);
3794
+ assertCurrent();
3795
+ return result === "dead";
3796
+ });
3797
+ let removed = 0;
3798
+ for (const plan of dead) {
3799
+ assertCurrent();
3800
+ removed += input.withSourceReference(plan.path, () => {
3801
+ const journal = createTelegramUpdateJournalStore({
3802
+ path: plan.path, profileName: input.profile, botIdentity: input.botIdentity,
3803
+ queueRuntimeIdentity: input.recoveryOwner,
3804
+ // This private synchronous store stays inside the cross-family serializer already held above.
3805
+ withSourceSerialization(operation) { assertCurrent(); return operation(); },
3806
+ sourceAccess: { directory: dirname(plan.path), limits: input.limits },
3807
+ getQueueProcessLiveness(owner) {
3808
+ assertCurrent();
3809
+ const result = liveness(owner);
3810
+ assertCurrent();
3811
+ return result;
3812
+ },
3813
+ withWriterAdmission(operation) { assertCurrent(); return operation(); },
3814
+ });
3815
+ // The journal transaction rechecks whole-group identity, absence of handoff and fresh process-death proof.
3816
+ return journal.recoverDeadQueueOwner(plan.recovery).recoveredUpdateIds.length;
3817
+ });
3818
+ }
3819
+ assertCurrent();
3820
+ return removed;
3821
+ });
3822
+ }
3823
+
3744
3824
  function cloneEntry(
3745
3825
  entry: TelegramUpdateJournalEntry,
3746
3826
  ): TelegramUpdateJournalEntry {
@@ -130,6 +130,7 @@ export interface TelegramQueuedOutboundAttachmentView {
130
130
  export interface TelegramOutboundAttachmentQueueTargetView {
131
131
  queuedAttachments: TelegramQueuedOutboundAttachmentView[];
132
132
  guestQueryId?: string;
133
+ guestInlineMessageId?: string;
133
134
  }
134
135
 
135
136
  export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
@@ -718,6 +719,11 @@ export async function queueTelegramOutboundAttachments(options: {
718
719
  maxAttachmentSizeBytes: options.maxAttachmentSizeBytes,
719
720
  statPath: options.statPath,
720
721
  });
722
+ if (options.activeTurn.guestInlineMessageId && pendingAttachments.some(
723
+ (attachment) => getTelegramGuestAttachmentTransport(attachment.path).fileField === "voice",
724
+ )) {
725
+ throw new Error("Telegram Guest Mode cannot replace an inline placeholder with a voice note; no attachment was queued. Use MP3 audio or a normal bot chat.");
726
+ }
721
727
  options.activeTurn.queuedAttachments.push(...pendingAttachments);
722
728
  const added = pendingAttachments.map((attachment) => attachment.path);
723
729
  return {
@@ -733,6 +739,13 @@ export async function queueTelegramOutboundAttachments(options: {
733
739
 
734
740
  export async function deliverTelegramGuestCachedAttachment(options: {
735
741
  guestQueryId: string;
742
+ guestInlineMessageId?: string;
743
+ editGuestInlineMessage?: (
744
+ inlineMessageId: string,
745
+ content: { media: { type: "photo" | "document" | "audio"; media: string; caption?: string } },
746
+ options: { assertAuthority: () => void },
747
+ ) => Promise<void>;
748
+ isDeliveryActive?: () => boolean;
736
749
  stagingChatId: number;
737
750
  stagingTarget?: TelegramTarget;
738
751
  attachment: TelegramQueuedOutboundAttachmentView;
@@ -750,6 +763,14 @@ export async function deliverTelegramGuestCachedAttachment(options: {
750
763
  const transport = getTelegramGuestAttachmentTransport(
751
764
  options.attachment.path,
752
765
  );
766
+ const isActive = () => options.isDeliveryActive?.() !== false;
767
+ const assertActive = () => {
768
+ if (!isActive()) throw new Error("Guest attachment delivery lost authority.");
769
+ };
770
+ if (!isActive()) return;
771
+ if (options.guestInlineMessageId && (!options.editGuestInlineMessage || transport.fileField === "voice")) {
772
+ throw new Error("Guest inline attachment editing is unavailable for this media.");
773
+ }
753
774
  let stagingMessageId: number | undefined;
754
775
  let answerAttempted = false;
755
776
  try {
@@ -764,6 +785,7 @@ export async function deliverTelegramGuestCachedAttachment(options: {
764
785
  options.attachment.fileName,
765
786
  )) as TelegramGuestStagingMessage;
766
787
  stagingMessageId = message.message_id;
788
+ if (!isActive()) return;
767
789
  const caption = options.caption
768
790
  ? Array.from(options.caption).slice(0, 1024).join("")
769
791
  : undefined;
@@ -810,17 +832,28 @@ export async function deliverTelegramGuestCachedAttachment(options: {
810
832
  ...(caption ? { caption } : {}),
811
833
  };
812
834
  }
835
+ if (!isActive()) return;
813
836
  answerAttempted = true;
814
- await options.answerGuestQuery(options.guestQueryId, result);
837
+ if (options.guestInlineMessageId && options.editGuestInlineMessage) {
838
+ if (result.type === "voice") throw new Error("Voice notes cannot replace inline placeholders.");
839
+ const media = result.type === "photo" ? result.photo_file_id
840
+ : result.type === "audio" ? result.audio_file_id : result.document_file_id;
841
+ await options.editGuestInlineMessage(options.guestInlineMessageId, {
842
+ media: { type: result.type, media, ...(caption ? { caption } : {}) },
843
+ }, { assertAuthority: assertActive });
844
+ } else {
845
+ await options.answerGuestQuery(options.guestQueryId, result);
846
+ }
815
847
  } catch (error) {
816
- if (!answerAttempted && options.answerGuestText && options.fallbackText) {
848
+ if (!isActive()) return;
849
+ if (!options.guestInlineMessageId && !answerAttempted && options.answerGuestText && options.fallbackText) {
817
850
  answerAttempted = true;
818
851
  await options.answerGuestText(options.guestQueryId, options.fallbackText);
819
852
  } else {
820
853
  throw error;
821
854
  }
822
855
  } finally {
823
- if (stagingMessageId !== undefined) {
856
+ if (stagingMessageId !== undefined && isActive()) {
824
857
  try {
825
858
  await options.deleteMessage(options.stagingChatId, stagingMessageId);
826
859
  } catch (error) {
@@ -1983,13 +1983,18 @@ export interface TelegramAgentEndRuntimeDeps<
1983
1983
  ) => Promise<void>;
1984
1984
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
1985
1985
  /** Replaces the early guest ACK with the final text. */
1986
- editGuestReply?: (inlineMessageId: string, markdown: string) => Promise<void>;
1986
+ editGuestReply?: (
1987
+ inlineMessageId: string,
1988
+ markdown: string,
1989
+ options?: { assertAuthority?: () => void },
1990
+ ) => Promise<void>;
1987
1991
  /** Cancels the animated guest placeholder before the final edit. */
1988
1992
  stopGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
1989
1993
  sendGuestAttachment?: (
1990
1994
  turn: TTurn,
1991
1995
  attachment: QueuedAttachment,
1992
1996
  caption?: string,
1997
+ options?: { isDeliveryActive?: () => boolean },
1993
1998
  ) => Promise<void>;
1994
1999
  sendGuestVoiceReply?: (
1995
2000
  turn: TTurn,
@@ -2350,13 +2355,46 @@ export async function handleTelegramAgentEndRuntime<
2350
2355
  });
2351
2356
  }
2352
2357
  }
2353
- if (turn.guestInlineMessageId && deps.editGuestReply) {
2354
- const experimentText = assistant.errorMessage
2358
+ if (!isDeliveryActive()) return;
2359
+ if (turn.guestInlineMessageId) {
2360
+ let experimentText = assistant.errorMessage
2355
2361
  ? "Telegram bridge: Pi failed while processing the request."
2356
2362
  : finalText;
2363
+ const [attachment] = turn.queuedAttachments;
2364
+ if (!assistant.errorMessage && attachment) {
2365
+ try {
2366
+ if (!deps.sendGuestAttachment) throw new Error("Guest attachment delivery is unavailable.");
2367
+ await deps.sendGuestAttachment(turn, attachment, finalText || undefined, { isDeliveryActive });
2368
+ if (isDeliveryActive() && endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2369
+ return;
2370
+ } catch (error) {
2371
+ deps.recordRuntimeEvent?.("delivery", error, {
2372
+ phase: "guest-attachment", guestQueryId: turn.guestQueryId,
2373
+ });
2374
+ if (!isDeliveryActive()) return;
2375
+ if (isTelegramApiCommitUnknownError(error)) {
2376
+ if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2377
+ return;
2378
+ }
2379
+ experimentText = "Telegram bridge could not deliver the requested attachment. Please retry in a normal bot chat.";
2380
+ }
2381
+ } else if (!assistant.errorMessage && hasOutboundArtifacts) {
2382
+ experimentText = [finalText, "Telegram Guest Mode cannot replace an inline placeholder with a voice note. Please request MP3 audio or use a normal bot chat."].filter(Boolean).join("\n\n");
2383
+ }
2384
+ if (!deps.editGuestReply) {
2385
+ deps.recordRuntimeEvent?.("delivery", new Error("Guest inline reply editing is unavailable."), {
2386
+ phase: "guest-ack-edit", guestQueryId: turn.guestQueryId,
2387
+ });
2388
+ if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2389
+ return;
2390
+ }
2357
2391
  if (experimentText) {
2358
2392
  try {
2359
- await deps.editGuestReply(turn.guestInlineMessageId, experimentText);
2393
+ await deps.editGuestReply(turn.guestInlineMessageId, experimentText, {
2394
+ assertAuthority() {
2395
+ if (!isDeliveryActive()) throw new Error("Guest reply edit lost authority.");
2396
+ },
2397
+ });
2360
2398
  deps.recordRuntimeEvent?.(
2361
2399
  "guest",
2362
2400
  new Error("Guest ACK edited the guest answer"),
@@ -2401,6 +2439,7 @@ export async function handleTelegramAgentEndRuntime<
2401
2439
  turn,
2402
2440
  guestAttachment,
2403
2441
  finalText || undefined,
2442
+ { isDeliveryActive },
2404
2443
  );
2405
2444
  } catch (error) {
2406
2445
  deps.recordRuntimeEvent?.("delivery", error, {
@@ -1033,13 +1033,14 @@ export function createGuestMarkdownReplyEditor(deps: {
1033
1033
  editGuestInlineMessage: (
1034
1034
  inlineMessageId: string,
1035
1035
  content: { richMessage?: TelegramInputRichMessage; text?: string },
1036
+ options?: { assertAuthority?: () => void },
1036
1037
  ) => Promise<void>;
1037
1038
  }) {
1038
- return async (inlineMessageId: string, markdown: string) => {
1039
+ return async (inlineMessageId: string, markdown: string, options?: { assertAuthority?: () => void }) => {
1039
1040
  const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
1040
1041
  await deps.editGuestInlineMessage(inlineMessageId, {
1041
1042
  richMessage: { markdown: richMarkdown },
1042
- });
1043
+ }, options);
1043
1044
  };
1044
1045
  }
1045
1046