diff --git a/AGENT.md b/AGENT.md index 4268244..5c427ca 100644 --- a/AGENT.md +++ b/AGENT.md @@ -528,36 +528,123 @@ match it — that is what `folder remove --exclude` is for. ### Contacts ``` -tlgr contact list [--limit N] [--cursor TOKEN] -→ {"contacts": [{"id": ..., "name": ..., "username": ..., "phone": ...}], "has_more": false} - -tlgr contact add [name] -→ {"added": true, "user_id": 123} +tlgr contact list [--limit N] [--cursor TOKEN] [--with-status] [--with-stories] + [--sort name|first-name|last-name|last-seen|added] [--mutual-only] + [--close-friends-only] [--ids-only] [--export vcard|csv|json --out PATH] +→ Page[Contact]: {"items": [{"id", "name", "username", "phone", "mutual", ...}], + "has_more": false, "next_cursor": null, "total": 2} + +tlgr contact add [name] [--first-name T] [--last-name T] [--note T] + [--share-phone] [--from-message :] +→ {"added": true, "user_id": 123, "imported": [123], "retry": [], "reason": null} +# By USER it is contacts.addContact; by +PHONE it is contacts.importContacts. +# An empty `imported` with an empty `retry` is AMBIGUOUS: the number may have +# no Telegram account, OR its owner may refuse lookups by phone. `reason` says +# so. Do not report it as "no such user". `retry` entries are not failures -- +# the server is asking for them again later. tlgr contact rename [--first-name TEXT] [--last-name TEXT] → {"saved": true, "user_id": 123, "first_name": "...", "last_name": "..."} # Works on non-contacts too (saves them as a contact). Omitted parts keep the -# current profile name. Useful for tagging users with state markers. - -tlgr contact remove -→ {"removed": true} - -tlgr contact search [--limit N] [--cursor TOKEN] -→ {"contacts": [...], "has_more": false} +# current profile name. Useful for tagging users with state markers. An empty +# first name is sent as "." because the server rejects an empty one. + +tlgr contact remove ... [--phone NUMBER] +→ {"removed": true, "user_ids": [123], "phones": []} +# Deleting by phone reaches numbers with no Telegram account and is +# irreversible server-side. + +tlgr contact search [--mine-only] [--global-only] [--recent] [--type KIND] +→ Page[FoundPeer]: each row carries `source`: mine | global | recent | sponsored. +# Adverts are OFF unless --with-sponsored. `--recent` is tlgr-local state. + +tlgr contact note set [text] [--clear] # private; read back by `user get --full` +tlgr contact status list [--online-only] # last-seen for every contact, one call +tlgr contact birthday list [--window DAYS] +tlgr contact close-friends list|set ... # --add/--remove are read-modify-write +tlgr contact blocked list [--stories] # the two blocklists are independent +tlgr contact blocked set ... # REPLACES the list; the reply is the diff +tlgr contact top list|set [--category NAME] # frequent contacts +tlgr contact import [--batch-size N] +tlgr contact sync [--apply] [--delete-missing] # prints the diff unless --apply +tlgr contact saved list [--invite-text] # every number ever uploaded +tlgr contact share --to +tlgr contact share-phone # irreversible disclosure ``` +`contact status list` reports `by_me` on the coarse buckets (`recently`, +`last_week`, `last_month`). It means **our own** last-seen privacy caused the +coarseness — never report it as the peer hiding from us. + ### Users ``` -tlgr user get -→ {"id": ..., "first_name": ..., "username": ..., "bio": ..., "is_bot": false, ...} +tlgr user get [--full] [--translate-bio LANG] + [--from-chat CHAT --from-message ID] +→ {"id": ..., "first_name": ..., "username": ..., "bio": ..., "is_bot": false, + "status": "online", "stories_hidden": false, ...} +# Never prints an access hash: `access_hash_cached` says whether one is held. +# A bare numeric id resolves only from this account's peer cache -- there is no +# MTProto call that mints an access hash for one. For a `min` user (seen only +# inside a channel message) pass --from-chat/--from-message. +# No photo plus an empty status is a SIGNAL, not a verdict: this never claims +# "they blocked you". Use the global --select to pull out one field. + +tlgr user block [--stories] [--report-spam] [--delete-history] +→ {"peer_id": ..., "blocked": true, "stories_only": false, "deleted": false} +tlgr user unblock [--stories] +→ {"peer_id": ..., "blocked": false, "already": false} + +tlgr user can-message ... +→ Page[ContactRequirement]: {"user_id", "result": free|premium|paid, "stars_amount"} +# Pairs with dialog-status for cold-outreach gating: this answers "am I +# allowed to", dialog-status answers "have I already". + +tlgr user chat list [--leave-all] # groups and channels you share +tlgr user link [--profile] [--text T] # `me --token` mints an expiring link +tlgr user photo list|set +tlgr user music list +tlgr user personal-channel get +tlgr user birthday set # sends a visible message tlgr user dialog-status [--max-dialogs N] → {"ref": ..., "id": ..., "username": ..., "resolved": true, "has_dialog": true, "message_count": 12, "source": "peer_dialogs", "reason": null} -tlgr user hide-stories [--unhide] +tlgr user hide-stories ... [--unhide] [--all on|off] → {"user_id": ..., "username": ..., "hidden": true, "already": false} +# More than one peer fills `peers`; a single peer answers with exactly the +# four keys above. +``` + +### Resolving a reference + +``` +tlgr resolve peer ... [--from-chat CHAT --from-message ID] [--ids botapi] +→ Page[ResolvedRef]: {"ref", "id" (raw), "marked_id", "type", "title", + "source", "resolved", "access_hash_cached"} +# `source` says HOW it was answered. An uncached bare numeric id FAILS +# (exit 5 or 13) rather than being guessed at -- there is no MTProto call that +# turns an id into an access hash for a non-bot account. + +tlgr resolve username [--type user|bot|group|channel] +# USERNAME_INVALID exits 2 (a typo); USERNAME_NOT_OCCUPIED exits 5 (free). + +tlgr resolve phone <+number> [--offline] [--countries] +→ {"phone", "e164", "country", "resolved", "peer", "reason"} +# PHONE_NOT_OCCUPIED exits 13, NEVER 5: no account and a privacy refusal are +# indistinguishable. --offline formats and validates without an RPC. + +tlgr resolve link [--no-network] [--open] [--draft CHAT] +→ {"kind", "raw_url", "username", "msg_id", ..., "delegated_to"} +# Classifies any t.me / tg:// link into one of ~30 kinds and NEVER acts: +# `delegated_to` names the command that would (chat join, bot start, gift +# redeem, proxy add...). `t.me/+X` is a PHONE when X parses as a number and an +# invite hash otherwise. + +tlgr resolve cache get [--type KIND] [--stale 7d] [--refresh PEER] [--purge] +# The per-account peer database. Access hashes are never printed, only +# `access_hash_cached`; they are per login session and worthless elsewhere. ``` `hide-stories` is Telegram's own "Hide Stories" menu item: the peer leaves the diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce68f6..87f7913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,14 +37,23 @@ The update transport follows: `events`, `watch`, `daemon`, `sync`, `net`, operations, 114 event types, and the `updates_sync_network` domain fully accounted for. +Then `contact`, `user` and `resolve`: 38 operations covering the address book, +one person's profile, both blocklists, the phonebook and the reference +resolver every other group already leans on. `tlgr/cli/legacy/contact.py` and +`tlgr/cli/legacy/user.py` are deleted, along with their eight IPC routes and +the eight `ClientWrapper` methods behind them. The two semantics AGENT.md +freezes are unchanged: `user dialog-status` is still three-valued and still +exits 13 for "could not establish", and `user hide-stories` still reports +`already` and sends nothing when there is nothing to do. + ### Breaking Every change below applies **only to commands generated from the operation registry** — in this release that is the `message`, `draft`, `chat`, `folder`, `auth`, `account`, `passport`, `media`, `sticker`, `gif`, `emoji`, `events`, `watch`, `daemon`, `sync`, `net`, `proxy`, `config`, `job`, -`webhook` and `export` groups, `tlgr completion`, `tlgr status`, -`tlgr schema` and the `agent` group. Commands still +`webhook`, `export`, `contact`, `user` and `resolve` groups, +`tlgr completion`, `tlgr status`, `tlgr schema` and the `agent` group. Commands still hand-written under `tlgr/cli/legacy/` behave exactly as they did in v1 until their own migration PR, at which point these rules apply to them too. @@ -84,6 +93,14 @@ status envelopes: | 11 | `config.list` | the raw TOML document | `Page[ConfigEntry]`, one row per key with `value`, `default` and `source`; secrets redacted | `--defaults` includes keys still at their default; `config get ` is the point lookup | | 12 | `config.keys` | `{keys: {name: {section, key, description}}}` | `Page[ConfigKey]` with `type`, `default`, `scope`, `requires_restart` and `help` | key names gained a section prefix (`idle_timeout` → `daemon.idle_timeout`); both spellings are accepted by `config get`/`set`/`unset` | +Three more changed in the contact and user groups: + +| # | Change | v1 | v2 | Migration | +|---|---|---|---|---| +| 10 | `contact.list`, `contact.search` | `{"contacts":[…],"has_more":…}` | `Page[Contact]` | `--results-only` yields `{items, has_more, next_cursor, total}`; every v1 row key (`id`, `name`, `username`, `phone`) is still there, and `phone` is now normalised to E.164 | +| 11 | `contact.add` | `{"added": true, "user_id": 123}` | the same two keys plus `imported`, `retry`, `popular_importers` and `reason` | additive. `reason` is filled when the import came back empty, because "no such account" and "the owner hides their number" are indistinguishable and v1 reported the first | +| 12 | `user.get` | `{"id","first_name","username","bio","is_bot","status","stories_hidden",…}` | the same keys, plus everything `users.getFullUser` carries | additive; `--select` reaches any of it. `--field` is gone: the global `--select bio --results-only` does the same thing on every command | + `tlgr agent whoami --json` reports `output_schema_version: 2`, so an agent can branch on the two sets without probing for each change. diff --git a/README.md b/README.md index 1552a98..97ebe24 100644 --- a/README.md +++ b/README.md @@ -185,25 +185,67 @@ Full reference: [`docs/reference/folder.md`](docs/reference/folder.md). ### Contacts ```bash -tlgr contact list -tlgr contact add [name] +tlgr contact list # --with-status --sort last-seen --export vcard --out FILE +tlgr contact add [name] # --first-name --last-name --note --share-phone tlgr contact rename # --first-name, --last-name (tags non-contacts too) -tlgr contact remove -tlgr contact search -``` +tlgr contact remove ... # --phone reaches numbers with no account +tlgr contact search # --mine-only --global-only --recent +tlgr contact note set # --clear +tlgr contact status list # online / last-seen for every contact, in one call +tlgr contact birthday list +tlgr contact close-friends list|set +tlgr contact blocked list|set # --stories for the story blocklist +tlgr contact top list|set # frequent contacts, by category +tlgr contact import # bulk phonebook import +tlgr contact sync # diff a phonebook against the server (--apply) +tlgr contact saved list # every number ever uploaded, account or not +tlgr contact share --to +tlgr contact share-phone # irreversible +``` + +An empty `contact add` by phone is **ambiguous** — the number may have no +account, or its owner may refuse lookups by phone — and `reason` says so +rather than the reply claiming "no such user". ### Users ```bash -tlgr user get +tlgr user get # --full --translate-bio LANG --from-chat/--from-message tlgr user dialog-status # does THIS account have prior history with them? -tlgr user hide-stories # archive their stories for this account (--unhide) +tlgr user hide-stories ... # archive their stories for this account (--unhide) +tlgr user block # --stories --report-spam --delete-history +tlgr user unblock +tlgr user can-message ... # free | premium | paid (and the Stars price) +tlgr user chat list # groups you share (--leave-all) +tlgr user link # --profile --text; `me --token` for a contact token +tlgr user photo list|set +tlgr user music list +tlgr user personal-channel get +tlgr user birthday set ``` `dialog-status` distinguishes "yes", "definitively no", and "cannot tell" (exit 13) instead of guessing. Never infer "no history" from an entity resolution error — see AGENT.md for why. +`hide-stories` is idempotent: it reads the current flag first and reports +`already: true` without an RPC, so a bulk pass over hundreds of peers is +nearly free to repeat. + +### Resolving references + +```bash +tlgr resolve peer ... # @username | id | +phone | t.me link | me +tlgr resolve username +tlgr resolve phone <+number> # --offline formats and validates, no RPC +tlgr resolve link # classify any t.me / tg:// link (--open) +tlgr resolve cache get # inspect the per-account peer database +``` + +`resolve link` never *acts*: it says what a link is and names the command +that would follow it in `delegated_to`. A phone lookup that comes back empty +exits 13, never 5 — no account and a privacy refusal are indistinguishable. + ### Media, stickers, GIFs and emoji ```bash diff --git a/docs/design/DECISIONS.md b/docs/design/DECISIONS.md index b4bab02..22f4b98 100644 --- a/docs/design/DECISIONS.md +++ b/docs/design/DECISIONS.md @@ -912,3 +912,105 @@ the CLI in-process, and any embedding program) silently read the real the `.production` marker turned the bug into a hard failure rather than a quiet one. Both now call `paths.default_base()` at call time. `CONFIG_DIR` survives for the legacy modules that will be deleted with their groups. + +## 2026-09-03 — a result can be indeterminate *and* still be a result + +`user dialog-status` has to answer three ways and AGENT.md freezes all three, +including `resolved=false, has_dialog=null` with exit 13. Raising +`IndeterminateError` would have satisfied the exit code and thrown away the +body — and the body is where `reason` and `scanned_dialogs` live, which is +what a caller needs to decide what to do next. (It would not even have +produced exit 13 over IPC: `IndeterminateError.http` is 200, so the transport +reads it as success.) The context therefore grew `mark_indeterminate(reason)`, +the daemon puts `meta.indeterminate` on the envelope, and the CLI exits 13 +after rendering. `contact top list` on an account with the feature turned off +and `resolve phone` on an unoccupied number use the same mechanism, for the +same reason: an empty list and a "not found" would both be claims nobody +established. + +## 2026-09-03 — `contact` and `user` models emit their falses + +`Model` sets `omit_defaults=True`, so "absent" can mean "not applicable". For +this group the opposite is true: `already: false`, `added: false`, +`hidden: false`, `resolved: false` and `has_dialog: null` *are* the answer, +and v1 printed every one of them. `ContactModel` therefore sets +`omit_defaults=False` and every contact/user shape inherits it. `ResolvedLink` +is the exception and stays compact — a null for each of thirty link kinds is +noise — with `kind` made a required field so the one thing that must never be +missing cannot be. + +## 2026-09-03 — `user get` is single-target, and has no `--field` + +The work list had `user get ...` variadic. A spec has one response type, +so a variadic `user get` would have had to return `Page[UserProfile]` — and +AGENT.md documents `tlgr user get ` returning a bare object with `id`, +`first_name`, `username`, `bio`, `is_bot`. Keeping the documented shape won; +several users at once is `resolve peer` (which is variadic) or a loop. +`--field` went the same way: the global `--select bio --results-only` already +projects any field of any op, and a second, per-command spelling of one idea +is what STYLE §1 exists to prevent. `--refresh` went with it — tlgr keeps no +`userFull` cache to bypass; the 60 s one is the server's. + +## 2026-09-03 — `user hide-stories` is variadic without changing its shape + +v1 took one peer and returned `{user_id, username, hidden, already}`; the +whole point of the command is bulk passes over hundreds of peers, which a +loop of one-peer calls makes needlessly expensive to write. It now accepts +several, and a single peer still answers with exactly v1's four keys — extra +peers appear in `peers` — so nothing that reads the documented shape has to +change. + +## 2026-09-03 — a two-segment alias cannot shadow a three-segment group + +`contact saved`, `contact joined`, `user music`, `user personal-channel` and +`resolve cache` were all going to be short aliases for their `… list`/`… get` +operation. The Click tree builder places a command at a path, so each of them +would have *replaced* the group of the same name and taken its subcommand with +it. They were dropped rather than teaching the builder to be a command and a +group at once; the pluralised aliases that collide with nothing +(`contact statuses`, `contact birthdays`, `user photos`, `user blocked`) stay. + +## 2026-09-03 — `contact list --export` writes a file, and only a file + +The implementation runs in the daemon, so `--out -` would write to the +daemon's stdout, not the caller's. `--export` therefore requires `--out PATH` +and refuses `-` with a sentence saying why; `--json`/`--plain` already give +machine-readable output on the caller's stdout. The same rule makes +`contact import -` and `contact sync -` refusals rather than silent reads of +the wrong stdin. Exported phonebooks are written 0600: a contact list is +exactly the kind of file that should not become world-readable because a +shell redirect was convenient. + +## 2026-09-03 — `resolve link` classifies, and never acts + +Twenty-odd link kinds, one command, because the human pasting a link does not +know which kind it is — that is the question. Following one is always a +different, confirmed verb (`chat join`, `bot start`, `gift redeem`, +`proxy add`), and `delegated_to` names it. `--open` is the one concession and +it performs a *read* only. The single ambiguity worth spelling out is +`t.me/+X`: it is a phone number when X parses as one and an invite hash +otherwise, and guessing wrong turns a lookup into joining a group. + +## 2026-09-03 — `user chat list --leave-all` honours `--dry-run` itself + +Listing shared groups is a read and must stay dry-runnable, so the op is not +`mutating`; but `--leave-all` writes. Declaring the op mutating would make +every plain listing print a dry-run stub, and declaring it destructive would +prompt on every listing. It follows `folder list --tags` instead: the write +branch checks `ctx.dry_run` and says what it would do, and the help says +plainly that it leaves immediately. + +## 2026-09-03 — `link.py` folded into `resolve.py` + +PR-5's scope names a `link` module, but the work list contains no `link.*` +operation: the two link commands are `resolve link` (classify a t.me/tg:// URL) +and `user link` (build one). They live with the group whose noun they carry +rather than in a module that would hold one function and a docstring. + +## 2026-09-03 — `resolve peer`, `resolve phone` and `resolve username` are verbs + +STYLE §1's verb list has no entry for these, and the registry lints the last +path segment against it. COMMANDS.md's conventions already carve out +verb-first nouns (`resolve `, `search `), so `peer`, `phone` and +`username` were added to `VERBS` with a comment saying which rule they arrive +under — rather than bending the paths into `resolve peer get`. diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index 6831320..1c63c36 100644 --- a/docs/reference/PARITY.md +++ b/docs/reference/PARITY.md @@ -7,31 +7,31 @@ Coverage against the Telegram feature catalog, computed from the registry: every `covered` is implemented today. `acct%` is covered **plus** waived — an id that belongs to a group a later PR owns, named in `tlgr/data/parity_waivers.toml` with the PR that closes it. Ids whose feasibility is `not-applicable` or `prohibited` are excluded from the denominator once and never counted again. ``` -catalog 2026-09-02 — 355 operations, 532 invocable paths +catalog 2026-09-02 — 393 operations, 586 invocable paths domain covered req % acct% ops auth_sessions_security 87 89 97.8% 100.0% 44 bots_inline_payments 17 175 9.7% 100.0% 7 -calls_voicechats 126 133 94.7% 100.0% 49 -contacts_users 18 121 14.9% 100.0% 13 -dialogs_chats 114 146 78.1% 100.0% 55 -groups_channels_admin 26 162 16.0% 100.0% 15 +calls_voicechats 128 133 96.2% 100.0% 51 +contacts_users 106 121 87.6% 100.0% 49 +dialogs_chats 131 146 89.7% 100.0% 69 +groups_channels_admin 27 162 16.7% 100.0% 16 media_files 121 143 84.6% 100.0% 60 messages_core 159 167 95.2% 100.0% 54 -polls_reactions_content 117 174 67.2% 100.0% 57 -profile_settings_privacy 25 178 14.0% 100.0% 24 -stories 12 120 10.0% 100.0% 9 +polls_reactions_content 123 174 70.7% 100.0% 62 +profile_settings_privacy 31 178 17.4% 100.0% 29 +stories 14 120 11.7% 100.0% 11 updates_sync_network 188 189 99.5% 100.0% 67 priority covered req % acct% -P0 116 178 65.2% 100.0% -P1 240 379 63.3% 100.0% -P2 320 610 52.5% 100.0% -P3 334 630 53.0% 100.0% +P0 136 178 76.4% 100.0% +P1 266 379 70.2% 100.0% +P2 355 610 58.2% 100.0% +P3 375 630 59.5% 100.0% -TOTAL 1010 1797 56.2% 100.0% +TOTAL 1132 1797 63.0% 100.0% excluded: not-applicable 79, prohibited 40 -uncovered: 787 (787 waived with a PR number) +uncovered: 665 (665 waived with a PR number) ``` ## By domain @@ -40,25 +40,25 @@ uncovered: 787 (787 waived with a PR number) |---|---:|---:|---:|---:|---:| | `auth_sessions_security` | 87 | 89 | 97.8% | 100.0% | 44 | | `bots_inline_payments` | 17 | 175 | 9.7% | 100.0% | 7 | -| `calls_voicechats` | 126 | 133 | 94.7% | 100.0% | 49 | -| `contacts_users` | 18 | 121 | 14.9% | 100.0% | 13 | -| `dialogs_chats` | 114 | 146 | 78.1% | 100.0% | 55 | -| `groups_channels_admin` | 26 | 162 | 16.0% | 100.0% | 15 | +| `calls_voicechats` | 128 | 133 | 96.2% | 100.0% | 51 | +| `contacts_users` | 106 | 121 | 87.6% | 100.0% | 49 | +| `dialogs_chats` | 131 | 146 | 89.7% | 100.0% | 69 | +| `groups_channels_admin` | 27 | 162 | 16.7% | 100.0% | 16 | | `media_files` | 121 | 143 | 84.6% | 100.0% | 60 | | `messages_core` | 159 | 167 | 95.2% | 100.0% | 54 | -| `polls_reactions_content` | 117 | 174 | 67.2% | 100.0% | 57 | -| `profile_settings_privacy` | 25 | 178 | 14.0% | 100.0% | 24 | -| `stories` | 12 | 120 | 10.0% | 100.0% | 9 | +| `polls_reactions_content` | 123 | 174 | 70.7% | 100.0% | 62 | +| `profile_settings_privacy` | 31 | 178 | 17.4% | 100.0% | 29 | +| `stories` | 14 | 120 | 11.7% | 100.0% | 11 | | `updates_sync_network` | 188 | 189 | 99.5% | 100.0% | 67 | ## By priority | Priority | Covered | Required | % | Accounted % | |---|---:|---:|---:|---:| -| P0 | 116 | 178 | 65.2% | 100.0% | -| P1 | 240 | 379 | 63.3% | 100.0% | -| P2 | 320 | 610 | 52.5% | 100.0% | -| P3 | 334 | 630 | 53.0% | 100.0% | +| P0 | 136 | 178 | 76.4% | 100.0% | +| P1 | 266 | 379 | 70.2% | 100.0% | +| P2 | 355 | 610 | 58.2% | 100.0% | +| P3 | 375 | 630 | 59.5% | 100.0% | ## Partial coverage @@ -76,9 +76,6 @@ uncovered: 787 (787 waived with a PR number) | `conference.kick-participant` | `conference.remove` | the request is built and sent; the removal block that rotates the shared key is an e2e.chain builder tlgr does not have and accepts from outside | | `conference.link-qr` | `conference.get` | `--qr` returns the exact text to encode; drawing the code needs a QR encoder tlgr does not bundle | | `conference.prune-left` | `conference.remove` | the request is built and sent; the removal block that rotates the shared key is an e2e.chain builder tlgr does not have and accepts from outside | -| `contacts-users.contacts-sort` | `chat.list` | Peer search here is a substring match over the dialog list; the global one is `contact search`. `--sort` orders chats, not contacts. | -| `contacts-users.user-leave-common-groups` | `chat.leave` | `--common-with` leaves the shared groups; listing them is `user chat list`. | -| `dialogs.search-peers` | `chat.list` | Peer search here is a substring match over the dialog list; the global one is `contact search`. `--sort` orders chats, not contacts. | | `game.play` | `message.game.get` | A CLI cannot render an HTML5 game; --url is refused with NOT_SUPPORTED. | | `groups-channels-admin.pending-suggestions` | `chat.get` | Pending suggestions are reported here; dismissing one is PR-7's. | | `media.download-stream-stdout` | `media.download` | The daemon owns the connection, so it cannot write bytes to the caller's terminal: --stdout spools the file and reports its path, and --play is refused rather than having the daemon spawn a player. | @@ -99,33 +96,29 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | Catalog id | Priority | Feature | Closed by | |---|---|---|---| | `calls.privacy-who-can-call` | P0 | Privacy: who can call me | waived until PR-12: inputPrivacyKeyPhoneCall is a privacy rule, set with `privacy set` in the privacy group (PR-12); `call start` already reports the peer's side of it. | -| `dialogs.block-user` | P0 | Block user | waived until PR-5: Blocking is `user block` (PR-5); `chat report --block` calls it. | -| `dialogs.resolve-peer` | P0 | Resolve @username / phone / t.me link to a chat | waived until PR-5: Turning a @username, a phone number or a t.me link into a chat is `resolve` (PR-5); the chat group consumes the resolver rather than exposing it. | -| `dialogs.unblock-user` | P0 | Unblock user | waived until PR-5: Unblocking is `user unblock` (PR-5). | | `profile.photo-set` | P0 | Set profile photo | waived until PR-12: Setting your profile photo is `profile photo set` (PR-12). | | `calls.privacy-p2p` | P1 | Privacy: peer-to-peer calls | waived until PR-12: inputPrivacyKeyPhoneP2P is the same account.setPrivacy surface as every other privacy key (PR-12). | | `chat.photo-set` | P1 | Set group / channel photo (photo, video or emoji/sticker avatar) | waived until PR-7: A group or channel photo is `chat photo set` (PR-7). | -| `contact.receive-card` | P1 | Add a received contact card to your address book | waived until PR-5: contact cards, notes and birthdays are the `contact` surface (PR-5). | -| `dialogs.actionbar-add-contact` | P1 | Add to contacts from the action bar | waived until PR-5: The bar's Add-contact button is `contact add` (PR-5). | -| `dialogs.block-stories` | P1 | Hide my stories from a user (story blocklist) | waived until PR-5: The story blocklist is a privacy surface on the user group (PR-5). | -| `dialogs.dialog-exists` | P1 | Does a dialog with this peer exist | waived until PR-5: `user dialog-status` answers this and migrates with the user group (PR-5). | +| `contacts-users.privacy-added-by-phone` | P1 | Privacy: who can find me by my phone number | waived until PR-12: Privacy keys are the `privacy` group (PR-12); `contact add --share-phone` is the per-user exception. | +| `contacts-users.privacy-global` | P1 | Global privacy settings | waived until PR-12: `privacy global set` is the account-wide privacy surface (PR-12). | +| `contacts-users.privacy-phone-number` | P1 | Privacy: who can see my phone number | waived until PR-12: Privacy keys are the `privacy` group (PR-12). | +| `contacts-users.url-auth-login` | P1 | Log in to a website with Telegram (URL authorization) | waived until PR-10: URL authorization is a bot surface (PR-10); `resolve link` classifies the link and delegates. | | `dialogs.notify-exceptions` | P1 | List notification exceptions | waived until PR-12: The exceptions *list* is `notify exceptions` (PR-12); one chat's exception is `chat notify`. | | `profile.photos-list-history` | P1 | View own / another user's profile photo history | waived until PR-12: Profile photo history is the `profile` group (PR-12). | | `stars.balance` | P1 | Telegram Stars balance | waived until PR-12: the Star balance and top-up packages are the `stars` surface (PR-12). | | `attach.menu-bots` | P2 | Attachment-menu / side-menu mini-app bots: list, info, add, remove | waived until PR-10: Attachment-menu bots are the `bot` group (PR-10). | | `auth.url-auth-bot-button` | P2 | Log in to a website via a bot's login button (Seamless Telegram Login) | waived until PR-10: Seamless Telegram Login is a bot keyboard button (messages.requestUrlAuth / acceptUrlAuth); it lands with the bots group in PR-10. | -| `contact.note` | P2 | Private note on a contact | waived until PR-5: contact cards, notes and birthdays are the `contact` surface (PR-5). | -| `contact.share-token` | P2 | Share your contact via a link | waived until PR-5: contact cards, notes and birthdays are the `contact` surface (PR-5). | -| `contact.suggest-birthday` | P2 | Suggest a birthday for a contact | waived until PR-5: contact cards, notes and birthdays are the `contact` surface (PR-5). | +| `contacts-users.privacy-about` | P2 | Privacy: bio | waived until PR-12: Privacy keys are the `privacy` group (PR-12). | +| `contacts-users.privacy-chat-invite` | P2 | Privacy: who can add me to groups | waived until PR-12: Privacy keys are the `privacy` group (PR-12). | +| `contacts-users.privacy-exception-lists` | P2 | Always/Never allow exception lists | waived until PR-12: Always/Never lists are privacy rules (PR-12); the close-friends list is `contact close-friends`. | +| `contacts-users.privacy-forwards` | P2 | Privacy: forwarded messages link back to me | waived until PR-12: Privacy keys are the `privacy` group (PR-12). | +| `contacts-users.user-status-reveal` | P2 | Show My Last Seen to reveal theirs | waived until PR-12: Revealing my own last-seen to see theirs is a privacy setting (PR-12); `contact status list` reports the by_me flag that explains it. | +| `contacts-users.user-stories` | P2 | A user's stories on their profile | waived until PR-8: A user's stories are the `story` group (PR-8); hiding them is `user hide-stories`. | | `content.limits` | P2 | Server limits for polls, reactions, checklists and gifts | waived until PR-12: the app-config limit table is read through the settings surface (PR-12). | -| `dialogs.actionbar-share-phone` | P2 | Share my phone number | waived until PR-5: Sharing my number is `contact share-phone` (PR-5). | -| `dialogs.bot-stop-restart` | P2 | Stop and block bot / Restart bot | waived until PR-10: Stopping and restarting a bot is the bot group (PR-10). | | `dialogs.business-bot-bar` | P2 | Manage connected business bot in a chat | waived until PR-12: The connected-business-bot bar is a business setting (PR-12). | | `dialogs.business-link-create` | P2 | Create a business 'link to chat' | waived until PR-12: Business chat links are a business setting (PR-12). | | `dialogs.business-link-list` | P2 | List business chat links (with view counters) | waived until PR-12: Business chat links are a business setting (PR-12). | -| `dialogs.hide-stories-peer` | P2 | Hide a peer's stories from the strip | waived until PR-8: Hiding a peer's stories is the story strip (PR-8). | | `dialogs.notify-scope-defaults` | P2 | Default notification settings per chat type | waived until PR-12: Scope-wide defaults are `notify set` (PR-12). | -| `dialogs.presence-watch` | P2 | Peer online status / last seen | waived until PR-4: Online/last-seen is an update stream (PR-4). | | `emoji.status-set` | P2 | Set / clear own emoji status (custom emoji or collectible gift), with expiry | waived until PR-12: Setting your own emoji status is `profile status set` (PR-12). | | `gift.catalog` | P2 | Browse available gifts | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | | `gift.convert-to-stars` | P2 | Convert a gift back into Stars | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | @@ -163,27 +156,21 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `auth.oauth-deep-link` | P3 | Authorize an OAuth login request from a website/app (tg://oauth deep link) | waived until PR-10: A tg://oauth request is a bot authorization flow (messages.requestUrlAuth); it lands with the bots group in PR-10. | | `bot.media-previews` | P3 | Manage a bot's Mini App media previews (owned bots) | waived until PR-10: A bot's Mini App previews are the `bot` group (PR-10). | | `bot.profile-photo-set` | P3 | Set profile photo of an owned bot | waived until PR-10: Setting an owned bot's photo is the `bot` group (PR-10). | -| `calls.reset-top-caller` | P3 | Remove a peer from call suggestions | waived until PR-5: contacts.resetTopPeerRating is the same surface as top-callers (PR-5). | -| `calls.top-callers` | P3 | Frequently-called contacts suggestions | waived until PR-5: contacts.getTopPeers is the contact group's suggestion surface (PR-5); the phone-calls category is one flag on it. | -| `contact.birthday-accept` | P3 | Accept a suggested birthday | waived until PR-5: contact cards, notes and birthdays are the `contact` surface (PR-5). | -| `contact.birthdays` | P3 | Contacts' birthdays (gift prompts) | waived until PR-5: contact cards, notes and birthdays are the `contact` surface (PR-5). | -| `dialogs.blocked-set-bulk` | P3 | Replace the whole blocklist | waived until PR-5: Replacing the whole blocklist is `user block --from-file` (PR-5). | +| `contacts-users.people-you-may-know` | P3 | Suggested / recommended peers | waived until PR-7: Suggested peers come from channels.getChannelRecommendations, a channel surface (PR-7). | +| `contacts-users.privacy-gifts` | P3 | Privacy: who can see / send me gifts | waived until PR-12: Gift privacy is the `privacy` group (PR-12). | +| `contacts-users.privacy-no-paid-messages` | P3 | Privacy: who may message me without paying | waived until PR-12: Paid-message privacy is a privacy key (PR-12); reading the price is `user can-message`. | +| `contacts-users.privacy-voice-messages` | P3 | Privacy: who can send me voice messages | waived until PR-12: Privacy keys are the `privacy` group (PR-12). | +| `contacts-users.user-business-greeting-away` | P3 | Business greeting / away messages | waived until PR-12: Business greeting and away messages are the `business` group (PR-12). | | `dialogs.business-link-delete` | P3 | Delete a business chat link | waived until PR-12: Business chat links are a business setting (PR-12). | | `dialogs.business-link-edit` | P3 | Edit a business chat link | waived until PR-12: Business chat links are a business setting (PR-12). | -| `dialogs.business-link-resolve` | P3 | Open a business chat link (t.me/m/) | waived until PR-12: Resolving a t.me/m/ link is a business surface (PR-12). | | `dialogs.channel-autotranslation` | P3 | Channel auto-translation for all subscribers | waived until PR-7: Channel-wide auto-translation is a channel admin setting (PR-7). | | `dialogs.community-collapse` | P3 | Community: group / ungroup chats in the list | waived until PR-7: Community grouping is a channel/community surface (PR-7). | | `dialogs.community-join-requests` | P3 | Community pending peer-link requests | waived until PR-7: Community join requests are moderation (PR-7). | -| `dialogs.contact-signup-notify` | P3 | Notify when a contact joins Telegram | waived until PR-12: The contact-joined notification is a notify setting (PR-12). | | `dialogs.forum-tabs-mode` | P3 | Forum topics as tabs or list (admin) | waived until PR-7: Forum tabs are a forum admin setting (PR-7). | | `dialogs.new-chats-privacy` | P3 | Who can start a chat with me (Premium-only / paid messages) | waived until PR-12: Who may start a chat with me is a privacy key (PR-12). | | `dialogs.notify-community` | P3 | Community-level notification settings | waived until PR-12: Community notification settings are the notify surface (PR-12). | -| `dialogs.personal-channel-preview` | P3 | Personal channel preview on a profile | waived until PR-5: A profile's personal-channel card is the user group (PR-5). | | `dialogs.reactions-notify` | P3 | Reaction / poll-vote notification settings | waived until PR-12: Reaction notification settings are the notify surface (PR-12). | -| `dialogs.recent-searches` | P3 | Recent searches list | waived until PR-5: The recent-search list is search state on the contact group (PR-5). | -| `dialogs.recommended-channels` | P3 | Similar / recommended channels and bots | waived until PR-5: Similar-channel suggestions are a discovery surface (PR-5). | -| `dialogs.sponsored-search-peers` | P3 | Sponsored chats in search results | waived until PR-10: Sponsored peers in search are the ads surface (PR-10). | -| `dialogs.top-peers-toggle` | P3 | Enable / disable frequent-contact suggestions | waived until PR-5: Frequent-contact suggestions are the contact group (PR-5). | +| `dialogs.recommended-channels` | P3 | Similar / recommended channels and bots | waived until PR-7: Similar-channel suggestions are `channels.getChannelRecommendations`, a channel surface (PR-7). | | `emoji.status-channel` | P3 | Channel / group emoji status (boost-gated) | waived until PR-12: A channel emoji status is a profile setting (PR-12). | | `emoji.status-lists` | P3 | Emoji status suggestions: default, recent, collectible, themed; clear recent | waived until PR-12: Emoji status suggestions belong to `profile status` (PR-12). | | `gift.as-emoji-status` | P3 | Wear a collectible gift as your emoji status | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | diff --git a/docs/reference/README.md b/docs/reference/README.md index 27d7626..471f4cd 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -2,7 +2,7 @@ # Command reference -355 operations across 29 groups, generated from the operation registry. Groups still served by v1's hand-written commands are not listed here; they arrive with their own PR. +393 operations across 32 groups, generated from the operation registry. Groups still served by v1's hand-written commands are not listed here; they arrive with their own PR. | Group | Operations | Reference | |---|---:|---| @@ -13,6 +13,7 @@ | `chat` | 34 | [chat.md](chat.md) | | `conference` | 9 | [conference.md](conference.md) | | `config` | 13 | [config.md](config.md) | +| `contact` | 20 | [contact.md](contact.md) | | `daemon` | 14 | [daemon.md](daemon.md) | | `draft` | 3 | [draft.md](draft.md) | | `emoji` | 3 | [emoji.md](emoji.md) | @@ -29,10 +30,12 @@ | `poll` | 9 | [poll.md](poll.md) | | `proxy` | 6 | [proxy.md](proxy.md) | | `reaction` | 17 | [reaction.md](reaction.md) | +| `resolve` | 5 | [resolve.md](resolve.md) | | `search` | 3 | [search.md](search.md) | | `sticker` | 20 | [sticker.md](sticker.md) | | `sync` | 5 | [sync.md](sync.md) | | `todo` | 5 | [todo.md](todo.md) | +| `user` | 13 | [user.md](user.md) | | `vc` | 23 | [vc.md](vc.md) | | `webhook` | 3 | [webhook.md](webhook.md) | diff --git a/docs/reference/contact.md b/docs/reference/contact.md new file mode 100644 index 0000000..8d252f3 --- /dev/null +++ b/docs/reference/contact.md @@ -0,0 +1,647 @@ + + +# `tlgr contact` + +20 operations. Every one takes the global flags (`--json`, `--plain`, `-a/--account`, `--results-only`, `--select`, `--dry-run`, `--yes`, `--no-input`, `--flood-wait-max`, `-v`) anywhere on the line. + +| Command | Summary | +|---|---| +| [`contact add`](#tlgr-contact-add) | Add a contact — by user, by phone, or from a contact card in a message | +| [`contact birthday list`](#tlgr-contact-birthday-list) | Contacts whose birthday is today or within a day | +| [`contact blocked list`](#tlgr-contact-blocked-list) | The blocklist, or the separate story blocklist | +| [`contact blocked set`](#tlgr-contact-blocked-set) | Replace the whole blocklist atomically | +| [`contact close-friends list`](#tlgr-contact-close-friends-list) | List your close friends | +| [`contact close-friends set`](#tlgr-contact-close-friends-set) | Read or edit the close-friends list | +| [`contact import`](#tlgr-contact-import) | Bulk-import a phonebook from vCard or CSV | +| [`contact joined list`](#tlgr-contact-joined-list) | Contacts who joined Telegram, and the 'X joined' notification switch | +| [`contact list`](#tlgr-contact-list) | The contact list, with sorting, status, story state and export formats | +| [`contact note set`](#tlgr-contact-note-set) | Set or clear the private note attached to a contact | +| [`contact remove`](#tlgr-contact-remove) | Delete contacts, by user or by phone number | +| [`contact rename`](#tlgr-contact-rename) | Change the locally visible name of a contact | +| [`contact saved list`](#tlgr-contact-saved-list) | Every phone number this account ever uploaded, including non-Telegram ones | +| [`contact search`](#tlgr-contact-search) | Search contacts, known peers and global public usernames | +| [`contact share`](#tlgr-contact-share) | Send someone's contact card into a chat | +| [`contact share-phone`](#tlgr-contact-share-phone) | Share my phone number with someone who added me as a contact | +| [`contact status list`](#tlgr-contact-status-list) | Online / last-seen status of every contact in one call | +| [`contact sync`](#tlgr-contact-sync) | Two-way sync of a local phonebook file with the server contact list | +| [`contact top list`](#tlgr-contact-top-list) | Frequent contacts / top peers by category | +| [`contact top set`](#tlgr-contact-top-set) | Enable/disable frequent-contact collection, or reset one peer's rating | + +### `contact add` + +Add a contact — by user, by phone, or from a contact card in a message. + +An empty `imported` with an empty `retry` is ambiguous: the number has no account, or its owner hides it from phone lookups. `reason` says so rather than the reply claiming 'no such user'. `--retry` entries must be sent again later; they are not failures. + +``` +tlgr contact add [USER] [NAME] [OPTIONS] +``` + +**mutating · returns `ContactAdded`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | no | @username, id or +phone. | +| `NAME` | text | no | v1 spelling of --first-name [--last-name]. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--first-name` | text | | Mandatory for a new contact. | +| `--from-message` | text | | Take the contact card of that message. | +| `--last-name` | text | | — | +| `--note` | text | | Private annotation on the contact. | +| `--phone` | text | | Attach a phone number. | +| `--share-phone` | flag | | Grant them a phone-number privacy exception. | + +```console +$ tlgr contact add @alice --first-name Alice --json +``` + +
Catalog coverage (6 full, 0 partial) + +Full: `contact.receive-card`, `contacts-users.contact-add-by-phone`, `contacts-users.contact-add-by-user`, `contacts-users.contact-card-open`, `contacts-users.contact-phone-privacy-exception`, `dialogs.actionbar-add-contact` + +
+ +### `contact birthday list` + +Contacts whose birthday is today or within a day. + +Visible only per each contact's birthday privacy. Dismissing the chat-list bar is `chat promo list --dismiss BIRTHDAY_CONTACTS_TODAY`. + +``` +tlgr contact birthday list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[Contact]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--window` | int | `1` | Days around today to include. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr contact birthdays` + +```console +$ tlgr contact birthday list --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contact.birthdays`, `contacts-users.contacts-birthdays` + +
+ +### `contact blocked list` + +The blocklist, or the separate story blocklist. + +``` +tlgr contact blocked list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[BlockedPeer]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--stories` | flag | | The story blocklist instead (my_stories_from). | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr user blocked` + +```console +$ tlgr contact blocked list --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.block-list`, `contacts-users.block-stories-list` + +
+ +### `contact blocked set` + +Replace the whole blocklist atomically. + +`contacts.setBlocked` REPLACES the list: everyone not passed is unblocked. The reply is the diff against what was there before. + +``` +tlgr contact blocked set [PEER]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `BlockedSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PEER` | chat | one or more | The complete new list. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--from-file` | path | | Read the peer list from a file. | +| `--stories` | flag | | Operate on the story blocklist. | + +```console +$ tlgr contact blocked set @spammer --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `dialogs.blocked-set-bulk` + +
+ +### `contact close-friends list` + +List your close friends. + +No dedicated getter exists; `user.close_friend` on the contact list is it. + +``` +tlgr contact close-friends list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[Contact]`** + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr story close-friends list` + +```console +$ tlgr contact close-friends list --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `stories.close-friends-list` + +
+ +### `contact close-friends set` + +Read or edit the close-friends list. + +`contacts.editCloseFriends` replaces the list, so --add/--remove are a read-modify-write over the current contact list. + +``` +tlgr contact close-friends set [USER]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `CloseFriends`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | one or more | The complete new list. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--add` | user | | Read-modify-write add. | +| `--remove` | user | | Read-modify-write remove. | + +Also invocable as: `tlgr privacy close-friends set`, `tlgr story close-friends set` + +```console +$ tlgr contact close-friends set @alice --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.close-friends-set`, `stories.close-friends-set` + +
+ +### `contact import` + +Bulk-import a phonebook from vCard or CSV. + +Heavily flood-limited, so imports are chunked (`--batch-size`, 200 by default) and paced by the session limiter. `popular_invites` says how many other people already imported that number. + +``` +tlgr contact import [OPTIONS] +``` + +**mutating · returns `ContactImport`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `FILE` | path | yes | file.vcf | file.csv | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--batch-size` | int | `200` | Contacts per call. | + +```console +$ tlgr contact import phonebook.vcf --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `contacts-users.contacts-import-bulk` + +
+ +### `contact joined list` + +Contacts who joined Telegram, and the 'X joined' notification switch. + +Telegram has no sign-up list: each one is a `messageActionContactSignUp` service message, so this scans recent chats for them and warns when the scan was capped. + +``` +tlgr contact joined list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[SignUp]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--max-chats` | int | `200` | Cap the dialog scan. | +| `--notify` | on|off | | Turn the 'contact joined' notification on or off. | +| `--since` | datetime | | Only sign-ups after this date. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr contact joined list --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.contacts-joined-notification`, `dialogs.contact-signup-notify` + +
+ +### `contact list` + +The contact list, with sorting, status, story state and export formats. + +`contacts.getContacts` sends the whole list in one call, so sorting and the vCard/CSV rendering happen locally. `--ids-only` is the cheap drift check (`contacts.getContactIDs`); `--with-status` and `--with-stories` each cost one extra call for the whole list, never one per contact. A phone number appears only where privacy allows. + +``` +tlgr contact list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[Contact]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--close-friends-only` | flag | | Only close friends. | +| `--export` | vcard|csv|json | | Write the list out instead. | +| `--ids-only` | flag | | Cheap drift check: contacts.getContactIDs only. | +| `--mutual-only` | flag | | Only mutual contacts. | +| `--out` | path | | Destination file for --export. | +| `--sort` | name|first-name|last-name|last-seen|added | `name` | Ordering. | +| `--unregistered` | flag | | Saved numbers with no Telegram account (takeout). | +| `--with-status` | flag | | Merge contacts.getStatuses into every row. | +| `--with-stories` | flag | | Add has_unseen_stories per contact. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr contacts` + +```console +$ tlgr contact list --with-status --json +``` + +
Catalog coverage (6 full, 0 partial) + +Full: `contacts-users.close-friends-list`, `contacts-users.contacts-export-vcard`, `contacts-users.contacts-ids`, `contacts-users.contacts-list`, `contacts-users.contacts-sort`, `contacts-users.contacts-story-state` + +
+ +### `contact note set` + +Set or clear the private note attached to a contact. + +An empty `TextWithEntities` is how Telegram spells 'no note'. + +``` +tlgr contact note set [TEXT] [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `ContactNote`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Which contact. | +| `TEXT` | text | no | The note. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--clear` | flag | | Delete the note. | +| `--parse` | md|html|none | | Markup of the note. | + +```console +$ tlgr contact note set @alice "met at the conference" --json +``` + +
Catalog coverage (3 full, 0 partial) + +Full: `contact.note`, `contacts-users.contact-note-delete`, `contacts-users.contact-note-set` + +
+ +### `contact remove` + +Delete contacts, by user or by phone number. + +``` +tlgr contact remove [USER]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `ContactRemoved`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | one or more | Contacts to delete. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--phone` | text | | Delete a phonebook entry by number. | + +```console +$ tlgr contact remove @alice --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.contact-delete`, `contacts-users.contact-delete-by-phone` + +
+ +### `contact rename` + +Change the locally visible name of a contact. + +Works on non-contacts too — it saves them — which is what makes it usable for tagging users with a state marker in the last name. + +``` +tlgr contact rename [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `ContactRenamed`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Who to rename. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--first-name` | text | | — | +| `--last-name` | text | | — | + +```console +$ tlgr contact rename @alice --last-name '· lead' --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `contacts-users.contact-edit-name` + +
+ +### `contact saved list` + +Every phone number this account ever uploaded, including non-Telegram ones. + +Needs a takeout session, which this opens automatically. `has_account` is computed against the contact list, so it is null when the contact list could not be read. + +``` +tlgr contact saved list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[SavedPhoneContact]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--invite-text` | flag | | Also print the localized invite copy. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr contact saved list --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.contacts-saved-phonebook`, `contacts-users.user-invite-friends` + +
+ +### `contact search` + +Search contacts, known peers and global public usernames. + +`source` labels every row: `mine` is a contact or an already-known peer, `global` is a public username match, `recent` is tlgr's own search history and `sponsored` is an advert (off unless --with-sponsored). Local title matching over the dialog list is `chat list --search`. + +``` +tlgr contact search [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[FoundPeer]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `QUERY` | text | yes | What to look for. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--bots` | flag | | Restrict to bots. | +| `--broadcasts` | flag | | Restrict to channels. | +| `--clear-recent` | flag | | Forget the whole history. | +| `--forget` | chat | | Drop one entry and reset its rating. | +| `--global-only` | flag | | Only public username matches. | +| `--mine-only` | flag | | Only contacts and known peers. | +| `--recent` | flag | | List the recently searched peers instead. | +| `--type` | user|bot|group|channel | | Kind filter. | +| `--with-sponsored` | flag | | Also request sponsored peers (off by default). | +| `--with-tme-urls` | flag | | With --recent: include help.getRecentMeUrls. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr chat search` + +```console +$ tlgr contact search alice --json +``` + +
Catalog coverage (6 full, 0 partial) + +Full: `contacts-users.contacts-search`, `contacts-users.search-recent`, `contacts-users.search-sponsored-peers`, `dialogs.recent-searches`, `dialogs.search-peers`, `dialogs.sponsored-search-peers` + +
+ +### `contact share` + +Send someone's contact card into a chat. + +``` +tlgr contact share [OPTIONS] +``` + +**mutating · returns `ContactShared`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Whose card to send. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--to` | chat | | Destination chat. | + +```console +$ tlgr contact share @alice --to @bobby --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `contacts-users.user-share-contact-card` + +
+ +### `contact share-phone` + +Share my phone number with someone who added me as a contact. + +Irreversible: your number cannot be un-shared once they have it. + +``` +tlgr contact share-phone [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `PhoneShared`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Who to share it with. | + +```console +$ tlgr contact share-phone @alice --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.contact-accept-share-phone`, `dialogs.actionbar-share-phone` + +
+ +### `contact status list` + +Online / last-seen status of every contact in one call. + +`userStatusRecently`/`LastWeek`/`LastMonth` carry `by_me`: the coarse bucket is caused by OUR OWN last-seen privacy, not by theirs. Never report it as the peer hiding from you. + +``` +tlgr contact status list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[UserStatus]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--online-only` | flag | | Only contacts online right now. | +| `--since` | datetime | | Only statuses newer than this. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr contact statuses` + +```console +$ tlgr contact status list --online-only --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.contacts-statuses`, `dialogs.presence-watch` + +
+ +### `contact sync` + +Two-way sync of a local phonebook file with the server contact list. + +Prints the diff and changes nothing unless `--apply` is given, because `contacts.deleteByPhones` is irreversible server-side. + +``` +tlgr contact sync [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `ContactSync`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `FILE` | path | yes | The phonebook to sync from. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--apply` | flag | | Actually apply the diff (the default is to print it). | +| `--delete-missing` | flag | | Delete server contacts absent from the file. | + +```console +$ tlgr contact sync phonebook.vcf --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.contacts-sync`, `privacy.sync-contacts-delete` + +
+ +### `contact top list` + +Frequent contacts / top peers by category. + +Ratings decay with the server's `rating_e_decay`. A disabled feature answers exit 13, not an empty list: nothing was measured. + +``` +tlgr contact top list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[TopPeer]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--category` | text | | Rating category; repeatable. correspondents, bots-pm, bots-inline, bots-app, bots-guestchat, calls, forward-users, forward-chats, groups, channels | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr contact top list --category correspondents --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `calls.top-callers`, `contacts-users.top-peers-get` + +
+ +### `contact top set` + +Enable/disable frequent-contact collection, or reset one peer's rating. + +Turning it off also wipes the ratings server-side, which is why it needs --yes. + +``` +tlgr contact top set [STATE] [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `TopPeerState`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `STATE` | text | no | on | off | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--category` | text | `correspondents` | Category for --reset. | +| `--reset` | chat | | Zero this peer's rating instead. | + +```console +$ tlgr contact top set on --json +``` + +
Catalog coverage (4 full, 0 partial) + +Full: `calls.reset-top-caller`, `contacts-users.top-peers-reset`, `contacts-users.top-peers-toggle`, `dialogs.top-peers-toggle` + +
diff --git a/docs/reference/resolve.md b/docs/reference/resolve.md new file mode 100644 index 0000000..be0c8e9 --- /dev/null +++ b/docs/reference/resolve.md @@ -0,0 +1,174 @@ + + +# `tlgr resolve` + +5 operations. Every one takes the global flags (`--json`, `--plain`, `-a/--account`, `--results-only`, `--select`, `--dry-run`, `--yes`, `--no-input`, `--flood-wait-max`, `-v`) anywhere on the line. + +| Command | Summary | +|---|---| +| [`resolve cache get`](#tlgr-resolve-cache-get) | Inspect, refresh or purge the local peer database | +| [`resolve link`](#tlgr-resolve-link) | Normalise any t.me / tg:// link into a typed JSON object | +| [`resolve peer`](#tlgr-resolve-peer) | Resolve any peer reference to a normalised peer object | +| [`resolve phone`](#tlgr-resolve-phone) | Resolve a phone number to a user without adding a contact | +| [`resolve username`](#tlgr-resolve-username) | Resolve a public @username to a peer | + +### `resolve cache get` + +Inspect, refresh or purge the local peer database. + +Access-hash priority is full > min > from-message > none. Telethon skips `min` entities in both of its caches, so tlgr records the (peer, msg_id) context itself — that is what makes a `chat posters` follow-up possible. Hashes are per login session: never printed, never copied between accounts. `--purge` drops cache rows only; the session and its auth key are untouched. + +``` +tlgr resolve cache get [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[CachedPeerRow]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--purge` | flag | | Drop cached entries (never the session auth key). | +| `--refresh` | chat | | Re-fetch these peers. | +| `--stale` | duration | | Only entries older than this. | +| `--type` | user|bot|group|channel | | Only entries of this kind. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr resolve cache get --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `contacts-users.peer-cache` + +
+ +### `resolve link` + +Normalise any t.me / tg:// link into a typed JSON object. + +One dispatcher and one discriminated union. `t.me/+X` is a PHONE when X parses as a number and an invite hash otherwise. `t.me/c//` carries a bare channel id, so the access hash must come from this account's peer cache — it fails loudly rather than guessing. Resolution NEVER acts: `delegated_to` names the command that would. + +``` +tlgr resolve link [OPTIONS] +``` + +**returns `ResolvedLink`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `URL` | text | yes | Any t.me / tg:// link, or a bare slug. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--draft` | chat | | Save the carried text as a draft here. | +| `--no-network` | flag | | Classify from the URL only; resolve nothing. | +| `--open` | flag | | Perform the follow-up read for the classified kind. | + +```console +$ tlgr resolve link https://t.me/alice/4210 --json +``` + +
Catalog coverage (17 full, 0 partial) + +Full: `contact.share-token`, `contacts-users.contacts-deeplink-sections`, `contacts-users.resolve-account-maintenance-links`, `contacts-users.resolve-boost-link`, `contacts-users.resolve-bot-start-link`, `contacts-users.resolve-business-chat-link`, `contacts-users.resolve-deeplink`, `contacts-users.resolve-gift-link`, `contacts-users.resolve-invite-link`, `contacts-users.resolve-message-link`, `contacts-users.resolve-proxy-link`, `contacts-users.resolve-share-url-link`, `contacts-users.resolve-stickerset-link`, `contacts-users.resolve-story-link`, `contacts-users.resolve-theme-wallpaper-link`, `contacts-users.resolve-unknown-deeplink`, `dialogs.business-link-resolve` + +
+ +### `resolve peer` + +Resolve any peer reference to a normalised peer object. + +There is NO method that turns a bare id into an access hash, so an uncached numeric id fails (exit 5 or 13) instead of guessing — that is the trap `user dialog-status` was built around. Access hashes are per account and never printed. + +``` +tlgr resolve peer [REF]... [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[ResolvedRef]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `REF` | text | one or more | @username, id, marked id, +phone, link, me. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--cache-only` | flag | | Never hit the network. | +| `--from-chat` | chat | | Context chat for a `min` peer. | +| `--from-message` | msg-id | | Message id in --from-chat. | +| `--ids` | mtproto|botapi | | Also emit the id in the other id space. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr resolve peer @alice --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.peer-id-conversion`, `dialogs.resolve-peer` + +
+ +### `resolve phone` + +Resolve a phone number to a user without adding a contact. + +PHONE_NOT_OCCUPIED exits 13, never 5: no account and a privacy refusal are indistinguishable from here. The server asks for at most one lookup every three seconds, so --offline formats and validates against help.getCountriesList without an RPC. + +``` +tlgr resolve phone [OPTIONS] +``` + +**returns `ResolvedPhone`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PHONE` | text | yes | +countrycode number. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--countries` | flag | | Dump the country/prefix/format table. | +| `--lang` | text | | Language for the table. | +| `--offline` | flag | | Format and validate only; perform no RPC. | + +```console +$ tlgr resolve phone +15550001111 --offline --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.phone-number-info`, `contacts-users.resolve-phone` + +
+ +### `resolve username` + +Resolve a public @username to a peer. + +USERNAME_INVALID exits 2 and USERNAME_NOT_OCCUPIED exits 5: a typo and a free username are different answers. Resolution always hits the network and floods at roughly fifty lookups in a short period, so the access hash is cached for a day afterwards. + +``` +tlgr resolve username [OPTIONS] +``` + +**returns `ResolvedUsername`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USERNAME` | text | yes | With or without the @. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--referer` | text | | Attribute the resolution to a referrer. | +| `--type` | user|bot|group|channel | | Fail unless the result is of this kind. | + +```console +$ tlgr resolve username @alice --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `contacts-users.search-public-chat` + +
diff --git a/docs/reference/user.md b/docs/reference/user.md new file mode 100644 index 0000000..0959497 --- /dev/null +++ b/docs/reference/user.md @@ -0,0 +1,435 @@ + + +# `tlgr user` + +13 operations. Every one takes the global flags (`--json`, `--plain`, `-a/--account`, `--results-only`, `--select`, `--dry-run`, `--yes`, `--no-input`, `--flood-wait-max`, `-v`) anywhere on the line. + +| Command | Summary | +|---|---| +| [`user birthday set`](#tlgr-user-birthday-set) | Suggest a birthday to a contact | +| [`user block`](#tlgr-user-block) | Block a user, bot or channel — optionally stories-only, with report and cleanup | +| [`user can-message`](#tlgr-user-can-message) | Can I message this user, and at what price? | +| [`user chat list`](#tlgr-user-chat-list) | Groups and channels you share with a user | +| [`user dialog-status`](#tlgr-user-dialog-status) | Does this account have prior history with this user? (three-valued, never guessed) | +| [`user get`](#tlgr-user-get) | Full profile of a user | +| [`user hide-stories`](#tlgr-user-hide-stories) | Hide or unhide a peer's stories (per-account; the other side is never notified) | +| [`user link`](#tlgr-user-link) | Build a link to a user (t.me / tg://), or my own temporary profile link | +| [`user music list`](#tlgr-user-music-list) | Music a user pinned to their profile | +| [`user personal-channel get`](#tlgr-user-personal-channel-get) | The channel a user pinned to their profile, with its latest posts | +| [`user photo list`](#tlgr-user-photo-list) | A user's profile-photo history | +| [`user photo set`](#tlgr-user-photo-set) | Set, suggest or reset the personal photo you see for a contact | +| [`user unblock`](#tlgr-user-unblock) | Unblock a user, bot or channel | + +### `user birthday set` + +Suggest a birthday to a contact. + +``` +tlgr user birthday set [OPTIONS] +``` + +**mutating · returns `SuggestedBirthday`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Who to suggest it to. | +| `DATE` | text | yes | YYYY-MM-DD or MM-DD. | + +Also invocable as: `tlgr user suggest-birthday` + +```console +$ tlgr user birthday set @alice 1990-04-01 --json +``` + +
Catalog coverage (4 full, 0 partial) + +Full: `contact.birthday-accept`, `contact.suggest-birthday`, `contacts-users.user-suggest-birthday`, `profile.birthday-suggest` + +
+ +### `user block` + +Block a user, bot or channel — optionally stories-only, with report and cleanup. + +The main blocklist stops messages, calls, status, photo and stories. `--stories` is the independent story blocklist and stops only stories. `--delete-history` revokes for both sides, which is why the whole command is destructive. + +``` +tlgr user block [USER] [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `BlockResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | chat | no | User, bot or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--delete-history` | flag | | Also delete the chat for BOTH sides. | +| `--delete-message` | flag | | With --from-replies: also delete that message. | +| `--from-replies` | int | | Block the author of this Replies message. | +| `--report-spam` | flag | | Report spam first. | +| `--stories` | flag | | Story blocklist only: they keep messaging you. | + +Also invocable as: `tlgr chat block`, `tlgr contact blocked add` + +```console +$ tlgr user block @spammer --json +``` + +
Catalog coverage (5 full, 0 partial) + +Full: `contacts-users.block-delete-and-block`, `contacts-users.block-from-replies`, `dialogs.block-stories`, `dialogs.block-user`, `dialogs.bot-stop-restart` + +
+ +### `user can-message` + +Can I message this user, and at what price?. + +`free` | `premium` | `paid` (with `stars_amount`). The send-time failure this predicts is PRIVACY_PREMIUM_REQUIRED (403). + +``` +tlgr user can-message [USER]... [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[ContactRequirement]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | one or more | Who to check. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr user can-message @alice --json +``` + +
Catalog coverage (3 full, 0 partial) + +Full: `contacts-users.user-paid-messages`, `contacts-users.user-requirements-to-contact`, `privacy.requirements-to-contact` + +
+ +### `user chat list` + +Groups and channels you share with a user. + +`--leave-all` leaves every listed chat immediately — run it under --dry-run first, which prints what would go. `userFull.common_chats_count` is the count; this is the list. + +``` +tlgr user chat list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[Chat]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Whose common chats. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--leave-all` | flag | | Leave every listed chat. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr user common-chats` + +```console +$ tlgr user chat list @alice --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `contacts-users.user-leave-common-groups` + +
+ +### `user dialog-status` + +Does this account have prior history with this user? (three-valued, never guessed). + +resolved=true/has_dialog=true — a dialog exists, message_count is the server's exact total. resolved=true/has_dialog=false — definitively none, because the COMPLETE dialog list was enumerated. resolved=false/has_dialog=null — exit 13, and `reason` says why. Exit 13 means UNKNOWN: a caller gating a cold first message must treat it as a refusal, never as a green light. + +``` +tlgr user dialog-status [OPTIONS] +``` + +**returns `DialogStatus`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Who to ask about. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--max-dialogs` | int | `5000` | Cap the fallback dialog scan. Hitting it is indeterminate, never 'no'. | + +```console +$ tlgr user dialog-status @alice --json +``` + +
Catalog coverage (2 full, 1 partial) + +Full: `contacts-users.user-dialog-exists`, `dialogs.dialog-exists` + +Partial: `dialogs.resolve-peer` + +The reference-resolution half of `dialogs.resolve-peer` is `resolve peer`; this op only answers the has-a-dialog question about a user. + +
+ +### `user get` + +Full profile of a user. + +Never prints an access hash: `access_hash_cached` says whether one is held. A bare numeric id resolves only from this account's peer cache; for a `min` user pass --from-chat/--from-message so `inputUserFromMessage` can be built. `userFull` is invalidated server-side after 60 s and whenever our own last-seen privacy changes. No photo plus an empty status is a signal, not a verdict: this never claims 'they blocked you'. To pull out one field, use the global `--select bio --results-only` rather than a per-command projection flag. + +``` +tlgr user get [OPTIONS] +``` + +**returns `UserProfile`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | @username, id or +phone. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--from-chat` | chat | | Context for a `min` user. | +| `--from-message` | msg-id | | Message id in --from-chat. | +| `--full/--no-full` | flag | `True` | Add users.getFullUser (bio, note, birthday, business, blocked). | +| `--translate-bio` | text | | Translate the bio. | + +```console +$ tlgr user get @alice --json +``` + +
Catalog coverage (23 full, 0 partial) + +Full: `contacts-users.block-status`, `contacts-users.resolve-min-users`, `contacts-users.resolve-user-id`, `contacts-users.user-badges`, `contacts-users.user-bio`, `contacts-users.user-bio-translate`, `contacts-users.user-birthday-read`, `contacts-users.user-business-hours`, `contacts-users.user-business-intro`, `contacts-users.user-business-location`, `contacts-users.user-copy-fields`, `contacts-users.user-emoji-status`, `contacts-users.user-gifts-count`, `contacts-users.user-main-profile-tab`, `contacts-users.user-peer-colors`, `contacts-users.user-phone`, `contacts-users.user-profile-basic`, `contacts-users.user-profile-full`, `contacts-users.user-stars-rating`, `contacts-users.user-status`, `contacts-users.user-unofficial-warning`, `contacts-users.user-usernames`, `profile.security-risk-flag` + +
+ +### `user hide-stories` + +Hide or unhide a peer's stories (per-account; the other side is never notified). + +Idempotent: the fresh flag is read first and `already: true` means no RPC was sent, so repeating a bulk pass is nearly free. Purely local to this account — the chat, the contact entry and their access to you are untouched. `user get` reports the current value as `stories_hidden`. More than one peer fills `peers`; a single peer answers exactly as v1 did. + +``` +tlgr user hide-stories [USER]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `StoriesHidden`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | one or more | Peers to hide. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | text | | Collapse or expand the whole story strip. | +| `--unhide` | flag | | Put them back in the main stories bar. | + +```console +$ tlgr user hide-stories @alice --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.user-hide-stories`, `dialogs.hide-stories-peer` + +
+ +### `user link` + +Build a link to a user (t.me / tg://), or my own temporary profile link. + +Mostly local string building. `--token` is the exception: `contacts.exportContactToken` mints a t.me/contact/ link that works without a username and EXPIRES, so `expires` is always reported next to it. + +``` +tlgr user link [OPTIONS] +``` + +**returns `UserLink`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Use `me` with --token. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--profile` | flag | | Add ?profile so clients open the profile, not the chat. | +| `--scheme` | tme|tg | `tme` | Link flavour. | +| `--text` | text | | Pre-fill a draft (?text=). | +| `--token` | flag | | For `me`: a t.me/contact/ link with no username. | + +```console +$ tlgr user link @alice --profile --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.contact-token-export`, `contacts-users.user-link-build` + +
+ +### `user music list` + +Music a user pinned to their profile. + +An empty list is not evidence: `inputPrivacyKeySavedMusic` may simply not include us. Managing your own is `profile music`. + +``` +tlgr user music list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[MusicTrack]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Whose pinned music. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--download` | path | | Download into this directory. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr user music list @alice --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `contacts-users.user-saved-music` + +
+ +### `user personal-channel get` + +The channel a user pinned to their profile, with its latest posts. + +Setting your OWN personal channel is `profile update --personal-channel`. + +``` +tlgr user personal-channel get [OPTIONS] +``` + +**returns `PersonalChannel`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Whose profile. | + +```console +$ tlgr user personal-channel get @alice --json +``` + +
Catalog coverage (3 full, 0 partial) + +Full: `contacts-users.user-personal-channel`, `dialogs.personal-channel-preview`, `groups-channels-admin.personal-channel` + +
+ +### `user photo list` + +A user's profile-photo history. + +``` +tlgr user photo list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[ProfilePhoto]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Whose photos. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--big` | flag | | Prefer the largest size when downloading. | +| `--download` | path | | Download into this directory. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr user photos` + +```console +$ tlgr user photo list @alice --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `contacts-users.user-profile-photos` + +
+ +### `user photo set` + +Set, suggest or reset the personal photo you see for a contact. + +`--suggest` posts a visible message to them; without it the photo is a private override only this account sees. + +``` +tlgr user photo set [FILE] [OPTIONS] +``` + +**mutating · returns `PhotoResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Whose card to change. | +| `FILE` | path | no | Image or video. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--reset` | flag | | Remove the personal photo. | +| `--suggest` | flag | | Send it as a suggestion instead of applying it locally. | +| `--video` | flag | | Upload as a video avatar. | + +Also invocable as: `tlgr user set-photo` + +```console +$ tlgr user photo set @alice avatar.jpg --json +``` + +
Catalog coverage (4 full, 0 partial) + +Full: `contacts-users.user-personal-photo-reset`, `contacts-users.user-suggest-photo`, `profile.photo-personal-for-contact`, `profile.photo-suggest-to-user` + +
+ +### `user unblock` + +Unblock a user, bot or channel. + +`already: true` means the peer was not on the list and no RPC changed anything. + +``` +tlgr user unblock [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `BlockResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | chat | yes | User, bot or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--stories` | flag | | Remove from the story blocklist instead. | + +Also invocable as: `tlgr chat unblock`, `tlgr contact blocked remove` + +```console +$ tlgr user unblock @alice --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `contacts-users.block-unblock`, `dialogs.unblock-user` + +
diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 1dae392..8a5f87f 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -77,22 +77,6 @@ DH_G = 2 -class _FullUser: - """`users.getFullUser`, reduced to the three call-availability flags.""" - - def __init__(self, available: bool, users: list[Any]) -> None: - self.full_user = _FullUserInner(available) - self.users = users - self.chats: list[Any] = [] - - -class _FullUserInner: - def __init__(self, available: bool) -> None: - self.phone_calls_available = available - self.video_calls_available = available - self.phone_calls_private = not available - - def _json_object(mapping: dict[str, Any]) -> Any: return types.JsonObject( value=[types.JsonObjectValue(key=key, value=_json_value(v)) for key, v in mapping.items()] @@ -121,6 +105,20 @@ def make_user(user_id: int, *, username: str | None = None, first: str = "Test") ) +def _e164(phone: str) -> str: + digits = "".join(c for c in str(phone or "") if c.isdigit()) + return f"+{digits}" if digits else "" + + +def _peer_for(marked: int) -> Any: + """`types.Peer*` for a marked id, which is what a contacts reply carries.""" + if marked < -1000000000000: + return types.PeerChannel(channel_id=-1000000000000 - marked) + if marked < 0: + return types.PeerChat(chat_id=-marked) + return types.PeerUser(user_id=marked) + + def make_channel( channel_id: int, *, title: str = "Channel", megagroup: bool = False ) -> types.Channel: @@ -496,6 +494,54 @@ class World: #: The `peerSettings` action bar, per chat. peer_settings: dict[int, Any] = field(default_factory=dict) global_privacy: Any = None + + # -- the contact world ------------------------------------------------- + # + # Stage E. The address book is a *world* too: adding a contact really + # flips `user.contact`, blocking really puts the peer on the list + # `contacts.getBlocked` answers with, and hiding stories really sets + # `stories_hidden` — so the idempotent second pass can be asserted + # against state that moved rather than against a canned reply. + + #: user id → mutual. Membership of the server-side contact list. + contacts: dict[int, bool] = field(default_factory=dict) + #: user id → the date it was blocked, per list. + blocked: dict[int, datetime] = field(default_factory=dict) + blocked_stories: dict[int, datetime] = field(default_factory=dict) + #: user id → the private note attached to the contact. + contact_notes: dict[int, str] = field(default_factory=dict) + #: user id → `types.Birthday`, as birthday privacy lets us see it. + birthdays: dict[int, Any] = field(default_factory=dict) + #: Phone numbers this account ever uploaded (`contacts.getSaved`). + saved_contacts: list[Any] = field(default_factory=list) + #: phone (E.164) → the user id it imports to. A missing number is the + #: ambiguous case: no account, or a privacy refusal. + phonebook: dict[str, int] = field(default_factory=dict) + #: category name → [(user id, rating)]. + top_peers: dict[str, list[tuple[int, float]]] = field(default_factory=dict) + top_peers_enabled: bool = True + #: `users.getRequirementsToContact`: user id → free | premium | paid:N. + contact_requirements: dict[int, str] = field(default_factory=dict) + #: user id → profile photo history, newest first. + user_photos: dict[int, list[Any]] = field(default_factory=dict) + #: user id → the documents pinned to their profile. + saved_music: dict[int, list[Any]] = field(default_factory=dict) + #: user id → `userFull` overrides, merged over the defaults. + user_full: dict[int, dict[str, Any]] = field(default_factory=dict) + #: The "X joined Telegram" notification switch (silent=True means off). + contact_signup_silent: bool = False + #: Peers `contacts.search` answers with, split the way the server does. + search_mine: list[int] = field(default_factory=list) + search_global: list[int] = field(default_factory=list) + sponsored_peers: list[int] = field(default_factory=list) + #: The story read marks `stories.getAllReadPeerStories` reports. + stories_read: dict[int, int] = field(default_factory=dict) + #: `contacts.exportContactToken`. + contact_token: str = "AbCdEfToken" + #: `help.getDeepLinkInfo` for an unknown tg:// path. + deep_link_message: str = "Update your app to open this link." + all_stories_hidden: bool = False + #: request type name → callable(request) -> result, or a plain value. raw: dict[str, Any] = field(default_factory=dict) calls: list[tuple[str, Any]] = field(default_factory=list) @@ -795,6 +841,34 @@ def notify_of(self, chat_id: int) -> types.PeerNotifySettings: row = self.dialog(chat_id) return types.PeerNotifySettings(mute_until=row.mute_until, silent=row.silent) + # -- the contact world ------------------------------------------------- + + def add_contact(self, user: types.User, *, mutual: bool = False, **flags: Any) -> types.User: + """Put a user in the address book, with the flags a contact carries.""" + self.add_user(user) + user.contact = True + user.mutual_contact = mutual + for name, value in flags.items(): + setattr(user, name, value) + self.contacts[int(user.id)] = mutual + if getattr(user, "phone", None): + self.phonebook.setdefault(_e164(user.phone), int(user.id)) + return user + + def block(self, user_id: int, *, stories: bool = False) -> None: + target = self.blocked_stories if stories else self.blocked + target[int(user_id)] = datetime.now(timezone.utc) + + def add_saved_contact(self, phone: str, first: str = "", last: str = "") -> Any: + entry = types.SavedPhoneContact( + phone=_e164(phone), + first_name=first, + last_name=last, + date=datetime.now(timezone.utc), + ) + self.saved_contacts.append(entry) + return entry + def add_folder(self, folder_id: int, title: str, **kwargs: Any) -> Any: """A `dialogFilter` in the world, addressed by id or by title.""" folder = types.DialogFilter( @@ -1340,8 +1414,33 @@ def _raw_SendSignalingDataRequest(self, request: Any) -> bool: return True def _raw_GetFullUserRequest(self, request: Any) -> Any: + user = self._user_of(request.id) + if user is None: + from telethon.errors import RPCError + + raise RPCError(request, "USER_ID_INVALID", 400) + uid = int(user.id) + overrides = dict(self.world.user_full.get(uid, {})) + note = self.world.contact_notes.get(uid) available = self.world.calls_available - return _FullUser(available, list(self.world.users.values())[:1]) + full = types.UserFull( + id=uid, + settings=self.world.peer_settings.get(uid) or types.PeerSettings(), + notify_settings=types.PeerNotifySettings(), + common_chats_count=overrides.pop("common_chats_count", 0), + about=overrides.pop("about", None), + blocked=uid in self.world.blocked or None, + blocked_my_stories_from=uid in self.world.blocked_stories or None, + birthday=self.world.birthdays.get(uid), + note=types.TextWithEntities(text=note, entities=[]) if note else None, + phone_calls_available=available, + video_calls_available=available, + phone_calls_private=not available, + **overrides, + ) + return types.users.UserFull( + full_user=full, chats=list(self.world.chats.values()), users=[user] + ) # -- group calls ------------------------------------------------------- @@ -2091,6 +2190,11 @@ def _raw_SearchSentMediaRequest(self, request: Any) -> Any: return self._slice(found[: int(request.limit)]) def _raw_SearchRequest(self, request: Any) -> Any: + # `messages.search` and `contacts.search` share a class name, and the + # fake dispatches on that name alone. The peer is what tells them + # apart: a contact search has none. + if getattr(request, "peer", None) is None: + return self._contacts_search(request) if type(getattr(request, "filter", None)).__name__ == "InputMessagesFilterPhoneCalls": return self._call_log_search(request) chat_id = self._chat_id(request.peer) @@ -2536,6 +2640,8 @@ def _raw_ResetWebAuthorizationsRequest(self, request: Any) -> bool: return True def _raw_BlockRequest(self, request: Any) -> bool: + marked = self._chat_id(request.id) + self._blocked_target(request)[abs(marked)] = datetime.now(timezone.utc) return True # passkeys ------------------------------------------------------------- @@ -2899,6 +3005,13 @@ def _raw_GetCountriesListRequest(self, request: Any) -> Any: default_name="Spain", country_codes=[types.help.CountryCode(country_code="34")], ), + types.help.Country( + iso2="US", + default_name="United States", + country_codes=[ + types.help.CountryCode(country_code="1", patterns=["XXX XXX XXXX"]) + ], + ), ], hash=0, ) @@ -2928,7 +3041,439 @@ async def _raw_InvokeWithTakeoutRequest(self, request: Any) -> Any: self.world.takeout_calls.append(type(request.query).__name__) return await self(request.query) - # -- entities ---------------------------------------------------------- + # -- the contact world ------------------------------------------------- + # + # Every one of these moves the world: `contacts.addContact` really flips + # `user.contact`, `contacts.block` really lands the peer on the list + # `getBlocked` answers with, and `stories.togglePeerStoriesHidden` really + # sets the flag `user get` reads back. That is what makes the idempotent + # second pass — `already: true`, no RPC — assertable at all. + + def _user_of(self, ref: Any) -> Any: + for attribute in ("user_id", "id"): + value = getattr(ref, attribute, None) + if isinstance(value, int): + found = self.world.users.get(value) + if found is not None: + return found + if type(ref).__name__ in ("InputUserSelf", "InputPeerSelf"): + return self.world.me + return None + + def _contact_users(self) -> list[types.User]: + return [self.world.users[uid] for uid in self.world.contacts if uid in self.world.users] + + def _raw_GetContactsRequest(self, request: Any) -> Any: + return types.contacts.Contacts( + contacts=[ + types.Contact(user_id=uid, mutual=mutual) + for uid, mutual in self.world.contacts.items() + ], + saved_count=len(self.world.saved_contacts) or len(self.world.contacts), + users=self._contact_users(), + ) + + def _raw_GetContactIDsRequest(self, request: Any) -> list[int]: + return sorted(self.world.contacts) + + def _raw_GetStatusesRequest(self, request: Any) -> list[Any]: + return [ + types.ContactStatus(user_id=uid, status=user.status) + for uid, user in self.world.users.items() + if uid in self.world.contacts and getattr(user, "status", None) is not None + ] + + def _raw_AddContactRequest(self, request: Any) -> types.Updates: + user = self._user_of(request.id) + if user is None: + from telethon.errors import RPCError + + raise RPCError(request, "CONTACT_ID_INVALID", 400) + if not request.first_name: + from telethon.errors import RPCError + + raise RPCError(request, "CONTACT_NAME_EMPTY", 400) + user.first_name = request.first_name + user.last_name = request.last_name + user.contact = True + self.world.contacts.setdefault(int(user.id), False) + note = getattr(request, "note", None) + if note is not None: + self.world.contact_notes[int(user.id)] = getattr(note, "text", "") or "" + return self._updates() + + def _raw_ImportContactsRequest(self, request: Any) -> Any: + imported, retry, popular = [], [], [] + for item in request.contacts: + phone = _e164(item.phone) + user_id = self.world.phonebook.get(phone) + if user_id is None: + # Neither imported nor retried: the ambiguous answer. + continue + if user_id < 0: # a test asking for the retry branch + retry.append(int(item.client_id)) + continue + imported.append(types.ImportedContact(user_id=user_id, client_id=int(item.client_id))) + self.world.contacts.setdefault(user_id, False) + user = self.world.users.get(user_id) + if user is not None: + user.contact = True + popular.append(types.PopularContact(client_id=int(item.client_id), importers=3)) + return types.contacts.ImportedContacts( + imported=imported, + popular_invites=popular, + retry_contacts=retry, + users=[self.world.users[i.user_id] for i in imported if i.user_id in self.world.users], + ) + + def _raw_DeleteContactsRequest(self, request: Any) -> types.Updates: + removed = [] + for ref in request.id: + user = self._user_of(ref) + if user is None: + continue + self.world.contacts.pop(int(user.id), None) + user.contact = False + removed.append(user) + return types.Updates( + updates=[], users=removed, chats=[], date=datetime.now(timezone.utc), seq=0 + ) + + def _raw_DeleteByPhonesRequest(self, request: Any) -> bool: + for phone in request.phones: + self.world.saved_contacts = [ + entry for entry in self.world.saved_contacts if entry.phone != _e164(phone) + ] + user_id = self.world.phonebook.pop(_e164(phone), None) + if user_id is not None: + self.world.contacts.pop(user_id, None) + return True + + def _raw_UpdateContactNoteRequest(self, request: Any) -> types.Updates: + user = self._user_of(request.id) + if user is None or int(user.id) not in self.world.contacts: + from telethon.errors import RPCError + + raise RPCError(request, "CONTACT_MISSING", 400) + text = getattr(request.note, "text", "") or "" + if text: + self.world.contact_notes[int(user.id)] = text + else: + self.world.contact_notes.pop(int(user.id), None) + return self._updates() + + def _raw_AcceptContactRequest(self, request: Any) -> types.Updates: + return self._updates() + + def _contacts_search(self, request: Any) -> Any: + query = (request.q or "").lower() + + def matches(uid: int) -> bool: + entity = self.world.entity_for(uid) or self.world.users.get(uid) + if entity is None: + return False + haystack = " ".join( + str(getattr(entity, key, "") or "") + for key in ("first_name", "last_name", "title", "username") + ).lower() + return query in haystack + + mine = [uid for uid in self.world.search_mine if matches(uid)] + found = [uid for uid in self.world.search_global if matches(uid)] + entities = {uid: self.world.entity_for(uid) for uid in mine + found} + return types.contacts.Found( + my_results=[_peer_for(uid) for uid in mine], + results=[_peer_for(uid) for uid in found], + chats=[e for e in entities.values() if e is not None and not isinstance(e, types.User)], + users=[e for e in entities.values() if isinstance(e, types.User)], + ) + + def _raw_GetSponsoredPeersRequest(self, request: Any) -> Any: + if not self.world.sponsored_peers: + return types.contacts.SponsoredPeersEmpty() + return types.contacts.SponsoredPeers( + peers=[ + types.SponsoredPeer(peer=_peer_for(uid), random_id=b"\x01\x02") + for uid in self.world.sponsored_peers + ], + chats=[], + users=[ + self.world.users[uid] + for uid in self.world.sponsored_peers + if uid in self.world.users + ], + ) + + def _raw_GetRecentMeUrlsRequest(self, request: Any) -> Any: + return types.help.RecentMeUrls(urls=[], chats=[], users=[]) + + # -- blocking ---------------------------------------------------------- + + def _raw_GetBlockedRequest(self, request: Any) -> Any: + source = ( + self.world.blocked_stories + if getattr(request, "my_stories_from", None) + else self.world.blocked + ) + rows = sorted(source.items()) + window = rows[request.offset : request.offset + request.limit] + return types.contacts.BlockedSlice( + count=len(rows), + blocked=[types.PeerBlocked(peer_id=_peer_for(uid), date=date) for uid, date in window], + chats=[], + users=[self.world.users[uid] for uid, _ in window if uid in self.world.users], + ) + + def _blocked_target(self, request: Any) -> dict[int, Any]: + return ( + self.world.blocked_stories + if getattr(request, "my_stories_from", None) + else self.world.blocked + ) + + def _raw_UnblockRequest(self, request: Any) -> bool: + marked = abs(self._chat_id(request.id)) + return self._blocked_target(request).pop(marked, None) is not None + + def _raw_SetBlockedRequest(self, request: Any) -> bool: + target = self._blocked_target(request) + target.clear() + for peer in request.id: + target[abs(self._chat_id(peer))] = datetime.now(timezone.utc) + return True + + def _raw_BlockFromRepliesRequest(self, request: Any) -> types.Updates: + return self._updates() + + # -- close friends, birthdays, top peers ------------------------------- + + def _raw_EditCloseFriendsRequest(self, request: Any) -> bool: + wanted = {int(i) for i in request.id} + for uid, user in self.world.users.items(): + user.close_friend = uid in wanted + return True + + def _raw_GetBirthdaysRequest(self, request: Any) -> Any: + return types.contacts.ContactBirthdays( + contacts=[ + types.ContactBirthday(contact_id=uid, birthday=birthday) + for uid, birthday in self.world.birthdays.items() + ], + users=[ + self.world.users[uid] for uid in self.world.birthdays if uid in self.world.users + ], + ) + + def _raw_SuggestBirthdayRequest(self, request: Any) -> types.Updates: + return self._updates() + + def _raw_GetTopPeersRequest(self, request: Any) -> Any: + if not self.world.top_peers_enabled: + return types.contacts.TopPeersDisabled() + wanted = { + "correspondents": "TopPeerCategoryCorrespondents", + "bots_pm": "TopPeerCategoryBotsPM", + "phone_calls": "TopPeerCategoryPhoneCalls", + "groups": "TopPeerCategoryGroups", + "channels": "TopPeerCategoryChannels", + } + categories = [] + users: list[Any] = [] + for flag, constructor in wanted.items(): + if not getattr(request, flag, None): + continue + rows = self.world.top_peers.get(flag.replace("_", "-"), []) + categories.append( + types.TopPeerCategoryPeers( + category=getattr(types, constructor)(), + count=len(rows), + peers=[ + types.TopPeer(peer=_peer_for(uid), rating=rating) for uid, rating in rows + ], + ) + ) + users += [self.world.users[uid] for uid, _ in rows if uid in self.world.users] + return types.contacts.TopPeers(categories=categories, chats=[], users=users) + + def _raw_ToggleTopPeersRequest(self, request: Any) -> bool: + self.world.top_peers_enabled = bool(request.enabled) + if not request.enabled: + self.world.top_peers.clear() + return True + + def _raw_ResetTopPeerRatingRequest(self, request: Any) -> bool: + return True + + # -- users ------------------------------------------------------------- + + def _raw_GetUsersRequest(self, request: Any) -> list[Any]: + out = [] + for ref in request.id: + user = self._user_of(ref) + out.append(user if user is not None else types.UserEmpty(id=0)) + return out + + def _raw_GetRequirementsToContactRequest(self, request: Any) -> list[Any]: + out = [] + for ref in request.id: + user = self._user_of(ref) + rule = self.world.contact_requirements.get(int(getattr(user, "id", 0) or 0), "free") + if rule == "premium": + out.append(types.RequirementToContactPremium()) + elif rule.startswith("paid:"): + out.append(types.RequirementToContactPaidMessages(stars_amount=int(rule[5:]))) + else: + out.append(types.RequirementToContactEmpty()) + return out + + def _raw_ExportContactTokenRequest(self, request: Any) -> Any: + return types.ExportedContactToken( + url=f"https://t.me/contact/{self.world.contact_token}", + expires=datetime.now(timezone.utc), + ) + + def _raw_ImportContactTokenRequest(self, request: Any) -> Any: + return next(iter(self.world.users.values()), self.world.me) + + def _raw_GetUserPhotosRequest(self, request: Any) -> Any: + user = self._user_of(request.user_id) + photos = self.world.user_photos.get(int(getattr(user, "id", 0) or 0), []) + window = photos[request.offset : request.offset + request.limit] + return types.photos.PhotosSlice(count=len(photos), photos=window, users=[]) + + def _raw_UploadContactProfilePhotoRequest(self, request: Any) -> Any: + if request.file is None and request.video is None: + return types.photos.Photo(photo=types.PhotoEmpty(id=0), users=[]) + return types.photos.Photo( + photo=types.Photo( + id=5150, + access_hash=1, + file_reference=b"", + date=datetime.now(timezone.utc), + sizes=[], + dc_id=2, + ), + users=[], + ) + + def _raw_GetSavedMusicRequest(self, request: Any) -> Any: + user = self._user_of(request.id) + documents = self.world.saved_music.get(int(getattr(user, "id", 0) or 0), []) + return types.users.SavedMusic(count=len(documents), documents=documents) + + def _raw_GetPersonalChannelHistoryRequest(self, request: Any) -> Any: + user = self._user_of(request.user_id) + overrides = self.world.user_full.get(int(getattr(user, "id", 0) or 0), {}) + channel_id = overrides.get("personal_channel_id") + history = list(reversed(self.world.history(-1000000000000 - int(channel_id or 0)))) + return types.messages.ChannelMessages( + pts=1, + count=len(history), + messages=history[: request.limit], + topics=[], + chats=list(self.world.chats.values()), + users=[], + ) + + def _raw_GetSavedRequest(self, request: Any) -> list[Any]: + return list(self.world.saved_contacts) + + def _raw_GetContactSignUpNotificationRequest(self, request: Any) -> bool: + return self.world.contact_signup_silent + + def _raw_SetContactSignUpNotificationRequest(self, request: Any) -> bool: + self.world.contact_signup_silent = bool(request.silent) + return True + + # -- resolution -------------------------------------------------------- + + def _resolved(self, entity: Any) -> Any: + from telethon import utils + + return types.contacts.ResolvedPeer( + peer=utils.get_peer(entity), + chats=[] if isinstance(entity, types.User) else [entity], + users=[entity] if isinstance(entity, types.User) else [], + ) + + def _raw_ResolveUsernameRequest(self, request: Any) -> Any: + entity = self._lookup(request.username) + if entity is None: + from telethon.errors import UsernameNotOccupiedError + + raise UsernameNotOccupiedError(request) + return self._resolved(entity) + + def _raw_ResolvePhoneRequest(self, request: Any) -> Any: + user_id = self.world.phonebook.get(_e164(request.phone)) + entity = self.world.users.get(user_id or 0) + if entity is None: + from telethon.errors import RPCError + + raise RPCError(request, "PHONE_NOT_OCCUPIED", 400) + return self._resolved(entity) + + def _raw_GetDeepLinkInfoRequest(self, request: Any) -> Any: + return types.help.DeepLinkInfo(message=self.world.deep_link_message) + + # -- the reads `resolve link --open` performs --------------------------- + # + # Every one of them is a *read*: `resolve link` classifies and reports, + # and the acting verb lives in another group. These exist so that the + # dispatcher's per-kind branch is exercised rather than assumed. + + def _raw_CheckChatInviteRequest(self, request: Any) -> Any: + return types.ChatInvite( + title="Shared group", + photo=types.PhotoEmpty(id=0), + participants_count=12, + color=0, + ) + + def _raw_GetBoostsStatusRequest(self, request: Any) -> Any: + return types.premium.BoostsStatus( + level=3, + current_level_boosts=10, + boosts=12, + boost_url="https://t.me/boost/news", + ) + + def _raw_CheckGiftCodeRequest(self, request: Any) -> Any: + return types.payments.CheckedGiftCode( + date=datetime.now(timezone.utc), + days=90, + chats=[], + users=[], + used_date=datetime.now(timezone.utc), + ) + + def _raw_GetThemeRequest(self, request: Any) -> Any: + return types.Theme(id=1, access_hash=1, slug="Slug", title="Midnight") + + # -- stories ----------------------------------------------------------- + + def _raw_TogglePeerStoriesHiddenRequest(self, request: Any) -> bool: + marked = abs(self._chat_id(request.peer)) + user = self.world.users.get(marked) + if user is not None: + user.stories_hidden = bool(request.hidden) + return True + + def _raw_ToggleAllStoriesHiddenRequest(self, request: Any) -> bool: + self.world.all_stories_hidden = bool(request.hidden) + return True + + def _raw_GetAllReadPeerStoriesRequest(self, request: Any) -> types.Updates: + return types.Updates( + updates=[ + types.UpdateReadStories(peer=_peer_for(uid), max_id=max_id) + for uid, max_id in self.world.stories_read.items() + ], + users=[], + chats=[], + date=datetime.now(timezone.utc), + seq=0, + ) # -- entities ---------------------------------------------------------- diff --git a/tests/test_agentmd_compat.py b/tests/test_agentmd_compat.py index fc37d8e..5bf72bf 100644 --- a/tests/test_agentmd_compat.py +++ b/tests/test_agentmd_compat.py @@ -64,6 +64,15 @@ ("media", "upload"), ("dl",), ("up",), + ("contact", "list"), + ("contact", "add"), + ("contact", "rename"), + ("contact", "remove"), + ("contact", "search"), + ("contacts",), + ("user", "get"), + ("user", "dialog-status"), + ("user", "hide-stories"), ] #: Documented v1 paths that are still hand-written commands rather than @@ -122,6 +131,21 @@ "chat.leave": {"left", "chat_id"}, "chat.typing": {"typing", "chat_id"}, "chat.catchup": {"chats"}, + "contact.add": {"added", "user_id"}, + "contact.rename": {"saved", "user_id", "first_name", "last_name"}, + "contact.remove": {"removed"}, + "user.get": {"id", "first_name", "username", "bio", "is_bot", "status", "stories_hidden"}, + "user.dialog-status": { + "ref", + "id", + "username", + "resolved", + "has_dialog", + "message_count", + "source", + "reason", + }, + "user.hide-stories": {"user_id", "username", "hidden", "already"}, } #: The changes CHANGELOG.md lists under "Breaking". Anything not in here has @@ -147,6 +171,9 @@ "chat.list": "`{chats: [...]}` became the `Page[Dialog]` envelope, and " "each row's `id`/`name`/`type`/`username` moved into a nested `chat` " "object so a dialog names its peer the same way every other response does", + "contact.list": "`{contacts: [...]}` became the `Page[Contact]` envelope; " + "every v1 row key survives and `phone` is normalised to E.164", + "contact.search": "same as contact.list", "chat.poster.list": "`{posters: [...], scanned_messages, distinct_posters}` " "keeps its keys, but each poster gained `user_id` beside v1's `id` and " "`last_date`/`last_message_id` became `date`/`date_unix`/`last_msg_id`", diff --git a/tests/test_dialog_status.py b/tests/test_dialog_status.py deleted file mode 100644 index f99469b..0000000 --- a/tests/test_dialog_status.py +++ /dev/null @@ -1,254 +0,0 @@ -"""`user dialog-status` must never turn "I can't tell" into "no dialog". - -Regression for the 2026-08-31 cold-contact incident. The guard that stops a -second account from re-greeting someone probed with `message list` and read -Telethon's "Could not find the input entity" as PROOF of no history. It is -not: `get_input_entity` for a bare numeric id only consults the local entity -cache, and its network fallback (`users.GetUsers` with access_hash=0) returns -`UserEmpty` for any non-contact. During that run id 1863814631 was refused as -unresolvable on Pouri2048 and, minutes later, the identical probe on the same -account returned a real outgoing message from 2026-08-21. - -So the unresolvable case must come back as `resolved: false`, and the only -thing that licenses `has_dialog: false` is an *exhausted* server-side dialog -list. -""" - -from __future__ import annotations - -import asyncio -from pathlib import Path -from types import SimpleNamespace - -import pytest -from telethon.errors import FloodWaitError -from telethon.tl.functions.messages import GetHistoryRequest, GetPeerDialogsRequest -from telethon.tl.types import InputPeerUser, User - -from tlgr.core.client import ClientWrapper -from tlgr.core.errors import EXIT_CODE_MAP, EXIT_INDETERMINATE - - -class _TotalList(list): - """Mirrors Telethon's TotalList: a list that carries a server-side total.""" - - def __init__(self, items, total): - super().__init__(items) - self.total = total - - -def _dialog(user: User, top_message: int = 0): - return SimpleNamespace(id=user.id, entity=user, top_message=top_message) - - -class _FakeTelethon: - """A Telethon stand-in whose entity cache can be cold on purpose.""" - - def __init__( - self, *, dialogs=None, cached_ids=(), totals=None, peer_dialog_top=None, dialogs_raise=None - ): - self._dialogs = dialogs or [] - self._cached = set(cached_ids) - self._totals = totals or {} - self._peer_dialog_top = peer_dialog_top or {} - self._dialogs_raise = dialogs_raise - self.dialogs_iterated = 0 - - async def get_input_entity(self, ref): - if isinstance(ref, User): - return InputPeerUser(ref.id, access_hash=ref.access_hash or 0) - if isinstance(ref, InputPeerUser): - return ref - if isinstance(ref, int) and ref in self._cached: - return InputPeerUser(ref, access_hash=123) - # The exact failure the old guard mis-read as "no history". - raise ValueError( - f"Could not find the input entity for PeerUser(user_id={ref}). " - "Please read https://docs.telethon.dev/en/stable/concepts/entities.html" - ) - - async def iter_dialogs(self): - idx = 0 - while True: - if self._dialogs_raise is not None and idx == self._dialogs_raise[0]: - raise self._dialogs_raise[1] - if idx >= len(self._dialogs): - return - self.dialogs_iterated += 1 - yield self._dialogs[idx] - idx += 1 - - async def __call__(self, request): - assert isinstance(request, GetPeerDialogsRequest) - peer = request.peers[0].peer - uid = peer.user_id - top = self._peer_dialog_top.get(uid, 0) - return SimpleNamespace(dialogs=[SimpleNamespace(top_message=top)]) - - async def get_messages(self, peer, limit=1, **kw): - uid = getattr(peer, "user_id", getattr(peer, "id", None)) - total = self._totals.get(uid, 0) - return _TotalList([SimpleNamespace(id=1)] if total else [], total) - - -def _wrap(fake) -> ClientWrapper: - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = fake - return w - - -def _u(uid, username=None): - return User(id=uid, first_name=f"u{uid}", username=username, access_hash=99) - - -# -------------------------------------------------------------------------- -# The regression itself -# -------------------------------------------------------------------------- - - -def test_unresolvable_id_is_never_reported_as_no_dialog(): - """THE bug. A cold cache must produce "unknown", not a green light.""" - fake = _FakeTelethon( - dialogs=[], cached_ids=(), dialogs_raise=(0, FloodWaitError(GetHistoryRequest, capture=30)) - ) - r = asyncio.run(_wrap(fake).dialog_status(1863814631)) - assert r["resolved"] is False - assert r["has_dialog"] is None # NOT False - assert r["source"] == "unknown" - assert r["message_count"] is None - assert "did not complete" in r["reason"] - - -def test_cold_cache_still_finds_a_real_dialog_via_the_server_scan(): - """The 2026-08-31 case: unresolvable id, but the account HAS history.""" - target = _u(1863814631, "E_Gurl") - fake = _FakeTelethon( - dialogs=[_dialog(_u(1)), _dialog(target), _dialog(_u(3))], - cached_ids=(), # entity cache is cold - totals={1863814631: 12}, - peer_dialog_top={1863814631: 44}, - ) - r = asyncio.run(_wrap(fake).dialog_status(1863814631)) - assert r["resolved"] is True - assert r["has_dialog"] is True - assert r["message_count"] == 12 - assert r["source"] == "dialog_scan" - assert r["username"] == "E_Gurl" - - -def test_exhausted_dialog_list_is_the_only_licence_for_a_negative(): - fake = _FakeTelethon(dialogs=[_dialog(_u(1)), _dialog(_u(2))], cached_ids=()) - r = asyncio.run(_wrap(fake).dialog_status(777)) - assert r["resolved"] is True - assert r["has_dialog"] is False - assert r["message_count"] == 0 - assert r["source"] == "dialog_scan" - assert r["scanned_dialogs"] == 2 - assert "complete dialog list" in r["reason"] - - -def test_scan_cap_is_indeterminate_not_a_negative(): - """A truncated scan proves nothing; it must not read as 'clear to send'.""" - fake = _FakeTelethon(dialogs=[_dialog(_u(i)) for i in range(1, 51)], cached_ids=()) - r = asyncio.run(_wrap(fake).dialog_status(9999, max_dialogs=10)) - assert r["resolved"] is False - assert r["has_dialog"] is None - assert r["scanned_dialogs"] == 10 - assert "cap" in r["reason"] - - -def test_flood_wait_mid_scan_is_indeterminate(): - fake = _FakeTelethon( - dialogs=[_dialog(_u(1)), _dialog(_u(2)), _dialog(_u(3))], - cached_ids=(), - dialogs_raise=(2, FloodWaitError(GetHistoryRequest, capture=17)), - ) - r = asyncio.run(_wrap(fake).dialog_status(3)) - assert r["resolved"] is False - assert r["has_dialog"] is None - - -# -------------------------------------------------------------------------- -# The cheap path -# -------------------------------------------------------------------------- - - -def test_cached_peer_is_confirmed_against_the_server_not_the_cache(): - """Resolving an entity is not evidence of a dialog — a group co-member - resolves fine and has never been messaged. Only the server's answer counts.""" - fake = _FakeTelethon(cached_ids=(555,), totals={555: 0}, peer_dialog_top={555: 0}) - r = asyncio.run(_wrap(fake).dialog_status(555)) - assert r["resolved"] is True - assert r["has_dialog"] is False - assert r["source"] == "peer_dialogs" - assert fake.dialogs_iterated == 0 # no scan needed - - -def test_cached_peer_with_history_reports_the_server_message_total(): - fake = _FakeTelethon(cached_ids=(555,), totals={555: 12}, peer_dialog_top={555: 88}) - r = asyncio.run(_wrap(fake).dialog_status(555)) - assert (r["resolved"], r["has_dialog"], r["message_count"]) == (True, True, 12) - assert r["source"] == "peer_dialogs" - - -def test_top_message_alone_establishes_a_dialog(): - """A dialog whose history the peer wiped still counts as prior contact.""" - fake = _FakeTelethon(cached_ids=(555,), totals={555: 0}, peer_dialog_top={555: 5}) - r = asyncio.run(_wrap(fake).dialog_status(555)) - assert r["has_dialog"] is True - - -def test_string_numeric_ref_behaves_like_the_int(): - fake = _FakeTelethon( - dialogs=[_dialog(_u(42))], cached_ids=(), totals={42: 4}, peer_dialog_top={42: 9} - ) - r = asyncio.run(_wrap(fake).dialog_status("42")) - assert r["resolved"] is True and r["has_dialog"] is True - - -def test_username_ref_is_matched_during_the_scan(): - fake = _FakeTelethon( - dialogs=[_dialog(_u(1)), _dialog(_u(2, "someone"))], - cached_ids=(), - totals={2: 3}, - peer_dialog_top={2: 9}, - ) - r = asyncio.run(_wrap(fake).dialog_status("@someone")) - assert r["resolved"] is True and r["has_dialog"] is True and r["id"] == 2 - - -def test_indeterminate_has_its_own_stable_exit_code(): - assert EXIT_CODE_MAP["INDETERMINATE"]["code"] == EXIT_INDETERMINATE == 13 - assert EXIT_INDETERMINATE not in (0, 3) # not success, not "empty results" - - -@pytest.mark.parametrize("outcome", ["positive", "negative", "indeterminate"]) -def test_the_three_outcomes_are_distinguishable(outcome): - if outcome == "positive": - fake = _FakeTelethon(cached_ids=(5,), totals={5: 2}, peer_dialog_top={5: 7}) - elif outcome == "negative": - fake = _FakeTelethon(dialogs=[_dialog(_u(1))], cached_ids=()) - else: - fake = _FakeTelethon(dialogs=[], cached_ids=(), dialogs_raise=(0, RuntimeError("rpc died"))) - r = asyncio.run(_wrap(fake).dialog_status(5)) - seen = (r["resolved"], r["has_dialog"]) - assert ( - seen - == { - "positive": (True, True), - "negative": (True, False), - "indeterminate": (False, None), - }[outcome] - ) - - -def test_scan_hit_wins_even_if_the_follow_up_count_is_zero(): - """Presence in the dialog list IS the dialog. A zero message total (both - sides wiped history) must not downgrade that to "never spoke".""" - fake = _FakeTelethon( - dialogs=[_dialog(_u(8))], cached_ids=(), totals={8: 0}, peer_dialog_top={8: 0} - ) - r = asyncio.run(_wrap(fake).dialog_status(8)) - assert r["resolved"] is True - assert r["has_dialog"] is True - assert r["source"] == "dialog_scan" diff --git a/tests/test_ops_contacts.py b/tests/test_ops_contacts.py new file mode 100644 index 0000000..c246736 --- /dev/null +++ b/tests/test_ops_contacts.py @@ -0,0 +1,1500 @@ +"""The contact, user and resolve operations, end to end through a real daemon. + +Every test goes over a real Unix socket, through the real middleware chain and +the real dispatcher, into the real implementation, against a fake Telegram — +and the assertion is usually that the fake's *world moved*: the contact list +grew, the peer landed on the blocklist, `stories_hidden` really flipped. That +is the only way the idempotent second pass (`already: true`, no RPC) can be +asserted at all. + +Three contracts get more attention than the rest, because AGENT.md freezes +them and a live agent reads them today: + +* `user dialog-status` is three-valued and its exit code is part of the + answer — exit 13 must never be reachable by reading "unknown" as "no"; +* `user hide-stories` reports `already` and sends nothing when there is + nothing to do; +* `contact rename` writes only *our* view of a name, and an empty first name + still becomes `"."` the way v1 sent it. +""" + +from __future__ import annotations + +import stat +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import pytest +from telethon.tl import types + +from tlgr.core.errors import ( + EXIT_INDETERMINATE, + EXIT_NOT_FOUND, + EXIT_USAGE, + classify, +) + +ALICE = 4242 +BOB = 4343 +CAROL = 4444 +NOBODY = 999999 +NEWS = 5150 +NEWS_ID = -1000000000000 - NEWS +OTHER = 5151 +OTHER_ID = -1000000000000 - OTHER + + +@pytest.fixture +def book(world): + """An address book: two contacts, one stranger, two channels.""" + from fake_telethon import make_channel, make_user, make_wallpaper + + alice = make_user(ALICE, username="alice", first="Alice") + alice.last_name = "Anderson" + alice.phone = "15550001111" + alice.status = types.UserStatusOnline(expires=datetime.now(timezone.utc) + timedelta(hours=1)) + world.add_contact(alice, mutual=True) + + bob = make_user(BOB, username="bobby", first="Bob") + bob.phone = "15550002222" + # `by_me` — the bucket is coarse because of OUR privacy, not Bob's. + bob.status = types.UserStatusRecently(by_me=True) + world.add_contact(bob, close_friend=True) + + carol = make_user(CAROL, username="carol", first="Carol") + world.add_user(carol) + + world.add_channel(make_channel(NEWS, title="News")).username = "newschan" + world.add_channel(make_channel(OTHER, title="Other")) + world.search_mine = [ALICE] + world.search_global = [CAROL] + world.phonebook["+15550009999"] = CAROL + # The two link targets `resolve link --open` reads back. The sticker and + # wallpaper worlds belong to the media group; seeding them here is what + # lets this test assert against the same replies that group's ops see. + world.add_sticker_set("Pack", []) + world.wallpapers["Slug"] = make_wallpaper("Slug", wallpaper_id=77) + return world + + +async def call(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> dict[str, Any]: + kwargs.setdefault("account", "work") + return await in_thread(client.op, op, request, **kwargs) + + +async def result(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> Any: + envelope = await call(client, in_thread, op, request, **kwargs) + return envelope["result"] + + +# --------------------------------------------------------------------------- +# contact list +# --------------------------------------------------------------------------- + + +class TestContactList: + async def test_the_contact_list_comes_back_with_its_people( + self, live_daemon, client, in_thread, book + ): + rows = await result(client, in_thread, "contact.list") + by_id = {row["id"]: row for row in rows} + assert set(by_id) == {ALICE, BOB} + assert by_id[ALICE]["name"] == "Alice Anderson" + assert by_id[ALICE]["mutual"] is True + + async def test_v1_keys_survive(self, live_daemon, client, in_thread, book): + """AGENT.md publishes id/name/username/phone for every row.""" + rows = await result(client, in_thread, "contact.list") + assert {"id", "name", "username", "phone"} <= set(rows[0]) + + async def test_ids_only_is_the_cheap_drift_check(self, live_daemon, client, in_thread, book): + rows = await result(client, in_thread, "contact.list", {"ids_only": True}) + assert sorted(row["id"] for row in rows) == [ALICE, BOB] + assert book.called("GetContactIDsRequest") + assert not book.called("GetContactsRequest") + + async def test_with_status_merges_one_call_for_the_whole_list( + self, live_daemon, client, in_thread, book + ): + rows = await result(client, in_thread, "contact.list", {"with_status": True}) + statuses = {row["id"]: row["status"]["kind"] for row in rows} + assert statuses[ALICE] == "online" + assert statuses[BOB] == "recently" + assert len(book.called("GetStatusesRequest")) == 1 + + async def test_a_coarse_bucket_says_it_is_our_own_privacy( + self, live_daemon, client, in_thread, book + ): + """`by_me` is the difference between 'coarse' and 'they hid from you'.""" + rows = await result(client, in_thread, "contact.list", {"with_status": True}) + bob = next(row for row in rows if row["id"] == BOB) + assert bob["status"]["by_me"] is True + + async def test_mutual_and_close_friend_filters(self, live_daemon, client, in_thread, book): + mutual = await result(client, in_thread, "contact.list", {"mutual_only": True}) + close = await result(client, in_thread, "contact.list", {"close_friends_only": True}) + assert [row["id"] for row in mutual] == [ALICE] + assert [row["id"] for row in close] == [BOB] + + async def test_sorting_is_local(self, live_daemon, client, in_thread, book): + by_first = await result(client, in_thread, "contact.list", {"sort": "first-name"}) + assert [row["id"] for row in by_first] == [ALICE, BOB] + + async def test_the_cursor_walks_forward(self, live_daemon, client, in_thread, book): + first = await call(client, in_thread, "contact.list", limit=1) + assert first["page"]["has_more"] is True + second = await call( + client, in_thread, "contact.list", limit=1, cursor=first["page"]["next_cursor"] + ) + assert first["result"][0]["id"] != second["result"][0]["id"] + assert second["page"]["has_more"] is False + + async def test_export_writes_a_private_file( + self, live_daemon, client, in_thread, book, tmp_path + ): + target = tmp_path / "book.vcf" + await result(client, in_thread, "contact.list", {"export": "vcard", "out": str(target)}) + text = target.read_text() + assert "BEGIN:VCARD" in text and "+15550001111" in text + # A phonebook is not something to leave world-readable. + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + + async def test_export_without_a_destination_is_a_usage_error( + self, live_daemon, client, in_thread, book + ): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.list", {"export": "csv"}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_with_stories_flags_unseen_ones(self, live_daemon, client, in_thread, book): + book.users[ALICE].stories_max_id = types.RecentStory(max_id=9) + book.stories_read[ALICE] = 4 + book.users[BOB].stories_max_id = types.RecentStory(max_id=2) + book.stories_read[BOB] = 2 + rows = await result(client, in_thread, "contact.list", {"with_stories": True}) + unseen = {row["id"]: row["has_unseen_stories"] for row in rows} + assert unseen == {ALICE: True, BOB: False} + + async def test_unregistered_lists_numbers_with_no_account( + self, live_daemon, client, in_thread, book + ): + book.add_saved_contact("+15550003333", "Dave") + book.add_saved_contact("+15550001111", "Alice") + rows = await result(client, in_thread, "contact.list", {"unregistered": True}) + assert [row["phone"] for row in rows] == ["+15550003333"] + + +# --------------------------------------------------------------------------- +# contact add / rename / remove / note +# --------------------------------------------------------------------------- + + +class TestContactAdd: + async def test_adding_a_known_user_uses_add_contact(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "contact.add", {"user": "@carol", "first_name": "Carol"} + ) + assert answer["added"] is True + assert answer["user_id"] == CAROL + assert CAROL in book.contacts + assert book.users[CAROL].contact is True + + async def test_the_v1_positional_name_still_works(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "contact.add", {"user": "@carol", "name": "Carol Cooper"} + ) + assert (answer["first_name"], answer["last_name"]) == ("Carol", "Cooper") + + async def test_a_phone_goes_through_import_contacts(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "contact.add", {"user": "+15550009999", "first_name": "Carol"} + ) + assert answer["added"] is True + assert answer["imported"] == [CAROL] + assert book.called("ImportContactsRequest") + + async def test_an_empty_import_reports_the_ambiguity_not_a_negative( + self, live_daemon, client, in_thread, book + ): + """No account and a privacy refusal are indistinguishable from here.""" + answer = await result( + client, in_thread, "contact.add", {"user": "+15550007777", "first_name": "Nobody"} + ) + assert answer["added"] is False + assert answer["imported"] == [] + assert "privacy" in answer["reason"] or "refuses" in answer["reason"] + + async def test_share_phone_warns_that_it_cannot_be_undone( + self, live_daemon, client, in_thread, book + ): + envelope = await call( + client, + in_thread, + "contact.add", + {"user": "@carol", "first_name": "Carol", "share_phone": True}, + ) + assert any("cannot be undone" in w for w in envelope["meta"]["warnings"]) + + async def test_a_contact_card_in_a_message_can_be_added( + self, live_daemon, client, in_thread, book + ): + message = book.add_message(ALICE, "", message_id=310) + message.media = types.MessageMediaContact( + phone_number="+15550009999", + first_name="Carol", + last_name="Cooper", + vcard="", + user_id=CAROL, + ) + answer = await result(client, in_thread, "contact.add", {"from_message": "@alice:310"}) + assert answer["user_id"] == CAROL + + async def test_a_card_with_no_user_falls_back_to_the_phone( + self, live_daemon, client, in_thread, book + ): + message = book.add_message(ALICE, "", message_id=311) + message.media = types.MessageMediaContact( + phone_number="+15550009999", first_name="Carol", last_name="", vcard="", user_id=0 + ) + answer = await result(client, in_thread, "contact.add", {"from_message": "@alice:311"}) + assert answer["imported"] == [CAROL] + + async def test_no_target_at_all_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.add", {"first_name": "Nobody"}) + assert classify(caught.value).exit_code == EXIT_USAGE + + +class TestContactRename: + async def test_it_writes_only_our_view_of_the_name(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "contact.rename", {"user": "@alice", "last_name": "· lead"} + ) + assert answer == { + "saved": True, + "user_id": ALICE, + "first_name": "Alice", + "last_name": "· lead", + } + assert book.users[ALICE].last_name == "· lead" + + async def test_an_omitted_part_keeps_the_current_one( + self, live_daemon, client, in_thread, book + ): + await result(client, in_thread, "contact.rename", {"user": "@alice", "last_name": "X"}) + assert book.users[ALICE].first_name == "Alice" + + async def test_an_empty_first_name_still_becomes_a_dot( + self, live_daemon, client, in_thread, book + ): + """v1's dodge for CONTACT_NAME_EMPTY, which the tagging scheme relies on.""" + book.users[ALICE].first_name = "" + answer = await result( + client, in_thread, "contact.rename", {"user": "@alice", "last_name": "tagged"} + ) + assert answer["first_name"] == "." + + async def test_it_works_on_a_non_contact(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "contact.rename", {"user": "@carol", "first_name": "C"} + ) + assert answer["saved"] is True + + +class TestContactRemove: + async def test_removing_a_contact_moves_the_world(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "contact.remove", {"user": ["@alice"]}) + assert answer["removed"] is True + assert answer["user_ids"] == [ALICE] + assert ALICE not in book.contacts + + async def test_removing_by_phone_reaches_numbers_with_no_account( + self, live_daemon, client, in_thread, book + ): + book.add_saved_contact("+15550003333", "Dave") + answer = await result(client, in_thread, "contact.remove", {"phone": ["+1 555 000 3333"]}) + assert answer["phones"] == ["+15550003333"] + assert book.saved_contacts == [] + + async def test_nothing_to_remove_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.remove", {}) + assert classify(caught.value).exit_code == EXIT_USAGE + + +class TestContactNote: + async def test_a_note_is_written_and_read_back_through_user_get( + self, live_daemon, client, in_thread, book + ): + answer = await result( + client, in_thread, "contact.note.set", {"user": "@alice", "text": "met in Berlin"} + ) + assert answer["user_id"] == ALICE + assert answer["note"] == "met in Berlin" + profile = await result(client, in_thread, "user.get", {"user": "@alice", "full": True}) + assert profile["note"] == "met in Berlin" + + async def test_clearing_sends_an_empty_text(self, live_daemon, client, in_thread, book): + await result(client, in_thread, "contact.note.set", {"user": "@alice", "text": "x"}) + answer = await result( + client, in_thread, "contact.note.set", {"user": "@alice", "clear": True} + ) + assert answer["cleared"] is True + assert ALICE not in book.contact_notes + + async def test_a_note_on_a_non_contact_fails(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception): + await result(client, in_thread, "contact.note.set", {"user": "@carol", "text": "hi"}) + + +# --------------------------------------------------------------------------- +# contact search / status / birthdays / joined +# --------------------------------------------------------------------------- + + +class TestContactSearch: + async def test_results_are_labelled_with_where_they_came_from( + self, live_daemon, client, in_thread, book + ): + rows = await result(client, in_thread, "contact.search", {"query": "a"}) + sources = {row["peer"]["id"]: row["source"] for row in rows} + assert sources[ALICE] == "mine" + assert sources[CAROL] == "global" + + async def test_mine_only_and_global_only(self, live_daemon, client, in_thread, book): + mine = await result(client, in_thread, "contact.search", {"query": "a", "mine_only": True}) + globally = await result( + client, in_thread, "contact.search", {"query": "a", "global_only": True} + ) + assert [row["peer"]["id"] for row in mine] == [ALICE] + assert [row["peer"]["id"] for row in globally] == [CAROL] + + async def test_adverts_are_off_by_default(self, live_daemon, client, in_thread, book): + book.sponsored_peers = [CAROL] + rows = await result(client, in_thread, "contact.search", {"query": "a"}) + assert not any(row["sponsored"] for row in rows) + assert not book.called("GetSponsoredPeersRequest") + + async def test_sponsored_rows_are_labelled_when_asked_for( + self, live_daemon, client, in_thread, book + ): + book.sponsored_peers = [CAROL] + rows = await result( + client, in_thread, "contact.search", {"query": "a", "with_sponsored": True} + ) + assert any(row["source"] == "sponsored" for row in rows) + + async def test_recent_history_is_local_state_that_survives_a_search( + self, live_daemon, client, in_thread, book + ): + await result(client, in_thread, "contact.search", {"query": "alice"}) + rows = await result(client, in_thread, "contact.search", {"recent": True}) + assert ALICE in {row["peer"]["id"] for row in rows} + assert all(row["source"] == "recent" for row in rows) + + async def test_clear_recent_empties_it(self, live_daemon, client, in_thread, book): + await result(client, in_thread, "contact.search", {"query": "alice"}) + await result(client, in_thread, "contact.search", {"clear_recent": True, "recent": True}) + rows = await result(client, in_thread, "contact.search", {"recent": True}) + assert rows == [] + + async def test_forget_resets_the_server_side_rating_too( + self, live_daemon, client, in_thread, book + ): + await result(client, in_thread, "contact.search", {"query": "alice"}) + await result(client, in_thread, "contact.search", {"forget": "@alice", "recent": True}) + assert book.called("ResetTopPeerRatingRequest") + + async def test_an_empty_query_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.search", {"query": " "}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_search_stays_dry_runnable(self, live_daemon, client, in_thread, book): + """A read must not print a stub just because one flag can write.""" + envelope = await call(client, in_thread, "contact.search", {"query": "alice"}, dry_run=True) + assert envelope["result"][0]["peer"]["id"] == ALICE + + async def test_dry_run_does_not_forget_anything(self, live_daemon, client, in_thread, book): + await call( + client, in_thread, "contact.search", {"forget": "@alice", "recent": True}, dry_run=True + ) + assert not book.called("ResetTopPeerRatingRequest") + + +class TestContactStatuses: + async def test_statuses_come_back_in_one_call(self, live_daemon, client, in_thread, book): + rows = await result(client, in_thread, "contact.status.list") + assert {row["user_id"] for row in rows} == {ALICE, BOB} + + async def test_online_only(self, live_daemon, client, in_thread, book): + rows = await result(client, in_thread, "contact.status.list", {"online_only": True}) + assert [row["user_id"] for row in rows] == [ALICE] + + +class TestContactBirthdays: + async def test_todays_birthdays_carry_an_age(self, live_daemon, client, in_thread, book): + today = datetime.now(timezone.utc) + book.birthdays[ALICE] = types.Birthday(day=today.day, month=today.month, year=1990) + rows = await result(client, in_thread, "contact.birthday.list") + assert rows[0]["id"] == ALICE + assert rows[0]["birthday"].endswith(f"{today.month:02d}-{today.day:02d}") + assert rows[0]["age"] == today.year - 1990 + + async def test_a_birthday_outside_the_window_is_dropped( + self, live_daemon, client, in_thread, book + ): + far = datetime.now(timezone.utc) + timedelta(days=40) + book.birthdays[ALICE] = types.Birthday(day=far.day, month=far.month, year=1990) + rows = await result(client, in_thread, "contact.birthday.list") + assert rows == [] + + +class TestContactJoined: + async def test_the_notification_switch_is_read_back(self, live_daemon, client, in_thread, book): + book.contact_signup_silent = True + rows = await result(client, in_thread, "contact.joined.list", {"notify": "on"}) + assert book.contact_signup_silent is False + assert rows == [] or rows[0]["notify"] is True + + async def test_a_signup_service_message_is_found(self, live_daemon, client, in_thread, book): + from fake_telethon import _Dialog + + message = book.add_message(ALICE, "", message_id=320) + message.action = types.MessageActionContactSignUp() + book.dialogs = [_Dialog(book.users[ALICE])] + rows = await result(client, in_thread, "contact.joined.list") + assert [row["user_id"] for row in rows] == [ALICE] + + +# --------------------------------------------------------------------------- +# blocking +# --------------------------------------------------------------------------- + + +class TestBlocking: + async def test_block_lands_the_peer_on_the_list(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "user.block", {"user": "@carol"}) + assert answer["blocked"] is True + assert CAROL in book.blocked + rows = await result(client, in_thread, "contact.blocked.list") + assert [row["peer"]["id"] for row in rows] == [CAROL] + + async def test_the_story_blocklist_is_independent(self, live_daemon, client, in_thread, book): + await result(client, in_thread, "user.block", {"user": "@carol", "stories": True}) + assert CAROL in book.blocked_stories + assert CAROL not in book.blocked + rows = await result(client, in_thread, "contact.blocked.list", {"stories": True}) + assert [row["kind"] for row in rows] == ["stories"] + + async def test_delete_history_revokes_for_both_sides( + self, live_daemon, client, in_thread, book + ): + book.add_message(ALICE, "hello", message_id=301) + answer = await result( + client, in_thread, "user.block", {"user": "@alice", "delete_history": True} + ) + assert answer["deleted"] is True + assert book.history(ALICE) == [] + + async def test_report_spam_happens_before_the_block(self, live_daemon, client, in_thread, book): + await result(client, in_thread, "user.block", {"user": "@carol", "report_spam": True}) + names = [name for name, _ in book.calls] + assert names.index("ReportSpamRequest") < names.index("BlockRequest") + + async def test_from_replies_takes_a_message_id_in_the_replies_chat( + self, live_daemon, client, in_thread, book + ): + answer = await result(client, in_thread, "user.block", {"from_replies": 77}) + assert answer["blocked"] is True + assert book.called("BlockFromRepliesRequest")[0].msg_id == 77 + + async def test_blocking_nothing_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.block", {}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_unblock_is_idempotent(self, live_daemon, client, in_thread, book): + book.block(CAROL) + first = await call(client, in_thread, "user.unblock", {"user": "@carol"}) + second = await call(client, in_thread, "user.unblock", {"user": "@carol"}) + assert first["result"]["already"] is False + assert second["result"]["already"] is True + assert second["meta"]["already"] is True + + async def test_blocked_list_pages(self, live_daemon, client, in_thread, book): + book.block(ALICE) + book.block(BOB) + book.block(CAROL) + first = await call(client, in_thread, "contact.blocked.list", limit=2) + assert first["page"]["total"] == 3 + assert first["page"]["has_more"] is True + second = await call( + client, + in_thread, + "contact.blocked.list", + limit=2, + cursor=first["page"]["next_cursor"], + ) + assert len(second["result"]) == 1 + + async def test_set_blocked_reports_the_diff_it_caused( + self, live_daemon, client, in_thread, book + ): + book.block(ALICE) + answer = await result(client, in_thread, "contact.blocked.set", {"user": ["@carol"]}) + assert answer["blocked"] == [CAROL] + assert answer["unblocked"] == [ALICE] + assert set(book.blocked) == {CAROL} + + async def test_an_empty_replacement_is_refused(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.blocked.set", {}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_set_blocked_reads_a_file(self, live_daemon, client, in_thread, book, tmp_path): + listing = tmp_path / "block.txt" + listing.write_text("# spammers\n@carol\n") + answer = await result(client, in_thread, "contact.blocked.set", {"from_file": str(listing)}) + assert answer["count"] == 1 + + +# --------------------------------------------------------------------------- +# close friends and top peers +# --------------------------------------------------------------------------- + + +class TestCloseFriends: + async def test_the_list_is_the_contact_list_filtered( + self, live_daemon, client, in_thread, book + ): + rows = await result(client, in_thread, "contact.close-friends.list") + assert [row["id"] for row in rows] == [BOB] + + async def test_setting_replaces_the_whole_list(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "contact.close-friends.set", {"user": ["@alice"]}) + assert answer["user_ids"] == [ALICE] + assert book.users[BOB].close_friend is False + + async def test_add_is_a_read_modify_write(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "contact.close-friends.set", {"add": ["@alice"]}) + assert sorted(answer["user_ids"]) == sorted([ALICE, BOB]) + + async def test_remove_is_a_read_modify_write(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "contact.close-friends.set", {"remove": ["@bobby"]} + ) + assert answer["user_ids"] == [] + + async def test_no_change_sends_no_rpc(self, live_daemon, client, in_thread, book): + envelope = await call(client, in_thread, "contact.close-friends.set", {"user": ["@bobby"]}) + assert envelope["meta"]["already"] is True + assert not book.called("EditCloseFriendsRequest") + + async def test_only_contacts_may_be_close_friends(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.close-friends.set", {"user": ["@carol"]}) + assert classify(caught.value).exit_code == EXIT_USAGE + + +class TestTopPeers: + async def test_categories_come_back_labelled(self, live_daemon, client, in_thread, book): + book.top_peers["correspondents"] = [(ALICE, 12.5)] + rows = await result(client, in_thread, "contact.top.list") + assert rows[0]["peer"]["id"] == ALICE + assert rows[0]["category"] == "correspondents" + assert rows[0]["rating"] == 12.5 + + async def test_an_unknown_category_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.top.list", {"category": ["nope"]}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_a_disabled_feature_is_indeterminate_not_empty( + self, live_daemon, client, in_thread, book + ): + """Nothing was measured, so an empty list would be a lie by omission.""" + book.top_peers_enabled = False + envelope = await call(client, in_thread, "contact.top.list") + assert envelope["result"] == [] + assert envelope["meta"]["indeterminate"] is True + assert "turned off" in envelope["meta"]["reason"] + + async def test_turning_it_off_wipes_the_ratings(self, live_daemon, client, in_thread, book): + book.top_peers["correspondents"] = [(ALICE, 1.0)] + answer = await result(client, in_thread, "contact.top.set", {"state": "off"}) + assert answer["enabled"] is False + assert book.top_peers == {} + + async def test_reset_zeroes_one_peer(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "contact.top.set", {"reset": "@alice"}) + assert answer["reset_peer"] == ALICE + assert book.called("ResetTopPeerRatingRequest") + + async def test_neither_state_nor_reset_is_a_usage_error( + self, live_daemon, client, in_thread, book + ): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.top.set", {}) + assert classify(caught.value).exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# sharing, the phonebook, syncing +# --------------------------------------------------------------------------- + + +class TestSharing: + async def test_sharing_a_card_sends_a_message(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "contact.share", {"user": "@alice", "to": "@bobby"} + ) + assert answer["chat_id"] == BOB + assert answer["contact"]["id"] == ALICE + assert book.history(BOB) + + async def test_sharing_needs_a_destination(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.share", {"user": "@alice"}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_share_phone_accepts_the_contact(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "contact.share-phone", {"user": "@alice"}) + assert answer == {"user_id": ALICE, "shared": True} + assert book.called("AcceptContactRequest") + + +class TestPhonebook: + def _vcard(self, path: Path) -> Path: + path.write_text( + "BEGIN:VCARD\nVERSION:3.0\nN:Cooper;Carol;;;\nFN:Carol Cooper\n" + "TEL;TYPE=CELL:+1 555 000 9999\nEND:VCARD\n" + ) + return path + + async def test_import_reads_a_vcard_and_reports_what_landed( + self, live_daemon, client, in_thread, book, tmp_path + ): + answer = await result( + client, + in_thread, + "contact.import", + {"file": str(self._vcard(tmp_path / "book.vcf"))}, + ) + assert answer["parsed"] == 1 + assert answer["imported"][0]["user_id"] == CAROL + assert CAROL in book.contacts + + async def test_import_reports_the_retry_list_rather_than_dropping_it( + self, live_daemon, client, in_thread, book, tmp_path + ): + book.phonebook["+15550008888"] = -1 # the fake's "ask again later" + path = tmp_path / "retry.csv" + path.write_text("+15550008888,Later,Person\n") + envelope = await call(client, in_thread, "contact.import", {"file": str(path)}) + assert envelope["result"]["retry"][0]["phone"] == "+15550008888" + assert any("retry_contacts" in w for w in envelope["meta"]["warnings"]) + + async def test_import_batches(self, live_daemon, client, in_thread, book, tmp_path): + path = tmp_path / "many.csv" + path.write_text("\n".join(f"+1555000{i:04d},P{i}," for i in range(5))) + answer = await result( + client, in_thread, "contact.import", {"file": str(path), "batch_size": 2} + ) + assert answer["batches"] == 3 + + async def test_an_empty_file_is_a_usage_error( + self, live_daemon, client, in_thread, book, tmp_path + ): + path = tmp_path / "empty.csv" + path.write_text("phone,first,last\n") + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.import", {"file": str(path)}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_stdin_is_refused_because_the_daemon_cannot_reach_it( + self, live_daemon, client, in_thread, book + ): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "contact.import", {"file": "-"}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_sync_prints_the_diff_and_changes_nothing_by_default( + self, live_daemon, client, in_thread, book, tmp_path + ): + answer = await result( + client, in_thread, "contact.sync", {"file": str(self._vcard(tmp_path / "b.vcf"))} + ) + assert answer["applied"] is False + assert answer["to_import"][0]["phone"] == "+15550009999" + assert CAROL not in book.contacts + + async def test_sync_applies_and_can_delete_what_is_missing( + self, live_daemon, client, in_thread, book, tmp_path + ): + answer = await result( + client, + in_thread, + "contact.sync", + { + "file": str(self._vcard(tmp_path / "b.vcf")), + "apply": True, + "delete_missing": True, + }, + ) + assert answer["applied"] is True + assert answer["imported"] == 1 + assert sorted(answer["to_delete"]) == ["+15550001111", "+15550002222"] + + async def test_saved_list_reaches_numbers_with_no_account( + self, live_daemon, client, in_thread, book + ): + book.add_saved_contact("+15550003333", "Dave") + book.add_saved_contact("+15550001111", "Alice") + rows = await result(client, in_thread, "contact.saved.list", {"invite_text": True}) + by_phone = {row["phone"]: row for row in rows} + assert by_phone["+15550001111"]["has_account"] is True + assert by_phone["+15550003333"]["has_account"] is False + assert rows[0]["invite_text"] + + +# --------------------------------------------------------------------------- +# user get +# --------------------------------------------------------------------------- + + +class TestUserGet: + async def test_v1_keys_survive_verbatim(self, live_daemon, client, in_thread, book): + """AGENT.md publishes id/first_name/username/bio/is_bot/status.""" + book.user_full[ALICE] = {"about": "somewhere warm"} + profile = await result(client, in_thread, "user.get", {"user": "@alice"}) + assert profile["id"] == ALICE + assert profile["first_name"] == "Alice" + assert profile["username"] == "alice" + assert profile["bio"] == "somewhere warm" + assert profile["is_bot"] is False + assert profile["status"] == "online" + + async def test_stories_hidden_is_auditable_without_a_write( + self, live_daemon, client, in_thread, book + ): + book.users[ALICE].stories_hidden = True + profile = await result(client, in_thread, "user.get", {"user": "@alice"}) + assert profile["stories_hidden"] is True + + async def test_the_access_hash_is_never_printed(self, live_daemon, client, in_thread, book): + profile = await result(client, in_thread, "user.get", {"user": "@alice"}) + assert "access_hash" not in profile + assert profile["access_hash_cached"] is True + + async def test_full_adds_the_profile_fields(self, live_daemon, client, in_thread, book): + book.user_full[ALICE] = {"about": "hi", "common_chats_count": 2} + profile = await result(client, in_thread, "user.get", {"user": "@alice", "full": True}) + assert profile["full"] is True + assert profile["common_chats_count"] == 2 + + async def test_blocked_state_comes_from_the_full_user( + self, live_daemon, client, in_thread, book + ): + book.block(ALICE) + profile = await result(client, in_thread, "user.get", {"user": "@alice", "full": True}) + assert profile["blocked"] is True + + async def test_one_field_is_pulled_out_with_the_global_select( + self, live_daemon, client, in_thread, book + ): + """`--select` is the projection, and it works on every op.""" + from click.testing import CliRunner + + from tlgr.cli import cli + + outcome = CliRunner().invoke(cli, ["user", "get", "--help"]) + assert outcome.exit_code == 0 + assert "--select" in outcome.output + assert "--field" not in outcome.output + + async def test_a_short_profile_survives_a_full_user_failure( + self, live_daemon, client, in_thread, book + ): + book.fail_next("GetFullUserRequest", RuntimeError("privacy")) + envelope = await call(client, in_thread, "user.get", {"user": "@alice", "full": True}) + assert envelope["result"]["id"] == ALICE + assert any("getFullUser" in w for w in envelope["meta"]["warnings"]) + + async def test_a_bio_can_be_translated(self, live_daemon, client, in_thread, book): + from telethon.tl import types as tl + + book.user_full[ALICE] = {"about": "irgendwo warm"} + book.raw["TranslateTextRequest"] = tl.messages.TranslateResult( + result=[tl.TextWithEntities(text="somewhere warm", entities=[])] + ) + profile = await result( + client, in_thread, "user.get", {"user": "@alice", "translate_bio": "en"} + ) + assert profile["bio_translated"] == "somewhere warm" + + async def test_a_min_user_is_addressed_through_the_message_it_was_seen_in( + self, live_daemon, client, in_thread, book + ): + """Telethon builds `inputUserFromMessage` for nobody; tlgr does.""" + book.add_message(NEWS_ID, "posted", message_id=88) + envelope = await call( + client, + in_thread, + "user.get", + {"user": str(CAROL), "from_chat": str(NEWS_ID), "from_message": 88}, + ) + built = book.called("GetUsersRequest")[0].id[0] + assert type(built).__name__ == "InputUserFromMessage" + assert envelope["result"]["id"] == CAROL + + async def test_an_unknown_username_is_not_found(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.get", {"user": "@ghost"}) + assert classify(caught.value).exit_code in (EXIT_NOT_FOUND, EXIT_INDETERMINATE) + + async def test_a_channel_id_is_refused_as_a_user(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.get", {"user": str(NEWS_ID)}) + assert classify(caught.value).exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# user dialog-status — the frozen contract +# --------------------------------------------------------------------------- + + +class TestDialogStatus: + async def test_a_dialog_is_confirmed_against_the_server( + self, live_daemon, client, in_thread, book + ): + book.add_message(ALICE, "hello", message_id=101) + book.add_dialog(ALICE, top_message=101) + answer = await result(client, in_thread, "user.dialog-status", {"user": "@alice"}) + assert answer["resolved"] is True + assert answer["has_dialog"] is True + assert answer["source"] == "peer_dialogs" + + async def test_the_documented_keys_are_all_there(self, live_daemon, client, in_thread, book): + book.add_dialog(ALICE, top_message=0) + answer = await result(client, in_thread, "user.dialog-status", {"user": "@alice"}) + assert { + "ref", + "id", + "username", + "resolved", + "has_dialog", + "message_count", + "source", + } <= set(answer) + + async def test_resolving_an_entity_is_not_evidence_of_a_dialog( + self, live_daemon, client, in_thread, book + ): + """A group co-member resolves fine and has never been messaged.""" + answer = await result(client, in_thread, "user.dialog-status", {"user": "@carol"}) + assert answer["resolved"] is True + assert answer["has_dialog"] is False + assert answer["source"] == "peer_dialogs" + + async def test_an_exhausted_dialog_list_is_the_only_licence_for_a_negative( + self, live_daemon, client, in_thread, book + ): + answer = await result(client, in_thread, "user.dialog-status", {"user": str(NOBODY)}) + assert answer["resolved"] is True + assert answer["has_dialog"] is False + assert answer["source"] == "dialog_scan" + assert "complete dialog list" in answer["reason"] + + async def test_a_capped_scan_is_indeterminate_and_exits_13( + self, live_daemon, client, in_thread, book + ): + """THE bug: a truncated scan proves nothing and must not read as 'go'.""" + envelope = await call( + client, + in_thread, + "user.dialog-status", + {"user": str(NOBODY), "max_dialogs": 1}, + ) + answer = envelope["result"] + assert answer["resolved"] is False + assert answer["has_dialog"] is None # NOT False + assert "cap" in answer["reason"] + assert envelope["meta"]["indeterminate"] is True + + async def test_the_three_outcomes_are_distinguishable( + self, live_daemon, client, in_thread, book + ): + book.add_message(ALICE, "hi", message_id=101) + book.add_dialog(ALICE, top_message=101) + positive = await result(client, in_thread, "user.dialog-status", {"user": "@alice"}) + negative = await result(client, in_thread, "user.dialog-status", {"user": str(NOBODY)}) + unknown = await result( + client, in_thread, "user.dialog-status", {"user": str(NOBODY), "max_dialogs": 1} + ) + assert (positive["resolved"], positive["has_dialog"]) == (True, True) + assert (negative["resolved"], negative["has_dialog"]) == (True, False) + assert (unknown["resolved"], unknown["has_dialog"]) == (False, None) + + async def test_the_cli_exits_13_on_an_indeterminate_answer(self, tlgr_home, monkeypatch): + """Exit 13 is part of the answer, not decoration on top of it.""" + from click.testing import CliRunner + + from tlgr.cli import cli, gen + + def fake_dispatch(spec, request, state): + return { + "ok": True, + "op": spec.id, + "result": {"ref": "@x", "resolved": False, "has_dialog": None}, + "meta": {"indeterminate": True, "reason": "cap hit"}, + } + + monkeypatch.setattr(gen, "_dispatch", fake_dispatch) + outcome = CliRunner().invoke(cli, ["--json", "user", "dialog-status", "@alice"]) + assert outcome.exit_code == EXIT_INDETERMINATE + assert '"has_dialog": null' in outcome.output + + +# --------------------------------------------------------------------------- +# user hide-stories — the other frozen contract +# --------------------------------------------------------------------------- + + +class TestHideStories: + async def test_hiding_moves_the_flag_and_reports_v1_keys( + self, live_daemon, client, in_thread, book + ): + answer = await result(client, in_thread, "user.hide-stories", {"user": ["@alice"]}) + assert { + "user_id": ALICE, + "username": "alice", + "hidden": True, + "already": False, + }.items() <= answer.items() + assert book.users[ALICE].stories_hidden is True + + async def test_a_second_pass_costs_no_rpc(self, live_daemon, client, in_thread, book): + await result(client, in_thread, "user.hide-stories", {"user": ["@alice"]}) + book.calls.clear() + envelope = await call(client, in_thread, "user.hide-stories", {"user": ["@alice"]}) + assert envelope["result"]["already"] is True + assert envelope["meta"]["already"] is True + assert not book.called("TogglePeerStoriesHiddenRequest") + + async def test_unhide_puts_them_back(self, live_daemon, client, in_thread, book): + book.users[ALICE].stories_hidden = True + answer = await result( + client, in_thread, "user.hide-stories", {"user": ["@alice"], "unhide": True} + ) + assert answer["hidden"] is False + assert book.users[ALICE].stories_hidden is False + + async def test_a_bulk_pass_keeps_the_single_peer_shape( + self, live_daemon, client, in_thread, book + ): + answer = await result( + client, in_thread, "user.hide-stories", {"user": ["@alice", "@bobby"]} + ) + assert answer["user_id"] == ALICE + assert [row["user_id"] for row in answer["peers"]] == [ALICE, BOB] + + async def test_the_whole_strip_can_be_collapsed(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "user.hide-stories", {"all_stories": "on"}) + assert answer["all_hidden"] is True + assert book.all_stories_hidden is True + + async def test_no_target_at_all_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.hide-stories", {}) + assert classify(caught.value).exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# the rest of the user group +# --------------------------------------------------------------------------- + + +class TestUserMisc: + async def test_can_message_reports_the_requirement(self, live_daemon, client, in_thread, book): + book.contact_requirements = {ALICE: "free", BOB: "paid:25"} + rows = await result(client, in_thread, "user.can-message", {"user": ["@alice", "@bobby"]}) + by_id = {row["user_id"]: row for row in rows} + assert by_id[ALICE]["result"] == "free" + assert by_id[BOB]["result"] == "paid" + assert by_id[BOB]["stars_amount"] == 25 + + async def test_can_message_needs_a_user(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.can-message", {}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_common_chats_lists_what_is_shared(self, live_daemon, client, in_thread, book): + rows = await result(client, in_thread, "user.chat.list", {"user": "@alice"}) + assert {row["id"] for row in rows} == {NEWS_ID, OTHER_ID} + + async def test_leave_all_is_dry_runnable(self, live_daemon, client, in_thread, book): + envelope = await call( + client, + in_thread, + "user.chat.list", + {"user": "@alice", "leave_all": True}, + dry_run=True, + ) + assert any("would leave" in w for w in envelope["meta"]["warnings"]) + assert not book.called("LeaveChannelRequest") + + async def test_leave_all_leaves(self, live_daemon, client, in_thread, book): + rows = await result( + client, in_thread, "user.chat.list", {"user": "@alice", "leave_all": True} + ) + assert all(row["left"] for row in rows) + assert len(book.called("LeaveChannelRequest")) == 2 + + async def test_a_link_is_built_locally(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "user.link", {"user": "@alice"}) + assert answer["url"] == "https://t.me/alice" + + async def test_a_profile_link_and_a_prefilled_draft(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "user.link", {"user": "@alice", "profile": True, "text": "hi"} + ) + assert "profile" in answer["url"] and "text=hi" in answer["url"] + + async def test_a_user_with_no_username_has_no_tme_link( + self, live_daemon, client, in_thread, book + ): + book.users[CAROL].username = None + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.link", {"user": str(CAROL)}) + assert classify(caught.value).exit_code == EXIT_NOT_FOUND + + async def test_a_contact_token_link_reports_its_expiry( + self, live_daemon, client, in_thread, book + ): + answer = await result(client, in_thread, "user.link", {"user": "me", "token": True}) + assert answer["kind"] == "contact-token" + assert answer["expires"] + + async def test_a_token_link_is_only_for_me(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.link", {"user": "@alice", "token": True}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_profile_photos_page(self, live_daemon, client, in_thread, book): + book.user_photos[ALICE] = [ + types.Photo( + id=900 + i, + access_hash=1, + file_reference=b"", + date=datetime.now(timezone.utc), + sizes=[], + dc_id=2, + ) + for i in range(3) + ] + first = await call(client, in_thread, "user.photo.list", {"user": "@alice"}, limit=2) + assert len(first["result"]) == 2 + assert first["page"]["total"] == 3 + + async def test_a_personal_photo_can_be_set_and_reset( + self, live_daemon, client, in_thread, book, tmp_path + ): + image = tmp_path / "avatar.jpg" + image.write_bytes(b"\xff\xd8\xff\xdb" + b"0" * 64) + answer = await result( + client, in_thread, "user.photo.set", {"user": "@alice", "file": str(image)} + ) + assert answer["photo_id"] == 5150 + cleared = await result( + client, in_thread, "user.photo.set", {"user": "@alice", "reset": True} + ) + assert cleared["reset"] is True + + async def test_pinned_music_comes_back(self, live_daemon, client, in_thread, book): + book.saved_music[ALICE] = [ + types.Document( + id=991, + access_hash=1, + file_reference=b"", + date=datetime.now(timezone.utc), + mime_type="audio/mpeg", + size=1024, + dc_id=2, + attributes=[ + types.DocumentAttributeAudio(duration=180, title="Nocturne", performer="Chopin") + ], + ) + ] + rows = await result(client, in_thread, "user.music.list", {"user": "@alice"}) + assert rows[0]["title"] == "Nocturne" + assert rows[0]["performer"] == "Chopin" + + async def test_a_personal_channel_comes_with_its_posts( + self, live_daemon, client, in_thread, book + ): + book.user_full[ALICE] = {"personal_channel_id": NEWS, "personal_channel_message": 7} + book.add_message(NEWS_ID, "a post", message_id=7) + answer = await result(client, in_thread, "user.personal-channel.get", {"user": "@alice"}) + assert answer["channel"]["id"] == NEWS_ID + assert answer["posts"][0]["text"] == "a post" + + async def test_no_personal_channel_is_not_found(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.personal-channel.get", {"user": "@alice"}) + assert classify(caught.value).exit_code == EXIT_NOT_FOUND + + async def test_a_birthday_can_be_suggested(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "user.birthday.set", {"user": "@alice", "date": "1990-04-01"} + ) + assert answer == {"user_id": ALICE, "birthday": "1990-04-01", "sent": True} + + async def test_a_month_day_birthday_is_accepted(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "user.birthday.set", {"user": "@alice", "date": "04-01"} + ) + assert answer["birthday"] == "04-01" + + async def test_a_bad_date_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "user.birthday.set", {"user": "@alice", "date": "nope"}) + assert classify(caught.value).exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# resolve +# --------------------------------------------------------------------------- + + +class TestResolve: + async def test_a_username_resolves_to_a_peer(self, live_daemon, client, in_thread, book): + answer = await result(client, in_thread, "resolve.username", {"username": "alice"}) + assert answer["peer"]["id"] == ALICE + assert answer["kind"] == "user" + + async def test_a_free_username_is_not_found(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "resolve.username", {"username": "ghosty"}) + assert classify(caught.value).exit_code == EXIT_NOT_FOUND + + async def test_a_type_mismatch_is_not_found(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result( + client, in_thread, "resolve.username", {"username": "alice", "type": "channel"} + ) + assert classify(caught.value).exit_code == EXIT_NOT_FOUND + + async def test_an_empty_username_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "resolve.username", {"username": " "}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_a_phone_resolves_without_adding_a_contact( + self, live_daemon, client, in_thread, book + ): + answer = await result(client, in_thread, "resolve.phone", {"phone": "+1 555 000 9999"}) + assert answer["resolved"] is True + assert answer["peer"]["id"] == CAROL + assert CAROL not in book.contacts + + async def test_an_unoccupied_phone_is_indeterminate_never_not_found( + self, live_daemon, client, in_thread, book + ): + """No account and a privacy refusal are indistinguishable from here.""" + envelope = await call(client, in_thread, "resolve.phone", {"phone": "+15550007777"}) + assert envelope["result"]["resolved"] is False + assert "refuse lookups by phone" in envelope["result"]["reason"] + assert envelope["meta"]["indeterminate"] is True + + async def test_offline_formats_and_validates_without_an_rpc( + self, live_daemon, client, in_thread, book + ): + answer = await result( + client, in_thread, "resolve.phone", {"phone": "+1 555 000 1111", "offline": True} + ) + assert answer["e164"] == "+15550001111" + assert answer["country"] == "United States" + assert answer["resolved"] is False + assert not book.called("ResolvePhoneRequest") + + async def test_peer_resolution_emits_both_id_spaces(self, live_daemon, client, in_thread, book): + rows = await result( + client, in_thread, "resolve.peer", {"ref": [str(NEWS_ID)], "ids": "botapi"} + ) + assert rows[0]["marked_id"] == NEWS_ID + assert rows[0]["id"] == NEWS + assert rows[0]["botapi_id"] == NEWS_ID + + async def test_peer_resolution_reports_how_it_answered( + self, live_daemon, client, in_thread, book + ): + rows = await result(client, in_thread, "resolve.peer", {"ref": ["@alice"]}) + assert rows[0]["resolved"] is True + assert rows[0]["source"] == "resolve_username" + assert "access_hash" not in rows[0] + + async def test_an_uncached_bare_id_fails_rather_than_guessing( + self, live_daemon, client, in_thread, book + ): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "resolve.peer", {"ref": [str(NOBODY)]}) + assert classify(caught.value).exit_code in (EXIT_NOT_FOUND, EXIT_INDETERMINATE) + + async def test_resolving_nothing_is_a_usage_error(self, live_daemon, client, in_thread, book): + with pytest.raises(Exception) as caught: + await result(client, in_thread, "resolve.peer", {"ref": []}) + assert classify(caught.value).exit_code == EXIT_USAGE + + async def test_the_peer_cache_is_inspectable_without_hashes( + self, live_daemon, client, in_thread, book + ): + await result(client, in_thread, "resolve.username", {"username": "alice"}) + rows = await result(client, in_thread, "resolve.cache.get") + row = next(r for r in rows if r["marked_id"] == ALICE) + assert row["access_hash_cached"] is True + assert "access_hash" not in row + + async def test_purge_drops_the_rows(self, live_daemon, client, in_thread, book): + await result(client, in_thread, "resolve.username", {"username": "alice"}) + await result(client, in_thread, "resolve.cache.get", {"purge": True}) + rows = await result(client, in_thread, "resolve.cache.get") + assert rows == [] + + async def test_purge_honours_dry_run(self, live_daemon, client, in_thread, book): + await result(client, in_thread, "resolve.username", {"username": "alice"}) + envelope = await call(client, in_thread, "resolve.cache.get", {"purge": True}, dry_run=True) + assert any("would be dropped" in w for w in envelope["meta"]["warnings"]) + rows = await result(client, in_thread, "resolve.cache.get") + assert rows + + +# --------------------------------------------------------------------------- +# resolve link — one dispatcher, one union +# --------------------------------------------------------------------------- + + +class TestResolveLink: + @pytest.mark.parametrize( + ("url", "kind"), + [ + ("https://t.me/alice", "public-username"), + ("https://t.me/alice/4210", "message"), + ("https://t.me/c/1234/56", "private-post"), + ("https://t.me/+AbCdEf", "invite"), + ("https://t.me/+15550001111", "phone"), + ("https://t.me/joinchat/AbCdEf", "invite"), + ("https://t.me/addlist/AbCdEf", "chatlist-invite"), + ("https://t.me/addstickers/Pack", "stickerset"), + ("https://t.me/addemoji/Pack", "emojiset"), + ("https://t.me/addtheme/Slug", "theme"), + ("https://t.me/bg/Slug", "wallpaper"), + ("https://t.me/giftcode/AbC", "giftcode"), + ("https://t.me/nft/AbC", "unique-gift"), + ("https://t.me/contact/Token", "contact-token"), + ("https://t.me/m/Slug", "business-chat-link"), + ("https://t.me/alice/s/12", "story"), + ("https://t.me/mybot?start=abc", "bot-start"), + ("https://t.me/mybot?startgroup=abc", "bot-startgroup"), + ("https://t.me/mybot?startapp=abc", "webapp"), + ("https://t.me/proxy?server=x&port=1", "proxy"), + ("https://t.me/share?url=x", "share-url"), + ("https://t.me/contacts/new", "contacts-section"), + ("tg://settings", "settings-section"), + ("tg://resolve?domain=alice", "public-username"), + ("tg://join?invite=AbC", "invite"), + ("tg://privatepost?channel=1234&post=5", "private-post"), + ("tg://msg_url?url=x&text=y", "share-url"), + ], + ) + async def test_every_link_shape_is_classified( + self, live_daemon, client, in_thread, book, url, kind + ): + answer = await result(client, in_thread, "resolve.link", {"url": url, "no_network": True}) + assert answer["kind"] == kind + + async def test_a_plus_number_is_a_phone_not_an_invite( + self, live_daemon, client, in_thread, book + ): + """The one ambiguity that turns a lookup into a join if guessed wrong.""" + answer = await result( + client, in_thread, "resolve.link", {"url": "t.me/+15550001111", "no_network": True} + ) + assert answer["kind"] == "phone" + assert answer["phone"] == "+15550001111" + + async def test_resolution_names_the_command_that_would_act( + self, live_daemon, client, in_thread, book + ): + answer = await result( + client, in_thread, "resolve.link", {"url": "t.me/+AbCdEf", "no_network": True} + ) + assert answer["delegated_to"] == "chat join" + assert answer["requires_action"] is True + + async def test_no_network_performs_no_rpc(self, live_daemon, client, in_thread, book): + await result(client, in_thread, "resolve.link", {"url": "t.me/alice", "no_network": True}) + assert not book.called("ResolveUsernameRequest") + + async def test_open_performs_the_follow_up_read(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "resolve.link", {"url": "t.me/alice", "open": True} + ) + assert answer["peer"]["id"] == ALICE + + async def test_an_unknown_tg_path_asks_the_server_what_it_means( + self, live_daemon, client, in_thread, book + ): + answer = await result(client, in_thread, "resolve.link", {"url": "tg://whatever"}) + assert answer["kind"] == "unknown" + assert answer["deeplink_info"] == book.deep_link_message + + async def test_a_message_link_carries_its_ids(self, live_daemon, client, in_thread, book): + answer = await result( + client, in_thread, "resolve.link", {"url": "t.me/alice/4210", "no_network": True} + ) + assert (answer["username"], answer["msg_id"]) == ("alice", 4210) + + async def test_a_thread_link_splits_thread_from_message( + self, live_daemon, client, in_thread, book + ): + answer = await result( + client, in_thread, "resolve.link", {"url": "t.me/alice/12/34", "no_network": True} + ) + assert (answer["thread_id"], answer["msg_id"]) == (12, 34) + + @pytest.mark.parametrize( + ("url", "check"), + [ + ("t.me/+AbCdEf", lambda a: a["title"] == "Shared group"), + ("t.me/addstickers/Pack", lambda a: a["title"] == "Pack"), + ("t.me/addtheme/Slug", lambda a: a["title"] == "Midnight"), + ("t.me/bg/Slug", lambda a: a["opened"]["id"] == 77), + ("t.me/boost/newschan", lambda a: a["opened"]["level"] == 3), + ("t.me/giftcode/AbC", lambda a: a["opened"]["used"] is True), + ("t.me/alice/s/12", lambda a: a["opened"]["stories"] == 0), + ("t.me/contact/Token", lambda a: a["peer"]["id"] in (ALICE, BOB, CAROL)), + ], + ) + async def test_open_reads_but_never_acts( + self, live_daemon, client, in_thread, book, url, check + ): + answer = await result(client, in_thread, "resolve.link", {"url": url, "open": True}) + assert check(answer) + # Nothing that *changes* the world may have been sent. + assert not book.called("ImportChatInviteRequest") + assert not book.called("InstallStickerSetRequest") + + async def test_a_message_link_can_be_read(self, live_daemon, client, in_thread, book): + book.add_message(ALICE, "the post", message_id=4210) + answer = await result( + client, in_thread, "resolve.link", {"url": "t.me/alice/4210", "open": True} + ) + assert answer["opened"]["text"] == "the post" + + async def test_a_private_post_resolves_through_the_peer_cache( + self, live_daemon, client, in_thread, book + ): + """`t.me/c//` carries a bare channel id and nothing else.""" + book.add_message(NEWS_ID, "private post", message_id=7) + answer = await result( + client, in_thread, "resolve.link", {"url": f"t.me/c/{NEWS}/7", "open": True} + ) + assert answer["kind"] == "private-post" + assert answer["opened"]["text"] == "private post" + + async def test_a_shared_text_can_be_saved_as_a_draft( + self, live_daemon, client, in_thread, book + ): + answer = await result( + client, + in_thread, + "resolve.link", + {"url": "tg://msg_url?url=x&text=hello", "draft": "@alice"}, + ) + assert answer["draft_saved"] is True + assert book.drafts[ALICE].message == "hello" + + async def test_a_draft_with_no_text_is_a_usage_error( + self, live_daemon, client, in_thread, book + ): + with pytest.raises(Exception) as caught: + await result( + client, in_thread, "resolve.link", {"url": "t.me/alice", "draft": "@alice"} + ) + assert classify(caught.value).exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# cross-cutting +# --------------------------------------------------------------------------- + + +class TestDryRun: + @pytest.mark.parametrize( + ("op", "payload"), + [ + ("contact.add", {"user": "@carol", "first_name": "C"}), + ("contact.remove", {"user": ["@alice"]}), + ("contact.rename", {"user": "@alice", "first_name": "X"}), + ("user.block", {"user": "@carol"}), + ("user.hide-stories", {"user": ["@alice"]}), + ("contact.blocked.set", {"user": ["@carol"]}), + ], + ) + async def test_a_mutating_op_changes_nothing_under_dry_run( + self, live_daemon, client, in_thread, book, op, payload + ): + before = (dict(book.contacts), dict(book.blocked), len(book.calls)) + envelope = await call(client, in_thread, op, payload, dry_run=True) + assert envelope["result"]["dry_run"] is True + assert envelope["result"]["would"] == op + assert (dict(book.contacts), dict(book.blocked), len(book.calls)) == before + + +class TestLegacyPaths: + """§12.4: no documented v1 path disappears.""" + + @pytest.mark.parametrize( + ("path", "op_id"), + [ + ("contact list", "contact.list"), + ("contacts", "contact.list"), + ("contact add", "contact.add"), + ("contact rename", "contact.rename"), + ("contact remove", "contact.remove"), + ("contact search", "contact.search"), + ("user get", "user.get"), + ("user dialog-status", "user.dialog-status"), + ("user hide-stories", "user.hide-stories"), + ], + ) + def test_the_v1_path_still_resolves(self, path, op_id): + import tlgr.ops # noqa: F401 + from tlgr.registry import canonical + + assert canonical(path) == op_id + + @pytest.mark.parametrize( + "path", + [ + ("contact", "list"), + ("contacts",), + ("contact", "add"), + ("contact", "rename"), + ("contact", "remove"), + ("contact", "search"), + ("user", "get"), + ("user", "dialog-status"), + ("user", "hide-stories"), + ], + ids=lambda p: " ".join(p), + ) + def test_the_v1_path_is_still_invocable(self, path): + from tlgr.cli import cli + + node: Any = cli + for token in path: + node = node.commands.get(token) if hasattr(node, "commands") else None + assert node is not None diff --git a/tests/test_parity.py b/tests/test_parity.py index 0671a7f..a99f513 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -25,10 +25,10 @@ #: Every P0 catalog id the landed PRs claim. Raised by each group PR, never #: lowered. ARCHITECTURE §1.3: "P0 coverage may never decrease and must reach #: 100 % before 2.0.0 final". -P0_FLOOR = 116 +P0_FLOOR = 136 #: The floor for total covered ids. Same rule, weaker guarantee. -COVERED_FLOOR = 1010 +COVERED_FLOOR = 1132 #: Every P0 catalog id PR-1's own operations cover, named rather than #: counted, so a swap (one dropped, one added) cannot pass a count check @@ -217,10 +217,38 @@ ) #: `(group prefixes, the P0 ids those groups claim)` for each landed PR. +#: The P0 ids PR-5's own operations cover, named for the same reason. +PR5_P0_IDS = frozenset( + { + "contacts-users.block-unblock", + "contacts-users.contact-add-by-phone", + "contacts-users.contact-add-by-user", + "contacts-users.contact-delete", + "contacts-users.contact-edit-name", + "contacts-users.contacts-list", + "contacts-users.contacts-search", + "contacts-users.resolve-deeplink", + "contacts-users.resolve-message-link", + "contacts-users.resolve-phone", + "contacts-users.search-public-chat", + "contacts-users.user-bio", + "contacts-users.user-phone", + "contacts-users.user-profile-basic", + "contacts-users.user-profile-full", + "contacts-users.user-requirements-to-contact", + "contacts-users.user-status", + "dialogs.block-user", + "dialogs.resolve-peer", + "dialogs.unblock-user", + } +) + + P0_OWNERS = ( (("message.", "draft."), PR1_P0_IDS), (("auth.", "account.", "passport."), PR2_P0_IDS), (("chat.", "folder."), PR3_P0_IDS), + (("contact.", "user.", "resolve."), PR5_P0_IDS), (PR4_GROUPS, PR4_P0_IDS), (("media.", "sticker.", "gif.", "emoji."), PR6_P0_IDS), (("poll.", "reaction.", "todo.", "location.", "search."), PR9_P0_IDS), @@ -296,7 +324,9 @@ def test_every_p0_id_this_pr_owns_is_covered(self): assert missing == [], f"a landed PR dropped coverage of {missing}" @pytest.mark.parametrize( - "prefixes,expected", P0_OWNERS, ids=["pr1", "pr2", "pr3", "pr4", "pr6", "pr9", "pr11"] + "prefixes,expected", + P0_OWNERS, + ids=["pr1", "pr2", "pr3", "pr5", "pr4", "pr6", "pr9", "pr11"], ) def test_the_floor_is_the_whole_truth(self, prefixes, expected): """Each named list is exactly the P0 set its own groups claim. @@ -386,6 +416,15 @@ def test_media_files_is_fully_accounted_for(self, report): assert stats["accounted_percent"] == 100.0 assert stats["covered"] >= 118 + def test_contacts_users_is_fully_accounted_for(self, report): + """PR-5's own domain: implemented, or waived to a named later PR.""" + stats = report.by_domain["contacts_users"] + assert stats["accounted_percent"] == 100.0 + assert stats["covered"] >= 99 + + def test_the_contacts_users_domain_is_no_longer_waived_wholesale(self): + assert "contacts_users" not in waivers().domains + class TestReport: def test_the_excluded_set_is_the_documented_one(self, report): diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index b82b251..1200eec 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -28,11 +28,17 @@ def test_top_level_block(self, runner): assert "not enabled" in result.output def test_legacy_top_level_block_still_exits_2(self, runner): - """`contact` is still hand-written; `chat` became generated in PR-3.""" - result = runner.invoke(cli, ["--enable-commands", "message", "contact", "list"]) + """`profile` is the last hand-written group; `contact` is generated in PR-5.""" + result = runner.invoke(cli, ["--enable-commands", "message", "profile", "get"]) assert result.exit_code == 2 assert "not enabled" in result.output + def test_a_generated_group_blocks_with_permission_denied(self, runner): + """`contact` is generated now, so its block is PERMISSION_DENIED (6).""" + result = runner.invoke(cli, ["--enable-commands", "message", "contact", "list"]) + assert result.exit_code == 6 + assert "not enabled" in result.output + def test_top_level_allow(self, runner): result = runner.invoke(cli, ["--enable-commands", "agent", "agent", "exit-codes"]) assert result.exit_code == 0 diff --git a/tests/test_stories_hidden.py b/tests/test_stories_hidden.py deleted file mode 100644 index 079fed4..0000000 --- a/tests/test_stories_hidden.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Hiding a peer's stories — the per-account "Hide Stories" toggle. - -Two properties matter to the callers that do this in bulk: the flag is read -back from the *fresh* user object (so a no-op costs no RPC), and `user get` -reports the current value (so the state is auditable without a write). -""" - -from __future__ import annotations - -import asyncio -from pathlib import Path - -import pytest -from telethon.tl.functions.stories import TogglePeerStoriesHiddenRequest -from telethon.tl.types import User - -from tlgr.core.client import ClientWrapper -from tlgr.core.errors import TlgrError - - -class _FakeTelethon: - def __init__(self, entity): - self.entity = entity - self.requests: list = [] - - async def get_entity(self, ref): - return self.entity - - async def __call__(self, request): - self.requests.append(request) - return True - - -def _make(entity): - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _FakeTelethon(entity) - return w - - -def _user(uid=7, hidden=None): - return User(id=uid, first_name="u", username="someone", stories_hidden=hidden) - - -def test_hides_stories_and_reports_the_peer(): - w = _make(_user()) - result = asyncio.run(w.set_stories_hidden(7)) - assert result == { - "user_id": 7, - "username": "someone", - "hidden": True, - "already": False, - } - (req,) = w._client.requests - assert isinstance(req, TogglePeerStoriesHiddenRequest) - assert req.hidden is True - - -def test_already_hidden_sends_no_request(): - w = _make(_user(hidden=True)) - result = asyncio.run(w.set_stories_hidden(7)) - assert result["already"] is True - assert result["hidden"] is True - assert w._client.requests == [] - - -def test_unhide_is_the_same_toggle_the_other_way(): - w = _make(_user(hidden=True)) - result = asyncio.run(w.set_stories_hidden(7, hidden=False)) - assert result["already"] is False - (req,) = w._client.requests - assert req.hidden is False - - -def test_unhide_of_a_visible_peer_is_also_a_no_op(): - w = _make(_user(hidden=None)) - assert asyncio.run(w.set_stories_hidden(7, hidden=False))["already"] is True - assert w._client.requests == [] - - -def test_refuses_a_non_user_peer(): - from telethon.tl.types import Channel - - w = _make(Channel(id=9, title="c", photo=None, date=None)) - with pytest.raises(TlgrError): - asyncio.run(w.set_stories_hidden(9)) - assert w._client.requests == [] - - -# -- the flag has to be readable, not only writable ------------------------- - - -class _FullFake(_FakeTelethon): - async def __call__(self, request): - raise RuntimeError("no full-user fetch in this test") - - -def test_user_get_reports_stories_hidden(): - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _FullFake(_user(hidden=True)) - info = asyncio.run(w.get_user_info(7)) - assert info["stories_hidden"] is True - - -def test_user_get_reports_false_when_unset(): - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _FullFake(_user(hidden=None)) - assert asyncio.run(w.get_user_info(7))["stories_hidden"] is False diff --git a/tlgr/cli/__init__.py b/tlgr/cli/__init__.py index c7d9218..d1213f6 100644 --- a/tlgr/cli/__init__.py +++ b/tlgr/cli/__init__.py @@ -231,25 +231,9 @@ def cli( from tlgr.cli.gen import build_click_tree # noqa: E402 from tlgr.cli.legacy.chat import chat_create, chat_members # noqa: E402 -from tlgr.cli.legacy.contact import contact_group # noqa: E402 from tlgr.cli.legacy.profile import profile_group # noqa: E402 -from tlgr.cli.legacy.user import user_group # noqa: E402 -cli.add_command(contact_group, "contact") cli.add_command(profile_group, "profile") -cli.add_command(user_group, "user") - - -# --------------------------------------------------------------------------- -# Top-level action shortcuts (desire paths) -# --------------------------------------------------------------------------- - - -@cli.command("contacts") -@click.pass_context -def shortcut_contacts(ctx: click.Context) -> None: - """List all contacts (shortcut for 'contact list').""" - ctx.invoke(contact_group.commands["list"], account=ctx.obj.get("account")) # --------------------------------------------------------------------------- diff --git a/tlgr/cli/gen.py b/tlgr/cli/gen.py index 33eb7a1..f52a6aa 100644 --- a/tlgr/cli/gen.py +++ b/tlgr/cli/gen.py @@ -32,7 +32,13 @@ resolve_account, state_from, ) -from tlgr.core.errors import EXIT_EMPTY, DaemonError, PermissionError_, UsageError +from tlgr.core.errors import ( + EXIT_EMPTY, + EXIT_INDETERMINATE, + DaemonError, + PermissionError_, + UsageError, +) from tlgr.core.pagination import DATE_OFFSET_KINDS from tlgr.models.base import UNSET from tlgr.models.peer import PeerRef @@ -80,6 +86,9 @@ def emit(self, event_type: str, payload: dict[str, Any], **kwargs: Any) -> None: def mark_already(self) -> None: """No-op: a local operation has no envelope meta to flag.""" + def mark_indeterminate(self, reason: str = "") -> None: + """No-op: a local operation answers from data it already holds.""" + Dispatcher = Callable[[OperationSpec, msgspec.Struct, CliState], dict[str, Any]] StreamDispatcher = Callable[[OperationSpec, msgspec.Struct, CliState], Iterator[dict[str, Any]]] @@ -544,6 +553,11 @@ def callback(**values: Any) -> None: wide=state.wide, no_header=state.no_header, ) + # An operation that could not *establish* its answer still returns + # the partial result; the non-zero status is what makes a caller + # gating on it fail closed rather than read "unknown" as "no". + if (envelope.get("meta") or {}).get("indeterminate"): + ctx.exit(EXIT_INDETERMINATE) result = envelope.get("result") if spec.empty_exit == EXIT_EMPTY and not result: ctx.exit(EXIT_EMPTY) diff --git a/tlgr/cli/legacy/contact.py b/tlgr/cli/legacy/contact.py deleted file mode 100644 index 800acfa..0000000 --- a/tlgr/cli/legacy/contact.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Contact management commands.""" - -from __future__ import annotations - -import click - -from tlgr.cli.legacy._common import resolve_account -from tlgr.core.output import add_pagination, decode_cursor, emit -from tlgr.ipc_client import ipc_request - - -@click.group("contact") -def contact_group() -> None: - """Manage contacts.""" - - -@contact_group.command("list") -@click.option("--limit", "-n", type=int, default=None, help="Max contacts to return.") -@click.option("--cursor", default=None, help="Pagination cursor from a previous response.") -@click.option("--account", "-a", default=None) -@click.pass_context -def contact_list( - ctx: click.Context, limit: int | None, cursor: str | None, account: str | None -) -> None: - """List all contacts.""" - acct = resolve_account(ctx, account) - result = ipc_request("GET", "/contact/list", params={"account": acct}) - contacts = result.get("contacts", []) - cur = decode_cursor(cursor) - offset = cur.get("offset", 0) - if offset: - contacts = contacts[offset:] - effective_limit = limit or len(contacts) - page = contacts[:effective_limit] - fmt = ctx.obj.get("fmt", "human") - if fmt == "json": - next_state = {"offset": offset + len(page)} - out = {"contacts": page} - add_pagination(out, page, effective_limit, next_state, has_more=len(contacts) > len(page)) - emit(ctx.obj, out) - else: - emit(ctx.obj, page, columns=["id", "name", "username", "phone"]) - - -@contact_group.command("add") -@click.argument("phone") -@click.argument("name", required=False, default="") -@click.option("--account", "-a", default=None) -@click.pass_context -def contact_add(ctx: click.Context, phone: str, name: str, account: str | None) -> None: - """Add a contact by phone number.""" - acct = resolve_account(ctx, account) - result = ipc_request( - "POST", "/contact/add", body={"phone": phone, "name": name, "account": acct} - ) - emit(ctx.obj, result) - - -@contact_group.command("rename") -@click.argument("user") -@click.option("--first-name", default=None, help="New first name (omit to keep current).") -@click.option("--last-name", default=None, help="New last name (omit to keep current).") -@click.option("--account", "-a", default=None) -@click.pass_context -def contact_rename( - ctx: click.Context, - user: str, - first_name: str | None, - last_name: str | None, - account: str | None, -) -> None: - """Save a user as a contact under a custom name (also works on non-contacts). - - Useful for tagging users, e.g. appending a state marker to the last name. - """ - acct = resolve_account(ctx, account) - body: dict = {"user": user, "account": acct} - if first_name is not None: - body["first_name"] = first_name - if last_name is not None: - body["last_name"] = last_name - if ctx.obj.get("dry_run"): - emit(ctx.obj, {"dry_run": True, "op": "contact.rename", **body}) - return - result = ipc_request("POST", "/contact/rename", body=body) - emit(ctx.obj, result, columns=["saved", "user_id", "first_name", "last_name"]) - - -@contact_group.command("remove") -@click.argument("user") -@click.option("--account", "-a", default=None) -@click.pass_context -def contact_remove(ctx: click.Context, user: str, account: str | None) -> None: - """Remove a contact.""" - acct = resolve_account(ctx, account) - if ctx.obj.get("dry_run"): - emit(ctx.obj, {"dry_run": True, "op": "contact.remove", "user": user}) - return - result = ipc_request("POST", "/contact/remove", body={"user": user, "account": acct}) - emit(ctx.obj, result) - - -@contact_group.command("search") -@click.argument("query") -@click.option("--limit", "-n", type=int, default=None, help="Max results.") -@click.option("--cursor", default=None, help="Pagination cursor from a previous response.") -@click.option("--account", "-a", default=None) -@click.pass_context -def contact_search( - ctx: click.Context, query: str, limit: int | None, cursor: str | None, account: str | None -) -> None: - """Search contacts.""" - acct = resolve_account(ctx, account) - result = ipc_request("GET", "/contact/search", params={"query": query, "account": acct}) - contacts = result.get("contacts", []) - cur = decode_cursor(cursor) - offset = cur.get("offset", 0) - if offset: - contacts = contacts[offset:] - effective_limit = limit or len(contacts) - page = contacts[:effective_limit] - fmt = ctx.obj.get("fmt", "human") - if fmt == "json": - next_state = {"offset": offset + len(page)} - out = {"contacts": page} - add_pagination(out, page, effective_limit, next_state, has_more=len(contacts) > len(page)) - emit(ctx.obj, out) - else: - emit(ctx.obj, page, columns=["id", "name", "username"]) diff --git a/tlgr/cli/legacy/user.py b/tlgr/cli/legacy/user.py deleted file mode 100644 index bda08b8..0000000 --- a/tlgr/cli/legacy/user.py +++ /dev/null @@ -1,100 +0,0 @@ -"""User info commands.""" - -from __future__ import annotations - -import click - -from tlgr.cli.legacy._common import resolve_account -from tlgr.core.errors import EXIT_INDETERMINATE -from tlgr.core.output import emit -from tlgr.ipc_client import ipc_request - - -@click.group("user") -def user_group() -> None: - """Look up Telegram users.""" - - -@user_group.command("get") -@click.argument("user") -@click.option("--account", "-a", default=None) -@click.pass_context -def user_get(ctx: click.Context, user: str, account: str | None) -> None: - """Get detailed info about a user.""" - acct = resolve_account(ctx, account) - result = ipc_request("GET", "/user/get", params={"user": user, "account": acct}) - emit( - ctx.obj, - result, - columns=["id", "first_name", "username", "bio", "is_bot", "status", "stories_hidden"], - ) - - -@user_group.command("hide-stories") -@click.argument("user") -@click.option( - "--unhide", is_flag=True, default=False, help="Put them back in the main stories bar instead." -) -@click.option("--account", "-a", default=None) -@click.pass_context -def user_hide_stories(ctx: click.Context, user: str, unhide: bool, account: str | None) -> None: - """Archive USER's stories for this account ("Hide Stories"). - - The same thing as the "Hide Stories" item in Telegram's own story - context menu: they drop out of the main stories bar into the collapsed - Hidden list. Per-account and purely local — the other side is never - told, and nothing about the chat, the contact or their access changes. - - Idempotent: `already: true` means the flag was already set and no RPC - was sent, so bulk passes are cheap to repeat. `tlgr user get` reports - the current value as `stories_hidden`. - """ - acct = resolve_account(ctx, account) - result = ipc_request( - "POST", - "/user/stories-hidden", - body={"user": user, "hidden": not unhide, "account": acct}, - ) - emit(ctx.obj, result, columns=["user_id", "username", "hidden", "already"]) - - -@user_group.command("dialog-status") -@click.argument("user") -@click.option( - "--max-dialogs", - type=int, - default=5000, - help="Cap on the fallback dialog-list scan. Hitting it is reported " - "as indeterminate, never as 'no dialog'.", -) -@click.option("--account", "-a", default=None) -@click.pass_context -def user_dialog_status( - ctx: click.Context, user: str, max_dialogs: int, account: str | None -) -> None: - """Does this account have a dialog with USER? (authoritative, or honest.) - - Answers one of three things, and they are never conflated: - - resolved=true, has_dialog=true -> prior conversation exists - resolved=true, has_dialog=false -> definitively none (exit 0) - resolved=false, has_dialog=null -> could not be established (exit 13) - - Exit 13 means UNKNOWN. Callers gating a cold first message must treat it - as a refusal, not as a green light -- that conflation is exactly the bug - this command exists to remove. - """ - acct = resolve_account(ctx, account) - result = ipc_request( - "GET", - "/user/dialog-status", - params={"user": user, "account": acct, "max_dialogs": max_dialogs}, - timeout=600, - ) - emit( - ctx.obj, - result, - columns=["id", "username", "resolved", "has_dialog", "message_count", "source"], - ) - if not result.get("resolved"): - ctx.exit(EXIT_INDETERMINATE) diff --git a/tlgr/core/client.py b/tlgr/core/client.py index d34d2b0..90dc208 100644 --- a/tlgr/core/client.py +++ b/tlgr/core/client.py @@ -6,14 +6,13 @@ from typing import Any from telethon import TelegramClient, utils -from telethon.errors import FloodWaitError, SessionPasswordNeededError +from telethon.errors import SessionPasswordNeededError from telethon.tl.types import Channel, Chat, User from tlgr.core.errors import ( AuthenticationError, ChatNotFoundError, SessionError, - TlgrError, ) DEFAULT_FLOOD_WAIT_MAX = 120 @@ -477,91 +476,6 @@ async def create_chat( result = await self.client.create_group(name, users) return {"id": result.id if hasattr(result, "id") else 0, "name": name, "type": "group"} - async def list_contacts(self) -> list[dict[str, Any]]: - from telethon.tl.functions.contacts import GetContactsRequest - - result = await self.client(GetContactsRequest(hash=0)) - contacts: list[dict[str, Any]] = [] - for u in result.users: - contacts.append( - { - "id": u.id, - "name": f"{u.first_name or ''} {u.last_name or ''}".strip(), - "username": u.username, - "phone": u.phone, - } - ) - return contacts - - async def add_contact(self, phone: str, name: str = "") -> dict[str, Any]: - from telethon.tl.functions.contacts import ImportContactsRequest - from telethon.tl.types import InputPhoneContact - - parts = name.split(maxsplit=1) - first = parts[0] if parts else "" - last = parts[1] if len(parts) > 1 else "" - result = await self.client( - ImportContactsRequest( - [InputPhoneContact(client_id=0, phone=phone, first_name=first, last_name=last)] - ) - ) - imported = result.imported - if imported: - return {"added": True, "user_id": imported[0].user_id} - return {"added": False, "error": "Could not import contact"} - - async def rename_contact( - self, - user_ref: str, - *, - first_name: str | None = None, - last_name: str | None = None, - ) -> dict[str, Any]: - """Save a user as a contact with the given name. - - Works on any user (also non-contacts, e.g. to tag them). Omitted - name parts keep the user's current profile name. - """ - from telethon.tl.functions.contacts import AddContactRequest - - entity = await self.client.get_entity(user_ref) - if not isinstance(entity, User): - raise TlgrError(f"'{user_ref}' is not a user") - first = first_name if first_name is not None else (entity.first_name or "") - last = last_name if last_name is not None else (entity.last_name or "") - if not first: - first = "." - await self.client( - AddContactRequest( - id=entity, - first_name=first, - last_name=last, - phone=getattr(entity, "phone", None) or "", - add_phone_privacy_exception=False, - ) - ) - return {"saved": True, "user_id": entity.id, "first_name": first, "last_name": last} - - async def remove_contact(self, user_ref: str) -> dict[str, Any]: - from telethon.tl.functions.contacts import DeleteContactsRequest - - entity = await self.client.get_entity(user_ref) - await self.client(DeleteContactsRequest(id=[entity])) - return {"removed": True} - - async def search_contacts(self, query: str) -> list[dict[str, Any]]: - from telethon.tl.functions.contacts import SearchRequest - - result = await self.client(SearchRequest(q=query, limit=50)) - return [ - { - "id": u.id, - "name": f"{u.first_name or ''} {u.last_name or ''}".strip(), - "username": u.username, - } - for u in result.users - ] - async def get_profile(self) -> dict[str, Any]: me = await self.client.get_me() return { @@ -596,233 +510,6 @@ async def update_profile( await self.client.upload_profile_photo(file=photo) return {"updated": True} - async def get_user_info(self, user_ref: str) -> dict[str, Any]: - """Get detailed info about a user.""" - from telethon.tl.functions.users import GetFullUserRequest - - entity = await self.client.get_entity(user_ref) - if not isinstance(entity, User): - return self._entity_to_dict(entity) - - try: - full = await self.client(GetFullUserRequest(entity)) - user = full.users[0] if full.users else entity - about = full.full_user.about or "" - except Exception: - user = entity - about = "" - - status_str = "" - if hasattr(user, "status") and user.status: - status_str = type(user.status).__name__.replace("UserStatus", "").lower() - - return { - "id": user.id, - "first_name": user.first_name or "", - "last_name": user.last_name or "", - "username": user.username, - "phone": user.phone, - "bio": about, - "is_bot": getattr(user, "bot", False), - "status": status_str, - # no photo + status "empty" together is the classic signature of - # an account that blocked you (or an abandoned account) - "has_photo": getattr(user, "photo", None) is not None, - "deleted": getattr(user, "deleted", False), - # Whether THIS account has archived their stories (Telegram's own - # "Hide Stories" menu item). Read-only here; set it with - # set_stories_hidden(). Reported so the state is checkable without - # a write — a toggle you can only set is a toggle you cannot audit. - "stories_hidden": bool(getattr(user, "stories_hidden", False)), - } - - async def set_stories_hidden(self, user_ref: int | str, hidden: bool = True) -> dict[str, Any]: - """Archive (or unarchive) a peer's stories for this account. - - Exactly Telegram's own "Hide Stories" context-menu item: the peer moves - out of the main stories bar into the collapsed "Hidden" list. It is a - purely local, per-account preference — the other side is not notified - and nothing about the chat changes — which is why it is safe to apply - in bulk to people an outreach campaign has contacted. - - Reports `already` when the flag was already in the requested state, so - a bulk pass over hundreds of peers costs one RPC each on the first run - and none on every run after. `stories_hidden` comes from the fresh User - the resolve returns, not from the session cache. - """ - from telethon.tl.functions.stories import TogglePeerStoriesHiddenRequest - - entity = await self.client.get_entity(user_ref) - if not isinstance(entity, User): - raise TlgrError(f"'{user_ref}' is not a user") - was = bool(getattr(entity, "stories_hidden", False)) - if was == hidden: - return { - "user_id": entity.id, - "username": entity.username, - "hidden": hidden, - "already": True, - } - await self.client(TogglePeerStoriesHiddenRequest(peer=entity, hidden=hidden)) - return { - "user_id": entity.id, - "username": entity.username, - "hidden": hidden, - "already": False, - } - # ------------------------------------------------------------------ # Authoritative history / harvest primitives # ------------------------------------------------------------------ - - async def dialog_status( - self, - user_ref: int | str, - *, - max_dialogs: int = 5000, - ) -> dict[str, Any]: - """Answer "does THIS account have a dialog with this peer?" — or admit - it cannot tell. - - The naive probe (list a few messages and read the error) is unsound for - a *bare numeric id*: `get_input_entity` only consults the local entity - cache and, for a non-bot account, the network fallback - (`users.GetUsers` with access_hash=0) returns `UserEmpty` for anyone - who is not already a contact. So a cold cache raises "Could not find - the input entity" for ids the account demonstrably HAS talked to, and - that error is indistinguishable from a genuinely unknown peer. Callers - that read it as "no history" will happily cold-message someone twice. - - There is no MTProto call that resolves a bare user id to an access - hash. What *is* server-side and authoritative is the dialog list - itself, so: - - 1. try to get an input peer cheaply (cache / disk / username - resolution) and, if that works, ask the server directly with - `messages.GetPeerDialogs` plus an exact server-side message total; - 2. if the peer cannot be resolved, enumerate the account's *complete* - dialog list from the server and look for the id. Finding it is a - positive; **exhausting** it is the only thing that licenses a - negative; - 3. if neither completes — cap hit, FloodWait, RPC failure — report - ``resolved: false`` and let the caller fail closed. An honest - "I don't know" is the whole point of this command. - - Caveat, deliberately not papered over: this reports on the dialog - list. If the account *deleted* the conversation, the history is gone - server-side too and this correctly says there is no dialog. - """ - from telethon.tl.functions.messages import GetPeerDialogsRequest - from telethon.tl.types import InputDialogPeer - - out: dict[str, Any] = { - "ref": user_ref, - "id": None, - "username": None, - "resolved": False, - "has_dialog": None, - "message_count": None, - "source": "unknown", - "reason": None, - } - - target_id: int | None = None - target_username: str | None = None - if isinstance(user_ref, int): - target_id = user_ref - elif isinstance(user_ref, str): - s = user_ref.strip() - if s.lstrip("-").isdigit(): - target_id = int(s) - else: - target_username = s.lstrip("@").lower() - - peer: Any = None - try: - peer = await self.client.get_input_entity(user_ref) - except FloodWaitError as e: - out["reason"] = f"rate limited while resolving entity (wait {e.seconds}s)" - return out - except Exception as e: - # NOT evidence of absence — just a cold cache or an unknown handle. - out["reason"] = f"entity not resolvable directly: {e}" - - scanned = 0 - if peer is None: - if target_id is None and target_username is None: - out["reason"] = f"unusable reference: {user_ref!r}" - return out - try: - async for dialog in self.client.iter_dialogs(): - scanned += 1 - ent = dialog.entity - ent_id = getattr(ent, "id", None) - dlg_id = getattr(dialog, "id", None) - uname = (getattr(ent, "username", None) or "").lower() - if (target_id is not None and target_id in (ent_id, dlg_id)) or ( - target_username is not None and uname == target_username - ): - peer = ent - out["source"] = "dialog_scan" - break - if scanned >= max_dialogs: - out["scanned_dialogs"] = scanned - out["reason"] = ( - f"dialog scan hit the {max_dialogs}-dialog cap without a " - "match — indeterminate, NOT a negative" - ) - return out - except Exception as e: - out["scanned_dialogs"] = scanned - out["reason"] = f"dialog scan did not complete: {e}" - return out - - out["scanned_dialogs"] = scanned - if peer is None: - # The server handed us every dialog this account has and the - # peer was not among them. This is the definitive negative. - out.update( - resolved=True, - has_dialog=False, - message_count=0, - source="dialog_scan", - reason="absent from the account's complete dialog list", - ) - out["id"] = target_id - out["username"] = target_username - return out - - try: - input_peer = await self.client.get_input_entity(peer) - res = await self.client(GetPeerDialogsRequest(peers=[InputDialogPeer(peer=input_peer)])) - dialogs = list(getattr(res, "dialogs", []) or []) - top = max((getattr(d, "top_message", 0) or 0) for d in dialogs) if dialogs else 0 - msgs = await self.client.get_messages(input_peer, limit=1) - total = getattr(msgs, "total", None) - if total is None: - total = len(msgs) - total = int(total) - except FloodWaitError as e: - out["reason"] = f"rate limited while querying the server (wait {e.seconds}s)" - return out - except Exception as e: - out["reason"] = f"server dialog query failed: {e}" - return out - - pid = getattr(peer, "user_id", None) - if pid is None: - pid = ( - getattr(peer, "channel_id", None) - or getattr(peer, "chat_id", None) - or getattr(peer, "id", None) - ) - out["id"] = pid if pid is not None else target_id - out["username"] = getattr(peer, "username", None) or target_username - out["resolved"] = True - # Presence in the dialog list is itself the dialog: a scan hit stays a - # positive even if both sides have since wiped the history. - out["has_dialog"] = out["source"] == "dialog_scan" or bool(top) or total > 0 - out["message_count"] = total - if out["source"] != "dialog_scan": - out["source"] = "peer_dialogs" - return out diff --git a/tlgr/daemon/dispatch.py b/tlgr/daemon/dispatch.py index 048ccc5..4deeb7a 100644 --- a/tlgr/daemon/dispatch.py +++ b/tlgr/daemon/dispatch.py @@ -86,6 +86,11 @@ class DaemonContext: warnings: list[str] = field(default_factory=list) flood_wait_slept: int = 0 already: bool = False + #: Set when an operation could not *establish* its answer. The CLI turns + #: it into exit 13, so a truncated harvest or an unresolvable peer still + #: returns its partial result and still fails closed (§7.3). + indeterminate: bool = False + indeterminate_reason: str = "" def warn(self, message: str) -> None: self.warnings.append(message) @@ -147,6 +152,18 @@ def mark_already(self) -> None: """Record that the world already looked the way the caller asked for.""" self.already = True + def mark_indeterminate(self, reason: str = "") -> None: + """Record that the answer could not be established. + + The result is still returned — a partial answer with a reason beats + an error that throws the partial answer away — and the CLI exits 13, + so a caller gating on it fails closed. `user dialog-status` is the + op this exists for: its three-valued contract needs both the body + and the non-zero status. + """ + self.indeterminate = True + self.indeterminate_reason = reason + def emit(self, event_type: str, payload: dict[str, Any], **kwargs: Any) -> None: """Echo an action tlgr itself performed onto the bus (§6.5). @@ -392,6 +409,10 @@ def _envelope( } if dry_run: envelope["meta"]["dry_run"] = True + if context.indeterminate: + envelope["meta"]["indeterminate"] = True + if context.indeterminate_reason: + envelope["meta"]["reason"] = context.indeterminate_reason # `omit_defaults` drops an empty `items`, so the membership test v1 used # here made an empty page lose its `page` envelope entirely — the one # shape a caller walking pages must be able to rely on. diff --git a/tlgr/daemon/ipc.py b/tlgr/daemon/ipc.py index 4f86469..c72fcd8 100644 --- a/tlgr/daemon/ipc.py +++ b/tlgr/daemon/ipc.py @@ -121,16 +121,8 @@ def _register_routes(self, app: web.Application) -> None: app.router.add_get("/chat/members", self._chat_members) # Contacts - app.router.add_get("/contact/list", self._contact_list) - app.router.add_post("/contact/add", self._contact_add) - app.router.add_post("/contact/remove", self._contact_remove) - app.router.add_get("/contact/search", self._contact_search) - app.router.add_post("/contact/rename", self._contact_rename) # Users - app.router.add_get("/user/get", self._user_get) - app.router.add_get("/user/dialog-status", self._user_dialog_status) - app.router.add_post("/user/stories-hidden", self._user_stories_hidden) # Profile app.router.add_get("/profile/get", self._profile_get) @@ -173,113 +165,6 @@ async def _chat_members(self, request: web.Request) -> web.Response: except Exception as e: return _handle_exception(e) - async def _contact_list(self, request: web.Request) -> web.Response: - q = request.query - account = q.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - contacts = await client.list_contacts() - return _json_response({"contacts": contacts}) - except Exception as e: - return _handle_exception(e) - - async def _contact_add(self, request: web.Request) -> web.Response: - body = await _get_body(request) - account = body.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - result = await client.add_contact(body["phone"], body.get("name", "")) - return _json_response(result) - except Exception as e: - return _handle_exception(e) - - async def _contact_remove(self, request: web.Request) -> web.Response: - body = await _get_body(request) - account = body.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - result = await client.remove_contact(_ref(body["user"])) - return _json_response(result) - except Exception as e: - return _handle_exception(e) - - async def _contact_rename(self, request: web.Request) -> web.Response: - body = await _get_body(request) - account = body.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - result = await client.rename_contact( - _ref(body["user"]), - first_name=body.get("first_name"), - last_name=body.get("last_name"), - ) - return _json_response(result) - except Exception as e: - return _handle_exception(e) - - async def _contact_search(self, request: web.Request) -> web.Response: - q = request.query - account = q.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - contacts = await client.search_contacts(q.get("query", "")) - return _json_response({"contacts": contacts}) - except Exception as e: - return _handle_exception(e) - - # -- Users -- - - async def _user_get(self, request: web.Request) -> web.Response: - q = request.query - account = q.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - info = await client.get_user_info(_ref(q["user"])) - return _json_response(info) - except Exception as e: - return _handle_exception(e) - - async def _user_dialog_status(self, request: web.Request) -> web.Response: - q = request.query - account = q.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - status = await client.dialog_status( - _ref(q["user"]), - max_dialogs=int(q.get("max_dialogs", 5000)), - ) - return _json_response(status) - except Exception as e: - return _handle_exception(e) - - async def _user_stories_hidden(self, request: web.Request) -> web.Response: - body = await _get_body(request) - account = body.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - result = await client.set_stories_hidden( - _ref(body["user"]), hidden=bool(body.get("hidden", True)) - ) - return _json_response(result) - except Exception as e: - return _handle_exception(e) - # -- Profile -- async def _profile_get(self, request: web.Request) -> web.Response: diff --git a/tlgr/data/parity_waivers.toml b/tlgr/data/parity_waivers.toml index 657caaa..bd9fbf7 100644 --- a/tlgr/data/parity_waivers.toml +++ b/tlgr/data/parity_waivers.toml @@ -17,11 +17,6 @@ final_pr = 12 # Whole domains that no PR has migrated yet. Each becomes its own group PR. # --------------------------------------------------------------------------- -[[domain]] -name = "contacts_users" -pr = 5 -reason = "contact, user and blocking land in PR-5." - [[domain]] name = "groups_channels_admin" pr = 7 @@ -48,31 +43,6 @@ reason = "profile, privacy, notify, settings, business, premium, gift and stars # --------------------------------------------------------------------------- # The dialogs_chats ids PR-3 does not own. Each names the group that does. -[[id]] -id = "dialogs.actionbar-add-contact" -pr = 5 -reason = "The bar's Add-contact button is `contact add` (PR-5)." - -[[id]] -id = "dialogs.actionbar-share-phone" -pr = 5 -reason = "Sharing my number is `contact share-phone` (PR-5)." - -[[id]] -id = "dialogs.block-stories" -pr = 5 -reason = "The story blocklist is a privacy surface on the user group (PR-5)." - -[[id]] -id = "dialogs.block-user" -pr = 5 -reason = "Blocking is `user block` (PR-5); `chat report --block` calls it." - -[[id]] -id = "dialogs.blocked-set-bulk" -pr = 5 -reason = "Replacing the whole blocklist is `user block --from-file` (PR-5)." - [[id]] id = "dialogs.bot-stop-restart" pr = 10 @@ -103,11 +73,6 @@ id = "dialogs.business-link-list" pr = 12 reason = "Business chat links are a business setting (PR-12)." -[[id]] -id = "dialogs.business-link-resolve" -pr = 12 -reason = "Resolving a t.me/m/ link is a business surface (PR-12)." - [[id]] id = "dialogs.channel-autotranslation" pr = 7 @@ -123,16 +88,6 @@ id = "dialogs.community-join-requests" pr = 7 reason = "Community join requests are moderation (PR-7)." -[[id]] -id = "dialogs.contact-signup-notify" -pr = 12 -reason = "The contact-joined notification is a notify setting (PR-12)." - -[[id]] -id = "dialogs.dialog-exists" -pr = 5 -reason = "`user dialog-status` answers this and migrates with the user group (PR-5)." - [[id]] id = "dialogs.forum-tabs-mode" pr = 7 @@ -143,11 +98,6 @@ id = "dialogs.frozen-account" pr = 12 reason = "The frozen-account state is reported by the account surface (PR-12)." -[[id]] -id = "dialogs.hide-stories-peer" -pr = 8 -reason = "Hiding a peer's stories is the story strip (PR-8)." - [[id]] id = "dialogs.new-chats-privacy" pr = 12 @@ -168,61 +118,26 @@ id = "dialogs.notify-scope-defaults" pr = 12 reason = "Scope-wide defaults are `notify set` (PR-12)." -[[id]] -id = "dialogs.personal-channel-preview" -pr = 5 -reason = "A profile's personal-channel card is the user group (PR-5)." - -[[id]] -id = "dialogs.presence-watch" -pr = 4 -reason = "Online/last-seen is an update stream (PR-4)." - [[id]] id = "dialogs.reactions-notify" pr = 12 reason = "Reaction notification settings are the notify surface (PR-12)." -[[id]] -id = "dialogs.recent-searches" -pr = 5 -reason = "The recent-search list is search state on the contact group (PR-5)." - [[id]] id = "dialogs.recommended-channels" -pr = 5 -reason = "Similar-channel suggestions are a discovery surface (PR-5)." - -[[id]] -id = "dialogs.resolve-peer" -pr = 5 -reason = "Turning a @username, a phone number or a t.me link into a chat is `resolve` (PR-5); the chat group consumes the resolver rather than exposing it." +pr = 7 +reason = "Similar-channel suggestions are `channels.getChannelRecommendations`, a channel surface (PR-7)." [[id]] id = "dialogs.saved-tags" pr = 9 reason = "Saved-Messages reaction tags are reactions (PR-9)." -[[id]] -id = "dialogs.sponsored-search-peers" -pr = 10 -reason = "Sponsored peers in search are the ads surface (PR-10)." - -[[id]] -id = "dialogs.top-peers-toggle" -pr = 5 -reason = "Frequent-contact suggestions are the contact group (PR-5)." - [[id]] id = "dialogs.typing-watch" pr = 4 reason = "Watching who is typing is an update stream (PR-4); sending one is `chat typing`." -[[id]] -id = "dialogs.unblock-user" -pr = 5 -reason = "Unblocking is `user unblock` (PR-5)." - [[id]] id = "dialogs.wallpaper-gallery" pr = 6 @@ -846,3 +761,82 @@ reason = "contacts.resetTopPeerRating is the same surface as top-callers (PR-5). id = "groupcall.speaking-indicator" pr = 3 reason = "speakingInGroupCallAction is broadcast with messages.setTyping, i.e. the chat-action surface `chat typing` (PR-3) — and tlgr has no microphone behind it in any case." +# --------------------------------------------------------------------------- +# The contacts_users ids PR-5 does not own. The domain-wide waiver is gone, +# so each of these names the group that does own it. +# --------------------------------------------------------------------------- + +[[id]] +id = "contacts-users.privacy-about" +pr = 12 +reason = "Privacy keys are the `privacy` group (PR-12)." + +[[id]] +id = "contacts-users.privacy-added-by-phone" +pr = 12 +reason = "Privacy keys are the `privacy` group (PR-12); `contact add --share-phone` is the per-user exception." + +[[id]] +id = "contacts-users.privacy-chat-invite" +pr = 12 +reason = "Privacy keys are the `privacy` group (PR-12)." + +[[id]] +id = "contacts-users.privacy-exception-lists" +pr = 12 +reason = "Always/Never lists are privacy rules (PR-12); the close-friends list is `contact close-friends`." + +[[id]] +id = "contacts-users.privacy-forwards" +pr = 12 +reason = "Privacy keys are the `privacy` group (PR-12)." + +[[id]] +id = "contacts-users.privacy-gifts" +pr = 12 +reason = "Gift privacy is the `privacy` group (PR-12)." + +[[id]] +id = "contacts-users.privacy-global" +pr = 12 +reason = "`privacy global set` is the account-wide privacy surface (PR-12)." + +[[id]] +id = "contacts-users.privacy-no-paid-messages" +pr = 12 +reason = "Paid-message privacy is a privacy key (PR-12); reading the price is `user can-message`." + +[[id]] +id = "contacts-users.privacy-phone-number" +pr = 12 +reason = "Privacy keys are the `privacy` group (PR-12)." + +[[id]] +id = "contacts-users.privacy-voice-messages" +pr = 12 +reason = "Privacy keys are the `privacy` group (PR-12)." + +[[id]] +id = "contacts-users.user-status-reveal" +pr = 12 +reason = "Revealing my own last-seen to see theirs is a privacy setting (PR-12); `contact status list` reports the by_me flag that explains it." + +[[id]] +id = "contacts-users.user-business-greeting-away" +pr = 12 +reason = "Business greeting and away messages are the `business` group (PR-12)." + +[[id]] +id = "contacts-users.user-stories" +pr = 8 +reason = "A user's stories are the `story` group (PR-8); hiding them is `user hide-stories`." + +[[id]] +id = "contacts-users.people-you-may-know" +pr = 7 +reason = "Suggested peers come from channels.getChannelRecommendations, a channel surface (PR-7)." + +[[id]] +id = "contacts-users.url-auth-login" +pr = 10 +reason = "URL authorization is a bot surface (PR-10); `resolve link` classifies the link and delegates." diff --git a/tlgr/models/__init__.py b/tlgr/models/__init__.py index e2f1ccf..da1313a 100644 --- a/tlgr/models/__init__.py +++ b/tlgr/models/__init__.py @@ -91,6 +91,39 @@ ValidationIssue, ValidationReport, ) +from tlgr.models.contact import ( + BlockedPeer, + BlockedSet, + BlockResult, + CloseFriends, + Contact, + ContactAdded, + ContactImport, + ContactNote, + ContactRemoved, + ContactRenamed, + ContactRequirement, + ContactShared, + ContactSync, + DialogStatus, + FoundPeer, + ImportedPhone, + MusicTrack, + PersonalChannel, + PhoneShared, + PhotoResult, + ProfilePhoto, + SavedPhoneContact, + SignUp, + StoriesHidden, + StoriesHiddenPeer, + SuggestedBirthday, + TopPeer, + TopPeerState, + UserLink, + UserProfile, + UserStatus, +) from tlgr.models.daemon import ( AccountHealth, DaemonStatus, @@ -301,6 +334,14 @@ ReactionUser, TopReactor, ) +from tlgr.models.resolve import ( + CachedPeerRow, + LinkKind, + ResolvedLink, + ResolvedPhone, + ResolvedRef, + ResolvedUsername, +) from tlgr.models.sticker import ( EmojiGroup, EmojiKeyword, @@ -354,7 +395,11 @@ "AvailableReaction", "BackfillPage", "Badge", + "BlockResult", + "BlockedPeer", + "BlockedSet", "Button", + "CachedPeerRow", "Call", "CallConfig", "CallDebugUpload", @@ -383,6 +428,7 @@ "ChatlistJoin", "ChatlistUpdates", "ClearResult", + "CloseFriends", "ComposeResult", "ConferenceCreated", "ConferenceDeclined", @@ -395,6 +441,15 @@ "ConfigKey", "ConfigPaths", "ConfigValue", + "Contact", + "ContactAdded", + "ContactImport", + "ContactNote", + "ContactRemoved", + "ContactRenamed", + "ContactRequirement", + "ContactShared", + "ContactSync", "ContentSettings", "ContentSettingsSaved", "Country", @@ -408,6 +463,7 @@ "DeleteResult", "DeviceLock", "Dialog", + "DialogStatus", "DiceCatalog", "DifferenceResult", "Downloaded", @@ -438,6 +494,7 @@ "FolderOrder", "Forward", "ForwardedMessage", + "FoundPeer", "GameInfo", "GameScore", "GeoPoint", @@ -457,6 +514,7 @@ "GroupCallStarted", "HealthSummary", "ImportState", + "ImportedPhone", "InCallMessage", "InCallMessagesDeleted", "InfoTopic", @@ -466,6 +524,7 @@ "JobTestFrame", "LeaveResult", "LifecycleResult", + "LinkKind", "LinkResult", "LiveLocation", "LiveStopped", @@ -492,6 +551,7 @@ "MessageReactionState", "Meta", "Model", + "MusicTrack", "MuteResult", "MuteState", "Nearby", @@ -529,8 +589,11 @@ "PeerRef", "PeerRefKind", "PeerResult", + "PersonalChannel", "PhoneChange", + "PhoneShared", "Photo", + "PhotoResult", "PinResult", "PingResult", "PinnedDialogs", @@ -540,6 +603,7 @@ "PollVoter", "Poster", "PosterReport", + "ProfilePhoto", "Promo", "PromoData", "Proxy", @@ -567,11 +631,16 @@ "ReportResult", "Request", "ResetResult", + "ResolvedLink", + "ResolvedPhone", + "ResolvedRef", + "ResolvedUsername", "Rights", "RtmpInfo", "SaveStateResult", "SavedDialog", "SavedGif", + "SavedPhoneContact", "SavedState", "ScheduledSent", "SecretChat", @@ -584,6 +653,7 @@ "SessionChange", "SessionTermination", "ShareDeleted", + "SignUp", "SmsJobs", "SponsoredHidden", "SponsoredMessage", @@ -593,8 +663,11 @@ "StickerSetsChanged", "StorageCleared", "StorageUsage", + "StoriesHidden", + "StoriesHiddenPeer", "StreamChannel", "StreamDownload", + "SuggestedBirthday", "SuggestedFolder", "SuggestedPostState", "Suggestion", @@ -610,6 +683,8 @@ "Todo", "TodoTask", "Tone", + "TopPeer", + "TopPeerState", "TopReactor", "Transcription", "Transfer", @@ -623,7 +698,10 @@ "Unset", "Uploaded", "User", + "UserLink", + "UserProfile", "UserRef", + "UserStatus", "ValidationIssue", "ValidationReport", "Venue", diff --git a/tlgr/models/contact.py b/tlgr/models/contact.py new file mode 100644 index 0000000..a6867a0 --- /dev/null +++ b/tlgr/models/contact.py @@ -0,0 +1,488 @@ +"""Contacts, users and blocking — the address-book side of the model. + +Three shapes here exist because Telegram's own answers are ambiguous and a +CLI must not pass that ambiguity on as a fact. + +* **`UserStatus.by_me`.** `userStatusRecently` is a *coarse bucket*, and the + reason it is coarse is usually MY last-seen privacy, not theirs. The flag + is carried through so nothing reports "they hid from you" when the truth is + "you hid from everyone". +* **`ContactAdded.retry` and `imported`.** An empty `imported` means the + number has no account **or** its owner refuses phone lookups. Both lists + are reported rather than collapsed into a boolean. +* **`DialogStatus` is three-valued.** `resolved=false` carries + `has_dialog=null`, never `false`. AGENT.md freezes this and + `tests/test_ops_contacts.py` holds the line. + +Access hashes never appear. `access_hash_cached` says whether one is held; +the value itself is per-login-session state that has no business in output a +human pastes into a bug report. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from tlgr.models.base import Model +from tlgr.models.message import Message +from tlgr.models.peer import Peer, Photo + +__all__ = [ + "BlockResult", + "BlockedPeer", + "BlockedSet", + "CloseFriends", + "Contact", + "ContactAdded", + "ContactImport", + "ContactModel", + "ContactNote", + "ContactRemoved", + "ContactRenamed", + "ContactRequirement", + "ContactShared", + "ContactSync", + "DialogStatus", + "FoundPeer", + "ImportedPhone", + "MusicTrack", + "PersonalChannel", + "PhoneShared", + "PhotoResult", + "ProfilePhoto", + "SavedPhoneContact", + "SignUp", + "StoriesHidden", + "StoriesHiddenPeer", + "SuggestedBirthday", + "TopPeer", + "TopPeerState", + "UserLink", + "UserProfile", + "UserStatus", +] + +StatusKind = Literal["online", "offline", "recently", "last_week", "last_month", "empty"] + + +class ContactModel(Model, omit_defaults=False): + """A contact/user shape that emits every field, including the false ones. + + `Model` drops defaults so that "absent" can mean "not applicable"; here + the opposite is true. `already: false`, `resolved: false`, `has_dialog: + null`, `added: false` and `hidden: false` are the *answer*, and AGENT.md + publishes them — a caller that reads a missing key as "not set" would + re-introduce exactly the three-valued confusion this group exists to + remove. + """ + + +class UserStatus(ContactModel): + """Online / last-seen, with the honesty flag Telegram attaches to it. + + `by_me` is set on the coarse buckets (`recently`, `last_week`, + `last_month`) when the reason the answer is coarse is *our own* + last-seen privacy. Reporting that as "they are hiding from you" is a + conclusion the data does not support. + """ + + user_id: int = 0 + kind: StatusKind = "empty" + expires: str | None = None + expires_unix: int | None = None + was_online: str | None = None + was_online_unix: int | None = None + by_me: bool = False + + +class Contact(ContactModel): + """A row of the contact list. + + `phone` is present only where privacy allows it, which is why it is + nullable rather than empty-string: "hidden" and "has none" are different. + """ + + id: int + raw_id: int = 0 + first_name: str | None = None + last_name: str | None = None + name: str = "" + username: str | None = None + usernames: list[str] = [] + phone: str | None = None + mutual: bool = False + close_friend: bool = False + premium: bool = False + bot: bool = False + deleted: bool = False + verified: bool = False + scam: bool = False + fake: bool = False + stories_hidden: bool = False + has_unseen_stories: bool | None = None + status: UserStatus | None = None + birthday: str | None = None + age: int | None = None + note: str | None = None + #: The total size of the server-side phonebook, echoed on every row's + #: page rather than per row; `contact list --ids-only` reports it alone. + saved_count: int | None = None + + +class ContactAdded(ContactModel): + """The reply to `contact add`, keeping v1's `added`/`user_id` keys. + + `imported` empty with `retry` empty is the ambiguous case: the number has + no Telegram account, or its owner hides it behind + `inputPrivacyKeyAddedByPhone`. `reason` says which one we can and cannot + tell apart. + """ + + added: bool = False + user_id: int | None = None + first_name: str | None = None + last_name: str | None = None + imported: list[int] = [] + retry: list[int] = [] + popular_importers: int | None = None + shared_phone: bool = False + note: str | None = None + reason: str | None = None + + +class ContactRenamed(ContactModel): + """v1's shape, unchanged: this is only *our* view of their name.""" + + saved: bool = True + user_id: int = 0 + first_name: str = "" + last_name: str = "" + + +class ContactRemoved(ContactModel): + removed: bool = False + user_ids: list[int] = [] + phones: list[str] = [] + + +class ContactNote(ContactModel): + user_id: int = 0 + note: str | None = None + cleared: bool = False + + +class ImportedPhone(ContactModel): + """One line of a phonebook import, and what the server made of it.""" + + phone: str = "" + first_name: str = "" + last_name: str = "" + user_id: int | None = None + importers: int | None = None + retry: bool = False + + +class ContactImport(ContactModel): + """`contact import`, reporting the retry list rather than swallowing it. + + `retry` is not an error list: the server asks for those numbers to be + sent again later, and a caller that drops them silently loses contacts. + """ + + parsed: int = 0 + imported: list[ImportedPhone] = [] + retry: list[ImportedPhone] = [] + popular_invites: list[ImportedPhone] = [] + batches: int = 0 + flood_waits: int = 0 + dry_run: bool = False + + +class ContactSync(ContactModel): + """The diff between a local phonebook file and the server's list.""" + + to_import: list[ImportedPhone] = [] + to_delete: list[str] = [] + applied: bool = False + imported: int = 0 + deleted: int = 0 + + +class SavedPhoneContact(ContactModel): + """A number this account once uploaded, whether or not it has an account.""" + + phone: str = "" + first_name: str = "" + last_name: str = "" + date: str | None = None + date_unix: int | None = None + has_account: bool | None = None + invite_text: str | None = None + + +class BlockedPeer(ContactModel): + peer: Peer + date: str | None = None + date_unix: int | None = None + kind: Literal["main", "stories"] = "main" + + +class BlockResult(ContactModel): + """`user block` / `user unblock`. `already` means no RPC was needed.""" + + peer_id: int = 0 + blocked: bool = False + stories_only: bool = False + already: bool = False + deleted: bool = False + reported: bool = False + + +class BlockedSet(ContactModel): + """`contact blocked set` — a replacement, so the diff is the answer.""" + + count: int = 0 + blocked: list[int] = [] + unblocked: list[int] = [] + kind: Literal["main", "stories"] = "main" + applied: bool = False + + +class CloseFriends(ContactModel): + user_ids: list[int] = [] + count: int = 0 + contacts: list[Contact] = [] + + +class SignUp(ContactModel): + """A contact who joined Telegram, found as a service message.""" + + user_id: int = 0 + name: str = "" + username: str | None = None + chat_id: int = 0 + msg_id: int = 0 + date: str | None = None + date_unix: int | None = None + #: The account-wide "X joined Telegram" notification switch, echoed on + #: the page so `--notify on|off` has somewhere to report its result. + notify: bool | None = None + + +class TopPeer(ContactModel): + peer: Peer + category: str = "correspondents" + rating: float = 0.0 + + +class TopPeerState(ContactModel): + enabled: bool | None = None + reset_peer: int | None = None + category: str | None = None + disabled_by_user: bool = False + + +class FoundPeer(ContactModel): + """A `contacts.search` hit, labelled with where it came from. + + `source` is the whole point: `mine` is a contact or an already-known + peer, `global` is a public username match, `recent` is local search + history and `sponsored` is an ad. Merging them into one list without the + label is how a CLI ends up presenting an advert as a contact. + """ + + peer: Peer + source: Literal["mine", "global", "recent", "sponsored", "tme"] = "mine" + sponsored: bool = False + random_id: str | None = None + url: str | None = None + + +class ContactRequirement(ContactModel): + """Can I message this user, and at what price?""" + + user_id: int = 0 + result: Literal["free", "premium", "paid", "unknown"] = "unknown" + stars_amount: int | None = None + contact_require_premium: bool | None = None + + +class DialogStatus(ContactModel): + """SEMANTICS FROZEN (AGENT.md). Three answers, never conflated. + + `resolved=true, has_dialog=true` — a dialog exists; `message_count` is + the server's exact total. + `resolved=true, has_dialog=false` — definitively none, because the + account's *complete* dialog list was enumerated. + `resolved=false, has_dialog=null` — could not be established; exit 13. + + `has_dialog` is deliberately `bool | None`: there is no third boolean, + and a caller that reads `null` as `false` re-introduces the cold-contact + bug this command exists to remove. + """ + + ref: str = "" + id: int | None = None + username: str | None = None + resolved: bool = False + has_dialog: bool | None = None + message_count: int | None = None + source: str = "unknown" + reason: str | None = None + scanned_dialogs: int | None = None + + +class StoriesHiddenPeer(ContactModel): + user_id: int = 0 + username: str | None = None + hidden: bool = False + already: bool = False + + +class StoriesHidden(ContactModel): + """SEMANTICS FROZEN (AGENT.md): v1's four keys, plus a bulk tail. + + A single target answers exactly as v1 did. Extra targets appear in + `peers`, so a bulk pass stays one command without changing the shape the + documented single-peer call returns. + """ + + user_id: int = 0 + username: str | None = None + hidden: bool = False + already: bool = False + peers: list[StoriesHiddenPeer] = [] + all_hidden: bool | None = None + + +class UserProfile(ContactModel): + """`user get` — v1's keys, plus everything `users.getFullUser` carries. + + v1's `id`, `first_name`, `last_name`, `username`, `phone`, `bio`, + `is_bot`, `status`, `has_photo`, `deleted` and `stories_hidden` are all + still here and still mean the same thing; `status` stays the short + lowercase string v1 printed and `status_detail` carries the structured + form. + """ + + id: int + raw_id: int = 0 + kind: Literal["user", "bot"] = "user" + first_name: str = "" + last_name: str = "" + name: str = "" + username: str | None = None + usernames: list[str] = [] + phone: str | None = None + bio: str = "" + bio_translated: str | None = None + note: str | None = None + birthday: str | None = None + status: str = "" + status_detail: UserStatus | None = None + is_self: bool = False + is_bot: bool = False + is_contact: bool = False + is_mutual_contact: bool = False + is_close_friend: bool = False + is_premium: bool = False + is_support: bool = False + is_verified: bool = False + is_scam: bool = False + is_fake: bool = False + deleted: bool = False + restricted: bool = False + restriction_reason: list[str] = [] + has_photo: bool = False + stories_hidden: bool = False + lang_code: str | None = None + photo: Photo | None = None + personal_photo: Photo | None = None + fallback_photo: Photo | None = None + emoji_status_id: int | None = None + colors: dict[str, Any] | None = None + #: True only when `users.getFullUser` ran. Everything below it is null + #: until it does, and this flag is how "not asked" is told apart from + #: "asked, and the answer was nothing". + full: bool = False + blocked: bool | None = None + blocked_my_stories_from: bool | None = None + common_chats_count: int | None = None + personal_channel_id: int | None = None + personal_channel_message_id: int | None = None + contact_require_premium: bool | None = None + send_paid_messages_stars: int | None = None + stargifts_count: int | None = None + stars_rating: int | None = None + main_tab: str | None = None + unofficial_security_risk: bool | None = None + business_hours: dict[str, Any] | None = None + business_location: str | None = None + business_intro: dict[str, Any] | None = None + wallpaper: str | None = None + action_bar: dict[str, Any] | None = None + #: Whether this account holds a usable access hash for them. The hash + #: itself is never printed: it is per-login-session and worthless (and + #: dangerous) anywhere else. + access_hash_cached: bool = False + min: bool = False + + +class SuggestedBirthday(ContactModel): + user_id: int = 0 + birthday: str = "" + sent: bool = False + + +class UserLink(ContactModel): + url: str = "" + kind: str = "profile" + expires: str | None = None + expires_unix: int | None = None + + +class MusicTrack(ContactModel): + id: int = 0 + title: str | None = None + performer: str | None = None + duration: int | None = None + mime_type: str | None = None + size: int | None = None + file: str | None = None + + +class ProfilePhoto(ContactModel): + id: int = 0 + date: str | None = None + date_unix: int | None = None + sizes: list[str] = [] + video: bool = False + dc_id: int | None = None + file: str | None = None + + +class PhotoResult(ContactModel): + user_id: int = 0 + photo_id: int | None = None + suggested: bool = False + reset: bool = False + + +class PersonalChannel(ContactModel): + """The channel a user pinned to their profile, with a post preview.""" + + user_id: int = 0 + channel: Peer | None = None + msg_id: int | None = None + posts: list[Message] = [] + + +class ContactShared(ContactModel): + chat_id: int = 0 + msg_id: int = 0 + contact: Contact | None = None + + +class PhoneShared(ContactModel): + user_id: int = 0 + shared: bool = False diff --git a/tlgr/models/resolve.py b/tlgr/models/resolve.py new file mode 100644 index 0000000..2ca14db --- /dev/null +++ b/tlgr/models/resolve.py @@ -0,0 +1,175 @@ +"""Resolution results: what a reference, a link or a cache entry turns into. + +`resolve` is the one group whose whole job is to be honest about *how* an +answer was reached, so every shape here carries provenance: + +* `ResolvedRef.source` says which strategy answered (cache, username, phone, + dialog scan, arithmetic on a `t.me/c/` link). +* `ResolvedPhone.reason` exists because `PHONE_NOT_OCCUPIED` is genuinely + ambiguous — no account, or an owner who refuses phone lookups — and the op + exits 13 rather than claiming "not found". +* `ResolvedLink.delegated_to` names the command that would *act* on a link. + Resolution never joins, starts, installs, boosts or redeems anything; it + says what the link is and which verb would. + +No access hash is ever emitted, only `access_hash_cached`. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from tlgr.models.base import Model +from tlgr.models.peer import Peer + +__all__ = [ + "CachedPeerRow", + "LinkKind", + "ResolvedLink", + "ResolvedPhone", + "ResolvedRef", + "ResolvedUsername", +] + +#: Every shape a t.me / tg:// reference can take. `unknown` is a real answer: +#: Telegram adds deep links faster than any client learns them, and reporting +#: `unknown` with the raw path beats guessing wrong. +LinkKind = Literal[ + "public-username", + "phone", + "invite", + "chatlist-invite", + "message", + "private-post", + "story", + "bot-start", + "bot-startgroup", + "bot-startchannel", + "webapp", + "business-chat-link", + "contact-token", + "proxy", + "boost", + "giftcode", + "unique-gift", + "stars-topup", + "wallpaper", + "theme", + "stickerset", + "emojiset", + "share-url", + "settings-section", + "contacts-section", + "login-code", + "confirm-phone", + "invoice", + "premium-offer", + "folder", + "unknown", +] + + +class ResolvedRef(Model, omit_defaults=False): + """One `resolve peer` answer, with the strategy that produced it.""" + + ref: str = "" + kind: str = "" + id: int | None = None + marked_id: int | None = None + botapi_id: int | None = None + type: str = "" + title: str = "" + username: str | None = None + access_hash_cached: bool = False + min: bool = False + source: str = "" + resolved: bool = False + reason: str | None = None + + +class ResolvedUsername(Model, omit_defaults=False): + kind: str = "" + peer: Peer | None = None + username: str = "" + access_hash_cached: bool = False + + +class ResolvedPhone(Model, omit_defaults=False): + """A phone lookup. `resolved=false` with a `reason` is exit 13, not 5.""" + + phone: str = "" + e164: str = "" + country: str | None = None + prefix: str | None = None + pattern: str | None = None + resolved: bool = False + peer: Peer | None = None + reason: str | None = None + countries: list[dict[str, Any]] = [] + + +class ResolvedLink(Model): + """A t.me / tg:// link, classified and (optionally) read. + + One discriminated shape rather than one command per link type: a human + pasting a link does not know which of twenty kinds it is, and that is + precisely the question. + """ + + kind: LinkKind + raw_url: str = "" + scheme: str = "" + peer: Peer | None = None + username: str | None = None + phone: str | None = None + invite_hash: str | None = None + chatlist_slug: str | None = None + msg_id: int | None = None + thread_id: int | None = None + comment_id: int | None = None + story_id: int | None = None + bot: str | None = None + start_param: str | None = None + start_target: str | None = None + boost: bool | None = None + gift: str | None = None + stars: int | None = None + proxy: dict[str, Any] | None = None + theme: str | None = None + wallpaper: str | None = None + stickerset: str | None = None + share: dict[str, Any] | None = None + contact_token: str | None = None + section: str | None = None + deeplink_info: str | None = None + title: str | None = None + #: Set only with `--open`: the follow-up read for the classified kind. + opened: dict[str, Any] | None = None + requires_action: bool = False + #: The command that would act on this link. Naming it is the alternative + #: to acting: joining, starting a bot or redeeming a gift are separate, + #: confirmed verbs in their own groups. + delegated_to: str | None = None + draft_saved: bool = False + + +class CachedPeerRow(Model, omit_defaults=False): + """One entry of the per-account resolver cache. + + `min_context` is the `(chat, message)` where a `min` user was seen — + Telethon stores none, and without it a stranger who posted in a channel + is unaddressable. + """ + + id: int = 0 + marked_id: int = 0 + type: str = "" + username: str | None = None + phone: str | None = None + access_hash_cached: bool = False + min: bool = False + min_context: str | None = None + seen_at: str | None = None + seen_at_unix: int | None = None + refreshed: bool = False + purged: bool = False diff --git a/tlgr/ops/contact.py b/tlgr/ops/contact.py new file mode 100644 index 0000000..893f166 --- /dev/null +++ b/tlgr/ops/contact.py @@ -0,0 +1,2330 @@ +"""The `contact` group: the address book, the blocklist and the phonebook. + +Four things about Telegram's contact API shape this module. + +* **Adding a contact is two different methods.** `contacts.addContact` takes + a user you can already address; `contacts.importContacts` takes a raw + phone number. They fail differently and they answer differently, and an + empty `imported` is *ambiguous* — the number may have no account, or its + owner may refuse phone lookups — so both lists come back rather than a + boolean. +* **Several "edit" calls are replacements.** `contacts.setBlocked` and + `contacts.editCloseFriends` overwrite the whole list, so everything here + reads the current state, prints the diff and writes the union — never a + bare append, which is how a client silently unblocks everyone. +* **A contact's name is only ever *our* view of it.** `contacts.addContact` + on someone who is already a contact rewrites the local name and touches + nothing on their profile. v1 leaned on that for tagging and so does this. +* **Phone numbers are privacy-bearing.** They appear only where the server + chose to send one, and `--redact` blanks them like any other secret. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import contextlib +import json +import re +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Any + +from tlgr.core.errors import NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page, decode_cursor +from tlgr.core.paths import write_private +from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix +from tlgr.models.base import Request +from tlgr.models.contact import ( + BlockedPeer, + BlockedSet, + CloseFriends, + Contact, + ContactAdded, + ContactImport, + ContactNote, + ContactRemoved, + ContactRenamed, + ContactShared, + ContactSync, + FoundPeer, + ImportedPhone, + PhoneShared, + SavedPhoneContact, + SignUp, + TopPeer, + TopPeerState, + UserStatus, +) +from tlgr.models.page import Page +from tlgr.models.peer import Peer, PeerRef +from tlgr.ops import _send +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._serialize import entity_to_peer, peer_id_of +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: `contacts.importContacts` is one of the most flood-limited methods there +#: is; official clients send a few hundred per call and pause between them. +IMPORT_BATCH = 200 + +#: The rating categories `contacts.getTopPeers` splits its answer into, in +#: the CLI's spelling. The key is the request flag Telethon expects. +TOP_CATEGORIES: dict[str, str] = { + "correspondents": "correspondents", + "bots-pm": "bots_pm", + "bots-inline": "bots_inline", + "bots-app": "bots_app", + "bots-guestchat": "bots_guestchat", + "calls": "phone_calls", + "forward-users": "forward_users", + "forward-chats": "forward_chats", + "groups": "groups", + "channels": "channels", +} + +#: category name → the `TopPeerCategory*` constructor `resetTopPeerRating` +#: and the reply both use. +_TOP_TYPES: dict[str, str] = { + "correspondents": "TopPeerCategoryCorrespondents", + "bots-pm": "TopPeerCategoryBotsPM", + "bots-inline": "TopPeerCategoryBotsInline", + "bots-app": "TopPeerCategoryBotsApp", + "bots-guestchat": "TopPeerCategoryBotsGuestChat", + "calls": "TopPeerCategoryPhoneCalls", + "forward-users": "TopPeerCategoryForwardUsers", + "forward-chats": "TopPeerCategoryForwardChats", + "groups": "TopPeerCategoryGroups", + "channels": "TopPeerCategoryChannels", +} + +_EXAMPLE_CONTACT: dict[str, Any] = { + "id": 777123, + "raw_id": 777123, + "name": "Alice", + "username": "alice", + "phone": "+15550001111", + "mutual": True, +} + +_PHONE_CHARS = re.compile(r"[^0-9+]") + + +# --------------------------------------------------------------------------- +# Shared helpers — `user.py` and `resolve.py` import from here +# --------------------------------------------------------------------------- + + +def client_of(ctx: OpContext) -> Any: + client = getattr(ctx, "client", None) + if client is None: # pragma: no cover - the daemon always supplies one + raise UsageError("this operation needs a connected account") + return client + + +def mark_already(ctx: OpContext) -> None: + mark = getattr(ctx, "mark_already", None) + if callable(mark): + mark() + + +def e164(phone: str) -> str: + """`(555) 000-1111` → `+15550001111`. Format only; nothing is looked up.""" + cleaned = _PHONE_CHARS.sub("", (phone or "").strip()) + digits = cleaned.lstrip("+") + return f"+{digits}" if digits else "" + + +async def input_user( + ctx: OpContext, + ref: PeerRef | str, + *, + from_chat: PeerRef | None = None, + from_message: int | None = None, +) -> Any: + """The `InputUser` for *ref*, including the `min` form Telethon never builds. + + A user seen only inside a channel message carries no usable access hash. + `--from-chat/--from-message` is what turns that into + `inputUserFromMessage`, and it is the difference between `chat posters` + producing ids and producing something you can act on. + """ + from telethon import utils + from telethon.tl import types + + if from_chat is not None and from_message is not None: + container = await _send.resolve(ctx, from_chat) + raw = str(getattr(ref, "value", ref)).lstrip("@") + if not raw.lstrip("-").isdigit(): + raise UsageError( + "--from-chat/--from-message address a user by id; pass the numeric id", + field="user", + ) + return types.InputUserFromMessage( + peer=container, msg_id=int(from_message), user_id=abs(int(raw)) + ) + + peer = await _send.resolve(ctx, ref) + try: + return utils.get_input_user(peer) + except (TypeError, ValueError) as exc: + raise UsageError( + f"{getattr(ref, 'raw', ref)!r} is a chat, not a user", field="user" + ) from exc + + +async def fetch_user(ctx: OpContext, target: Any) -> Any: + """The full `User` object behind an `InputUser`. + + Used wherever a command has to read the *current* state before writing — + the existing name for a rename, `stories_hidden` for the idempotent + hide — because writing a value the server already holds is an RPC and a + flood-budget entry for nothing. + """ + from telethon.tl.functions import users as ufn + + found = await client_of(ctx)(ufn.GetUsersRequest(id=[target])) + for user in list(found or []): + if type(user).__name__ == "User": + return user + raise NotFoundError("that user could not be read back from the server") + + +def display_name(user: Any) -> str: + first = getattr(user, "first_name", "") or "" + last = getattr(user, "last_name", "") or "" + return f"{first} {last}".strip() + + +def status_model(user_id: int, status: Any) -> UserStatus: + """`userStatus*` as a model, keeping `by_me` intact. + + `userStatusRecently` and friends are coarse *because of a privacy + setting*, and `by_me` says the setting is ours. Dropping it is how a + client concludes "they hid from you" about someone who did nothing. + """ + name = type(status).__name__ + kind = { + "UserStatusOnline": "online", + "UserStatusOffline": "offline", + "UserStatusRecently": "recently", + "UserStatusLastWeek": "last_week", + "UserStatusLastMonth": "last_month", + }.get(name, "empty") + expires = getattr(status, "expires", None) + was = getattr(status, "was_online", None) + return UserStatus( + user_id=user_id, + kind=kind, # type: ignore[arg-type] + expires=fmt_dt(expires), + expires_unix=to_unix(expires), + was_online=fmt_dt(was), + was_online_unix=to_unix(was), + by_me=bool(getattr(status, "by_me", False)), + ) + + +def status_word(status: Any) -> str: + """v1's short lowercase status string (`online`, `offline`, `recently`).""" + return type(status).__name__.replace("UserStatus", "").lower() if status else "" + + +def birthday_text(birthday: Any) -> str | None: + """`birthday` as `YYYY-MM-DD`, or `MM-DD` when the year is withheld.""" + if birthday is None: + return None + day = int(getattr(birthday, "day", 0) or 0) + month = int(getattr(birthday, "month", 0) or 0) + if not day or not month: + return None + year = getattr(birthday, "year", None) + return f"{year:04d}-{month:02d}-{day:02d}" if year else f"{month:02d}-{day:02d}" + + +def birthday_age(birthday: Any, *, today: datetime | None = None) -> int | None: + year = getattr(birthday, "year", None) if birthday is not None else None + if not year: + return None + now = today or datetime.now(timezone.utc) + month = int(getattr(birthday, "month", 1) or 1) + day = int(getattr(birthday, "day", 1) or 1) + age = now.year - int(year) - ((now.month, now.day) < (month, day)) + return age if age >= 0 else None + + +def contact_model(user: Any, *, mutual: bool | None = None) -> Contact: + """A Telethon `User` as a contact row.""" + raw_id = int(getattr(user, "id", 0) or 0) + status = getattr(user, "status", None) + return Contact( + id=raw_id, + raw_id=raw_id, + first_name=getattr(user, "first_name", None), + last_name=getattr(user, "last_name", None), + name=display_name(user), + username=getattr(user, "username", None), + usernames=[ + u.username + for u in (getattr(user, "usernames", None) or []) + if getattr(u, "username", None) + ], + # Telegram sends a bare number; tlgr emits E.164 everywhere. + phone=e164(getattr(user, "phone", "") or "") or None, + mutual=bool(getattr(user, "mutual_contact", False)) if mutual is None else bool(mutual), + close_friend=bool(getattr(user, "close_friend", False)), + premium=bool(getattr(user, "premium", False)), + bot=bool(getattr(user, "bot", False)), + deleted=bool(getattr(user, "deleted", False)), + verified=bool(getattr(user, "verified", False)), + scam=bool(getattr(user, "scam", False)), + fake=bool(getattr(user, "fake", False)), + stories_hidden=bool(getattr(user, "stories_hidden", False)), + status=status_model(raw_id, status) if status is not None else None, + ) + + +def peers_by_id(*collections: Any) -> dict[int, Any]: + """`{marked id: entity}` for the users/chats a reply carried with it.""" + from telethon import utils + + out: dict[int, Any] = {} + for collection in collections: + for entity in collection or []: + with contextlib.suppress(TypeError, ValueError): + out[int(utils.get_peer_id(entity))] = entity + return out + + +def peer_model(peer: Any, known: dict[int, Any]) -> Peer: + """A bare `Peer` resolved against the entities the same reply carried.""" + marked = peer_id_of(peer) + entity = known.get(marked or 0) + if entity is not None: + return entity_to_peer(entity) + return Peer(id=marked or 0, raw_id=abs(marked or 0), kind="unknown") + + +async def load_contacts(ctx: OpContext) -> tuple[list[Any], list[Any], int]: + """`(contacts, users, saved_count)` from `contacts.getContacts`. + + `hash=0` because Telethon has no store to diff against; the cheap drift + check is `contact list --ids-only`, which is `getContactIDs`. + """ + from telethon.tl.functions import contacts as fn + + result = await client_of(ctx)(fn.GetContactsRequest(hash=0)) + if type(result).__name__ == "ContactsNotModified": # pragma: no cover - hash is always 0 + return [], [], 0 + return ( + list(getattr(result, "contacts", None) or []), + list(getattr(result, "users", None) or []), + int(getattr(result, "saved_count", 0) or 0), + ) + + +def _window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]: + """`(limit, cursor state)` — `--limit`/`--cursor` are transport-level (L5).""" + limit = int(getattr(ctx, "limit", None) or default) + if limit < 1: + raise UsageError("--limit must be at least 1", field="limit") + token = getattr(ctx, "cursor", None) + state: dict[str, Any] = {} + if token: + state = decode_cursor(token, op=op, kind=kind, account=ctx.account) + return min(limit, 1000), state + + +def _slice(items: list[Any], ctx: OpContext, op: str, offset: int, limit: int) -> Page[Any]: + """One page out of a list we already hold in full.""" + window = items[offset : offset + limit] + return build_page( + window, + op=op, + kind=PageKind.LOCAL, + state={"offset": offset + len(window)}, + account=ctx.account, + has_more=offset + len(window) < len(items), + total=len(items), + ) + + +# --------------------------------------------------------------------------- +# Phonebook files +# --------------------------------------------------------------------------- + + +def _read_file(source: str, field: str) -> str: + """Read a phonebook the daemon can reach. + + `-` is refused rather than silently read: the implementation runs inside + the daemon, so "stdin" there is the daemon's stdin, not the caller's. + """ + if source.strip() == "-": + raise UsageError( + "'-' reads the caller's stdin, and this operation runs in the daemon; " + "write the phonebook to a file and pass its path", + field=field, + ) + path = Path(source).expanduser() + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + raise UsageError(f"{source}: {exc.strerror or exc}", field=field) from exc + + +def parse_phonebook(text: str) -> list[ImportedPhone]: + """vCard, CSV or one-number-per-line, into the same list. + + Deliberately forgiving about the input and strict about the output: every + entry ends up with an E.164 number and a first name, because + `importContacts` rejects an empty name with `CONTACT_NAME_EMPTY` and a + whole batch fails for one bad row. + """ + body = text.strip() + if not body: + return [] + if "BEGIN:VCARD" in body.upper(): + return _parse_vcard(body) + return _parse_csv(body) + + +def _parse_vcard(text: str) -> list[ImportedPhone]: + out: list[ImportedPhone] = [] + first = last = "" + phone = "" + for raw_line in text.splitlines(): + line = raw_line.strip() + upper = line.upper() + if upper.startswith("BEGIN:VCARD"): + first = last = phone = "" + elif upper.startswith("N:"): + parts = line.split(":", 1)[1].split(";") + last = parts[0].strip() if parts else "" + first = parts[1].strip() if len(parts) > 1 else "" + elif upper.startswith("FN:") and not first: + words = line.split(":", 1)[1].strip().split(maxsplit=1) + first = words[0] if words else "" + last = words[1] if len(words) > 1 else last + elif upper.startswith("TEL") and ":" in line: + phone = e164(line.split(":", 1)[1]) + elif upper.startswith("END:VCARD") and phone: + out.append(ImportedPhone(phone=phone, first_name=first or phone, last_name=last)) + return out + + +def _parse_csv(text: str) -> list[ImportedPhone]: + import csv + import io + + out: list[ImportedPhone] = [] + for row in csv.reader(io.StringIO(text)): + cells = [cell.strip() for cell in row if cell.strip()] + if not cells: + continue + phone = e164(cells[0]) + if not phone or not phone.lstrip("+").isdigit(): + # A header row, or a comment. Skipping beats importing "phone". + continue + out.append( + ImportedPhone( + phone=phone, + first_name=cells[1] if len(cells) > 1 else phone, + last_name=cells[2] if len(cells) > 2 else "", + ) + ) + return out + + +def render_export(contacts: list[Contact], fmt: str) -> str: + """The contact list as vCard, CSV or JSON — all local, no RPC.""" + if fmt == "json": + import msgspec + + return msgspec.json.format(msgspec.json.encode(contacts).decode(), indent=2) + if fmt == "csv": + lines = ["id,first_name,last_name,username,phone"] + for row in contacts: + lines.append( + ",".join( + str(value or "") + for value in ( + row.id, + row.first_name, + row.last_name, + row.username, + row.phone, + ) + ) + ) + return "\n".join(lines) + "\n" + cards: list[str] = [] + for row in contacts: + card = [ + "BEGIN:VCARD", + "VERSION:3.0", + f"N:{row.last_name or ''};{row.first_name or ''};;;", + f"FN:{row.name or row.first_name or ''}", + ] + if row.phone: + card.append(f"TEL;TYPE=CELL:{row.phone}") + if row.username: + card.append(f"X-TELEGRAM:{row.username}") + card.append("END:VCARD") + cards.append("\n".join(card)) + return "\n".join(cards) + "\n" + + +# --------------------------------------------------------------------------- +# contact list +# --------------------------------------------------------------------------- + + +class ListReq(Request): + sort: Annotated[ + str, + choice("name", "first-name", "last-name", "last-seen", "added", help="Ordering."), + ] = "name" + with_status: Annotated[ + bool, opt("--with-status", help="Merge contacts.getStatuses into every row.") + ] = False + with_stories: Annotated[ + bool, opt("--with-stories", help="Add has_unseen_stories per contact.") + ] = False + mutual_only: Annotated[bool, opt("--mutual-only", help="Only mutual contacts.")] = False + close_friends_only: Annotated[bool, opt("--close-friends-only", help="Only close friends.")] = ( + False + ) + unregistered: Annotated[ + bool, opt("--unregistered", help="Saved numbers with no Telegram account (takeout).") + ] = False + ids_only: Annotated[ + bool, opt("--ids-only", help="Cheap drift check: contacts.getContactIDs only.") + ] = False + export: Annotated[ + str | None, choice("vcard", "csv", "json", help="Write the list out instead.") + ] = None + out: Annotated[ + str | None, opt("--out", metavar="PATH", kind="path", help="Destination file for --export.") + ] = None + + +async def _read_stories(ctx: OpContext, users: list[Any]) -> dict[int, bool]: + """`{user id: has unseen stories}` from the read marks the server holds.""" + from telethon.tl.functions import stories as sfn + + read: dict[int, int] = {} + with contextlib.suppress(Exception): + result = await client_of(ctx)(sfn.GetAllReadPeerStoriesRequest()) + for update in getattr(result, "updates", None) or []: + peer = getattr(update, "peer", None) + marked = peer_id_of(peer) + if marked is not None: + read[abs(marked)] = int(getattr(update, "max_id", 0) or 0) + out: dict[int, bool] = {} + for user in users: + recent = getattr(user, "stories_max_id", None) + max_id = int(getattr(recent, "max_id", 0) or 0) + out[int(user.id)] = bool(max_id and max_id > read.get(int(user.id), 0)) + return out + + +def _sorted(rows: list[Contact], how: str) -> list[Contact]: + if how == "first-name": + return sorted(rows, key=lambda c: ((c.first_name or "").casefold(), c.id)) + if how == "last-name": + return sorted(rows, key=lambda c: ((c.last_name or "").casefold(), c.id)) + if how == "last-seen": + # Newest first: an unknown last-seen sorts last rather than as 1970. + return sorted(rows, key=lambda c: -((c.status.was_online_unix or 0) if c.status else 0)) + if how == "added": + # Telegram sends the contact list in the order it was built up. + return rows + return sorted(rows, key=lambda c: ((c.name or "").casefold(), c.id)) + + +async def list_contacts(ctx: OpContext, req: ListReq) -> Page[Contact]: + """The contact list, sorted, decorated and optionally written to a file. + + Sorting and the vCard/CSV rendering are entirely local: the server sends + one list and has no opinion about its order, so asking it per sort would + be a second full download for nothing. + """ + from telethon.tl.functions import contacts as fn + + limit, state = _window(ctx, "contact.list", PageKind.LOCAL, default=200) + offset = int(state.get("offset", 0) or 0) + + if req.ids_only: + ids = list(await client_of(ctx)(fn.GetContactIDsRequest(hash=0))) + rows = [Contact(id=int(i), raw_id=int(i)) for i in ids] + return _slice(rows, ctx, "contact.list", offset, limit) + + if req.unregistered: + saved = await _saved_contacts(ctx) + _, users, _ = await load_contacts(ctx) + known = {e164(getattr(u, "phone", "") or "") for u in users} + rows = [ + Contact( + id=0, + first_name=entry.first_name, + last_name=entry.last_name, + name=f"{entry.first_name} {entry.last_name}".strip(), + phone=entry.phone, + ) + for entry in saved + if entry.phone not in known + ] + return _slice(rows, ctx, "contact.list", offset, limit) + + contacts, users, saved_count = await load_contacts(ctx) + mutual = {int(c.user_id): bool(getattr(c, "mutual", False)) for c in contacts} + rows = [contact_model(u, mutual=mutual.get(int(u.id))) for u in users] + + if req.with_status: + statuses = { + int(item.user_id): status_model(int(item.user_id), item.status) + for item in (await client_of(ctx)(fn.GetStatusesRequest()) or []) + } + for row in rows: + row.status = statuses.get(row.id, row.status) + if req.with_stories: + unseen = await _read_stories(ctx, users) + for row in rows: + row.has_unseen_stories = unseen.get(row.id, False) + + if req.mutual_only: + rows = [row for row in rows if row.mutual] + if req.close_friends_only: + rows = [row for row in rows if row.close_friend] + rows = _sorted(rows, req.sort) + if rows: + rows[0].saved_count = saved_count + + if req.export: + if not req.out: + raise UsageError( + "--export writes a file and this operation runs in the daemon, so it " + "needs --out PATH; for machine-readable output on stdout use --json", + field="out", + ) + text = render_export(rows, req.export) + # 0600: a phonebook is exactly the kind of file that should not be + # world-readable because a shell redirect was convenient. + write_private(Path(req.out).expanduser(), text) + ctx.warn(f"wrote {len(rows)} contacts as {req.export} to {req.out}") + + return _slice(rows, ctx, "contact.list", offset, limit) + + +SPEC_LIST = OperationSpec( + id="contact.list", + request=ListReq, + response=Page[Contact], + impl=list_contacts, + summary="The contact list, with sorting, status, story state and export formats", + description=( + "`contacts.getContacts` sends the whole list in one call, so sorting " + "and the vCard/CSV rendering happen locally. `--ids-only` is the " + "cheap drift check (`contacts.getContactIDs`); `--with-status` and " + "`--with-stories` each cost one extra call for the whole list, never " + "one per contact. A phone number appears only where privacy allows." + ), + aliases=("contacts",), + legacy_paths=("contact list", "contacts"), + paginated=PageKind.LOCAL, + rate_class="read", + columns=("id", "name", "username", "phone"), + headers=("Id", "Name", "Username", "Phone"), + example={"items": [_EXAMPLE_CONTACT], "has_more": False, "total": 1}, + example_args="contact list --with-status", + covers=( + "contacts-users.close-friends-list", + "contacts-users.contacts-export-vcard", + "contacts-users.contacts-ids", + "contacts-users.contacts-list", + "contacts-users.contacts-sort", + "contacts-users.contacts-story-state", + ), +) + + +# --------------------------------------------------------------------------- +# contact add / rename / remove +# --------------------------------------------------------------------------- + + +class AddReq(Request): + user: Annotated[ + PeerRef | None, + arg(0, metavar="USER", required=False, kind="user", help="@username, id or +phone."), + ] = None + name: Annotated[ + str | None, + arg(1, metavar="NAME", required=False, help="v1 spelling of --first-name [--last-name]."), + ] = None + first_name: Annotated[ + str | None, opt("--first-name", metavar="TEXT", help="Mandatory for a new contact.") + ] = None + last_name: Annotated[str | None, opt("--last-name", metavar="TEXT")] = None + phone: Annotated[ + str | None, opt("--phone", metavar="NUMBER", help="Attach a phone number.") + ] = None + share_phone: Annotated[ + bool, opt("--share-phone", help="Grant them a phone-number privacy exception.") + ] = False + note: Annotated[ + str | None, opt("--note", metavar="TEXT", help="Private annotation on the contact.") + ] = None + from_message: Annotated[ + str | None, + opt("--from-message", metavar="CHAT:ID", help="Take the contact card of that message."), + ] = None + + +def _split_name(name: str | None) -> tuple[str, str]: + parts = (name or "").split(maxsplit=1) + return (parts[0] if parts else ""), (parts[1] if len(parts) > 1 else "") + + +def _note_of(text: str | None) -> Any: + from telethon.tl import types + + if text is None: + return None + return types.TextWithEntities(text=text, entities=[]) + + +async def _card_from_message(ctx: OpContext, ref: str) -> tuple[str, str, str, int]: + """`(phone, first, last, user_id)` out of a `messageMediaContact`.""" + chat_ref, _, raw_id = ref.rpartition(":") + if not chat_ref or not raw_id.strip().isdigit(): + raise UsageError("--from-message takes :", field="from-message") + peer = await _send.resolve(ctx, chat_ref) + # `get_messages` picks channels.getMessages or messages.getMessages by + # peer kind; building the request by hand here would get that wrong for + # exactly the case (a channel) where a contact card is most often seen. + found = await client_of(ctx).get_messages(peer, ids=[int(raw_id)]) + for message in found or []: + media = getattr(message, "media", None) if message is not None else None + if type(media).__name__ == "MessageMediaContact": + return ( + e164(getattr(media, "phone_number", "") or ""), + getattr(media, "first_name", "") or "", + getattr(media, "last_name", "") or "", + int(getattr(media, "user_id", 0) or 0), + ) + raise NotFoundError(f"message {raw_id} in {chat_ref} carries no contact card") + + +async def _import_phone( + ctx: OpContext, phone: str, first: str, last: str, note: str | None +) -> ContactAdded: + """`contacts.importContacts` for one number, ambiguity intact.""" + from telethon.tl import types + from telethon.tl.functions import contacts as fn + + result = await client_of(ctx)( + fn.ImportContactsRequest( + [ + types.InputPhoneContact( + client_id=int(time.time() * 1000) & 0x7FFFFFFF, + phone=phone, + first_name=first or phone, + last_name=last, + note=_note_of(note), + ) + ] + ) + ) + imported = [int(i.user_id) for i in getattr(result, "imported", None) or []] + popular = [int(getattr(p, "importers", 0) or 0) for p in getattr(result, "popular_invites", [])] + reason = None + if not imported: + reason = ( + "the server imported nothing: the number has no Telegram account, OR its " + "owner refuses lookups by phone (inputPrivacyKeyAddedByPhone). These two " + "are not distinguishable from here." + ) + return ContactAdded( + added=bool(imported), + user_id=imported[0] if imported else None, + first_name=first or phone, + last_name=last, + imported=imported, + retry=[int(i) for i in getattr(result, "retry_contacts", None) or []], + popular_importers=max(popular) if popular else None, + note=note, + reason=reason, + ) + + +async def add(ctx: OpContext, req: AddReq) -> ContactAdded: + """Add a contact — by user, by phone, or from a contact card in a message. + + Which method runs is decided by what we can address: a user we can build + an `InputUser` for goes through `contacts.addContact` (no phone needed); + a bare number goes through `contacts.importContacts`, whose empty answer + is ambiguous and is reported as ambiguous rather than as "no such user". + """ + from telethon.tl.functions import contacts as fn + + first, last = _split_name(req.name) + first = req.first_name if req.first_name is not None else first + last = req.last_name if req.last_name is not None else last + phone = e164(req.phone or "") + + if req.from_message: + card_phone, card_first, card_last, user_id = await _card_from_message(ctx, req.from_message) + first = first or card_first + last = last or card_last + phone = phone or card_phone + if not user_id: + # A card with user_id 0 belongs to somebody with no account we can + # see; only importContacts can do anything with it. + if not phone: + raise NotFoundError("that contact card carries neither a user nor a phone number") + return await _import_phone(ctx, phone, first, last, req.note) + target = await input_user(ctx, str(user_id)) + elif req.user is not None and req.user.kind == "phone": + return await _import_phone(ctx, str(req.user.value), first, last, req.note) + elif req.user is not None: + target = await input_user(ctx, req.user) + elif phone: + return await _import_phone(ctx, phone, first, last, req.note) + else: + raise UsageError("give a user, a +phone, or --from-message", field="user") + + known = await fetch_user(ctx, target) + first = first or (getattr(known, "first_name", "") or "") + last = last or (getattr(known, "last_name", "") or "") + if not first: + # The server rejects an empty first name outright; v1 sent "." and + # everything downstream (including the user's own tagging scheme) + # depends on that still working. + first = "." + if req.share_phone and not getattr(ctx, "dry_run", False): + ctx.warn("--share-phone discloses your own number to them; it cannot be undone") + await client_of(ctx)( + fn.AddContactRequest( + id=target, + first_name=first, + last_name=last, + phone=phone or (getattr(known, "phone", None) or ""), + add_phone_privacy_exception=req.share_phone or None, + note=_note_of(req.note), + ) + ) + user_id = int(getattr(known, "id", 0) or 0) + ctx.emit("contact_add", {"user_id": user_id}) + return ContactAdded( + added=True, + user_id=user_id, + first_name=first, + last_name=last, + imported=[user_id], + shared_phone=req.share_phone, + note=req.note, + ) + + +SPEC_ADD = OperationSpec( + id="contact.add", + request=AddReq, + response=ContactAdded, + impl=add, + summary="Add a contact — by user, by phone, or from a contact card in a message", + description=( + "An empty `imported` with an empty `retry` is ambiguous: the number " + "has no account, or its owner hides it from phone lookups. `reason` " + "says so rather than the reply claiming 'no such user'. `--retry` " + "entries must be sent again later; they are not failures." + ), + legacy_paths=("contact add",), + mutating=True, + rate_class="bulk", + columns=("added", "user_id", "first_name"), + example={"added": True, "user_id": 777123, "first_name": "Alice", "imported": [777123]}, + example_args="contact add @alice --first-name Alice", + covers=( + "contact.receive-card", + "contacts-users.contact-add-by-phone", + "contacts-users.contact-add-by-user", + "contacts-users.contact-card-open", + "contacts-users.contact-phone-privacy-exception", + "dialogs.actionbar-add-contact", + ), + tags=frozenset({"visible-to-others"}), +) + + +class RenameReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to rename.")] + first_name: Annotated[str | None, opt("--first-name", metavar="TEXT")] = None + last_name: Annotated[str | None, opt("--last-name", metavar="TEXT")] = None + + +async def rename(ctx: OpContext, req: RenameReq) -> ContactRenamed: + """Change the locally visible name of a contact. + + Re-issuing `addContact` for someone who is already a contact rewrites + only *our* view of their name; their profile is untouched, and it works + on non-contacts too (it saves them). Omitted parts keep the current + profile name, and an empty first name becomes `"."` because the server + rejects an empty one — v1 did this and the user's tagging depends on it. + """ + from telethon.tl.functions import contacts as fn + + target = await input_user(ctx, req.user) + known = await fetch_user(ctx, target) + first = req.first_name if req.first_name is not None else (known.first_name or "") + last = req.last_name if req.last_name is not None else (known.last_name or "") + if not first: + first = "." + await client_of(ctx)( + fn.AddContactRequest( + id=target, + first_name=first, + last_name=last, + phone=getattr(known, "phone", None) or "", + add_phone_privacy_exception=None, + ) + ) + user_id = int(getattr(known, "id", 0) or 0) + ctx.emit("contact_rename", {"user_id": user_id}) + return ContactRenamed(saved=True, user_id=user_id, first_name=first, last_name=last) + + +SPEC_RENAME = OperationSpec( + id="contact.rename", + request=RenameReq, + response=ContactRenamed, + impl=rename, + summary="Change the locally visible name of a contact", + description=( + "Works on non-contacts too — it saves them — which is what makes it " + "usable for tagging users with a state marker in the last name." + ), + legacy_paths=("contact rename",), + mutating=True, + idempotent=True, + columns=("saved", "user_id", "first_name", "last_name"), + example={"saved": True, "user_id": 777123, "first_name": "Alice", "last_name": "· lead"}, + example_args="contact rename @alice --last-name '· lead'", + covers=("contacts-users.contact-edit-name",), +) + + +class RemoveReq(Request): + user: Annotated[ + list[PeerRef], + arg(0, metavar="USER", variadic=True, kind="user", help="Contacts to delete."), + ] = [] + phone: Annotated[ + list[str], + opt("--phone", metavar="NUMBER", help="Delete a phonebook entry by number."), + ] = [] + + +async def remove(ctx: OpContext, req: RemoveReq) -> ContactRemoved: + """Delete contacts, by user or by phone number. + + Deleting by phone reaches entries with no Telegram account at all, which + is the only way to clear them — and it is irreversible server-side, which + is why the whole op is destructive and needs `--yes`. + """ + from telethon.tl.functions import contacts as fn + + if not req.user and not req.phone: + raise UsageError("give at least one user or --phone", field="user") + + user_ids: list[int] = [] + if req.user: + targets = [await input_user(ctx, ref) for ref in req.user] + result = await client_of(ctx)(fn.DeleteContactsRequest(id=targets)) + user_ids = [ + int(u.id) for u in (getattr(result, "users", None) or []) if hasattr(u, "id") + ] or [int(getattr(t, "user_id", 0) or 0) for t in targets] + + phones = [e164(p) for p in req.phone if e164(p)] + if phones: + await client_of(ctx)(fn.DeleteByPhonesRequest(phones=phones)) + + ctx.emit("contact_remove", {"user_ids": user_ids, "phones": phones}) + return ContactRemoved(removed=True, user_ids=user_ids, phones=phones) + + +SPEC_REMOVE = OperationSpec( + id="contact.remove", + request=RemoveReq, + response=ContactRemoved, + impl=remove, + summary="Delete contacts, by user or by phone number", + legacy_paths=("contact remove",), + mutating=True, + destructive=True, + rate_class="bulk", + columns=("removed", "user_ids", "phones"), + example={"removed": True, "user_ids": [777123], "phones": []}, + example_args="contact remove @alice", + covers=("contacts-users.contact-delete", "contacts-users.contact-delete-by-phone"), +) + + +# --------------------------------------------------------------------------- +# contact note set +# --------------------------------------------------------------------------- + + +class NoteSetReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Which contact.")] + text: Annotated[str | None, arg(1, metavar="TEXT", required=False, help="The note.")] = None + clear: Annotated[bool, opt("--clear", help="Delete the note.")] = False + parse: Annotated[str | None, choice("md", "html", "none", help="Markup of the note.")] = None + + +async def note_set(ctx: OpContext, req: NoteSetReq) -> ContactNote: + """Set or clear the private note attached to a contact. + + The note is ours alone; read it back with `user get --full`. Only a + contact can carry one, so a non-contact fails with CONTACT_MISSING rather + than silently doing nothing. + """ + from telethon.tl import types + from telethon.tl.functions import contacts as fn + + if req.clear and req.text: + raise UsageError("--clear and a note text contradict each other", field="clear") + text, entities = _send.body(req.text or "", parse=req.parse) + if req.clear: + text, entities = "", [] + + target = await input_user(ctx, req.user) + await client_of(ctx)( + fn.UpdateContactNoteRequest( + id=target, + note=types.TextWithEntities(text=text, entities=_send.tl_entities(entities) or []), + ) + ) + user_id = int(getattr(target, "user_id", 0) or 0) + ctx.emit("contact_note", {"user_id": user_id}) + return ContactNote(user_id=user_id, note=text or None, cleared=not text) + + +SPEC_NOTE_SET = OperationSpec( + id="contact.note.set", + request=NoteSetReq, + response=ContactNote, + impl=note_set, + summary="Set or clear the private note attached to a contact", + description="An empty `TextWithEntities` is how Telegram spells 'no note'.", + mutating=True, + idempotent=True, + columns=("user_id", "note"), + example={"user_id": 777123, "note": "met at the conference"}, + example_args='contact note set @alice "met at the conference"', + covers=( + "contact.note", + "contacts-users.contact-note-delete", + "contacts-users.contact-note-set", + ), +) + + +# --------------------------------------------------------------------------- +# contact search +# --------------------------------------------------------------------------- + + +class SearchReq(Request): + query: Annotated[str, arg(0, metavar="QUERY", help="What to look for.")] = "" + mine_only: Annotated[bool, opt("--mine-only", help="Only contacts and known peers.")] = False + global_only: Annotated[bool, opt("--global-only", help="Only public username matches.")] = False + broadcasts: Annotated[bool, opt("--broadcasts", help="Restrict to channels.")] = False + bots: Annotated[bool, opt("--bots", help="Restrict to bots.")] = False + type: Annotated[str | None, choice("user", "bot", "group", "channel", help="Kind filter.")] = ( + None + ) + with_sponsored: Annotated[ + bool, opt("--with-sponsored", help="Also request sponsored peers (off by default).") + ] = False + recent: Annotated[bool, opt("--recent", help="List the recently searched peers instead.")] = ( + False + ) + forget: Annotated[ + PeerRef | None, + opt("--forget", metavar="PEER", kind="peer", help="Drop one entry and reset its rating."), + ] = None + clear_recent: Annotated[bool, opt("--clear-recent", help="Forget the whole history.")] = False + with_tme_urls: Annotated[ + bool, opt("--with-tme-urls", help="With --recent: include help.getRecentMeUrls.") + ] = False + + +def _recent_path(ctx: OpContext) -> Path | None: + paths = getattr(ctx, "paths", None) + if paths is None or not ctx.account: # pragma: no cover - the daemon supplies both + return None + return Path(paths.account_dir(ctx.account)) / "recent_peers.json" + + +def _recent_load(ctx: OpContext) -> list[dict[str, Any]]: + path = _recent_path(ctx) + if path is None or not path.exists(): + return [] + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): # pragma: no cover - a corrupt file is empty + return [] + return list(raw.get("peers", [])) if isinstance(raw, dict) else [] + + +def _recent_save(ctx: OpContext, rows: list[dict[str, Any]]) -> None: + path = _recent_path(ctx) + if path is None: # pragma: no cover + return + with contextlib.suppress(OSError): + write_private(path, json.dumps({"peers": rows[:50]})) + + +async def search(ctx: OpContext, req: SearchReq) -> Page[FoundPeer]: + """Search contacts, known peers and global public usernames. + + `contacts.search` splits its answer in two and the split is the useful + part: `my_results` is people this account already knows, `results` is + everyone else's public username. They arrive labelled (`source`) rather + than merged, and sponsored rows stay off unless asked for — a CLI has no + reason to render an advert next to a contact. + + The recent-search list is tlgr's own state: TDLib keeps it client-side + and MTProto has no call for it. `--forget` is the one half that *is* + server-side, because dropping a peer also resets its top-peer rating so + the server stops suggesting it. + """ + from telethon.tl.functions import contacts as fn + from telethon.tl.functions import help as hfn + + limit, state = _window(ctx, "contact.search", PageKind.LOCAL, default=50) + offset = int(state.get("offset", 0) or 0) + + # Searching is a read, so the op stays dry-runnable; the two branches + # that *do* write guard themselves rather than making the whole command + # print a stub under --dry-run (the `folder list --tags` pattern). + if req.clear_recent: + if getattr(ctx, "dry_run", False): + ctx.warn("--dry-run: the recent-search history would be cleared") + else: + _recent_save(ctx, []) + ctx.emit("contact_search_clear", {}) + if req.forget is not None: + from telethon.tl import types as tl + + peer = await _send.resolve(ctx, req.forget) + marked = _send.peer_id_of(peer) + if getattr(ctx, "dry_run", False): + ctx.warn(f"--dry-run: {marked} would be forgotten and its rating reset") + else: + _recent_save(ctx, [row for row in _recent_load(ctx) if int(row.get("id", 0)) != marked]) + await client_of(ctx)( + fn.ResetTopPeerRatingRequest(category=tl.TopPeerCategoryCorrespondents(), peer=peer) + ) + ctx.emit("contact_search_forget", {"peer_id": marked}) + + if req.recent: + recent: list[FoundPeer] = [ + FoundPeer( + peer=Peer( + id=int(row.get("id", 0)), + raw_id=abs(int(row.get("id", 0))), + kind=row.get("kind", "unknown"), + title=row.get("title", ""), + username=row.get("username"), + ), + source="recent", + ) + for row in _recent_load(ctx) + ] + if req.with_tme_urls: + result = await client_of(ctx)(hfn.GetRecentMeUrlsRequest(referer="")) + known = peers_by_id(getattr(result, "users", None), getattr(result, "chats", None)) + for url in getattr(result, "urls", None) or []: + peer_ref = getattr(url, "peer", None) or getattr(url, "chat", None) + if peer_ref is None: + continue + recent.append( + FoundPeer( + peer=peer_model(peer_ref, known), + source="tme", + url=str(getattr(url, "url", "") or ""), + ) + ) + return _slice(recent, ctx, "contact.search", offset, limit) + + if not req.query.strip(): + raise UsageError("a search query is required", field="query") + + found = await client_of(ctx)( + fn.SearchRequest( + q=req.query, + limit=min(limit + offset, 200), + broadcasts=req.broadcasts or None, + bots=req.bots or None, + ) + ) + known = peers_by_id(getattr(found, "users", None), getattr(found, "chats", None)) + rows: list[FoundPeer] = [] + if not req.global_only: + rows += [ + FoundPeer(peer=peer_model(p, known), source="mine") + for p in getattr(found, "my_results", None) or [] + ] + if not req.mine_only: + seen = {row.peer.id for row in rows} + rows += [ + FoundPeer(peer=peer_model(p, known), source="global") + for p in getattr(found, "results", None) or [] + if peer_id_of(p) not in seen + ] + + if req.with_sponsored: + sponsored = await client_of(ctx)(fn.GetSponsoredPeersRequest(q=req.query)) + ads = peers_by_id(getattr(sponsored, "users", None), getattr(sponsored, "chats", None)) + for item in getattr(sponsored, "peers", None) or []: + random_id = getattr(item, "random_id", None) + rows.append( + FoundPeer( + peer=peer_model(getattr(item, "peer", None), ads), + source="sponsored", + sponsored=True, + random_id=random_id.hex() if isinstance(random_id, bytes) else None, + ) + ) + + if req.type: + wanted = {"user": {"user"}, "bot": {"bot"}, "group": {"group", "supergroup"}}.get( + req.type, {"channel"} + ) + rows = [row for row in rows if row.peer.kind in wanted] + + # Remember what was found so `--recent` has something to show; this is + # local state, and the only reason it exists is that MTProto has no call + # for the recently-searched list every GUI client keeps. + history = _recent_load(ctx) + for row in rows[:10]: + entry = { + "id": row.peer.id, + "kind": row.peer.kind, + "title": row.peer.title, + "username": row.peer.username, + } + history = [item for item in history if int(item.get("id", 0)) != row.peer.id] + history.insert(0, entry) + _recent_save(ctx, history) + + return _slice(rows, ctx, "contact.search", offset, limit) + + +SPEC_SEARCH = OperationSpec( + id="contact.search", + request=SearchReq, + response=Page[FoundPeer], + impl=search, + summary="Search contacts, known peers and global public usernames", + description=( + "`source` labels every row: `mine` is a contact or an already-known " + "peer, `global` is a public username match, `recent` is tlgr's own " + "search history and `sponsored` is an advert (off unless " + "--with-sponsored). Local title matching over the dialog list is " + "`chat list --search`." + ), + aliases=("chat.search",), + legacy_paths=("contact search",), + paginated=PageKind.LOCAL, + rate_class="resolve", + tags=frozenset({"mutating-checked"}), + columns=("peer.id", "peer.title", "peer.username", "source"), + headers=("Id", "Title", "Username", "Source"), + example={ + "items": [ + { + "peer": {"id": 777123, "raw_id": 777123, "kind": "user", "title": "Alice"}, + "source": "mine", + } + ], + "has_more": False, + }, + example_args="contact search alice", + covers=( + "contacts-users.contacts-search", + "contacts-users.search-recent", + "contacts-users.search-sponsored-peers", + "dialogs.recent-searches", + "dialogs.search-peers", + "dialogs.sponsored-search-peers", + ), +) + + +# --------------------------------------------------------------------------- +# contact status list / birthday list / joined list +# --------------------------------------------------------------------------- + + +class StatusListReq(Request): + online_only: Annotated[bool, opt("--online-only", help="Only contacts online right now.")] = ( + False + ) + since: Annotated[ + str | None, + opt("--since", metavar="TS", kind="datetime", help="Only statuses newer than this."), + ] = None + + +async def status_list(ctx: OpContext, req: StatusListReq) -> Page[UserStatus]: + """Online / last-seen for every contact, in one call. + + This is the cold-start snapshot; live changes arrive as `updateUserStatus` + on the event bus. `by_me` on a coarse bucket means *our* last-seen privacy + caused the coarseness — never report that as the peer hiding from us. + """ + from telethon.tl.functions import contacts as fn + + rows = [ + status_model(int(item.user_id), item.status) + for item in (await client_of(ctx)(fn.GetStatusesRequest()) or []) + ] + if req.online_only: + rows = [row for row in rows if row.kind == "online"] + if req.since: + floor = parse_dt(req.since) + cutoff = int(floor.timestamp()) if floor else 0 + rows = [ + row for row in rows if max(row.was_online_unix or 0, row.expires_unix or 0) >= cutoff + ] + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_STATUS_LIST = OperationSpec( + id="contact.status.list", + request=StatusListReq, + response=Page[UserStatus], + impl=status_list, + summary="Online / last-seen status of every contact in one call", + description=( + "`userStatusRecently`/`LastWeek`/`LastMonth` carry `by_me`: the " + "coarse bucket is caused by OUR OWN last-seen privacy, not by " + "theirs. Never report it as the peer hiding from you." + ), + aliases=("contact.statuses",), + paginated=PageKind.LOCAL, + columns=("user_id", "kind", "was_online"), + headers=("User", "State", "Last seen"), + example={"items": [{"user_id": 777123, "kind": "online"}], "has_more": False}, + example_args="contact status list --online-only", + covers=("contacts-users.contacts-statuses", "dialogs.presence-watch"), +) + + +class BirthdayListReq(Request): + window: Annotated[ + int, opt("--window", metavar="DAYS", help="Days around today to include.", ge=0) + ] = 1 + + +async def birthday_list(ctx: OpContext, req: BirthdayListReq) -> Page[Contact]: + """Contacts whose birthday is today or within a day. + + Only the ones whose birthday privacy lets us see it. Official clients + poll this every six to eight hours, which makes it a good `job` and a bad + thing to call in a loop. + """ + from telethon.tl.functions import contacts as fn + + result = await client_of(ctx)(fn.GetBirthdaysRequest()) + users = {int(u.id): u for u in getattr(result, "users", None) or []} + today = datetime.now(timezone.utc) + rows: list[Contact] = [] + for entry in getattr(result, "contacts", None) or []: + user = users.get(int(entry.contact_id)) + row = contact_model(user) if user is not None else Contact(id=int(entry.contact_id)) + row.birthday = birthday_text(entry.birthday) + row.age = birthday_age(entry.birthday, today=today) + if req.window and not _within(entry.birthday, today, req.window): + continue + rows.append(row) + limit, state = _window(ctx, "contact.birthday.list", PageKind.LOCAL, default=100) + return _slice(rows, ctx, "contact.birthday.list", int(state.get("offset", 0) or 0), limit) + + +def _within(birthday: Any, today: datetime, window: int) -> bool: + """Is this birthday within `window` days of today, wrapping the year?""" + month = int(getattr(birthday, "month", 0) or 0) + day = int(getattr(birthday, "day", 0) or 0) + if not month or not day: + return False + for offset in range(-window, window + 1): + moment = today.fromordinal(today.toordinal() + offset) + if (moment.month, moment.day) == (month, day): + return True + return False + + +SPEC_BIRTHDAY_LIST = OperationSpec( + id="contact.birthday.list", + request=BirthdayListReq, + response=Page[Contact], + impl=birthday_list, + summary="Contacts whose birthday is today or within a day", + description=( + "Visible only per each contact's birthday privacy. Dismissing the " + "chat-list bar is `chat promo list --dismiss BIRTHDAY_CONTACTS_TODAY`." + ), + aliases=("contact.birthdays",), + paginated=PageKind.LOCAL, + columns=("id", "name", "birthday", "age"), + headers=("Id", "Name", "Birthday", "Age"), + example={ + "items": [{"id": 777123, "name": "Alice", "birthday": "1990-04-01", "age": 36}], + "has_more": False, + }, + example_args="contact birthday list", + covers=("contact.birthdays", "contacts-users.contacts-birthdays"), +) + + +class JoinedListReq(Request): + since: Annotated[ + str | None, + opt("--since", metavar="TS", kind="datetime", help="Only sign-ups after this date."), + ] = None + notify: Annotated[ + str | None, + choice("on", "off", help="Turn the 'contact joined' notification on or off."), + ] = None + max_chats: Annotated[ + int, opt("--max-chats", metavar="N", help="Cap the dialog scan.", ge=1) + ] = 200 + + +async def joined_list(ctx: OpContext, req: JoinedListReq) -> Page[SignUp]: + """Contacts who joined Telegram, and the "X joined" notification switch. + + There is no method that lists sign-ups: Telegram delivers each one as a + `messageActionContactSignUp` service message in that person's chat, so + this scans recent dialogs for them. The scan is capped and says so rather + than pretending an empty answer is authoritative. + """ + from telethon.tl.functions import account as afn + + notify: bool | None = None + if req.notify is not None: + if getattr(ctx, "dry_run", False): + ctx.warn(f"--dry-run: the contact-joined notification would be turned {req.notify}") + else: + await client_of(ctx)( + afn.SetContactSignUpNotificationRequest(silent=req.notify == "off") + ) + notify = req.notify == "on" + else: + silent = await client_of(ctx)(afn.GetContactSignUpNotificationRequest()) + notify = not bool(silent) + + floor = parse_dt(req.since) if req.since else None + rows: list[SignUp] = [] + scanned = 0 + client = client_of(ctx) + async for dialog in client.iter_dialogs(limit=req.max_chats): + scanned += 1 + entity = getattr(dialog, "entity", None) + if type(entity).__name__ != "User": + continue + async for message in client.iter_messages(entity, limit=20): + if message is None: + continue + action = getattr(message, "action", None) + if type(action).__name__ != "MessageActionContactSignUp": + continue + when = getattr(message, "date", None) + if floor is not None and when is not None and when < floor: + continue + rows.append( + SignUp( + user_id=int(getattr(entity, "id", 0) or 0), + name=display_name(entity), + username=getattr(entity, "username", None), + chat_id=int(getattr(entity, "id", 0) or 0), + msg_id=int(getattr(message, "id", 0) or 0), + date=fmt_dt(when), + date_unix=to_unix(when), + notify=notify, + ) + ) + if scanned >= req.max_chats: + ctx.warn( + f"the scan stopped at {req.max_chats} chats; raise --max-chats to look further. " + "An empty list here is not proof that nobody joined." + ) + limit, state = _window(ctx, "contact.joined.list", PageKind.LOCAL, default=50) + return _slice(rows, ctx, "contact.joined.list", int(state.get("offset", 0) or 0), limit) + + +SPEC_JOINED_LIST = OperationSpec( + id="contact.joined.list", + request=JoinedListReq, + response=Page[SignUp], + impl=joined_list, + summary="Contacts who joined Telegram, and the 'X joined' notification switch", + description=( + "Telegram has no sign-up list: each one is a " + "`messageActionContactSignUp` service message, so this scans recent " + "chats for them and warns when the scan was capped." + ), + paginated=PageKind.LOCAL, + rate_class="bulk", + timeout_s=300, + tags=frozenset({"mutating-checked"}), + columns=("user_id", "name", "date"), + headers=("User", "Name", "Joined"), + example={"items": [{"user_id": 777123, "name": "Alice", "notify": True}], "has_more": False}, + example_args="contact joined list", + covers=("contacts-users.contacts-joined-notification", "dialogs.contact-signup-notify"), +) + + +# --------------------------------------------------------------------------- +# contact blocked list / set +# --------------------------------------------------------------------------- + + +class BlockedListReq(Request): + stories: Annotated[ + bool, opt("--stories", help="The story blocklist instead (my_stories_from).") + ] = False + + +async def _blocked_page(ctx: OpContext, *, stories: bool, offset: int, limit: int) -> Any: + from telethon.tl.functions import contacts as fn + + return await client_of(ctx)( + fn.GetBlockedRequest(offset=offset, limit=limit, my_stories_from=stories or None) + ) + + +async def blocked_list(ctx: OpContext, req: BlockedListReq) -> Page[BlockedPeer]: + """The blocklist, or the separate story blocklist. + + The two lists are independent: someone on the story blocklist can still + message you, and someone blocked outright is not automatically on it. + """ + limit, state = _window(ctx, "contact.blocked.list", PageKind.PARTICIPANTS, default=100) + offset = int(state.get("offset", 0) or 0) + kind = "stories" if req.stories else "main" + + rows: list[BlockedPeer] = [] + total: int | None = None + fetch_all = bool(getattr(ctx, "fetch_all", False)) + while True: + result = await _blocked_page( + ctx, stories=req.stories, offset=offset + len(rows), limit=limit + ) + known = peers_by_id(getattr(result, "users", None), getattr(result, "chats", None)) + batch = list(getattr(result, "blocked", None) or []) + total = getattr(result, "count", None) + for item in batch: + date = getattr(item, "date", None) + rows.append( + BlockedPeer( + peer=peer_model(getattr(item, "peer_id", None), known), + date=fmt_dt(date), + date_unix=to_unix(date), + kind=kind, # type: ignore[arg-type] + ) + ) + if not fetch_all or not batch or (total is not None and offset + len(rows) >= total): + break + + has_more = total is not None and offset + len(rows) < int(total) + return build_page( + rows, + op="contact.blocked.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(rows)}, + account=ctx.account, + has_more=has_more and not fetch_all, + total=int(total) if total is not None else None, + ) + + +SPEC_BLOCKED_LIST = OperationSpec( + id="contact.blocked.list", + request=BlockedListReq, + response=Page[BlockedPeer], + impl=blocked_list, + summary="The blocklist, or the separate story blocklist", + aliases=("user.blocked",), + paginated=PageKind.PARTICIPANTS, + columns=("peer.id", "peer.title", "date", "kind"), + headers=("Id", "Peer", "Blocked", "List"), + example={ + "items": [{"peer": {"id": 777123, "raw_id": 777123, "kind": "user"}, "kind": "main"}], + "has_more": False, + }, + example_args="contact blocked list", + covers=("contacts-users.block-list", "contacts-users.block-stories-list"), +) + + +class BlockedSetReq(Request): + user: Annotated[ + list[PeerRef], + arg(0, metavar="PEER", variadic=True, kind="peer", help="The complete new list."), + ] = [] + stories: Annotated[bool, opt("--stories", help="Operate on the story blocklist.")] = False + from_file: Annotated[ + str | None, + opt("--from-file", metavar="PATH", kind="path", help="Read the peer list from a file."), + ] = None + + +async def blocked_set(ctx: OpContext, req: BlockedSetReq) -> BlockedSet: + """Replace the whole blocklist atomically. + + DESTRUCTIVE in a way the method name hides: `contacts.setBlocked` + *replaces* the list, so everyone not named is unblocked. The current list + is read first and the diff is part of the answer, because "I unblocked + forty people" should not be something you discover later. + """ + from telethon.tl.functions import contacts as fn + + refs = list(req.user) + if req.from_file: + for line in _read_file(req.from_file, "from-file").splitlines(): + text = line.strip() + if text and not text.startswith("#"): + from tlgr.models.peer import parse_peer_ref + + refs.append(parse_peer_ref(text)) + if not refs: + raise UsageError( + "give the complete new blocklist; setBlocked replaces it, so an empty " + "list would unblock everyone", + field="user", + ) + + peers = [await _send.resolve(ctx, ref) for ref in refs] + wanted = {_send.peer_id_of(peer) for peer in peers} + + current = await _blocked_page(ctx, stories=req.stories, offset=0, limit=1000) + known = peers_by_id(getattr(current, "users", None), getattr(current, "chats", None)) + before = { + peer_model(getattr(item, "peer_id", None), known).id + for item in getattr(current, "blocked", None) or [] + } + + await client_of(ctx)( + fn.SetBlockedRequest(id=peers, limit=len(peers), my_stories_from=req.stories or None) + ) + ctx.emit("blocked_set", {"count": len(peers)}) + return BlockedSet( + count=len(peers), + blocked=sorted(wanted - before), + unblocked=sorted(before - wanted), + kind="stories" if req.stories else "main", + applied=True, + ) + + +SPEC_BLOCKED_SET = OperationSpec( + id="contact.blocked.set", + request=BlockedSetReq, + response=BlockedSet, + impl=blocked_set, + summary="Replace the whole blocklist atomically", + description=( + "`contacts.setBlocked` REPLACES the list: everyone not passed is " + "unblocked. The reply is the diff against what was there before." + ), + mutating=True, + destructive=True, + rate_class="bulk", + columns=("count", "blocked", "unblocked"), + example={"count": 1, "blocked": [777123], "unblocked": [], "applied": True}, + example_args="contact blocked set @spammer", + covers=("dialogs.blocked-set-bulk",), +) + + +# --------------------------------------------------------------------------- +# contact close-friends list / set +# --------------------------------------------------------------------------- + + +class CloseFriendsListReq(Request): + pass + + +async def close_friends_list(ctx: OpContext, req: CloseFriendsListReq) -> Page[Contact]: + """List close friends. + + There is no getter: a close friend is a contact carrying `close_friend`, + so the contact list is fetched and filtered. + """ + _, users, _ = await load_contacts(ctx) + rows = [contact_model(u) for u in users if getattr(u, "close_friend", False)] + limit, state = _window(ctx, "contact.close-friends.list", PageKind.LOCAL, default=30) + return _slice(rows, ctx, "contact.close-friends.list", int(state.get("offset", 0) or 0), limit) + + +SPEC_CLOSE_FRIENDS_LIST = OperationSpec( + id="contact.close-friends.list", + request=CloseFriendsListReq, + response=Page[Contact], + impl=close_friends_list, + summary="List your close friends", + description="No dedicated getter exists; `user.close_friend` on the contact list is it.", + aliases=("story.close-friends.list",), + paginated=PageKind.LOCAL, + columns=("id", "name", "username"), + headers=("Id", "Name", "Username"), + example={"items": [dict(_EXAMPLE_CONTACT, close_friend=True)], "has_more": False}, + example_args="contact close-friends list", + covers=("stories.close-friends-list",), +) + + +class CloseFriendsSetReq(Request): + user: Annotated[ + list[PeerRef], + arg(0, metavar="USER", variadic=True, kind="user", help="The complete new list."), + ] = [] + add: Annotated[ + list[PeerRef], opt("--add", metavar="USER", kind="user", help="Read-modify-write add.") + ] = [] + remove: Annotated[ + list[PeerRef], + opt("--remove", metavar="USER", kind="user", help="Read-modify-write remove."), + ] = [] + + +async def close_friends_set(ctx: OpContext, req: CloseFriendsSetReq) -> CloseFriends: + """Read or edit the close-friends list. + + `contacts.editCloseFriends` replaces the whole list, so `--add`/`--remove` + read the current contact list first and send the union. Only contacts may + be close friends; the server refuses anyone else. + """ + from telethon.tl.functions import contacts as fn + + _, users, _ = await load_contacts(ctx) + by_id = {int(u.id): u for u in users} + current = [int(u.id) for u in users if getattr(u, "close_friend", False)] + + if req.user and (req.add or req.remove): + raise UsageError("give either a complete list or --add/--remove, not both", field="user") + + if req.user: + wanted = [int(getattr(await input_user(ctx, ref), "user_id", 0) or 0) for ref in req.user] + else: + wanted = list(current) + for ref in req.add: + uid = int(getattr(await input_user(ctx, ref), "user_id", 0) or 0) + if uid and uid not in wanted: + wanted.append(uid) + for ref in req.remove: + uid = int(getattr(await input_user(ctx, ref), "user_id", 0) or 0) + wanted = [i for i in wanted if i != uid] + + strangers = [uid for uid in wanted if uid not in by_id] + if strangers: + raise UsageError( + f"only contacts can be close friends; {strangers} are not in the contact list", + field="user", + ) + if sorted(wanted) == sorted(current): + mark_already(ctx) + return CloseFriends( + user_ids=current, + count=len(current), + contacts=[contact_model(by_id[i]) for i in current if i in by_id], + ) + + await client_of(ctx)(fn.EditCloseFriendsRequest(id=wanted)) + ctx.emit("close_friends_set", {"count": len(wanted)}) + return CloseFriends( + user_ids=wanted, + count=len(wanted), + contacts=[contact_model(by_id[i]) for i in wanted if i in by_id], + ) + + +SPEC_CLOSE_FRIENDS_SET = OperationSpec( + id="contact.close-friends.set", + request=CloseFriendsSetReq, + response=CloseFriends, + impl=close_friends_set, + summary="Read or edit the close-friends list", + description=( + "`contacts.editCloseFriends` replaces the list, so --add/--remove are " + "a read-modify-write over the current contact list." + ), + aliases=("privacy.close-friends.set", "story.close-friends.set"), + mutating=True, + idempotent=True, + rate_class="bulk", + columns=("count", "user_ids"), + example={"user_ids": [777123], "count": 1}, + example_args="contact close-friends set @alice", + covers=("contacts-users.close-friends-set", "stories.close-friends-set"), +) + + +# --------------------------------------------------------------------------- +# contact top list / set +# --------------------------------------------------------------------------- + + +class TopListReq(Request): + category: Annotated[ + list[str], + opt( + "--category", + metavar="NAME", + help=("Rating category; repeatable. " + ", ".join(TOP_CATEGORIES)), + ), + ] = [] + + +async def top_list(ctx: OpContext, req: TopListReq) -> Page[TopPeer]: + """Frequent contacts / top peers by category. + + `topPeersDisabled` is a real answer, not an empty one: the user turned + the feature off, and the ratings are gone server-side. Reporting it as + "no frequent contacts" would suggest there is something to look at. + """ + from telethon.tl.functions import contacts as fn + + wanted = list(req.category or ["correspondents"]) + unknown = [name for name in wanted if name not in TOP_CATEGORIES] + if unknown: + raise UsageError( + f"unknown --category {unknown}; pick from {', '.join(TOP_CATEGORIES)}", + field="category", + ) + limit, state = _window(ctx, "contact.top.list", PageKind.PARTICIPANTS, default=50) + offset = int(state.get("offset", 0) or 0) + + flags = {TOP_CATEGORIES[name]: True for name in wanted} + result = await client_of(ctx)( + fn.GetTopPeersRequest(offset=offset, limit=limit, hash=0, **flags) + ) + if type(result).__name__ == "TopPeersDisabled": + reason = ( + "frequent-contact collection is turned off for this account, so there are " + "no ratings to report; turn it back on with `tlgr contact top set on`" + ) + ctx.warn(reason) + mark = getattr(ctx, "mark_indeterminate", None) + if callable(mark): + mark(reason) + return Page(items=[], has_more=False, total=0) + known = peers_by_id(getattr(result, "users", None), getattr(result, "chats", None)) + names = {value: key for key, value in _TOP_TYPES.items()} + rows: list[TopPeer] = [] + for group in getattr(result, "categories", None) or []: + label = names.get(type(getattr(group, "category", None)).__name__, "correspondents") + for entry in getattr(group, "peers", None) or []: + rows.append( + TopPeer( + peer=peer_model(getattr(entry, "peer", None), known), + category=label, + rating=float(getattr(entry, "rating", 0.0) or 0.0), + ) + ) + return build_page( + rows, + op="contact.top.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(rows)}, + account=ctx.account, + limit=limit, + ) + + +SPEC_TOP_LIST = OperationSpec( + id="contact.top.list", + request=TopListReq, + response=Page[TopPeer], + impl=top_list, + summary="Frequent contacts / top peers by category", + description=( + "Ratings decay with the server's `rating_e_decay`. A disabled " + "feature answers exit 13, not an empty list: nothing was measured." + ), + paginated=PageKind.PARTICIPANTS, + columns=("category", "peer.title", "rating"), + headers=("Category", "Peer", "Rating"), + example={ + "items": [ + { + "peer": {"id": 777123, "raw_id": 777123, "kind": "user", "title": "Alice"}, + "category": "correspondents", + "rating": 12.5, + } + ], + "has_more": False, + }, + example_args="contact top list --category correspondents", + covers=("calls.top-callers", "contacts-users.top-peers-get"), +) + + +class TopSetReq(Request): + state: Annotated[str | None, arg(0, metavar="STATE", required=False, help="on | off")] = None + reset: Annotated[ + PeerRef | None, + opt("--reset", metavar="PEER", kind="peer", help="Zero this peer's rating instead."), + ] = None + category: Annotated[str, opt("--category", metavar="NAME", help="Category for --reset.")] = ( + "correspondents" + ) + + +async def top_set(ctx: OpContext, req: TopSetReq) -> TopPeerState: + """Enable/disable frequent-contact collection, or reset one peer's rating. + + Turning it off wipes the ratings server-side, so it is destructive even + though it looks like a switch. + """ + from telethon.tl import types + from telethon.tl.functions import contacts as fn + + if req.reset is not None: + constructor = _TOP_TYPES.get(req.category) + if constructor is None: + raise UsageError( + f"unknown --category {req.category!r}; pick from {', '.join(_TOP_TYPES)}", + field="category", + ) + peer = await _send.resolve(ctx, req.reset) + await client_of(ctx)( + fn.ResetTopPeerRatingRequest(category=getattr(types, constructor)(), peer=peer) + ) + marked = _send.peer_id_of(peer) + ctx.emit("top_peer_reset", {"peer_id": marked}) + return TopPeerState(reset_peer=marked, category=req.category) + + if req.state not in ("on", "off"): + raise UsageError("say `on` or `off`, or pass --reset ", field="state") + enabled = req.state == "on" + await client_of(ctx)(fn.ToggleTopPeersRequest(enabled=enabled)) + ctx.emit("top_peers_toggle", {"enabled": enabled}) + return TopPeerState(enabled=enabled, disabled_by_user=not enabled) + + +SPEC_TOP_SET = OperationSpec( + id="contact.top.set", + request=TopSetReq, + response=TopPeerState, + impl=top_set, + summary="Enable/disable frequent-contact collection, or reset one peer's rating", + description="Turning it off also wipes the ratings server-side, which is why it needs --yes.", + mutating=True, + destructive=True, + columns=("enabled", "reset_peer", "category"), + example={"enabled": True}, + example_args="contact top set on", + covers=( + "calls.reset-top-caller", + "contacts-users.top-peers-reset", + "contacts-users.top-peers-toggle", + "dialogs.top-peers-toggle", + ), +) + + +# --------------------------------------------------------------------------- +# contact share / share-phone +# --------------------------------------------------------------------------- + + +class ShareReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose card to send.")] + to: Annotated[ + PeerRef | None, opt("--to", metavar="CHAT", kind="peer", help="Destination chat.") + ] = None + + +async def share(ctx: OpContext, req: ShareReq) -> ContactShared: + """Send someone's contact card into a chat. + + The phone may be empty when their privacy hides it, which yields a card + without a number rather than an error — that is what the GUI sends too. + """ + from telethon.helpers import generate_random_long + from telethon.tl import types + from telethon.tl.functions import messages as mfn + + if req.to is None: + raise UsageError("--to names the chat to send the card into", field="to") + target = await input_user(ctx, req.user) + known = await fetch_user(ctx, target) + destination = await _send.resolve(ctx, req.to) + + updates = await client_of(ctx)( + mfn.SendMediaRequest( + peer=destination, + media=types.InputMediaContact( + phone_number=getattr(known, "phone", None) or "", + first_name=getattr(known, "first_name", None) or "", + last_name=getattr(known, "last_name", None) or "", + vcard="", + ), + message="", + random_id=generate_random_long(), + ) + ) + chat_id = _send.peer_id_of(destination) + message = _send.message_from_updates(updates, chat_id=chat_id) + ctx.emit("contact_share", {"chat_id": chat_id, "user_id": int(known.id)}) + return ContactShared(chat_id=chat_id, msg_id=message.id, contact=contact_model(known)) + + +SPEC_SHARE = OperationSpec( + id="contact.share", + request=ShareReq, + response=ContactShared, + impl=share, + summary="Send someone's contact card into a chat", + mutating=True, + rate_class="send", + columns=("chat_id", "msg_id"), + example={"chat_id": 777123, "msg_id": 4211}, + example_args="contact share @alice --to @bobby", + covers=("contacts-users.user-share-contact-card",), + tags=frozenset({"visible-to-others"}), +) + + +class SharePhoneReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to share it with.")] + + +async def share_phone(ctx: OpContext, req: SharePhoneReq) -> PhoneShared: + """Share my phone number with someone who added me as a contact. + + Only valid while `peerSettings.share_contact` is set — check + `chat action-bar` first — and irreversible: a number cannot be un-shared. + """ + from telethon.tl.functions import contacts as fn + + target = await input_user(ctx, req.user) + await client_of(ctx)(fn.AcceptContactRequest(id=target)) + user_id = int(getattr(target, "user_id", 0) or 0) + ctx.emit("contact_share_phone", {"user_id": user_id}) + return PhoneShared(user_id=user_id, shared=True) + + +SPEC_SHARE_PHONE = OperationSpec( + id="contact.share-phone", + request=SharePhoneReq, + response=PhoneShared, + impl=share_phone, + summary="Share my phone number with someone who added me as a contact", + description="Irreversible: your number cannot be un-shared once they have it.", + mutating=True, + destructive=True, + columns=("user_id", "shared"), + example={"user_id": 777123, "shared": True}, + example_args="contact share-phone @alice", + covers=("contacts-users.contact-accept-share-phone", "dialogs.actionbar-share-phone"), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# contact saved list / import / sync +# --------------------------------------------------------------------------- + + +async def _saved_contacts(ctx: OpContext) -> list[SavedPhoneContact]: + """`contacts.getSaved`, inside a takeout session when the server insists. + + TAKEOUT_REQUIRED is not a failure: it is Telegram saying "this is a data + export, open one". `TAKEOUT_INIT_DELAY_X` is a wait, and it surfaces as + a rate limit rather than an error. + """ + from telethon.tl.functions import InvokeWithTakeoutRequest + from telethon.tl.functions import account as afn + from telethon.tl.functions import contacts as fn + + client = client_of(ctx) + query = fn.GetSavedRequest() + try: + rows = await client(query) + except Exception as exc: + if "TAKEOUT" not in type(exc).__name__.upper() and "TAKEOUT" not in str(exc).upper(): + raise + session = await client(afn.InitTakeoutSessionRequest(contacts=True)) + rows = await client( + InvokeWithTakeoutRequest(takeout_id=int(getattr(session, "id", 0) or 0), query=query) + ) + out: list[SavedPhoneContact] = [] + for entry in list(rows or []): + date = getattr(entry, "date", None) + out.append( + SavedPhoneContact( + phone=e164(getattr(entry, "phone", "") or ""), + first_name=getattr(entry, "first_name", "") or "", + last_name=getattr(entry, "last_name", "") or "", + date=fmt_dt(date), + date_unix=to_unix(date), + ) + ) + return out + + +class SavedListReq(Request): + invite_text: Annotated[ + bool, opt("--invite-text", help="Also print the localized invite copy.") + ] = False + + +async def saved_list(ctx: OpContext, req: SavedListReq) -> Page[SavedPhoneContact]: + """Every phone number this account ever uploaded, Telegram account or not. + + A CLI cannot send an SMS, so `--invite-text` prints the copy Telegram + would have used and leaves the sending to a human. + """ + from telethon.tl.functions import help as hfn + + rows = await _saved_contacts(ctx) + _, users, _ = await load_contacts(ctx) + known = {e164(getattr(u, "phone", "") or "") for u in users if getattr(u, "phone", None)} + for row in rows: + row.has_account = row.phone in known if known else None + if req.invite_text and rows: + invite = await client_of(ctx)(hfn.GetInviteTextRequest()) + rows[0].invite_text = str(getattr(invite, "message", "") or "") + + limit, state = _window(ctx, "contact.saved.list", PageKind.LOCAL, default=100) + return _slice(rows, ctx, "contact.saved.list", int(state.get("offset", 0) or 0), limit) + + +SPEC_SAVED_LIST = OperationSpec( + id="contact.saved.list", + request=SavedListReq, + response=Page[SavedPhoneContact], + impl=saved_list, + summary="Every phone number this account ever uploaded, including non-Telegram ones", + description=( + "Needs a takeout session, which this opens automatically. " + "`has_account` is computed against the contact list, so it is null " + "when the contact list could not be read." + ), + paginated=PageKind.LOCAL, + rate_class="bulk", + timeout_s=300, + columns=("phone", "first_name", "last_name", "has_account"), + headers=("Phone", "First", "Last", "On Telegram"), + example={ + "items": [{"phone": "+15550001111", "first_name": "Alice", "has_account": True}], + "has_more": False, + }, + example_args="contact saved list", + covers=("contacts-users.contacts-saved-phonebook", "contacts-users.user-invite-friends"), +) + + +class ImportReq(Request): + file: Annotated[str, arg(0, metavar="FILE", kind="path", help="file.vcf | file.csv")] + batch_size: Annotated[ + int, opt("--batch-size", metavar="N", help="Contacts per call.", ge=1, le=500) + ] = IMPORT_BATCH + + +async def contact_import(ctx: OpContext, req: ImportReq) -> ContactImport: + """Bulk-import a phonebook from vCard or CSV. + + `retry_contacts` is not an error list: the server is asking for those + numbers again later, and a caller that drops them loses contacts + silently. They come back in `retry` so a second pass can send them. + """ + from telethon.tl import types + from telethon.tl.functions import contacts as fn + + entries = parse_phonebook(_read_file(req.file, "file")) + if not entries: + raise UsageError(f"{req.file} has no usable phone numbers in it", field="file") + + imported: list[ImportedPhone] = [] + retry: list[ImportedPhone] = [] + popular: list[ImportedPhone] = [] + batches = 0 + client = client_of(ctx) + for start in range(0, len(entries), req.batch_size): + chunk = entries[start : start + req.batch_size] + batches += 1 + result = await client( + fn.ImportContactsRequest( + [ + types.InputPhoneContact( + client_id=start + index, + phone=entry.phone, + first_name=entry.first_name or entry.phone, + last_name=entry.last_name, + ) + for index, entry in enumerate(chunk) + ] + ) + ) + by_client = {start + index: entry for index, entry in enumerate(chunk)} + for item in getattr(result, "imported", None) or []: + entry = by_client.get(int(item.client_id)) + if entry is not None: + imported.append( + ImportedPhone( + phone=entry.phone, + first_name=entry.first_name, + last_name=entry.last_name, + user_id=int(item.user_id), + ) + ) + for item in getattr(result, "popular_invites", None) or []: + entry = by_client.get(int(item.client_id)) + if entry is not None: + popular.append( + ImportedPhone( + phone=entry.phone, + first_name=entry.first_name, + last_name=entry.last_name, + importers=int(getattr(item, "importers", 0) or 0), + ) + ) + for client_id in getattr(result, "retry_contacts", None) or []: + entry = by_client.get(int(client_id)) + if entry is not None: + retry.append( + ImportedPhone( + phone=entry.phone, + first_name=entry.first_name, + last_name=entry.last_name, + retry=True, + ) + ) + + if retry: + ctx.warn( + f"{len(retry)} numbers came back in retry_contacts; the server wants them " + "sent again later. Re-run with a file containing just those." + ) + ctx.emit("contact_import", {"imported": len(imported), "retry": len(retry)}) + return ContactImport( + parsed=len(entries), + imported=imported, + retry=retry, + popular_invites=popular, + batches=batches, + flood_waits=int(getattr(ctx, "flood_wait_slept", 0) or 0), + ) + + +SPEC_IMPORT = OperationSpec( + id="contact.import", + request=ImportReq, + response=ContactImport, + impl=contact_import, + summary="Bulk-import a phonebook from vCard or CSV", + description=( + "Heavily flood-limited, so imports are chunked (`--batch-size`, 200 " + "by default) and paced by the session limiter. `popular_invites` " + "says how many other people already imported that number." + ), + mutating=True, + rate_class="bulk", + timeout_s=600, + columns=("parsed", "batches", "imported", "retry"), + example={"parsed": 2, "batches": 1, "imported": [{"phone": "+15550001111"}], "retry": []}, + example_args="contact import phonebook.vcf", + covers=("contacts-users.contacts-import-bulk",), +) + + +class SyncReq(Request): + file: Annotated[str, arg(0, metavar="FILE", kind="path", help="The phonebook to sync from.")] + delete_missing: Annotated[ + bool, opt("--delete-missing", help="Delete server contacts absent from the file.") + ] = False + apply: Annotated[ + bool, opt("--apply", help="Actually apply the diff (the default is to print it).") + ] = False + + +async def sync(ctx: OpContext, req: SyncReq) -> ContactSync: + """Two-way sync of a local phonebook file with the server contact list. + + A headless CLI has no OS address book, so the "device phonebook" is the + file you point at. Printing the diff is the default because deleting by + phone is irreversible server-side; `--apply` is what actually writes. + """ + from telethon.tl import types + from telethon.tl.functions import contacts as fn + + entries = parse_phonebook(_read_file(req.file, "file")) + _, users, _ = await load_contacts(ctx) + server = {e164(getattr(u, "phone", "") or ""): u for u in users if getattr(u, "phone", None)} + local = {entry.phone: entry for entry in entries} + + to_import = [entry for phone, entry in local.items() if phone not in server] + to_delete = [phone for phone in server if phone not in local] if req.delete_missing else [] + + if not req.apply: + return ContactSync(to_import=to_import, to_delete=to_delete, applied=False) + + imported = 0 + if to_import: + result = await client_of(ctx)( + fn.ImportContactsRequest( + [ + types.InputPhoneContact( + client_id=index, + phone=entry.phone, + first_name=entry.first_name or entry.phone, + last_name=entry.last_name, + ) + for index, entry in enumerate(to_import) + ] + ) + ) + imported = len(getattr(result, "imported", None) or []) + if to_delete: + await client_of(ctx)(fn.DeleteByPhonesRequest(phones=to_delete)) + ctx.emit("contact_sync", {"imported": imported, "deleted": len(to_delete)}) + return ContactSync( + to_import=to_import, + to_delete=to_delete, + applied=True, + imported=imported, + deleted=len(to_delete), + ) + + +SPEC_SYNC = OperationSpec( + id="contact.sync", + request=SyncReq, + response=ContactSync, + impl=sync, + summary="Two-way sync of a local phonebook file with the server contact list", + description=( + "Prints the diff and changes nothing unless `--apply` is given, " + "because `contacts.deleteByPhones` is irreversible server-side." + ), + mutating=True, + destructive=True, + rate_class="bulk", + timeout_s=600, + columns=("applied", "imported", "deleted"), + example={"to_import": [{"phone": "+15550001111"}], "to_delete": [], "applied": False}, + example_args="contact sync phonebook.vcf", + covers=("contacts-users.contacts-sync", "privacy.sync-contacts-delete"), +) diff --git a/tlgr/ops/resolve.py b/tlgr/ops/resolve.py new file mode 100644 index 0000000..1d1c012 --- /dev/null +++ b/tlgr/ops/resolve.py @@ -0,0 +1,1140 @@ +"""The `resolve` group: references, links and the per-account peer cache. + +This is the group whose entire job is to be honest about *how* an answer was +reached, because every other group depends on it being right. + +* **A bare numeric id cannot be turned into an access hash.** There is no + MTProto call that does it for a non-bot account: `users.getUsers` with + `access_hash=0` answers `UserEmpty` for any non-contact. So an uncached id + fails with NOT_FOUND or INDETERMINATE rather than being guessed, which is + the trap `user dialog-status` was built around. +* **`PHONE_NOT_OCCUPIED` is ambiguous.** No account, or an owner who refuses + lookups by phone — the two are indistinguishable from here, so + `resolve phone` exits 13 INDETERMINATE, never 5. +* **Resolution never acts.** Joining a chat, starting a bot, installing a + theme or a sticker set, enabling a proxy, applying a boost, redeeming a + gift: each is a separate, confirmed command in its own group, and + `resolve link` names it in `delegated_to` instead of doing it. +* **Access hashes are per login session.** They are never printed and never + copied between accounts; `access_hash_cached` is the only thing said about + them. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import contextlib +import time +from datetime import datetime, timezone +from typing import Annotated, Any +from urllib.parse import parse_qsl, urlsplit + +from tlgr.core.errors import NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page, decode_cursor +from tlgr.core.timefmt import fmt_dt +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.peer import Peer, PeerRef, parse_peer_ref +from tlgr.models.resolve import ( + CachedPeerRow, + ResolvedLink, + ResolvedPhone, + ResolvedRef, + ResolvedUsername, +) +from tlgr.ops import _send +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._serialize import entity_to_peer +from tlgr.ops._spec import OpContext, OperationSpec +from tlgr.ops.contact import client_of, e164 + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: Bot API ids offset channels by this much; the two id spaces differ and a +#: caller moving between them should not have to remember the arithmetic. +_CHANNEL_MARK = -1000000000000 + +#: `tg://` paths that name a settings screen rather than a peer. +_SETTINGS_SECTIONS = frozenset( + { + "settings", + "privacy", + "language", + "themes", + "devices", + "folders", + "chat_folders", + "stickers", + "premium", + "premium_offer", + "premium_multigift", + "stars", + "stars_topup", + "giftcode", + "restore_purchases", + "passport", + "change_number", + "auto_delete", + "edit_profile", + } +) + +#: `t.me/contacts/
` — a screen in the contacts UI, not an RPC. +_CONTACT_SECTIONS = frozenset({"new", "search", "sort", "invite", "manage"}) + +#: kind → the command that would *act* on a link of that kind. +DELEGATES: dict[str, str] = { + "invite": "chat join", + "chatlist-invite": "folder join", + "folder": "folder join", + "bot-start": "bot start", + "bot-startgroup": "bot add", + "bot-startchannel": "bot add", + "webapp": "webapp open", + "proxy": "proxy add", + "boost": "boost apply", + "giftcode": "gift redeem", + "unique-gift": "gift get", + "stars-topup": "stars buy", + "stickerset": "sticker set install", + "emojiset": "sticker set install", + "theme": "settings theme install", + "wallpaper": "chat wallpaper set", + "contact-token": "contact add", + "share-url": "message send", + "business-chat-link": "message send", + "message": "message get", + "private-post": "message get", + "story": "story get", + "public-username": "chat get", + "phone": "user get", +} + +_EXAMPLE_REF: dict[str, Any] = { + "ref": "@alice", + "kind": "username", + "id": 777123, + "marked_id": 777123, + "type": "user", + "title": "Alice", + "username": "alice", + "source": "resolve_username", + "resolved": True, +} + + +def raw_id(marked: int) -> int: + """The unmarked MTProto id behind a marked one. + + The two id spaces differ only for chats and channels — `-100…` and `-` + are the marks — and every caller that has reimplemented this arithmetic + has eventually got a channel id wrong. `resolve peer` emits both. + """ + if marked < _CHANNEL_MARK: + return _CHANNEL_MARK - marked + if marked < 0: + return -marked + return marked + + +def _peer_of(entity: Any) -> Peer: + return entity_to_peer(entity) + + +def _kind_matches(kind: str, wanted: str) -> bool: + return kind in { + "user": {"user", "saved"}, + "bot": {"bot"}, + "group": {"group", "supergroup"}, + "channel": {"channel"}, + }.get(wanted, {wanted}) + + +# --------------------------------------------------------------------------- +# resolve username +# --------------------------------------------------------------------------- + + +class UsernameReq(Request): + username: Annotated[str, arg(0, metavar="USERNAME", help="With or without the @.")] + referer: Annotated[ + str | None, + opt("--referer", metavar="USERNAME", help="Attribute the resolution to a referrer."), + ] = None + type: Annotated[ + str | None, + choice("user", "bot", "group", "channel", help="Fail unless the result is of this kind."), + ] = None + + +async def username(ctx: OpContext, req: UsernameReq) -> ResolvedUsername: + """Resolve a public @username to a peer. + + `USERNAME_INVALID` (malformed) and `USERNAME_NOT_OCCUPIED` (free) are + different answers and get different exit codes — 2 and 5 — because "you + typed it wrong" and "nobody has it" call for different reactions. + + This hits the network every time and floods at roughly fifty lookups in a + short window, so the returned access hash is persisted in the per-account + peer cache on the way out. + """ + from telethon.tl.functions import contacts as fn + + handle = (req.username or "").strip().lstrip("@") + if not handle: + raise UsageError("a username is required", field="username") + + kwargs: dict[str, Any] = {"username": handle} + if req.referer: + # Layer 224+ only; an older build simply does not accept the field, + # and losing the attribution is better than losing the resolution. + try: + request = fn.ResolveUsernameRequest(referer=req.referer, **kwargs) + except TypeError: + ctx.warn("this Telethon build has no --referer support; resolving without it") + request = fn.ResolveUsernameRequest(**kwargs) + else: + request = fn.ResolveUsernameRequest(**kwargs) + + try: + result = await client_of(ctx)(request) + except Exception as exc: + name = type(exc).__name__ + if name == "UsernameInvalidError": + raise UsageError(f"@{handle} is not a valid username", field="username") from exc + if name == "UsernameNotOccupiedError": + raise NotFoundError(f"nobody holds @{handle}") from exc + raise + + entities = list(getattr(result, "users", None) or []) + list( + getattr(result, "chats", None) or [] + ) + if not entities: + raise NotFoundError(f"nobody holds @{handle}") + peer = _peer_of(entities[0]) + if req.type and not _kind_matches(peer.kind, req.type): + raise NotFoundError(f"@{handle} is a {peer.kind}, not a {req.type}") + + # Persist what we paid a round trip for. + resolver = getattr(ctx, "resolver", None) + if resolver is not None: + with contextlib.suppress(Exception): + resolver._remember(entities[0], username=handle) + return ResolvedUsername( + kind=peer.kind, + peer=peer, + username=handle, + access_hash_cached=bool(getattr(entities[0], "access_hash", None)), + ) + + +SPEC_USERNAME = OperationSpec( + id="resolve.username", + request=UsernameReq, + response=ResolvedUsername, + impl=username, + summary="Resolve a public @username to a peer", + description=( + "USERNAME_INVALID exits 2 and USERNAME_NOT_OCCUPIED exits 5: a typo " + "and a free username are different answers. Resolution always hits " + "the network and floods at roughly fifty lookups in a short period, " + "so the access hash is cached for a day afterwards." + ), + rate_class="resolve", + columns=("kind", "peer.id", "peer.title", "username"), + example={ + "kind": "user", + "username": "alice", + "peer": {"id": 777123, "raw_id": 777123, "kind": "user", "title": "Alice"}, + }, + example_args="resolve username @alice", + covers=("contacts-users.search-public-chat",), +) + + +# --------------------------------------------------------------------------- +# resolve phone +# --------------------------------------------------------------------------- + + +class PhoneReq(Request): + phone: Annotated[str, arg(0, metavar="PHONE", help="+countrycode number.")] = "" + offline: Annotated[bool, opt("--offline", help="Format and validate only; perform no RPC.")] = ( + False + ) + countries: Annotated[bool, opt("--countries", help="Dump the country/prefix/format table.")] = ( + False + ) + lang: Annotated[str, opt("--lang", metavar="CODE", help="Language for the table.")] = "" + + +async def _country_table(ctx: OpContext, lang: str) -> list[dict[str, Any]]: + from telethon.tl.functions import help as hfn + + result = await client_of(ctx)(hfn.GetCountriesListRequest(lang_code=lang or "", hash=0)) + out: list[dict[str, Any]] = [] + for country in getattr(result, "countries", None) or []: + for code in getattr(country, "country_codes", None) or []: + out.append( + { + "iso2": getattr(country, "iso2", None), + "name": getattr(country, "default_name", None), + "prefix": "+" + str(getattr(code, "country_code", "") or ""), + "patterns": list(getattr(code, "patterns", None) or []), + } + ) + return out + + +def _match_country(number: str, table: list[dict[str, Any]]) -> dict[str, Any] | None: + """Longest prefix wins: +1 and +1204 both exist and only one is right.""" + best: dict[str, Any] | None = None + for row in table: + prefix = str(row.get("prefix") or "") + if ( + len(prefix) > 1 + and number.startswith(prefix) + and (best is None or len(prefix) > len(str(best.get("prefix") or ""))) + ): + best = row + return best + + +def _unknown(ctx: OpContext, out: ResolvedPhone, reason: str) -> ResolvedPhone: + """Report a lookup we could not settle, and make the process fail closed. + + An exception would throw away `reason` and the formatting work already + done; `mark_indeterminate` keeps the body and still exits 13. + """ + out.resolved = False + out.reason = reason + mark = getattr(ctx, "mark_indeterminate", None) + if callable(mark): + mark(reason) + return out + + +async def phone(ctx: OpContext, req: PhoneReq) -> ResolvedPhone: + """Resolve a phone number to a user, without adding a contact. + + `PHONE_NOT_OCCUPIED` is genuinely ambiguous — the number may have no + account, or its owner may hide themselves behind + `inputPrivacyKeyAddedByPhone` — so this exits 13 INDETERMINATE and never + "not found". Unlike `contact add`, nothing is saved to the address book. + + Telegram asks for at most one of these every three seconds, which is why + `--offline` exists: format and validate locally first. + """ + from telethon.tl.functions import contacts as fn + + number = e164(req.phone) + table: list[dict[str, Any]] = [] + if req.countries and not number: + table = await _country_table(ctx, req.lang) + return ResolvedPhone(phone="", e164="", resolved=False, countries=table) + if not number: + raise UsageError("a phone number is required", field="phone") + + out = ResolvedPhone(phone=req.phone, e164=number) + if req.offline or req.countries: + table = await _country_table(ctx, req.lang) + match = _match_country(number, table) + if match is not None: + out.country = str(match.get("name") or "") + out.prefix = str(match.get("prefix") or "") + patterns = list(match.get("patterns") or []) + out.pattern = str(patterns[0]) if patterns else None + if req.countries: + out.countries = table + if req.offline: + out.reason = "offline: the number was formatted and validated, not looked up" + return out + + try: + result = await client_of(ctx)(fn.ResolvePhoneRequest(phone=number.lstrip("+"))) + except Exception as exc: + name = type(exc).__name__ + if name == "PhoneNumberInvalidError": + raise UsageError(f"{number} is not a valid phone number", field="phone") from exc + # Everything else — including PHONE_NOT_OCCUPIED — is "we could not + # establish it", and a caller must not read it as "no account". The + # body is still returned so `reason` survives; the exit code is 13. + return _unknown( + ctx, + out, + f"{type(exc).__name__}: the number may have no Telegram account, OR its " + "owner may refuse lookups by phone. These are not distinguishable.", + ) + + entities = list(getattr(result, "users", None) or []) + list( + getattr(result, "chats", None) or [] + ) + if not entities: + return _unknown( + ctx, out, "the server answered with no peer: no account, or a privacy refusal" + ) + out.peer = _peer_of(entities[0]) + out.resolved = True + return out + + +SPEC_PHONE = OperationSpec( + id="resolve.phone", + request=PhoneReq, + response=ResolvedPhone, + impl=phone, + summary="Resolve a phone number to a user without adding a contact", + description=( + "PHONE_NOT_OCCUPIED exits 13, never 5: no account and a privacy " + "refusal are indistinguishable from here. The server asks for at " + "most one lookup every three seconds, so --offline formats and " + "validates against help.getCountriesList without an RPC." + ), + rate_class="resolve", + min_interval_s=3.0, + columns=("e164", "country", "resolved"), + example={"phone": "+15550001111", "e164": "+15550001111", "resolved": False}, + example_args="resolve phone +15550001111 --offline", + covers=("contacts-users.phone-number-info", "contacts-users.resolve-phone"), +) + + +# --------------------------------------------------------------------------- +# resolve peer +# --------------------------------------------------------------------------- + + +class PeerReq(Request): + ref: Annotated[ + list[str], + arg(0, metavar="REF", variadic=True, help="@username, id, marked id, +phone, link, me."), + ] = [] + from_chat: Annotated[ + PeerRef | None, + opt("--from-chat", metavar="CHAT", kind="peer", help="Context chat for a `min` peer."), + ] = None + from_message: Annotated[ + int | None, + opt("--from-message", metavar="ID", kind="msg_id", help="Message id in --from-chat."), + ] = None + ids: Annotated[ + str | None, + choice("mtproto", "botapi", help="Also emit the id in the other id space."), + ] = None + cache_only: Annotated[bool, opt("--cache-only", help="Never hit the network.")] = False + + +async def _describe(ctx: OpContext, target: Any) -> tuple[str, str]: + """`(type, title)` for a resolved peer, from whatever is already known.""" + kind = { + "InputPeerUser": "user", + "InputPeerUserFromMessage": "user", + "InputPeerChat": "group", + "InputPeerChannel": "channel", + "InputPeerChannelFromMessage": "channel", + "InputPeerSelf": "saved", + }.get(type(target).__name__, "unknown") + title = "" + with contextlib.suppress(Exception): + entity = await client_of(ctx).get_entity(target) + peer = _peer_of(entity) + kind, title = peer.kind, peer.title + return kind, title + + +async def peer(ctx: OpContext, req: PeerReq) -> Page[ResolvedRef]: + """Resolve any peer reference to a normalised peer object. + + Order: cache → `resolveUsername`/`resolvePhone` → `getPeerDialogs` → + dialog-list scan → `contacts.search`, cheapest first, and every step + exists because the one before it cannot answer. A bare numeric id that + nothing has cached fails — there is no call that mints an access hash for + it — rather than being guessed at. + + `--from-chat/--from-message` builds `inputPeerUserFromMessage` for a + `min` peer, which Telethon never builds and which is what makes a + stranger seen in a channel actionable. + """ + from telethon import utils + from telethon.tl import types + + if not req.ref: + raise UsageError("give at least one reference to resolve", field="ref") + + rows: list[ResolvedRef] = [] + for raw in req.ref: + row = ResolvedRef(ref=raw) + try: + parsed = parse_peer_ref(raw) + except ValueError as exc: + row.reason = str(exc) + rows.append(row) + continue + row.kind = parsed.kind + + if req.from_chat is not None and req.from_message is not None and parsed.kind == "id": + container = await _send.resolve(ctx, req.from_chat) + target: Any = types.InputPeerUserFromMessage( + peer=container, msg_id=int(req.from_message), user_id=abs(int(parsed.value)) + ) + row.source = "from_message" + row.min = True + else: + resolver = getattr(ctx, "resolver", None) + if resolver is None: # pragma: no cover - the daemon always supplies one + raise UsageError("no peer resolver is available in this context") + try: + target = await resolver.resolve(parsed, allow_network=not req.cache_only) + except Exception as exc: + row.reason = f"{type(exc).__name__}: {exc}" + rows.append(row) + if len(req.ref) == 1: + raise + continue + row.source = "cache" if req.cache_only else _source_for(parsed.kind) + + with contextlib.suppress(TypeError, ValueError): + row.marked_id = int(utils.get_peer_id(target)) + # `id` is the raw MTProto id, `marked_id` the signed form every tlgr + # response uses (COR-10). Both are always present so nobody has to + # redo the sign arithmetic; --ids adds the Bot API spelling, which is + # the marked one. + row.id = raw_id(row.marked_id) if row.marked_id is not None else None + row.access_hash_cached = bool(int(getattr(target, "access_hash", 0) or 0)) + row.type, row.title = await _describe(ctx, target) + row.username = str(parsed.value) if parsed.kind == "username" else None + if req.ids is not None: + row.botapi_id = row.marked_id if req.ids == "botapi" else row.id + row.resolved = row.marked_id is not None + rows.append(row) + + limit = int(getattr(ctx, "limit", None) or 100) + return build_page( + rows[:limit], + op="resolve.peer", + kind=PageKind.LOCAL, + state={"offset": limit}, + account=ctx.account, + has_more=len(rows) > limit, + total=len(rows), + ) + + +def _source_for(kind: str) -> str: + return { + "username": "resolve_username", + "phone": "resolve_phone", + "id": "cache_or_dialogs", + "invite": "check_chat_invite", + "self": "self", + "saved": "self", + "link": "link", + }.get(kind, kind) + + +SPEC_PEER = OperationSpec( + id="resolve.peer", + request=PeerReq, + response=Page[ResolvedRef], + impl=peer, + summary="Resolve any peer reference to a normalised peer object", + description=( + "There is NO method that turns a bare id into an access hash, so an " + "uncached numeric id fails (exit 5 or 13) instead of guessing — that " + "is the trap `user dialog-status` was built around. Access hashes " + "are per account and never printed." + ), + paginated=PageKind.LOCAL, + rate_class="resolve", + columns=("ref", "id", "type", "title", "source"), + headers=("Ref", "Id", "Kind", "Title", "How"), + example={"items": [_EXAMPLE_REF], "has_more": False}, + example_args="resolve peer @alice", + covers=("contacts-users.peer-id-conversion", "dialogs.resolve-peer"), +) + + +# --------------------------------------------------------------------------- +# resolve link +# --------------------------------------------------------------------------- + + +class LinkReq(Request): + url: Annotated[str, arg(0, metavar="URL", help="Any t.me / tg:// link, or a bare slug.")] + no_network: Annotated[ + bool, opt("--no-network", help="Classify from the URL only; resolve nothing.") + ] = False + open: Annotated[ + bool, opt("--open", help="Perform the follow-up read for the classified kind.") + ] = False + draft: Annotated[ + PeerRef | None, + opt("--draft", metavar="CHAT", kind="peer", help="Save the carried text as a draft here."), + ] = None + + +def _split(url: str) -> tuple[str, list[str], dict[str, str]]: + """`(scheme, path segments, query)` for a t.me or tg:// reference.""" + text = (url or "").strip() + if text.lower().startswith("tg://"): + rest = text[5:] + head, _, query = rest.partition("?") + return "tg", [s for s in head.split("/") if s], dict(parse_qsl(query)) + if "://" not in text: + text = "https://" + text.lstrip("/") + parts = urlsplit(text) + host = (parts.netloc or "").lower() + if host not in ("t.me", "telegram.me", "telegram.dog", "www.t.me"): + return "", [], {} + return "tme", [s for s in parts.path.split("/") if s], dict(parse_qsl(parts.query)) + + +def classify(url: str) -> ResolvedLink: + """Classify a link from its shape alone. No network, no side effects. + + One function rather than twenty commands because the human pasting a + link does not know which of the twenty kinds it is — that is the + question. `unknown` is a real answer and keeps the raw path. + """ + out = ResolvedLink(kind="unknown", raw_url=url) + scheme, segments, query = _split(url) + out.scheme = scheme + if not scheme: + return out + + if scheme == "tg": + verb = (segments[0] if segments else "").lower() + return _classify_tg(out, verb, query) + + if not segments: + return out + + head = segments[0] + lowered = head.lower() + + if lowered == "contact" and len(segments) > 1: + out.kind = "contact-token" + out.contact_token = segments[1] + elif lowered == "addlist" and len(segments) > 1: + out.kind = "chatlist-invite" + out.chatlist_slug = segments[1] + elif lowered == "list" and len(segments) > 1: + out.kind = "folder" + out.chatlist_slug = segments[1] + elif lowered in ("addstickers", "addemoji") and len(segments) > 1: + out.kind = "emojiset" if lowered == "addemoji" else "stickerset" + out.stickerset = segments[1] + elif lowered == "addtheme" and len(segments) > 1: + out.kind = "theme" + out.theme = segments[1] + elif lowered == "bg" and len(segments) > 1: + out.kind = "wallpaper" + out.wallpaper = segments[1] + elif lowered == "proxy" or lowered == "socks": + out.kind = "proxy" + out.proxy = dict(query) + elif lowered == "share" and query: + out.kind = "share-url" + out.share = dict(query) + elif lowered == "giftcode" and len(segments) > 1: + out.kind = "giftcode" + out.gift = segments[1] + elif lowered == "nft" and len(segments) > 1: + out.kind = "unique-gift" + out.gift = segments[1] + elif lowered == "boost": + out.kind = "boost" + out.boost = True + out.username = query.get("c") or (segments[1] if len(segments) > 1 else None) + elif lowered == "m" and len(segments) > 1: + out.kind = "business-chat-link" + out.start_param = segments[1] + elif lowered == "invoice" and len(segments) > 1: + out.kind = "invoice" + out.start_param = segments[1] + elif lowered == "login" and len(segments) > 1: + out.kind = "login-code" + out.start_param = segments[1] + elif lowered == "contacts" and len(segments) > 1 and segments[1].lower() in _CONTACT_SECTIONS: + out.kind = "contacts-section" + out.section = segments[1].lower() + elif head.startswith("+") or lowered == "joinchat": + value = head[1:] if head.startswith("+") else (segments[1] if len(segments) > 1 else "") + # `t.me/+15550001111` is a PHONE when it parses as a number; only + # otherwise is it an invite hash. Guessing the wrong one turns a + # contact lookup into a join. + if value.isdigit(): + out.kind = "phone" + out.phone = "+" + value + elif value: + out.kind = "invite" + out.invite_hash = value + elif lowered == "c" and len(segments) > 2 and segments[1].isdigit(): + out.kind = "private-post" + out.msg_id = int(segments[2]) if segments[2].isdigit() else None + out.username = None + out.thread_id = int(segments[3]) if len(segments) > 3 and segments[3].isdigit() else None + elif lowered == "s" and len(segments) > 1: + out.kind = "public-username" + out.username = segments[1].lower() + else: + out.username = head.lower() + if len(segments) > 1 and segments[1].isdigit(): + out.kind = "message" + out.msg_id = int(segments[1]) + if len(segments) > 2 and segments[2].isdigit(): + out.thread_id, out.msg_id = out.msg_id, int(segments[2]) + elif len(segments) > 1 and segments[1].lower() == "s" and len(segments) > 2: + out.kind = "story" + out.story_id = int(segments[2]) if segments[2].isdigit() else None + elif "start" in query: + out.kind = "bot-start" + out.bot = head.lower() + out.start_param = query["start"] + elif "startgroup" in query: + out.kind = "bot-startgroup" + out.bot = head.lower() + out.start_param = query["startgroup"] + elif "startchannel" in query: + out.kind = "bot-startchannel" + out.bot = head.lower() + out.start_param = query["startchannel"] + elif "startapp" in query or "appname" in query: + out.kind = "webapp" + out.bot = head.lower() + out.start_param = query.get("startapp") or query.get("appname") + else: + out.kind = "public-username" + + if "comment" in query and query["comment"].isdigit(): + out.comment_id = int(query["comment"]) + if "thread" in query and query["thread"].isdigit(): + out.thread_id = int(query["thread"]) + if "single" in query and out.kind == "message": + out.start_target = "single" + return out + + +def _classify_tg(out: ResolvedLink, verb: str, query: dict[str, str]) -> ResolvedLink: + if verb == "resolve": + out.username = (query.get("domain") or "").lower() or None + out.phone = ("+" + query["phone"]) if query.get("phone") else None + if query.get("post", "").isdigit(): + out.kind = "message" + out.msg_id = int(query["post"]) + elif "start" in query: + out.kind = "bot-start" + out.bot = out.username + out.start_param = query["start"] + elif "startapp" in query: + out.kind = "webapp" + out.bot = out.username + out.start_param = query["startapp"] + elif out.phone: + out.kind = "phone" + else: + out.kind = "public-username" + elif verb == "join": + out.kind = "invite" + out.invite_hash = query.get("invite") + elif verb == "privatepost": + out.kind = "private-post" + out.msg_id = int(query["post"]) if query.get("post", "").isdigit() else None + elif verb in ("addstickers", "addemoji"): + out.kind = "emojiset" if verb == "addemoji" else "stickerset" + out.stickerset = query.get("set") + elif verb == "addtheme": + out.kind = "theme" + out.theme = query.get("slug") + elif verb in ("bg", "wallpaper"): + out.kind = "wallpaper" + out.wallpaper = query.get("slug") or query.get("color") + elif verb in ("proxy", "socks"): + out.kind = "proxy" + out.proxy = dict(query) + elif verb == "msg_url": + out.kind = "share-url" + out.share = dict(query) + elif verb == "boost": + out.kind = "boost" + out.boost = True + out.username = (query.get("domain") or "").lower() or None + elif verb == "giftcode": + out.kind = "giftcode" + out.gift = query.get("slug") + elif verb == "nft": + out.kind = "unique-gift" + out.gift = query.get("slug") + elif verb in ("stars_topup", "premium_offer"): + out.kind = "stars-topup" if verb == "stars_topup" else "premium-offer" + out.stars = int(query["balance"]) if query.get("balance", "").isdigit() else None + elif verb == "confirmphone": + out.kind = "confirm-phone" + out.phone = ("+" + query["phone"]) if query.get("phone") else None + elif verb == "login": + out.kind = "login-code" + out.start_param = query.get("code") + elif verb == "message": + out.kind = "business-chat-link" + out.start_param = query.get("slug") + elif verb == "invoice": + out.kind = "invoice" + out.start_param = query.get("slug") + elif verb in _SETTINGS_SECTIONS: + out.kind = "settings-section" + out.section = verb + elif verb == "contacts": + out.kind = "contacts-section" + out.section = (query.get("section") or "new").lower() + return out + + +async def _open(ctx: OpContext, out: ResolvedLink) -> None: + """The follow-up *read* for a classified link. Never an action.""" + from telethon.tl import types as tl + from telethon.tl.functions import account as afn + from telethon.tl.functions import contacts as cfn + from telethon.tl.functions import messages as mfn + from telethon.tl.functions import payments as pfn + from telethon.tl.functions import premium as prfn + from telethon.tl.functions import stories as sfn + + client = client_of(ctx) + kind = out.kind + if kind in ("public-username", "bot-start", "bot-startgroup", "bot-startchannel", "webapp"): + found = await client(cfn.ResolveUsernameRequest(out.username or out.bot or "")) + entities = list(getattr(found, "users", None) or []) + list( + getattr(found, "chats", None) or [] + ) + if entities: + out.peer = _peer_of(entities[0]) + elif kind == "phone" and out.phone: + found = await client(cfn.ResolvePhoneRequest(phone=out.phone.lstrip("+"))) + entities = list(getattr(found, "users", None) or []) + if entities: + out.peer = _peer_of(entities[0]) + elif kind == "invite" and out.invite_hash: + preview = await client(mfn.CheckChatInviteRequest(hash=out.invite_hash)) + out.title = str(getattr(preview, "title", "") or "") + chat = getattr(preview, "chat", None) + out.peer = _peer_of(chat) if chat is not None else None + out.opened = {"already_member": type(preview).__name__ == "ChatInviteAlready"} + elif kind in ("chatlist-invite", "folder") and out.chatlist_slug: + from telethon.tl.functions import chatlists as clfn + + preview = await client(clfn.CheckChatlistInviteRequest(slug=out.chatlist_slug)) + title = getattr(preview, "title", None) + out.title = str(getattr(title, "text", title) or "") + elif kind == "contact-token" and out.contact_token: + imported = await client(cfn.ImportContactTokenRequest(token=out.contact_token)) + out.peer = _peer_of(imported) if imported is not None else None + elif kind == "business-chat-link" and out.start_param: + resolved = await client(afn.ResolveBusinessChatLinkRequest(slug=out.start_param)) + message = getattr(resolved, "message", None) + out.opened = {"text": message} + elif kind in ("message", "private-post") and out.msg_id: + from tlgr.core.peers import channel_id_from_link + + if out.username: + reference: Any = "@" + out.username + else: + found_link = channel_id_from_link(out.raw_url) + if found_link is None: + raise NotFoundError("that private-post link carries no channel id") + # A bare channel id needs an access hash this account already + # holds; there is no call that mints one, so this fails loudly. + reference = str(found_link[0]) + target = await _send.resolve(ctx, reference) + found = await client.get_messages(target, ids=[out.msg_id]) + text = next((getattr(m, "message", "") for m in found or [] if m is not None), "") + out.peer = out.peer or Peer( + id=_send.peer_id_of(target), raw_id=abs(_send.peer_id_of(target)), kind="unknown" + ) + out.opened = {"text": text} + elif kind == "story" and out.story_id and out.username: + target = await _send.resolve(ctx, "@" + out.username) + found = await client(sfn.GetStoriesByIDRequest(peer=target, id=[out.story_id])) + out.opened = {"stories": len(list(getattr(found, "stories", None) or []))} + elif kind == "boost" and out.username: + target = await _send.resolve(ctx, "@" + out.username) + status = await client(prfn.GetBoostsStatusRequest(peer=target)) + out.opened = { + "level": getattr(status, "level", None), + "boosts": getattr(status, "boosts", None), + } + elif kind == "giftcode" and out.gift: + info = await client(pfn.CheckGiftCodeRequest(slug=out.gift)) + out.opened = {"used": bool(getattr(info, "used_date", None))} + elif kind == "unique-gift" and out.gift: + info = await client(pfn.GetUniqueStarGiftRequest(slug=out.gift)) + out.opened = {"title": getattr(getattr(info, "gift", None), "title", None)} + elif kind in ("stickerset", "emojiset") and out.stickerset: + info = await client( + mfn.GetStickerSetRequest( + stickerset=tl.InputStickerSetShortName(short_name=out.stickerset), hash=0 + ) + ) + out.title = str(getattr(getattr(info, "set", None), "title", "") or "") + elif kind == "theme" and out.theme: + info = await client( + afn.GetThemeRequest(format="android", theme=tl.InputThemeSlug(slug=out.theme)) + ) + out.title = str(getattr(info, "title", "") or "") + elif kind == "wallpaper" and out.wallpaper: + info = await client( + afn.GetWallPaperRequest(wallpaper=tl.InputWallPaperSlug(slug=out.wallpaper)) + ) + out.opened = {"id": getattr(info, "id", None)} + + +async def link(ctx: OpContext, req: LinkReq) -> ResolvedLink: + """Normalise any t.me / tg:// link into a typed object. + + Classification is local and always happens; `--open` adds the read that + matches the kind. Nothing here ever *acts*: joining, starting a bot, + installing a theme, enabling a proxy, applying a boost and redeeming a + gift are separate confirmed verbs, and `delegated_to` names the one this + link would need. + """ + from telethon.tl.functions import help as hfn + + out = classify(req.url) + out.delegated_to = DELEGATES.get(out.kind) + out.requires_action = out.kind in DELEGATES and out.kind not in ( + "public-username", + "message", + "private-post", + "phone", + ) + + if out.kind == "unknown" and out.scheme == "tg" and not req.no_network: + # Telegram adds deep links faster than any client learns them; + # `help.getDeepLinkInfo` is the server telling us what it means. The + # query is deliberately not sent — it can carry a token. + path = req.url.split("://", 1)[-1].split("?", 1)[0] + info = await client_of(ctx)(hfn.GetDeepLinkInfoRequest(path=path)) + message = getattr(info, "message", None) + if message: + out.deeplink_info = str(message) + + if req.no_network: + return out + if req.open: + try: + await _open(ctx, out) + except Exception as exc: + ctx.warn(f"--open could not read this link: {type(exc).__name__}: {exc}") + + if req.draft is not None: + text = (out.share or {}).get("text") or (out.opened or {}).get("text") + if not text: + raise UsageError("this link carries no text to save as a draft", field="draft") + if getattr(ctx, "dry_run", False): + ctx.warn("--dry-run: the carried text would be saved as a draft") + else: + from telethon.tl.functions import messages as mfn + + target = await _send.resolve(ctx, req.draft) + await client_of(ctx)(mfn.SaveDraftRequest(peer=target, message=str(text))) + out.draft_saved = True + return out + + +SPEC_LINK = OperationSpec( + id="resolve.link", + request=LinkReq, + response=ResolvedLink, + impl=link, + summary="Normalise any t.me / tg:// link into a typed JSON object", + description=( + "One dispatcher and one discriminated union. `t.me/+X` is a PHONE " + "when X parses as a number and an invite hash otherwise. " + "`t.me/c//` carries a bare channel id, so the access hash " + "must come from this account's peer cache — it fails loudly rather " + "than guessing. Resolution NEVER acts: `delegated_to` names the " + "command that would." + ), + rate_class="resolve", + tags=frozenset({"mutating-checked"}), + columns=("kind", "username", "msg_id", "delegated_to"), + example={ + "kind": "message", + "raw_url": "https://t.me/alice/4210", + "scheme": "tme", + "username": "alice", + "msg_id": 4210, + "delegated_to": "message get", + }, + example_args="resolve link https://t.me/alice/4210", + covers=( + "contact.share-token", + "contacts-users.contacts-deeplink-sections", + "contacts-users.resolve-account-maintenance-links", + "contacts-users.resolve-boost-link", + "contacts-users.resolve-bot-start-link", + "contacts-users.resolve-business-chat-link", + "contacts-users.resolve-deeplink", + "contacts-users.resolve-gift-link", + "contacts-users.resolve-invite-link", + "contacts-users.resolve-message-link", + "contacts-users.resolve-proxy-link", + "contacts-users.resolve-share-url-link", + "contacts-users.resolve-stickerset-link", + "contacts-users.resolve-story-link", + "contacts-users.resolve-theme-wallpaper-link", + "contacts-users.resolve-unknown-deeplink", + "dialogs.business-link-resolve", + ), +) + + +# --------------------------------------------------------------------------- +# resolve cache get +# --------------------------------------------------------------------------- + + +class CacheGetReq(Request): + type: Annotated[ + str | None, choice("user", "bot", "group", "channel", help="Only entries of this kind.") + ] = None + refresh: Annotated[ + list[PeerRef], + opt("--refresh", metavar="PEER", kind="peer", help="Re-fetch these peers."), + ] = [] + purge: Annotated[ + bool, opt("--purge", help="Drop cached entries (never the session auth key).") + ] = False + stale: Annotated[ + str | None, + opt("--stale", metavar="DURATION", kind="duration", help="Only entries older than this."), + ] = None + + +async def cache_get(ctx: OpContext, req: CacheGetReq) -> Page[CachedPeerRow]: + """Inspect, refresh or purge this account's peer database. + + The cache is what makes a bare numeric id addressable at all, and it is + per account: an access hash minted for one login is meaningless to + another, which is why this never prints one and why `--purge` is scoped + to the resolver's own store and never touches the session. + + `min_context` is the `(chat, message)` where a `min` user was seen. + Telethon records none, so tlgr keeps it; without it a stranger who posted + in a channel cannot be addressed at all. + """ + from tlgr.core.timefmt import parse_duration + + resolver = getattr(ctx, "resolver", None) + if resolver is None: # pragma: no cover - the daemon always supplies one + raise UsageError("no peer resolver is available in this context") + cache = resolver.cache + + refreshed: set[int] = set() + if req.refresh: + if getattr(ctx, "dry_run", False): + ctx.warn(f"--dry-run: {len(req.refresh)} peers would be re-fetched") + else: + for ref in req.refresh: + target = await resolver.resolve(ref) + with contextlib.suppress(Exception): + entity = await client_of(ctx).get_entity(target) + resolver._remember(entity) + refreshed.add(_send.peer_id_of(target)) + + cutoff = 0.0 + if req.stale: + seconds = parse_duration(req.stale) + if seconds is None: + raise UsageError(f"--stale: cannot read {req.stale!r} as a duration", field="stale") + cutoff = time.time() - float(seconds) + + rows: list[CachedPeerRow] = [] + for entry in list(cache.by_id.values()): + if req.type and not _kind_matches(entry.kind, req.type): + continue + if cutoff and entry.resolved_at > cutoff: + continue + seen = float(entry.resolved_at or 0.0) + rows.append( + CachedPeerRow( + id=abs(int(entry.peer_id)), + marked_id=int(entry.peer_id), + type=entry.kind, + username=entry.username or None, + access_hash_cached=bool(entry.access_hash), + min=not entry.access_hash and bool(entry.from_message), + min_context=( + f"{entry.from_chat}:{entry.from_message}" if entry.from_message else None + ), + seen_at=fmt_dt(datetime.fromtimestamp(seen, tz=timezone.utc)) if seen else None, + seen_at_unix=int(seen) if seen else None, + refreshed=int(entry.peer_id) in refreshed, + ) + ) + rows.sort(key=lambda row: (-(row.seen_at_unix or 0), row.marked_id)) + + purged = 0 + if req.purge: + if getattr(ctx, "dry_run", False): + ctx.warn(f"--dry-run: {len(rows)} cache entries would be dropped") + else: + for row in rows: + entry = cache.by_id.pop(row.marked_id, None) + if entry is not None: + purged += 1 + if entry.username: + cache.by_username.pop(entry.username.lower(), None) + row.purged = True + cache._dirty = True + cache.save() + ctx.emit("peer_cache_purge", {"count": purged}) + + limit = int(getattr(ctx, "limit", None) or 200) + token = getattr(ctx, "cursor", None) + offset = 0 + if token: + offset = int( + decode_cursor( + token, op="resolve.cache.get", kind=PageKind.LOCAL, account=ctx.account + ).get("offset", 0) + or 0 + ) + window = rows[offset : offset + limit] + return build_page( + window, + op="resolve.cache.get", + kind=PageKind.LOCAL, + state={"offset": offset + len(window)}, + account=ctx.account, + has_more=offset + len(window) < len(rows), + total=len(rows), + ) + + +SPEC_CACHE_GET = OperationSpec( + id="resolve.cache.get", + request=CacheGetReq, + response=Page[CachedPeerRow], + impl=cache_get, + summary="Inspect, refresh or purge the local peer database", + description=( + "Access-hash priority is full > min > from-message > none. Telethon " + "skips `min` entities in both of its caches, so tlgr records the " + "(peer, msg_id) context itself — that is what makes a `chat posters` " + "follow-up possible. Hashes are per login session: never printed, " + "never copied between accounts. `--purge` drops cache rows only; the " + "session and its auth key are untouched." + ), + paginated=PageKind.LOCAL, + rate_class="local", + tags=frozenset({"mutating-checked"}), + columns=("marked_id", "type", "username", "access_hash_cached", "seen_at"), + headers=("Id", "Kind", "Username", "Hash", "Seen"), + example={ + "items": [{"id": 777123, "marked_id": 777123, "type": "user", "access_hash_cached": True}], + "has_more": False, + }, + example_args="resolve cache get", + covers=("contacts-users.peer-cache",), +) diff --git a/tlgr/ops/user.py b/tlgr/ops/user.py new file mode 100644 index 0000000..0c89d12 --- /dev/null +++ b/tlgr/ops/user.py @@ -0,0 +1,1469 @@ +"""The `user` group: one person's profile, and what this account may do to them. + +Two contracts here are frozen by `AGENT.md` and must not drift; the tests in +`tests/test_ops_contacts.py` hold the line. + +* **`user dialog-status` is three-valued.** `resolved=true, has_dialog=true` + is a dialog with an exact server-side message count; + `resolved=true, has_dialog=false` is a *definitive* negative, licensed only + by enumerating the account's complete dialog list; anything else is + `resolved=false, has_dialog=null` and exit 13. "Could not find the input + entity" is never evidence of absence — `get_input_entity` on a bare numeric + id only consults the local cache, and its network fallback returns + `UserEmpty` for any non-contact. Reading that as "no history" is the + cold-contact bug this command exists to remove. +* **`user hide-stories` is idempotent and local.** It reads the fresh + `stories_hidden` flag first and returns `already: true` with no RPC when + there is nothing to do, so a bulk pass over hundreds of peers is nearly + free. The other side is never notified and nothing about the chat, the + contact entry or their access to us changes. + +Access hashes are never printed. `access_hash_cached` says whether one is +held; the value is per-login-session state that is useless — and unsafe — +anywhere else. +""" + +from __future__ import annotations + +import contextlib +from typing import Annotated, Any + +from tlgr.core.errors import NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page, decode_cursor +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.base import Request +from tlgr.models.contact import ( + BlockResult, + ContactRequirement, + DialogStatus, + MusicTrack, + PersonalChannel, + PhotoResult, + ProfilePhoto, + StoriesHidden, + StoriesHiddenPeer, + SuggestedBirthday, + UserLink, + UserProfile, +) +from tlgr.models.page import Page +from tlgr.models.peer import Chat, PeerRef +from tlgr.ops import _send +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._serialize import action_bar, entity_to_peer, message_to_model, photo_summary +from tlgr.ops._spec import OpContext, OperationSpec +from tlgr.ops.contact import ( + birthday_text, + client_of, + display_name, + e164, + fetch_user, + input_user, + mark_already, + status_model, + status_word, +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: The Replies pseudo-chat. `contacts.blockFromReplies` takes a message id +#: *inside* it, which is why `--from-replies` is a bare integer. +REPLIES_PEER = 1271266957 + +_EXAMPLE_USER: dict[str, Any] = { + "id": 777123, + "raw_id": 777123, + "first_name": "Alice", + "name": "Alice", + "username": "alice", + "bio": "somewhere warm", + "is_bot": False, + "status": "offline", + "stories_hidden": False, +} + + +def _window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]: + limit = int(getattr(ctx, "limit", None) or default) + if limit < 1: + raise UsageError("--limit must be at least 1", field="limit") + token = getattr(ctx, "cursor", None) + state: dict[str, Any] = {} + if token: + state = decode_cursor(token, op=op, kind=kind, account=ctx.account) + return min(limit, 1000), state + + +def _has_hash(target: Any) -> bool: + """Does this `InputUser` carry a real access hash? + + `InputUserFromMessage` deliberately does not, which is the honest answer + for a `min` user: we can address them in that one context and nowhere + else. + """ + return bool(int(getattr(target, "access_hash", 0) or 0)) + + +# --------------------------------------------------------------------------- +# user get +# --------------------------------------------------------------------------- + + +class GetReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="@username, id or +phone.")] + full: Annotated[ + bool, opt("--full", help="Add users.getFullUser (bio, note, birthday, business, blocked).") + ] = True + translate_bio: Annotated[ + str | None, opt("--translate-bio", metavar="LANG", help="Translate the bio.") + ] = None + from_chat: Annotated[ + PeerRef | None, + opt("--from-chat", metavar="CHAT", kind="peer", help="Context for a `min` user."), + ] = None + from_message: Annotated[ + int | None, + opt("--from-message", metavar="ID", kind="msg_id", help="Message id in --from-chat."), + ] = None + + +def _colors(user: Any) -> dict[str, Any] | None: + color = getattr(user, "color", None) + profile = getattr(user, "profile_color", None) + if color is None and profile is None: + return None + out: dict[str, Any] = {} + if color is not None: + out["name_color"] = getattr(color, "color", None) + out["name_emoji_id"] = getattr(color, "background_emoji_id", None) + if profile is not None: + out["profile_color"] = getattr(profile, "color", None) + out["profile_emoji_id"] = getattr(profile, "background_emoji_id", None) + return out + + +def _business_hours(full: Any) -> dict[str, Any] | None: + hours = getattr(full, "business_work_hours", None) + if hours is None: + return None + return { + "timezone": getattr(hours, "timezone_id", None), + "open_now": getattr(hours, "open_now", None), + "periods": [ + {"start": int(getattr(p, "start_minute", 0)), "end": int(getattr(p, "end_minute", 0))} + for p in getattr(hours, "weekly_open", None) or [] + ], + } + + +def profile_model(user: Any, *, full: Any = None, has_hash: bool = False) -> UserProfile: + """A `User` (plus an optional `userFull`) as the profile shape. + + v1's keys survive verbatim — `id`, `first_name`, `username`, `bio`, + `is_bot`, `status`, `stories_hidden`, `deleted`, `has_photo` — because + AGENT.md documents them and agents read them today. + """ + raw_id = int(getattr(user, "id", 0) or 0) + status = getattr(user, "status", None) + photo = getattr(user, "photo", None) + model = UserProfile( + id=raw_id, + raw_id=raw_id, + kind="bot" if getattr(user, "bot", False) else "user", + first_name=getattr(user, "first_name", "") or "", + last_name=getattr(user, "last_name", "") or "", + name=display_name(user), + username=getattr(user, "username", None), + usernames=[ + u.username + for u in (getattr(user, "usernames", None) or []) + if getattr(u, "username", None) + ], + phone=e164(getattr(user, "phone", "") or "") or None, + status=status_word(status), + status_detail=status_model(raw_id, status) if status is not None else None, + is_self=bool(getattr(user, "is_self", False)), + is_bot=bool(getattr(user, "bot", False)), + is_contact=bool(getattr(user, "contact", False)), + is_mutual_contact=bool(getattr(user, "mutual_contact", False)), + is_close_friend=bool(getattr(user, "close_friend", False)), + is_premium=bool(getattr(user, "premium", False)), + is_support=bool(getattr(user, "support", False)), + is_verified=bool(getattr(user, "verified", False)), + is_scam=bool(getattr(user, "scam", False)), + is_fake=bool(getattr(user, "fake", False)), + deleted=bool(getattr(user, "deleted", False)), + restricted=bool(getattr(user, "restricted", False)), + restriction_reason=[ + str(getattr(r, "text", "") or "") + for r in getattr(user, "restriction_reason", None) or [] + ], + # No photo together with an empty status is the classic signature of + # an account that blocked us — or of an abandoned one. Both signals + # are reported; the conclusion is not drawn here, because it cannot + # be drawn correctly. + has_photo=photo is not None and type(photo).__name__ != "UserProfilePhotoEmpty", + stories_hidden=bool(getattr(user, "stories_hidden", False)), + lang_code=getattr(user, "lang_code", None), + photo=photo_summary(photo), + emoji_status_id=getattr(getattr(user, "emoji_status", None), "document_id", None), + colors=_colors(user), + access_hash_cached=has_hash or bool(getattr(user, "access_hash", None)), + min=bool(getattr(user, "min", False)), + ) + if full is None: + return model + + model.full = True + model.bio = getattr(full, "about", None) or "" + note = getattr(full, "note", None) + model.note = getattr(note, "text", None) if note is not None else None + model.birthday = birthday_text(getattr(full, "birthday", None)) + model.blocked = getattr(full, "blocked", None) + model.blocked_my_stories_from = getattr(full, "blocked_my_stories_from", None) + model.common_chats_count = getattr(full, "common_chats_count", None) + model.personal_channel_id = getattr(full, "personal_channel_id", None) + model.personal_channel_message_id = getattr(full, "personal_channel_message", None) + model.contact_require_premium = getattr(full, "contact_require_premium", None) + model.send_paid_messages_stars = getattr(full, "send_paid_messages_stars", None) + model.stargifts_count = getattr(full, "stargifts_count", None) + rating = getattr(full, "stars_rating", None) + model.stars_rating = getattr(rating, "level", None) if rating is not None else None + tab = getattr(full, "main_tab", None) + model.main_tab = type(tab).__name__.removeprefix("ProfileTab").lower() if tab else None + model.unofficial_security_risk = getattr(full, "unofficial_security_risk", None) + model.business_hours = _business_hours(full) + location = getattr(full, "business_location", None) + model.business_location = getattr(location, "address", None) if location else None + intro = getattr(full, "business_intro", None) + if intro is not None: + model.business_intro = { + "title": getattr(intro, "title", None), + "description": getattr(intro, "description", None), + } + model.personal_photo = photo_summary(getattr(full, "personal_photo", None)) + model.fallback_photo = photo_summary(getattr(full, "fallback_photo", None)) + paper = getattr(full, "wallpaper", None) + model.wallpaper = getattr(paper, "slug", None) if paper is not None else None + settings = getattr(full, "settings", None) + if settings is not None: + from tlgr.models.base import to_builtins + + model.action_bar = to_builtins(action_bar(settings, chat_id=raw_id)) + return model + + +async def get(ctx: OpContext, req: GetReq) -> UserProfile: + """Full profile of one user. + + A bare numeric id resolves only from this account's own peer cache — + there is no MTProto call that turns an id into an access hash — so an + uncached one fails rather than guessing. For a `min` user (someone seen + only inside a channel message) pass `--from-chat/--from-message`; + Telethon builds `inputUserFromMessage` for nobody. + """ + from telethon.tl.functions import messages as mfn + from telethon.tl.functions import users as ufn + + target = await input_user(ctx, req.user, from_chat=req.from_chat, from_message=req.from_message) + user = await fetch_user(ctx, target) + + full = None + if req.full: + try: + answer = await client_of(ctx)(ufn.GetFullUserRequest(id=target)) + except Exception as exc: + # A profile we can see the shell of but not the inside of is a + # real state (privacy, a deleted account); half an answer beats + # an error that hides the half we do have. + ctx.warn(f"users.getFullUser failed, reporting the short profile only: {exc}") + answer = None + if answer is not None: + full = getattr(answer, "full_user", None) + for candidate in getattr(answer, "users", None) or []: + if int(getattr(candidate, "id", 0) or 0) == int(user.id): + user = candidate + + model = profile_model(user, full=full, has_hash=_has_hash(target)) + + if req.translate_bio and model.bio: + from telethon.tl import types + + try: + translated = await client_of(ctx)( + mfn.TranslateTextRequest( + to_lang=req.translate_bio, + text=[types.TextWithEntities(text=model.bio, entities=[])], + ) + ) + first = list(getattr(translated, "result", None) or []) + model.bio_translated = getattr(first[0], "text", None) if first else None + except Exception as exc: # pragma: no cover - server-side feature gate + ctx.warn(f"bio translation is unavailable: {exc}") + + return model + + +SPEC_GET = OperationSpec( + id="user.get", + request=GetReq, + response=UserProfile, + impl=get, + summary="Full profile of a user", + description=( + "Never prints an access hash: `access_hash_cached` says whether one " + "is held. A bare numeric id resolves only from this account's peer " + "cache; for a `min` user pass --from-chat/--from-message so " + "`inputUserFromMessage` can be built. `userFull` is invalidated " + "server-side after 60 s and whenever our own last-seen privacy " + "changes. No photo plus an empty status is a signal, not a verdict: " + "this never claims 'they blocked you'. To pull out one field, use " + "the global `--select bio --results-only` rather than a per-command " + "projection flag." + ), + legacy_paths=("user get",), + columns=("id", "first_name", "username", "bio", "is_bot", "status", "stories_hidden"), + example=_EXAMPLE_USER, + example_args="user get @alice", + covers=( + "contacts-users.block-status", + "contacts-users.resolve-min-users", + "contacts-users.resolve-user-id", + "contacts-users.user-badges", + "contacts-users.user-bio", + "contacts-users.user-bio-translate", + "contacts-users.user-birthday-read", + "contacts-users.user-business-hours", + "contacts-users.user-business-intro", + "contacts-users.user-business-location", + "contacts-users.user-copy-fields", + "contacts-users.user-emoji-status", + "contacts-users.user-gifts-count", + "contacts-users.user-main-profile-tab", + "contacts-users.user-peer-colors", + "contacts-users.user-phone", + "contacts-users.user-profile-basic", + "contacts-users.user-profile-full", + "contacts-users.user-stars-rating", + "contacts-users.user-status", + "contacts-users.user-unofficial-warning", + "contacts-users.user-usernames", + "profile.security-risk-flag", + ), +) + + +# --------------------------------------------------------------------------- +# user block / unblock +# --------------------------------------------------------------------------- + + +class BlockReq(Request): + user: Annotated[ + PeerRef | None, + arg(0, metavar="USER", required=False, kind="peer", help="User, bot or channel."), + ] = None + stories: Annotated[ + bool, opt("--stories", help="Story blocklist only: they keep messaging you.") + ] = False + report_spam: Annotated[bool, opt("--report-spam", help="Report spam first.")] = False + delete_history: Annotated[ + bool, opt("--delete-history", help="Also delete the chat for BOTH sides.") + ] = False + from_replies: Annotated[ + int | None, + opt("--from-replies", metavar="ID", help="Block the author of this Replies message."), + ] = None + delete_message: Annotated[ + bool, opt("--delete-message", help="With --from-replies: also delete that message.") + ] = False + + +async def block(ctx: OpContext, req: BlockReq) -> BlockResult: + """Block a user, bot or channel — optionally stories-only, with cleanup. + + The main blocklist and the story blocklist are independent: `--stories` + stops them seeing our stories and nothing else. Stopping a bot *is* + `contacts.block(bot)`; restarting it is `user unblock` plus `bot start`. + """ + from telethon.tl.functions import contacts as fn + from telethon.tl.functions import messages as mfn + + if req.from_replies is not None: + await client_of(ctx)( + fn.BlockFromRepliesRequest( + msg_id=int(req.from_replies), + delete_message=req.delete_message or None, + delete_history=req.delete_history or None, + report_spam=req.report_spam or None, + ) + ) + ctx.emit("user_block", {"msg_id": int(req.from_replies), "source": "replies"}) + return BlockResult( + peer_id=REPLIES_PEER, + blocked=True, + deleted=req.delete_history, + reported=req.report_spam, + ) + + if req.user is None: + raise UsageError("give a user to block, or --from-replies ", field="user") + peer = await _send.resolve(ctx, req.user) + marked = _send.peer_id_of(peer) + + reported = False + if req.report_spam: + # Reporting before blocking, because a blocked peer's chat is no + # longer somewhere a report can point at. + await client_of(ctx)(mfn.ReportSpamRequest(peer=peer)) + reported = True + + await client_of(ctx)(fn.BlockRequest(id=peer, my_stories_from=req.stories or None)) + + deleted = False + if req.delete_history: + await client_of(ctx)(mfn.DeleteHistoryRequest(peer=peer, max_id=0, revoke=True)) + deleted = True + + ctx.emit("user_block", {"peer_id": marked, "stories_only": req.stories}) + return BlockResult( + peer_id=marked, + blocked=True, + stories_only=req.stories, + deleted=deleted, + reported=reported, + ) + + +SPEC_BLOCK = OperationSpec( + id="user.block", + request=BlockReq, + response=BlockResult, + impl=block, + summary="Block a user, bot or channel — optionally stories-only, with report and cleanup", + description=( + "The main blocklist stops messages, calls, status, photo and " + "stories. `--stories` is the independent story blocklist and stops " + "only stories. `--delete-history` revokes for both sides, which is " + "why the whole command is destructive." + ), + aliases=("chat.block", "contact.blocked.add"), + mutating=True, + destructive=True, + columns=("peer_id", "blocked", "stories_only"), + example={"peer_id": 777123, "blocked": True, "stories_only": False}, + example_args="user block @spammer", + covers=( + "contacts-users.block-delete-and-block", + "contacts-users.block-from-replies", + "dialogs.block-stories", + "dialogs.block-user", + "dialogs.bot-stop-restart", + ), + tags=frozenset({"visible-to-others"}), +) + + +class UnblockReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="peer", help="User, bot or channel.")] + stories: Annotated[bool, opt("--stories", help="Remove from the story blocklist instead.")] = ( + False + ) + + +async def unblock(ctx: OpContext, req: UnblockReq) -> BlockResult: + """Unblock a user, bot or channel. Idempotent.""" + from telethon.tl.functions import contacts as fn + + peer = await _send.resolve(ctx, req.user) + marked = _send.peer_id_of(peer) + changed = await client_of(ctx)(fn.UnblockRequest(id=peer, my_stories_from=req.stories or None)) + already = changed is False + if already: + mark_already(ctx) + else: + ctx.emit("user_unblock", {"peer_id": marked, "stories_only": req.stories}) + return BlockResult(peer_id=marked, blocked=False, stories_only=req.stories, already=already) + + +SPEC_UNBLOCK = OperationSpec( + id="user.unblock", + request=UnblockReq, + response=BlockResult, + impl=unblock, + summary="Unblock a user, bot or channel", + description="`already: true` means the peer was not on the list and no RPC changed anything.", + aliases=("chat.unblock", "contact.blocked.remove"), + mutating=True, + idempotent=True, + columns=("peer_id", "blocked", "already"), + example={"peer_id": 777123, "blocked": False, "already": False}, + example_args="user unblock @alice", + covers=("contacts-users.block-unblock", "dialogs.unblock-user"), +) + + +# --------------------------------------------------------------------------- +# user dialog-status +# --------------------------------------------------------------------------- + + +class DialogStatusReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to ask about.")] + max_dialogs: Annotated[ + int, + opt( + "--max-dialogs", + metavar="N", + help="Cap the fallback dialog scan. Hitting it is indeterminate, never 'no'.", + ge=1, + ), + ] = 5000 + + +async def dialog_status(ctx: OpContext, req: DialogStatusReq) -> DialogStatus: + """Does this account have prior history with this user? Three-valued. + + SEMANTICS FROZEN (AGENT.md). The naive probe — list a few messages, read + the error — is unsound for a bare numeric id, because + `get_input_entity` only consults the local cache and its network fallback + returns `UserEmpty` for any non-contact. So: + + 1. try to address the peer cheaply and, if that works, ask the server + directly with `messages.getPeerDialogs` plus an exact message total; + 2. if it cannot be addressed, enumerate the account's *complete* dialog + list. Finding the id is a positive; **exhausting** the list is the + only thing that licenses a negative; + 3. if neither completes — cap, flood, RPC failure — report + `resolved: false` and exit 13 so the caller fails closed. + + It reports on the dialog list: a conversation this account itself deleted + is gone server-side too and correctly reads as no dialog. + """ + from telethon import utils + from telethon.tl import types + from telethon.tl.functions import messages as mfn + + def unknown(reason: str) -> DialogStatus: + """The third answer: report it, and make the process fail closed. + + The body is still returned — a caller needs `reason` and + `scanned_dialogs` to decide what to do — and `mark_indeterminate` + is what turns the exit status into 13, so "could not establish" + can never be read as "no history". + """ + out.reason = reason + out.resolved = False + out.has_dialog = None + mark = getattr(ctx, "mark_indeterminate", None) + if callable(mark): + mark(reason) + return out + + out = DialogStatus(ref=getattr(req.user, "raw", str(req.user))) + target_id: int | None = None + target_username: str | None = None + if req.user.kind == "id": + target_id = int(req.user.value) + elif req.user.kind == "username": + target_username = str(req.user.value).lstrip("@").lower() + + client = client_of(ctx) + peer: Any = None + try: + peer = await _send.resolve(ctx, req.user) + except Exception as exc: + # NOT evidence of absence — a cold cache or an unknown handle. + out.reason = f"entity not resolvable directly: {exc}" + + scanned = 0 + if peer is None: + if target_id is None and target_username is None: + return unknown(f"unusable reference: {out.ref!r}") + try: + async for dialog in client.iter_dialogs(limit=req.max_dialogs): + scanned += 1 + entity = getattr(dialog, "entity", None) + entity_id = getattr(entity, "id", None) + dialog_id = getattr(dialog, "id", None) + handle = (getattr(entity, "username", None) or "").lower() + if (target_id is not None and target_id in (entity_id, dialog_id)) or ( + target_username is not None and handle == target_username + ): + peer = entity + out.source = "dialog_scan" + break + else: + if scanned >= req.max_dialogs: + out.scanned_dialogs = scanned + return unknown( + f"dialog scan hit the {req.max_dialogs}-dialog cap without a " + "match — indeterminate, NOT a negative" + ) + except Exception as exc: + out.scanned_dialogs = scanned + return unknown(f"dialog scan did not complete: {exc}") + + out.scanned_dialogs = scanned + if peer is None: + # The server handed over every dialog this account has and the + # peer was not among them. This is the definitive negative, and + # the only one. + out.resolved = True + out.has_dialog = False + out.message_count = 0 + out.source = "dialog_scan" + out.reason = "absent from the account's complete dialog list" + out.id = target_id + out.username = target_username + return out + + try: + input_peer = await client.get_input_entity(peer) + answer = await client( + mfn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer=input_peer)]) + ) + dialogs = list(getattr(answer, "dialogs", None) or []) + top = max((int(getattr(d, "top_message", 0) or 0) for d in dialogs), default=0) + messages = await client.get_messages(input_peer, limit=1) + total = getattr(messages, "total", None) + total = int(total if total is not None else len(messages or [])) + except Exception as exc: + return unknown(f"server dialog query failed: {exc}") + + with contextlib.suppress(TypeError, ValueError): + out.id = int(utils.get_peer_id(peer)) + if out.id is None: + out.id = target_id + out.username = getattr(peer, "username", None) or target_username + out.resolved = True + # A scan hit stays a positive even if both sides have since wiped the + # history: presence in the dialog list *is* the dialog. + out.has_dialog = out.source == "dialog_scan" or bool(top) or total > 0 + out.message_count = total + if out.source != "dialog_scan": + out.source = "peer_dialogs" + return out + + +SPEC_DIALOG_STATUS = OperationSpec( + id="user.dialog-status", + request=DialogStatusReq, + response=DialogStatus, + impl=dialog_status, + summary="Does this account have prior history with this user? (three-valued, never guessed)", + description=( + "resolved=true/has_dialog=true — a dialog exists, message_count is " + "the server's exact total. resolved=true/has_dialog=false — " + "definitively none, because the COMPLETE dialog list was enumerated. " + "resolved=false/has_dialog=null — exit 13, and `reason` says why. " + "Exit 13 means UNKNOWN: a caller gating a cold first message must " + "treat it as a refusal, never as a green light." + ), + legacy_paths=("user dialog-status",), + rate_class="bulk", + timeout_s=600, + columns=("id", "username", "resolved", "has_dialog", "message_count", "source"), + example={ + "ref": "@alice", + "id": 777123, + "username": "alice", + "resolved": True, + "has_dialog": True, + "message_count": 12, + "source": "peer_dialogs", + "reason": None, + }, + example_args="user dialog-status @alice", + covers=("contacts-users.user-dialog-exists", "dialogs.dialog-exists"), + covers_partial=("dialogs.resolve-peer",), + coverage_note=( + "The reference-resolution half of `dialogs.resolve-peer` is `resolve peer`; " + "this op only answers the has-a-dialog question about a user." + ), +) + + +# --------------------------------------------------------------------------- +# user hide-stories +# --------------------------------------------------------------------------- + + +class HideStoriesReq(Request): + user: Annotated[ + list[PeerRef], + arg(0, metavar="USER", variadic=True, kind="user", help="Peers to hide."), + ] = [] + unhide: Annotated[bool, opt("--unhide", help="Put them back in the main stories bar.")] = False + all_stories: Annotated[ + str | None, + opt("--all", metavar="ON|OFF", help="Collapse or expand the whole story strip."), + ] = None + + +async def hide_stories(ctx: OpContext, req: HideStoriesReq) -> StoriesHidden: + """Hide or unhide a peer's stories — per account, silently. + + SEMANTICS FROZEN (AGENT.md). Exactly Telegram's own "Hide Stories" menu + item: the peer leaves the main stories bar for the collapsed Hidden list. + The other side is never notified and nothing about the chat, the contact + entry or their access to us changes. + + The fresh `stories_hidden` flag is read first, so a peer already in the + requested state costs no RPC and reports `already: true` — which is what + makes a bulk pass over hundreds of peers nearly free to repeat. + """ + from telethon.tl.functions import stories as sfn + + hidden = not req.unhide + result = StoriesHidden(hidden=hidden) + + if req.all_stories is not None: + wanted = req.all_stories.strip().lower() + if wanted not in ("on", "off"): + raise UsageError("--all takes on or off", field="all") + await client_of(ctx)(sfn.ToggleAllStoriesHiddenRequest(hidden=wanted == "on")) + result.all_hidden = wanted == "on" + if not req.user: + return result + + if not req.user: + raise UsageError("give at least one user, or --all on|off", field="user") + + rows: list[StoriesHiddenPeer] = [] + for ref in req.user: + target = await input_user(ctx, ref) + user = await fetch_user(ctx, target) + was = bool(getattr(user, "stories_hidden", False)) + already = was == hidden + if not already: + peer = await _send.resolve(ctx, ref) + await client_of(ctx)(sfn.TogglePeerStoriesHiddenRequest(peer=peer, hidden=hidden)) + ctx.emit("stories_hidden", {"user_id": int(user.id), "hidden": hidden}) + rows.append( + StoriesHiddenPeer( + user_id=int(getattr(user, "id", 0) or 0), + username=getattr(user, "username", None), + hidden=hidden, + already=already, + ) + ) + + first = rows[0] + result.user_id = first.user_id + result.username = first.username + result.hidden = first.hidden + result.already = first.already + if len(rows) > 1: + result.peers = rows + elif all(row.already for row in rows): + mark_already(ctx) + return result + + +SPEC_HIDE_STORIES = OperationSpec( + id="user.hide-stories", + request=HideStoriesReq, + response=StoriesHidden, + impl=hide_stories, + summary="Hide or unhide a peer's stories (per-account; the other side is never notified)", + description=( + "Idempotent: the fresh flag is read first and `already: true` means " + "no RPC was sent, so repeating a bulk pass is nearly free. Purely " + "local to this account — the chat, the contact entry and their " + "access to you are untouched. `user get` reports the current value " + "as `stories_hidden`. More than one peer fills `peers`; a single " + "peer answers exactly as v1 did." + ), + legacy_paths=("user hide-stories",), + mutating=True, + idempotent=True, + rate_class="bulk", + columns=("user_id", "username", "hidden", "already"), + example={"user_id": 777123, "username": "alice", "hidden": True, "already": False}, + example_args="user hide-stories @alice", + covers=("contacts-users.user-hide-stories", "dialogs.hide-stories-peer"), +) + + +# --------------------------------------------------------------------------- +# user can-message +# --------------------------------------------------------------------------- + + +class CanMessageReq(Request): + user: Annotated[ + list[PeerRef], + arg(0, metavar="USER", variadic=True, kind="user", help="Who to check."), + ] = [] + + +async def can_message(ctx: OpContext, req: CanMessageReq) -> Page[ContactRequirement]: + """Can I message this user, and at what price? + + Pairs with `user dialog-status` for cold-outreach gating: this answers + "am I allowed to", that one answers "have I already". Reading the Stars + price is fine; paying it is a payment a human initiates. + """ + from telethon.tl.functions import users as ufn + + if not req.user: + raise UsageError("give at least one user", field="user") + targets = [await input_user(ctx, ref) for ref in req.user] + answers = list(await client_of(ctx)(ufn.GetRequirementsToContactRequest(id=targets)) or []) + + rows: list[ContactRequirement] = [] + for target, answer in zip(targets, answers, strict=False): + name = type(answer).__name__ + kind = { + "RequirementToContactEmpty": "free", + "RequirementToContactPremium": "premium", + "RequirementToContactPaidMessages": "paid", + }.get(name, "unknown") + rows.append( + ContactRequirement( + user_id=int(getattr(target, "user_id", 0) or 0), + result=kind, # type: ignore[arg-type] + stars_amount=getattr(answer, "stars_amount", None), + contact_require_premium=kind == "premium" or None, + ) + ) + limit, state = _window(ctx, "user.can-message", PageKind.LOCAL, default=100) + offset = int(state.get("offset", 0) or 0) + window = rows[offset : offset + limit] + return build_page( + window, + op="user.can-message", + kind=PageKind.LOCAL, + state={"offset": offset + len(window)}, + account=ctx.account, + has_more=offset + len(window) < len(rows), + total=len(rows), + ) + + +SPEC_CAN_MESSAGE = OperationSpec( + id="user.can-message", + request=CanMessageReq, + response=Page[ContactRequirement], + impl=can_message, + summary="Can I message this user, and at what price?", + description=( + "`free` | `premium` | `paid` (with `stars_amount`). The send-time " + "failure this predicts is PRIVACY_PREMIUM_REQUIRED (403)." + ), + paginated=PageKind.LOCAL, + columns=("user_id", "result", "stars_amount"), + headers=("User", "Requirement", "Stars"), + example={"items": [{"user_id": 777123, "result": "free"}], "has_more": False}, + example_args="user can-message @alice", + covers=( + "contacts-users.user-paid-messages", + "contacts-users.user-requirements-to-contact", + "privacy.requirements-to-contact", + ), +) + + +# --------------------------------------------------------------------------- +# user chat list +# --------------------------------------------------------------------------- + + +class ChatListReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose common chats.")] + leave_all: Annotated[bool, opt("--leave-all", help="Leave every listed chat.")] = False + + +async def chat_list(ctx: OpContext, req: ChatListReq) -> Page[Chat]: + """Groups and channels shared with a user, optionally leaving all of them. + + `userFull.common_chats_count` is the count; this is the list. Leaving is + opt-in and destructive, and the chats are listed before anything is left. + """ + from telethon.tl.functions import channels as cfn + from telethon.tl.functions import messages as mfn + + limit, state = _window(ctx, "user.chat.list", PageKind.PARTICIPANTS, default=100) + max_id = int(state.get("max_id", 0) or 0) + target = await input_user(ctx, req.user) + result = await client_of(ctx)( + mfn.GetCommonChatsRequest(user_id=target, max_id=max_id, limit=limit) + ) + chats = list(getattr(result, "chats", None) or []) + + rows: list[Chat] = [] + for entity in chats: + peer = entity_to_peer(entity) + rows.append( + Chat( + id=peer.id, + raw_id=peer.raw_id, + kind=peer.kind, + title=peer.title, + username=peer.username, + usernames=peer.usernames, + left=bool(getattr(entity, "left", False)), + ) + ) + + if req.leave_all and rows: + # Listing is a read and stays dry-runnable, so this branch honours + # --dry-run itself rather than turning the whole command into a stub. + if getattr(ctx, "dry_run", False): + ctx.warn(f"--dry-run: would leave {len(rows)} shared chats") + else: + from telethon import utils + from telethon.tl import types + + for entity in chats: + try: + if type(entity).__name__ == "Channel": + await client_of(ctx)( + cfn.LeaveChannelRequest(utils.get_input_channel(entity)) + ) + else: + await client_of(ctx)( + mfn.DeleteChatUserRequest( + chat_id=int(entity.id), user_id=types.InputUserSelf() + ) + ) + except Exception as exc: + ctx.warn(f"could not leave {getattr(entity, 'title', entity)}: {exc}") + continue + row = next((r for r in rows if r.raw_id == int(entity.id)), None) + if row is not None: + row.left = True + ctx.emit("user_common_chats_leave", {"count": len(rows)}) + + return build_page( + rows, + op="user.chat.list", + kind=PageKind.PARTICIPANTS, + state={"max_id": min((abs(row.raw_id) for row in rows), default=0)}, + account=ctx.account, + limit=limit, + ) + + +SPEC_CHAT_LIST = OperationSpec( + id="user.chat.list", + request=ChatListReq, + response=Page[Chat], + impl=chat_list, + summary="Groups and channels you share with a user", + description=( + "`--leave-all` leaves every listed chat immediately — run it under " + "--dry-run first, which prints what would go. `userFull." + "common_chats_count` is the count; this is the list." + ), + aliases=("user.common-chats",), + paginated=PageKind.PARTICIPANTS, + rate_class="bulk", + tags=frozenset({"mutating-checked"}), + columns=("id", "title", "kind", "left"), + headers=("Id", "Title", "Kind", "Left"), + example={ + "items": [{"id": -1001234, "raw_id": 1234, "kind": "supergroup", "title": "News"}], + "has_more": False, + }, + example_args="user chat list @alice", + covers=("contacts-users.user-leave-common-groups",), +) + + +# --------------------------------------------------------------------------- +# user link +# --------------------------------------------------------------------------- + + +class LinkReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Use `me` with --token.")] + profile: Annotated[ + bool, opt("--profile", help="Add ?profile so clients open the profile, not the chat.") + ] = False + text: Annotated[ + str | None, opt("--text", metavar="TEXT", help="Pre-fill a draft (?text=).") + ] = None + scheme: Annotated[str, choice("tme", "tg", help="Link flavour.")] = "tme" + token: Annotated[ + bool, opt("--token", help="For `me`: a t.me/contact/ link with no username.") + ] = False + + +async def link(ctx: OpContext, req: LinkReq) -> UserLink: + """Build a link to a user, or my own temporary contact-token link. + + A contact token EXPIRES, so the expiry is reported next to the URL — a + link with no expiry printed is a link somebody will paste next month. + """ + from urllib.parse import quote + + from telethon.tl.functions import contacts as fn + + if req.token: + if req.user.kind not in ("self", "saved"): + raise UsageError("--token builds a link to your own profile: use `me`", field="user") + exported = await client_of(ctx)(fn.ExportContactTokenRequest()) + expires = getattr(exported, "expires", None) + return UserLink( + url=str(getattr(exported, "url", "") or ""), + kind="contact-token", + expires=fmt_dt(expires), + expires_unix=to_unix(expires), + ) + + target = await input_user(ctx, req.user) + user = await fetch_user(ctx, target) + handle = getattr(user, "username", None) + query: list[str] = [] + if req.profile: + query.append("profile") + if req.text: + # A draft starting with '@' would be read as a username by the + # clients that honour ?text=, so it is prefixed with a space. + text = req.text if not req.text.startswith("@") else " " + req.text + query.append("text=" + quote(text[:4096], safe="")) + + if req.scheme == "tg": + base = f"tg://user?id={int(user.id)}" if not handle else f"tg://resolve?domain={handle}" + joined = base + ("&" + "&".join(query) if query else "") + return UserLink(url=joined, kind="profile" if req.profile else "chat") + + if not handle: + raise NotFoundError( + "that user has no public username, so no t.me link exists for them; " + "use --scheme tg, which addresses them by id" + ) + joined = f"https://t.me/{handle}" + ("?" + "&".join(query) if query else "") + return UserLink(url=joined, kind="profile" if req.profile else "chat") + + +SPEC_LINK = OperationSpec( + id="user.link", + request=LinkReq, + response=UserLink, + impl=link, + summary="Build a link to a user (t.me / tg://), or my own temporary profile link", + description=( + "Mostly local string building. `--token` is the exception: " + "`contacts.exportContactToken` mints a t.me/contact/ link that " + "works without a username and EXPIRES, so `expires` is always " + "reported next to it." + ), + columns=("url", "kind", "expires"), + example={"url": "https://t.me/alice", "kind": "chat"}, + example_args="user link @alice --profile", + covers=("contacts-users.contact-token-export", "contacts-users.user-link-build"), +) + + +# --------------------------------------------------------------------------- +# user photo list / set +# --------------------------------------------------------------------------- + + +class PhotoListReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose photos.")] + download: Annotated[ + str | None, + opt("--download", metavar="DIR", kind="path", help="Download into this directory."), + ] = None + big: Annotated[bool, opt("--big", help="Prefer the largest size when downloading.")] = False + + +async def photo_list(ctx: OpContext, req: PhotoListReq) -> Page[ProfilePhoto]: + """A user's profile-photo history. + + Personal and fallback photos are NOT in here — they are `userFull` fields + and `user get --full` reports them. + """ + from telethon.tl.functions import photos as pfn + + limit, state = _window(ctx, "user.photo.list", PageKind.PARTICIPANTS, default=50) + offset = int(state.get("offset", 0) or 0) + target = await input_user(ctx, req.user) + result = await client_of(ctx)( + pfn.GetUserPhotosRequest(user_id=target, offset=offset, max_id=0, limit=limit) + ) + photos = list(getattr(result, "photos", None) or []) + total = getattr(result, "count", None) + + rows: list[ProfilePhoto] = [] + for photo in photos: + date = getattr(photo, "date", None) + rows.append( + ProfilePhoto( + id=int(getattr(photo, "id", 0) or 0), + date=fmt_dt(date), + date_unix=to_unix(date), + sizes=[ + str(getattr(size, "type", "")) + for size in getattr(photo, "sizes", None) or [] + if getattr(size, "type", None) + ], + video=bool(getattr(photo, "video_sizes", None)), + dc_id=getattr(photo, "dc_id", None), + ) + ) + + if req.download: + from pathlib import Path + + directory = Path(req.download).expanduser() + directory.mkdir(parents=True, exist_ok=True) + for photo, row in zip(photos, rows, strict=True): + try: + saved = await client_of(ctx).download_media( + photo, file=str(directory / f"{row.id}.jpg") + ) + except Exception as exc: + ctx.warn(f"could not download photo {row.id}: {exc}") + continue + row.file = str(saved) if saved else None + + return build_page( + rows, + op="user.photo.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(rows)}, + account=ctx.account, + limit=limit, + total=int(total) if total is not None else None, + ) + + +SPEC_PHOTO_LIST = OperationSpec( + id="user.photo.list", + request=PhotoListReq, + response=Page[ProfilePhoto], + impl=photo_list, + summary="A user's profile-photo history", + aliases=("user.photos",), + paginated=PageKind.PARTICIPANTS, + rate_class="file", + timeout_s=300, + columns=("id", "date", "video"), + headers=("Photo", "Taken", "Video"), + example={"items": [{"id": 55123, "video": False}], "has_more": False}, + example_args="user photo list @alice", + covers=("contacts-users.user-profile-photos",), +) + + +class PhotoSetReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose card to change.")] + file: Annotated[ + str | None, arg(1, metavar="FILE", required=False, kind="path", help="Image or video.") + ] = None + suggest: Annotated[ + bool, opt("--suggest", help="Send it as a suggestion instead of applying it locally.") + ] = False + video: Annotated[bool, opt("--video", help="Upload as a video avatar.")] = False + reset: Annotated[bool, opt("--reset", help="Remove the personal photo.")] = False + + +async def photo_set(ctx: OpContext, req: PhotoSetReq) -> PhotoResult: + """Set, suggest or reset the personal photo you see for a contact. + + One method, three modes: `save` applies a photo only we see, `suggest` + posts `messageActionSuggestProfilePhoto` to them (visible, hence --yes), + and neither with no file removes what is there. + """ + from pathlib import Path + + from telethon.tl.functions import photos as pfn + + target = await input_user(ctx, req.user) + user_id = int(getattr(target, "user_id", 0) or 0) + + if req.reset or not req.file: + await client_of(ctx)(pfn.UploadContactProfilePhotoRequest(user_id=target)) + ctx.emit("user_photo_reset", {"user_id": user_id}) + return PhotoResult(user_id=user_id, reset=True) + + upload = getattr(ctx, "upload_file", None) + if upload is None: # pragma: no cover - the daemon always supplies one + raise UsageError("this context cannot upload files") + path = Path(req.file).expanduser() + if not path.exists(): + raise UsageError(f"{req.file} does not exist", field="file") + handle = await upload(path) + + result = await client_of(ctx)( + pfn.UploadContactProfilePhotoRequest( + user_id=target, + suggest=req.suggest or None, + save=None if req.suggest else True, + file=None if req.video else handle, + video=handle if req.video else None, + ) + ) + photo = getattr(result, "photo", None) + ctx.emit("user_photo_set", {"user_id": user_id, "suggested": req.suggest}) + return PhotoResult( + user_id=user_id, + photo_id=int(getattr(photo, "id", 0) or 0) or None, + suggested=req.suggest, + ) + + +SPEC_PHOTO_SET = OperationSpec( + id="user.photo.set", + request=PhotoSetReq, + response=PhotoResult, + impl=photo_set, + summary="Set, suggest or reset the personal photo you see for a contact", + description=( + "`--suggest` posts a visible message to them; without it the photo " + "is a private override only this account sees." + ), + aliases=("user.set-photo",), + mutating=True, + rate_class="file", + timeout_s=300, + columns=("user_id", "photo_id", "suggested"), + example={"user_id": 777123, "photo_id": 55123, "suggested": False}, + example_args="user photo set @alice avatar.jpg", + covers=( + "contacts-users.user-personal-photo-reset", + "contacts-users.user-suggest-photo", + "profile.photo-personal-for-contact", + "profile.photo-suggest-to-user", + ), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# user birthday set +# --------------------------------------------------------------------------- + + +class BirthdaySetReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to suggest it to.")] + date: Annotated[str, arg(1, metavar="DATE", help="YYYY-MM-DD or MM-DD.")] + + +def _birthday(value: str) -> Any: + from telethon.tl import types + + parts = [p for p in (value or "").replace("/", "-").split("-") if p] + try: + numbers = [int(p) for p in parts] + except ValueError as exc: + raise UsageError(f"{value!r} is not a date; use YYYY-MM-DD or MM-DD", field="date") from exc + year: int | None + if len(numbers) == 3: + year, month, day = numbers + elif len(numbers) == 2: + year, month, day = None, numbers[0], numbers[1] + else: + raise UsageError(f"{value!r} is not a date; use YYYY-MM-DD or MM-DD", field="date") + if not 1 <= month <= 12 or not 1 <= day <= 31: + raise UsageError(f"{value!r} is not a real date", field="date") + return types.Birthday(day=day, month=month, year=year) + + +async def birthday_set(ctx: OpContext, req: BirthdaySetReq) -> SuggestedBirthday: + """Suggest a birthday to a contact. + + This sends `messageActionSuggestBirthday` on our behalf — a visible + message — so it needs --yes. BIRTHDAY_ALREADY means they already have one + we can see. + """ + from telethon.tl.functions import users as ufn + + target = await input_user(ctx, req.user) + birthday = _birthday(req.date) + await client_of(ctx)(ufn.SuggestBirthdayRequest(id=target, birthday=birthday)) + user_id = int(getattr(target, "user_id", 0) or 0) + ctx.emit("user_birthday_suggest", {"user_id": user_id}) + return SuggestedBirthday( + user_id=user_id, birthday=birthday_text(birthday) or req.date, sent=True + ) + + +SPEC_BIRTHDAY_SET = OperationSpec( + id="user.birthday.set", + request=BirthdaySetReq, + response=SuggestedBirthday, + impl=birthday_set, + summary="Suggest a birthday to a contact", + aliases=("user.suggest-birthday",), + mutating=True, + rate_class="send", + columns=("user_id", "birthday", "sent"), + example={"user_id": 777123, "birthday": "1990-04-01", "sent": True}, + example_args="user birthday set @alice 1990-04-01", + covers=( + "contact.birthday-accept", + "contact.suggest-birthday", + "contacts-users.user-suggest-birthday", + "profile.birthday-suggest", + ), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# user music list / personal-channel get +# --------------------------------------------------------------------------- + + +class MusicListReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose pinned music.")] + download: Annotated[ + str | None, + opt("--download", metavar="DIR", kind="path", help="Download into this directory."), + ] = None + + +async def music_list(ctx: OpContext, req: MusicListReq) -> Page[MusicTrack]: + """Music a user pinned to their profile. + + Visibility is governed by `inputPrivacyKeySavedMusic`, so an empty list + can mean "none pinned" or "not shared with you"; it is not evidence + either way. + """ + from telethon.tl.functions import users as ufn + + limit, state = _window(ctx, "user.music.list", PageKind.PARTICIPANTS, default=50) + offset = int(state.get("offset", 0) or 0) + target = await input_user(ctx, req.user) + result = await client_of(ctx)( + ufn.GetSavedMusicRequest(id=target, offset=offset, limit=limit, hash=0) + ) + documents = list(getattr(result, "documents", None) or []) + + rows: list[MusicTrack] = [] + for document in documents: + title = performer = None + duration = None + for attribute in getattr(document, "attributes", None) or []: + if type(attribute).__name__ == "DocumentAttributeAudio": + title = getattr(attribute, "title", None) + performer = getattr(attribute, "performer", None) + duration = getattr(attribute, "duration", None) + rows.append( + MusicTrack( + id=int(getattr(document, "id", 0) or 0), + title=title, + performer=performer, + duration=duration, + mime_type=getattr(document, "mime_type", None), + size=getattr(document, "size", None), + ) + ) + + if req.download: + from pathlib import Path + + directory = Path(req.download).expanduser() + directory.mkdir(parents=True, exist_ok=True) + for document, row in zip(documents, rows, strict=True): + try: + saved = await client_of(ctx).download_media(document, file=str(directory)) + except Exception as exc: + ctx.warn(f"could not download track {row.id}: {exc}") + continue + row.file = str(saved) if saved else None + + return build_page( + rows, + op="user.music.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(rows)}, + account=ctx.account, + limit=limit, + total=getattr(result, "count", None), + ) + + +SPEC_MUSIC_LIST = OperationSpec( + id="user.music.list", + request=MusicListReq, + response=Page[MusicTrack], + impl=music_list, + summary="Music a user pinned to their profile", + description=( + "An empty list is not evidence: `inputPrivacyKeySavedMusic` may " + "simply not include us. Managing your own is `profile music`." + ), + paginated=PageKind.PARTICIPANTS, + rate_class="file", + timeout_s=300, + columns=("id", "title", "performer", "duration"), + headers=("Id", "Title", "Performer", "Seconds"), + example={"items": [{"id": 991, "title": "Nocturne", "performer": "Chopin"}], "has_more": False}, + example_args="user music list @alice", + covers=("contacts-users.user-saved-music",), +) + + +class PersonalChannelReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose profile.")] + + +async def personal_channel(ctx: OpContext, req: PersonalChannelReq) -> PersonalChannel: + """The channel a user pinned to their profile, with its latest posts. + + `messages.getPersonalChannelHistory` returns the posts without joining + the channel and without resolving it separately, which is the only reason + this is one command rather than three. + """ + from telethon.tl.functions import messages as mfn + from telethon.tl.functions import users as ufn + + limit = int(getattr(ctx, "limit", None) or 5) + target = await input_user(ctx, req.user) + answer = await client_of(ctx)(ufn.GetFullUserRequest(id=target)) + full = getattr(answer, "full_user", None) + channel_id = getattr(full, "personal_channel_id", None) + user_id = int(getattr(target, "user_id", 0) or 0) + if not channel_id: + raise NotFoundError("that user has no personal channel pinned to their profile") + + channel = None + for entity in getattr(answer, "chats", None) or []: + if int(getattr(entity, "id", 0) or 0) == int(channel_id): + channel = entity_to_peer(entity) + + history = await client_of(ctx)( + mfn.GetPersonalChannelHistoryRequest( + user_id=target, limit=limit, max_id=0, min_id=0, hash=0 + ) + ) + posts = [ + message_to_model(message, chat_id=channel.id if channel else None) + for message in getattr(history, "messages", None) or [] + if getattr(message, "id", None) is not None + ] + return PersonalChannel( + user_id=user_id, + channel=channel, + msg_id=getattr(full, "personal_channel_message", None), + posts=posts, + ) + + +SPEC_PERSONAL_CHANNEL_GET = OperationSpec( + id="user.personal-channel.get", + request=PersonalChannelReq, + response=PersonalChannel, + impl=personal_channel, + summary="The channel a user pinned to their profile, with its latest posts", + description="Setting your OWN personal channel is `profile update --personal-channel`.", + columns=("channel.id", "channel.title", "msg_id"), + example={ + "user_id": 777123, + "channel": {"id": -1001234, "raw_id": 1234, "kind": "channel", "title": "Alice writes"}, + "posts": [], + }, + example_args="user personal-channel get @alice", + covers=( + "contacts-users.user-personal-channel", + "dialogs.personal-channel-preview", + "groups-channels-admin.personal-channel", + ), +) diff --git a/tlgr/registry.py b/tlgr/registry.py index 965270d..0a5b19e 100644 --- a/tlgr/registry.py +++ b/tlgr/registry.py @@ -183,6 +183,11 @@ "dialog-status", "hide-stories", "rename", + # `resolve ` is verb-first (COMMANDS.md conventions): the noun + # is `resolve` and the tail names what is being resolved. + "peer", + "phone", + "username", "info", "temp", "retry",