chore: fork @llblab/[email protected].0 as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
This commit is contained in:
commit
d1d04001d0
155 files changed
+107848
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Documentation Index
|
||||
|
||||
Living index of project documentation in `/docs`.
|
||||
|
||||
`public-api.md` is the canonical entrypoint for stable extension surfaces. Focused documents exist only when a surface has enough runtime rules to need a dedicated standard.
|
||||
|
||||
## Documents
|
||||
|
||||
- [architecture.md](./architecture.md) — Overview of the Telegram bridge runtime, optional profile isolation, queueing model, native Rich Markdown delivery, UI/compat rendering, and interactive controls
|
||||
- [public-api.md](./public-api.md) — Stable public API map: package entrypoints, commands, config, assistant markup, extension APIs, smoke examples, and compatibility boundaries
|
||||
- [delivery.md](./delivery.md) — Target-aware companion delivery contract for operational views, logical message handles, target scopes, lifecycle fencing, and leader/follower transport
|
||||
- [activity.md](./activity.md) — Normalized Pi lifecycle API for extension-owned reasoning, intermediate prose, tool activity, source identity, delivery contexts, and consumer policy examples
|
||||
- [../.agents/skills/telegram-bot/SKILL.md](../.agents/skills/telegram-bot/SKILL.md) — Agent-facing Telegram Bot API lookup skill backed by a local full Bot API reference
|
||||
- [../.agents/skills/domain-dag/SKILL.md](../.agents/skills/domain-dag/SKILL.md) — Project-local Domain DAG architecture skill and validator guidance
|
||||
- [command-templates.md](./command-templates.md) — Portable command-template standard core
|
||||
- [inbound.md](./inbound.md) — Local `pi-telegram` inbound text/media handler bus, programmatic inbound handlers, registered STT provider fallbacks, legacy `attachmentHandlers` compatibility, placeholders, and fallbacks
|
||||
- [outbound.md](./outbound.md) — Local `pi-telegram` outbound-handler config, text/voice/button behavior, voice synthesis provider fallback priority, artifact outputs, and callback routing
|
||||
- [callback-namespaces.md](./callback-namespaces.md) — Shared Telegram `callback_data` namespace standard for layered extensions
|
||||
- [updates.md](./updates.md) — Update classification and runtime handler registry that lets layered extensions observe and consume Telegram updates without owning their own polling connection
|
||||
- [multi-instance-bus.md](./multi-instance-bus.md) — Optional multi-instance Telegram bus architecture: profile-scoped transport, leader/follower routing, thread targets, instance slots, manual follower registration, and recovery semantics
|
||||
- [sections.md](./sections.md) — Telegram Extension Sections Standard: registration contract, context ports, callback routing, navigation hierarchy, and demo reference for pi extensions that want Telegram UI surfaces
|
||||
- [voice.md](./voice.md) — Voice integration guide: detection, reply policy, STT/TTS provider registration, provider-owned conversion, and transparent interception
|
||||
- [locks.md](./locks.md) — Shared `locks.json` standard for singleton extension ownership
|
||||
- [ui-style.md](./ui-style.md) — Inline UI style guide for buttons, toggles, tabs, option lists, cards, and dialogs
|
||||
@@ -0,0 +1,294 @@
|
||||
# Telegram Activity API
|
||||
|
||||
## Purpose
|
||||
|
||||
The Telegram Activity API lets trusted extension consumers observe normalized Pi work lifecycle without importing pi-telegram internals, correlating raw Pi events with bridge state, or capturing session contexts.
|
||||
|
||||
It is a higher-level event surface over the [Telegram Delivery API](./delivery.md). Activity owns lifecycle normalization and event routing; delivery owns target authorization, rendering transport, ordering, chunk reconciliation, and stale-generation behavior.
|
||||
|
||||
The public membrane is:
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerTelegramActivityHandler,
|
||||
} from "@evandrodevbr/oh-my-pi-telegram/activity";
|
||||
```
|
||||
|
||||
An issue #126 consumer can own optional Settings policy for reasoning, intermediate assistant prose, and tool rows. Those visibility choices do not become mandatory bridge-core settings.
|
||||
|
||||
## Ownership Boundary
|
||||
|
||||
Consumer extension code owns:
|
||||
|
||||
- Which activity event classes are visible.
|
||||
- Settings state and presentation policy.
|
||||
- Rendering event payloads into operational views.
|
||||
- Coalescing or replacing its own visible activity documents.
|
||||
- Redacting additional domain-specific data before display.
|
||||
|
||||
pi-telegram owns:
|
||||
|
||||
- Mapping Pi lifecycle events into one normalized activity stream.
|
||||
- Correlating events with the current Telegram turn or Pi instance.
|
||||
- Stable activity/run identity within one runtime generation.
|
||||
- Default delivery scope selection.
|
||||
- Handler registration, ordering, isolation, disposal, and diagnostics.
|
||||
- Avoiding duplicate provider tool-call versus executed-tool events.
|
||||
- Session shutdown fencing and fresh delivery contexts.
|
||||
|
||||
Pi owns the source lifecycle and provider event protocol. The Activity API requires Pi `0.80.6` or newer because `agent_settled` is the terminal boundary that distinguishes a fully settled activity from automatic retry, compaction, and queued continuation. The API does not invent reasoning when a provider does not expose it.
|
||||
|
||||
## Registration
|
||||
|
||||
```ts
|
||||
export interface TelegramActivityHandlerRegistration {
|
||||
id: string;
|
||||
order?: number;
|
||||
handle: (
|
||||
event: TelegramActivityEvent,
|
||||
ctx: TelegramActivityContext,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function registerTelegramActivityHandler(
|
||||
registration: TelegramActivityHandlerRegistration,
|
||||
): () => void;
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `id` is a stable consumer identity, normally derived from package identity plus a local activity suffix.
|
||||
- Duplicate active ids are rejected rather than silently stacking duplicate Telegram output.
|
||||
- Handlers run by `order`, then `id`.
|
||||
- Registration returns a stale-safe disposer that removes only its own registration.
|
||||
- Consumers register on `session_start` and dispose on `session_shutdown`.
|
||||
- The bridge creates a fresh dispatcher generation on every `session_start`; shutdown stops and clears only the retiring generation, so same-process session replacement resumes delivery instead of leaving Activity permanently stopped.
|
||||
- Handler failures are isolated and recorded in `/telegram-status`; they never break Pi lifecycle or other handlers.
|
||||
|
||||
## Activity Identity And Source
|
||||
|
||||
```ts
|
||||
export type TelegramActivitySource =
|
||||
| "telegram"
|
||||
| "local"
|
||||
| "autonomous"
|
||||
| "unknown";
|
||||
|
||||
export type TelegramActivityTarget = Readonly<TelegramDeliveryTarget>;
|
||||
|
||||
export interface TelegramActivityEnvelope {
|
||||
activityId: string;
|
||||
sequence: number;
|
||||
source: TelegramActivitySource;
|
||||
target?: TelegramActivityTarget;
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
`activityId` identifies one logical settled-work sequence inside the current delivery generation. `sequence` increases monotonically within that activity and lets consumers reject stale asynchronous rendering. Telegram-owned activities carry a frozen `target` captured when the activity starts; it contains only safe chat/thread identity, never a Telegram client or Pi context.
|
||||
|
||||
Source classification follows evidence, not guesses:
|
||||
|
||||
- `telegram`: a dispatched active Telegram turn exists.
|
||||
- `local`: the initiating Pi input event reports `interactive` or `rpc` and no Telegram turn owns the run.
|
||||
- `autonomous`: the initiating input reports `extension` and no Telegram turn owns the run.
|
||||
- `unknown`: retries, restored continuations, or runtimes without enough input evidence cannot be classified safely.
|
||||
|
||||
Automatic retries, overflow compaction retries, and tool continuations inherit the current activity identity/source until `agent_settled`. A new unrelated `agent_start` after settlement allocates a new activity id.
|
||||
|
||||
A standalone compaction owns a temporary activity only until `session_compact`. Pi does not expose a sibling-extension cancellation/failure callback after `session_before_compact`, so pi-telegram abandons that temporary identity at the first provable fallback boundary: the next `agent_start`, a replacement compaction start, session shutdown, or the existing five-minute compaction-observer timeout. A late `session_compact` after abandonment is ignored and cannot attach to the next run. Compaction inside an existing agent activity never clears that agent's identity.
|
||||
|
||||
## Event Contract
|
||||
|
||||
```ts
|
||||
export type TelegramActivityEvent = TelegramActivityEnvelope & (
|
||||
| { type: "agent-start" }
|
||||
| {
|
||||
type: "assistant-text-delta";
|
||||
contentIndex: number;
|
||||
delta: string;
|
||||
}
|
||||
| {
|
||||
type: "assistant-segment";
|
||||
contentIndex: number;
|
||||
text: string;
|
||||
placement: "intermediate" | "final" | "terminal-partial";
|
||||
}
|
||||
| {
|
||||
type: "reasoning-delta";
|
||||
contentIndex: number;
|
||||
delta: string;
|
||||
}
|
||||
| {
|
||||
type: "reasoning-end";
|
||||
contentIndex: number;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "tool-start";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}
|
||||
| {
|
||||
type: "tool-update";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
update: unknown;
|
||||
}
|
||||
| {
|
||||
type: "tool-end";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
isError: boolean;
|
||||
}
|
||||
| {
|
||||
type: "compaction-start";
|
||||
reason: "manual" | "threshold" | "overflow" | "unknown";
|
||||
}
|
||||
| {
|
||||
type: "compaction-end";
|
||||
reason: "manual" | "threshold" | "overflow" | "unknown";
|
||||
}
|
||||
| { type: "agent-end" }
|
||||
| { type: "agent-settled" }
|
||||
);
|
||||
```
|
||||
|
||||
### Assistant segment classification
|
||||
|
||||
Provider stream events expose `text_start`, `text_delta`, and `text_end`, but `text_end` alone does not prove whether prose is intermediate or final. The normalizer therefore holds a completed text segment briefly until the next provider boundary proves placement:
|
||||
|
||||
- A following `toolcall_start` classifies the pending text as `intermediate`.
|
||||
- A following successful `done` classifies it as `final`.
|
||||
- A following `error` classifies it as `terminal-partial`.
|
||||
- A new text block flushes any older still-pending segment as `intermediate` only when the provider event order proves another block follows.
|
||||
|
||||
`assistant-text-delta` remains available for extensions that want progressive rendering. Consumers that only want complete intermediate prose should ignore deltas and render `assistant-segment` where `placement === "intermediate"`.
|
||||
|
||||
### Reasoning
|
||||
|
||||
Pi provider events use `thinking_*`; the public product term is `reasoning`. The normalizer maps `thinking_delta` to `reasoning-delta` and `thinking_end` to `reasoning-end`.
|
||||
|
||||
- Signed/redacted provider metadata is not exposed as display text.
|
||||
- Empty reasoning blocks are skipped.
|
||||
- Providers that hide reasoning produce no reasoning events.
|
||||
- Consumers must treat reasoning as potentially sensitive and disabled by default.
|
||||
|
||||
### Tools
|
||||
|
||||
Tool activity uses Pi's executed-tool lifecycle (`tool_execution_start/update/end`), not provider `toolcall_*` payloads. Provider tool-call boundaries are used only to classify preceding assistant prose. This prevents duplicate tool rows and reports actual execution results.
|
||||
|
||||
`args`, `update`, and `result` may contain paths, source text, command output, or other sensitive data. They are available to trusted local extension code but must not be rendered wholesale by default. Reference UI should summarize tool name/state and expose bounded details only through explicit policy.
|
||||
|
||||
## Delivery Context
|
||||
|
||||
```ts
|
||||
export interface TelegramActivityContext {
|
||||
activityId: string;
|
||||
sequence: number;
|
||||
source: TelegramActivitySource;
|
||||
defaultScope: TelegramDeliveryScope;
|
||||
send(
|
||||
view: TelegramDeliveryView,
|
||||
options?: {
|
||||
scope?: TelegramDeliveryScope;
|
||||
replyToMessageId?: number;
|
||||
},
|
||||
): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
|
||||
edit(
|
||||
handle: TelegramDeliveryHandle,
|
||||
view: TelegramDeliveryView,
|
||||
): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
|
||||
delete(
|
||||
handle: TelegramDeliveryHandle,
|
||||
): Promise<TelegramDeliveryResult<void>>;
|
||||
chatAction(
|
||||
action: TelegramDeliveryChatAction,
|
||||
options?: { scope?: TelegramDeliveryScope },
|
||||
): Promise<TelegramDeliveryResult<void>>;
|
||||
}
|
||||
```
|
||||
|
||||
Default scope:
|
||||
|
||||
- `telegram` uses `{ kind: "target", target: event.target }`, binding delayed handlers to the immutable originating thread rather than whichever turn happens to be active later.
|
||||
- `local`, `autonomous`, and `unknown` use `{ kind: "instance" }`.
|
||||
|
||||
Handlers may explicitly choose aggregate or another authorized scope. Context methods delegate to the public delivery runtime on every call; they do not retain Telegram clients, Pi contexts, or transport ownership objects. The Delivery API rechecks authorization when an operation runs, so a captured target that is no longer owned fails closed instead of rerouting.
|
||||
|
||||
A handler may still receive events while no target is currently deliverable. Delivery then returns the normal structured `target-unavailable`, `target-unauthorized`, or `runtime-unavailable` result.
|
||||
|
||||
## Dispatch And Backpressure
|
||||
|
||||
Pi lifecycle must not wait for extension rendering or Telegram transport.
|
||||
|
||||
- Lifecycle hooks normalize and enqueue activity events synchronously, then return.
|
||||
- Each handler owns a serialized asynchronous queue so its event order is stable without blocking other handlers.
|
||||
- Handler queues and contexts are generation-bound. `session_shutdown` stops queued events immediately, and an already-running handler receives `runtime-unavailable` instead of delivering through a replacement session after it resumes.
|
||||
- Disposing a handler also fences contexts captured from that registration; they cannot outlive their ownership and adopt another registration with the same id.
|
||||
- High-frequency `assistant-text-delta`, `reasoning-delta`, and `tool-update` events may coalesce only with the immediately adjacent event of the same type, activity id, content/tool id, and handler queue.
|
||||
- Boundary events (`assistant-segment`, `reasoning-end`, tool start/end, compaction, agent end/settled) are never dropped or reordered.
|
||||
- The 0.21 dispatcher does not impose an event-count drop policy or emit queue-length diagnostics. Adjacent delta/update coalescing reduces common streaming pressure, but boundary events accumulate behind a slow handler; consumers must keep handler work bounded and delegate long-lived work outside the callback when appropriate.
|
||||
|
||||
The Delivery API independently serializes concrete Telegram operations per target. Activity serialization preserves semantic event order; delivery serialization preserves transport order.
|
||||
|
||||
## Lifecycle Mapping
|
||||
|
||||
The bridge maps Pi hooks as follows:
|
||||
|
||||
- `input`: capture source evidence for the next logical activity.
|
||||
- `agent_start`: allocate or reuse activity identity and emit `agent-start` after Telegram queue consumption establishes active-turn ownership.
|
||||
- `message_update.assistantMessageEvent`: normalize text/reasoning/provider boundaries.
|
||||
- `tool_execution_start/update/end`: emit executed tool events.
|
||||
- `session_before_compact` / `session_compact`: emit compaction boundaries and preserve activity identity across retry compaction; abandon an unterminated standalone compaction at the next lifecycle boundary or observer timeout rather than merging it into another run. A missing or unrecognized reason maps to `unknown` rather than guessing.
|
||||
- `agent_end`: emit low-level run completion but keep identity alive for retry/follow-up work.
|
||||
- `agent_settled`: emit terminal settlement, flush pending terminal segments, and release activity identity.
|
||||
- `session_shutdown`: stop dispatch, clear pending normalization state, and invalidate delivery generation through the existing delivery lifecycle.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Duplicate registration fails synchronously. When a handler throws or rejects, the dispatcher forwards only the handler id, event type, activity id, and error to pi-telegram's existing bounded/redacted runtime event recorder. The bridge does not copy reasoning, assistant prose, tool arguments/results, Telegram payloads, or queue contents into diagnostic metadata. It does not currently record queue phase, queue length, coalescing counts, or handler latency.
|
||||
|
||||
The first implementation has no public Activity diagnostics getter because handler failures already flow through bridge runtime diagnostics and `/telegram-status`. Add queue telemetry or a dedicated getter only when an observed consumer needs inspectable backpressure state.
|
||||
|
||||
## Security And Non-Goals
|
||||
|
||||
The Activity API does not:
|
||||
|
||||
- Enable reasoning visibility by default.
|
||||
- Guarantee reasoning availability across providers.
|
||||
- Expose signed/redacted reasoning metadata.
|
||||
- Render raw tool arguments or results automatically.
|
||||
- Replace assistant final replies or Rich Draft previews.
|
||||
- Mutate queues, models, thinking levels, sessions, or process state.
|
||||
- Expose Telegram clients, bot tokens, Pi contexts, or private runtime objects.
|
||||
- Block Pi lifecycle on extension handlers or Telegram delivery.
|
||||
|
||||
## Validation Contract
|
||||
|
||||
The implementation must cover:
|
||||
|
||||
- Stable id registration, ordering, duplicate rejection, and stale-safe disposal.
|
||||
- Telegram, local, autonomous, and unknown source evidence.
|
||||
- Identity reuse across retry/compaction and reset at settlement.
|
||||
- Text delta flow and intermediate/final/terminal segment classification.
|
||||
- Reasoning mapping and empty/hidden reasoning behavior.
|
||||
- Executed tool start/update/end without provider tool-call duplication.
|
||||
- Default active-turn versus instance delivery scopes.
|
||||
- Adjacent delta/update coalescing and boundary preservation.
|
||||
- Handler error isolation, non-blocking lifecycle, shutdown fencing, and redacted diagnostics.
|
||||
- Public package import without `/lib` reach-through.
|
||||
|
||||
## Consumer Policy Pattern
|
||||
|
||||
The registration and delivery examples above provide the complete public building blocks for issue #126-style visibility policy:
|
||||
|
||||
- Reasoning can default off and send completed `reasoning-end` blocks only when enabled.
|
||||
- Intermediate prose can default off and send only `assistant-segment` events with `placement: "intermediate"`, never the final assistant segment.
|
||||
- Tool rows can default on, show only tool name/state, and edit generation-bound logical handles from running to done/failed without exposing arguments or results.
|
||||
- Interactive toggles belong in a registered Section and Settings row; activity messages can remain non-interactive.
|
||||
- `session_shutdown` should dispose stable registrations and drop retained delivery handles, so reload/session replacement cannot reuse old contexts or handles.
|
||||
|
||||
The separate [`pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) project remains the maintained companion-extension and managed-UI reference. This document owns the Activity-specific usage pattern; pi-telegram does not ship a redundant `examples/` package directory.
|
||||
@@ -0,0 +1,331 @@
|
||||
# Telegram Bridge Architecture
|
||||
|
||||
## Purpose
|
||||
|
||||
`pi-telegram` is a session-aware Pi runtime extension that binds Telegram destinations to running Pi instances and routes each accepted prompt into the assigned instance's currently active session. It owns the Telegram bridge boundary:
|
||||
|
||||
- Poll Telegram updates and enforce single-user pairing.
|
||||
- Translate Telegram text, callbacks, media, and files into Pi turns.
|
||||
- Stream previews and deliver final Pi responses back to Telegram.
|
||||
- Provide Telegram-native controls for queueing, model/thinking/settings menus, compaction, abort/stop, prompt templates, reactions, and outbound artifacts.
|
||||
|
||||
The bridge is a mobile companion for a live Pi runtime, not a remote terminal or session browser. It should let an operator start work in the TUI and continue supervising the instance's active session from Telegram, while staying inside Pi's extension-facing contracts.
|
||||
|
||||
This document is the architectural map. Focused behavior standards live in sibling docs:
|
||||
|
||||
- [Public API](./public-api.md) — stable commands, config, package entrypoints, assistant markup, extension APIs, and compatibility boundaries.
|
||||
- [Telegram Delivery API](./delivery.md) — target-aware operational views, logical message handles, lifecycle fencing, and leader/follower transport.
|
||||
- [Telegram Activity API](./activity.md) — normalized Pi lifecycle events, activity/source identity, non-blocking extension dispatch, and delivery contexts.
|
||||
- [UI Style](./ui-style.md) — inline UI labels, navigation, state markers, cards, and dialogs.
|
||||
- [Callback Namespaces](./callback-namespaces.md) — callback prefix ownership and fallback rules.
|
||||
- [Sections](./sections.md) — structured Telegram menu sections.
|
||||
- [Updates](./updates.md) — update classification, default-routing plans, and raw Telegram update interception.
|
||||
- [Voice Integration](./voice.md) — voice reply policy and STT/TTS provider surface.
|
||||
- [Command Templates](./command-templates.md) — shell-free command-template contract.
|
||||
- [Telegram Multi-Instance Bus](./multi-instance-bus.md) — Threaded Mode bus leadership, Telegram UI thread targets, instance identity, and leader/follower routing.
|
||||
|
||||
## Runtime Topology
|
||||
|
||||
`index.ts` is the only composition root. It wires live Pi ports, Telegram Bot API ports, session-local stores, lifecycle hooks, and domain runtimes. It should operate at high-level domain-runtime boundaries: non-trivial Threaded Mode capability decisions, leader/follower recovery, sync-slice bookkeeping, manual thread cleanup, and bus routing policies belong in their owning `/lib` domains. Reusable logic lives in flat `/lib/*.ts` domain modules rather than a deep local module tree.
|
||||
|
||||
### Extension Boundary Vs Supervisor Control
|
||||
|
||||
`pi-telegram` runs inside the current Pi process as an extension. That gives it safe access to public extension APIs such as aborting work, compacting, dispatching queued prompts, observing lifecycle events, and rendering Telegram-native controls. It does not own the terminal, the interactive-mode chat transcript, or the process lifecycle.
|
||||
|
||||
Keep this boundary explicit:
|
||||
|
||||
- Do not use raw TTY injection, ANSI terminal clearing, private TUI container mutation, or a shadow `pi` subprocess to simulate interactive commands.
|
||||
- Do not treat Telegram as a generic remote shell for every Pi slash command.
|
||||
- Commands that require interactive session replacement or TUI rerendering, such as a true Telegram `/new`, need a public Pi API that invokes the same runtime path as the terminal command.
|
||||
- A separate PTY supervisor or daemon could choose to own those risks, but that would be a different product mode rather than this extension's runtime contract.
|
||||
|
||||
### Instance, Session, And Context Cost
|
||||
|
||||
A Telegram destination follows a Pi instance, not an immutable Pi session file. Ordinary Telegram prompts enter whichever session is active in that assigned instance when dispatch occurs. If the operator replaces or resumes a session locally, pi-telegram rebinds its session-scoped runtime state while preserving the instance's Telegram target where supported. Telegram currently exposes compaction for the active session, but not new-session, resume, fork, tree navigation, session switching, or full reload; those operations require safe public Pi extension APIs.
|
||||
|
||||
`/telegram-connect` never launches a hidden or headless Pi process. A long-lived background Pi process can own Telegram only when something else explicitly launched that process and it satisfies the normal lock/runtime rules. Pi `print` and `json` modes stay passive and exit rather than becoming hidden polling owners.
|
||||
|
||||
Pi session JSONL and pi-telegram runtime JSONL serve different purposes. Pi session files contain model conversation, tool, usage, branch, and compaction entries. Profile-scoped `logs.jsonl` / `logs.<profile>.jsonl` contain redacted bridge operations from one or more instances and never become model context. Sharing a Telegram profile or working directory does not by itself merge Pi session identities or model histories.
|
||||
|
||||
A Telegram prompt is a normal Pi model turn. It inherits the active post-compaction context just like a TUI prompt in the same session; pi-telegram does not promise context isolation or token cost proportional only to the new message. Current prompt guidance uses a small transient system note and the on-demand `telegram_help` tool instead of persisting the former large guidance suffix in every user turn. Existing session files created by older versions may still contain those historical repeated suffixes until session replacement or compaction removes them from active context.
|
||||
|
||||
The repository uses a **Flat Domain DAG**:
|
||||
|
||||
- Local imports must form a directed acyclic graph.
|
||||
- Cohesive domain files are preferred over atomizing every helper.
|
||||
- Shared buckets such as `lib/constants.ts` or `lib/types.ts` are avoided.
|
||||
- Constants and state types live with their owning domain.
|
||||
- Narrow structural projections are allowed when they avoid importing broader runtime or wire DTOs.
|
||||
- Source file headers include `Zones:` tags so cross-cutting responsibility stays visible without folder nesting.
|
||||
|
||||
### Domain Ownership Map
|
||||
|
||||
- `index.ts`: composition root for live ports, session state, transport adapters, and lifecycle registration.
|
||||
- `api`: Bot API helpers, retries, uploads/downloads, temp cleanup, byte limits, chat actions, lazy token clients, and API error recording.
|
||||
- `config` / `setup`: `telegram.json`, bot token setup, named bot/session profiles, first-user pairing, authorization, env fallback, atomic persistence, effective config views, and live config accessors.
|
||||
- `locks` / `polling`: serialized singleton lock storage, exact-owner epoch exposure, process-global reload generations, lock-aware polling lifecycle/takeover/follower registration, and the cohesive classic-vs-Threaded capability state/monitor/observation/polling orchestration. Polling also owns long-poll controller state, offset admission/persistence, and poll-loop wiring.
|
||||
- `bus` / `bus-api` / `bus-leader` / `bus-follower` / `ownership` / `target`: Threaded Mode multi-instance bus contracts, profile-scoped process/endpoint identity, local leader/follower IPC, leader-only orchestration, follower-side manual registration/session runtime, follower-routed Bot API calls, live message ownership, and `{ chatId, threadId? }` target identity. `bus` owns shared protocol, process identity, profile-aware local endpoints, and IPC primitives; `bus-leader` owns leader runtime, leader envelope handling, activation scheduling, and leader polling/server/prune orchestration; `bus-follower` owns process-stable manual-follower keys plus this Pi instance's follower-side registration, heartbeat, one-sequence authenticated client assembly, forwarded-update adaptation/receiving, recovery retry defaults, and routed API caller without any process spawning.
|
||||
- `sync`: demand-driven Telegram reconciliation, mutable sync-slice state, nested provisioning activity, and local assumption policy. It does not own a complete Telegram bot read-model; Bot API lacks a complete topic/thread listing surface. It owns sync slices, invalidation triggers, observation intake, status/debug freshness, and reconciliation scheduling across bot identity, pairing assumptions, live target bindings, reservations, and transport health after meaningful observable signals. It should call narrower domain primitives rather than letting `index.ts`, `threads`, or `status` accumulate cross-cutting reconciliation policy.
|
||||
- `thread-reconciler`: Threaded Mode control-plane planning for Telegram thread/tab lifecycle. It owns the reconciliation state machine (`stable`, `provisioning`, `sync-required`, `cleanup-required`), pure plans, proof-before-delete rules, pending-provision protection, fresh-creation grace windows, leader-epoch checks, and the single policy authority for destructive thread cleanup actions. It excludes live Telegram API calls, inbound routing, menu rendering, and direct persistence.
|
||||
- `threads`: Telegram UI thread/tab binding state mapped to Bot API `message_thread_id` / `ForumTopic` transport. Owns leader and current-instance identity state, status projections, slot allocation from the current extension state, baked compact thread-name selection, current binding persistence, and primitive thread provision helpers. It should not persist stale/offline/failed target history, own destructive cleanup policy, grow into the general Telegram synchronization domain, or expose a rename tool.
|
||||
- `updates` / `routing`: update classification, authorization planning, callbacks, edited messages, reactions, target-owner forwarding, inbound bus ownership/live-target/local-label projection, and inbound route composition.
|
||||
- `media` / `text-groups` / `time-injection` / `turns` / `inbound`: inbound text/media/file extraction, rich-message reply-context plaintext recovery, media-group debounce, long-text coalescing, optional `[time]` context, handler execution, and prompt-turn assembly/editing.
|
||||
- `queue`: queue item contracts, profile/token transport-generation stamping, lane admission/order, readiness gates, mutations, dispatch runtime, prompt/control enqueueing, and session/agent/tool lifecycle sequencing.
|
||||
- `runtime`: session-local coordination primitives: counters, flags, setup guard, abort handler, typing timers, dispatch flags, and reset binding.
|
||||
- `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu-queue` / `menu-settings` / `menu` / `commands`: model identity, thinking levels, scoped model handling, menu render/callback behavior, slash commands, bot commands, and interactive controls.
|
||||
- `sections`: Telegram menu-section registry, opaque section callback tokens, render/callback dispatch, safe section ports, and diagnostics.
|
||||
- `keyboard`: shared inline-keyboard reply-markup shape only; feature domains own labels, callback data, and behavior.
|
||||
- `preview` / `replies` / `rendering`: throttled native Rich Markdown draft delivery, native final reply delivery, reply parameters, transport-limit chunking, and remaining Telegram HTML rendering for bridge-owned UI/compatibility surfaces.
|
||||
- `delivery`: public extension operational-view delivery, active-turn/instance/aggregate/authorized target policy, logical chunk handles, per-target ordering, runtime generation fencing, and the process-local runtime membrane. Its bridge adapter composes the established UI/compat reply renderer with narrow bus-aware Telegram API and ownership ports; it never exposes bot clients or Pi contexts.
|
||||
- `activity`: public normalized Pi lifecycle registration, activity/source identity, assistant segment and reasoning normalization, executed-tool events, non-blocking per-handler queues, delivery contexts, compatibility adapters, and shutdown fencing. It does not own visibility policy or Telegram rendering.
|
||||
- `outbound-markup`: top-level assistant action comment parsing, attribute parsing, voice reply planning, and preview/delivery stripping.
|
||||
- `outbound`: outbound text transformations, voice/button artifact delivery, and generated callback actions.
|
||||
- `outbound-attachments`: `telegram_attach`, queued outbound files, stat/limit checks, and photo/document delivery classification.
|
||||
- `status` / `logs`: status bar/status-message rendering, queue-lane summaries, the structural redacted event ring, profile-aware JSONL scope/reset/append behavior, exact-owner destructive commits, status snapshot scheduling, and grouped diagnostics. `status` remains a structural leaf; `logs` composes filesystem evidence with status projections.
|
||||
- `lifecycle` / `prompts` / `prompt-templates` / `pi`: session-generation fencing and start/shutdown assembly across Queue, grouped input, Delivery, polling, capability monitor, watchdog, and follower refresh; Pi hook registration; Telegram prompt guidance; prompt-template discovery/expansion; and centralized direct Pi SDK imports.
|
||||
- `command-templates`: shell-free command-template helpers, composition expansion, placeholder substitution, executable resolution, warnings, and retry/timeout semantics.
|
||||
|
||||
### Guarded Invariants
|
||||
|
||||
Architecture invariant tests protect:
|
||||
|
||||
- Acyclic local imports.
|
||||
- Direct Pi SDK imports centralized in the `pi` adapter.
|
||||
- `index.ts` as a composition root without local runtime adapter logic.
|
||||
- Runtime state isolation from local domain imports.
|
||||
- Structural leaf-domain isolation.
|
||||
- Menu/model boundary direction.
|
||||
- API/config separation.
|
||||
- Media/update/API decoupling.
|
||||
- Outbound attachment isolation from queue, inbound media, and API helpers.
|
||||
|
||||
Mirrored domain regressions live in `/tests/*.test.ts`. Shared test fixtures should exist only when multiple suites genuinely reuse them.
|
||||
|
||||
## Configuration And Ownership
|
||||
|
||||
Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership lives separately in `~/.pi/agent/locks.json` under `@evandrodevbr/oh-my-pi-telegram`.
|
||||
|
||||
### Setup Flow
|
||||
|
||||
`/telegram-setup` progressively resolves the bot token:
|
||||
|
||||
1. Use the locally saved token when present.
|
||||
2. Otherwise use the first supported Telegram token environment variable.
|
||||
3. Otherwise show the example placeholder.
|
||||
|
||||
`ctx.ui.input()` only supports placeholder text, so setup uses `ctx.ui.editor()` when a real default must appear already filled in. Persisted config is written through a private temp file plus atomic rename and left with `0600` permissions.
|
||||
|
||||
### Runtime Ownership
|
||||
|
||||
- `/telegram-connect` acquires or moves singleton polling ownership before polling starts.
|
||||
- `/telegram-disconnect` stops polling and releases ownership. In Threaded Mode it first tears down the disconnecting instance's bound Telegram thread: leaders delete their own thread directly, and followers ask the leader to delete their assigned thread through scoped bus API before unregistering.
|
||||
- Session start schedules Telegram polling resume asynchronously only when the existing lock already points at the current `pid`/`cwd`, or when a stale same-`cwd` lock can be safely replaced after process restart. Startup and `/resume` should not wait on Telegram leader election, Bot API probes, poller handoff, or thread reconciliation before restoring the Pi session.
|
||||
- Pi `print`/`json` run modes stay passive: they do not start or resume Telegram polling even if a lock is present. Older Pi runtimes without `ctx.mode` keep the previous compatibility behavior.
|
||||
- Inherited child sessions that see the same `telegram.json` but do not own the `pid`/`cwd` lock must not auto-start polling or call `getUpdates` unless the operator force-takes ownership.
|
||||
- Session replacement suspends polling/watchers without releasing ownership so the next session-start hook in the same process can resume. A registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and automatically re-registers the new session context through the live leader without marking or replacing its Telegram thread.
|
||||
- Live polling owners require explicit takeover confirmation.
|
||||
- Long-lived polling timers use snapshotted ownership context and stop local polling when the lock no longer points at their own process.
|
||||
- `locks.json` owns only external Telegram control/polling. Local extension and queue state are per Pi instance: losing the lock stops live Telegram control here, but does not drain or silence this instance's accepted queue, previews, final delivery, or dispatch.
|
||||
- Proactive local/headless final-result push is not accepted-turn delivery. It is allowed only when proactive push is enabled and this instance currently owns the Telegram lock.
|
||||
|
||||
Deleting `locks.json` resets runtime ownership without deleting Telegram configuration.
|
||||
|
||||
### Threaded Mode Multi-Instance Bus
|
||||
|
||||
Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode.
|
||||
|
||||
Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the default profile. A different selected profile is a parallel bot runtime: its locks, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
|
||||
|
||||
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; profile bot/session fields and observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
|
||||
|
||||
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping: it preserves the follower thread binding and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Successful follower target reuse refreshes the binding's recovery timestamp; post-leader-reload compaction preserves that recently refreshed binding across a brief follower session-replacement registry gap, while genuinely historical records still leave current state after grace. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers that target without creating a replacement Telegram thread; a carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leLine truncated
|
||||
|
||||
Follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then does that process register as a follower with an instance-scoped internal binding identity and cause the leader to create/reuse a thread for it. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection.
|
||||
|
||||
### Unbound Thread Detection
|
||||
|
||||
When Threaded Mode is enabled, writing a message in the `All` tab can create a new thread without an existing instance binding. The bridge detects this during update execution: if a message from the owner has a `message_thread_id` that no instance owns, the message is routed to the unbound-thread handler instead of the leader's normal message handler. In the default runtime, this handler first reclaims the thread for the leader when the leader has no active bound thread, assigns the current leader thread identity, persists the active binding, and serves the prompt locally. If the leader already has an active thread, the handler preserves the prompt in the source Telegram thread and shows a target-thread chooser; explicit successful routing may later close/delete only extra confirmed source threads through `thread-reconciler` proof-before-delete planning and stale-epoch fencing. Unknown `forum_topic_created` service events are recorded as observations and are not destructive cleanup proof, because Telegram can deliver creation events before local provisioning/binding writes become visible across reloads. If Threaded Mode is unavailable, the message is processed normally through classic routing.
|
||||
|
||||
Threadless messages from `All` are not routed as prompts once bound threads exist, because `All` cannot identify the owning Pi instance. Known commands from `All` open a compact live-target chooser, while ordinary threadless prompts get guidance to use a bound Pi thread tab. This preserves a safe default after the operator closes every thread while still preventing later accidental empty tabs from black-holing prompts or spawning hidden Pi processes. The operator-facing path for another instance is visible manual follower registration: start Pi in a terminal, then run `/telegram-connect`.
|
||||
|
||||
The routing identity split is deliberate:
|
||||
|
||||
- Live routing owner: `instanceId` from the currently registered follower/leader runtime. A live instance may have only one active bound thread; provisioning a new target removes older current-state bindings for the same `instanceId` and closes duplicate Telegram threads when possible.
|
||||
- Current binding owner: explicit `owner` metadata (`leader`, `manual-follower`, or API-level pending thread creation) plus cwd/thread-name metadata; string compatibility keys are derived internally and must not be the persisted source of ownership truth.
|
||||
- Instance slot: extension-owned single-letter `A`-`Z` ordering metadata. New instances advance through the alphabet and wrap after `Z` only to a free slot; live concurrent instances are capped to available alphabet slots rather than duplicating occupied letters. The compact `bot.lastSlot` cursor persists while its binding remains live/recovering, including true `Z → A` wraparound. When post-grace follower compaction removes the binding represented by the cursor, the same reconciliation pass realigns it to the newest-created remaining live binding so removed historical followers cannot dictate fresh allocation; unexpired pending provisions and reservations remain collision guards. Other thread deletion paths may intentionally preserve an orphaned cursor to continue ring sequence.
|
||||
- Instance thread name: durable human-facing identity metadata that replaces slot-only thread titles. Fresh threads choose one baked 4-6 letter Latin-word name from the assigned slot's curated palette using provisioning timestamp entropy and create the Telegram thread with that title immediately. Telegram-originated prompt prefixes expose this thread identity label, never follower/leader roles or generic seeds. Bare slot letters are fallback/legacy labels only; agents are not asked to name or rename threads.
|
||||
- Telegram destination: `TelegramTarget` as `{ chatId, threadId? }`, where `threadId` is Telegram `message_thread_id` for UI thread targets.
|
||||
|
||||
Guest-mode updates are owned by the current transport leader by default in Threaded Mode. Guest queries have no Telegram thread binding and no local follower identity, so the leader queues and answers them unless a future explicit guest-owner policy is added. Followers may still transport `answerGuestQuery` through the leader for replies to work if a guest turn is ever delegated deliberately, but implicit guest routing does not pick an arbitrary follower.
|
||||
|
||||
All inbound updates are gated by the configured authorized user id.
|
||||
|
||||
## Core Flows
|
||||
|
||||
### Inbound Turn Flow
|
||||
|
||||
1. Poll updates through `getUpdates`.
|
||||
2. Persist update offsets only after successful handling; repeated handler failures are bounded.
|
||||
3. Filter to the paired private user; guest-mode updates require an existing paired user and cannot establish first pairing.
|
||||
4. Dispatch owned callbacks and controls before fallback prompt forwarding.
|
||||
5. Coalesce media groups and likely split long text when needed.
|
||||
6. Download files into `~/.pi/agent/tmp/telegram` with size limits and partial-download cleanup.
|
||||
7. Run configured/programmatic inbound handlers in order, appending successful stdout under `[outputs]`.
|
||||
8. Add local attachments under `[attachments]`, optional voice context, and optional final `[time]` context.
|
||||
9. Build a `PendingTelegramTurn` and append it to the bridge queue.
|
||||
10. Handle `edited_message` updates separately while the original turn is still queued.
|
||||
11. Dispatch only when all safety gates are clear.
|
||||
|
||||
Long-text split recovery is intentionally conservative: only human text at or above the near-limit threshold opens the debounce window; commands, bots, captions, media groups, and normal short follow-ups bypass it.
|
||||
|
||||
### Queue And Dispatch Safety
|
||||
|
||||
The bridge keeps its own Telegram queue. Queue items have two explicit dimensions:
|
||||
|
||||
- `kind`: `prompt` or `control`.
|
||||
- `queueLane`: `control`, `priority`, or `default`.
|
||||
|
||||
Dispatch rank:
|
||||
|
||||
1. `control` lane.
|
||||
2. `priority` prompt lane.
|
||||
3. `default` prompt lane.
|
||||
|
||||
Admission and planning validate lane contracts. Invalid lane/kind pairings fail predictably instead of being silently coerced.
|
||||
|
||||
Dispatch requires:
|
||||
|
||||
- No active Telegram turn.
|
||||
- No pending Telegram dispatch already sent to Pi.
|
||||
- No compaction in progress.
|
||||
- `ctx.isIdle()` is true.
|
||||
- `ctx.hasPendingMessages()` is false.
|
||||
|
||||
A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies.
|
||||
|
||||
Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere. When a queued Telegram prompt is forwarded into Pi, it uses a normal `sendUserMessage(content)` turn after the bridge's idle/dispatch guards pass; it does not use Pi's `followUp` delivery option or inject terminal input.
|
||||
|
||||
One monotonic session generation also fences agent/tool/message events, compaction callbacks, preview state, scheduled final delivery, controls, and shutdown. Distinct Pi context objects observed within one session adopt that generation; contexts already observed under an older generation remain stale after replacement. Session start invalidates pending preview work, delayed finals check their captured context before delivery, and shutdown rechecks after asynchronous polling/preview boundaries with a bounded preview-clear wait.
|
||||
|
||||
### Controls And Menus
|
||||
|
||||
Telegram controls execute through command/callback domains, not by entering the normal prompt queue unless they intentionally create a prompt turn.
|
||||
|
||||
Immediate controls:
|
||||
|
||||
- `/start` opens the main inline application menu.
|
||||
- `/model`, `/thinking`, `/queue`, and `/settings` are hidden shortcuts to menu sections.
|
||||
- `/compact` opens an inline confirmation dialog and then runs compaction when the bridge is idle.
|
||||
- `/next` dispatches the next queued turn, aborting Pi first when needed.
|
||||
- `/abort` aborts active work while preserving queued items. Abort-history preservation is enabled only for Telegram-owned active turns; later local/non-Telegram agent starts clear stale abort-history mode so the next Telegram prompt appends instead of absorbing old queued turns as history.
|
||||
- `/stop` aborts and clears waiting Telegram queue items.
|
||||
|
||||
Queued controls:
|
||||
|
||||
- `/continue` creates a priority Telegram-owned `continue` prompt.
|
||||
- Prompt-template commands expand Telegram-safe Pi template aliases before entering the prompt queue.
|
||||
- Model-switch continuation uses the control lane when an in-flight Telegram-owned run must be stopped and resumed.
|
||||
|
||||
Queue and menu mutations are reachable through Telegram updates handled by the current polling owner. After ownership moves, the old instance keeps processing its accepted local queue, but it no longer receives new menu callbacks or control updates for remote mutation. UI label, navigation, tab, toggle, card, and dialog rules are defined in [UI Style](./ui-style.md). Callback prefix ownership is defined in [Callback Namespaces](./callback-namespaces.md).
|
||||
|
||||
### Compaction And Typing Status
|
||||
|
||||
Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch, retain that flag in explicit diagnostics, and clear it on compact completion, timeout fallback, or session shutdown. Pi owns its terminal compaction lifecycle; pi-telegram keeps `Active` scoped to Telegram-owned work and otherwise preserves the stable connected/leader/follower role.
|
||||
|
||||
Native typing during compaction follows connected-instance activity rather than terminal status:
|
||||
|
||||
- Confirmed manual `/compact` starts a native `typing` keepalive in the command target and stops it on completion/failure.
|
||||
- Automatic/session compaction with an active Telegram turn reuses that turn's target.
|
||||
- Automatic/session compaction without an active Telegram turn uses the connected instance's assigned target; an unconnected instance sends nothing.
|
||||
- Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface; completion, timeout, and shutdown stop the keyed loop.
|
||||
|
||||
At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
|
||||
|
||||
### Rendering And Delivery
|
||||
|
||||
Rich Markdown is the default model-answer membrane. Complete assistant replies send final Markdown directly as `InputRichMessage.markdown` through `sendRichMessage` when `assistant.rendering` is `rich`, and through the legacy Markdown-to-HTML renderer when `assistant.rendering` is `html`; guest replies use native Rich Markdown through `InputRichMessageContent` in `answerGuestQuery` results. Tool-call rows, reasoning/thinking blocks, menus, status rows, queue controls, settings, diagnostics, and other harness-owned surfaces stay on explicit Telegram HTML/plain rendering. Streaming previews may use `sendRichMessageDraft` only when `assistant.draftPreviews` is enabled and draft delivery succeeds. The bridge still strips top-level assistant action comments before delivery and may split output only for Telegram transport limits.
|
||||
|
||||
Assistant delivery guarantees:
|
||||
|
||||
- Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML unless the operator selects `assistant.rendering: "html"` for compatibility.
|
||||
- Before native Rich Markdown delivery, the bridge normalizes known Bot-API-fragile source forms without changing visible meaning, including space-after-marker blockquotes and dollar-prefixed ticker atoms that Telegram may otherwise treat as unterminated math.
|
||||
- Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In a private Guest Mode turn the paired owner's `from` identity is never the guest: the remote private-chat identity wins, then non-owner caller metadata, with a non-bot replied peer available only as a final identity fallback when stronger conversation evidence is absent; username falls back to the remote display name and numeric id. Reply attribution still belongs independently in `[reply|from:...]`, and a replied bot can never define or replace the current `[telegram|guest:...]` location identity. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context.
|
||||
- Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text.
|
||||
- Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
|
||||
- When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice suppression, and serialized flushes. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
|
||||
- Preview flushes are serialized so older edits cannot race newer drafts; final delivery waits for active draft flushes and does not perform a post-final draft-clear call.
|
||||
|
||||
UI/compat rendering guarantees:
|
||||
|
||||
- Bridge-owned UI surfaces such as tool rows, reasoning/thinking blocks, commands, menus, status messages, queue controls, diagnostics, settings, and interactive sections use Telegram HTML/plain rendering helpers by default. These texts are authored for operational UI rather than model output, so explicit HTML/plain markup remains clearer, safer, and easier to maintain.
|
||||
- In those UI/compat surfaces, real code blocks stay literal and escaped, supported absolute links stay clickable, unsupported links degrade safely, tables use compact monospace rendering with grapheme/display-width accounting, and list/quote/heading spacing stays Telegram-safe.
|
||||
|
||||
Final delivery attaches reply metadata only where requested. Reply parameters apply only to the first chunk of split messages; continuation chunks are adjacent normal messages. Media-group turns reply to the representative message id.
|
||||
|
||||
### Outbound Artifacts And Assistant Actions
|
||||
|
||||
Outbound files staged during an active Telegram turn are delivered after that turn completes. They use `telegram_attach`, are checked atomically per tool call, and use configurable size limits before photo/document upload. When no Telegram turn is active, `telegram_attach` sends files immediately to the paired/default chat, an assigned follower thread, or an explicit `chat_id` plus optional `thread_id`; `telegram_message` provides direct local/TUI Markdown text delivery for explicit user requests and runs the same `telegram_button` markup planner so buttons attach to that text message. Direct local/TUI delivery is singleton-controlled: classic mode requires this Pi instance to own `/telegram-connect`, while Threaded Mode followers must be registered and route through the leader-owned transport. Already accepted active-turn reply/attachment delivery remains session-local.
|
||||
|
||||
Assistant-authored final-message actions use hidden top-level comments:
|
||||
|
||||
- `telegram_voice` creates voice reply artifacts through configured outbound handlers, programmatic voice handlers, or registered synthesis providers.
|
||||
- `telegram_button` creates inline buttons whose callbacks enqueue the configured prompt text as a normal Telegram prompt turn.
|
||||
|
||||
Preview delivery strips top-level action comments before streaming draft Markdown. Comments inside code fences, quotes, lists, or indented examples stay literal.
|
||||
|
||||
Unknown callback data outside owned prefixes is forwarded as `[callback] <data>` only after built-in and extension handlers decline it.
|
||||
|
||||
## Extension Surfaces
|
||||
|
||||
`pi-telegram` intentionally owns one `getUpdates` loop per bot. `polling` owns that internal loop; `updates` owns classification/default-routing plans plus the public handler registry layered extensions use to observe or consume updates without opening a competing polling connection. Layered extensions should integrate through extension surfaces instead of polling the same bot independently.
|
||||
|
||||
- Raw update observation/consumption: [Updates](./updates.md).
|
||||
- Telegram-native slash commands: `registerTelegramCommand()` from [Public API](./public-api.md#commands).
|
||||
- Target-aware operational views and chat actions: [Telegram Delivery API](./delivery.md).
|
||||
- Normalized non-blocking Pi lifecycle events: [Telegram Activity API](./activity.md); the separate [`pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) project remains the companion-extension reference.
|
||||
- Structured inline UI sections: [Sections](./sections.md).
|
||||
- Callback namespace discipline: [Callback Namespaces](./callback-namespaces.md).
|
||||
- Voice/STT/TTS providers: [Voice Integration](./voice.md).
|
||||
- Inbound/outbound command-template handlers: [Command Templates](./command-templates.md).
|
||||
|
||||
Extension callbacks must avoid `pi-telegram` owned prefixes such as `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`. Workflow-specific Telegram slash commands should use the public command registry instead of becoming new core built-ins unless they are bridge lifecycle, transport ownership, queue safety, or essential operator controls.
|
||||
|
||||
The bridge does not mirror arbitrary `ctx.ui.confirm/input/select/custom` prompts from other extensions into Telegram. Companion extensions that need Telegram operation should expose a Telegram-native command, section, settings row, callback, status line, inbound/update handler, or assistant action-markup path instead of relying on hidden TUI-only prompts.
|
||||
|
||||
## Diagnostics And Operational Behavior
|
||||
|
||||
Status rendering distinguishes connected, active, dispatching, queued, tool-running, model-switching, and compacting states. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, status remains active instead of degrading to connected.
|
||||
|
||||
Queue reactions are shortcut controls for waiting turns. Promotion reactions (`👍`, `⚡️`, `❤️`, `🕊`, `🔥`) move prompts to priority; removal reactions (`👎`, `👻`, `💔`, `💩`, `🗑`) remove waiting turns because ordinary Telegram DM deletions are not exposed through Bot API polling.
|
||||
|
||||
`/telegram-status` records grouped diagnostics for transport/API, polling/update, prompt dispatch, controls, typing, compaction, setup, session lifecycle, attachment queue/delivery, and recent redacted runtime events. Expected preview noise such as unchanged edit responses is filtered out.
|
||||
|
||||
When proactive push is enabled and this instance owns the Telegram lock, successful local non-Telegram final replies are sent to the paired chat. Non-owners skip proactive delivery and record a runtime diagnostic. Local prompt text is not mirrored because the bot does not own terminal user messages.
|
||||
|
||||
Telegram prompt guidance is context-aware. Unconfigured sessions receive no bridge suffix. Local/TUI prompts receive only explicit direct-delivery guidance so ordinary terminal replies do not learn raw Telegram action-comment syntax. Telegram-originated turns receive the full inbound context, phone-width output, and native action contract, including the 37-display-cell mobile readability hint.
|
||||
|
||||
## In-Flight Model Switching
|
||||
|
||||
When `/model` is used during an active Telegram-owned run, the bridge can emulate Pi's interactive stop/switch/continue workflow:
|
||||
|
||||
1. Apply the selected model immediately.
|
||||
2. Queue or stage a synthetic Telegram continuation turn.
|
||||
3. Abort the active Telegram turn immediately, or wait for the current tool to finish before aborting.
|
||||
4. Dispatch the continuation after abort completion.
|
||||
|
||||
This is limited to Telegram-owned runs. If Pi is busy with non-Telegram work, the bridge refuses the switch instead of hijacking unrelated activity.
|
||||
|
||||
## Shutdown And Timer Lifecycle
|
||||
|
||||
`session_shutdown` is the hard boundary for session-bound runtime work. It suspends Telegram polling through the locked polling runtime, aborts the poll controller, stops native typing, unbinds deferred queue dispatch, suspends pending media/text-group debounce work for rebinding to the replacement session, clears preview state, clears active turns, and drops the active abort handler.
|
||||
|
||||
Non-critical timers are `unref()`ed so print/headless processes are not kept alive only by Telegram housekeeping. This includes typing keepalive intervals, bounded typing-idle waits, deferred queue dispatch, media/text-group debounce windows, preview flush timers, and polling retry sleeps. Polling retry sleep is abort-aware, so shutdown does not wait for the normal retry delay after a polling error.
|
||||
|
||||
Non-interactive `pi -p` runs must remain passive unless Pi provides a live Telegram session lifecycle. Loading the extension with `telegram.json`, proactive push settings, or existing lock state must not by itself keep the print-mode process alive or let a non-owner send proactive Telegram output.
|
||||
|
||||
## Related
|
||||
|
||||
- [README.md](../README.md)
|
||||
- [Project Context](../AGENTS.md)
|
||||
- [Project Backlog](../BACKLOG.md)
|
||||
- [Changelog](../CHANGELOG.md)
|
||||
@@ -0,0 +1,48 @@
|
||||
# Callback Namespace Standard
|
||||
|
||||
Telegram `callback_data` is one bot-wide namespace. Any extension that creates inline buttons for a bot shared with `pi-telegram` must use namespaced callback data.
|
||||
|
||||
## Format
|
||||
|
||||
```text
|
||||
<namespace>:<action>[:<payload>]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
vividfish:approve:123
|
||||
vividfish:deny:123
|
||||
myext:page:2
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Use a stable extension-owned namespace, preferably the package or extension name without scope punctuation.
|
||||
- Keep the namespace lowercase ASCII: `a-z`, `0-9`, `_`, `-`.
|
||||
- Do not use `pi-telegram` owned prefixes: `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, `section:`. Current app navigation uses `menu:`; `status:` remains reserved for legacy/owned status callbacks but is not emitted by current UI. `compact:` is owned by the manual compaction confirmation dialog. `section:` is owned by the Extension Sections platform (0.10.0+), documented in [Extension Sections](./sections.md). `settings:` is owned for the built-in Settings submenu.
|
||||
- Keep the full `callback_data` within Telegram's 64-byte limit.
|
||||
- Put only opaque ids or small enum values in payloads; do not store secrets, full prompts, or large state.
|
||||
- Treat callbacks as untrusted input. Validate namespace, action, and payload before executing side effects.
|
||||
|
||||
## pi-telegram fallback
|
||||
|
||||
If `pi-telegram` receives callback data that is not owned by its built-in prefixes and no built-in handler consumes it, it forwards the click to Pi as:
|
||||
|
||||
```text
|
||||
[callback] <callback_data>
|
||||
```
|
||||
|
||||
Layered extensions may intercept that message and handle their own namespace. If no extension handles it, the assistant may see the fallback message and should tell the user the callback was not handled and the environment may be misconfigured.
|
||||
|
||||
## Extension sections
|
||||
|
||||
[Telegram Extension Sections](./sections.md) are a higher-level UI contract over this namespace rule. A section owns a canonical extension identity such as `@evandrodevbr/oh-my-pi-telegram-explorer`, but its Telegram `callback_data` should use the `pi-telegram` owned `section:` prefix plus a compact token, because Telegram limits callback payloads to 64 bytes.
|
||||
|
||||
Conceptual form:
|
||||
|
||||
```text
|
||||
section:<token>:<action>[:<payload>]
|
||||
```
|
||||
|
||||
The token maps back to the full section identity inside the section registry. Section authors should not hand-roll `section:` callbacks outside the section context helpers, and ordinary layered extensions should continue using their own namespace plus update handlers or the `[callback]` fallback.
|
||||
@@ -0,0 +1,365 @@
|
||||
# Command Template Standard
|
||||
|
||||
Command templates are the portable integration format for deterministic local automation.
|
||||
|
||||
**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).
|
||||
|
||||
**Scope:** portable synchronous command execution format — shell-free exec, composition/pipes, optional timeout, delay-before-start, bounded retry, failure propagation, recover cleanup, output artifact selection, and handler-level fallback. Single JSON standard; no platform lock-in.
|
||||
|
||||
---
|
||||
|
||||
Extensions may choose their own config files, selectors, placeholder sources, and examples, but should preserve this core contract.
|
||||
|
||||
Layer boundary: command templates own only the synchronous execution graph. Recipe imports, import-reference expressions, recipe lookup, `async: true`, run ids, state dirs, FIFO controls, and outbox events are host/recipe/async-run configuration layers, not portable command-template syntax.
|
||||
|
||||
## Shape
|
||||
|
||||
A command template is either a command-line string or an ordered array of command-template leaves:
|
||||
|
||||
```json
|
||||
{
|
||||
"template": "/path/to/stt --file {file} --lang {lang=ru}"
|
||||
}
|
||||
```
|
||||
|
||||
When the surrounding schema already implies a command template, the compact string form is equivalent:
|
||||
|
||||
```json
|
||||
"/path/to/stt --file {file} --lang {lang=ru}"
|
||||
```
|
||||
|
||||
There is no portable `command` field. The command is derived from `template`: after splitting, the first word is the executable and the remaining words are argv args. Templates do not infer flags: `{file}` is one positional arg; `--file {file}` is a flag arg plus its value.
|
||||
|
||||
Common object fields:
|
||||
|
||||
- `label`: Optional human label for diagnostics and parallel branch reports.
|
||||
- `parallel`: Optional boolean execution flag for array templates. Default is sequential execution; `true` runs children concurrently when the host execution layer supports branch fanout.
|
||||
- `when`: Optional boolean or condition string. Falsy values skip the node. String forms may reference a flag name, `!flag`, or a placeholder expression such as `{flag?yes:}`.
|
||||
- `args`: Optional placeholder declarations. Untyped names remain valid; compact typed forms such as `file:path`, `timeout:int`, `speed:number`, `dry_run:bool`, `prompts:array`, and `mode:enum(check,fix)` are valid when the host supports typed tool schemas. Defaults belong in `defaults` or inline placeholder defaults; hosts may normalize interactive shorthand such as `timeout:int=60000` before persistence.
|
||||
- `defaults`: Placeholder default values by name.
|
||||
- `timeout`: Optional execution timeout in milliseconds, as a number or placeholder-resolved string. Omit it, or set `0`, to leave the command unbounded. Set an explicit positive timeout when a tool must fail closed instead of waiting indefinitely.
|
||||
- `delay`: Optional wait in milliseconds before starting this node, as a number or placeholder-resolved string. Default is no delay.
|
||||
- `output`: Optional result selector. Default is `"stdout"`; runtime values such as `"ogg"` are valid.
|
||||
- `retry`: Optional max attempts including the first, as a number or placeholder-resolved string. Default is `1`.
|
||||
- `failure`: Optional failure propagation scope: `continue`, `branch`, or `root`. Default is `continue`.
|
||||
- `recover`: Optional command template run between failed retry attempts. Recovery output is ignored; recovery failure stops retries.
|
||||
- `template`: Required command string or ordered composition array.
|
||||
|
||||
For object form, write `template` last. Read the node flags first, then the executable content. Storage paths, labels, selectors, descriptions, and registry-specific metadata belong to each extension's local schema.
|
||||
|
||||
## Execution
|
||||
|
||||
A runtime must:
|
||||
|
||||
1. Split the template into shell-like words with simple single quotes, double quotes, and backslash escapes
|
||||
2. Substitute placeholders inside each split word
|
||||
3. Execute command + args directly, without shell evaluation
|
||||
4. Treat exit code `0` as success and non-zero as failure
|
||||
5. Use stdout as the default result channel and stderr only for diagnostics
|
||||
|
||||
Implementations may expand `~` in command position and may resolve relative command paths against the caller cwd.
|
||||
|
||||
## Placeholders
|
||||
|
||||
Supported forms:
|
||||
|
||||
| Form | Meaning |
|
||||
| --- | --- |
|
||||
| `{name}` | Required value from runtime values or `defaults` |
|
||||
| `{name=default}` | Inline default when no value is provided |
|
||||
| `{items[index]}` | Array item selected by literal or repeat index |
|
||||
| `{value??fallback}` | Fallback when the value is absent or falsy |
|
||||
| `{flag?yes:no}` | Conditional text selected by flag truthiness |
|
||||
|
||||
Resolution order is runtime values → `defaults` → inline default → error. Default values that are themselves a single placeholder, such as `{prompt}` resolving to `{prompts[index]}`, are resolved recursively with a small depth guard. A repeat node may set `repeat` to `{items.length}` when an array arg should determine fanout width.
|
||||
|
||||
```json
|
||||
{
|
||||
"template": "/path/to/tts --text {text} --lang {lang=ru} --rate {rate=+30%}"
|
||||
}
|
||||
```
|
||||
|
||||
With runtime values `{ "text": "hello" }`, argv is:
|
||||
|
||||
```text
|
||||
["--text", "hello", "--lang", "ru", "--rate", "+30%"]
|
||||
```
|
||||
|
||||
Use `defaults` for visible configuration data; use inline defaults for compact local literals. Prefer flag-style examples such as `/path/to/tool --file {file} --lang {lang=ru}` for readability, but positional forms such as `/path/to/tool {file} {lang=ru}` are valid when the invoked script defines that CLI contract.
|
||||
|
||||
Typed declarations annotate the public tool interface, not the shell command. They may live in `args` or inline placeholders such as `{timeout:int=60000}` and `{mode:enum(check,fix)=check}`. Use metadata-first authoring (`args` plus `defaults`) when long templates should stay visually short; use inline-first authoring when one self-contained `template` property is clearer. They do not sandbox or reinterpret the executable; they only let the host generate narrower input schemas and normalize runtime values before placeholder substitution. Untyped `args` and untyped placeholders continue to work unchanged.
|
||||
|
||||
## Quoting
|
||||
|
||||
Placeholder values are not shell-escaped because no shell is used. A value containing spaces remains one argv item when it replaces one split word:
|
||||
|
||||
```text
|
||||
template="echo {text}"
|
||||
text="hello world"
|
||||
args=["hello world"]
|
||||
```
|
||||
|
||||
A placeholder may also be embedded inside one word:
|
||||
|
||||
```text
|
||||
template="/path/to/tool --file={file}"
|
||||
file="/tmp/a b.ogg"
|
||||
args=["--file=/tmp/a b.ogg"]
|
||||
```
|
||||
|
||||
Use quotes only for literal template words that should contain spaces before placeholder substitution:
|
||||
|
||||
```text
|
||||
template="echo 'literal words' {text}"
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
`template: [...]` means sequential composition by default; each leaf is a command template executed with one shared runtime value map:
|
||||
|
||||
```json
|
||||
{
|
||||
"template": [
|
||||
"/path/to/tts --text {text} --lang {lang=ru} --out {mp3}",
|
||||
"ffmpeg -y -i {mp3} -c:a libopus {ogg}"
|
||||
],
|
||||
"output": "ogg"
|
||||
}
|
||||
```
|
||||
|
||||
Composition rules:
|
||||
|
||||
- Execute leaves in order by default
|
||||
- Execute child templates concurrently when `parallel` is `true`
|
||||
- Parallel composition uses soft-quorum semantics by default: failed children are reported as degraded branches unless failure propagation escalates
|
||||
- Non-critical failures are recorded and execution continues, while `failure: "branch"` stops the current branch and `failure: "root"` aborts the root composition
|
||||
- Treat the whole composition as one handler for selector matching and fallback
|
||||
- Top-level `args` and `defaults` apply to every leaf unless the leaf defines private values
|
||||
- Leaf `args` replace inherited `args`; leaf `defaults` merge over inherited defaults; `timeout` and `output` are not inherited into leaves
|
||||
- Timeout is disabled by default; configure a positive `timeout` for bounded commands that should fail closed
|
||||
- Each sequence leaf receives the previous leaf's stdout on stdin by default, while the final leaf stdout remains the default composition result
|
||||
- Each parallel child receives the same stdin, and child stdout values are joined in stable array order before flowing to the next sequence leaf
|
||||
- Parallel branch joins include branch label and status, and tool details include branch metadata plus coverage summary
|
||||
- Each leaf still applies its own inline defaults
|
||||
|
||||
```json
|
||||
{
|
||||
"template": [
|
||||
"/path/to/tts --text {text} --lang {lang} --out {mp3}",
|
||||
{
|
||||
"defaults": { "codec": "libopus" },
|
||||
"template": "ffmpeg -y -i {mp3} -c:a {codec} {ogg}"
|
||||
}
|
||||
],
|
||||
"args": ["text", "lang", "mp3", "ogg"],
|
||||
"defaults": { "lang": "en" },
|
||||
"output": "ogg"
|
||||
}
|
||||
```
|
||||
|
||||
`output` selects the primary result channel. Omitted `output` means `"stdout"`, and explicitly writing `"output": "stdout"` is valid standard syntax. Artifact-producing handlers may instead name a runtime value or placeholder path, e.g. `"ogg"` or `"{ogg}"`.
|
||||
|
||||
### Repeat
|
||||
|
||||
`repeat` expands one command-template node N times before execution. It works with both sequence and parallel nodes and is useful when many branches differ only by a number.
|
||||
|
||||
```json
|
||||
{
|
||||
"parallel": true,
|
||||
"repeat": 8,
|
||||
"template": "render page{_(index+1)}.html --prev page{_(prev+1)}.html --next page{_(next+1)}.html --zero page{_index}.html"
|
||||
}
|
||||
```
|
||||
|
||||
Reserved repeat placeholders are injected into each repeated node:
|
||||
|
||||
- `{index}`: current zero-based index, `0..repeat-1`
|
||||
- `{prev}` / `{next}`: wrapped zero-based neighbors
|
||||
- `{repeat}`: total repeat count
|
||||
|
||||
Human 1-based numbering is intentionally expressed as limited arithmetic: `{index+1}`, `{prev+1}`, `{next+1}`.
|
||||
|
||||
Leading underscores on repeat placeholders request zero padding. One underscore means width 2, two underscores mean width 3, and so on:
|
||||
|
||||
```text
|
||||
{_index} → 00, 01, ...
|
||||
{_(index+1)} → 01, 02, ...
|
||||
{__(index+1)} → 001, 002, ...
|
||||
{_(prev+1)} → wrapped previous page number, padded to width 2
|
||||
{_(next+1)} → wrapped next page number, padded to width 2
|
||||
```
|
||||
|
||||
Repeat expressions support only integers, `index`, `prev`, `next`, `repeat`, parentheses, and `+`, `-`, `*`, `/`, `%`. They are not JavaScript and cannot call functions or access properties.
|
||||
|
||||
Repeat placeholders are local generated values. Call-time args should not use these reserved names to override the repeat index.
|
||||
|
||||
Parallel nodes use the same object shape. Flags come first and `template` stays last:
|
||||
|
||||
```json
|
||||
{
|
||||
"template": [
|
||||
"prepare {out_dir}",
|
||||
{
|
||||
"parallel": true,
|
||||
"template": [
|
||||
{
|
||||
"label": "gpt-5.5",
|
||||
"timeout": 300000,
|
||||
"template": "review-gpt {scope}"
|
||||
},
|
||||
{
|
||||
"label": "deepseek-pro",
|
||||
"timeout": 300000,
|
||||
"template": "review-deepseek {scope}"
|
||||
},
|
||||
{
|
||||
"label": "kimi",
|
||||
"timeout": 300000,
|
||||
"template": "review-kimi {scope}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"merge {out_dir}"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A degraded parallel join is still usable when at least one branch succeeds:
|
||||
|
||||
```text
|
||||
--- branch: gpt-5.5 status: done ---
|
||||
review text
|
||||
--- branch: deepseek-pro status: failed ---
|
||||
exit: 1
|
||||
stderr: provider balance exhausted
|
||||
```
|
||||
|
||||
Use `template: [...]` for ordered composition. Older local `pipe` aliases are not part of the 0.13.0 command-template standard.
|
||||
|
||||
## Fail-Open Default Policy
|
||||
|
||||
By default, composition continues on failure: the failed step is logged and the next step executes. This is analogous to `make -k` — the user sees all failures at once and decides what to fix.
|
||||
|
||||
## Failure Propagation
|
||||
|
||||
By default, failed steps use `failure: "continue"`: record the failure, clear stdout for that step, and continue the current sequence. This preserves the fail-open profile.
|
||||
|
||||
Use `failure` when a node should stop more aggressively:
|
||||
|
||||
- `"continue"`: record the failure and continue the current sequence.
|
||||
- `"branch"`: stop the current sequence/subtree and return a failed branch to the nearest parent. In a parallel node, sibling branches keep running and the join becomes degraded. At the root, branch failure is still a tool failure.
|
||||
- `"root"`: abort the outermost composition.
|
||||
|
||||
```json
|
||||
{
|
||||
"parallel": true,
|
||||
"template": [
|
||||
{
|
||||
"label": "agent-a",
|
||||
"failure": "branch",
|
||||
"template": [
|
||||
"agent-a-work {scope}",
|
||||
"agent-a-validate {scope}",
|
||||
"agent-a-push {scope}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "agent-b",
|
||||
"failure": "branch",
|
||||
"template": [
|
||||
"agent-b-work {scope}",
|
||||
"agent-b-validate {scope}",
|
||||
"agent-b-push {scope}"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If `agent-a-validate` fails, `agent-a-push` is skipped, `agent-b` can still finish, and the parallel join reports degraded branch coverage.
|
||||
|
||||
Use `failure: "root"` to abort the root composition. Older local `critical: true` shapes are not part of the 0.13.0 command-template standard.
|
||||
|
||||
## Retry
|
||||
|
||||
Set `retry: N` to attempt execution up to `N` times including the first. The first successful attempt stops the retry loop.
|
||||
|
||||
On leaf commands, retry repeats that command. On sequence or parallel nodes, retry repeats the whole node. A retried group only retries when the group returns a failure, so validator checkpoints normally pair group retry with `failure: "branch"` or `failure: "root"`.
|
||||
|
||||
```json
|
||||
{
|
||||
"failure": "branch",
|
||||
"retry": 3,
|
||||
"template": ["implement {scope}", "npm test", "git diff --check"]
|
||||
}
|
||||
```
|
||||
|
||||
Here the whole group runs again when a validator fails. Without `failure: "branch"`, the failed validator would be logged and the group would continue by default.
|
||||
|
||||
## Recover
|
||||
|
||||
Set `recover` on a retried node to run cleanup after a failed attempt and before the next attempt. `recover` is another command template: it can be a string command, sequence, or mode tree. Its output is ignored and the next retry receives the original stdin.
|
||||
|
||||
```json
|
||||
{
|
||||
"failure": "branch",
|
||||
"retry": 3,
|
||||
"recover": "git -C {work_dir} reset --hard HEAD",
|
||||
"template": ["pi -p --tools read,edit,bash {scope_file}", "npm test"]
|
||||
}
|
||||
```
|
||||
|
||||
`recover` is not a fallback success path. It is cleanup between attempts. Practical uses include resetting a worktree, removing temp files, clearing generated output, releasing a local lock, or stopping a helper process before trying the node again. If recovery fails, retries stop and the recovery failure is returned. Recovery uses fail-closed semantics by default; set an explicit `failure` inside a recover template only when a softer cleanup failure is intentional.
|
||||
|
||||
## Delay
|
||||
|
||||
Set `delay` to wait before starting a node. The value is milliseconds. Delay is not inherited into child nodes, just like `timeout`.
|
||||
|
||||
```json
|
||||
{
|
||||
"template": [
|
||||
"prepare {scope}",
|
||||
{ "delay": 1000, "template": "review {scope}" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
On a sequence node, `delay` waits before the sequence begins. On a parallel node, `delay` waits before launching its children. On a branch, `delay` waits before that branch starts, without blocking sibling branches.
|
||||
|
||||
Use `delay` only for explicit backoff, rate pacing, or staged launch. Do not use it as a scheduler.
|
||||
|
||||
## Progressive Disclosure
|
||||
|
||||
The standard uses a single `template` field that grows with the user's needs:
|
||||
|
||||
```text
|
||||
string → leaf command
|
||||
string[] → sequential composition
|
||||
{ template } → leaf command object
|
||||
{ parallel, template } → parallel subtree
|
||||
{ parallel, when, args, defaults, delay, retry, failure, recover, output, template } → full node
|
||||
```
|
||||
|
||||
Start with a string. Add composition when needed. Add `parallel: true` when independent work can run concurrently. Add `when` for conditional nodes. Add delay when launch pacing matters. Add retry when flaky. Add `failure` when propagation scope matters. Add `recover` when a retried node needs cleanup before another attempt. Same contract, growing capability, no dead weight.
|
||||
|
||||
`parallel: true` is the synchronous fanout shape. Saved JSON recipes and detached lifecycle concerns such as logs, cancellation, and durable state belong to host-specific recipe/async-run standards, not to command templates.
|
||||
|
||||
## Trust Boundary
|
||||
|
||||
Command templates avoid shell interpolation by splitting the template into argv first and substituting placeholders per arg. A placeholder value containing spaces remains one argv value, not a shell fragment.
|
||||
|
||||
This is not a sandbox. The executable still runs with the same user permissions as the host agent. Shells, interpreter eval modes, destructive filesystem commands, and local scripts remain trusted code. Examples that deserve extra operator attention:
|
||||
|
||||
- `bash`, `sh`, `zsh`, or `fish`, especially with `-c`.
|
||||
- `node -e`, `python -c`, `ruby -e`, `perl -e`, or similar eval modes.
|
||||
- `rm`, `mv`, `cp`, or `rsync` over broad paths or placeholder-derived paths.
|
||||
|
||||
Hosts may surface lightweight warnings for these obvious high-risk shapes. Warnings should inform review without blocking existing tools, because many trusted local wrappers intentionally use shells or filesystem mutation.
|
||||
|
||||
## Tool Boundary
|
||||
|
||||
Agent tools are a separate abstraction. A tool name is not a portable command template because the pi extension API exposes tool registration metadata, not a public extension-to-extension `executeTool(name, args)` contract. Until such an API exists, extensions should use command templates for deterministic local automation.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Consumers should share this contract, not private registry fields or implementation details from any specific extension.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Telegram Delivery API
|
||||
|
||||
## Purpose
|
||||
|
||||
The Telegram Delivery API gives trusted extension consumers a safe programmatic way to render operational Telegram UI without importing bridge internals or owning Telegram transport.
|
||||
|
||||
It fills the boundary between callback-scoped `TelegramSectionContext.open()` and agent-callable `telegram_message` / `telegram_attach`. It does not replace normal active-turn final replies, Sections, outbound handlers, or raw update handlers.
|
||||
|
||||
The public package membrane is:
|
||||
|
||||
```ts
|
||||
import {
|
||||
deleteTelegramView,
|
||||
editTelegramView,
|
||||
sendTelegramChatAction,
|
||||
sendTelegramView,
|
||||
} from "@evandrodevbr/oh-my-pi-telegram/delivery";
|
||||
```
|
||||
|
||||
## Ownership Boundary
|
||||
|
||||
Consumer extension code owns:
|
||||
|
||||
- When an operational view should appear.
|
||||
- View text, parse mode, and optional inline keyboard.
|
||||
- Its own settings and callback policy.
|
||||
- Retaining a returned handle only for the current live extension generation.
|
||||
|
||||
pi-telegram owns:
|
||||
|
||||
- Active-turn and current-instance target resolution.
|
||||
- Pairing and target authorization.
|
||||
- Classic direct transport versus follower-to-leader routing.
|
||||
- Named-profile isolation.
|
||||
- Per-target operation ordering.
|
||||
- Telegram text limits, rendering, and chunk reconciliation.
|
||||
- Runtime generation checks, shutdown behavior, and diagnostics.
|
||||
- Bot credentials, polling, offsets, retries, and raw API clients.
|
||||
|
||||
## Public Contract
|
||||
|
||||
### Views
|
||||
|
||||
```ts
|
||||
export type TelegramDeliveryParseMode = "plain" | "html" | "markdown";
|
||||
|
||||
export interface TelegramDeliveryView {
|
||||
text: string;
|
||||
parseMode?: TelegramDeliveryParseMode;
|
||||
replyMarkup?: TelegramInlineKeyboardMarkup;
|
||||
}
|
||||
```
|
||||
|
||||
`plain` is the default. Operational activity should prefer `plain` or explicit `html`. `markdown` exists for extension-authored content that naturally owns Markdown; the bridge converts it through the existing UI/compat Markdown-to-HTML renderer rather than entering the native assistant final-reply pipeline.
|
||||
|
||||
`replyMarkup` accepts only structural keyboard data. Callback ownership stays with Sections or a registered raw update handler. The documented issue #126 consumer shape uses Sections for interactive Settings toggles and keeps delivered activity rows non-interactive, so a second managed callback registry would duplicate token, answer, edit, navigation, and cleanup ownership without a proven use case. Revisit only when a public-import-only consumer must generate managed callbacks independently of a registered Section context for arbitrary delivered messages.
|
||||
|
||||
### Target scopes
|
||||
|
||||
```ts
|
||||
export type TelegramDeliveryScope =
|
||||
| { kind: "active-turn" }
|
||||
| { kind: "instance" }
|
||||
| { kind: "aggregate" }
|
||||
| { kind: "target"; target: TelegramDeliveryTarget };
|
||||
|
||||
export interface TelegramDeliveryTarget {
|
||||
chatId: number;
|
||||
threadId?: number;
|
||||
}
|
||||
```
|
||||
|
||||
Resolution rules:
|
||||
|
||||
- `active-turn` requires a current Telegram-owned turn and resolves its exact `{ chatId, threadId? }`.
|
||||
- `instance` resolves the current process's assigned follower/leader thread, or the paired private chat in classic mode. It does not silently fall back to an unrelated active thread.
|
||||
- `aggregate` resolves the paired private chat without `threadId`; it is the Threaded Mode `All` surface and the ordinary classic chat. Follower aggregate messages carry an internal authenticated-bus marker that the leader validates for the assigned chat and strips before Bot API transport; unmarked or cross-chat threadless follower writes remain denied.
|
||||
- `target` validates an explicit destination against the active profile and current runtime authority. A classic owner may target its paired private chat. A follower may target only its assigned thread or aggregate surface. A leader may target its own thread, aggregate surface, or a currently live bound thread for that profile. Unknown, stale, cross-profile, and unpaired targets are rejected.
|
||||
|
||||
No scope selects another named profile. Profile activation remains session-local bridge state.
|
||||
|
||||
### Handles
|
||||
|
||||
```ts
|
||||
export interface TelegramDeliveryHandle {
|
||||
readonly target: TelegramDeliveryTarget;
|
||||
readonly messageIds: readonly number[];
|
||||
readonly generation: string;
|
||||
}
|
||||
```
|
||||
|
||||
A handle represents one logical view, which may span multiple Telegram messages after chunking. `generation` is an opaque runtime identity, not an authorization secret. Callers must not construct handles or persist them across reload/session replacement.
|
||||
|
||||
Edit reconciles the logical view as one operation:
|
||||
|
||||
- Existing chunks are edited in order.
|
||||
- Additional chunks are sent when the new view grows.
|
||||
- Surplus old chunks are deleted when the new view shrinks.
|
||||
- The returned handle replaces the previous handle.
|
||||
|
||||
Delete removes every message still represented by the handle. Partial transport failure returns a structured failure and records diagnostics; it never pretends the whole logical view succeeded.
|
||||
|
||||
### Results
|
||||
|
||||
```ts
|
||||
export type TelegramDeliveryFailureReason =
|
||||
| "runtime-unavailable"
|
||||
| "target-unavailable"
|
||||
| "target-unauthorized"
|
||||
| "stale-handle"
|
||||
| "invalid-view"
|
||||
| "commit-unknown"
|
||||
| "transport-failed";
|
||||
|
||||
export type TelegramDeliveryResult<T> =
|
||||
| { ok: true; value: T }
|
||||
| {
|
||||
ok: false;
|
||||
reason: TelegramDeliveryFailureReason;
|
||||
message: string;
|
||||
partial?: T;
|
||||
};
|
||||
```
|
||||
|
||||
Expected availability, authorization, lifecycle, and ambiguous mutation outcomes return failures rather than throwing. `commit-unknown` means a non-idempotent Bot API mutation may have committed before its response was lost; callers must not blindly replay it. When earlier chunks remain known, `partial` still carries their recoverable logical handle. Programmer errors may still throw for malformed objects that cannot satisfy the TypeScript contract. Transport failures are redacted before reaching callers and are also recorded in bridge runtime diagnostics.
|
||||
|
||||
When a multi-chunk `send` or growing `edit` fails after materializing part of the logical view, `partial` contains a valid handle for every message still visible from that operation. Callers may pass it to `editTelegramView` to reconcile the view or to `deleteTelegramView` for cleanup. A failure before any message exists omits `partial`; callers must never infer message ids or construct a handle.
|
||||
|
||||
### Operations
|
||||
|
||||
```ts
|
||||
export interface SendTelegramViewOptions {
|
||||
scope: TelegramDeliveryScope;
|
||||
replyToMessageId?: number;
|
||||
}
|
||||
|
||||
export function sendTelegramView(
|
||||
view: TelegramDeliveryView,
|
||||
options: SendTelegramViewOptions,
|
||||
): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
|
||||
|
||||
export function editTelegramView(
|
||||
handle: TelegramDeliveryHandle,
|
||||
view: TelegramDeliveryView,
|
||||
): Promise<TelegramDeliveryResult<TelegramDeliveryHandle>>;
|
||||
|
||||
export function deleteTelegramView(
|
||||
handle: TelegramDeliveryHandle,
|
||||
): Promise<TelegramDeliveryResult<void>>;
|
||||
|
||||
export function sendTelegramChatAction(
|
||||
action: "typing" | "upload_document" | "upload_photo" | "record_voice",
|
||||
options: { scope: TelegramDeliveryScope },
|
||||
): Promise<TelegramDeliveryResult<void>>;
|
||||
```
|
||||
|
||||
`replyToMessageId` applies only to the first chunk and must belong to the resolved chat. The initial contract exposes only actions already used by bridge-owned activity and delivery paths; it is not a generic Bot API action string.
|
||||
|
||||
## Runtime Binding
|
||||
|
||||
The public functions resolve a process-local runtime binding on every call. They never capture a Pi `ExtensionContext` or command context.
|
||||
|
||||
The bridge constructs and binds a genuinely fresh delivery runtime during every `session_start`. It unbinds and shuts down the current runtime during `session_shutdown` before session-bound transport state is discarded; binding an unexpected replacement also shuts down the displaced runtime. Reload and session replacement therefore produce these outcomes:
|
||||
|
||||
- A new call resolves the newly bound runtime after startup.
|
||||
- An old handle returns `stale-handle` for edit and delete.
|
||||
- A call while no generation is bound returns `runtime-unavailable`.
|
||||
- Queued operations from the old generation stop before transport begins.
|
||||
- An already-issued Telegram request may resolve during shutdown, but the old operation returns `runtime-unavailable` and cannot issue another edit, delete, chunk, or chat action afterward.
|
||||
- Old operations never adopt the replacement generation implicitly.
|
||||
|
||||
The binding uses the same `globalThis` membrane pattern as other extension registries so package load order does not expose bridge internals. Only pi-telegram may bind or replace the runtime port.
|
||||
|
||||
## Ordering And Delivery Semantics
|
||||
|
||||
- Operations serialize per profile and concrete target. Different targets may progress independently.
|
||||
- `send`, logical `edit`, and logical `delete` preserve caller order for the same target.
|
||||
- Delivery uses the existing bridge API runtime so followers route allowlisted calls through the leader rather than contacting Telegram directly.
|
||||
- Text is chunked through the existing parse-mode-appropriate renderer/splitter. HTML chunks remain balanced, extension-authored Markdown becomes balanced UI/compat HTML, and plain chunks preserve text.
|
||||
- Inline keyboard markup attaches only to the final chunk of a logical view. Reply parameters attach only to the first chunk.
|
||||
- Edit growth sends additional chunks; edit shrink deletes surplus chunks; delete removes every chunk in handle order.
|
||||
- A partial send failure returns the ids already sent. A partial edit failure returns the original surviving ids plus every newly sent id, minus any surplus ids already deleted during shrink. The returned handle therefore remains sufficient for deterministic retry or cleanup.
|
||||
- The API does not participate in assistant preview/final deduplication and does not mutate the Telegram turn queue.
|
||||
- A successful operational send does not imply agent work, create a Pi prompt, or alter terminal status.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Failures record redacted runtime events under a delivery-specific category with operation, scope kind, profile, and failure reason. Diagnostics must not include bot tokens, unrestricted message bodies, callback payload secrets, or raw transport responses.
|
||||
|
||||
A future `getTelegramDeliveryDiagnostics()` is unnecessary for the first slice because callers receive structured results and `/telegram-status` already owns bridge diagnostics. Add a dedicated diagnostics getter only if a real consumer needs registry-level introspection.
|
||||
|
||||
## Security And Non-Goals
|
||||
|
||||
The API does not expose:
|
||||
|
||||
- Bot tokens or raw Telegram clients.
|
||||
- Arbitrary Bot API methods.
|
||||
- A second polling loop.
|
||||
- Cross-profile delivery.
|
||||
- Unrestricted cross-instance targeting.
|
||||
- Session replacement, reload, process launch, or Pi slash-command dispatch.
|
||||
- File/media uploads in the first 0.21 slice.
|
||||
- Captured Pi contexts or mutable queue/session state.
|
||||
|
||||
Extension consumers remain trusted local code, but the contract still preserves product ownership boundaries so accidental misuse cannot silently bypass Threaded Mode routing or target identity.
|
||||
|
||||
## Validation Contract
|
||||
|
||||
The implementation must cover:
|
||||
|
||||
- Classic `instance` and `aggregate` resolution.
|
||||
- Leader own-thread, aggregate, and live-bound explicit target resolution.
|
||||
- Follower assigned-thread and aggregate routing through the leader.
|
||||
- Missing active turns and disconnected runtimes.
|
||||
- Cross-profile, unknown, stale, and unauthorized explicit targets.
|
||||
- Plain, HTML, and Markdown chunking.
|
||||
- Reply-first and keyboard-last chunk placement.
|
||||
- Send/edit growth/edit shrink/delete ordering.
|
||||
- Reload/session-replacement generation invalidation.
|
||||
- In-flight shutdown fencing and redacted diagnostics.
|
||||
- Package-boundary imports with no `/lib` access.
|
||||
|
||||
## Relationship To Activity
|
||||
|
||||
The Activity API builds on this contract rather than duplicating transport. Activity handlers receive a fresh target-aware context whose `send`, `edit`, `delete`, and chat-action methods delegate to the same delivery runtime. The Activity API owns lifecycle normalization; this API owns delivery only.
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# Inbound Handlers
|
||||
|
||||
`pi-telegram` can run ordered inbound handlers before a Telegram turn enters the Pi queue. Inbound handlers are the provider-neutral Telegram → Pi transformation bus for raw text and downloaded media/files. Extensions can also register programmatic inbound handlers with `registerTelegramInboundHandler()`, and voice extensions can register STT providers as a zero-config fallback for Telegram voice/audio files.
|
||||
|
||||
This document is the local inbound adaptation of the portable [Command Template Standard](./command-templates.md). It is also the canonical home for the legacy `attachmentHandlers` compatibility config.
|
||||
|
||||
## Config Shape
|
||||
|
||||
Prefer `inboundHandlers` for new configs:
|
||||
|
||||
```json
|
||||
{
|
||||
"inboundHandlers": [
|
||||
{
|
||||
"type": "text",
|
||||
"template": "/path/to/translate --lang {lang=en} --text \"{text}\""
|
||||
},
|
||||
{
|
||||
"type": "voice",
|
||||
"template": [
|
||||
"/path/to/stt --file {file} --lang {lang=ru}",
|
||||
"/path/to/translate-stdin --lang {lang=en}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"mime": "application/pdf",
|
||||
"template": "/path/to/pdf-to-text --file {file}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Legacy `telegram.json` files may still define `attachmentHandlers` for media/file preprocessing:
|
||||
|
||||
```json
|
||||
{
|
||||
"attachmentHandlers": [
|
||||
{
|
||||
"type": "voice",
|
||||
"template": "/path/to/stt1 --file {file} --lang {lang=ru}"
|
||||
},
|
||||
{
|
||||
"mime": "audio/*",
|
||||
"template": "/path/to/stt2 --file {file} --lang {lang=ru}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
At runtime, `attachmentHandlers` is appended after `inboundHandlers`. Existing configs continue to work, while new configs should use `inboundHandlers`.
|
||||
|
||||
Handlers match by optional `type`, `mime`, or `match`. `mime` and `type` are independent selectors: if `mime` is present, `type` is not required. Wildcards such as `audio/*` or `text/*` are accepted. Each matching handler must provide `template`; a string is one command, and an array is ordered composition. Top-level `args` and `defaults` apply to composed steps unless a step defines private values. The command-template default timeout applies automatically. Use `template: [...]` for composition; the old local `pipe` alias is removed in 0.13.0.
|
||||
|
||||
`defaults` may provide additional placeholder values such as `{lang}` or `{model}`. `args` is only a string-array declaration of supported placeholders; defaults belong in `defaults` or inline placeholders such as `{lang=ru}`. Examples prefer explicit flag-style CLIs such as `--file {file}` and `--lang {lang=ru}` for readability, but positional forms such as `/path/to/stt {file} {lang=ru} {model=voxtral-mini-latest}` are equally valid when the target script supports them.
|
||||
|
||||
## Text Handlers
|
||||
|
||||
`type: "text"` handlers transform raw Telegram text before prompt construction. Raw Telegram text also has synthetic `mime: "text/plain"`, so a handler can match it with `type: "text"`, `mime: "text/plain"`, `mime: "text/*"`, or `match: "text/plain"`. The source text is provided on stdin and as `{text}`. Successful non-empty stdout replaces the current text and is passed to the next matching text handler. Empty stdout, non-zero exit, or handler failure keeps the previous text and records diagnostics.
|
||||
|
||||
Built-in placeholders for text handlers:
|
||||
|
||||
| Placeholder | Value |
|
||||
| --- | --- |
|
||||
| `{text}` | Current text |
|
||||
| `{mime}` | `text/plain` |
|
||||
| `{type}` | `text` |
|
||||
|
||||
## Media/File Handlers
|
||||
|
||||
Media/file handlers keep the legacy attachment-handler behavior: downloaded files are matched by `mime`, `type`, or `match`, then each file runs the first successful matching handler. Downloaded files with `mime: "text/plain"` or any `text/*` MIME type have a built-in fail-open handler that reads UTF-8 content into `[outputs]` when no configured handler produced output. Composition is useful for pipelines such as voice transcription followed by machine translation, so the agent receives translated `[outputs]` instead of the raw STT language.
|
||||
|
||||
Built-in placeholders for media/file handlers:
|
||||
|
||||
| Placeholder | Value |
|
||||
| --- | --- |
|
||||
| `{file}` | Downloaded file path |
|
||||
| `{mime}` | MIME type if known |
|
||||
| `{type}` | Kind: `voice`, `audio`, `document`, or `photo` |
|
||||
| `{text}` | Empty string |
|
||||
|
||||
If a top-level one-step media handler template has no `{file}` placeholder, the downloaded file path is appended as the last command arg as a one-step handler convenience. Composition steps are plain command templates and do not receive implicit file-path args; include `{file}` explicitly where needed.
|
||||
|
||||
## Ordered Fallbacks
|
||||
|
||||
A handler list is ordered. For each downloaded file, matching media/file handlers run in list order and stop after the first successful handler. A composed handler counts as one handler for fallback purposes: if any step fails, the next matching handler is tried.
|
||||
|
||||
If a matching handler fails with a non-zero exit code, the runtime records diagnostics and tries the next matching handler. If every matching handler fails, the attachment remains visible in the prompt as a normal local file reference.
|
||||
|
||||
## Programmatic Inbound Handlers And STT Fallbacks
|
||||
|
||||
Extensions can register programmatic inbound handlers with `registerTelegramInboundHandler(kind, handler)` from `@evandrodevbr/oh-my-pi-telegram/inbound`. This is the code-level counterpart to configured `inboundHandlers`; use it for extension-owned transformations that are not voice-specific.
|
||||
|
||||
Voice extensions can register STT providers with `registerTelegramVoiceTranscriptionProvider()` from `@evandrodevbr/oh-my-pi-telegram/voice`. This is the zero-config extension path for voice/audio input: a companion extension can transcribe Telegram voice notes without requiring the operator to write an `inboundHandlers` command template.
|
||||
|
||||
Priority stays explicit and predictable:
|
||||
|
||||
1. configured `inboundHandlers`
|
||||
2. legacy `attachmentHandlers`
|
||||
3. programmatic `registerTelegramInboundHandler(kind, ...)` handlers
|
||||
4. registered STT providers for `voice`/`audio` files that still have no handler output
|
||||
5. built-in text-file fallback for text attachments
|
||||
|
||||
```ts
|
||||
import { registerTelegramInboundHandler } from "@evandrodevbr/oh-my-pi-telegram/inbound";
|
||||
import { registerTelegramVoiceTranscriptionProvider } from "@evandrodevbr/oh-my-pi-telegram/voice";
|
||||
|
||||
const disposeInbound = registerTelegramInboundHandler(
|
||||
"document",
|
||||
async ({ file }) => {
|
||||
if (!file?.mimeType?.includes("pdf")) return undefined;
|
||||
const text = await extractPdf(file.path);
|
||||
return text || undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const dispose = registerTelegramVoiceTranscriptionProvider(
|
||||
async (file) => {
|
||||
if (file.kind !== "voice" && file.kind !== "audio") return undefined;
|
||||
const result = await transcribe(file.path);
|
||||
return result.text
|
||||
? { text: result.text, language: result.language }
|
||||
: undefined;
|
||||
},
|
||||
{ id: "my-stt" },
|
||||
);
|
||||
```
|
||||
|
||||
A provider can return a plain transcript string, `{ text, language? }`, or `undefined` to pass. Provider output is injected into `[outputs]` exactly like command-template handler output. Programmatic inbound handlers and STT providers are fallbacks only; they do not override operator-configured inbound handlers.
|
||||
|
||||
If several programmatic inbound handlers are registered for a kind, they are tried in registration order; the first non-empty output wins for media files, while text handlers transform text sequentially. If several STT providers are registered, they are tried in registration order. The first provider that returns non-empty text wins. Providers that return `undefined` pass; providers that throw are recorded and the next provider is tried. If none produces text, the voice/audio file remains as a normal attachment reference.
|
||||
|
||||
## Prompt Output
|
||||
|
||||
Local attachments stay in the prompt under `[attachments] <directory>` with relative file entries. Successful media/file handler stdout is added under `[outputs]`. For composed media/file handlers, each step receives the previous step's stdout on stdin by default, and stdout from the last successful step is used as the handler output. Empty output and failed handler output are omitted from the prompt text.
|
||||
|
||||
Text handler output replaces the prompt text directly and is not duplicated under `[outputs]`.
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Extension Locks Standard
|
||||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
`locks.json` is a shared registry for singleton pi extensions.
|
||||
|
||||
Path:
|
||||
|
||||
```text
|
||||
~/.pi/agent/locks.json
|
||||
```
|
||||
|
||||
## Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"@scope/pi-singleton": {
|
||||
"pid": 2590864,
|
||||
"cwd": "/home/user/project"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Top-level keys are extension identities. Values are JSON objects owned by that extension.
|
||||
|
||||
## Identity key
|
||||
|
||||
Use the most stable available identity:
|
||||
|
||||
1. `package.json/name` for npm-style pi packages
|
||||
2. Directory name when the extension entrypoint is `index.ts` but there is no package name
|
||||
3. File basename when the extension is a single file
|
||||
|
||||
For npm-style package extensions, the canonical value is the `package.json` `name`. Implementations may keep that value as a small local constant when it is clearer than runtime package introspection. The fallback rules are only for unpackaged extensions.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
extensions/pi-singleton/package.json name=@scope/pi-singleton -> @scope/pi-singleton
|
||||
extensions/pi-singleton/index.ts without package.json -> pi-singleton
|
||||
extensions/pi-singleton.ts -> pi-singleton
|
||||
```
|
||||
|
||||
## Required fields
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 2590864
|
||||
}
|
||||
```
|
||||
|
||||
`pid` is the process that currently owns the singleton runtime. `cwd` should be stored when ownership is tied to a pi session directory.
|
||||
|
||||
During a user-initiated start/connect event, an extension should:
|
||||
|
||||
1. Read its lock entry
|
||||
2. If `pid` is stale, replace the entry
|
||||
3. If `pid` and `cwd` match the current pi instance, refresh or keep the entry
|
||||
4. If a live polling owner exists, ask interactively whether to move singleton ownership here
|
||||
|
||||
## Acquisition timing
|
||||
|
||||
Lock writes must be caused by an explicit user-initiated runtime event, such as a start/connect command or a confirmed takeover prompt.
|
||||
|
||||
Extension initialization and session-start hooks may read `locks.json`, update local status, install ownership watchers, and resume local work when the existing lock already points at the current `pid`/`cwd`. After a full process restart, a session-start hook may replace a stale lock from the same `cwd` to restore explicitly requested ownership. They must not create ownership from an inactive lock, take over a live polling owner, or replace a stale lock from another directory by themselves. Such locks should stay visible as state until the user runs the start/connect command. Session replacement should suspend local runtime work and ownership watchers without releasing the lock, so the next session in the same `pid`/`cwd` can resume from explicit ownership.
|
||||
|
||||
## Optional fields
|
||||
|
||||
Extensions may add compact fields when useful:
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 2590864,
|
||||
"cwd": "/repo/project",
|
||||
"mode": "connected",
|
||||
"updatedAt": "2026-04-28T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Do not print optional fields in normal UI unless they help the user act.
|
||||
|
||||
## Ownership rules
|
||||
|
||||
- One top-level key per singleton extension
|
||||
- An extension may only mutate its own key
|
||||
- Other keys must be preserved exactly
|
||||
- If `cwd` is present, active-here ownership means both `pid` and `cwd` match the current pi instance
|
||||
- Human-readable diagnostics should say `active here`, `active elsewhere`, or `stale`
|
||||
- Debug data belongs in `locks.json`, not in normal status output
|
||||
|
||||
## Runtime status
|
||||
|
||||
Singleton extensions with footer/status presence should expose quiet but explicit local state:
|
||||
|
||||
- `off` when this pi instance does not own the singleton runtime
|
||||
- `on` when this pi instance owns the runtime but has no pending runtime detail to show
|
||||
- `[16:32:39]` when the runtime owns scheduled work and can show the next countdown
|
||||
|
||||
Extensions may prefix those states with their own compact name, such as `wakeup off` or `telegram on`.
|
||||
|
||||
## Interactive takeover
|
||||
|
||||
Start/connect commands should make singleton moves easy:
|
||||
|
||||
1. If no live owner exists, take ownership without an extra prompt
|
||||
2. If a live polling owner exists, ask whether to move singleton ownership to this pi instance
|
||||
3. On confirmation, write the current `{ "pid": ..., "cwd": ... }` to this extension's key in `locks.json`
|
||||
4. The previous owner must notice that `locks.json` no longer points at its own `pid`/`cwd` and stop singleton-owned work such as polling/watchers without deleting the new lock or unrelated session-local queues
|
||||
|
||||
Takeover prompts should use the extension name as the dialog title, then the question, a blank line, and source/target lines:
|
||||
|
||||
```text
|
||||
pi-singleton
|
||||
move singleton lock here?
|
||||
|
||||
from: pid 2590864, cwd /old
|
||||
to: /new
|
||||
```
|
||||
|
||||
Avoid repeating the extension name in the body. Color is encouraged: extension title/name accent, question warning, `from:`/`to:` muted.
|
||||
|
||||
The previous owner may use `fs.watch`, mtime polling, or an existing status/timer tick. Long-lived watchers should compare against a snapshotted `pid`/`cwd` identity rather than a live pi context object, because session replacement such as `/new` makes captured contexts stale. The important contract is graceful singleton-runtime shutdown after ownership mismatch while session-local state that does not require polling remains owned by its original instance.
|
||||
|
||||
## Reset
|
||||
|
||||
Delete `~/.pi/agent/locks.json` to reset singleton runtime ownership for all participating extensions without deleting their configuration files.
|
||||
|
||||
## Atomicity
|
||||
|
||||
Every ownership mutation runs inside a short cross-process transaction acquired through exclusive file creation. The transaction covers the complete registry read/check/write sequence, so concurrent processes cannot both win an ordinary acquisition or stale-overwrite unrelated extension/profile keys. The registry payload itself still commits through same-directory temp-file replacement.
|
||||
|
||||
A guard is written completely to a private staged file and published with one exclusive hard-link operation, so no live empty/partially written generation becomes recoverable by age. A crashed transaction owner leaves a complete guard that a later process recovers only when the recorded owner PID is no longer alive. Recovery contenders serialize through a second atomically published guard so one process cannot rename a newer live transaction generation. Cleanup compares the exact random guard generation before unlinking. Malformed guards, unsupported atomic publication, and unverifiable recovery fail closed after bounded contention rather than risking concurrent registry mutation.
|
||||
|
||||
Transactional reads treat only a missing registry as empty. Read, parse, and shape failures abort without replacing the existing file or erasing unrelated ownership keys.
|
||||
|
||||
Each runtime retains the profile key plus exact owner identity it acquired. New leaders receive a collision-resistant epoch independent from heartbeat timestamps and a monotonic same-process runtime generation. Refresh, release, and thread-state snapshot publication commit only while the profile key, PID, cwd, instance id, epoch, and runtime generation still match. Snapshot rename runs inside the same lock-registry transaction guard as its final owner check. Forced replacement additionally requires the exact previously observed owner; same-process lifecycle handoff permits only a newer runtime generation to replace an older one. A delayed old runtime cannot reverse that transition, publish prepared thread state, or reinterpret its retained owner token under another profile key.
|
||||
|
||||
Direct Bot API authority follows this exact ownership check. Accepted local queue work may continue through Pi after ownership moves, but stale preview/final/menu/file mutations fail closed instead of bypassing the replacement transport owner. Follower recovery retries registration while the exact external owner remains live and promotes only after an election transaction confirms that the observed owner still remains stale or that no owner appeared after an inactive observation; IPC unreachability alone never authorizes takeover. Simultaneous election losers re-register with the winner using their carried exact thread target.
|
||||
|
||||
The ownership heartbeat starts immediately after acquisition, before binding handoff or slow leader startup. It remains active through provisioning/server/polling startup; startup errors release the exact acquired lock after cleanup, and ownership loss during startup stops the partial runtime and returns failure rather than announcing leadership. Topic provisioning also stamps the acquired epoch and rechecks it before and after every persistence and Bot API boundary, including `epoch → undefined` loss.
|
||||
|
||||
## Migration
|
||||
|
||||
Migrations from legacy lock files or legacy keys should be one-off cleanup work. Runtime ownership should read and write only `locks.json` under the canonical identity key.
|
||||
@@ -0,0 +1,434 @@
|
||||
# Telegram Multi-Instance Bus Architecture
|
||||
|
||||
## Status
|
||||
|
||||
Implemented for paired bots where Telegram exposes private-chat Threaded Mode. Classic/private-chat mode remains the default whenever Telegram threads are unavailable, and live Telegram client smoke remains the release gate for client-visible thread UX.
|
||||
|
||||
This document uses **thread** as the canonical product term because Telegram clients present the tabbed UI as threads. The Bot API calls the underlying primitive a `Topic` / `ForumTopic`, but project language follows user-perceived client reality rather than API naming. Use **topic** only when discussing Bot API method names, service-message names, or transport-level evidence. User/operator UX and product docs should say thread.
|
||||
|
||||
This document supersedes the narrower API-topic framing. Telegram threads are a UI/routing substrate, but the deeper design problem is multi-instance coordination: one bot token has one Telegram API update bus, while multiple live Pi agent instances may want to expose their own Telegram workspace through that bus.
|
||||
|
||||
Named bot profiles are optional and orthogonal to this design. The ordinary unnamed profile keeps the existing setup, connect, lock, state, log, Unix socket, and Windows named-pipe paths. When operators configure additional profiles, each profile is an independent bot runtime with its own lock key, observable state, thread ownership, and leader/follower IPC endpoints; leader election and follower routing never cross profile boundaries.
|
||||
|
||||
## Problem
|
||||
|
||||
Classic `pi-telegram` mode binds one private Telegram DM to one live Pi instance through one bot token and one singleton polling owner. The lock currently answers: "which Pi instance owns Telegram control/polling?"
|
||||
|
||||
That model is safe, but it leaves concurrency on the table:
|
||||
|
||||
- Only one live Pi instance can receive Telegram updates for a bot token.
|
||||
- Moving `/telegram-connect` changes the active Telegram control owner instead of letting several instances coexist.
|
||||
- Multiple projects, tmux panes, remote workers, or long-running Pi instances require separate bot tokens or manual ownership switching.
|
||||
- A thread workspace is only useful if it routes to a live agent instance, not to a dead session record that can no longer answer.
|
||||
|
||||
Telegram itself has one relevant constraint: for a bot token, `getUpdates` must be owned by one poller. The architecture must embrace that by electing one local Telegram bus leader and routing work to follower instances.
|
||||
|
||||
## Goal
|
||||
|
||||
Support a Threaded Mode multi-instance runtime where:
|
||||
|
||||
```text
|
||||
one bot token -> one local Pi organism -> ephemeral bus leader -> many live Pi instances -> many Telegram targets
|
||||
```
|
||||
|
||||
The leader is a temporary transport role, not the ontological owner of the system. A terminal-visible Pi instance may become the initial leader because it is the operator's visible harness, while additional terminal-visible Pi instances can explicitly register as followers through `/telegram-connect` and one of them can later take over bus leadership if the leader exits.
|
||||
|
||||
A practical Telegram UI can then use threads:
|
||||
|
||||
```text
|
||||
one private bot chat -> one thread per live Pi instance
|
||||
```
|
||||
|
||||
The operator experience:
|
||||
|
||||
1. Start one Pi instance; it becomes the Telegram bus leader and polls Telegram.
|
||||
2. Start another Pi instance with `pi-telegram` and run `/telegram-connect`; the follower registers instead of fighting for `getUpdates`.
|
||||
3. The leader provisions or reuses a Telegram thread target for that instance.
|
||||
4. Messages, callbacks, reactions, files, voice, previews, and menus in that target route to the owning live Pi instance.
|
||||
5. If the leader exits, remaining followers elect/promote a new leader, which resumes polling and keeps the registered target routes alive where possible.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not let more than one process call `getUpdates` for the same bot token.
|
||||
- Do not treat Telegram as a raw terminal, PTY, or process supervisor.
|
||||
- Do not couple the first design to Pi sessions if live instance ownership is the better runtime truth.
|
||||
- Do not expose arbitrary group participants to prompts, controls, or artifacts.
|
||||
- Do not require Threaded Mode for classic private-chat users; classic private-chat mode remains valid and should not receive slot/thread-name guidance.
|
||||
- Do not implement leader election through unsafe lock stealing without heartbeats or stale-owner checks.
|
||||
|
||||
## Terms
|
||||
|
||||
- `Telegram bus`: The singleton local capability to poll Telegram updates and send Telegram API calls for one bot token.
|
||||
- `Leader`: The live Pi instance that currently owns the Telegram bus and calls `getUpdates`; this is an ephemeral role transferable after stale heartbeat detection.
|
||||
- `Follower`: A live Pi instance that wants Telegram presence but routes Telegram API access through the leader.
|
||||
- `Bus lifecycle`: Transient recovery state only. Stable identity is the bus role (`leader` / `follower`); lifecycle surfaces exceptional handoff states such as `electing`, not duplicate roles with labels like `leader-active`.
|
||||
- `Agent instance`: A running Pi process/session with its own extension state, queue, active turn, model, tools, and lifecycle hooks.
|
||||
- `Telegram target`: The concrete Telegram destination for an instance, represented as `{ chatId, threadId? }`.
|
||||
- `Thread target`: A Telegram UI thread destination, represented as `{ chatId, threadId: message_thread_id }` over Bot API topic transport.
|
||||
- `Classic target`: The existing private-chat target, represented as `{ chatId: allowedUserId }`.
|
||||
|
||||
## Core Shift
|
||||
|
||||
In Threaded Mode, the lock means "this instance is the current Telegram bus leader" rather than "this instance is the only usable Telegram extension".
|
||||
|
||||
Classic lock meaning:
|
||||
|
||||
```text
|
||||
locks.json / @evandrodevbr/oh-my-pi-telegram -> polling/control owner
|
||||
```
|
||||
|
||||
Threaded Mode meaning:
|
||||
|
||||
```text
|
||||
locks.json / @evandrodevbr/oh-my-pi-telegram -> bus leader identity + heartbeat
|
||||
```
|
||||
|
||||
Followers do not poll. They register with the leader and receive routed inbound updates from it. Followers still own their local queue, active-turn state, previews, final delivery planning, model switches, and Pi lifecycle. The leader owns only Telegram transport and update fanout. Pi session replacement (`new`) changes follower agent context, not bus membership: a registered follower preserves its registration and refreshes the live context instead of disconnecting. The Telegram bus belongs to the local set of cooperating visible Pi instances rather than to the first terminal session forever: if the visible terminal leader exits, a live registered follower can take over leadership.
|
||||
|
||||
## Target Abstraction
|
||||
|
||||
The bridge uses a first-class target abstraction:
|
||||
|
||||
```ts
|
||||
type TelegramTarget = {
|
||||
chatId: number;
|
||||
threadId?: number;
|
||||
};
|
||||
```
|
||||
|
||||
Private-chat mode uses `{ chatId: allowedUserId }`.
|
||||
|
||||
Threaded Mode uses `{ chatId: privateChatId, threadId: messageThreadId }` over Bot API topic transport.
|
||||
|
||||
Every session/instance-scoped path carries or preserves a target:
|
||||
|
||||
- Inbound update routing.
|
||||
- Queue item identity.
|
||||
- Active turn state.
|
||||
- Preview draft state.
|
||||
- Final replies and reply deduplication.
|
||||
- Voice and attachment uploads.
|
||||
- Menu/status/settings/queue/section messages.
|
||||
- Button callback ownership.
|
||||
- Reactions.
|
||||
- Typing/record-voice chat actions.
|
||||
- Direct local/TUI Telegram delivery.
|
||||
|
||||
## Binding Model
|
||||
|
||||
A thread maps to a currently running Pi instance, not to a historical session file. `instanceId` is the live routing owner, while `instanceProfileKey` is a reuse hint for reclaiming a compatible current thread across process replacement.
|
||||
|
||||
```text
|
||||
runtime owner: live instance id
|
||||
reuse hint: cwd/profile/user-chosen alias/session id when available
|
||||
```
|
||||
|
||||
This keeps thread liveness honest: if an instance is registered, there is a live owner to answer. It also avoids coupling `/new`, compaction, and session-file internals to Telegram routing. Restarted projects may still reclaim previous current bindings through profile-aware reuse when that does not conflict with live ownership.
|
||||
|
||||
## Instance Identity
|
||||
|
||||
A registered instance exposes:
|
||||
|
||||
```json
|
||||
{
|
||||
"instanceId": "uuid-or-runtime-id",
|
||||
"pid": 12345,
|
||||
"cwd": "/home/user/project",
|
||||
"startedAt": "2026-05-20T10:00:00.000Z",
|
||||
"owner": { "kind": "leader", "cwd": "/home/user/project" },
|
||||
"threadName": "<valid-instance-identity>",
|
||||
"target": { "chatId": 123456789, "threadId": 42 },
|
||||
"status": "idle|active|queued|compacting|disconnected",
|
||||
"lastHeartbeatAt": "2026-05-20T10:00:05.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
`instanceId` is liveness identity. `owner` is explicit current binding identity (`leader`, `manual-follower`, or `pending-topic`). Internal compatibility keys may be derived, but `state.json` should not hide ownership direction inside legacy string keys. `threadName` is the user-facing instance-thread name: it drives Telegram UI thread naming and the Telegram-originated prompt identity label. Fresh threads receive a baked compact thread name from the assigned slot's curated palette; bare slot labels are fallback state only, and role/cwd seeds never replace the thread label.
|
||||
|
||||
## Leader Election
|
||||
|
||||
Leader election is heartbeat-gated and lock-backed:
|
||||
|
||||
1. On startup, read the Telegram lock.
|
||||
2. If no leader exists, acquire leadership and start polling.
|
||||
3. If a live leader exists, register as follower.
|
||||
4. If the leader heartbeat is stale, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
|
||||
5. If several followers detect stale leadership, atomic compare/write lock acquisition ensures only one becomes leader.
|
||||
|
||||
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. After the grace window they promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact live target is absent from persisted bindings, the leader recovers it instead of creating another Telegram thread and keeps its carried slot only when that slot is free. Every successful reuse refreshes the binding timestamp. The leader does not restore persisted followers into the live registry speculatively: after a bounded re-registration grace window it removes older follower records that still lack a live bus owner from current `state.json`, while preserving a recently refreshed binding across the brief registry gap caused by follower session replacement and never deleting Telegram tabs. This preserves real thread bindings through transient reload gaps without allowing historical records or competing pollers to masquerade as live state.
|
||||
|
||||
## Leader/Follower Communication
|
||||
|
||||
Implemented transport:
|
||||
|
||||
### Local IPC endpoint under agent temp dir
|
||||
|
||||
Leader opens a local Node `net` endpoint: a Unix-domain socket under the agent temp directory on Unix-like platforms, or a deterministic Windows named pipe (`\\.\pipe\pi-telegram-...`) on native Windows. On Unix, each server listens on a private generation socket and atomically publishes the stable profile path as a relative symlink; delayed shutdown closes only its private path and cannot remove a replacement generation's link. Followers register, heartbeat, and exchange routed events. The transport boundary owns endpoint derivation, socket-vs-pipe detection, bounded operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, and request-scoped transport events. Follower registration uses a longer registration-specific response timeout than ordinary heartbeat/forwarding calls because the leader may need to provision a Telegram thread before it can return the assigned target; timing out that handshake leaves a visible tab with no follower heartbeat. Keep this handshake to the true critical path: create/reuse the target, persist the live binding, and return it. Connected notices and replaced-thread reconciliation cleanup are non-critical and should run after registration so a follower becomes routable before Telegram client/server UI convergence work finishes.
|
||||
|
||||
Pros:
|
||||
|
||||
- Natural request/response for sending Telegram API calls through the leader.
|
||||
- Can route inbound updates to followers while preserving one poller.
|
||||
- Good fit for live process membership.
|
||||
|
||||
Cons:
|
||||
|
||||
- Adds IPC lifecycle and security concerns.
|
||||
- Cross-machine workers need tunneling or a different transport.
|
||||
|
||||
Alternative transports such as file-backed mailboxes or an external daemon remain out of the current product boundary. Local IPC is the default internal bus while the public design stays compatible with a future daemon if deployment needs outgrow one host.
|
||||
|
||||
## Native Windows Smoke Plan
|
||||
|
||||
Native Windows support should not require WSL. The baseline transport uses Windows named pipes for leader/follower IPC, but live verification still needs an operator with a native Windows Pi install.
|
||||
|
||||
Manual smoke checklist:
|
||||
|
||||
1. Enable Telegram private-chat Threaded Mode for the paired bot.
|
||||
2. Start Pi in one Windows terminal and run `/telegram-connect`; verify it becomes the leader and gets a named Telegram thread.
|
||||
3. Start Pi in a second Windows terminal and run `/telegram-connect`; verify it registers as follower rather than offering takeover, creates/uses its assigned thread, terminal status shows `<ThreadName> Follower` while idle, and a follower prompt flips it to `<ThreadName> Active` while work is running.
|
||||
4. From the follower thread, send a prompt that requests inline buttons; tap a button and verify the follow-up prompt queues in the follower instance.
|
||||
5. From the follower thread, request a voice reply and/or attachment; verify upload routes through the leader transport into the follower thread.
|
||||
6. Close the follower terminal; verify heartbeat pruning, disconnected notice, and cleanup behavior match Unix-like behavior.
|
||||
7. Reload the leader and verify status/debug output does not expose raw pipe internals except in explicit diagnostics.
|
||||
|
||||
If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs._prev.jsonl`. Debug status prints local leader/follower endpoints with their active transport kind (`pipe` or `socket`), while the runtime log records request-scoped transport failures with envelope kind, request id, retry attempt, endpoint, and classified IPC error. Reloads preserve the prior JSONL log as `logs._prev.jsonl` so the evidence that caused the reload is not immediately overwritten.
|
||||
|
||||
### Native Windows Assumption Audit
|
||||
|
||||
Current portability audit:
|
||||
|
||||
- Local bus transport: adapted. Unix-like platforms use filesystem socket paths; native Windows uses named pipes so no POSIX socket pathname is required.
|
||||
- Bus endpoint permissions: Unix sockets/directories use `chmod`; Windows named-pipe endpoints skip POSIX chmod/unlink path handling because the pipe is not a filesystem node.
|
||||
- Shared lock/config/state/temp files: path construction uses `path.join`/`path.resolve` under the Pi agent directory. File permission calls remain best-effort private-mode hardening; native Windows may emulate POSIX modes, so broad Windows ACL auditing is outside this extension's current local-bus baseline.
|
||||
- Process liveness: lock ownership uses `process.kill(pid, 0)`, which Node supports on Windows for existence checks. Cross-user permission failures are treated as alive, matching Unix semantics.
|
||||
- Shell/provider commands: outbound handler command templates remain operator-configured and platform-dependent; Threaded Mode bus portability does not guarantee every configured STT/TTS/shell provider is Windows-native.
|
||||
- Manual follower identity: process ids are used as local liveness/profile hints only, not cross-machine identifiers.
|
||||
|
||||
Remaining risk is live native Windows behavior: named-pipe creation/connect timing, antivirus/firewall/ACL interference, and provider command availability need operator smoke evidence.
|
||||
|
||||
## Telegram Thread UX
|
||||
|
||||
In Telegram private-chat Threaded Mode:
|
||||
|
||||
- The private bot chat is a tabbed instance workspace, not a classic `General + threads` forum.
|
||||
- `All` is an aggregate view, not a process launcher. Explicit new instances use live Pi follower registration: the operator starts Pi in a terminal and runs `/telegram-connect`; owner-created empty threads are observed but not treated as a Pi instance until the user chooses a route or restore action.
|
||||
- The leader proactively creates or reclaims its own thread on startup/activation when Threaded Mode is available, so the visible leader has the same two-way binding as followers.
|
||||
- **Unbound thread detection**: when the owner writes in an unknown `message_thread_id`, the bridge checks effective Threaded Mode state. If the current leader has no active bound thread, that new thread is reclaimed for the leader and the prompt is served locally. Otherwise the bridge preserves the prompt in that Telegram thread and shows a target-thread chooser; explicit routing may later close/delete only extra confirmed source threads through `thread-reconciler`.
|
||||
- Unknown later threads and threadless prompt messages are not silently routed to the leader and never launch hidden Pi processes. The default and only operator path for a new visible instance is starting a visible second Pi process and letting it register as follower through `/telegram-connect`. Manual follower registration creates a fresh visible thread unless the same explicit binding identity already has a live binding; old offline/failed records may point at closed/deleted Telegram tabs and are not silently claimed.
|
||||
- Thread lifecycle service messages (`forum_topic_created`, `forum_topic_closed`, `forum_topic_reopened`, deletion/stale send errors) update observations and binding state. Closed/deleted leader or follower threads can be reclaimed or recreated deliberately. Leader startup also probes reused own threads with a non-visible chat action; if Telegram reports the thread closed/deleted, the binding is marked stale and a fresh leader thread is created. Unknown `forum_topic_created` service events are observation-only and are not destructive cleanup proof.
|
||||
- Bidirectional binding is a core UX requirement, not an implementation detail: Pi instances actively advertise/remember their thread identity, while the bot observes Telegram-client thread state and reflects it back into instance state. This keeps the system responsive, recognizable, and controllable even when the operator closes tabs, writes from `All`, or a follower later becomes leader.
|
||||
|
||||
In Telegram private-chat Threaded Mode:
|
||||
|
||||
- The private bot DM becomes the operator's multi-instance dashboard.
|
||||
- Each live bound instance gets one visible thread.
|
||||
- Each instance has a durable single-letter slot (`A`-`Z`) assigned by the extension and a bridge-authored `threadName`.
|
||||
- New slots advance through the alphabet and wrap after `Z` only to a free slot, intentionally capping concurrent visible instances to the alphabet without duplicating occupied letters. The compact `bot.lastSlot` cursor persists while its binding remains live/recovering, including true `Z → A` wraparound. When post-grace follower compaction removes the cursor's binding, that same reconciliation pass realigns it to the newest-created remaining live binding; pending provisions and reservations still block collisions. Other explicit thread deletion paths may preserve the cursor to continue ring sequence.
|
||||
- A follower that later becomes leader keeps its existing slot and thread name; leadership changes are transport role changes, not identity resets.
|
||||
- Instance-thread names are short and recognizable. Default provisioning chooses one baked 4-6 letter single-word Latin thread name from the assigned slot's five-name palette using provisioning timestamp entropy and creates the Telegram thread with that title immediately. The slot remains internal ordering metadata and is not redundantly included in the thread name. Bare slot titles are fallback/legacy state only; do not prompt agents to self-name and do not expose a rename tool. Existing human-named threads are preserved across reloads and leadership changes when they remain the current live binding. If reload creates a new runtime instance while the previous leader thread is still alive, the new leader should take the next free slot instead of reusing the old slot immediately.
|
||||
- A thread-local `/start` opens that instance's menu.
|
||||
- Prompts typed in a thread route to the owning instance.
|
||||
- Replies, previews, files, voice, and buttons stay in that thread.
|
||||
- Queue controls and reactions affect only that instance target.
|
||||
- Telegram's native `…typing` indicator for real agent work is sent to that instance thread and mirrored to `All`; `All` is the aggregate surface and should show activity when any bound instance is running a Telegram turn, local prompt, or autonomous continuation. Terminal `Active` remains Telegram-turn-specific. Startup/connect/reload/recovery must not send activity by themselves.
|
||||
- If the instance disconnects, the leader can post/update a compact status: `Instance offline`.
|
||||
- If the same live binding identity returns, it can reclaim the thread and post a compact reconnect status.
|
||||
|
||||
## Bot API Evidence For Private-Chat Threaded Mode
|
||||
|
||||
The local Bot API reference in [`../.agents/skills/telegram-bot/api.md`](../.agents/skills/telegram-bot/api.md) supports private bot Threaded Mode through bot capability fields and thread-target transport:
|
||||
|
||||
- `User` returned by `getMe` can include `has_topics_enabled` and `allows_users_to_create_topics`; these are the private-chat Threaded Mode capability fields and are the startup/runtime probe source for this extension.
|
||||
- `createForumTopic` works in a private chat with a user and returns a `ForumTopic`, so the returned `message_thread_id` is persistable as an instance thread target.
|
||||
- Private-thread management uses Bot API methods such as `editForumTopic`, `closeForumTopic`, `reopenForumTopic`, `deleteForumTopic`, and related unpin methods. Thread-unavailable errors from these methods are degradation evidence when Threaded Mode is disabled or unavailable for the bot.
|
||||
- `Message` exposes `message_thread_id` and `is_topic_message`; an incoming private-chat message with `message_thread_id` is a live Threaded Mode observation and can trigger progressive upgrade.
|
||||
- Topic lifecycle service messages include `forum_topic_created`, `forum_topic_edited`, `forum_topic_closed`, `forum_topic_reopened`, `general_forum_topic_hidden`, and `general_forum_topic_unhidden`.
|
||||
- `message_thread_id` is supported by the send/upload methods the bridge uses or may need: `sendMessage`, `sendPhoto`, `sendDocument`, `sendVoice`, `sendMediaGroup`, `sendSticker`, `sendRichMessage`, `sendMessageDraft`, `sendRichMessageDraft`, and `sendChatAction`.
|
||||
|
||||
Non-goal: group detection is not the control-plane model for this extension. Threaded Mode lives in the private bot chat, so startup and runtime switching must not depend on group chat metadata or group admin capability fields.
|
||||
|
||||
Remaining live-verification points:
|
||||
|
||||
- Whether callback query messages always carry `message_thread_id` in private bot threads, or whether generated button callbacks must rely on stored message id -> target ownership.
|
||||
- Whether message-reaction updates carry thread identity in the current Bot API shape. The reference exposes chat id and message id for reactions, so routing may need stored message ownership.
|
||||
- Whether every rich draft/final/upload/chat-action path behaves identically in Telegram clients when `message_thread_id` is supplied.
|
||||
|
||||
Implemented behavior stays evidence-gated: when Telegram client or Bot API behavior differs from the contract above, capture a minimized fixture or documented client caveat before changing routing.
|
||||
|
||||
## Inbound Routing
|
||||
|
||||
The leader polls all updates for the bot token. It classifies each update into a target key:
|
||||
|
||||
```text
|
||||
targetKey = chatId + ':' + (threadId ?? 'private')
|
||||
```
|
||||
|
||||
Then it dispatches:
|
||||
|
||||
- If target belongs to the leader instance, handle locally.
|
||||
- If target belongs to a follower, forward the normalized update/event to that follower.
|
||||
- If target is unknown but authorized and setup allows provisioning, offer or create a binding.
|
||||
- If target is unknown or unauthorized, ignore or send a safe denial.
|
||||
|
||||
Follower instances receive normalized events, not raw Telegram transport internals where possible. The follower still runs the same queue/routing logic, but Telegram API calls go back through the leader transport port.
|
||||
|
||||
## Outbound Routing
|
||||
|
||||
Followers do not call Telegram Bot API directly for routed Telegram work. Instead, they call a leader-owned transport port:
|
||||
|
||||
```text
|
||||
follower reply/preview/upload/chat-action/download/callback-answer -> leader IPC -> Telegram API
|
||||
```
|
||||
|
||||
This preserves one API bus and one set of rate-limit/retry diagnostics. The current local bus routes JSON calls, multipart uploads, chat actions, message deletes, callback/guest answers, and file downloads through the leader when a follower is registered.
|
||||
|
||||
Every outbound request carries its target. The leader injects `message_thread_id` when `target.threadId` exists.
|
||||
|
||||
## Leader/Follower Capability Parity Matrix
|
||||
|
||||
Threaded Mode should make follower threads behave like normal Telegram instance surfaces, with the leader acting only as transport owner. Any feature in the matrix below that works for the leader must either work for followers or have an explicit documented exception.
|
||||
|
||||
| Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
|
||||
| Queued-message removal reactions | 👎/👻/💔/💩/🗑 removes pending prompt/media turn | Same reaction on a queued follower prompt removes that follower's pending turn before dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
|
||||
| Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
|
||||
| Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
|
||||
| Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
|
||||
| Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
|
||||
| Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
|
||||
| Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
|
||||
| Native activity status | `sendChatAction(typing)` renders Telegram's native `…typing` indicator in the assigned thread and mirrors aggregate `All` for every agent run, including local and autonomous work; terminal status changes to `active` only for Telegram-owned turns | Followers route one thread action and one aggregate action through leader transport for any agent run while retaining their stable terminal role during non-Telegram work | Agent lifecycle targets the active Telegram turn when present and otherwise the instance binding; one keyed loop avoids duplicate aggregate sends/rate-limit pressure | Agent-start binding, typing-loop, target-routing, and terminal-status regressions |
|
||||
| Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
|
||||
| `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
|
||||
| Follower reconnect | Existing leader binding is reused only when still usable | Same-process `/new` or `/reload` suspends the old follower socket/context and automatically re-registers the new session to the exact prior target; explicit reconnect to a genuinely closed/stale Telegram tab still recreates a visible thread before success | A short-lived handoff carries the assigned target across session replacement, and the leader transfers that binding to the new runtime instance id by stable manual-follower identity; stale Bot API errors remain the proof for fresh provisioning | Session handoff/refresh, leader binding-transfer, persisted leader-reload reuse, and stale-target replacement regressions |
|
||||
| Unbound thread reroute/restore | New unbound thread can route to a live instance or replace the selected instance thread | Same chooser exposes all currently live bus leader/follower targets; restore is offered from concrete unbound threads, not historical snapshots | Live bus roster plus active target bindings define the selectable set; history/state snapshots are not authority | Routing chooser regressions for live target filtering and restore rows |
|
||||
| Status/menu diagnostics | Status reflects leader role, queue, and target | Follower status reflects follower role, thread name, queue, and bus health | Status is local runtime truth plus bus registration state, not leader queue state | Status and bus diagnostics tests |
|
||||
|
||||
## Queue And State Scoping
|
||||
|
||||
Each instance owns its own queue and active turn state. The leader does not become a central queue scheduler for all agents; that would be a separate daemon-mode architecture.
|
||||
|
||||
Target-scoped state requirements:
|
||||
|
||||
- Queue item identity includes target plus source message id.
|
||||
- Reply deduplication is keyed by target, not just chat id.
|
||||
- Preview draft state is keyed by target.
|
||||
- Button callbacks store target and owning instance id.
|
||||
- Reactions resolve to target/instance before mutation.
|
||||
- Attachments generated by a follower are uploaded by the leader into the follower's target.
|
||||
|
||||
## Configuration
|
||||
|
||||
There is no public `telegram.json` switch for the bus. Telegram private-chat Threaded Mode is the runtime switch: when Telegram exposes threads for the bot, the bridge enables the local bus; when Telegram runs as an ordinary private DM, the bridge uses classic private-chat flow as the base mode.
|
||||
|
||||
Typical config remains just bot identity and authorization:
|
||||
|
||||
```json
|
||||
{
|
||||
"botToken": "...",
|
||||
"allowedUserId": 123456789
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers. During a live downgrade from Threaded Mode, the current bus leader becomes the classic polling owner after two 2.5-second capability-monitor probes and followers disconnect; if classic polling restore fails transiently, later monitor ticks retry the restore instead of allowing a follower takeover. Followers must not turn the downgrade into a takeover while active thread bindings prove the singleton owner was already established by the bus leader.
|
||||
- Telegram private-chat Threaded Mode enables local leader/follower behavior automatically. The leader owns `getUpdates`; registered followers route Telegram API work through the leader. `/telegram-connect` registers as follower when a live leader exists and does not offer manual takeover in that state. The TUI status bar reports `telegram leader` or `telegram follower` while idle so transport role is visible without opening diagnostics, and both roles switch to `active`/`compacting` processing labels during local Telegram work. Follower registration is unique by live profile/target: a reload or session replacement must replace stale registry entries rather than leaving multiple routable ids for one Telegram thread, and fallback target ownership must not classify leader records as followers.
|
||||
- The thread chat is the owner's private bot DM (`allowedUserId`); no `topics.chatId` config is needed. Thread names are assigned by the bridge from a baked compact per-slot palette. There is no agent-facing `telegram_rename_thread` tool and no separate user-facing slash command for manual thread renames.
|
||||
- Thread reuse is extension-owned through current live binding identity; there is no separate `topics` config surface in the active private-chat thread model. Manual followers use instance-scoped internal keys by default so multiple terminal processes in the same cwd can receive separate threads.
|
||||
- Thread cleanup remains conservative and centralized: destructive close/delete actions are planned and applied through `thread-reconciler` with proof-before-delete checks, leader-epoch fencing, and retry-preserving failure semantics.
|
||||
- `allowedUserId` remains the primary authorization boundary unless explicit allowlists are added. Forum/group membership alone must not grant control.
|
||||
|
||||
## Runtime State
|
||||
|
||||
Current state under the agent dir:
|
||||
|
||||
- `locks.json`: current bus leader identity, capability secret, heartbeat, and cleanup fencing epoch. The local bus endpoint is derived from the agent directory by default; legacy `busSocketPath` entries are tolerated but are not required.
|
||||
- `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. Every process on one Telegram profile reads this shared path, but only the active transport lock owner may persist it; followers become writers only after promotion. Status-only persistence refreshes disk-backed bindings before serialization so an already-loaded stale view cannot erase newer leader records. It mirrors `/telegram-status`-style projections: top-level `bot` stores bot-wide capability state such as `threadMode: "unknown" | "enabled" | "disabled"`, `runtime` identifies leader/follower role and process status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors status/debug signals, `threads` stores current routeable bindings, `bot.lastSlot` stores the compact slot cursor used when all current threads are gone, and `reservations` records short-lived slot collision guards.
|
||||
- Local bus endpoints: Unix-like platforms expose stable `tmp/telegram/bus.sock` and `tmp/telegram/followers/*` symlinks backed by private generation sockets; native Windows uses deterministic named pipes under `\\.\pipe\pi-telegram-...`. These are transient IPC endpoints, not durable routing state.
|
||||
|
||||
The bridge must not keep a durable `telegram-targets.json` history. Stale/offline/failed thread entries are reconciliation observations, not reusable source-of-truth state; persisting them increases collision risk. `sync` is event-driven assumption reconciliation, not full Telegram bot-state mirroring, because Bot API does not expose a complete topic/thread listing surface. `state.json` therefore exists for extension+bot observability, diagnostics, startup hints, and explaining reconciliation decisions; live bus/runtime state remains authoritative for routing and provisioning. Non-current routeable thread bindings are pruned during load/persist; old session records must not be retained just to compute the next slot because `bot.lastSlot` is the only durable cursor. Previous-process leader bindings are treated as occupied TTL-bounded reservations until Telegram confirms deletion: reload/startup may close/delete/probe the old thread, known reservations are retried proactively on leader startup, and if Telegram still accepts the old thread id, the new leader should provision the next free slot (`B`, `C`, …) rather than creating a duplicate same-letter tab or blocking startup on Telegram UI convergence. Routing must use live current threads/follower registry, never reservations. The bus leader provisions its own thread during bus startup/connect and provisions follower threads on `follower.register`; registered followers also live in the leader's in-memory registry and communicate over the local bus socket. The live follower registry can resolve a follower by exact `{ chatId, threadId? }`; the leader uses that target ownership to forward message and edited-message updates to followers, and the follower receiver accepts those updates in addition to callbacks and reactions. Terminal status and `[telegram|thread:name]` resolve the matching current-instance identity through the same target-aware path, preferring registered local metadata over stale shared bindings. Media album grouping and split-text coalescing keys include the thread target, queue reaction mutations can scope by chat/thread to avoid cross-target message-id collisions, active-turn target is exposed for lifecycle cleanup and local direct-tool defaults, transport reply dedup is chat/thread-scoped, stored menu state is keyed by chat/message so callback state lookup cannot collide across chats, and generated button turns plus section prompt/open actions preserve the callback thread target. `telegram_message` and immediate `telegram_attach` delivery can also carry an explicit `thread_id` with `chat_id`; when a follower is registered, their default direct-tool target is the assigned thread target and the bus-aware API runtime routes the send through the leader instead of calling Bot API transport locally.
|
||||
|
||||
All files containing routing, chat ids, thread ids, or process details use private permissions and represent current state rather than historical target caches.
|
||||
|
||||
## Failure Modes
|
||||
|
||||
### Leader exits cleanly
|
||||
|
||||
- Leader stops polling and marks itself offline.
|
||||
- Followers detect missing heartbeat.
|
||||
- One follower promotes itself after jitter/tie-break.
|
||||
- New leader resumes `getUpdates` from the persisted offset if safe.
|
||||
|
||||
### Leader crashes
|
||||
|
||||
- Followers detect stale heartbeat.
|
||||
- One follower promotes itself.
|
||||
- Some updates may be delayed or skipped depending on offset persistence; dispatcher design must define this explicitly.
|
||||
|
||||
### Follower heartbeat is missed
|
||||
|
||||
- Leader prunes the follower from the live registry after missed heartbeats, but heartbeat pruning is only immediate liveness bookkeeping.
|
||||
- A missed heartbeat does not delete, close, mark offline, or send a disconnected notice for the follower's Telegram thread binding because the common cause may be leader reload, IPC handoff, or transient reconnect rather than a dead follower.
|
||||
- Followers treat rejected/missing heartbeat acknowledgements as registration loss: retain the last known target locally, clear registered truth, try to re-register with the current leader, wait a short leader-reload grace window, and retry. They promote only after the exact leader lease becomes stale or inactive; a live owner with an unreachable endpoint leaves the follower disconnected/retrying rather than creating a competing poller.
|
||||
- After leader reload grace expires, persisted manual-follower records without a matching live registry owner leave current `state.json`; this compaction is non-destructive and does not close/delete the Telegram thread.
|
||||
- Every successful follower registration/re-registration sends a compact connected notice in the assigned thread so recovery and reconnection are visible during live testing without confusing heartbeat suspicion with real disconnect.
|
||||
- Successful forwarded updates and follower-originated API calls refresh liveness, so active followers are not pruned only because the interval heartbeat tick lagged.
|
||||
- Destructive follower thread teardown belongs to explicit `/telegram-disconnect` or confirmed reconciliation actions, not generic heartbeat pruning.
|
||||
- Historical offline thread entries are not retained as reusable source of truth.
|
||||
|
||||
### Thread is deleted
|
||||
|
||||
- Target mapping becomes stale.
|
||||
- On next outbound failure or reconnect, leader records a diagnostic.
|
||||
- Depending on policy, recreate a thread or mark the instance as needing operator action.
|
||||
|
||||
### Split brain
|
||||
|
||||
- Two leaders calling `getUpdates` is the main safety failure.
|
||||
- Lock heartbeat/takeover must be atomic enough to prevent this under normal local concurrency.
|
||||
- If Telegram returns API conflict behavior, record diagnostics and force one leader to step down.
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
- Messages, edits, callbacks, and reactions check user authorization, not only chat/thread membership.
|
||||
- Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary.
|
||||
- Follower Bot API proxying is allowlisted and target-scoped where applicable so a follower can reply in its assigned thread without gaining arbitrary bot control.
|
||||
- Button and section callbacks verify authorized `from.id` and owning target/instance.
|
||||
- Generated artifacts stay scoped to the owning thread after leader failover.
|
||||
- Diagnostics redact bot tokens, large prompts, attachment paths, and handler output.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] The lock semantics are redesigned as Telegram bus leadership with heartbeat and stale takeover rules.
|
||||
- [x] A first-class `TelegramTarget` can represent classic private chats and thread destinations.
|
||||
- [x] The bridge can run in classic mode with unchanged private-chat behavior.
|
||||
- [x] A live Pi instance can register as a follower when another live instance is leader.
|
||||
- [x] Followers never call `getUpdates` for the shared bot token.
|
||||
- [x] Followers can send replies, previews, voice, attachments, menus, and chat actions through the leader transport.
|
||||
- [x] The leader can route inbound messages, edits, callbacks, reactions, media groups, and split text to the owning instance by target. Message/edit and callback/reaction routing is authorized by user id; media and split-text coalescing are target-keyed locally.
|
||||
- [x] Telegram UI thread targets can be provisioned as current state bindings; stale/deleted Telegram observations and explicit disconnect/reconciliation actions remove unusable bindings instead of persisting reusable history, while heartbeat-pruned followers preserve their thread binding so transient leader reload/reconnect gaps do not create split-brain Telegram UX.
|
||||
- [x] Leader failover promotes one remaining follower without creating competing pollers.
|
||||
- [x] Queue, active turn, preview, reply deduplication, menu, section, button, reaction, and attachment state are scoped by instance/target. Queue reaction mutations and transport reply dedup are chat/thread-scoped; active-turn target is available to lifecycle cleanup; stored menu state is chat/message-keyed; generated button turns and section prompt/open actions preserve callback targets; preview and attachment delivery already carry targets.
|
||||
- [x] Authorization prevents arbitrary Telegram users or local processes from controlling agents or receiving artifacts.
|
||||
|
||||
Live client and native Windows evidence gates are tracked in `BACKLOG.md`; this architecture document records the implemented contract, not the active smoke queue.
|
||||
|
||||
## Implemented Shape
|
||||
|
||||
- Bus semantics are the feature frame: Telegram threads are one Telegram UI substrate for a local multi-instance bus.
|
||||
- `TelegramTarget` and target-key helpers represent classic private chats and thread destinations.
|
||||
- Outbound ports, previews, replies, voice, attachments, chat actions, menus, sections, buttons, queue mutations, and direct local delivery carry target metadata where needed.
|
||||
- The transport lock distinguishes live bus leadership from ordinary classic ownership through heartbeat, leader epoch, and stale takeover rules.
|
||||
- The leader records live follower registration, heartbeat, thread identity, slot, and target mapping; followers do not poll `getUpdates`.
|
||||
- Local IPC is the default internal bus. Registered followers receive normalized inbound updates and send allowlisted, target-scoped Bot API calls through the leader.
|
||||
- Thread targets are current-state bindings, not durable historical delivery addresses. Stale/offline/failed entries are reconciliation evidence only.
|
||||
- Failover promotes a remaining follower after dead or clean-disconnected leaders without creating competing pollers; follower heartbeat recovery owns re-register → grace → promotion while preserving thread bindings across transient leader reload gaps.
|
||||
- Thread cleanup is centralized in `thread-reconciler`, fails closed without a leader epoch while leadership exists, revalidates that epoch immediately before every close/delete call and local cleanup-state mutation, and requires confirmed delete/stale evidence before state is marked deleted.
|
||||
- Stable docs/UI now describe classic mode, opt-in Threaded Mode, manual follower registration, status/diagnostics, unbound-thread reroute/restore UX, and operator recovery boundaries.
|
||||
|
||||
## Evidence Gates
|
||||
|
||||
Open live/client questions belong in `BACKLOG.md` until confirmed. Capture confirmed quirks as focused regressions or documented caveats, not broad speculative matrices.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Outbound Handlers
|
||||
|
||||
`pi-telegram` maps hidden assistant-authored HTML comments to Telegram-native outbound actions.
|
||||
|
||||
Normal Telegram-turn replies are intentionally prompt-driven: the agent writes Markdown plus small hidden top-level blocks, and the bridge performs transport after `agent_end`. `telegram_voice` and `telegram_button` are not Pi tools. For local/TUI-initiated work where the user explicitly asks to send something to Telegram, the bridge also exposes direct tools: `telegram_message` for Markdown text and `telegram_attach` for file delivery when no Telegram turn is active. In classic mode, direct local/TUI delivery requires this Pi instance to own `/telegram-connect`; in Threaded Mode, a registered follower may route direct-tool sends through the leader-owned bus transport. If neither condition is true, the tools fail instead of bypassing singleton ownership. Explicit thread delivery uses `chat_id` plus `thread_id`; registered followers default to their assigned thread target. Outbound behavior combines assistant prompt markup, text command-template handlers, registered voice synthesis providers, generated artifacts, direct Telegram tools, and reply delivery. Direct `telegram_message` text is planned through the same reply markup path, so embedded top-level `telegram_button` comments become buttons attached to that text message.
|
||||
|
||||
Text handlers use the portable [Command Template Standard](./command-templates.md). Programmatic outbound handlers use `registerTelegramOutboundHandler(kind, handler)`. Voice replies can use configured command-template handlers or the provider API described in [Voice Integration](./voice.md).
|
||||
|
||||
## Standard
|
||||
|
||||
An outbound handler is selected by `type`. Text replies and assistant markup map to handler types:
|
||||
|
||||
| Source | Handler | Action |
|
||||
| --- | --- | --- |
|
||||
| Final text | `outboundHandlers[type=text]` | Transform before render |
|
||||
| `telegram_voice` | Voice pipeline | OGG/Opus `sendVoice` |
|
||||
| `telegram_button` | Built-in | Attach inline button |
|
||||
|
||||
The voice pipeline is detailed below: configured `type: "voice"` handlers first, then programmatic handlers, then registered synthesis providers.
|
||||
|
||||
### Guest Mode media boundary
|
||||
|
||||
A Guest Mode reply is one `answerGuestQuery` call carrying exactly one `InlineQueryResult`; it is not a normal chat target and cannot receive `sendDocument`/`sendVoice` multipart uploads through sentinel `chatId: 0`. `telegram_attach` therefore admits at most one file during a guest turn and rejects additional files before queue mutation.
|
||||
|
||||
Telegram accepts public URLs or existing Telegram `file_id` values for inline media results, but pi-telegram does not publish local artifacts to external hosting. A local guest document, photo, MP3 audio, or OGG/OPUS voice therefore uses a temporary upload to the paired owner's bot chat, extraction of the returned `file_id`, one cached-media guest answer, and best-effort deletion of the staging message. That message can briefly appear or notify the owner. One guest query can carry only one media item, and its answer text must fit the media caption limit rather than a separate full Rich Markdown message.
|
||||
|
||||
Configured text handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. Use `template: [...]` for composition; the old local `pipe` alias is removed in 0.13.0.
|
||||
|
||||
## Text Handler Config
|
||||
|
||||
`type: "text"` handlers transform final text replies before native Rich Markdown delivery. The source text is provided on stdin and as `{text}`. Successful non-empty stdout replaces the current text. Empty stdout or handler failure keeps the previous text and records diagnostics.
|
||||
|
||||
This is ideal for machine translation, tone normalization, redaction, glossary expansion, compliance footers, or any other final text rewrite that should be configured outside the agent prompt. Text handlers run before native Rich Markdown delivery, so a Markdown reply remains Markdown input to the handler. They also run when the bridge finalizes an already streamed rich preview; in that path Telegram can briefly show a pre-transform preview before the final Rich Message reply replaces it. Inline buttons are built as reply markup: visible button labels pass through the same text handler, while callback data and callback prompts remain unchanged.
|
||||
|
||||
Simple machine-translation handler with explicit text placeholder:
|
||||
|
||||
```json
|
||||
{
|
||||
"outboundHandlers": [
|
||||
{
|
||||
"type": "text",
|
||||
"template": "/path/to/translate --lang {lang=ru} --text \"{text}\""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Stdin-based or subagent-backed translation can omit `{text}` from the template because the bridge also provides the source reply on stdin:
|
||||
|
||||
```json
|
||||
{
|
||||
"outboundHandlers": [
|
||||
{
|
||||
"type": "text",
|
||||
"template": "/path/to/translate-stdin --lang {lang=ru}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A text handler should preserve the full message unless shortening is intentional; for translation prompts, explicitly ask the tool to keep Markdown, line breaks, and details unchanged.
|
||||
|
||||
## Voice Delivery Priority
|
||||
|
||||
Voice replies use one fallback pipeline:
|
||||
|
||||
1. configured `outboundHandlers` with `type: "voice"` in `telegram.json` order
|
||||
2. programmatic `registerTelegramOutboundHandler("voice", ...)` handlers
|
||||
3. registered voice synthesis providers from `@evandrodevbr/oh-my-pi-telegram/voice`
|
||||
|
||||
This makes provider extensions a zero-config convenience without overriding explicit operator-owned `telegram.json` handlers. If several synthesis providers are registered, they are tried in registration order; the first provider that returns a valid `.ogg`/`.opus` artifact handles the reply. Returning `undefined` passes to the next provider, while thrown errors or invalid files are recorded before the next fallback is tried.
|
||||
|
||||
## Voice Synthesis Provider API
|
||||
|
||||
Voice replies can be delivered by synthesis providers registered through `@evandrodevbr/oh-my-pi-telegram/voice`:
|
||||
|
||||
```ts
|
||||
import { registerTelegramVoiceSynthesisProvider } from "@evandrodevbr/oh-my-pi-telegram/voice";
|
||||
|
||||
const dispose = registerTelegramVoiceSynthesisProvider(
|
||||
async (text, options) => {
|
||||
const audioPath = await synthesizeToOggOpus(text, options);
|
||||
return { audioPath, transcriptText: text };
|
||||
},
|
||||
{ id: "my-extension/tts" },
|
||||
);
|
||||
```
|
||||
|
||||
Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. Stable registrations pass a durable `id`; omitted ids remain a compatibility path for older providers. Providers own translation, TTS, speech rewriting, transcript choice, and OGG/Opus conversion. The bridge validates that the returned file ends in `.ogg` or `.opus`, sends it through Telegram `sendVoice`, and falls back to planned text if delivery fails before any visible text was delivered. Providers run after configured and programmatic voice handlers in the priority chain above.
|
||||
|
||||
## Voice Markup
|
||||
|
||||
Assistant replies can include a hidden voice block:
|
||||
|
||||
```md
|
||||
Full text answer stays here.
|
||||
|
||||
<!-- telegram_voice lang=ru rate=+30%
|
||||
Text to synthesize as a Telegram voice message.
|
||||
-->
|
||||
|
||||
<!-- telegram_voice lang=ru rate=+30% text="Short spoken companion summary." -->
|
||||
|
||||
<!-- telegram_voice: Short spoken companion summary. -->
|
||||
```
|
||||
|
||||
The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` block to a provider call, generates one file per block, and sends each file as an independent Telegram-native voice message. The opening `<!-- telegram_voice` marker must start at column zero on a top-level line outside fenced code, quotes, and lists; otherwise it is rendered as literal Markdown. Body-form comments leave the opening line unclosed until the body-ending `-->`; closed heads can use `text="..."` for explicit one-line spoken text.
|
||||
|
||||
## Buttons Markup
|
||||
|
||||
Assistant replies can include independent button blocks. The prompt is sent back to Pi when the user taps the button; use the colon shorthand when the prompt should equal the label, `prompt="..."` for one-line prompts, or the body form for multiline prompts:
|
||||
|
||||
```md
|
||||
I can continue.
|
||||
|
||||
<!-- telegram_button label=Continue prompt="Continue with the current plan." -->
|
||||
|
||||
<!-- telegram_button label="Show risks"
|
||||
List the main risks first.
|
||||
-->
|
||||
|
||||
<!-- telegram_button: Done -->
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `telegram_button: Label` creates one independent label-only button row whose prompt equals the label.
|
||||
- `telegram_button label="Label" prompt="Prompt"` creates one independent button row whose prompt is the `prompt` attribute.
|
||||
- `telegram_button label="Label"` with a body creates one independent button row whose prompt is the block body.
|
||||
- The opening `<!-- telegram_button` marker must start at column zero on a top-level line outside fenced code, quotes, lists, and indented examples; otherwise it is literal Markdown.
|
||||
- Keep the canonical body form as `<!-- telegram_button label="Label"` + body + `-->`; closed heads must use `prompt="..."` or the colon shorthand to create a button.
|
||||
- Use one block per button; this mirrors HTML's singular element model and avoids a nested button DSL inside comments.
|
||||
- Button actions are stored in memory with short `callback_data`; Telegram never sees the full prompt in the button payload.
|
||||
|
||||
Do not emit JSON button specs, inline comments after visible text, standalone button actions, or tool calls for ordinary Telegram-turn buttons. The agent writes Markdown plus hidden comments; the bridge strips comments and attaches Telegram `reply_markup` after `agent_end`. For local/TUI-originated direct sends, put the same Markdown and `telegram_button` comments in `telegram_message(text)`.
|
||||
|
||||
Buttons are built in and do not need a command template because they are pure Telegram reply markup plus callback routing.
|
||||
|
||||
## Prompt Contract
|
||||
|
||||
The extension injects prompt guidance by context:
|
||||
|
||||
- If no bot token is configured, no Telegram bridge suffix is injected.
|
||||
- For ordinary local/TUI prompts, the agent only sees compact direct-delivery guidance: use `telegram_attach` or `telegram_message` when the user asks to send something to Telegram, and otherwise answer locally as normal.
|
||||
- For Telegram-originated turns, the prompt carries only minimal mobile/reply/file guidance; agents can call `telegram_help()` for full voice/button/direct-delivery/Threaded Mode/formatting/debug details.
|
||||
- For Telegram-originated turns, write the full technical answer as normal Markdown.
|
||||
- Add `telegram_voice` when a Telegram-native voice message is useful; use body text, `text="..."`, or colon shorthand for the text to synthesize. A companion summary is optional, no specific summary format is required.
|
||||
- Add `telegram_button: ...` when label equals prompt, `telegram_button label="..." prompt="..."` for one-line prompts, or `telegram_button label="..."` with a body for multiline prompts. If the reply contains only button/voice comment blocks, add a short visible marker (for example `Choose one:`) before them so Telegram always has a visible parent message for attachment.
|
||||
- For ordinary Telegram-turn replies, do not call transport tools for voice or buttons; the bridge owns delivery, while registered voice synthesis providers own TTS and OGG/Opus conversion. For explicit local/TUI direct sends, `telegram_message` may include top-level `telegram_button` comments in its Markdown text because those buttons are attached to that text message.
|
||||
- Never send buttons without visible parent text. If the answer would contain only hidden comments, add a compact line such as `Choose one:` first.
|
||||
|
||||
This keeps the agent focused on semantics, prevents Telegram action syntax from leaking into normal local replies, and lets the bridge handle low-latency Telegram adaptation.
|
||||
@@ -0,0 +1,578 @@
|
||||
# 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.
|
||||
|
||||
The 0.21 Activity surface requires Pi `0.80.6` or newer. This minimum belongs to the package peer contract because `agent_settled` provides the only safe terminal boundary after retries, compaction, and queued continuations.
|
||||
|
||||
## Package Entrypoints
|
||||
|
||||
Preferred public imports:
|
||||
|
||||
```ts
|
||||
import telegram from "@evandrodevbr/oh-my-pi-telegram";
|
||||
import { registerTelegramSection } from "@evandrodevbr/oh-my-pi-telegram/sections";
|
||||
import { registerTelegramStatusLineProvider } from "@evandrodevbr/oh-my-pi-telegram/status";
|
||||
import { registerTelegramUpdateHandler } from "@evandrodevbr/oh-my-pi-telegram/updates";
|
||||
import { registerTelegramCommand } from "@evandrodevbr/oh-my-pi-telegram/commands";
|
||||
import { registerTelegramInboundHandler } from "@evandrodevbr/oh-my-pi-telegram/inbound";
|
||||
import { registerTelegramOutboundHandler } from "@evandrodevbr/oh-my-pi-telegram/outbound";
|
||||
import { sendTelegramView } from "@evandrodevbr/oh-my-pi-telegram/delivery";
|
||||
import { registerTelegramActivityHandler } from "@evandrodevbr/oh-my-pi-telegram/activity";
|
||||
import {
|
||||
registerTelegramVoiceSynthesisProvider,
|
||||
registerTelegramVoiceTranscriptionProvider,
|
||||
} from "@evandrodevbr/oh-my-pi-telegram/voice";
|
||||
```
|
||||
|
||||
`0.12.0` intentionally removes the published `@evandrodevbr/oh-my-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](#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 or session browser. A Telegram destination follows its assigned Pi instance and sends prompts into that instance's currently active session; it is not permanently bound to one session identity. Compaction operates on the current session, while new-session, resume, fork, tree navigation, session switching, TUI transcript clearing, and arbitrary slash-command dispatch stay out of the stable Telegram API unless Pi exposes safe public extension hooks 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](./outbound.md) 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:
|
||||
|
||||
```ts
|
||||
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.
|
||||
- `sendTelegramView()` / `editTelegramView()` / `deleteTelegramView()` / `sendTelegramChatAction()`
|
||||
- Identity: current process-local delivery generation and returned logical message handles.
|
||||
- Purpose: ownership-gated operational delivery to active-turn, current-instance, aggregate, or explicitly authorized targets.
|
||||
- `registerTelegramActivityHandler()`
|
||||
- Identity: required stable `id`.
|
||||
- Purpose: normalized non-blocking Pi lifecycle activity with source identity and fresh delivery contexts.
|
||||
- `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.
|
||||
|
||||
## Capability Inventory And Gap Classification
|
||||
|
||||
This inventory maps the complete bridge capability plane to its supported extension boundary. A capability may stay private deliberately; completeness means every meaningful capability has an explicit classification, not that every internal helper becomes public.
|
||||
|
||||
### Public now
|
||||
|
||||
- **Extension loading:** The root export loads the bridge as a Pi extension; companion code uses the domain subpaths below rather than importing root runtime state.
|
||||
- **Telegram commands:** `/commands` registers explicit Telegram-native slash commands with scoped reply and prompt-enqueue ports.
|
||||
- **Managed menu and Settings UI:** `/sections` registers main-menu views, Settings rows, namespaced callbacks, standalone callback-scoped messages, and diagnostics.
|
||||
- **Programmatic target-aware delivery:** `/delivery` sends, edits, deletes, and signals operational views against active-turn, current-instance, aggregate, or explicitly authorized targets through generation-bound logical handles.
|
||||
- **Normalized lifecycle activity:** `/activity` registers non-blocking extension handlers for evidence-based run/source identity, assistant prose/reasoning segments, executed tools, compaction, and settlement with fresh delivery contexts.
|
||||
- **Compact status projection:** `/status` contributes synchronous status rows to the `/start` menu.
|
||||
- **Raw inbound update interception:** `/updates` observes or consumes Telegram updates before default routing and remains the low-level callback escape hatch.
|
||||
- **Inbound content transforms:** `/inbound` adds Telegram-to-Pi text/media preprocessing after operator-configured handlers.
|
||||
- **Final outbound transforms:** `/outbound` adds final text/voice transformation fallbacks and exposes redacted runtime-event recording.
|
||||
- **Voice providers and policy helpers:** `/voice` registers STT/TTS providers and exposes stable voice-mode projections.
|
||||
- **Keyboard structures:** `/keyboard` exposes inline-keyboard structural types without transport operations.
|
||||
- **Agent-callable delivery:** `telegram_message` and `telegram_attach` provide ownership-gated text/file delivery to the agent, not a JavaScript companion-extension transport API.
|
||||
|
||||
### Intentionally private
|
||||
|
||||
- **Credentials and raw transport:** Bot tokens, Telegram clients, unrestricted Bot API calls, polling, retry loops, offsets, and multipart/download internals stay private so companions cannot bypass pairing or open a second transport owner.
|
||||
- **Ownership and multi-instance routing:** Locks, named-profile isolation, leader/follower IPC, authorization capabilities, thread provisioning, reconciliation, and sync assumptions stay bridge-owned.
|
||||
- **Session and queue coordination:** Active turns, queue lanes, dispatch gates, abort/compaction state, previews, final-reply ordering, and session-bound context stores stay internal invariants rather than shared mutable extension state. Telegram targets identify Pi instances and resolve their current session at dispatch time; they are not public handles to immutable session files.
|
||||
- **Core operator UI:** Built-in menus, model/thinking controls, rendering internals, prompt-template expansion, status diagnostics assembly, and thread naming remain core policy; companions extend them through commands, sections, and status providers.
|
||||
- **Raw Pi runtime objects:** Companion APIs never return captured `ExtensionContext`, `ExtensionCommandContext`, session managers, or private session-replacement/runtime handles.
|
||||
|
||||
### Assessed and not required for 0.21
|
||||
|
||||
- **General managed callbacks outside Sections:** The documented issue #126 consumer shape needs interactive Settings toggles, not interactive activity rows. `/sections` already owns stable callback namespacing, callback answers, edits, navigation, and cleanup for those toggles; `/delivery` can render the resulting non-interactive activity views. A second callback registry would duplicate ownership without a proven use case, while `/updates` remains the deliberate low-level escape hatch for consumers that truly need raw callback interception. Revisit only when a public-import-only consumer must generate managed callbacks independently of a registered Section context for arbitrary delivered messages.
|
||||
|
||||
### Explicitly deferred
|
||||
|
||||
- **Programmatic artifact/media delivery:** `telegram_attach` covers agent-authored artifacts, while companion JavaScript has no general file/media send contract. The first 0.21 delivery slice targets operational text/activity views; media should earn a typed extension only from a concrete companion use case.
|
||||
- **General configuration mutation:** Companions own their configuration and Settings state. pi-telegram does not expose unrestricted mutation of `telegram.json`, profile identity, pairing, rendering, queue, or transport settings.
|
||||
- **Process and session control:** Reload, new-session, fork, resume, process launch, and arbitrary Pi slash-command dispatch remain outside the Telegram companion API until Pi exposes safe async extension hooks.
|
||||
|
||||
The 0.21 platform boundary lets a public-import-only consumer own reasoning, intermediate-prose, and tool-row policy while pi-telegram retains target selection, transport, authorization, lifecycle safety, and delivery ordering. Activity-specific examples live in this documentation; the separate [`pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) project remains the maintained companion-extension reference.
|
||||
|
||||
## Commands
|
||||
|
||||
Import from `@evandrodevbr/oh-my-pi-telegram/commands`. This registers Telegram slash commands only; it does not expose Pi slash commands and is unrelated to command-template handlers.
|
||||
|
||||
```ts
|
||||
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 `@evandrodevbr/oh-my-pi-telegram/sections`.
|
||||
|
||||
```ts
|
||||
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](./sections.md).
|
||||
|
||||
## Telegram Delivery API
|
||||
|
||||
Import from `@evandrodevbr/oh-my-pi-telegram/delivery`.
|
||||
|
||||
```ts
|
||||
const sent = await sendTelegramView(
|
||||
{
|
||||
text: "<b>Indexing…</b>",
|
||||
parseMode: "html",
|
||||
},
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
if (!sent.ok) {
|
||||
recordLocalDiagnostic(sent.reason, sent.message);
|
||||
}
|
||||
```
|
||||
|
||||
The delivery runtime resolves its live binding on every call and returns structured failures for unavailable runtimes, missing or unauthorized targets, stale handles, invalid views, and transport failures. A logical handle may represent several chunked Telegram messages; edit and delete reconcile the whole logical view. If send or edit growth fails after materializing messages, the failure carries a valid partial handle for deterministic retry or cleanup. Followers route through the existing leader transport, and reload/session replacement invalidates old handles rather than retaining Pi contexts.
|
||||
|
||||
Full behavior: [Telegram Delivery API](./delivery.md).
|
||||
|
||||
## Telegram Activity API
|
||||
|
||||
Import from `@evandrodevbr/oh-my-pi-telegram/activity`.
|
||||
|
||||
```ts
|
||||
const off = registerTelegramActivityHandler({
|
||||
id: "@scope/activity-view",
|
||||
async handle(event, ctx) {
|
||||
if (event.type !== "tool-start") return;
|
||||
await ctx.send({
|
||||
text: `Tool: ${event.toolName}`,
|
||||
parseMode: "plain",
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Handlers receive ordered normalized events but run outside Pi's critical lifecycle path. Each handler has an isolated asynchronous queue; adjacent high-frequency deltas may coalesce while semantic boundaries remain ordered. Activity contexts choose active-turn delivery for Telegram-owned work and instance delivery for local/autonomous/unknown work, delegating every operation through the current `/delivery` generation.
|
||||
|
||||
Full behavior and consumer policy examples: [Telegram Activity API](./activity.md).
|
||||
|
||||
## Status Lines
|
||||
|
||||
Import from `@evandrodevbr/oh-my-pi-telegram/status`.
|
||||
|
||||
```ts
|
||||
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 `@evandrodevbr/oh-my-pi-telegram/updates`.
|
||||
|
||||
```ts
|
||||
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](./updates.md).
|
||||
|
||||
## Inbound
|
||||
|
||||
Import from `@evandrodevbr/oh-my-pi-telegram/inbound`.
|
||||
|
||||
```ts
|
||||
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](./inbound.md).
|
||||
|
||||
## Outbound
|
||||
|
||||
Import from `@evandrodevbr/oh-my-pi-telegram/outbound`.
|
||||
|
||||
```ts
|
||||
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](./outbound.md).
|
||||
|
||||
## Voice Providers
|
||||
|
||||
Import from `@evandrodevbr/oh-my-pi-telegram/voice`.
|
||||
|
||||
```ts
|
||||
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](./voice.md).
|
||||
|
||||
## Public API Smoke Examples
|
||||
|
||||
Minimal companion-extension examples that import only stable `@evandrodevbr/oh-my-pi-telegram/*` public membranes. Copy one into an extension `index.ts`, load it beside `pi-telegram`, and verify that it starts without importing any `@evandrodevbr/oh-my-pi-telegram/lib/*` implementation path.
|
||||
|
||||
### Extension Sections
|
||||
|
||||
```ts
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { registerTelegramSection } from "@evandrodevbr/oh-my-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
|
||||
|
||||
```ts
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { registerTelegramUpdateHandler } from "@evandrodevbr/oh-my-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
|
||||
|
||||
```ts
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { registerTelegramInboundHandler } from "@evandrodevbr/oh-my-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
|
||||
|
||||
```ts
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { registerTelegramOutboundHandler } from "@evandrodevbr/oh-my-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
|
||||
|
||||
```ts
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
getTelegramVoiceSendTranscript,
|
||||
registerTelegramVoiceSynthesisProvider,
|
||||
registerTelegramVoiceTranscriptionProvider,
|
||||
} from "@evandrodevbr/oh-my-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 public package membranes: `@evandrodevbr/oh-my-pi-telegram`, `/commands`, `/sections`, `/status`, `/delivery`, `/activity`, `/updates`, `/inbound`, `/outbound`, `/voice`, or `/keyboard`.
|
||||
- It does not import `@evandrodevbr/oh-my-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](./callback-namespaces.md).
|
||||
|
||||
## 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`.
|
||||
@@ -0,0 +1,451 @@
|
||||
# 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=@evandrodevbr/oh-my-pi-telegram-extension-demo → @evandrodevbr/oh-my-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
|
||||
|
||||
```ts
|
||||
import { registerTelegramSection } from "@evandrodevbr/oh-my-pi-telegram/sections";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const unregister = registerTelegramSection({
|
||||
id: "@evandrodevbr/oh-my-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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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 `@evandrodevbr/oh-my-pi-telegram/sections`. The function reads from a `globalThis` registry set by `pi-telegram` at startup. In `0.12.0`, package-private `@evandrodevbr/oh-my-pi-telegram/lib/*.ts` deep imports are no longer exported.
|
||||
|
||||
**Relative import (local):** When the extension cannot resolve `@evandrodevbr/oh-my-pi-telegram` as an npm package, use the public API membrane via a relative path:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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 `@evandrodevbr/oh-my-pi-telegram-explorer` often exceed this. `pi-telegram` maps each registered section to a compact numeric token:
|
||||
|
||||
```text
|
||||
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 menu` → `menu:back`
|
||||
- Section sub-view (`ctx.edit()` in handler): `⬆️ Back` → `section:<token>:open`
|
||||
- Settings root (from Settings list): `⬆️ Back` → `settings:list`
|
||||
- Settings sub-view (`ctx.edit()` in settings handler): `⬆️ Back` → `settings: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()`
|
||||
|
||||
```ts
|
||||
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()`
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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`:
|
||||
|
||||
```text
|
||||
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 `@evandrodevbr/oh-my-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:
|
||||
|
||||
```ts
|
||||
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](./callback-namespaces.md): defines `section:` as pi-telegram-owned prefix. Sections use namespaced callbacks but authors never hand-roll them
|
||||
- [Updates](./updates.md): raw update interception for direct Telegram update access. Sections are the structured UI layer above
|
||||
- [Extension Locks](../docs/locks.md) (polling): same identity key rules (`package.json/name` → canonical id)
|
||||
- [Command Templates](./command-templates.md): sections do not execute command templates by default. UI registration + callback routing, not shell execution
|
||||
|
||||
## 15. Demo Extension
|
||||
|
||||
[`@evandrodevbr/oh-my-pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) is the maintained companion-extension reference:
|
||||
|
||||
- Main-menu and Settings surfaces with dynamic labels.
|
||||
- Managed section callbacks, buttons, edits, navigation, and cleanup.
|
||||
- Public `@evandrodevbr/oh-my-pi-telegram/*` imports rather than package-private `/lib` paths.
|
||||
- Independent package and lifecycle ownership outside pi-telegram core.
|
||||
|
||||
Use it as a template for section-based extensions. Activity-specific registration and delivery patterns remain in [Telegram Activity API](./activity.md); pi-telegram does not duplicate the demo as an in-package `examples/` directory.
|
||||
@@ -0,0 +1,241 @@
|
||||
# UI Style Guide
|
||||
|
||||
Small standard for inline buttons, menu rows, state controls, cards, and confirmation dialogs.
|
||||
|
||||
## Principles
|
||||
|
||||
- Keep UI compact and phone-readable.
|
||||
- Put emoji where they help scanning, not everywhere.
|
||||
- Use one strong indicator for current selection; avoid emoji noise on every option.
|
||||
- Match label casing to control role.
|
||||
- Prefer minimal, clear configuration UI over exhaustive explanation.
|
||||
- Preserve domain-owned callback prefixes and behavior in the owning module.
|
||||
|
||||
## Emoji Semantics
|
||||
|
||||
Use emoji as stable semantic markers, not decoration. Emoji carry transportable meaning across command descriptions, inline menu rows, message headings, status copy, and tests. Before adding a new UI emoji, either reuse one below or extend this registry in the same change.
|
||||
|
||||
### Domain Markers
|
||||
|
||||
| Emoji | Meaning | Canonical surfaces | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `🧵` | Telegram/Pi thread routing | Thread chooser headings, unbound-thread warnings, thread lifecycle/status copy | Canonical thread marker. Do not add it to every concrete target button; target buttons use `threadName` or slot fallback. |
|
||||
| `📡` | Telegram transport / bridge connection | Instance connected notices, polling/transport role, bridge online copy | Transport is not thread identity; use `🧵` for thread concepts. |
|
||||
| `📊` | Status / overview | `/status` command description, status cards or status rows | Use for status summaries, not queue priority. |
|
||||
| `🤖` | Model selection | `/model`, model menu headings, model status rows | Keep model-control surfaces visually distinct from thinking. |
|
||||
| `🧠` | Thinking level | `/thinking`, thinking menu headings, thinking status rows | Use only for reasoning/thinking controls. |
|
||||
| `🔢` | Queue list / ordered work | `/queue`, queue menu entrypoints | Queue item rows may also use numeric labels. |
|
||||
| `⏱️` | Queue is ticking / current work is active | Inline main-menu Queue row only | Running-clock queue state: the narrow present moment is being worked now. |
|
||||
| `⏳` | Queue has waiting prompts | Inline main-menu Queue row only | Hourglass queue state: sand above the neck is future work still waiting. |
|
||||
| `⌛` | Queue is empty / standing idle | Inline main-menu Queue row only | Standing hourglass queue state: no future work is waiting above the neck. |
|
||||
| `⚙️` | Settings / configuration | Settings menu headings and Settings navigation rows | Extension-injected rows appear before the built-in `⚙️ Settings` row. |
|
||||
| `🧩` | Extension-provided surface | Extension command examples, extension section examples | Companion extensions may choose their own emoji, but `🧩` means generic extension/plugin. |
|
||||
| `👄` | Voice reply policy | Voice reply settings row and detail card | Not a generic audio attachment marker. |
|
||||
| `🕒` | Time injection / wall-clock context | Time injection settings row and detail card | Clock-face marker with hands; not a generic duration/progress marker. |
|
||||
| `📌` | Proactive push / pinned behavior | Proactive push settings row and detail card | Not generic active/selected state. |
|
||||
| `📎` | Attachment | Attachment summaries, queue rows for attachment-only turns | Not for thread binding. |
|
||||
|
||||
### Command And Control Actions
|
||||
|
||||
| Emoji | Meaning | Canonical surfaces | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `🟢` | Start / active / current positive state | `/start`, active row, current selected option, active `On` toggle | In command context it means “open/start menu”; in state context it means selected/active. |
|
||||
| `🗜` | Compact session | `/compact`, compact confirmation action | Do not use for generic cleanup/delete. |
|
||||
| `⏩` | Force next queued turn | `/next` command and matching menu action | Means skip/advance to next waiting item. |
|
||||
| `▶️` | Continue/resume generation | `/continue` command and matching menu action | Means resume/continue current session flow, not force-next. |
|
||||
| `⏹️` | Abort current Pi work | `/abort` command description | Stops active work but is not a destructive queue clear by itself. |
|
||||
| `🟥` | Stop / abort-and-clear danger | `/stop` command description | Stronger than `⏹️`; use for disruptive stop/clear semantics. |
|
||||
| `🆕` | New session / fresh start | Reserved visible extension command example for `/new`-like flows | Same-thread Telegram `/new` is currently blocked by Pi core API; keep this meaning reserved. |
|
||||
| `🌀` | Refresh | Queue refresh row and future refresh buttons | Re-fetch/re-render current surface, not transport reconnect. |
|
||||
| `↪️` | Reroute to an existing target | Thread chooser buttons that send a captured command/message from one thread to another live thread | Curved arrow means the message arrived here but bends to another target. |
|
||||
| `🔁` | Replace/restore mode | Thread replace/restore chooser entrypoints | Opens a second step for moving a Pi instance binding to the current source thread. |
|
||||
| `➡️` | Choose replacement target | Thread replace/restore target buttons that select which Pi instance should move to the current thread | Use inside the second replace/restore chooser, not for ordinary reroutes. |
|
||||
| `☑️` | Activate / choose this item | Model detail activation action | Positive action; use `🟢 Active` for already-current state. |
|
||||
| `❌` | No / cancel | Confirmation cancel buttons | Use for safe cancellation, not destructive removal. |
|
||||
| `🗑` | Delete / remove | Queue delete actions, destructive confirmations, remove reaction | Use only when something is removed/closed/deleted. |
|
||||
|
||||
### State Indicators And Button Grammars
|
||||
|
||||
| Emoji | Meaning | Canonical surfaces | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `🟢` | Current/active/enabled `On` | Current option in vertical lists, active state rows, active `On` toggle | One strong current marker per option list. |
|
||||
| `🟡` | Active `Off` or elevated/filter state | Active `Off` toggle, Priority/Scoped active tab | Yellow means intentionally not-normal or off/default-caution, not error. |
|
||||
| `🟣` | Normal/default active tab | Normal priority tab, All/default scope tab, active page picker | Use for neutral active tabs. |
|
||||
| `⚫️` | Inactive placeholder | Inactive toggle values and inactive tabs | Keeps row width stable. |
|
||||
| `⬆️` | Navigate upward | `⬆️ Main menu`, `⬆️ Back` | Always first row in submenus. |
|
||||
|
||||
### Queue Reaction Shortcuts
|
||||
|
||||
Queue reactions are shortcut controls for waiting turns. Preserve their semantics across Telegram reactions, queue-menu rows, status previews, and tests.
|
||||
|
||||
| Emoji | Meaning | Canonical surfaces | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `👍` | Promote to priority | Queue reaction shortcut | Normalized from variants like `👍️`. |
|
||||
| `⚡` | Promote to priority / fast lane | Queue reaction shortcut, priority fallback badge | Also used as the default priority badge when no specific priority emoji is stored. |
|
||||
| `❤` / `❤️` | Promote to priority | Queue reaction shortcut | Normalize display consistently where code normalizes reactions. |
|
||||
| `🕊` / `🕊️` | Promote to priority | Queue reaction shortcut | Soft/peaceful promotion gesture. |
|
||||
| `🔥` | Promote to priority | Queue reaction shortcut | Urgent/hot promotion gesture. |
|
||||
| `👎` | Remove waiting turn | Queue reaction shortcut | Removal, not negative feedback to the agent. |
|
||||
| `👻` | Remove waiting turn | Queue reaction shortcut | Disappear/remove metaphor. |
|
||||
| `💔` | Remove waiting turn | Queue reaction shortcut | Removal/cancel metaphor. |
|
||||
| `💩` | Remove waiting turn | Queue reaction shortcut | Removal/reject metaphor. |
|
||||
| `🗑` | Remove/delete waiting turn | Queue reaction shortcut and queue delete UI | Same destructive semantics as delete buttons. |
|
||||
|
||||
### Decorative Or Local-Example Emoji
|
||||
|
||||
Some emoji are intentionally local examples or decorative variants, not global semantics. Empty-queue rotating messages (`🫙`, `🍃`, `🕳`, `🦗`, `🌙`, `🧘`, `🪐`, `🧺`, `🔭`, `🫧`, `🛸`) are copy flavor only and must not become controls. Example extension icons such as `🧪`, `🔧`, and `🗂` are documentation fixtures for companion extensions, not built-in pi-telegram meanings.
|
||||
|
||||
Thread UI rule: when a message heading, chooser, or status line is specifically about Telegram/Pi threads or target thread selection, start the heading with `🧵`. Button labels for concrete thread targets should stay clean (`threadName` or slot fallback) and should not add `🧵` to every target button unless the row would otherwise be ambiguous.
|
||||
|
||||
## Action Buttons
|
||||
|
||||
Action buttons perform an operation.
|
||||
|
||||
Rules:
|
||||
|
||||
- Use an emoji plus capitalized action text.
|
||||
- Prefer direct verb or action noun.
|
||||
- Keep labels short.
|
||||
|
||||
Examples:
|
||||
|
||||
- `🗜 Yes, compact`
|
||||
- `❌ No`
|
||||
- `🗑 Yes, delete`
|
||||
- `☑️ Activate`
|
||||
|
||||
## State & Navigation Buttons
|
||||
|
||||
State buttons show the current state and navigate to a submenu or detail rather than performing an operation directly.
|
||||
|
||||
Rules:
|
||||
|
||||
- Use an emoji that reflects the current state.
|
||||
- Use Capitalized, descriptive state text.
|
||||
- Tapping opens a submenu or returns to the parent list.
|
||||
|
||||
Examples:
|
||||
|
||||
- `🟢 Active` — model detail, navigates back to model list
|
||||
- `📌 Proactive push: On` — settings row, opens the toggle submenu
|
||||
- `👄 Voice reply: Mirror` — settings row, opens the option list
|
||||
|
||||
## Boolean Toggles
|
||||
|
||||
Boolean settings use a horizontal `On` / `Off` pair.
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep the pair in one row: `On` left, `Off` right.
|
||||
- Use Capitalized labels.
|
||||
- Always show an indicator on both buttons to avoid horizontal label shift.
|
||||
- Mark active `On` with `🟢`.
|
||||
- Mark active `Off` with `🟡`.
|
||||
- Mark the inactive value with `⚫️`.
|
||||
|
||||
Examples:
|
||||
|
||||
- `🟢 On` / `⚫️ Off`
|
||||
- `⚫️ On` / `🟡 Off`
|
||||
|
||||
## Horizontal Tabs
|
||||
|
||||
Tabs or small mutually-exclusive scopes use a horizontal row.
|
||||
|
||||
Rules:
|
||||
|
||||
- Use Capitalized labels.
|
||||
- Always show an indicator on every tab to avoid horizontal label shift.
|
||||
- Use active tab color to convey semantics:
|
||||
- `🟣` for the default / normal state (All models, Normal priority).
|
||||
- `🟡` for an elevated or filtered state (Scoped models, Priority).
|
||||
- `🟣` for neutral navigation controls (page picker).
|
||||
- Mark inactive tabs with `⚫️`.
|
||||
|
||||
Examples:
|
||||
|
||||
- `🟡 Scoped` / `⚫️ All`
|
||||
- `⚫️ Priority` / `🟣 Normal`
|
||||
- `1` / `🟣 2` / `3`
|
||||
|
||||
## Vertical Option Lists
|
||||
|
||||
Vertical option lists choose one value from a potentially longer list, for example model selection, thinking level, voice reply mode, or time injection mode.
|
||||
|
||||
Rules:
|
||||
|
||||
- Put each option on its own row.
|
||||
- Mark only the current value with `🟢`.
|
||||
- Leave non-current values without emoji.
|
||||
- Use lowercase labels when the option is a value.
|
||||
|
||||
Examples:
|
||||
|
||||
- `🟢 mirror`
|
||||
- `manual`
|
||||
- `always`
|
||||
|
||||
## Navigation
|
||||
|
||||
Inline submenu navigation is hierarchical.
|
||||
|
||||
Rules:
|
||||
|
||||
- Put the navigation row first.
|
||||
- First-level submenus opened from the main inline menu start with `⬆️ Main menu`.
|
||||
- Deeper submenus start with `⬆️ Back`.
|
||||
- `Main menu` returns to the root inline menu.
|
||||
- `Back` returns one level up, never directly to the root unless the parent is the root.
|
||||
|
||||
Examples:
|
||||
|
||||
- Main menu → Settings: first row is `⬆️ Main menu`.
|
||||
- Settings → Voice reply mode: first row is `⬆️ Back`.
|
||||
|
||||
## Message Cards
|
||||
|
||||
Message cards sent by the bot should start with a strong heading.
|
||||
|
||||
Rules:
|
||||
|
||||
- Start with a bold heading or, for dialogs, a bold question.
|
||||
- Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
|
||||
- Explain what the setting does and what the options mean only as much as needed.
|
||||
- Keep descriptions short and clear.
|
||||
|
||||
Examples:
|
||||
|
||||
```html
|
||||
<b>👄 Voice reply mode:</b> <code>mirror</code>
|
||||
```
|
||||
|
||||
```html
|
||||
<b>Queue</b>
|
||||
```
|
||||
|
||||
## Confirmation Dialogs
|
||||
|
||||
Confirmation dialogs protect risky or disruptive actions.
|
||||
|
||||
Rules:
|
||||
|
||||
- Body text is one bold text-only question.
|
||||
- Do not put emoji in the dialog question.
|
||||
- Do not add explanatory body copy unless the risk cannot be understood from the question and action labels.
|
||||
- Put emoji on the buttons, not in the question.
|
||||
- Preserve dialog-specific button order by intent.
|
||||
|
||||
Example:
|
||||
|
||||
```html
|
||||
<b>Compact session?</b>
|
||||
```
|
||||
|
||||
Buttons:
|
||||
|
||||
- `🗜 Yes, compact`
|
||||
- `❌ No`
|
||||
|
||||
## Callback Ownership
|
||||
|
||||
UI style does not change callback ownership. Callback prefixes remain owned by their feature domain and must be listed in callback namespace documentation when they become public collision risks.
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# Updates
|
||||
|
||||
`updates` owns Telegram update classification, default-routing plans, and the public update-handler registry. The internal `polling` domain owns the actual `getUpdates` loop, offsets, and abort/controller state.
|
||||
|
||||
`pi-telegram` owns a single `getUpdates` long-poll connection per bot. Other pi extensions cannot open a competing polling connection against the same bot — the Telegram Bot API uses a per-bot `offset` cursor, and two loops race each other and lose updates.
|
||||
|
||||
This document describes the registry that lets layered pi extensions running in the same pi process hook into `pi-telegram`'s polling loop and react to inbound Telegram updates **before** `pi-telegram`'s default routing fires.
|
||||
|
||||
It is the runtime counterpart to [Callback Namespaces](./callback-namespaces.md): callback namespaces define how to share `callback_data` cleanly; update handlers define how to observe and optionally short-circuit the dispatch of those updates.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use it when a layered extension needs to:
|
||||
|
||||
- Resolve out-of-band state, for example a `tool_call` approval Promise, the moment a Telegram callback arrives, rather than waiting for the next agent turn.
|
||||
- Suppress `pi-telegram`'s default routing for callbacks owned by the layered extension, so `pi-telegram` does not also forward them as `[callback] <data>` text.
|
||||
- Observe arbitrary update types such as messages, edits, channel posts, or reactions without owning the polling connection.
|
||||
|
||||
If the layered extension only needs to read assistant-visible callbacks, the existing `[callback] <data>` fallback documented in [Callback Namespaces](./callback-namespaces.md) is enough.
|
||||
|
||||
If the extension needs a durable top-level Telegram menu section with managed rendering, callback routing, authorization, and diagnostics, use the higher-level [Telegram Extension Sections](./sections.md) contract instead of a raw update handler.
|
||||
|
||||
## Constraints
|
||||
|
||||
- One bot, one pi process, one `getUpdates` loop. This registry does **not** enable running multiple pi instances against the same bot.
|
||||
- Handlers run in the polling loop. They must return quickly; long awaits delay subsequent updates.
|
||||
- Handler errors are caught and logged silently so polling never breaks. If you need durable error reporting, do it inside your handler.
|
||||
- The registry lives on `globalThis`. Module instance identity is not required, so layered extensions can reach it without importing `@evandrodevbr/oh-my-pi-telegram`.
|
||||
|
||||
## Verdicts
|
||||
|
||||
Each handler returns one of:
|
||||
|
||||
- `"consume"` — `pi-telegram` skips its default routing for this update.
|
||||
- `"pass"` or `void` / `undefined` — `pi-telegram` routes the update normally. Other handlers registered after this one still run for the same update.
|
||||
|
||||
The first handler that returns `"consume"` wins; later handlers are not called for that update.
|
||||
|
||||
## Registering a handler
|
||||
|
||||
Two equivalent paths.
|
||||
|
||||
### Typed import (recommended when you can depend on `@evandrodevbr/oh-my-pi-telegram`)
|
||||
|
||||
```ts
|
||||
import { registerTelegramUpdateHandler } from "@evandrodevbr/oh-my-pi-telegram/updates";
|
||||
|
||||
const off = registerTelegramUpdateHandler(async (update) => {
|
||||
const cb = (update as { callback_query?: { id?: string; data?: string } })
|
||||
.callback_query;
|
||||
if (!cb?.data?.startsWith("myext:")) return "pass";
|
||||
await resolveMyApproval(cb);
|
||||
return "consume";
|
||||
});
|
||||
|
||||
// Later, when your extension shuts down:
|
||||
off();
|
||||
```
|
||||
|
||||
### Zero-coupling globalThis lookup
|
||||
|
||||
When the layered extension prefers no `import` from `@evandrodevbr/oh-my-pi-telegram`, so load order between the two extensions does not matter and either can be installed first, it must implement the **full v1 registry contract**, not just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on whatever object it finds at `globalThis.__piTelegramUpdateHandlerRegistry__`, so a partial object would silently break the first update.
|
||||
|
||||
pi-telegram defensively re-creates the registry if the object on `globalThis` is missing `add` or `dispatch`, validated as `version === 1`, `typeof add === "function"`, and `typeof dispatch === "function"`. Handlers registered against a malformed object are dropped — make sure your bootstrap implements all three fields.
|
||||
|
||||
```ts
|
||||
type PiTelegramVerdict =
|
||||
| "consume"
|
||||
| "pass"
|
||||
| void
|
||||
| Promise<"consume" | "pass" | void>;
|
||||
type PiTelegramUpdateHandler = (update: unknown) => PiTelegramVerdict;
|
||||
|
||||
interface PiTelegramUpdateHandlerRegistry {
|
||||
readonly version: 1;
|
||||
add: (handler: PiTelegramUpdateHandler) => () => void;
|
||||
// Required: pi-telegram's polling loop calls this on every update.
|
||||
dispatch: (update: unknown) => Promise<"consume" | "pass">;
|
||||
}
|
||||
|
||||
const REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
|
||||
|
||||
function getOrCreateRegistry(): PiTelegramUpdateHandlerRegistry {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const existing = g[REGISTRY_KEY] as
|
||||
| PiTelegramUpdateHandlerRegistry
|
||||
| undefined;
|
||||
if (
|
||||
existing &&
|
||||
existing.version === 1 &&
|
||||
typeof existing.add === "function" &&
|
||||
typeof existing.dispatch === "function"
|
||||
) {
|
||||
return existing;
|
||||
}
|
||||
const handlers = new Set<PiTelegramUpdateHandler>();
|
||||
const registry: PiTelegramUpdateHandlerRegistry = {
|
||||
version: 1,
|
||||
add(handler) {
|
||||
handlers.add(handler);
|
||||
return () => handlers.delete(handler);
|
||||
},
|
||||
async dispatch(update) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const result = await handler(update);
|
||||
if (result === "consume") return "consume";
|
||||
} catch {
|
||||
// Never break polling because of a handler error.
|
||||
}
|
||||
}
|
||||
return "pass";
|
||||
},
|
||||
};
|
||||
g[REGISTRY_KEY] = registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
const off = getOrCreateRegistry().add((update) => {
|
||||
/* … */
|
||||
return "pass";
|
||||
});
|
||||
```
|
||||
|
||||
The registry object on `globalThis.__piTelegramUpdateHandlerRegistry__` is versioned (`version: 1`) and stable across pi-telegram releases; future breaking changes will use a new schema version and a new key.
|
||||
|
||||
## Interaction with built-in routing
|
||||
|
||||
`pi-telegram` invokes registered handlers first, then routes the update through its own handlers: commands, app menu, queue menu, model menu, default prompt routing, and callback namespace fallback. If any handler returns `"consume"`, `pi-telegram` skips the rest of routing for that update.
|
||||
|
||||
This means:
|
||||
|
||||
- Extensions can claim callback namespaces that `pi-telegram` would otherwise forward as `[callback] <data>` text.
|
||||
- Extensions can observe updates by always returning `"pass"`.
|
||||
- Extensions must not consume updates that belong to `pi-telegram`'s own prefixes (`compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, `section:`) unless they are deliberately replacing that behavior.
|
||||
|
||||
## Ownership semantics
|
||||
|
||||
The handler registry is ownership-agnostic and does not interact with the `locks.json` singleton lock documented in [Locks](./locks.md). When the locked polling runtime stops `pi-telegram`'s `getUpdates` loop, for example after ownership is moved to another pi process, handlers stop receiving updates because no updates are being fetched. They are not unregistered.
|
||||
|
||||
If a layered extension needs to react to ownership changes, it should observe `pi-telegram` lifecycle events through the standard pi extension hooks rather than through the handler registry.
|
||||
|
||||
## Not a multiplexer
|
||||
|
||||
This registry does not multiplex one bot across multiple pi processes, and it does not bypass Telegram's single-polling-connection-per-bot constraint. To run multiple pi instances on Telegram, give each instance its own bot and its own `~/.pi/agent` directory; the registry is for layered extensions inside **one** pi process.
|
||||
|
||||
## Relationship to extension sections
|
||||
|
||||
Update handlers are the raw update primitive. Extension sections are the structured Telegram UI layer above that primitive.
|
||||
|
||||
Use update handlers for immediate update interception, custom callback namespaces, out-of-band Promise resolution, and update types that should not become a Telegram menu surface.
|
||||
|
||||
Use extension sections when the desired behavior is a menu-integrated UI: `render(ctx)`, managed callback dispatch, safe runtime ports, stale-callback handling, and diagnostics owned by `pi-telegram`.
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
# Voice Integration
|
||||
|
||||
Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by voice provider extensions. This is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot polling loop.
|
||||
|
||||
## Overview
|
||||
|
||||
1. **Inbound:** A voice message arrives via Telegram. Inbound handlers transcribe it to text.
|
||||
2. **Processing:** The transcription becomes the agent prompt. The bridge tags the turn if it originated from voice.
|
||||
3. **Outbound:** If voice replies are enabled, the agent's text response is converted to voice and sent back. No text draft appears in Telegram during generation.
|
||||
|
||||
The bridge owns Telegram transport, queue integration, reply-mode policy, preview suppression, fallback text delivery, and Settings UI. Provider extensions own STT/TTS calls, speech rewriting, provider-specific menus, transcript preference, and OGG/Opus conversion.
|
||||
|
||||
## Voice Detection
|
||||
|
||||
Voice messages arrive as `message.voice` in Telegram updates. The bridge's media processing detects these and sets `kind: "voice"` on the downloaded file. Regular audio files (`message.audio`) get `kind: "audio"`; `mirror` mode treats both voice notes and audio uploads as voice input for reply-policy tagging.
|
||||
|
||||
Inbound handlers match `kind: "voice"` or `mime: "audio/*"` to run a transcription command:
|
||||
|
||||
```json
|
||||
{
|
||||
"inboundHandlers": [
|
||||
{
|
||||
"mime": "audio/*",
|
||||
"template": ["/path/to/stt", "--file={file}", "--mime={mime}"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The transcription output becomes the raw text of the prompt.
|
||||
|
||||
Voice provider extensions can also register STT backends with `registerTelegramVoiceTranscriptionProvider()` from `@evandrodevbr/oh-my-pi-telegram/voice`. Inbound command-template handlers and programmatic inbound handlers remain the stronger generic paths and run first; if no matching handler produces output for a voice/audio file, registered transcription providers are tried as fallback in registration order. The first provider that returns non-empty text wins; providers that return `undefined` pass to the next provider, and provider failures are recorded before trying the next provider. This lets a full voice extension provide both TTS and STT without requiring `telegram.json` handler templates, while still preserving operator-configured inbound handlers as the stronger choice.
|
||||
|
||||
## Voice Reply Policy
|
||||
|
||||
The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). If config does not set a valid mode, the bridge uses the `hidden` default: manual behavior without adding voice policy text to the prompt context.
|
||||
|
||||
### Modes
|
||||
|
||||
- **`hidden` (default):** no `voice.replyMode` is stored; same behavior as manual, no prompt voice context.
|
||||
- **`manual`:** only reply with voice when the agent authors `<!-- telegram_voice -->` markup; explicit prompt context is added.
|
||||
- **`mirror`:** reply with voice when the inbound message was a voice note or audio file. Text input stays on the manual path where explicit `telegram_voice` markup still works.
|
||||
- **`always`:** always reply with voice.
|
||||
|
||||
**Warning:** In `always` mode, the bridge transparently intercepts ALL text replies and converts them to voice on success. Users will only receive voice messages when voice generation succeeds. If voice generation fails, the bridge falls back to sending the planned text reply.
|
||||
|
||||
When a message is received, the bridge resolves the active voice reply mode and tags the turn:
|
||||
|
||||
- `voiceReplyPreferred`: `true` when mode is `mirror` and the turn has a voice file
|
||||
- `voiceReplyRequired`: `true` when mode is `always`
|
||||
|
||||
At `agent_end`, if the turn is voice-tagged and the agent response has no explicit `telegram_voice` markup, the bridge transparently intercepts the text reply and converts it to voice. If the agent uses multiple `telegram_voice` blocks, each becomes a separate voice message. The same reply-mode decision applies to both registered voice synthesis providers and configured outbound voice handlers.
|
||||
|
||||
### Preview Suppression
|
||||
|
||||
When a turn is voice-tagged, the bridge suppresses text preview streaming during LLM generation. This prevents draft text from appearing in Telegram before the voice message is delivered.
|
||||
|
||||
## Voice Provider Extension Surface
|
||||
|
||||
A voice extension may combine three public seams:
|
||||
|
||||
- `registerTelegramVoiceTranscriptionProvider()` for inbound STT fallback on voice/audio files
|
||||
- `registerTelegramVoiceSynthesisProvider()` for outbound TTS/synthesis fallback to Telegram voice messages
|
||||
- `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style, transcript, or provider on/off controls
|
||||
|
||||
The reply policy itself remains a built-in pi-telegram setting (`voice.replyMode`) rather than a provider-owned menu.
|
||||
|
||||
## Outbound Voice Synthesis Provider Registration
|
||||
|
||||
Voice synthesis provider extensions register themselves through `registerTelegramVoiceSynthesisProvider()`. The bridge only provides the registration seam and the actual delivery to Telegram. **The provider is fully responsible for**:
|
||||
|
||||
- Text optimisation / speech-style rewriting
|
||||
- Adding speech tags (when desired)
|
||||
- Running TTS + ffmpeg conversion to OGG/Opus
|
||||
- Deciding whether to return `transcriptText` at all based on the bridge-owned `voice.sendTranscript` preference when the provider has access to the current Telegram config
|
||||
- `transcriptText` (when returned) is attached by the bridge as the voice message **caption** only. Separate transcript messages are no longer sent.
|
||||
|
||||
The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`. When a provider returns `transcriptText`, the bridge attaches it as the voice caption.
|
||||
|
||||
Providers can implement `getVoicePromptContribution(view)` to inject voice-specific instructions into voice-tagged prompts (for example: "Reply only with the spoken text"). The bridge appends the first non-empty provider contribution when `mirror` or `always` mode tags the turn.
|
||||
|
||||
Import provider APIs from `@evandrodevbr/oh-my-pi-telegram/voice`; see the TSDoc on `registerTelegramVoiceSynthesisProvider` and `TelegramVoiceSynthesisProviderResult` there for the exact interface.
|
||||
|
||||
The provider receives the raw agent text plus optional `{ lang?, rate? }`.
|
||||
|
||||
It must return one of:
|
||||
|
||||
- `string` — path to a ready `.ogg` or `.opus` file
|
||||
- `{ audioPath: string, transcriptText?: string }` — `audioPath` must be OGG/Opus. When `transcriptText` is present it is attached as the voice message **caption**. Providers should treat pi-telegram's `voice.sendTranscript` as the bridge-owned transcript preference instead of inventing a second reply-policy UI.
|
||||
- `undefined` — skip this text block
|
||||
|
||||
**Important:** Providers are fully responsible for producing a clean, TTS-optimised native voice file. The bridge may also run configured outbound voice command templates for users who prefer process-boundary handlers instead of provider extensions.
|
||||
|
||||
**File format:** Telegram `sendVoice` requires **OGG/Opus** to display the message as a native voice note (waveform, inline playback). MP3 and other formats are accepted by the API but render as regular audio attachments (music note icon, filename visible). **Providers and outbound voice handlers must return `.ogg` or `.opus` files.** Returning non-OGG files causes the bridge to throw and fall back to text delivery.
|
||||
|
||||
Registration returns a disposer function for cleanup. Stable provider registrations pass a durable `id` in options; omitted ids remain a compatibility path for older providers and receive generated session-local ids. Extensions should call disposers on shutdown or re-register safely on session start when their runtime is recreated.
|
||||
|
||||
## Outbound Voice Handlers
|
||||
|
||||
Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`manual`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
|
||||
|
||||
Voice handlers receive the text on stdin in composed pipelines and can use `{text}`, `{lang}`, `{rate}`, `{mp3}`, and `{ogg}` placeholders. Set `output` to `"ogg"` or another placeholder name when the template writes to a known path:
|
||||
|
||||
```json
|
||||
{
|
||||
"voice": { "replyMode": "mirror" },
|
||||
"outboundHandlers": [
|
||||
{
|
||||
"type": "voice",
|
||||
"template": [
|
||||
"/path/to/tts --write-media {mp3}",
|
||||
"ffmpeg -y -i {mp3} -c:a libopus -b:a 32k -ar 16000 -ac 1 {ogg}"
|
||||
],
|
||||
"output": "ogg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Priority for outbound voice delivery is: configured `outboundHandlers` with `type: "voice"` in their `telegram.json` order, then programmatic `voice` outbound handlers, then registered voice synthesis providers. Provider extensions are the zero-config tail of the same pipeline: they handle voice when no explicit configured handler succeeds, but they do not override operator-configured handlers. If multiple providers are registered, only one handles a given voice reply: the first provider that returns a valid `.ogg`/`.opus` artifact wins. Providers that return `undefined` explicitly pass to the next provider; providers that throw or return invalid output are recorded and the next fallback is tried.
|
||||
|
||||
### Provider with transcript caption (controlled by user toggle)
|
||||
|
||||
When the user's "Send Transcript" toggle is ON, return the clean spoken text as `transcriptText`. The bridge attaches it as the caption on the voice message. When the toggle is OFF, return only the audio path (no `transcriptText`).
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getTelegramVoiceSendTranscript,
|
||||
registerTelegramVoiceSynthesisProvider,
|
||||
} from "@evandrodevbr/oh-my-pi-telegram/voice";
|
||||
|
||||
registerTelegramVoiceSynthesisProvider(
|
||||
async (text, options) => {
|
||||
const rewritten = rewriteWithSpeechTags(text);
|
||||
const audioPath = await myTTS(rewritten, { language: options?.lang });
|
||||
const sendTranscript = getTelegramVoiceSendTranscript(
|
||||
getCurrentTelegramConfigView(),
|
||||
);
|
||||
return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
|
||||
},
|
||||
{ id: "my-voice-provider/tts" },
|
||||
);
|
||||
```
|
||||
|
||||
`getCurrentTelegramConfigView()` represents whatever current `TelegramConfig` view your extension already owns or receives; pi-telegram does not require providers to read config directly. The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
|
||||
|
||||
### Surfacing provider diagnostics
|
||||
|
||||
Voice provider extensions can record runtime events that appear in `/telegram-status` alongside pi-telegram's own events:
|
||||
|
||||
```typescript
|
||||
import { recordTelegramRuntimeEvent } from "@evandrodevbr/oh-my-pi-telegram/outbound";
|
||||
|
||||
recordTelegramRuntimeEvent("voice-provider", new Error("TTS failed"), {
|
||||
phase: "tts",
|
||||
text: text.slice(0, 50),
|
||||
});
|
||||
```
|
||||
|
||||
`recordTelegramRuntimeEvent` writes to the same event ring that pi-telegram uses. Events are visible via `/telegram-status` in Telegram. Calls are silently dropped if pi-telegram is not loaded.
|
||||
|
||||
## Voice Extension Section
|
||||
|
||||
Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style, transcript behavior, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
|
||||
|
||||
**Note on resume:** Because the previous automatic persistent re-registration system has been removed, extensions are responsible for re-registering their Voice Extension Section on `session_start` if they want the menu to survive a `pi resume`. See `registerTelegramSection` from `@evandrodevbr/oh-my-pi-telegram/sections`.
|
||||
|
||||
## Prompt Guidance
|
||||
|
||||
The bridge keeps voice prompt context compact and policy-owned. It adds `[voice] reply mode: ...` only when `telegram.json` explicitly contains a valid `voice.replyMode`. `hidden`/no configured mode behaves like manual, but prompts stay silent. When explicit, voice-originated `manual` turns add `[voice] reply mode: manual`, voice-originated `mirror` turns add `[voice] reply mode: mirror`, and `always` mode adds `[voice] reply mode: always` for every turn. If voice context later contains multiple fields, the bridge renders it as a `[voice]` list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
|
||||
|
||||
Voice synthesis providers can supply prompt guidance through `getVoicePromptContribution(view)`, but provider text should stay optional and provider-specific. Reply-mode context belongs to pi-telegram.
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
### If voice generation fails
|
||||
|
||||
1. The bridge records the failure via `recordRuntimeEvent`
|
||||
2. The voice sender throws an error, which the runtime catches
|
||||
3. The runtime falls back to sending the planned text reply (outbound markup stripped, `replyMarkup` preserved)
|
||||
|
||||
### If no voice synthesis provider is registered
|
||||
|
||||
- The voice sender throws because no configured handler or synthesis provider can deliver the voice reply
|
||||
- The runtime catches the error and falls back to text delivery
|
||||
|
||||
### If the provider returns a non-OGG file
|
||||
|
||||
- `ensureTelegramVoiceFileFormat` rejects the file (only `.ogg` and `.opus` are accepted)
|
||||
- The voice sender throws and the runtime falls back to text delivery
|
||||
- The provider should handle format conversion internally before returning the path
|
||||
|
||||
## Telegram Voice Limits
|
||||
|
||||
- **Duration:** Up to ~60 minutes per voice message
|
||||
- **File size:** Up to 20 MB for voice uploads via `sendVoice`
|
||||
- **Format:** OGG Opus is native; MP3 and other formats render as regular audio attachments
|
||||
- **Splitting:** The bridge does not split long responses into multiple voice messages. Chunking is the provider's responsibility
|
||||
|
||||
## Configuration
|
||||
|
||||
### Bridge config (`telegram.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"voice": {
|
||||
"replyMode": "manual"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Valid stored values: `"manual"`, `"mirror"`, `"always"`. Missing or invalid values are shown in Settings as `hidden`, behave like manual, and stay silent in prompt context.
|
||||
|
||||
The bridge reads `voice.replyMode` from the config when building a turn.
|
||||
|
||||
### Provider config
|
||||
|
||||
Provider-specific settings (voice ID, language, speech style, transcript behavior, STT/TTS enablement) are owned by the voice provider extension. Reply mode is owned by pi-telegram's `voice.replyMode` and configured from the built-in pi-telegram Settings menu, not duplicated in provider UIs.
|
||||
Reference in new issue
Block a user