Skip to content

Latest commit

 

History

History
450 lines (338 loc) · 17.7 KB

File metadata and controls

450 lines (338 loc) · 17.7 KB

Telegram Extension Sections Standard

Status: Implemented in 0.10.0. Stable public API.

Meta-contract: transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).


1. Philosophy

Telegram Extension Sections let ordinary pi extensions add structured UI surfaces to the pi-telegram inline application menu. The platform mirrors Pi's own extensibility model: small, composable extensions that plug into a shared shell without owning transport, polling, authorization, or menu lifecycle.

pi-telegram stays the single bot operator. Extensions register typed sections; the bridge handles Telegram UI rendering, callback routing, token mapping, navigation hierarchy, and diagnostics. Section views default to explicit Telegram HTML UI markup, while extensions can request Markdown or plain text when that better matches their content. No second polling loop, no new loader — just one registerTelegramSection() call.

2. Contract Layers

The standard operates across three integration surfaces:

  • Extension API: registration shape, context ports, callbackData(), getLabel(), navigation, disposer
  • Telegram Bot API: 64-byte limit → token mapping, inline keyboard, menu:back/settings:list routing, stale-token answers
  • Pi Extension API: typed import + globalThis, pi.on("shutdown") cleanup, load-order, identity

3. Identity Key

Each section has one stable identity key. Use the same rules as the Extension Locks Standard:

  1. package.json/name for npm-style pi packages
  2. Directory name when the entrypoint is index.ts without package.json
  3. File basename for single-file extensions
extensions/pi-telegram-extension-demo/package.json name=@llblab/pi-telegram-extension-demo → @llblab/pi-telegram-extension-demo
extensions/pi-telegram-extension-demo/index.ts without package.json              → pi-telegram-extension-demo
extensions/pi-telegram-extension-demo.ts                                         → pi-telegram-extension-demo

The id is the owner identity. No separate owner field. Used for registry ownership, conflict detection, diagnostics, cleanup, and callback routing lookup.

4. Registration Shape

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

export default function (pi: ExtensionAPI) {
  const unregister = registerTelegramSection({
    id: "@llblab/pi-telegram-extension-demo",
    label: "🧪 Demo submenu",
    order: 10,
    getLabel: () => `${flag ? "🟢" : "⚫️"} Demo submenu`,
    render: async (ctx) => ({
      text: "<b>Demo</b>",
      parseMode: "html",
      replyMarkup: {
        inline_keyboard: [
          [{ text: "Click me", callback_data: ctx.callbackData("act", "x") }],
        ],
      },
    }),
    handleCallback: async (ctx) => {
      if (ctx.action === "act") {
        await ctx.answerCallback(`payload: ${ctx.payload}`);
        return "handled";
      }
      return "pass";
    },
    settings: {
      label: "🧪 Demo settings",
      order: 0,
      getLabel: () => `${flag ? "🟢" : "⚫️"} Demo settings`,
      open: async (ctx) => ({ text: "<b>Settings</b>", parseMode: "html" }),
      handleCallback: async (ctx) => {
        flag = ctx.payload === "on";
        return "handled";
      },
    },
  });
  pi.on("shutdown", () => unregister());
}

Full TypeScript shape

type TelegramSectionId = string;
type TelegramSectionCallbackResult = "handled" | "pass";

interface TelegramSectionRegistration {
  id: TelegramSectionId;
  label: string;
  order?: number;
  getLabel?: () => string;
  render: (
    ctx: TelegramSectionContext,
  ) => TelegramSectionView | Promise<TelegramSectionView>;
  handleCallback?: (
    ctx: TelegramSectionCallbackContext,
  ) => TelegramSectionCallbackResult | Promise<TelegramSectionCallbackResult>;
  settings?: {
    label: string;
    order?: number;
    getLabel?: () => string;
    open: (
      ctx: TelegramSectionContext,
    ) => TelegramSectionView | Promise<TelegramSectionView>;
    handleCallback?: (
      ctx: TelegramSectionCallbackContext,
    ) => TelegramSectionCallbackResult | Promise<TelegramSectionCallbackResult>;
  };
}

interface TelegramSectionView {
  text: string;
  // Defaults to "html" for explicit Telegram UI markup.
  // Use "markdown" when the section naturally owns Markdown content.
  parseMode?: "markdown" | "html" | "plain";
  replyMarkup?: TelegramInlineKeyboardMarkup;
}

Registration returns a disposer

const unregister = registerTelegramSection(section);
unregister(); // removes from main menu, settings, and callback routing

5. Loading Model

Two paths, same registry:

Typed import (preferred): Extension imports registerTelegramSection from @llblab/pi-telegram/sections. The function reads from a globalThis registry set by pi-telegram at startup. In 0.12.0, package-private @llblab/pi-telegram/lib/*.ts deep imports are no longer exported.

Relative import (local): When the extension cannot resolve @llblab/pi-telegram as an npm package, use the public API membrane via a relative path:

import { registerTelegramSection } from "../pi-telegram/api/sections.ts";

GlobalThis bridge (zero-coupling): pi-telegram exposes __piTelegramSectionRegistry__ on globalThis. The typed import is a thin wrapper. Extensions never touch the raw registry.

Load order: pi-telegram must load first (sets the global registry). Demo/consumer extensions load second (call registerTelegramSection). Pi's normal extension loader guarantees this when pi-telegram is listed first.

Shutdown: Call pi.on("shutdown", () => unregister()) to clean up your section. pi-telegram owns the registry for its loaded session, but it does not globally wipe extension registries on every session_shutdown.

6. Menu Integration

Sections appear in two locations:

Main menu

Section rows are injected before the ⚙️ Settings row. Ordered by order (lower first), then id alphabetically. The top-level getLabel() function (if present) is called on every render to produce a dynamic main-menu label — use it for extension status indicators.

🤖 Model: anthropic/claude-sonnet-4-5
🧠 Thinking: off
⌛ Queue: 0
🟢 Demo submenu          ← extension section (dynamic label)
⚙️ Settings

Built-in core rows keep priority. Section errors do not break menu rendering — a failed dynamic label is omitted with a diagnostic entry until a later label render succeeds.

Settings submenu

Extensions with a settings block inject rows before built-in Proactive push. The getLabel() function (if present) is called on every render to produce a dynamic label — use it for status indicators:

getLabel: () => `${flag ? "🟢" : "⚫️"} Demo settings`;
⬆️ Main menu
🟢 Demo settings          ← extension settings (dynamic label)
🟢 Proactive push

Ordered by settings.order (lower first), then id alphabetically.

7. Callback Routing

Token mapping

Telegram limits callback_data to 64 bytes. Full npm names like @llblab/pi-telegram-explorer often exceed this. pi-telegram maps each registered section to a compact numeric token:

section:<token>:<action>:<payload>

Example: section:0:counter:5

The token is an implementation detail. Section authors never write section: strings manually. Use ctx.callbackData(action, payload?) which fills in the correct token and rejects callback data above Telegram's 64-byte limit.

Routing order

  1. Telegram update arrives through the single pi-telegram polling loop
  2. Update handlers observe/consume (raw update interception)
  3. Button action store (tgbtn:*)
  4. Compact confirmation callbacks (compact:*)
  5. Queue menu callbacks (queue:*)
  6. Settings menu callbacks (settings:*)
  7. Section callbacks (section:*)
  8. Built-in menu callbacks (menu:*, model:*, thinking:*, status:*)
  9. Unknown callbacks fall back to [callback] prompt text

Handler return values

  • "handled": callback consumed, stop routing, answerCallbackQuery already called
  • "pass": section declines; fallback to settings handler (if exists), then to caller

Fallback chain in handleCallback

section.handleCallback(ctx) → "handled" | "pass"
  └─ if "pass" and settings.handleCallback exists →
       settings.handleCallback(newCtx with backCallback="settings:list")

The fallback creates a new context with the correct backCallback for the navigation level.

Stale tokens

If a section is unregistered or a token is unknown, the callback is answered with a short Telegram native popup:

"This section is no longer available."

Section render and callback errors are caught, surfaced as popup text, and stored in section diagnostics until the matching surface later succeeds. No unhandled exceptions leak to polling.

8. Navigation Hierarchy

ctx.edit() automatically prepends a Back row for menu-bound views. The Back target depends on the navigation level:

  • Section root (from main menu): ⬆️ Main menumenu:back
  • Section sub-view (ctx.edit() in handler): ⬆️ Backsection:<token>:open
  • Settings root (from Settings list): ⬆️ Backsettings:list
  • Settings sub-view (ctx.edit() in settings handler): ⬆️ Backsettings:list

Section authors do not need to manage the Back button for ctx.edit() — it is added automatically and deduplicated when already present. ctx.open() sends a standalone chat message and does not prepend a Back row.

Main menu
  ├─ 🧪 Demo submenu ──── [⬆️ Main menu]
  │    └─ Counter ─────── [⬆️ Back → Demo submenu]
  └─ ⚙️ Settings ──────── [⬆️ Main menu]
       └─ Demo settings ─ [⬆️ Back → Settings list]
            └─ toggle ─── [⬆️ Back → Settings list] (via ctx.edit)

9. Context Ports

TelegramSectionContext — for render() and settings.open()

interface TelegramSectionContext {
  sectionId: string;
  chatId: number;
  messageId?: number;
  /** Answer the callback query with an optional popup text */
  answerCallback(text?: string): Promise<void>;
  /** Edit the current message (auto-prepends Back row) */
  edit(view: TelegramSectionView): Promise<void>;
  /** Send a standalone chat message without auto-navigation */
  open(view: TelegramSectionView): Promise<void>;
  /** Enqueue a plain-text prompt turn */
  enqueuePrompt(prompt: string): Promise<void>;
  /** Build a section-namespaced callback_data string */
  callbackData(action: string, payload?: string): string;
  /** Delete the message that triggered this callback */
  deleteMessage(): Promise<void>;
}

TelegramSectionCallbackContext — for handleCallback()

interface TelegramSectionCallbackContext {
  sectionId: string;
  chatId: number;
  messageId?: number;
  /** The action segment from callback_data */
  action: string;
  /** The payload segment from callback_data */
  payload: string;
  answerCallback(text?: string): Promise<void>;
  edit(view: TelegramSectionView): Promise<void>;
  open(view: TelegramSectionView): Promise<void>;
  enqueuePrompt(prompt: string): Promise<void>;
  callbackData(action: string, payload?: string): string;
  /** Delete the message that triggered this callback */
  deleteMessage(): Promise<void>;
}

enqueuePrompt semantics

Queues a [telegram] <prompt> turn in the default lane with the paired user's chatId. Uses queueMutationRuntime.append() and triggers dispatchNextQueuedTelegramTurn(). The prompt arrives as a normal Telegram-owned turn — the agent sees it as if the user typed it.

Capability scope

Context ports are intentionally narrow. Sections cannot:

  • Read/write filesystem
  • Access raw process or bot clients
  • Start a second polling loop
  • Mutate session state
  • Send arbitrary Telegram API calls

Add capability-specific ports only when the first real extension proves the need.

Interactive messages in chat (ctx.open)

ctx.open() sends a new message directly into the Telegram chat — outside the menu hierarchy. No Back row is prepended. Use it for extension-driven interactions that live in the conversation:

  • Confirmation dialogs ("Delete file.txt?")
  • Approve/deny gates ("Allow tool execution?")
  • Multi-step forms that should not be menu-bound
  • Status reports with action buttons
handleCallback: async (ctx) => {
  if (ctx.action === "delete-file") {
    await ctx.open({
      text: `<b>Delete ${ctx.payload}?</b>\n\nThis cannot be undone.`,
      parseMode: "html",
      replyMarkup: {
        inline_keyboard: [
          [
            {
              text: "✅ Yes, delete",
              callback_data: ctx.callbackData("confirm-delete", ctx.payload),
            },
            { text: "❌ Cancel", callback_data: ctx.callbackData("cancel") },
          ],
        ],
      },
    });
    return "handled";
  }
  if (ctx.action === "confirm-delete") {
    await ctx.deleteMessage();
    await ctx.answerCallback(`Deleted: ${ctx.payload}`);
    return "handled";
  }
  if (ctx.action === "cancel") {
    await ctx.deleteMessage();
    return "handled";
  }
};

ctx.deleteMessage() removes the dialog from chat after the user makes a choice. Callbacks from chat buttons route through the same handleCallback — the same ctx.callbackData() works regardless of where the button lives. The extension owns its callback namespace; the bridge owns transport.

10. Telegram Bot API Integration

callback_data contract

Section callbacks use the section: prefix owned by pi-telegram:

section:0:open                   → open section root
section:0:settings:open          → open settings root
section:0:<action>:<payload>     → forwarded to handleCallback

section: is listed in TELEGRAM_OWNED_CALLBACK_PREFIXES alongside compact:, menu:, model:, settings:, status:, tgbtn:, thinking:, queue:. Layered extensions must not use this prefix.

Inline keyboard layout

Section views return TelegramSectionView.replyMarkup — a standard TelegramInlineKeyboardMarkup. The bridge prepends the Back row and sends the result through editMessageText / sendMessage with parse_mode: "HTML".

Button labels are not truncated by the bridge. Section authors should keep labels compact for mobile Telegram (under ~30 display-width cells). Use truncateTelegramButtonLabel for long dynamic text.

Stale message handling

If the interactive message has expired (no stored model menu state), the callback receives:

"Interactive message expired."

This applies to section callbacks as well — the state check runs before dispatch.

11. Pi Extension API Inspiration

The platform inherits from Pi's own extension model:

  • export default function(pi)registerTelegramSection(section)
  • pi.on("shutdown", ...) → disposer from registerTelegramSection
  • Typed imports → typed import from @llblab/pi-telegram/sections
  • globalThis registry → __piTelegramSectionRegistry__ on globalThis
  • Identity from package.json/name → same identity rules as Locks Standard
  • Narrow typed context ports → TelegramSectionContext / TelegramSectionCallbackContext
  • Extension does not own transport → pi-telegram owns polling, message lifecycle

12. Diagnostics

getTelegramSectionDiagnostics() returns:

interface TelegramSectionDiagnostic {
  id: string;
  token: string;
  label: string;
  status: "active" | "error";
  lastError?: string;
}

Available programmatically via getTelegramSectionDiagnostics(). Main-menu/settings dynamic label failures, section render failures, and callback failures set status: "error" with lastError; the entry returns to active only after the matching label render, section render, or callback succeeds for that token. Section runtime state is not shown in Telegram status text; sections should surface user-facing state through dynamic button labels and their own submenus.

13. Purpose and Non-Goals

Use sections for:

  • File/project explorers
  • Prompt/session history viewers
  • Tool approval dashboards
  • Runtime status panels
  • Extension settings or diagnostics
  • Human-in-the-loop forms that should not become agent turns

Do not use sections for:

  • Plain agent prompts (use normal queue)
  • One-shot assistant-authored buttons (use telegram_button outbound comments)
  • Command-template pipelines (use inbound/outbound handlers)

Non-goals:

  • No second Telegram polling loop
  • No new pi extension loader
  • No generic webview system
  • No default filesystem mutation API
  • No prompt rollback semantics
  • No separate owner field while identity key is sufficient

14. Relationship to Other Standards

  • Callback Namespaces: defines section: as pi-telegram-owned prefix. Sections use namespaced callbacks but authors never hand-roll them
  • Updates: raw update interception for direct Telegram update access. Sections are the structured UI layer above
  • Extension Locks (polling): same identity key rules (package.json/name → canonical id)
  • Command Templates: sections do not execute command templates by default. UI registration + callback routing, not shell execution

15. Demo Extension

@llblab/pi-telegram-extension-demo (extensions/pi-telegram-extension-demo/) is the reference implementation:

  • Main menu: 🧪 Demo submenu — enqueue prompt, answer callback, show info, interactive counter
  • Settings: 🧪 Demo settings — ON/OFF toggle with dynamic getLabel() status indicator, enqueue from settings
  • Navigation: full Back/Main menu hierarchy across all three levels

Use it as a template for new section-based extensions.