From 894b1b98e8088a110c6f5b24c6007f42413910dd Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 3 Sep 2026 22:38:14 +0330 Subject: [PATCH 1/8] models: contacts, users and resolution results The three shapes that carry provenance rather than a verdict: UserStatus keeps by_me so a coarse last-seen bucket is never reported as the peer hiding; ContactAdded keeps both imported and retry so an empty import is not read as 'no such number'; DialogStatus stays three-valued, with has_dialog null whenever resolved is false. --- tlgr/models/__init__.py | 78 +++++++ tlgr/models/contact.py | 474 ++++++++++++++++++++++++++++++++++++++++ tlgr/models/resolve.py | 175 +++++++++++++++ 3 files changed, 727 insertions(+) create mode 100644 tlgr/models/contact.py create mode 100644 tlgr/models/resolve.py 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..05a92e7 --- /dev/null +++ b/tlgr/models/contact.py @@ -0,0 +1,474 @@ +"""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", + "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 UserStatus(Model): + """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(Model): + """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(Model): + """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(Model): + """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(Model): + removed: bool = False + user_ids: list[int] = [] + phones: list[str] = [] + + +class ContactNote(Model): + user_id: int = 0 + note: str | None = None + cleared: bool = False + + +class ImportedPhone(Model): + """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(Model): + """`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(Model): + """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(Model): + """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(Model): + peer: Peer + date: str | None = None + date_unix: int | None = None + kind: Literal["main", "stories"] = "main" + + +class BlockResult(Model): + """`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(Model): + """`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(Model): + user_ids: list[int] = [] + count: int = 0 + contacts: list[Contact] = [] + + +class SignUp(Model): + """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(Model): + peer: Peer + category: str = "correspondents" + rating: float = 0.0 + + +class TopPeerState(Model): + enabled: bool | None = None + reset_peer: int | None = None + category: str | None = None + disabled_by_user: bool = False + + +class FoundPeer(Model): + """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(Model): + """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(Model): + """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(Model): + user_id: int = 0 + username: str | None = None + hidden: bool = False + already: bool = False + + +class StoriesHidden(Model): + """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(Model): + """`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; the fields below are absent + #: otherwise rather than defaulted, so "not asked" is distinguishable. + 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(Model): + user_id: int = 0 + birthday: str = "" + sent: bool = False + + +class UserLink(Model): + url: str = "" + kind: str = "profile" + expires: str | None = None + expires_unix: int | None = None + + +class MusicTrack(Model): + 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(Model): + 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(Model): + user_id: int = 0 + photo_id: int | None = None + suggested: bool = False + reset: bool = False + + +class PersonalChannel(Model): + """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(Model): + chat_id: int = 0 + msg_id: int = 0 + contact: Contact | None = None + + +class PhoneShared(Model): + 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..0fd9e42 --- /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): + """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): + kind: str = "" + peer: Peer | None = None + username: str = "" + access_hash_cached: bool = False + + +class ResolvedPhone(Model): + """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 = "unknown" + 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): + """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 From c013cd21e36a6b2161c6bdaf107d37994d09b0b0 Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 3 Sep 2026 23:02:34 +0330 Subject: [PATCH 2/8] contact+user+resolve: 38 operations, and the legacy contact/user group deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The address book, one person's profile, blocking, and the resolver every other group already leans on. Three semantics carry over verbatim because AGENT.md freezes them: dialog-status stays three-valued, hide-stories stays idempotent and silent, and contact rename still writes only our own view of a name (empty first name still becomes '.'). Two shapes are new. A result can now be marked indeterminate, so dialog-status returns its body AND exits 13 — an error envelope would have thrown away the reason a caller needs. And 'resolve link' classifies without acting: it names the command that would act in delegated_to. Deletes tlgr/cli/legacy/{contact,user}.py, the eight contact/user IPC routes and the eight ClientWrapper methods behind them; every v1 path stays invocable through legacy_paths. --- tests/test_dialog_status.py | 254 ---- tests/test_sandbox.py | 10 +- tests/test_stories_hidden.py | 107 -- tlgr/cli/__init__.py | 16 - tlgr/cli/gen.py | 16 +- tlgr/cli/legacy/contact.py | 129 -- tlgr/cli/legacy/user.py | 100 -- tlgr/core/client.py | 315 +---- tlgr/daemon/dispatch.py | 21 + tlgr/daemon/ipc.py | 115 -- tlgr/ops/contact.py | 2325 ++++++++++++++++++++++++++++++++++ tlgr/ops/resolve.py | 1114 ++++++++++++++++ tlgr/ops/user.py | 1486 ++++++++++++++++++++++ tlgr/registry.py | 5 + 14 files changed, 4975 insertions(+), 1038 deletions(-) delete mode 100644 tests/test_dialog_status.py delete mode 100644 tests/test_stories_hidden.py delete mode 100644 tlgr/cli/legacy/contact.py delete mode 100644 tlgr/cli/legacy/user.py create mode 100644 tlgr/ops/contact.py create mode 100644 tlgr/ops/resolve.py create mode 100644 tlgr/ops/user.py 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_sandbox.py b/tests/test_sandbox.py index b82b251..45c979a 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"]) + """`media` is still hand-written; `contact` became generated in PR-5.""" + result = runner.invoke(cli, ["--enable-commands", "message", "media", "download"]) 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/ops/contact.py b/tlgr/ops/contact.py new file mode 100644 index 0000000..254f80a --- /dev/null +++ b/tlgr/ops/contact.py @@ -0,0 +1,2325 @@ +"""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 ( + IndeterminateError, + 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) + ], + phone=getattr(user, "phone", 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",), + 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) + return Page(items=rows, has_more=False, total=len(rows)) + + +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",), + 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": + raise IndeterminateError( + "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`" + ) + 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..bc3aa01 --- /dev/null +++ b/tlgr/ops/resolve.py @@ -0,0 +1,1114 @@ +"""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 IndeterminateError, 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 + + +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". + out.reason = ( + f"{type(exc).__name__}: the number may have no Telegram account, OR its " + "owner may refuse lookups by phone. These are not distinguishable." + ) + raise IndeterminateError(out.reason) from exc + + entities = list(getattr(result, "users", None) or []) + list( + getattr(result, "chats", None) or [] + ) + if not entities: + raise IndeterminateError( + "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) + + return Page(items=rows, has_more=False, 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." + ), + 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(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..3084fb1 --- /dev/null +++ b/tlgr/ops/user.py @@ -0,0 +1,1486 @@ +"""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, + 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", +} + + +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 + refresh: Annotated[bool, opt("--refresh", help="Ignore the 60 s userFull cache.")] = False + field: Annotated[ + str | None, + choice( + "id", + "username", + "phone", + "bio", + "birthday", + "link", + "status", + "name", + help="Emit a single field for scripting.", + ), + ] = None + 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=getattr(user, "phone", 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}") + + if req.field: + # `--field` is a projection, not a different response: the other keys + # are cleared rather than the shape changing, so a script that reads + # `.username` keeps working either way. + keep = { + "id": "id", + "username": "username", + "phone": "phone", + "bio": "bio", + "birthday": "birthday", + "status": "status", + "name": "name", + }.get(req.field) + if req.field == "link": + keep = "username" + if keep is not None: + blank = UserProfile(id=model.id) + setattr(blank, keep, getattr(model, keep)) + return blank + 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'." + ), + 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", + }, + 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, + ) + ) + return Page(items=rows, has_more=False, 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)." + ), + 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", From dda97f1b6a470cc9ace9fc86a5068defa4a5c06a Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 3 Sep 2026 23:17:06 +0330 Subject: [PATCH 3/8] tests: the contact surface proved against an address book that moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake grows a contact world — the list, the two blocklists, top peers, birthdays, the phonebook, saved music, profile photos — so a test asserts that adding a contact really flipped user.contact and that a second hide-stories pass sent nothing at all. The two frozen contracts get the coverage they earned: dialog-status is checked in all three outcomes including the capped scan that must exit 13, and the v1 paths (contact list/add/rename/remove/search, user get/ dialog-status/hide-stories, and the bare 'contacts') are asserted to still resolve and still be invocable. Replaces tests/test_dialog_status.py and tests/test_stories_hidden.py, whose ClientWrapper methods this PR deleted. --- docs/reference/PARITY.md | 93 +-- docs/reference/README.md | 5 +- docs/reference/contact.md | 647 ++++++++++++++++ docs/reference/resolve.md | 174 +++++ docs/reference/user.md | 435 +++++++++++ tests/fake_telethon.py | 547 +++++++++++++- tests/test_ops_contacts.py | 1419 ++++++++++++++++++++++++++++++++++++ tlgr/models/contact.py | 80 +- tlgr/models/resolve.py | 10 +- tlgr/ops/contact.py | 21 +- tlgr/ops/resolve.py | 44 +- tlgr/ops/user.py | 55 +- 12 files changed, 3366 insertions(+), 164 deletions(-) create mode 100644 docs/reference/contact.md create mode 100644 docs/reference/resolve.md create mode 100644 docs/reference/user.md create mode 100644 tests/test_ops_contacts.py diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index 6831320..f32623d 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-5: contact, user and blocking land in PR-5. | +| `contacts-users.privacy-global` | P1 | Global privacy settings | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.privacy-phone-number` | P1 | Privacy: who can see my phone number | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.url-auth-login` | P1 | Log in to a website with Telegram (URL authorization) | waived until PR-5: contact, user and blocking land in PR-5. | | `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-5: contact, user and blocking land in PR-5. | +| `contacts-users.privacy-chat-invite` | P2 | Privacy: who can add me to groups | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.privacy-exception-lists` | P2 | Always/Never allow exception lists | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.privacy-forwards` | P2 | Privacy: forwarded messages link back to me | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.user-status-reveal` | P2 | Show My Last Seen to reveal theirs | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.user-stories` | P2 | A user's stories on their profile | waived until PR-5: contact, user and blocking land in PR-5. | | `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-5: contact, user and blocking land in PR-5. | +| `contacts-users.privacy-gifts` | P3 | Privacy: who can see / send me gifts | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.privacy-no-paid-messages` | P3 | Privacy: who may message me without paying | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.privacy-voice-messages` | P3 | Privacy: who can send me voice messages | waived until PR-5: contact, user and blocking land in PR-5. | +| `contacts-users.user-business-greeting-away` | P3 | Business greeting / away messages | waived until PR-5: contact, user and blocking land in PR-5. | | `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). | | `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..2a6eba5 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,405 @@ 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) + + # -- 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_ops_contacts.py b/tests/test_ops_contacts.py new file mode 100644 index 0000000..1cce610 --- /dev/null +++ b/tests/test_ops_contacts.py @@ -0,0 +1,1419 @@ +"""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 + + 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")) + world.add_channel(make_channel(OTHER, title="Other")) + world.search_mine = [ALICE] + world.search_global = [CAROL] + world.phonebook["+15550009999"] = CAROL + 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_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_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) + + 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/tlgr/models/contact.py b/tlgr/models/contact.py index 05a92e7..a6867a0 100644 --- a/tlgr/models/contact.py +++ b/tlgr/models/contact.py @@ -35,6 +35,7 @@ "Contact", "ContactAdded", "ContactImport", + "ContactModel", "ContactNote", "ContactRemoved", "ContactRenamed", @@ -64,7 +65,19 @@ StatusKind = Literal["online", "offline", "recently", "last_week", "last_month", "empty"] -class UserStatus(Model): +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`, @@ -82,7 +95,7 @@ class UserStatus(Model): by_me: bool = False -class Contact(Model): +class Contact(ContactModel): """A row of the contact list. `phone` is present only where privacy allows it, which is why it is @@ -116,7 +129,7 @@ class Contact(Model): saved_count: int | None = None -class ContactAdded(Model): +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 @@ -137,7 +150,7 @@ class ContactAdded(Model): reason: str | None = None -class ContactRenamed(Model): +class ContactRenamed(ContactModel): """v1's shape, unchanged: this is only *our* view of their name.""" saved: bool = True @@ -146,19 +159,19 @@ class ContactRenamed(Model): last_name: str = "" -class ContactRemoved(Model): +class ContactRemoved(ContactModel): removed: bool = False user_ids: list[int] = [] phones: list[str] = [] -class ContactNote(Model): +class ContactNote(ContactModel): user_id: int = 0 note: str | None = None cleared: bool = False -class ImportedPhone(Model): +class ImportedPhone(ContactModel): """One line of a phonebook import, and what the server made of it.""" phone: str = "" @@ -169,7 +182,7 @@ class ImportedPhone(Model): retry: bool = False -class ContactImport(Model): +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 @@ -185,7 +198,7 @@ class ContactImport(Model): dry_run: bool = False -class ContactSync(Model): +class ContactSync(ContactModel): """The diff between a local phonebook file and the server's list.""" to_import: list[ImportedPhone] = [] @@ -195,7 +208,7 @@ class ContactSync(Model): deleted: int = 0 -class SavedPhoneContact(Model): +class SavedPhoneContact(ContactModel): """A number this account once uploaded, whether or not it has an account.""" phone: str = "" @@ -207,14 +220,14 @@ class SavedPhoneContact(Model): invite_text: str | None = None -class BlockedPeer(Model): +class BlockedPeer(ContactModel): peer: Peer date: str | None = None date_unix: int | None = None kind: Literal["main", "stories"] = "main" -class BlockResult(Model): +class BlockResult(ContactModel): """`user block` / `user unblock`. `already` means no RPC was needed.""" peer_id: int = 0 @@ -225,7 +238,7 @@ class BlockResult(Model): reported: bool = False -class BlockedSet(Model): +class BlockedSet(ContactModel): """`contact blocked set` — a replacement, so the diff is the answer.""" count: int = 0 @@ -235,13 +248,13 @@ class BlockedSet(Model): applied: bool = False -class CloseFriends(Model): +class CloseFriends(ContactModel): user_ids: list[int] = [] count: int = 0 contacts: list[Contact] = [] -class SignUp(Model): +class SignUp(ContactModel): """A contact who joined Telegram, found as a service message.""" user_id: int = 0 @@ -256,20 +269,20 @@ class SignUp(Model): notify: bool | None = None -class TopPeer(Model): +class TopPeer(ContactModel): peer: Peer category: str = "correspondents" rating: float = 0.0 -class TopPeerState(Model): +class TopPeerState(ContactModel): enabled: bool | None = None reset_peer: int | None = None category: str | None = None disabled_by_user: bool = False -class FoundPeer(Model): +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 @@ -285,7 +298,7 @@ class FoundPeer(Model): url: str | None = None -class ContactRequirement(Model): +class ContactRequirement(ContactModel): """Can I message this user, and at what price?""" user_id: int = 0 @@ -294,7 +307,7 @@ class ContactRequirement(Model): contact_require_premium: bool | None = None -class DialogStatus(Model): +class DialogStatus(ContactModel): """SEMANTICS FROZEN (AGENT.md). Three answers, never conflated. `resolved=true, has_dialog=true` — a dialog exists; `message_count` is @@ -319,14 +332,14 @@ class DialogStatus(Model): scanned_dialogs: int | None = None -class StoriesHiddenPeer(Model): +class StoriesHiddenPeer(ContactModel): user_id: int = 0 username: str | None = None hidden: bool = False already: bool = False -class StoriesHidden(Model): +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 @@ -342,7 +355,7 @@ class StoriesHidden(Model): all_hidden: bool | None = None -class UserProfile(Model): +class UserProfile(ContactModel): """`user get` — v1's keys, plus everything `users.getFullUser` carries. v1's `id`, `first_name`, `last_name`, `username`, `phone`, `bio`, @@ -388,8 +401,9 @@ class UserProfile(Model): fallback_photo: Photo | None = None emoji_status_id: int | None = None colors: dict[str, Any] | None = None - #: True only when `users.getFullUser` ran; the fields below are absent - #: otherwise rather than defaulted, so "not asked" is distinguishable. + #: 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 @@ -414,20 +428,20 @@ class UserProfile(Model): min: bool = False -class SuggestedBirthday(Model): +class SuggestedBirthday(ContactModel): user_id: int = 0 birthday: str = "" sent: bool = False -class UserLink(Model): +class UserLink(ContactModel): url: str = "" kind: str = "profile" expires: str | None = None expires_unix: int | None = None -class MusicTrack(Model): +class MusicTrack(ContactModel): id: int = 0 title: str | None = None performer: str | None = None @@ -437,7 +451,7 @@ class MusicTrack(Model): file: str | None = None -class ProfilePhoto(Model): +class ProfilePhoto(ContactModel): id: int = 0 date: str | None = None date_unix: int | None = None @@ -447,14 +461,14 @@ class ProfilePhoto(Model): file: str | None = None -class PhotoResult(Model): +class PhotoResult(ContactModel): user_id: int = 0 photo_id: int | None = None suggested: bool = False reset: bool = False -class PersonalChannel(Model): +class PersonalChannel(ContactModel): """The channel a user pinned to their profile, with a post preview.""" user_id: int = 0 @@ -463,12 +477,12 @@ class PersonalChannel(Model): posts: list[Message] = [] -class ContactShared(Model): +class ContactShared(ContactModel): chat_id: int = 0 msg_id: int = 0 contact: Contact | None = None -class PhoneShared(Model): +class PhoneShared(ContactModel): user_id: int = 0 shared: bool = False diff --git a/tlgr/models/resolve.py b/tlgr/models/resolve.py index 0fd9e42..2ca14db 100644 --- a/tlgr/models/resolve.py +++ b/tlgr/models/resolve.py @@ -69,7 +69,7 @@ ] -class ResolvedRef(Model): +class ResolvedRef(Model, omit_defaults=False): """One `resolve peer` answer, with the strategy that produced it.""" ref: str = "" @@ -87,14 +87,14 @@ class ResolvedRef(Model): reason: str | None = None -class ResolvedUsername(Model): +class ResolvedUsername(Model, omit_defaults=False): kind: str = "" peer: Peer | None = None username: str = "" access_hash_cached: bool = False -class ResolvedPhone(Model): +class ResolvedPhone(Model, omit_defaults=False): """A phone lookup. `resolved=false` with a `reason` is exit 13, not 5.""" phone: str = "" @@ -116,7 +116,7 @@ class ResolvedLink(Model): precisely the question. """ - kind: LinkKind = "unknown" + kind: LinkKind raw_url: str = "" scheme: str = "" peer: Peer | None = None @@ -153,7 +153,7 @@ class ResolvedLink(Model): draft_saved: bool = False -class CachedPeerRow(Model): +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 — diff --git a/tlgr/ops/contact.py b/tlgr/ops/contact.py index 254f80a..893f166 100644 --- a/tlgr/ops/contact.py +++ b/tlgr/ops/contact.py @@ -31,11 +31,7 @@ from pathlib import Path from typing import Annotated, Any -from tlgr.core.errors import ( - IndeterminateError, - NotFoundError, - UsageError, -) +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 @@ -274,7 +270,8 @@ def contact_model(user: Any, *, mutual: bool | None = None) -> Contact: for u in (getattr(user, "usernames", None) or []) if getattr(u, "username", None) ], - phone=getattr(user, "phone", 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)), @@ -1320,6 +1317,7 @@ async def status_list(ctx: OpContext, req: StatusListReq) -> Page[UserStatus]: "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}, @@ -1355,7 +1353,8 @@ async def birthday_list(ctx: OpContext, req: BirthdayListReq) -> Page[Contact]: if req.window and not _within(entry.birthday, today, req.window): continue rows.append(row) - return Page(items=rows, has_more=False, total=len(rows)) + 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: @@ -1382,6 +1381,7 @@ def _within(birthday: Any, today: datetime, window: int) -> bool: "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={ @@ -1819,10 +1819,15 @@ async def top_list(ctx: OpContext, req: TopListReq) -> Page[TopPeer]: fn.GetTopPeersRequest(offset=offset, limit=limit, hash=0, **flags) ) if type(result).__name__ == "TopPeersDisabled": - raise IndeterminateError( + 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] = [] diff --git a/tlgr/ops/resolve.py b/tlgr/ops/resolve.py index bc3aa01..1d1c012 100644 --- a/tlgr/ops/resolve.py +++ b/tlgr/ops/resolve.py @@ -30,7 +30,7 @@ from typing import Annotated, Any from urllib.parse import parse_qsl, urlsplit -from tlgr.core.errors import IndeterminateError, NotFoundError, UsageError +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 @@ -301,6 +301,20 @@ def _match_country(number: str, table: list[dict[str, Any]]) -> dict[str, Any] | 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. @@ -344,19 +358,21 @@ async def phone(ctx: OpContext, req: PhoneReq) -> ResolvedPhone: 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". - out.reason = ( + # 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." + "owner may refuse lookups by phone. These are not distinguishable.", ) - raise IndeterminateError(out.reason) from exc entities = list(getattr(result, "users", None) or []) + list( getattr(result, "chats", None) or [] ) if not entities: - raise IndeterminateError( - "the server answered with no peer: no account, or a privacy refusal" + 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 @@ -493,7 +509,16 @@ async def peer(ctx: OpContext, req: PeerReq) -> Page[ResolvedRef]: row.resolved = row.marked_id is not None rows.append(row) - return Page(items=rows, has_more=False, total=len(rows)) + 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: @@ -520,6 +545,7 @@ def _source_for(kind: str) -> str: "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"), @@ -571,7 +597,7 @@ def classify(url: str) -> ResolvedLink: 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(raw_url=url) + out = ResolvedLink(kind="unknown", raw_url=url) scheme, segments, query = _split(url) out.scheme = scheme if not scheme: diff --git a/tlgr/ops/user.py b/tlgr/ops/user.py index 3084fb1..d0b0aa9 100644 --- a/tlgr/ops/user.py +++ b/tlgr/ops/user.py @@ -56,6 +56,7 @@ birthday_text, client_of, display_name, + e164, fetch_user, input_user, mark_already, @@ -112,21 +113,6 @@ class GetReq(Request): full: Annotated[ bool, opt("--full", help="Add users.getFullUser (bio, note, birthday, business, blocked).") ] = True - refresh: Annotated[bool, opt("--refresh", help="Ignore the 60 s userFull cache.")] = False - field: Annotated[ - str | None, - choice( - "id", - "username", - "phone", - "bio", - "birthday", - "link", - "status", - "name", - help="Emit a single field for scripting.", - ), - ] = None translate_bio: Annotated[ str | None, opt("--translate-bio", metavar="LANG", help="Translate the bio.") ] = None @@ -192,7 +178,7 @@ def profile_model(user: Any, *, full: Any = None, has_hash: bool = False) -> Use for u in (getattr(user, "usernames", None) or []) if getattr(u, "username", None) ], - phone=getattr(user, "phone", 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)), @@ -314,25 +300,6 @@ async def get(ctx: OpContext, req: GetReq) -> UserProfile: except Exception as exc: # pragma: no cover - server-side feature gate ctx.warn(f"bio translation is unavailable: {exc}") - if req.field: - # `--field` is a projection, not a different response: the other keys - # are cleared rather than the shape changing, so a script that reads - # `.username` keeps working either way. - keep = { - "id": "id", - "username": "username", - "phone": "phone", - "bio": "bio", - "birthday": "birthday", - "status": "status", - "name": "name", - }.get(req.field) - if req.field == "link": - keep = "username" - if keep is not None: - blank = UserProfile(id=model.id) - setattr(blank, keep, getattr(model, keep)) - return blank return model @@ -349,7 +316,9 @@ async def get(ctx: OpContext, req: GetReq) -> UserProfile: "`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'." + "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"), @@ -857,7 +826,18 @@ async def can_message(ctx: OpContext, req: CanMessageReq) -> Page[ContactRequire contact_require_premium=kind == "premium" or None, ) ) - return Page(items=rows, has_more=False, total=len(rows)) + 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( @@ -870,6 +850,7 @@ async def can_message(ctx: OpContext, req: CanMessageReq) -> Page[ContactRequire "`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}, From b616ce4d539c2616cc4dcf0524e9871a09e24358 Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 3 Sep 2026 23:19:32 +0330 Subject: [PATCH 4/8] parity: the contacts_users domain is migrated, so its gaps name their owners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The domain-wide waiver is gone. The 22 required ids still uncovered in it each name the group that does own them — privacy keys to PR-12, People Nearby to the location group, live presence to the event bus — which is what makes 'the contact group is done' checkable rather than asserted. P0 floor 53 -> 73, covered floor 350 -> 472, with the 20 new P0 ids named rather than counted so a swap cannot pass silently. --- docs/reference/PARITY.md | 32 +++--- tests/test_parity.py | 45 +++++++- tlgr/data/parity_waivers.toml | 203 +++++++++++++++++++--------------- 3 files changed, 174 insertions(+), 106 deletions(-) diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index f32623d..1c63c36 100644 --- a/docs/reference/PARITY.md +++ b/docs/reference/PARITY.md @@ -99,21 +99,21 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `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). | -| `contacts-users.privacy-added-by-phone` | P1 | Privacy: who can find me by my phone number | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.privacy-global` | P1 | Global privacy settings | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.privacy-phone-number` | P1 | Privacy: who can see my phone number | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.url-auth-login` | P1 | Log in to a website with Telegram (URL authorization) | waived until PR-5: contact, user and blocking land in 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. | -| `contacts-users.privacy-about` | P2 | Privacy: bio | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.privacy-chat-invite` | P2 | Privacy: who can add me to groups | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.privacy-exception-lists` | P2 | Always/Never allow exception lists | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.privacy-forwards` | P2 | Privacy: forwarded messages link back to me | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.user-status-reveal` | P2 | Show My Last Seen to reveal theirs | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.user-stories` | P2 | A user's stories on their profile | waived until PR-5: contact, user and blocking land in 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.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). | @@ -156,11 +156,11 @@ 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). | -| `contacts-users.people-you-may-know` | P3 | Suggested / recommended peers | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.privacy-gifts` | P3 | Privacy: who can see / send me gifts | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.privacy-no-paid-messages` | P3 | Privacy: who may message me without paying | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.privacy-voice-messages` | P3 | Privacy: who can send me voice messages | waived until PR-5: contact, user and blocking land in PR-5. | -| `contacts-users.user-business-greeting-away` | P3 | Business greeting / away messages | waived until PR-5: contact, user and blocking land in 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.channel-autotranslation` | P3 | Channel auto-translation for all subscribers | waived until PR-7: Channel-wide auto-translation is a channel admin setting (PR-7). | @@ -170,7 +170,7 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `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.reactions-notify` | P3 | Reaction / poll-vote notification settings | waived until PR-12: Reaction notification settings are the notify surface (PR-12). | -| `dialogs.recommended-channels` | P3 | Similar / recommended channels and bots | waived until PR-5: Similar-channel suggestions are a discovery surface (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/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/tlgr/data/parity_waivers.toml b/tlgr/data/parity_waivers.toml index 657caaa..0d83a68 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,117 @@ 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-support" +pr = 12 +reason = "Contacting Telegram support is a settings surface (PR-12)." + +[[id]] +id = "contacts-users.user-support-info" +pr = 12 +reason = "Support-account info is a settings surface (PR-12)." + +[[id]] +id = "contacts-users.user-status-watch" +pr = 4 +reason = "Live presence is an update stream (PR-4); `contact status list` is the cold-start snapshot." + +[[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.nearby-publish" +pr = 9 +reason = "People Nearby is geolocation, the `location` group (PR-9)." + +[[id]] +id = "contacts-users.nearby-stop" +pr = 9 +reason = "People Nearby is geolocation, the `location` group (PR-9)." + +[[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.search-hashtag-history" +pr = 9 +reason = "The recent-hashtag list is the `search` group (PR-9); `contact search --recent` is the peer half." + +[[id]] +id = "contacts-users.search-public-posts" +pr = 9 +reason = "Public post search is the `search` group (PR-9) and has its own paid quota." + +[[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." From 1d4e0e8bf9803d5b29a333c5975f7ff736d656a6 Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 3 Sep 2026 23:24:23 +0330 Subject: [PATCH 5/8] docs: the contact, user and resolve surface, as it shipped AGENT.md gains the whole group with the two ambiguities spelled out where an agent will read them: an empty contact import is not 'no such user', and by_me on a coarse last-seen bucket is our own privacy, not theirs. The resolve section is new and says plainly that classification never acts. CHANGELOG rows 10-12 cover the three shape changes (contact list/search became Page envelopes, contact add and user get gained keys, --field is gone in favour of the global --select), and DECISIONS records the ten calls this PR had to make. --- AGENT.md | 117 ++++++++++++++++++++++++++++++----- CHANGELOG.md | 21 ++++++- README.md | 56 ++++++++++++++--- docs/design/DECISIONS.md | 102 ++++++++++++++++++++++++++++++ tests/test_agentmd_compat.py | 27 ++++++++ tlgr/ops/user.py | 2 + 6 files changed, 301 insertions(+), 24 deletions(-) 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/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/tlgr/ops/user.py b/tlgr/ops/user.py index d0b0aa9..0c89d12 100644 --- a/tlgr/ops/user.py +++ b/tlgr/ops/user.py @@ -79,6 +79,7 @@ "bio": "somewhere warm", "is_bot": False, "status": "offline", + "stories_hidden": False, } @@ -671,6 +672,7 @@ def unknown(reason: str) -> DialogStatus: "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"), From 8455eb854f9dd8aebaccec1842e1680897cc85cb Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 3 Sep 2026 23:28:50 +0330 Subject: [PATCH 6/8] tests: prove that resolve link --open reads and never acts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each classified kind gets its follow-up read exercised against a real reply shape — an invite preview, a sticker set, a boost status, a gift code, a private post reached through the peer cache — and the assertion is that no acting request was sent alongside it. Also covers the three paths that were assumed rather than checked: --with-stories against real read marks, --translate-bio, and a min user addressed through inputUserFromMessage, which Telethon builds for nobody. --- tests/fake_telethon.py | 60 +++++++++++++++++++++++++++++ tests/test_ops_contacts.py | 78 +++++++++++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 2a6eba5..6f25427 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -3416,6 +3416,66 @@ def _raw_ResolvePhoneRequest(self, request: Any) -> Any: 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_GetStickerSetRequest(self, request: Any) -> Any: + return types.messages.StickerSet( + set=types.StickerSet( + id=1, + access_hash=1, + title="Pack", + short_name="Pack", + count=0, + hash=0, + ), + packs=[], + keywords=[], + documents=[], + ) + + 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_GetStoriesByIDRequest(self, request: Any) -> Any: + return types.stories.Stories(count=0, stories=[], chats=[], users=[]) + + def _raw_GetThemeRequest(self, request: Any) -> Any: + return types.Theme(id=1, access_hash=1, slug="Slug", title="Midnight") + + def _raw_GetWallPaperRequest(self, request: Any) -> Any: + return types.WallPaper( + id=77, + access_hash=1, + slug="Slug", + document=types.DocumentEmpty(id=0), + ) + # -- stories ----------------------------------------------------------- def _raw_TogglePeerStoriesHiddenRequest(self, request: Any) -> bool: diff --git a/tests/test_ops_contacts.py b/tests/test_ops_contacts.py index 1cce610..ef7c2f2 100644 --- a/tests/test_ops_contacts.py +++ b/tests/test_ops_contacts.py @@ -65,7 +65,7 @@ def book(world): carol = make_user(CAROL, username="carol", first="Carol") world.add_user(carol) - world.add_channel(make_channel(NEWS, title="News")) + 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] @@ -162,6 +162,15 @@ async def test_export_without_a_destination_is_a_usage_error( 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 ): @@ -808,6 +817,33 @@ async def test_a_short_profile_survives_a_full_user_failure( 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"}) @@ -1323,6 +1359,46 @@ async def test_a_thread_link_splits_thread_from_message( ) 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 ): From c82dddc1950e3955d58c426fd678122086db631c Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 01:57:57 +0330 Subject: [PATCH 7/8] tests: the three fixtures the rebase moved out from under this branch Three assertions were written against a tree where `media`, the sticker sets and the wallpapers were still v1's. On `main` they are not: - `test_sandbox` picked `media download` as its example of a still-hand-written group. PR-6 generated it, and `profile` is now the only one left. - the fake grew a stub `messages.getStickerSet` / `account.getWallPaper` / `stories.getStoriesByID` beside PR-6's world-backed handlers. Python keeps the last definition, so the stubs were dead and the reads came back empty. The stubs are deleted and the address book seeds the two worlds instead, which is the stronger test: `resolve link --open` now reads the same replies the media group's own ops read. --- tests/fake_telethon.py | 26 -------------------------- tests/test_ops_contacts.py | 7 ++++++- tests/test_sandbox.py | 4 ++-- 3 files changed, 8 insertions(+), 29 deletions(-) diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 6f25427..8a5f87f 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -3430,21 +3430,6 @@ def _raw_CheckChatInviteRequest(self, request: Any) -> Any: color=0, ) - def _raw_GetStickerSetRequest(self, request: Any) -> Any: - return types.messages.StickerSet( - set=types.StickerSet( - id=1, - access_hash=1, - title="Pack", - short_name="Pack", - count=0, - hash=0, - ), - packs=[], - keywords=[], - documents=[], - ) - def _raw_GetBoostsStatusRequest(self, request: Any) -> Any: return types.premium.BoostsStatus( level=3, @@ -3462,20 +3447,9 @@ def _raw_CheckGiftCodeRequest(self, request: Any) -> Any: used_date=datetime.now(timezone.utc), ) - def _raw_GetStoriesByIDRequest(self, request: Any) -> Any: - return types.stories.Stories(count=0, stories=[], chats=[], users=[]) - def _raw_GetThemeRequest(self, request: Any) -> Any: return types.Theme(id=1, access_hash=1, slug="Slug", title="Midnight") - def _raw_GetWallPaperRequest(self, request: Any) -> Any: - return types.WallPaper( - id=77, - access_hash=1, - slug="Slug", - document=types.DocumentEmpty(id=0), - ) - # -- stories ----------------------------------------------------------- def _raw_TogglePeerStoriesHiddenRequest(self, request: Any) -> bool: diff --git a/tests/test_ops_contacts.py b/tests/test_ops_contacts.py index ef7c2f2..c246736 100644 --- a/tests/test_ops_contacts.py +++ b/tests/test_ops_contacts.py @@ -48,7 +48,7 @@ @pytest.fixture def book(world): """An address book: two contacts, one stranger, two channels.""" - from fake_telethon import make_channel, make_user + from fake_telethon import make_channel, make_user, make_wallpaper alice = make_user(ALICE, username="alice", first="Alice") alice.last_name = "Anderson" @@ -70,6 +70,11 @@ def book(world): 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 diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 45c979a..1200eec 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -28,8 +28,8 @@ def test_top_level_block(self, runner): assert "not enabled" in result.output def test_legacy_top_level_block_still_exits_2(self, runner): - """`media` is still hand-written; `contact` became generated in PR-5.""" - result = runner.invoke(cli, ["--enable-commands", "message", "media", "download"]) + """`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 From 89b6ce196f641ec351edefaabd22caf88867794f Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 02:12:15 +0330 Subject: [PATCH 8/8] parity: seven of PR-5's waivers name ids their own PR already covers Each of the seven was written when `location`, `search`, `events` and `account` were still ahead of this branch; all four have landed, and the group each waiver names is the group that now covers the id. A waiver on a covered id is a promise that has already been kept, so it is noise in the one file whose whole job is naming what is still missing. --- tlgr/data/parity_waivers.toml | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/tlgr/data/parity_waivers.toml b/tlgr/data/parity_waivers.toml index 0d83a68..bd9fbf7 100644 --- a/tlgr/data/parity_waivers.toml +++ b/tlgr/data/parity_waivers.toml @@ -826,51 +826,16 @@ 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-support" -pr = 12 -reason = "Contacting Telegram support is a settings surface (PR-12)." - -[[id]] -id = "contacts-users.user-support-info" -pr = 12 -reason = "Support-account info is a settings surface (PR-12)." - -[[id]] -id = "contacts-users.user-status-watch" -pr = 4 -reason = "Live presence is an update stream (PR-4); `contact status list` is the cold-start snapshot." - [[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.nearby-publish" -pr = 9 -reason = "People Nearby is geolocation, the `location` group (PR-9)." - -[[id]] -id = "contacts-users.nearby-stop" -pr = 9 -reason = "People Nearby is geolocation, the `location` group (PR-9)." - [[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.search-hashtag-history" -pr = 9 -reason = "The recent-hashtag list is the `search` group (PR-9); `contact search --recent` is the peer half." - -[[id]] -id = "contacts-users.search-public-posts" -pr = 9 -reason = "Public post search is the `search` group (PR-9) and has its own paid quota." - [[id]] id = "contacts-users.url-auth-login" pr = 10