13 Commits
Author SHA1 Message Date
evandrodevbr 6f7e266a6e feat: keep error status until polling recovers, classify unconfirmed deliveries, document the UI
Validate / validate (push) Successful in 9m52s
- the poll loop now clears its error status after the next successful getUpdates, not before the retry, so a slow retry never shows as healthy
- recovery is reported only when getUpdates itself failed
- a send that may have reached Telegram is reported as unconfirmed instead of failed
- README and CHANGELOG describe the states, toasts, queue preview and spinner text
2026-10-01 17:48:15 -03:00
evandrodevbr 057281a3bb feat: human error states, operator toasts and a queue/origin view in the omp UI
- classify transport and API failures into a short state with the next step
- count undelivered messages and show them next to the sent counter
- toast on owner paired, connection recovered and reply not delivered (throttled)
- second status line previews the next queued turns
- host spinner names the Telegram prompt while its turn runs
- sanitize every Telegram-controlled string that reaches the widget
2026-10-01 17:33:37 -03:00
evandrodevbr 9435ff709b feat: render the status line as a themed widget in the TUI and document it
omp strips ANSI from extension status text, so setStatus drops the theme colors.
String widgets keep them: in the TUI the line is a belowEditor widget and the
plain footer entry is cleared; other modes keep setStatus.
2026-10-01 11:28:53 -03:00
evandrodevbr ca05b58f38 feat: show connection state, bot, message counters and last activity in the omp status line 2026-10-01 11:21:14 -03:00
evandrodevbr 60254b288b fix: keep the paired user when the poll loop commits its offset
The poll loop persisted the config object it captured when polling started.
The store swaps in a fresh object on every persist, so after the initial sync
that captured copy lacked later changes: the next offset commit erased the
paired user and the bridge re-paired on every message.
2026-10-01 11:14:02 -03:00
evandrodevbr 033a92f3bb Merge branch 'omp-18-compat' 2026-10-01 10:26:53 -03:00
evandrodevbr 2aefc7ef5a fix: adapt the bridge to omp 18.4.8 host differences
Validate / validate (push) Successful in 9m53s
- resolve the agent dir through the host getAgentDir() (registered by the composition root) instead of sniffing execPath/argv
- synthesize agent_settled from the first terminal agent_end on omp
- read the live model through ctx.models.current() and prefer it over the stored model on omp
- adapt omp Settings (reloadFromDisk, global enabledModels) at the SDK boundary and drop scoped-model persistence on omp
2026-10-01 10:07:47 -03:00
Evandro Fonseca Junior 52c9fbeaf3 Merge pull request #3 from evandrodevbr/codex/review-2026-09-30-049051
fix: resolve diagnostic paths and recover time injection
2026-09-30 23:03:45 -03:00
Evandro Fonseca Junior a46fb916a0 fix: resolve diagnostic paths and recover time injection 2026-09-30 17:10:30 -03:00
Evandro Fonseca Junior a9364a344a docs: point the README verification section at the green CI run
The Validate workflow is green again on main after the audit overrides
refresh and the bus ledger test fix; record the run id instead of the
stale 2026-07-15 entry. Also note the fix under Unreleased.
2026-09-14 08:32:10 -03:00
Evandro Fonseca Junior 0852274102 test: stop assuming a fixed envelope delivery time in the bus ledger test
"Bus local server memoizes completed and in-flight request results" sent two
envelopes, slept a fixed 5 ms and then asserted that the handler had already
run once. On a slower CI runner the first envelope can still be in the socket
queue at that point, so the run failed with 0 !== 1 (run 34838232340).

Wait (bounded, 5 s deadline) until the first envelope reaches the handler
before asserting. The duplicate stays in flight because the gate is only
released after the assertion, so the memoization guarantee under test is
unchanged.
2026-09-14 08:31:01 -03:00
Evandro Fonseca Junior 7a54e2dbeb docs: rewrite README against verified code, gates and omp CLI
- standard structure: about, how it works, stack, requirements, quick
  start, usage/API, production and release, project structure,
  verification, current state and limitations, documentation, license.
- fix the omp install instructions: "omp install --link <path>" is not a
  valid flag (Unknown option). Document the local-path form that was
  actually exercised and the plugin source syntax from omp plugin --help.
- state plainly that the package is not published on npm and that omp's
  plugin source resolver rejects npm plugin sources.
- correct the agent directory resolution (PI_CODING_AGENT_DIR, then
  ~/.omp/agent under omp, else ~/.pi/agent) and the proxy note (proxy
  support comes from the Node runtime, not from this extension).
- list the three registered tools and the real env vars with defaults.
- add a Verification table with the commands and their real results.
2026-09-14 08:26:33 -03:00
Evandro Fonseca Junior 897c69006e fix: keep the validate and release gates green
- refresh dependency overrides so the audit gate passes again
  (protobufjs 7.6.4 -> 7.6.6, undici 8.5.0 -> 8.10.2, new
  brace-expansion override 5.0.9). npm run validate is green again:
  typecheck, 1229/1230 tests, 0 vulnerabilities, pack clean.
- promote the 0.22.0-evandro.1 changelog section from "###" to "##" so
  .github/workflows/release.yml can build release notes for the tag.
  The previous Release run failed with "CHANGELOG.md has no section for
  0.22.0-evandro.1".
- record both fixes under "## Unreleased".
2026-09-14 08:26:27 -03:00
32 changed files with 2157 additions and 643 deletions

No files matched your search

+23 -1
View File
@@ -2,7 +2,29 @@
## Unreleased
### 0.22.0-evandro.1 — Evandro Fonseca Junior fork (oh-my-pi-telegram)
- `Status Errors`: Failures of the poll loop and of message delivery are classified into a short state with the next step (`invalid token · /telegram-setup`, `rate limited 12s · retrying`, `another poller is active · /telegram-status`, `Telegram unavailable`, `offline · retrying`); unrecognized messages stay verbatim, sanitized and trimmed to 100 characters. A non-idempotent send that hit a transport failure after the request left is reported as `delivery unconfirmed` instead of a definite failure. Impact: the status says what to do instead of showing raw runtime text.
- `Status Persistence`: The poll loop cleared the error status after its retry sleep, before the next attempt, so a slow retry (a 12 s rate-limit wait) showed as `connected`. The reset now happens after the next successful `getUpdates`, and recovery is reported only when `getUpdates` itself had failed, not when an update handler did. Impact: an error stays visible until polling works again.
- `Delivery Counters`: `lib/traffic.ts` counts undelivered messages (`recordFailed`, `onCallFailed`) and the status shows `✘N` next to the sent counter; `/telegram-status` adds `N failed`. `TelegramBridgeApiRuntime` gains `onCallFailed` beside `onCallSucceeded`.
- `Operator Notices`: New leaf-adjacent domain `lib/notices.ts` raises host toasts for owner paired (`onPaired` on the pairing runtime), connection recovered (`onRecovered` on the poll loop) and reply not delivered (throttled to one per 30 s). A failing toast is recorded as a runtime event and never reaches the send, pairing or polling path.
- `Queue Preview`: With turns waiting, the TUI widget adds a second line with the next two summaries in dispatch order (`Queue.formatQueuedTelegramItemsPreview`) and a remainder count; the plain `setStatus` entry keeps one line.
- `Working Message`: While a Telegram-originated turn runs, the omp spinner reads `Answering Telegram · "<prompt>"` and is restored when the turn settles. `setExtensionWorkingMessage` skips hosts without `ui.setWorkingMessage`, because the text is cosmetic.
- `Widget Sanitization`: Widget lines bypass the host's status sanitizer, so `sanitizeTelegramStatusText` strips control characters and bidi overrides from every Telegram-controlled string (thread name, bot name, queued prompts, error text) before it reaches the terminal.
- `Status Line`: The line under the omp editor now shows a state glyph, the bot name, received/sent message counters and the time of the last message (`telegram ● connected · @bot · ⤵12 ⤴11 · ⏱ 14:32`), plus the next step for non-working states (`/telegram-setup`, `send /start to @bot`, `/telegram-connect`). Glyphs follow omp's `symbolPreset`. omp strips ANSI from extension status text, so in the TUI the line is rendered as a `belowEditor` widget that keeps the theme colors; other modes keep `setStatus`. Counters come from the new leaf domain `lib/traffic.ts`: received on every owner message, sent on every successful `sendMessage`/`sendRichMessage`/media call, and `/telegram-status` gains a `- messages:` line. Impact: connection state, traffic and recovery steps are visible without opening `/telegram-status`.
- `Pairing`: The poll loop persisted the config object it captured when polling started. The store swaps in a fresh object on every persist, so after the initial sync that stale copy lacked later changes and the next offset commit erased the paired owner: `telegram.json` never kept `allowedUserId`, the bridge re-paired on every message and the status flipped back to `awaiting pairing`. The runner now carries only the poll offset onto the store's current config. Impact: first-time pairing sticks, and another Telegram user can no longer be paired by writing before the owner's next message.
- `omp Agent Directory`: Resolve the agent directory from the host's own `getAgentDir()` (registered by the composition root via `setHostAgentDirResolver`) instead of sniffing `execPath`/`argv[1]` for an `omp` prefix. omp installed through Bun runs as `bun …/@oh-my-pi/pi-coding-agent/dist/cli.js`, which matched neither, so with `telegram.json` in `~/.omp/agent` the bridge never connected and wrote its log and bus socket under `~/.pi/agent`. `PI_CODING_AGENT_DIR` still wins; with no host registered the fallback stays `~/.pi/agent`. Impact: a bot token saved by `/telegram-setup` lives in omp's own agent directory and is found again. `lib/paths.ts` stays free of Pi SDK imports, which cost about a second of startup per importing process.
- `omp Settled Boundary`: omp never emits `agent_settled`, the only place the activity runtime finalized a turn. On omp the first terminal `agent_end` (no `willContinue: true`, which omp sets for retries, compaction continuations, todo/plan reminders and async wakes) now drives `onAgentSettled`; hosts that emit `agent_settled` natively are unchanged. Impact: Activity API consumers receive `agent-settled` and the activity id no longer leaks across turns.
- `omp Current Model`: omp never emits `model_select`, so the stored current model went stale after a local `/model` switch, and omp builds command contexts by spreading a base context, which freezes `ctx.model` at the moment of `/telegram-connect`. On omp the live `ctx.models.current()` now wins over both, and the stored value no longer overrides it. Impact: the Telegram model menu marks the model omp is actually using.
- `omp Settings Manager`: omp's `SettingsManager.create()` returns its own `Settings`, which has no `reload()` or `getEnabledModels()`; rendering the model menu and `/status` failed with `settingsManager.reload is not a function`, so no menu reached Telegram. The SDK boundary now adapts it (`reloadFromDisk()`, global `enabledModels` read) and scoped-model persistence is not offered on omp. Impact: the operator menus render on omp.
- `Time Injection`: Reject non-finite configured intervals and resume time annotations after the wall clock moves backwards.
- `Paths`: Resolve diagnostic paths from the current agent directory and profile instead of reporting hard-coded `~/.pi` locations.
- `Security`: Apply the `brace-expansion` `5.0.12` override at both root and coding-agent scopes to address the latest recursion and expansion denial-of-service advisories.
- **Security overrides refreshed** — `protobufjs` `7.6.4` → `7.6.6`, `undici` `8.5.0` → `8.10.2`, and a new `brace-expansion` `5.0.9` override keep `npm audit` at 0 vulnerabilities; the `validate` gate (`typecheck` → `test` → `audit` → `pack:check`) passes again.
- **README rewritten** to the repository audit standard: verified install/build/test commands, the real `omp install` syntax, omp agent-directory resolution, and an explicit limitations section.
- **Release notes extraction fixed** — the `0.22.0-evandro.1` fork section promoted from `###` to `##` so `.github/workflows/release.yml` can build release notes for tag `v0.22.0-evandro.1`.
- **Bus ledger test made timing-independent** — `tests/bus.test.ts` no longer assumes the first bus envelope reaches the server within a fixed 5 ms, which failed on a slower CI runner (`0 !== 1`). The `Validate` workflow is green again on `main`.
## 0.22.0-evandro.1 — Evandro Fonseca Junior fork (oh-my-pi-telegram)
- **Repackaged as `@evandrodevbr/oh-my-pi-telegram`** — version suffix `-evandro.1` keeps the 0.22.0 source line (SHA `afe09c5`) traceable to upstream `llblab/pi-telegram` while making the fork unambiguous in the `omp` extension registry.
- **Oh My Pi (omp) compatibility metadata** — added an `omp` field in `package.json` (displayName, homepage, `compatibleWith: { "@oh-my-pi/pi-coding-agent": ">=16.5.2" }`) so the fork is discoverable as a first-class extension for `can1357/oh-my-pi` (`omp >= 16.5.2`).
+224 -274
View File
@@ -1,339 +1,289 @@
# oh-my-pi-telegram
![pi-telegram screenshot](screenshot.png)
**Telegram runtime adapter for live [Oh My Pi](https://github.com/can1357/oh-my-pi) (`omp`) sessions: send prompts, queue work, watch previews and collect artifacts from a private Telegram DM while the agent keeps running in the terminal.**
**Telegram companion hub for live [Oh My Pi](https://github.com/can1357/oh-my-pi) (omp) sessions.**
[![Validate](https://github.com/evandrodevbr/oh-my-pi-telegram/actions/workflows/validate.yml/badge.svg)](https://github.com/evandrodevbr/oh-my-pi-telegram/actions/workflows/validate.yml)
![TypeScript](https://img.shields.io/badge/TypeScript-7-3178C6?logo=typescript&logoColor=white)
![Node](https://img.shields.io/badge/Node-%3E%3D22.19.0-5FA04E?logo=nodedotjs&logoColor=white)
![omp](https://img.shields.io/badge/omp-%3E%3D16.5.2-111111)
![License](https://img.shields.io/badge/license-MIT-green)
> **This is the Evandro Fonseca Junior fork** of [`@llblab/[email protected]`](https://github.com/llblab/pi-telegram/tree/v0.22.0) (SHA `afe09c5`), repackaged and validated as a drop-in extension for [`can1357/oh-my-pi`](https://github.com/can1357/oh-my-pi) — the AI coding agent for the terminal.
>
> Upstream lineage: `badlogic/pi-telegram` → `llblab/pi-telegram` (0.22.0) → **`evandrodevbr/oh-my-pi-telegram`** (this fork).
![Telegram DM with the operator menu, model picker and a formatted answer, next to the local session](screenshot.png)
## Oh My Pi (omp) Compatibility
> Fork of [`@llblab/[email protected]`](https://github.com/llblab/pi-telegram/tree/v0.22.0) (SHA `afe09c5`), republished as `@evandrodevbr/oh-my-pi-telegram` for `omp`.
> Lineage: `badlogic/pi-telegram` → `llblab/pi-telegram` (0.22.0) → **this fork**.
> The fork is based on upstream 0.22.0, with `omp` compatibility metadata and local runtime fixes recorded in `CHANGELOG.md`.
This fork is built and tested against:
## About
| Component | Version | Status |
| --- | --- | --- |
| `can1357/oh-my-pi` (CLI: `omp`) | `>=16.5.2` (`@oh-my-pi/pi-coding-agent`) | ✅ verified |
| `@earendil-works/pi-{ai,agent-core,coding-agent}` | `>=0.80.6` | ✅ verified (peerDeps preserved from upstream) |
| `node` | `>=22.19.0` | ✅ verified on Node 24.15.0 |
| Telegram Bot API | current | ✅ unchanged from upstream |
Supervising a long agent run means staying at the keyboard, because the work lives in the terminal session. This extension removes that constraint for the part that does not need a terminal: it binds a private Telegram DM to a running `omp` instance, so text, replies, images, files and voice notes sent from the phone become ordinary turns in that instance's active session, and answers, artifacts, buttons and voice come back to the chat.
### Install into omp
What it does:
- Accepts prompts from Telegram (text, replies, edits, images, files, albums, voice) and injects them into the active `omp` session.
- Queues turns while the agent is busy instead of interrupting the running turn, with menu controls to inspect, promote, delete or force the next item.
- Streams native "typing"/draft previews and delivers final answers as Telegram Rich Markdown, plus files through `telegram_attach`.
- Exposes an operator menu (`/start`) for status, model, thinking level, settings, queue, prompt templates and diagnostics.
- Pairs exactly one Telegram owner; other users are ignored.
What it deliberately is not: a remote terminal, a PTY, a shell, a process launcher or a session browser. It does not spawn hidden `omp` processes and does not forward arbitrary Telegram slash commands into the TUI.
## How it works
```
Telegram DM ──▶ getUpdates (single polling owner)
│
▼
inbound layer ──▶ Telegram turn ──▶ queue / active dispatch
│ │
│ ▼
│ omp active session (model turn)
▼ │
inbound files to <agent-dir>/tmp/telegram ▼
streaming preview / native active status
│
▼
final Rich Markdown reply ──▶ Telegram DM
(+ files, voice, inline buttons)
```
- One live `omp` instance owns polling for a profile; each Telegram destination follows that instance and sends prompts into its currently active session (it is not bound to one session file).
- A second `omp` instance started while Telegram private-chat Threaded Mode is available registers as a follower through a local leader/follower bus, each one behind its own Telegram thread.
- Companion extensions can register commands, menu sections, status rows, update/callback handlers and voice providers without owning a second bot loop.
## Stack
| Layer | Choice |
| --- | --- |
| Language | TypeScript 7, strict, run directly by the `omp`/Pi runtime (no build step) |
| Runtime | Node.js >= 22.19.0 (`node:test` + `--experimental-strip-types` for the suite) |
| Host agent | `omp` (`@oh-my-pi/pi-coding-agent` >= 16.5.2); peer deps `@earendil-works/pi-{ai,agent-core,coding-agent}` >= 0.80.6, `@sinclair/typebox` |
| Telegram transport | Direct Telegram Bot API over `undici` HTTP calls (long polling, Rich Messages, files, voice) |
| Transport hardening | Dependency `overrides` pin security-patched `protobufjs`, `undici`, `ws`, `brace-expansion` for the transitive tree |
| Package manager | npm (`package-lock.json` is the lockfile CI uses); `bun.lock` is a leftover from upstream |
| CI | GitHub Actions: `Validate` (npm ci + `npm run validate`), `Release` (tag `v*.*.*` → GitHub release from `CHANGELOG.md`) |
## Requirements
- Node.js `>= 22.19.0` (declared in `engines`; verified here on 24.20.0)
- npm `>= 10` for the validation gates (verified on 11.19.0). `bun` also works for running the extension, but the gates are npm-based.
- `omp` `>= 16.5.2` (verified with `omp` 18.4.8) or a Pi runtime exposing the same `@earendil-works/pi-*` extension API
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
## Quick start
The extension is **not published to npm yet** (`npm view @evandrodevbr/oh-my-pi-telegram` returns 404), so clone the repository and install from the local checkout.
```bash
# From npm (after this fork is published):
omp install npm:@evandrodevbr/oh-my-pi-telegram
# Local development checkout, run the gates first
git clone https://github.com/evandrodevbr/oh-my-pi-telegram
cd oh-my-pi-telegram
npm ci
npm run validate
# From git (always works):
omp install git:github.com/evandrodevbr/oh-my-pi-telegram
# Install into omp from the checkout (verified working; --link is NOT a valid flag)
omp install /absolute/path/to/oh-my-pi-telegram
# From a local checkout (development):
omp install --link /path/to/oh-my-pi-telegram
# Confirm omp sees the extension and reads its `omp` manifest field
omp plugin list --json
omp plugin doctor
```
`omp plugin --help` documents these plugin sources: a local path (`./path`, `../path`, `/abs`, `~/path`, symlinked), `github:user/repo[#ref]` (also `gitlab:`, `bitbucket:`, `codeberg:`, `sourcehut:`), a full git URL (`https://github.com/user/repo`), `name@marketplace`, and npm specs (`pkg`, `[email protected]`). Prefer the local-path form: the package is not on npm, and omp's plugin source resolver currently rejects `npm` sources with `npm plugin sources are not yet supported. Use git-based sources instead.`
Then inside `omp`:
```bash
/telegram-setup # paste bot token
/telegram-connect # activate polling for this omp instance
```
In your Telegram bot DM:
```text
/start
/telegram-setup # paste the bot token (offers a saved token as default)
/telegram-connect # start polling for this instance
```
The first user becomes the allowed owner.
Finally, open the bot DM and send `/start`. The first Telegram user to write becomes the allowed owner.
### Why this fork exists
Config is written to `<agent-dir>/telegram.json`. The agent directory is `PI_CODING_AGENT_DIR` when set, otherwise the directory the host reports through `getAgentDir()`: `~/.pi/agent` on Pi, and on `omp` its profile-aware directory (`~/.omp/agent` by default). The executable name and `argv` are not inspected.
`llblab/[email protected]` targets the **Pi extension platform** (`@earendil-works/pi-*`). `omp` ships the same platform but republishes it under `@oh-my-pi/pi-coding-agent` with the additional harness tooling (LSP, DAP, hashline, subagents, advisor, TTSR, …). This fork:
## Usage
1. **Renames the npm package** to `@evandrodevbr/oh-my-pi-telegram` so it is unambiguous in the `omp` extension registry.
2. **Keeps the upstream `peerDependencies`** on `@earendil-works/*` (these are the canonical runtime packages that both Pi and omp consume).
3. **Adds an `omp` field** in `package.json` with explicit compatibility metadata for downstream extension discovery.
4. **Re-runs every upstream gate** (`typecheck` → `test` → `audit` → `pack:check`) on the exact `v0.22.0` source — 1229 tests pass, 0 vulnerabilities, pack clean.
5. **Adds this README section** so the fork is discoverable as the Telegram bridge for omp.
## What `pi-telegram` (and this fork) does
`pi-telegram` turns a private Telegram DM into a mobile operator surface for Pi/omp. It accepts prompts, queues work, streams readable previews, delivers final replies and files, exposes safe controls, and lets companion extensions add Telegram-native capabilities without owning a second bot loop.
It is a **runtime adapter**, not a remote terminal. Start or supervise work in the `omp` TUI, then continue from Telegram while away from the keyboard. Each Telegram destination follows a running `omp` instance and sends prompts into that instance's currently active session; it is not permanently bound to one session file or session identity. The bridge preserves Pi session semantics instead of pretending Telegram is a PTY, shell, process launcher, or session browser. That boundary is the product: Telegram gets safe runtime handles, not raw terminal power.
This fork is derived from [`llblab/[email protected]`](https://github.com/llblab/pi-telegram/tree/v0.22.0) (SHA `afe09c5`), which itself is an actively maintained fork of [`badlogic/pi-telegram`](https://github.com/badlogic/pi-telegram).
## Install (npm/git, same as upstream)
From npm:
```bash
pi install npm:@evandrodevbr/oh-my-pi-telegram
```
From git:
```bash
pi install git:github.com/evandrodevbr/oh-my-pi-telegram
```
The 0.21 extension platform requires Pi `0.80.6` or newer. Its Activity API uses the public `agent_settled` lifecycle event to keep retries/continuations under one activity identity and release that identity only after the run fully settles.
## Quick Start
### 1. Create a Telegram bot
1. Open [@BotFather](https://t.me/BotFather).
2. Run `/newbot`.
3. Pick a name and username.
4. Copy the bot token.
### 2. Configure Pi/omp
Run this inside Pi/omp:
```bash
/telegram-setup
```
Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved token, setup offers it as the default. If no saved token exists, setup can prefill from `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, or `TELEGRAM_KEY`. Named profiles are optional; the ordinary `/telegram-setup` and `/telegram-connect` flow keeps using the default profile. Use `/telegram-setup <name>` only when you want an additional bot profile. Cancelling or failing named-profile token validation leaves the currently active profile and polling runtime unchanged; setup reports the profile as saved and connected only after polling startup succeeds.
### 3. Connect this Pi/omp instance and its active session
```bash
/telegram-connect
```
The connected instance owns Telegram polling. Use `/telegram-connect <name>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the unnamed default profile keeps legacy paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers.
### 4. Pair your Telegram account
Open the bot DM and send:
```text
/start
```
The first Telegram user to message the bot becomes the allowed owner. Other users are ignored.
## What It Feels Like
- Start a task in the terminal, walk away, and keep supervising it from your phone.
- Send another prompt while Pi is busy; it becomes a queued Telegram turn instead of interrupting the active run.
- Open `/start` to inspect status, model, thinking, settings, prompt templates, and queue controls.
- Send voice, images, files, replies, edits, or media groups; the bridge turns them into Pi context.
- Ask for an artifact; `telegram_attach` returns it through the active reply or direct Telegram delivery.
- In Threaded Mode, run multiple visible Pi instances through one bot, each with its own Telegram thread.
- Configure named profiles to run independent Telegram bots from the same Pi agent directory without sharing transport or routing state.
## Product Model
| Lens | What `pi-telegram` owns |
| --- | --- |
| Operator companion | A phone-width control surface for the active session of a running Pi instance |
| Runtime adapter | Telegram targets mapped to Pi instances, then into each instance's current session lifecycle, queueing, previews, final replies, and artifacts |
| Telegram UI harness | Menus, settings, callbacks, Rich Markdown, drafts, active status, buttons, voice, and files |
| Multi-instance organism | One leader plus explicit visible followers routed through Telegram private-chat threads |
| Extension platform | Commands, sections, status rows, update handlers, inbound/outbound handlers, and voice providers |
| Safety boundary | No hidden Pi processes, no fake terminal, no PTY tricks, no arbitrary TUI slash-command forwarding |
## Feature Showcase
`pi-telegram` is intentionally broad: it is a Telegram-shaped runtime surface, not only a message relay. This catalogue keeps the practical feature surface visible while detailed contracts stay in `/docs`.
| Surface | What you can do | Why it matters |
| --- | --- | --- |
| Prompt intake | Send text, replies, edits, images, files, albums, voice notes, and handler output into Pi. | Telegram becomes a real mobile input surface with file/context references, not just a text tunnel. |
| Queue control | Inspect waiting turns, delete stale work, promote important prompts, continue, abort, stop, or force the next queued item. | Long Pi tasks keep running while new mobile prompts stay visible and controllable instead of interrupting or disappearing. |
| Operator menu | Use `/start` for status, prompt templates, model, thinking, settings, queue, extension sections, and diagnostics. | The bot is an operator panel, not a command cheat sheet. |
| Prompt templates | Run Pi prompt templates as Telegram-safe commands such as `/fix_tests`. | Reusable local workflows become phone-accessible without exposing arbitrary terminal commands. |
| Model and thinking | Switch model or reasoning level from Telegram through safe continuation flows. | Mobile control can adjust execution strategy without tearing down the current session. |
| Compaction | Confirm `/compact`, show native active status during compaction, and preserve Telegram-owned turn semantics. | Context maintenance is visible and safe from the phone. |
| Draft previews | Show Telegram's native `…typing` indicator whenever the connected instance is doing agent work, or enable Rich Draft previews for streamed answer text. | Local prompts, Telegram turns, and autonomous continuations remain visibly active while draft visibility stays independent from final rendering. |
| Assistant rendering | Choose Native Rich Markdown or legacy Markdown-to-HTML for final assistant replies. | Renderer compatibility is explicit instead of being conflated with draft previews. |
| Bridge UI rendering | Render tool rows, reasoning/technical steps, menus, queue controls, status, settings, diagnostics, and sections through explicit Telegram HTML/plain UI. | Harness-owned surfaces remain operationally predictable and visually distinct from model-authored answers. |
| Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
| Outbound artifacts | Return generated files through `telegram_attach` during active turns or explicit direct delivery. | Agents send real artifacts as files, not pasted blobs. |
| Voice input | Route audio through configured command-template handlers, programmatic handlers, or STT providers. | Voice notes become usable prompt context. |
| Voice output | Use `telegram_voice`, reply modes, configured voice handlers, and TTS providers. | Replies can become Telegram voice messages when the workflow calls for it. |
| Buttons | Turn top-level `telegram_button` comments into inline buttons. | Assistant-authored choices become native Telegram interactions. |
| Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
| Threaded Mode | Run one leader plus visible follower Pi instances through named private-chat threads. | One bot can host a local multi-instance Pi organism without hidden process spawning. |
| Reroute and restore | Preserve unknown threads and offer explicit target choices. | Telegram client state can be repaired without silently deleting or hijacking prompts. |
| Extension sections | Add menu sections, commands, status rows, settings, callbacks, and delivery helpers from companion extensions. | `pi-telegram` becomes a platform surface for other Pi extensions. |
| Runtime diagnostics | Use `/telegram-status` and recent runtime events for connection, role, queue, transport, and failure evidence. | Debugging lives in the operator surface instead of hidden logs only. |
| Safety and ownership | Pair one owner, lock transport, scope targets, and reject fake terminal behavior. | Remote access remains explicit, bounded, and understandable. |
## Core Loop
```text
Telegram message
-> Telegram turn
-> queue or active dispatch
-> Pi agent lifecycle
-> streaming preview / native active status
-> final Rich Markdown reply
-> optional files, voice, buttons, or callback actions
```
The bridge keeps Telegram responsive without stealing Pi's runtime model. Queueing, model changes, compaction, aborts, final delivery, and direct artifact sends all stay scoped to the Pi instance that accepted the work.
## Telegram Controls
Use these in the bot DM.
### Telegram commands
| Command | Purpose |
| --- | --- |
| `/start` | Pair when needed and open the main operator menu |
| `/compact` | Confirm and run session compaction when safe |
| `/start` | Pair when needed and open the operator menu (status, templates, model, thinking, settings, queue, sections) |
| `/compact` | Confirm and run session compaction |
| `/next` | Dispatch the next queued turn, aborting first if needed |
| `/continue` | Enqueue a priority continuation prompt |
| `/abort` | Abort the active run while preserving the queue |
| `/abort` | Abort the active run, keep the queue |
| `/stop` | Abort the active run and clear waiting Telegram turns |
Hidden compatibility shortcuts: `/help`, `/status`, `/model`, `/thinking`, `/queue`, and `/settings` jump into the same menu system.
`/help`, `/status`, `/model`, `/thinking`, `/queue` and `/settings` are accepted shortcuts into the same menus. Prompt templates registered in `omp` are reachable as `/template_name` commands.
## Pi Commands
Run these inside Pi.
### omp commands
| Command | Purpose |
| --- | --- |
| `/telegram-setup` | Save or update the default bot token |
| `/telegram-setup <profile>` | Save or update a named-profile bot token |
| `/telegram-connect` | Activate the default profile and acquire its transport ownership |
| `/telegram-connect <profile>` | Activate a named profile and acquire its transport ownership |
| `/telegram-setup <profile>` | Save or update a named profile's bot token |
| `/telegram-connect` | Start polling on the default profile and take transport ownership |
| `/telegram-connect <profile>` | Activate a named profile |
| `/telegram-disconnect` | Stop polling and release ownership |
| `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
| `/telegram-status` | Connection, mode, queue, transport and recent diagnostics |
Named profile identifiers contain only lowercase ASCII letters and digits (maximum 32 characters); `default`, `main`, and `active` remain reserved.
Profile names: lowercase ASCII letters and digits, up to 32 characters; `default`, `main` and `active` are reserved. Profiles keep isolated polling, diagnostics, Threaded Mode state and local bus transport.
## Main Surfaces
### Status line
### Operator Menu
The bridge keeps one live line under the `omp` editor. In the interactive TUI it is a themed widget (`omp` strips color from plain extension status text, so the widget is what keeps the theme colors); in RPC, JSON and print modes it falls back to a plain status entry. The glyphs follow `omp`'s `symbolPreset` (`unicode`, `nerd` or `ascii`).
`/start` opens the Telegram-native control panel: status, prompt-template commands, model selection, thinking level, settings, queue controls, and extension sections. It is the primary Telegram UI; reaction shortcuts are secondary queue affordances.
```text
telegram ● connected · @my_bot · ⤵12 ⤴11 · ⏱ 14:32
telegram ⟳ active +3 · @my_bot · ⤵14 ⤴11 · ⏱ 14:33
next: "second prompt" · "third prompt" · +1 more
telegram ● connected · @my_bot · ⤵12 ⤴10 ✘2 · ⏱ 14:32
telegram ⚠ awaiting pairing · send /start to @my_bot
telegram ○ disconnected · /telegram-connect
telegram ⦸ not configured · /telegram-setup
telegram ✘ invalid token · /telegram-setup
telegram ⟳ rate limited 12s · retrying
telegram ⟳ offline · retrying
telegram ⚠ another poller is active · /telegram-status
telegram ✘ error <unrecognized message>
```
### Queue Runtime
- `⤵N` counts messages received from the paired owner and `⤴N` counts messages the bridge delivered to Telegram (replies, menus and attachments; typing indicators, drafts and edits are not counted). `✘N` appears only when some deliveries failed or could not be confirmed. `⏱` is the time of the latest message. Counters live in memory, start at zero when the extension loads, and are shown only while the bridge is up.
- The state word is the same ladder as before: `not configured`, `awaiting pairing`, `electing`, `disconnected`, then `active` while a turn runs or is queued, and `connected`, `leader` or `follower` otherwise.
- While turns wait, a second line previews the next two in dispatch order and how many more follow.
- Transport and API failures are classified into a short state with the next step: HTTP 401 (`invalid token`), 429 (`rate limited`), 409 (`another poller is active`), 5xx (`Telegram unavailable`) and network errors (`offline`). Anything unrecognized stays visible verbatim, trimmed to 100 characters.
- An error stays on screen until a poll succeeds again, so a slow retry is never shown as healthy. The API client waits out a rate limit itself, so `rate limited` appears once those waits are exhausted, not at the first 429.
- Toasts: `Telegram paired with user <id>`, `Telegram reconnected`, and `Telegram: reply not delivered (<reason>)`. When a send hit a transport failure after the request left, the outcome is unknown and the toast says `reply delivery unconfirmed (check the chat)`. Delivery failure toasts are throttled to one per 30 seconds.
- While a turn that arrived from Telegram runs, the `omp` spinner reads `Answering Telegram · "<prompt>"`.
- Widget lines bypass the host's text sanitizer, so every Telegram-controlled string shown there (thread name, bot name, queued prompts, error text) is stripped of control characters and bidi overrides.
- Messages a follower instance sends through the leader are counted by the leader process, not by the follower.
- `/telegram-status` repeats the counters as a `- messages:` line.
Messages sent while Pi is busy become queued turns. Priority lanes support control actions and model-switch continuations. Queue controls let you inspect, delete, promote, and dispatch work from Telegram without touching the terminal.
### Tools available to the agent
### Native Rich Markdown
| Tool | Purpose |
| --- | --- |
| `telegram_help` | Fetch the bridge contract on demand instead of keeping it in every prompt |
| `telegram_attach` | Attach a generated file to the active Telegram reply, or send it via direct delivery |
| `telegram_message` | Send Markdown text directly to the paired chat or an explicit `chat_id` (`/thread_id`) |
Rich Markdown is the default model-answer membrane. Complete assistant and guest model replies use Telegram's native Rich Message APIs, while tool-call rows, reasoning/thinking blocks, menus, status rows, queue controls, settings, diagnostics, and other harness-owned surfaces use explicit Telegram HTML/plain rendering. This keeps meaningful model-authored answers visually distinct from bridge-owned operational UI. Two Settings controls keep the layers separate: `Draft previews` toggles live `sendRichMessageDraft` frames, while `Assistant rendering` chooses final-answer delivery (`rich` Native Rich Markdown or `html` legacy Markdown-to-HTML).
Assistant replies can also carry top-level hidden HTML comments: `<!-- telegram_voice: ... -->` for voice output and `<!-- telegram_button: ... -->` for inline prompt buttons. They are stripped from the visible text.
### Files And Artifacts
### Environment configuration
Inbound files land under `<agent-dir>/tmp/telegram` and default to a 50 MiB limit. `telegram_attach` is the canonical outbound file path. During Telegram-originated turns it attaches to the active reply; during explicit local/TUI delivery it can send to the paired/default chat or routed Threaded Mode target.
### Voice And Media
Voice notes, audio, images, PDFs, and other media can pass through configured inbound handlers, programmatic handlers, or registered STT providers. Outbound voice can use configured `outboundHandlers` or registered TTS providers; `pi-telegram` owns reply policy and Telegram transport, while providers own synthesis.
### Buttons And Callbacks
Assistant replies can include top-level hidden `telegram_button` comments. The bridge strips the comments from visible text, renders inline buttons, and routes callbacks back into Pi as queued prompts or extension-owned callback actions.
### Threaded Mode And Multi-Instance Bus
Classic private DM mode is the base product mode. When Telegram private-chat Threaded Mode is available, the bridge enables a local leader/follower bus automatically:
- One live leader owns `getUpdates`.
- Followers are visible Pi processes started by the operator.
- Each connected instance gets a Telegram thread target.
- Follower session replacement automatically reconnects the new session context to the same thread instead of requiring another manual connect.
- Unknown threads are preserved and offered explicit reroute/restore choices.
- Telegram never launches hidden Pi processes.
| Mode | Best for | Runtime shape |
| --- | --- | --- |
| Classic DM | One running Pi instance and its active session controlled from one private bot chat | One polling owner, one queue/runtime surface |
| Threaded Mode | Several visible Pi instances sharing one bot | One leader owns transport; each named private-chat thread follows its assigned instance and current session |
## Environment Configuration
Most controls live in Pi commands or the Telegram menu. Environment variables remain for bootstrap and transport boundaries:
Most controls live in the Telegram menu or in `/telegram-*` commands. Environment variables cover bootstrap and transport boundaries:
| Area | Variables |
| --- | --- |
| Bot token bootstrap | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, `TELEGRAM_KEY` |
| HTTP proxy | `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, plus `NODE_USE_ENV_PROXY=1` or Node `--use-env-proxy` |
| Telegram network family | `PI_TELEGRAM_NETWORK_FAMILY=auto`, `ipv4`, `ipv6`, or `ipv4-fallback` |
| Bot token bootstrap (used as `/telegram-setup` prefill) | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_KEY`, `TELEGRAM_TOKEN`, `TELEGRAM_KEY` |
| Agent data root | `PI_CODING_AGENT_DIR` |
| Inbound file limit | `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES`, `TELEGRAM_MAX_FILE_SIZE_BYTES` |
| Outbound attachment limit | `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES`, `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES` |
| Telegram network family | `PI_TELEGRAM_NETWORK_FAMILY` = `auto` (default), `ipv4`, `ipv6`, `ipv4-fallback` |
| Inbound file limit | `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES`, `TELEGRAM_MAX_FILE_SIZE_BYTES` (default 50 MiB) |
| Outbound attachment limit | `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES`, `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES` (default 50 MiB) |
| HTTP proxy | Not implemented by the bridge: proxies come from the Node runtime itself (`node --use-env-proxy`, or `NODE_USE_ENV_PROXY=1`, honouring `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`) |
Defaults are chosen for ordinary private-bot use: saved config in `~/.pi/agent`, inbound temp files in `~/.pi/agent/tmp/telegram`, `assistant: { rendering: "rich", draftPreviews: false }` for assistant answer output, and native Telegram active status for long-running turns.
Defaults: config in `<agent-dir>/telegram.json`, inbound temp files in `<agent-dir>/tmp/telegram`, `assistant.rendering = "rich"` (native Rich Markdown) with `assistant.draftPreviews = false`, and native Telegram active status for long turns.
## Extension Platform
### Extension API
Companion extensions can integrate with Telegram without owning polling or transport:
Stable package entrypoints (see `exports` in `package.json`):
- Register Telegram slash commands.
- Add menu sections and settings surfaces.
- Add compact status rows.
- Deliver target-aware operational views and chat actions from companion code.
- Observe normalized assistant, reasoning, tool, compaction, and settlement activity without blocking Pi.
- Handle update/callback namespaces.
- Provide inbound preprocessing handlers.
- Provide outbound voice synthesis.
- Use direct delivery helpers for explicit local/TUI sends.
| Subpath | Content |
| --- | --- |
| `.` | Extension entrypoint (`index.ts`) |
| `./inbound`, `./outbound`, `./delivery`, `./activity`, `./updates`, `./commands`, `./sections`, `./status`, `./voice`, `./keyboard` | Public companion-extension APIs |
Stable public entrypoints are documented in [Public API](./docs/public-api.md), [Telegram Delivery API](./docs/delivery.md), [Telegram Activity API](./docs/activity.md), [Extension Sections](./docs/sections.md), [Inbound Handlers](./docs/inbound.md), [Outbound Handlers](./docs/outbound.md), [Updates](./docs/updates.md), and [Voice Integration](./docs/voice.md).
Deep `lib/*` imports are intentionally not exported. Contract details are in [`docs/public-api.md`](docs/public-api.md).
## Safety Boundaries
## Production and release
`pi-telegram` intentionally does not:
- Spawn hidden Pi follower processes.
- Pretend Telegram is a terminal or PTY.
- Forward arbitrary Telegram slash commands into the Pi TUI.
- Inject raw TTY input or terminal-control sequences.
- Replace Pi session lifecycle without an official Pi API.
- Let non-owner Telegram users control the bridge.
Telegram is a companion surface around a live Pi runtime, not a second runtime. It can compact the current session, but it cannot create, resume, fork, browse, or switch sessions until Pi exposes safe public extension APIs for those operations.
A Telegram prompt is a normal model turn in the active Pi session and therefore inherits that session's active post-compaction context; the bridge does not make token cost proportional only to the new mobile message. Current releases keep per-turn guidance small and transient, with detailed bridge instructions available on demand through `telegram_help` instead of persisted in every user turn. Pi session JSONL contains model history; profile-scoped pi-telegram `logs*.jsonl` contains redacted operational events and is never model context.
## Documentation Map
- [Architecture](./docs/architecture.md) — runtime, domains, queue, transport, and Threaded Mode overview.
- [Public API](./docs/public-api.md) — package entrypoints and stable companion-extension contracts.
- [Telegram Delivery API](./docs/delivery.md) — target-aware operational views, logical message handles, and lifecycle-safe transport.
- [Telegram Activity API](./docs/activity.md) — normalized lifecycle events, source identity, non-blocking delivery contexts, and consumer policy examples.
- [Inbound Handlers](./docs/inbound.md) — Telegram-to-Pi preprocessing pipelines.
- [Outbound Handlers](./docs/outbound.md) — final text/voice/file transformation and delivery.
- [Voice Integration](./docs/voice.md) — STT/TTS provider model and reply policies.
- [Extension Sections](./docs/sections.md) — Telegram-native companion UI surfaces.
- [Updates](./docs/updates.md) — update handler registry and callback interop.
- [Multi-Instance Bus](./docs/multi-instance-bus.md) — leader/follower routing in Threaded Mode.
- [Locks](./docs/locks.md) — singleton ownership and shared lock conventions.
- [UI Style](./docs/ui-style.md) — menu, emoji, labels, dialogs, and inline keyboard standards.
- [Callback Namespaces](./docs/callback-namespaces.md) — callback ownership and routing.
- [Command Templates](./docs/command-templates.md) — handler command-template conventions.
The docs index lives at [docs/README.md](./docs/README.md).
## Development
There is no build step: `omp` loads the TypeScript sources directly, and `npm pack` ships them.
```bash
npm run typecheck
npm test
npm run audit
npm run pack:check
npm run validate # the gate: typecheck + tests + audit + pack:check
npm run pack:check # npm pack --dry-run, 87 files, no secrets
```
Full validation:
Release flow: bump `package.json` version, add a matching `## <version>` section to `CHANGELOG.md`, then push the tag.
```bash
npm run validate
# after bumping the version and adding the CHANGELOG section
git tag v0.22.0-evandro.2 && git push origin v0.22.0-evandro.2
```
Project context:
`.github/workflows/release.yml` fails fast unless the tag equals the `package.json` version and `CHANGELOG.md` has a non-empty `## <version>` section; it then runs `gh release create` with those notes. Publishing to npm (`npm publish`, `publishConfig.access = public`) has not been done yet.
- [AGENTS.md](./AGENTS.md) — engineering and runtime conventions.
- [BACKLOG.md](./BACKLOG.md) — release-relevant open work.
- [CHANGELOG.md](./CHANGELOG.md) — completed delivery history.
## Project structure
```
index.ts extension entrypoint: composition root and runtime wiring
api/ public package subpaths (inbound, outbound, delivery, ...)
lib/ 54 domain modules: config, paths, polling, queue, routing,
rendering, outbound, bus leader/follower, locks, voice, menus
tests/ 55 node:test suites (unit + integration, no network)
docs/ architecture and per-surface contracts
.agents/skills/ project-local agent skills (Telegram Bot API reference, domain DAG)
AGENTS.md engineering and runtime conventions (project context, not docs)
BACKLOG.md open, evidence-gated work items
CHANGELOG.md release history used to build GitHub release notes
```
The doc index lives at [`docs/README.md`](docs/README.md).
## Verification
Everything below was executed in this repository on Node 24.20.0 / npm 11.19.0 (Linux, Manjaro), plus the GitHub Actions history of `main`:
| Command | Result |
| --- | --- |
| `npm ci` | exit 0, lockfile consistent, `found 0 vulnerabilities` |
| `npm run typecheck` | exit 0, `tsc --noEmit` |
| `npm test` | exit 0, `tests 1230 / pass 1229 / fail 0 / skipped 1` (the skip is a Windows-only case) |
| `npm run audit` | exit 0, `found 0 vulnerabilities` |
| `npm run pack:check` | exit 0, tarball `evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz` |
| `npm run validate` | exit 0 (the four gates above, in order, exactly as CI runs them) |
| `omp plugin list --json` | exit 0, `omp` 18.1.20 reads the package `omp` manifest (`displayName`, `extensions: ["./index.ts"]`, `compatibleWith`) |
| `omp plugin doctor` | `3 ok, 1 warnings, 0 errors` (warning: no plugin package manifest) |
| `omp install <local path>` | exit 0, `Linked @evandrodevbr/oh-my-pi-telegram from .` (the link was reverted afterwards to leave the machine as it was) |
| `omp install --link <path>` | fails: `Unknown option '--link'` (kept out of this README) |
| ad hoc e2e on `omp` 18.4.8 | throwaway harness, not kept in the repo: real `omp --mode rpc` with a sandboxed `HOME`, a fake Telegram Bot API and a fake OpenAI-compatible model. `/telegram-connect` finds `~/.omp/agent/telegram.json` and nothing is written under `~/.pi`; a Telegram prompt runs a model turn and the reply is delivered; Activity API handlers receive `agent-settled`; `/model` renders and marks the model that was switched locally in `omp` |
The test suite is deterministic and offline: transport calls are injected, so no live Telegram bot is required. Live Telegram paths (long polling, Rich Messages, Threaded Mode threading) are covered by the operator's own smoke sessions, not by this suite; the `Validate` workflow (npm ci + `npm run validate` on Node 24) is green on `main` (run 34838605928, 2026-09-14).
## Current state and limitations
- **Not published to npm**: `@evandrodevbr/oh-my-pi-telegram` returns 404 on the registry. Install from a local checkout; on top of that, `omp` 18.1.20's plugin source resolver rejects `npm` sources (`npm plugin sources are not yet supported. Use git-based sources instead.`).
- **No GitHub release yet**: the only tag, `v0.22.0-evandro.1`, produced a failed `Release` run (`CHANGELOG.md has no section for 0.22.0-evandro.1`, because the fork section was a level-3 heading). The heading is fixed in this commit, but the existing tag still points at the pre-fix commit, so the release must be created manually or the tag moved.
- **Fork lags upstream**: this fork stays on upstream `0.22.0` (SHA `afe09c5`), while `@llblab/pi-telegram` has since published 0.51.6. Upstream changes after 0.22.0 are not included. Upstream 0.51.6 loads in `omp` 18.4.8 but subscribes to five events `omp` never emits (`agent_settled`, `model_select`, `session_compact_failed`, `ui_prompt_start`, `ui_prompt_end`), so it is not a drop-in replacement.
- **Verified on one platform only**: Linux with Node 24.20.0. The suite contains Windows-specific cases (and the single skipped test is one of them), but Windows/macOS were not run here.
- **Requires a live `omp` instance and a real bot token** for end-to-end use; the repository suite does not cover the Telegram API surface, and the `omp` 18.4.8 checks above used a fake Bot API, so live Telegram behavior (Rich Messages, Threaded Mode, voice, files) is unverified on `omp`.
- **LLM/token cost applies**: a Telegram prompt is a normal model turn in the active session, so it inherits that session's post-compaction context.
- **Single-owner by design**: the first Telegram user to message the bot is the owner; other users are ignored. No multi-user or group-chat support.
- **omp host differences**: `omp` emits neither `agent_settled` nor `model_select`. The bridge derives the settled boundary from the first terminal `agent_end` (`willContinue` not `true`) and reads the live model through `ctx.models.current()`. Scoped-model persistence from the Telegram model menu is not offered on `omp`, and its scoped list is read only from a global `enabledModels` array.
- `bun.lock` is still present from upstream while CI validates with `package-lock.json`; the two are not kept in sync.
## Documentation
| Document | Content |
| --- | --- |
| [`docs/README.md`](docs/README.md) | Documentation index |
| [`docs/architecture.md`](docs/architecture.md) | Runtime, domains, queue, transport and Threaded Mode overview |
| [`docs/public-api.md`](docs/public-api.md) | Package entrypoints and stable extension contracts |
| [`docs/delivery.md`](docs/delivery.md) | Target-aware delivery, logical message handles, leader/follower transport |
| [`docs/activity.md`](docs/activity.md) | Normalized agent lifecycle events for companion extensions |
| [`docs/inbound.md`](docs/inbound.md), [`docs/outbound.md`](docs/outbound.md), [`docs/voice.md`](docs/voice.md) | Inbound pipelines, outbound transforms and STT/TTS providers |
| [`docs/sections.md`](docs/sections.md), [`docs/updates.md`](docs/updates.md), [`docs/callback-namespaces.md`](docs/callback-namespaces.md) | Companion UI sections, update routing and callback ownership |
| [`docs/multi-instance-bus.md`](docs/multi-instance-bus.md), [`docs/locks.md`](docs/locks.md) | Leader/follower routing and singleton lock conventions |
| [`docs/ui-style.md`](docs/ui-style.md), [`docs/command-templates.md`](docs/command-templates.md) | Inline UI standards and command-template conventions |
| [`BACKLOG.md`](BACKLOG.md), [`CHANGELOG.md`](CHANGELOG.md) | Open work and delivery history |
## License
MIT. See [`LICENSE`](LICENSE), which keeps the copyright notices of the upstream chain (`badlogic/pi-telegram`, `llblab/pi-telegram`) alongside this fork's.
## Review checks (2026-09-30)
Diagnostics report the resolved agent directory, including `omp` and `PI_CODING_AGENT_DIR` overrides, with isolated profile suffixes. Interval time injection recovers after a backward wall-clock adjustment and rejects non-finite intervals. The `brace-expansion` security override is updated to 5.0.12.
Focused offline checks: `node --experimental-strip-types --test tests/time-injection.test.ts tests/paths.test.ts tests/config.test.ts tests/status.test.ts`, followed by `npm run typecheck`. The Domain DAG check remains part of architecture validation. Run `npm run validate` in an environment that permits Unix-domain sockets; this review's restricted environment blocks the bus socket tests. No live bot, Telegram client or Windows IPC session was used.
+30 -4
View File
@@ -24,6 +24,7 @@ import * as MenuQueue from "./lib/menu-queue.ts";
import * as MenuSettings from "./lib/menu-settings.ts";
import * as Menu from "./lib/menu.ts";
import * as Model from "./lib/model.ts";
import * as Notices from "./lib/notices.ts";
import * as Outbound from "./lib/outbound.ts";
import * as Ownership from "./lib/ownership.ts";
import * as Paths from "./lib/paths.ts";
@@ -43,6 +44,7 @@ import * as TelegramApi from "./lib/telegram-api.ts";
import * as TextGroups from "./lib/text-groups.ts";
import * as ThreadReconciler from "./lib/thread-reconciler.ts";
import * as TimeInjection from "./lib/time-injection.ts";
import * as Traffic from "./lib/traffic.ts";
import * as Updates from "./lib/updates.ts";
import * as Voice from "./lib/voice.ts";
@@ -51,6 +53,7 @@ type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
// --- Extension Runtime ---
export default function (pi: Pi.ExtensionAPI) {
Paths.setHostAgentDirResolver(Pi.getHostAgentDir);
const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
const {
getCommands,
@@ -273,11 +276,13 @@ export default function (pi: Pi.ExtensionAPI) {
getFollowerThreadName: telegramBusFollowerRegistrationState.getThreadName,
getCurrentIdentity: getCurrentInstanceThreadIdentity,
});
const trafficCounters = Traffic.createTelegramTrafficCounters();
const statusRuntime = Status.createTelegramBridgeStatusRuntime<
Pi.ExtensionContext,
Queue.TelegramQueueItem<Pi.ExtensionContext>
>({
getConfig: configStore.get,
getTraffic: trafficCounters.snapshot,
getActiveProfileName: configStore.getActiveProfileName,
getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
isPollingActive: Polling.createTelegramPollingActivityReader(
@@ -291,6 +296,7 @@ export default function (pi: Pi.ExtensionAPI) {
hasPendingModelSwitch: pendingModelSwitchStore.has,
getQueuedItems: telegramQueueStore.getQueuedItems,
formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
getQueuedPreview: Queue.formatQueuedTelegramItemsPreview,
getRecentRuntimeEvents: runtimeEvents.getEvents,
getRuntimeLockState: lockRuntime.getStatusLabel,
...threadStatusProjectionRuntime,
@@ -322,6 +328,18 @@ export default function (pi: Pi.ExtensionAPI) {
getCwd: Pi.getExtensionContextCwd,
recordRuntimeEvent,
});
const notices = Notices.createTelegramNoticeRuntime({
getContext: telegramSessionContextStore.get,
notify: Pi.notifyExtensionContext,
recordRuntimeEvent,
});
const trafficBridge = Traffic.createTelegramTrafficStatusBridge({
counters: trafficCounters,
getContext: telegramSessionContextStore.get,
updateStatus,
recordRuntimeEvent,
onDeliveryFailed: notices.deliveryFailed,
});
// --- Telegram API ---
@@ -329,6 +347,8 @@ export default function (pi: Pi.ExtensionAPI) {
TelegramApi.createDefaultTelegramBridgeApiRuntime({
getBotToken: configStore.getBotToken,
recordRuntimeEvent,
onCallSucceeded: trafficBridge.onCallSucceeded,
onCallFailed: trafficBridge.onCallFailed,
});
const telegramBusFollowerClients =
BusFollower.createTelegramBusFollowerClientRuntime<
@@ -397,6 +417,7 @@ export default function (pi: Pi.ExtensionAPI) {
const currentModelRuntime = Model.createCurrentModelRuntime({
getContextModel,
updateStatus,
preferContextModel: Pi.isOmpHost(pi),
});
const queueMutationRuntime = Queue.createTelegramQueueMutationController({
...telegramQueueStore,
@@ -643,6 +664,8 @@ export default function (pi: Pi.ExtensionAPI) {
const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime({
configStore,
callApi: callTelegramApi,
onAuthorizedMessage: trafficBridge.onAuthorizedMessage,
onPaired: notices.paired,
getCurrentInstanceId() {
return telegramInstanceId;
},
@@ -698,10 +721,12 @@ export default function (pi: Pi.ExtensionAPI) {
resolveTimeLine: timeInjectionRuntime.resolveLine,
getThinkingLevel,
setThinkingLevel,
persistScopedModelPatterns: Pi.createScopedModelPatternPersister({
createSettingsManager: Pi.createSettingsManager,
clearCachedModelMenuInputs: modelMenuRuntime.clearCachedInputs,
}),
persistScopedModelPatterns: Pi.isOmpHost(pi)
? undefined
: Pi.createScopedModelPatternPersister({
createSettingsManager: Pi.createSettingsManager,
clearCachedModelMenuInputs: modelMenuRuntime.clearCachedInputs,
}),
setModel,
sendUserMessage,
isIdle,
@@ -818,6 +843,7 @@ export default function (pi: Pi.ExtensionAPI) {
stopTypingLoop: typing.stop,
updateStatus,
recordRuntimeEvent,
onRecovered: notices.recovered,
});
const recoverStaleTelegramTopicApiError = function (
apiBody: unknown,
+15
View File
@@ -329,6 +329,8 @@ export function registerTelegramLifecycleRuntimeHooks({
updateStatus,
recordRuntimeEvent,
}: TelegramLifecycleBindingDeps): void {
// Whether this bridge set the host spinner text, so it only clears its own.
let telegramWorkingMessageShown = false;
const agentEndResetter = Runtime.createTelegramAgentEndResetter({
abort,
typing,
@@ -589,6 +591,14 @@ export function registerTelegramLifecycleRuntimeHooks({
await agentStartWithDedupReset(event, ctx);
activityRuntime.onAgentStart(activeTurnRuntime.get()?.target);
startAgentActivityTypingLoop(ctx);
const telegramTurn = activeTurnRuntime.get();
if (telegramTurn) {
Pi.setExtensionWorkingMessage(
ctx,
Status.formatTelegramWorkingMessage(telegramTurn.statusSummary),
);
telegramWorkingMessageShown = true;
}
},
async onToolExecutionStart(event, ctx) {
if (!isSessionContextActive(ctx)) return;
@@ -638,7 +648,12 @@ export function registerTelegramLifecycleRuntimeHooks({
onAgentSettled(_event, ctx) {
if (!isSessionContextActive(ctx)) return;
activityRuntime.onAgentSettled();
if (telegramWorkingMessageShown) {
telegramWorkingMessageShown = false;
Pi.setExtensionWorkingMessage(ctx, undefined);
}
},
settleOnTerminalAgentEnd: Pi.isOmpHost(pi),
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
isConfigured: configStore.hasBotToken,
isProactivePushEnabled,
+9 -4
View File
@@ -669,7 +669,7 @@ export function resolveTelegramTimeConfig(
? raw.injectionMode
: "hidden";
const interval =
typeof raw?.interval === "number" && raw.interval > 0
typeof raw?.interval === "number" && Number.isFinite(raw.interval) && raw.interval > 0
? raw.interval
: 60 * 60 * 1000;
const timezone = getSystemTimezone();
@@ -777,6 +777,8 @@ export interface TelegramUserPairingRuntimeDeps<TContext> {
setAllowedUserId: (userId: number) => void;
persistConfig: () => Promise<void>;
updateStatus: (ctx: TContext) => void;
/** Called once the user was accepted as owner and the config persisted. */
onPaired?: (userId: number, ctx: TContext) => void;
}
export interface TelegramUserPairingRuntime<TContext> {
@@ -827,13 +829,16 @@ export function createTelegramUserPairingRuntime<TContext>(
deps: TelegramUserPairingRuntimeDeps<TContext>,
): TelegramUserPairingRuntime<TContext> {
return {
pairIfNeeded: (userId, ctx) =>
pairTelegramUserIfNeeded(userId, {
pairIfNeeded: async (userId, ctx) => {
const paired = await pairTelegramUserIfNeeded(userId, {
allowedUserId: deps.getAllowedUserId(),
ctx,
setAllowedUserId: deps.setAllowedUserId,
persistConfig: deps.persistConfig,
updateStatus: deps.updateStatus,
}),
});
if (paired) deps.onPaired?.(userId, ctx);
return paired;
},
};
}
+10
View File
@@ -23,6 +23,7 @@ import type {
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
} from "./pi.ts";
import { isTerminalAgentEnd } from "./pi.ts";
let resetTransportReplyDedupFn: (() => void) | undefined;
@@ -110,6 +111,12 @@ export interface TelegramLifecycleRegistrationDeps {
event: AgentSettledEvent,
ctx: ExtensionContext,
) => Promise<void> | void;
/**
* Hosts that never emit `agent_settled` (omp) get it synthesized from the
* first terminal `agent_end`. Leave unset on hosts that emit it natively so
* the settle point is not announced early.
*/
settleOnTerminalAgentEnd?: boolean;
}
export interface TelegramSessionLifecycleHooks {
@@ -562,6 +569,9 @@ export function registerTelegramLifecycleHooks(
pi.on("agent_end", async (event, ctx) => {
if (!isActive(ctx)) return;
await deps.onAgentEnd(event, ctx);
if (deps.settleOnTerminalAgentEnd && isTerminalAgentEnd(event)) {
await deps.onAgentSettled?.({ type: "agent_settled" }, ctx);
}
});
pi.on("agent_settled", async (event, ctx) => {
if (!isActive(ctx)) return;
+16 -2
View File
@@ -55,15 +55,25 @@ export type CurrentModelRuntime<
> = CurrentModelStore<TContext, TModel> &
CurrentModelUpdateRuntime<TContext, TModel>;
/**
* `preferContextModel` makes the host's live context model win over the stored
* one. Pi pushes model changes through `model_select`, so its stored value is
* current; omp has no such event, so its stored value would go stale after a
* local `/model` switch.
*/
export function createCurrentModelStore<
TContext,
TModel extends MenuModel = MenuModel,
>(
getContextModel: (ctx: TContext) => TModel | undefined,
preferContextModel = false,
): CurrentModelStore<TContext, TModel> {
let currentModel: TModel | undefined;
return {
get: (ctx) => currentModel ?? getContextModel(ctx),
get: (ctx) =>
preferContextModel
? (getContextModel(ctx) ?? currentModel)
: (currentModel ?? getContextModel(ctx)),
getStored: () => currentModel,
set: (model) => {
currentModel = model;
@@ -96,8 +106,12 @@ export function createCurrentModelRuntime<
>(deps: {
getContextModel: (ctx: TContext) => TModel | undefined;
updateStatus: (ctx: TContext) => void;
preferContextModel?: boolean;
}): CurrentModelRuntime<TContext, TModel> {
const store = createCurrentModelStore(deps.getContextModel);
const store = createCurrentModelStore(
deps.getContextModel,
deps.preferContextModel,
);
return {
...store,
...createCurrentModelUpdateRuntime({
+104
View File
@@ -0,0 +1,104 @@
/**
* Telegram operator notices
* Zones: telegram ui, tui
* Owns the one-off host toasts (owner paired, connection recovered, reply not delivered) and their throttling
*/
import {
sanitizeTelegramStatusText,
summarizeTelegramStatusError,
} from "./status.ts";
export type TelegramNoticeLevel = "info" | "warning" | "error";
/** A failed delivery raises at most one notice per this window. */
export const TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS = 30_000;
export interface TelegramNoticeRuntimeDeps<TContext> {
/** Current session context, when the bridge has one. */
getContext: () => TContext | undefined;
notify: (ctx: TContext, message: string, level: TelegramNoticeLevel) => void;
recordRuntimeEvent: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
now?: () => number;
}
export interface TelegramNoticeRuntime<TContext> {
/** The first Telegram user was accepted as the bridge owner. */
paired: (userId: number, ctx: TContext) => void;
/** Polling works again after at least one failed attempt. */
recovered: (ctx: TContext) => void;
/** A message could not be delivered; throttled, uses the current context. */
deliveryFailed: (error: unknown) => void;
}
function describeTelegramDeliveryFailure(error: unknown): {
message: string;
level: TelegramNoticeLevel;
} {
const message = error instanceof Error ? error.message : String(error);
const summary = summarizeTelegramStatusError(message);
if (summary?.deliveryUnknown) {
return {
message: "Telegram: reply delivery unconfirmed (check the chat)",
level: "warning",
};
}
if (summary) {
return {
message: `Telegram: reply not delivered (${summary.label})`,
level: summary.kind === "fatal" ? "error" : "warning",
};
}
const raw = sanitizeTelegramStatusText(message);
const reason = raw.length > 80 ? `${raw.slice(0, 79)}…` : raw;
return { message: `Telegram: reply not delivered (${reason})`, level: "error" };
}
/**
* Toasts never propagate failures into the path that raised them: a stale
* session context only produces a recorded runtime event.
*/
export function createTelegramNoticeRuntime<TContext>(
deps: TelegramNoticeRuntimeDeps<TContext>,
): TelegramNoticeRuntime<TContext> {
const now = deps.now ?? Date.now;
let lastDeliveryNoticeAtMs: number | undefined;
const show = (
ctx: TContext,
message: string,
level: TelegramNoticeLevel,
): void => {
try {
deps.notify(ctx, message, level);
} catch (error) {
deps.recordRuntimeEvent("status", error, { phase: "notice" });
}
};
return {
paired(userId, ctx) {
show(ctx, `Telegram paired with user ${userId}`, "info");
},
recovered(ctx) {
show(ctx, "Telegram reconnected", "info");
},
deliveryFailed(error) {
const ctx = deps.getContext();
if (ctx === undefined) return;
const nowMs = now();
if (
lastDeliveryNoticeAtMs !== undefined &&
nowMs - lastDeliveryNoticeAtMs <
TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS
) {
return;
}
lastDeliveryNoticeAtMs = nowMs;
const failure = describeTelegramDeliveryFailure(error);
show(ctx, failure.message, failure.level);
},
};
}
+28 -21
View File
@@ -1,19 +1,32 @@
/**
* Telegram bridge path resolution for Pi-compatible runtimes
* Zones: telemetry paths, filesystem, runtime identity
* Owns agent-dir detection and extension-local path derivation
* Owns agent-dir resolution and extension-local path derivation
*
* This domain is pure/path-only: it resolves directories and file paths
* from environment and runtime identity. It does not read config, manage
* state, or import broader Telegram domains.
* This domain is pure/path-only: it resolves directories and file paths from
* the environment and the host runtime's own agent directory. It does not read
* config, manage state, or import broader Telegram domains or the Pi SDK.
*/
import { homedir } from "node:os";
import { join, resolve } from "node:path";
export interface TelegramAgentDirResolutionInput {
env?: Partial<Pick<NodeJS.ProcessEnv, "PI_CODING_AGENT_DIR">>;
execPath?: string;
argv?: readonly string[];
}
let hostAgentDirResolver: (() => string) | undefined;
/**
* Register the host runtime's own agent-directory lookup (the Pi SDK
* `getAgentDir()`). It is registered by the composition root instead of
* imported here: importing the Pi SDK from this leaf made every domain that
* touches a path, and every child process the test suite spawns, pay about a
* second of SDK startup.
*/
export function setHostAgentDirResolver(
resolver: (() => string) | undefined,
): void {
hostAgentDirResolver = resolver;
}
/**
@@ -21,23 +34,18 @@ export interface TelegramAgentDirResolutionInput {
*
* Precedence:
* 1. `PI_CODING_AGENT_DIR` env variable, when explicitly set.
* 2. Detect Pi-compatible runtime identity from the executable or argv[1]
* (e.g. OMP vs standard Pi agent).
* 3. Fallback: `~/.pi/agent`.
* 2. The registered host agent directory. Pi returns `~/.pi/agent`; omp's Pi
* shim returns its profile-aware directory (default `~/.omp/agent`). The
* executable and argv are not inspected: omp installed through Bun runs as
* `bun .../dist/cli.js`, which neither contains nor starts with `omp`.
* 3. Fallback when no host is registered: `~/.pi/agent`.
*/
export function resolveAgentDir(
input: TelegramAgentDirResolutionInput = {},
): string {
const env = input.env ?? process.env;
if (env.PI_CODING_AGENT_DIR) return resolve(env.PI_CODING_AGENT_DIR);
const execPath = input.execPath ?? process.execPath;
const argv = input.argv ?? process.argv;
const execBasename = execPath.toLowerCase().split(/[\\/]/u).pop() ?? "";
const argv1Last = (argv[1] ?? "").toLowerCase().split(/[\\/]/u).pop() ?? "";
if (execBasename.startsWith("omp") || argv1Last.startsWith("omp")) {
return join(homedir(), ".omp", "agent");
}
return join(homedir(), ".pi", "agent");
return hostAgentDirResolver?.() ?? join(homedir(), ".pi", "agent");
}
/** Telegram bridge configuration file (<agentDir>/telegram.json). */
@@ -75,11 +83,10 @@ export function getTelegramDiagnosticsDisplayPaths(profileName?: string): {
state: string;
logs: string;
} {
const suffix = getTelegramProfilePathSuffix(profileName);
const profileSlug = suffix.slice(1);
const agentDir = resolveAgentDir();
return {
state: `~/.pi/agent/tmp/telegram/state${suffix}.json`,
logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
state: resolveTelegramProfileTempFilePath("state", "json", agentDir, profileName),
logs: resolveTelegramProfileTempFilePath("logs", "jsonl", agentDir, profileName),
};
}
+106 -2
View File
@@ -20,6 +20,7 @@ import {
type SessionStartEvent,
type SlashCommandInfo,
SettingsManager,
getAgentDir,
} from "@earendil-works/pi-coding-agent";
export type {
@@ -39,6 +40,13 @@ export type {
SlashCommandInfo,
};
/**
* Agent data directory owned by the host runtime. Pi returns its own root and
* omp's legacy Pi shim forwards omp's profile-aware directory, so no argv or
* executable sniffing is needed.
*/
export { getAgentDir as getHostAgentDir };
export interface ToolExecutionStartEvent {
type: "tool_execution_start";
toolCallId: string;
@@ -69,6 +77,26 @@ export interface PiSettingsManager {
setEnabledModels: (patterns: string[] | undefined) => void;
}
/**
* omp's `Settings` is what its legacy shim returns from
* `SettingsManager.create()`. It has no `reload()` or `get/setEnabledModels()`:
* it reloads through `reloadFromDisk()` and keeps `enabledModels` as a
* path-scoped typed setting.
*/
interface OmpSettingsSurface {
reloadFromDisk: () => Promise<void>;
flush: () => Promise<void>;
getGlobalSettings: () => { enabledModels?: unknown };
}
function isOmpSettings(value: object): value is OmpSettingsSurface {
return (
"reloadFromDisk" in value &&
"flush" in value &&
"getGlobalSettings" in value
);
}
export type PiSlashCommandInfo = SlashCommandInfo;
export type PiRunMode = "tui" | "rpc" | "json" | "print";
@@ -150,7 +178,22 @@ export function createExtensionApiRuntimePorts(
}
export function createSettingsManager(cwd: string): PiSettingsManager {
return SettingsManager.create(cwd);
const manager: PiSettingsManager = SettingsManager.create(cwd);
if (!isOmpSettings(manager)) return manager;
return {
reload: () => manager.reloadFromDisk(),
flush: () => manager.flush(),
getEnabledModels: () => {
const enabled = manager.getGlobalSettings().enabledModels;
return Array.isArray(enabled) &&
enabled.every((pattern) => typeof pattern === "string")
? enabled
: undefined;
},
setEnabledModels: () => {
throw new Error("Scoped model persistence is not supported on omp.");
},
};
}
export function createScopedModelPatternPersister(deps: {
@@ -167,16 +210,77 @@ export function createScopedModelPatternPersister(deps: {
};
}
interface OmpModelQuery {
current: () => ExtensionContext["model"];
}
function hasOmpModelQuery(
ctx: ExtensionContext,
): ctx is ExtensionContext & { models: OmpModelQuery } {
return (
"models" in ctx &&
typeof ctx.models === "object" &&
ctx.models !== null &&
"current" in ctx.models &&
typeof ctx.models.current === "function"
);
}
/**
* omp builds command contexts by spreading a base context, which evaluates the
* `model` getter once: a context stored from `/telegram-connect` would report
* the model of that moment forever. omp's `ctx.models.current()` closes over
* the live session model, so prefer it when present.
*/
export function getExtensionContextModel(
ctx: ExtensionContext,
): ExtensionContext["model"] {
return ctx.model;
return hasOmpModelQuery(ctx) ? ctx.models.current() : ctx.model;
}
export function getExtensionContextCwd(ctx: ExtensionContext): string {
return ctx.cwd;
}
/**
* True when the extension API belongs to omp. omp's `ExtensionAPI` carries
* `typebox` and `pi` module facades that upstream Pi's API does not have.
*/
export function isOmpHost(api: ExtensionAPI): boolean {
return "typebox" in api && "pi" in api;
}
/**
* omp marks an `agent_end` with `willContinue: true` whenever it already
* scheduled a retry, compaction continuation, todo/plan reminder, or async
* wake. Any other `agent_end` is terminal, which is what Pi reports through
* `agent_settled`.
*/
export function isTerminalAgentEnd(event: AgentEndEvent): boolean {
return !("willContinue" in event && event.willContinue === true);
}
/** Show a one-off toast in the host UI; a no-op where the host has no UI. */
export function notifyExtensionContext(
ctx: ExtensionContext,
message: string,
level: "info" | "warning" | "error",
): void {
ctx.ui.notify(message, level);
}
/**
* Replace the host spinner text, or restore its default with `undefined`.
* Pi-compatible hosts do not all expose `setWorkingMessage`; the spinner text
* is cosmetic, so a host without it is skipped instead of failing the turn.
*/
export function setExtensionWorkingMessage(
ctx: ExtensionContext,
message?: string,
): void {
ctx.ui.setWorkingMessage?.(message);
}
export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
return ctx.isIdle();
}
+47 -4
View File
@@ -163,6 +163,7 @@ export function createTelegramPollingControllerRuntime<
updateStatus: deps.updateStatus,
sleep: deps.sleep,
maxUpdateFailures: deps.maxUpdateFailures,
onRecovered: deps.onRecovered,
recordRuntimeEvent: deps.recordRuntimeEvent,
}),
updateStatus: deps.updateStatus,
@@ -913,6 +914,8 @@ export interface TelegramPollLoopDeps<
onStatusReset: () => void;
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
maxUpdateFailures?: number;
/** Called on the first successful poll after a failed attempt. */
onRecovered?: () => void;
}
export interface TelegramPollLoopRunnerDeps<
@@ -930,6 +933,8 @@ export interface TelegramPollLoopRunnerDeps<
updateStatus: (ctx: TContext, message?: string) => void;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
maxUpdateFailures?: number;
/** Called on the first successful poll after a failed attempt. */
onRecovered?: (ctx: TContext) => void;
}
export function sleepTelegramPollingRetry(
@@ -971,7 +976,15 @@ export function createTelegramPollLoopRunner<
config: deps.getConfig(),
deleteWebhook: deps.deleteWebhook,
getUpdates: deps.getUpdates,
persistConfig: deps.persistConfig,
// The loop keeps the config object captured when polling started, but the
// store swaps in a fresh object on every persist. Persisting that stale
// copy erased later changes such as the paired user, so only the poll
// offset is carried onto the store's current config.
persistConfig: (polled) => {
const current = deps.getConfig();
current.lastUpdateId = polled.lastUpdateId;
return deps.persistConfig(current);
},
handleUpdate: deps.handleUpdate,
onErrorStatus: (message) => {
updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
@@ -986,6 +999,15 @@ export function createTelegramPollLoopRunner<
},
sleep,
maxUpdateFailures: deps.maxUpdateFailures,
onRecovered: () => {
try {
deps.onRecovered?.(ctx);
} catch (error) {
deps.recordRuntimeEvent?.("polling", error, {
phase: "recovered-notice",
});
}
},
recordRuntimeEvent: deps.recordRuntimeEvent,
});
}
@@ -1033,13 +1055,34 @@ export async function runTelegramPollLoop<
const admittedUpdates = new Set<number>();
let handledUpdateFailureRethrown = false;
let consecutiveGetUpdatesConflicts = 0;
// An error status is on screen; it stays until polling works again, so a
// slow retry (for example a long rate-limit wait) never shows as healthy.
let errorShown = false;
// The last getUpdates failed, so the next successful one is a recovery.
// Failures of an update handler do not count: the transport kept working.
let transportDown = false;
const showError = (message: string): void => {
errorShown = true;
deps.onErrorStatus(message);
};
while (!deps.signal.aborted) {
let inGetUpdates = false;
try {
inGetUpdates = true;
const updates = await deps.getUpdates(
buildTelegramLongPollRequest(deps.config.lastUpdateId),
deps.signal,
);
inGetUpdates = false;
consecutiveGetUpdatesConflicts = 0;
if (errorShown) {
errorShown = false;
deps.onStatusReset();
}
if (transportDown) {
transportDown = false;
deps.onRecovered?.();
}
for (const update of updates) {
if (admittedUpdates.has(update.update_id)) {
deps.config.lastUpdateId = update.update_id;
@@ -1068,7 +1111,7 @@ export async function runTelegramPollLoop<
throw error;
}
const message = getTelegramPollingErrorMessage(error);
deps.onErrorStatus(
showError(
`skipping Telegram update ${update.update_id} after ${failureCount} failures: ${message}`,
);
admittedUpdates.add(update.update_id);
@@ -1097,10 +1140,10 @@ export async function runTelegramPollLoop<
continue;
}
consecutiveGetUpdatesConflicts = 0;
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
if (inGetUpdates) transportDown = true;
showError(getTelegramPollingErrorMessage(error));
await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
if (deps.signal.aborted) return;
deps.onStatusReset();
}
}
}
+19
View File
@@ -499,6 +499,25 @@ export function formatQueuedTelegramItemsStatus<TContext = unknown>(
return items.length === 0 ? "" : ` +${items.length}`;
}
/**
* Summaries of the waiting items in dispatch order, for the status preview.
* Display must never break the status update, so a malformed item only
* costs the ordering.
*/
export function formatQueuedTelegramItemsPreview<TContext = unknown>(
items: TelegramQueueItem<TContext>[],
): string[] {
let ordered = items;
try {
ordered = [...items].sort(compareTelegramQueueItems);
} catch {
// Keep the store order.
}
return ordered
.map((item) => item.statusSummary.trim())
.filter((summary) => summary.length > 0);
}
export function truncateTelegramQueueSummary(
text: string,
maxWords = 5,
+6
View File
@@ -554,6 +554,10 @@ export interface TelegramInboundRouteRuntimeDeps<
method: string,
body: Record<string, unknown>,
) => Promise<TResponse>;
/** Called for every message from the paired owner, before it is dispatched. */
onAuthorizedMessage?: (ctx: TContext) => void;
/** Called when the first Telegram user is accepted as the bridge owner. */
onPaired?: (userId: number, ctx: TContext) => void;
getCurrentInstanceId?: () => string | undefined;
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
@@ -2074,6 +2078,7 @@ export function createTelegramInboundRouteRuntime<
setAllowedUserId: deps.configStore.setAllowedUserId,
persistConfig: deps.configStore.persist,
updateStatus: deps.updateStatus,
onPaired: deps.onPaired,
removePendingMediaGroupMessages: deps.mediaGroupRuntime.removeMessages,
removeQueuedTelegramTurnsByMessageIds:
deps.queueMutationRuntime.removeByMessageIds,
@@ -2086,6 +2091,7 @@ export function createTelegramInboundRouteRuntime<
handleAuthorizedTelegramCallbackQuery: callbackHandler,
sendTextReply: deps.sendTextReply,
handleAuthorizedTelegramMessage: async (message, ctx) => {
deps.onAuthorizedMessage?.(ctx);
if (typeof message.message_thread_id === "number") {
await deps.handleTelegramThreadTargetObserved?.(
{
+307 -25
View File
@@ -4,6 +4,15 @@
* Builds usage, cost, and context summaries for the interactive Telegram status view
*/
/** Message counters the status shows; structurally satisfied by the traffic domain. */
export interface TelegramStatusTraffic {
received: number;
sent: number;
/** Deliveries that failed after the transport retries; absent means none. */
failed?: number;
lastAtMs?: number;
}
export type TelegramStatusQueueLane = "control" | "priority" | "default";
export interface TelegramUsageStats {
@@ -191,6 +200,7 @@ export interface TelegramBridgeStatusLineState {
instanceSlot?: string;
instanceThreadName?: string;
lockState?: string;
traffic?: TelegramStatusTraffic;
pollingActive: boolean;
lastUpdateId?: number;
activeSourceMessageIds?: number[];
@@ -215,12 +225,28 @@ export interface TelegramStatusBarTheme {
token: "accent" | "error" | "muted" | "warning" | "success",
text: string,
) => string;
/**
* omp symbol presets (unicode, nerd font, ascii) so the status follows the
* user's glyph set. Hosts that do not expose them get the unicode fallbacks.
*/
status?: {
enabled?: string;
shadowed?: string;
running?: string;
warning?: string;
error?: string;
disabled?: string;
};
sep?: { dot?: string };
icon?: { input?: string; output?: string; time?: string };
}
export interface TelegramStatusBarState {
hasBotToken: boolean;
pollingActive: boolean;
paired: boolean;
botUsername?: string;
traffic?: TelegramStatusTraffic;
busRole?: TelegramBridgeBusRole;
busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
instanceSlot?: string;
@@ -229,13 +255,22 @@ export interface TelegramStatusBarState {
processing: boolean;
processingStatus?: string;
queuedStatus: string;
/** Summaries of the queued turns, in dispatch order. */
queuedPreview?: string[];
error?: string;
}
export interface TelegramStatusRuntimeContext {
/** Host run mode; the colored widget is only used in the interactive TUI. */
mode?: string;
ui: {
theme: TelegramStatusBarTheme;
setStatus: (key: string, text: string) => void;
setStatus(key: string, text: string | undefined): void;
setWidget?(
key: string,
content: string[] | undefined,
options?: { placement?: "aboveEditor" | "belowEditor" },
): void;
};
}
@@ -272,6 +307,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
hasPendingModelSwitch: () => boolean;
getQueuedItems: () => TQueueItem[];
formatQueuedStatus: (items: TQueueItem[]) => string;
getQueuedPreview?: (items: TQueueItem[]) => string[];
getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
getRuntimeLockState?: () => string;
getBusRole?: () => TelegramBridgeBusRole | undefined;
@@ -298,6 +334,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
getInstanceSlot?: () => string | undefined;
getInstanceThreadName?: () => string | undefined;
getNowMs?: () => number;
getTraffic?: () => TelegramStatusTraffic;
}
export interface TelegramBridgeStatusLineOptions {
@@ -565,13 +602,19 @@ export function createTelegramStatusRuntime<
const statusKey = deps.statusKey ?? "telegram";
return {
updateStatus: (ctx, error) => {
ctx.ui.setStatus(
statusKey,
buildTelegramStatusBarText(
ctx.ui.theme,
deps.getStatusBarState(ctx, error),
),
const lines = buildTelegramStatusBarLines(
ctx.ui.theme,
deps.getStatusBarState(ctx, error),
);
if (ctx.mode === "tui" && ctx.ui.setWidget) {
// The host strips ANSI from status text, so setStatus would drop the
// theme colors. String widgets keep them; clear the plain footer entry
// so the state is not shown twice.
ctx.ui.setWidget(statusKey, lines, { placement: "belowEditor" });
ctx.ui.setStatus(statusKey, undefined);
return;
}
ctx.ui.setStatus(statusKey, lines[0]);
},
getStatusLines: (options) =>
buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options),
@@ -599,6 +642,8 @@ export function createTelegramBridgeStatusRuntime<
hasBotToken: !!config.botToken,
pollingActive: deps.isPollingActive(),
paired: !!config.allowedUserId,
botUsername: config.botUsername,
traffic: deps.getTraffic?.(),
busRole: deps.getBusRole?.(),
busLifecyclePhase: deps.getBusLifecyclePhase?.(),
instanceSlot: deps.getInstanceSlot?.(),
@@ -618,6 +663,7 @@ export function createTelegramBridgeStatusRuntime<
queuedItems: queuedItems.length,
}),
queuedStatus: deps.formatQueuedStatus(queuedItems),
queuedPreview: deps.getQueuedPreview?.(queuedItems),
error,
};
},
@@ -639,6 +685,7 @@ export function createTelegramBridgeStatusRuntime<
instanceSlot: deps.getInstanceSlot?.(),
instanceThreadName: deps.getInstanceThreadName?.(),
lockState: deps.getRuntimeLockState?.(),
traffic: deps.getTraffic?.(),
pollingActive: deps.isPollingActive(),
lastUpdateId: config.lastUpdateId,
activeSourceMessageIds: deps.getActiveSourceMessageIds(),
@@ -761,38 +808,271 @@ function getTelegramStatusBarLabel(state: TelegramStatusBarState): string {
return threadName;
}
interface TelegramStatusBarGlyphs {
enabled: string;
shadowed: string;
running: string;
warning: string;
error: string;
disabled: string;
dot: string;
input: string;
output: string;
time: string;
}
function resolveTelegramStatusBarGlyphs(
theme: TelegramStatusBarTheme,
): TelegramStatusBarGlyphs {
return {
enabled: theme.status?.enabled ?? "●",
shadowed: theme.status?.shadowed ?? "○",
running: theme.status?.running ?? "⟳",
warning: theme.status?.warning ?? "⚠",
error: theme.status?.error ?? "✘",
disabled: theme.status?.disabled ?? "⦸",
dot: theme.sep?.dot ?? " · ",
input: theme.icon?.input ?? "↓",
output: theme.icon?.output ?? "↑",
time: theme.icon?.time ?? "⏱",
};
}
function formatTelegramStatusBarClock(timestampMs: number): string {
const date = new Date(timestampMs);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
/** Bot name, message counters and last activity, each behind a muted dot. */
function buildTelegramStatusBarDetails(
theme: TelegramStatusBarTheme,
glyphs: TelegramStatusBarGlyphs,
state: TelegramStatusBarState,
): string {
const parts: string[] = [];
if (state.botUsername) {
parts.push(`@${sanitizeTelegramStatusText(state.botUsername)}`);
}
if (state.traffic) {
const failed = state.traffic.failed
? ` ${glyphs.error}${state.traffic.failed}`
: "";
parts.push(
`${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}${failed}`,
);
if (state.traffic.lastAtMs !== undefined) {
parts.push(
`${glyphs.time} ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`,
);
}
}
return parts
.map((part) => theme.fg("muted", `${glyphs.dot}${part}`))
.join("");
}
function buildTelegramTrafficLines(
state: Pick<TelegramBridgeStatusLineState, "traffic">,
): string[] {
if (!state.traffic) return [];
const last =
state.traffic.lastAtMs === undefined
? ""
: `, last ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`;
return [
`- messages: ${state.traffic.received} received, ${state.traffic.sent} sent${state.traffic.failed ? `, ${state.traffic.failed} failed` : ""}${last}`,
];
}
/**
* Strip control characters and bidi overrides. Widget lines bypass the host's
* status sanitizer, and thread names, bot names, queue summaries and error text
* can all originate from Telegram, so none may carry terminal escapes.
*/
export function sanitizeTelegramStatusText(text: string): string {
return text
.replace(
/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g,
" ",
)
.replace(/ +/g, " ")
.trim();
}
export interface TelegramStatusErrorSummary {
/** Short state text such as "invalid token". */
label: string;
/** What the operator can do next, or "retrying" while the bridge retries. */
hint?: string;
/** fatal needs the operator, attention needs a look, retrying heals itself. */
kind: "fatal" | "attention" | "retrying";
/**
* The failed call may still have reached Telegram: a non-idempotent send hit
* a transport failure after the request left, so the outcome is unknown.
*/
deliveryUnknown?: boolean;
}
/**
* Turn a raw transport or API failure into a short state and next step.
* Returns undefined for anything unrecognized so the raw text stays visible.
*/
export function summarizeTelegramStatusError(
message: string,
): TelegramStatusErrorSummary | undefined {
if (/may have committed before transport failed/i.test(message)) {
return {
kind: "attention",
label: "delivery unconfirmed",
deliveryUnknown: true,
};
}
if (/HTTP 401\b|\bUnauthorized\b/i.test(message)) {
return { kind: "fatal", label: "invalid token", hint: "/telegram-setup" };
}
if (/HTTP 429\b|Too Many Requests|retry[ _]after/i.test(message)) {
const seconds = /retry[ _]after[:= ]+(\d+)/i.exec(message)?.[1];
return {
kind: "retrying",
label: seconds ? `rate limited ${seconds}s` : "rate limited",
hint: "retrying",
};
}
if (
/HTTP 409\b|\bConflict\b|terminated by other getUpdates/i.test(message)
) {
return {
kind: "attention",
label: "another poller is active",
hint: "/telegram-status",
};
}
if (
/HTTP 5\d\d\b|Bad Gateway|Service Unavailable|Internal Server Error/i.test(
message,
)
) {
return { kind: "retrying", label: "Telegram unavailable", hint: "retrying" };
}
if (
/Unable to connect|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|fetch failed|network|timed? out|socket hang up/i.test(
message,
)
) {
return { kind: "retrying", label: "offline", hint: "retrying" };
}
return undefined;
}
function formatTelegramStatusBarError(
theme: TelegramStatusBarTheme,
glyphs: TelegramStatusBarGlyphs,
label: string,
error: string,
): string {
const summary = summarizeTelegramStatusError(error);
if (!summary) {
const raw = sanitizeTelegramStatusText(error);
const shown = raw.length > 100 ? `${raw.slice(0, 99)}…` : raw;
return `${label} ${theme.fg("error", `${glyphs.error} error`)} ${theme.fg("muted", shown)}`;
}
const token = summary.kind === "fatal" ? "error" : "warning";
const glyph =
summary.kind === "fatal"
? glyphs.error
: summary.kind === "retrying"
? glyphs.running
: glyphs.warning;
const hint = summary.hint
? theme.fg("muted", `${glyphs.dot}${summary.hint}`)
: "";
return `${label} ${theme.fg(token, `${glyph} ${summary.label}`)}${hint}`;
}
export function buildTelegramStatusBarText(
theme: TelegramStatusBarTheme,
state: TelegramStatusBarState,
): string {
const label = theme.fg("accent", getTelegramStatusBarLabel(state));
if (state.error) {
return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
}
const glyphs = resolveTelegramStatusBarGlyphs(theme);
const label = theme.fg(
"accent",
sanitizeTelegramStatusText(getTelegramStatusBarLabel(state)),
);
const hint = (text: string) => theme.fg("muted", `${glyphs.dot}${text}`);
const queued = state.queuedStatus
? theme.fg("success", state.queuedStatus)
: "";
if (!state.hasBotToken)
return `${label} ${theme.fg("muted", "not configured")}${queued}`;
if (!state.paired)
return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`;
if (state.busLifecyclePhase === "electing")
return `${label} ${theme.fg("warning", "electing")}${queued}`;
if (!state.pollingActive && state.busRole !== "follower")
return `${theme.fg("accent", "telegram")} ${theme.fg("muted", "disconnected")}${queued}`;
if (state.error) {
return formatTelegramStatusBarError(theme, glyphs, label, state.error);
}
if (!state.hasBotToken) {
return `${label} ${theme.fg("muted", `${glyphs.disabled} not configured`)}${queued}${hint("/telegram-setup")}`;
}
if (!state.paired) {
const bot = state.botUsername
? `@${sanitizeTelegramStatusText(state.botUsername)}`
: "the bot";
return `${label} ${theme.fg("warning", `${glyphs.warning} awaiting pairing`)}${queued}${hint(`send /start to ${bot}`)}`;
}
if (state.busLifecyclePhase === "electing") {
return `${label} ${theme.fg("warning", `${glyphs.running} electing`)}${queued}`;
}
if (!state.pollingActive && state.busRole !== "follower") {
return `${theme.fg("accent", "telegram")} ${theme.fg("muted", `${glyphs.shadowed} disconnected`)}${queued}${hint("/telegram-connect")}`;
}
const details = buildTelegramStatusBarDetails(theme, glyphs, state);
if (state.processing) {
const processingStatus = state.queuedStatus
? "active"
: (state.processingStatus ?? "processing");
const processingToken =
processingStatus === "active" ? "warning" : "accent";
return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`;
return `${label} ${theme.fg(processingToken, `${glyphs.running} ${processingStatus}`)}${queued}${details}`;
}
if (state.busRole === "follower")
return `${label} ${theme.fg("success", "follower")}${queued}`;
if (state.busRole === "leader")
return `${label} ${theme.fg("success", "leader")}`;
return `${label} ${theme.fg("success", "connected")}`;
const role = state.busRole ?? "connected";
return `${label} ${theme.fg("success", `${glyphs.enabled} ${role}`)}${queued}${details}`;
}
/** Up to two queued turns and a remainder count, only while the bridge is up. */
function buildTelegramStatusBarQueueLine(
theme: TelegramStatusBarTheme,
state: TelegramStatusBarState,
): string | undefined {
const operational =
state.hasBotToken &&
state.paired &&
!state.error &&
state.busLifecyclePhase !== "electing" &&
(state.pollingActive || state.busRole === "follower");
const preview = (state.queuedPreview ?? [])
.map(sanitizeTelegramStatusText)
.filter((summary) => summary.length > 0);
if (!operational || preview.length === 0) return undefined;
const glyphs = resolveTelegramStatusBarGlyphs(theme);
const shown = preview.slice(0, 2).map((summary) => `"${summary}"`);
const more =
preview.length > 2 ? `${glyphs.dot}+${preview.length - 2} more` : "";
return theme.fg("muted", ` next: ${shown.join(glyphs.dot)}${more}`);
}
/** The main status line, plus a queue preview line when turns are waiting. */
export function buildTelegramStatusBarLines(
theme: TelegramStatusBarTheme,
state: TelegramStatusBarState,
): string[] {
const queueLine = buildTelegramStatusBarQueueLine(theme, state);
return [
buildTelegramStatusBarText(theme, state),
...(queueLine ? [queueLine] : []),
];
}
/** Host spinner text while a turn that arrived from Telegram is running. */
export function formatTelegramWorkingMessage(summary: string): string {
const text = sanitizeTelegramStatusText(summary);
return text ? `Answering Telegram · "${text}"` : "Answering Telegram";
}
function formatTelegramBridgeBotStatus(
@@ -1079,6 +1359,7 @@ function buildTelegramBridgeCompactStatusLines(
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
`- user: ${state.allowedUserId ?? "not paired"}`,
...buildTelegramTrafficLines(state),
...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []),
...(state.busRole ? [`- role: ${state.busRole}`] : []),
...(state.busLifecyclePhase
@@ -1134,6 +1415,7 @@ export function buildTelegramBridgeDiagnosticStatusLines(
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
`- allowed user: ${state.allowedUserId ?? "not paired"}`,
...buildTelegramTrafficLines(state),
...(state.botThreadMode
? [
`- thread mode: ${state.botThreadMode}${state.botThreadModeAction ? ` reconcile=${state.botThreadModeAction}` : ""}`,
+26 -10
View File
@@ -375,6 +375,10 @@ export interface TelegramBridgeApiRuntimeDeps {
error: unknown,
details?: Record<string, unknown>,
) => void;
/** Called after a Bot API call or multipart upload succeeded. */
onCallSucceeded?: (method: string) => void;
/** Called after a Bot API call or multipart upload failed for good. */
onCallFailed?: (method: string, error: unknown) => void;
}
export interface TelegramBridgeApiRuntime {
@@ -1246,6 +1250,8 @@ export function createTelegramAssistantDraftSender(deps: {
export function createDefaultTelegramBridgeApiRuntime(deps: {
getBotToken: () => string | undefined;
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
onCallSucceeded?: TelegramBridgeApiRuntimeDeps["onCallSucceeded"];
onCallFailed?: TelegramBridgeApiRuntimeDeps["onCallFailed"];
}): TelegramBridgeApiRuntime {
return createTelegramBridgeApiRuntime({
client: createTelegramApiClient(deps.getBotToken, {
@@ -1255,6 +1261,8 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
recordRuntimeEvent: deps.recordRuntimeEvent,
onCallSucceeded: deps.onCallSucceeded,
onCallFailed: deps.onCallFailed,
});
}
@@ -1266,16 +1274,20 @@ export function createTelegramBridgeApiRuntime(
body: Record<string, unknown>,
options?: TelegramApiCallOptions,
): Promise<TResponse> => {
let response: TResponse;
try {
return await deps.client.call<TResponse>(method, body, options);
response = await deps.client.call<TResponse>(method, body, options);
} catch (error) {
deps.recordRuntimeEvent(
"api",
error,
withTelegramTransportDiagnostics(error, { method }),
);
deps.onCallFailed?.(method, error);
throw error;
}
deps.onCallSucceeded?.(method);
return response;
};
return {
call: callRecorded,
@@ -1285,16 +1297,17 @@ export function createTelegramBridgeApiRuntime(
* photos, documents, animations, etc.).
* Errors are recorded under the "multipart" category for diagnostics.
*/
callMultipart: async (
method,
fields,
fileField,
filePath,
fileName,
options,
) => {
callMultipart: async <TResponse>(
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
options?: TelegramApiCallOptions,
): Promise<TResponse> => {
let response: TResponse;
try {
return await deps.client.callMultipart(
response = await deps.client.callMultipart<TResponse>(
method,
fields,
fileField,
@@ -1308,8 +1321,11 @@ export function createTelegramBridgeApiRuntime(
error,
withTelegramTransportDiagnostics(error, { method, fileName }),
);
deps.onCallFailed?.(method, error);
throw error;
}
deps.onCallSucceeded?.(method);
return response;
},
/**
+1
View File
@@ -67,6 +67,7 @@ export function createTimeInjectionRuntime(
const nowMs = now.getTime();
if (
previous !== undefined &&
nowMs >= previous &&
nowMs - previous < config.interval
) {
return null;
+134
View File
@@ -0,0 +1,134 @@
/**
* Telegram bridge traffic counters
* Zones: telegram transport, status
* Owns in-memory received/sent message counts and last-activity time for the status bar
*
* This domain is pure: it counts, it does not read config, call Telegram, or import other domains.
*/
export interface TelegramTrafficSnapshot {
/** Authorized messages received from the paired owner since the bridge loaded. */
received: number;
/** Messages the bridge delivered to Telegram since the bridge loaded. */
sent: number;
/** Messages that could not be delivered after the transport retries. */
failed: number;
/** Time of the latest received or sent message, when there was one. */
lastAtMs?: number;
}
export interface TelegramTrafficCounters {
recordReceived: () => void;
recordSent: () => void;
recordFailed: () => void;
snapshot: () => TelegramTrafficSnapshot;
}
/**
* Bot API methods that deliver a message the owner can read. Typing actions,
* drafts, edits, reactions and callback answers are not messages.
*/
const TELEGRAM_OUTBOUND_MESSAGE_METHODS: Readonly<Record<string, true>> = {
sendMessage: true,
sendRichMessage: true,
sendPhoto: true,
sendDocument: true,
sendAudio: true,
sendVideo: true,
sendVoice: true,
sendAnimation: true,
sendVideoNote: true,
sendMediaGroup: true,
sendSticker: true,
};
export function isTelegramOutboundMessageMethod(method: string): boolean {
return Object.hasOwn(TELEGRAM_OUTBOUND_MESSAGE_METHODS, method);
}
export function createTelegramTrafficCounters(
now: () => number = Date.now,
): TelegramTrafficCounters {
let received = 0;
let sent = 0;
let failed = 0;
let lastAtMs: number | undefined;
return {
recordReceived() {
received += 1;
lastAtMs = now();
},
recordSent() {
sent += 1;
lastAtMs = now();
},
recordFailed() {
failed += 1;
},
snapshot: () => ({
received,
sent,
failed,
...(lastAtMs === undefined ? {} : { lastAtMs }),
}),
};
}
export interface TelegramTrafficStatusBridgeDeps<TContext> {
counters: TelegramTrafficCounters;
/** Current session context, when the bridge has one. */
getContext: () => TContext | undefined;
updateStatus: (ctx: TContext) => void;
recordRuntimeEvent: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
/** Told about every message delivery that failed, e.g. to raise a notice. */
onDeliveryFailed?: (error: unknown) => void;
}
export interface TelegramTrafficStatusBridge<TContext> {
/** Observer for successful Bot API calls; counts delivered messages. */
onCallSucceeded: (method: string) => void;
/** Observer for failed Bot API calls; counts undelivered messages. */
onCallFailed: (method: string, error: unknown) => void;
/** Observer for messages accepted from the paired owner. */
onAuthorizedMessage: (ctx: TContext) => void;
}
/**
* Counts traffic and refreshes the status bar so the counters stay current. A
* status refresh failure (for example a stale session context) is recorded and
* never propagates into the send or receive path that triggered it.
*/
export function createTelegramTrafficStatusBridge<TContext>(
deps: TelegramTrafficStatusBridgeDeps<TContext>,
): TelegramTrafficStatusBridge<TContext> {
const refreshStatus = (ctx: TContext): void => {
try {
deps.updateStatus(ctx);
} catch (error) {
deps.recordRuntimeEvent("status", error, { phase: "traffic-refresh" });
}
};
return {
onCallSucceeded(method) {
if (!isTelegramOutboundMessageMethod(method)) return;
deps.counters.recordSent();
const ctx = deps.getContext();
if (ctx !== undefined) refreshStatus(ctx);
},
onCallFailed(method, error) {
if (!isTelegramOutboundMessageMethod(method)) return;
deps.counters.recordFailed();
deps.onDeliveryFailed?.(error);
const ctx = deps.getContext();
if (ctx !== undefined) refreshStatus(ctx);
},
onAuthorizedMessage(ctx) {
deps.counters.recordReceived();
refreshStatus(ctx);
},
};
}
+1
View File
@@ -850,6 +850,7 @@ export function createTelegramPairedUpdateRuntime<
setAllowedUserId: deps.setAllowedUserId,
persistConfig: deps.persistConfig,
updateStatus: deps.updateStatus,
onPaired: deps.onPaired,
}).pairIfNeeded,
answerCallbackQuery: deps.answerCallbackQuery,
answerGuestQuery: deps.answerGuestQuery,
+10 -19
View File
@@ -963,7 +963,6 @@
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -1363,14 +1362,16 @@
"peer": true
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/buffer-equal-constant-time": {
@@ -1879,7 +1880,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.6.4",
"version": "7.6.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"peer": true,
@@ -1900,19 +1903,6 @@
"node": ">=12.0.0"
}
},
"node_modules/protobufjs/node_modules/@types/node": {
"version": "22.19.19",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/protobufjs/node_modules/@types/node/node_modules/undici-types": {
"version": "6.21.0",
"license": "MIT",
"peer": true
},
"node_modules/retry": {
"version": "0.12.0",
"license": "MIT",
@@ -2037,7 +2027,9 @@
}
},
"node_modules/undici": {
"version": "8.5.0",
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -2048,7 +2040,6 @@
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
+10 -8
View File
@@ -81,25 +81,27 @@
"typescript": "latest"
},
"overrides": {
"protobufjs": "7.6.4",
"undici": "8.5.0",
"protobufjs": "7.6.6",
"undici": "8.10.2",
"brace-expansion": "5.0.12",
"ws": "8.21.0",
"@earendil-works/pi-coding-agent": {
"protobufjs": "7.6.4",
"undici": "8.5.0",
"protobufjs": "7.6.6",
"undici": "8.10.2",
"brace-expansion": "5.0.12",
"ws": "8.21.0",
"@google/genai": {
"protobufjs": "7.6.4",
"protobufjs": "7.6.6",
"ws": "8.21.0"
},
"@earendil-works/pi-ai": {
"protobufjs": "7.6.4",
"protobufjs": "7.6.6",
"ws": "8.21.0",
"@google/genai": {
"protobufjs": "7.6.4",
"protobufjs": "7.6.6",
"ws": "8.21.0"
}
}
}
}
}
}
+6 -1
View File
@@ -1060,7 +1060,12 @@ test("Bus local server memoizes completed and in-flight request results", async
};
const first = sendTelegramBusLocalEnvelope({ socketPath, envelope });
const duplicate = sendTelegramBusLocalEnvelope({ socketPath, envelope });
await new Promise((resolve) => setTimeout(resolve, 5));
// Wait for the first envelope to reach the handler instead of assuming a
// fixed delivery time; the duplicate must still be memoized while in flight.
const deliveryDeadlineMs = Date.now() + 5_000;
while (executions === 0 && Date.now() < deliveryDeadlineMs) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.equal(executions, 1);
release?.();
const [firstResult, duplicateResult] = await Promise.all([first, duplicate]);
+23
View File
@@ -683,6 +683,29 @@ test("Telegram config pairing runtime binds config and status ports", async () =
assert.deepEqual(events, ["set:7", "persist", "status:ctx"]);
});
test("Telegram config pairing runtime reports the new owner only after persisting", async () => {
const events: string[] = [];
let allowedUserId: number | undefined;
const runtime = createTelegramUserPairingRuntime({
getAllowedUserId: () => allowedUserId,
setAllowedUserId: (userId) => {
allowedUserId = userId;
},
persistConfig: async () => {
events.push("persist");
},
updateStatus: () => {
events.push("status");
},
onPaired: (userId, ctx: string) => {
events.push(`paired:${userId}:${ctx}`);
},
});
assert.equal(await runtime.pairIfNeeded(7, "ctx"), true);
assert.equal(await runtime.pairIfNeeded(8, "ctx"), false);
assert.deepEqual(events, ["persist", "status", "paired:7:ctx"]);
});
test("Bot token input prefers stored config over env vars", () => {
const value = getTelegramBotTokenInputDefault(
{
+35
View File
@@ -560,3 +560,38 @@ test("Lifecycle helpers register pi hooks and delegate to handlers", async () =>
"agent-end",
]);
});
test("Terminal agent_end synthesizes agent_settled only for hosts that never emit it", async () => {
const cases = [
{ settle: true, event: { messages: [] }, expected: ["agent-end", "agent-settled"] },
{ settle: true, event: { messages: [], willContinue: true }, expected: ["agent-end"] },
{ settle: false, event: { messages: [] }, expected: ["agent-end"] },
];
for (const { settle, event, expected } of cases) {
const harness = createLifecycleApiHarness();
const events: string[] = [];
registerTelegramLifecycleHooks(harness.api, {
settleOnTerminalAgentEnd: settle,
onSessionStart: async () => {},
onSessionShutdown: async () => {},
onBeforeAgentStart: () => undefined,
onModelSelect: () => {},
onAgentStart: async () => {},
onToolExecutionStart: () => {},
onToolExecutionEnd: () => {},
onMessageStart: async () => {},
onMessageUpdate: async () => {},
onAgentEnd: async () => {
events.push("agent-end");
},
onAgentSettled: () => {
events.push("agent-settled");
},
});
await getRequiredLifecycleHandler(harness.handlers, "agent_end")(
event,
createLifecycleContext(),
);
assert.deepEqual(events, expected, JSON.stringify({ settle, event }));
}
});
+12
View File
@@ -83,6 +83,18 @@ test("Current model store prefers explicit Telegram model over context fallback"
assert.equal(store.get({ model: fallback }), fallback);
});
test("Current model store follows the live context model on hosts without model_select", () => {
const stale = { provider: "stale", id: "a", name: "Stale" };
const live = { provider: "live", id: "b", name: "Live" };
const store = createCurrentModelStore<{ model?: typeof live }, typeof live>(
(ctx) => ctx.model,
true,
);
store.set(stale);
assert.equal(store.get({ model: live }), live);
assert.equal(store.get({ model: undefined }), stale);
});
test("Current model runtime combines store fallback and status updates", () => {
const fallback = { provider: "fallback", id: "f", name: "Fallback" };
const selected = { provider: "selected", id: "s", name: "Selected" };
+111
View File
@@ -0,0 +1,111 @@
/**
* Regression tests for the Telegram operator notices
* Covers the toast wording, severity, throttling and failure isolation.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
createTelegramNoticeRuntime,
TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS,
} from "../lib/notices.ts";
function createNoticeHarness(options: { notifyThrows?: boolean } = {}) {
const notices: string[] = [];
const recorded: string[] = [];
let nowMs = 1_000_000;
let context: string | undefined = "session";
const runtime = createTelegramNoticeRuntime({
getContext: () => context,
notify: (ctx: string, message, level) => {
if (options.notifyThrows) throw new Error("stale ctx");
notices.push(`${level}|${ctx}|${message}`);
},
recordRuntimeEvent: (category, error, details) => {
recorded.push(`${category}:${(error as Error).message}:${details?.phase}`);
},
now: () => nowMs,
});
return {
runtime,
notices,
recorded,
advance: (ms: number) => {
nowMs += ms;
},
dropContext: () => {
context = undefined;
},
};
}
test("Notices announce pairing and recovery as info toasts", () => {
const { runtime, notices } = createNoticeHarness();
runtime.paired(777, "ctx-a");
runtime.recovered("ctx-b");
assert.deepEqual(notices, [
"info|ctx-a|Telegram paired with user 777",
"info|ctx-b|Telegram reconnected",
]);
});
test("Delivery failures explain the reason and pick a severity from it", () => {
const harness = createNoticeHarness();
harness.runtime.deliveryFailed(
new Error("Telegram API sendMessage failed: HTTP 429: Too Many Requests: retry after 12"),
);
harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS);
harness.runtime.deliveryFailed(new Error("Unable to connect. Is the computer able to access the url?"));
harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS);
harness.runtime.deliveryFailed(new Error("Telegram API sendMessage failed: HTTP 401: Unauthorized"));
harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS);
harness.runtime.deliveryFailed(new Error("message is too long"));
harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS);
harness.runtime.deliveryFailed(
new Error("Telegram API sendRichMessage may have committed before transport failed."),
);
assert.deepEqual(harness.notices, [
"warning|session|Telegram: reply not delivered (rate limited 12s)",
"warning|session|Telegram: reply not delivered (offline)",
"error|session|Telegram: reply not delivered (invalid token)",
"error|session|Telegram: reply not delivered (message is too long)",
"warning|session|Telegram: reply delivery unconfirmed (check the chat)",
]);
});
test("Delivery failure notices are throttled and need a live context", () => {
const harness = createNoticeHarness();
harness.runtime.deliveryFailed(new Error("fetch failed"));
harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS - 1);
harness.runtime.deliveryFailed(new Error("fetch failed"));
assert.equal(harness.notices.length, 1);
harness.advance(1);
harness.runtime.deliveryFailed(new Error("fetch failed"));
assert.equal(harness.notices.length, 2);
harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS);
harness.dropContext();
harness.runtime.deliveryFailed(new Error("fetch failed"));
assert.equal(harness.notices.length, 2);
});
test("A failing toast is recorded and never reaches the caller", () => {
const harness = createNoticeHarness({ notifyThrows: true });
assert.doesNotThrow(() => {
harness.runtime.paired(777, "ctx");
harness.runtime.recovered("ctx");
harness.runtime.deliveryFailed(new Error("fetch failed"));
});
assert.deepEqual(harness.recorded, [
"status:stale ctx:notice",
"status:stale ctx:notice",
"status:stale ctx:notice",
]);
});
test("Delivery failure notices carry no control sequences from raw error text", () => {
const harness = createNoticeHarness();
harness.runtime.deliveryFailed(new Error("bad\u001b]0;pwned\u0007\u001b[31m text"));
assert.equal(harness.notices.length, 1);
assert.doesNotMatch(harness.notices[0], /[\u0000-\u001f\u007f-\u009f]/);
});
+30 -26
View File
@@ -10,48 +10,52 @@ import test from "node:test";
import {
resolveAgentDir,
setHostAgentDirResolver,
resolveTelegramConfigPath,
resolveTelegramLocksPath,
resolveTelegramTempDir,
resolveTelegramRuntimeLogPath,
getTelegramDiagnosticsDisplayPaths,
resolveTelegramProfileTempFilePath,
} from "../lib/paths.ts";
test("Diagnostics use the resolved agent root and profile suffix", () => {
const agentDir = resolveAgentDir();
for (const profile of [undefined, "work"]) {
assert.deepEqual(getTelegramDiagnosticsDisplayPaths(profile), {
state: resolveTelegramProfileTempFilePath("state", "json", agentDir, profile),
logs: resolveTelegramProfileTempFilePath("logs", "jsonl", agentDir, profile),
});
}
});
await test("resolveAgentDir", async (t) => {
await t.test("returns PI_CODING_AGENT_DIR when env is set", () => {
t.afterEach(() => setHostAgentDirResolver(undefined));
await t.test("PI_CODING_AGENT_DIR wins over the host directory", () => {
setHostAgentDirResolver(() => {
throw new Error("host directory must not be consulted");
});
assert.equal(
resolveAgentDir({
env: { PI_CODING_AGENT_DIR: "/custom/agent/dir" },
execPath: "/usr/bin/omp",
argv: ["omp"],
}),
resolveAgentDir({ env: { PI_CODING_AGENT_DIR: "/custom/agent/dir" } }),
resolve("/custom/agent/dir"),
);
});
await t.test("returns ~/.omp/agent for OMP-compatible runtimes", () => {
assert.equal(
resolveAgentDir({ env: {}, execPath: "/home/user/.local/bin/omp" }),
join(homedir(), ".omp", "agent"),
);
assert.equal(
resolveAgentDir({
env: {},
execPath: "/usr/bin/node",
argv: ["node", "omp"],
}),
join(homedir(), ".omp", "agent"),
);
});
await t.test(
"returns ~/.pi/agent as fallback when no env and no OMP runtime",
"uses the registered host runtime directory when env is unset",
() => {
assert.equal(
resolveAgentDir({ env: {}, execPath: "/usr/bin/node", argv: ["node"] }),
join(homedir(), ".pi", "agent"),
);
setHostAgentDirResolver(() => "/home/user/.omp/agent");
assert.equal(resolveAgentDir({ env: {} }), "/home/user/.omp/agent");
},
);
await t.test("falls back to ~/.pi/agent when no host is registered", () => {
assert.equal(
resolveAgentDir({ env: {} }),
join(homedir(), ".pi", "agent"),
);
});
});
await test("resolveTelegramConfigPath", () => {
+35
View File
@@ -19,6 +19,8 @@ import {
hasExtensionContextPendingMessages,
isExtensionContextIdle,
isExtensionContextPassiveRunMode,
notifyExtensionContext,
setExtensionWorkingMessage,
} from "../lib/pi.ts";
type PiRuntimeApiHarness = Parameters<
@@ -157,3 +159,36 @@ test("Pi context helpers expose model, idle, pending-message, and compact adapte
assert.equal(hasExtensionContextPendingMessages(ctx), false);
assert.deepEqual(events, ["compact", "complete"]);
});
test("Pi context model prefers omp's live models facade over a frozen ctx.model", () => {
const frozen = { provider: "fake", id: "fake-1" };
const live = { provider: "fake", id: "fake-2" };
const ompCtx = {
model: frozen,
models: { current: () => live },
} as unknown as ExtensionContext;
const piCtx = { model: frozen } as unknown as ExtensionContext;
assert.equal(getExtensionContextModel(ompCtx), live);
assert.equal(getExtensionContextModel(piCtx), frozen);
});
test("Host UI helpers forward to the context and tolerate a host without a spinner API", () => {
const calls: string[] = [];
const full = {
ui: {
notify: (message: string, level: string) => {
calls.push(`notify:${level}:${message}`);
},
setWorkingMessage: (message?: string) => {
calls.push(`working:${message}`);
},
},
} as unknown as ExtensionContext;
notifyExtensionContext(full, "hello", "warning");
setExtensionWorkingMessage(full, "busy");
setExtensionWorkingMessage(full, undefined);
assert.deepEqual(calls, ["notify:warning:hello", "working:busy", "working:undefined"]);
const withoutSpinner = { ui: {} } as unknown as ExtensionContext;
assert.doesNotThrow(() => setExtensionWorkingMessage(withoutSpinner, "busy"));
});
+171
View File
@@ -4,6 +4,9 @@
*/
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
@@ -29,6 +32,7 @@ import {
stopTelegramPollingRuntime,
TELEGRAM_ALLOWED_UPDATES,
} from "../lib/polling.ts";
import { createTelegramConfigStore } from "../lib/config.ts";
const TEST_CONTEXT = "ctx";
@@ -601,6 +605,103 @@ test("Poll loop runner binds config, status, and transport ports", async () => {
assert.deepEqual(events, ["deleteWebhook", "handle:ctx:6", "persist:6"]);
});
test("Poll loop keeps the paired user when the offset is committed after the initial sync replaced the stored config", async () => {
const dir = mkdtempSync(join(tmpdir(), "telegram-pairing-"));
const configPath = join(dir, "telegram.json");
const store = createTelegramConfigStore({ agentDir: dir, configPath });
await store.load();
store.update((config) => {
config.botToken = "123:abc";
});
await store.persist();
let calls = 0;
const runPollLoop = createTelegramPollLoopRunner({
getConfig: store.get,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls === 1) return [{ update_id: 1 }]; // initial sync
if (calls === 2) return [{ update_id: 2 }]; // the owner's /start
throw new DOMException("stop", "AbortError");
},
persistConfig: store.persist,
handleUpdate: async () => {
store.setAllowedUserId(777);
await store.persist();
},
updateStatus: () => {},
sleep: async () => {},
});
await runPollLoop("ctx", new AbortController().signal);
assert.equal(store.getAllowedUserId(), 777);
const saved = JSON.parse(readFileSync(configPath, "utf8"));
assert.equal(saved.allowedUserId, 777);
assert.equal(saved.lastUpdateId, 2);
});
test("Poll loop reports recovery once, only after a failed attempt", async () => {
const run = async (failures: number): Promise<string[]> => {
const events: string[] = [];
let calls = 0;
const runPollLoop = createTelegramPollLoopRunner({
getConfig: () => ({ botToken: "123:abc", lastUpdateId: 1 }),
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= failures) throw new Error("network down");
if (calls <= failures + 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
updateStatus: (_ctx: string, message?: string) => {
events.push(`status:${message ?? "ok"}`);
},
onRecovered: (ctx: string) => {
events.push(`recovered:${ctx}`);
},
sleep: async () => {},
});
await runPollLoop("ctx", new AbortController().signal);
return events;
};
assert.deepEqual(await run(2), [
"status:network down",
"status:network down",
"status:ok",
"recovered:ctx",
]);
assert.deepEqual(await run(0), []);
});
test("Poll loop keeps polling when the recovery notice itself fails", async () => {
const recorded: string[] = [];
let calls = 0;
const runPollLoop = createTelegramPollLoopRunner({
getConfig: () => ({ botToken: "123:abc", lastUpdateId: 1 }),
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls === 1) throw new Error("network down");
if (calls === 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
updateStatus: () => {},
onRecovered: () => {
throw new Error("toast broke");
},
sleep: async () => {},
recordRuntimeEvent: (category, error, details) => {
recorded.push(`${category}:${(error as Error).message}:${details?.phase}`);
},
});
await runPollLoop("ctx", new AbortController().signal);
assert.equal(calls, 3);
assert.ok(recorded.includes("polling:toast broke:recovered-notice"), recorded.join("|"));
});
test("Poll loop runner ignores stale-context status failures while retrying", async () => {
const config = { botToken: "123:abc", lastUpdateId: 1 };
const events: string[] = [];
@@ -612,6 +713,7 @@ test("Poll loop runner ignores stale-context status failures while retrying", as
getUpdates: async () => {
calls += 1;
if (calls === 1) throw new Error("network down");
if (calls === 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
@@ -637,6 +739,74 @@ test("Poll loop runner ignores stale-context status failures while retrying", as
]);
});
test("Poll loop keeps the error status until a poll succeeds", async () => {
const events: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config: { botToken: "123:abc", lastUpdateId: 1 },
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= 2) throw new Error("rate limited");
if (calls === 3) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
onErrorStatus: (message) => {
events.push(`error:${message}`);
},
onStatusReset: () => {
events.push("reset");
},
sleep: async () => {
events.push("sleep");
},
});
assert.deepEqual(events, [
"error:rate limited",
"sleep",
"error:rate limited",
"sleep",
"reset",
]);
});
test("Poll loop does not report recovery when only an update handler failed", async () => {
const events: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config: { botToken: "123:abc", lastUpdateId: 1 },
maxUpdateFailures: 3,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= 3) return [{ update_id: 6 }];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {
throw new Error("handler failed");
},
onErrorStatus: (message) => {
events.push(`error:${message}`);
},
onStatusReset: () => {
events.push("reset");
},
onRecovered: () => {
events.push("recovered");
},
sleep: async () => {},
});
assert.equal(events.includes("recovered"), false, events.join("|"));
assert.ok(events.includes("reset"), events.join("|"));
});
test("Poll loop initializes lastUpdateId and processes updates", async () => {
const handled: number[] = [];
const config: { botToken: string; lastUpdateId?: number } = {
@@ -893,6 +1063,7 @@ test("Poll loop reports retryable errors and sleeps before retrying", async () =
if (calls === 1) {
throw new Error("network down");
}
if (calls === 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
+37
View File
@@ -48,6 +48,7 @@ import {
executeTelegramControlItemRuntime,
executeTelegramQueueDispatchPlan,
formatQueuedTelegramItemsStatus,
formatQueuedTelegramItemsPreview,
getNextTelegramToolExecutionCount,
getTelegramQueueItemAdmissionMode,
getTelegramQueueLaneContract,
@@ -405,6 +406,42 @@ test("Control-lane items sort before priority and default prompt items", () => {
);
});
test("Queued item preview lists summaries in dispatch order and skips empty ones", () => {
const defaultPrompt: TelegramQueueItem = createQueueTestPromptTurn({
queueOrder: 10,
queueLane: "default",
laneOrder: 0,
statusSummary: "default",
});
const emptyPrompt: TelegramQueueItem = createQueueTestPromptTurn({
queueOrder: 13,
queueLane: "default",
laneOrder: 1,
statusSummary: " ",
});
const priorityPrompt: TelegramQueueItem = createQueueTestPromptTurn({
queueOrder: 11,
queueLane: "priority",
laneOrder: 0,
statusSummary: "priority",
});
const controlItem: TelegramQueueItem = createQueueTestControlItem({
queueOrder: 12,
statusSummary: "control",
});
const stored = [defaultPrompt, emptyPrompt, controlItem, priorityPrompt];
assert.deepEqual(formatQueuedTelegramItemsPreview(stored), [
"control",
"priority",
"default",
]);
assert.deepEqual(
stored.map((item) => item.statusSummary),
["default", " ", "control", "priority"],
);
assert.deepEqual(formatQueuedTelegramItemsPreview([]), []);
});
test("Queue mutation helpers remove prompt items by Telegram message id", () => {
const promptItem: TelegramQueueItem = createQueueTestPromptTurn({
replyToMessageId: 1,
+394 -242
View File
@@ -10,6 +10,7 @@ import {
buildTelegramBridgeStatusLines,
buildTelegramRuntimeEventLines,
buildTelegramStatusBarText,
buildTelegramStatusBarLines,
clearTelegramStatusLineProviders,
createTelegramBridgeStatusRuntime,
createTelegramRuntimeDiagnosticsSnapshotScheduler,
@@ -19,6 +20,9 @@ import {
createTelegramStatusSnapshot,
createTelegramStatusRuntime,
getTelegramStatusBarProcessingStatus,
formatTelegramWorkingMessage,
sanitizeTelegramStatusText,
summarizeTelegramStatusError,
recordStructuredTelegramRuntimeEvent,
recordTelegramRuntimeEvent,
registerTelegramStatusLineProvider,
@@ -111,248 +115,165 @@ test("Status runtime diagnostics scheduler coalesces snapshot persists", async (
assert.deepEqual(errors, []);
});
test("Status bar text renders bridge connection and queue states", () => {
const theme = {
fg: (token: string, text: string) => `<${token}>${text}</${token}>`,
const statusBarTheme = {
fg: (token: string, text: string) => `<${token}>${text}</${token}>`,
};
function plainStatusText(text: string): string {
return text.replace(/<\/?[a-z]+>/g, "");
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
test("Status bar text keeps the label, state color and queue for every connection state", () => {
const base = {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: false,
queuedStatus: "",
};
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: false,
pollingActive: false,
paired: false,
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>telegram</accent> <muted>not configured</muted>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: true,
queuedStatus: " +1",
}),
"<accent>telegram</accent> <warning>active</warning><success> +1</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: true,
processingStatus: "dispatching",
queuedStatus: " +1",
}),
"<accent>telegram</accent> <warning>active</warning><success> +1</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: true,
processingStatus: "active",
queuedStatus: "",
}),
"<accent>telegram</accent> <warning>active</warning>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: true,
processing: false,
queuedStatus: "",
}),
"<accent>telegram</accent> <success>connected</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: true,
processing: true,
processingStatus: "active",
queuedStatus: "",
}),
"<accent>telegram</accent> <warning>active</warning>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
instanceThreadName: "Aurora",
compactionInProgress: false,
processing: true,
processingStatus: "queued",
queuedStatus: " +2",
}),
"<accent>telegram</accent> <muted>disconnected</muted><success> +2</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
busRole: "follower",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>telegram</accent> <success>follower</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
busRole: "follower",
instanceThreadName: "Amber",
compactionInProgress: false,
processing: true,
processingStatus: "active",
queuedStatus: "",
}),
"<accent>Amber</accent> <warning>active</warning>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
busRole: "follower",
busLifecyclePhase: "electing",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>telegram</accent> <warning>electing</warning>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
busLifecyclePhase: "electing",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>telegram</accent> <warning>electing</warning>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
busRole: "follower",
instanceThreadName: "Follower",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>telegram</accent> <success>follower</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
busRole: "follower",
instanceThreadName: "Lname",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>Lname</accent> <success>follower</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
busRole: "follower",
instanceSlot: "O",
instanceThreadName: "extensions Follower",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>extensions Follower</accent> <success>follower</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: false,
paired: true,
busRole: "follower",
instanceSlot: "O",
instanceThreadName: "Oname",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>Oname</accent> <success>follower</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: true,
busRole: "leader",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>telegram</accent> <success>leader</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: true,
busRole: "leader",
instanceThreadName: "🌙 A-identity",
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
"<accent>🌙 A-identity</accent> <success>leader</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: false,
compactionInProgress: false,
processing: true,
processingStatus: "queued",
queuedStatus: " +1",
}),
"<accent>telegram</accent> <warning>awaiting pairing</warning><success> +1</success>",
);
assert.equal(
buildTelegramStatusBarText(theme, {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: false,
queuedStatus: "",
const cases: Array<{
state: Partial<Parameters<typeof buildTelegramStatusBarText>[1]>;
label: string;
token: string;
word: string;
queued?: string;
}> = [
{ state: { hasBotToken: false, pollingActive: false, paired: false }, label: "telegram", token: "muted", word: "not configured" },
{ state: { processing: true, queuedStatus: " +1" }, label: "telegram", token: "warning", word: "active", queued: " +1" },
{ state: { processing: true, processingStatus: "dispatching", queuedStatus: " +1" }, label: "telegram", token: "warning", word: "active", queued: " +1" },
{ state: { processing: true, processingStatus: "active" }, label: "telegram", token: "warning", word: "active" },
{ state: { compactionInProgress: true }, label: "telegram", token: "success", word: "connected" },
{ state: { compactionInProgress: true, processing: true, processingStatus: "active" }, label: "telegram", token: "warning", word: "active" },
{ state: { pollingActive: false, instanceThreadName: "Aurora", processing: true, processingStatus: "queued", queuedStatus: " +2" }, label: "telegram", token: "muted", word: "disconnected", queued: " +2" },
{ state: { pollingActive: false, busRole: "follower" }, label: "telegram", token: "success", word: "follower" },
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Amber", processing: true, processingStatus: "active" }, label: "Amber", token: "warning", word: "active" },
{ state: { pollingActive: false, busRole: "follower", busLifecyclePhase: "electing" }, label: "telegram", token: "warning", word: "electing" },
{ state: { pollingActive: false, busLifecyclePhase: "electing" }, label: "telegram", token: "warning", word: "electing" },
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Follower" }, label: "telegram", token: "success", word: "follower" },
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Lname" }, label: "Lname", token: "success", word: "follower" },
{ state: { pollingActive: false, busRole: "follower", instanceSlot: "O", instanceThreadName: "extensions Follower" }, label: "extensions Follower", token: "success", word: "follower" },
{ state: { pollingActive: false, busRole: "follower", instanceSlot: "O", instanceThreadName: "Oname" }, label: "Oname", token: "success", word: "follower" },
{ state: { busRole: "leader" }, label: "telegram", token: "success", word: "leader" },
{ state: { busRole: "leader", instanceThreadName: "🌙 A-identity" }, label: "🌙 A-identity", token: "success", word: "leader" },
{ state: { paired: false, processing: true, processingStatus: "queued", queuedStatus: " +1" }, label: "telegram", token: "warning", word: "awaiting pairing", queued: " +1" },
];
for (const { state, label, token, word, queued } of cases) {
const text = buildTelegramStatusBarText(statusBarTheme, { ...base, ...state });
const expected = new RegExp(
`^<accent>${escapeRegExp(label)}</accent> <${token}>\\S+ ${word}</${token}>${queued ? `<success>${escapeRegExp(queued)}</success>` : ""}`,
);
assert.match(text, expected, JSON.stringify(state));
}
assert.match(
buildTelegramStatusBarText(statusBarTheme, {
...base,
error: "typing failed",
}),
"<accent>telegram</accent> <error>error</error> <muted>typing failed</muted>",
/^<accent>telegram<\/accent> <error>\S+ error<\/error> <muted>typing failed<\/muted>$/,
);
});
test("Status bar shows bot, message counters and last activity only while the bridge is up", () => {
const lastAtMs = new Date(2026, 9, 1, 14, 32).getTime();
const connected = {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: false,
queuedStatus: "",
botUsername: "demo_bot",
traffic: { received: 12, sent: 11, lastAtMs },
};
const up = plainStatusText(buildTelegramStatusBarText(statusBarTheme, connected));
assert.match(up, /connected/);
assert.match(up, /@demo_bot/);
assert.match(up, /12 \S*11/);
assert.match(up, /14:32/);
const withoutActivity = plainStatusText(
buildTelegramStatusBarText(statusBarTheme, {
...connected,
traffic: { received: 0, sent: 0 },
}),
);
assert.match(withoutActivity, /0 \S*0/);
assert.doesNotMatch(withoutActivity, /\d\d:\d\d/);
const working = plainStatusText(
buildTelegramStatusBarText(statusBarTheme, {
...connected,
processing: true,
processingStatus: "active",
}),
);
assert.match(working, /active/);
assert.match(working, /@demo_bot/);
assert.match(working, /12 \S*11/);
const offline = plainStatusText(
buildTelegramStatusBarText(statusBarTheme, { ...connected, pollingActive: false }),
);
assert.doesNotMatch(offline, /@demo_bot|14:32/);
assert.match(offline, /disconnected/);
});
test("Status bar tells the operator how to leave a non-working state", () => {
const base = {
hasBotToken: true,
pollingActive: false,
paired: true,
compactionInProgress: false,
processing: false,
queuedStatus: "",
};
assert.match(
plainStatusText(buildTelegramStatusBarText(statusBarTheme, { ...base, hasBotToken: false, paired: false })),
/\/telegram-setup/,
);
const pairing = plainStatusText(
buildTelegramStatusBarText(statusBarTheme, { ...base, paired: false, botUsername: "demo_bot" }),
);
assert.match(pairing, /\/start/);
assert.match(pairing, /@demo_bot/);
assert.match(
plainStatusText(buildTelegramStatusBarText(statusBarTheme, base)),
/\/telegram-connect/,
);
});
test("Status bar follows the host symbol preset and falls back without one", () => {
const state = {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: false,
queuedStatus: "",
botUsername: "demo_bot",
traffic: { received: 3, sent: 2, lastAtMs: new Date(2026, 9, 1, 9, 5).getTime() },
};
const ascii = plainStatusText(
buildTelegramStatusBarText(
{
...statusBarTheme,
status: { enabled: "[x]" },
sep: { dot: " - " },
icon: { input: "in:", output: "out:", time: "t:" },
},
state,
),
);
assert.equal(ascii, "telegram [x] connected - @demo_bot - in:3 out:2 - t: 09:05");
const fallback = plainStatusText(buildTelegramStatusBarText(statusBarTheme, state));
assert.match(fallback, /^telegram \S+ connected · @demo_bot · \S*3 \S*2 · \S+ 09:05$/u);
});
test("Status runtime updates the status bar and exposes bridge lines", () => {
const events: string[] = [];
const ctx = {
@@ -390,9 +311,9 @@ test("Status runtime updates the status bar and exposes bridge lines", () => {
}),
});
runtime.updateStatus(ctx, "demo error");
assert.equal(
assert.match(
events[0],
"telegram:<accent>telegram</accent> <error>error</error> <muted>demo error</muted>",
/^telegram:<accent>telegram<\/accent> <error>\S+ error<\/error> <muted>demo error<\/muted>$/,
);
assert.deepEqual(runtime.getStatusLines().slice(0, 3), [
"connection:",
@@ -401,6 +322,237 @@ test("Status runtime updates the status bar and exposes bridge lines", () => {
]);
});
test("Status runtime keeps theme colors in the TUI through a widget and stays plain elsewhere", () => {
const createContext = (mode: string | undefined) => {
const events: string[] = [];
return {
events,
ctx: {
mode,
ui: {
theme: statusBarTheme,
setStatus: (key: string, text: string | undefined) => {
events.push(`status:${key}:${text}`);
},
setWidget: (
key: string,
content: string[] | undefined,
options?: { placement?: string },
) => {
events.push(`widget:${key}:${options?.placement}:${content?.join("|")}`);
},
},
},
};
};
const runtime = createTelegramStatusRuntime({
getStatusBarState: () => ({
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: false,
queuedStatus: "",
}),
getBridgeStatusLineState: () => {
throw new Error("not used");
},
});
const tui = createContext("tui");
runtime.updateStatus(tui.ctx);
assert.equal(tui.events.length, 2);
assert.match(tui.events[0], /^widget:telegram:belowEditor:<accent>telegram<\/accent> <success>\S+ connected<\/success>$/);
assert.equal(tui.events[1], "status:telegram:undefined");
for (const mode of ["rpc", "json", "print", undefined]) {
const other = createContext(mode);
runtime.updateStatus(other.ctx);
assert.equal(other.events.length, 1, String(mode));
assert.match(other.events[0], /^status:telegram:<accent>telegram<\/accent> <success>\S+ connected<\/success>$/);
}
const noWidgetApi = createContext("tui");
delete (noWidgetApi.ctx.ui as { setWidget?: unknown }).setWidget;
runtime.updateStatus(noWidgetApi.ctx);
assert.equal(noWidgetApi.events.length, 1);
assert.match(noWidgetApi.events[0], /^status:telegram:/);
});
test("Error summaries turn transport failures into a short state and next step", () => {
const cases: Array<[string, string | undefined, string | undefined, string | undefined]> = [
["Telegram API getUpdates failed: HTTP 401: Unauthorized", "fatal", "invalid token", "/telegram-setup"],
["Telegram API sendMessage failed: HTTP 429: Too Many Requests: retry after 12", "retrying", "rate limited 12s", "retrying"],
["Telegram API sendMessage failed: HTTP 429", "retrying", "rate limited", "retrying"],
["Conflict: terminated by other getUpdates request; make sure that only one bot instance is running", "attention", "another poller is active", "/telegram-status"],
["Telegram API getUpdates failed: HTTP 502: Bad Gateway", "retrying", "Telegram unavailable", "retrying"],
["Unable to connect. Is the computer able to access the url?", "retrying", "offline", "retrying"],
["fetch failed", "retrying", "offline", "retrying"],
["Telegram API sendRichMessage may have committed before transport failed.", "attention", "delivery unconfirmed", undefined],
["typing failed", undefined, undefined, undefined],
["settingsManager.reload is not a function", undefined, undefined, undefined],
];
for (const [message, kind, label, hint] of cases) {
const summary = summarizeTelegramStatusError(message);
assert.equal(summary?.kind, kind, message);
assert.equal(summary?.label, label, message);
assert.equal(summary?.hint, hint, message);
}
});
test("Status bar shows classified errors with their next step and keeps unknown ones verbatim", () => {
const base = {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: false,
queuedStatus: "",
};
const fatal = buildTelegramStatusBarText(statusBarTheme, {
...base,
error: "Telegram API getUpdates failed: HTTP 401: Unauthorized",
});
assert.match(fatal, /<error>\S+ invalid token<\/error>/);
assert.match(plainStatusText(fatal), /\/telegram-setup/);
const retrying = buildTelegramStatusBarText(statusBarTheme, {
...base,
error: "Unable to connect. Is the computer able to access the url?",
});
assert.match(retrying, /<warning>\S+ offline<\/warning>/);
assert.match(plainStatusText(retrying), /retrying/);
const attention = buildTelegramStatusBarText(statusBarTheme, {
...base,
error: "HTTP 409 Conflict",
});
assert.match(attention, /<warning>\S+ another poller is active<\/warning>/);
assert.match(plainStatusText(attention), /\/telegram-status/);
assert.match(
buildTelegramStatusBarText(statusBarTheme, { ...base, error: "typing failed" }),
/<error>\S+ error<\/error> <muted>typing failed<\/muted>/,
);
const long = plainStatusText(
buildTelegramStatusBarText(statusBarTheme, { ...base, error: "x".repeat(200) }),
);
assert.ok(long.includes("…"));
assert.equal(long.includes("x".repeat(101)), false);
});
test("Status bar lines carry no terminal control sequences from Telegram-controlled text", () => {
const hostile = "\u001b]0;pwned\u0007\u001b[31mred\u001b[0m\u202e";
const base = {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: true,
queuedStatus: " +1",
instanceThreadName: `thread${hostile}`,
botUsername: `bot${hostile}`,
queuedPreview: [`queued${hostile}`],
};
const unsafe = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e]/;
for (const state of [
base,
{ ...base, error: `boom${hostile}` },
{ ...base, paired: false },
]) {
for (const line of buildTelegramStatusBarLines(statusBarTheme, state)) {
assert.doesNotMatch(line, unsafe, JSON.stringify(line));
}
}
assert.equal(sanitizeTelegramStatusText(" a\u001b[0m b\u0000 "), "a [0m b");
});
test("Status bar previews the next queued turns only while the bridge is up", () => {
const base = {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: true,
queuedStatus: " +3",
};
const three = buildTelegramStatusBarLines(statusBarTheme, {
...base,
queuedPreview: ["one", "two", "three"],
});
assert.equal(three.length, 2);
assert.match(plainStatusText(three[1]), /next: "one" · "two" · \+1 more/);
const two = buildTelegramStatusBarLines(statusBarTheme, {
...base,
queuedPreview: ["one", "two"],
});
assert.match(plainStatusText(two[1]), /next: "one" · "two"$/);
for (const state of [
{ ...base, queuedPreview: [] },
{ ...base, queuedPreview: ["one"], pollingActive: false },
{ ...base, queuedPreview: ["one"], paired: false },
{ ...base, queuedPreview: ["one"], error: "typing failed" },
]) {
assert.equal(buildTelegramStatusBarLines(statusBarTheme, state).length, 1, JSON.stringify(state));
}
});
test("Status bar surfaces failed deliveries only when there are some", () => {
const state = {
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: false,
queuedStatus: "",
traffic: { received: 12, sent: 11, failed: 2 },
};
assert.match(plainStatusText(buildTelegramStatusBarText(statusBarTheme, state)), /12 \S*11 \S*2/);
const none = plainStatusText(
buildTelegramStatusBarText(statusBarTheme, {
...state,
traffic: { received: 12, sent: 11, failed: 0 },
}),
);
assert.equal(none.includes("✘"), false);
});
test("Status runtime puts the queue preview in the widget and keeps the plain status to one line", () => {
const runtime = createTelegramStatusRuntime({
getStatusBarState: () => ({
hasBotToken: true,
pollingActive: true,
paired: true,
compactionInProgress: false,
processing: true,
queuedStatus: " +1",
queuedPreview: ["one"],
}),
getBridgeStatusLineState: () => {
throw new Error("not used");
},
});
const widgets: string[][] = [];
const statuses: Array<string | undefined> = [];
const ui = {
theme: statusBarTheme,
setStatus: (_key: string, text: string | undefined) => {
statuses.push(text);
},
setWidget: (_key: string, content: string[] | undefined) => {
widgets.push(content ?? []);
},
};
runtime.updateStatus({ mode: "tui", ui });
assert.equal(widgets[0].length, 2);
assert.match(plainStatusText(widgets[0][1]), /next: "one"/);
runtime.updateStatus({ mode: "rpc", ui });
assert.equal(statuses.at(-1)?.includes("next:"), false);
});
test("Working message names the Telegram prompt and ignores control characters", () => {
assert.equal(formatTelegramWorkingMessage("say pong"), 'Answering Telegram · "say pong"');
assert.equal(formatTelegramWorkingMessage(" "), "Answering Telegram");
assert.equal(formatTelegramWorkingMessage("a\u001b[0mb"), 'Answering Telegram · "a [0mb"');
});
test("Status lines expose thread reconciliation state", () => {
const lines = buildTelegramBridgeStatusLines({
botUsername: "demo_bot",
@@ -550,9 +702,9 @@ test("Bridge status runtime stays active while tools run after queue changes", (
},
},
});
assert.equal(
assert.match(
events[0],
"telegram:<accent>telegram</accent> <warning>active</warning>",
/^telegram:<accent>telegram<\/accent> <warning>\S+ active<\/warning>/,
);
});
@@ -589,9 +741,9 @@ test("Bridge status runtime builds status state from live ports", () => {
},
},
});
assert.equal(
assert.match(
events[0],
"telegram:<accent>telegram</accent> <warning>active</warning><success> +1</success>",
/^telegram:<accent>telegram<\/accent> <warning>\S+ active<\/warning><success> \+1<\/success>/,
);
assert.deepEqual(runtime.getStatusLines(), [
"connection:",
+14
View File
@@ -47,6 +47,20 @@ test("Time config rejects non-positive interval", () => {
assert.equal(resolved.interval, 60 * 60 * 1000);
});
test("Time config rejects non-finite intervals", () => {
for (const interval of [NaN, Infinity, -Infinity]) {
assert.equal(resolveTelegramTimeConfig({ injectionMode: "interval", interval }).interval, 3_600_000);
}
});
test("Time injection recovers when the wall clock moves backwards", () => {
const runtime = makeRuntime({ injectionMode: "interval", interval: 60_000, timezone: "UTC" });
assert.ok(runtime.resolveLine(1, new Date("2026-05-16T15:00:00Z")));
assert.ok(runtime.resolveLine(1, new Date("2026-05-16T14:00:00Z")));
assert.equal(runtime.resolveLine(1, new Date("2026-05-16T14:00:30Z")), null);
assert.ok(runtime.resolveLine(1, new Date("2026-05-16T14:01:00Z")));
});
test("Time injection runtime returns null for every call when injectionMode is hidden", () => {
const runtime = makeRuntime({ injectionMode: "hidden", timezone: "UTC" });
for (let i = 0; i < 5; i++) {
+163
View File
@@ -0,0 +1,163 @@
/**
* Regression tests for the Telegram traffic counters
* Covers what counts as a message, last-activity tracking, failed deliveries, and the /telegram-status line.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { buildTelegramBridgeStatusLines } from "../lib/status.ts";
import {
createTelegramTrafficCounters,
createTelegramTrafficStatusBridge,
isTelegramOutboundMessageMethod,
} from "../lib/traffic.ts";
test("Traffic counters count every direction and remember the latest message", () => {
let nowMs = 1_000;
const counters = createTelegramTrafficCounters(() => nowMs);
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0, failed: 0 });
counters.recordReceived();
nowMs = 2_000;
counters.recordSent();
counters.recordSent();
nowMs = 3_000;
counters.recordFailed();
assert.deepEqual(counters.snapshot(), {
received: 1,
sent: 2,
failed: 1,
lastAtMs: 2_000,
});
});
test("Only calls that deliver a readable message count as sent", () => {
for (const method of ["sendMessage", "sendRichMessage", "sendPhoto", "sendDocument", "sendVoice"]) {
assert.equal(isTelegramOutboundMessageMethod(method), true, method);
}
for (const method of [
"sendChatAction",
"sendMessageDraft",
"editMessageText",
"setMessageReaction",
"answerCallbackQuery",
"getUpdates",
"toString",
]) {
assert.equal(isTelegramOutboundMessageMethod(method), false, method);
}
});
test("Status lines report the message counters and the time of the last one", () => {
const baseState = {
botUsername: "demo_bot",
allowedUserId: 7,
pollingActive: true,
pendingDispatch: false,
compactionInProgress: false,
activeToolExecutions: 0,
pendingModelSwitch: false,
queuedItems: [],
recentRuntimeEvents: [],
};
const lastAtMs = new Date(2026, 9, 1, 14, 32).getTime();
const clean = buildTelegramBridgeStatusLines({
...baseState,
traffic: { received: 12, sent: 11, failed: 0, lastAtMs },
});
assert.ok(
clean.includes("- messages: 12 received, 11 sent, last 14:32"),
clean.join("\n"),
);
const withFailures = buildTelegramBridgeStatusLines({
...baseState,
traffic: { received: 12, sent: 11, failed: 2, lastAtMs },
});
assert.ok(
withFailures.includes("- messages: 12 received, 11 sent, 2 failed, last 14:32"),
withFailures.join("\n"),
);
const withoutTraffic = buildTelegramBridgeStatusLines(baseState);
assert.equal(withoutTraffic.some((line) => line.startsWith("- messages:")), false);
});
test("Traffic bridge counts delivered messages and refreshes the status with the current context", () => {
const counters = createTelegramTrafficCounters();
const refreshed: string[] = [];
let context: string | undefined = "session-a";
const bridge = createTelegramTrafficStatusBridge({
counters,
getContext: () => context,
updateStatus: (ctx: string) => {
refreshed.push(ctx);
},
recordRuntimeEvent: () => {},
});
bridge.onCallSucceeded("sendChatAction");
bridge.onCallSucceeded("editMessageText");
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0, failed: 0 });
assert.deepEqual(refreshed, []);
bridge.onCallSucceeded("sendRichMessage");
assert.equal(counters.snapshot().sent, 1);
assert.deepEqual(refreshed, ["session-a"]);
context = undefined;
bridge.onCallSucceeded("sendMessage");
assert.equal(counters.snapshot().sent, 2);
assert.deepEqual(refreshed, ["session-a"]);
bridge.onAuthorizedMessage("session-b");
assert.equal(counters.snapshot().received, 1);
assert.deepEqual(refreshed, ["session-a", "session-b"]);
});
test("Traffic bridge counts undelivered messages, reports them and refreshes the status", () => {
const counters = createTelegramTrafficCounters();
const refreshed: string[] = [];
const failures: string[] = [];
const bridge = createTelegramTrafficStatusBridge({
counters,
getContext: () => "session",
updateStatus: (ctx: string) => {
refreshed.push(ctx);
},
recordRuntimeEvent: () => {},
onDeliveryFailed: (error) => {
failures.push((error as Error).message);
},
});
bridge.onCallFailed("getUpdates", new Error("poll failed"));
bridge.onCallFailed("sendChatAction", new Error("typing failed"));
assert.equal(counters.snapshot().failed, 0);
assert.deepEqual(failures, []);
assert.deepEqual(refreshed, []);
bridge.onCallFailed("sendMessage", new Error("HTTP 500"));
assert.equal(counters.snapshot().failed, 1);
assert.equal(counters.snapshot().sent, 0);
assert.deepEqual(failures, ["HTTP 500"]);
assert.deepEqual(refreshed, ["session"]);
});
test("Traffic bridge records a failed status refresh instead of breaking the message path", () => {
const counters = createTelegramTrafficCounters();
const recorded: string[] = [];
const bridge = createTelegramTrafficStatusBridge({
counters,
getContext: () => "session",
updateStatus: () => {
throw new Error("stale ctx");
},
recordRuntimeEvent: (category, error, details) => {
recorded.push(`${category}:${(error as Error).message}:${details?.phase}`);
},
});
bridge.onCallSucceeded("sendMessage");
bridge.onAuthorizedMessage("session");
bridge.onCallFailed("sendMessage", new Error("HTTP 500"));
assert.equal(counters.snapshot().received, 1);
assert.equal(counters.snapshot().sent, 1);
assert.equal(counters.snapshot().failed, 1);
assert.deepEqual(recorded, [
"status:stale ctx:traffic-refresh",
"status:stale ctx:traffic-refresh",
"status:stale ctx:traffic-refresh",
]);
});