# Telethon Plus [Health: Active]

**Category:** 💬 Communication  
**Repository:** https://github.com/psyb0t/docker-telethon-plus  
**GitHub Stars:** 2  
**Views:** 0  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/telethon-plus

## Description
Self-hosted MCP server: Telegram userbot control (Telethon) via HTTP and MCP.

## Claude Desktop Quick Installation
Heuristic fallback — verify the package name and runner against the repository README before running it. Uses `npx` (confidence: low):

```json
"mcpServers": {
  "telethon-plus": {
    "command": "npx",
    "args": ["-y","telethon-plus"]
  }
}
```

## Documentation & README

# docker-telethon-plus

[![CI](https://github.com/psyb0t/docker-telethon-plus/actions/workflows/pipeline.yml/badge.svg?branch=main)](https://github.com/psyb0t/docker-telethon-plus/actions/workflows/pipeline.yml)
[![version](https://raw.githubusercontent.com/psyb0t/docker-telethon-plus/badges/version.svg)](https://github.com/psyb0t/docker-telethon-plus/releases)
[![license](https://raw.githubusercontent.com/psyb0t/docker-telethon-plus/badges/license.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/psyb0t/telethon-plus?style=flat-square)](https://hub.docker.com/r/psyb0t/telethon-plus)

Your Telegram account, but it takes HTTP requests. Wraps [Telethon](https://codeberg.org/Lonami/Telethon) — the real MTProto userbot client, not that neutered Bot API garbage — behind a JSON HTTP API and a Model Context Protocol endpoint.

Same tools, two doors. POST some JSON, or point your AI agent at `/mcp` and let it go nuts. Either way it's talking to Telegram as *you*, with full account access.

One login. One session string. Never type a code again.

## Table of Contents

- [How it works](#how-it-works)
- [Quick start](#quick-start)
- [First-time login](#first-time-login)
- [Configuration](#configuration)
- [Tools](#tools)
- [HTTP API](#http-api)
- [MCP](#mcp)
- [Agent integrations](#agent-integrations)
- [Development](#development)
- [Tests](#tests)
- [License](#license)

## How it works

```
+-------------------+        +-----------------------+
|  Any HTTP client  | -----> |  REST  /api/...       | --+
+-------------------+        +-----------------------+   |
                                                         |   +-----------+        Telegram
+-------------------+        +-----------------------+   +-> |  Telethon | <----> Servers
|  MCP-aware agent  | -----> |  /mcp  (Streamable    | --+   +-----------+       (MTProto)
|  (Claude, etc.)   |        |   HTTP transport)     |
+-------------------+        +-----------------------+
```

One Telethon client. One async lock. Both surfaces share the same tool registry — no duplication, no weird state, no bullshit.

## Quick start

```yaml
services:
  telethon-plus:
    image: psyb0t/telethon-plus
    ports:
      - "8080:8080"
    environment:
      TELETHON_API_ID: "123456"
      TELETHON_API_HASH: "your-api-hash"
      TELETHON_SESSION: "1Aa...long-string-from-login-helper..."
    restart: unless-stopped
```

Get `API_ID` / `API_HASH` from <https://my.telegram.org/apps>. Get the session string from the [login helper](#first-time-login) below.

## First-time login

Telegram makes you prove you're a human once — phone number, SMS code, optionally 2FA. Do it once, never again.

```bash
cp .env.example .env
$EDITOR .env  # put in TELETHON_API_ID and TELETHON_API_HASH

make login
```

`make login` builds the image, runs the interactive flow, and shoves `TELETHON_SESSION` straight into your `.env`. That's it. Run `make run` and you're live.

No repo? No problem:

```bash
docker run --rm -it \
  -e TELETHON_API_ID=123456 \
  -e TELETHON_API_HASH=your-api-hash \
  psyb0t/telethon-plus login
```

Copy the session string it spits out, set it as `TELETHON_SESSION`, done.

> **The session string is full account access.** Whoever has it is you. Don't commit it, don't paste it in Slack, don't tattoo it anywhere.

## Configuration

All config via environment variables. Copy `.env.example` to get the full list with comments.

| Variable | Required | Default | Description |
|---|---|---|---|
| `TELETHON_API_ID` | yes | — | API ID from my.telegram.org |
| `TELETHON_API_HASH` | yes | — | API hash from my.telegram.org |
| `TELETHON_SESSION` | yes | — | StringSession from the login helper |
| `TELETHON_HTTP_LISTEN_ADDRESS` | no | `0.0.0.0:8080` | `host:port` to bind |
| `TELETHON_LOG_LEVEL` | no | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `TELETHON_REQUEST_TIMEOUT` | no | `60` | Per-request timeout in seconds |
| `TELETHON_FLOOD_SLEEP_THRESHOLD` | no | `60` | Auto-sleep through `FLOOD_WAIT` errors below this many seconds. Telegram will rate-limit you — this is the safety valve. |
| `TELETHON_DEVICE_MODEL` | no | `docker-telethon-plus` | What Telegram thinks your device is |
| `TELETHON_SYSTEM_VERSION` | no | `1.0` | Ditto for OS |
| `TELETHON_APP_VERSION` | no | `1.0` | Ditto for app |
| `TELETHON_DOWNLOAD_DIR` | no | `/tmp/telethon-plus` | Scratch space for `send_file` uploads |
| `TELETHON_AUTH_KEY` | no | `""` | When set, all endpoints require `Authorization: Bearer <key>`. `/healthz` stays public. Empty = no auth. |

### Throttling & cache (anti-flood)

Telegram bans accounts that hammer it. Defaults here are conservative — meant to keep you under the server-side limits without you having to think about it. Tune only if you know what you're doing.

| Variable | Default | What it does |
|---|---|---|
| `TELETHON_THROTTLE_ENABLED` | `true` | Master switch for all rate-limiting below |
| `TELETHON_THROTTLE_GLOBAL_INTERVAL_MS` | `50` | Min gap between any two outgoing requests |
| `TELETHON_THROTTLE_JITTER_MS` | `200` | Random ±jitter added on top (kills metronome traffic patterns) |
| `TELETHON_THROTTLE_PER_CHAT_INTERVAL_MS` | `1100` | Min gap between sends to the same chat (Telegram's "1/sec/chat" ceiling, with margin) |
| `TELETHON_THROTTLE_PER_CHAT_READ_INTERVAL_MS` | `250` | Min gap between reads from the same chat. Stops single-channel scraping from monopolizing the read bucket. |
| `TELETHON_THROTTLE_ADAPTIVE` | `true` | On each `FLOOD_WAIT`, multiply all waits ×2 for an hour. Resets after a quiet hour. |
| `TELETHON_BUCKET_RESOLVE_PER_MIN` | `5` | Cap on `resolveUsername` — **the main thing that gets accounts 22-hour-banned** |
| `TELETHON_BUCKET_GET_FULL_PER_MIN` | `10` | Cap on `getFullChannel` / `getFullUser` / `get_participants` |
| `TELETHON_BUCKET_JOIN_PER_HOUR` | `5` | Cap on channel/group joins |
| `TELETHON_BUCKET_CREATE_PER_HOUR` | `5` | Cap on channel/group creation |
| `TELETHON_BUCKET_SEND_PER_MIN` | `20` | Cap on sends across all chats |
| `TELETHON_BUCKET_READ_PER_MIN` | `600` | Cap on read ops. **Counted per server-side API call**, not per tool call: `get_messages(limit=300)` charges 3 slots (Telegram caps GetHistory at 100/page); `get_dialogs(limit=500)` similarly charges 5. |
| `TELETHON_CACHE_ENABLED` | `true` | Persist resolved entities to disk |
| `TELETHON_CACHE_PATH` | `/cache/entities.json` | Mount `/cache` as a host volume to keep this across rebuilds |
| `TELETHON_CACHE_TTL_SECONDS` | `604800` | 7 days. Set `0` for no expiry. |
| `TELETHON_FLOOD_SLEEP_THRESHOLD` | `60` | Telethon's reactive auto-sleep: if a `FLOOD_WAIT` is shorter than this many seconds, sleep through it. Above this, raise. Set very high (e.g. `86400`) to never raise — but then a hostile FLOOD_WAIT will block your process for the full duration. |

**How the layers stack:**

1. Entity cache short-circuits resolveUsername — first lookup of `@somechannel` calls Telegram; every subsequent lookup is free. Survives container restarts via the `/cache` volume.
2. Per-method token buckets cap the dangerous methods. If you try to resolve 6 new usernames in one minute, the 6th sleeps until the oldest expires.
3. Per-chat send interval throttles sends to each chat to ≤1/sec (with margin).
4. Global gap + jitter humanizes the overall traffic shape.
5. Adaptive backoff: a single FLOOD_WAIT halves your effective rate for an hour. Three in a row → ÷8. Auto-recovers after an idle hour.

**For bulk scraping work** (the scenario that caused 22-hour bans before): the cache + `bucket_resolve_per_min=5` combination is what saves you. Resolving 200 new channels takes ~40 minutes instead of getting you banned in 5.

## Tools

JSON in, JSON out. All inputs are pydantic-validated — send garbage, get a `400` back with exactly what's wrong.

Chat references (`chat`, `from_chat`, `to_chat`) accept whatever Telethon accepts:

| Format | Example |
|---|---|
| Username | `@psyb0t` |
| Phone number | `+1234567890` |
| t.me link | `https://t.me/psyb0t` |
| Numeric ID | `123456789` |
| Supergroup/channel ID | `-1001234567890` |
| Your own Saved Messages | `me` |

> **Numeric IDs only resolve for entities Telethon has already seen** — i.e. cached in your session via a prior `@username` / `t.me` lookup, dialog list, or incoming message. MTProto needs an `access_hash`, not just an ID, and bare numbers don't carry one. Especially relevant for bots: pass `@botusername` first (or call `GET /api/dialogs` / `GET /api/entities?chat=@bot` once) before referring to it by numeric ID. If you only have the bot's token and no username, hit Telegram's Bot API `getMe` to fetch the username, then use that.

### Response shape

Every 2xx returns the resource directly. No `{"result": ...}` wrapper, no envelope. Lists are JSON arrays, singles are JSON objects.

Errors return `{"detail": "..."}` (FastAPI standard). Telegram RPC errors arrive as `502` with `detail = {"telegram_error": "...", "message": "..."}`.

### Quick reference

| Endpoint | Required params | What it does |
|---|---|---|
| `GET /api/me` | — | Account profile. |
| `GET /api/entities` | `chat` | Resolve a username/ID/link to a profile. |
| `POST /api/entities/bulk` | `chats` | Bulk resolve, honoring the resolve-username bucket. |
| `GET /api/dialogs` | — | List dialogs. Optional: `limit`, `archived`, `search` (substring on title/@). |
| `GET /api/messages` | `chat` | Read recent messages. Optional: `limit`, `offset_id`, `search`. |
| `GET /api/messages/{id}` | `chat` | Fetch a single message. |
| `GET /api/messages/{id}/media` | `chat` | Download attachment as **raw bytes** (binary stream). Returns `Content-Type` from Telegram + `Content-Disposition: attachment; filename=...`. Optional: `max_bytes`. MCP clients should use the `download_media` tool which returns base64. |
| `POST /api/messages` | `chat`, **`text` or `file_url`** | Send a message. If `file_url` is present, fetches it and sends as media (with `text` as caption). Otherwise sends `text`. Optional: `parse_mode`, `reply_to`, `silent`, `link_preview`, `schedule`, `force_document`, `max_bytes`. |
| `POST /api/messages/forward` | `from_chat`, `to_chat`, `message_ids` | Forward messages. |
| `POST /api/messages/read` | `chat` | Mark as read. Optional `max_id`. |
| `PATCH /api/messages/{id}` | `chat`, `text` | Edit. |
| `DELETE /api/messages` | `chat`, `message_ids` | Bulk delete (body has the list). |
| `POST /api/messages/{id}/pin` | `chat` | Pin. Optional `silent`, `pm_oneside`. |
| `POST /api/messages/{id}/unpin` | `chat` | Unpin. |
| `POST /api/messages/{id}/reactions` | `chat`, `emoji` | React. Optional `big`. |
| `DELETE /api/messages/{id}/reactions` | `chat` | Remove your reaction. |
| `GET /api/participants` | `chat` | List members. Optional: `limit`, `search`. |
| `POST /api/chats` | `title` | Create supergroup or channel. |
| `DELETE /api/chats` | `chat` | Delete supergroup/channel you own (body). |
| `POST /api/chats/join` | `chat` | Join public channel/group. |
| `POST /api/chats/invite` | `invite` | Join via private `t.me/+hash`. |
| `POST /api/chats/leave` | `chat` | Leave. |
| `GET /api/chats/{chat}/linked` | — | Resolve a channel's linked discussion group. |
| `POST /api/chats/{chat}/admin/ban` | `user` | Ban a user. Optional `until_seconds`. |
| `POST /api/chats/{chat}/admin/unban` | `user` | Lift a ban. |
| `POST /api/chats/{chat}/admin/kick` | `user` | Kick (ban + immediate unban). |
| `POST /api/chats/{chat}/admin/promote` | `user` | Grant admin rights + optional `title`. |
| `POST /api/chats/{chat}/admin/demote` | `user` | Strip admin rights. |
| `POST /api/polls` | `chat`, `question`, `options` | Create a poll. Optional `quiz`, `correct_option`, `solution`. |
| `POST /api/polls/{id}/vote` | `chat`, `options` | Vote (0-based indices). |
| `GET /api/polls/{id}/results` | `chat` | Current vote counts. |
| `GET /api/throttle/status` | — | Live rate-limit + cache state. |
| `GET /api/account/health` | — | Flood-risk tier. |
| `GET /metrics` | — | Prometheus exposition (no auth). |
| `WS /ws/updates` | `?token=...` | Stream incoming Telegram events. |

**`{chat}` in path** accepts the same formats as elsewhere: `@username`, numeric ID, `me`. Phone (`+...`) and `t.me/...` links need URL-encoding (`%2B` etc.) — usernames and IDs work inline.

## HTTP API

Standard REST API. JSON in, JSON out. 2xx returns the resource directly; errors return `{"detail": ...}`.

If `TELETHON_AUTH_KEY` is set, every request (except `/healthz`) needs:

```http
Authorization: Bearer your-secret-key
```

### GET /api/me

Who am I right now.

```http
GET /api/me
```

```json
{
    "id": 123456789,
    "type": "User",
    "username": "psyb0t",
    "first_name": "Ciprian",
    "phone": "+40..."
  }
```

### GET /api/entities

Resolve any chat reference to a full profile.

```http
GET /api/entities?chat=@telegram
```

```json
{
    "id": 1234567,
    "type": "Channel",
    "username": "telegram",
    "title": "Telegram"
  }
```

### GET /api/dialogs

```http
GET /api/dialogs?limit=10&archived=false
```

| Param | Type | Default | Description |
|---|---|---|---|
| `limit` | int | 20 | How many dialogs (1–200) |
| `archived` | bool | false | Include archived chats |

```json
[
  {
    "id": 123456789,
    "type": "User",
    "username": "someone",
    "first_name": "Some",
    "last_name": "One",
    "unread_count": 3,
    "pinned": true,
    "last_message": {
      "id": 999,
      "date": "2026-04-29T11:00:00+00:00",
      "chat_id": 123456789,
      "sender_id": 123456789,
      "text": "hey",
      "out": false,
      "reply_to_msg_id": null,
      "media": false,
      "media_type": null
    }
  }
]
```

### GET /api/messages

```http
GET /api/messages?chat=me&limit=5&search=hello
```

| Param | Type | Default | Description |
|---|---|---|---|
| `chat` | string | required | Chat to read from |
| `limit` | int | 20 | How many messages (1–200) |
| `offset_id` | int | 0 | Start from this message ID (pagination) |
| `search` | string | — | Full-text search filter |

```json
[
  {
    "id": 4242,
    "date": "2026-04-29T12:00:00+00:00",
    "chat_id": 12345,
    "sender_id": 67890,
    "text": "hello",
    "out": false,
    "reply_to_msg_id": null,
    "media": false,
    "media_type": null
  }
]
```

Newest first. Returns `[]` if nothing matches.

### POST /api/messages

Send a message. The same endpoint handles **text** and **files** — if the body contains `file_url`, the URL is fetched and sent as media (with `text` becoming the caption); otherwise `text` is sent as a plain message.

```http
POST /api/messages
Content-Type: application/json

{
  "chat": "@psyb0t",
  "text": "**hello** from a container",
  "parse_mode": "md",
  "silent": true,
  "reply_to": 4241
}
```

```http
POST /api/messages
Content-Type: application/json

{
  "chat": "@psyb0t",
  "file_url": "https://example.com/photo.jpg",
  "text": "look at this shit",
  "silent": true
}
```

| Field | Type | Default | Description |
|---|---|---|---|
| `chat` | string | required | Target chat |
| `text` | string | required if no `file_url` | Message text or caption (1–4096 chars) |
| `file_url` | string | null | HTTPS URL to fetch and send as media. Telegram auto-picks media type from extension/MIME; override with `force_document`. |
| `parse_mode` | string | null | `md` / `markdown` / `html` / null |
| `reply_to` | int | null | Message ID to reply to |
| `silent` | bool | false | Send without notification |
| `link_preview` | bool | true | Show link previews (text only) |
| `schedule` | string | null | ISO datetime in the future to schedule the send |
| `force_document` | bool | false | When sending a file: send as generic doc instead of letting Telegram pick |
| `max_bytes` | int | 52428800 | When sending a file: reject larger than this (max 2 GB) |

```json
{
    "id": 4242,
    "date": "2026-04-29T12:00:00+00:00",
    "chat_id": 12345,
    "sender_id": 67890,
    "text": "hello from a container",
    "out": true,
    "reply_to_msg_id": 4241,
    "media": false,
    "media_type": null
  }
```

### PATCH /api/messages/{id}

Edit a message. Message ID goes in the URL, everything else in the body.

```http
PATCH /api/messages/4242
Content-Type: application/json

{
  "chat": "me",
  "text": "fixed version",
  "parse_mode": "md"
}
```

| Field | Type | Default | Description |
|---|---|---|---|
| `chat` | string | required | Chat containing the message |
| `text` | string | required | New text (1–4096 chars) |
| `parse_mode` | string | null | `md` / `html` / null |
| `link_preview` | bool | true | Show link previews |

```json
{
    "id": 4242,
    "date": "2026-04-29T12:00:00+00:00",
    "chat_id": 99999,
    "sender_id": 123456789,
    "text": "fixed version",
    "out": true,
    "reply_to_msg_id": null,
    "media": false,
    "media_type": null
  }
```

### DELETE /api/messages

Delete messages by ID.

```http
DELETE /api/messages
Content-Type: application/json

{
  "chat": "@psyb0t",
  "message_ids": [4242, 4243],
  "revoke": true
}
```

| Field | Type | Default | Description |
|---|---|---|---|
| `chat` | string | required | Chat containing the messages |
| `message_ids` | list[int] | required | IDs to delete (max 100) |
| `revoke` | bool | true | Delete for everyone, not just yourself |

```json
{ "deleted": 2, "requested": 2 }
```

### POST /api/messages/forward

```http
POST /api/messages/forward
Content-Type: application/json

{
  "from_chat": "@sourcechannel",
  "to_chat": "me",
  "message_ids": [101, 102, 103],
  "silent": true
}
```

| Field | Type | Default | Description |
|---|---|---|---|
| `from_chat` | string | required | Source chat |
| `to_chat` | string | required | Destination chat |
| `message_ids` | list[int] | required | IDs to forward (max 100) |
| `silent` | bool | false | Forward without notification |

```json
[
  {
    "id": 5001,
    "date": "2026-04-29T12:01:00+00:00",
    "chat_id": 99999,
    "sender_id": 123456789,
    "text": "forwarded content here",
    "out": true,
    "reply_to_msg_id": null,
    "media": false,
    "media_type": null
  }
]
```

### POST /api/messages/read

Mark messages as read.

```http
POST /api/messages/read
Content-Type: application/json

{ "chat": "@psyb0t", "max_id": 0 }
```

| Field | Type | Default | Description |
|---|---|---|---|
| `chat` | string | required | Chat to mark as read |
| `max_id` | int | 0 | Mark up to this message ID. `0` = mark all. |

```json
{ "ok": true }
```

### GET /api/participants

List members of a group or channel.

```http
GET /api/participants?chat=-1001234567890&limit=50&search=john
```

| Param | Type | Default | Description |
|---|---|---|---|
| `chat` | string | required | Group or channel |
| `limit` | int | 100 | Max members to return (1–1000) |
| `search` | string | — | Filter by name |

```json
[
  { "id": 123456789, "type": "User", "username": "johndoe", "first_name": "John" }
]
```

Large public channels may return a limited set or require admin rights.

### POST /api/chats

Create a supergroup or broadcast channel.

```http
POST /api/chats
Content-Type: application/json

{ "title": "my-group", "megagroup": true }
```

| Field | Type | Default | Description |
|---|---|---|---|
| `title` | string | required | Group name (1–255 chars) |
| `megagroup` | bool | true | `true` = supergroup, `false` = broadcast channel |

```json
{ "id": 1234567890, "type": "Channel", "title": "my-group" }
```

### DELETE /api/chats

Delete a supergroup or channel you own. **Irreversible.**

```http
DELETE /api/chats
Content-Type: application/json

{ "chat": "-1001234567890" }
```

```json
{ "ok": true }
```

### POST /api/chats/join

Join a public channel or supergroup.

```http
POST /api/chats/join
Content-Type: application/json

{ "chat": "@somegroup" }
```

```json
{ "ok": true }
```

### POST /api/chats/leave

Leave a channel or supergroup.

```http
POST /api/chats/leave
Content-Type: application/json

{ "chat": "@somegroup" }
```

```json
{ "ok": true }
```

### Errors

| Status | When |
|---|---|
| `400` | Bad JSON, validation failure, or Telethon said the input is nonsense. Body has details. |
| `401` | Missing or wrong Bearer token (only when `TELETHON_AUTH_KEY` is set). |
| `404` | Unknown endpoint. |
| `502` | Telegram threw an RPC error (`FloodWaitError`, `ChatWriteForbiddenError`, etc.). Body has the error class and message. |

### Health

```http
GET /healthz
```

```json
{ "status": "ok", "authorized": true }
```

`authorized: false` means the container started but the session is fucked — bad string, revoked, or Telegram unreachable. Always public, no auth required.

## Observability

### `GET /metrics`

Prometheus exposition. Scrape it. No auth (publicly readable inside your cluster — if you need it locked down, put it behind your usual scrape-network policy).

Series exported:
- `telethon_plus_tool_calls_total{tool=...}`
- `telethon_plus_tool_errors_total{tool=...}`
- `telethon_plus_flood_events_total{bucket=...}`
- `telethon_plus_cache_hits_total` / `_misses_total` / `_entries`
- `telethon_plus_throttle_multiplier`
- `telethon_plus_bucket_used{bucket=...}` / `_bucket_limit{bucket=...}`
- `telethon_plus_uptime_seconds`

### `GET /api/throttle/status`

Live state of every bucket, multiplier, recent flood events, cache stats:

```json
{
  "throttle": {
    "enabled": true,
    "adaptive": true,
    "multiplier": 1.0,
    "flood_events_1h": 0,
    "buckets": {
      "resolve_username": {"used": 0, "limit": 5, "window_seconds": 60},
      "send": {"used": 0, "limit": 20, "window_seconds": 60}
    },
    "tracked_chats_send": 12,
    "tracked_chats_read": 30,
    "global_interval_ms": 50,
    "per_chat_interval_ms": 1100,
    "per_chat_read_interval_ms": 250,
    "jitter_ms": 200
  },
  "cache": {"entries": 247},
  "read_only": false,
  "dry_run": false
}
```

### `GET /api/account/health`

```json
{
  "authorized": true,
  "risk": "ok",
  "multiplier": 1.0,
  "flood_events_1h": 0,
  "read_only": false,
  "dry_run": false
}
```

`risk` is `ok` (multiplier 1×), `warning` (≥2×), or `high` (≥8×).

### Throttle response headers

Every `/api/...` response carries:
- `X-Throttle-Multiplier`
- `X-Throttle-Flood-Events-1h`
- `X-RateLimit-Remaining-<bucket>` for each bucket

Lets clients self-throttle without polling `/api/throttle/status`.

## Updates / webhooks

Two ways to receive incoming Telegram events (new messages, edits, deletes, chat actions):

### WebSocket — `/ws/updates`

```javascript
const ws = new WebSocket('ws://your-host:8080/ws/updates?token=YOUR_AUTH_KEY');
ws.onmessage = (e) => console.log(JSON.parse(e.data));
```

If `TELETHON_AUTH_KEY` is set, pass it as `?token=`. Browser WS clients can't set Authorization headers, hence query-string.

Multiple subscribers are supported — each gets its own queue. Slow consumers drop events at `TELETHON_UPDATES_BUFFER_SIZE`.

### Outbound webhook — `TELETHON_POST_TO_URL`

Set the env var to your endpoint and every event gets `POST`ed there as JSON. Fire-and-forget — a slow or failing webhook never blocks Telethon's loop. Use for distributed workers, separate processes, anything that can't hold a WS open.

### Event payload shape

```json
{
  "type": "NewMessage",
  "message": {
    "id": 4242,
    "date": "2026-04-29T12:00:00+00:00",
    "text": "hi",
    "out": false,
    "sender_id": 12345,
    "chat_id": -1001234567890,
    "reply_to_msg_id": null,
    "media": false,
    "media_type": null
  },
  "chat_id": -1001234567890
}
```

For `MessageEdited`, `MessageDeleted`, `ChatAction` — same shape, fewer fields.

## Safety switches

### `TELETHON_READ_ONLY=true`

Every write endpoint returns `403`. Reads, status, metrics still work. Killswitch for panic mode or for keeping a test deployment harmless.

### `TELETHON_DRY_RUN=true`

Write endpoints accept the request, validate it, **don't** call Telegram, return `{"dry_run": true, "would_...": {...}}`. Useful for verifying scripts before pointing them at production.

## MCP

Mounted at `/mcp/` using the [streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). Every tool from the table above shows up automatically as an MCP tool with the same name and schema.

Point your agent at:

```
http://your-host:8080/mcp/
```

Stateless — every request is independent, no session juggling. Drop it into Claude Desktop, a custom agent, anything that speaks MCP over HTTP. Works out of the box.

## Agent integrations

The [skill](https://github.com/psyb0t/docker-telethon-plus/blob/HEAD/.agents/skills/telethon-plus) works in any agent that reads `.agents/skills/`, and installs natively in the clients below.

### Claude Code

```bash
claude plugin marketplace add psyb0t/agents
claude plugin install telethon-plus@psyb0t
```

Claude Code prompts for the telethon-plus server URL and, if auth is enabled, the bearer token — the token is stored in your OS keychain.

### Codex

```bash
codex plugin marketplace add psyb0t/agents
codex plugin add telethon-plus@psyb0t
```

Installed via the marketplace, the skill invokes as `$telethon-plus:telethon-plus`. Codex also picks the skill up automatically with no install in any repo containing `.agents/skills/`, where it invokes as plain `$telethon-plus`.

### OpenClaw

The skill is published to ClawHub on every release:

```bash
openclaw skills install @psyb0t/telethon-plus
```

For MCP clients that speak local stdio, the [`@psyb0t/telethon-plus`](https://github.com/psyb0t/docker-telethon-plus/blob/HEAD/.agents/plugins/telethon-plus) plugin bridges to the service's `/mcp/` endpoint:

```bash
openclaw plugins install clawhub:@psyb0t/telethon-plus
```

Then set `TELETHON_PLUS_URL` (and `TELETHON_AUTH_KEY` if the server requires auth).

## Development

```bash
make build        # build psyb0t/telethon-plus:latest
make build-test   # build psyb0t/telethon-plus:latest-test
make run          # run locally on :8080 (reads .env)
make login        # interactive login — writes TELETHON_SESSION to .env automatically
make lint         # flake8 + pyright
make format       # isort + black
make test         # run integration tests in Docker
make clean        # remove built images
```

## Tests

Real tests. Real Telegram. No mocking bullshit.

`tests/` spins up the container and hammers both REST and MCP with your actual account. Messages get sent and deleted. If anything breaks, you'll know.

Setup:

```bash
cp .env.example .env
$EDITOR .env  # needs TELETHON_API_ID, TELETHON_API_HASH, TELETHON_SESSION, TEST_CHAT

make test
```

`TEST_CHAT` is where test messages land. Use `me` for Saved Messages — private, yours, no one else sees it. All chat reference formats from the [Tools](#tools) section work here.

`make test` builds both images and runs pytest inside Docker with the socket mounted. No setup beyond `.env`. If credentials are missing, the suite skips cleanly.

| Test file | What it beats on |
|---|---|
| `test_health.py` | Container boots, auth succeeds, OpenAPI spec has all the routes. |
| `test_rest.py` | Validation errors, extra fields rejected, send → edit → fetch → delete roundtrip, dialogs, entity resolution, public channel read, group create/delete, participants. |
| `test_mcp.py` | MCP streamable HTTP: tool discovery, `get_me`, send + delete roundtrip, validation errors come back as `isError`. |
| `test_auth.py` | Auth middleware: 401 on missing/wrong token, 200 on correct token, `/healthz` always public, MCP endpoint protected too. |

## License

[WTFPL](https://github.com/psyb0t/docker-telethon-plus/blob/HEAD/LICENSE) — do whatever the fuck you want.

