@llblab/pi-kit 0.7.0 → 0.7.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 (30) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-telegram/BACKLOG.md +3 -2
  5. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +6 -1
  6. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  7. package/node_modules/@llblab/pi-telegram/docs/architecture.md +8 -6
  8. package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -4
  9. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +18 -0
  10. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
  11. package/node_modules/@llblab/pi-telegram/lib/bus.ts +5 -0
  12. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +189 -15
  13. package/node_modules/@llblab/pi-telegram/lib/commands.ts +3 -0
  14. package/node_modules/@llblab/pi-telegram/lib/config.ts +67 -4
  15. package/node_modules/@llblab/pi-telegram/lib/extension.ts +65 -6
  16. package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
  17. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +49 -3
  18. package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
  19. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +1 -0
  20. package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -8
  21. package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
  22. package/node_modules/@llblab/pi-telegram/lib/replies.ts +19 -0
  23. package/node_modules/@llblab/pi-telegram/lib/routing.ts +39 -0
  24. package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
  25. package/node_modules/@llblab/pi-telegram/lib/status.ts +41 -4
  26. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +74 -18
  27. package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
  28. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  29. package/package.json +2 -2
  30. /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
@@ -13,6 +13,10 @@ import type {
13
13
  TelegramBusAgentMessage,
14
14
  TelegramBusAgentTargetSelector,
15
15
  } from "./bus.ts";
16
+ import {
17
+ isTelegramChannelPostValidationError,
18
+ TelegramChannelPostValidationError,
19
+ } from "./channel-posts.ts";
16
20
  import type { ExtensionAPI } from "./pi.ts";
17
21
  import {
18
22
  TELEGRAM_ATTACH_PROMPT_GUIDELINES,
@@ -104,6 +108,12 @@ export interface TelegramOutboundMessageToolRegistrationDeps extends TelegramOut
104
108
  markdown: string,
105
109
  options: { operationId: string; replyMarkup?: unknown },
106
110
  ) => Promise<number | undefined>;
111
+ sendChannelMediaMessage?: (
112
+ channel: number | string,
113
+ mediaPath: string,
114
+ markdown: string,
115
+ options: { operationId: string; replyMarkup?: unknown },
116
+ ) => Promise<number | undefined>;
107
117
  }
108
118
 
109
119
  export interface TelegramQueuedOutboundAttachmentView {
@@ -517,11 +527,18 @@ export function registerTelegramOutboundMessageTool(
517
527
  name: "telegram_message",
518
528
  label: "Telegram Message",
519
529
  description:
520
- "Send Markdown text directly to the paired/default Telegram chat, an exact channel chat_id, or an explicit live target. Channel posting requires Telegram-granted bot permission. Hidden telegram_button comments become inline prompt buttons.",
530
+ "Send Markdown text directly to the paired/default Telegram chat, an exact channel chat_id, or an explicit live target. Channel delivery supports one optional local photo or video upload with the text as its caption. Channel posting requires Telegram-granted bot permission. Hidden telegram_button comments become inline prompt buttons.",
521
531
  promptSnippet: TELEGRAM_MESSAGE_PROMPT_SNIPPET,
522
532
  promptGuidelines: [...TELEGRAM_MESSAGE_PROMPT_GUIDELINES],
523
533
  parameters: Type.Object({
524
534
  text: Type.String({ description: "Message text to send" }),
535
+ media: Type.Optional(
536
+ Type.String({
537
+ minLength: 1,
538
+ description:
539
+ "Local single-file channel upload: .jpg/.jpeg/.png/.webp photo or .mp4 video; the text becomes its caption (max 1024 characters). Albums and other media types are rejected.",
540
+ }),
541
+ ),
525
542
  chat_id: Type.Optional(
526
543
  Type.Union([
527
544
  Type.Number(),
@@ -547,6 +564,7 @@ export function registerTelegramOutboundMessageTool(
547
564
  try {
548
565
  return await sendTelegramOutboundMessage({
549
566
  text: params.text,
567
+ media: params.media,
550
568
  operationId: toolCallId,
551
569
  channel: params.channel,
552
570
  chatId: params.chat_id,
@@ -561,9 +579,12 @@ export function registerTelegramOutboundMessageTool(
561
579
  planMessage: deps.planMessage,
562
580
  sendMarkdownMessage: deps.sendMarkdownMessage,
563
581
  sendChannelMarkdownMessage: deps.sendChannelMarkdownMessage,
582
+ sendChannelMediaMessage: deps.sendChannelMediaMessage,
564
583
  });
565
584
  } catch (error) {
566
- const reportableError = typeof params.chat_id === "string" || params.channel === true
585
+ const isChannelTarget = typeof params.chat_id === "string" || params.channel === true;
586
+ const reportableError = isChannelTarget &&
587
+ !isTelegramChannelPostValidationError(error)
567
588
  ? new Error("Telegram channel publication failed; inspect the retained local record before retrying.")
568
589
  : error;
569
590
  deps.recordRuntimeEvent?.("message", reportableError, { phase: "direct" });
@@ -771,6 +792,7 @@ export async function deliverTelegramGuestCachedAttachment(options: {
771
792
 
772
793
  export async function sendTelegramOutboundMessage(options: {
773
794
  text: string;
795
+ media?: string;
774
796
  operationId?: string;
775
797
  channel?: boolean;
776
798
  chatId?: number | string;
@@ -798,6 +820,12 @@ export async function sendTelegramOutboundMessage(options: {
798
820
  markdown: string,
799
821
  options: { operationId: string; replyMarkup?: unknown },
800
822
  ) => Promise<number | undefined>;
823
+ sendChannelMediaMessage?: (
824
+ channel: number | string,
825
+ mediaPath: string,
826
+ markdown: string,
827
+ options: { operationId: string; replyMarkup?: unknown },
828
+ ) => Promise<number | undefined>;
801
829
  }): Promise<{
802
830
  content: Array<{ type: "text"; text: string }>;
803
831
  details: { chatId: number | string; messageId?: number };
@@ -811,10 +839,23 @@ export async function sendTelegramOutboundMessage(options: {
811
839
  options.target !== undefined || options.agentThread !== undefined) {
812
840
  throw new Error("Telegram channel delivery requires one exact @username or negative numeric channel ID without a thread target.");
813
841
  }
842
+ const plan = options.planMessage(options.text);
843
+ if (options.media !== undefined) {
844
+ if (!options.sendChannelMediaMessage || !options.operationId) {
845
+ throw new TelegramChannelPostValidationError(
846
+ "Telegram channel media delivery requires direct leader transport ownership and operation identity.",
847
+ );
848
+ }
849
+ const messageId = await options.sendChannelMediaMessage(
850
+ options.chatId, options.media, plan.markdown,
851
+ { operationId: options.operationId, replyMarkup: plan.replyMarkup });
852
+ return { content: [{ type: "text",
853
+ text: formatTelegramOutboundMessageToolResultText(options.chatId) }],
854
+ details: { chatId: options.chatId, messageId } };
855
+ }
814
856
  if (!options.sendChannelMarkdownMessage || !options.operationId) {
815
857
  throw new Error("Telegram channel delivery requires direct leader transport ownership and operation identity.");
816
858
  }
817
- const plan = options.planMessage(options.text);
818
859
  const messageId = await options.sendChannelMarkdownMessage(
819
860
  options.chatId, plan.markdown, { operationId: options.operationId,
820
861
  replyMarkup: plan.replyMarkup });
@@ -822,6 +863,11 @@ export async function sendTelegramOutboundMessage(options: {
822
863
  text: formatTelegramOutboundMessageToolResultText(options.chatId) }],
823
864
  details: { chatId: options.chatId, messageId } };
824
865
  }
866
+ if (options.media !== undefined) {
867
+ throw new TelegramChannelPostValidationError(
868
+ "telegram_message media uploads require channel delivery with an exact @username or negative numeric channel ID and channel: true.",
869
+ );
870
+ }
825
871
  const requestedAgentSelector: TelegramBusAgentTargetSelector | undefined =
826
872
  options.agentThread !== undefined
827
873
  ? typeof options.agentThread === "number"
@@ -70,6 +70,7 @@ export interface TelegramPreviewActiveTurn {
70
70
  target?: TelegramTarget;
71
71
  voiceReplyPreferred?: boolean;
72
72
  voiceReplyRequired?: boolean;
73
+ guestQueryId?: string;
73
74
  }
74
75
 
75
76
  export interface TelegramAssistantMessagePreviewStartDeps<TMessage> {
@@ -408,6 +409,17 @@ export function createTelegramAssistantMessagePreviewHooks<TMessage>(
408
409
  };
409
410
  }
410
411
 
412
+ /**
413
+ * Returns true when the active turn is a Telegram Guest Mode query. A guest
414
+ * query allows exactly one answer within a limited Telegram response window,
415
+ * so it must never emit streaming draft previews.
416
+ */
417
+ export function shouldSuppressPreviewForGuestTurn(
418
+ turn: { guestQueryId?: string } | null | undefined,
419
+ ): boolean {
420
+ return !!turn?.guestQueryId;
421
+ }
422
+
411
423
  export async function handleTelegramAssistantMessagePreviewStart<TMessage>(
412
424
  message: TMessage,
413
425
  deps: TelegramAssistantMessagePreviewStartDeps<TMessage>,
@@ -422,6 +434,10 @@ export async function handleTelegramAssistantMessagePreviewStart<TMessage>(
422
434
  deps.setState(undefined);
423
435
  return;
424
436
  }
437
+ if (shouldSuppressPreviewForGuestTurn(turn)) {
438
+ deps.setState(undefined);
439
+ return;
440
+ }
425
441
  const state = deps.getState();
426
442
  sealTelegramPreviewState(state);
427
443
  const next = deps.createPreviewState();
@@ -443,6 +459,7 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
443
459
  return;
444
460
  }
445
461
  if (shouldSuppressPreviewForVoice(turn)) return;
462
+ if (shouldSuppressPreviewForGuestTurn(turn)) return;
446
463
  let state = deps.getState();
447
464
  if (!state) {
448
465
  state = deps.createPreviewState();
@@ -32,6 +32,7 @@ export const TELEGRAM_MESSAGE_PROMPT_SNIPPET =
32
32
  export const TELEGRAM_MESSAGE_PROMPT_GUIDELINES = [
33
33
  "Use telegram_message only when the user explicitly asks to send a message to Telegram from the local/TUI side, or names a concrete Telegram delivery target.",
34
34
  "For an explicitly requested channel post, pass its exact numeric id or public @username as chat_id; no local channel registry is required, and Telegram remains the authority on the bot's posting permission.",
35
+ "For an explicitly requested channel media post, pass one local .jpg/.jpeg/.png/.webp photo or .mp4 video as media; the text becomes its caption (max 1024 characters), and albums or other media types are rejected.",
35
36
  "For a live Pi thread target, provide thread as its case-insensitive name or numeric id; the bridge sends visibly and admits one attributed turn to that live instance. Unknown, ambiguous, same, or offline targets fail before sending.",
36
37
  "Add buttons by embedding the same top-level telegram_button HTML comments used in normal Telegram replies; Telegram does not support standalone buttons.",
37
38
  "During an active Telegram turn, omit telegram_message for the current target and answer normally; use thread only when the user requests delivery to a different live Pi thread.",
@@ -110,6 +110,7 @@ export interface TelegramQueueItemBase {
110
110
  transportStamp?: TelegramTransportStamp;
111
111
  replyToMessageId: number;
112
112
  guestQueryId?: string;
113
+ guestInlineMessageId?: string;
113
114
  queueOrder: number;
114
115
  queueLane: TelegramQueueLane;
115
116
  laneOrder: number;
@@ -152,6 +153,7 @@ export interface TelegramQueueHandoffBase {
152
153
  transportStamp?: TelegramTransportStamp;
153
154
  replyToMessageId: number;
154
155
  guestQueryId?: string;
156
+ guestInlineMessageId?: string;
155
157
  queueOrder: number;
156
158
  queueLane: TelegramQueueLane;
157
159
  laneOrder: number;
@@ -612,6 +614,9 @@ export function createTelegramQueueHandoffPayload<TContext>(
612
614
  ...(item.transportStamp ? { transportStamp: item.transportStamp } : {}),
613
615
  replyToMessageId: item.replyToMessageId,
614
616
  ...(item.guestQueryId ? { guestQueryId: item.guestQueryId } : {}),
617
+ ...(item.guestInlineMessageId
618
+ ? { guestInlineMessageId: item.guestInlineMessageId }
619
+ : {}),
615
620
  queueOrder: item.queueOrder,
616
621
  queueLane: item.queueLane,
617
622
  laneOrder: item.laneOrder,
@@ -626,6 +631,9 @@ export function createTelegramQueueHandoffPayload<TContext>(
626
631
  ...(item.transportStamp ? { transportStamp: item.transportStamp } : {}),
627
632
  replyToMessageId: item.replyToMessageId,
628
633
  ...(item.guestQueryId ? { guestQueryId: item.guestQueryId } : {}),
634
+ ...(item.guestInlineMessageId
635
+ ? { guestInlineMessageId: item.guestInlineMessageId }
636
+ : {}),
629
637
  queueOrder: item.queueOrder,
630
638
  queueLane: item.queueLane,
631
639
  laneOrder: item.laneOrder,
@@ -1544,6 +1552,8 @@ export interface TelegramAgentEndRuntimeDeps<
1544
1552
  options?: { parseMode?: string },
1545
1553
  ) => Promise<void>;
1546
1554
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
1555
+ /** Replaces the early guest ACK with the final text. */
1556
+ editGuestReply?: (inlineMessageId: string, markdown: string) => Promise<void>;
1547
1557
  sendGuestAttachment?: (
1548
1558
  turn: TTurn,
1549
1559
  attachment: QueuedAttachment,
@@ -1622,6 +1632,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
1622
1632
  >["sendRichAttachmentReply"];
1623
1633
  answerGuestQuery?: TelegramAgentEndRuntimeDeps<TTurn>["answerGuestQuery"];
1624
1634
  sendGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestReply"];
1635
+ editGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["editGuestReply"];
1625
1636
  sendGuestAttachment?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestAttachment"];
1626
1637
  sendGuestVoiceReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestVoiceReply"];
1627
1638
  planOutboundReply?: TelegramAgentEndRuntimeDeps<
@@ -1762,6 +1773,7 @@ export function createTelegramAgentEndHook<
1762
1773
  sendRichAttachmentReply: deps.sendRichAttachmentReply,
1763
1774
  answerGuestQuery: deps.answerGuestQuery,
1764
1775
  sendGuestReply: deps.sendGuestReply,
1776
+ editGuestReply: deps.editGuestReply,
1765
1777
  sendGuestAttachment: deps.sendGuestAttachment,
1766
1778
  sendGuestVoiceReply: deps.sendGuestVoiceReply,
1767
1779
  planOutboundReply: deps.planOutboundReply,
@@ -1854,11 +1866,47 @@ export async function handleTelegramAgentEndRuntime<
1854
1866
  return;
1855
1867
  }
1856
1868
  if (turn.guestQueryId) {
1869
+ if (turn.guestInlineMessageId && deps.editGuestReply) {
1870
+ const experimentText = assistant.errorMessage
1871
+ ? "Telegram bridge: Pi failed while processing the request."
1872
+ : finalText;
1873
+ if (experimentText) {
1874
+ try {
1875
+ await deps.editGuestReply(turn.guestInlineMessageId, experimentText);
1876
+ deps.recordRuntimeEvent?.(
1877
+ "guest",
1878
+ new Error("Guest ACK experiment edited the guest answer"),
1879
+ { phase: "guest-ack-edited", guestQueryId: turn.guestQueryId },
1880
+ );
1881
+ } catch (error) {
1882
+ deps.recordRuntimeEvent?.("delivery", error, {
1883
+ phase: "guest-ack-edit",
1884
+ guestQueryId: turn.guestQueryId,
1885
+ });
1886
+ }
1887
+ } else {
1888
+ deps.recordRuntimeEvent?.(
1889
+ "delivery",
1890
+ new Error("Guest ACK experiment turn produced no editable text"),
1891
+ { phase: "guest-ack-edit-empty", guestQueryId: turn.guestQueryId },
1892
+ );
1893
+ }
1894
+ if (!isDeliveryActive()) return;
1895
+ if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1896
+ return;
1897
+ }
1857
1898
  if (assistant.errorMessage) {
1858
- await deps.answerGuestQuery?.(
1859
- turn.guestQueryId,
1860
- "Telegram bridge: Pi failed while processing the request.",
1861
- );
1899
+ try {
1900
+ await deps.answerGuestQuery?.(
1901
+ turn.guestQueryId,
1902
+ "Telegram bridge: Pi failed while processing the request.",
1903
+ );
1904
+ } catch (error) {
1905
+ deps.recordRuntimeEvent?.("delivery", error, {
1906
+ phase: "guest-error-reply",
1907
+ guestQueryId: turn.guestQueryId,
1908
+ });
1909
+ }
1862
1910
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1863
1911
  return;
1864
1912
  }
@@ -1894,10 +1942,20 @@ export async function handleTelegramAgentEndRuntime<
1894
1942
  });
1895
1943
  }
1896
1944
  } else if (finalText) {
1897
- if (deps.sendGuestReply) {
1898
- await deps.sendGuestReply(turn.guestQueryId, finalText);
1899
- } else {
1900
- await deps.answerGuestQuery?.(turn.guestQueryId, finalText);
1945
+ try {
1946
+ if (deps.sendGuestReply) {
1947
+ await deps.sendGuestReply(turn.guestQueryId, finalText);
1948
+ } else {
1949
+ await deps.answerGuestQuery?.(turn.guestQueryId, finalText);
1950
+ }
1951
+ } catch (error) {
1952
+ // Guest queries expire after Telegram's response timeout, so a slow
1953
+ // turn can fail the only delivery attempt. Record and continue the
1954
+ // agent-end lifecycle instead of rejecting the extension hook.
1955
+ deps.recordRuntimeEvent?.("delivery", error, {
1956
+ phase: "guest-reply",
1957
+ guestQueryId: turn.guestQueryId,
1958
+ });
1901
1959
  }
1902
1960
  }
1903
1961
  if (!isDeliveryActive()) return;
@@ -551,9 +551,12 @@ function applyInlineMarkdownStyles(text: string): string {
551
551
  result = renderDelimitedInlineStyle(result, "*", (content) => {
552
552
  return `<i>${content}</i>`;
553
553
  });
554
- return renderDelimitedInlineStyle(result, "_", (content) => {
554
+ result = renderDelimitedInlineStyle(result, "_", (content) => {
555
555
  return `<i>${content}</i>`;
556
556
  });
557
+ return renderDelimitedInlineStyle(result, "||", (content) => {
558
+ return `<tg-spoiler>${content}</tg-spoiler>`;
559
+ });
557
560
  }
558
561
 
559
562
  function restoreInlineMarkdownTokens(
@@ -898,3 +898,22 @@ export function createGuestMarkdownReplySender(deps: {
898
898
  });
899
899
  };
900
900
  }
901
+
902
+ /**
903
+ * Guest reply editor: replaces an early Guest Mode answer (the temporary ACK
904
+ * experiment) with native Rich Markdown content addressed by
905
+ * `inline_message_id` instead of a chat/message pair.
906
+ */
907
+ export function createGuestMarkdownReplyEditor(deps: {
908
+ editGuestInlineMessage: (
909
+ inlineMessageId: string,
910
+ content: { richMessage?: TelegramInputRichMessage; text?: string },
911
+ ) => Promise<void>;
912
+ }) {
913
+ return async (inlineMessageId: string, markdown: string) => {
914
+ const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
915
+ await deps.editGuestInlineMessage(inlineMessageId, {
916
+ richMessage: { markdown: richMarkdown },
917
+ });
918
+ };
919
+ }
@@ -659,6 +659,12 @@ export interface TelegramInboundRouteRuntimeDeps<
659
659
  ) => Promise<number | undefined>;
660
660
  deleteMessage?: (chatId: number, messageId: number) => Promise<void>;
661
661
  answerGuestQuery: (guestQueryId: string, text?: string) => Promise<void>;
662
+ /** Answers the guest query immediately and returns its inline message id. */
663
+ answerGuestQueryForInlineMessage?: (
664
+ guestQueryId: string,
665
+ text: string,
666
+ options?: { parseMode?: "HTML" },
667
+ ) => Promise<string | undefined>;
662
668
  sendTextReply: (
663
669
  chatId: number,
664
670
  replyToMessageId: number,
@@ -2655,6 +2661,9 @@ export function createTelegramInboundRouteRuntime<
2655
2661
  await deps.threadStore.load();
2656
2662
  assertExecutionCurrent();
2657
2663
  };
2664
+ // Answer the guest query immediately so the agent-end edit can replace the
2665
+ // early ACK once the turn settles. See BACKLOG.md for live acceptance.
2666
+ const TELEGRAM_GUEST_ACK_HTML = "<b>⚙️ Received. Working on it…</b>";
2658
2667
  const handleAuthorizedTelegramGuestMessage = async (
2659
2668
  guestMessage: Updates.TelegramGuestMessage & { from: TelegramUser },
2660
2669
  ctx: TContext,
@@ -2662,6 +2671,31 @@ export function createTelegramInboundRouteRuntime<
2662
2671
  const assertExecutionCurrent =
2663
2672
  Updates.createTelegramUpdateExecutionFenceGuard(guestMessage);
2664
2673
  assertExecutionCurrent();
2674
+ let guestInlineMessageId: string | undefined;
2675
+ if (deps.answerGuestQueryForInlineMessage) {
2676
+ try {
2677
+ guestInlineMessageId = await deps.answerGuestQueryForInlineMessage(
2678
+ guestMessage.guest_query_id,
2679
+ TELEGRAM_GUEST_ACK_HTML,
2680
+ { parseMode: "HTML" },
2681
+ );
2682
+ deps.recordRuntimeEvent?.(
2683
+ "guest",
2684
+ new Error("Guest ACK experiment answered the guest query"),
2685
+ {
2686
+ phase: "guest-ack-sent",
2687
+ guestQueryId: guestMessage.guest_query_id,
2688
+ hasInlineMessageId: !!guestInlineMessageId,
2689
+ },
2690
+ );
2691
+ } catch (error) {
2692
+ deps.recordRuntimeEvent?.("guest", error, {
2693
+ phase: "guest-ack-failed",
2694
+ guestQueryId: guestMessage.guest_query_id,
2695
+ });
2696
+ }
2697
+ assertExecutionCurrent();
2698
+ }
2665
2699
  const text = guestMessage.text ?? "";
2666
2700
  const gm = guestMessage as unknown as Record<string, unknown>;
2667
2701
  // Build telegram prefix with guest context
@@ -2752,6 +2786,10 @@ export function createTelegramInboundRouteRuntime<
2752
2786
  promptFiles: processed.promptFiles,
2753
2787
  handlerOutputs: processed.handlerOutputs,
2754
2788
  sourceContext,
2789
+ // Guest Mode allows exactly one reply within Telegram's limited response
2790
+ // window; the note travels with the turn text so the agent sees it at
2791
+ // execution time without a guest-specific system prompt variant.
2792
+ guestTurn: true,
2755
2793
  });
2756
2794
  const order = deps.bridgeRuntime.queue.allocateItemOrder();
2757
2795
  const content: Queue.TelegramPromptContent[] = [
@@ -2778,6 +2816,7 @@ export function createTelegramInboundRouteRuntime<
2778
2816
  chatId: 0,
2779
2817
  replyToMessageId: 0,
2780
2818
  guestQueryId: guestMessage.guest_query_id,
2819
+ ...(guestInlineMessageId ? { guestInlineMessageId } : {}),
2781
2820
  sourceMessageIds: [],
2782
2821
  queueOrder: order,
2783
2822
  queueLane: "default",
@@ -42,6 +42,10 @@ export interface TelegramSetupDeps {
42
42
  result?: TelegramSetupUser;
43
43
  description?: string;
44
44
  }>;
45
+ /** Resolve a submitted literal token or `$NAME`/`${NAME}` reference. */
46
+ resolveBotToken?: (value: string) => string | undefined;
47
+ /** Redacted diagnostic for an unresolved or malformed token reference. */
48
+ describeBotToken?: (value: string) => string | undefined;
45
49
  persistConfig: (config: TelegramSetupConfig) => Promise<void>;
46
50
  notify: (message: string, level: "info" | "error") => void;
47
51
  startPolling: () => unknown | Promise<unknown>;
@@ -70,6 +74,8 @@ export interface TelegramSetupPromptRuntimeDeps<
70
74
  setConfig: (config: TelegramSetupConfig) => void;
71
75
  setupGuard: TelegramSetupGuard;
72
76
  getMe: TelegramSetupDeps["getMe"];
77
+ resolveBotToken?: TelegramSetupDeps["resolveBotToken"];
78
+ describeBotToken?: TelegramSetupDeps["describeBotToken"];
73
79
  persistConfig: (config: TelegramSetupConfig) => Promise<void>;
74
80
  startPolling: (ctx: TContext) => unknown | Promise<unknown>;
75
81
  updateStatus: (ctx: TContext) => void;
@@ -88,6 +94,25 @@ const TELEGRAM_BOT_TOKEN_ENV_VARS = [
88
94
  "TELEGRAM_KEY",
89
95
  ] as const;
90
96
 
97
+ /**
98
+ * Default submitted-token handling for structural callers that inject no
99
+ * reference port: plain literals pass through, while `$`-prefixed values fail
100
+ * closed instead of being sent to the Bot API as a literal token.
101
+ */
102
+ function resolveSubmittedTelegramBotToken(value: string): string | undefined {
103
+ const trimmed = value.trim();
104
+ if (!trimmed || trimmed.startsWith("$")) return undefined;
105
+ return trimmed;
106
+ }
107
+
108
+ function describeSubmittedTelegramBotToken(
109
+ value: string,
110
+ ): string | undefined {
111
+ return value.trim().startsWith("$")
112
+ ? "Telegram bot token environment reference is unavailable in this setup environment."
113
+ : undefined;
114
+ }
115
+
91
116
  function isTelegramPollingStartResult(
92
117
  value: unknown,
93
118
  ): value is TelegramPollingStartResult {
@@ -105,8 +130,8 @@ export function getTelegramBotTokenInputDefault(
105
130
  const trimmedConfigToken = configToken?.trim();
106
131
  if (trimmedConfigToken) return trimmedConfigToken;
107
132
  for (const key of TELEGRAM_BOT_TOKEN_ENV_VARS) {
108
- const value = env[key]?.trim();
109
- if (value) return value;
133
+ // Persist the originating alias rather than copying the resolved secret.
134
+ if (env[key]?.trim()) return `$${key}`;
110
135
  }
111
136
  return TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER;
112
137
  }
@@ -135,13 +160,26 @@ export async function runTelegramSetup(
135
160
  ? await deps.promptEditor("Telegram bot token", tokenPrompt.value)
136
161
  : await deps.promptInput("Telegram bot token", tokenPrompt.value);
137
162
  if (!token) return { status: "cancelled" };
163
+ const submittedToken = token.trim();
164
+ const resolveBotToken =
165
+ deps.resolveBotToken ?? resolveSubmittedTelegramBotToken;
166
+ const describeBotToken =
167
+ deps.describeBotToken ?? describeSubmittedTelegramBotToken;
168
+ const resolvedToken = resolveBotToken(submittedToken);
138
169
  const nextConfig: TelegramSetupConfig = {
139
170
  ...deps.config,
140
- botToken: token.trim(),
171
+ botToken: submittedToken,
141
172
  };
173
+ if (!resolvedToken) {
174
+ deps.notify(
175
+ describeBotToken(submittedToken) ?? "Invalid Telegram bot token",
176
+ "error",
177
+ );
178
+ return { status: "validation-failed" };
179
+ }
142
180
  let data: Awaited<ReturnType<TelegramSetupDeps["getMe"]>>;
143
181
  try {
144
- data = await deps.getMe(nextConfig.botToken ?? "");
182
+ data = await deps.getMe(resolvedToken);
145
183
  } catch (error) {
146
184
  const message = error instanceof Error ? error.message : String(error);
147
185
  deps.notify(`Telegram API check failed: ${message}`, "error");
@@ -195,6 +233,8 @@ export function createTelegramSetupPromptRuntime<
195
233
  promptInput: (label, value) => ctx.ui.input(label, value),
196
234
  promptEditor: (label, value) => ctx.ui.editor(label, value),
197
235
  getMe: deps.getMe,
236
+ resolveBotToken: deps.resolveBotToken,
237
+ describeBotToken: deps.describeBotToken,
198
238
  persistConfig: async (config) => {
199
239
  const previousConfig = deps.getConfig();
200
240
  deps.setConfig(config);
@@ -245,6 +245,8 @@ export interface TelegramBridgeInboundWorkerState {
245
245
  export interface TelegramBridgeStatusLineState {
246
246
  hasBotToken?: boolean;
247
247
  botUsername?: string;
248
+ /** Redacted named-variable diagnostic when the stored token reference cannot resolve. */
249
+ botTokenDiagnostic?: string;
248
250
  activeProfileName?: string;
249
251
  diagnosticPaths?: { state: string; logs: string };
250
252
  allowedUserId?: number;
@@ -323,10 +325,40 @@ export interface TelegramStatusRuntimeDeps<
323
325
 
324
326
  export interface TelegramBridgeStatusConfig {
325
327
  botToken?: string;
328
+ /** Caller-resolved token availability; falls back to raw presence. */
329
+ botHasToken?: boolean;
330
+ /** Caller-supplied redacted diagnostic for an unresolved token reference. */
331
+ botTokenDiagnostic?: string;
326
332
  botUsername?: string;
327
333
  allowedUserId?: number;
328
334
  }
329
335
 
336
+ /** Narrow config-store view used to project resolved bot-token availability. */
337
+ export interface TelegramBridgeStatusConfigSource {
338
+ get: () => TelegramBridgeStatusConfig;
339
+ hasBotToken?: () => boolean;
340
+ getBotTokenDiagnostic?: () => string | undefined;
341
+ }
342
+
343
+ /**
344
+ * Project a config store into the status view without moving token-reference
345
+ * resolution into this structural leaf domain.
346
+ */
347
+ export function createTelegramBridgeStatusConfigGetter(
348
+ source: TelegramBridgeStatusConfigSource,
349
+ ): () => TelegramBridgeStatusConfig {
350
+ return () => {
351
+ const config = source.get();
352
+ return {
353
+ ...config,
354
+ ...(source.hasBotToken ? { botHasToken: source.hasBotToken() } : {}),
355
+ ...(source.getBotTokenDiagnostic
356
+ ? { botTokenDiagnostic: source.getBotTokenDiagnostic() }
357
+ : {}),
358
+ };
359
+ };
360
+ }
361
+
330
362
  export interface TelegramBridgeStatusRuntimeDeps<
331
363
  TQueueItem extends { queueLane: TelegramStatusQueueLane },
332
364
  > {
@@ -681,7 +713,7 @@ export function createTelegramBridgeStatusRuntime<
681
713
  const compactionInProgress = deps.isCompactionInProgress();
682
714
  const localBus = deps.getLocalBus?.();
683
715
  return {
684
- hasBotToken: !!config.botToken,
716
+ hasBotToken: config.botHasToken ?? Boolean(config.botToken),
685
717
  pollingActive: deps.isPollingActive(),
686
718
  paired: !!config.allowedUserId,
687
719
  busRole: deps.getBusRole?.(),
@@ -715,8 +747,9 @@ export function createTelegramBridgeStatusRuntime<
715
747
  ? (deps.getActiveProfileName() ?? TELEGRAM_STATUS_DEFAULT_PROFILE_NAME)
716
748
  : undefined;
717
749
  return {
718
- hasBotToken: Boolean(config.botToken),
750
+ hasBotToken: config.botHasToken ?? Boolean(config.botToken),
719
751
  botUsername: config.botUsername,
752
+ botTokenDiagnostic: config.botTokenDiagnostic,
720
753
  activeProfileName,
721
754
  diagnosticPaths: deps.getDiagnosticPaths?.(activeProfileName),
722
755
  allowedUserId: config.allowedUserId,
@@ -925,10 +958,14 @@ export function buildTelegramStatusBarText(
925
958
  }
926
959
 
927
960
  function formatTelegramBridgeBotStatus(
928
- state: Pick<TelegramBridgeStatusLineState, "hasBotToken" | "botUsername">,
961
+ state: Pick<
962
+ TelegramBridgeStatusLineState,
963
+ "hasBotToken" | "botUsername" | "botTokenDiagnostic"
964
+ >,
929
965
  ): string {
930
966
  if (state.botUsername) return `@${state.botUsername}`;
931
- return state.hasBotToken ? "unknown" : "not configured";
967
+ if (state.hasBotToken) return "unknown";
968
+ return state.botTokenDiagnostic ?? "not configured";
932
969
  }
933
970
 
934
971
  function formatTelegramStatusTarget(