Skip to content

Latest commit

 

History

History
487 lines (384 loc) · 21.7 KB

File metadata and controls

487 lines (384 loc) · 21.7 KB

Public API

pi-telegram is both a Pi extension and a small Telegram platform for companion extensions. This document defines the stable public surface. Everything outside this document is implementation detail unless another focused doc explicitly marks it stable.

Stability Levels

  • Stable: documented here and covered by compatibility expectations.
  • Advanced stable: public for extension authors, but lower-level; prefer higher-level APIs when possible.
  • Compatibility: older import/config paths that remain supported but should not be used for new code.
  • Internal: exported from source for tests or domain reuse, but not a compatibility promise.

Package Entrypoints

Preferred public imports:

import telegram from "@llblab/pi-telegram";
import { registerTelegramSection } from "@llblab/pi-telegram/sections";
import { registerTelegramStatusLineProvider } from "@llblab/pi-telegram/status";
import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
import { registerTelegramCommand } from "@llblab/pi-telegram/commands";
import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";
import { registerTelegramOutboundHandler } from "@llblab/pi-telegram/outbound";
import {
  registerTelegramVoiceSynthesisProvider,
  registerTelegramVoiceTranscriptionProvider,
} from "@llblab/pi-telegram/voice";

0.12.0 intentionally removes the published @llblab/pi-telegram/lib/*.ts compatibility wildcard. Integrations should use the public API domain subpaths above. Package exports point at /api/*.ts membranes that re-export only stable companion-extension symbols; implementation modules under lib/ remain package-private. Telegram command extensions use /commands as an explicit opt-in surface instead of automatically exposing arbitrary Pi slash commands to Telegram. See Public API Smoke Examples below for minimal companion-extension patterns that avoid implementation imports.

User-Facing API

Pi commands

Stable commands inside Pi:

  • /telegram-setup — configure/update the bot token.
  • /telegram-connect — start polling here and acquire external Telegram control ownership. Accepted queue/reply state stays local if ownership later moves elsewhere.
  • /telegram-disconnect — stop polling and release ownership without deleting or silencing accepted local queue state.
  • /telegram-status — show connection, polling, execution, queue, and recent event diagnostics.

Telegram commands

Stable commands inside the paired Telegram DM:

  • /start — pair when needed and open the main application menu.
  • /compact — open confirmation and compact when idle.
  • /next — dispatch the next queued turn, aborting active work first when needed.
  • /continue — enqueue a priority continue prompt.
  • /abort — abort active work and keep the queue; abort-history is scoped to Telegram-owned active turns.
  • /stop — abort active Telegram-owned work and clear waiting Telegram queue items.

Hidden compatibility shortcuts may open sections directly: /help, /status, /model, /thinking, /queue, and /settings.

This command surface is a mobile companion subset, not a raw terminal-command bridge. Commands that depend on Pi's interactive runtime owning session replacement, TUI transcript clearing, or arbitrary slash-command dispatch stay out of the stable Telegram API unless Pi exposes a safe public extension hook for them.

Tools and assistant-authored actions

  • telegram_attach(paths, chat_id?, thread_id?, caption?) is the stable artifact delivery tool for generated files. During Telegram turns it queues files for the active reply; outside Telegram turns it sends files directly to the paired/default chat, the registered follower's assigned thread, or an explicit chat_id plus optional thread_id when this Pi instance owns /telegram-connect or is registered with the multi-instance bus.
  • telegram_message(text, chat_id?, thread_id?) sends a direct Telegram Markdown message from local/TUI-initiated work when this Pi instance owns /telegram-connect or is registered with the multi-instance bus. Top-level telegram_button comments inside text are parsed with the same planner used for normal replies and attached to that message; buttons are never standalone Telegram messages.
  • telegram_help() returns detailed agent-facing guidance for pi-telegram delivery actions, Threaded Mode, formatting, and debugging. The regular prompt only points agents at this tool instead of repeating the full guidance on every turn.
  • telegram_voice hidden comments request Telegram-native voice delivery.
  • telegram_button hidden comments create inline buttons whose taps enqueue prompts. Use top-level column-zero comments outside code, quotes, lists, and indented examples; do not emit JSON button specs or standalone button actions.

Prompt guidance is context-aware: local/TUI prompts see only explicit direct-delivery guidance, while Telegram-originated turns receive the full action-comment syntax and phone-width output contract.

See Outbound Handlers for exact markup forms.

Configuration API

Configuration lives in ~/.pi/agent/telegram.json unless PI_CODING_AGENT_DIR changes the agent root.

Stable config keys:

interface TelegramConfig {
  botToken?: string;
  botUsername?: string; // runtime-managed
  botId?: number; // runtime-managed
  allowedUserId?: number;
  lastUpdateId?: number; // runtime-managed
  proactivePush?: boolean;
  inboundHandlers?: TelegramInboundHandlerConfig[];
  attachmentHandlers?: TelegramInboundHandlerConfig[]; // compatibility alias
  outboundHandlers?: TelegramOutboundHandlerConfig[];
  voice?: {
    replyMode?: "manual" | "mirror" | "always";
    sendTranscript?: boolean;
  };
  time?: {
    injectionMode?: "hidden" | "always" | "interval";
    interval?: number;
  };
}

Hidden/default semantics are represented by absence:

  • Voice Reply hidden: no voice.replyMode key is persisted.
  • Agent activity status is not configurable in this release. Telegram uses native sendChatAction(typing) / product ...active status as the only automatic in-chat work signal before the final reply.
  • Time Injection hidden: no time.injectionMode key is persisted; if time becomes empty, the whole time object may be omitted.

Assistant Markdown delivery is native: final replies are sent as InputRichMessage.markdown via sendRichMessage, and draft previews use sendRichMessageDraft when a structurally closed preview frame is available. Draft-frame failures are recorded and skipped rather than converted into raw plain preview messages, because partial Markdown can be temporarily invalid while the final answer remains valid. Long native replies are split at Telegram Rich Message transport limits, with oversized fenced code, display-math, and fully wrapped inline-formatting blocks rewrapped per chunk so persisted chunks remain structurally valid. Guest replies use InputRichMessageContent in answerGuestQuery results. Bridge-owned UI surfaces such as menus, status, queue controls, commands, and sections keep explicit Telegram HTML/plain rendering by default because those texts are authored by the bridge or companion extensions for Telegram UI. Companion extension sections may explicitly request "markdown", "html", or "plain" per view. There is no telegram.json rendering toggle for assistant delivery. The bridge sets skip_entity_detection: true for assistant and guest Markdown so technical text such as /commands, hashtags, URLs, phone numbers, and card-like numbers does not gain unintended automatic entities; explicit Markdown links still belong in the Markdown source.

Environment variables are stable only where documented in the README: bot-token bootstrap, proxy behavior, agent root, and inbound/outbound file size limits.

Programmatic API Matrix

High-level stable APIs:

  • registerTelegramSection()
    • Identity: required id.
    • Purpose: managed menu/settings UI surfaces.
  • registerTelegramCommand()
    • Identity: command name.
    • Purpose: explicit opt-in Telegram-native slash commands for companion workflows.
  • registerTelegramStatusLineProvider()
    • Identity: required id.
    • Purpose: compact companion status rows in the /start menu status text.
  • registerTelegramVoiceTranscriptionProvider()
    • Identity: required stable id for new code.
    • Purpose: STT fallback for voice/audio input.
  • registerTelegramVoiceSynthesisProvider()
    • Identity: required stable id for new code.
    • Purpose: TTS fallback for Telegram voice replies.

Low-level stable buses:

  • registerTelegramUpdateHandler()
    • Identity: no id.
    • Purpose: observe or consume raw Telegram updates before default routing.
  • registerTelegramInboundHandler()
    • Identity: no id.
    • Purpose: generic Telegram-to-Pi transforms.
  • registerTelegramOutboundHandler()
    • Identity: no id.
    • Purpose: generic final-reply transforms or voice command fallbacks.

Advanced stable diagnostics:

  • recordTelegramRuntimeEvent()
    • Identity: caller supplies category.
    • Purpose: surface companion diagnostics in /telegram-status.

All registration APIs return a disposer. Companion extensions should call disposers on shutdown and re-register on session start when they recreate runtime state. Low-level bus APIs intentionally avoid ids and run in registration order. High-level provider/UI APIs require stable identity in their public contract so diagnostics, replacement, and cleanup are understandable. Generated voice-provider ids remain a temporary compatibility path where documented.

Commands

Import from @llblab/pi-telegram/commands. This registers Telegram slash commands only; it does not expose Pi slash commands and is unrelated to command-template handlers.

const off = registerTelegramCommand({
  name: "review",
  description: "Review queued work",
  showInMenu: true,
  emoji: "🧩",
  handler: async (ctx) => {
    await ctx.enqueuePrompt(`Review this work: ${ctx.args}`);
  },
});

Contract:

  • Command names are Telegram Bot API names: lowercase a-z, digits, and _, up to 32 characters. Hyphenated names are rejected.
  • Built-in bridge commands such as /start, /compact, /next, /abort, and /stop are reserved and cannot be claimed by extensions.
  • Duplicate extension command names are rejected. The disposer removes only its own command registration.
  • Routing precedence is built-in bridge commands first, registered extension commands second, and prompt-template aliases after that. This lets an extension intentionally claim a command name; prompt-template owners can resolve collisions by renaming the template alias.
  • showInMenu defaults to false. When true, emoji is required and the command appears in /start help with that marker; it also joins Bot API command sync only when description is provided, because Telegram command-list entries require descriptions. The emoji is prefixed to the Bot API description as well. Workflow/product commands should opt in deliberately instead of expanding the core command row by default.
  • The command context currently provides name, args, reply(text), and enqueuePrompt(prompt). Use enqueuePrompt() when a command should create normal queued Pi work rather than perform immediate Telegram-side handling.
  • Handler failures are isolated: the bridge records a telegram-command runtime diagnostic, sends a compact failure reply, and keeps Telegram polling/routing alive.

Core commands stay reserved for bridge lifecycle, transport ownership, queue safety, and essential operator controls. Opinionated workflow commands should live in companion extensions through this registry.

Sections

Import from @llblab/pi-telegram/sections.

const unregister = registerTelegramSection({
  id: "@scope/my-extension",
  label: "🧩 My extension",
  order: 10,
  render: async (ctx) => ({
    text: "<b>My extension</b>",
    parseMode: "html",
    replyMarkup: {
      inline_keyboard: [
        [{ text: "▶️ Run", callback_data: ctx.callbackData("run") }],
      ],
    },
  }),
  handleCallback: async (ctx) => {
    if (ctx.action !== "run") return "pass";
    await ctx.enqueuePrompt("Run my extension workflow.");
    await ctx.answerCallback("Queued");
    return "handled";
  },
});

Contract:

  • id is unique per active registry. Duplicate ids are rejected.
  • ctx.callbackData(action, payload?) builds compact section: callbacks and validates Telegram's 64-byte limit.
  • ctx.edit() auto-prepends the correct Back/Main-menu row. ctx.open() sends a standalone chat message without auto-navigation.
  • Section dynamic-label, render, and callback errors are isolated, surfaced as callback popups where applicable, and reflected by getTelegramSectionDiagnostics() until the matching surface succeeds.

Full behavior: Extension Sections.

Status Lines

Import from @llblab/pi-telegram/status.

const off = registerTelegramStatusLineProvider(
  ({ activeModel }) => {
    if (activeModel?.provider !== "example-provider") return undefined;
    return { label: "service", value: "ready" };
  },
  { id: "@scope/example-status" },
);

Contract:

  • Providers are synchronous because /start status text is rendered inline with the menu.
  • Return undefined when the line is not relevant for the active model.
  • Provider failures are isolated and skipped so optional companion status cannot break the core Telegram menu.
  • The bridge renders rows as <Label>: <value> in the same HTML status block as Status, Usage, Cost, and Context, capitalizing the first label character for Telegram UI consistency.

Updates

Import from @llblab/pi-telegram/updates.

const off = registerTelegramUpdateHandler(async (update) => {
  const data = (update as { callback_query?: { data?: string } }).callback_query
    ?.data;
  if (!data?.startsWith("myext:")) return "pass";
  await handleMyCallback(data);
  return "consume";
});

Use this as a low-level escape hatch. Prefer sections for menu-integrated UI.

Full behavior: Updates.

Inbound

Import from @llblab/pi-telegram/inbound.

const off = registerTelegramInboundHandler("document", async ({ file }) => {
  if (!file?.mimeType?.includes("pdf")) return undefined;
  return await extractPdfText(file.path);
});

Priority order:

  1. configured inboundHandlers
  2. compatibility attachmentHandlers
  3. programmatic inbound handlers
  4. voice transcription providers
  5. built-in text-file fallback

Full behavior: Inbound Handlers.

Outbound

Import from @llblab/pi-telegram/outbound.

const off = registerTelegramOutboundHandler("text", async (text) => {
  return await rewriteFinalText(text);
});

Programmatic outbound handlers are fallbacks/transformers behind operator-owned telegram.json configuration. Voice delivery priority is configured voice handlers, then programmatic voice handlers, then synthesis providers.

Full behavior: Outbound Handlers.

Voice Providers

Import from @llblab/pi-telegram/voice.

const offStt = registerTelegramVoiceTranscriptionProvider(
  async (file) => {
    if (file.kind !== "voice" && file.kind !== "audio") return undefined;
    return { text: await transcribe(file.path) };
  },
  { id: "@scope/my-extension/stt" },
);

const offTts = registerTelegramVoiceSynthesisProvider(
  async (text, options) => {
    const audioPath = await synthesizeOggOpus(text, options);
    return getTelegramVoiceSendTranscript(getCurrentTelegramConfigView())
      ? { audioPath, transcriptText: text }
      : { audioPath };
  },
  { id: "@scope/my-extension/tts" },
);

Stable voice-provider registrations pass a durable id. Omitting id is a compatibility path for older providers and receives a generated session-local id. Providers return undefined to pass. TTS providers must return .ogg or .opus files for native Telegram voice notes. voice.sendTranscript is the bridge-owned transcript preference; providers that expose captions should gate transcriptText with getTelegramVoiceSendTranscript(config) instead of defining a second reply-policy toggle.

Full behavior: Voice Integration.

Public API Smoke Examples

Minimal companion-extension examples that import only stable @llblab/pi-telegram/* public membranes. Copy one into an extension index.ts, load it beside pi-telegram, and verify that it starts without importing any @llblab/pi-telegram/lib/* implementation path.

Extension Sections

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerTelegramSection } from "@llblab/pi-telegram/sections";

export default function demoSection(pi: ExtensionAPI) {
  let unregister: (() => void) | undefined;
  pi.on("session_start", async () => {
    unregister?.();
    unregister = registerTelegramSection({
      id: "demo-section/status",
      label: "🧩 Demo section",
      order: 50,
      render: () => ({
        text: "<b>Demo section</b>\n\nThis section was rendered by a companion extension.",
        parseMode: "html",
        replyMarkup: { inline_keyboard: [] },
      }),
    });
  });
  pi.on("session_shutdown", async () => {
    unregister?.();
    unregister = undefined;
  });
}

Raw Update Handler

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";

export default function demoUpdates(pi: ExtensionAPI) {
  let unregister: (() => void) | undefined;
  pi.on("session_start", async () => {
    unregister?.();
    unregister = registerTelegramUpdateHandler((update) => {
      if (!update || typeof update !== "object") return "pass";
      return "pass";
    });
  });
  pi.on("session_shutdown", async () => {
    unregister?.();
    unregister = undefined;
  });
}

Inbound Handler

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";

export default function demoInbound(pi: ExtensionAPI) {
  let unregister: (() => void) | undefined;
  pi.on("session_start", async () => {
    unregister?.();
    unregister = registerTelegramInboundHandler("text/*", async (file) => {
      if (!file.path.endsWith(".demo.txt")) return undefined;
      return `Demo inbound handler saw ${file.fileName ?? file.path}`;
    });
  });
  pi.on("session_shutdown", async () => {
    unregister?.();
    unregister = undefined;
  });
}

Outbound Handler

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerTelegramOutboundHandler } from "@llblab/pi-telegram/outbound";

export default function demoOutbound(pi: ExtensionAPI) {
  let unregister: (() => void) | undefined;
  pi.on("session_start", async () => {
    unregister?.();
    unregister = registerTelegramOutboundHandler("text", async (text) => {
      if (!text.includes("[demo-outbound]")) return undefined;
      return text.replace("[demo-outbound]", "Demo outbound handler:");
    });
  });
  pi.on("session_shutdown", async () => {
    unregister?.();
    unregister = undefined;
  });
}

Voice Providers

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
  getTelegramVoiceSendTranscript,
  registerTelegramVoiceSynthesisProvider,
  registerTelegramVoiceTranscriptionProvider,
} from "@llblab/pi-telegram/voice";

export default function demoVoice(pi: ExtensionAPI) {
  let unregisterTts: (() => void) | undefined;
  let unregisterStt: (() => void) | undefined;
  let currentConfig: { voice?: { sendTranscript?: boolean } } = {};
  pi.on("session_start", async () => {
    unregisterTts?.();
    unregisterStt?.();
    unregisterTts = registerTelegramVoiceSynthesisProvider(
      async (text) => {
        const audioPath = await synthesizeDemoOgg(text);
        return getTelegramVoiceSendTranscript(currentConfig)
          ? { audioPath, transcriptText: text }
          : { audioPath };
      },
      { id: "demo-voice/tts" },
    );
    unregisterStt = registerTelegramVoiceTranscriptionProvider(
      async (file) => {
        if (file.kind !== "voice" && file.kind !== "audio") return undefined;
        return { text: `Demo transcript for ${file.fileName ?? file.path}` };
      },
      { id: "demo-voice/stt" },
    );
  });
  pi.on("session_shutdown", async () => {
    unregisterTts?.();
    unregisterStt?.();
    unregisterTts = undefined;
    unregisterStt = undefined;
  });
}

async function synthesizeDemoOgg(_text: string): Promise<string> {
  throw new Error("Replace synthesizeDemoOgg with a real OGG/Opus generator.");
}

Smoke Checklist

  • The extension imports only @llblab/pi-telegram/sections, /updates, /inbound, /outbound, /voice, or /keyboard.
  • It does not import @llblab/pi-telegram/lib/*.
  • It registers on session_start and disposes on session_shutdown.
  • Stable high-level registrations use durable ids.
  • Failures are visible during manual testing through /telegram-status or extension-owned logging.

Callback Namespaces

Owned prefixes are reserved by pi-telegram: compact:, tgbtn:, menu:, model:, thinking:, status:, queue:, settings:, and section:.

Companion extensions should use their own short prefix for raw callbacks or use ctx.callbackData() inside sections. Unknown unowned callbacks may be forwarded to Pi as [callback] <data> after built-in handlers decline them.

Full behavior: Callback Namespaces.

Internal Surface

The following are not stable public contracts unless explicitly documented elsewhere:

  • queue/runtime/lifecycle stores and planners
  • menu implementation helpers
  • polling/lock internals
  • Telegram API transport helpers
  • rendering internals
  • command implementation helpers
  • test support functions

They are intentionally not exposed through a ./lib/*.ts export wildcard in 0.12.0.