diff --git a/CHANGELOG.md b/CHANGELOG.md index 442ef36..a676f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 667359f..49f1aef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 [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 [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) @@ -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:`) — 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: diff --git a/README.md b/README.md index c3273b1..01e11b4 100644 --- a/README.md +++ b/README.md @@ -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 ` matches username/first/last name (server-side for the default filter), `--filter all|admins|bots` picks the Telegram-side filter. `--user ` 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 `` 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 `. `--text` may be omitted for media-only sends. Attachments, scheduling, and `--reply-to` apply to targeted sends only, not mass mode. `--rich-markdown ` 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 =`, `--vault-dir `, `--media-group =`) — see below. @@ -236,6 +241,7 @@ All `/telegram/*` endpoints require `Authorization: Bearer ` 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. @@ -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) | diff --git a/docs/superpowers/plans/2026-08-05-chats-inspect-phase2.md b/docs/superpowers/plans/2026-08-05-chats-inspect-phase2.md new file mode 100644 index 0000000..8bc992e --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-chats-inspect-phase2.md @@ -0,0 +1,1422 @@ +# `chats inspect` Phase 2 — HTTP + MCP surfaces + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose the already-shipped `chats inspect` domain op on the HTTP API (`GET /telegram/chats/inspect`) and on the MCP server (`telegram_chats_inspect`). Phase 1 (the `chats/` domain package and the CLI command) is complete and merged on this branch; **no new domain logic** is written here. + +**Architecture:** A new router module `src/telegram_assistant/http_api/chats.py` holds one route plus the shared helper `inspect_chat_for_request()` that the MCP tool reuses verbatim — the same pattern by which `mcp/tools.py` reuses `http_api/members.py`'s `_member_list_backend_or_503` and `http_api/topics.py`'s `_resolve_chat_id_generic`. A `chat_inspect_backend_factory` on `app.state` (built once in `create_app()`, like every other backend factory) returns `None` until the Telethon client is connected, which the route turns into `503`. + +**Tech Stack:** Python 3.12, FastAPI, FastMCP, Telethon >= 1.44, pytest + pytest-asyncio (asyncio mode auto), ruff. + +**Spec:** `docs/superpowers/specs/2026-08-05-chats-inspect-design.md` (section "Phase 2 — remaining surfaces") + +**Research:** `.superpowers/research/phase2-surfaces.md` + +## Global Constraints + +- Use `.venv` — run everything as `.venv/bin/pytest`, `.venv/bin/ruff`. +- `ruff check src tests` must pass: line-length 100, target py312, `E501` ignored. +- The full suite is currently **2318 passing** and must stay green at every commit. +- No new domain logic. `src/telegram_assistant/chats/service.py` and `chats/telethon_backend.py` are **not** modified by this plan; if a task seems to need a change there, stop and report it. +- `chats/service.py` must not import `telethon`. +- `access_hash` must never appear in any payload. The remote surfaces never pass `raw=True`, so they cannot emit one. +- READ-gated on every surface, enforced in the domain layer — the surfaces do not re-implement the gate, they build the `Authorizer` and let `inspect_chat()` check it. +- `chat_id` in the payload is the bare id (no `-100` marker). +- HTTP status mapping: `AccessDenied` → 403, entity-not-found → 404, ambiguous entity → 409, domain `ValueError` → 400, backend unavailable → 503, `FloodWaitError` → 502 with `Retry-After`. + +--- + +## Decisions this plan implements (from the spec, not re-openable) + +1. **Chat reference.** Both remote surfaces take the same set the CLI does: `chat_id`, `entity`, or `chat_name` with `folder_name` / `folder_id`. **The model followed is `messages pin`** (`PinBody._shape` in `src/telegram_assistant/http_api/messages.py:446-463` plus the `pin` route's resolution ladder at `messages.py:1282-1300`): a 3-way exclusive-or over `telegram_chat_id`/`entity`/`chat_name`, with `chat_name requires folder_name`. `messages edit` (`EditBody._shape`, `messages.py:405-424`) is byte-for-byte the same shape, so either would do; `pin` is named because it is also the surface whose `FloodWaitError` mapping is being reused (decision 3), which keeps this plan pointing at one precedent rather than two. + **Folder defaulting:** *none* on the remote surfaces. `telegram.default_chat_folder.folder_name` is a CLI convenience applied in `cli/main.py::chats_inspect` via `_resolve_folder_name`; no HTTP body in this codebase defaults it, and `PinBody` requires `folder_name` explicitly whenever `chat_name` is given. This plan does the same. + **Parameter naming:** the remote surfaces call the numeric parameter `chat_id`, not `telegram_chat_id`. The payload's own key is `chat_id`, the CLI flag is `--chat-id`, and the two sibling bare-READ endpoints (`GET /telegram/members/list`, `GET /telegram/messages/recent`) already spell it `chat_id`. `telegram_chat_id` is the spelling used by the POST bodies that carry a *message* id next to it, which this op does not. +2. **`raw` is CLI-only.** Both surfaces accept a `raw` parameter and **reject** it — HTTP 400, MCP a tool error — with a message naming the reason. The rejection runs before the backend is even resolved, and after it the surfaces call `inspect_chat(..., raw=False)` unconditionally, so no remote caller's flag can reach the serializer. Tests on both surfaces prove the rejection *and* that the fake backend recorded zero calls. +3. **`FloodWaitError` is mapped.** HTTP answers 502 with `Retry-After` and `retry_after_seconds` in the body; MCP reports `needs_review` with the same field. This reuses the exact mapping `messages pin`/`unpin` established — `_translate_flood_wait` (`http_api/messages.py:93-109`) on the HTTP side and the `FolderPeerFailureError | FloodWaitError` branch of `_raise_from_exception` (`http_api/mcp/tools.py:428-436`) on the MCP side, both reading `retry_after_details()` (`messages/pacing.py:240-253`). `members list` maps none of this; that omission is deliberately **not** copied. + One wrinkle the plan handles explicitly: `retry_after_details()` reads `exc.retry_after_seconds`, which only a **paced** flood-wait (`PacedFloodWaitError`, `messages/pacing.py:69-98`) carries. `chats inspect` is a one-shot read with no pacer, so `chats/telethon_backend.py` surfaces a bare `worker.queue.FloodWaitError` whose window lives on `.seconds`. Task 1 adds a five-line `_annotate_retry_after()` in the *surface* module that copies `.seconds` onto `retry_after_seconds`/`retry_at` before re-raising, so the existing mapping produces the promised payload instead of a poorer second one. No domain file changes. +4. **Payload unchanged.** Both surfaces return exactly `ChatInfo.to_dict()` — no `telegram_chat_id` wrapper key, no `limit`/`filter` echo (`members list` has those because it has such parameters; this op does not). + +## Verified facts a fresh implementer would otherwise have to rediscover + +- **`_on_swap` needs no entry.** Verified by reading `src/telegram_assistant/http_api/app.py:735-764`: the config hot-reload closure rebuilds `plugin_registry`, clears `folder_membership_cache`, calls `_ensure_state_stores`, and re-applies `mcp.disabled_tools`. It touches **no** `*_backend_factory`; every factory is built once in `create_app()` closed over the stable `session_manager`. Adding `chat_inspect_backend_factory` to `_on_swap` would be dead code. **Do not add it.** +- **`mcp.disabled_tools` needs no code change.** `configure_mcp_tools` (`http_api/mcp/server.py`) applies the filter after `register_telegram_tools` has registered everything, at mount time and on every hot-reload. A tool registered inside `register_telegram_tools` is automatically prunable by `telegram_chats_inspect` or `telegram_chats_*`. +- **`tests/test_skill_inventory.py` is CLI-only.** It compares the Typer command tree against the SKILL.md catalog; `chats inspect` is already in both from phase 1, so it is green now and stays green. It does not enumerate HTTP routes or MCP tools. +- **`tests/test_mcp_mount.py::EXPECTED_TOOL_NAMES` is asserted for exact equality** against the live `tools/list` response. Adding a tool without adding its name there fails `test_mcp_initialize_and_tools_list_are_reachable_with_token`. +- **No test enumerates HTTP routes generically** — there is no route-inventory guard to update. + +--- + +### Task 1: HTTP route `GET /telegram/chats/inspect` + `chat_inspect_backend_factory` + +**Files:** +- Create: `src/telegram_assistant/http_api/chats.py` +- Modify: `src/telegram_assistant/http_api/app.py` +- Create: `tests/test_chats_inspect_surfaces.py` + +**Interfaces:** + +*Consumes (all already exist, unchanged):* +- `telegram_assistant.chats.inspect_chat(*, backend: ChatInspectBackend, chat_id: int, raw: bool = False, authorizer: Authorizer | None = None) -> ChatInfo` +- `telegram_assistant.chats.ChatInspectBackend` — Protocol, `async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo` +- `telegram_assistant.chats.ChatInfo.to_dict() -> dict[str, Any]` — omits the `raw` key entirely when `raw is None` +- `telegram_assistant.http_api.access.build_authorizer(request, *, folder_backend=None) -> Authorizer` +- `telegram_assistant.http_api.access.translate_access_error(exc) -> HTTPException | None` +- `telegram_assistant.http_api.topics._resolve_chat_id_generic(*, telegram_chat_id: int | None, chat_name: str | None, entity: str | int | None = None, folder_name: str | None, folder_id: int | None, request: Request) -> int` — resolves entity → resolver (503/404/409), numeric passthrough, or `resolve_chat_in_folder` (503/404/409 via `_translate_folder_error`) +- `telegram_assistant.http_api.messages._translate_flood_wait(exc: FloodWaitError) -> HTTPException` +- `telegram_assistant.http_api.auth.BearerAuth` +- `telegram_assistant.worker.queue.FloodWaitError` — `RuntimeError` subclass with `.seconds: float` +- `telegram_assistant.chats.telethon_backend.TelethonChatInspectBackend(client)` + +*Produces (consumed by Task 2):* +- `telegram_assistant.http_api.chats.RAW_REJECTED_MESSAGE: str` +- `telegram_assistant.http_api.chats.validate_chat_inspect_args(*, chat_id: int | None, chat_name: str | None, entity: str | int | None, folder_name: str | None, raw: bool) -> None` — raises `ValueError` +- `telegram_assistant.http_api.chats._chat_inspect_backend_or_503(request) -> ChatInspectBackend` +- `telegram_assistant.http_api.chats.inspect_chat_for_request(request, *, chat_id=None, chat_name=None, entity=None, folder_name=None, folder_id=None, raw=False) -> dict[str, Any]` — **this is the single entry point Task 2's MCP tool calls** +- `telegram_assistant.http_api.chats.build_router() -> APIRouter` +- `telegram_assistant.http_api.app.ChatInspectBackendFactory = Callable[[Request], ChatInspectBackend | None]` +- `create_app(..., chat_inspect_backend_factory: ChatInspectBackendFactory | None = None, ...)` and `app.state.chat_inspect_backend_factory` +- In `tests/test_chats_inspect_surfaces.py`: `FakeInspectBackend`, `FakeResolver`, `FakeFolderBackend`, `_chat_info()`, `_folder_backend()`, `_config_with_access()`, `_READ_ACCESS`, `_WRITE_ACCESS`, `_make_store()`, `_http_client()`, `AUTH` — Task 2 adds MCP tests to the *same* file and reuses `FakeInspectBackend`, `FakeResolver`, `FakeFolderBackend`, `_chat_info`, `_folder_backend`. + +--- + +- [ ] **Step 1: Write the failing test file** + +Create `tests/test_chats_inspect_surfaces.py`: + +```python +"""Surface tests for `chats inspect` — HTTP and MCP wiring. + +The domain op is covered by ``test_chats_inspect.py``, the Telethon adapter by +``test_chats_inspect_backend.py`` and the CLI by ``test_cli_chats_inspect.py``. +This module covers the two *remote* surfaces: parameter validation, the payload +shape, the CLI-only ``raw`` rejection and the status / error taxonomy. +""" + +from __future__ import annotations + +import tempfile +import textwrap +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient + +from telegram_assistant.chats import ChatInfo +from telegram_assistant.config import load_config_from_text +from telegram_assistant.entities import EntityNotFoundError, ResolvedEntity +from telegram_assistant.folders import FolderChat, FolderSnapshot +from telegram_assistant.http_api import create_app +from telegram_assistant.persistence import OperationStore +from telegram_assistant.worker.queue import FloodWaitError + +AUTH = {"Authorization": "Bearer secret_token"} + +#: The chat the fakes answer for. Bare id, no ``-100`` marker — that is what +#: ``ChatInfo.chat_id`` carries and what the payload must report. +CHAT_ID = 2305069221 + + +def _chat_info(chat_id: int = CHAT_ID) -> ChatInfo: + """A canned supergroup payload touching one field per payload group.""" + return ChatInfo( + chat_id=chat_id, + kind="supergroup", + title="Client chat", + username="clientchat", + usernames=("clientchat",), + about="A client chat", + created_at=datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC), + ttl_period=86400, + megagroup=True, + is_forum=True, + topics_layout="tabs", + participants_count=12, + ) + + +class FakeInspectBackend: + """Records every call; returns a canned ``ChatInfo`` or raises ``error``.""" + + def __init__( + self, info: ChatInfo | None = None, *, error: Exception | None = None + ) -> None: + self._info = _chat_info() if info is None else info + self._error = error + self.calls: list[dict[str, Any]] = [] + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + self.calls.append({"chat_id": chat_id, "raw": raw}) + if self._error is not None: + raise self._error + return self._info + + +class FakeResolver: + def __init__(self, mapping: dict[str, int]) -> None: + self._mapping = mapping + + async def resolve(self, ref: object) -> ResolvedEntity: + key = str(ref) + if key not in self._mapping: + raise EntityNotFoundError(f"entity {key!r} not found") + return ResolvedEntity(chat_id=self._mapping[key], title=key, kind="channel") + + +class FakeFolderBackend: + """Just enough of ``FolderBackend`` for ``resolve_chat_in_folder``.""" + + def __init__(self, snapshots: list[FolderSnapshot] | None = None) -> None: + self._snapshots = [] if snapshots is None else snapshots + + async def list_folders(self) -> list[FolderSnapshot]: + return list(self._snapshots) + + +def _folder_backend() -> FakeFolderBackend: + return FakeFolderBackend( + [ + FolderSnapshot( + folder_id=2, + folder_name="Planfix clients", + chats=[FolderChat(chat_id=CHAT_ID, title="Client chat")], + ) + ] + ) + + +def _config_with_access(access_block: str | None) -> str: + base = textwrap.dedent( + """ + telegram: + api_id: 123456 + api_hash: "telegram_api_hash" + session_path: /data/telegram-assistant.session + default_chat_folder: + folder_id: 2 + folder_name: "Planfix clients" + {access} + http: + host: "0.0.0.0" + port: 8085 + bearer_token: "secret_token" + logging: + level: INFO + """ + ) + indented = "" + if access_block is not None: + indented = textwrap.indent(access_block, " ") + return base.format(access=indented).strip() + + +_READ_ACCESS = "access:\n rules:\n - all: true\n permission: read\n" +_WRITE_ACCESS = "access:\n rules:\n - all: true\n permission: write\n" + + +def _make_store() -> OperationStore: + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".db") + tmp.close() + return OperationStore(Path(tmp.name)) + + +def _http_client( + *, + access_block: str | None = None, + backend: FakeInspectBackend | None = None, + resolver: FakeResolver | None = None, + folder_backend: FakeFolderBackend | None = None, + has_factory: bool = True, +) -> TestClient: + config = load_config_from_text(_config_with_access(access_block)) + app = create_app( + config, + session_manager=None, + chat_inspect_backend_factory=( + (lambda _r: backend) if has_factory else (lambda _r: None) + ), + folder_backend_factory=( + (lambda _r: folder_backend) + if folder_backend is not None + else (lambda _r: None) + ), + resolver_factory=( + (lambda _r: resolver) if resolver is not None else (lambda _r: None) + ), + operation_store=_make_store(), + ) + return TestClient(app) + + +# --- HTTP ------------------------------------------------------------------ + + +def test_http_chats_inspect_returns_the_domain_payload() -> None: + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + # Exactly ChatInfo.to_dict() — no wrapper keys, no echoed parameters. + assert body["chat_id"] == CHAT_ID + assert body["kind"] == "supergroup" + assert body["ttl_period"] == 86400 + assert body["topics_layout"] == "tabs" + assert body["participants_count"] == 12 + assert body["created_at"].startswith("2026-01-02T03:04:05") + assert body["usernames"] == ["clientchat"] + # Fields that do not apply to a supergroup are present and null. + assert body["phone"] is None + # `raw` is dropped entirely, never sent as null. + assert "raw" not in body + assert "telegram_chat_id" not in body + # The remote surface never asks the backend for the raw objects. + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_http_chats_inspect_resolves_entity() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + resolver=FakeResolver({"@clientchat": CHAT_ID}), + ) + + resp = client.get( + "/telegram/chats/inspect", params={"entity": "@clientchat"}, headers=AUTH + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["chat_id"] == CHAT_ID + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_http_chats_inspect_resolves_chat_name_in_folder() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_folder_backend(), + ) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_name": "Client chat", "folder_name": "Planfix clients"}, + headers=AUTH, + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["chat_id"] == CHAT_ID + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_http_chats_inspect_reports_a_missing_entity_as_404() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + resolver=FakeResolver({}), + ) + + resp = client.get( + "/telegram/chats/inspect", params={"entity": "@nope"}, headers=AUTH + ) + + assert resp.status_code == 404, resp.text + assert backend.calls == [] + + +def test_http_chats_inspect_reports_a_missing_chat_name_as_404() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_folder_backend(), + ) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_name": "Other chat", "folder_name": "Planfix clients"}, + headers=AUTH, + ) + + assert resp.status_code == 404, resp.text + assert backend.calls == [] + + +def test_http_chats_inspect_requires_exactly_one_ref() -> None: + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + none_given = client.get("/telegram/chats/inspect", headers=AUTH) + two_given = client.get( + "/telegram/chats/inspect", + params={"chat_id": CHAT_ID, "entity": "@clientchat"}, + headers=AUTH, + ) + + assert none_given.status_code == 400, none_given.text + assert "exactly one" in none_given.json()["detail"] + assert two_given.status_code == 400, two_given.text + assert backend.calls == [] + + +def test_http_chats_inspect_requires_folder_name_with_chat_name() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_folder_backend(), + ) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_name": "Client chat"}, headers=AUTH + ) + + assert resp.status_code == 400, resp.text + assert resp.json()["detail"] == "chat_name requires folder_name" + assert backend.calls == [] + + +def test_http_chats_inspect_rejects_raw() -> None: + """`raw` is CLI-only — rejected, never silently ignored.""" + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_id": CHAT_ID, "raw": "true"}, + headers=AUTH, + ) + + assert resp.status_code == 400, resp.text + detail = resp.json()["detail"] + assert "raw is CLI-only" in detail + # The rejection happens before anything reaches the domain op, so a remote + # caller's flag can never reach the serializer. + assert backend.calls == [] + + +def test_http_chats_inspect_raw_false_is_accepted() -> None: + """An explicit `raw=false` is a normal request, not a rejection.""" + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_id": CHAT_ID, "raw": "false"}, + headers=AUTH, + ) + + assert resp.status_code == 200, resp.text + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_http_chats_inspect_denied_without_read() -> None: + backend = FakeInspectBackend() + client = _http_client(access_block=_WRITE_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error"] == "access_denied" + # The gate runs before any Telegram call. + assert backend.calls == [] + + +def test_http_chats_inspect_503_without_backend() -> None: + client = _http_client(access_block=_READ_ACCESS, has_factory=False) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 503, resp.text + + +def test_http_chats_inspect_rejects_an_uninspectable_peer_with_400() -> None: + backend = FakeInspectBackend( + error=ValueError(f"chat {CHAT_ID} is private or inaccessible") + ) + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 400, resp.text + assert resp.json()["detail"] == f"chat {CHAT_ID} is private or inaccessible" + + +def test_http_chats_inspect_maps_flood_wait_to_502_with_retry_after() -> None: + backend = FakeInspectBackend(error=FloodWaitError(30.0)) + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 502, resp.text + detail = resp.json()["detail"] + assert detail["error"] == "needs_review" + assert detail["retry_after_seconds"] == 30.0 + assert detail["retry_at"] > 0 + assert resp.headers["Retry-After"] == "30" + + +def test_http_chats_inspect_requires_a_bearer_token() -> None: + client = _http_client(access_block=_READ_ACCESS, backend=FakeInspectBackend()) + + resp = client.get("/telegram/chats/inspect", params={"chat_id": CHAT_ID}) + + # A missing Authorization header is 401 on every /telegram/* route + # (tests/test_app_skeleton.py::test_protected_endpoint_requires_authorization_header). + assert resp.status_code == 401, resp.text +``` + +- [ ] **Step 2: Run the test and watch it fail for the right reason** + +```bash +.venv/bin/pytest tests/test_chats_inspect_surfaces.py -q +``` + +Expected: every test errors with `TypeError: create_app() got an unexpected keyword argument 'chat_inspect_backend_factory'`. + +- [ ] **Step 3: Create `src/telegram_assistant/http_api/chats.py`** + +```python +"""HTTP routes for read-only chat metadata (the ``chats`` domain). + +One route, ``GET /telegram/chats/inspect``, exposing +:func:`telegram_assistant.chats.inspect_chat`. The reference handling, the READ +gate wiring and the domain call live in :func:`inspect_chat_for_request`, which +the MCP tool ``telegram_chats_inspect`` calls verbatim — the two remote surfaces +must not be able to drift apart on which references they accept or on whether +``raw`` reaches the domain op. +""" + +from __future__ import annotations + +import time +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, status + +from telegram_assistant.access import AccessDenied +from telegram_assistant.chats import ChatInspectBackend, inspect_chat +from telegram_assistant.folders import FolderBackend +from telegram_assistant.http_api.access import build_authorizer, translate_access_error +from telegram_assistant.http_api.auth import BearerAuth +from telegram_assistant.http_api.messages import _translate_flood_wait +from telegram_assistant.http_api.topics import _resolve_chat_id_generic +from telegram_assistant.worker.queue import FloodWaitError + +#: Why a remote caller may not ask for the serialized Telethon objects. The +#: curated payload is designed to be enough; ``raw`` carries considerably more +#: (a legacy group's whole member roster via ``ChatFull.participants``, a user's +#: business location and stories), and this project already keeps local-only +#: capabilities off the remote surfaces — ``scan_media`` resolves server-side +#: paths for the CLI alone, ``messages download --out`` is unconfined only there. +RAW_REJECTED_MESSAGE = ( + "raw is CLI-only: the serialized entity/Full objects are never returned " + "over HTTP or MCP; run `telegram-assistant chats inspect --raw` locally" +) + + +def _chat_inspect_backend_or_503(request: Request) -> ChatInspectBackend: + """Resolve the chat-inspect backend, or raise 503. + + Two stages like every sibling helper: no factory at all means nobody wired + one (only a test opts out); a factory returning ``None`` is the production + case where the Telethon client is not connected yet. + """ + factory = getattr(request.app.state, "chat_inspect_backend_factory", None) + if factory is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Telegram chat-inspect backend is not configured (session may be unauthorized)", + ) + backend = factory(request) + if backend is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Telegram chat-inspect backend is not available", + ) + return backend + + +def _folder_backend_optional(request: Request) -> FolderBackend | None: + factory = getattr(request.app.state, "folder_backend_factory", None) + if factory is None: + return None + return factory(request) + + +def validate_chat_inspect_args( + *, + chat_id: int | None, + chat_name: str | None, + entity: str | int | None, + folder_name: str | None, + raw: bool, +) -> None: + """Reject a malformed remote chats-inspect request (raises ``ValueError``). + + ``raw`` is checked **first**, and rejected rather than ignored: a silently + dropped ``raw=true`` is indistinguishable from an empty raw payload, so the + caller would never learn the flag went nowhere. Checking it before the + reference rules also means the message names the real problem even when the + request is malformed in two ways at once. + + The reference rules mirror ``PinBody._shape``: exactly one of ``chat_id`` / + ``chat_name`` / ``entity``, and ``chat_name`` needs ``folder_name`` (there + is no config-derived folder default on the remote surfaces — that is a CLI + convenience). + """ + if raw: + raise ValueError(RAW_REJECTED_MESSAGE) + refs = sum([chat_id is not None, chat_name is not None, entity is not None]) + if refs != 1: + raise ValueError("provide exactly one of chat_id, chat_name, or entity") + if chat_name is not None and folder_name is None: + raise ValueError("chat_name requires folder_name") + + +def _annotate_retry_after(exc: FloodWaitError) -> FloodWaitError: + """Give an *unpaced* flood-wait the retry fields the surfaces report. + + ``messages pin``/``unpin`` run behind a pacer, so what reaches their + surfaces is a ``PacedFloodWaitError`` already carrying + ``retry_after_seconds``/``retry_at`` — the two fields + :func:`retry_after_details` reads and that both surfaces echo (HTTP also as + the standard ``Retry-After`` header). ``chats inspect`` is a one-shot read + with no pacer, so its adapter surfaces a bare ``FloodWaitError`` whose wait + window lives on ``.seconds`` only. Copying it across lets the *same* + mapping produce the same payload here rather than a second, poorer one — + the caller of a read op needs to know when to come back just as much. + """ + if getattr(exc, "retry_after_seconds", None) is None: + seconds = float(getattr(exc, "seconds", 0.0) or 0.0) + exc.retry_after_seconds = seconds # type: ignore[attr-defined] + exc.retry_at = time.time() + seconds # type: ignore[attr-defined] + return exc + + +async def inspect_chat_for_request( + request: Request, + *, + chat_id: int | None = None, + chat_name: str | None = None, + entity: str | int | None = None, + folder_name: str | None = None, + folder_id: int | None = None, + raw: bool = False, +) -> dict[str, Any]: + """Resolve the chat reference, gate READ, and return the inspect payload. + + Shared by the HTTP route and the MCP tool. It raises rather than mapping, + so each surface applies its own taxonomy: ``ValueError`` for malformed + input, ``HTTPException`` for the 503/404/409 resolution failures, + ``AccessDenied`` for a denied chat, and ``FloodWaitError`` (already carrying + retry-after) for a throttle. + + ``raw`` is only ever *rejected* here; the domain call passes ``raw=False`` + unconditionally, so no remote caller's flag can reach the serializer. + """ + validate_chat_inspect_args( + chat_id=chat_id, + chat_name=chat_name, + entity=entity, + folder_name=folder_name, + raw=raw, + ) + backend = _chat_inspect_backend_or_503(request) + resolved_chat_id = await _resolve_chat_id_generic( + telegram_chat_id=chat_id, + chat_name=chat_name, + entity=entity, + folder_name=folder_name, + folder_id=folder_id, + request=request, + ) + authorizer = build_authorizer( + request, folder_backend=_folder_backend_optional(request) + ) + try: + info = await inspect_chat( + backend=backend, + chat_id=resolved_chat_id, + raw=False, + authorizer=authorizer, + ) + except FloodWaitError as exc: + _annotate_retry_after(exc) + raise + return info.to_dict() + + +def build_router() -> APIRouter: + router = APIRouter(dependencies=[BearerAuth]) + + @router.get("/chats/inspect") + async def chats_inspect( + request: Request, + chat_id: int | None = None, + chat_name: str | None = None, + entity: str | None = None, + folder_name: str | None = None, + folder_id: int | None = None, + raw: bool = False, + ) -> dict[str, Any]: + """Read one chat's metadata: TTL, description, counts, rights (READ-gated). + + Target the chat with exactly one of ``chat_id``, ``entity``, or + ``chat_name`` (which requires ``folder_name``, optionally cross-checked + by ``folder_id``) — the same set the CLI takes. ``raw`` is accepted only + so it can be rejected with 400: the serialized Telethon objects are + CLI-only. The body is the domain payload verbatim. + """ + try: + return await inspect_chat_for_request( + request, + chat_id=chat_id, + chat_name=chat_name, + entity=entity, + folder_name=folder_name, + folder_id=folder_id, + raw=raw, + ) + except AccessDenied as exc: + raise translate_access_error(exc) from exc + except FloodWaitError as exc: + raise _translate_flood_wait(exc) from exc + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + + return router +``` + +- [ ] **Step 4: Wire the factory and the router into `src/telegram_assistant/http_api/app.py`** + +Four edits plus two imports, all mirroring `member_list_backend_factory` line for line. + +**4a — domain import.** Between `from telegram_assistant import __version__` and `from telegram_assistant.config import (`, insert: + +```python +from telegram_assistant.chats import ChatInspectBackend +``` + +**4b — router import.** Between `from telegram_assistant.http_api.auth import BearerAuth` and `from telegram_assistant.http_api.folders import build_router as build_folders_router`, insert: + +```python +from telegram_assistant.http_api.chats import build_router as build_chats_router +``` + +**4c — type alias.** In the factory-alias block, directly *above* `FolderBackendFactory = Callable[[Request], FolderBackend | None]`, insert: + +```python +ChatInspectBackendFactory = Callable[[Request], ChatInspectBackend | None] +``` + +**4d — default factory builder.** Directly after `_default_member_list_backend_factory`'s closing ` return _factory` (the one whose body imports `TelethonMemberListBackend`) and its blank lines, before `def _default_group_backend_factory(`, insert: + +```python +def _default_chat_inspect_backend_factory( + session_manager: TelethonSessionManager | None, +) -> ChatInspectBackendFactory: + """Build a Telethon-backed chat-inspect factory for the read op. + + Mirrors :func:`_default_member_list_backend_factory`: returns ``None`` until + a Telethon client is available so the endpoint can return 503. + """ + + def _factory(_request: Request) -> ChatInspectBackend | None: + if session_manager is None: + return None + client = getattr(session_manager, "_client", None) + if client is None: + return None + from telegram_assistant.chats.telethon_backend import ( + TelethonChatInspectBackend, + ) + + return TelethonChatInspectBackend(client) + + return _factory + + +``` + +**4e — `create_app()` parameter.** Directly after the line ` member_list_backend_factory: MemberListBackendFactory | None = None,` in the `create_app(` signature, insert: + +```python + chat_inspect_backend_factory: ChatInspectBackendFactory | None = None, +``` + +**4f — `app.state` assignment.** Directly after the `app.state.member_list_backend_factory = (...)` block (the one ending `else _default_member_list_backend_factory(session_manager)\n )`), insert: + +```python + app.state.chat_inspect_backend_factory = ( + chat_inspect_backend_factory + if chat_inspect_backend_factory is not None + else _default_chat_inspect_backend_factory(session_manager) + ) +``` + +**4g — router mount.** Directly after ` app.include_router(build_members_router(), prefix="/telegram")`, insert: + +```python + app.include_router(build_chats_router(), prefix="/telegram") +``` + +Do **not** add anything to `_on_swap` — every backend factory is built once in `create_app()` and the hot-reload closure deliberately does not touch them (verified at `app.py:735-764`). + +- [ ] **Step 5: Run the new test file and see it green** + +```bash +.venv/bin/pytest tests/test_chats_inspect_surfaces.py -q +``` + +Expected: 14 passed. + +- [ ] **Step 6: Run the full suite** + +```bash +.venv/bin/pytest -q +``` + +Expected: 2332 passed (2318 + the 14 new), 0 failed. `tests/test_docker_image.py` may skip when Docker is unavailable. + +- [ ] **Step 7: Lint** + +```bash +.venv/bin/ruff check src tests +``` + +Expected: `All checks passed!` + +- [ ] **Step 8: Commit** + +```bash +git add src/telegram_assistant/http_api/chats.py src/telegram_assistant/http_api/app.py tests/test_chats_inspect_surfaces.py +git commit -m "feat(http): serve chats inspect at GET /telegram/chats/inspect" +``` + +--- + +### Task 2: MCP tool `telegram_chats_inspect` + +**Files:** +- Modify: `src/telegram_assistant/http_api/mcp/tools.py` +- Modify: `tests/test_mcp_mount.py` (`EXPECTED_TOOL_NAMES` only) +- Modify: `tests/test_chats_inspect_surfaces.py` (append the MCP section) + +**Interfaces:** + +*Consumes (from Task 1, unchanged):* +- `telegram_assistant.http_api.chats.inspect_chat_for_request(request, *, chat_id=None, chat_name=None, entity=None, folder_name=None, folder_id=None, raw=False) -> dict[str, Any]` +- `create_app(..., chat_inspect_backend_factory=...)` and `app.state.chat_inspect_backend_factory` +- From `tests/test_chats_inspect_surfaces.py`: `FakeInspectBackend`, `FakeResolver`, `FakeFolderBackend`, `_chat_info`, `_folder_backend`, `CHAT_ID` + +*Consumes (already exists, unchanged):* +- `tools.py::_request(provider) -> _McpRequest` — the `app.state` shim whose `.app.state` makes the HTTP helpers work unmodified +- `tools.py::_raise_from_exception(exc) -> NoReturn` — maps `HTTPException` by status, `AccessDenied` → 403 `access_denied`, entity errors → 404/409, `FloodWaitError` → 502 `needs_review` with `retry_after_details(exc)` as `detail`, `ValueError` → 400 `invalid_request` +- `tools.py::READ_TELEGRAM` — the read-op `ToolAnnotations` +- `tests/test_mcp_mount.py`: `FakeGoogleOidcProvider`, `FakeSessionManager`, `_enabled_mcp_yaml`, `_initialize_payload`, `_mcp_headers`, `_mint_token` + +*Produces:* +- MCP tool `telegram_chats_inspect(chat_id=None, chat_name=None, entity=None, folder_name=None, folder_id=None, raw=False) -> dict[str, Any]` +- `"telegram_chats_inspect"` in `EXPECTED_TOOL_NAMES` +- In the test file: `_with_access()`, `_mcp_client()`, `_initialize()`, `_call_tool()` + +--- + +- [ ] **Step 1: Add the failing mount assertion** + +In `tests/test_mcp_mount.py`, add `"telegram_chats_inspect",` as the **first** entry of `EXPECTED_TOOL_NAMES`, above `"telegram_folders_add_chat",` (the set is written alphabetically and `c` < `f`): + +```python +EXPECTED_TOOL_NAMES = { + "telegram_chats_inspect", + "telegram_folders_add_chat", + "telegram_folders_inspect", +``` + +- [ ] **Step 2: Run the mount test and watch it fail** + +```bash +.venv/bin/pytest tests/test_mcp_mount.py -q -k tools_list +``` + +Expected: FAIL — the live `tools/list` response is missing `telegram_chats_inspect`, so the exact-equality assertion in `test_mcp_initialize_and_tools_list_are_reachable_with_token` fails. + +- [ ] **Step 3: Append the MCP functional tests to `tests/test_chats_inspect_surfaces.py`** + +There is no existing MCP functional test for `telegram_members_list` to copy, so these are modelled on `tests/test_mcp_tools.py::test_mcp_recent_messages_reads_via_backend` (the nearest fully-worked MCP READ-op test) with its `_client`/`_initialize`/`_call_tool` scaffolding rebuilt locally — `test_mcp_tools.py::_client` does not accept a chat-inspect factory, and importing that module would drag in its whole fake-backend zoo for one tool. + +First extend the import block at the top of the file. Replace: + +```python +import tempfile +import textwrap +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +``` + +with: + +```python +import json +import tempfile +import textwrap +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest +``` + +and add, after `from telegram_assistant.worker.queue import FloodWaitError`: + +```python +from tests.test_mcp_mount import ( + FakeGoogleOidcProvider, + FakeSessionManager, + _enabled_mcp_yaml, + _initialize_payload, + _list_tools, + _mcp_headers, + _mint_token, +) +``` + +Then append to the end of the file: + +```python +# --- MCP ------------------------------------------------------------------- + + +def _with_access(minimal_config_yaml: str, access_block: str) -> str: + """Splice an `access:` block into the shared minimal config fixture.""" + return minimal_config_yaml.replace( + " defaults:\n", + f" access:\n{access_block} defaults:\n", + 1, + ) + + +_MCP_READ_ACCESS = " rules:\n - all: true\n permission: read\n" +_MCP_WRITE_ACCESS = " rules:\n - all: true\n permission: write\n" + + +def _mcp_client( + config_yaml: str, + tmp_path: Path, + *, + backend: FakeInspectBackend | None = None, + resolver: FakeResolver | None = None, + folder_backend: FakeFolderBackend | None = None, + disabled_tools: tuple[str, ...] = (), +) -> TestClient: + config = load_config_from_text( + _enabled_mcp_yaml(config_yaml, disabled_tools=disabled_tools) + ) + app = create_app( + config, + session_manager=FakeSessionManager(), # type: ignore[arg-type] + mcp_google_provider=FakeGoogleOidcProvider(), + chat_inspect_backend_factory=( + (lambda _r: backend) if backend is not None else (lambda _r: None) + ), + folder_backend_factory=( + (lambda _r: folder_backend) + if folder_backend is not None + else (lambda _r: None) + ), + resolver_factory=lambda _r: resolver, + operation_store=OperationStore(tmp_path / "state.db"), + ) + return TestClient(app) + + +def _initialize(client: TestClient, token: str) -> None: + headers = _mcp_headers(token) + initialize = client.post("/mcp", json=_initialize_payload(), headers=headers) + assert initialize.status_code == 200, initialize.text + initialized = client.post( + "/mcp", + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers=headers, + ) + assert initialized.status_code == 202, initialized.text + + +def _call_tool( + client: TestClient, token: str, name: str, arguments: dict[str, Any] +) -> dict[str, Any]: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, + headers=_mcp_headers(token), + ) + assert response.status_code == 200, response.text + return response.json()["result"] + + +def _error_payload(result: dict[str, Any]) -> dict[str, Any]: + """The JSON error body an MCP tool failure carries.""" + return json.loads(result["content"][0]["text"]) + + +def test_mcp_chats_inspect_reads_via_backend( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"chat_id": CHAT_ID} + ) + + assert result["isError"] is False, result + payload = result["structuredContent"] + assert payload["chat_id"] == CHAT_ID + assert payload["kind"] == "supergroup" + assert payload["ttl_period"] == 86400 + assert payload["topics_layout"] == "tabs" + assert "raw" not in payload + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_mcp_chats_inspect_resolves_entity( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client( + config_yaml, + tmp_path, + backend=backend, + resolver=FakeResolver({"@clientchat": CHAT_ID}), + ) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"entity": "@clientchat"} + ) + + assert result["isError"] is False, result + assert result["structuredContent"]["chat_id"] == CHAT_ID + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_mcp_chats_inspect_resolves_chat_name_in_folder( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client( + config_yaml, tmp_path, backend=backend, folder_backend=_folder_backend() + ) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, + token, + "telegram_chats_inspect", + {"chat_name": "Client chat", "folder_name": "Planfix clients"}, + ) + + assert result["isError"] is False, result + assert result["structuredContent"]["chat_id"] == CHAT_ID + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_mcp_chats_inspect_rejects_raw( + minimal_config_yaml: str, tmp_path: Path +) -> None: + """`raw` is CLI-only — a tool error, never a silently dropped flag.""" + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, + token, + "telegram_chats_inspect", + {"chat_id": CHAT_ID, "raw": True}, + ) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "invalid_request" + assert error["status"] == 400 + assert "raw is CLI-only" in error["message"] + # Nothing reached the domain op, so no raw payload could ever be built. + assert backend.calls == [] + + +def test_mcp_chats_inspect_requires_exactly_one_ref( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool(client, token, "telegram_chats_inspect", {}) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "invalid_request" + assert "exactly one" in error["message"] + assert backend.calls == [] + + +def test_mcp_chats_inspect_denied_without_read( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_WRITE_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"chat_id": CHAT_ID} + ) + + assert result["isError"] is True, result + assert _error_payload(result)["error"] == "access_denied" + assert backend.calls == [] + + +def test_mcp_chats_inspect_backend_unavailable( + minimal_config_yaml: str, tmp_path: Path +) -> None: + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"chat_id": CHAT_ID} + ) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "backend_unavailable" + assert error["status"] == 503 + + +def test_mcp_chats_inspect_maps_flood_wait_to_needs_review( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend(error=FloodWaitError(30.0)) + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"chat_id": CHAT_ID} + ) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "needs_review" + assert error["status"] == 502 + assert error["detail"]["retry_after_seconds"] == 30.0 + + +@pytest.mark.parametrize( + "disabled", + ["telegram_chats_inspect", "telegram_chats_*"], +) +def test_mcp_chats_inspect_can_be_disabled( + minimal_config_yaml: str, tmp_path: Path, disabled: str +) -> None: + """`mcp.disabled_tools` prunes it by exact name and by prefix wildcard.""" + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client( + config_yaml, + tmp_path, + backend=FakeInspectBackend(), + disabled_tools=(disabled,), + ) as client: + token = _mint_token(client) + listed = _list_tools(client, token) + + assert "telegram_chats_inspect" not in set(listed) + # The filter is targeted, not a blanket prune. + assert "telegram_members_list" in set(listed) +``` + +- [ ] **Step 4: Run the MCP tests and watch them fail** + +```bash +.venv/bin/pytest tests/test_chats_inspect_surfaces.py -q -k mcp +``` + +Expected: FAIL — the tool does not exist, so `tools/call` answers with an "Unknown tool: telegram_chats_inspect" error (`isError` is `True` with a `tool_error` payload rather than the asserted code), and `test_mcp_chats_inspect_reads_via_backend` fails on `result["isError"] is False`. + +- [ ] **Step 5: Register the tool in `src/telegram_assistant/http_api/mcp/tools.py`** + +**5a — import.** Directly after the `from telegram_assistant.http_api.access import (...)` block (the one ending with `translate_entity_error,\n)`), and before `from telegram_assistant.http_api.folders import AddChatRequest`, insert: + +```python +from telegram_assistant.http_api.chats import inspect_chat_for_request +``` + +**5b — the tool.** Inside `register_telegram_tools`, directly after the `telegram_members_list` tool's final ` _raise_from_exception(exc)` line and before the ` @server.tool(\n name="telegram_members_add",` decorator, insert: + +```python + @server.tool( + name="telegram_chats_inspect", + annotations=READ_TELEGRAM, + structured_output=True, + ) + async def telegram_chats_inspect( + chat_id: int | None = None, + chat_name: str | None = None, + entity: str | int | None = None, + folder_name: str | None = None, + folder_id: int | None = None, + raw: bool = False, + ) -> dict[str, Any]: + """Read one chat's metadata: TTL, description, counts, rights (READ-gated). + + Answers "what is this chat" for every peer kind with one flat payload — + auto-delete ``ttl_period``, ``about``, member counts, slow mode, + restrictions, our own rights — so a caller can read a field without + branching on whether the target is a supergroup, a channel or a private + chat. Target it with exactly one of ``chat_id``, ``entity``, or + ``chat_name`` (which requires ``folder_name``, optionally cross-checked + by ``folder_id``). It never writes: there is no way to *change* any of + these settings through this tool. + + ``raw`` is accepted only so it can be rejected — the serialized Telethon + objects are CLI-only, and silently dropping the flag would look like an + empty raw payload. + """ + request = _request(provider) + try: + return await inspect_chat_for_request( + request, # type: ignore[arg-type] + chat_id=chat_id, + chat_name=chat_name, + entity=entity, + folder_name=folder_name, + folder_id=folder_id, + raw=raw, + ) + except Exception as exc: + _raise_from_exception(exc) + +``` + +- [ ] **Step 6: Run both affected test files and see them green** + +```bash +.venv/bin/pytest tests/test_chats_inspect_surfaces.py tests/test_mcp_mount.py -q +``` + +Expected: all pass (14 HTTP + 10 MCP tests in the surfaces file, plus the whole mount file). + +- [ ] **Step 7: Run the full suite** + +```bash +.venv/bin/pytest -q +``` + +Expected: 2342 passed (2318 + 14 from Task 1 + 10 here), 0 failed. + +- [ ] **Step 8: Lint** + +```bash +.venv/bin/ruff check src tests +``` + +Expected: `All checks passed!` + +- [ ] **Step 9: Commit** + +```bash +git add src/telegram_assistant/http_api/mcp/tools.py tests/test_mcp_mount.py tests/test_chats_inspect_surfaces.py +git commit -m "feat(mcp): add telegram_chats_inspect tool" +``` + +--- + +### Task 3: Documentation — SKILL.md, README, CLAUDE.md, skill sync + +**Files:** +- Modify: `skills/telegram-assistant/SKILL.md` +- Modify: `README.md` (the HTTP endpoint list **and** the MCP tool catalog) +- Modify: `CLAUDE.md` (the backend-factory enumeration) +- Copy: `~/.claude/skills/telegram-assistant/SKILL.md` + +**Interfaces:** +- Consumes: the HTTP route from Task 1 (`GET /telegram/chats/inspect`, params `chat_id` / `chat_name` + `folder_name`/`folder_id` / `entity`, plus the rejected `raw`) and the MCP tool from Task 2 (`telegram_chats_inspect`, same arguments). +- Produces: no code. Documentation only, plus a still-green `tests/test_skill_inventory.py`. + +**Why `CLAUDE.md` needs a line:** its Architecture paragraph enumerates the backend factories on `app.state.*_backend_factory` and states "When changing how backends are constructed, preserve this contract". A new factory that is not listed there breaks that enumeration. The `## Common commands` CLI line already names `chats inspect` (added in phase 1) and needs no change. + +--- + +- [ ] **Step 1: Add the remote-surfaces bullet to the SKILL's per-pair section** + +In `skills/telegram-assistant/SKILL.md`, inside `#### \`chats\` / \`inspect\``, insert directly **after** the `- \`--raw\`: adds a \`raw\` key ...` bullet (the one ending "its shape moves with the Telegram layer.") and **before** the `- Note it does **not** write anything:` bullet: + +```markdown +- Other surfaces: the same op is served by HTTP `GET /telegram/chats/inspect` + and by the MCP tool `telegram_chats_inspect`, taking the same chat references + (`chat_id` / `chat_name` + `folder_name`/`folder_id` / `entity`) and returning + the same payload. `raw` is **CLI-only** there — both surfaces *reject* + `raw=true` (HTTP `400`, MCP a tool error) rather than ignoring it, so a + serialized dump can only be produced locally. This skill still uses the CLI; + mention the remote surfaces only if the human is asking about them. +``` + +- [ ] **Step 2: Note the mapped flood-wait in the SKILL's error bullet** + +In the same section, replace the `- Typical errors:` bullet with: + +```markdown +- Typical errors: `exactly one of --chat-id, --chat-name, or --entity must be + supplied` (exit 2), `chat cannot be inspected (resolved to ...)` (exit 2 + — the reference resolved to something with no metadata to read), + `chat is private or inaccessible` (exit 2), `chat is forbidden` + (exit 2 — we were removed from it), `access denied ...` (exit 3), entity + not-found / ambiguous (exit 2). A `FLOOD_WAIT` exits 1 on the CLI (one-shot + read, nothing retries it); on HTTP/MCP the same throttle comes back as + `502` / `needs_review` carrying `retry_after_seconds` — wait that long and + try again rather than retrying immediately. +``` + +- [ ] **Step 3: Verify the CLI-catalog guard is still green** + +```bash +.venv/bin/pytest tests/test_skill_inventory.py -q +``` + +Expected: PASS. (The guard compares the Typer command tree against the SKILL catalog; `chats inspect` has been in both since phase 1 and neither changed here.) + +- [ ] **Step 4: Add the HTTP endpoint bullet to `README.md`** + +In the `## HTTP API` bullet list, insert directly after the `- \`GET /telegram/members/list\` ...` bullet: + +```markdown +- `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`, …). `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. +``` + +- [ ] **Step 5: Add the MCP tool-catalog row to `README.md`** + +In the `MCP tool catalog:` table, insert directly after the `| \`telegram_members_list\` | ... |` row (keeping the read ops together; `telegram_folders_inspect`, the other bare-READ inspect op, is two rows below): + +```markdown +| `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` | +``` + +- [ ] **Step 6: List the new factory in `CLAUDE.md`** + +In the Architecture paragraph beginning "This split is what lets tests inject fakes without spinning up Telethon.", extend the parenthesised factory list so it reads: + +``` +(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`) +``` + +i.e. insert `` `chat_inspect_backend_factory`, `` between `` `search_backend_factory`, `` and `` `notification_backend_factory`, ``. + +- [ ] **Step 7: Sync the skill to the user skills directory** + +```bash +cp skills/telegram-assistant/SKILL.md ~/.claude/skills/telegram-assistant/SKILL.md +diff skills/telegram-assistant/SKILL.md ~/.claude/skills/telegram-assistant/SKILL.md && echo "skill in sync" +``` + +Expected: `skill in sync` (no diff output). + +- [ ] **Step 8: Run the full suite** + +```bash +.venv/bin/pytest -q +``` + +Expected: 2342 passed, 0 failed. + +- [ ] **Step 9: Lint** + +```bash +.venv/bin/ruff check src tests +``` + +Expected: `All checks passed!` + +- [ ] **Step 10: Commit** + +```bash +git add skills/telegram-assistant/SKILL.md README.md CLAUDE.md +git commit -m "docs: document the chats inspect HTTP route and MCP tool" +``` + +--- + +## Self-Review + +### Spec coverage + +Every phase-2 requirement in `docs/superpowers/specs/2026-08-05-chats-inspect-design.md` maps to a task: + +| Spec requirement | Task | Where | +| --- | --- | --- | +| HTTP `GET /telegram/chats/inspect` | 1 | Step 3 (`build_router`) + Step 4g (mount) | +| `chat_inspect_backend_factory` on `app.state`, `None` → 503 | 1 | Steps 3 (`_chat_inspect_backend_or_503`), 4c/4d/4e/4f; tested by `test_http_chats_inspect_503_without_backend` | +| MCP tool `telegram_chats_inspect` | 2 | Step 5b | +| `EXPECTED_TOOL_NAMES` in `tests/test_mcp_mount.py` | 2 | Step 1 | +| `tests/test_chats_inspect_surfaces.py` | 1 (HTTP half) + 2 (MCP half) | Task 1 Step 1, Task 2 Step 3 | +| README MCP tool catalog | 3 | Step 5 | +| Decision 1 — same chat references as the CLI, `messages pin` as the model | 1 | `validate_chat_inspect_args` + `_resolve_chat_id_generic`; tested by the entity / chat-name / XOR / `chat_name requires folder_name` tests on both surfaces | +| Decision 2 — `raw` accepted and rejected; domain op not called | 1 + 2 | `validate_chat_inspect_args` checks `raw` first; `test_http_chats_inspect_rejects_raw` and `test_mcp_chats_inspect_rejects_raw` both assert `backend.calls == []` | +| Decision 2b — surfaces call the domain op with `raw=False` | 1 | `inspect_chat_for_request` hard-codes `raw=False`; every happy-path test asserts the recorded call is `{"chat_id": …, "raw": False}` | +| Decision 3 — `FloodWaitError` → HTTP 502 + `Retry-After` + `retry_after_seconds`, MCP `needs_review` with the same field, reusing the pin/unpin mapping | 1 + 2 | `_translate_flood_wait` reused, `_annotate_retry_after` feeds it; `test_http_chats_inspect_maps_flood_wait_to_retry_after` and `test_mcp_chats_inspect_maps_flood_wait_to_needs_review` | +| Decision 4 — payload is `ChatInfo.to_dict()`, nothing else | 1 | `inspect_chat_for_request` returns `info.to_dict()`; `test_http_chats_inspect_returns_the_domain_payload` asserts `"telegram_chat_id" not in body` | +| SKILL.md + README + skill re-sync (`CLAUDE.md` checked) | 3 | Steps 1, 2, 4, 5, 6, 7 | +| `_on_swap` question answered from the real file | — | "Verified facts" section: `app.py:735-764` touches no factory ⇒ no entry, stated as a "do not add" instruction in Task 1 Step 4 | + +No gaps found. + +### Placeholder scan + +Searched this plan for `TBD`, `add appropriate`, `handle edge cases`, `write tests for the above`, `similar to Task N`, `etc.` in place of code, and `...` standing in for an unwritten body. No hits: every file is given in full (`http_api/chats.py` complete, both test halves complete, every `app.py` / `tools.py` / doc edit quoted with an explicit anchor). The only ellipses in the plan are inside prose descriptions of README payload field lists, where they are part of the documentation text being written, not a placeholder for the implementer. + +### Type consistency + +- The factory is `chat_inspect_backend_factory` (snake) / `ChatInspectBackendFactory` (alias) in Task 1's `app.py` edits, in Task 1's `_http_client`, and in Task 2's `_mcp_client` — one spelling everywhere. +- `inspect_chat_for_request(request, *, chat_id, chat_name, entity, folder_name, folder_id, raw)` is defined once in Task 1 and called with exactly those keywords from the HTTP route (Task 1) and the MCP tool (Task 2). +- `entity` is `str | int | None` in `inspect_chat_for_request`, `validate_chat_inspect_args` and the MCP tool (matching `_resolve_chat_id_generic`), and narrowed to `str | None` only on the FastAPI query signature, where a query value is always a string. That widening is safe in one direction and is the same split `messages pin` uses (`PinBody.entity: str | int | None`, resolved from a JSON body). +- `FakeInspectBackend.inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo` matches the `ChatInspectBackend` protocol in `chats/service.py:145` exactly (both keyword-only, both required, no defaults on the protocol). +- `FakeInspectBackend`, `FakeResolver`, `FakeFolderBackend`, `_chat_info`, `_folder_backend` and `CHAT_ID` are defined in Task 1's test file and reused unchanged by Task 2's appended section — Task 2 adds only `_with_access`, `_mcp_client`, `_initialize`, `_call_tool`, `_error_payload` and the two `_MCP_*_ACCESS` constants. +- `RAW_REJECTED_MESSAGE` starts with `raw is CLI-only:`, which is the substring both raw-rejection tests assert on. diff --git a/docs/superpowers/plans/2026-08-05-chats-inspect.md b/docs/superpowers/plans/2026-08-05-chats-inspect.md new file mode 100644 index 0000000..2f36b8a --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-chats-inspect.md @@ -0,0 +1,1870 @@ +# `chats inspect` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a read-only `telegram-assistant chats inspect` CLI command that reports what Telegram knows about one chat — auto-delete TTL, description, member counts, restrictions, our own rights — for supergroups, channels, legacy groups, users and bots. + +**Architecture:** A new `chats/` domain package shaped like `members/listing.py`: a pure `service.py` (dataclass + protocol + `inspect_chat()` with the READ gate) and a `telethon_backend.py` adapter that issues `get_input_entity` plus one `GetFull*` request and maps the pair into one flat payload. The CLI wires them exactly like `members list`. + +**Tech Stack:** Python 3.12, Telethon >= 1.44, Typer (CLI), pytest + pytest-asyncio (asyncio mode auto), ruff. + +**Spec:** `docs/superpowers/specs/2026-08-05-chats-inspect-design.md` + +## Global Constraints + +- Use `.venv` — run everything as `.venv/bin/pytest`, `.venv/bin/ruff`, `.venv/bin/telegram-assistant`. +- `ruff check src tests` must pass: line-length 100, target py312, `E501` ignored. +- `chats/service.py` must not import `telethon` at all. `chats/telethon_backend.py` imports Telethon **inside** functions only (the pattern in `members/telethon_backend.py`), so the package imports cleanly without a session. +- The op opens **no** operation row, has **no** idempotency key and **no** `--dry-run`. It is READ-gated. +- `chat_id` in the payload is the **bare** id (no `-100` marker), matching `EntityRef.numeric_id`. +- `access_hash` must never appear in any payload, including `--raw`. +- Phase 1 is CLI-only. Do **not** add HTTP routes, MCP tools, or backend factories in this plan. +- Exit codes: caller-input and resolution failures → 2, `AccessDenied` → 3, anything else → 1. + +--- + +### Task 1: `chats/` domain — `ChatInfo`, backend protocol, `inspect_chat()` + +**Files:** +- Create: `src/telegram_assistant/chats/__init__.py` +- Create: `src/telegram_assistant/chats/service.py` +- Test: `tests/test_chats_inspect.py` + +**Interfaces:** +- Consumes: `telegram_assistant.access.service.AccessLevel`, `Authorizer` (already exist; `await authorizer.require(chat_id, AccessLevel.READ)` raises `AccessDenied`). +- Produces: + - `ChatInfo` — frozen dataclass, all fields keyword-constructible, `to_dict() -> dict[str, Any]`. + - `ChatInspectBackend` — Protocol with `async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo`. + - `async def inspect_chat(*, backend: ChatInspectBackend, chat_id: int, raw: bool = False, authorizer: Authorizer | None = None) -> ChatInfo`. + - `CHAT_KINDS: frozenset[str]` = `{"user", "bot", "basic_group", "supergroup", "channel"}`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_chats_inspect.py`: + +```python +"""Tests for the read-only chat-inspect domain op.""" + +from __future__ import annotations + +import pytest + +from telegram_assistant.access import AccessDenied, Authorizer +from telegram_assistant.chats import ChatInfo, inspect_chat +from telegram_assistant.config.models import AccessConfig, AccessRule + + +class FakeBackend: + """Records calls and returns a canned ChatInfo.""" + + def __init__(self, info: ChatInfo | None = None) -> None: + self.info = info or ChatInfo(chat_id=42, kind="supergroup", title="T") + self.calls: list[dict[str, object]] = [] + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + self.calls.append({"chat_id": chat_id, "raw": raw}) + return self.info + + +@pytest.mark.asyncio +async def test_inspect_chat_returns_backend_result() -> None: + backend = FakeBackend() + + result = await inspect_chat(backend=backend, chat_id=42) + + assert result is backend.info + assert backend.calls == [{"chat_id": 42, "raw": False}] + + +@pytest.mark.asyncio +async def test_inspect_chat_passes_raw_through() -> None: + backend = FakeBackend() + + await inspect_chat(backend=backend, chat_id=42, raw=True) + + assert backend.calls == [{"chat_id": 42, "raw": True}] + + +@pytest.mark.asyncio +async def test_read_gate_denies_before_any_rpc() -> None: + backend = FakeBackend() + authorizer = Authorizer(AccessConfig(rules=[])) + + with pytest.raises(AccessDenied): + await inspect_chat(backend=backend, chat_id=42, authorizer=authorizer) + + assert backend.calls == [] + + +@pytest.mark.asyncio +async def test_read_gate_allows_granted_chat() -> None: + backend = FakeBackend() + authorizer = Authorizer( + AccessConfig(rules=[AccessRule(all=True, permissions=["read"])]) + ) + + result = await inspect_chat(backend=backend, chat_id=42, authorizer=authorizer) + + assert result.chat_id == 42 + assert backend.calls == [{"chat_id": 42, "raw": False}] + + +@pytest.mark.asyncio +async def test_write_only_grant_does_not_satisfy_read() -> None: + backend = FakeBackend() + authorizer = Authorizer( + AccessConfig(rules=[AccessRule(all=True, permissions=["write"])]) + ) + + with pytest.raises(AccessDenied): + await inspect_chat(backend=backend, chat_id=42, authorizer=authorizer) + + assert backend.calls == [] + + +def test_to_dict_omits_raw_when_absent() -> None: + info = ChatInfo(chat_id=7, kind="user", title="Someone") + + payload = info.to_dict() + + assert payload["chat_id"] == 7 + assert payload["kind"] == "user" + assert "raw" not in payload + # Fields that do not apply to a user are present and null, so the shape + # never depends on what was inspected. + assert payload["admins_count"] is None + assert payload["ttl_period"] is None + + +def test_to_dict_includes_raw_when_present() -> None: + info = ChatInfo( + chat_id=7, kind="supergroup", title="T", raw={"entity": {}, "full": {}} + ) + + payload = info.to_dict() + + assert payload["raw"] == {"entity": {}, "full": {}} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/test_chats_inspect.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'telegram_assistant.chats'` + +- [ ] **Step 3: Write `src/telegram_assistant/chats/service.py`** + +```python +"""Read-only chat metadata — the inspect op of the chats domain. + +A READ op in the shape of :mod:`telegram_assistant.members.listing`: no +operation row, no idempotency key, no ``--dry-run``. It answers "what is this +chat" for every peer kind with one flat payload, so a caller can read +``ttl_period`` without knowing whether the target is a supergroup or a private +chat. + +The payload is a *curated* set rather than a dump of Telethon's ``*Full`` +objects: those carry 60+ fields that move with the Telegram layer, and pinning +tests to them would turn a Telethon upgrade into a test failure. ``raw`` is the +escape hatch for everything left out. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Any, Protocol + +from telegram_assistant.access.service import AccessLevel, Authorizer + +#: Peer kinds ``ChatInfo.kind`` may report. +CHAT_KINDS: frozenset[str] = frozenset( + {"user", "bot", "basic_group", "supergroup", "channel"} +) + + +@dataclass(frozen=True) +class ChatInfo: + """One chat's metadata, flat and kind-agnostic. + + Every field exists for every kind; the ones Telegram does not answer for a + given peer are ``None`` (or ``False`` for flags). That is deliberate — a + caller running ``jq .ttl_period`` must not have to branch on ``kind``. + """ + + # --- identity (all kinds) --- + chat_id: int + kind: str + title: str | None = None + username: str | None = None + usernames: tuple[str, ...] = () + about: str | None = None + created_at: datetime | None = None + + # --- the reason this op exists, plus the settings next to it --- + ttl_period: int | None = None + pinned_message_id: int | None = None + archived: bool = False + muted: bool = False + muted_until: datetime | None = None + has_scheduled: bool = False + + # --- trust / restrictions (all kinds) --- + restricted: bool = False + restriction_reason: tuple[dict[str, Any], ...] = () + verified: bool = False + scam: bool = False + fake: bool = False + + # --- our standing (all kinds) --- + is_creator: bool = False + left: bool = False + invite_link: str | None = None + my_admin_rights: dict[str, Any] | None = None + default_banned_rights: dict[str, Any] | None = None + + # --- groups and channels --- + is_forum: bool = False + topics_layout: str | None = None + broadcast: bool = False + megagroup: bool = False + gigagroup: bool = False + participants_count: int | None = None + admins_count: int | None = None + kicked_count: int | None = None + banned_count: int | None = None + online_count: int | None = None + slowmode_seconds: int | None = None + slowmode_next_send_date: datetime | None = None + linked_chat_id: int | None = None + migrated_from_chat_id: int | None = None + migrated_to_chat_id: int | None = None + deactivated: bool = False + hidden_prehistory: bool = False + participants_hidden: bool = False + antispam: bool = False + can_view_participants: bool = False + can_view_stats: bool = False + can_delete_channel: bool = False + can_set_username: bool = False + join_to_send: bool = False + join_request: bool = False + requests_pending: int | None = None + noforwards: bool = False + unread_count: int | None = None + available_reactions: Any = None + reactions_limit: int | None = None + call_active: bool = False + + # --- users and bots --- + first_name: str | None = None + last_name: str | None = None + phone: str | None = None + is_bot: bool = False + is_deleted: bool = False + is_premium: bool = False + is_contact: bool = False + is_mutual_contact: bool = False + blocked: bool = False + common_chats_count: int | None = None + birthday: dict[str, Any] | None = None + personal_channel_id: int | None = None + last_seen_status: str | None = None + + # --- escape hatch --- + raw: dict[str, Any] | None = field(default=None) + + def to_dict(self) -> dict[str, Any]: + """The payload body. ``raw`` appears only when it was requested.""" + payload = asdict(self) + payload["usernames"] = list(self.usernames) + payload["restriction_reason"] = list(self.restriction_reason) + if self.raw is None: + payload.pop("raw") + return payload + + +class ChatInspectBackend(Protocol): + """Telethon-facing surface needed to read one chat's metadata. + + Production wires this to + :class:`telegram_assistant.chats.telethon_backend.TelethonChatInspectBackend`; + tests inject a fake. + """ + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + ... + + +async def inspect_chat( + *, + backend: ChatInspectBackend, + chat_id: int, + raw: bool = False, + authorizer: Authorizer | None = None, +) -> ChatInfo: + """Read ``chat_id``'s metadata. + + A READ op: when an ``authorizer`` is supplied it must grant READ on the + chat, checked before any Telegram call. The payload carries the + description, the member counts and the invite link, so a denied caller must + cost no round trip and learn nothing about the chat. + """ + if authorizer is not None: + await authorizer.require(chat_id, AccessLevel.READ) + + return await backend.inspect_chat(chat_id=chat_id, raw=raw) + + +__all__ = [ + "CHAT_KINDS", + "ChatInfo", + "ChatInspectBackend", + "inspect_chat", +] +``` + +- [ ] **Step 4: Write `src/telegram_assistant/chats/__init__.py`** + +```python +"""Chat-wide read operations (metadata inspection).""" + +from telegram_assistant.chats.service import ( + CHAT_KINDS, + ChatInfo, + ChatInspectBackend, + inspect_chat, +) + +__all__ = [ + "CHAT_KINDS", + "ChatInfo", + "ChatInspectBackend", + "inspect_chat", +] +``` + +- [ ] **Step 5: Run the tests** + +Run: `.venv/bin/pytest tests/test_chats_inspect.py -v` +Expected: PASS (7 tests) + +`AccessConfig` / `AccessRule` live in `telegram_assistant.config.models` (verified), while `Authorizer` / `AccessLevel` / `AccessDenied` are re-exported from `telegram_assistant.access` — the same import split `tests/test_members_list.py` uses. + +- [ ] **Step 6: Lint** + +Run: `.venv/bin/ruff check src/telegram_assistant/chats tests/test_chats_inspect.py` +Expected: `All checks passed!` + +- [ ] **Step 7: Commit** + +```bash +git add src/telegram_assistant/chats tests/test_chats_inspect.py +git commit -m "feat(chats): add read-only chat-inspect domain op" +``` + +--- + +### Task 2: Telethon adapter — map three peer kinds into `ChatInfo` + +**Files:** +- Create: `src/telegram_assistant/chats/telethon_backend.py` +- Modify: `src/telegram_assistant/chats/__init__.py` (no new export — the adapter is imported by path, like `members.telethon_backend`) +- Test: `tests/test_chats_inspect_backend.py` + +**Interfaces:** +- Consumes: `ChatInfo` from Task 1. +- Produces: `TelethonChatInspectBackend(client)` implementing `ChatInspectBackend`. + +**Wire facts this task depends on** (do not re-derive): +- `channels.GetFullChannel` and `messages.GetFullChat` both answer with a `messages.ChatFull` carrying `.full_chat`, `.chats` and `.users`. `users.GetFullUser` answers with `.full_user`, `.chats` and `.users`. +- `forum_tabs` is a flag on the **`Channel`** constructor (flags2.19), not on `ChannelFull` — `groups/telethon_backend.py::get_topics_layout` already reads it out of the response's own `.chats`, matched by `full_chat.id`. Do the same; do not issue a second `get_entity`. +- `get_input_entity(chat_id)` returns `InputPeerChannel` / `InputPeerChat` / `InputPeerUser`, which is enough to dispatch. The adapter never calls `get_entity`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_chats_inspect_backend.py`: + +```python +"""Tests for the Telethon chat-inspect adapter. + +Exercised against a fake client: the stand-in classes' *names* are what the +peer dispatch keys on, mirroring tests/test_members_list_backend.py. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from telegram_assistant.chats.telethon_backend import TelethonChatInspectBackend + +# --- fake telethon-shaped objects ----------------------------------------- + + +class InputPeerChannel: + def __init__(self, channel_id: int) -> None: + self.channel_id = channel_id + + +class InputPeerChat: + def __init__(self, chat_id: int) -> None: + self.chat_id = chat_id + + +class InputPeerUser: + def __init__(self, user_id: int) -> None: + self.user_id = user_id + + +class Rights: + """Stand-in for ChatAdminRights / ChatBannedRights.""" + + def __init__(self, **flags) -> None: + self._flags = flags + + def to_dict(self) -> dict: + return {"_": "ChatAdminRights", **self._flags} + + +class NotifySettings: + def __init__(self, mute_until=None, silent=False) -> None: + self.mute_until = mute_until + self.silent = silent + + +class InviteExported: + def __init__(self, link: str) -> None: + self.link = link + + +class RestrictionReason: + def __init__(self, platform: str, reason: str, text: str) -> None: + self.platform = platform + self.reason = reason + self.text = text + + +class Username: + def __init__(self, username: str, active: bool = True) -> None: + self.username = username + self.active = active + + +class ReactionEmoji: + def __init__(self, emoticon: str) -> None: + self.emoticon = emoticon + + +class ChatReactionsSome: + def __init__(self, reactions) -> None: + self.reactions = reactions + + +class ChatReactionsAll: + pass + + +class Channel: + def __init__(self, cid: int, **kw) -> None: + self.id = cid + self.title = kw.get("title", "Chat") + self.username = kw.get("username") + self.usernames = kw.get("usernames") + self.date = kw.get("date") + self.creator = kw.get("creator", False) + self.left = kw.get("left", False) + self.broadcast = kw.get("broadcast", False) + self.megagroup = kw.get("megagroup", True) + self.gigagroup = kw.get("gigagroup", False) + self.forum = kw.get("forum", False) + self.forum_tabs = kw.get("forum_tabs", False) + self.verified = kw.get("verified", False) + self.scam = kw.get("scam", False) + self.fake = kw.get("fake", False) + self.restricted = kw.get("restricted", False) + self.restriction_reason = kw.get("restriction_reason") + self.noforwards = kw.get("noforwards", False) + self.join_to_send = kw.get("join_to_send", False) + self.join_request = kw.get("join_request", False) + self.call_active = kw.get("call_active", False) + self.admin_rights = kw.get("admin_rights") + self.default_banned_rights = kw.get("default_banned_rights") + self.access_hash = 999999 + + +class ChannelFull: + def __init__(self, cid: int, **kw) -> None: + self.id = cid + self.about = kw.get("about", "") + self.ttl_period = kw.get("ttl_period") + self.pinned_msg_id = kw.get("pinned_msg_id") + self.folder_id = kw.get("folder_id") + self.notify_settings = kw.get("notify_settings", NotifySettings()) + self.has_scheduled = kw.get("has_scheduled", False) + self.participants_count = kw.get("participants_count") + self.admins_count = kw.get("admins_count") + self.kicked_count = kw.get("kicked_count") + self.banned_count = kw.get("banned_count") + self.online_count = kw.get("online_count") + self.slowmode_seconds = kw.get("slowmode_seconds") + self.slowmode_next_send_date = kw.get("slowmode_next_send_date") + self.linked_chat_id = kw.get("linked_chat_id") + self.migrated_from_chat_id = kw.get("migrated_from_chat_id") + self.hidden_prehistory = kw.get("hidden_prehistory", False) + self.participants_hidden = kw.get("participants_hidden", False) + self.antispam = kw.get("antispam", False) + self.can_view_participants = kw.get("can_view_participants", False) + self.can_view_stats = kw.get("can_view_stats", False) + self.can_delete_channel = kw.get("can_delete_channel", False) + self.can_set_username = kw.get("can_set_username", False) + self.requests_pending = kw.get("requests_pending") + self.unread_count = kw.get("unread_count") + self.available_reactions = kw.get("available_reactions") + self.reactions_limit = kw.get("reactions_limit") + self.exported_invite = kw.get("exported_invite") + + def to_dict(self) -> dict: + return {"_": "ChannelFull", "id": self.id, "about": self.about} + + +class Chat: + def __init__(self, cid: int, **kw) -> None: + self.id = cid + self.title = kw.get("title", "Legacy") + self.date = kw.get("date") + self.creator = kw.get("creator", False) + self.left = kw.get("left", False) + self.deactivated = kw.get("deactivated", False) + self.noforwards = kw.get("noforwards", False) + self.call_active = kw.get("call_active", False) + self.participants_count = kw.get("participants_count") + self.migrated_to = kw.get("migrated_to") + self.admin_rights = kw.get("admin_rights") + self.default_banned_rights = kw.get("default_banned_rights") + + +class ChatFull: + def __init__(self, cid: int, **kw) -> None: + self.id = cid + self.about = kw.get("about", "") + self.ttl_period = kw.get("ttl_period") + self.pinned_msg_id = kw.get("pinned_msg_id") + self.folder_id = kw.get("folder_id") + self.notify_settings = kw.get("notify_settings", NotifySettings()) + self.has_scheduled = kw.get("has_scheduled", False) + self.can_set_username = kw.get("can_set_username", False) + self.requests_pending = kw.get("requests_pending") + self.available_reactions = kw.get("available_reactions") + self.reactions_limit = kw.get("reactions_limit") + self.exported_invite = kw.get("exported_invite") + + def to_dict(self) -> dict: + return {"_": "ChatFull", "id": self.id} + + +class InputPeerChannelMigrated: + def __init__(self, channel_id: int) -> None: + self.channel_id = channel_id + + +class UserStatusRecently: + pass + + +class User: + def __init__(self, uid: int, **kw) -> None: + self.id = uid + self.first_name = kw.get("first_name", "First") + self.last_name = kw.get("last_name") + self.username = kw.get("username") + self.usernames = kw.get("usernames") + self.phone = kw.get("phone") + self.bot = kw.get("bot", False) + self.deleted = kw.get("deleted", False) + self.premium = kw.get("premium", False) + self.contact = kw.get("contact", False) + self.mutual_contact = kw.get("mutual_contact", False) + self.verified = kw.get("verified", False) + self.scam = kw.get("scam", False) + self.fake = kw.get("fake", False) + self.restricted = kw.get("restricted", False) + self.restriction_reason = kw.get("restriction_reason") + self.status = kw.get("status") + self.access_hash = 777777 + + +class Birthday: + def __init__(self, day: int, month: int, year=None) -> None: + self.day = day + self.month = month + self.year = year + + +class UserFull: + def __init__(self, uid: int, **kw) -> None: + self.id = uid + self.about = kw.get("about") + self.ttl_period = kw.get("ttl_period") + self.pinned_msg_id = kw.get("pinned_msg_id") + self.folder_id = kw.get("folder_id") + self.notify_settings = kw.get("notify_settings", NotifySettings()) + self.has_scheduled = kw.get("has_scheduled", False) + self.blocked = kw.get("blocked", False) + self.common_chats_count = kw.get("common_chats_count") + self.birthday = kw.get("birthday") + self.personal_channel_id = kw.get("personal_channel_id") + + def to_dict(self) -> dict: + return {"_": "UserFull", "id": self.id} + + +class FullChannelResult: + def __init__(self, full_chat, chats) -> None: + self.full_chat = full_chat + self.chats = chats + self.users = [] + + +class FullUserResult: + def __init__(self, full_user, users) -> None: + self.full_user = full_user + self.users = users + self.chats = [] + + +class FakeClient: + def __init__(self, *, peer, result) -> None: + self._peer = peer + self._result = result + self.requests: list[object] = [] + + async def get_input_entity(self, ref): + return self._peer + + async def __call__(self, request): + self.requests.append(request) + return self._result + + +# --- supergroup ------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_supergroup_mapping() -> None: + created = datetime(2024, 3, 1, tzinfo=timezone.utc) + channel = Channel( + 5, + title="Team", + username="teamchat", + usernames=[Username("alt"), Username("dead", active=False)], + date=created, + forum=True, + forum_tabs=True, + creator=True, + noforwards=True, + admin_rights=Rights(delete_messages=True), + default_banned_rights=Rights(send_media=True), + ) + full = ChannelFull( + 5, + about="About us", + ttl_period=86400, + pinned_msg_id=41, + folder_id=1, + participants_count=12, + admins_count=2, + kicked_count=0, + banned_count=1, + online_count=3, + slowmode_seconds=30, + available_reactions=ChatReactionsSome([ReactionEmoji("👍")]), + exported_invite=InviteExported("https://t.me/+abc"), + notify_settings=NotifySettings(mute_until=created, silent=True), + ) + client = FakeClient( + peer=InputPeerChannel(5), result=FullChannelResult(full, [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=-1000000000005, raw=False) + + assert info.chat_id == 5 + assert info.kind == "supergroup" + assert info.title == "Team" + assert info.username == "teamchat" + assert info.usernames == ("alt",) + assert info.about == "About us" + assert info.ttl_period == 86400 + assert info.pinned_message_id == 41 + assert info.archived is True + assert info.muted is True + assert info.muted_until == created + assert info.created_at == created + assert info.is_forum is True + assert info.topics_layout == "tabs" + assert info.participants_count == 12 + assert info.admins_count == 2 + assert info.banned_count == 1 + assert info.online_count == 3 + assert info.slowmode_seconds == 30 + assert info.noforwards is True + assert info.is_creator is True + assert info.invite_link == "https://t.me/+abc" + assert info.available_reactions == ["👍"] + assert info.my_admin_rights == {"delete_messages": True} + assert info.default_banned_rights == {"send_media": True} + assert info.raw is None + + +@pytest.mark.asyncio +async def test_broadcast_channel_kind_and_layout_default() -> None: + channel = Channel(6, broadcast=True, megagroup=False) + client = FakeClient( + peer=InputPeerChannel(6), result=FullChannelResult(ChannelFull(6), [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=6, raw=False) + + assert info.kind == "channel" + assert info.broadcast is True + assert info.is_forum is False + assert info.topics_layout is None + + +@pytest.mark.asyncio +async def test_reactions_all_maps_to_all() -> None: + channel = Channel(6) + full = ChannelFull(6, available_reactions=ChatReactionsAll()) + client = FakeClient(peer=InputPeerChannel(6), result=FullChannelResult(full, [channel])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=6, raw=False) + + assert info.available_reactions == "all" + + +@pytest.mark.asyncio +async def test_restriction_reason_is_mapped() -> None: + channel = Channel( + 8, + restricted=True, + restriction_reason=[RestrictionReason("all", "terms", "violated ToS")], + ) + client = FakeClient( + peer=InputPeerChannel(8), result=FullChannelResult(ChannelFull(8), [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=8, raw=False) + + assert info.restricted is True + assert info.restriction_reason == ( + {"platform": "all", "reason": "terms", "text": "violated ToS"}, + ) + + +# --- legacy basic group ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_basic_group_mapping() -> None: + chat = Chat( + 9, + title="Old", + participants_count=4, + deactivated=True, + migrated_to=InputPeerChannelMigrated(500), + ) + full = ChatFull(9, about="legacy", ttl_period=60) + client = FakeClient(peer=InputPeerChat(9), result=FullChannelResult(full, [chat])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=9, raw=False) + + assert info.kind == "basic_group" + assert info.title == "Old" + assert info.participants_count == 4 + assert info.deactivated is True + assert info.migrated_to_chat_id == 500 + assert info.ttl_period == 60 + assert info.is_forum is False + assert info.admins_count is None + + +# --- users and bots --------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_user_mapping() -> None: + user = User( + 11, + first_name="Ann", + last_name="Lee", + username="annlee", + phone="79990000000", + premium=True, + contact=True, + status=UserStatusRecently(), + ) + full = UserFull( + 11, + about="bio", + ttl_period=604800, + blocked=True, + common_chats_count=3, + birthday=Birthday(4, 7, 1990), + ) + client = FakeClient(peer=InputPeerUser(11), result=FullUserResult(full, [user])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=11, raw=False) + + assert info.kind == "user" + assert info.title == "Ann Lee" + assert info.first_name == "Ann" + assert info.last_name == "Lee" + assert info.phone == "79990000000" + assert info.is_premium is True + assert info.is_contact is True + assert info.blocked is True + assert info.common_chats_count == 3 + assert info.birthday == {"day": 4, "month": 7, "year": 1990} + assert info.ttl_period == 604800 + assert info.last_seen_status == "UserStatusRecently" + + +@pytest.mark.asyncio +async def test_bot_kind() -> None: + user = User(12, first_name="Helper", bot=True) + client = FakeClient(peer=InputPeerUser(12), result=FullUserResult(UserFull(12), [user])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=12, raw=False) + + assert info.kind == "bot" + assert info.is_bot is True + + +# --- raw -------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_raw_carries_both_halves_without_access_hash() -> None: + channel = Channel(5, title="Team") + client = FakeClient( + peer=InputPeerChannel(5), result=FullChannelResult(ChannelFull(5), [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=5, raw=True) + + assert set(info.raw) == {"entity", "full"} + assert info.raw["full"]["_"] == "ChannelFull" + assert info.raw["entity"]["title"] == "Team" + assert "access_hash" not in info.raw["entity"] + + +@pytest.mark.asyncio +async def test_user_raw_strips_access_hash() -> None: + user = User(11, first_name="Ann") + client = FakeClient(peer=InputPeerUser(11), result=FullUserResult(UserFull(11), [user])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=11, raw=True) + + assert "access_hash" not in info.raw["entity"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/test_chats_inspect_backend.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'telegram_assistant.chats.telethon_backend'` + +- [ ] **Step 3: Write `src/telegram_assistant/chats/telethon_backend.py`** + +```python +"""Telethon adapter for the chat-inspect op. + +Two RPCs at most: ``get_input_entity`` to learn the peer kind, then one +``GetFull*`` request. The shallow half of the answer (``forum_tabs``, +``restriction_reason``, the rights defaults) is read out of that response's own +``chats``/``users`` list rather than a second ``get_entity`` — ``forum_tabs`` +is a flag on ``Channel``, not on ``ChannelFull``, which is why +``groups/telethon_backend.py::get_topics_layout`` already resolves it that way. +""" + +from __future__ import annotations + +from typing import Any + +from telegram_assistant.chats.service import ChatInfo +from telegram_assistant.telegram_client.errors import translate_flood_wait + +#: Never leaves the process: a peer credential, not metadata. +_REDACTED_RAW_KEYS = frozenset({"access_hash"}) + + +def _bare_id(chat_id: int) -> int: + """Strip the ``-100`` supergroup marker, matching ``EntityRef.numeric_id``.""" + text = str(chat_id) + if text.startswith("-100"): + return int(text[4:]) + return abs(int(chat_id)) + + +def _rights(raw: Any) -> dict[str, Any] | None: + """Flag dict for ChatAdminRights / ChatBannedRights, minus the type tag.""" + if raw is None: + return None + to_dict = getattr(raw, "to_dict", None) + if to_dict is None: + return None + return {k: v for k, v in to_dict().items() if k != "_"} + + +def _usernames(raw: Any) -> tuple[str, ...]: + """Active alternative usernames only — an inactive one is not reachable.""" + return tuple( + str(u.username) + for u in (raw or ()) + if getattr(u, "username", None) and getattr(u, "active", True) + ) + + +def _restriction_reasons(raw: Any) -> tuple[dict[str, Any], ...]: + return tuple( + { + "platform": getattr(r, "platform", None), + "reason": getattr(r, "reason", None), + "text": getattr(r, "text", None), + } + for r in (raw or ()) + ) + + +def _reactions(raw: Any) -> Any: + """``"all"``, ``"none"``, or the list of allowed emoticons.""" + if raw is None: + return None + name = type(raw).__name__ + if name == "ChatReactionsAll": + return "all" + if name == "ChatReactionsNone": + return "none" + return [ + getattr(r, "emoticon", None) or getattr(r, "document_id", None) + for r in (getattr(raw, "reactions", ()) or ()) + ] + + +def _birthday(raw: Any) -> dict[str, Any] | None: + if raw is None: + return None + return { + "day": getattr(raw, "day", None), + "month": getattr(raw, "month", None), + "year": getattr(raw, "year", None), + } + + +def _peer_id(raw: Any) -> int | None: + """Read the numeric id off any InputPeer/Peer shape.""" + for attr in ("channel_id", "chat_id", "user_id"): + value = getattr(raw, attr, None) + if value is not None: + return int(value) + return None + + +def _serialize(raw: Any) -> dict[str, Any] | None: + if raw is None: + return None + to_dict = getattr(raw, "to_dict", None) + if to_dict is not None: + payload = to_dict() + else: + payload = { + k: v for k, v in vars(raw).items() if not k.startswith("_") + } + return {k: v for k, v in payload.items() if k not in _REDACTED_RAW_KEYS} + + +def _shallow_for(result: Any, full: Any, bucket: str) -> Any: + """The shallow object matching ``full.id`` in ``result.``. + + Telegram returns the peer alongside its Full object; match by id and fall + back to the only entry when it returned just one. + """ + items = list(getattr(result, bucket, None) or []) + target_id = int(getattr(full, "id", 0) or 0) + match = next((i for i in items if int(getattr(i, "id", 0) or 0) == target_id), None) + if match is None and items: + return items[0] + return match + + +class TelethonChatInspectBackend: + """Adapter from the Telethon ``TelegramClient`` to ``ChatInspectBackend``.""" + + def __init__(self, client: Any) -> None: + self._client = client + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + try: + peer = await self._client.get_input_entity(chat_id) + except Exception as exc: + raise translate_flood_wait(exc) from exc + + kind = type(peer).__name__ + if kind == "InputPeerChannel": + return await self._inspect_channel(peer, raw=raw) + if kind == "InputPeerChat": + return await self._inspect_basic_group(peer, raw=raw) + if kind in {"InputPeerUser", "InputPeerSelf"}: + return await self._inspect_user(peer, raw=raw) + raise ValueError(f"chat {chat_id} cannot be inspected (resolved to {kind})") + + # --- per-kind branches -------------------------------------------------- + + async def _inspect_channel(self, peer: Any, *, raw: bool) -> ChatInfo: + from telethon.tl import functions + + try: + result = await self._client( + functions.channels.GetFullChannelRequest(channel=peer) + ) + except Exception as exc: + raise translate_flood_wait(exc) from exc + + full = getattr(result, "full_chat", None) + entity = _shallow_for(result, full, "chats") + broadcast = bool(getattr(entity, "broadcast", False)) + + return ChatInfo( + chat_id=int(getattr(full, "id", 0) or getattr(peer, "channel_id", 0)), + kind="channel" if broadcast else "supergroup", + title=getattr(entity, "title", None), + username=getattr(entity, "username", None), + usernames=_usernames(getattr(entity, "usernames", None)), + about=getattr(full, "about", None) or None, + created_at=getattr(entity, "date", None), + ttl_period=getattr(full, "ttl_period", None), + pinned_message_id=getattr(full, "pinned_msg_id", None), + archived=getattr(full, "folder_id", None) == 1, + muted=_is_muted(getattr(full, "notify_settings", None)), + muted_until=getattr( + getattr(full, "notify_settings", None), "mute_until", None + ), + has_scheduled=bool(getattr(full, "has_scheduled", False)), + restricted=bool(getattr(entity, "restricted", False)), + restriction_reason=_restriction_reasons( + getattr(entity, "restriction_reason", None) + ), + verified=bool(getattr(entity, "verified", False)), + scam=bool(getattr(entity, "scam", False)), + fake=bool(getattr(entity, "fake", False)), + is_creator=bool(getattr(entity, "creator", False)), + left=bool(getattr(entity, "left", False)), + invite_link=getattr(getattr(full, "exported_invite", None), "link", None), + my_admin_rights=_rights(getattr(entity, "admin_rights", None)), + default_banned_rights=_rights( + getattr(entity, "default_banned_rights", None) + ), + is_forum=bool(getattr(entity, "forum", False)), + topics_layout=( + ("tabs" if getattr(entity, "forum_tabs", False) else "list") + if getattr(entity, "forum", False) + else None + ), + broadcast=broadcast, + megagroup=bool(getattr(entity, "megagroup", False)), + gigagroup=bool(getattr(entity, "gigagroup", False)), + participants_count=getattr(full, "participants_count", None), + admins_count=getattr(full, "admins_count", None), + kicked_count=getattr(full, "kicked_count", None), + banned_count=getattr(full, "banned_count", None), + online_count=getattr(full, "online_count", None), + slowmode_seconds=getattr(full, "slowmode_seconds", None), + slowmode_next_send_date=getattr(full, "slowmode_next_send_date", None), + linked_chat_id=getattr(full, "linked_chat_id", None), + migrated_from_chat_id=getattr(full, "migrated_from_chat_id", None), + hidden_prehistory=bool(getattr(full, "hidden_prehistory", False)), + participants_hidden=bool(getattr(full, "participants_hidden", False)), + antispam=bool(getattr(full, "antispam", False)), + can_view_participants=bool(getattr(full, "can_view_participants", False)), + can_view_stats=bool(getattr(full, "can_view_stats", False)), + can_delete_channel=bool(getattr(full, "can_delete_channel", False)), + can_set_username=bool(getattr(full, "can_set_username", False)), + join_to_send=bool(getattr(entity, "join_to_send", False)), + join_request=bool(getattr(entity, "join_request", False)), + requests_pending=getattr(full, "requests_pending", None), + noforwards=bool(getattr(entity, "noforwards", False)), + unread_count=getattr(full, "unread_count", None), + available_reactions=_reactions(getattr(full, "available_reactions", None)), + reactions_limit=getattr(full, "reactions_limit", None), + call_active=bool(getattr(entity, "call_active", False)), + raw=( + {"entity": _serialize(entity), "full": _serialize(full)} + if raw + else None + ), + ) + + async def _inspect_basic_group(self, peer: Any, *, raw: bool) -> ChatInfo: + from telethon.tl import functions + + try: + result = await self._client( + functions.messages.GetFullChatRequest(chat_id=peer.chat_id) + ) + except Exception as exc: + raise translate_flood_wait(exc) from exc + + full = getattr(result, "full_chat", None) + entity = _shallow_for(result, full, "chats") + + return ChatInfo( + chat_id=int(getattr(full, "id", 0) or getattr(peer, "chat_id", 0)), + kind="basic_group", + title=getattr(entity, "title", None), + about=getattr(full, "about", None) or None, + created_at=getattr(entity, "date", None), + ttl_period=getattr(full, "ttl_period", None), + pinned_message_id=getattr(full, "pinned_msg_id", None), + archived=getattr(full, "folder_id", None) == 1, + muted=_is_muted(getattr(full, "notify_settings", None)), + muted_until=getattr( + getattr(full, "notify_settings", None), "mute_until", None + ), + has_scheduled=bool(getattr(full, "has_scheduled", False)), + is_creator=bool(getattr(entity, "creator", False)), + left=bool(getattr(entity, "left", False)), + invite_link=getattr(getattr(full, "exported_invite", None), "link", None), + my_admin_rights=_rights(getattr(entity, "admin_rights", None)), + default_banned_rights=_rights( + getattr(entity, "default_banned_rights", None) + ), + participants_count=getattr(entity, "participants_count", None), + deactivated=bool(getattr(entity, "deactivated", False)), + migrated_to_chat_id=_peer_id(getattr(entity, "migrated_to", None)), + can_set_username=bool(getattr(full, "can_set_username", False)), + requests_pending=getattr(full, "requests_pending", None), + noforwards=bool(getattr(entity, "noforwards", False)), + available_reactions=_reactions(getattr(full, "available_reactions", None)), + reactions_limit=getattr(full, "reactions_limit", None), + call_active=bool(getattr(entity, "call_active", False)), + raw=( + {"entity": _serialize(entity), "full": _serialize(full)} + if raw + else None + ), + ) + + async def _inspect_user(self, peer: Any, *, raw: bool) -> ChatInfo: + from telethon.tl import functions + + try: + result = await self._client(functions.users.GetFullUserRequest(id=peer)) + except Exception as exc: + raise translate_flood_wait(exc) from exc + + full = getattr(result, "full_user", None) + entity = _shallow_for(result, full, "users") + first = getattr(entity, "first_name", None) + last = getattr(entity, "last_name", None) + title = " ".join(part for part in (first, last) if part) or None + + return ChatInfo( + chat_id=int(getattr(full, "id", 0) or getattr(peer, "user_id", 0)), + kind="bot" if getattr(entity, "bot", False) else "user", + title=title, + username=getattr(entity, "username", None), + usernames=_usernames(getattr(entity, "usernames", None)), + about=getattr(full, "about", None) or None, + ttl_period=getattr(full, "ttl_period", None), + pinned_message_id=getattr(full, "pinned_msg_id", None), + archived=getattr(full, "folder_id", None) == 1, + muted=_is_muted(getattr(full, "notify_settings", None)), + muted_until=getattr( + getattr(full, "notify_settings", None), "mute_until", None + ), + has_scheduled=bool(getattr(full, "has_scheduled", False)), + restricted=bool(getattr(entity, "restricted", False)), + restriction_reason=_restriction_reasons( + getattr(entity, "restriction_reason", None) + ), + verified=bool(getattr(entity, "verified", False)), + scam=bool(getattr(entity, "scam", False)), + fake=bool(getattr(entity, "fake", False)), + first_name=first, + last_name=last, + phone=getattr(entity, "phone", None), + is_bot=bool(getattr(entity, "bot", False)), + is_deleted=bool(getattr(entity, "deleted", False)), + is_premium=bool(getattr(entity, "premium", False)), + is_contact=bool(getattr(entity, "contact", False)), + is_mutual_contact=bool(getattr(entity, "mutual_contact", False)), + blocked=bool(getattr(full, "blocked", False)), + common_chats_count=getattr(full, "common_chats_count", None), + birthday=_birthday(getattr(full, "birthday", None)), + personal_channel_id=getattr(full, "personal_channel_id", None), + last_seen_status=( + type(getattr(entity, "status", None)).__name__ + if getattr(entity, "status", None) is not None + else None + ), + raw=( + {"entity": _serialize(entity), "full": _serialize(full)} + if raw + else None + ), + ) + + +def _is_muted(settings: Any) -> bool: + """A chat is muted when ``silent`` is set or ``mute_until`` is populated.""" + if settings is None: + return False + if getattr(settings, "silent", False): + return True + return getattr(settings, "mute_until", None) is not None + + +__all__ = ["TelethonChatInspectBackend"] +``` + +- [ ] **Step 4: Run the tests** + +Run: `.venv/bin/pytest tests/test_chats_inspect_backend.py -v` +Expected: PASS (9 tests) + +If `translate_flood_wait` is not importable from `telegram_assistant.telegram_client.errors`, run `grep -rn "def translate_flood_wait" src/` and import it from where it actually lives — `groups/telethon_backend.py` uses the same helper. + +- [ ] **Step 5: Lint** + +Run: `.venv/bin/ruff check src/telegram_assistant/chats tests/test_chats_inspect_backend.py` +Expected: `All checks passed!` + +- [ ] **Step 6: Commit** + +```bash +git add src/telegram_assistant/chats/telethon_backend.py tests/test_chats_inspect_backend.py +git commit -m "feat(chats): map channel/basic-group/user metadata in the Telethon adapter" +``` + +--- + +### Task 3: CLI command `chats inspect` + +**Files:** +- Modify: `src/telegram_assistant/cli/main.py` (insert the new section immediately before the `# --- messages ---` divider that precedes `messages_app = typer.Typer(...)`) +- Test: `tests/test_cli_chats_inspect.py` + +**Interfaces:** +- Consumes: `inspect_chat` and `TelethonChatInspectBackend` from Tasks 1-2; existing CLI helpers `_load_config_or_exit`, `_resolve_folder_name`, `_cli_authorizer`, `_raise_for_access_or_entity_error`, `TelethonSessionManager`. +- Produces: `_build_chat_inspect_backends(config_path)` returning `(config, manager, _open)` where `_open()` yields `(chat_backend, folder_backend, resolver)`. Tests monkeypatch this symbol. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_cli_chats_inspect.py`: + +```python +"""CLI tests for `chats inspect`.""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from telegram_assistant.access import AccessDenied +from telegram_assistant.chats import ChatInfo +from telegram_assistant.cli import main as cli_main +from telegram_assistant.entities import EntityNotFoundError + +runner = CliRunner() + + +class FakeChatBackend: + def __init__(self, info: ChatInfo | None = None, error: Exception | None = None): + self.info = info or ChatInfo(chat_id=5, kind="supergroup", title="Team") + self.error = error + self.calls: list[dict[str, object]] = [] + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + self.calls.append({"chat_id": chat_id, "raw": raw}) + if self.error is not None: + raise self.error + return self.info + + +class FakeResolved: + def __init__(self, chat_id: int) -> None: + self.chat_id = chat_id + + +class FakeResolver: + def __init__(self, chat_id: int = 5, error: Exception | None = None) -> None: + self.chat_id = chat_id + self.error = error + + async def resolve(self, ref: str): + if self.error is not None: + raise self.error + return FakeResolved(self.chat_id) + + +class FakeManager: + async def disconnect(self) -> None: + return None + + +@pytest.fixture +def wire(monkeypatch, minimal_config_yaml, tmp_path): + """Patch the backend builder; return a helper that installs fakes.""" + + config_path = tmp_path / "config.yml" + config_path.write_text(minimal_config_yaml, encoding="utf-8") + + def _install(backend, resolver=None, authorizer=None): + config = cli_main._load_config_or_exit(config_path) + + def _build(_path): + async def _open(): + return backend, object(), resolver or FakeResolver() + + return config, FakeManager(), _open + + monkeypatch.setattr(cli_main, "_build_chat_inspect_backends", _build) + if authorizer is not None: + monkeypatch.setattr(cli_main, "_cli_authorizer", lambda *a, **k: authorizer) + return config_path + + return _install + + +def test_requires_exactly_one_reference(wire): + config_path = wire(FakeChatBackend()) + + result = runner.invoke( + cli_main.app, ["chats", "inspect", "--config", str(config_path)] + ) + + assert result.exit_code == 2 + assert "exactly one of --chat-id, --chat-name, or --entity" in result.output + + +def test_rejects_two_references(wire): + config_path = wire(FakeChatBackend()) + + result = runner.invoke( + cli_main.app, + [ + "chats", "inspect", + "--chat-id", "5", + "--entity", "@team", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + + +def test_prints_payload_json(wire): + backend = FakeChatBackend( + ChatInfo(chat_id=5, kind="supergroup", title="Team", ttl_period=86400) + ) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--config", str(config_path)], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["chat_id"] == 5 + assert payload["kind"] == "supergroup" + assert payload["ttl_period"] == 86400 + assert "raw" not in payload + assert backend.calls == [{"chat_id": 5, "raw": False}] + + +def test_entity_reference_is_resolved(wire): + backend = FakeChatBackend() + config_path = wire(backend, resolver=FakeResolver(chat_id=77)) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--entity", "@team", "--config", str(config_path)], + ) + + assert result.exit_code == 0, result.output + assert backend.calls == [{"chat_id": 77, "raw": False}] + + +def test_raw_flag_is_passed_through(wire): + backend = FakeChatBackend( + ChatInfo(chat_id=5, kind="supergroup", raw={"entity": {}, "full": {}}) + ) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--raw", "--config", str(config_path)], + ) + + assert result.exit_code == 0, result.output + assert backend.calls == [{"chat_id": 5, "raw": True}] + assert json.loads(result.output)["raw"] == {"entity": {}, "full": {}} + + +def test_access_denied_exits_3(wire): + backend = FakeChatBackend(error=AccessDenied("chat 5 is not readable")) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--config", str(config_path)], + ) + + assert result.exit_code == 3 + assert "access denied" in result.output + + +def test_unresolvable_entity_exits_2(wire): + config_path = wire( + FakeChatBackend(), resolver=FakeResolver(error=EntityNotFoundError("no such chat")) + ) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--entity", "@ghost", "--config", str(config_path)], + ) + + assert result.exit_code == 2 + assert "no such chat" in result.output + + +def test_domain_value_error_exits_2(wire): + backend = FakeChatBackend(error=ValueError("chat 5 cannot be inspected")) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--config", str(config_path)], + ) + + assert result.exit_code == 2 + assert "cannot be inspected" in result.output + + +def test_unexpected_error_exits_1(wire): + backend = FakeChatBackend(error=RuntimeError("boom")) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--config", str(config_path)], + ) + + assert result.exit_code == 1 + assert "chats inspect failed: boom" in result.output +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest tests/test_cli_chats_inspect.py -v` +Expected: FAIL — `AttributeError: module 'telegram_assistant.cli.main' has no attribute '_build_chat_inspect_backends'` + +- [ ] **Step 3: Add the CLI section** + +Insert into `src/telegram_assistant/cli/main.py`, immediately **before** the line `# --- messages ---------------------------------------------------------------`: + +```python +# --- chats ------------------------------------------------------------------ + +chats_app = typer.Typer( + help="Read chat metadata.", no_args_is_help=True +) +app.add_typer(chats_app, name="chats") + + +def _build_chat_inspect_backends(config_path: Path | None): + """Open the Telethon-backed chat-inspect + folder backends + resolver. + + Mirrors :func:`_build_member_list_backends`: the read backend, the folder + backend (for ``--chat-name`` and folder access rules) and a shared entity + resolver so ``--entity`` works. Tests monkeypatch this to inject fakes. + """ + config = _load_config_or_exit(config_path) + manager = TelethonSessionManager(config.telegram) + + async def _open(): + from telegram_assistant.chats.telethon_backend import ( + TelethonChatInspectBackend, + ) + from telegram_assistant.entities import TelethonEntityResolver + from telegram_assistant.folders import TelethonFolderBackend + + client = await manager.get_client() + if not await client.is_user_authorized(): + raise RuntimeError( + "Telethon session is not authorized; run " + "`telegram-assistant auth` first." + ) + return ( + TelethonChatInspectBackend(client), + TelethonFolderBackend(client), + TelethonEntityResolver(client), + ) + + return config, manager, _open + + +@chats_app.command("inspect") +def chats_inspect( + chat_id: int | None = typer.Option( + None, + "--chat-id", + help="Numeric Telegram chat id to read.", + ), + chat_name: str | None = typer.Option( + None, + "--chat-name", + help="Chat title (resolved within --folder-name).", + ), + entity: str | None = typer.Option( + None, + "--entity", + help="Flexible entity reference (numeric id, @username, t.me/invite link, " + "phone, or exact title) resolved via the shared resolver.", + ), + folder_name: str | None = typer.Option( + None, + "--folder-name", + help="Folder used for --chat-name lookup " + "(defaults to telegram.default_chat_folder.folder_name).", + ), + folder_id: int | None = typer.Option( + None, + "--folder-id", + help="Optional folder id cross-check.", + ), + raw: bool = typer.Option( + False, + "--raw", + help="Also include the serialized entity and Full objects under 'raw'.", + ), + config_path: Path | None = typer.Option( # noqa: B008 + None, + "--config", + "-c", + help="Path to config.yml (defaults: ./data/config.yml, then ~/.config/telegram-assistant/config.yml).", + exists=False, + ), +) -> None: + """Read one chat's metadata: TTL, description, counts, rights (READ-gated).""" + from telegram_assistant.chats import inspect_chat + from telegram_assistant.folders import FolderError, resolve_chat_in_folder + + refs = sum([chat_id is not None, chat_name is not None, entity is not None]) + if refs != 1: + typer.echo( + "exactly one of --chat-id, --chat-name, or --entity must be supplied", + err=True, + ) + raise typer.Exit(code=2) + + config, manager, open_backends = _build_chat_inspect_backends(config_path) + + if chat_name is not None: + resolved_folder_name, default_fid, _ = _resolve_folder_name( + folder_name, config_path + ) + effective_folder_id = folder_id if folder_id is not None else default_fid + else: + resolved_folder_name = folder_name + effective_folder_id = folder_id + + async def _run() -> dict[str, object]: + try: + chat_backend, folder_backend, resolver = await open_backends() + if entity is not None: + resolved_chat_id = (await resolver.resolve(entity)).chat_id + elif chat_id is not None: + resolved_chat_id = chat_id + else: + resolved = await resolve_chat_in_folder( + folder_backend, + folder_name=resolved_folder_name or "", + chat_name=chat_name or "", + folder_id=effective_folder_id, + ) + resolved_chat_id = resolved.chat_id + + authorizer = _cli_authorizer( + config, resolver=resolver, folder_backend=folder_backend + ) + info = await inspect_chat( + backend=chat_backend, + chat_id=resolved_chat_id, + raw=raw, + authorizer=authorizer, + ) + return info.to_dict() + finally: + try: + await manager.disconnect() + except Exception: + pass + + try: + payload = asyncio.run(_run()) + except FolderError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + except ValueError as exc: + # Bad caller input / an uninspectable peer — exit 2 like the rest of the + # domain rejections. AccessDenied/EntityError are RuntimeErrors, so they + # fall through to the mapping below. + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + except Exception as exc: + _raise_for_access_or_entity_error(exc) + typer.echo(f"chats inspect failed: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(json.dumps(payload, sort_keys=True, default=str)) +``` + +- [ ] **Step 4: Run the tests** + +Run: `.venv/bin/pytest tests/test_cli_chats_inspect.py -v` +Expected: PASS (9 tests) + +If `CliRunner` merges stderr into `result.output` differently than the assertions expect, mirror whatever `tests/test_cli_groups_layout.py` does — do not weaken the exit-code assertions. + +- [ ] **Step 5: Run the full suite to catch collateral damage** + +Run: `.venv/bin/pytest -q` +Expected: PASS except `tests/test_skill_inventory.py`, which now fails with `chats inspect` missing from the SKILL.md catalog. That failure is expected and is fixed in Task 4. + +- [ ] **Step 6: Lint** + +Run: `.venv/bin/ruff check src tests` +Expected: `All checks passed!` + +- [ ] **Step 7: Commit** + +```bash +git add src/telegram_assistant/cli/main.py tests/test_cli_chats_inspect.py +git commit -m "feat(cli): add chats inspect" +``` + +--- + +### Task 4: Documentation — SKILL.md, README, skill sync + +**Files:** +- Modify: `skills/telegram-assistant/SKILL.md` +- Modify: `README.md` (the CLI command bullet list, next to the `members list` entry around line 101) +- Modify: `CLAUDE.md` (the "Run the CLI" line, which enumerates commands) +- Copy: `~/.claude/skills/telegram-assistant/SKILL.md` + +**Interfaces:** +- Consumes: the CLI surface from Task 3. No code changes. +- Produces: a green `tests/test_skill_inventory.py`. + +- [ ] **Step 1: Confirm the guard is red for the right reason** + +Run: `.venv/bin/pytest tests/test_skill_inventory.py -v` +Expected: FAIL naming `chats inspect` as a CLI command missing from the SKILL.md catalog. + +- [ ] **Step 2: Add the catalog row to `skills/telegram-assistant/SKILL.md`** + +In the `## Resources & actions` table, insert directly after the `members` / `list` row: + +```markdown +| `chats` | `inspect` | Read-only: report one chat's metadata — auto-delete TTL, description, member counts, slow mode, restrictions, our own rights (READ-gated, no `--dry-run`). `--raw` adds the serialized entity/Full objects. | `telegram-assistant chats inspect ...` | +``` + +- [ ] **Step 3: Add the per-pair section** + +Insert a new `#### \`chats\` / \`inspect\`` block directly after the `#### \`members\` / \`list\`` section: + +```markdown +#### `chats` / `inspect` + +- Extract: chat reference (`--chat-id` / `--chat-name` / `--entity`), optional + `--raw`. +- Required flags: exactly one chat reference. +- From config: `--folder-name` default when resolving `--chat-name`. +- Temp file: no. +- Automation: read-only — run immediately when the human asks «какой статус + автоудаления у чата X», «что за чат X», «сколько участников в X», «почему в X + не отправляется». No `--dry-run` (there is none), no confirmation. +- Payload: one flat JSON object with the same keys for every chat kind — + fields that do not apply are `null`. Always present: `chat_id` (bare id, no + `-100`), `kind` (`user`/`bot`/`basic_group`/`supergroup`/`channel`), `title`, + `username`, `about`, `ttl_period` (auto-delete, seconds; `null` = off), + `pinned_message_id`, `archived`, `muted`/`muted_until`, `restricted` + + `restriction_reason`, `is_creator`, `left`, `invite_link`, + `my_admin_rights`, `default_banned_rights`. Groups and channels add + `is_forum`, `topics_layout`, `participants_count`, `admins_count`, + `kicked_count`, `banned_count`, `online_count`, `slowmode_seconds`, + `linked_chat_id`, `hidden_prehistory`, `antispam`, `join_to_send`, + `noforwards`, `available_reactions` and friends. Private chats add + `first_name`/`last_name`, `phone`, `is_bot`, `is_premium`, `is_contact`, + `blocked`, `common_chats_count`, `birthday`, `last_seen_status`. +- `--raw`: adds a `raw` key holding `{"entity": …, "full": …}` — the two + serialized Telegram objects behind the curated fields, minus `access_hash`. + Use it only when the human asks for a field the curated set does not name; + it is large and its shape moves with the Telegram layer. +- Note it does **not** write anything: there is no way to *change* the TTL, + the description or the archive state through this CLI. If the human asks to + set auto-delete, say so plainly rather than reaching for another command. +- Confirmation: not required (read-only). Still READ-gated by the + `telegram.access` policy — a chat with no `read` grant exits 3 with + `access denied`; surface that and stop. +- Typical errors: `exactly one of --chat-id, --chat-name, or --entity must be + supplied` (exit 2), `chat cannot be inspected (resolved to ...)` (exit 2 + — the reference resolved to something with no metadata to read), + `access denied ...` (exit 3), entity not-found / ambiguous (exit 2). +``` + +- [ ] **Step 4: Add the scenario** + +Insert a `### \`chats inspect\`` scenario directly after the `### \`members list\`` scenario: + +```markdown +### `chats inspect` + +Request: «Какой статус автоудаления у чата 2305069221?» + +1. Resource/action: `chats` / `inspect`. Read-only — run it immediately, no + `--dry-run`, no confirmation. +2. Run: + + ```bash + telegram-assistant chats inspect --entity 2305069221 + ``` + +3. Read `ttl_period` from the payload: `null` means auto-delete is off, + otherwise it is the window in seconds (86400 = 1 day, 604800 = 1 week). + Report the other fields only if the human asked for them — the payload is + wide by design. +4. If the human then asks to *change* it, stop: this command only reads. +``` + +- [ ] **Step 5: Verify the guard is green** + +Run: `.venv/bin/pytest tests/test_skill_inventory.py -v` +Expected: PASS + +- [ ] **Step 6: Update `README.md`** + +Add this bullet directly after the `members list` bullet (around line 101): + +```markdown +- `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`, `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 — there is no command to change any of these settings. +``` + +- [ ] **Step 7: Update the CLI command list in `CLAUDE.md`** + +In the "Run the CLI" bullet under `## Common commands`, add `chats inspect` to the parenthesised list of examples, after `members list`. + +- [ ] **Step 8: Sync the skill to the user skills directory** + +```bash +cp skills/telegram-assistant/SKILL.md ~/.claude/skills/telegram-assistant/SKILL.md +``` + +- [ ] **Step 9: Run the full suite** + +Run: `.venv/bin/pytest -q` +Expected: PASS (no failures; `tests/test_docker_image.py` may skip when Docker is unavailable) + +- [ ] **Step 10: Commit** + +```bash +git add skills/telegram-assistant/SKILL.md README.md CLAUDE.md +git commit -m "docs: document chats inspect in the skill, README and CLAUDE.md" +``` + +--- + +### Task 5: Live read-only verification + +**Files:** none — this task produces a report, not a diff. + +**Interfaces:** +- Consumes: the finished command from Tasks 1-4. + +This is a **read-only** check against the live account, which the project's e2e rule permits without asking first. Do **not** run any mutating e2e script here. + +- [ ] **Step 1: Inspect the chat that motivated the feature** + +Run: `.venv/bin/telegram-assistant chats inspect --entity 2305069221` +Expected: a JSON object; note `kind`, `title`, and `ttl_period`. + +- [ ] **Step 2: Inspect a forum supergroup** + +Run: `.venv/bin/telegram-assistant chats inspect --entity "e2e test group"` +Expected: `kind` is `supergroup`, `is_forum` and `topics_layout` populated. + +Cross-check the layout against the existing command — the two must agree: + +Run: `.venv/bin/telegram-assistant groups get-layout --chat-id ` +Expected: the same word `chats inspect` reported in `topics_layout`. + +- [ ] **Step 3: Inspect a private chat (Saved Messages)** + +Run: `.venv/bin/telegram-assistant chats inspect --entity me` +Expected: `kind` is `user`, `title` is your own name, no crash on the missing group-only fields. + +- [ ] **Step 4: Inspect a broadcast channel** + +Pick any channel id from `.venv/bin/telegram-assistant folders inspect` and run: + +Run: `.venv/bin/telegram-assistant chats inspect --chat-id ` +Expected: `kind` is `channel`, `broadcast` is `true`. + +- [ ] **Step 5: Check `--raw` on one of them** + +Run: `.venv/bin/telegram-assistant chats inspect --entity me --raw` +Expected: a `raw` key with `entity` and `full` sub-objects, and no `access_hash` anywhere: + +Run: `.venv/bin/telegram-assistant chats inspect --entity me --raw | grep -c access_hash` +Expected: `0` + +- [ ] **Step 6: Report the results to the human** + +Post the four `kind`/`ttl_period` pairs and flag any field that came back `null` where it should not have. Phase 2 (HTTP + MCP) starts only after the human has looked at this output. + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task | +|---|---| +| `chats/` package, `service.py`, `ChatInfo`, protocol, `inspect_chat()` | 1 | +| READ gate before any RPC | 1 (tested), 3 (wired) | +| Telethon adapter, three peer kinds, shallow half from the Full response | 2 | +| Flat payload, curated field list, per-kind extras | 1 (shape), 2 (population) | +| `--raw` with both halves, `access_hash` stripped | 2 | +| CLI flags, reference exclusivity, folder defaults, JSON output | 3 | +| Error ladder (2 / 3 / 1) | 3 | +| Three test files mirroring `test_members_list*` | 1, 2, 3 | +| SKILL.md + README + skill sync + inventory guard | 4 | +| Live read-only verification across peer kinds | 5 | +| Phase 2 (HTTP/MCP) | out of scope — a separate plan | + +**Deviation from the spec, deliberate:** the spec's architecture paragraph says the adapter "resolves the peer once via `get_entity`". The plan uses `get_input_entity` instead, because the `GetFull*` response already returns the shallow `Channel`/`Chat`/`User` in its own `chats`/`users` list — the same trick `get_topics_layout` uses — so `get_entity` would be a redundant round trip. The observable payload is unchanged. + +**Type consistency:** `inspect_chat(*, backend, chat_id, raw, authorizer)` in Task 1 matches every call site in Tasks 2-3; `ChatInspectBackend.inspect_chat(*, chat_id, raw)` matches `TelethonChatInspectBackend.inspect_chat` and both fakes; `_build_chat_inspect_backends` returns the `(config, manager, _open)` triple the CLI and the CLI test both assume. diff --git a/docs/superpowers/plans/2026-08-06-chats-set-ttl.md b/docs/superpowers/plans/2026-08-06-chats-set-ttl.md new file mode 100644 index 0000000..3d1e8cd --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-chats-set-ttl.md @@ -0,0 +1,2125 @@ +# `chats set-ttl` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a CLI-only, WRITE-gated `chats set-ttl` command that changes a chat's auto-delete period, so operators stop reaching for ad-hoc Telethon scripts. + +**Architecture:** A new `chats/ttl.py` module beside the existing read-only `chats/service.py` (the split `members/listing.py` established), holding a pure `parse_ttl`, a narrow `ChatTtlBackend` protocol, and `set_chat_ttl` which gates, reads the current period, short-circuits a no-op, writes through the shared `Pacer`, and re-reads to decide success. A `TelethonChatTtlBackend` is appended to `chats/telethon_backend.py`; the CLI wires them together in the existing `chats` Typer group. + +**Tech Stack:** Python 3.12, Telethon >= 1.44 (`messages.SetHistoryTTLRequest`), Typer, pytest (asyncio mode auto), ruff (line-length 100, py312, E501 ignored), Pydantic config models. + +**Spec:** `docs/superpowers/specs/2026-08-06-chats-set-ttl-design.md` + +## Global Constraints + +- **CLI only.** No HTTP route, no MCP tool, no backend factory on `app.state`. The domain layer must stay surface-agnostic so those can be added later, but this plan adds none of them. +- **`chats/ttl.py` must not import telethon.** Same rule the existing `chats/service.py` follows; the adapter owns every Telethon import, and Telethon symbols are imported *inside* functions (the established pattern in this repo). +- **Use `.venv` for everything.** `.venv/bin/pytest`, `.venv/bin/ruff`. Never a system Python. +- **The WRITE gate runs before any Telegram call.** `authorizer.require(chat_id, AccessLevel.WRITE)` is the first statement that can raise. +- **Setting the value a chat already has must issue no write.** Telegram posts a member-visible service message on every successful `SetHistoryTTL`, including a no-op one. +- **The read-back is the authority.** Never report the requested period as the result; report what `get_ttl` returned after the write. +- **`ttl_period` is `null` when auto-delete is off, never `0`** — matching what `chats inspect` already reports for the same field. +- **Exit codes:** caller input / domain rejection / read-back mismatch → 2, `AccessDenied` → 3, exhausted flood-wait cap and anything else → 1. `AccessDenied`, `EntityNotFoundError` and `AmbiguousEntityError` are `RuntimeError` subclasses, so `except ValueError` cannot catch them — the existing `_raise_for_access_or_entity_error` helper handles them. +- **No `OperationStore` row and no idempotency key.** Shaped like `notifications mute`. +- **Never run mutating live e2e on your own initiative.** No `scripts/e2e_*.sh`, no `scripts/spike_rich_*.py`, no ad-hoc send/react/set probe against the real account. Read-only live checks are allowed. The spec's live verification step is explicitly out of this plan. +- **Commit after every task**, with the task's tests passing and `ruff check src tests` clean. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/telegram_assistant/chats/ttl.py` | **Create.** `parse_ttl`, `SetTtlRequest`, `SetTtlResult`, `ChatTtlBackend`, `set_chat_ttl`. Pure domain, no Telethon. | +| `src/telegram_assistant/chats/__init__.py` | **Modify.** Re-export the new names beside the inspect ones. | +| `src/telegram_assistant/chats/telethon_backend.py` | **Modify.** Append `TelethonChatTtlBackend` and extend `__all__`. | +| `src/telegram_assistant/config/models.py` | **Modify.** Three `TelegramConfig` fields for pacing. | +| `src/telegram_assistant/messages/pacing.py` | **Modify.** Add `ttl_pacing_key`. | +| `src/telegram_assistant/messages/__init__.py` | **Modify.** Export `ttl_pacing_key`. | +| `src/telegram_assistant/cli/main.py` | **Modify.** `_build_chat_ttl_backends`, `_cli_ttl_pacer`, the `chats set-ttl` command. | +| `tests/test_chats_set_ttl.py` | **Create.** Domain against a fake backend. | +| `tests/test_chats_set_ttl_backend.py` | **Create.** Telethon adapter against a fake client. | +| `tests/test_cli_chats_set_ttl.py` | **Create.** CLI flags, payloads, exit codes. | +| `skills/telegram-assistant/SKILL.md` + `~/.claude/skills/telegram-assistant/SKILL.md` | **Modify.** Catalog row, extraction section, confirmation bucket, dry-run list, scenario. | +| `README.md` | **Modify.** Command list entry. | + +Task boundaries follow that table: Task 1 is the pure domain (testable alone), Task 2 the adapter (testable alone against a fake client), Task 3 config + pacing key (a small, separately rejectable unit the CLI depends on), Task 4 the CLI, Task 5 docs (whose failure mode — the `test_skill_inventory.py` guard — is its own gate). + +--- + +### Task 1: Domain module — `parse_ttl` and `set_chat_ttl` + +**Files:** +- Create: `src/telegram_assistant/chats/ttl.py` +- Modify: `src/telegram_assistant/chats/__init__.py` +- Test: `tests/test_chats_set_ttl.py` + +**Interfaces:** +- Consumes: `telegram_assistant.access.service.AccessLevel`, `Authorizer` (already used by `chats/service.py:21`). `Authorizer.require(chat_id: int, level: AccessLevel)` is a coroutine. +- Produces, for Tasks 2 and 4: + - `parse_ttl(value: str) -> int` + - `ChatTtlBackend` protocol: `async def get_ttl(self, *, chat_id: int) -> int | None` and `async def set_ttl(self, *, chat_id: int, period: int) -> None` + - `SetTtlRequest(telegram_chat_id: int, period: int, chat_name: str | None = None)` (frozen) + - `SetTtlResult(chat_id, requested_ttl_seconds, previous_ttl_seconds, ttl_period, changed, dry_run, chat_name)` (frozen) with `to_dict() -> dict[str, Any]` + - `async def set_chat_ttl(*, backend: ChatTtlBackend, request: SetTtlRequest, authorizer: Authorizer | None = None, pacer: Any | None = None, dry_run: bool = False) -> SetTtlResult` + - `MAX_TTL_SECONDS = 2**31 - 1` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_chats_set_ttl.py`: + +```python +"""Domain tests for `chats set-ttl`.""" + +from __future__ import annotations + +import pytest + +from telegram_assistant.access import AccessDenied, AccessLevel +from telegram_assistant.chats.ttl import ( + MAX_TTL_SECONDS, + SetTtlRequest, + parse_ttl, + set_chat_ttl, +) + + +class FakeTtlBackend: + """Records calls; ``reads`` is the queue of values ``get_ttl`` returns.""" + + def __init__(self, reads: list[int | None] | None = None) -> None: + # Default: currently off, and off again after any write. + self._reads = list(reads if reads is not None else [None, None]) + self.calls: list[tuple[str, dict[str, object]]] = [] + + async def get_ttl(self, *, chat_id: int) -> int | None: + self.calls.append(("get_ttl", {"chat_id": chat_id})) + return self._reads.pop(0) if self._reads else None + + async def set_ttl(self, *, chat_id: int, period: int) -> None: + self.calls.append(("set_ttl", {"chat_id": chat_id, "period": period})) + + @property + def writes(self) -> list[dict[str, object]]: + return [args for name, args in self.calls if name == "set_ttl"] + + +class DenyingAuthorizer: + def __init__(self) -> None: + self.checked: list[tuple[int, AccessLevel]] = [] + + async def require(self, chat_id: int, level: AccessLevel) -> None: + self.checked.append((chat_id, level)) + raise AccessDenied(chat_ref=chat_id, required_level=level) + + +class AllowingAuthorizer: + def __init__(self) -> None: + self.checked: list[tuple[int, AccessLevel]] = [] + + async def require(self, chat_id: int, level: AccessLevel) -> None: + self.checked.append((chat_id, level)) + + +# --- parse_ttl -------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("off", 0), + ("OFF", 0), + ("0", 0), + ("30s", 30), + ("5m", 300), + ("24h", 86400), + ("1d", 86400), + ("31d", 2678400), + ("93d", 8035200), + ("180d", 15552000), + ("2w", 1209600), + ("86400", 86400), + (" 1d ", 86400), + ], +) +def test_parse_ttl_accepts(text: str, expected: int) -> None: + assert parse_ttl(text) == expected + + +@pytest.mark.parametrize( + "text", + ["", " ", "-1", "-1d", "1.5d", "1y", "d", "1 d", "abc", "1dd", "1d2h"], +) +def test_parse_ttl_rejects(text: str) -> None: + with pytest.raises(ValueError): + parse_ttl(text) + + +def test_parse_ttl_rejects_over_int32() -> None: + with pytest.raises(ValueError) as exc: + parse_ttl(str(MAX_TTL_SECONDS + 1)) + assert "too large" in str(exc.value) + + +def test_parse_ttl_error_names_the_offending_text() -> None: + with pytest.raises(ValueError) as exc: + parse_ttl("1y") + assert "1y" in str(exc.value) + + +# --- the gate --------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_write_gate_fires_before_any_rpc() -> None: + backend = FakeTtlBackend() + authorizer = DenyingAuthorizer() + + with pytest.raises(AccessDenied): + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + authorizer=authorizer, + ) + + assert backend.calls == [] + assert authorizer.checked == [(5, AccessLevel.WRITE)] + + +@pytest.mark.asyncio +async def test_gate_is_write_not_read() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + authorizer = AllowingAuthorizer() + + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + authorizer=authorizer, + ) + + assert authorizer.checked == [(5, AccessLevel.WRITE)] + + +# --- the no-op short-circuit ------------------------------------------------ + + +@pytest.mark.asyncio +async def test_setting_the_same_period_issues_no_write() -> None: + backend = FakeTtlBackend(reads=[86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + ) + + assert backend.writes == [] + assert result.changed is False + assert result.previous_ttl_seconds == 86400 + assert result.ttl_period == 86400 + + +@pytest.mark.asyncio +async def test_turning_off_an_already_off_chat_issues_no_write() -> None: + backend = FakeTtlBackend(reads=[None]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + ) + + assert backend.writes == [] + assert result.changed is False + assert result.previous_ttl_seconds is None + assert result.ttl_period is None + + +# --- dry run ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dry_run_reads_but_never_writes() -> None: + backend = FakeTtlBackend(reads=[86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + dry_run=True, + ) + + assert backend.writes == [] + assert result.dry_run is True + assert result.changed is True + assert result.previous_ttl_seconds == 86400 + assert result.ttl_period == 86400 # unchanged: nothing was written + + +@pytest.mark.asyncio +async def test_dry_run_still_runs_the_gate() -> None: + backend = FakeTtlBackend(reads=[86400]) + authorizer = DenyingAuthorizer() + + with pytest.raises(AccessDenied): + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + authorizer=authorizer, + dry_run=True, + ) + + assert backend.calls == [] + + +# --- the write and the read-back -------------------------------------------- + + +@pytest.mark.asyncio +async def test_write_then_read_back_reports_the_server_value() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400, chat_name="Team"), + ) + + assert backend.writes == [{"chat_id": 5, "period": 86400}] + assert result.previous_ttl_seconds is None + assert result.ttl_period == 86400 + assert result.requested_ttl_seconds == 86400 + assert result.changed is True + assert result.chat_name == "Team" + assert result.dry_run is False + + +@pytest.mark.asyncio +async def test_turning_off_reports_null_not_zero() -> None: + backend = FakeTtlBackend(reads=[86400, None]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + ) + + assert result.ttl_period is None + assert result.requested_ttl_seconds == 0 + assert result.changed is True + + +@pytest.mark.asyncio +async def test_read_back_mismatch_raises() -> None: + # Server clamped or ignored the value: 93d asked, 31d stored. + backend = FakeTtlBackend(reads=[None, 2678400]) + + with pytest.raises(ValueError) as exc: + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=8035200), + ) + + message = str(exc.value) + assert "8035200" in message + assert "2678400" in message + + +@pytest.mark.asyncio +async def test_a_silent_write_is_still_judged_by_the_read_back() -> None: + """The domain never inspects what ``set_ttl`` returned. + + Task 2's adapter swallows the ``TypeNotFoundError`` Telegram can answer with + while the write applies; the domain's half of that contract is that a + ``set_ttl`` returning nothing at all is fine, because only the read-back + decides. A backend whose ``set_ttl`` is a no-op therefore still yields + ``changed: True`` when the read-back agrees with the request. + """ + + class SilentBackend(FakeTtlBackend): + async def set_ttl(self, *, chat_id: int, period: int) -> None: + self.calls.append(("set_ttl", {"chat_id": chat_id, "period": period})) + return None + + backend = SilentBackend(reads=[86400, None]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + ) + + assert backend.writes == [{"chat_id": 5, "period": 0}] + assert result.ttl_period is None + assert result.changed is True + + +# --- pacing ----------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pacer_wraps_only_the_write() -> None: + calls: list[str] = [] + + class RecordingPacer: + async def run(self, key, op): + calls.append(key) + return await op() + + backend = FakeTtlBackend(reads=[None, 86400]) + + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + pacer=RecordingPacer(), + ) + + assert calls == ["ttl:5"] + + +@pytest.mark.asyncio +async def test_no_pacer_calls_the_backend_directly() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + ) + + assert backend.writes == [{"chat_id": 5, "period": 86400}] + + +@pytest.mark.asyncio +async def test_no_op_short_circuit_never_touches_the_pacer() -> None: + class ExplodingPacer: + async def run(self, key, op): + raise AssertionError("pacer must not be used for a no-op") + + backend = FakeTtlBackend(reads=[86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + pacer=ExplodingPacer(), + ) + + assert result.changed is False + + +# --- payload ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_to_dict_shape() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400, chat_name="Team"), + ) + + assert result.to_dict() == { + "chat_id": 5, + "chat_name": "Team", + "changed": True, + "dry_run": False, + "previous_ttl_seconds": None, + "requested_ttl_seconds": 86400, + "ttl_period": 86400, + } + + +@pytest.mark.asyncio +async def test_marked_chat_id_is_reported_bare() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=-1002305069221, period=86400), + ) + + assert result.chat_id == 2305069221 + # The backend still receives what the caller resolved. + assert backend.writes == [{"chat_id": -1002305069221, "period": 86400}] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_chats_set_ttl.py -q` +Expected: collection error — `ModuleNotFoundError: No module named 'telegram_assistant.chats.ttl'`. + +- [ ] **Step 3: Write `src/telegram_assistant/chats/ttl.py`** + +```python +"""Auto-delete period writes — the set-ttl op of the chats domain. + +Kept out of :mod:`telegram_assistant.chats.service` (which is the read-only +inspect op) the same way ``members/`` splits ``listing.py`` out of its own +``service.py``: one operation per module, READ and WRITE not mixed. Like +``notifications mute`` this opens no operation row and has no idempotency key — +the target is naturally idempotent. + +Three Telegram facts shape the order of operations here, all proven live on +2026-08-05 (see the spec): + +* every successful ``SetHistoryTTL`` posts a member-visible service message, + **including one that changes nothing** — hence the no-op short-circuit; +* the RPC's response may fail to parse while the write applied — hence the + unconditional read-back, which is the only authority on the result; +* the flood waits on this method escalate into the hundreds of seconds — hence + the pacer around the write. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Protocol + +from telegram_assistant.access.service import AccessLevel, Authorizer +from telegram_assistant.entities import EntityRef + +#: Largest period the wire accepts — ``ttl_period`` is a 32-bit int. +MAX_TTL_SECONDS = 2**31 - 1 + +#: Suffix multipliers for :func:`parse_ttl`. No ``y``/``mo``: a month is not a +#: fixed number of seconds, and guessing one silently would be worse than +#: making the caller write ``31d``. +_TTL_UNITS: dict[str, int] = { + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + "w": 604800, +} + +_TTL_PATTERN = re.compile(r"^(\d+)([smhdw]?)$") + + +def parse_ttl(value: str) -> int: + """Parse a CLI ``--ttl`` value into seconds. + + Accepts ``off`` (case-insensitive) and ``0`` for "auto-delete disabled", + ```` with unit ``s``/``m``/``h``/``d``/``w``, and a bare integer + read as seconds. Everything else raises :class:`ValueError` naming the + offending text. + + There is deliberately no allow-list of preset durations: Telegram's clients + offer only day/week/month, but real chats were found at 31, 93 and 180 days, + so arbitrary periods pass. The server is the authority on what it accepts. + """ + text = (value or "").strip() + if not text: + raise ValueError("--ttl must not be empty; use 'off' or a duration like 1d") + if text.lower() == "off": + return 0 + + match = _TTL_PATTERN.match(text) + if match is None: + raise ValueError( + f"cannot parse --ttl {value!r}; expected 'off' or " + f"with unit one of {', '.join(sorted(_TTL_UNITS))} (e.g. 1d, 24h, 93d)" + ) + + amount = int(match.group(1)) + unit = match.group(2) or "s" + seconds = amount * _TTL_UNITS[unit] + if seconds > MAX_TTL_SECONDS: + raise ValueError( + f"--ttl {value!r} is too large; the maximum is {MAX_TTL_SECONDS} seconds" + ) + return seconds + + +@dataclass(frozen=True) +class SetTtlRequest: + """Input to :func:`set_chat_ttl`. + + ``telegram_chat_id`` is the resolved numeric id in whatever shape the + surface produced it (marked ``-100…`` or bare) — the backend gets it + verbatim, the payload reports it bare. ``period`` is seconds, ``0`` meaning + auto-delete off. ``chat_name`` is carried through for the payload. + """ + + telegram_chat_id: int + period: int + chat_name: str | None = None + + +@dataclass(frozen=True) +class SetTtlResult: + """Outcome of :func:`set_chat_ttl`. + + ``previous_ttl_seconds`` and ``ttl_period`` are ``None`` when auto-delete is + off, never ``0`` — that is what ``chats inspect`` reports for the same + field, and the two commands must not disagree about one chat. ``ttl_period`` + is what the server returned on the read-back, never the requested value. + """ + + chat_id: int + requested_ttl_seconds: int + previous_ttl_seconds: int | None + ttl_period: int | None + changed: bool + dry_run: bool = False + chat_name: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "chat_id": self.chat_id, + "chat_name": self.chat_name, + "changed": self.changed, + "dry_run": self.dry_run, + "previous_ttl_seconds": self.previous_ttl_seconds, + "requested_ttl_seconds": self.requested_ttl_seconds, + "ttl_period": self.ttl_period, + } + + +class ChatTtlBackend(Protocol): + """Telethon-facing surface needed to read and write a chat's TTL. + + Deliberately narrower than ``ChatInspectBackend``: reading the whole + ``ChatInfo`` for one field would cost peer-kind dispatch, serialization and + ``access_hash`` redaction, and a test fake would have to be a whole + ``ChatInfo``. + """ + + async def get_ttl(self, *, chat_id: int) -> int | None: + ... + + async def set_ttl(self, *, chat_id: int, period: int) -> None: + ... + + +def _reported(period: int | None) -> int | None: + """Normalise a wire value to the reported one: ``0`` and ``None`` are off.""" + return period or None + + +def ttl_gate_key(chat_id: int) -> str: + """Gate key for TTL writes, kept next to the request that uses it. + + Re-exported from :mod:`telegram_assistant.messages.pacing` as + ``ttl_pacing_key``; defined there so all gate keys live together. + """ + from telegram_assistant.messages import ttl_pacing_key + + return ttl_pacing_key(chat_id) + + +async def set_chat_ttl( + *, + backend: ChatTtlBackend, + request: SetTtlRequest, + authorizer: Authorizer | None = None, + pacer: Any | None = None, + dry_run: bool = False, +) -> SetTtlResult: + """Set ``request.telegram_chat_id``'s auto-delete period. + + A WRITE op: when an ``authorizer`` is supplied it must grant WRITE on the + chat, checked before any Telegram call. + + Order: gate → read current → short-circuit when already equal → return on + ``dry_run`` → write (through ``pacer`` when supplied) → read back. The + read-back decides the result; a value disagreeing with the request raises + :class:`ValueError` naming both, because a server that clamped or dropped + the value must not be reported as success. + """ + if request.period < 0: + raise ValueError("ttl period must not be negative") + if request.period > MAX_TTL_SECONDS: + raise ValueError( + f"ttl period {request.period} is too large; " + f"the maximum is {MAX_TTL_SECONDS} seconds" + ) + + chat_id = request.telegram_chat_id + if authorizer is not None: + await authorizer.require(chat_id, AccessLevel.WRITE) + + bare_id = EntityRef(raw=int(chat_id)).numeric_id + current = _reported(await backend.get_ttl(chat_id=chat_id)) + wanted = _reported(request.period) + + if current == wanted: + # Telegram posts a service message on *every* successful set, so + # re-applying the value a chat already has is not free: a re-run over a + # folder would spam every chat in it. + return SetTtlResult( + chat_id=bare_id, + requested_ttl_seconds=request.period, + previous_ttl_seconds=current, + ttl_period=current, + changed=False, + dry_run=dry_run, + chat_name=request.chat_name, + ) + + if dry_run: + return SetTtlResult( + chat_id=bare_id, + requested_ttl_seconds=request.period, + previous_ttl_seconds=current, + ttl_period=current, + changed=True, + dry_run=True, + chat_name=request.chat_name, + ) + + async def _call() -> None: + await backend.set_ttl(chat_id=chat_id, period=request.period) + + if pacer is not None: + await pacer.run(ttl_gate_key(chat_id), _call) + else: + await _call() + + stored = _reported(await backend.get_ttl(chat_id=chat_id)) + if stored != wanted: + raise ValueError( + f"chat {bare_id}: requested ttl {request.period} but the server " + f"stored {stored if stored is not None else 0}" + ) + + return SetTtlResult( + chat_id=bare_id, + requested_ttl_seconds=request.period, + previous_ttl_seconds=current, + ttl_period=stored, + changed=True, + dry_run=False, + chat_name=request.chat_name, + ) + + +__all__ = [ + "MAX_TTL_SECONDS", + "ChatTtlBackend", + "SetTtlRequest", + "SetTtlResult", + "parse_ttl", + "set_chat_ttl", + "ttl_gate_key", +] +``` + +Note: `ttl_gate_key` imports `ttl_pacing_key` from `telegram_assistant.messages` — which Task 3 adds. Until Task 3 lands, the pacing tests in this task fail on import. To keep Task 1 independently green, **implement `ttl_gate_key` inline for now** and switch it to the re-export in Task 3: + +```python +def ttl_gate_key(chat_id: int) -> str: + """Gate key for TTL writes — bare id, so marked and bare ids share one row.""" + return f"ttl:{EntityRef(raw=int(chat_id)).numeric_id}" +``` + +Use the inline body in Task 1. Task 3 replaces it with the re-export. + +- [ ] **Step 4: Extend `src/telegram_assistant/chats/__init__.py`** + +Replace the whole file with: + +```python +"""Chat-wide operations: metadata inspection (read) and auto-delete TTL (write).""" + +from telegram_assistant.chats.service import ( + CHAT_KINDS, + ChatInfo, + ChatInspectBackend, + inspect_chat, +) +from telegram_assistant.chats.ttl import ( + MAX_TTL_SECONDS, + ChatTtlBackend, + SetTtlRequest, + SetTtlResult, + parse_ttl, + set_chat_ttl, +) + +__all__ = [ + "CHAT_KINDS", + "MAX_TTL_SECONDS", + "ChatInfo", + "ChatInspectBackend", + "ChatTtlBackend", + "SetTtlRequest", + "SetTtlResult", + "inspect_chat", + "parse_ttl", + "set_chat_ttl", +] +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_chats_set_ttl.py -q` +Expected: PASS, all tests. + +- [ ] **Step 6: Confirm the module imports no telethon** + +Run: `.venv/bin/python -c "import ast,sys; src=open('src/telegram_assistant/chats/ttl.py').read(); assert 'telethon' not in src, 'ttl.py must not reference telethon'; print('ok')"` +Expected: `ok` + +- [ ] **Step 7: Lint** + +Run: `.venv/bin/ruff check src tests` +Expected: `All checks passed!` + +- [ ] **Step 8: Full suite (nothing else may break)** + +Run: `.venv/bin/pytest -q` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/telegram_assistant/chats/ttl.py src/telegram_assistant/chats/__init__.py tests/test_chats_set_ttl.py +git commit -m "feat(chats): add the set-ttl domain op with a no-op short-circuit" +``` + +--- + +### Task 2: Telethon adapter — `TelethonChatTtlBackend` + +**Files:** +- Modify: `src/telegram_assistant/chats/telethon_backend.py` (append the class before `__all__` at line 464, and extend `__all__`) +- Test: `tests/test_chats_set_ttl_backend.py` + +**Interfaces:** +- Consumes from Task 1: nothing at runtime — the adapter satisfies `ChatTtlBackend` structurally (`get_ttl(*, chat_id) -> int | None`, `set_ttl(*, chat_id, period) -> None`). +- Consumes from the existing module: `translate_flood_wait` (already imported at `telethon_backend.py:17`). +- Produces for Task 4: `TelethonChatTtlBackend(client)`. + +Peer dispatch mirrors `TelethonChatInspectBackend.inspect_chat` (`telethon_backend.py:230-251`): `get_input_entity`, then branch on `type(peer).__name__` — `InputPeerChannel` → `channels.GetFullChannelRequest`, `InputPeerChat` → `messages.GetFullChatRequest`, `InputPeerUser`/`InputPeerSelf` → `users.GetFullUserRequest`. `ttl_period` lives on `.full_chat` for the first two and on `.full_user` for the third. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_chats_set_ttl_backend.py`: + +```python +"""Tests for the Telethon set-ttl adapter. + +Fakes are stand-ins whose class *names* match Telethon's, because that is what +the peer dispatch keys on — the same convention as +``tests/test_members_list_backend.py``. +""" + +from __future__ import annotations + +import pytest + +from telegram_assistant.chats.telethon_backend import TelethonChatTtlBackend + + +class InputPeerChannel: + def __init__(self, channel_id: int) -> None: + self.channel_id = channel_id + + +class InputPeerChat: + def __init__(self, chat_id: int) -> None: + self.chat_id = chat_id + + +class InputPeerUser: + def __init__(self, user_id: int) -> None: + self.user_id = user_id + + +class _FullChat: + def __init__(self, ttl_period) -> None: + self.ttl_period = ttl_period + + +class FullChannelResult: + def __init__(self, ttl_period) -> None: + self.full_chat = _FullChat(ttl_period) + + +class FullUserResult: + def __init__(self, ttl_period) -> None: + self.full_user = _FullChat(ttl_period) + + +class TypeNotFoundError(Exception): + """Name-matched by the adapter; Telethon raises this when a response + carries a constructor newer than the installed layer.""" + + +class FakeClient: + def __init__(self, *, peer, full=None, set_error=None) -> None: + self._peer = peer + self._full = full + self._set_error = set_error + self.requests: list[object] = [] + + async def get_input_entity(self, ref): + return self._peer + + async def __call__(self, request): + self.requests.append(request) + name = type(request).__name__ + if name in {"GetFullChannelRequest", "GetFullChatRequest", "GetFullUserRequest"}: + return self._full + if name == "SetHistoryTTLRequest": + if self._set_error is not None: + raise self._set_error + return object() + raise AssertionError(f"unexpected request {name}") + + @property + def request_names(self) -> list[str]: + return [type(r).__name__ for r in self.requests] + + +# --- get_ttl ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_ttl_reads_a_supergroup() -> None: + client = FakeClient(peer=InputPeerChannel(7), full=FullChannelResult(86400)) + backend = TelethonChatTtlBackend(client) + + assert await backend.get_ttl(chat_id=-1007) == 86400 + assert client.request_names == ["GetFullChannelRequest"] + + +@pytest.mark.asyncio +async def test_get_ttl_reads_a_basic_group() -> None: + client = FakeClient(peer=InputPeerChat(55), full=FullChannelResult(2678400)) + backend = TelethonChatTtlBackend(client) + + assert await backend.get_ttl(chat_id=-55) == 2678400 + assert client.request_names == ["GetFullChatRequest"] + + +@pytest.mark.asyncio +async def test_get_ttl_reads_a_user() -> None: + client = FakeClient(peer=InputPeerUser(9), full=FullUserResult(604800)) + backend = TelethonChatTtlBackend(client) + + assert await backend.get_ttl(chat_id=9) == 604800 + assert client.request_names == ["GetFullUserRequest"] + + +@pytest.mark.asyncio +async def test_get_ttl_returns_none_when_off() -> None: + client = FakeClient(peer=InputPeerChannel(7), full=FullChannelResult(None)) + backend = TelethonChatTtlBackend(client) + + assert await backend.get_ttl(chat_id=-1007) is None + + +@pytest.mark.asyncio +async def test_unsupported_peer_raises_value_error() -> None: + class InputPeerEmpty: + pass + + client = FakeClient(peer=InputPeerEmpty()) + backend = TelethonChatTtlBackend(client) + + with pytest.raises(ValueError) as exc: + await backend.get_ttl(chat_id=1) + assert "1" in str(exc.value) + + +# --- set_ttl ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_set_ttl_sends_the_request_with_the_period() -> None: + client = FakeClient(peer=InputPeerChannel(7), full=FullChannelResult(None)) + backend = TelethonChatTtlBackend(client) + + await backend.set_ttl(chat_id=-1007, period=86400) + + assert client.request_names == ["SetHistoryTTLRequest"] + assert client.requests[0].period == 86400 + + +@pytest.mark.asyncio +async def test_set_ttl_zero_disables() -> None: + client = FakeClient(peer=InputPeerChannel(7), full=FullChannelResult(None)) + backend = TelethonChatTtlBackend(client) + + await backend.set_ttl(chat_id=-1007, period=0) + + assert client.requests[0].period == 0 + + +@pytest.mark.asyncio +async def test_unparseable_response_is_swallowed() -> None: + """Proven live 2026-08-05: the write applied, only the response failed to + parse. Raising here would report a successful change as a failure — the + domain's read-back is what decides.""" + client = FakeClient( + peer=InputPeerChannel(7), + full=FullChannelResult(None), + set_error=TypeNotFoundError("Could not find a matching Constructor ID"), + ) + backend = TelethonChatTtlBackend(client) + + await backend.set_ttl(chat_id=-1007, period=0) # must not raise + + +@pytest.mark.asyncio +async def test_other_errors_propagate() -> None: + client = FakeClient( + peer=InputPeerChannel(7), + full=FullChannelResult(None), + set_error=RuntimeError("CHAT_ADMIN_REQUIRED"), + ) + backend = TelethonChatTtlBackend(client) + + with pytest.raises(RuntimeError): + await backend.set_ttl(chat_id=-1007, period=0) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_chats_set_ttl_backend.py -q` +Expected: `ImportError: cannot import name 'TelethonChatTtlBackend'`. + +- [ ] **Step 3: Append the adapter to `src/telegram_assistant/chats/telethon_backend.py`** + +Insert immediately before the final `__all__` line: + +```python +class TelethonChatTtlBackend: + """Adapter from the Telethon ``TelegramClient`` to ``ChatTtlBackend``. + + Two RPCs at most per call, and the peer dispatch is the same shape as + :class:`TelethonChatInspectBackend` — the ``ttl_period`` field lives on + ``full_chat`` for channels and basic groups, on ``full_user`` for users. + """ + + def __init__(self, client: Any) -> None: + self._client = client + + async def _peer(self, chat_id: int) -> tuple[Any, str]: + try: + peer = await self._client.get_input_entity(chat_id) + except Exception as exc: + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + raise + kind = type(peer).__name__ + if kind not in { + "InputPeerChannel", + "InputPeerChat", + "InputPeerUser", + "InputPeerSelf", + }: + raise ValueError( + f"chat {chat_id} has no auto-delete setting (resolved to {kind})" + ) + return peer, kind + + async def get_ttl(self, *, chat_id: int) -> int | None: + from telethon.errors import ChannelPrivateError + from telethon.tl import functions + + peer, kind = await self._peer(chat_id) + if kind == "InputPeerChannel": + request = functions.channels.GetFullChannelRequest(channel=peer) + attr = "full_chat" + elif kind == "InputPeerChat": + request = functions.messages.GetFullChatRequest(chat_id=peer.chat_id) + attr = "full_chat" + else: + request = functions.users.GetFullUserRequest(id=peer) + attr = "full_user" + + try: + result = await self._client(request) + except Exception as exc: + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + # Mirrors the inspect adapter: the peer resolved but Telegram + # refuses the Full fetch, which is caller-input-shaped (exit 2), + # not an internal error. + if isinstance(exc, ChannelPrivateError): + raise ValueError(f"chat {chat_id} is private or inaccessible") from exc + raise + + return getattr(getattr(result, attr, None), "ttl_period", None) + + async def set_ttl(self, *, chat_id: int, period: int) -> None: + from telethon.tl import functions + + peer, _kind = await self._peer(chat_id) + try: + await self._client( + functions.messages.SetHistoryTTLRequest(peer=peer, period=period) + ) + except Exception as exc: + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + # Proven live 2026-08-05 (Migragate): Telegram answered with a + # constructor newer than the installed layer, Telethon could not + # read it — and the write had applied. Treating that as a failure + # would report a successful change as an error; the domain's + # read-back is what decides. Matched by class *name* so no import + # of a Telethon-version-specific symbol is needed. + if type(exc).__name__ == "TypeNotFoundError": + return + raise +``` + +Then change the last line from `__all__ = ["TelethonChatInspectBackend"]` to: + +```python +__all__ = ["TelethonChatInspectBackend", "TelethonChatTtlBackend"] +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_chats_set_ttl_backend.py -q` +Expected: PASS. + +- [ ] **Step 5: Confirm the adapter really satisfies the protocol** + +Run: +```bash +.venv/bin/python -c " +from telegram_assistant.chats import ChatTtlBackend +from telegram_assistant.chats.telethon_backend import TelethonChatTtlBackend +import inspect +for name in ('get_ttl', 'set_ttl'): + assert hasattr(TelethonChatTtlBackend, name), name + assert inspect.iscoroutinefunction(getattr(TelethonChatTtlBackend, name)), name +print('ok') +" +``` +Expected: `ok` + +- [ ] **Step 6: Lint and full suite** + +Run: `.venv/bin/ruff check src tests && .venv/bin/pytest -q` +Expected: `All checks passed!` then PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/telegram_assistant/chats/telethon_backend.py tests/test_chats_set_ttl_backend.py +git commit -m "feat(chats): add the Telethon set-ttl adapter, tolerating unparseable responses" +``` + +--- + +### Task 3: Config keys and the TTL gate key + +**Files:** +- Modify: `src/telegram_assistant/messages/pacing.py` (add `ttl_pacing_key` next to `pin_pacing_key` at line 225, extend `__all__`) +- Modify: `src/telegram_assistant/messages/__init__.py` (import at line ~48, export at line ~237) +- Modify: `src/telegram_assistant/config/models.py` (three fields after `pin_min_interval_seconds`, line 257) +- Modify: `src/telegram_assistant/chats/ttl.py` (switch `ttl_gate_key` to the re-export) +- Test: `tests/test_chats_set_ttl.py` (append), `tests/test_config_models.py` if it exists — otherwise the config assertions go in `tests/test_chats_set_ttl.py` + +**Interfaces:** +- Consumes: `EntityRef` from `telegram_assistant.entities` (already imported in `pacing.py` for `pin_pacing_key`). +- Produces for Task 4: `ttl_pacing_key(chat_id: int) -> str`, and `TelegramConfig.ttl_min_interval_seconds: float`, `TelegramConfig.ttl_max_flood_wait_seconds: float`, `TelegramConfig.ttl_max_flood_wait_retries: int`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_chats_set_ttl.py`: + +```python +# --- gate key and config ---------------------------------------------------- + + +def test_ttl_pacing_key_uses_the_bare_id() -> None: + from telegram_assistant.messages import ttl_pacing_key + + assert ttl_pacing_key(-1002305069221) == "ttl:2305069221" + assert ttl_pacing_key(2305069221) == "ttl:2305069221" + + +def test_ttl_gate_key_matches_the_pacing_key() -> None: + from telegram_assistant.chats.ttl import ttl_gate_key + from telegram_assistant.messages import ttl_pacing_key + + assert ttl_gate_key(-1002305069221) == ttl_pacing_key(-1002305069221) + + +def test_ttl_gate_key_does_not_collide_with_the_pin_gate() -> None: + from telegram_assistant.messages import pin_pacing_key, ttl_pacing_key + + assert ttl_pacing_key(5) != pin_pacing_key(5) + + +def test_config_defaults_for_ttl_pacing(minimal_config_yaml) -> None: + from telegram_assistant.config.loader import load_config_from_text + + config = load_config_from_text(minimal_config_yaml, source="test") + + assert config.telegram.ttl_min_interval_seconds == 2.0 + assert config.telegram.ttl_max_flood_wait_seconds == 3600.0 + assert config.telegram.ttl_max_flood_wait_retries == 5 + + +def test_config_rejects_negative_ttl_interval(minimal_config_yaml) -> None: + from telegram_assistant.config.loader import ConfigError, load_config_from_text + + # The fixture's `telegram:` line is followed by 2-space-indented keys, so + # inserting one right after the header keeps the YAML valid. + text = minimal_config_yaml.replace( + "telegram:", "telegram:\n ttl_min_interval_seconds: -1", 1 + ) + with pytest.raises((ConfigError, ValueError)): + load_config_from_text(text, source="test") +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `.venv/bin/pytest tests/test_chats_set_ttl.py -q -k "ttl_pacing_key or gate_key or config"` +Expected: `ImportError: cannot import name 'ttl_pacing_key'` and attribute errors on the config. + +- [ ] **Step 3: Add `ttl_pacing_key` to `src/telegram_assistant/messages/pacing.py`** + +Insert immediately after the `pin_pacing_key` function (which ends at line 237 with `return f"pin:{EntityRef(raw=int(chat_id)).numeric_id}"`): + +```python +def ttl_pacing_key(chat_id: int) -> str: + """Gate key for auto-delete (TTL) writes — a different Telegram limit than pins. + + Its own gate row rather than sharing ``pin:``: Telegram meters + ``messages.SetHistoryTTL`` separately, and observed waits on it escalate far + past anything pins produce (261s, 703s and 866s within one hour on one + account, 2026-08-05). Sharing a row would let a slow TTL sweep throttle + unrelated pins and vice versa. + + The id is reduced to its bare form for the same reason ``pin_pacing_key`` + does it: an explicit ``--chat-id -1001234567890`` keeps the marker while an + ``--entity`` lookup yields the bare id, and keying on the raw value would + open two independent rows for one chat. + """ + return f"ttl:{EntityRef(raw=int(chat_id)).numeric_id}" +``` + +Add `"ttl_pacing_key"` to `pacing.py`'s `__all__`, keeping it alphabetically placed next to `"retry_after_details"`. + +- [ ] **Step 4: Export it from `src/telegram_assistant/messages/__init__.py`** + +Add `ttl_pacing_key` to the `from telegram_assistant.messages.pacing import (...)` block (after `retry_after_details`, line ~49) and `"ttl_pacing_key"` to the module `__all__` (after `"retry_after_details"`, line ~238). + +- [ ] **Step 5: Add the three config fields** + +In `src/telegram_assistant/config/models.py`, insert after the `pin_min_interval_seconds` field (ends line 257) and before `download_root`: + +```python + ttl_min_interval_seconds: float = Field( + default=2.0, + ge=0.0, + description=( + "Minimum seconds between two `chats set-ttl` writes on the same " + "chat, paced through the same shared SQLite gate as pins but on a " + "separate row (Telegram meters SetHistoryTTL separately). " + "0 disables pacing." + ), + ) + ttl_max_flood_wait_seconds: float = Field( + default=3600.0, + ge=0.0, + description=( + "Longest single FLOOD_WAIT `chats set-ttl` will sleep through. " + "Waits on SetHistoryTTL escalate into the hundreds of seconds " + "(261s, 703s and 866s observed within one hour), so the default is " + "far above the 60s used elsewhere — but finite, so a stuck call " + "cannot hang unnoticed forever." + ), + ) + ttl_max_flood_wait_retries: int = Field( + default=5, + ge=1, + description=( + "How many FLOOD_WAIT pauses `chats set-ttl` will sit through before " + "giving up. Above the pacer's own default of 3 because the waits " + "escalate: one chat can plausibly spend two or three of them, and " + "running out reports a flood wait as a failure on a call that was " + "about to succeed." + ), + ) +``` + +- [ ] **Step 6: Switch `ttl_gate_key` in `chats/ttl.py` to the re-export** + +Replace the inline body written in Task 1 with: + +```python +def ttl_gate_key(chat_id: int) -> str: + """Gate key for TTL writes. + + Delegates to :func:`telegram_assistant.messages.pacing.ttl_pacing_key` so + every gate key lives in one module; imported lazily to keep this module free + of an import-time dependency on ``messages``. + """ + from telegram_assistant.messages import ttl_pacing_key + + return ttl_pacing_key(chat_id) +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_chats_set_ttl.py -q` +Expected: PASS (including the Task 1 tests, which must still pass with the re-export in place). + +- [ ] **Step 8: Lint and full suite** + +Run: `.venv/bin/ruff check src tests && .venv/bin/pytest -q` +Expected: `All checks passed!` then PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/telegram_assistant/messages/pacing.py src/telegram_assistant/messages/__init__.py src/telegram_assistant/config/models.py src/telegram_assistant/chats/ttl.py tests/test_chats_set_ttl.py +git commit -m "feat(config): add ttl pacing knobs and a dedicated ttl gate key" +``` + +--- + +### Task 4: The `chats set-ttl` CLI command + +**Files:** +- Modify: `src/telegram_assistant/cli/main.py` (new `_build_chat_ttl_backends` and `_cli_ttl_pacer` beside `_build_chat_inspect_backends` at line 3413; the command after `chats_inspect`, which ends line 3556) +- Test: `tests/test_cli_chats_set_ttl.py` + +**Interfaces:** +- Consumes from Task 1: `parse_ttl`, `SetTtlRequest`, `set_chat_ttl` from `telegram_assistant.chats`. +- Consumes from Task 2: `TelethonChatTtlBackend`. +- Consumes from Task 3: `config.telegram.ttl_min_interval_seconds`, `ttl_max_flood_wait_seconds`, `ttl_max_flood_wait_retries`. +- Consumes from the existing CLI: `_load_config_or_exit`, `_resolve_folder_name`, `_cli_authorizer`, `_raise_for_access_or_entity_error`, `_raise_for_flood_wait`, `default_database_path`, `TelethonSessionManager`. +- Produces: nothing consumed by later tasks except the command name `chats set-ttl`, which Task 5 documents. + +The `--dry-run` payload uses the project-wide envelope (`{"status": "dry_run", "dry_run": true, "command": ..., "would": ..., "resolved": {...}, "planned_actions": [...], "warnings": []}`) that all 20 other dry-run sites in `cli/main.py` emit — the skill tells the agent to look for `status = dry_run`. The domain's `SetTtlResult` fields go inside `resolved`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_cli_chats_set_ttl.py`: + +```python +"""CLI tests for `chats set-ttl`.""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from telegram_assistant.access import AccessDenied, AccessLevel +from telegram_assistant.cli import main as cli_main +from telegram_assistant.entities import EntityNotFoundError + +runner = CliRunner() + + +class FakeTtlBackend: + def __init__(self, reads=None, set_error=None) -> None: + self._reads = list(reads if reads is not None else [None, None]) + self.set_error = set_error + self.calls: list[tuple[str, dict]] = [] + + async def get_ttl(self, *, chat_id: int): + self.calls.append(("get_ttl", {"chat_id": chat_id})) + return self._reads.pop(0) if self._reads else None + + async def set_ttl(self, *, chat_id: int, period: int) -> None: + self.calls.append(("set_ttl", {"chat_id": chat_id, "period": period})) + if self.set_error is not None: + raise self.set_error + + @property + def writes(self): + return [args for name, args in self.calls if name == "set_ttl"] + + +class FakeResolved: + def __init__(self, chat_id: int) -> None: + self.chat_id = chat_id + + +class FakeResolver: + def __init__(self, chat_id: int = 5, error: Exception | None = None) -> None: + self.chat_id = chat_id + self.error = error + + async def resolve(self, ref: str): + if self.error is not None: + raise self.error + return FakeResolved(self.chat_id) + + +class FakeManager: + async def disconnect(self) -> None: + return None + + +@pytest.fixture +def wire(monkeypatch, minimal_config_yaml, tmp_path): + config_path = tmp_path / "config.yml" + config_path.write_text(minimal_config_yaml, encoding="utf-8") + + def _install(backend, resolver=None, authorizer=None): + config = cli_main._load_config_or_exit(config_path) + + def _build(_path): + async def _open(): + return backend, object(), resolver or FakeResolver() + + return config, FakeManager(), _open + + monkeypatch.setattr(cli_main, "_build_chat_ttl_backends", _build) + if authorizer is not None: + monkeypatch.setattr(cli_main, "_cli_authorizer", lambda *a, **k: authorizer) + return config_path + + return _install + + +# --- flag validation -------------------------------------------------------- + + +def test_requires_exactly_one_reference(wire): + config_path = wire(FakeTtlBackend()) + + result = runner.invoke( + cli_main.app, + ["chats", "set-ttl", "--ttl", "off", "--config", str(config_path)], + ) + + assert result.exit_code == 2 + assert "exactly one of --chat-id, --chat-name, or --entity" in result.output + + +def test_rejects_two_references(wire): + config_path = wire(FakeTtlBackend()) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--entity", "@team", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + + +def test_unparseable_ttl_exits_2_before_any_rpc(wire): + backend = FakeTtlBackend() + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "1y", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + assert "1y" in result.output + assert backend.calls == [] + + +# --- happy paths ------------------------------------------------------------ + + +def test_sets_a_period_and_prints_the_payload(wire): + backend = FakeTtlBackend(reads=[None, 8035200]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "93d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["chat_id"] == 5 + assert payload["requested_ttl_seconds"] == 8035200 + assert payload["previous_ttl_seconds"] is None + assert payload["ttl_period"] == 8035200 + assert payload["changed"] is True + assert payload["dry_run"] is False + assert backend.writes == [{"chat_id": 5, "period": 8035200}] + + +def test_off_reports_null_ttl(wire): + backend = FakeTtlBackend(reads=[2678400, None]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["ttl_period"] is None + assert payload["previous_ttl_seconds"] == 2678400 + assert payload["changed"] is True + + +def test_no_op_reports_unchanged_without_writing(wire): + backend = FakeTtlBackend(reads=[86400]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "1d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["changed"] is False + assert backend.writes == [] + + +def test_entity_reference_is_resolved(wire): + backend = FakeTtlBackend(reads=[None, 86400]) + config_path = wire(backend, resolver=FakeResolver(chat_id=77)) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--entity", "@team", + "--ttl", "1d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + assert backend.writes == [{"chat_id": 77, "period": 86400}] + + +# --- dry run ---------------------------------------------------------------- + + +def test_dry_run_emits_the_standard_envelope_and_writes_nothing(wire): + backend = FakeTtlBackend(reads=[2678400]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--dry-run", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "dry_run" + assert payload["dry_run"] is True + assert payload["command"] == "chats.set-ttl" + assert payload["resolved"]["previous_ttl_seconds"] == 2678400 + assert payload["resolved"]["requested_ttl_seconds"] == 0 + assert payload["resolved"]["changed"] is True + assert payload["planned_actions"] + assert backend.writes == [] + + +def test_dry_run_of_a_no_op_says_so(wire): + backend = FakeTtlBackend(reads=[None]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--dry-run", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["resolved"]["changed"] is False + assert payload["planned_actions"] == [] + assert payload["warnings"] + + +# --- error ladder ----------------------------------------------------------- + + +def test_access_denied_exits_3(wire): + class Denying: + async def require(self, chat_id, level): + raise AccessDenied(chat_ref=chat_id, required_level=level) + + backend = FakeTtlBackend() + config_path = wire(backend, authorizer=Denying()) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 3 + assert "access denied" in result.output + + +def test_unresolvable_entity_exits_2(wire): + config_path = wire( + FakeTtlBackend(), + resolver=FakeResolver(error=EntityNotFoundError("no such chat")), + ) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--entity", "@ghost", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + assert "no such chat" in result.output + + +def test_read_back_mismatch_exits_2(wire): + backend = FakeTtlBackend(reads=[None, 2678400]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "93d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + assert "8035200" in result.output + assert "2678400" in result.output + + +def test_paced_flood_wait_exits_1_with_retry_after(wire): + from telegram_assistant.messages import PacedFloodWaitError + + backend = FakeTtlBackend( + reads=[None, None], + set_error=PacedFloodWaitError( + 866, retry_after_seconds=871.0, retry_at=1.0, attempts=5 + ), + ) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 1 + assert "Retry after" in result.output + + +def test_unexpected_error_exits_1(wire): + backend = FakeTtlBackend(reads=[None, None], set_error=RuntimeError("boom")) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "1d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 1 + assert "chats set-ttl failed: boom" in result.output +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `.venv/bin/pytest tests/test_cli_chats_set_ttl.py -q` +Expected: `AttributeError: module ... has no attribute '_build_chat_ttl_backends'`. + +- [ ] **Step 3: Widen the `chats` group help and add the builders** + +In `src/telegram_assistant/cli/main.py`, change the group help at line 3407-3409 from `help="Read chat metadata."` to: + +```python +chats_app = typer.Typer( + help="Read chat metadata and set the auto-delete period.", no_args_is_help=True +) +``` + +Then add, immediately after `_build_chat_inspect_backends` (which ends line 3442): + +```python +def _build_chat_ttl_backends(config_path: Path | None): + """Open the Telethon-backed chat-TTL + folder backends + resolver. + + Same shape as :func:`_build_chat_inspect_backends`, with the write adapter + in place of the read one. Tests monkeypatch this to inject fakes. + """ + config = _load_config_or_exit(config_path) + manager = TelethonSessionManager(config.telegram) + + async def _open(): + from telegram_assistant.chats.telethon_backend import TelethonChatTtlBackend + from telegram_assistant.entities import TelethonEntityResolver + from telegram_assistant.folders import TelethonFolderBackend + + client = await manager.get_client() + if not await client.is_user_authorized(): + raise RuntimeError( + "Telethon session is not authorized; run " + "`telegram-assistant auth` first." + ) + return ( + TelethonChatTtlBackend(client), + TelethonFolderBackend(client), + TelethonEntityResolver(client), + ) + + return config, manager, _open + + +def _cli_ttl_pacer(config): + """Build the auto-delete pacer for a CLI invocation. + + Mirrors :func:`_cli_pin_pacer` but on the TTL gate row and with the far + higher wait ceiling this method needs: FLOOD_WAITs on SetHistoryTTL run into + the hundreds of seconds, and the operator's choice was to sit through them. + """ + from telegram_assistant.messages import Pacer + + interval = float(getattr(config.telegram, "ttl_min_interval_seconds", 0.0)) + max_wait = float(getattr(config.telegram, "ttl_max_flood_wait_seconds", 3600.0)) + retries = int(getattr(config.telegram, "ttl_max_flood_wait_retries", 5)) + gate = None + if interval > 0: + from telegram_assistant.persistence.rate_gate import RateGateStore + + try: + gate = RateGateStore(default_database_path(config)) + except Exception: + gate = None + return Pacer( + gate, + min_interval_seconds=interval, + max_flood_wait_seconds=max_wait, + max_flood_wait_retries=retries, + ) +``` + +- [ ] **Step 4: Add the command after `chats_inspect`** + +Append after `chats_inspect` (which ends at line 3556): + +```python +@chats_app.command("set-ttl") +def chats_set_ttl( + ttl: str = typer.Option( + ..., + "--ttl", + help="New auto-delete period: 'off' (or 0), or with " + "unit s/m/h/d/w (e.g. 1d, 24h, 93d). A bare integer is seconds.", + ), + chat_id: int | None = typer.Option( + None, + "--chat-id", + help="Numeric Telegram chat id to change.", + ), + chat_name: str | None = typer.Option( + None, + "--chat-name", + help="Chat title (resolved within --folder-name).", + ), + entity: str | None = typer.Option( + None, + "--entity", + help="Flexible entity reference (numeric id, @username, t.me/invite link, " + "phone, or exact title) resolved via the shared resolver.", + ), + folder_name: str | None = typer.Option( + None, + "--folder-name", + help="Folder used for --chat-name lookup " + "(defaults to telegram.default_chat_folder.folder_name).", + ), + folder_id: int | None = typer.Option( + None, + "--folder-id", + help="Optional folder id cross-check.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Resolve the chat and report the change without writing.", + ), + config_path: Path | None = typer.Option( # noqa: B008 + None, + "--config", + "-c", + help="Path to config.yml (defaults: ./data/config.yml, then ~/.config/telegram-assistant/config.yml).", + exists=False, + ), +) -> None: + """Set one chat's auto-delete period (WRITE-gated). + + Setting the period a chat already has writes nothing: Telegram posts a + member-visible service message on every successful change, including a + no-op one. + """ + from telegram_assistant.chats import SetTtlRequest, parse_ttl, set_chat_ttl + from telegram_assistant.folders import FolderError, resolve_chat_in_folder + + refs = sum([chat_id is not None, chat_name is not None, entity is not None]) + if refs != 1: + typer.echo( + "exactly one of --chat-id, --chat-name, or --entity must be supplied", + err=True, + ) + raise typer.Exit(code=2) + + try: + period = parse_ttl(ttl) + except ValueError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + + config, manager, open_backends = _build_chat_ttl_backends(config_path) + + if chat_name is not None: + resolved_folder_name, default_fid, _ = _resolve_folder_name( + folder_name, config_path + ) + effective_folder_id = folder_id if folder_id is not None else default_fid + else: + resolved_folder_name = folder_name + effective_folder_id = folder_id + + async def _run() -> dict[str, object]: + try: + ttl_backend, folder_backend, resolver = await open_backends() + if entity is not None: + resolved_chat_id = (await resolver.resolve(entity)).chat_id + resolved_name = entity + elif chat_id is not None: + resolved_chat_id = chat_id + resolved_name = None + else: + resolved = await resolve_chat_in_folder( + folder_backend, + folder_name=resolved_folder_name or "", + chat_name=chat_name or "", + folder_id=effective_folder_id, + ) + resolved_chat_id = resolved.chat_id + resolved_name = chat_name + + authorizer = _cli_authorizer( + config, resolver=resolver, folder_backend=folder_backend + ) + result = await set_chat_ttl( + backend=ttl_backend, + request=SetTtlRequest( + telegram_chat_id=resolved_chat_id, + period=period, + chat_name=resolved_name, + ), + authorizer=authorizer, + pacer=_cli_ttl_pacer(config), + dry_run=dry_run, + ) + return result.to_dict() + finally: + try: + await manager.disconnect() + except Exception: + pass + + try: + payload = asyncio.run(_run()) + except FolderError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + except ValueError as exc: + # Bad input, an uninspectable peer, or a read-back that disagreed with + # the request — all caller-facing, so exit 2 rather than the internal + # exit 1. AccessDenied/EntityError are RuntimeErrors and fall through. + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + except Exception as exc: + _raise_for_access_or_entity_error(exc) + _raise_for_flood_wait(exc, "chats set-ttl") + typer.echo(f"chats set-ttl failed: {exc}", err=True) + raise typer.Exit(code=1) from exc + + if dry_run: + target = payload["chat_id"] + scope = "off" if period == 0 else f"{period}s" + changed = bool(payload["changed"]) + action = f"set auto-delete of chat {target} to {scope}" + envelope = { + "status": "dry_run", + "dry_run": True, + "command": "chats.set-ttl", + "would": action if changed else f"leave chat {target} unchanged", + "resolved": payload, + "planned_actions": [action] if changed else [], + "warnings": ( + [] + if changed + else [ + f"chat {target} already has this auto-delete period; " + "no write would be issued (Telegram posts a visible service " + "message on every successful change)" + ] + ), + } + typer.echo(json.dumps(envelope, sort_keys=True, default=str)) + return + + typer.echo(json.dumps(payload, sort_keys=True, default=str)) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_cli_chats_set_ttl.py -q` +Expected: PASS. + +- [ ] **Step 6: Confirm the command is registered** + +Run: `.venv/bin/telegram-assistant chats --help` +Expected: output lists both `inspect` and `set-ttl`. + +- [ ] **Step 7: Confirm `--ttl` is required** + +Run: `.venv/bin/telegram-assistant chats set-ttl --chat-id 5; echo "exit=$?"` +Expected: a Typer "Missing option '--ttl'" message and `exit=2`. + +- [ ] **Step 8: Lint and full suite** + +Run: `.venv/bin/ruff check src tests && .venv/bin/pytest -q` +Expected: `All checks passed!`; the suite passes **except** `tests/test_skill_inventory.py`, which now fails because the CLI has a command SKILL.md does not list. That is expected and is closed by Task 5. + +- [ ] **Step 9: Commit** + +```bash +git add src/telegram_assistant/cli/main.py tests/test_cli_chats_set_ttl.py +git commit -m "feat(cli): add chats set-ttl with dry-run and ttl pacing" +``` + +--- + +### Task 5: Documentation — SKILL.md, README, skill sync + +**Files:** +- Modify: `skills/telegram-assistant/SKILL.md` (catalog row after line 223; confirmation bucket 2 at lines 74-84; the `--dry-run` supported set in algorithm step 7; a `#### chats / set-ttl` extraction section after the `chats / inspect` one ending line 580; a scenario after `### chats inspect` at line 1495) +- Modify: `README.md` (after the `chats inspect` bullet, line 105) +- Copy: `~/.claude/skills/telegram-assistant/SKILL.md` +- Test: `tests/test_skill_inventory.py` (existing guard — no new test file) + +**Interfaces:** +- Consumes: the command name `chats set-ttl` and its flags from Task 4. +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Run the guard to see it fail** + +Run: `.venv/bin/pytest tests/test_skill_inventory.py -q` +Expected: FAIL — `chats set-ttl` is in the CLI but not in the SKILL.md catalog. + +- [ ] **Step 2: Add the catalog row** + +In `skills/telegram-assistant/SKILL.md`, immediately after the `chats` / `inspect` row (line 223), add: + +```markdown +| `chats` | `set-ttl` | Change a chat's auto-delete period (`--ttl off\|1d\|93d`, WRITE-gated; `--dry-run`). Setting the period the chat already has writes nothing. | `telegram-assistant chats set-ttl ...` | +``` + +- [ ] **Step 3: Add it to confirmation bucket 2** + +In the bucket-2 list (lines 74-80), add `chats set-ttl` after `folders remove-chat`: + +```markdown +2. **State-changing, single object** — `groups create`, `groups set-layout`, + `groups rename`, `topics create`, `topics close`, `topics open`, + `topics rename`, + `messages send` (single chat), + `messages react`, `messages forward`, `notifications mute`, + `notifications unmute`, `folders add-chat`, `folders remove-chat`, + `chats set-ttl`, + `operations retry`. Always: +``` + +- [ ] **Step 4: Add it to the `--dry-run` supported set in algorithm step 7** + +Find the sentence beginning "The supported set is: `groups create`," and add `chats set-ttl` to that list, after `folders remove-chat`. + +- [ ] **Step 5: Add the extraction section** + +After the `#### chats / inspect` section (which ends around line 580), add: + +```markdown +#### `chats` / `set-ttl` + +- Extract: chat reference (`--chat-id` / `--chat-name` / `--entity`) and the new + period (`--ttl`). +- Required flags: exactly one chat reference, plus `--ttl`. +- From config: `--folder-name` default when resolving `--chat-name`. +- Temp file: no. +- Automation: none. This is a bucket-2 state change — always `--dry-run` first, + show the plan, confirm via `AskUserQuestion`, then run for real. +- `--ttl` values: `off` (or `0`) disables auto-delete; otherwise + `` with unit `s`/`m`/`h`/`d`/`w` (`1d`, `24h`, `93d`, `2w`); a + bare integer is seconds. Telegram's own clients offer only day/week/month, but + arbitrary periods are accepted — real chats have been found at 31, 93 and 180 + days. There is no preset allow-list; an unacceptable value is rejected by the + server, not by the CLI. +- Payload: `chat_id` (bare), `chat_name`, `requested_ttl_seconds`, + `previous_ttl_seconds`, `ttl_period`, `changed`, `dry_run`. + `previous_ttl_seconds` and `ttl_period` are `null` when auto-delete is off, + never `0` — the same spelling `chats inspect` uses. `ttl_period` is what the + server reported **after** the write, not what was asked for. +- **Every successful change posts a service message into the chat**, visible to + all members. Say so in the plan before asking for confirmation — in a client + chat that message is seen by the client. +- Setting the period a chat already has is a no-op: `changed: false`, no write, + no service message. The `--dry-run` envelope says so in `warnings` and leaves + `planned_actions` empty. Do not "re-apply to be sure" — that is exactly what + would spam the chat. +- Slowness is expected. Telegram flood-waits this method hard and the waits + escalate (261s, 703s and 866s were observed within one hour on one account). + The command sits through them by design, up to + `telegram.ttl_max_flood_wait_seconds` (default 3600) and + `telegram.ttl_max_flood_wait_retries` (default 5). A command that appears to + hang for minutes is normal — do not kill and retry it, and never run two at + once against the same account. +- Sweeping a folder: loop the command over the chat ids from `folders inspect`, + one chat per call, sequentially. There is no bulk mode and no folder flag — + and running several in parallel makes the flood waits worse. +- Other surfaces: none. This is **CLI-only** — there is no HTTP route and no MCP + tool for it. +- Typical errors: `cannot parse --ttl ...` (exit 2), `access denied ...` + (exit 3), `chat N: requested ttl X but the server stored Y` (exit 2 — the + server refused or clamped the value), `chats set-ttl rate-limited by + Telegram ... Retry after Ns` (exit 1). +``` + +- [ ] **Step 6: Add the scenario** + +After the `### chats inspect` scenario block, add: + +```markdown +### `chats set-ttl` + +1. Resource/action: `chats` / `set-ttl`. **Bucket 2** — never run it without a + confirmed dry-run. +2. Read the current state first with `chats inspect` and show it: the human + should see what the period is now before deciding what it becomes. +3. Dry-run: + +```bash +telegram-assistant chats set-ttl --entity 2305069221 --ttl off --dry-run +``` + +4. Show the plan with three things named explicitly: the chat, the old and new + periods, and that a service message will appear in the chat for everyone to + see. If the dry-run reports `changed: false`, stop — there is nothing to do, + and re-applying would post that message for no reason. +5. Confirm via `AskUserQuestion`, then re-run without `--dry-run`. +6. Expect it to be slow. Flood waits on this method run into minutes; the + command waits them out on purpose. Do not start a second one in parallel. +7. For several chats, loop one call per chat over the ids from `folders + inspect`, sequentially — and say up front how many chats will each get a + service message. +``` + +- [ ] **Step 7: Add the README entry** + +In `README.md`, after the `chats inspect` bullet (line 105), add: + +```markdown +- `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 `` 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. +``` + +Also update the last sentence of the `chats inspect` bullet, which currently reads "It reads only — there is no command to change any of these settings." Replace it with: "It reads only; `chats set-ttl` is the one write counterpart, and it covers `ttl_period` alone." + +- [ ] **Step 8: Sync the skill** + +```bash +cp skills/telegram-assistant/SKILL.md ~/.claude/skills/telegram-assistant/SKILL.md +diff -q skills/telegram-assistant/SKILL.md ~/.claude/skills/telegram-assistant/SKILL.md +``` +Expected: no output from `diff` (files identical). + +- [ ] **Step 9: Run the guard to verify it passes** + +Run: `.venv/bin/pytest tests/test_skill_inventory.py -q` +Expected: PASS. + +- [ ] **Step 10: Full suite and lint** + +Run: `.venv/bin/ruff check src tests && .venv/bin/pytest -q` +Expected: `All checks passed!` then PASS, whole suite green. + +- [ ] **Step 11: Commit** + +```bash +git add skills/telegram-assistant/SKILL.md README.md +git commit -m "docs: document chats set-ttl in the skill catalog and README" +``` + +--- + +## Out of scope (deliberately) + +- **HTTP route and MCP tool.** The user's constraint. `EXPECTED_TOOL_NAMES` in `tests/test_mcp_mount.py` is not touched. +- **Bulk / folder sweep.** The caller loops. +- **Live verification.** The spec's Saved-Messages check is mutating and needs explicit human approval; it is not part of this plan and must not be run on the implementer's initiative. +- **A `--wait` / `--max-wait` flag.** The decision was to always wait; the ceiling is config, not a flag. + +## Self-review notes + +Checked against the spec, 2026-08-06: + +- Every spec section maps to a task: CLI surface → Task 4; `parse_ttl` and payload → Task 1; module layout → Tasks 1-2; order of operations (gate, read, no-op, dry-run, write, read-back, mismatch) → Task 1 with tests per clause; pacing keys and the three config knobs → Task 3; `TypeNotFoundError` tolerance → Task 2; access → Task 1; error ladder → Task 4; testing trio → Tasks 1, 2, 4; docs → Task 5. +- One layering the spec left implicit is resolved here: the domain returns a `SetTtlResult` with `dry_run=True`, and the CLI wraps it in the project-wide `status: dry_run` envelope that the other 20 dry-run sites emit, with the result under `resolved`. Emitting the bare result would have broken the "look for `status = dry_run`" instruction the skill gives the agent. +- `ttl_gate_key` is written inline in Task 1 and switched to a re-export in Task 3, so each task is independently green rather than Task 1 depending on a module Task 3 creates. +- Task 4 knowingly leaves `tests/test_skill_inventory.py` red; Task 5 closes it. That is stated in Task 4 Step 8 so a reviewer does not read it as a regression. diff --git a/docs/superpowers/specs/2026-08-05-chats-inspect-design.md b/docs/superpowers/specs/2026-08-05-chats-inspect-design.md new file mode 100644 index 0000000..169af47 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-chats-inspect-design.md @@ -0,0 +1,238 @@ +# `chats inspect` — read-only chat metadata + +**Date:** 2026-08-05 +**Status:** approved, not implemented + +## Problem + +Nothing in the project can answer "what is the auto-delete setting of chat +2305069221". `folders inspect` returns two fields per chat (`chat_id`, +`title`) by design — it builds its list from `InputPeer` ids without a +per-chat lookup — and `groups get-layout` reads one boolean. Everything else +Telegram knows about a chat (TTL, description, slow mode, member counts, +restrictions, our own admin rights) is unreachable without leaving the CLI, +which the operating skill forbids. + +The immediate trigger is `ttl_period`, but a single command that answers "what +is this chat" is worth more than a one-field probe: the same two RPCs already +carry the answers to the questions that keep coming up (why does this chat +reject sends, how many members does it have, is it a forum, are we admin). + +## Scope + +**In:** one read-only CLI command, `chats inspect`, plus the domain module +behind it. Supergroups/channels, legacy basic groups, users and bots. + +**Out:** writing any of it back (`set-ttl`, archive, description) — that is a +WRITE operation with its own dry-run and gate, and belongs to a separate +change. No folder-wide sweep: the caller loops over chat ids, same as +`members list --user`. + +Delivery is two-phase by explicit request: **phase 1 is CLI-only** and gets +verified against the live account before **phase 2** adds HTTP and MCP. + +## Architecture + +A new package `src/telegram_assistant/chats/`, shaped like +`members/listing.py`: a READ op with no operation row, no idempotency key and +no `--dry-run`. + +``` +src/telegram_assistant/chats/ + __init__.py # re-exports + service.py # ChatInfo, ChatInspectBackend, inspect_chat() + telethon_backend.py # TelethonChatInspectBackend +``` + +- `service.py` holds `ChatInfo` (frozen dataclass with `to_dict()`), the + `ChatInspectBackend` protocol (`inspect_chat(*, chat_id: int, raw: bool) -> + ChatInfo`) and `inspect_chat(*, backend, chat_id, raw=False, + authorizer=None)`. The authorizer, when supplied, must grant `READ` on the + chat, and is checked **before any Telegram call** — the payload carries the + description, the member list size and the invite link, so a denied caller + must not cost a round trip nor learn the chat exists. +- `telethon_backend.py` resolves the peer once via `get_entity`, dispatches on + its type to `GetFullChannelRequest` / `GetFullChatRequest` / + `GetFullUserRequest`, and maps the pair into `ChatInfo`. Two RPCs maximum, + no dialog walk. The shallow half of a channel's fields is read from the + `Channel` inside the Full response's own `chats` list rather than from the + resolved entity — `forum_tabs` is a flag on `Channel` (flags2.19), not on + `ChannelFull`, and `groups/telethon_backend.py::get_topics_layout` already + resolves it that way, matching it by `full_chat.id`. +- The CLI adds a `chats` Typer group with one command, wired by a + `_build_chat_inspect_backends(config_path)` helper mirroring + `_build_member_list_backends`. + +Splitting this into its own package rather than extending `groups/` is +deliberate: `groups/` is about creating and administering supergroups, while +this command answers for users and channels too. The package is also the +natural home for the chat-wide write ops that are explicitly out of scope now. + +## CLI surface + +``` +telegram-assistant chats inspect \ + (--chat-id N | --chat-name TITLE | --entity REF) \ + [--folder-name NAME] [--folder-id N] [--raw] [--config PATH] +``` + +Exactly one chat reference, same rule and same wording as `members list`. +`--folder-name` / `--folder-id` only matter for `--chat-name` resolution and +default from `telegram.default_chat_folder`. Output is a single JSON object on +stdout via `json.dumps(payload, sort_keys=True, default=str)`. + +## Payload + +One flat shape for every chat kind, with `None` in the fields that do not +apply, so `jq .ttl_period` works regardless of what was inspected. Three +values are naturally nested objects: `my_admin_rights`, +`default_banned_rights`, and the notification settings, which are flattened +into `muted` / `muted_until`. + +Common to all kinds: + +`chat_id` (bare, no `-100` marker, matching `EntityRef.numeric_id`), `kind` +(`user` | `bot` | `basic_group` | `supergroup` | `channel`), `title`, +`username`, `usernames`, `about`, **`ttl_period`**, `pinned_message_id`, +`archived`, `muted`, `muted_until`, `has_scheduled`, `restricted`, +`restriction_reason`, `verified`, `scam`, `fake`, `is_creator`, `left`, +`created_at`, `invite_link`, `my_admin_rights`, `default_banned_rights`. + +Supergroups and channels add: `is_forum`, `topics_layout` (`list` | `tabs`, +from `forum_tabs` — the same value `groups get-layout` reports), `broadcast`, +`megagroup`, `gigagroup`, `participants_count`, `admins_count`, +`kicked_count`, `banned_count`, `online_count`, `slowmode_seconds`, +`slowmode_next_send_date`, `linked_chat_id`, `migrated_from_chat_id`, +`hidden_prehistory`, `participants_hidden`, `antispam`, +`can_view_participants`, `can_view_stats`, `can_delete_channel`, +`can_set_username`, `join_to_send`, `join_request`, `requests_pending`, +`noforwards`, `unread_count`, `available_reactions`, `reactions_limit`, +`call_active`. + +Legacy basic groups add: `participants_count`, `deactivated`, +`migrated_to_chat_id`, `call_active`, `noforwards`, `requests_pending`, +`available_reactions`, `reactions_limit` — `ChatFull` carries the last four +too, so they are not channel-only. + +Users and bots add: `first_name`, `last_name`, `phone`, `is_bot`, +`is_deleted`, `is_premium`, `is_contact`, `is_mutual_contact`, `blocked`, +`common_chats_count`, `birthday`, `personal_channel_id`, `last_seen_status`. + +`--raw` adds one extra key, `raw`, holding **both** serialized objects — +`{"entity": …, "full": …}` — because the two carry different halves of the +picture: `forum_tabs`, `restriction_reason` and the banned-rights defaults +live on the shallow `Channel`/`Chat`/`User`, everything else on the `*Full`. +It is an escape hatch for fields the curated set does not name — wallpapers, +themes, sticker sets, boost levels, star pricing, business hours, `stats_dc` — +so a newly interesting field can be read without shipping a release. +`access_hash` is stripped: it is a credential for impersonating the peer +reference, not metadata, and nothing downstream needs it. + +The curated set is deliberately not "everything `ChannelFull` has": the raw +object carries 60+ fields that change with the Telegram layer, and pinning +tests to them would make a Telethon upgrade a test failure rather than a +feature. + +## Access + +`READ` on the chat, enforced in the domain layer on every surface, exactly +like `members list` (and unlike `folders inspect`, which is CLI-ungated). With +no `telegram.access` block configured the authorizer is the usual allow-all +no-op. + +The invite link and a user's phone number are returned to any caller holding +`READ` — an explicit decision: `READ` on a chat already means the caller can +read its messages, so withholding the link buys little, and a separate +`--secrets` flag would be one more thing to forget. Revisit if MCP tokens +start being handed to third parties. + +## Errors + +The same ladder `members list` uses, so a domain rejection never reads as an +internal error: + +| situation | exit | +|---|---| +| not exactly one of `--chat-id` / `--chat-name` / `--entity` | 2 | +| `FolderError` (folder or chat-by-name not found) | 2 | +| `EntityNotFoundError` / `AmbiguousEntityError` | 2 | +| `ChannelForbidden` / `ChatForbidden` — peer visible, Full unreachable; raised as `ValueError` naming the chat | 2 | +| `AccessDenied` | 3 | +| `FloodWaitError` and anything else → `chats inspect failed: ` | 1 | + +`FloodWaitError` is neither slept through nor retried: this is a one-shot read +with no queue behind it, so the operator decides when to try again. + +## Testing + +Three files, mirroring the `test_members_list*.py` trio: + +- `tests/test_chats_inspect.py` — the service against a fake backend: the READ + gate fires **before** any RPC (the fake records zero calls on denial), `raw` + is passed through, `to_dict()` has the documented shape. +- `tests/test_chats_inspect_backend.py` — the Telethon adapter against a fake + client: mapping for a forum supergroup, a broadcast channel, a legacy basic + group, a user and a bot; plus one test asserting `access_hash` is absent + even with `--raw`. +- `tests/test_cli_chats_inspect.py` — flag exclusivity (exit 2), JSON on + stdout, `access denied` (exit 3), unresolvable entity (exit 2). + +`tests/test_skill_inventory.py` fails until `SKILL.md` lists the new command — +that guard is the reason documentation is part of phase 1 rather than a +follow-up. + +Live verification (read-only, so it needs no separate approval under the +project's e2e rule) closes phase 1: run the command against chat +`2305069221`, a channel, a private chat and a legacy group, one per mapping +branch. + +## Phases + +**Phase 1 — CLI only** + +1. `chats/` package (`service.py`, `telethon_backend.py`, `__init__.py`) with + `tests/test_chats_inspect.py` and `tests/test_chats_inspect_backend.py`. +2. `chats inspect` command in `cli/main.py` with + `tests/test_cli_chats_inspect.py`. +3. `skills/telegram-assistant/SKILL.md` — catalog row, a `chats` / `inspect` + extraction section, and a scenario; re-sync to + `~/.claude/skills/telegram-assistant/SKILL.md`; command list in + `README.md`. +4. Live read-only check across the four peer kinds. + +**Phase 2 — remaining surfaces** (decided 2026-08-05, after the phase-1 output +was reviewed) + +- HTTP `GET /telegram/chats/inspect`, served through a + `chat_inspect_backend_factory` on `app.state` that returns `None` (→ 503) + until the Telethon client is connected. +- MCP tool `telegram_chats_inspect`, plus `EXPECTED_TOOL_NAMES` in + `tests/test_mcp_mount.py` and the tool catalog in `README.md`. +- `tests/test_chats_inspect_surfaces.py`. + +Four decisions settle the places where `members list` is a poor model: + +- **Chat reference:** the remote surfaces take the *same* set the CLI does — + `entity`, `chat_id`, or `chat_name` with `folder_name` / `folder_id` — + rather than `members list`'s narrower `chat_id`/`entity` pair. `messages + edit` and `messages pin` already resolve a name over HTTP, so the precedent + exists, and a surface that cannot address what its own CLI sibling can is a + gap nobody would defend later. +- **`raw` is CLI-only.** The remote surfaces still *accept* the parameter and + reject it with 400 / a tool error naming the reason, rather than ignoring it + — a silently dropped `raw=true` would look like an empty raw payload. The + curated set is designed to be enough; `raw` carries considerably more (a + legacy group's whole member roster via `ChatFull.participants`, a user's + `business_location`, `stories` and `personal_channel_id`), and the project + already keeps local-only capabilities off the remote surfaces for this + reason — `scan_media` resolves server-side paths for the CLI alone, and + `messages download --out` is unconfined only there. +- **`FloodWaitError` is mapped**, not left to fall through as `members list` + leaves it: HTTP answers **502** with `Retry-After` and a body carrying + `retry_after_seconds`, MCP reports `needs_review` with the same field. The + adapter already translates flood-waits at four call sites, so an unmapped + one would surface as Starlette's empty 500 and tell the caller nothing about + waiting. This reuses the mapping `messages pin`/`unpin` established. +- **`raw` never reaches the domain call** from a remote surface: the routes + pass `raw=False` after the rejection above, so there is no path where a + remote caller's flag reaches `_serialize`. diff --git a/docs/superpowers/specs/2026-08-06-chats-set-ttl-design.md b/docs/superpowers/specs/2026-08-06-chats-set-ttl-design.md new file mode 100644 index 0000000..591013a --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-chats-set-ttl-design.md @@ -0,0 +1,220 @@ +# `chats set-ttl` — write a chat's auto-delete period + +**Date:** 2026-08-06 +**Status:** approved, not implemented + +## Problem + +`chats inspect` reports `ttl_period` but nothing can change it. The gap was +found the hard way: disabling auto-delete across the 78-chat `Агентства` +folder on 2026-08-05 needed an ad-hoc `messages.SetHistoryTTL` script run +outside the CLI, bypassing `telegram.access`, `OperationStore` and +`--dry-run`. The `chats inspect` spec deliberately left this out of scope as +"a WRITE operation with its own dry-run and gate, belonging to a separate +change" — this is that change. + +That run also produced three facts this design is built on, none of them +guessable from the API docs: + +1. **`SetHistoryTTL` is flood-waited hard, and the waits escalate.** Observed + pauses on one account within one hour: 261s, 703s, 866s. +2. **The RPC's return value cannot be trusted.** The last chat failed with + Telethon's `TypeNotFoundError` (constructor `e4e0b29d`, newer than the + installed layer) — yet the write had applied. Only a read-back showed it. +3. **Every successful set posts a service message into the chat**, visible to + all members, including a set that changes nothing. + +## Scope + +**In:** one CLI command, `chats set-ttl`, plus the domain module behind it. +Any peer `chats inspect` handles — supergroups, channels, legacy basic +groups, users. + +**Out:** HTTP and MCP, by explicit request — CLI only. The domain layer stays +surface-agnostic, so adding them later is wiring, not redesign. Also out: a +folder-wide sweep; the caller loops over chats, as with `members list --user`. + +## CLI surface + +``` +telegram-assistant chats set-ttl \ + (--chat-id N | --chat-name TITLE | --entity REF) \ + --ttl off| \ + [--folder-name NAME] [--folder-id N] [--dry-run] [--config PATH] +``` + +Exactly one chat reference, same rule and same wording as `chats inspect`. +`--folder-name` / `--folder-id` matter only for `--chat-name` resolution and +default from `telegram.default_chat_folder`. + +`--ttl` accepts: + +- `off` or `0` → period `0` (auto-delete disabled) +- `` with unit `s` | `m` | `h` | `d` | `w` (e.g. `1d`, `93d`, + `24h`, `2w`) +- a bare integer → seconds + +Rejected at parse time with exit 2: negative values, non-integer values, an +unknown unit, an empty string, and anything exceeding `2**31 - 1` seconds +(the wire field is a 32-bit int). There is deliberately **no** allow-list of +preset durations: Telegram's clients offer only day/week/month, but the +`Агентства` folder held live chats at 31, 93 and 180 days, so arbitrary +periods clearly pass. The server is the authority on what it accepts; a +rejection surfaces as its own error rather than being pre-empted by a guess. + +Output is a single JSON object on stdout via +`json.dumps(payload, sort_keys=True, default=str)`: + +`chat_id` (bare, no `-100` marker), `title`, `requested_ttl_seconds`, +`previous_ttl_seconds`, `ttl_period` (the value read back from the server — +the authoritative one), `changed`, `dry_run`. + +`previous_ttl_seconds` and `ttl_period` are `null` when auto-delete is off, +never `0` — that is what `chats inspect` already reports for the same field, +and the two commands must not disagree about the same chat. `off` therefore +means "make `ttl_period` null", so `--ttl off` against a chat that already +has no TTL is a no-op (`changed: false`, no write, no service message). + +## Architecture + +A new module `src/telegram_assistant/chats/ttl.py` beside the existing +`service.py`, mirroring how `members/` splits `listing.py` out of +`service.py`: one operation per file, READ and WRITE not mixed. + +``` +src/telegram_assistant/chats/ + service.py # unchanged — ChatInfo, inspect_chat (READ) + ttl.py # NEW — parse_ttl, SetTtlRequest/Result, set_chat_ttl + telethon_backend.py # TelethonChatInspectBackend + TelethonChatTtlBackend +``` + +`ttl.py` holds: + +- `parse_ttl(value: str) -> int` — pure, no I/O, raises `ValueError` with the + offending text. Tested as a table. +- `ChatTtlBackend` protocol: + `set_ttl(*, chat_id: int, period: int) -> None` and + `get_ttl(*, chat_id: int) -> int | None`. + `get_ttl` is deliberately narrow rather than reusing `inspect_chat`: the + full payload costs peer-kind dispatch, serialization and `access_hash` + redaction for one field, and a fake for it in tests would have to be a + whole `ChatInfo`. +- `SetTtlRequest` / `SetTtlResult`, frozen dataclasses; `SetTtlResult` has + `to_dict()` producing the payload above. +- `set_chat_ttl(*, backend, chat_id, period, chat_name=None, authorizer=None, + pacer=None, dry_run=False) -> SetTtlResult`. + +`TelethonChatTtlBackend` sits at the bottom of `chats/telethon_backend.py`, +resolves the peer once, and dispatches on its type exactly as the inspect +adapter does — `get_ttl` reads `full_chat.ttl_period` from +`GetFullChannel` / `GetFullChat` / `GetFullUser`; `set_ttl` issues +`messages.SetHistoryTTLRequest(peer=…, period=…)`. + +## Order of operations + +`set_chat_ttl` runs, in this order: + +1. **WRITE gate** — `authorizer.require(chat_id, AccessLevel.WRITE)`, before + any Telegram call. A denied caller costs no round trip and learns nothing + about the chat. +2. **Read the current period** via `get_ttl`. +3. **No-op short-circuit.** If the current period already equals the + requested one, return with `changed: false` **without writing**. This is a + correctness requirement, not an optimization: Telegram posts a service + message on every successful `SetHistoryTTL`, including one that changes + nothing, so a re-run over a folder would otherwise spam every chat in it. +4. **`--dry-run`** returns here, reporting `previous_ttl_seconds`, the + `changed` it would produce, and `ttl_period` equal to the current value. +5. **Write**, through the pacer (below). +6. **Read back** via `get_ttl`, unconditionally. The reported `ttl_period` is + this value, never the requested one. +7. **Mismatch is an error.** If the read-back differs from the requested + period, raise `ValueError` naming both. A server that silently clamped or + rejected the value must not be reported as success. + +No `OperationStore` row and no idempotency key: this is a single-step write +with a naturally idempotent target, shaped like `notifications mute`. + +## Pacing and flood waits + +The write goes through `messages/pacing.py`'s `Pacer` with key +`ttl:` — its own row in the shared `RateGateStore`, not +shared with the pin gate, since these are different Telegram limits. Keying +on the bare id (not the `-100`-marked form) matches `pin_pacing_key` and +keeps one gate row per chat. + +Two new config keys under `telegram`: + +- `ttl_min_interval_seconds` — default `2.0`, `0` disables. The shared + minimum interval between two TTL writes on one chat, honoured across CLI + processes. +- `ttl_max_flood_wait_seconds` — default `3600`. High enough to sit through + the observed 866s waits (the decision was "wait as long as it takes"), but + finite so a stuck call cannot hang forever unnoticed. +- `ttl_max_flood_wait_retries` — default `5`. `Pacer`'s own default is `3`, + which is too few here: the waits escalate, so a single chat can plausibly + spend two or three of them before succeeding, and exhausting the count + reports a flood wait as a failure on a call that was about to work. + +Both caps bind independently: the pacer gives up when either the retry count +is exhausted or one requested wait exceeds `ttl_max_flood_wait_seconds`. + +Every flood-wait pause logs a `WARNING` naming the chat and the wait — a +15-minute silent process is indistinguishable from a hang. Exhausting the cap +raises `PacedFloodWaitError`, whose `retry_after_seconds` reaches the +operator. + +**`TypeNotFoundError` during the write is not a failure.** It means Telethon +could not parse the response, not that the write failed — proven live. The +adapter catches it and falls through to the read-back, which decides. Any +other `RPCError` propagates. + +## Access + +WRITE on the chat, enforced in the domain layer. With no `telegram.access` +block configured the authorizer is the usual allow-all no-op. Note that +`write` does **not** imply `read` in this project's policy, and this command +needs only WRITE — the `get_ttl` calls are part of the write operation, not a +separate READ grant. + +## Errors + +| situation | exit | +|---|---| +| not exactly one of `--chat-id` / `--chat-name` / `--entity` | 2 | +| unparseable `--ttl` | 2 | +| `FolderError` (folder or chat-by-name not found) | 2 | +| `EntityNotFoundError` / `AmbiguousEntityError` | 2 | +| `ChannelForbidden` / `ChatForbidden`, raised as `ValueError` naming the chat | 2 | +| read-back disagrees with the requested period | 2 | +| `AccessDenied` | 3 | +| `PacedFloodWaitError` (cap exhausted) — message carries `retry_after_seconds` | 1 | +| anything else → `chats set-ttl failed: ` | 1 | + +## Testing + +Three files, mirroring the `test_chats_inspect*.py` trio: + +- `tests/test_chats_set_ttl.py` — the service against a fake backend: + `parse_ttl` as a table (valid forms, every rejection), the WRITE gate fires + before any RPC (the fake records zero calls on denial), the no-op + short-circuit issues no write, `--dry-run` writes nothing, the read-back + value wins over the requested one, a mismatch raises, and a backend raising + `TypeNotFoundError` still succeeds when the read-back confirms. +- `tests/test_chats_set_ttl_backend.py` — the Telethon adapter against a fake + client: peer-kind dispatch for `get_ttl`, the `SetHistoryTTLRequest` + arguments, and `TypeNotFoundError` tolerance. +- `tests/test_cli_chats_set_ttl.py` — flag exclusivity (exit 2), `--ttl` + parsing failures (exit 2), JSON on stdout, `--dry-run` payload shape, + access denied (exit 3). + +`skills/telegram-assistant/SKILL.md` (catalog row, extraction section, +scenario), the re-sync to `~/.claude/skills/telegram-assistant/SKILL.md`, and +the command list in `README.md` are part of the change, not a follow-up — +`tests/test_skill_inventory.py` fails until the catalog matches. + +Live verification is **mutating** and therefore needs explicit human approval +under the project's e2e rule. The proposed check, once approved, is Saved +Messages (`me`) only: set a short TTL, read it back, set `off`, read back, +re-run `off` to confirm the no-op short-circuit issues no write and posts no +service message. diff --git a/skills/telegram-assistant/SKILL.md b/skills/telegram-assistant/SKILL.md index 67f99a1..b1df710 100644 --- a/skills/telegram-assistant/SKILL.md +++ b/skills/telegram-assistant/SKILL.md @@ -69,14 +69,15 @@ telegram-assistant health Commands fall into three buckets: 1. **Read-only** — `health`, `folders inspect`, `operations status`, - `groups get-layout`, `members list`. Run them immediately, no - confirmation, no `--dry-run`. + `groups get-layout`, `members list`, `chats inspect`. Run them + immediately, no confirmation, no `--dry-run`. 2. **State-changing, single object** — `groups create`, `groups set-layout`, `groups rename`, `topics create`, `topics close`, `topics open`, `topics rename`, `messages send` (single chat), `messages react`, `messages forward`, `notifications mute`, `notifications unmute`, `folders add-chat`, `folders remove-chat`, + `chats set-ttl`, `operations retry`. Always: prepare command → run with `--dry-run` → show the plan and dry-run output → ask for explicit human confirmation @@ -168,7 +169,7 @@ not skip steps, even if the request looks obvious. `members bulk-add`, `members bulk-remove`, `messages send`, `messages react`, `messages forward`, `notifications mute`, `notifications unmute`, `folders add-chat`, - `folders remove-chat`, `operations retry`. + `folders remove-chat`, `chats set-ttl`, `operations retry`. 8. Present a short plan to the human: what was found (chat id, folder, matched users), the full command that would run, and the relevant parts of the dry-run output (`status = dry_run`, planned actions, validation @@ -220,6 +221,8 @@ agent stops and asks for clarification — it does not invent a new path. | `members` | `bulk-add` | Add one or many users to a chat, optionally as admin. | `telegram-assistant members bulk-add ...` | | `members` | `bulk-remove` | Remove one or many users from a chat (kick or permanent ban). | `telegram-assistant members bulk-remove ...` | | `members` | `list` | Read-only: list a chat's participants (`--query`, `--filter all\|admins\|bots`, `--limit`, default 200), or check one user's membership with `--user` (READ-gated, never writes). | `telegram-assistant members list ...` | +| `chats` | `inspect` | Read-only: report one chat's metadata — auto-delete TTL, description, member counts, slow mode, restrictions, our own rights (READ-gated, no `--dry-run`). `--raw` adds the serialized entity/Full objects. | `telegram-assistant chats inspect ...` | +| `chats` | `set-ttl` | Change a chat's auto-delete period (`--ttl off\|1d\|93d`, WRITE-gated; `--dry-run`). Setting the period the chat already has writes nothing. | `telegram-assistant chats set-ttl ...` | | `messages` | `send` | Send a message or service command to one chat/topic, or fan it out across a folder. `--rich-markdown ` sends a Telegram rich message (article) instead of plain text, with paragraph spacing, line splitting and `` grouping on by default (`--no-spaced-paragraphs`, `--no-line-breaks`, `--media-group`) and local media resolved from the article's directory (`--rich-file`, `--vault-dir`). | `telegram-assistant messages send ...` | | `messages` | `recent` | Read-only: return the most recent messages from a chat (READ-gated; default limit 5). | `telegram-assistant messages recent ...` | | `messages` | `react` | Set (`--emoji`) or clear (`--clear`) an emoji reaction on a message (`--message-id`, WRITE-gated). | `telegram-assistant messages react ...` | @@ -531,6 +534,118 @@ command rather than after `folder_cache_ttl` seconds. - Typical errors: `refusing to remove without --yes (or use --dry-run to preview)`, protected-account refusals, `BulkMemberRemoveNeedsReview`. +#### `chats` / `inspect` + +- Extract: chat reference (`--chat-id` / `--chat-name` / `--entity`), optional + `--raw`. +- Required flags: exactly one chat reference. +- From config: `--folder-name` default when resolving `--chat-name`. +- Temp file: no. +- Automation: read-only — run immediately when the human asks «какой статус + автоудаления у чата X», «что за чат X», «сколько участников в X», «почему в X + не отправляется». No `--dry-run` (there is none), no confirmation. +- Payload: one flat JSON object with the same keys for every chat kind — + fields that do not apply are `null`. Always present: `chat_id` (bare id, no + `-100`), `kind` (`user`/`bot`/`basic_group`/`supergroup`/`channel`), `title`, + `username`, `about`, `ttl_period` (auto-delete, seconds; `null` = off), + `pinned_message_id`, `archived`, `muted`/`muted_until`/`silent`, `restricted` + + `restriction_reason`, `is_creator`, `left`, `invite_link`, + `my_admin_rights`, `default_banned_rights`. Groups and channels add + `is_forum`, `topics_layout`, `participants_count`, `admins_count`, + `kicked_count`, `banned_count`, `online_count`, `slowmode_seconds`, + `linked_chat_id`, `hidden_prehistory`, `antispam`, `join_to_send`, + `noforwards`, `available_reactions` and friends. Private chats add + `first_name`/`last_name`, `phone`, `is_bot`, `is_premium`, `is_contact`, + `blocked`, `common_chats_count`, `birthday`, `last_seen_status`. +- Notifications are three fields, not one. `muted` is true only while the + chat's notifications are suppressed **right now** — an expired mute, and the + unmuted state Telegram spells as an epoch timestamp, both report `false` with + `muted_until` `null`. `muted_until` is the future expiry when there is one, + `null` otherwise (never a past date). `silent` is a separate Telegram flag — + the notification's *sound* is off — so a chat can be `silent: true` and + `muted: false`. Do not report a chat as muted on `silent` alone. +- `--raw`: adds a `raw` key holding `{"entity": …, "full": …}` — the two + serialized Telegram objects behind the curated fields, minus `access_hash`. + Use it only when the human asks for a field the curated set does not name; + it is large and its shape moves with the Telegram layer. +- Other surfaces: the same op is served by HTTP `GET /telegram/chats/inspect` + and by the MCP tool `telegram_chats_inspect`, taking the same chat references + (`chat_id` / `chat_name` + `folder_name`/`folder_id` / `entity`) and returning + the same **fields** — every key of the CLI payload, with the same meanings. + Only the datetime *rendering* differs: the CLI prints Python's own repr + (`"2026-01-02 03:04:05+00:00"`), the JSON surfaces ISO-8601 + (`"2026-01-02T03:04:05Z"`), for `created_at`, `muted_until` and + `slowmode_next_send_date`. Parse them, do not string-compare across + surfaces. `raw` is **CLI-only** there — both surfaces *reject* + `raw=true` (HTTP `400`, MCP a tool error) rather than ignoring it, so a + serialized dump can only be produced locally. This skill still uses the CLI; + mention the remote surfaces only if the human is asking about them. +- Note it does **not** write anything: there is no way to *change* the + description or the archive state through this CLI. The one write + counterpart is `chats set-ttl`, and it covers `ttl_period` alone — if the + human asks to set auto-delete, use that command, not this one. +- Confirmation: not required (read-only). Still READ-gated by the + `telegram.access` policy — a chat with no `read` grant exits 3 with + `access denied`; surface that and stop. +- Typical errors: `exactly one of --chat-id, --chat-name, or --entity must be + supplied` (exit 2), `chat cannot be inspected (resolved to ...)` (exit 2 + — the reference resolved to something with no metadata to read), + `chat is private or inaccessible` (exit 2), `chat is forbidden` + (exit 2 — we were removed from it), `access denied ...` (exit 3), entity + not-found / ambiguous (exit 2). A `FLOOD_WAIT` exits 1 on the CLI (one-shot + read, nothing retries it); on HTTP/MCP the same throttle comes back as + `502` / `needs_review` carrying `retry_after_seconds` — wait that long and + try again rather than retrying immediately. + +#### `chats` / `set-ttl` + +- Extract: chat reference (`--chat-id` / `--chat-name` / `--entity`) and the new + period (`--ttl`). +- Required flags: exactly one chat reference, plus `--ttl`. +- From config: `--folder-name` default when resolving `--chat-name`. +- Temp file: no. +- Automation: none — WRITE-gated state change. Run `--dry-run` first, show the + plan, wait for confirmation, then run without `--dry-run`. +- `--ttl` values: `off` (or `0`) disables auto-delete; otherwise + `` with unit `s`/`m`/`h`/`d`/`w` (`1d`, `24h`, `93d`, `2w`); a + bare integer is seconds. Telegram's own clients offer only day/week/month, but + arbitrary periods are accepted — real chats have been found at 31, 93 and 180 + days. There is no preset allow-list; an unacceptable value is rejected by the + server, not by the CLI. +- Payload: `chat_id` (bare), `chat_name`, `requested_ttl_seconds`, + `previous_ttl_seconds`, `ttl_period`, `changed`, `dry_run`. + `previous_ttl_seconds` and `ttl_period` are `null` when auto-delete is off, + never `0` — the same spelling `chats inspect` uses. `ttl_period` is what the + server reported **after** the write, not what was asked for. `chat_name` + echoes the caller's own reference string (e.g. `@username`), or `null` for + `--chat-id` — it is not the chat's real title the way `chats inspect`'s + `title` field is; do not relay it to a human as the chat's name. +- **Every successful change posts a service message into the chat**, visible to + all members. Say so in the plan before asking for confirmation — in a client + chat that message is seen by the client. +- Setting the period a chat already has is a no-op: `changed: false`, no write, + no service message. The `--dry-run` envelope says so in `warnings` and leaves + `planned_actions` empty. Do not "re-apply to be sure" — that is exactly what + would spam the chat. +- Slowness is expected. Telegram flood-waits this method hard and the waits + escalate (261s, 703s and 866s were observed within one hour on one account). + The command sits through them by design, up to + `telegram.ttl_max_flood_wait_seconds` (default 3600) and + `telegram.ttl_max_flood_wait_retries` (default 5). A command that appears to + hang for minutes is normal — do not kill and retry it, and never run two at + once against the same account. +- Sweeping a folder: loop the command over the chat ids from `folders inspect`, + one chat per call, sequentially. There is no bulk mode and no fan-out flag — + `--folder-name`/`--folder-id` only scope `--chat-name` — and running several + in parallel makes the flood waits worse. +- Other surfaces: none. This is **CLI-only** — there is no HTTP route and no MCP + tool for it. +- Confirmation: required (bucket 2). +- Typical errors: `cannot parse --ttl ...` (exit 2), `access denied ...` + (exit 3), `chat N: requested ttl X but the server stored Y` (exit 2 — the + server refused or clamped the value), `chats set-ttl rate-limited by + Telegram ... Retry after Ns` (exit 1). + #### `messages` / `send` - Extract: `--text`, chat/topic references, optional `--operation-id`, @@ -1429,6 +1544,48 @@ Request: «Кто состоит в чате Клиент / проект?» / « must be supplied` and `unknown filter '...'` (exit 2); a chat with no READ grant exits 3. +### `chats inspect` + +Request: «Какой статус автоудаления у чата 2305069221?» + +1. Resource/action: `chats` / `inspect`. Read-only — run it immediately, no + `--dry-run`, no confirmation. +2. Run: + + ```bash + telegram-assistant chats inspect --entity 2305069221 + ``` + +3. Read `ttl_period` from the payload: `null` means auto-delete is off, + otherwise it is the window in seconds (86400 = 1 day, 604800 = 1 week). + Report the other fields only if the human asked for them — the payload is + wide by design. +4. If the human then asks to *change* it, use `chats set-ttl` — see the next + scenario. + +### `chats set-ttl` + +1. Resource/action: `chats` / `set-ttl`. **Bucket 2** — never run it without a + confirmed dry-run. +2. Read the current state first with `chats inspect` and show it: the human + should see what the period is now before deciding what it becomes. +3. Dry-run: + + ```bash + telegram-assistant chats set-ttl --entity 2305069221 --ttl off --dry-run + ``` + +4. Show the plan with three things named explicitly: the chat, the old and new + periods, and that a service message will appear in the chat for everyone to + see. If the dry-run reports `changed: false`, stop — there is nothing to do, + and re-applying would post that message for no reason. +5. Confirm via `AskUserQuestion`, then re-run without `--dry-run`. +6. Expect it to be slow. Flood waits on this method run into minutes; the + command waits them out on purpose. Do not start a second one in parallel. +7. For several chats, loop one call per chat over the ids from `folders + inspect`, sequentially — and say up front how many chats will each get a + service message. + ### `messages send` — targeted Request: «Отправь /task 123456 в топик "Документы" чата diff --git a/src/telegram_assistant/chats/__init__.py b/src/telegram_assistant/chats/__init__.py new file mode 100644 index 0000000..214ee87 --- /dev/null +++ b/src/telegram_assistant/chats/__init__.py @@ -0,0 +1,29 @@ +"""Chat-wide operations: metadata inspection (read) and auto-delete TTL (write).""" + +from telegram_assistant.chats.service import ( + CHAT_KINDS, + ChatInfo, + ChatInspectBackend, + inspect_chat, +) +from telegram_assistant.chats.ttl import ( + MAX_TTL_SECONDS, + ChatTtlBackend, + SetTtlRequest, + SetTtlResult, + parse_ttl, + set_chat_ttl, +) + +__all__ = [ + "CHAT_KINDS", + "MAX_TTL_SECONDS", + "ChatInfo", + "ChatInspectBackend", + "ChatTtlBackend", + "SetTtlRequest", + "SetTtlResult", + "inspect_chat", + "parse_ttl", + "set_chat_ttl", +] diff --git a/src/telegram_assistant/chats/service.py b/src/telegram_assistant/chats/service.py new file mode 100644 index 0000000..cb02fdf --- /dev/null +++ b/src/telegram_assistant/chats/service.py @@ -0,0 +1,174 @@ +"""Read-only chat metadata — the inspect op of the chats domain. + +A READ op in the shape of :mod:`telegram_assistant.members.listing`: no +operation row, no idempotency key, no ``--dry-run``. It answers "what is this +chat" for every peer kind with one flat payload, so a caller can read +``ttl_period`` without knowing whether the target is a supergroup or a private +chat. + +The payload is a *curated* set rather than a dump of Telethon's ``*Full`` +objects: those carry 60+ fields that move with the Telegram layer, and pinning +tests to them would turn a Telethon upgrade into a test failure. ``raw`` is the +escape hatch for everything left out. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Any, Protocol + +from telegram_assistant.access.service import AccessLevel, Authorizer + +#: Peer kinds ``ChatInfo.kind`` may report. +CHAT_KINDS: frozenset[str] = frozenset( + {"user", "bot", "basic_group", "supergroup", "channel"} +) + + +@dataclass(frozen=True) +class ChatInfo: + """One chat's metadata, flat and kind-agnostic. + + Every field exists for every kind; the ones Telegram does not answer for a + given peer are ``None`` (or ``False`` for flags). That is deliberate — a + caller running ``jq .ttl_period`` must not have to branch on ``kind``. + """ + + # --- identity (all kinds) --- + chat_id: int + kind: str + title: str | None = None + username: str | None = None + usernames: tuple[str, ...] = () + about: str | None = None + created_at: datetime | None = None + + # --- the reason this op exists, plus the settings next to it --- + ttl_period: int | None = None + pinned_message_id: int | None = None + archived: bool = False + #: Notifications suppressed *right now* — i.e. ``muted_until`` is in the + #: future. An expired mute, and the ``mute_until = 0`` Telegram writes for + #: an unmute, both report ``False`` with ``muted_until`` ``None``. + muted: bool = False + muted_until: datetime | None = None + #: The separate ``silent`` notify flag: the notification's *sound* is off, + #: which is not the same thing as the chat being muted — hence its own + #: field rather than being folded into ``muted``. + silent: bool = False + has_scheduled: bool = False + + # --- trust / restrictions (all kinds) --- + restricted: bool = False + restriction_reason: tuple[dict[str, Any], ...] = () + verified: bool = False + scam: bool = False + fake: bool = False + + # --- our standing (all kinds) --- + is_creator: bool = False + left: bool = False + invite_link: str | None = None + my_admin_rights: dict[str, Any] | None = None + default_banned_rights: dict[str, Any] | None = None + + # --- groups and channels --- + is_forum: bool = False + topics_layout: str | None = None + broadcast: bool = False + megagroup: bool = False + gigagroup: bool = False + participants_count: int | None = None + admins_count: int | None = None + kicked_count: int | None = None + banned_count: int | None = None + online_count: int | None = None + slowmode_seconds: int | None = None + slowmode_next_send_date: datetime | None = None + linked_chat_id: int | None = None + migrated_from_chat_id: int | None = None + migrated_to_chat_id: int | None = None + deactivated: bool = False + hidden_prehistory: bool = False + participants_hidden: bool = False + antispam: bool = False + can_view_participants: bool = False + can_view_stats: bool = False + can_delete_channel: bool = False + can_set_username: bool = False + join_to_send: bool = False + join_request: bool = False + requests_pending: int | None = None + noforwards: bool = False + unread_count: int | None = None + available_reactions: Any = None + reactions_limit: int | None = None + call_active: bool = False + + # --- users and bots --- + first_name: str | None = None + last_name: str | None = None + phone: str | None = None + is_bot: bool = False + is_deleted: bool = False + is_premium: bool = False + is_contact: bool = False + is_mutual_contact: bool = False + blocked: bool = False + common_chats_count: int | None = None + birthday: dict[str, Any] | None = None + personal_channel_id: int | None = None + last_seen_status: str | None = None + + # --- escape hatch --- + raw: dict[str, Any] | None = field(default=None) + + def to_dict(self) -> dict[str, Any]: + """The payload body. ``raw`` appears only when it was requested.""" + payload = asdict(self) + payload["usernames"] = list(self.usernames) + payload["restriction_reason"] = list(self.restriction_reason) + if self.raw is None: + payload.pop("raw") + return payload + + +class ChatInspectBackend(Protocol): + """Telethon-facing surface needed to read one chat's metadata. + + Production wires this to + :class:`telegram_assistant.chats.telethon_backend.TelethonChatInspectBackend`; + tests inject a fake. + """ + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + ... + + +async def inspect_chat( + *, + backend: ChatInspectBackend, + chat_id: int, + raw: bool = False, + authorizer: Authorizer | None = None, +) -> ChatInfo: + """Read ``chat_id``'s metadata. + + A READ op: when an ``authorizer`` is supplied it must grant READ on the + chat, checked before any Telegram call. The payload carries the + description, the member counts and the invite link, so a denied caller must + cost no round trip and learn nothing about the chat. + """ + if authorizer is not None: + await authorizer.require(chat_id, AccessLevel.READ) + + return await backend.inspect_chat(chat_id=chat_id, raw=raw) + + +__all__ = [ + "CHAT_KINDS", + "ChatInfo", + "ChatInspectBackend", + "inspect_chat", +] diff --git a/src/telegram_assistant/chats/telethon_backend.py b/src/telegram_assistant/chats/telethon_backend.py new file mode 100644 index 0000000..7205570 --- /dev/null +++ b/src/telegram_assistant/chats/telethon_backend.py @@ -0,0 +1,551 @@ +"""Telethon adapter for the chat-inspect op. + +Two RPCs at most: ``get_input_entity`` to learn the peer kind, then one +``GetFull*`` request. The shallow half of the answer (``forum_tabs``, +``restriction_reason``, the rights defaults) is read out of that response's own +``chats``/``users`` list rather than a second ``get_entity`` — ``forum_tabs`` +is a flag on ``Channel``, not on ``ChannelFull``, which is why +``groups/telethon_backend.py::get_topics_layout`` already resolves it that way. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from telegram_assistant.chats.service import ChatInfo +from telegram_assistant.telegram_client.errors import translate_flood_wait + +#: Never leaves the process: a peer credential, not metadata. +_REDACTED_RAW_KEYS = frozenset({"access_hash"}) + + +def _rights(raw: Any) -> dict[str, Any] | None: + """Flag dict for ChatAdminRights / ChatBannedRights, minus the type tag.""" + if raw is None: + return None + to_dict = getattr(raw, "to_dict", None) + if to_dict is None: + return None + return {k: v for k, v in to_dict().items() if k != "_"} + + +def _usernames(raw: Any) -> tuple[str, ...]: + """Active alternative usernames only — an inactive one is not reachable.""" + return tuple( + str(u.username) + for u in (raw or ()) + if getattr(u, "username", None) and getattr(u, "active", True) + ) + + +def _restriction_reasons(raw: Any) -> tuple[dict[str, Any], ...]: + return tuple( + { + "platform": getattr(r, "platform", None), + "reason": getattr(r, "reason", None), + "text": getattr(r, "text", None), + } + for r in (raw or ()) + ) + + +def _reactions(raw: Any) -> Any: + """``"all"``, ``"none"``, or the list of allowed emoticons.""" + if raw is None: + return None + name = type(raw).__name__ + if name == "ChatReactionsAll": + return "all" + if name == "ChatReactionsNone": + return "none" + return [ + getattr(r, "emoticon", None) or getattr(r, "document_id", None) + for r in (getattr(raw, "reactions", ()) or ()) + ] + + +def _birthday(raw: Any) -> dict[str, Any] | None: + if raw is None: + return None + return { + "day": getattr(raw, "day", None), + "month": getattr(raw, "month", None), + "year": getattr(raw, "year", None), + } + + +def _peer_id(raw: Any) -> int | None: + """Read the numeric id off any InputPeer/Peer shape.""" + for attr in ("channel_id", "chat_id", "user_id"): + value = getattr(raw, attr, None) + if value is not None: + return int(value) + return None + + +def _redact(value: Any, *, _seen: frozenset[int] = frozenset()) -> Any: + """Recursively strip ``_REDACTED_RAW_KEYS`` from a serialized value. + + Telethon's own ``to_dict()`` already recurses into nested TLObjects (its + generated ``ChannelFull.to_dict()``, for instance, calls + ``self.chat_photo.to_dict()`` itself), so an ``access_hash`` on a nested + ``Photo`` reaches this function buried inside an already-plain ``dict`` — + a *top-level-only* key filter never sees it, which is exactly how it + leaked into ``--raw`` live (``raw.full.chat_photo.access_hash``, + ``raw.full.profile_photo.access_hash``). This walks every shape + ``to_dict()``/``vars()`` output can carry at any depth: + + * a ``dict`` — drop redacted keys, recurse into what is left; + * a ``list``/``tuple`` — recurse into each item (Telegram returns these + for e.g. ``usernames``, ``restriction_reason``, ``bot_info``); + * an object that still carries its own ``to_dict()`` — reachable from the + ``vars()`` fallback path in :func:`_serialize`, whose *nested* + attributes may still be live objects even when the top-level one has no + ``to_dict()`` of its own (a test fake is the concrete case; a mixed + fake/real object graph is also possible). + + Anything else — ``str``, ``int``, ``bool``, ``bytes``, ``datetime``, + ``None`` — is a leaf and is returned unchanged: none of those can carry a + nested ``access_hash``, and neither ``bytes`` nor ``str`` are treated as a + sequence of sub-values to recurse into. + + ``_seen`` (object ids already entered through the ``to_dict()`` branch) + guards a reference cycle — an object whose own ``to_dict()`` yields itself + or an ancestor — without imposing a depth cap: a legitimately deep, + acyclic payload is walked in full rather than silently truncated. + """ + if isinstance(value, dict): + return { + key: _redact(item, _seen=_seen) + for key, item in value.items() + if key not in _REDACTED_RAW_KEYS + } + if isinstance(value, (list, tuple)): + return [_redact(item, _seen=_seen) for item in value] + to_dict = getattr(value, "to_dict", None) + if to_dict is not None: + obj_id = id(value) + if obj_id in _seen: + return None + return _redact(to_dict(), _seen=_seen | {obj_id}) + return value + + +def _serialize(raw: Any) -> dict[str, Any] | None: + """Serialize *raw* for the ``raw`` payload with ``access_hash`` stripped + at every depth, not just the top level — see :func:`_redact`.""" + if raw is None: + return None + to_dict = getattr(raw, "to_dict", None) + if to_dict is not None: + payload = to_dict() + else: + payload = { + k: v for k, v in vars(raw).items() if not k.startswith("_") + } + return _redact(payload) + + +def _shallow_for(result: Any, full: Any, bucket: str) -> Any: + """The shallow object matching ``full.id`` in ``result.``. + + Telegram returns the peer alongside its Full object; match by id, and fall + back to the only entry **when the bucket holds exactly one** — there the + fallback cannot pick the wrong chat. With two or more entries there is no + safe guess: ``GetFullChannel`` on a channel with a linked discussion group + returns both in ``result.chats``, so taking ``items[0]`` on an id mismatch + would map the *linked group's* title, flags and banned-rights defaults onto + the chat that was asked about. ``None`` (every shallow field reported as + ``None``/``False``) is the honest answer to "the peer Telegram sent back + does not match the Full object it sent with it". + """ + items = list(getattr(result, bucket, None) or []) + target_id = int(getattr(full, "id", 0) or 0) + match = next((i for i in items if int(getattr(i, "id", 0) or 0) == target_id), None) + if match is None and len(items) == 1: + return items[0] + return match + + +def _raw_payload(entity: Any, full: Any, raw: bool) -> dict[str, Any] | None: + """The ``raw`` field's value: both serialized halves, or ``None`` when unrequested. + + Shared by all three ``_inspect_*`` branches so the ``access_hash`` redaction + in :func:`_serialize` is applied from exactly one call site rather than + three copies that could drift. + """ + if not raw: + return None + return {"entity": _serialize(entity), "full": _serialize(full)} + + +def _mute_fields(full: Any) -> tuple[bool, Any, bool]: + """``(muted, muted_until, silent)`` read off ``full.notify_settings``, once. + + Shared by all three branches for the same reason as :func:`_raw_payload`. + + ``muted`` answers "are this chat's notifications suppressed **right now**", + which is true only while ``mute_until`` lies in the future. Three shapes + make the naive "``mute_until`` is populated" test wrong: + + * **unmuted.** Telegram spells an unmute as ``mute_until = 0``, and that is + what this project itself writes (``notifications/telethon_backend.py`` + sends ``InputPeerNotifySettings(mute_until=0)``). Telethon 1.44's + ``BinaryReader.tgread_date`` no longer special-cases 0 — it returns + ``_EPOCH + timedelta(seconds=0)``, i.e. a perfectly non-``None`` + ``1970-01-01T00:00:00+00:00`` — so ``notifications unmute`` followed by + ``chats inspect`` reported ``muted: true`` with an epoch ``muted_until``. + * **an expired temporary mute.** The timestamp stays on the settings after + it passes; only its position relative to now says anything. + * **``silent``.** Per the TL schema that flag mutes the notification's + *sound*, not the chat, so it gets its own field rather than being folded + into ``muted``. + + ``muted_until`` is reported only when it is that future timestamp, so the + payload never carries an epoch or a stale date as if it meant something. + + ``mute_until`` off the wire is always a timezone-aware UTC ``datetime`` + (``tgread_date`` builds it from a tz-aware epoch) or ``None`` — hence the + tz-aware "now", and hence no branch for a bare ``int``: that shape is not + reachable from a deserialized response. + """ + settings = getattr(full, "notify_settings", None) + if settings is None: + return False, None, False + + silent = bool(getattr(settings, "silent", False)) + mute_until = getattr(settings, "mute_until", None) + if mute_until is None or mute_until <= datetime.now(UTC): + return False, None, silent + return True, mute_until, silent + + +class TelethonChatInspectBackend: + """Adapter from the Telethon ``TelegramClient`` to ``ChatInspectBackend``.""" + + def __init__(self, client: Any) -> None: + self._client = client + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + try: + peer = await self._client.get_input_entity(chat_id) + except Exception as exc: + # Same shape as the two GetFull* branches below: re-raise the + # original untouched when there is nothing to translate. + # ``translate_flood_wait`` returns the *same object* then, so an + # unconditional ``raise translated from exc`` would set + # ``exc.__cause__ is exc``. + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + raise + + kind = type(peer).__name__ + if kind == "InputPeerChannel": + return await self._inspect_channel(peer, chat_id=chat_id, raw=raw) + if kind == "InputPeerChat": + return await self._inspect_basic_group(peer, chat_id=chat_id, raw=raw) + if kind in {"InputPeerUser", "InputPeerSelf"}: + return await self._inspect_user(peer, raw=raw) + raise ValueError(f"chat {chat_id} cannot be inspected (resolved to {kind})") + + # --- per-kind branches -------------------------------------------------- + + async def _inspect_channel(self, peer: Any, *, chat_id: int, raw: bool) -> ChatInfo: + from telethon.errors import ChannelPrivateError + from telethon.tl import functions + + try: + result = await self._client( + functions.channels.GetFullChannelRequest(channel=peer) + ) + except Exception as exc: + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + # The peer resolved (get_input_entity succeeded) but Telegram + # refuses the Full fetch — we were kicked/banned or it is private + # and we never joined. That is a caller-input-shaped failure, not + # an internal error, so it maps to ValueError -> CLI exit 2 rather + # than the unmapped exit 1 a bare RPCError would get. + if isinstance(exc, ChannelPrivateError): + raise ValueError(f"chat {chat_id} is private or inaccessible") from exc + raise + + full = getattr(result, "full_chat", None) + entity = _shallow_for(result, full, "chats") + broadcast = bool(getattr(entity, "broadcast", False)) + muted, muted_until, silent = _mute_fields(full) + + return ChatInfo( + chat_id=int(getattr(full, "id", 0) or getattr(peer, "channel_id", 0)), + kind="channel" if broadcast else "supergroup", + title=getattr(entity, "title", None), + username=getattr(entity, "username", None), + usernames=_usernames(getattr(entity, "usernames", None)), + about=getattr(full, "about", None) or None, + created_at=getattr(entity, "date", None), + ttl_period=getattr(full, "ttl_period", None), + pinned_message_id=getattr(full, "pinned_msg_id", None), + archived=getattr(full, "folder_id", None) == 1, + muted=muted, + muted_until=muted_until, + silent=silent, + has_scheduled=bool(getattr(full, "has_scheduled", False)), + restricted=bool(getattr(entity, "restricted", False)), + restriction_reason=_restriction_reasons( + getattr(entity, "restriction_reason", None) + ), + verified=bool(getattr(entity, "verified", False)), + scam=bool(getattr(entity, "scam", False)), + fake=bool(getattr(entity, "fake", False)), + is_creator=bool(getattr(entity, "creator", False)), + left=bool(getattr(entity, "left", False)), + invite_link=getattr(getattr(full, "exported_invite", None), "link", None), + my_admin_rights=_rights(getattr(entity, "admin_rights", None)), + default_banned_rights=_rights( + getattr(entity, "default_banned_rights", None) + ), + is_forum=bool(getattr(entity, "forum", False)), + topics_layout=( + ("tabs" if getattr(entity, "forum_tabs", False) else "list") + if getattr(entity, "forum", False) + else None + ), + broadcast=broadcast, + megagroup=bool(getattr(entity, "megagroup", False)), + gigagroup=bool(getattr(entity, "gigagroup", False)), + participants_count=getattr(full, "participants_count", None), + admins_count=getattr(full, "admins_count", None), + kicked_count=getattr(full, "kicked_count", None), + banned_count=getattr(full, "banned_count", None), + online_count=getattr(full, "online_count", None), + slowmode_seconds=getattr(full, "slowmode_seconds", None), + slowmode_next_send_date=getattr(full, "slowmode_next_send_date", None), + linked_chat_id=getattr(full, "linked_chat_id", None), + migrated_from_chat_id=getattr(full, "migrated_from_chat_id", None), + hidden_prehistory=bool(getattr(full, "hidden_prehistory", False)), + participants_hidden=bool(getattr(full, "participants_hidden", False)), + antispam=bool(getattr(full, "antispam", False)), + can_view_participants=bool(getattr(full, "can_view_participants", False)), + can_view_stats=bool(getattr(full, "can_view_stats", False)), + can_delete_channel=bool(getattr(full, "can_delete_channel", False)), + can_set_username=bool(getattr(full, "can_set_username", False)), + join_to_send=bool(getattr(entity, "join_to_send", False)), + join_request=bool(getattr(entity, "join_request", False)), + requests_pending=getattr(full, "requests_pending", None), + noforwards=bool(getattr(entity, "noforwards", False)), + unread_count=getattr(full, "unread_count", None), + available_reactions=_reactions(getattr(full, "available_reactions", None)), + reactions_limit=getattr(full, "reactions_limit", None), + call_active=bool(getattr(entity, "call_active", False)), + raw=_raw_payload(entity, full, raw), + ) + + async def _inspect_basic_group(self, peer: Any, *, chat_id: int, raw: bool) -> ChatInfo: + from telethon.errors import ChatForbiddenError + from telethon.tl import functions + + try: + result = await self._client( + functions.messages.GetFullChatRequest(chat_id=peer.chat_id) + ) + except Exception as exc: + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + # Same shape as the channel branch above: the peer resolved but + # Telegram refuses the Full fetch for this legacy basic group + # (we were removed from it) -> ValueError -> CLI exit 2. + if isinstance(exc, ChatForbiddenError): + raise ValueError(f"chat {chat_id} is forbidden") from exc + raise + + full = getattr(result, "full_chat", None) + entity = _shallow_for(result, full, "chats") + muted, muted_until, silent = _mute_fields(full) + + return ChatInfo( + chat_id=int(getattr(full, "id", 0) or getattr(peer, "chat_id", 0)), + kind="basic_group", + title=getattr(entity, "title", None), + about=getattr(full, "about", None) or None, + created_at=getattr(entity, "date", None), + ttl_period=getattr(full, "ttl_period", None), + pinned_message_id=getattr(full, "pinned_msg_id", None), + archived=getattr(full, "folder_id", None) == 1, + muted=muted, + muted_until=muted_until, + silent=silent, + has_scheduled=bool(getattr(full, "has_scheduled", False)), + is_creator=bool(getattr(entity, "creator", False)), + left=bool(getattr(entity, "left", False)), + invite_link=getattr(getattr(full, "exported_invite", None), "link", None), + my_admin_rights=_rights(getattr(entity, "admin_rights", None)), + default_banned_rights=_rights( + getattr(entity, "default_banned_rights", None) + ), + participants_count=getattr(entity, "participants_count", None), + deactivated=bool(getattr(entity, "deactivated", False)), + migrated_to_chat_id=_peer_id(getattr(entity, "migrated_to", None)), + can_set_username=bool(getattr(full, "can_set_username", False)), + requests_pending=getattr(full, "requests_pending", None), + noforwards=bool(getattr(entity, "noforwards", False)), + available_reactions=_reactions(getattr(full, "available_reactions", None)), + reactions_limit=getattr(full, "reactions_limit", None), + call_active=bool(getattr(entity, "call_active", False)), + raw=_raw_payload(entity, full, raw), + ) + + async def _inspect_user(self, peer: Any, *, raw: bool) -> ChatInfo: + from telethon.tl import functions + + try: + result = await self._client(functions.users.GetFullUserRequest(id=peer)) + except Exception as exc: + # As above: re-raise the original untouched rather than + # ``raise exc from exc``. There is no forbidden-peer branch here — + # a user's Full fetch has no ChannelPrivateError analogue. + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + raise + + full = getattr(result, "full_user", None) + entity = _shallow_for(result, full, "users") + first = getattr(entity, "first_name", None) + last = getattr(entity, "last_name", None) + title = " ".join(part for part in (first, last) if part) or None + muted, muted_until, silent = _mute_fields(full) + + return ChatInfo( + chat_id=int(getattr(full, "id", 0) or getattr(peer, "user_id", 0)), + kind="bot" if getattr(entity, "bot", False) else "user", + title=title, + username=getattr(entity, "username", None), + usernames=_usernames(getattr(entity, "usernames", None)), + about=getattr(full, "about", None) or None, + ttl_period=getattr(full, "ttl_period", None), + pinned_message_id=getattr(full, "pinned_msg_id", None), + archived=getattr(full, "folder_id", None) == 1, + muted=muted, + muted_until=muted_until, + silent=silent, + has_scheduled=bool(getattr(full, "has_scheduled", False)), + restricted=bool(getattr(entity, "restricted", False)), + restriction_reason=_restriction_reasons( + getattr(entity, "restriction_reason", None) + ), + verified=bool(getattr(entity, "verified", False)), + scam=bool(getattr(entity, "scam", False)), + fake=bool(getattr(entity, "fake", False)), + first_name=first, + last_name=last, + phone=getattr(entity, "phone", None), + is_bot=bool(getattr(entity, "bot", False)), + is_deleted=bool(getattr(entity, "deleted", False)), + is_premium=bool(getattr(entity, "premium", False)), + is_contact=bool(getattr(entity, "contact", False)), + is_mutual_contact=bool(getattr(entity, "mutual_contact", False)), + blocked=bool(getattr(full, "blocked", False)), + common_chats_count=getattr(full, "common_chats_count", None), + birthday=_birthday(getattr(full, "birthday", None)), + personal_channel_id=getattr(full, "personal_channel_id", None), + last_seen_status=( + type(getattr(entity, "status", None)).__name__ + if getattr(entity, "status", None) is not None + else None + ), + raw=_raw_payload(entity, full, raw), + ) + + +class TelethonChatTtlBackend: + """Adapter from the Telethon ``TelegramClient`` to ``ChatTtlBackend``. + + Two RPCs at most per call, and the peer dispatch is the same shape as + :class:`TelethonChatInspectBackend` — the ``ttl_period`` field lives on + ``full_chat`` for channels and basic groups, on ``full_user`` for users. + """ + + def __init__(self, client: Any) -> None: + self._client = client + + async def _peer(self, chat_id: int) -> tuple[Any, str]: + try: + peer = await self._client.get_input_entity(chat_id) + except Exception as exc: + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + raise + kind = type(peer).__name__ + if kind not in { + "InputPeerChannel", + "InputPeerChat", + "InputPeerUser", + "InputPeerSelf", + }: + raise ValueError( + f"chat {chat_id} has no auto-delete setting (resolved to {kind})" + ) + return peer, kind + + async def get_ttl(self, *, chat_id: int) -> int | None: + from telethon.errors import ChannelPrivateError, ChatForbiddenError + from telethon.tl import functions + + peer, kind = await self._peer(chat_id) + if kind == "InputPeerChannel": + request = functions.channels.GetFullChannelRequest(channel=peer) + attr = "full_chat" + elif kind == "InputPeerChat": + request = functions.messages.GetFullChatRequest(chat_id=peer.chat_id) + attr = "full_chat" + else: + request = functions.users.GetFullUserRequest(id=peer) + attr = "full_user" + + try: + result = await self._client(request) + except Exception as exc: + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + # Mirrors the inspect adapter's two forbidden-Full-fetch branches + # (``_inspect_channel``/``_inspect_basic_group``): the peer + # resolved but Telegram refuses the Full fetch, which is + # caller-input-shaped (exit 2), not an internal error. + if kind == "InputPeerChannel" and isinstance(exc, ChannelPrivateError): + raise ValueError(f"chat {chat_id} is private or inaccessible") from exc + if kind == "InputPeerChat" and isinstance(exc, ChatForbiddenError): + raise ValueError(f"chat {chat_id} is forbidden") from exc + raise + + return getattr(getattr(result, attr, None), "ttl_period", None) + + async def set_ttl(self, *, chat_id: int, period: int) -> None: + from telethon.tl import functions + + peer, _kind = await self._peer(chat_id) + try: + await self._client( + functions.messages.SetHistoryTTLRequest(peer=peer, period=period) + ) + except Exception as exc: + translated = translate_flood_wait(exc) + if translated is not exc: + raise translated from exc + # Proven live 2026-08-05 (Migragate): Telegram answered with a + # constructor newer than the installed layer, Telethon could not + # read it — and the write had applied. Treating that as a failure + # would report a successful change as an error; the domain's + # read-back is what decides. Matched by class *name* so no import + # of a Telethon-version-specific symbol is needed. + if type(exc).__name__ == "TypeNotFoundError": + return + raise + + +__all__ = ["TelethonChatInspectBackend", "TelethonChatTtlBackend"] diff --git a/src/telegram_assistant/chats/ttl.py b/src/telegram_assistant/chats/ttl.py new file mode 100644 index 0000000..bd6c820 --- /dev/null +++ b/src/telegram_assistant/chats/ttl.py @@ -0,0 +1,251 @@ +"""Auto-delete period writes — the set-ttl op of the chats domain. + +Kept out of :mod:`telegram_assistant.chats.service` (which is the read-only +inspect op) the same way ``members/`` splits ``listing.py`` out of its own +``service.py``: one operation per module, READ and WRITE not mixed. Like +``notifications mute`` this opens no operation row and has no idempotency key — +the target is naturally idempotent. + +Three Telegram facts shape the order of operations here, all proven live on +2026-08-05 (see the spec): + +* every successful ``SetHistoryTTL`` posts a member-visible service message, + **including one that changes nothing** — hence the no-op short-circuit; +* the RPC's response may fail to parse while the write applied — hence the + unconditional read-back, which is the only authority on the result; +* the flood waits on this method escalate into the hundreds of seconds — hence + the pacer around the write. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Protocol + +from telegram_assistant.access.service import AccessLevel, Authorizer +from telegram_assistant.entities import EntityRef + +#: Largest period the wire accepts — ``ttl_period`` is a 32-bit int. +MAX_TTL_SECONDS = 2**31 - 1 + +#: Suffix multipliers for :func:`parse_ttl`. No ``y``/``mo``: a month is not a +#: fixed number of seconds, and guessing one silently would be worse than +#: making the caller write ``31d``. +_TTL_UNITS: dict[str, int] = { + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + "w": 604800, +} + +_TTL_PATTERN = re.compile(r"^(\d+)([smhdw]?)$") + + +def parse_ttl(value: str) -> int: + """Parse a CLI ``--ttl`` value into seconds. + + Accepts ``off`` (case-insensitive) and ``0`` for "auto-delete disabled", + ```` with unit ``s``/``m``/``h``/``d``/``w``, and a bare integer + read as seconds. Everything else raises :class:`ValueError` naming the + offending text. + + There is deliberately no allow-list of preset durations: Telegram's clients + offer only day/week/month, but real chats were found at 31, 93 and 180 days, + so arbitrary periods pass. The server is the authority on what it accepts. + """ + text = (value or "").strip() + if not text: + raise ValueError("--ttl must not be empty; use 'off' or a duration like 1d") + if text.lower() == "off": + return 0 + + match = _TTL_PATTERN.match(text) + if match is None: + raise ValueError( + f"cannot parse --ttl {value!r}; expected 'off' or " + f"with unit one of {', '.join(sorted(_TTL_UNITS))} (e.g. 1d, 24h, 93d)" + ) + + amount = int(match.group(1)) + unit = match.group(2) or "s" + seconds = amount * _TTL_UNITS[unit] + if seconds > MAX_TTL_SECONDS: + raise ValueError( + f"--ttl {value!r} is too large; the maximum is {MAX_TTL_SECONDS} seconds" + ) + return seconds + + +@dataclass(frozen=True) +class SetTtlRequest: + """Input to :func:`set_chat_ttl`. + + ``telegram_chat_id`` is the resolved numeric id in whatever shape the + surface produced it (marked ``-100…`` or bare) — the backend gets it + verbatim, the payload reports it bare. ``period`` is seconds, ``0`` meaning + auto-delete off. ``chat_name`` is carried through for the payload. + """ + + telegram_chat_id: int + period: int + chat_name: str | None = None + + +@dataclass(frozen=True) +class SetTtlResult: + """Outcome of :func:`set_chat_ttl`. + + ``previous_ttl_seconds`` and ``ttl_period`` are ``None`` when auto-delete is + off, never ``0`` — that is what ``chats inspect`` reports for the same + field, and the two commands must not disagree about one chat. ``ttl_period`` + is what the server returned on the read-back, never the requested value. + """ + + chat_id: int + requested_ttl_seconds: int + previous_ttl_seconds: int | None + ttl_period: int | None + changed: bool + dry_run: bool = False + chat_name: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "chat_id": self.chat_id, + "chat_name": self.chat_name, + "changed": self.changed, + "dry_run": self.dry_run, + "previous_ttl_seconds": self.previous_ttl_seconds, + "requested_ttl_seconds": self.requested_ttl_seconds, + "ttl_period": self.ttl_period, + } + + +class ChatTtlBackend(Protocol): + """Telethon-facing surface needed to read and write a chat's TTL. + + Deliberately narrower than ``ChatInspectBackend``: reading the whole + ``ChatInfo`` for one field would cost peer-kind dispatch, serialization and + ``access_hash`` redaction, and a test fake would have to be a whole + ``ChatInfo``. + """ + + async def get_ttl(self, *, chat_id: int) -> int | None: ... + + async def set_ttl(self, *, chat_id: int, period: int) -> None: ... + + +def _reported(period: int | None) -> int | None: + """Normalise a wire value to the reported one: ``0`` and ``None`` are off.""" + return period or None + + +def ttl_gate_key(chat_id: int) -> str: + """Gate key for TTL writes. + + Delegates to :func:`telegram_assistant.messages.pacing.ttl_pacing_key` so + every gate key lives in one module; imported lazily to keep this module free + of an import-time dependency on ``messages``. + """ + from telegram_assistant.messages import ttl_pacing_key + + return ttl_pacing_key(chat_id) + + +async def set_chat_ttl( + *, + backend: ChatTtlBackend, + request: SetTtlRequest, + authorizer: Authorizer | None = None, + pacer: Any | None = None, + dry_run: bool = False, +) -> SetTtlResult: + """Set ``request.telegram_chat_id``'s auto-delete period. + + A WRITE op: when an ``authorizer`` is supplied it must grant WRITE on the + chat, checked before any Telegram call. + + Order: gate → read current → short-circuit when already equal → return on + ``dry_run`` → write (through ``pacer`` when supplied) → read back. The + read-back decides the result; a value disagreeing with the request raises + :class:`ValueError` naming both, because a server that clamped or dropped + the value must not be reported as success. + """ + if request.period < 0: + raise ValueError("ttl period must not be negative") + if request.period > MAX_TTL_SECONDS: + raise ValueError( + f"ttl period {request.period} is too large; " + f"the maximum is {MAX_TTL_SECONDS} seconds" + ) + + chat_id = request.telegram_chat_id + if authorizer is not None: + await authorizer.require(chat_id, AccessLevel.WRITE) + + bare_id = EntityRef(raw=int(chat_id)).numeric_id + current = _reported(await backend.get_ttl(chat_id=chat_id)) + wanted = _reported(request.period) + + if current == wanted: + # Telegram posts a service message on *every* successful set, so + # re-applying the value a chat already has is not free: a re-run over a + # folder would spam every chat in it. + return SetTtlResult( + chat_id=bare_id, + requested_ttl_seconds=request.period, + previous_ttl_seconds=current, + ttl_period=current, + changed=False, + dry_run=dry_run, + chat_name=request.chat_name, + ) + + if dry_run: + return SetTtlResult( + chat_id=bare_id, + requested_ttl_seconds=request.period, + previous_ttl_seconds=current, + ttl_period=current, + changed=True, + dry_run=True, + chat_name=request.chat_name, + ) + + async def _call() -> None: + await backend.set_ttl(chat_id=chat_id, period=request.period) + + if pacer is not None: + await pacer.run(ttl_gate_key(chat_id), _call) + else: + await _call() + + stored = _reported(await backend.get_ttl(chat_id=chat_id)) + if stored != wanted: + raise ValueError( + f"chat {bare_id}: requested ttl {request.period} but the server " + f"stored {stored if stored is not None else 0}" + ) + + return SetTtlResult( + chat_id=bare_id, + requested_ttl_seconds=request.period, + previous_ttl_seconds=current, + ttl_period=stored, + changed=True, + dry_run=False, + chat_name=request.chat_name, + ) + + +__all__ = [ + "MAX_TTL_SECONDS", + "ChatTtlBackend", + "SetTtlRequest", + "SetTtlResult", + "parse_ttl", + "set_chat_ttl", + "ttl_gate_key", +] diff --git a/src/telegram_assistant/cli/main.py b/src/telegram_assistant/cli/main.py index 4d684df..cdb91e0 100644 --- a/src/telegram_assistant/cli/main.py +++ b/src/telegram_assistant/cli/main.py @@ -3402,6 +3402,386 @@ async def _run() -> dict[str, object]: typer.echo(json.dumps(payload, sort_keys=True, default=str)) +# --- chats ------------------------------------------------------------------ + +chats_app = typer.Typer( + help="Read chat metadata and set the auto-delete period.", no_args_is_help=True +) +app.add_typer(chats_app, name="chats") + + +def _build_chat_inspect_backends(config_path: Path | None): + """Open the Telethon-backed chat-inspect + folder backends + resolver. + + Mirrors :func:`_build_member_list_backends`: the read backend, the folder + backend (for ``--chat-name`` and folder access rules) and a shared entity + resolver so ``--entity`` works. Tests monkeypatch this to inject fakes. + """ + config = _load_config_or_exit(config_path) + manager = TelethonSessionManager(config.telegram) + + async def _open(): + from telegram_assistant.chats.telethon_backend import ( + TelethonChatInspectBackend, + ) + from telegram_assistant.entities import TelethonEntityResolver + from telegram_assistant.folders import TelethonFolderBackend + + client = await manager.get_client() + if not await client.is_user_authorized(): + raise RuntimeError( + "Telethon session is not authorized; run " + "`telegram-assistant auth` first." + ) + return ( + TelethonChatInspectBackend(client), + TelethonFolderBackend(client), + TelethonEntityResolver(client), + ) + + return config, manager, _open + + +def _build_chat_ttl_backends(config_path: Path | None): + """Open the Telethon-backed chat-TTL + folder backends + resolver. + + Same shape as :func:`_build_chat_inspect_backends`, with the write adapter + in place of the read one. Tests monkeypatch this to inject fakes. + """ + config = _load_config_or_exit(config_path) + manager = TelethonSessionManager(config.telegram) + + async def _open(): + from telegram_assistant.chats.telethon_backend import TelethonChatTtlBackend + from telegram_assistant.entities import TelethonEntityResolver + from telegram_assistant.folders import TelethonFolderBackend + + client = await manager.get_client() + if not await client.is_user_authorized(): + raise RuntimeError( + "Telethon session is not authorized; run " + "`telegram-assistant auth` first." + ) + return ( + TelethonChatTtlBackend(client), + TelethonFolderBackend(client), + TelethonEntityResolver(client), + ) + + return config, manager, _open + + +def _cli_ttl_pacer(config): + """Build the auto-delete pacer for a CLI invocation. + + Mirrors :func:`_cli_pin_pacer` but on the TTL gate row and with the far + higher wait ceiling this method needs: FLOOD_WAITs on SetHistoryTTL run into + the hundreds of seconds, and the operator's choice was to sit through them. + """ + from telegram_assistant.messages import Pacer + + interval = float(getattr(config.telegram, "ttl_min_interval_seconds", 0.0)) + max_wait = float(getattr(config.telegram, "ttl_max_flood_wait_seconds", 3600.0)) + retries = int(getattr(config.telegram, "ttl_max_flood_wait_retries", 5)) + gate = None + if interval > 0: + from telegram_assistant.persistence.rate_gate import RateGateStore + + try: + gate = RateGateStore(default_database_path(config)) + except Exception: + gate = None + return Pacer( + gate, + min_interval_seconds=interval, + max_flood_wait_seconds=max_wait, + max_flood_wait_retries=retries, + ) + + +@chats_app.command("inspect") +def chats_inspect( + chat_id: int | None = typer.Option( + None, + "--chat-id", + help="Numeric Telegram chat id to read.", + ), + chat_name: str | None = typer.Option( + None, + "--chat-name", + help="Chat title (resolved within --folder-name).", + ), + entity: str | None = typer.Option( + None, + "--entity", + help="Flexible entity reference (numeric id, @username, t.me/invite link, " + "phone, or exact title) resolved via the shared resolver.", + ), + folder_name: str | None = typer.Option( + None, + "--folder-name", + help="Folder used for --chat-name lookup " + "(defaults to telegram.default_chat_folder.folder_name).", + ), + folder_id: int | None = typer.Option( + None, + "--folder-id", + help="Optional folder id cross-check.", + ), + raw: bool = typer.Option( + False, + "--raw", + help="Also include the serialized entity and Full objects under 'raw'.", + ), + config_path: Path | None = typer.Option( # noqa: B008 + None, + "--config", + "-c", + help="Path to config.yml (defaults: ./data/config.yml, then ~/.config/telegram-assistant/config.yml).", + exists=False, + ), +) -> None: + """Read one chat's metadata: TTL, description, counts, rights (READ-gated).""" + from telegram_assistant.chats import inspect_chat + from telegram_assistant.folders import FolderError, resolve_chat_in_folder + + refs = sum([chat_id is not None, chat_name is not None, entity is not None]) + if refs != 1: + typer.echo( + "exactly one of --chat-id, --chat-name, or --entity must be supplied", + err=True, + ) + raise typer.Exit(code=2) + + config, manager, open_backends = _build_chat_inspect_backends(config_path) + + if chat_name is not None: + resolved_folder_name, default_fid, _ = _resolve_folder_name( + folder_name, config_path + ) + effective_folder_id = folder_id if folder_id is not None else default_fid + else: + resolved_folder_name = folder_name + effective_folder_id = folder_id + + async def _run() -> dict[str, object]: + try: + chat_backend, folder_backend, resolver = await open_backends() + if entity is not None: + resolved_chat_id = (await resolver.resolve(entity)).chat_id + elif chat_id is not None: + resolved_chat_id = chat_id + else: + resolved = await resolve_chat_in_folder( + folder_backend, + folder_name=resolved_folder_name or "", + chat_name=chat_name or "", + folder_id=effective_folder_id, + ) + resolved_chat_id = resolved.chat_id + + authorizer = _cli_authorizer( + config, resolver=resolver, folder_backend=folder_backend + ) + info = await inspect_chat( + backend=chat_backend, + chat_id=resolved_chat_id, + raw=raw, + authorizer=authorizer, + ) + return info.to_dict() + finally: + try: + await manager.disconnect() + except Exception: + pass + + try: + payload = asyncio.run(_run()) + except FolderError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + except ValueError as exc: + # Bad caller input / an uninspectable peer — exit 2 like the rest of the + # domain rejections. AccessDenied/EntityError are RuntimeErrors, so they + # fall through to the mapping below. + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + except Exception as exc: + _raise_for_access_or_entity_error(exc) + typer.echo(f"chats inspect failed: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(json.dumps(payload, sort_keys=True, default=str)) + + +@chats_app.command("set-ttl") +def chats_set_ttl( + ttl: str = typer.Option( + ..., + "--ttl", + help="New auto-delete period: 'off' (or 0), or with " + "unit s/m/h/d/w (e.g. 1d, 24h, 93d). A bare integer is seconds.", + ), + chat_id: int | None = typer.Option( + None, + "--chat-id", + help="Numeric Telegram chat id to change.", + ), + chat_name: str | None = typer.Option( + None, + "--chat-name", + help="Chat title (resolved within --folder-name).", + ), + entity: str | None = typer.Option( + None, + "--entity", + help="Flexible entity reference (numeric id, @username, t.me/invite link, " + "phone, or exact title) resolved via the shared resolver.", + ), + folder_name: str | None = typer.Option( + None, + "--folder-name", + help="Folder used for --chat-name lookup " + "(defaults to telegram.default_chat_folder.folder_name).", + ), + folder_id: int | None = typer.Option( + None, + "--folder-id", + help="Optional folder id cross-check.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Resolve the chat and report the change without writing.", + ), + config_path: Path | None = typer.Option( # noqa: B008 + None, + "--config", + "-c", + help="Path to config.yml (defaults: ./data/config.yml, then ~/.config/telegram-assistant/config.yml).", + exists=False, + ), +) -> None: + """Set one chat's auto-delete period (WRITE-gated). + + Setting the period a chat already has writes nothing: Telegram posts a + member-visible service message on every successful change, including a + no-op one. + """ + from telegram_assistant.chats import SetTtlRequest, parse_ttl, set_chat_ttl + from telegram_assistant.folders import FolderError, resolve_chat_in_folder + + refs = sum([chat_id is not None, chat_name is not None, entity is not None]) + if refs != 1: + typer.echo( + "exactly one of --chat-id, --chat-name, or --entity must be supplied", + err=True, + ) + raise typer.Exit(code=2) + + try: + period = parse_ttl(ttl) + except ValueError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + + config, manager, open_backends = _build_chat_ttl_backends(config_path) + + if chat_name is not None: + resolved_folder_name, default_fid, _ = _resolve_folder_name( + folder_name, config_path + ) + effective_folder_id = folder_id if folder_id is not None else default_fid + else: + resolved_folder_name = folder_name + effective_folder_id = folder_id + + async def _run() -> dict[str, object]: + try: + ttl_backend, folder_backend, resolver = await open_backends() + if entity is not None: + resolved_chat_id = (await resolver.resolve(entity)).chat_id + resolved_name = entity + elif chat_id is not None: + resolved_chat_id = chat_id + resolved_name = None + else: + resolved = await resolve_chat_in_folder( + folder_backend, + folder_name=resolved_folder_name or "", + chat_name=chat_name or "", + folder_id=effective_folder_id, + ) + resolved_chat_id = resolved.chat_id + resolved_name = chat_name + + authorizer = _cli_authorizer( + config, resolver=resolver, folder_backend=folder_backend + ) + result = await set_chat_ttl( + backend=ttl_backend, + request=SetTtlRequest( + telegram_chat_id=resolved_chat_id, + period=period, + chat_name=resolved_name, + ), + authorizer=authorizer, + pacer=_cli_ttl_pacer(config), + dry_run=dry_run, + ) + return result.to_dict() + finally: + try: + await manager.disconnect() + except Exception: + pass + + try: + payload = asyncio.run(_run()) + except FolderError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + except ValueError as exc: + # Bad input, an uninspectable peer, or a read-back that disagreed with + # the request — all caller-facing, so exit 2 rather than the internal + # exit 1. AccessDenied/EntityError are RuntimeErrors and fall through. + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + except Exception as exc: + _raise_for_access_or_entity_error(exc) + _raise_for_flood_wait(exc, "chats set-ttl") + typer.echo(f"chats set-ttl failed: {exc}", err=True) + raise typer.Exit(code=1) from exc + + if dry_run: + target = payload["chat_id"] + scope = "off" if period == 0 else f"{period}s" + changed = bool(payload["changed"]) + action = f"set auto-delete of chat {target} to {scope}" + envelope = { + "status": "dry_run", + "dry_run": True, + "command": "chats.set-ttl", + "would": action if changed else f"leave chat {target} unchanged", + "resolved": payload, + "planned_actions": [action] if changed else [], + "warnings": ( + [] + if changed + else [ + f"chat {target} already has this auto-delete period; " + "no write would be issued (Telegram posts a visible service " + "message on every successful change)" + ] + ), + } + typer.echo(json.dumps(envelope, sort_keys=True, default=str)) + return + + typer.echo(json.dumps(payload, sort_keys=True, default=str)) + + # --- messages --------------------------------------------------------------- messages_app = typer.Typer(help="Send messages and service commands.", no_args_is_help=True) diff --git a/src/telegram_assistant/config/models.py b/src/telegram_assistant/config/models.py index 5210fbb..c31edbd 100644 --- a/src/telegram_assistant/config/models.py +++ b/src/telegram_assistant/config/models.py @@ -255,6 +255,38 @@ class TelegramConfig(BaseModel): "HTTP/MCP server pace against each other). 0 disables pacing." ), ) + ttl_min_interval_seconds: float = Field( + default=2.0, + ge=0.0, + description=( + "Minimum seconds between two `chats set-ttl` writes on the same " + "chat, paced through the same shared SQLite gate as pins but on a " + "separate row (Telegram meters SetHistoryTTL separately). " + "0 disables pacing." + ), + ) + ttl_max_flood_wait_seconds: float = Field( + default=3600.0, + ge=0.0, + description=( + "Longest single FLOOD_WAIT `chats set-ttl` will sleep through. " + "Waits on SetHistoryTTL escalate into the hundreds of seconds " + "(261s, 703s and 866s observed within one hour), so the default is " + "far above the 60s used elsewhere — but finite, so a stuck call " + "cannot hang unnoticed forever." + ), + ) + ttl_max_flood_wait_retries: int = Field( + default=5, + ge=1, + description=( + "How many FLOOD_WAIT pauses `chats set-ttl` will sit through before " + "giving up. Above the pacer's own default of 3 because the waits " + "escalate: one chat can plausibly spend two or three of them, and " + "running out reports a flood wait as a failure on a call that was " + "about to succeed." + ), + ) download_root: str | None = Field( default=None, description=( diff --git a/src/telegram_assistant/http_api/app.py b/src/telegram_assistant/http_api/app.py index 7abeb93..c9db4f5 100644 --- a/src/telegram_assistant/http_api/app.py +++ b/src/telegram_assistant/http_api/app.py @@ -12,6 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware from telegram_assistant import __version__ +from telegram_assistant.chats import ChatInspectBackend from telegram_assistant.config import ( AppConfig, ConfigWatcher, @@ -24,6 +25,7 @@ from telegram_assistant.groups import GroupBackend from telegram_assistant.health import collect_health, default_database_path from telegram_assistant.http_api.auth import BearerAuth +from telegram_assistant.http_api.chats import build_router as build_chats_router from telegram_assistant.http_api.folders import build_router as build_folders_router from telegram_assistant.http_api.groups import build_router as build_groups_router from telegram_assistant.http_api.mcp import ( @@ -69,6 +71,7 @@ _log = get_logger(__name__) +ChatInspectBackendFactory = Callable[[Request], ChatInspectBackend | None] FolderBackendFactory = Callable[[Request], FolderBackend | None] GroupBackendFactory = Callable[[Request], GroupBackend | None] TopicBackendFactory = Callable[[Request], TopicBackend | None] @@ -222,6 +225,30 @@ def _factory(_request: Request) -> MemberListBackend | None: return _factory +def _default_chat_inspect_backend_factory( + session_manager: TelethonSessionManager | None, +) -> ChatInspectBackendFactory: + """Build a Telethon-backed chat-inspect factory for the read op. + + Mirrors :func:`_default_member_list_backend_factory`: returns ``None`` until + a Telethon client is available so the endpoint can return 503. + """ + + def _factory(_request: Request) -> ChatInspectBackend | None: + if session_manager is None: + return None + client = getattr(session_manager, "_client", None) + if client is None: + return None + from telegram_assistant.chats.telethon_backend import ( + TelethonChatInspectBackend, + ) + + return TelethonChatInspectBackend(client) + + return _factory + + def _default_group_backend_factory( session_manager: TelethonSessionManager | None, ) -> GroupBackendFactory: @@ -581,6 +608,7 @@ def create_app( member_backend_factory: MemberBackendFactory | None = None, member_remove_backend_factory: MemberRemoveBackendFactory | None = None, member_list_backend_factory: MemberListBackendFactory | None = None, + chat_inspect_backend_factory: ChatInspectBackendFactory | None = None, message_backend_factory: MessageBackendFactory | None = None, message_read_backend_factory: MessageReadBackendFactory | None = None, search_backend_factory: SearchBackendFactory | None = None, @@ -840,6 +868,11 @@ def _on_reload() -> None: if member_list_backend_factory is not None else _default_member_list_backend_factory(session_manager) ) + app.state.chat_inspect_backend_factory = ( + chat_inspect_backend_factory + if chat_inspect_backend_factory is not None + else _default_chat_inspect_backend_factory(session_manager) + ) app.state.resolver_factory = ( resolver_factory if resolver_factory is not None @@ -927,6 +960,7 @@ def _on_reload() -> None: app.include_router(build_groups_router(), prefix="/telegram") app.include_router(build_topics_router(), prefix="/telegram") app.include_router(build_members_router(), prefix="/telegram") + app.include_router(build_chats_router(), prefix="/telegram") app.include_router(build_messages_router(), prefix="/telegram") app.include_router(build_notifications_router(), prefix="/telegram") if mcp_asgi_app is not None: diff --git a/src/telegram_assistant/http_api/chats.py b/src/telegram_assistant/http_api/chats.py new file mode 100644 index 0000000..cd515e8 --- /dev/null +++ b/src/telegram_assistant/http_api/chats.py @@ -0,0 +1,218 @@ +"""HTTP routes for read-only chat metadata (the ``chats`` domain). + +One route, ``GET /telegram/chats/inspect``, exposing +:func:`telegram_assistant.chats.inspect_chat`. The reference handling, the READ +gate wiring and the domain call live in :func:`inspect_chat_for_request`, which +the MCP tool ``telegram_chats_inspect`` calls verbatim — the two remote surfaces +must not be able to drift apart on which references they accept or on whether +``raw`` reaches the domain op. +""" + +from __future__ import annotations + +import time +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, status + +from telegram_assistant.access import AccessDenied +from telegram_assistant.chats import ChatInspectBackend, inspect_chat +from telegram_assistant.folders import FolderBackend +from telegram_assistant.http_api.access import build_authorizer, translate_access_error +from telegram_assistant.http_api.auth import BearerAuth +from telegram_assistant.http_api.messages import _translate_flood_wait +from telegram_assistant.http_api.topics import _resolve_chat_id_generic +from telegram_assistant.worker.queue import FloodWaitError + +#: Why a remote caller may not ask for the serialized Telethon objects. The +#: curated payload is designed to be enough; ``raw`` carries considerably more +#: (a legacy group's whole member roster via ``ChatFull.participants``, a user's +#: business location and stories), and this project already keeps local-only +#: capabilities off the remote surfaces — ``scan_media`` resolves server-side +#: paths for the CLI alone, ``messages download --out`` is unconfined only there. +RAW_REJECTED_MESSAGE = ( + "raw is CLI-only: the serialized entity/Full objects are never returned " + "over HTTP or MCP; run `telegram-assistant chats inspect --raw` locally" +) + + +def _chat_inspect_backend_or_503(request: Request) -> ChatInspectBackend: + """Resolve the chat-inspect backend, or raise 503. + + Two stages like every sibling helper: no factory at all means nobody wired + one (only a test opts out); a factory returning ``None`` is the production + case where the Telethon client is not connected yet. + """ + factory = getattr(request.app.state, "chat_inspect_backend_factory", None) + if factory is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Telegram chat-inspect backend is not configured (session may be unauthorized)", + ) + backend = factory(request) + if backend is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Telegram chat-inspect backend is not available", + ) + return backend + + +def _folder_backend_optional(request: Request) -> FolderBackend | None: + factory = getattr(request.app.state, "folder_backend_factory", None) + if factory is None: + return None + return factory(request) + + +def validate_chat_inspect_args( + *, + chat_id: int | None, + chat_name: str | None, + entity: str | int | None, + folder_name: str | None, + raw: bool, +) -> None: + """Reject a malformed remote chats-inspect request (raises ``ValueError``). + + ``raw`` is checked **first**, and rejected rather than ignored: a silently + dropped ``raw=true`` is indistinguishable from an empty raw payload, so the + caller would never learn the flag went nowhere. Checking it before the + reference rules also means the message names the real problem even when the + request is malformed in two ways at once. + + The reference rules mirror ``PinBody._shape``: exactly one of ``chat_id`` / + ``chat_name`` / ``entity``, and ``chat_name`` needs ``folder_name`` (there + is no config-derived folder default on the remote surfaces — that is a CLI + convenience). + """ + if raw: + raise ValueError(RAW_REJECTED_MESSAGE) + refs = sum([chat_id is not None, chat_name is not None, entity is not None]) + if refs != 1: + raise ValueError("provide exactly one of chat_id, chat_name, or entity") + if chat_name is not None and folder_name is None: + raise ValueError("chat_name requires folder_name") + + +def _annotate_retry_after(exc: FloodWaitError) -> FloodWaitError: + """Give an *unpaced* flood-wait the retry fields the surfaces report. + + ``messages pin``/``unpin`` run behind a pacer, so what reaches their + surfaces is a ``PacedFloodWaitError`` already carrying + ``retry_after_seconds``/``retry_at`` — the two fields + :func:`retry_after_details` reads and that both surfaces echo (HTTP also as + the standard ``Retry-After`` header). ``chats inspect`` is a one-shot read + with no pacer, so its adapter surfaces a bare ``FloodWaitError`` whose wait + window lives on ``.seconds`` only. Copying it across lets the *same* + mapping produce the same payload here rather than a second, poorer one — + the caller of a read op needs to know when to come back just as much. + """ + if getattr(exc, "retry_after_seconds", None) is None: + seconds = float(getattr(exc, "seconds", 0.0) or 0.0) + exc.retry_after_seconds = seconds # type: ignore[attr-defined] + exc.retry_at = time.time() + seconds # type: ignore[attr-defined] + return exc + + +async def inspect_chat_for_request( + request: Request, + *, + chat_id: int | None = None, + chat_name: str | None = None, + entity: str | int | None = None, + folder_name: str | None = None, + folder_id: int | None = None, + raw: bool = False, +) -> dict[str, Any]: + """Resolve the chat reference, gate READ, and return the inspect payload. + + Shared by the HTTP route and the MCP tool. It raises rather than mapping, + so each surface applies its own taxonomy: ``ValueError`` for malformed + input, ``HTTPException`` for the 503/404/409 resolution failures, + ``AccessDenied`` for a denied chat, and ``FloodWaitError`` (already carrying + retry-after) for a throttle. + + ``raw`` is only ever *rejected* here; the domain call passes ``raw=False`` + unconditionally, so no remote caller's flag can reach the serializer. + """ + validate_chat_inspect_args( + chat_id=chat_id, + chat_name=chat_name, + entity=entity, + folder_name=folder_name, + raw=raw, + ) + backend = _chat_inspect_backend_or_503(request) + try: + # The annotation covers *reference resolution* too, not just the domain + # call: the ``entity`` probe (``get_entity`` plus the exact-title + # ``iter_dialogs`` scan) and the ``chat_name`` branch's + # ``list_folders()`` are classic FLOOD_WAIT sources, and their adapters + # raise the same bare ``FloodWaitError`` carrying only ``.seconds``. + # Annotating only the ``inspect_chat`` call left half the surface + # answering 502 with no ``Retry-After``/``retry_after_seconds`` at all. + resolved_chat_id = await _resolve_chat_id_generic( + telegram_chat_id=chat_id, + chat_name=chat_name, + entity=entity, + folder_name=folder_name, + folder_id=folder_id, + request=request, + ) + authorizer = build_authorizer( + request, folder_backend=_folder_backend_optional(request) + ) + info = await inspect_chat( + backend=backend, + chat_id=resolved_chat_id, + raw=False, + authorizer=authorizer, + ) + except FloodWaitError as exc: + _annotate_retry_after(exc) + raise + return info.to_dict() + + +def build_router() -> APIRouter: + router = APIRouter(dependencies=[BearerAuth]) + + @router.get("/chats/inspect") + async def chats_inspect( + request: Request, + chat_id: int | None = None, + chat_name: str | None = None, + entity: str | None = None, + folder_name: str | None = None, + folder_id: int | None = None, + raw: bool = False, + ) -> dict[str, Any]: + """Read one chat's metadata: TTL, description, counts, rights (READ-gated). + + Target the chat with exactly one of ``chat_id``, ``entity``, or + ``chat_name`` (which requires ``folder_name``, optionally cross-checked + by ``folder_id``) — the same set the CLI takes. ``raw`` is accepted only + so it can be rejected with 400: the serialized Telethon objects are + CLI-only. The body is the domain payload verbatim. + """ + try: + return await inspect_chat_for_request( + request, + chat_id=chat_id, + chat_name=chat_name, + entity=entity, + folder_name=folder_name, + folder_id=folder_id, + raw=raw, + ) + except AccessDenied as exc: + raise translate_access_error(exc) from exc + except FloodWaitError as exc: + raise _translate_flood_wait(exc) from exc + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + + return router diff --git a/src/telegram_assistant/http_api/mcp/tools.py b/src/telegram_assistant/http_api/mcp/tools.py index 983b809..a3cb40d 100644 --- a/src/telegram_assistant/http_api/mcp/tools.py +++ b/src/telegram_assistant/http_api/mcp/tools.py @@ -58,6 +58,7 @@ translate_access_error, translate_entity_error, ) +from telegram_assistant.http_api.chats import inspect_chat_for_request from telegram_assistant.http_api.folders import AddChatRequest from telegram_assistant.http_api.groups import ( ContactBody, @@ -2063,6 +2064,48 @@ async def telegram_members_list( except Exception as exc: _raise_from_exception(exc) + @server.tool( + name="telegram_chats_inspect", + annotations=READ_TELEGRAM, + structured_output=True, + ) + async def telegram_chats_inspect( + chat_id: int | None = None, + chat_name: str | None = None, + entity: str | int | None = None, + folder_name: str | None = None, + folder_id: int | None = None, + raw: bool = False, + ) -> dict[str, Any]: + """Read one chat's metadata: TTL, description, counts, rights (READ-gated). + + Answers "what is this chat" for every peer kind with one flat payload — + auto-delete ``ttl_period``, ``about``, member counts, slow mode, + restrictions, our own rights — so a caller can read a field without + branching on whether the target is a supergroup, a channel or a private + chat. Target it with exactly one of ``chat_id``, ``entity``, or + ``chat_name`` (which requires ``folder_name``, optionally cross-checked + by ``folder_id``). It never writes: there is no way to *change* any of + these settings through this tool. + + ``raw`` is accepted only so it can be rejected — the serialized Telethon + objects are CLI-only, and silently dropping the flag would look like an + empty raw payload. + """ + request = _request(provider) + try: + return await inspect_chat_for_request( + request, # type: ignore[arg-type] + chat_id=chat_id, + chat_name=chat_name, + entity=entity, + folder_name=folder_name, + folder_id=folder_id, + raw=raw, + ) + except Exception as exc: + _raise_from_exception(exc) + @server.tool( name="telegram_members_add", annotations=WRITE_IDEMPOTENT, diff --git a/src/telegram_assistant/messages/__init__.py b/src/telegram_assistant/messages/__init__.py index 00fc660..e8a8bc8 100644 --- a/src/telegram_assistant/messages/__init__.py +++ b/src/telegram_assistant/messages/__init__.py @@ -47,6 +47,7 @@ RateGate, pin_pacing_key, retry_after_details, + ttl_pacing_key, ) from telegram_assistant.messages.pinning import ( PinBackend, @@ -236,6 +237,7 @@ "RateGate", "pin_pacing_key", "retry_after_details", + "ttl_pacing_key", "PinBackend", "PinMessageRequest", "PinMessageResult", diff --git a/src/telegram_assistant/messages/pacing.py b/src/telegram_assistant/messages/pacing.py index ae3c0fb..657d42a 100644 --- a/src/telegram_assistant/messages/pacing.py +++ b/src/telegram_assistant/messages/pacing.py @@ -170,6 +170,13 @@ async def run(self, key: str, op: Callable[[], Awaitable[T]]) -> T: retry_at=retry_at, attempts=attempts, ) from exc + # A silent multi-minute (or multi-hour, across retries) sleep is + # indistinguishable from a hang to whoever is watching the + # process — name the chat/key, the pause and which attempt this + # is before going quiet for it. + _log.warning( + "flood_wait_pause", key=key, seconds=pause, attempt=attempts + ) await self._sleep(pause) async def _wait_for_slot(self, key: str, *, attempts: int = 0) -> None: @@ -237,6 +244,23 @@ def pin_pacing_key(chat_id: int) -> str: return f"pin:{EntityRef(raw=int(chat_id)).numeric_id}" +def ttl_pacing_key(chat_id: int) -> str: + """Gate key for auto-delete (TTL) writes — a different Telegram limit than pins. + + Its own gate row rather than sharing ``pin:``: Telegram meters + ``messages.SetHistoryTTL`` separately, and observed waits on it escalate far + past anything pins produce (261s, 703s and 866s within one hour on one + account, 2026-08-05). Sharing a row would let a slow TTL sweep throttle + unrelated pins and vice versa. + + The id is reduced to its bare form for the same reason ``pin_pacing_key`` + does it: an explicit ``--chat-id -1001234567890`` keeps the marker while an + ``--entity`` lookup yields the bare id, and keying on the raw value would + open two independent rows for one chat. + """ + return f"ttl:{EntityRef(raw=int(chat_id)).numeric_id}" + + def retry_after_details(exc: BaseException) -> dict[str, float] | None: """Extract the retry-after payload from a paced flood-wait error. @@ -262,4 +286,5 @@ def retry_after_details(exc: BaseException) -> dict[str, float] | None: "RateGate", "pin_pacing_key", "retry_after_details", + "ttl_pacing_key", ] diff --git a/tests/test_chats_inspect.py b/tests/test_chats_inspect.py new file mode 100644 index 0000000..b6a9b1d --- /dev/null +++ b/tests/test_chats_inspect.py @@ -0,0 +1,106 @@ +"""Tests for the read-only chat-inspect domain op.""" + +from __future__ import annotations + +import pytest + +from telegram_assistant.access import AccessDenied, Authorizer +from telegram_assistant.chats import ChatInfo, inspect_chat +from telegram_assistant.config.models import AccessConfig, AccessRule + + +class FakeBackend: + """Records calls and returns a canned ChatInfo.""" + + def __init__(self, info: ChatInfo | None = None) -> None: + self.info = info or ChatInfo(chat_id=42, kind="supergroup", title="T") + self.calls: list[dict[str, object]] = [] + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + self.calls.append({"chat_id": chat_id, "raw": raw}) + return self.info + + +@pytest.mark.asyncio +async def test_inspect_chat_returns_backend_result() -> None: + backend = FakeBackend() + + result = await inspect_chat(backend=backend, chat_id=42) + + assert result is backend.info + assert backend.calls == [{"chat_id": 42, "raw": False}] + + +@pytest.mark.asyncio +async def test_inspect_chat_passes_raw_through() -> None: + backend = FakeBackend() + + await inspect_chat(backend=backend, chat_id=42, raw=True) + + assert backend.calls == [{"chat_id": 42, "raw": True}] + + +@pytest.mark.asyncio +async def test_read_gate_denies_before_any_rpc() -> None: + backend = FakeBackend() + authorizer = Authorizer(AccessConfig(rules=[])) + + with pytest.raises(AccessDenied): + await inspect_chat(backend=backend, chat_id=42, authorizer=authorizer) + + assert backend.calls == [] + + +@pytest.mark.asyncio +async def test_read_gate_allows_granted_chat() -> None: + backend = FakeBackend() + authorizer = Authorizer( + AccessConfig(rules=[AccessRule(all=True, permissions=["read"])]) + ) + + result = await inspect_chat(backend=backend, chat_id=42, authorizer=authorizer) + + assert result.chat_id == 42 + assert backend.calls == [{"chat_id": 42, "raw": False}] + + +@pytest.mark.asyncio +async def test_write_only_grant_does_not_satisfy_read() -> None: + backend = FakeBackend() + authorizer = Authorizer( + AccessConfig(rules=[AccessRule(all=True, permissions=["write"])]) + ) + + with pytest.raises(AccessDenied): + await inspect_chat(backend=backend, chat_id=42, authorizer=authorizer) + + assert backend.calls == [] + + +def test_to_dict_omits_raw_when_absent() -> None: + info = ChatInfo(chat_id=7, kind="user", title="Someone") + + payload = info.to_dict() + + assert payload["chat_id"] == 7 + assert payload["kind"] == "user" + assert "raw" not in payload + # Fields that do not apply to a user are present and null, so the shape + # never depends on what was inspected. + assert payload["admins_count"] is None + assert payload["ttl_period"] is None + # The notification settings are three fields, not two: `silent` (sound off) + # is a separate Telegram flag from `muted` (notifications suppressed now). + assert payload["muted"] is False + assert payload["muted_until"] is None + assert payload["silent"] is False + + +def test_to_dict_includes_raw_when_present() -> None: + info = ChatInfo( + chat_id=7, kind="supergroup", title="T", raw={"entity": {}, "full": {}} + ) + + payload = info.to_dict() + + assert payload["raw"] == {"entity": {}, "full": {}} diff --git a/tests/test_chats_inspect_backend.py b/tests/test_chats_inspect_backend.py new file mode 100644 index 0000000..860fd7f --- /dev/null +++ b/tests/test_chats_inspect_backend.py @@ -0,0 +1,916 @@ +"""Tests for the Telethon chat-inspect adapter. + +Exercised against a fake client: the stand-in classes' *names* are what the +peer dispatch keys on, mirroring tests/test_members_list_backend.py. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta + +import pytest + +from telegram_assistant.chats.service import CHAT_KINDS +from telegram_assistant.chats.telethon_backend import TelethonChatInspectBackend + +#: A mute that has not expired. Fixed offsets rather than a frozen clock: the +#: backend compares ``mute_until`` against the real ``datetime.now(UTC)``, and +#: a decade of slack keeps that comparison unambiguous without a time-travel +#: dependency. +_FUTURE = datetime.now(UTC) + timedelta(days=3650) +#: A mute that has already expired -- Telegram leaves the stale timestamp on +#: the settings, so only its position relative to now says anything. +_PAST = datetime.now(UTC) - timedelta(days=3650) +#: What ``notifications unmute`` writes: ``InputPeerNotifySettings(mute_until=0)`` +#: comes back through telethon 1.44's ``tgread_date`` as a non-``None`` +#: ``1970-01-01T00:00:00+00:00``, not as ``None``. +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) + +# --- fake telethon-shaped objects ----------------------------------------- + + +class InputPeerChannel: + def __init__(self, channel_id: int) -> None: + self.channel_id = channel_id + + +class InputPeerChat: + def __init__(self, chat_id: int) -> None: + self.chat_id = chat_id + + +class InputPeerUser: + def __init__(self, user_id: int) -> None: + self.user_id = user_id + + +class Rights: + """Stand-in for ChatAdminRights / ChatBannedRights.""" + + def __init__(self, **flags) -> None: + self._flags = flags + + def to_dict(self) -> dict: + return {"_": "ChatAdminRights", **self._flags} + + +class NotifySettings: + """Stand-in for PeerNotifySettings. + + Both flags default to ``None``, not ``False``: telethon leaves an unset + optional flag as ``None``, and the backend must read that as "not silent" + rather than tripping over it. + """ + + def __init__(self, mute_until=None, silent=None) -> None: + self.mute_until = mute_until + self.silent = silent + + +class InviteExported: + def __init__(self, link: str) -> None: + self.link = link + + +class RestrictionReason: + def __init__(self, platform: str, reason: str, text: str) -> None: + self.platform = platform + self.reason = reason + self.text = text + + +class Username: + def __init__(self, username: str, active: bool = True) -> None: + self.username = username + self.active = active + + +class ReactionEmoji: + def __init__(self, emoticon: str) -> None: + self.emoticon = emoticon + + +class ChatReactionsSome: + def __init__(self, reactions) -> None: + self.reactions = reactions + + +class ChatReactionsAll: + pass + + +class Photo: + """Stand-in for Telethon's Photo/UserProfilePhoto/ChatPhoto. + + Carries its own ``access_hash`` — this is the exact nested shape that + leaked live through ``--raw`` (``raw.full.chat_photo.access_hash``, + ``raw.full.profile_photo.access_hash``): a top-level-only redaction filter + never reaches a key nested one level down like this. + """ + + def __init__(self, photo_id: int, access_hash: int) -> None: + self.id = photo_id + self.access_hash = access_hash + + def to_dict(self) -> dict: + return {"_": "Photo", "id": self.id, "access_hash": self.access_hash} + + +class Channel: + def __init__(self, cid: int, **kw) -> None: + self.id = cid + self.title = kw.get("title", "Chat") + self.username = kw.get("username") + self.usernames = kw.get("usernames") + self.date = kw.get("date") + self.creator = kw.get("creator", False) + self.left = kw.get("left", False) + self.broadcast = kw.get("broadcast", False) + self.megagroup = kw.get("megagroup", True) + self.gigagroup = kw.get("gigagroup", False) + self.forum = kw.get("forum", False) + self.forum_tabs = kw.get("forum_tabs", False) + self.verified = kw.get("verified", False) + self.scam = kw.get("scam", False) + self.fake = kw.get("fake", False) + self.restricted = kw.get("restricted", False) + self.restriction_reason = kw.get("restriction_reason") + self.noforwards = kw.get("noforwards", False) + self.join_to_send = kw.get("join_to_send", False) + self.join_request = kw.get("join_request", False) + self.call_active = kw.get("call_active", False) + self.admin_rights = kw.get("admin_rights") + self.default_banned_rights = kw.get("default_banned_rights") + self.photo = kw.get("photo") + self.access_hash = 999999 + + +class ChannelFull: + def __init__(self, cid: int, **kw) -> None: + self.id = cid + self.about = kw.get("about", "") + self.ttl_period = kw.get("ttl_period") + self.pinned_msg_id = kw.get("pinned_msg_id") + self.folder_id = kw.get("folder_id") + self.notify_settings = kw.get("notify_settings", NotifySettings()) + self.has_scheduled = kw.get("has_scheduled", False) + self.participants_count = kw.get("participants_count") + self.admins_count = kw.get("admins_count") + self.kicked_count = kw.get("kicked_count") + self.banned_count = kw.get("banned_count") + self.online_count = kw.get("online_count") + self.slowmode_seconds = kw.get("slowmode_seconds") + self.slowmode_next_send_date = kw.get("slowmode_next_send_date") + self.linked_chat_id = kw.get("linked_chat_id") + self.migrated_from_chat_id = kw.get("migrated_from_chat_id") + self.hidden_prehistory = kw.get("hidden_prehistory", False) + self.participants_hidden = kw.get("participants_hidden", False) + self.antispam = kw.get("antispam", False) + self.can_view_participants = kw.get("can_view_participants", False) + self.can_view_stats = kw.get("can_view_stats", False) + self.can_delete_channel = kw.get("can_delete_channel", False) + self.can_set_username = kw.get("can_set_username", False) + self.requests_pending = kw.get("requests_pending") + self.unread_count = kw.get("unread_count") + self.available_reactions = kw.get("available_reactions") + self.reactions_limit = kw.get("reactions_limit") + self.exported_invite = kw.get("exported_invite") + self.chat_photo = kw.get("chat_photo") + + def to_dict(self) -> dict: + # Real ChannelFull.to_dict() calls self.chat_photo.to_dict() itself + # (verified by reading the generated telethon source) -- mirror that + # here so this fake exercises the same "already nested inside a plain + # dict by the time _serialize sees it" shape that leaked live. + return { + "_": "ChannelFull", + "id": self.id, + "about": self.about, + "chat_photo": self.chat_photo.to_dict() if self.chat_photo is not None else None, + } + + +class Chat: + def __init__(self, cid: int, **kw) -> None: + self.id = cid + self.title = kw.get("title", "Legacy") + self.date = kw.get("date") + self.creator = kw.get("creator", False) + self.left = kw.get("left", False) + self.deactivated = kw.get("deactivated", False) + self.noforwards = kw.get("noforwards", False) + self.call_active = kw.get("call_active", False) + self.participants_count = kw.get("participants_count") + self.migrated_to = kw.get("migrated_to") + self.admin_rights = kw.get("admin_rights") + self.default_banned_rights = kw.get("default_banned_rights") + self.photo = kw.get("photo") + + +class ChatFull: + def __init__(self, cid: int, **kw) -> None: + self.id = cid + self.about = kw.get("about", "") + self.ttl_period = kw.get("ttl_period") + self.pinned_msg_id = kw.get("pinned_msg_id") + self.folder_id = kw.get("folder_id") + self.notify_settings = kw.get("notify_settings", NotifySettings()) + self.has_scheduled = kw.get("has_scheduled", False) + self.can_set_username = kw.get("can_set_username", False) + self.requests_pending = kw.get("requests_pending") + self.available_reactions = kw.get("available_reactions") + self.reactions_limit = kw.get("reactions_limit") + self.exported_invite = kw.get("exported_invite") + self.chat_photo = kw.get("chat_photo") + + def to_dict(self) -> dict: + return { + "_": "ChatFull", + "id": self.id, + "chat_photo": self.chat_photo.to_dict() if self.chat_photo is not None else None, + } + + +class InputPeerChannelMigrated: + def __init__(self, channel_id: int) -> None: + self.channel_id = channel_id + + +class UserStatusRecently: + pass + + +class User: + def __init__(self, uid: int, **kw) -> None: + self.id = uid + self.first_name = kw.get("first_name", "First") + self.last_name = kw.get("last_name") + self.username = kw.get("username") + self.usernames = kw.get("usernames") + self.phone = kw.get("phone") + self.bot = kw.get("bot", False) + self.deleted = kw.get("deleted", False) + self.premium = kw.get("premium", False) + self.contact = kw.get("contact", False) + self.mutual_contact = kw.get("mutual_contact", False) + self.verified = kw.get("verified", False) + self.scam = kw.get("scam", False) + self.fake = kw.get("fake", False) + self.restricted = kw.get("restricted", False) + self.restriction_reason = kw.get("restriction_reason") + self.status = kw.get("status") + self.photo = kw.get("photo") + self.access_hash = 777777 + + +class Birthday: + def __init__(self, day: int, month: int, year=None) -> None: + self.day = day + self.month = month + self.year = year + + +class UserFull: + def __init__(self, uid: int, **kw) -> None: + self.id = uid + self.about = kw.get("about") + self.ttl_period = kw.get("ttl_period") + self.pinned_msg_id = kw.get("pinned_msg_id") + self.folder_id = kw.get("folder_id") + self.notify_settings = kw.get("notify_settings", NotifySettings()) + self.has_scheduled = kw.get("has_scheduled", False) + self.blocked = kw.get("blocked", False) + self.common_chats_count = kw.get("common_chats_count") + self.birthday = kw.get("birthday") + self.personal_channel_id = kw.get("personal_channel_id") + self.profile_photo = kw.get("profile_photo") + + def to_dict(self) -> dict: + return { + "_": "UserFull", + "id": self.id, + "profile_photo": ( + self.profile_photo.to_dict() if self.profile_photo is not None else None + ), + } + + +class FullChannelResult: + def __init__(self, full_chat, chats) -> None: + self.full_chat = full_chat + self.chats = chats + self.users = [] + + +class FullUserResult: + def __init__(self, full_user, users) -> None: + self.full_user = full_user + self.users = users + self.chats = [] + + +class FakeClient: + def __init__(self, *, peer, result) -> None: + self._peer = peer + self._result = result + self.requests: list[object] = [] + + async def get_input_entity(self, ref): + return self._peer + + async def __call__(self, request): + self.requests.append(request) + return self._result + + +# --- supergroup ------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_supergroup_mapping() -> None: + created = datetime(2024, 3, 1, tzinfo=UTC) + channel = Channel( + 5, + title="Team", + username="teamchat", + usernames=[Username("alt"), Username("dead", active=False)], + date=created, + forum=True, + forum_tabs=True, + creator=True, + noforwards=True, + admin_rights=Rights(delete_messages=True), + default_banned_rights=Rights(send_media=True), + ) + full = ChannelFull( + 5, + about="About us", + ttl_period=86400, + pinned_msg_id=41, + folder_id=1, + participants_count=12, + admins_count=2, + kicked_count=0, + banned_count=1, + online_count=3, + slowmode_seconds=30, + available_reactions=ChatReactionsSome([ReactionEmoji("👍")]), + exported_invite=InviteExported("https://t.me/+abc"), + notify_settings=NotifySettings(mute_until=_FUTURE, silent=True), + ) + client = FakeClient( + peer=InputPeerChannel(5), result=FullChannelResult(full, [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=-1000000000005, raw=False) + + assert info.chat_id == 5 + assert info.kind == "supergroup" + assert info.kind in CHAT_KINDS + assert info.title == "Team" + assert info.username == "teamchat" + assert info.usernames == ("alt",) + assert info.about == "About us" + assert info.ttl_period == 86400 + assert info.pinned_message_id == 41 + assert info.archived is True + assert info.muted is True + assert info.muted_until == _FUTURE + assert info.silent is True + assert info.created_at == created + assert info.is_forum is True + assert info.topics_layout == "tabs" + assert info.participants_count == 12 + assert info.admins_count == 2 + assert info.banned_count == 1 + assert info.online_count == 3 + assert info.slowmode_seconds == 30 + assert info.noforwards is True + assert info.is_creator is True + assert info.invite_link == "https://t.me/+abc" + assert info.available_reactions == ["👍"] + assert info.my_admin_rights == {"delete_messages": True} + assert info.default_banned_rights == {"send_media": True} + assert info.raw is None + + +@pytest.mark.asyncio +async def test_broadcast_channel_kind_and_layout_default() -> None: + channel = Channel(6, broadcast=True, megagroup=False) + client = FakeClient( + peer=InputPeerChannel(6), result=FullChannelResult(ChannelFull(6), [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=6, raw=False) + + assert info.kind == "channel" + assert info.kind in CHAT_KINDS + assert info.broadcast is True + assert info.is_forum is False + assert info.topics_layout is None + + +@pytest.mark.asyncio +async def test_reactions_all_maps_to_all() -> None: + channel = Channel(6) + full = ChannelFull(6, available_reactions=ChatReactionsAll()) + client = FakeClient(peer=InputPeerChannel(6), result=FullChannelResult(full, [channel])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=6, raw=False) + + assert info.available_reactions == "all" + + +@pytest.mark.asyncio +async def test_restriction_reason_is_mapped() -> None: + channel = Channel( + 8, + restricted=True, + restriction_reason=[RestrictionReason("all", "terms", "violated ToS")], + ) + client = FakeClient( + peer=InputPeerChannel(8), result=FullChannelResult(ChannelFull(8), [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=8, raw=False) + + assert info.restricted is True + assert info.restriction_reason == ( + {"platform": "all", "reason": "terms", "text": "violated ToS"}, + ) + + +# --- notification settings --------------------------------------------------- +# +# ``muted`` answers "are notifications suppressed *right now*", which is true +# only while ``mute_until`` is in the future. Three states make the naive +# "``mute_until`` is populated" test report a muted chat that is not muted, and +# the epoch one is not hypothetical: ``notifications unmute`` in this very +# project sends ``InputPeerNotifySettings(mute_until=0)``, and telethon 1.44's +# ``BinaryReader.tgread_date`` no longer special-cases 0 -- it returns +# ``_EPOCH + timedelta(seconds=0)``, a non-``None`` 1970 datetime. ``silent`` +# is a separate TL flag (the notification's *sound*), so it gets its own field. + + +def _channel_with_notify_settings(settings) -> TelethonChatInspectBackend: + channel = Channel(20) + full = ChannelFull(20, notify_settings=settings) + return TelethonChatInspectBackend( + FakeClient(peer=InputPeerChannel(20), result=FullChannelResult(full, [channel])) + ) + + +@pytest.mark.asyncio +async def test_future_mute_until_reports_muted() -> None: + backend = _channel_with_notify_settings(NotifySettings(mute_until=_FUTURE)) + + info = await backend.inspect_chat(chat_id=20, raw=False) + + assert info.muted is True + assert info.muted_until == _FUTURE + assert info.silent is False + + +@pytest.mark.asyncio +async def test_expired_mute_until_reports_unmuted_and_drops_the_stale_date() -> None: + backend = _channel_with_notify_settings(NotifySettings(mute_until=_PAST)) + + info = await backend.inspect_chat(chat_id=20, raw=False) + + assert info.muted is False + # Not ``_PAST``: a timestamp that has already passed says nothing, and + # reporting it invites a reader to treat it as a live mute window. + assert info.muted_until is None + assert info.silent is False + + +@pytest.mark.asyncio +async def test_epoch_mute_until_is_the_unmuted_state() -> None: + """``notifications unmute`` writes ``mute_until=0``; that is *not* a mute.""" + backend = _channel_with_notify_settings(NotifySettings(mute_until=_EPOCH)) + + info = await backend.inspect_chat(chat_id=20, raw=False) + + assert info.muted is False + assert info.muted_until is None + assert info.silent is False + + +@pytest.mark.asyncio +async def test_silent_alone_is_reported_separately_and_is_not_a_mute() -> None: + backend = _channel_with_notify_settings(NotifySettings(mute_until=None, silent=True)) + + info = await backend.inspect_chat(chat_id=20, raw=False) + + assert info.muted is False + assert info.muted_until is None + assert info.silent is True + + +@pytest.mark.asyncio +async def test_absent_notify_settings_reports_all_three_false() -> None: + backend = _channel_with_notify_settings(None) + + info = await backend.inspect_chat(chat_id=20, raw=False) + + assert info.muted is False + assert info.muted_until is None + assert info.silent is False + + +@pytest.mark.asyncio +async def test_untouched_notify_settings_report_all_three_false() -> None: + """The never-configured chat: flags unset, no ``mute_until`` at all.""" + backend = _channel_with_notify_settings(NotifySettings()) + + info = await backend.inspect_chat(chat_id=20, raw=False) + + assert info.muted is False + assert info.muted_until is None + assert info.silent is False + + +# --- forbidden peers --------------------------------------------------------- +# +# The peer resolves (get_input_entity succeeds, telethon normalizes a +# ChannelForbidden/ChatForbidden into an ordinary InputPeerChannel/InputPeerChat +# before returning it — see telethon.utils.get_input_peer), but the Full fetch +# itself is refused because we were kicked/banned or never had access. Per the +# plan's error table this must surface as ValueError naming the chat, so the +# CLI maps it to exit 2 rather than the unmapped exit 1 a bare RPCError would +# get. + + +class RaisingClient: + """A fake client whose ``__call__`` raises instead of returning a result.""" + + def __init__(self, *, peer, exc: BaseException) -> None: + self._peer = peer + self._exc = exc + + async def get_input_entity(self, ref): + return self._peer + + async def __call__(self, request): + raise self._exc + + +@pytest.mark.asyncio +async def test_forbidden_channel_raises_value_error_naming_chat() -> None: + from telethon.errors import ChannelPrivateError + + exc = ChannelPrivateError(request=object()) + client = RaisingClient(peer=InputPeerChannel(13), exc=exc) + backend = TelethonChatInspectBackend(client) + + with pytest.raises(ValueError, match="13") as excinfo: + await backend.inspect_chat(chat_id=13, raw=False) + + assert excinfo.value.__cause__ is exc + + +@pytest.mark.asyncio +async def test_forbidden_basic_group_raises_value_error_naming_chat() -> None: + from telethon.errors import ChatForbiddenError + + exc = ChatForbiddenError(request=object()) + client = RaisingClient(peer=InputPeerChat(14), exc=exc) + backend = TelethonChatInspectBackend(client) + + with pytest.raises(ValueError, match="14") as excinfo: + await backend.inspect_chat(chat_id=14, raw=False) + + assert excinfo.value.__cause__ is exc + + +# --- legacy basic group ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_basic_group_mapping() -> None: + chat = Chat( + 9, + title="Old", + participants_count=4, + deactivated=True, + migrated_to=InputPeerChannelMigrated(500), + ) + full = ChatFull(9, about="legacy", ttl_period=60) + client = FakeClient(peer=InputPeerChat(9), result=FullChannelResult(full, [chat])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=9, raw=False) + + assert info.kind == "basic_group" + assert info.kind in CHAT_KINDS + assert info.title == "Old" + assert info.participants_count == 4 + assert info.deactivated is True + assert info.migrated_to_chat_id == 500 + assert info.ttl_period == 60 + assert info.is_forum is False + assert info.admins_count is None + + +# --- users and bots --------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_user_mapping() -> None: + user = User( + 11, + first_name="Ann", + last_name="Lee", + username="annlee", + phone="79990000000", + premium=True, + contact=True, + status=UserStatusRecently(), + ) + full = UserFull( + 11, + about="bio", + ttl_period=604800, + blocked=True, + common_chats_count=3, + birthday=Birthday(4, 7, 1990), + ) + client = FakeClient(peer=InputPeerUser(11), result=FullUserResult(full, [user])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=11, raw=False) + + assert info.kind == "user" + assert info.kind in CHAT_KINDS + assert info.title == "Ann Lee" + assert info.first_name == "Ann" + assert info.last_name == "Lee" + assert info.phone == "79990000000" + assert info.is_premium is True + assert info.is_contact is True + assert info.blocked is True + assert info.common_chats_count == 3 + assert info.birthday == {"day": 4, "month": 7, "year": 1990} + assert info.ttl_period == 604800 + assert info.last_seen_status == "UserStatusRecently" + + +@pytest.mark.asyncio +async def test_bot_kind() -> None: + user = User(12, first_name="Helper", bot=True) + client = FakeClient(peer=InputPeerUser(12), result=FullUserResult(UserFull(12), [user])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=12, raw=False) + + assert info.kind == "bot" + assert info.kind in CHAT_KINDS + assert info.is_bot is True + + +# --- raw -------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_raw_carries_both_halves_without_access_hash() -> None: + channel = Channel(5, title="Team") + client = FakeClient( + peer=InputPeerChannel(5), result=FullChannelResult(ChannelFull(5), [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=5, raw=True) + + assert set(info.raw) == {"entity", "full"} + assert info.raw["full"]["_"] == "ChannelFull" + assert info.raw["entity"]["title"] == "Team" + assert "access_hash" not in info.raw["entity"] + + +@pytest.mark.asyncio +async def test_user_raw_strips_access_hash() -> None: + user = User(11, first_name="Ann") + client = FakeClient(peer=InputPeerUser(11), result=FullUserResult(UserFull(11), [user])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=11, raw=True) + + assert "access_hash" not in info.raw["entity"] + + +# --- raw: nested access_hash (the live leak this backfills) ----------------- +# +# The live finding: raw.full.chat_photo.access_hash / +# raw.full.profile_photo.access_hash survived a top-level-only redaction +# filter. Each test below covers one peer kind and exercises *both* +# serialization paths at once: the "full" side goes through Full.to_dict(), +# which (mirroring real Telethon) has already flattened chat_photo/ +# profile_photo into a plain nested dict by the time _serialize sees it; the +# "entity" side has no to_dict() of its own, so it goes through the vars() +# fallback, whose `photo` attribute is still a *live* Photo object with its +# own to_dict() -- the second path the review called out as a possible +# unclosed hole. Asserting against the whole payload via json.dumps (rather +# than the top-level dict only) is the same check the live verification ran. + + +@pytest.mark.asyncio +async def test_raw_strips_access_hash_from_nested_channel_photo() -> None: + channel = Channel(5, title="Team", photo=Photo(101, access_hash=999999001)) + full = ChannelFull(5, about="About us", chat_photo=Photo(102, access_hash=999999002)) + client = FakeClient( + peer=InputPeerChannel(5), result=FullChannelResult(full, [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=5, raw=True) + + assert info.raw["full"]["chat_photo"]["id"] == 102 + assert "access_hash" not in info.raw["full"]["chat_photo"] + # entity has no to_dict() of its own -- vars() fallback -- so `photo` is + # still a live Photo object going in; _redact must recurse into it via + # its own to_dict() rather than leaving it untouched. + assert info.raw["entity"]["photo"]["id"] == 101 + assert "access_hash" not in info.raw["entity"]["photo"] + assert "access_hash" not in json.dumps(info.raw, default=str) + + +@pytest.mark.asyncio +async def test_raw_strips_access_hash_from_nested_basic_group_photo() -> None: + chat = Chat(9, title="Old", photo=Photo(201, access_hash=999999003)) + full = ChatFull(9, about="legacy", chat_photo=Photo(202, access_hash=999999004)) + client = FakeClient(peer=InputPeerChat(9), result=FullChannelResult(full, [chat])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=9, raw=True) + + assert info.raw["full"]["chat_photo"]["id"] == 202 + assert "access_hash" not in info.raw["full"]["chat_photo"] + assert info.raw["entity"]["photo"]["id"] == 201 + assert "access_hash" not in info.raw["entity"]["photo"] + assert "access_hash" not in json.dumps(info.raw, default=str) + + +@pytest.mark.asyncio +async def test_raw_strips_access_hash_from_nested_user_photo() -> None: + user = User(11, first_name="Ann", photo=Photo(301, access_hash=999999005)) + full = UserFull(11, about="bio", profile_photo=Photo(302, access_hash=999999006)) + client = FakeClient(peer=InputPeerUser(11), result=FullUserResult(full, [user])) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=11, raw=True) + + assert info.raw["full"]["profile_photo"]["id"] == 302 + assert "access_hash" not in info.raw["full"]["profile_photo"] + assert info.raw["entity"]["photo"]["id"] == 301 + assert "access_hash" not in info.raw["entity"]["photo"] + assert "access_hash" not in json.dumps(info.raw, default=str) + + +# --- _redact: shapes that break a naive recursive redaction ----------------- +# +# Every other test in this file goes through the public inspect_chat() +# surface, matching house style (see e.g. tests/test_topics_telethon_backend.py +# -- private helpers are exercised only indirectly). These three are a +# deliberate exception: the round-1 review explicitly named three shapes a +# naive recursive redaction can get wrong -- "a value that is a list of +# objects ..., a bytes value, and a self-referential or deeply nested +# structure" -- and none of the three peer-kind payloads above happens to +# carry a list of access_hash-bearing objects or a cyclic reference, so +# there is no way to exercise those specific shapes through inspect_chat() +# without inventing an unrealistic Full/entity fixture. _redact() is not +# exported (absent from __all__); it is imported directly here only for this. + + +def test_redact_strips_access_hash_from_a_list_of_nested_objects() -> None: + from telegram_assistant.chats.telethon_backend import _redact + + photos = [Photo(1, access_hash=111), Photo(2, access_hash=222)] + + result = _redact({"photos": photos}) + + assert result == {"photos": [{"_": "Photo", "id": 1}, {"_": "Photo", "id": 2}]} + + +def test_redact_leaves_bytes_untouched() -> None: + from telegram_assistant.chats.telethon_backend import _redact + + result = _redact({"file_reference": b"\x00\x01raw-bytes"}) + + assert result == {"file_reference": b"\x00\x01raw-bytes"} + + +def test_redact_guards_against_a_reference_cycle() -> None: + from telegram_assistant.chats.telethon_backend import _redact + + class Cyclic: + def to_dict(self): + return {"access_hash": 1, "self": self} + + cyclic = Cyclic() + + # Must terminate rather than raise RecursionError / hang, and the cycle + # is broken (not silently re-entered) rather than truncating an + # otherwise-acyclic payload. + result = _redact(cyclic) + + assert "access_hash" not in result + assert result["self"] is None + + +# --- CHAT_KINDS ------------------------------------------------------------- +# +# The constant is the documented answer to "which strings can `kind` take". It +# is only worth exporting if something checks it, so this pins it to the +# backend's own dispatch in both directions: every branch's string must be in +# the set (a new kind spelled only in the backend fails here), and every member +# of the set must be produced by some branch (a member with no branch behind it +# fails too). The individual mapping tests additionally assert membership at +# their own call site. + + +@pytest.mark.asyncio +async def test_chat_kinds_is_exactly_what_the_backend_branches_produce() -> None: + cases = [ + # megagroup supergroup, broadcast channel, legacy basic group + (InputPeerChannel(30), FullChannelResult(ChannelFull(30), [Channel(30)])), + ( + InputPeerChannel(31), + FullChannelResult(ChannelFull(31), [Channel(31, broadcast=True)]), + ), + (InputPeerChat(32), FullChannelResult(ChatFull(32), [Chat(32)])), + (InputPeerUser(33), FullUserResult(UserFull(33), [User(33)])), + (InputPeerUser(34), FullUserResult(UserFull(34), [User(34, bot=True)])), + ] + + produced = set() + for peer, result in cases: + backend = TelethonChatInspectBackend(FakeClient(peer=peer, result=result)) + # The fake client ignores the reference and answers with `peer`; the + # id only ever reaches an error message on this path. + info = await backend.inspect_chat(chat_id=0, raw=False) + produced.add(info.kind) + + assert produced == set(CHAT_KINDS) + + +# --- _shallow_for: the peer bucket does not match the Full object ----------- + + +@pytest.mark.asyncio +async def test_shallow_falls_back_to_the_only_entry_on_an_id_mismatch() -> None: + """One entry in the bucket: the fallback cannot pick the wrong chat.""" + channel = Channel(999, title="Renumbered") + client = FakeClient( + peer=InputPeerChannel(40), result=FullChannelResult(ChannelFull(40), [channel]) + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=40, raw=False) + + assert info.title == "Renumbered" + + +@pytest.mark.asyncio +async def test_shallow_refuses_to_guess_when_the_bucket_holds_several() -> None: + """A channel's ``result.chats`` also carries its linked discussion group. + + On an id mismatch, taking ``items[0]`` would map that *other* chat's title + and flags onto the chat that was asked about, silently. Reporting the + shallow half as absent is the honest answer. + """ + linked = Channel(41, title="Discussion", megagroup=True) + other = Channel(42, title="Unrelated", broadcast=True) + client = FakeClient( + peer=InputPeerChannel(40), + result=FullChannelResult(ChannelFull(40), [linked, other]), + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=40, raw=False) + + assert info.chat_id == 40 + assert info.title is None + assert info.megagroup is False + assert info.broadcast is False + + +@pytest.mark.asyncio +async def test_shallow_matches_by_id_when_the_bucket_holds_several() -> None: + """The normal case: the requested chat is found among its neighbours.""" + wanted = Channel(40, title="Announcements", broadcast=True) + linked = Channel(41, title="Discussion", megagroup=True) + client = FakeClient( + peer=InputPeerChannel(40), + result=FullChannelResult(ChannelFull(40), [linked, wanted]), + ) + backend = TelethonChatInspectBackend(client) + + info = await backend.inspect_chat(chat_id=40, raw=False) + + assert info.title == "Announcements" + assert info.kind == "channel" diff --git a/tests/test_chats_inspect_surfaces.py b/tests/test_chats_inspect_surfaces.py new file mode 100644 index 0000000..a7938e9 --- /dev/null +++ b/tests/test_chats_inspect_surfaces.py @@ -0,0 +1,1119 @@ +"""Surface tests for `chats inspect` — HTTP and MCP wiring. + +The domain op is covered by ``test_chats_inspect.py``, the Telethon adapter by +``test_chats_inspect_backend.py`` and the CLI by ``test_cli_chats_inspect.py``. +This module covers the two *remote* surfaces: parameter validation, the payload +shape, the CLI-only ``raw`` rejection and the status / error taxonomy. +""" + +from __future__ import annotations + +import json +import tempfile +import textwrap +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from telegram_assistant.chats import ChatInfo +from telegram_assistant.config import load_config_from_text +from telegram_assistant.entities import ( + AmbiguousEntityError, + EntityNotFoundError, + ResolvedEntity, +) +from telegram_assistant.folders import FolderChat, FolderSnapshot +from telegram_assistant.http_api import create_app +from telegram_assistant.persistence import OperationStore +from telegram_assistant.worker.queue import FloodWaitError +from tests.test_mcp_mount import ( + FakeGoogleOidcProvider, + FakeSessionManager, + _enabled_mcp_yaml, + _initialize_payload, + _list_tools, + _mcp_headers, + _mint_token, +) + +AUTH = {"Authorization": "Bearer secret_token"} + +#: The chat the fakes answer for. Bare id, no ``-100`` marker — that is what +#: ``ChatInfo.chat_id`` carries and what the payload must report. +CHAT_ID = 2305069221 + + +def _chat_info(chat_id: int = CHAT_ID) -> ChatInfo: + """A canned supergroup payload touching one field per payload group.""" + return ChatInfo( + chat_id=chat_id, + kind="supergroup", + title="Client chat", + username="clientchat", + usernames=("clientchat",), + about="A client chat", + created_at=datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC), + ttl_period=86400, + megagroup=True, + is_forum=True, + topics_layout="tabs", + participants_count=12, + ) + + +class FakeInspectBackend: + """Records every call; returns a canned ``ChatInfo`` or raises ``error``.""" + + def __init__( + self, info: ChatInfo | None = None, *, error: Exception | None = None + ) -> None: + self._info = _chat_info() if info is None else info + self._error = error + self.calls: list[dict[str, Any]] = [] + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + self.calls.append({"chat_id": chat_id, "raw": raw}) + if self._error is not None: + raise self._error + return self._info + + +class FakeResolver: + def __init__(self, mapping: dict[str, int]) -> None: + self._mapping = mapping + + async def resolve(self, ref: object) -> ResolvedEntity: + key = str(ref) + if key not in self._mapping: + raise EntityNotFoundError(f"entity {key!r} not found") + return ResolvedEntity(chat_id=self._mapping[key], title=key, kind="channel") + + +class FakeFolderBackend: + """Just enough of ``FolderBackend`` for ``resolve_chat_in_folder``.""" + + def __init__( + self, snapshots: list[FolderSnapshot] | None = None, *, error: Exception | None = None + ) -> None: + self._snapshots = [] if snapshots is None else snapshots + self._error = error + + async def list_folders(self) -> list[FolderSnapshot]: + if self._error is not None: + raise self._error + return list(self._snapshots) + + +class FloodResolver: + """A resolver that throttles — the ``entity`` branch's FLOOD_WAIT source. + + The adapter (``entities/telethon_backend.py``) raises the bare + ``worker.queue.FloodWaitError``: it carries ``.seconds`` and nothing else, + which is exactly why ``_annotate_retry_after`` has to run over the + resolution too and not only over the domain call. + """ + + async def resolve(self, ref: object) -> ResolvedEntity: + raise FloodWaitError(30.0) + + +class AmbiguousResolver: + async def resolve(self, ref: object) -> ResolvedEntity: + raise AmbiguousEntityError(ref=str(ref), matches=[111, 222]) + + +def _folder_backend() -> FakeFolderBackend: + return FakeFolderBackend( + [ + FolderSnapshot( + folder_id=2, + folder_name="Planfix clients", + chats=[FolderChat(chat_id=CHAT_ID, title="Client chat")], + ) + ] + ) + + +def _duplicate_title_folder_backend() -> FakeFolderBackend: + """One folder holding two chats with the same title → ``AmbiguousChatNameError``.""" + return FakeFolderBackend( + [ + FolderSnapshot( + folder_id=2, + folder_name="Planfix clients", + chats=[ + FolderChat(chat_id=CHAT_ID, title="Client chat"), + FolderChat(chat_id=CHAT_ID + 1, title="Client chat"), + ], + ) + ] + ) + + +def _config_with_access(access_block: str | None) -> str: + base = textwrap.dedent( + """ + telegram: + api_id: 123456 + api_hash: "telegram_api_hash" + session_path: /data/telegram-assistant.session + default_chat_folder: + folder_id: 2 + folder_name: "Planfix clients" + {access} + http: + host: "0.0.0.0" + port: 8085 + bearer_token: "secret_token" + logging: + level: INFO + """ + ) + indented = "" + if access_block is not None: + indented = textwrap.indent(access_block, " ") + return base.format(access=indented).strip() + + +_READ_ACCESS = "access:\n rules:\n - all: true\n permission: read\n" +_WRITE_ACCESS = "access:\n rules:\n - all: true\n permission: write\n" + + +def _make_store() -> OperationStore: + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".db") + tmp.close() + return OperationStore(Path(tmp.name)) + + +def _http_client( + *, + access_block: str | None = None, + backend: FakeInspectBackend | None = None, + resolver: FakeResolver | None = None, + folder_backend: FakeFolderBackend | None = None, + backend_returns_none: bool = False, +) -> TestClient: + """Build the app. + + ``backend_returns_none`` installs a chat-inspect factory that answers + ``None`` — the *second* 503 stage in ``_chat_inspect_backend_or_503`` + (the production "Telethon client not connected yet" case). The first + stage, a missing factory attribute, is unreachable through ``create_app``, + which always sets it. + """ + config = load_config_from_text(_config_with_access(access_block)) + app = create_app( + config, + session_manager=None, + chat_inspect_backend_factory=( + (lambda _r: None) if backend_returns_none else (lambda _r: backend) + ), + folder_backend_factory=( + (lambda _r: folder_backend) + if folder_backend is not None + else (lambda _r: None) + ), + resolver_factory=( + (lambda _r: resolver) if resolver is not None else (lambda _r: None) + ), + operation_store=_make_store(), + ) + return TestClient(app) + + +# --- HTTP ------------------------------------------------------------------ + + +def test_http_chats_inspect_returns_the_domain_payload() -> None: + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + # Exactly ChatInfo.to_dict() — no wrapper keys, no echoed parameters. + assert body["chat_id"] == CHAT_ID + assert body["kind"] == "supergroup" + assert body["ttl_period"] == 86400 + assert body["topics_layout"] == "tabs" + assert body["participants_count"] == 12 + assert body["created_at"].startswith("2026-01-02T03:04:05") + assert body["usernames"] == ["clientchat"] + # Fields that do not apply to a supergroup are present and null. + assert body["phone"] is None + # `raw` is dropped entirely, never sent as null. + assert "raw" not in body + assert "telegram_chat_id" not in body + # The remote surface never asks the backend for the raw objects. + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_http_chats_inspect_resolves_entity() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + resolver=FakeResolver({"@clientchat": CHAT_ID}), + ) + + resp = client.get( + "/telegram/chats/inspect", params={"entity": "@clientchat"}, headers=AUTH + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["chat_id"] == CHAT_ID + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_http_chats_inspect_resolves_chat_name_in_folder() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_folder_backend(), + ) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_name": "Client chat", "folder_name": "Planfix clients"}, + headers=AUTH, + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["chat_id"] == CHAT_ID + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_http_chats_inspect_reports_a_missing_entity_as_404() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + resolver=FakeResolver({}), + ) + + resp = client.get( + "/telegram/chats/inspect", params={"entity": "@nope"}, headers=AUTH + ) + + assert resp.status_code == 404, resp.text + assert backend.calls == [] + + +def test_http_chats_inspect_reports_a_missing_chat_name_as_404() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_folder_backend(), + ) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_name": "Other chat", "folder_name": "Planfix clients"}, + headers=AUTH, + ) + + assert resp.status_code == 404, resp.text + assert backend.calls == [] + + +def test_http_chats_inspect_reports_an_ambiguous_entity_as_409() -> None: + """An ``entity`` matching several dialogs (e.g. a duplicate title) is 409. + + A dedicated resolver stand-in rather than ``FakeResolver`` (whose mapping + only ever answers "found" or "not found") — mirrors the resolver + ``test_http_groups_rename.py::test_http_rename_entity_ambiguous_409`` uses + for the same shared ``resolve_entity_chat_id`` → ``AmbiguousEntityError`` + → 409 path. + """ + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, backend=backend, resolver=AmbiguousResolver() + ) + + resp = client.get( + "/telegram/chats/inspect", params={"entity": "Client chat"}, headers=AUTH + ) + + assert resp.status_code == 409, resp.text + assert backend.calls == [] + + +def test_http_chats_inspect_reports_an_ambiguous_chat_name_as_409() -> None: + """Two chats sharing a title inside the same folder is a *different* 409 + source from an ambiguous ``entity`` — a different exception + (``AmbiguousChatNameError``, raised inside ``resolve_chat_in_folder``, + ``folders/service.py``), caught by a different handler + (``_translate_folder_error``, not ``translate_entity_error``), and a + different response-body shape (``error: "ambiguous_chat_name"`` plus + ``chat_name``/``folder_name``/``matches`` — not ``ambiguous_entity`` plus + ``entity``/``matches``). A test that only checked the status code would + not catch the two being collapsed into one. + """ + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_duplicate_title_folder_backend(), + ) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_name": "Client chat", "folder_name": "Planfix clients"}, + headers=AUTH, + ) + + assert resp.status_code == 409, resp.text + detail = resp.json()["detail"] + assert detail["error"] == "ambiguous_chat_name" + assert detail["chat_name"] == "Client chat" + assert detail["folder_name"] == "Planfix clients" + assert sorted(detail["matches"]) == sorted([CHAT_ID, CHAT_ID + 1]) + assert backend.calls == [] + + +def test_http_chats_inspect_reports_a_folder_id_mismatch_as_409() -> None: + """A third, still-distinguishable 409 shape: the folder matched by name + exists, but its numeric id disagrees with the caller's ``folder_id`` + (``FolderIdMismatchError``, plain-string body) — neither the + ``ambiguous_entity`` nor the ``ambiguous_chat_name`` dict shape. + """ + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_folder_backend(), # folder_id=2 + ) + + resp = client.get( + "/telegram/chats/inspect", + params={ + "chat_name": "Client chat", + "folder_name": "Planfix clients", + "folder_id": 999, + }, + headers=AUTH, + ) + + assert resp.status_code == 409, resp.text + detail = resp.json()["detail"] + assert isinstance(detail, str) + assert backend.calls == [] + + +def test_http_chats_inspect_reports_a_missing_folder_as_404() -> None: + """The folder itself not existing (``FolderNotFoundError``) is a distinct + 404 source from the chat not being found *inside* an existing folder + (``ChatNotFoundError``, covered by + ``test_http_chats_inspect_reports_a_missing_chat_name_as_404``). + """ + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_folder_backend(), # only has "Planfix clients" + ) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_name": "Client chat", "folder_name": "Nonexistent folder"}, + headers=AUTH, + ) + + assert resp.status_code == 404, resp.text + assert backend.calls == [] + + +def test_http_chats_inspect_requires_exactly_one_ref() -> None: + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + none_given = client.get("/telegram/chats/inspect", headers=AUTH) + two_given = client.get( + "/telegram/chats/inspect", + params={"chat_id": CHAT_ID, "entity": "@clientchat"}, + headers=AUTH, + ) + + assert none_given.status_code == 400, none_given.text + assert "exactly one" in none_given.json()["detail"] + assert two_given.status_code == 400, two_given.text + assert backend.calls == [] + + +def test_http_chats_inspect_requires_folder_name_with_chat_name() -> None: + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=_folder_backend(), + ) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_name": "Client chat"}, headers=AUTH + ) + + assert resp.status_code == 400, resp.text + assert resp.json()["detail"] == "chat_name requires folder_name" + assert backend.calls == [] + + +def test_http_chats_inspect_rejects_raw() -> None: + """`raw` is CLI-only — rejected, never silently ignored.""" + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_id": CHAT_ID, "raw": "true"}, + headers=AUTH, + ) + + assert resp.status_code == 400, resp.text + detail = resp.json()["detail"] + assert "raw is CLI-only" in detail + # The rejection happens before anything reaches the domain op, so a remote + # caller's flag can never reach the serializer. + assert backend.calls == [] + + +def test_http_chats_inspect_raw_false_is_accepted() -> None: + """An explicit `raw=false` is a normal request, not a rejection.""" + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_id": CHAT_ID, "raw": "false"}, + headers=AUTH, + ) + + assert resp.status_code == 200, resp.text + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_http_chats_inspect_denied_without_read() -> None: + backend = FakeInspectBackend() + client = _http_client(access_block=_WRITE_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error"] == "access_denied" + # The gate runs before any Telegram call. + assert backend.calls == [] + + +def test_http_chats_inspect_503_without_backend() -> None: + client = _http_client(access_block=_READ_ACCESS, backend_returns_none=True) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 503, resp.text + + +def test_http_chats_inspect_503_without_resolver_for_entity() -> None: + """A distinct 503 source from the chat-inspect backend's own: the + inspect backend is wired (so ``_chat_inspect_backend_or_503`` passes), + but no entity resolver is — so resolution itself 503s before the + inspect backend is ever called. + """ + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"entity": "@clientchat"}, headers=AUTH + ) + + assert resp.status_code == 503, resp.text + assert backend.calls == [] + + +def test_http_chats_inspect_503_without_folder_backend_for_chat_name() -> None: + """Same shape as the resolver case, for the ``chat_name`` branch: the + inspect backend is wired, but no folder backend is, so ``chat_name`` + resolution 503s before the inspect backend is ever called. + """ + backend = FakeInspectBackend() + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_name": "Client chat", "folder_name": "Planfix clients"}, + headers=AUTH, + ) + + assert resp.status_code == 503, resp.text + assert backend.calls == [] + + +def test_http_chats_inspect_rejects_an_uninspectable_peer_with_400() -> None: + backend = FakeInspectBackend( + error=ValueError(f"chat {CHAT_ID} is private or inaccessible") + ) + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 400, resp.text + assert resp.json()["detail"] == f"chat {CHAT_ID} is private or inaccessible" + + +def test_http_chats_inspect_maps_flood_wait_to_502_with_retry_after() -> None: + backend = FakeInspectBackend(error=FloodWaitError(30.0)) + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 502, resp.text + detail = resp.json()["detail"] + assert detail["error"] == "needs_review" + assert detail["retry_after_seconds"] == 30.0 + assert detail["retry_at"] > 0 + assert resp.headers["Retry-After"] == "30" + + +def test_http_chats_inspect_flood_wait_while_resolving_an_entity_keeps_retry_after() -> None: + """A throttle during *resolution* answers exactly like one during the read. + + ``resolve_entity_chat_id`` runs before the domain call, and its adapter's + ``get_entity`` probe / exact-title dialog scan are classic FLOOD_WAIT + sources. The documented contract (README, SKILL.md) is one 502 shape for + the whole route — ``retry_after_seconds`` in the body and the standard + ``Retry-After`` header — so the annotation has to cover the reference + branches too, not only ``inspect_chat``. + """ + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, backend=backend, resolver=FloodResolver() + ) + + resp = client.get( + "/telegram/chats/inspect", params={"entity": "@clientchat"}, headers=AUTH + ) + + assert resp.status_code == 502, resp.text + detail = resp.json()["detail"] + assert detail["error"] == "needs_review" + assert detail["retry_after_seconds"] == 30.0 + assert detail["retry_at"] > 0 + assert resp.headers["Retry-After"] == "30" + assert backend.calls == [] + + +def test_http_chats_inspect_flood_wait_while_resolving_a_chat_name_keeps_retry_after() -> None: + """The ``chat_name`` half of the same contract: ``list_folders()`` throttles.""" + backend = FakeInspectBackend() + client = _http_client( + access_block=_READ_ACCESS, + backend=backend, + folder_backend=FakeFolderBackend(error=FloodWaitError(30.0)), + ) + + resp = client.get( + "/telegram/chats/inspect", + params={"chat_name": "Client chat", "folder_name": "Planfix clients"}, + headers=AUTH, + ) + + assert resp.status_code == 502, resp.text + detail = resp.json()["detail"] + assert detail["error"] == "needs_review" + assert detail["retry_after_seconds"] == 30.0 + assert detail["retry_at"] > 0 + assert resp.headers["Retry-After"] == "30" + assert backend.calls == [] + + +def test_http_chats_inspect_does_not_clobber_a_paced_flood_waits_own_retry_after() -> None: + """An error that already carries retry-after keeps its own values. + + ``_annotate_retry_after`` fills in only what is missing, so a + ``PacedFloodWaitError``-shaped exception (the pin/unpin pacer's, which knows + a real wall-clock ``retry_at``) is passed through untouched rather than + overwritten with ``.seconds``. + """ + paced = FloodWaitError(30.0) + paced.retry_after_seconds = 7.5 # type: ignore[attr-defined] + paced.retry_at = 1_900_000_000.0 # type: ignore[attr-defined] + backend = FakeInspectBackend(error=paced) + client = _http_client(access_block=_READ_ACCESS, backend=backend) + + resp = client.get( + "/telegram/chats/inspect", params={"chat_id": CHAT_ID}, headers=AUTH + ) + + assert resp.status_code == 502, resp.text + detail = resp.json()["detail"] + assert detail["retry_after_seconds"] == 7.5 + assert detail["retry_at"] == 1_900_000_000.0 + assert resp.headers["Retry-After"] == "8" + + +def test_http_chats_inspect_requires_a_bearer_token() -> None: + client = _http_client(access_block=_READ_ACCESS, backend=FakeInspectBackend()) + + resp = client.get("/telegram/chats/inspect", params={"chat_id": CHAT_ID}) + + # A missing Authorization header is 401 on every /telegram/* route + # (tests/test_app_skeleton.py::test_protected_endpoint_requires_authorization_header). + assert resp.status_code == 401, resp.text + + +# --- MCP ------------------------------------------------------------------- + + +def _with_access(minimal_config_yaml: str, access_block: str) -> str: + """Splice an `access:` block into the shared minimal config fixture.""" + return minimal_config_yaml.replace( + " defaults:\n", + f" access:\n{access_block} defaults:\n", + 1, + ) + + +_MCP_READ_ACCESS = " rules:\n - all: true\n permission: read\n" +_MCP_WRITE_ACCESS = " rules:\n - all: true\n permission: write\n" + + +def _mcp_client( + config_yaml: str, + tmp_path: Path, + *, + backend: FakeInspectBackend | None = None, + resolver: FakeResolver | None = None, + folder_backend: FakeFolderBackend | None = None, + disabled_tools: tuple[str, ...] = (), +) -> TestClient: + config = load_config_from_text( + _enabled_mcp_yaml(config_yaml, disabled_tools=disabled_tools) + ) + app = create_app( + config, + session_manager=FakeSessionManager(), # type: ignore[arg-type] + mcp_google_provider=FakeGoogleOidcProvider(), + chat_inspect_backend_factory=( + (lambda _r: backend) if backend is not None else (lambda _r: None) + ), + folder_backend_factory=( + (lambda _r: folder_backend) + if folder_backend is not None + else (lambda _r: None) + ), + resolver_factory=lambda _r: resolver, + operation_store=OperationStore(tmp_path / "state.db"), + ) + return TestClient(app) + + +def _initialize(client: TestClient, token: str) -> None: + headers = _mcp_headers(token) + initialize = client.post("/mcp", json=_initialize_payload(), headers=headers) + assert initialize.status_code == 200, initialize.text + initialized = client.post( + "/mcp", + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers=headers, + ) + assert initialized.status_code == 202, initialized.text + + +def _call_tool( + client: TestClient, token: str, name: str, arguments: dict[str, Any] +) -> dict[str, Any]: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, + headers=_mcp_headers(token), + ) + assert response.status_code == 200, response.text + return response.json()["result"] + + +def _error_payload(result: dict[str, Any]) -> dict[str, Any]: + """The JSON error body an MCP tool failure carries. + + The mcp SDK's ``Tool.run`` (``mcp/server/fastmcp/tools/base.py``) wraps + *every* exception a tool raises — including our own ``McpToolError``, + whose message is already the JSON payload — in + ``f"Error executing tool {name}: {e}"`` before it reaches ``content[0]``. + That prefix is universal (it predates this tool and applies to every MCP + tool's error path), not something this tool's registration controls, so + it is stripped here rather than in production code. + """ + text = result["content"][0]["text"] + prefix, _, remainder = text.partition(": ") + if prefix.startswith("Error executing tool "): + text = remainder + return json.loads(text) + + +def test_mcp_chats_inspect_reads_via_backend( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"chat_id": CHAT_ID} + ) + + assert result["isError"] is False, result + payload = result["structuredContent"] + assert payload["chat_id"] == CHAT_ID + assert payload["kind"] == "supergroup" + assert payload["ttl_period"] == 86400 + assert payload["topics_layout"] == "tabs" + assert "raw" not in payload + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_mcp_chats_inspect_resolves_entity( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client( + config_yaml, + tmp_path, + backend=backend, + resolver=FakeResolver({"@clientchat": CHAT_ID}), + ) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"entity": "@clientchat"} + ) + + assert result["isError"] is False, result + assert result["structuredContent"]["chat_id"] == CHAT_ID + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_mcp_chats_inspect_resolves_chat_name_in_folder( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client( + config_yaml, tmp_path, backend=backend, folder_backend=_folder_backend() + ) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, + token, + "telegram_chats_inspect", + {"chat_name": "Client chat", "folder_name": "Planfix clients"}, + ) + + assert result["isError"] is False, result + assert result["structuredContent"]["chat_id"] == CHAT_ID + assert backend.calls == [{"chat_id": CHAT_ID, "raw": False}] + + +def test_mcp_chats_inspect_rejects_raw( + minimal_config_yaml: str, tmp_path: Path +) -> None: + """`raw` is CLI-only — a tool error, never a silently dropped flag.""" + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, + token, + "telegram_chats_inspect", + {"chat_id": CHAT_ID, "raw": True}, + ) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "invalid_request" + assert error["status"] == 400 + assert "raw is CLI-only" in error["message"] + # Nothing reached the domain op, so no raw payload could ever be built. + assert backend.calls == [] + + +def test_mcp_chats_inspect_requires_exactly_one_ref( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool(client, token, "telegram_chats_inspect", {}) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "invalid_request" + assert "exactly one" in error["message"] + assert backend.calls == [] + + +def test_mcp_chats_inspect_denied_without_read( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_WRITE_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"chat_id": CHAT_ID} + ) + + assert result["isError"] is True, result + assert _error_payload(result)["error"] == "access_denied" + assert backend.calls == [] + + +def test_mcp_chats_inspect_backend_unavailable( + minimal_config_yaml: str, tmp_path: Path +) -> None: + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"chat_id": CHAT_ID} + ) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "backend_unavailable" + assert error["status"] == 503 + + +def test_mcp_chats_inspect_maps_flood_wait_to_needs_review( + minimal_config_yaml: str, tmp_path: Path +) -> None: + backend = FakeInspectBackend(error=FloodWaitError(30.0)) + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, backend=backend) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"chat_id": CHAT_ID} + ) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "needs_review" + assert error["status"] == 502 + assert error["detail"]["retry_after_seconds"] == 30.0 + + +def test_mcp_chats_inspect_flood_wait_while_resolving_keeps_retry_after( + minimal_config_yaml: str, tmp_path: Path +) -> None: + """MCP's half of the resolution-throttle contract. + + ``retry_after_details()`` reads ``retry_after_seconds``, which the bare + adapter-raised ``FloodWaitError`` does not carry; without the annotation + covering resolution the ``detail`` key is dropped entirely and the client + is told to wait an unknown amount of time. + """ + backend = FakeInspectBackend() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client( + config_yaml, tmp_path, backend=backend, resolver=FloodResolver() + ) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool( + client, token, "telegram_chats_inspect", {"entity": "@clientchat"} + ) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["error"] == "needs_review" + assert error["status"] == 502 + assert error["detail"]["retry_after_seconds"] == 30.0 + assert backend.calls == [] + + +@pytest.mark.parametrize( + ("wiring", "arguments", "expected_status", "expected_error"), + [ + pytest.param( + lambda: {"backend": FakeInspectBackend(), "resolver": FakeResolver({})}, + {"entity": "@nope"}, + 404, + "not_found", + id="entity-not-found", + ), + pytest.param( + lambda: { + "backend": FakeInspectBackend(), + "folder_backend": _folder_backend(), + }, + {"chat_name": "Client chat", "folder_name": "Nonexistent folder"}, + 404, + "not_found", + id="folder-not-found", + ), + pytest.param( + lambda: { + "backend": FakeInspectBackend(), + "folder_backend": _folder_backend(), + }, + {"chat_name": "Other chat", "folder_name": "Planfix clients"}, + 404, + "not_found", + id="chat-not-found-in-folder", + ), + pytest.param( + lambda: {"backend": FakeInspectBackend(), "resolver": AmbiguousResolver()}, + {"entity": "Client chat"}, + 409, + "ambiguous_entity", + id="ambiguous-entity", + ), + pytest.param( + lambda: { + "backend": FakeInspectBackend(), + "folder_backend": _duplicate_title_folder_backend(), + }, + {"chat_name": "Client chat", "folder_name": "Planfix clients"}, + 409, + "ambiguous_chat_name", + id="ambiguous-chat-name", + ), + pytest.param( + lambda: { + "backend": FakeInspectBackend(), + "folder_backend": _folder_backend(), # folder_id=2 + }, + { + "chat_name": "Client chat", + "folder_name": "Planfix clients", + "folder_id": 999, + }, + 409, + "conflict", + id="folder-id-mismatch", + ), + pytest.param( + lambda: { + "backend": FakeInspectBackend(), + "folder_backend": _folder_backend(), + }, + {"chat_name": "Client chat"}, + 400, + "invalid_request", + id="chat-name-without-folder-name", + ), + pytest.param( + lambda: { + "backend": FakeInspectBackend( + error=ValueError(f"chat {CHAT_ID} is private or inaccessible") + ) + }, + {"chat_id": CHAT_ID}, + 400, + "invalid_request", + id="uninspectable-peer", + ), + pytest.param( + lambda: {"backend": FakeInspectBackend()}, # no resolver wired + {"entity": "@clientchat"}, + 503, + "backend_unavailable", + id="no-resolver-for-entity", + ), + pytest.param( + lambda: {"backend": FakeInspectBackend()}, # no folder backend wired + {"chat_name": "Client chat", "folder_name": "Planfix clients"}, + 503, + "backend_unavailable", + id="no-folder-backend-for-chat-name", + ), + ], +) +def test_mcp_chats_inspect_error_taxonomy( + minimal_config_yaml: str, + tmp_path: Path, + wiring: Any, + arguments: dict[str, Any], + expected_status: int, + expected_error: str, +) -> None: + """Every failure ``inspect_chat_for_request`` raises, mapped by MCP. + + One parametrized case per source rather than ten near-duplicate functions: + the helper *raises* and each surface *maps*, so the point of this test is + that the MCP mapping stays status-for-status identical to the HTTP route's + (each case above has a named HTTP sibling in this module). A future change + to how the helper splits raising from mapping cannot reshape one surface + without this failing. + """ + wiring_kwargs = wiring() + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client(config_yaml, tmp_path, **wiring_kwargs) as client: + token = _mint_token(client) + _initialize(client, token) + + result = _call_tool(client, token, "telegram_chats_inspect", arguments) + + assert result["isError"] is True, result + error = _error_payload(result) + assert error["status"] == expected_status, error + assert error["error"] == expected_error, error + + +@pytest.mark.parametrize( + "disabled", + ["telegram_chats_inspect", "telegram_chats_*"], +) +def test_mcp_chats_inspect_can_be_disabled( + minimal_config_yaml: str, tmp_path: Path, disabled: str +) -> None: + """`mcp.disabled_tools` prunes it by exact name and by prefix wildcard.""" + config_yaml = _with_access(minimal_config_yaml, _MCP_READ_ACCESS) + with _mcp_client( + config_yaml, + tmp_path, + backend=FakeInspectBackend(), + disabled_tools=(disabled,), + ) as client: + token = _mint_token(client) + listed = _list_tools(client, token) + + assert "telegram_chats_inspect" not in set(listed) + # The filter is targeted, not a blanket prune. + assert "telegram_members_list" in set(listed) diff --git a/tests/test_chats_set_ttl.py b/tests/test_chats_set_ttl.py new file mode 100644 index 0000000..2ff8077 --- /dev/null +++ b/tests/test_chats_set_ttl.py @@ -0,0 +1,411 @@ +"""Domain tests for `chats set-ttl`.""" + +from __future__ import annotations + +import pytest + +from telegram_assistant.access import AccessDenied, AccessLevel +from telegram_assistant.chats.ttl import ( + MAX_TTL_SECONDS, + SetTtlRequest, + parse_ttl, + set_chat_ttl, +) + + +class FakeTtlBackend: + """Records calls; ``reads`` is the queue of values ``get_ttl`` returns.""" + + def __init__(self, reads: list[int | None] | None = None) -> None: + # Default: currently off, and off again after any write. + self._reads = list(reads if reads is not None else [None, None]) + self.calls: list[tuple[str, dict[str, object]]] = [] + + async def get_ttl(self, *, chat_id: int) -> int | None: + self.calls.append(("get_ttl", {"chat_id": chat_id})) + return self._reads.pop(0) if self._reads else None + + async def set_ttl(self, *, chat_id: int, period: int) -> None: + self.calls.append(("set_ttl", {"chat_id": chat_id, "period": period})) + + @property + def writes(self) -> list[dict[str, object]]: + return [args for name, args in self.calls if name == "set_ttl"] + + +class DenyingAuthorizer: + def __init__(self) -> None: + self.checked: list[tuple[int, AccessLevel]] = [] + + async def require(self, chat_id: int, level: AccessLevel) -> None: + self.checked.append((chat_id, level)) + raise AccessDenied(chat_ref=chat_id, required_level=level) + + +class AllowingAuthorizer: + def __init__(self) -> None: + self.checked: list[tuple[int, AccessLevel]] = [] + + async def require(self, chat_id: int, level: AccessLevel) -> None: + self.checked.append((chat_id, level)) + + +# --- parse_ttl -------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("off", 0), + ("OFF", 0), + ("0", 0), + ("30s", 30), + ("5m", 300), + ("24h", 86400), + ("1d", 86400), + ("31d", 2678400), + ("93d", 8035200), + ("180d", 15552000), + ("2w", 1209600), + ("86400", 86400), + (" 1d ", 86400), + ], +) +def test_parse_ttl_accepts(text: str, expected: int) -> None: + assert parse_ttl(text) == expected + + +@pytest.mark.parametrize( + "text", + ["", " ", "-1", "-1d", "1.5d", "1y", "d", "1 d", "abc", "1dd", "1d2h"], +) +def test_parse_ttl_rejects(text: str) -> None: + with pytest.raises(ValueError): + parse_ttl(text) + + +def test_parse_ttl_rejects_over_int32() -> None: + with pytest.raises(ValueError) as exc: + parse_ttl(str(MAX_TTL_SECONDS + 1)) + assert "too large" in str(exc.value) + + +def test_parse_ttl_error_names_the_offending_text() -> None: + with pytest.raises(ValueError) as exc: + parse_ttl("1y") + assert "1y" in str(exc.value) + + +# --- the gate --------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_write_gate_fires_before_any_rpc() -> None: + backend = FakeTtlBackend() + authorizer = DenyingAuthorizer() + + with pytest.raises(AccessDenied): + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + authorizer=authorizer, + ) + + assert backend.calls == [] + assert authorizer.checked == [(5, AccessLevel.WRITE)] + + +@pytest.mark.asyncio +async def test_gate_is_write_not_read() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + authorizer = AllowingAuthorizer() + + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + authorizer=authorizer, + ) + + assert authorizer.checked == [(5, AccessLevel.WRITE)] + + +# --- the no-op short-circuit ------------------------------------------------ + + +@pytest.mark.asyncio +async def test_setting_the_same_period_issues_no_write() -> None: + backend = FakeTtlBackend(reads=[86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + ) + + assert backend.writes == [] + assert result.changed is False + assert result.previous_ttl_seconds == 86400 + assert result.ttl_period == 86400 + + +@pytest.mark.asyncio +async def test_turning_off_an_already_off_chat_issues_no_write() -> None: + backend = FakeTtlBackend(reads=[None]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + ) + + assert backend.writes == [] + assert result.changed is False + assert result.previous_ttl_seconds is None + assert result.ttl_period is None + + +# --- dry run ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dry_run_reads_but_never_writes() -> None: + backend = FakeTtlBackend(reads=[86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + dry_run=True, + ) + + assert backend.writes == [] + assert result.dry_run is True + assert result.changed is True + assert result.previous_ttl_seconds == 86400 + assert result.ttl_period == 86400 # unchanged: nothing was written + + +@pytest.mark.asyncio +async def test_dry_run_still_runs_the_gate() -> None: + backend = FakeTtlBackend(reads=[86400]) + authorizer = DenyingAuthorizer() + + with pytest.raises(AccessDenied): + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + authorizer=authorizer, + dry_run=True, + ) + + assert backend.calls == [] + + +# --- the write and the read-back -------------------------------------------- + + +@pytest.mark.asyncio +async def test_write_then_read_back_reports_the_server_value() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400, chat_name="Team"), + ) + + assert backend.writes == [{"chat_id": 5, "period": 86400}] + assert result.previous_ttl_seconds is None + assert result.ttl_period == 86400 + assert result.requested_ttl_seconds == 86400 + assert result.changed is True + assert result.chat_name == "Team" + assert result.dry_run is False + + +@pytest.mark.asyncio +async def test_turning_off_reports_null_not_zero() -> None: + backend = FakeTtlBackend(reads=[86400, None]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + ) + + assert result.ttl_period is None + assert result.requested_ttl_seconds == 0 + assert result.changed is True + + +@pytest.mark.asyncio +async def test_read_back_mismatch_raises() -> None: + # Server clamped or ignored the value: 93d asked, 31d stored. + backend = FakeTtlBackend(reads=[None, 2678400]) + + with pytest.raises(ValueError) as exc: + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=8035200), + ) + + message = str(exc.value) + assert "8035200" in message + assert "2678400" in message + + +@pytest.mark.asyncio +async def test_a_silent_write_is_still_judged_by_the_read_back() -> None: + """The domain never inspects what ``set_ttl`` returned. + + Task 2's adapter swallows the ``TypeNotFoundError`` Telegram can answer with + while the write applies; the domain's half of that contract is that a + ``set_ttl`` returning nothing at all is fine, because only the read-back + decides. A backend whose ``set_ttl`` is a no-op therefore still yields + ``changed: True`` when the read-back agrees with the request. + """ + + class SilentBackend(FakeTtlBackend): + async def set_ttl(self, *, chat_id: int, period: int) -> None: + self.calls.append(("set_ttl", {"chat_id": chat_id, "period": period})) + return None + + backend = SilentBackend(reads=[86400, None]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=0), + ) + + assert backend.writes == [{"chat_id": 5, "period": 0}] + assert result.ttl_period is None + assert result.changed is True + + +# --- pacing ----------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pacer_wraps_only_the_write() -> None: + calls: list[str] = [] + + class RecordingPacer: + async def run(self, key, op): + calls.append(key) + return await op() + + backend = FakeTtlBackend(reads=[None, 86400]) + + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + pacer=RecordingPacer(), + ) + + assert calls == ["ttl:5"] + + +@pytest.mark.asyncio +async def test_no_pacer_calls_the_backend_directly() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + + await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + ) + + assert backend.writes == [{"chat_id": 5, "period": 86400}] + + +@pytest.mark.asyncio +async def test_no_op_short_circuit_never_touches_the_pacer() -> None: + class ExplodingPacer: + async def run(self, key, op): + raise AssertionError("pacer must not be used for a no-op") + + backend = FakeTtlBackend(reads=[86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400), + pacer=ExplodingPacer(), + ) + + assert result.changed is False + + +# --- payload ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_to_dict_shape() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=5, period=86400, chat_name="Team"), + ) + + assert result.to_dict() == { + "chat_id": 5, + "chat_name": "Team", + "changed": True, + "dry_run": False, + "previous_ttl_seconds": None, + "requested_ttl_seconds": 86400, + "ttl_period": 86400, + } + + +@pytest.mark.asyncio +async def test_marked_chat_id_is_reported_bare() -> None: + backend = FakeTtlBackend(reads=[None, 86400]) + + result = await set_chat_ttl( + backend=backend, + request=SetTtlRequest(telegram_chat_id=-1002305069221, period=86400), + ) + + assert result.chat_id == 2305069221 + # The backend still receives what the caller resolved. + assert backend.writes == [{"chat_id": -1002305069221, "period": 86400}] + + +# --- gate key and config ---------------------------------------------------- + + +def test_ttl_pacing_key_uses_the_bare_id() -> None: + from telegram_assistant.messages import ttl_pacing_key + + assert ttl_pacing_key(-1002305069221) == "ttl:2305069221" + assert ttl_pacing_key(2305069221) == "ttl:2305069221" + + +def test_ttl_gate_key_matches_the_pacing_key() -> None: + from telegram_assistant.chats.ttl import ttl_gate_key + from telegram_assistant.messages import ttl_pacing_key + + assert ttl_gate_key(-1002305069221) == ttl_pacing_key(-1002305069221) + + +def test_ttl_gate_key_does_not_collide_with_the_pin_gate() -> None: + from telegram_assistant.messages import pin_pacing_key, ttl_pacing_key + + assert ttl_pacing_key(5) != pin_pacing_key(5) + + +def test_config_defaults_for_ttl_pacing(minimal_config_yaml) -> None: + from telegram_assistant.config.loader import load_config_from_text + + config = load_config_from_text(minimal_config_yaml, source="test") + + assert config.telegram.ttl_min_interval_seconds == 2.0 + assert config.telegram.ttl_max_flood_wait_seconds == 3600.0 + assert config.telegram.ttl_max_flood_wait_retries == 5 + + +def test_config_rejects_negative_ttl_interval(minimal_config_yaml) -> None: + from telegram_assistant.config.loader import ConfigError, load_config_from_text + + # The fixture's `telegram:` line is followed by 2-space-indented keys, so + # inserting one right after the header keeps the YAML valid. + text = minimal_config_yaml.replace( + "telegram:", "telegram:\n ttl_min_interval_seconds: -1", 1 + ) + with pytest.raises((ConfigError, ValueError)): + load_config_from_text(text, source="test") diff --git a/tests/test_chats_set_ttl_backend.py b/tests/test_chats_set_ttl_backend.py new file mode 100644 index 0000000..e6ce985 --- /dev/null +++ b/tests/test_chats_set_ttl_backend.py @@ -0,0 +1,252 @@ +"""Tests for the Telethon set-ttl adapter. + +Fakes are stand-ins whose class *names* match Telethon's, because that is what +the peer dispatch keys on — the same convention as +``tests/test_members_list_backend.py``. +""" + +from __future__ import annotations + +import pytest + +from telegram_assistant.chats.telethon_backend import TelethonChatTtlBackend +from telegram_assistant.worker.queue import FloodWaitError + + +class InputPeerChannel: + def __init__(self, channel_id: int) -> None: + self.channel_id = channel_id + + +class InputPeerChat: + def __init__(self, chat_id: int) -> None: + self.chat_id = chat_id + + +class InputPeerUser: + def __init__(self, user_id: int) -> None: + self.user_id = user_id + + +class _FullChat: + def __init__(self, ttl_period) -> None: + self.ttl_period = ttl_period + + +class FullChannelResult: + def __init__(self, ttl_period) -> None: + self.full_chat = _FullChat(ttl_period) + + +class FullUserResult: + def __init__(self, ttl_period) -> None: + self.full_user = _FullChat(ttl_period) + + +class TypeNotFoundError(Exception): + """Name-matched by the adapter; Telethon raises this when a response + carries a constructor newer than the installed layer.""" + + +class FakeClient: + def __init__(self, *, peer, full=None, full_error=None, set_error=None) -> None: + self._peer = peer + self._full = full + self._full_error = full_error + self._set_error = set_error + self.requests: list[object] = [] + + async def get_input_entity(self, ref): + return self._peer + + async def __call__(self, request): + self.requests.append(request) + name = type(request).__name__ + if name in {"GetFullChannelRequest", "GetFullChatRequest", "GetFullUserRequest"}: + if self._full_error is not None: + raise self._full_error + return self._full + if name == "SetHistoryTTLRequest": + if self._set_error is not None: + raise self._set_error + return object() + raise AssertionError(f"unexpected request {name}") + + @property + def request_names(self) -> list[str]: + return [type(r).__name__ for r in self.requests] + + +def _flood_error() -> Exception: + """Name-spoofed like Telethon's own, so ``translate_flood_wait`` matches it + by class name alone — the same convention as + ``tests/test_messages_pin.py::_flood_error``.""" + + class _Flood(Exception): + def __init__(self) -> None: + self.seconds = 7 + + _Flood.__name__ = "FloodWaitError" + return _Flood() + + +# --- get_ttl ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_ttl_reads_a_supergroup() -> None: + client = FakeClient(peer=InputPeerChannel(7), full=FullChannelResult(86400)) + backend = TelethonChatTtlBackend(client) + + assert await backend.get_ttl(chat_id=-1007) == 86400 + assert client.request_names == ["GetFullChannelRequest"] + + +@pytest.mark.asyncio +async def test_get_ttl_reads_a_basic_group() -> None: + client = FakeClient(peer=InputPeerChat(55), full=FullChannelResult(2678400)) + backend = TelethonChatTtlBackend(client) + + assert await backend.get_ttl(chat_id=-55) == 2678400 + assert client.request_names == ["GetFullChatRequest"] + + +@pytest.mark.asyncio +async def test_get_ttl_reads_a_user() -> None: + client = FakeClient(peer=InputPeerUser(9), full=FullUserResult(604800)) + backend = TelethonChatTtlBackend(client) + + assert await backend.get_ttl(chat_id=9) == 604800 + assert client.request_names == ["GetFullUserRequest"] + + +@pytest.mark.asyncio +async def test_get_ttl_returns_none_when_off() -> None: + client = FakeClient(peer=InputPeerChannel(7), full=FullChannelResult(None)) + backend = TelethonChatTtlBackend(client) + + assert await backend.get_ttl(chat_id=-1007) is None + + +@pytest.mark.asyncio +async def test_unsupported_peer_raises_value_error() -> None: + class InputPeerEmpty: + pass + + client = FakeClient(peer=InputPeerEmpty()) + backend = TelethonChatTtlBackend(client) + + with pytest.raises(ValueError) as exc: + await backend.get_ttl(chat_id=1) + assert "1" in str(exc.value) + + +@pytest.mark.asyncio +async def test_get_ttl_forbidden_channel_raises_value_error_naming_chat() -> None: + from telethon.errors import ChannelPrivateError + + exc = ChannelPrivateError(request=object()) + client = FakeClient(peer=InputPeerChannel(13), full_error=exc) + backend = TelethonChatTtlBackend(client) + + with pytest.raises(ValueError, match="13") as excinfo: + await backend.get_ttl(chat_id=13) + assert excinfo.value.__cause__ is exc + + +@pytest.mark.asyncio +async def test_get_ttl_translates_flood_wait() -> None: + """The single most-cited fact behind this feature is that this method's + flood waits escalate into the hundreds of seconds — if the raw Telethon + error ever leaked through untranslated, the pacer's ``except + FloodWaitError`` in ``messages/pacing.py`` would never fire and a + ``chats set-ttl`` call would exit 1 on the first wait instead of sitting + through it. See ``tests/test_messages_pin.py::test_telethon_pin_translates_flood_wait`` + for the identical pattern.""" + client = FakeClient(peer=InputPeerChannel(7), full_error=_flood_error()) + backend = TelethonChatTtlBackend(client) + + with pytest.raises(FloodWaitError): + await backend.get_ttl(chat_id=-1007) + + +@pytest.mark.asyncio +async def test_get_ttl_forbidden_basic_group_raises_value_error_naming_chat() -> None: + from telethon.errors import ChatForbiddenError + + exc = ChatForbiddenError(request=object()) + client = FakeClient(peer=InputPeerChat(14), full_error=exc) + backend = TelethonChatTtlBackend(client) + + with pytest.raises(ValueError, match="14") as excinfo: + await backend.get_ttl(chat_id=14) + assert excinfo.value.__cause__ is exc + + +# --- set_ttl ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_set_ttl_sends_the_request_with_the_period() -> None: + client = FakeClient(peer=InputPeerChannel(7), full=FullChannelResult(None)) + backend = TelethonChatTtlBackend(client) + + await backend.set_ttl(chat_id=-1007, period=86400) + + assert client.request_names == ["SetHistoryTTLRequest"] + assert client.requests[0].period == 86400 + + +@pytest.mark.asyncio +async def test_set_ttl_zero_disables() -> None: + client = FakeClient(peer=InputPeerChannel(7), full=FullChannelResult(None)) + backend = TelethonChatTtlBackend(client) + + await backend.set_ttl(chat_id=-1007, period=0) + + assert client.requests[0].period == 0 + + +@pytest.mark.asyncio +async def test_set_ttl_translates_flood_wait() -> None: + """Mirror of ``test_get_ttl_translates_flood_wait`` for the write side — + the pacer wraps ``set_chat_ttl``'s backend call, so an untranslated + ``FloodWaitError`` here would silently drop the feature's headline + sit-through-the-wait behaviour.""" + client = FakeClient( + peer=InputPeerChannel(7), + full=FullChannelResult(None), + set_error=_flood_error(), + ) + backend = TelethonChatTtlBackend(client) + + with pytest.raises(FloodWaitError): + await backend.set_ttl(chat_id=-1007, period=86400) + + +@pytest.mark.asyncio +async def test_unparseable_response_is_swallowed() -> None: + """Proven live 2026-08-05: the write applied, only the response failed to + parse. Raising here would report a successful change as a failure — the + domain's read-back is what decides.""" + client = FakeClient( + peer=InputPeerChannel(7), + full=FullChannelResult(None), + set_error=TypeNotFoundError("Could not find a matching Constructor ID"), + ) + backend = TelethonChatTtlBackend(client) + + await backend.set_ttl(chat_id=-1007, period=0) # must not raise + + +@pytest.mark.asyncio +async def test_other_errors_propagate() -> None: + client = FakeClient( + peer=InputPeerChannel(7), + full=FullChannelResult(None), + set_error=RuntimeError("CHAT_ADMIN_REQUIRED"), + ) + backend = TelethonChatTtlBackend(client) + + with pytest.raises(RuntimeError): + await backend.set_ttl(chat_id=-1007, period=0) diff --git a/tests/test_cli_chats_inspect.py b/tests/test_cli_chats_inspect.py new file mode 100644 index 0000000..69c368f --- /dev/null +++ b/tests/test_cli_chats_inspect.py @@ -0,0 +1,208 @@ +"""CLI tests for `chats inspect`.""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from telegram_assistant.access import AccessDenied, AccessLevel +from telegram_assistant.chats import ChatInfo +from telegram_assistant.cli import main as cli_main +from telegram_assistant.entities import EntityNotFoundError + +runner = CliRunner() + + +class FakeChatBackend: + def __init__(self, info: ChatInfo | None = None, error: Exception | None = None): + self.info = info or ChatInfo(chat_id=5, kind="supergroup", title="Team") + self.error = error + self.calls: list[dict[str, object]] = [] + + async def inspect_chat(self, *, chat_id: int, raw: bool) -> ChatInfo: + self.calls.append({"chat_id": chat_id, "raw": raw}) + if self.error is not None: + raise self.error + return self.info + + +class FakeResolved: + def __init__(self, chat_id: int) -> None: + self.chat_id = chat_id + + +class FakeResolver: + def __init__(self, chat_id: int = 5, error: Exception | None = None) -> None: + self.chat_id = chat_id + self.error = error + + async def resolve(self, ref: str): + if self.error is not None: + raise self.error + return FakeResolved(self.chat_id) + + +class FakeManager: + async def disconnect(self) -> None: + return None + + +@pytest.fixture +def wire(monkeypatch, minimal_config_yaml, tmp_path): + """Patch the backend builder; return a helper that installs fakes.""" + + config_path = tmp_path / "config.yml" + config_path.write_text(minimal_config_yaml, encoding="utf-8") + + def _install(backend, resolver=None, authorizer=None): + config = cli_main._load_config_or_exit(config_path) + + def _build(_path): + async def _open(): + return backend, object(), resolver or FakeResolver() + + return config, FakeManager(), _open + + monkeypatch.setattr(cli_main, "_build_chat_inspect_backends", _build) + if authorizer is not None: + monkeypatch.setattr(cli_main, "_cli_authorizer", lambda *a, **k: authorizer) + return config_path + + return _install + + +def test_requires_exactly_one_reference(wire): + config_path = wire(FakeChatBackend()) + + result = runner.invoke( + cli_main.app, ["chats", "inspect", "--config", str(config_path)] + ) + + assert result.exit_code == 2 + assert "exactly one of --chat-id, --chat-name, or --entity" in result.output + + +def test_rejects_two_references(wire): + config_path = wire(FakeChatBackend()) + + result = runner.invoke( + cli_main.app, + [ + "chats", "inspect", + "--chat-id", "5", + "--entity", "@team", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + + +def test_prints_payload_json(wire): + backend = FakeChatBackend( + ChatInfo(chat_id=5, kind="supergroup", title="Team", ttl_period=86400) + ) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--config", str(config_path)], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["chat_id"] == 5 + assert payload["kind"] == "supergroup" + assert payload["ttl_period"] == 86400 + assert "raw" not in payload + assert backend.calls == [{"chat_id": 5, "raw": False}] + + +def test_entity_reference_is_resolved(wire): + backend = FakeChatBackend() + config_path = wire(backend, resolver=FakeResolver(chat_id=77)) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--entity", "@team", "--config", str(config_path)], + ) + + assert result.exit_code == 0, result.output + assert backend.calls == [{"chat_id": 77, "raw": False}] + + +def test_raw_flag_is_passed_through(wire): + backend = FakeChatBackend( + ChatInfo(chat_id=5, kind="supergroup", raw={"entity": {}, "full": {}}) + ) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--raw", "--config", str(config_path)], + ) + + assert result.exit_code == 0, result.output + assert backend.calls == [{"chat_id": 5, "raw": True}] + assert json.loads(result.output)["raw"] == {"entity": {}, "full": {}} + + +def test_access_denied_exits_3(wire): + # AccessDenied is keyword-only (chat_ref, required_level, ...) in + # telegram_assistant.access.service — not the positional-message form the + # brief sketched. Constructed here to match the real signature; the CLI's + # own "access denied: {exc}" wrapping is what the assertion below checks. + backend = FakeChatBackend( + error=AccessDenied(chat_ref=5, required_level=AccessLevel.READ) + ) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--config", str(config_path)], + ) + + assert result.exit_code == 3 + assert "access denied" in result.output + + +def test_unresolvable_entity_exits_2(wire): + config_path = wire( + FakeChatBackend(), resolver=FakeResolver(error=EntityNotFoundError("no such chat")) + ) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--entity", "@ghost", "--config", str(config_path)], + ) + + assert result.exit_code == 2 + assert "no such chat" in result.output + + +def test_domain_value_error_exits_2(wire): + backend = FakeChatBackend(error=ValueError("chat 5 cannot be inspected")) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--config", str(config_path)], + ) + + assert result.exit_code == 2 + assert "cannot be inspected" in result.output + + +def test_unexpected_error_exits_1(wire): + backend = FakeChatBackend(error=RuntimeError("boom")) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + ["chats", "inspect", "--chat-id", "5", "--config", str(config_path)], + ) + + assert result.exit_code == 1 + assert "chats inspect failed: boom" in result.output diff --git a/tests/test_cli_chats_set_ttl.py b/tests/test_cli_chats_set_ttl.py new file mode 100644 index 0000000..7e1fc7b --- /dev/null +++ b/tests/test_cli_chats_set_ttl.py @@ -0,0 +1,390 @@ +"""CLI tests for `chats set-ttl`.""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from telegram_assistant.access import AccessDenied +from telegram_assistant.cli import main as cli_main +from telegram_assistant.entities import EntityNotFoundError + +runner = CliRunner() + + +class FakeTtlBackend: + def __init__(self, reads=None, set_error=None) -> None: + self._reads = list(reads if reads is not None else [None, None]) + self.set_error = set_error + self.calls: list[tuple[str, dict]] = [] + + async def get_ttl(self, *, chat_id: int): + self.calls.append(("get_ttl", {"chat_id": chat_id})) + return self._reads.pop(0) if self._reads else None + + async def set_ttl(self, *, chat_id: int, period: int) -> None: + self.calls.append(("set_ttl", {"chat_id": chat_id, "period": period})) + if self.set_error is not None: + raise self.set_error + + @property + def writes(self): + return [args for name, args in self.calls if name == "set_ttl"] + + +class FakeResolved: + def __init__(self, chat_id: int) -> None: + self.chat_id = chat_id + + +class FakeResolver: + def __init__(self, chat_id: int = 5, error: Exception | None = None) -> None: + self.chat_id = chat_id + self.error = error + + async def resolve(self, ref: str): + if self.error is not None: + raise self.error + return FakeResolved(self.chat_id) + + +class FakeManager: + async def disconnect(self) -> None: + return None + + +@pytest.fixture +def wire(monkeypatch, minimal_config_yaml, tmp_path): + config_path = tmp_path / "config.yml" + config_path.write_text(minimal_config_yaml, encoding="utf-8") + + def _install(backend, resolver=None, authorizer=None): + config = cli_main._load_config_or_exit(config_path) + + def _build(_path): + async def _open(): + return backend, object(), resolver or FakeResolver() + + return config, FakeManager(), _open + + monkeypatch.setattr(cli_main, "_build_chat_ttl_backends", _build) + if authorizer is not None: + monkeypatch.setattr(cli_main, "_cli_authorizer", lambda *a, **k: authorizer) + return config_path + + return _install + + +# --- flag validation -------------------------------------------------------- + + +def test_requires_exactly_one_reference(wire): + config_path = wire(FakeTtlBackend()) + + result = runner.invoke( + cli_main.app, + ["chats", "set-ttl", "--ttl", "off", "--config", str(config_path)], + ) + + assert result.exit_code == 2 + assert "exactly one of --chat-id, --chat-name, or --entity" in result.output + + +def test_rejects_two_references(wire): + config_path = wire(FakeTtlBackend()) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--entity", "@team", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + + +def test_unparseable_ttl_exits_2_before_any_rpc(wire): + backend = FakeTtlBackend() + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "1y", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + assert "1y" in result.output + assert backend.calls == [] + + +# --- happy paths ------------------------------------------------------------ + + +def test_sets_a_period_and_prints_the_payload(wire): + backend = FakeTtlBackend(reads=[None, 8035200]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "93d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["chat_id"] == 5 + assert payload["requested_ttl_seconds"] == 8035200 + assert payload["previous_ttl_seconds"] is None + assert payload["ttl_period"] == 8035200 + assert payload["changed"] is True + assert payload["dry_run"] is False + assert backend.writes == [{"chat_id": 5, "period": 8035200}] + + +def test_off_reports_null_ttl(wire): + backend = FakeTtlBackend(reads=[2678400, None]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["ttl_period"] is None + assert payload["previous_ttl_seconds"] == 2678400 + assert payload["changed"] is True + + +def test_no_op_reports_unchanged_without_writing(wire): + backend = FakeTtlBackend(reads=[86400]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "1d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["changed"] is False + assert backend.writes == [] + + +def test_entity_reference_is_resolved(wire): + backend = FakeTtlBackend(reads=[None, 86400]) + config_path = wire(backend, resolver=FakeResolver(chat_id=77)) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--entity", "@team", + "--ttl", "1d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + assert backend.writes == [{"chat_id": 77, "period": 86400}] + + +# --- dry run ---------------------------------------------------------------- + + +def test_dry_run_emits_the_standard_envelope_and_writes_nothing(wire): + backend = FakeTtlBackend(reads=[2678400]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--dry-run", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "dry_run" + assert payload["dry_run"] is True + assert payload["command"] == "chats.set-ttl" + assert payload["resolved"]["previous_ttl_seconds"] == 2678400 + assert payload["resolved"]["requested_ttl_seconds"] == 0 + assert payload["resolved"]["changed"] is True + assert payload["planned_actions"] + assert backend.writes == [] + + +def test_dry_run_of_a_no_op_says_so(wire): + backend = FakeTtlBackend(reads=[None]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--dry-run", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["resolved"]["changed"] is False + assert payload["planned_actions"] == [] + assert payload["warnings"] + + +# --- error ladder ----------------------------------------------------------- + + +def test_access_denied_exits_3(wire): + class Denying: + async def require(self, chat_id, level): + raise AccessDenied(chat_ref=chat_id, required_level=level) + + backend = FakeTtlBackend() + config_path = wire(backend, authorizer=Denying()) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 3 + assert "access denied" in result.output + + +def test_unresolvable_entity_exits_2(wire): + config_path = wire( + FakeTtlBackend(), + resolver=FakeResolver(error=EntityNotFoundError("no such chat")), + ) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--entity", "@ghost", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + assert "no such chat" in result.output + + +def test_read_back_mismatch_exits_2(wire): + backend = FakeTtlBackend(reads=[None, 2678400]) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "93d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 2 + assert "8035200" in result.output + assert "2678400" in result.output + + +def test_paced_flood_wait_exits_1_with_retry_after(wire): + from telegram_assistant.worker.queue import FloodWaitError + + # NOTE two deviations from a naive transcription of the brief, both + # required to actually exercise the pacer's exhausted-budget path rather + # than hang or silently no-op — verified directly against `set_chat_ttl` + # and against the equivalent `FloodPinBackend` pattern in + # tests/test_messages_pin_surfaces.py: + # 1. The current TTL (first read) must differ from the requested one + # (here: currently on at 86400s, requesting "off"). With current and + # requested both normalising to "off", chats/ttl.py's no-op + # short-circuit returns before any write, so `backend.set_ttl` would + # never be called and the flood-wait error would never fire. + # 2. The backend must raise a plain `FloodWaitError`, not a pre-built + # `PacedFloodWaitError`: the latter is itself a `FloodWaitError` + # subclass, so `Pacer.run`'s own `except FloodWaitError` catches it + # and retries through a real `asyncio.sleep` (the pacer has no way to + # tell an injected already-paced-out error from a fresh one). Seconds + # must exceed `ttl_max_flood_wait_seconds` (default 3600.0, unset in + # minimal_config_yaml) so the pacer converts it to + # `PacedFloodWaitError` on the very first attempt, without sleeping. + backend = FakeTtlBackend( + reads=[86400], + set_error=FloodWaitError(3600.0), + ) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "off", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 1 + assert "Retry after" in result.output + + +def test_unexpected_error_exits_1(wire): + backend = FakeTtlBackend(reads=[None, None], set_error=RuntimeError("boom")) + config_path = wire(backend) + + result = runner.invoke( + cli_main.app, + [ + "chats", "set-ttl", + "--chat-id", "5", + "--ttl", "1d", + "--config", str(config_path), + ], + ) + + assert result.exit_code == 1 + assert "chats set-ttl failed: boom" in result.output diff --git a/tests/test_mcp_mount.py b/tests/test_mcp_mount.py index f6cd1f5..8dc2183 100644 --- a/tests/test_mcp_mount.py +++ b/tests/test_mcp_mount.py @@ -213,6 +213,7 @@ def _list_tools(client: TestClient, token: str) -> dict[str, dict[str, object]]: EXPECTED_TOOL_NAMES = { + "telegram_chats_inspect", "telegram_folders_add_chat", "telegram_folders_inspect", "telegram_folders_remove_chat",