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. 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` + +
diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 2da8e82..129b2fa 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -764,6 +764,52 @@ 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) + sponsored_peers: list[int] = 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 +1737,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 +3623,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 +4392,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 +4412,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 +4541,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 +5857,607 @@ 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_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 + + 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..89cc65b --- /dev/null +++ b/tests/test_ops_bot.py @@ -0,0 +1,2569 @@ +"""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_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" + + +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/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/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..139c768 --- /dev/null +++ b/tlgr/models/bot.py @@ -0,0 +1,576 @@ +"""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` 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 + + +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` 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 | None = None + 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 = "" + has_access_hash: bool = False + username: str | None = None + + +class BotUsernameCheck(Model): + username: str = "" + #: None only if nothing was checked; a real answer is always emitted. + available: bool | None = None + 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 | None = None + emoji_status_allowed: bool | None = None + key: str | None = None + state: str | None = None + already: bool = False + + +class BotVerified(Model): + peer_id: int = 0 + verified: bool | None = None + 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 | None = None + write_allowed: bool | None = None + + +class RecentBots(Model): + enabled: bool = True + kind: str = "pm" + forgotten: list[int] = [] + + +class SponsoredRead(Model): + random_id: str = "" + viewed: bool | None = None + clicked: bool | None = None + + +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..ee8b520 --- /dev/null +++ b/tlgr/models/inline.py @@ -0,0 +1,84 @@ +"""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. + #: -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 + description: str | None = None + url: str | None = None + thumb: str | None = None + #: `url` for a `botInlineResult`, `media` for a `botInlineMediaResult`. + #: 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 + 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/message.py b/tlgr/models/message.py index 9db2460..07077fc 100644 --- a/tlgr/models/message.py +++ b/tlgr/models/message.py @@ -144,11 +144,20 @@ 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. 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 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/models/payment.py b/tlgr/models/payment.py new file mode 100644 index 0000000..9689ea1 --- /dev/null +++ b/tlgr/models/payment.py @@ -0,0 +1,172 @@ +"""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, 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 = "" + + +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 | 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 | None = None + cleared: bool = False + + +class PaymentInfoCleared(Model): + credentials_cleared: bool | None = None + info_cleared: bool | None = None + + +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 | 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 | None = None + missing_balance: bool | None = None + 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 | None = None + until_date: str | None = None diff --git a/tlgr/models/webapp.py b/tlgr/models/webapp.py new file mode 100644 index 0000000..34a0f26 --- /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 | None = None + file_name: str = "" + url: str = "" + downloaded: bool | None = None + path: str | None = None + + +class WebAppProlong(Model): + query_id: str = "" + prolonged_at: str | None = None + alive: bool = True + reason: str | None = None 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 # --------------------------------------------------------------------------- diff --git a/tlgr/ops/bot.py b/tlgr/ops/bot.py new file mode 100644 index 0000000..295de5d --- /dev/null +++ b/tlgr/ops/bot.py @@ -0,0 +1,4880 @@ +"""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 []) if hasattr(raw, "commands") else (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: + """`bots.accessSettings` as the model. The allow-list is `add_users`.""" + return BotAccess( + restricted=bool(getattr(settings, "restricted", False)), + allowed_users=[ + 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, "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 +# --------------------------------------------------------------------------- + + +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", + 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 " + "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", "dialogs.bot-stop-restart"), +) + + +# --------------------------------------------------------------------------- +# 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", + 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 " + "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(kind="commands") + + +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(str(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, + hash=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", + 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 " + "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", + 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 " + "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=_business_rights(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", + tags=frozenset({"visible-to-others"}), + 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) + 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: + 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", + tags=frozenset({"visible-to-others"}), + 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") + 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) + 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 <= 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[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: 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: + 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 | 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 — or a search — would show. + + Opt-in, like `message sponsored list`: tlgr never mixes ads into a message + 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 = [ + 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)) + + +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, + 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", "dialogs.sponsored-search-peers"), + 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", + tags=frozenset({"visible-to-others"}), + 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", + tags=frozenset({"visible-to-others"}), + 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", + tags=frozenset({"not-supported"}), +) + + +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", + tags=frozenset({"not-supported"}), +) + + +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", + tags=frozenset({"not-supported"}), +) + + +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!", + tags=frozenset({"not-supported"}), +) + + +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", + tags=frozenset({"not-supported"}), +) diff --git a/tlgr/ops/inline.py b/tlgr/ops/inline.py new file mode 100644 index 0000000..08b0f63 --- /dev/null +++ b/tlgr/ops/inline.py @@ -0,0 +1,773 @@ +"""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) + # 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 ""), + 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", + 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, " + "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", + tags=frozenset({"visible-to-others"}), + 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..0342878 --- /dev/null +++ b/tlgr/ops/payment.py @@ -0,0 +1,907 @@ +"""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", + tags=frozenset({"visible-to-others"}), + 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) or None, + ) + ) + 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..f28028f --- /dev/null +++ b/tlgr/ops/webapp.py @@ -0,0 +1,717 @@ +"""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.page import Page +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 Page( + items=[ + WebAppProlong( + query_id=req.query_id, alive=False, reason="the session has expired" + ) + ], + has_more=False, + ) + return + 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) + + +def _now() -> Any: + from datetime import datetime, timezone + + return datetime.now(timezone.utc) + + +SPEC_WATCH = OperationSpec( + id="webapp.watch", + request=WatchReq, + response=Page[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={"items": [{"query_id": "987654321", "alive": True}], "has_more": False}, + 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"), +) 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")