Metadata-Version: 2.5
Name: kestrel-channel-telegram
Version: 0.1.3
Summary: Telegram channel for Kestrel agents — isolated-venv feature (Telegram Bot API).
License-Expression: MIT
Requires-Python: <3.15,>=3.11
Requires-Dist: kestrel-sovereign-sdk<0.37,>=0.36
Provides-Extra: service
Requires-Dist: httpx<1,>=0.27; extra == 'service'
Description-Content-Type: text/markdown

# kestrel-channel-telegram

Telegram channel for [Kestrel](https://github.com/KestrelSovereignAI/kestrel-sovereign)
agents — an **isolated-venv feature** where the agent talks over a Telegram
**bot** it owns (a Bot API token), not a device grafted onto a personal account.

Unlike the WhatsApp channel (a companion device linked to a personal number),
Telegram gives the agent its **own identity** with almost no friction:

- Create a bot with [@BotFather](https://t.me/BotFather) → you get a **token**.
  No phone number, no QR, no linked device.
- You DM the bot; the agent replies **as the bot**.
- Access is **default-deny**: with an empty `allowed_senders` the bot replies to
  no one. Obtain the user's immutable numeric Telegram ID through an
  operator-controlled path, then add it to `allowed_senders` to enable the
  conversation. Unauthorized `/start` and `/id` are intentionally dropped.

## Setup — the agent walks you through it

You don't need to remember these steps: enable the feature and **ask the agent to
set up Telegram**. It calls `telegram_status`, which returns a plain-language
`next_step`, and guides you through whichever step is next. The full flow:

1. **Create the bot** *(only you can do this — it's in your Telegram)*:
   - Open Telegram, search **@BotFather** (the blue-check official one), press
     **Start**.
   - Send **`/newbot`**, choose a **name** (e.g. `Emma`) and a **username** ending
     in `bot` (e.g. `emma_kestrel_bot`).
   - BotFather replies with a **token** like `12345678:AAExample…` (~46 chars).
     Copy it. *(It's the bot's password — keep it private; it's fine to give to
     the agent/operator to store in config.)*
2. **Configure the token**: save the token in this feature's config (`token`) via
   the features UI (or hand it to the operator). The agent tells you when it's
   set and connected (as your bot's `@username`).
3. **Get on the allowlist** *(this is how you get access — default-deny)*:
   - Ask the agent for the link, or run `telegram_link` → it returns
     `https://t.me/<your_bot>`.
   - Open it to confirm you have reached the right bot. Obtain your numeric
     Telegram ID through an operator-controlled path.
   - Add that numeric ID to **`allowed_senders`**. Usernames are not accepted:
     they are mutable, while authorization uses the sender's immutable ID.
4. **Chat**: DM the bot — the agent replies. Done.

> **What the agent does vs. what you do.** The agent can't create your bot or read
> your Telegram, so creating the bot and obtaining your numeric Telegram ID are
> operator/user steps. Everything else — telling you exactly what's missing
> (`telegram_status.next_step`) and handing you the `t.me` link
> (`telegram_link`) — the agent drives. Setting the token/allowlist config today goes through the features
> UI/operator; an agent-side `feature_configure` tool would let the agent do that
> for you too (planned).

## How it works

The package has two roles (same shape as `kestrel-channel-whatsapp`):

- **Discovery marker** `TelegramFeature` (SDK-only) — installed into the host.
- **Transport service** `kestrel_channel_telegram.service` — its only dep is
  `httpx` (the `[service]` extra), installed by the host loader into a per-agent
  isolated venv and run as the `kestrel-telegram-service` console script over the
  SDK stdio JSON-RPC contract.

The service registers `telegram_send`, `telegram_status`, and `telegram_link`.
Telegram 0.1.3 requires Kestrel Core 0.53.0+ before startup: Core injects the
acknowledged-inbound capability during the isolated-service handshake, and the
child fails clearly before polling if it is absent. Release Core first, then
Telegram; the package intentionally does not depend on Core because the feature
must not invert the framework → feature dependency direction.
Its active ingress is **`polling`** (the default): it removes any configured
Telegram webhook and long-polls the Bot API (`getUpdates`), delivering text
messages from **allowed** senders as `channel.inbound` and sending via
`sendMessage`. `auto` is a backwards-compatible deterministic alias for
polling.

## Hosted Telegram delivery contract (for the Frinz host integration)

`transport: webhook` is intentionally absent from feature config and rejected
from both config and environment fallback. A user cannot select hosted delivery,
set route readiness, or pass a URL/secret into this child. The only ordinary
states are `polling` and its `auto` alias.

This 0.1.3 package advertises five Telegram-specific **private SDK 0.36+
host-ingress operations**, consumed by Core 0.53.0+ and never exposed as agent
tools, so the next Frinz ticket can add the concrete integration without another
package release:

| private operation | host payload | effect |
|---|---|---|
| `telegram-hosted-activate` | `{"scope":{"agent_id":"…","tenant_id":"…"},"bot_id":"…","activation_generation":"…"}` | Validates the canonical public bot id against the configured token, binds the exact immutable scope **and host-persisted activation generation**, then stops and reaps polling before success. Retrying the same generation is safe; another generation conflicts while active. |
| `telegram-update` | activation scope, bot id, `activation_generation`, and one authenticated Telegram `update` | Accepted only while that exact generation binding is active. It returns a stable dedupe identity and `deliver`, `dropped`, or `ignored` disposition; an unauthorized command never creates a reply action. |
| `telegram-hosted-deactivate` | activation scope, bot id, `activation_generation`, plus `"webhook_deleted": true` | Accepts only the exact active generation and the host's deletion attestation, then restores polling. A completed old deactivation replay cannot clear a newer generation. |
| `telegram-polling-ack` | `{"dedupe_key":"telegram:v2:bot:<bot_id>:update:<update_id>","attempt_token":"…"}` | Releases an allowed polling callback only after Core completed/durably accepted that exact callback attempt; an opaque per-callback token prevents a delayed old ACK from acknowledging a newer redelivery. |
| `telegram-polling-nack` | `{"dedupe_key":"telegram:v2:bot:<bot_id>:update:<update_id>","attempt_token":"…"}` | Releases that exact polling callback attempt for retry after retryable Core/cognition failure. It leaves the Bot API offset unchanged; delayed completions from an earlier attempt are rejected without affecting the current future. |

The child retains completed activation generations for its entire process
lifetime, but process-local memory is not a cross-restart replay defense. The
host route provisioner must durably record every spent
`activation_generation` and reject it after a child or host restart before it
can reach `telegram-hosted-activate`.

The service also advertises Core's generic private
`external-ingress-quiesce` / `external-ingress-resume` lifecycle capability.
Before a config transition Core asks the poller to cancel/reap `getUpdates` and
finish any already-emitted callback, then closes its event gate. If the old
child remains after a failed or live-applied transition, Core sends the exact
matching resume call while that gate is still closed. The child **returns the
resume response before it can restart polling or produce another callback**;
Core then reopens the gate after receiving that response. This preserves the
closed-gate handoff even for a newly-created disconnected adapter, which is
restarted through its polling supervisor. This never changes hosted-route
ownership and never deletes a webhook or pending Telegram updates.

Polling uses two private completions, `telegram-polling-ack` and
`telegram-polling-nack`. After it emits an allowed `channel.inbound`
notification, the child waits for Core to complete its scoped inbound handler
and issue exactly one completion with the canonical `bot_id + update_id` dedupe
key **and that callback's opaque attempt token**. ACK advances the Bot API
offset only after durable acceptance; NACK releases the callback, leaves the
offset unchanged, and makes the poller back off/redeliver. These callbacks are
not agent tools and the host must dispatch
them after the event handler returns (as separate JSON-RPC tasks), because
event reading and private-ingress responses share the SDK stream. Hosted
webhook delivery remains host-owned durable admission and does not use this
child polling-completion protocol.

Private ingress admission is the host attestation: no route URL, webhook
secret, or user-writable readiness flag crosses into the isolated service. The
child does not expose HTTP, verify Telegram's secret header, call `setWebhook`,
or call `deleteWebhook` while hosted. It reports `host-activated` distinctly
from polling and never claims route readiness; host activation merely confirms
the local poller is stopped and that a trusted caller selected a bound scope.

The concrete **Frinz host route/provisioner is the next integration layer**. It
must own the public URL, secret-header verification, `setWebhook`/
`deleteWebhook`, durable enqueue-and-dedupe, and immutable agent/tenant/bot
route record. It must generate one 256-bit route value with
`secrets.token_urlsafe(32)`, persist it as `activation_generation`, and use the
same value on every activation, update, and deactivation call for that route.
It never belongs in feature config and it must generate a **new** value after a
completed deactivation (a completed generation cannot be reactivated). Its
required order is:

1. Start the child in polling and invoke `telegram-hosted-activate` with the
   resolved scope and bot identity. Only after it succeeds may the host install
   the authenticated durable route and call `setWebhook`.
2. If provisioning fails, delete any partially installed webhook and invoke
   `telegram-hosted-deactivate` with the same binding and
   `webhook_deleted: true`; this is what safely resumes polling.
3. For teardown, first call and confirm Telegram `deleteWebhook`, then invoke
   the same private deactivation payload. A mismatch or missing confirmation is
   rejected and leaves hosted mode latched, avoiding dual consumers.

After authenticating the Telegram secret, the host invokes `telegram-update`,
then atomically durable-dedupes/enqueues any returned `deliver` event before
acknowledging Telegram. The child uses a canonical `bot_id + update_id` event
ID in both polling and hosted mode (not Telegram's chat-local `message_id` and
not host scope), and retains raw `message_id` in metadata.
Valid Telegram Messages without `from` (such as channel posts) are safely
`ignored` with a 2xx disposition; they are never authorized or routed to the
agent.

## Config

| key | meaning |
|-----|---------|
| `enabled` | turn the channel on/off |
| `token` | Bot API token from @BotFather (the agent's identity) |
| `transport` | `polling` (default) or `auto` (a deterministic polling compatibility alias) |
| `allowed_senders` | Canonical positive decimal Telegram user IDs allowed to DM the agent. **Empty = deny all.** Usernames are not supported. |

Webhook route URL, secret, bot identity, readiness, and agent/tenant binding
remain outside feature config and outside isolated-service
initialize/config-transition RPCs. They are passed only in the private
host-ingress payloads above after host resolution and authentication.

## Development

```bash
uv run --no-project --with "kestrel-sovereign==0.53.0" --with httpx \
  --with pytest --with pytest-asyncio python -m pytest -q
```

## Release

Bump `version` in `pyproject.toml` (+ `uv.lock`), tag `vX.Y.Z`; `publish.yml`
verifies the tag matches and publishes to PyPI via OIDC trusted publishing
(`environment: pypi`).

## License

MIT
