@llblab/pi-kit 0.35.0 → 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 (42) hide show
  1. package/BACKLOG.md +1 -0
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +6 -1
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +6 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  10. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  12. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +40 -45
  13. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  14. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  17. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  18. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  19. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  20. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  21. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  23. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +10 -5
  24. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +19 -2
  25. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +23 -11
  26. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  27. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  28. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +3 -1
  29. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  30. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
  31. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  32. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  33. package/node_modules/@llblab/pi-telegram/lib/extension.ts +43 -55
  34. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  35. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  36. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  37. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  38. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  39. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +22 -5
  40. package/node_modules/@llblab/pi-telegram/lib/threads.ts +25 -15
  41. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  42. package/package.json +2 -2
@@ -1171,7 +1171,7 @@ interface TelegramLifecycleBindingDeps {
1171
1171
  shutdownGenerativeAppLiveSurfaces?: () => void;
1172
1172
  resolveAutomaticThreadCleanupEnabled?: () => boolean | Promise<boolean>;
1173
1173
  buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
1174
- callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
1174
+ callMultipart: (...args: Parameters<TelegramApi.TelegramBridgeApiRuntime["callMultipart"]>) => Promise<unknown>;
1175
1175
  sendChatAction: NonNullable<
1176
1176
  OutboundHandlers.TelegramVoiceReplySenderDeps["sendChatAction"]
1177
1177
  >;
@@ -1198,6 +1198,7 @@ interface TelegramLifecycleBindingDeps {
1198
1198
  ctx: Pi.ExtensionContext,
1199
1199
  ) => void;
1200
1200
  answerGuestQuery: TelegramApi.TelegramBridgeApiRuntime["answerGuestQuery"];
1201
+ editGuestInlineMessage?: TelegramApi.TelegramBridgeApiRuntime["editGuestInlineMessage"];
1201
1202
  deleteMessage: TelegramApi.TelegramBridgeApiRuntime["deleteMessage"];
1202
1203
  sendGuestReply: NonNullable<
1203
1204
  Queue.TelegramAgentEndHookRuntimeDeps<
@@ -1276,6 +1277,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1276
1277
  onSteerTurnEnd,
1277
1278
  onPromptHandedOff,
1278
1279
  answerGuestQuery,
1280
+ editGuestInlineMessage,
1279
1281
  deleteMessage,
1280
1282
  sendGuestReply,
1281
1283
  editGuestReply,
@@ -1318,6 +1320,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1318
1320
  turn: Queue.PendingTelegramTurn,
1319
1321
  attachment: Queue.QueuedAttachment,
1320
1322
  caption?: string,
1323
+ options?: { isDeliveryActive?: () => boolean },
1321
1324
  ): Promise<void> => {
1322
1325
  const stagingTarget = proactivePushTargetGetter();
1323
1326
  const stagingChatId = stagingTarget?.chatId;
@@ -1328,11 +1331,19 @@ export function registerTelegramLifecycleRuntimeHooks({
1328
1331
  }
1329
1332
  await OutboundAttachments.deliverTelegramGuestCachedAttachment({
1330
1333
  guestQueryId: turn.guestQueryId!,
1334
+ guestInlineMessageId: turn.guestInlineMessageId,
1335
+ editGuestInlineMessage,
1336
+ isDeliveryActive: options?.isDeliveryActive,
1331
1337
  stagingChatId,
1332
1338
  stagingTarget,
1333
1339
  attachment,
1334
1340
  caption,
1335
- sendMultipart: callMultipart,
1341
+ sendMultipart: (method, fields, fileField, path, name) =>
1342
+ callMultipart(method, fields, fileField, path, name, {
1343
+ assertAuthority() {
1344
+ if (options?.isDeliveryActive?.() === false) throw new Error("Guest attachment upload lost authority.");
1345
+ },
1346
+ }),
1336
1347
  answerGuestQuery: (guestQueryId, result) =>
1337
1348
  answerGuestQuery(guestQueryId, undefined, { result }),
1338
1349
  answerGuestText: (guestQueryId, text) =>
@@ -536,9 +536,10 @@ export function createTelegramBusAwareApiRuntime(
536
536
  editGuestInlineMessage(
537
537
  inlineMessageId: string,
538
538
  content: TelegramEditGuestInlineMessageContent,
539
+ options?: Pick<TelegramApiCallOptions, "assertAuthority">,
539
540
  ): Promise<void> {
540
541
  return deps.ownsDirect()
541
- ? deps.directRuntime.editGuestInlineMessage(inlineMessageId, content)
542
+ ? deps.directRuntime.editGuestInlineMessage(inlineMessageId, content, options)
542
543
  : rejectTelegramDirectOwnership("editGuestInlineMessage");
543
544
  },
544
545
  async deleteMessage(chatId: number, messageId: number): Promise<void> {
@@ -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 =
@@ -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