feat: chats inspect (CLI/HTTP/MCP) and chats set-ttl (CLI) - #26
Merged
Conversation
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/.
chats inspect on CLI, HTTP and MCP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a new
chats/domain area with two operations that had no equivalent before: a read-onlychats inspecton all three surfaces, and a CLI-onlychats set-ttlthat writes a chat's auto-delete period.folders inspectreturns two fields per chat by design andgroups get-layoutreads 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
chats inspectGET /telegram/chats/inspecttelegram_chats_inspectchats set-ttlBoth 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-ttlis 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 themembers/split — one operation per file, READ and WRITE never mixed:service.py—ChatInfo(frozen, ~70 fields,to_dict()), theChatInspectBackendprotocol,inspect_chat(). READ-gated before any Telegram call. No telethon import.ttl.py—parse_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 likenotifications mute.telethon_backend.py—TelethonChatInspectBackend(resolve the peer once, oneGetFull*, map the pair — two RPCs maximum, no dialog walk) andTelethonChatTtlBackendbelow it.http_api/chats.py— the route,chat_inspect_backend_factoryonapp.state(returnsNone→ 503 until the client connects), and the shared request helper.The inspect payload is flat and kind-agnostic:
Nonewhere a field does not apply, sojq .ttl_periodworks regardless of what was inspected.chats set-ttlThe gap was found the hard way: disabling auto-delete across a 78-chat folder needed an ad-hoc
messages.SetHistoryTTLscript, bypassingtelegram.access,--dry-runand 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:set_chat_ttlreads 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.TypeNotFoundError(constructor newer than the installed layer) — yet the write had applied. So the adapter treatsTypeNotFoundErrorduring the write as non-fatal and falls through to an unconditional read-back, which is the authoritativettl_period. A read-back disagreeing with the request is an error naming both values, never a reported success.SetHistoryTTLis flood-waited hard, and the waits escalate — 261s, 703s, 866s observed on one account within an hour. The write goes through the sharedPaceron its own gate keyttl:<bare id>(not shared with the pin gate — different Telegram limits), with three newtelegram.*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).--ttltakesoff/0,<int><unit>(s/m/h/d/w) or a bare integer of seconds; negatives, non-integers, unknown units and anything past2**31 - 1are 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_periodarenullwhen auto-delete is off, never0, matching whatchats inspectreports for the same chat.Deliberate decisions
rawis CLI-only. HTTP and MCP accept the parameter and reject it (400 / tool error) rather than ignoring it — a silently droppedraw=truewould 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'sbusiness_location,stories,personal_channel_id.access_hashnever appears in any payload, including--raw. Redaction is recursive.FloodWaitErroris mapped on inspect, unlikemembers listwhich lets it fall through: HTTP 502 withRetry-Afterandretry_after_seconds, MCPneeds_reviewwith the same field.entity,chat_id, orchat_namewithfolder_name/folder_id— rather than the narrower pairmembers listuses.set-ttlneeds only WRITE.writedoes not implyreadin this project's policy, and theget_ttlcalls are part of the write operation, not a separate READ grant.members list --user.Defects caught by review, not by the tests
access_hashleaked throughraw.full.chat_photo.access_hashon 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.mutedwas wrong in three of five real states, including after this project's ownnotifications unmute(which writesmute_until=0, decoded by telethon 1.44 as a non-null 1970 date).mutedis now true only while a mute is in force,muted_untilisNonefor a past timestamp, andsilentbecame its own field.FLOOD_WAITis most likely returned a 502 with noRetry-After— contradicting the docs added in the same change.WARNINGwas never implemented.Pacer.runnow 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, andmessages pin/unpinget 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,TypeNotFoundErrortolerance),test_chats_set_ttl_backend.py,test_cli_chats_set_ttl.py.Manual verification checklist
chats inspect --entity <supergroup>—ttl_periodmatches what the Telegram client shows under auto-deletechats inspect --entity me—kind: user, no crash on group-only fieldschats inspect --entity <channel> --raw | grep -c access_hash→0topics_layoutagrees withgroups get-layouton the same forum supergroupcurl "$API/telegram/chats/inspect?entity=me"with a bearer token → same fields as the CLIcurl "$API/telegram/chats/inspect?entity=me&raw=true"→ 400 naming the reasontelegram_chats_inspectthrough the inspector → same payload as HTTPreadgrant intelegram.access→ CLI exit 3, HTTP 403, MCP access-deniedchats set-ttl --entity me --ttl 1d --dry-run→ reports the change, writes nothingchats set-ttl --entity me --ttl 1d→changed: true,chats inspect --entity meagreeschats set-ttl --entity me --ttl offtwice — the second run ischanged: falseand posts no service messagechats set-ttlon a chat with nowritegrant → exit 3(set-ttl checks are mutating; run them against Saved Messages only, per the project's e2e rule.)
Known gaps
botandbasic_groupinspect 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-ttlhas had no live run on this branch; the behaviour it encodes comes from the ad-hoc 78-chat run that motivated it._shallow_forfinds no id match among 2+ entries (a channel plus its linked discussion group),kindstill reportssupergroupbecausebroadcastis read off the absent object.chat_nameechoes the caller's own reference string, not the chat's real title (documented in SKILL.md).default=strgives2026-01-02 03:04:05+00:00, the JSON surfaces give ISO-8601. Documented, not normalized.🤖 Generated with Claude Code