Skip to content

feat: chats inspect (CLI/HTTP/MCP) and chats set-ttl (CLI) - #26

Merged
popstas merged 28 commits into
masterfrom
feat/chats-inspect
Aug 6, 2026
Merged

feat: chats inspect (CLI/HTTP/MCP) and chats set-ttl (CLI)#26
popstas merged 28 commits into
masterfrom
feat/chats-inspect

Conversation

@popstas

@popstas popstas commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Adds a new chats/ domain area with two operations that had no equivalent before: a read-only chats inspect on all three surfaces, and a CLI-only chats set-ttl that writes a chat's auto-delete period.

folders inspect returns two fields per chat by design and groups get-layout reads one boolean — nothing could report a chat's TTL, description, member counts, restrictions or our own admin rights, and nothing could change the TTL at all.

Surfaces

Command CLI HTTP MCP
chats inspect GET /telegram/chats/inspect telegram_chats_inspect
chats set-ttl — (deliberate) — (deliberate)
telegram-assistant chats inspect (--chat-id N | --chat-name TITLE | --entity REF) [--folder-name NAME] [--folder-id N] [--raw]
telegram-assistant chats set-ttl (--chat-id N | --chat-name TITLE | --entity REF) --ttl off|<duration> [--folder-name NAME] [--folder-id N] [--dry-run]

Both remote inspect surfaces call one shared helper, inspect_chat_for_request, so parameter names, error mapping and payload agree by construction rather than by coincidence. set-ttl is CLI-only by explicit request; the domain layer stays surface-agnostic, so adding HTTP/MCP later is wiring, not redesign.

Architecture

src/telegram_assistant/chats/ follows the members/ split — one operation per file, READ and WRITE never mixed:

  • service.pyChatInfo (frozen, ~70 fields, to_dict()), the ChatInspectBackend protocol, inspect_chat(). READ-gated before any Telegram call. No telethon import.
  • ttl.pyparse_ttl(), ChatTtlBackend, SetTtlRequest/SetTtlResult, set_chat_ttl(). WRITE-gated. No operation row and no idempotency key — a single-step write against a naturally idempotent target, shaped like notifications mute.
  • telethon_backend.pyTelethonChatInspectBackend (resolve the peer once, one GetFull*, map the pair — two RPCs maximum, no dialog walk) and TelethonChatTtlBackend below it.
  • http_api/chats.py — the route, chat_inspect_backend_factory on app.state (returns None → 503 until the client connects), and the shared request helper.

The inspect payload is flat and kind-agnostic: None where a field does not apply, so jq .ttl_period works regardless of what was inspected.

chats set-ttl

The gap was found the hard way: disabling auto-delete across a 78-chat folder needed an ad-hoc messages.SetHistoryTTL script, bypassing telegram.access, --dry-run and any pacing. That run produced three facts the design is built on, none of them guessable from the API docs — they're why this is not a thin RPC wrapper:

  1. Every successful set posts a service message into the chat, visible to all members — including a set that changes nothing. So set_chat_ttl reads the current period first and short-circuits when it already matches (changed: false, no write). That is a correctness requirement, not an optimization: without it, re-running over a folder spams every chat in it.
  2. The RPC's return value cannot be trusted. One chat failed with Telethon's TypeNotFoundError (constructor newer than the installed layer) — yet the write had applied. So the adapter treats TypeNotFoundError during the write as non-fatal and falls through to an unconditional read-back, which is the authoritative ttl_period. A read-back disagreeing with the request is an error naming both values, never a reported success.
  3. SetHistoryTTL is flood-waited hard, and the waits escalate — 261s, 703s, 866s observed on one account within an hour. The write goes through the shared Pacer on its own gate key ttl:<bare id> (not shared with the pin gate — different Telegram limits), with three new telegram.* config keys: ttl_min_interval_seconds (2.0), ttl_max_flood_wait_seconds (3600, high enough to sit through the observed waits but finite), ttl_max_flood_wait_retries (5, since the pacer's default 3 is too few when waits escalate).

--ttl takes off/0, <int><unit> (s/m/h/d/w) or a bare integer of seconds; negatives, non-integers, unknown units and anything past 2**31 - 1 are rejected at parse time with exit 2. There is deliberately no allow-list of preset durations — Telegram's clients offer day/week/month, but live chats were found at 31, 93 and 180 days. The server is the authority.

previous_ttl_seconds / ttl_period are null when auto-delete is off, never 0, matching what chats inspect reports for the same chat.

Deliberate decisions

  • raw is CLI-only. HTTP and MCP accept the parameter and reject it (400 / tool error) rather than ignoring it — a silently dropped raw=true would look like an empty raw payload. The raw dump carries considerably more than the curated set: a legacy group's whole member roster, a user's business_location, stories, personal_channel_id.
  • access_hash never appears in any payload, including --raw. Redaction is recursive.
  • FloodWaitError is mapped on inspect, unlike members list which lets it fall through: HTTP 502 with Retry-After and retry_after_seconds, MCP needs_review with the same field.
  • Reference set matches the CLI on every surfaceentity, chat_id, or chat_name with folder_name/folder_id — rather than the narrower pair members list uses.
  • set-ttl needs only WRITE. write does not imply read in this project's policy, and the get_ttl calls are part of the write operation, not a separate READ grant.
  • No folder-wide sweep for either command — the caller loops over chats, as with members list --user.

Defects caught by review, not by the tests

  1. access_hash leaked through raw.full.chat_photo.access_hash on every peer kind — _serialize() stripped only the top level. Found during live verification; the unit fakes are flat and carry no nested objects. Fixed with a recursive _redact() plus tests asserting against the whole serialized payload.
  2. muted was wrong in three of five real states, including after this project's own notifications unmute (which writes mute_until=0, decoded by telethon 1.44 as a non-null 1970 date). muted is now true only while a mute is in force, muted_until is None for a past timestamp, and silent became its own field.
  3. A flood-wait raised during reference resolution escaped un-annotated, so the half of the inspect surface where FLOOD_WAIT is most likely returned a 502 with no Retry-After — contradicting the docs added in the same change.
  4. The spec's required flood-wait WARNING was never implemented. Pacer.run now logs before every retry sleep, naming the key, pause and attempt — a 15-minute silent sleep was indistinguishable from a hang. It goes to stderr (verified), so it cannot pollute the JSON stdout payload, and messages pin/unpin get it too since they share the pacer.

Tests

2435 passing, ruff clean. test_chats_inspect.py, test_chats_inspect_backend.py (all peer kinds), test_cli_chats_inspect.py, test_chats_inspect_surfaces.py (HTTP + MCP, full status ladder on both reference branches), test_chats_set_ttl.py (gate-before-RPC, no-op short-circuit, dry-run, read-back wins, mismatch raises, TypeNotFoundError tolerance), test_chats_set_ttl_backend.py, test_cli_chats_set_ttl.py.

Manual verification checklist

  • chats inspect --entity <supergroup>ttl_period matches what the Telegram client shows under auto-delete
  • chats inspect --entity mekind: user, no crash on group-only fields
  • chats inspect --entity <channel> --raw | grep -c access_hash0
  • topics_layout agrees with groups get-layout on the same forum supergroup
  • curl "$API/telegram/chats/inspect?entity=me" with a bearer token → same fields as the CLI
  • curl "$API/telegram/chats/inspect?entity=me&raw=true" → 400 naming the reason
  • MCP telegram_chats_inspect through the inspector → same payload as HTTP
  • A chat with no read grant in telegram.access → CLI exit 3, HTTP 403, MCP access-denied
  • chats set-ttl --entity me --ttl 1d --dry-run → reports the change, writes nothing
  • chats set-ttl --entity me --ttl 1dchanged: true, chats inspect --entity me agrees
  • chats set-ttl --entity me --ttl off twice — the second run is changed: false and posts no service message
  • chats set-ttl on a chat with no write grant → exit 3

(set-ttl checks are mutating; run them against Saved Messages only, per the project's e2e rule.)

Known gaps

  • The bot and basic_group inspect branches were never exercised against the live account — no such chats were reachable. Covered by unit tests plus an offline run of real telethon 1.44 objects through the backend.
  • chats set-ttl has had no live run on this branch; the behaviour it encodes comes from the ad-hoc 78-chat run that motivated it.
  • If _shallow_for finds no id match among 2+ entries (a channel plus its linked discussion group), kind still reports supergroup because broadcast is read off the absent object.
  • The set-ttl payload's chat_name echoes the caller's own reference string, not the chat's real title (documented in SKILL.md).
  • Datetimes render differently on the wire: the CLI's default=str gives 2026-01-02 03:04:05+00:00, the JSON surfaces give ISO-8601. Documented, not normalized.

🤖 Generated with Claude Code

popstas and others added 28 commits August 5, 2026 01:18
Adds the spec for a chat-metadata read op: a `chats/` domain package and a
CLI-first `chats inspect` command returning a flat, kind-agnostic payload
(ttl_period, counters, restrictions, rights) with a `--raw` escape hatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five TDD tasks: chats/ domain package, Telethon adapter for the three peer
kinds, the CLI command, docs/skill sync, and a live read-only check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…apter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- ChannelPrivateError/ChatForbiddenError from the GetFull* RPC now raise
  ValueError naming the chat (CLI exit 2) instead of propagating as an
  unmapped RPCError (exit 1). Telethon normalizes ChatForbidden/
  ChannelForbidden into an ordinary InputPeerChat/InputPeerChannel before
  get_input_entity returns, so the inspect_chat dispatch catch-all never
  saw this case.
- Extract _raw_payload() and _mute_fields() helpers shared by all three
  _inspect_* branches, replacing three verbatim copies of the raw-payload
  and muted/muted_until construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- README: give chats its own heading instead of burying chats inspect
  under the members heading
- SKILL.md: chats/inspect Typical errors now also lists 'chat <id> is
  private or inaccessible' and 'chat <id> is forbidden', matching
  chats/telethon_backend.py

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live verification (Task 5) found access_hash surviving --raw through
nested Photo objects (raw.full.chat_photo.access_hash for
supergroups/channels, raw.full.profile_photo.access_hash for users).
_serialize() only filtered the top-level dict; Telethon's own to_dict()
already recurses into nested TLObjects (confirmed by reading the
generated ChannelFull.to_dict() source), so a nested access_hash was
never seen by the old one-level filter.

Replace the top-level filter with _redact(), which walks dicts, lists/
tuples, and nested objects reached via either the to_dict() path or the
vars() fallback, at any depth. A _seen id-set guards a reference cycle
without imposing a depth cap on legitimate payloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Final review-fix wave for `chats inspect`, five findings in one pass.

`muted` was true in three of the five real notify-settings states. It
tested `mute_until is not None`, but telethon 1.44's `tgread_date` no
longer special-cases 0 — it returns `_EPOCH + timedelta(seconds=value)`,
so the `InputPeerNotifySettings(mute_until=0)` this project's own
`notifications unmute` writes comes back as a non-None
1970-01-01T00:00:00+00:00. An unmuted chat therefore reported
`"muted": true, "muted_until": "1970-01-01T00:00:00+00:00"`, and an
expired temporary mute reported its stale date the same way.

`muted` is now true only while `mute_until` lies in the future (compared
against a tz-aware now — telethon's dates are always aware), and
`muted_until` is None when the timestamp is absent or past, so the
payload never carries an epoch or a stale date as if it meant something.
`silent` — a separate TL flag meaning the notification's *sound* is off,
not the chat — gets its own `ChatInfo` field next to them rather than
being folded into `muted`. That makes the spec's "flattened into
`muted` / `muted_until`" three fields; SKILL.md and README.md say so.

The supergroup fixture that enshrined this (a mute expired two years
ago, asserted as muted) now uses a future timestamp, and the negative
states get their own tests: expired, epoch, silent-alone, settings
absent, settings untouched. The `NotifySettings` fake's `silent` now
defaults to None like a real unset optional flag.

Also:

- `CHAT_KINDS` was exported but unchecked. A new test pins it to the
  backend's dispatch in both directions, and each mapping test asserts
  membership, so it is now the checked answer to what `kind` can be.
- `_shallow_for` returned `items[0]` on an id mismatch whatever the
  bucket held, contradicting its own comment. `GetFullChannel` on a
  channel with a linked discussion group returns both chats, so that
  could map the linked group's title and flags onto the requested chat.
  Tightened to the single-entry case the comment describes; with two or
  more there is no safe guess and None is the honest answer.
  `groups/telethon_backend.py` is deliberately left alone.
- Two `raise translate_flood_wait(exc) from exc` sites set
  `__cause__ is exc` when there was nothing to translate. They now use
  the conditional shape the other two branches already had.

Tests: 2318 passed (+10). Ruff clean.
Four decisions taken before implementing HTTP and MCP: the remote surfaces
take the same chat reference set as the CLI, raw stays CLI-only and is
rejected rather than ignored, FloodWaitError is mapped to 502/needs_review
instead of falling through, and raw never reaches the domain call from a
remote surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`inspect_chat_for_request` only annotated a `FloodWaitError` raised by the
domain call, so a throttle from the `entity` probe or the `chat_name`
branch's `list_folders()` reached the surfaces as a bare error carrying
only `.seconds`. `retry_after_details()` then answered `None`, and the
documented contract broke on half the surface: HTTP returned 502 with no
`Retry-After` header and no `retry_after_seconds`, MCP dropped `detail`
entirely. Wrap the whole body — resolution, gate wiring and domain call —
in one `try`. The annotation only fills in missing fields, so a paced
flood-wait keeps its own values.

Also close the MCP error-path coverage gap with one parametrized test over
the ten failure sources HTTP already covers (404/409/400/503, asserting the
`error` discriminator so the three distinct 409s cannot collapse), rename
the misleading `has_factory` test parameter to `backend_returns_none`, and
correct the docs' "same payload" claim: the remote surfaces return the same
fields, but datetimes render as ISO-8601 there and as Python's repr on the
CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the three wire facts the 2026-08-05 ad-hoc folder sweep proved:
SetHistoryTTL flood-waits escalate (261s/703s/866s observed), its RPC
return cannot be trusted (TypeNotFoundError on a write that applied), and
every successful set posts a service message — which is why setting an
unchanged value must short-circuit without writing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five tasks: pure domain, Telethon adapter, config/pacing key, CLI, docs.
Each ends independently green — Task 1 writes ttl_gate_key inline and
Task 3 switches it to the re-export, so no task depends on a module a
later one creates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… responses

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found get_ttl's shared except block only mapped ChannelPrivateError,
missing the ChatForbiddenError -> ValueError mapping that
_inspect_basic_group already has for the InputPeerChat (basic-group) path.
Also added tests for both forbidden-Full-fetch branches, which were
previously untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consumes chats/ttl.py (parse_ttl, SetTtlRequest, set_chat_ttl), the
TelethonChatTtlBackend adapter, and the ttl_min_interval_seconds /
ttl_max_flood_wait_seconds / ttl_max_flood_wait_retries config fields
added by earlier tasks in this plan.

Adds _build_chat_ttl_backends and _cli_ttl_pacer beside their chats
inspect / messages pin counterparts, and the `chats set-ttl` command
itself: resolves the chat (--chat-id/--chat-name/--entity), parses
--ttl, calls set_chat_ttl, and maps errors to the standard exit-code
ladder (2 caller input/read-back mismatch, 3 AccessDenied, 1 flood-wait
exhaustion/other). --dry-run wraps the domain result in the project's
standard dry-run envelope.

tests/test_skill_inventory.py::test_every_cli_command_is_in_skill_catalog
now fails as expected -- SKILL.md/README docs land in the next task.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- messages/pacing.py: log a WARNING before every flood-wait retry sleep
  in Pacer.run, naming the pacing key, pause length and attempt number —
  the spec required this and it was never implemented; a 15-minute
  silent sleep was indistinguishable from a hang. Confirmed the shared
  structlog logger writes to stderr by default (configure_logging's
  `stream` param defaults to sys.stderr, and neither CLI nor HTTP call
  site overrides it), so this cannot pollute the JSON stdout payload.
  Benefits messages pin/unpin too, since they share this pacer.

- test_chats_set_ttl_backend.py: add flood-wait translation coverage for
  TelethonChatTtlBackend.get_ttl/set_ttl, following the precedent in
  test_messages_pin.py. Verified the new tests fail when
  translate_flood_wait is neutered, then restored the source.

- skills/telegram-assistant/SKILL.md: fix the set-ttl section's
  self-contradiction ("no folder flag" vs. the documented
  --folder-name/--folder-id scoping flags) and add a caveat that the
  set-ttl payload's chat_name is the caller's own reference string, not
  the chat's real title. Re-synced to ~/.claude/skills/telegram-assistant/.
@popstas popstas changed the title feat: read-only chats inspect on CLI, HTTP and MCP feat: chats inspect (CLI/HTTP/MCP) and chats set-ttl (CLI) Aug 6, 2026
@popstas
popstas merged commit 2cf56b9 into master Aug 6, 2026
2 checks passed
@popstas
popstas deleted the feat/chats-inspect branch August 6, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant