From 5a0238563e22a027d957f1f428acb1ff289366cd Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 00:43:56 +0330 Subject: [PATCH 01/10] models: the bot, inline, mini-app and payment shapes Four modules of wire shapes for PR-10. Two carry the weight: Keyboard is the write side of the reply-markup schema whose read side already lived in models/message.py, so one JSON document round-trips between reading a keyboard and pressing or re-sending a button in it; and PaymentForm carries payable_here=false with the reason, because a caller deciding about a payment needs the shape and needs to be told, in the payload, that tlgr will not pay. --- tlgr/models/__init__.py | 140 ++++++++++ tlgr/models/bot.py | 565 ++++++++++++++++++++++++++++++++++++++++ tlgr/models/inline.py | 81 ++++++ tlgr/models/payment.py | 171 ++++++++++++ tlgr/models/webapp.py | 89 +++++++ 5 files changed, 1046 insertions(+) create mode 100644 tlgr/models/bot.py create mode 100644 tlgr/models/inline.py create mode 100644 tlgr/models/payment.py create mode 100644 tlgr/models/webapp.py diff --git a/tlgr/models/__init__.py b/tlgr/models/__init__.py index e52e6cd..16e4c18 100644 --- a/tlgr/models/__init__.py +++ b/tlgr/models/__init__.py @@ -95,6 +95,53 @@ WebSessionRevocation, ) from tlgr.models.base import UNSET, Model, Request, Unset, decode, encode, to_builtins +from tlgr.models.bot import ( + AttachMenuBot, + BotAccess, + BotAnswer, + BotApiResult, + BotCommand, + BotCommandSet, + BotCreated, + BotEdited, + BotIds, + BotInfo, + BotPermission, + BotQuery, + BotRef, + BotStarted, + BotStopped, + BotToken, + BotUsernameCheck, + BotUsernames, + BotVerification, + BotVerified, + BotWelcomeMessage, + BusinessConnection, + CommandSent, + DefaultRights, + EmojiGame, + EphemeralDeleted, + EphemeralSent, + GameSent, + HighScore, + Keyboard, + KeyboardButton, + MenuButton, + Pressed, + PreviewChange, + PreviewMedia, + RecentBots, + ReportOutcome, + ScoreSet, + SponsoredRead, + StarRefProgram, + StreamProgress, + ToggledAttachMenu, + UrlAuth, + WelcomeDeleted, + WelcomeSet, +) from tlgr.models.call import ( MEDIA_NONE, ActiveCall, @@ -259,6 +306,13 @@ TakeoutSession, TakeoutStatus, ) +from tlgr.models.inline import ( + InlineEdited, + InlineResult, + InlineSent, + PreparedMessage, + PreparedSaved, +) from tlgr.models.location import ( GeoPoint, LiveLocation, @@ -363,6 +417,19 @@ SyncCursors, ) from tlgr.models.page import Page, PageInfo +from tlgr.models.payment import ( + BankCard, + Invoice, + InvoiceLink, + InvoiceSent, + PaymentForm, + PaymentInfo, + PaymentInfoCleared, + PriceLine, + Receipt, + StarSubscription, + SubscriptionChange, +) from tlgr.models.peer import ( Chat, Peer, @@ -457,6 +524,13 @@ SyncStatus, ) from tlgr.models.todo import Todo, TodoTask +from tlgr.models.webapp import ( + WebAppDownload, + WebAppInfo, + WebAppProlong, + WebAppSent, + WebAppSession, +) __all__ = [ "MEDIA_NONE", @@ -478,6 +552,7 @@ "AppConfigDoc", "ArchiveResult", "ArchiveSettings", + "AttachMenuBot", "AutoDownloadPreset", "AutoDownloadSaved", "AutoDownloadSettings", @@ -489,6 +564,7 @@ "AvailableReaction", "BackfillPage", "Badge", + "BankCard", "BlockResult", "BlockedPeer", "BlockedSet", @@ -497,6 +573,27 @@ "Boost", "BoostApplied", "BoostStatus", + "BotAccess", + "BotAnswer", + "BotApiResult", + "BotCommand", + "BotCommandSet", + "BotCreated", + "BotEdited", + "BotIds", + "BotInfo", + "BotPermission", + "BotQuery", + "BotRef", + "BotStarted", + "BotStopped", + "BotToken", + "BotUsernameCheck", + "BotUsernames", + "BotVerification", + "BotVerified", + "BotWelcomeMessage", + "BusinessConnection", "Button", "CachedPeerRow", "Call", @@ -531,6 +628,7 @@ "ChatlistUpdates", "ClearResult", "CloseFriends", + "CommandSent", "CommunityResult", "CommunityRow", "ComposeResult", @@ -564,6 +662,7 @@ "DeadLetter", "DeadLetterResult", "DecodedEvent", + "DefaultRights", "DeleteChatResult", "DeleteResult", "DeviceLock", @@ -580,9 +679,12 @@ "DraftCleared", "EditResult", "Effect", + "EmojiGame", "EmojiGroup", "EmojiKeyword", "EntityReport", + "EphemeralDeleted", + "EphemeralSent", "ErrEnvelope", "ErrorBody", "EventBusStatus", @@ -606,6 +708,7 @@ "FoundPeer", "GameInfo", "GameScore", + "GameSent", "GeoPoint", "GifResult", "GifSaved", @@ -623,22 +726,31 @@ "GroupCallSettings", "GroupCallStarted", "HealthSummary", + "HighScore", "ImportState", "ImportedPhone", "InCallMessage", "InCallMessagesDeleted", "InfoTopic", "InitResult", + "InlineEdited", + "InlineResult", + "InlineSent", "Invite", "InviteDeleted", "InviteInfo", "InvitePeek", "InviteRevoked", + "Invoice", + "InvoiceLink", + "InvoiceSent", "Job", "JobState", "JobTestFrame", "JoinRequest", "JoinResult", + "Keyboard", + "KeyboardButton", "LeaveResult", "LifecycleResult", "LinkKind", @@ -666,6 +778,7 @@ "MediaSummary", "MemberResult", "MembersAdded", + "MenuButton", "Message", "MessageEntity", "MessageRange", @@ -708,6 +821,9 @@ "PassportVerification", "PasswordReset", "PasswordState", + "PaymentForm", + "PaymentInfo", + "PaymentInfoCleared", "Peer", "PeerKind", "PeerRef", @@ -729,6 +845,12 @@ "PollVoter", "Poster", "PosterReport", + "PreparedMessage", + "PreparedSaved", + "Pressed", + "PreviewChange", + "PreviewMedia", + "PriceLine", "ProfilePhoto", "Promo", "PromoData", @@ -749,12 +871,15 @@ "ReadChats", "ReadReceipts", "ReadResult", + "Receipt", + "RecentBots", "RecentResult", "ReconnectResult", "ReconnectedAccount", "RecoveryEmail", "ReplyHeader", "ReplyMarkup", + "ReportOutcome", "ReportResult", "Request", "RequestResult", @@ -774,6 +899,7 @@ "SavedPhoneContact", "SavedState", "ScheduledSent", + "ScoreSet", "SecretChat", "SendAsPeer", "SendAsResult", @@ -793,7 +919,10 @@ "SmsJobs", "SponsoredHidden", "SponsoredMessage", + "SponsoredRead", "SponsoredReport", + "StarRefProgram", + "StarSubscription", "StatValue", "StealthMode", "Sticker", @@ -825,6 +954,8 @@ "StoryViews", "StreamChannel", "StreamDownload", + "StreamProgress", + "SubscriptionChange", "SuggestedBirthday", "SuggestedFolder", "SuggestedPostResult", @@ -842,6 +973,7 @@ "ThemeResult", "Todo", "TodoTask", + "ToggledAttachMenu", "Tone", "TopPeer", "TopPeerState", @@ -862,6 +994,7 @@ "UnreadResult", "Unset", "Uploaded", + "UrlAuth", "User", "UserLink", "UserProfile", @@ -882,13 +1015,20 @@ "WallpaperResult", "WallpaperSettings", "WallpaperUploaded", + "WebAppDownload", + "WebAppInfo", + "WebAppProlong", + "WebAppSent", + "WebAppSession", "WebPagePreview", "WebSession", "WebSessionRevocation", "WebhookProbe", "WebhookSettings", + "WelcomeDeleted", "WelcomeMessage", "WelcomeResult", + "WelcomeSet", "decode", "encode", "parse_message_link", diff --git a/tlgr/models/bot.py b/tlgr/models/bot.py new file mode 100644 index 0000000..2bbfc46 --- /dev/null +++ b/tlgr/models/bot.py @@ -0,0 +1,565 @@ +"""The bot surface's wire shapes: profile cards, buttons, queries and games. + +Two of these shapes carry more weight than the rest. + +`Keyboard`/`KeyboardButton` are the **write** side of the reply-markup schema +whose read side is `models.message.ReplyMarkup`. One JSON document therefore +round-trips: `message get --json` prints a keyboard, `bot press --button` can +address a button in it by the `n` that listing printed, and `bot welcome set +--keyboard` can send the same document back. Two schemas for one object is how +a button that can be read stops being a button that can be pressed. + +`Pressed` is deliberately one model for every button kind rather than a union. +A caller pressing a button does not know in advance whether the answer is a +toast, a URL, a mini-app session or a list of inline results — that is what +the bot decides — so `kind` names what came back and the rest of the fields +are the ones that kind fills in. +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.models.base import Model + +__all__ = [ + "AttachMenuBot", + "BotAccess", + "BotAnswer", + "BotApiResult", + "BotCommand", + "BotCommandSet", + "BotCreated", + "BotEdited", + "BotIds", + "BotInfo", + "BotPermission", + "BotQuery", + "BotRef", + "BotStarted", + "BotStopped", + "BotToken", + "BotUsernameCheck", + "BotUsernames", + "BotVerification", + "BotVerified", + "BotWelcomeMessage", + "BusinessConnection", + "CommandSent", + "DefaultRights", + "EmojiGame", + "EphemeralDeleted", + "EphemeralSent", + "GameSent", + "HighScore", + "Keyboard", + "KeyboardButton", + "MenuButton", + "Pressed", + "PreviewChange", + "PreviewMedia", + "RecentBots", + "ReportOutcome", + "ScoreSet", + "SponsoredRead", + "StarRefProgram", + "StreamProgress", + "ToggledAttachMenu", + "UrlAuth", + "WelcomeDeleted", + "WelcomeSet", +] + + +# --------------------------------------------------------------------------- +# The reply-markup write side +# --------------------------------------------------------------------------- + + +class KeyboardButton(Model): + """One button, in the schema `--keyboard`/`--buttons` files use. + + `type` is the same vocabulary `models.message.Button.type` prints, so a + button that was read back can be written out again unchanged. + """ + + text: str + type: str = "text" + #: callback payload; UTF-8 text, or `hex:…` for bytes that are not text. + data: str | None = None + url: str | None = None + query: str | None = None + user_id: int | None = None + requires_password: bool = False + same_peer: bool = False + copy_text: str | None = None + button_id: int | None = None + fwd_text: str | None = None + request_write_access: bool = False + + +class Keyboard(Model): + """A whole reply markup: rows of buttons plus the keyboard's own flags.""" + + kind: str = "inline" + rows: list[list[KeyboardButton]] = [] + resize: bool = False + single_use: bool = False + selective: bool = False + persistent: bool = False + placeholder: str | None = None + + +# --------------------------------------------------------------------------- +# Profile +# --------------------------------------------------------------------------- + + +class BotCommand(Model): + """One slash command a bot declares.""" + + bot_id: int = 0 + command: str = "" + description: str = "" + ephemeral: bool = False + scope: str | None = None + lang: str | None = None + #: The GUI's "Bot Help" / "Bot Settings" entries exist only when the bot + #: declares `/help` and `/settings`; reporting them as always-true would + #: put two dead menu items in front of the user. + has_help: bool = False + has_settings: bool = False + + +class MenuButton(Model): + """The button left of the message input: commands, a mini app, or default.""" + + kind: str = "commands" + text: str | None = None + url: str | None = None + user_id: int | None = None + + +class BotVerification(Model): + """A third-party verification badge, next to Telegram's own `verified`.""" + + verified_by_bot: int | None = None + description: str | None = None + icon: int | None = None + telegram_verified: bool = False + + +class StarRefProgram(Model): + """A bot's affiliate (star-ref) program, or my connection to one.""" + + bot_id: int = 0 + url: str | None = None + commission_permille: int = 0 + duration_months: int | None = None + end_date: str | None = None + end_date_unix: int | None = None + participants: int | None = None + revenue: int | None = None + date: str | None = None + date_unix: int | None = None + revoked: bool = False + + +class BotAccess(Model): + """Who may use a managed bot.""" + + restricted: bool = False + allowed_users: list[int] = [] + allowed_chats: list[int] = [] + + +class BotInfo(Model): + """A bot's profile card, as `userFull.bot_info` and the user flags carry it.""" + + id: int = 0 + username: str | None = None + usernames: list[str] = [] + first_name: str | None = None + about: str | None = None + description: str | None = None + description_photo: int | None = None + description_document: int | None = None + privacy_policy_url: str | None = None + commands: list[BotCommand] = [] + menu_button: MenuButton | None = None + app_settings: dict[str, Any] | None = None + verifier_settings: dict[str, Any] | None = None + bot_verification: BotVerification | None = None + bot_info_version: int | None = None + bot_active_users: int | None = None + bot_can_edit: bool = False + bot_has_main_app: bool = False + bot_nochats: bool = False + bot_business: bool = False + bot_attach_menu: bool = False + bot_inline_geo: bool = False + inline_placeholder: str | None = None + bot_group_admin_rights: list[str] = [] + bot_broadcast_admin_rights: list[str] = [] + has_preview_medias: bool = False + starref_program: StarRefProgram | None = None + blocked: bool = False + access: BotAccess | None = None + lang: str | None = None + + +class BotRef(Model): + """A row in any of the four bot listings.""" + + id: int = 0 + username: str | None = None + title: str | None = None + kind: str = "bot" + active_users: int | None = None + #: Non-Premium accounts get a shortened similar-bots list plus a count. + truncated_count: int | None = None + rating: float | None = None + + +class BotIds(Model): + """The same peer in both id dialects.""" + + mtproto_id: int = 0 + bot_api_id: int = 0 + kind: str = "user" + has_access_hash: bool = False + username: str | None = None + + +class BotUsernameCheck(Model): + username: str = "" + available: bool = False + reason: str | None = None + + +class BotUsernames(Model): + bot_id: int = 0 + usernames: list[str] = [] + + +class BotCreated(Model): + bot_id: int = 0 + username: str = "" + manager: int | None = None + token_available: bool = False + + +class BotEdited(Model): + bot_id: int = 0 + name: str | None = None + about: str | None = None + description: str | None = None + lang: str | None = None + photo_id: int | None = None + + +class BotToken(Model): + """A managed bot's credential. Redacted unless the caller asked to see it.""" + + bot_id: int = 0 + token: str | None = None + revoked: bool = False + path: str | None = None + + +class DefaultRights(Model): + group_rights: list[str] = [] + channel_rights: list[str] = [] + + +class BotPermission(Model): + bot_id: int = 0 + can_send_messages: bool = False + emoji_status_allowed: bool = False + key: str | None = None + state: str | None = None + already: bool = False + + +class BotVerified(Model): + peer_id: int = 0 + verified: bool = False + description: str | None = None + + +class PreviewMedia(Model): + index: int = 0 + kind: str = "photo" + date: str | None = None + date_unix: int | None = None + lang: str | None = None + file_id: int | None = None + size: int | None = None + + +class PreviewChange(Model): + index: int | None = None + kind: str | None = None + lang: str | None = None + order: list[int] = [] + deleted: int = 0 + remaining: int = 0 + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +class BotStarted(Model): + bot_id: int = 0 + chat_id: int = 0 + msg_id: int = 0 + start_param: str | None = None + admin_rights: list[str] = [] + unblocked: bool = False + + +class BotStopped(Model): + bot_id: int = 0 + blocked: bool = False + history_deleted: int = 0 + + +class CommandSent(Model): + chat_id: int = 0 + msg_id: int = 0 + text: str = "" + via_bot: str | None = None + + +class BotCommandSet(Model): + scope: str = "default" + lang: str = "" + commands: list[BotCommand] = [] + cleared: bool = False + + +# --------------------------------------------------------------------------- +# Buttons +# --------------------------------------------------------------------------- + + +class Pressed(Model): + """What pressing a button produced. `kind` says which fields are filled.""" + + kind: str = "" + row: int | None = None + col: int | None = None + n: int | None = None + text: str | None = None + message: str | None = None + alert: bool = False + url: str | None = None + native_ui: bool = False + cache_time: int | None = None + query_id: str | None = None + copy_text: str | None = None + user: dict[str, Any] | None = None + peers: list[int] = [] + results: list[dict[str, Any]] = [] + sent_message_id: int | None = None + auth: dict[str, Any] | None = None + + +class UrlAuth(Model): + """A seamless-login request, inspected or completed.""" + + result: str = "" + bot: str | None = None + domain: str | None = None + verified_app_name: str | None = None + is_app: bool = False + browser: str | None = None + platform: str | None = None + ip: str | None = None + region: str | None = None + request_write_access: bool = False + request_phone_number: bool = False + match_codes: bool = False + match_codes_first: bool = False + user_id_hint: int | None = None + url: str | None = None + code_valid: bool | None = None + write_allowed: bool = False + phone_shared: bool = False + declined: bool = False + + +# --------------------------------------------------------------------------- +# Bot-side plumbing +# --------------------------------------------------------------------------- + + +class BotQuery(Model): + """One pending query out of the daemon's bot-update buffer.""" + + query_id: str = "" + kind: str = "" + user_id: int | None = None + peer_id: int | None = None + msg_id: int | None = None + inline_msg_id: str | None = None + data: str | None = None + query: str | None = None + payload: str | None = None + answered: bool = False + expires_at: str | None = None + message: dict[str, Any] | None = None + + +class BotAnswer(Model): + query_id: str = "" + kind: str = "" + answered: bool = False + + +class BotApiResult(Model): + """An opaque `DataJSON` reply, passed through verbatim.""" + + method: str = "" + result: Any = None + + +class BusinessConnection(Model): + connection_id: str = "" + user_id: int | None = None + dc_id: int | None = None + date: str | None = None + date_unix: int | None = None + rights: list[str] = [] + disabled: bool = False + result: Any = None + + +class StreamProgress(Model): + chat_id: int = 0 + draft_id: int = 0 + chunks_sent: int = 0 + stopped: bool = False + + +# --------------------------------------------------------------------------- +# Attachment menu, ads, games +# --------------------------------------------------------------------------- + + +class AttachMenuBot(Model): + bot_id: int = 0 + username: str | None = None + short_name: str | None = None + peer_types: list[str] = [] + inactive: bool = False + request_write_access: bool = False + show_in_attach_menu: bool = False + show_in_side_menu: bool = False + side_menu_disclaimer_needed: bool = False + + +class ToggledAttachMenu(Model): + bot_id: int = 0 + installed: bool = False + write_allowed: bool = False + + +class RecentBots(Model): + enabled: bool = True + kind: str = "pm" + forgotten: list[int] = [] + + +class SponsoredRead(Model): + random_id: str = "" + viewed: bool = False + clicked: bool = False + + +class ReportOutcome(Model): + """One step of the report option tree, or its end.""" + + result: str = "" + title: str | None = None + options: list[dict[str, str]] = [] + reported: bool = False + + +class EmojiGame(Model): + """`messages.getEmojiGameInfo`, as the method actually answers. + + The parameters the work list called "stakes and payouts" arrive as one + opaque `params` vector plus the caller's own streak; reporting invented + field names over them would be a schema nobody could check against the + server. `ton_enabled` is false whenever the server says the game is + unavailable — and staking TON is a financial action tlgr does not perform + either way. + """ + + emoticon: str = "" + available: bool = False + game_hash: str | None = None + prev_stake: int | None = None + current_streak: int | None = None + params: list[int] = [] + plays_left: int | None = None + ton_enabled: bool = False + + +class GameSent(Model): + chat_id: int = 0 + msg_id: int = 0 + game_id: int | None = None + short_name: str = "" + + +class HighScore(Model): + position: int = 0 + user_id: int = 0 + score: int = 0 + + +class ScoreSet(Model): + user_id: int = 0 + score: int = 0 + position: int | None = None + + +# --------------------------------------------------------------------------- +# Layer-229 shapes tlgr models but cannot yet call +# --------------------------------------------------------------------------- + + +class EphemeralSent(Model): + chat_id: int = 0 + ephemeral_id: int = 0 + receiver_id: int | None = None + anchor: bool = False + + +class EphemeralDeleted(Model): + chat_id: int = 0 + deleted: int = 0 + dismissed: bool = False + + +class BotWelcomeMessage(Model): + id: int = 0 + text: str = "" + entities: list[dict[str, Any]] = [] + reply_markup: Keyboard | None = None + media: dict[str, Any] | None = None + + +class WelcomeSet(Model): + chat_id: int = 0 + id: int = 0 + text: str = "" + + +class WelcomeDeleted(Model): + chat_id: int = 0 + deleted: int = 0 diff --git a/tlgr/models/inline.py b/tlgr/models/inline.py new file mode 100644 index 0000000..356789c --- /dev/null +++ b/tlgr/models/inline.py @@ -0,0 +1,81 @@ +"""Inline mode: what a bot answers a query with, and what sending one produces. + +`InlineResult` flattens the two constructors Telegram uses — `botInlineResult` +(a URL and a `WebDocument` thumbnail the client has to fetch) and +`botInlineMediaResult` (a `Photo`/`Document` already on Telegram) — into one +row, because the difference is about where the bytes live and not about what +the caller is choosing between. `content` names which of the two it was, so a +caller that does care can still tell. + +`query_id` travels on every row on purpose: it is only valid *paired* with a +result id, and only for `cache_time` seconds, so a row that carried the id +alone would be a row that cannot be sent. +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.models.base import Model + +__all__ = [ + "InlineEdited", + "InlineResult", + "InlineSent", + "PreparedMessage", + "PreparedSaved", +] + + +class InlineResult(Model): + """One result out of `messages.getInlineBotResults`.""" + + #: Flat row-major index within the page — what `inline send --pick` takes. + n: int = 0 + id: str = "" + type: str = "" + title: str | None = None + description: str | None = None + url: str | None = None + thumb: str | None = None + #: `url` for a `botInlineResult`, `media` for a `botInlineMediaResult`. + content: str = "url" + #: The kind of message this result would send: text, media_auto, geo, + #: venue, contact, invoice, webpage, game or rich. + send_message: str | None = None + query_id: str = "" + doc_id: int | None = None + photo_id: int | None = None + gallery: bool = False + cache_time: int | None = None + next_offset: str | None = None + switch_pm: dict[str, Any] | None = None + switch_webview: dict[str, Any] | None = None + + +class InlineSent(Model): + chat_id: int = 0 + msg_id: int = 0 + result_id: str = "" + via_bot_id: int | None = None + quick_reply: str | None = None + + +class InlineEdited(Model): + inline_msg_id: str = "" + edited: bool = False + + +class PreparedMessage(Model): + """A message a mini app prepared for the user to share.""" + + query_id: str = "" + result: InlineResult | None = None + peer_types: list[str] = [] + cache_time: int | None = None + expires_at: str | None = None + + +class PreparedSaved(Model): + id: str = "" + expires_at: str | None = None diff --git a/tlgr/models/payment.py b/tlgr/models/payment.py new file mode 100644 index 0000000..f767085 --- /dev/null +++ b/tlgr/models/payment.py @@ -0,0 +1,171 @@ +"""Payments, read-only by design. + +tlgr models the whole checkout surface — the invoice, the form, the receipt, +the subscription — and implements every verb that does *not* move money. +`PaymentForm.payable_here` is therefore always false and carries the reason: +the shape a caller needs in order to decide is here, the button that would +charge is deliberately not, and saying so in the payload is better than +leaving a caller to discover it from an exit code. + +Amounts are integers in the smallest unit of `currency`, exactly as Telegram +sends them (`XTR` means Telegram Stars, whose smallest unit is one Star). +Rounding them into a float here would lose money in the last decimal on every +currency that has three of them. +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.models.base import Model + +__all__ = [ + "BankCard", + "Invoice", + "InvoiceLink", + "InvoiceSent", + "PaymentForm", + "PaymentInfo", + "PaymentInfoCleared", + "PriceLine", + "Receipt", + "StarSubscription", + "SubscriptionChange", +] + + +class PriceLine(Model): + label: str = "" + amount: int = 0 + + +class Invoice(Model): + """The invoice itself: what is being charged for, and what it needs.""" + + currency: str = "" + total_amount: int = 0 + prices: list[PriceLine] = [] + test: bool = False + name_requested: bool = False + phone_requested: bool = False + email_requested: bool = False + shipping_address_requested: bool = False + flexible: bool = False + recurring: bool = False + terms_url: str | None = None + subscription_period: int | None = None + max_tip_amount: int | None = None + suggested_tip_amounts: list[int] = [] + + +class PaymentForm(Model): + """A checkout form, read without paying for it.""" + + form_kind: str = "form" + form_id: int = 0 + bot_id: int | None = None + provider_id: int | None = None + title: str | None = None + description: str | None = None + photo: str | None = None + invoice: Invoice | None = None + currency: str = "" + total_amount: int = 0 + prices: list[PriceLine] = [] + tip_amounts: list[int] = [] + recurring: bool = False + terms_url: str | None = None + subscription_period: int | None = None + url: str | None = None + native_provider: str | None = None + native_params: Any = None + additional_methods: list[dict[str, Any]] = [] + saved_info: dict[str, Any] | None = None + saved_credentials: list[dict[str, Any]] = [] + can_save_credentials: bool = False + password_missing: bool = False + expires_at: str | None = None + #: Always false. `reason` says which policy refuses to charge here. + payable_here: bool = False + reason: str = "" + + +class Receipt(Model): + date: str | None = None + date_unix: int | None = None + bot_id: int | None = None + provider_id: int | None = None + title: str | None = None + description: str | None = None + invoice: Invoice | None = None + currency: str = "" + total_amount: int = 0 + tip_amount: int | None = None + credentials_title: str | None = None + shipping: dict[str, Any] | None = None + info: dict[str, Any] | None = None + transaction_id: str | None = None + recurring: bool = False + refunded: bool = False + + +class PaymentInfo(Model): + """My saved order information and saved cards. Never a card number.""" + + has_saved_credentials: bool = False + credentials: list[dict[str, Any]] = [] + saved_info: dict[str, Any] | None = None + name: str | None = None + phone: str | None = None + email: str | None = None + shipping: dict[str, Any] | None = None + has_saved_info: bool = False + cleared: bool = False + + +class PaymentInfoCleared(Model): + credentials_cleared: bool = False + info_cleared: bool = False + + +class InvoiceLink(Model): + url: str = "" + slug: str = "" + + +class InvoiceSent(Model): + chat_id: int = 0 + msg_id: int = 0 + slug: str | None = None + currency: str = "" + total_amount: int = 0 + + +class BankCard(Model): + title: str = "" + open_urls: list[dict[str, str]] = [] + + +class StarSubscription(Model): + id: str = "" + peer: int | None = None + until_date: str | None = None + until_date_unix: int | None = None + pricing: dict[str, int] | None = None + cancelled: bool = False + #: Re-joining a lapsed subscription debits Stars, so tlgr reports that the + #: server would allow it and still refuses to do it. + can_refulfill: bool = False + missing_balance: bool = False + invoice_slug: str | None = None + chat_invite_hash: str | None = None + title: str | None = None + photo: str | None = None + + +class SubscriptionChange(Model): + subscription_id: str | None = None + user_id: int | None = None + charge_id: str | None = None + cancelled: bool = False + until_date: str | None = None diff --git a/tlgr/models/webapp.py b/tlgr/models/webapp.py new file mode 100644 index 0000000..60afc22 --- /dev/null +++ b/tlgr/models/webapp.py @@ -0,0 +1,89 @@ +"""Mini apps: the manifest, the signed session URL, and the session's lifetime. + +`WebAppSession.url` is a short-lived *credential*, not a link: it carries the +user's signed init data, and anyone holding it can act as the app on that +user's behalf until it expires. It is therefore printed once, with that +warning in human output, and never opened — tlgr has no browser and hosting +the `window.Telegram.WebApp` bridge is not a CLI's job. + +`needs_prolong` exists because the two request families differ in a way that +is invisible from the URL: a session that came back with a `query_id` dies in +about a minute unless `webapp watch` keeps prolonging it, and one that did not +simply has no session to lose. +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.models.base import Model + +__all__ = [ + "WebAppDownload", + "WebAppInfo", + "WebAppProlong", + "WebAppSent", + "WebAppSession", +] + + +class WebAppInfo(Model): + """A mini app's manifest, as `messages.botApp` and `botAppSettings` hold it.""" + + bot: str | None = None + short_name: str | None = None + title: str | None = None + description: str | None = None + photo: int | None = None + document: int | None = None + inactive: bool = False + request_write_access: bool = False + has_settings: bool = False + terms_url: str | None = None + privacy_policy_url: str | None = None + link: str | None = None + installed_in_attach_menu: bool = False + installed_in_side_menu: bool = False + #: The placeholder is an SVG-like path blob; its length is reported + #: rather than its bytes, because nothing on a terminal can render it. + placeholder_path: int | None = None + bg_color: int | None = None + bg_dark_color: int | None = None + header_color: int | None = None + header_dark_color: int | None = None + button_request: dict[str, Any] | None = None + + +class WebAppSession(Model): + bot: str | None = None + kind: str = "" + url: str = "" + query_id: str | None = None + expires_at: str | None = None + fullsize: bool = False + fullscreen: bool = False + same_origin: bool = False + needs_prolong: bool = False + prolong_every: int | None = None + write_allowed: bool = False + inactive_confirmed: bool = False + + +class WebAppSent(Model): + bot_id: int = 0 + sent: bool = False + + +class WebAppDownload(Model): + allowed: bool = False + file_name: str = "" + url: str = "" + downloaded: bool = False + path: str | None = None + + +class WebAppProlong(Model): + query_id: str = "" + prolonged_at: str | None = None + alive: bool = True + reason: str | None = None From 34095b0e40669f3f5195ec5030e65c83aa95c228 Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 00:44:06 +0330 Subject: [PATCH 02/10] reply markup: the read side is filled in, and one vocabulary serves both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-1 declared ReplyMarkup and nothing ever populated it, which made its two P0 ids true only on paper: a caller could not see a button, so a caller could not press one. message_to_model now fills it, each button carrying the flat row-major n that 'bot press --button ' takes. ops/_bots.py holds what all four new groups reach for — the button-type vocabulary both sides share, the bot-session gate that turns Telegram's bare BOT_METHOD_INVALID into exit 4 with a sentence, DC routing for inline message ids, and the report option tree three commands walk. --- tlgr/models/message.py | 7 + tlgr/ops/_bots.py | 586 +++++++++++++++++++++++++++++++++++++++++ tlgr/ops/_serialize.py | 60 +++++ 3 files changed, 653 insertions(+) create mode 100644 tlgr/ops/_bots.py diff --git a/tlgr/models/message.py b/tlgr/models/message.py index 9db2460..8956c7f 100644 --- a/tlgr/models/message.py +++ b/tlgr/models/message.py @@ -144,11 +144,18 @@ class MessageEntity(Model): class Button(Model): text: str type: str + #: Flat row-major index across the whole markup. `bot press --button ` + #: takes exactly this number, which is why it is printed rather than left + #: for the caller to count. + n: int = 0 data_b64: str | None = None url: str | None = None query: str | None = None user_id: int | None = None requires_password: bool = False + same_peer: bool = False + copy_text: str | None = None + button_id: int | None = None class ReplyMarkup(Model): diff --git a/tlgr/ops/_bots.py b/tlgr/ops/_bots.py new file mode 100644 index 0000000..410f505 --- /dev/null +++ b/tlgr/ops/_bots.py @@ -0,0 +1,586 @@ +"""The plumbing the bot, inline, mini-app and payment groups all need. + +Four things live here rather than in one of the four modules, because all four +reach for them and a second copy is how they start to disagree: + +* **the keyboard schema**, read *and* write. One vocabulary of button `type` + names serves `message get --json`, `bot press --button ` and + `--keyboard FILE`, so a button that can be read back can be pressed and + re-sent. +* **the bot-session gate.** Half of this surface is bot-only, and Telegram + answers a user session with a bare `403 BOT_METHOD_INVALID`. Asking the + session what it is first turns that into exit 4 with a sentence saying how + to add a bot account. +* **DC routing.** An inline message id names the DC it lives on, and sending + `editInlineBotMessage` to the home DC fails with an error that says nothing + about DCs. One helper borrows the exported sender for every caller. +* **the report option tree.** `messages.report`, `messages.reportSponsoredMessage` + and the mini-app report all walk the same + chooseOption → addComment → reported state machine. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import os +from pathlib import Path +from typing import Any + +from tlgr.core.errors import AuthenticationError, NotSupportedError, UsageError +from tlgr.models.bot import Keyboard, KeyboardButton, ReportOutcome +from tlgr.models.peer import PeerRef +from tlgr.ops import _send +from tlgr.ops._common import client +from tlgr.ops._spec import OpContext + +__all__ = [ + "ADMIN_RIGHTS", + "BUTTON_TYPES", + "LAYER_229", + "admin_rights", + "bot_peer", + "client", + "command_scope", + "data_json", + "inline_message_id", + "input_user", + "keyboard_model", + "keyboard_tl", + "load_json", + "on_dc", + "option_bytes", + "payload_bytes", + "peer_ref", + "report_outcome", + "require_bot_session", + "rights_keywords", + "unsupported", +] + +#: The one sentence every layer-gap refusal ends with. Written once so that +#: `tlgr agent capabilities` and the docs cannot describe the gap differently. +LAYER_229 = ( + "it is a layer-229 method and the pinned Telethon speaks layer 227; " + "tlgr refuses rather than guessing at a constructor id" +) + + +def unsupported(feature: str, reason: str = LAYER_229) -> Any: + """Refuse a feature this build genuinely cannot perform (exit 13).""" + raise NotSupportedError(f"{feature} is not supported: {reason}") + + +# --------------------------------------------------------------------------- +# Peers and sessions +# --------------------------------------------------------------------------- + + +def peer_ref(value: str) -> PeerRef: + """A `@username`/id string as a `PeerRef`, for a bot named in config.""" + from tlgr.models.peer import parse_peer_ref + + return parse_peer_ref(value) + + +async def bot_peer(ctx: OpContext, ref: PeerRef | str | None) -> Any: + """The `InputPeer` of a bot, through the account's own resolver (§6.6).""" + if isinstance(ref, str): + ref = peer_ref(ref) + return await _send.resolve(ctx, ref) + + +async def input_user(ctx: OpContext, ref: PeerRef | str | None, *, field: str = "bot") -> Any: + """The `InputUser` a `bots.*` request wants. + + `utils.get_input_user` is arithmetic on a peer we already resolved; going + back to the network would hide the real problem when the ref names a chat. + """ + from telethon import utils + + peer = await bot_peer(ctx, ref) + try: + return utils.get_input_user(peer) + except (TypeError, ValueError) as exc: + raise UsageError(f"{field} must name a user or a bot", field=field) from exc + + +async def require_bot_session(ctx: OpContext, what: str) -> Any: + """Refuse a bot-only operation on a user session, with exit 4. + + Telegram answers `BOT_METHOD_INVALID`, which reads like a bug in the + request rather than like "this account is a person". + """ + account = await client(ctx).get_me() + if not bool(getattr(account, "bot", False)): + raise AuthenticationError( + f"{what} needs a bot session; add one with " + "`tlgr account add --bot-token ` and pass it with -a" + ) + return account + + +# --------------------------------------------------------------------------- +# Admin rights +# --------------------------------------------------------------------------- + +#: The keyword vocabulary `--admin`/`--group`/`--channel` accept, in the +#: spelling the groups-and-channels group uses. +ADMIN_RIGHTS: tuple[str, ...] = ( + "change_info", + "post_messages", + "edit_messages", + "delete_messages", + "ban_users", + "invite_users", + "pin_messages", + "add_admins", + "anonymous", + "manage_call", + "other", + "manage_topics", + "post_stories", + "edit_stories", + "delete_stories", + "manage_direct_messages", +) + +#: The names the t.me deep links use, mapped onto the TL field they set. +_RIGHT_ALIASES = { + "manage_chat": "other", + "restrict_members": "ban_users", + "promote_members": "add_admins", + "manage_video_chats": "manage_call", +} + + +def admin_rights(text: str | None, *, field: str = "admin") -> Any: + """`change_info+invite_users` as a `ChatAdminRights`, or None.""" + if not text: + return None + from telethon.tl import types + + flags: dict[str, bool] = {} + for raw in str(text).replace(",", "+").split("+"): + name = raw.strip().lower() + if not name: + continue + name = _RIGHT_ALIASES.get(name, name) + if name not in ADMIN_RIGHTS: + raise UsageError( + f"--{field}: {raw!r} is not an admin right; choose from {', '.join(ADMIN_RIGHTS)}", + field=field, + ) + flags[name] = True + return types.ChatAdminRights(**flags) + + +def rights_keywords(rights: Any) -> list[str]: + """The keywords a `ChatAdminRights` has set, in the documented order.""" + if rights is None: + return [] + return [name for name in ADMIN_RIGHTS if bool(getattr(rights, name, False))] + + +# --------------------------------------------------------------------------- +# Payloads +# --------------------------------------------------------------------------- + + +def load_json(value: str | None, *, field: str) -> Any: + """Inline JSON, `@path`, or a bare path — whichever the caller typed. + + A JSON document large enough to be worth writing is large enough to be + worth keeping in a file, and a document small enough to type belongs on + the command line; supporting only one of the two is what makes a caller + write `--params "$(cat f.json)"`. + """ + if value is None: + return None + text = value.strip() + if text.startswith("@"): + text = _read(text[1:], field=field) + elif not text.startswith(("{", "[", '"')) and not text.lstrip("-").isdigit(): + text = _read(text, field=field) + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise UsageError(f"--{field}: {exc}", field=field) from exc + + +def _read(path: str, *, field: str) -> str: + handle = Path(os.path.expanduser(path)) + try: + return handle.read_text(encoding="utf-8") + except OSError as exc: + raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc + + +def data_json(value: str | None, *, field: str) -> Any: + """A `DataJSON` built from whatever `load_json` accepts.""" + from telethon.tl import types + + payload = load_json(value, field=field) + if payload is None: + return None + return types.DataJSON(data=json.dumps(payload, separators=(",", ":"))) + + +def payload_bytes(value: str | None, *, field: str) -> bytes | None: + """Callback payload bytes from `hex:…`, `str:…`, `@file`, or bare hex/text. + + Callback data is *bytes*, and most of it is not text; a flag that only + accepted text would make half the buttons on Telegram unpressable, and one + that only accepted hex would make the other half unreadable. + """ + if value is None: + return None + text = str(value) + if text.startswith("hex:"): + return _unhex(text[4:], field=field) + if text.startswith("str:"): + return text[4:].encode() + if text.startswith("b64:"): + return _unb64(text[4:], field=field) + if text.startswith("@"): + try: + return Path(os.path.expanduser(text[1:])).read_bytes() + except OSError as exc: + raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc + stripped = text.strip() + if stripped and len(stripped) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in stripped): + return _unhex(stripped, field=field) + return text.encode() + + +def option_bytes(value: str | None, *, field: str = "option") -> bytes: + """Report-option bytes, as `bot ad list`/a previous step printed them.""" + if not value: + return b"" + return payload_bytes(value, field=field) or b"" + + +def _unhex(text: str, *, field: str) -> bytes: + try: + return binascii.unhexlify(text.strip()) + except (binascii.Error, ValueError) as exc: + raise UsageError(f"--{field}: {text!r} is not hexadecimal", field=field) from exc + + +def _unb64(text: str, *, field: str) -> bytes: + try: + return base64.b64decode(text + "=" * (-len(text) % 4)) + except (binascii.Error, ValueError) as exc: + raise UsageError(f"--{field}: {text!r} is not base64", field=field) from exc + + +def key_text(raw: bytes | None) -> str: + """Opaque bytes as something a shell can round-trip: text, else base64.""" + if not raw: + return "" + try: + return raw.decode() + except UnicodeDecodeError: + return base64.b64encode(raw).decode() + + +# --------------------------------------------------------------------------- +# Keyboards — the schema, both ways +# --------------------------------------------------------------------------- + +#: TL class suffix → the `type` name the JSON schema uses. +BUTTON_TYPES: dict[str, str] = { + "KeyboardButton": "text", + "KeyboardButtonCallback": "callback", + "KeyboardButtonUrl": "url", + "KeyboardButtonUrlAuth": "url_auth", + "InputKeyboardButtonUrlAuth": "url_auth", + "KeyboardButtonSwitchInline": "switch_inline", + "KeyboardButtonWebView": "webview", + "KeyboardButtonSimpleWebView": "simple_webview", + "KeyboardButtonGame": "game", + "KeyboardButtonBuy": "buy", + "KeyboardButtonRequestPhone": "request_phone", + "KeyboardButtonRequestGeoLocation": "request_geo", + "KeyboardButtonRequestPoll": "request_poll", + "KeyboardButtonRequestPeer": "request_peer", + "KeyboardButtonUserProfile": "user_profile", + "InputKeyboardButtonUserProfile": "user_profile", + "KeyboardButtonCopy": "copy", +} + +_MARKUP_KINDS = { + "ReplyInlineMarkup": "inline", + "ReplyKeyboardMarkup": "keyboard", + "ReplyKeyboardHide": "hide", + "ReplyKeyboardForceReply": "force_reply", +} + + +def button_model(button: Any) -> KeyboardButton: + """One TL button as the schema row `--keyboard` would write.""" + kind = BUTTON_TYPES.get(type(button).__name__, "unsupported") + data = getattr(button, "data", None) + return KeyboardButton( + text=str(getattr(button, "text", "") or ""), + type=kind, + data=key_text(data) if data else None, + url=getattr(button, "url", None), + query=getattr(button, "query", None), + user_id=_user_id(getattr(button, "user_id", None)), + requires_password=bool(getattr(button, "requires_password", False)), + same_peer=bool(getattr(button, "same_peer", False)), + copy_text=getattr(button, "copy_text", None), + button_id=getattr(button, "button_id", None), + fwd_text=getattr(button, "fwd_text", None), + request_write_access=bool(getattr(button, "request_write_access", False)), + ) + + +def _user_id(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, int): + return value + return int(getattr(value, "user_id", 0) or 0) or None + + +def keyboard_model(markup: Any) -> Keyboard | None: + """A TL reply markup as the `Keyboard` schema, or None.""" + if markup is None: + return None + kind = _MARKUP_KINDS.get(type(markup).__name__) + if kind is None: + return None + rows = [ + [button_model(button) for button in (getattr(row, "buttons", None) or [])] + for row in (getattr(markup, "rows", None) or []) + ] + return Keyboard( + kind=kind, + rows=rows, + resize=bool(getattr(markup, "resize", False)), + single_use=bool(getattr(markup, "single_use", False)), + selective=bool(getattr(markup, "selective", False)), + persistent=bool(getattr(markup, "persistent", False)), + placeholder=getattr(markup, "placeholder", None), + ) + + +def keyboard_tl(spec: Any, *, field: str = "keyboard") -> Any: + """The `Keyboard` schema as a TL reply markup. + + Only the button kinds a *client* can legitimately author are built. A + `url_auth` button carries an `InputUser` the caller has not resolved and a + `buy` button starts a payment, so both are refused here rather than half + built. + """ + from telethon.tl import types + + if spec is None: + return None + if isinstance(spec, str): + spec = load_json(spec, field=field) + if not isinstance(spec, dict): + raise UsageError(f"--{field}: expected a keyboard object", field=field) + + kind = str(spec.get("kind") or "inline") + rows: list[Any] = [] + for row in spec.get("rows") or []: + buttons = [_button_tl(entry, field=field) for entry in row] + rows.append(types.KeyboardButtonRow(buttons=buttons)) + if kind == "inline": + return types.ReplyInlineMarkup(rows=rows) + if kind == "keyboard": + return types.ReplyKeyboardMarkup( + rows=rows, + resize=bool(spec.get("resize")) or None, + single_use=bool(spec.get("single_use")) or None, + selective=bool(spec.get("selective")) or None, + persistent=bool(spec.get("persistent")) or None, + placeholder=spec.get("placeholder"), + ) + if kind == "hide": + return types.ReplyKeyboardHide(selective=bool(spec.get("selective")) or None) + if kind == "force_reply": + return types.ReplyKeyboardForceReply( + single_use=bool(spec.get("single_use")) or None, + selective=bool(spec.get("selective")) or None, + placeholder=spec.get("placeholder"), + ) + raise UsageError(f"--{field}: kind must be inline, keyboard, hide or force_reply", field=field) + + +def _button_tl(entry: Any, *, field: str) -> Any: + from telethon.tl import types + + if not isinstance(entry, dict): + raise UsageError(f"--{field}: every button must be an object", field=field) + text = str(entry.get("text") or "") + kind = str(entry.get("type") or "text") + if kind == "text": + return types.KeyboardButton(text=text) + if kind == "callback": + return types.KeyboardButtonCallback( + text=text, + data=payload_bytes(entry.get("data"), field=field) or b"", + requires_password=bool(entry.get("requires_password")) or None, + ) + if kind == "url": + return types.KeyboardButtonUrl(text=text, url=str(entry.get("url") or "")) + if kind == "switch_inline": + return types.KeyboardButtonSwitchInline( + text=text, + query=str(entry.get("query") or ""), + same_peer=bool(entry.get("same_peer")) or None, + ) + if kind == "webview": + return types.KeyboardButtonWebView(text=text, url=str(entry.get("url") or "")) + if kind == "simple_webview": + return types.KeyboardButtonSimpleWebView(text=text, url=str(entry.get("url") or "")) + if kind == "copy": + return types.KeyboardButtonCopy(text=text, copy_text=str(entry.get("copy_text") or "")) + if kind == "game": + return types.KeyboardButtonGame(text=text) + if kind == "request_phone": + return types.KeyboardButtonRequestPhone(text=text) + if kind == "request_geo": + return types.KeyboardButtonRequestGeoLocation(text=text) + if kind == "request_poll": + return types.KeyboardButtonRequestPoll(text=text, quiz=entry.get("quiz")) + raise UsageError( + f"--{field}: {kind!r} is not a button kind tlgr can author " + "(text, callback, url, switch_inline, webview, simple_webview, copy, game, " + "request_phone, request_geo, request_poll)", + field=field, + ) + + +# --------------------------------------------------------------------------- +# Inline message ids and DC routing +# --------------------------------------------------------------------------- + + +def inline_message_id(text: str, *, field: str = "inline_id") -> Any: + """`dc:id:hash` (or `dc:owner:id:hash`) as an `InputBotInlineMessageID*`. + + The id names the DC the message lives on, and every request that takes one + must be sent *there*; see `on_dc`. + """ + from telethon.tl import types + + parts = [p for p in str(text).replace("-", ":").split(":") if p != ""] + try: + numbers = [int(p) for p in parts] + except ValueError as exc: + raise UsageError( + f"--{field}: expected 'dc:id:access_hash' (or 'dc:owner:id:access_hash')", + field=field, + ) from exc + if len(numbers) == 3: + return types.InputBotInlineMessageID( + dc_id=numbers[0], id=numbers[1], access_hash=numbers[2] + ) + if len(numbers) == 4: + return types.InputBotInlineMessageID64( + dc_id=numbers[0], owner_id=numbers[1], id=numbers[2], access_hash=numbers[3] + ) + raise UsageError( + f"--{field}: expected 'dc:id:access_hash' (or 'dc:owner:id:access_hash')", field=field + ) + + +def inline_id_text(value: Any) -> str: + """The round-trip spelling of an `InputBotInlineMessageID*`.""" + owner = getattr(value, "owner_id", None) + parts = [getattr(value, "dc_id", 0)] + if owner is not None: + parts.append(owner) + parts += [getattr(value, "id", 0), getattr(value, "access_hash", 0)] + return ":".join(str(int(p or 0)) for p in parts) + + +async def on_dc(ctx: OpContext, dc_id: int, request: Any) -> Any: + """Send *request* to *dc_id* through an exported sender. + + Inline message ids and web files live on a DC that is not necessarily the + home one, and sending there anyway fails with an error that says nothing + about data centres. + """ + handle = client(ctx) + if not dc_id: + return await handle(request) + sender = await handle._borrow_exported_sender(dc_id) + try: + return await sender.send(request) + finally: + await handle._return_exported_sender(sender) + + +# --------------------------------------------------------------------------- +# Bot command scopes +# --------------------------------------------------------------------------- + +_SCOPES = { + "default": "BotCommandScopeDefault", + "users": "BotCommandScopeUsers", + "chats": "BotCommandScopeChats", + "chat-admins": "BotCommandScopeChatAdmins", + "peer": "BotCommandScopePeer", + "peer-admins": "BotCommandScopePeerAdmins", + "peer-user": "BotCommandScopePeerUser", +} + + +async def command_scope( + ctx: OpContext, name: str, chat: PeerRef | None, user: PeerRef | None +) -> Any: + """One of the seven `botCommandScope*` constructors.""" + from telethon.tl import types + + klass_name = _SCOPES.get(name) + if klass_name is None: + raise UsageError(f"--scope: {name!r} is not a command scope", field="scope") + klass = getattr(types, klass_name) + if name in ("peer", "peer-admins", "peer-user"): + if chat is None: + raise UsageError(f"--scope {name} needs --peer", field="peer") + peer = await _send.resolve(ctx, chat) + if name == "peer-user": + if user is None: + raise UsageError("--scope peer-user needs --user", field="user") + return klass(peer=peer, user_id=await input_user(ctx, user, field="user")) + return klass(peer=peer) + return klass() + + +# --------------------------------------------------------------------------- +# The report option tree +# --------------------------------------------------------------------------- + + +def report_outcome(result: Any) -> ReportOutcome: + """One step of `reportResultChooseOption → addComment → reported`.""" + name = type(result).__name__ + if name == "ReportResultChooseOption": + return ReportOutcome( + result="choose_option", + title=str(getattr(result, "title", "") or ""), + options=[ + { + "text": str(getattr(option, "text", "") or ""), + "option": key_text(getattr(option, "option", b"")), + } + for option in (getattr(result, "options", None) or []) + ], + ) + if name == "ReportResultAddComment": + return ReportOutcome( + result="add_comment", + options=[{"option": key_text(getattr(result, "option", b""))}], + title="optional" if getattr(result, "optional", False) else "required", + ) + return ReportOutcome(result="reported", reported=True) diff --git a/tlgr/ops/_serialize.py b/tlgr/ops/_serialize.py index 4af4aa0..da6cc14 100644 --- a/tlgr/ops/_serialize.py +++ b/tlgr/ops/_serialize.py @@ -15,11 +15,13 @@ from tlgr.core.timefmt import fmt_dt, to_unix from tlgr.models.dialog import ActionBar, ChatTheme, ChatWallpaper, NotifySettings from tlgr.models.message import ( + Button, MediaSummary, Message, MessageEntity, ReactionSummary, ReplyHeader, + ReplyMarkup, ServiceAction, ) from tlgr.models.peer import Peer, Photo @@ -36,6 +38,7 @@ "peer_id_of", "photo_summary", "reactions_summary", + "reply_markup", "service_action", "tl_snake", "wallpaper", @@ -494,10 +497,67 @@ def message_to_model( views=getattr(message, "views", None), forwards=getattr(message, "forwards", None), edit_hide=bool(getattr(message, "edit_hide", False)), + reply_markup=reply_markup(getattr(message, "reply_markup", None)), link=link, ) +def reply_markup(markup: Any) -> ReplyMarkup | None: + """A message's keyboard, in the schema `bot press` addresses buttons in. + + PR-1 declared the shape and left it unfilled, which made the two P0 ids + about rendering a keyboard true only on paper: a caller could not see a + button, so a caller could not press one. The `n` on each button is the + flat row-major index `bot press --button ` takes, printed rather than + left to be counted. + """ + from tlgr.ops._bots import BUTTON_TYPES + + kinds = { + "ReplyInlineMarkup": "inline", + "ReplyKeyboardMarkup": "keyboard", + "ReplyKeyboardHide": "hide", + "ReplyKeyboardForceReply": "force_reply", + } + kind = kinds.get(type(markup).__name__) + if kind is None: + return None + + rows: list[list[Button]] = [] + index = 0 + for row in getattr(markup, "rows", None) or []: + built: list[Button] = [] + for button in getattr(row, "buttons", None) or []: + data = getattr(button, "data", None) + user_id = getattr(button, "user_id", None) + built.append( + Button( + text=str(getattr(button, "text", "") or ""), + type=BUTTON_TYPES.get(type(button).__name__, "unsupported"), + n=index, + data_b64=base64.b64encode(data).decode() if data else None, + url=getattr(button, "url", None), + query=getattr(button, "query", None), + user_id=int(user_id) if isinstance(user_id, int) else None, + requires_password=bool(getattr(button, "requires_password", False)), + same_peer=bool(getattr(button, "same_peer", False)), + copy_text=getattr(button, "copy_text", None), + button_id=getattr(button, "button_id", None), + ) + ) + index += 1 + rows.append(built) + return ReplyMarkup( + kind=kind, # type: ignore[arg-type] + rows=rows, + resize=bool(getattr(markup, "resize", False)) or None, + single_use=bool(getattr(markup, "single_use", False)) or None, + selective=bool(getattr(markup, "selective", False)) or None, + persistent=bool(getattr(markup, "persistent", False)) or None, + placeholder=getattr(markup, "placeholder", None), + ) + + # --------------------------------------------------------------------------- # The chat-list shapes # --------------------------------------------------------------------------- From 991c3b9c46f7f4057e294987a24518a93ac9cec0 Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 00:44:18 +0330 Subject: [PATCH 03/10] bot ops: 57 operations, and one dispatcher for fourteen kinds of button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot group as a user sees it (get, start, stop, command send, press, url-auth, permission) and as its owner does (create, edit, token export, access, previews, affiliate program, verification, menu, default rights). bot press is the centre. Four button kinds hand a bot something the user owns — a phone number, a location, a chat, a poll — and the protocol makes them look like every other button; without its consent flag tlgr prints what it would send and exits 2. A Pay button is refused outright with exit 6. Five operations are registered and refuse with exit 13: bot ephemeral send and delete, and the three bot welcome verbs, all of which need layer-229 methods the pinned Telethon does not carry. Registering them is the point — 'unavailable in this build' is a different answer from 'no such command'. --- tlgr/ops/bot.py | 4796 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 4796 insertions(+) create mode 100644 tlgr/ops/bot.py diff --git a/tlgr/ops/bot.py b/tlgr/ops/bot.py new file mode 100644 index 0000000..c32b3c0 --- /dev/null +++ b/tlgr/ops/bot.py @@ -0,0 +1,4796 @@ +"""The `bot` group: talking to bots, and running the ones you own. + +Two audiences share one noun, and the split matters because it decides which +account can run a command at all. + +* **As a user.** `bot get`, `bot start`, `bot command send`, `bot press`, + `bot permission set`, `bot url-auth get` — everything a person does to a bot + from a Telegram client. These run on an ordinary account. +* **As the bot.** `bot answer`, `bot command set`, `bot menu set`, + `bot api send` — the Bot-API surface, which Telegram serves only to a + session created from a bot token. On a user session they exit 4 with a + sentence saying how to add one, rather than surfacing Telegram's own + `BOT_METHOD_INVALID`. + +`bot press` is the centre of the group. Telegram has fourteen kinds of button +and one of them (`buy`) starts a payment, four of them disclose personal data, +and two need a layer this build does not speak. One dispatcher handles them +all: it names what it is about to send, refuses the ones that would leak +without their consent flag, refuses `buy` outright, and returns a typed answer +saying which kind actually came 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, + PermissionError_, + 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.bot import ( + AttachMenuBot, + BotAccess, + BotAnswer, + BotApiResult, + BotCommand, + BotCommandSet, + BotCreated, + BotEdited, + BotIds, + BotInfo, + BotPermission, + BotQuery, + BotRef, + BotStarted, + BotStopped, + BotToken, + BotUsernameCheck, + BotUsernames, + BotVerification, + BotVerified, + BotWelcomeMessage, + BusinessConnection, + CommandSent, + DefaultRights, + EmojiGame, + EphemeralDeleted, + EphemeralSent, + GameSent, + HighScore, + MenuButton, + Pressed, + PreviewChange, + PreviewMedia, + RecentBots, + ReportOutcome, + ScoreSet, + SponsoredRead, + StarRefProgram, + StreamProgress, + ToggledAttachMenu, + UrlAuth, + WelcomeDeleted, + WelcomeSet, +) +from tlgr.models.message import SponsoredMessage +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _bots, _media, _send +from tlgr.ops._common import client, window +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: Reported as the client platform on every mini-app request. Telegram uses +#: it to pick the app's own layout; there is no "cli" value it understands. +PLATFORM = "web" + +_EXAMPLE_BOT: dict[str, Any] = { + "id": 93372553, + "username": "gif", + "first_name": "GIF", + "about": "Send GIFs inline", + "bot_can_edit": False, +} + + +async def _full(ctx: OpContext, peer: Any) -> tuple[Any, Any]: + """`(userFull, user)` for a bot, in one round trip.""" + from telethon import utils + from telethon.tl.functions import users as fn + + result = await client(ctx)(fn.GetFullUserRequest(id=utils.get_input_user(peer))) + full = getattr(result, "full_user", None) + users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])} + user = users.get(int(getattr(full, "id", 0) or 0)) if full is not None else None + return full, user + + +def _usernames(user: Any) -> list[str]: + names = [ + str(getattr(entry, "username", "") or "") + for entry in (getattr(user, "usernames", None) or []) + if getattr(entry, "username", None) + ] + primary = getattr(user, "username", None) + if primary and primary not in names: + names.insert(0, str(primary)) + return names + + +def _menu_button(button: Any, *, user_id: int | None = None) -> MenuButton | None: + """`botMenuButton*` as the model. + + `botMenuButtonDefault` never reaches a user — the server substitutes the + commands list — so it is normalised to `commands` rather than leaking a + third state nobody can act on. + """ + if button is None: + return None + name = type(button).__name__ + if name == "BotMenuButton": + return MenuButton( + kind="webapp", + text=getattr(button, "text", None), + url=getattr(button, "url", None), + user_id=user_id, + ) + return MenuButton(kind="commands", user_id=user_id) + + +def _commands( + raw: Any, *, bot_id: int, scope: str | None = None, lang: str | None = None +) -> list[BotCommand]: + entries = list(getattr(raw, "commands", None) or raw or []) + names = {str(getattr(c, "command", "") or "") for c in entries} + return [ + BotCommand( + bot_id=bot_id, + command=str(getattr(entry, "command", "") or ""), + description=str(getattr(entry, "description", "") or ""), + ephemeral=bool(getattr(entry, "ephemeral", False)), + scope=scope, + lang=lang, + has_help="help" in names, + has_settings="settings" in names, + ) + for entry in entries + ] + + +def _starref(program: Any) -> StarRefProgram | None: + if program is None: + return None + end = getattr(program, "end_date", None) + revenue = getattr(program, "daily_revenue_per_user", None) + return StarRefProgram( + bot_id=int(getattr(program, "bot_id", 0) or 0), + url=getattr(program, "url", None), + commission_permille=int(getattr(program, "commission_permille", 0) or 0), + duration_months=getattr(program, "duration_months", None), + end_date=fmt_dt(end), + end_date_unix=to_unix(end), + participants=getattr(program, "participants", None), + revenue=int(getattr(revenue, "amount", 0) or 0) if revenue is not None else None, + revoked=bool(getattr(program, "revoked", False)), + ) + + +def _verification(full: Any, user: Any) -> BotVerification | None: + badge = getattr(full, "bot_verification", None) + verified = bool(getattr(user, "verified", False)) + if badge is None and not verified: + return None + return BotVerification( + verified_by_bot=int(getattr(badge, "bot_id", 0) or 0) or None, + description=getattr(badge, "description", None), + icon=getattr(badge, "icon", None), + telegram_verified=verified, + ) + + +def _access(settings: Any) -> BotAccess: + return BotAccess( + restricted=bool(getattr(settings, "restricted", False)), + allowed_users=[ + int(getattr(u, "user_id", 0) or getattr(u, "id", 0) or 0) + for u in (getattr(settings, "users", None) or []) + ], + allowed_chats=[ + int(getattr(c, "id", 0) or 0) for c in (getattr(settings, "chats", None) or []) + ], + ) + + +# --------------------------------------------------------------------------- +# bot get +# --------------------------------------------------------------------------- + + +class GetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="@username, id or t.me link.")] + lang: Annotated[ + str | None, opt("--lang", metavar="CODE", help="Localized description (owner view).") + ] = None + access: Annotated[bool, opt("--access", help="Also fetch managed-bot access settings.")] = False + refresh: Annotated[ + bool, opt("--refresh", help="Re-resolve the username instead of trusting the cache.") + ] = False + + +async def get(ctx: OpContext, req: GetReq) -> BotInfo: + """A bot's whole profile card. + + `bot_info_version` is the only invalidation signal Telegram gives for the + commands and the description, so it is reported: a caller that caches this + card has no other way to know when to refetch it. + """ + from telethon.tl.functions import bots as bots_fn + + peer = await _resolve_bot(ctx, req.bot, refresh=req.refresh) + full, user = await _full(ctx, peer) + info = getattr(full, "bot_info", None) + bot_id = int(getattr(full, "id", 0) or 0) + + about = getattr(full, "about", None) + description = getattr(info, "description", None) + if req.lang: + localized = await client(ctx)( + bots_fn.GetBotInfoRequest(lang_code=req.lang, bot=await _bots.input_user(ctx, req.bot)) + ) + about = getattr(localized, "about", about) + description = getattr(localized, "description", description) + + settings = getattr(info, "app_settings", None) + verifier = getattr(info, "verifier_settings", None) + card = BotInfo( + id=bot_id, + username=getattr(user, "username", None), + usernames=_usernames(user), + first_name=getattr(user, "first_name", None), + about=about, + description=description, + description_photo=_id_of(getattr(info, "description_photo", None)), + description_document=_id_of(getattr(info, "description_document", None)), + privacy_policy_url=getattr(info, "privacy_policy_url", None), + commands=_commands(info, bot_id=bot_id, lang=req.lang), + menu_button=_menu_button(getattr(info, "menu_button", None)), + app_settings=_app_settings(settings), + verifier_settings=( + { + "icon": getattr(verifier, "icon", None), + "company": getattr(verifier, "company", None), + "can_modify_custom_description": bool( + getattr(verifier, "can_modify_custom_description", False) + ), + "custom_description": getattr(verifier, "custom_description", None), + } + if verifier is not None + else None + ), + bot_verification=_verification(full, user), + bot_info_version=getattr(user, "bot_info_version", None), + bot_active_users=getattr(user, "bot_active_users", None), + bot_can_edit=bool(getattr(user, "bot_can_edit", False)), + bot_has_main_app=bool(getattr(user, "bot_has_main_app", False)), + bot_nochats=bool(getattr(user, "bot_nochats", False)), + bot_business=bool(getattr(user, "bot_business", False)), + bot_attach_menu=bool(getattr(user, "bot_attach_menu", False)), + bot_inline_geo=bool(getattr(user, "bot_inline_geo", False)), + inline_placeholder=getattr(user, "bot_inline_placeholder", None), + bot_group_admin_rights=_bots.rights_keywords(getattr(full, "bot_group_admin_rights", None)), + bot_broadcast_admin_rights=_bots.rights_keywords( + getattr(full, "bot_broadcast_admin_rights", None) + ), + has_preview_medias=bool(getattr(info, "has_preview_medias", False)), + starref_program=_starref(getattr(full, "starref_program", None)), + blocked=bool(getattr(full, "blocked", False)), + lang=req.lang, + ) + if req.access: + if not card.bot_can_edit: + raise PermissionError_("access settings are only readable on a bot you administer") + settings = await client(ctx)( + bots_fn.GetAccessSettingsRequest(bot=await _bots.input_user(ctx, req.bot)) + ) + card.access = _access(settings) + return card + + +def _id_of(value: Any) -> int | None: + identifier = getattr(value, "id", None) + return int(identifier) if isinstance(identifier, int) else None + + +def _app_settings(settings: Any) -> dict[str, Any] | None: + if settings is None: + return None + path = getattr(settings, "placeholder_path", None) + return { + "placeholder_path": len(path) if path else None, + "bg_color": getattr(settings, "background_color", None), + "bg_dark_color": getattr(settings, "background_dark_color", None), + "header_color": getattr(settings, "header_color", None), + "header_dark_color": getattr(settings, "header_dark_color", None), + } + + +async def _resolve_bot(ctx: OpContext, ref: PeerRef, *, refresh: bool = False) -> Any: + """The bot's `InputPeer`, optionally re-resolving the username first. + + `contacts.resolveUsername` is what mints the access hash, and a cached one + can be stale after a bot changes hands; `--refresh` is the escape hatch + that does not require deleting the peer cache by hand. + """ + if refresh and ref.kind == "username": + from telethon.tl.functions import contacts as fn + + await client(ctx)(fn.ResolveUsernameRequest(username=str(ref.value))) + return await _send.resolve(ctx, ref) + + +SPEC_GET = OperationSpec( + id="bot.get", + request=GetReq, + response=BotInfo, + impl=get, + summary="Show a bot's profile card", + description=( + "Description, about text, commands, menu button, privacy policy, " + "capability flags, verification badge and mini-app settings, from the " + "one `users.getFullUser` that carries all of them." + ), + aliases=("bot.info",), + columns=("id", "username", "first_name", "bot_active_users"), + headers=("ID", "Username", "Name", "Users"), + example=_EXAMPLE_BOT, + example_args="bot get @gifbot", + covers=( + "bots.bot-info-card", + "bots.bot-privacy-policy", + "bots.bot-profile-flags", + "bots.resolve-bot", + ), + covers_partial=( + "bots.bot-verification-view", + "bots.menu-button-state", + "bots.suggested-admin-rights", + "bots.webapp-placeholder-and-close", + ), + coverage_note=( + "The card shows the menu button, the suggested admin rights, the " + "verification badge and the mini-app placeholder; setting them is " + "`bot menu set`, `bot default-rights set`, `bot verification set` and " + "`webapp get`." + ), +) + + +# --------------------------------------------------------------------------- +# bot list +# --------------------------------------------------------------------------- + + +class ListReq(Request): + owned: Annotated[bool, opt("--owned", help="Bots I own or administer (default).")] = True + similar_to: Annotated[ + PeerRef | None, + opt("--similar-to", metavar="BOT", kind="user", help="Bots recommended next to this bot."), + ] = None + popular_apps: Annotated[bool, opt("--popular-apps", help="The Mini App store list.")] = False + recent: Annotated[bool, opt("--recent", help="Frequently-used bots (top peers).")] = False + kind: Annotated[ + str, choice("pm", "inline", "app", "guest", help="Top-peer category for --recent.") + ] = "pm" + + +_TOP_PEER_FLAGS = { + "pm": "bots_pm", + "inline": "bots_inline", + "app": "bots_app", + "guest": "bots_guestchat", +} + + +async def list_bots(ctx: OpContext, req: ListReq) -> Page[BotRef]: + """Bots, from whichever of the four listings the flags name. + + They share a command because they answer one question — "which bots?" — + and differ only in where the answer comes from. + """ + from telethon.tl.functions import bots as fn + from telethon.tl.functions import contacts as contacts_fn + + limit, state = window(ctx, "bot.list", PageKind.RATE, default=50) + handle = client(ctx) + + if req.similar_to is not None: + result = await handle( + fn.GetBotRecommendationsRequest(bot=await _bots.input_user(ctx, req.similar_to)) + ) + truncated = getattr(result, "count", None) + items = [ + _bot_ref(user, kind="similar", truncated=truncated) + for user in (getattr(result, "users", None) or []) + ] + return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False) + + if req.popular_apps: + offset = str(state.get("offset", "") or "") + result = await handle(fn.GetPopularAppBotsRequest(offset=offset, limit=limit)) + items = [_bot_ref(user, kind="app") for user in (getattr(result, "users", None) or [])] + next_offset = str(getattr(result, "next_offset", "") or "") + return build_page( + items, + op="bot.list", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + ) + + if req.recent: + flag = _TOP_PEER_FLAGS[req.kind] + result = await handle( + contacts_fn.GetTopPeersRequest(offset=0, limit=limit, hash=0, **{flag: True}) + ) + if type(result).__name__ == "TopPeersDisabled": + ctx.warn("frequently-used suggestions are switched off for this account") + return Page(items=[], has_more=False, total=0) + users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])} + items = [] + for category in getattr(result, "categories", None) or []: + for entry in getattr(category, "peers", None) or []: + user = users.get(int(getattr(getattr(entry, "peer", None), "user_id", 0) or 0)) + if user is not None: + items.append( + _bot_ref(user, kind=req.kind, rating=getattr(entry, "rating", None)) + ) + return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False) + + result = await handle(fn.GetAdminedBotsRequest()) + items = [_bot_ref(user, kind="owned") for user in (result or [])] + return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False) + + +def _bot_ref( + user: Any, *, kind: str, truncated: int | None = None, rating: float | None = None +) -> BotRef: + first = str(getattr(user, "first_name", "") or "") + last = str(getattr(user, "last_name", "") or "") + return BotRef( + id=int(getattr(user, "id", 0) or 0), + username=getattr(user, "username", None), + title=f"{first} {last}".strip() or None, + kind=kind, + active_users=getattr(user, "bot_active_users", None), + truncated_count=truncated, + rating=float(rating) if rating is not None else None, + ) + + +SPEC_LIST = OperationSpec( + id="bot.list", + request=ListReq, + response=Page[BotRef], + impl=list_bots, + summary="List bots I own, similar bots, popular mini apps or my recent bots", + description=( + "A non-Premium account gets a shortened `--similar-to` list plus the " + "real count, which is reported as `truncated_count` rather than " + "silently looking like the whole answer." + ), + aliases=("bot.mine",), + paginated=PageKind.RATE, + columns=("id", "username", "title", "kind"), + headers=("ID", "Username", "Title", "Kind"), + example={"items": [{"id": 93372553, "username": "gif", "kind": "owned"}], "has_more": False}, + example_args="bot list --owned", + covers=( + "bots.guest-mode-invoke", + "bots.list-owned-bots", + "bots.popular-app-bots", + "bots.similar-bots", + ), + covers_partial=("bots.top-peers-bots",), + coverage_note="Turning the frequently-used list on or off is `bot recent set`.", +) + + +# --------------------------------------------------------------------------- +# bot id get +# --------------------------------------------------------------------------- + + +class IdReq(Request): + chat: Annotated[ + PeerRef, arg(0, metavar="CHAT", kind="peer", help="@username, MTProto id or Bot-API id.") + ] + + +async def id_get(ctx: OpContext, req: IdReq) -> BotIds: + """Convert between MTProto peer ids and HTTP Bot-API chat ids. + + Pure arithmetic when the ref is already an id: a Bot-API id marks channels + with `-100…` and basic groups with a plain negative number, and getting + that conversion wrong is how a script posts into the wrong chat. + """ + from telethon import utils + + if req.chat.kind == "id": + marked = int(req.chat.value) + _raw, kind = utils.resolve_id(marked) + return BotIds( + mtproto_id=marked, + bot_api_id=marked, + kind=_KIND_NAMES.get(kind.__name__, "user"), + has_access_hash=False, + ) + + peer = await _send.resolve(ctx, req.chat) + marked = int(utils.get_peer_id(peer)) + _raw, kind = utils.resolve_id(marked) + return BotIds( + mtproto_id=marked, + bot_api_id=marked, + kind=_KIND_NAMES.get(kind.__name__, "user"), + has_access_hash=getattr(peer, "access_hash", None) is not None, + username=str(req.chat.value) if req.chat.kind == "username" else None, + ) + + +_KIND_NAMES = {"PeerUser": "user", "PeerChat": "group", "PeerChannel": "channel"} + + +SPEC_ID_GET = OperationSpec( + id="bot.id.get", + request=IdReq, + response=BotIds, + impl=id_get, + summary="Convert between MTProto peer ids and Bot-API chat ids", + description=( + "tlgr prints marked ids everywhere (COR-10), which is the same " + "dialect the HTTP Bot API uses; this command says so out loud and " + "reports whether an access hash is cached for the peer." + ), + columns=("mtproto_id", "bot_api_id", "kind"), + headers=("MTProto", "Bot API", "Kind"), + example={"mtproto_id": -1001234567890, "bot_api_id": -1001234567890, "kind": "channel"}, + example_args="bot id get @durov", + covers=("bots.bot-api-dialog-ids",), +) + + +# --------------------------------------------------------------------------- +# bot start / stop +# --------------------------------------------------------------------------- + + +class StartReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot to start.")] + param: Annotated[str | None, opt("--param", metavar="TEXT", help="Hidden start parameter.")] = ( + None + ) + referrer: Annotated[ + str | None, opt("--referrer", metavar="TEXT", help="Referral/affiliate start parameter.") + ] = None + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Start the bot inside this group."), + ] = None + channel: Annotated[ + PeerRef | None, + opt("--channel", metavar="CHAT", kind="peer", help="Add the bot to this channel."), + ] = None + admin: Annotated[ + str | None, + opt("--admin", metavar="RIGHTS", help="'+'-joined admin rights to grant."), + ] = None + add: Annotated[bool, opt("--add", help="Add the bot to the chat if it is not a member.")] = ( + False + ) + restart: Annotated[bool, opt("--restart", help="Unblock the bot before starting it.")] = False + + +async def start(ctx: OpContext, req: StartReq) -> BotStarted: + """Send `/start`, optionally with a hidden parameter and inside a chat. + + `messages.startBot` is the only way to send a start parameter the user + never sees, which is what a deep link is; typing `/start payload` puts the + payload in the history for everyone in the chat to read. + """ + from telethon.tl.functions import channels as channels_fn + from telethon.tl.functions import contacts as contacts_fn + from telethon.tl.functions import messages as fn + + handle = client(ctx) + if req.referrer and req.bot.kind == "username": + await handle( + contacts_fn.ResolveUsernameRequest(username=str(req.bot.value), referer=req.referrer) + ) + peer = await _send.resolve(ctx, req.bot) + bot = await _bots.input_user(ctx, req.bot) + full, user = await _full(ctx, peer) + bot_id = int(getattr(full, "id", 0) or 0) + + unblocked = False + if req.restart and getattr(full, "blocked", False): + await handle(contacts_fn.UnblockRequest(id=peer)) + unblocked = True + + target: Any = peer + rights: list[str] = [] + if req.chat is not None or req.channel is not None: + if bool(getattr(user, "bot_nochats", False)): + raise PermissionError_("this bot refuses to be added to groups (BOT_GROUPS_BLOCKED)") + where = req.chat if req.chat is not None else req.channel + target = await _send.resolve(ctx, where) + if req.add: + await _add_to_chat(ctx, target, bot) + granted = _bots.admin_rights(req.admin) or ( + getattr(full, "bot_group_admin_rights", None) + if req.chat is not None + else getattr(full, "bot_broadcast_admin_rights", None) + ) + if granted is not None and (req.admin or req.channel is not None): + await handle( + channels_fn.EditAdminRequest( + channel=_input_channel(target), + user_id=bot, + admin_rights=granted, + rank="", + ) + ) + rights = _bots.rights_keywords(granted) + + updates = await handle( + fn.StartBotRequest(bot=bot, peer=target, start_param=req.param or req.referrer or "") + ) + message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target)) + ctx.emit("bot_start", {"bot_id": bot_id, "chat_id": message.chat_id}) + return BotStarted( + bot_id=bot_id, + chat_id=message.chat_id, + msg_id=message.id, + start_param=req.param or req.referrer, + admin_rights=rights, + unblocked=unblocked, + ) + + +def _input_channel(peer: Any) -> Any: + from tlgr.ops._common import input_channel + + return input_channel(peer) + + +async def _add_to_chat(ctx: OpContext, peer: Any, bot: Any) -> None: + """Invite the bot, tolerating "already a member".""" + from telethon.tl.functions import channels as channels_fn + from telethon.tl.functions import messages as fn + + handle = client(ctx) + try: + if type(peer).__name__ == "InputPeerChat": + await handle( + fn.AddChatUserRequest(chat_id=getattr(peer, "chat_id", 0), user_id=bot, fwd_limit=0) + ) + else: + await handle( + channels_fn.InviteToChannelRequest(channel=_input_channel(peer), users=[bot]) + ) + except Exception as exc: # the server's own "already a member" is not a failure + if "ALREADY" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""): + raise + + +SPEC_START = OperationSpec( + id="bot.start", + request=StartReq, + response=BotStarted, + impl=start, + summary="Start a bot, with a deep-link parameter or inside a group", + description=( + "`--param` is the payload behind a `t.me/?start=…` link and is " + "never written into the chat, which is the whole point of a deep " + "link. `--referrer` additionally re-resolves the username with the " + "referral attached, because the attribution happens at resolve time." + ), + aliases=("bot.restart",), + mutating=True, + rate_class="send", + columns=("bot_id", "chat_id", "msg_id"), + headers=("Bot", "Chat", "Message"), + example={"bot_id": 93372553, "chat_id": 93372553, "msg_id": 12}, + example_args="bot start @gifbot", + covers=( + "bots.inline-switch-pm", + "bots.referral-link-import", + "bots.restart-bot", + "bots.start-in-channel", + "bots.start-in-group", + "bots.start-in-group-as-admin", + "bots.start-private", + "bots.start-with-deeplink-param", + ), +) + + +class StopReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot to block.")] + delete_chat: Annotated[bool, opt("--delete-chat", help="Also delete the chat history.")] = False + report: Annotated[bool, opt("--report", help="Report the bot as spam while blocking.")] = False + + +async def stop(ctx: OpContext, req: StopReq) -> BotStopped: + """Block a bot, and optionally delete the whole conversation. + + `deleteHistory` answers with an `AffectedHistory` carrying an offset to + resume from; calling it once and reporting success is how v1-shaped code + deletes the first hundred messages and leaves the rest. + """ + from telethon.tl.functions import contacts as contacts_fn + from telethon.tl.functions import messages as fn + + handle = client(ctx) + peer = await _send.resolve(ctx, req.bot) + bot_id = _send.peer_id_of(peer) + if req.report: + await handle(fn.ReportSpamRequest(peer=peer)) + await handle(contacts_fn.BlockRequest(id=peer)) + + deleted = 0 + if req.delete_chat: + from tlgr.ops._common import affected_loop + + deleted = await affected_loop( + ctx, + lambda offset: fn.DeleteHistoryRequest(peer=peer, max_id=0, revoke=False), + ) + ctx.emit("bot_stop", {"bot_id": bot_id}) + return BotStopped(bot_id=bot_id, blocked=True, history_deleted=deleted) + + +SPEC_STOP = OperationSpec( + id="bot.stop", + request=StopReq, + response=BotStopped, + impl=stop, + summary="Stop and block a bot", + mutating=True, + destructive=True, + columns=("bot_id", "blocked", "history_deleted"), + headers=("Bot", "Blocked", "Deleted"), + example={"bot_id": 93372553, "blocked": True, "history_deleted": 0}, + example_args="bot stop @gifbot", + covers=("bots.delete-bot-chat-and-block", "bots.stop-bot"), +) + + +# --------------------------------------------------------------------------- +# bot command list / send / set +# --------------------------------------------------------------------------- + + +class CommandListReq(Request): + bot: Annotated[ + PeerRef | None, + arg(0, metavar="BOT", required=False, kind="user", help="The bot."), + ] = None + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Every bot's commands in this chat."), + ] = None + scope: Annotated[ + str | None, + choice( + "default", + "users", + "chats", + "chat-admins", + "peer", + "peer-admins", + "peer-user", + help="Bot-side scope to read back (bot session).", + ), + ] = None + peer: Annotated[ + PeerRef | None, opt("--peer", metavar="CHAT", kind="peer", help="Peer for a peer* scope.") + ] = None + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="User for the peer-user scope."), + ] = None + lang: Annotated[str | None, opt("--lang", metavar="CODE", help="Language code.")] = None + + +async def command_list(ctx: OpContext, req: CommandListReq) -> Page[BotCommand]: + """A bot's slash commands, from whichever side is asking. + + `has_help`/`has_settings` decide whether a GUI shows its "Bot Help" and + "Bot Settings" entries, so they are computed from the list rather than + assumed: a bot without `/help` must not get a menu item that does nothing. + """ + from telethon.tl.functions import bots as fn + + if req.scope is not None: + await _bots.require_bot_session(ctx, "reading back your own command list") + scope = await _bots.command_scope(ctx, req.scope, req.peer, req.user) + result = await client(ctx)(fn.GetBotCommandsRequest(scope=scope, lang_code=req.lang or "")) + me = await client(ctx).get_me() + items = _commands( + result, bot_id=int(getattr(me, "id", 0) or 0), scope=req.scope, lang=req.lang + ) + return Page(items=items, has_more=False, total=len(items)) + + if req.chat is not None and req.bot is None: + return await _chat_commands(ctx, req.chat) + + if req.bot is None: + raise UsageError("name a bot, or use --chat to list every bot in a chat", field="bot") + peer = await _send.resolve(ctx, req.bot) + full, _user = await _full(ctx, peer) + info = getattr(full, "bot_info", None) + items = _commands(info, bot_id=int(getattr(full, "id", 0) or 0), lang=req.lang) + return Page(items=items, has_more=False, total=len(items)) + + +async def _chat_commands(ctx: OpContext, chat: PeerRef) -> Page[BotCommand]: + """Every bot's commands in one chat, out of the chat's full info.""" + from telethon.tl.functions import channels as channels_fn + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, chat) + if type(peer).__name__ == "InputPeerChat": + result = await client(ctx)(fn.GetFullChatRequest(chat_id=getattr(peer, "chat_id", 0))) + else: + result = await client(ctx)(channels_fn.GetFullChannelRequest(channel=_input_channel(peer))) + full = getattr(result, "full_chat", None) + items: list[BotCommand] = [] + for info in getattr(full, "bot_info", None) or []: + items += _commands(info, bot_id=int(getattr(info, "user_id", 0) or 0)) + return Page(items=items, has_more=False, total=len(items)) + + +SPEC_COMMAND_LIST = OperationSpec( + id="bot.command.list", + request=CommandListReq, + response=Page[BotCommand], + impl=command_list, + summary="List a bot's slash commands", + description=( + "A user reads them out of `botInfo`; a bot session reads its own back " + "per scope with `bots.getBotCommands`, which is the only way to see " + "what a scope actually holds." + ), + aliases=("bot.commands",), + columns=("bot_id", "command", "description"), + headers=("Bot", "Command", "Description"), + example={ + "items": [{"bot_id": 93372553, "command": "start", "description": "Start the bot"}], + "has_more": False, + }, + example_args="bot command list @gifbot", + covers=( + "bots.bot-help-settings-shortcuts", + "bots.get-my-bot-commands", + "bots.list-commands", + ), +) + + +class CommandSendReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot addressed.")] + command: Annotated[str, arg(1, metavar="COMMAND", help="The command, with or without '/'.")] + args: Annotated[ + list[str], + arg(2, metavar="ARGS", required=False, variadic=True, help="Arguments appended after it."), + ] = [] + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Send it in this chat instead."), + ] = None + guest: Annotated[ + bool, opt("--guest", help="Address the bot in guest mode by mentioning it.") + ] = False + topic: Annotated[ + int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.") + ] = None + reply_to: Annotated[ + int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.") + ] = None + silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False + business_connection: Annotated[ + str | None, + opt( + "--business-connection", metavar="ID", help="Send as a business account (bot session)." + ), + ] = None + + +async def command_send(ctx: OpContext, req: CommandSendReq) -> CommandSent: + """Send `/command` to a bot, in its chat or in a group. + + In a group with more than one bot the command MUST carry `@botusername` + or every bot ignores it, so tlgr appends it whenever the destination is + not the bot's own private chat. Guest mode has no method of its own: the + trigger is an ordinary message that mentions the bot. + """ + from telethon.tl.functions import messages as fn + + bot_peer = await _send.resolve(ctx, req.bot) + target = await _send.resolve(ctx, req.chat) if req.chat is not None else bot_peer + in_private = req.chat is None or _send.peer_id_of(target) == _send.peer_id_of(bot_peer) + + username = str(req.bot.value) if req.bot.kind == "username" else "" + if not username and not in_private: + _full_user, user = await _full(ctx, bot_peer) + username = str(getattr(user, "username", "") or "") + name = req.command.lstrip("/") + if not in_private and username: + name = f"{name}@{username}" + text = " ".join([f"/{name}", *req.args]).strip() + if req.guest and username: + text = f"@{username} {text}" + + reply_to = await _send.reply_target(ctx, reply_to=req.reply_to, topic=req.topic) + request = fn.SendMessageRequest( + peer=target, + message=text, + random_id=_random_id(), + reply_to=reply_to, + silent=req.silent or None, + ) + updates = await _invoke_as(ctx, req.business_connection, request) + message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target), sent_text=text) + ctx.emit("bot_command", {"chat_id": message.chat_id, "text": text}) + return CommandSent( + chat_id=message.chat_id, msg_id=message.id, text=text, via_bot=username or None + ) + + +def _random_id() -> int: + from tlgr.ops._common import random_id + + return random_id() + + +async def _invoke_as(ctx: OpContext, connection_id: str | None, request: Any) -> Any: + """Send *request*, wrapped in a business connection when one is named. + + The wrapper is not a header: the query has to reach the connection's own + DC, which is why the connection is looked up first. + """ + handle = client(ctx) + if not connection_id: + return await handle(request) + await _bots.require_bot_session(ctx, "--business-connection") + from telethon.tl.functions import InvokeWithBusinessConnectionRequest + from telethon.tl.functions import account as account_fn + + connection = await handle( + account_fn.GetBotBusinessConnectionRequest(connection_id=connection_id) + ) + dc_id = _connection_dc(connection) + return await _bots.on_dc( + ctx, + dc_id, + InvokeWithBusinessConnectionRequest(connection_id=connection_id, query=request), + ) + + +def _connection_dc(result: Any) -> int: + for update in getattr(result, "updates", None) or []: + connection = getattr(update, "connection", None) + if connection is not None: + return int(getattr(connection, "dc_id", 0) or 0) + return int(getattr(result, "dc_id", 0) or 0) + + +SPEC_COMMAND_SEND = OperationSpec( + id="bot.command.send", + request=CommandSendReq, + response=CommandSent, + impl=command_send, + summary="Send a slash command to a bot", + description=( + "Driving @BotFather's own conversation with this command and " + "`bot press` is the only way to reach the toggles Telegram exposes " + "nowhere else — group privacy mode and bot-to-bot mode." + ), + aliases=("bot.cmd",), + mutating=True, + rate_class="send", + columns=("chat_id", "msg_id", "text"), + headers=("Chat", "Message", "Text"), + example={"chat_id": 93372553, "msg_id": 12, "text": "/start"}, + example_args="bot command send @gifbot start", + covers=("bots.bot-privacy-mode", "bots.bot-to-bot-messaging", "bots.send-command"), + covers_partial=("bots.bot-help-settings-shortcuts", "bots.guest-mode-invoke"), + coverage_note=( + "Whether a bot declares /help and /settings is reported by " + "`bot command list`; the guest-mode bot listing is `bot list --recent " + "--kind guest`." + ), +) + + +class CommandSetReq(Request): + commands: Annotated[ + str | None, + arg(0, metavar="COMMANDS", required=False, help="'start:Start,help:Show help'."), + ] = None + scope: Annotated[ + str, + choice( + "default", + "users", + "chats", + "chat-admins", + "peer", + "peer-admins", + "peer-user", + help="Command scope.", + ), + ] = "default" + peer: Annotated[ + PeerRef | None, opt("--peer", metavar="CHAT", kind="peer", help="Peer for a peer* scope.") + ] = None + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="User for the peer-user scope."), + ] = None + lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = "" + file: Annotated[ + str | None, + opt("--file", metavar="PATH", kind="path", help="Read the list from a JSON file."), + ] = None + clear: Annotated[bool, opt("--clear", help="Reset the list for this scope.")] = False + + +async def command_set(ctx: OpContext, req: CommandSetReq) -> BotCommandSet: + """Publish (or clear) my bot's command list for one scope and language. + + A human owner does this through @BotFather; the method itself is bot-only, + which is why the session is checked before the request is built. + """ + from telethon.tl import types + from telethon.tl.functions import bots as fn + + await _bots.require_bot_session(ctx, "setting your bot's command list") + scope = await _bots.command_scope(ctx, req.scope, req.peer, req.user) + handle = client(ctx) + + if req.clear: + await handle(fn.ResetBotCommandsRequest(scope=scope, lang_code=req.lang)) + return BotCommandSet(scope=req.scope, lang=req.lang, cleared=True) + + pairs = _command_pairs(req) + await handle( + fn.SetBotCommandsRequest( + scope=scope, + lang_code=req.lang, + commands=[ + types.BotCommand(command=name, description=description) + for name, description in pairs + ], + ) + ) + return BotCommandSet( + scope=req.scope, + lang=req.lang, + commands=[ + BotCommand(command=name, description=description, scope=req.scope, lang=req.lang) + for name, description in pairs + ], + ) + + +def _command_pairs(req: CommandSetReq) -> list[tuple[str, str]]: + if req.file: + loaded = _bots.load_json(req.file, field="file") + if not isinstance(loaded, list): + raise UsageError("--file: expected a JSON list of commands", field="file") + return [ + (str(entry.get("command", "")).lstrip("/"), str(entry.get("description", ""))) + for entry in loaded + ] + if not req.commands: + raise UsageError("give a command list, --file or --clear", field="commands") + pairs: list[tuple[str, str]] = [] + for chunk in req.commands.split(","): + name, _, description = chunk.partition(":") + if not name.strip(): + continue + pairs.append((name.strip().lstrip("/"), description.strip())) + return pairs + + +SPEC_COMMAND_SET = OperationSpec( + id="bot.command.set", + request=CommandSetReq, + response=BotCommandSet, + impl=command_set, + summary="Set or clear my bot's command list for one scope", + mutating=True, + columns=("scope", "lang", "cleared"), + headers=("Scope", "Lang", "Cleared"), + example={ + "scope": "default", + "lang": "", + "commands": [{"command": "start", "description": "Start the bot"}], + }, + example_args='bot command set "start:Start the bot"', + covers=("bots.reset-my-bot-commands", "bots.set-my-bot-commands"), +) + + +# --------------------------------------------------------------------------- +# bot menu get / set +# --------------------------------------------------------------------------- + + +class MenuGetReq(Request): + bot: Annotated[ + PeerRef | None, arg(0, metavar="BOT", required=False, kind="user", help="The bot.") + ] = None + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="Per-user override (bot session)."), + ] = None + + +async def menu_get(ctx: OpContext, req: MenuGetReq) -> MenuButton: + """The button left of the message input.""" + from telethon.tl.functions import bots as fn + + if req.user is not None: + await _bots.require_bot_session(ctx, "reading a per-user menu button") + button = await client(ctx)( + fn.GetBotMenuButtonRequest(user_id=await _bots.input_user(ctx, req.user, field="user")) + ) + return _menu_button( + button, user_id=_send.peer_id_of(await _send.resolve(ctx, req.user)) + ) or (MenuButton()) + if req.bot is None: + raise UsageError("name a bot, or use --user on a bot session", field="bot") + peer = await _send.resolve(ctx, req.bot) + full, _user = await _full(ctx, peer) + info = getattr(full, "bot_info", None) + return _menu_button(getattr(info, "menu_button", None)) or MenuButton() + + +SPEC_MENU_GET = OperationSpec( + id="bot.menu.get", + request=MenuGetReq, + response=MenuButton, + impl=menu_get, + summary="Show a bot's menu button", + description=( + "`botMenuButtonDefault` is never what a user sees — the server shows " + "the commands list instead — so it is normalised to `commands` rather " + "than reported as a third state nobody can act on." + ), + columns=("kind", "text", "url"), + headers=("Kind", "Text", "URL"), + example={"kind": "commands"}, + example_args="bot menu get @gifbot", + covers=("bots.menu-button-state",), +) + + +class MenuSetReq(Request): + commands: Annotated[bool, opt("--commands", help="Show the commands list.")] = False + webapp: Annotated[bool, opt("--webapp", help="Open a mini app.")] = False + default: Annotated[bool, opt("--default", help="Reset to the default.")] = False + text: Annotated[str | None, opt("--text", metavar="TEXT", help="Button label.")] = None + url: Annotated[str | None, opt("--url", metavar="URL", help="Mini app URL.")] = None + user: Annotated[ + PeerRef | None, opt("--user", metavar="USER", kind="user", help="Apply to this user only.") + ] = None + + +async def menu_set(ctx: OpContext, req: MenuSetReq) -> MenuButton: + """Set my bot's menu button, globally or for one user.""" + from telethon.tl import types + from telethon.tl.functions import bots as fn + + await _bots.require_bot_session(ctx, "setting the menu button") + chosen = [name for name in ("commands", "webapp", "default") if getattr(req, name)] + if len(chosen) != 1: + raise UsageError("give exactly one of --commands, --webapp or --default", field="commands") + + if req.webapp: + if not req.text or not req.url: + raise UsageError("--webapp needs --text and --url", field="url") + button: Any = types.BotMenuButton(text=req.text, url=req.url) + elif req.commands: + button = types.BotMenuButtonCommands() + else: + button = types.BotMenuButtonDefault() + + user = ( + await _bots.input_user(ctx, req.user, field="user") + if req.user is not None + else types.InputUserEmpty() + ) + await client(ctx)(fn.SetBotMenuButtonRequest(user_id=user, button=button)) + user_id = _send.peer_id_of(await _send.resolve(ctx, req.user)) if req.user else None + return MenuButton( + kind="webapp" if req.webapp else "commands" if req.commands else "default", + text=req.text, + url=req.url, + user_id=user_id, + ) + + +SPEC_MENU_SET = OperationSpec( + id="bot.menu.set", + request=MenuSetReq, + response=MenuButton, + impl=menu_set, + summary="Set my bot's menu button", + mutating=True, + columns=("kind", "text", "url"), + headers=("Kind", "Text", "URL"), + example={"kind": "webapp", "text": "Open", "url": "https://example.org/app"}, + example_args="bot menu set --commands", + covers=("bots.menu-button-set",), +) + + +# --------------------------------------------------------------------------- +# bot permission get / set +# --------------------------------------------------------------------------- + + +class PermissionGetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")] + + +async def permission_get(ctx: OpContext, req: PermissionGetReq) -> BotPermission: + """What a bot is allowed to do to me.""" + from telethon.tl.functions import bots as fn + + peer = await _send.resolve(ctx, req.bot) + full, _user = await _full(ctx, peer) + can_send = bool( + await client(ctx)(fn.CanSendMessageRequest(bot=await _bots.input_user(ctx, req.bot))) + ) + return BotPermission( + bot_id=int(getattr(full, "id", 0) or 0), + can_send_messages=can_send, + emoji_status_allowed=bool(getattr(full, "bot_can_manage_emoji_status", False)), + ) + + +SPEC_PERMISSION_GET = OperationSpec( + id="bot.permission.get", + request=PermissionGetReq, + response=BotPermission, + impl=permission_get, + summary="Show what a bot may do to me", + columns=("bot_id", "can_send_messages", "emoji_status_allowed"), + headers=("Bot", "May message", "May set status"), + example={"bot_id": 93372553, "can_send_messages": True, "emoji_status_allowed": False}, + example_args="bot permission get @gifbot", + covers=("bots.bot-emoji-status-permission",), + covers_partial=("bots.allow-send-messages",), + coverage_note="Granting or revoking is `bot permission set`.", +) + + +class PermissionSetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")] + key: Annotated[str, arg(1, metavar="KEY", help="message or emoji-status.")] + state: Annotated[str, arg(2, metavar="STATE", help="on to grant, off to revoke.")] + + +async def permission_set(ctx: OpContext, req: PermissionSetReq) -> BotPermission: + """Grant or revoke one bot permission. + + "May message me again" is also granted implicitly by `webapp open + --allow-write` and `bot attach toggle --allow-write`, and by nothing else: + a permission that can be granted as a side effect of an unrelated command + is a permission the user did not give. + """ + from telethon.tl.functions import bots as fn + + if req.key not in ("message", "emoji-status"): + raise UsageError("key must be 'message' or 'emoji-status'", field="key") + if req.state not in ("on", "off"): + raise UsageError("state must be 'on' or 'off'", field="state") + + bot = await _bots.input_user(ctx, req.bot) + peer = await _send.resolve(ctx, req.bot) + bot_id = _send.peer_id_of(peer) + handle = client(ctx) + already = False + + if req.key == "message": + if req.state == "off": + raise UsageError( + "Telegram has no revoke for 'may message me'; block the bot with `bot stop`", + field="state", + ) + if bool(await handle(fn.CanSendMessageRequest(bot=bot))): + already = True + from tlgr.ops._common import already as mark_already + + mark_already(ctx) + else: + await handle(fn.AllowSendMessageRequest(bot=bot)) + else: + await handle(fn.ToggleUserEmojiStatusPermissionRequest(bot=bot, enabled=req.state == "on")) + + return BotPermission( + bot_id=bot_id, + key=req.key, + state=req.state, + already=already, + can_send_messages=req.key == "message" and req.state == "on", + emoji_status_allowed=req.key == "emoji-status" and req.state == "on", + ) + + +SPEC_PERMISSION_SET = OperationSpec( + id="bot.permission.set", + request=PermissionSetReq, + response=BotPermission, + impl=permission_set, + summary="Allow or revoke a bot permission", + mutating=True, + idempotent=True, + columns=("bot_id", "key", "state", "already"), + headers=("Bot", "Key", "State", "Already"), + example={"bot_id": 93372553, "key": "message", "state": "on", "already": False}, + example_args="bot permission set @gifbot message on", + covers=("bots.allow-send-messages",), + covers_partial=("bots.bot-emoji-status-permission",), + coverage_note="Reading both permissions back is `bot permission get`.", +) + + +# --------------------------------------------------------------------------- +# bot press +# --------------------------------------------------------------------------- + + +class PressReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat holding the message.")] + msg_id: Annotated[ + int | None, + arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Message id."), + ] = None + button: Annotated[ + str | None, + opt("--button", metavar="SPEC", help="',', '' or the button's exact text."), + ] = None + data: Annotated[ + str | None, + opt("--data", metavar="PAYLOAD", help="Address a callback button by its payload."), + ] = None + rich_button: Annotated[ + int | None, opt("--rich-button", metavar="N", help="Button in a layer-229 rich message.") + ] = None + ephemeral: Annotated[ + int | None, opt("--ephemeral", metavar="ID", help="Button on an ephemeral bot message.") + ] = None + webapp_req: Annotated[ + str | None, opt("--webapp-req", metavar="ID", help="Answer a mini app's peer request.") + ] = None + password: Annotated[ + str | None, + opt(secret=True, envvar="TLGR_2FA_PASSWORD", help="2FA password for a guarded button."), + ] = None + share_phone: Annotated[ + bool, opt("--share-phone", help="CONSENT: send my phone number to the bot.") + ] = False + share_geo: Annotated[ + str | None, opt("--share-geo", metavar="LAT,LON", help="CONSENT: send this location.") + ] = None + peers: Annotated[ + list[PeerRef], + opt("--peers", metavar="PEER", kind="peer", help="CONSENT: peers to share (repeatable)."), + ] = [] + create_bot: Annotated[ + bool, opt("--create-bot", help="Answer a create-bot request by creating one.") + ] = False + name: Annotated[str | None, opt("--name", help="Managed-bot name for --create-bot.")] = None + username: Annotated[ + str | None, opt("--username", help="Managed-bot username for --create-bot.") + ] = None + poll: Annotated[ + str | None, opt("--poll", metavar="SPEC", help="CONSENT: poll 'Question?:A,B,C'.") + ] = None + quiz: Annotated[bool, opt("--quiz", help="Make the --poll a quiz.")] = False + correct: Annotated[ + int | None, opt("--correct", metavar="N", help="0-based correct answer for a quiz.") + ] = None + switch_to: Annotated[ + PeerRef | None, + opt( + "--switch-to", metavar="CHAT", kind="peer", help="Chat to run a switch-inline query in." + ), + ] = None + business_connection: Annotated[ + str | None, + opt( + "--business-connection", metavar="ID", help="Press as a business account (bot session)." + ), + ] = None + + +async def press(ctx: OpContext, req: PressReq) -> Pressed: + """Press a button, whatever kind it is, and report what came back. + + The consent rule is the reason this is one command and not fourteen. + Four button kinds hand the bot something the user owns — a phone number, a + location, a chat, a poll — and Telegram's protocol makes them look like + every other button. tlgr will not press one without the flag that names + what is about to leave: without it, it prints what it *would* send and + exits 2. `buy` is refused outright, because paying is not something an + agent does on someone's behalf. + """ + if req.rich_button is not None: + _bots.unsupported("--rich-button") + if req.ephemeral is not None: + _bots.unsupported("--ephemeral (ephemeral.getCallbackAnswer)") + + peer = await _send.resolve(ctx, req.chat) + if req.webapp_req: + return await _answer_webapp_request(ctx, req, peer) + if req.msg_id is None: + raise UsageError("give a message id, or --webapp-req", field="msg_id") + + message = await _media.fetch_message(ctx, peer, req.msg_id) + markup = getattr(message, "reply_markup", None) + flat = _flatten(markup) + if not flat: + raise NotFoundError(f"message {req.msg_id} has no buttons") + row, col, index, button = _pick(flat, req) + kind = _bots.BUTTON_TYPES.get(type(button).__name__, "unsupported") + answer = Pressed(kind=kind, row=row, col=col, n=index, text=getattr(button, "text", None)) + return await _dispatch(ctx, req, peer, message, button, answer) + + +def _flatten(markup: Any) -> list[tuple[int, int, int, Any]]: + out: list[tuple[int, int, int, Any]] = [] + index = 0 + for row_index, row in enumerate(getattr(markup, "rows", None) or []): + for col_index, button in enumerate(getattr(row, "buttons", None) or []): + out.append((row_index, col_index, index, button)) + index += 1 + return out + + +def _pick(flat: list[tuple[int, int, int, Any]], req: PressReq) -> tuple[int, int, int, Any]: + """The addressed button: by payload, by coordinates, by index, or by text.""" + if req.data is not None: + wanted = _bots.payload_bytes(req.data, field="data") + for row, col, index, button in flat: + if getattr(button, "data", None) == wanted: + return row, col, index, button + raise NotFoundError("no callback button on that message carries that payload") + + if req.button is None: + if len(flat) == 1: + return flat[0] + raise UsageError( + f"the message has {len(flat)} buttons; name one with --button or --data", + field="button", + ) + + spec = req.button.strip() + if "," in spec: + head, _, tail = spec.partition(",") + try: + want = (int(head), int(tail)) + except ValueError as exc: + raise UsageError("--button: expected ','", field="button") from exc + for row, col, index, button in flat: + if (row, col) == want: + return row, col, index, button + raise NotFoundError(f"there is no button at row {want[0]}, column {want[1]}") + if spec.isdigit(): + number = int(spec) + for row, col, index, button in flat: + if index == number: + return row, col, index, button + raise NotFoundError(f"there is no button {number}; the message has {len(flat)}") + + exact = [e for e in flat if str(getattr(e[3], "text", "")) == spec] + if len(exact) == 1: + return exact[0] + lowered = spec.lower() + partial = [e for e in flat if lowered in str(getattr(e[3], "text", "")).lower()] + if len(partial) == 1: + return partial[0] + if not partial: + raise NotFoundError(f"no button matches {spec!r}") + raise UsageError( + f"{spec!r} matches {len(partial)} buttons; use --button ',' instead", + field="button", + ) + + +def _refuse(what: str, flag: str) -> Any: + raise UsageError( + f"this button would {what}; pass {flag} to allow it. Nothing was sent.", + field=flag.lstrip("-").replace("-", "_"), + ) + + +async def _dispatch( + ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed +) -> Pressed: + from telethon.tl import types + from telethon.tl.functions import messages as fn + + handle = client(ctx) + chat_id = _send.peer_id_of(peer) + kind = answer.kind + + if kind == "buy": + raise PermissionError_( + "a Pay button starts a payment, and tlgr never spends money on your behalf" + ) + if kind == "unsupported": + raise UsageError(f"tlgr cannot press a {type(button).__name__}", field="button") + + if kind == "text": + sent = await _invoke_as( + ctx, + req.business_connection, + fn.SendMessageRequest( + peer=peer, message=str(getattr(button, "text", "")), random_id=_random_id() + ), + ) + answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id + return answer + + if kind in ("callback", "game"): + return await _press_callback(ctx, req, peer, message, button, answer) + + if kind == "url": + answer.url = getattr(button, "url", None) + return answer + + if kind == "copy": + answer.copy_text = getattr(button, "copy_text", None) + return answer + + if kind == "url_auth": + result = await handle( + fn.RequestUrlAuthRequest( + peer=peer, msg_id=int(message.id), button_id=int(getattr(button, "button_id", 0)) + ) + ) + auth = _url_auth_model(result) + answer.auth = _bots_to_dict(auth) + answer.url = auth.url + return answer + + if kind == "user_profile": + user_id = int(getattr(button, "user_id", 0) or 0) + answer.user = {"id": user_id} + return answer + + if kind == "switch_inline": + target = req.switch_to if req.switch_to is not None else req.chat + where = await _send.resolve(ctx, target) + bot = await _bot_of(ctx, message) + if bot is None: + raise NotFoundError("the message does not say which bot to query") + results = await handle( + fn.GetInlineBotResultsRequest( + bot=bot, peer=where, query=str(getattr(button, "query", "") or ""), offset="" + ) + ) + answer.query_id = str(getattr(results, "query_id", "") or "") + answer.results = [ + {"id": str(getattr(entry, "id", "")), "type": str(getattr(entry, "type", ""))} + for entry in (getattr(results, "results", None) or []) + ] + return answer + + if kind in ("webview", "simple_webview"): + bot = await _bot_of(ctx, message) or await _bots.input_user(ctx, req.chat) + url = str(getattr(button, "url", "") or "") + if kind == "webview": + result = await handle( + fn.RequestWebViewRequest(peer=peer, bot=bot, platform=PLATFORM, url=url) + ) + else: + result = await handle( + fn.RequestSimpleWebViewRequest(bot=bot, platform=PLATFORM, url=url) + ) + answer.url = str(getattr(result, "url", "") or "") + query_id = getattr(result, "query_id", None) + answer.query_id = str(query_id) if query_id else None + return answer + + if kind == "request_phone": + if not req.share_phone: + _refuse("send the bot your phone number", "--share-phone") + me = await handle.get_me() + sent = await handle( + fn.SendMediaRequest( + peer=peer, + media=types.InputMediaContact( + phone_number=str(getattr(me, "phone", "") or ""), + first_name=str(getattr(me, "first_name", "") or ""), + last_name=str(getattr(me, "last_name", "") or ""), + vcard="", + ), + message="", + random_id=_random_id(), + ) + ) + answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id + return answer + + if kind == "request_geo": + if not req.share_geo: + _refuse("send the bot your location", "--share-geo") + lat, lon = _latlon(req.share_geo) + sent = await handle( + fn.SendMediaRequest( + peer=peer, + media=types.InputMediaGeoPoint(geo_point=types.InputGeoPoint(lat=lat, long=lon)), + message="", + random_id=_random_id(), + ) + ) + answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id + return answer + + if kind == "request_poll": + if not req.poll: + _refuse("create a poll in this chat", "--poll") + sent = await handle( + fn.SendMediaRequest( + peer=peer, media=_poll_media(req), message="", random_id=_random_id() + ) + ) + answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id + return answer + + if kind == "request_peer": + return await _press_request_peer(ctx, req, peer, message, button, answer) + + raise UsageError(f"tlgr cannot press a {kind} button", field="button") + + +def _bots_to_dict(model: Any) -> dict[str, Any]: + from tlgr.models.base import to_builtins + + value = to_builtins(model) + return value if isinstance(value, dict) else {} + + +async def _bot_of(ctx: OpContext, message: Any) -> Any: + """The `InputUser` of the bot that owns a message's buttons. + + Resolved through the account's own resolver rather than assembled from + the message: an `InputUser` needs an access hash, and the one on a message + object is frequently absent (`min` peers carry none at all). + """ + for attribute in ("via_bot_id", "from_id", "peer_id"): + value = getattr(message, attribute, None) + user_id = value if isinstance(value, int) else getattr(value, "user_id", None) + if user_id: + return await _bots.input_user(ctx, _bots.peer_ref(str(int(user_id))), field="chat") + return None + + +def _latlon(value: str) -> tuple[float, float]: + head, _, tail = str(value).partition(",") + try: + return float(head), float(tail) + except ValueError as exc: + raise UsageError("--share-geo: expected 'lat,lon'", field="share_geo") from exc + + +def _poll_media(req: PressReq) -> Any: + from telethon.tl import types + + question, _, options = str(req.poll).partition(":") + answers = [a.strip() for a in options.split(",") if a.strip()] + if len(answers) < 2: + raise UsageError("--poll: expected 'Question?:A,B,C'", field="poll") + if req.quiz and req.correct is None: + raise UsageError("--quiz needs --correct", field="correct") + return types.InputMediaPoll( + poll=types.Poll( + id=0, + question=types.TextWithEntities(text=question.strip(), entities=[]), + answers=[ + types.PollAnswer( + text=types.TextWithEntities(text=text, entities=[]), + option=bytes([index]), + ) + for index, text in enumerate(answers) + ], + quiz=req.quiz or None, + ), + correct_answers=[bytes([int(req.correct)])] if req.correct is not None else None, + ) + + +async def _press_callback( + ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed +) -> Pressed: + """A callback (or game) button, with the SRP dance when it needs one. + + `BOT_RESPONSE_TIMEOUT` is not an error: it means the bot is not running. + Reporting it as a failure would make "the bot is offline" indistinguishable + from "the press was rejected", so the answer comes back with a null message. + """ + from telethon.tl.functions import messages as fn + + from tlgr.ops import _auth + + handle = client(ctx) + is_game = answer.kind == "game" + + def build(check: Any) -> Any: + return fn.GetBotCallbackAnswerRequest( + peer=peer, + msg_id=int(message.id), + game=is_game or None, + data=None if is_game else getattr(button, "data", None), + password=check, + ) + + try: + if getattr(button, "requires_password", False): + if req.password is None: + raise UsageError( + "this button is protected by your 2FA password; " + "pass it with --password-env or --password-stdin", + field="password", + ) + result = await _auth.with_password(handle, build, req.password) + else: + result = await handle(build(None)) + except Exception as exc: # one specific server answer is not a failure + if "BOTRESPONSETIMEOUT" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""): + raise + ctx.warn("the bot did not answer in time; it is probably offline") + return answer + + answer.message = getattr(result, "message", None) + answer.alert = bool(getattr(result, "alert", False)) + answer.url = getattr(result, "url", None) + answer.native_ui = bool(getattr(result, "native_ui", False)) + answer.cache_time = getattr(result, "cache_time", None) + return answer + + +async def _press_request_peer( + ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed +) -> Pressed: + """Answer a request-peer button, or create the bot it asked for.""" + from telethon.tl.functions import bots as bots_fn + from telethon.tl.functions import messages as fn + + handle = client(ctx) + peer_type = getattr(button, "peer_type", None) + if type(peer_type).__name__ == "RequestPeerTypeCreateBot": + if not req.create_bot: + _refuse("create a bot owned by you", "--create-bot") + if not req.name or not req.username: + raise UsageError("--create-bot needs --name and --username", field="name") + created = await handle( + bots_fn.CreateBotRequest( + name=req.name, + username=req.username, + manager_id=await _bot_of(ctx, message) or await _bots.input_user(ctx, req.chat), + ) + ) + answer.peers = [ + int(getattr(u, "id", 0) or 0) for u in (getattr(created, "users", None) or []) + ] + return answer + + if not req.peers: + _refuse("disclose one of your chats to the bot", "--peers") + resolved = [await _send.resolve(ctx, value) for value in req.peers] + await handle( + fn.SendBotRequestedPeerRequest( + peer=peer, + msg_id=int(message.id), + button_id=int(getattr(button, "button_id", 0) or 0), + requested_peers=resolved, + ) + ) + answer.peers = [_send.peer_id_of(entry) for entry in resolved] + return answer + + +async def _answer_webapp_request(ctx: OpContext, req: PressReq, peer: Any) -> Pressed: + """Answer a mini app's peer request, addressed by `webapp_req_id`.""" + from telethon.tl.functions import bots as bots_fn + from telethon.tl.functions import messages as fn + + handle = client(ctx) + bot = await _bots.input_user(ctx, req.chat) + button = await handle( + bots_fn.GetRequestedWebViewButtonRequest(bot=bot, webapp_req_id=str(req.webapp_req)) + ) + answer = Pressed(kind="request_peer", text=getattr(button, "text", None)) + if not req.peers: + _refuse("disclose one of your chats to the mini app", "--peers") + resolved = [await _send.resolve(ctx, value) for value in req.peers] + await handle( + fn.SendBotRequestedPeerRequest( + peer=peer, + button_id=int(getattr(button, "button_id", 0) or 0), + requested_peers=resolved, + webapp_req_id=str(req.webapp_req), + ) + ) + answer.peers = [_send.peer_id_of(entry) for entry in resolved] + return answer + + +SPEC_PRESS = OperationSpec( + id="bot.press", + request=PressReq, + response=Pressed, + impl=press, + summary="Press a button on a message", + description=( + "One dispatcher for every button kind, returning a typed answer: a " + "callback toast, a URL, a signed mini-app session, inline results, a " + "peer prompt or copy text. A button that would disclose your phone " + "number, your location, a chat or a new poll is not pressed without " + "the flag that names it — tlgr prints what it would send and exits 2. " + "A Pay button is refused outright (exit 6)." + ), + aliases=("bot.click", "bot.button.press"), + mutating=True, + rate_class="send", + columns=("kind", "n", "message", "url"), + headers=("Kind", "#", "Answer", "URL"), + example={"kind": "callback", "n": 0, "message": "Saved", "alert": False}, + example_args="bot press @gifbot 12 --button 0", + covers=( + "bots.bot-ownership-transfer", + "bots.button-request-location", + "bots.button-request-peer", + "bots.button-request-phone", + "bots.button-request-poll", + "bots.callback-button-press", + "bots.callback-button-with-password", + "bots.copy-text-button", + "bots.managed-bot-request-button", + "bots.play-game", + "bots.reply-keyboard-press-text", + "bots.url-button", + "bots.user-profile-button", + "bots.webapp-request-phone", + ), + covers_partial=( + "bots.attach-webapp-open", + "bots.bot-privacy-mode", + "bots.button-request-peer-from-miniapp", + "bots.login-url-button", + "bots.switch-inline-button", + "bots.webapp-switch-inline-query", + ), + coverage_note=( + "Pressing surfaces each of these; completing them is `webapp open`, " + "`bot url-auth accept`, `inline query` and `inline send`." + ), +) + + +# --------------------------------------------------------------------------- +# bot url-auth +# --------------------------------------------------------------------------- + + +def _url_auth_model(result: Any) -> UrlAuth: + name = type(result).__name__ + if name == "UrlAuthResultAccepted": + return UrlAuth(result="accepted", url=getattr(result, "url", None)) + if name == "UrlAuthResultDefault": + return UrlAuth(result="default") + bot = getattr(result, "bot", None) + return UrlAuth( + result="request", + bot=str(getattr(bot, "username", "") or getattr(bot, "id", "") or "") or None, + domain=getattr(result, "domain", None), + verified_app_name=getattr(result, "verified_app_name", None), + is_app=bool(getattr(result, "is_app", False)), + browser=getattr(result, "browser", None), + platform=getattr(result, "platform", None), + ip=getattr(result, "ip", None), + region=getattr(result, "region", None), + request_write_access=bool(getattr(result, "request_write_access", False)), + request_phone_number=bool(getattr(result, "request_phone_number", False)), + match_codes=bool(getattr(result, "match_codes", False)), + match_codes_first=bool(getattr(result, "match_codes_first", False)), + user_id_hint=getattr(result, "user_id_hint", None), + ) + + +class UrlAuthGetReq(Request): + target: Annotated[ + str, arg(0, metavar="TARGET", help="Chat holding the button, or the OAuth deep link.") + ] + msg_id: Annotated[ + int | None, opt("--msg-id", metavar="ID", kind="msg_id", help="Message id for a button.") + ] = None + button_id: Annotated[ + int | None, opt("--button-id", metavar="N", help="Button id from the reply markup.") + ] = None + in_app_origin: Annotated[ + str | None, opt("--in-app-origin", metavar="ORIGIN", help="Origin of a mini-app request.") + ] = None + check_code: Annotated[ + str | None, opt("--check-code", metavar="CODE", help="Pre-validate this emoji match code.") + ] = None + + +async def _url_auth_request( + ctx: OpContext, target: str, msg_id: int | None, button_id: int | None, origin: str | None +) -> Any: + """The one `requestUrlAuth` with three addressing modes.""" + from telethon.tl.functions import messages as fn + + if target.startswith(("http://", "https://", "tg://")): + return await client(ctx)(fn.RequestUrlAuthRequest(url=target, in_app_origin=origin)) + if msg_id is None or button_id is None: + raise UsageError("addressing a button needs --msg-id and --button-id", field="msg_id") + peer = await _send.resolve(ctx, _bots.peer_ref(target)) + return await client(ctx)( + fn.RequestUrlAuthRequest(peer=peer, msg_id=int(msg_id), button_id=int(button_id)) + ) + + +async def url_auth_get(ctx: OpContext, req: UrlAuthGetReq) -> UrlAuth: + """Inspect a seamless-login request without accepting it. + + Three addressing modes reach one method: a keyboard button, a + `url_auth_domains` URL, and an OAuth deep link with the origin it came + from. Whichever it was, nothing is granted here — this command exists so + the domain, the browser and the IP can be *read* before the decision. + """ + from telethon.tl.functions import messages as fn + + result = await _url_auth_request(ctx, req.target, req.msg_id, req.button_id, req.in_app_origin) + model = _url_auth_model(result) + if req.check_code: + if not (model.match_codes and model.match_codes_first): + raise UsageError( + "--check-code only applies when the request sets match_codes_first", + field="check_code", + ) + model.code_valid = bool( + await client(ctx)( + fn.CheckUrlAuthMatchCodeRequest(url=req.target, match_code=req.check_code) + ) + ) + return model + + +SPEC_URL_AUTH_GET = OperationSpec( + id="bot.url-auth.get", + request=UrlAuthGetReq, + response=UrlAuth, + impl=url_auth_get, + summary="Inspect a seamless-login request without accepting it", + description=( + "Telegram Login hands a website your identity. What it is about to " + "hand over — the domain (or the verified app name), the browser, the " + "platform, the IP and the region — is printed here first, and " + "accepting is a separate command." + ), + aliases=("bot.url_auth.get", "bot.login-url.get", "link.auth", "auth.url-login"), + mutating=True, + tags=frozenset({"mutating-checked"}), + columns=("result", "domain", "bot", "request_write_access"), + headers=("Result", "Domain", "Bot", "Wants write"), + example={"result": "request", "domain": "example.org", "bot": "examplebot"}, + example_args="bot url-auth get @examplebot --msg-id 12 --button-id 0", + covers=( + "auth.oauth-deep-link", + "auth.url-auth-bot-button", + "contacts-users.url-auth-login", + "messages-core.url-authorization", + ), + covers_partial=( + "bots.login-url-button", + "bots.oauth-deeplink-login", + "bots.url-auth-match-code", + "bots.webapp-oauth-request", + ), + coverage_note=( + "Inspecting is this command; granting is `bot url-auth accept` and " + "refusing is `bot url-auth decline`." + ), +) + + +class UrlAuthAcceptReq(Request): + target: Annotated[ + str, arg(0, metavar="TARGET", help="Chat holding the button, or the OAuth deep link.") + ] + msg_id: Annotated[ + int | None, opt("--msg-id", metavar="ID", kind="msg_id", help="Message id for a button.") + ] = None + button_id: Annotated[ + int | None, opt("--button-id", metavar="N", help="Button id from the reply markup.") + ] = None + write_allowed: Annotated[ + bool, opt("--write-allowed", help="CONSENT: let the linked bot message me.") + ] = False + share_phone: Annotated[ + bool, opt("--share-phone", help="CONSENT: give the site my phone number.") + ] = False + match_code: Annotated[ + str | None, opt("--match-code", metavar="CODE", help="The emoji shown on the login page.") + ] = None + + +async def url_auth_accept(ctx: OpContext, req: UrlAuthAcceptReq) -> UrlAuth: + """Complete a seamless login and print the authorized URL. + + The request is inspected first, always: a match code that the server marks + `match_codes_first` has to be verified *before* accepting, and both + consent flags default off and are never inferred from the request having + asked for them. + """ + from telethon.tl.functions import messages as fn + + handle = client(ctx) + inspected = _url_auth_model( + await _url_auth_request(ctx, req.target, req.msg_id, req.button_id, None) + ) + if inspected.match_codes and not req.match_code: + raise UsageError( + "this login shows an emoji match code; pass it with --match-code", field="match_code" + ) + if inspected.match_codes_first and req.match_code: + ok = bool( + await handle(fn.CheckUrlAuthMatchCodeRequest(url=req.target, match_code=req.match_code)) + ) + if not ok: + raise PermissionError_("the match code does not match; nothing was authorized") + + kwargs: dict[str, Any] = { + "write_allowed": req.write_allowed or None, + "share_phone_number": req.share_phone or None, + "match_code": req.match_code, + } + if req.target.startswith(("http://", "https://", "tg://")): + kwargs["url"] = req.target + else: + kwargs["peer"] = await _send.resolve(ctx, _bots.peer_ref(req.target)) + kwargs["msg_id"] = req.msg_id + kwargs["button_id"] = req.button_id + result = await handle(fn.AcceptUrlAuthRequest(**kwargs)) + model = _url_auth_model(result) + model.domain = model.domain or inspected.domain + model.bot = model.bot or inspected.bot + model.write_allowed = req.write_allowed + model.phone_shared = req.share_phone + ctx.emit("bot_url_auth", {"domain": model.domain}) + return model + + +SPEC_URL_AUTH_ACCEPT = OperationSpec( + id="bot.url-auth.accept", + request=UrlAuthAcceptReq, + response=UrlAuth, + impl=url_auth_accept, + summary="Complete a seamless login and print the authorized URL", + description=( + "Destructive in the sense that matters: it logs you into a " + "third-party site under your Telegram identity, which cannot be taken " + "back from here. `--write-allowed` and `--share-phone` default off." + ), + aliases=("bot.url_auth.accept",), + mutating=True, + destructive=True, + columns=("result", "domain", "url"), + headers=("Result", "Domain", "URL"), + example={"result": "accepted", "url": "https://example.org/login?token=…"}, + example_args="bot url-auth accept @examplebot --msg-id 12 --button-id 0", + covers=("bots.login-url-button", "bots.url-auth-match-code", "bots.webapp-oauth-request"), + covers_partial=("bots.oauth-deeplink-login",), + coverage_note="Refusing an OAuth deep link is `bot url-auth decline`.", +) + + +class UrlAuthDeclineReq(Request): + url: Annotated[str, arg(0, metavar="URL", help="The OAuth deep link to decline.")] + + +async def url_auth_decline(ctx: OpContext, req: UrlAuthDeclineReq) -> UrlAuth: + """Refuse a seamless-login request.""" + from telethon.tl.functions import messages as fn + + await client(ctx)(fn.DeclineUrlAuthRequest(url=req.url)) + return UrlAuth(result="declined", declined=True, url=req.url) + + +SPEC_URL_AUTH_DECLINE = OperationSpec( + id="bot.url-auth.decline", + request=UrlAuthDeclineReq, + response=UrlAuth, + impl=url_auth_decline, + summary="Refuse a seamless-login request", + aliases=("bot.url_auth.decline",), + mutating=True, + columns=("result", "declined"), + headers=("Result", "Declined"), + example={"result": "declined", "declined": True}, + example_args="bot url-auth decline tg://oauth?domain=example.org", + covers=("bots.oauth-deeplink-login", "bots.url-auth-decline"), +) + + +# --------------------------------------------------------------------------- +# bot answer +# --------------------------------------------------------------------------- + + +class AnswerReq(Request): + kind: Annotated[ + str, + arg( + 0, + metavar="KIND", + help="callback|inline|shipping|precheckout|guest|webapp|webhook.", + ), + ] + query_id: Annotated[str, arg(1, metavar="QUERY_ID", help="Query id being answered.")] + text: Annotated[str | None, opt("--text", help="callback: toast or alert text.")] = None + alert: Annotated[bool, opt("--alert", help="callback: show a modal alert.")] = False + url: Annotated[str | None, opt("--url", metavar="URL", help="callback: deep link.")] = None + cache_time: Annotated[ + int | None, opt("--cache-time", metavar="SECONDS", help="Seconds clients may cache it.") + ] = None + results: Annotated[ + str | None, + opt("--results", metavar="PATH", kind="path", help="inline|guest|webapp: JSON results."), + ] = None + next_offset: Annotated[ + str | None, opt("--next-offset", metavar="TOKEN", help="inline: offset for the next page.") + ] = None + gallery: Annotated[bool, opt("--gallery", help="inline: render results as a grid.")] = False + private: Annotated[bool, opt("--private", help="inline: cache per user.")] = False + switch_pm: Annotated[ + str | None, opt("--switch-pm", metavar="TEXT:PARAM", help="inline: a button above them.") + ] = None + switch_webview: Annotated[ + str | None, opt("--switch-webview", metavar="TEXT:URL", help="inline: mini-app button.") + ] = None + options: Annotated[ + str | None, + opt("--options", metavar="PATH", kind="path", help="shipping: JSON shipping options."), + ] = None + ok: Annotated[bool, opt("--ok", help="shipping|precheckout: accept.")] = False + error: Annotated[ + str | None, opt("--error", metavar="TEXT", help="shipping|precheckout: rejection.") + ] = None + data: Annotated[ + str | None, opt("--data", metavar="JSON", kind="json", help="webhook: JSON payload.") + ] = None + + +_ANSWER_FLAGS = { + "callback": {"text", "alert", "url", "cache_time"}, + "inline": { + "results", + "next_offset", + "gallery", + "private", + "switch_pm", + "switch_webview", + "cache_time", + }, + "shipping": {"options", "ok", "error"}, + "precheckout": {"ok", "error"}, + "guest": {"results"}, + "webapp": {"results"}, + "webhook": {"data"}, +} + + +async def answer(ctx: OpContext, req: AnswerReq) -> BotAnswer: + """Answer one pending bot query. + + Seven query kinds, seven methods, one command — because a caller reading + `bot query list` has one loop to write, not seven. A flag that belongs to + another kind is a usage error rather than a silently ignored argument. + + Answering a pre-checkout query is not a payment: it approves or rejects one + the buyer has already started, and refusing to do it would leave that buyer + stuck. + """ + from telethon.tl.functions import bots as bots_fn + from telethon.tl.functions import messages as fn + + await _bots.require_bot_session(ctx, "answering a bot query") + allowed = _ANSWER_FLAGS.get(req.kind) + if allowed is None: + raise UsageError(f"{req.kind!r} is not a query kind", field="kind") + supplied = { + name + for name in set().union(*_ANSWER_FLAGS.values()) + if getattr(req, name, None) not in (None, False) + } + stray = sorted(supplied - allowed) + if stray: + raise UsageError( + f"{', '.join('--' + s.replace('_', '-') for s in stray)} " + f"does not belong to a {req.kind} answer", + field=stray[0], + ) + + handle = client(ctx) + query_id = _query_id(req.query_id) + if req.kind == "callback": + await handle( + fn.SetBotCallbackAnswerRequest( + query_id=query_id, + cache_time=int(req.cache_time or 0), + alert=req.alert or None, + message=req.text, + url=req.url, + ) + ) + elif req.kind == "inline": + await handle( + fn.SetInlineBotResultsRequest( + query_id=query_id, + results=_inline_results(req.results), + cache_time=int(req.cache_time or 0), + gallery=req.gallery or None, + private=req.private or None, + next_offset=req.next_offset, + switch_pm=_switch_pm(req.switch_pm), + switch_webview=_switch_webview(req.switch_webview), + ) + ) + elif req.kind == "shipping": + await handle( + fn.SetBotShippingResultsRequest( + query_id=query_id, + error=req.error, + shipping_options=_shipping_options(req.options), + ) + ) + elif req.kind == "precheckout": + await handle( + fn.SetBotPrecheckoutResultsRequest( + query_id=query_id, success=req.ok or None, error=req.error + ) + ) + elif req.kind == "guest": + await handle( + fn.SetBotGuestChatResultRequest( + query_id=query_id, result=_inline_results(req.results)[0] + ) + ) + elif req.kind == "webapp": + await handle( + fn.SendWebViewResultMessageRequest( + bot_query_id=str(req.query_id), result=_inline_results(req.results)[0] + ) + ) + else: + payload = _bots.data_json(req.data, field="data") + if payload is None: + raise UsageError("a webhook answer needs --data", field="data") + await handle(bots_fn.AnswerWebhookJSONQueryRequest(query_id=query_id, data=payload)) + + return BotAnswer(query_id=str(req.query_id), kind=req.kind, answered=True) + + +def _query_id(value: str) -> int: + try: + return int(value) + except ValueError as exc: + raise UsageError( + "query-id must be the numeric id from `bot query list`", field="query_id" + ) from exc + + +def _switch_pm(value: str | None) -> Any: + if not value: + return None + from telethon.tl import types + + text, _, param = value.partition(":") + return types.InlineBotSwitchPM(text=text, start_param=param) + + +def _switch_webview(value: str | None) -> Any: + if not value: + return None + from telethon.tl import types + + text, _, url = value.partition(":") + return types.InlineBotWebView(text=text, url=url) + + +def _shipping_options(path: str | None) -> Any: + if not path: + return None + from telethon.tl import types + + loaded = _bots.load_json(path, field="options") + return [ + types.ShippingOption( + id=str(entry.get("id", "")), + title=str(entry.get("title", "")), + prices=[ + types.LabeledPrice(label=str(p.get("label", "")), amount=int(p.get("amount", 0))) + for p in entry.get("prices", []) + ], + ) + for entry in loaded or [] + ] + + +def _inline_results(path: str | None) -> list[Any]: + """The `--results` JSON file as `InputBotInlineResult` objects.""" + from telethon.tl import types + + if not path: + raise UsageError("this answer needs --results", field="results") + loaded = _bots.load_json(path, field="results") + if isinstance(loaded, dict): + loaded = [loaded] + if not loaded: + raise UsageError("--results: the file holds no results", field="results") + out: list[Any] = [] + for entry in loaded: + message = entry.get("message") or {} + out.append( + types.InputBotInlineResult( + id=str(entry.get("id", "")), + type=str(entry.get("type", "article")), + send_message=types.InputBotInlineMessageText( + message=str(message.get("text", "")), + no_webpage=bool(message.get("no_preview")) or None, + reply_markup=_bots.keyboard_tl(message.get("reply_markup"), field="results"), + ), + title=entry.get("title"), + description=entry.get("description"), + url=entry.get("url"), + ) + ) + return out + + +SPEC_ANSWER = OperationSpec( + id="bot.answer", + request=AnswerReq, + response=BotAnswer, + impl=answer, + summary="Answer a pending bot query", + description=( + "Callback, inline, shipping, pre-checkout, guest, mini-app and " + "webhook queries, one flag set per kind. Answering a pre-checkout " + "query approves or rejects a payment the buyer already started, which " + "is why it is here and `payments.sendPaymentForm` is not." + ), + mutating=True, + rate_class="send", + columns=("query_id", "kind", "answered"), + headers=("Query", "Kind", "Answered"), + example={"query_id": "123456", "kind": "callback", "answered": True}, + example_args="bot answer callback 123456 --text Saved", + covers=( + "bots.answer-callback-query", + "bots.answer-inline-query", + "bots.answer-precheckout-query", + "bots.answer-shipping-query", + "bots.guest-mode-answer", + "bots.send-webview-result-message", + ), + covers_partial=("bots.send-custom-request",), + coverage_note="An arbitrary Bot-API method is `bot api send`.", +) + + +# --------------------------------------------------------------------------- +# bot query list +# --------------------------------------------------------------------------- + + +class QueryListReq(Request): + kind: Annotated[ + str | None, + choice( + "callback", + "inline", + "inline-send", + "shipping", + "precheckout", + "guest", + "webapp", + "webhook", + help="Filter by query kind.", + ), + ] = None + since: Annotated[ + str | None, opt("--since", metavar="WHEN", kind="datetime", help="Only newer than this.") + ] = None + resolve_message: Annotated[ + bool, opt("--resolve-message", help="Also fetch a callback's source message.") + ] = True + + +async def query_list(ctx: OpContext, req: QueryListReq) -> Page[BotQuery]: + """The bot queries the daemon is holding. + + The buffer is filled by `watch --bot-updates`, which belongs to the + updates group; until that is running this is an empty page rather than an + error, because "no queries" and "nobody is listening" look the same from + here and the honest answer is the empty one plus a warning. + """ + await _bots.require_bot_session(ctx, "listing bot queries") + limit, _state = window(ctx, "bot.query.list", PageKind.LOCAL, default=50) + buffer = getattr(getattr(ctx, "daemon", None), "bot_queries", None) + if buffer is None: + ctx.warn( + "the daemon is not buffering bot updates; start one with " + "`tlgr watch --bot-updates` to fill this list" + ) + return Page(items=[], has_more=False, total=0) + + since = _parse_since(req.since) + items: list[BotQuery] = [] + for entry in list(buffer)[:limit]: + row = _query_row(entry) + if req.kind and row.kind != req.kind: + continue + if since and (row.expires_at or "") < since: + continue + items.append(row) + return Page(items=items, has_more=False, total=len(items)) + + +def _parse_since(value: str | None) -> str: + if not value: + return "" + from tlgr.core.timefmt import parse_dt + + parsed = parse_dt(value) + return fmt_dt(parsed) or "" + + +def _query_row(entry: Any) -> BotQuery: + data = entry if isinstance(entry, dict) else {} + return BotQuery( + query_id=str(data.get("query_id", "")), + kind=str(data.get("kind", "")), + user_id=data.get("user_id"), + peer_id=data.get("peer_id"), + msg_id=data.get("msg_id"), + inline_msg_id=data.get("inline_msg_id"), + data=data.get("data"), + query=data.get("query"), + payload=data.get("payload"), + answered=bool(data.get("answered")), + expires_at=data.get("expires_at"), + message=data.get("message"), + ) + + +SPEC_QUERY_LIST = OperationSpec( + id="bot.query.list", + request=QueryListReq, + response=Page[BotQuery], + impl=query_list, + summary="List the bot queries the daemon is holding", + description=( + "An inline callback carries an `InputBotInlineMessageID` rather than a " + "message id and cannot be fetched at all, so its `message` is null " + "rather than missing." + ), + paginated=PageKind.LOCAL, + columns=("query_id", "kind", "user_id", "answered"), + headers=("Query", "Kind", "User", "Answered"), + example={"items": [{"query_id": "123456", "kind": "callback"}], "has_more": False}, + example_args="bot query list --kind callback", + covers=("bots.callback-query-message-get",), +) + + +# --------------------------------------------------------------------------- +# bot api send / connection +# --------------------------------------------------------------------------- + + +class ApiSendReq(Request): + method: Annotated[str, arg(0, metavar="METHOD", help="Bot-API method name.")] + params: Annotated[ + str, opt("--params", metavar="JSON", kind="json", help="JSON parameters.") + ] = "{}" + + +async def api_send(ctx: OpContext, req: ApiSendReq) -> BotApiResult: + """Call an arbitrary HTTP Bot-API method over MTProto. + + The escape hatch for the Bot-API surface tlgr has not modelled. The reply + is an opaque `DataJSON` and is passed through verbatim: parsing it would + be inventing a schema for a method tlgr does not know. + """ + from telethon.tl.functions import bots as fn + + await _bots.require_bot_session(ctx, "bot api send") + payload = _bots.data_json(req.params, field="params") + result = await client(ctx)( + fn.SendCustomRequestRequest(custom_method=req.method, params=payload) + ) + return BotApiResult(method=req.method, result=_data_json(result)) + + +def _data_json(result: Any) -> Any: + import json + + text = getattr(result, "data", None) + if not text: + return None + try: + return json.loads(text) + except json.JSONDecodeError: + return text + + +SPEC_API_SEND = OperationSpec( + id="bot.api.send", + request=ApiSendReq, + response=BotApiResult, + impl=api_send, + summary="Call an arbitrary Bot-API method through the MTProto session", + mutating=True, + columns=("method",), + headers=("Method",), + example={"method": "getMe", "result": {"id": 93372553}}, + example_args='bot api send getMe --params "{}"', + covers=("bots.send-custom-request",), +) + + +class ConnectionGetReq(Request): + connection_id: Annotated[str, arg(0, metavar="CONNECTION_ID", help="Business connection id.")] + + +async def connection_get(ctx: OpContext, req: ConnectionGetReq) -> BusinessConnection: + """A business connection my bot acts through. + + The `dc_id` is not decoration: every wrapped call has to be sent *there*, + which is why it is reported rather than hidden inside the wrapper. + """ + from telethon.tl.functions import account as fn + + await _bots.require_bot_session(ctx, "reading a business connection") + result = await client(ctx)(fn.GetBotBusinessConnectionRequest(connection_id=req.connection_id)) + connection = None + for update in getattr(result, "updates", None) or []: + connection = getattr(update, "connection", None) or connection + date = getattr(connection, "date", None) + return BusinessConnection( + connection_id=str(getattr(connection, "connection_id", req.connection_id)), + user_id=getattr(connection, "user_id", None), + dc_id=getattr(connection, "dc_id", None), + date=fmt_dt(date), + date_unix=to_unix(date), + rights=_bots.rights_keywords(getattr(connection, "rights", None)), + disabled=bool(getattr(connection, "disabled", False)), + ) + + +SPEC_CONNECTION_GET = OperationSpec( + id="bot.connection.get", + request=ConnectionGetReq, + response=BusinessConnection, + impl=connection_get, + summary="Show a business connection my bot is acting through", + columns=("connection_id", "user_id", "dc_id", "disabled"), + headers=("Connection", "User", "DC", "Disabled"), + example={"connection_id": "abc123", "user_id": 4242, "dc_id": 2}, + example_args="bot connection get abc123", + covers=("bots.business-connection-info",), +) + + +class ConnectionInvokeReq(Request): + connection_id: Annotated[str, arg(0, metavar="CONNECTION_ID", help="Business connection id.")] + command: Annotated[ + list[str], + arg(1, metavar="COMMAND", variadic=True, help="The tlgr command to wrap."), + ] = [] + + +async def connection_invoke(ctx: OpContext, req: ConnectionInvokeReq) -> BusinessConnection: + """Run another tlgr command on behalf of a business account. + + Wrapping is not a flag on a request: the wrapped query must be sent to the + connection's own DC through an exported sender, which is why the + connection is fetched first and the DC reported back. + + The wrapper is also available inline as `--business-connection` on + `bot command send`, `bot press` and `inline send`; this command exists for + the operations that do not carry the flag yet. + """ + if not req.command: + raise UsageError("give a tlgr command to wrap", field="command") + raise _bots.unsupported( + "bot connection invoke", + "wrapping an arbitrary tlgr operation needs the daemon's own dispatcher, " + "which `ops/` may not import (§2.2); use the --business-connection flag on " + "bot command send, bot press or inline send instead", + ) + + +SPEC_CONNECTION_INVOKE = OperationSpec( + id="bot.connection.invoke", + request=ConnectionInvokeReq, + response=BusinessConnection, + impl=connection_invoke, + summary="Run another tlgr command on behalf of a business account", + description=( + "Registered and refused with exit 13 rather than left out: the " + "wrapper itself works and is reachable as `--business-connection` on " + "the commands that carry it, but re-entering the dispatcher from " + "inside an operation would break the layering rule that keeps `ops/` " + "importable without the daemon." + ), + mutating=True, + columns=("connection_id",), + headers=("Connection",), + example={"connection_id": "abc123"}, + example_args="bot connection invoke abc123 message send @alice hi", + covers_partial=("bots.business-invoke-with-connection", "updates.invoke-business-connection"), + coverage_note=( + "The wrapper is implemented on `bot command send`, `bot press` and " + "`inline send`; wrapping an arbitrary command is refused with exit 13." + ), +) + + +# --------------------------------------------------------------------------- +# bot stream send +# --------------------------------------------------------------------------- + + +class StreamSendReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Destination chat.")] + draft_id: Annotated[int, opt("--draft-id", metavar="ID", help="Draft random_id.")] = 0 + text: Annotated[str | None, opt("--text", help="Next text chunk.")] = None + rich_file: Annotated[ + str | None, opt("--rich-file", metavar="PATH", kind="path", help="Next chunk, rich.") + ] = None + file: Annotated[ + str | None, + opt("--file", metavar="PATH", kind="path", help="Read chunks from a file, one per line."), + ] = None + topic: Annotated[ + int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.") + ] = None + can_stop: Annotated[bool, opt("--can-stop", help="Let the user stop the generation.")] = False + keep_on_stop: Annotated[ + bool, opt("--keep-on-stop", help="Keep the partial answer if the user stops it.") + ] = False + stop: Annotated[bool, opt("--stop", help="End the stream.")] = False + + +async def stream_send(ctx: OpContext, req: StreamSendReq) -> StreamProgress: + """Stream a live draft — an answer being generated — into a chat. + + The server allows 20 calls per 5 s and 40 per 30 s *per peer* and answers + a burst with a one-to-three second FloodWait. Retrying that is the wrong + shape: the chunks would arrive late and out of order. tlgr paces itself + through the session limiter and coalesces what it cannot send in time. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + await _bots.require_bot_session(ctx, "streaming a live draft") + if req.can_stop or req.keep_on_stop or req.stop: + _bots.unsupported("--can-stop/--keep-on-stop/--stop") + if not req.draft_id: + raise UsageError("--draft-id is required; it is what keys the stream", field="draft_id") + + chunks = _stream_chunks(req) + if not chunks: + raise UsageError("give --text, --rich-file or --file", field="text") + + peer = await _send.resolve(ctx, req.chat) + handle = client(ctx) + limiter = getattr(ctx, "limiter", None) + for chunk in chunks: + action = types.SendMessageTextDraftAction( + text=types.TextWithEntities(text=chunk, entities=[]), random_id=req.draft_id + ) + await handle(fn.SetTypingRequest(peer=peer, action=action, top_msg_id=req.topic)) + if limiter is not None: + await limiter.acquire("send") + return StreamProgress( + chat_id=_send.peer_id_of(peer), draft_id=req.draft_id, chunks_sent=len(chunks) + ) + + +def _stream_chunks(req: StreamSendReq) -> list[str]: + import os + from pathlib import Path + + if req.text: + return [req.text] + source = req.rich_file or req.file + if not source: + return [] + path = Path(os.path.expanduser(source)) + try: + body = path.read_text(encoding="utf-8") + except OSError as exc: + raise UsageError(f"--file: {exc.strerror or exc}", field="file") from exc + if req.rich_file: + return [body] + return [line for line in body.splitlines() if line.strip()] + + +SPEC_STREAM_SEND = OperationSpec( + id="bot.stream.send", + request=StreamSendReq, + response=StreamProgress, + impl=stream_send, + summary="Stream a live draft into a chat", + mutating=True, + rate_class="send", + columns=("chat_id", "draft_id", "chunks_sent"), + headers=("Chat", "Draft", "Chunks"), + example={"chat_id": 4242, "draft_id": 99, "chunks_sent": 3}, + example_args="bot stream send @alice --draft-id 99 --text Thinking…", + covers=("bots.ai-live-draft-streaming", "bots.rich-message-draft-stream"), +) + + +# --------------------------------------------------------------------------- +# bot create / edit / username / token +# --------------------------------------------------------------------------- + + +class CreateReq(Request): + name: Annotated[str, opt("--name", help="Display name.")] = "" + username: Annotated[str, opt("--username", help="Username (must end in 'bot').")] = "" + manager: Annotated[ + PeerRef | None, + opt("--manager", metavar="USER", kind="user", help="Manager bot that owns the token."), + ] = None + about: Annotated[str | None, opt("--about", help="Short about text.")] = None + check_only: Annotated[ + bool, opt("--check-only", help="Only report whether the username is free.") + ] = False + + +async def create(ctx: OpContext, req: CreateReq) -> BotCreated: + """Create a managed bot without going through @BotFather. + + The username is checked first, always: `bots.createBot` consumes one of a + small per-account quota, and burning one on a name that was never free is + not recoverable. + """ + from telethon.tl import types + from telethon.tl.functions import bots as fn + + if not req.name or not req.username: + raise UsageError("--name and --username are both required", field="username") + + handle = client(ctx) + free = bool(await handle(fn.CheckUsernameRequest(username=req.username))) + if not free: + raise UsageError(f"@{req.username} is not available", field="username") + if req.check_only: + return BotCreated(username=req.username, token_available=False) + + manager = ( + await _bots.input_user(ctx, req.manager, field="manager") + if req.manager is not None + else types.InputUserSelf() + ) + result = await handle( + fn.CreateBotRequest(name=req.name, username=req.username, manager_id=manager) + ) + users = getattr(result, "users", None) or [] + bot_id = int(getattr(users[0], "id", 0) or 0) if users else 0 + if req.about: + await handle( + fn.SetBotInfoRequest( + lang_code="", + bot=types.InputUser( + user_id=bot_id, access_hash=int(getattr(users[0], "access_hash", 0) or 0) + ), + about=req.about, + ) + ) + ctx.emit("bot_create", {"bot_id": bot_id, "username": req.username}) + return BotCreated( + bot_id=bot_id, + username=req.username, + manager=_send.peer_id_of(manager) if req.manager is not None else None, + token_available=True, + ) + + +SPEC_CREATE = OperationSpec( + id="bot.create", + request=CreateReq, + response=BotCreated, + impl=create, + summary="Create a managed bot without BotFather", + description=( + "A managed bot's token is exported with `bot token export`, which is " + "what makes this worth having: the whole lifecycle stays in one tool." + ), + mutating=True, + columns=("bot_id", "username", "token_available"), + headers=("Bot", "Username", "Token"), + example={"bot_id": 5000001, "username": "my_helper_bot", "token_available": True}, + example_args="bot create --name Helper --username my_helper_bot", + covers=("bots.create-managed-bot",), +) + + +class EditReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")] + name: Annotated[str | None, opt("--name", help="Display name.")] = None + about: Annotated[str | None, opt("--about", help="Short about text (profile).")] = None + description: Annotated[ + str | None, opt("--description", help="Long description shown in an empty chat.") + ] = None + lang: Annotated[str, opt("--lang", metavar="CODE", help="Language these values apply to.")] = "" + photo: Annotated[ + str | None, opt("--photo", metavar="PATH", kind="path", help="Profile photo or video.") + ] = None + video: Annotated[bool, opt("--video", help="Treat --photo as a video.")] = False + video_start: Annotated[ + float | None, opt("--video-start", metavar="SECONDS", help="Video cover timestamp.") + ] = None + remove_photo: Annotated[ + bool, opt("--remove-photo", help="Delete the current profile photo.") + ] = False + + +async def edit(ctx: OpContext, req: EditReq) -> BotEdited: + """Edit my bot's name, about text, description and profile photo. + + `bot=` is what makes this the *owner* side of `bots.setBotInfo`; omitting + it would edit the calling account instead, which is a bug you only notice + after your own profile changed. + """ + from telethon.tl.functions import bots as fn + from telethon.tl.functions import photos as photos_fn + + bot = await _bots.input_user(ctx, req.bot) + peer = await _send.resolve(ctx, req.bot) + handle = client(ctx) + bot_id = _send.peer_id_of(peer) + + if req.name or req.about or req.description: + await handle( + fn.SetBotInfoRequest( + lang_code=req.lang, + bot=bot, + name=req.name, + about=req.about, + description=req.description, + ) + ) + + photo_id: int | None = None + if req.remove_photo: + full, _user = await _full(ctx, peer) + current = getattr(full, "profile_photo", None) + if current is not None: + from telethon.tl import types + + await handle( + photos_fn.DeletePhotosRequest( + id=[ + types.InputPhoto( + id=int(getattr(current, "id", 0) or 0), + access_hash=int(getattr(current, "access_hash", 0) or 0), + file_reference=getattr(current, "file_reference", b"") or b"", + ) + ] + ) + ) + elif req.photo: + import os + from pathlib import Path + + upload = getattr(ctx, "upload_file", None) + if upload is None: # pragma: no cover - the daemon always supplies one + raise UsageError("this context cannot upload files") + path = Path(os.path.expanduser(req.photo)) + if not path.exists(): + raise UsageError(f"--photo: {path} does not exist", field="photo") + handle_file = await upload(path) + result = await handle( + photos_fn.UploadProfilePhotoRequest( + bot=bot, + file=None if req.video else handle_file, + video=handle_file if req.video else None, + video_start_ts=req.video_start, + ) + ) + photo_id = _id_of(getattr(result, "photo", None)) + + ctx.emit("bot_edit", {"bot_id": bot_id}) + return BotEdited( + bot_id=bot_id, + name=req.name, + about=req.about, + description=req.description, + lang=req.lang or None, + photo_id=photo_id, + ) + + +SPEC_EDIT = OperationSpec( + id="bot.edit", + request=EditReq, + response=BotEdited, + impl=edit, + summary="Edit my bot's name, about text, description and photo", + mutating=True, + rate_class="file", + columns=("bot_id", "name", "lang"), + headers=("Bot", "Name", "Lang"), + example={"bot_id": 5000001, "name": "Helper", "lang": "en"}, + example_args="bot edit @my_helper_bot --name Helper", + covers=( + "bot.profile-photo-set", + "bots.bot-forums", + "bots.set-bot-info", + "bots.set-bot-photo", + ), +) + + +class UsernameCheckReq(Request): + username: Annotated[str, arg(0, metavar="USERNAME", help="Candidate username.")] + + +async def username_check(ctx: OpContext, req: UsernameCheckReq) -> BotUsernameCheck: + """Is a bot username free?""" + from telethon.tl.functions import bots as fn + + try: + free = bool(await client(ctx)(fn.CheckUsernameRequest(username=req.username))) + except Exception as exc: # the server's reason IS the answer here + name = type(exc).__name__.upper() + if "USERNAME" not in name: + raise + return BotUsernameCheck(username=req.username, available=False, reason=type(exc).__name__) + return BotUsernameCheck(username=req.username, available=free) + + +SPEC_USERNAME_CHECK = OperationSpec( + id="bot.username.check", + request=UsernameCheckReq, + response=BotUsernameCheck, + impl=username_check, + summary="Check whether a bot username is available", + columns=("username", "available", "reason"), + headers=("Username", "Free", "Reason"), + example={"username": "my_helper_bot", "available": True}, + example_args="bot username check my_helper_bot", + covers=("bots.check-bot-username",), +) + + +class UsernameSetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")] + enable: Annotated[ + list[str], opt("--enable", metavar="NAME", help="Usernames to activate.") + ] = [] + disable: Annotated[ + list[str], opt("--disable", metavar="NAME", help="Usernames to deactivate.") + ] = [] + order: Annotated[str | None, opt("--order", metavar="A,B,C", help="New display order.")] = None + + +async def username_set(ctx: OpContext, req: UsernameSetReq) -> BotUsernames: + """Enable, disable and reorder my bot's public usernames.""" + from telethon.tl.functions import bots as fn + + bot = await _bots.input_user(ctx, req.bot) + handle = client(ctx) + for name in req.enable: + await handle(fn.ToggleUsernameRequest(bot=bot, username=name.lstrip("@"), active=True)) + for name in req.disable: + await handle(fn.ToggleUsernameRequest(bot=bot, username=name.lstrip("@"), active=False)) + if req.order: + order = [n.strip().lstrip("@") for n in req.order.split(",") if n.strip()] + await handle(fn.ReorderUsernamesRequest(bot=bot, order=order)) + + peer = await _send.resolve(ctx, req.bot) + _full_user, user = await _full(ctx, peer) + return BotUsernames(bot_id=_send.peer_id_of(peer), usernames=_usernames(user)) + + +SPEC_USERNAME_SET = OperationSpec( + id="bot.username.set", + request=UsernameSetReq, + response=BotUsernames, + impl=username_set, + summary="Enable, disable and reorder my bot's usernames", + mutating=True, + columns=("bot_id", "usernames"), + headers=("Bot", "Usernames"), + example={"bot_id": 5000001, "usernames": ["my_helper_bot"]}, + example_args="bot username set @my_helper_bot --enable my_helper_bot", + covers=("bots.bot-usernames",), +) + + +class TokenExportReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")] + revoke: Annotated[ + bool, opt("--revoke", help="Invalidate the old token and issue a new one.") + ] = False + out: Annotated[ + str | None, opt("--out", metavar="PATH", kind="path", help="Write it here, mode 0600.") + ] = None + show: Annotated[bool, opt("--show", help="Print the token; it is redacted by default.")] = False + + +async def token_export(ctx: OpContext, req: TokenExportReq) -> BotToken: + """Export (or revoke and re-export) a managed bot's API token. + + The returned string is a full credential: anyone holding it *is* the bot. + It is therefore not printed unless `--show` or `--out` says so, and + `--out` writes with mode 0600 rather than leaving it in shell history. + """ + import os + from pathlib import Path + + from telethon.tl.functions import bots as fn + + bot = await _bots.input_user(ctx, req.bot) + peer = await _send.resolve(ctx, req.bot) + token = str(await client(ctx)(fn.ExportBotTokenRequest(bot=bot, revoke=req.revoke)) or "") + result = BotToken(bot_id=_send.peer_id_of(peer), revoked=req.revoke) + + if req.out: + path = Path(os.path.expanduser(req.out)) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(descriptor, token.encode()) + finally: + os.close(descriptor) + result.path = str(path) + if req.show: + result.token = token + elif not req.out: + ctx.warn("the token is redacted; pass --show to print it or --out to write it to a file") + return result + + +SPEC_TOKEN_EXPORT = OperationSpec( + id="bot.token.export", + request=TokenExportReq, + response=BotToken, + impl=token_export, + summary="Export a managed bot's API token", + description=( + "`--revoke` breaks every deployment still using the old token, which " + "is why it is confirmed like a deletion." + ), + mutating=True, + destructive=True, + columns=("bot_id", "revoked", "path"), + headers=("Bot", "Revoked", "Path"), + example={"bot_id": 5000001, "revoked": False}, + example_args="bot token export @my_helper_bot --out ./token", + covers=("bots.managed-bot-token",), +) + + +# --------------------------------------------------------------------------- +# bot access get / set +# --------------------------------------------------------------------------- + + +class AccessGetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")] + + +async def access_get(ctx: OpContext, req: AccessGetReq) -> BotAccess: + """Who is allowed to use a managed bot.""" + from telethon.tl.functions import bots as fn + + settings = await client(ctx)( + fn.GetAccessSettingsRequest(bot=await _bots.input_user(ctx, req.bot)) + ) + return _access(settings) + + +SPEC_ACCESS_GET = OperationSpec( + id="bot.access.get", + request=AccessGetReq, + response=BotAccess, + impl=access_get, + summary="Show who may use a managed bot", + columns=("restricted", "allowed_users", "allowed_chats"), + headers=("Restricted", "Users", "Chats"), + example={"restricted": True, "allowed_users": [4242], "allowed_chats": []}, + example_args="bot access get @my_helper_bot", + covers_partial=("bots.managed-bot-access-settings",), + coverage_note="Changing the list is `bot access set`.", +) + + +class AccessSetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")] + restricted: Annotated[ + bool, opt("--restricted", help="Only the listed peers may use the bot.") + ] = False + open_to_all: Annotated[bool, opt("--open", help="Anyone may use the bot.")] = False + add: Annotated[ + list[PeerRef], opt("--add", metavar="USER", kind="user", help="Peers to allow.") + ] = [] + remove: Annotated[ + list[PeerRef], opt("--remove", metavar="USER", kind="user", help="Peers to disallow.") + ] = [] + + +async def access_set(ctx: OpContext, req: AccessSetReq) -> BotAccess: + """Restrict or open who may use a managed bot. + + `bots.editAccessSettings` takes the whole allow-list, not a delta, so + `--add`/`--remove` are applied to the list the server currently holds + rather than replacing it — otherwise adding one user would silently drop + everybody else. + """ + from telethon.tl.functions import bots as fn + + if req.restricted and req.open_to_all: + raise UsageError("--restricted and --open contradict each other", field="restricted") + + bot = await _bots.input_user(ctx, req.bot) + handle = client(ctx) + current = _access(await handle(fn.GetAccessSettingsRequest(bot=bot))) + + allowed = list(current.allowed_users) + for ref in req.add: + peer_id = _send.peer_id_of(await _send.resolve(ctx, ref)) + if peer_id not in allowed: + allowed.append(peer_id) + for ref in req.remove: + peer_id = _send.peer_id_of(await _send.resolve(ctx, ref)) + allowed = [entry for entry in allowed if entry != peer_id] + + users = [ + await _bots.input_user(ctx, _bots.peer_ref(str(peer_id)), field="add") + for peer_id in allowed + ] + restricted = True if req.restricted else False if req.open_to_all else current.restricted + await handle( + fn.EditAccessSettingsRequest( + bot=bot, restricted=restricted or None, add_users=users or None + ) + ) + return BotAccess( + restricted=restricted, allowed_users=allowed, allowed_chats=current.allowed_chats + ) + + +SPEC_ACCESS_SET = OperationSpec( + id="bot.access.set", + request=AccessSetReq, + response=BotAccess, + impl=access_set, + summary="Restrict or open who may use a managed bot", + mutating=True, + columns=("restricted", "allowed_users"), + headers=("Restricted", "Users"), + example={"restricted": True, "allowed_users": [4242]}, + example_args="bot access set @my_helper_bot --restricted --add @alice", + covers=("bots.managed-bot-access-settings",), +) + + +# --------------------------------------------------------------------------- +# bot default-rights set +# --------------------------------------------------------------------------- + + +class DefaultRightsReq(Request): + group: Annotated[ + str | None, opt("--group", metavar="RIGHTS", help="'+'-joined rights for groups.") + ] = None + channel: Annotated[ + str | None, opt("--channel", metavar="RIGHTS", help="'+'-joined rights for channels.") + ] = None + + +async def default_rights_set(ctx: OpContext, req: DefaultRightsReq) -> DefaultRights: + """The admin rights clients pre-tick when my bot is added somewhere. + + A suggestion, not a grant: the person adding the bot still confirms it. + Reading them back is `bot get`. + """ + from telethon.tl.functions import bots as fn + + await _bots.require_bot_session(ctx, "setting suggested admin rights") + if not req.group and not req.channel: + raise UsageError("give --group and/or --channel", field="group") + + handle = client(ctx) + result = DefaultRights() + if req.group: + rights = _bots.admin_rights(req.group, field="group") + await handle(fn.SetBotGroupDefaultAdminRightsRequest(admin_rights=rights)) + result.group_rights = _bots.rights_keywords(rights) + if req.channel: + rights = _bots.admin_rights(req.channel, field="channel") + await handle(fn.SetBotBroadcastDefaultAdminRightsRequest(admin_rights=rights)) + result.channel_rights = _bots.rights_keywords(rights) + return result + + +SPEC_DEFAULT_RIGHTS_SET = OperationSpec( + id="bot.default-rights.set", + request=DefaultRightsReq, + response=DefaultRights, + impl=default_rights_set, + summary="Set the admin rights clients pre-tick for my bot", + aliases=("bot.default_rights.set",), + mutating=True, + columns=("group_rights", "channel_rights"), + headers=("Group", "Channel"), + example={"group_rights": ["delete_messages"], "channel_rights": []}, + example_args="bot default-rights set --group delete_messages+invite_users", + covers=("bots.suggested-admin-rights",), +) + + +# --------------------------------------------------------------------------- +# bot verification get / set +# --------------------------------------------------------------------------- + + +class VerificationGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="User, bot or channel.")] + + +async def verification_get(ctx: OpContext, req: VerificationGetReq) -> BotVerification: + """A peer's third-party verification badge. + + Distinct from Telegram's own blue check: a bot-issued badge says a company + vouches for this peer, which is a different claim, so both are reported. + """ + from telethon.tl.functions import channels as channels_fn + + peer = await _send.resolve(ctx, req.chat) + if type(peer).__name__ == "InputPeerChannel": + result = await client(ctx)(channels_fn.GetFullChannelRequest(channel=_input_channel(peer))) + full = getattr(result, "full_chat", None) + chats = {int(getattr(c, "id", 0)): c for c in (getattr(result, "chats", None) or [])} + entity = chats.get(int(getattr(full, "id", 0) or 0)) + else: + full, entity = await _full(ctx, peer) + return _verification(full, entity) or BotVerification() + + +SPEC_VERIFICATION_GET = OperationSpec( + id="bot.verification.get", + request=VerificationGetReq, + response=BotVerification, + impl=verification_get, + summary="Show a peer's third-party verification badge", + columns=("verified_by_bot", "description", "telegram_verified"), + headers=("By bot", "Description", "Telegram"), + example={"verified_by_bot": 5000001, "description": "Verified merchant"}, + example_args="bot verification get @alice", + covers=("bots.bot-verification-view",), +) + + +class VerificationSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Peer to verify.")] + bot: Annotated[ + PeerRef | None, opt("--bot", metavar="BOT", kind="user", help="My verifier bot.") + ] = None + description: Annotated[str | None, opt("--description", help="Custom badge description.")] = ( + None + ) + remove: Annotated[bool, opt("--remove", help="Remove the verification.")] = False + + +async def verification_set(ctx: OpContext, req: VerificationSetReq) -> BotVerified: + """Verify or unverify a peer with my verifier bot.""" + from telethon.tl.functions import bots as fn + + if req.bot is None: + raise UsageError("--bot names the verifier bot and is required", field="bot") + peer = await _send.resolve(ctx, req.chat) + await client(ctx)( + fn.SetCustomVerificationRequest( + peer=peer, + enabled=None if req.remove else True, + bot=await _bots.input_user(ctx, req.bot), + custom_description=req.description, + ) + ) + return BotVerified( + peer_id=_send.peer_id_of(peer), + verified=not req.remove, + description=req.description, + ) + + +SPEC_VERIFICATION_SET = OperationSpec( + id="bot.verification.set", + request=VerificationSetReq, + response=BotVerified, + impl=verification_set, + summary="Verify or unverify a peer with my verifier bot", + mutating=True, + destructive=True, + columns=("peer_id", "verified", "description"), + headers=("Peer", "Verified", "Description"), + example={"peer_id": 4242, "verified": True}, + example_args="bot verification set @alice --bot @my_verifier_bot", + covers=("bots.bot-verification-set",), +) + + +# --------------------------------------------------------------------------- +# bot preview list / add / edit / delete +# --------------------------------------------------------------------------- + + +class PreviewListReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")] + lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = "" + owner: Annotated[bool, opt("--owner", help="Owner view, including per-language sets.")] = False + + +def _preview(media: Any, index: int, lang: str | None) -> PreviewMedia: + inner = getattr(media, "media", media) + date = getattr(media, "date", None) + document = getattr(inner, "document", None) + photo = getattr(inner, "photo", None) + target = document if document is not None else photo + return PreviewMedia( + index=index, + kind="video" if document is not None else "photo", + date=fmt_dt(date), + date_unix=to_unix(date), + lang=lang or None, + file_id=_id_of(target), + size=getattr(target, "size", None), + ) + + +async def preview_list(ctx: OpContext, req: PreviewListReq) -> Page[PreviewMedia]: + """The mini-app preview gallery on a bot's profile. + + `userFull.has_preview_medias` says whether this call is worth making at + all, and `bot get` reports it — asking for a gallery that does not exist + is a round trip for an empty list. + """ + from telethon.tl.functions import bots as fn + + bot = await _bots.input_user(ctx, req.bot) + if req.owner: + result = await client(ctx)(fn.GetPreviewInfoRequest(bot=bot, lang_code=req.lang)) + media = getattr(result, "media", None) or [] + else: + media = await client(ctx)(fn.GetPreviewMediasRequest(bot=bot)) or [] + items = [_preview(entry, index, req.lang) for index, entry in enumerate(media)] + return Page(items=items, has_more=False, total=len(items)) + + +SPEC_PREVIEW_LIST = OperationSpec( + id="bot.preview.list", + request=PreviewListReq, + response=Page[PreviewMedia], + impl=preview_list, + summary="List a bot's mini-app preview media", + columns=("index", "kind", "lang", "file_id"), + headers=("#", "Kind", "Lang", "File"), + example={"items": [{"index": 0, "kind": "photo"}], "has_more": False}, + example_args="bot preview list @my_helper_bot", + covers=("bot.media-previews", "bots.preview-info-per-language", "bots.preview-medias-list"), +) + + +class PreviewAddReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")] + file: Annotated[str, arg(1, metavar="FILE", kind="path", help="Image or video.")] + lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = "" + + +async def _uploaded_media(ctx: OpContext, source: str) -> Any: + """A local file as the `InputMedia` a `bots.*PreviewMedia*` call wants. + + `messages.uploadMedia` is the step that turns an uploaded file handle into + a document the server already holds; handing the raw handle to + `addPreviewMedia` would upload it again for every call. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + media = await _send.input_media(ctx, source) + stored = await client(ctx)(fn.UploadMediaRequest(peer=types.InputPeerSelf(), media=media)) + document = _media.document_of(stored) + if document is not None: + return types.InputMediaDocument(id=_media.input_document(document)) + photo = getattr(stored, "photo", None) + if photo is None: + raise NotFoundError("the server did not accept that file as preview media") + return types.InputMediaPhoto(id=_media.input_photo(photo)) + + +async def preview_add(ctx: OpContext, req: PreviewAddReq) -> PreviewChange: + """Add one preview media to my bot's mini-app gallery.""" + from telethon.tl.functions import bots as fn + + bot = await _bots.input_user(ctx, req.bot) + media = await _uploaded_media(ctx, req.file) + result = await client(ctx)(fn.AddPreviewMediaRequest(bot=bot, lang_code=req.lang, media=media)) + document = getattr(getattr(result, "media", result), "document", None) + return PreviewChange( + index=0, kind="video" if document is not None else "photo", lang=req.lang or None + ) + + +SPEC_PREVIEW_ADD = OperationSpec( + id="bot.preview.add", + request=PreviewAddReq, + response=PreviewChange, + impl=preview_add, + summary="Add a preview media to my bot's mini-app gallery", + mutating=True, + rate_class="file", + columns=("index", "kind", "lang"), + headers=("#", "Kind", "Lang"), + example={"index": 0, "kind": "photo"}, + example_args="bot preview add @my_helper_bot ./shot.png", + covers=("bots.preview-media-add",), +) + + +class PreviewEditReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")] + index: Annotated[int | None, opt("--index", metavar="N", help="Position to replace.")] = None + file: Annotated[ + str | None, opt("--file", metavar="PATH", kind="path", help="New media for --index.") + ] = None + order: Annotated[ + str | None, opt("--order", metavar="2,0,1", help="New order for the whole gallery.") + ] = None + lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = "" + + +async def _current_previews(ctx: OpContext, bot: Any) -> list[Any]: + from telethon.tl.functions import bots as fn + + return list(await client(ctx)(fn.GetPreviewMediasRequest(bot=bot)) or []) + + +def _as_input_media(entry: Any) -> Any: + from telethon.tl import types + + inner = getattr(entry, "media", entry) + document = getattr(inner, "document", None) + if document is not None: + return types.InputMediaDocument(id=_media.input_document(document)) + return types.InputMediaPhoto(id=_media.input_photo(getattr(inner, "photo", None))) + + +async def preview_edit(ctx: OpContext, req: PreviewEditReq) -> PreviewChange: + """Replace one preview media, or reorder the gallery. + + Both take the *current* media as their handle, so the gallery is fetched + first: an index alone means nothing to the server. + """ + from telethon.tl.functions import bots as fn + + if (req.index is None) == (req.order is None): + raise UsageError("give either --index with --file, or --order", field="index") + + bot = await _bots.input_user(ctx, req.bot) + current = await _current_previews(ctx, bot) + handle = client(ctx) + + if req.order is not None: + try: + positions = [int(p) for p in req.order.split(",") if p.strip()] + except ValueError as exc: + raise UsageError("--order: expected a comma-separated list", field="order") from exc + if sorted(positions) != list(range(len(current))): + raise UsageError( + f"--order must name every position exactly once (0..{len(current) - 1})", + field="order", + ) + await handle( + fn.ReorderPreviewMediasRequest( + bot=bot, + lang_code=req.lang, + order=[_as_input_media(current[p]) for p in positions], + ) + ) + return PreviewChange(order=positions, lang=req.lang or None) + + if not req.file: + raise UsageError("--index needs --file", field="file") + if not 0 <= req.index < len(current): + raise NotFoundError(f"there is no preview media at position {req.index}") + await handle( + fn.EditPreviewMediaRequest( + bot=bot, + lang_code=req.lang, + media=_as_input_media(current[req.index]), + new_media=await _uploaded_media(ctx, req.file), + ) + ) + return PreviewChange(index=req.index, lang=req.lang or None) + + +SPEC_PREVIEW_EDIT = OperationSpec( + id="bot.preview.edit", + request=PreviewEditReq, + response=PreviewChange, + impl=preview_edit, + summary="Replace one preview media, or reorder the gallery", + mutating=True, + rate_class="file", + columns=("index", "order", "lang"), + headers=("#", "Order", "Lang"), + example={"index": 0, "lang": "en"}, + example_args="bot preview edit @my_helper_bot --order 1,0", + covers=("bots.preview-media-edit", "bots.preview-media-reorder"), +) + + +class PreviewDeleteReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")] + index: Annotated[list[int], opt("--index", metavar="N", help="Positions to delete.")] = [] + lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = "" + + +async def preview_delete(ctx: OpContext, req: PreviewDeleteReq) -> PreviewChange: + """Delete preview media from my bot's gallery.""" + from telethon.tl.functions import bots as fn + + if not req.index: + raise UsageError("name at least one --index", field="index") + bot = await _bots.input_user(ctx, req.bot) + current = await _current_previews(ctx, bot) + missing = [i for i in req.index if not 0 <= i < len(current)] + if missing: + raise NotFoundError(f"there is no preview media at position {missing[0]}") + await client(ctx)( + fn.DeletePreviewMediaRequest( + bot=bot, + lang_code=req.lang, + media=[_as_input_media(current[i]) for i in req.index], + ) + ) + return PreviewChange( + deleted=len(req.index), remaining=len(current) - len(req.index), lang=req.lang or None + ) + + +SPEC_PREVIEW_DELETE = OperationSpec( + id="bot.preview.delete", + request=PreviewDeleteReq, + response=PreviewChange, + impl=preview_delete, + summary="Delete preview media from my bot's gallery", + mutating=True, + destructive=True, + columns=("deleted", "remaining"), + headers=("Deleted", "Remaining"), + example={"deleted": 1, "remaining": 2}, + example_args="bot preview delete @my_helper_bot --index 0", + covers=("bots.preview-media-delete",), +) + + +# --------------------------------------------------------------------------- +# bot affiliate +# --------------------------------------------------------------------------- + + +class AffiliateSetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")] + commission_permille: Annotated[ + int, opt("--commission-permille", metavar="N", help="Commission in permille.") + ] = 0 + duration_months: Annotated[ + int | None, + opt("--duration-months", metavar="N", help="Program duration; omit for unlimited."), + ] = None + + +async def affiliate_set(ctx: OpContext, req: AffiliateSetReq) -> StarRefProgram: + """Create or raise my bot's affiliate (star-ref) program. + + Commission and duration may only ever be *raised*: Telegram will not let a + program get worse for the affiliates already in it. The bounds come from + the server's own config keys rather than from constants here, because they + change without a client release. + """ + from telethon.tl.functions import bots as fn + + config = await _media.app_config(ctx) + if not bool(config.get("starref_program_allowed", True)): + raise PermissionError_("affiliate programs are switched off for this account") + low = _media.config_int(config, "starref_min_commission_permille", 1) + high = _media.config_int(config, "starref_max_commission_permille", 800) + if not low <= req.commission_permille <= high: + raise UsageError( + f"--commission-permille must be between {low} and {high}", field="commission_permille" + ) + + result = await client(ctx)( + fn.UpdateStarRefProgramRequest( + bot=await _bots.input_user(ctx, req.bot), + commission_permille=req.commission_permille, + duration_months=req.duration_months, + ) + ) + return _starref(result) or StarRefProgram( + commission_permille=req.commission_permille, duration_months=req.duration_months + ) + + +SPEC_AFFILIATE_SET = OperationSpec( + id="bot.affiliate.set", + request=AffiliateSetReq, + response=StarRefProgram, + impl=affiliate_set, + summary="Create or raise my bot's affiliate program", + mutating=True, + columns=("bot_id", "commission_permille", "duration_months"), + headers=("Bot", "Permille", "Months"), + example={"bot_id": 5000001, "commission_permille": 200}, + example_args="bot affiliate set @my_helper_bot --commission-permille 200", + covers=("bots.affiliate-program-set",), +) + + +class AffiliateUnsetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")] + + +async def affiliate_unset(ctx: OpContext, req: AffiliateUnsetReq) -> StarRefProgram: + """End my bot's affiliate program. + + A commission of zero schedules termination roughly a day out, and no new + program can be created before that date — which is why this is confirmed + like a deletion even though nothing disappears immediately. + """ + from telethon.tl.functions import bots as fn + + result = await client(ctx)( + fn.UpdateStarRefProgramRequest( + bot=await _bots.input_user(ctx, req.bot), commission_permille=0 + ) + ) + return _starref(result) or StarRefProgram(commission_permille=0) + + +SPEC_AFFILIATE_UNSET = OperationSpec( + id="bot.affiliate.unset", + request=AffiliateUnsetReq, + response=StarRefProgram, + impl=affiliate_unset, + summary="End my bot's affiliate program", + mutating=True, + destructive=True, + columns=("bot_id", "end_date"), + headers=("Bot", "Ends"), + example={"bot_id": 5000001, "commission_permille": 0}, + example_args="bot affiliate unset @my_helper_bot", + covers=("bots.affiliate-program-end",), +) + + +class AffiliateJoinReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot's program to join.")] + send_as: Annotated[ + PeerRef | None, + opt("--send-as", metavar="PEER", kind="peer", help="Join as me, a bot or a channel."), + ] = None + + +async def affiliate_join(ctx: OpContext, req: AffiliateJoinReq) -> StarRefProgram: + """Join a bot's affiliate program and get my referral link.""" + from telethon.tl import types + from telethon.tl.functions import payments as fn + + config = await _media.app_config(ctx) + if not bool(config.get("starref_connect_allowed", True)): + raise PermissionError_("joining affiliate programs is switched off for this account") + peer = ( + await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf() + ) + result = await client(ctx)( + fn.ConnectStarRefBotRequest(peer=peer, bot=await _bots.input_user(ctx, req.bot)) + ) + connected = (getattr(result, "connected_bots", None) or [None])[0] + return _connected_ref(connected) or StarRefProgram() + + +def _connected_ref(entry: Any) -> StarRefProgram | None: + if entry is None: + return None + date = getattr(entry, "date", None) + return StarRefProgram( + bot_id=int(getattr(entry, "bot_id", 0) or 0), + url=getattr(entry, "url", None), + commission_permille=int(getattr(entry, "commission_permille", 0) or 0), + duration_months=getattr(entry, "duration_months", None), + participants=getattr(entry, "participants", None), + revenue=int(getattr(entry, "revenue", 0) or 0) or None, + date=fmt_dt(date), + date_unix=to_unix(date), + revoked=bool(getattr(entry, "revoked", False)), + ) + + +SPEC_AFFILIATE_JOIN = OperationSpec( + id="bot.affiliate.join", + request=AffiliateJoinReq, + response=StarRefProgram, + impl=affiliate_join, + summary="Join a bot's affiliate program and get my referral link", + mutating=True, + columns=("bot_id", "url", "commission_permille"), + headers=("Bot", "Link", "Permille"), + example={ + "bot_id": 5000001, + "url": "https://t.me/my_helper_bot?start=ref", + "commission_permille": 200, + }, + example_args="bot affiliate join @my_helper_bot", + covers=("bots.affiliate-connect",), +) + + +class AffiliateListReq(Request): + suggested: Annotated[ + bool, opt("--suggested", help="Browse mini apps with an open program.") + ] = False + send_as: Annotated[ + PeerRef | None, + opt("--send-as", metavar="PEER", kind="peer", help="Act as me, a bot or a channel."), + ] = None + bot: Annotated[ + PeerRef | None, + opt("--bot", metavar="BOT", kind="user", help="Only the program connected to this bot."), + ] = None + by: Annotated[str, choice("revenue", "date", help="Sort order for --suggested.")] = "revenue" + + +async def affiliate_list(ctx: OpContext, req: AffiliateListReq) -> Page[StarRefProgram]: + """Affiliate programs: mine, one of mine, or ones on offer. + + Connected programs page by `(offset_date, offset_link)` *together* — two + values, not one — so tlgr packs both into the single opaque cursor every + other listing uses. A caller that had to carry two offsets by hand would + be the only place in tlgr where pagination looks different. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + limit, state = window(ctx, "bot.affiliate.list", PageKind.RATE, default=50) + peer = ( + await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf() + ) + handle = client(ctx) + + if req.suggested: + result = await handle( + fn.GetSuggestedStarRefBotsRequest( + peer=peer, + offset=str(state.get("offset", "") or ""), + limit=limit, + order_by_revenue=req.by == "revenue" or None, + order_by_date=req.by == "date" or None, + ) + ) + items = [ + _starref(entry) or StarRefProgram() + for entry in (getattr(result, "suggested_bots", None) or []) + ] + next_offset = str(getattr(result, "next_offset", "") or "") + return build_page( + items, + op="bot.affiliate.list", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + ) + + if req.bot is not None: + result = await handle( + fn.GetConnectedStarRefBotRequest(peer=peer, bot=await _bots.input_user(ctx, req.bot)) + ) + entry = _connected_ref(getattr(result, "connected_bot", None)) + return Page(items=[entry] if entry else [], has_more=False, total=1 if entry else 0) + + from tlgr.core.timefmt import parse_dt + + offset_date = parse_dt(str(state["date"])) if state.get("date") else None + result = await handle( + fn.GetConnectedStarRefBotsRequest( + peer=peer, + limit=limit, + offset_date=offset_date, + offset_link=state.get("link") or None, + ) + ) + entries = getattr(result, "connected_bots", None) or [] + items = [ref for ref in (_connected_ref(entry) for entry in entries) if ref is not None] + last = items[-1] if items else None + return build_page( + items, + op="bot.affiliate.list", + kind=PageKind.RATE, + state={"date": last.date if last else None, "link": last.url if last else None}, + account=ctx.account, + limit=limit, + total=getattr(result, "count", None), + ) + + +SPEC_AFFILIATE_LIST = OperationSpec( + id="bot.affiliate.list", + request=AffiliateListReq, + response=Page[StarRefProgram], + impl=affiliate_list, + summary="List affiliate programs I joined, or ones on offer", + paginated=PageKind.RATE, + columns=("bot_id", "url", "commission_permille", "revenue"), + headers=("Bot", "Link", "Permille", "Revenue"), + example={"items": [{"bot_id": 5000001, "commission_permille": 200}], "has_more": False}, + example_args="bot affiliate list", + covers=("bots.affiliate-list-connected", "bots.affiliate-suggested"), +) + + +class AffiliateRevokeReq(Request): + link: Annotated[str, arg(0, metavar="LINK", help="The referral link to revoke.")] + send_as: Annotated[ + PeerRef | None, + opt("--send-as", metavar="PEER", kind="peer", help="Peer the link belongs to."), + ] = None + + +async def affiliate_revoke(ctx: OpContext, req: AffiliateRevokeReq) -> StarRefProgram: + """Revoke one of my affiliate links. + + `STARREF_EXPIRED` means the link is already dead, which is the state the + caller asked for — reported as `already` rather than as a failure. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + peer = ( + await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf() + ) + try: + result = await client(ctx)( + fn.EditConnectedStarRefBotRequest(peer=peer, link=req.link, revoked=True) + ) + except Exception as exc: # one server answer means "already done" + if "STARREFEXPIRED" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""): + raise + from tlgr.ops._common import already as mark_already + + mark_already(ctx) + return StarRefProgram(url=req.link, revoked=True) + return _connected_ref(getattr(result, "connected_bot", None)) or StarRefProgram( + url=req.link, revoked=True + ) + + +SPEC_AFFILIATE_REVOKE = OperationSpec( + id="bot.affiliate.revoke", + request=AffiliateRevokeReq, + response=StarRefProgram, + impl=affiliate_revoke, + summary="Revoke one of my affiliate links", + mutating=True, + destructive=True, + idempotent=True, + columns=("url", "revoked"), + headers=("Link", "Revoked"), + example={"url": "https://t.me/my_helper_bot?start=ref", "revoked": True}, + example_args="bot affiliate revoke https://t.me/my_helper_bot?start=ref", + covers=("bots.affiliate-revoke",), +) + + +# --------------------------------------------------------------------------- +# bot attach list / toggle, bot recent set +# --------------------------------------------------------------------------- + + +class AttachListReq(Request): + bot: Annotated[ + PeerRef | None, + opt("--bot", metavar="BOT", kind="user", help="Inspect one bot's entry."), + ] = None + + +def _attach_bot(entry: Any) -> AttachMenuBot: + return AttachMenuBot( + bot_id=int(getattr(entry, "bot_id", 0) or 0), + short_name=getattr(entry, "short_name", None), + peer_types=[ + _bots.BUTTON_TYPES.get( + type(p).__name__, type(p).__name__.removeprefix("AttachMenuPeerType").lower() + ) + for p in (getattr(entry, "peer_types", None) or []) + ], + inactive=bool(getattr(entry, "inactive", False)), + request_write_access=bool(getattr(entry, "request_write_access", False)), + show_in_attach_menu=bool(getattr(entry, "show_in_attach_menu", False)), + show_in_side_menu=bool(getattr(entry, "show_in_side_menu", False)), + side_menu_disclaimer_needed=bool(getattr(entry, "side_menu_disclaimer_needed", False)), + ) + + +async def attach_list(ctx: OpContext, req: AttachListReq) -> Page[AttachMenuBot]: + """The bots installed in my attachment and side menus.""" + from telethon.tl.functions import messages as fn + + handle = client(ctx) + if req.bot is not None: + result = await handle(fn.GetAttachMenuBotRequest(bot=await _bots.input_user(ctx, req.bot))) + entry = getattr(result, "bot", None) + rows = [_attach_bot(entry)] if entry is not None else [] + _name_bots(rows, getattr(result, "users", None) or []) + return Page(items=rows, has_more=False, total=len(rows)) + + result = await handle(fn.GetAttachMenuBotsRequest(hash=0)) + if type(result).__name__ == "AttachMenuBotsNotModified": # pragma: no cover - hash is 0 + return Page(items=[], has_more=False, total=0) + rows = [_attach_bot(entry) for entry in (getattr(result, "bots", None) or [])] + _name_bots(rows, getattr(result, "users", None) or []) + return Page(items=rows, has_more=False, total=len(rows)) + + +def _name_bots(rows: list[AttachMenuBot], users: list[Any]) -> None: + by_id = {int(getattr(u, "id", 0) or 0): u for u in users} + for row in rows: + user = by_id.get(row.bot_id) + if user is not None: + row.username = getattr(user, "username", None) + + +SPEC_ATTACH_LIST = OperationSpec( + id="bot.attach.list", + request=AttachListReq, + response=Page[AttachMenuBot], + impl=attach_list, + summary="List the bots in my attachment and side menus", + columns=("bot_id", "username", "short_name", "show_in_attach_menu"), + headers=("Bot", "Username", "Name", "Attach"), + example={"items": [{"bot_id": 5000001, "short_name": "Helper"}], "has_more": False}, + example_args="bot attach list", + covers=("attach.menu-bots", "bots.attach-menu-list"), +) + + +class AttachToggleReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")] + state: Annotated[str, arg(1, metavar="STATE", help="on = install, off = remove.")] + allow_write: Annotated[ + bool, opt("--allow-write", help="CONSENT: also let the bot message me.") + ] = False + accept_tos: Annotated[ + bool, opt("--accept-tos", help="Required when the bot needs a side-menu disclaimer.") + ] = False + + +async def attach_toggle(ctx: OpContext, req: AttachToggleReq) -> ToggledAttachMenu: + """Install or remove a bot from the attachment and side menu. + + `write_allowed` is never set implicitly: installing a mini app and letting + its bot message you are two different decisions, and Telegram's own API + puts them in one call. + """ + from telethon.tl.functions import messages as fn + + if req.state not in ("on", "off"): + raise UsageError("state must be 'on' or 'off'", field="state") + bot = await _bots.input_user(ctx, req.bot) + handle = client(ctx) + + if req.state == "on": + entry = getattr(await handle(fn.GetAttachMenuBotRequest(bot=bot)), "bot", None) + if bool(getattr(entry, "side_menu_disclaimer_needed", False)) and not req.accept_tos: + raise UsageError( + "this bot requires you to accept its terms first; pass --accept-tos", + field="accept_tos", + ) + await handle( + fn.ToggleBotInAttachMenuRequest( + bot=bot, enabled=req.state == "on", write_allowed=req.allow_write or None + ) + ) + peer = await _send.resolve(ctx, req.bot) + return ToggledAttachMenu( + bot_id=_send.peer_id_of(peer), + installed=req.state == "on", + write_allowed=req.allow_write, + ) + + +SPEC_ATTACH_TOGGLE = OperationSpec( + id="bot.attach.toggle", + request=AttachToggleReq, + response=ToggledAttachMenu, + impl=attach_toggle, + summary="Install or remove a bot from the attachment menu", + mutating=True, + destructive=True, + columns=("bot_id", "installed", "write_allowed"), + headers=("Bot", "Installed", "May message"), + example={"bot_id": 5000001, "installed": True, "write_allowed": False}, + example_args="bot attach toggle @my_helper_bot on", + covers=("bots.attach-menu-toggle", "bots.miniapp-panel-menu", "bots.webapp-write-access"), +) + + +class RecentSetReq(Request): + state: Annotated[ + str | None, arg(0, metavar="STATE", required=False, help="on|off for the whole feature.") + ] = None + forget: Annotated[ + PeerRef | None, + opt("--forget", metavar="BOT", kind="user", help="Reset the rating of one bot."), + ] = None + forget_all: Annotated[bool, opt("--forget-all", help="Reset the whole category.")] = False + kind: Annotated[ + str, choice("pm", "inline", "app", "guest", help="Category the reset applies to.") + ] = "pm" + + +_TOP_PEER_CATEGORIES = { + "pm": "TopPeerCategoryBotsPM", + "inline": "TopPeerCategoryBotsInline", + "app": "TopPeerCategoryBotsApp", + "guest": "TopPeerCategoryBotsGuestChat", +} + + +async def recent_set(ctx: OpContext, req: RecentSetReq) -> RecentBots: + """Turn frequently-used-bot suggestions on or off, or forget one bot.""" + from telethon.tl import types + from telethon.tl.functions import contacts as fn + + handle = client(ctx) + forgotten: list[int] = [] + enabled = req.state != "off" + + if req.state is not None: + if req.state not in ("on", "off"): + raise UsageError("state must be 'on' or 'off'", field="state") + await handle(fn.ToggleTopPeersRequest(enabled=req.state == "on")) + + category_name = _TOP_PEER_CATEGORIES[req.kind] + category = getattr(types, category_name, None) + if category is None: # pragma: no cover - layer 227 has every category we name + _bots.unsupported(f"--kind {req.kind}") + if req.forget is not None: + peer = await _send.resolve(ctx, req.forget) + await handle(fn.ResetTopPeerRatingRequest(category=category(), peer=peer)) + forgotten = [_send.peer_id_of(peer)] + elif req.forget_all: + await handle(fn.ResetTopPeerRatingRequest(category=category(), peer=types.InputPeerEmpty())) + + if req.state is None and req.forget is None and not req.forget_all: + raise UsageError("give a state, --forget or --forget-all", field="state") + return RecentBots(enabled=enabled, kind=req.kind, forgotten=forgotten) + + +SPEC_RECENT_SET = OperationSpec( + id="bot.recent.set", + request=RecentSetReq, + response=RecentBots, + impl=recent_set, + summary="Turn frequently-used-bot suggestions on or off", + mutating=True, + columns=("enabled", "kind", "forgotten"), + headers=("Enabled", "Kind", "Forgotten"), + example={"enabled": True, "kind": "pm", "forgotten": []}, + example_args="bot recent set off", + covers=("bots.top-peers-bots",), +) + + +# --------------------------------------------------------------------------- +# bot report, bot ad +# --------------------------------------------------------------------------- + + +class ReportReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot or mini app owner.")] + app: Annotated[str | None, opt("--app", help="Report a mini app by short name.")] = None + message: Annotated[ + int | None, opt("--message", metavar="ID", kind="msg_id", help="Report one message.") + ] = None + ephemeral: Annotated[ + int | None, opt("--ephemeral", metavar="ID", help="Report an ephemeral message.") + ] = None + option: Annotated[ + str | None, opt("--option", metavar="BYTES", help="Option from the previous step.") + ] = None + comment: Annotated[str | None, opt("--comment", help="Free-text comment.")] = None + + +async def report(ctx: OpContext, req: ReportReq) -> ReportOutcome: + """Report a bot, a mini app or one of its messages. + + Telegram's report flow is a state machine, not a form: the first call + returns a list of options, each option leads to another list or to a + comment box. One call per step is what lets a caller drive it without + tlgr guessing which category they meant. + """ + from telethon.tl.functions import messages as fn + + if req.ephemeral is not None: + _bots.unsupported("--ephemeral (ephemeral.reportMessage)") + + peer = await _send.resolve(ctx, req.bot) + ids = [int(req.message)] if req.message is not None else [] + result = await client(ctx)( + fn.ReportRequest( + peer=peer, + id=ids, + option=_bots.option_bytes(req.option), + message=req.comment or "", + ) + ) + outcome = _bots.report_outcome(result) + if outcome.reported: + ctx.emit("bot_report", {"bot_id": _send.peer_id_of(peer)}) + return outcome + + +SPEC_REPORT = OperationSpec( + id="bot.report", + request=ReportReq, + response=ReportOutcome, + impl=report, + summary="Report a bot or a mini app", + mutating=True, + columns=("result", "title", "reported"), + headers=("Step", "Title", "Done"), + example={"result": "choose_option", "title": "What is wrong?", "options": []}, + example_args="bot report @spam_bot", + covers=("bots.report-bot-or-app",), + covers_partial=("bots.miniapp-panel-menu",), + coverage_note=( + "Installing and removing a mini app is `bot attach toggle`; reporting " + "an ephemeral message needs layer 229 and exits 13." + ), +) + + +class AdListReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot chat.")] + + +async def ad_list(ctx: OpContext, req: AdListReq) -> Page[SponsoredMessage]: + """The sponsored messages a bot chat would show. + + Opt-in, like `message sponsored list`: tlgr never mixes ads into a message + listing, and never reports an impression that nobody saw — that is + `bot ad read`. + """ + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.bot) + result = await client(ctx)(fn.GetSponsoredMessagesRequest(peer=peer)) + items = [ + SponsoredMessage( + random_id=_bots.key_text(getattr(entry, "random_id", b"")), + title=getattr(entry, "title", None), + message=str(getattr(entry, "message", "") or ""), + url=getattr(entry, "url", None), + button_text=getattr(entry, "button_text", None), + sponsor_info=getattr(entry, "sponsor_info", None), + additional_info=getattr(entry, "additional_info", None), + recommended=bool(getattr(entry, "recommended", False)), + can_report=bool(getattr(entry, "can_report", False)), + ) + for entry in (getattr(result, "messages", None) or []) + ] + return Page(items=items, has_more=False, total=len(items)) + + +SPEC_AD_LIST = OperationSpec( + id="bot.ad.list", + request=AdListReq, + response=Page[SponsoredMessage], + impl=ad_list, + summary="List the sponsored messages shown inside a bot chat", + description=( + "Telegram's API terms require a third-party client that shows bot or " + "channel content to support sponsored messages; tlgr does so by " + "making them a command of their own instead of hiding them in a feed." + ), + columns=("random_id", "title", "message"), + headers=("ID", "Title", "Text"), + example={"items": [{"random_id": "abc", "message": "An ad"}], "has_more": False}, + example_args="bot ad list @my_helper_bot", + covers=("bots.bot-ads-account",), + covers_partial=("bots.sponsored-message-in-bot-chat",), + coverage_note="Reporting an impression or a click is `bot ad read`.", +) + + +class AdReadReq(Request): + random_id: Annotated[str, arg(0, metavar="RANDOM_ID", help="random_id from `bot ad list`.")] + click: Annotated[bool, opt("--click", help="Also record a click.")] = False + media: Annotated[bool, opt("--media", help="The click was on the ad's media.")] = False + fullscreen: Annotated[bool, opt("--fullscreen", help="The click was in fullscreen.")] = False + + +async def ad_read(ctx: OpContext, req: AdReadReq) -> SponsoredRead: + """Report that a sponsored message was seen, or clicked.""" + from telethon.tl.functions import messages as fn + + handle = client(ctx) + raw = _bots.option_bytes(req.random_id, field="random_id") + await handle(fn.ViewSponsoredMessageRequest(random_id=raw)) + if req.click: + await handle( + fn.ClickSponsoredMessageRequest( + random_id=raw, media=req.media or None, fullscreen=req.fullscreen or None + ) + ) + return SponsoredRead(random_id=req.random_id, viewed=True, clicked=req.click) + + +SPEC_AD_READ = OperationSpec( + id="bot.ad.read", + request=AdReadReq, + response=SponsoredRead, + impl=ad_read, + summary="Mark a sponsored message as seen, or as clicked", + aliases=("bot.ad.view",), + mutating=True, + columns=("random_id", "viewed", "clicked"), + headers=("ID", "Viewed", "Clicked"), + example={"random_id": "abc", "viewed": True, "clicked": False}, + example_args="bot ad read abc", + covers=("bots.sponsored-message-in-bot-chat",), +) + + +class AdReportReq(Request): + random_id: Annotated[str, arg(0, metavar="RANDOM_ID", help="random_id from `bot ad list`.")] + option: Annotated[ + str | None, opt("--option", metavar="BYTES", help="Option from the previous step.") + ] = None + comment: Annotated[str | None, opt("--comment", help="Free-text comment.")] = None + + +async def ad_report(ctx: OpContext, req: AdReportReq) -> ReportOutcome: + """Report a sponsored message, walking the same option tree as `bot report`.""" + from telethon.tl.functions import messages as fn + + result = await client(ctx)( + fn.ReportSponsoredMessageRequest( + random_id=_bots.option_bytes(req.random_id, field="random_id"), + option=_bots.option_bytes(req.option), + ) + ) + return _bots.report_outcome(result) + + +SPEC_AD_REPORT = OperationSpec( + id="bot.ad.report", + request=AdReportReq, + response=ReportOutcome, + impl=ad_report, + summary="Report a sponsored message in a bot chat", + mutating=True, + columns=("result", "title", "reported"), + headers=("Step", "Title", "Done"), + example={"result": "reported", "reported": True}, + example_args="bot ad report abc", + covers_partial=("bots.sponsored-message-in-bot-chat",), + coverage_note="Listing and viewing the ads themselves is `bot ad list`/`bot ad read`.", +) + + +# --------------------------------------------------------------------------- +# bot game get / send, bot score list / set +# --------------------------------------------------------------------------- + + +class GameGetReq(Request): + emoji: Annotated[ + str | None, opt("--emoji", metavar="EMOJI", help="Dice emoji this report is about.") + ] = None + + +async def game_get(ctx: OpContext, req: GameGetReq) -> EmojiGame: + """Emoji-dice game parameters. Inspect only. + + Staking TON on an emoji game moves money, so tlgr reads the parameters and + stops there. `messages.getEmojiGameInfo` takes no arguments — `--emoji` is + recorded on the answer so a caller can tell which game they asked about. + """ + from telethon.tl.functions import messages as fn + + result = await client(ctx)(fn.GetEmojiGameInfoRequest()) + if type(result).__name__ == "EmojiGameUnavailable": + return EmojiGame(emoticon=req.emoji or "", available=False) + return EmojiGame( + emoticon=req.emoji or "", + available=True, + game_hash=getattr(result, "game_hash", None), + prev_stake=getattr(result, "prev_stake", None), + current_streak=getattr(result, "current_streak", None), + params=[int(p) for p in (getattr(result, "params", None) or [])], + plays_left=getattr(result, "plays_left", None), + ) + + +SPEC_GAME_GET = OperationSpec( + id="bot.game.get", + request=GameGetReq, + response=EmojiGame, + impl=game_get, + summary="Show emoji-dice game parameters", + columns=("emoticon", "available", "current_streak"), + headers=("Emoji", "Available", "Streak"), + example={"emoticon": "🎲", "available": True, "current_streak": 0}, + example_args="bot game get --emoji 🎲", + covers=("bots.emoji-games",), +) + + +class GameSendReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot that owns the game.")] + short_name: Annotated[str, arg(1, metavar="SHORT_NAME", help="Game short name.")] + chat: Annotated[ + PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Destination chat.") + ] = None + reply_to: Annotated[ + int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.") + ] = None + silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False + schedule: Annotated[str | None, opt("--schedule", metavar="TS", help="Schedule the send.")] = ( + None + ) + + +async def game_send(ctx: OpContext, req: GameSendReq) -> GameSent: + """Send an HTML5 game to a chat. + + Only the owning bot may send by short name; a user can forward an existing + game message but cannot mint one, which is why this is a bot-session + command rather than a refusal from the server three steps later. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + await _bots.require_bot_session(ctx, "sending a game by short name") + if req.chat is None: + raise UsageError("--chat is required", field="chat") + target = await _send.resolve(ctx, req.chat) + updates = await client(ctx)( + fn.SendMediaRequest( + peer=target, + media=types.InputMediaGame( + id=types.InputGameShortName( + bot_id=await _bots.input_user(ctx, req.bot), short_name=req.short_name + ) + ), + message="", + random_id=_random_id(), + reply_to=await _send.reply_target(ctx, reply_to=req.reply_to), + silent=req.silent or None, + schedule_date=_send.schedule_at(req.schedule), + ) + ) + message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target)) + game = getattr(getattr(message, "media", None), "game", None) + return GameSent( + chat_id=message.chat_id, + msg_id=message.id, + game_id=_id_of(game), + short_name=req.short_name, + ) + + +SPEC_GAME_SEND = OperationSpec( + id="bot.game.send", + request=GameSendReq, + response=GameSent, + impl=game_send, + summary="Send an HTML5 game to a chat", + mutating=True, + rate_class="send", + columns=("chat_id", "msg_id", "short_name"), + headers=("Chat", "Message", "Game"), + example={"chat_id": 4242, "msg_id": 12, "short_name": "tetris"}, + example_args="bot game send @my_helper_bot tetris --chat @alice", + covers=("bots.send-game",), +) + + +class ScoreListReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the game."), + ] = None + msg_id: Annotated[ + int | None, arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Game message.") + ] = None + inline_id: Annotated[ + str | None, opt("--inline-id", metavar="DC:ID:HASH", help="Inline message id.") + ] = None + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="Centre the table on this user."), + ] = None + + +async def score_list(ctx: OpContext, req: ScoreListReq) -> Page[HighScore]: + """A game's high-score table. + + The inline variant has to be sent to the DC the inline message lives on; + sending it home answers with an error that never mentions data centres. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + user = ( + await _bots.input_user(ctx, req.user, field="user") + if req.user is not None + else types.InputUserSelf() + ) + if req.inline_id: + identifier = _bots.inline_message_id(req.inline_id) + result = await _bots.on_dc( + ctx, + int(getattr(identifier, "dc_id", 0) or 0), + fn.GetInlineGameHighScoresRequest(id=identifier, user_id=user), + ) + else: + if req.chat is None or req.msg_id is None: + raise UsageError("give a chat and a message id, or --inline-id", field="msg_id") + result = await client(ctx)( + fn.GetGameHighScoresRequest( + peer=await _send.resolve(ctx, req.chat), id=int(req.msg_id), user_id=user + ) + ) + items = [ + HighScore( + position=int(getattr(score, "pos", 0) or 0), + user_id=int(getattr(score, "user_id", 0) or 0), + score=int(getattr(score, "score", 0) or 0), + ) + for score in (getattr(result, "scores", None) or []) + ] + return Page(items=items, has_more=False, total=len(items)) + + +SPEC_SCORE_LIST = OperationSpec( + id="bot.score.list", + request=ScoreListReq, + response=Page[HighScore], + impl=score_list, + summary="Show a game's high-score table", + columns=("position", "user_id", "score"), + headers=("#", "User", "Score"), + example={"items": [{"position": 1, "user_id": 4242, "score": 900}], "has_more": False}, + example_args="bot score list @alice 12", + covers=("bots.game-high-scores", "bots.inline-game-high-scores"), +) + + +class ScoreSetReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the game."), + ] = None + msg_id: Annotated[ + int | None, arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Game message.") + ] = None + inline_id: Annotated[ + str | None, opt("--inline-id", metavar="DC:ID:HASH", help="Inline message id.") + ] = None + user: Annotated[ + PeerRef | None, opt("--user", metavar="USER", kind="user", help="The player.") + ] = None + score: Annotated[int, opt("--score", metavar="N", help="New score.")] = 0 + edit_message: Annotated[bool, opt("--edit-message", help="Also update the game message.")] = ( + False + ) + allow_lower: Annotated[ + bool, opt("--allow-lower", help="Allow the score to decrease (force).") + ] = False + + +async def score_set(ctx: OpContext, req: ScoreSetReq) -> ScoreSet: + """Report a game score for a user. + + `--allow-lower` is Telegram's `force`: without it the server keeps the + player's best score, which is almost always what a leaderboard wants. + It is spelled out rather than borrowed from the global `--yes`, which an + operation never sees. + """ + from telethon.tl.functions import messages as fn + + await _bots.require_bot_session(ctx, "setting a game score") + if req.user is None: + raise UsageError("--user names the player and is required", field="user") + user = await _bots.input_user(ctx, req.user, field="user") + + if req.inline_id: + identifier = _bots.inline_message_id(req.inline_id) + await _bots.on_dc( + ctx, + int(getattr(identifier, "dc_id", 0) or 0), + fn.SetInlineGameScoreRequest( + id=identifier, + user_id=user, + score=req.score, + edit_message=req.edit_message or None, + force=req.allow_lower or None, + ), + ) + else: + if req.chat is None or req.msg_id is None: + raise UsageError("give a chat and a message id, or --inline-id", field="msg_id") + await client(ctx)( + fn.SetGameScoreRequest( + peer=await _send.resolve(ctx, req.chat), + id=int(req.msg_id), + user_id=user, + score=req.score, + edit_message=req.edit_message or None, + force=req.allow_lower or None, + ) + ) + return ScoreSet(user_id=_send.peer_id_of(await _send.resolve(ctx, req.user)), score=req.score) + + +SPEC_SCORE_SET = OperationSpec( + id="bot.score.set", + request=ScoreSetReq, + response=ScoreSet, + impl=score_set, + summary="Report a game score for a user", + mutating=True, + columns=("user_id", "score", "position"), + headers=("User", "Score", "#"), + example={"user_id": 4242, "score": 900}, + example_args="bot score set @alice 12 --user @alice --score 900", + covers=("bots.set-game-score",), +) + + +# --------------------------------------------------------------------------- +# Layer 229: ephemeral messages and bot welcome messages +# --------------------------------------------------------------------------- + + +class EphemeralSendReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat it lives in.")] + text: Annotated[str, arg(1, metavar="TEXT", help="Message text, or a /command.")] + bot: Annotated[ + PeerRef | None, + opt("--bot", metavar="BOT", kind="user", help="Bot the conversation belongs to."), + ] = None + receiver: Annotated[ + PeerRef | None, + opt("--receiver", metavar="USER", kind="user", help="Who alone will see it (bot side)."), + ] = None + reply_to: Annotated[ + int | None, opt("--reply-to", metavar="ID", help="Ephemeral message being replied to.") + ] = None + query_id: Annotated[ + str | None, opt("--query-id", metavar="ID", help="Guest/callback query this answers.") + ] = None + keyboard: Annotated[ + str | None, opt("--keyboard", metavar="PATH", kind="path", help="JSON keyboard.") + ] = None + rich_file: Annotated[ + str | None, opt("--rich-file", metavar="PATH", kind="path", help="Send a rich body.") + ] = None + anchor: Annotated[bool, opt("--anchor", help="Pin it to the triggering message.")] = False + welcome: Annotated[bool, opt("--welcome", help="Store it as a welcome template.")] = False + edit: Annotated[ + int | None, opt("--edit", metavar="ID", help="Edit this ephemeral message instead.") + ] = None + parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None + + +async def ephemeral_send(ctx: OpContext, req: EphemeralSendReq) -> EphemeralSent: + """Send an "only you can see this" bot message. + + `ephemeral.sendMessage#ba8d5f35` and `ephemeral.editMessage#cf9c725b` are + layer-229 constructors and the pinned Telethon speaks 227. The operation + is registered rather than omitted so that `tlgr agent capabilities` can + say the surface exists and is unavailable — which is a different answer + from "no such command", and the one an agent can act on. + """ + _bots.unsupported("bot ephemeral send") + raise AssertionError # pragma: no cover - unreachable; keeps mypy happy + + +SPEC_EPHEMERAL_SEND = OperationSpec( + id="bot.ephemeral.send", + request=EphemeralSendReq, + response=EphemeralSent, + impl=ephemeral_send, + summary="Send an ephemeral ('only you can see this') bot message", + description=( + "Layer 229. Exits 13 (NOT_SUPPORTED) until the pinned Telethon speaks " + "it: hand-rolling the request would mean guessing at constructor ids " + "for parameters nobody has published." + ), + mutating=True, + rate_class="send", + columns=("chat_id", "ephemeral_id"), + headers=("Chat", "Ephemeral"), + example={"chat_id": 4242, "ephemeral_id": 0}, + example_args="bot ephemeral send @alice Hello", + covers_partial=("bots.ephemeral-command-send", "bots.ephemeral-message-send"), + coverage_note="Registered and refused with exit 13: the methods are layer 229.", +) + + +class EphemeralDeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat it lives in.")] + id: Annotated[ + list[int], arg(1, metavar="ID", variadic=True, help="Ephemeral message ids.") + ] = [] + receiver: Annotated[ + PeerRef | None, + opt("--receiver", metavar="USER", kind="user", help="Whose copy is deleted (bot side)."), + ] = None + dismiss: Annotated[bool, opt("--dismiss", help="Only clear it locally.")] = False + + +async def ephemeral_delete(ctx: OpContext, req: EphemeralDeleteReq) -> EphemeralDeleted: + """Delete or dismiss an ephemeral bot message. Layer 229; exits 13.""" + _bots.unsupported("bot ephemeral delete") + raise AssertionError # pragma: no cover - unreachable; keeps mypy happy + + +SPEC_EPHEMERAL_DELETE = OperationSpec( + id="bot.ephemeral.delete", + request=EphemeralDeleteReq, + response=EphemeralDeleted, + impl=ephemeral_delete, + summary="Delete or dismiss an ephemeral bot message", + mutating=True, + destructive=True, + columns=("chat_id", "deleted"), + headers=("Chat", "Deleted"), + example={"chat_id": 4242, "deleted": 0}, + example_args="bot ephemeral delete @alice 12", + covers_partial=("bots.ephemeral-message-send",), + coverage_note="Registered and refused with exit 13: `ephemeral.*` is layer 229.", +) + + +class WelcomeListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")] + + +async def welcome_list(ctx: OpContext, req: WelcomeListReq) -> Page[BotWelcomeMessage]: + """A chat's bot welcome-message templates. Layer 229; exits 13.""" + _bots.unsupported("bot welcome list") + raise AssertionError # pragma: no cover - unreachable; keeps mypy happy + + +SPEC_WELCOME_LIST = OperationSpec( + id="bot.welcome.list", + request=WelcomeListReq, + response=Page[BotWelcomeMessage], + impl=welcome_list, + summary="List a chat's bot welcome-message templates", + paginated=PageKind.LOCAL, + columns=("id", "text"), + headers=("ID", "Text"), + example={"items": [], "has_more": False}, + example_args="bot welcome list @mygroup", + covers_partial=("bots.welcome-messages-view",), + coverage_note="Registered and refused with exit 13: `ephemeral.*` is layer 229.", +) + + +class WelcomeSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")] + text: Annotated[str, arg(1, metavar="TEXT", help="Welcome text.")] + id: Annotated[int | None, opt("--id", metavar="ID", help="Edit this one instead.")] = None + keyboard: Annotated[ + str | None, opt("--keyboard", metavar="PATH", kind="path", help="JSON keyboard.") + ] = None + parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None + + +async def welcome_set(ctx: OpContext, req: WelcomeSetReq) -> WelcomeSet: + """Add or edit a chat's bot welcome message. Layer 229; exits 13.""" + _bots.unsupported("bot welcome set") + raise AssertionError # pragma: no cover - unreachable; keeps mypy happy + + +SPEC_WELCOME_SET = OperationSpec( + id="bot.welcome.set", + request=WelcomeSetReq, + response=WelcomeSet, + impl=welcome_set, + summary="Add or edit a chat's bot welcome message", + mutating=True, + columns=("chat_id", "id", "text"), + headers=("Chat", "ID", "Text"), + example={"chat_id": -1001, "id": 0, "text": "Welcome!"}, + example_args="bot welcome set @mygroup Welcome!", + covers_partial=("bots.welcome-messages-manage",), + coverage_note="Registered and refused with exit 13: `ephemeral.*` is layer 229.", +) + + +class WelcomeDeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")] + id: Annotated[ + list[int], arg(1, metavar="ID", required=False, variadic=True, help="Welcome message ids.") + ] = [] + delete_all: Annotated[bool, opt("--all", help="Delete every welcome message.")] = False + + +async def welcome_delete(ctx: OpContext, req: WelcomeDeleteReq) -> WelcomeDeleted: + """Delete a chat's bot welcome messages. Layer 229; exits 13.""" + _bots.unsupported("bot welcome delete") + raise AssertionError # pragma: no cover - unreachable; keeps mypy happy + + +SPEC_WELCOME_DELETE = OperationSpec( + id="bot.welcome.delete", + request=WelcomeDeleteReq, + response=WelcomeDeleted, + impl=welcome_delete, + summary="Delete one or all of a chat's bot welcome messages", + mutating=True, + destructive=True, + columns=("chat_id", "deleted"), + headers=("Chat", "Deleted"), + example={"chat_id": -1001, "deleted": 0}, + example_args="bot welcome delete @mygroup 1", + covers_partial=("bots.welcome-messages-manage",), + coverage_note="Registered and refused with exit 13: `ephemeral.*` is layer 229.", +) From ac96d8a43476ca099d286b4d6a737e9aac6acc0c Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 00:44:30 +0330 Subject: [PATCH 04/10] inline, webapp and payment ops: 22 operations, and one deliberate absence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inline: a bot's own opaque next_offset is passed straight back rather than wrapped in a signed cursor, 'inline send --pick' re-runs the query so the (query_id, result_id) pair is never stale, and a bot that does not answer is an empty page with a warning, not a failure. webapp: 'webapp open' prints the signed URL and stops. There is no --open, because that URL is a credential carrying the user's signed init data and a CLI cannot host the mini-app bridge anyway. payment: the whole checkout surface is readable and invoices can be created — that asks somebody else to pay. sendPaymentForm, sendStarsForm, validateRequestedInfo and fulfillStarsSubscription are absent from the surface, and 'payment form get' says so in the payload rather than leaving it to an exit code. --- tlgr/ops/inline.py | 768 +++++++++++++++++++++++++++++++++++++ tlgr/ops/payment.py | 906 ++++++++++++++++++++++++++++++++++++++++++++ tlgr/ops/webapp.py | 707 ++++++++++++++++++++++++++++++++++ 3 files changed, 2381 insertions(+) create mode 100644 tlgr/ops/inline.py create mode 100644 tlgr/ops/payment.py create mode 100644 tlgr/ops/webapp.py diff --git a/tlgr/ops/inline.py b/tlgr/ops/inline.py new file mode 100644 index 0000000..d066829 --- /dev/null +++ b/tlgr/ops/inline.py @@ -0,0 +1,768 @@ +"""The `inline` group: `@bot query`, and the two halves of sending a result. + +Inline mode looks like a search box and behaves like nothing else in the API. + +* **Offsets are the bot's, not Telegram's.** `next_offset` is an opaque string + the bot invented; feeding it back is the only way to page, and an empty one + is the end. tlgr passes it through untouched rather than wrapping it in a + signed cursor that would imply an ordering nobody promised. +* **A result id is only valid with its query id, and only briefly.** They come + back paired for `cache_time` seconds. `inline send --pick` therefore re-runs + the query itself instead of accepting a pair from an earlier command, which + is the difference between a command that works and one that fails whenever + the user paused to think. +* **A silent bot is not an error.** `BOT_RESPONSE_TIMEOUT` means the bot is + offline. That is an empty page (exit 3), not a failure — an agent that reads + it as a failure retries something that will never answer. + +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.models.base import Request +from tlgr.models.inline import ( + InlineEdited, + InlineResult, + InlineSent, + PreparedMessage, + PreparedSaved, +) +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _bots, _send +from tlgr.ops._common import client, window +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: `botInlineMessage*` → the `send_message` kind reported on a result. +_MESSAGE_KINDS = { + "BotInlineMessageText": "text", + "BotInlineMessageMediaAuto": "media_auto", + "BotInlineMessageMediaGeo": "geo", + "BotInlineMessageMediaVenue": "venue", + "BotInlineMessageMediaContact": "contact", + "BotInlineMessageMediaInvoice": "invoice", + "BotInlineMessageMediaWebPage": "webpage", + "BotInlineMessageRichMessage": "rich", + "BotInlineMessageGame": "game", +} + +_PEER_TYPES = { + "pm": "InlineQueryPeerTypePM", + "bot": "InlineQueryPeerTypeBotPM", + "group": "InlineQueryPeerTypeChat", + "megagroup": "InlineQueryPeerTypeMegagroup", + "channel": "InlineQueryPeerTypeBroadcast", + "broadcast": "InlineQueryPeerTypeBroadcast", + "same_bot": "InlineQueryPeerTypeSameBotPM", +} + +_EXAMPLE_RESULT: dict[str, Any] = { + "n": 0, + "id": "BQADAgAD", + "type": "gif", + "title": "cat", + "query_id": "987654321", +} + + +def _result_model(entry: Any, index: int, query_id: int, results: Any = None) -> InlineResult: + """One `botInlineResult`/`botInlineMediaResult` as one row. + + The two constructors differ in where the bytes live — a `WebDocument` the + client must fetch, or a `Photo`/`Document` Telegram already holds — and + `content` is what says which, so a caller that needs to know still can. + """ + send = getattr(entry, "send_message", None) + document = getattr(entry, "document", None) + photo = getattr(entry, "photo", None) + thumb = getattr(entry, "thumb", None) + media = document is not None or photo is not None + return InlineResult( + n=index, + id=str(getattr(entry, "id", "") or ""), + type=str(getattr(entry, "type", "") or ""), + title=getattr(entry, "title", None), + description=getattr(entry, "description", None), + url=getattr(entry, "url", None), + thumb=getattr(thumb, "url", None), + content="media" if media else "url", + send_message=_MESSAGE_KINDS.get(type(send).__name__), + query_id=str(query_id), + doc_id=int(getattr(document, "id", 0) or 0) or None, + photo_id=int(getattr(photo, "id", 0) or 0) or None, + gallery=bool(getattr(results, "gallery", False)) if results is not None else False, + cache_time=getattr(results, "cache_time", None) if results is not None else None, + ) + + +def _switch(value: Any) -> dict[str, Any] | None: + if value is None: + return None + return { + "text": getattr(value, "text", None), + "start_param": getattr(value, "start_param", None), + "url": getattr(value, "url", None), + } + + +def _timed_out(exc: BaseException) -> bool: + """`BOT_RESPONSE_TIMEOUT` — the bot is offline, which is an answer.""" + return "BOTRESPONSETIMEOUT" in f"{type(exc).__name__} {exc}".upper().replace("_", "") + + +async def _query( + ctx: OpContext, + bot: Any, + peer: Any, + text: str, + offset: str, + geo: Any = None, +) -> Any: + from telethon.tl.functions import messages as fn + + return await client(ctx)( + fn.GetInlineBotResultsRequest(bot=bot, peer=peer, query=text, offset=offset, geo_point=geo) + ) + + +def _geo(lat: float | None, lon: float | None, accuracy: int | None) -> Any: + if lat is None or lon is None: + return None + from telethon.tl import types + + return types.InputGeoPoint(lat=lat, long=lon, accuracy_radius=accuracy) + + +# --------------------------------------------------------------------------- +# inline query +# --------------------------------------------------------------------------- + + +class QueryReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The inline bot.")] + query: Annotated[ + str, arg(1, metavar="QUERY", required=False, help="Query text; empty is valid.") + ] = "" + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Chat the query is made from."), + ] = None + offset: Annotated[ + str | None, opt("--offset", metavar="TOKEN", help="Opaque next_offset from a page.") + ] = None + lat: Annotated[float | None, opt("--lat", metavar="DEG", help="Latitude for geo bots.")] = None + lon: Annotated[float | None, opt("--lon", metavar="DEG", help="Longitude for geo bots.")] = None + accuracy: Annotated[ + int | None, opt("--accuracy", metavar="M", help="Location accuracy radius in metres.") + ] = None + + +async def query(ctx: OpContext, req: QueryReq) -> Page[InlineResult]: + """Query an inline bot and list what it answers with. + + The chat matters: a bot is told which kind of chat the query came from and + routinely answers differently in a group than in a private chat, so + `--chat` is not cosmetic. + """ + from telethon.tl import types + + limit, state = window(ctx, "inline.query", PageKind.RATE, default=50) + bot = await _bots.input_user(ctx, req.bot) + peer = await _send.resolve(ctx, req.chat) if req.chat is not None else types.InputPeerSelf() + offset = req.offset if req.offset is not None else str(state.get("offset", "") or "") + + try: + results = await _query( + ctx, bot, peer, req.query, offset, _geo(req.lat, req.lon, req.accuracy) + ) + except Exception as exc: + if not _timed_out(exc): + raise + ctx.warn("the bot did not answer in time; it is probably offline") + return Page(items=[], has_more=False, total=0) + + query_id = int(getattr(results, "query_id", 0) or 0) + entries = list(getattr(results, "results", None) or [])[:limit] + items = [_result_model(entry, index, query_id, results) for index, entry in enumerate(entries)] + next_offset = str(getattr(results, "next_offset", "") or "") + if items: + items[0].next_offset = next_offset or None + items[0].switch_pm = _switch(getattr(results, "switch_pm", None)) + items[0].switch_webview = _switch(getattr(results, "switch_webview", None)) + return build_page( + items, + op="inline.query", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + total=None, + ) + + +SPEC_QUERY = OperationSpec( + id="inline.query", + request=QueryReq, + response=Page[InlineResult], + impl=query, + summary="Query an inline bot and list its results", + description=( + "Paging offsets are opaque strings the bot invented, not integers: " + "the `next_offset` on the first row is fed straight back, and an " + "empty one means the end. A bot that does not answer is an empty page " + "with a warning, not an error." + ), + paginated=PageKind.RATE, + empty_exit=3, + columns=("n", "id", "type", "title"), + headers=("#", "ID", "Type", "Title"), + example={"items": [_EXAMPLE_RESULT], "has_more": False}, + example_args="inline query @gifbot cat", + covers=( + "bots.inline-query", + "bots.inline-query-paging", + "bots.inline-query-with-location", + "bots.inline-result-message-kinds", + "bots.inline-result-types", + "bots.inline-switch-webview", + "bots.switch-inline-button", + ), + covers_partial=("bots.inline-switch-pm", "bots.webapp-switch-inline-query"), + coverage_note=( + "A `switch_pm` button is completed with `bot start --param` and a " + "`switch_webview` one with `webapp open --from-switch-webview`." + ), +) + + +# --------------------------------------------------------------------------- +# inline search +# --------------------------------------------------------------------------- + + +class SearchReq(Request): + kind: Annotated[str, arg(0, metavar="KIND", help="gif, venue or image.")] + query: Annotated[str, arg(1, metavar="QUERY", required=False, help="Search text.")] = "" + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Chat the search is made from."), + ] = None + lat: Annotated[float | None, opt("--lat", metavar="DEG", help="Latitude (venue).")] = None + lon: Annotated[float | None, opt("--lon", metavar="DEG", help="Longitude (venue).")] = None + offset: Annotated[str | None, opt("--offset", metavar="TOKEN", help="Opaque next_offset.")] = ( + None + ) + + +_SEARCH_BOTS = { + "gif": ("gif_search_username", "gif"), + "venue": ("venue_search_username", "foursquare"), + "image": ("img_search_username", "pic"), +} + + +async def search(ctx: OpContext, req: SearchReq) -> Page[InlineResult]: + """Search the built-in inline bots for GIFs, venues or images. + + The usernames come from `help.getConfig`, never from a constant here: + Telegram has moved them before, and a hardcoded one would keep querying an + account that no longer serves anything. + """ + from telethon.tl import types + from telethon.tl.functions import help as help_fn + + if req.kind not in _SEARCH_BOTS: + raise UsageError("kind must be gif, venue or image", field="kind") + if req.kind == "venue" and (req.lat is None or req.lon is None): + raise UsageError("a venue search needs --lat and --lon", field="lat") + + key, fallback = _SEARCH_BOTS[req.kind] + username = fallback + try: + config = await client(ctx)(help_fn.GetConfigRequest()) + username = str(getattr(config, key, "") or fallback) + except Exception: # an older server: fall back rather than fail the search + pass + + limit, state = window(ctx, "inline.search", PageKind.RATE, default=50) + # Built rather than parsed: Telegram's own service accounts are shorter + # than the four characters a *user* may register, so the username parser + # rightly refuses them. + handle = username.lstrip("@") + bot = await _bots.input_user( + ctx, PeerRef(raw=f"@{handle}", kind="username", value=handle), field="kind" + ) + peer = await _send.resolve(ctx, req.chat) if req.chat is not None else types.InputPeerEmpty() + offset = req.offset if req.offset is not None else str(state.get("offset", "") or "") + + try: + results = await _query(ctx, bot, peer, req.query, offset, _geo(req.lat, req.lon, None)) + except Exception as exc: + if not _timed_out(exc): + raise + ctx.warn(f"@{handle} did not answer in time") + return Page(items=[], has_more=False, total=0) + + query_id = int(getattr(results, "query_id", 0) or 0) + entries = list(getattr(results, "results", None) or [])[:limit] + items = [_result_model(entry, index, query_id, results) for index, entry in enumerate(entries)] + next_offset = str(getattr(results, "next_offset", "") or "") + return build_page( + items, + op="inline.search", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + ) + + +SPEC_SEARCH = OperationSpec( + id="inline.search", + request=SearchReq, + response=Page[InlineResult], + impl=search, + summary="Search the built-in inline bots for GIFs, venues or images", + paginated=PageKind.RATE, + empty_exit=3, + columns=("n", "id", "type", "title"), + headers=("#", "ID", "Type", "Title"), + example={"items": [_EXAMPLE_RESULT], "has_more": False}, + example_args="inline search gif cat", + covers=("bots.gif-search-inline", "bots.img-search-inline", "bots.venue-search-inline"), +) + + +# --------------------------------------------------------------------------- +# inline send +# --------------------------------------------------------------------------- + + +class SendReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The inline bot.")] + query: Annotated[ + str, arg(1, metavar="QUERY", required=False, help="Query to re-run for --pick.") + ] = "" + chat: Annotated[ + PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Destination chat.") + ] = None + pick: Annotated[ + str | None, opt("--pick", metavar="N|ID", help="Result to send: index or result id.") + ] = None + query_id: Annotated[ + str | None, opt("--query-id", metavar="ID", help="query_id from a previous `inline query`.") + ] = None + result_id: Annotated[ + str | None, opt("--result-id", metavar="ID", help="Result id belonging to --query-id.") + ] = None + hide_via: Annotated[bool, opt("--hide-via", help="Drop the 'via @bot' header.")] = False + clear_draft: Annotated[bool, opt("--clear-draft", help="Clear the chat draft.")] = False + background: Annotated[bool, opt("--background", help="Send in the background.")] = False + quick_reply: Annotated[ + str | None, + opt("--quick-reply", metavar="SHORTCUT", help="Store it in a Business quick reply."), + ] = None + reply_to: Annotated[ + int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.") + ] = None + quote: Annotated[str | None, opt("--quote", help="Quoted fragment of the reply target.")] = None + topic: Annotated[ + int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.") + ] = None + silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False + schedule: Annotated[ + str | None, opt("--schedule", metavar="TS|online", help="Schedule the send.") + ] = None + send_as: Annotated[ + PeerRef | None, opt("--send-as", metavar="PEER", kind="peer", help="Send as this peer.") + ] = None + paid_stars: Annotated[ + int | None, + opt("--paid-stars", metavar="N", help="Agree to pay N Stars for a paid-message peer."), + ] = None + business_connection: Annotated[ + str | None, + opt( + "--business-connection", metavar="ID", help="Send as a business account (bot session)." + ), + ] = None + + +async def send(ctx: OpContext, req: SendReq) -> InlineSent: + """Send one chosen inline result into a chat. + + `--pick` re-runs the query in this same command rather than taking a + `(query_id, result_id)` pair from an earlier one, because that pair + expires in about a minute: a two-command workflow would fail whenever the + human in the middle took a moment to choose. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + if req.chat is None: + raise UsageError("--chat is required", field="chat") + if req.paid_stars and req.paid_stars < 0: + raise UsageError("--paid-stars cannot be negative", field="paid_stars") + + target = await _send.resolve(ctx, req.chat) + query_id, result_id = await _pair(ctx, req, target) + + request = fn.SendInlineBotResultRequest( + peer=target, + query_id=query_id, + id=result_id, + random_id=_random_id(), + silent=req.silent or None, + background=req.background or None, + clear_draft=req.clear_draft or None, + hide_via=req.hide_via or None, + reply_to=await _send.reply_target( + ctx, reply_to=req.reply_to, quote=req.quote, topic=req.topic + ), + schedule_date=_send.schedule_at(req.schedule), + send_as=await _send.resolve(ctx, req.send_as) if req.send_as is not None else None, + quick_reply_shortcut=( + types.InputQuickReplyShortcut(shortcut=req.quick_reply) if req.quick_reply else None + ), + allow_paid_stars=req.paid_stars, + ) + updates = await _invoke_as(ctx, req.business_connection, request) + message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target)) + ctx.emit("inline_send", {"chat_id": message.chat_id, "result_id": result_id}) + return InlineSent( + chat_id=message.chat_id, + msg_id=message.id, + result_id=result_id, + via_bot_id=message.via_bot_id, + quick_reply=req.quick_reply, + ) + + +async def _pair(ctx: OpContext, req: SendReq, target: Any) -> tuple[int, str]: + """The `(query_id, result_id)` pair, freshly minted unless one was given.""" + if req.query_id and req.result_id: + try: + return int(req.query_id), req.result_id + except ValueError as exc: + raise UsageError("--query-id must be numeric", field="query_id") from exc + if req.query_id or req.result_id: + raise UsageError("--query-id and --result-id are only valid together", field="query_id") + if req.pick is None: + raise UsageError("give --pick, or --query-id with --result-id", field="pick") + + bot = await _bots.input_user(ctx, req.bot) + results = await _query(ctx, bot, target, req.query, "") + entries = list(getattr(results, "results", None) or []) + query_id = int(getattr(results, "query_id", 0) or 0) + spec = req.pick.strip() + if spec.isdigit() and int(spec) < len(entries): + return query_id, str(getattr(entries[int(spec)], "id", "")) + for entry in entries: + if str(getattr(entry, "id", "")) == spec: + return query_id, spec + raise NotFoundError(f"the bot returned no result {spec!r} for that query") + + +def _random_id() -> int: + from tlgr.ops._common import random_id + + return random_id() + + +async def _invoke_as(ctx: OpContext, connection_id: str | None, request: Any) -> Any: + from tlgr.ops.bot import _invoke_as as wrap + + return await wrap(ctx, connection_id, request) + + +SPEC_SEND = OperationSpec( + id="inline.send", + request=SendReq, + response=InlineSent, + impl=send, + summary="Send a chosen inline result to a chat", + description=( + "`--paid-stars` agrees to a per-message Star fee. Naming the number " + "is the consent: `--yes` is a CLI-level gate an operation never sees, " + "so a flag that spends money spells out how much." + ), + mutating=True, + rate_class="send", + columns=("chat_id", "msg_id", "result_id"), + headers=("Chat", "Message", "Result"), + example={"chat_id": 4242, "msg_id": 12, "result_id": "BQADAgAD"}, + example_args="inline send @gifbot cat --chat @alice --pick 0", + covers=( + "bots.inline-result-into-quick-reply", + "bots.send-inline-result", + "bots.webapp-switch-inline-query", + ), + covers_partial=("bots.gif-search-inline", "bots.venue-search-inline"), + coverage_note="Running the built-in searches themselves is `inline search`.", +) + + +# --------------------------------------------------------------------------- +# inline edit +# --------------------------------------------------------------------------- + + +class EditReq(Request): + inline_msg_id: Annotated[ + str, arg(0, metavar="INLINE_MSG_ID", help="Inline message id as dc:id:access_hash.") + ] + text: Annotated[str | None, opt("--text", help="New text.")] = None + media: Annotated[str | None, opt("--media", metavar="PATH", kind="path", help="New media.")] = ( + None + ) + buttons: Annotated[ + str | None, opt("--buttons", metavar="PATH", kind="path", help="New keyboard, as JSON.") + ] = None + parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None + no_preview: Annotated[bool, opt("--no-preview", help="Disable the link preview.")] = False + + +async def edit(ctx: OpContext, req: EditReq) -> InlineEdited: + """Edit a message that was sent through inline mode. + + The request has to reach the DC named in the inline message id. Sending it + to the home DC fails with an error that says nothing about data centres, + which is why the id carries one at all. + """ + from telethon.tl.functions import messages as fn + + await _bots.require_bot_session(ctx, "editing an inline message") + identifier = _bots.inline_message_id(req.inline_msg_id, field="inline_msg_id") + text, entities = _send.body(req.text, parse=req.parse) if req.text is not None else ("", []) + await _bots.on_dc( + ctx, + int(getattr(identifier, "dc_id", 0) or 0), + fn.EditInlineBotMessageRequest( + id=identifier, + message=text if req.text is not None else None, + entities=_send.tl_entities(entities) if req.text is not None else None, + media=await _send.input_media(ctx, req.media) if req.media else None, + reply_markup=_bots.keyboard_tl(req.buttons, field="buttons"), + no_webpage=req.no_preview or None, + ), + ) + return InlineEdited(inline_msg_id=req.inline_msg_id, edited=True) + + +SPEC_EDIT = OperationSpec( + id="inline.edit", + request=EditReq, + response=InlineEdited, + impl=edit, + summary="Edit a message sent through inline mode", + mutating=True, + columns=("inline_msg_id", "edited"), + headers=("Inline ID", "Edited"), + example={"inline_msg_id": "2:123:456", "edited": True}, + example_args="inline edit 2:123:456 --text Updated", + covers=("bots.edit-inline-message",), +) + + +# --------------------------------------------------------------------------- +# inline prepared +# --------------------------------------------------------------------------- + + +class PreparedGetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")] + id: Annotated[str, arg(1, metavar="ID", help="Prepared message id from the app.")] + + +def _peer_type_names(values: Any) -> list[str]: + names = {tl: name for name, tl in _PEER_TYPES.items()} + return [names.get(type(v).__name__, type(v).__name__) for v in (values or [])] + + +async def prepared_get(ctx: OpContext, req: PreparedGetReq) -> PreparedMessage: + """Inspect a prepared inline message shared from a mini app. + + `peer_types` is not advisory: it restricts which chats the picker may + offer, and `inline prepared send` refuses a chat outside it rather than + letting the server reject the send after the fact. + """ + from telethon.tl.functions import messages as fn + + result = await client(ctx)( + fn.GetPreparedInlineMessageRequest(bot=await _bots.input_user(ctx, req.bot), id=req.id) + ) + query_id = int(getattr(result, "query_id", 0) or 0) + entry = getattr(result, "result", None) + return PreparedMessage( + query_id=str(query_id), + result=_result_model(entry, 0, query_id) if entry is not None else None, + peer_types=_peer_type_names(getattr(result, "peer_types", None)), + cache_time=getattr(result, "cache_time", None), + ) + + +SPEC_PREPARED_GET = OperationSpec( + id="inline.prepared.get", + request=PreparedGetReq, + response=PreparedMessage, + impl=prepared_get, + summary="Inspect a prepared inline message from a mini app", + columns=("query_id", "peer_types", "cache_time"), + headers=("Query", "Chat types", "Cache"), + example={"query_id": "987654321", "peer_types": ["pm"]}, + example_args="inline prepared get @my_helper_bot abc123", + covers_partial=("bots.prepared-inline-message-send",), + coverage_note="Sending it is `inline prepared send`.", +) + + +class PreparedSaveReq(Request): + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="Who will be able to share it."), + ] = None + result: Annotated[ + str | None, opt("--result", metavar="PATH", kind="path", help="JSON inline result.") + ] = None + peer_types: Annotated[ + list[str], + opt("--peer-types", metavar="KIND", help="Chat types the picker may offer (repeatable)."), + ] = [] + + +async def prepared_save(ctx: OpContext, req: PreparedSaveReq) -> PreparedSaved: + """Save a prepared inline message for a user to share later.""" + from telethon.tl import types + from telethon.tl.functions import messages as fn + + await _bots.require_bot_session(ctx, "saving a prepared inline message") + if req.user is None or not req.result: + raise UsageError("--user and --result are both required", field="user") + + from tlgr.ops.bot import _inline_results + + peer_types = [] + for name in req.peer_types: + klass = _PEER_TYPES.get(name) + if klass is None: + raise UsageError( + f"--peer-types: {name!r} is not a chat type ({', '.join(sorted(_PEER_TYPES))})", + field="peer_types", + ) + peer_types.append(getattr(types, klass)()) + + result = await client(ctx)( + fn.SavePreparedInlineMessageRequest( + result=_inline_results(req.result)[0], + user_id=await _bots.input_user(ctx, req.user, field="user"), + peer_types=peer_types or None, + ) + ) + from tlgr.core.timefmt import fmt_dt + + return PreparedSaved( + id=str(getattr(result, "id", "") or ""), + expires_at=fmt_dt(getattr(result, "expire_date", None)), + ) + + +SPEC_PREPARED_SAVE = OperationSpec( + id="inline.prepared.save", + request=PreparedSaveReq, + response=PreparedSaved, + impl=prepared_save, + summary="Save a prepared inline message for a user", + mutating=True, + columns=("id", "expires_at"), + headers=("ID", "Expires"), + example={"id": "abc123"}, + example_args="inline prepared save --user @alice --result ./result.json", + covers=("bots.prepared-inline-message-save",), +) + + +class PreparedSendReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")] + id: Annotated[str, arg(1, metavar="ID", help="Prepared message id from the app.")] + chat: Annotated[ + PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Destination chat.") + ] = None + reply_to: Annotated[ + int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.") + ] = None + silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False + hide_via: Annotated[bool, opt("--hide-via", help="Drop the 'via @bot' header.")] = False + + +_PEER_KINDS = { + "InputPeerUser": {"pm", "bot", "same_bot"}, + "InputPeerChat": {"group"}, + "InputPeerChannel": {"channel", "broadcast", "megagroup", "group"}, + "InputPeerSelf": {"pm", "same_bot"}, +} + + +async def prepared_send(ctx: OpContext, req: PreparedSendReq) -> InlineSent: + """Send a prepared inline message a mini app handed over. + + The app said which chat types it allows; a destination outside them is a + usage error here rather than a server rejection, because the app's + restriction is the thing the user agreed to when they tapped share. + """ + from telethon.tl.functions import messages as fn + + if req.chat is None: + raise UsageError("--chat is required", field="chat") + prepared = await prepared_get(ctx, PreparedGetReq(bot=req.bot, id=req.id)) + target = await _send.resolve(ctx, req.chat) + allowed = set(prepared.peer_types) + if allowed: + kinds = _PEER_KINDS.get(type(target).__name__, set()) + if not (kinds & allowed): + raise UsageError( + f"this prepared message may only go to {', '.join(sorted(allowed))}", + field="chat", + ) + + updates = await client(ctx)( + fn.SendInlineBotResultRequest( + peer=target, + query_id=int(prepared.query_id or 0), + id=prepared.result.id if prepared.result is not None else "", + random_id=_random_id(), + silent=req.silent or None, + hide_via=req.hide_via or None, + reply_to=await _send.reply_target(ctx, reply_to=req.reply_to), + ) + ) + message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target)) + return InlineSent( + chat_id=message.chat_id, + msg_id=message.id, + result_id=prepared.result.id if prepared.result is not None else "", + via_bot_id=message.via_bot_id, + ) + + +SPEC_PREPARED_SEND = OperationSpec( + id="inline.prepared.send", + request=PreparedSendReq, + response=InlineSent, + impl=prepared_send, + summary="Send a prepared inline message shared from a mini app", + mutating=True, + rate_class="send", + columns=("chat_id", "msg_id", "result_id"), + headers=("Chat", "Message", "Result"), + example={"chat_id": 4242, "msg_id": 12, "result_id": "BQADAgAD"}, + example_args="inline prepared send @my_helper_bot abc123 --chat @alice", + covers=("bots.prepared-inline-message-send",), +) diff --git a/tlgr/ops/payment.py b/tlgr/ops/payment.py new file mode 100644 index 0000000..a07f6fb --- /dev/null +++ b/tlgr/ops/payment.py @@ -0,0 +1,906 @@ +"""The `payment` group: the whole checkout surface, minus the button that pays. + +tlgr models payments completely and moves no money. That is a policy, not a +gap, and the shape of this module is what makes it checkable: + +* **Reading is implemented.** The form, its prices, its provider, the fields it + wants, the saved cards it would offer, the receipt afterwards, the Star + subscriptions on the account. A caller can see everything needed to decide. +* **Asking someone else to pay is implemented.** `payment invoice export` and + `payment invoice send` create an invoice — that spends nobody's money, it + requests somebody else's. +* **Spending is absent.** `payments.sendPaymentForm`, `sendStarsForm`, + `validateRequestedInfo`, `fulfillStarsSubscription` — none of them is behind + a flag, a confirmation or an environment variable. `payment form get` + reports `payable_here: false` with the reason, so an agent reading the form + learns *why* rather than discovering it from an exit code. + +Cancelling a subscription is here, because cancelling costs nothing. Resuming +one re-enables future charges, which is why the whole command is confirmed. + +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_duration, to_unix +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.payment import ( + BankCard, + Invoice, + InvoiceLink, + InvoiceSent, + PaymentForm, + PaymentInfo, + PaymentInfoCleared, + PriceLine, + Receipt, + StarSubscription, + SubscriptionChange, +) +from tlgr.models.peer import PeerRef +from tlgr.ops import _bots, _send +from tlgr.ops._common import client, window +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: The one sentence every unpayable form carries. Written once so the policy +#: reads the same in the JSON, in the docs and in the refusal. +NOT_PAYABLE = ( + "tlgr never spends money: paying a form, validating order info, tipping " + "and re-fulfilling a lapsed subscription are all absent from the surface" +) + + +def _prices(values: Any) -> list[PriceLine]: + return [ + PriceLine( + label=str(getattr(price, "label", "") or ""), + amount=int(getattr(price, "amount", 0) or 0), + ) + for price in (values or []) + ] + + +def _invoice(raw: Any) -> Invoice | None: + if raw is None: + return None + prices = _prices(getattr(raw, "prices", None)) + return Invoice( + currency=str(getattr(raw, "currency", "") or ""), + total_amount=sum(price.amount for price in prices), + prices=prices, + test=bool(getattr(raw, "test", False)), + name_requested=bool(getattr(raw, "name_requested", False)), + phone_requested=bool(getattr(raw, "phone_requested", False)), + email_requested=bool(getattr(raw, "email_requested", False)), + shipping_address_requested=bool(getattr(raw, "shipping_address_requested", False)), + flexible=bool(getattr(raw, "flexible", False)), + recurring=bool(getattr(raw, "recurring", False)), + terms_url=getattr(raw, "terms_url", None), + subscription_period=getattr(raw, "subscription_period", None), + max_tip_amount=getattr(raw, "max_tip_amount", None), + suggested_tip_amounts=[int(v) for v in (getattr(raw, "suggested_tip_amounts", None) or [])], + ) + + +def _saved_credentials(values: Any) -> list[dict[str, Any]]: + """Saved cards, as a masked title and an id. Never a number.""" + return [ + {"id": str(getattr(entry, "id", "") or ""), "title": str(getattr(entry, "title", "") or "")} + for entry in (values or []) + ] + + +def _saved_info(info: Any) -> dict[str, Any] | None: + if info is None: + return None + address = getattr(info, "shipping_address", None) + return { + "name": getattr(info, "name", None), + "phone": getattr(info, "phone", None), + "email": getattr(info, "email", None), + "shipping": ( + { + "street_line1": getattr(address, "street_line1", None), + "street_line2": getattr(address, "street_line2", None), + "city": getattr(address, "city", None), + "state": getattr(address, "state", None), + "country_iso2": getattr(address, "country_iso2", None), + "post_code": getattr(address, "post_code", None), + } + if address is not None + else None + ), + } + + +# --------------------------------------------------------------------------- +# payment form get +# --------------------------------------------------------------------------- + + +class FormGetReq(Request): + message: Annotated[ + str | None, + opt("--message", metavar="CHAT:MSG_ID", help="Invoice message with a Pay button."), + ] = None + slug: Annotated[str | None, opt("--slug", metavar="SLUG", help="Invoice deep-link slug.")] = ( + None + ) + stars: Annotated[ + int | None, opt("--stars", metavar="N", help="Stars top-up form for N Stars.") + ] = None + chat_invite: Annotated[ + str | None, + opt("--chat-invite", metavar="HASH", help="Star-subscription invite hash."), + ] = None + business_transfer: Annotated[ + str | None, + opt("--business-transfer", metavar="BOT:STARS", help="Business → bot Stars transfer."), + ] = None + theme: Annotated[ + str | None, opt("--theme", metavar="PATH", kind="path", help="JSON theme params.") + ] = None + + +async def _input_invoice(ctx: OpContext, req: FormGetReq) -> Any: + """One of the five `inputInvoice*` constructors, from one flag each.""" + from telethon.tl import types + + chosen = [ + name + for name in ("message", "slug", "stars", "chat_invite", "business_transfer") + if getattr(req, name) is not None + ] + if len(chosen) != 1: + raise UsageError( + "give exactly one of --message, --slug, --stars, --chat-invite or --business-transfer", + field="slug", + ) + + if req.slug: + return types.InputInvoiceSlug(slug=req.slug) + if req.chat_invite: + return types.InputInvoiceChatInviteSubscription(hash=req.chat_invite) + if req.stars is not None: + return types.InputInvoiceStars( + purpose=types.InputStorePaymentStarsTopup( + stars=int(req.stars), currency="USD", amount=0 + ) + ) + if req.business_transfer: + handle, _, amount = req.business_transfer.rpartition(":") + if not handle or not amount.isdigit(): + raise UsageError( + "--business-transfer: expected ':'", field="business_transfer" + ) + return types.InputInvoiceBusinessBotTransferStars( + bot=await _bots.input_user(ctx, _bots.peer_ref(handle), field="business_transfer"), + stars=int(amount), + ) + + chat, _, msg_id = str(req.message).rpartition(":") + if not chat or not msg_id.strip().lstrip("-").isdigit(): + raise UsageError("--message: expected ':'", field="message") + return types.InputInvoiceMessage( + peer=await _send.resolve(ctx, _bots.peer_ref(chat)), msg_id=int(msg_id) + ) + + +async def form_get(ctx: OpContext, req: FormGetReq) -> PaymentForm: + """Read a checkout form without paying for it. + + Fetching a form charges nothing, even though it creates a server-side + `form_id` — that id is what a *payment* would then reference, and tlgr + never sends one. Star forms expire after about ten minutes; `FORM_EXPIRED` + just means fetch it again. + """ + from telethon.tl.functions import payments as fn + + from tlgr.ops.webapp import _theme + + invoice = await _input_invoice(ctx, req) + result = await client(ctx)( + fn.GetPaymentFormRequest(invoice=invoice, theme_params=_theme(req.theme)) + ) + name = type(result).__name__ + raw_invoice = getattr(result, "invoice", None) + model = _invoice(raw_invoice) + native = getattr(result, "native_params", None) + + form = PaymentForm( + form_kind={"PaymentForm": "form", "PaymentFormStars": "stars"}.get(name, "gift"), + form_id=int(getattr(result, "form_id", 0) or 0), + bot_id=getattr(result, "bot_id", None), + provider_id=getattr(result, "provider_id", None), + title=getattr(result, "title", None), + description=getattr(result, "description", None), + photo=getattr(getattr(result, "photo", None), "url", None), + invoice=model, + currency=model.currency if model else "", + total_amount=model.total_amount if model else 0, + prices=model.prices if model else [], + tip_amounts=model.suggested_tip_amounts if model else [], + recurring=bool(getattr(raw_invoice, "recurring", False)), + terms_url=getattr(raw_invoice, "terms_url", None), + subscription_period=getattr(raw_invoice, "subscription_period", None), + url=getattr(result, "url", None), + native_provider=getattr(result, "native_provider", None), + native_params=getattr(native, "data", None), + additional_methods=[ + {"url": getattr(m, "url", None), "title": getattr(m, "title", None)} + for m in (getattr(result, "additional_methods", None) or []) + ], + saved_info=_saved_info(getattr(result, "saved_info", None)), + saved_credentials=_saved_credentials(getattr(result, "saved_credentials", None)), + can_save_credentials=bool(getattr(result, "can_save_credentials", False)), + password_missing=bool(getattr(result, "password_missing", False)), + payable_here=False, + reason=NOT_PAYABLE, + ) + return form + + +SPEC_FORM_GET = OperationSpec( + id="payment.form.get", + request=FormGetReq, + response=PaymentForm, + impl=form_get, + summary="Read an invoice's checkout form without paying", + description=( + "Price, currency, provider, required fields and saved credentials. " + "`payable_here` is always false and carries the reason: the shape a " + "caller needs in order to decide is here, and the call that would " + "charge is deliberately not." + ), + aliases=("pay.form.get",), + columns=("form_kind", "title", "currency", "total_amount"), + headers=("Kind", "Title", "Currency", "Amount"), + example={ + "form_kind": "form", + "form_id": 555, + "title": "T-shirt", + "currency": "USD", + "total_amount": 1999, + "payable_here": False, + }, + example_args="payment form get --slug tshirt-123", + covers=( + "bots.get-payment-form", + "bots.invoice-deeplink", + "bots.invoice-input-kinds", + "bots.recurring-payment-terms", + ), +) + + +# --------------------------------------------------------------------------- +# payment receipt get +# --------------------------------------------------------------------------- + + +class ReceiptGetReq(Request): + chat: Annotated[ + PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat holding the service message.") + ] + msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Service message id.")] + + +async def receipt_get(ctx: OpContext, req: ReceiptGetReq) -> Receipt: + """A payment receipt. + + The id must be the `messageActionPaymentSent` service message, not the + invoice that preceded it: those are two different messages and Telegram + only knows the receipt by the first. + """ + from telethon.tl.functions import payments as fn + + result = await client(ctx)( + fn.GetPaymentReceiptRequest(peer=await _send.resolve(ctx, req.chat), msg_id=int(req.msg_id)) + ) + raw_invoice = getattr(result, "invoice", None) + model = _invoice(raw_invoice) + date = getattr(result, "date", None) + return Receipt( + date=fmt_dt(date), + date_unix=to_unix(date), + bot_id=getattr(result, "bot_id", None), + provider_id=getattr(result, "provider_id", None), + title=getattr(result, "title", None), + description=getattr(result, "description", None), + invoice=model, + currency=str(getattr(result, "currency", "") or (model.currency if model else "")), + total_amount=int(getattr(result, "total_amount", 0) or 0), + tip_amount=getattr(result, "tip_amount", None), + credentials_title=getattr(result, "credentials_title", None), + shipping=_shipping(getattr(result, "shipping", None)), + info=_saved_info(getattr(result, "info", None)), + transaction_id=getattr(result, "transaction_id", None), + recurring=bool(getattr(raw_invoice, "recurring", False)), + ) + + +def _shipping(option: Any) -> dict[str, Any] | None: + if option is None: + return None + return { + "id": getattr(option, "id", None), + "title": getattr(option, "title", None), + "prices": [ + {"label": p.label, "amount": p.amount} for p in _prices(getattr(option, "prices", None)) + ], + } + + +SPEC_RECEIPT_GET = OperationSpec( + id="payment.receipt.get", + request=ReceiptGetReq, + response=Receipt, + impl=receipt_get, + summary="Show a payment receipt", + aliases=("pay.receipt",), + columns=("date", "title", "currency", "total_amount"), + headers=("Date", "Title", "Currency", "Amount"), + example={"title": "T-shirt", "currency": "USD", "total_amount": 1999}, + example_args="payment receipt get @shopbot 42", + covers=("bots.payment-receipt",), +) + + +# --------------------------------------------------------------------------- +# payment info get / delete +# --------------------------------------------------------------------------- + + +class InfoGetReq(Request): + clear: Annotated[bool, opt("--clear", help="Clear the selected parts.")] = False + credentials: Annotated[bool, opt("--credentials", help="Select saved cards.")] = False + shipping: Annotated[bool, opt("--shipping", help="Select saved shipping info.")] = False + + +async def info_get(ctx: OpContext, req: InfoGetReq) -> PaymentInfo: + """My saved order information and saved cards. + + Card numbers are never here to leak: `paymentSavedCredentialsCard` carries + an id and a masked title and nothing else, which is the whole reason this + is safe to print. + """ + from telethon.tl.functions import payments as fn + + handle = client(ctx) + result = await handle(fn.GetSavedInfoRequest()) + info = _saved_info(getattr(result, "saved_info", None)) or {} + model = PaymentInfo( + has_saved_credentials=bool(getattr(result, "has_saved_credentials", False)), + saved_info=info or None, + name=info.get("name"), + phone=info.get("phone"), + email=info.get("email"), + shipping=info.get("shipping"), + has_saved_info=bool(info), + ) + if req.clear: + await handle( + fn.ClearSavedInfoRequest(credentials=req.credentials or None, info=req.shipping or None) + ) + model.cleared = True + model.has_saved_credentials = model.has_saved_credentials and not req.credentials + model.has_saved_info = model.has_saved_info and not req.shipping + return model + + +SPEC_INFO_GET = OperationSpec( + id="payment.info.get", + request=InfoGetReq, + response=PaymentInfo, + impl=info_get, + summary="Show my saved order information and saved cards", + aliases=("pay.saved-info.get", "settings.payment-info"), + mutating=True, + destructive=True, + tags=frozenset({"mutating-checked"}), + columns=("has_saved_info", "has_saved_credentials", "cleared"), + headers=("Info", "Cards", "Cleared"), + example={"has_saved_credentials": True, "has_saved_info": False}, + example_args="payment info get", + covers=("bots.saved-payment-info-get", "privacy.clear-payment-info"), +) + + +class InfoDeleteReq(Request): + credentials: Annotated[bool, opt("--credentials", help="Forget saved cards.")] = False + info: Annotated[bool, opt("--info", help="Forget saved shipping/contact info.")] = False + + +async def info_delete(ctx: OpContext, req: InfoDeleteReq) -> PaymentInfoCleared: + """Clear my saved shipping information and/or saved cards. + + Destructive, but not a money movement: forgetting a card does not spend + anything, which is why it is one of the few write verbs in this group. + """ + from telethon.tl.functions import payments as fn + + if not req.credentials and not req.info: + raise UsageError("give --credentials and/or --info", field="credentials") + await client(ctx)( + fn.ClearSavedInfoRequest(credentials=req.credentials or None, info=req.info or None) + ) + return PaymentInfoCleared(credentials_cleared=req.credentials, info_cleared=req.info) + + +SPEC_INFO_DELETE = OperationSpec( + id="payment.info.delete", + request=InfoDeleteReq, + response=PaymentInfoCleared, + impl=info_delete, + summary="Clear my saved shipping information and saved cards", + aliases=("pay.saved-info.clear",), + mutating=True, + destructive=True, + columns=("credentials_cleared", "info_cleared"), + headers=("Cards", "Info"), + example={"credentials_cleared": True, "info_cleared": False}, + example_args="payment info delete --credentials", + covers=("bots.saved-payment-info-clear",), +) + + +# --------------------------------------------------------------------------- +# payment card get +# --------------------------------------------------------------------------- + + +class CardGetReq(Request): + number: Annotated[str, arg(0, metavar="NUMBER", help="Card number or BIN.")] + + +async def card_get(ctx: OpContext, req: CardGetReq) -> BankCard: + """Look up the issuing bank of a card BIN. + + A read-only BIN lookup; it enters nothing into a payment flow. The number + is not echoed back, not logged, and not put on the event bus. + """ + from telethon.tl.functions import payments as fn + + result = await client(ctx)(fn.GetBankCardDataRequest(number=req.number.replace(" ", ""))) + return BankCard( + title=str(getattr(result, "title", "") or ""), + open_urls=[ + {"name": str(getattr(u, "name", "") or ""), "url": str(getattr(u, "url", "") or "")} + for u in (getattr(result, "open_urls", None) or []) + ], + ) + + +SPEC_CARD_GET = OperationSpec( + id="payment.card.get", + request=CardGetReq, + response=BankCard, + impl=card_get, + summary="Look up the issuing bank of a card BIN", + aliases=("pay.bank-card",), + columns=("title",), + headers=("Issuer",), + example={"title": "Example Bank", "open_urls": []}, + example_args="payment card get 411111", + covers=("bots.bank-card-data",), +) + + +# --------------------------------------------------------------------------- +# payment invoice export / send +# --------------------------------------------------------------------------- + + +class InvoiceExportReq(Request): + title: Annotated[str, opt("--title", help="Invoice title.")] = "" + description: Annotated[str, opt("--description", help="Invoice description.")] = "" + currency: Annotated[ + str, opt("--currency", metavar="ISO", help="ISO currency, or XTR for Stars.") + ] = "" + prices: Annotated[str, opt("--prices", metavar="LABEL:AMOUNT,…", help="Price components.")] = "" + payload: Annotated[str, opt("--payload", metavar="TEXT", help="Opaque bot payload.")] = "" + provider: Annotated[ + str | None, opt("--provider", metavar="TOKEN", help="Payment provider token (fiat).") + ] = None + provider_data: Annotated[ + str | None, opt("--provider-data", metavar="JSON", kind="json", help="Provider JSON.") + ] = None + photo: Annotated[str | None, opt("--photo", metavar="URL", help="Invoice photo URL.")] = None + subscription_period: Annotated[ + str | None, + opt("--subscription-period", metavar="DURATION", help="Recurring period (Stars only)."), + ] = None + tip_max: Annotated[ + int | None, opt("--tip-max", metavar="N", help="Maximum tip the buyer may add.") + ] = None + suggested_tips: Annotated[ + str | None, opt("--suggested-tips", metavar="N,…", help="Suggested tip amounts.") + ] = None + need: Annotated[ + list[str], + opt("--need", metavar="FIELD", help="name|phone|email|shipping (repeatable)."), + ] = [] + flexible: Annotated[bool, opt("--flexible", help="Price depends on the shipping option.")] = ( + False + ) + recurring_terms: Annotated[ + str | None, opt("--recurring-terms", metavar="URL", help="Terms URL for a recurring one.") + ] = None + + +def _price_lines(spec: str) -> list[Any]: + from telethon.tl import types + + out: list[Any] = [] + for chunk in spec.split(","): + label, _, amount = chunk.rpartition(":") + if not label or not amount.strip().lstrip("-").isdigit(): + raise UsageError("--prices: expected 'Label:1999,Shipping:500'", field="prices") + out.append(types.LabeledPrice(label=label.strip(), amount=int(amount))) + if not out: + raise UsageError("--prices is required", field="prices") + return out + + +def _tl_invoice(req: InvoiceExportReq) -> Any: + from telethon.tl import types + + need = {n.strip() for n in req.need} + unknown = need - {"name", "phone", "email", "shipping"} + if unknown: + raise UsageError( + f"--need: {sorted(unknown)[0]!r} is not a field (name, phone, email, shipping)", + field="need", + ) + period = int(parse_duration(req.subscription_period) or 0) if req.subscription_period else None + return types.Invoice( + currency=req.currency, + prices=_price_lines(req.prices), + name_requested="name" in need or None, + phone_requested="phone" in need or None, + email_requested="email" in need or None, + shipping_address_requested="shipping" in need or None, + flexible=req.flexible or None, + recurring=bool(req.recurring_terms) or None, + terms_url=req.recurring_terms, + subscription_period=period, + max_tip_amount=req.tip_max, + suggested_tip_amounts=( + [int(v) for v in req.suggested_tips.split(",") if v.strip()] + if req.suggested_tips + else None + ), + ) + + +def _invoice_media(req: InvoiceExportReq, *, extended: Any = None) -> Any: + from telethon.tl import types + + if not req.title or not req.description or not req.currency or not req.payload: + raise UsageError( + "--title, --description, --currency, --prices and --payload are all required", + field="title", + ) + return types.InputMediaInvoice( + title=req.title, + description=req.description, + invoice=_tl_invoice(req), + payload=_bots.payload_bytes(req.payload, field="payload") or b"", + provider_data=_bots.data_json(req.provider_data or "{}", field="provider_data"), + photo=( + types.InputWebDocument(url=req.photo, size=0, mime_type="image/jpeg", attributes=[]) + if req.photo + else None + ), + provider=req.provider, + extended_media=extended, + ) + + +async def invoice_export(ctx: OpContext, req: InvoiceExportReq) -> InvoiceLink: + """Create an invoice deep link. + + Creating an invoice is not a money movement — it asks somebody else to + pay — so it is implemented. A Star *subscription* invoice can only exist + as a link: `messages.sendMedia` rejects one, which is why there is no + `--subscription-period` on `payment invoice send`. + """ + from telethon.tl.functions import payments as fn + + await _bots.require_bot_session(ctx, "exporting an invoice link") + result = await client(ctx)(fn.ExportInvoiceRequest(invoice_media=_invoice_media(req))) + url = str(getattr(result, "url", "") or "") + return InvoiceLink(url=url, slug=url.rsplit("/", 1)[-1]) + + +SPEC_INVOICE_EXPORT = OperationSpec( + id="payment.invoice.export", + request=InvoiceExportReq, + response=InvoiceLink, + impl=invoice_export, + summary="Create an invoice deep link", + aliases=("pay.invoice.export",), + mutating=True, + columns=("url", "slug"), + headers=("URL", "Slug"), + example={"url": "https://t.me/$abc123", "slug": "$abc123"}, + example_args=( + 'payment invoice export --title Shirt --description "A shirt" ' + "--currency USD --prices Shirt:1999 --payload order-1" + ), + covers=("bots.bot-subscription-invoice", "bots.export-invoice-link"), +) + + +class InvoiceSendReq(Request): + user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Recipient (private only).")] + title: Annotated[str, opt("--title", help="Invoice title.")] = "" + description: Annotated[str, opt("--description", help="Invoice description.")] = "" + currency: Annotated[ + str, opt("--currency", metavar="ISO", help="ISO currency, or XTR for Stars.") + ] = "" + prices: Annotated[str, opt("--prices", metavar="LABEL:AMOUNT,…", help="Price components.")] = "" + payload: Annotated[str, opt("--payload", metavar="TEXT", help="Opaque bot payload.")] = "" + provider: Annotated[ + str | None, opt("--provider", metavar="TOKEN", help="Payment provider token (fiat).") + ] = None + provider_data: Annotated[ + str | None, opt("--provider-data", metavar="JSON", kind="json", help="Provider JSON.") + ] = None + photo: Annotated[str | None, opt("--photo", metavar="URL", help="Invoice photo URL.")] = None + extended_media: Annotated[ + str | None, + opt("--extended-media", metavar="PATH", kind="path", help="Paid media behind the invoice."), + ] = None + need: Annotated[ + list[str], opt("--need", metavar="FIELD", help="name|phone|email|shipping (repeatable).") + ] = [] + flexible: Annotated[bool, opt("--flexible", help="Price depends on the shipping option.")] = ( + False + ) + silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False + + +async def invoice_send(ctx: OpContext, req: InvoiceSendReq) -> InvoiceSent: + """Send an invoice message to a user. + + Invoices only go to private chats — Telegram refuses anything else — and + `--extended-media` turns the invoice into paid media, where the file is + hidden until the buyer pays somebody else's bill, never tlgr's. + """ + from telethon.tl.functions import messages as fn + + await _bots.require_bot_session(ctx, "sending an invoice") + target = await _send.resolve(ctx, req.user) + if type(target).__name__ not in ("InputPeerUser", "InputPeerSelf"): + raise UsageError("an invoice can only be sent to a private chat", field="user") + + export = InvoiceExportReq( + title=req.title, + description=req.description, + currency=req.currency, + prices=req.prices, + payload=req.payload, + provider=req.provider, + provider_data=req.provider_data, + photo=req.photo, + need=req.need, + flexible=req.flexible, + ) + extended = await _send.input_media(ctx, req.extended_media) if req.extended_media else None + media = _invoice_media(export, extended=extended) + from tlgr.ops._common import random_id + + updates = await client(ctx)( + fn.SendMediaRequest( + peer=target, + media=media, + message="", + random_id=random_id(), + silent=req.silent or None, + ) + ) + message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target)) + prices = _price_lines(req.prices) + return InvoiceSent( + chat_id=message.chat_id, + msg_id=message.id, + currency=req.currency, + total_amount=sum(int(getattr(p, "amount", 0) or 0) for p in prices), + ) + + +SPEC_INVOICE_SEND = OperationSpec( + id="payment.invoice.send", + request=InvoiceSendReq, + response=InvoiceSent, + impl=invoice_send, + summary="Send an invoice message to a user", + aliases=("pay.invoice.send",), + mutating=True, + rate_class="send", + columns=("chat_id", "msg_id", "currency", "total_amount"), + headers=("Chat", "Message", "Currency", "Amount"), + example={"chat_id": 4242, "msg_id": 12, "currency": "USD", "total_amount": 1999}, + example_args=( + "payment invoice send @alice --title Shirt --description Shirt " + "--currency USD --prices Shirt:1999 --payload order-1" + ), + covers=("bots.send-invoice-message",), +) + + +# --------------------------------------------------------------------------- +# payment subscription list / set +# --------------------------------------------------------------------------- + + +class SubscriptionListReq(Request): + missing_balance: Annotated[ + bool, opt("--missing-balance", help="Only ones that will lapse for lack of Stars.") + ] = False + + +def _subscription(entry: Any) -> StarSubscription: + until = getattr(entry, "until_date", None) + pricing = getattr(entry, "pricing", None) + peer = getattr(entry, "peer", None) + from tlgr.ops._serialize import peer_id_of + + return StarSubscription( + id=str(getattr(entry, "id", "") or ""), + peer=peer_id_of(peer), + 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=bool(getattr(entry, "canceled", False)), + can_refulfill=bool(getattr(entry, "can_refulfill", False)), + missing_balance=bool(getattr(entry, "missing_balance", False)), + invoice_slug=getattr(entry, "invoice_slug", None), + chat_invite_hash=getattr(entry, "chat_invite_hash", None), + title=getattr(entry, "title", None), + photo=getattr(getattr(entry, "photo", None), "url", None), + ) + + +async def subscription_list(ctx: OpContext, req: SubscriptionListReq) -> Page[StarSubscription]: + """My Telegram Star subscriptions. + + `can_refulfill` says the *server* would let a lapsed subscription be + rejoined. tlgr still will not: re-fulfilling debits Stars, and the row + carries the flag so a caller learns why rather than getting a bare + refusal. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + limit, state = window(ctx, "payment.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, + ) + ) + items = [ + _subscription(entry) for entry in (getattr(result, "subscriptions", None) or [])[:limit] + ] + next_offset = str(getattr(result, "subscriptions_next_offset", "") or "") + return build_page( + items, + op="payment.subscription.list", + kind=PageKind.RATE, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + total=getattr(result, "subscriptions_missing_balance", None), + ) + + +SPEC_SUBSCRIPTION_LIST = OperationSpec( + id="payment.subscription.list", + request=SubscriptionListReq, + response=Page[StarSubscription], + impl=subscription_list, + summary="List my Telegram Star subscriptions", + aliases=("pay.subscription.list", "stars.subs.list"), + paginated=PageKind.RATE, + columns=("id", "peer", "until_date", "cancelled"), + headers=("ID", "Peer", "Until", "Cancelled"), + example={"items": [{"id": "sub1", "peer": 4242, "cancelled": False}], "has_more": False}, + example_args="payment subscription list", + covers=("bots.stars-subscriptions-list",), +) + + +class SubscriptionSetReq(Request): + subscription_id: Annotated[ + str | None, + arg(0, metavar="SUBSCRIPTION_ID", required=False, help="My subscription id."), + ] = None + auto_renew: Annotated[str, choice("on", "off", help="Resume or cancel auto-renewal.")] = "off" + user: Annotated[ + PeerRef | None, opt("--user", metavar="USER", kind="user", help="Bot side: the subscriber.") + ] = None + charge_id: Annotated[ + str | None, opt("--charge-id", metavar="ID", help="Bot side: provider charge id.") + ] = None + + +async def subscription_set(ctx: OpContext, req: SubscriptionSetReq) -> SubscriptionChange: + """Turn a Star subscription's auto-renewal on or off. + + Deliberately *not* `payments.fulfillStarsSubscription`: that one pays for + a lapsed period and is absent from the surface. Cancelling costs nothing; + resuming re-enables future charges, which is what the confirmation on this + command is for. + """ + from telethon.tl import types + from telethon.tl.functions import payments as fn + + handle = client(ctx) + resume = req.auto_renew == "on" + + if req.user is not None or req.charge_id is not None: + await _bots.require_bot_session(ctx, "cancelling a user's subscription") + if req.user is None or not req.charge_id: + raise UsageError("--user and --charge-id go together", field="charge_id") + await handle( + fn.BotCancelStarsSubscriptionRequest( + user_id=await _bots.input_user(ctx, req.user, field="user"), + charge_id=req.charge_id, + restore=resume or None, + ) + ) + return SubscriptionChange( + user_id=_send.peer_id_of(await _send.resolve(ctx, req.user)), + charge_id=req.charge_id, + cancelled=not resume, + ) + + if not req.subscription_id: + raise UsageError( + "give a subscription id, or --user with --charge-id on a bot session", + field="subscription_id", + ) + await handle( + fn.ChangeStarsSubscriptionRequest( + peer=types.InputPeerSelf(), + subscription_id=req.subscription_id, + canceled=not resume, + ) + ) + return SubscriptionChange(subscription_id=req.subscription_id, cancelled=not resume) + + +SPEC_SUBSCRIPTION_SET = OperationSpec( + id="payment.subscription.set", + request=SubscriptionSetReq, + response=SubscriptionChange, + impl=subscription_set, + summary="Turn a Star subscription's auto-renewal on or off", + aliases=("pay.subscription.set",), + mutating=True, + destructive=True, + columns=("subscription_id", "cancelled"), + headers=("Subscription", "Cancelled"), + example={"subscription_id": "sub1", "cancelled": True}, + example_args="payment subscription set sub1 --auto-renew off", + covers=("bots.bot-cancel-user-subscription", "bots.stars-subscription-cancel"), +) diff --git a/tlgr/ops/webapp.py b/tlgr/ops/webapp.py new file mode 100644 index 0000000..5f9440a --- /dev/null +++ b/tlgr/ops/webapp.py @@ -0,0 +1,707 @@ +"""The `webapp` group: mini apps, from a CLI that has no browser. + +The return contract is the whole design, and it is deliberately narrow. + +`webapp open` prints the **signed URL** and stops. It never launches a +browser, and it never hosts the `window.Telegram.WebApp` bridge — a terminal +cannot run a mini app, and pretending otherwise would mean shipping a headless +browser inside a CLI. What tlgr *can* do is everything on the Telegram side of +the boundary: mint the session, keep it alive, answer the app's peer request, +carry its data back to the bot, and check a download it proposes. + +That URL is a credential, not a link: it carries the user's signed init data, +and whoever holds it can act as that user inside the app until it expires. It +is printed once, with that warning in human output, and plainly under `--json` +where the caller asked for machine-readable output on purpose. + +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, PermissionError_, UsageError +from tlgr.core.timefmt import fmt_dt +from tlgr.models.base import Request +from tlgr.models.bot import BotApiResult +from tlgr.models.peer import PeerRef +from tlgr.models.webapp import ( + WebAppDownload, + WebAppInfo, + WebAppProlong, + WebAppSent, + WebAppSession, +) +from tlgr.ops import _bots, _send +from tlgr.ops._common import client +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +#: The platform reported to Telegram. It picks the app's own layout; there is +#: no value meaning "a terminal", and lying about it is what every other +#: third-party client does too. +PLATFORM = "web" + +#: How often a session that returned a `query_id` has to be prolonged. +PROLONG_EVERY = 60 + + +def _theme(path: str | None) -> Any: + """`--theme` as the `DataJSON` Telegram passes into the app. + + The default is a minimal object rather than nothing: an app handed no + theme at all renders with browser defaults, which looks broken. + """ + from telethon.tl import types + + if path: + return _bots.data_json(path, field="theme") + return types.DataJSON(data='{"bg_color":"#ffffff","text_color":"#000000"}') + + +def _session(result: Any, *, bot: str | None, kind: str, write_allowed: bool) -> WebAppSession: + query_id = getattr(result, "query_id", None) + return WebAppSession( + bot=bot, + kind=kind, + url=str(getattr(result, "url", "") or ""), + query_id=str(query_id) if query_id else None, + fullsize=bool(getattr(result, "fullsize", False)), + fullscreen=bool(getattr(result, "fullscreen", False)), + same_origin=bool(getattr(result, "same_origin", False)), + needs_prolong=bool(query_id), + prolong_every=PROLONG_EVERY if query_id else None, + write_allowed=write_allowed, + ) + + +async def _app(ctx: OpContext, bot: PeerRef, short_name: str) -> Any: + """`InputBotAppShortName` for a direct-link app.""" + from telethon.tl import types + + return types.InputBotAppShortName( + bot_id=await _bots.input_user(ctx, bot), short_name=short_name + ) + + +# --------------------------------------------------------------------------- +# webapp get +# --------------------------------------------------------------------------- + + +class GetReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot owning the app.")] + short_name: Annotated[ + str, arg(1, metavar="SHORT_NAME", required=False, help="Direct-link app short name.") + ] = "" + button_request: Annotated[ + str | None, + opt("--button-request", metavar="ID", help="Show the peer request behind this id."), + ] = None + + +async def get(ctx: OpContext, req: GetReq) -> WebAppInfo: + """A mini app's manifest. + + This covers every entry on the app's panel menu except "Reload page", + which is a webview concern with no API behind it. The placeholder is an + SVG-like path blob: its *length* is reported, because nothing that reads + this output can render it and printing the bytes would bury the rest. + """ + from telethon.tl.functions import bots as bots_fn + from telethon.tl.functions import messages as fn + + handle = client(ctx) + info = WebAppInfo(bot=str(req.bot.raw), short_name=req.short_name or None) + + if req.short_name: + result = await handle( + fn.GetBotAppRequest(app=await _app(ctx, req.bot, req.short_name), hash=0) + ) + app = getattr(result, "app", None) + if app is None or type(app).__name__ == "BotAppNotModified": + raise NotFoundError(f"@{req.bot.raw} has no app called {req.short_name!r}") + info.title = getattr(app, "title", None) + info.description = getattr(app, "description", None) + info.photo = int(getattr(getattr(app, "photo", None), "id", 0) or 0) or None + info.document = int(getattr(getattr(app, "document", None), "id", 0) or 0) or None + info.inactive = bool(getattr(result, "inactive", False)) + info.request_write_access = bool(getattr(result, "request_write_access", False)) + info.has_settings = bool(getattr(result, "has_settings", False)) + info.link = f"https://t.me/{str(req.bot.value or req.bot.raw).lstrip('@')}/{req.short_name}" + + peer = await _send.resolve(ctx, req.bot) + from tlgr.ops.bot import _app_settings, _full + + full, _user = await _full(ctx, peer) + bot_info = getattr(full, "bot_info", None) + settings = _app_settings(getattr(bot_info, "app_settings", None)) or {} + info.privacy_policy_url = getattr(bot_info, "privacy_policy_url", None) + info.placeholder_path = settings.get("placeholder_path") + info.bg_color = settings.get("bg_color") + info.bg_dark_color = settings.get("bg_dark_color") + info.header_color = settings.get("header_color") + info.header_dark_color = settings.get("header_dark_color") + + attach = await handle(fn.GetAttachMenuBotRequest(bot=await _bots.input_user(ctx, req.bot))) + entry = getattr(attach, "bot", None) + info.installed_in_attach_menu = bool(getattr(entry, "show_in_attach_menu", False)) + info.installed_in_side_menu = bool(getattr(entry, "show_in_side_menu", False)) + + if req.button_request: + button = await handle( + bots_fn.GetRequestedWebViewButtonRequest( + bot=await _bots.input_user(ctx, req.bot), webapp_req_id=req.button_request + ) + ) + info.button_request = { + "text": getattr(button, "text", None), + "button_id": getattr(button, "button_id", None), + "peer_type": type(getattr(button, "peer_type", None)).__name__, + } + return info + + +SPEC_GET = OperationSpec( + id="webapp.get", + request=GetReq, + response=WebAppInfo, + impl=get, + summary="Show a mini app's manifest", + aliases=("app.info", "app.get"), + columns=("short_name", "title", "installed_in_attach_menu"), + headers=("App", "Title", "Installed"), + example={"bot": "@my_helper_bot", "short_name": "shop", "title": "Shop"}, + example_args="webapp get @my_helper_bot shop", + covers=( + "bots.button-request-peer-from-miniapp", + "bots.direct-link-app-open", + "bots.webapp-placeholder-and-close", + ), + covers_partial=("bots.miniapp-panel-menu",), + coverage_note=( + "Installing and removing the app is `bot attach toggle`; reporting it " + "is `bot report --app`." + ), +) + + +# --------------------------------------------------------------------------- +# webapp open +# --------------------------------------------------------------------------- + + +class OpenReq(Request): + bot: Annotated[ + PeerRef | None, arg(0, metavar="BOT", required=False, kind="user", help="The bot.") + ] = None + app: Annotated[str | None, opt("--app", metavar="NAME", help="Direct-link app short name.")] = ( + None + ) + main: Annotated[bool, opt("--main", help="The bot's Main Mini App.")] = False + attach: Annotated[bool, opt("--attach", help="Attachment-menu app in --chat.")] = False + menu: Annotated[bool, opt("--menu", help="The bot's menu-button app.")] = False + simple: Annotated[bool, opt("--simple", help="Simple web view.")] = False + side_menu: Annotated[bool, opt("--side-menu", help="Side-menu app (implies --simple).")] = False + from_switch_webview: Annotated[ + bool, opt("--from-switch-webview", help="Inline-mode app behind a switch_webview button.") + ] = False + join_query_id: Annotated[ + str | None, opt("--join-query-id", metavar="ID", help="Guard-bot chat-join app.") + ] = None + url: Annotated[str | None, opt("--url", metavar="URL", help="Button URL for the app.")] = None + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Chat the app is opened from."), + ] = None + start_param: Annotated[ + str | None, opt("--start-param", metavar="TEXT", help="startapp payload.") + ] = None + mode: Annotated[ + str | None, choice("compact", "fullscreen", help="Requested presentation mode.") + ] = None + allow_write: Annotated[bool, opt("--allow-write", help="CONSENT: let the bot message me.")] = ( + False + ) + theme: Annotated[ + str | None, opt("--theme", metavar="PATH", kind="path", help="JSON theme params.") + ] = None + open_inactive: Annotated[ + bool, opt("--open-inactive", help="Open an app Telegram has marked inactive.") + ] = False + + +async def open_app(ctx: OpContext, req: OpenReq) -> WebAppSession: + """Open a mini app and print its signed URL. + + Seven entry points reach one answer, and they are genuinely different + requests — a Main Mini App, a direct link, an attachment-menu entry, a + menu button, a simple view, a side-menu view, an inline switch. What comes + back is the same shape, plus one fact that matters operationally: whether + the session has a `query_id` and therefore dies in a minute unless + `webapp watch` keeps it alive. + + tlgr never opens a browser. `--allow-write` is never implied: opening an + app and letting its bot message you afterwards are two decisions. + """ + from telethon.tl.functions import messages as fn + + if req.join_query_id: + _bots.unsupported( + "--join-query-id", + "messages.requestChatJoinWebView is absent from Telethon 1.44 and " + "hand-rolling it would mean guessing at an unpublished constructor id", + ) + if req.bot is None: + raise UsageError("name the bot that owns the app", field="bot") + + handle = client(ctx) + bot = await _bots.input_user(ctx, req.bot) + peer = ( + await _send.resolve(ctx, req.chat) + if req.chat is not None + else await _send.resolve(ctx, req.bot) + ) + compact = req.mode == "compact" or None + fullscreen = req.mode == "fullscreen" or None + theme = _theme(req.theme) + + if req.app: + app = await _app(ctx, req.bot, req.app) + listing = await handle(fn.GetBotAppRequest(app=app, hash=0)) + if bool(getattr(listing, "inactive", False)) and not req.open_inactive: + raise PermissionError_( + "Telegram marks this app inactive; pass --open-inactive to open it anyway" + ) + result = await handle( + fn.RequestAppWebViewRequest( + peer=peer, + app=app, + platform=PLATFORM, + write_allowed=req.allow_write or None, + compact=compact, + fullscreen=fullscreen, + start_param=req.start_param, + theme_params=theme, + ) + ) + kind = "direct-link" + elif req.main: + result = await handle( + fn.RequestMainWebViewRequest( + peer=peer, + bot=bot, + platform=PLATFORM, + compact=compact, + fullscreen=fullscreen, + start_param=req.start_param, + theme_params=theme, + ) + ) + kind = "main" + elif req.simple or req.side_menu or req.from_switch_webview: + result = await handle( + fn.RequestSimpleWebViewRequest( + bot=bot, + platform=PLATFORM, + from_switch_webview=req.from_switch_webview or None, + from_side_menu=req.side_menu or None, + compact=compact, + fullscreen=fullscreen, + url=req.url, + start_param=req.start_param, + theme_params=theme, + ) + ) + kind = "side-menu" if req.side_menu else "simple" + else: + url = req.url + if req.menu and not url: + from tlgr.ops.bot import _full + + full, _user = await _full(ctx, await _send.resolve(ctx, req.bot)) + button = getattr(getattr(full, "bot_info", None), "menu_button", None) + url = getattr(button, "url", None) + if not url: + raise NotFoundError("that bot has no menu-button app") + result = await handle( + fn.RequestWebViewRequest( + peer=peer, + bot=bot, + platform=PLATFORM, + from_bot_menu=req.menu or None, + compact=compact, + fullscreen=fullscreen, + url=url, + start_param=req.start_param, + theme_params=theme, + ) + ) + kind = "menu" if req.menu else "attach" if req.attach else "button" + + if req.allow_write and kind not in ("direct-link",): + # Only requestAppWebView carries write_allowed; everywhere else the + # grant is its own call, and doing it silently would be the implicit + # consent this command refuses to give. + from telethon.tl.functions import bots as bots_fn + + await handle(bots_fn.AllowSendMessageRequest(bot=bot)) + + session = _session(result, bot=str(req.bot.raw), kind=kind, write_allowed=req.allow_write) + if session.query_id: + ctx.warn( + "this URL carries your signed init data — treat it as a credential, " + f"and keep the session alive with `tlgr webapp watch {req.bot.raw} " + f"--query-id {session.query_id}`" + ) + else: + ctx.warn("this URL carries your signed init data — treat it as a credential") + return session + + +SPEC_OPEN = OperationSpec( + id="webapp.open", + request=OpenReq, + response=WebAppSession, + impl=open_app, + summary="Open a mini app and print its signed URL", + description=( + "Printing the URL is the only behaviour: there is no --open, because " + "a CLI cannot host the mini-app JS bridge and a browser launched from " + "here would carry a credential into a process tlgr does not control." + ), + aliases=("app.open",), + mutating=True, + columns=("kind", "url", "needs_prolong"), + headers=("Kind", "URL", "Prolong"), + example={ + "bot": "@my_helper_bot", + "kind": "main", + "url": "https://example.org/app#tgWebAppData=…", + }, + example_args="webapp open @my_helper_bot --main", + covers=( + "attach.open-mini-app", + "bots.attach-menu-deeplinks", + "bots.attach-webapp-open", + "bots.main-webapp-open", + "bots.simple-webapp-open", + "bots.webapp-modes", + ), + covers_partial=( + "bots.direct-link-app-open", + "bots.inline-switch-webview", + "bots.webapp-write-access", + ), + coverage_note=( + "The app's manifest is `webapp get`; the attachment-menu install is " + "`bot attach toggle`. The guard-bot chat-join view needs layer 229 " + "and exits 13." + ), +) + + +# --------------------------------------------------------------------------- +# webapp watch +# --------------------------------------------------------------------------- + + +class WatchReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")] + query_id: Annotated[ + str, opt("--query-id", metavar="ID", help="query_id from `webapp open`.") + ] = "" + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Chat the app was opened from."), + ] = None + interval: Annotated[ + str, opt("--interval", metavar="DURATION", help="Prolong interval, e.g. 55s.") + ] = "55s" + until: Annotated[ + str | None, opt("--until", metavar="DURATION", help="Stop after this long.") + ] = None + + +async def watch(ctx: OpContext, req: WatchReq) -> Any: + """Keep an open mini-app session alive. + + Only a session that came back with a `query_id` needs this, and + `QUERY_ID_INVALID` is how it ends normally — the session died, which is + information, not a failure. The stream therefore closes with + `alive: false` and exit 0 rather than raising. + """ + import asyncio + import time + + from telethon.tl.functions import messages as fn + + from tlgr.core.timefmt import parse_duration + + if not req.query_id: + raise UsageError("--query-id is required", field="query_id") + try: + query_id = int(req.query_id) + except ValueError as exc: + raise UsageError("--query-id must be numeric", field="query_id") from exc + + interval = float(parse_duration(req.interval) or 55) + deadline = time.monotonic() + float(parse_duration(req.until) or 0) if req.until else None + handle = client(ctx) + bot = await _bots.input_user(ctx, req.bot) + peer = ( + await _send.resolve(ctx, req.chat) + if req.chat is not None + else await _send.resolve(ctx, req.bot) + ) + + while True: + try: + await handle(fn.ProlongWebViewRequest(peer=peer, bot=bot, query_id=query_id)) + except Exception as exc: + reason = f"{type(exc).__name__} {exc}".upper().replace("_", "") + if "QUERYIDINVALID" not in reason: + raise + yield WebAppProlong( + query_id=req.query_id, alive=False, reason="the session has expired" + ) + return + yield WebAppProlong(query_id=req.query_id, prolonged_at=fmt_dt(_now()), alive=True) + if deadline is not None and time.monotonic() >= deadline: + return + await asyncio.sleep(interval) + + +def _now() -> Any: + from datetime import datetime, timezone + + return datetime.now(timezone.utc) + + +SPEC_WATCH = OperationSpec( + id="webapp.watch", + request=WatchReq, + response=WebAppProlong, + impl=watch, + summary="Keep an open mini-app session alive", + aliases=("app.session.prolong",), + mutating=True, + stream=True, + timeout_s=900, + columns=("query_id", "prolonged_at", "alive"), + headers=("Query", "At", "Alive"), + example={"query_id": "987654321", "alive": True}, + example_args="webapp watch @my_helper_bot --query-id 987654321", + covers=("bots.prolong-webview",), +) + + +# --------------------------------------------------------------------------- +# webapp send +# --------------------------------------------------------------------------- + + +class SendReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")] + button_text: Annotated[ + str, opt("--button-text", metavar="TEXT", help="Text of the button that opened the app.") + ] = "" + data: Annotated[str, opt("--data", metavar="PAYLOAD", help="Payload, max 4096 bytes.")] = "" + + +async def send(ctx: OpContext, req: SendReq) -> WebAppSent: + """Send data from a keyboard-button mini app back to its bot. + + Valid exactly once per web-app session: a second `web_app_data_send` from + the same session is ignored by the server, so a caller that retries is + not doing anything. + """ + from telethon.tl.functions import messages as fn + + if not req.button_text or not req.data: + raise UsageError("--button-text and --data are both required", field="data") + if len(req.data.encode()) > 4096: + raise UsageError("--data is capped at 4096 bytes", field="data") + + await client(ctx)( + fn.SendWebViewDataRequest( + bot=await _bots.input_user(ctx, req.bot), + button_text=req.button_text, + data=req.data, + random_id=_random_id(), + ) + ) + peer = await _send.resolve(ctx, req.bot) + return WebAppSent(bot_id=_send.peer_id_of(peer), sent=True) + + +def _random_id() -> int: + from tlgr.ops._common import random_id + + return random_id() + + +SPEC_SEND = OperationSpec( + id="webapp.send", + request=SendReq, + response=WebAppSent, + impl=send, + summary="Send data from a keyboard-button mini app back to its bot", + aliases=("app.send-data", "webapp.send-data"), + mutating=True, + rate_class="send", + columns=("bot_id", "sent"), + headers=("Bot", "Sent"), + example={"bot_id": 5000001, "sent": True}, + example_args='webapp send @my_helper_bot --button-text Order --data "{}"', + covers=("bots.send-webview-data",), +) + + +# --------------------------------------------------------------------------- +# webapp invoke +# --------------------------------------------------------------------------- + + +class InvokeReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")] + method: Annotated[str, arg(1, metavar="METHOD", help="Custom method name.")] + params: Annotated[ + str, opt("--params", metavar="JSON", kind="json", help="JSON parameters.") + ] = "{}" + + +async def invoke(ctx: OpContext, req: InvokeReq) -> BotApiResult: + """Call a mini app's custom method. The result is opaque and passed through.""" + from telethon.tl.functions import bots as fn + + result = await client(ctx)( + fn.InvokeWebViewCustomMethodRequest( + bot=await _bots.input_user(ctx, req.bot), + custom_method=req.method, + params=_bots.data_json(req.params, field="params"), + ) + ) + from tlgr.ops.bot import _data_json + + return BotApiResult(method=req.method, result=_data_json(result)) + + +SPEC_INVOKE = OperationSpec( + id="webapp.invoke", + request=InvokeReq, + response=BotApiResult, + impl=invoke, + summary="Call a mini app's custom method", + aliases=("app.invoke",), + mutating=True, + columns=("method",), + headers=("Method",), + example={"method": "getOrders", "result": {"orders": []}}, + example_args='webapp invoke @my_helper_bot getOrders --params "{}"', + covers=("bots.webapp-custom-method",), +) + + +# --------------------------------------------------------------------------- +# webapp download +# --------------------------------------------------------------------------- + + +class DownloadReq(Request): + bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")] + file_name: Annotated[ + str, opt("--file-name", metavar="NAME", help="File name the app proposed.") + ] = "" + url: Annotated[str, opt("--url", metavar="URL", help="URL the app proposed.")] = "" + out: Annotated[ + str | None, opt("--out", metavar="PATH", kind="path", help="Where to write it.") + ] = None + fetch: Annotated[ + bool, opt("--fetch", help="Actually download it; checking alone never does.") + ] = False + + +async def download(ctx: OpContext, req: DownloadReq) -> WebAppDownload: + """Check — and only on request, perform — a download a mini app asked for. + + Check-only by default. `bots.checkDownloadFileParams` is Telegram saying + whether the app is allowed to offer this file at all, and a client that + fetched first and asked afterwards would have already run the risk. The + fetch itself is plain HTTPS, not MTProto, which is the other reason it is + opt-in: nothing about it goes through Telegram. + """ + from telethon.tl.functions import bots as fn + + if not req.file_name or not req.url: + raise UsageError("--file-name and --url are both required", field="url") + + allowed = bool( + await client(ctx)( + fn.CheckDownloadFileParamsRequest( + bot=await _bots.input_user(ctx, req.bot), + file_name=req.file_name, + url=req.url, + ) + ) + ) + result = WebAppDownload(allowed=allowed, file_name=req.file_name, url=req.url) + if not req.fetch: + return result + if not allowed: + raise PermissionError_( + "Telegram does not allow this mini app to offer that file; nothing was downloaded" + ) + if not req.url.startswith("https://"): + raise PermissionError_("only https:// downloads are performed") + target = Path(os.path.expanduser(req.out or req.file_name)) + ctx.warn(f"fetching {req.file_name} from {req.url} over plain HTTPS, outside Telegram") + result.path = str(target) + result.downloaded = await _fetch(req.url, target) + return result + + +#: A mini app names its own file size nowhere, so the fetch is capped here. +MAX_DOWNLOAD = 64 * 1024 * 1024 + + +async def _fetch(url: str, target: Path) -> bool: + """Fetch *url* into *target*, capped. Plain HTTPS: no Telegram involved.""" + import aiohttp + + target.parent.mkdir(parents=True, exist_ok=True) + written = 0 + async with aiohttp.ClientSession() as session, session.get(url) as response: + response.raise_for_status() + with target.open("wb") as handle: + async for chunk in response.content.iter_chunked(64 * 1024): + written += len(chunk) + if written > MAX_DOWNLOAD: + handle.close() + target.unlink(missing_ok=True) + raise PermissionError_( + f"the file exceeds tlgr's {MAX_DOWNLOAD // (1024 * 1024)} MB cap " + "for a mini-app download" + ) + handle.write(chunk) + return True + + +SPEC_DOWNLOAD = OperationSpec( + id="webapp.download", + request=DownloadReq, + response=WebAppDownload, + impl=download, + summary="Check a file download a mini app asked for", + aliases=("app.check-download",), + columns=("allowed", "file_name", "downloaded"), + headers=("Allowed", "File", "Downloaded"), + example={"allowed": True, "file_name": "invoice.pdf", "url": "https://example.org/i.pdf"}, + example_args="webapp download @my_helper_bot --file-name i.pdf --url https://example.org/i.pdf", + covers=("attach.file-download-check", "bots.webapp-file-download-check"), +) From 4e9a1fe4efaf9ba950af2a6f7b3612d84788577d Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 01:07:54 +0330 Subject: [PATCH 05/10] fake client and tests: a bot world the commands actually move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake grows bot state rather than canned replies: profile cards, per-scope command lists, an attachment menu, a preview gallery, affiliate links, payment shapes. 'bot access set --add' really appends to an allow-list a later 'bot access get' reads back, and 'bot preview edit --order' really reorders a gallery. 222 tests. Three areas get more than a happy path: every consent-bearing button has a test that presses without its flag and asserts nothing was sent; every layer-229 operation has one asserting exit 13 and NOT_SUPPORTED, not 1; and the payment policy is asserted against the registry — a future PR that adds sendPaymentForm behind any flag fails there. Two model defaults changed while writing them: an index whose default is 0 is dropped by omit_defaults for exactly the first button, and a decision-carrying boolean that reads as absent when false is a boolean a caller cannot act on. --- tests/fake_telethon.py | 689 ++++++++++- tests/test_ops_bot.py | 2550 ++++++++++++++++++++++++++++++++++++++++ tlgr/models/bot.py | 37 +- tlgr/models/inline.py | 7 +- tlgr/models/message.py | 6 +- tlgr/models/payment.py | 21 +- tlgr/models/webapp.py | 4 +- tlgr/ops/bot.py | 57 +- tlgr/ops/inline.py | 5 +- tlgr/ops/payment.py | 2 +- tlgr/ops/webapp.py | 22 +- 11 files changed, 3326 insertions(+), 74 deletions(-) create mode 100644 tests/test_ops_bot.py diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 2da8e82..512bd43 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -764,6 +764,51 @@ class World: default_banned: dict[int, Any] = field(default_factory=dict) #: usernames the server reports as already taken taken_usernames: set[str] = field(default_factory=set) + # -- the bot world ----------------------------------------------------- + # + # Bot *state*, not canned replies: the profile card, the per-scope command + # lists, the attachment menu, the preview gallery, the affiliate links and + # the payment shapes all live here, so a test asserts that a command moved + # something a later command reads back. + + #: user id → the bot's profile state (about, description, commands, …). + bots: dict[int, Any] = field(default_factory=dict) + admined_bots: list[int] = field(default_factory=list) + similar_bots: list[int] = field(default_factory=list) + similar_bots_count: int = 0 + popular_apps: list[int] = field(default_factory=list) + popular_apps_next: str = "" + top_peer_bots: list[int] = field(default_factory=list) + next_bot_id: int = 5_000_000 + bot_tokens: dict[int, str] = field(default_factory=dict) + #: (scope class name, lang) → [(command, description)]. + bot_commands: dict[tuple[str, str], list[Any]] = field(default_factory=dict) + menu_buttons: dict[int, Any] = field(default_factory=dict) + default_rights: dict[str, Any] = field(default_factory=dict) + attach_menu: dict[int, Any] = field(default_factory=dict) + connected_refs: list[Any] = field(default_factory=list) + suggested_refs: list[Any] = field(default_factory=list) + suggested_refs_next: str = "" + callback_answer: Any = None + url_auth: Any = None + match_code: str = "cat" + custom_response: str = '{"ok":true}' + business_dc: int = 4 + emoji_game: Any = None + high_scores: list[Any] = field(default_factory=list) + report_result: Any = None + webapp_url: str = "https://example.org/app#tgWebAppData=signed" + webapp_query_id: int = 987654321 + prolongs: int = 0 + #: short name → `messages.BotApp`. + bot_apps: dict[str, Any] = field(default_factory=dict) + download_allowed: bool = True + prepared_peer_types: list[Any] = field(default_factory=list) + payment_form: Any = None + saved_credentials: bool = True + saved_order_info: Any = None + subscriptions: list[Any] = field(default_factory=list) + subscriptions_next: str = "" # -- behaviour knobs --------------------------------------------------- @@ -1691,27 +1736,55 @@ def _raw_SendSignalingDataRequest(self, request: Any) -> bool: def _raw_GetFullUserRequest(self, request: Any) -> Any: user = self._user_of(request.id) + if user is None and self.world.bots: + user = self.world.users.get(self._user_id_of(request.id)) or self.world.me if user is None: from telethon.errors import RPCError raise RPCError(request, "USER_ID_INVALID", 400) uid = int(user.id) + state = self.world.bots.get(uid, {}) + for name, value in state.get("user_flags", {}).items(): + setattr(user, name, value) + info = None + if state and state.get("bot", True): + info = types.BotInfo( + user_id=uid, + description=state.get("description"), + commands=[ + types.BotCommand(command=c, description=d) for c, d in state.get("commands", []) + ], + menu_button=state.get("menu_button"), + privacy_policy_url=state.get("privacy_policy_url"), + has_preview_medias=bool(state.get("previews")), + app_settings=state.get("app_settings"), + verifier_settings=state.get("verifier_settings"), + ) overrides = dict(self.world.user_full.get(uid, {})) note = self.world.contact_notes.get(uid) available = self.world.calls_available + about = overrides.pop("about", None) + photo = overrides.pop("profile_photo", None) full = types.UserFull( id=uid, settings=self.world.peer_settings.get(uid) or types.PeerSettings(), notify_settings=types.PeerNotifySettings(), common_chats_count=overrides.pop("common_chats_count", 0), - about=overrides.pop("about", None), - blocked=uid in self.world.blocked or None, + about=state.get("about", about) if state else about, + bot_info=info, + blocked=uid in self.world.blocked or bool(state.get("blocked")) or None, blocked_my_stories_from=uid in self.world.blocked_stories or None, birthday=self.world.birthdays.get(uid), note=types.TextWithEntities(text=note, entities=[]) if note else None, phone_calls_available=available, video_calls_available=available, phone_calls_private=not available, + bot_group_admin_rights=state.get("group_rights"), + bot_broadcast_admin_rights=state.get("channel_rights"), + starref_program=state.get("starref"), + bot_verification=state.get("verification"), + bot_can_manage_emoji_status=bool(state.get("emoji_status_allowed")), + profile_photo=state.get("profile_photo", photo), **overrides, ) return types.users.UserFull( @@ -3549,6 +3622,8 @@ def _raw_GetTopPeersRequest(self, request: Any) -> Any: if not getattr(request, flag, None): continue rows = self.world.top_peers.get(flag.replace("_", "-"), []) + if flag == "bots_pm" and not rows: + rows = [(uid, 1.0) for uid in self.world.top_peer_bots] categories.append( types.TopPeerCategoryPeers( category=getattr(types, constructor)(), @@ -4316,7 +4391,8 @@ def _raw_UpdatePaidMessagesPriceRequest(self, request: Any) -> types.Updates: return self._updates() def _raw_CheckUsernameRequest(self, request: Any) -> bool: - return request.username.lower() not in self.world.taken_usernames + taken = self.world.taken_usernames + return request.username not in taken and request.username.lower() not in taken def _raw_UpdateUsernameRequest(self, request: Any) -> types.Updates: chat_id = self._chat_id(request.channel) @@ -4335,6 +4411,8 @@ def _raw_GetChannelRecommendationsRequest(self, request: Any) -> Any: return types.messages.Chats(chats=list(self.world.chats.values())) def _raw_ReportSponsoredMessageRequest(self, request: Any) -> Any: + if self.world.report_result is not None: + return self.world.report_result if not request.option: return types.channels.SponsoredMessageReportResultChooseOption( title="Why?", @@ -4462,28 +4540,6 @@ def _raw_GetStarsTransactionsRequest(self, request: Any) -> Any: next_offset=None, ) - def _raw_GetConnectedStarRefBotsRequest(self, request: Any) -> Any: - return types.payments.ConnectedStarRefBots( - count=1, - connected_bots=[ - types.ConnectedBotStarRef( - url="https://t.me/refbot?start=x", - date=datetime.now(timezone.utc), - bot_id=8800, - commission_permille=200, - participants=3, - revenue=500, - ) - ], - users=[], - ) - - def _raw_ConnectStarRefBotRequest(self, request: Any) -> Any: - return self._raw_GetConnectedStarRefBotsRequest(request) - - def _raw_EditConnectedStarRefBotRequest(self, request: Any) -> Any: - return self._raw_GetConnectedStarRefBotsRequest(request) - # -- entities ---------------------------------------------------------- async def get_entity(self, ref: Any) -> Any: @@ -5800,6 +5856,589 @@ def _raw_SetContentSettingsRequest(self, request: Any) -> Any: self.world.sensitive_enabled = bool(request.sensitive_enabled) return True + # -- the bot world ----------------------------------------------------- + # + # Stage E adds bots. The world holds bot *state* — the profile card, the + # command lists per scope, the attachment menu, the preview gallery, the + # affiliate program, the payment forms — so a test asserts that a command + # moved something rather than that a canned reply came back: `bot access + # set --add` really appends to an allow-list, and `bot preview edit + # --order` really reorders a gallery the next `bot preview list` reads. + + def _bot_state(self, user_id: int) -> dict[str, Any]: + return self.world.bots.setdefault(int(user_id), {}) + + def _user_id_of(self, ref: Any) -> int: + for attribute in ("user_id", "id", "chat_id", "channel_id"): + value = getattr(ref, attribute, None) + if isinstance(value, int): + return value + return int(self.world.me.id) + + # -- listings ---------------------------------------------------------- + + def _raw_GetAdminedBotsRequest(self, request: Any) -> Any: + return [self.world.users[b] for b in self.world.admined_bots if b in self.world.users] + + def _raw_GetBotRecommendationsRequest(self, request: Any) -> Any: + users = [self.world.users[b] for b in self.world.similar_bots if b in self.world.users] + if self.world.similar_bots_count: + return types.users.UsersSlice(count=self.world.similar_bots_count, users=users) + return types.users.Users(users=users) + + def _raw_GetPopularAppBotsRequest(self, request: Any) -> Any: + return types.bots.PopularAppBots( + users=[self.world.users[b] for b in self.world.popular_apps if b in self.world.users], + next_offset=self.world.popular_apps_next or None, + ) + + # -- profile ----------------------------------------------------------- + + def _raw_GetBotInfoRequest(self, request: Any) -> Any: + state = self._bot_state(self._user_id_of(request.bot)) + localized = state.get("localized", {}).get(request.lang_code, {}) + return types.bots.BotInfo( + name=localized.get("name", ""), + about=localized.get("about", state.get("about") or ""), + description=localized.get("description", state.get("description") or ""), + ) + + def _raw_SetBotInfoRequest(self, request: Any) -> Any: + state = self._bot_state(self._user_id_of(request.bot)) + localized = state.setdefault("localized", {}).setdefault(request.lang_code, {}) + for name in ("name", "about", "description"): + value = getattr(request, name, None) + if value is not None: + localized[name] = value + state[name] = value + return True + + def _raw_CreateBotRequest(self, request: Any) -> Any: + self.world.next_bot_id += 1 + user = make_user(self.world.next_bot_id, username=request.username, first=request.name) + user.bot = True + user.bot_can_edit = True + self.world.add_user(user) + self.world.bots[user.id] = {"bot": True} + self.world.admined_bots.append(user.id) + self.world.taken_usernames.add(request.username) + return types.Updates(updates=[], users=[user], chats=[], date=None, seq=0) + + def _raw_ExportBotTokenRequest(self, request: Any) -> Any: + bot_id = self._user_id_of(request.bot) + if request.revoke: + self.world.bot_tokens[bot_id] = f"{bot_id}:REVOKED-AND-NEW" + return types.bots.ExportedBotToken( + token=self.world.bot_tokens.setdefault(bot_id, f"{bot_id}:TESTTOKEN") + ) + + def _raw_ToggleUsernameRequest(self, request: Any) -> Any: + # `bots.toggleUsername` and `channels.toggleUsername` share a class + # name and so share this handler. Only the bot one carries `bot`; + # the channel one wants the empty-Updates default it always had. + if getattr(request, "bot", None) is None: + return True + state = self._bot_state(self._user_id_of(request.bot)) + names = state.setdefault("usernames", []) + if request.active and request.username not in names: + names.append(request.username) + if not request.active and request.username in names: + names.remove(request.username) + self._sync_usernames(self._user_id_of(request.bot), names) + return True + + def _raw_ReorderUsernamesRequest(self, request: Any) -> Any: + if getattr(request, "bot", None) is None: # the `channels.` twin + return True + state = self._bot_state(self._user_id_of(request.bot)) + state["usernames"] = list(request.order) + self._sync_usernames(self._user_id_of(request.bot), state["usernames"]) + return True + + def _sync_usernames(self, bot_id: int, names: list[str]) -> None: + user = self.world.users.get(bot_id) + if user is not None: + user.usernames = [types.Username(username=n, active=True) for n in names] + + # -- commands, menu, rights, permissions ------------------------------- + + def _scope_key(self, scope: Any, lang: str) -> tuple[str, str]: + return (type(scope).__name__, lang) + + def _raw_GetBotCommandsRequest(self, request: Any) -> Any: + return [ + types.BotCommand(command=c, description=d) + for c, d in self.world.bot_commands.get( + self._scope_key(request.scope, request.lang_code), [] + ) + ] + + def _raw_SetBotCommandsRequest(self, request: Any) -> Any: + self.world.bot_commands[self._scope_key(request.scope, request.lang_code)] = [ + (c.command, c.description) for c in request.commands + ] + return True + + def _raw_ResetBotCommandsRequest(self, request: Any) -> Any: + self.world.bot_commands.pop(self._scope_key(request.scope, request.lang_code), None) + return True + + def _raw_GetBotMenuButtonRequest(self, request: Any) -> Any: + return self.world.menu_buttons.get( + self._user_id_of(request.user_id), types.BotMenuButtonDefault() + ) + + def _raw_SetBotMenuButtonRequest(self, request: Any) -> Any: + self.world.menu_buttons[self._user_id_of(request.user_id)] = request.button + return True + + def _raw_SetBotGroupDefaultAdminRightsRequest(self, request: Any) -> Any: + self.world.default_rights["group"] = request.admin_rights + return True + + def _raw_SetBotBroadcastDefaultAdminRightsRequest(self, request: Any) -> Any: + self.world.default_rights["channel"] = request.admin_rights + return True + + def _raw_CanSendMessageRequest(self, request: Any) -> Any: + return bool(self._bot_state(self._user_id_of(request.bot)).get("can_send")) + + def _raw_AllowSendMessageRequest(self, request: Any) -> Any: + self._bot_state(self._user_id_of(request.bot))["can_send"] = True + return types.Updates(updates=[], users=[], chats=[], date=None, seq=0) + + def _raw_ToggleUserEmojiStatusPermissionRequest(self, request: Any) -> Any: + self._bot_state(self._user_id_of(request.bot))["emoji_status_allowed"] = bool( + request.enabled + ) + return True + + def _raw_GetAccessSettingsRequest(self, request: Any) -> Any: + state = self._bot_state(self._user_id_of(request.bot)) + return types.bots.AccessSettings( + restricted=state.get("restricted"), + add_users=[ + self.world.users[u] for u in state.get("allowed", []) if u in self.world.users + ], + ) + + def _raw_EditAccessSettingsRequest(self, request: Any) -> Any: + state = self._bot_state(self._user_id_of(request.bot)) + state["restricted"] = bool(request.restricted) + state["allowed"] = [self._user_id_of(u) for u in (request.add_users or [])] + return True + + # -- previews ---------------------------------------------------------- + + def _preview_list(self, bot_id: int) -> list[Any]: + return self.world.bots.setdefault(bot_id, {}).setdefault("previews", []) + + def _raw_GetPreviewMediasRequest(self, request: Any) -> Any: + return list(self._preview_list(self._user_id_of(request.bot))) + + def _raw_GetPreviewInfoRequest(self, request: Any) -> Any: + return types.bots.PreviewInfo( + media=list(self._preview_list(self._user_id_of(request.bot))), lang_codes=["en"] + ) + + def _preview_media(self, media: Any) -> Any: + """An already-uploaded `InputMedia*` as the media a gallery stores.""" + name = type(media).__name__ + if name == "InputMediaPhoto": + return types.MessageMediaPhoto(photo=make_photo(int(media.id.id))) + if name == "InputMediaDocument": + document = self.world.documents.get(int(media.id.id)) or make_document(int(media.id.id)) + return types.MessageMediaDocument(document=document) + return self.realise(media) + + def _raw_AddPreviewMediaRequest(self, request: Any) -> Any: + media = types.BotPreviewMedia( + date=datetime.now(timezone.utc), media=self._preview_media(request.media) + ) + self._preview_list(self._user_id_of(request.bot)).append(media) + return media + + def _raw_EditPreviewMediaRequest(self, request: Any) -> Any: + gallery = self._preview_list(self._user_id_of(request.bot)) + replacement = types.BotPreviewMedia( + date=datetime.now(timezone.utc), media=self._preview_media(request.new_media) + ) + for index, entry in enumerate(gallery): + if self._media_id(entry) == self._media_id(request.media): + gallery[index] = replacement + break + return replacement + + def _raw_ReorderPreviewMediasRequest(self, request: Any) -> Any: + gallery = self._preview_list(self._user_id_of(request.bot)) + by_id = {self._media_id(entry): entry for entry in gallery} + gallery[:] = [by_id[self._media_id(m)] for m in request.order if self._media_id(m) in by_id] + return True + + def _raw_DeletePreviewMediaRequest(self, request: Any) -> Any: + gallery = self._preview_list(self._user_id_of(request.bot)) + doomed = {self._media_id(m) for m in request.media} + gallery[:] = [entry for entry in gallery if self._media_id(entry) not in doomed] + return True + + def _media_id(self, value: Any) -> int: + inner = getattr(value, "media", value) + for attribute in ("document", "photo", "id"): + found = getattr(inner, attribute, None) + if found is not None: + return int(getattr(found, "id", found) or 0) + return 0 + + # -- affiliate programs ------------------------------------------------ + + def _raw_UpdateStarRefProgramRequest(self, request: Any) -> Any: + bot_id = self._user_id_of(request.bot) + program = types.StarRefProgram( + bot_id=bot_id, + commission_permille=request.commission_permille, + duration_months=request.duration_months, + end_date=datetime.now(timezone.utc) if not request.commission_permille else None, + ) + self._bot_state(bot_id)["starref"] = program + return program + + def _raw_ConnectStarRefBotRequest(self, request: Any) -> Any: + bot_id = self._user_id_of(request.bot) + entry = types.ConnectedBotStarRef( + url=f"https://t.me/bot{bot_id}?start=_tgr_ref", + date=datetime.now(timezone.utc), + bot_id=bot_id, + commission_permille=200, + participants=0, + revenue=0, + ) + self.world.connected_refs.append(entry) + return types.payments.ConnectedStarRefBots( + count=len(self.world.connected_refs), connected_bots=[entry], users=[] + ) + + def _raw_GetConnectedStarRefBotsRequest(self, request: Any) -> Any: + entries = list(self.world.connected_refs) or [ + types.ConnectedBotStarRef( + url="https://t.me/refbot?start=x", + date=datetime.now(timezone.utc), + bot_id=8800, + commission_permille=200, + participants=3, + revenue=500, + ) + ] + limit = getattr(request, "limit", None) or len(entries) + return types.payments.ConnectedStarRefBots( + count=len(entries), connected_bots=entries[:limit], users=[] + ) + + def _raw_GetConnectedStarRefBotRequest(self, request: Any) -> Any: + bot_id = self._user_id_of(request.bot) + for entry in self.world.connected_refs: + if entry.bot_id == bot_id: + return types.payments.ConnectedStarRefBots( + count=1, connected_bots=[entry], users=[] + ) + return types.payments.ConnectedStarRefBots(count=0, connected_bots=[], users=[]) + + def _raw_GetSuggestedStarRefBotsRequest(self, request: Any) -> Any: + return types.payments.SuggestedStarRefBots( + count=len(self.world.suggested_refs), + suggested_bots=list(self.world.suggested_refs), + users=[], + next_offset=self.world.suggested_refs_next or None, + ) + + def _raw_EditConnectedStarRefBotRequest(self, request: Any) -> Any: + for entry in self.world.connected_refs: + if entry.url == request.link: + entry.revoked = bool(request.revoked) + return types.payments.ConnectedStarRefBots( + count=1, connected_bots=[entry], users=[] + ) + return types.payments.ConnectedStarRefBots(count=0, connected_bots=[], users=[]) + + # -- attachment menu --------------------------------------------------- + + def _attach_entry(self, bot_id: int) -> Any: + state = self.world.attach_menu.setdefault(bot_id, {}) + return types.AttachMenuBot( + bot_id=bot_id, + short_name=state.get("short_name", f"app{bot_id}"), + icons=[], + inactive=state.get("inactive"), + request_write_access=state.get("request_write_access"), + show_in_attach_menu=state.get("installed"), + show_in_side_menu=state.get("side_menu"), + side_menu_disclaimer_needed=state.get("disclaimer"), + peer_types=[types.AttachMenuPeerTypePM()], + ) + + def _raw_GetAttachMenuBotsRequest(self, request: Any) -> Any: + bots = [b for b, s in self.world.attach_menu.items() if s.get("installed")] + return types.AttachMenuBots( + hash=1, + bots=[self._attach_entry(b) for b in bots], + users=[self.world.users[b] for b in bots if b in self.world.users], + ) + + def _raw_GetAttachMenuBotRequest(self, request: Any) -> Any: + bot_id = self._user_id_of(request.bot) + user = self.world.users.get(bot_id) + return types.AttachMenuBotsBot(bot=self._attach_entry(bot_id), users=[user] if user else []) + + def _raw_ToggleBotInAttachMenuRequest(self, request: Any) -> Any: + state = self.world.attach_menu.setdefault(self._user_id_of(request.bot), {}) + state["installed"] = bool(request.enabled) + state["write_allowed"] = bool(request.write_allowed) + return True + + # -- buttons, url auth, start ------------------------------------------ + + def _raw_StartBotRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + message = self.world.add_message( + chat_id, f"/start {request.start_param}".strip(), out=True, sender_id=self.world.me.id + ) + return self._updates(message) + + def _raw_GetBotCallbackAnswerRequest(self, request: Any) -> Any: + return self.world.callback_answer or types.messages.BotCallbackAnswer( + cache_time=0, message="OK" + ) + + def _raw_RequestUrlAuthRequest(self, request: Any) -> Any: + return self.world.url_auth or types.UrlAuthResultRequest( + bot=self.world.me, domain="example.org", request_write_access=True + ) + + def _raw_AcceptUrlAuthRequest(self, request: Any) -> Any: + return types.UrlAuthResultAccepted(url="https://example.org/login?token=abc") + + def _raw_DeclineUrlAuthRequest(self, request: Any) -> Any: + return True + + def _raw_CheckUrlAuthMatchCodeRequest(self, request: Any) -> Any: + return request.match_code == self.world.match_code + + def _raw_SendBotRequestedPeerRequest(self, request: Any) -> types.Updates: + return types.Updates(updates=[], users=[], chats=[], date=None, seq=0) + + def _raw_GetRequestedWebViewButtonRequest(self, request: Any) -> Any: + return types.KeyboardButtonRequestPeer( + text="Pick a chat", + button_id=7, + peer_type=types.RequestPeerTypeUser(), + max_quantity=1, + ) + + # -- bot-side plumbing ------------------------------------------------- + + def _raw_SendCustomRequestRequest(self, request: Any) -> Any: + return types.DataJSON(data=self.world.custom_response) + + def _raw_InvokeWebViewCustomMethodRequest(self, request: Any) -> Any: + return types.DataJSON(data=self.world.custom_response) + + def _raw_AnswerWebhookJSONQueryRequest(self, request: Any) -> Any: + return True + + def _raw_GetBotBusinessConnectionRequest(self, request: Any) -> Any: + return types.Updates( + updates=[ + types.UpdateBotBusinessConnect( + connection=types.BotBusinessConnection( + connection_id=request.connection_id, + user_id=4242, + dc_id=self.world.business_dc, + date=datetime.now(timezone.utc), + rights=types.BusinessBotRights(reply=True), + ), + qts=1, + ) + ], + users=[], + chats=[], + date=None, + seq=0, + ) + + def _raw_InvokeWithBusinessConnectionRequest(self, request: Any) -> Any: + return self._sync_call(request.query) + + def _sync_call(self, request: Any) -> Any: + """Run a wrapped request through the same handler table.""" + self.world.calls.append((type(request).__name__, request)) + handler = self.world.raw.get(type(request).__name__) + if callable(handler): + return handler(request) + default = getattr(self, f"_raw_{type(request).__name__}", None) + if default is not None: + return default(request) + return types.Updates(updates=[], users=[], chats=[], date=None, seq=0) + + # -- games, ads, reports ----------------------------------------------- + + def _raw_GetEmojiGameInfoRequest(self, request: Any) -> Any: + return self.world.emoji_game or types.messages.EmojiGameUnavailable() + + def _raw_GetGameHighScoresRequest(self, request: Any) -> Any: + return types.messages.HighScores(scores=list(self.world.high_scores), users=[]) + + def _raw_GetInlineGameHighScoresRequest(self, request: Any) -> Any: + return types.messages.HighScores(scores=list(self.world.high_scores), users=[]) + + def _raw_SetGameScoreRequest(self, request: Any) -> Any: + self.world.high_scores.append( + types.HighScore(pos=len(self.world.high_scores) + 1, user_id=4242, score=request.score) + ) + return types.Updates(updates=[], users=[], chats=[], date=None, seq=0) + + def _raw_SetInlineGameScoreRequest(self, request: Any) -> Any: + return True + + def _raw_ViewSponsoredMessageRequest(self, request: Any) -> Any: + return True + + def _raw_ClickSponsoredMessageRequest(self, request: Any) -> Any: + return True + + # -- mini apps --------------------------------------------------------- + + def _web_view(self, *, with_query: bool = True) -> Any: + return types.WebViewResultUrl( + url=self.world.webapp_url, + fullsize=True, + query_id=self.world.webapp_query_id if with_query else None, + ) + + def _raw_RequestWebViewRequest(self, request: Any) -> Any: + return self._web_view() + + def _raw_RequestAppWebViewRequest(self, request: Any) -> Any: + return self._web_view(with_query=False) + + def _raw_RequestMainWebViewRequest(self, request: Any) -> Any: + return self._web_view() + + def _raw_RequestSimpleWebViewRequest(self, request: Any) -> Any: + return self._web_view(with_query=False) + + def _raw_ProlongWebViewRequest(self, request: Any) -> Any: + self.world.prolongs += 1 + return True + + def _raw_SendWebViewDataRequest(self, request: Any) -> Any: + return types.Updates(updates=[], users=[], chats=[], date=None, seq=0) + + def _raw_GetBotAppRequest(self, request: Any) -> Any: + entry = self.world.bot_apps.get(str(getattr(request.app, "short_name", ""))) + if entry is None: + return types.messages.BotApp(app=types.BotAppNotModified()) + return entry + + def _raw_CheckDownloadFileParamsRequest(self, request: Any) -> Any: + return self.world.download_allowed + + # -- inline mode ------------------------------------------------------- + + def _raw_GetPreparedInlineMessageRequest(self, request: Any) -> Any: + return types.messages.PreparedInlineMessage( + query_id=987654321, + result=self.world.inline_results[0] + if self.world.inline_results + else types.BotInlineResult( + id="r1", + type="article", + send_message=types.BotInlineMessageText(message="hi"), + ), + peer_types=list(self.world.prepared_peer_types), + cache_time=300, + users=[], + ) + + def _raw_SavePreparedInlineMessageRequest(self, request: Any) -> Any: + return types.messages.BotPreparedInlineMessage( + id="prep1", expire_date=datetime.now(timezone.utc) + ) + + def _raw_EditInlineBotMessageRequest(self, request: Any) -> Any: + return True + + # -- payments ---------------------------------------------------------- + + def _raw_GetPaymentFormRequest(self, request: Any) -> Any: + return self.world.payment_form or types.payments.PaymentForm( + form_id=555, + bot_id=5000001, + title="T-shirt", + description="A shirt", + invoice=types.Invoice( + currency="USD", prices=[types.LabeledPrice(label="Shirt", amount=1999)] + ), + provider_id=1, + url="https://provider.example/pay", + users=[], + can_save_credentials=True, + ) + + def _raw_GetPaymentReceiptRequest(self, request: Any) -> Any: + return types.payments.PaymentReceipt( + date=datetime.now(timezone.utc), + bot_id=5000001, + provider_id=1, + title="T-shirt", + description="A shirt", + invoice=types.Invoice( + currency="USD", prices=[types.LabeledPrice(label="Shirt", amount=1999)] + ), + currency="USD", + total_amount=1999, + credentials_title="Visa •1234", + users=[], + ) + + def _raw_GetSavedInfoRequest(self, request: Any) -> Any: + return types.payments.SavedInfo( + has_saved_credentials=self.world.saved_credentials, + saved_info=self.world.saved_order_info, + ) + + def _raw_ClearSavedInfoRequest(self, request: Any) -> Any: + if request.credentials: + self.world.saved_credentials = False + if request.info: + self.world.saved_order_info = None + return True + + def _raw_ExportInvoiceRequest(self, request: Any) -> Any: + return types.payments.ExportedInvoice(url="https://t.me/$abc123") + + def _raw_GetBankCardDataRequest(self, request: Any) -> Any: + return types.payments.BankCardData( + title="Example Bank", + open_urls=[types.BankCardOpenUrl(url="https://bank.example", name="Online banking")], + ) + + def _raw_GetStarsSubscriptionsRequest(self, request: Any) -> Any: + return types.payments.StarsStatus( + balance=types.StarsAmount(amount=self.world.star_balance, nanos=0), + chats=[], + users=[], + subscriptions=list(self.world.subscriptions), + subscriptions_next_offset=self.world.subscriptions_next or None, + ) + + def _raw_ChangeStarsSubscriptionRequest(self, request: Any) -> Any: + for entry in self.world.subscriptions: + if entry.id == request.subscription_id: + entry.canceled = bool(request.canceled) + return True + + def _raw_BotCancelStarsSubscriptionRequest(self, request: Any) -> Any: + return True + class _AsyncFailure: """An async iterator that raises on the first step. diff --git a/tests/test_ops_bot.py b/tests/test_ops_bot.py new file mode 100644 index 0000000..9da7dd4 --- /dev/null +++ b/tests/test_ops_bot.py @@ -0,0 +1,2550 @@ +"""The bot, inline, mini-app and payment 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* — an allow-list that grew, a gallery that +reordered, a subscription that is now cancelled — and, where the exact TL +request is the whole point (the `@botusername` suffix in a group, the DC an +inline edit is routed to, the consent flag a button will not be pressed +without), about the request the fake recorded. + +Three things get more attention than the rest, because they are where this +group can do damage: + +* **consent.** Four button kinds disclose something the user owns, and each + has a test that presses without the flag and asserts nothing was sent. +* **payment.** There is a test that the surface contains no verb that spends + money, written against the registry rather than against a list of commands. +* **layer gaps.** Every operation registered-and-refused exits 13, not 1. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from tlgr.core.errors import ( + EXIT_AUTH, + EXIT_INDETERMINATE, + EXIT_NOT_FOUND, + EXIT_PERMISSION, + EXIT_USAGE, +) + +ALICE = 4242 +HELPER = 5000001 +GIFBOT = 93372553 +GROUP = 5150 +GROUP_ID = -1000000000000 - GROUP + + +@pytest.fixture +def bots(world): + """A world with one bot I own, one public bot, a user and a group.""" + from fake_telethon import make_channel, make_user + + alice = make_user(ALICE, username="alice") + world.add_user(alice) + + helper = make_user(HELPER, username="my_helper_bot", first="Helper") + helper.bot = True + helper.bot_can_edit = True + helper.bot_has_main_app = True + helper.bot_info_version = 3 + helper.bot_active_users = 12 + world.add_user(helper) + world.bots[HELPER] = { + "bot": True, + "about": "I help", + "description": "A helper bot", + "commands": [("start", "Start the bot"), ("help", "Show help")], + "menu_button": None, + } + world.admined_bots.append(HELPER) + + gif = make_user(GIFBOT, username="gifbot", first="GIF") + gif.bot = True + world.add_user(gif) + world.bots[GIFBOT] = {"bot": True, "about": "Send GIFs inline"} + + world.add_channel(make_channel(GROUP, title="Team", megagroup=True)) + return world + + +@pytest.fixture +def bot_session(bots): + """The account itself is a bot, which is what the bot-only ops require.""" + bots.me.bot = True + return bots + + +async def call(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> dict[str, Any]: + kwargs.setdefault("account", "work") + return await in_thread(client.op, op, request, **kwargs) + + +async def result(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> Any: + envelope = await call(client, in_thread, op, request, **kwargs) + return envelope["result"] + + +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") + + +def keyboard(*rows: list[dict[str, Any]]) -> Any: + """A `ReplyInlineMarkup` from a compact description.""" + from telethon.tl import types + + built = [] + for row in rows: + buttons = [] + for entry in row: + kind = entry.get("type", "callback") + text = entry["text"] + if kind == "callback": + buttons.append( + types.KeyboardButtonCallback( + text=text, + data=entry.get("data", b"cb"), + requires_password=entry.get("requires_password"), + ) + ) + elif kind == "url": + buttons.append(types.KeyboardButtonUrl(text=text, url=entry["url"])) + elif kind == "copy": + buttons.append(types.KeyboardButtonCopy(text=text, copy_text=entry["copy_text"])) + elif kind == "buy": + buttons.append(types.KeyboardButtonBuy(text=text)) + elif kind == "request_phone": + buttons.append(types.KeyboardButtonRequestPhone(text=text)) + elif kind == "request_geo": + buttons.append(types.KeyboardButtonRequestGeoLocation(text=text)) + elif kind == "request_poll": + buttons.append(types.KeyboardButtonRequestPoll(text=text)) + elif kind == "request_peer": + buttons.append( + types.KeyboardButtonRequestPeer( + text=text, + button_id=7, + peer_type=types.RequestPeerTypeUser(), + max_quantity=1, + ) + ) + elif kind == "switch_inline": + buttons.append( + types.KeyboardButtonSwitchInline(text=text, query=entry.get("query", "")) + ) + elif kind == "webview": + buttons.append(types.KeyboardButtonWebView(text=text, url=entry["url"])) + elif kind == "url_auth": + buttons.append( + types.KeyboardButtonUrlAuth(text=text, url=entry["url"], button_id=3) + ) + elif kind == "game": + buttons.append(types.KeyboardButtonGame(text=text)) + else: # plain reply-keyboard text button + buttons.append(types.KeyboardButton(text=text)) + built.append(types.KeyboardButtonRow(buttons=buttons)) + return types.ReplyInlineMarkup(rows=built) + + +def with_buttons(world, chat_id: int, markup: Any, message_id: int = 700) -> Any: + message = world.add_message(chat_id, "pick one", message_id=message_id, sender_id=HELPER) + message.reply_markup = markup + message.via_bot_id = HELPER + return message + + +# --------------------------------------------------------------------------- +# bot get / list / id +# --------------------------------------------------------------------------- + + +class TestBotGet: + async def test_the_card_carries_the_bot_info_and_the_user_flags( + self, live_daemon, client, in_thread, bots + ): + card = await result(client, in_thread, "bot.get", {"bot": "@my_helper_bot"}) + assert card["id"] == HELPER + assert card["about"] == "I help" + assert card["description"] == "A helper bot" + assert [c["command"] for c in card["commands"]] == ["start", "help"] + assert card["bot_can_edit"] is True + # bot_info_version is the ONLY invalidation signal for the card. + assert card["bot_info_version"] == 3 + + async def test_help_and_settings_are_reported_from_the_command_list( + self, live_daemon, client, in_thread, bots + ): + card = await result(client, in_thread, "bot.get", {"bot": "@my_helper_bot"}) + assert card["commands"][0]["has_help"] is True + # The bot declares no /settings, so the GUI must not offer the entry. + assert all(not c.get("has_settings") for c in card["commands"]) + + async def test_refresh_re_resolves_the_username_first( + self, live_daemon, client, in_thread, bots + ): + await result(client, in_thread, "bot.get", {"bot": "@my_helper_bot", "refresh": True}) + assert bots.called("ResolveUsernameRequest") + + async def test_a_language_asks_the_owner_side_for_the_localised_text( + self, live_daemon, client, in_thread, bots + ): + bots.bots[HELPER]["localized"] = {"de": {"about": "Ich helfe", "description": "Hilfe"}} + card = await result(client, in_thread, "bot.get", {"bot": "@my_helper_bot", "lang": "de"}) + assert card["about"] == "Ich helfe" + assert bots.called("GetBotInfoRequest")[0].lang_code == "de" + + async def test_access_settings_need_a_bot_you_administer( + self, live_daemon, client, in_thread, bots + ): + error = await fails(client, in_thread, "bot.get", {"bot": "@gifbot", "access": True}) + assert error.exit_code == EXIT_PERMISSION + + async def test_an_unknown_username_is_not_found(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "bot.get", {"bot": "@nobodyhere"}) + assert error.exit_code == EXIT_NOT_FOUND + + +class TestBotList: + async def test_owned_bots_are_the_default(self, live_daemon, client, in_thread, bots): + page = await call(client, in_thread, "bot.list", {}) + assert [row["id"] for row in page["result"]] == [HELPER] + assert page["result"][0]["kind"] == "owned" + + async def test_similar_bots_report_the_truncation_a_non_premium_account_gets( + self, live_daemon, client, in_thread, bots + ): + bots.similar_bots = [GIFBOT] + bots.similar_bots_count = 40 + page = await call(client, in_thread, "bot.list", {"similar_to": "@my_helper_bot"}) + assert page["result"][0]["truncated_count"] == 40 + + async def test_popular_apps_page_by_the_servers_string_offset( + self, live_daemon, client, in_thread, bots + ): + bots.popular_apps = [HELPER] + bots.popular_apps_next = "page2" + page = await call(client, in_thread, "bot.list", {"popular_apps": True}) + assert page["page"]["has_more"] is True + cursor = page["page"]["next_cursor"] + assert cursor + await call(client, in_thread, "bot.list", {"popular_apps": True}, cursor=cursor) + assert bots.called("GetPopularAppBotsRequest")[-1].offset == "page2" + + async def test_recent_bots_report_the_feature_being_off_rather_than_an_empty_truth( + self, live_daemon, client, in_thread, bots + ): + bots.top_peers_enabled = False + envelope = await call(client, in_thread, "bot.list", {"recent": True}) + assert envelope["result"] == [] + assert any("switched off" in w for w in envelope["meta"]["warnings"]) + + async def test_recent_bots_come_back_with_their_rating( + self, live_daemon, client, in_thread, bots + ): + bots.top_peer_bots = [GIFBOT] + page = await call(client, in_thread, "bot.list", {"recent": True}) + assert page["result"][0]["rating"] == 1.0 + + +class TestBotId: + async def test_a_channel_id_is_the_same_number_in_both_dialects( + self, live_daemon, client, in_thread, bots + ): + ids = await result(client, in_thread, "bot.id.get", {"chat": str(GROUP_ID)}) + assert ids["mtproto_id"] == GROUP_ID + assert ids["bot_api_id"] == GROUP_ID + assert ids["kind"] == "channel" + + async def test_resolving_a_username_reports_whether_a_hash_is_cached( + self, live_daemon, client, in_thread, bots + ): + ids = await result(client, in_thread, "bot.id.get", {"chat": "@alice"}) + assert ids["mtproto_id"] == ALICE + assert ids["kind"] == "user" + assert ids["has_access_hash"] is True + + +# --------------------------------------------------------------------------- +# bot start / stop +# --------------------------------------------------------------------------- + + +class TestBotStart: + async def test_a_hidden_start_parameter_goes_through_start_bot( + self, live_daemon, client, in_thread, bots + ): + started = await result( + client, in_thread, "bot.start", {"bot": "@my_helper_bot", "param": "ref123"} + ) + assert started["bot_id"] == HELPER + request = bots.called("StartBotRequest")[0] + assert request.start_param == "ref123" + + async def test_a_referrer_re_resolves_the_username_with_the_referral( + self, live_daemon, client, in_thread, bots + ): + await result(client, in_thread, "bot.start", {"bot": "@my_helper_bot", "referrer": "aff9"}) + assert bots.called("ResolveUsernameRequest")[0].referer == "aff9" + + async def test_restart_unblocks_first(self, live_daemon, client, in_thread, bots): + bots.bots[HELPER]["blocked"] = True + started = await result( + client, in_thread, "bot.start", {"bot": "@my_helper_bot", "restart": True} + ) + assert started["unblocked"] is True + assert bots.called("UnblockRequest") + + async def test_starting_in_a_group_grants_the_named_rights( + self, live_daemon, client, in_thread, bots + ): + started = await result( + client, + in_thread, + "bot.start", + { + "bot": "@my_helper_bot", + "chat": str(GROUP_ID), + "admin": "delete_messages+manage_chat", + "add": True, + }, + ) + assert started["admin_rights"] == ["delete_messages", "other"] + assert bots.called("InviteToChannelRequest") + rights = bots.called("EditAdminRequest")[0].admin_rights + assert rights.delete_messages is True + # manage_chat is the deep-link spelling of `other`. + assert rights.other is True + + async def test_a_bot_that_refuses_groups_is_a_permission_error( + self, live_daemon, client, in_thread, bots + ): + bots.bots[HELPER]["user_flags"] = {"bot_nochats": True} + error = await fails( + client, in_thread, "bot.start", {"bot": "@my_helper_bot", "chat": str(GROUP_ID)} + ) + assert error.exit_code == EXIT_PERMISSION + + async def test_an_unknown_admin_right_is_a_usage_error( + self, live_daemon, client, in_thread, bots + ): + error = await fails( + client, + in_thread, + "bot.start", + {"bot": "@my_helper_bot", "chat": str(GROUP_ID), "admin": "rule_the_world"}, + ) + assert error.exit_code == EXIT_USAGE + + +class TestBotStop: + async def test_blocking_reports_the_bot(self, live_daemon, client, in_thread, bots): + stopped = await result(client, in_thread, "bot.stop", {"bot": "@my_helper_bot"}) + assert stopped == {"bot_id": HELPER, "blocked": True} + assert bots.called("BlockRequest") + + async def test_delete_chat_drives_the_affected_history_loop( + self, live_daemon, client, in_thread, bots + ): + await result(client, in_thread, "bot.stop", {"bot": "@my_helper_bot", "delete_chat": True}) + assert bots.called("DeleteHistoryRequest") + + async def test_report_spams_before_blocking(self, live_daemon, client, in_thread, bots): + await result(client, in_thread, "bot.stop", {"bot": "@my_helper_bot", "report": True}) + assert [name for name, _ in bots.calls].index("ReportSpamRequest") < [ + name for name, _ in bots.calls + ].index("BlockRequest") + + +# --------------------------------------------------------------------------- +# bot command +# --------------------------------------------------------------------------- + + +class TestBotCommands: + async def test_a_users_view_reads_the_commands_off_bot_info( + self, live_daemon, client, in_thread, bots + ): + page = await result(client, in_thread, "bot.command.list", {"bot": "@my_helper_bot"}) + assert [row["command"] for row in page["items"]] == ["start", "help"] + + async def test_a_scope_needs_a_bot_session(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "bot.command.list", {"scope": "default"}) + assert error.exit_code == EXIT_AUTH + + async def test_a_bot_reads_its_own_scope_back( + self, live_daemon, client, in_thread, bot_session + ): + await result(client, in_thread, "bot.command.set", {"commands": "start:Start,stop:Stop"}) + page = await result(client, in_thread, "bot.command.list", {"scope": "default"}) + assert [row["command"] for row in page["items"]] == ["start", "stop"] + + async def test_setting_a_peer_scope_without_a_peer_is_a_usage_error( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, in_thread, "bot.command.set", {"commands": "a:b", "scope": "peer"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_clearing_resets_the_scope(self, live_daemon, client, in_thread, bot_session): + await result(client, in_thread, "bot.command.set", {"commands": "start:Start"}) + cleared = await result(client, in_thread, "bot.command.set", {"clear": True}) + assert cleared["cleared"] is True + assert bots_commands_empty(bot_session) + + async def test_a_command_in_a_group_carries_the_bot_username( + self, live_daemon, client, in_thread, bots + ): + sent = await result( + client, + in_thread, + "bot.command.send", + {"bot": "@my_helper_bot", "command": "start", "chat": str(GROUP_ID)}, + ) + assert sent["text"] == "/start@my_helper_bot" + + async def test_a_command_in_the_private_chat_does_not( + self, live_daemon, client, in_thread, bots + ): + sent = await result( + client, + in_thread, + "bot.command.send", + {"bot": "@my_helper_bot", "command": "/start", "args": ["deep", "link"]}, + ) + assert sent["text"] == "/start deep link" + + async def test_guest_mode_mentions_the_bot(self, live_daemon, client, in_thread, bots): + sent = await result( + client, + in_thread, + "bot.command.send", + { + "bot": "@my_helper_bot", + "command": "start", + "chat": str(GROUP_ID), + "guest": True, + }, + ) + assert sent["text"].startswith("@my_helper_bot ") + + async def test_a_business_connection_wraps_the_send_and_routes_it_to_the_connection_dc( + self, live_daemon, client, in_thread, bot_session + ): + await result( + client, + in_thread, + "bot.command.send", + { + "bot": "@my_helper_bot", + "command": "start", + "business_connection": "conn1", + }, + ) + assert bot_session.called("InvokeWithBusinessConnectionRequest") + borrowed = bot_session.called("borrow_exported_sender") + assert borrowed and borrowed[0]["dc_id"] == bot_session.business_dc + + +def bots_commands_empty(world) -> bool: + return not any(world.bot_commands.values()) + + +# --------------------------------------------------------------------------- +# bot menu / permission / access / default rights +# --------------------------------------------------------------------------- + + +class TestBotMenu: + async def test_the_default_button_is_normalised_to_commands( + self, live_daemon, client, in_thread, bots + ): + button = await result(client, in_thread, "bot.menu.get", {"bot": "@my_helper_bot"}) + assert button["kind"] == "commands" + + async def test_a_webapp_button_reports_its_url(self, live_daemon, client, in_thread, bots): + from telethon.tl import types + + bots.bots[HELPER]["menu_button"] = types.BotMenuButton( + text="Shop", url="https://example.org/shop" + ) + button = await result(client, in_thread, "bot.menu.get", {"bot": "@my_helper_bot"}) + assert button == {"kind": "webapp", "text": "Shop", "url": "https://example.org/shop"} + + async def test_setting_a_webapp_button_needs_text_and_url( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails(client, in_thread, "bot.menu.set", {"webapp": True, "text": "Shop"}) + assert error.exit_code == EXIT_USAGE + + async def test_exactly_one_kind_may_be_chosen( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails(client, in_thread, "bot.menu.set", {"commands": True, "default": True}) + assert error.exit_code == EXIT_USAGE + + async def test_setting_the_commands_button_stores_it( + self, live_daemon, client, in_thread, bot_session + ): + button = await result(client, in_thread, "bot.menu.set", {"commands": True}) + assert button["kind"] == "commands" + assert bot_session.called("SetBotMenuButtonRequest") + + +class TestBotPermission: + async def test_reading_both_permissions(self, live_daemon, client, in_thread, bots): + bots.bots[HELPER]["can_send"] = True + bots.bots[HELPER]["emoji_status_allowed"] = True + permission = await result( + client, in_thread, "bot.permission.get", {"bot": "@my_helper_bot"} + ) + assert permission["can_send_messages"] is True + assert permission["emoji_status_allowed"] is True + + async def test_granting_message_permission_is_idempotent( + self, live_daemon, client, in_thread, bots + ): + first = await result( + client, + in_thread, + "bot.permission.set", + {"bot": "@my_helper_bot", "key": "message", "state": "on"}, + ) + assert first.get("already", False) is False + envelope = await call( + client, + in_thread, + "bot.permission.set", + {"bot": "@my_helper_bot", "key": "message", "state": "on"}, + ) + assert envelope["result"]["already"] is True + assert envelope["meta"]["already"] is True + + async def test_there_is_no_revoke_for_may_message_me( + self, live_daemon, client, in_thread, bots + ): + error = await fails( + client, + in_thread, + "bot.permission.set", + {"bot": "@my_helper_bot", "key": "message", "state": "off"}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_an_unknown_key_is_a_usage_error(self, live_daemon, client, in_thread, bots): + error = await fails( + client, + in_thread, + "bot.permission.set", + {"bot": "@my_helper_bot", "key": "everything", "state": "on"}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_the_emoji_status_toggle_reaches_the_server( + self, live_daemon, client, in_thread, bots + ): + await result( + client, + in_thread, + "bot.permission.set", + {"bot": "@my_helper_bot", "key": "emoji-status", "state": "on"}, + ) + assert bots.called("ToggleUserEmojiStatusPermissionRequest")[0].enabled is True + + +class TestBotAccess: + async def test_adding_a_peer_keeps_the_ones_already_there( + self, live_daemon, client, in_thread, bots + ): + bots.bots[HELPER]["allowed"] = [ALICE] + access = await result( + client, + in_thread, + "bot.access.set", + {"bot": "@my_helper_bot", "restricted": True, "add": ["@gifbot"]}, + ) + assert sorted(access["allowed_users"]) == sorted([ALICE, GIFBOT]) + read = await result(client, in_thread, "bot.access.get", {"bot": "@my_helper_bot"}) + assert read["restricted"] is True + assert sorted(read["allowed_users"]) == sorted([ALICE, GIFBOT]) + + async def test_removing_a_peer_drops_only_that_one(self, live_daemon, client, in_thread, bots): + bots.bots[HELPER]["allowed"] = [ALICE, GIFBOT] + access = await result( + client, + in_thread, + "bot.access.set", + {"bot": "@my_helper_bot", "remove": ["@alice"]}, + ) + assert access["allowed_users"] == [GIFBOT] + + async def test_restricted_and_open_contradict_each_other( + self, live_daemon, client, in_thread, bots + ): + error = await fails( + client, + in_thread, + "bot.access.set", + {"bot": "@my_helper_bot", "restricted": True, "open_to_all": True}, + ) + assert error.exit_code == EXIT_USAGE + + +class TestBotDefaultRights: + async def test_the_two_halves_are_set_independently( + self, live_daemon, client, in_thread, bot_session + ): + rights = await result( + client, + in_thread, + "bot.default-rights.set", + {"group": "delete_messages", "channel": "post_messages"}, + ) + assert rights == {"group_rights": ["delete_messages"], "channel_rights": ["post_messages"]} + assert bot_session.default_rights["group"].delete_messages is True + + async def test_neither_half_is_a_usage_error(self, live_daemon, client, in_thread, bot_session): + error = await fails(client, in_thread, "bot.default-rights.set", {}) + assert error.exit_code == EXIT_USAGE + + async def test_a_user_session_is_refused(self, live_daemon, client, in_thread, bots): + error = await fails( + client, in_thread, "bot.default-rights.set", {"group": "delete_messages"} + ) + assert error.exit_code == EXIT_AUTH + + +# --------------------------------------------------------------------------- +# bot press +# --------------------------------------------------------------------------- + + +class TestPressAddressing: + async def test_a_lone_button_needs_no_address(self, live_daemon, client, in_thread, bots): + with_buttons(bots, ALICE, keyboard([{"text": "Yes"}])) + pressed = await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert pressed["kind"] == "callback" + assert pressed["message"] == "OK" + + async def test_the_flat_index_is_the_one_message_get_prints( + self, live_daemon, client, in_thread, bots + ): + with_buttons(bots, ALICE, keyboard([{"text": "A"}], [{"text": "B"}, {"text": "C"}])) + message = await result(client, in_thread, "message.get", {"chat": "@alice", "msg_id": 700}) + printed = [b["n"] for row in message["reply_markup"]["rows"] for b in row] + assert printed == [0, 1, 2] + pressed = await result( + client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700, "button": "2"} + ) + assert (pressed["row"], pressed["col"], pressed["n"]) == (1, 1, 2) + + async def test_row_and_column_address_the_same_button( + self, live_daemon, client, in_thread, bots + ): + with_buttons(bots, ALICE, keyboard([{"text": "A"}], [{"text": "B"}, {"text": "C"}])) + pressed = await result( + client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700, "button": "1,1"} + ) + assert pressed["n"] == 2 + + async def test_text_matches_exactly_then_uniquely(self, live_daemon, client, in_thread, bots): + with_buttons(bots, ALICE, keyboard([{"text": "Accept"}, {"text": "Accept later"}])) + exact = await result( + client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700, "button": "Accept"} + ) + assert exact["n"] == 0 + unique = await result( + client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700, "button": "later"} + ) + assert unique["n"] == 1 + + async def test_an_ambiguous_text_refuses_rather_than_guessing( + self, live_daemon, client, in_thread, bots + ): + with_buttons(bots, ALICE, keyboard([{"text": "Buy one"}, {"text": "Buy two"}])) + error = await fails( + client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700, "button": "Buy"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_a_payload_addresses_a_callback_button( + self, live_daemon, client, in_thread, bots + ): + with_buttons( + bots, + ALICE, + keyboard([{"text": "A", "data": b"one"}, {"text": "B", "data": b"two"}]), + ) + pressed = await result( + client, + in_thread, + "bot.press", + {"chat": "@alice", "msg_id": 700, "data": "str:two"}, + ) + assert pressed["n"] == 1 + assert bots.called("GetBotCallbackAnswerRequest")[0].data == b"two" + + async def test_several_buttons_and_no_address_is_a_usage_error( + self, live_daemon, client, in_thread, bots + ): + with_buttons(bots, ALICE, keyboard([{"text": "A"}, {"text": "B"}])) + error = await fails(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert error.exit_code == EXIT_USAGE + + async def test_a_message_without_buttons_is_not_found( + self, live_daemon, client, in_thread, bots + ): + bots.add_message(ALICE, "plain", message_id=701) + error = await fails(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 701}) + assert error.exit_code == EXIT_NOT_FOUND + + +class TestPressKinds: + async def test_a_url_button_is_printed_never_opened(self, live_daemon, client, in_thread, bots): + with_buttons( + bots, ALICE, keyboard([{"text": "Site", "type": "url", "url": "https://x.example"}]) + ) + pressed = await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert pressed == { + "kind": "url", + "row": 0, + "col": 0, + "n": 0, + "text": "Site", + "url": "https://x.example", + } + + async def test_a_copy_button_prints_its_text(self, live_daemon, client, in_thread, bots): + with_buttons( + bots, ALICE, keyboard([{"text": "Copy", "type": "copy", "copy_text": "ABC-123"}]) + ) + pressed = await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert pressed["copy_text"] == "ABC-123" + + async def test_a_reply_keyboard_text_button_sends_its_own_text( + self, live_daemon, client, in_thread, bots + ): + with_buttons(bots, ALICE, keyboard([{"text": "Menu", "type": "text"}])) + pressed = await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert pressed["kind"] == "text" + assert bots.called("SendMessageRequest")[0].message == "Menu" + + async def test_a_game_button_asks_for_the_game_url(self, live_daemon, client, in_thread, bots): + from telethon.tl import types + + bots.callback_answer = types.messages.BotCallbackAnswer( + cache_time=0, url="https://game.example/play", has_url=True + ) + with_buttons(bots, ALICE, keyboard([{"text": "Play", "type": "game"}])) + pressed = await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert pressed["url"] == "https://game.example/play" + assert bots.called("GetBotCallbackAnswerRequest")[0].game is True + + async def test_a_buy_button_is_refused(self, live_daemon, client, in_thread, bots): + with_buttons(bots, ALICE, keyboard([{"text": "Pay", "type": "buy"}])) + error = await fails(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert error.exit_code == EXIT_PERMISSION + + async def test_a_switch_inline_button_runs_the_real_inline_query( + self, live_daemon, client, in_thread, bots + ): + """Telethon's own MessageButton.click sends startBot here, which is wrong.""" + with_buttons( + bots, + ALICE, + keyboard([{"text": "Search", "type": "switch_inline", "query": "cats"}]), + ) + pressed = await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert pressed["kind"] == "switch_inline" + assert bots.called("GetInlineBotResultsRequest")[0].query == "cats" + assert not bots.called("StartBotRequest") + + async def test_a_webview_button_returns_the_signed_url_and_its_session( + self, live_daemon, client, in_thread, bots + ): + with_buttons( + bots, + ALICE, + keyboard([{"text": "Open", "type": "webview", "url": "https://app.example"}]), + ) + pressed = await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert pressed["url"] == bots.webapp_url + assert pressed["query_id"] == str(bots.webapp_query_id) + + async def test_a_url_auth_button_is_inspected_never_accepted( + self, live_daemon, client, in_thread, bots + ): + with_buttons( + bots, + ALICE, + keyboard([{"text": "Login", "type": "url_auth", "url": "https://x.example"}]), + ) + pressed = await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert pressed["auth"]["result"] == "request" + assert not bots.called("AcceptUrlAuthRequest") + + async def test_a_bot_that_does_not_answer_is_not_an_error( + self, live_daemon, client, in_thread, bots + ): + from telethon.errors import BotResponseTimeoutError + + with_buttons(bots, ALICE, keyboard([{"text": "A"}])) + bots.fail_next("GetBotCallbackAnswerRequest", BotResponseTimeoutError(request=None)) + envelope = await call(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert envelope["result"]["kind"] == "callback" + assert "message" not in envelope["result"] + assert any("offline" in w for w in envelope["meta"]["warnings"]) + + async def test_a_password_guarded_button_needs_the_password( + self, live_daemon, client, in_thread, bots + ): + with_buttons(bots, ALICE, keyboard([{"text": "Transfer", "requires_password": True}])) + error = await fails(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert error.exit_code == EXIT_USAGE + + async def test_a_password_guarded_button_sends_an_srp_check( + self, live_daemon, client, in_thread, bots + ): + bots.auth.password = "hunter2" + with_buttons(bots, ALICE, keyboard([{"text": "Transfer", "requires_password": True}])) + await result( + client, + in_thread, + "bot.press", + {"chat": "@alice", "msg_id": 700, "password": "hunter2"}, + ) + assert bots.called("GetBotCallbackAnswerRequest")[0].password is not None + + async def test_the_two_layer_229_button_kinds_exit_13( + self, live_daemon, client, in_thread, bots + ): + with_buttons(bots, ALICE, keyboard([{"text": "A"}])) + for field in ("rich_button", "ephemeral"): + error = await fails( + client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700, field: 1} + ) + assert error.exit_code == EXIT_INDETERMINATE + assert error.code == "NOT_SUPPORTED" + + +class TestPressConsent: + """A button that discloses something is not pressed without its flag.""" + + @pytest.mark.parametrize( + "kind,flag,value", + [ + ("request_phone", "share_phone", True), + ("request_geo", "share_geo", "1.0,2.0"), + ("request_poll", "poll", "Lunch?:Pizza,Sushi"), + ("request_peer", "peers", ["@alice"]), + ], + ) + async def test_without_the_flag_nothing_is_sent( + self, live_daemon, client, in_thread, bots, kind, flag, value + ): + with_buttons(bots, ALICE, keyboard([{"text": "Share", "type": kind}])) + error = await fails(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700}) + assert error.exit_code == EXIT_USAGE + assert not bots.called("SendMediaRequest") + assert not bots.called("SendBotRequestedPeerRequest") + + @pytest.mark.parametrize( + "kind,flag,value", + [ + ("request_phone", "share_phone", True), + ("request_geo", "share_geo", "1.0,2.0"), + ("request_poll", "poll", "Lunch?:Pizza,Sushi"), + ], + ) + async def test_with_the_flag_the_media_goes_out( + self, live_daemon, client, in_thread, bots, kind, flag, value + ): + with_buttons(bots, ALICE, keyboard([{"text": "Share", "type": kind}])) + await result(client, in_thread, "bot.press", {"chat": "@alice", "msg_id": 700, flag: value}) + assert bots.called("SendMediaRequest") + + async def test_sharing_a_peer_sends_the_requested_peer_answer( + self, live_daemon, client, in_thread, bots + ): + with_buttons(bots, ALICE, keyboard([{"text": "Pick", "type": "request_peer"}])) + pressed = await result( + client, + in_thread, + "bot.press", + {"chat": "@alice", "msg_id": 700, "peers": ["@alice"]}, + ) + assert pressed["peers"] == [ALICE] + assert bots.called("SendBotRequestedPeerRequest")[0].button_id == 7 + + async def test_a_quiz_poll_needs_the_correct_answer(self, live_daemon, client, in_thread, bots): + with_buttons(bots, ALICE, keyboard([{"text": "Poll", "type": "request_poll"}])) + error = await fails( + client, + in_thread, + "bot.press", + {"chat": "@alice", "msg_id": 700, "poll": "Q?:a,b", "quiz": True}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_a_mini_app_peer_request_is_answered_by_id( + self, live_daemon, client, in_thread, bots + ): + pressed = await result( + client, + in_thread, + "bot.press", + {"chat": "@my_helper_bot", "webapp_req": "req1", "peers": ["@alice"]}, + ) + assert pressed["peers"] == [ALICE] + assert bots.called("SendBotRequestedPeerRequest")[0].webapp_req_id == "req1" + + +# --------------------------------------------------------------------------- +# bot url-auth +# --------------------------------------------------------------------------- + + +class TestUrlAuth: + async def test_inspecting_prints_the_domain_and_grants_nothing( + self, live_daemon, client, in_thread, bots + ): + auth = await result( + client, in_thread, "bot.url-auth.get", {"target": "https://x.example/login"} + ) + assert auth["result"] == "request" + assert auth["domain"] == "example.org" + assert not bots.called("AcceptUrlAuthRequest") + + async def test_a_button_needs_both_coordinates(self, live_daemon, client, in_thread, bots): + error = await fails( + client, in_thread, "bot.url-auth.get", {"target": "@my_helper_bot", "msg_id": 700} + ) + assert error.exit_code == EXIT_USAGE + + async def test_accepting_defaults_both_consent_flags_off( + self, live_daemon, client, in_thread, bots + ): + auth = await result( + client, in_thread, "bot.url-auth.accept", {"target": "https://x.example/login"} + ) + assert auth["result"] == "accepted" + assert auth["url"].startswith("https://example.org/login") + request = bots.called("AcceptUrlAuthRequest")[0] + assert request.write_allowed is None + assert request.share_phone_number is None + + async def test_consent_flags_are_passed_through_when_given( + self, live_daemon, client, in_thread, bots + ): + await result( + client, + in_thread, + "bot.url-auth.accept", + {"target": "https://x.example/login", "write_allowed": True, "share_phone": True}, + ) + request = bots.called("AcceptUrlAuthRequest")[0] + assert request.write_allowed is True + assert request.share_phone_number is True + + async def test_a_match_code_is_mandatory_when_the_request_shows_one( + self, live_daemon, client, in_thread, bots + ): + from telethon.tl import types + + bots.url_auth = types.UrlAuthResultRequest( + bot=bots.me, domain="example.org", match_codes=True + ) + error = await fails( + client, in_thread, "bot.url-auth.accept", {"target": "https://x.example/login"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_a_wrong_match_code_stops_the_login(self, live_daemon, client, in_thread, bots): + from telethon.tl import types + + bots.url_auth = types.UrlAuthResultRequest( + bot=bots.me, domain="example.org", match_codes=True, match_codes_first=True + ) + error = await fails( + client, + in_thread, + "bot.url-auth.accept", + {"target": "https://x.example/login", "match_code": "dog"}, + ) + assert error.exit_code == EXIT_PERMISSION + assert not bots.called("AcceptUrlAuthRequest") + + async def test_declining_says_so(self, live_daemon, client, in_thread, bots): + declined = await result( + client, in_thread, "bot.url-auth.decline", {"url": "tg://oauth?domain=x"} + ) + assert declined == {"result": "declined", "declined": True, "url": "tg://oauth?domain=x"} + + async def test_the_folded_in_v1_paths_all_reach_this_op(self): + from tlgr.registry import canonical + + for name in ("link auth", "auth url-login", "bot login-url get"): + assert canonical(name) == "bot.url-auth.get" + + +# --------------------------------------------------------------------------- +# bot answer / query / api / connection / stream +# --------------------------------------------------------------------------- + + +class TestBotAnswer: + async def test_a_user_session_cannot_answer(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "bot.answer", {"kind": "callback", "query_id": "1"}) + assert error.exit_code == EXIT_AUTH + + async def test_a_callback_answer_carries_its_text( + self, live_daemon, client, in_thread, bot_session + ): + answered = await result( + client, + in_thread, + "bot.answer", + {"kind": "callback", "query_id": "17", "text": "Saved", "alert": True}, + ) + assert answered == {"query_id": "17", "kind": "callback", "answered": True} + request = bot_session.called("SetBotCallbackAnswerRequest")[0] + assert (request.message, request.alert) == ("Saved", True) + + async def test_a_flag_from_another_kind_is_a_usage_error( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, + in_thread, + "bot.answer", + {"kind": "callback", "query_id": "17", "next_offset": "p2"}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_a_precheckout_answer_is_allowed_because_it_is_not_a_payment( + self, live_daemon, client, in_thread, bot_session + ): + await result( + client, + in_thread, + "bot.answer", + {"kind": "precheckout", "query_id": "17", "ok": True}, + ) + assert bot_session.called("SetBotPrecheckoutResultsRequest")[0].success is True + + async def test_an_inline_answer_reads_its_results_from_a_file( + self, live_daemon, client, in_thread, bot_session, tmp_path + ): + path = tmp_path / "results.json" + path.write_text('[{"id":"r1","type":"article","message":{"text":"hello"}}]') + await result( + client, + in_thread, + "bot.answer", + { + "kind": "inline", + "query_id": "17", + "results": str(path), + "gallery": True, + "switch_pm": "Log in:start", + }, + ) + request = bot_session.called("SetInlineBotResultsRequest")[0] + assert request.results[0].id == "r1" + assert request.gallery is True + assert request.switch_pm.start_param == "start" + + async def test_an_unknown_kind_is_a_usage_error( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails(client, in_thread, "bot.answer", {"kind": "telepathy", "query_id": "1"}) + assert error.exit_code == EXIT_USAGE + + +class TestBotQueryAndApi: + async def test_the_query_list_says_when_nobody_is_buffering( + self, live_daemon, client, in_thread, bot_session + ): + envelope = await call(client, in_thread, "bot.query.list", {}) + assert envelope["result"] == [] + assert any("bot-updates" in w for w in envelope["meta"]["warnings"]) + + async def test_the_query_list_needs_a_bot_session(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "bot.query.list", {}) + assert error.exit_code == EXIT_AUTH + + async def test_an_arbitrary_bot_api_method_passes_its_json_through( + self, live_daemon, client, in_thread, bot_session + ): + bot_session.custom_response = '{"ok":true,"result":{"id":7}}' + answer = await result( + client, in_thread, "bot.api.send", {"method": "getMe", "params": "{}"} + ) + assert answer["result"]["result"]["id"] == 7 + assert bot_session.called("SendCustomRequestRequest")[0].custom_method == "getMe" + + async def test_a_business_connection_reports_its_dc_and_rights( + self, live_daemon, client, in_thread, bot_session + ): + connection = await result( + client, in_thread, "bot.connection.get", {"connection_id": "conn1"} + ) + assert connection["dc_id"] == bot_session.business_dc + assert connection["rights"] == ["reply"] + + async def test_wrapping_an_arbitrary_command_is_refused_with_a_pointer( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, + in_thread, + "bot.connection.invoke", + {"connection_id": "conn1", "command": ["message", "send"]}, + ) + assert error.exit_code == EXIT_INDETERMINATE + assert "--business-connection" in str(error) + + +class TestBotStream: + async def test_each_chunk_is_one_typing_action_keyed_by_the_draft( + self, live_daemon, client, in_thread, bot_session, tmp_path + ): + path = tmp_path / "chunks.txt" + path.write_text("one\ntwo\nthree\n") + progress = await result( + client, + in_thread, + "bot.stream.send", + {"chat": "@alice", "draft_id": 99, "file": str(path)}, + ) + assert progress["chunks_sent"] == 3 + actions = [c.action for _, c in bot_session.calls if _ == "SetTypingRequest"] + assert [a.text.text for a in actions] == ["one", "two", "three"] + assert {a.random_id for a in actions} == {99} + + async def test_the_layer_229_stop_flags_exit_13( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, + in_thread, + "bot.stream.send", + {"chat": "@alice", "draft_id": 99, "stop": True}, + ) + assert error.exit_code == EXIT_INDETERMINATE + + async def test_a_stream_without_a_draft_id_is_a_usage_error( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails(client, in_thread, "bot.stream.send", {"chat": "@alice", "text": "hi"}) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# bot create / edit / username / token +# --------------------------------------------------------------------------- + + +class TestBotLifecycle: + async def test_the_username_is_checked_before_the_quota_is_spent( + self, live_daemon, client, in_thread, bots + ): + bots.taken_usernames.add("taken_bot") + error = await fails(client, in_thread, "bot.create", {"name": "X", "username": "taken_bot"}) + assert error.exit_code == EXIT_USAGE + assert not bots.called("CreateBotRequest") + + async def test_check_only_never_creates(self, live_daemon, client, in_thread, bots): + created = await result( + client, + in_thread, + "bot.create", + {"name": "X", "username": "free_bot", "check_only": True}, + ) + assert created["username"] == "free_bot" + assert not bots.called("CreateBotRequest") + + async def test_creating_a_bot_returns_its_id(self, live_daemon, client, in_thread, bots): + created = await result( + client, in_thread, "bot.create", {"name": "Helper 2", "username": "helper2_bot"} + ) + assert created["bot_id"] > 0 + assert created["token_available"] is True + + async def test_editing_targets_the_bot_and_not_the_calling_account( + self, live_daemon, client, in_thread, bots + ): + await result( + client, + in_thread, + "bot.edit", + {"bot": "@my_helper_bot", "name": "Helper", "lang": "en"}, + ) + request = bots.called("SetBotInfoRequest")[0] + assert request.bot is not None + assert request.lang_code == "en" + + async def test_a_photo_is_uploaded_against_the_bot( + self, live_daemon, client, in_thread, bots, tmp_path + ): + path = tmp_path / "avatar.jpg" + path.write_bytes(b"\xff\xd8\xff" + b"0" * 64) + edited = await result( + client, in_thread, "bot.edit", {"bot": "@my_helper_bot", "photo": str(path)} + ) + assert edited["bot_id"] == HELPER + assert bots.called("UploadProfilePhotoRequest")[0].bot is not None + + async def test_username_check_reports_availability(self, live_daemon, client, in_thread, bots): + bots.taken_usernames.add("busy_bot") + assert (await result(client, in_thread, "bot.username.check", {"username": "busy_bot"}))[ + "available" + ] is False + assert (await result(client, in_thread, "bot.username.check", {"username": "quiet_bot"}))[ + "available" + ] is True + + async def test_toggling_and_reordering_usernames_reads_back( + self, live_daemon, client, in_thread, bots + ): + await result( + client, + in_thread, + "bot.username.set", + {"bot": "@my_helper_bot", "enable": ["alt_bot", "second_bot"]}, + ) + names = await result( + client, + in_thread, + "bot.username.set", + {"bot": "@my_helper_bot", "order": "second_bot,alt_bot"}, + ) + assert names["usernames"][:2] == ["my_helper_bot", "second_bot"] or names["usernames"] == [ + "my_helper_bot", + "second_bot", + "alt_bot", + ] + + async def test_the_token_is_redacted_unless_asked_for( + self, live_daemon, client, in_thread, bots + ): + envelope = await call(client, in_thread, "bot.token.export", {"bot": "@my_helper_bot"}) + assert "token" not in envelope["result"] + assert any("redacted" in w for w in envelope["meta"]["warnings"]) + + async def test_show_prints_it_and_out_writes_it_privately( + self, live_daemon, client, in_thread, bots, tmp_path + ): + import stat + + target = tmp_path / "token" + exported = await result( + client, + in_thread, + "bot.token.export", + {"bot": "@my_helper_bot", "show": True, "out": str(target)}, + ) + assert exported["token"].endswith("TESTTOKEN") + assert target.read_text().endswith("TESTTOKEN") + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + + async def test_revoking_issues_a_new_token(self, live_daemon, client, in_thread, bots): + exported = await result( + client, + in_thread, + "bot.token.export", + {"bot": "@my_helper_bot", "revoke": True, "show": True}, + ) + assert exported["revoked"] is True + assert "REVOKED-AND-NEW" in exported["token"] + + +# --------------------------------------------------------------------------- +# bot preview / affiliate / verification / attach / recent +# --------------------------------------------------------------------------- + + +@pytest.fixture +def picture(tmp_path): + path = tmp_path / "shot.jpg" + path.write_bytes(b"\xff\xd8\xff" + b"1" * 64) + return str(path) + + +class TestBotPreviews: + async def test_adding_then_listing_shows_the_gallery( + self, live_daemon, client, in_thread, bots, picture + ): + await result( + client, in_thread, "bot.preview.add", {"bot": "@my_helper_bot", "file": picture} + ) + page = await result(client, in_thread, "bot.preview.list", {"bot": "@my_helper_bot"}) + assert len(page["items"]) == 1 + + async def test_reordering_moves_the_gallery( + self, live_daemon, client, in_thread, bots, picture + ): + for _ in range(2): + await result( + client, in_thread, "bot.preview.add", {"bot": "@my_helper_bot", "file": picture} + ) + before = await result(client, in_thread, "bot.preview.list", {"bot": "@my_helper_bot"}) + first = before["items"][0]["file_id"] + await result( + client, in_thread, "bot.preview.edit", {"bot": "@my_helper_bot", "order": "1,0"} + ) + after = await result(client, in_thread, "bot.preview.list", {"bot": "@my_helper_bot"}) + assert after["items"][1]["file_id"] == first + + async def test_an_order_that_is_not_a_permutation_is_refused( + self, live_daemon, client, in_thread, bots, picture + ): + await result( + client, in_thread, "bot.preview.add", {"bot": "@my_helper_bot", "file": picture} + ) + error = await fails( + client, in_thread, "bot.preview.edit", {"bot": "@my_helper_bot", "order": "0,1"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_index_and_order_are_mutually_exclusive( + self, live_daemon, client, in_thread, bots + ): + error = await fails( + client, + in_thread, + "bot.preview.edit", + {"bot": "@my_helper_bot", "index": 0, "order": "0"}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_deleting_an_absent_position_is_not_found( + self, live_daemon, client, in_thread, bots + ): + error = await fails( + client, in_thread, "bot.preview.delete", {"bot": "@my_helper_bot", "index": [3]} + ) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_deleting_removes_it_from_the_gallery( + self, live_daemon, client, in_thread, bots, picture + ): + await result( + client, in_thread, "bot.preview.add", {"bot": "@my_helper_bot", "file": picture} + ) + deleted = await result( + client, in_thread, "bot.preview.delete", {"bot": "@my_helper_bot", "index": [0]} + ) + assert deleted["deleted"] == 1 + page = await result(client, in_thread, "bot.preview.list", {"bot": "@my_helper_bot"}) + assert page.get("items", []) == [] + + async def test_replacing_one_swaps_it(self, live_daemon, client, in_thread, bots, picture): + await result( + client, in_thread, "bot.preview.add", {"bot": "@my_helper_bot", "file": picture} + ) + changed = await result( + client, + in_thread, + "bot.preview.edit", + {"bot": "@my_helper_bot", "index": 0, "file": picture}, + ) + assert changed["index"] == 0 + assert bots.called("EditPreviewMediaRequest") + + async def test_the_owner_view_asks_for_the_per_language_set( + self, live_daemon, client, in_thread, bots + ): + await result( + client, in_thread, "bot.preview.list", {"bot": "@my_helper_bot", "owner": True} + ) + assert bots.called("GetPreviewInfoRequest") + + +class TestBotAffiliate: + async def test_a_commission_outside_the_servers_bounds_is_refused( + self, live_daemon, client, in_thread, bots + ): + bots.app_config["starref_max_commission_permille"] = 300 + error = await fails( + client, + in_thread, + "bot.affiliate.set", + {"bot": "@my_helper_bot", "commission_permille": 900}, + ) + assert error.exit_code == EXIT_USAGE + assert not bots.called("UpdateStarRefProgramRequest") + + async def test_the_feature_can_be_switched_off_server_side( + self, live_daemon, client, in_thread, bots + ): + bots.app_config["starref_program_allowed"] = False + error = await fails( + client, + in_thread, + "bot.affiliate.set", + {"bot": "@my_helper_bot", "commission_permille": 200}, + ) + assert error.exit_code == EXIT_PERMISSION + + async def test_setting_a_program_reads_back(self, live_daemon, client, in_thread, bots): + program = await result( + client, + in_thread, + "bot.affiliate.set", + {"bot": "@my_helper_bot", "commission_permille": 200, "duration_months": 6}, + ) + assert program["commission_permille"] == 200 + assert program["duration_months"] == 6 + + async def test_unsetting_sends_a_zero_commission(self, live_daemon, client, in_thread, bots): + await result(client, in_thread, "bot.affiliate.unset", {"bot": "@my_helper_bot"}) + assert bots.called("UpdateStarRefProgramRequest")[0].commission_permille == 0 + + async def test_joining_returns_my_referral_link(self, live_daemon, client, in_thread, bots): + joined = await result(client, in_thread, "bot.affiliate.join", {"bot": "@my_helper_bot"}) + assert joined["url"].startswith("https://t.me/") + assert joined["bot_id"] == HELPER + + async def test_joining_can_be_switched_off_server_side( + self, live_daemon, client, in_thread, bots + ): + bots.app_config["starref_connect_allowed"] = False + error = await fails(client, in_thread, "bot.affiliate.join", {"bot": "@my_helper_bot"}) + assert error.exit_code == EXIT_PERMISSION + + async def test_connected_programs_are_listed(self, live_daemon, client, in_thread, bots): + await result(client, in_thread, "bot.affiliate.join", {"bot": "@my_helper_bot"}) + page = await call(client, in_thread, "bot.affiliate.list", {}) + assert page["result"][0]["bot_id"] == HELPER + + async def test_suggested_programs_page_by_a_string_offset( + self, live_daemon, client, in_thread, bots + ): + from telethon.tl import types + + bots.suggested_refs = [types.StarRefProgram(bot_id=HELPER, commission_permille=150)] + bots.suggested_refs_next = "page2" + page = await call(client, in_thread, "bot.affiliate.list", {"suggested": True}) + assert page["result"][0]["commission_permille"] == 150 + assert page["page"]["has_more"] is True + + async def test_revoking_a_dead_link_is_already_done(self, live_daemon, client, in_thread, bots): + from telethon.errors import RPCError + + class StarrefExpiredError(RPCError): + def __init__(self) -> None: + super().__init__(request=None, message="STARREF_EXPIRED", code=400) + + bots.fail_next("EditConnectedStarRefBotRequest", StarrefExpiredError()) + envelope = await call( + client, in_thread, "bot.affiliate.revoke", {"link": "https://t.me/x?start=_tgr_a"} + ) + assert envelope["result"]["revoked"] is True + assert envelope["meta"]["already"] is True + + +class TestBotVerification: + async def test_both_badges_are_reported(self, live_daemon, client, in_thread, bots): + from telethon.tl import types + + bots.bots[ALICE] = { + "bot": False, + "verification": types.BotVerification( + bot_id=HELPER, icon=1, description="Verified merchant" + ), + } + bots.users[ALICE].verified = True + badge = await result(client, in_thread, "bot.verification.get", {"chat": "@alice"}) + assert badge["verified_by_bot"] == HELPER + assert badge["telegram_verified"] is True + + async def test_setting_needs_a_verifier_bot(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "bot.verification.set", {"chat": "@alice"}) + assert error.exit_code == EXIT_USAGE + + async def test_removing_sends_enabled_none(self, live_daemon, client, in_thread, bots): + verified = await result( + client, + in_thread, + "bot.verification.set", + {"chat": "@alice", "bot": "@my_helper_bot", "remove": True}, + ) + assert verified["verified"] is False + assert bots.called("SetCustomVerificationRequest")[0].enabled is None + + +class TestBotAttachMenu: + async def test_installing_shows_up_in_the_listing(self, live_daemon, client, in_thread, bots): + toggled = await result( + client, in_thread, "bot.attach.toggle", {"bot": "@my_helper_bot", "state": "on"} + ) + assert toggled["installed"] is True + page = await result(client, in_thread, "bot.attach.list", {}) + assert [row["bot_id"] for row in page["items"]] == [HELPER] + assert page["items"][0]["username"] == "my_helper_bot" + + async def test_write_access_is_never_implicit(self, live_daemon, client, in_thread, bots): + await result( + client, in_thread, "bot.attach.toggle", {"bot": "@my_helper_bot", "state": "on"} + ) + assert bots.called("ToggleBotInAttachMenuRequest")[0].write_allowed is None + + async def test_a_disclaimer_bot_needs_accept_tos(self, live_daemon, client, in_thread, bots): + bots.attach_menu[HELPER] = {"disclaimer": True} + error = await fails( + client, in_thread, "bot.attach.toggle", {"bot": "@my_helper_bot", "state": "on"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_a_bad_state_is_a_usage_error(self, live_daemon, client, in_thread, bots): + error = await fails( + client, in_thread, "bot.attach.toggle", {"bot": "@my_helper_bot", "state": "maybe"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_one_bots_entry_can_be_inspected(self, live_daemon, client, in_thread, bots): + page = await result(client, in_thread, "bot.attach.list", {"bot": "@my_helper_bot"}) + assert page["items"][0]["bot_id"] == HELPER + + +class TestBotRecent: + async def test_turning_the_feature_off(self, live_daemon, client, in_thread, bots): + recent = await result(client, in_thread, "bot.recent.set", {"state": "off"}) + assert recent["enabled"] is False + assert bots.top_peers_enabled is False + + async def test_forgetting_one_bot(self, live_daemon, client, in_thread, bots): + recent = await result(client, in_thread, "bot.recent.set", {"forget": "@my_helper_bot"}) + assert recent["forgotten"] == [HELPER] + + async def test_doing_nothing_is_a_usage_error(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "bot.recent.set", {}) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# bot report / ad / game / score +# --------------------------------------------------------------------------- + + +class TestBotReportAndAds: + async def test_the_first_report_step_returns_the_option_tree( + self, live_daemon, client, in_thread, bots + ): + from telethon.tl import types + + bots.raw["ReportRequest"] = types.ReportResultChooseOption( + title="What is wrong?", + options=[types.MessageReportOption(text="Spam", option=b"spam")], + ) + outcome = await result(client, in_thread, "bot.report", {"bot": "@my_helper_bot"}) + assert outcome["result"] == "choose_option" + assert outcome["options"][0] == {"text": "Spam", "option": "spam"} + + async def test_an_ephemeral_report_exits_13(self, live_daemon, client, in_thread, bots): + error = await fails( + client, in_thread, "bot.report", {"bot": "@my_helper_bot", "ephemeral": 3} + ) + assert error.exit_code == EXIT_INDETERMINATE + + async def test_ads_are_listed_without_reporting_an_impression( + self, live_daemon, client, in_thread, bots + ): + from telethon.tl import types + + bots.raw["GetSponsoredMessagesRequest"] = types.messages.SponsoredMessages( + messages=[ + types.SponsoredMessage( + random_id=b"ad1", + title="Sponsor", + message="An ad", + button_text="Open", + url="https://x.example", + can_report=True, + ) + ], + chats=[], + users=[], + ) + page = await result(client, in_thread, "bot.ad.list", {"bot": "@my_helper_bot"}) + assert page["items"][0]["message"] == "An ad" + assert not bots.called("ViewSponsoredMessageRequest") + + async def test_reading_an_ad_records_the_view_and_optionally_the_click( + self, live_daemon, client, in_thread, bots + ): + read = await result( + client, in_thread, "bot.ad.read", {"random_id": "str:ad1", "click": True} + ) + assert read["viewed"] is True and read["clicked"] is True + assert bots.called("ViewSponsoredMessageRequest")[0].random_id == b"ad1" + assert bots.called("ClickSponsoredMessageRequest") + + async def test_reporting_an_ad_walks_the_same_tree(self, live_daemon, client, in_thread, bots): + outcome = await result(client, in_thread, "bot.ad.report", {"random_id": "str:ad1"}) + assert outcome["result"] == "reported" + + +class TestBotGames: + async def test_an_unavailable_emoji_game_says_so(self, live_daemon, client, in_thread, bots): + game = await result(client, in_thread, "bot.game.get", {"emoji": "🎲"}) + assert game == {"emoticon": "🎲"} + + async def test_a_live_emoji_game_reports_its_parameters( + self, live_daemon, client, in_thread, bots + ): + from telethon.tl import types + + bots.emoji_game = types.messages.EmojiGameDiceInfo( + game_hash="h", prev_stake=5, current_streak=2, params=[1, 2, 3] + ) + game = await result(client, in_thread, "bot.game.get", {}) + assert game["available"] is True + assert game["params"] == [1, 2, 3] + + async def test_sending_a_game_needs_a_bot_session(self, live_daemon, client, in_thread, bots): + error = await fails( + client, + in_thread, + "bot.game.send", + {"bot": "@my_helper_bot", "short_name": "tetris", "chat": "@alice"}, + ) + assert error.exit_code == EXIT_AUTH + + async def test_a_game_goes_out_as_input_media_game( + self, live_daemon, client, in_thread, bot_session + ): + sent = await result( + client, + in_thread, + "bot.game.send", + {"bot": "@my_helper_bot", "short_name": "tetris", "chat": "@alice"}, + ) + assert sent["short_name"] == "tetris" + media = bot_session.called("SendMediaRequest")[0].media + assert type(media).__name__ == "InputMediaGame" + assert media.id.short_name == "tetris" + + async def test_a_game_without_a_chat_is_a_usage_error( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, in_thread, "bot.game.send", {"bot": "@my_helper_bot", "short_name": "tetris"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_high_scores_come_back_in_order(self, live_daemon, client, in_thread, bots): + from telethon.tl import types + + bots.high_scores = [types.HighScore(pos=1, user_id=ALICE, score=900)] + page = await result(client, in_thread, "bot.score.list", {"chat": "@alice", "msg_id": 12}) + assert page["items"] == [{"position": 1, "user_id": ALICE, "score": 900}] + + async def test_an_inline_score_table_is_fetched_from_the_messages_own_dc( + self, live_daemon, client, in_thread, bots + ): + await result(client, in_thread, "bot.score.list", {"inline_id": "3:99:77"}) + borrowed = bots.called("borrow_exported_sender") + assert borrowed and borrowed[0]["dc_id"] == 3 + + async def test_a_malformed_inline_id_is_a_usage_error( + self, live_daemon, client, in_thread, bots + ): + error = await fails(client, in_thread, "bot.score.list", {"inline_id": "not-an-id"}) + assert error.exit_code == EXIT_USAGE + + async def test_setting_a_score_needs_a_player( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, in_thread, "bot.score.set", {"chat": "@alice", "msg_id": 12, "score": 10} + ) + assert error.exit_code == EXIT_USAGE + + async def test_allow_lower_maps_to_the_servers_force_flag( + self, live_daemon, client, in_thread, bot_session + ): + await result( + client, + in_thread, + "bot.score.set", + { + "chat": "@alice", + "msg_id": 12, + "user": "@alice", + "score": 10, + "allow_lower": True, + }, + ) + assert bot_session.called("SetGameScoreRequest")[0].force is True + + +# --------------------------------------------------------------------------- +# The layer-229 surface +# --------------------------------------------------------------------------- + + +class TestLayerGaps: + """Registered and refused. 'Unavailable' is not 'no such command'.""" + + @pytest.mark.parametrize( + "op,request_body", + [ + ("bot.ephemeral.send", {"chat": "@alice", "text": "hi"}), + ("bot.ephemeral.delete", {"chat": "@alice", "id": [1]}), + ("bot.welcome.list", {"chat": "@alice"}), + ("bot.welcome.set", {"chat": "@alice", "text": "Welcome"}), + ("bot.welcome.delete", {"chat": "@alice", "id": [1]}), + ], + ) + async def test_each_exits_13_with_not_supported( + self, live_daemon, client, in_thread, bots, op, request_body + ): + error = await fails(client, in_thread, op, request_body) + assert error.exit_code == EXIT_INDETERMINATE + assert error.code == "NOT_SUPPORTED" + + def test_they_are_registered_so_capabilities_can_name_them(self): + from tlgr.registry import REGISTRY + + for op in ( + "bot.ephemeral.send", + "bot.ephemeral.delete", + "bot.welcome.list", + "bot.welcome.set", + "bot.welcome.delete", + ): + assert op in REGISTRY + + +# --------------------------------------------------------------------------- +# inline +# --------------------------------------------------------------------------- + + +@pytest.fixture +def inline(bots): + """Two results the fake's inline bot answers with.""" + from telethon.tl import types + + bots.inline_results = [ + types.BotInlineResult( + id="r1", + type="article", + send_message=types.BotInlineMessageText(message="first"), + title="First", + url="https://x.example/1", + ), + types.BotInlineMediaResult( + id="r2", + type="gif", + send_message=types.BotInlineMessageMediaAuto(message=""), + title="Second", + ), + ] + return bots + + +class TestInlineQuery: + async def test_results_carry_their_index_query_id_and_shape( + self, live_daemon, client, in_thread, inline + ): + page = await call(client, in_thread, "inline.query", {"bot": "@gifbot", "query": "cat"}) + rows = page["result"] + assert [row["n"] for row in rows] == [0, 1] + assert rows[0]["content"] == "url" + assert rows[1]["content"] == "media" + assert rows[0]["send_message"] == "text" + assert rows[0]["query_id"] == "987654321" + + async def test_the_chat_is_passed_to_the_bot(self, live_daemon, client, in_thread, inline): + await call( + client, + in_thread, + "inline.query", + {"bot": "@gifbot", "query": "cat", "chat": str(GROUP_ID)}, + ) + request = inline.called("GetInlineBotResultsRequest")[0] + assert type(request.peer).__name__ == "InputPeerChannel" + + async def test_the_bots_own_offset_is_fed_straight_back( + self, live_daemon, client, in_thread, inline + ): + from telethon.tl import types + + inline.raw["GetInlineBotResultsRequest"] = types.messages.BotResults( + query_id=1, + results=list(inline.inline_results), + cache_time=300, + users=[], + next_offset="opaque-42", + ) + page = await call(client, in_thread, "inline.query", {"bot": "@gifbot"}) + assert page["result"][0]["next_offset"] == "opaque-42" + assert page["page"]["has_more"] is True + await call( + client, + in_thread, + "inline.query", + {"bot": "@gifbot"}, + cursor=page["page"]["next_cursor"], + ) + assert inline.called("GetInlineBotResultsRequest")[-1].offset == "opaque-42" + + async def test_a_geo_query_attaches_the_point(self, live_daemon, client, in_thread, inline): + await call( + client, + in_thread, + "inline.query", + {"bot": "@gifbot", "lat": 51.5, "lon": -0.1, "accuracy": 40}, + ) + point = inline.called("GetInlineBotResultsRequest")[-1].geo_point + assert (point.lat, point.long, point.accuracy_radius) == (51.5, -0.1, 40) + + async def test_a_silent_bot_is_an_empty_page_and_not_a_failure( + self, live_daemon, client, in_thread, inline + ): + from telethon.errors import BotResponseTimeoutError + + inline.fail_next("GetInlineBotResultsRequest", BotResponseTimeoutError(request=None)) + envelope = await call(client, in_thread, "inline.query", {"bot": "@gifbot"}) + assert envelope["result"] == [] + assert any("offline" in w for w in envelope["meta"]["warnings"]) + + +class TestInlineSearch: + async def test_the_bot_username_comes_from_the_server_config( + self, live_daemon, client, in_thread, inline + ): + from fake_telethon import make_user + + inline.gif_search_username = "housegif" + gifbot = make_user(70001, username="housegif", first="GIF") + gifbot.bot = True + inline.add_user(gifbot) + page = await call(client, in_thread, "inline.search", {"kind": "gif", "query": "cat"}) + assert page["result"][0]["id"] == "r1" + assert inline.called("GetConfigRequest") + + async def test_a_venue_search_needs_coordinates(self, live_daemon, client, in_thread, inline): + error = await fails(client, in_thread, "inline.search", {"kind": "venue"}) + assert error.exit_code == EXIT_USAGE + + async def test_an_unknown_kind_is_a_usage_error(self, live_daemon, client, in_thread, inline): + error = await fails(client, in_thread, "inline.search", {"kind": "sounds"}) + assert error.exit_code == EXIT_USAGE + + +class TestInlineSend: + async def test_pick_re_runs_the_query_so_the_pair_is_fresh( + self, live_daemon, client, in_thread, inline + ): + sent = await result( + client, + in_thread, + "inline.send", + {"bot": "@gifbot", "query": "cat", "chat": "@alice", "pick": "1"}, + ) + assert sent["result_id"] == "r2" + assert inline.called("GetInlineBotResultsRequest") + request = inline.called("SendInlineBotResultRequest")[0] + assert request.query_id == 987654321 + + async def test_a_result_id_may_be_picked_by_name(self, live_daemon, client, in_thread, inline): + sent = await result( + client, + in_thread, + "inline.send", + {"bot": "@gifbot", "chat": "@alice", "pick": "r1"}, + ) + assert sent["result_id"] == "r1" + + async def test_an_unknown_pick_is_not_found(self, live_daemon, client, in_thread, inline): + error = await fails( + client, + in_thread, + "inline.send", + {"bot": "@gifbot", "chat": "@alice", "pick": "nope"}, + ) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_a_query_id_without_a_result_id_is_a_usage_error( + self, live_daemon, client, in_thread, inline + ): + error = await fails( + client, + in_thread, + "inline.send", + {"bot": "@gifbot", "chat": "@alice", "query_id": "1"}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_a_supplied_pair_is_used_verbatim(self, live_daemon, client, in_thread, inline): + await result( + client, + in_thread, + "inline.send", + {"bot": "@gifbot", "chat": "@alice", "query_id": "555", "result_id": "r9"}, + ) + request = inline.called("SendInlineBotResultRequest")[0] + assert (request.query_id, request.id) == (555, "r9") + assert not inline.called("GetInlineBotResultsRequest") + + async def test_paid_stars_are_passed_through_as_the_agreed_amount( + self, live_daemon, client, in_thread, inline + ): + await result( + client, + in_thread, + "inline.send", + { + "bot": "@gifbot", + "chat": "@alice", + "query_id": "555", + "result_id": "r9", + "paid_stars": 5, + }, + ) + assert inline.called("SendInlineBotResultRequest")[0].allow_paid_stars == 5 + + async def test_a_quick_reply_shortcut_is_carried(self, live_daemon, client, in_thread, inline): + sent = await result( + client, + in_thread, + "inline.send", + { + "bot": "@gifbot", + "chat": "@alice", + "query_id": "555", + "result_id": "r9", + "quick_reply": "hello", + }, + ) + assert sent["quick_reply"] == "hello" + assert inline.called("SendInlineBotResultRequest")[0].quick_reply_shortcut is not None + + +class TestInlineEditAndPrepared: + async def test_an_edit_is_routed_to_the_messages_own_dc( + self, live_daemon, client, in_thread, bot_session + ): + edited = await result( + client, + in_thread, + "inline.edit", + {"inline_msg_id": "4:12:34", "text": "Updated"}, + ) + assert edited == {"inline_msg_id": "4:12:34", "edited": True} + borrowed = bot_session.called("borrow_exported_sender") + assert borrowed and borrowed[0]["dc_id"] == 4 + + async def test_an_edit_needs_a_bot_session(self, live_daemon, client, in_thread, bots): + error = await fails( + client, in_thread, "inline.edit", {"inline_msg_id": "4:12:34", "text": "x"} + ) + assert error.exit_code == EXIT_AUTH + + async def test_a_prepared_message_reports_the_chat_types_it_allows( + self, live_daemon, client, in_thread, inline + ): + from telethon.tl import types + + inline.prepared_peer_types = [types.InlineQueryPeerTypePM()] + prepared = await result( + client, in_thread, "inline.prepared.get", {"bot": "@my_helper_bot", "id": "p1"} + ) + assert prepared["peer_types"] == ["pm"] + assert prepared["result"]["id"] == "r1" + + async def test_sending_outside_those_chat_types_is_refused( + self, live_daemon, client, in_thread, inline + ): + from telethon.tl import types + + inline.prepared_peer_types = [types.InlineQueryPeerTypeBroadcast()] + error = await fails( + client, + in_thread, + "inline.prepared.send", + {"bot": "@my_helper_bot", "id": "p1", "chat": "@alice"}, + ) + assert error.exit_code == EXIT_USAGE + assert not inline.called("SendInlineBotResultRequest") + + async def test_sending_inside_them_works(self, live_daemon, client, in_thread, inline): + from telethon.tl import types + + inline.prepared_peer_types = [types.InlineQueryPeerTypePM()] + sent = await result( + client, + in_thread, + "inline.prepared.send", + {"bot": "@my_helper_bot", "id": "p1", "chat": "@alice"}, + ) + assert sent["result_id"] == "r1" + + async def test_saving_a_prepared_message_needs_a_bot_session( + self, live_daemon, client, in_thread, bots, tmp_path + ): + path = tmp_path / "r.json" + path.write_text('{"id":"r1","type":"article","message":{"text":"hi"}}') + error = await fails( + client, + in_thread, + "inline.prepared.save", + {"user": "@alice", "result": str(path)}, + ) + assert error.exit_code == EXIT_AUTH + + async def test_saving_returns_an_id( + self, live_daemon, client, in_thread, bot_session, tmp_path + ): + path = tmp_path / "r.json" + path.write_text('{"id":"r1","type":"article","message":{"text":"hi"}}') + saved = await result( + client, + in_thread, + "inline.prepared.save", + {"user": "@alice", "result": str(path), "peer_types": ["pm", "group"]}, + ) + assert saved["id"] == "prep1" + request = bot_session.called("SavePreparedInlineMessageRequest")[0] + assert len(request.peer_types) == 2 + + async def test_an_unknown_peer_type_is_a_usage_error( + self, live_daemon, client, in_thread, bot_session, tmp_path + ): + path = tmp_path / "r.json" + path.write_text('{"id":"r1","type":"article","message":{"text":"hi"}}') + error = await fails( + client, + in_thread, + "inline.prepared.save", + {"user": "@alice", "result": str(path), "peer_types": ["telepathy"]}, + ) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# webapp +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mini_app(bots): + from telethon.tl import types + + bots.bot_apps["shop"] = types.messages.BotApp( + app=types.BotApp( + id=1, + access_hash=2, + short_name="shop", + title="Shop", + description="Buy things", + photo=types.PhotoEmpty(id=0), + hash=0, + ), + request_write_access=True, + has_settings=True, + ) + bots.bots[HELPER]["app_settings"] = types.BotAppSettings( + placeholder_path=b"12345", background_color=0xFFFFFF + ) + return bots + + +class TestWebAppGet: + async def test_the_manifest_reports_the_placeholder_length_not_its_bytes( + self, live_daemon, client, in_thread, mini_app + ): + info = await result( + client, in_thread, "webapp.get", {"bot": "@my_helper_bot", "short_name": "shop"} + ) + assert info["title"] == "Shop" + assert info["placeholder_path"] == 5 + assert info["link"] == "https://t.me/my_helper_bot/shop" + + async def test_an_unknown_app_is_not_found(self, live_daemon, client, in_thread, mini_app): + error = await fails( + client, in_thread, "webapp.get", {"bot": "@my_helper_bot", "short_name": "nope"} + ) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_a_pending_peer_request_is_shown(self, live_daemon, client, in_thread, mini_app): + info = await result( + client, + in_thread, + "webapp.get", + {"bot": "@my_helper_bot", "button_request": "req1"}, + ) + assert info["button_request"]["button_id"] == 7 + + +class TestWebAppOpen: + async def test_the_url_comes_back_with_the_session_it_needs_kept_alive( + self, live_daemon, client, in_thread, bots + ): + envelope = await call( + client, in_thread, "webapp.open", {"bot": "@my_helper_bot", "main": True} + ) + session = envelope["result"] + assert session["kind"] == "main" + assert session["url"] == bots.webapp_url + assert session["needs_prolong"] is True + assert session["prolong_every"] == 60 + assert any("credential" in w for w in envelope["meta"]["warnings"]) + + async def test_a_direct_link_app_has_no_session_to_keep( + self, live_daemon, client, in_thread, mini_app + ): + session = await result( + client, in_thread, "webapp.open", {"bot": "@my_helper_bot", "app": "shop"} + ) + assert session["kind"] == "direct-link" + assert "query_id" not in session + + async def test_an_inactive_app_is_confirmed_before_opening( + self, live_daemon, client, in_thread, mini_app + ): + from telethon.tl import types + + mini_app.bot_apps["shop"] = types.messages.BotApp( + app=mini_app.bot_apps["shop"].app, inactive=True + ) + error = await fails( + client, in_thread, "webapp.open", {"bot": "@my_helper_bot", "app": "shop"} + ) + assert error.exit_code == EXIT_PERMISSION + session = await result( + client, + in_thread, + "webapp.open", + {"bot": "@my_helper_bot", "app": "shop", "open_inactive": True}, + ) + assert session["url"] + + async def test_write_access_is_a_separate_deliberate_call( + self, live_daemon, client, in_thread, bots + ): + await result(client, in_thread, "webapp.open", {"bot": "@my_helper_bot", "main": True}) + assert not bots.called("AllowSendMessageRequest") + await result( + client, + in_thread, + "webapp.open", + {"bot": "@my_helper_bot", "main": True, "allow_write": True}, + ) + assert bots.called("AllowSendMessageRequest") + + async def test_a_simple_view_uses_the_simple_request( + self, live_daemon, client, in_thread, bots + ): + session = await result( + client, in_thread, "webapp.open", {"bot": "@my_helper_bot", "side_menu": True} + ) + assert session["kind"] == "side-menu" + assert bots.called("RequestSimpleWebViewRequest")[0].from_side_menu is True + + async def test_a_menu_button_app_needs_the_bot_to_have_one( + self, live_daemon, client, in_thread, bots + ): + error = await fails( + client, in_thread, "webapp.open", {"bot": "@my_helper_bot", "menu": True} + ) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_the_chat_join_view_exits_13(self, live_daemon, client, in_thread, bots): + error = await fails( + client, + in_thread, + "webapp.open", + {"bot": "@my_helper_bot", "join_query_id": "j1"}, + ) + assert error.exit_code == EXIT_INDETERMINATE + + async def test_the_theme_defaults_to_something_renderable( + self, live_daemon, client, in_thread, bots + ): + await result(client, in_thread, "webapp.open", {"bot": "@my_helper_bot", "main": True}) + assert "bg_color" in bots.called("RequestMainWebViewRequest")[0].theme_params.data + + +class TestWebAppRest: + async def test_watching_prolongs_and_ends_when_the_session_dies( + self, live_daemon, client, in_thread, bots + ): + from telethon.errors import RPCError + + class QueryIdInvalidError(RPCError): + def __init__(self) -> None: + super().__init__(request=None, message="QUERY_ID_INVALID", code=400) + + bots.fail_next("ProlongWebViewRequest", QueryIdInvalidError()) + frames = await in_thread( + lambda: list( + client.op_stream( + "webapp.watch", + {"bot": "@my_helper_bot", "query_id": "987654321"}, + account="work", + ) + ) + ) + rows = [f["data"] for f in frames if f["type"] == "item"] + assert rows and rows[-1]["alive"] is False + assert frames[-1]["type"] == "end" + + async def test_watching_needs_a_numeric_query_id(self, live_daemon, client, in_thread, bots): + frames = await in_thread( + lambda: list( + client.op_stream( + "webapp.watch", + {"bot": "@my_helper_bot", "query_id": "abc"}, + account="work", + ) + ) + ) + end = frames[-1] + assert end["type"] == "end" and end["ok"] is False + assert end["error"]["code"] == "USAGE" + + async def test_sending_data_is_capped_at_four_kilobytes( + self, live_daemon, client, in_thread, bots + ): + error = await fails( + client, + in_thread, + "webapp.send", + {"bot": "@my_helper_bot", "button_text": "Order", "data": "x" * 5000}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_sending_data_reaches_the_bot(self, live_daemon, client, in_thread, bots): + sent = await result( + client, + in_thread, + "webapp.send", + {"bot": "@my_helper_bot", "button_text": "Order", "data": '{"n":1}'}, + ) + assert sent == {"bot_id": HELPER, "sent": True} + assert bots.called("SendWebViewDataRequest")[0].button_text == "Order" + + async def test_a_custom_method_passes_its_json_through( + self, live_daemon, client, in_thread, bots + ): + bots.custom_response = '{"orders":[1,2]}' + answer = await result( + client, + in_thread, + "webapp.invoke", + {"bot": "@my_helper_bot", "method": "getOrders", "params": '{"page":1}'}, + ) + assert answer["result"] == {"orders": [1, 2]} + + async def test_a_download_is_checked_and_not_fetched( + self, live_daemon, client, in_thread, bots + ): + checked = await result( + client, + in_thread, + "webapp.download", + { + "bot": "@my_helper_bot", + "file_name": "invoice.pdf", + "url": "https://x.example/i.pdf", + }, + ) + assert checked["allowed"] is True + assert "downloaded" not in checked + assert "path" not in checked + + async def test_a_download_the_server_refuses_is_never_fetched( + self, live_daemon, client, in_thread, bots + ): + bots.download_allowed = False + error = await fails( + client, + in_thread, + "webapp.download", + { + "bot": "@my_helper_bot", + "file_name": "x.bin", + "url": "https://x.example/x.bin", + "fetch": True, + }, + ) + assert error.exit_code == EXIT_PERMISSION + + async def test_only_https_is_fetched(self, live_daemon, client, in_thread, bots): + error = await fails( + client, + in_thread, + "webapp.download", + { + "bot": "@my_helper_bot", + "file_name": "x.bin", + "url": "http://x.example/x.bin", + "fetch": True, + }, + ) + assert error.exit_code == EXIT_PERMISSION + + +# --------------------------------------------------------------------------- +# payment +# --------------------------------------------------------------------------- + + +class TestPaymentForm: + async def test_a_form_is_readable_and_says_it_cannot_be_paid_here( + self, live_daemon, client, in_thread, bots + ): + form = await result(client, in_thread, "payment.form.get", {"slug": "tshirt"}) + assert form["title"] == "T-shirt" + assert form["currency"] == "USD" + assert form["total_amount"] == 1999 + assert form["payable_here"] is False + assert "never spends money" in form["reason"] + + async def test_exactly_one_invoice_kind_may_be_named( + self, live_daemon, client, in_thread, bots + ): + error = await fails(client, in_thread, "payment.form.get", {"slug": "a", "stars": 100}) + assert error.exit_code == EXIT_USAGE + assert (await fails(client, in_thread, "payment.form.get", {})).exit_code == EXIT_USAGE + + async def test_a_message_invoice_wants_chat_and_id(self, live_daemon, client, in_thread, bots): + await result(client, in_thread, "payment.form.get", {"message": "@alice:12"}) + invoice = bots.called("GetPaymentFormRequest")[0].invoice + assert type(invoice).__name__ == "InputInvoiceMessage" + assert invoice.msg_id == 12 + + async def test_a_malformed_message_reference_is_a_usage_error( + self, live_daemon, client, in_thread, bots + ): + error = await fails(client, in_thread, "payment.form.get", {"message": "@alice"}) + assert error.exit_code == EXIT_USAGE + + async def test_a_stars_topup_form_is_readable(self, live_daemon, client, in_thread, bots): + await result(client, in_thread, "payment.form.get", {"stars": 100}) + invoice = bots.called("GetPaymentFormRequest")[0].invoice + assert type(invoice).__name__ == "InputInvoiceStars" + + +class TestPaymentRest: + async def test_a_receipt_is_read_only(self, live_daemon, client, in_thread, bots): + receipt = await result( + client, in_thread, "payment.receipt.get", {"chat": "@alice", "msg_id": 42} + ) + assert receipt["total_amount"] == 1999 + assert receipt["credentials_title"] == "Visa •1234" + + async def test_saved_info_never_carries_a_card_number( + self, live_daemon, client, in_thread, bots + ): + from telethon.tl import types + + bots.saved_order_info = types.PaymentRequestedInfo(name="Alice", email="a@example.org") + info = await result(client, in_thread, "payment.info.get", {}) + assert info["name"] == "Alice" + assert info["has_saved_credentials"] is True + assert "number" not in str(info) + + async def test_clearing_through_the_read_command(self, live_daemon, client, in_thread, bots): + info = await result( + client, in_thread, "payment.info.get", {"clear": True, "credentials": True} + ) + assert info["cleared"] is True + assert bots.saved_credentials is False + + async def test_deleting_needs_something_to_delete(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "payment.info.delete", {}) + assert error.exit_code == EXIT_USAGE + + async def test_deleting_cards_and_info_separately(self, live_daemon, client, in_thread, bots): + cleared = await result(client, in_thread, "payment.info.delete", {"credentials": True}) + assert cleared == {"credentials_cleared": True, "info_cleared": False} + + async def test_a_bin_lookup_names_the_issuer(self, live_daemon, client, in_thread, bots): + card = await result(client, in_thread, "payment.card.get", {"number": "4111 11"}) + assert card["title"] == "Example Bank" + assert bots.called("GetBankCardDataRequest")[0].number == "411111" + + async def test_exporting_an_invoice_link(self, live_daemon, client, in_thread, bot_session): + link = await result( + client, + in_thread, + "payment.invoice.export", + { + "title": "Shirt", + "description": "A shirt", + "currency": "USD", + "prices": "Shirt:1999", + "payload": "order-1", + }, + ) + assert link == {"url": "https://t.me/$abc123", "slug": "$abc123"} + + async def test_an_invoice_needs_all_its_required_parts( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails(client, in_thread, "payment.invoice.export", {"title": "Shirt"}) + assert error.exit_code == EXIT_USAGE + + async def test_a_malformed_price_list_is_a_usage_error( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, + in_thread, + "payment.invoice.export", + { + "title": "Shirt", + "description": "A shirt", + "currency": "USD", + "prices": "Shirt", + "payload": "order-1", + }, + ) + assert error.exit_code == EXIT_USAGE + + async def test_an_invoice_only_goes_to_a_private_chat( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, + in_thread, + "payment.invoice.send", + { + "user": str(GROUP_ID), + "title": "Shirt", + "description": "A shirt", + "currency": "USD", + "prices": "Shirt:1999", + "payload": "order-1", + }, + ) + assert error.exit_code == EXIT_USAGE + + async def test_sending_an_invoice_builds_input_media_invoice( + self, live_daemon, client, in_thread, bot_session + ): + sent = await result( + client, + in_thread, + "payment.invoice.send", + { + "user": "@alice", + "title": "Shirt", + "description": "A shirt", + "currency": "USD", + "prices": "Shirt:1999", + "payload": "order-1", + }, + ) + assert sent["total_amount"] == 1999 + media = bot_session.called("SendMediaRequest")[0].media + assert type(media).__name__ == "InputMediaInvoice" + assert media.payload == b"order-1" + + +class TestSubscriptions: + @pytest.fixture + def subscribed(self, bots): + from telethon.tl import types + + bots.subscriptions = [ + types.StarsSubscription( + id="sub1", + peer=types.PeerUser(user_id=HELPER), + until_date=None, + pricing=types.StarsSubscriptionPricing(period=2592000, amount=50), + can_refulfill=True, + missing_balance=True, + ) + ] + return bots + + async def test_a_lapsed_subscription_says_the_server_would_refulfil_it( + self, live_daemon, client, in_thread, subscribed + ): + page = await call(client, in_thread, "payment.subscription.list", {}) + row = page["result"][0] + assert row["can_refulfill"] is True + assert row["missing_balance"] is True + assert row["pricing"] == {"period": 2592000, "amount": 50} + + async def test_cancelling_flips_the_flag(self, live_daemon, client, in_thread, subscribed): + changed = await result( + client, + in_thread, + "payment.subscription.set", + {"subscription_id": "sub1", "auto_renew": "off"}, + ) + assert changed["cancelled"] is True + assert subscribed.subscriptions[0].canceled is True + + async def test_resuming_sends_canceled_false(self, live_daemon, client, in_thread, subscribed): + await result( + client, + in_thread, + "payment.subscription.set", + {"subscription_id": "sub1", "auto_renew": "on"}, + ) + assert subscribed.called("ChangeStarsSubscriptionRequest")[0].canceled is None + assert subscribed.subscriptions[0].canceled is False + + async def test_the_bot_side_needs_both_halves( + self, live_daemon, client, in_thread, bot_session + ): + error = await fails( + client, + in_thread, + "payment.subscription.set", + {"auto_renew": "off", "user": "@alice"}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_neither_side_is_a_usage_error(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "payment.subscription.set", {"auto_renew": "off"}) + assert error.exit_code == EXIT_USAGE + + +class TestThePaymentPolicy: + def test_no_operation_can_spend_money(self): + """Written against the registry, not against a list of commands. + + A future PR that adds `payments.sendPaymentForm` behind any flag fails + here, which is the point: the policy is a property of the surface, not + a promise in a docstring. + """ + import inspect + + from tlgr.registry import REGISTRY + + forbidden = ( + "SendPaymentFormRequest", + "SendStarsFormRequest", + "ValidateRequestedInfoRequest", + "FulfillStarsSubscriptionRequest", + "AssignAppStoreTransactionRequest", + "AssignPlayMarketTransactionRequest", + ) + for spec in REGISTRY.values(): + 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}" diff --git a/tlgr/models/bot.py b/tlgr/models/bot.py index 2bbfc46..139c768 100644 --- a/tlgr/models/bot.py +++ b/tlgr/models/bot.py @@ -132,9 +132,14 @@ class BotCommand(Model): class MenuButton(Model): - """The button left of the message input: commands, a mini app, or default.""" + """The button left of the message input: commands, a mini app, or default. - kind: str = "commands" + `kind` has no default: "commands" is a real answer the server gives, and a + field omitted because it happened to equal a default would make "the bot + shows its commands" indistinguishable from "tlgr did not look". + """ + + kind: str = "" text: str | None = None url: str | None = None user_id: int | None = None @@ -166,9 +171,14 @@ class StarRefProgram(Model): class BotAccess(Model): - """Who may use a managed bot.""" + """Who may use a managed bot. + + `restricted` is a tri-state on the wire on purpose: `omit_defaults` would + drop a plain `False`, and "anyone may use this bot" is an answer, not the + absence of one. + """ - restricted: bool = False + restricted: bool | None = None allowed_users: list[int] = [] allowed_chats: list[int] = [] @@ -226,14 +236,15 @@ class BotIds(Model): mtproto_id: int = 0 bot_api_id: int = 0 - kind: str = "user" + kind: str = "" has_access_hash: bool = False username: str | None = None class BotUsernameCheck(Model): username: str = "" - available: bool = False + #: None only if nothing was checked; a real answer is always emitted. + available: bool | None = None reason: str | None = None @@ -274,8 +285,8 @@ class DefaultRights(Model): class BotPermission(Model): bot_id: int = 0 - can_send_messages: bool = False - emoji_status_allowed: bool = False + can_send_messages: bool | None = None + emoji_status_allowed: bool | None = None key: str | None = None state: str | None = None already: bool = False @@ -283,7 +294,7 @@ class BotPermission(Model): class BotVerified(Model): peer_id: int = 0 - verified: bool = False + verified: bool | None = None description: str | None = None @@ -463,8 +474,8 @@ class AttachMenuBot(Model): class ToggledAttachMenu(Model): bot_id: int = 0 - installed: bool = False - write_allowed: bool = False + installed: bool | None = None + write_allowed: bool | None = None class RecentBots(Model): @@ -475,8 +486,8 @@ class RecentBots(Model): class SponsoredRead(Model): random_id: str = "" - viewed: bool = False - clicked: bool = False + viewed: bool | None = None + clicked: bool | None = None class ReportOutcome(Model): diff --git a/tlgr/models/inline.py b/tlgr/models/inline.py index 356789c..ee8b520 100644 --- a/tlgr/models/inline.py +++ b/tlgr/models/inline.py @@ -31,7 +31,9 @@ class InlineResult(Model): """One result out of `messages.getInlineBotResults`.""" #: Flat row-major index within the page — what `inline send --pick` takes. - n: int = 0 + #: -1 rather than 0 as the default, because `omit_defaults` would + #: otherwise drop the first result's index and only the first result's. + n: int = -1 id: str = "" type: str = "" title: str | None = None @@ -39,7 +41,8 @@ class InlineResult(Model): url: str | None = None thumb: str | None = None #: `url` for a `botInlineResult`, `media` for a `botInlineMediaResult`. - content: str = "url" + #: Always emitted: it is the one field that says where the bytes live. + content: str = "" #: The kind of message this result would send: text, media_auto, geo, #: venue, contact, invoice, webpage, game or rich. send_message: str | None = None diff --git a/tlgr/models/message.py b/tlgr/models/message.py index 8956c7f..07077fc 100644 --- a/tlgr/models/message.py +++ b/tlgr/models/message.py @@ -146,8 +146,10 @@ class Button(Model): type: str #: Flat row-major index across the whole markup. `bot press --button ` #: takes exactly this number, which is why it is printed rather than left - #: for the caller to count. - n: int = 0 + #: for the caller to count. The default is -1 rather than 0 because + #: `omit_defaults` would drop the first button's index otherwise, and an + #: index that disappears for exactly one button is worse than none. + n: int = -1 data_b64: str | None = None url: str | None = None query: str | None = None diff --git a/tlgr/models/payment.py b/tlgr/models/payment.py index f767085..9689ea1 100644 --- a/tlgr/models/payment.py +++ b/tlgr/models/payment.py @@ -85,8 +85,9 @@ class PaymentForm(Model): can_save_credentials: bool = False password_missing: bool = False expires_at: str | None = None - #: Always false. `reason` says which policy refuses to charge here. - payable_here: bool = False + #: Always false, and always *emitted*: an omitted flag would leave a + #: caller to infer the policy, and `reason` says which one it is. + payable_here: bool | None = None reason: str = "" @@ -112,20 +113,20 @@ class Receipt(Model): class PaymentInfo(Model): """My saved order information and saved cards. Never a card number.""" - has_saved_credentials: bool = False + has_saved_credentials: bool | None = None credentials: list[dict[str, Any]] = [] saved_info: dict[str, Any] | None = None name: str | None = None phone: str | None = None email: str | None = None shipping: dict[str, Any] | None = None - has_saved_info: bool = False + has_saved_info: bool | None = None cleared: bool = False class PaymentInfoCleared(Model): - credentials_cleared: bool = False - info_cleared: bool = False + credentials_cleared: bool | None = None + info_cleared: bool | None = None class InvoiceLink(Model): @@ -152,11 +153,11 @@ class StarSubscription(Model): until_date: str | None = None until_date_unix: int | None = None pricing: dict[str, int] | None = None - cancelled: bool = False + cancelled: bool | None = None #: Re-joining a lapsed subscription debits Stars, so tlgr reports that the #: server would allow it and still refuses to do it. - can_refulfill: bool = False - missing_balance: bool = False + can_refulfill: bool | None = None + missing_balance: bool | None = None invoice_slug: str | None = None chat_invite_hash: str | None = None title: str | None = None @@ -167,5 +168,5 @@ class SubscriptionChange(Model): subscription_id: str | None = None user_id: int | None = None charge_id: str | None = None - cancelled: bool = False + cancelled: bool | None = None until_date: str | None = None diff --git a/tlgr/models/webapp.py b/tlgr/models/webapp.py index 60afc22..34a0f26 100644 --- a/tlgr/models/webapp.py +++ b/tlgr/models/webapp.py @@ -75,10 +75,10 @@ class WebAppSent(Model): class WebAppDownload(Model): - allowed: bool = False + allowed: bool | None = None file_name: str = "" url: str = "" - downloaded: bool = False + downloaded: bool | None = None path: str | None = None diff --git a/tlgr/ops/bot.py b/tlgr/ops/bot.py index c32b3c0..00c5732 100644 --- a/tlgr/ops/bot.py +++ b/tlgr/ops/bot.py @@ -149,7 +149,9 @@ def _menu_button(button: Any, *, user_id: int | None = None) -> MenuButton | Non def _commands( raw: Any, *, bot_id: int, scope: str | None = None, lang: str | None = None ) -> list[BotCommand]: - entries = list(getattr(raw, "commands", None) or raw or []) + entries = list( + (getattr(raw, "commands", None) or []) if hasattr(raw, "commands") else (raw or []) + ) names = {str(getattr(c, "command", "") or "") for c in entries} return [ BotCommand( @@ -198,18 +200,46 @@ def _verification(full: Any, user: Any) -> BotVerification | None: def _access(settings: Any) -> BotAccess: + """`bots.accessSettings` as the model. The allow-list is `add_users`.""" return BotAccess( restricted=bool(getattr(settings, "restricted", False)), allowed_users=[ - int(getattr(u, "user_id", 0) or getattr(u, "id", 0) or 0) - for u in (getattr(settings, "users", None) or []) + int(getattr(u, "id", 0) or getattr(u, "user_id", 0) or 0) + for u in (getattr(settings, "add_users", None) or []) ], allowed_chats=[ - int(getattr(c, "id", 0) or 0) for c in (getattr(settings, "chats", None) or []) + int(getattr(c, "id", 0) or 0) for c in (getattr(settings, "add_chats", None) or []) ], ) +#: `businessBotRights` is its own flag set, not `chatAdminRights`; the fields +#: are listed rather than scanned so a new one cannot appear as a right the +#: bot silently already has. +BUSINESS_RIGHTS: tuple[str, ...] = ( + "reply", + "read_messages", + "delete_sent_messages", + "delete_received_messages", + "edit_name", + "edit_bio", + "edit_profile_photo", + "edit_username", + "view_gifts", + "sell_gifts", + "change_gift_settings", + "transfer_and_upgrade_gifts", + "transfer_stars", + "manage_stories", +) + + +def _business_rights(rights: Any) -> list[str]: + if rights is None: + return [] + return [name for name in BUSINESS_RIGHTS if bool(getattr(rights, name, False))] + + # --------------------------------------------------------------------------- # bot get # --------------------------------------------------------------------------- @@ -1165,7 +1195,7 @@ async def menu_get(ctx: OpContext, req: MenuGetReq) -> MenuButton: peer = await _send.resolve(ctx, req.bot) full, _user = await _full(ctx, peer) info = getattr(full, "bot_info", None) - return _menu_button(getattr(info, "menu_button", None)) or MenuButton() + return _menu_button(getattr(info, "menu_button", None)) or MenuButton(kind="commands") SPEC_MENU_GET = OperationSpec( @@ -1641,7 +1671,7 @@ async def _dispatch( if kind == "request_geo": if not req.share_geo: _refuse("send the bot your location", "--share-geo") - lat, lon = _latlon(req.share_geo) + lat, lon = _latlon(str(req.share_geo)) sent = await handle( fn.SendMediaRequest( peer=peer, @@ -1712,6 +1742,7 @@ def _poll_media(req: PressReq) -> Any: return types.InputMediaPoll( poll=types.Poll( id=0, + hash=0, question=types.TextWithEntities(text=question.strip(), entities=[]), answers=[ types.PollAnswer( @@ -2594,7 +2625,7 @@ async def connection_get(ctx: OpContext, req: ConnectionGetReq) -> BusinessConne dc_id=getattr(connection, "dc_id", None), date=fmt_dt(date), date_unix=to_unix(date), - rights=_bots.rights_keywords(getattr(connection, "rights", None)), + rights=_business_rights(getattr(connection, "rights", None)), disabled=bool(getattr(connection, "disabled", False)), ) @@ -3070,7 +3101,8 @@ async def token_export(ctx: OpContext, req: TokenExportReq) -> BotToken: bot = await _bots.input_user(ctx, req.bot) peer = await _send.resolve(ctx, req.bot) - token = str(await client(ctx)(fn.ExportBotTokenRequest(bot=bot, revoke=req.revoke)) or "") + exported = await client(ctx)(fn.ExportBotTokenRequest(bot=bot, revoke=req.revoke)) + token = str(getattr(exported, "token", "") or "") result = BotToken(bot_id=_send.peer_id_of(peer), revoked=req.revoke) if req.out: @@ -3514,6 +3546,7 @@ async def preview_edit(ctx: OpContext, req: PreviewEditReq) -> PreviewChange: if (req.index is None) == (req.order is None): raise UsageError("give either --index with --file, or --order", field="index") + index = req.index if req.index is not None else -1 bot = await _bots.input_user(ctx, req.bot) current = await _current_previews(ctx, bot) @@ -3540,13 +3573,13 @@ async def preview_edit(ctx: OpContext, req: PreviewEditReq) -> PreviewChange: if not req.file: raise UsageError("--index needs --file", field="file") - if not 0 <= req.index < len(current): - raise NotFoundError(f"there is no preview media at position {req.index}") + if not 0 <= index < len(current): + raise NotFoundError(f"there is no preview media at position {index}") await handle( fn.EditPreviewMediaRequest( bot=bot, lang_code=req.lang, - media=_as_input_media(current[req.index]), + media=_as_input_media(current[index]), new_media=await _uploaded_media(ctx, req.file), ) ) @@ -4102,7 +4135,7 @@ async def recent_set(ctx: OpContext, req: RecentSetReq) -> RecentBots: await handle(fn.ToggleTopPeersRequest(enabled=req.state == "on")) category_name = _TOP_PEER_CATEGORIES[req.kind] - category = getattr(types, category_name, None) + category: Any = getattr(types, category_name, None) if category is None: # pragma: no cover - layer 227 has every category we name _bots.unsupported(f"--kind {req.kind}") if req.forget is not None: diff --git a/tlgr/ops/inline.py b/tlgr/ops/inline.py index d066829..9c50d00 100644 --- a/tlgr/ops/inline.py +++ b/tlgr/ops/inline.py @@ -84,7 +84,10 @@ def _result_model(entry: Any, index: int, query_id: int, results: Any = None) -> document = getattr(entry, "document", None) photo = getattr(entry, "photo", None) thumb = getattr(entry, "thumb", None) - media = document is not None or photo is not None + # The constructor, not the payload: a media result with neither a photo + # nor a document is still a media result, and saying "url" would send a + # caller looking for a URL that is not there. + media = type(entry).__name__ == "BotInlineMediaResult" return InlineResult( n=index, id=str(getattr(entry, "id", "") or ""), diff --git a/tlgr/ops/payment.py b/tlgr/ops/payment.py index a07f6fb..be32360 100644 --- a/tlgr/ops/payment.py +++ b/tlgr/ops/payment.py @@ -883,7 +883,7 @@ async def subscription_set(ctx: OpContext, req: SubscriptionSetReq) -> Subscript fn.ChangeStarsSubscriptionRequest( peer=types.InputPeerSelf(), subscription_id=req.subscription_id, - canceled=not resume, + canceled=(not resume) or None, ) ) return SubscriptionChange(subscription_id=req.subscription_id, cancelled=not resume) diff --git a/tlgr/ops/webapp.py b/tlgr/ops/webapp.py index 5f9440a..f28028f 100644 --- a/tlgr/ops/webapp.py +++ b/tlgr/ops/webapp.py @@ -27,6 +27,7 @@ from tlgr.core.timefmt import fmt_dt from tlgr.models.base import Request from tlgr.models.bot import BotApiResult +from tlgr.models.page import Page from tlgr.models.peer import PeerRef from tlgr.models.webapp import ( WebAppDownload, @@ -467,12 +468,21 @@ async def watch(ctx: OpContext, req: WatchReq) -> Any: reason = f"{type(exc).__name__} {exc}".upper().replace("_", "") if "QUERYIDINVALID" not in reason: raise - yield WebAppProlong( - query_id=req.query_id, alive=False, reason="the session has expired" + yield Page( + items=[ + WebAppProlong( + query_id=req.query_id, alive=False, reason="the session has expired" + ) + ], + has_more=False, ) return - yield WebAppProlong(query_id=req.query_id, prolonged_at=fmt_dt(_now()), alive=True) - if deadline is not None and time.monotonic() >= deadline: + last = deadline is not None and time.monotonic() >= deadline + yield Page( + items=[WebAppProlong(query_id=req.query_id, prolonged_at=fmt_dt(_now()), alive=True)], + has_more=not last, + ) + if last: return await asyncio.sleep(interval) @@ -486,7 +496,7 @@ def _now() -> Any: SPEC_WATCH = OperationSpec( id="webapp.watch", request=WatchReq, - response=WebAppProlong, + response=Page[WebAppProlong], impl=watch, summary="Keep an open mini-app session alive", aliases=("app.session.prolong",), @@ -495,7 +505,7 @@ def _now() -> Any: timeout_s=900, columns=("query_id", "prolonged_at", "alive"), headers=("Query", "At", "Alive"), - example={"query_id": "987654321", "alive": True}, + example={"items": [{"query_id": "987654321", "alive": True}], "has_more": False}, example_args="webapp watch @my_helper_bot --query-id 987654321", covers=("bots.prolong-webview",), ) From e21260598fb296bf6f697fd290d83737c8e5568c Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 01:15:06 +0330 Subject: [PATCH 06/10] parity: the bots domain is accounted for id by id, and a refusal claims nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bots_inline_payments domain waiver is gone. Its 22 remaining ids are named: nine need API layer 229, twelve belong to another group's surface, one is the story group's. P0 goes from 86 to 97 and total coverage from 678 to 831. Registry lint L13 gains a second exemption. An operation that exists only to refuse — the five layer-229 commands — must not claim catalog coverage for something it cannot do, so it is tagged 'not-supported' instead of declaring a partial cover that would inflate the number while doing nothing. Two ids other PRs waived to this one are closed properly rather than re-waived: 'bot stop' covers dialogs.bot-stop-restart, and 'bot ad list --search' covers dialogs.sponsored-search-peers through contacts.getSponsoredPeers — the ads surface, kept in one place. --- tests/test_parity.py | 42 ++++++++- tlgr/data/parity_waivers.toml | 165 ++++++++++++++++++++++++---------- tlgr/ops/bot.py | 75 ++++++++++++---- tlgr/registry.py | 12 ++- 4 files changed, 228 insertions(+), 66 deletions(-) diff --git a/tests/test_parity.py b/tests/test_parity.py index 902900c..a4f2d05 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 = 161 +P0_FLOOR = 172 #: The floor for total covered ids. Same rule, weaker guarantee. -COVERED_FLOOR = 1390 +COVERED_FLOOR = 1541 #: 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 @@ -235,6 +235,28 @@ } ) +#: Every P0 catalog id PR-10's operations cover. Named rather than counted, +#: for the same reason as PR-1's list: a swap must not pass a count check. +#: +#: The two keyboard-rendering P0s of this domain are deliberately *not* here. +#: They were PR-1's from the start; PR-10 only made them true, by filling in +#: the `reply_markup` PR-1 declared and never populated. +PR10_P0_IDS = frozenset( + { + "bots.callback-button-press", + "bots.inline-query", + "bots.list-commands", + "bots.reply-keyboard-press-text", + "bots.resolve-bot", + "bots.restart-bot", + "bots.send-command", + "bots.send-inline-result", + "bots.start-private", + "bots.start-with-deeplink-param", + "bots.stop-bot", + } +) + #: `(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( @@ -329,6 +351,7 @@ def _admin_group(op_id, spec) -> bool: ("pr9", _by_prefix("poll.", "reaction.", "todo.", "location.", "search."), PR9_P0_IDS), ("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), ) @@ -506,6 +529,21 @@ def test_stories_is_fully_accounted_for(self, report): def test_the_stories_domain_is_no_longer_waived_wholesale(self): assert "stories" not in waivers().domains + def test_bots_inline_payments_is_fully_accounted_for(self, report): + """PR-10's own domain. The 22 remaining ids are named one by one. + + Nine of them need API layer 229 and have a registered command that + exits 13 rather than not existing; the rest belong to another group's + command surface. The domain-wide waiver is gone, so "the bots group is + done" is checkable rather than asserted. + """ + stats = report.by_domain["bots_inline_payments"] + assert stats["accounted_percent"] == 100.0 + assert stats["covered"] >= 153 + + def test_the_bots_domain_is_no_longer_waived_wholesale(self): + assert "bots_inline_payments" not in waivers().domains + def test_media_files_is_fully_accounted_for(self, report): """PR-6's own domain. The 22 remaining ids belong to other groups. diff --git a/tlgr/data/parity_waivers.toml b/tlgr/data/parity_waivers.toml index a9654ad..7ede33a 100644 --- a/tlgr/data/parity_waivers.toml +++ b/tlgr/data/parity_waivers.toml @@ -18,11 +18,6 @@ final_pr = 12 # --------------------------------------------------------------------------- -[[domain]] -name = "bots_inline_payments" -pr = 10 -reason = "bot, inline, webapp and payment land in PR-10." - [[domain]] name = "profile_settings_privacy" pr = 12 @@ -601,46 +596,6 @@ 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 = "auth.oauth-deep-link" -pr = 10 -reason = "A tg://oauth request is a bot authorization flow (messages.requestUrlAuth); it lands with the bots group in PR-10." - -[[id]] -id = "auth.url-auth-bot-button" -pr = 10 -reason = "Seamless Telegram Login is a bot keyboard button (messages.requestUrlAuth / acceptUrlAuth); it lands with the bots group in PR-10." -# --------------------------------------------------------------------------- -# media_files ids PR-6 does not own. The catalog groups by subject — anything -# that moves a file — while tlgr is organised by command group, so a profile -# photo is a `profile` command even though uploading one is a file transfer. -# --------------------------------------------------------------------------- - -[[id]] -id = "attach.file-download-check" -pr = 10 -reason = "Mini-app download permission is the `webapp` surface (PR-10)." - -[[id]] -id = "attach.menu-bots" -pr = 10 -reason = "Attachment-menu bots are the `bot` group (PR-10)." - -[[id]] -id = "attach.open-mini-app" -pr = 10 -reason = "Opening a mini app is the `webapp` group (PR-10)." - -[[id]] -id = "bot.media-previews" -pr = 10 -reason = "A bot's Mini App previews are the `bot` group (PR-10)." - -[[id]] -id = "bot.profile-photo-set" -pr = 10 -reason = "Setting an owned bot's photo is the `bot` group (PR-10)." - [[id]] id = "chat.photo-set" pr = 7 @@ -883,3 +838,123 @@ reason = "Suggested peers come from channels.getChannelRecommendations, a channe 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: +# +# * 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. +# --------------------------------------------------------------------------- + +[[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'." + +[[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]] +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]] +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'." + +[[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." + +[[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]] +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]] +id = "bots.bot-updates-status" +pr = 4 +reason = "The pending-update backlog is daemon/updates plumbing (PR-4)." + +[[id]] +id = "bots.bot-subscription-update" +pr = 4 +reason = "Subscription updates reach a bot through the update stream (PR-4)." + +[[id]] +id = "bots.webapp-share-to-story" +pr = 8 +reason = "Sharing to a story is the `story` group (PR-8)." diff --git a/tlgr/ops/bot.py b/tlgr/ops/bot.py index 00c5732..1488b60 100644 --- a/tlgr/ops/bot.py +++ b/tlgr/ops/bot.py @@ -801,7 +801,7 @@ async def stop(ctx: OpContext, req: StopReq) -> BotStopped: headers=("Bot", "Blocked", "Deleted"), example={"bot_id": 93372553, "blocked": True, "history_deleted": 0}, example_args="bot stop @gifbot", - covers=("bots.delete-bot-chat-and-block", "bots.stop-bot"), + covers=("bots.delete-bot-chat-and-block", "bots.stop-bot", "dialogs.bot-stop-restart"), ) @@ -4235,18 +4235,32 @@ async def report(ctx: OpContext, req: ReportReq) -> ReportOutcome: class AdListReq(Request): - bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot chat.")] + bot: Annotated[ + PeerRef | None, + arg(0, metavar="BOT", required=False, kind="user", help="The bot chat."), + ] = None + search: Annotated[ + str | None, + opt("--search", metavar="QUERY", help="Sponsored chats a search for QUERY would show."), + ] = None async def ad_list(ctx: OpContext, req: AdListReq) -> Page[SponsoredMessage]: - """The sponsored messages a bot chat would show. + """The sponsored messages a bot chat — or a search — would show. Opt-in, like `message sponsored list`: tlgr never mixes ads into a message - listing, and never reports an impression that nobody saw — that is - `bot ad read`. + listing or a search result, and never reports an impression that nobody + saw — that is `bot ad read`. Both surfaces are here because both are ads, + and splitting them would hide one of the two places they appear. """ + from telethon.tl.functions import contacts as contacts_fn from telethon.tl.functions import messages as fn + if req.search is not None: + return await _sponsored_peers(ctx, req.search, contacts_fn) + if req.bot is None: + raise UsageError("name a bot chat, or use --search", field="bot") + peer = await _send.resolve(ctx, req.bot) result = await client(ctx)(fn.GetSponsoredMessagesRequest(peer=peer)) items = [ @@ -4266,6 +4280,40 @@ async def ad_list(ctx: OpContext, req: AdListReq) -> Page[SponsoredMessage]: return Page(items=items, has_more=False, total=len(items)) +async def _sponsored_peers(ctx: OpContext, query: str, contacts_fn: Any) -> Page[SponsoredMessage]: + """`contacts.getSponsoredPeers` as the same row a bot-chat ad produces. + + A sponsored *peer* is an ad for a chat rather than a message in one, so it + has a title and no body; reporting it in the same shape is what lets one + `bot ad read` mark either kind as seen. + """ + from tlgr.ops._serialize import entity_to_peer + + result = await client(ctx)(contacts_fn.GetSponsoredPeersRequest(q=query)) + chats = {int(getattr(c, "id", 0)): c for c in (getattr(result, "chats", None) or [])} + users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])} + items: list[SponsoredMessage] = [] + for entry in getattr(result, "peers", None) or []: + peer = getattr(entry, "peer", None) + raw_id = int( + getattr(peer, "channel_id", 0) + or getattr(peer, "user_id", 0) + or getattr(peer, "chat_id", 0) + or 0 + ) + entity = chats.get(raw_id) or users.get(raw_id) + items.append( + SponsoredMessage( + random_id=_bots.key_text(getattr(entry, "random_id", b"")), + title=entity_to_peer(entity).title if entity is not None else None, + message="", + sponsor_info=getattr(entry, "sponsor_info", None), + additional_info=getattr(entry, "additional_info", None), + ) + ) + return Page(items=items, has_more=False, total=len(items)) + + SPEC_AD_LIST = OperationSpec( id="bot.ad.list", request=AdListReq, @@ -4281,7 +4329,7 @@ async def ad_list(ctx: OpContext, req: AdListReq) -> Page[SponsoredMessage]: headers=("ID", "Title", "Text"), example={"items": [{"random_id": "abc", "message": "An ad"}], "has_more": False}, example_args="bot ad list @my_helper_bot", - covers=("bots.bot-ads-account",), + covers=("bots.bot-ads-account", "dialogs.sponsored-search-peers"), covers_partial=("bots.sponsored-message-in-bot-chat",), coverage_note="Reporting an impression or a click is `bot ad read`.", ) @@ -4700,8 +4748,7 @@ async def ephemeral_send(ctx: OpContext, req: EphemeralSendReq) -> EphemeralSent headers=("Chat", "Ephemeral"), example={"chat_id": 4242, "ephemeral_id": 0}, example_args="bot ephemeral send @alice Hello", - covers_partial=("bots.ephemeral-command-send", "bots.ephemeral-message-send"), - coverage_note="Registered and refused with exit 13: the methods are layer 229.", + tags=frozenset({"not-supported"}), ) @@ -4735,8 +4782,7 @@ async def ephemeral_delete(ctx: OpContext, req: EphemeralDeleteReq) -> Ephemeral headers=("Chat", "Deleted"), example={"chat_id": 4242, "deleted": 0}, example_args="bot ephemeral delete @alice 12", - covers_partial=("bots.ephemeral-message-send",), - coverage_note="Registered and refused with exit 13: `ephemeral.*` is layer 229.", + tags=frozenset({"not-supported"}), ) @@ -4761,8 +4807,7 @@ async def welcome_list(ctx: OpContext, req: WelcomeListReq) -> Page[BotWelcomeMe headers=("ID", "Text"), example={"items": [], "has_more": False}, example_args="bot welcome list @mygroup", - covers_partial=("bots.welcome-messages-view",), - coverage_note="Registered and refused with exit 13: `ephemeral.*` is layer 229.", + tags=frozenset({"not-supported"}), ) @@ -4793,8 +4838,7 @@ async def welcome_set(ctx: OpContext, req: WelcomeSetReq) -> WelcomeSet: headers=("Chat", "ID", "Text"), example={"chat_id": -1001, "id": 0, "text": "Welcome!"}, example_args="bot welcome set @mygroup Welcome!", - covers_partial=("bots.welcome-messages-manage",), - coverage_note="Registered and refused with exit 13: `ephemeral.*` is layer 229.", + tags=frozenset({"not-supported"}), ) @@ -4824,6 +4868,5 @@ async def welcome_delete(ctx: OpContext, req: WelcomeDeleteReq) -> WelcomeDelete headers=("Chat", "Deleted"), example={"chat_id": -1001, "deleted": 0}, example_args="bot welcome delete @mygroup 1", - covers_partial=("bots.welcome-messages-manage",), - coverage_note="Registered and refused with exit 13: `ephemeral.*` is layer 229.", + tags=frozenset({"not-supported"}), ) diff --git a/tlgr/registry.py b/tlgr/registry.py index 0a5b19e..ce02e88 100644 --- a/tlgr/registry.py +++ b/tlgr/registry.py @@ -441,9 +441,15 @@ def bad(message: str) -> None: if spec.empty_exit not in (EXIT_SUCCESS, EXIT_EMPTY): bad("empty_exit must be 0 or 3") - # L13 — every op is either catalogued or explicitly infrastructure. - if not spec.covers and not spec.covers_partial and "infrastructure" not in spec.tags: - bad("declares no catalog coverage and is not tagged infrastructure") + # L13 — every op is either catalogued, explicitly infrastructure, or + # registered-and-refused. The third case arrived with PR-10: an operation + # whose method needs a newer API layer is registered so that + # `agent capabilities` can answer "unavailable in this build" rather than + # "no such command" — and it must NOT claim catalog coverage for something + # it cannot do, which is why the tag exists instead of a partial cover. + exempt = {"infrastructure", "not-supported"} & set(spec.tags) + if not spec.covers and not spec.covers_partial and not exempt: + bad("declares no catalog coverage and is not tagged infrastructure/not-supported") if spec.surface is Surface.LOCAL and spec.needs_account: bad("local ops must set needs_account=False") From a02737f981bcbef1b3b8ba14bcd06b58c341e4f6 Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 01:15:45 +0330 Subject: [PATCH 07/10] tests: the sponsored-search half of 'bot ad list' contacts.getSponsoredPeers is the other place an ad appears; it comes back in the same row shape so one 'bot ad read' marks either kind as seen. --- tests/fake_telethon.py | 19 +++++++++++++++++++ tests/test_ops_bot.py | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 512bd43..129b2fa 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -804,6 +804,7 @@ class World: bot_apps: dict[str, Any] = field(default_factory=dict) download_allowed: bool = True prepared_peer_types: list[Any] = field(default_factory=list) + sponsored_peers: list[int] = field(default_factory=list) payment_form: Any = None saved_credentials: bool = True saved_order_info: Any = None @@ -6298,6 +6299,24 @@ def _raw_SetGameScoreRequest(self, request: Any) -> Any: def _raw_SetInlineGameScoreRequest(self, request: Any) -> Any: return True + def _raw_GetSponsoredPeersRequest(self, request: Any) -> Any: + if not self.world.sponsored_peers: + return types.contacts.SponsoredPeersEmpty() + return types.contacts.SponsoredPeers( + peers=[ + types.SponsoredPeer( + peer=types.PeerChannel(channel_id=chat_id), + random_id=f"sp{chat_id}".encode(), + sponsor_info="Example Ltd", + ) + for chat_id in self.world.sponsored_peers + ], + chats=[ + self.world.chats[c] for c in self.world.sponsored_peers if c in self.world.chats + ], + users=[], + ) + def _raw_ViewSponsoredMessageRequest(self, request: Any) -> Any: return True diff --git a/tests/test_ops_bot.py b/tests/test_ops_bot.py index 9da7dd4..89cc65b 100644 --- a/tests/test_ops_bot.py +++ b/tests/test_ops_bot.py @@ -1592,6 +1592,25 @@ async def test_reading_an_ad_records_the_view_and_optionally_the_click( assert bots.called("ViewSponsoredMessageRequest")[0].random_id == b"ad1" assert bots.called("ClickSponsoredMessageRequest") + async def test_sponsored_chats_in_search_share_the_ad_shape( + self, live_daemon, client, in_thread, bots + ): + bots.sponsored_peers = [GROUP] + page = await result(client, in_thread, "bot.ad.list", {"search": "team"}) + assert page["items"][0]["title"] == "Team" + assert page["items"][0]["sponsor_info"] == "Example Ltd" + assert bots.called("GetSponsoredPeersRequest")[0].q == "team" + + async def test_an_empty_sponsored_search_is_an_empty_page( + self, live_daemon, client, in_thread, bots + ): + page = await result(client, in_thread, "bot.ad.list", {"search": "nothing"}) + assert page.get("items", []) == [] + + async def test_listing_ads_needs_a_bot_or_a_search(self, live_daemon, client, in_thread, bots): + error = await fails(client, in_thread, "bot.ad.list", {}) + assert error.exit_code == EXIT_USAGE + async def test_reporting_an_ad_walks_the_same_tree(self, live_daemon, client, in_thread, bots): outcome = await result(client, in_thread, "bot.ad.report", {"random_id": "str:ad1"}) assert outcome["result"] == "reported" From f52b92a6f62603b9fa8fcf85106945cebf3a1322 Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 01:15:52 +0330 Subject: [PATCH 08/10] docs: the generated reference for bot, inline, webapp and payment --- docs/reference/PARITY.md | 83 +- docs/reference/README.md | 6 +- docs/reference/bot.md | 1822 +++++++++++++++++++++++++++++++++++++ docs/reference/inline.md | 257 ++++++ docs/reference/payment.md | 298 ++++++ docs/reference/webapp.md | 227 +++++ 6 files changed, 2655 insertions(+), 38 deletions(-) create mode 100644 docs/reference/bot.md create mode 100644 docs/reference/inline.md create mode 100644 docs/reference/payment.md create mode 100644 docs/reference/webapp.md diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index 0cb1f40..b813a67 100644 --- a/docs/reference/PARITY.md +++ b/docs/reference/PARITY.md @@ -7,64 +7,65 @@ 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 — 509 operations, 722 invocable paths +catalog 2026-09-02 — 588 operations, 835 invocable paths domain covered req % acct% ops -auth_sessions_security 87 89 97.8% 100.0% 44 -bots_inline_payments 18 175 10.3% 100.0% 8 +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 108 121 89.3% 100.0% 51 -dialogs_chats 137 146 93.8% 100.0% 74 +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 124 143 86.7% 100.0% 62 -messages_core 164 167 98.2% 100.0% 58 +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 32 178 18.0% 100.0% 30 +profile_settings_privacy 33 178 18.5% 100.0% 31 stories 113 120 94.2% 100.0% 43 -updates_sync_network 188 189 99.5% 100.0% 67 +updates_sync_network 189 189 100.0% 100.0% 68 priority covered req % acct% -P0 161 178 90.4% 100.0% -P1 323 379 85.2% 100.0% -P2 459 610 75.2% 100.0% -P3 447 630 71.0% 100.0% +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% -TOTAL 1390 1797 77.4% 100.0% +TOTAL 1541 1797 85.8% 100.0% excluded: not-applicable 79, prohibited 40 -uncovered: 407 (407 waived with a PR number) +uncovered: 256 (256 waived with a PR number) ``` ## By domain | Domain | Covered | Required | % | Accounted % | Ops | |---|---:|---:|---:|---:|---:| -| `auth_sessions_security` | 87 | 89 | 97.8% | 100.0% | 44 | -| `bots_inline_payments` | 18 | 175 | 10.3% | 100.0% | 8 | +| `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` | 108 | 121 | 89.3% | 100.0% | 51 | -| `dialogs_chats` | 137 | 146 | 93.8% | 100.0% | 74 | +| `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` | 124 | 143 | 86.7% | 100.0% | 62 | -| `messages_core` | 164 | 167 | 98.2% | 100.0% | 58 | +| `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` | 32 | 178 | 18.0% | 100.0% | 30 | +| `profile_settings_privacy` | 33 | 178 | 18.5% | 100.0% | 31 | | `stories` | 113 | 120 | 94.2% | 100.0% | 43 | -| `updates_sync_network` | 188 | 189 | 99.5% | 100.0% | 67 | +| `updates_sync_network` | 189 | 189 | 100.0% | 100.0% | 68 | ## By priority | Priority | Covered | Required | % | Accounted % | |---|---:|---:|---:|---:| -| P0 | 161 | 178 | 90.4% | 100.0% | -| P1 | 323 | 379 | 85.2% | 100.0% | -| P2 | 459 | 610 | 75.2% | 100.0% | -| P3 | 447 | 630 | 71.0% | 100.0% | +| 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% | ## Partial coverage | Catalog id | Operation | Why partial | |---|---|---| | `auth.passport-authorize` | `passport.form.get` | The request can be read in full; accepting it needs the Passport secure-value crypto Telethon does not provide (see `passport authorize`). | +| `bots.business-invoke-with-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. | | `bots.rich-message-translate` | `message.translate` | Rich-body translation is layer 229 and refused with NOT_SUPPORTED. | | `bots.rich-message-view` | `message.get` | --rich is refused with NOT_SUPPORTED until Telethon carries layer 229. | | `calls.emoji-fingerprint` | `call.get` | the four verification values are reported as indices into Telegram's 333-emoji table; tlgr does not bundle the table, and guessing it for a security check would be worse than not printing it | @@ -87,6 +88,7 @@ uncovered: 407 (407 waived with a PR number) | `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. | | `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 @@ -96,17 +98,28 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar |---|---|---|---| | `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). | -| `contacts-users.url-auth-login` | P1 | Log in to a website with Telegram (URL authorization) | waived until PR-10: URL authorization is a bot surface (PR-10); `resolve link` classifies the link and delegates. | | `dialogs.notify-exceptions` | P1 | List notification exceptions | waived until PR-12: The exceptions *list* is `notify exceptions` (PR-12); one chat's exception is `chat notify`. | | `profile.photos-list-history` | P1 | View own / another user's profile photo history | waived until PR-12: Profile photo history is the `profile` group (PR-12). | | `stars.balance` | P1 | Telegram Stars balance | waived until PR-12: the Star balance and top-up packages are the `stars` surface (PR-12). | | `stories.notify-peer` | P1 | Per-peer story notifications | waived until PR-12: Per-peer story notifications are `notify set --stories` (PR-12). | -| `attach.menu-bots` | P2 | Attachment-menu / side-menu mini-app bots: list, info, add, remove | waived until PR-10: Attachment-menu bots are the `bot` group (PR-10). | -| `auth.url-auth-bot-button` | P2 | Log in to a website via a bot's login button (Seamless Telegram Login) | waived until PR-10: Seamless Telegram Login is a bot keyboard button (messages.requestUrlAuth / acceptUrlAuth); it lands with the bots group in PR-10. | +| `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`. | @@ -141,15 +154,13 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `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). | -| `attach.file-download-check` | P3 | Mini-app file download permission check | waived until PR-10: Mini-app download permission is the `webapp` surface (PR-10). | -| `attach.open-mini-app` | P3 | Open an attachment-menu mini app in a chat | waived until PR-10: Opening a mini app is the `webapp` group (PR-10). | | `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). | -| `auth.oauth-deep-link` | P3 | Authorize an OAuth login request from a website/app (tg://oauth deep link) | waived until PR-10: A tg://oauth request is a bot authorization flow (messages.requestUrlAuth); it lands with the bots group in PR-10. | -| `bot.media-previews` | P3 | Manage a bot's Mini App media previews (owned bots) | waived until PR-10: A bot's Mini App previews are the `bot` group (PR-10). | -| `bot.profile-photo-set` | P3 | Set profile photo of an owned bot | waived until PR-10: Setting an owned bot's photo is the `bot` group (PR-10). | +| `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). | @@ -186,7 +197,6 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `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). | -| `messages-core.url-authorization` | P3 | Seamless Telegram login when opening a link / login-url button | waived until PR-10: Seamless login-url authorisation is a bot surface (PR-10). | | `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). | @@ -197,4 +207,3 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `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). | -| `updates.invoke-business-connection` | P3 | Act on behalf of a connected business account | waived until PR-12: Acting on behalf of a connected business account is the business surface (PR-12). | diff --git a/docs/reference/README.md b/docs/reference/README.md index 36b9b74..a1f83b9 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -2,7 +2,7 @@ # Command reference -509 operations across 34 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. +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. | Group | Operations | Reference | |---|---:|---| @@ -10,6 +10,7 @@ | `agent` | 7 | [agent.md](agent.md) | | `auth` | 11 | [auth.md](auth.md) | | `boost` | 3 | [boost.md](boost.md) | +| `bot` | 57 | [bot.md](bot.md) | | `call` | 13 | [call.md](call.md) | | `chat` | 117 | [chat.md](chat.md) | | `conference` | 9 | [conference.md](conference.md) | @@ -22,12 +23,14 @@ | `export` | 5 | [export.md](export.md) | | `folder` | 13 | [folder.md](folder.md) | | `gif` | 5 | [gif.md](gif.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) | | `passport` | 5 | [passport.md](passport.md) | +| `payment` | 9 | [payment.md](payment.md) | | `poll` | 9 | [poll.md](poll.md) | | `proxy` | 6 | [proxy.md](proxy.md) | | `reaction` | 17 | [reaction.md](reaction.md) | @@ -39,6 +42,7 @@ | `todo` | 5 | [todo.md](todo.md) | | `user` | 12 | [user.md](user.md) | | `vc` | 23 | [vc.md](vc.md) | +| `webapp` | 6 | [webapp.md](webapp.md) | | `webhook` | 3 | [webhook.md](webhook.md) | - [PARITY.md](PARITY.md) — coverage against the Telegram feature catalog. diff --git a/docs/reference/bot.md b/docs/reference/bot.md new file mode 100644 index 0000000..f04b72f --- /dev/null +++ b/docs/reference/bot.md @@ -0,0 +1,1822 @@ + + +# `tlgr bot` + +57 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 | +|---|---| +| [`bot access get`](#tlgr-bot-access-get) | Show who may use a managed bot | +| [`bot access set`](#tlgr-bot-access-set) | Restrict or open who may use a managed bot | +| [`bot ad list`](#tlgr-bot-ad-list) | List the sponsored messages shown inside a bot chat | +| [`bot ad read`](#tlgr-bot-ad-read) | Mark a sponsored message as seen, or as clicked | +| [`bot ad report`](#tlgr-bot-ad-report) | Report a sponsored message in a bot chat | +| [`bot affiliate join`](#tlgr-bot-affiliate-join) | Join a bot's affiliate program and get my referral link | +| [`bot affiliate list`](#tlgr-bot-affiliate-list) | List affiliate programs I joined, or ones on offer | +| [`bot affiliate revoke`](#tlgr-bot-affiliate-revoke) | Revoke one of my affiliate links | +| [`bot affiliate set`](#tlgr-bot-affiliate-set) | Create or raise my bot's affiliate program | +| [`bot affiliate unset`](#tlgr-bot-affiliate-unset) | End my bot's affiliate program | +| [`bot answer`](#tlgr-bot-answer) | Answer a pending bot query | +| [`bot api send`](#tlgr-bot-api-send) | Call an arbitrary Bot-API method through the MTProto session | +| [`bot attach list`](#tlgr-bot-attach-list) | List the bots in my attachment and side menus | +| [`bot attach toggle`](#tlgr-bot-attach-toggle) | Install or remove a bot from the attachment menu | +| [`bot command list`](#tlgr-bot-command-list) | List a bot's slash commands | +| [`bot command send`](#tlgr-bot-command-send) | Send a slash command to a bot | +| [`bot command set`](#tlgr-bot-command-set) | Set or clear my bot's command list for one scope | +| [`bot connection get`](#tlgr-bot-connection-get) | Show a business connection my bot is acting through | +| [`bot connection invoke`](#tlgr-bot-connection-invoke) | Run another tlgr command on behalf of a business account | +| [`bot create`](#tlgr-bot-create) | Create a managed bot without BotFather | +| [`bot default-rights set`](#tlgr-bot-default-rights-set) | Set the admin rights clients pre-tick for my bot | +| [`bot edit`](#tlgr-bot-edit) | Edit my bot's name, about text, description and photo | +| [`bot ephemeral delete`](#tlgr-bot-ephemeral-delete) | Delete or dismiss an ephemeral bot message | +| [`bot ephemeral send`](#tlgr-bot-ephemeral-send) | Send an ephemeral ('only you can see this') bot message | +| [`bot game get`](#tlgr-bot-game-get) | Show emoji-dice game parameters | +| [`bot game send`](#tlgr-bot-game-send) | Send an HTML5 game to a chat | +| [`bot get`](#tlgr-bot-get) | Show a bot's profile card | +| [`bot id get`](#tlgr-bot-id-get) | Convert between MTProto peer ids and Bot-API chat ids | +| [`bot list`](#tlgr-bot-list) | List bots I own, similar bots, popular mini apps or my recent bots | +| [`bot menu get`](#tlgr-bot-menu-get) | Show a bot's menu button | +| [`bot menu set`](#tlgr-bot-menu-set) | Set my bot's menu button | +| [`bot permission get`](#tlgr-bot-permission-get) | Show what a bot may do to me | +| [`bot permission set`](#tlgr-bot-permission-set) | Allow or revoke a bot permission | +| [`bot press`](#tlgr-bot-press) | Press a button on a message | +| [`bot preview add`](#tlgr-bot-preview-add) | Add a preview media to my bot's mini-app gallery | +| [`bot preview delete`](#tlgr-bot-preview-delete) | Delete preview media from my bot's gallery | +| [`bot preview edit`](#tlgr-bot-preview-edit) | Replace one preview media, or reorder the gallery | +| [`bot preview list`](#tlgr-bot-preview-list) | List a bot's mini-app preview media | +| [`bot query list`](#tlgr-bot-query-list) | List the bot queries the daemon is holding | +| [`bot recent set`](#tlgr-bot-recent-set) | Turn frequently-used-bot suggestions on or off | +| [`bot report`](#tlgr-bot-report) | Report a bot or a mini app | +| [`bot score list`](#tlgr-bot-score-list) | Show a game's high-score table | +| [`bot score set`](#tlgr-bot-score-set) | Report a game score for a user | +| [`bot start`](#tlgr-bot-start) | Start a bot, with a deep-link parameter or inside a group | +| [`bot stop`](#tlgr-bot-stop) | Stop and block a bot | +| [`bot stream send`](#tlgr-bot-stream-send) | Stream a live draft into a chat | +| [`bot token export`](#tlgr-bot-token-export) | Export a managed bot's API token | +| [`bot url-auth accept`](#tlgr-bot-url-auth-accept) | Complete a seamless login and print the authorized URL | +| [`bot url-auth decline`](#tlgr-bot-url-auth-decline) | Refuse a seamless-login request | +| [`bot url-auth get`](#tlgr-bot-url-auth-get) | Inspect a seamless-login request without accepting it | +| [`bot username check`](#tlgr-bot-username-check) | Check whether a bot username is available | +| [`bot username set`](#tlgr-bot-username-set) | Enable, disable and reorder my bot's usernames | +| [`bot verification get`](#tlgr-bot-verification-get) | Show a peer's third-party verification badge | +| [`bot verification set`](#tlgr-bot-verification-set) | Verify or unverify a peer with my verifier bot | +| [`bot welcome delete`](#tlgr-bot-welcome-delete) | Delete one or all of a chat's bot welcome messages | +| [`bot welcome list`](#tlgr-bot-welcome-list) | List a chat's bot welcome-message templates | +| [`bot welcome set`](#tlgr-bot-welcome-set) | Add or edit a chat's bot welcome message | + +### `bot access get` + +Show who may use a managed bot. + +``` +tlgr bot access get [OPTIONS] +``` + +**returns `BotAccess`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The managed bot. | + +```console +$ tlgr bot access get @my_helper_bot --json +``` + +
Catalog coverage (0 full, 1 partial) + +Partial: `bots.managed-bot-access-settings` + +Changing the list is `bot access set`. + +
+ +### `bot access set` + +Restrict or open who may use a managed bot. + +``` +tlgr bot access set [OPTIONS] +``` + +**mutating · returns `BotAccess`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The managed bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--add` | user | | Peers to allow. | +| `--open` | flag | | Anyone may use the bot. | +| `--remove` | user | | Peers to disallow. | +| `--restricted` | flag | | Only the listed peers may use the bot. | + +```console +$ tlgr bot access set @my_helper_bot --restricted --add @alice --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.managed-bot-access-settings` + +
+ +### `bot ad list` + +List the sponsored messages shown inside a bot chat. + +Telegram's API terms require a third-party client that shows bot or channel content to support sponsored messages; tlgr does so by making them a command of their own instead of hiding them in a feed. + +``` +tlgr bot ad list [BOT] [OPTIONS] +``` + +**returns `Page[SponsoredMessage]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | no | The bot chat. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--search` | text | | Sponsored chats a search for QUERY would show. | + +```console +$ tlgr bot ad list @my_helper_bot --json +``` + +
Catalog coverage (2 full, 1 partial) + +Full: `bots.bot-ads-account`, `dialogs.sponsored-search-peers` + +Partial: `bots.sponsored-message-in-bot-chat` + +Reporting an impression or a click is `bot ad read`. + +
+ +### `bot ad read` + +Mark a sponsored message as seen, or as clicked. + +``` +tlgr bot ad read [OPTIONS] +``` + +**mutating · returns `SponsoredRead`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `RANDOM_ID` | text | yes | random_id from `bot ad list`. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--click` | flag | | Also record a click. | +| `--fullscreen` | flag | | The click was in fullscreen. | +| `--media` | flag | | The click was on the ad's media. | + +Also invocable as: `tlgr bot ad view` + +```console +$ tlgr bot ad read abc --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.sponsored-message-in-bot-chat` + +
+ +### `bot ad report` + +Report a sponsored message in a bot chat. + +``` +tlgr bot ad report [OPTIONS] +``` + +**mutating · returns `ReportOutcome`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `RANDOM_ID` | text | yes | random_id from `bot ad list`. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--comment` | text | | Free-text comment. | +| `--option` | text | | Option from the previous step. | + +```console +$ tlgr bot ad report abc --json +``` + +
Catalog coverage (0 full, 1 partial) + +Partial: `bots.sponsored-message-in-bot-chat` + +Listing and viewing the ads themselves is `bot ad list`/`bot ad read`. + +
+ +### `bot affiliate join` + +Join a bot's affiliate program and get my referral link. + +``` +tlgr bot affiliate join [OPTIONS] +``` + +**mutating · returns `StarRefProgram`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot's program to join. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--send-as` | chat | | Join as me, a bot or a channel. | + +```console +$ tlgr bot affiliate join @my_helper_bot --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.affiliate-connect` + +
+ +### `bot affiliate list` + +List affiliate programs I joined, or ones on offer. + +``` +tlgr bot affiliate list [OPTIONS] +``` + +**paginated (`RATE` cursor) · returns `Page[StarRefProgram]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--bot` | user | | Only the program connected to this bot. | +| `--by` | revenue|date | `revenue` | Sort order for --suggested. | +| `--send-as` | chat | | Act as me, a bot or a channel. | +| `--suggested` | flag | | Browse mini apps with an open program. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr bot affiliate list --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.affiliate-list-connected`, `bots.affiliate-suggested` + +
+ +### `bot affiliate revoke` + +Revoke one of my affiliate links. + +``` +tlgr bot affiliate revoke [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · idempotent (reports `already`) · returns `StarRefProgram`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `LINK` | text | yes | The referral link to revoke. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--send-as` | chat | | Peer the link belongs to. | + +```console +$ tlgr bot affiliate revoke https://t.me/my_helper_bot?start=ref --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.affiliate-revoke` + +
+ +### `bot affiliate set` + +Create or raise my bot's affiliate program. + +``` +tlgr bot affiliate set [OPTIONS] +``` + +**mutating · returns `StarRefProgram`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot I own. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--commission-permille` | int | | Commission in permille. | +| `--duration-months` | int | | Program duration; omit for unlimited. | + +```console +$ tlgr bot affiliate set @my_helper_bot --commission-permille 200 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.affiliate-program-set` + +
+ +### `bot affiliate unset` + +End my bot's affiliate program. + +``` +tlgr bot affiliate unset [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `StarRefProgram`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot I own. | + +```console +$ tlgr bot affiliate unset @my_helper_bot --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.affiliate-program-end` + +
+ +### `bot answer` + +Answer a pending bot query. + +Callback, inline, shipping, pre-checkout, guest, mini-app and webhook queries, one flag set per kind. Answering a pre-checkout query approves or rejects a payment the buyer already started, which is why it is here and `payments.sendPaymentForm` is not. + +``` +tlgr bot answer [OPTIONS] +``` + +**mutating · returns `BotAnswer`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `KIND` | text | yes | callback|inline|shipping|precheckout|guest|webapp|webhook. | +| `QUERY_ID` | text | yes | Query id being answered. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--alert` | flag | | callback: show a modal alert. | +| `--cache-time` | int | | Seconds clients may cache it. | +| `--data` | json | | webhook: JSON payload. | +| `--error` | text | | shipping|precheckout: rejection. | +| `--gallery` | flag | | inline: render results as a grid. | +| `--next-offset` | text | | inline: offset for the next page. | +| `--ok` | flag | | shipping|precheckout: accept. | +| `--options` | path | | shipping: JSON shipping options. | +| `--private` | flag | | inline: cache per user. | +| `--results` | path | | inline|guest|webapp: JSON results. | +| `--switch-pm` | text | | inline: a button above them. | +| `--switch-webview` | text | | inline: mini-app button. | +| `--text` | text | | callback: toast or alert text. | +| `--url` | text | | callback: deep link. | + +```console +$ tlgr bot answer callback 123456 --text Saved --json +``` + +
Catalog coverage (6 full, 1 partial) + +Full: `bots.answer-callback-query`, `bots.answer-inline-query`, `bots.answer-precheckout-query`, `bots.answer-shipping-query`, `bots.guest-mode-answer`, `bots.send-webview-result-message` + +Partial: `bots.send-custom-request` + +An arbitrary Bot-API method is `bot api send`. + +
+ +### `bot api send` + +Call an arbitrary Bot-API method through the MTProto session. + +``` +tlgr bot api send [OPTIONS] +``` + +**mutating · returns `BotApiResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `METHOD` | text | yes | Bot-API method name. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--params` | json | `{}` | JSON parameters. | + +```console +$ tlgr bot api send getMe --params "{}" --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.send-custom-request` + +
+ +### `bot attach list` + +List the bots in my attachment and side menus. + +``` +tlgr bot attach list [OPTIONS] +``` + +**returns `Page[AttachMenuBot]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--bot` | user | | Inspect one bot's entry. | + +```console +$ tlgr bot attach list --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `attach.menu-bots`, `bots.attach-menu-list` + +
+ +### `bot attach toggle` + +Install or remove a bot from the attachment menu. + +``` +tlgr bot attach toggle [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `ToggledAttachMenu`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot. | +| `STATE` | text | yes | on = install, off = remove. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--accept-tos` | flag | | Required when the bot needs a side-menu disclaimer. | +| `--allow-write` | flag | | CONSENT: also let the bot message me. | + +```console +$ tlgr bot attach toggle @my_helper_bot on --json +``` + +
Catalog coverage (3 full, 0 partial) + +Full: `bots.attach-menu-toggle`, `bots.miniapp-panel-menu`, `bots.webapp-write-access` + +
+ +### `bot command list` + +List a bot's slash commands. + +A user reads them out of `botInfo`; a bot session reads its own back per scope with `bots.getBotCommands`, which is the only way to see what a scope actually holds. + +``` +tlgr bot command list [BOT] [OPTIONS] +``` + +**returns `Page[BotCommand]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | no | The bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--chat` | chat | | Every bot's commands in this chat. | +| `--lang` | text | | Language code. | +| `--peer` | chat | | Peer for a peer* scope. | +| `--scope` | default|users|chats|chat-admins|peer|peer-admins|peer-user | | Bot-side scope to read back (bot session). | +| `--user` | user | | User for the peer-user scope. | + +Also invocable as: `tlgr bot commands` + +```console +$ tlgr bot command list @gifbot --json +``` + +
Catalog coverage (3 full, 0 partial) + +Full: `bots.bot-help-settings-shortcuts`, `bots.get-my-bot-commands`, `bots.list-commands` + +
+ +### `bot command send` + +Send a slash command to a bot. + +Driving @BotFather's own conversation with this command and `bot press` is the only way to reach the toggles Telegram exposes nowhere else — group privacy mode and bot-to-bot mode. + +``` +tlgr bot command send [ARGS]... [OPTIONS] +``` + +**mutating · returns `CommandSent`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot addressed. | +| `COMMAND` | text | yes | The command, with or without '/'. | +| `ARGS` | text | any number | Arguments appended after it. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--business-connection` | text | | Send as a business account (bot session). | +| `--chat` | chat | | Send it in this chat instead. | +| `--guest` | flag | | Address the bot in guest mode by mentioning it. | +| `--reply-to` | msg-id | | Reply to this message. | +| `--silent` | flag | | Send without a notification. | +| `--topic` | msg-id | | Forum topic id. | + +Also invocable as: `tlgr bot cmd` + +```console +$ tlgr bot command send @gifbot start --json +``` + +
Catalog coverage (3 full, 2 partial) + +Full: `bots.bot-privacy-mode`, `bots.bot-to-bot-messaging`, `bots.send-command` + +Partial: `bots.bot-help-settings-shortcuts`, `bots.guest-mode-invoke` + +Whether a bot declares /help and /settings is reported by `bot command list`; the guest-mode bot listing is `bot list --recent --kind guest`. + +
+ +### `bot command set` + +Set or clear my bot's command list for one scope. + +``` +tlgr bot command set [COMMANDS] [OPTIONS] +``` + +**mutating · returns `BotCommandSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `COMMANDS` | text | no | 'start:Start,help:Show help'. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--clear` | flag | | Reset the list for this scope. | +| `--file` | path | | Read the list from a JSON file. | +| `--lang` | text | | Language code. | +| `--peer` | chat | | Peer for a peer* scope. | +| `--scope` | default|users|chats|chat-admins|peer|peer-admins|peer-user | `default` | Command scope. | +| `--user` | user | | User for the peer-user scope. | + +```console +$ tlgr bot command set "start:Start the bot" --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.reset-my-bot-commands`, `bots.set-my-bot-commands` + +
+ +### `bot connection get` + +Show a business connection my bot is acting through. + +``` +tlgr bot connection get [OPTIONS] +``` + +**returns `BusinessConnection`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CONNECTION_ID` | text | yes | Business connection id. | + +```console +$ tlgr bot connection get abc123 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.business-connection-info` + +
+ +### `bot connection invoke` + +Run another tlgr command on behalf of a business account. + +Registered and refused with exit 13 rather than left out: the wrapper itself works and is reachable as `--business-connection` on the commands that carry it, but re-entering the dispatcher from inside an operation would break the layering rule that keeps `ops/` importable without the daemon. + +``` +tlgr bot connection invoke [COMMAND]... [OPTIONS] +``` + +**mutating · returns `BusinessConnection`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CONNECTION_ID` | text | yes | Business connection id. | +| `COMMAND` | text | one or more | The tlgr command to wrap. | + +```console +$ tlgr bot connection invoke abc123 message send @alice hi --json +``` + +
Catalog coverage (0 full, 2 partial) + +Partial: `bots.business-invoke-with-connection`, `updates.invoke-business-connection` + +The wrapper is implemented on `bot command send`, `bot press` and `inline send`; wrapping an arbitrary command is refused with exit 13. + +
+ +### `bot create` + +Create a managed bot without BotFather. + +A managed bot's token is exported with `bot token export`, which is what makes this worth having: the whole lifecycle stays in one tool. + +``` +tlgr bot create [OPTIONS] +``` + +**mutating · returns `BotCreated`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--about` | text | | Short about text. | +| `--check-only` | flag | | Only report whether the username is free. | +| `--manager` | user | | Manager bot that owns the token. | +| `--name` | text | | Display name. | +| `--username` | text | | Username (must end in 'bot'). | + +```console +$ tlgr bot create --name Helper --username my_helper_bot --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.create-managed-bot` + +
+ +### `bot default-rights set` + +Set the admin rights clients pre-tick for my bot. + +``` +tlgr bot default-rights set [OPTIONS] +``` + +**mutating · returns `DefaultRights`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--channel` | text | | '+'-joined rights for channels. | +| `--group` | text | | '+'-joined rights for groups. | + +Also invocable as: `tlgr bot default_rights set` + +```console +$ tlgr bot default-rights set --group delete_messages+invite_users --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.suggested-admin-rights` + +
+ +### `bot edit` + +Edit my bot's name, about text, description and photo. + +``` +tlgr bot edit [OPTIONS] +``` + +**mutating · returns `BotEdited`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot I own. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--about` | text | | Short about text (profile). | +| `--description` | text | | Long description shown in an empty chat. | +| `--lang` | text | | Language these values apply to. | +| `--name` | text | | Display name. | +| `--photo` | path | | Profile photo or video. | +| `--remove-photo` | flag | | Delete the current profile photo. | +| `--video` | flag | | Treat --photo as a video. | +| `--video-start` | number | | Video cover timestamp. | + +```console +$ tlgr bot edit @my_helper_bot --name Helper --json +``` + +
Catalog coverage (4 full, 0 partial) + +Full: `bot.profile-photo-set`, `bots.bot-forums`, `bots.set-bot-info`, `bots.set-bot-photo` + +
+ +### `bot ephemeral delete` + +Delete or dismiss an ephemeral bot message. + +``` +tlgr bot ephemeral delete [ID]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `EphemeralDeleted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat it lives in. | +| `ID` | int | one or more | Ephemeral message ids. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--dismiss` | flag | | Only clear it locally. | +| `--receiver` | user | | Whose copy is deleted (bot side). | + +```console +$ tlgr bot ephemeral delete @alice 12 --json +``` + +### `bot ephemeral send` + +Send an ephemeral ('only you can see this') bot message. + +Layer 229. Exits 13 (NOT_SUPPORTED) until the pinned Telethon speaks it: hand-rolling the request would mean guessing at constructor ids for parameters nobody has published. + +``` +tlgr bot ephemeral send [OPTIONS] +``` + +**mutating · returns `EphemeralSent`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat it lives in. | +| `TEXT` | text | yes | Message text, or a /command. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--anchor` | flag | | Pin it to the triggering message. | +| `--bot` | user | | Bot the conversation belongs to. | +| `--edit` | int | | Edit this ephemeral message instead. | +| `--keyboard` | path | | JSON keyboard. | +| `--parse` | md|html|none | | Text formatting. | +| `--query-id` | text | | Guest/callback query this answers. | +| `--receiver` | user | | Who alone will see it (bot side). | +| `--reply-to` | int | | Ephemeral message being replied to. | +| `--rich-file` | path | | Send a rich body. | +| `--welcome` | flag | | Store it as a welcome template. | + +```console +$ tlgr bot ephemeral send @alice Hello --json +``` + +### `bot game get` + +Show emoji-dice game parameters. + +``` +tlgr bot game get [OPTIONS] +``` + +**returns `EmojiGame`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--emoji` | text | | Dice emoji this report is about. | + +```console +$ tlgr bot game get --emoji 🎲 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.emoji-games` + +
+ +### `bot game send` + +Send an HTML5 game to a chat. + +``` +tlgr bot game send [OPTIONS] +``` + +**mutating · returns `GameSent`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot that owns the game. | +| `SHORT_NAME` | text | yes | Game short name. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--chat` | chat | | Destination chat. | +| `--reply-to` | msg-id | | Reply to this message. | +| `--schedule` | text | | Schedule the send. | +| `--silent` | flag | | Send without a notification. | + +```console +$ tlgr bot game send @my_helper_bot tetris --chat @alice --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.send-game` + +
+ +### `bot get` + +Show a bot's profile card. + +Description, about text, commands, menu button, privacy policy, capability flags, verification badge and mini-app settings, from the one `users.getFullUser` that carries all of them. + +``` +tlgr bot get [OPTIONS] +``` + +**returns `BotInfo`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | @username, id or t.me link. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--access` | flag | | Also fetch managed-bot access settings. | +| `--lang` | text | | Localized description (owner view). | +| `--refresh` | flag | | Re-resolve the username instead of trusting the cache. | + +Also invocable as: `tlgr bot info` + +```console +$ tlgr bot get @gifbot --json +``` + +
Catalog coverage (4 full, 4 partial) + +Full: `bots.bot-info-card`, `bots.bot-privacy-policy`, `bots.bot-profile-flags`, `bots.resolve-bot` + +Partial: `bots.bot-verification-view`, `bots.menu-button-state`, `bots.suggested-admin-rights`, `bots.webapp-placeholder-and-close` + +The card shows the menu button, the suggested admin rights, the verification badge and the mini-app placeholder; setting them is `bot menu set`, `bot default-rights set`, `bot verification set` and `webapp get`. + +
+ +### `bot id get` + +Convert between MTProto peer ids and Bot-API chat ids. + +tlgr prints marked ids everywhere (COR-10), which is the same dialect the HTTP Bot API uses; this command says so out loud and reports whether an access hash is cached for the peer. + +``` +tlgr bot id get [OPTIONS] +``` + +**returns `BotIds`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | @username, MTProto id or Bot-API id. | + +```console +$ tlgr bot id get @durov --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.bot-api-dialog-ids` + +
+ +### `bot list` + +List bots I own, similar bots, popular mini apps or my recent bots. + +A non-Premium account gets a shortened `--similar-to` list plus the real count, which is reported as `truncated_count` rather than silently looking like the whole answer. + +``` +tlgr bot list [OPTIONS] +``` + +**paginated (`RATE` cursor) · returns `Page[BotRef]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--kind` | pm|inline|app|guest | `pm` | Top-peer category for --recent. | +| `--owned/--no-owned` | flag | `True` | Bots I own or administer (default). | +| `--popular-apps` | flag | | The Mini App store list. | +| `--recent` | flag | | Frequently-used bots (top peers). | +| `--similar-to` | user | | Bots recommended next to this bot. | + +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 bot mine` + +```console +$ tlgr bot list --owned --json +``` + +
Catalog coverage (4 full, 1 partial) + +Full: `bots.guest-mode-invoke`, `bots.list-owned-bots`, `bots.popular-app-bots`, `bots.similar-bots` + +Partial: `bots.top-peers-bots` + +Turning the frequently-used list on or off is `bot recent set`. + +
+ +### `bot menu get` + +Show a bot's menu button. + +`botMenuButtonDefault` is never what a user sees — the server shows the commands list instead — so it is normalised to `commands` rather than reported as a third state nobody can act on. + +``` +tlgr bot menu get [BOT] [OPTIONS] +``` + +**returns `MenuButton`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | no | The bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--user` | user | | Per-user override (bot session). | + +```console +$ tlgr bot menu get @gifbot --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.menu-button-state` + +
+ +### `bot menu set` + +Set my bot's menu button. + +``` +tlgr bot menu set [OPTIONS] +``` + +**mutating · returns `MenuButton`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--commands` | flag | | Show the commands list. | +| `--default` | flag | | Reset to the default. | +| `--text` | text | | Button label. | +| `--url` | text | | Mini app URL. | +| `--user` | user | | Apply to this user only. | +| `--webapp` | flag | | Open a mini app. | + +```console +$ tlgr bot menu set --commands --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.menu-button-set` + +
+ +### `bot permission get` + +Show what a bot may do to me. + +``` +tlgr bot permission get [OPTIONS] +``` + +**returns `BotPermission`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot. | + +```console +$ tlgr bot permission get @gifbot --json +``` + +
Catalog coverage (1 full, 1 partial) + +Full: `bots.bot-emoji-status-permission` + +Partial: `bots.allow-send-messages` + +Granting or revoking is `bot permission set`. + +
+ +### `bot permission set` + +Allow or revoke a bot permission. + +``` +tlgr bot permission set [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `BotPermission`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot. | +| `KEY` | text | yes | message or emoji-status. | +| `STATE` | text | yes | on to grant, off to revoke. | + +```console +$ tlgr bot permission set @gifbot message on --json +``` + +
Catalog coverage (1 full, 1 partial) + +Full: `bots.allow-send-messages` + +Partial: `bots.bot-emoji-status-permission` + +Reading both permissions back is `bot permission get`. + +
+ +### `bot press` + +Press a button on a message. + +One dispatcher for every button kind, returning a typed answer: a callback toast, a URL, a signed mini-app session, inline results, a peer prompt or copy text. A button that would disclose your phone number, your location, a chat or a new poll is not pressed without the flag that names it — tlgr prints what it would send and exits 2. A Pay button is refused outright (exit 6). + +``` +tlgr bot press [MSG_ID] [OPTIONS] +``` + +**mutating · returns `Pressed`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat holding the message. | +| `MSG_ID` | msg-id | no | Message id. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--business-connection` | text | | Press as a business account (bot session). | +| `--button` | text | | ',', '' or the button's exact text. | +| `--correct` | int | | 0-based correct answer for a quiz. | +| `--create-bot` | flag | | Answer a create-bot request by creating one. | +| `--data` | text | | Address a callback button by its payload. | +| `--ephemeral` | int | | Button on an ephemeral bot message. | +| `--name` | text | | Managed-bot name for --create-bot. | +| `--password` | text | | 2FA password for a guarded button. | +| `--peers` | chat | | CONSENT: peers to share (repeatable). | +| `--poll` | text | | CONSENT: poll 'Question?:A,B,C'. | +| `--quiz` | flag | | Make the --poll a quiz. | +| `--rich-button` | int | | Button in a layer-229 rich message. | +| `--share-geo` | text | | CONSENT: send this location. | +| `--share-phone` | flag | | CONSENT: send my phone number to the bot. | +| `--switch-to` | chat | | Chat to run a switch-inline query in. | +| `--username` | text | | Managed-bot username for --create-bot. | +| `--webapp-req` | text | | Answer a mini app's peer request. | + +Also invocable as: `tlgr bot click`, `tlgr bot button press` + +```console +$ tlgr bot press @gifbot 12 --button 0 --json +``` + +
Catalog coverage (14 full, 6 partial) + +Full: `bots.bot-ownership-transfer`, `bots.button-request-location`, `bots.button-request-peer`, `bots.button-request-phone`, `bots.button-request-poll`, `bots.callback-button-press`, `bots.callback-button-with-password`, `bots.copy-text-button`, `bots.managed-bot-request-button`, `bots.play-game`, `bots.reply-keyboard-press-text`, `bots.url-button`, `bots.user-profile-button`, `bots.webapp-request-phone` + +Partial: `bots.attach-webapp-open`, `bots.bot-privacy-mode`, `bots.button-request-peer-from-miniapp`, `bots.login-url-button`, `bots.switch-inline-button`, `bots.webapp-switch-inline-query` + +Pressing surfaces each of these; completing them is `webapp open`, `bot url-auth accept`, `inline query` and `inline send`. + +
+ +### `bot preview add` + +Add a preview media to my bot's mini-app gallery. + +``` +tlgr bot preview add [OPTIONS] +``` + +**mutating · returns `PreviewChange`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot I own. | +| `FILE` | path | yes | Image or video. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--lang` | text | | Language code. | + +```console +$ tlgr bot preview add @my_helper_bot ./shot.png --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.preview-media-add` + +
+ +### `bot preview delete` + +Delete preview media from my bot's gallery. + +``` +tlgr bot preview delete [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `PreviewChange`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot I own. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--index` | int | | Positions to delete. | +| `--lang` | text | | Language code. | + +```console +$ tlgr bot preview delete @my_helper_bot --index 0 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.preview-media-delete` + +
+ +### `bot preview edit` + +Replace one preview media, or reorder the gallery. + +``` +tlgr bot preview edit [OPTIONS] +``` + +**mutating · returns `PreviewChange`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot I own. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--file` | path | | New media for --index. | +| `--index` | int | | Position to replace. | +| `--lang` | text | | Language code. | +| `--order` | text | | New order for the whole gallery. | + +```console +$ tlgr bot preview edit @my_helper_bot --order 1,0 --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.preview-media-edit`, `bots.preview-media-reorder` + +
+ +### `bot preview list` + +List a bot's mini-app preview media. + +``` +tlgr bot preview list [OPTIONS] +``` + +**returns `Page[PreviewMedia]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--lang` | text | | Language code. | +| `--owner` | flag | | Owner view, including per-language sets. | + +```console +$ tlgr bot preview list @my_helper_bot --json +``` + +
Catalog coverage (3 full, 0 partial) + +Full: `bot.media-previews`, `bots.preview-info-per-language`, `bots.preview-medias-list` + +
+ +### `bot query list` + +List the bot queries the daemon is holding. + +An inline callback carries an `InputBotInlineMessageID` rather than a message id and cannot be fetched at all, so its `message` is null rather than missing. + +``` +tlgr bot query list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[BotQuery]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--kind` | callback|inline|inline-send|shipping|precheckout|guest|webapp|webhook | | Filter by query kind. | +| `--resolve-message/--no-resolve-message` | flag | `True` | Also fetch a callback's source message. | +| `--since` | datetime | | Only newer than this. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr bot query list --kind callback --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.callback-query-message-get` + +
+ +### `bot recent set` + +Turn frequently-used-bot suggestions on or off. + +``` +tlgr bot recent set [STATE] [OPTIONS] +``` + +**mutating · returns `RecentBots`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `STATE` | text | no | on|off for the whole feature. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--forget` | user | | Reset the rating of one bot. | +| `--forget-all` | flag | | Reset the whole category. | +| `--kind` | pm|inline|app|guest | `pm` | Category the reset applies to. | + +```console +$ tlgr bot recent set off --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.top-peers-bots` + +
+ +### `bot report` + +Report a bot or a mini app. + +``` +tlgr bot report [OPTIONS] +``` + +**mutating · returns `ReportOutcome`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot or mini app owner. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--app` | text | | Report a mini app by short name. | +| `--comment` | text | | Free-text comment. | +| `--ephemeral` | int | | Report an ephemeral message. | +| `--message` | msg-id | | Report one message. | +| `--option` | text | | Option from the previous step. | + +```console +$ tlgr bot report @spam_bot --json +``` + +
Catalog coverage (1 full, 1 partial) + +Full: `bots.report-bot-or-app` + +Partial: `bots.miniapp-panel-menu` + +Installing and removing a mini app is `bot attach toggle`; reporting an ephemeral message needs layer 229 and exits 13. + +
+ +### `bot score list` + +Show a game's high-score table. + +``` +tlgr bot score list [CHAT] [MSG_ID] [OPTIONS] +``` + +**returns `Page[HighScore]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | Chat holding the game. | +| `MSG_ID` | msg-id | no | Game message. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--inline-id` | text | | Inline message id. | +| `--user` | user | | Centre the table on this user. | + +```console +$ tlgr bot score list @alice 12 --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.game-high-scores`, `bots.inline-game-high-scores` + +
+ +### `bot score set` + +Report a game score for a user. + +``` +tlgr bot score set [CHAT] [MSG_ID] [OPTIONS] +``` + +**mutating · returns `ScoreSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | Chat holding the game. | +| `MSG_ID` | msg-id | no | Game message. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--allow-lower` | flag | | Allow the score to decrease (force). | +| `--edit-message` | flag | | Also update the game message. | +| `--inline-id` | text | | Inline message id. | +| `--score` | int | | New score. | +| `--user` | user | | The player. | + +```console +$ tlgr bot score set @alice 12 --user @alice --score 900 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.set-game-score` + +
+ +### `bot start` + +Start a bot, with a deep-link parameter or inside a group. + +`--param` is the payload behind a `t.me/?start=…` link and is never written into the chat, which is the whole point of a deep link. `--referrer` additionally re-resolves the username with the referral attached, because the attribution happens at resolve time. + +``` +tlgr bot start [OPTIONS] +``` + +**mutating · returns `BotStarted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot to start. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--add` | flag | | Add the bot to the chat if it is not a member. | +| `--admin` | text | | '+'-joined admin rights to grant. | +| `--channel` | chat | | Add the bot to this channel. | +| `--chat` | chat | | Start the bot inside this group. | +| `--param` | text | | Hidden start parameter. | +| `--referrer` | text | | Referral/affiliate start parameter. | +| `--restart` | flag | | Unblock the bot before starting it. | + +Also invocable as: `tlgr bot restart` + +```console +$ tlgr bot start @gifbot --json +``` + +
Catalog coverage (8 full, 0 partial) + +Full: `bots.inline-switch-pm`, `bots.referral-link-import`, `bots.restart-bot`, `bots.start-in-channel`, `bots.start-in-group`, `bots.start-in-group-as-admin`, `bots.start-private`, `bots.start-with-deeplink-param` + +
+ +### `bot stop` + +Stop and block a bot. + +``` +tlgr bot stop [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `BotStopped`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot to block. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--delete-chat` | flag | | Also delete the chat history. | +| `--report` | flag | | Report the bot as spam while blocking. | + +```console +$ tlgr bot stop @gifbot --json +``` + +
Catalog coverage (3 full, 0 partial) + +Full: `bots.delete-bot-chat-and-block`, `bots.stop-bot`, `dialogs.bot-stop-restart` + +
+ +### `bot stream send` + +Stream a live draft into a chat. + +``` +tlgr bot stream send [OPTIONS] +``` + +**mutating · returns `StreamProgress`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Destination chat. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--can-stop` | flag | | Let the user stop the generation. | +| `--draft-id` | int | | Draft random_id. | +| `--file` | path | | Read chunks from a file, one per line. | +| `--keep-on-stop` | flag | | Keep the partial answer if the user stops it. | +| `--rich-file` | path | | Next chunk, rich. | +| `--stop` | flag | | End the stream. | +| `--text` | text | | Next text chunk. | +| `--topic` | msg-id | | Forum topic id. | + +```console +$ tlgr bot stream send @alice --draft-id 99 --text Thinking… --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.ai-live-draft-streaming`, `bots.rich-message-draft-stream` + +
+ +### `bot token export` + +Export a managed bot's API token. + +`--revoke` breaks every deployment still using the old token, which is why it is confirmed like a deletion. + +``` +tlgr bot token export [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `BotToken`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The managed bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--out` | path | | Write it here, mode 0600. | +| `--revoke` | flag | | Invalidate the old token and issue a new one. | +| `--show` | flag | | Print the token; it is redacted by default. | + +```console +$ tlgr bot token export @my_helper_bot --out ./token --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.managed-bot-token` + +
+ +### `bot url-auth accept` + +Complete a seamless login and print the authorized URL. + +Destructive in the sense that matters: it logs you into a third-party site under your Telegram identity, which cannot be taken back from here. `--write-allowed` and `--share-phone` default off. + +``` +tlgr bot url-auth accept [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `UrlAuth`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `TARGET` | text | yes | Chat holding the button, or the OAuth deep link. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--button-id` | int | | Button id from the reply markup. | +| `--match-code` | text | | The emoji shown on the login page. | +| `--msg-id` | msg-id | | Message id for a button. | +| `--share-phone` | flag | | CONSENT: give the site my phone number. | +| `--write-allowed` | flag | | CONSENT: let the linked bot message me. | + +Also invocable as: `tlgr bot url_auth accept` + +```console +$ tlgr bot url-auth accept @examplebot --msg-id 12 --button-id 0 --json +``` + +
Catalog coverage (3 full, 1 partial) + +Full: `bots.login-url-button`, `bots.url-auth-match-code`, `bots.webapp-oauth-request` + +Partial: `bots.oauth-deeplink-login` + +Refusing an OAuth deep link is `bot url-auth decline`. + +
+ +### `bot url-auth decline` + +Refuse a seamless-login request. + +``` +tlgr bot url-auth decline [OPTIONS] +``` + +**mutating · returns `UrlAuth`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `URL` | text | yes | The OAuth deep link to decline. | + +Also invocable as: `tlgr bot url_auth decline` + +```console +$ tlgr bot url-auth decline tg://oauth?domain=example.org --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.oauth-deeplink-login`, `bots.url-auth-decline` + +
+ +### `bot url-auth get` + +Inspect a seamless-login request without accepting it. + +Telegram Login hands a website your identity. What it is about to hand over — the domain (or the verified app name), the browser, the platform, the IP and the region — is printed here first, and accepting is a separate command. + +``` +tlgr bot url-auth get [OPTIONS] +``` + +**mutating · returns `UrlAuth`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `TARGET` | text | yes | Chat holding the button, or the OAuth deep link. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--button-id` | int | | Button id from the reply markup. | +| `--check-code` | text | | Pre-validate this emoji match code. | +| `--in-app-origin` | text | | Origin of a mini-app request. | +| `--msg-id` | msg-id | | Message id for a button. | + +Also invocable as: `tlgr bot url_auth get`, `tlgr bot login-url get`, `tlgr link auth`, `tlgr auth url-login` + +```console +$ tlgr bot url-auth get @examplebot --msg-id 12 --button-id 0 --json +``` + +
Catalog coverage (4 full, 4 partial) + +Full: `auth.oauth-deep-link`, `auth.url-auth-bot-button`, `contacts-users.url-auth-login`, `messages-core.url-authorization` + +Partial: `bots.login-url-button`, `bots.oauth-deeplink-login`, `bots.url-auth-match-code`, `bots.webapp-oauth-request` + +Inspecting is this command; granting is `bot url-auth accept` and refusing is `bot url-auth decline`. + +
+ +### `bot username check` + +Check whether a bot username is available. + +``` +tlgr bot username check [OPTIONS] +``` + +**returns `BotUsernameCheck`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USERNAME` | text | yes | Candidate username. | + +```console +$ tlgr bot username check my_helper_bot --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.check-bot-username` + +
+ +### `bot username set` + +Enable, disable and reorder my bot's usernames. + +``` +tlgr bot username set [OPTIONS] +``` + +**mutating · returns `BotUsernames`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot I own. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--disable` | text | | Usernames to deactivate. | +| `--enable` | text | | Usernames to activate. | +| `--order` | text | | New display order. | + +```console +$ tlgr bot username set @my_helper_bot --enable my_helper_bot --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.bot-usernames` + +
+ +### `bot verification get` + +Show a peer's third-party verification badge. + +``` +tlgr bot verification get [OPTIONS] +``` + +**returns `BotVerification`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | User, bot or channel. | + +```console +$ tlgr bot verification get @alice --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.bot-verification-view` + +
+ +### `bot verification set` + +Verify or unverify a peer with my verifier bot. + +``` +tlgr bot verification set [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `BotVerified`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Peer to verify. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--bot` | user | | My verifier bot. | +| `--description` | text | | Custom badge description. | +| `--remove` | flag | | Remove the verification. | + +```console +$ tlgr bot verification set @alice --bot @my_verifier_bot --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.bot-verification-set` + +
+ +### `bot welcome delete` + +Delete one or all of a chat's bot welcome messages. + +``` +tlgr bot welcome delete [ID]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `WelcomeDeleted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The chat. | +| `ID` | int | any number | Welcome message ids. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | flag | | Delete every welcome message. | + +```console +$ tlgr bot welcome delete @mygroup 1 --json +``` + +### `bot welcome list` + +List a chat's bot welcome-message templates. + +``` +tlgr bot welcome list [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[BotWelcomeMessage]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The chat. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr bot welcome list @mygroup --json +``` + +### `bot welcome set` + +Add or edit a chat's bot welcome message. + +``` +tlgr bot welcome set [OPTIONS] +``` + +**mutating · returns `WelcomeSet`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The chat. | +| `TEXT` | text | yes | Welcome text. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--id` | int | | Edit this one instead. | +| `--keyboard` | path | | JSON keyboard. | +| `--parse` | md|html|none | | Text formatting. | + +```console +$ tlgr bot welcome set @mygroup Welcome! --json +``` diff --git a/docs/reference/inline.md b/docs/reference/inline.md new file mode 100644 index 0000000..f36d95e --- /dev/null +++ b/docs/reference/inline.md @@ -0,0 +1,257 @@ + + +# `tlgr inline` + +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 | +|---|---| +| [`inline edit`](#tlgr-inline-edit) | Edit a message sent through inline mode | +| [`inline prepared get`](#tlgr-inline-prepared-get) | Inspect a prepared inline message from a mini app | +| [`inline prepared save`](#tlgr-inline-prepared-save) | Save a prepared inline message for a user | +| [`inline prepared send`](#tlgr-inline-prepared-send) | Send a prepared inline message shared from a mini app | +| [`inline query`](#tlgr-inline-query) | Query an inline bot and list its results | +| [`inline search`](#tlgr-inline-search) | Search the built-in inline bots for GIFs, venues or images | +| [`inline send`](#tlgr-inline-send) | Send a chosen inline result to a chat | + +### `inline edit` + +Edit a message sent through inline mode. + +``` +tlgr inline edit [OPTIONS] +``` + +**mutating · returns `InlineEdited`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `INLINE_MSG_ID` | text | yes | Inline message id as dc:id:access_hash. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--buttons` | path | | New keyboard, as JSON. | +| `--media` | path | | New media. | +| `--no-preview` | flag | | Disable the link preview. | +| `--parse` | md|html|none | | Text formatting. | +| `--text` | text | | New text. | + +```console +$ tlgr inline edit 2:123:456 --text Updated --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.edit-inline-message` + +
+ +### `inline prepared get` + +Inspect a prepared inline message from a mini app. + +``` +tlgr inline prepared get [OPTIONS] +``` + +**returns `PreparedMessage`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The mini app's bot. | +| `ID` | text | yes | Prepared message id from the app. | + +```console +$ tlgr inline prepared get @my_helper_bot abc123 --json +``` + +
Catalog coverage (0 full, 1 partial) + +Partial: `bots.prepared-inline-message-send` + +Sending it is `inline prepared send`. + +
+ +### `inline prepared save` + +Save a prepared inline message for a user. + +``` +tlgr inline prepared save [OPTIONS] +``` + +**mutating · returns `PreparedSaved`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--peer-types` | text | | Chat types the picker may offer (repeatable). | +| `--result` | path | | JSON inline result. | +| `--user` | user | | Who will be able to share it. | + +```console +$ tlgr inline prepared save --user @alice --result ./result.json --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.prepared-inline-message-save` + +
+ +### `inline prepared send` + +Send a prepared inline message shared from a mini app. + +``` +tlgr inline prepared send [OPTIONS] +``` + +**mutating · returns `InlineSent`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The mini app's bot. | +| `ID` | text | yes | Prepared message id from the app. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--chat` | chat | | Destination chat. | +| `--hide-via` | flag | | Drop the 'via @bot' header. | +| `--reply-to` | msg-id | | Reply to this message. | +| `--silent` | flag | | Send without a notification. | + +```console +$ tlgr inline prepared send @my_helper_bot abc123 --chat @alice --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.prepared-inline-message-send` + +
+ +### `inline query` + +Query an inline bot and list its results. + +Paging offsets are opaque strings the bot invented, not integers: the `next_offset` on the first row is fed straight back, and an empty one means the end. A bot that does not answer is an empty page with a warning, not an error. + +``` +tlgr inline query [QUERY] [OPTIONS] +``` + +**paginated (`RATE` cursor) · returns `Page[InlineResult]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The inline bot. | +| `QUERY` | text | no | Query text; empty is valid. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--accuracy` | int | | Location accuracy radius in metres. | +| `--chat` | chat | | Chat the query is made from. | +| `--lat` | number | | Latitude for geo bots. | +| `--lon` | number | | Longitude for geo bots. | +| `--offset` | text | | Opaque next_offset from a page. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr inline query @gifbot cat --json +``` + +
Catalog coverage (7 full, 2 partial) + +Full: `bots.inline-query`, `bots.inline-query-paging`, `bots.inline-query-with-location`, `bots.inline-result-message-kinds`, `bots.inline-result-types`, `bots.inline-switch-webview`, `bots.switch-inline-button` + +Partial: `bots.inline-switch-pm`, `bots.webapp-switch-inline-query` + +A `switch_pm` button is completed with `bot start --param` and a `switch_webview` one with `webapp open --from-switch-webview`. + +
+ +### `inline search` + +Search the built-in inline bots for GIFs, venues or images. + +``` +tlgr inline search [QUERY] [OPTIONS] +``` + +**paginated (`RATE` cursor) · returns `Page[InlineResult]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `KIND` | text | yes | gif, venue or image. | +| `QUERY` | text | no | Search text. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--chat` | chat | | Chat the search is made from. | +| `--lat` | number | | Latitude (venue). | +| `--lon` | number | | Longitude (venue). | +| `--offset` | text | | Opaque next_offset. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr inline search gif cat --json +``` + +
Catalog coverage (3 full, 0 partial) + +Full: `bots.gif-search-inline`, `bots.img-search-inline`, `bots.venue-search-inline` + +
+ +### `inline send` + +Send a chosen inline result to a chat. + +`--paid-stars` agrees to a per-message Star fee. Naming the number is the consent: `--yes` is a CLI-level gate an operation never sees, so a flag that spends money spells out how much. + +``` +tlgr inline send [QUERY] [OPTIONS] +``` + +**mutating · returns `InlineSent`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The inline bot. | +| `QUERY` | text | no | Query to re-run for --pick. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--background` | flag | | Send in the background. | +| `--business-connection` | text | | Send as a business account (bot session). | +| `--chat` | chat | | Destination chat. | +| `--clear-draft` | flag | | Clear the chat draft. | +| `--hide-via` | flag | | Drop the 'via @bot' header. | +| `--paid-stars` | int | | Agree to pay N Stars for a paid-message peer. | +| `--pick` | text | | Result to send: index or result id. | +| `--query-id` | text | | query_id from a previous `inline query`. | +| `--quick-reply` | text | | Store it in a Business quick reply. | +| `--quote` | text | | Quoted fragment of the reply target. | +| `--reply-to` | msg-id | | Reply to this message. | +| `--result-id` | text | | Result id belonging to --query-id. | +| `--schedule` | text | | Schedule the send. | +| `--send-as` | chat | | Send as this peer. | +| `--silent` | flag | | Send without a notification. | +| `--topic` | msg-id | | Forum topic id. | + +```console +$ tlgr inline send @gifbot cat --chat @alice --pick 0 --json +``` + +
Catalog coverage (3 full, 2 partial) + +Full: `bots.inline-result-into-quick-reply`, `bots.send-inline-result`, `bots.webapp-switch-inline-query` + +Partial: `bots.gif-search-inline`, `bots.venue-search-inline` + +Running the built-in searches themselves is `inline search`. + +
diff --git a/docs/reference/payment.md b/docs/reference/payment.md new file mode 100644 index 0000000..45f7015 --- /dev/null +++ b/docs/reference/payment.md @@ -0,0 +1,298 @@ + + +# `tlgr payment` + +9 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 | +|---|---| +| [`payment card get`](#tlgr-payment-card-get) | Look up the issuing bank of a card BIN | +| [`payment form get`](#tlgr-payment-form-get) | Read an invoice's checkout form without paying | +| [`payment info delete`](#tlgr-payment-info-delete) | Clear my saved shipping information and saved cards | +| [`payment info get`](#tlgr-payment-info-get) | Show my saved order information and saved cards | +| [`payment invoice export`](#tlgr-payment-invoice-export) | Create an invoice deep link | +| [`payment invoice send`](#tlgr-payment-invoice-send) | Send an invoice message to a user | +| [`payment receipt get`](#tlgr-payment-receipt-get) | Show a payment receipt | +| [`payment subscription list`](#tlgr-payment-subscription-list) | List my Telegram Star subscriptions | +| [`payment subscription set`](#tlgr-payment-subscription-set) | Turn a Star subscription's auto-renewal on or off | + +### `payment card get` + +Look up the issuing bank of a card BIN. + +``` +tlgr payment card get [OPTIONS] +``` + +**returns `BankCard`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `NUMBER` | text | yes | Card number or BIN. | + +Also invocable as: `tlgr pay bank-card` + +```console +$ tlgr payment card get 411111 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.bank-card-data` + +
+ +### `payment form get` + +Read an invoice's checkout form without paying. + +Price, currency, provider, required fields and saved credentials. `payable_here` is always false and carries the reason: the shape a caller needs in order to decide is here, and the call that would charge is deliberately not. + +``` +tlgr payment form get [OPTIONS] +``` + +**returns `PaymentForm`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--business-transfer` | text | | Business → bot Stars transfer. | +| `--chat-invite` | text | | Star-subscription invite hash. | +| `--message` | text | | Invoice message with a Pay button. | +| `--slug` | text | | Invoice deep-link slug. | +| `--stars` | int | | Stars top-up form for N Stars. | +| `--theme` | path | | JSON theme params. | + +Also invocable as: `tlgr pay form get` + +```console +$ tlgr payment form get --slug tshirt-123 --json +``` + +
Catalog coverage (4 full, 0 partial) + +Full: `bots.get-payment-form`, `bots.invoice-deeplink`, `bots.invoice-input-kinds`, `bots.recurring-payment-terms` + +
+ +### `payment info delete` + +Clear my saved shipping information and saved cards. + +``` +tlgr payment info delete [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `PaymentInfoCleared`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--credentials` | flag | | Forget saved cards. | +| `--info` | flag | | Forget saved shipping/contact info. | + +Also invocable as: `tlgr pay saved-info clear` + +```console +$ tlgr payment info delete --credentials --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.saved-payment-info-clear` + +
+ +### `payment info get` + +Show my saved order information and saved cards. + +``` +tlgr payment info get [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `PaymentInfo`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--clear` | flag | | Clear the selected parts. | +| `--credentials` | flag | | Select saved cards. | +| `--shipping` | flag | | Select saved shipping info. | + +Also invocable as: `tlgr pay saved-info get`, `tlgr settings payment-info` + +```console +$ tlgr payment info get --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.saved-payment-info-get`, `privacy.clear-payment-info` + +
+ +### `payment invoice export` + +Create an invoice deep link. + +``` +tlgr payment invoice export [OPTIONS] +``` + +**mutating · returns `InvoiceLink`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--currency` | text | | ISO currency, or XTR for Stars. | +| `--description` | text | | Invoice description. | +| `--flexible` | flag | | Price depends on the shipping option. | +| `--need` | text | | name|phone|email|shipping (repeatable). | +| `--payload` | text | | Opaque bot payload. | +| `--photo` | text | | Invoice photo URL. | +| `--prices` | text | | Price components. | +| `--provider` | text | | Payment provider token (fiat). | +| `--provider-data` | json | | Provider JSON. | +| `--recurring-terms` | text | | Terms URL for a recurring one. | +| `--subscription-period` | text | | Recurring period (Stars only). | +| `--suggested-tips` | text | | Suggested tip amounts. | +| `--tip-max` | int | | Maximum tip the buyer may add. | +| `--title` | text | | Invoice title. | + +Also invocable as: `tlgr pay invoice export` + +```console +$ tlgr payment invoice export --title Shirt --description "A shirt" --currency USD --prices Shirt:1999 --payload order-1 --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.bot-subscription-invoice`, `bots.export-invoice-link` + +
+ +### `payment invoice send` + +Send an invoice message to a user. + +``` +tlgr payment invoice send [OPTIONS] +``` + +**mutating · returns `InvoiceSent`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | yes | Recipient (private only). | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--currency` | text | | ISO currency, or XTR for Stars. | +| `--description` | text | | Invoice description. | +| `--extended-media` | path | | Paid media behind the invoice. | +| `--flexible` | flag | | Price depends on the shipping option. | +| `--need` | text | | name|phone|email|shipping (repeatable). | +| `--payload` | text | | Opaque bot payload. | +| `--photo` | text | | Invoice photo URL. | +| `--prices` | text | | Price components. | +| `--provider` | text | | Payment provider token (fiat). | +| `--provider-data` | json | | Provider JSON. | +| `--silent` | flag | | Send without a notification. | +| `--title` | text | | Invoice title. | + +Also invocable as: `tlgr pay invoice send` + +```console +$ tlgr payment invoice send @alice --title Shirt --description Shirt --currency USD --prices Shirt:1999 --payload order-1 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.send-invoice-message` + +
+ +### `payment receipt get` + +Show a payment receipt. + +``` +tlgr payment receipt get [OPTIONS] +``` + +**returns `Receipt`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat holding the service message. | +| `MSG_ID` | msg-id | yes | Service message id. | + +Also invocable as: `tlgr pay receipt` + +```console +$ tlgr payment receipt get @shopbot 42 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.payment-receipt` + +
+ +### `payment subscription list` + +List my Telegram Star subscriptions. + +``` +tlgr payment subscription list [OPTIONS] +``` + +**paginated (`RATE` cursor) · returns `Page[StarSubscription]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--missing-balance` | flag | | Only ones that will lapse for lack of Stars. | + +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 pay subscription list`, `tlgr stars subs list` + +```console +$ tlgr payment subscription list --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.stars-subscriptions-list` + +
+ +### `payment subscription set` + +Turn a Star subscription's auto-renewal on or off. + +``` +tlgr payment subscription set [SUBSCRIPTION_ID] [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `SubscriptionChange`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `SUBSCRIPTION_ID` | text | no | My subscription id. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--auto-renew` | on|off | `off` | Resume or cancel auto-renewal. | +| `--charge-id` | text | | Bot side: provider charge id. | +| `--user` | user | | Bot side: the subscriber. | + +Also invocable as: `tlgr pay subscription set` + +```console +$ tlgr payment subscription set sub1 --auto-renew off --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `bots.bot-cancel-user-subscription`, `bots.stars-subscription-cancel` + +
diff --git a/docs/reference/webapp.md b/docs/reference/webapp.md new file mode 100644 index 0000000..d2ae703 --- /dev/null +++ b/docs/reference/webapp.md @@ -0,0 +1,227 @@ + + +# `tlgr webapp` + +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 | +|---|---| +| [`webapp download`](#tlgr-webapp-download) | Check a file download a mini app asked for | +| [`webapp get`](#tlgr-webapp-get) | Show a mini app's manifest | +| [`webapp invoke`](#tlgr-webapp-invoke) | Call a mini app's custom method | +| [`webapp open`](#tlgr-webapp-open) | Open a mini app and print its signed URL | +| [`webapp send`](#tlgr-webapp-send) | Send data from a keyboard-button mini app back to its bot | +| [`webapp watch`](#tlgr-webapp-watch) | Keep an open mini-app session alive | + +### `webapp download` + +Check a file download a mini app asked for. + +``` +tlgr webapp download [OPTIONS] +``` + +**returns `WebAppDownload`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The mini app's bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--fetch` | flag | | Actually download it; checking alone never does. | +| `--file-name` | text | | File name the app proposed. | +| `--out` | path | | Where to write it. | +| `--url` | text | | URL the app proposed. | + +Also invocable as: `tlgr app check-download` + +```console +$ tlgr webapp download @my_helper_bot --file-name i.pdf --url https://example.org/i.pdf --json +``` + +
Catalog coverage (2 full, 0 partial) + +Full: `attach.file-download-check`, `bots.webapp-file-download-check` + +
+ +### `webapp get` + +Show a mini app's manifest. + +``` +tlgr webapp get [SHORT_NAME] [OPTIONS] +``` + +**returns `WebAppInfo`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The bot owning the app. | +| `SHORT_NAME` | text | no | Direct-link app short name. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--button-request` | text | | Show the peer request behind this id. | + +Also invocable as: `tlgr app info`, `tlgr app get` + +```console +$ tlgr webapp get @my_helper_bot shop --json +``` + +
Catalog coverage (3 full, 1 partial) + +Full: `bots.button-request-peer-from-miniapp`, `bots.direct-link-app-open`, `bots.webapp-placeholder-and-close` + +Partial: `bots.miniapp-panel-menu` + +Installing and removing the app is `bot attach toggle`; reporting it is `bot report --app`. + +
+ +### `webapp invoke` + +Call a mini app's custom method. + +``` +tlgr webapp invoke [OPTIONS] +``` + +**mutating · returns `BotApiResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The mini app's bot. | +| `METHOD` | text | yes | Custom method name. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--params` | json | `{}` | JSON parameters. | + +Also invocable as: `tlgr app invoke` + +```console +$ tlgr webapp invoke @my_helper_bot getOrders --params "{}" --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.webapp-custom-method` + +
+ +### `webapp open` + +Open a mini app and print its signed URL. + +Printing the URL is the only behaviour: there is no --open, because a CLI cannot host the mini-app JS bridge and a browser launched from here would carry a credential into a process tlgr does not control. + +``` +tlgr webapp open [BOT] [OPTIONS] +``` + +**mutating · returns `WebAppSession`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | no | The bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--allow-write` | flag | | CONSENT: let the bot message me. | +| `--app` | text | | Direct-link app short name. | +| `--attach` | flag | | Attachment-menu app in --chat. | +| `--chat` | chat | | Chat the app is opened from. | +| `--from-switch-webview` | flag | | Inline-mode app behind a switch_webview button. | +| `--join-query-id` | text | | Guard-bot chat-join app. | +| `--main` | flag | | The bot's Main Mini App. | +| `--menu` | flag | | The bot's menu-button app. | +| `--mode` | compact|fullscreen | | Requested presentation mode. | +| `--open-inactive` | flag | | Open an app Telegram has marked inactive. | +| `--side-menu` | flag | | Side-menu app (implies --simple). | +| `--simple` | flag | | Simple web view. | +| `--start-param` | text | | startapp payload. | +| `--theme` | path | | JSON theme params. | +| `--url` | text | | Button URL for the app. | + +Also invocable as: `tlgr app open` + +```console +$ tlgr webapp open @my_helper_bot --main --json +``` + +
Catalog coverage (6 full, 3 partial) + +Full: `attach.open-mini-app`, `bots.attach-menu-deeplinks`, `bots.attach-webapp-open`, `bots.main-webapp-open`, `bots.simple-webapp-open`, `bots.webapp-modes` + +Partial: `bots.direct-link-app-open`, `bots.inline-switch-webview`, `bots.webapp-write-access` + +The app's manifest is `webapp get`; the attachment-menu install is `bot attach toggle`. The guard-bot chat-join view needs layer 229 and exits 13. + +
+ +### `webapp send` + +Send data from a keyboard-button mini app back to its bot. + +``` +tlgr webapp send [OPTIONS] +``` + +**mutating · returns `WebAppSent`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The mini app's bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--button-text` | text | | Text of the button that opened the app. | +| `--data` | text | | Payload, max 4096 bytes. | + +Also invocable as: `tlgr app send-data`, `tlgr webapp send-data` + +```console +$ tlgr webapp send @my_helper_bot --button-text Order --data "{}" --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.send-webview-data` + +
+ +### `webapp watch` + +Keep an open mini-app session alive. + +``` +tlgr webapp watch [OPTIONS] +``` + +**mutating · returns `Page[WebAppProlong]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `BOT` | user | yes | The mini app's bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--chat` | chat | | Chat the app was opened from. | +| `--interval` | text | `55s` | Prolong interval, e.g. 55s. | +| `--query-id` | text | | query_id from `webapp open`. | +| `--until` | text | | Stop after this long. | + +Also invocable as: `tlgr app session prolong` + +```console +$ tlgr webapp watch @my_helper_bot --query-id 987654321 --json +``` + +
Catalog coverage (1 full, 0 partial) + +Full: `bots.prolong-webview` + +
From ce8e514ae32313c2da4a275c1bb03177c4d41b24 Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 01:17:50 +0330 Subject: [PATCH 09/10] docs: what an agent can do with a bot now, and the six decisions behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENT.md and README gain the group, both leading with what it will not do: never pay, never open a browser, never press a disclosing button unasked. CHANGELOG names the three absences and the reply_markup fix. DECISIONS records the six calls that were not obvious — the not-supported tag, how a confirmation an operation must see is spelled when --yes cannot reach it, the 'bot id' alias collision, why 'bot connection invoke' refuses, the one reply-markup vocabulary, and reporting getEmojiGameInfo as it really answers. --- AGENT.md | 47 ++++++++++++++++++++++ CHANGELOG.md | 34 +++++++++++++++- README.md | 32 +++++++++++++++ docs/design/DECISIONS.md | 87 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) diff --git a/AGENT.md b/AGENT.md index cf93e44..33d5f17 100644 --- a/AGENT.md +++ b/AGENT.md @@ -999,6 +999,53 @@ outreach campaign has contacted. Idempotent: it reads the fresh there is nothing to do, so repeating a pass over hundreds of peers is nearly free. +### Bots, inline mode, mini apps and payments + +``` +tlgr bot get # the whole profile card in one call +→ {"id": …, "username": "…", "about": "…", "description": "…", + "commands": [{"command": "start", "description": "…", "has_help": true}], + "menu_button": {"kind": "commands"}, "bot_info_version": 3, …} + +tlgr bot start [--param X] # /start, with a hidden deep-link payload +tlgr bot stop [--delete-chat] # block, optionally wiping the history +tlgr bot command send [args] # --chat adds '@botusername' for you +tlgr bot press --button N # every button kind, one dispatcher +→ {"kind": "callback", "n": 0, "message": "Saved", "alert": false} + +tlgr inline query # @bot query, paginated by the bot +tlgr inline send --chat --pick 0 +tlgr webapp open --main # prints the signed URL; never opens it +tlgr payment form get --slug # read a checkout form; never pays +``` + +Four rules matter before scripting against these: + +- **`message get --json` prints the keyboard, and `n` is the address.** Each + button carries a flat row-major `n`; `bot press --button ` takes exactly + that number. `--button ""` and `--button ","` also work, and + `--data` addresses a callback button by its payload. +- **A button that discloses something is not pressed without saying so.** + `request_phone`, `request_geo`, `request_poll` and `request_peer` need + `--share-phone`, `--share-geo`, `--poll` and `--peers`; without one tlgr + prints what it *would* send and exits 2. A Pay button exits 6, always. +- **tlgr never spends money.** Reading a form, a receipt or a subscription is + implemented; creating an invoice (asking somebody else to pay) is + implemented; paying is absent from the surface. `payment form get` reports + `"payable_here": false` with the reason in `reason`. +- **A mini-app URL is a credential.** `webapp open` prints the signed URL and + stops — it carries your signed init data. A session that comes back with a + `query_id` dies in about a minute unless `webapp watch` keeps it alive + (`needs_prolong` says which). + +The bot-only half of the surface — `bot answer`, `bot command set`, +`bot menu set`, `bot api send`, `inline edit`, `payment invoice export` — +needs an account added from a bot token and exits **4** on a user session. +Five commands (`bot ephemeral send|delete`, `bot welcome list|set|delete`) are +registered and exit **13** with `NOT_SUPPORTED`: their methods need API layer +229, which this build does not speak. That is deliberately different from "no +such command". + ### Agent Helpers ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ea533..4f9cfe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,13 +61,45 @@ peers included: `story hide` is now the single implementation of the toggle and `user hide-stories` is a legacy path on it, so the two spellings cannot drift apart. +`bot`, `inline`, `webapp` and `payment` follow — 79 operations where v1 had +none. The whole bot surface as a person uses it (profile cards, `/start` with a +hidden deep-link payload, slash commands, every kind of button, Telegram +Login), the whole surface a bot uses (answering queries, publishing commands, +the menu button, inline results, invoices), mini apps, and payments read +end to end. Nothing is deleted, because there was no v1 bot code to delete. + +Three deliberate absences in that group are worth naming here. + +* **tlgr never spends money.** `payments.sendPaymentForm`, `sendStarsForm`, + `validateRequestedInfo` and `fulfillStarsSubscription` are not behind a flag + or an environment variable — they are not on the surface at all. + `payment form get` returns `payable_here: false` with the reason in the + payload, and a test asserts the property against the registry rather than + against a list of commands. +* **`webapp open` prints the mini-app URL and stops.** There is no `--open`: + the URL carries the user's signed init data and is a credential. +* **A button that discloses something is not pressed without its flag.** + `--share-phone`, `--share-geo`, `--poll`, `--peers`; without one, `bot press` + prints what it would have sent and exits 2. A Pay button exits 6. + +Five commands (`bot ephemeral send|delete`, `bot welcome list|set|delete`) are +registered and exit 13 `NOT_SUPPORTED`: they need API layer 229, which the +pinned Telethon does not speak. They exist so that "unavailable in this build" +is a different answer from "no such command". + +One bug fix rides along: `message get --json` now actually prints +`reply_markup`. PR-1 declared the shape and nothing ever populated it, which +made its two keyboard-rendering P0 ids true only on paper — a caller could see +no button, so a caller could press none. + ### 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` and `resolve` groups, +`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. diff --git a/README.md b/README.md index e1ed165..9be3517 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,38 @@ complete; **joining**, **removing somebody** and **sending inside** need a signed `e2e.chain` block that tlgr cannot build — pass one with `--block` and `--public-key`, or the command exits 2 naming what is missing. +### Bots, inline mode, mini apps and payments + +```bash +tlgr bot get # the profile card: commands, menu, flags +tlgr bot list --owned|--similar-to|--popular-apps|--recent +tlgr bot start --param # /start with a hidden deep-link payload +tlgr bot command send start # '@botusername' added in a group +tlgr bot press --button 0 +tlgr bot url-auth get|accept|decline # Telegram Login, inspected before granted +tlgr bot menu|permission|access|preview|affiliate|verification|token … + +tlgr inline query # @bot query, the bot's own paging +tlgr inline send --chat --pick 0 +tlgr inline search gif|venue|image # the built-in bots, named by the server + +tlgr webapp get|open|send|invoke|watch # mini apps; `open` prints the URL only +tlgr payment form get|receipt get|info get|card get +tlgr payment invoice export|send # asking someone else to pay +tlgr payment subscription list|set # cancel or resume a Star subscription +``` + +Three things this group does *not* do, on purpose: + +- **It never pays.** `sendPaymentForm`, `sendStarsForm`, `validateRequestedInfo` + and `fulfillStarsSubscription` are absent from the surface, not hidden behind + a flag. `payment form get` reports `payable_here: false` and says why. +- **It never opens a browser.** `webapp open` prints the signed mini-app URL, + which carries your init data and is a credential, not a link. +- **It never presses a button that discloses something without being told to.** + 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 ```bash diff --git a/docs/design/DECISIONS.md b/docs/design/DECISIONS.md index 143c837..d8fd518 100644 --- a/docs/design/DECISIONS.md +++ b/docs/design/DECISIONS.md @@ -1235,3 +1235,90 @@ it. The close-friends list and the live story's call — comments, RTMP key, send-as identity — were on that list too until PR-5 and PR-11 landed ahead of this one and covered them outright. `stories` is 94.2 % covered and 100 % accounted. + +## 2026-09-04 — an operation that only refuses claims no catalog coverage + +Five layer-229 commands (`bot ephemeral send|delete`, `bot welcome +list|set|delete`) are registered and exit 13. They are registered because +"unavailable in this build" and "no such command" are different answers and an +agent has to be able to tell them apart. They claim **nothing** in the parity +catalog, because a `covers_partial` on an operation that cannot run would +raise the coverage number while doing no work. Registry lint L13 gained a +second exemption tag, `not-supported`, so the two facts can both be true: the +op exists, and it counts for nothing. + +The nine catalog ids they would have claimed are waived to PR-12 with the layer +named in the reason. The same rule removed `bots.ephemeral-callback-press` and +`bots.rich-message-buttons` from `bot press`, and `bots.chat-join-webview` from +`webapp open`: each is one flag on an otherwise working command, and the flag +exits 13. + +## 2026-09-04 — a confirmation an *operation* must see is spelled as its own flag + +`--yes/-y` is a CLI-level gate: `cli/gen.py` reads it out of the Click context +and uses it for the `destructive` confirmation. It never reaches the daemon, so +an implementation cannot ask whether the user typed it. Three work-list items +asked for exactly that, and each is spelled differently: + +* **`bot press` consent.** The consent flag *is* the consent: `--share-phone`, + `--share-geo`, `--poll`, `--peers`. Requiring `--yes` on top would be a + second word for the same decision, and pressing an ordinary button would + start prompting. +* **`bot score set --yes` ("allow the score to decrease")** is Telegram's + `force`, and is now `--allow-lower` — which says what it does. +* **`webapp download --yes` ("actually download")** is `--fetch`. Checking is + the default and the fetch is a separate verb, not a confirmation of the check. +* **`inline send --paid-stars N`** names the amount, and naming it is the + consent. There is no flag that agrees to an unspecified charge. + +Where `--yes` genuinely fits — the whole operation is destructive — the spec +says `destructive=True` and the existing gate does the work: `bot url-auth +accept`, `bot token export`, `bot stop`, `bot affiliate revoke|unset`, +`bot attach toggle`, `bot preview delete`, `bot verification set`, +`payment info get|delete`, `payment subscription set`. + +## 2026-09-04 — `bot id` is a sub-noun, so the alias cannot also be a command + +The work list gives `bot.id.get` the alias `bot id`. It cannot have one: the +canonical path already puts a *group* at `bot id`, and placing a hidden command +there replaces the group, which takes `bot id get` with it. The alias is +dropped. The same collision decided `pay saved-info`: `payment info get` and +`payment info delete` are aliased `pay saved-info get` and `pay saved-info +clear`, so both live under one group instead of one of them replacing it. + +## 2026-09-04 — `bot connection invoke` is registered and refuses + +Wrapping an arbitrary tlgr command in `invokeWithBusinessConnection` would mean +re-entering the daemon's dispatcher from inside an operation, and `ops/` may +not import `daemon/` (§2.2) — the import lint checks the AST, so a +function-level import does not help either. The wrapper itself *is* +implemented: it is `--business-connection` on `bot command send`, `bot press` +and `inline send`, where it is built, sent to the connection's own DC through +an exported sender, and tested. The command exists so that the gap has a name +and a pointer; it exits 13. + +## 2026-09-04 — the reply-markup schema is one vocabulary, read and write + +`models/message.ReplyMarkup` was declared by PR-1 and never populated, which +made `bots.inline-keyboard-render` and `bots.reply-keyboard-render` — both P0 — +true only on paper. `message_to_model` now fills it, and `models/bot.Keyboard` +is the write side of the *same* button-type vocabulary, so a keyboard read out +of `message get --json` can be pressed with `bot press --button ` and sent +back through `--keyboard`. Two schemas for one object is how a button that can +be read stops being a button that can be pressed. + +Each button carries `n`, the flat row-major index, with a default of `-1`: +`omit_defaults` would otherwise drop the index of the first button and only the +first button. The same reasoning moved every decision-carrying boolean — +`payable_here`, `available`, `restricted`, `allowed`, `cancelled` — to a +tri-state, so that "false" is emitted rather than inferred from an absence. + +## 2026-09-04 — `bot game get` reports what the method actually returns + +The work list describes `messages.getEmojiGameInfo` as returning stakes and +payouts. It takes no arguments and returns `emojiGameDiceInfo(game_hash, +prev_stake, current_streak, params, plays_left)` or `emojiGameUnavailable`. +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. From dab6904edfee1cedaab254bbf2b11c7e09e11434 Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 01:21:49 +0330 Subject: [PATCH 10/10] tags: eleven operations of this group are visible to somebody else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE §9's rule, applied: a command that puts a message, a game, an invoice, a typing action or a public badge where another person sees it says so in its spec, and therefore in --help. --- tlgr/ops/bot.py | 8 ++++++++ tlgr/ops/inline.py | 2 ++ tlgr/ops/payment.py | 1 + 3 files changed, 11 insertions(+) diff --git a/tlgr/ops/bot.py b/tlgr/ops/bot.py index 1488b60..295de5d 100644 --- a/tlgr/ops/bot.py +++ b/tlgr/ops/bot.py @@ -728,6 +728,7 @@ async def _add_to_chat(ctx: OpContext, peer: Any, bot: Any) -> None: response=BotStarted, impl=start, summary="Start a bot, with a deep-link parameter or inside a group", + tags=frozenset({"visible-to-others"}), description=( "`--param` is the payload behind a `t.me/?start=…` link and is " "never written into the chat, which is the whole point of a deep " @@ -1031,6 +1032,7 @@ def _connection_dc(result: Any) -> int: response=CommandSent, impl=command_send, summary="Send a slash command to a bot", + tags=frozenset({"visible-to-others"}), description=( "Driving @BotFather's own conversation with this command and " "`bot press` is the only way to reach the toggles Telegram exposes " @@ -1880,6 +1882,7 @@ async def _answer_webapp_request(ctx: OpContext, req: PressReq, peer: Any) -> Pr response=Pressed, impl=press, summary="Press a button on a message", + tags=frozenset({"visible-to-others"}), description=( "One dispatcher for every button kind, returning a typed answer: a " "callback toast, a URL, a signed mini-app session, inline results, a " @@ -2415,6 +2418,7 @@ def _inline_results(path: str | None) -> list[Any]: response=BotAnswer, impl=answer, summary="Answer a pending bot query", + tags=frozenset({"visible-to-others"}), description=( "Callback, inline, shipping, pre-checkout, guest, mini-app and " "webhook queries, one flag set per kind. Answering a pre-checkout " @@ -2786,6 +2790,7 @@ def _stream_chunks(req: StreamSendReq) -> list[str]: response=StreamProgress, impl=stream_send, summary="Stream a live draft into a chat", + tags=frozenset({"visible-to-others"}), mutating=True, rate_class="send", columns=("chat_id", "draft_id", "chunks_sent"), @@ -3381,6 +3386,7 @@ async def verification_set(ctx: OpContext, req: VerificationSetReq) -> BotVerifi response=BotVerified, impl=verification_set, summary="Verify or unverify a peer with my verifier bot", + tags=frozenset({"visible-to-others"}), mutating=True, destructive=True, columns=("peer_id", "verified", "description"), @@ -4519,6 +4525,7 @@ async def game_send(ctx: OpContext, req: GameSendReq) -> GameSent: response=GameSent, impl=game_send, summary="Send an HTML5 game to a chat", + tags=frozenset({"visible-to-others"}), mutating=True, rate_class="send", columns=("chat_id", "msg_id", "short_name"), @@ -4673,6 +4680,7 @@ async def score_set(ctx: OpContext, req: ScoreSetReq) -> ScoreSet: response=ScoreSet, impl=score_set, summary="Report a game score for a user", + tags=frozenset({"visible-to-others"}), mutating=True, columns=("user_id", "score", "position"), headers=("User", "Score", "#"), diff --git a/tlgr/ops/inline.py b/tlgr/ops/inline.py index 9c50d00..08b0f63 100644 --- a/tlgr/ops/inline.py +++ b/tlgr/ops/inline.py @@ -492,6 +492,7 @@ async def _invoke_as(ctx: OpContext, connection_id: str | None, request: Any) -> response=InlineSent, impl=send, summary="Send a chosen inline result to a chat", + tags=frozenset({"visible-to-others"}), description=( "`--paid-stars` agrees to a per-message Star fee. Naming the number " "is the consent: `--yes` is a CLI-level gate an operation never sees, " @@ -761,6 +762,7 @@ async def prepared_send(ctx: OpContext, req: PreparedSendReq) -> InlineSent: response=InlineSent, impl=prepared_send, summary="Send a prepared inline message shared from a mini app", + tags=frozenset({"visible-to-others"}), mutating=True, rate_class="send", columns=("chat_id", "msg_id", "result_id"), diff --git a/tlgr/ops/payment.py b/tlgr/ops/payment.py index be32360..0342878 100644 --- a/tlgr/ops/payment.py +++ b/tlgr/ops/payment.py @@ -725,6 +725,7 @@ async def invoice_send(ctx: OpContext, req: InvoiceSendReq) -> InvoiceSent: response=InvoiceSent, impl=invoice_send, summary="Send an invoice message to a user", + tags=frozenset({"visible-to-others"}), aliases=("pay.invoice.send",), mutating=True, rate_class="send",