Metadata-Version: 2.5
Name: kestrel-channel-telegram
Version: 0.1.8
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.40,>=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.8 package advertises five Telegram-specific **private SDK 0.36+
host-ingress operations**, consumed by Core 0.53.0+ and never exposed as agent
tools. Frinz owns the concrete hosted route; durable scheduled-outbound custody
and host deduplication remain separate follow-up work:

| private operation | host payload | effect |
|---|---|---|
| `telegram-hosted-activate` | `{"scope":{"agent_id":"…","tenant_id":"…"},"bot_id":"…","chat_id":"…","activation_generation":"…"}` | Binds the canonical public bot id, signed-decimal chat id, exact immutable scope, and **host-persisted activation generation**, then stops and reaps polling before success. If a child token is configured, its bot id must match; a tokenless child may receive but cannot send. Retrying the same generation is safe; another generation conflicts while active. |
| `telegram-update` | activation scope, bot id, chat id, `activation_generation`, and one authenticated Telegram `update` | Accepted only while that exact generation binding is active. An update for another chat is terminally dropped. 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, chat 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. |

For rolling upgrades, 0.1.7 and later still accept the 0.1.6 lifecycle envelope without
`chat_id`. Such a legacy binding may continue receiving its already-host-scoped
route, but hosted outbound is fail-closed until the host reactivates it with an
immutable destination. A destination-bound activation cannot be downgraded by
a legacy update or deactivation envelope.

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 trusted HTTP host must reject a declared or streamed request body above
1 MiB with HTTP 413 before child dispatch. After JSON parsing it must accept
only an object with at most 32 nested containers, 4096 aggregate collection
members, and 131072 characters in any string value or object key; violations
are HTTP 400. The host must authenticate Telegram's secret before reading the
body, durably deduplicate the returned bot/update identity before HTTP 200,
confirm the exact callback URL with `getWebhookInfo` after `setWebhook`, and
confirm an empty callback URL with `getWebhookInfo` after `deleteWebhook`.

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.

For a routable text reply, normalization carries Telegram's chat-local
`reply_to_message_id` both in the private ingress result and in
`channel.message` metadata. It is present only when the reply points to the
same chat as the inbound message; malformed or cross-chat reply identities are
rejected. The nested replied-to message content is never copied. Hosts must
still correlate the reply against their own bound bot/chat, outbound receipt,
workflow run, and post-send timestamp before treating an `OK` as an
acknowledgement. The provider's `message_date` (Unix seconds) and optional
`edit_date` are retained without content; a reply lacking a valid sent time is
rejected. For an edited `OK`, compare the original `message_date` to the
outbound send time—an edit after the send must not turn an earlier message
into an acknowledgement.

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.

Outbound safety in 0.1.6: a startup-attested child cannot send before the host
activates its exact bot route. Once activated, the child may call `sendMessage`
only if its configured token identifies the same bot; a tokenless or mismatched
child fails before constructing a Telegram HTTP client. This also holds if
activation races a send. This is not durable scheduled-send custody: Frinz
still needs a user/companion/bot-scoped encrypted credential and fenced host
outbound boundary before the morning-check workflow can send autonomously.
Shutdown permanently latches the child closed: queued send/link/connect and
host lifecycle calls cannot revive polling after a hosted binding is cleared.

The concrete **Frinz host route/provisioner** owns 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
