From fed782c65dd259029200b64cbb092eb8f0ea1a29 Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 06:28:14 +0330 Subject: [PATCH 01/15] models: the profile, privacy, notification, settings, business, premium, stars and gift shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight files for the last group, and four of them exist because the server shape and the human shape genuinely differ. A username is a row, not a string: a Fragment collectible can be active or parked and only the first active one is the handle a link resolves to, so flattening the vector is how a script deactivates the wrong name. A privacy key keeps `raw_rules` alongside the four exception lists, because `account.setPrivacy` replaces the whole ordered vector and a rule tlgr does not recognise still has to survive the round trip. `mute_until` carries its unix twin so the model itself says the timestamp is absolute — v1 computed it from the asyncio loop clock and muted chats into 1970. And a gift is addressed by a `ref` string everywhere, so a reference read from a listing can be handed straight to convert, transfer or set without a lookup table. `BotRights` enumerates every flag of `businessBotRights` rather than carrying a mask: connecting a business bot hands another program the right to read, reply, edit the profile and move Stars, and a right that is absent from the model is a right nobody can audit. --- tlgr/models/__init__.py | 180 ++++++++++++++++++++++++++ tlgr/models/business.py | 239 ++++++++++++++++++++++++++++++++++ tlgr/models/gift.py | 275 ++++++++++++++++++++++++++++++++++++++++ tlgr/models/notify.py | 105 +++++++++++++++ tlgr/models/premium.py | 157 +++++++++++++++++++++++ tlgr/models/privacy.py | 91 +++++++++++++ tlgr/models/profile.py | 213 +++++++++++++++++++++++++++++++ tlgr/models/settings.py | 103 +++++++++++++++ tlgr/models/stars.py | 101 +++++++++++++++ 9 files changed, 1464 insertions(+) create mode 100644 tlgr/models/business.py create mode 100644 tlgr/models/gift.py create mode 100644 tlgr/models/notify.py create mode 100644 tlgr/models/premium.py create mode 100644 tlgr/models/privacy.py create mode 100644 tlgr/models/profile.py create mode 100644 tlgr/models/settings.py create mode 100644 tlgr/models/stars.py diff --git a/tlgr/models/__init__.py b/tlgr/models/__init__.py index 16e4c18..74386ec 100644 --- a/tlgr/models/__init__.py +++ b/tlgr/models/__init__.py @@ -142,6 +142,27 @@ WelcomeDeleted, WelcomeSet, ) +from tlgr.models.business import ( + BotConnection, + BotPaused, + BotRights, + BusinessAway, + BusinessGreeting, + BusinessIntro, + BusinessLocation, + BusinessOpen, + BusinessProfile, + BusinessRecipients, + BusinessSet, + ChatLink, + ChatLinkSet, + QuickReply, + QuickReplyMessage, + QuickReplySent, + QuickReplySet, + StarsTransferQuote, + WorkHours, +) from tlgr.models.call import ( MEDIA_NONE, ActiveCall, @@ -306,6 +327,24 @@ TakeoutSession, TakeoutStatus, ) +from tlgr.models.gift import ( + GiftAttribute, + GiftAuction, + GiftAuctionState, + GiftCollection, + GiftConverted, + GiftCrafted, + GiftDisplay, + GiftListing, + GiftOfferResolved, + GiftTransferred, + GiftUpgraded, + GiftVariant, + OwnedGift, + ResaleGift, + StarGift, + UniqueGift, +) from tlgr.models.inline import ( InlineEdited, InlineResult, @@ -416,6 +455,14 @@ ServerConfig, SyncCursors, ) +from tlgr.models.notify import ( + ExceptionsCleared, + NotifyException, + NotifyReset, + NotifyTarget, + Ringtone, + RingtoneSaved, +) from tlgr.models.page import Page, PageInfo from tlgr.models.payment import ( BankCard, @@ -445,6 +492,41 @@ parse_user_ref, ) from tlgr.models.poll import Poll, PollOption, PollStats, PollVoter +from tlgr.models.premium import ( + GiftCode, + GiftCodeApplied, + GiveawayInfo, + GiveawayLaunched, + GiveawayWinner, + PremiumFeatures, + PremiumGiftOption, + PremiumGiftQuote, + PremiumLimit, + PremiumStatus, + PrepaidGiveaway, +) +from tlgr.models.privacy import ( + GlobalPrivacy, + PaidMessageRevenue, + PrivacyExceptions, + PrivacyRule, + PrivacySettings, +) +from tlgr.models.profile import ( + AdminedChannel, + ColorPalette, + ColorSet, + EmojiStatusItem, + EmojiStatusSet, + PhotosDeleted, + PresenceSet, + ProfileFull, + ProfileLink, + ProfilePhotoSet, + ProfileUpdated, + ProfileUsername, + UsernameSet, +) from tlgr.models.reaction import ( AvailableReaction, ChatReactions, @@ -466,6 +548,22 @@ ResolvedRef, ResolvedUsername, ) +from tlgr.models.settings import ( + CloudTheme, + Language, + SettingChange, + SettingUnset, + SettingValue, + ThemeInstalled, +) +from tlgr.models.stars import ( + StarsBalance, + StarsRating, + StarsRefulfill, + StarsRevenue, + StarsTransaction, + StarsUrl, +) from tlgr.models.sticker import ( EmojiGroup, EmojiKeyword, @@ -544,6 +642,7 @@ "ActiveCall", "AdminLogEvent", "AdminResult", + "AdminedChannel", "AffiliateBot", "AffiliateResult", "AlbumDeleted", @@ -578,13 +677,16 @@ "BotApiResult", "BotCommand", "BotCommandSet", + "BotConnection", "BotCreated", "BotEdited", "BotIds", "BotInfo", + "BotPaused", "BotPermission", "BotQuery", "BotRef", + "BotRights", "BotStarted", "BotStopped", "BotToken", @@ -593,7 +695,15 @@ "BotVerification", "BotVerified", "BotWelcomeMessage", + "BusinessAway", "BusinessConnection", + "BusinessGreeting", + "BusinessIntro", + "BusinessLocation", + "BusinessOpen", + "BusinessProfile", + "BusinessRecipients", + "BusinessSet", "Button", "CachedPeerRow", "Call", @@ -617,6 +727,8 @@ "Chat", "ChatEditResult", "ChatInfo", + "ChatLink", + "ChatLinkSet", "ChatPhotoResult", "ChatReactions", "ChatStats", @@ -628,6 +740,9 @@ "ChatlistUpdates", "ClearResult", "CloseFriends", + "CloudTheme", + "ColorPalette", + "ColorSet", "CommandSent", "CommunityResult", "CommunityRow", @@ -682,6 +797,8 @@ "EmojiGame", "EmojiGroup", "EmojiKeyword", + "EmojiStatusItem", + "EmojiStatusSet", "EntityReport", "EphemeralDeleted", "EphemeralSent", @@ -691,6 +808,7 @@ "EventEnvelope", "EventType", "EventTypeDetail", + "ExceptionsCleared", "ExportResult", "ExportedFile", "FactCheck", @@ -713,6 +831,24 @@ "GifResult", "GifSaved", "GifSent", + "GiftAttribute", + "GiftAuction", + "GiftAuctionState", + "GiftCode", + "GiftCodeApplied", + "GiftCollection", + "GiftConverted", + "GiftCrafted", + "GiftDisplay", + "GiftListing", + "GiftOfferResolved", + "GiftTransferred", + "GiftUpgraded", + "GiftVariant", + "GiveawayInfo", + "GiveawayLaunched", + "GiveawayWinner", + "GlobalPrivacy", "Graph", "GroupCall", "GroupCallCreated", @@ -751,6 +887,7 @@ "JoinResult", "Keyboard", "KeyboardButton", + "Language", "LeaveResult", "LifecycleResult", "LinkKind", @@ -795,11 +932,15 @@ "NearestDc", "NetStatus", "NetUsage", + "NotifyException", + "NotifyReset", "NotifySettings", + "NotifyTarget", "NotifyView", "OkEnvelope", "OpRequest", "OpenResult", + "OwnedGift", "PackCreated", "PackDeleted", "PackEdited", @@ -808,6 +949,7 @@ "Page", "PageInfo", "PaidItem", + "PaidMessageRevenue", "PaidMessageSettings", "PaidPost", "PaidReactionResult", @@ -836,6 +978,7 @@ "PhoneShared", "Photo", "PhotoResult", + "PhotosDeleted", "PinResult", "PingResult", "PinnedDialogs", @@ -845,13 +988,28 @@ "PollVoter", "Poster", "PosterReport", + "PremiumFeatures", + "PremiumGiftOption", + "PremiumGiftQuote", + "PremiumLimit", + "PremiumStatus", + "PrepaidGiveaway", "PreparedMessage", "PreparedSaved", + "PresenceSet", "Pressed", "PreviewChange", "PreviewMedia", "PriceLine", + "PrivacyExceptions", + "PrivacyRule", + "PrivacySettings", + "ProfileFull", + "ProfileLink", "ProfilePhoto", + "ProfilePhotoSet", + "ProfileUpdated", + "ProfileUsername", "Promo", "PromoData", "Proxy", @@ -860,6 +1018,10 @@ "ProxySelection", "PublicForward", "QrLogin", + "QuickReply", + "QuickReplyMessage", + "QuickReplySent", + "QuickReplySet", "RaisedHand", "ReactionPrivacy", "ReactionPurge", @@ -883,6 +1045,7 @@ "ReportResult", "Request", "RequestResult", + "ResaleGift", "ResetResult", "ResolvedLink", "ResolvedPhone", @@ -892,6 +1055,8 @@ "RevenueTransaction", "RightInfo", "Rights", + "Ringtone", + "RingtoneSaved", "RtmpInfo", "SaveStateResult", "SavedDialog", @@ -911,7 +1076,10 @@ "Session", "SessionChange", "SessionTermination", + "SettingChange", "SettingResult", + "SettingUnset", + "SettingValue", "SettingsView", "ShareDeleted", "SignUp", @@ -921,8 +1089,16 @@ "SponsoredMessage", "SponsoredRead", "SponsoredReport", + "StarGift", "StarRefProgram", "StarSubscription", + "StarsBalance", + "StarsRating", + "StarsRefulfill", + "StarsRevenue", + "StarsTransaction", + "StarsTransferQuote", + "StarsUrl", "StatValue", "StealthMode", "Sticker", @@ -970,6 +1146,7 @@ "TakeoutStatus", "TempPassword", "Terms", + "ThemeInstalled", "ThemeResult", "Todo", "TodoTask", @@ -991,6 +1168,7 @@ "Translation", "TtlResult", "TypingResult", + "UniqueGift", "UnreadResult", "Unset", "Uploaded", @@ -1002,6 +1180,7 @@ "UserStatus", "UsernameCheck", "UsernameResult", + "UsernameSet", "ValidationIssue", "ValidationReport", "Venue", @@ -1029,6 +1208,7 @@ "WelcomeMessage", "WelcomeResult", "WelcomeSet", + "WorkHours", "decode", "encode", "parse_message_link", diff --git a/tlgr/models/business.py b/tlgr/models/business.py new file mode 100644 index 0000000..e646b57 --- /dev/null +++ b/tlgr/models/business.py @@ -0,0 +1,239 @@ +"""Telegram Business: opening hours, location, intro, quick replies, links, +and the chatbot that may act on the account's behalf. + +Two of these carry real risk and the models say so out loud. + +* **`BotRights` is a full enumeration, never a mask.** Connecting a business + bot hands another program the ability to read, reply, edit the profile and + move Stars. A right that is absent from the model is a right nobody can + audit, so every flag of `businessBotRights` is named. +* **Opening hours are minutes-of-week, and that arithmetic is lossy.** + `BusinessOpen` keeps the server's raw minute offsets *and* the human + spelling, because "mon 09:00-18:00" cannot represent an interval that + crosses Sunday midnight and the server's vector can. +""" + +from __future__ import annotations + +from tlgr.models.base import Model +from tlgr.models.message import MessageEntity +from tlgr.models.peer import Peer + +__all__ = [ + "BotConnection", + "BotPaused", + "BotRights", + "BusinessAway", + "BusinessGreeting", + "BusinessIntro", + "BusinessLocation", + "BusinessOpen", + "BusinessProfile", + "BusinessRecipients", + "BusinessSet", + "ChatLink", + "ChatLinkSet", + "QuickReply", + "QuickReplyMessage", + "QuickReplySent", + "QuickReplySet", + "StarsTransferQuote", + "WorkHours", +] + + +class BusinessOpen(Model): + """One opening interval. + + `start_minute`/`end_minute` are minutes since Monday 00:00 in the + business's own timezone, which is what the server stores; `day` and the + two clock strings are the same interval said the way a human wrote it. + """ + + start_minute: int + end_minute: int + day: str = "" + open: str = "" + close: str = "" + + +class WorkHours(Model): + timezone_id: str = "" + weekly_open: list[BusinessOpen] = [] + #: Server-set and never sent back: whether the business is open right now. + open_now: bool | None = None + + +class BusinessLocation(Model): + address: str = "" + lat: float | None = None + lon: float | None = None + + +class BusinessIntro(Model): + title: str = "" + description: str = "" + sticker_id: int | None = None + + +class BusinessRecipients(Model): + """Who a greeting/away message or a connected bot applies to.""" + + contacts: bool = False + non_contacts: bool = False + existing_chats: bool = False + new_chats: bool = False + exclude_selected: bool = False + users: list[int] = [] + exclude_users: list[int] = [] + + +class BusinessGreeting(Model): + shortcut_id: int = 0 + shortcut: str | None = None + no_activity_days: int = 0 + recipients: BusinessRecipients | None = None + enabled: bool = True + + +class BusinessAway(Model): + shortcut_id: int = 0 + shortcut: str | None = None + #: always | outside-hours | custom + schedule: str = "always" + since: str | None = None + until: str | None = None + offline_only: bool = False + recipients: BusinessRecipients | None = None + enabled: bool = True + + +class BotRights(Model): + """`businessBotRights`, every flag named. Absent means "not granted".""" + + reply: bool = False + read_messages: bool = False + delete_sent_messages: bool = False + delete_received_messages: bool = False + edit_name: bool = False + edit_bio: bool = False + edit_username: bool = False + edit_profile_photo: bool = False + view_gifts: bool = False + sell_gifts: bool = False + change_gift_settings: bool = False + transfer_and_upgrade_gifts: bool = False + transfer_stars: bool = False + manage_stories: bool = False + + +class BotConnection(Model): + """A chatbot connected to (or pending on) this account.""" + + bot_id: int = 0 + bot: Peer | None = None + connection_id: str | None = None + recipients: BusinessRecipients | None = None + rights: BotRights | None = None + paused: bool = False + #: layer 229: a connection stays inert until the user confirms it. + confirmed: bool = True + disabled: bool = False + deleted: bool = False + date: str | None = None + dc_id: int | None = None + + +class BotPaused(Model): + chat_id: int + paused: bool | None = None + removed: bool = False + already: bool = False + + +class ChatLink(Model): + slug: str = "" + link: str = "" + title: str | None = None + message: str = "" + entities: list[MessageEntity] = [] + views: int | None = None + + +class ChatLinkSet(Model): + slug: str | None = None + link: str | None = None + title: str | None = None + message: str | None = None + deleted: bool = False + + +class QuickReplyMessage(Model): + id: int + text: str = "" + entities: list[MessageEntity] = [] + media: str | None = None + date: str | None = None + + +class QuickReply(Model): + shortcut_id: int + shortcut: str = "" + count: int = 0 + top_message: int | None = None + messages: list[QuickReplyMessage] = [] + + +class QuickReplySet(Model): + shortcut_id: int | None = None + shortcut: str | None = None + msg_id: int | None = None + msg_ids: list[int] = [] + deleted: int = 0 + order: list[int] = [] + already: bool = False + + +class QuickReplySent(Model): + chat_id: int + shortcut_id: int = 0 + message_ids: list[int] = [] + + +class BusinessProfile(Model): + """`business get`: the whole Business screen in one object.""" + + work_hours: WorkHours | None = None + open_now: bool | None = None + location: BusinessLocation | None = None + greeting: BusinessGreeting | None = None + away: BusinessAway | None = None + intro: BusinessIntro | None = None + sponsored_enabled: bool | None = None + connected_bots: list[BotConnection] = [] + chat_links: list[ChatLink] = [] + timezones: list[dict[str, object]] = [] + premium: bool = False + + +class BusinessSet(Model): + work_hours: WorkHours | None = None + location: BusinessLocation | None = None + intro: BusinessIntro | None = None + changed: list[str] = [] + already: bool = False + + +class StarsTransferQuote(Model): + """What a Stars transfer to a business bot *would* cost. + + `ok` is false and `reason` says why: tlgr reads the payment form and + never signs it (see `ops/payment.py`). + """ + + bot_id: int + stars: int = 0 + currency: str = "XTR" + ok: bool = False + reason: str = "" + form_id: int | None = None diff --git a/tlgr/models/gift.py b/tlgr/models/gift.py new file mode 100644 index 0000000..701f247 --- /dev/null +++ b/tlgr/models/gift.py @@ -0,0 +1,275 @@ +"""Star gifts: the catalogue, the gifts a profile holds, collectibles, +collections, the resale market and the auctions. + +A gift is addressed by a **reference**, not an id, and `ref` is that string +everywhere in this module: `msg:` for a gift I received in a private +chat, `:` for one held by a channel, or a bare collectible +slug. One spelling, so a `ref` read from a listing can be handed straight to +`gift set`, `gift convert` or `gift transfer` without a lookup table. + +Every time gate the server publishes is surfaced rather than collapsed into +a boolean. "Can I transfer this?" has three different answers — yes, not yet +(and here is when), never — and a client that reports only the first two +sends its user to wait for a date that will not come. +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.models.base import Model +from tlgr.models.peer import Peer + +__all__ = [ + "GiftAttribute", + "GiftAuction", + "GiftAuctionState", + "GiftCollection", + "GiftConverted", + "GiftCrafted", + "GiftDisplay", + "GiftListing", + "GiftOfferResolved", + "GiftTransferred", + "GiftUpgraded", + "GiftVariant", + "OwnedGift", + "ResaleGift", + "StarGift", + "UniqueGift", +] + + +class GiftAttribute(Model): + """One model / pattern / backdrop of a collectible, with its rarity.""" + + #: model | pattern | backdrop | original-details + kind: str = "" + name: str = "" + document_id: int | None = None + rarity_permille: int | None = None + backdrop_id: int | None = None + center_color: int | None = None + edge_color: int | None = None + pattern_color: int | None = None + text_color: int | None = None + crafted: bool = False + sender_id: int | None = None + recipient_id: int | None = None + message: str | None = None + date: str | None = None + + +class StarGift(Model): + """A gift as the catalogue offers it.""" + + gift_id: int = 0 + title: str = "" + stars: int = 0 + convert_stars: int | None = None + upgrade_stars: int | None = None + limited: bool = False + sold_out: bool = False + birthday: bool = False + require_premium: bool = False + availability_remains: int | None = None + availability_total: int | None = None + availability_resale: int | None = None + first_sale_date: str | None = None + last_sale_date: str | None = None + document_id: int | None = None + resell_min_stars: int | None = None + #: Only filled when `--until` named a recipient. + can_send: bool | None = None + can_send_reason: str | None = None + per_user_total: int | None = None + per_user_remains: int | None = None + + +class UniqueGift(Model): + """A collectible: the upgraded, numbered, tradable form of a gift.""" + + slug: str = "" + gift_id: int = 0 + id: int = 0 + title: str = "" + num: int = 0 + owner_id: int | None = None + owner: Peer | None = None + owner_name: str | None = None + owner_address: str | None = None + gift_address: str | None = None + availability_issued: int | None = None + availability_total: int | None = None + attributes: list[GiftAttribute] = [] + resell_stars: int | None = None + resell_ton: int | None = None + resale_ton_only: bool = False + value_stars: int | None = None + value_ton: int | None = None + value_currency: str | None = None + value_usd: int | None = None + #: A TON-hosted collectible lives outside Telegram's own custody. + hosted: bool = False + burned: bool = False + crafted: bool = False + theme_available: bool = False + peer_color_available: bool = False + offer_min_stars: int | None = None + craft_chance_permille: int | None = None + link: str | None = None + + +class OwnedGift(Model): + """A gift a profile holds, with every gate that decides what may be done.""" + + ref: str = "" + #: gift | collectible + kind: str = "gift" + gift_id: int | None = None + slug: str | None = None + title: str = "" + num: int | None = None + from_id: int | None = None + from_peer: Peer | None = None + name_hidden: bool = False + message: str | None = None + date: str | None = None + date_unix: int | None = None + msg_id: int | None = None + saved_id: int | None = None + pinned: bool = False + displayed: bool = True + refunded: bool = False + can_upgrade: bool = False + convert_stars: int | None = None + upgrade_stars: int | None = None + transfer_stars: int | None = None + can_export_at: str | None = None + can_transfer_at: str | None = None + can_resell_at: str | None = None + can_craft_at: str | None = None + locked_until_date: str | None = None + resell_stars: int | None = None + resell_ton: int | None = None + collection_ids: list[int] = [] + hosted: bool = False + attributes: list[GiftAttribute] = [] + unique: UniqueGift | None = None + + +class GiftCollection(Model): + id: int + title: str = "" + count: int = 0 + icon_document_id: int | None = None + order: int | None = None + + +class GiftDisplay(Model): + """The profile-display state of one or more gifts after `gift set`.""" + + ref: str = "" + refs: list[str] = [] + displayed: bool | None = None + pinned: bool | None = None + worn: bool | None = None + until: str | None = None + already: bool = False + + +class GiftConverted(Model): + ref: str + stars_received: int = 0 + balance_after: int | None = None + + +class GiftUpgraded(Model): + ref: str + slug: str | None = None + num: int | None = None + attributes: list[GiftAttribute] = [] + upgraded: bool = False + price_stars: int | None = None + refused_reason: str | None = None + + +class GiftTransferred(Model): + ref: str + to: int | None = None + transferred: bool = False + price_stars: int | None = None + can_transfer_at: str | None = None + refused_reason: str | None = None + + +class GiftListing(Model): + """A collectible put on (or taken off) the resale market.""" + + ref: str + listed: bool = False + price_stars: int | None = None + price_ton: int | None = None + can_resell_at: str | None = None + already: bool = False + + +class ResaleGift(Model): + slug: str = "" + num: int = 0 + price_stars: int | None = None + price_ton: int | None = None + seller_id: int | None = None + attributes: list[GiftAttribute] = [] + + +class GiftVariant(Model): + """One possible outcome of an upgrade, with how likely it is.""" + + #: model | pattern | backdrop + kind: str = "" + name: str = "" + document_id: int | None = None + rarity_permille: int | None = None + count: int | None = None + sample: bool = False + + +class GiftCrafted(Model): + ref: str | None = None + slug: str | None = None + burned: list[str] = [] + crafted: bool = False + candidates: list[OwnedGift] = [] + + +class GiftOfferResolved(Model): + msg_id: int + #: accepted | declined | refused + state: str = "declined" + price_stars: int | None = None + buyer: int | None = None + reason: str | None = None + + +class GiftAuction(Model): + auction: str = "" + gift_id: int | None = None + slug: str | None = None + my_bid: int | None = None + min_bid: int | None = None + ends_at: str | None = None + state: str = "" + + +class GiftAuctionState(Model): + auction: str = "" + state: str = "" + version: int = 0 + min_bid_amount: int | None = None + my_bid: int | None = None + position: int | None = None + ends_at: str | None = None + timeout: int | None = None + finished: bool = False + raw: dict[str, Any] | None = None diff --git a/tlgr/models/notify.py b/tlgr/models/notify.py new file mode 100644 index 0000000..20a7452 --- /dev/null +++ b/tlgr/models/notify.py @@ -0,0 +1,105 @@ +"""The Notifications screen: scopes, per-chat exceptions, reactions, sounds. + +Telegram spreads one screen over three unrelated APIs — `getNotifySettings` +for the scopes and the chats, `getReactionsNotifySettings` for the reaction +alerts, `getContactSignUpNotification` for "X joined Telegram" — so the +model, like the command, is one shape with a `target` naming which of them +answered. + +`mute_until` is an **absolute UNIX timestamp**, and the second field exists +to say so. v1 computed it from the asyncio event loop's clock, which is an +arbitrary monotonic origin: "mute for an hour" produced a timestamp somewhere +in 1970 and the chat was never muted at all. +""" + +from __future__ import annotations + +from tlgr.models.base import Model +from tlgr.models.dialog import NotifySettings +from tlgr.models.peer import Peer + +__all__ = [ + "ExceptionsCleared", + "NotifyException", + "NotifyReset", + "NotifyTarget", + "Ringtone", + "RingtoneSaved", +] + + +class NotifyTarget(Model): + """Notification settings for one target, whatever kind of target it is. + + `target` is the word the caller typed (`private`, `groups`, `channels`, + `stories`, `reactions`, `contact-joined`, or a chat reference), so the + answer can be piped straight back into `notify set`. + """ + + target: str + kind: str = "scope" + chat_id: int | None = None + chat: Peer | None = None + topic: int | None = None + settings: NotifySettings | None = None + muted: bool | None = None + mute_until: str | None = None + mute_until_unix: int | None = None + show_previews: bool | None = None + sound: str | None = None + stories_muted: bool | None = None + stories_hide_sender: bool | None = None + stories_sound: str | None = None + #: `reactions` target: contacts | all | off, one per alert kind. + messages_from: str | None = None + stories_from: str | None = None + poll_votes_from: str | None = None + #: `contact-joined` target. Stored inverted on the wire (`silent=true`). + contact_joined: bool | None = None + changed: list[str] = [] + already: bool = False + + +class NotifyException(Model): + """A chat whose settings differ from its scope default.""" + + chat_id: int + chat: Peer | None = None + title: str = "" + muted: bool = False + mute_until: str | None = None + mute_until_unix: int | None = None + show_previews: bool | None = None + sound: str | None = None + stories_muted: bool | None = None + scope: str = "" + + +class ExceptionsCleared(Model): + cleared: int = 0 + chat_ids: list[int] = [] + scope: str | None = None + already: bool = False + + +class NotifyReset(Model): + ok: bool = True + + +class Ringtone(Model): + id: int + access_hash: int | None = None + file_name: str = "" + mime_type: str = "" + size: int = 0 + duration: int | None = None + + +class RingtoneSaved(Model): + id: int | None = None + file_name: str | None = None + #: The server may hand back a *new* document id when an existing voice + #: message is saved as a ringtone; using the old one afterwards fails. + converted: bool = False + removed: bool = False + already: bool = False diff --git a/tlgr/models/premium.py b/tlgr/models/premium.py new file mode 100644 index 0000000..c3d9cfe --- /dev/null +++ b/tlgr/models/premium.py @@ -0,0 +1,157 @@ +"""Telegram Premium, boosts, giveaways and gift codes. + +The limit table is the part a CLI actually uses: caption length, upload size, +folder count, pinned chats, public usernames all double with Premium, and a +script that guesses them writes a message the server then refuses. It has no +MTProto method of its own — it is assembled from `help.getAppConfig` — which +is why `PremiumLimit` carries `source`. + +Buying is absent throughout, as in `ops/payment.py`: `PremiumGiftQuote` +reports the price and says, in `reason`, that tlgr does not sign the form. +""" + +from __future__ import annotations + +from tlgr.models.base import Model +from tlgr.models.peer import Peer + +__all__ = [ + "GiftCode", + "GiftCodeApplied", + "GiveawayInfo", + "GiveawayLaunched", + "GiveawayWinner", + "PremiumFeatures", + "PremiumGiftOption", + "PremiumGiftQuote", + "PremiumLimit", + "PremiumStatus", + "PrepaidGiveaway", +] + + +class PremiumStatus(Model): + premium: bool = False + premium_until: str | None = None + #: appConfig `premium_purchase_blocked`: the store path is closed here. + premium_purchase_blocked: bool = True + invoice_link: str | None = None + premium_bot: str | None = None + reason: str = "" + + +class PremiumLimit(Model): + """One `*_limit_default` / `*_limit_premium` pair.""" + + name: str + default: int = 0 + premium: int = 0 + source: str = "app-config" + + +class PremiumFeatures(Model): + status_text: str = "" + period_options: list[dict[str, object]] = [] + video_sections: list[str] = [] + limits: list[PremiumLimit] = [] + #: Assembled from `channel_*_level_min` / `group_*_level_min`. + boost_levels: list[dict[str, object]] = [] + channel_level: int | None = None + + +class PremiumGiftOption(Model): + months: int = 0 + users: int = 1 + currency: str = "" + amount: int = 0 + store_product: str | None = None + + +class PremiumGiftQuote(Model): + """The price of gifting Premium, and the refusal to pay it.""" + + user_id: int + months: int = 0 + stars: int = 0 + currency: str = "XTR" + ok: bool = False + reason: str = "" + form_id: int | None = None + + +class GiftCode(Model): + """`payments.checkGiftCode`, plus the link it came from.""" + + slug: str = "" + link: str = "" + from_id: int | None = None + to_id: int | None = None + date: str | None = None + date_unix: int | None = None + months: int | None = None + days: int | None = None + used_date: str | None = None + via_giveaway: bool = False + giveaway_msg_id: int | None = None + used: bool = False + + +class GiftCodeApplied(Model): + slug: str + applied: bool = False + months: int | None = None + until_date: str | None = None + already: bool = False + + +class GiveawayWinner(Model): + user_id: int + user: Peer | None = None + slug: str | None = None + + +class GiveawayInfo(Model): + """`payments.getGiveawayInfo` — the personal "did I win?" answer.""" + + chat_id: int = 0 + msg_id: int = 0 + #: ongoing | finished + state: str = "ongoing" + start_date: str | None = None + until_date: str | None = None + winners_count: int | None = None + months: int | None = None + stars: int | None = None + only_new_subscribers: bool = False + countries: list[str] = [] + joined: bool = False + #: participating | already-participating | disallowed-country | admin | + #: joined-too-early — why this account cannot take part. + disallowed_reason: str | None = None + winner: bool = False + refunded: bool = False + gift_code_slug: str | None = None + activated_count: int | None = None + winners: list[GiveawayWinner] = [] + prize_description: str | None = None + + +class PrepaidGiveaway(Model): + id: int + quantity: int = 0 + months: int | None = None + stars: int | None = None + boosts: int | None = None + date: str | None = None + date_unix: int | None = None + slug: str | None = None + used: bool = False + from_chat: int | None = None + + +class GiveawayLaunched(Model): + chat_id: int + prepaid_id: int = 0 + msg_id: int | None = None + winners_count: int = 0 + until_date: str | None = None diff --git a/tlgr/models/privacy.py b/tlgr/models/privacy.py new file mode 100644 index 0000000..5bd839f --- /dev/null +++ b/tlgr/models/privacy.py @@ -0,0 +1,91 @@ +"""Privacy rules, the global privacy switches, and paid-message revenue. + +`account.setPrivacy` **replaces** the whole ordered rule vector, and +`account.setGlobalPrivacySettings` replaces the whole constructor. Both are +therefore read-modify-write operations, and both need a model that survives +the round trip without losing anything: + +* `PrivacySettings` splits the vector into the four exception lists a human + edits *and* keeps `raw_rules` — the constructors in server order — so a + rule tlgr does not recognise is still sent back unchanged. +* `GlobalPrivacy` names every field of the constructor, so patching one and + writing the rest back cannot silently clear a switch nobody mentioned. +""" + +from __future__ import annotations + +from tlgr.models.base import Model +from tlgr.models.peer import Peer + +__all__ = [ + "GlobalPrivacy", + "PaidMessageRevenue", + "PrivacyExceptions", + "PrivacyRule", + "PrivacySettings", +] + + +class PrivacyRule(Model): + """One `privacyValue*` constructor, in tlgr's vocabulary.""" + + #: allow | disallow + action: str = "allow" + #: all | contacts | close-friends | premium | bots | users | chats + scope: str = "all" + ids: list[int] = [] + + +class PrivacyExceptions(Model): + """The four lists the GUI calls "Always allow" / "Never allow".""" + + allow_users: list[int] = [] + deny_users: list[int] = [] + allow_chats: list[int] = [] + deny_chats: list[int] = [] + + +class PrivacySettings(Model): + """One privacy key, read or written. + + `base` is the headline the GUI shows; the exception lists are what it + puts under it. `raw_rules` is the server's own ordered vector, kept so a + write can reproduce it exactly. + """ + + key: str + #: everybody | contacts | close-friends | premium | bots | nobody + base: str = "nobody" + allow_users: list[int] = [] + deny_users: list[int] = [] + allow_chats: list[int] = [] + deny_chats: list[int] = [] + raw_rules: list[PrivacyRule] = [] + #: Resolved names for the exception ids, when `--resolve` was given. + peers: list[Peer] = [] + + +class GlobalPrivacy(Model): + """`globalPrivacySettings`, every field named. + + Nothing here defaults to a value the server might disagree with: each + flag is a tri-state so "the server did not report it" stays different + from "off", which is what makes the read-modify-write safe. + """ + + hide_read_marks: bool | None = None + archive_and_mute_new_noncontact_peers: bool | None = None + new_noncontact_peers_require_premium: bool | None = None + noncontact_peers_paid_stars: int | None = None + keep_archived_unmuted: bool | None = None + keep_archived_folders: bool | None = None + display_gifts_button: bool | None = None + #: unlimited | limited | unique | premium | from-channels + disallowed_gifts: list[str] = [] + changed: list[str] = [] + already: bool = False + + +class PaidMessageRevenue(Model): + user_id: int + stars_amount: int = 0 diff --git a/tlgr/models/profile.py b/tlgr/models/profile.py new file mode 100644 index 0000000..48321cf --- /dev/null +++ b/tlgr/models/profile.py @@ -0,0 +1,213 @@ +"""My own profile — the Settings ▸ Edit Profile screen, as data. + +Everything here describes *this* account rather than somebody else's: the +name and bio the world sees, the usernames (including the Fragment +collectibles), the avatar history, the accent colours, the emoji status and +the presence switch. + +Two shapes are deliberate. + +* **`ProfileFull` is one object, not a union of three RPCs.** `users.getUsers` + answers the name, `users.getFullUser` the bio, birthday, personal channel + and gift counters. v1 fetched only the first and hard-coded `bio` to `""`, + which is a wrong answer dressed as a real one. One model, both calls. +* **A username is a row, not a string.** A collectible bought on Fragment can + be active or parked, and only the *first* active one is the main handle; + flattening the vector to `username` is how a script deactivates the wrong + name. +""" + +from __future__ import annotations + +from tlgr.models.base import Model +from tlgr.models.peer import Peer + +__all__ = [ + "AdminedChannel", + "ColorPalette", + "ColorSet", + "EmojiStatusItem", + "EmojiStatusSet", + "PhotosDeleted", + "PresenceSet", + "ProfileFull", + "ProfileLink", + "ProfilePhotoSet", + "ProfileUpdated", + "ProfileUsername", + "UsernameSet", +] + + +class ProfileUsername(Model): + """One entry of `user.usernames`. + + `main` is derived rather than reported: the server marks the basic + username with `editable`, and the first *active* entry is what a link + resolves to. Both facts matter and neither is the other. + """ + + username: str + active: bool = True + editable: bool = False + main: bool = False + + +class ProfileFull(Model): + """My profile as the Edit Profile screen shows it.""" + + id: int = 0 + first_name: str = "" + last_name: str = "" + username: str | None = None + usernames: list[ProfileUsername] = [] + phone: str | None = None + premium: bool = False + bot: bool = False + #: Only present with `--full`; `""` means "fetched and empty", `None` + #: means "not fetched", which v1 could not tell apart. + bio: str | None = None + birthday: str | None = None + personal_channel_id: int | None = None + personal_channel: Peer | None = None + emoji_status: int | None = None + emoji_status_collectible_id: int | None = None + emoji_status_until: str | None = None + color: int | None = None + color_collectible_id: int | None = None + background_emoji_id: int | None = None + profile_color: int | None = None + profile_background_emoji_id: int | None = None + #: Which tab the profile page opens on (`stargifts`, `posts`, …). + main_tab: str | None = None + stargifts_count: int | None = None + stars_rating: int | None = None + ttl_period: int | None = None + sponsored_enabled: bool | None = None + #: Gift categories this account refuses, from `disallowed_gifts`. + disallowed_gifts: list[str] = [] + photo_id: int | None = None + fallback_photo_id: int | None = None + contacts_count: int | None = None + common_chats_count: int | None = None + + +class ProfileUpdated(Model): + """What `profile update` actually changed, field by field. + + Only the fields the caller named appear, because the command spans three + RPCs and "I asked for a birthday and got a name back" is a report nobody + can act on. + """ + + first_name: str | None = None + last_name: str | None = None + bio: str | None = None + birthday: str | None = None + personal_channel_id: int | None = None + photo_id: int | None = None + changed: list[str] = [] + already: bool = False + + +class UsernameSet(Model): + """`profile username set`, in all five of its moods.""" + + username: str | None = None + active: bool | None = None + #: Only for `--check`: whether the name may be taken right now. + available: bool | None = None + #: Set when the server says `USERNAME_PURCHASE_AVAILABLE`: the name is + #: free only on Fragment, which is a different answer from "taken". + purchasable: bool = False + usernames: list[ProfileUsername] = [] + already: bool = False + + +class ColorPalette(Model): + """One entry of `help.getPeerColors` / `getPeerProfileColors`.""" + + color_id: int + colors: list[str] = [] + dark_colors: list[str] = [] + min_level: int = 0 + hidden: bool = False + channel_min_level: int | None = None + group_min_level: int | None = None + #: True for palettes 0-6, whose colours every client hard-codes. + builtin: bool = False + + +class ColorSet(Model): + """`profile color set`: the palette now in force.""" + + color: int | None = None + collectible_id: int | None = None + background_emoji_id: int | None = None + for_profile: bool = False + already: bool = False + + +class EmojiStatusItem(Model): + """A wearable emoji status, from any of the four suggestion lists.""" + + document_id: int = 0 + collectible_id: int | None = None + title: str | None = None + slug: str | None = None + group: str = "" + until: str | None = None + + +class EmojiStatusSet(Model): + document_id: int | None = None + collectible_id: int | None = None + until: str | None = None + until_unix: int | None = None + cleared: bool = False + already: bool = False + + +class PresenceSet(Model): + """`profile presence set`. `online` is what was *reported*, not measured.""" + + online: bool + already: bool = False + + +class ProfilePhotoSet(Model): + photo_id: int | None = None + is_video: bool = False + fallback: bool = False + #: Set when the avatar was built server-side from a custom emoji. + emoji_markup: bool = False + + +class PhotosDeleted(Model): + deleted: int = 0 + photo_ids: list[int] = [] + already: bool = False + + +class ProfileLink(Model): + """`profile link`: the public handle, and what Fragment knows about it.""" + + link: str = "" + username: str | None = None + user_id: int | None = None + #: A unicode-block QR, when `--qr` was given. + qr: str | None = None + qr_path: str | None = None + #: `fragment.getCollectibleInfo`, when `--collectible` was given. + collectible: dict[str, object] | None = None + resolvable_by_strangers: bool = True + + +class AdminedChannel(Model): + """A public channel eligible to be shown on the profile.""" + + id: int + title: str = "" + username: str | None = None + participants_count: int | None = None + current: bool = False diff --git a/tlgr/models/settings.py b/tlgr/models/settings.py new file mode 100644 index 0000000..d094eb1 --- /dev/null +++ b/tlgr/models/settings.py @@ -0,0 +1,103 @@ +"""Cloud-synced account settings, languages and cloud themes. + +`settings get`/`settings set` are one generic pair over a dozen unrelated +RPCs, so the model is a *key and a value* rather than a struct per toggle. +That is not laziness: every key prints the exact token vocabulary its setter +accepts, which makes `tlgr settings get X | tlgr settings set X -` a real +round trip and keeps twelve near-identical commands out of the surface. + +`previous` is always reported on a write. A setting that was already in the +wanted state answers `already: true` with `previous == value`, which is the +only way a script can tell "I changed it" from "it was like that". +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.models.base import Model + +__all__ = [ + "CloudTheme", + "Language", + "SettingChange", + "SettingUnset", + "SettingValue", + "ThemeInstalled", +] + + +class SettingValue(Model): + """One cloud setting, read. + + `changeable` is false when the server will refuse the write for a reason + that is not an error — Premium-only keys, and the sensitive-content + toggle in regions that require an age check first. + """ + + key: str + value: Any = None + changeable: bool = True + #: server | app-config | derived — where the value came from. + source: str = "server" + accepts: str = "" + reason: str | None = None + + +class SettingChange(Model): + key: str + value: Any = None + previous: Any = None + already: bool = False + #: `auto-delete --apply-to-existing`: how many chats were rewritten. + applied_to: int | None = None + + +class SettingUnset(Model): + key: str + removed: int = 0 + values: list[str] = [] + already: bool = False + + +class Language(Model): + lang_code: str + name: str = "" + native_name: str = "" + official: bool = False + beta: bool = False + rtl: bool = False + strings_count: int = 0 + translated_count: int = 0 + translations_url: str | None = None + plural_code: str | None = None + base_lang_code: str | None = None + + +class CloudTheme(Model): + """A cloud theme's metadata. tlgr has no theming engine and renders none.""" + + id: int = 0 + access_hash: int | None = None + slug: str = "" + title: str = "" + creator: bool = False + default: bool = False + for_chat: bool = False + installs_count: int | None = None + document_id: int | None = None + emoticon: str | None = None + settings: list[dict[str, Any]] = [] + link: str | None = None + + +class ThemeInstalled(Model): + slug: str | None = None + id: int | None = None + title: str | None = None + installed: bool = False + saved: bool = False + removed: bool = False + dark: bool = False + document_id: int | None = None + already: bool = False diff --git a/tlgr/models/stars.py b/tlgr/models/stars.py new file mode 100644 index 0000000..6133b45 --- /dev/null +++ b/tlgr/models/stars.py @@ -0,0 +1,101 @@ +"""Telegram Stars: the balance, the ledger, subscriptions and revenue. + +A Stars amount is `(amount, nanos)` on the wire, and both halves are kept: +collapsing them to a float loses the ninth digit that a ledger reconciliation +depends on, and TON amounts arrive in the same shape with nine decimals of +their own. + +Nothing here moves value. The withdrawal command produces a Fragment URL for +a human to open, which is why `StarsUrl` is a URL and not a receipt. +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.models.base import Model +from tlgr.models.peer import Peer + +__all__ = [ + "StarsBalance", + "StarsRating", + "StarsRefulfill", + "StarsRevenue", + "StarsTransaction", + "StarsUrl", +] + + +class StarsBalance(Model): + """`payments.getStarsStatus`. `ton` is in nanotons, never rounded.""" + + stars: int = 0 + nanos: int = 0 + ton: int | None = None + currency: str = "XTR" + subscriptions_missing_balance: int | None = None + + +class StarsTransaction(Model): + id: str = "" + date: str | None = None + date_unix: int | None = None + #: Signed: negative is money leaving the balance. + stars: int = 0 + nanos: int = 0 + refund: bool = False + pending: bool = False + failed: bool = False + peer: int | None = None + peer_kind: str = "" + peer_ref: Peer | None = None + title: str | None = None + description: str | None = None + msg_id: int | None = None + subscription_period: int | None = None + transaction_url: str | None = None + #: gift | reaction | subscription | resale | upgrade | ads | … + kind: str = "" + + +class StarsRating(Model): + level: int = 0 + stars: int = 0 + current_level_stars: int = 0 + next_level_stars: int | None = None + pending_stars: int | None = None + pending_date: str | None = None + learnmore_url: str | None = None + + +class StarsRevenue(Model): + chat_id: int = 0 + current_balance: int | None = None + available_balance: int | None = None + overall_revenue: int | None = None + withdrawal_enabled: bool = False + next_withdrawal_at: str | None = None + usd_rate: float | None = None + revenue_graph: dict[str, Any] | None = None + top_hours_graph: dict[str, Any] | None = None + + +class StarsUrl(Model): + """A Fragment URL. Opening it — and the transfer — is the human's job.""" + + url: str = "" + #: withdrawal | ads + kind: str = "withdrawal" + chat_id: int = 0 + amount: int | None = None + ton: bool = False + + +class StarsRefulfill(Model): + """Re-joining a lapsed Star subscription, reported and not performed.""" + + id: str + ok: bool = False + can_refulfill: bool | None = None + stars: int | None = None + reason: str = "" From 6675d748c7379c58d5f18985cdeea99a6da49abe Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 07:11:24 +0330 Subject: [PATCH 02/15] profile ops: sixteen operations, and three bugs v1 shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Edit Profile screen, whole: names and bio, usernames including the Fragment collectibles, the avatar history, the accent palettes, the emoji status, the presence switch, the personal channel, the saved music, the wallpaper and the public link. Three of these are corrections rather than features. `profile get` now fetches `users.getFullUser`, so `bio` is the bio instead of the `""` v1 hard-coded for every account. `profile photo set` uploads the file and sends raw `photos.uploadProfilePhoto`, because v1 called `client.upload_profile_photo()`, which Telethon 1.44 does not have — the command could never have worked. And `profile update` is one command over four RPCs, because the GUI shows one screen and `updateProfile` / `updateBirthday` / `updatePersonalChannel` is the server's decomposition, not a vocabulary an agent should have to learn. `profile presence set` exists because a daemon needs a presence *policy*: always reporting online advertises a machine, and reading history while reporting offline is the classic bot tell, so tlgr reports neither unless asked. The last v1 command module goes with it — `tlgr/cli/legacy/profile.py` is deleted and both its paths stay invocable through `legacy_paths`. --- tlgr/cli/__init__.py | 3 - tlgr/cli/legacy/profile.py | 54 -- tlgr/models/contact.py | 4 + tlgr/ops/_settings.py | 306 ++++++++ tlgr/ops/profile.py | 1477 ++++++++++++++++++++++++++++++++++++ tlgr/registry.py | 4 + 6 files changed, 1791 insertions(+), 57 deletions(-) delete mode 100644 tlgr/cli/legacy/profile.py create mode 100644 tlgr/ops/_settings.py create mode 100644 tlgr/ops/profile.py diff --git a/tlgr/cli/__init__.py b/tlgr/cli/__init__.py index 05a66c5..2f07d58 100644 --- a/tlgr/cli/__init__.py +++ b/tlgr/cli/__init__.py @@ -230,9 +230,6 @@ def cli( # --------------------------------------------------------------------------- from tlgr.cli.gen import build_click_tree # noqa: E402 -from tlgr.cli.legacy.profile import profile_group # noqa: E402 - -cli.add_command(profile_group, "profile") # --------------------------------------------------------------------------- diff --git a/tlgr/cli/legacy/profile.py b/tlgr/cli/legacy/profile.py deleted file mode 100644 index 502aa4c..0000000 --- a/tlgr/cli/legacy/profile.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Profile management commands.""" - -from __future__ import annotations - -import click - -from tlgr.cli.legacy._common import resolve_account -from tlgr.core.output import emit -from tlgr.ipc_client import ipc_request - - -@click.group("profile") -def profile_group() -> None: - """View and update your Telegram profile.""" - - -@profile_group.command("get") -@click.option("--account", "-a", default=None) -@click.pass_context -def profile_get(ctx: click.Context, account: str | None) -> None: - """Show your current profile.""" - acct = resolve_account(ctx, account) - result = ipc_request("GET", "/profile/get", params={"account": acct}) - emit(ctx.obj, result, columns=["id", "first_name", "last_name", "username", "phone"]) - - -@profile_group.command("update") -@click.option("--first-name", default=None) -@click.option("--last-name", default=None) -@click.option("--bio", default=None) -@click.option("--photo", default=None, type=click.Path(exists=True), help="Path to profile photo.") -@click.option("--account", "-a", default=None) -@click.pass_context -def profile_update( - ctx: click.Context, - first_name: str | None, - last_name: str | None, - bio: str | None, - photo: str | None, - account: str | None, -) -> None: - """Update your profile.""" - acct = resolve_account(ctx, account) - body = {"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 bio is not None: - body["bio"] = bio - if photo is not None: - body["photo"] = photo - result = ipc_request("POST", "/profile/update", body=body) - emit(ctx.obj, result) diff --git a/tlgr/models/contact.py b/tlgr/models/contact.py index f72dc8f..c42d0cd 100644 --- a/tlgr/models/contact.py +++ b/tlgr/models/contact.py @@ -448,6 +448,10 @@ class ProfilePhoto(ContactModel): video: bool = False dc_id: int | None = None file: str | None = None + #: Only filled for my own history (`profile photo list`): which of these + #: is the avatar in force. Absent on another user's photos, because the + #: server does not say. + current: bool = False class PhotoResult(ContactModel): diff --git a/tlgr/ops/_settings.py b/tlgr/ops/_settings.py new file mode 100644 index 0000000..8df9878 --- /dev/null +++ b/tlgr/ops/_settings.py @@ -0,0 +1,306 @@ +"""The plumbing the nine settings modules share. + +`profile`, `privacy`, `notify`, `settings`, `business`, `premium`, `stars`, +`gift` and `giveaway` are one GUI screen each, but they keep meeting the same +four problems, and a second copy of any of them is how two commands start +disagreeing about the same server field: + +* **a Stars amount is `(amount, nanos)`**, and TON arrives in the same shape + with nine decimals — collapsing either to a float loses the digit a ledger + reconciliation needs; +* **half of this surface replaces a whole constructor**, so the read half of + a read-modify-write is shared rather than re-derived per flag; +* **a gift is addressed by a reference string**, and the three spellings + (`msg:`, `:`, a bare slug) must parse and *print* the + same way in every module; +* **five payments methods are absent from Telethon 1.44**, and the refusal + has to say which one and why, in one sentence, everywhere. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.core.errors import NotSupportedError, PermissionError_, UsageError +from tlgr.core.timefmt import fmt_dt, fmt_unix +from tlgr.models.peer import Peer, PeerRef +from tlgr.ops._common import client +from tlgr.ops._spec import OpContext + +__all__ = [ + "ABSENT_METHODS", + "NO_SPEND", + "app_config", + "client", + "color_int", + "color_text", + "entity_map", + "gift_ref_text", + "input_gift", + "input_user", + "iso", + "method_gap", + "on_off", + "peer_model", + "peer_of", + "refuse_spend", + "resolve", + "slug_of", + "sound_text", + "sound_value", + "stars_of", + "unix", +] + +#: The one sentence every refusal to move value ends with. PR-10 settled the +#: policy for the `payment` group (`ops/payment.py`); PR-12 inherits it rather +#: than opening a second door onto the same money. +NO_SPEND = ( + "tlgr never spends money: it reads the price and refuses to sign the form. " + "Complete the purchase in an official client if you want it" +) + +#: `payments.*` methods this build has no request class for, and what each +#: one would have answered. Named here so the refusal, the docs and +#: `agent capabilities` cannot describe the gap three different ways. +ABSENT_METHODS: dict[str, str] = { + "payments.canSendStarGift": "whether a specific recipient accepts a specific gift", + "payments.getStarGiftCraftCandidates": "which of my gifts can be melted into a given one", + "payments.getStarGiftAttributes": "the full attribute table of a gift type", + "payments.getStarGiftValueInfo": "the floor price and last sale of a collectible", + "payments.getPrepaidGiveaways": "a channel's prepaid giveaways as a list of their own", +} + + +def method_gap(feature: str, method: str) -> Any: + """Refuse a feature whose only MTProto method this Telethon lacks (exit 13). + + Distinct from `_layer.py`: those are layer-229 *features* with no + constructor at all, these are individual methods missing from a Telethon + that otherwise speaks the surface. The command still exists and the rest + of it still works — only the flag that needs the method refuses. + """ + answers = ABSENT_METHODS.get(method, "") + raise NotSupportedError( + f"{feature} needs {method}, which Telethon 1.44 has no request class for" + + (f" (it is what answers {answers})" if answers else "") + + "; the rest of this command works, and the flag starts working with " + "the Telethon uplift without its spelling changing" + ) + + +def refuse_spend(what: str) -> Any: + """Refuse an operation that would move a financial asset (exit 6).""" + raise PermissionError_(f"{what}: {NO_SPEND}") + + +# --------------------------------------------------------------------------- +# Peers +# --------------------------------------------------------------------------- + + +async def resolve(ctx: OpContext, ref: PeerRef | str | None) -> Any: + """The `InputPeer` for *ref*, through the account's own resolver.""" + from tlgr.ops import _send + + return await _send.resolve(ctx, ref) + + +async def input_user(ctx: OpContext, ref: PeerRef | str | None, *, field: str = "user") -> Any: + """The `InputUser` a `users.*`/`account.*` request wants.""" + from tlgr.ops import _bots + + return await _bots.input_user(ctx, ref, field=field) + + +def peer_of(peer: Any) -> int: + """The marked id of a resolved `InputPeer`.""" + from tlgr.ops import _send + + return _send.peer_id_of(peer) + + +def peer_model(entity: Any) -> Peer | None: + """A `User`/`Chat`/`Channel` as the shared `Peer` shape.""" + if entity is None: + return None + from tlgr.ops._serialize import entity_to_peer + + return entity_to_peer(entity) + + +def entity_map(result: Any) -> dict[int, Any]: + """`{raw id: entity}` for the users *and* chats an answer carried. + + Every `payments.*` and `account.*` answer in this group ships its peers in + two parallel vectors; a single map is what lets one lookup fill in a name + without caring which vector it came from. + """ + found: dict[int, Any] = {} + for name in ("users", "chats"): + for entity in getattr(result, name, None) or []: + with_id = getattr(entity, "id", None) + if with_id is not None: + found[int(with_id)] = entity + return found + + +# --------------------------------------------------------------------------- +# Scalars +# --------------------------------------------------------------------------- + + +def iso(value: Any) -> str | None: + """An RFC-3339 string for a datetime or a unix int, or None.""" + if value is None: + return None + if isinstance(value, (int, float)): + return fmt_unix(int(value)) if value else None + return fmt_dt(value) + + +def unix(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) or None + from tlgr.core.timefmt import to_unix + + return to_unix(value) + + +def stars_of(amount: Any) -> tuple[int, int]: + """`starsAmount` as `(amount, nanos)`. + + An int is accepted because half the payments surface still reports a bare + Star count; the nanos are then genuinely zero rather than unknown. + """ + if amount is None: + return 0, 0 + if isinstance(amount, (int, float)): + return int(amount), 0 + return int(getattr(amount, "amount", 0) or 0), int(getattr(amount, "nanos", 0) or 0) + + +def on_off(value: str | None, *, field: str) -> bool | None: + """`on`/`off` as a bool, `None` for "the caller did not say".""" + if value is None: + return None + text = value.strip().lower() + if text in ("on", "true", "yes", "1"): + return True + if text in ("off", "false", "no", "0"): + return False + raise UsageError(f"--{field.replace('_', '-')} takes on or off", field=field) + + +def color_int(text: str | None, *, field: str = "color") -> int | None: + """`#RRGGBB`, `0xRRGGBB` or a decimal, as the int the API wants.""" + if text is None: + return None + raw = str(text).strip().lstrip("#") + if raw.lower().startswith("0x"): + raw = raw[2:] + try: + return int(raw, 16) if not raw.isdigit() or len(raw) == 6 else int(raw) + except ValueError as exc: + raise UsageError(f"{text!r} is not a colour (use #RRGGBB)", field=field) from exc + + +def color_text(value: Any) -> str: + """An int colour as `#RRGGBB`, which is how a human reads one back.""" + return f"#{int(value or 0) & 0xFFFFFF:06X}" + + +def sound_value(text: str | None) -> Any: + """`default | none | local: | ringtone:<id> | <id>` as a constructor.""" + from telethon.tl import types + + if text is None: + return None + value = text.strip() + if value in ("none", "off", "silent"): + return types.NotificationSoundNone() + if value in ("default", ""): + return types.NotificationSoundDefault() + if value.startswith("local:"): + title = value.split(":", 1)[1] + return types.NotificationSoundLocal(title=title, data=title) + if value.startswith("ringtone:"): + value = value.split(":", 1)[1] + try: + return types.NotificationSoundRingtone(id=int(value)) + except ValueError as exc: + raise UsageError( + "--sound takes default, none, local:<title> or ringtone:<id>", field="sound" + ) from exc + + +def sound_text(value: Any) -> str | None: + """The inverse of `sound_value`, so a read can be piped into a write.""" + from tlgr.ops._serialize import _sound + + return _sound(value) + + +# --------------------------------------------------------------------------- +# Gift references +# --------------------------------------------------------------------------- + + +async def input_gift(ctx: OpContext, ref: str, *, field: str = "ref") -> Any: + """One `inputSavedStarGift*` from tlgr's single reference spelling. + + `msg:<id>` is a gift received in a private chat, `<peer>:<saved_id>` one + held by a channel, and anything else is a collectible slug (a `t.me/nft/` + link is accepted and reduced to its slug). Three server constructors, one + string a caller can copy out of a listing. + """ + from telethon.tl import types + + text = str(ref).strip() + if not text: + raise UsageError("give a gift reference", field=field) + head, sep, tail = text.partition(":") + if sep and head.lower() in ("msg", "message"): + if not tail.lstrip("-").isdigit(): + raise UsageError(f"{text!r}: msg:<id> wants a message id", field=field) + return types.InputSavedStarGiftUser(msg_id=int(tail)) + if sep and tail.lstrip("-").isdigit() and not text.startswith("http"): + return types.InputSavedStarGiftChat(peer=await resolve(ctx, head), saved_id=int(tail)) + return types.InputSavedStarGiftSlug(slug=slug_of(text)) + + +def slug_of(text: str) -> str: + """`t.me/nft/PlushPepe-42` → `PlushPepe-42`; a bare slug passes through.""" + value = str(text).strip() + for marker in ("/nft/", "t.me/", "tg://nft?slug="): + if marker in value: + value = value.split(marker, 1)[1] + return value.split("?", 1)[0].split("#", 1)[0].strip("/") + + +def gift_ref_text(raw: Any) -> str: + """The reference string for a `savedStarGift` the server just handed us.""" + msg_id = getattr(raw, "msg_id", None) + if msg_id: + return f"msg:{msg_id}" + saved_id = getattr(raw, "saved_id", None) + if saved_id: + return f"saved:{saved_id}" + gift = getattr(raw, "gift", None) + return str(getattr(gift, "slug", "") or "") + + +# --------------------------------------------------------------------------- +# App config +# --------------------------------------------------------------------------- + + +async def app_config(ctx: OpContext) -> dict[str, Any]: + """`help.getAppConfig` as plain Python. Never hardcode a server limit.""" + from tlgr.ops import _media + + return await _media.app_config(ctx) diff --git a/tlgr/ops/profile.py b/tlgr/ops/profile.py new file mode 100644 index 0000000..dc00604 --- /dev/null +++ b/tlgr/ops/profile.py @@ -0,0 +1,1477 @@ +"""The `profile` group: Settings ▸ Edit Profile, and everything on it. + +Seven sub-nouns, one subject — *how I appear to other people*: the name and +bio, the usernames (including the Fragment collectibles), the avatar history, +the accent colours, the emoji status, the presence switch and the public link. + +Three things here are corrections rather than features. + +* **`profile get` fetches `users.getFullUser`.** v1 called `get_me()` and + hard-coded `bio` to `""`, so every agent that read a bio read a lie. The + full user is where the bio, the birthday, the personal channel, the gift + counters and the Star rating live, and one command answers with all of it. +* **`profile photo set` uploads the file itself.** v1 called + `client.upload_profile_photo()`, which does not exist in Telethon 1.44, so + the command could never have worked at all. The real path is `upload_file` + followed by raw `photos.uploadProfilePhoto`. +* **`profile update` is one command over three RPCs.** The GUI shows one Edit + Profile screen; `account.updateProfile`, `account.updateBirthday` and + `account.updatePersonalChannel` are the server's decomposition, not a + vocabulary an agent should have to learn. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Annotated, Any + +from tlgr.core.errors import NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix +from tlgr.models.base import Request +from tlgr.models.contact import MusicTrack, ProfilePhoto +from tlgr.models.media import WallpaperInstalled +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.models.profile import ( + AdminedChannel, + ColorPalette, + ColorSet, + EmojiStatusItem, + EmojiStatusSet, + PhotosDeleted, + PresenceSet, + ProfileFull, + ProfileLink, + ProfilePhotoSet, + ProfileUpdated, + ProfileUsername, + UsernameSet, +) +from tlgr.ops import _settings +from tlgr.ops._common import client, window +from tlgr.ops._params import arg, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: Palette ids 0-6 carry no colours of their own: every official client draws +#: red, orange, violet, green, cyan, blue and pink for them. Saying so is the +#: difference between "no colours" and "the built-in seven". +_BUILTIN_PALETTES = ("red", "orange", "violet", "green", "cyan", "blue", "pink") + + +# --------------------------------------------------------------------------- +# profile get +# --------------------------------------------------------------------------- + + +def _usernames(user: Any) -> list[ProfileUsername]: + """`user.usernames`, or the single `username` when the vector is absent. + + The main handle is the first *active* entry, which is the rule links + resolve by; `editable` marks the basic (non-collectible) name, and the + two are different facts that a flattened string loses. + """ + rows: list[ProfileUsername] = [] + for entry in getattr(user, "usernames", None) or []: + rows.append( + ProfileUsername( + username=str(getattr(entry, "username", "") or ""), + active=bool(getattr(entry, "active", False)), + editable=bool(getattr(entry, "editable", False)), + ) + ) + if not rows and getattr(user, "username", None): + rows.append(ProfileUsername(username=str(user.username), active=True, editable=True)) + for row in rows: + if row.active: + row.main = True + break + return rows + + +def _birthday_text(raw: Any) -> str | None: + """`birthday` as `DD-MM` or `DD-MM-YYYY`, the same spelling `--birthday` takes.""" + if raw is None: + return None + day = int(getattr(raw, "day", 0) or 0) + month = int(getattr(raw, "month", 0) or 0) + year = getattr(raw, "year", None) + return f"{day:02d}-{month:02d}" + (f"-{int(year)}" if year else "") + + +def _birthday_tl(text: str | None) -> Any: + """The inverse. `none` clears it, which the API spells "send no birthday".""" + from telethon.tl import types + + if text is None or text.strip().lower() in ("none", "clear", ""): + return None + parts = [part for part in text.replace("/", "-").replace(".", "-").split("-") if part] + if len(parts) not in (2, 3) or not all(part.isdigit() for part in parts): + raise UsageError("--birthday takes DD-MM, DD-MM-YYYY or 'none'", field="birthday") + day, month = int(parts[0]), int(parts[1]) + if not (1 <= day <= 31 and 1 <= month <= 12): + raise UsageError(f"{text!r} is not a date", field="birthday") + return types.Birthday(day=day, month=month, year=int(parts[2]) if len(parts) == 3 else None) + + +def _disallowed_gifts(raw: Any) -> list[str]: + """`disallowedGiftsSettings` as the keyword list `privacy global set` takes.""" + names = { + "disallow_unlimited_stargifts": "unlimited", + "disallow_limited_stargifts": "limited", + "disallow_unique_stargifts": "unique", + "disallow_premium_gifts": "premium", + "disallow_stargifts_from_channels": "from-channels", + } + if raw is None: + return [] + return sorted(word for field, word in names.items() if getattr(raw, field, False)) + + +def _emoji_status(raw: Any, into: ProfileFull) -> None: + name = type(raw).__name__ + if name == "EmojiStatus": + into.emoji_status = int(getattr(raw, "document_id", 0) or 0) + elif name == "EmojiStatusCollectible": + into.emoji_status = int(getattr(raw, "document_id", 0) or 0) + into.emoji_status_collectible_id = int(getattr(raw, "collectible_id", 0) or 0) + else: + return + into.emoji_status_until = fmt_dt(getattr(raw, "until", None)) + + +def _peer_color(raw: Any) -> tuple[int | None, int | None, int | None]: + """`(palette id, collectible id, background emoji id)` from a `PeerColor`.""" + if raw is None: + return None, None, None + if type(raw).__name__ == "PeerColorCollectible": + return ( + None, + int(getattr(raw, "collectible_id", 0) or 0), + getattr(raw, "background_emoji_id", None), + ) + return getattr(raw, "color", None), None, getattr(raw, "background_emoji_id", None) + + +class GetReq(Request): + full: Annotated[ + bool, + opt("--full/--no-full", help="Also fetch users.getFullUser (bio, birthday, counters)."), + ] = True + refresh: Annotated[ + bool, opt("--refresh", help="Force the userFull fetch even with --no-full.") + ] = False + + +async def get(ctx: OpContext, req: GetReq) -> ProfileFull: + """My own profile, including the fields only `userFull` carries. + + `--no-full` exists for the hot path — a script that only needs the id and + the name should not pay for a second round trip — but the default is + `--full`, because v1's default was a `bio` that was always `""`. + """ + from telethon.tl import types + from telethon.tl.functions import users as fn + + handle = client(ctx) + me = await handle.get_me() + profile = ProfileFull( + id=int(getattr(me, "id", 0) or 0), + first_name=str(getattr(me, "first_name", "") or ""), + last_name=str(getattr(me, "last_name", "") or ""), + username=getattr(me, "username", None), + usernames=_usernames(me), + phone=getattr(me, "phone", None), + premium=bool(getattr(me, "premium", False)), + bot=bool(getattr(me, "bot", False)), + photo_id=getattr(getattr(me, "photo", None), "photo_id", None), + ) + _emoji_status(getattr(me, "emoji_status", None), profile) + profile.color, profile.color_collectible_id, profile.background_emoji_id = _peer_color( + getattr(me, "color", None) + ) + profile.profile_color, _, profile.profile_background_emoji_id = _peer_color( + getattr(me, "profile_color", None) + ) + if not req.full and not req.refresh: + return profile + + # tlgr holds no `userFull` cache of its own — the daemon caches peers, + # not profiles — so the fetch below *is* the refresh. `--refresh` exists + # so a caller that assumed a cache gets the fresh answer it wanted rather + # than a flag that silently means nothing. + answer = await handle(fn.GetFullUserRequest(id=types.InputUserSelf())) + full = getattr(answer, "full_user", None) + if full is None: # pragma: no cover - the server always sends one + return profile + profile.bio = str(getattr(full, "about", "") or "") + profile.birthday = _birthday_text(getattr(full, "birthday", None)) + profile.personal_channel_id = getattr(full, "personal_channel_id", None) + if profile.personal_channel_id is not None: + found = _settings.entity_map(answer).get(int(profile.personal_channel_id)) + profile.personal_channel = _settings.peer_model(found) + profile.ttl_period = getattr(full, "ttl_period", None) + profile.sponsored_enabled = getattr(full, "sponsored_enabled", None) + profile.stargifts_count = getattr(full, "stargifts_count", None) + rating = getattr(full, "stars_rating", None) + profile.stars_rating = getattr(rating, "level", None) if rating is not None else None + profile.disallowed_gifts = _disallowed_gifts(getattr(full, "disallowed_gifts", None)) + tab = getattr(full, "main_tab", None) + if tab is not None: + profile.main_tab = type(tab).__name__.removeprefix("ProfileTab").lower() or None + fallback = getattr(full, "fallback_photo", None) + profile.fallback_photo_id = getattr(fallback, "id", None) + profile.common_chats_count = getattr(full, "common_chats_count", None) + return profile + + +SPEC_GET = OperationSpec( + id="profile.get", + request=GetReq, + response=ProfileFull, + impl=get, + summary="Show my own profile (bio, birthday, business, gifts and colours included)", + description=( + 'v1 answered from `get_me()` alone and reported `bio: ""` for every ' + "account, whether or not one was set. This fetches `users.getFullUser` " + "as well, so an absent bio and an empty bio are different answers." + ), + legacy_paths=("profile get",), + idempotent=True, + columns=("id", "first_name", "last_name", "username", "phone", "premium"), + headers=("ID", "First", "Last", "Username", "Phone", "Premium"), + example={ + "id": 4242, + "first_name": "Ada", + "last_name": "Lovelace", + "username": "ada", + "phone": "+989123456789", + "premium": True, + "bio": "counting on it", + "birthday": "10-12", + }, + example_args="profile get", + covers=("profile.main-tab", "profile.set-bio", "profile.view-own"), + covers_partial=("profile.usernames-list",), + coverage_note="The per-username detail (active, collectible) is `profile username list`.", + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# profile update +# --------------------------------------------------------------------------- + + +class UpdateReq(Request): + first_name: Annotated[str | None, opt("--first-name", metavar="TEXT", help="First name.")] = ( + None + ) + last_name: Annotated[ + str | None, opt("--last-name", metavar="TEXT", help="Last name ('' clears it).") + ] = None + bio: Annotated[str | None, opt("--bio", metavar="TEXT", help="About text.")] = None + birthday: Annotated[ + str | None, opt("--birthday", metavar="DATE", help="DD-MM, DD-MM-YYYY, or 'none'.") + ] = None + channel: Annotated[ + str | None, + opt("--channel", metavar="CHAT", help="Personal channel to show; 'none' unlinks."), + ] = None + photo: Annotated[ + str | None, opt("--photo", metavar="PATH", kind="path", help="Shortcut for photo set.") + ] = None + + +async def update(ctx: OpContext, req: UpdateReq) -> ProfileUpdated: + """Edit my profile: names, bio, birthday, personal channel, photo. + + One command, up to four RPCs, and only the ones the caller's flags need. + An empty string is a real value — `--last-name ""` clears the surname, + which is why the fields are `str | None` and not truthiness tests. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + result = ProfileUpdated() + + if req.first_name is not None or req.last_name is not None or req.bio is not None: + await handle( + fn.UpdateProfileRequest( + first_name=req.first_name, last_name=req.last_name, about=req.bio + ) + ) + for name, value in ( + ("first_name", req.first_name), + ("last_name", req.last_name), + ("bio", req.bio), + ): + if value is not None: + setattr(result, name, value) + result.changed.append(name) + + if req.birthday is not None: + await handle(fn.UpdateBirthdayRequest(birthday=_birthday_tl(req.birthday))) + result.birthday = _birthday_text(_birthday_tl(req.birthday)) + result.changed.append("birthday") + + if req.channel is not None: + if req.channel.strip().lower() in ("none", "clear", ""): + channel: Any = types.InputChannelEmpty() + result.personal_channel_id = None + else: + from tlgr.ops._common import input_channel + + peer = await _settings.resolve(ctx, req.channel) + channel = input_channel(peer) + result.personal_channel_id = _settings.peer_of(peer) + await handle(fn.UpdatePersonalChannelRequest(channel=channel)) + result.changed.append("personal_channel") + + if req.photo is not None: + photo = await photo_set(ctx, PhotoSetReq(file=req.photo)) + result.photo_id = photo.photo_id + result.changed.append("photo") + + if not result.changed: + raise UsageError( + "nothing to change: give --first-name, --last-name, --bio, " + "--birthday, --channel or --photo", + field="first_name", + ) + ctx.emit("profile_updated", {"changed": result.changed}) + return result + + +SPEC_UPDATE = OperationSpec( + id="profile.update", + request=UpdateReq, + response=ProfileUpdated, + impl=update, + summary="Edit my profile: names, bio, birthday, personal channel, photo", + description=( + "`changed` names exactly the fields that were written, because the " + "command spans four RPCs and a report that lists what you did not ask " + "for is one nobody can act on." + ), + aliases=("profile.set",), + legacy_paths=("profile update",), + mutating=True, + columns=("first_name", "last_name", "bio", "changed"), + headers=("First", "Last", "Bio", "Changed"), + example={"first_name": "Ada", "bio": "counting on it", "changed": ["first_name", "bio"]}, + example_args='profile update --bio "counting on it"', + covers=("profile.birthday-set", "profile.set-name"), + covers_partial=("profile.personal-channel", "profile.photo-upload", "profile.set-bio"), + coverage_note=( + "Reading these back is `profile get`; the avatar's own flags live on " + "`profile photo set`, and the eligible channels on `profile channel list`." + ), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# profile username list / set +# --------------------------------------------------------------------------- + + +class UsernameListReq(Request): + pass + + +async def username_list(ctx: OpContext, req: UsernameListReq) -> Page[ProfileUsername]: + """Every username on this account, collectibles included.""" + rows = _usernames(await client(ctx).get_me()) + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_USERNAME_LIST = OperationSpec( + id="profile.username.list", + request=UsernameListReq, + response=Page[ProfileUsername], + impl=username_list, + summary="List my usernames, including Fragment collectibles", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("username", "active", "editable", "main"), + headers=("Username", "Active", "Editable", "Main"), + example={ + "items": [ + {"username": "ada", "active": True, "editable": True, "main": True}, + {"username": "lovelace", "active": False}, + ] + }, + example_args="profile username list", + covers=("profile.usernames-list",), + tags=frozenset({"agent-safe"}), +) + + +class UsernameSetReq(Request): + name: Annotated[ + str | None, arg(0, metavar="NAME", required=False, help="The username to act on.") + ] = None + check: Annotated[bool, opt("--check", help="Only test availability.")] = False + clear: Annotated[bool, opt("--clear", help="Remove the main username.")] = False + on: Annotated[bool, opt("--on", help="Activate a collectible username.")] = False + off: Annotated[bool, opt("--off", help="Deactivate a collectible username.")] = False + order: Annotated[ + str | None, + opt("--order", metavar="LIST", help="Comma-separated: every active username, in order."), + ] = None + + +async def username_set(ctx: OpContext, req: UsernameSetReq) -> UsernameSet: + """Set, check, clear, activate/deactivate or reorder usernames. + + `USERNAME_PURCHASE_AVAILABLE` is the interesting failure: the name is not + taken, it simply only exists for sale on Fragment. Reporting that as + `purchasable: true` rather than as an opaque error is the difference + between "pick another name" and "you can have this one, for money". + """ + from telethon.tl.functions import account as fn + + handle = client(ctx) + + if req.order is not None: + order = [part.strip().lstrip("@") for part in req.order.split(",") if part.strip()] + if not order: + raise UsageError("--order wants every active username, in order", field="order") + await handle(fn.ReorderUsernamesRequest(order=order)) + return UsernameSet(usernames=_usernames(await handle.get_me())) + + if req.on or req.off: + if not req.name: + raise UsageError("--on/--off need the username to toggle", field="name") + await handle(fn.ToggleUsernameRequest(username=req.name.lstrip("@"), active=bool(req.on))) + return UsernameSet( + username=req.name.lstrip("@"), + active=bool(req.on), + usernames=_usernames(await handle.get_me()), + ) + + if req.clear: + await handle(fn.UpdateUsernameRequest(username="")) + return UsernameSet(username=None, active=False, usernames=_usernames(await handle.get_me())) + + if not req.name: + raise UsageError("give a username, or --clear / --order", field="name") + wanted = req.name.lstrip("@") + + if req.check: + try: + free = bool(await handle(fn.CheckUsernameRequest(username=wanted))) + except Exception as exc: + if "USERNAME_PURCHASE_AVAILABLE" in f"{type(exc).__name__} {exc}".upper(): + return UsernameSet(username=wanted, available=False, purchasable=True) + raise + return UsernameSet(username=wanted, available=free) + + await handle(fn.UpdateUsernameRequest(username=wanted)) + ctx.emit("profile_username", {"username": wanted}) + return UsernameSet(username=wanted, active=True, usernames=_usernames(await handle.get_me())) + + +SPEC_USERNAME_SET = OperationSpec( + id="profile.username.set", + request=UsernameSetReq, + response=UsernameSet, + impl=username_set, + summary="Set, check, clear, activate/deactivate or reorder my usernames", + description=( + "`--check` writes nothing. A name the server answers " + "`USERNAME_PURCHASE_AVAILABLE` for is reported as `purchasable`, not " + "as taken: it exists only on Fragment." + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("username", "active", "available", "purchasable"), + headers=("Username", "Active", "Available", "On Fragment"), + example={"username": "ada", "active": True}, + example_args="profile username set ada", + covers=("profile.set-username", "profile.username-reorder", "profile.username-toggle"), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# profile photo list / set / delete +# --------------------------------------------------------------------------- + + +class PhotoListReq(Request): + download: Annotated[ + str | None, + opt("--download", metavar="DIR", kind="path", help="Download the listed photos here."), + ] = None + + +async def photo_list(ctx: OpContext, req: PhotoListReq) -> Page[ProfilePhoto]: + """My profile-photo history, newest first. + + Also the source of the ids `profile photo set --photo-id` and `profile + photo delete` take: a photo id alone is useless without the access hash + and file reference this listing put in the session's cache, which is why + reusing an id from a previous run fails with `FILE_REFERENCE_EXPIRED`. + """ + from telethon.tl import types + from telethon.tl.functions import photos as fn + + limit, state = window(ctx, "profile.photo.list", PageKind.PARTICIPANTS, default=20) + offset = int(state.get("offset", 0) or 0) + handle = client(ctx) + result = await handle( + fn.GetUserPhotosRequest(user_id=types.InputUserSelf(), offset=offset, max_id=0, limit=limit) + ) + photos = list(getattr(result, "photos", None) or []) + me = await handle.get_me() + current = getattr(getattr(me, "photo", None), "photo_id", None) + + rows = [ + ProfilePhoto( + id=int(getattr(photo, "id", 0) or 0), + date=fmt_dt(getattr(photo, "date", None)), + date_unix=to_unix(getattr(photo, "date", None)), + 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), + current=current is not None and int(getattr(photo, "id", 0) or 0) == int(current), + ) + for photo in photos + ] + + if req.download: + directory = Path(os.path.expanduser(req.download)) + directory.mkdir(parents=True, exist_ok=True) + for photo, row in zip(photos, rows, strict=True): + try: + saved = await handle.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="profile.photo.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(rows)}, + account=ctx.account, + limit=limit, + total=getattr(result, "count", None), + ) + + +SPEC_PHOTO_LIST = OperationSpec( + id="profile.photo.list", + request=PhotoListReq, + response=Page[ProfilePhoto], + impl=photo_list, + summary="List (and optionally download) my profile photos", + paginated=PageKind.PARTICIPANTS, + idempotent=True, + rate_class="file", + timeout_s=300, + columns=("id", "date", "video", "current"), + headers=("Photo", "Taken", "Video", "Current"), + example={ + "items": [{"id": 55123, "date": "2026-08-01T10:00:00Z", "video": False, "current": True}], + "has_more": False, + }, + example_args="profile photo list", + covers=("profile.photo-list", "profile.photos-list-history"), + tags=frozenset({"agent-safe"}), +) + + +class PhotoSetReq(Request): + file: Annotated[ + str | None, + arg(0, metavar="FILE", required=False, kind="path", help="Image or video to upload."), + ] = None + video: Annotated[bool, opt("--video", help="Treat the file as an animated avatar.")] = False + start_ts: Annotated[ + float | None, opt("--start-ts", metavar="SECONDS", help="Cover frame of a video avatar.") + ] = None + photo_id: Annotated[ + str | None, opt("--photo-id", metavar="ID", help="Re-use a photo from `photo list`.") + ] = None + emoji: Annotated[ + str | None, opt("--emoji", metavar="ID", help="Build the avatar from a custom emoji.") + ] = None + colors: Annotated[ + str | None, opt("--colors", metavar="LIST", help="Background gradient for --emoji.") + ] = None + sticker_set: Annotated[ + str | None, + opt("--sticker-set", metavar="SET:ID", help="Sticker markup instead of a custom emoji."), + ] = None + fallback: Annotated[ + bool, opt("--fallback", help="Set the public fallback photo instead of the main one.") + ] = False + + +async def photo_set(ctx: OpContext, req: PhotoSetReq) -> ProfilePhotoSet: + """Set my avatar from a file, a video, an older photo or a custom emoji. + + The fallback photo is what people who may *not* see the real avatar get, + so it only means anything next to a restrictive `privacy set + profile-photo` rule — setting one without that rule changes nothing + anybody will ever see. + """ + from telethon.tl import types + from telethon.tl.functions import photos as fn + + handle = client(ctx) + + if req.photo_id: + if not req.photo_id.strip().lstrip("-").isdigit(): + raise UsageError("--photo-id wants a photo id from `profile photo list`", field="photo") + photo = await _input_photo(ctx, int(req.photo_id)) + result = await handle(fn.UpdateProfilePhotoRequest(id=photo, fallback=req.fallback or None)) + return ProfilePhotoSet(photo_id=_photo_id_of(result), fallback=req.fallback, is_video=False) + + kwargs: dict[str, Any] = {"fallback": req.fallback or None} + markup: Any = None + colours = [ + _settings.color_int(value, field="colors") + for value in (req.colors or "").split(",") + if value.strip() + ] + if req.emoji: + if not req.emoji.strip().isdigit(): + raise UsageError("--emoji wants a custom-emoji document id", field="emoji") + markup = types.VideoSizeEmojiMarkup( + emoji_id=int(req.emoji), background_colors=[c for c in colours if c is not None] + ) + elif req.sticker_set: + from tlgr.ops import _media + + short, _, sticker_id = req.sticker_set.rpartition(":") + if not short or not sticker_id.isdigit(): + raise UsageError("--sticker-set wants '<set>:<sticker id>'", field="sticker_set") + markup = types.VideoSizeStickerMarkup( + stickerset=_media.sticker_set_ref(short, field="sticker_set"), + sticker_id=int(sticker_id), + background_colors=[c for c in colours if c is not None], + ) + if markup is not None: + kwargs["video_emoji_markup"] = markup + else: + if not req.file: + raise UsageError("give a FILE, or --photo-id / --emoji / --sticker-set", field="file") + path = Path(os.path.expanduser(req.file)) + if not path.exists(): + raise UsageError(f"{req.file} does not exist", field="file") + 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") + handle_file = await upload(path) + if req.video: + kwargs["video"] = handle_file + kwargs["video_start_ts"] = req.start_ts + else: + kwargs["file"] = handle_file + + result = await handle(fn.UploadProfilePhotoRequest(**kwargs)) + ctx.emit("profile_photo", {"fallback": req.fallback}) + return ProfilePhotoSet( + photo_id=_photo_id_of(result), + is_video=bool(req.video), + fallback=req.fallback, + emoji_markup=markup is not None, + ) + + +def _photo_id_of(result: Any) -> int | None: + photo = getattr(result, "photo", None) + value = getattr(photo, "id", None) + return int(value) if value is not None else None + + +async def _input_photo(ctx: OpContext, photo_id: int) -> Any: + """An `InputPhoto` for one of my own photos, with its live file reference. + + The access hash and file reference are only valid for the session that + fetched them, so the photo is looked up again rather than reconstructed + from an id a caller kept from yesterday. + """ + from telethon.tl import types + from telethon.tl.functions import photos as fn + + result = await client(ctx)( + fn.GetUserPhotosRequest(user_id=types.InputUserSelf(), offset=0, max_id=0, limit=100) + ) + for photo in getattr(result, "photos", None) or []: + if int(getattr(photo, "id", 0) or 0) == photo_id: + return types.InputPhoto( + id=photo.id, + access_hash=photo.access_hash, + file_reference=getattr(photo, "file_reference", b"") or b"", + ) + raise NotFoundError(f"photo {photo_id} is not in my photo history") + + +SPEC_PHOTO_SET = OperationSpec( + id="profile.photo.set", + request=PhotoSetReq, + response=ProfilePhotoSet, + impl=photo_set, + summary="Set my profile photo from a file, a video, an older photo or a custom emoji", + description=( + "v1's implementation called `client.upload_profile_photo()`, which " + "Telethon 1.44 does not have; this uploads the file and sends raw " + "`photos.uploadProfilePhoto`." + ), + mutating=True, + rate_class="file", + timeout_s=300, + columns=("photo_id", "is_video", "fallback"), + headers=("Photo", "Video", "Fallback"), + example={"photo_id": 55123, "is_video": False, "fallback": False}, + example_args="profile photo set avatar.jpg", + covers=( + "profile.contact-personal-photo", + "profile.photo-emoji-markup", + "profile.photo-fallback", + "profile.photo-fallback-public", + "profile.photo-set", + "profile.photo-set-as-main", + "profile.photo-set-emoji-sticker", + "profile.photo-set-existing", + "profile.photo-set-video", + "profile.photo-upload", + "profile.photo-upload-video", + ), + tags=frozenset({"visible-to-others"}), +) + + +class PhotoDeleteReq(Request): + photo_id: Annotated[ + tuple[str, ...], + arg(0, metavar="PHOTO_ID", required=False, variadic=True, help="Photos to delete."), + ] = () + current: Annotated[ + bool, opt("--current", help="Delete the current photo; the previous one is promoted.") + ] = False + every: Annotated[bool, opt("--every", help="Delete every profile photo.")] = False + + +async def photo_delete(ctx: OpContext, req: PhotoDeleteReq) -> PhotosDeleted: + """Delete profile photos. + + Deleting the current one promotes the previous one, which is Telegram's + behaviour and not tlgr's: there is no "no avatar, but keep the history" + state short of `--every`. + """ + from telethon.tl import types + from telethon.tl.functions import photos as fn + + handle = client(ctx) + wanted: list[int] = [] + if req.every or req.current: + result = await handle( + fn.GetUserPhotosRequest(user_id=types.InputUserSelf(), offset=0, max_id=0, limit=100) + ) + photos = list(getattr(result, "photos", None) or []) + if req.current: + me = await handle.get_me() + current = getattr(getattr(me, "photo", None), "photo_id", None) + photos = [p for p in photos if current and int(p.id) == int(current)] + wanted = [int(photo.id) for photo in photos] + for value in req.photo_id: + if not str(value).lstrip("-").isdigit(): + raise UsageError(f"{value!r} is not a photo id", field="photo_id") + wanted.append(int(value)) + if not wanted: + if req.every or req.current: + return PhotosDeleted(deleted=0, already=True) + raise UsageError("give one or more photo ids, or --current / --every", field="photo_id") + + inputs = [await _input_photo(ctx, photo_id) for photo_id in wanted] + deleted = await handle(fn.DeletePhotosRequest(id=inputs)) + ctx.emit("profile_photo_deleted", {"photo_ids": wanted}) + return PhotosDeleted(deleted=len(list(deleted or wanted)), photo_ids=wanted) + + +SPEC_PHOTO_DELETE = OperationSpec( + id="profile.photo.delete", + request=PhotoDeleteReq, + response=PhotosDeleted, + impl=photo_delete, + summary="Delete profile photos", + mutating=True, + destructive=True, + rate_class="send", + columns=("deleted", "photo_ids"), + headers=("Deleted", "Photos"), + example={"deleted": 1, "photo_ids": [55123]}, + example_args="profile photo delete 55123", + covers=("profile.photo-delete",), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# profile presence set +# --------------------------------------------------------------------------- + + +class PresenceSetReq(Request): + state: Annotated[str, arg(0, metavar="STATE", help="online or offline.")] + + +async def presence_set(ctx: OpContext, req: PresenceSetReq) -> PresenceSet: + """Go online or offline. + + A daemon needs a *policy* here, not a default. Always reporting online + advertises that something is running around the clock; reading history + while reporting offline is the classic bot tell. tlgr therefore never + reports presence on its own — this command, and the `presence` config + key, are the only two things that do. + """ + from telethon.tl.functions import account as fn + + wanted = req.state.strip().lower() + if wanted not in ("online", "offline"): + raise UsageError("STATE is `online` or `offline`", field="state") + online = wanted == "online" + await client(ctx)(fn.UpdateStatusRequest(offline=not online)) + ctx.emit("profile_presence", {"online": online}) + return PresenceSet(online=online) + + +SPEC_PRESENCE_SET = OperationSpec( + id="profile.presence.set", + request=PresenceSetReq, + response=PresenceSet, + impl=presence_set, + summary="Go online or offline (account.updateStatus)", + aliases=("profile.online", "profile.offline"), + mutating=True, + idempotent=True, + rate_class="send", + columns=("online",), + headers=("Online",), + example={"online": True}, + example_args="profile presence set online", + covers=("profile.online-status",), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# profile status list / set +# --------------------------------------------------------------------------- + + +class StatusListReq(Request): + recent: Annotated[bool, opt("--recent", help="Recently used statuses.")] = False + default: Annotated[bool, opt("--default", help="Telegram's default set.")] = False + collectible: Annotated[ + bool, opt("--collectible", help="Collectible gift statuses you may wear.") + ] = False + groups: Annotated[bool, opt("--groups", help="The category chips.")] = False + clear_recent: Annotated[bool, opt("--clear-recent", help="Clear the recent list.")] = False + + +async def status_list(ctx: OpContext, req: StatusListReq) -> Page[EmojiStatusItem]: + """Browse the emoji statuses this account may wear. + + Four server lists behind one command, because the GUI shows them as four + tabs of one picker. The op is a read, so `--dry-run` does not + short-circuit it centrally — which is why `--clear-recent`, the single + write in here, checks `ctx.dry_run` itself rather than being exempt. + """ + from telethon.tl.functions import account as fn + from telethon.tl.functions import messages as mfn + + handle = client(ctx) + rows: list[EmojiStatusItem] = [] + + if req.clear_recent: + if getattr(ctx, "dry_run", False): + ctx.warn("--dry-run: the recent emoji-status list was left alone") + return Page(items=[], has_more=False, total=0) + await handle(fn.ClearRecentEmojiStatusesRequest()) + return Page(items=[], has_more=False, total=0) + + if req.groups: + result = await handle(mfn.GetEmojiStatusGroupsRequest(hash=0)) + for group in getattr(result, "groups", None) or []: + title = str(getattr(group, "title", "") or "") + for document_id in getattr(group, "document_id", None) or []: + rows.append(EmojiStatusItem(document_id=int(document_id), group=title)) + return Page(items=rows, has_more=False, total=len(rows)) + + wanted = [ + ("recent", req.recent, fn.GetRecentEmojiStatusesRequest), + ("default", req.default, fn.GetDefaultEmojiStatusesRequest), + ("collectible", req.collectible, fn.GetCollectibleEmojiStatusesRequest), + ] + if not any(flag for _, flag, _ in wanted): + wanted = [(name, True, request) for name, _, request in wanted] + + for name, flag, request in wanted: + if not flag: + continue + result = await handle(request(hash=0)) + for status in getattr(result, "statuses", None) or []: + rows.append( + EmojiStatusItem( + document_id=int(getattr(status, "document_id", 0) or 0), + collectible_id=getattr(status, "collectible_id", None), + title=getattr(status, "title", None), + slug=getattr(status, "slug", None), + group=name, + until=fmt_dt(getattr(status, "until", None)), + ) + ) + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_STATUS_LIST = OperationSpec( + id="profile.status.list", + request=StatusListReq, + response=Page[EmojiStatusItem], + impl=status_list, + summary="Browse emoji-status suggestions (recent, default, themed groups, collectibles)", + description=( + "A read, with one exception: `--clear-recent` empties the recent " + "list. It honours `--dry-run` on its own rather than making the " + "whole listing a mutating operation." + ), + paginated=PageKind.LOCAL, + columns=("document_id", "collectible_id", "title", "group"), + headers=("Emoji", "Collectible", "Title", "List"), + example={"items": [{"document_id": 5301, "group": "recent"}], "has_more": False}, + example_args="profile status list --recent", + covers=( + "emoji.status-lists", + "profile.emoji-status-collectible", + "profile.emoji-status-suggestions", + ), +) + + +class StatusSetReq(Request): + emoji: Annotated[ + str | None, + arg(0, metavar="EMOJI", required=False, help="Document id, collectible:<id>, or 'none'."), + ] = None + until: Annotated[ + str | None, opt("--until", metavar="WHEN", kind="datetime", help="Expire the status.") + ] = None + clear: Annotated[bool, opt("--clear", help="Remove the status.")] = False + + +async def status_set(ctx: OpContext, req: StatusSetReq) -> EmojiStatusSet: + """Set or clear my emoji status, including a collectible gift. + + A collectible status and a collectible profile palette are mutually + exclusive on the server: setting one silently clears the other. tlgr says + so in the docs rather than pretending both can be worn. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + until = parse_dt(req.until) if req.until else None + if req.clear or (req.emoji or "").strip().lower() in ("none", "clear"): + await client(ctx)(fn.UpdateEmojiStatusRequest(emoji_status=types.EmojiStatusEmpty())) + return EmojiStatusSet(cleared=True) + if not req.emoji: + raise UsageError("give an emoji document id, collectible:<id>, or --clear", field="emoji") + + text = req.emoji.strip() + if text.lower().startswith("collectible:"): + value = text.split(":", 1)[1] + if not value.isdigit(): + raise UsageError("collectible:<id> wants a collectible id", field="emoji") + status: Any = types.InputEmojiStatusCollectible(collectible_id=int(value), until=until) + result = EmojiStatusSet(collectible_id=int(value)) + else: + if not text.isdigit(): + raise UsageError("give a custom-emoji document id, or collectible:<id>", field="emoji") + status = types.EmojiStatus(document_id=int(text), until=until) + result = EmojiStatusSet(document_id=int(text)) + await client(ctx)(fn.UpdateEmojiStatusRequest(emoji_status=status)) + result.until = fmt_dt(until) + result.until_unix = to_unix(until) + ctx.emit("profile_status", {"document_id": result.document_id}) + return result + + +SPEC_STATUS_SET = OperationSpec( + id="profile.status.set", + request=StatusSetReq, + response=EmojiStatusSet, + impl=status_set, + summary="Set or clear my emoji status (including a collectible gift)", + description=( + "Premium only. A collectible status and a collectible message palette " + "cannot both be worn: the server clears one when you set the other." + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("document_id", "collectible_id", "until", "cleared"), + headers=("Emoji", "Collectible", "Until", "Cleared"), + example={"document_id": 5301, "until": "2026-09-10T00:00:00Z"}, + example_args="profile status set 5301 --until +7d", + covers=("emoji.status-set", "profile.emoji-status"), + covers_partial=("profile.emoji-status-collectible",), + coverage_note="Browsing the wearable collectibles is `profile status list --collectible`.", + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# profile color list / set +# --------------------------------------------------------------------------- + + +class ColorListReq(Request): + profile: Annotated[ + bool, opt("--profile", help="Profile-page palettes instead of name/message palettes.") + ] = False + emojis: Annotated[bool, opt("--emojis", help="Also list the default background emojis.")] = ( + False + ) + + +async def color_list(ctx: OpContext, req: ColorListReq) -> Page[ColorPalette]: + """The accent palettes this account may wear. + + Ids 0-6 come back with no colours at all, because every client draws them + from a built-in table. Reporting them as empty would read as "no colours + available"; `builtin` plus the name is the honest answer. + """ + from telethon.tl.functions import help as fn + + handle = client(ctx) + request = fn.GetPeerProfileColorsRequest if req.profile else fn.GetPeerColorsRequest + result = await handle(request(hash=0)) + rows: list[ColorPalette] = [] + for option in getattr(result, "colors", None) or []: + color_id = int(getattr(option, "color_id", 0) or 0) + colors = getattr(option, "colors", None) + dark = getattr(option, "dark_colors", None) + rows.append( + ColorPalette( + color_id=color_id, + colors=[_settings.color_text(v) for v in getattr(colors, "colors", None) or []], + dark_colors=[_settings.color_text(v) for v in getattr(dark, "colors", None) or []], + min_level=int(getattr(option, "channel_min_level", 0) or 0), + channel_min_level=getattr(option, "channel_min_level", None), + group_min_level=getattr(option, "group_min_level", None), + hidden=bool(getattr(option, "hidden", False)), + builtin=color_id < len(_BUILTIN_PALETTES), + ) + ) + if req.emojis: + from telethon.tl.functions import account as afn + + emojis = await handle(afn.GetDefaultBackgroundEmojisRequest(hash=0)) + for document in getattr(emojis, "documents", None) or []: + rows.append(ColorPalette(color_id=-1, colors=[str(getattr(document, "id", 0) or 0)])) + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_COLOR_LIST = OperationSpec( + id="profile.color.list", + request=ColorListReq, + response=Page[ColorPalette], + impl=color_list, + summary="List the name and profile colour palettes", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("color_id", "colors", "dark_colors", "min_level", "hidden"), + headers=("Id", "Light", "Dark", "Min level", "Hidden"), + example={"items": [{"color_id": 5, "colors": ["#3FA3E8"], "min_level": 0}], "has_more": False}, + example_args="profile color list", + covers=("profile.name-color", "profile.profile-color"), + tags=frozenset({"agent-safe"}), +) + + +class ColorSetReq(Request): + color: Annotated[ + str, arg(0, metavar="COLOR", help="Palette id, collectible:<slug|id>, or 'none'.") + ] + profile: Annotated[ + bool, opt("--profile", help="Change the profile-page colour, not the message colour.") + ] = False + emoji: Annotated[ + str | None, opt("--emoji", metavar="ID", help="Background custom-emoji document id.") + ] = None + + +async def color_set(ctx: OpContext, req: ColorSetReq) -> ColorSet: + """Set my message accent colour, profile colour or collectible palette. + + A collectible palette is a gift you own, and the server only accepts it + for the *message* colour — `--profile` with one is refused here rather + than sent and rejected with an error that names neither flag. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + text = req.color.strip() + emoji = int(req.emoji) if req.emoji and req.emoji.isdigit() else None + if text.lower() in ("none", "clear", "default"): + await client(ctx)(fn.UpdateColorRequest(for_profile=req.profile or None, color=None)) + return ColorSet(for_profile=req.profile) + + if text.lower().startswith("collectible:"): + if req.profile: + raise UsageError( + "a collectible palette is only accepted for the message colour, not for --profile", + field="color", + ) + value = text.split(":", 1)[1] + gift_id = int(value) if value.isdigit() else await _collectible_id(ctx, value) + await client(ctx)( + fn.UpdateColorRequest(color=types.InputPeerColorCollectible(collectible_id=gift_id)) + ) + return ColorSet(collectible_id=gift_id) + + if not text.lstrip("-").isdigit(): + raise UsageError("COLOR is a palette id, collectible:<slug|id>, or 'none'", field="color") + await client(ctx)( + fn.UpdateColorRequest( + for_profile=req.profile or None, + color=types.PeerColor(color=int(text), background_emoji_id=emoji), + ) + ) + ctx.emit("profile_color", {"color": int(text), "for_profile": req.profile}) + return ColorSet(color=int(text), background_emoji_id=emoji, for_profile=req.profile) + + +async def _collectible_id(ctx: OpContext, slug: str) -> int: + """The collectible id behind a gift slug, so `collectible:<slug>` works.""" + from telethon.tl.functions import payments as fn + + result = await client(ctx)(fn.GetUniqueStarGiftRequest(slug=_settings.slug_of(slug))) + gift = getattr(result, "gift", None) + value = getattr(gift, "id", None) + if value is None: + raise NotFoundError(f"no collectible named {slug!r}") + return int(value) + + +SPEC_COLOR_SET = OperationSpec( + id="profile.color.set", + request=ColorSetReq, + response=ColorSet, + impl=color_set, + summary="Set my message accent colour, profile colour, or a collectible palette", + mutating=True, + idempotent=True, + rate_class="send", + columns=("color", "collectible_id", "background_emoji_id", "for_profile"), + headers=("Palette", "Collectible", "Emoji", "Profile"), + example={"color": 5, "for_profile": False}, + example_args="profile color set 5", + covers=("gift.as-peer-color", "profile.collectible-message-palette"), + covers_partial=("profile.name-color", "profile.profile-color"), + coverage_note="Listing the palettes is `profile color list`.", + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# profile channel list / music list / wallpaper set / link +# --------------------------------------------------------------------------- + + +class ChannelListReq(Request): + pass + + +async def channel_list(ctx: OpContext, req: ChannelListReq) -> Page[AdminedChannel]: + """Public channels I administer that may be shown on my profile.""" + from telethon.tl import types + from telethon.tl.functions import channels as fn + from telethon.tl.functions import users as ufn + + handle = client(ctx) + result = await handle(fn.GetAdminedPublicChannelsRequest(for_personal=True)) + answer = await handle(ufn.GetFullUserRequest(id=types.InputUserSelf())) + current = getattr(getattr(answer, "full_user", None), "personal_channel_id", None) + rows = [ + AdminedChannel( + id=int(getattr(chat, "id", 0) or 0), + title=str(getattr(chat, "title", "") or ""), + username=getattr(chat, "username", None), + participants_count=getattr(chat, "participants_count", None), + current=current is not None and int(getattr(chat, "id", 0) or 0) == int(current), + ) + for chat in getattr(result, "chats", None) or [] + ] + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_CHANNEL_LIST = OperationSpec( + id="profile.channel.list", + request=ChannelListReq, + response=Page[AdminedChannel], + impl=channel_list, + summary="List public channels I administer that can be shown on my profile", + description="Pick one with `profile update --channel <chat>`; `none` unlinks it.", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("id", "title", "username", "participants_count", "current"), + headers=("ID", "Title", "Username", "Members", "Shown"), + example={"items": [{"id": 777, "title": "Notes", "username": "ada_notes"}], "has_more": False}, + example_args="profile channel list", + covers=("profile.personal-channel",), + tags=frozenset({"agent-safe"}), +) + + +class MusicListReq(Request): + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="Whose profile music (default: me)."), + ] = None + + +async def music_list(ctx: OpContext, req: MusicListReq) -> Page[MusicTrack]: + """The music pinned to a profile — mine unless `--user` names another. + + Somebody else's list obeys `inputPrivacyKeySavedMusic`, so an empty + answer is not evidence that they pinned nothing. + """ + from tlgr.models.peer import parse_peer_ref + from tlgr.ops.user import MusicListReq as UserMusicListReq + from tlgr.ops.user import music_list as user_music_list + + target = req.user or parse_peer_ref("me") + return await user_music_list(ctx, UserMusicListReq(user=target)) + + +SPEC_MUSIC_LIST = OperationSpec( + id="profile.music.list", + request=MusicListReq, + response=Page[MusicTrack], + impl=music_list, + summary="List the music shown on a profile", + paginated=PageKind.PARTICIPANTS, + idempotent=True, + rate_class="file", + timeout_s=300, + columns=("id", "title", "performer", "duration"), + headers=("Document", "Title", "Performer", "Seconds"), + example={"items": [{"id": 991, "title": "Nocturne", "performer": "Chopin"}], "has_more": False}, + example_args="profile music list", + covers=("profile.saved-music", "profile.saved-music-list", "stories.story-music-save"), + tags=frozenset({"agent-safe"}), +) + + +class WallpaperSetReq(Request): + source: Annotated[ + str | None, + arg(0, metavar="SOURCE", required=False, help="Image file, or a wallpaper slug."), + ] = None + blur: Annotated[bool, opt("--blur", help="wallPaperSettings.blur.")] = False + motion: Annotated[bool, opt("--motion", help="wallPaperSettings.motion.")] = False + intensity: Annotated[int | None, opt("--intensity", metavar="N", help="Pattern intensity.")] = ( + None + ) + colors: Annotated[ + str | None, opt("--colors", metavar="LIST", help="Up to four gradient colours.") + ] = None + for_chat: Annotated[ + bool, opt("--for-chat", help="Upload it for use as a per-chat wallpaper.") + ] = False + save: Annotated[ + bool, opt("--save", help="Only add it to the saved list, do not install it.") + ] = False + reset: Annotated[bool, opt("--reset", help="Wipe the saved wallpaper list.")] = False + + +async def wallpaper_set(ctx: OpContext, req: WallpaperSetReq) -> WallpaperInstalled: + """Upload or install my chat wallpaper, or reset the saved list. + + The catalogue itself is `media wallpaper list`; setting one chat's + wallpaper is `chat wallpaper set`, which is a different server call with + a "for both sides" flag. This is the account-wide write path, and it is + here rather than in `media` because the GUI reaches it from Settings ▸ + Chat Settings and not from a file picker. + """ + from tlgr.ops.media import WallpaperSetReq as MediaSetReq + from tlgr.ops.media import WallpaperUploadReq, wallpaper_upload + from tlgr.ops.media import wallpaper_set as media_wallpaper_set + + colours = [value.strip() for value in (req.colors or "").split(",") if value.strip()] + if req.reset: + return await media_wallpaper_set(ctx, MediaSetReq(reset=True)) + + if not req.source: + raise UsageError("give an image file or a wallpaper slug, or --reset", field="source") + + path = Path(os.path.expanduser(req.source)) + slug = req.source + if path.exists(): + uploaded = await wallpaper_upload( + ctx, + WallpaperUploadReq( + path=str(path), + colors=colours, + blur=req.blur, + motion=req.motion, + intensity=req.intensity if req.intensity is not None else 50, + pattern=req.intensity is not None, + for_chat=bool(req.for_chat), + ), + ) + slug = uploaded.slug or "" + if req.save: + return WallpaperInstalled(slug=slug, saved=True, settings=uploaded.settings) + + return await media_wallpaper_set( + ctx, + MediaSetReq( + wallpaper=slug, + blur=req.blur, + motion=req.motion, + intensity=req.intensity, + colors=colours, + save_only=req.save, + ), + ) + + +SPEC_WALLPAPER_SET = OperationSpec( + id="profile.wallpaper.set", + request=WallpaperSetReq, + response=WallpaperInstalled, + impl=wallpaper_set, + summary="Upload/install my chat wallpaper, or reset the saved wallpaper list", + mutating=True, + rate_class="file", + timeout_s=300, + columns=("slug", "installed", "saved", "reset"), + headers=("Slug", "Installed", "Saved", "Reset"), + example={"slug": "Ycb0FfC6", "installed": True, "saved": True}, + example_args="profile wallpaper set Ycb0FfC6", + covers=("wallpaper.save-install-reset", "wallpaper.upload"), +) + + +class LinkReq(Request): + target: Annotated[ + str | None, + arg(0, metavar="TARGET", required=False, help="@username or +888…; default me."), + ] = None + qr: Annotated[bool, opt("--qr", help="Render a unicode-block QR of the link.")] = False + out: Annotated[ + str | None, opt("--out", metavar="PATH", kind="path", help="Write a PNG QR instead.") + ] = None + collectible: Annotated[ + bool, opt("--collectible", help="Fetch Fragment purchase date and price.") + ] = False + + +async def link(ctx: OpContext, req: LinkReq) -> ProfileLink: + """My public link and QR code, and Fragment's record of a collectible. + + An account with no username has only the `tg://user?id=` form, and that + only opens for peers who already know it — which is why the answer says + `resolvable_by_strangers: false` instead of handing back a link that + quietly does nothing. + """ + from telethon.tl import types + from telethon.tl.functions import fragment as fn + + handle = client(ctx) + target = (req.target or "").strip() + result = ProfileLink() + + if not target or target.lower() in ("me", "self"): + me = await handle.get_me() + result.user_id = int(getattr(me, "id", 0) or 0) + result.username = getattr(me, "username", None) + elif target.startswith("+"): + result.username = target + else: + result.username = target.lstrip("@") + + if result.username: + result.link = f"https://t.me/{str(result.username).lstrip('+')}" + else: + result.link = f"tg://user?id={result.user_id}" + result.resolvable_by_strangers = False + + if req.collectible and result.username: + name = str(result.username) + collectible = ( + types.InputCollectiblePhone(phone=name) + if name.startswith("+") + else types.InputCollectibleUsername(username=name) + ) + try: + info = await handle(fn.GetCollectibleInfoRequest(collectible=collectible)) + except Exception as exc: + ctx.warn(f"no Fragment record for {name}: {exc}") + else: + result.collectible = { + "purchase_date": fmt_dt(getattr(info, "purchase_date", None)), + "currency": getattr(info, "currency", None), + "amount": getattr(info, "amount", None), + "crypto_currency": getattr(info, "crypto_currency", None), + "crypto_amount": getattr(info, "crypto_amount", None), + "url": getattr(info, "url", None), + } + + if req.qr or req.out: + result.qr, result.qr_path = _qr(ctx, result.link, req.out) + return result + + +def _qr(ctx: OpContext, text: str, out: str | None) -> tuple[str | None, str | None]: + """The link as a QR, reusing the encoder `chat invite link` already uses. + + A QR carries nothing the link does not, so it is pure local rendering — + and a second implementation of it would be a second thing to get wrong. + """ + from tlgr.ops.chat_invite import _render_qr + + return _render_qr(ctx, text, out) + + +SPEC_LINK = OperationSpec( + id="profile.link", + request=LinkReq, + response=ProfileLink, + impl=link, + summary="My public link and QR code, and Fragment details for a username or phone", + description=( + "The GUI's styled QR *image* is a rendering choice a terminal has no " + "use for; the link, a block QR and an optional PNG are the parts that " + "carry information." + ), + idempotent=True, + columns=("link", "username", "resolvable_by_strangers"), + headers=("Link", "Username", "Public"), + example={"link": "https://t.me/ada", "username": "ada"}, + example_args="profile link --qr", + covers=("profile.collectible-info", "profile.qr-code"), + tags=frozenset({"agent-safe"}), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] diff --git a/tlgr/registry.py b/tlgr/registry.py index ce02e88..12db8c4 100644 --- a/tlgr/registry.py +++ b/tlgr/registry.py @@ -209,6 +209,10 @@ # §12.4 promises stays invocable. "switch", "completion", + # PR-12. `profile update` is a path v1 documented, and §12.4 makes a + # documented path permanent; `profile set` is its STYLE-shaped alias, + # so both spellings reach the one operation. + "update", ] ) From 6417ac58f2c296d289e78cbfd61e68fe01370896 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 07:11:37 +0330 Subject: [PATCH 03/15] privacy and notify ops: two replace-the-world APIs made safe to script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `account.setPrivacy` replaces the whole ordered rule vector and `account.setGlobalPrivacySettings` replaces the whole constructor, so both commands read first and write back complete. That is what `--add-allow`, `--add-disallow` and `--remove` are for: a script that wants to add one exception should never have to re-state a list it did not mean to touch, and a global flag nobody passed is written back exactly as it was found. `notify get`/`notify set` take a target — a scope, a chat, a topic, `reactions` or `contact-joined` — and pick between three unrelated server APIs, because the official clients show them as one Notifications screen. `mute_until` is computed from the wall clock: v1 used the asyncio event loop's clock, whose origin is arbitrary, so "mute for an hour" produced a timestamp in 1970 and muted nothing. `contact-joined` is reported the way a human reads it, even though the wire stores the opposite (`silent=true` means off). `privacy set stories` is refused with a sentence naming the two commands that do own story visibility, rather than accepted and silently ignored: there is no `inputPrivacyKeyStories`, and pretending otherwise is worse than saying so. --- tlgr/ops/notify.py | 840 ++++++++++++++++++++++++++++++++++++++++++++ tlgr/ops/privacy.py | 794 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1634 insertions(+) create mode 100644 tlgr/ops/notify.py create mode 100644 tlgr/ops/privacy.py diff --git a/tlgr/ops/notify.py b/tlgr/ops/notify.py new file mode 100644 index 0000000..088c024 --- /dev/null +++ b/tlgr/ops/notify.py @@ -0,0 +1,840 @@ +"""The `notify` group: Settings ▸ Notifications and Sounds. + +One screen in every official client, three unrelated server APIs behind it — +`account.getNotifySettings` for the scopes and the chats, +`account.getReactionsNotifySettings` for the reaction alerts, +`account.getContactSignUpNotification` for "X joined Telegram". `notify get` +and `notify set` take a *target* and pick the right one, because which RPC +answers a question is the server's business and not the caller's. + +Two hazards live here. + +* **`mute_until` is an absolute UNIX timestamp.** v1 computed it from the + asyncio event loop's clock — an arbitrary monotonic origin — so "mute for + an hour" produced a timestamp in 1970 and the chat was never muted. The + arithmetic is `int(time.time()) + seconds`, once, in `_mute_until`. +* **`inputPeerNotifySettings` fields are optional.** A field you do not send + is left untouched, which is why every switch here is `on|off|default` and + `default` means *remove the exception* rather than "set it to off". + +The two whole-constructor APIs — reactions and contact-joined — are +read-modify-written, like every other replace-the-world call in this PR. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Annotated, Any + +from tlgr.core.errors import UsageError +from tlgr.core.pagination import PageKind +from tlgr.core.timefmt import parse_duration +from tlgr.models.base import Request +from tlgr.models.notify import ( + ExceptionsCleared, + NotifyException, + NotifyReset, + NotifyTarget, + Ringtone, + RingtoneSaved, +) +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _settings +from tlgr.ops._common import client +from tlgr.ops._params import arg, opt +from tlgr.ops._serialize import notify_settings +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: `mute_until` for "forever". Telegram's own sentinel, and the reason the +#: field is an int rather than a duration. +FOREVER = 2**31 - 1 + +#: The scope words `notify get`/`notify set` accept, and the `inputNotify*` +#: class each one names. +SCOPES: dict[str, str] = { + "private": "InputNotifyUsers", + "users": "InputNotifyUsers", + "groups": "InputNotifyChats", + "chats": "InputNotifyChats", + "channels": "InputNotifyBroadcasts", + "broadcasts": "InputNotifyBroadcasts", + "stories": "InputNotifyUsers", +} + +#: The three targets that are not a notify scope at all. +SPECIAL = ("reactions", "contact-joined") + +#: `reactionNotificationsFrom*` ⇄ the word `--messages`/`--stories` take. +FROM_WORDS = { + "ReactionNotificationsFromContacts": "contacts", + "ReactionNotificationsFromAll": "all", +} + + +def _mute_until(value: str | None) -> int | None: + """`5m`/`2h`/`forever` as the absolute UNIX second the server wants. + + `int(time.time())`, not the event loop's clock: `loop.time()` counts from + an arbitrary origin, and v1 used it here. + """ + if value is None: + return None + text = value.strip().lower() + if text in ("forever", "always"): + return FOREVER + seconds = parse_duration(text) + if seconds is None: + raise UsageError("--mute takes a duration (30s, 5m, 2h, 7d) or 'forever'", field="mute") + return int(time.time()) + int(seconds) + + +def _scope_tl(name: str) -> Any: + from telethon.tl import types + + return getattr(types, SCOPES[name])() + + +def _from_tl(word: str | None, *, field: str) -> Any: + """`contacts|all|off` as a `ReactionNotificationsFrom*`, or None for off.""" + from telethon.tl import types + + if word is None: + return ... + text = word.strip().lower() + if text in ("off", "none", "nobody"): + return None + if text == "contacts": + return types.ReactionNotificationsFromContacts() + if text == "all": + return types.ReactionNotificationsFromAll() + raise UsageError(f"--{field} takes contacts, all or off", field=field) + + +async def _target(ctx: OpContext, target: str, topic: int | None) -> tuple[str, Any, int | None]: + """`(kind, inputNotifyPeer or None, chat id)` for a target word or a chat ref.""" + from telethon.tl import types + + name = target.strip().lower() + if name in SPECIAL: + return name, None, None + if name in SCOPES: + return "scope", _scope_tl(name), None + peer = await _settings.resolve(ctx, target) + chat_id = _settings.peer_of(peer) + notify = ( + types.InputNotifyForumTopic(peer=peer, top_msg_id=topic) + if topic is not None + else types.InputNotifyPeer(peer=peer) + ) + return "peer", notify, chat_id + + +def _fill(model: NotifyTarget, raw: Any) -> NotifyTarget: + """Copy a `peerNotifySettings` onto the flat answer, tri-state intact.""" + settings = notify_settings(raw) + model.settings = settings + if settings is None: + return model + model.muted = settings.muted + model.mute_until = settings.mute_until + model.mute_until_unix = settings.mute_until_unix + model.show_previews = settings.show_previews + model.sound = settings.sound + model.stories_muted = settings.stories_muted + model.stories_hide_sender = settings.stories_hide_sender + model.stories_sound = settings.stories_sound + return model + + +# --------------------------------------------------------------------------- +# notify get / set +# --------------------------------------------------------------------------- + + +class GetReq(Request): + target: Annotated[ + str, + arg( + 0, + metavar="TARGET", + help="private | groups | channels | stories | reactions | contact-joined | <chat>", + ), + ] + topic: Annotated[int | None, opt("--topic", metavar="ID", help="A forum topic id.")] = None + + +async def get(ctx: OpContext, req: GetReq) -> NotifyTarget: + """Read notification settings for a scope, a chat, a topic, reactions + or the contact-joined toggle. + + The `sound` is normalised to `default | none | local:<title> | + ringtone:<id>` — the same vocabulary `notify set --sound` accepts — even + though `peerNotifySettings` carries three per-platform sound fields and + the input constructor takes exactly one. + """ + from telethon.tl.functions import account as fn + + handle = client(ctx) + kind, notify, chat_id = await _target(ctx, req.target, req.topic) + model = NotifyTarget(target=req.target, kind=kind, chat_id=chat_id, topic=req.topic) + + if kind == "reactions": + raw = await handle(fn.GetReactionsNotifySettingsRequest()) + model.messages_from = FROM_WORDS.get( + type(getattr(raw, "messages_notify_from", None)).__name__, "off" + ) + model.stories_from = FROM_WORDS.get( + type(getattr(raw, "stories_notify_from", None)).__name__, "off" + ) + model.poll_votes_from = FROM_WORDS.get( + type(getattr(raw, "poll_votes_notify_from", None)).__name__, "off" + ) + model.show_previews = getattr(raw, "show_previews", None) + model.sound = _settings.sound_text(getattr(raw, "sound", None)) + return model + + if kind == "contact-joined": + raw = await handle(fn.GetContactSignUpNotificationRequest()) + # Stored inverted on the wire: `silent=true` means the notification + # is OFF, which is exactly the sort of double negative a CLI should + # absorb rather than pass on. + model.contact_joined = not bool(raw) + return model + + return _fill(model, await handle(fn.GetNotifySettingsRequest(peer=notify))) + + +SPEC_GET = OperationSpec( + id="notify.get", + request=GetReq, + response=NotifyTarget, + impl=get, + summary="Read notification settings for a scope, chat, topic, reactions or contact-joined", + description=( + "One command over three server APIs, because the GUI presents them as " + "one Notifications screen. `contact-joined` is reported the way a " + "human reads it: `true` means the notification is on, even though the " + "wire stores the opposite." + ), + idempotent=True, + columns=("target", "muted", "mute_until", "show_previews", "sound"), + headers=("Target", "Muted", "Until", "Previews", "Sound"), + example={ + "target": "private", + "kind": "scope", + "muted": False, + "show_previews": True, + "sound": "default", + }, + example_args="notify get private", + covers=( + "dialogs.reactions-notify", + "notify.contact-joined", + "notify.peer", + "notify.scope-channels", + "notify.scope-groups", + "notify.scope-private", + "notify.stories", + ), + covers_partial=("notify.forum-topic", "notify.reactions", "notify.sound-selection"), + coverage_note="Writing any of them is `notify set`; the sound list is `notify ringtone list`.", + tags=frozenset({"agent-safe"}), +) + + +class SetReq(Request): + target: Annotated[ + str, + arg( + 0, + metavar="TARGET", + help="private | groups | channels | stories | reactions | contact-joined | <chat>", + ), + ] + mute: Annotated[ + str | None, opt("--mute", metavar="FOR", help="Mute for this long, or 'forever'.") + ] = None + unmute: Annotated[bool, opt("--unmute", help="Unmute (mute_until = 0).")] = False + preview: Annotated[ + str | None, opt("--preview", metavar="ON|OFF", help="Message text in notifications.") + ] = None + sound: Annotated[ + str | None, + opt("--sound", metavar="SOUND", help="default | none | local:<title> | ringtone:<id>."), + ] = None + topic: Annotated[int | None, opt("--topic", metavar="ID", help="A forum topic id.")] = None + stories_mute: Annotated[ + str | None, opt("--stories-mute", metavar="ON|OFF", help="Mute this peer's stories.") + ] = None + stories_hide_sender: Annotated[ + str | None, + opt("--stories-hide-sender", metavar="ON|OFF", help="Hide the author on story alerts."), + ] = None + stories_sound: Annotated[ + str | None, opt("--stories-sound", metavar="SOUND", help="Sound for story alerts.") + ] = None + messages: Annotated[ + str | None, + opt("--messages", metavar="WHO", help="reactions: contacts|all|off for message reactions."), + ] = None + stories: Annotated[ + str | None, opt("--stories", metavar="WHO", help="reactions: story-reaction alerts.") + ] = None + poll_votes: Annotated[ + str | None, opt("--poll-votes", metavar="WHO", help="reactions: poll-vote alerts.") + ] = None + on: Annotated[bool, opt("--on", help="contact-joined: enable the notification.")] = False + off: Annotated[bool, opt("--off", help="contact-joined: disable the notification.")] = False + + +async def set_(ctx: OpContext, req: SetReq) -> NotifyTarget: + """Change notification settings for a scope, chat, topic, reactions or + the contact-joined toggle. + + For a scope or a chat only the named fields are sent, because + `inputPeerNotifySettings` leaves an omitted field alone — that is what + makes "mute this chat" not also reset its sound. The reactions and + contact-joined APIs replace their whole constructor, so those two are + read first and written back complete. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + kind, notify, chat_id = await _target(ctx, req.target, req.topic) + changed: list[str] = [] + + if kind == "contact-joined": + if req.on == req.off: + raise UsageError("contact-joined takes --on or --off", field="on") + await handle(fn.SetContactSignUpNotificationRequest(silent=req.off)) + ctx.emit("notify_set", {"target": "contact-joined", "enabled": req.on}) + return NotifyTarget( + target=req.target, kind=kind, contact_joined=req.on, changed=["contact_joined"] + ) + + if kind == "reactions": + current = await handle(fn.GetReactionsNotifySettingsRequest()) + values: dict[str, Any] = { + "messages_notify_from": getattr(current, "messages_notify_from", None), + "stories_notify_from": getattr(current, "stories_notify_from", None), + "poll_votes_notify_from": getattr(current, "poll_votes_notify_from", None), + "sound": getattr(current, "sound", None) or types.NotificationSoundDefault(), + "show_previews": bool(getattr(current, "show_previews", False)), + } + for flag, field in ( + ("messages", "messages_notify_from"), + ("stories", "stories_notify_from"), + ("poll_votes", "poll_votes_notify_from"), + ): + value = _from_tl(getattr(req, flag), field=flag) + if value is not ...: + values[field] = value + changed.append(field) + if req.sound is not None: + values["sound"] = _settings.sound_value(req.sound) + changed.append("sound") + preview = _settings.on_off(req.preview, field="preview") + if preview is not None: + values["show_previews"] = preview + changed.append("show_previews") + if not changed: + raise UsageError( + "nothing to change: pass --messages, --stories, --poll-votes, --sound or --preview", + field="messages", + ) + await handle( + fn.SetReactionsNotifySettingsRequest(settings=types.ReactionsNotifySettings(**values)) + ) + ctx.emit("notify_set", {"target": "reactions", "changed": changed}) + result = await get(ctx, GetReq(target=req.target)) + result.changed = changed + return result + + kwargs: dict[str, Any] = {} + if req.unmute: + kwargs["mute_until"] = 0 + changed.append("mute_until") + elif req.mute is not None: + kwargs["mute_until"] = _mute_until(req.mute) + changed.append("mute_until") + for flag, field in ( + ("preview", "show_previews"), + ("stories_mute", "stories_muted"), + ("stories_hide_sender", "stories_hide_sender"), + ): + value = _settings.on_off(getattr(req, flag), field=flag) + if value is not None: + kwargs[field] = value + changed.append(field) + if req.sound is not None: + kwargs["sound"] = _settings.sound_value(req.sound) + changed.append("sound") + if req.stories_sound is not None: + kwargs["stories_sound"] = _settings.sound_value(req.stories_sound) + changed.append("stories_sound") + if not changed: + raise UsageError( + "nothing to change: pass --mute, --unmute, --preview or --sound", field="mute" + ) + + await handle( + fn.UpdateNotifySettingsRequest( + peer=notify, settings=types.InputPeerNotifySettings(**kwargs) + ) + ) + ctx.emit("notify_set", {"target": req.target, "chat_id": chat_id, "changed": changed}) + result = await get(ctx, GetReq(target=req.target, topic=req.topic)) + result.changed = changed + return result + + +SPEC_SET = OperationSpec( + id="notify.set", + request=SetReq, + response=NotifyTarget, + impl=set_, + summary="Change notification settings for a scope, chat, topic, reactions or contact-joined", + description=( + "`mute_until` is an absolute UNIX timestamp; `--mute 2h` is turned " + "into one from the wall clock, which is the bug v1 had (it used the " + "event loop's clock and muted nothing). v1's `chat mute` is still its " + "own operation and keeps that path; this is the scope-and-target form." + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("target", "muted", "mute_until", "show_previews", "sound"), + headers=("Target", "Muted", "Until", "Previews", "Sound"), + example={"target": "private", "muted": True, "mute_until": "2026-09-04T12:00:00Z"}, + example_args="notify set private --mute 2h", + covers=( + "dialogs.notify-scope-defaults", + "gifts.channel-notifications", + "notify.forum-topic", + "notify.reactions", + "notify.sound-selection", + "stories.notify-global", + "stories.notify-peer", + "stories.notify-reactions", + ), + covers_partial=( + "notify.contact-joined", + "notify.peer", + "notify.scope-channels", + "notify.scope-groups", + "notify.scope-private", + "notify.stories", + ), + coverage_note="Reading any of them back is `notify get`.", +) + + +# --------------------------------------------------------------------------- +# notify exception list / clear +# --------------------------------------------------------------------------- + + +class ExceptionListReq(Request): + scope: Annotated[ + str | None, opt("--scope", metavar="SCOPE", help="private | groups | channels.") + ] = None + compare_sound: Annotated[ + bool, opt("--compare-sound", help="Count a differing sound as an exception.") + ] = False + compare_stories: Annotated[ + bool, opt("--compare-stories", help="Count differing story settings as an exception.") + ] = False + + +def _scope_of(chat_id: int) -> str: + """Which scope a chat inherits from, decided from its marked id.""" + if chat_id > 0: + return "private" + return "channels" if str(chat_id).startswith("-100") else "groups" + + +async def exception_list(ctx: OpContext, req: ExceptionListReq) -> Page[NotifyException]: + """Chats whose notification settings differ from their scope default. + + The server answers with an `Updates` container rather than a list: the + exceptions arrive as `updateNotifySettings` entries alongside the users + and chats vectors, so the rows are assembled from the updates and named + from the vectors. + """ + from telethon.tl.functions import account as fn + + result = await client(ctx)( + fn.GetNotifyExceptionsRequest( + compare_sound=req.compare_sound or None, + compare_stories=req.compare_stories or None, + ) + ) + known = _settings.entity_map(result) + rows: list[NotifyException] = [] + for update in getattr(result, "updates", None) or []: + peer = getattr(getattr(update, "peer", None), "peer", None) + if peer is None: + continue + from tlgr.ops._serialize import peer_id_of + + chat_id = peer_id_of(peer) + if chat_id is None: + continue + settings = notify_settings(getattr(update, "notify_settings", None)) + entity = known.get(abs(chat_id) if chat_id < 0 else chat_id) + scope = _scope_of(chat_id) + if req.scope and scope != req.scope.strip().lower(): + continue + rows.append( + NotifyException( + chat_id=chat_id, + chat=_settings.peer_model(entity), + title=str( + getattr(entity, "title", None) or getattr(entity, "first_name", "") or "" + ), + muted=bool(settings and settings.muted), + mute_until=settings.mute_until if settings else None, + mute_until_unix=settings.mute_until_unix if settings else None, + show_previews=settings.show_previews if settings else None, + sound=settings.sound if settings else None, + stories_muted=settings.stories_muted if settings else None, + scope=scope, + ) + ) + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_EXCEPTION_LIST = OperationSpec( + id="notify.exception.list", + request=ExceptionListReq, + response=Page[NotifyException], + impl=exception_list, + summary="List chats whose notification settings differ from their scope default", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("chat_id", "title", "muted", "mute_until", "sound", "scope"), + headers=("Chat", "Title", "Muted", "Until", "Sound", "Scope"), + example={ + "items": [{"chat_id": -1001, "title": "Noisy group", "muted": True, "scope": "groups"}], + "has_more": False, + }, + example_args="notify exception list", + covers=("dialogs.notify-exceptions", "stories.notify-exceptions"), + covers_partial=("notify.exceptions-list",), + coverage_note="Dropping an exception is `notify exception clear`.", + tags=frozenset({"agent-safe"}), +) + + +class ExceptionClearReq(Request): + chat: Annotated[ + tuple[PeerRef, ...], + arg(0, metavar="CHAT", required=False, variadic=True, kind="peer", help="Chats to reset."), + ] = () + every: Annotated[bool, opt("--every", help="Clear every exception in --scope.")] = False + scope: Annotated[str | None, opt("--scope", metavar="SCOPE", help="Scope for --every.")] = None + + +async def exception_clear(ctx: OpContext, req: ExceptionClearReq) -> ExceptionsCleared: + """Drop per-chat overrides so those chats follow their scope default. + + There is no "delete exception" method: an empty `inputPeerNotifySettings` + is what removes one, because every field of it is optional and an unset + field means "inherit". + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + targets: list[int] = [] + peers: list[Any] = [] + for ref in req.chat: + peer = await _settings.resolve(ctx, ref) + peers.append(peer) + targets.append(_settings.peer_of(peer)) + + if req.every: + rows = await exception_list(ctx, ExceptionListReq(scope=req.scope)) + for row in rows.items: + if row.chat_id in targets: + continue + peers.append(await _settings.resolve(ctx, str(row.chat_id))) + targets.append(row.chat_id) + elif not peers: + raise UsageError("give one or more chats, or --every --scope <scope>", field="chat") + + if not peers: + return ExceptionsCleared(cleared=0, scope=req.scope, already=True) + for peer in peers: + await handle( + fn.UpdateNotifySettingsRequest( + peer=types.InputNotifyPeer(peer=peer), + settings=types.InputPeerNotifySettings(), + ) + ) + ctx.emit("notify_exceptions_cleared", {"chat_ids": targets}) + return ExceptionsCleared(cleared=len(targets), chat_ids=targets, scope=req.scope) + + +SPEC_EXCEPTION_CLEAR = OperationSpec( + id="notify.exception.clear", + request=ExceptionClearReq, + response=ExceptionsCleared, + impl=exception_clear, + summary="Drop per-chat notification overrides so the chats follow their scope default", + mutating=True, + idempotent=True, + rate_class="bulk", + columns=("cleared", "chat_ids", "scope"), + headers=("Cleared", "Chats", "Scope"), + example={"cleared": 2, "chat_ids": [-1001, 777123]}, + example_args="notify exception clear @noisy", + covers=("notify.exceptions-list",), + covers_partial=("notify.peer",), + coverage_note="Setting one chat's exception is `notify set <chat>`.", +) + + +# --------------------------------------------------------------------------- +# notify reset +# --------------------------------------------------------------------------- + + +class ResetReq(Request): + pass + + +async def reset(ctx: OpContext, req: ResetReq) -> NotifyReset: + """Reset every notification setting — scopes and per-chat — to the defaults. + + Irreversible in the only sense that matters: the exceptions are gone and + the server does not say what they were. `notify exception list` before + running this is the backup. + """ + from telethon.tl.functions import account as fn + + await client(ctx)(fn.ResetNotifySettingsRequest()) + ctx.emit("notify_reset", {}) + return NotifyReset(ok=True) + + +SPEC_RESET = OperationSpec( + id="notify.reset", + request=ResetReq, + response=NotifyReset, + impl=reset, + summary="Reset every notification setting (scopes and per-chat) to Telegram's defaults", + aliases=("notify.reset-all",), + mutating=True, + destructive=True, + rate_class="send", + columns=("ok",), + headers=("OK",), + example={"ok": True}, + example_args="notify reset", + covers=("notify.reset-all",), +) + + +# --------------------------------------------------------------------------- +# notify ringtone list / set +# --------------------------------------------------------------------------- + + +class RingtoneListReq(Request): + pass + + +async def ringtone_list(ctx: OpContext, req: RingtoneListReq) -> Page[Ringtone]: + """Saved notification sounds, with the ids `notify set --sound` takes.""" + from telethon.tl.functions import account as fn + + result = await client(ctx)(fn.GetSavedRingtonesRequest(hash=0)) + rows = [ + Ringtone( + id=int(getattr(document, "id", 0) or 0), + access_hash=getattr(document, "access_hash", None), + file_name=next( + ( + str(getattr(attribute, "file_name", "")) + for attribute in getattr(document, "attributes", None) or [] + if type(attribute).__name__ == "DocumentAttributeFilename" + ), + "", + ), + mime_type=str(getattr(document, "mime_type", "") or ""), + size=int(getattr(document, "size", 0) or 0), + duration=next( + ( + int(getattr(attribute, "duration", 0) or 0) + for attribute in getattr(document, "attributes", None) or [] + if type(attribute).__name__ == "DocumentAttributeAudio" + ), + None, + ), + ) + for document in getattr(result, "ringtones", None) or [] + ] + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_RINGTONE_LIST = OperationSpec( + id="notify.ringtone.list", + request=RingtoneListReq, + response=Page[Ringtone], + impl=ringtone_list, + summary="List saved notification sounds", + description="The `id` of a row is what `notify set --sound ringtone:<id>` takes.", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("id", "file_name", "mime_type", "size", "duration"), + headers=("Id", "File", "Type", "Bytes", "Seconds"), + example={ + "items": [{"id": 8811, "file_name": "chime.ogg", "mime_type": "audio/ogg", "size": 20480}], + "has_more": False, + }, + example_args="notify ringtone list", + covers=("notify.ringtones-list",), + tags=frozenset({"agent-safe"}), +) + + +class RingtoneSetReq(Request): + file: Annotated[ + str | None, + arg(0, metavar="FILE", required=False, kind="path", help="MP3 or OGG/OPUS to upload."), + ] = None + from_message: Annotated[ + str | None, + opt("--from-message", metavar="CHAT:ID", help="Save a voice message as a ringtone."), + ] = None + remove: Annotated[ + str | None, opt("--remove", metavar="ID", help="Document id of a saved ringtone.") + ] = None + + +async def ringtone_set(ctx: OpContext, req: RingtoneSetReq) -> RingtoneSaved: + """Upload a notification sound, save an existing voice message, or remove one. + + Saving an *existing* document can hand back a different one: + `account.savedRingtoneConverted` carries a NEW document id, and using the + old one afterwards fails. `converted: true` says that happened, and `id` + is always the id that now works. + """ + import mimetypes + + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + + if req.remove: + if not req.remove.strip().isdigit(): + raise UsageError("--remove wants a saved ringtone's document id", field="remove") + document = await _saved_ringtone(ctx, int(req.remove)) + await handle(fn.SaveRingtoneRequest(id=document, unsave=True)) + return RingtoneSaved(id=int(req.remove), removed=True) + + if req.from_message: + from tlgr.ops import _media + + chat, _, msg_id = req.from_message.rpartition(":") + if not chat or not msg_id.strip().lstrip("-").isdigit(): + raise UsageError("--from-message wants '<chat>:<msg_id>'", field="from_message") + peer = await _settings.resolve(ctx, chat) + message = await _media.fetch_message(ctx, peer, int(msg_id)) + document = _media.input_document(_media.document_of(getattr(message, "media", None))) + answer = await handle(fn.SaveRingtoneRequest(id=document, unsave=False)) + converted = type(answer).__name__ == "AccountSavedRingtoneConverted" + new_document = getattr(answer, "document", None) + return RingtoneSaved( + id=int(getattr(new_document, "id", 0) or 0) or int(msg_id), + converted=converted, + ) + + if not req.file: + raise UsageError("give a FILE, --from-message or --remove", field="file") + path = Path(os.path.expanduser(req.file)) + if not path.exists(): + raise UsageError(f"{req.file} does not exist", field="file") + limits = await _settings.app_config(ctx) + size_max = int(limits.get("ringtone_size_max") or 0) + if size_max and path.stat().st_size > size_max: + raise UsageError( + f"{path.name} is larger than the server's ringtone_size_max ({size_max} bytes)", + field="file", + ) + 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") + uploaded = await handle( + fn.UploadRingtoneRequest( + file=await upload(path), + file_name=path.name, + mime_type=mimetypes.guess_type(path.name)[0] or "audio/mpeg", + ) + ) + document = types.InputDocument( + id=getattr(uploaded, "id", 0), + access_hash=getattr(uploaded, "access_hash", 0), + file_reference=getattr(uploaded, "file_reference", b"") or b"", + ) + answer = await handle(fn.SaveRingtoneRequest(id=document, unsave=False)) + ctx.emit("ringtone_saved", {"file_name": path.name}) + return RingtoneSaved( + id=int(getattr(uploaded, "id", 0) or 0), + file_name=path.name, + converted=type(answer).__name__ == "AccountSavedRingtoneConverted", + ) + + +async def _saved_ringtone(ctx: OpContext, document_id: int) -> Any: + """The `InputDocument` for a saved ringtone, with its live file reference.""" + from telethon.tl import types + from telethon.tl.functions import account as fn + + result = await client(ctx)(fn.GetSavedRingtonesRequest(hash=0)) + for document in getattr(result, "ringtones", None) or []: + if int(getattr(document, "id", 0) or 0) == document_id: + return types.InputDocument( + id=document.id, + access_hash=document.access_hash, + file_reference=getattr(document, "file_reference", b"") or b"", + ) + raise UsageError(f"{document_id} is not a saved ringtone", field="remove") + + +SPEC_RINGTONE_SET = OperationSpec( + id="notify.ringtone.set", + request=RingtoneSetReq, + response=RingtoneSaved, + impl=ringtone_set, + summary="Upload a notification sound, save a voice message as one, or remove one", + description=( + "Saving an existing document may return a *converted* one with a new " + "id; `converted: true` says so and `id` is always the usable one." + ), + mutating=True, + rate_class="file", + timeout_s=300, + columns=("id", "file_name", "converted", "removed"), + headers=("Id", "File", "Converted", "Removed"), + example={"id": 8811, "file_name": "chime.ogg", "converted": False}, + example_args="notify ringtone set chime.ogg", + covers=( + "notify.ringtone-remove", + "notify.ringtone-upload", + "ringtone.manage", + "ringtone.set-for-chat", + ), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] diff --git a/tlgr/ops/privacy.py b/tlgr/ops/privacy.py new file mode 100644 index 0000000..c08d327 --- /dev/null +++ b/tlgr/ops/privacy.py @@ -0,0 +1,794 @@ +"""The `privacy` group: Settings ▸ Privacy and Security, minus the sessions. + +Fourteen privacy keys, eight global switches and two blocklists, and the +whole group exists to make two dangerous APIs safe to use from a script. + +* **`account.setPrivacy` replaces the whole ordered rule vector.** Sending + "allow contacts" wipes every exception the user had. So `privacy set` + always GETs first, and the `--add-*`/`--remove` flags exist precisely so a + script never has to re-state a list it did not mean to touch. +* **`account.setGlobalPrivacySettings` replaces the whole constructor.** Same + hazard, same answer: read, patch the named fields, write. A switch nobody + mentioned is written back exactly as it was found. + +The rule vocabulary — `everybody`, `contacts`, `close-friends`, `premium`, +`bots`, `nobody` — is the one the official clients show, not the one the TL +schema uses, because `privacyValueDisallowAll` plus `privacyValueAllowUsers` +is a *shape* rather than a setting anybody chose. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from tlgr.core.errors import UsageError +from tlgr.core.pagination import PageKind +from tlgr.models.base import Request +from tlgr.models.contact import BlockedPeer, BlockedSet +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.models.privacy import GlobalPrivacy, PaidMessageRevenue, PrivacyRule, PrivacySettings +from tlgr.ops import _settings +from tlgr.ops._common import client +from tlgr.ops._params import arg, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: tlgr's key name → the `inputPrivacyKey*` class name. The tlgr spelling is +#: the GUI's row label; the TL name is an implementation detail nobody should +#: have to learn to mute their last-seen time. +KEYS: dict[str, str] = { + "last-seen": "InputPrivacyKeyStatusTimestamp", + "profile-photo": "InputPrivacyKeyProfilePhoto", + "phone-number": "InputPrivacyKeyPhoneNumber", + "forwards": "InputPrivacyKeyForwards", + "calls": "InputPrivacyKeyPhoneCall", + "phone-p2p": "InputPrivacyKeyPhoneP2P", + "chat-invite": "InputPrivacyKeyChatInvite", + "voice-messages": "InputPrivacyKeyVoiceMessages", + "bio": "InputPrivacyKeyAbout", + "birthday": "InputPrivacyKeyBirthday", + "gifts-auto-save": "InputPrivacyKeyStarGiftsAutoSave", + "no-paid-messages": "InputPrivacyKeyNoPaidMessages", + "saved-music": "InputPrivacyKeySavedMusic", + "added-by-phone": "InputPrivacyKeyAddedByPhone", +} + +#: The base rules, in the order `account.setPrivacy` wants them written: the +#: broad allow/disallow first, exceptions after, because the server applies +#: the vector in order and a trailing `allowAll` would undo everything. +BASES = ("everybody", "contacts", "close-friends", "premium", "bots", "nobody") + +#: `disallowedGiftsSettings` field ⇄ the keyword `--disallow-gifts` takes. +GIFT_KINDS: dict[str, str] = { + "unlimited": "disallow_unlimited_stargifts", + "limited": "disallow_limited_stargifts", + "unique": "disallow_unique_stargifts", + "premium": "disallow_premium_gifts", + "from-channels": "disallow_stargifts_from_channels", +} + + +def _key_tl(name: str) -> Any: + """The `inputPrivacyKey*` for a tlgr key name, or a usage error that lists them.""" + from telethon.tl import types + + wanted = name.strip().lower() + if wanted == "stories": + raise UsageError( + "story visibility is not a privacy key: the audience is chosen per " + "story (`story post --audience`) and the exclusion list is " + "`story blocklist set` / `privacy blocked set --stories`", + field="key", + ) + if wanted not in KEYS: + raise UsageError( + f"unknown privacy key {name!r}; one of: {' '.join(sorted(KEYS))}", field="key" + ) + return getattr(types, KEYS[wanted])() + + +def _rules_model(key: str, rules: Any) -> PrivacySettings: + """A `privacyValue*` vector split into the shape the GUI shows. + + The base is whichever broad rule the vector carries; the four exception + lists are the user/chat rules beside it. `raw_rules` keeps the server's + own ordering so a later write can reproduce it exactly. + """ + model = PrivacySettings(key=key) + for rule in rules or []: + name = type(rule).__name__.removeprefix("PrivacyValue") + action = "allow" if name.startswith("Allow") else "disallow" + scope = name.removeprefix("Allow").removeprefix("Disallow") + ids = [int(v) for v in (getattr(rule, "users", None) or getattr(rule, "chats", None) or [])] + model.raw_rules.append( + PrivacyRule(action=action, scope=_SCOPE_NAMES.get(scope, scope.lower()), ids=ids) + ) + if scope == "Users": + (model.allow_users if action == "allow" else model.deny_users).extend(ids) + elif scope == "ChatParticipants": + (model.allow_chats if action == "allow" else model.deny_chats).extend(ids) + elif scope in _BASE_FOR: + model.base = _BASE_FOR[scope] if action == "allow" else _DENY_BASE[scope] + return model + + +_SCOPE_NAMES = { + "All": "all", + "Contacts": "contacts", + "CloseFriends": "close-friends", + "Premium": "premium", + "Bots": "bots", + "Users": "users", + "ChatParticipants": "chats", +} +_BASE_FOR = { + "All": "everybody", + "Contacts": "contacts", + "CloseFriends": "close-friends", + "Premium": "premium", + "Bots": "bots", +} +#: `disallowContacts` and friends are the *other* half of the same switch: +#: "not my contacts" is how "nobody" is spelled for some keys. +_DENY_BASE = { + "All": "nobody", + "Contacts": "nobody", + "CloseFriends": "nobody", + "Premium": "nobody", + "Bots": "nobody", +} + + +# --------------------------------------------------------------------------- +# privacy get / set +# --------------------------------------------------------------------------- + + +class GetReq(Request): + key: Annotated[ + str | None, + arg(0, metavar="KEY", required=False, help="One key; omit for every key."), + ] = None + resolve: Annotated[ + bool, opt("--resolve/--no-resolve", help="Resolve exception ids to names.") + ] = True + + +async def get(ctx: OpContext, req: GetReq) -> Page[PrivacySettings]: + """Read one privacy setting, or all of them. + + The output is exactly what `privacy set` accepts back, which is what + makes "copy this account's privacy to that one" a pipeline rather than a + reading exercise. + """ + from telethon.tl.functions import account as fn + + handle = client(ctx) + wanted = [req.key.strip().lower()] if req.key else sorted(KEYS) + rows: list[PrivacySettings] = [] + for name in wanted: + answer = await handle(fn.GetPrivacyRequest(key=_key_tl(name))) + model = _rules_model(name, getattr(answer, "rules", None)) + if req.resolve: + known = _settings.entity_map(answer) + model.peers = [ + peer + for raw_id in (*model.allow_users, *model.deny_users) + if (peer := _settings.peer_model(known.get(raw_id))) is not None + ] + rows.append(model) + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_GET = OperationSpec( + id="privacy.get", + request=GetReq, + response=Page[PrivacySettings], + impl=get, + summary="Read one privacy setting, or all of them", + description=( + "`base` is the headline value the GUI shows and the four lists are " + "its exceptions; `raw_rules` keeps the server's own ordered vector so " + "nothing is lost in the translation." + ), + paginated=PageKind.LOCAL, + idempotent=True, + columns=("key", "base", "allow_users", "deny_users"), + headers=("Key", "Base", "Always allow", "Never allow"), + example={ + "items": [{"key": "last-seen", "base": "contacts", "deny_users": [777123]}], + "has_more": False, + }, + example_args="privacy get last-seen", + covers=( + "contacts-users.privacy-about", + "contacts-users.privacy-added-by-phone", + "contacts-users.privacy-chat-invite", + "contacts-users.privacy-forwards", + "contacts-users.privacy-gifts", + "contacts-users.privacy-no-paid-messages", + "contacts-users.privacy-phone-number", + "contacts-users.privacy-voice-messages", + "gift.auto-save-privacy", + "privacy.get-rules", + "privacy.key-birthday", + "privacy.key-calls", + "privacy.key-no-paid-messages", + "privacy.key-phone-number", + "privacy.key-voice-messages", + ), + covers_partial=( + "privacy.key-bio", + "privacy.key-chat-invite", + "privacy.key-forwards", + "privacy.key-gifts-auto-save", + "privacy.key-last-seen", + "privacy.key-profile-photo", + "privacy.key-saved-music", + ), + coverage_note="Writing any of these keys is `privacy set`.", + tags=frozenset({"agent-safe"}), +) + + +class SetReq(Request): + key: Annotated[str, arg(0, metavar="KEY", help="The privacy key to change.")] + rule: Annotated[ + str | None, + arg(1, metavar="RULE", required=False, help="everybody|contacts|close-friends|nobody…"), + ] = None + allow: Annotated[ + str | None, opt("--allow", metavar="LIST", help="Replace the 'always allow' list.") + ] = None + disallow: Annotated[ + str | None, opt("--disallow", metavar="LIST", help="Replace the 'never allow' list.") + ] = None + add_allow: Annotated[ + str | None, opt("--add-allow", metavar="LIST", help="Append to the allow list.") + ] = None + add_disallow: Annotated[ + str | None, opt("--add-disallow", metavar="LIST", help="Append to the deny list.") + ] = None + remove: Annotated[ + str | None, opt("--remove", metavar="LIST", help="Drop these from both lists.") + ] = None + clear_exceptions: Annotated[ + bool, opt("--clear-exceptions", help="Send only the base rule.") + ] = False + + +async def _ids_of(ctx: OpContext, text: str | None) -> tuple[list[int], list[int]]: + """A comma-separated peer list, split into `(user ids, chat ids)`.""" + users: list[int] = [] + chats: list[int] = [] + for entry in (text or "").split(","): + ref = entry.strip() + if not ref: + continue + peer = await _settings.resolve(ctx, ref) + marked = _settings.peer_of(peer) + (users if marked > 0 else chats).append(abs(marked) if marked < 0 else marked) + return users, chats + + +async def set_(ctx: OpContext, req: SetReq) -> PrivacySettings: + """Change a privacy key: a base rule plus optional exception lists. + + Read-modify-write, always. `account.setPrivacy` takes the *complete* + ordered vector, so sending only what changed would delete everything + else — which is the whole reason `--add-allow` and `--remove` exist. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + name = req.key.strip().lower() + key = _key_tl(name) + handle = client(ctx) + current = _rules_model( + name, getattr(await handle(fn.GetPrivacyRequest(key=key)), "rules", None) + ) + + base = current.base + if req.rule is not None: + base = req.rule.strip().lower() + if base not in BASES: + raise UsageError(f"RULE is one of: {' '.join(BASES)}", field="rule") + + allow_users, allow_chats = list(current.allow_users), list(current.allow_chats) + deny_users, deny_chats = list(current.deny_users), list(current.deny_chats) + + if req.clear_exceptions: + allow_users, allow_chats, deny_users, deny_chats = [], [], [], [] + if req.allow is not None: + allow_users, allow_chats = await _ids_of(ctx, req.allow) + if req.disallow is not None: + deny_users, deny_chats = await _ids_of(ctx, req.disallow) + if req.add_allow: + more_users, more_chats = await _ids_of(ctx, req.add_allow) + allow_users += [i for i in more_users if i not in allow_users] + allow_chats += [i for i in more_chats if i not in allow_chats] + if req.add_disallow: + more_users, more_chats = await _ids_of(ctx, req.add_disallow) + deny_users += [i for i in more_users if i not in deny_users] + deny_chats += [i for i in more_chats if i not in deny_chats] + if req.remove: + gone_users, gone_chats = await _ids_of(ctx, req.remove) + allow_users = [i for i in allow_users if i not in gone_users] + deny_users = [i for i in deny_users if i not in gone_users] + allow_chats = [i for i in allow_chats if i not in gone_chats] + deny_chats = [i for i in deny_chats if i not in gone_chats] + + rules: list[Any] = [] + # The exceptions go first: the server evaluates the vector in order, so a + # broad rule written before them would decide every case on its own. + if allow_users: + rules.append( + types.InputPrivacyValueAllowUsers( + users=[await _settings.input_user(ctx, str(i)) for i in allow_users] + ) + ) + if deny_users: + rules.append( + types.InputPrivacyValueDisallowUsers( + users=[await _settings.input_user(ctx, str(i)) for i in deny_users] + ) + ) + if allow_chats: + rules.append(types.InputPrivacyValueAllowChatParticipants(chats=allow_chats)) + if deny_chats: + rules.append(types.InputPrivacyValueDisallowChatParticipants(chats=deny_chats)) + rules.append(_base_rule(base)) + + answer = await handle(fn.SetPrivacyRequest(key=key, rules=rules)) + ctx.emit("privacy_set", {"key": name, "base": base}) + return _rules_model(name, getattr(answer, "rules", None)) + + +def _base_rule(base: str) -> Any: + from telethon.tl import types + + return { + "everybody": types.InputPrivacyValueAllowAll, + "contacts": types.InputPrivacyValueAllowContacts, + "close-friends": types.InputPrivacyValueAllowCloseFriends, + "premium": types.InputPrivacyValueAllowPremium, + "bots": types.InputPrivacyValueAllowBots, + "nobody": types.InputPrivacyValueDisallowAll, + }[base]() + + +SPEC_SET = OperationSpec( + id="privacy.set", + request=SetReq, + response=PrivacySettings, + impl=set_, + summary="Change a privacy setting: a base rule plus optional exception lists", + description=( + "`account.setPrivacy` replaces the whole ordered vector, so this " + "always reads the current rules first. `--add-allow`/`--remove` edit " + "the lists in place; `--allow`/`--disallow` replace them." + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("key", "base", "allow_users", "deny_users"), + headers=("Key", "Base", "Always allow", "Never allow"), + example={"key": "last-seen", "base": "contacts", "deny_users": [777123]}, + example_args="privacy set last-seen contacts --add-disallow @nosy", + covers=( + "bots.privacy-rule-bots", + "calls.privacy-p2p", + "calls.privacy-who-can-call", + "contacts-users.privacy-exception-lists", + "contacts-users.user-status-reveal", + "dialogs.new-chats-privacy", + "gift.privacy-disallowed", + "privacy.exceptions", + "privacy.key-bio", + "privacy.key-chat-invite", + "privacy.key-forwards", + "privacy.key-gifts-auto-save", + "privacy.key-last-seen", + "privacy.key-profile-photo", + "privacy.key-saved-music", + "privacy.set-rules", + ), + covers_partial=( + "privacy.key-birthday", + "privacy.key-calls", + "privacy.key-no-paid-messages", + "privacy.key-phone-number", + "privacy.key-voice-messages", + ), + coverage_note="Reading any of these keys back is `privacy get`.", + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# privacy global get / set +# --------------------------------------------------------------------------- + + +def _global_model(raw: Any) -> GlobalPrivacy: + gifts = getattr(raw, "disallowed_gifts", None) + return GlobalPrivacy( + hide_read_marks=getattr(raw, "hide_read_marks", None), + archive_and_mute_new_noncontact_peers=getattr( + raw, "archive_and_mute_new_noncontact_peers", None + ), + new_noncontact_peers_require_premium=getattr( + raw, "new_noncontact_peers_require_premium", None + ), + noncontact_peers_paid_stars=getattr(raw, "noncontact_peers_paid_stars", None), + keep_archived_unmuted=getattr(raw, "keep_archived_unmuted", None), + keep_archived_folders=getattr(raw, "keep_archived_folders", None), + display_gifts_button=getattr(raw, "display_gifts_button", None), + disallowed_gifts=sorted( + word for word, field in GIFT_KINDS.items() if getattr(gifts, field, False) + ), + ) + + +class GlobalGetReq(Request): + pass + + +async def global_get(ctx: OpContext, req: GlobalGetReq) -> GlobalPrivacy: + """The account-wide privacy switches: read marks, archiving, paid messages.""" + from telethon.tl.functions import account as fn + + return _global_model(await client(ctx)(fn.GetGlobalPrivacySettingsRequest())) + + +SPEC_GLOBAL_GET = OperationSpec( + id="privacy.global.get", + request=GlobalGetReq, + response=GlobalPrivacy, + impl=global_get, + summary="Read the global privacy settings (read time, archiving, paid messages, gifts)", + idempotent=True, + columns=( + "hide_read_marks", + "archive_and_mute_new_noncontact_peers", + "new_noncontact_peers_require_premium", + "noncontact_peers_paid_stars", + ), + headers=("Hide read marks", "Archive strangers", "Premium only", "Stars/message"), + example={"hide_read_marks": False, "new_noncontact_peers_require_premium": True}, + example_args="privacy global get", + covers=( + "contacts-users.privacy-global", + "privacy.global-disallowed-gifts", + "privacy.global-keep-archived-unmuted", + "privacy.global-require-premium-to-message", + ), + covers_partial=( + "privacy.global-archive-new-noncontacts", + "privacy.global-display-gifts-button", + "privacy.global-hide-read-marks", + "privacy.global-keep-archived-folders", + "privacy.global-paid-messages-price", + ), + coverage_note="Writing any of these switches is `privacy global set`.", + tags=frozenset({"agent-safe"}), +) + + +class GlobalSetReq(Request): + hide_read_marks: Annotated[ + str | None, opt("--hide-read-marks", metavar="ON|OFF", help="Hide when I read messages.") + ] = None + archive_new_noncontacts: Annotated[ + str | None, + opt("--archive-new-noncontacts", metavar="ON|OFF", help="Archive+mute unknown senders."), + ] = None + require_premium_to_message: Annotated[ + str | None, + opt("--require-premium-to-message", metavar="ON|OFF", help="Premium non-contacts only."), + ] = None + paid_messages_price: Annotated[ + int | None, + opt("--paid-messages-price", metavar="STARS", help="Stars per message; 0 turns it off."), + ] = None + keep_archived_unmuted: Annotated[ + str | None, + opt("--keep-archived-unmuted", metavar="ON|OFF", help="Unmuted archived chats stay put."), + ] = None + keep_archived_folders: Annotated[ + str | None, + opt("--keep-archived-folders", metavar="ON|OFF", help="Folder chats stay archived."), + ] = None + display_gifts_button: Annotated[ + str | None, + opt("--display-gifts-button", metavar="ON|OFF", help="Gift button in private chats."), + ] = None + disallow_gifts: Annotated[ + str | None, opt("--disallow-gifts", metavar="LIST", help="Gift categories to refuse.") + ] = None + allow_gifts: Annotated[ + str | None, opt("--allow-gifts", metavar="LIST", help="Gift categories to accept again.") + ] = None + + +async def global_set(ctx: OpContext, req: GlobalSetReq) -> GlobalPrivacy: + """Change one global privacy switch; tlgr does the read-modify-write. + + `account.setGlobalPrivacySettings` replaces the whole constructor, so a + flag nobody passed would be written back as `false` unless the current + value is fetched first. It is, every time. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + current = await handle(fn.GetGlobalPrivacySettingsRequest()) + before = _global_model(current) + + values: dict[str, Any] = { + "hide_read_marks": before.hide_read_marks, + "archive_and_mute_new_noncontact_peers": before.archive_and_mute_new_noncontact_peers, + "new_noncontact_peers_require_premium": before.new_noncontact_peers_require_premium, + "keep_archived_unmuted": before.keep_archived_unmuted, + "keep_archived_folders": before.keep_archived_folders, + "display_gifts_button": before.display_gifts_button, + } + changed: list[str] = [] + for flag, field in ( + ("hide_read_marks", "hide_read_marks"), + ("archive_new_noncontacts", "archive_and_mute_new_noncontact_peers"), + ("require_premium_to_message", "new_noncontact_peers_require_premium"), + ("keep_archived_unmuted", "keep_archived_unmuted"), + ("keep_archived_folders", "keep_archived_folders"), + ("display_gifts_button", "display_gifts_button"), + ): + value = _settings.on_off(getattr(req, flag), field=flag) + if value is not None: + values[field] = value + changed.append(field) + + stars = before.noncontact_peers_paid_stars + if req.paid_messages_price is not None: + if req.paid_messages_price < 0: + raise UsageError( + "--paid-messages-price cannot be negative", field="paid_messages_price" + ) + stars = req.paid_messages_price or None + changed.append("noncontact_peers_paid_stars") + + kinds = set(before.disallowed_gifts) + for text, add in ((req.disallow_gifts, True), (req.allow_gifts, False)): + for word in (part.strip().lower() for part in (text or "").split(",") if part.strip()): + if word not in GIFT_KINDS: + raise UsageError( + f"unknown gift category {word!r}; one of: {' '.join(sorted(GIFT_KINDS))}", + field="disallow_gifts", + ) + kinds.add(word) if add else kinds.discard(word) + changed.append("disallowed_gifts") + + if not changed: + raise UsageError("nothing to change: pass at least one flag", field="hide_read_marks") + + gifts = ( + types.DisallowedGiftsSettings(**{GIFT_KINDS[word]: True for word in sorted(kinds)}) + if kinds + else None + ) + answer = await handle( + fn.SetGlobalPrivacySettingsRequest( + settings=types.GlobalPrivacySettings( + **{k: v or None for k, v in values.items()}, + noncontact_peers_paid_stars=stars, + disallowed_gifts=gifts, + ) + ) + ) + ctx.emit("privacy_global", {"changed": sorted(set(changed))}) + result = _global_model(answer) + result.changed = sorted(set(changed)) + return result + + +SPEC_GLOBAL_SET = OperationSpec( + id="privacy.global.set", + request=GlobalSetReq, + response=GlobalPrivacy, + impl=global_set, + summary="Change global privacy settings (one flag at a time; tlgr read-modify-writes)", + mutating=True, + idempotent=True, + rate_class="send", + columns=("hide_read_marks", "new_noncontact_peers_require_premium", "changed"), + headers=("Hide read marks", "Premium only", "Changed"), + example={"hide_read_marks": True, "changed": ["hide_read_marks"]}, + example_args="privacy global set --hide-read-marks on", + covers=( + "privacy.global-archive-new-noncontacts", + "privacy.global-display-gifts-button", + "privacy.global-hide-read-marks", + "privacy.global-keep-archived-folders", + "privacy.global-paid-messages-price", + ), + covers_partial=( + "privacy.global-disallowed-gifts", + "privacy.global-keep-archived-unmuted", + "privacy.global-require-premium-to-message", + ), + coverage_note="Reading them back is `privacy global get`.", + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# privacy blocked list / set +# --------------------------------------------------------------------------- + + +class BlockedListReq(Request): + stories: Annotated[ + bool, opt("--stories", help="The 'blocked from my stories' list instead.") + ] = False + + +async def blocked_list(ctx: OpContext, req: BlockedListReq) -> Page[BlockedPeer]: + """Blocked users and bots, or the separate story blocklist. + + The same list `contact blocked list` answers with — reached from the + Privacy screen, which is where the GUI puts it, rather than from the + address book. + """ + from tlgr.ops.contact import BlockedListReq as ContactBlockedListReq + from tlgr.ops.contact import blocked_list as contact_blocked_list + + return await contact_blocked_list(ctx, ContactBlockedListReq(stories=req.stories)) + + +SPEC_BLOCKED_LIST = OperationSpec( + id="privacy.blocked.list", + request=BlockedListReq, + response=Page[BlockedPeer], + impl=blocked_list, + summary="List blocked users (and the separate story-only block list)", + aliases=("block.list",), + paginated=PageKind.PARTICIPANTS, + idempotent=True, + 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="privacy blocked list", + covers_partial=("privacy.blocked-list",), + coverage_note="The write half is `privacy blocked set`; `contact blocked list` is the same list.", + tags=frozenset({"agent-safe"}), +) + + +class BlockedSetReq(Request): + peer: Annotated[ + tuple[PeerRef, ...], + arg(0, metavar="PEER", required=False, variadic=True, kind="peer", help="Who to block."), + ] = () + unblock: Annotated[bool, opt("--unblock", help="Remove the block instead of adding it.")] = ( + False + ) + stories: Annotated[bool, opt("--stories", help="Only block them from seeing my stories.")] = ( + False + ) + replace_with: Annotated[ + str | None, + opt("--replace-with", metavar="LIST", help="Replace the whole list at once."), + ] = None + + +async def blocked_set(ctx: OpContext, req: BlockedSetReq) -> BlockedSet: + """Block or unblock peers, or replace the whole blocklist. + + The answer is always the diff — who this call blocked and who it + unblocked — because `--replace-with` is `contacts.setBlocked`, which + *replaces* the list: everyone not named is unblocked. One shape for both + paths means a script never has to branch on which flag it passed. + """ + from telethon.tl.functions import contacts as fn + + if req.replace_with is not None: + from tlgr.models.peer import parse_peer_ref + from tlgr.ops.contact import BlockedSetReq as ContactBlockedSetReq + from tlgr.ops.contact import blocked_set as contact_blocked_set + + refs = [ + parse_peer_ref(part.strip()) for part in req.replace_with.split(",") if part.strip() + ] + return await contact_blocked_set(ctx, ContactBlockedSetReq(user=refs, stories=req.stories)) + + if not req.peer: + raise UsageError("give one or more peers, or --replace-with", field="peer") + + handle = client(ctx) + marked: list[int] = [] + for ref in req.peer: + peer = await _settings.resolve(ctx, ref) + request = fn.UnblockRequest if req.unblock else fn.BlockRequest + await handle(request(id=peer, my_stories_from=req.stories or None)) + marked.append(_settings.peer_of(peer)) + ctx.emit("privacy_blocked", {"peer_ids": marked, "unblock": req.unblock}) + return BlockedSet( + count=len(marked), + blocked=[] if req.unblock else marked, + unblocked=marked if req.unblock else [], + kind="stories" if req.stories else "main", + applied=True, + ) + + +SPEC_BLOCKED_SET = OperationSpec( + id="privacy.blocked.set", + request=BlockedSetReq, + response=BlockedSet, + impl=blocked_set, + summary="Block or unblock a user or bot (also the story-only block list)", + description=( + "The same operation as `user block` / `user unblock`, reached from " + "the Privacy screen. `--replace-with` is the bulk form and answers " + "with the diff it applied." + ), + aliases=("block.set",), + mutating=True, + rate_class="send", + columns=("count", "blocked", "unblocked", "kind"), + headers=("Peers", "Blocked", "Unblocked", "List"), + example={"count": 1, "blocked": [777123], "unblocked": [], "kind": "main", "applied": True}, + example_args="privacy blocked set @spammer", + covers=("privacy.blocked-list",), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# privacy revenue get +# --------------------------------------------------------------------------- + + +class RevenueGetReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who paid me.")] + parent_peer: Annotated[ + str | None, + opt("--parent-peer", metavar="CHAT", help="Business or channel parent peer."), + ] = None + + +async def revenue_get(ctx: OpContext, req: RevenueGetReq) -> PaidMessageRevenue: + """Stars earned from paid messages one user sent me.""" + from telethon.tl.functions import account as fn + + target = await _settings.input_user(ctx, req.user) + parent = await _settings.resolve(ctx, req.parent_peer) if req.parent_peer else None + result = await client(ctx)(fn.GetPaidMessagesRevenueRequest(user_id=target, parent_peer=parent)) + return PaidMessageRevenue( + user_id=_settings.peer_of(await _settings.resolve(ctx, req.user)), + stars_amount=int(getattr(result, "stars_amount", 0) or 0), + ) + + +SPEC_REVENUE_GET = OperationSpec( + id="privacy.revenue.get", + request=RevenueGetReq, + response=PaidMessageRevenue, + impl=revenue_get, + summary="Stars earned from paid messages sent by a user", + idempotent=True, + columns=("user_id", "stars_amount"), + headers=("User", "Stars"), + example={"user_id": 777123, "stars_amount": 25}, + example_args="privacy revenue get @alice", + covers=("privacy.paid-message-revenue",), + tags=frozenset({"agent-safe"}), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] From 6a05fcfc3563d059562baf467a54dc5b1888859f Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 07:11:56 +0330 Subject: [PATCH 04/15] settings ops: one generic pair over a dozen RPCs, not a dozen toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `settings get`/`settings set` address fifteen cloud-synced keys by name. A dozen thin toggle commands would be a dozen names to learn, a dozen response shapes and a dozen places to get the same read-modify-write wrong; here every row carries `accepts`, the exact token vocabulary its setter takes, so a read can be piped straight back into a write. Where another group already implements a setting — sensitive media, auto-download presets, the quick reaction, paid-reaction privacy, saved tags, top peers, folder tags — the dispatcher calls that operation instead of sending the RPC a second time. One implementation, two entry points, and no second place for the two to disagree. `settings theme *` is deliberately metadata only: a theme is a rendering instruction and tlgr renders nothing, but publishing a theme file, installing one and listing what is installed are server-side facts the phone sharing the account will act on. --- tlgr/ops/settings.py | 1060 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1060 insertions(+) create mode 100644 tlgr/ops/settings.py diff --git a/tlgr/ops/settings.py b/tlgr/ops/settings.py new file mode 100644 index 0000000..7dbc59b --- /dev/null +++ b/tlgr/ops/settings.py @@ -0,0 +1,1060 @@ +"""The `settings` group: the cloud-synced switches, languages and themes. + +`settings get` and `settings set` are one generic pair over a dozen unrelated +RPCs. That is a decision, not a shortcut: a dozen thin toggle commands would +be a dozen names to learn, a dozen response shapes and a dozen places for +the same "read-modify-write" mistake. Here every key prints the exact token +vocabulary its setter accepts, so `settings get X` and `settings set X <value>` +are a genuine round trip. + +Where the group that owns a setting already implements it — sensitive media, +auto-download presets, the quick reaction, paid-reaction privacy, saved tags, +top peers, folder tags — this dispatches to that operation instead of issuing +the RPC a second time. One server call, one implementation, two entry points. + +`settings theme *` is metadata only. tlgr has no theming engine and renders +nothing; what it can do is publish a theme file, install one for the account +and list what is installed, which is the server-side half the GUI shares. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Annotated, Any + +from tlgr.core.errors import NotFoundError, UsageError +from tlgr.core.pagination import PageKind +from tlgr.core.timefmt import parse_duration +from tlgr.models.base import Request +from tlgr.models.media import AutoSaveSaved +from tlgr.models.page import Page +from tlgr.models.settings import ( + CloudTheme, + Language, + SettingChange, + SettingUnset, + SettingValue, + ThemeInstalled, +) +from tlgr.ops import _settings +from tlgr.ops._common import client +from tlgr.ops._params import arg, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: key → the token vocabulary its setter accepts. Printed with every read so +#: the output of `settings get` can be piped back into `settings set`. +ACCEPTS: dict[str, str] = { + "sensitive-content": "on|off", + "auto-delete": "1d|1w|1m|<duration>|off", + "top-peers": "on|off", + "quick-reaction": "<emoji>|custom:<doc-id>", + "folder-tags": "on|off", + "paid-reaction-privacy": "default|anonymous|peer:<channel>", + "sponsored-ads": "on|off", + "browser": "external|in-app", + "browser-close-button": "on|off", + "browser-exception": "<url> external|in-app", + "no-forwards": "<peer> on|off (use --peer)", + "saved-tag": "<emoji> <title>", + "language": "<lang-code>", + "auto-download": "auto-download.<low|medium|high>.<field> <value>", + "age-verification": "(read-only)", +} + +#: The `auto-download.<preset>.<field>` names, in three spellings: the tlgr +#: key, the field on `AutoDownloadPreset` a read comes back on, and the flag +#: `media auto-download set` takes. Keeping the three in one table is what +#: stops the dotted key and the delegate drifting apart. +PRESETS = ("low", "medium", "high") +DOWNLOAD_FIELDS = { + "photo-max": "photo_size_max", + "video-max": "video_size_max", + "file-size-max": "file_size_max", + "video-preload-large": "video_preload_large", + "audio-preload-next": "audio_preload_next", + "stories-preload": "stories_preload", + "disabled": "disabled", +} +DOWNLOAD_FLAGS = { + "photo-max": "photo_max", + "video-max": "video_max", + "file-size-max": "file_max", + "video-preload-large": "preload_large_video", + "audio-preload-next": "preload_next_audio", + "stories-preload": "preload_stories", + "disabled": "disabled", +} + + +def _key_of(raw: str) -> tuple[str, str]: + """`auto-download.low.photo-max` → `("auto-download", "low.photo-max")`.""" + head, _, tail = raw.strip().lower().partition(".") + if head not in ACCEPTS: + raise UsageError( + f"unknown setting {raw!r}; one of: {' '.join(sorted(ACCEPTS))}", field="key" + ) + return head, tail + + +# --------------------------------------------------------------------------- +# The readers +# --------------------------------------------------------------------------- + + +async def _read(ctx: OpContext, key: str, tail: str, peer: str | None) -> SettingValue: + """One key's current value, with where it came from and whether it may be set.""" + from telethon.tl import types + from telethon.tl.functions import account as afn + from telethon.tl.functions import messages as mfn + from telethon.tl.functions import users as ufn + + handle = client(ctx) + value: Any = None + source = "server" + changeable = True + reason: str | None = None + + if key == "sensitive-content": + from tlgr.ops.media import SensitiveGetReq, sensitive_get + + content = await sensitive_get(ctx, SensitiveGetReq()) + value = "on" if content.sensitive_enabled else "off" + changeable = content.sensitive_can_change + reason = content.reason + elif key == "auto-delete": + period = int(getattr(await handle(mfn.GetDefaultHistoryTTLRequest()), "period", 0) or 0) + value = f"{period}s" if period else "off" + elif key == "top-peers": + # There is no getter for the switch itself: `contacts.getTopPeers` + # answers `topPeersDisabled` when collection is off, which is the + # only signal the server gives. + answer = await handle(_top_peers_request()) + value = "off" if type(answer).__name__ == "ContactsTopPeersDisabled" else "on" + elif key == "quick-reaction": + from tlgr.ops.reaction import DefaultGetReq, default_get + + quick = await default_get(ctx, DefaultGetReq()) + value = quick.reaction or None + elif key == "folder-tags": + from tlgr.ops.folder import raw_filters + + _, enabled = await raw_filters(ctx) + value = "on" if enabled else "off" + elif key == "paid-reaction-privacy": + raw = await handle(mfn.GetPaidReactionPrivacyRequest()) + value = _paid_privacy_word(raw) + elif key == "sponsored-ads": + answer = await handle(ufn.GetFullUserRequest(id=types.InputUserSelf())) + value = ( + "on" + if getattr(getattr(answer, "full_user", None), "sponsored_enabled", False) + else "off" + ) + elif key in ("browser", "browser-close-button", "browser-exception"): + settings = await handle(afn.GetWebBrowserSettingsRequest(hash=0)) + if key == "browser": + value = "external" if getattr(settings, "open_external_browser", False) else "in-app" + elif key == "browser-close-button": + value = "on" if getattr(settings, "display_close_button", False) else "off" + else: + value = [ + { + "url": getattr(entry, "url", ""), + "mode": ( + "external" if getattr(entry, "open_external_browser", False) else "in-app" + ), + } + for entry in getattr(settings, "exceptions", None) or [] + ] + elif key == "no-forwards": + answer = await handle(ufn.GetFullUserRequest(id=types.InputUserSelf())) + full = getattr(answer, "full_user", None) + field = "noforwards_peer_enabled" if peer else "noforwards_my_enabled" + value = "on" if getattr(full, field, False) else "off" + elif key == "saved-tag": + from tlgr.ops.reaction import TagListReq, tag_list + + tags = await tag_list(ctx, TagListReq()) + value = [ + {"reaction": tag.reaction, "title": tag.title, "count": tag.count} for tag in tags.items + ] + elif key == "language": + from tlgr.ops.config import ConfigGetReq, config_get + + stored = await config_get(ctx, ConfigGetReq(key="identity.lang_code")) + value = stored.value or None + source = "local" + elif key == "auto-download": + from tlgr.ops.media import AutoDownloadGetReq, auto_download_get + + presets = await auto_download_get(ctx, AutoDownloadGetReq()) + value = [ + {"preset": preset.preset, **{k: getattr(preset, v) for k, v in DOWNLOAD_FIELDS.items()}} + for preset in presets.presets + if not tail or preset.preset == tail.partition(".")[0] + ] + elif key == "age-verification": + config = await _settings.app_config(ctx) + value = { + "need_age_video_verification": bool(config.get("need_age_video_verification")), + "verify_age_min": config.get("verify_age_min"), + "verify_age_bot_username": config.get("verify_age_bot_username"), + } + changeable = False + reason = ( + "age verification runs in a Telegram-designated bot's Main Mini App " + "with a camera; a terminal cannot complete it" + ) + source = "app-config" + + return SettingValue( + key=f"{key}.{tail}" if tail else key, + value=value, + changeable=changeable, + source=source, + accepts=ACCEPTS[key], + reason=reason, + ) + + +def _top_peers_request() -> Any: + from telethon.tl.functions import contacts as fn + + return fn.GetTopPeersRequest(correspondents=True, offset=0, limit=1, hash=0) + + +def _paid_privacy_word(raw: Any) -> str: + name = type(raw).__name__ + if name == "PaidReactionPrivacyAnonymous": + return "anonymous" + if name == "PaidReactionPrivacyPeer": + return "peer" + return "default" + + +class GetReq(Request): + key: Annotated[ + str | None, arg(0, metavar="KEY", required=False, help="One key; omit for every key.") + ] = None + peer: Annotated[ + str | None, opt("--peer", metavar="CHAT", help="Target peer for per-peer keys.") + ] = None + + +async def get(ctx: OpContext, req: GetReq) -> Page[SettingValue]: + """Read cloud-synced account settings — one key, or all of them. + + Every row carries `accepts`, the exact token vocabulary its setter takes, + so a caller never has to guess whether a switch wants `on` or `true`. + """ + wanted = [_key_of(req.key)] if req.key else [(name, "") for name in sorted(ACCEPTS)] + rows: list[SettingValue] = [] + for name, tail in wanted: + try: + rows.append(await _read(ctx, name, tail, req.peer)) + except Exception as exc: + if req.key: + raise + ctx.warn(f"could not read {name}: {exc}") + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_GET = OperationSpec( + id="settings.get", + request=GetReq, + response=Page[SettingValue], + impl=get, + summary="Read cloud-synced account settings (one key or all of them)", + description=( + "One generic pair instead of a dozen thin toggles. `accepts` on each " + "row is the vocabulary `settings set` takes for that key, so the read " + "and the write are the same words." + ), + paginated=PageKind.LOCAL, + idempotent=True, + columns=("key", "value", "changeable", "accepts"), + headers=("Key", "Value", "Changeable", "Accepts"), + example={ + "items": [ + {"key": "auto-delete", "value": "off", "changeable": True, "accepts": "1d|1w|1m|off"} + ], + "has_more": False, + }, + example_args="settings get auto-delete", + covers=( + "appearance.default-reaction", + "data.auto-download", + "privacy.paid-reaction-anonymity", + "privacy.pm-content-protection", + "privacy.sensitive-content", + ), + covers_partial=( + "appearance.folder-tags", + "appearance.saved-tags", + "business.reenable-ads", + "data.web-browser-settings", + "lang.set", + "privacy.age-verification", + "privacy.default-ttl", + "privacy.top-peers-suggest", + ), + coverage_note="Writing any of these keys is `settings set`.", + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# settings set +# --------------------------------------------------------------------------- + + +class SetReq(Request): + key: Annotated[str, arg(0, metavar="KEY", help="The setting to change.")] + value: Annotated[ + tuple[str, ...], arg(1, metavar="VALUE", variadic=True, help="Value(s) for the key.") + ] = () + apply_to_existing: Annotated[ + bool, opt("--apply-to-existing", help="auto-delete: rewrite every chat's TTL too.") + ] = False + peer: Annotated[ + str | None, opt("--peer", metavar="CHAT", help="Target peer for per-peer keys.") + ] = None + + +async def set_(ctx: OpContext, req: SetReq) -> SettingChange: + """Change one cloud-synced account setting. + + Settings whose server call replaces a whole constructor — the + auto-download presets, the web-browser settings — are read-modify-written + here, and the dotted key names the one field being changed. + """ + from telethon.tl.functions import account as afn + from telethon.tl.functions import messages as mfn + + handle = client(ctx) + key, tail = _key_of(req.key) + values = [value for value in req.value if value != ""] + before = await _read(ctx, key, tail, req.peer) + if not before.changeable: + from tlgr.core.errors import PermissionError_ + + raise PermissionError_(before.reason or f"{key} cannot be changed from an API client") + if not values: + raise UsageError(f"{key} wants a value: {ACCEPTS[key]}", field="value") + word = values[0].strip() + result = SettingChange(key=before.key, previous=before.value) + + if key == "sensitive-content": + from tlgr.ops.media import SensitiveSetReq, sensitive_set + + content = await sensitive_set(ctx, SensitiveSetReq(state=word)) + result.value = "on" if content.sensitive_enabled else "off" + result.already = content.already + elif key == "auto-delete": + period = 0 if word.lower() in ("off", "none", "0") else int(parse_duration(word) or 0) + if word.lower() not in ("off", "none", "0") and not period: + raise UsageError("auto-delete takes a duration (1d, 1w, 1m) or 'off'", field="value") + await handle(mfn.SetDefaultHistoryTTLRequest(period=period)) + result.value = f"{period}s" if period else "off" + if req.apply_to_existing: + result.applied_to = await _apply_ttl_everywhere(ctx, period) + elif key == "top-peers": + from tlgr.ops.contact import TopSetReq, top_set + + state = _settings.on_off(word, field="value") + await top_set(ctx, TopSetReq(state="on" if state else "off")) + result.value = "on" if state else "off" + elif key == "quick-reaction": + from tlgr.ops.reaction import DefaultSetReq, default_set + + await default_set(ctx, DefaultSetReq(emoji=word)) + result.value = word + elif key == "folder-tags": + state = _settings.on_off(word, field="value") + await handle(mfn.ToggleDialogFilterTagsRequest(enabled=bool(state))) + result.value = "on" if state else "off" + elif key == "paid-reaction-privacy": + from tlgr.ops.reaction import PrivacySetReq, privacy_set + + await privacy_set(ctx, PrivacySetReq(mode=word)) + result.value = word + elif key == "sponsored-ads": + state = _settings.on_off(word, field="value") + await handle(afn.ToggleSponsoredMessagesRequest(enabled=bool(state))) + result.value = "on" if state else "off" + elif key in ("browser", "browser-close-button"): + settings = await handle(afn.GetWebBrowserSettingsRequest(hash=0)) + external = bool(getattr(settings, "open_external_browser", False)) + close = bool(getattr(settings, "display_close_button", False)) + if key == "browser": + if word not in ("external", "in-app"): + raise UsageError("browser takes external or in-app", field="value") + external = word == "external" + result.value = word + else: + close = bool(_settings.on_off(word, field="value")) + result.value = "on" if close else "off" + await handle( + afn.UpdateWebBrowserSettingsRequest( + open_external_browser=external or None, display_close_button=close or None + ) + ) + elif key == "browser-exception": + if len(values) != 2 or values[1] not in ("external", "in-app"): + raise UsageError("browser-exception takes '<url> external|in-app'", field="value") + await handle( + afn.ToggleWebBrowserSettingsExceptionRequest( + url=values[0], open_external_browser=values[1] == "external" or None + ) + ) + result.value = {"url": values[0], "mode": values[1]} + elif key == "no-forwards": + if not req.peer: + raise UsageError("no-forwards needs --peer <chat>", field="peer") + state = _settings.on_off(word, field="value") + await handle( + mfn.ToggleNoForwardsRequest( + peer=await _settings.resolve(ctx, req.peer), enabled=bool(state) + ) + ) + result.value = "on" if state else "off" + elif key == "saved-tag": + from tlgr.ops.reaction import TagSetReq, tag_set + + if len(values) < 2: + raise UsageError("saved-tag takes '<emoji> <title>'", field="value") + await tag_set(ctx, TagSetReq(emoji=values[0], title=" ".join(values[1:]))) + result.value = {"emoji": values[0], "title": " ".join(values[1:])} + elif key == "language": + from tlgr.ops.config import ConfigSetReq, config_set + + stored = await config_set(ctx, ConfigSetReq(key="identity.lang_code", value=word)) + result.value = stored.value + result.already = stored.already + elif key == "auto-download": + result.value = await _set_auto_download(ctx, tail, word) + else: # pragma: no cover - `age-verification` is read-only and refused above + raise UsageError(f"{key} cannot be written", field="key") + + if result.value == result.previous: + result.already = True + ctx.mark_already() + ctx.emit("settings_set", {"key": result.key}) + return result + + +async def _apply_ttl_everywhere(ctx: OpContext, period: int) -> int: + """Push the default TTL onto every existing chat, one `setHistoryTTL` each. + + Gated behind `--apply-to-existing` and `--yes` because it rewrites a + setting on every dialog, and the server has no bulk form. + """ + from telethon.tl.functions import messages as fn + + from tlgr.ops.chat import ListReq, list_chats + + handle = client(ctx) + page = await list_chats(ctx, ListReq()) + touched = 0 + for dialog in page.items: + chat = getattr(dialog, "chat", None) + if chat is None: + continue + peer = await _settings.resolve(ctx, str(chat.id)) + try: + await handle(fn.SetHistoryTTLRequest(peer=peer, period=period)) + except Exception as exc: + ctx.warn(f"could not set the TTL on {chat.id}: {exc}") + continue + touched += 1 + return touched + + +async def _set_auto_download(ctx: OpContext, tail: str, word: str) -> Any: + """`auto-download.<preset>.<field> <value>`, read-modify-written.""" + from tlgr.ops.media import AutoDownloadSetReq, auto_download_set + + preset, _, field = tail.partition(".") + if preset not in PRESETS or field not in DOWNLOAD_FLAGS: + raise UsageError( + f"auto-download.<{'|'.join(PRESETS)}>.<{'|'.join(sorted(DOWNLOAD_FIELDS))}>", + field="key", + ) + flag = DOWNLOAD_FLAGS[field] + kwargs: dict[str, Any] = {"preset": preset} + kwargs[flag] = ( + word + if field in ("photo-max", "video-max", "file-size-max") + else bool(_settings.on_off(word, field="value")) + ) + saved = await auto_download_set(ctx, AutoDownloadSetReq(**kwargs)) + return { + field: getattr(saved.settings, DOWNLOAD_FIELDS[field], None) if saved.settings else None + } + + +SPEC_SET = OperationSpec( + id="settings.set", + request=SetReq, + response=SettingChange, + impl=set_, + summary="Change a cloud-synced account setting", + description=( + "`previous` is always reported, so a script can tell 'I changed it' " + "from 'it was already like that'. Premium-only keys pass the server's " + "`PREMIUM_ACCOUNT_REQUIRED` through rather than pretending to succeed." + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("key", "value", "previous", "already"), + headers=("Key", "Value", "Previous", "Already"), + example={"key": "auto-delete", "value": "604800s", "previous": "off"}, + example_args="settings set auto-delete 1w", + covers=( + "appearance.folder-tags", + "appearance.saved-tags", + "business.reenable-ads", + "gift.button-visibility", + "privacy.age-verification", + "privacy.default-ttl", + ), + covers_partial=( + "appearance.default-reaction", + "data.auto-download", + "data.web-browser-settings", + "lang.set", + "privacy.paid-reaction-anonymity", + "privacy.pm-content-protection", + "privacy.sensitive-content", + "privacy.top-peers-suggest", + ), + coverage_note="Reading any of these keys back is `settings get`.", +) + + +# --------------------------------------------------------------------------- +# settings unset +# --------------------------------------------------------------------------- + + +class UnsetReq(Request): + key: Annotated[ + str, arg(0, metavar="KEY", help="top-peers | browser-exception | autosave | saved-tag.") + ] + value: Annotated[ + str | None, + arg(1, metavar="VALUE", required=False, help="Peer, URL or emoji to forget."), + ] = None + category: Annotated[ + str | None, opt("--category", metavar="NAME", help="top-peers: which rating to reset.") + ] = None + every: Annotated[bool, opt("--every", help="Clear every exception for that key.")] = False + + +async def unset(ctx: OpContext, req: UnsetReq) -> SettingUnset: + """Remove a per-peer or per-URL exception, or a single suggestion. + + The opposite of `settings set` only for the keys that *have* exceptions; + everything else has a value and is changed rather than removed, which is + why this is a separate verb and not `settings set X none`. + """ + from telethon.tl.functions import account as afn + + handle = client(ctx) + key = req.key.strip().lower() + + if key == "top-peers": + from tlgr.ops.contact import TopSetReq, top_set + + if not req.value: + raise UsageError("top-peers wants the peer whose rating to reset", field="value") + from tlgr.models.peer import parse_peer_ref + + await top_set( + ctx, + TopSetReq(reset=parse_peer_ref(req.value), category=req.category or "correspondents"), + ) + return SettingUnset(key=key, removed=1, values=[req.value]) + + if key == "browser-exception": + if req.every: + await handle(afn.DeleteWebBrowserSettingsExceptionsRequest()) + return SettingUnset(key=key, removed=-1) + if not req.value: + raise UsageError("browser-exception wants a URL, or --every", field="value") + await handle(afn.ToggleWebBrowserSettingsExceptionRequest(url=req.value, delete=True)) + return SettingUnset(key=key, removed=1, values=[req.value]) + + if key == "autosave": + await handle(afn.DeleteAutoSaveExceptionsRequest()) + return SettingUnset(key=key, removed=-1) + + if key == "saved-tag": + from tlgr.ops.reaction import TagSetReq, tag_set + + if not req.value: + raise UsageError("saved-tag wants the emoji whose title to clear", field="value") + await tag_set(ctx, TagSetReq(emoji=req.value, title="")) + return SettingUnset(key=key, removed=1, values=[req.value]) + + raise UsageError("unset takes top-peers, browser-exception, autosave or saved-tag", field="key") + + +SPEC_UNSET = OperationSpec( + id="settings.unset", + request=UnsetReq, + response=SettingUnset, + impl=unset, + summary="Remove a per-peer/per-URL exception or a single suggestion", + description="`removed: -1` means the server cleared the list without saying how many.", + mutating=True, + idempotent=True, + rate_class="send", + columns=("key", "removed", "values"), + headers=("Key", "Removed", "Values"), + example={"key": "browser-exception", "removed": 1, "values": ["https://example.org"]}, + example_args="settings unset browser-exception https://example.org", + covers=("data.web-browser-settings", "privacy.top-peers-suggest"), + covers_partial=("data.autosave-gallery",), + coverage_note="Setting the autosave rules is `settings autosave set`.", +) + + +# --------------------------------------------------------------------------- +# settings autosave set +# --------------------------------------------------------------------------- + + +class AutosaveSetReq(Request): + scope: Annotated[ + str | None, opt("--scope", metavar="SCOPE", help="users | chats | broadcasts.") + ] = None + peer: Annotated[ + str | None, opt("--peer", metavar="CHAT", help="Set an exception for one chat instead.") + ] = None + photos: Annotated[str | None, opt("--photos", metavar="ON|OFF", help="Auto-save photos.")] = ( + None + ) + videos: Annotated[str | None, opt("--videos", metavar="ON|OFF", help="Auto-save videos.")] = ( + None + ) + max_size: Annotated[ + str | None, opt("--max-size", metavar="SIZE", help="Largest video to auto-save (100M).") + ] = None + clear_exceptions: Annotated[ + bool, opt("--clear-exceptions", help="Drop every per-chat exception.") + ] = False + + +async def autosave_set(ctx: OpContext, req: AutosaveSetReq) -> AutoSaveSaved: + """Save-to-gallery rules for incoming media, per scope or per chat. + + Cloud-synced, so it is genuine parity even for a CLI that has no gallery: + the setting an official client obeys is the one written here, and tlgr's + own downloader can read it. + """ + from telethon.tl.functions import account as fn + + from tlgr.ops.media import AutoSaveSetReq, auto_save_set + + if req.clear_exceptions and not (req.photos or req.videos or req.max_size): + await client(ctx)(fn.DeleteAutoSaveExceptionsRequest()) + return AutoSaveSaved(scope=req.scope or "all", ok=True, cleared_exceptions=True) + + scope = {"users": "users", "chats": "groups", "broadcasts": "channels"}.get( + (req.scope or "users").strip().lower() + ) + if scope is None: + raise UsageError("--scope is users, chats or broadcasts", field="scope") + from tlgr.models.peer import parse_peer_ref + + return await auto_save_set( + ctx, + AutoSaveSetReq( + scope=scope, + chat=parse_peer_ref(req.peer) if req.peer else None, + photos=_settings.on_off(req.photos, field="photos"), + videos=_settings.on_off(req.videos, field="videos"), + video_max=req.max_size, + ), + ) + + +SPEC_AUTOSAVE_SET = OperationSpec( + id="settings.autosave.set", + request=AutosaveSetReq, + response=AutoSaveSaved, + impl=autosave_set, + summary="Save-to-gallery rules for incoming media (per scope or per chat)", + mutating=True, + idempotent=True, + rate_class="send", + columns=("scope", "ok", "cleared_exceptions"), + headers=("Scope", "OK", "Cleared"), + example={"scope": "users", "ok": True, "settings": {"photos": True, "videos": False}}, + example_args="settings autosave set --scope users --photos on", + covers=("data.autosave-gallery",), +) + + +# --------------------------------------------------------------------------- +# settings language list +# --------------------------------------------------------------------------- + + +class LanguageListReq(Request): + pack: Annotated[ + str, opt("--pack", metavar="NAME", help="lang_pack id (android/tdesktop/ios; '' generic).") + ] = "" + code: Annotated[ + str | None, opt("--code", metavar="CODE", help="Fetch one language, custom slugs included.") + ] = None + + +async def language_list(ctx: OpContext, req: LanguageListReq) -> Page[Language]: + """Interface languages the server offers. + + tlgr has no localised UI of its own, but `lang_code` is sent in + `initConnection` and decides the language of *server-side* strings — + service messages, error texts, country names. `settings set language + <code>` is what changes it. + """ + from telethon.tl.functions import langpack as fn + + handle = client(ctx) + if req.code: + rows = [await handle(fn.GetLanguageRequest(lang_pack=req.pack, lang_code=req.code))] + else: + rows = list(await handle(fn.GetLanguagesRequest(lang_pack=req.pack)) or []) + items = [ + Language( + lang_code=str(getattr(row, "lang_code", "") or ""), + name=str(getattr(row, "name", "") or ""), + native_name=str(getattr(row, "native_name", "") or ""), + official=bool(getattr(row, "official", False)), + beta=bool(getattr(row, "beta", False)), + rtl=bool(getattr(row, "rtl", False)), + strings_count=int(getattr(row, "strings_count", 0) or 0), + translated_count=int(getattr(row, "translated_count", 0) or 0), + translations_url=getattr(row, "translations_url", None), + plural_code=getattr(row, "plural_code", None), + base_lang_code=getattr(row, "base_lang_code", None), + ) + for row in rows + ] + return Page(items=items, has_more=False, total=len(items)) + + +SPEC_LANGUAGE_LIST = OperationSpec( + id="settings.language.list", + request=LanguageListReq, + response=Page[Language], + impl=language_list, + summary="List interface languages available on the server", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("lang_code", "name", "native_name", "official", "beta"), + headers=("Code", "Name", "Native", "Official", "Beta"), + example={ + "items": [{"lang_code": "fa", "name": "Persian", "native_name": "فارسی", "official": True}], + "has_more": False, + }, + example_args="settings language list", + covers=("lang.custom-pack", "lang.list", "lang.set"), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# settings theme list / create / install +# --------------------------------------------------------------------------- + + +def _theme_model(raw: Any) -> CloudTheme: + document = getattr(raw, "document", None) + slug = str(getattr(raw, "slug", "") or "") + return CloudTheme( + id=int(getattr(raw, "id", 0) or 0), + access_hash=getattr(raw, "access_hash", None), + slug=slug, + title=str(getattr(raw, "title", "") or ""), + creator=bool(getattr(raw, "creator", False)), + default=bool(getattr(raw, "default", False)), + for_chat=bool(getattr(raw, "for_chat", False)), + installs_count=getattr(raw, "installs_count", None), + document_id=getattr(document, "id", None), + emoticon=getattr(raw, "emoticon", None), + settings=[ + { + "base_theme": type(getattr(entry, "base_theme", None)).__name__, + "accent_color": _settings.color_text(getattr(entry, "accent_color", 0)), + "message_colors": [ + _settings.color_text(value) + for value in getattr(entry, "message_colors", None) or [] + ], + } + for entry in getattr(raw, "settings", None) or [] + ], + link=f"https://t.me/addtheme/{slug}" if slug else None, + ) + + +class ThemeListReq(Request): + slug: Annotated[ + str | None, opt("--slug", metavar="SLUG", help="One theme (a t.me/addtheme link works).") + ] = None + gift: Annotated[bool, opt("--gift", help="Collectible-gift chat themes instead.")] = False + format: Annotated[str, opt("--format", metavar="NAME", help="Theming engine identifier.")] = ( + "tdesktop" + ) + + +async def theme_list(ctx: OpContext, req: ThemeListReq) -> Page[CloudTheme]: + """Cloud themes: installed, one by slug, or the collectible-gift ones. + + Metadata only. A theme is a rendering instruction and a CLI has nothing + to render it with; what is useful is knowing which one is installed and + being able to install another for the phone that shares the account. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + if req.gift: + result = await handle(fn.GetUniqueGiftChatThemesRequest(offset="", limit=100, hash=0)) + rows = [_theme_model(theme) for theme in getattr(result, "themes", None) or []] + return Page(items=rows, has_more=False, total=getattr(result, "count", None)) + + if req.slug: + slug = req.slug.rsplit("/", 1)[-1] + result = await handle( + fn.GetThemeRequest(format=req.format, theme=types.InputThemeSlug(slug=slug)) + ) + return Page(items=[_theme_model(result)], has_more=False, total=1) + + result = await handle(fn.GetThemesRequest(format=req.format, hash=0)) + rows = [_theme_model(theme) for theme in getattr(result, "themes", None) or []] + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_THEME_LIST = OperationSpec( + id="settings.theme.list", + request=ThemeListReq, + response=Page[CloudTheme], + impl=theme_list, + summary="List cloud themes (installed, one by slug, or the collectible-gift themes)", + description="Metadata only: tlgr has no theming engine and renders nothing.", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("id", "slug", "title", "creator", "installs_count"), + headers=("Id", "Slug", "Title", "Mine", "Installs"), + example={ + "items": [{"id": 991, "slug": "Nord", "title": "Nord", "installs_count": 4200}], + "has_more": False, + }, + example_args="settings theme list", + covers=("theme.cloud-themes", "theme.get", "theme.gift-chat-themes", "theme.list-cloud"), + tags=frozenset({"agent-safe"}), +) + + +class ThemeCreateReq(Request): + title: Annotated[str | None, arg(0, metavar="TITLE", required=False, help="Theme title.")] = ( + None + ) + slug: Annotated[ + str | None, opt("--slug", metavar="SLUG", help="Public slug; an existing one edits it.") + ] = None + file: Annotated[ + str | None, opt("--file", metavar="PATH", kind="path", help="Theme file to upload.") + ] = None + base: Annotated[ + str | None, + opt("--base", metavar="NAME", help="classic | day | night | tinted | arctic."), + ] = None + accent: Annotated[ + str | None, opt("--accent", metavar="COLOR", help="Accent colour, #RRGGBB.") + ] = None + outbox_accent: Annotated[ + str | None, opt("--outbox-accent", metavar="COLOR", help="Outgoing accent colour.") + ] = None + message_colors: Annotated[ + str | None, opt("--message-colors", metavar="LIST", help="Message gradient colours.") + ] = None + wallpaper: Annotated[ + str | None, opt("--wallpaper", metavar="SLUG", help="Wallpaper for the theme settings.") + ] = None + dark: Annotated[bool, opt("--dark", help="Mark the settings vector as the dark variant.")] = ( + False + ) + + +_BASE_THEMES = { + "classic": "BaseThemeClassic", + "day": "BaseThemeDay", + "night": "BaseThemeNight", + "tinted": "BaseThemeTinted", + "arctic": "BaseThemeArctic", +} + + +async def theme_create(ctx: OpContext, req: ThemeCreateReq) -> ThemeInstalled: + """Publish or edit a cloud theme you own. + + Editing is creator-only, and a CLI cannot author or preview a theme file + — it can upload one somebody made and give it a public slug, which is the + part that needs an account. + """ + import mimetypes + + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + document: Any = None + if req.file: + path = Path(os.path.expanduser(req.file)) + if not path.exists(): + raise UsageError(f"{req.file} does not exist", field="file") + 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") + uploaded = await handle( + fn.UploadThemeRequest( + file=await upload(path), + file_name=path.name, + mime_type=mimetypes.guess_type(path.name)[0] or "application/x-tgtheme", + ) + ) + document = types.InputDocument( + id=getattr(uploaded, "id", 0), + access_hash=getattr(uploaded, "access_hash", 0), + file_reference=getattr(uploaded, "file_reference", b"") or b"", + ) + + settings = _theme_settings(req) + if req.slug: + existing = await theme_list(ctx, ThemeListReq(slug=req.slug)) + if not existing.items: + raise NotFoundError(f"no theme with the slug {req.slug!r}") + theme = existing.items[0] + result = await handle( + fn.UpdateThemeRequest( + format="tdesktop", + theme=types.InputTheme(id=theme.id, access_hash=theme.access_hash or 0), + slug=req.slug, + title=req.title, + document=document, + settings=settings, + ) + ) + else: + if not req.title: + raise UsageError("give a TITLE (or --slug to edit an existing theme)", field="title") + result = await handle( + fn.CreateThemeRequest(slug="", title=req.title, document=document, settings=settings) + ) + model = _theme_model(result) + ctx.emit("theme_created", {"slug": model.slug}) + return ThemeInstalled( + slug=model.slug, id=model.id, title=model.title, document_id=model.document_id + ) + + +def _theme_settings(req: ThemeCreateReq) -> list[Any] | None: + from telethon.tl import types + + if req.base is None: + return None + if req.base not in _BASE_THEMES: + raise UsageError(f"--base is one of: {' '.join(sorted(_BASE_THEMES))}", field="base") + return [ + types.InputThemeSettings( + base_theme=getattr(types, _BASE_THEMES[req.base])(), + accent_color=_settings.color_int(req.accent, field="accent") or 0, + outbox_accent_color=_settings.color_int(req.outbox_accent, field="outbox_accent"), + message_colors=[ + value + for part in (req.message_colors or "").split(",") + if part.strip() and (value := _settings.color_int(part, field="message_colors")) + ] + or None, + wallpaper=types.InputWallPaperSlug(slug=req.wallpaper) if req.wallpaper else None, + ) + ] + + +SPEC_THEME_CREATE = OperationSpec( + id="settings.theme.create", + request=ThemeCreateReq, + response=ThemeInstalled, + impl=theme_create, + summary="Publish or edit a cloud theme you own", + mutating=True, + rate_class="file", + timeout_s=300, + columns=("slug", "id", "title", "document_id"), + headers=("Slug", "Id", "Title", "Document"), + example={"slug": "Nord", "id": 991, "title": "Nord"}, + example_args="settings theme create Nord --file nord.tdesktop-theme", + covers=("theme.create", "theme.update"), +) + + +class ThemeInstallReq(Request): + slug: Annotated[str, arg(0, metavar="SLUG", help="The theme to install or save.")] + dark: Annotated[bool, opt("--dark", help="Install it as the dark theme.")] = False + save: Annotated[bool, opt("--save/--no-save", help="Also add it to the saved list.")] = True + remove: Annotated[bool, opt("--remove", help="Remove it from the saved list instead.")] = False + format: Annotated[str, opt("--format", metavar="NAME", help="Theming engine identifier.")] = ( + "tdesktop" + ) + + +async def theme_install(ctx: OpContext, req: ThemeInstallReq) -> ThemeInstalled: + """Install, save or remove a cloud theme for this account. + + Server-side bookkeeping shared with the GUI clients: what tlgr changes + here is what the phone signed into the same account will draw. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + slug = req.slug.rsplit("/", 1)[-1] + theme = types.InputThemeSlug(slug=slug) + + if req.remove: + await handle(fn.SaveThemeRequest(theme=theme, unsave=True)) + return ThemeInstalled(slug=slug, removed=True) + + if req.save: + await handle(fn.SaveThemeRequest(theme=theme, unsave=False)) + await handle(fn.InstallThemeRequest(dark=req.dark or None, theme=theme, format=req.format)) + ctx.emit("theme_installed", {"slug": slug, "dark": req.dark}) + return ThemeInstalled(slug=slug, installed=True, saved=req.save, dark=req.dark) + + +SPEC_THEME_INSTALL = OperationSpec( + id="settings.theme.install", + request=ThemeInstallReq, + response=ThemeInstalled, + impl=theme_install, + summary="Install / save / remove a cloud theme for this account", + mutating=True, + idempotent=True, + rate_class="send", + columns=("slug", "installed", "saved", "removed"), + headers=("Slug", "Installed", "Saved", "Removed"), + example={"slug": "Nord", "installed": True, "saved": True}, + example_args="settings theme install Nord", + covers=("theme.save-install",), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] From 60f87b3126cc28e05a538c4c074f24c32b3032d6 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 07:11:56 +0330 Subject: [PATCH 05/15] business ops: fourteen operations, and the most dangerous switch in tlgr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram Business, whole: opening hours, location and chat intro, the greeting and away messages, the quick replies they send, the public chat links, and the chatbot that may act on the account's behalf. `business bot set` grants another program the right to read my messages, reply as me, rewrite my name, bio and photo, manage my gifts and transfer my Stars. So every right is opt-in by name — there is deliberately no `--all` — the operation is destructive, and the reply enumerates exactly what was granted, because an audit should not have to trust the flags somebody typed. Opening hours are minutes-of-week arithmetic and the easy thing to get wrong. `--open 'mon-fri 09:00-18:00'` is expanded, sorted and merged before it is sent, because the server rejects overlapping intervals and writing two lines for the same day is the normal way a human produces them; a range that ends before it starts is a usage error rather than a silent wrap, since "22:00-02:00" almost always means the next day and guessing is worse than asking. `business stars transfer` prices the transfer and refuses to make it. PR-10 settled that tlgr never signs a payment form; this group inherits the policy rather than opening a second door onto the same money. --- tlgr/models/__init__.py | 2 + tlgr/models/business.py | 28 +- tlgr/models/premium.py | 6 +- tlgr/models/profile.py | 4 +- tlgr/ops/business.py | 1518 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1554 insertions(+), 4 deletions(-) create mode 100644 tlgr/ops/business.py diff --git a/tlgr/models/__init__.py b/tlgr/models/__init__.py index 74386ec..8a11870 100644 --- a/tlgr/models/__init__.py +++ b/tlgr/models/__init__.py @@ -150,6 +150,7 @@ BusinessGreeting, BusinessIntro, BusinessLocation, + BusinessMessage, BusinessOpen, BusinessProfile, BusinessRecipients, @@ -700,6 +701,7 @@ "BusinessGreeting", "BusinessIntro", "BusinessLocation", + "BusinessMessage", "BusinessOpen", "BusinessProfile", "BusinessRecipients", diff --git a/tlgr/models/business.py b/tlgr/models/business.py index e646b57..2c86905 100644 --- a/tlgr/models/business.py +++ b/tlgr/models/business.py @@ -15,6 +15,8 @@ from __future__ import annotations +from typing import Any + from tlgr.models.base import Model from tlgr.models.message import MessageEntity from tlgr.models.peer import Peer @@ -27,6 +29,7 @@ "BusinessGreeting", "BusinessIntro", "BusinessLocation", + "BusinessMessage", "BusinessOpen", "BusinessProfile", "BusinessRecipients", @@ -108,6 +111,29 @@ class BusinessAway(Model): enabled: bool = True +class BusinessMessage(Model): + """`business message set`, whichever of the two it configured. + + One shape for both, because a caller that just switched a greeting on + should not have to know that the away message answers with a different + struct — and the difference between them is two fields, not two ideas. + """ + + #: greeting | away + kind: str = "greeting" + shortcut_id: int = 0 + shortcut: str | None = None + #: away only: always | outside-hours | custom + schedule: str | None = None + since: str | None = None + until: str | None = None + offline_only: bool = False + #: greeting only. + no_activity_days: int | None = None + recipients: BusinessRecipients | None = None + enabled: bool = True + + class BotRights(Model): """`businessBotRights`, every flag named. Absent means "not granted".""" @@ -212,7 +238,7 @@ class BusinessProfile(Model): sponsored_enabled: bool | None = None connected_bots: list[BotConnection] = [] chat_links: list[ChatLink] = [] - timezones: list[dict[str, object]] = [] + timezones: list[dict[str, Any]] = [] premium: bool = False diff --git a/tlgr/models/premium.py b/tlgr/models/premium.py index c3d9cfe..6c88a80 100644 --- a/tlgr/models/premium.py +++ b/tlgr/models/premium.py @@ -12,6 +12,8 @@ from __future__ import annotations +from typing import Any + from tlgr.models.base import Model from tlgr.models.peer import Peer @@ -51,11 +53,11 @@ class PremiumLimit(Model): class PremiumFeatures(Model): status_text: str = "" - period_options: list[dict[str, object]] = [] + period_options: list[dict[str, Any]] = [] video_sections: list[str] = [] limits: list[PremiumLimit] = [] #: Assembled from `channel_*_level_min` / `group_*_level_min`. - boost_levels: list[dict[str, object]] = [] + boost_levels: list[dict[str, Any]] = [] channel_level: int | None = None diff --git a/tlgr/models/profile.py b/tlgr/models/profile.py index 48321cf..e7499d3 100644 --- a/tlgr/models/profile.py +++ b/tlgr/models/profile.py @@ -19,6 +19,8 @@ from __future__ import annotations +from typing import Any + from tlgr.models.base import Model from tlgr.models.peer import Peer @@ -199,7 +201,7 @@ class ProfileLink(Model): qr: str | None = None qr_path: str | None = None #: `fragment.getCollectibleInfo`, when `--collectible` was given. - collectible: dict[str, object] | None = None + collectible: dict[str, Any] | None = None resolvable_by_strangers: bool = True diff --git a/tlgr/ops/business.py b/tlgr/ops/business.py new file mode 100644 index 0000000..1fd76ec --- /dev/null +++ b/tlgr/ops/business.py @@ -0,0 +1,1518 @@ +"""The `business` group: Telegram Business, and the bot that may act as me. + +Six sub-nouns behind one Settings ▸ Telegram Business screen: opening hours, +location and chat intro (`business set`), the greeting and away messages +(`business message set`), the quick replies they send (`business reply *`), +the public chat links (`business link *`) and the connected chatbot +(`business bot *`). + +Two things deserve their own paragraph. + +**The connected bot is the most dangerous switch in tlgr.** `businessBotRights` +grants another program the ability to read my messages, reply as me, edit my +name, bio and photo, manage my gifts and *transfer my Stars*. So every right +is opt-in by name — there is no `--all` — the command is destructive (so `-y` +is required off a TTY), and the reply enumerates exactly what was granted. + +**Opening hours are minutes-of-week arithmetic, and it is easy to get wrong.** +The server stores intervals as minutes since Monday 00:00 in the business's +own timezone; an interval that runs past midnight on Sunday wraps. `_hours` +sorts, merges and clamps, and `open_now` is server-set and never sent back. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from tlgr.core.errors import NotFoundError, UsageError +from tlgr.core.pagination import PageKind +from tlgr.core.timefmt import fmt_dt, parse_dt +from tlgr.models.base import Request +from tlgr.models.business import ( + BotConnection, + BotPaused, + BotRights, + BusinessAway, + BusinessGreeting, + BusinessIntro, + BusinessLocation, + BusinessMessage, + BusinessOpen, + BusinessProfile, + BusinessRecipients, + BusinessSet, + ChatLink, + ChatLinkSet, + QuickReply, + QuickReplyMessage, + QuickReplySent, + QuickReplySet, + StarsTransferQuote, + WorkHours, +) +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _settings +from tlgr.ops._common import client, random_id +from tlgr.ops._params import arg, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +DAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") +WEEK_MINUTES = 7 * 24 * 60 + +#: `--flag` → the `businessBotRights` field it grants. Spelled out rather than +#: generated, because this is the list a person audits before saying yes. +BOT_RIGHTS: dict[str, str] = { + "reply_to": "reply", + "read": "read_messages", + "delete_sent": "delete_sent_messages", + "delete_received": "delete_received_messages", + "edit_name": "edit_name", + "edit_bio": "edit_bio", + "edit_username": "edit_username", + "edit_photo": "edit_profile_photo", + "manage_gifts": "view_gifts", + "transfer_stars": "transfer_stars", + "manage_stories": "manage_stories", +} + + +# --------------------------------------------------------------------------- +# Shared shapes +# --------------------------------------------------------------------------- + + +def _minutes(text: str, *, field: str) -> int: + """`09:00` → 540. A bare hour (`9`) is accepted; anything else is an error.""" + raw = text.strip() + hours, _, mins = raw.partition(":") + if not hours.isdigit() or (mins and not mins.isdigit()): + raise UsageError(f"{text!r} is not a time of day (HH:MM)", field=field) + total = int(hours) * 60 + int(mins or 0) + if total > 24 * 60: + raise UsageError(f"{text!r} is not a time of day (HH:MM)", field=field) + return total + + +def _hours(entries: list[str]) -> list[Any]: + """`['mon 09:00-18:00', 'tue-fri 09:00-13:00,14:00-18:00']` as weekly opens. + + Sorted and merged, because the server rejects overlapping intervals and a + human writing two lines for the same day is the normal way to produce + them. Intervals are clamped to one week; an interval that ends before it + starts is a usage error rather than a silent wrap, since "22:00-02:00" + almost always means the next day and guessing is worse than asking. + """ + from telethon.tl import types + + spans: list[tuple[int, int]] = [] + for entry in entries: + days_part, _, ranges_part = entry.strip().partition(" ") + if not ranges_part: + raise UsageError( + f"--open takes '<day[-day]> HH:MM-HH:MM[,HH:MM-HH:MM]'; got {entry!r}", + field="open", + ) + days = _days_of(days_part) + for span in ranges_part.split(","): + start_text, _, end_text = span.partition("-") + if not end_text: + raise UsageError(f"{span!r} is not a time range", field="open") + start, end = _minutes(start_text, field="open"), _minutes(end_text, field="open") + if end <= start: + raise UsageError( + f"{span!r} ends before it starts; write the two days separately", + field="open", + ) + for day in days: + spans.append((day * 24 * 60 + start, day * 24 * 60 + end)) + + merged: list[tuple[int, int]] = [] + for start, end in sorted(spans): + if merged and start <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + return [ + types.BusinessWeeklyOpen(start_minute=start, end_minute=min(end, WEEK_MINUTES)) + for start, end in merged + ] + + +def _days_of(text: str) -> list[int]: + """`mon`, `mon-fri` or `mon,wed` as day indices.""" + out: list[int] = [] + for part in text.lower().split(","): + start, _, end = part.partition("-") + if start not in DAYS: + raise UsageError(f"{part!r} is not a weekday (mon…sun)", field="open") + if not end: + out.append(DAYS.index(start)) + continue + if end not in DAYS: + raise UsageError(f"{part!r} is not a weekday range", field="open") + first, last = DAYS.index(start), DAYS.index(end) + out.extend(range(first, last + 1) if first <= last else []) + return sorted(set(out)) + + +def _hours_model(raw: Any) -> WorkHours | None: + if raw is None: + return None + opens = [] + for entry in getattr(raw, "weekly_open", None) or []: + start = int(getattr(entry, "start_minute", 0) or 0) + end = int(getattr(entry, "end_minute", 0) or 0) + opens.append( + BusinessOpen( + start_minute=start, + end_minute=end, + day=DAYS[min(start // (24 * 60), 6)], + open=f"{start % (24 * 60) // 60:02d}:{start % 60:02d}", + close=f"{end % (24 * 60) // 60:02d}:{end % 60:02d}", + ) + ) + return WorkHours( + timezone_id=str(getattr(raw, "timezone_id", "") or ""), + weekly_open=opens, + open_now=getattr(raw, "open_now", None), + ) + + +def _recipients_model(raw: Any) -> BusinessRecipients | None: + if raw is None: + return None + return BusinessRecipients( + contacts=bool(getattr(raw, "contacts", False)), + non_contacts=bool(getattr(raw, "non_contacts", False)), + existing_chats=bool(getattr(raw, "existing_chats", False)), + new_chats=bool(getattr(raw, "new_chats", False)), + exclude_selected=bool(getattr(raw, "exclude_selected", False)), + users=[int(v) for v in getattr(raw, "users", None) or []], + exclude_users=[int(v) for v in getattr(raw, "exclude_users", None) or []], + ) + + +def _rights_model(raw: Any) -> BotRights | None: + if raw is None: + return None + return BotRights( + **{field: bool(getattr(raw, field, False)) for field in BotRights.__struct_fields__} + ) + + +def _bot_model(raw: Any, known: dict[int, Any]) -> BotConnection: + bot_id = int(getattr(raw, "bot_id", 0) or 0) + return BotConnection( + bot_id=bot_id, + bot=_settings.peer_model(known.get(bot_id)), + connection_id=getattr(raw, "connection_id", None), + recipients=_recipients_model(getattr(raw, "recipients", None)), + rights=_rights_model(getattr(raw, "rights", None)), + paused=bool(getattr(raw, "paused", False)), + confirmed=not bool(getattr(raw, "can_reply", None) is False), + disabled=bool(getattr(raw, "disabled", False)), + date=fmt_dt(getattr(raw, "date", None)), + dc_id=getattr(raw, "dc_id", None), + ) + + +async def _self_full(ctx: OpContext) -> Any: + from telethon.tl import types + from telethon.tl.functions import users as fn + + answer = await client(ctx)(fn.GetFullUserRequest(id=types.InputUserSelf())) + return getattr(answer, "full_user", None) + + +def _greeting_model(raw: Any) -> BusinessGreeting | None: + if raw is None: + return None + return BusinessGreeting( + shortcut_id=int(getattr(raw, "shortcut_id", 0) or 0), + no_activity_days=int(getattr(raw, "no_activity_days", 0) or 0), + recipients=_recipients_model(getattr(raw, "recipients", None)), + ) + + +def _away_model(raw: Any) -> BusinessAway | None: + if raw is None: + return None + schedule = getattr(raw, "schedule", None) + name = type(schedule).__name__ + word = { + "BusinessAwayMessageScheduleAlways": "always", + "BusinessAwayMessageScheduleOutsideWorkHours": "outside-hours", + "BusinessAwayMessageScheduleCustom": "custom", + }.get(name, "always") + return BusinessAway( + shortcut_id=int(getattr(raw, "shortcut_id", 0) or 0), + schedule=word, + since=fmt_dt(getattr(schedule, "start_date", None)), + until=fmt_dt(getattr(schedule, "end_date", None)), + offline_only=bool(getattr(raw, "offline_only", False)), + recipients=_recipients_model(getattr(raw, "recipients", None)), + ) + + +def _link_model(raw: Any) -> ChatLink: + from tlgr.ops._serialize import message_entities + + slug = str(getattr(raw, "link", "") or "").rsplit("/", 1)[-1] + return ChatLink( + slug=slug, + link=str(getattr(raw, "link", "") or ""), + title=getattr(raw, "title", None), + message=str(getattr(raw, "message", "") or ""), + entities=message_entities(raw), + views=getattr(raw, "views", None), + ) + + +# --------------------------------------------------------------------------- +# business get / set +# --------------------------------------------------------------------------- + + +class GetReq(Request): + timezones: Annotated[ + bool, opt("--timezones", help="Also print the timezone ids `business set --tz` takes.") + ] = False + + +async def get(ctx: OpContext, req: GetReq) -> BusinessProfile: + """My Telegram Business configuration, in one object. + + Everything but the chat links and the connected bots lives in `userFull` + on self, which is why one command can answer the whole screen. Business + needs Premium; connected bots are the exception and work without it. + """ + from telethon.tl.functions import account as afn + from telethon.tl.functions import help as hfn + + handle = client(ctx) + full = await _self_full(ctx) + profile = BusinessProfile( + work_hours=_hours_model(getattr(full, "business_work_hours", None)), + location=_location_model(getattr(full, "business_location", None)), + greeting=_greeting_model(getattr(full, "business_greeting_message", None)), + away=_away_model(getattr(full, "business_away_message", None)), + intro=_intro_model(getattr(full, "business_intro", None)), + sponsored_enabled=getattr(full, "sponsored_enabled", None), + ) + if profile.work_hours is not None: + profile.open_now = profile.work_hours.open_now + + try: + bots = await handle(afn.GetConnectedBotsRequest()) + except Exception as exc: + ctx.warn(f"connected bots are unavailable on this account: {exc}") + else: + known = _settings.entity_map(bots) + profile.connected_bots = [ + _bot_model(row, known) for row in getattr(bots, "connected_bots", None) or [] + ] + + try: + links = await handle(afn.GetBusinessChatLinksRequest()) + except Exception as exc: + ctx.warn(f"business chat links are unavailable on this account: {exc}") + else: + profile.chat_links = [_link_model(row) for row in getattr(links, "links", None) or []] + + if req.timezones: + zones = await handle(hfn.GetTimezonesListRequest(hash=0)) + profile.timezones = [ + { + "id": getattr(zone, "id", ""), + "name": getattr(zone, "name", ""), + "utc_offset": getattr(zone, "utc_offset", 0), + } + for zone in getattr(zones, "timezones", None) or [] + ] + me = await handle.get_me() + profile.premium = bool(getattr(me, "premium", False)) + return profile + + +def _location_model(raw: Any) -> BusinessLocation | None: + if raw is None: + return None + geo = getattr(raw, "geo_point", None) + return BusinessLocation( + address=str(getattr(raw, "address", "") or ""), + lat=getattr(geo, "lat", None), + lon=getattr(geo, "long", None), + ) + + +def _intro_model(raw: Any) -> BusinessIntro | None: + if raw is None: + return None + return BusinessIntro( + title=str(getattr(raw, "title", "") or ""), + description=str(getattr(raw, "description", "") or ""), + sticker_id=getattr(getattr(raw, "sticker", None), "id", None), + ) + + +SPEC_GET = OperationSpec( + id="business.get", + request=GetReq, + response=BusinessProfile, + impl=get, + summary="Show my Telegram Business configuration", + idempotent=True, + columns=("premium", "open_now", "work_hours.timezone_id", "location.address"), + headers=("Premium", "Open now", "Timezone", "Address"), + example={ + "premium": True, + "open_now": True, + "work_hours": {"timezone_id": "Europe/Amsterdam", "weekly_open": []}, + }, + example_args="business get", + covers=("business.overview",), + tags=frozenset({"agent-safe"}), +) + + +class SetReq(Request): + tz: Annotated[ + str | None, opt("--tz", metavar="ID", help="Timezone id from `business get --timezones`.") + ] = None + open: Annotated[ + tuple[str, ...], + opt("--open", metavar="SPEC", help="Repeatable: 'mon 09:00-18:00'."), + ] = () + clear_hours: Annotated[bool, opt("--clear-hours", help="Remove the opening hours.")] = False + address: Annotated[ + str | None, opt("--address", metavar="TEXT", help="Business address (<= 96 chars).") + ] = None + lat: Annotated[float | None, opt("--lat", metavar="DEG", help="Latitude.")] = None + lon: Annotated[float | None, opt("--lon", metavar="DEG", help="Longitude.")] = None + clear_location: Annotated[bool, opt("--clear-location", help="Remove the location.")] = False + intro_title: Annotated[ + str | None, opt("--intro-title", metavar="TEXT", help="Chat intro title.") + ] = None + intro_text: Annotated[ + str | None, opt("--intro-text", metavar="TEXT", help="Chat intro description.") + ] = None + intro_sticker: Annotated[ + str | None, + opt( + "--intro-sticker", + metavar="SET/REF", + help="Sticker as <set>/<index> or <set>/<emoji>.", + ), + ] = None + clear_intro: Annotated[ + bool, opt("--clear-intro", help="Revert to the random default intro.") + ] = False + + +async def set_(ctx: OpContext, req: SetReq) -> BusinessSet: + """Set opening hours, business location and chat intro. + + Three RPCs behind one screen, and each `--clear-*` sends the constructor + *without* its field — which is how the API deletes one. Omitting a flag + leaves that struct untouched, so the three are independent. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + result = BusinessSet() + + if req.clear_hours: + await handle(fn.UpdateBusinessWorkHoursRequest(business_work_hours=None)) + result.changed.append("work_hours") + elif req.open: + if not req.tz: + raise UsageError("--open needs --tz <timezone id>", field="tz") + hours = types.BusinessWorkHours(timezone_id=req.tz, weekly_open=_hours(list(req.open))) + await handle(fn.UpdateBusinessWorkHoursRequest(business_work_hours=hours)) + result.work_hours = _hours_model(hours) + result.changed.append("work_hours") + + if req.clear_location: + await handle(fn.UpdateBusinessLocationRequest()) + result.changed.append("location") + elif req.address or req.lat is not None or req.lon is not None: + if not req.address: + raise UsageError("a business location needs --address", field="address") + if len(req.address) > 96: + raise UsageError("--address is at most 96 characters", field="address") + geo = ( + types.InputGeoPoint(lat=req.lat, long=req.lon) + if req.lat is not None and req.lon is not None + else None + ) + await handle(fn.UpdateBusinessLocationRequest(geo_point=geo, address=req.address)) + result.location = BusinessLocation(address=req.address, lat=req.lat, lon=req.lon) + result.changed.append("location") + + if req.clear_intro: + await handle(fn.UpdateBusinessIntroRequest(intro=None)) + result.changed.append("intro") + elif req.intro_title is not None or req.intro_text is not None: + sticker = None + sticker_id = None + if req.intro_sticker: + from tlgr.ops import _media + + # Always through a fresh `getStickerSet`: an InputDocument built + # from a remembered id carries a dead file_reference. + document = (await _media.resolve_stickers(ctx, [req.intro_sticker]))[0] + sticker = _media.input_document(document) + sticker_id = int(getattr(document, "id", 0) or 0) + intro = types.InputBusinessIntro( + title=req.intro_title or "", description=req.intro_text or "", sticker=sticker + ) + await handle(fn.UpdateBusinessIntroRequest(intro=intro)) + result.intro = BusinessIntro( + title=intro.title, description=intro.description, sticker_id=sticker_id + ) + result.changed.append("intro") + + if not result.changed: + raise UsageError( + "nothing to change: give --open/--tz, --address or --intro-title", field="open" + ) + ctx.emit("business_set", {"changed": result.changed}) + return result + + +SPEC_SET = OperationSpec( + id="business.set", + request=SetReq, + response=BusinessSet, + impl=set_, + summary="Set opening hours, business location and chat intro", + description=( + "`--clear-*` sends the constructor without its field, which is how " + "the API deletes one; a flag you omit leaves that struct alone." + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("changed", "work_hours.timezone_id", "location.address"), + headers=("Changed", "Timezone", "Address"), + example={"changed": ["work_hours"], "work_hours": {"timezone_id": "Europe/Amsterdam"}}, + example_args="business set --tz Europe/Amsterdam --open 'mon-fri 09:00-18:00'", + covers=( + "business.intro", + "business.location", + "business.working-hours", + "location.business-address", + ), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# business message set +# --------------------------------------------------------------------------- + + +class MessageSetReq(Request): + kind: Annotated[str, arg(0, metavar="KIND", help="greeting or away.")] + shortcut: Annotated[ + str | None, opt("--shortcut", metavar="NAME", help="Quick-reply shortcut to send.") + ] = None + schedule: Annotated[ + str | None, + opt("--schedule", metavar="WHEN", help="away: always | outside-hours | custom."), + ] = None + since: Annotated[ + str | None, opt("--since", metavar="WHEN", kind="datetime", help="custom schedule start.") + ] = None + until: Annotated[ + str | None, opt("--until", metavar="WHEN", kind="datetime", help="custom schedule end.") + ] = None + offline_only: Annotated[ + bool, opt("--offline-only", help="away: only send while I am offline.") + ] = False + no_activity_days: Annotated[ + int, opt("--no-activity-days", metavar="N", help="greeting: silence after N quiet days.") + ] = 7 + contacts: Annotated[bool, opt("--contacts", help="Include contacts.")] = False + non_contacts: Annotated[bool, opt("--non-contacts", help="Include non-contacts.")] = False + existing_chats: Annotated[bool, opt("--existing-chats", help="Include existing chats.")] = False + new_chats: Annotated[bool, opt("--new-chats", help="Include new chats.")] = False + users: Annotated[ + str | None, opt("--users", metavar="LIST", help="Explicit recipient users.") + ] = None + exclude: Annotated[bool, opt("--exclude", help="Treat the selection as an exclusion.")] = False + exclude_users: Annotated[ + str | None, opt("--exclude-users", metavar="LIST", help="away: users to exclude.") + ] = None + off: Annotated[bool, opt("--off", help="Disable this message.")] = False + + +async def _recipients_tl(ctx: OpContext, req: MessageSetReq, *, bot: bool = False) -> Any: + from telethon.tl import types + + users = [ + await _settings.input_user(ctx, part.strip(), field="users") + for part in (req.users or "").split(",") + if part.strip() + ] + kwargs: dict[str, Any] = { + "existing_chats": req.existing_chats or None, + "new_chats": req.new_chats or None, + "contacts": req.contacts or None, + "non_contacts": req.non_contacts or None, + "exclude_selected": req.exclude or None, + "users": users or None, + } + if bot: + kwargs["exclude_users"] = [ + await _settings.input_user(ctx, part.strip(), field="exclude_users") + for part in (req.exclude_users or "").split(",") + if part.strip() + ] or None + return types.InputBusinessBotRecipients(**kwargs) + return types.InputBusinessRecipients(**kwargs) + + +async def message_set(ctx: OpContext, req: MessageSetReq) -> BusinessMessage: + """Configure the greeting message or the away message. + + Both are "send this quick reply to these people", which is why they share + one command and one recipients shape. Omitting `--shortcut` (or passing + `--off`) disables the feature — the API expresses that by sending no + message at all. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + kind = req.kind.strip().lower() + if kind not in ("greeting", "away"): + raise UsageError("KIND is `greeting` or `away`", field="kind") + + if req.off or not req.shortcut: + request = ( + fn.UpdateBusinessGreetingMessageRequest + if kind == "greeting" + else fn.UpdateBusinessAwayMessageRequest + ) + await handle(request(message=None)) + ctx.emit("business_message", {"kind": kind, "enabled": False}) + return BusinessMessage(kind=kind, enabled=False) + + shortcut_id = await _shortcut_id(ctx, req.shortcut) + recipients = await _recipients_tl(ctx, req) + + if kind == "greeting": + message = types.InputBusinessGreetingMessage( + shortcut_id=shortcut_id, + recipients=recipients, + no_activity_days=req.no_activity_days, + ) + await handle(fn.UpdateBusinessGreetingMessageRequest(message=message)) + ctx.emit("business_message", {"kind": kind, "shortcut_id": shortcut_id}) + return BusinessMessage( + kind=kind, + shortcut_id=shortcut_id, + shortcut=req.shortcut, + no_activity_days=req.no_activity_days, + recipients=_recipients_model(recipients), + ) + + word = (req.schedule or "always").strip().lower() + if word == "always": + schedule: Any = types.BusinessAwayMessageScheduleAlways() + elif word == "outside-hours": + schedule = types.BusinessAwayMessageScheduleOutsideWorkHours() + elif word == "custom": + if not req.since or not req.until: + raise UsageError("--schedule custom needs --since and --until", field="since") + schedule = types.BusinessAwayMessageScheduleCustom( + start_date=parse_dt(req.since), end_date=parse_dt(req.until) + ) + else: + raise UsageError("--schedule is always, outside-hours or custom", field="schedule") + + message = types.InputBusinessAwayMessage( + shortcut_id=shortcut_id, + schedule=schedule, + recipients=recipients, + offline_only=req.offline_only or None, + ) + await handle(fn.UpdateBusinessAwayMessageRequest(message=message)) + ctx.emit("business_message", {"kind": kind, "shortcut_id": shortcut_id}) + return BusinessMessage( + kind=kind, + shortcut_id=shortcut_id, + shortcut=req.shortcut, + schedule=word, + since=fmt_dt(getattr(schedule, "start_date", None)), + until=fmt_dt(getattr(schedule, "end_date", None)), + offline_only=req.offline_only, + recipients=_recipients_model(recipients), + ) + + +async def _shortcut_id(ctx: OpContext, name: str) -> int: + """A quick-reply shortcut's id, from its name or its id.""" + if name.strip().isdigit(): + return int(name) + page = await reply_list(ctx, ReplyListReq()) + for row in page.items: + if row.shortcut == name.strip(): + return row.shortcut_id + raise NotFoundError( + f"no quick-reply shortcut named {name!r}; create one with `business reply add`" + ) + + +SPEC_MESSAGE_SET = OperationSpec( + id="business.message.set", + request=MessageSetReq, + response=BusinessMessage, + impl=message_set, + summary="Configure the greeting message or the away message", + description=( + "Both need an existing quick-reply shortcut; `--schedule outside-hours` " + "additionally needs opening hours. Omitting `--shortcut` disables the " + "feature, which is how the API expresses 'off'." + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("kind", "shortcut_id", "shortcut", "schedule", "enabled"), + headers=("Kind", "Shortcut", "Name", "Schedule", "Enabled"), + example={ + "kind": "greeting", + "shortcut_id": 3, + "shortcut": "hello", + "no_activity_days": 7, + "enabled": True, + }, + example_args="business message set greeting --shortcut hello --new-chats", + covers=( + "business.away-message", + "business.greeting-message", + "contacts-users.user-business-greeting-away", + ), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# business reply list / add / edit / delete / send +# --------------------------------------------------------------------------- + + +class ReplyListReq(Request): + shortcut: Annotated[ + str | None, + arg(0, metavar="SHORTCUT", required=False, help="Show the messages inside one shortcut."), + ] = None + + +async def reply_list(ctx: OpContext, req: ReplyListReq) -> Page[QuickReply]: + """List quick-reply shortcuts, or the messages inside one. + + Quick-reply messages have their own id sequence, unrelated to chat message + ids — `msg_id` here is only meaningful inside its shortcut. + """ + from telethon.tl.functions import messages as fn + + handle = client(ctx) + result = await handle(fn.GetQuickRepliesRequest(hash=0)) + rows = [ + QuickReply( + shortcut_id=int(getattr(row, "shortcut_id", 0) or 0), + shortcut=str(getattr(row, "shortcut", "") or ""), + count=int(getattr(row, "count", 0) or 0), + top_message=getattr(row, "top_message", None), + ) + for row in getattr(result, "quick_replies", None) or [] + ] + if req.shortcut: + wanted = req.shortcut.strip() + rows = [row for row in rows if row.shortcut == wanted or str(row.shortcut_id) == wanted] + if not rows: + raise NotFoundError(f"no quick-reply shortcut named {req.shortcut!r}") + messages = await handle( + fn.GetQuickReplyMessagesRequest(shortcut_id=rows[0].shortcut_id, hash=0) + ) + rows[0].messages = [_quick_message(m) for m in getattr(messages, "messages", None) or []] + return Page(items=rows, has_more=False, total=len(rows)) + + +def _quick_message(raw: Any) -> QuickReplyMessage: + from tlgr.ops._serialize import media_summary, message_entities + + summary = media_summary(getattr(raw, "media", None)) + return QuickReplyMessage( + id=int(getattr(raw, "id", 0) or 0), + text=str(getattr(raw, "message", "") or ""), + entities=message_entities(raw), + media=summary.kind if summary is not None else None, + date=fmt_dt(getattr(raw, "date", None)), + ) + + +SPEC_REPLY_LIST = OperationSpec( + id="business.reply.list", + request=ReplyListReq, + response=Page[QuickReply], + impl=reply_list, + summary="List quick-reply shortcuts, or the messages inside one", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("shortcut_id", "shortcut", "count", "top_message"), + headers=("Id", "Shortcut", "Messages", "Top"), + example={ + "items": [{"shortcut_id": 3, "shortcut": "hello", "count": 1}], + "has_more": False, + }, + example_args="business reply list", + covers=( + "business.quick-replies-list", + "business.quick-reply-messages", + "messages-core.quick-reply-list", + ), + tags=frozenset({"agent-safe"}), +) + + +class ReplyAddReq(Request): + shortcut: Annotated[str, arg(0, metavar="SHORTCUT", help="Shortcut name (created if new).")] + text: Annotated[str | None, opt("--text", metavar="TEXT", help="Message text.")] = None + file: Annotated[ + tuple[str, ...], + opt("--file", metavar="PATH", kind="path", help="Attach a file (repeatable)."), + ] = () + parse: Annotated[str, opt("--parse", metavar="MODE", help="md | html | none.")] = "md" + copy_from: Annotated[ + str | None, + opt("--copy-from", metavar="CHAT:ID", help="Copy an existing message into the shortcut."), + ] = None + + +async def reply_add(ctx: OpContext, req: ReplyAddReq) -> QuickReplySet: + """Add a message to a quick-reply shortcut, creating the shortcut if new. + + The shortcut is addressed by *name* the first time and by *id* afterwards; + `messages.checkQuickReplyShortcut` is what tells the two apart, and it is + also what enforces `quick_replies_limit` before anything is sent. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + from tlgr.ops import _send + + handle = client(ctx) + name = req.shortcut.strip() + existing = { + row.shortcut: row.shortcut_id for row in (await reply_list(ctx, ReplyListReq())).items + } + if name in existing: + shortcut: Any = types.InputQuickReplyShortcutId(shortcut_id=existing[name]) + else: + await handle(fn.CheckQuickReplyShortcutRequest(shortcut=name)) + shortcut = types.InputQuickReplyShortcut(shortcut=name) + + if req.copy_from: + chat, _, msg_id = req.copy_from.rpartition(":") + if not chat or not msg_id.strip().lstrip("-").isdigit(): + raise UsageError("--copy-from wants '<chat>:<msg_id>'", field="copy_from") + await handle( + fn.ForwardMessagesRequest( + from_peer=await _settings.resolve(ctx, chat), + id=[int(msg_id)], + random_id=[random_id()], + to_peer=types.InputPeerSelf(), + quick_reply_shortcut=shortcut, + ) + ) + return QuickReplySet(shortcut=name, shortcut_id=existing.get(name)) + + if not req.text and not req.file: + raise UsageError("give --text, --file or --copy-from", field="text") + + text, entities = _send.body(req.text or "", parse=req.parse, entities=None) + if req.file: + media = await _send.input_media(ctx, str(req.file[0])) + result = await handle( + fn.SendMediaRequest( + peer=types.InputPeerSelf(), + media=media, + message=text, + entities=entities, + random_id=random_id(), + quick_reply_shortcut=shortcut, + ) + ) + else: + result = await handle( + fn.SendMessageRequest( + peer=types.InputPeerSelf(), + message=text, + entities=entities, + random_id=random_id(), + quick_reply_shortcut=shortcut, + ) + ) + ctx.emit("business_reply", {"shortcut": name}) + ids = [ + int(getattr(getattr(update, "message", None), "id", 0) or 0) + for update in getattr(result, "updates", None) or [] + if getattr(update, "message", None) is not None + ] + return QuickReplySet( + shortcut=name, + shortcut_id=existing.get(name), + msg_id=ids[0] if ids else None, + msg_ids=ids, + ) + + +SPEC_REPLY_ADD = OperationSpec( + id="business.reply.add", + request=ReplyAddReq, + response=QuickReplySet, + impl=reply_add, + summary="Add a message to a quick-reply shortcut (creating the shortcut if needed)", + mutating=True, + rate_class="send", + columns=("shortcut", "shortcut_id", "msg_id"), + headers=("Shortcut", "Id", "Message"), + example={"shortcut": "hello", "shortcut_id": 3, "msg_id": 1}, + example_args='business reply add hello --text "Hi! I will reply shortly."', + covers=("business.quick-reply-add",), +) + + +class ReplyEditReq(Request): + shortcut: Annotated[ + str | None, arg(0, metavar="SHORTCUT", required=False, help="The shortcut to edit.") + ] = None + msg_id: Annotated[ + int | None, arg(1, metavar="MSG_ID", required=False, help="A message inside it.") + ] = None + text: Annotated[str | None, opt("--text", metavar="TEXT", help="New message text.")] = None + parse: Annotated[str, opt("--parse", metavar="MODE", help="md | html | none.")] = "md" + rename: Annotated[str | None, opt("--rename", metavar="NAME", help="New shortcut name.")] = None + order: Annotated[ + str | None, opt("--order", metavar="LIST", help="Every shortcut, in the wanted order.") + ] = None + + +async def reply_edit(ctx: OpContext, req: ReplyEditReq) -> QuickReplySet: + """Edit a quick-reply message, rename a shortcut, or reorder the list. + + `--order` wants the *complete* list of shortcut ids; the API replaces the + order rather than moving one entry, and a partial list would silently + drop the rest. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + from tlgr.ops import _send + + handle = client(ctx) + rows = {row.shortcut: row.shortcut_id for row in (await reply_list(ctx, ReplyListReq())).items} + + if req.order is not None: + order = [ + rows.get(part.strip(), int(part.strip()) if part.strip().isdigit() else 0) + for part in req.order.split(",") + if part.strip() + ] + if not order or 0 in order: + raise UsageError("--order wants every shortcut, by name or id", field="order") + await handle(fn.ReorderQuickRepliesRequest(order=order)) + return QuickReplySet(order=order) + + if not req.shortcut: + raise UsageError("give a shortcut, or --order", field="shortcut") + shortcut_id = await _shortcut_id(ctx, req.shortcut) + + if req.rename is not None: + await handle(fn.EditQuickReplyShortcutRequest(shortcut_id=shortcut_id, shortcut=req.rename)) + return QuickReplySet(shortcut_id=shortcut_id, shortcut=req.rename) + + if req.msg_id is None or req.text is None: + raise UsageError("editing a message needs MSG_ID and --text", field="msg_id") + text, entities = _send.body(req.text, parse=req.parse, entities=None) + await handle( + fn.EditMessageRequest( + peer=types.InputPeerSelf(), + id=req.msg_id, + message=text, + entities=entities, + quick_reply_shortcut_id=shortcut_id, + ) + ) + ctx.emit("business_reply_edit", {"shortcut_id": shortcut_id, "msg_id": req.msg_id}) + return QuickReplySet(shortcut_id=shortcut_id, shortcut=req.shortcut, msg_id=req.msg_id) + + +SPEC_REPLY_EDIT = OperationSpec( + id="business.reply.edit", + request=ReplyEditReq, + response=QuickReplySet, + impl=reply_edit, + summary="Edit a quick-reply message, rename a shortcut or reorder the shortcut list", + mutating=True, + rate_class="send", + columns=("shortcut_id", "shortcut", "msg_id", "order"), + headers=("Id", "Shortcut", "Message", "Order"), + example={"shortcut_id": 3, "shortcut": "hello", "msg_id": 1}, + example_args='business reply edit hello 1 --text "Hello!"', + covers=( + "business.quick-reply-edit", + "business.quick-reply-rename", + "business.quick-reply-reorder", + "messages-core.quick-reply-manage", + ), +) + + +class ReplyDeleteReq(Request): + shortcut: Annotated[str, arg(0, metavar="SHORTCUT", help="The shortcut to delete from.")] + msg_id: Annotated[ + tuple[int, ...], + arg(1, metavar="MSG_ID", required=False, variadic=True, help="Messages to delete."), + ] = () + + +async def reply_delete(ctx: OpContext, req: ReplyDeleteReq) -> QuickReplySet: + """Delete a quick-reply shortcut, or single messages inside it.""" + from telethon.tl.functions import messages as fn + + handle = client(ctx) + shortcut_id = await _shortcut_id(ctx, req.shortcut) + if req.msg_id: + await handle( + fn.DeleteQuickReplyMessagesRequest( + shortcut_id=shortcut_id, id=[int(v) for v in req.msg_id] + ) + ) + return QuickReplySet( + shortcut_id=shortcut_id, shortcut=req.shortcut, deleted=len(req.msg_id) + ) + await handle(fn.DeleteQuickReplyShortcutRequest(shortcut_id=shortcut_id)) + ctx.emit("business_reply_delete", {"shortcut_id": shortcut_id}) + return QuickReplySet(shortcut_id=shortcut_id, shortcut=req.shortcut, deleted=1) + + +SPEC_REPLY_DELETE = OperationSpec( + id="business.reply.delete", + request=ReplyDeleteReq, + response=QuickReplySet, + impl=reply_delete, + summary="Delete a quick-reply shortcut, or single messages inside it", + mutating=True, + destructive=True, + rate_class="send", + columns=("shortcut_id", "shortcut", "deleted"), + headers=("Id", "Shortcut", "Deleted"), + example={"shortcut_id": 3, "shortcut": "hello", "deleted": 1}, + example_args="business reply delete hello", + covers=("business.quick-reply-delete", "business.quick-reply-delete-messages"), +) + + +class ReplySendReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Private chat to send to.")] + shortcut: Annotated[str, arg(1, metavar="SHORTCUT", help="The shortcut to send.")] + only: Annotated[ + str | None, opt("--only", metavar="LIST", help="Only these message ids from the shortcut.") + ] = None + + +async def reply_send(ctx: OpContext, req: ReplySendReq) -> QuickReplySent: + """Send a quick reply into a private chat. Users only, private chats only.""" + from telethon.tl.functions import messages as fn + + handle = client(ctx) + peer = await _settings.resolve(ctx, req.chat) + shortcut_id = await _shortcut_id(ctx, req.shortcut) + page = await reply_list(ctx, ReplyListReq(shortcut=req.shortcut)) + available = [message.id for message in page.items[0].messages] + wanted = [int(part) for part in req.only.split(",") if part.strip()] if req.only else available + if not wanted: + raise UsageError(f"shortcut {req.shortcut!r} has no messages", field="shortcut") + result = await handle( + fn.SendQuickReplyMessagesRequest( + peer=peer, + shortcut_id=shortcut_id, + id=wanted, + random_id=[random_id() for _ in wanted], + ) + ) + ids = [ + int(getattr(getattr(update, "message", None), "id", 0) or 0) + for update in getattr(result, "updates", None) or [] + if getattr(update, "message", None) is not None + ] + chat_id = _settings.peer_of(peer) + ctx.emit("business_reply_sent", {"chat_id": chat_id, "shortcut_id": shortcut_id}) + return QuickReplySent(chat_id=chat_id, shortcut_id=shortcut_id, message_ids=ids) + + +SPEC_REPLY_SEND = OperationSpec( + id="business.reply.send", + request=ReplySendReq, + response=QuickReplySent, + impl=reply_send, + summary="Send a quick reply into a private chat", + aliases=("quickreply.send",), + mutating=True, + rate_class="send", + columns=("chat_id", "shortcut_id", "message_ids"), + headers=("Chat", "Shortcut", "Messages"), + example={"chat_id": 777123, "shortcut_id": 3, "message_ids": [4242]}, + example_args="business reply send @alice hello", + covers=("business.quick-reply-send",), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# business link list / set +# --------------------------------------------------------------------------- + + +class LinkListReq(Request): + slug: Annotated[ + str | None, + opt("--slug", metavar="SLUG", help="Resolve one link, including other people's."), + ] = None + + +async def link_list(ctx: OpContext, req: LinkListReq) -> Page[ChatLink]: + """My business chat links, or a resolved one. + + Creating and editing needs Premium; *resolving* somebody else's link does + not, which is why `--slug` is the one half of this command that works on + any account. + """ + from telethon.tl.functions import account as fn + + handle = client(ctx) + if req.slug: + slug = req.slug.rsplit("/", 1)[-1] + resolved = await handle(fn.ResolveBusinessChatLinkRequest(slug=slug)) + from tlgr.ops._serialize import message_entities + + peer_id = getattr(getattr(resolved, "peer", None), "user_id", None) + return Page( + items=[ + ChatLink( + slug=slug, + link=f"https://t.me/m/{slug}", + message=str(getattr(resolved, "message", "") or ""), + entities=message_entities(resolved), + title=str(peer_id) if peer_id else None, + ) + ], + has_more=False, + total=1, + ) + result = await handle(fn.GetBusinessChatLinksRequest()) + rows = [_link_model(row) for row in getattr(result, "links", None) or []] + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_LINK_LIST = OperationSpec( + id="business.link.list", + request=LinkListReq, + response=Page[ChatLink], + impl=link_list, + summary="List my business chat links, or resolve someone's link", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("slug", "link", "title", "views"), + headers=("Slug", "Link", "Title", "Views"), + example={ + "items": [{"slug": "abc", "link": "https://t.me/m/abc", "message": "Hi!"}], + "has_more": False, + }, + example_args="business link list", + covers=("business.chat-links", "dialogs.business-link-list"), + tags=frozenset({"agent-safe"}), +) + + +class LinkSetReq(Request): + slug: Annotated[ + str | None, arg(0, metavar="SLUG", required=False, help="Omit to create a new link.") + ] = None + text: Annotated[str | None, opt("--text", metavar="TEXT", help="Prefilled message.")] = None + title: Annotated[str | None, opt("--title", metavar="TEXT", help="Link title.")] = None + parse: Annotated[str, opt("--parse", metavar="MODE", help="md | html | none.")] = "md" + entities: Annotated[ + str | None, opt("--entities", metavar="JSON", help="Explicit entities.") + ] = None + delete: Annotated[bool, opt("--delete", help="Delete the link.")] = False + + +async def link_set(ctx: OpContext, req: LinkSetReq) -> ChatLinkSet: + """Create, edit or delete a business chat link. + + `CHATLINKS_TOO_MUCH` means the `business_chat_links_limit` from appConfig + is reached; the server says which, so tlgr passes it through rather than + counting the links itself and guessing. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + from tlgr.ops import _send + + handle = client(ctx) + if req.delete: + if not req.slug: + raise UsageError("--delete needs the slug to delete", field="slug") + await handle(fn.DeleteBusinessChatLinkRequest(slug=req.slug)) + ctx.emit("business_link_deleted", {"slug": req.slug}) + return ChatLinkSet(slug=req.slug, deleted=True) + + text, entities = _send.body(req.text or "", parse=req.parse, entities=req.entities) + link = types.InputBusinessChatLink(message=text, entities=entities, title=req.title) + if req.slug: + result = await handle(fn.EditBusinessChatLinkRequest(slug=req.slug, link=link)) + else: + result = await handle(fn.CreateBusinessChatLinkRequest(link=link)) + model = _link_model(result) + ctx.emit("business_link", {"slug": model.slug}) + return ChatLinkSet(slug=model.slug, link=model.link, title=model.title, message=model.message) + + +SPEC_LINK_SET = OperationSpec( + id="business.link.set", + request=LinkSetReq, + response=ChatLinkSet, + impl=link_set, + summary="Create, edit or delete a business chat link", + mutating=True, + rate_class="send", + columns=("slug", "link", "title", "deleted"), + headers=("Slug", "Link", "Title", "Deleted"), + example={"slug": "abc", "link": "https://t.me/m/abc", "message": "Hi!"}, + example_args='business link set --text "Hi! How can I help?"', + covers=( + "dialogs.business-link-create", + "dialogs.business-link-delete", + "dialogs.business-link-edit", + ), + covers_partial=("business.chat-links",), + coverage_note="Listing and resolving links is `business link list`.", +) + + +# --------------------------------------------------------------------------- +# business bot list / set / toggle +# --------------------------------------------------------------------------- + + +class BotListReq(Request): + connection: Annotated[ + str | None, + opt("--connection", metavar="ID", help="Bot side: inspect one business connection."), + ] = None + + +async def bot_list(ctx: OpContext, req: BotListReq) -> Page[BotConnection]: + """Chatbots connected to my account, or one connection in detail. + + `--connection` is the *bot's* view and only works from a bot session; a + user account asking for it gets `BOT_METHOD_INVALID`, which is why the + flag says so rather than the error doing it. + """ + from telethon.tl.functions import account as fn + + handle = client(ctx) + if req.connection: + from tlgr.ops import _bots + + await _bots.require_bot_session(ctx, "business bot list --connection") + result = await handle(fn.GetBotBusinessConnectionRequest(connection_id=req.connection)) + known = _settings.entity_map(result) + rows = [ + _bot_model(getattr(update, "connection", update), known) + for update in getattr(result, "updates", None) or [] + if getattr(update, "connection", None) is not None + ] + return Page(items=rows, has_more=False, total=len(rows)) + + result = await handle(fn.GetConnectedBotsRequest()) + known = _settings.entity_map(result) + rows = [_bot_model(row, known) for row in getattr(result, "connected_bots", None) or []] + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_BOT_LIST = OperationSpec( + id="business.bot.list", + request=BotListReq, + response=Page[BotConnection], + impl=bot_list, + summary="List chatbots connected to my account (and inspect one connection)", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("bot_id", "connection_id", "paused", "confirmed", "disabled"), + headers=("Bot", "Connection", "Paused", "Confirmed", "Disabled"), + example={ + "items": [{"bot_id": 5000001, "paused": False, "rights": {"reply": True}}], + "has_more": False, + }, + example_args="business bot list", + covers=( + "bots.business-bots-list", + "business.bot-connection", + "business.connected-bots", + "dialogs.business-bot-bar", + ), + tags=frozenset({"agent-safe"}), +) + + +class BotSetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot to connect.")] + reply_to: Annotated[bool, opt("--reply-to", help="Right: reply to messages.")] = False + read: Annotated[bool, opt("--read", help="Right: read messages.")] = False + delete_sent: Annotated[bool, opt("--delete-sent", help="Right: delete messages it sent.")] = ( + False + ) + delete_received: Annotated[ + bool, opt("--delete-received", help="Right: delete messages it received.") + ] = False + edit_name: Annotated[bool, opt("--edit-name", help="Right: edit my name.")] = False + edit_bio: Annotated[bool, opt("--edit-bio", help="Right: edit my bio.")] = False + edit_username: Annotated[bool, opt("--edit-username", help="Right: edit my username.")] = False + edit_photo: Annotated[bool, opt("--edit-photo", help="Right: edit my profile photo.")] = False + manage_gifts: Annotated[ + bool, opt("--manage-gifts", help="Right: view and manage gifts and Stars.") + ] = False + transfer_stars: Annotated[ + bool, opt("--transfer-stars", help="Right: transfer Stars to the bot.") + ] = False + manage_stories: Annotated[bool, opt("--manage-stories", help="Right: manage stories.")] = False + contacts: Annotated[bool, opt("--contacts", help="Recipients: contacts.")] = False + non_contacts: Annotated[bool, opt("--non-contacts", help="Recipients: non-contacts.")] = False + existing_chats: Annotated[bool, opt("--existing-chats", help="Recipients: existing chats.")] = ( + False + ) + new_chats: Annotated[bool, opt("--new-chats", help="Recipients: new chats.")] = False + users: Annotated[ + str | None, opt("--users", metavar="LIST", help="Explicit recipient users.") + ] = None + exclude_users: Annotated[ + str | None, opt("--exclude-users", metavar="LIST", help="Users to exclude.") + ] = None + exclude: Annotated[bool, opt("--exclude", help="Invert the selection.")] = False + confirm: Annotated[bool, opt("--confirm", help="Activate a pending connection.")] = False + disconnect: Annotated[bool, opt("--disconnect", help="Remove the bot.")] = False + + +async def bot_set(ctx: OpContext, req: BotSetReq) -> BotConnection: + """Connect, re-scope, confirm or disconnect a business chatbot. + + Every right is opt-in **by name**. There is deliberately no `--all`: this + is the command that lets another program read your messages, rewrite your + profile and move your Stars, and the reply enumerates exactly what was + granted so an audit does not have to trust the flags that were typed. + """ + from telethon.tl import types + from telethon.tl.functions import account as fn + + handle = client(ctx) + bot = await _settings.input_user(ctx, req.bot, field="bot") + + if req.confirm: + await handle(fn.ConfirmBotConnectionRequest(bot_id=bot)) + ctx.emit("business_bot_confirmed", {}) + page = await bot_list(ctx, BotListReq()) + return page.items[0] if page.items else BotConnection(confirmed=True) + + recipients = types.InputBusinessBotRecipients( + existing_chats=req.existing_chats or None, + new_chats=req.new_chats or None, + contacts=req.contacts or None, + non_contacts=req.non_contacts or None, + exclude_selected=req.exclude or None, + users=[ + await _settings.input_user(ctx, part.strip(), field="users") + for part in (req.users or "").split(",") + if part.strip() + ] + or None, + exclude_users=[ + await _settings.input_user(ctx, part.strip(), field="exclude_users") + for part in (req.exclude_users or "").split(",") + if part.strip() + ] + or None, + ) + + granted = {field: True for flag, field in BOT_RIGHTS.items() if getattr(req, flag)} + rights = None if req.disconnect else types.BusinessBotRights(**granted) + await handle( + fn.UpdateConnectedBotRequest( + bot=bot, + recipients=recipients, + deleted=req.disconnect or None, + rights=rights, + ) + ) + ctx.emit( + "business_bot", + {"granted": sorted(granted), "deleted": req.disconnect}, + ) + return BotConnection( + bot_id=_settings.peer_of(await _settings.resolve(ctx, req.bot)), + recipients=_recipients_model(recipients), + rights=_rights_model(rights), + deleted=req.disconnect, + ) + + +SPEC_BOT_SET = OperationSpec( + id="business.bot.set", + request=BotSetReq, + response=BotConnection, + impl=bot_set, + summary="Connect, re-scope, confirm or disconnect a business chatbot", + description=( + "Rights default to none and each one is named explicitly, because a " + "connected bot can read, reply, rewrite the profile and move Stars. " + "Acting *as* the bot on somebody's account " + "(`invokeWithBusinessConnection`) is a bot-side surface and out of " + "scope for the user-side CLI." + ), + mutating=True, + destructive=True, + rate_class="send", + columns=("bot_id", "rights", "deleted"), + headers=("Bot", "Rights", "Removed"), + example={"bot_id": 5000001, "rights": {"reply": True, "read_messages": True}}, + example_args="business bot set @mybot --reply-to --read --new-chats", + covers=( + "bots.business-bot-connect", + "bots.business-bot-disconnect", + "bots.business-bot-remove-from-chat", + "business.account-edit-via-bot", + "business.confirm-bot-connection", + "stories.business-story", + ), + covers_partial=("business.connected-bots",), + coverage_note="Listing the connections is `business bot list`.", + tags=frozenset({"visible-to-others"}), +) + + +class BotToggleReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat to change.")] + resume: Annotated[bool, opt("--resume", help="Un-pause the bot in this chat.")] = False + remove: Annotated[bool, opt("--remove", help="Remove the bot from this chat entirely.")] = False + + +async def bot_toggle(ctx: OpContext, req: BotToggleReq) -> BotPaused: + """Pause, resume or exclude the connected bot in one chat.""" + from telethon.tl.functions import account as fn + + handle = client(ctx) + peer = await _settings.resolve(ctx, req.chat) + chat_id = _settings.peer_of(peer) + if req.remove: + await handle(fn.DisablePeerConnectedBotRequest(peer=peer)) + ctx.emit("business_bot_chat", {"chat_id": chat_id, "removed": True}) + return BotPaused(chat_id=chat_id, removed=True) + paused = not req.resume + await handle(fn.ToggleConnectedBotPausedRequest(peer=peer, paused=paused)) + ctx.emit("business_bot_chat", {"chat_id": chat_id, "paused": paused}) + return BotPaused(chat_id=chat_id, paused=paused) + + +SPEC_BOT_TOGGLE = OperationSpec( + id="business.bot.toggle", + request=BotToggleReq, + response=BotPaused, + impl=bot_toggle, + summary="Pause, resume or exclude the connected bot in one chat", + aliases=("business.bot.pause",), + mutating=True, + idempotent=True, + rate_class="send", + columns=("chat_id", "paused", "removed"), + headers=("Chat", "Paused", "Removed"), + example={"chat_id": 777123, "paused": True}, + example_args="business bot toggle @alice", + covers=("business.bot-pause-chat", "business.bot-remove-chat"), +) + + +# --------------------------------------------------------------------------- +# business stars transfer +# --------------------------------------------------------------------------- + + +class StarsTransferReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The connected bot.")] + amount: Annotated[int | None, opt("--amount", metavar="STARS", help="Stars to transfer.")] = ( + None + ) + + +async def stars_transfer(ctx: OpContext, req: StarsTransferReq) -> StarsTransferQuote: + """Price a Stars transfer to a business bot — and refuse to make it. + + `payments.sendStarsForm` is absent from tlgr's surface by policy (see + `ops/payment.py`), and this PR does not open a second door onto the same + money. The form is fetched so the price is visible, and `ok` is false with + the reason attached. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + if req.amount is None or req.amount <= 0: + raise UsageError("--amount is the number of Stars to transfer", field="amount") + bot = await _settings.input_user(ctx, req.bot, field="bot") + invoice = types.InputInvoiceBusinessBotTransferStars(bot=bot, stars=int(req.amount)) + form = await client(ctx)(fn.GetPaymentFormRequest(invoice=invoice)) + prices = getattr(getattr(form, "invoice", None), "prices", None) or [] + return StarsTransferQuote( + bot_id=_settings.peer_of(await _settings.resolve(ctx, req.bot)), + stars=sum(int(getattr(price, "amount", 0) or 0) for price in prices) or int(req.amount), + currency=str(getattr(getattr(form, "invoice", None), "currency", "XTR") or "XTR"), + ok=False, + reason=_settings.NO_SPEND, + form_id=getattr(form, "form_id", None), + ) + + +SPEC_STARS_TRANSFER = OperationSpec( + id="business.stars.transfer", + request=StarsTransferReq, + response=StarsTransferQuote, + impl=stars_transfer, + summary="Price a Stars transfer from a business account to its bot", + description=( + "Reads `payments.getPaymentForm` and stops there. tlgr never signs a " + "payment form — PR-10 settled that for the `payment` group and this " + "group inherits it rather than opening a second door onto the money." + ), + idempotent=True, + columns=("bot_id", "stars", "currency", "ok", "reason"), + headers=("Bot", "Stars", "Currency", "Sent", "Why not"), + example={"bot_id": 5000001, "stars": 100, "currency": "XTR", "ok": False}, + example_args="business stars transfer @mybot --amount 100", + covers_partial=("stars.business-bot-transfer",), + coverage_note=( + "The price and the form are reported; signing the form is deliberately " + "absent from tlgr's whole surface." + ), + tags=frozenset({"agent-safe"}), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] From 843d26f3821bebcafd2c96690b3691ea0d003928 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 07:22:29 +0330 Subject: [PATCH 06/15] premium, stars and giveaway ops: nineteen operations, and one policy held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The limit table is what a script actually needs out of Premium: caption length, upload size, folder count, pinned chats and public usernames all change with the subscription, and a script that guesses them writes a message the server then refuses. It has no MTProto method of its own — it is assembled from appConfig — so every row says where it came from. The Stars group reads and nothing else. A Stars amount keeps its `nanos` because a rounded ledger cannot be reconciled, and the transactions cursor is the server's opaque `next_offset` string rather than an integer: passing a number where the API wants a token silently restarts the walk, which is how an export ends up with its first page repeated. Giveaways get a noun of their own because almost all of the surface is free: joining spends a boost slot I already own, redeeming a code activates a subscription somebody else paid for, and launching a *prepaid* giveaway spends nothing at all. Buying a new one is a purchase and is absent. `premium gift send`, `stars subscription refulfill` and `stars url get` are where the policy shows. PR-10 named `sendStarsForm`, `sendPaymentForm`, `validateRequestedInfo` and `fulfillStarsSubscription` as deliberately absent from tlgr's surface; PR-12 does not re-add any of them behind a flag. Each of these three reports the price, or the URL, or whether the server would allow it — and stops there. --- tlgr/ops/giveaway.py | 544 +++++++++++++++++++++++++++++++++++++++ tlgr/ops/premium.py | 481 +++++++++++++++++++++++++++++++++++ tlgr/ops/stars.py | 594 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1619 insertions(+) create mode 100644 tlgr/ops/giveaway.py create mode 100644 tlgr/ops/premium.py create mode 100644 tlgr/ops/stars.py diff --git a/tlgr/ops/giveaway.py b/tlgr/ops/giveaway.py new file mode 100644 index 0000000..a7a05e3 --- /dev/null +++ b/tlgr/ops/giveaway.py @@ -0,0 +1,544 @@ +"""The `giveaway` group: joining one, checking a code, launching a prepaid one. + +Giveaways are a first-class surface in the official clients and they are +almost entirely free to operate from a CLI, which is why they get a noun of +their own rather than living under `gift`: + +* **joining** spends a boost slot I already own, not money; +* **redeeming a code** activates a subscription somebody else paid for; +* **launching a prepaid giveaway** spends nothing either — the giveaway was + bought earlier, and `payments.launchPrepaidGiveaway` only starts it. + +Buying a *new* giveaway is a payment and is therefore absent, like every +other purchase in tlgr. + +`giveaway get` answers the question the public message cannot: am I eligible, +did I win, and which code did I win. `messageMediaGiveaway` carries the public +half; `payments.getGiveawayInfo` carries the personal half, and one command +reports both. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from tlgr.core.errors import UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix +from tlgr.models.admin import BoostApplied +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.models.premium import ( + GiftCode, + GiftCodeApplied, + GiveawayInfo, + GiveawayLaunched, + GiveawayWinner, + PrepaidGiveaway, +) +from tlgr.ops import _settings +from tlgr.ops._common import client, random_id, window +from tlgr.ops._params import arg, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: `giveawayInfo.disallowed_reason` → the word tlgr reports. +DISALLOWED = { + "GiveawayInfoDisallowedCountry": "disallowed-country", + "GiveawayInfoDisallowedAdminRequired": "admin", + "GiveawayInfoDisallowedJoinedTooEarly": "joined-too-early", +} + + +# --------------------------------------------------------------------------- +# giveaway get +# --------------------------------------------------------------------------- + + +class GetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The giveaway's channel.")] + msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The giveaway message.")] + winners: Annotated[ + bool, opt("--winners", help="Resolve the winner list from the results message.") + ] = False + + +async def get(ctx: OpContext, req: GetReq) -> GiveawayInfo: + """Giveaway status and results: am I eligible, did I win, who won. + + Two sources, one answer. The message media says how many winners there + are, when it ends and which countries are eligible; + `payments.getGiveawayInfo` says whether *this* account is in it and, if + it has finished, which gift code it won. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + peer = await _settings.resolve(ctx, req.chat) + chat_id = _settings.peer_of(peer) + raw = await handle(fn.GetGiveawayInfoRequest(peer=peer, msg_id=int(req.msg_id))) + finished = type(raw).__name__ == "PaymentsGiveawayInfoResults" + info = GiveawayInfo( + chat_id=chat_id, + msg_id=int(req.msg_id), + state="finished" if finished else "ongoing", + start_date=fmt_dt(getattr(raw, "start_date", None)), + joined=bool(getattr(raw, "participating", False)), + disallowed_reason=_disallowed_word(raw), + winner=bool(getattr(raw, "winner", False)), + refunded=bool(getattr(raw, "refunded", False)), + gift_code_slug=getattr(raw, "gift_code_slug", None), + activated_count=getattr(raw, "activated_count", None), + until_date=fmt_dt(getattr(raw, "finish_date", None)), + stars=getattr(raw, "stars_prize", None), + ) + + media = await _giveaway_media(ctx, peer, int(req.msg_id)) + if media is not None: + info.winners_count = getattr(media, "quantity", None) or getattr( + media, "winners_count", None + ) + info.months = getattr(media, "months", None) + info.only_new_subscribers = bool(getattr(media, "only_new_subscribers", False)) + info.countries = [str(code) for code in getattr(media, "countries_iso2", None) or []] + info.prize_description = getattr(media, "prize_description", None) + if info.until_date is None: + info.until_date = fmt_dt(getattr(media, "until_date", None)) + + if req.winners: + # Only the *results* media carries the winner vector; an ongoing + # giveaway has none, and reporting an empty list for one would read + # as "nobody won" rather than "not drawn yet". + info.winners = [ + GiveawayWinner(user_id=int(user_id)) + for user_id in getattr(media, "winners", None) or [] + ] + return info + + +def _disallowed_word(raw: Any) -> str | None: + """The `giveawayInfo` reason, whichever of the three flavours it is.""" + for reason in getattr(raw, "disallowed_reason", None) or []: + word = DISALLOWED.get(type(reason).__name__) + if word: + return word + if getattr(raw, "admin_disallowed", False): + return "admin" + if getattr(raw, "joined_too_early_date", None): + return "joined-too-early" + if getattr(raw, "disallowed_country", None): + return "disallowed-country" + return None + + +async def _giveaway_media(ctx: OpContext, peer: Any, msg_id: int) -> Any: + """The `messageMediaGiveaway*` on the giveaway post, or None.""" + from tlgr.ops import _media + + try: + message = await _media.fetch_message(ctx, peer, msg_id) + except Exception as exc: + ctx.warn(f"could not read the giveaway message: {exc}") + return None + media = getattr(message, "media", None) + if type(media).__name__ in ("MessageMediaGiveaway", "MessageMediaGiveawayResults"): + return media + return None + + +SPEC_GET = OperationSpec( + id="giveaway.get", + request=GetReq, + response=GiveawayInfo, + impl=get, + summary="Giveaway status and results: am I eligible, did I win, who won", + aliases=("giveaway.info",), + idempotent=True, + columns=("state", "joined", "winner", "winners_count", "until_date", "gift_code_slug"), + headers=("State", "Joined", "Won", "Winners", "Until", "Code"), + example={ + "chat_id": -1001600, + "msg_id": 42, + "state": "ongoing", + "joined": True, + "winners_count": 10, + }, + example_args="giveaway get @mychannel 42", + covers=( + "giveaway.info", + "giveaway.results", + "groups-channels-admin.giveaway-info", + "premium.giveaway-info", + ), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# giveaway join +# --------------------------------------------------------------------------- + + +class JoinReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel to boost.")] + slots: Annotated[ + list[int], opt("--slots", metavar="N", help="Which of my boost slots to use.") + ] = [] + + +async def join(ctx: OpContext, req: JoinReq) -> BoostApplied: + """Join a giveaway by boosting the channel. + + Joining *is* boosting: the giveaway counts participants by boost. The + slot is occupied for a month, which is why the command is confirmed, and + the boost side of it is `boost add` — one implementation, reached from + the two places the GUI reaches it from. + """ + from tlgr.ops.chat_stats import BoostAddReq, add_boost + + return await add_boost(ctx, BoostAddReq(chat=req.chat, slots=list(req.slots))) + + +SPEC_JOIN = OperationSpec( + id="giveaway.join", + request=JoinReq, + response=BoostApplied, + impl=join, + summary="Join a giveaway by boosting the channel", + description=( + "Needs Premium (or gifted boost slots). A slot stays occupied for a " + "month, so `-y` is required off a TTY." + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("chat_id", "level", "boosts", "slots", "already"), + headers=("Chat", "Level", "Boosts", "Slots", "Already"), + example={"chat_id": -1001600, "level": 4, "boosts": 15, "slots": [1]}, + example_args="giveaway join @mychannel", + covers=("giveaway.join-by-boosting",), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# giveaway list +# --------------------------------------------------------------------------- + + +class ListReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="CHAT", required=False, kind="peer", help="The channel to inspect."), + ] = None + prepaid: Annotated[ + bool, opt("--prepaid/--no-prepaid", help="Prepaid giveaways bought for this channel.") + ] = True + codes: Annotated[ + bool, opt("--codes", help="My received giveaway gift codes (the inbox side).") + ] = False + + +async def list_(ctx: OpContext, req: ListReq) -> Page[PrepaidGiveaway]: + """Prepaid giveaways on a channel, or the gift codes I have received. + + `payments.getPrepaidGiveaways` has no request class in Telethon 1.44, but + `premium.getBoostsStatus` carries the same `prepaid_giveaways` vector — + so the answer is the server's, from the method this build can send. + """ + from telethon.tl.functions import premium as fn + + handle = client(ctx) + if req.codes: + return await _received_codes(ctx) + + if req.chat is None: + raise UsageError("name a channel, or pass --codes for my received codes", field="chat") + peer = await _settings.resolve(ctx, req.chat) + status = await handle(fn.GetBoostsStatusRequest(peer=peer)) + rows = [ + PrepaidGiveaway( + id=int(getattr(row, "id", 0) or 0), + quantity=int(getattr(row, "quantity", 0) or 0), + months=getattr(row, "months", None), + stars=getattr(row, "stars", None), + boosts=getattr(row, "boosts", None), + date=fmt_dt(getattr(row, "date", None)), + date_unix=to_unix(getattr(row, "date", None)), + from_chat=_settings.peer_of(peer), + ) + for row in getattr(status, "prepaid_giveaways", None) or [] + ] + return Page(items=rows, has_more=False, total=len(rows)) + + +async def _received_codes(ctx: OpContext) -> Page[PrepaidGiveaway]: + """Gift codes that arrived as `messageActionGiftCode` service messages. + + There is no "my codes" endpoint: the codes are service messages in the + account's own history, so they are found by scanning and then checked one + by one, which is exactly what an official client does. + """ + from telethon.tl.functions import payments as fn + + from tlgr.ops.chat import ListReq as ChatListReq + from tlgr.ops.chat import list_chats + + handle = client(ctx) + limit, _ = window(ctx, "giveaway.list", PageKind.LOCAL, default=20) + rows: list[PrepaidGiveaway] = [] + dialogs = await list_chats(ctx, ChatListReq()) + for dialog in dialogs.items: + message = getattr(dialog, "last_message", None) + action = getattr(message, "action", None) + slug = getattr(action, "slug", None) if action is not None else None + if not slug: + continue + try: + checked = await handle(fn.CheckGiftCodeRequest(slug=slug)) + except Exception as exc: + ctx.warn(f"could not check the code {slug}: {exc}") + continue + rows.append( + PrepaidGiveaway( + id=0, + quantity=1, + months=getattr(checked, "months", None), + slug=slug, + used=getattr(checked, "used_date", None) is not None, + date=fmt_dt(getattr(checked, "date", None)), + date_unix=to_unix(getattr(checked, "date", None)), + from_chat=getattr(dialog.chat, "id", None) if dialog.chat else None, + ) + ) + if len(rows) >= limit: + break + return build_page( + rows, op="giveaway.list", kind=PageKind.LOCAL, has_more=False, total=len(rows) + ) + + +SPEC_LIST = OperationSpec( + id="giveaway.list", + request=ListReq, + response=Page[PrepaidGiveaway], + impl=list_, + summary="Prepaid giveaways available on a channel, and the gift codes I received", + description=( + "The prepaid list comes from `premium.getBoostsStatus`, which carries " + "the same vector as the absent `payments.getPrepaidGiveaways`. " + "`--codes` scans for `messageActionGiftCode` service messages and " + "checks each slug, because there is no 'my codes' endpoint." + ), + paginated=PageKind.LOCAL, + idempotent=True, + columns=("id", "quantity", "months", "stars", "slug", "used"), + headers=("Id", "Winners", "Months", "Stars", "Code", "Used"), + example={ + "items": [{"id": 77, "quantity": 10, "months": 3, "from_chat": -1001600}], + "has_more": False, + }, + example_args="giveaway list @mychannel", + covers=("giveaway.gift-code-received", "giveaway.list-prepaid"), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# giveaway start +# --------------------------------------------------------------------------- + + +class StartReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel.")] + prepaid_id: Annotated[int, arg(1, metavar="PREPAID_ID", help="From `giveaway list <chat>`.")] + winners: Annotated[int | None, opt("--winners", metavar="N", help="Number of winners.")] = None + until: Annotated[ + str | None, opt("--until", metavar="WHEN", kind="datetime", help="Draw date.") + ] = None + only_new: Annotated[ + bool, opt("--only-new", help="Only subscribers who joined after the start.") + ] = False + public_winners: Annotated[ + bool, opt("--public-winners", help="Show the winner list when it ends.") + ] = False + countries: Annotated[ + str | None, opt("--countries", metavar="LIST", help="ISO-2 codes, comma separated.") + ] = None + also_chat: Annotated[ + list[str], + opt("--also-chat", metavar="CHAT", help="Another channel a participant must join."), + ] = [] + prize: Annotated[str | None, opt("--prize", metavar="TEXT", help="Prize description.")] = None + + +async def start(ctx: OpContext, req: StartReq) -> GiveawayLaunched: + """Launch a giveaway that was already paid for. + + Launching a prepaid giveaway is **not** a payment, which is why it is + here at all: the Stars or the fiat were spent when the giveaway was + bought, and this only starts it. Creating a new (bought) giveaway is a + purchase and is absent. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + handle = client(ctx) + peer = await _settings.resolve(ctx, req.chat) + config = await _settings.app_config(ctx) + countries = [part.strip().upper() for part in (req.countries or "").split(",") if part.strip()] + max_countries = int(config.get("giveaway_countries_max") or 0) + if max_countries and len(countries) > max_countries: + raise UsageError(f"--countries takes at most {max_countries} entries", field="countries") + extra = [await _settings.resolve(ctx, ref) for ref in req.also_chat] + max_peers = int(config.get("giveaway_add_peers_max") or 0) + if max_peers and len(extra) > max_peers: + raise UsageError(f"--also-chat takes at most {max_peers} channels", field="also_chat") + + until = parse_dt(req.until) if req.until else None + purpose = types.InputStorePaymentPremiumGiveaway( + boost_peer=peer, + until_date=until, + currency="XTR", + amount=0, + only_new_subscribers=req.only_new or None, + winners_are_visible=req.public_winners or None, + additional_peers=extra or None, + countries_iso2=countries or None, + prize_description=req.prize, + random_id=random_id(), + ) + result = await handle( + fn.LaunchPrepaidGiveawayRequest(peer=peer, giveaway_id=int(req.prepaid_id), purpose=purpose) + ) + msg_id = next( + ( + int(getattr(getattr(update, "message", None), "id", 0) or 0) + for update in getattr(result, "updates", None) or [] + if getattr(update, "message", None) is not None + ), + None, + ) + chat_id = _settings.peer_of(peer) + ctx.emit("giveaway_started", {"chat_id": chat_id, "prepaid_id": int(req.prepaid_id)}) + return GiveawayLaunched( + chat_id=chat_id, + prepaid_id=int(req.prepaid_id), + msg_id=msg_id, + winners_count=int(req.winners or 0), + until_date=fmt_dt(until), + ) + + +SPEC_START = OperationSpec( + id="giveaway.start", + request=StartReq, + response=GiveawayLaunched, + impl=start, + summary="Launch a giveaway that was already paid for (prepaid)", + description=( + "Not a payment: the giveaway was bought earlier and this only starts " + "it. Creating a new, bought giveaway is a purchase and is absent." + ), + aliases=("giveaway.launch",), + mutating=True, + rate_class="send", + columns=("chat_id", "prepaid_id", "msg_id", "winners_count", "until_date"), + headers=("Chat", "Prepaid", "Post", "Winners", "Until"), + example={"chat_id": -1001600, "prepaid_id": 77, "msg_id": 42, "winners_count": 10}, + example_args="giveaway start @mychannel 77 --winners 10 --until +7d", + covers=("groups-channels-admin.giveaway-prepaid-launch", "premium.giveaway-create"), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# giveaway code check / apply +# --------------------------------------------------------------------------- + + +class CodeCheckReq(Request): + slug: Annotated[str, arg(0, metavar="SLUG", help="The code, or a t.me/giftcode link.")] + + +async def code_check(ctx: OpContext, req: CodeCheckReq) -> GiftCode: + """Check a gift code before using it. + + Also how a giveaway admin identifies a winner: `to_id` is who the code + was issued to, which the public results message does not always say. + """ + from tlgr.ops.premium import GiftcodeGetReq, giftcode_get + + return await giftcode_get(ctx, GiftcodeGetReq(slug=req.slug)) + + +SPEC_CODE_CHECK = OperationSpec( + id="giveaway.code.check", + request=CodeCheckReq, + response=GiftCode, + impl=code_check, + summary="Check a gift code / t.me/giftcode link before using it", + aliases=("giftcode.check",), + idempotent=True, + columns=("slug", "from_id", "to_id", "date", "months", "used_date", "via_giveaway"), + headers=("Slug", "From", "To", "Date", "Months", "Used", "Giveaway"), + example={"slug": "abcdef", "from_id": -1001600, "to_id": 777123, "months": 3}, + example_args="giveaway code check abcdef", + covers=("giftcode.check",), + tags=frozenset({"agent-safe"}), +) + + +class CodeApplyReq(Request): + slug: Annotated[str, arg(0, metavar="SLUG", help="The code, or a t.me/giftcode link.")] + + +async def code_apply(ctx: OpContext, req: CodeApplyReq) -> GiftCodeApplied: + """Redeem a gift code, activating the Premium subscription it carries. + + Costs nothing: the code was paid for by whoever gave it. A code that is + already used answers `already: true` rather than failing. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + slug = req.slug.rsplit("/", 1)[-1] + checked = await handle(fn.CheckGiftCodeRequest(slug=slug)) + if getattr(checked, "used_date", None) is not None: + mark = getattr(ctx, "mark_already", None) + if callable(mark): + mark() + return GiftCodeApplied( + slug=slug, + applied=False, + months=getattr(checked, "months", None), + already=True, + ) + await handle(fn.ApplyGiftCodeRequest(slug=slug)) + ctx.emit("giftcode_applied", {"slug": slug}) + return GiftCodeApplied(slug=slug, applied=True, months=getattr(checked, "months", None)) + + +SPEC_CODE_APPLY = OperationSpec( + id="giveaway.code.apply", + request=CodeApplyReq, + response=GiftCodeApplied, + impl=code_apply, + summary="Redeem a gift code (activates the Premium subscription it carries)", + description="Free: the code is already paid for, so this is not a purchase.", + aliases=("giftcode.apply",), + mutating=True, + idempotent=True, + rate_class="send", + columns=("slug", "applied", "months", "already"), + headers=("Slug", "Applied", "Months", "Already"), + example={"slug": "abcdef", "applied": True, "months": 3}, + example_args="giveaway code apply abcdef", + covers=("giftcode.apply", "groups-channels-admin.gift-code-redeem"), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] diff --git a/tlgr/ops/premium.py b/tlgr/ops/premium.py new file mode 100644 index 0000000..a6383d2 --- /dev/null +++ b/tlgr/ops/premium.py @@ -0,0 +1,481 @@ +"""The `premium` group: subscription status, the limit table, boosts, gifts. + +The genuinely useful part for a CLI is `premium feature list --limits`. The +caption length, the upload size, the folder count, the pinned-chat count and +the public-username count all change with Premium, and a script that guesses +them writes a message the server then refuses. There is no MTProto method for +the table — it is assembled from `help.getAppConfig` — which is why every row +carries `source`. + +Buying is absent, throughout and by policy. `premium status` prints the +premium bot and the invoice deep link for a human to open; `premium gift +send` fetches the payment form, reports the price and stops. PR-10 settled +this for the `payment` group (`ops/payment.py` names the four methods it will +not call) and this group inherits it rather than opening a second door onto +the same money. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from tlgr.core.errors import UsageError +from tlgr.core.pagination import PageKind +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.admin import Boost +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.models.premium import ( + GiftCode, + PremiumFeatures, + PremiumGiftOption, + PremiumGiftQuote, + PremiumLimit, + PremiumStatus, +) +from tlgr.ops import _settings +from tlgr.ops._common import client +from tlgr.ops._params import arg, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: appConfig suffixes that make a `*_limit_default` / `*_limit_premium` pair. +_LIMIT_DEFAULT = "_limit_default" +_LIMIT_PREMIUM = "_limit_premium" + +#: The boost-level keys, which have no method of their own. +_LEVEL_SUFFIXES = ("_level_min",) + + +# --------------------------------------------------------------------------- +# premium status / feature list +# --------------------------------------------------------------------------- + + +class StatusReq(Request): + pass + + +async def status(ctx: OpContext, req: StatusReq) -> PremiumStatus: + """My Telegram Premium status, and how a human would buy it. + + Executing a fiat payment from a CLI is prohibited, and store receipts are + rejected for third-party api_ids anyway — so the useful answer is the + deep link, not an error. + """ + from telethon.tl.functions import help as fn + + handle = client(ctx) + me = await handle.get_me() + config = await _settings.app_config(ctx) + result = PremiumStatus( + premium=bool(getattr(me, "premium", False)), + premium_purchase_blocked=bool(config.get("premium_purchase_blocked", True)), + premium_bot=str(config.get("premium_bot_username") or "") or None, + reason=_settings.NO_SPEND, + ) + try: + promo = await handle(fn.GetPremiumPromoRequest()) + except Exception as exc: # pragma: no cover - promo is optional + ctx.warn(f"the Premium promo is unavailable: {exc}") + return result + for option in getattr(promo, "period_options", None) or []: + link = getattr(option, "bot_url", None) + if link: + result.invoice_link = str(link) + break + return result + + +SPEC_STATUS = OperationSpec( + id="premium.status", + request=StatusReq, + response=PremiumStatus, + impl=status, + summary="My Telegram Premium status (and how to buy it, which tlgr never does)", + idempotent=True, + columns=("premium", "premium_purchase_blocked", "premium_bot"), + headers=("Premium", "Purchase blocked", "Bot"), + example={"premium": True, "premium_purchase_blocked": True, "premium_bot": "PremiumBot"}, + example_args="premium status", + covers=("premium.status",), + tags=frozenset({"agent-safe"}), +) + + +class FeatureListReq(Request): + limits: Annotated[bool, opt("--limits", help="Only the *_limit_default/_premium table.")] = ( + False + ) + boost_levels: Annotated[ + bool, opt("--boost-levels", help="The boost-level unlock table from appConfig.") + ] = False + channel: Annotated[ + PeerRef | None, + opt("--channel", metavar="CHAT", kind="peer", help="Show this channel's boost level."), + ] = None + + +async def feature_list(ctx: OpContext, req: FeatureListReq) -> PremiumFeatures: + """Premium features, the promo text and the limit table. + + The limit table is the practically useful half: caption length, upload + size, folder counts, pinned chats, public usernames. There is no MTProto + method for the boost-level table either — it is `channel_*_level_min` and + `group_*_level_min` from appConfig, assembled here. + """ + from telethon.tl.functions import help as fn + from telethon.tl.functions import premium as pfn + + handle = client(ctx) + config = await _settings.app_config(ctx) + result = PremiumFeatures() + + pairs: dict[str, dict[str, int]] = {} + for key, value in config.items(): + for suffix, side in ((_LIMIT_DEFAULT, "default"), (_LIMIT_PREMIUM, "premium")): + if key.endswith(suffix): + try: + pairs.setdefault(key[: -len(suffix)], {})[side] = int(float(value)) + except (TypeError, ValueError): + continue + result.limits = [ + PremiumLimit(name=name, default=sides.get("default", 0), premium=sides.get("premium", 0)) + for name, sides in sorted(pairs.items()) + ] + + if req.boost_levels or not req.limits: + result.boost_levels = sorted( + ( + {"key": key, "level": int(float(value))} + for key, value in config.items() + if any(key.endswith(suffix) for suffix in _LEVEL_SUFFIXES) and _is_number(value) + ), + key=lambda row: (int(row["level"]), str(row["key"])), + ) + + if req.channel is not None: + peer = await _settings.resolve(ctx, req.channel) + boosts = await handle(pfn.GetBoostsStatusRequest(peer=peer)) + result.channel_level = int(getattr(boosts, "level", 0) or 0) + + if req.limits or req.boost_levels: + return result + + promo = await handle(fn.GetPremiumPromoRequest()) + result.status_text = str(getattr(promo, "status_text", "") or "") + result.period_options = [ + { + "months": int(getattr(option, "months", 0) or 0), + "currency": str(getattr(option, "currency", "") or ""), + "amount": int(getattr(option, "amount", 0) or 0), + "bot_url": getattr(option, "bot_url", None), + } + for option in getattr(promo, "period_options", None) or [] + ] + result.video_sections = [str(name) for name in getattr(promo, "video_sections", None) or []] + return result + + +def _is_number(value: Any) -> bool: + try: + float(value) + except (TypeError, ValueError): + return False + return True + + +SPEC_FEATURE_LIST = OperationSpec( + id="premium.feature.list", + request=FeatureListReq, + response=PremiumFeatures, + impl=feature_list, + summary="Premium features, promo text and the default/premium limit table", + description=( + "`--limits` is the part a script needs: it is what decides whether a " + "caption, an upload or a folder will be accepted before it is sent." + ), + aliases=("premium.features",), + idempotent=True, + columns=("status_text", "channel_level"), + headers=("Promo", "Channel level"), + example={ + "limits": [{"name": "caption_length", "default": 1024, "premium": 2048}], + "boost_levels": [], + }, + example_args="premium feature list --limits", + covers=("content.limits", "premium.boost-level-features", "premium.features-list"), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# premium boost list +# --------------------------------------------------------------------------- + + +class BoostListReq(Request): + channel: Annotated[ + PeerRef | None, + arg(0, metavar="CHANNEL", required=False, kind="peer", help="Omit for my own slots."), + ] = None + gifts: Annotated[bool, opt("--gifts", help="Only gift and giveaway boosts.")] = False + of_user: Annotated[ + PeerRef | None, + opt("--of-user", metavar="USER", kind="user", help="Narrow to one booster."), + ] = None + + +async def boost_list(ctx: OpContext, req: BoostListReq) -> Page[Boost]: + """My boost slots, or the boosts applied to a channel I administer. + + The same listing `boost list` answers with, reached from the Premium + screen: Premium grants `boosts_per_premium` slots and gifting Premium + adds more, which is a fact about the subscription rather than about a + channel. + """ + from tlgr.ops.chat_stats import BoostListReq as StatsBoostListReq + from tlgr.ops.chat_stats import list_boosts + + return await list_boosts( + ctx, + StatsBoostListReq( + chat=req.channel, + user=req.of_user, + gifts=req.gifts, + mine=req.channel is None, + ), + ) + + +SPEC_BOOST_LIST = OperationSpec( + id="premium.boost.list", + request=BoostListReq, + response=Page[Boost], + impl=boost_list, + summary="My boost slots, or the boosts applied to a channel I administer", + aliases=("boost.slots",), + paginated=PageKind.PARTICIPANTS, + idempotent=True, + columns=("slot", "chat_id", "user_id", "expires", "cooldown_until_date"), + headers=("Slot", "Chat", "User", "Expires", "Cooldown"), + example={ + "items": [{"slot": 1, "chat_id": -1001600, "expires": "2026-10-01T00:00:00Z"}], + "has_more": False, + }, + example_args="premium boost list", + covers=("premium.channel-boosts-list", "premium.my-boosts", "stories.boost-status"), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# premium gift list / send +# --------------------------------------------------------------------------- + + +class GiftListReq(Request): + boost_peer: Annotated[ + PeerRef | None, + opt( + "--boost-peer", metavar="CHAT", kind="peer", help="Options tied to boosting a channel." + ), + ] = None + single: Annotated[ + bool, opt("--single/--all-options", help="Only options for a single recipient.") + ] = True + + +async def gift_list(ctx: OpContext, req: GiftListReq) -> Page[PremiumGiftOption]: + """Premium gift price options, in Stars and in fiat. + + A third-party client can only act on the `XTR` (Stars) options; the fiat + ones exist for the official apps' store flows. Options with `users > 1` + are giveaway options rather than direct gifts. + """ + from telethon.tl.functions import payments as fn + + peer = await _settings.resolve(ctx, req.boost_peer) if req.boost_peer else None + result = await client(ctx)(fn.GetPremiumGiftCodeOptionsRequest(boost_peer=peer)) + rows = [ + PremiumGiftOption( + months=int(getattr(option, "months", 0) or 0), + users=int(getattr(option, "users", 1) or 1), + currency=str(getattr(option, "currency", "") or ""), + amount=int(getattr(option, "amount", 0) or 0), + store_product=getattr(option, "store_product", None), + ) + for option in result or [] + ] + if req.single: + rows = [row for row in rows if row.users == 1] + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_GIFT_LIST = OperationSpec( + id="premium.gift.list", + request=GiftListReq, + response=Page[PremiumGiftOption], + impl=gift_list, + summary="Premium gift price options (Stars and fiat), for a user or a channel giveaway", + aliases=("premium.gift.options",), + paginated=PageKind.LOCAL, + idempotent=True, + columns=("months", "users", "currency", "amount", "store_product"), + headers=("Months", "Users", "Currency", "Amount", "Store"), + example={ + "items": [{"months": 3, "users": 1, "currency": "XTR", "amount": 1000}], + "has_more": False, + }, + example_args="premium gift list", + covers=("premium.gift-options",), + tags=frozenset({"agent-safe"}), +) + + +class GiftSendReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to gift Premium to.")] + months: Annotated[int | None, opt("--months", metavar="N", help="Subscription length.")] = None + message: Annotated[ + str | None, opt("--message", metavar="TEXT", help="Note attached to the gift.") + ] = None + + +async def gift_send(ctx: OpContext, req: GiftSendReq) -> PremiumGiftQuote: + """Price gifting Premium to a user — and refuse to buy it. + + The form is fetched so the price is visible and the recipient is + validated; `payments.sendStarsForm` is deliberately absent from tlgr's + whole surface, so `ok` is false and `reason` says why. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + if not req.months: + raise UsageError( + "--months is the subscription length; `premium gift list` shows the options", + field="months", + ) + user = await _settings.input_user(ctx, req.user, field="user") + invoice = types.InputInvoicePremiumGiftStars( + user_id=user, + months=int(req.months), + message=(types.TextWithEntities(text=req.message, entities=[]) if req.message else None), + ) + form = await client(ctx)(fn.GetPaymentFormRequest(invoice=invoice)) + prices = getattr(getattr(form, "invoice", None), "prices", None) or [] + return PremiumGiftQuote( + user_id=_settings.peer_of(await _settings.resolve(ctx, req.user)), + months=int(req.months), + stars=sum(int(getattr(price, "amount", 0) or 0) for price in prices), + currency=str(getattr(getattr(form, "invoice", None), "currency", "XTR") or "XTR"), + ok=False, + reason=_settings.NO_SPEND, + form_id=getattr(form, "form_id", None), + ) + + +SPEC_GIFT_SEND = OperationSpec( + id="premium.gift.send", + request=GiftSendReq, + response=PremiumGiftQuote, + impl=gift_send, + summary="Price gifting Telegram Premium to a user (tlgr reads the form, never signs it)", + idempotent=True, + columns=("user_id", "months", "stars", "currency", "ok"), + headers=("User", "Months", "Stars", "Currency", "Sent"), + example={"user_id": 777123, "months": 3, "stars": 1000, "currency": "XTR", "ok": False}, + example_args="premium gift send @alice --months 3", + covers_partial=("premium.gift-to-user",), + coverage_note=( + "The recipient, the length and the price are reported; signing the " + "payment form is absent from tlgr's whole surface by policy." + ), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# premium giftcode get +# --------------------------------------------------------------------------- + + +def _code_model(slug: str, raw: Any) -> GiftCode: + date = getattr(raw, "date", None) + return GiftCode( + slug=slug, + link=f"https://t.me/giftcode/{slug}", + from_id=_peer_id(getattr(raw, "from_id", None)), + to_id=getattr(raw, "to_id", None), + date=fmt_dt(date), + date_unix=to_unix(date), + months=getattr(raw, "months", None), + days=getattr(raw, "months", None) and int(getattr(raw, "months", 0)) * 30, + used_date=fmt_dt(getattr(raw, "used_date", None)), + via_giveaway=bool(getattr(raw, "via_giveaway", False)), + giveaway_msg_id=getattr(raw, "giveaway_msg_id", None), + used=getattr(raw, "used_date", None) is not None, + ) + + +def _peer_id(peer: Any) -> int | None: + from tlgr.ops._serialize import peer_id_of + + return peer_id_of(peer) if peer is not None else None + + +class GiftcodeGetReq(Request): + slug: Annotated[str, arg(0, metavar="SLUG", help="The gift code, or a t.me/giftcode link.")] + redeem: Annotated[bool, opt("--redeem", help="Apply the code to this account (free).")] = False + + +async def giftcode_get(ctx: OpContext, req: GiftcodeGetReq) -> GiftCode: + """Check a Premium gift code, and optionally redeem it. + + Redeeming involves no payment: the code was already bought by whoever + sent it, so this is one of the few `payments.*` writes tlgr performs. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + slug = req.slug.rsplit("/", 1)[-1] + result = await handle(fn.CheckGiftCodeRequest(slug=slug)) + model = _code_model(slug, result) + if req.redeem: + if model.used: + _already(ctx) + else: + await handle(fn.ApplyGiftCodeRequest(slug=slug)) + ctx.emit("giftcode_applied", {"slug": slug}) + model.used = True + return model + + +def _already(ctx: OpContext) -> None: + mark = getattr(ctx, "mark_already", None) + if callable(mark): + mark() + + +SPEC_GIFTCODE_GET = OperationSpec( + id="premium.giftcode.get", + request=GiftcodeGetReq, + response=GiftCode, + impl=giftcode_get, + summary="Check a Premium gift code, and optionally redeem it", + description="Redeeming costs nothing — the code is already paid for.", + mutating=True, + idempotent=True, + rate_class="send", + columns=("slug", "from_id", "to_id", "months", "used_date", "via_giveaway"), + headers=("Slug", "From", "To", "Months", "Used", "Giveaway"), + example={"slug": "abcdef", "from_id": -1001600, "months": 3, "via_giveaway": True}, + example_args="premium giftcode get abcdef", + covers=("premium.giftcode-apply", "premium.giftcode-check"), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] diff --git a/tlgr/ops/stars.py b/tlgr/ops/stars.py new file mode 100644 index 0000000..cc6236a --- /dev/null +++ b/tlgr/ops/stars.py @@ -0,0 +1,594 @@ +"""The `stars` group: the balance, the ledger, subscriptions and revenue. + +Everything here reads. Acquiring Stars, moving them and withdrawing them are +financial transfers, and tlgr performs none of them — `stars url get` prints +the Fragment URL and leaves the transfer to a human in a browser, which is +what "control-only" means in the catalog and what the whole group is shaped +around. + +Two details are load-bearing. + +* **A Stars amount is `(amount, nanos)`.** TON arrives in the same shape with + nine decimals, and both halves are reported. A ledger that rounds is a + ledger that cannot be reconciled. +* **The transactions cursor is an opaque string**, not an integer offset. + `next_offset` comes back from the server and goes back to it unchanged; + tlgr signs it into a normal `--cursor` token so it cannot be spliced onto + another account or another op. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from tlgr.core.errors import UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.payment import StarSubscription +from tlgr.models.peer import PeerRef +from tlgr.models.stars import ( + StarsBalance, + StarsRating, + StarsRefulfill, + StarsRevenue, + StarsTransaction, + StarsUrl, +) +from tlgr.ops import _settings +from tlgr.ops._common import client, window +from tlgr.ops._params import arg, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +_PASSWORD = opt( + secret=True, envvar="TLGR_2FA_PASSWORD", help="The 2FA cloud password (never in argv)." +) + +#: `starsTransactionPeer*` → the one word that says who the other side was. +PEER_KINDS = { + "StarsTransactionPeer": "peer", + "StarsTransactionPeerAppStore": "app-store", + "StarsTransactionPeerPlayMarket": "play-market", + "StarsTransactionPeerPremiumBot": "premium-bot", + "StarsTransactionPeerFragment": "fragment", + "StarsTransactionPeerAds": "ads", + "StarsTransactionPeerAPI": "api", + "StarsTransactionPeerUnsupported": "unsupported", +} + +#: The boolean flags a transaction carries, in the order a reader wants them. +KINDS = ( + "gift", + "reaction", + "stargift_upgrade", + "stargift_resale", + "stargift_auction_bid", + "business_transfer", + "posts_search", + "offer", +) + + +# --------------------------------------------------------------------------- +# stars balance get +# --------------------------------------------------------------------------- + + +class BalanceGetReq(Request): + ton: Annotated[bool, opt("--ton", help="The TON balance instead (amounts are nanotons).")] = ( + False + ) + + +async def balance_get(ctx: OpContext, req: BalanceGetReq) -> StarsBalance: + """My Telegram Stars balance, or my TON balance. + + Read-only on purpose: topping up and withdrawing happen on Fragment or in + an official app, and a CLI that could do either would be a CLI that could + lose money by accident. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + result = await client(ctx)( + fn.GetStarsStatusRequest(peer=types.InputPeerSelf(), ton=req.ton or None) + ) + amount, nanos = _settings.stars_of(getattr(result, "balance", None)) + missing = [ + row + for row in getattr(result, "subscriptions", None) or [] + if getattr(row, "missing_balance", False) + ] + return StarsBalance( + stars=0 if req.ton else amount, + nanos=nanos, + ton=amount if req.ton else None, + currency="TON" if req.ton else "XTR", + subscriptions_missing_balance=len(missing) or None, + ) + + +SPEC_BALANCE_GET = OperationSpec( + id="stars.balance.get", + request=BalanceGetReq, + response=StarsBalance, + impl=balance_get, + summary="My Telegram Stars balance (and the TON balance with --ton)", + description=( + "`nanos` is the fractional part the wire carries; TON amounts are " + "nanotons. Neither is rounded, because a rounded ledger cannot be " + "reconciled." + ), + idempotent=True, + columns=("stars", "nanos", "ton", "subscriptions_missing_balance"), + headers=("Stars", "Nanos", "TON", "Lapsing subs"), + example={"stars": 250, "nanos": 0, "currency": "XTR"}, + example_args="stars balance get", + covers=( + "bots.bot-stars-balance", + "bots.stars-topup-deeplink", + "bots.stars-topup-options", + "stars.balance", + "stars.topup-options", + ), + covers_partial=("stars.ton-balance",), + coverage_note="The TON ledger itself is `stars transaction list --ton`.", + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# stars transaction list +# --------------------------------------------------------------------------- + + +class TransactionListReq(Request): + inbound: Annotated[bool, opt("--in", help="Incoming only.")] = False + outbound: Annotated[bool, opt("--out", help="Outgoing only.")] = False + ton: Annotated[bool, opt("--ton", help="The TON ledger instead of the Stars one.")] = False + peer: Annotated[ + PeerRef | None, + opt("--peer", metavar="CHAT", kind="peer", help="Transactions with one bot or channel."), + ] = None + subscription: Annotated[ + str | None, opt("--subscription", metavar="ID", help="Only one subscription's charges.") + ] = None + ascending: Annotated[bool, opt("--ascending", help="Oldest first.")] = False + id: Annotated[ + str | None, opt("--id", metavar="LIST", help="Fetch specific transactions by id.") + ] = None + + +async def transaction_list(ctx: OpContext, req: TransactionListReq) -> Page[StarsTransaction]: + """Star (or TON) transaction history. + + The cursor here is the server's opaque `next_offset` string rather than a + number: passing an integer where the API wants a token silently restarts + the walk from the beginning, which is how a ledger export ends up with + the first page repeated. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + handle = client(ctx) + limit, state = window(ctx, "stars.transaction.list", PageKind.RATE, default=50) + peer = await _settings.resolve(ctx, req.peer) if req.peer is not None else types.InputPeerSelf() + + if req.id: + wanted = [ + types.InputStarsTransaction(id=part.strip()) + for part in req.id.split(",") + if part.strip() + ] + result = await handle( + fn.GetStarsTransactionsByIDRequest(peer=peer, id=wanted, ton=req.ton or None) + ) + rows = [_transaction(row, result) for row in getattr(result, "history", None) or []] + return Page(items=rows, has_more=False, total=len(rows)) + + result = await handle( + fn.GetStarsTransactionsRequest( + peer=peer, + offset=str(state.get("offset", "") or ""), + limit=limit, + inbound=req.inbound or None, + outbound=req.outbound or None, + ascending=req.ascending or None, + ton=req.ton or None, + subscription_id=req.subscription, + ) + ) + rows = [_transaction(row, result) for row in getattr(result, "history", None) or []] + next_offset = str(getattr(result, "next_offset", "") or "") + return build_page( + rows, + op="stars.transaction.list", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + ) + + +def _transaction(raw: Any, envelope: Any) -> StarsTransaction: + from tlgr.ops._serialize import peer_id_of + + amount, nanos = _settings.stars_of(getattr(raw, "amount", None)) + holder = getattr(raw, "peer", None) + inner = getattr(holder, "peer", None) + known = _settings.entity_map(envelope) + peer_id = peer_id_of(inner) if inner is not None else None + date = getattr(raw, "date", None) + return StarsTransaction( + id=str(getattr(raw, "id", "") or ""), + date=fmt_dt(date), + date_unix=to_unix(date), + stars=amount, + nanos=nanos, + refund=bool(getattr(raw, "refund", False)), + pending=bool(getattr(raw, "pending", False)), + failed=bool(getattr(raw, "failed", False)), + peer=peer_id, + peer_kind=PEER_KINDS.get(type(holder).__name__, ""), + peer_ref=_settings.peer_model(known.get(abs(peer_id)) if peer_id else None), + title=getattr(raw, "title", None), + description=getattr(raw, "description", None), + msg_id=getattr(raw, "msg_id", None), + subscription_period=getattr(raw, "subscription_period", None), + transaction_url=getattr(raw, "transaction_url", None), + kind=next((name for name in KINDS if getattr(raw, name, False)), ""), + ) + + +SPEC_TRANSACTION_LIST = OperationSpec( + id="stars.transaction.list", + request=TransactionListReq, + response=Page[StarsTransaction], + impl=transaction_list, + summary="Star (or TON) transaction history", + aliases=("stars.transactions",), + paginated=PageKind.RATE, + idempotent=True, + columns=("id", "date", "stars", "peer", "title", "kind", "refund"), + headers=("Id", "Date", "Stars", "Peer", "Title", "Kind", "Refund"), + example={ + "items": [ + {"id": "tx1", "stars": -25, "peer": 5000001, "title": "Sticker pack", "kind": "gift"} + ], + "has_more": False, + }, + example_args="stars transaction list --out", + covers=("stars.ton-balance", "stars.transactions"), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# stars subscription list / refulfill +# --------------------------------------------------------------------------- + + +class SubscriptionListReq(Request): + missing_balance: Annotated[ + bool, opt("--missing-balance", help="Only the ones about to lapse for want of Stars.") + ] = False + + +async def subscription_list(ctx: OpContext, req: SubscriptionListReq) -> Page[StarSubscription]: + """My Star subscriptions. + + `can_refulfill` means the *server* would allow a re-join; tlgr still + refuses to make the charge, which is what `stars subscription refulfill` + reports. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + from tlgr.ops._serialize import peer_id_of + + _, state = window(ctx, "stars.subscription.list", PageKind.RATE, default=50) + result = await client(ctx)( + fn.GetStarsSubscriptionsRequest( + peer=types.InputPeerSelf(), + offset=str(state.get("offset", "") or ""), + missing_balance=req.missing_balance or None, + ) + ) + rows = [] + for raw in getattr(result, "subscriptions", None) or []: + pricing = getattr(raw, "pricing", None) + until = getattr(raw, "until_date", None) + rows.append( + StarSubscription( + id=str(getattr(raw, "id", "") or ""), + peer=peer_id_of(getattr(raw, "peer", None)), + until_date=fmt_dt(until), + until_date_unix=to_unix(until), + pricing=( + { + "period": int(getattr(pricing, "period", 0) or 0), + "amount": int(getattr(pricing, "amount", 0) or 0), + } + if pricing is not None + else None + ), + cancelled=getattr(raw, "canceled", None), + can_refulfill=getattr(raw, "can_refulfill", None), + missing_balance=getattr(raw, "missing_balance", None), + invoice_slug=getattr(raw, "invoice_slug", None), + chat_invite_hash=getattr(raw, "chat_invite_hash", None), + title=getattr(raw, "title", None), + ) + ) + next_offset = str(getattr(result, "subscriptions_next_offset", "") or "") + return build_page( + rows, + op="stars.subscription.list", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + ) + + +SPEC_SUBSCRIPTION_LIST = OperationSpec( + id="stars.subscription.list", + request=SubscriptionListReq, + response=Page[StarSubscription], + impl=subscription_list, + summary="My Star subscriptions", + paginated=PageKind.RATE, + idempotent=True, + columns=("id", "peer", "until_date", "cancelled", "missing_balance"), + headers=("Id", "Peer", "Until", "Cancelled", "Lapsing"), + example={ + "items": [{"id": "sub1", "peer": -1001600, "until_date": "2026-10-01T00:00:00Z"}], + "has_more": False, + }, + example_args="stars subscription list", + covers=( + "groups-channels-admin.channel-subscription-manage", + "stars.subscriptions-list", + ), + tags=frozenset({"agent-safe"}), +) + + +class SubscriptionRefulfillReq(Request): + id: Annotated[str, arg(0, metavar="ID", help="The subscription id.")] + + +async def subscription_refulfill(ctx: OpContext, req: SubscriptionRefulfillReq) -> StarsRefulfill: + """Report whether a lapsed Star subscription could be re-joined — and refuse to. + + Re-joining debits Stars. `payments.fulfillStarsSubscription` is one of the + four methods `ops/payment.py` names as deliberately absent from tlgr's + surface, and this command exists to say so with the subscription's own + state attached rather than to be a second way in. + """ + page = await subscription_list(ctx, SubscriptionListReq()) + for row in page.items: + if row.id == req.id: + return StarsRefulfill( + id=req.id, + ok=False, + can_refulfill=row.can_refulfill, + stars=(row.pricing or {}).get("amount"), + reason=_settings.NO_SPEND, + ) + raise UsageError( + f"no Star subscription with the id {req.id!r}; `stars subscription list` shows them", + field="id", + ) + + +SPEC_SUBSCRIPTION_REFULFILL = OperationSpec( + id="stars.subscription.refulfill", + request=SubscriptionRefulfillReq, + response=StarsRefulfill, + impl=subscription_refulfill, + summary="Report whether a lapsed Star subscription can be re-joined (tlgr does not charge)", + idempotent=True, + columns=("id", "ok", "can_refulfill", "stars", "reason"), + headers=("Id", "Done", "Allowed", "Stars", "Why not"), + example={"id": "sub1", "ok": False, "can_refulfill": True, "stars": 100}, + example_args="stars subscription refulfill sub1", + covers_partial=("stars.subscription-refulfill",), + coverage_note=( + "Whether the server would allow it, and what it would cost, are " + "reported; the charge itself is absent from tlgr's surface by policy." + ), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# stars rating get / revenue get / url get +# --------------------------------------------------------------------------- + + +class RatingGetReq(Request): + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="Whose rating (default: me)."), + ] = None + + +async def rating_get(ctx: OpContext, req: RatingGetReq) -> StarsRating: + """The Star rating badge: level, progress and what is still pending.""" + from telethon.tl import types + from telethon.tl.functions import users as fn + + target = ( + await _settings.input_user(ctx, req.user) if req.user is not None else types.InputUserSelf() + ) + answer = await client(ctx)(fn.GetFullUserRequest(id=target)) + full = getattr(answer, "full_user", None) + rating = getattr(full, "stars_rating", None) + pending = getattr(full, "stars_my_pending_rating", None) + config = await _settings.app_config(ctx) + return StarsRating( + level=int(getattr(rating, "level", 0) or 0), + stars=int(getattr(rating, "stars", 0) or 0), + current_level_stars=int(getattr(rating, "current_level_stars", 0) or 0), + next_level_stars=getattr(rating, "next_level_stars", None), + pending_stars=getattr(pending, "stars", None), + pending_date=fmt_dt(getattr(full, "stars_my_pending_rating_date", None)), + learnmore_url=str(config.get("stars_rating_learnmore_url") or "") or None, + ) + + +SPEC_RATING_GET = OperationSpec( + id="stars.rating.get", + request=RatingGetReq, + response=StarsRating, + impl=rating_get, + summary="Star rating badge (level and progress)", + idempotent=True, + columns=("level", "stars", "current_level_stars", "next_level_stars", "pending_stars"), + headers=("Level", "Stars", "This level", "Next level", "Pending"), + example={"level": 3, "stars": 1200, "current_level_stars": 1000, "next_level_stars": 2000}, + example_args="stars rating get", + covers=("stars.rating",), + tags=frozenset({"agent-safe"}), +) + + +class RevenueGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="A channel or bot I own.")] + ton: Annotated[bool, opt("--ton", help="TON revenue instead of Stars.")] = False + dark: Annotated[bool, opt("--dark", help="Dark-theme graph tokens.")] = False + + +async def revenue_get(ctx: OpContext, req: RevenueGetReq) -> StarsRevenue: + """Star (or ad) revenue statistics for a channel or bot I own. + + The graphs come back as `statsGraphAsync` tokens that need a second load; + the token is reported rather than resolved, because loading it is the + stats domain's job and doing it here would double every call. + """ + from telethon.tl.functions import payments as fn + + peer = await _settings.resolve(ctx, req.chat) + result = await client(ctx)( + fn.GetStarsRevenueStatsRequest(peer=peer, dark=req.dark or None, ton=req.ton or None) + ) + status = getattr(result, "status", None) + current, _ = _settings.stars_of(getattr(status, "current_balance", None)) + available, _ = _settings.stars_of(getattr(status, "available_balance", None)) + overall, _ = _settings.stars_of(getattr(status, "overall_revenue", None)) + return StarsRevenue( + chat_id=_settings.peer_of(peer), + current_balance=current, + available_balance=available, + overall_revenue=overall, + withdrawal_enabled=bool(getattr(status, "withdrawal_enabled", False)), + next_withdrawal_at=fmt_dt(getattr(status, "next_withdrawal_at", None)), + usd_rate=getattr(result, "usd_rate", None), + revenue_graph=_graph(getattr(result, "revenue_graph", None)), + top_hours_graph=_graph(getattr(result, "top_hours_graph", None)), + ) + + +def _graph(raw: Any) -> dict[str, Any] | None: + if raw is None: + return None + return { + "kind": type(raw).__name__.removeprefix("StatsGraph").lower() or "graph", + "token": getattr(raw, "token", None), + "json": getattr(getattr(raw, "json", None), "data", None), + "error": getattr(raw, "error", None), + } + + +SPEC_REVENUE_GET = OperationSpec( + id="stars.revenue.get", + request=RevenueGetReq, + response=StarsRevenue, + impl=revenue_get, + summary="Star / ad revenue statistics for a channel or bot I own", + description="Needs `channelFull.can_view_stars_revenue`; the graphs are async tokens.", + idempotent=True, + columns=("chat_id", "current_balance", "available_balance", "withdrawal_enabled"), + headers=("Chat", "Balance", "Available", "Withdrawable"), + example={"chat_id": -1001600, "current_balance": 4200, "withdrawal_enabled": True}, + example_args="stars revenue get @mychannel", + covers=("bots.bot-revenue-stats", "stars.revenue-stats"), + tags=frozenset({"agent-safe"}), +) + + +class UrlGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel or bot.")] + password: Annotated[str | None, _PASSWORD] = None + ads: Annotated[bool, opt("--ads", help="The ads-account URL instead of a withdrawal URL.")] = ( + False + ) + amount: Annotated[int | None, opt("--amount", metavar="STARS", help="Amount to withdraw.")] = ( + None + ) + ton: Annotated[bool, opt("--ton", help="Withdraw TON instead of Stars.")] = False + + +async def url_get(ctx: OpContext, req: UrlGetReq) -> StarsUrl: + """Print the Fragment URL for withdrawing revenue, or for the ads account. + + Control-only by design. The withdrawal needs the cloud password as an SRP + proof, and what comes back is a URL a human opens in a browser — tlgr + prints it and stops, and does not drive the ad-purchase flow either. + """ + from telethon.tl.functions import payments as fn + + from tlgr.ops import _auth + + handle = client(ctx) + peer = await _settings.resolve(ctx, req.chat) + if req.ads: + result = await handle(fn.GetStarsRevenueAdsAccountUrlRequest(peer=peer)) + return StarsUrl( + url=str(getattr(result, "url", "") or ""), + kind="ads", + chat_id=_settings.peer_of(peer), + ) + + result = await _auth.with_password( + handle, + lambda srp: fn.GetStarsRevenueWithdrawalUrlRequest( + peer=peer, password=srp, ton=req.ton or None, amount=req.amount + ), + req.password, + ) + return StarsUrl( + url=str(getattr(result, "url", "") or ""), + kind="withdrawal", + chat_id=_settings.peer_of(peer), + amount=req.amount, + ton=req.ton, + ) + + +SPEC_URL_GET = OperationSpec( + id="stars.url.get", + request=UrlGetReq, + response=StarsUrl, + impl=url_get, + summary="Get the Fragment URL for withdrawing revenue, or for buying ads with Stars", + description=( + "Control-only: the URL is printed and the human completes the " + "transfer in a browser. tlgr moves no money." + ), + idempotent=True, + rate_class="send", + columns=("kind", "url", "amount", "ton"), + headers=("Kind", "URL", "Amount", "TON"), + example={"kind": "withdrawal", "url": "https://fragment.com/stars/withdraw?…"}, + example_args="stars url get @mychannel --amount 1000", + covers=("gifts.withdraw-ton", "stars.ads-account", "stars.withdraw"), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] From d413b3563d35d42041c89f041664cb9a6edfed48 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 07:22:29 +0330 Subject: [PATCH 07/15] gift ops: nineteen operations, and one reference spelling for all of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gift is addressed by a `ref`: `msg:<id>` for one received in a private chat, `<peer>:<saved_id>` for one a channel holds, or a bare collectible slug (a t.me/nft/ link is reduced to one). One spelling means a reference read out of `gift list` goes straight into `gift set`, `gift convert`, `gift transfer` or `gift upgrade` with no lookup table in between. Every time gate the server publishes is surfaced instead of collapsed. "Can I transfer this?" has three answers — yes, not yet and here is when, never — and a client that reports only the first two sends its user to wait for a date that will not come. The dividing line is cost, not danger. Displaying, pinning, wearing, converting back into Stars, a free transfer, a prepaid upgrade, listing a collectible for sale, declining an offer and crafting are all performed; anything that would need a payment form signed is priced and refused with the reason attached. `gift craft` is the exception that proves it: free, and still gated behind `--yes`, because it burns every input gift whatever the outcome. Three flags name a method Telethon 1.44 has no request class for (`canSendStarGift`, `getStarGiftCraftCandidates`, `getStarGiftAttributes`) and refuse with exit 13 saying which; the rest of each command still works. --- tlgr/ops/gift.py | 1511 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1511 insertions(+) create mode 100644 tlgr/ops/gift.py diff --git a/tlgr/ops/gift.py b/tlgr/ops/gift.py new file mode 100644 index 0000000..228f39e --- /dev/null +++ b/tlgr/ops/gift.py @@ -0,0 +1,1511 @@ +"""The `gift` group: the catalogue, the gifts a profile holds, collectibles. + +A gift is addressed by a **reference**, and `ref` is the same string +everywhere here: `msg:<id>` for one received in a private chat, +`<peer>:<saved_id>` for one a channel holds, or a bare collectible slug (a +`t.me/nft/<slug>` link is accepted). One spelling, so a `ref` read out of +`gift list` can be handed straight to `gift set`, `gift convert`, `gift +transfer` or `gift upgrade` without a lookup table. + +The dividing line in this group is **cost**, not danger: + +* free operations are performed — displaying, pinning, wearing, converting a + gift back into Stars, a free transfer, a prepaid upgrade, listing a + collectible for sale, declining an offer, crafting; +* anything whose completion needs a payment form signed is priced and + refused, with `refused_reason` saying so. `payments.sendStarsForm` is + absent from tlgr's whole surface (`ops/payment.py`), and this group does + not open a second door onto it. + +`gift craft` is the one free operation that still needs `--yes`: it burns +every input gift whatever the outcome, which is `rm -rf` semantics without a +payment anywhere near it. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from tlgr.core.errors import NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, fmt_unix, parse_dt, to_unix +from tlgr.models.base import Request +from tlgr.models.gift import ( + GiftAttribute, + GiftAuction, + GiftAuctionState, + GiftCollection, + GiftConverted, + GiftCrafted, + GiftDisplay, + GiftListing, + GiftOfferResolved, + GiftTransferred, + GiftUpgraded, + GiftVariant, + OwnedGift, + ResaleGift, + StarGift, + UniqueGift, +) +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _settings +from tlgr.ops._common import client, window +from tlgr.ops._params import arg, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + + +# --------------------------------------------------------------------------- +# Shared serialisation +# --------------------------------------------------------------------------- + + +def _attribute(raw: Any) -> GiftAttribute: + from tlgr.ops._serialize import peer_id_of + + name = type(raw).__name__.removeprefix("StarGiftAttribute") + kind = { + "Model": "model", + "Pattern": "pattern", + "Backdrop": "backdrop", + "OriginalDetails": "original-details", + }.get(name, name.lower()) + rarity = getattr(raw, "rarity", None) + document = getattr(raw, "document", None) + message = getattr(raw, "message", None) + sender = getattr(raw, "sender_id", None) + recipient = getattr(raw, "recipient_id", None) + return GiftAttribute( + kind=kind, + name=str(getattr(raw, "name", "") or ""), + document_id=getattr(document, "id", None), + rarity_permille=getattr(rarity, "permille", None), + backdrop_id=getattr(raw, "backdrop_id", None), + center_color=getattr(raw, "center_color", None), + edge_color=getattr(raw, "edge_color", None), + pattern_color=getattr(raw, "pattern_color", None), + text_color=getattr(raw, "text_color", None), + crafted=bool(getattr(raw, "crafted", False)), + sender_id=peer_id_of(sender) if sender is not None else None, + recipient_id=peer_id_of(recipient) if recipient is not None else None, + message=getattr(message, "text", None), + date=fmt_dt(getattr(raw, "date", None)), + ) + + +def _unique(raw: Any, known: dict[int, Any] | None = None) -> UniqueGift: + from tlgr.ops._serialize import peer_id_of + + owner = getattr(raw, "owner_id", None) + owner_id = peer_id_of(owner) if owner is not None else None + resale = getattr(raw, "resell_amount", None) or [] + stars = next((a for a in resale if type(a).__name__ == "StarsAmount"), None) + ton = next((a for a in resale if type(a).__name__ == "StarsTonAmount"), None) + slug = str(getattr(raw, "slug", "") or "") + return UniqueGift( + slug=slug, + gift_id=int(getattr(raw, "gift_id", 0) or 0), + id=int(getattr(raw, "id", 0) or 0), + title=str(getattr(raw, "title", "") or ""), + num=int(getattr(raw, "num", 0) or 0), + owner_id=owner_id, + owner=_settings.peer_model((known or {}).get(abs(owner_id)) if owner_id else None), + owner_name=getattr(raw, "owner_name", None), + owner_address=getattr(raw, "owner_address", None), + gift_address=getattr(raw, "gift_address", None), + availability_issued=getattr(raw, "availability_issued", None), + availability_total=getattr(raw, "availability_total", None), + attributes=[_attribute(a) for a in getattr(raw, "attributes", None) or []], + resell_stars=_settings.stars_of(stars)[0] if stars is not None else None, + resell_ton=_settings.stars_of(ton)[0] if ton is not None else None, + resale_ton_only=bool(getattr(raw, "resale_ton_only", False)), + value_stars=getattr(raw, "value_amount", None), + value_currency=getattr(raw, "value_currency", None), + value_usd=getattr(raw, "value_usd_amount", None), + hosted=getattr(raw, "host_id", None) is not None, + burned=bool(getattr(raw, "burned", False)), + crafted=bool(getattr(raw, "crafted", False)), + theme_available=bool(getattr(raw, "theme_available", False)), + peer_color_available=getattr(raw, "peer_color", None) is not None, + offer_min_stars=getattr(raw, "offer_min_stars", None), + craft_chance_permille=getattr(raw, "craft_chance_permille", None), + link=f"https://t.me/nft/{slug}" if slug else None, + ) + + +def _catalog_gift(raw: Any) -> StarGift: + return StarGift( + gift_id=int(getattr(raw, "id", 0) or 0), + title=str(getattr(raw, "title", "") or ""), + stars=int(getattr(raw, "stars", 0) or 0), + convert_stars=getattr(raw, "convert_stars", None), + upgrade_stars=getattr(raw, "upgrade_stars", None), + limited=bool(getattr(raw, "limited", False)), + sold_out=bool(getattr(raw, "sold_out", False)), + birthday=bool(getattr(raw, "birthday", False)), + require_premium=bool(getattr(raw, "require_premium", False)), + availability_remains=getattr(raw, "availability_remains", None), + availability_total=getattr(raw, "availability_total", None), + availability_resale=getattr(raw, "availability_resale", None), + first_sale_date=fmt_dt(getattr(raw, "first_sale_date", None)), + last_sale_date=fmt_dt(getattr(raw, "last_sale_date", None)), + document_id=getattr(getattr(raw, "sticker", None), "id", None), + resell_min_stars=getattr(raw, "resell_min_stars", None), + per_user_total=getattr(raw, "per_user_total", None), + per_user_remains=getattr(raw, "per_user_remains", None), + ) + + +def _owned(raw: Any, known: dict[int, Any]) -> OwnedGift: + from tlgr.ops._serialize import peer_id_of + + gift = getattr(raw, "gift", None) + unique = _unique(gift, known) if type(gift).__name__ == "StarGiftUnique" else None + sender = getattr(raw, "from_id", None) + from_id = peer_id_of(sender) if sender is not None else None + message = getattr(raw, "message", None) + date = getattr(raw, "date", None) + return OwnedGift( + ref=_settings.gift_ref_text(raw), + kind="collectible" if unique is not None else "gift", + gift_id=int(getattr(gift, "id", 0) or 0) or None, + slug=unique.slug if unique is not None else None, + title=str(getattr(gift, "title", "") or ""), + num=getattr(raw, "gift_num", None) or (unique.num if unique is not None else None), + from_id=from_id, + from_peer=_settings.peer_model(known.get(abs(from_id)) if from_id else None), + name_hidden=bool(getattr(raw, "name_hidden", False)), + message=getattr(message, "text", None), + date=fmt_dt(date), + date_unix=to_unix(date), + msg_id=getattr(raw, "msg_id", None), + saved_id=getattr(raw, "saved_id", None), + pinned=bool(getattr(raw, "pinned_to_top", False)), + displayed=not bool(getattr(raw, "unsaved", False)), + refunded=bool(getattr(raw, "refunded", False)), + can_upgrade=bool(getattr(raw, "can_upgrade", False)), + convert_stars=getattr(raw, "convert_stars", None), + upgrade_stars=getattr(raw, "upgrade_stars", None), + transfer_stars=getattr(raw, "transfer_stars", None), + can_export_at=fmt_unix(getattr(raw, "can_export_at", None) or 0) or None, + can_transfer_at=fmt_unix(getattr(raw, "can_transfer_at", None) or 0) or None, + can_resell_at=fmt_unix(getattr(raw, "can_resell_at", None) or 0) or None, + can_craft_at=fmt_unix(getattr(raw, "can_craft_at", None) or 0) or None, + collection_ids=[int(v) for v in getattr(raw, "collection_id", None) or []], + hosted=unique.hosted if unique is not None else False, + attributes=unique.attributes if unique is not None else [], + unique=unique, + resell_stars=unique.resell_stars if unique is not None else None, + resell_ton=unique.resell_ton if unique is not None else None, + ) + + +async def _peer_or_self(ctx: OpContext, ref: PeerRef | None) -> Any: + from telethon.tl import types + + if ref is None: + return types.InputPeerSelf() + return await _settings.resolve(ctx, ref) + + +# --------------------------------------------------------------------------- +# gift catalog +# --------------------------------------------------------------------------- + + +class CatalogReq(Request): + until: Annotated[ + PeerRef | None, + opt("--until", metavar="PEER", kind="peer", help="Annotate each gift with can_send."), + ] = None + limited: Annotated[bool, opt("--limited", help="Only limited-supply gifts.")] = False + available: Annotated[bool, opt("--available", help="Hide sold-out gifts.")] = False + refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False + + +async def catalog(ctx: OpContext, req: CatalogReq) -> Page[StarGift]: + """Browse the gifts on sale. + + Price discovery is free and automatic; *buying* one is not here, like + every other purchase. `--until` would annotate each row with whether the + named recipient accepts it, and needs `payments.canSendStarGift`, which + Telethon 1.44 has no request class for. + """ + from telethon.tl.functions import payments as fn + + if req.until is not None: + _settings.method_gap("gift catalog --until", "payments.canSendStarGift") + + limit, state = window(ctx, "gift.catalog", PageKind.LOCAL, default=20) + result = await client(ctx)(fn.GetStarGiftsRequest(hash=0)) + rows = [_catalog_gift(gift) for gift in getattr(result, "gifts", None) or []] + if req.limited: + rows = [row for row in rows if row.limited] + if req.available: + rows = [row for row in rows if not row.sold_out] + offset = int(state.get("offset", 0) or 0) + window_rows = rows[offset : offset + limit] + return build_page( + window_rows, + op="gift.catalog", + kind=PageKind.LOCAL, + state={"offset": offset + len(window_rows)}, + account=ctx.account, + has_more=offset + len(window_rows) < len(rows), + total=len(rows), + ) + + +SPEC_CATALOG = OperationSpec( + id="gift.catalog", + request=CatalogReq, + response=Page[StarGift], + impl=catalog, + summary="Browse the gifts on sale", + description=( + "Reading the catalogue is free; buying from it is absent, like every " + "purchase in tlgr. `--until` needs `payments.canSendStarGift`, which " + "this Telethon has no request class for, and refuses with exit 13." + ), + paginated=PageKind.LOCAL, + idempotent=True, + columns=("gift_id", "title", "stars", "limited", "sold_out", "availability_remains"), + headers=("Id", "Title", "Stars", "Limited", "Sold out", "Left"), + example={ + "items": [{"gift_id": 5100, "title": "Plush Pepe", "stars": 500, "limited": True}], + "has_more": False, + }, + example_args="gift catalog --available", + covers=("gift.catalog", "gifts.catalog"), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# gift list / get +# --------------------------------------------------------------------------- + + +class ListReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="PEER", required=False, kind="peer", help="Whose profile; default me."), + ] = None + collection: Annotated[ + int | None, opt("--collection", metavar="ID", help="Only this collection.") + ] = None + sort: Annotated[str, opt("--sort", metavar="ORDER", help="date | value.")] = "date" + exclude_unsaved: Annotated[ + bool, opt("--exclude-unsaved", help="Hide gifts not displayed on the profile.") + ] = False + exclude_unique: Annotated[bool, opt("--exclude-unique", help="Hide collectibles.")] = False + exclude_hosted: Annotated[ + bool, opt("--exclude-hosted", help="Hide TON-hosted collectibles.") + ] = False + only_unique: Annotated[bool, opt("--only-unique", help="Only collectibles.")] = False + + +async def list_(ctx: OpContext, req: ListReq) -> Page[OwnedGift]: + """Gifts a profile holds — mine, or somebody else's. + + Another profile's gifts are only visible when they chose to display them, + so an empty list is not evidence that they have none. + """ + from telethon.tl.functions import payments as fn + + limit, state = window(ctx, "gift.list", PageKind.RATE, default=20) + peer = await _peer_or_self(ctx, req.chat) + result = await client(ctx)( + fn.GetSavedStarGiftsRequest( + peer=peer, + offset=str(state.get("offset", "") or ""), + limit=limit, + exclude_unsaved=req.exclude_unsaved or None, + exclude_unique=req.exclude_unique or None, + exclude_hosted=req.exclude_hosted or None, + exclude_unlimited=None, + sort_by_value=(req.sort == "value") or None, + collection_id=req.collection, + ) + ) + known = _settings.entity_map(result) + rows = [_owned(row, known) for row in getattr(result, "gifts", None) or []] + if req.only_unique: + rows = [row for row in rows if row.kind == "collectible"] + next_offset = str(getattr(result, "next_offset", "") or "") + return build_page( + rows, + op="gift.list", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + total=getattr(result, "count", None), + ) + + +SPEC_LIST = OperationSpec( + id="gift.list", + request=ListReq, + response=Page[OwnedGift], + impl=list_, + summary="Gifts received by a profile (mine or someone else's)", + description=( + "`ref` is the handle every other gift command takes: `msg:<id>`, " + "`<peer>:<saved_id>` or a collectible slug." + ), + paginated=PageKind.RATE, + idempotent=True, + columns=("ref", "kind", "title", "num", "from_id", "displayed", "pinned"), + headers=("Ref", "Kind", "Title", "#", "From", "Shown", "Pinned"), + example={ + "items": [{"ref": "msg:120", "kind": "gift", "title": "Plush Pepe", "convert_stars": 250}], + "has_more": False, + }, + example_args="gift list", + covers=("gift.hosted", "gift.received-list"), + tags=frozenset({"agent-safe"}), +) + + +class GetReq(Request): + ref: Annotated[str, arg(0, metavar="REF", help="msg:<id>, <peer>:<saved_id>, or a slug.")] + + +async def get(ctx: OpContext, req: GetReq) -> OwnedGift: + """One owned gift, with every time gate the server publishes. + + "Can I transfer this?" has three answers — yes, not yet (and here is + when), never — so `can_transfer_at`, `can_resell_at`, `can_export_at`, + `can_craft_at` and `can_upgrade` are all reported rather than collapsed + into one boolean. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + stargift = await _settings.input_gift(ctx, req.ref) + if type(stargift).__name__ == "InputSavedStarGiftSlug": + result = await handle(fn.GetUniqueStarGiftRequest(slug=stargift.slug)) + known = _settings.entity_map(result) + unique = _unique(getattr(result, "gift", None), known) + return OwnedGift( + ref=req.ref, + kind="collectible", + gift_id=unique.gift_id, + slug=unique.slug, + title=unique.title, + num=unique.num, + attributes=unique.attributes, + unique=unique, + hosted=unique.hosted, + resell_stars=unique.resell_stars, + resell_ton=unique.resell_ton, + ) + result = await handle(fn.GetSavedStarGiftRequest(stargift=[stargift])) + known = _settings.entity_map(result) + gifts = getattr(result, "gifts", None) or [] + if not gifts: + raise NotFoundError(f"no gift matches {req.ref!r}") + return _owned(gifts[0], known) + + +SPEC_GET = OperationSpec( + id="gift.get", + request=GetReq, + response=OwnedGift, + impl=get, + summary="Details of one owned gift, including every time gate", + idempotent=True, + columns=("ref", "kind", "title", "convert_stars", "can_transfer_at", "can_resell_at"), + headers=("Ref", "Kind", "Title", "Convert", "Transfer at", "Resell at"), + example={"ref": "msg:120", "kind": "gift", "title": "Plush Pepe", "convert_stars": 250}, + example_args="gift get msg:120", + covers=("gift.get-one", "gifts.get-one"), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# gift set +# --------------------------------------------------------------------------- + + +class SetReq(Request): + ref: Annotated[tuple[str, ...], arg(0, metavar="REF", variadic=True, help="Gift references.")] + peer: Annotated[ + PeerRef | None, + opt("--peer", metavar="PEER", kind="peer", help="Profile the gift lives on."), + ] = None + save: Annotated[bool, opt("--save", help="Display it on the profile.")] = False + unsave: Annotated[bool, opt("--unsave", help="Hide it from the profile.")] = False + pin: Annotated[bool, opt("--pin", help="Pin it to the top of the profile.")] = False + unpin: Annotated[bool, opt("--unpin", help="Unpin it.")] = False + pin_order: Annotated[ + str | None, + opt("--pin-order", metavar="LIST", help="Replace the whole pinned set, in this order."), + ] = None + wear: Annotated[bool, opt("--wear", help="Wear the collectible as my emoji status.")] = False + wear_off: Annotated[bool, opt("--wear-off", help="Stop wearing it.")] = False + until: Annotated[ + str | None, opt("--until", metavar="WHEN", kind="datetime", help="Wear until this time.") + ] = None + + +async def set_(ctx: OpContext, req: SetReq) -> GiftDisplay: + """Show, hide, pin or wear a gift. + + Pinning is a *set* operation on the server — `toggleStarGiftsPinnedToTop` + replaces the pinned collection — so `--pin` reads the current set and + adds to it, and `--pin-order` is the raw replace for when you mean it. + """ + from telethon.tl import types + from telethon.tl.functions import account as afn + from telethon.tl.functions import payments as fn + + handle = client(ctx) + peer = await _peer_or_self(ctx, req.peer) + result = GiftDisplay(ref=req.ref[0] if req.ref else "", refs=list(req.ref)) + + if req.save or req.unsave: + for ref in req.ref: + await handle( + fn.SaveStarGiftRequest( + stargift=await _settings.input_gift(ctx, ref), unsave=req.unsave or None + ) + ) + result.displayed = not req.unsave + + if req.pin or req.unpin or req.pin_order is not None: + wanted: list[str] + if req.pin_order is not None: + wanted = [part.strip() for part in req.pin_order.split(",") if part.strip()] + else: + page = await list_(ctx, ListReq(chat=req.peer)) + pinned = [row.ref for row in page.items if row.pinned] + wanted = ( + [ref for ref in pinned if ref not in req.ref] + list(req.ref) + if req.pin + else [ref for ref in pinned if ref not in req.ref] + ) + await handle( + fn.ToggleStarGiftsPinnedToTopRequest( + peer=peer, + stargift=[await _settings.input_gift(ctx, ref) for ref in wanted], + ) + ) + result.pinned = bool(req.pin) + + if req.wear or req.wear_off: + if req.wear_off: + await handle(afn.UpdateEmojiStatusRequest(emoji_status=types.EmojiStatusEmpty())) + result.worn = False + else: + gift = await get(ctx, GetReq(ref=req.ref[0])) + if gift.unique is None: + raise UsageError("only a collectible can be worn as an emoji status", field="ref") + until = parse_dt(req.until) if req.until else None + await handle( + afn.UpdateEmojiStatusRequest( + emoji_status=types.InputEmojiStatusCollectible( + collectible_id=gift.unique.id, until=until + ) + ) + ) + result.worn = True + result.until = fmt_dt(until) + + if result.displayed is None and result.pinned is None and result.worn is None: + raise UsageError( + "give --save/--unsave, --pin/--unpin/--pin-order or --wear/--wear-off", + field="save", + ) + ctx.emit("gift_display", {"refs": list(req.ref)}) + return result + + +SPEC_SET = OperationSpec( + id="gift.set", + request=SetReq, + response=GiftDisplay, + impl=set_, + summary="Profile display state of a gift: show/hide, pin, or wear it as my emoji status", + aliases=( + "gift.save", + "gift.unsave", + "gift.show", + "gift.hide", + "gift.pin", + "gift.unpin", + "gift.wear", + ), + mutating=True, + idempotent=True, + rate_class="send", + columns=("ref", "displayed", "pinned", "worn", "until"), + headers=("Ref", "Shown", "Pinned", "Worn", "Until"), + example={"ref": "msg:120", "displayed": True}, + example_args="gift set msg:120 --save", + covers=( + "gift.as-emoji-status", + "gift.display-toggle", + "gift.pin", + "gifts.drop-original-details", + "gifts.save-unsave", + ), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# gift convert / upgrade / transfer / craft +# --------------------------------------------------------------------------- + + +class ConvertReq(Request): + ref: Annotated[str, arg(0, metavar="REF", help="The gift to convert.")] + + +async def convert(ctx: OpContext, req: ConvertReq) -> GiftConverted: + """Convert a received gift back into Stars. + + Destructive but free: the gift is destroyed and Stars are credited, which + is why it is confirmed like a spend even though it earns. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + before = await get(ctx, GetReq(ref=req.ref)) + await handle(fn.ConvertStarGiftRequest(stargift=await _settings.input_gift(ctx, req.ref))) + ctx.emit("gift_converted", {"ref": req.ref}) + balance = None + try: + from tlgr.ops.stars import BalanceGetReq, balance_get + + balance = (await balance_get(ctx, BalanceGetReq())).stars + except Exception as exc: # pragma: no cover - the balance is a nicety + ctx.warn(f"could not read the Stars balance afterwards: {exc}") + return GiftConverted( + ref=req.ref, stars_received=before.convert_stars or 0, balance_after=balance + ) + + +SPEC_CONVERT = OperationSpec( + id="gift.convert", + request=ConvertReq, + response=GiftConverted, + impl=convert, + summary="Convert a received gift back into Stars", + description="Free, and irreversible: the gift is gone and cannot be un-converted.", + mutating=True, + destructive=True, + rate_class="send", + columns=("ref", "stars_received", "balance_after"), + headers=("Ref", "Stars", "Balance"), + example={"ref": "msg:120", "stars_received": 250, "balance_after": 500}, + example_args="gift convert msg:120", + covers=("gift.convert-to-stars",), +) + + +class UpgradeReq(Request): + ref: Annotated[str, arg(0, metavar="REF", help="The gift to upgrade.")] + keep_original_details: Annotated[ + bool, opt("--keep-original-details", help="Keep the sender and message on it.") + ] = False + + +async def upgrade(ctx: OpContext, req: UpgradeReq) -> GiftUpgraded: + """Upgrade a gift into a collectible, on the free or prepaid path. + + When the upgrade is prepaid — `savedStarGift.upgrade_stars` is set, or + the service message carried `prepaid_upgrade` — it is a plain method + call and tlgr makes it. When it would need a payment form signed, tlgr + prints the price and refuses, because signing forms is absent from the + whole surface. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + before = await get(ctx, GetReq(ref=req.ref)) + if before.upgrade_stars is None and not before.can_upgrade: + return GiftUpgraded( + ref=req.ref, + upgraded=False, + price_stars=before.upgrade_stars, + refused_reason=( + "this gift's upgrade is not prepaid, so it needs a payment form. " + + _settings.NO_SPEND + ), + ) + result = await handle( + fn.UpgradeStarGiftRequest( + stargift=await _settings.input_gift(ctx, req.ref), + keep_original_details=req.keep_original_details or None, + ) + ) + unique = _find_unique(result) + ctx.emit("gift_upgraded", {"ref": req.ref}) + return GiftUpgraded( + ref=req.ref, + slug=unique.slug if unique is not None else None, + num=unique.num if unique is not None else None, + attributes=unique.attributes if unique is not None else [], + upgraded=True, + price_stars=before.upgrade_stars, + ) + + +def _find_unique(updates: Any) -> UniqueGift | None: + """The `starGiftUnique` an `Updates` container carries, if any.""" + for update in getattr(updates, "updates", None) or []: + for holder in (update, getattr(update, "message", None)): + action = getattr(holder, "action", None) + gift = getattr(action, "gift", None) or getattr(holder, "gift", None) + if type(gift).__name__ == "StarGiftUnique": + return _unique(gift) + return None + + +SPEC_UPGRADE = OperationSpec( + id="gift.upgrade", + request=UpgradeReq, + response=GiftUpgraded, + impl=upgrade, + summary="Upgrade a gift into a collectible (free/prepaid path only)", + mutating=True, + rate_class="send", + columns=("ref", "slug", "num", "upgraded", "price_stars", "refused_reason"), + headers=("Ref", "Slug", "#", "Upgraded", "Price", "Why not"), + example={"ref": "msg:120", "slug": "PlushPepe-42", "num": 42, "upgraded": True}, + example_args="gift upgrade msg:120", + covers=("gift.upgrade", "gifts.upgrade", "gifts.upgrade-preview"), +) + + +class TransferReq(Request): + ref: Annotated[str, arg(0, metavar="REF", help="The collectible to transfer.")] + chat: Annotated[PeerRef, arg(1, metavar="PEER", kind="peer", help="Who to transfer it to.")] + + +async def transfer(ctx: OpContext, req: TransferReq) -> GiftTransferred: + """Transfer a collectible to another peer, when the transfer is free. + + A paid transfer goes through `inputInvoiceStarGiftTransfer` and a payment + form; tlgr prints the price and refuses that path, as it does everywhere + else. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + before = await get(ctx, GetReq(ref=req.ref)) + to_peer = await _settings.resolve(ctx, req.chat) + if before.transfer_stars: + return GiftTransferred( + ref=req.ref, + to=_settings.peer_of(to_peer), + transferred=False, + price_stars=before.transfer_stars, + can_transfer_at=before.can_transfer_at, + refused_reason="this transfer costs Stars. " + _settings.NO_SPEND, + ) + await handle( + fn.TransferStarGiftRequest(stargift=await _settings.input_gift(ctx, req.ref), to_id=to_peer) + ) + ctx.emit("gift_transferred", {"ref": req.ref, "to": _settings.peer_of(to_peer)}) + return GiftTransferred( + ref=req.ref, + to=_settings.peer_of(to_peer), + transferred=True, + can_transfer_at=before.can_transfer_at, + ) + + +SPEC_TRANSFER = OperationSpec( + id="gift.transfer", + request=TransferReq, + response=GiftTransferred, + impl=transfer, + summary="Transfer a collectible gift to another peer (free transfers only)", + mutating=True, + destructive=True, + rate_class="send", + columns=("ref", "to", "transferred", "price_stars", "refused_reason"), + headers=("Ref", "To", "Done", "Price", "Why not"), + example={"ref": "PlushPepe-42", "to": 777123, "transferred": True}, + example_args="gift transfer PlushPepe-42 @alice", + covers=("gift.transfer",), + tags=frozenset({"visible-to-others"}), +) + + +class CraftReq(Request): + ref: Annotated[ + tuple[str, ...], + arg(0, metavar="REF", required=False, variadic=True, help="The gifts to melt down."), + ] = () + candidates: Annotated[ + int | None, + opt("--candidates", metavar="GIFT_ID", help="List my gifts usable to craft this one."), + ] = None + + +async def craft(ctx: OpContext, req: CraftReq) -> GiftCrafted: + """Craft (combine) collectible gifts. + + `payments.craftStarGift` burns **every** input gift regardless of the + outcome, which is why this is destructive and confirmed even though it + costs nothing: a failed craft is still four gifts gone. + """ + from telethon.tl.functions import payments as fn + + if req.candidates is not None: + _settings.method_gap("gift craft --candidates", "payments.getStarGiftCraftCandidates") + if not req.ref: + raise UsageError("give the gift references to melt down", field="ref") + + handle = client(ctx) + inputs = [await _settings.input_gift(ctx, ref) for ref in req.ref] + result = await handle(fn.CraftStarGiftRequest(stargift=inputs)) + unique = _find_unique(result) + ctx.emit("gift_crafted", {"burned": list(req.ref)}) + return GiftCrafted( + ref=f"slug:{unique.slug}" if unique is not None else None, + slug=unique.slug if unique is not None else None, + burned=list(req.ref), + crafted=True, + ) + + +SPEC_CRAFT = OperationSpec( + id="gift.craft", + request=CraftReq, + response=GiftCrafted, + impl=craft, + summary="Craft (combine) collectible gifts", + description=( + "Every input gift is burned whatever the outcome, so `--yes` is " + "required and `--dry-run` prints exactly what would be consumed." + ), + mutating=True, + destructive=True, + rate_class="send", + columns=("slug", "burned", "crafted"), + headers=("Result", "Burned", "Crafted"), + example={"slug": "PlushPepe-77", "burned": ["msg:120", "msg:121"], "crafted": True}, + example_args="gift craft msg:120 msg:121", + covers=("gift.craft", "gifts.craft"), + covers_partial=("gift.craft-candidates",), + coverage_note=( + "Listing the eligible ingredients needs `payments.getStarGiftCraftCandidates`, " + "which Telethon 1.44 has no request class for; the flag refuses with exit 13." + ), +) + + +# --------------------------------------------------------------------------- +# gift resale list / set, offer approve +# --------------------------------------------------------------------------- + + +class ResaleListReq(Request): + gift_id: Annotated[int, arg(0, metavar="GIFT_ID", help="The gift type to browse.")] + sort: Annotated[str, opt("--sort", metavar="ORDER", help="price | num | date.")] = "price" + ton_only: Annotated[bool, opt("--ton-only", help="Only TON listings.")] = False + stars_only: Annotated[bool, opt("--stars-only", help="Only Stars listings.")] = False + attr: Annotated[ + tuple[str, ...], + opt("--attr", metavar="KIND=ID", help="Filter by model/pattern/backdrop."), + ] = () + + +async def resale_list(ctx: OpContext, req: ResaleListReq) -> Page[ResaleGift]: + """Browse the collectible marketplace for one gift. + + Reading prices is free; buying from the marketplace is a purchase and is + absent, so this is where a price check ends. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + limit, state = window(ctx, "gift.resale.list", PageKind.RATE, default=20) + attributes = [] + for entry in req.attr: + kind, _, value = entry.partition("=") + if not value.isdigit(): + raise UsageError("--attr takes model=<id>, pattern=<id> or backdrop=<id>", field="attr") + builder = { + "model": types.StarGiftAttributeIdModel, + "pattern": types.StarGiftAttributeIdPattern, + "backdrop": types.StarGiftAttributeIdBackdrop, + }.get(kind.strip().lower()) + if builder is None: + raise UsageError("--attr takes model=, pattern= or backdrop=", field="attr") + attributes.append( + builder(backdrop_id=int(value)) + if kind.strip().lower() == "backdrop" + else builder(document_id=int(value)) + ) + + result = await client(ctx)( + fn.GetResaleStarGiftsRequest( + gift_id=int(req.gift_id), + offset=str(state.get("offset", "") or ""), + limit=limit, + sort_by_price=(req.sort == "price") or None, + sort_by_num=(req.sort == "num") or None, + stars_only=req.stars_only or None, + attributes=attributes or None, + ) + ) + known = _settings.entity_map(result) + rows = [] + for gift in getattr(result, "gifts", None) or []: + unique = _unique(gift, known) + if req.ton_only and unique.resell_ton is None: + continue + rows.append( + ResaleGift( + slug=unique.slug, + num=unique.num, + price_stars=unique.resell_stars, + price_ton=unique.resell_ton, + seller_id=unique.owner_id, + attributes=unique.attributes, + ) + ) + next_offset = str(getattr(result, "next_offset", "") or "") + return build_page( + rows, + op="gift.resale.list", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + total=getattr(result, "count", None), + ) + + +SPEC_RESALE_LIST = OperationSpec( + id="gift.resale.list", + request=ResaleListReq, + response=Page[ResaleGift], + impl=resale_list, + summary="Browse the collectible marketplace for one gift", + paginated=PageKind.RATE, + idempotent=True, + columns=("slug", "num", "price_stars", "price_ton", "seller_id"), + headers=("Slug", "#", "Stars", "TON", "Seller"), + example={ + "items": [{"slug": "PlushPepe-42", "num": 42, "price_stars": 12000}], + "has_more": False, + }, + example_args="gift resale list 5100", + covers=("gift.resale-browse", "gifts.resale-price"), + tags=frozenset({"agent-safe"}), +) + + +class ResaleSetReq(Request): + ref: Annotated[str, arg(0, metavar="REF", help="My collectible.")] + stars: Annotated[int | None, opt("--stars", metavar="N", help="Asking price in Stars.")] = None + ton: Annotated[int | None, opt("--ton", metavar="NANO", help="Asking price in nanotons.")] = ( + None + ) + unlist: Annotated[bool, opt("--unlist", help="Take it off the market.")] = False + + +async def resale_set(ctx: OpContext, req: ResaleSetReq) -> GiftListing: + """Put one of my collectibles up for sale, or take it off the market. + + Listing is free — it earns rather than spends — and the sale itself + happens when a buyer pays, which is somebody else's payment form and not + tlgr's. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + handle = client(ctx) + if req.unlist: + amount: Any = types.StarsAmount(amount=0, nanos=0) + elif req.ton is not None: + amount = types.StarsTonAmount(amount=int(req.ton)) + elif req.stars is not None: + amount = types.StarsAmount(amount=int(req.stars), nanos=0) + else: + raise UsageError("give --stars, --ton, or --unlist", field="stars") + + before = await get(ctx, GetReq(ref=req.ref)) + await handle( + fn.UpdateStarGiftPriceRequest( + stargift=await _settings.input_gift(ctx, req.ref), resell_amount=amount + ) + ) + ctx.emit("gift_listed", {"ref": req.ref, "unlist": req.unlist}) + return GiftListing( + ref=req.ref, + listed=not req.unlist, + price_stars=None if req.unlist else req.stars, + price_ton=None if req.unlist else req.ton, + can_resell_at=before.can_resell_at, + ) + + +SPEC_RESALE_SET = OperationSpec( + id="gift.resale.set", + request=ResaleSetReq, + response=GiftListing, + impl=resale_set, + summary="Put one of my collectibles up for sale, or take it off the market", + mutating=True, + idempotent=True, + rate_class="send", + columns=("ref", "listed", "price_stars", "price_ton", "can_resell_at"), + headers=("Ref", "Listed", "Stars", "TON", "Sellable at"), + example={"ref": "PlushPepe-42", "listed": True, "price_stars": 12000}, + example_args="gift resale set PlushPepe-42 --stars 12000", + covers=("gift.resale-list-mine", "gifts.resale-buy"), + tags=frozenset({"visible-to-others"}), +) + + +class OfferApproveReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where the offer arrived.")] + msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The offer message.")] + deny: Annotated[bool, opt("--deny", help="Decline the offer (always free).")] = False + + +async def offer_approve(ctx: OpContext, req: OfferApproveReq) -> GiftOfferResolved: + """Decline somebody's offer to buy my collectible — or report the price. + + Declining is free and is performed. Accepting sells the asset for Stars: + that is a financial transfer, and tlgr reports the offer instead of + completing it, in the same way it never signs a payment form. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + await _settings.resolve(ctx, req.chat) + if not req.deny: + return GiftOfferResolved( + msg_id=int(req.msg_id), + state="refused", + reason="accepting an offer sells the collectible for Stars. " + _settings.NO_SPEND, + ) + await handle(fn.ResolveStarGiftOfferRequest(offer_msg_id=int(req.msg_id), decline=True)) + ctx.emit("gift_offer", {"msg_id": int(req.msg_id), "declined": True}) + return GiftOfferResolved(msg_id=int(req.msg_id), state="declined") + + +SPEC_OFFER_APPROVE = OperationSpec( + id="gift.offer.approve", + request=OfferApproveReq, + response=GiftOfferResolved, + impl=offer_approve, + summary="Decline an offer to buy my collectible (accepting sells an asset and is refused)", + aliases=("gift.offer.deny", "gift.offer.decline"), + mutating=True, + destructive=True, + rate_class="send", + columns=("msg_id", "state", "price_stars", "reason"), + headers=("Message", "State", "Stars", "Why"), + example={"msg_id": 512, "state": "declined"}, + example_args="gift offer approve @alice 512 --deny", + covers_partial=("gift.offer-resolve", "gifts.purchase-offer"), + coverage_note=( + "Declining is performed; accepting transfers an asset for money and is " + "reported rather than done, like every other value transfer in tlgr." + ), +) + + +# --------------------------------------------------------------------------- +# gift unique get / variant list +# --------------------------------------------------------------------------- + + +class UniqueGetReq(Request): + slug: Annotated[str, arg(0, metavar="SLUG", help="A slug or a t.me/nft/ link.")] + value: Annotated[bool, opt("--value", help="Include the valuation the gift carries.")] = False + + +async def unique_get(ctx: OpContext, req: UniqueGetReq) -> UniqueGift: + """Look up a collectible by link or slug. + + The full valuation breakdown — floor price, last sale — needs + `payments.getStarGiftValueInfo`, which this Telethon has no request class + for; the gift itself carries `value_amount`/`value_currency`, and that is + what `--value` reports, with a warning saying what is missing. + """ + from telethon.tl.functions import payments as fn + + result = await client(ctx)(fn.GetUniqueStarGiftRequest(slug=_settings.slug_of(req.slug))) + known = _settings.entity_map(result) + gift = _unique(getattr(result, "gift", None), known) + if req.value: + ctx.warn( + "the floor price and last sale need payments.getStarGiftValueInfo, which " + "Telethon 1.44 has no request class for; the gift's own valuation is reported" + ) + return gift + + +SPEC_UNIQUE_GET = OperationSpec( + id="gift.unique.get", + request=UniqueGetReq, + response=UniqueGift, + impl=unique_get, + summary="Look up a collectible by link or slug, with the valuation it carries", + idempotent=True, + columns=("slug", "num", "title", "owner_id", "value_stars", "resell_stars"), + headers=("Slug", "#", "Title", "Owner", "Value", "For sale"), + example={"slug": "PlushPepe-42", "num": 42, "title": "Plush Pepe", "value_stars": 15000}, + example_args="gift unique get PlushPepe-42", + covers=("gift.unique-info", "gifts.unique-info"), + covers_partial=("gift.unique-value",), + coverage_note=( + "The gift's own `value_amount`/`value_currency` are reported; the floor " + "price and last sale need `payments.getStarGiftValueInfo`, absent from " + "Telethon 1.44." + ), + tags=frozenset({"agent-safe"}), +) + + +class VariantListReq(Request): + gift_id: Annotated[int, arg(0, metavar="GIFT_ID", help="The gift type.")] + preview: Annotated[ + bool, opt("--preview", help="Sample attributes an upgrade could produce.") + ] = False + craft_only: Annotated[ + bool, opt("--craft-only", help="Only variants reachable by crafting.") + ] = False + + +async def variant_list(ctx: OpContext, req: VariantListReq) -> Page[GiftVariant]: + """Possible collectible variants of a gift, and their rarities. + + `payments.getStarGiftUpgradePreview` is what this build can send; the + full attribute table (`payments.getStarGiftAttributes`) has no request + class here, so `--craft-only`, which only that method can answer, + refuses rather than returning a filtered guess. + """ + from telethon.tl.functions import payments as fn + + if req.craft_only: + _settings.method_gap("gift variant list --craft-only", "payments.getStarGiftAttributes") + + limit, state = window(ctx, "gift.variant.list", PageKind.LOCAL, default=20) + result = await client(ctx)(fn.GetStarGiftUpgradePreviewRequest(gift_id=int(req.gift_id))) + rows = [ + GiftVariant( + kind=attribute.kind, + name=attribute.name, + document_id=attribute.document_id, + rarity_permille=attribute.rarity_permille, + sample=True, + ) + for attribute in ( + _attribute(raw) for raw in getattr(result, "sample_attributes", None) or [] + ) + ] + offset = int(state.get("offset", 0) or 0) + page_rows = rows[offset : offset + limit] + return build_page( + page_rows, + op="gift.variant.list", + kind=PageKind.LOCAL, + state={"offset": offset + len(page_rows)}, + account=ctx.account, + has_more=offset + len(page_rows) < len(rows), + total=len(rows), + ) + + +SPEC_VARIANT_LIST = OperationSpec( + id="gift.variant.list", + request=VariantListReq, + response=Page[GiftVariant], + impl=variant_list, + summary="Possible collectible variants of a gift, and a preview of an upgrade", + aliases=("gift.variants",), + paginated=PageKind.LOCAL, + idempotent=True, + columns=("kind", "name", "rarity_permille", "document_id", "sample"), + headers=("Kind", "Name", "Rarity ‰", "Document", "Sample"), + example={ + "items": [{"kind": "model", "name": "Golden", "rarity_permille": 5, "sample": True}], + "has_more": False, + }, + example_args="gift variant list 5100 --preview", + covers=("gift.upgrade-preview",), + covers_partial=("gift.upgrade-attributes",), + coverage_note=( + "The upgrade preview is the sample the server offers; the exhaustive " + "attribute table needs `payments.getStarGiftAttributes`, absent from " + "Telethon 1.44." + ), + tags=frozenset({"agent-safe"}), +) + + +# --------------------------------------------------------------------------- +# gift collection list / create / edit / delete +# --------------------------------------------------------------------------- + + +def _collection(raw: Any, order: int) -> GiftCollection: + return GiftCollection( + id=int(getattr(raw, "collection_id", 0) or 0), + title=str(getattr(raw, "title", "") or ""), + count=int(getattr(raw, "gifts_count", 0) or 0), + icon_document_id=getattr(getattr(raw, "icon", None), "id", None), + order=order, + ) + + +class CollectionListReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="PEER", required=False, kind="peer", help="Whose profile; default me."), + ] = None + refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False + + +async def collection_list(ctx: OpContext, req: CollectionListReq) -> Page[GiftCollection]: + """Gift collections on a profile, in display order.""" + from telethon.tl.functions import payments as fn + + peer = await _peer_or_self(ctx, req.chat) + result = await client(ctx)(fn.GetStarGiftCollectionsRequest(peer=peer, hash=0)) + rows = [ + _collection(raw, index) + for index, raw in enumerate(getattr(result, "collections", None) or []) + ] + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_COLLECTION_LIST = OperationSpec( + id="gift.collection.list", + request=CollectionListReq, + response=Page[GiftCollection], + impl=collection_list, + summary="Gift collections on a profile", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("id", "title", "count", "order"), + headers=("Id", "Title", "Gifts", "Order"), + example={"items": [{"id": 1, "title": "Favourites", "count": 4}], "has_more": False}, + example_args="gift collection list", + covers=("gift.collections-list", "gifts.collections"), + tags=frozenset({"agent-safe"}), +) + + +class CollectionCreateReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="PEER", kind="peer", help="Whose profile.")] + title: Annotated[str, arg(1, metavar="TITLE", help="The collection's name.")] + refs: Annotated[ + tuple[str, ...], + arg(2, metavar="REF", required=False, variadic=True, help="Gifts to put in it."), + ] = () + + +async def collection_create(ctx: OpContext, req: CollectionCreateReq) -> GiftCollection: + """Create a gift collection. `stargifts_collections_max` bounds how many.""" + from telethon.tl.functions import payments as fn + + peer = await _settings.resolve(ctx, req.chat) + result = await client(ctx)( + fn.CreateStarGiftCollectionRequest( + peer=peer, + title=req.title, + stargift=[await _settings.input_gift(ctx, ref) for ref in req.refs], + ) + ) + ctx.emit("gift_collection", {"title": req.title}) + return _collection(result, 0) + + +SPEC_COLLECTION_CREATE = OperationSpec( + id="gift.collection.create", + request=CollectionCreateReq, + response=GiftCollection, + impl=collection_create, + summary="Create a gift collection", + mutating=True, + rate_class="send", + columns=("id", "title", "count"), + headers=("Id", "Title", "Gifts"), + example={"id": 1, "title": "Favourites", "count": 2}, + example_args="gift collection create me Favourites msg:120 msg:121", + covers=("gift.collection-create",), +) + + +class CollectionEditReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="PEER", kind="peer", help="Whose profile.")] + id: Annotated[ + int | None, + arg(1, metavar="ID", required=False, help="Collection id; omit with --order-collections."), + ] = None + title: Annotated[str | None, opt("--title", metavar="TEXT", help="New title.")] = None + add: Annotated[str | None, opt("--add", metavar="LIST", help="Gifts to add.")] = None + remove: Annotated[str | None, opt("--remove", metavar="LIST", help="Gifts to remove.")] = None + order: Annotated[ + str | None, opt("--order", metavar="LIST", help="New order of the gifts inside it.") + ] = None + order_collections: Annotated[ + str | None, + opt("--order-collections", metavar="LIST", help="New order of the collections."), + ] = None + + +async def collection_edit(ctx: OpContext, req: CollectionEditReq) -> GiftCollection: + """Rename a collection, add or remove gifts, or reorder either level. + + Removing a gift from a collection never deletes the gift — the two are + different operations and only `gift convert` destroys anything. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + peer = await _settings.resolve(ctx, req.chat) + + if req.order_collections is not None: + order = [int(p) for p in req.order_collections.split(",") if p.strip().isdigit()] + if not order: + raise UsageError("--order-collections wants every collection id", field="order") + await handle(fn.ReorderStarGiftCollectionsRequest(peer=peer, order=order)) + return GiftCollection(id=order[0], title="", count=0, order=0) + + if req.id is None: + raise UsageError("give a collection id, or --order-collections", field="id") + + async def refs(value: str | None) -> list[Any] | None: + if value is None: + return None + return [ + await _settings.input_gift(ctx, part.strip()) + for part in value.split(",") + if part.strip() + ] + + result = await handle( + fn.UpdateStarGiftCollectionRequest( + peer=peer, + collection_id=int(req.id), + title=req.title, + add_stargift=await refs(req.add), + delete_stargift=await refs(req.remove), + order=await refs(req.order), + ) + ) + ctx.emit("gift_collection_edit", {"id": int(req.id)}) + return _collection(result, 0) + + +SPEC_COLLECTION_EDIT = OperationSpec( + id="gift.collection.edit", + request=CollectionEditReq, + response=GiftCollection, + impl=collection_edit, + summary="Rename a collection, add or remove gifts, or reorder either level", + mutating=True, + rate_class="send", + columns=("id", "title", "count", "order"), + headers=("Id", "Title", "Gifts", "Order"), + example={"id": 1, "title": "Favourites", "count": 3}, + example_args="gift collection edit me 1 --add msg:122", + covers=("gift.collection-reorder", "gift.collection-update"), +) + + +class CollectionDeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="PEER", kind="peer", help="Whose profile.")] + id: Annotated[int, arg(1, metavar="ID", help="The collection to delete.")] + + +async def collection_delete(ctx: OpContext, req: CollectionDeleteReq) -> GiftCollection: + """Delete a gift collection. The gifts themselves are untouched.""" + from telethon.tl.functions import payments as fn + + peer = await _settings.resolve(ctx, req.chat) + await client(ctx)(fn.DeleteStarGiftCollectionRequest(peer=peer, collection_id=int(req.id))) + ctx.emit("gift_collection_deleted", {"id": int(req.id)}) + return GiftCollection(id=int(req.id), title="", count=0) + + +SPEC_COLLECTION_DELETE = OperationSpec( + id="gift.collection.delete", + request=CollectionDeleteReq, + response=GiftCollection, + impl=collection_delete, + summary="Delete a gift collection (the gifts stay)", + mutating=True, + destructive=True, + rate_class="send", + columns=("id", "title"), + headers=("Id", "Title"), + example={"id": 1, "title": ""}, + example_args="gift collection delete me 1", + covers=("gift.collection-delete",), +) + + +# --------------------------------------------------------------------------- +# gift auction list / get +# --------------------------------------------------------------------------- + + +class AuctionListReq(Request): + won: Annotated[bool, opt("--won", help="Gifts I acquired in auctions.")] = False + gift_id: Annotated[ + int | None, opt("--gift-id", metavar="ID", help="With --won: only this gift type.") + ] = None + + +async def auction_list(ctx: OpContext, req: AuctionListReq) -> Page[GiftAuction]: + """Gift auctions I am bidding in, or gifts I won in one. + + Read-only, and not because of an API gap: a bid cannot be retracted, so + tlgr never places one. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + if req.won: + if req.gift_id is None: + raise UsageError("--won needs --gift-id <id>", field="gift_id") + result = await handle(fn.GetStarGiftAuctionAcquiredGiftsRequest(gift_id=int(req.gift_id))) + known = _settings.entity_map(result) + rows = [ + GiftAuction( + auction=str(getattr(gift, "slug", "") or ""), + gift_id=int(req.gift_id), + slug=_unique(gift, known).slug, + state="won", + ) + for gift in getattr(result, "gifts", None) or [] + ] + return Page(items=rows, has_more=False, total=len(rows)) + + result = await handle(fn.GetStarGiftActiveAuctionsRequest(hash=0)) + rows = [] + for raw in getattr(result, "auctions", None) or []: + gift = getattr(raw, "gift", None) + my_bid, _ = _settings.stars_of(getattr(raw, "my_bid", None)) + min_bid, _ = _settings.stars_of(getattr(raw, "min_bid_amount", None)) + rows.append( + GiftAuction( + auction=str(getattr(gift, "slug", "") or getattr(raw, "gift_id", "") or ""), + gift_id=getattr(raw, "gift_id", None) or getattr(gift, "gift_id", None), + slug=getattr(gift, "slug", None), + my_bid=my_bid or None, + min_bid=min_bid or None, + ends_at=fmt_dt(getattr(raw, "end_date", None)), + state=type(raw).__name__.removeprefix("StarGiftAuction").lower() or "active", + ) + ) + return Page(items=rows, has_more=False, total=len(rows)) + + +SPEC_AUCTION_LIST = OperationSpec( + id="gift.auction.list", + request=AuctionListReq, + response=Page[GiftAuction], + impl=auction_list, + summary="Gift auctions: the ones I am bidding in, and the gifts I won", + description="Read-only on purpose: a bid cannot be retracted, so tlgr never places one.", + paginated=PageKind.LOCAL, + idempotent=True, + columns=("auction", "gift_id", "slug", "my_bid", "min_bid", "ends_at", "state"), + headers=("Auction", "Gift", "Slug", "My bid", "Min bid", "Ends", "State"), + example={ + "items": [{"auction": "PlushPepe-42", "my_bid": 5000, "min_bid": 5500}], + "has_more": False, + }, + example_args="gift auction list", + covers=("auction.acquired-gifts", "auction.active-list", "gifts.auctions"), + tags=frozenset({"agent-safe"}), +) + + +class AuctionGetReq(Request): + auction: Annotated[str, arg(0, metavar="AUCTION", help="A gift id or a collectible slug.")] + with_position: Annotated[ + bool, opt("--with-position", help="Estimate my position in the ladder.") + ] = False + watch: Annotated[ + bool, opt("--watch", help="Keep the subscription alive and stream updates.") + ] = False + version: Annotated[int, opt("--version", metavar="N", help="Last seen state version.")] = 0 + + +async def auction_get(ctx: OpContext, req: AuctionGetReq) -> Any: + """Auction state, bid ladder and my position — optionally as a stream. + + `getStarGiftAuctionState` doubles as an update subscription that lasts + `timeout` seconds, so `--watch` re-invokes it to stay subscribed. A new + state is applied only when `version` increases, and a finished state + always wins — otherwise a slow reply can overwrite a newer one. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + handle = client(ctx) + text = req.auction.strip() + auction = ( + types.InputStarGiftAuction(gift_id=int(text)) + if text.isdigit() + else types.InputStarGiftAuctionSlug(slug=_settings.slug_of(text)) + ) + version = int(req.version) + while True: + raw = await handle(fn.GetStarGiftAuctionStateRequest(auction=auction, version=version)) + state = _auction_state(text, raw) + if state.version >= version: + version = state.version + yield state + if not req.watch or state.finished: + return + + +def _auction_state(name: str, raw: Any) -> GiftAuctionState: + my_bid, _ = _settings.stars_of(getattr(raw, "my_bid", None)) + min_bid, _ = _settings.stars_of(getattr(raw, "min_bid_amount", None)) + kind = type(raw).__name__.removeprefix("StarGiftAuctionState").lower() + return GiftAuctionState( + auction=name, + state=kind or "active", + version=int(getattr(raw, "version", 0) or 0), + min_bid_amount=min_bid or None, + my_bid=my_bid or None, + position=getattr(raw, "position", None), + ends_at=fmt_dt(getattr(raw, "end_date", None)), + timeout=getattr(raw, "timeout", None), + finished=kind == "finished", + ) + + +SPEC_AUCTION_GET = OperationSpec( + id="gift.auction.get", + request=AuctionGetReq, + response=GiftAuctionState, + impl=auction_get, + summary="Auction state, bid ladder and my position", + stream=True, + idempotent=True, + columns=("auction", "state", "version", "min_bid_amount", "my_bid", "position", "ends_at"), + headers=("Auction", "State", "Version", "Min bid", "My bid", "Place", "Ends"), + example={"auction": "PlushPepe-42", "state": "active", "version": 3, "min_bid_amount": 5500}, + example_args="gift auction get PlushPepe-42", + covers=("auction.position-estimate", "auction.state"), + tags=frozenset({"agent-safe"}), +) + +__all__ = [name for name in dir() if name.startswith("SPEC_")] From 8024f00294c8127f9bf99bfe779ddc3fddeb1d44 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 07:58:58 +0330 Subject: [PATCH 08/15] fake client and tests: a settings world the commands actually move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 238 tests over a fake Telegram that holds state rather than answering canned replies, because this group's whole job is round trips: a privacy vector that is rewritten and read back, a notification exception that a later listing finds, a gift that leaves one profile and arrives on another, a code that can only be redeemed once. Four of them are the ones worth having. `mute_until` is asserted against the wall clock, which is the bug v1 shipped. `--add-allow` is asserted to keep what was already there, because `setPrivacy` replaces the whole vector. One test walks the registry's *source* and fails if anything in this group names `sendStarsForm`, `sendPaymentForm`, `validateRequestedInfo` or `fulfillStarsSubscription` — so a future addition cannot quietly re-open the door PR-10 closed. And every flag that needs a request class Telethon 1.44 lacks is asserted to exit 13 with the method named, not 1. Two things had to change to make the answers honest. Fields that carry the answer — `ok`, `state`, `kind`, `upgraded`, `transferred`, `version`, `resolvable_by_strangers`, the privacy `base` — lost their defaults, because `omit_defaults` was dropping exactly the value a caller most needs to see; absent must mean "not applicable", never "the interesting case". And ten server error names this surface can produce (`THEME_INVALID`, `GIFT_SLUG_INVALID`, `CHATLINKS_TOO_MUCH`, `USERNAME_PURCHASE_AVAILABLE`, …) joined the error table, so they exit 5 or 2 with a reason instead of 1. `profile` was the last hand-written group, so the sandbox's two-exit-code rule collapses to one: every refusal is PERMISSION_DENIED now. --- docs/reference/PARITY.md | 172 +-- docs/reference/README.md | 11 +- docs/reference/business.md | 474 ++++++++ docs/reference/gift.md | 608 +++++++++++ docs/reference/giveaway.md | 201 ++++ docs/reference/notify.md | 237 ++++ docs/reference/premium.md | 187 ++++ docs/reference/privacy.md | 243 +++++ docs/reference/profile.md | 508 +++++++++ docs/reference/settings.md | 276 +++++ docs/reference/stars.md | 219 ++++ tests/fake_telethon.py | 1196 ++++++++++++++++++++ tests/test_ops_settings.py | 2124 ++++++++++++++++++++++++++++++++++++ tests/test_sandbox.py | 20 +- tlgr/cli/__init__.py | 1 - tlgr/core/errors.py | 24 + tlgr/models/business.py | 6 +- tlgr/models/gift.py | 16 +- tlgr/models/notify.py | 2 +- tlgr/models/premium.py | 12 +- tlgr/models/privacy.py | 6 +- tlgr/models/profile.py | 4 +- tlgr/models/settings.py | 2 +- tlgr/models/stars.py | 6 +- tlgr/ops/business.py | 2 + tlgr/ops/gift.py | 95 +- tlgr/ops/giveaway.py | 37 +- tlgr/ops/premium.py | 11 +- tlgr/ops/privacy.py | 2 +- tlgr/ops/profile.py | 8 +- tlgr/ops/settings.py | 14 +- 31 files changed, 6498 insertions(+), 226 deletions(-) create mode 100644 docs/reference/business.md create mode 100644 docs/reference/gift.md create mode 100644 docs/reference/giveaway.md create mode 100644 docs/reference/notify.md create mode 100644 docs/reference/premium.md create mode 100644 docs/reference/privacy.md create mode 100644 docs/reference/profile.md create mode 100644 docs/reference/settings.md create mode 100644 docs/reference/stars.md create mode 100644 tests/test_ops_settings.py diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index b813a67..fb9a307 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 — 588 operations, 835 invocable paths +catalog 2026-09-02 — 678 operations, 951 invocable paths domain covered req % acct% ops auth_sessions_security 89 89 100.0% 100.0% 45 -bots_inline_payments 158 175 90.3% 100.0% 82 -calls_voicechats 131 133 98.5% 100.0% 55 -contacts_users 109 121 90.1% 100.0% 52 -dialogs_chats 137 146 93.8% 100.0% 76 -groups_channels_admin 158 162 97.5% 100.0% 104 -media_files 129 143 90.2% 100.0% 67 -messages_core 165 167 98.8% 100.0% 59 -polls_reactions_content 130 174 74.7% 100.0% 68 -profile_settings_privacy 33 178 18.5% 100.0% 31 -stories 113 120 94.2% 100.0% 43 +bots_inline_payments 167 175 95.4% 100.0% 87 +calls_voicechats 133 133 100.0% 100.0% 56 +contacts_users 121 121 100.0% 100.0% 56 +dialogs_chats 146 146 100.0% 100.0% 83 +groups_channels_admin 162 162 100.0% 100.0% 108 +media_files 143 143 100.0% 100.0% 75 +messages_core 167 167 100.0% 100.0% 61 +polls_reactions_content 173 174 99.4% 100.0% 99 +profile_settings_privacy 178 178 100.0% 100.0% 109 +stories 120 120 100.0% 100.0% 48 updates_sync_network 189 189 100.0% 100.0% 68 priority covered req % acct% -P0 172 178 96.6% 100.0% -P1 345 379 91.0% 100.0% -P2 511 610 83.8% 100.0% -P3 513 630 81.4% 100.0% +P0 178 178 100.0% 100.0% +P1 377 379 99.5% 100.0% +P2 606 610 99.3% 100.0% +P3 627 630 99.5% 100.0% -TOTAL 1541 1797 85.8% 100.0% +TOTAL 1788 1797 99.5% 100.0% excluded: not-applicable 79, prohibited 40 -uncovered: 256 (256 waived with a PR number) +uncovered: 9 (9 waived with a PR number) ``` ## By domain @@ -39,26 +39,26 @@ uncovered: 256 (256 waived with a PR number) | Domain | Covered | Required | % | Accounted % | Ops | |---|---:|---:|---:|---:|---:| | `auth_sessions_security` | 89 | 89 | 100.0% | 100.0% | 45 | -| `bots_inline_payments` | 158 | 175 | 90.3% | 100.0% | 82 | -| `calls_voicechats` | 131 | 133 | 98.5% | 100.0% | 55 | -| `contacts_users` | 109 | 121 | 90.1% | 100.0% | 52 | -| `dialogs_chats` | 137 | 146 | 93.8% | 100.0% | 76 | -| `groups_channels_admin` | 158 | 162 | 97.5% | 100.0% | 104 | -| `media_files` | 129 | 143 | 90.2% | 100.0% | 67 | -| `messages_core` | 165 | 167 | 98.8% | 100.0% | 59 | -| `polls_reactions_content` | 130 | 174 | 74.7% | 100.0% | 68 | -| `profile_settings_privacy` | 33 | 178 | 18.5% | 100.0% | 31 | -| `stories` | 113 | 120 | 94.2% | 100.0% | 43 | +| `bots_inline_payments` | 167 | 175 | 95.4% | 100.0% | 87 | +| `calls_voicechats` | 133 | 133 | 100.0% | 100.0% | 56 | +| `contacts_users` | 121 | 121 | 100.0% | 100.0% | 56 | +| `dialogs_chats` | 146 | 146 | 100.0% | 100.0% | 83 | +| `groups_channels_admin` | 162 | 162 | 100.0% | 100.0% | 108 | +| `media_files` | 143 | 143 | 100.0% | 100.0% | 75 | +| `messages_core` | 167 | 167 | 100.0% | 100.0% | 61 | +| `polls_reactions_content` | 173 | 174 | 99.4% | 100.0% | 99 | +| `profile_settings_privacy` | 178 | 178 | 100.0% | 100.0% | 109 | +| `stories` | 120 | 120 | 100.0% | 100.0% | 48 | | `updates_sync_network` | 189 | 189 | 100.0% | 100.0% | 68 | ## By priority | Priority | Covered | Required | % | Accounted % | |---|---:|---:|---:|---:| -| P0 | 172 | 178 | 96.6% | 100.0% | -| P1 | 345 | 379 | 91.0% | 100.0% | -| P2 | 511 | 610 | 83.8% | 100.0% | -| P3 | 513 | 630 | 81.4% | 100.0% | +| P0 | 178 | 178 | 100.0% | 100.0% | +| P1 | 377 | 379 | 99.5% | 100.0% | +| P2 | 606 | 610 | 99.3% | 100.0% | +| P3 | 627 | 630 | 99.5% | 100.0% | ## Partial coverage @@ -78,15 +78,23 @@ uncovered: 256 (256 waived with a PR number) | `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 | | `game.play` | `message.game.get` | A CLI cannot render an HTML5 game; --url is refused with NOT_SUPPORTED. | +| `gift.craft-candidates` | `gift.craft` | Listing the eligible ingredients needs `payments.getStarGiftCraftCandidates`, which Telethon 1.44 has no request class for; the flag refuses with exit 13. | +| `gift.offer-resolve` | `gift.offer.approve` | Declining is performed; accepting transfers an asset for money and is reported rather than done, like every other value transfer in tlgr. | +| `gift.unique-value` | `gift.unique.get` | The gift's own `value_amount`/`value_currency` are reported; the floor price and last sale need `payments.getStarGiftValueInfo`, absent from Telethon 1.44. | +| `gift.upgrade-attributes` | `gift.variant.list` | The upgrade preview is the sample the server offers; the exhaustive attribute table needs `payments.getStarGiftAttributes`, absent from Telethon 1.44. | +| `gifts.purchase-offer` | `gift.offer.approve` | Declining is performed; accepting transfers an asset for money and is reported rather than done, like every other value transfer in tlgr. | | `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. | | `messages-core.ephemeral-messages` | `message.delete` | --revert needs layer 229's ephemeral.* namespace and is refused. | | `messages-core.send-rich-message` | `message.send` | A layer-229 rich body is refused with NOT_SUPPORTED: the pinned Telethon speaks layer 227 and cannot serialise inputRichMessage*. | | `passport.authorization` | `passport.authorize` | The request is readable (`passport form get`); acceptance needs the Passport secure-value crypto and raises NOT_SUPPORTED. | +| `premium.gift-to-user` | `premium.gift.send` | The recipient, the length and the price are reported; signing the payment form is absent from tlgr's whole surface by policy. | | `richmsg.compose-ai` | `message.compose` | Composing a rich body needs layer 229 and is refused with NOT_SUPPORTED. | | `richmsg.get` | `message.get` | --rich is refused with NOT_SUPPORTED until Telethon carries layer 229. | | `richmsg.send` | `message.send` | A layer-229 rich body is refused with NOT_SUPPORTED: the pinned Telethon speaks layer 227 and cannot serialise inputRichMessage*. | | `richmsg.tasks` | `message.edit` | Checklist tasks live in a layer-229 rich body; --toggle-task is refused. | | `richmsg.translate` | `message.translate` | Rich-body translation is layer 229 and refused with NOT_SUPPORTED. | +| `stars.business-bot-transfer` | `business.stars.transfer` | The price and the form are reported; signing the form is deliberately absent from tlgr's whole surface. | +| `stars.subscription-refulfill` | `stars.subscription.refulfill` | Whether the server would allow it, and what it would cost, are reported; the charge itself is absent from tlgr's surface by policy. | | `stories.live-join` | `story.live.get` | The live story is reported; its group call is not reachable from layer 227's storyItem, and joining a broadcast needs a media engine tlgr does not have. | | `updates.invoke-business-connection` | `bot.connection.invoke` | The wrapper is implemented on `bot command send`, `bot press` and `inline send`; wrapping an arbitrary command is refused with exit 13. | @@ -96,114 +104,12 @@ 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. | -| `profile.photo-set` | P0 | Set profile photo | waived until PR-12: Setting your profile photo is `profile photo set` (PR-12). | -| `bots.bot-stars-balance` | P1 | Bot Stars balance | waived until PR-12: The Star balance is the `stars` surface (PR-12). | | `bots.ephemeral-callback-press` | P1 | Press a button on an ephemeral bot message | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | | `bots.ephemeral-command-send` | P1 | Send an ephemeral bot command / reply to an ephemeral message | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `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). | -| `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). | -| `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). | -| `stories.notify-peer` | P1 | Per-peer story notifications | waived until PR-12: Per-peer story notifications are `notify set --stories` (PR-12). | -| `bots.bot-revenue-stats` | P2 | Bot revenue statistics (Stars and TON graphs) | waived until PR-12: Bot revenue graphs are the `stars`/`stats` surface (PR-12). | -| `bots.business-bot-connect` | P2 | Connect / reconfigure a business bot | waived until PR-12: Business bots are the `business` surface (PR-12). | -| `bots.business-bot-disconnect` | P2 | Disconnect a business bot | waived until PR-12: Business bots are the `business` surface (PR-12). | -| `bots.business-bot-remove-from-chat` | P2 | Remove the business bot from one chat permanently | waived until PR-12: Business bots are the `business` surface (PR-12). | -| `bots.business-bots-list` | P2 | List business bots connected to my account | waived until PR-12: Business bots are the `business` surface (PR-12). | | `bots.ephemeral-message-send` | P2 | Send / edit / delete an ephemeral message (bot side) | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.privacy-rule-bots` | P2 | Allow / disallow bots and mini apps in a privacy rule | waived until PR-12: Allowing or disallowing bots in a privacy rule is `privacy set` (PR-12). | | `bots.rich-message-buttons` | P2 | Buttons inside a rich bot message | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.stars-topup-options` | P2 | Buy Telegram Stars | waived until PR-12: Buying Stars is the `stars` surface (PR-12). | | `bots.welcome-messages-manage` | P2 | Add / edit / delete a chat's welcome messages | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | | `bots.welcome-messages-view` | P2 | Bot welcome messages in an empty chat | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `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. | -| `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). | -| `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.notify-scope-defaults` | P2 | Default notification settings per chat type | waived until PR-12: Scope-wide defaults are `notify set` (PR-12). | -| `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). | -| `gift.display-toggle` | P2 | Show / hide a gift on your profile | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.privacy-disallowed` | P2 | Refuse certain kinds of gifts | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.received-list` | P2 | Gifts received by a profile | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.resale-browse` | P2 | Browse the gift marketplace | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.transfer` | P2 | Transfer a collectible gift | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.upgrade` | P2 | Upgrade a gift to a collectible | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `giftcode.apply` | P2 | Redeem a gift code | waived until PR-12: gift codes are the `gift` surface (PR-12). | -| `giftcode.check` | P2 | Check a gift code / giftcode link | waived until PR-12: gift codes are the `gift` surface (PR-12). | -| `giveaway.gift-code-received` | P2 | Receive a giveaway gift code | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | -| `giveaway.info` | P2 | Giveaway status / did I win? | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | -| `giveaway.join-by-boosting` | P2 | Join a giveaway by boosting the channel | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | -| `groups-channels-admin.channel-subscription-manage` | P2 | Manage my paid (Stars) channel subscriptions | waived until PR-12: My own paid subscriptions are `stars subscription *` (PR-12); the admin side is `chat invite list`. | -| `messages-core.quick-reply-list` | P2 | Quick replies (business shortcuts): list shortcuts and their messages | waived until PR-12: Business quick-reply shortcuts are a business setting (PR-12); `message send --quick-reply` uses one. | -| `messages-core.quick-reply-manage` | P2 | Create/edit/reorder/delete quick reply shortcuts and their messages | waived until PR-12: Business quick-reply shortcuts are a business setting (PR-12). | -| `profile.contact-personal-photo` | P2 | Set a personal photo for a contact / suggest a photo to a contact | waived until PR-12: A personal photo for a contact is the `profile` group (PR-12). | -| `profile.photo-set-as-main` | P2 | Set an older profile photo as main | waived until PR-12: Promoting an older photo is the `profile` group (PR-12). | -| `profile.photo-set-video` | P2 | Animated profile photo (video avatar) | waived until PR-12: A video avatar is the `profile` group (PR-12). | -| `profile.saved-music` | P2 | Music on profile (save songs to profile, list, reorder) | waived until PR-12: Music on a profile is the `profile` group (PR-12). | -| `stories.boost-status` | P2 | Boost level needed to post channel stories | waived until PR-7: Boost levels are the `boost` group (PR-7); `story can-post` reports the gate. | -| `stories.notify-global` | P2 | Global story notification settings | waived until PR-12: Global story notification settings are `notify set` (PR-12). | -| `stories.notify-reactions` | P2 | Notifications for reactions to my stories | waived until PR-12: Notifications for reactions to my stories are `notify set` (PR-12). | -| `auction.acquired-gifts` | P3 | Gifts I won in an auction | waived until PR-12: collectible-gift auctions are the `gift` surface (PR-12). | -| `auction.active-list` | P3 | Auctions I am bidding in | waived until PR-12: collectible-gift auctions are the `gift` surface (PR-12). | -| `auction.position-estimate` | P3 | My position in the auction | waived until PR-12: collectible-gift auctions are the `gift` surface (PR-12). | -| `auction.state` | P3 | Auction state and bid ladder | waived until PR-12: collectible-gift auctions are the `gift` surface (PR-12). | | `bots.chat-join-webview` | P3 | Guard-bot join webview (chat approval mini app) | waived until PR-12: messages.requestChatJoinWebView is absent from Telethon 1.44; `webapp open --join-query-id` is registered and exits 13. | | `bots.ephemeral-report` | P3 | Report an ephemeral bot message | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.stars-topup-deeplink` | P3 | Stars top-up deep link | waived until PR-12: The Stars top-up deep link is the `stars` surface (PR-12). | -| `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.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.reactions-notify` | P3 | Reaction / poll-vote notification settings | waived until PR-12: Reaction notification settings are the notify surface (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). | -| `gift.as-peer-color` | P3 | Use a collectible as message palette and pattern | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.auto-save-privacy` | P3 | Auto-display received gifts | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.button-visibility` | P3 | Show the gift button in the input bar | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | | `gift.can-send` | P3 | Can I send this gift? | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.collection-create` | P3 | Create a gift collection | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.collection-delete` | P3 | Delete a gift collection | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.collection-reorder` | P3 | Reorder collections on a profile | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.collection-update` | P3 | Rename or edit a gift collection | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.collections-list` | P3 | Gift collections on a profile | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.craft` | P3 | Craft (combine) collectible gifts | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.craft-candidates` | P3 | Gifts usable for crafting | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.get-one` | P3 | Details of a specific owned gift | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.hosted` | P3 | Hosted collectibles (TON-owned, profile-linked) | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.offer-resolve` | P3 | Accept or decline a purchase offer | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.pin` | P3 | Pin gifts to the top of the profile | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.resale-list-mine` | P3 | Put a collectible up for sale | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.unique-info` | P3 | Look up a collectible gift by link | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.unique-value` | P3 | Estimated value of a collectible | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.upgrade-attributes` | P3 | All possible collectible variants | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `gift.upgrade-preview` | P3 | Preview a gift upgrade | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `giveaway.list-prepaid` | P3 | Prepaid giveaways on a channel | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | -| `giveaway.results` | P3 | Giveaway results message | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | -| `groups-channels-admin.gift-code-redeem` | P3 | Check and redeem a giveaway gift code | waived until PR-12: Gift codes are the `gift` noun (PR-12); `boost list --gifts` finds the winners. | -| `groups-channels-admin.giveaway-info` | P3 | Inspect a giveaway's state and results | waived until PR-12: `giveaway *` lands with gifts and Stars (PR-12); `boost get` reports the prepaid ones. | -| `groups-channels-admin.giveaway-prepaid-launch` | P3 | Launch a prepaid giveaway | waived until PR-12: Launching a giveaway is `giveaway launch` (PR-12); `boost get` reports the prepaid slots it spends. | -| `location.business-address` | P3 | Business account location | waived until PR-12: a business account's address is the `business` surface (PR-12). | -| `profile.main-tab` | P3 | Main profile tab (Posts / Gifts / Media) for own profile and channels | waived until PR-12: The profile tab layout is the `profile` group (PR-12). | -| `profile.photo-fallback-public` | P3 | Public (fallback) profile photo for users who cannot see the main one | waived until PR-12: The public fallback photo is the `profile` group (PR-12). | -| `profile.photo-set-emoji-sticker` | P3 | Profile photo from sticker / custom emoji on a colour background | waived until PR-12: An emoji avatar is the `profile` group (PR-12). | -| `ringtone.manage` | P3 | Custom notification sounds: list, upload, save from voice/audio, remove | waived until PR-12: Custom notification sounds are the `notify` group (PR-12). | -| `ringtone.set-for-chat` | P3 | Set notification sound for a chat or chat category | waived until PR-12: Per-chat notification sounds are the `notify` group (PR-12). | -| `stars.topup-options` | P3 | Star purchase packages | waived until PR-12: the Star balance and top-up packages are the `stars` surface (PR-12). | -| `stories.business-story` | P3 | Bot posting stories for a business account | waived until PR-10: Posting for a business account goes through a bot connection (PR-10). | -| `stories.notify-exceptions` | P3 | List peers with custom story notification settings | waived until PR-12: Per-peer notification exceptions are the `notify` group (PR-12). | -| `stories.story-music-save` | P3 | Save a story's soundtrack (Add to Profile / Saved Messages) | waived until PR-12: Saving a story's soundtrack to the profile is the profile group (PR-12). | -| `theme.cloud-themes` | P3 | Cloud themes (list, install, create, update, upload theme file) | waived until PR-12: Cloud themes are the `settings` group (PR-12). | diff --git a/docs/reference/README.md b/docs/reference/README.md index a1f83b9..2c9a880 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -2,7 +2,7 @@ # Command reference -588 operations across 38 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. +678 operations across 47 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 | |---|---:|---| @@ -11,6 +11,7 @@ | `auth` | 11 | [auth.md](auth.md) | | `boost` | 3 | [boost.md](boost.md) | | `bot` | 57 | [bot.md](bot.md) | +| `business` | 14 | [business.md](business.md) | | `call` | 13 | [call.md](call.md) | | `chat` | 117 | [chat.md](chat.md) | | `conference` | 9 | [conference.md](conference.md) | @@ -23,19 +24,27 @@ | `export` | 5 | [export.md](export.md) | | `folder` | 13 | [folder.md](folder.md) | | `gif` | 5 | [gif.md](gif.md) | +| `gift` | 19 | [gift.md](gift.md) | +| `giveaway` | 6 | [giveaway.md](giveaway.md) | | `inline` | 7 | [inline.md](inline.md) | | `job` | 8 | [job.md](job.md) | | `location` | 9 | [location.md](location.md) | | `media` | 28 | [media.md](media.md) | | `message` | 39 | [message.md](message.md) | | `net` | 5 | [net.md](net.md) | +| `notify` | 7 | [notify.md](notify.md) | | `passport` | 5 | [passport.md](passport.md) | | `payment` | 9 | [payment.md](payment.md) | | `poll` | 9 | [poll.md](poll.md) | +| `premium` | 6 | [premium.md](premium.md) | +| `privacy` | 7 | [privacy.md](privacy.md) | +| `profile` | 16 | [profile.md](profile.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) | +| `settings` | 8 | [settings.md](settings.md) | +| `stars` | 7 | [stars.md](stars.md) | | `sticker` | 20 | [sticker.md](sticker.md) | | `story` | 31 | [story.md](story.md) | | `sync` | 5 | [sync.md](sync.md) | diff --git a/docs/reference/business.md b/docs/reference/business.md new file mode 100644 index 0000000..1822783 --- /dev/null +++ b/docs/reference/business.md @@ -0,0 +1,474 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr business` + +14 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 | +|---|---| +| [`business bot list`](#tlgr-business-bot-list) | List chatbots connected to my account (and inspect one connection) | +| [`business bot set`](#tlgr-business-bot-set) | Connect, re-scope, confirm or disconnect a business chatbot | +| [`business bot toggle`](#tlgr-business-bot-toggle) | Pause, resume or exclude the connected bot in one chat | +| [`business get`](#tlgr-business-get) | Show my Telegram Business configuration | +| [`business link list`](#tlgr-business-link-list) | List my business chat links, or resolve someone's link | +| [`business link set`](#tlgr-business-link-set) | Create, edit or delete a business chat link | +| [`business message set`](#tlgr-business-message-set) | Configure the greeting message or the away message | +| [`business reply add`](#tlgr-business-reply-add) | Add a message to a quick-reply shortcut (creating the shortcut if needed) | +| [`business reply delete`](#tlgr-business-reply-delete) | Delete a quick-reply shortcut, or single messages inside it | +| [`business reply edit`](#tlgr-business-reply-edit) | Edit a quick-reply message, rename a shortcut or reorder the shortcut list | +| [`business reply list`](#tlgr-business-reply-list) | List quick-reply shortcuts, or the messages inside one | +| [`business reply send`](#tlgr-business-reply-send) | Send a quick reply into a private chat | +| [`business set`](#tlgr-business-set) | Set opening hours, business location and chat intro | +| [`business stars transfer`](#tlgr-business-stars-transfer) | Price a Stars transfer from a business account to its bot | + +### `business bot list` + +List chatbots connected to my account (and inspect one connection). + +``` +tlgr business bot list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[BotConnection]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--connection` | text | | Bot side: inspect one business connection. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr business bot list --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `bots.business-bots-list`, `business.bot-connection`, `business.connected-bots`, `dialogs.business-bot-bar` + +</details> + +### `business bot set` + +Connect, re-scope, confirm or disconnect a business chatbot. + +Rights default to none and each one is named explicitly, because a connected bot can read, reply, rewrite the profile and move Stars. Acting *as* the bot on somebody's account (`invokeWithBusinessConnection`) is a bot-side surface and out of scope for the user-side CLI. + +``` +tlgr business bot set <BOT> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `BotConnection`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot to connect. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--confirm` | flag | | Activate a pending connection. | +| `--contacts` | flag | | Recipients: contacts. | +| `--delete-received` | flag | | Right: delete messages it received. | +| `--delete-sent` | flag | | Right: delete messages it sent. | +| `--disconnect` | flag | | Remove the bot. | +| `--edit-bio` | flag | | Right: edit my bio. | +| `--edit-name` | flag | | Right: edit my name. | +| `--edit-photo` | flag | | Right: edit my profile photo. | +| `--edit-username` | flag | | Right: edit my username. | +| `--exclude` | flag | | Invert the selection. | +| `--exclude-users` | text | | Users to exclude. | +| `--existing-chats` | flag | | Recipients: existing chats. | +| `--manage-gifts` | flag | | Right: view and manage gifts and Stars. | +| `--manage-stories` | flag | | Right: manage stories. | +| `--new-chats` | flag | | Recipients: new chats. | +| `--non-contacts` | flag | | Recipients: non-contacts. | +| `--read` | flag | | Right: read messages. | +| `--reply-to` | flag | | Right: reply to messages. | +| `--transfer-stars` | flag | | Right: transfer Stars to the bot. | +| `--users` | text | | Explicit recipient users. | + +```console +$ tlgr business bot set @mybot --reply-to --read --new-chats --json +``` + +<details><summary>Catalog coverage (6 full, 1 partial)</summary> + +Full: `bots.business-bot-connect`, `bots.business-bot-disconnect`, `bots.business-bot-remove-from-chat`, `business.account-edit-via-bot`, `business.confirm-bot-connection`, `stories.business-story` + +Partial: `business.connected-bots` + +Listing the connections is `business bot list`. + +</details> + +### `business bot toggle` + +Pause, resume or exclude the connected bot in one chat. + +``` +tlgr business bot toggle <CHAT> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `BotPaused`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The chat to change. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--remove` | flag | | Remove the bot from this chat entirely. | +| `--resume` | flag | | Un-pause the bot in this chat. | + +Also invocable as: `tlgr business bot pause` + +```console +$ tlgr business bot toggle @alice --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `business.bot-pause-chat`, `business.bot-remove-chat` + +</details> + +### `business get` + +Show my Telegram Business configuration. + +``` +tlgr business get [OPTIONS] +``` + +**idempotent (reports `already`) · returns `BusinessProfile`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--timezones` | flag | | Also print the timezone ids `business set --tz` takes. | + +```console +$ tlgr business get --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `business.overview` + +</details> + +### `business link list` + +List my business chat links, or resolve someone's link. + +``` +tlgr business link list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[ChatLink]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--slug` | text | | Resolve one link, including other people's. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr business link list --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `business.chat-links`, `dialogs.business-link-list` + +</details> + +### `business link set` + +Create, edit or delete a business chat link. + +``` +tlgr business link set [SLUG] [OPTIONS] +``` + +**mutating · returns `ChatLinkSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SLUG` | text | no | Omit to create a new link. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--delete` | flag | | Delete the link. | +| `--entities` | text | | Explicit entities. | +| `--parse` | text | `md` | md | html | none. | +| `--text` | text | | Prefilled message. | +| `--title` | text | | Link title. | + +```console +$ tlgr business link set --text "Hi! How can I help?" --json +``` + +<details><summary>Catalog coverage (3 full, 1 partial)</summary> + +Full: `dialogs.business-link-create`, `dialogs.business-link-delete`, `dialogs.business-link-edit` + +Partial: `business.chat-links` + +Listing and resolving links is `business link list`. + +</details> + +### `business message set` + +Configure the greeting message or the away message. + +Both need an existing quick-reply shortcut; `--schedule outside-hours` additionally needs opening hours. Omitting `--shortcut` disables the feature, which is how the API expresses 'off'. + +``` +tlgr business message set <KIND> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `BusinessMessage`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `KIND` | text | yes | greeting or away. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--contacts` | flag | | Include contacts. | +| `--exclude` | flag | | Treat the selection as an exclusion. | +| `--exclude-users` | text | | away: users to exclude. | +| `--existing-chats` | flag | | Include existing chats. | +| `--new-chats` | flag | | Include new chats. | +| `--no-activity-days` | int | `7` | greeting: silence after N quiet days. | +| `--non-contacts` | flag | | Include non-contacts. | +| `--off` | flag | | Disable this message. | +| `--offline-only` | flag | | away: only send while I am offline. | +| `--schedule` | text | | away: always | outside-hours | custom. | +| `--shortcut` | text | | Quick-reply shortcut to send. | +| `--since` | datetime | | custom schedule start. | +| `--until` | datetime | | custom schedule end. | +| `--users` | text | | Explicit recipient users. | + +```console +$ tlgr business message set greeting --shortcut hello --new-chats --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `business.away-message`, `business.greeting-message`, `contacts-users.user-business-greeting-away` + +</details> + +### `business reply add` + +Add a message to a quick-reply shortcut (creating the shortcut if needed). + +``` +tlgr business reply add <SHORTCUT> [OPTIONS] +``` + +**mutating · returns `QuickReplySet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SHORTCUT` | text | yes | Shortcut name (created if new). | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--copy-from` | text | | Copy an existing message into the shortcut. | +| `--file` | path | | Attach a file (repeatable). | +| `--parse` | text | `md` | md | html | none. | +| `--text` | text | | Message text. | + +```console +$ tlgr business reply add hello --text "Hi! I will reply shortly." --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `business.quick-reply-add` + +</details> + +### `business reply delete` + +Delete a quick-reply shortcut, or single messages inside it. + +``` +tlgr business reply delete <SHORTCUT> [MSG_ID]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `QuickReplySet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SHORTCUT` | text | yes | The shortcut to delete from. | +| `MSG_ID` | int | any number | Messages to delete. | + +```console +$ tlgr business reply delete hello --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `business.quick-reply-delete`, `business.quick-reply-delete-messages` + +</details> + +### `business reply edit` + +Edit a quick-reply message, rename a shortcut or reorder the shortcut list. + +``` +tlgr business reply edit [SHORTCUT] [MSG_ID] [OPTIONS] +``` + +**mutating · returns `QuickReplySet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SHORTCUT` | text | no | The shortcut to edit. | +| `MSG_ID` | int | no | A message inside it. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--order` | text | | Every shortcut, in the wanted order. | +| `--parse` | text | `md` | md | html | none. | +| `--rename` | text | | New shortcut name. | +| `--text` | text | | New message text. | + +```console +$ tlgr business reply edit hello 1 --text "Hello!" --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `business.quick-reply-edit`, `business.quick-reply-rename`, `business.quick-reply-reorder`, `messages-core.quick-reply-manage` + +</details> + +### `business reply list` + +List quick-reply shortcuts, or the messages inside one. + +``` +tlgr business reply list [SHORTCUT] [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[QuickReply]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SHORTCUT` | text | no | Show the messages inside one shortcut. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr business reply list --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `business.quick-replies-list`, `business.quick-reply-messages`, `messages-core.quick-reply-list` + +</details> + +### `business reply send` + +Send a quick reply into a private chat. + +``` +tlgr business reply send <CHAT> <SHORTCUT> [OPTIONS] +``` + +**mutating · returns `QuickReplySent`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Private chat to send to. | +| `SHORTCUT` | text | yes | The shortcut to send. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--only` | text | | Only these message ids from the shortcut. | + +Also invocable as: `tlgr quickreply send` + +```console +$ tlgr business reply send @alice hello --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `business.quick-reply-send` + +</details> + +### `business set` + +Set opening hours, business location and chat intro. + +`--clear-*` sends the constructor without its field, which is how the API deletes one; a flag you omit leaves that struct alone. + +``` +tlgr business set [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `BusinessSet`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--address` | text | | Business address (<= 96 chars). | +| `--clear-hours` | flag | | Remove the opening hours. | +| `--clear-intro` | flag | | Revert to the random default intro. | +| `--clear-location` | flag | | Remove the location. | +| `--intro-sticker` | text | | Sticker as <set>/<index> or <set>/<emoji>. | +| `--intro-text` | text | | Chat intro description. | +| `--intro-title` | text | | Chat intro title. | +| `--lat` | number | | Latitude. | +| `--lon` | number | | Longitude. | +| `--open` | text | | Repeatable: 'mon 09:00-18:00'. | +| `--tz` | text | | Timezone id from `business get --timezones`. | + +```console +$ tlgr business set --tz Europe/Amsterdam --open 'mon-fri 09:00-18:00' --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `business.intro`, `business.location`, `business.working-hours`, `location.business-address` + +</details> + +### `business stars transfer` + +Price a Stars transfer from a business account to its bot. + +Reads `payments.getPaymentForm` and stops there. tlgr never signs a payment form — PR-10 settled that for the `payment` group and this group inherits it rather than opening a second door onto the money. + +``` +tlgr business stars transfer <BOT> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `StarsTransferQuote`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The connected bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--amount` | int | | Stars to transfer. | + +```console +$ tlgr business stars transfer @mybot --amount 100 --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `stars.business-bot-transfer` + +The price and the form are reported; signing the form is deliberately absent from tlgr's whole surface. + +</details> diff --git a/docs/reference/gift.md b/docs/reference/gift.md new file mode 100644 index 0000000..74c3f40 --- /dev/null +++ b/docs/reference/gift.md @@ -0,0 +1,608 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr gift` + +19 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 | +|---|---| +| [`gift auction get`](#tlgr-gift-auction-get) | Auction state, bid ladder and my position | +| [`gift auction list`](#tlgr-gift-auction-list) | Gift auctions: the ones I am bidding in, and the gifts I won | +| [`gift catalog`](#tlgr-gift-catalog) | Browse the gifts on sale | +| [`gift collection create`](#tlgr-gift-collection-create) | Create a gift collection | +| [`gift collection delete`](#tlgr-gift-collection-delete) | Delete a gift collection (the gifts stay) | +| [`gift collection edit`](#tlgr-gift-collection-edit) | Rename a collection, add or remove gifts, or reorder either level | +| [`gift collection list`](#tlgr-gift-collection-list) | Gift collections on a profile | +| [`gift convert`](#tlgr-gift-convert) | Convert a received gift back into Stars | +| [`gift craft`](#tlgr-gift-craft) | Craft (combine) collectible gifts | +| [`gift get`](#tlgr-gift-get) | Details of one owned gift, including every time gate | +| [`gift list`](#tlgr-gift-list) | Gifts received by a profile (mine or someone else's) | +| [`gift offer approve`](#tlgr-gift-offer-approve) | Decline an offer to buy my collectible (accepting sells an asset and is refused) | +| [`gift resale list`](#tlgr-gift-resale-list) | Browse the collectible marketplace for one gift | +| [`gift resale set`](#tlgr-gift-resale-set) | Put one of my collectibles up for sale, or take it off the market | +| [`gift set`](#tlgr-gift-set) | Profile display state of a gift: show/hide, pin, or wear it as my emoji status | +| [`gift transfer`](#tlgr-gift-transfer) | Transfer a collectible gift to another peer (free transfers only) | +| [`gift unique get`](#tlgr-gift-unique-get) | Look up a collectible by link or slug, with the valuation it carries | +| [`gift upgrade`](#tlgr-gift-upgrade) | Upgrade a gift into a collectible (free/prepaid path only) | +| [`gift variant list`](#tlgr-gift-variant-list) | Possible collectible variants of a gift, and a preview of an upgrade | + +### `gift auction get` + +Auction state, bid ladder and my position. + +``` +tlgr gift auction get <AUCTION> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `Page[GiftAuctionState]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `AUCTION` | text | yes | A gift id or a collectible slug. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--version` | int | | Last seen state version. | +| `--watch` | flag | | Keep the subscription alive and stream updates. | +| `--with-position` | flag | | Estimate my position in the ladder. | + +```console +$ tlgr gift auction get PlushPepe-42 --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `auction.position-estimate`, `auction.state` + +</details> + +### `gift auction list` + +Gift auctions: the ones I am bidding in, and the gifts I won. + +Read-only on purpose: a bid cannot be retracted, so tlgr never places one. + +``` +tlgr gift auction list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[GiftAuction]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--gift-id` | int | | With --won: only this gift type. | +| `--won` | flag | | Gifts I acquired in auctions. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr gift auction list --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `auction.acquired-gifts`, `auction.active-list`, `gifts.auctions` + +</details> + +### `gift catalog` + +Browse the gifts on sale. + +Reading the catalogue is free; buying from it is absent, like every purchase in tlgr. `--until` needs `payments.canSendStarGift`, which this Telethon has no request class for, and refuses with exit 13. + +``` +tlgr gift catalog [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[StarGift]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--available` | flag | | Hide sold-out gifts. | +| `--limited` | flag | | Only limited-supply gifts. | +| `--refresh` | flag | | Ignore the cached hash. | +| `--until` | chat | | Annotate each gift with can_send. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr gift catalog --available --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `gift.catalog`, `gifts.catalog` + +</details> + +### `gift collection create` + +Create a gift collection. + +``` +tlgr gift collection create <PEER> <TITLE> [REF]... [OPTIONS] +``` + +**mutating · returns `GiftCollection`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PEER` | chat | yes | Whose profile. | +| `TITLE` | text | yes | The collection's name. | +| `REF` | text | any number | Gifts to put in it. | + +```console +$ tlgr gift collection create me Favourites msg:120 msg:121 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `gift.collection-create` + +</details> + +### `gift collection delete` + +Delete a gift collection (the gifts stay). + +``` +tlgr gift collection delete <PEER> <ID> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `GiftCollection`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PEER` | chat | yes | Whose profile. | +| `ID` | int | yes | The collection to delete. | + +```console +$ tlgr gift collection delete me 1 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `gift.collection-delete` + +</details> + +### `gift collection edit` + +Rename a collection, add or remove gifts, or reorder either level. + +``` +tlgr gift collection edit <PEER> [ID] [OPTIONS] +``` + +**mutating · returns `GiftCollection`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PEER` | chat | yes | Whose profile. | +| `ID` | int | no | Collection id; omit with --order-collections. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--add` | text | | Gifts to add. | +| `--order` | text | | New order of the gifts inside it. | +| `--order-collections` | text | | New order of the collections. | +| `--remove` | text | | Gifts to remove. | +| `--title` | text | | New title. | + +```console +$ tlgr gift collection edit me 1 --add msg:122 --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `gift.collection-reorder`, `gift.collection-update` + +</details> + +### `gift collection list` + +Gift collections on a profile. + +``` +tlgr gift collection list [PEER] [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[GiftCollection]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PEER` | chat | no | Whose profile; default me. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--refresh` | flag | | Ignore the cached hash. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr gift collection list --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `gift.collections-list`, `gifts.collections` + +</details> + +### `gift convert` + +Convert a received gift back into Stars. + +Free, and irreversible: the gift is gone and cannot be un-converted. + +``` +tlgr gift convert <REF> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `GiftConverted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `REF` | text | yes | The gift to convert. | + +```console +$ tlgr gift convert msg:120 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `gift.convert-to-stars` + +</details> + +### `gift craft` + +Craft (combine) collectible gifts. + +Every input gift is burned whatever the outcome, so `--yes` is required and `--dry-run` prints exactly what would be consumed. + +``` +tlgr gift craft [REF]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `GiftCrafted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `REF` | text | any number | The gifts to melt down. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--candidates` | int | | List my gifts usable to craft this one. | + +```console +$ tlgr gift craft msg:120 msg:121 --json +``` + +<details><summary>Catalog coverage (2 full, 1 partial)</summary> + +Full: `gift.craft`, `gifts.craft` + +Partial: `gift.craft-candidates` + +Listing the eligible ingredients needs `payments.getStarGiftCraftCandidates`, which Telethon 1.44 has no request class for; the flag refuses with exit 13. + +</details> + +### `gift get` + +Details of one owned gift, including every time gate. + +``` +tlgr gift get <REF> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `OwnedGift`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `REF` | text | yes | msg:<id>, <peer>:<saved_id>, or a slug. | + +```console +$ tlgr gift get msg:120 --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `gift.get-one`, `gifts.get-one` + +</details> + +### `gift list` + +Gifts received by a profile (mine or someone else's). + +`ref` is the handle every other gift command takes: `msg:<id>`, `<peer>:<saved_id>` or a collectible slug. + +``` +tlgr gift list [PEER] [OPTIONS] +``` + +**paginated (`RATE` cursor) · idempotent (reports `already`) · returns `Page[OwnedGift]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PEER` | chat | no | Whose profile; default me. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--collection` | int | | Only this collection. | +| `--exclude-hosted` | flag | | Hide TON-hosted collectibles. | +| `--exclude-unique` | flag | | Hide collectibles. | +| `--exclude-unsaved` | flag | | Hide gifts not displayed on the profile. | +| `--only-unique` | flag | | Only collectibles. | +| `--sort` | text | `date` | date | value. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr gift list --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `gift.hosted`, `gift.received-list` + +</details> + +### `gift offer approve` + +Decline an offer to buy my collectible (accepting sells an asset and is refused). + +``` +tlgr gift offer approve <CHAT> <MSG_ID> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `GiftOfferResolved`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Where the offer arrived. | +| `MSG_ID` | msg-id | yes | The offer message. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--deny` | flag | | Decline the offer (always free). | + +Also invocable as: `tlgr gift offer deny`, `tlgr gift offer decline` + +```console +$ tlgr gift offer approve @alice 512 --deny --json +``` + +<details><summary>Catalog coverage (0 full, 2 partial)</summary> + +Partial: `gift.offer-resolve`, `gifts.purchase-offer` + +Declining is performed; accepting transfers an asset for money and is reported rather than done, like every other value transfer in tlgr. + +</details> + +### `gift resale list` + +Browse the collectible marketplace for one gift. + +``` +tlgr gift resale list <GIFT_ID> [OPTIONS] +``` + +**paginated (`RATE` cursor) · idempotent (reports `already`) · returns `Page[ResaleGift]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `GIFT_ID` | int | yes | The gift type to browse. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--attr` | text | | Filter by model/pattern/backdrop. | +| `--sort` | text | `price` | price | num | date. | +| `--stars-only` | flag | | Only Stars listings. | +| `--ton-only` | flag | | Only TON listings. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr gift resale list 5100 --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `gift.resale-browse`, `gifts.resale-price` + +</details> + +### `gift resale set` + +Put one of my collectibles up for sale, or take it off the market. + +``` +tlgr gift resale set <REF> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `GiftListing`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `REF` | text | yes | My collectible. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--stars` | int | | Asking price in Stars. | +| `--ton` | int | | Asking price in nanotons. | +| `--unlist` | flag | | Take it off the market. | + +```console +$ tlgr gift resale set PlushPepe-42 --stars 12000 --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `gift.resale-list-mine`, `gifts.resale-buy` + +</details> + +### `gift set` + +Profile display state of a gift: show/hide, pin, or wear it as my emoji status. + +``` +tlgr gift set [REF]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `GiftDisplay`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `REF` | text | one or more | Gift references. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--peer` | chat | | Profile the gift lives on. | +| `--pin` | flag | | Pin it to the top of the profile. | +| `--pin-order` | text | | Replace the whole pinned set, in this order. | +| `--save` | flag | | Display it on the profile. | +| `--unpin` | flag | | Unpin it. | +| `--unsave` | flag | | Hide it from the profile. | +| `--until` | datetime | | Wear until this time. | +| `--wear` | flag | | Wear the collectible as my emoji status. | +| `--wear-off` | flag | | Stop wearing it. | + +Also invocable as: `tlgr gift save`, `tlgr gift unsave`, `tlgr gift show`, `tlgr gift hide`, `tlgr gift pin`, `tlgr gift unpin`, `tlgr gift wear` + +```console +$ tlgr gift set msg:120 --save --json +``` + +<details><summary>Catalog coverage (5 full, 0 partial)</summary> + +Full: `gift.as-emoji-status`, `gift.display-toggle`, `gift.pin`, `gifts.drop-original-details`, `gifts.save-unsave` + +</details> + +### `gift transfer` + +Transfer a collectible gift to another peer (free transfers only). + +``` +tlgr gift transfer <REF> <PEER> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `GiftTransferred`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `REF` | text | yes | The collectible to transfer. | +| `PEER` | chat | yes | Who to transfer it to. | + +```console +$ tlgr gift transfer PlushPepe-42 @alice --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `gift.transfer` + +</details> + +### `gift unique get` + +Look up a collectible by link or slug, with the valuation it carries. + +``` +tlgr gift unique get <SLUG> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `UniqueGift`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SLUG` | text | yes | A slug or a t.me/nft/ link. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--value` | flag | | Include the valuation the gift carries. | + +```console +$ tlgr gift unique get PlushPepe-42 --json +``` + +<details><summary>Catalog coverage (2 full, 1 partial)</summary> + +Full: `gift.unique-info`, `gifts.unique-info` + +Partial: `gift.unique-value` + +The gift's own `value_amount`/`value_currency` are reported; the floor price and last sale need `payments.getStarGiftValueInfo`, absent from Telethon 1.44. + +</details> + +### `gift upgrade` + +Upgrade a gift into a collectible (free/prepaid path only). + +``` +tlgr gift upgrade <REF> [OPTIONS] +``` + +**mutating · returns `GiftUpgraded`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `REF` | text | yes | The gift to upgrade. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--keep-original-details` | flag | | Keep the sender and message on it. | + +```console +$ tlgr gift upgrade msg:120 --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `gift.upgrade`, `gifts.upgrade`, `gifts.upgrade-preview` + +</details> + +### `gift variant list` + +Possible collectible variants of a gift, and a preview of an upgrade. + +``` +tlgr gift variant list <GIFT_ID> [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[GiftVariant]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `GIFT_ID` | int | yes | The gift type. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--craft-only` | flag | | Only variants reachable by crafting. | +| `--preview` | flag | | Sample attributes an upgrade could produce. | + +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 gift variants` + +```console +$ tlgr gift variant list 5100 --preview --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `gift.upgrade-preview` + +Partial: `gift.upgrade-attributes` + +The upgrade preview is the sample the server offers; the exhaustive attribute table needs `payments.getStarGiftAttributes`, absent from Telethon 1.44. + +</details> diff --git a/docs/reference/giveaway.md b/docs/reference/giveaway.md new file mode 100644 index 0000000..f9dc435 --- /dev/null +++ b/docs/reference/giveaway.md @@ -0,0 +1,201 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr giveaway` + +6 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 | +|---|---| +| [`giveaway code apply`](#tlgr-giveaway-code-apply) | Redeem a gift code (activates the Premium subscription it carries) | +| [`giveaway code check`](#tlgr-giveaway-code-check) | Check a gift code / t.me/giftcode link before using it | +| [`giveaway get`](#tlgr-giveaway-get) | Giveaway status and results: am I eligible, did I win, who won | +| [`giveaway join`](#tlgr-giveaway-join) | Join a giveaway by boosting the channel | +| [`giveaway list`](#tlgr-giveaway-list) | Prepaid giveaways available on a channel, and the gift codes I received | +| [`giveaway start`](#tlgr-giveaway-start) | Launch a giveaway that was already paid for (prepaid) | + +### `giveaway code apply` + +Redeem a gift code (activates the Premium subscription it carries). + +Free: the code is already paid for, so this is not a purchase. + +``` +tlgr giveaway code apply <SLUG> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `GiftCodeApplied`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SLUG` | text | yes | The code, or a t.me/giftcode link. | + +Also invocable as: `tlgr giftcode apply` + +```console +$ tlgr giveaway code apply abcdef --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `giftcode.apply`, `groups-channels-admin.gift-code-redeem` + +</details> + +### `giveaway code check` + +Check a gift code / t.me/giftcode link before using it. + +``` +tlgr giveaway code check <SLUG> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `GiftCode`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SLUG` | text | yes | The code, or a t.me/giftcode link. | + +Also invocable as: `tlgr giftcode check` + +```console +$ tlgr giveaway code check abcdef --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `giftcode.check` + +</details> + +### `giveaway get` + +Giveaway status and results: am I eligible, did I win, who won. + +``` +tlgr giveaway get <CHAT> <MSG_ID> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `GiveawayInfo`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The giveaway's channel. | +| `MSG_ID` | msg-id | yes | The giveaway message. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--winners` | flag | | Resolve the winner list from the results message. | + +Also invocable as: `tlgr giveaway info` + +```console +$ tlgr giveaway get @mychannel 42 --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `giveaway.info`, `giveaway.results`, `groups-channels-admin.giveaway-info`, `premium.giveaway-info` + +</details> + +### `giveaway join` + +Join a giveaway by boosting the channel. + +Needs Premium (or gifted boost slots). A slot stays occupied for a month, so `-y` is required off a TTY. + +``` +tlgr giveaway join <CHAT> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `BoostApplied`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The channel to boost. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--slots` | int | | Which of my boost slots to use. | + +```console +$ tlgr giveaway join @mychannel --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `giveaway.join-by-boosting` + +</details> + +### `giveaway list` + +Prepaid giveaways available on a channel, and the gift codes I received. + +The prepaid list comes from `premium.getBoostsStatus`, which carries the same vector as the absent `payments.getPrepaidGiveaways`. `--codes` scans for `messageActionGiftCode` service messages and checks each slug, because there is no 'my codes' endpoint. + +``` +tlgr giveaway list [CHAT] [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[PrepaidGiveaway]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | The channel to inspect. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--codes` | flag | | My received giveaway gift codes (the inbox side). | +| `--prepaid/--no-prepaid` | flag | `True` | Prepaid giveaways bought for this channel. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr giveaway list @mychannel --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `giveaway.gift-code-received`, `giveaway.list-prepaid` + +</details> + +### `giveaway start` + +Launch a giveaway that was already paid for (prepaid). + +Not a payment: the giveaway was bought earlier and this only starts it. Creating a new, bought giveaway is a purchase and is absent. + +``` +tlgr giveaway start <CHAT> <PREPAID_ID> [OPTIONS] +``` + +**mutating · returns `GiveawayLaunched`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The channel. | +| `PREPAID_ID` | int | yes | From `giveaway list <chat>`. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--also-chat` | text | | Another channel a participant must join. | +| `--countries` | text | | ISO-2 codes, comma separated. | +| `--only-new` | flag | | Only subscribers who joined after the start. | +| `--prize` | text | | Prize description. | +| `--public-winners` | flag | | Show the winner list when it ends. | +| `--until` | datetime | | Draw date. | +| `--winners` | int | | Number of winners. | + +Also invocable as: `tlgr giveaway launch` + +```console +$ tlgr giveaway start @mychannel 77 --winners 10 --until +7d --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.giveaway-prepaid-launch`, `premium.giveaway-create` + +</details> diff --git a/docs/reference/notify.md b/docs/reference/notify.md new file mode 100644 index 0000000..ead48ce --- /dev/null +++ b/docs/reference/notify.md @@ -0,0 +1,237 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr notify` + +7 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 | +|---|---| +| [`notify exception clear`](#tlgr-notify-exception-clear) | Drop per-chat notification overrides so the chats follow their scope default | +| [`notify exception list`](#tlgr-notify-exception-list) | List chats whose notification settings differ from their scope default | +| [`notify get`](#tlgr-notify-get) | Read notification settings for a scope, chat, topic, reactions or contact-joined | +| [`notify reset`](#tlgr-notify-reset) | Reset every notification setting (scopes and per-chat) to Telegram's defaults | +| [`notify ringtone list`](#tlgr-notify-ringtone-list) | List saved notification sounds | +| [`notify ringtone set`](#tlgr-notify-ringtone-set) | Upload a notification sound, save a voice message as one, or remove one | +| [`notify set`](#tlgr-notify-set) | Change notification settings for a scope, chat, topic, reactions or contact-joined | + +### `notify exception clear` + +Drop per-chat notification overrides so the chats follow their scope default. + +``` +tlgr notify exception clear [CHAT]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `ExceptionsCleared`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | any number | Chats to reset. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--every` | flag | | Clear every exception in --scope. | +| `--scope` | text | | Scope for --every. | + +```console +$ tlgr notify exception clear @noisy --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `notify.exceptions-list` + +Partial: `notify.peer` + +Setting one chat's exception is `notify set <chat>`. + +</details> + +### `notify exception list` + +List chats whose notification settings differ from their scope default. + +``` +tlgr notify exception list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[NotifyException]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--compare-sound` | flag | | Count a differing sound as an exception. | +| `--compare-stories` | flag | | Count differing story settings as an exception. | +| `--scope` | text | | private | 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 notify exception list --json +``` + +<details><summary>Catalog coverage (2 full, 1 partial)</summary> + +Full: `dialogs.notify-exceptions`, `stories.notify-exceptions` + +Partial: `notify.exceptions-list` + +Dropping an exception is `notify exception clear`. + +</details> + +### `notify get` + +Read notification settings for a scope, chat, topic, reactions or contact-joined. + +One command over three server APIs, because the GUI presents them as one Notifications screen. `contact-joined` is reported the way a human reads it: `true` means the notification is on, even though the wire stores the opposite. + +``` +tlgr notify get <TARGET> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `NotifyTarget`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `TARGET` | text | yes | private | groups | channels | stories | reactions | contact-joined | <chat> | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--topic` | int | | A forum topic id. | + +```console +$ tlgr notify get private --json +``` + +<details><summary>Catalog coverage (7 full, 3 partial)</summary> + +Full: `dialogs.reactions-notify`, `notify.contact-joined`, `notify.peer`, `notify.scope-channels`, `notify.scope-groups`, `notify.scope-private`, `notify.stories` + +Partial: `notify.forum-topic`, `notify.reactions`, `notify.sound-selection` + +Writing any of them is `notify set`; the sound list is `notify ringtone list`. + +</details> + +### `notify reset` + +Reset every notification setting (scopes and per-chat) to Telegram's defaults. + +``` +tlgr notify reset [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `NotifyReset`** + +Also invocable as: `tlgr notify reset-all` + +```console +$ tlgr notify reset --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `notify.reset-all` + +</details> + +### `notify ringtone list` + +List saved notification sounds. + +The `id` of a row is what `notify set --sound ringtone:<id>` takes. + +``` +tlgr notify ringtone list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[Ringtone]`** + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr notify ringtone list --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `notify.ringtones-list` + +</details> + +### `notify ringtone set` + +Upload a notification sound, save a voice message as one, or remove one. + +Saving an existing document may return a *converted* one with a new id; `converted: true` says so and `id` is always the usable one. + +``` +tlgr notify ringtone set [FILE] [OPTIONS] +``` + +**mutating · returns `RingtoneSaved`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `FILE` | path | no | MP3 or OGG/OPUS to upload. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--from-message` | text | | Save a voice message as a ringtone. | +| `--remove` | text | | Document id of a saved ringtone. | + +```console +$ tlgr notify ringtone set chime.ogg --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `notify.ringtone-remove`, `notify.ringtone-upload`, `ringtone.manage`, `ringtone.set-for-chat` + +</details> + +### `notify set` + +Change notification settings for a scope, chat, topic, reactions or contact-joined. + +`mute_until` is an absolute UNIX timestamp; `--mute 2h` is turned into one from the wall clock, which is the bug v1 had (it used the event loop's clock and muted nothing). v1's `chat mute` is still its own operation and keeps that path; this is the scope-and-target form. + +``` +tlgr notify set <TARGET> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `NotifyTarget`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `TARGET` | text | yes | private | groups | channels | stories | reactions | contact-joined | <chat> | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--messages` | text | | reactions: contacts|all|off for message reactions. | +| `--mute` | text | | Mute for this long, or 'forever'. | +| `--off` | flag | | contact-joined: disable the notification. | +| `--on` | flag | | contact-joined: enable the notification. | +| `--poll-votes` | text | | reactions: poll-vote alerts. | +| `--preview` | text | | Message text in notifications. | +| `--sound` | text | | default | none | local:<title> | ringtone:<id>. | +| `--stories` | text | | reactions: story-reaction alerts. | +| `--stories-hide-sender` | text | | Hide the author on story alerts. | +| `--stories-mute` | text | | Mute this peer's stories. | +| `--stories-sound` | text | | Sound for story alerts. | +| `--topic` | int | | A forum topic id. | +| `--unmute` | flag | | Unmute (mute_until = 0). | + +```console +$ tlgr notify set private --mute 2h --json +``` + +<details><summary>Catalog coverage (8 full, 6 partial)</summary> + +Full: `dialogs.notify-scope-defaults`, `gifts.channel-notifications`, `notify.forum-topic`, `notify.reactions`, `notify.sound-selection`, `stories.notify-global`, `stories.notify-peer`, `stories.notify-reactions` + +Partial: `notify.contact-joined`, `notify.peer`, `notify.scope-channels`, `notify.scope-groups`, `notify.scope-private`, `notify.stories` + +Reading any of them back is `notify get`. + +</details> diff --git a/docs/reference/premium.md b/docs/reference/premium.md new file mode 100644 index 0000000..6b1bdc5 --- /dev/null +++ b/docs/reference/premium.md @@ -0,0 +1,187 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr premium` + +6 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 | +|---|---| +| [`premium boost list`](#tlgr-premium-boost-list) | My boost slots, or the boosts applied to a channel I administer | +| [`premium feature list`](#tlgr-premium-feature-list) | Premium features, promo text and the default/premium limit table | +| [`premium gift list`](#tlgr-premium-gift-list) | Premium gift price options (Stars and fiat), for a user or a channel giveaway | +| [`premium gift send`](#tlgr-premium-gift-send) | Price gifting Telegram Premium to a user (tlgr reads the form, never signs it) | +| [`premium giftcode get`](#tlgr-premium-giftcode-get) | Check a Premium gift code, and optionally redeem it | +| [`premium status`](#tlgr-premium-status) | My Telegram Premium status (and how to buy it, which tlgr never does) | + +### `premium boost list` + +My boost slots, or the boosts applied to a channel I administer. + +``` +tlgr premium boost list [CHANNEL] [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · idempotent (reports `already`) · returns `Page[Boost]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHANNEL` | chat | no | Omit for my own slots. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--gifts` | flag | | Only gift and giveaway boosts. | +| `--of-user` | user | | Narrow to one booster. | + +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 boost slots` + +```console +$ tlgr premium boost list --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `premium.channel-boosts-list`, `premium.my-boosts`, `stories.boost-status` + +</details> + +### `premium feature list` + +Premium features, promo text and the default/premium limit table. + +`--limits` is the part a script needs: it is what decides whether a caption, an upload or a folder will be accepted before it is sent. + +``` +tlgr premium feature list [OPTIONS] +``` + +**idempotent (reports `already`) · returns `PremiumFeatures`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--boost-levels` | flag | | The boost-level unlock table from appConfig. | +| `--channel` | chat | | Show this channel's boost level. | +| `--limits` | flag | | Only the *_limit_default/_premium table. | + +Also invocable as: `tlgr premium features` + +```console +$ tlgr premium feature list --limits --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `content.limits`, `premium.boost-level-features`, `premium.features-list` + +</details> + +### `premium gift list` + +Premium gift price options (Stars and fiat), for a user or a channel giveaway. + +``` +tlgr premium gift list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[PremiumGiftOption]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--boost-peer` | chat | | Options tied to boosting a channel. | +| `--single/--all-options` | flag | `True` | Only options for a single recipient. | + +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 premium gift options` + +```console +$ tlgr premium gift list --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `premium.gift-options` + +</details> + +### `premium gift send` + +Price gifting Telegram Premium to a user (tlgr reads the form, never signs it). + +``` +tlgr premium gift send <USER> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `PremiumGiftQuote`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Who to gift Premium to. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--message` | text | | Note attached to the gift. | +| `--months` | int | | Subscription length. | + +```console +$ tlgr premium gift send @alice --months 3 --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `premium.gift-to-user` + +The recipient, the length and the price are reported; signing the payment form is absent from tlgr's whole surface by policy. + +</details> + +### `premium giftcode get` + +Check a Premium gift code, and optionally redeem it. + +Redeeming costs nothing — the code is already paid for. + +``` +tlgr premium giftcode get <SLUG> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `GiftCode`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SLUG` | text | yes | The gift code, or a t.me/giftcode link. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--redeem` | flag | | Apply the code to this account (free). | + +```console +$ tlgr premium giftcode get abcdef --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `premium.giftcode-apply`, `premium.giftcode-check` + +</details> + +### `premium status` + +My Telegram Premium status (and how to buy it, which tlgr never does). + +``` +tlgr premium status [OPTIONS] +``` + +**idempotent (reports `already`) · returns `PremiumStatus`** + +```console +$ tlgr premium status --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `premium.status` + +</details> diff --git a/docs/reference/privacy.md b/docs/reference/privacy.md new file mode 100644 index 0000000..0fa7e02 --- /dev/null +++ b/docs/reference/privacy.md @@ -0,0 +1,243 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr privacy` + +7 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 | +|---|---| +| [`privacy blocked list`](#tlgr-privacy-blocked-list) | List blocked users (and the separate story-only block list) | +| [`privacy blocked set`](#tlgr-privacy-blocked-set) | Block or unblock a user or bot (also the story-only block list) | +| [`privacy get`](#tlgr-privacy-get) | Read one privacy setting, or all of them | +| [`privacy global get`](#tlgr-privacy-global-get) | Read the global privacy settings (read time, archiving, paid messages, gifts) | +| [`privacy global set`](#tlgr-privacy-global-set) | Change global privacy settings (one flag at a time; tlgr read-modify-writes) | +| [`privacy revenue get`](#tlgr-privacy-revenue-get) | Stars earned from paid messages sent by a user | +| [`privacy set`](#tlgr-privacy-set) | Change a privacy setting: a base rule plus optional exception lists | + +### `privacy blocked list` + +List blocked users (and the separate story-only block list). + +``` +tlgr privacy blocked list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · idempotent (reports `already`) · returns `Page[BlockedPeer]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--stories` | flag | | The 'blocked from my stories' list instead. | + +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 block list` + +```console +$ tlgr privacy blocked list --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `privacy.blocked-list` + +The write half is `privacy blocked set`; `contact blocked list` is the same list. + +</details> + +### `privacy blocked set` + +Block or unblock a user or bot (also the story-only block list). + +The same operation as `user block` / `user unblock`, reached from the Privacy screen. `--replace-with` is the bulk form and answers with the diff it applied. + +``` +tlgr privacy blocked set [PEER]... [OPTIONS] +``` + +**mutating · returns `BlockedSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PEER` | chat | any number | Who to block. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--replace-with` | text | | Replace the whole list at once. | +| `--stories` | flag | | Only block them from seeing my stories. | +| `--unblock` | flag | | Remove the block instead of adding it. | + +Also invocable as: `tlgr block set` + +```console +$ tlgr privacy blocked set @spammer --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `privacy.blocked-list` + +</details> + +### `privacy get` + +Read one privacy setting, or all of them. + +`base` is the headline value the GUI shows and the four lists are its exceptions; `raw_rules` keeps the server's own ordered vector so nothing is lost in the translation. + +``` +tlgr privacy get [KEY] [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[PrivacySettings]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `KEY` | text | no | One key; omit for every key. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--resolve/--no-resolve` | flag | `True` | Resolve exception ids to names. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr privacy get last-seen --json +``` + +<details><summary>Catalog coverage (15 full, 7 partial)</summary> + +Full: `contacts-users.privacy-about`, `contacts-users.privacy-added-by-phone`, `contacts-users.privacy-chat-invite`, `contacts-users.privacy-forwards`, `contacts-users.privacy-gifts`, `contacts-users.privacy-no-paid-messages`, `contacts-users.privacy-phone-number`, `contacts-users.privacy-voice-messages`, `gift.auto-save-privacy`, `privacy.get-rules`, `privacy.key-birthday`, `privacy.key-calls`, `privacy.key-no-paid-messages`, `privacy.key-phone-number`, `privacy.key-voice-messages` + +Partial: `privacy.key-bio`, `privacy.key-chat-invite`, `privacy.key-forwards`, `privacy.key-gifts-auto-save`, `privacy.key-last-seen`, `privacy.key-profile-photo`, `privacy.key-saved-music` + +Writing any of these keys is `privacy set`. + +</details> + +### `privacy global get` + +Read the global privacy settings (read time, archiving, paid messages, gifts). + +``` +tlgr privacy global get [OPTIONS] +``` + +**idempotent (reports `already`) · returns `GlobalPrivacy`** + +```console +$ tlgr privacy global get --json +``` + +<details><summary>Catalog coverage (4 full, 5 partial)</summary> + +Full: `contacts-users.privacy-global`, `privacy.global-disallowed-gifts`, `privacy.global-keep-archived-unmuted`, `privacy.global-require-premium-to-message` + +Partial: `privacy.global-archive-new-noncontacts`, `privacy.global-display-gifts-button`, `privacy.global-hide-read-marks`, `privacy.global-keep-archived-folders`, `privacy.global-paid-messages-price` + +Writing any of these switches is `privacy global set`. + +</details> + +### `privacy global set` + +Change global privacy settings (one flag at a time; tlgr read-modify-writes). + +``` +tlgr privacy global set [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `GlobalPrivacy`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--allow-gifts` | text | | Gift categories to accept again. | +| `--archive-new-noncontacts` | text | | Archive+mute unknown senders. | +| `--disallow-gifts` | text | | Gift categories to refuse. | +| `--display-gifts-button` | text | | Gift button in private chats. | +| `--hide-read-marks` | text | | Hide when I read messages. | +| `--keep-archived-folders` | text | | Folder chats stay archived. | +| `--keep-archived-unmuted` | text | | Unmuted archived chats stay put. | +| `--paid-messages-price` | int | | Stars per message; 0 turns it off. | +| `--require-premium-to-message` | text | | Premium non-contacts only. | + +```console +$ tlgr privacy global set --hide-read-marks on --json +``` + +<details><summary>Catalog coverage (5 full, 3 partial)</summary> + +Full: `privacy.global-archive-new-noncontacts`, `privacy.global-display-gifts-button`, `privacy.global-hide-read-marks`, `privacy.global-keep-archived-folders`, `privacy.global-paid-messages-price` + +Partial: `privacy.global-disallowed-gifts`, `privacy.global-keep-archived-unmuted`, `privacy.global-require-premium-to-message` + +Reading them back is `privacy global get`. + +</details> + +### `privacy revenue get` + +Stars earned from paid messages sent by a user. + +``` +tlgr privacy revenue get <USER> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `PaidMessageRevenue`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Who paid me. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--parent-peer` | text | | Business or channel parent peer. | + +```console +$ tlgr privacy revenue get @alice --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `privacy.paid-message-revenue` + +</details> + +### `privacy set` + +Change a privacy setting: a base rule plus optional exception lists. + +`account.setPrivacy` replaces the whole ordered vector, so this always reads the current rules first. `--add-allow`/`--remove` edit the lists in place; `--allow`/`--disallow` replace them. + +``` +tlgr privacy set <KEY> [RULE] [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `PrivacySettings`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `KEY` | text | yes | The privacy key to change. | +| `RULE` | text | no | everybody|contacts|close-friends|nobody… | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--add-allow` | text | | Append to the allow list. | +| `--add-disallow` | text | | Append to the deny list. | +| `--allow` | text | | Replace the 'always allow' list. | +| `--clear-exceptions` | flag | | Send only the base rule. | +| `--disallow` | text | | Replace the 'never allow' list. | +| `--remove` | text | | Drop these from both lists. | + +```console +$ tlgr privacy set last-seen contacts --add-disallow @nosy --json +``` + +<details><summary>Catalog coverage (16 full, 5 partial)</summary> + +Full: `bots.privacy-rule-bots`, `calls.privacy-p2p`, `calls.privacy-who-can-call`, `contacts-users.privacy-exception-lists`, `contacts-users.user-status-reveal`, `dialogs.new-chats-privacy`, `gift.privacy-disallowed`, `privacy.exceptions`, `privacy.key-bio`, `privacy.key-chat-invite`, `privacy.key-forwards`, `privacy.key-gifts-auto-save`, `privacy.key-last-seen`, `privacy.key-profile-photo`, `privacy.key-saved-music`, `privacy.set-rules` + +Partial: `privacy.key-birthday`, `privacy.key-calls`, `privacy.key-no-paid-messages`, `privacy.key-phone-number`, `privacy.key-voice-messages` + +Reading any of these keys back is `privacy get`. + +</details> diff --git a/docs/reference/profile.md b/docs/reference/profile.md new file mode 100644 index 0000000..00ed5ed --- /dev/null +++ b/docs/reference/profile.md @@ -0,0 +1,508 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr profile` + +16 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 | +|---|---| +| [`profile channel list`](#tlgr-profile-channel-list) | List public channels I administer that can be shown on my profile | +| [`profile color list`](#tlgr-profile-color-list) | List the name and profile colour palettes | +| [`profile color set`](#tlgr-profile-color-set) | Set my message accent colour, profile colour, or a collectible palette | +| [`profile get`](#tlgr-profile-get) | Show my own profile (bio, birthday, business, gifts and colours included) | +| [`profile link`](#tlgr-profile-link) | My public link and QR code, and Fragment details for a username or phone | +| [`profile music list`](#tlgr-profile-music-list) | List the music shown on a profile | +| [`profile photo delete`](#tlgr-profile-photo-delete) | Delete profile photos | +| [`profile photo list`](#tlgr-profile-photo-list) | List (and optionally download) my profile photos | +| [`profile photo set`](#tlgr-profile-photo-set) | Set my profile photo from a file, a video, an older photo or a custom emoji | +| [`profile presence set`](#tlgr-profile-presence-set) | Go online or offline (account.updateStatus) | +| [`profile status list`](#tlgr-profile-status-list) | Browse emoji-status suggestions (recent, default, themed groups, collectibles) | +| [`profile status set`](#tlgr-profile-status-set) | Set or clear my emoji status (including a collectible gift) | +| [`profile update`](#tlgr-profile-update) | Edit my profile: names, bio, birthday, personal channel, photo | +| [`profile username list`](#tlgr-profile-username-list) | List my usernames, including Fragment collectibles | +| [`profile username set`](#tlgr-profile-username-set) | Set, check, clear, activate/deactivate or reorder my usernames | +| [`profile wallpaper set`](#tlgr-profile-wallpaper-set) | Upload/install my chat wallpaper, or reset the saved wallpaper list | + +### `profile channel list` + +List public channels I administer that can be shown on my profile. + +Pick one with `profile update --channel <chat>`; `none` unlinks it. + +``` +tlgr profile channel list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[AdminedChannel]`** + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr profile channel list --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `profile.personal-channel` + +</details> + +### `profile color list` + +List the name and profile colour palettes. + +``` +tlgr profile color list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[ColorPalette]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--emojis` | flag | | Also list the default background emojis. | +| `--profile` | flag | | Profile-page palettes instead of name/message palettes. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr profile color list --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `profile.name-color`, `profile.profile-color` + +</details> + +### `profile color set` + +Set my message accent colour, profile colour, or a collectible palette. + +``` +tlgr profile color set <COLOR> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `ColorSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `COLOR` | text | yes | Palette id, collectible:<slug|id>, or 'none'. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--emoji` | text | | Background custom-emoji document id. | +| `--profile` | flag | | Change the profile-page colour, not the message colour. | + +```console +$ tlgr profile color set 5 --json +``` + +<details><summary>Catalog coverage (2 full, 2 partial)</summary> + +Full: `gift.as-peer-color`, `profile.collectible-message-palette` + +Partial: `profile.name-color`, `profile.profile-color` + +Listing the palettes is `profile color list`. + +</details> + +### `profile get` + +Show my own profile (bio, birthday, business, gifts and colours included). + +v1 answered from `get_me()` alone and reported `bio: ""` for every account, whether or not one was set. This fetches `users.getFullUser` as well, so an absent bio and an empty bio are different answers. + +``` +tlgr profile get [OPTIONS] +``` + +**idempotent (reports `already`) · returns `ProfileFull`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--full/--no-full` | flag | `True` | Also fetch users.getFullUser (bio, birthday, counters). | +| `--refresh` | flag | | Force the userFull fetch even with --no-full. | + +```console +$ tlgr profile get --json +``` + +<details><summary>Catalog coverage (3 full, 1 partial)</summary> + +Full: `profile.main-tab`, `profile.set-bio`, `profile.view-own` + +Partial: `profile.usernames-list` + +The per-username detail (active, collectible) is `profile username list`. + +</details> + +### `profile link` + +My public link and QR code, and Fragment details for a username or phone. + +The GUI's styled QR *image* is a rendering choice a terminal has no use for; the link, a block QR and an optional PNG are the parts that carry information. + +``` +tlgr profile link [TARGET] [OPTIONS] +``` + +**idempotent (reports `already`) · returns `ProfileLink`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `TARGET` | text | no | @username or +888…; default me. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--collectible` | flag | | Fetch Fragment purchase date and price. | +| `--out` | path | | Write a PNG QR instead. | +| `--qr` | flag | | Render a unicode-block QR of the link. | + +```console +$ tlgr profile link --qr --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `profile.collectible-info`, `profile.qr-code` + +</details> + +### `profile music list` + +List the music shown on a profile. + +``` +tlgr profile music list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · idempotent (reports `already`) · returns `Page[MusicTrack]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--user` | user | | Whose profile music (default: me). | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr profile music list --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `profile.saved-music`, `profile.saved-music-list`, `stories.story-music-save` + +</details> + +### `profile photo delete` + +Delete profile photos. + +``` +tlgr profile photo delete [PHOTO_ID]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `PhotosDeleted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `PHOTO_ID` | text | any number | Photos to delete. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--current` | flag | | Delete the current photo; the previous one is promoted. | +| `--every` | flag | | Delete every profile photo. | + +```console +$ tlgr profile photo delete 55123 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `profile.photo-delete` + +</details> + +### `profile photo list` + +List (and optionally download) my profile photos. + +``` +tlgr profile photo list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · idempotent (reports `already`) · returns `Page[ProfilePhoto]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--download` | path | | Download the listed photos here. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr profile photo list --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `profile.photo-list`, `profile.photos-list-history` + +</details> + +### `profile photo set` + +Set my profile photo from a file, a video, an older photo or a custom emoji. + +v1's implementation called `client.upload_profile_photo()`, which Telethon 1.44 does not have; this uploads the file and sends raw `photos.uploadProfilePhoto`. + +``` +tlgr profile photo set [FILE] [OPTIONS] +``` + +**mutating · returns `ProfilePhotoSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `FILE` | path | no | Image or video to upload. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--colors` | text | | Background gradient for --emoji. | +| `--emoji` | text | | Build the avatar from a custom emoji. | +| `--fallback` | flag | | Set the public fallback photo instead of the main one. | +| `--photo-id` | text | | Re-use a photo from `photo list`. | +| `--start-ts` | number | | Cover frame of a video avatar. | +| `--sticker-set` | text | | Sticker markup instead of a custom emoji. | +| `--video` | flag | | Treat the file as an animated avatar. | + +```console +$ tlgr profile photo set avatar.jpg --json +``` + +<details><summary>Catalog coverage (11 full, 0 partial)</summary> + +Full: `profile.contact-personal-photo`, `profile.photo-emoji-markup`, `profile.photo-fallback`, `profile.photo-fallback-public`, `profile.photo-set`, `profile.photo-set-as-main`, `profile.photo-set-emoji-sticker`, `profile.photo-set-existing`, `profile.photo-set-video`, `profile.photo-upload`, `profile.photo-upload-video` + +</details> + +### `profile presence set` + +Go online or offline (account.updateStatus). + +``` +tlgr profile presence set <STATE> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `PresenceSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `STATE` | text | yes | online or offline. | + +Also invocable as: `tlgr profile online`, `tlgr profile offline` + +```console +$ tlgr profile presence set online --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `profile.online-status` + +</details> + +### `profile status list` + +Browse emoji-status suggestions (recent, default, themed groups, collectibles). + +A read, with one exception: `--clear-recent` empties the recent list. It honours `--dry-run` on its own rather than making the whole listing a mutating operation. + +``` +tlgr profile status list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[EmojiStatusItem]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--clear-recent` | flag | | Clear the recent list. | +| `--collectible` | flag | | Collectible gift statuses you may wear. | +| `--default` | flag | | Telegram's default set. | +| `--groups` | flag | | The category chips. | +| `--recent` | flag | | Recently used statuses. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr profile status list --recent --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `emoji.status-lists`, `profile.emoji-status-collectible`, `profile.emoji-status-suggestions` + +</details> + +### `profile status set` + +Set or clear my emoji status (including a collectible gift). + +Premium only. A collectible status and a collectible message palette cannot both be worn: the server clears one when you set the other. + +``` +tlgr profile status set [EMOJI] [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `EmojiStatusSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `EMOJI` | text | no | Document id, collectible:<id>, or 'none'. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--clear` | flag | | Remove the status. | +| `--until` | datetime | | Expire the status. | + +```console +$ tlgr profile status set 5301 --until +7d --json +``` + +<details><summary>Catalog coverage (2 full, 1 partial)</summary> + +Full: `emoji.status-set`, `profile.emoji-status` + +Partial: `profile.emoji-status-collectible` + +Browsing the wearable collectibles is `profile status list --collectible`. + +</details> + +### `profile update` + +Edit my profile: names, bio, birthday, personal channel, photo. + +`changed` names exactly the fields that were written, because the command spans four RPCs and a report that lists what you did not ask for is one nobody can act on. + +``` +tlgr profile update [OPTIONS] +``` + +**mutating · returns `ProfileUpdated`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--bio` | text | | About text. | +| `--birthday` | text | | DD-MM, DD-MM-YYYY, or 'none'. | +| `--channel` | text | | Personal channel to show; 'none' unlinks. | +| `--first-name` | text | | First name. | +| `--last-name` | text | | Last name ('' clears it). | +| `--photo` | path | | Shortcut for photo set. | + +Also invocable as: `tlgr profile set` + +```console +$ tlgr profile update --bio "counting on it" --json +``` + +<details><summary>Catalog coverage (2 full, 3 partial)</summary> + +Full: `profile.birthday-set`, `profile.set-name` + +Partial: `profile.personal-channel`, `profile.photo-upload`, `profile.set-bio` + +Reading these back is `profile get`; the avatar's own flags live on `profile photo set`, and the eligible channels on `profile channel list`. + +</details> + +### `profile username list` + +List my usernames, including Fragment collectibles. + +``` +tlgr profile username list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[ProfileUsername]`** + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr profile username list --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `profile.usernames-list` + +</details> + +### `profile username set` + +Set, check, clear, activate/deactivate or reorder my usernames. + +`--check` writes nothing. A name the server answers `USERNAME_PURCHASE_AVAILABLE` for is reported as `purchasable`, not as taken: it exists only on Fragment. + +``` +tlgr profile username set [NAME] [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `UsernameSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `NAME` | text | no | The username to act on. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--check` | flag | | Only test availability. | +| `--clear` | flag | | Remove the main username. | +| `--off` | flag | | Deactivate a collectible username. | +| `--on` | flag | | Activate a collectible username. | +| `--order` | text | | Comma-separated: every active username, in order. | + +```console +$ tlgr profile username set ada --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `profile.set-username`, `profile.username-reorder`, `profile.username-toggle` + +</details> + +### `profile wallpaper set` + +Upload/install my chat wallpaper, or reset the saved wallpaper list. + +``` +tlgr profile wallpaper set [SOURCE] [OPTIONS] +``` + +**mutating · returns `WallpaperInstalled`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SOURCE` | text | no | Image file, or a wallpaper slug. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--blur` | flag | | wallPaperSettings.blur. | +| `--colors` | text | | Up to four gradient colours. | +| `--for-chat` | flag | | Upload it for use as a per-chat wallpaper. | +| `--intensity` | int | | Pattern intensity. | +| `--motion` | flag | | wallPaperSettings.motion. | +| `--reset` | flag | | Wipe the saved wallpaper list. | +| `--save` | flag | | Only add it to the saved list, do not install it. | + +```console +$ tlgr profile wallpaper set Ycb0FfC6 --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `wallpaper.save-install-reset`, `wallpaper.upload` + +</details> diff --git a/docs/reference/settings.md b/docs/reference/settings.md new file mode 100644 index 0000000..397ac64 --- /dev/null +++ b/docs/reference/settings.md @@ -0,0 +1,276 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr settings` + +8 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 | +|---|---| +| [`settings autosave set`](#tlgr-settings-autosave-set) | Save-to-gallery rules for incoming media (per scope or per chat) | +| [`settings get`](#tlgr-settings-get) | Read cloud-synced account settings (one key or all of them) | +| [`settings language list`](#tlgr-settings-language-list) | List interface languages available on the server | +| [`settings set`](#tlgr-settings-set) | Change a cloud-synced account setting | +| [`settings theme create`](#tlgr-settings-theme-create) | Publish or edit a cloud theme you own | +| [`settings theme install`](#tlgr-settings-theme-install) | Install / save / remove a cloud theme for this account | +| [`settings theme list`](#tlgr-settings-theme-list) | List cloud themes (installed, one by slug, or the collectible-gift themes) | +| [`settings unset`](#tlgr-settings-unset) | Remove a per-peer/per-URL exception or a single suggestion | + +### `settings autosave set` + +Save-to-gallery rules for incoming media (per scope or per chat). + +``` +tlgr settings autosave set [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `AutoSaveSaved`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--clear-exceptions` | flag | | Drop every per-chat exception. | +| `--max-size` | text | | Largest video to auto-save (100M). | +| `--peer` | text | | Set an exception for one chat instead. | +| `--photos` | text | | Auto-save photos. | +| `--scope` | text | | users | chats | broadcasts. | +| `--videos` | text | | Auto-save videos. | + +```console +$ tlgr settings autosave set --scope users --photos on --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `data.autosave-gallery` + +</details> + +### `settings get` + +Read cloud-synced account settings (one key or all of them). + +One generic pair instead of a dozen thin toggles. `accepts` on each row is the vocabulary `settings set` takes for that key, so the read and the write are the same words. + +``` +tlgr settings get [KEY] [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[SettingValue]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `KEY` | text | no | One key; omit for every key. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--peer` | text | | Target peer for per-peer keys. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr settings get auto-delete --json +``` + +<details><summary>Catalog coverage (5 full, 8 partial)</summary> + +Full: `appearance.default-reaction`, `data.auto-download`, `privacy.paid-reaction-anonymity`, `privacy.pm-content-protection`, `privacy.sensitive-content` + +Partial: `appearance.folder-tags`, `appearance.saved-tags`, `business.reenable-ads`, `data.web-browser-settings`, `lang.set`, `privacy.age-verification`, `privacy.default-ttl`, `privacy.top-peers-suggest` + +Writing any of these keys is `settings set`. + +</details> + +### `settings language list` + +List interface languages available on the server. + +``` +tlgr settings language list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[Language]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--code` | text | | Fetch one language, custom slugs included. | +| `--pack` | text | | lang_pack id (android/tdesktop/ios; '' generic). | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr settings language list --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `lang.custom-pack`, `lang.list`, `lang.set` + +</details> + +### `settings set` + +Change a cloud-synced account setting. + +`previous` is always reported, so a script can tell 'I changed it' from 'it was already like that'. Premium-only keys pass the server's `PREMIUM_ACCOUNT_REQUIRED` through rather than pretending to succeed. + +``` +tlgr settings set <KEY> [VALUE]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `SettingChange`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `KEY` | text | yes | The setting to change. | +| `VALUE` | text | one or more | Value(s) for the key. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--apply-to-existing` | flag | | auto-delete: rewrite every chat's TTL too. | +| `--peer` | text | | Target peer for per-peer keys. | + +```console +$ tlgr settings set auto-delete 1w --json +``` + +<details><summary>Catalog coverage (6 full, 8 partial)</summary> + +Full: `appearance.folder-tags`, `appearance.saved-tags`, `business.reenable-ads`, `gift.button-visibility`, `privacy.age-verification`, `privacy.default-ttl` + +Partial: `appearance.default-reaction`, `data.auto-download`, `data.web-browser-settings`, `lang.set`, `privacy.paid-reaction-anonymity`, `privacy.pm-content-protection`, `privacy.sensitive-content`, `privacy.top-peers-suggest` + +Reading any of these keys back is `settings get`. + +</details> + +### `settings theme create` + +Publish or edit a cloud theme you own. + +``` +tlgr settings theme create [TITLE] [OPTIONS] +``` + +**mutating · returns `ThemeInstalled`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `TITLE` | text | no | Theme title. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--accent` | text | | Accent colour, #RRGGBB. | +| `--base` | text | | classic | day | night | tinted | arctic. | +| `--dark` | flag | | Mark the settings vector as the dark variant. | +| `--file` | path | | Theme file to upload. | +| `--message-colors` | text | | Message gradient colours. | +| `--outbox-accent` | text | | Outgoing accent colour. | +| `--slug` | text | | Public slug; an existing one edits it. | +| `--wallpaper` | text | | Wallpaper for the theme settings. | + +```console +$ tlgr settings theme create Nord --file nord.tdesktop-theme --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `theme.create`, `theme.update` + +</details> + +### `settings theme install` + +Install / save / remove a cloud theme for this account. + +``` +tlgr settings theme install <SLUG> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `ThemeInstalled`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SLUG` | text | yes | The theme to install or save. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--dark` | flag | | Install it as the dark theme. | +| `--format` | text | `tdesktop` | Theming engine identifier. | +| `--remove` | flag | | Remove it from the saved list instead. | +| `--save/--no-save` | flag | `True` | Also add it to the saved list. | + +```console +$ tlgr settings theme install Nord --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `theme.save-install` + +</details> + +### `settings theme list` + +List cloud themes (installed, one by slug, or the collectible-gift themes). + +Metadata only: tlgr has no theming engine and renders nothing. + +``` +tlgr settings theme list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · idempotent (reports `already`) · returns `Page[CloudTheme]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--format` | text | `tdesktop` | Theming engine identifier. | +| `--gift` | flag | | Collectible-gift chat themes instead. | +| `--slug` | text | | One theme (a t.me/addtheme link works). | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr settings theme list --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `theme.cloud-themes`, `theme.get`, `theme.gift-chat-themes`, `theme.list-cloud` + +</details> + +### `settings unset` + +Remove a per-peer/per-URL exception or a single suggestion. + +`removed: -1` means the server cleared the list without saying how many. + +``` +tlgr settings unset <KEY> [VALUE] [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `SettingUnset`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `KEY` | text | yes | top-peers | browser-exception | autosave | saved-tag. | +| `VALUE` | text | no | Peer, URL or emoji to forget. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--category` | text | | top-peers: which rating to reset. | +| `--every` | flag | | Clear every exception for that key. | + +```console +$ tlgr settings unset browser-exception https://example.org --json +``` + +<details><summary>Catalog coverage (2 full, 1 partial)</summary> + +Full: `data.web-browser-settings`, `privacy.top-peers-suggest` + +Partial: `data.autosave-gallery` + +Setting the autosave rules is `settings autosave set`. + +</details> diff --git a/docs/reference/stars.md b/docs/reference/stars.md new file mode 100644 index 0000000..6f1e394 --- /dev/null +++ b/docs/reference/stars.md @@ -0,0 +1,219 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr stars` + +7 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 | +|---|---| +| [`stars balance get`](#tlgr-stars-balance-get) | My Telegram Stars balance (and the TON balance with --ton) | +| [`stars rating get`](#tlgr-stars-rating-get) | Star rating badge (level and progress) | +| [`stars revenue get`](#tlgr-stars-revenue-get) | Star / ad revenue statistics for a channel or bot I own | +| [`stars subscription list`](#tlgr-stars-subscription-list) | My Star subscriptions | +| [`stars subscription refulfill`](#tlgr-stars-subscription-refulfill) | Report whether a lapsed Star subscription can be re-joined (tlgr does not charge) | +| [`stars transaction list`](#tlgr-stars-transaction-list) | Star (or TON) transaction history | +| [`stars url get`](#tlgr-stars-url-get) | Get the Fragment URL for withdrawing revenue, or for buying ads with Stars | + +### `stars balance get` + +My Telegram Stars balance (and the TON balance with --ton). + +`nanos` is the fractional part the wire carries; TON amounts are nanotons. Neither is rounded, because a rounded ledger cannot be reconciled. + +``` +tlgr stars balance get [OPTIONS] +``` + +**idempotent (reports `already`) · returns `StarsBalance`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--ton` | flag | | The TON balance instead (amounts are nanotons). | + +```console +$ tlgr stars balance get --json +``` + +<details><summary>Catalog coverage (5 full, 1 partial)</summary> + +Full: `bots.bot-stars-balance`, `bots.stars-topup-deeplink`, `bots.stars-topup-options`, `stars.balance`, `stars.topup-options` + +Partial: `stars.ton-balance` + +The TON ledger itself is `stars transaction list --ton`. + +</details> + +### `stars rating get` + +Star rating badge (level and progress). + +``` +tlgr stars rating get [OPTIONS] +``` + +**idempotent (reports `already`) · returns `StarsRating`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--user` | user | | Whose rating (default: me). | + +```console +$ tlgr stars rating get --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stars.rating` + +</details> + +### `stars revenue get` + +Star / ad revenue statistics for a channel or bot I own. + +Needs `channelFull.can_view_stars_revenue`; the graphs are async tokens. + +``` +tlgr stars revenue get <CHAT> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `StarsRevenue`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | A channel or bot I own. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--dark` | flag | | Dark-theme graph tokens. | +| `--ton` | flag | | TON revenue instead of Stars. | + +```console +$ tlgr stars revenue get @mychannel --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `bots.bot-revenue-stats`, `stars.revenue-stats` + +</details> + +### `stars subscription list` + +My Star subscriptions. + +``` +tlgr stars subscription list [OPTIONS] +``` + +**paginated (`RATE` cursor) · idempotent (reports `already`) · returns `Page[StarSubscription]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--missing-balance` | flag | | Only the ones about to lapse for want of Stars. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr stars subscription list --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.channel-subscription-manage`, `stars.subscriptions-list` + +</details> + +### `stars subscription refulfill` + +Report whether a lapsed Star subscription can be re-joined (tlgr does not charge). + +``` +tlgr stars subscription refulfill <ID> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `StarsRefulfill`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `ID` | text | yes | The subscription id. | + +```console +$ tlgr stars subscription refulfill sub1 --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `stars.subscription-refulfill` + +Whether the server would allow it, and what it would cost, are reported; the charge itself is absent from tlgr's surface by policy. + +</details> + +### `stars transaction list` + +Star (or TON) transaction history. + +``` +tlgr stars transaction list [OPTIONS] +``` + +**paginated (`RATE` cursor) · idempotent (reports `already`) · returns `Page[StarsTransaction]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--ascending` | flag | | Oldest first. | +| `--id` | text | | Fetch specific transactions by id. | +| `--in` | flag | | Incoming only. | +| `--out` | flag | | Outgoing only. | +| `--peer` | chat | | Transactions with one bot or channel. | +| `--subscription` | text | | Only one subscription's charges. | +| `--ton` | flag | | The TON ledger instead of the Stars one. | + +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 stars transactions` + +```console +$ tlgr stars transaction list --out --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `stars.ton-balance`, `stars.transactions` + +</details> + +### `stars url get` + +Get the Fragment URL for withdrawing revenue, or for buying ads with Stars. + +Control-only: the URL is printed and the human completes the transfer in a browser. tlgr moves no money. + +``` +tlgr stars url get <CHAT> [OPTIONS] +``` + +**idempotent (reports `already`) · returns `StarsUrl`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The channel or bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--ads` | flag | | The ads-account URL instead of a withdrawal URL. | +| `--amount` | int | | Amount to withdraw. | +| `--password` | text | | The 2FA cloud password (never in argv). | +| `--ton` | flag | | Withdraw TON instead of Stars. | + +```console +$ tlgr stars url get @mychannel --amount 1000 --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `gifts.withdraw-ton`, `stars.ads-account`, `stars.withdraw` + +</details> diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 129b2fa..b1985e8 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -811,6 +811,96 @@ class World: subscriptions: list[Any] = field(default_factory=list) subscriptions_next: str = "" + # -- the settings world (PR-12) ---------------------------------------- + # + # Nine command groups share one Settings screen in the official clients, + # and they share one world here for the same reason: `privacy set` writes + # a rule vector that `privacy get` must read back, `notify set` writes an + # exception `notify exception list` must find, and a gift that has been + # converted must stop appearing in `gift list`. Canned replies could not + # express any of that. + + #: `inputPrivacyKey*` class name -> the rule vector the server holds. + privacy: dict[str, list[Any]] = field(default_factory=dict) + #: "users" | "chats" | "broadcasts" -> the scope's `peerNotifySettings`. + notify_scopes: dict[str, Any] = field(default_factory=dict) + #: marked chat id -> its notification exception. + notify_peers: dict[int, Any] = field(default_factory=dict) + reactions_notify: Any = None + notify_reset_calls: int = 0 + ringtones: list[Any] = field(default_factory=list) + #: slug -> `types.Theme`, plus what is installed and what is saved. + themes: dict[str, Any] = field(default_factory=dict) + installed_theme: str | None = None + saved_themes: list[str] = field(default_factory=list) + languages: list[Any] = field(default_factory=list) + web_browser: Any = None + peer_colors: list[Any] = field(default_factory=list) + profile_colors: list[Any] = field(default_factory=list) + background_emojis: list[Any] = field(default_factory=list) + #: "recent" | "default" | "collectible" -> emoji statuses. + emoji_statuses: dict[str, list[Any]] = field(default_factory=dict) + emoji_status_groups: list[Any] = field(default_factory=list) + recent_statuses_cleared: int = 0 + #: The channels `channels.getAdminedPublicChannels` answers with. + admined_public: list[int] = field(default_factory=list) + #: `fragment.getCollectibleInfo`. + collectible: Any = None + #: Where `account.updateProfile`/`updateBirthday`/`updatePersonalChannel` land. + profile: dict[str, Any] = field(default_factory=dict) + my_color: Any = None + my_profile_color: Any = None + + # -- the business world ------------------------------------------------ + + business: dict[str, Any] = field(default_factory=dict) + connected_bots: list[Any] = field(default_factory=list) + bot_connection: Any = None + confirmed_bots: list[int] = field(default_factory=list) + #: marked chat id -> "paused" | "resumed" | "removed" for the connected bot. + bot_chat_state: dict[int, str] = field(default_factory=dict) + chat_links: dict[str, Any] = field(default_factory=dict) + #: shortcut id -> {"shortcut": name, "messages": [types.Message]}. + quick_replies: dict[int, dict[str, Any]] = field(default_factory=dict) + quick_reply_order: list[int] = field(default_factory=list) + next_shortcut_id: int = 1 + + # -- the gift and Stars world ------------------------------------------ + + #: The catalogue, as `types.StarGift`. + gift_catalog: list[Any] = field(default_factory=list) + #: marked peer id -> the `savedStarGift` rows on that profile. + saved_gifts: dict[int, list[Any]] = field(default_factory=dict) + #: slug -> `types.StarGiftUnique`. + unique_gifts: dict[str, Any] = field(default_factory=dict) + #: marked peer id -> `types.StarGiftCollection` rows, in display order. + gift_collections: dict[int, list[Any]] = field(default_factory=dict) + next_collection_id: int = 1 + #: gift id -> the collectibles listed for resale. + resale_gifts: dict[int, list[Any]] = field(default_factory=dict) + #: gift id -> the sample attributes an upgrade would draw from. + upgrade_preview: dict[int, list[Any]] = field(default_factory=dict) + auctions: list[Any] = field(default_factory=list) + auction_states: list[Any] = field(default_factory=list) + auction_acquired: dict[int, list[Any]] = field(default_factory=dict) + gift_offers: list[int] = field(default_factory=list) + star_transactions: list[Any] = field(default_factory=list) + star_subscriptions: list[Any] = field(default_factory=list) + star_nanos: int = 0 + ton_balance: int = 0 + stars_revenue: Any = None + stars_url: str = "https://fragment.com/stars/withdraw?token=fake" + paid_message_revenue: int = 0 + #: slug -> `types.payments.CheckedGiftCode`. + gift_codes: dict[str, Any] = field(default_factory=dict) + applied_codes: list[str] = field(default_factory=list) + premium_promo: Any = None + giveaway_info: Any = None + #: marked channel id -> its prepaid giveaways. + prepaid_giveaways: dict[int, list[Any]] = field(default_factory=dict) + launched_giveaways: list[int] = field(default_factory=list) + premium_gift_options: list[Any] = field(default_factory=list) + # -- behaviour knobs --------------------------------------------------- _fail_next: dict[str, BaseException] = field(default_factory=dict) @@ -6458,6 +6548,955 @@ def _raw_ChangeStarsSubscriptionRequest(self, request: Any) -> Any: def _raw_BotCancelStarsSubscriptionRequest(self, request: Any) -> Any: return True + # ====================================================================== + # The settings world (PR-12) + # ====================================================================== + # + # `profile`, `privacy`, `notify`, `settings`, `business`, `premium`, + # `stars`, `gift` and `giveaway`. A few of the names below already exist + # earlier in this class as canned stubs from an earlier PR; these + # definitions replace them, because a stub that always answers the same + # thing cannot express "I wrote this, now read it back", which is what + # every test in this group is about. + + # -- profile ----------------------------------------------------------- + + def _raw_UpdateProfileRequest(self, request: Any) -> Any: + for name in ("first_name", "last_name", "about"): + value = getattr(request, name, None) + if value is not None: + self.world.profile[name] = value + if name == "about": + self._self_full()["about"] = value + else: + setattr(self.world.me, name, value) + return self.world.me + + def _self_full(self) -> dict[str, Any]: + """The `userFull` overrides for my own account. + + `world.user_full` already feeds `users.getFullUser`; writing here + rather than into a second dict is what makes `profile update` and + `profile get` talk about the same bio. + """ + return self.world.user_full.setdefault(int(self.world.me.id), {}) + + def _raw_UpdateBirthdayRequest(self, request: Any) -> bool: + self.world.profile["birthday"] = request.birthday + self.world.birthdays[int(self.world.me.id)] = request.birthday + return True + + def _raw_UpdatePersonalChannelRequest(self, request: Any) -> bool: + raw_id = getattr(request.channel, "channel_id", None) + self.world.profile["personal_channel_id"] = raw_id + self._self_full()["personal_channel_id"] = raw_id + return True + + def _raw_UpdateStatusRequest(self, request: Any) -> bool: + self.world.profile["offline"] = bool(request.offline) + return True + + def _raw_UpdateColorRequest(self, request: Any) -> bool: + if getattr(request, "for_profile", False): + self.world.my_profile_color = request.color + self.world.me.profile_color = request.color + else: + self.world.my_color = request.color + self.world.me.color = request.color + return True + + def _raw_UpdateEmojiStatusRequest(self, request: Any) -> bool: + self.world.profile["emoji_status"] = request.emoji_status + self.world.me.emoji_status = request.emoji_status + return True + + def _raw_GetRecentEmojiStatusesRequest(self, request: Any) -> Any: + return types.account.EmojiStatuses( + hash=0, statuses=list(self.world.emoji_statuses.get("recent", [])) + ) + + def _raw_GetDefaultEmojiStatusesRequest(self, request: Any) -> Any: + return types.account.EmojiStatuses( + hash=0, statuses=list(self.world.emoji_statuses.get("default", [])) + ) + + def _raw_GetCollectibleEmojiStatusesRequest(self, request: Any) -> Any: + return types.account.EmojiStatuses( + hash=0, statuses=list(self.world.emoji_statuses.get("collectible", [])) + ) + + def _raw_ClearRecentEmojiStatusesRequest(self, request: Any) -> bool: + self.world.emoji_statuses["recent"] = [] + self.world.recent_statuses_cleared += 1 + return True + + def _raw_GetPeerColorsRequest(self, request: Any) -> Any: + return types.help.PeerColors(hash=0, colors=list(self.world.peer_colors)) + + def _raw_GetPeerProfileColorsRequest(self, request: Any) -> Any: + return types.help.PeerColors(hash=0, colors=list(self.world.profile_colors)) + + def _raw_GetAdminedPublicChannelsRequest(self, request: Any) -> Any: + return types.messages.Chats( + chats=[ + self.world.chats[raw] + for raw in self.world.admined_public + if raw in self.world.chats + ] + ) + + def _raw_GetCollectibleInfoRequest(self, request: Any) -> Any: + if self.world.collectible is None: + from telethon.errors import RPCError + + raise RPCError(request, "USERNAME_NOT_OCCUPIED", 400) + return self.world.collectible + + def _raw_UploadProfilePhotoRequest(self, request: Any) -> Any: + photo = make_photo(5151 + len(self.world.user_photos.get(int(self.world.me.id), []))) + self.world.user_photos.setdefault(int(self.world.me.id), []).insert(0, photo) + if not getattr(request, "fallback", False): + self.world.me.photo = types.UserProfilePhoto(photo_id=photo.id, dc_id=2) + return types.photos.Photo(photo=photo, users=[self.world.me]) + + def _raw_UpdateProfilePhotoRequest(self, request: Any) -> Any: + wanted = int(getattr(request.id, "id", 0) or 0) + photos = self.world.user_photos.setdefault(int(self.world.me.id), []) + for photo in photos: + if int(photo.id) == wanted: + photos.remove(photo) + photos.insert(0, photo) + self.world.me.photo = types.UserProfilePhoto(photo_id=photo.id, dc_id=2) + return types.photos.Photo(photo=photo, users=[self.world.me]) + return types.photos.Photo(photo=types.PhotoEmpty(id=wanted), users=[]) + + def _raw_DeletePhotosRequest(self, request: Any) -> Any: + wanted = {int(getattr(item, "id", 0) or 0) for item in request.id} + photos = self.world.user_photos.setdefault(int(self.world.me.id), []) + self.world.user_photos[int(self.world.me.id)] = [ + photo for photo in photos if int(photo.id) not in wanted + ] + return sorted(wanted) + + def _raw_GetSavedMusicIdsRequest(self, request: Any) -> Any: + return types.account.SavedMusicIds( + ids=[int(d.id) for d in self.world.saved_music.get(int(self.world.me.id), [])] + ) + + def _raw_GetSavedMusicByIDRequest(self, request: Any) -> Any: + wanted = {int(getattr(d, "id", 0) or 0) for d in request.documents} + found = [ + document + for document in self.world.saved_music.get(int(self.world.me.id), []) + if int(document.id) in wanted + ] + return types.users.SavedMusic(count=len(found), documents=found) + + # -- privacy ----------------------------------------------------------- + + def _raw_GetPrivacyRequest(self, request: Any) -> Any: + return types.account.PrivacyRules( + rules=list(self.world.privacy.get(type(request.key).__name__, [])), + chats=list(self.world.chats.values()), + users=list(self.world.users.values()), + ) + + def _raw_SetPrivacyRequest(self, request: Any) -> Any: + stored: list[Any] = [] + for rule in request.rules: + name = type(rule).__name__.replace("InputPrivacyValue", "PrivacyValue") + builder = getattr(types, name, None) + if builder is None: # pragma: no cover - every input has an output twin + continue + users = getattr(rule, "users", None) + chats = getattr(rule, "chats", None) + if users is not None: + stored.append(builder(users=[self._user_id_of(u) for u in users])) + elif chats is not None: + stored.append(builder(chats=[int(c) for c in chats])) + else: + stored.append(builder()) + self.world.privacy[type(request.key).__name__] = stored + return types.account.PrivacyRules( + rules=stored, + chats=list(self.world.chats.values()), + users=list(self.world.users.values()), + ) + + def _raw_GetPaidMessagesRevenueRequest(self, request: Any) -> Any: + return types.account.PaidMessagesRevenue(stars_amount=self.world.paid_message_revenue) + + # -- notifications ----------------------------------------------------- + + #: `inputNotify*` class name -> the scope key the world stores it under. + SCOPE_NAMES = { + "InputNotifyUsers": "users", + "InputNotifyChats": "chats", + "InputNotifyBroadcasts": "broadcasts", + } + + def _raw_GetNotifySettingsRequest(self, request: Any) -> Any: + scope = self.SCOPE_NAMES.get(type(request.peer).__name__) + if scope is not None: + return self.world.notify_scopes.get(scope) or types.PeerNotifySettings(silent=False) + peer = getattr(request.peer, "peer", None) + if peer is None: + return types.PeerNotifySettings(silent=False) + chat_id = self._chat_id(peer) + stored = self.world.notify_peers.get(chat_id) + return stored if stored is not None else self.world.notify_of(chat_id) + + def _raw_UpdateNotifySettingsRequest(self, request: Any) -> bool: + settings = request.settings + scope = self.SCOPE_NAMES.get(type(request.peer).__name__) + if scope is not None: + self.world.notify_scopes[scope] = _merged_notify( + self.world.notify_scopes.get(scope), settings + ) + return True + peer = getattr(request.peer, "peer", None) + if peer is None: + return True + chat_id = self._chat_id(peer) + row = self.world.dialog(chat_id) + if _is_empty_notify(settings): + self.world.notify_peers.pop(chat_id, None) + row.mute_until = None + row.silent = None + return True + merged = _merged_notify(self.world.notify_peers.get(chat_id), settings) + self.world.notify_peers[chat_id] = merged + row.mute_until = getattr(merged, "mute_until", None) + row.silent = getattr(merged, "silent", None) + return True + + def _raw_GetNotifyExceptionsRequest(self, request: Any) -> Any: + return types.Updates( + updates=[ + types.UpdateNotifySettings( + peer=types.NotifyPeer(peer=_peer_for(chat_id)), notify_settings=settings + ) + for chat_id, settings in self.world.notify_peers.items() + ], + users=list(self.world.users.values()), + chats=list(self.world.chats.values()), + date=datetime.now(timezone.utc), + seq=0, + ) + + def _raw_ResetNotifySettingsRequest(self, request: Any) -> bool: + self.world.notify_peers.clear() + self.world.notify_scopes.clear() + self.world.notify_reset_calls += 1 + return True + + def _raw_GetReactionsNotifySettingsRequest(self, request: Any) -> Any: + return self.world.reactions_notify or types.ReactionsNotifySettings( + sound=types.NotificationSoundDefault(), show_previews=True + ) + + def _raw_SetReactionsNotifySettingsRequest(self, request: Any) -> Any: + self.world.reactions_notify = request.settings + return request.settings + + def _raw_GetSavedRingtonesRequest(self, request: Any) -> Any: + return types.account.SavedRingtones(hash=0, ringtones=list(self.world.ringtones)) + + def _raw_UploadRingtoneRequest(self, request: Any) -> Any: + self.world.next_document_id += 1 + document = make_document( + self.world.next_document_id, + mime=request.mime_type, + attributes=[types.DocumentAttributeFilename(file_name=request.file_name)], + ) + return self.world.add_document(document) + + def _raw_SaveRingtoneRequest(self, request: Any) -> Any: + wanted = int(getattr(request.id, "id", 0) or 0) + if request.unsave: + self.world.ringtones = [r for r in self.world.ringtones if int(r.id) != wanted] + return types.account.SavedRingtone() + document = self.world.documents.get(wanted) or make_document(wanted) + self.world.ringtones.append(document) + return types.account.SavedRingtone() + + # -- settings ---------------------------------------------------------- + + def _raw_GetDefaultHistoryTTLRequest(self, request: Any) -> Any: + return types.DefaultHistoryTTL(period=int(self.world.profile.get("default_ttl", 0) or 0)) + + def _raw_SetDefaultHistoryTTLRequest(self, request: Any) -> bool: + self.world.profile["default_ttl"] = int(request.period) + return True + + def _raw_ToggleSponsoredMessagesRequest(self, request: Any) -> bool: + self._self_full()["sponsored_enabled"] = bool(request.enabled) + return True + + def _browser(self) -> Any: + if self.world.web_browser is None: + self.world.web_browser = types.account.WebBrowserSettings( + external_exceptions=[], inapp_exceptions=[], hash=0 + ) + return self.world.web_browser + + def _raw_GetWebBrowserSettingsRequest(self, request: Any) -> Any: + return self._browser() + + def _raw_UpdateWebBrowserSettingsRequest(self, request: Any) -> bool: + current = self._browser() + current.open_external_browser = ( + bool(getattr(request, "open_external_browser", False)) or None + ) + current.display_close_button = bool(getattr(request, "display_close_button", False)) or None + return True + + def _raw_ToggleWebBrowserSettingsExceptionRequest(self, request: Any) -> bool: + current = self._browser() + domain = str(request.url).split("//")[-1].split("/")[0] + for vector in ("external_exceptions", "inapp_exceptions"): + setattr( + current, + vector, + [e for e in getattr(current, vector) if getattr(e, "domain", "") != domain], + ) + if not getattr(request, "delete", False): + vector = ( + "external_exceptions" + if getattr(request, "open_external_browser", False) + else "inapp_exceptions" + ) + getattr(current, vector).append( + types.WebDomainException(domain=domain, url=request.url, title=domain) + ) + return True + + def _raw_DeleteWebBrowserSettingsExceptionsRequest(self, request: Any) -> bool: + current = self._browser() + current.external_exceptions = [] + current.inapp_exceptions = [] + return True + + def _raw_GetLanguagesRequest(self, request: Any) -> Any: + return list(self.world.languages) + + def _raw_GetLanguageRequest(self, request: Any) -> Any: + for language in self.world.languages: + if getattr(language, "lang_code", None) == request.lang_code: + return language + from telethon.errors import RPCError + + raise RPCError(request, "LANG_PACK_INVALID", 400) + + def _raw_GetThemesRequest(self, request: Any) -> Any: + return types.account.Themes(hash=0, themes=list(self.world.themes.values())) + + def _raw_GetThemeRequest(self, request: Any) -> Any: + slug = getattr(request.theme, "slug", "") + theme = self.world.themes.get(slug) + if theme is None and self.world.themes: + from telethon.errors import RPCError + + raise RPCError(request, "THEME_INVALID", 400) + # An empty theme world keeps the canned answer the link-resolution + # suite was written against. + return theme or types.Theme(id=1, access_hash=1, slug=slug or "Slug", title="Midnight") + + def _raw_CreateThemeRequest(self, request: Any) -> Any: + slug = request.slug or request.title.lower() + theme = types.Theme( + id=900 + len(self.world.themes), + access_hash=1, + slug=slug, + title=request.title, + creator=True, + ) + self.world.themes[slug] = theme + return theme + + def _raw_UpdateThemeRequest(self, request: Any) -> Any: + slug = request.slug or getattr(request.theme, "slug", "") + theme = self.world.themes.get(slug) + if theme is None: + from telethon.errors import RPCError + + raise RPCError(request, "THEME_INVALID", 400) + if request.title: + theme.title = request.title + return theme + + def _raw_UploadThemeRequest(self, request: Any) -> Any: + self.world.next_document_id += 1 + return self.world.add_document( + make_document(self.world.next_document_id, mime=request.mime_type) + ) + + def _raw_SaveThemeRequest(self, request: Any) -> bool: + slug = getattr(request.theme, "slug", "") + if request.unsave: + self.world.saved_themes = [s for s in self.world.saved_themes if s != slug] + elif slug not in self.world.saved_themes: + self.world.saved_themes.append(slug) + return True + + def _raw_InstallThemeRequest(self, request: Any) -> bool: + self.world.installed_theme = getattr(request.theme, "slug", None) + return True + + def _raw_GetUniqueGiftChatThemesRequest(self, request: Any) -> Any: + return types.account.Themes(hash=0, themes=list(self.world.themes.values())) + + # -- business ---------------------------------------------------------- + + def _raw_UpdateBusinessWorkHoursRequest(self, request: Any) -> bool: + self.world.business["work_hours"] = request.business_work_hours + self._self_full()["business_work_hours"] = request.business_work_hours + return True + + def _raw_UpdateBusinessLocationRequest(self, request: Any) -> bool: + address = getattr(request, "address", None) + self.world.business["location"] = ( + types.BusinessLocation(address=address, geo_point=getattr(request, "geo_point", None)) + if address + else None + ) + self._self_full()["business_location"] = self.world.business["location"] + return True + + def _raw_UpdateBusinessIntroRequest(self, request: Any) -> bool: + intro = getattr(request, "intro", None) + self.world.business["intro"] = ( + types.BusinessIntro(title=intro.title, description=intro.description) + if intro is not None + else None + ) + self._self_full()["business_intro"] = self.world.business["intro"] + return True + + def _raw_UpdateBusinessGreetingMessageRequest(self, request: Any) -> bool: + message = getattr(request, "message", None) + self.world.business["greeting"] = ( + types.BusinessGreetingMessage( + shortcut_id=message.shortcut_id, + recipients=_recipients_out(message.recipients), + no_activity_days=message.no_activity_days, + ) + if message is not None + else None + ) + self._self_full()["business_greeting_message"] = self.world.business["greeting"] + return True + + def _raw_UpdateBusinessAwayMessageRequest(self, request: Any) -> bool: + message = getattr(request, "message", None) + self.world.business["away"] = ( + types.BusinessAwayMessage( + shortcut_id=message.shortcut_id, + schedule=message.schedule, + recipients=_recipients_out(message.recipients), + offline_only=message.offline_only, + ) + if message is not None + else None + ) + self._self_full()["business_away_message"] = self.world.business["away"] + return True + + def _raw_GetConnectedBotsRequest(self, request: Any) -> Any: + return types.account.ConnectedBots( + connected_bots=list(self.world.connected_bots), + users=list(self.world.users.values()), + ) + + def _raw_UpdateConnectedBotRequest(self, request: Any) -> Any: + bot_id = self._user_id_of(request.bot) + self.world.connected_bots = [ + row for row in self.world.connected_bots if int(row.bot_id) != bot_id + ] + if not getattr(request, "deleted", False): + self.world.connected_bots.append( + types.ConnectedBot( + bot_id=bot_id, + recipients=_recipients_out(request.recipients, bot=True), + rights=request.rights or types.BusinessBotRights(), + ) + ) + return self._updates() + + def _raw_ConfirmBotConnectionRequest(self, request: Any) -> Any: + self.world.confirmed_bots.append(self._user_id_of(request.bot_id)) + return self._updates() + + def _raw_ToggleConnectedBotPausedRequest(self, request: Any) -> bool: + self.world.bot_chat_state[self._chat_id(request.peer)] = ( + "paused" if request.paused else "resumed" + ) + return True + + def _raw_DisablePeerConnectedBotRequest(self, request: Any) -> bool: + self.world.bot_chat_state[self._chat_id(request.peer)] = "removed" + return True + + def _raw_GetBusinessChatLinksRequest(self, request: Any) -> Any: + return types.account.BusinessChatLinks( + links=list(self.world.chat_links.values()), chats=[], users=[] + ) + + def _raw_CreateBusinessChatLinkRequest(self, request: Any) -> Any: + slug = f"link{len(self.world.chat_links) + 1}" + link = types.BusinessChatLink( + link=f"https://t.me/m/{slug}", + message=request.link.message, + views=0, + entities=list(request.link.entities or []), + title=request.link.title, + ) + self.world.chat_links[slug] = link + return link + + def _raw_EditBusinessChatLinkRequest(self, request: Any) -> Any: + link = self.world.chat_links.get(request.slug) + if link is None: + from telethon.errors import RPCError + + raise RPCError(request, "BUSINESS_LINK_INVALID", 400) + link.message = request.link.message + link.title = request.link.title + return link + + def _raw_DeleteBusinessChatLinkRequest(self, request: Any) -> bool: + self.world.chat_links.pop(request.slug, None) + return True + + def _raw_ResolveBusinessChatLinkRequest(self, request: Any) -> Any: + link = self.world.chat_links.get(request.slug) + return types.account.ResolvedBusinessChatLinks( + peer=types.PeerUser(user_id=self.world.me.id), + message=getattr(link, "message", "") if link is not None else "", + chats=[], + users=[self.world.me], + entities=[], + ) + + # -- quick replies ----------------------------------------------------- + + def _raw_GetQuickRepliesRequest(self, request: Any) -> Any: + order = self.world.quick_reply_order or sorted(self.world.quick_replies) + rows = [] + for shortcut_id in order: + entry = self.world.quick_replies.get(shortcut_id) + if entry is None: + continue + messages = entry.get("messages", []) + rows.append( + types.QuickReply( + shortcut_id=shortcut_id, + shortcut=entry["shortcut"], + top_message=int(getattr(messages[-1], "id", 0) or 0) if messages else 0, + count=len(messages), + ) + ) + return types.messages.QuickReplies( + quick_replies=rows, + messages=[m for e in self.world.quick_replies.values() for m in e.get("messages", [])], + chats=[], + users=[], + ) + + def _raw_GetQuickReplyMessagesRequest(self, request: Any) -> Any: + entry = self.world.quick_replies.get(int(request.shortcut_id), {}) + return types.messages.Messages( + messages=list(entry.get("messages", [])), topics=[], chats=[], users=[] + ) + + def _raw_CheckQuickReplyShortcutRequest(self, request: Any) -> bool: + return True + + def _raw_EditQuickReplyShortcutRequest(self, request: Any) -> bool: + entry = self.world.quick_replies.get(int(request.shortcut_id)) + if entry is not None: + entry["shortcut"] = request.shortcut + return True + + def _raw_DeleteQuickReplyShortcutRequest(self, request: Any) -> bool: + self.world.quick_replies.pop(int(request.shortcut_id), None) + self.world.quick_reply_order = [ + i for i in self.world.quick_reply_order if i != int(request.shortcut_id) + ] + return True + + def _raw_DeleteQuickReplyMessagesRequest(self, request: Any) -> Any: + entry = self.world.quick_replies.get(int(request.shortcut_id)) + if entry is not None: + wanted = {int(i) for i in request.id} + entry["messages"] = [m for m in entry["messages"] if int(m.id) not in wanted] + return self._updates() + + def _raw_ReorderQuickRepliesRequest(self, request: Any) -> bool: + self.world.quick_reply_order = [int(i) for i in request.order] + return True + + def _raw_SendQuickReplyMessagesRequest(self, request: Any) -> Any: + entry = self.world.quick_replies.get(int(request.shortcut_id), {}) + chat_id = self._chat_id(request.peer) + wanted = {int(i) for i in request.id} + sent = [ + self.world.add_message(chat_id, getattr(m, "message", ""), out=True) + for m in entry.get("messages", []) + if int(m.id) in wanted + ] + return self._updates(*sent) + + # -- premium and giveaways --------------------------------------------- + + def _raw_GetPremiumPromoRequest(self, request: Any) -> Any: + return self.world.premium_promo or types.help.PremiumPromo( + status_text="Telegram Premium", + status_entities=[], + video_sections=["stories"], + videos=[], + period_options=[ + types.PremiumSubscriptionOption( + months=3, + currency="XTR", + amount=1000, + bot_url="https://t.me/PremiumBot?start=promo", + ) + ], + users=[], + ) + + def _raw_GetPremiumGiftCodeOptionsRequest(self, request: Any) -> Any: + return list(self.world.premium_gift_options) + + def _raw_CheckGiftCodeRequest(self, request: Any) -> Any: + found = self.world.gift_codes.get(request.slug) + if found is None and self.world.gift_codes: + from telethon.errors import RPCError + + raise RPCError(request, "GIFT_SLUG_INVALID", 400) + return found or types.payments.CheckedGiftCode( + date=datetime.now(timezone.utc), + days=90, + chats=[], + users=[], + used_date=datetime.now(timezone.utc), + ) + + def _raw_ApplyGiftCodeRequest(self, request: Any) -> Any: + self.world.applied_codes.append(request.slug) + found = self.world.gift_codes.get(request.slug) + if found is not None: + found.used_date = datetime.now(timezone.utc) + return self._updates() + + def _raw_GetGiveawayInfoRequest(self, request: Any) -> Any: + if self.world.giveaway_info is None: + return types.payments.GiveawayInfo( + start_date=datetime.now(timezone.utc), participating=True + ) + return self.world.giveaway_info + + def _raw_LaunchPrepaidGiveawayRequest(self, request: Any) -> Any: + self.world.launched_giveaways.append(int(request.giveaway_id)) + message = self.world.add_message(self._chat_id(request.peer), "giveaway", out=True) + return self._updates(message) + + # -- Stars ------------------------------------------------------------- + + def _raw_GetStarsStatusRequest(self, request: Any) -> Any: + ton = bool(getattr(request, "ton", False)) + amount = self.world.ton_balance if ton else self.world.star_balance + return types.payments.StarsStatus( + balance=types.StarsAmount(amount=amount, nanos=self.world.star_nanos), + chats=[], + users=[], + subscriptions=list(self.world.subscriptions), + ) + + def _raw_GetStarsTransactionsRequest(self, request: Any) -> Any: + rows = list(self.world.star_transactions) or [ + types.StarsTransaction( + id="tx1", + amount=types.StarsAmount(amount=50, nanos=0), + date=datetime.now(timezone.utc), + peer=types.StarsTransactionPeerFragment(), + title="Subscription", + ) + ] + if getattr(request, "inbound", False): + rows = [r for r in rows if int(getattr(r.amount, "amount", 0)) > 0] + if getattr(request, "outbound", False): + rows = [r for r in rows if int(getattr(r.amount, "amount", 0)) < 0] + return types.payments.StarsStatus( + balance=types.StarsAmount(amount=self.world.star_balance, nanos=0), + chats=[], + users=list(self.world.users.values()), + history=rows, + ) + + def _raw_GetStarsTransactionsByIDRequest(self, request: Any) -> Any: + wanted = {getattr(item, "id", "") for item in request.id} + return types.payments.StarsStatus( + balance=types.StarsAmount(amount=self.world.star_balance, nanos=0), + chats=[], + users=list(self.world.users.values()), + history=[r for r in self.world.star_transactions if r.id in wanted], + ) + + def _raw_GetStarsRevenueWithdrawalUrlRequest(self, request: Any) -> Any: + return types.payments.StarsRevenueWithdrawalUrl(url=self.world.stars_url) + + def _raw_GetStarsRevenueAdsAccountUrlRequest(self, request: Any) -> Any: + return types.payments.StarsRevenueAdsAccountUrl( + url="https://ads.telegram.org/account?token=fake" + ) + + # -- gifts ------------------------------------------------------------- + + def _raw_GetStarGiftsRequest(self, request: Any) -> Any: + return types.payments.StarGifts( + hash=0, gifts=list(self.world.gift_catalog), chats=[], users=[] + ) + + def _raw_GetSavedStarGiftsRequest(self, request: Any) -> Any: + rows = list(self.world.saved_gifts.get(self._chat_id(request.peer), [])) + if getattr(request, "exclude_unsaved", False): + rows = [r for r in rows if not getattr(r, "unsaved", False)] + if getattr(request, "exclude_unique", False): + rows = [r for r in rows if type(r.gift).__name__ != "StarGiftUnique"] + if getattr(request, "collection_id", None): + wanted = int(request.collection_id) + rows = [r for r in rows if wanted in (getattr(r, "collection_id", None) or [])] + return types.payments.SavedStarGifts( + count=len(rows), + gifts=rows, + chats=list(self.world.chats.values()), + users=list(self.world.users.values()), + ) + + def _raw_GetSavedStarGiftRequest(self, request: Any) -> Any: + wanted = [self._gift_key(item) for item in request.stargift] + found = [ + row + for rows in self.world.saved_gifts.values() + for row in rows + if self._saved_key(row) in wanted + ] + return types.payments.SavedStarGifts( + count=len(found), + gifts=found, + chats=list(self.world.chats.values()), + users=list(self.world.users.values()), + ) + + def _gift_key(self, ref: Any) -> Any: + for name in ("msg_id", "saved_id", "slug"): + value = getattr(ref, name, None) + if value is not None: + return (name, value) + return ("", None) + + def _saved_key(self, row: Any) -> Any: + if getattr(row, "msg_id", None): + return ("msg_id", row.msg_id) + if getattr(row, "saved_id", None): + return ("saved_id", row.saved_id) + return ("slug", getattr(getattr(row, "gift", None), "slug", None)) + + def _find_saved(self, ref: Any) -> Any: + wanted = self._gift_key(ref) + for chat_id, rows in self.world.saved_gifts.items(): + for row in rows: + if self._saved_key(row) == wanted: + return chat_id, row + return None + + def _raw_GetUniqueStarGiftRequest(self, request: Any) -> Any: + gift = self.world.unique_gifts.get(request.slug) + if gift is None: + from telethon.errors import RPCError + + raise RPCError(request, "SLUG_INVALID", 400) + return types.payments.UniqueStarGift( + gift=gift, chats=[], users=list(self.world.users.values()) + ) + + def _raw_SaveStarGiftRequest(self, request: Any) -> bool: + found = self._find_saved(request.stargift) + if found is not None: + found[1].unsaved = bool(getattr(request, "unsave", False)) or None + return True + + def _raw_ToggleStarGiftsPinnedToTopRequest(self, request: Any) -> bool: + chat_id = self._chat_id(request.peer) + wanted = [self._gift_key(item) for item in request.stargift] + for row in self.world.saved_gifts.get(chat_id, []): + row.pinned_to_top = (self._saved_key(row) in wanted) or None + return True + + def _raw_ConvertStarGiftRequest(self, request: Any) -> bool: + found = self._find_saved(request.stargift) + if found is not None: + chat_id, row = found + self.world.star_balance += int(getattr(row, "convert_stars", 0) or 0) + self.world.saved_gifts[chat_id] = [ + r for r in self.world.saved_gifts[chat_id] if r is not row + ] + return True + + def _raw_UpgradeStarGiftRequest(self, request: Any) -> Any: + found = self._find_saved(request.stargift) + if found is None: + return self._updates() + chat_id, row = found + gift = make_unique_gift( + slug=f"Upgraded-{len(self.world.unique_gifts) + 1}", + gift_id=int(getattr(row.gift, "id", 0) or 0), + num=len(self.world.unique_gifts) + 1, + ) + self.world.unique_gifts[gift.slug] = gift + row.gift = gift + message = self.world.add_message(chat_id, "") + message.action = types.MessageActionStarGiftUnique(gift=gift, upgrade=True) + return self._updates(message) + + def _raw_TransferStarGiftRequest(self, request: Any) -> Any: + found = self._find_saved(request.stargift) + if found is not None: + chat_id, row = found + self.world.saved_gifts[chat_id] = [ + r for r in self.world.saved_gifts[chat_id] if r is not row + ] + self.world.saved_gifts.setdefault(self._chat_id(request.to_id), []).append(row) + return self._updates() + + def _raw_CraftStarGiftRequest(self, request: Any) -> Any: + for ref in request.stargift: + found = self._find_saved(ref) + if found is not None: + chat_id, row = found + self.world.saved_gifts[chat_id] = [ + r for r in self.world.saved_gifts[chat_id] if r is not row + ] + gift = make_unique_gift( + slug=f"Crafted-{len(self.world.unique_gifts) + 1}", + gift_id=1, + num=len(self.world.unique_gifts) + 1, + ) + self.world.unique_gifts[gift.slug] = gift + message = self.world.add_message(int(self.world.me.id), "") + message.action = types.MessageActionStarGiftUnique(gift=gift, craft=True) + return self._updates(message) + + def _raw_UpdateStarGiftPriceRequest(self, request: Any) -> Any: + found = self._find_saved(request.stargift) + if found is not None: + gift = getattr(found[1], "gift", None) + if gift is not None and hasattr(gift, "resell_amount"): + amount = int(getattr(request.resell_amount, "amount", 0) or 0) + gift.resell_amount = [request.resell_amount] if amount else None + return self._updates() + + def _raw_GetResaleStarGiftsRequest(self, request: Any) -> Any: + rows = list(self.world.resale_gifts.get(int(request.gift_id), [])) + return types.payments.ResaleStarGifts( + count=len(rows), gifts=rows, chats=[], users=list(self.world.users.values()) + ) + + def _raw_ResolveStarGiftOfferRequest(self, request: Any) -> Any: + self.world.gift_offers.append(int(request.offer_msg_id)) + return self._updates() + + def _raw_GetStarGiftUpgradePreviewRequest(self, request: Any) -> Any: + return types.payments.StarGiftUpgradePreview( + sample_attributes=list(self.world.upgrade_preview.get(int(request.gift_id), [])), + prices=[], + next_prices=[], + ) + + def _raw_GetStarGiftCollectionsRequest(self, request: Any) -> Any: + return types.payments.StarGiftCollections( + collections=list(self.world.gift_collections.get(self._chat_id(request.peer), [])) + ) + + def _raw_CreateStarGiftCollectionRequest(self, request: Any) -> Any: + collection = types.StarGiftCollection( + collection_id=self.world.next_collection_id, + title=request.title, + gifts_count=len(request.stargift), + hash=0, + ) + self.world.next_collection_id += 1 + self.world.gift_collections.setdefault(self._chat_id(request.peer), []).append(collection) + return collection + + def _raw_UpdateStarGiftCollectionRequest(self, request: Any) -> Any: + for collection in self.world.gift_collections.get(self._chat_id(request.peer), []): + if int(collection.collection_id) == int(request.collection_id): + if request.title: + collection.title = request.title + collection.gifts_count += len(getattr(request, "add_stargift", None) or []) + collection.gifts_count -= len(getattr(request, "delete_stargift", None) or []) + return collection + from telethon.errors import RPCError + + raise RPCError(request, "COLLECTION_ID_INVALID", 400) + + def _raw_DeleteStarGiftCollectionRequest(self, request: Any) -> bool: + chat_id = self._chat_id(request.peer) + self.world.gift_collections[chat_id] = [ + c + for c in self.world.gift_collections.get(chat_id, []) + if int(c.collection_id) != int(request.collection_id) + ] + return True + + def _raw_ReorderStarGiftCollectionsRequest(self, request: Any) -> bool: + chat_id = self._chat_id(request.peer) + order = [int(i) for i in request.order] + self.world.gift_collections[chat_id] = sorted( + self.world.gift_collections.get(chat_id, []), + key=lambda c: ( + order.index(int(c.collection_id)) if int(c.collection_id) in order else len(order) + ), + ) + return True + + def _raw_GetBoostsStatusRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + rows = self.world.boosts.get(chat_id) or [] + return types.premium.BoostsStatus( + level=max(len(rows) // 2, 3 if not rows else 0), + current_level_boosts=len(rows) or 10, + boosts=len(rows) or 12, + boost_url=f"https://t.me/boost?c={abs(chat_id)}", + my_boost=any(getattr(r, "user_id", None) == self.world.me.id for r in rows) or None, + prepaid_giveaways=list(self.world.prepaid_giveaways.get(chat_id, [])) or None, + ) + + def _raw_GetStarGiftActiveAuctionsRequest(self, request: Any) -> Any: + return types.payments.StarGiftActiveAuctions( + auctions=list(self.world.auctions), users=[], chats=[] + ) + + def _raw_GetStarGiftAuctionAcquiredGiftsRequest(self, request: Any) -> Any: + return types.payments.StarGiftAuctionAcquiredGifts( + gifts=list(self.world.auction_acquired.get(int(request.gift_id), [])), + users=[], + chats=[], + ) + + def _raw_GetStarGiftAuctionStateRequest(self, request: Any) -> Any: + states = self.world.auction_states + if not states: + from telethon.errors import RPCError + + raise RPCError(request, "AUCTION_INVALID", 400) + # A `--watch` run steps through the queued states one at a time; the + # last one stays, so a caller that keeps asking keeps getting the end. + return states.pop(0) if len(states) > 1 else states[0] + class _AsyncFailure: """An async iterator that raises on the first step. @@ -6619,3 +7658,160 @@ def factory(session_path: Any, options: Any) -> FakeTelegramClient: factory.world = shared # type: ignore[attr-defined] return factory + + +# --------------------------------------------------------------------------- +# The settings world's own helpers (PR-12) +# --------------------------------------------------------------------------- + + +def _is_empty_notify(settings: Any) -> bool: + """True when `inputPeerNotifySettings` carries nothing at all. + + That is how the API deletes an exception — every field is optional, and + an unset field means "inherit" — so the fake has to treat it as a delete + rather than as a write of all-false. + """ + return all( + getattr(settings, name, None) is None + for name in ( + "show_previews", + "silent", + "mute_until", + "sound", + "stories_muted", + "stories_hide_sender", + "stories_sound", + ) + ) + + +def _merged_notify(current: Any, incoming: Any) -> Any: + """Apply an `inputPeerNotifySettings` over a stored `peerNotifySettings`. + + Field by field, because the input constructor only carries what the + caller set: merging is what makes "mute this chat" leave its sound alone, + which is the behaviour the ops promise. + """ + kept = { + "show_previews": getattr(current, "show_previews", None), + "silent": getattr(current, "silent", None), + "mute_until": getattr(current, "mute_until", None), + "other_sound": getattr(current, "other_sound", None), + "stories_muted": getattr(current, "stories_muted", None), + "stories_hide_sender": getattr(current, "stories_hide_sender", None), + "stories_other_sound": getattr(current, "stories_other_sound", None), + } + for name, target in ( + ("show_previews", "show_previews"), + ("silent", "silent"), + ("mute_until", "mute_until"), + ("sound", "other_sound"), + ("stories_muted", "stories_muted"), + ("stories_hide_sender", "stories_hide_sender"), + ("stories_sound", "stories_other_sound"), + ): + value = getattr(incoming, name, None) + if value is not None: + kept[target] = value + mute = kept["mute_until"] + if isinstance(mute, int): + kept["mute_until"] = datetime.fromtimestamp(mute, timezone.utc) if mute else None + return types.PeerNotifySettings(**kept) + + +def _recipients_out(raw: Any, *, bot: bool = False) -> Any: + """An `inputBusinessRecipients` as the output constructor the server returns.""" + builder = types.BusinessBotRecipients if bot else types.BusinessRecipients + kwargs: dict[str, Any] = { + "existing_chats": getattr(raw, "existing_chats", None), + "new_chats": getattr(raw, "new_chats", None), + "contacts": getattr(raw, "contacts", None), + "non_contacts": getattr(raw, "non_contacts", None), + "exclude_selected": getattr(raw, "exclude_selected", None), + "users": [int(getattr(u, "user_id", 0) or 0) for u in getattr(raw, "users", None) or []] + or None, + } + if bot: + kwargs["exclude_users"] = [ + int(getattr(u, "user_id", 0) or 0) for u in getattr(raw, "exclude_users", None) or [] + ] or None + return builder(**kwargs) + + +def make_star_gift( + gift_id: int = 5100, + *, + title: str = "Plush Pepe", + stars: int = 500, + convert_stars: int = 250, + limited: bool = False, + sold_out: bool = False, +) -> Any: + """A catalogue `starGift`.""" + return types.StarGift( + id=gift_id, + sticker=make_sticker_document(gift_id, emoji="🎁"), + stars=stars, + convert_stars=convert_stars, + limited=limited or None, + sold_out=sold_out or None, + title=title, + availability_remains=7 if limited else None, + availability_total=100 if limited else None, + upgrade_stars=25, + ) + + +def make_unique_gift( + *, + slug: str = "PlushPepe-42", + gift_id: int = 5100, + num: int = 42, + owner_id: int | None = None, + resell_stars: int | None = None, +) -> Any: + """A collectible `starGiftUnique`, with one attribute of each kind.""" + return types.StarGiftUnique( + id=gift_id * 1000 + num, + gift_id=gift_id, + title="Plush Pepe", + slug=slug, + num=num, + attributes=[ + types.StarGiftAttributeModel( + name="Golden", + document=make_sticker_document(gift_id + 1, emoji="🎁"), + rarity=types.StarGiftAttributeRarity(permille=5), + ) + ], + availability_issued=num, + availability_total=1000, + owner_id=types.PeerUser(user_id=owner_id) if owner_id else None, + resell_amount=([types.StarsAmount(amount=resell_stars, nanos=0)] if resell_stars else None), + value_amount=15000, + value_currency="XTR", + ) + + +def make_saved_gift( + gift: Any, + *, + msg_id: int | None = None, + saved_id: int | None = None, + from_id: int | None = None, + convert_stars: int = 250, + unsaved: bool = False, +) -> Any: + """A `savedStarGift` row on somebody's profile.""" + return types.SavedStarGift( + date=datetime.now(timezone.utc), + gift=gift, + msg_id=msg_id, + saved_id=saved_id, + from_id=types.PeerUser(user_id=from_id) if from_id else None, + convert_stars=convert_stars, + upgrade_stars=25, + can_upgrade=True, + unsaved=unsaved or None, + ) diff --git a/tests/test_ops_settings.py b/tests/test_ops_settings.py new file mode 100644 index 0000000..07f9d2f --- /dev/null +++ b/tests/test_ops_settings.py @@ -0,0 +1,2124 @@ +"""The profile, privacy, notification, settings, business, premium, Stars, +gift and giveaway operations. + +Same arrangement as the other group suites: a real Unix socket, the real +middleware chain, the real dispatcher, a fake Telegram. The assertions are +about *the world changing* — a privacy vector that was rewritten, a gift that +left one profile and arrived on another, a notification exception that a +later listing finds — because a canned reply cannot tell a working command +from a command that only looks right. + +Four things get more attention than the rest, because they are where this +group can do damage or tell a lie: + +* **replace-the-world APIs.** `setPrivacy` and `setGlobalPrivacySettings` + replace their whole payload; there is a test for each that changes one + thing and asserts the rest survived. +* **the mute clock.** v1 computed `mute_until` from the event loop's clock; + there is a test that the timestamp is a real, near-future wall-clock one. +* **money.** There is a test, written against the registry rather than + against a list of commands, that this group added no verb that spends. +* **absent methods.** Every flag that needs a request class Telethon 1.44 + lacks exits 13, not 1. +""" + +from __future__ import annotations + +import time +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest + +from tlgr.core.errors import ( + EXIT_INDETERMINATE, + EXIT_NOT_FOUND, + EXIT_PERMISSION, + EXIT_USAGE, +) + +ALICE = 4242 +BOB = 4343 +MYBOT = 5000001 +CHANNEL = 1600 +CHANNEL_ID = -1000000000000 - CHANNEL +GIFT_ID = 5100 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def settings_world(world): + """A world with a profile, two peers, a channel and the settings state.""" + from fake_telethon import ( + make_channel, + make_document, + make_saved_gift, + make_star_gift, + make_unique_gift, + make_user, + ) + from telethon.tl import types + + world.me.first_name = "Ada" + world.me.last_name = "Lovelace" + world.me.username = "adalovelace" + world.me.phone = "989123456789" + world.me.premium = True + world.add_user(make_user(ALICE, username="alice", first="Alice")) + world.add_user(make_user(BOB, username="bobby", first="Bob")) + bot = make_user(MYBOT, username="my_helper_bot", first="Helper") + bot.bot = True + world.add_user(bot) + channel = make_channel(CHANNEL, title="Notes") + channel.username = "ada_notes" + world.add_channel(channel) + world.admined_public.append(CHANNEL) + + world.user_full[int(world.me.id)] = {"about": "counting on it"} + + world.peer_colors = [ + types.help.PeerColorOption(color_id=5, hidden=None, channel_min_level=0, group_min_level=0), + types.help.PeerColorOption( + color_id=9, + colors=types.help.PeerColorSet(colors=[0x3FA3E8]), + dark_colors=types.help.PeerColorSet(colors=[0x1A5C86]), + channel_min_level=3, + ), + ] + world.profile_colors = list(world.peer_colors) + world.emoji_statuses = { + "recent": [types.EmojiStatus(document_id=5301)], + "default": [types.EmojiStatus(document_id=5302)], + "collectible": [], + } + world.languages = [ + types.LangPackLanguage( + name="Persian", + native_name="فارسی", + lang_code="fa", + plural_code="fa", + strings_count=4000, + translated_count=4000, + translations_url="https://translations.telegram.org/fa", + official=True, + ) + ] + world.themes = { + "Nord": types.Theme(id=991, access_hash=1, slug="Nord", title="Nord", installs_count=42) + } + world.ringtones = [ + make_document( + 8811, + mime="audio/ogg", + attributes=[types.DocumentAttributeFilename(file_name="chime.ogg")], + ) + ] + world.collectible = types.fragment.CollectibleInfo( + purchase_date=datetime.now(timezone.utc), + currency="USD", + amount=1000, + crypto_currency="TON", + crypto_amount=5, + url="https://fragment.com/username/adalovelace", + ) + + # -- business ------------------------------------------------------------ + world.quick_replies = { + 3: { + "shortcut": "hello", + "messages": [ + types.Message( + id=1, + peer_id=types.PeerUser(user_id=world.me.id), + date=datetime.now(timezone.utc), + message="Hi! I will reply shortly.", + out=True, + ) + ], + } + } + world.quick_reply_order = [3] + + # -- gifts and Stars ----------------------------------------------------- + gift = make_star_gift(GIFT_ID, limited=True) + world.gift_catalog = [gift, make_star_gift(5200, title="Star", sold_out=True)] + unique = make_unique_gift(slug="PlushPepe-42", gift_id=GIFT_ID, num=42, resell_stars=12000) + world.unique_gifts = {unique.slug: unique} + world.saved_gifts = { + int(world.me.id): [ + make_saved_gift(gift, msg_id=120, from_id=ALICE), + make_saved_gift(unique, msg_id=121, from_id=ALICE, convert_stars=0), + ] + } + world.resale_gifts = {GIFT_ID: [unique]} + world.upgrade_preview = { + GIFT_ID: [ + types.StarGiftAttributeModel( + name="Golden", + document=make_document(9001), + rarity=types.StarGiftAttributeRarity(permille=5), + ) + ] + } + world.star_balance = 250 + world.star_transactions = [ + types.StarsTransaction( + id="tx1", + amount=types.StarsAmount(amount=-25, nanos=0), + date=datetime.now(timezone.utc), + peer=types.StarsTransactionPeer(peer=types.PeerUser(user_id=MYBOT)), + title="Sticker pack", + gift=True, + ), + types.StarsTransaction( + id="tx2", + amount=types.StarsAmount(amount=100, nanos=0), + date=datetime.now(timezone.utc), + peer=types.StarsTransactionPeerFragment(), + title="Top-up", + ), + ] + world.subscriptions = [ + types.StarsSubscription( + id="sub1", + peer=types.PeerChannel(channel_id=CHANNEL), + until_date=datetime.now(timezone.utc) + timedelta(days=30), + pricing=types.StarsSubscriptionPricing(period=2592000, amount=100), + can_refulfill=True, + ) + ] + world.premium_gift_options = [ + types.PremiumGiftCodeOption(users=1, months=3, currency="XTR", amount=1000), + types.PremiumGiftCodeOption(users=10, months=3, currency="XTR", amount=9000), + ] + world.gift_codes = { + "abcdef": types.payments.CheckedGiftCode( + date=datetime.now(timezone.utc), + days=90, + chats=[], + users=[], + from_id=types.PeerChannel(channel_id=CHANNEL), + to_id=ALICE, + via_giveaway=True, + used_date=datetime.now(timezone.utc), + ) + } + world.prepaid_giveaways = { + CHANNEL_ID: [ + types.PrepaidGiveaway(id=77, months=3, quantity=10, date=datetime.now(timezone.utc)) + ] + } + world.app_config = { + "giveaway_countries_max": 10, + "giveaway_add_peers_max": 10, + "ringtone_size_max": 300 * 1024, + "caption_length_limit_default": 1024, + "caption_length_limit_premium": 2048, + "channel_wallpaper_level_min": 9, + "premium_purchase_blocked": True, + "premium_bot_username": "PremiumBot", + } + 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: + """The result, with a paginated one put back together. + + The daemon splits a `Page` across `result` (the items) and `page` (the + cursor half), which is the wire shape a caller walks; a test reads + better with the two halves back in one dict. + """ + envelope = await call(client, in_thread, op, request, **kwargs) + if "page" in envelope: + return {"items": envelope["result"], **envelope["page"]} + return envelope["result"] + + +async def fails(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> Any: + """Run an op that must fail, and hand back the exception.""" + from tlgr.core.errors import TlgrError + + try: + await call(client, in_thread, op, request, **kwargs) + except TlgrError as exc: + return exc + raise AssertionError(f"{op} was expected to fail") + + +# --------------------------------------------------------------------------- +# profile +# --------------------------------------------------------------------------- + + +class TestProfileGet: + async def test_the_bio_comes_from_the_full_user_not_from_an_empty_string( + self, live_daemon, client, in_thread, settings_world + ): + """v1's bug: `get_me()` has no bio, so v1 reported `""` for everyone.""" + profile = await result(client, in_thread, "profile.get") + assert profile["bio"] == "counting on it" + assert profile["id"] == settings_world.me.id + assert profile["first_name"] == "Ada" + assert profile["username"] == "adalovelace" + + async def test_no_full_skips_the_second_round_trip( + self, live_daemon, client, in_thread, settings_world + ): + profile = await result(client, in_thread, "profile.get", {"full": False}) + assert "bio" not in profile + assert not settings_world.called("GetFullUserRequest") + + async def test_the_username_vector_marks_the_main_handle( + self, live_daemon, client, in_thread, settings_world + ): + from telethon.tl import types + + settings_world.me.usernames = [ + types.Username(username="parked", active=False), + types.Username(username="adalovelace", active=True, editable=True), + ] + profile = await result(client, in_thread, "profile.get") + rows = {row["username"]: row for row in profile["usernames"]} + assert rows["adalovelace"].get("main") is True + assert rows["parked"].get("main") is not True + + async def test_the_v1_documented_path_still_resolves(self): + from tlgr.registry import ALIASES + + assert ALIASES["profile.get"] == "profile.get" + assert ALIASES["profile.update"] == "profile.update" + assert ALIASES["profile.set"] == "profile.update" + + +class TestProfileUpdate: + async def test_it_reports_only_the_fields_it_changed( + self, live_daemon, client, in_thread, settings_world + ): + changed = await result(client, in_thread, "profile.update", {"bio": "now with bees"}) + assert changed["changed"] == ["bio"] + assert changed["bio"] == "now with bees" + assert settings_world.user_full[int(settings_world.me.id)]["about"] == "now with bees" + + async def test_an_empty_last_name_clears_it_rather_than_being_ignored( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "profile.update", {"last_name": ""}) + assert settings_world.me.last_name == "" + + async def test_a_birthday_reaches_its_own_rpc( + self, live_daemon, client, in_thread, settings_world + ): + changed = await result(client, in_thread, "profile.update", {"birthday": "10-12-1815"}) + assert changed["birthday"] == "10-12-1815" + sent = settings_world.called("UpdateBirthdayRequest")[0] + assert (sent.birthday.day, sent.birthday.month, sent.birthday.year) == (10, 12, 1815) + + async def test_a_nonsense_birthday_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "profile.update", {"birthday": "yesterday"}) + assert error.exit_code == EXIT_USAGE + + async def test_changing_nothing_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "profile.update", {}) + assert error.exit_code == EXIT_USAGE + + async def test_none_unlinks_the_personal_channel( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "profile.update", {"channel": "none"}) + sent = settings_world.called("UpdatePersonalChannelRequest")[0] + assert type(sent.channel).__name__ == "InputChannelEmpty" + + +class TestProfileUsername: + async def test_check_writes_nothing(self, live_daemon, client, in_thread, settings_world): + answer = await result( + client, in_thread, "profile.username.set", {"name": "newname", "check": True} + ) + assert answer["available"] is True + assert not settings_world.called("UpdateUsernameRequest") + + async def test_a_fragment_only_name_is_purchasable_not_taken( + self, live_daemon, client, in_thread, settings_world + ): + from telethon.errors import RPCError + + settings_world.fail_next( + "CheckUsernameRequest", RPCError(None, "USERNAME_PURCHASE_AVAILABLE", 400) + ) + answer = await result( + client, in_thread, "profile.username.set", {"name": "adalovelace", "check": True} + ) + assert answer["available"] is False + assert answer["purchasable"] is True + + async def test_toggling_needs_the_name(self, live_daemon, client, in_thread, settings_world): + error = await fails(client, in_thread, "profile.username.set", {"on": True}) + assert error.exit_code == EXIT_USAGE + + async def test_reorder_sends_the_whole_list( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "profile.username.set", {"order": "adalovelace,parked"}) + assert settings_world.called("ReorderUsernamesRequest")[0].order == [ + "adalovelace", + "parked", + ] + + async def test_the_listing_answers_from_the_user( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "profile.username.list") + assert [row["username"] for row in page["items"]] == ["adalovelace"] + + +class TestProfilePhoto: + async def test_the_listing_marks_the_current_avatar( + self, live_daemon, client, in_thread, settings_world + ): + from fake_telethon import make_photo + from telethon.tl import types + + photo = make_photo(55123) + settings_world.user_photos[int(settings_world.me.id)] = [photo, make_photo(55122)] + settings_world.me.photo = types.UserProfilePhoto(photo_id=photo.id, dc_id=2) + page = await result(client, in_thread, "profile.photo.list") + assert [row["id"] for row in page["items"]] == [55123, 55122] + assert page["items"][0]["current"] is True + + async def test_setting_from_a_file_uploads_and_sends_the_raw_request( + self, live_daemon, client, in_thread, settings_world, tmp_path + ): + path = tmp_path / "avatar.jpg" + path.write_bytes(b"\xff\xd8\xff" + b"0" * 64) + answer = await result(client, in_thread, "profile.photo.set", {"file": str(path)}) + assert answer["photo_id"] + sent = settings_world.called("UploadProfilePhotoRequest")[0] + assert sent.file is not None and sent.video is None + + async def test_an_emoji_avatar_sends_a_markup_and_no_file( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, + in_thread, + "profile.photo.set", + {"emoji": "5301", "colors": "#FF0000,#00FF00"}, + ) + sent = settings_world.called("UploadProfilePhotoRequest")[0] + assert sent.file is None + assert sent.video_emoji_markup.emoji_id == 5301 + assert sent.video_emoji_markup.background_colors == [0xFF0000, 0x00FF00] + + async def test_setting_without_a_source_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "profile.photo.set", {}) + assert error.exit_code == EXIT_USAGE + + async def test_reusing_an_unknown_photo_id_is_not_found( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "profile.photo.set", {"photo_id": "999"}) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_deleting_removes_it_from_the_history( + self, live_daemon, client, in_thread, settings_world + ): + from fake_telethon import make_photo + + settings_world.user_photos[int(settings_world.me.id)] = [make_photo(55123)] + answer = await result(client, in_thread, "profile.photo.delete", {"photo_id": ["55123"]}) + assert answer["deleted"] == 1 + assert settings_world.user_photos[int(settings_world.me.id)] == [] + + async def test_deleting_every_photo_when_there_are_none_is_already( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "profile.photo.delete", {"every": True}) + assert answer["already"] is True + + +class TestProfileMisc: + async def test_presence_sends_the_inverse_of_online( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "profile.presence.set", {"state": "online"}) + assert answer["online"] is True + assert settings_world.called("UpdateStatusRequest")[0].offline is False + + async def test_an_unknown_presence_word_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "profile.presence.set", {"state": "away"}) + assert error.exit_code == EXIT_USAGE + + async def test_the_status_lists_carry_the_group_they_came_from( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "profile.status.list", {"recent": True}) + assert [row["group"] for row in page["items"]] == ["recent"] + + async def test_clear_recent_empties_the_list( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "profile.status.list", {"clear_recent": True}) + assert settings_world.recent_statuses_cleared == 1 + assert settings_world.emoji_statuses["recent"] == [] + + async def test_a_status_is_set_and_read_back( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "profile.status.set", {"emoji": "5301"}) + assert answer["document_id"] == 5301 + assert settings_world.me.emoji_status.document_id == 5301 + + async def test_clearing_the_status_sends_the_empty_constructor( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "profile.status.set", {"clear": True}) + assert answer["cleared"] is True + sent = settings_world.called("UpdateEmojiStatusRequest")[-1] + assert type(sent.emoji_status).__name__ == "EmojiStatusEmpty" + + async def test_the_builtin_palettes_are_named_as_such( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "profile.color.list") + by_id = {row["color_id"]: row for row in page["items"]} + assert by_id[5]["builtin"] is True + assert by_id[9].get("builtin") is not True + assert by_id[9]["colors"] == ["#3FA3E8"] + + async def test_setting_a_palette_sends_the_peer_color( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "profile.color.set", {"color": "5"}) + assert answer["color"] == 5 + assert settings_world.my_color.color == 5 + + async def test_a_collectible_palette_is_refused_for_the_profile_colour( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, + in_thread, + "profile.color.set", + {"color": "collectible:9", "profile": True}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_the_admined_channels_mark_the_one_on_the_profile( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.user_full[int(settings_world.me.id)]["personal_channel_id"] = CHANNEL + page = await result(client, in_thread, "profile.channel.list") + assert page["items"][0]["username"] == "ada_notes" + assert page["items"][0]["current"] is True + + async def test_the_link_is_the_username_form_when_there_is_one( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "profile.link") + assert answer["link"] == "https://t.me/adalovelace" + assert answer["resolvable_by_strangers"] is True + + async def test_without_a_username_the_link_only_works_for_people_who_know_me( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.me.username = None + answer = await result(client, in_thread, "profile.link") + assert answer["link"].startswith("tg://user?id=") + assert answer["resolvable_by_strangers"] is False + + async def test_the_collectible_details_come_from_fragment( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "profile.link", {"collectible": True}) + assert answer["collectible"]["currency"] == "USD" + assert answer["collectible"]["amount"] == 1000 + + async def test_the_profile_music_defaults_to_my_own( + self, live_daemon, client, in_thread, settings_world + ): + from fake_telethon import make_document + from telethon.tl import types + + settings_world.saved_music[int(settings_world.me.id)] = [ + make_document( + 991, + mime="audio/mpeg", + attributes=[ + types.DocumentAttributeAudio(duration=300, title="Nocturne", performer="Chopin") + ], + ) + ] + page = await result(client, in_thread, "profile.music.list") + assert page["items"][0]["title"] == "Nocturne" + + +# --------------------------------------------------------------------------- +# privacy +# --------------------------------------------------------------------------- + + +class TestPrivacy: + async def test_a_rule_written_is_a_rule_read_back( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, + in_thread, + "privacy.set", + {"key": "last-seen", "rule": "contacts", "disallow": "@alice"}, + ) + answer = await result(client, in_thread, "privacy.get", {"key": "last-seen"}) + row = answer["items"][0] + assert row["base"] == "contacts" + assert row["deny_users"] == [ALICE] + + async def test_add_allow_keeps_what_was_already_there( + self, live_daemon, client, in_thread, settings_world + ): + """`setPrivacy` replaces the vector; the point of `--add-*` is that a + script never has to re-state a list it did not mean to touch.""" + await result( + client, + in_thread, + "privacy.set", + {"key": "phone-number", "rule": "nobody", "allow": "@alice"}, + ) + await result( + client, in_thread, "privacy.set", {"key": "phone-number", "add_allow": "@bobby"} + ) + row = (await result(client, in_thread, "privacy.get", {"key": "phone-number"}))["items"][0] + assert sorted(row["allow_users"]) == sorted([ALICE, BOB]) + assert row["base"] == "nobody" + + async def test_remove_drops_a_peer_from_both_lists( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, + in_thread, + "privacy.set", + {"key": "forwards", "rule": "contacts", "allow": "@alice", "disallow": "@bobby"}, + ) + await result(client, in_thread, "privacy.set", {"key": "forwards", "remove": "@alice"}) + row = (await result(client, in_thread, "privacy.get", {"key": "forwards"}))["items"][0] + # An empty list is the default, so `omit_defaults` drops it: absent + # means "no exceptions", which is exactly what was asked for. + assert row.get("allow_users", []) == [] + assert row["deny_users"] == [BOB] + + async def test_the_exception_rules_precede_the_base_rule( + self, live_daemon, client, in_thread, settings_world + ): + """The server applies the vector in order, so a broad rule written + first would decide every case on its own.""" + await result( + client, + in_thread, + "privacy.set", + {"key": "bio", "rule": "everybody", "disallow": "@alice"}, + ) + sent = settings_world.called("SetPrivacyRequest")[-1] + names = [type(rule).__name__ for rule in sent.rules] + assert names.index("InputPrivacyValueDisallowUsers") < names.index( + "InputPrivacyValueAllowAll" + ) + + async def test_an_unknown_key_lists_the_ones_that_exist( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "privacy.get", {"key": "telepathy"}) + assert error.exit_code == EXIT_USAGE + assert "last-seen" in str(error) + + async def test_stories_names_the_two_commands_that_own_story_visibility( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "privacy.get", {"key": "stories"}) + assert error.exit_code == EXIT_USAGE + assert "story blocklist set" in str(error) + + async def test_reading_every_key_answers_one_row_each( + self, live_daemon, client, in_thread, settings_world + ): + from tlgr.ops.privacy import KEYS + + page = await result(client, in_thread, "privacy.get") + assert len(page["items"]) == len(KEYS) + + +class TestGlobalPrivacy: + async def test_one_flag_changes_and_the_others_survive( + self, live_daemon, client, in_thread, settings_world + ): + """`setGlobalPrivacySettings` replaces the whole constructor, so the + read-modify-write is the only thing standing between one flag and + silently clearing the rest.""" + await result(client, in_thread, "privacy.global.set", {"hide_read_marks": "on"}) + await result(client, in_thread, "privacy.global.set", {"archive_new_noncontacts": "on"}) + answer = await result(client, in_thread, "privacy.global.get") + assert answer["hide_read_marks"] is True + assert answer["archive_and_mute_new_noncontact_peers"] is True + + async def test_the_paid_price_is_carried_through( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "privacy.global.set", {"paid_messages_price": 5}) + assert answer["noncontact_peers_paid_stars"] == 5 + assert "noncontact_peers_paid_stars" in answer["changed"] + + async def test_gift_categories_are_named_not_masked( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "privacy.global.set", {"disallow_gifts": "limited,unique"} + ) + assert answer["disallowed_gifts"] == ["limited", "unique"] + + async def test_an_unknown_gift_category_lists_the_real_ones( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "privacy.global.set", {"disallow_gifts": "socks"}) + assert error.exit_code == EXIT_USAGE + + async def test_changing_nothing_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "privacy.global.set", {}) + assert error.exit_code == EXIT_USAGE + + +class TestBlocked: + async def test_blocking_puts_the_peer_on_the_list( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "privacy.blocked.set", {"peer": ["@alice"]}) + assert answer["blocked"] == [ALICE] + page = await result(client, in_thread, "privacy.blocked.list") + assert [row["peer"]["id"] for row in page["items"]] == [ALICE] + + async def test_unblocking_reports_the_other_half_of_the_diff( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "privacy.blocked.set", {"peer": ["@alice"]}) + answer = await result( + client, in_thread, "privacy.blocked.set", {"peer": ["@alice"], "unblock": True} + ) + assert answer["unblocked"] == [ALICE] + assert answer["blocked"] == [] + + async def test_the_story_list_is_a_separate_one( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, in_thread, "privacy.blocked.set", {"peer": ["@alice"], "stories": True} + ) + main = await result(client, in_thread, "privacy.blocked.list") + stories = await result(client, in_thread, "privacy.blocked.list", {"stories": True}) + assert main["items"] == [] + assert [row["peer"]["id"] for row in stories["items"]] == [ALICE] + + async def test_naming_nobody_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "privacy.blocked.set", {}) + assert error.exit_code == EXIT_USAGE + + async def test_the_paid_message_revenue_reads_back( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.paid_message_revenue = 25 + answer = await result(client, in_thread, "privacy.revenue.get", {"user": "@alice"}) + assert answer["stars_amount"] == 25 + assert answer["user_id"] == ALICE + + +# --------------------------------------------------------------------------- +# notify +# --------------------------------------------------------------------------- + + +class TestNotify: + async def test_the_mute_timestamp_is_wall_clock_not_the_event_loop( + self, live_daemon, client, in_thread, settings_world + ): + """v1 computed this from `loop.time()`, whose origin is arbitrary, so + every "mute for an hour" produced a timestamp in 1970.""" + answer = await result(client, in_thread, "notify.set", {"target": "private", "mute": "2h"}) + sent = settings_world.called("UpdateNotifySettingsRequest")[0] + assert abs(int(sent.settings.mute_until) - (int(time.time()) + 7200)) < 30 + assert answer["muted"] is True + + async def test_forever_is_the_servers_own_sentinel( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "notify.set", {"target": "groups", "mute": "forever"}) + sent = settings_world.called("UpdateNotifySettingsRequest")[0] + assert int(sent.settings.mute_until) == 2**31 - 1 + + async def test_muting_a_chat_leaves_its_sound_alone( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, in_thread, "notify.set", {"target": "@alice", "sound": "ringtone:8811"} + ) + await result(client, in_thread, "notify.set", {"target": "@alice", "mute": "1h"}) + answer = await result(client, in_thread, "notify.get", {"target": "@alice"}) + assert answer["sound"] == "8811" + assert answer["muted"] is True + + async def test_the_scope_and_the_chat_are_different_targets( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "notify.set", {"target": "private", "mute": "1h"}) + chat = await result(client, in_thread, "notify.get", {"target": "@alice"}) + assert chat.get("muted") is not True + + async def test_contact_joined_is_reported_the_way_a_human_reads_it( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "notify.set", {"target": "contact-joined", "off": True}) + answer = await result(client, in_thread, "notify.get", {"target": "contact-joined"}) + assert answer["contact_joined"] is False + assert settings_world.called("SetContactSignUpNotificationRequest")[0].silent is True + + async def test_contact_joined_needs_one_of_the_two_flags( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "notify.set", {"target": "contact-joined"}) + assert error.exit_code == EXIT_USAGE + + async def test_the_reaction_alerts_are_read_modify_written( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, in_thread, "notify.set", {"target": "reactions", "messages": "contacts"} + ) + await result(client, in_thread, "notify.set", {"target": "reactions", "stories": "off"}) + answer = await result(client, in_thread, "notify.get", {"target": "reactions"}) + assert answer["messages_from"] == "contacts" + assert answer["stories_from"] == "off" + + async def test_an_unknown_reaction_audience_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, in_thread, "notify.set", {"target": "reactions", "messages": "friends"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_changing_nothing_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "notify.set", {"target": "private"}) + assert error.exit_code == EXIT_USAGE + + async def test_an_exception_is_listed_and_then_cleared( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "notify.set", {"target": "@alice", "mute": "1h"}) + page = await result(client, in_thread, "notify.exception.list") + assert [row["chat_id"] for row in page["items"]] == [ALICE] + assert page["items"][0]["scope"] == "private" + + cleared = await result(client, in_thread, "notify.exception.clear", {"chat": ["@alice"]}) + assert cleared["cleared"] == 1 + assert (await result(client, in_thread, "notify.exception.list"))["items"] == [] + + async def test_clearing_nothing_named_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "notify.exception.clear", {}) + assert error.exit_code == EXIT_USAGE + + async def test_reset_drops_every_exception( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "notify.set", {"target": "@alice", "mute": "1h"}) + answer = await result(client, in_thread, "notify.reset") + assert answer["ok"] is True + assert settings_world.notify_peers == {} + + async def test_the_ringtone_id_is_what_the_sound_flag_takes( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "notify.ringtone.list") + assert page["items"][0]["id"] == 8811 + assert page["items"][0]["file_name"] == "chime.ogg" + + async def test_uploading_a_ringtone_saves_it( + self, live_daemon, client, in_thread, settings_world, tmp_path + ): + path = tmp_path / "bell.ogg" + path.write_bytes(b"OggS" + b"0" * 64) + answer = await result(client, in_thread, "notify.ringtone.set", {"file": str(path)}) + assert answer["file_name"] == "bell.ogg" + assert len(settings_world.ringtones) == 2 + + async def test_a_ringtone_over_the_server_limit_is_refused_before_upload( + self, live_daemon, client, in_thread, settings_world, tmp_path + ): + settings_world.app_config["ringtone_size_max"] = 16 + path = tmp_path / "big.ogg" + path.write_bytes(b"0" * 64) + error = await fails(client, in_thread, "notify.ringtone.set", {"file": str(path)}) + assert error.exit_code == EXIT_USAGE + assert not settings_world.called("UploadRingtoneRequest") + + async def test_removing_an_unknown_ringtone_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "notify.ringtone.set", {"remove": "999"}) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# settings +# --------------------------------------------------------------------------- + + +class TestSettings: + async def test_every_row_says_what_its_setter_accepts( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "settings.get", {"key": "auto-delete"}) + row = answer["items"][0] + assert row["key"] == "auto-delete" + assert row["accepts"] == "1d|1w|1m|<duration>|off" + + async def test_a_write_reports_what_it_replaced( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "settings.set", {"key": "auto-delete", "value": ["1w"]} + ) + assert answer["previous"] == "off" + assert answer["value"] == "604800s" + + async def test_writing_the_value_that_is_already_there_is_already( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "settings.set", {"key": "auto-delete", "value": ["1w"]}) + answer = await result( + client, in_thread, "settings.set", {"key": "auto-delete", "value": ["1w"]} + ) + assert answer["already"] is True + + async def test_an_unknown_key_lists_the_real_ones( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "settings.get", {"key": "telepathy"}) + assert error.exit_code == EXIT_USAGE + + async def test_the_read_only_key_refuses_the_write_with_a_reason( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, in_thread, "settings.set", {"key": "age-verification", "value": ["on"]} + ) + assert error.exit_code == EXIT_PERMISSION + + async def test_the_browser_exception_lands_in_the_vector_that_means_its_mode( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, + in_thread, + "settings.set", + {"key": "browser-exception", "value": ["https://example.org/x", "external"]}, + ) + answer = await result(client, in_thread, "settings.get", {"key": "browser-exception"}) + assert answer["items"][0]["value"] == [ + { + "domain": "example.org", + "url": "https://example.org/x", + "title": "example.org", + "mode": "external", + } + ] + + async def test_a_browser_exception_without_a_mode_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, + in_thread, + "settings.set", + {"key": "browser-exception", "value": ["https://example.org"]}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_unsetting_the_browser_exception_removes_it( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, + in_thread, + "settings.set", + {"key": "browser-exception", "value": ["https://example.org", "in-app"]}, + ) + answer = await result( + client, + in_thread, + "settings.unset", + {"key": "browser-exception", "value": "https://example.org"}, + ) + assert answer["removed"] == 1 + read = await result(client, in_thread, "settings.get", {"key": "browser-exception"}) + assert read["items"][0]["value"] == [] + + async def test_unsetting_an_unknown_key_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "settings.unset", {"key": "colour"}) + assert error.exit_code == EXIT_USAGE + + async def test_reading_every_key_survives_one_of_them_failing( + self, live_daemon, client, in_thread, settings_world + ): + """A single unreadable key must not take the whole screen with it.""" + from telethon.errors import RPCError + + settings_world.fail_next("GetContentSettingsRequest", RPCError(None, "INTERNAL", 500)) + page = await result(client, in_thread, "settings.get") + assert len(page["items"]) >= 10 + + async def test_the_language_list_comes_from_the_server( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "settings.language.list") + assert page["items"][0]["lang_code"] == "fa" + assert page["items"][0]["official"] is True + + async def test_an_unknown_language_code_is_not_found( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "settings.language.list", {"code": "xx"}) + assert error.exit_code in (EXIT_NOT_FOUND, EXIT_USAGE) + + async def test_a_theme_is_created_and_then_listed( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "settings.theme.create", {"title": "Dusk"}) + assert answer["title"] == "Dusk" + page = await result(client, in_thread, "settings.theme.list") + assert "Dusk" in [row["title"] for row in page["items"]] + + async def test_creating_a_theme_without_a_title_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "settings.theme.create", {}) + assert error.exit_code == EXIT_USAGE + + async def test_installing_a_theme_saves_and_installs_it( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "settings.theme.install", {"slug": "Nord"}) + assert answer["installed"] is True and answer["saved"] is True + assert settings_world.installed_theme == "Nord" + assert settings_world.saved_themes == ["Nord"] + + async def test_removing_a_theme_only_unsaves_it( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "settings.theme.install", {"slug": "Nord"}) + answer = await result( + client, in_thread, "settings.theme.install", {"slug": "Nord", "remove": True} + ) + assert answer["removed"] is True + assert settings_world.saved_themes == [] + + async def test_an_unknown_theme_slug_is_not_found( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "settings.theme.list", {"slug": "Nope"}) + assert error.exit_code in (EXIT_NOT_FOUND, EXIT_USAGE) + + async def test_autosave_writes_the_scope_the_server_names( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "settings.autosave.set", {"scope": "users", "photos": "on"}) + sent = settings_world.called("SaveAutoSaveSettingsRequest")[0] + assert sent.users is True + assert sent.settings.photos is True + + async def test_an_unknown_autosave_scope_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "settings.autosave.set", {"scope": "everyone"}) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# business +# --------------------------------------------------------------------------- + + +class TestBusiness: + async def test_the_overview_reads_what_the_setters_wrote( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, + in_thread, + "business.set", + {"tz": "Europe/London", "open": ["mon-fri 09:00-18:00"]}, + ) + answer = await result(client, in_thread, "business.get") + assert answer["work_hours"]["timezone_id"] == "Europe/London" + assert len(answer["work_hours"]["weekly_open"]) == 5 + + async def test_opening_hours_are_merged_and_sorted( + self, live_daemon, client, in_thread, settings_world + ): + """Two lines for the same day is how a human writes one interval; the + server rejects overlaps, so they are merged before they are sent.""" + await result( + client, + in_thread, + "business.set", + {"tz": "Europe/London", "open": ["mon 09:00-13:00", "mon 12:00-18:00"]}, + ) + sent = settings_world.called("UpdateBusinessWorkHoursRequest")[0] + opens = sent.business_work_hours.weekly_open + assert len(opens) == 1 + assert (opens[0].start_minute, opens[0].end_minute) == (9 * 60, 18 * 60) + + async def test_a_range_that_ends_before_it_starts_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, + in_thread, + "business.set", + {"tz": "Europe/London", "open": ["mon 22:00-02:00"]}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_opening_hours_need_a_timezone( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.set", {"open": ["mon 09:00-18:00"]}) + assert error.exit_code == EXIT_USAGE + + async def test_a_location_needs_an_address( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.set", {"lat": 51.5, "lon": -0.1}) + assert error.exit_code == EXIT_USAGE + + async def test_clearing_the_hours_sends_no_constructor( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "business.set", {"clear_hours": True}) + assert ( + settings_world.called("UpdateBusinessWorkHoursRequest")[0].business_work_hours is None + ) + + async def test_changing_nothing_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.set", {}) + assert error.exit_code == EXIT_USAGE + + async def test_the_greeting_names_a_shortcut_and_lands_on_the_world( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, + in_thread, + "business.message.set", + {"kind": "greeting", "shortcut": "hello", "new_chats": True}, + ) + assert answer["kind"] == "greeting" + assert answer["shortcut_id"] == 3 + assert settings_world.business["greeting"].shortcut_id == 3 + + async def test_an_unknown_shortcut_is_not_found( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, + in_thread, + "business.message.set", + {"kind": "greeting", "shortcut": "missing"}, + ) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_omitting_the_shortcut_disables_the_message( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "business.message.set", {"kind": "away"}) + assert answer["enabled"] is False + assert settings_world.business["away"] is None + + async def test_a_custom_away_schedule_needs_both_ends( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, + in_thread, + "business.message.set", + {"kind": "away", "shortcut": "hello", "schedule": "custom"}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_an_unknown_message_kind_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.message.set", {"kind": "farewell"}) + assert error.exit_code == EXIT_USAGE + + +class TestQuickReplies: + async def test_the_listing_carries_the_messages_of_one_shortcut( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "business.reply.list", {"shortcut": "hello"}) + assert page["items"][0]["shortcut"] == "hello" + assert page["items"][0]["messages"][0]["text"].startswith("Hi!") + + async def test_an_unknown_shortcut_is_not_found( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.reply.list", {"shortcut": "missing"}) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_adding_to_a_new_shortcut_checks_it_first( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, in_thread, "business.reply.add", {"shortcut": "bye", "text": "Goodbye"} + ) + assert settings_world.called("CheckQuickReplyShortcutRequest")[0].shortcut == "bye" + sent = settings_world.called("SendMessageRequest")[0] + assert type(sent.quick_reply_shortcut).__name__ == "InputQuickReplyShortcut" + + async def test_adding_to_an_existing_shortcut_uses_its_id( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, in_thread, "business.reply.add", {"shortcut": "hello", "text": "Again"} + ) + sent = settings_world.called("SendMessageRequest")[0] + assert type(sent.quick_reply_shortcut).__name__ == "InputQuickReplyShortcutId" + + async def test_adding_nothing_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.reply.add", {"shortcut": "bye"}) + assert error.exit_code == EXIT_USAGE + + async def test_renaming_a_shortcut_changes_the_world( + self, live_daemon, client, in_thread, settings_world + ): + await result( + client, in_thread, "business.reply.edit", {"shortcut": "hello", "rename": "hi"} + ) + assert settings_world.quick_replies[3]["shortcut"] == "hi" + + async def test_reorder_wants_every_shortcut( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.reply.edit", {"order": "nope"}) + assert error.exit_code == EXIT_USAGE + + async def test_deleting_one_message_keeps_the_shortcut( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "business.reply.delete", {"shortcut": "hello", "msg_id": [1]} + ) + assert answer["deleted"] == 1 + assert settings_world.quick_replies[3]["messages"] == [] + assert 3 in settings_world.quick_replies + + async def test_deleting_the_shortcut_removes_it( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "business.reply.delete", {"shortcut": "hello"}) + assert settings_world.quick_replies == {} + + async def test_sending_a_quick_reply_lands_in_the_chat( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "business.reply.send", {"chat": "@alice", "shortcut": "hello"} + ) + assert answer["chat_id"] == ALICE + assert settings_world.history(ALICE) + + +class TestBusinessLinksAndBots: + async def test_a_link_is_created_and_listed( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "business.link.set", {"text": "Hi there"}) + assert answer["message"] == "Hi there" + page = await result(client, in_thread, "business.link.list") + assert page["items"][0]["message"] == "Hi there" + + async def test_deleting_a_link_needs_its_slug( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.link.set", {"delete": True}) + assert error.exit_code == EXIT_USAGE + + async def test_resolving_a_link_answers_its_prefilled_message( + self, live_daemon, client, in_thread, settings_world + ): + created = await result(client, in_thread, "business.link.set", {"text": "Hi there"}) + page = await result(client, in_thread, "business.link.list", {"slug": created["slug"]}) + assert page["items"][0]["message"] == "Hi there" + + async def test_rights_default_to_none_and_are_granted_by_name( + self, live_daemon, client, in_thread, settings_world + ): + """The security-critical assertion of this group: no flag, no right.""" + await result( + client, + in_thread, + "business.bot.set", + {"bot": "@my_helper_bot", "reply_to": True, "new_chats": True}, + ) + sent = settings_world.called("UpdateConnectedBotRequest")[0] + granted = ( + {name for name in sent.rights.__struct_fields__ if getattr(sent.rights, name, None)} + if hasattr(sent.rights, "__struct_fields__") + else { + name + for name in dir(sent.rights) + if not name.startswith("_") and getattr(sent.rights, name, None) is True + } + ) + assert granted == {"reply"} + + async def test_the_connection_is_listed_after_it_is_made( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "business.bot.set", {"bot": "@my_helper_bot", "read": True}) + page = await result(client, in_thread, "business.bot.list") + assert page["items"][0]["bot_id"] == MYBOT + assert page["items"][0]["rights"]["read_messages"] is True + + async def test_disconnecting_removes_it(self, live_daemon, client, in_thread, settings_world): + await result(client, in_thread, "business.bot.set", {"bot": "@my_helper_bot"}) + await result( + client, in_thread, "business.bot.set", {"bot": "@my_helper_bot", "disconnect": True} + ) + assert settings_world.connected_bots == [] + + async def test_the_bot_side_connection_needs_a_bot_session( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.bot.list", {"connection": "conn1"}) + assert error.exit_code in (4, EXIT_PERMISSION) + + async def test_pausing_and_removing_are_different_chat_states( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "business.bot.toggle", {"chat": "@alice"}) + assert settings_world.bot_chat_state[ALICE] == "paused" + await result(client, in_thread, "business.bot.toggle", {"chat": "@alice", "remove": True}) + assert settings_world.bot_chat_state[ALICE] == "removed" + + async def test_the_stars_transfer_prices_and_refuses( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, + in_thread, + "business.stars.transfer", + {"bot": "@my_helper_bot", "amount": 100}, + ) + assert answer["ok"] is False + assert "never spends money" in answer["reason"] + assert not settings_world.called("SendStarsFormRequest") + + async def test_the_transfer_needs_an_amount( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "business.stars.transfer", {"bot": "@my_helper_bot"}) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# premium, stars, giveaway +# --------------------------------------------------------------------------- + + +class TestPremium: + async def test_the_status_prints_the_deep_link_rather_than_an_error( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "premium.status") + assert answer["premium"] is True + assert answer["premium_bot"] == "PremiumBot" + assert answer["invoice_link"].startswith("https://t.me/PremiumBot") + + async def test_the_limit_table_pairs_default_with_premium( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "premium.feature.list", {"limits": True}) + rows = {row["name"]: row for row in answer["limits"]} + assert rows["caption_length"]["default"] == 1024 + assert rows["caption_length"]["premium"] == 2048 + + async def test_the_boost_level_table_is_assembled_from_app_config( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "premium.feature.list", {"boost_levels": True}) + assert {"key": "channel_wallpaper_level_min", "level": 9} in answer["boost_levels"] + + async def test_my_boost_slots_are_the_default_listing( + self, live_daemon, client, in_thread, settings_world + ): + from telethon.tl import types + + settings_world.my_boosts = [ + types.MyBoost(slot=1, date=datetime.now(timezone.utc), expires=None) + ] + page = await result(client, in_thread, "premium.boost.list") + assert page["items"][0]["slot"] == 1 + + async def test_the_gift_options_hide_the_giveaway_ones_by_default( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "premium.gift.list") + assert [row["users"] for row in page["items"]] == [1] + every = await result(client, in_thread, "premium.gift.list", {"single": False}) + assert len(every["items"]) == 2 + + async def test_gifting_premium_prices_and_refuses( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "premium.gift.send", {"user": "@alice", "months": 3} + ) + assert answer["ok"] is False + assert answer["months"] == 3 + assert not settings_world.called("SendStarsFormRequest") + + async def test_gifting_needs_the_length(self, live_daemon, client, in_thread, settings_world): + error = await fails(client, in_thread, "premium.gift.send", {"user": "@alice"}) + assert error.exit_code == EXIT_USAGE + + async def test_a_gift_code_reports_months_from_the_wires_days( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "premium.giftcode.get", {"slug": "abcdef"}) + assert answer["days"] == 90 + assert answer["months"] == 3 + assert answer["via_giveaway"] is True + + async def test_an_unknown_code_is_not_found( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "premium.giftcode.get", {"slug": "nope"}) + assert error.exit_code in (EXIT_NOT_FOUND, EXIT_USAGE) + + async def test_redeeming_a_code_applies_it( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.gift_codes["abcdef"].used_date = None + await result(client, in_thread, "premium.giftcode.get", {"slug": "abcdef", "redeem": True}) + assert settings_world.applied_codes == ["abcdef"] + + +class TestStars: + async def test_the_balance_keeps_its_nanos( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.star_nanos = 500000000 + answer = await result(client, in_thread, "stars.balance.get") + assert answer["stars"] == 250 + assert answer["nanos"] == 500000000 + + async def test_the_ton_balance_is_reported_separately( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.ton_balance = 7 + answer = await result(client, in_thread, "stars.balance.get", {"ton": True}) + assert answer["ton"] == 7 + assert answer["currency"] == "TON" + + async def test_the_ledger_signs_outgoing_amounts( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "stars.transaction.list") + by_id = {row["id"]: row for row in page["items"]} + assert by_id["tx1"]["stars"] == -25 + assert by_id["tx1"]["kind"] == "gift" + assert by_id["tx2"]["peer_kind"] == "fragment" + + async def test_out_filters_to_the_spends(self, live_daemon, client, in_thread, settings_world): + page = await result(client, in_thread, "stars.transaction.list", {"outbound": True}) + assert [row["id"] for row in page["items"]] == ["tx1"] + + async def test_specific_ids_are_fetched_by_id( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "stars.transaction.list", {"id": "tx2"}) + assert [row["id"] for row in page["items"]] == ["tx2"] + assert settings_world.called("GetStarsTransactionsByIDRequest") + + async def test_the_subscriptions_carry_the_refulfill_flag( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "stars.subscription.list") + assert page["items"][0]["id"] == "sub1" + assert page["items"][0]["can_refulfill"] is True + + async def test_refulfill_reports_the_price_and_refuses( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "stars.subscription.refulfill", {"id": "sub1"}) + assert answer["ok"] is False + assert answer["can_refulfill"] is True + assert answer["stars"] == 100 + assert not settings_world.called("FulfillStarsSubscriptionRequest") + + async def test_refulfilling_an_unknown_subscription_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "stars.subscription.refulfill", {"id": "nope"}) + assert error.exit_code == EXIT_USAGE + + async def test_the_rating_reports_the_progress_to_the_next_level( + self, live_daemon, client, in_thread, settings_world + ): + from telethon.tl import types + + settings_world.user_full[int(settings_world.me.id)]["stars_rating"] = types.StarsRating( + level=3, current_level_stars=1000, stars=1200, next_level_stars=2000 + ) + answer = await result(client, in_thread, "stars.rating.get") + assert (answer["level"], answer["stars"], answer["next_level_stars"]) == (3, 1200, 2000) + + async def test_the_revenue_reports_the_graph_as_a_token( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "stars.revenue.get", {"chat": "ada_notes"}) + assert answer["withdrawal_enabled"] is True + assert answer["revenue_graph"]["kind"] + + async def test_the_withdrawal_url_is_printed_and_nothing_moves( + self, live_daemon, client, in_thread, settings_world, monkeypatch + ): + monkeypatch.setenv("TLGR_2FA_PASSWORD", "hunter2") + answer = await result( + client, in_thread, "stars.url.get", {"chat": "ada_notes", "amount": 1000} + ) + assert answer["kind"] == "withdrawal" + assert answer["url"].startswith("https://fragment.com/") + + async def test_the_ads_url_needs_no_password( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "stars.url.get", {"chat": "ada_notes", "ads": True} + ) + assert answer["kind"] == "ads" + + +class TestGiveaway: + async def test_the_personal_and_public_halves_are_one_answer( + self, live_daemon, client, in_thread, settings_world + ): + from telethon.tl import types + + settings_world.giveaway_info = types.payments.GiveawayInfoResults( + start_date=datetime.now(timezone.utc), + finish_date=datetime.now(timezone.utc), + winners_count=10, + winner=True, + gift_code_slug="abcdef", + activated_count=4, + ) + answer = await result( + client, in_thread, "giveaway.get", {"chat": "ada_notes", "msg_id": 42} + ) + assert answer["state"] == "finished" + assert answer["winner"] is True + assert answer["gift_code_slug"] == "abcdef" + + async def test_a_country_refusal_is_named(self, live_daemon, client, in_thread, settings_world): + from telethon.tl import types + + settings_world.giveaway_info = types.payments.GiveawayInfo( + start_date=datetime.now(timezone.utc), disallowed_country="NL" + ) + answer = await result( + client, in_thread, "giveaway.get", {"chat": "ada_notes", "msg_id": 42} + ) + assert answer["disallowed_reason"] == "disallowed-country" + + async def test_joining_is_boosting(self, live_daemon, client, in_thread, settings_world): + from telethon.tl import types + + settings_world.my_boosts = [ + types.MyBoost(slot=1, date=datetime.now(timezone.utc), expires=None) + ] + answer = await result(client, in_thread, "giveaway.join", {"chat": "ada_notes"}) + assert answer["chat_id"] == CHANNEL_ID + assert settings_world.called("ApplyBoostRequest") + + async def test_the_prepaid_list_comes_from_the_boost_status( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "giveaway.list", {"chat": "ada_notes"}) + assert page["items"][0]["id"] == 77 + assert page["items"][0]["quantity"] == 10 + + async def test_listing_without_a_channel_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "giveaway.list", {}) + assert error.exit_code == EXIT_USAGE + + async def test_launching_a_prepaid_giveaway_spends_nothing( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, + in_thread, + "giveaway.start", + {"chat": "ada_notes", "prepaid_id": 77, "winners": 10}, + ) + assert answer["prepaid_id"] == 77 + assert settings_world.launched_giveaways == [77] + assert not settings_world.called("SendStarsFormRequest") + + async def test_too_many_countries_is_refused_before_the_call( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.app_config["giveaway_countries_max"] = 1 + error = await fails( + client, + in_thread, + "giveaway.start", + {"chat": "ada_notes", "prepaid_id": 77, "countries": "NL,GB"}, + ) + assert error.exit_code == EXIT_USAGE + assert not settings_world.called("LaunchPrepaidGiveawayRequest") + + async def test_checking_a_code_names_the_winner( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "giveaway.code.check", {"slug": "abcdef"}) + assert answer["to_id"] == ALICE + + async def test_applying_a_used_code_is_already( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "giveaway.code.apply", {"slug": "abcdef"}) + assert answer["already"] is True + assert settings_world.applied_codes == [] + + async def test_applying_a_fresh_code_activates_it( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.gift_codes["abcdef"].used_date = None + answer = await result(client, in_thread, "giveaway.code.apply", {"slug": "abcdef"}) + assert answer["applied"] is True + assert settings_world.applied_codes == ["abcdef"] + + +# --------------------------------------------------------------------------- +# gift +# --------------------------------------------------------------------------- + + +class TestGift: + async def test_the_catalogue_reads_and_filters( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "gift.catalog", {"available": True}) + assert [row["gift_id"] for row in page["items"]] == [GIFT_ID] + + async def test_the_can_send_annotation_refuses_with_the_method_it_needs( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.catalog", {"until": "@alice"}) + assert error.exit_code == EXIT_INDETERMINATE + assert "canSendStarGift" in str(error) + + async def test_a_gift_reference_round_trips( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "gift.list") + refs = [row["ref"] for row in page["items"]] + assert refs == ["msg:120", "msg:121"] + one = await result(client, in_thread, "gift.get", {"ref": "msg:120"}) + assert one["ref"] == "msg:120" + assert one["convert_stars"] == 250 + + async def test_a_slug_reference_resolves_the_collectible( + self, live_daemon, client, in_thread, settings_world + ): + one = await result(client, in_thread, "gift.get", {"ref": "PlushPepe-42"}) + assert one["kind"] == "collectible" + assert one["num"] == 42 + + async def test_a_t_me_nft_link_is_reduced_to_its_slug( + self, live_daemon, client, in_thread, settings_world + ): + one = await result( + client, in_thread, "gift.unique.get", {"slug": "https://t.me/nft/PlushPepe-42"} + ) + assert one["slug"] == "PlushPepe-42" + assert one["link"] == "https://t.me/nft/PlushPepe-42" + + async def test_an_unknown_reference_is_not_found( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.get", {"ref": "msg:999"}) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_a_malformed_reference_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.get", {"ref": "msg:abc"}) + assert error.exit_code == EXIT_USAGE + + async def test_hiding_a_gift_takes_it_off_the_profile( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "gift.set", {"ref": ["msg:120"], "unsave": True}) + assert answer["displayed"] is False + page = await result(client, in_thread, "gift.list", {"exclude_unsaved": True}) + assert [row["ref"] for row in page["items"]] == ["msg:121"] + + async def test_pinning_adds_to_the_set_rather_than_replacing_it( + self, live_daemon, client, in_thread, settings_world + ): + await result(client, in_thread, "gift.set", {"ref": ["msg:120"], "pin": True}) + await result(client, in_thread, "gift.set", {"ref": ["msg:121"], "pin": True}) + page = await result(client, in_thread, "gift.list") + assert [row["ref"] for row in page["items"] if row.get("pinned")] == [ + "msg:120", + "msg:121", + ] + + async def test_wearing_needs_a_collectible( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.set", {"ref": ["msg:120"], "wear": True}) + assert error.exit_code == EXIT_USAGE + + async def test_wearing_a_collectible_sets_the_emoji_status( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "gift.set", {"ref": ["msg:121"], "wear": True}) + assert answer["worn"] is True + assert type(settings_world.me.emoji_status).__name__ == "InputEmojiStatusCollectible" + + async def test_asking_for_no_change_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.set", {"ref": ["msg:120"]}) + assert error.exit_code == EXIT_USAGE + + async def test_converting_credits_stars_and_destroys_the_gift( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "gift.convert", {"ref": "msg:120"}) + assert answer["stars_received"] == 250 + assert answer["balance_after"] == 500 + page = await result(client, in_thread, "gift.list") + assert [row["ref"] for row in page["items"]] == ["msg:121"] + + async def test_a_prepaid_upgrade_is_performed( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result(client, in_thread, "gift.upgrade", {"ref": "msg:120"}) + assert answer["upgraded"] is True + assert answer["slug"] + + async def test_an_unpaid_upgrade_is_priced_and_refused( + self, live_daemon, client, in_thread, settings_world + ): + row = settings_world.saved_gifts[int(settings_world.me.id)][0] + row.upgrade_stars = None + row.can_upgrade = None + answer = await result(client, in_thread, "gift.upgrade", {"ref": "msg:120"}) + assert answer["upgraded"] is False + assert "never spends money" in answer["refused_reason"] + assert not settings_world.called("UpgradeStarGiftRequest") + + async def test_a_free_transfer_moves_the_gift( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "gift.transfer", {"ref": "msg:121", "chat": "@alice"} + ) + assert answer["transferred"] is True + assert settings_world.saved_gifts[ALICE] + + async def test_a_paid_transfer_is_priced_and_refused( + self, live_daemon, client, in_thread, settings_world + ): + settings_world.saved_gifts[int(settings_world.me.id)][1].transfer_stars = 50 + answer = await result( + client, in_thread, "gift.transfer", {"ref": "msg:121", "chat": "@alice"} + ) + assert answer["transferred"] is False + assert answer["price_stars"] == 50 + assert not settings_world.called("TransferStarGiftRequest") + + async def test_crafting_burns_every_input(self, live_daemon, client, in_thread, settings_world): + answer = await result(client, in_thread, "gift.craft", {"ref": ["msg:120", "msg:121"]}) + assert answer["crafted"] is True + assert answer["burned"] == ["msg:120", "msg:121"] + page = await result(client, in_thread, "gift.list") + assert page["items"] == [] + + async def test_the_craft_candidates_flag_names_the_method_it_needs( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.craft", {"candidates": GIFT_ID}) + assert error.exit_code == EXIT_INDETERMINATE + assert "getStarGiftCraftCandidates" in str(error) + + async def test_crafting_nothing_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.craft", {}) + assert error.exit_code == EXIT_USAGE + + async def test_the_marketplace_reads_prices( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "gift.resale.list", {"gift_id": GIFT_ID}) + assert page["items"][0]["price_stars"] == 12000 + + async def test_a_malformed_attribute_filter_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, in_thread, "gift.resale.list", {"gift_id": GIFT_ID, "attr": ["colour=red"]} + ) + assert error.exit_code == EXIT_USAGE + + async def test_listing_my_collectible_sets_a_price( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "gift.resale.set", {"ref": "msg:121", "stars": 9000} + ) + assert answer["listed"] is True + sent = settings_world.called("UpdateStarGiftPriceRequest")[0] + assert sent.resell_amount.amount == 9000 + + async def test_listing_without_a_price_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.resale.set", {"ref": "msg:121"}) + assert error.exit_code == EXIT_USAGE + + async def test_declining_an_offer_is_performed( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, + in_thread, + "gift.offer.approve", + {"chat": "@alice", "msg_id": 512, "deny": True}, + ) + assert answer["state"] == "declined" + assert settings_world.gift_offers == [512] + + async def test_accepting_an_offer_is_refused_because_it_sells_an_asset( + self, live_daemon, client, in_thread, settings_world + ): + answer = await result( + client, in_thread, "gift.offer.approve", {"chat": "@alice", "msg_id": 512} + ) + assert answer["state"] == "refused" + assert settings_world.gift_offers == [] + + async def test_the_upgrade_preview_reports_rarity( + self, live_daemon, client, in_thread, settings_world + ): + page = await result( + client, in_thread, "gift.variant.list", {"gift_id": GIFT_ID, "preview": True} + ) + assert page["items"][0]["rarity_permille"] == 5 + assert page["items"][0]["kind"] == "model" + + async def test_the_craft_only_filter_names_the_method_it_needs( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, in_thread, "gift.variant.list", {"gift_id": GIFT_ID, "craft_only": True} + ) + assert error.exit_code == EXIT_INDETERMINATE + + async def test_the_value_flag_warns_about_what_is_missing( + self, live_daemon, client, in_thread, settings_world + ): + envelope = await call( + client, in_thread, "gift.unique.get", {"slug": "PlushPepe-42", "value": True} + ) + assert envelope["result"]["value_stars"] == 15000 + assert any("getStarGiftValueInfo" in w for w in envelope["meta"].get("warnings", [])) + + +class TestGiftCollections: + async def test_a_collection_is_created_listed_edited_and_deleted( + self, live_daemon, client, in_thread, settings_world + ): + created = await result( + client, + in_thread, + "gift.collection.create", + {"chat": "me", "title": "Favourites", "refs": ["msg:120"]}, + ) + assert created["title"] == "Favourites" + + page = await result(client, in_thread, "gift.collection.list") + assert [row["title"] for row in page["items"]] == ["Favourites"] + + edited = await result( + client, + in_thread, + "gift.collection.edit", + {"chat": "me", "id": created["id"], "title": "Best"}, + ) + assert edited["title"] == "Best" + + await result( + client, in_thread, "gift.collection.delete", {"chat": "me", "id": created["id"]} + ) + assert (await result(client, in_thread, "gift.collection.list"))["items"] == [] + + async def test_editing_an_unknown_collection_is_not_found( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, in_thread, "gift.collection.edit", {"chat": "me", "id": 99, "title": "x"} + ) + assert error.exit_code in (EXIT_NOT_FOUND, EXIT_USAGE) + + async def test_reordering_needs_the_collection_ids( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails( + client, + in_thread, + "gift.collection.edit", + {"chat": "me", "order_collections": "nope"}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_editing_without_an_id_is_a_usage_error( + self, live_daemon, client, in_thread, settings_world + ): + error = await fails(client, in_thread, "gift.collection.edit", {"chat": "me"}) + assert error.exit_code == EXIT_USAGE + + +class TestGiftAuctions: + @pytest.fixture + def auction_world(self, settings_world): + from telethon.tl import types + + gift = settings_world.unique_gifts["PlushPepe-42"] + state = types.StarGiftAuctionState( + version=3, + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc) + timedelta(hours=1), + min_bid_amount=5500, + bid_levels=[ + types.AuctionBidLevel(pos=1, amount=9000, date=None), + types.AuctionBidLevel(pos=2, amount=3000, date=None), + ], + top_bidders=[], + next_round_at=0, + last_gift_num=1, + gifts_left=1, + current_round=1, + total_rounds=1, + rounds=[], + ) + user = types.StarGiftAuctionUserState(acquired_count=0, bid_amount=5000) + settings_world.auctions = [ + types.StarGiftActiveAuctionState(gift=gift, state=state, user_state=user) + ] + settings_world.auction_states = [ + types.payments.StarGiftAuctionState( + gift=gift, state=state, user_state=user, timeout=30, users=[], chats=[] + ) + ] + return settings_world + + async def test_the_active_list_flattens_the_three_nested_structures( + self, live_daemon, client, in_thread, auction_world + ): + page = await result(client, in_thread, "gift.auction.list") + row = page["items"][0] + assert row["slug"] == "PlushPepe-42" + assert row["my_bid"] == 5000 + assert row["min_bid"] == 5500 + assert row["state"] == "active" + + async def test_the_won_listing_needs_a_gift_id( + self, live_daemon, client, in_thread, auction_world + ): + error = await fails(client, in_thread, "gift.auction.list", {"won": True}) + assert error.exit_code == EXIT_USAGE + + async def test_the_state_stream_estimates_my_position( + self, live_daemon, client, in_thread, auction_world + ): + frames = await in_thread( + lambda: list( + client.op_stream( + "gift.auction.get", + {"auction": "PlushPepe-42", "with_position": True}, + account="work", + ) + ) + ) + rows = [f["data"] for f in frames if f.get("type") == "item"] + assert rows + state = rows[0] + assert state["version"] == 3 + # One bid above mine, so I am second. + assert state["position"] == 2 + + +# --------------------------------------------------------------------------- +# Cross-cutting +# --------------------------------------------------------------------------- + + +GROUPS = ( + "profile", + "privacy", + "notify", + "settings", + "business", + "premium", + "stars", + "gift", + "giveaway", +) + + +def _specs(): + import tlgr.ops # noqa: F401 + from tlgr.registry import REGISTRY + + return [spec for op_id, spec in REGISTRY.items() if op_id.split(".")[0] in GROUPS] + + +class TestTheSurface: + def test_the_group_registered_every_operation_the_work_list_names(self): + assert len(_specs()) == 90 + + def test_no_operation_in_this_group_signs_a_payment_form(self): + """Written against the source rather than against a list of commands, + so a future addition cannot quietly re-open the door PR-10 closed.""" + import inspect + + forbidden = ( + "SendStarsFormRequest", + "SendPaymentFormRequest", + "ValidateRequestedInfoRequest", + "FulfillStarsSubscriptionRequest", + ) + for spec in _specs(): + try: + source = inspect.getsource(spec.impl) + except (OSError, TypeError): # pragma: no cover - every impl has source + continue + for name in forbidden: + assert name not in source, f"{spec.id} calls {name}" + + def test_every_destructive_operation_is_also_mutating(self): + for spec in _specs(): + if spec.destructive: + assert spec.mutating, spec.id + + def test_the_operations_that_change_what_others_see_say_so(self): + """`visible-to-others` is what an agent filters on before acting.""" + wanted = { + "profile.update", + "profile.photo.set", + "profile.presence.set", + "profile.status.set", + "business.bot.set", + "gift.transfer", + } + tagged = {spec.id for spec in _specs() if "visible-to-others" in spec.tags} + assert wanted <= tagged + + @pytest.mark.parametrize( + "op_id", + sorted(spec.id for spec in _specs() if spec.mutating), + ) + def test_a_dry_run_never_reaches_a_mutating_implementation(self, op_id, tlgr_home): + """The short-circuit lives above every implementation (COR-17).""" + import shlex + + from click.testing import CliRunner + + from tlgr.cli import cli + from tlgr.registry import get + + spec = get(op_id) + outcome = CliRunner().invoke( + cli, [*shlex.split(spec.example_args), "--dry-run", "--yes", "--json"] + ) + assert outcome.exit_code == 0, outcome.output + assert '"dry_run": true' in outcome.output + + +class TestPagination: + async def test_the_gift_catalogue_pages_with_a_signed_cursor( + self, live_daemon, client, in_thread, settings_world + ): + first = await result(client, in_thread, "gift.catalog", limit=1) + assert first["has_more"] is True + assert first["next_cursor"] + second = await result( + client, in_thread, "gift.catalog", limit=1, cursor=first["next_cursor"] + ) + assert [row["gift_id"] for row in second["items"]] == [5200] + + async def test_a_cursor_from_another_operation_is_refused( + self, live_daemon, client, in_thread, settings_world + ): + first = await result(client, in_thread, "gift.catalog", limit=1) + error = await fails( + client, + in_thread, + "gift.variant.list", + {"gift_id": GIFT_ID}, + cursor=first["next_cursor"], + ) + assert error.exit_code == EXIT_USAGE + + async def test_the_variant_listing_pages_too( + self, live_daemon, client, in_thread, settings_world + ): + page = await result(client, in_thread, "gift.variant.list", {"gift_id": GIFT_ID}, limit=1) + assert len(page["items"]) == 1 + assert page["has_more"] is False diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 1200eec..9ec3d5f 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -14,12 +14,12 @@ def runner(): class TestEnableCommands: - """Two exit codes on purpose, until every group is generated. + """One exit code, now that every group is generated. - A registry-generated command enforces the allowlist by canonical op id and - answers PERMISSION_DENIED (exit 6, §7.2). The v1 path matching in - `TlgrGroup.resolve_command` still answers exit 2, and goes when the last - hand-written group does. + Every command is registry-generated now, so every block is + PERMISSION_DENIED (exit 6, §7.2). The v1 path matching in + `TlgrGroup.resolve_command` that answered exit 2 went with the last + hand-written group in PR-12. """ def test_top_level_block(self, runner): @@ -27,10 +27,14 @@ def test_top_level_block(self, runner): assert result.exit_code == 6 assert "not enabled" in result.output - def test_legacy_top_level_block_still_exits_2(self, runner): - """`profile` is the last hand-written group; `contact` is generated in PR-5.""" + def test_the_last_hand_written_group_now_blocks_the_same_way(self, runner): + """`profile` was the last hand-written group and answered exit 2. + + PR-12 generated it, so it answers PERMISSION_DENIED like everything + else — which is the point of the migration: one refusal, one code. + """ result = runner.invoke(cli, ["--enable-commands", "message", "profile", "get"]) - assert result.exit_code == 2 + assert result.exit_code == 6 assert "not enabled" in result.output def test_a_generated_group_blocks_with_permission_denied(self, runner): diff --git a/tlgr/cli/__init__.py b/tlgr/cli/__init__.py index 2f07d58..6969754 100644 --- a/tlgr/cli/__init__.py +++ b/tlgr/cli/__init__.py @@ -231,7 +231,6 @@ def cli( from tlgr.cli.gen import build_click_tree # noqa: E402 - # --------------------------------------------------------------------------- # The generated tree # --------------------------------------------------------------------------- diff --git a/tlgr/core/errors.py b/tlgr/core/errors.py index 121b9c9..76f43aa 100644 --- a/tlgr/core/errors.py +++ b/tlgr/core/errors.py @@ -471,6 +471,30 @@ class ErrorRule: (re.compile(r"Cannot send requests while disconnected"), _RETRY), (re.compile(r"Could not find the input entity"), _NOT_FOUND), (re.compile(r"^AUTH_KEY_(UNREGISTERED|INVALID|DUPLICATED|PERM_EMPTY)$"), _SESSION), + # PR-12. The settings surface names things by slug, id and code, and + # Telethon has no generated class for any of these — without a rule they + # all arrive as exit 1, which tells a caller nothing about whether to + # retry, to fix the name, or to give up. + ( + re.compile( + r"\b(GIFT_SLUG_INVALID|SLUG_INVALID|THEME_INVALID|LANG_PACK_INVALID" + r"|COLLECTION_ID_INVALID|AUCTION_INVALID|BUSINESS_LINK_INVALID" + r"|STARGIFT_INVALID|SHORTCUT_INVALID|RINGTONE_INVALID)\b" + ), + _NOT_FOUND, + ), + # Limits the server enforces and names. They are usage errors, not + # failures: the fix is to pass fewer of something. + ( + re.compile( + r"\b(CHATLINKS_TOO_MUCH|QUICK_REPLIES_TOO_MUCH|REPLY_MESSAGES_TOO_MUCH" + r"|USERNAMES_ACTIVE_TOO_MUCH|BIRTHDAY_INVALID|TTL_DAYS_INVALID)\b" + ), + _USAGE, + ), + # `USERNAME_PURCHASE_AVAILABLE` means the name is free *on Fragment*, + # which is neither "taken" nor an error tlgr can retry past. + (re.compile(r"\bUSERNAME_PURCHASE_AVAILABLE\b"), _USAGE), ) _GENERIC = ErrorRule("GENERIC", EXIT_GENERIC, 500) diff --git a/tlgr/models/business.py b/tlgr/models/business.py index 2c86905..040978f 100644 --- a/tlgr/models/business.py +++ b/tlgr/models/business.py @@ -120,7 +120,8 @@ class BusinessMessage(Model): """ #: greeting | away - kind: str = "greeting" + kind: str + enabled: bool shortcut_id: int = 0 shortcut: str | None = None #: away only: always | outside-hours | custom @@ -131,7 +132,6 @@ class BusinessMessage(Model): #: greeting only. no_activity_days: int | None = None recipients: BusinessRecipients | None = None - enabled: bool = True class BotRights(Model): @@ -258,8 +258,8 @@ class StarsTransferQuote(Model): """ bot_id: int + ok: bool stars: int = 0 currency: str = "XTR" - ok: bool = False reason: str = "" form_id: int | None = None diff --git a/tlgr/models/gift.py b/tlgr/models/gift.py index 701f247..f797b0e 100644 --- a/tlgr/models/gift.py +++ b/tlgr/models/gift.py @@ -180,24 +180,24 @@ class GiftDisplay(Model): class GiftConverted(Model): ref: str - stars_received: int = 0 + stars_received: int balance_after: int | None = None class GiftUpgraded(Model): ref: str + upgraded: bool slug: str | None = None num: int | None = None attributes: list[GiftAttribute] = [] - upgraded: bool = False price_stars: int | None = None refused_reason: str | None = None class GiftTransferred(Model): ref: str + transferred: bool to: int | None = None - transferred: bool = False price_stars: int | None = None can_transfer_at: str | None = None refused_reason: str | None = None @@ -207,7 +207,7 @@ class GiftListing(Model): """A collectible put on (or taken off) the resale market.""" ref: str - listed: bool = False + listed: bool price_stars: int | None = None price_ton: int | None = None can_resell_at: str | None = None @@ -236,17 +236,17 @@ class GiftVariant(Model): class GiftCrafted(Model): + crafted: bool ref: str | None = None slug: str | None = None burned: list[str] = [] - crafted: bool = False candidates: list[OwnedGift] = [] class GiftOfferResolved(Model): msg_id: int #: accepted | declined | refused - state: str = "declined" + state: str price_stars: int | None = None buyer: int | None = None reason: str | None = None @@ -263,9 +263,9 @@ class GiftAuction(Model): class GiftAuctionState(Model): + state: str + version: int auction: str = "" - state: str = "" - version: int = 0 min_bid_amount: int | None = None my_bid: int | None = None position: int | None = None diff --git a/tlgr/models/notify.py b/tlgr/models/notify.py index 20a7452..932f294 100644 --- a/tlgr/models/notify.py +++ b/tlgr/models/notify.py @@ -83,7 +83,7 @@ class ExceptionsCleared(Model): class NotifyReset(Model): - ok: bool = True + ok: bool class Ringtone(Model): diff --git a/tlgr/models/premium.py b/tlgr/models/premium.py index 6c88a80..ddd7093 100644 --- a/tlgr/models/premium.py +++ b/tlgr/models/premium.py @@ -62,8 +62,8 @@ class PremiumFeatures(Model): class PremiumGiftOption(Model): + users: int months: int = 0 - users: int = 1 currency: str = "" amount: int = 0 store_product: str | None = None @@ -73,10 +73,12 @@ class PremiumGiftQuote(Model): """The price of gifting Premium, and the refusal to pay it.""" user_id: int + #: No default: a refusal that can be mistaken for an absent field is a + #: refusal a caller may act on as a success. + ok: bool months: int = 0 stars: int = 0 currency: str = "XTR" - ok: bool = False reason: str = "" form_id: int | None = None @@ -100,7 +102,7 @@ class GiftCode(Model): class GiftCodeApplied(Model): slug: str - applied: bool = False + applied: bool months: int | None = None until_date: str | None = None already: bool = False @@ -115,10 +117,10 @@ class GiveawayWinner(Model): class GiveawayInfo(Model): """`payments.getGiveawayInfo` — the personal "did I win?" answer.""" + #: ongoing | finished + state: str chat_id: int = 0 msg_id: int = 0 - #: ongoing | finished - state: str = "ongoing" start_date: str | None = None until_date: str | None = None winners_count: int | None = None diff --git a/tlgr/models/privacy.py b/tlgr/models/privacy.py index 5bd839f..6f6fb5f 100644 --- a/tlgr/models/privacy.py +++ b/tlgr/models/privacy.py @@ -54,8 +54,10 @@ class PrivacySettings(Model): """ key: str - #: everybody | contacts | close-friends | premium | bots | nobody - base: str = "nobody" + #: everybody | contacts | close-friends | premium | bots | nobody. No + #: default: it is the headline, and `omit_defaults` would hide the most + #: restrictive answer exactly when a caller most wants to see it. + base: str allow_users: list[int] = [] deny_users: list[int] = [] allow_chats: list[int] = [] diff --git a/tlgr/models/profile.py b/tlgr/models/profile.py index e7499d3..8285ebd 100644 --- a/tlgr/models/profile.py +++ b/tlgr/models/profile.py @@ -194,6 +194,9 @@ class PhotosDeleted(Model): class ProfileLink(Model): """`profile link`: the public handle, and what Fragment knows about it.""" + #: No default: `omit_defaults` would drop the reassuring answer and + #: leave a caller unable to tell "public" from "not reported". + resolvable_by_strangers: bool link: str = "" username: str | None = None user_id: int | None = None @@ -202,7 +205,6 @@ class ProfileLink(Model): qr_path: str | None = None #: `fragment.getCollectibleInfo`, when `--collectible` was given. collectible: dict[str, Any] | None = None - resolvable_by_strangers: bool = True class AdminedChannel(Model): diff --git a/tlgr/models/settings.py b/tlgr/models/settings.py index d094eb1..88be96e 100644 --- a/tlgr/models/settings.py +++ b/tlgr/models/settings.py @@ -55,7 +55,7 @@ class SettingChange(Model): class SettingUnset(Model): key: str - removed: int = 0 + removed: int values: list[str] = [] already: bool = False diff --git a/tlgr/models/stars.py b/tlgr/models/stars.py index 6133b45..293d8bd 100644 --- a/tlgr/models/stars.py +++ b/tlgr/models/stars.py @@ -83,9 +83,9 @@ class StarsRevenue(Model): class StarsUrl(Model): """A Fragment URL. Opening it — and the transfer — is the human's job.""" - url: str = "" #: withdrawal | ads - kind: str = "withdrawal" + kind: str + url: str = "" chat_id: int = 0 amount: int | None = None ton: bool = False @@ -95,7 +95,7 @@ class StarsRefulfill(Model): """Re-joining a lapsed Star subscription, reported and not performed.""" id: str - ok: bool = False + ok: bool can_refulfill: bool | None = None stars: int | None = None reason: str = "" diff --git a/tlgr/ops/business.py b/tlgr/ops/business.py index 1fd76ec..1b9c1b4 100644 --- a/tlgr/ops/business.py +++ b/tlgr/ops/business.py @@ -618,6 +618,7 @@ async def message_set(ctx: OpContext, req: MessageSetReq) -> BusinessMessage: ctx.emit("business_message", {"kind": kind, "shortcut_id": shortcut_id}) return BusinessMessage( kind=kind, + enabled=True, shortcut_id=shortcut_id, shortcut=req.shortcut, no_activity_days=req.no_activity_days, @@ -648,6 +649,7 @@ async def message_set(ctx: OpContext, req: MessageSetReq) -> BusinessMessage: ctx.emit("business_message", {"kind": kind, "shortcut_id": shortcut_id}) return BusinessMessage( kind=kind, + enabled=True, shortcut_id=shortcut_id, shortcut=req.shortcut, schedule=word, diff --git a/tlgr/ops/gift.py b/tlgr/ops/gift.py index 228f39e..8c36124 100644 --- a/tlgr/ops/gift.py +++ b/tlgr/ops/gift.py @@ -1382,15 +1382,15 @@ async def auction_list(ctx: OpContext, req: AuctionListReq) -> Page[GiftAuction] if req.gift_id is None: raise UsageError("--won needs --gift-id <id>", field="gift_id") result = await handle(fn.GetStarGiftAuctionAcquiredGiftsRequest(gift_id=int(req.gift_id))) - known = _settings.entity_map(result) rows = [ GiftAuction( - auction=str(getattr(gift, "slug", "") or ""), + auction=str(req.gift_id), gift_id=int(req.gift_id), - slug=_unique(gift, known).slug, + my_bid=int(getattr(row, "bid_amount", 0) or 0) or None, + ends_at=fmt_dt(getattr(row, "date", None)), state="won", ) - for gift in getattr(result, "gifts", None) or [] + for row in getattr(result, "gifts", None) or [] ] return Page(items=rows, has_more=False, total=len(rows)) @@ -1398,22 +1398,34 @@ async def auction_list(ctx: OpContext, req: AuctionListReq) -> Page[GiftAuction] rows = [] for raw in getattr(result, "auctions", None) or []: gift = getattr(raw, "gift", None) - my_bid, _ = _settings.stars_of(getattr(raw, "my_bid", None)) - min_bid, _ = _settings.stars_of(getattr(raw, "min_bid_amount", None)) + state = getattr(raw, "state", None) + user = getattr(raw, "user_state", None) + gift_id = int(getattr(gift, "gift_id", 0) or getattr(gift, "id", 0) or 0) + slug = getattr(gift, "slug", None) rows.append( GiftAuction( - auction=str(getattr(gift, "slug", "") or getattr(raw, "gift_id", "") or ""), - gift_id=getattr(raw, "gift_id", None) or getattr(gift, "gift_id", None), - slug=getattr(gift, "slug", None), - my_bid=my_bid or None, - min_bid=min_bid or None, - ends_at=fmt_dt(getattr(raw, "end_date", None)), - state=type(raw).__name__.removeprefix("StarGiftAuction").lower() or "active", + auction=slug or str(gift_id), + gift_id=gift_id or None, + slug=slug, + my_bid=getattr(user, "bid_amount", None), + min_bid=getattr(state, "min_bid_amount", None), + ends_at=fmt_dt(getattr(state, "end_date", None)), + state=_state_word(state), ) ) return Page(items=rows, has_more=False, total=len(rows)) +def _state_word(state: Any) -> str: + """`starGiftAuctionState*` as one word. `finished` always wins a race.""" + name = type(state).__name__ + if name == "StarGiftAuctionStateFinished": + return "finished" + if name == "StarGiftAuctionStateNotModified": + return "not-modified" + return "active" + + SPEC_AUCTION_LIST = OperationSpec( id="gift.auction.list", request=AuctionListReq, @@ -1465,44 +1477,67 @@ async def auction_get(ctx: OpContext, req: AuctionGetReq) -> Any: else types.InputStarGiftAuctionSlug(slug=_settings.slug_of(text)) ) version = int(req.version) + seen = -1 while True: raw = await handle(fn.GetStarGiftAuctionStateRequest(auction=auction, version=version)) - state = _auction_state(text, raw) - if state.version >= version: + state = _auction_state(text, raw, with_position=req.with_position) + # A state only counts when its version *increased*; a finished state + # always wins, because a slow reply must not overwrite the end. + last = not req.watch or state.finished + if state.finished or state.version > seen: + seen = state.version version = state.version - yield state - if not req.watch or state.finished: + yield Page(items=[state], has_more=not last) + if last: return -def _auction_state(name: str, raw: Any) -> GiftAuctionState: - my_bid, _ = _settings.stars_of(getattr(raw, "my_bid", None)) - min_bid, _ = _settings.stars_of(getattr(raw, "min_bid_amount", None)) - kind = type(raw).__name__.removeprefix("StarGiftAuctionState").lower() +def _auction_state(name: str, raw: Any, *, with_position: bool = False) -> GiftAuctionState: + """`payments.starGiftAuctionState` flattened. + + The server nests three things — the gift, the auction and *my* side of + it — and the numbers a bidder wants are spread across all three. + """ + inner = getattr(raw, "state", None) + user = getattr(raw, "user_state", None) + word = _state_word(inner) + position = None + if with_position: + bid = getattr(user, "bid_amount", None) + if bid is not None: + levels = getattr(inner, "bid_levels", None) or [] + position = 1 + sum( + 1 for level in levels if int(getattr(level, "amount", 0) or 0) > int(bid) + ) return GiftAuctionState( auction=name, - state=kind or "active", - version=int(getattr(raw, "version", 0) or 0), - min_bid_amount=min_bid or None, - my_bid=my_bid or None, - position=getattr(raw, "position", None), - ends_at=fmt_dt(getattr(raw, "end_date", None)), + state=word, + version=int(getattr(inner, "version", 0) or 0), + min_bid_amount=getattr(inner, "min_bid_amount", None), + my_bid=getattr(user, "bid_amount", None), + position=position, + ends_at=fmt_dt(getattr(inner, "end_date", None)), timeout=getattr(raw, "timeout", None), - finished=kind == "finished", + finished=word == "finished", ) SPEC_AUCTION_GET = OperationSpec( id="gift.auction.get", request=AuctionGetReq, - response=GiftAuctionState, + response=Page[GiftAuctionState], impl=auction_get, summary="Auction state, bid ladder and my position", stream=True, idempotent=True, columns=("auction", "state", "version", "min_bid_amount", "my_bid", "position", "ends_at"), headers=("Auction", "State", "Version", "Min bid", "My bid", "Place", "Ends"), - example={"auction": "PlushPepe-42", "state": "active", "version": 3, "min_bid_amount": 5500}, + example={ + "items": [ + {"auction": "PlushPepe-42", "state": "active", "version": 3, "min_bid_amount": 5500} + ], + "has_more": False, + }, example_args="gift auction get PlushPepe-42", covers=("auction.position-estimate", "auction.state"), tags=frozenset({"agent-safe"}), diff --git a/tlgr/ops/giveaway.py b/tlgr/ops/giveaway.py index a7a05e3..51e8c16 100644 --- a/tlgr/ops/giveaway.py +++ b/tlgr/ops/giveaway.py @@ -46,14 +46,6 @@ __all__ = [name for name in dir() if name.startswith("SPEC_")] -#: `giveawayInfo.disallowed_reason` → the word tlgr reports. -DISALLOWED = { - "GiveawayInfoDisallowedCountry": "disallowed-country", - "GiveawayInfoDisallowedAdminRequired": "admin", - "GiveawayInfoDisallowedJoinedTooEarly": "joined-too-early", -} - - # --------------------------------------------------------------------------- # giveaway get # --------------------------------------------------------------------------- @@ -81,7 +73,7 @@ async def get(ctx: OpContext, req: GetReq) -> GiveawayInfo: peer = await _settings.resolve(ctx, req.chat) chat_id = _settings.peer_of(peer) raw = await handle(fn.GetGiveawayInfoRequest(peer=peer, msg_id=int(req.msg_id))) - finished = type(raw).__name__ == "PaymentsGiveawayInfoResults" + finished = type(raw).__name__.endswith("GiveawayInfoResults") info = GiveawayInfo( chat_id=chat_id, msg_id=int(req.msg_id), @@ -95,12 +87,15 @@ async def get(ctx: OpContext, req: GetReq) -> GiveawayInfo: activated_count=getattr(raw, "activated_count", None), until_date=fmt_dt(getattr(raw, "finish_date", None)), stars=getattr(raw, "stars_prize", None), + winners_count=getattr(raw, "winners_count", None), ) media = await _giveaway_media(ctx, peer, int(req.msg_id)) if media is not None: - info.winners_count = getattr(media, "quantity", None) or getattr( - media, "winners_count", None + info.winners_count = ( + info.winners_count + or getattr(media, "quantity", None) + or getattr(media, "winners_count", None) ) info.months = getattr(media, "months", None) info.only_new_subscribers = bool(getattr(media, "only_new_subscribers", False)) @@ -121,17 +116,18 @@ async def get(ctx: OpContext, req: GetReq) -> GiveawayInfo: def _disallowed_word(raw: Any) -> str | None: - """The `giveawayInfo` reason, whichever of the three flavours it is.""" - for reason in getattr(raw, "disallowed_reason", None) or []: - word = DISALLOWED.get(type(reason).__name__) - if word: - return word - if getattr(raw, "admin_disallowed", False): + """Why this account cannot take part, as one word. + + `giveawayInfo` spells the three refusals as three unrelated optional + fields — a country code, a chat id and a date — so the mapping happens + once, here, rather than in whatever reads the answer. + """ + if getattr(raw, "disallowed_country", None): + return "disallowed-country" + if getattr(raw, "admin_disallowed_chat_id", None): return "admin" if getattr(raw, "joined_too_early_date", None): return "joined-too-early" - if getattr(raw, "disallowed_country", None): - return "disallowed-country" return None @@ -303,11 +299,12 @@ async def _received_codes(ctx: OpContext) -> Page[PrepaidGiveaway]: except Exception as exc: ctx.warn(f"could not check the code {slug}: {exc}") continue + days = getattr(checked, "days", None) rows.append( PrepaidGiveaway( id=0, quantity=1, - months=getattr(checked, "months", None), + months=int(days) // 30 if days else None, slug=slug, used=getattr(checked, "used_date", None) is not None, date=fmt_dt(getattr(checked, "date", None)), diff --git a/tlgr/ops/premium.py b/tlgr/ops/premium.py index a6383d2..83a206d 100644 --- a/tlgr/ops/premium.py +++ b/tlgr/ops/premium.py @@ -405,7 +405,14 @@ async def gift_send(ctx: OpContext, req: GiftSendReq) -> PremiumGiftQuote: def _code_model(slug: str, raw: Any) -> GiftCode: + """`payments.checkedGiftCode` as a model. + + The wire says `days`; every client and every price option says *months*, + so both are reported and neither is invented — `months` is the whole + months the day count buys. + """ date = getattr(raw, "date", None) + days = getattr(raw, "days", None) return GiftCode( slug=slug, link=f"https://t.me/giftcode/{slug}", @@ -413,8 +420,8 @@ def _code_model(slug: str, raw: Any) -> GiftCode: to_id=getattr(raw, "to_id", None), date=fmt_dt(date), date_unix=to_unix(date), - months=getattr(raw, "months", None), - days=getattr(raw, "months", None) and int(getattr(raw, "months", 0)) * 30, + months=int(days) // 30 if days else None, + days=int(days) if days else None, used_date=fmt_dt(getattr(raw, "used_date", None)), via_giveaway=bool(getattr(raw, "via_giveaway", False)), giveaway_msg_id=getattr(raw, "giveaway_msg_id", None), diff --git a/tlgr/ops/privacy.py b/tlgr/ops/privacy.py index c08d327..317300d 100644 --- a/tlgr/ops/privacy.py +++ b/tlgr/ops/privacy.py @@ -98,7 +98,7 @@ def _rules_model(key: str, rules: Any) -> PrivacySettings: lists are the user/chat rules beside it. `raw_rules` keeps the server's own ordering so a later write can reproduce it exactly. """ - model = PrivacySettings(key=key) + model = PrivacySettings(key=key, base="nobody") for rule in rules or []: name = type(rule).__name__.removeprefix("PrivacyValue") action = "allow" if name.startswith("Allow") else "disallow" diff --git a/tlgr/ops/profile.py b/tlgr/ops/profile.py index dc00604..ab1b48e 100644 --- a/tlgr/ops/profile.py +++ b/tlgr/ops/profile.py @@ -1400,7 +1400,7 @@ async def link(ctx: OpContext, req: LinkReq) -> ProfileLink: handle = client(ctx) target = (req.target or "").strip() - result = ProfileLink() + result = ProfileLink(resolvable_by_strangers=True) if not target or target.lower() in ("me", "self"): me = await handle.get_me() @@ -1468,7 +1468,11 @@ def _qr(ctx: OpContext, text: str, out: str | None) -> tuple[str | None, str | N idempotent=True, columns=("link", "username", "resolvable_by_strangers"), headers=("Link", "Username", "Public"), - example={"link": "https://t.me/ada", "username": "ada"}, + example={ + "link": "https://t.me/ada", + "username": "ada", + "resolvable_by_strangers": True, + }, example_args="profile link --qr", covers=("profile.collectible-info", "profile.qr-code"), tags=frozenset({"agent-safe"}), diff --git a/tlgr/ops/settings.py b/tlgr/ops/settings.py index 7dbc59b..fae16ee 100644 --- a/tlgr/ops/settings.py +++ b/tlgr/ops/settings.py @@ -162,14 +162,20 @@ async def _read(ctx: OpContext, key: str, tail: str, peer: str | None) -> Settin elif key == "browser-close-button": value = "on" if getattr(settings, "display_close_button", False) else "off" else: + # The server keeps two vectors, not one list with a flag on each + # row, so the mode is the vector a domain is *in*. value = [ { + "domain": getattr(entry, "domain", ""), "url": getattr(entry, "url", ""), - "mode": ( - "external" if getattr(entry, "open_external_browser", False) else "in-app" - ), + "title": getattr(entry, "title", ""), + "mode": mode, } - for entry in getattr(settings, "exceptions", None) or [] + for mode, vector in ( + ("external", getattr(settings, "external_exceptions", None) or []), + ("in-app", getattr(settings, "inapp_exceptions", None) or []), + ) + for entry in vector ] elif key == "no-forwards": answer = await handle(ufn.GetFullUserRequest(id=types.InputUserSelf())) From 878d02e710984ebefdde3c271c10d65581be3b03 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 08:01:16 +0330 Subject: [PATCH 09/15] parity: the gate flips from "waived until PR-N" to "cannot be done, and here is why" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The waiver file was a backlog. Until this PR a waiver was a promise with a PR number on it — "waived until PR-9" — which is exactly right while there are still PRs to come, and meaningless once there are not. Every one of those 177 promises has been kept, so the file now holds nine ids and each one names a permanent reason: seven layer-229 methods Telethon 1.44 has no constructors for, and two methods the layer has that the pinned library does not ship a request class for. `kind` is the new required field — `layer-gap`, `absent-method`, `prohibited` or `not-applicable` — and the gate enforces the whole shape rather than trusting the file: no domain may be waived, no unknown `kind` may appear, a layer or method waiver must name its method, and an id that is *covered* may not also be waived, which is the one way a number could lie about itself. P0 is 178 of 178. That is ARCHITECTURE §1.3's condition for 2.0.0 final, and it now has a test of its own rather than being a floor that could be raised one id at a time forever. Total coverage is 1788 of 1797. --- tests/test_parity.py | 94 +++- tlgr/data/parity_waivers.toml | 984 ++-------------------------------- tlgr/parity.py | 49 +- 3 files changed, 185 insertions(+), 942 deletions(-) diff --git a/tests/test_parity.py b/tests/test_parity.py index a4f2d05..bb33990 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 = 172 +P0_FLOOR = 178 #: The floor for total covered ids. Same rule, weaker guarantee. -COVERED_FLOOR = 1541 +COVERED_FLOOR = 1788 #: 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 @@ -257,6 +257,23 @@ } ) +#: Every P0 catalog id PR-12's operations cover. With these six the P0 set +#: is complete: 178 of 178, which is what ARCHITECTURE §1.3 required before +#: 2.0.0 final. Four of them are the profile screen v1 answered wrongly or not +#: at all; `notify.peer` is the per-chat notification exception; and +#: `calls.privacy-who-can-call` is a privacy key, which is why it lands here +#: rather than with the call group. +PR12_P0_IDS = frozenset( + { + "calls.privacy-who-can-call", + "notify.peer", + "profile.photo-set", + "profile.set-bio", + "profile.set-name", + "profile.view-own", + } +) + #: `(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( @@ -352,6 +369,21 @@ def _admin_group(op_id, spec) -> bool: ("pr8", _by_prefix("story."), PR8_P0_IDS), ("pr11", _by_prefix("call.", "vc.", "conference."), PR11_P0_IDS), ("pr10", _by_prefix("bot.", "inline.", "webapp.", "payment."), PR10_P0_IDS), + ( + "pr12", + _by_prefix( + "profile.", + "privacy.", + "notify.", + "settings.", + "business.", + "premium.", + "stars.", + "gift.", + "giveaway.", + ), + PR12_P0_IDS, + ), ) @@ -460,11 +492,60 @@ def test_the_floor_is_the_sum_of_what_the_landed_prs_own(self): named |= set(expected) assert len(named) == P0_FLOOR - def test_every_uncovered_id_is_waived_with_a_pr_number(self, report): + def test_every_uncovered_id_is_waived_with_a_reason(self, report): """No silent gaps: the gate is meaningful from day one, not at the end.""" unwaived = [u for u in report.uncovered if not u["reason"].startswith("waived")] assert unwaived == [], f"{len(unwaived)} ids are uncovered and unwaived: {unwaived[:5]}" + def test_p0_coverage_is_complete(self, report): + """§1.3: P0 must reach 100 % before 2.0.0 final. This is that check.""" + stats = report.by_priority["P0"] + assert stats["covered"] == stats["required"] == 178 + assert stats["waived"] == 0 + + def test_no_domain_is_waived_wholesale(self): + """The blanket waiver was the backlog; the backlog is empty. + + A domain waiver would let a whole group read as done without covering + anything in it, which is the one failure mode this file exists to + prevent. + """ + assert waivers().domains == {} + + def test_every_waiver_names_one_of_the_four_permanent_reasons(self): + """A waiver is no longer a promise: "owned by PR-N" has no meaning now. + + Each remaining entry must say *why it cannot be done in this build* — + a layer the pinned Telethon does not speak, a request class it does + not ship, a thing tlgr refuses on purpose, or a thing a CLI has no + use for. + """ + from tlgr.parity import KINDS + + bad = { + entry_id: waiver.kind + for entry_id, waiver in waivers().ids.items() + if waiver.kind not in KINDS + } + assert bad == {}, f"waivers with no permanent reason: {bad}" + + def test_every_layer_or_method_waiver_names_the_method(self): + """ "Not supported" without the method name is not a reason.""" + for entry_id, waiver in waivers().ids.items(): + if waiver.kind in ("layer-gap", "absent-method"): + assert "." in waiver.reason, f"{entry_id} does not name a method" + + def test_no_waiver_survives_for_an_id_that_is_covered(self, report): + """A waiver on covered work is a lie the numbers would repeat.""" + covered = _covered_ids() + stale = sorted(entry_id for entry_id in waivers().ids if entry_id in covered) + assert stale == [], f"these ids are covered and still waived: {stale}" + + def test_the_remaining_waivers_are_the_nine_this_build_cannot_do(self, report): + """Named, not counted: a swap must not pass a count check.""" + assert {u["id"] for u in report.uncovered} == set(waivers().ids) + assert len(waivers().ids) == 9 + def test_the_auth_domain_is_fully_accounted_for(self, report): """PR-2's own domain: implemented, or waived to a named later PR. @@ -573,6 +654,13 @@ def test_groups_channels_admin_is_fully_accounted_for(self, report): def test_the_groups_channels_admin_domain_is_no_longer_waived_wholesale(self): assert "groups_channels_admin" not in waivers().domains + def test_profile_settings_privacy_is_completely_covered(self, report): + """PR-12's own domain, and the last one: 178 of 178, nothing waived.""" + stats = report.by_domain["profile_settings_privacy"] + assert stats["covered"] == stats["required"] + assert stats["percent"] == 100.0 + assert stats["waived"] == 0 + 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 7ede33a..66d5623 100644 --- a/tlgr/data/parity_waivers.toml +++ b/tlgr/data/parity_waivers.toml @@ -1,960 +1,90 @@ -# Catalog ids that are knowingly not covered yet, and by when. +# Catalog ids that are knowingly not covered, and why. # -# A waiver is a promise with a date on it, not an excuse: every entry names -# the PR that closes it, so `tlgr agent parity` can report "uncovered, waived -# until PR-9" instead of silently shrinking the denominator. The gate in -# tests/test_parity.py refuses to let a *covered* id become waived, which is -# what stops the file from being used to hide a regression. +# Until PR-12 this file was a backlog: a waiver was a promise with a PR number +# on it, so `tlgr agent parity` could report "uncovered, waived until PR-9" +# instead of silently shrinking the denominator. Every one of those promises +# has now been kept, and what is left is a different thing entirely — nine ids +# that this build genuinely cannot cover, each naming the reason. +# +# **There are no blanket waivers.** No domain is waived, and no id is waived +# because "another PR owns it": that sentence has no meaning once every PR has +# landed. `kind` names which of the four permanent reasons applies: +# +# layer-gap the method arrived in an MTProto layer newer than the one +# Telethon 1.44 speaks; the command is registered and exits +# 13 (NOT_SUPPORTED) with the method named +# absent-method the method exists in the layer but Telethon 1.44 ships no +# request class for it; the flag that needs it exits 13 +# prohibited tlgr will not do it (spending money, deanonymising) +# not-applicable there is nothing for a CLI to do +# +# The gate in tests/test_parity.py enforces all of that: it refuses a domain +# waiver, refuses an unknown `kind`, refuses a waiver for an id that is in +# fact covered, and refuses a waiver whose reason does not name a method. # # Regenerate the catalog index with: python tools/prune_catalog.py [meta] catalog_version = "2026-09-02" -# The last PR removes this file entirely (ARCHITECTURE §12.5). +# The plan's last PR. Kept as a record of when the backlog emptied. final_pr = 12 # --------------------------------------------------------------------------- -# Whole domains that no PR has migrated yet. Each becomes its own group PR. -# --------------------------------------------------------------------------- - - -[[domain]] -name = "profile_settings_privacy" -pr = 12 -reason = "profile, privacy, notify, settings, business, premium, gift and stars land in PR-12." - -# --------------------------------------------------------------------------- -# Individual ids inside a domain that IS migrated. These are the ones that -# matter: each is a decision, not a backlog entry. -# --------------------------------------------------------------------------- - -# The groups_channels_admin ids PR-7 does not own. The domain-wide waiver is -# gone: every id left in it names the group that owns it, which is what makes -# "the groups-and-channels group is done" checkable rather than asserted. -[[id]] -id = "groups-channels-admin.channel-subscription-manage" -pr = 12 -reason = "My own paid subscriptions are `stars subscription *` (PR-12); the admin side is `chat invite list`." - -[[id]] -id = "groups-channels-admin.personal-channel" -pr = 12 -reason = "Showing a channel on my profile is `profile set --channel` (PR-12)." - -[[id]] -id = "groups-channels-admin.gift-code-redeem" -pr = 12 -reason = "Gift codes are the `gift` noun (PR-12); `boost list --gifts` finds the winners." - -[[id]] -id = "groups-channels-admin.giveaway-info" -pr = 12 -reason = "`giveaway *` lands with gifts and Stars (PR-12); `boost get` reports the prepaid ones." - -[[id]] -id = "groups-channels-admin.giveaway-prepaid-launch" -pr = 12 -reason = "Launching a giveaway is `giveaway launch` (PR-12); `boost get` reports the prepaid slots it spends." - -[[id]] -id = "groups-channels-admin.report-reaction" -pr = 9 -reason = "Reporting a reaction is `reaction report` (PR-9)." - - -# The dialogs_chats ids PR-3 does not own. Each names the group that does. -[[id]] -id = "dialogs.bot-stop-restart" -pr = 10 -reason = "Stopping and restarting a bot is the bot group (PR-10)." - -[[id]] -id = "dialogs.business-bot-bar" -pr = 12 -reason = "The connected-business-bot bar is a business setting (PR-12)." - -[[id]] -id = "dialogs.business-link-create" -pr = 12 -reason = "Business chat links are a business setting (PR-12)." - -[[id]] -id = "dialogs.business-link-delete" -pr = 12 -reason = "Business chat links are a business setting (PR-12)." - -[[id]] -id = "dialogs.business-link-edit" -pr = 12 -reason = "Business chat links are a business setting (PR-12)." - -[[id]] -id = "dialogs.business-link-list" -pr = 12 -reason = "Business chat links are a business setting (PR-12)." - -[[id]] -id = "dialogs.channel-autotranslation" -pr = 7 -reason = "Channel-wide auto-translation is a channel admin setting (PR-7)." - -[[id]] -id = "dialogs.community-collapse" -pr = 7 -reason = "Community grouping is a channel/community surface (PR-7)." - -[[id]] -id = "dialogs.community-join-requests" -pr = 7 -reason = "Community join requests are moderation (PR-7)." - -[[id]] -id = "dialogs.forum-tabs-mode" -pr = 7 -reason = "Forum tabs are a forum admin setting (PR-7)." - -[[id]] -id = "dialogs.frozen-account" -pr = 12 -reason = "The frozen-account state is reported by the account surface (PR-12)." - -[[id]] -id = "dialogs.new-chats-privacy" -pr = 12 -reason = "Who may start a chat with me is a privacy key (PR-12)." - -[[id]] -id = "dialogs.notify-community" -pr = 12 -reason = "Community notification settings are the notify surface (PR-12)." - -[[id]] -id = "dialogs.notify-exceptions" -pr = 12 -reason = "The exceptions *list* is `notify exceptions` (PR-12); one chat's exception is `chat notify`." - -[[id]] -id = "dialogs.notify-scope-defaults" -pr = 12 -reason = "Scope-wide defaults are `notify set` (PR-12)." - -[[id]] -id = "dialogs.reactions-notify" -pr = 12 -reason = "Reaction notification settings are the notify surface (PR-12)." - -[[id]] -id = "dialogs.recommended-channels" -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.typing-watch" -pr = 4 -reason = "Watching who is typing is an update stream (PR-4); sending one is `chat typing`." - -[[id]] -id = "dialogs.wallpaper-gallery" -pr = 6 -reason = "The global wallpaper gallery is the media group (PR-6); the per-chat one is `chat wallpaper`." - -[[id]] -id = "dialogs.watch-dialog-events" -pr = 4 -reason = "Live dialog events are the event bus (PR-4)." - -[[id]] -id = "messages-core.search-global" -pr = 3 -reason = "Global search spans the dialog list, so it is the `search` group's surface in PR-3; `message search` is scoped to one chat by design." - -[[id]] -id = "messages-core.search-global-media-tabs" -pr = 3 -reason = "The global media/links/files tabs are the global search surface (PR-3)." - -[[id]] -id = "messages-core.search-global-scope" -pr = 3 -reason = "Scoping global search to users/groups/channels belongs with global search (PR-3)." - -[[id]] -id = "messages-core.search-public-posts-fulltext" -pr = 3 -reason = "Full-text search over all public posts is a global surface with its own quota (PR-3)." - -[[id]] -id = "messages-core.search-hashtag-public-posts" -pr = 3 -reason = "Global hashtag search is global search (PR-3); the in-chat form is `message search --hashtag`." - -[[id]] -id = "messages-core.search-recent-hashtags" -pr = 3 -reason = "The recent-hashtag list is search state, not a message operation (PR-3)." - -[[id]] -id = "messages-core.search-sent-media" -pr = 3 -reason = "Recently-sent media is a global search index (PR-3)." - -[[id]] -id = "messages-core.delete-call-history" -pr = 3 -reason = "The call log is a chat-level history (PR-3)." - -[[id]] -id = "messages-core.chat-welcome-messages" -pr = 3 -reason = "Empty-chat welcome cards are a chat setting (PR-3)." - -[[id]] -id = "messages-core.ttl-default-new-chats" -pr = 3 -reason = "The default auto-delete timer is an account-wide chat setting (PR-3)." - -[[id]] -id = "messages-core.translate-channel-autotranslation" -pr = 3 -reason = "Channel auto-translation is a channel setting (PR-3)." - -# --------------------------------------------------------------------------- -# updates_sync_network (PR-4). The domain waiver is gone; these three ids are -# each owned by another group's command and are waived to that group's PR. -# --------------------------------------------------------------------------- - -[[id]] -id = "updates.config-terms-of-service" -pr = 2 -reason = "Accepting the Terms of Service is part of sign-up; `auth tos` owns it (PR-2)." - -[[id]] -id = "updates.invoke-business-connection" -pr = 12 -reason = "Acting on behalf of a connected business account is the business surface (PR-12)." - -[[id]] -id = "updates.presence-group-online-count" -pr = 7 -reason = "The live 'N online' counter is a group-membership read (PR-7)." - -[[id]] -id = "messages-core.message-watch-events" -pr = 4 -reason = "The live message stream is the event bus surface (PR-4)." - -[[id]] -id = "messages-core.message-statistics" -pr = 4 -reason = "Post statistics and public forwards are the `stats` surface (PR-4)." - -[[id]] -id = "messages-core.paid-messages-group-price" -pr = 7 -reason = "The per-group Star price is a supergroup setting (PR-7)." - -[[id]] -id = "messages-core.url-authorization" -pr = 10 -reason = "Seamless login-url authorisation is a bot surface (PR-10)." - -[[id]] -id = "messages-core.quick-reply-list" -pr = 12 -reason = "Business quick-reply shortcuts are a business setting (PR-12); `message send --quick-reply` uses one." - -[[id]] -id = "messages-core.quick-reply-manage" -pr = 12 -reason = "Business quick-reply shortcuts are a business setting (PR-12)." - -# --------------------------------------------------------------------------- -# `polls_reactions_content` after PR-9. PR-9 covered the poll, reaction, todo, -# location and search surface of this domain; the ids below share the domain -# but belong to other groups, so each names the PR that owns its noun rather -# than hiding behind a domain-wide promise PR-9 never made. -# --------------------------------------------------------------------------- - -[[id]] -id = "auction.acquired-gifts" -pr = 12 -reason = "collectible-gift auctions are the `gift` surface (PR-12)." - -[[id]] -id = "auction.active-list" -pr = 12 -reason = "collectible-gift auctions are the `gift` surface (PR-12)." - -[[id]] -id = "auction.position-estimate" -pr = 12 -reason = "collectible-gift auctions are the `gift` surface (PR-12)." - -[[id]] -id = "auction.state" -pr = 12 -reason = "collectible-gift auctions are the `gift` surface (PR-12)." - -[[id]] -id = "contact.birthday-accept" -pr = 5 -reason = "contact cards, notes and birthdays are the `contact` surface (PR-5)." - -[[id]] -id = "contact.birthdays" -pr = 5 -reason = "contact cards, notes and birthdays are the `contact` surface (PR-5)." - -[[id]] -id = "contact.note" -pr = 5 -reason = "contact cards, notes and birthdays are the `contact` surface (PR-5)." - -[[id]] -id = "contact.receive-card" -pr = 5 -reason = "contact cards, notes and birthdays are the `contact` surface (PR-5)." - -[[id]] -id = "contact.share-token" -pr = 5 -reason = "contact cards, notes and birthdays are the `contact` surface (PR-5)." - -[[id]] -id = "contact.suggest-birthday" -pr = 5 -reason = "contact cards, notes and birthdays are the `contact` surface (PR-5)." - -[[id]] -id = "content.limits" -pr = 12 -reason = "the app-config limit table is read through the settings surface (PR-12)." - -[[id]] -id = "gift.as-chat-theme" -pr = 3 -reason = "a collectible used as a chat theme is set through `chat theme` (PR-3)." - -[[id]] -id = "gift.as-emoji-status" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.as-peer-color" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.auto-save-privacy" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.button-visibility" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.can-send" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.catalog" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.collection-create" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.collection-delete" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.collection-reorder" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.collection-update" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.collections-list" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.convert-to-stars" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.craft" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.craft-candidates" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.display-toggle" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.get-one" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.hosted" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.offer-resolve" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.pin" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.privacy-disallowed" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.received-list" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.resale-browse" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.resale-list-mine" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.transfer" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.unique-info" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.unique-value" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.upgrade" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.upgrade-attributes" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "gift.upgrade-preview" -pr = 12 -reason = "gifts and collectibles are the `gift` surface (PR-12)." - -[[id]] -id = "giftcode.apply" -pr = 12 -reason = "gift codes are the `gift` surface (PR-12)." - -[[id]] -id = "giftcode.check" -pr = 12 -reason = "gift codes are the `gift` surface (PR-12)." - -[[id]] -id = "giveaway.boost-status" -pr = 7 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-7)." - -[[id]] -id = "giveaway.boosts-list" -pr = 7 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-7)." - -[[id]] -id = "giveaway.boosts-unrestrict" -pr = 7 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-7)." - -[[id]] -id = "giveaway.gift-code-received" -pr = 12 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12)." - -[[id]] -id = "giveaway.info" -pr = 12 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12)." - -[[id]] -id = "giveaway.join-by-boosting" -pr = 12 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12)." - -[[id]] -id = "giveaway.list-prepaid" -pr = 12 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12)." - -[[id]] -id = "giveaway.prize-stars" -pr = 4 -reason = "a Stars prize arrives as an update (PR-4)." - -[[id]] -id = "giveaway.results" -pr = 12 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12)." - -[[id]] -id = "giveaway.user-boosts" -pr = 7 -reason = "giveaways and channel boosts are the `giveaway`/`boost` surface (PR-7)." - -[[id]] -id = "location.business-address" -pr = 12 -reason = "a business account's address is the `business` surface (PR-12)." - -[[id]] -id = "location.channel-geo" -pr = 7 -reason = "a geo-group's location is set through the channel admin surface (PR-7)." - -[[id]] -id = "location.geogroup-create" -pr = 7 -reason = "creating a location-based group is `chat create` (PR-7)." - -[[id]] -id = "location.proximity-alert-event" -pr = 4 -reason = "a proximity alert arrives as an update, not a command (PR-4)." - -[[id]] -id = "location.viewed-receipt" -pr = 4 -reason = "a live-location view receipt arrives as an update (PR-4)." - -[[id]] -id = "stars.balance" -pr = 12 -reason = "the Star balance and top-up packages are the `stars` surface (PR-12)." - -[[id]] -id = "stars.topup-options" -pr = 12 -reason = "the Star balance and top-up packages are the `stars` surface (PR-12)." - -# --------------------------------------------------------------------------- -# PR-2 landed the auth/sessions/security domain. Four of its 89 ids belong to -# a *different* group's command surface, so they are named individually rather -# than left behind a domain waiver that is no longer true. -# --------------------------------------------------------------------------- - -[[id]] -id = "auth.countries-list" -pr = 4 -reason = "help.getCountriesList is config/help plumbing shared with phone formatting; it lands with `config` in PR-4." - -[[id]] -id = "auth.prelogin-language" -pr = 4 -reason = "The suggested login-screen language comes from the language pack, which is the `config`/langpack surface in PR-4." - -[[id]] -id = "chat.photo-set" -pr = 7 -reason = "A group or channel photo is `chat photo set` (PR-7)." - -[[id]] -id = "emoji.status-channel" -pr = 12 -reason = "A channel emoji status is a profile setting (PR-12)." - -[[id]] -id = "emoji.status-lists" -pr = 12 -reason = "Emoji status suggestions belong to `profile status` (PR-12)." - -[[id]] -id = "emoji.status-set" -pr = 12 -reason = "Setting your own emoji status is `profile status set` (PR-12)." - - -[[id]] -id = "profile.contact-personal-photo" -pr = 12 -reason = "A personal photo for a contact is the `profile` group (PR-12)." - -[[id]] -id = "profile.main-tab" -pr = 12 -reason = "The profile tab layout is the `profile` group (PR-12)." - -[[id]] -id = "profile.photo-fallback-public" -pr = 12 -reason = "The public fallback photo is the `profile` group (PR-12)." - -[[id]] -id = "profile.photo-set" -pr = 12 -reason = "Setting your profile photo is `profile photo set` (PR-12)." - -[[id]] -id = "profile.photo-set-as-main" -pr = 12 -reason = "Promoting an older photo is the `profile` group (PR-12)." - -[[id]] -id = "profile.photo-set-emoji-sticker" -pr = 12 -reason = "An emoji avatar is the `profile` group (PR-12)." - -[[id]] -id = "profile.photo-set-video" -pr = 12 -reason = "A video avatar is the `profile` group (PR-12)." - -[[id]] -id = "profile.photos-list-history" -pr = 12 -reason = "Profile photo history is the `profile` group (PR-12)." - -[[id]] -id = "profile.saved-music" -pr = 12 -reason = "Music on a profile is the `profile` group (PR-12)." - -[[id]] -id = "ringtone.manage" -pr = 12 -reason = "Custom notification sounds are the `notify` group (PR-12)." - -[[id]] -id = "ringtone.set-for-chat" -pr = 12 -reason = "Per-chat notification sounds are the `notify` group (PR-12)." - -[[id]] -id = "sticker.group-sticker-set" -pr = 7 -reason = "A supergroup's sticker set is a chat setting (PR-7)." - -[[id]] -id = "theme.cloud-themes" -pr = 12 -reason = "Cloud themes are the `settings` group (PR-12)." - -# --------------------------------------------------------------------------- -# The stories ids PR-8 does not own. Each names the group that does. -# --------------------------------------------------------------------------- - -[[id]] -id = "stories.boost-status" -pr = 7 -reason = "Boost levels are the `boost` group (PR-7); `story can-post` reports the gate." - -[[id]] -id = "stories.business-story" -pr = 10 -reason = "Posting for a business account goes through a bot connection (PR-10)." - -[[id]] -id = "stories.notify-exceptions" -pr = 12 -reason = "Per-peer notification exceptions are the `notify` group (PR-12)." - -[[id]] -id = "stories.notify-global" -pr = 12 -reason = "Global story notification settings are `notify set` (PR-12)." - -[[id]] -id = "stories.notify-peer" -pr = 12 -reason = "Per-peer story notifications are `notify set --stories` (PR-12)." - -[[id]] -id = "stories.notify-reactions" -pr = 12 -reason = "Notifications for reactions to my stories are `notify set` (PR-12)." - -[[id]] -id = "stories.story-music-save" -pr = 12 -reason = "Saving a story's soundtrack to the profile is the profile group (PR-12)." - -# --- PR-11 (calls) --------------------------------------------------------- -# The call domain is migrated; what is left inside it belongs to other groups -# by subject, not by effort. Each one names the group that owns it. - -[[id]] -id = "calls.privacy-who-can-call" -pr = 12 -reason = "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." - -[[id]] -id = "calls.privacy-p2p" -pr = 12 -reason = "inputPrivacyKeyPhoneP2P is the same account.setPrivacy surface as every other privacy key (PR-12)." - -[[id]] -id = "calls.session-accept-calls" -pr = 2 -reason = "Per-session call acceptance is account.changeAuthorizationSettings, i.e. `session` in the auth group (PR-2)." - -[[id]] -id = "groupcall.admin-log" -pr = 7 -reason = "channels.getAdminLog is one command with one filter vocabulary; the video-chat events are read through `chat admin log` (PR-7)." - -[[id]] -id = "groupcall.admin-right-manage-call" -pr = 7 -reason = "manage_call is one keyword in the admin-rights vocabulary owned by `chat admin promote` (PR-7)." - -[[id]] -id = "calls.top-callers" -pr = 5 -reason = "contacts.getTopPeers is the contact group's suggestion surface (PR-5); the phone-calls category is one flag on it." - -[[id]] -id = "calls.reset-top-caller" -pr = 5 -reason = "contacts.resetTopPeerRating is the same surface as top-callers (PR-5)." - -[[id]] -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.people-you-may-know" -pr = 7 -reason = "Suggested peers come from channels.getChannelRecommendations, a channel surface (PR-7)." - -[[id]] -id = "contacts-users.url-auth-login" -pr = 10 -reason = "URL authorization is a bot surface (PR-10); `resolve link` classifies the link and delegates." -# PR-10 landed the bots/inline/mini-app/payment domain. Its 22 remaining ids -# are of three kinds, and each is named rather than hidden behind a domain -# waiver that would have let the group read as unfinished forever: +# Layer 229: `ephemeral.*` and the rich-message keyboard. # -# * nine need API layer 229, which the pinned Telethon does not speak. Each -# has a registered command that exits 13 rather than not existing. -# * twelve belong to another group's command surface — Stars, business bots, -# privacy rules, the bot-side update stream, stories. -# * one, sharing a mini-app result to a story, is the story group's. +# Telethon 1.44 speaks layer 227 and has no TL classes for these, so tlgr has +# no request to send. Each command is registered and refuses with exit 13 +# naming the method, because a command that is absent teaches an agent +# nothing while a command that refuses teaches it exactly what is missing. # --------------------------------------------------------------------------- -[[id]] -id = "bots.ephemeral-callback-press" -pr = 12 -reason = "layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'." - [[id]] id = "bots.ephemeral-command-send" -pr = 12 -reason = "layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'." +kind = "layer-gap" +reason = "`ephemeral.sendMessage` is a layer-229 method; `bot command send --ephemeral` is registered and exits 13." [[id]] -id = "bots.ephemeral-message-send" -pr = 12 -reason = "layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'." +id = "bots.ephemeral-callback-press" +kind = "layer-gap" +reason = "`ephemeral.getCallbackAnswer` is a layer-229 method; `bot press --ephemeral` is registered and exits 13." [[id]] -id = "bots.ephemeral-message-view" -pr = 12 -reason = "layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'." +id = "bots.ephemeral-message-send" +kind = "layer-gap" +reason = "`ephemeral.sendMessage` is a layer-229 method; the command is registered and exits 13." [[id]] id = "bots.ephemeral-report" -pr = 12 -reason = "layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'." - -[[id]] -id = "bots.welcome-messages-manage" -pr = 12 -reason = "layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'." +kind = "layer-gap" +reason = "`ephemeral.report` is a layer-229 method; the command is registered and exits 13." [[id]] id = "bots.welcome-messages-view" -pr = 12 -reason = "layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'." - -[[id]] -id = "bots.rich-message-buttons" -pr = 12 -reason = "layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'." - -[[id]] -id = "bots.chat-join-webview" -pr = 12 -reason = "messages.requestChatJoinWebView is absent from Telethon 1.44; `webapp open --join-query-id` is registered and exits 13." +kind = "layer-gap" +reason = "`ephemeral.getWelcomeMessages` is a layer-229 method; `chat welcome list` is registered and exits 13." [[id]] -id = "bots.bot-stars-balance" -pr = 12 -reason = "The Star balance is the `stars` surface (PR-12)." - -[[id]] -id = "bots.bot-revenue-stats" -pr = 12 -reason = "Bot revenue graphs are the `stars`/`stats` surface (PR-12)." - -[[id]] -id = "bots.stars-topup-options" -pr = 12 -reason = "Buying Stars is the `stars` surface (PR-12)." - -[[id]] -id = "bots.stars-topup-deeplink" -pr = 12 -reason = "The Stars top-up deep link is the `stars` surface (PR-12)." - -[[id]] -id = "bots.business-bot-connect" -pr = 12 -reason = "Business bots are the `business` surface (PR-12)." - -[[id]] -id = "bots.business-bot-disconnect" -pr = 12 -reason = "Business bots are the `business` surface (PR-12)." - -[[id]] -id = "bots.business-bot-remove-from-chat" -pr = 12 -reason = "Business bots are the `business` surface (PR-12)." - -[[id]] -id = "bots.business-bots-list" -pr = 12 -reason = "Business bots are the `business` surface (PR-12)." - -[[id]] -id = "bots.privacy-rule-bots" -pr = 12 -reason = "Allowing or disallowing bots in a privacy rule is `privacy set` (PR-12)." +id = "bots.welcome-messages-manage" +kind = "layer-gap" +reason = "`ephemeral.sendMessage` / `ephemeral.editMessage` are layer-229 methods; `chat welcome set` is registered and exits 13." [[id]] -id = "bots.bot-side-update-stream" -pr = 4 -reason = "The bot-only update stream is `watch --bot-updates`, which is the updates group (PR-4)." +id = "bots.rich-message-buttons" +kind = "layer-gap" +reason = "The rich-message keyboard constructors arrived in layer 229; tlgr refuses rather than guessing at a constructor id." -[[id]] -id = "bots.bot-updates-status" -pr = 4 -reason = "The pending-update backlog is daemon/updates plumbing (PR-4)." +# --------------------------------------------------------------------------- +# Methods this Telethon has no request class for. The layer has them; the +# pinned library does not, so the *flag* that needs one refuses while the rest +# of its command still works. +# --------------------------------------------------------------------------- [[id]] -id = "bots.bot-subscription-update" -pr = 4 -reason = "Subscription updates reach a bot through the update stream (PR-4)." +id = "bots.chat-join-webview" +kind = "absent-method" +reason = "`messages.requestChatJoinWebView` is absent from Telethon 1.44; `webapp open --join-query-id` is registered and exits 13." [[id]] -id = "bots.webapp-share-to-story" -pr = 8 -reason = "Sharing to a story is the `story` group (PR-8)." +id = "gift.can-send" +kind = "absent-method" +reason = "`payments.canSendStarGift` is absent from Telethon 1.44; `gift catalog --until` is registered and exits 13, and the rest of the catalogue still reads." diff --git a/tlgr/parity.py b/tlgr/parity.py index 9629d07..2c20760 100644 --- a/tlgr/parity.py +++ b/tlgr/parity.py @@ -11,10 +11,13 @@ (bot-only, server-side, GUI-only) or `prohibited` (ToS, spam, deanonymisation) are excluded once, here, and never again. Anything else counts, so coverage cannot be improved by re-labelling work as out of scope. -* **A waiver is a promise with a PR number on it.** `parity_waivers.toml` - lists what is knowingly not covered yet and by when. A waived id is still - in the denominator; it is reported as uncovered-with-a-reason, not - subtracted. +* **A waiver names a permanent reason, not a later PR.** Until PR-12 a + waiver was a promise with a PR number on it; every one of those promises + has been kept, so `parity_waivers.toml` now holds only ids this build + genuinely cannot cover, each with a `kind` (`layer-gap`, `absent-method`, + `prohibited`, `not-applicable`) and the method that is missing. A waived id + is still in the denominator; it is reported as uncovered-with-a-reason, + never subtracted. * **An unknown id is a build failure.** An op that covers an id the catalog has never heard of is a typo, and a typo that inflates a coverage number is worse than a gap. @@ -36,6 +39,7 @@ __all__ = [ "CatalogEntry", "ParityReport", + "Waiver", "Waivers", "catalog", "compute", @@ -65,22 +69,41 @@ def required(self) -> bool: return self.feasibility in ("full", "partial", "control-only") +#: The only reasons a waiver may give. Anything else is a backlog entry +#: wearing a waiver's clothes, and `tests/test_parity.py` refuses it. +KINDS = ("layer-gap", "absent-method", "prohibited", "not-applicable") + + +@dataclass(frozen=True, slots=True) +class Waiver: + """One id that cannot be covered, and why.""" + + kind: str + reason: str + + @dataclass(frozen=True, slots=True) class Waivers: - """What is knowingly uncovered, and which PR closes it.""" + """What is knowingly uncovered, by id. + + `domains` survives as a mapping rather than being deleted, so that a + domain waiver reappearing in the file is something the gate can *see* and + refuse — "no blanket waivers" is then a rule the file cannot break rather + than a habit somebody has to remember. + """ catalog_version: str = "" final_pr: int = 0 domains: dict[str, tuple[int, str]] = field(default_factory=dict) - ids: dict[str, tuple[int, str]] = field(default_factory=dict) + ids: dict[str, Waiver] = field(default_factory=dict) def reason_for(self, entry: CatalogEntry) -> str: found = self.ids.get(entry.id) if found is not None: - return f"waived until PR-{found[0]}: {found[1]}" - found = self.domains.get(entry.domain) - if found is not None: - return f"waived until PR-{found[0]}: {found[1]}" + return f"waived ({found.kind}): {found.reason}" + legacy = self.domains.get(entry.domain) + if legacy is not None: # pragma: no cover - the file carries none + return f"waived (domain): {legacy[1]}" return "" @@ -133,7 +156,9 @@ def waivers(path: Path | None = None) -> Waivers: for item in raw.get("domain", []) }, ids={ - str(item["id"]): (int(item["pr"]), str(item.get("reason", ""))) + str(item["id"]): Waiver( + kind=str(item.get("kind", "")), reason=str(item.get("reason", "")) + ) for item in raw.get("id", []) }, ) @@ -352,7 +377,7 @@ def render_table(report: ParityReport) -> str: f"{'TOTAL':<28} {report.covered:>8} {report.required:>6} " f"{report.percent:>6.1f}% {report.accounted_percent:>6.1f}%", f"excluded: {excluded}", - f"uncovered: {len(report.uncovered)} ({report.waivers} waived with a PR number)", + f"uncovered: {len(report.uncovered)} ({report.waivers} waived with a reason)", ] if report.unknown: lines.append( From 04bcd9babe862aa344d3171709d5e7479df3020b Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 11:12:29 +0330 Subject: [PATCH 10/15] retire v1: the daemon serves /v1/* only, and nothing is hand-written any more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five modules go, and with them the last places where a command could reach Telegram without passing through the registry: * `tlgr/cli/legacy/` — the v1 command package. `profile get` and `profile update` were the last two commands in it, and they are registry operations now. Every path v1 documented is still invocable, as `legacy_paths` aliases on the ops that replaced them; `tests/test_agentmd_compat.py` walks the whole list and proves it. * `tlgr/daemon/ipc.py` — the v1 route table. `POST /v1/op`, `GET /v1/events`, `GET /v1/status` and `POST /v1/admin/{action}` are the entire HTTP surface now, which means the peer-uid check, the policy allowlist, the version handshake, the flood budget and the §7.2 error classification apply to every command without exception rather than by being registered alongside. * `tlgr/ipc_client.py` and `transport.legacy_request` — the shim those routes were called through, and the transport-level `flood_wait_max` default that existed only because the hand-written commands did not thread it into their own request bodies (COR-15). Every command threads it now. * `tlgr/core/client.py` — `ClientWrapper`. 460 lines that owned a Telethon client, logged accounts in and out, and serialised messages, held alive because the job engine needed *two* of its methods. Those two are now `jobs/client.py`'s `JobClient` protocol — the raw client and a resolver — and `AccountSession.job_client` supplies them, so a reconnect swaps the client underneath a running job instead of leaving it holding a dead one. `Daemon.status()` goes too: it was v1's `/daemon/status` body, and nothing has served it over HTTP since PR-4. The COR-37 claim it carried — that a client object existing and the link being usable are different facts — is made where it now lives, on `AccountSession.connected` and in the per-account `state` that `daemon status` answers from. Three test modules exercised only `ClientWrapper.get_messages`; that surface is `message list` and `tests/test_ops_message.py` owns it. `media_details` was the classifier those tests were really about, so its table moved onto `media_summary` rather than being deleted with it. The layering lint loses its one exemption, because there is nothing left to exempt. --- pyproject.toml | 21 +- tests/test_daemon_connection_health.py | 129 +++---- tests/test_dispatch.py | 34 +- tests/test_layering.py | 9 +- tests/test_media_kind.py | 96 ++---- tests/test_media_only_messages.py | 107 ------ tests/test_message_reactions.py | 185 ---------- tests/test_serialize.py | 14 +- tests/test_service_messages.py | 93 ----- tests/test_spam_flag_errors.py | 33 +- tests/test_transport.py | 35 +- tlgr/actions/__init__.py | 6 +- tlgr/actions/forward.py | 4 +- tlgr/actions/reply.py | 4 +- tlgr/cli/__init__.py | 61 +--- tlgr/cli/legacy/__init__.py | 7 - tlgr/cli/legacy/_common.py | 69 ---- tlgr/core/client.py | 458 ------------------------- tlgr/daemon/app.py | 56 +-- tlgr/daemon/ipc.py | 160 --------- tlgr/daemon/jobs.py | 4 +- tlgr/daemon/session.py | 66 +++- tlgr/gateway/engine.py | 4 +- tlgr/ipc_client.py | 39 --- tlgr/jobs/base.py | 4 +- tlgr/jobs/client.py | 37 ++ tlgr/transport/__init__.py | 4 - tlgr/transport/client.py | 39 --- 28 files changed, 293 insertions(+), 1485 deletions(-) delete mode 100644 tests/test_media_only_messages.py delete mode 100644 tests/test_message_reactions.py delete mode 100644 tests/test_service_messages.py delete mode 100644 tlgr/cli/legacy/__init__.py delete mode 100644 tlgr/cli/legacy/_common.py delete mode 100644 tlgr/core/client.py delete mode 100644 tlgr/daemon/ipc.py delete mode 100644 tlgr/ipc_client.py create mode 100644 tlgr/jobs/client.py diff --git a/pyproject.toml b/pyproject.toml index 1bc7d16..28a7a2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ asyncio_mode = "auto" [tool.ruff] line-length = 100 target-version = "py310" -extend-exclude = ["tlgr/cli/legacy", "*.md"] # design docs contain illustrative, not runnable, snippets +extend-exclude = ["*.md"] # design docs contain illustrative, not runnable, snippets [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "RUF"] @@ -78,9 +78,8 @@ ignore = [ "RUF001", "RUF002", "RUF003", # the docs and tests are full of real Unicode ] -# The v1 modules below are rewritten or deleted by their own group PR -# (ARCHITECTURE §2.4). Restyling them now would bury the foundation diff in -# unrelated churn, so their remaining nits are parked rather than fixed. +# PR-12 deleted the last v1 module, so nothing below is parked pending a +# rewrite any more: each entry is a rule that is wrong for that file. [tool.ruff.lint.per-file-ignores] # RUF012: msgspec turns a mutable Struct default into a default_factory, so # `list[str] = []` is the documented spelling, not a shared-mutable bug. @@ -93,25 +92,21 @@ ignore = [ # and the code/exit status is the contract. "tests/*" = ["B011", "B017", "SIM117", "RUF059", "RUF012"] "tlgr/cli/__init__.py" = ["SIM102"] -"tlgr/cli/legacy/*" = ["SIM102", "SIM105", "F401", "F841", "B904"] -"tlgr/cli/account.py" = ["SIM105", "F841"] -"tlgr/cli/config_cmd.py" = ["SIM105", "F401"] "tlgr/core/accounts.py" = ["SIM105"] -"tlgr/core/client.py" = ["SIM102", "SIM105", "B904"] "tlgr/daemon/server.py" = ["SIM105", "RUF006"] "tlgr/daemon/webhook.py" = ["SIM102", "SIM105"] "tlgr/filters/*" = ["SIM102"] "tlgr/gateway/engine.py" = ["RUF059"] -"tlgr/ipc_client.py" = ["B904", "F841"] "tlgr/jobs/base.py" = ["SIM105"] "tlgr/processors/__init__.py" = ["F811"] [tool.ruff.format] quote-style = "double" -# Strict typing is enforced on the v2 foundation only. The v1 modules under -# tlgr/cli/legacy and the not-yet-migrated core keep their existing looseness -# until their group PR rewrites them. +# Strict typing is enforced on the v2 foundation. What is left outside the +# list below is the job engine (`gateway/`, `jobs/`, `actions/`, `filters/`, +# `processors/`) and the platform glue, neither of which the command surface +# runs through. [tool.mypy] python_version = "3.10" warn_unused_configs = true @@ -150,7 +145,7 @@ strict = true [tool.coverage.run] source = ["tlgr"] -omit = ["tlgr/cli/legacy/*"] +omit = [] [project.urls] Homepage = "https://github.com/tlgrcli/tlgr" diff --git a/tests/test_daemon_connection_health.py b/tests/test_daemon_connection_health.py index f3a30b3..235ff35 100644 --- a/tests/test_daemon_connection_health.py +++ b/tests/test_daemon_connection_health.py @@ -3,7 +3,7 @@ On 2026-09-02 the machine's route to Telegram dropped mid-wake. Telethon exhausted its reconnect budget on all three campaign accounts and raised `ConnectionError: Connection to Telegram failed 5 time(s)`, leaving every -`ClientWrapper` in `Daemon._clients` — present, and dead. Every request then +client object in the daemon's table — present, and dead. Every request then failed with "Cannot send requests while disconnected". The documented remedy for exactly that situation ("`tlgr status` if things @@ -17,105 +17,88 @@ most reassuring possible answer while nothing worked, and the only way to learn otherwise was to attempt a real send and read the failure. -The wrapper existing and the wrapper being usable are different facts; status -now reports the second one. +The object existing and the link being usable are different facts. PR-12 +deleted the `ClientWrapper` this was first written against, so the claim is +made where it now lives: `AccountSession.connected` asks the client, and +`SessionManager.snapshot()` — what `daemon status` answers from — carries a +state per account rather than a list of keys. """ from __future__ import annotations from types import SimpleNamespace -from tlgr.core.client import ClientWrapper +import pytest +from tlgr.daemon.session import AccountSession, SessionState -def _wrapper(*, client=None): - w = ClientWrapper.__new__(ClientWrapper) - w._client = client - w._me = None - return w +def _session(alias: str, *, client=None, state: str = SessionState.ONLINE) -> AccountSession: + """A session with a client and nothing else; no loop, no socket.""" + session = AccountSession.__new__(AccountSession) + session.alias = alias + session.client = client + session.state = state + session.me = None + session.reason = "" + session.since = None + session.connected_since = None + session.last_update = None + session.reconnects = 0 + session.catch_up_pending = False + session.in_flight = 0 + session.resync_needed = set() + return session -class _FakeDaemon: - """Just enough of Daemon to exercise status() without a running loop.""" - def __init__(self, clients): - import os - import time +LIVE = SimpleNamespace(is_connected=lambda: True) +DEAD = SimpleNamespace(is_connected=lambda: False) - self._clients = clients - self._start_time = time.time() - self._job_runner = SimpleNamespace(list_jobs=lambda: []) - self._os_getpid = os.getpid - status = None # bound below +# -- AccountSession.connected ------------------------------------------------ -def _status(clients): - from tlgr.daemon.server import DaemonServer +def test_a_session_that_never_connected_is_not_connected(): + assert _session("work", client=None).connected is False - d = _FakeDaemon(clients) - return DaemonServer.status(d) +def test_a_session_with_a_live_client_is_connected(): + assert _session("work", client=LIVE).connected is True -# -- ClientWrapper.is_connected -- - -def test_wrapper_never_connected_is_not_connected(): - assert _wrapper(client=None).is_connected is False - - -def test_wrapper_with_live_client_is_connected(): - live = SimpleNamespace(is_connected=lambda: True) - assert _wrapper(client=live).is_connected is True - - -def test_wrapper_survives_its_connection(): +def test_a_session_survives_its_connection(): """The exact shape of the incident: the object is there, the link is not.""" - dead = SimpleNamespace(is_connected=lambda: False) - w = _wrapper(client=dead) - assert w._client is not None # what status() used to key off - assert w.is_connected is False # what it keys off now - - -# -- Daemon.status() -- - - -def test_status_reports_all_connected_as_healthy(): - live = SimpleNamespace(is_connected=lambda: True) - st = _status({"Pouri2048": _wrapper(client=live), "Mr": _wrapper(client=live)}) + session = _session("work", client=DEAD) + assert session.client is not None # what status() used to key off + assert session.connected is False # what it keys off now - assert st["healthy"] is True - assert st["disconnected"] == [] - assert st["connections"] == {"Pouri2048": True, "Mr": True} +# -- what `daemon status` answers from --------------------------------------- -def test_status_reports_a_fully_dead_daemon_as_unhealthy(): - dead = SimpleNamespace(is_connected=lambda: False) - clients = {a: _wrapper(client=dead) for a in ("Mr", "Pouri2048", "Pouri16", "Pouri256")} - st = _status(clients) - # The field that lied: still complete, still every account, unchanged shape. - assert sorted(st["accounts"]) == ["Mr", "Pouri16", "Pouri2048", "Pouri256"] - # The fields that tell the truth. - assert st["healthy"] is False - assert st["disconnected"] == ["Mr", "Pouri16", "Pouri2048", "Pouri256"] - assert not any(st["connections"].values()) +def _snapshot(*sessions: AccountSession) -> list[dict]: + return [session.snapshot() for session in sessions] -def test_status_reports_a_partial_outage(): - live = SimpleNamespace(is_connected=lambda: True) - dead = SimpleNamespace(is_connected=lambda: False) - st = _status({"Pouri2048": _wrapper(client=live), "Pouri16": _wrapper(client=dead)}) +def test_every_row_carries_a_state_not_just_a_name(): + """The field that lied was a list of keys. A row cannot be just a name.""" + rows = _snapshot(_session("Pouri2048", client=LIVE), _session("Mr", client=LIVE)) + assert sorted(row["alias"] for row in rows) == ["Mr", "Pouri2048"] + assert all(row["state"] == SessionState.ONLINE for row in rows) - assert st["healthy"] is False - assert st["disconnected"] == ["Pouri16"] - assert st["connections"] == {"Pouri2048": True, "Pouri16": False} +def test_a_dead_account_says_so_in_its_own_row(): + rows = _snapshot( + _session("Mr", client=DEAD, state=SessionState.DEGRADED), + _session("Pouri16", client=LIVE), + ) + by_alias = {row["alias"]: row for row in rows} + assert by_alias["Mr"]["state"] == SessionState.DEGRADED + assert by_alias["Pouri16"]["state"] == SessionState.ONLINE -def test_status_with_no_clients_is_healthy_not_broken(): - """No accounts loaded yet is a different thing from accounts that died.""" - st = _status({}) - assert st["healthy"] is True - assert st["disconnected"] == [] - assert st["connections"] == {} +@pytest.mark.parametrize("state", [SessionState.DEGRADED, SessionState.STOPPED]) +def test_a_session_that_is_not_online_reports_when_it_stopped_being(state): + """ "Since when" is the difference between a blip and an outage.""" + row = _session("Mr", client=DEAD, state=state).snapshot() + assert "since" in row diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 373366a..c50109e 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -311,28 +311,36 @@ async def test_an_older_client_protocol_is_refused(self, live_daemon, tlgr_home, assert classify(caught.value).exit_code == EXIT_DAEMON -async def test_the_v1_error_shape_is_flat_and_the_v2_one_is_wrapped(live_daemon, client, in_thread): - """Both surfaces classify identically; only the wrapper differs (§7.1). - - The raw bodies are read here rather than through `client.request`, which - would raise — and the shape of the body *is* the compatibility contract. - Everything goes through `in_thread`: the transport is synchronous, and - calling it directly would block the loop the daemon is running on. +async def test_the_only_error_shape_is_the_wrapped_one(live_daemon, client, in_thread): + """One surface, one shape (§7.1). + + Until PR-12 this asserted two: the v1 routes answered a flat + `{code, exit_code, error}` body and `/v1/op` answered the wrapped one. + The v1 routes are gone, so the flat shape has nothing left to describe — + what survives is that an unknown operation is a USAGE error inside the + envelope, and that a v1 path is simply not a route any more. + + The raw body is read here rather than through `client.request`, which + would raise, and everything goes through `in_thread`: the transport is + synchronous and would otherwise block the loop the daemon runs on. """ import json - def raw(method: str, path: str, body: bytes | None = None) -> dict[str, Any]: + def raw(method: str, path: str, body: bytes | None = None) -> tuple[int, Any]: conn, response = client._open(method, path, body=body) try: - return json.loads(response.read()) + payload = response.read() + try: + return response.status, json.loads(payload) + except json.JSONDecodeError: + return response.status, payload finally: conn.close() client._ready = True - v1 = await in_thread(raw, "GET", "/profile/get?account=nope") - assert {"code", "exit_code", "error"} <= set(v1) - assert "ok" not in v1 + status, _ = await in_thread(raw, "GET", "/profile/get?account=nope") + assert status == 404, "a v1 route must not be served at all" - v2 = await in_thread(raw, "POST", "/v1/op", msgspec.json.encode({"op": "nope.nothing"})) + _, v2 = await in_thread(raw, "POST", "/v1/op", msgspec.json.encode({"op": "nope.nothing"})) assert v2["ok"] is False assert v2["error"]["code"] == "USAGE" diff --git a/tests/test_layering.py b/tests/test_layering.py index f840bba..28b7960 100644 --- a/tests/test_layering.py +++ b/tests/test_layering.py @@ -9,8 +9,9 @@ * `cli/` must not import Telethon or the daemon, because `tlgr --help` has to be fast and has to work on a machine that never connects to Telegram. -`cli/legacy/` is exempt: those modules are v1, moved verbatim, and each is -deleted by its own group PR. +There are no exemptions any more. `cli/legacy/` held the v1 modules that were +moved verbatim and deleted one group PR at a time; PR-12 deleted the last of +them, so every module under `tlgr/` is held to the rule. """ from __future__ import annotations @@ -28,9 +29,7 @@ def _modules(package: str) -> list[Path]: return sorted( - path - for path in (ROOT / package).rglob("*.py") - if "legacy" not in path.parts and "__pycache__" not in path.parts + path for path in (ROOT / package).rglob("*.py") if "__pycache__" not in path.parts ) diff --git a/tests/test_media_kind.py b/tests/test_media_kind.py index 1ae0898..5c6b598 100644 --- a/tests/test_media_kind.py +++ b/tests/test_media_kind.py @@ -16,11 +16,28 @@ from __future__ import annotations -import asyncio -from pathlib import Path from types import SimpleNamespace -from tlgr.core.client import ClientWrapper, media_details +from tlgr.ops._serialize import media_summary + + +def media_details(media): + """v1's dict shape, from the typed summary that replaced it. + + PR-12 deleted `ClientWrapper` and with it `media_details`; the logic it + carried lives in `media_summary` and is what the ops serialise with. This + shim keeps the *claims* below written the way they were made, because + they are about the classifier and not about which module holds it. + """ + summary = media_summary(media) + if summary is None: + return {} + out = {"kind": summary.kind} + for name in ("alt", "duration", "file_name", "mime_type"): + value = getattr(summary, name, None) + if value is not None: + out[name] = value + return out class MessageMediaPhoto(SimpleNamespace): @@ -55,47 +72,6 @@ def _doc(*attrs, mime=None): return MessageMediaDocument(document=SimpleNamespace(mime_type=mime, attributes=list(attrs))) -def _msg(mid, text, *, out=False, media=None): - return SimpleNamespace( - id=mid, - date="2026-09-02", - text=text, - out=out, - action=None, - reply_to_msg_id=None, - sender=None, - sender_id=None, - media=media, - entities=None, - reactions=None, - reply_to=None, - forward=None, - ) - - -class _FakeTelethon: - def __init__(self, msgs): - self._msgs = msgs - - def iter_messages(self, chat_id, limit=20, offset_id=0, **kw): - msgs = self._msgs[:limit] - - async def _gen(): - for m in msgs: - yield m - - return _gen() - - async def get_messages(self, chat_id, ids=None): - return [m for m in self._msgs if m.id in (ids or [])] - - -def _wrap(msgs): - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _FakeTelethon(msgs) - return w - - # --- the classifier itself ------------------------------------------------ @@ -171,35 +147,3 @@ class MessageMediaPoll(SimpleNamespace): def test_none_media_is_empty(): assert media_details(None) == {} - - -# --- wired into every serialization site ---------------------------------- - - -def test_get_messages_labels_kind_without_asking(): - w = _wrap([_msg(2, "", media=_doc(DocumentAttributeSticker(alt="👍")))]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert out[0]["media_type"] == "MessageMediaDocument" - assert out[0]["media_kind"] == "sticker" - assert out[0]["media_alt"] == "👍" - - -def test_get_message_single_labels_kind(): - w = _wrap([_msg(2, "", media=_doc(DocumentAttributeAudio(voice=True, duration=11)))]) - out = asyncio.run(w.get_message(7, 2)) - assert out["media_kind"] == "voice" - assert out["media_duration"] == 11 - - -def test_include_media_payload_gains_the_same_detail(): - w = _wrap([_msg(1, "", media=_doc(DocumentAttributeSticker(alt="🙏")))]) - out = asyncio.run(w.get_messages(7, limit=10, include_media=True)) - assert out[0]["media"]["has_file"] is True - assert out[0]["media"]["kind"] == "sticker" - assert out[0]["media"]["alt"] == "🙏" - - -def test_text_message_has_no_kind(): - w = _wrap([_msg(1, "سلام")]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert "media_kind" not in out[0] diff --git a/tests/test_media_only_messages.py b/tests/test_media_only_messages.py deleted file mode 100644 index 7a482ff..0000000 --- a/tests/test_media_only_messages.py +++ /dev/null @@ -1,107 +0,0 @@ -"""A media-only message must be distinguishable from a message with no content. - -A sticker, photo, voice note or video sent with no caption serializes as -{"text": "", "out": false} — byte-identical to a message nobody typed anything -into. Every message-bearing response (message list, chat open, catchup, inbox) -therefore rendered a contact's reply as blank, and a wake reading the chat saw -silence where there was an answer. `service` (test_service_messages.py) solved -exactly this shape for Telegram's own events; `media_type` is its counterpart -for real media, and is emitted unconditionally for the same reason: the cheap -type marker has to be there for the reader who did NOT know to ask for it. - -The verbose `include_media` payload is unchanged — that one is opt-in. -""" - -from __future__ import annotations - -import asyncio -from pathlib import Path -from types import SimpleNamespace - -from tlgr.core.client import ClientWrapper - - -class MessageMediaPhoto(SimpleNamespace): - pass - - -class MessageMediaDocument(SimpleNamespace): - pass - - -def _msg(mid, text, *, out=False, media=None): - return SimpleNamespace( - id=mid, - date="2026-09-02", - text=text, - out=out, - action=None, - reply_to_msg_id=None, - sender=None, - sender_id=None, - media=media, - entities=None, - reactions=None, - reply_to=None, - forward=None, - ) - - -class _FakeTelethon: - def __init__(self, msgs): - self._msgs = msgs - - def iter_messages(self, chat_id, limit=20, offset_id=0, **kw): - msgs = self._msgs[:limit] - - async def _gen(): - for m in msgs: - yield m - - return _gen() - - async def get_messages(self, chat_id, ids=None): - return [m for m in self._msgs if m.id in (ids or [])] - - -def _wrap(msgs): - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _FakeTelethon(msgs) - return w - - -def test_media_only_message_is_labelled_without_asking(): - """The caption-less sticker case, with include_media left off.""" - w = _wrap([_msg(2, "", media=MessageMediaDocument()), _msg(1, "سلام")]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert out[0]["media_type"] == "MessageMediaDocument" - assert out[0]["text"] == "" - assert "service" not in out[0] - - -def test_truly_empty_message_has_no_media_type(): - """The distinction the label exists to make.""" - w = _wrap([_msg(1, "")]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert "media_type" not in out[0] - - -def test_text_message_with_media_keeps_both(): - """A captioned photo is text AND media — neither field hides the other.""" - w = _wrap([_msg(1, "اینم پروفایلم", media=MessageMediaPhoto())]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert out[0]["text"] == "اینم پروفایلم" - assert out[0]["media_type"] == "MessageMediaPhoto" - - -def test_include_media_payload_still_works_alongside(): - w = _wrap([_msg(1, "", media=MessageMediaPhoto())]) - out = asyncio.run(w.get_messages(7, limit=10, include_media=True)) - assert out[0]["media_type"] == "MessageMediaPhoto" - assert out[0]["media"]["type"] == "MessageMediaPhoto" - - -def test_get_message_single_also_labels_media(): - w = _wrap([_msg(2, "", media=MessageMediaDocument())]) - out = asyncio.run(w.get_message(7, 2)) - assert out["media_type"] == "MessageMediaDocument" diff --git a/tests/test_message_reactions.py b/tests/test_message_reactions.py deleted file mode 100644 index 848bb73..0000000 --- a/tests/test_message_reactions.py +++ /dev/null @@ -1,185 +0,0 @@ -"""A serialized message must say whether THIS account already reacted. - -tlgr had an `include_reactions` flag that was off by default, unexposed on -`chat open`/`catchup`, and emitted `str(msg.reactions)` — a Telethon repr. So -nothing reading a history could tell an unacknowledged message from one this -account had already hearted. The only way to find out was to send a duplicate -reaction and read the failure: Telegram answers one with MESSAGE_NOT_MODIFIED, -which surfaced as a generic error and looked like a broken send. - -`reactions.mine` closes that: it comes from ReactionCount.chosen_order, which -Telegram sets only on reactions this account made. -""" - -from __future__ import annotations - -import asyncio -from pathlib import Path -from types import SimpleNamespace - -from tlgr.core.client import ClientWrapper - - -def _rc(emoticon=None, count=1, chosen=None, document_id=None): - reaction = ( - SimpleNamespace(emoticon=emoticon) - if emoticon is not None - else SimpleNamespace(document_id=document_id) - ) - return SimpleNamespace(reaction=reaction, count=count, chosen_order=chosen) - - -def _msg(mid, text, *, out=False, reactions=None): - return SimpleNamespace( - id=mid, - date="2026-09-02", - text=text, - out=out, - action=None, - reply_to_msg_id=None, - sender=None, - sender_id=None, - media=None, - entities=None, - reactions=reactions, - reply_to=None, - forward=None, - ) - - -class _FakeTelethon: - def __init__(self, msgs): - self._msgs = msgs - - def iter_messages(self, chat_id, limit=20, offset_id=0, **kw): - msgs = self._msgs[:limit] - - async def _gen(): - for m in msgs: - yield m - - return _gen() - - async def get_messages(self, chat_id, ids=None): - return [m for m in self._msgs if m.id in (ids or [])] - - -def _wrap(msgs): - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _FakeTelethon(msgs) - return w - - -def test_no_reactions_means_no_field(): - """The field only appears where it means something.""" - w = _wrap([_msg(1, "سلام")]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert "reactions" not in out[0] - - -def test_reaction_by_someone_else_is_not_mine(): - r = SimpleNamespace(results=[_rc("❤", count=1, chosen=None)]) - w = _wrap([_msg(1, "زدم واست", reactions=r)]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert out[0]["reactions"]["counts"] == {"❤": 1} - assert out[0]["reactions"]["mine"] == [] - - -def test_our_own_reaction_is_reported_as_mine(): - """The whole point: don't re-react to something already hearted.""" - r = SimpleNamespace(results=[_rc("❤", count=2, chosen=0)]) - w = _wrap([_msg(1, "زدم واست", reactions=r)]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert out[0]["reactions"]["mine"] == ["❤"] - assert out[0]["reactions"]["counts"] == {"❤": 2} - - -def test_mixed_reactions_separate_ours_from_theirs(): - r = SimpleNamespace( - results=[ - _rc("❤", count=2, chosen=0), - _rc("👍", count=3, chosen=None), - ] - ) - w = _wrap([_msg(1, "x", reactions=r)]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert out[0]["reactions"]["counts"] == {"❤": 2, "👍": 3} - assert out[0]["reactions"]["mine"] == ["❤"] - - -def test_custom_premium_reaction_is_named_not_dropped(): - """A custom reaction has no emoticon; it must still be visible.""" - r = SimpleNamespace(results=[_rc(None, count=1, chosen=0, document_id=555)]) - w = _wrap([_msg(1, "x", reactions=r)]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert out[0]["reactions"]["counts"] == {"custom:555": 1} - assert out[0]["reactions"]["mine"] == ["custom:555"] - - -def test_empty_results_is_treated_as_no_reactions(): - """A reactions object with nothing in it is not a reaction.""" - w = _wrap([_msg(1, "x", reactions=SimpleNamespace(results=[]))]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert "reactions" not in out[0] - - -def test_get_message_single_also_reports_reactions(): - r = SimpleNamespace(results=[_rc("❤", count=1, chosen=0)]) - w = _wrap([_msg(2, "x", reactions=r)]) - out = asyncio.run(w.get_message(7, 2)) - assert out["reactions"]["mine"] == ["❤"] - - -def test_raw_repr_still_available_behind_the_flag(): - """include_reactions kept working, moved to reactions_raw.""" - r = SimpleNamespace(results=[_rc("❤", count=1, chosen=0)]) - w = _wrap([_msg(1, "x", reactions=r)]) - out = asyncio.run(w.get_messages(7, limit=10, include_reactions=True)) - assert "reactions_raw" in out[0] - assert out[0]["reactions"]["mine"] == ["❤"] - - -class _AlreadyReacted(Exception): - def __str__(self): - return "Content of the message was not modified (caused by SendReactionRequest)" - - -def test_duplicate_reaction_reports_already_not_error(): - """MESSAGE_NOT_MODIFIED is the desired end state, not a failed send.""" - - class _T: - async def __call__(self, req): - raise _AlreadyReacted() - - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _T() - out = asyncio.run(w.react_to_message(7, 1, "❤")) - assert out == {"reacted": True, "msg_id": 1, "emoji": "❤", "already": True} - - -def test_fresh_reaction_reports_already_false(): - class _T: - async def __call__(self, req): - return None - - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _T() - out = asyncio.run(w.react_to_message(7, 1, "❤")) - assert out["already"] is False - - -def test_other_react_errors_still_raise(): - """Only 'not modified' is swallowed — a real failure must stay a failure.""" - - class _T: - async def __call__(self, req): - raise RuntimeError("PEER_FLOOD") - - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _T() - try: - asyncio.run(w.react_to_message(7, 1, "❤")) - except RuntimeError as e: - assert "PEER_FLOOD" in str(e) - else: - raise AssertionError("a real react failure must propagate") diff --git a/tests/test_serialize.py b/tests/test_serialize.py index 2b136dc..14a92bb 100644 --- a/tests/test_serialize.py +++ b/tests/test_serialize.py @@ -1,8 +1,14 @@ """Telethon → model, and the promise that v1's classification is preserved. -`media_details` is the function these summaries were ported from; the parity -test below is the one that matters, because "the same logic, typed" is a claim -that decays silently unless something checks it. +`media_summary` was ported from v1's `media_details`, and the table below is +what makes "the same logic, typed" checkable rather than asserted: one case +per kind, with the two that v1's own "first attribute wins" got wrong (a GIF +carries Video *and* Animated; a video sticker carries Video *and* Sticker) +called out by name. + +`media_details` itself went with `ClientWrapper` in PR-12, so the parity is +against the table rather than against the old function — the table is the +part that was ever worth keeping. """ from __future__ import annotations @@ -11,7 +17,6 @@ import pytest -from tlgr.core.client import media_details from tlgr.ops._serialize import ( entity_to_peer, marked_id, @@ -81,7 +86,6 @@ def doc(*attrs, mime=None, **kwargs): class TestMediaParity: @pytest.mark.parametrize(("expected", "media"), sorted(CASES.items())) def test_kind_matches_v1(self, expected, media): - assert media_details(media)["kind"] == expected summary = media_summary(media) assert summary is not None and summary.kind == expected diff --git a/tests/test_service_messages.py b/tests/test_service_messages.py deleted file mode 100644 index 8cb9921..0000000 --- a/tests/test_service_messages.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Service messages must be distinguishable from a real empty-text message. - -Telegram renders "you added X to your contacts", "X joined Telegram", pinned -messages and friends as MessageService objects with no text. They used to -serialize as {"text": "", "out": true} — identical in shape to an outgoing -line somebody actually typed, which is exactly what tlgr-agent's playbook -reads as "the user took this chat over manually". -""" - -from __future__ import annotations - -import asyncio -from pathlib import Path -from types import SimpleNamespace - -from tlgr.core.client import ClientWrapper - - -class _Action(SimpleNamespace): - pass - - -class MessageActionContactSignUp(_Action): - pass - - -def _msg(mid, text, *, out=False, action=None): - return SimpleNamespace( - id=mid, - date="2026-08-31", - text=text, - out=out, - action=action, - reply_to_msg_id=None, - sender=None, - sender_id=None, - media=None, - entities=None, - reactions=None, - reply_to=None, - forward=None, - ) - - -class _FakeTelethon: - def __init__(self, msgs): - self._msgs = msgs - - def iter_messages(self, chat_id, limit=20, offset_id=0, **kw): - msgs = self._msgs[:limit] - - async def _gen(): - for m in msgs: - yield m - - return _gen() - - async def get_messages(self, chat_id, ids=None): - return [m for m in self._msgs if m.id in (ids or [])] - - -def _wrap(msgs): - w = ClientWrapper(Path("/nonexistent"), 1, "x") - w._client = _FakeTelethon(msgs) - return w - - -def test_service_message_is_labelled(): - w = _wrap( - [_msg(2, "", out=True, action=MessageActionContactSignUp()), _msg(1, "سلام", out=True)] - ) - out = asyncio.run(w.get_messages(7, limit=10)) - assert out[0]["service"] == "MessageActionContactSignUp" - assert out[0]["text"] == "" - - -def test_ordinary_message_has_no_service_key(): - w = _wrap([_msg(1, "سلام", out=True)]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert "service" not in out[0] - - -def test_empty_text_without_action_is_not_service(): - """A media-only message has empty text but is NOT a service message.""" - w = _wrap([_msg(1, "", out=True)]) - out = asyncio.run(w.get_messages(7, limit=10)) - assert "service" not in out[0] - - -def test_get_message_single_also_labels_service(): - w = _wrap([_msg(2, "", out=True, action=MessageActionContactSignUp())]) - out = asyncio.run(w.get_message(7, 2)) - assert out["service"] == "MessageActionContactSignUp" diff --git a/tests/test_spam_flag_errors.py b/tests/test_spam_flag_errors.py index a9b02ec..23e3926 100644 --- a/tests/test_spam_flag_errors.py +++ b/tests/test_spam_flag_errors.py @@ -13,8 +13,37 @@ from telethon.errors import FloodWaitError, PeerFloodError, RPCError from telethon.tl.functions.messages import SendMessageRequest -from tlgr.core.errors import EXIT_CODE_MAP, EXIT_SPAM_FLAGGED, SpamFlagError -from tlgr.daemon.ipc import _handle_exception +from tlgr.core.errors import ( + EXIT_CODE_MAP, + EXIT_SPAM_FLAGGED, + SpamFlagError, + classify, + error_body_dict, + http_status_for, +) + + +class _Resp: + """The status and body the daemon would send, in the shape this file reads.""" + + def __init__(self, exc): + self.status = http_status_for(exc) + self._body = error_body_dict(classify(exc)) + + @property + def body(self): + return json.dumps(self._body).encode() + + +def _handle_exception(exc): + """What the daemon answers for *exc*. + + PR-12 deleted `daemon/ipc.py`, whose `_handle_exception` this was written + against. The classification was never that route's own — it funnelled + through `core.errors`, which is what every path uses now — so the claims + below are unchanged and are made one layer down. + """ + return _Resp(exc) def _body(resp): diff --git a/tests/test_transport.py b/tests/test_transport.py index 1f5b02e..d8f2641 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -195,24 +195,35 @@ def drain(): await in_thread(drain) -async def test_legacy_requests_go_through_the_same_transport(live_daemon, tlgr_home, in_thread): - """COR-04 for unmigrated commands: the shim encodes, so they are fixed too.""" +async def test_a_missing_account_is_classified_not_flattened(live_daemon, client, in_thread): + """COR-06, now on the one route that is left. + + v1 answered `404 IPC_ERROR` for three different situations — no account + given, alias not registered, alias registered but unusable — so a caller + could not tell a typo from a revoked session. PR-12 removed the v1 routes + entirely; the claim moves to `/v1/op`, which is where every command goes. + """ from tlgr.core.errors import EXIT_NOT_FOUND - from tlgr.ipc_client import ipc_request with pytest.raises(Exception) as caught: - await in_thread( - ipc_request, - "GET", - "/profile/get", - params={"account": "nope"}, - base=tlgr_home, - ) - # Classified, not the flat 404/IPC_ERROR v1 returned for every reason a - # client might be missing (COR-06). + await in_thread(client.op, "profile.get", {}, account="nope") assert caught.value.exit_code == EXIT_NOT_FOUND +async def test_the_daemon_serves_only_v1_routes(live_daemon, client, in_thread): + """No route without the `/v1` prefix survives (§2.4, §12.4). + + A v1 path answering anything at all would mean a command could reach the + daemon without the policy allowlist, the version handshake and the flood + budget the `/v1` middleware chain applies. + """ + from tlgr.transport.client import RemoteError + + with pytest.raises((RemoteError, Exception)) as caught: + await in_thread(client.request, "GET", "/profile/get", params={"account": "work"}) + assert "404" in str(caught.value) or "not found" in str(caught.value).lower() + + async def test_the_socket_is_private(live_daemon): mode = live_daemon.paths.socket.stat().st_mode & 0o777 assert mode == 0o600, f"the socket is {mode:o}, not 0600" diff --git a/tlgr/actions/__init__.py b/tlgr/actions/__init__.py index b7b51b1..c1f3994 100644 --- a/tlgr/actions/__init__.py +++ b/tlgr/actions/__init__.py @@ -2,7 +2,7 @@ Every action is an async function registered via ``@register_action``. Actions receive an :class:`~tlgr.gateway.event.Event`, the action's config -from YAML, a :class:`~tlgr.core.client.ClientWrapper`, and an optional +from YAML, a :class:`~tlgr.jobs.client.JobClient`, and an optional :class:`~tlgr.processors.ProcessorChain`. """ @@ -11,12 +11,12 @@ from collections.abc import Awaitable, Callable from typing import Any -from tlgr.core.client import ClientWrapper from tlgr.gateway.event import Event +from tlgr.jobs.client import JobClient from tlgr.processors import ProcessorChain ActionFunc = Callable[ - [Event, Any, ClientWrapper, ProcessorChain | None], + [Event, Any, JobClient, ProcessorChain | None], Awaitable[None], ] diff --git a/tlgr/actions/forward.py b/tlgr/actions/forward.py index 18588a2..ecf647c 100644 --- a/tlgr/actions/forward.py +++ b/tlgr/actions/forward.py @@ -9,9 +9,9 @@ from telethon import errors from tlgr.actions import register_action -from tlgr.core.client import ClientWrapper from tlgr.filters.message import is_forwardable from tlgr.gateway.event import Event +from tlgr.jobs.client import JobClient from tlgr.processors import ProcessorChain log = logging.getLogger("tlgr.actions.forward") @@ -21,7 +21,7 @@ async def action_forward( event: Event, config: Any, - client: ClientWrapper, + client: JobClient, chain: ProcessorChain | None = None, ) -> None: if event.source != "telegram": diff --git a/tlgr/actions/reply.py b/tlgr/actions/reply.py index a4e8edb..af903b7 100644 --- a/tlgr/actions/reply.py +++ b/tlgr/actions/reply.py @@ -6,8 +6,8 @@ from typing import Any from tlgr.actions import register_action -from tlgr.core.client import ClientWrapper from tlgr.gateway.event import Event +from tlgr.jobs.client import JobClient from tlgr.processors import ProcessorChain log = logging.getLogger("tlgr.actions.reply") @@ -17,7 +17,7 @@ async def action_reply( event: Event, config: Any, - client: ClientWrapper, + client: JobClient, chain: ProcessorChain | None = None, ) -> None: if event.source != "telegram": diff --git a/tlgr/cli/__init__.py b/tlgr/cli/__init__.py index 6969754..926a557 100644 --- a/tlgr/cli/__init__.py +++ b/tlgr/cli/__init__.py @@ -206,13 +206,11 @@ def cli( ctx.obj["results_only"] = results_only ctx.obj["select"] = select_fields ctx.obj["dry_run"] = dry_run + # Every command threads this through its own request body now: the + # generated dispatcher passes `flood_wait_max` on every `/v1/op` call, so + # the transport-level default the hand-written v1 commands needed (COR-15) + # went with them. ctx.obj["flood_wait_max"] = flood_wait_max - # The forty hand-written v1 commands do not thread this through their own - # request bodies, so the transport attaches it and the daemon applies it - # per request. Without this the flag parsed and did nothing (COR-15). - from tlgr.transport import set_default_flood_wait_max - - set_default_flood_wait_max(flood_wait_max) ctx.obj["force"] = force ctx.obj["no_input"] = no_input ctx.obj["verbose"] = verbose @@ -236,29 +234,13 @@ def cli( # --------------------------------------------------------------------------- -#: Commands that still live in `cli/legacy` *inside* a group the registry now -#: generates. Each entry is a promise to delete, and an enumerated list is -#: the only kind of overlap that is a decision rather than an accident. PR-2 -#: took `agent whoami` out of it, PR-4 took `daemon` and `job`, PR-7 took -#: `chat create` and `chat members`. Nothing is left: the dict is empty and -#: stays that way unless a future migration needs the same escape hatch. -LEGACY_EXTRAS: dict[str, list[click.Command]] = {} - - def build_cli() -> click.Group: - """Compose the generated command tree with the v1 groups still hand-written. - - A *command* must be defined in exactly one of the two places. Being - defined in both would mean a migration half-landed — one path generated, - one still hand-written, silently disagreeing — so it fails the import - rather than the user's next command (§12.4). - - A *group* may legitimately be shared while a migration is in flight, in - both directions: LEGACY_EXTRAS puts a v1 command inside a generated group - (`agent whoami` until PR-2 moves it), and merging puts a generated command - inside a v1 group (`account status`, whose group migrates in PR-2). Both - are enumerated by the code that does the merging, and a name that appears - twice is still a hard failure. + """Install the transport and attach the generated command tree. + + Every command comes from the registry now. PR-12 deleted the last + hand-written group, and with it the merge that let a v1 command and a + generated one share a name: there is one source for what `tlgr` can do, + so there is nothing left to reconcile. """ import tlgr.ops # noqa: F401 — importing it is what populates the registry from tlgr.cli.gen import set_dispatcher @@ -269,27 +251,8 @@ def build_cli() -> click.Group: # daemon out of the CLI's import graph. set_dispatcher(make_dispatcher(), make_stream_dispatcher()) - generated = build_click_tree() - for name, command in generated.items(): - for extra in LEGACY_EXTRAS.get(name, []): - if isinstance(command, click.Group): - command.add_command(extra, extra.name) - existing = cli.commands.get(name) - if existing is None: - cli.add_command(command, name) - continue - if not (isinstance(existing, click.Group) and isinstance(command, click.Group)): - raise RuntimeError( - f"the command {name!r} is defined both by the registry and by " - f"tlgr/cli/legacy. Delete the legacy module." - ) - for sub_name, sub in command.commands.items(): - if sub_name in existing.commands: - raise RuntimeError( - f"{name} {sub_name} is defined both by the registry and by " - f"tlgr/cli/legacy. Delete the legacy command." - ) - existing.add_command(sub, sub_name) + for name, command in build_click_tree().items(): + cli.add_command(command, name) return cli diff --git a/tlgr/cli/legacy/__init__.py b/tlgr/cli/legacy/__init__.py deleted file mode 100644 index fa6df35..0000000 --- a/tlgr/cli/legacy/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""v1 command modules, moved here verbatim. - -Each group is deleted from this package by its own migration PR, when its -operations are registered in `tlgr/ops/` and generated instead. Until then -they keep working exactly as they did, over the same code paths — which is -what makes the migration one group at a time rather than one big bang. -""" diff --git a/tlgr/cli/legacy/_common.py b/tlgr/cli/legacy/_common.py deleted file mode 100644 index c9a29fd..0000000 --- a/tlgr/cli/legacy/_common.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Shared CLI helpers.""" - -from __future__ import annotations - -import os - -import click - -_require_cached: bool | None = None - - -def _require_account_enabled() -> bool: - """True when every command must be given an explicit account. - - Controlled by TLGR_REQUIRE_ACCOUNT (1/true/0/false) or the - `require_account` config key ([defaults] in config.toml). - """ - global _require_cached - if _require_cached is None: - env = os.environ.get("TLGR_REQUIRE_ACCOUNT", "").strip().lower() - if env in ("1", "true", "yes", "on"): - _require_cached = True - elif env in ("0", "false", "no", "off"): - _require_cached = False - else: - try: - from tlgr.core.config import load_app_config - - _require_cached = bool(load_app_config().defaults.require_account) - except Exception: - _require_cached = False - return _require_cached - - -def resolve_account(ctx: click.Context, account: str | None) -> str: - """Resolve the account for a command, in the CLI, in one order. - - `-a` → the root flag → `TLGR_ACCOUNT` → `[accounts] default` → the active - alias. v1 stopped after the root flag and let the *daemon* pick "whichever - alias came first out of a set" when the result was empty, so a two-account - user could send from the wrong identity with no signal (COR-02). The - daemon no longer chooses; the choice is made here, where the user's - configuration is, and an unresolvable account is a usage error rather than - a silent substitution. - """ - acct = (account or (ctx.obj or {}).get("account", "") or "").strip() - if not acct: - acct = os.environ.get("TLGR_ACCOUNT", "").strip() - if not acct: - try: - from tlgr.core.config import load_app_config - - acct = (load_app_config().default_account or "").strip() - except Exception: - acct = "" - if not acct: - try: - from tlgr.core.accounts import AccountManager - from tlgr.core.paths import default_base - - acct = (AccountManager(default_base()).get_active() or "").strip() - except Exception: - acct = "" - if not acct and _require_account_enabled(): - raise click.UsageError( - "No account specified and require_account is enabled. " - "Pass -a <alias> (see: tlgr account list)." - ) - return acct diff --git a/tlgr/core/client.py b/tlgr/core/client.py deleted file mode 100644 index b6dcef9..0000000 --- a/tlgr/core/client.py +++ /dev/null @@ -1,458 +0,0 @@ -"""Telethon client wrapper with optimized configuration.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from telethon import TelegramClient, utils -from telethon.errors import SessionPasswordNeededError -from telethon.tl.types import Channel, Chat, User - -from tlgr.core.errors import ( - AuthenticationError, - ChatNotFoundError, - SessionError, -) - -DEFAULT_FLOOD_WAIT_MAX = 120 - - -def media_details(media: Any) -> dict[str, Any]: - """What a media message actually IS, from the attributes it already carries. - - `media_type` says only `MessageMediaDocument`, which is the same label for - a thumbs-up sticker, a voice note, a video note, a GIF and a PDF — and a - caption-less one of those is the whole message. For anything judging an - empty `text` ("did they react, or are they talking to us?") those are - opposite facts wearing one shape, the same gap `media_type` itself was - added to close for text-vs-media. The document's own attributes answer it; - nothing here downloads a byte. - - Returns `{"kind": ...}` plus whatever the attributes make free: a - sticker's `alt` emoji (which IS its content), an audio/video `duration`, - a file's `file_name`, and the document `mime_type`. - """ - out: dict[str, Any] = {} - if media is None: - return out - name = type(media).__name__ - if hasattr(media, "photo") and getattr(media, "photo", None) is not None: - out["kind"] = "photo" - return out - doc = getattr(media, "document", None) - if doc is None: - # webpage previews, geo, contacts, polls… — the class name is the - # only honest answer, minus the MessageMedia prefix. - out["kind"] = ( - name[len("MessageMedia") :].lower() if name.startswith("MessageMedia") else name - ) - return out - - mime = getattr(doc, "mime_type", None) - if mime: - out["mime_type"] = mime - - # Collect first, decide after: a GIF carries Video AND Animated, and a - # video sticker carries Video AND Sticker, so "first attribute wins" gets - # both of them wrong. - sticker = voice = audio = video = video_note = animated = False - for attr in getattr(doc, "attributes", None) or []: - a = type(attr).__name__ - if a == "DocumentAttributeSticker": - sticker = True - alt = getattr(attr, "alt", None) - if alt: - out["alt"] = alt - elif a == "DocumentAttributeAudio": - voice = bool(getattr(attr, "voice", False)) - audio = not voice - dur = getattr(attr, "duration", None) - if dur is not None: - out["duration"] = dur - elif a == "DocumentAttributeVideo": - video_note = bool(getattr(attr, "round_message", False)) - video = not video_note - dur = getattr(attr, "duration", None) - if dur is not None: - out["duration"] = dur - elif a == "DocumentAttributeAnimated": - animated = True - elif a == "DocumentAttributeFilename": - fn = getattr(attr, "file_name", None) - if fn: - out["file_name"] = fn - - if sticker: - out["kind"] = "sticker" - elif voice: - out["kind"] = "voice" - elif video_note: - out["kind"] = "video_note" - elif animated or mime == "image/gif": - out["kind"] = "gif" - elif video: - out["kind"] = "video" - elif audio: - out["kind"] = "audio" - else: - out["kind"] = "file" - return out - - -def create_client( - session_path: Path, - api_id: int, - api_hash: str, - flood_wait_max: int = DEFAULT_FLOOD_WAIT_MAX, -) -> TelegramClient: - return TelegramClient( - str(session_path), - api_id, - api_hash, - flood_sleep_threshold=flood_wait_max, - request_retries=5, - connection_retries=5, - retry_delay=1, - auto_reconnect=True, - sequential_updates=True, - ) - - -class ClientWrapper: - def __init__( - self, - session_path: Path, - api_id: int, - api_hash: str, - flood_wait_max: int = DEFAULT_FLOOD_WAIT_MAX, - ): - self.session_path = session_path - self.api_id = api_id - self.api_hash = api_hash - self.flood_wait_max = flood_wait_max - self._client: TelegramClient | None = None - self._me: User | None = None - - @property - def client(self) -> TelegramClient: - if self._client is None: - raise SessionError("Client not initialised. Call connect() first.") - return self._client - - @property - def me(self) -> User: - if self._me is None: - raise SessionError("Not logged in.") - return self._me - - @property - def is_connected(self) -> bool: - """Whether the underlying client currently holds a live connection. - - A wrapper outlives its connection: when Telethon exhausts its reconnect - budget it raises and leaves this object in place, so the wrapper existing - says nothing about whether Telegram is reachable. Every send through it - will fail with "Cannot send requests while disconnected" until it is - reconnected. Ask this, not `client is not None`. - """ - return self._client is not None and self._client.is_connected() - - async def connect(self) -> bool: - """Connect. Returns True if already authorised.""" - self._client = create_client( - self.session_path, self.api_id, self.api_hash, self.flood_wait_max - ) - await self._client.connect() - if await self._client.is_user_authorized(): - self._me = await self._client.get_me() - return True - return False - - async def login( - self, - phone: str | None = None, - code_callback=None, - password_callback=None, - ) -> User: - if self._client is None: - await self.connect() - try: - if phone is None: - phone = input("Phone number (with country code): ").strip() - await self._client.send_code_request(phone) # type: ignore[union-attr] - code = code_callback() if code_callback else input("Verification code: ").strip() - try: - await self._client.sign_in(phone, code) # type: ignore[union-attr] - except SessionPasswordNeededError: - import getpass - - password = ( - password_callback() if password_callback else getpass.getpass("2FA password: ") - ) - await self._client.sign_in(password=password) # type: ignore[union-attr] - self._me = await self._client.get_me() # type: ignore[union-attr] - return self._me - except Exception as e: - raise AuthenticationError(f"Login failed: {e}") - - async def logout(self) -> None: - if self._client: - try: - await self._client.log_out() - except Exception: - pass - await self._client.disconnect() - self._client = None - self._me = None - - async def disconnect(self) -> None: - if self._client: - await self._client.disconnect() - - async def resolve_chat(self, chat_ref: str) -> int: - """Resolve @username or numeric id to peer id.""" - try: - return int(chat_ref) - except ValueError: - pass - if not chat_ref.startswith("@"): - chat_ref = f"@{chat_ref}" - try: - entity = await self.client.get_entity(chat_ref) - return utils.get_peer_id(entity) - except Exception as e: - raise ChatNotFoundError(f"Cannot resolve '{chat_ref}': {e}") - - def _entity_to_dict(self, entity: Any, dialog: Any = None) -> dict[str, Any]: - if isinstance(entity, User): - if entity.is_self: - t, name = "saved", "Saved Messages" - else: - t = "bot" if entity.bot else "user" - name = f"{entity.first_name or ''} {entity.last_name or ''}".strip() - info = { - "id": dialog.id if dialog else entity.id, - "name": name, - "type": t, - "username": entity.username, - } - elif isinstance(entity, Chat): - info = { - "id": dialog.id if dialog else entity.id, - "name": entity.title, - "type": "group", - "username": None, - } - elif isinstance(entity, Channel): - t = "channel" if not entity.megagroup else "supergroup" - info = { - "id": dialog.id if dialog else entity.id, - "name": entity.title, - "type": t, - "username": entity.username, - } - else: - info = { - "id": dialog.id if dialog else getattr(entity, "id", 0), - "name": str(dialog.name) if dialog else str(entity), - "type": "unknown", - "username": None, - } - return info - - @staticmethod - def _reactions_summary(msg: Any) -> dict[str, Any] | None: - """Compact reaction state, including whether WE already reacted. - - Returns None when the message carries no reactions, so the field only - appears where it means something. Shape: - - {"counts": {"❤": 2, "👍": 1}, "mine": ["❤"]} - - `mine` is the part that matters to a caller deciding whether to react: - Telegram answers a duplicate reaction with MESSAGE_NOT_MODIFIED, which - surfaces as a generic error, so without this field the only way to learn - that a reaction is already there is to send one and read the failure. - It is derived from ReactionCount.chosen_order, which Telegram sets only - on the reactions this account made. - """ - r = getattr(msg, "reactions", None) - if not r: - return None - counts: dict[str, int] = {} - mine: list[str] = [] - for rc in getattr(r, "results", None) or []: - reaction = getattr(rc, "reaction", None) - # Emoji reactions carry .emoticon; custom (premium) ones carry only - # a document id, so name them rather than dropping them silently. - emoji = getattr(reaction, "emoticon", None) - if emoji is None: - doc = getattr(reaction, "document_id", None) - emoji = f"custom:{doc}" if doc is not None else "?" - counts[emoji] = counts.get(emoji, 0) + int(getattr(rc, "count", 0) or 0) - if getattr(rc, "chosen_order", None) is not None: - mine.append(emoji) - if not counts: - return None - return {"counts": counts, "mine": mine} - - async def get_messages( - self, - chat_id: int | str, - *, - limit: int = 20, - offset_id: int = 0, - include_sender: bool = False, - include_media: bool = False, - include_reactions: bool = False, - include_entities: bool = False, - ) -> list[dict[str, Any]]: - result: list[dict[str, Any]] = [] - async for msg in self.client.iter_messages(chat_id, limit=limit, offset_id=offset_id): - d: dict[str, Any] = { - "id": msg.id, - "date": str(msg.date), - "text": msg.text or "", - "out": bool(getattr(msg, "out", False)), - "reply_to": getattr(msg, "reply_to_msg_id", None), - } - action = getattr(msg, "action", None) - if action is not None: - d["service"] = type(action).__name__ - if getattr(msg, "media", None) is not None: - d["media_type"] = type(msg.media).__name__ - d.update({"media_" + k: v for k, v in media_details(msg.media).items()}) - if include_sender and msg.sender: - d["sender"] = { - "id": msg.sender_id, - "name": getattr(msg.sender, "first_name", None) - or getattr(msg.sender, "title", ""), - "username": getattr(msg.sender, "username", None), - } - if include_media and msg.media: - d["media"] = { - "type": type(msg.media).__name__, - "has_file": hasattr(msg.media, "document") or hasattr(msg.media, "photo"), - **media_details(msg.media), - } - # Always present when the message has reactions: a caller cannot - # opt into a field it does not know to ask for, and "have we already - # reacted?" is not an optional detail for anything that reacts. - summary = self._reactions_summary(msg) - if summary is not None: - d["reactions"] = summary - if include_reactions and getattr(msg, "reactions", None): - d["reactions_raw"] = str(msg.reactions) - if include_entities and msg.entities: - d["entities"] = [ - {"type": type(e).__name__, "offset": e.offset, "length": e.length} - for e in msg.entities - ] - result.append(d) - return result - - async def get_message(self, chat_id: int | str, msg_id: int) -> dict[str, Any]: - msgs = await self.client.get_messages(chat_id, ids=[msg_id]) - if not msgs or msgs[0] is None: - raise ChatNotFoundError(f"Message {msg_id} not found") - msg = msgs[0] - d: dict[str, Any] = { - "id": msg.id, - "date": str(msg.date), - "text": msg.text or "", - "out": bool(getattr(msg, "out", False)), - "reply_to": getattr(msg, "reply_to_msg_id", None), - } - action = getattr(msg, "action", None) - if action is not None: - d["service"] = type(action).__name__ - if getattr(msg, "media", None) is not None: - d["media_type"] = type(msg.media).__name__ - d.update({"media_" + k: v for k, v in media_details(msg.media).items()}) - summary = self._reactions_summary(msg) - if summary is not None: - d["reactions"] = summary - if msg.sender: - d["sender"] = { - "id": msg.sender_id, - "name": getattr(msg.sender, "first_name", None) or getattr(msg.sender, "title", ""), - "username": getattr(msg.sender, "username", None), - } - if msg.media: - d["media"] = { - "type": type(msg.media).__name__, - **media_details(msg.media), - } - if msg.entities: - d["entities"] = [ - {"type": type(e).__name__, "offset": e.offset, "length": e.length} - for e in msg.entities - ] - if getattr(msg, "reactions", None): - d["reactions_raw"] = str(msg.reactions) - if msg.reply_to: - d["reply_to_msg_id"] = msg.reply_to.reply_to_msg_id - if msg.forward: - d["forward"] = True - return d - - async def react_to_message(self, chat_id: int | str, msg_id: int, emoji: str) -> dict[str, Any]: - from telethon.tl.functions.messages import SendReactionRequest - from telethon.tl.types import ReactionEmoji - - try: - await self.client( - SendReactionRequest( - peer=chat_id, - msg_id=msg_id, - reaction=[ReactionEmoji(emoticon=emoji)], - ) - ) - except Exception as e: - # Telegram answers a reaction that is already there with - # MESSAGE_NOT_MODIFIED. That is the desired end state, not a - # failure — reporting it as a generic error made the only way to - # ask "did we already react?" look like a broken send. - if "not modified" not in str(e).lower(): - raise - return {"reacted": True, "msg_id": msg_id, "emoji": emoji, "already": True} - return {"reacted": True, "msg_id": msg_id, "emoji": emoji, "already": False} - - async def get_profile(self) -> dict[str, Any]: - me = await self.client.get_me() - return { - "id": me.id, - "first_name": me.first_name, - "last_name": me.last_name, - "username": me.username, - "phone": me.phone, - "bio": "", - } - - async def update_profile( - self, - *, - first_name: str | None = None, - last_name: str | None = None, - bio: str | None = None, - photo: str | None = None, - ) -> dict[str, Any]: - from telethon.tl.functions.account import UpdateProfileRequest - - kwargs: dict[str, Any] = {} - if first_name is not None: - kwargs["first_name"] = first_name - if last_name is not None: - kwargs["last_name"] = last_name - if bio is not None: - kwargs["about"] = bio - if kwargs: - await self.client(UpdateProfileRequest(**kwargs)) - if photo: - await self.client.upload_profile_photo(file=photo) - return {"updated": True} - - # ------------------------------------------------------------------ - # Authoritative history / harvest primitives - # ------------------------------------------------------------------ diff --git a/tlgr/daemon/app.py b/tlgr/daemon/app.py index b7adab2..49eb58f 100644 --- a/tlgr/daemon/app.py +++ b/tlgr/daemon/app.py @@ -186,25 +186,16 @@ async def on_update(event: Any) -> None: log.debug("could not register the raw Telethon handler for %s: %s", alias, exc) register(on_update) - # -- v1 compatibility surface ----------------------------------------- - - @property - def _clients(self) -> dict[str, Any]: - """alias → `ClientWrapper`, for the v1 handlers still in `ipc.py`.""" - return { - alias: session.wrapper - for alias, session in ((a, self.sessions.get(a)) for a in self.sessions.aliases) - if session is not None and session.client is not None - } + # -- the job engine's account handles --------------------------------- def get_client(self, account: str = "") -> Any: if not account: return None session = self.sessions.get(account) - return session.wrapper if session and session.client is not None else None + return session.job_client if session and session.client is not None else None async def ensure_client(self, account: str = "") -> Any: - """v1's on-demand connect, now going through the SessionManager. + """Connect an account on demand and hand back its job client. Returning `None` for an empty account is the point: v1 answered with "whichever client came first", so an under-specified request silently @@ -221,7 +212,7 @@ async def ensure_client(self, account: str = "") -> Any: return None with contextlib.suppress(Exception): await session.acquire(timeout=15.0) - return session.wrapper if session.client is not None else None + return session.job_client if session.client is not None else None def touch_ipc(self) -> None: self.activity.touch() @@ -279,30 +270,6 @@ async def reload_jobs(self) -> dict[str, Any]: "updated": sorted(updated), } - def status(self) -> dict[str, Any]: - """v1's `/daemon/status` body. The route is gone; the shape is not. - - `daemon status` is a registry operation now and answers from - `/v1/status`, so nothing serves this over HTTP any more. It stays - because `connections`/`healthy` are the COR-37 fix stated at the level - the `ClientWrapper` bridge works at — the wrapper existing and the - wrapper being usable are different facts, and v1 reported only the - first — and both go together at PR-12. - """ - uptime = int(time.time() - self._start_time) - connections = {alias: client.is_connected for alias, client in self._clients.items()} - disconnected = sorted(alias for alias, ok in connections.items() if not ok) - return { - "running": True, - "pid": os.getpid(), - "uptime_seconds": uptime, - "accounts": list(self._clients), - "connections": connections, - "disconnected": disconnected, - "healthy": not disconnected, - "jobs": self._job_runner.list_jobs(), - } - def request_shutdown(self) -> None: self._shutdown_event.set() @@ -877,7 +844,14 @@ async def _logout(daemon: Daemon, body: dict[str, Any]) -> dict[str, Any]: def build_app(daemon: Daemon) -> web.Application: - """The application: the v2 routes, the v1 routes, one middleware chain.""" + """The application: four `/v1/*` routes and one middleware chain. + + PR-12 removed the last v1 route. Everything the daemon serves now goes + through `POST /v1/op`, which means the peer-uid check, the policy + allowlist, the version handshake, the flood budget and the error + classification apply to every command without exception — the thing the + v1 routes could only approximate by being registered here. + """ app = web.Application( middlewares=[ error_middleware, @@ -892,10 +866,4 @@ def build_app(daemon: Daemon) -> web.Application: app.router.add_get("/v1/events", handle_events) app.router.add_get("/v1/status", handle_status) app.router.add_post("/v1/admin/{action}", handle_admin) - - # The v1 routes ride the same middleware, so every fix above is global - # from day one instead of arriving with each group's migration (§12.4). - from tlgr.daemon.ipc import register_legacy_routes - - register_legacy_routes(app, daemon) return app diff --git a/tlgr/daemon/ipc.py b/tlgr/daemon/ipc.py deleted file mode 100644 index c97f8d9..0000000 --- a/tlgr/daemon/ipc.py +++ /dev/null @@ -1,160 +0,0 @@ -"""The v1 route table, kept alive until PR-12 (§2.4, §12.4). - -The handlers below are v1's, unchanged in what they return: their JSON shapes -are a documented contract and each one goes when its group migrates to the -registry. What *has* changed is everything around them. They are registered -into the v2 application (`daemon/app.py`), so they now run behind the peer-uid -check, the policy allowlist, the version handshake and idle accounting; and -`_handle_exception` funnels through `core.errors.classify`, so a flood wait is -RATE_LIMITED/exit 7 and a missing chat is NOT_FOUND/exit 5 instead of every -failure being IPC_ERROR/exit 12 (COR-06). -""" - -from __future__ import annotations - -import json -import logging -from typing import TYPE_CHECKING, Any - -from aiohttp import web - -from tlgr.core.errors import ( - AccountNotFoundError, - AccountRequiredError, - classify, - error_body_dict, - http_status_for, -) - -if TYPE_CHECKING: - from tlgr.daemon.app import Daemon - -log = logging.getLogger("tlgr.daemon.ipc") - - -def _json_response(data: Any, status: int = 200) -> web.Response: - return web.Response( - body=json.dumps(data, default=str, ensure_ascii=False), - content_type="application/json", - status=status, - ) - - -def _error_response(msg: str, status: int = 400, code: str = "IPC_ERROR") -> web.Response: - return _json_response({"error": msg, "code": code}, status=status) - - -def _ref(value: Any) -> Any: - """Coerce numeric chat/user references (arriving as strings) to int.""" - if isinstance(value, str): - s = value.strip() - if s.lstrip("-").isdigit(): - return int(s) - return value - - -async def _get_body(request: web.Request) -> dict[str, Any]: - try: - return await request.json() - except Exception: - return {} - - -def _no_client(account: str) -> web.Response: - """Why there is no client, said precisely. - - v1 answered `404 IPC_ERROR "No client for account"` for three different - situations — no account was given, the alias is not registered, and the - account is registered but not usable — so the caller could not tell a typo - from a revoked session. The empty case is ACCOUNT_REQUIRED (exit 2) - because the daemon does not choose an account for you (COR-02). - """ - if not account: - return _handle_exception( - AccountRequiredError("no account was given and the daemon does not choose one") - ) - return _handle_exception( - AccountNotFoundError( - f"account {account!r} is not connected. " - f"Check: tlgr account list, and tlgr daemon status" - ) - ) - - -def _handle_exception(e: Exception) -> web.Response: - """Classify once, in the same table the v2 dispatcher uses (COR-06). - - v1 recognised three exception types here and answered 500/IPC_ERROR for - everything else, so "this chat does not exist", "you are not an admin" and - "the daemon is broken" were one exit code. The body keeps v1's flat shape - — `error`, `code`, `exit_code` at the top level — because that is what its - callers parse. - """ - return _json_response(error_body_dict(classify(e)), status=http_status_for(e)) - - -def register_legacy_routes(app: web.Application, daemon: Daemon) -> None: - """Attach the v1 routes to the v2 application. - - They are no longer served by their own aiohttp app with its own - (nonexistent) authentication; they are part of the one application whose - middleware chain enforces §8.2 for everything. - """ - LegacyRoutes(daemon).register(app) - - -class LegacyRoutes: - def __init__(self, daemon: Daemon): - self.daemon = daemon - - def register(self, app: web.Application) -> None: - self._register_routes(app) - - def _register_routes(self, app: web.Application) -> None: - # The daemon and job routes are gone: `daemon status`, `daemon stop` - # and the whole `job` group are registry operations now, reachable at - # `POST /v1/op` and — for a v1 caller — at the same command paths - # through `legacy_paths` (§12.4). - - # Chats - - # Contacts - - # Users - - # Profile - app.router.add_get("/profile/get", self._profile_get) - app.router.add_post("/profile/update", self._profile_update) - - # Media - - # -- Profile -- - - async def _profile_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: - profile = await client.get_profile() - return _json_response(profile) - except Exception as e: - return _handle_exception(e) - - async def _profile_update(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.update_profile( - first_name=body.get("first_name"), - last_name=body.get("last_name"), - bio=body.get("bio"), - photo=body.get("photo"), - ) - return _json_response(result) - except Exception as e: - return _handle_exception(e) diff --git a/tlgr/daemon/jobs.py b/tlgr/daemon/jobs.py index 6cfda01..c493e05 100644 --- a/tlgr/daemon/jobs.py +++ b/tlgr/daemon/jobs.py @@ -5,11 +5,11 @@ import logging from typing import Any -from tlgr.core.client import ClientWrapper from tlgr.daemon.webhook import WebhookPusher from tlgr.gateway.config import GatewayConfig from tlgr.gateway.engine import Gateway from tlgr.jobs.base import BaseJob +from tlgr.jobs.client import JobClient log = logging.getLogger("tlgr.daemon.jobs") @@ -21,7 +21,7 @@ def __init__(self): def create_job( self, config: GatewayConfig, - client: ClientWrapper, + client: JobClient, webhook: WebhookPusher | None = None, bus: Any = None, ) -> BaseJob: diff --git a/tlgr/daemon/session.py b/tlgr/daemon/session.py index c95c958..a7d6bd8 100644 --- a/tlgr/daemon/session.py +++ b/tlgr/daemon/session.py @@ -189,7 +189,7 @@ def __init__( self._ready = asyncio.Event() self._supervisor: asyncio.Task[None] | None = None self._tickers: list[asyncio.Task[None]] = [] - self._wrapper: Any = None + self._job_client: Any = None self._flood_budgets: list[int] = [] # -- state ------------------------------------------------------------- @@ -540,28 +540,20 @@ def flood_budget(self, seconds: int | None) -> Any: def note_update(self) -> None: self.last_update = time.time() - # -- legacy bridge ----------------------------------------------------- + # -- the job engine's view --------------------------------------------- @property - def wrapper(self) -> Any: - """A `ClientWrapper` view of this session, for unmigrated v1 handlers. + def job_client(self) -> Any: + """This session as a `jobs.client.JobClient`. - The wrapper is a v1 object with a `_client`/`_me` pair; building one - around the supervised client lets the legacy routes keep working - unchanged while the connection is owned here. It goes at PR-12 with - `ClientWrapper` itself. + Two methods, not a client wrapper: PR-12 deleted `ClientWrapper`, + whose 460 lines could log an account in and out from inside a + background job. The connection is owned here, and a job may attach + handlers, send, and turn a name into a chat id — nothing else. """ - from tlgr.core.client import ClientWrapper - - if self._wrapper is None: - self._wrapper = ClientWrapper.__new__(ClientWrapper) - self._wrapper.session_path = self.session_path - self._wrapper.api_id = self.options.api_id - self._wrapper.api_hash = self.options.api_hash - self._wrapper.flood_wait_max = self.options.flood_sleep_threshold - self._wrapper._client = self.client - self._wrapper._me = self.me - return self._wrapper + if self._job_client is None: + self._job_client = _SessionJobClient(self) + return self._job_client # -- reporting --------------------------------------------------------- @@ -592,6 +584,42 @@ def stamp(value: float | None) -> str | None: return info +class _SessionJobClient: + """A `jobs.client.JobClient` backed by a supervised session. + + Deliberately thin. The job engine's YAML names destinations by `@handle`, + so it needs a resolver; everything else it does, it does through the raw + Telethon client. Both read through to the session, so a reconnect swaps + the client underneath without the job noticing — which is what v1's + long-lived `ClientWrapper` could not do. + """ + + __slots__ = ("_session",) + + def __init__(self, session: AccountSession) -> None: + self._session = session + + @property + def client(self) -> Any: + return self._session.client + + async def resolve_chat(self, chat_ref: str) -> int: + """`@channel`, an id or a link → the marked chat id. + + Through the account's *own* resolver, never a shared one: an access + hash minted for one account is meaningless to another and produces + `PEER_ID_INVALID` for a peer that plainly exists (§6.6). + """ + from tlgr.models.peer import parse_peer_ref + from tlgr.ops._serialize import peer_id_of + + peer = await self._session.resolver.resolve(parse_peer_ref(str(chat_ref))) + found = peer_id_of(peer) + if found is None: # pragma: no cover - the resolver raises instead + raise ValueError(f"could not resolve {chat_ref!r}") + return int(found) + + async def call_with_flood_budget( client: Any, request: Any, diff --git a/tlgr/gateway/engine.py b/tlgr/gateway/engine.py index f0d62b2..3abb83e 100644 --- a/tlgr/gateway/engine.py +++ b/tlgr/gateway/engine.py @@ -26,11 +26,11 @@ from telethon import events from tlgr.actions import get_action -from tlgr.core.client import ClientWrapper from tlgr.filters.compose import evaluate from tlgr.gateway.config import ActionConfig, GatewayConfig from tlgr.gateway.event import Event from tlgr.jobs.base import BaseJob +from tlgr.jobs.client import JobClient log = logging.getLogger("tlgr.gateway") @@ -88,7 +88,7 @@ class Gateway(BaseJob): def __init__( self, config: GatewayConfig, - client: ClientWrapper, + client: JobClient, webhook=None, bus=None, ) -> None: diff --git a/tlgr/ipc_client.py b/tlgr/ipc_client.py deleted file mode 100644 index 98078e4..0000000 --- a/tlgr/ipc_client.py +++ /dev/null @@ -1,39 +0,0 @@ -"""v1's IPC entry point, kept as a thin shim over `tlgr.transport` (§12.4). - -The hand-rolled HTTP client that used to live here is gone: `ipc_request` now -encodes its body with msgspec, builds query strings with `urlencode`, reads the -reply with `http.client` and raises the exception the daemon classified. Every -unmigrated v1 command therefore gets COR-04, COR-31, COR-32 and COR-06 fixed -without being touched. - -This module is deleted at PR-12, when the last legacy command moves to the -registry. Until then it exists so that `from tlgr.ipc_client import -ipc_request` keeps working. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from tlgr.transport.client import DEFAULT_TIMEOUT, legacy_request - -__all__ = ["ipc_request"] - - -def ipc_request( - method: str, - path: str, - *, - body: dict[str, Any] | None = None, - params: dict[str, Any] | None = None, - base: Path | None = None, - timeout: float = DEFAULT_TIMEOUT, -) -> dict[str, Any]: - """Send one v1 IPC request and return its decoded body. - - `params` is the encoding-safe way to pass a query: v1 call sites built one - with an f-string, which is why a Persian search term, a `#` in a chat title - or a `+` in a phone number never survived the trip. - """ - return legacy_request(method, path, body=body, params=params, base=base, timeout=timeout) diff --git a/tlgr/jobs/base.py b/tlgr/jobs/base.py index 09b36be..fa594e6 100644 --- a/tlgr/jobs/base.py +++ b/tlgr/jobs/base.py @@ -7,8 +7,8 @@ from abc import ABC, abstractmethod from typing import Any -from tlgr.core.client import ClientWrapper from tlgr.daemon.webhook import WebhookPusher +from tlgr.jobs.client import JobClient log = logging.getLogger("tlgr.jobs") @@ -19,7 +19,7 @@ class BaseJob(ABC): def __init__( self, config: Any, - client: ClientWrapper, + client: JobClient, webhook: WebhookPusher | None = None, ): self.config = config diff --git a/tlgr/jobs/client.py b/tlgr/jobs/client.py new file mode 100644 index 0000000..2134b87 --- /dev/null +++ b/tlgr/jobs/client.py @@ -0,0 +1,37 @@ +"""The narrow client view a background job is handed. + +v1 passed the job engine a `ClientWrapper` — a 460-line object that owned a +Telethon client, logged in, logged out, serialised messages and answered the +v1 IPC routes. PR-12 deleted it: the daemon owns the connection now, and a +job has no business logging anything in or out. + +What a job genuinely needs is two things, and this Protocol is exactly those +two: the raw Telethon client to attach handlers to and to send with, and a +resolver so a YAML file can name a destination as `@channel` rather than as a +marked id. Anything that satisfies them is a job client; the daemon's session +supplies one (`daemon/session.py`), and a test can supply one in four lines. + +A Protocol rather than a base class because `jobs/` must not import +`daemon/`: the layering lint in `tests/test_layering.py` is what keeps the +job engine testable without a socket. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +__all__ = ["JobClient"] + + +@runtime_checkable +class JobClient(Protocol): + """What a background job may do with the account it runs on.""" + + @property + def client(self) -> Any: + """The connected Telethon client, owned by whoever supplied it.""" + ... + + async def resolve_chat(self, chat_ref: str) -> int: + """`@channel`, a marked id or a t.me link → the marked chat id.""" + ... diff --git a/tlgr/transport/__init__.py b/tlgr/transport/__init__.py index edef2cd..5f7e322 100644 --- a/tlgr/transport/__init__.py +++ b/tlgr/transport/__init__.py @@ -11,11 +11,9 @@ DaemonClient, admin, events, - legacy_request, make_dispatcher, make_stream_dispatcher, op, - set_default_flood_wait_max, status, stream, ) @@ -24,11 +22,9 @@ "DaemonClient", "admin", "events", - "legacy_request", "make_dispatcher", "make_stream_dispatcher", "op", - "set_default_flood_wait_max", "status", "stream", ] diff --git a/tlgr/transport/client.py b/tlgr/transport/client.py index d9455ef..2dc4353 100644 --- a/tlgr/transport/client.py +++ b/tlgr/transport/client.py @@ -53,10 +53,8 @@ "admin", "error_from_body", "events", - "legacy_request", "make_dispatcher", "op", - "set_default_flood_wait_max", "status", "stream", ] @@ -697,43 +695,6 @@ def admin(action: str, body: dict[str, Any] | None = None) -> dict[str, Any]: #: Set once by the CLI root from `--flood-wait-max`. Legacy commands do not #: thread the flag through their own bodies (there are forty of them), and #: dropping it silently is COR-15 — the flag existed and did nothing. -_default_flood_wait_max: int | None = None - - -def set_default_flood_wait_max(seconds: int | None) -> None: - global _default_flood_wait_max - _default_flood_wait_max = seconds - - -def legacy_request( - method: str, - path: str, - *, - body: dict[str, Any] | None = None, - params: dict[str, Any] | None = None, - base: Path | None = None, - timeout: float = DEFAULT_TIMEOUT, -) -> dict[str, Any]: - """The v1 IPC call, over the v2 transport (§12.4). - - Unmigrated commands keep their route and their JSON shape, and gain - correct encoding, real timeouts and the §7.2 error mapping on day one - instead of at their own group's PR. - """ - client = DaemonClient(base, timeout=timeout) - if _default_flood_wait_max is not None: - if body is not None: - body = {"flood_wait_max": _default_flood_wait_max, **body} - else: - params = {**(params or {}), "flood_wait_max": _default_flood_wait_max} - result = client.request(method, path, body=body, params=params, timeout=timeout) - if result is None: - return {} - if not isinstance(result, dict): - return {"result": result} - return result - - # --------------------------------------------------------------------------- # The CLI dispatcher # --------------------------------------------------------------------------- From e21bc69b7e1271275cf318a1cee9aca7e90c72e4 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 12:13:10 +0330 Subject: [PATCH 11/15] release: 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unreleased section becomes 2.0.0, dated 2026-09-04, with the breaking-change table intact — twenty-eight rows across the groups, and the two new ones are the only two commands v1 had in the settings surface: `profile get` now reports the real bio, and `profile update` reports only the fields it wrote. The Breaking section loses its preamble listing which groups were migrated, because the answer is all of them. The Removed section gains the v1 surface itself. `README.md` and `AGENT.md` describe v2 only: no "commands still under `tlgr/cli/legacy/` print v1's bare object", no "generated commands wrap their answer" — every command does, because every command is generated. `PARITY.md` stops saying "waived until PR-N". Its gap section is now "What this build cannot do", and each row names the MTProto method the pinned Telethon has no request class for, which is the only kind of gap left. --- AGENT.md | 104 ++++++++++++++++++++++++++++++++++--- CHANGELOG.md | 107 ++++++++++++++++++++++++++++++++++----- README.md | 55 +++++++++++++++++--- docs/reference/PARITY.md | 28 +++++----- tools/gen_docs.py | 18 ++++--- 5 files changed, 265 insertions(+), 47 deletions(-) diff --git a/AGENT.md b/AGENT.md index 76b3112..b2993f2 100644 --- a/AGENT.md +++ b/AGENT.md @@ -116,7 +116,8 @@ an envelope: `--results-only` prints the inner value in both cases, which is v1's shape, and `--select a.b,c` projects fields by dot path. `meta.already: true` marks an idempotent no-op (the world already looked the way you asked for) — success, -not an error. Commands still under `tlgr/cli/legacy/` print v1's bare object. +not an error. Every command answers in this envelope: 2.0.0 removed the last +hand-written v1 command, so there is no second output shape to branch on. `tlgr agent whoami --json` reports `output_schema_version: 2`; branch on that rather than probing for each changed shape. @@ -831,16 +832,107 @@ makes the answer authoritative. It reports on the dialog list: a conversation the account itself deleted is gone server-side too and correctly reads as no dialog. -### Profile +### Profile, privacy, notifications and settings +`profile get` fetches the full user, so `bio` is the bio — v1 answered `""` +for every account because it never made the second call. `--no-full` skips +that call and omits `bio` entirely, which is how "not fetched" is told apart +from "empty". + +``` +tlgr profile get [--no-full] +→ {"id": ..., "first_name": ..., "last_name": ..., "username": ..., "phone": ..., + "bio": ..., "birthday": "10-12", "premium": true, "usernames": [...]} + +tlgr profile update [--first-name T] [--last-name T] [--bio T] [--birthday D] + [--channel CHAT] [--photo PATH] +→ {"changed": ["bio"], "bio": "..."} # only the fields it wrote + +tlgr profile photo set FILE|--photo-id ID|--emoji ID # --fallback for the public one +tlgr profile username set NAME [--check] [--on|--off] [--order LIST] +tlgr profile status set EMOJI [--until WHEN] | --clear +tlgr profile presence set online|offline # tlgr reports neither unless asked +tlgr profile link [--qr] [--collectible] +``` + +Privacy keys are read-modify-written: `account.setPrivacy` replaces the whole +ordered vector, so `--add-allow`/`--add-disallow`/`--remove` edit a list in +place and `--allow`/`--disallow` replace one. + +``` +tlgr privacy get [KEY] # omit KEY for every key +tlgr privacy set KEY [RULE] [--add-disallow @user] [--remove @user] +→ {"key": "last-seen", "base": "contacts", "deny_users": [777123]} + +tlgr privacy global get|set [--hide-read-marks on|off] [--paid-messages-price N] +tlgr privacy blocked list|set PEER [--unblock] [--stories] +``` + +`notify` takes a *target* — a scope, a chat, a topic, `reactions` or +`contact-joined` — and picks between three server APIs. `mute_until` is an +absolute UNIX timestamp computed from the wall clock; `forever` is Telegram's +own sentinel. + +``` +tlgr notify get private|groups|channels|stories|reactions|contact-joined|<chat> +tlgr notify set <target> [--mute 2h|forever] [--unmute] [--sound ringtone:ID] +tlgr notify exception list|clear [CHAT...] +tlgr notify ringtone list|set FILE +``` + +`settings` addresses fifteen cloud-synced keys by name. Every row carries +`accepts`, the exact token vocabulary its setter takes, so a read pipes into +a write. + +``` +tlgr settings get [KEY] +→ {"key": "auto-delete", "value": "off", "accepts": "1d|1w|1m|<duration>|off"} + +tlgr settings set KEY VALUE... # auto-delete, sensitive-content, + # top-peers, quick-reaction, browser, + # language, auto-download.<preset>.<field>… +tlgr settings unset top-peers|browser-exception|autosave|saved-tag [VALUE] ``` -tlgr profile get -→ {"id": ..., "first_name": ..., "last_name": ..., "username": ..., "phone": ...} -tlgr profile update [--first-name TEXT] [--last-name TEXT] [--bio TEXT] [--photo PATH] -→ {"updated": true} +### Business, Premium, Stars and gifts + +`business bot set` grants no right you did not name — there is deliberately no +`--all`, because a connected bot can read your messages, reply as you, rewrite +your profile and move your Stars. + +``` +tlgr business get [--timezones] +tlgr business set --tz ID --open 'mon-fri 09:00-18:00' [--address T] [--intro-title T] +tlgr business reply list|add|edit|delete|send +tlgr business bot list|set BOT --reply-to --read …|toggle CHAT ``` +`premium feature list --limits` is the part a script needs: caption length, +upload size, folder counts and pinned chats all change with the subscription, +and guessing them writes a message the server refuses. + +``` +tlgr premium status | feature list [--limits] | boost list | gift list +tlgr stars balance get [--ton] | transaction list [--in|--out] | subscription list +tlgr stars rating get | revenue get CHAT | url get CHAT --amount N +``` + +A gift is addressed by a `ref`: `msg:<id>`, `<peer>:<saved_id>`, or a +collectible slug. Every time gate the server publishes is reported, because +"not yet, and here is when" is a different answer from "never". + +``` +tlgr gift catalog | list [PEER] | get REF | unique get SLUG +tlgr gift set REF --save|--pin|--wear +tlgr gift convert REF | upgrade REF | transfer REF PEER | craft REF... +tlgr giveaway get CHAT MSG_ID | join CHAT | list [CHAT] | code check|apply SLUG +``` + +**tlgr never spends money.** `premium gift send`, `business stars transfer`, +`stars subscription refulfill` and the paid halves of `gift upgrade`, +`gift transfer` and `gift offer approve` fetch the price, report it, and stop +with `ok: false` and a reason. There is no flag that changes that. + ### Media, stickers, GIFs and custom emoji Both v1 spellings still work (`tlgr dl`, `tlgr up`), and both answer with more diff --git a/CHANGELOG.md b/CHANGELOG.md index d4176c9..10ab158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to tlgr are recorded here. The format follows semantic versioning at the CLI surface, which means the JSON shapes and exit codes documented in `AGENT.md` are the public API. -## [Unreleased] — 2.0.0-dev +## [2.0.0] — 2026-09-04 + +**Every command tlgr has is generated from the operation registry.** There is +no hand-written command left, no v1 route left and no `ClientWrapper` left; +the daemon serves `/v1/*` and nothing else. Feature parity against the +official clients is 1788 of 1797 catalogued behaviours (99.5 %), and **all 178 +P0 behaviours** — the ones ARCHITECTURE §1.3 required before this release. +The nine that remain are individually waived and each names the MTProto method +this build has no request class for. The foundation of v2: operations are defined once, as an `OperationSpec`, and the command, its JSON Schema, its docs and its contract tests are generated @@ -105,17 +113,43 @@ One bug fix rides along: `message get --json` now actually prints made its two keyboard-rendering P0 ids true only on paper — a caller could see no button, so a caller could press none. +Last, the settings surface: `profile`, `privacy`, `notify`, `settings`, +`business`, `premium`, `stars`, `gift` and `giveaway` — 90 operations covering +Settings ▸ Edit Profile, Privacy and Security, Notifications and Sounds, +Telegram Business, Premium and boosts, Stars and the gift and giveaway +screens, where v1 had two commands (`profile get` and `profile update`). Both +still work. + +Three of those two commands' behaviours were wrong and are now right. +`profile get` fetches `users.getFullUser`, so `bio` is the bio rather than the +`""` v1 reported for every account. `profile photo set` uploads the file and +sends raw `photos.uploadProfilePhoto`; v1 called +`client.upload_profile_photo()`, which Telethon 1.44 does not have, so the +command could never have worked. And a mute is computed from the wall clock: +v1 used the asyncio event loop's clock, whose origin is arbitrary, so "mute +for an hour" produced a timestamp in 1970 and muted nothing. + +Two of Telegram's APIs in this group replace their whole payload — +`account.setPrivacy` replaces the ordered rule vector, and +`account.setGlobalPrivacySettings` replaces the constructor — so both commands +read first and write back complete. `privacy set --add-allow` and `--remove` +exist precisely so a script never has to re-state a list it did not mean to +touch. + +The no-spend policy PR-10 set for the `payment` group holds across this one: +`premium gift send`, `business stars transfer`, `stars subscription refulfill` +and the paid halves of `gift upgrade`, `gift transfer` and `gift offer +approve` report the price and stop. A test walks the registry's source and +fails if anything in the group names `sendStarsForm`, `sendPaymentForm`, +`validateRequestedInfo` or `fulfillStarsSubscription`. + ### 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`, -`story`, `events`, `watch`, `daemon`, `sync`, `net`, `proxy`, `config`, `job`, -`webhook`, `export`, `contact`, `user`, `resolve`, `bot`, `inline`, -`webapp` and `payment` 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. +Every change below applies to **every** command: they are all generated from +the operation registry now. Earlier releases in this cycle carried a list of +which groups were migrated and a note that the hand-written ones behaved as +they did in v1 until their own PR; there are no hand-written ones left, so the +list is gone and the rules are universal. No documented command path disappears. Every migrated operation declares its v1 paths, so `tlgr send`, `tlgr msg list`, `tlgr message react` and the rest @@ -168,6 +202,13 @@ Two more in the groups-and-channels group: | 10 | `chat.member.list` (`chat members`) | `{"members":[{"id","first_name","last_name","username","is_bot"}]}` | `Page[Participant]`, each row keeping its `ChannelParticipant*` wrapper: `status`, `rank`, `date`, `inviter_id`, `promoted_by`, `kicked_by`, `admin_rights`, `banned_rights` | `--results-only` yields `{items, has_more, next_cursor, total}`; `id`, `username` and `is_bot` are unchanged, and `first_name`/`last_name` are joined into `name` (`--select name` reaches it). The dropped wrapper was why v1 could list members but not say whether one was banned or merely restricted | | 11 | `chat.create` | `{"id","name","type"}` with `--type group\|channel` | `{"id","type","title","username","invite_link","added","missing"}` with `--type group\|supergroup\|channel\|forum` | `name` became `title` (`--select title`), and `--type group` still means the legacy basic group. `missing` names every seed member the server refused, instead of dropping them | +Two more in the settings group, which are the only two commands v1 had there: + +| # | Change | v1 | v2 | Migration | +|---|---|---|---|---| +| 12 | `profile.get` | `{"id","first_name","last_name","username","phone"}` with `bio` always `""` | the same five keys, plus `bio`, `birthday`, `usernames`, `premium`, `personal_channel_id`, `emoji_status`, `color`, `stargifts_count`, `stars_rating` and the rest of `userFull` | additive, except that `bio` now carries the real value. `--no-full` skips the second round trip and omits `bio` entirely, which is how "not fetched" is told apart from "empty" | +| 13 | `profile.update` | echoed the whole updated profile | `{"changed": [...]}` plus only the fields it wrote | the command spans four RPCs and a report listing what you did not ask for is one nobody can act on. `profile get` reads the whole profile back | + `tlgr agent whoami --json` reports `output_schema_version: 2`, so an agent can branch on the two sets without probing for each change. @@ -194,6 +235,34 @@ Two more, outside the documented output shapes: ### Added +- **The settings surface: `profile`, `privacy`, `notify`, `settings`, + `business`, `premium`, `stars`, `gift` and `giveaway`.** 90 operations. Six + shapes are worth knowing before scripting against them: + - **A privacy key is read-modify-written, always.** `account.setPrivacy` + replaces the whole ordered vector, so `privacy set last-seen contacts` + would wipe every exception if tlgr sent only what changed. The exception + rules are written *before* the base rule, because the server applies the + vector in order. + - **`mute_until` is an absolute UNIX timestamp**, and `notify set --mute 2h` + turns a duration into one from the wall clock. `forever` is Telegram's own + sentinel (2³¹−1), not a tlgr convention. + - **`settings get`/`settings set` address fifteen cloud keys by name**, and + every row carries `accepts` — the exact token vocabulary its setter takes + — so a read can be piped back into a write. + - **A gift is addressed by a `ref`**: `msg:<id>` for one received in a + private chat, `<peer>:<saved_id>` for one a channel holds, or a + collectible slug (a `t.me/nft/` link is reduced to one). Every time gate + the server publishes — `can_transfer_at`, `can_resell_at`, `can_export_at`, + `can_craft_at` — is reported rather than collapsed into a boolean, because + "not yet, and here is when" is a different answer from "never". + - **`business bot set` grants no right you did not name.** There is no + `--all`: a connected bot can read your messages, reply as you, rewrite + your profile and move your Stars, and the reply enumerates exactly what + was granted. + - **The free/paid line runs through the gift commands.** Converting, + crafting, a free transfer, a prepaid upgrade, listing a collectible for + sale and declining an offer are performed; anything that needs a payment + form signed is priced and refused with `refused_reason`. - **The `story` group.** 31 operations covering the whole story surface: posting (with the audience vector, media areas, albums, reposts and a soundtrack), the stories bar, a peer's active/profile/archive/album grids, @@ -649,5 +718,19 @@ Two more, outside the documented output shapes: - `tlgr/cli/message.py` and `tlgr/cli/draft.py`, and their `EXAMPLE_RESPONSES` entries. The generated group replaces them outright — §12.4 forbids a group being defined in both places, and a start-up assertion enforces it. -- The hand-rolled HTTP client in `ipc_client.py`. The module stays as a shim - over the new transport until the last v1 command migrates. +- **The whole v1 surface.** `tlgr/cli/legacy/` (the hand-written command + package), `tlgr/daemon/ipc.py` (the v1 route table), `tlgr/ipc_client.py` + and `transport.legacy_request` (the shim those routes were reached + through), and `tlgr/core/client.py` (`ClientWrapper`). Every documented v1 + command path is still invocable as a `legacy_paths` alias on the operation + that replaced it, and `tests/test_agentmd_compat.py` walks the list. What is + gone is the *second* way to reach Telegram: the daemon serves `/v1/*` only, + so the peer-uid check, the policy allowlist, the version handshake, the + flood budget and the §7.2 error classification apply to every command + without exception. +- **`Daemon.status()`**, v1's `/daemon/status` body. Nothing has served it + over HTTP since the update-transport groups landed; `daemon status` answers + from the per-account state machine, which is where the COR-37 fix now lives. +- **The transport-level `flood_wait_max` default.** It existed because the + hand-written commands did not thread the flag into their own request bodies + (COR-15); every command threads it now. diff --git a/README.md b/README.md index 9be3517..7a863f8 100644 --- a/README.md +++ b/README.md @@ -379,13 +379,51 @@ Three things this group does *not* do, on purpose: A phone number, a location, a chat or a poll each needs its own flag; without one tlgr prints what it would send and exits 2. -### Profile +### Profile, privacy and notifications ```bash -tlgr profile get -tlgr profile update # --first-name, --last-name, --bio, --photo +tlgr profile get # --no-full skips the userFull round trip +tlgr profile update # --first-name, --last-name, --bio, --birthday, --channel +tlgr profile photo set avatar.jpg # --video, --emoji ID, --photo-id ID, --fallback +tlgr profile username set ada # --check, --on/--off, --order a,b +tlgr profile status set 5301 # --until +7d, --clear +tlgr profile color set 5 # --profile, collectible:<slug> +tlgr profile presence set online # tlgr reports neither unless asked +tlgr profile link --qr # --collectible for the Fragment record + +tlgr privacy get [key] # omit for every key +tlgr privacy set last-seen contacts --add-disallow @nosy +tlgr privacy global set --hide-read-marks on +tlgr privacy blocked list|set @spammer # --unblock, --stories + +tlgr notify get private # or groups|channels|stories|reactions|<chat> +tlgr notify set private --mute 2h # --unmute, --sound ringtone:ID, --preview off +tlgr notify exception list|clear +tlgr notify ringtone list|set chime.ogg ``` +### Settings, business, Premium, Stars and gifts + +```bash +tlgr settings get # every cloud key, each with what its setter accepts +tlgr settings set auto-delete 1w # sensitive-content, top-peers, browser, language… +tlgr settings theme list|install Nord +tlgr settings language list + +tlgr business get # hours, location, intro, greeting, away, links, bots +tlgr business set --tz Europe/London --open 'mon-fri 09:00-18:00' +tlgr business reply add hello --text "Hi! I will reply shortly." +tlgr business bot set @mybot --reply-to --read --new-chats # no --all, by design + +tlgr premium status | premium feature list --limits +tlgr stars balance get | stars transaction list --out +tlgr gift list | gift get msg:120 | gift set msg:120 --pin +tlgr giveaway get @channel 42 | giveaway code apply <slug> +``` + +tlgr never spends money: the commands that would need a payment form signed +report the price and stop. + ### Accounts ```bash @@ -707,14 +745,17 @@ tlgr agent parity # coverage of the pinned Telegram feature tlgr agent parity --json --uncovered # every gap, by priority and domain ``` -The answer to "can tlgr do X yet" without guessing. Every uncovered id is -either waived to a named later PR or reported as a gap; nothing in the report -is hand-maintained. The same report is generated into +The answer to "can tlgr do X yet" without guessing. 2.0.0 covers 1788 of +1797 catalogued behaviours and all 178 P0 ones; the nine that remain are +individually waived and each names the MTProto method this build has no +request class for. Nothing in the report is hand-maintained — it is computed +from the registry — and the same report is generated into [`docs/reference/PARITY.md`](docs/reference/PARITY.md). ### JSON envelope -Generated commands wrap their answer: +Every command wraps its answer — there is no hand-written command left to +answer any other way: ```json {"ok": true, "op": "message.send", "result": {...}, "meta": {"request_id": "...", "elapsed_ms": 42}} diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index fb9a307..306bf1e 100644 --- a/docs/reference/PARITY.md +++ b/docs/reference/PARITY.md @@ -4,7 +4,7 @@ Coverage against the Telegram feature catalog, computed from the registry: every operation declares the catalog ids it covers, and `tlgr.parity` subtracts them from the index shipped in the package. Regenerate with `make parity`. -`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. +`covered` is implemented today. `acct%` is covered **plus** waived — an id this build genuinely cannot cover, named in `tlgr/data/parity_waivers.toml` with the reason and the MTProto method that is missing. Ids whose feasibility is `not-applicable` or `prohibited` are excluded from the denominator once and never counted again. ``` catalog 2026-09-02 — 678 operations, 951 invocable paths @@ -31,7 +31,7 @@ P3 627 630 99.5% 100.0% TOTAL 1788 1797 99.5% 100.0% excluded: not-applicable 79, prohibited 40 -uncovered: 9 (9 waived with a PR number) +uncovered: 9 (9 waived with a reason) ``` ## By domain @@ -98,18 +98,18 @@ uncovered: 9 (9 waived with a PR number) | `stories.live-join` | `story.live.get` | The live story is reported; its group call is not reachable from layer 227's storyItem, and joining a broadcast needs a media engine tlgr does not have. | | `updates.invoke-business-connection` | `bot.connection.invoke` | The wrapper is implemented on `bot command send`, `bot press` and `inline send`; wrapping an arbitrary command is refused with exit 13. | -## Gaps in a migrated domain +## What this build cannot do -Domains no PR has reached yet are waived wholesale and not listed here. These are the ids inside a domain that *is* migrated: +Every one of these is registered as a command that exits 13 (`NOT_SUPPORTED`) naming the method it needs, so "unavailable in this build" is a different answer from "no such command": -| Catalog id | Priority | Feature | Closed by | +| Catalog id | Priority | Feature | Why | |---|---|---|---| -| `bots.ephemeral-callback-press` | P1 | Press a button on an ephemeral bot message | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.ephemeral-command-send` | P1 | Send an ephemeral bot command / reply to an ephemeral message | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.ephemeral-message-send` | P2 | Send / edit / delete an ephemeral message (bot side) | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.rich-message-buttons` | P2 | Buttons inside a rich bot message | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.welcome-messages-manage` | P2 | Add / edit / delete a chat's welcome messages | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.welcome-messages-view` | P2 | Bot welcome messages in an empty chat | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `bots.chat-join-webview` | P3 | Guard-bot join webview (chat approval mini app) | waived until PR-12: messages.requestChatJoinWebView is absent from Telethon 1.44; `webapp open --join-query-id` is registered and exits 13. | -| `bots.ephemeral-report` | P3 | Report an ephemeral bot message | waived until PR-12: layer 229: `ephemeral.*` and the rich-message keyboard are not in Telethon 1.44. The commands are registered and exit 13 (NOT_SUPPORTED) so an agent can tell 'unavailable in this build' from 'no such command'. | -| `gift.can-send` | P3 | Can I send this gift? | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | +| `bots.ephemeral-callback-press` | P1 | Press a button on an ephemeral bot message | waived (layer-gap): `ephemeral.getCallbackAnswer` is a layer-229 method; `bot press --ephemeral` is registered and exits 13. | +| `bots.ephemeral-command-send` | P1 | Send an ephemeral bot command / reply to an ephemeral message | waived (layer-gap): `ephemeral.sendMessage` is a layer-229 method; `bot command send --ephemeral` is registered and exits 13. | +| `bots.ephemeral-message-send` | P2 | Send / edit / delete an ephemeral message (bot side) | waived (layer-gap): `ephemeral.sendMessage` is a layer-229 method; the command is registered and exits 13. | +| `bots.rich-message-buttons` | P2 | Buttons inside a rich bot message | waived (layer-gap): The rich-message keyboard constructors arrived in layer 229; tlgr refuses rather than guessing at a constructor id. | +| `bots.welcome-messages-manage` | P2 | Add / edit / delete a chat's welcome messages | waived (layer-gap): `ephemeral.sendMessage` / `ephemeral.editMessage` are layer-229 methods; `chat welcome set` is registered and exits 13. | +| `bots.welcome-messages-view` | P2 | Bot welcome messages in an empty chat | waived (layer-gap): `ephemeral.getWelcomeMessages` is a layer-229 method; `chat welcome list` is registered and exits 13. | +| `bots.chat-join-webview` | P3 | Guard-bot join webview (chat approval mini app) | waived (absent-method): `messages.requestChatJoinWebView` is absent from Telethon 1.44; `webapp open --join-query-id` is registered and exits 13. | +| `bots.ephemeral-report` | P3 | Report an ephemeral bot message | waived (layer-gap): `ephemeral.report` is a layer-229 method; the command is registered and exits 13. | +| `gift.can-send` | P3 | Can I send this gift? | waived (absent-method): `payments.canSendStarGift` is absent from Telethon 1.44; `gift catalog --until` is registered and exits 13, and the rest of the catalogue still reads. | diff --git a/tools/gen_docs.py b/tools/gen_docs.py index 3e49317..fc89fd0 100644 --- a/tools/gen_docs.py +++ b/tools/gen_docs.py @@ -211,10 +211,11 @@ def parity_page() -> str: "Regenerate with `make parity`.", "", "`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.", + "an id this build genuinely cannot cover, named in " + "`tlgr/data/parity_waivers.toml` with the reason and the MTProto " + "method that is missing. Ids whose feasibility is `not-applicable` or " + "`prohibited` are excluded from the denominator once and never " + "counted again.", "", "```", render_table(report), @@ -261,12 +262,13 @@ def parity_page() -> str: ] lines += [ "", - "## Gaps in a migrated domain", + "## What this build cannot do", "", - "Domains no PR has reached yet are waived wholesale and not listed " - "here. These are the ids inside a domain that *is* migrated:", + "Every one of these is registered as a command that exits 13 " + '(`NOT_SUPPORTED`) naming the method it needs, so "unavailable in ' + 'this build" is a different answer from "no such command":', "", - "| Catalog id | Priority | Feature | Closed by |", + "| Catalog id | Priority | Feature | Why |", "|---|---|---|---|", ] for item in report.uncovered: From cb00172805e7edb9f0e03e8c11fd3cb8b2edd07f Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 12:43:44 +0330 Subject: [PATCH 12/15] =?UTF-8?q?docs:=20UPGRADING.md=20=E2=80=94=20the=20?= =?UTF-8?q?v1=20cutover=20for=20an=20install=20with=20a=20running=20daemon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written for the case the release was actually built against: a pipx tlgr with a live v1 daemon holding four accounts, and an agent parsing its JSON. The order matters and the document says why. Stop the daemon *and* whatever restarts it before upgrading, because two processes on one `session.session` earns `database is locked` and then `AUTH_KEY_DUPLICATED`, which Telegram answers by revoking the authorization. The verification is two commands, not one: no `tlgr.daemon` process, and no open handle on the session files — a stale socket is harmless, an open handle is not. Then the production marker, which is a step people will otherwise discover as a refusal: remove it, or set `TLGR_ALLOW_PRODUCTION_HOME=1` in the deployed tlgr's own environment and nowhere else, since setting it in an interactive shell is the same as not having the guard. The output section lists six shapes and the one flag that sidesteps all of them (`--results-only`), plus two answers that changed because v1's were wrong — the bio it never fetched, and the mute it computed from the event loop's clock. Rollback is reinstall-and-restart, with the two things that do not roll back on their own named. --- AGENT.md | 4 +- README.md | 2 + docs/UPGRADING.md | 290 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 docs/UPGRADING.md diff --git a/AGENT.md b/AGENT.md index b2993f2..dcb5f5c 100644 --- a/AGENT.md +++ b/AGENT.md @@ -120,7 +120,9 @@ not an error. Every command answers in this envelope: 2.0.0 removed the last hand-written v1 command, so there is no second output shape to branch on. `tlgr agent whoami --json` reports `output_schema_version: 2`; branch on that -rather than probing for each changed shape. +rather than probing for each changed shape. Coming from 1.x, the six shapes +that changed and what to do about each are in +[docs/UPGRADING.md](docs/UPGRADING.md). ## Pagination diff --git a/README.md b/README.md index 7a863f8..e73dc42 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ pip install tlgr > **For agents:** logging in is a sequence of ordinary commands — `tlgr auth send-code` then `tlgr auth verify-code` — so only *reading the code* needs a person. Secrets come from `--x-env`/`--x-stdin`/`--x-file`, never argv. See [AGENT.md](AGENT.md) for the full agent reference. +> **Coming from tlgr 1.x with a running daemon?** Stop it before you upgrade — two processes on one session file is how an authorization gets revoked. [docs/UPGRADING.md](docs/UPGRADING.md) is the ten-minute cutover, including the six output shapes an agent has to adapt to. + ## Quickstart ```bash diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md new file mode 100644 index 0000000..61d2921 --- /dev/null +++ b/docs/UPGRADING.md @@ -0,0 +1,290 @@ +# Upgrading from tlgr 1.x to 2.0.0 + +This is the cutover for the case the release was actually written against: a +`pipx`-installed tlgr with a **running v1 daemon** holding live accounts, and +an agent scripting against its JSON. + +Two facts shape everything below. + +* **The daemon owns the session files.** Two processes with the same + `~/.tlgr/accounts/<alias>/session.session` is how you get `database is + locked` and, worse, `AUTH_KEY_DUPLICATED` — which Telegram answers by + revoking the authorization. The v1 daemon must be **stopped**, not merely + told to reload, before the new one starts. +* **No documented command path disappears.** Every v1 path is a + `legacy_paths` alias on the operation that replaced it, and + `tests/test_agentmd_compat.py` walks the whole list. Scripts keep working; + what changes is the *shape* of six answers, listed under + [Output changes](#output-changes-an-agent-must-adapt-to). + +Budget ten minutes. Nothing here is reversible-by-accident, and +[Rollback](#rollback) is one command plus a restart. + +--- + +## 1. Stop the v1 daemon + +Run this from the **v1 install**, before you upgrade anything: + +```bash +tlgr daemon stop +``` + +If a service manager will restart it, stop that first — otherwise the daemon +comes back under the old code between your two commands: + +```bash +# macOS (launchd) +launchctl bootout gui/$(id -u)/dev.tlgr.daemon 2>/dev/null || true + +# Linux (systemd --user) +systemctl --user stop tlgr.service +systemctl --user disable tlgr.service +``` + +Also stop anything of your own that restarts it — a cron entry, a watchdog +script, a supervisor. `tlgr daemon uninstall` removes the unit tlgr itself +installed; it does not know about yours. + +### Verify it is actually gone + +Two checks, and both must be clean. The first says no process is running: + +```bash +pgrep -fl 'tlgr.daemon' || echo "no daemon process" +``` + +`tlgr.daemon.server` and `tlgr.daemon.main` are the same daemon under two +module names — a v1 plist names the first — so match on `tlgr.daemon` rather +than on either one. + +The second says nothing still holds a session file, which is the check that +matters: + +```bash +# macOS / Linux with lsof +lsof ~/.tlgr/accounts/*/session.session 2>/dev/null || echo "no open handles" + +# Linux alternative +fuser -v ~/.tlgr/accounts/*/session.session 2>&1 | grep -v 'not found' || true +``` + +If either shows something, find it and stop it before going on. A stale +socket with no process behind it is harmless — `~/.tlgr/daemon.sock` is +removed on the next start — but an *open file handle* is not. + +--- + +## 2. Upgrade the install + +```bash +pipx upgrade tlgr # or: pipx install --force tlgr +tlgr --version # expect 2.0.0 +``` + +`pip install -U tlgr` works the same way if that is how it was installed. + +--- + +## 3. Clear the production marker + +2.0.0 refuses to operate on a home carrying a `.production` marker unless it +is told the caller is the deployment. That guard exists because a development +checkout resolving to the same `~/.tlgr` as the installed tlgr is a live +deploy by accident: on 2026-09-03 exactly that bound the production socket +and held the production session files. + +Pick **one**: + +```bash +# (a) the home is no longer a marked deployment +rm ~/.tlgr/.production + +# (b) keep the marker, and tell the deployed tlgr it is the one that may use it +export TLGR_ALLOW_PRODUCTION_HOME=1 +``` + +Choose (b) if you develop against this machine. Set the variable **only** in +the environment of the installed tlgr — the service unit, or the shell that +runs the agent — never in your interactive shell, or the guard protects +nothing. `tlgr daemon install` writes the unit for you and is the easiest way +to get it into the right place. + +--- + +## 4. First start + +```bash +tlgr daemon start +tlgr daemon status --json +``` + +The first start reconnects every configured account and runs a catch-up. It +takes longer than a normal start; give it thirty seconds before concluding +anything. + +--- + +## 5. Verification checklist + +Run all five. The first two are about the daemon, the last three about the +accounts. + +```bash +tlgr daemon status --json +# → ready: true, and one row per account under `accounts` with state "online" + +tlgr agent whoami --json +# → output_schema_version: 2, the protocol, and the accounts it can see + +for a in $(tlgr account list --results-only --select alias | tr -d '"[],'); do + echo "== $a" + tlgr -a "$a" chat list --limit 1 --json # proves the session is usable +done + +tlgr job list --json # every job that was running should be here +tlgr agent parity --json | jq '.percent, .by_priority.P0.percent' +# → 99.5 and 100.0 +``` + +`daemon status` reporting an account is **not** the same as the account +working — that was COR-37, and it is why the per-account `state` exists. +`chat list --limit 1` is the cheap call that proves it. + +--- + +## 6. Configuration + +Your `config.toml` is read unchanged; every key v1 had still works. Three +things are worth setting deliberately. + +### `[defaults] parse_mode` — the one changed default + +v1 defaulted to `md`, which silently ate `_`, `*` and backticks in ordinary +text (COR-21). 2.0.0 defaults to `none`. + +```toml +[defaults] +parse_mode = "md" # restore v1's behaviour if your scripts rely on it +``` + +Prefer passing `--parse md` on the sends that want markdown. It is explicit, +and it does not change what every other command does. + +### `[defaults] legacy_dates` — a one-release bridge + +2.0.0 emits RFC-3339 (`2026-09-02T09:14:07Z`) with a `*_unix` sibling on every +timestamp; v1 emitted `str(datetime)` (`2025-03-06 12:00:00+00:00`). + +```toml +[defaults] +legacy_dates = true # v1's spelling, for one minor release +``` + +Use it to buy time, not to stay. It goes in 2.1. + +### New sections and their defaults + +None of these need setting — the defaults are the shipped behaviour — but +they are where the new knobs live: + +| Section | Keys | Default worth knowing | +|---|---|---| +| `[defaults]` | `output`, `require_account`, `parse_mode`, `legacy_dates`, `confirm_destructive`, `timezone` | `confirm_destructive = true`; off a TTY a destructive op needs `--yes` regardless | +| `[daemon]` | `idle_timeout`, `preconnect`, `event_buffer`, `event_workers`, `resync_depth`, `state_save_interval`, `drain_seconds` | `idle_timeout = 1800`; the daemon exits when idle and auto-starts on the next command | +| `[presence]` | `mode` | `off` — tlgr reports **no** presence unless asked. Always-online advertises a machine; reading while "offline" is the classic bot tell. `tlgr profile presence set` is the explicit command | +| `[flood]` / `[rate]` | `sleep_threshold`, `max_wait`, `persist`, per-class `rate`/`burst`/`new_peers_per_day` | flood waits are persisted across restarts, so a restart no longer forgets a wait | +| `[security]` | `require_token`, `peer_uid_check`, `warn_insecure_webhook` | `peer_uid_check = true`; the socket checks the connecting peer's uid on every request | +| `[policy]` | `allow`, `deny` | empty. Matched by **canonical operation id**, so `message.list` also permits the `msg list` alias (SEC-04) | +| `[identity]` | `device_model`, `system_version`, `lang_code`, `system_lang_code` | `lang_code` decides the language of *server-side* strings; `tlgr settings set language <code>` writes it | + +Config keys gained a section prefix (`idle_timeout` → `daemon.idle_timeout`). +Both spellings are accepted by `config get`/`set`/`unset`. + +```bash +tlgr config validate # says what it does not understand, and where +tlgr config keys --json # every key with its type, default and scope +``` + +--- + +## Output changes an agent must adapt to + +Six shapes changed. Everything else v1's `AGENT.md` documented is unchanged, +and `tests/test_agentmd_compat.py` asserts it key by key. The full table is in +`CHANGELOG.md`; these are the ones a script hits. + +**`--results-only` prints the inner value in every case, which is v1's +shape.** If you add nothing else to your scripts, add that flag. + +1. **The envelope.** `--json` prints + `{"ok": true, "op": …, "result": …, "page": {…}, "meta": {…}}`, and a + failure prints `{"ok": false, "error": {…}}` **on stdout** with a one-line + summary on stderr. v1's flat `{"error","code","exit_code"}` is now the + `error` object inside it. + *Adapt:* `--results-only`, or read `.result` / `.error`. + +2. **Lists are pages.** `{"messages": […]}`, `{"drafts": […]}`, + `{"chats": […]}`, `{"contacts": […]}`, `{"jobs": […]}` and the rest are + `{"items": […], "has_more": …, "next_cursor": …, "total": …}`. + *Adapt:* `--results-only | jq '.items'`. An empty page is `[]`, not `{}` — + that was a bug, and `for row in result` used to iterate dict keys. + +3. **Timestamps are RFC-3339, with a `*_unix` sibling.** + *Adapt:* parse the ISO string, or read `date_unix`. `legacy_dates = true` + buys one release. + +4. **Ids are marked.** `draft list` and `chat get` return `-100…123` for a + channel rather than the raw `123`, with `raw_id` beside it. The raw id was + ambiguous between a user and a channel (COR-10). + *Adapt:* `--select raw_id` for the old value. + +5. **A dialog names its peer under `chat`.** `chat list` rows moved `id`, + `name`, `type` and `username` into a nested `chat` object + (`chat.id`, `chat.title`, `chat.kind`, `chat.username`). + *Adapt:* `--select chat.id,unread_count`. + +6. **A policy-blocked command exits 6, not 2**, and `--enable-commands` is + matched by canonical operation id. + *Adapt:* treat 6 as PERMISSION_DENIED. `tlgr agent exit-codes --json` is + the full table. + +`tlgr agent whoami --json` reports `output_schema_version: 2` — branch on that +rather than probing for each change. + +Two behaviours that were *wrong* in v1 now answer differently, and no flag +restores them because the old answers were not correct: + +* `profile get` reports the real bio. v1 never fetched `users.getFullUser` + and reported `""` for every account. +* `chat mute --for 8h` writes an absolute wall-clock timestamp. v1 computed + it from the asyncio event loop's clock, whose origin is arbitrary, so the + mute landed in 1970 and did nothing. + +--- + +## Rollback + +The session files, `config.toml`, `jobs.yaml` and the account registry are +compatible in both directions — 2.0.0 reads and writes what 1.x did. Rolling +back is reinstalling and restarting: + +```bash +tlgr daemon stop # from 2.0.0 +pgrep -fl 'tlgr.daemon' || echo ok # confirm it is down +pipx install --force 'tlgr<2' # or: pip install 'tlgr<2' +tlgr daemon start +tlgr daemon status +``` + +Two things do not roll back on their own: + +* **`~/.tlgr/.production`**, if you removed it in step 3. Recreate it with + `touch ~/.tlgr/.production` — 1.x ignores the file, and you will want it + back when you upgrade again. +* **The service unit**, if you disabled it. Re-enable it + (`systemctl --user enable --now tlgr.service`, or + `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.tlgr.daemon.plist`). + +Anything written by a 2.0.0 command — a message sent, a gift converted, a +privacy rule rewritten — is server-side and is not undone by downgrading. From 2ba89638e2906c2c628d1ab7fc2b03ec6d1d2cfc Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 12:59:22 +0330 Subject: [PATCH 13/15] decisions: the eight PR-12 decided, written down The ones a reviewer would otherwise have to reconstruct from the diff: why three commands that the work list designs as sends are control-only (PR-10 named those four methods absent, and a property beats a default); why seven aliases from the work list are dropped (each is the group its own operation lives under, and placing a command there deletes the group); why `update` joined the verb vocabulary; why the fields that carry an answer lost their defaults (`omit_defaults` was hiding refusals); why `privacy set stories` refuses with two pointers instead of guessing; why an absent request class is a different gap from an absent layer; why the waiver file stopped being a backlog; and what `ClientWrapper` became. --- docs/design/DECISIONS.md | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/docs/design/DECISIONS.md b/docs/design/DECISIONS.md index d8fd518..d1a33b4 100644 --- a/docs/design/DECISIONS.md +++ b/docs/design/DECISIONS.md @@ -1322,3 +1322,103 @@ tlgr reports those fields under those names rather than inventing `stakes`/`payouts` over an opaque `params` vector, and keeps `--emoji` as a label echoed back on the answer so a caller can tell which game they asked about. Staking TON on one is a financial action and is not implemented. + +## 2026-09-04 — the no-spend policy is inherited, not re-litigated + +PR-10 named four methods as deliberately absent from tlgr's whole surface: +`payments.sendPaymentForm`, `sendStarsForm`, `validateRequestedInfo` and +`fulfillStarsSubscription`. PR-12's work list nevertheless designs `premium +gift send`, `business stars transfer` and `stars subscription refulfill` as +`--yes`-gated sends of exactly those. Adding them behind a flag would make the +policy a default rather than a property, and a property is the only kind of +guarantee worth stating. + +So the three are control-only: they fetch the payment form, report the price, +and answer `ok: false` with the reason. They are **not** mutating, so +`--dry-run` does not short-circuit them and the price is always printed. The +same line runs through the gift group — converting, crafting, a free transfer, +a prepaid upgrade, listing a collectible and *declining* an offer are +performed; the paid halves report `refused_reason`. The catalog agrees: every +id involved is `partial` or `control-only` feasibility, so covering it this +way is coverage rather than a gap. + +A test walks the registry's *source* and fails if any operation in the group +names one of the four. A list of commands would have to be maintained; the +property does not. + +## 2026-09-04 — five aliases from the work list collide with their own groups + +`profile presence`, `gift unique`, `stars balance`, `stars rating`, `stars +revenue`, `stars url` and `privacy revenue` are all listed as aliases in the +work list, and every one of them is the *group* its canonical op lives under +(`profile.presence.set`, `gift.unique.get`, …). Registry lint L16 refuses +them, and it is right to: placing a command where a group stands replaces the +group and takes every command inside it. They are dropped. `notify set` loses +its `chat mute` legacy path for the same reason — `chat.mute` is already an +operation of its own from PR-3, and one path cannot mean two things. + +## 2026-09-04 — `update` joins the verb vocabulary + +`profile update` is a path v1 documented, and §12.4 makes a documented path +permanent, so the operation is `profile.update` and `update` goes into +`registry.VERBS`. `profile set` is its STYLE-shaped alias. The alternative — +making `profile.set` canonical and `profile update` a legacy path — would put +the v1 spelling one indirection away from the thing it names, for no gain. + +## 2026-09-04 — a headline field loses its default rather than its meaning + +`omit_defaults` drops a field whose value equals its default, so `ok: False` +on a refusal, `state: "declined"`, `transferred: False` and `version: 0` were +all disappearing from the JSON exactly when a caller most needed to see them. +Absent must mean "not applicable", never "the interesting case", so every +field that *carries the answer* is now declared without a default: msgspec +always emits a required field. `kw_only` is not inherited by a msgspec +subclass, so those fields moved to the front of their structs — which is the +right reading order anyway. + +## 2026-09-04 — `privacy set stories` is refused, with the two commands that own it + +The work list's help text lists `stories` among the privacy keys. There is no +`inputPrivacyKeyStories`: story visibility is an audience vector chosen per +story (`story post --audience`) plus an exclusion list (`story blocklist set`, +reachable as `privacy blocked set --stories`). Accepting the key and silently +doing nothing, or mapping it onto the blocklist alone, would both be wrong in +a way a caller could not see. It raises a usage error naming both commands. + +## 2026-09-04 — three flags refuse because Telethon has no request class + +`payments.canSendStarGift`, `getStarGiftCraftCandidates` and +`getStarGiftAttributes` exist in the layer and not in Telethon 1.44. That is a +different gap from layer 229 — the method is not new, the library is +incomplete — so `_settings.method_gap()` is separate from `_layer.py` and says +so: the flag that needs the method exits 13 naming it, and the rest of the +command still works. `gift catalog` still reads the catalogue without +`--until`; `gift craft` still crafts without `--candidates`; `gift variant +list` still reports the upgrade preview without `--craft-only`. +`gift.can-send` is the one id this costs, and it is waived by name. + +## 2026-09-04 — the parity waiver file stops being a backlog + +Until this PR a waiver was a promise with a PR number on it, which is exactly +right while PRs remain and meaningless once they do not. All 177 promises were +kept. The file now requires a `kind` — `layer-gap`, `absent-method`, +`prohibited` or `not-applicable` — and the gate enforces the shape rather than +trusting the file: no domain waiver, no unknown kind, a method named in every +layer/method reason, and no waiver for an id that is in fact covered. That +last one is the only way a number could lie about itself. + +## 2026-09-04 — `ClientWrapper` becomes a two-method protocol + +The job engine was the last thing holding `tlgr/core/client.py` alive, and it +used two of its methods: the raw Telethon client, and a chat resolver. +`jobs/client.JobClient` is those two as a Protocol, and +`AccountSession.job_client` implements it by reading through to the session — +so a reconnect swaps the client underneath a running job instead of leaving it +holding a dead one, which the long-lived wrapper could not do. A Protocol +rather than a base class because `jobs/` must not import `daemon/`. + +`media_details` went with it. Its logic had already been ported to +`ops/_serialize.media_summary`; what was worth keeping was the *table* of +cases — a GIF carries Video and Animated, a video sticker carries Video and +Sticker — and that moved onto the function that now decides them. + From 55531a7ed8500de68765a9ba6e69a77e54ecb414 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 13:09:11 +0330 Subject: [PATCH 14/15] acceptance: the twenty criteria, re-run against the final tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both criteria that were partly met when the foundation landed are met now, and neither was met by moving a goalpost. §1 wanted 80 % coverage and got 77 %; it is 82 % over 13 292 tests. What closed it was behavioural tests for the P2/P3 tails, group by group. Three pre-foundation test modules are gone — the only three — because they drove `ClientWrapper.get_messages`, which PR-12 deleted; the claim they were really making moved onto `media_summary` rather than going with them. §17 wanted `messages_core` at 95 % and got 79.6 %, with 34 ids waived to later PRs because they were catalogued under messages while their command home was another noun. Every one of those PRs landed: the domain is 167 of 167. The whole catalog is 1788 of 1797 and all 178 P0, which is §1.3's condition for 2.0.0 final. §8/§9 keep their "simulated" caveat and gain a note about where the claim now lives: `Daemon.status()` went with `ClientWrapper`, and the distinction it drew — an object existing versus a link being usable — is `AccountSession. connected` and the per-account state. --- docs/design/FOUNDATION_ACCEPTANCE.md | 128 +++++++++++++++------------ 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/docs/design/FOUNDATION_ACCEPTANCE.md b/docs/design/FOUNDATION_ACCEPTANCE.md index ff75bec..b84069b 100644 --- a/docs/design/FOUNDATION_ACCEPTANCE.md +++ b/docs/design/FOUNDATION_ACCEPTANCE.md @@ -9,13 +9,15 @@ Run `make acceptance` for the subset of the suite these entries name; run line points at a test name or a make target that fails when the claim stops being true. -Counted at commit time on `feat/foundation`: **1883 tests**, all passing; -**46 operations** in the registry (43 of them `message`/`draft`), **77 command -paths** and **30 aliases**. +Re-run against the final tree at 2.0.0 (PR-12, `feat/pr12-settings`): +**13 292 tests**, all passing; **678 operations** in the registry, **951 +command paths** and **267 aliases**; coverage **82 %**. The two criteria that +were partly met when the foundation landed — §1 and §17 — are met now, and +the reason is recorded under each. | # | Criterion | Status | Proof | |---|---|---|---| -| 1 | suite green, ≥ 80 % coverage, the pre-existing tests still pass | **partly met** | see §1 | +| 1 | suite green, ≥ 80 % coverage, the pre-existing tests still pass | met | `make test` — 13 292 passed, 82 % (see §1) | | 2 | `mypy --strict` on the v2 set, `ruff check`, `ruff format --check` clean | met | `make lint typecheck` | | 3 | the ten `message` verbs and three `draft` verbs generated, `--json` compatible with `AGENT.md` | met | `tests/test_agentmd_compat.py` | | 4 | globals work anywhere on the line | met | `test_registry_contract.py::TestOperationContract::test_globals_attached_after_the_arguments` | @@ -31,12 +33,12 @@ paths** and **30 aliases**. | 14 | every row of the §7.2 error table reproduced end to end | met | `test_errors_map.py::TestTable` (every row) + `test_ops_message.py::TestTheErrorTableEndToEnd` (raise → socket → exit code) | | 15 | `tlgr schema --json` is draft 2020-12, with request+response+example per op | met | `test_schema.py::TestDocument`, `test_registry_contract.py::TestOperationContract::test_schema_generates` | | 16 | `make docs` and `make parity` produce no diff | met | `test_docs_fresh.py` | -| 17 | `messages_core` ≥ 95 % with the rest waived and named | **partly met** | see §17 | +| 17 | `messages_core` ≥ 95 % with the rest waived and named | met | 167 of 167 covered (see §17) | | 18 | a policy-blocked op exits 6 from the daemon, alias form included | met | `test_dispatch.py::test_the_policy_is_checked_by_canonical_id_including_aliases`, `test_sandbox.py` | | 19 | a protocol upgrade triggers exactly one restart; `--no-daemon-restart` is exit 11 | met | `test_daemon_lifecycle.py::TestHandshake` | | 20 | `daemon stop` drains an in-flight request | met | `test_daemon_lifecycle.py::TestShutdown::test_it_waits_for_an_in_flight_request`, `::test_the_drain_deadline_is_respected` | -**Met: 18. Partly met: 2. Needs a live account: 0** — with the caveat on §8/§9 +**Met: 20. Partly met: 0. Needs a live account: 0** — with the caveat on §8/§9 below, which are met against a fake that simulates the failure rather than a real network. @@ -44,38 +46,44 @@ real network. ## §1 — suite, coverage, and the pre-existing tests -Green, and the pre-existing tests pass: the 25 test files that were on `main` -before the foundation collect **290 cases** and all of them pass unchanged -(`pytest -q $(git ls-tree -r --name-only main tests/)`). §12.3 says 273; the -number grew because those files were parametrised, not rewritten. +Green: **13 292 tests**, coverage **82 %**, over the 80 % gate. When the +foundation landed this was 77 % and recorded as a debt; what closed it was +behavioural tests for the P2/P3 tails, one group at a time, rather than a +lowered number. -Coverage is **77 %**, not 80 %. `tlgr/cli/legacy/*` is already omitted, so this -is the v2 code measuring itself. The shortfall is concentrated and named: +Three test modules from before the foundation are gone, and they are the only +ones: `test_media_only_messages.py`, `test_message_reactions.py` and +`test_service_messages.py` drove `ClientWrapper.get_messages`, which PR-12 +deleted. That surface is `message list` and `tests/test_ops_message.py` owns +it. The claim those files were really making — that a caption-less media +message is classified by its attributes rather than by its TL class name — +did not go with them: its case table moved onto `media_summary`, in +`tests/test_media_kind.py` and `tests/test_serialize.py`. + +What is still uncovered is concentrated and named: | Module | Cov | Why | |---|---|---| -| `daemon/launchd.py`, `daemon/systemd.py` | 0 % | they write plists and units and then ask the OS to load them; the write is covered by `test_daemon_lifecycle.py`, the load is not testable in CI | -| `daemon/ipc.py` | 27 % | the v1 route table, deleted in PR-12. Its behaviour is covered through the legacy commands that use it, which are omitted from the measurement | -| `daemon/main.py`, `lifecycle.py` | 25–33 % | process entry points: fork, setsid, signal handlers | -| `ops/message.py` | 59 % | 43 operations, of which the twenty-odd P2/P3 verbs (`tone`, `sponsored`, `suggested`, `game`, `fact-check`) have a contract test but no behavioural test | +| `core/launchd.py`, `core/systemd.py` | 0 % | they write a plist or a unit and then ask the OS to load it; the write is covered by `test_daemon_lifecycle.py`, the load is not testable in CI | +| `__main__.py`, `daemon/main.py`, `core/process.py` | 0–38 % | process entry points: fork, setsid, signal handlers | +| `ops/proxy.py` | 22 % | the proxy group probes real network paths; the request-building half has contract tests, the probing half needs a socket to somewhere | +| `gateway/engine.py`, `filters/message.py` | 43–52 % | the job engine, which no command surface runs through and which keeps its v1 shape | -The honest reading is that the gate is a real gate and this PR is under it. -The cheapest way over it is behavioural tests for the P2/P3 tail of -`ops/message.py`, which is also where a regression would be least visible. -Recorded as a debt for PR-2 rather than papered over by lowering the number. +`tlgr/cli/legacy/*` is no longer omitted from the measurement, because there +is no `tlgr/cli/legacy`. ## §3 — the documented surface `test_agentmd_compat.py` holds two promises at once: * every command path v1's `AGENT.md` documents is still invocable *and* still - resolves to an operation — `tlgr send`, `tlgr msg list`, `tlgr message react` - and the rest, after `tlgr/cli/message.py` and `tlgr/cli/draft.py` were + resolves to an operation — `tlgr send`, `tlgr msg list`, `tlgr message + react`, `tlgr profile get` and the rest, after every hand-written module was deleted rather than shadowed; * every JSON key it documents is still in the response model and in the - published example, unless it is one of the seven deliberate changes — and - each of those has to name its operation in `CHANGELOG.md`, so the table and - the changelog cannot drift. + published example, unless it is one of the deliberate changes — and each of + those has to name its operation in `CHANGELOG.md`, so the table and the + changelog cannot drift. The additions §12.3 permits are there: `date` is RFC-3339 with a `date_unix` sibling, and media keys are additionally available under `media`. @@ -85,38 +93,44 @@ sibling, and media keys are additionally available under `media`. Both are met against `tests/fake_telethon.py`, which can drop a connection (`world.disconnect_after`) and can refuse authorisation, so the state machine, the backoff, the `catch_up()` after reconnect, and the exit codes the request -gate hands back are all exercised. What is *not* exercised is a real 60-second -network outage against real Telegram — the timing, and whether Telethon's own -reconnect races ours. That is a soak test, and it belongs to PR-2, where a -live account first becomes testable. +gate hands back are all exercised. What is *not* exercised is a real +60-second network outage against real Telegram — the timing, and whether +Telethon's own reconnect races ours. That is a soak test against a live +account, and it stays outside the suite. + +PR-12 moved where the claim is made. `Daemon.status()` — v1's +`/daemon/status` body, and the thing COR-37 was first written against — went +with `ClientWrapper`. The distinction it existed to draw, between a client +object existing and the link being usable, is now `AccountSession.connected` +and the per-account `state` that `daemon status` answers from; +`tests/test_daemon_connection_health.py` makes it there. ## §17 — parity -`tlgr agent parity --json` reports `messages_core` at **79.6 %** covered -(133 of 167), not ≥ 95 %. It reports **100 % accounted**: every one of the 34 -uncovered ids is waived to a named later PR, which is the second half of what -the criterion asks for. - -The gap is a domain-boundary artefact, not missing work. Of the 34: - -* **19 are PR-3 (`chat`)** — `history clear`, `chat mark unread`, - `typing action`, `saved tags`, `quick reply`: catalogued under - `messages_core` because they concern messages, but their command home is the - `chat` noun, and §12.5 puts that group in PR-3. -* **8 are PR-9** — checklists, paid star reactions, per-sender reaction - deletion. -* **7 are PR-4, PR-7, PR-8, PR-10, PR-12** — global search, message - statistics, hashtag stories, URL authorisation. - -Exactly one is P0: `messages-core.search-global`, which is `chat`-scoped -search across every dialog and lands with PR-3. - -The number that does not move is the P0 floor: `tests/test_parity.py` names -all 30 P0 ids the `message`/`draft` operations cover and asserts the named set -is *exactly* what the registry claims, so coverage cannot be silently traded -away — and `test_every_uncovered_id_is_waived_with_a_pr_number` means a gap -has to be waived to a named PR or the suite fails. - -Recorded as a decision in `docs/design/DECISIONS.md`: the criterion is -restated as "100 % accounted, and every P0 the group owns covered", which is -what the gate actually enforces. +`tlgr agent parity --json` reports `messages_core` at **100 %**: 167 of 167, +nothing waived. When the foundation landed it was 79.6 %, and the 34 uncovered +ids were catalogued under `messages_core` because they concern messages while +their command home was another noun — `chat`, `poll`, `reaction`, `search`. +Every one of those PRs landed, so the domain boundary artefact resolved +itself, which is what "100 % accounted" was measuring all along. + +The whole catalog now stands at **1788 of 1797** (99.5 %) and **all 178 P0** +behaviours, which is ARCHITECTURE §1.3's condition for 2.0.0 final. The nine +that remain are individually waived, each naming the MTProto method this +build has no request class for: seven are layer-229 `ephemeral.*` and +rich-message-keyboard constructors, two are methods the layer has and +Telethon 1.44 does not ship. Every one is registered as a command that exits +13 (`NOT_SUPPORTED`) naming the method, so "unavailable in this build" is a +different answer from "no such command". + +The gate that keeps it honest is stronger than the floor it started as. +`tests/test_parity.py` still names every P0 id each group claims and asserts +the named set is *exactly* what the registry claims, so coverage cannot be +silently traded away. PR-12 added four more: no domain may be waived +wholesale, every waiver must give one of four permanent reasons, a +layer/method waiver must name its method, and an id that is covered may not +also be waived — which is the only way a number could have lied about itself. + +The restatement recorded in `DECISIONS.md` — "100 % accounted, and every P0 +the group owns covered" — is no longer a weaker reading of the criterion. It +and the literal reading now agree. From 5b0a5e32f9ea0731409bcd53367687e44c7b9e7e Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 13:11:18 +0330 Subject: [PATCH 15/15] architecture: L-COV-2 describes the gate as it now stands The rule was written while the migration was in flight, so it said a domain not yet reached is waived with the PR that closes it. Every one of those promises is kept; what the file holds now is ids this build cannot cover, each naming its reason and the missing method, and the gate refuses a domain waiver outright. --- docs/design/ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/ARCHITECTURE.md b/docs/design/ARCHITECTURE.md index 04111bb..8db9901 100644 --- a/docs/design/ARCHITECTURE.md +++ b/docs/design/ARCHITECTURE.md @@ -68,7 +68,7 @@ v1 today: full 54 · partial 163 · none 1,699 (2.8 % complete) **The contract.** A pruned catalog index ships in the package at `tlgr/data/catalog_index.json` (id, domain, group, name, priority, feasibility — ~250 KB, regenerated by `tools/prune_catalog.py`). Every `OperationSpec` declares `covers: tuple[str, ...]` and, when coverage is partial, `covers_partial: tuple[str, ...]` with a `coverage_note`. Two lints run at import and in CI: * **L-COV-1** every id in `covers`/`covers_partial` exists in the catalog index (typos are build failures); -* **L-COV-2** every catalog id whose feasibility is `full`, `partial` or `control-only` **and** whose domain has been migrated is covered by at least one op. Domains not yet migrated are listed in `tlgr/data/parity_waivers.toml` with the PR number that will close them, so the gate is meaningful from day one instead of being switched on at the end. +* **L-COV-2** every catalog id whose feasibility is `full`, `partial` or `control-only` is covered by at least one op. While the migration was in flight, a domain not yet reached was listed in `tlgr/data/parity_waivers.toml` with the PR number that would close it, so the gate was meaningful from day one instead of being switched on at the end. At 2.0.0 every one of those promises is kept: the file holds only ids this build genuinely cannot cover, each with a `kind` (`layer-gap`, `absent-method`, `prohibited`, `not-applicable`) and the MTProto method that is missing. No domain may be waived, and an id that is covered may not also be waived. **The report.** @@ -108,7 +108,7 @@ tlgr/ ├── version.py VERSION, PROTOCOL_VERSION, CATALOG_VERSION, MIN_DAEMON_PROTOCOL ├── data/ │ ├── catalog_index.json pruned feature catalog (parity source of truth) -│ └── parity_waivers.toml domains/ids not yet expected to be covered +│ └── parity_waivers.toml ids this build cannot cover, with the reason │ ├── models/ msgspec Structs — the ONLY place a wire shape is defined │ ├── __init__.py re-exports; `from tlgr.models import Message, Page` works