Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2486ef2
docs: design for read-only `chats inspect`
popstas Aug 4, 2026
7bc55c0
docs: implementation plan for `chats inspect`
popstas Aug 4, 2026
5521e3d
feat(chats): add read-only chat-inspect domain op
popstas Aug 4, 2026
f65292b
feat(chats): map channel/basic-group/user metadata in the Telethon ad…
popstas Aug 4, 2026
0d77ee9
fix(chats): forbidden peers raise ValueError; dedupe raw/mute mapping
popstas Aug 4, 2026
a92b1ef
feat(cli): add chats inspect
popstas Aug 4, 2026
8f4c7c8
docs: document chats inspect in the skill, README and CLAUDE.md
popstas Aug 4, 2026
14c6a15
docs: fix chats inspect README placement, add missing error strings
popstas Aug 4, 2026
a2c787f
fix(chats): redact access_hash recursively from --raw payloads
popstas Aug 4, 2026
87354e4
fix(chats-inspect): report muted only while a mute is in force
popstas Aug 4, 2026
65112cf
docs: record phase-2 surface decisions in the chats inspect spec
popstas Aug 4, 2026
b7488da
docs: implementation plan for chats inspect phase 2 (HTTP + MCP)
popstas Aug 4, 2026
a941086
feat(http): serve chats inspect at GET /telegram/chats/inspect
popstas Aug 4, 2026
c5e537d
test(http): cover chats-inspect 409 ambiguous-entity mapping
popstas Aug 4, 2026
de2f3b0
test(http): cover chats-inspect 404/409/503 sources on both ref branches
popstas Aug 4, 2026
be39640
feat(mcp): add telegram_chats_inspect tool
popstas Aug 5, 2026
85d9829
docs: document the chats inspect HTTP route and MCP tool
popstas Aug 5, 2026
f8e6ecd
fix(http): annotate chats-inspect flood waits raised during resolution
popstas Aug 5, 2026
d4f6173
docs: spec for chats set-ttl (CLI-only auto-delete write)
popstas Aug 5, 2026
4adba9a
docs: implementation plan for chats set-ttl
popstas Aug 5, 2026
499d59e
feat(chats): add the set-ttl domain op with a no-op short-circuit
popstas Aug 5, 2026
9fc5566
feat(chats): add the Telethon set-ttl adapter, tolerating unparseable…
popstas Aug 5, 2026
b546297
fix(chats): map ChatForbiddenError in get_ttl and cover both branches
popstas Aug 5, 2026
d23675d
feat(config): add ttl pacing knobs and a dedicated ttl gate key
popstas Aug 5, 2026
cf88c89
feat(cli): add chats set-ttl with dry-run and ttl pacing
popstas Aug 5, 2026
641c461
docs: document chats set-ttl in the skill catalog and README
popstas Aug 5, 2026
6b4e463
docs(skill): add standalone Confirmation field to chats set-ttl section
popstas Aug 5, 2026
4a89f91
fix(chats-set-ttl): address whole-feature review findings
popstas Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,51 @@
# Changelog


## Unreleased

### Features

- cli: Add chats set-ttl with dry-run and ttl pacing
- config: Add ttl pacing knobs and a dedicated ttl gate key
- chats: Add the Telethon set-ttl adapter, tolerating unparseable responses
- chats: Add the set-ttl domain op with a no-op short-circuit
- mcp: Add telegram_chats_inspect tool
- http: Serve chats inspect at GET /telegram/chats/inspect
- cli: Add chats inspect
- chats: Map channel/basic-group/user metadata in the Telethon adapter
- chats: Add read-only chat-inspect domain op

### Bug Fixes

- chats: Map ChatForbiddenError in get_ttl and cover both branches
- http: Annotate chats-inspect flood waits raised during resolution
- chats-inspect: Report muted only while a mute is in force
- chats: Redact access_hash recursively from --raw payloads
- chats: Forbidden peers raise ValueError; dedupe raw/mute mapping

### Documentation

- skill: Add standalone Confirmation field to chats set-ttl section
- Document chats set-ttl in the skill catalog and README
- Implementation plan for chats set-ttl
- Spec for chats set-ttl (CLI-only auto-delete write)
- Document the chats inspect HTTP route and MCP tool
- Implementation plan for chats inspect phase 2 (HTTP + MCP)
- Record phase-2 surface decisions in the chats inspect spec
- Fix chats inspect README placement, add missing error strings
- Document chats inspect in the skill, README and CLAUDE.md
- Implementation plan for `chats inspect`
- Design for read-only `chats inspect`

### Miscellaneous

- Regenerate changelog

### Testing

- http: Cover chats-inspect 404/409/503 sources on both ref branches
- http: Cover chats-inspect 409 ambiguous-entity mapping

## v0.11.2 - 2026-08-03

### Bug Fixes
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ All runtime state — `config.yml`, Telethon session, SQLite DB, bearer token
## Common commands

- Run the API: `uvicorn telegram_assistant.http_api.app:create_app --factory --port 8085`
- Run the CLI: `telegram-assistant <resource> <action> [options]` (e.g. `health`, `auth`, `groups create`, `topics bulk-create`, `members bulk-add`, `members list`, `messages send`, `messages forward`, `notifications mute`, `folders inspect`, `operations status`)
- Run the CLI: `telegram-assistant <resource> <action> [options]` (e.g. `health`, `auth`, `groups create`, `topics bulk-create`, `members bulk-add`, `members list`, `chats inspect`, `messages send`, `messages forward`, `notifications mute`, `folders inspect`, `operations status`)
- Manual MCP smoke: enable `mcp:` in `data/config.yml`, run the API, then use `npx @modelcontextprotocol/inspector` against `http://localhost:8085/mcp` (requires Node.js/npm; see `docs/mcp-inspector-e2e.md`)
- Tests: `pytest` (asyncio mode auto). Single test: `pytest tests/test_groups.py::test_name` or filter with `-k pattern`
- Lint: `ruff check src tests` (line-length 100, py312, ignores E501)
Expand Down Expand Up @@ -67,7 +67,7 @@ Six cross-cutting behaviours in `messages/` that surfaces must not re-implement:
- **Pin/unpin are paced.** `pin_message`/`unpin_message` take an optional `pacer=`; without one the backend is called directly (pre-pacing behaviour, which existing tests rely on). The pacer enforces `telegram.pin_min_interval_seconds` through `persistence/rate_gate.py` (`RateGateStore`, table `rate_gate`) so CLI processes and the server share the gate — `reserve()` advances the slot inside one write transaction, `block_until()` never moves a gate backwards. `reserve(..., max_wait=)` makes the booking *conditional*: the pacer passes its cap, so a call it is about to reject reports the wait and leaves the gate untouched — otherwise a client polling a flood-waited chat would push its own retry time further out with every rejected attempt. `FloodWaitError` is slept through (+margin) and retried within a bounded budget; exhaustion (or a single wait over `max_flood_wait_seconds`, default 60) raises `PacedFloodWaitError`, a `worker.queue.FloodWaitError` subclass, so the existing HTTP 502 / MCP `needs_review` mapping is unchanged — surfaces only add `retry_after_seconds`/`retry_at` (HTTP also sets `Retry-After`). That cap bounds waits Telegram imposed, never the operator's own interval: the gate cap is `max(max_flood_wait_seconds, min_interval_seconds)`, so a `pin_min_interval_seconds` above 60 still paces instead of failing every call. Pin and unpin share one key per chat, built by `pin_pacing_key()` from the **bare** id (`pin:<EntityRef.numeric_id>`) — an explicit `telegram_chat_id` keeps the `-100` marker while `entity`/`chat_name` resolution yields the bare id, and keying on the raw value would open two independent gate rows for one chat, silently disabling the shared pacing.
- **Search range validation is shared.** `normalize_search_range(from_date=, to_date=, minutes=)` (exported from `messages/`) holds *all* range validation — both bounds required together, timezone-aware only, `from <= to`, mutually exclusive with `minutes` — and returns the UTC-normalised pair that surfaces echo. Every surface calls it *before* the backend/entity lookup, so a bad range never costs a Telegram round-trip, and `search_messages` re-validates. `TelethonSearchBackend.search_messages` pushes `q`, `from_id`, `top_msg_id`, `min_date`/`max_date` into a **single** `functions.messages.SearchRequest` and pages by `offset_id`; the dates sent are widened 1s per side, with the exact inclusive check re-applied on the mapped rows. One exception: Telegram **ignores `from_id` when the peer is a user**, so for 1:1 chats the adapter sends no `from_id` and re-applies the sender filter locally on the mapped rows (outgoing private messages carry no `from_id` at all — the sender there can only be us), mirroring what Telethon's own message iterator does — and because a 1:1 chat has only two possible senders, a `from_user` naming neither returns `[]` without issuing a single RPC. Paging is capped at `_SEARCH_MAX_PAGES` RPCs: rows dropped by the *local* filters do not count toward `limit`, so without the cap a mostly-filtered query would walk a chat's entire match set. For the same reason the **wire page width is not tied to `limit`** whenever a local filter is in play (private-chat `from_user`, or a date range) — it is the full `_SEARCH_PAGE_SIZE`, or the cap would shrink to `limit * _SEARCH_MAX_PAGES` messages and `--limit 1` would answer `[]` where `--limit 20` finds the same message. A **short page is not a stop condition** — channels may omit undisplayable messages from a full slice, so `len(page) < page_size` would silently hide older matches (Telethon's own iterator refuses the same shortcut); paging stops on an empty page, a non-advancing `offset_id`, the cap, or a page whose newest id is `<= page_size` (ids start at 1, so nothing older can exist). `MessageEmpty` rows are skipped after advancing the offset, so a deleted slot neither spends a `limit` slot nor stalls paging. Senders are mapped from `msg.sender_id` (which `Message.__init__` derives without any entity resolution) against a username index built from **both** `result.users` and `result.chats` — raw search hits never go through Telethon's `_finish_init`, so `msg.sender` is `None`, and keying on `from_id.user_id` alone would report no sender for channel posts, anonymous admins and incoming private messages while `messages recent` reports one for the very same message.

This split is what lets tests inject fakes without spinning up Telethon. The HTTP layer mirrors the pattern via **backend factories** on `app.state.*_backend_factory` (including `message_backend_factory`, `message_read_backend_factory`, `reaction_backend_factory`, `forward_backend_factory`, `edit_backend_factory`, `pin_backend_factory`, `download_backend_factory`, `search_backend_factory`, `notification_backend_factory`, and `resolver_factory`). A factory returns `None` when the Telethon client isn't yet connected; the router then responds **503 Service Unavailable** instead of 500. `TelethonMessageBackend` is the default send backend for text/media/scheduled sends; do not fall back to the topic backend for message sends. When changing how backends are constructed, preserve this contract — `/health` must still respond even with an unauthorized session.
This split is what lets tests inject fakes without spinning up Telethon. The HTTP layer mirrors the pattern via **backend factories** on `app.state.*_backend_factory` (including `message_backend_factory`, `message_read_backend_factory`, `reaction_backend_factory`, `forward_backend_factory`, `edit_backend_factory`, `pin_backend_factory`, `download_backend_factory`, `search_backend_factory`, `chat_inspect_backend_factory`, `notification_backend_factory`, and `resolver_factory`). A factory returns `None` when the Telethon client isn't yet connected; the router then responds **503 Service Unavailable** instead of 500. `TelethonMessageBackend` is the default send backend for text/media/scheduled sends; do not fall back to the topic backend for message sends. When changing how backends are constructed, preserve this contract — `/health` must still respond even with an unauthorized session.

Two shared domain modules sit alongside the per-area ones:

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ Top-level:
- `members bulk-remove` — bulk-remove members from a supergroup (kick or permanently ban).
- `members list` — read-only: list a chat's participants (READ-gated, no writes, no `--dry-run`). Target with `--chat-id`/`--chat-name`/`--entity`; `--limit` defaults to 200, `--query <substring>` matches username/first/last name (server-side for the default filter), `--filter all|admins|bots` picks the Telegram-side filter. `--user <ref>` switches to a single-request membership check and answers `is_member` plus the user's role (mutually exclusive with `--query`) — that is the cheap way to check one bot across many chats, unlike `members bulk-add --dry-run`, which plans an add without checking membership. Legacy basic groups are supported through a `messages.GetFullChat` fallback. The payload carries `participants_count` and `truncated` (the walk stopped at `--limit` or at Telegram's ~10k enumeration ceiling).

`chats` — read chat metadata:

- `chats inspect` — read-only: report one chat's metadata (READ-gated, no writes, no `--dry-run`). Target with `--chat-id`/`--chat-name`/`--entity`. Returns one flat JSON object with the same keys for every chat kind (`null` where a field does not apply): `ttl_period` (auto-delete window in seconds, `null` when off), `about`, `pinned_message_id`, `archived`, `muted`/`muted_until`/`silent` (`muted` is true only while the mute is still in force — an expired mute and the epoch timestamp Telegram writes for an unmute both report `false` with a `null` `muted_until`; `silent` is the separate sound-off flag), `restricted` + `restriction_reason`, `invite_link`, `my_admin_rights`, `default_banned_rights`, plus `is_forum`/`topics_layout`/`participants_count`/`admins_count`/`slowmode_seconds`/`linked_chat_id` for groups and channels and `phone`/`is_premium`/`blocked`/`common_chats_count`/`birthday` for private chats. Supergroups, channels, legacy basic groups, users and bots are all supported (one `GetFull*` request each). `--raw` adds the serialized entity and Full objects under `raw` for fields the curated set does not name; `access_hash` is never included. It reads only; `chats set-ttl` is the one write counterpart, and it covers `ttl_period` alone.
- `chats set-ttl` — set a chat's auto-delete period (WRITE-gated, supports `--dry-run`). Target with `--chat-id`/`--chat-name`/`--entity`; `--ttl` takes `off` (or `0`) or `<integer><unit>` with unit `s`/`m`/`h`/`d`/`w` (`1d`, `24h`, `93d`), a bare integer being seconds. Telegram accepts arbitrary periods, not just the day/week/month its clients offer, so there is no preset allow-list — the server rejects what it will not take. Returns `{chat_id, chat_name, requested_ttl_seconds, previous_ttl_seconds, ttl_period, changed, dry_run}`, where `ttl_period` is re-read from the server after the write rather than echoed from the request (Telegram's response to this call does not always parse, while the write still applies). Setting the period a chat already has issues **no write at all**: every successful change posts a service message visible to every member, so a re-run over a folder would otherwise spam it. Flood waits on this method escalate into the hundreds of seconds; the command sits through them, paced through the shared SQLite gate on its own row and bounded by `telegram.ttl_min_interval_seconds` (default 2.0), `telegram.ttl_max_flood_wait_seconds` (default 3600) and `telegram.ttl_max_flood_wait_retries` (default 5). CLI-only — there is no HTTP route or MCP tool.

`messages` — send messages and service commands:

- `messages send` — send a message or service command (targeted or folder-wide mass mode). Attach local files with repeated `--file` and/or remote URLs with repeated `--file-url` (multiple attachments send an album); defer delivery with `--schedule-at` (ISO-8601 datetime) or `--delay` (relative duration like `10m`, `2h`, `1d`); thread a reply with `--reply-to <message_id>`. `--text` may be omitted for media-only sends. Attachments, scheduling, and `--reply-to` apply to targeted sends only, not mass mode. `--rich-markdown <file.md>` sends the file's contents as a Telegram **rich message** (article) instead of plain text, with its own knobs (`--no-spaced-paragraphs`, `--no-line-breaks`, `--rich-file <reference>=<path>`, `--vault-dir <dir>`, `--media-group <index>=<collage|slideshow|none>`) — see below.
Expand Down Expand Up @@ -236,6 +241,7 @@ All `/telegram/*` endpoints require `Authorization: Bearer <token>` and use the
- `POST /telegram/messages/download` downloads an existing message's media to a **server-side** file (READ-gated). Target with `telegram_chat_id`, `entity`, or `chat_name` + `folder_name`/`folder_id`. Body carries `message_id` plus optional `out_dir` and `max_bytes`; `out_dir` is confined to `telegram.download_root` (default: the system temp dir) — a relative value is resolved inside the root, one escaping it is rejected with `400`, and omitting it uses the root — so a READ-only caller cannot pick an arbitrary write location. An existing file is never overwritten: the download goes to the first free `name (1).ext` and the response's `path` is the file actually written (plus size and mime; no base64/streaming in this iteration). Files are created mode `0600` (owner-only). Returns `503` when the session is not connected.
- `GET /telegram/messages/search` text-searches a chat newest-first (READ-gated); query params mirror `recent` in name (`query` required, plus `from_user`, `limit` — **default 20** here, not `recent`'s 5 — `minutes`, `topic_id`) and add the fixed inclusive range `from_date`/`to_date` (ISO-8601 with timezone, required together, mutually exclusive with `minutes`; invalid ranges → `400`). The response echoes the applied bounds normalised to UTC. Paging is capped at 20 search requests, so a query whose hits are mostly dropped by the local filters can return fewer than `limit` rows even when older matches exist — narrow the range or the sender. Returns `503` when the session is not connected.
- `GET /telegram/members/list` lists a chat's participants (READ-gated). Query params: exactly one of `chat_id` or `entity`, plus optional `limit` (default 200), `query` (substring on username/first/last name), `filter` (`all`|`admins`|`bots`), and `user`. With `user` the endpoint answers membership for that one user in a single request and adds `user`/`is_member` to the payload — `is_member` is `false` for a user who left or was banned, whose role is still reported. The response carries `participants` (`user_id`, `username`, `first_name`, `last_name`, `is_bot`, `role`), `count`, `participants_count` and `truncated` (the walk stopped at `limit` or at Telegram's ~10k enumeration ceiling). `user` and `query` together, an unknown `filter`, a non-positive `limit`, or a peer that is not a group are `400`; a denied chat is `403`; no connected session is `503`.
- `GET /telegram/chats/inspect` returns one chat's metadata (READ-gated). Query params: exactly one of `chat_id`, `entity`, or `chat_name` (which requires `folder_name`, optionally cross-checked by `folder_id`) — the same references the CLI takes. The body is one flat JSON object with the same keys for every chat kind (`null` where a field does not apply): `chat_id` (bare id, no `-100`), `kind`, `title`, `about`, `ttl_period` (auto-delete window in seconds, `null` when off), `pinned_message_id`, `archived`, `muted`/`muted_until`/`silent`, `restricted` + `restriction_reason`, `invite_link`, `my_admin_rights`, `default_banned_rights`, plus the groups/channels block (`is_forum`, `topics_layout`, `participants_count`, `admins_count`, `slowmode_seconds`, `linked_chat_id`, …) and the users/bots block (`phone`, `is_premium`, `blocked`, `common_chats_count`, `birthday`, …). The fields are the CLI's, but datetimes (`created_at`, `muted_until`, `slowmode_next_send_date`) render as ISO-8601 here (`2026-01-02T03:04:05Z`) while the CLI prints Python's own repr (`2026-01-02 03:04:05+00:00`) — parse them rather than string-comparing across surfaces. `raw` is **CLI-only**: passing `raw=true` is a `400` naming the reason rather than a silently dropped flag, so the serialized Telethon objects are never returned remotely and `access_hash` can never leak. Missing/duplicate references and an uninspectable peer are `400`, a denied chat is `403`, an unresolvable reference is `404`, an ambiguous one `409`, a `FLOOD_WAIT` is `502` with `retry_after_seconds` and the `Retry-After` header, and no connected session is `503`. It reads only — there is no endpoint to change any of these settings.
- `POST /telegram/notifications/mute` and `/telegram/notifications/unmute` mute or unmute a target chat/contact; mute accepts positive `duration_hours`.
- `GET /telegram/folders/{folder_name}` returns the folder snapshot (id, name, chats). **READ-gated on the folder**: the folder is resolved first and the gate runs on the snapshot's own `folder_name`/`folder_id`, so a `folder_id:` rule grants an inspect requested by name; a denied caller gets `403` and no chat list.
- `DELETE /telegram/folders/{folder_name}/chats` removes `chat_id`, `chat_name`, or `entity` from a folder and returns `already_absent` when no change was needed.
Expand Down Expand Up @@ -294,6 +300,7 @@ MCP tool catalog:
| `telegram_topics_open` | `topic_id` or `topic_name`, `telegram_chat_id`/`entity`/`chat_name` + `folder_name`/`folder_id`, optional `reason`; WRITE-gated, executes every call (already-open is a Telegram-level no-op) |
| `telegram_topics_rename` | `new_title`, `topic_id` or `topic_name`, `telegram_chat_id`/`entity`/`chat_name` + `folder_name`/`folder_id`, optional `reason`; WRITE-gated, idempotent by target title |
| `telegram_members_list` | exactly one of `chat_id`/`entity`, optional `limit` (default 200), `query`, `filter` (`all`\|`admins`\|`bots`), `user`; READ-gated read op — with `user` it answers `is_member` for one user in a single request, otherwise it returns the participants page plus `participants_count`/`truncated` |
| `telegram_chats_inspect` | exactly one of `chat_id`/`entity`/`chat_name` (+ `folder_name`, optional `folder_id`); READ-gated read op returning one flat metadata payload per chat kind — `ttl_period`, `about`, `pinned_message_id`, `archived`, `muted`/`muted_until`/`silent`, `restricted`, `invite_link`, `my_admin_rights`, `default_banned_rights`, plus the groups/channels and users/bots blocks. `raw` is CLI-only and is **rejected** (tool error) rather than ignored; a `FLOOD_WAIT` comes back as `needs_review` with `retry_after_seconds` |
| `telegram_members_add` | `telegram_chat_id`/`entity`/`chat_name` + `folder_name`, `items`, `mode`, `continue_on_error`, `operation_id` |
| `telegram_members_remove` | `telegram_chat_id`/`entity`/`chat_name` + `folder_name`, `items`, `mode`, `continue_on_error`, `operation_id` |
| `telegram_folders_inspect` | `folder_name`, optional `folder_id`; READ-gated on the folder (403 otherwise, since the payload lists every chat in it) |
Expand Down
Loading
Loading