diff --git a/AGENT.md b/AGENT.md index 5c427ca..ff19cd1 100644 --- a/AGENT.md +++ b/AGENT.md @@ -489,8 +489,135 @@ event loop's clock, so every timed mute resolved to 1970 and did nothing. `chat secret list|start|send` are registered and refuse with `NOT_SUPPORTED` (exit 13) — tlgr has no end-to-end layer; `chat secret discard` works. -`chat create` and `chat members` are unchanged from v1 and migrate with the -groups-and-channels group. +### Groups and channels + +Members, admins, invites, topics and the Manage screen. Three shapes matter +before you script any of it. + +* **A member is a participant, not a user.** Every row keeps its wrapper — + `status` (creator/admin/member/self/restricted/banned/left), `rank`, `date`, + `inviter_id`, `promoted_by`, `kicked_by`, `admin_rights`, `banned_rights` — + so "is this person banned or merely restricted" is answerable. +* **Rights are always allow-polarity.** Telegram stores banned rights inverted + (`send_messages=true` means *cannot* send); tlgr normalises once, so `true` + means allowed everywhere, including in `banned_rights`. +* **Both masks are replaced, never patched, server-side.** Every writing + command reads the current mask first and sends a complete one, which is why + `chat member restrict --deny send-media` does not hand back the four other + restrictions somebody set last week. + +``` +tlgr chat member list [--filter recent|admins|bots|contacts|kicked|banned|restricted|mentions] + [--search Q] [--via-link LINK] [--topic ID] [--limit N] [--all] +→ page of {"id", "user_id", "username", "name", "status", "rank", "date", + "inviter_id", "promoted_by", "kicked_by", "admin_rights", "banned_rights"} +# `chat members` is v1's spelling and still works. --filter kicked is people +# who were removed; --filter restricted is people still in the chat with a +# mask on them. A chat with participants_hidden answers exit 6, not an empty +# page — "nobody is in this group" would be a lie. + +tlgr chat member get # + effective_permissions: defaults patched with their mask +tlgr chat member add ... # missing[] carries {user_id, reason} verbatim +tlgr chat member remove ... # a kick: they may rejoin +tlgr chat member ban ... [--until 7d] [--purge] [--messages ID] [--report] --yes +tlgr chat member unban ... +tlgr chat member restrict [--deny R,R] [--allow R,R] [--none|--all|--clear] + [--replace] [--until 7d] [--purge] +tlgr chat member edit [--rank TITLE] [--free-messages on|off] [--refund] +tlgr chat member delete-history --yes +tlgr chat member report [--messages ID] --yes + +tlgr chat admin list [--no-rights] +tlgr chat admin promote [--rights R,R|--grant R,R|--revoke R,R|--all|--none] + [--except R,R] [--rank TITLE] [--anonymous] +tlgr chat admin demote --yes +tlgr chat permission list [--mask admin|member|all] [--chat CHAT] # the canonical names +tlgr chat permission get → {"allow": [...], "deny": [...], "rights": {...}} +tlgr chat permission set [--allow R,R] [--deny R,R] [--all|--none] [--replace] +tlgr chat transfer --password-stdin --yes # 2FA, irreversible +tlgr chat admin-log list [--filter join,ban,kick,…] [--admin USER] [--search Q] +→ page of {"id", "date", "user_id", "action", "raw_type", "prev", "new"} +tlgr chat admin-log report # anti-spam false positive +``` + +`chat permission list` is the single source of truth for right names. +`manage-linked-peers` and `manage-welcome-messages` are layer-229 flags +Telethon 1.44 cannot express: they are listed with `"supported": false` and +asking for one exits 13 rather than granting less than you asked for. + +Invites, join requests and topics: + +``` +tlgr chat invite create [--title T] [--expires 7d] [--limit N|--request-approval] + [--subscription-stars N] [--replace-primary] +tlgr chat invite list [--admin USER] [--revoked] [--by-admin] +tlgr chat invite get | chat invite get [--qr] [--png PATH] +tlgr chat invite edit [--title T] [--expires …] [--limit N] [--request-approval on|off] +tlgr chat invite revoke --yes # the permanent link is replaced, both are reported +tlgr chat invite delete [|--revoked] --yes +tlgr chat invite open # read a peek without joining +tlgr chat join <@name|t.me/+hash> +→ {"chat_id", "title", "joined", "pending_approval", "already"} +# All three outcomes exit 0: joined, already a member, and request-sent. + +tlgr chat request list [--link L] [--search Q] [--approve USER] [--decline USER] + [--approve-all] [--decline-all] +tlgr chat request approve|deny [...] [--all] [--link L] --yes + +tlgr chat topic list [--search Q] [--closed] [--hidden] [--pinned] +tlgr chat topic get ... # a deleted topic comes back as {"id", "deleted": true} +tlgr chat topic create [--icon-emoji ID] [--icon-color RGB] +tlgr chat topic edit <chat> <topic> [--title T] [--icon-emoji ID|--no-icon] [--closed on|off] +tlgr chat topic close|reopen <chat> <topic> +tlgr chat topic hide|unhide <chat> # General (id 1) only +tlgr chat topic pin <chat> <topic>... [--reorder] | chat topic unpin <chat> [<topic>...|--all] +tlgr chat topic mute <chat> <topic> [8h] | chat topic unmute <chat> <topic> +tlgr chat topic delete <chat> <topic> --yes +tlgr chat topic read <chat> <topic> [--max-id N] [--mentions] [--reactions] [--list] +``` + +A topic id **is** the id of its creation service message, so it is exactly what +`message send --topic` and `message list --topic` take. General is id 1, always +exists, cannot be deleted, and is the only topic that may be hidden. + +The Manage screen, and the numbers behind it: + +``` +tlgr chat create <title> [--type group|supergroup|channel|forum] [--about T] [--members USER] + [--photo PATH] [--username NAME] [--ttl 1d] [--geo lat,lon] [--tabs] +tlgr chat edit <chat> [--title T] [--about T] [--geo lat,lon|off] [--color ID] [--emoji-status ID] + [--main-tab posts|gifts|media|…] [--palettes] +tlgr chat convert <chat> supergroup|gigagroup --yes # one-way, both ids reported +tlgr chat setting get <chat> # every toggle, keyed as its flag +tlgr chat setting set <chat> [--slow-mode 30s] [--prehistory visible|hidden] [--forum on|off] + [--antispam on|off] [--hidden-members on|off] [--signatures on|off] + [--reactions all|none|👍,❤️] [--sticker-set NAME] [--ads on|off] … +→ {"chat_id", "changed": [...], "already": [...], "failed": {"key": "why"}} +tlgr chat username get <name> [--chat CHAT] | chat username set <chat> <name> [--order LIST] +tlgr chat username toggle <chat> <name> on|off | chat username unset <chat> [--all] +tlgr chat photo set <chat> <file>|--video PATH|--emoji-markup ID | chat photo delete <chat> +tlgr chat send-as list <chat> | chat send-as set <chat> <peer> +tlgr chat discussion list | chat discussion set <channel> <group> [--unhide-prehistory] + | chat discussion unset <channel> +tlgr chat stats get <chat> [--message ID|--story ID|--poll ID] [--load-graphs] [--out DIR] +tlgr chat stats list <chat> --message ID|--story ID # public reposts +tlgr chat revenue get <chat> [--ton] [--since USER] | chat revenue list <chat> [--in|--out] +tlgr boost get [<chat>] [--features] | boost list [<chat>] [--mine|--user U|--gifts] +tlgr boost add <chat> [--slots N] +``` + +`chat setting set` is a batch: a toggle already in the state you asked for is +reported in `already` and never sent, and a refusal lands in `failed` per key +instead of hiding the changes that did land. Statistics are routed to +`channelFull.stats_dc` automatically, and graphs are emitted as Telegram's own +chart spec — tlgr never redraws them. Revenue is read-only: withdrawing money +needs your 2FA password and belongs in an official client. + +Seven commands are registered and refuse with `NOT_SUPPORTED` (exit 13): +`chat community create|list|set|ban` and `chat welcome list|set|delete` need +MTProto layer 229 and Telethon 1.44 speaks 227, so there is no request to +send. The command shapes are settled and will start working with the layer +uplift. ### Folders diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f7913..cf06a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,17 @@ on the busiest group, not on a toy one. `tlgr/cli/message.py` and `tlgr/cli/draft.py` are deleted rather than shadowed. The `chat` and `folder` groups follow: 47 more operations covering the dialog -list, the per-chat settings and the chat folders. `tlgr/cli/legacy/chat.py` -keeps exactly `chat create` and `chat members`, which are member-and-admin -operations and migrate with the groups-and-channels group. +list, the per-chat settings and the chat folders. + +Then the groups-and-channels group: 86 more operations covering members, +admins, invites, join requests, topics, the Manage screen, the admin log, +statistics, revenue and boosts. `tlgr/cli/legacy/chat.py` is deleted along +with the `/chat/create` and `/chat/members` IPC routes and +`ClientWrapper.create_chat` / `list_participants`; both v1 paths keep working +through the registry. Seven commands — `chat community create|list|set|ban` +and `chat welcome list|set|delete` — are registered and refuse with +`NOT_SUPPORTED` (exit 13) because they need MTProto layer 229 and Telethon +1.44 speaks 227. `auth`, `account` and `passport` follow: 51 more operations covering logging in, the Devices list, 2-step verification, connected websites, passkeys and @@ -101,6 +109,13 @@ Three more changed in the contact and user groups: | 11 | `contact.add` | `{"added": true, "user_id": 123}` | the same two keys plus `imported`, `retry`, `popular_importers` and `reason` | additive. `reason` is filled when the import came back empty, because "no such account" and "the owner hides their number" are indistinguishable and v1 reported the first | | 12 | `user.get` | `{"id","first_name","username","bio","is_bot","status","stories_hidden",…}` | the same keys, plus everything `users.getFullUser` carries | additive; `--select` reaches any of it. `--field` is gone: the global `--select bio --results-only` does the same thing on every command | +Two more in the groups-and-channels group: + +| # | Change | v1 | v2 | Migration | +|---|---|---|---|---| +| 10 | `chat.member.list` (`chat members`) | `{"members":[{"id","first_name","last_name","username","is_bot"}]}` | `Page[Participant]`, each row keeping its `ChannelParticipant*` wrapper: `status`, `rank`, `date`, `inviter_id`, `promoted_by`, `kicked_by`, `admin_rights`, `banned_rights` | `--results-only` yields `{items, has_more, next_cursor, total}`; `id`, `username` and `is_bot` are unchanged, and `first_name`/`last_name` are joined into `name` (`--select name` reaches it). The dropped wrapper was why v1 could list members but not say whether one was banned or merely restricted | +| 11 | `chat.create` | `{"id","name","type"}` with `--type group\|channel` | `{"id","type","title","username","invite_link","added","missing"}` with `--type group\|supergroup\|channel\|forum` | `name` became `title` (`--select title`), and `--type group` still means the legacy basic group. `missing` names every seed member the server refused, instead of dropping them | + `tlgr agent whoami --json` reports `output_schema_version: 2`, so an agent can branch on the two sets without probing for each change. diff --git a/README.md b/README.md index 97ebe24..04a17fd 100644 --- a/README.md +++ b/README.md @@ -155,13 +155,32 @@ tlgr chat theme set <chat> --emoji 🌷 tlgr chat wallpaper set <chat> --slug pattern tlgr chat badge get --limits # the unread badge, and the chat-list limits behind it tlgr chat report <chat> --spam --yes -tlgr chat members <chat> # --admins, --search, --limit -tlgr chat create <name> # --type group|channel, --members +``` + +Members, admins, invites and topics: + +``` +tlgr chat member list <chat> --filter admins # `chat members` still works +tlgr chat member ban <chat> @spammer --purge --report --yes +tlgr chat member restrict <chat> @noisy --deny send-media --until 7d +tlgr chat admin promote <chat> @alice --rights ban-users,delete-messages +tlgr chat permission list --mask member # the canonical right names +tlgr chat permission set <chat> --deny send-stickers +tlgr chat invite create <chat> --limit 25 --expires 7d +tlgr chat request approve <chat> --all --yes +tlgr chat topic create <chat> Releases # the id `--topic` takes +tlgr chat setting set <chat> --slow-mode 30s --hidden-members on +tlgr chat create <name> --type supergroup # --members, --photo, --username +tlgr chat stats get <channel> --load-graphs +tlgr boost add <channel> ``` `chat list` returns a page of dialogs whose peer is nested under `chat`; `chat catchup` and `chat list` never emit a read receipt, `chat open` does on -purpose, and `chat unread` restores only your own badge. Full reference: +purpose, and `chat unread` restores only your own badge. A member row keeps +its participant wrapper (status, rank, promoter, both rights masks) and every +mask is allow-polarity, so `chat permission get` round-trips straight back +into `chat permission set`. Full reference: [`docs/reference/chat.md`](docs/reference/chat.md). ### Folders diff --git a/docs/design/DECISIONS.md b/docs/design/DECISIONS.md index 22f4b98..f781bc2 100644 --- a/docs/design/DECISIONS.md +++ b/docs/design/DECISIONS.md @@ -1014,3 +1014,117 @@ path segment against it. COMMANDS.md's conventions already carve out verb-first nouns (`resolve <kind>`, `search <scope>`), so `peer`, `phone` and `username` were added to `VERBS` with a comment saying which rule they arrive under — rather than bending the paths into `resolve peer get`. + +## 2026-09-03 — one rights vocabulary, in `ops/_rights.py`, or four that drift + +`chat admin promote --rights`, `chat member restrict --deny`, `chat permission +set --allow` and `bot default-rights set` all name the same two masks. Writing +the names four times would have produced four spellings within a release, so +the table lives in one module and every command reads it — which is also what +lets `chat permission list` be the answer to "what may I type here" instead of +a paragraph of documentation. The same module owns both conversions: +`ChatBannedRights` is stored inverted, and a single missing `not` turns "may +not send media" into "may send media", so the inversion happens once rather +than once per caller. + +## 2026-09-03 — every mask is read before it is written + +`channels.editBanned`, `channels.editAdmin` and +`messages.editChatDefaultBannedRights` replace the whole mask: a flag the +request omits is a flag the server clears. Sending only what the caller named +would therefore hand back every restriction somebody set last week, silently, +as a side effect of taking one new one away. `chat member restrict`, +`chat admin promote` and `chat permission set` all fetch the current mask +first and send a complete one, and the tests assert the *untouched* flags +survive rather than only that the named one changed. + +## 2026-09-03 — a layer-229 right is refused, not dropped + +`manage-linked-peers` and `manage-welcome-messages` exist in the API and have +no field in Telethon 1.44's `ChatAdminRights`. Accepting them and quietly +granting less than the caller asked for is the worst available outcome for a +permission change, so `chat permission list` marks them `supported: false` and +asking for one exits 13 with the reason. The same rule produced the seven +registered-and-refusing commands (`chat community *`, `chat welcome *`): a +command that refuses with an explanation tells an agent what is missing and +when it arrives; a command that is absent tells it nothing. + +## 2026-09-03 — `chat setting set` is a batch that reports per key + +The Manage screen is one screen in the GUI and about twenty MTProto methods +underneath, most of them gated on a different right or a boost level. Aborting +on the first refusal would let a boost-gated `--autotranslate` hide nine +changes that landed, and reporting only "ok" would hide the refusal. Flags are +therefore applied in a fixed order, a toggle already in the requested state is +reported in `already` and never sent, and each failure lands in `failed` under +its own key while the rest still run. + +## 2026-09-03 — `chat request list` stays a read, and answers under its own guard + +The work list marks the command mutating because `--approve`/`--decline` write. +Declaring the whole op mutating would make `--dry-run chat request list` print +a stub instead of the queue — the wrong trade for the command people run to +*see* who is waiting. The answer flags check `ctx.dry_run` themselves and warn +instead of firing, which is the rule `folder list --tags` already follows. + +## 2026-09-03 — every `stats.*` call follows STATS_MIGRATE + +Telegram answers a statistics request on the wrong data centre with +`STATS_MIGRATE_X`, and a client that does not follow it reports "no +statistics" for a channel that has plenty. Telethon's `get_stats` handles this +for two of the calls; tlgr needs six, so the migration is followed once in +`_stats()` through the same borrowed exported sender, and `chat stats list`, +`--load-graphs` and the story and poll variants all get it for free. + +## 2026-09-03 — a graph is emitted verbatim, and revenue is read-only + +Telegram's chart specification is a JSON document with columns, types, +colours, subcharts and stacking rules. Redrawing it would mean re-deciding +every one of those, in a CLI with no viewport, and a chart that is subtly +wrong is worse than no chart — so `chat stats get` reports the API's own +payload and lets the caller render. For the same class of reason +`payments.getStarsRevenueWithdrawalUrl` is not implemented: it moves money and +wants the 2FA password, so `chat revenue get` says a withdrawal is available +and points at an official client. + +## 2026-09-03 — tlgr never reports a sponsored-message impression + +`messages.viewSponsoredMessage` and `clickSponsoredMessage` tell the server an +advert was seen. A headless CLI has no viewport, so calling them would be +reporting something that did not happen — to the advertiser and to the channel +owner both. `chat sponsored list` therefore exposes the messages as data with +`viewed: false`, and the owner-side switch stays `chat setting set --ads off`. + +## 2026-09-03 — the QR code needs an optional extra rather than an encoder + +`chat invite get --qr` renders locally; the API contributes nothing beyond the +link. Carrying a pure-Python QR encoder for one P2 flag is a lot of surface to +own and to test, so the command tries `segno` (`pip install 'tlgr[qr]'`) and, +when it is absent, warns and still reports the link. The catalog id is claimed +as partial with that note, so the parity number does not overstate what a +default install does. + +## 2026-09-03 — no bare `chat similar` alias + +The work list lists `chat similar` as an alias of `chat similar list`. Click +cannot hold a command and a group under one name, so registering it would +replace the `chat similar` *group* and take `chat similar list` off the tree +entirely. The canonical three-word path is the only spelling; nothing else in +the group has this shape. + +## 2026-09-03 — `chat permission list` runs in the daemon + +The vocabulary is a static table and would happily be a `Surface.LOCAL` +operation, except that `--chat` marks which rights *you* may currently grant, +which needs your own participant row. Splitting the command in two for one +flag would be worse than one round trip, so the op is a normal daemon +operation with `rate_class="local"`: without `--chat` it answers from the +table and touches no network. + +## 2026-09-03 — the reference stays one page per group + +`docs/reference/` is generated one page per top-level noun, so the 86 new +operations land in `chat.md` and `boost.md` rather than in a hand-named +`chat-admin.md`. Adding a second grouping rule to the generator would mean the +page a command lives on is no longer derivable from its id, which is the +property that makes the docs impossible to get out of sync. diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index 1c63c36..c9a17a0 100644 --- a/docs/reference/PARITY.md +++ b/docs/reference/PARITY.md @@ -7,31 +7,31 @@ Coverage against the Telegram feature catalog, computed from the registry: every `covered` is implemented today. `acct%` is covered **plus** waived — an id that belongs to a group a later PR owns, named in `tlgr/data/parity_waivers.toml` with the PR that closes it. Ids whose feasibility is `not-applicable` or `prohibited` are excluded from the denominator once and never counted again. ``` -catalog 2026-09-02 — 393 operations, 586 invocable paths +catalog 2026-09-02 — 479 operations, 689 invocable paths domain covered req % acct% ops auth_sessions_security 87 89 97.8% 100.0% 44 bots_inline_payments 17 175 9.7% 100.0% 7 -calls_voicechats 128 133 96.2% 100.0% 51 -contacts_users 106 121 87.6% 100.0% 49 -dialogs_chats 131 146 89.7% 100.0% 69 -groups_channels_admin 27 162 16.7% 100.0% 16 -media_files 121 143 84.6% 100.0% 60 -messages_core 159 167 95.2% 100.0% 54 -polls_reactions_content 123 174 70.7% 100.0% 62 -profile_settings_privacy 31 178 17.4% 100.0% 29 -stories 14 120 11.7% 100.0% 11 +calls_voicechats 130 133 97.7% 100.0% 53 +contacts_users 107 121 88.4% 100.0% 50 +dialogs_chats 137 146 93.8% 100.0% 73 +groups_channels_admin 156 162 96.3% 100.0% 102 +media_files 124 143 86.7% 100.0% 62 +messages_core 163 167 97.6% 100.0% 57 +polls_reactions_content 129 174 74.1% 100.0% 67 +profile_settings_privacy 32 178 18.0% 100.0% 30 +stories 15 120 12.5% 100.0% 12 updates_sync_network 188 189 99.5% 100.0% 67 priority covered req % acct% -P0 136 178 76.4% 100.0% -P1 266 379 70.2% 100.0% -P2 355 610 58.2% 100.0% -P3 375 630 59.5% 100.0% +P0 148 178 83.1% 100.0% +P1 296 379 78.1% 100.0% +P2 410 610 67.2% 100.0% +P3 431 630 68.4% 100.0% -TOTAL 1132 1797 63.0% 100.0% +TOTAL 1285 1797 71.5% 100.0% excluded: not-applicable 79, prohibited 40 -uncovered: 665 (665 waived with a PR number) +uncovered: 512 (512 waived with a PR number) ``` ## By domain @@ -40,25 +40,25 @@ uncovered: 665 (665 waived with a PR number) |---|---:|---:|---:|---:|---:| | `auth_sessions_security` | 87 | 89 | 97.8% | 100.0% | 44 | | `bots_inline_payments` | 17 | 175 | 9.7% | 100.0% | 7 | -| `calls_voicechats` | 128 | 133 | 96.2% | 100.0% | 51 | -| `contacts_users` | 106 | 121 | 87.6% | 100.0% | 49 | -| `dialogs_chats` | 131 | 146 | 89.7% | 100.0% | 69 | -| `groups_channels_admin` | 27 | 162 | 16.7% | 100.0% | 16 | -| `media_files` | 121 | 143 | 84.6% | 100.0% | 60 | -| `messages_core` | 159 | 167 | 95.2% | 100.0% | 54 | -| `polls_reactions_content` | 123 | 174 | 70.7% | 100.0% | 62 | -| `profile_settings_privacy` | 31 | 178 | 17.4% | 100.0% | 29 | -| `stories` | 14 | 120 | 11.7% | 100.0% | 11 | +| `calls_voicechats` | 130 | 133 | 97.7% | 100.0% | 53 | +| `contacts_users` | 107 | 121 | 88.4% | 100.0% | 50 | +| `dialogs_chats` | 137 | 146 | 93.8% | 100.0% | 73 | +| `groups_channels_admin` | 156 | 162 | 96.3% | 100.0% | 102 | +| `media_files` | 124 | 143 | 86.7% | 100.0% | 62 | +| `messages_core` | 163 | 167 | 97.6% | 100.0% | 57 | +| `polls_reactions_content` | 129 | 174 | 74.1% | 100.0% | 67 | +| `profile_settings_privacy` | 32 | 178 | 18.0% | 100.0% | 30 | +| `stories` | 15 | 120 | 12.5% | 100.0% | 12 | | `updates_sync_network` | 188 | 189 | 99.5% | 100.0% | 67 | ## By priority | Priority | Covered | Required | % | Accounted % | |---|---:|---:|---:|---:| -| P0 | 136 | 178 | 76.4% | 100.0% | -| P1 | 266 | 379 | 70.2% | 100.0% | -| P2 | 355 | 610 | 58.2% | 100.0% | -| P3 | 375 | 630 | 59.5% | 100.0% | +| P0 | 148 | 178 | 83.1% | 100.0% | +| P1 | 296 | 379 | 78.1% | 100.0% | +| P2 | 410 | 610 | 67.2% | 100.0% | +| P3 | 431 | 630 | 68.4% | 100.0% | ## Partial coverage @@ -77,7 +77,6 @@ uncovered: 665 (665 waived with a PR number) | `conference.link-qr` | `conference.get` | `--qr` returns the exact text to encode; drawing the code needs a QR encoder tlgr does not bundle | | `conference.prune-left` | `conference.remove` | the request is built and sent; the removal block that rotates the shared key is an e2e.chain builder tlgr does not have and accepts from outside | | `game.play` | `message.game.get` | A CLI cannot render an HTML5 game; --url is refused with NOT_SUPPORTED. | -| `groups-channels-admin.pending-suggestions` | `chat.get` | Pending suggestions are reported here; dismissing one is PR-7's. | | `media.download-stream-stdout` | `media.download` | The daemon owns the connection, so it cannot write bytes to the caller's terminal: --stdout spools the file and reports its path, and --play is refused rather than having the daemon spawn a player. | | `messages-core.ephemeral-messages` | `message.delete` | --revert needs layer 229's ephemeral.* namespace and is refused. | | `messages-core.send-rich-message` | `message.send` | A layer-229 rich body is refused with NOT_SUPPORTED: the pinned Telethon speaks layer 227 and cannot serialise inputRichMessage*. | @@ -98,7 +97,6 @@ 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). | | `calls.privacy-p2p` | P1 | Privacy: peer-to-peer calls | waived until PR-12: inputPrivacyKeyPhoneP2P is the same account.setPrivacy surface as every other privacy key (PR-12). | -| `chat.photo-set` | P1 | Set group / channel photo (photo, video or emoji/sticker avatar) | waived until PR-7: A group or channel photo is `chat photo set` (PR-7). | | `contacts-users.privacy-added-by-phone` | P1 | Privacy: who can find me by my phone number | waived until PR-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). | @@ -130,23 +128,17 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `gift.upgrade` | P2 | Upgrade a gift to a collectible | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | | `giftcode.apply` | P2 | Redeem a gift code | waived until PR-12: gift codes are the `gift` surface (PR-12). | | `giftcode.check` | P2 | Check a gift code / giftcode link | waived until PR-12: gift codes are the `gift` surface (PR-12). | -| `giveaway.boost-status` | P2 | Channel boost level and progress | waived until PR-7: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-7). | -| `giveaway.boosts-list` | P2 | Who boosted this channel | waived until PR-7: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-7). | | `giveaway.gift-code-received` | P2 | Receive a giveaway gift code | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | | `giveaway.info` | P2 | Giveaway status / did I win? | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | | `giveaway.join-by-boosting` | P2 | Join a giveaway by boosting the channel | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | -| `groupcall.admin-log` | P2 | Video chat events in the admin log | waived until PR-7: channels.getAdminLog is one command with one filter vocabulary; the video-chat events are read through `chat admin log` (PR-7). | -| `groupcall.admin-right-manage-call` | P2 | Grant the 'Manage video chats' admin right | waived until PR-7: manage_call is one keyword in the admin-rights vocabulary owned by `chat admin promote` (PR-7). | +| `groups-channels-admin.channel-subscription-manage` | P2 | Manage my paid (Stars) channel subscriptions | waived until PR-12: My own paid subscriptions are `stars subscription *` (PR-12); the admin side is `chat invite list`. | | `livestory.start-rtmp` | P2 | Start an RTMP live story | waived until PR-8: Starting a live story is stories.startLive (PR-8); the RTMP credentials half is already covered here by `vc rtmp get --live-story`. | -| `messages-core.message-statistics` | P2 | Post statistics and public forwards (channel admins) | waived until PR-4: Post statistics and public forwards are the `stats` surface (PR-4). | | `messages-core.quick-reply-list` | P2 | Quick replies (business shortcuts): list shortcuts and their messages | waived until PR-12: Business quick-reply shortcuts are a business setting (PR-12); `message send --quick-reply` uses one. | | `messages-core.quick-reply-manage` | P2 | Create/edit/reorder/delete quick reply shortcuts and their messages | waived until PR-12: Business quick-reply shortcuts are a business setting (PR-12). | -| `messages-core.translate-channel-autotranslation` | P2 | Automatic translation of a channel's posts | waived until PR-3: Channel auto-translation is a channel setting (PR-3). | | `profile.contact-personal-photo` | P2 | Set a personal photo for a contact / suggest a photo to a contact | waived until PR-12: A personal photo for a contact is the `profile` group (PR-12). | | `profile.photo-set-as-main` | P2 | Set an older profile photo as main | waived until PR-12: Promoting an older photo is the `profile` group (PR-12). | | `profile.photo-set-video` | P2 | Animated profile photo (video avatar) | waived until PR-12: A video avatar is the `profile` group (PR-12). | | `profile.saved-music` | P2 | Music on profile (save songs to profile, list, reorder) | waived until PR-12: Music on a profile is the `profile` group (PR-12). | -| `sticker.group-sticker-set` | P2 | Group sticker set and group custom-emoji set (supergroup admin) | waived until PR-7: A supergroup's sticker set is a chat setting (PR-7). | | `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). | @@ -156,22 +148,14 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `auth.oauth-deep-link` | P3 | Authorize an OAuth login request from a website/app (tg://oauth deep link) | waived until PR-10: A tg://oauth request is a bot authorization flow (messages.requestUrlAuth); it lands with the bots group in PR-10. | | `bot.media-previews` | P3 | Manage a bot's Mini App media previews (owned bots) | waived until PR-10: A bot's Mini App previews are the `bot` group (PR-10). | | `bot.profile-photo-set` | P3 | Set profile photo of an owned bot | waived until PR-10: Setting an owned bot's photo is the `bot` group (PR-10). | -| `contacts-users.people-you-may-know` | P3 | Suggested / recommended peers | waived until PR-7: Suggested peers come from channels.getChannelRecommendations, a channel surface (PR-7). | | `contacts-users.privacy-gifts` | P3 | Privacy: who can see / send me gifts | waived until PR-12: Gift privacy is the `privacy` group (PR-12). | | `contacts-users.privacy-no-paid-messages` | P3 | Privacy: who may message me without paying | waived until PR-12: Paid-message privacy is a privacy key (PR-12); reading the price is `user can-message`. | | `contacts-users.privacy-voice-messages` | P3 | Privacy: who can send me voice messages | waived until PR-12: Privacy keys are the `privacy` group (PR-12). | | `contacts-users.user-business-greeting-away` | P3 | Business greeting / away messages | waived until PR-12: Business greeting and away messages are the `business` group (PR-12). | | `dialogs.business-link-delete` | P3 | Delete a business chat link | waived until PR-12: Business chat links are a business setting (PR-12). | | `dialogs.business-link-edit` | P3 | Edit a business chat link | waived until PR-12: Business chat links are a business setting (PR-12). | -| `dialogs.channel-autotranslation` | P3 | Channel auto-translation for all subscribers | waived until PR-7: Channel-wide auto-translation is a channel admin setting (PR-7). | -| `dialogs.community-collapse` | P3 | Community: group / ungroup chats in the list | waived until PR-7: Community grouping is a channel/community surface (PR-7). | -| `dialogs.community-join-requests` | P3 | Community pending peer-link requests | waived until PR-7: Community join requests are moderation (PR-7). | -| `dialogs.forum-tabs-mode` | P3 | Forum topics as tabs or list (admin) | waived until PR-7: Forum tabs are a forum admin setting (PR-7). | | `dialogs.new-chats-privacy` | P3 | Who can start a chat with me (Premium-only / paid messages) | waived until PR-12: Who may start a chat with me is a privacy key (PR-12). | -| `dialogs.notify-community` | P3 | Community-level notification settings | waived until PR-12: Community notification settings are the notify surface (PR-12). | | `dialogs.reactions-notify` | P3 | Reaction / poll-vote notification settings | waived until PR-12: Reaction notification settings are the notify surface (PR-12). | -| `dialogs.recommended-channels` | P3 | Similar / recommended channels and bots | waived until PR-7: Similar-channel suggestions are `channels.getChannelRecommendations`, a channel surface (PR-7). | -| `emoji.status-channel` | P3 | Channel / group emoji status (boost-gated) | waived until PR-12: A channel emoji status is a profile setting (PR-12). | | `emoji.status-lists` | P3 | Emoji status suggestions: default, recent, collectible, themed; clear recent | waived until PR-12: Emoji status suggestions belong to `profile status` (PR-12). | | `gift.as-emoji-status` | P3 | Wear a collectible gift as your emoji status | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | | `gift.as-peer-color` | P3 | Use a collectible as message palette and pattern | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | @@ -194,15 +178,14 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `gift.unique-value` | P3 | Estimated value of a collectible | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | | `gift.upgrade-attributes` | P3 | All possible collectible variants | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | | `gift.upgrade-preview` | P3 | Preview a gift upgrade | waived until PR-12: gifts and collectibles are the `gift` surface (PR-12). | -| `giveaway.boosts-unrestrict` | P3 | Let boosters bypass group restrictions | waived until PR-7: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-7). | | `giveaway.list-prepaid` | P3 | Prepaid giveaways on a channel | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | | `giveaway.results` | P3 | Giveaway results message | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | -| `giveaway.user-boosts` | P3 | Boosts a specific user gave a channel | waived until PR-7: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-7). | +| `groups-channels-admin.gift-code-redeem` | P3 | Check and redeem a giveaway gift code | waived until PR-12: Gift codes are the `gift` noun (PR-12); `boost list --gifts` finds the winners. | +| `groups-channels-admin.giveaway-info` | P3 | Inspect a giveaway's state and results | waived until PR-12: `giveaway *` lands with gifts and Stars (PR-12); `boost get` reports the prepaid ones. | +| `groups-channels-admin.giveaway-prepaid-launch` | P3 | Launch a prepaid giveaway | waived until PR-12: Launching a giveaway is `giveaway launch` (PR-12); `boost get` reports the prepaid slots it spends. | +| `groups-channels-admin.hide-peer-stories` | P3 | Hide a channel's stories from the feed | waived until PR-8: Hiding a peer's stories is `story hide` (PR-8); `user hide-stories` already does the user half. | +| `groups-channels-admin.stories-as-channel` | P3 | Post/manage stories as a channel (boost feature) | waived until PR-8: Posting as a channel is the story group (PR-8); the admin rights that gate it are `chat admin promote --rights post-stories`. | | `location.business-address` | P3 | Business account location | waived until PR-12: a business account's address is the `business` surface (PR-12). | -| `location.channel-geo` | P3 | Set a location for a geo-group | waived until PR-7: a geo-group's location is set through the channel admin surface (PR-7). | -| `location.geogroup-create` | P3 | Create a location-based group | waived until PR-7: creating a location-based group is `chat create` (PR-7). | -| `messages-core.chat-welcome-messages` | P3 | Chat welcome messages (empty-chat cards) | waived until PR-3: Empty-chat welcome cards are a chat setting (PR-3). | -| `messages-core.paid-messages-group-price` | P3 | Charge Stars per message in a supergroup / channel direct messages | waived until PR-7: The per-group Star price is a supergroup setting (PR-7). | | `messages-core.search-hashtag-stories` | P3 | Hashtag / location search in public stories | waived until PR-8: Hashtag search over public stories is the story surface (PR-8). | | `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). | diff --git a/docs/reference/README.md b/docs/reference/README.md index 471f4cd..bca98b8 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -2,15 +2,16 @@ # Command reference -393 operations across 32 groups, generated from the operation registry. Groups still served by v1's hand-written commands are not listed here; they arrive with their own PR. +479 operations across 33 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 | |---|---:|---| | `account` | 35 | [account.md](account.md) | | `agent` | 7 | [agent.md](agent.md) | | `auth` | 11 | [auth.md](auth.md) | +| `boost` | 3 | [boost.md](boost.md) | | `call` | 13 | [call.md](call.md) | -| `chat` | 34 | [chat.md](chat.md) | +| `chat` | 117 | [chat.md](chat.md) | | `conference` | 9 | [conference.md](conference.md) | | `config` | 13 | [config.md](config.md) | | `contact` | 20 | [contact.md](contact.md) | diff --git a/docs/reference/boost.md b/docs/reference/boost.md new file mode 100644 index 0000000..ffdd2db --- /dev/null +++ b/docs/reference/boost.md @@ -0,0 +1,115 @@ +<!-- Generated by tools/gen_docs.py. Do not edit; edit the OperationSpec. --> + +# `tlgr boost` + +3 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 | +|---|---| +| [`boost add`](#tlgr-boost-add) | Boost a channel or group with my Premium slots | +| [`boost get`](#tlgr-boost-get) | Boost status of a chat, or the boost-level feature table | +| [`boost list`](#tlgr-boost-list) | List boosters of a chat, one user's boosts, or my own boost slots | + +### `boost add` + +Boost a channel or group with my Premium slots. + +Needs Telegram Premium (PREMIUM_ACCOUNT_REQUIRED exits 6). BOOST_NOT_MODIFIED reports `already: true` and exits 0; moving a slot during its cooldown raises a flood wait and exits 7 with the wait. + +``` +tlgr boost add <CHAT> [OPTIONS] +``` + +**mutating · returns `BoostApplied`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to boost. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--slots` | int | | Slot ids to spend; default: the free ones. | + +Also invocable as: `tlgr boost apply`, `tlgr chat boost apply`, `tlgr premium boost apply` + +```console +$ tlgr boost add @mychannel --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `giveaway.boost-status`, `groups-channels-admin.boost-apply`, `premium.apply-boost` + +</details> + +### `boost get` + +Boost status of a chat, or the boost-level feature table. + +`--features` maps the appConfig level keys to the tlgr flags they gate, so “why was `--autotranslate` refused” has an answer without reading the API docs. `boost_url` is the shareable boost link. + +``` +tlgr boost get [CHAT] [OPTIONS] +``` + +**returns `BoostStatus`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | Chat to inspect. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--features` | flag | | Print what each boost level unlocks. | +| `--kind` | text | | With --features and no chat. | +| `--level` | int | | With --features: one level. | + +Also invocable as: `tlgr chat boost get` + +```console +$ tlgr boost get @mychannel --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `giveaway.boost-status`, `groups-channels-admin.boost-level-features`, `groups-channels-admin.boost-link`, `groups-channels-admin.boost-status` + +</details> + +### `boost list` + +List boosters of a chat, one user's boosts, or my own boost slots. + +The cursor is the opaque `next_offset` string. `--mine` reports each slot's `cooldown_until_date`, which is what `boost add` needs before moving a slot to another chat. + +``` +tlgr boost list [CHAT] [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[Boost]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | Chat whose boosters to list. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--gifts` | flag | | Only gift and giveaway boosts. | +| `--mine` | flag | | My own boost slots across every chat. | +| `--user` | user | | Only the boosts this user applied. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr chat boost list` + +```console +$ tlgr boost list @mychannel --json +``` + +<details><summary>Catalog coverage (5 full, 0 partial)</summary> + +Full: `giveaway.boosts-list`, `giveaway.boosts-unrestrict`, `giveaway.user-boosts`, `groups-channels-admin.boost-list`, `groups-channels-admin.boost-user` + +The boost surface a booster and an admin both read. Letting boosters bypass restrictions is `chat setting set` territory and shares this surface. + +</details> diff --git a/docs/reference/chat.md b/docs/reference/chat.md index 10e161c..31ea2fc 100644 --- a/docs/reference/chat.md +++ b/docs/reference/chat.md @@ -2,44 +2,127 @@ # `tlgr chat` -34 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. +117 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 | |---|---| | [`chat action-bar get`](#tlgr-chat-action-bar-get) | Read or dismiss the action bar of a chat (the anti-spam info box) | +| [`chat admin-log list`](#tlgr-chat-admin-log-list) | Recent actions (the admin log) | +| [`chat admin-log report`](#tlgr-chat-admin-log-report) | Report an anti-spam deletion as a false positive | +| [`chat admin demote`](#tlgr-chat-admin-demote) | Dismiss an administrator | +| [`chat admin list`](#tlgr-chat-admin-list) | List administrators with their rights and ranks | +| [`chat admin promote`](#tlgr-chat-admin-promote) | Promote a member to admin, or change an existing admin's rights and rank | +| [`chat affiliate list`](#tlgr-chat-affiliate-list) | Affiliate (Star referral) bots connected to a channel, plus suggestions | +| [`chat affiliate set`](#tlgr-chat-affiliate-set) | Connect or disconnect an affiliate bot for a channel | | [`chat archive`](#tlgr-chat-archive) | Move chats to the archive, or back out of it | | [`chat autoarchive set`](#tlgr-chat-autoarchive-set) | Chat-list archive rules for new and archived chats | | [`chat badge get`](#tlgr-chat-badge-get) | Aggregate unread badge and the chat-list limits behind it | | [`chat catchup`](#tlgr-chat-catchup) | What did I miss: every unread chat with its recent messages | | [`chat clear`](#tlgr-chat-clear) | Clear a chat's history — for me, for both sides, or a date range | +| [`chat community ban`](#tlgr-chat-community-ban) | Ban a member from a whole community | +| [`chat community create`](#tlgr-chat-community-create) | Create a Community (a hub grouping several chats) | +| [`chat community list`](#tlgr-chat-community-list) | List my communities, a community's chats, or its pending link requests | +| [`chat community set`](#tlgr-chat-community-set) | Add, hide, remove a chat in a community, or answer a link request | +| [`chat convert`](#tlgr-chat-convert) | Convert a basic group to a supergroup, or a supergroup to a gigagroup | +| [`chat create`](#tlgr-chat-create) | Create a basic group, supergroup, broadcast channel or forum | | [`chat delete`](#tlgr-chat-delete) | Delete a chat: for me, for both sides, or for everyone | +| [`chat direct ban`](#tlgr-chat-direct-ban) | Ban a user from a channel's direct messages | +| [`chat direct list`](#tlgr-chat-direct-list) | Browse a channel's direct-message conversations | +| [`chat direct unban`](#tlgr-chat-direct-unban) | Unban a user from a channel's direct messages | +| [`chat discussion list`](#tlgr-chat-discussion-list) | Groups eligible to become a channel's discussion group | +| [`chat discussion set`](#tlgr-chat-discussion-set) | Link a discussion group to a channel | +| [`chat discussion unset`](#tlgr-chat-discussion-unset) | Unlink a channel's discussion group | +| [`chat edit`](#tlgr-chat-edit) | Edit a group/channel profile: title, about, location, colors, emoji status, tab | | [`chat get`](#tlgr-chat-get) | Full info for one chat: dialog record, settings, notify, ttl, theme | | [`chat import`](#tlgr-chat-import) | Import a chat history exported from another messenger | +| [`chat invite create`](#tlgr-chat-invite-create) | Create an invite link (expiring, limited, approval-gated or paid) | +| [`chat invite delete`](#tlgr-chat-invite-delete) | Delete a revoked invite link, or every revoked link of an admin | +| [`chat invite edit`](#tlgr-chat-invite-edit) | Edit an invite link | +| [`chat invite get`](#tlgr-chat-invite-get) | Inspect one invite link, the primary link, or preview a link you were given | +| [`chat invite list`](#tlgr-chat-invite-list) | List a chat's invite links (active, revoked, or grouped by admin) | +| [`chat invite open`](#tlgr-chat-invite-open) | Read a private channel through an invite peek, without joining | +| [`chat invite revoke`](#tlgr-chat-invite-revoke) | Revoke an invite link | +| [`chat join`](#tlgr-chat-join) | Join a public group/channel, or a private one by invite link | | [`chat leave`](#tlgr-chat-leave) | Leave groups and channels | | [`chat list`](#tlgr-chat-list) | List dialogs with folder, type, unread, pinned and search filters | +| [`chat member add`](#tlgr-chat-member-add) | Add members to a group or channel | +| [`chat member ban`](#tlgr-chat-member-ban) | Ban a member, optionally purging and reporting them in one step | +| [`chat member delete-history`](#tlgr-chat-member-delete-history) | Delete every message one member ever sent | +| [`chat member edit`](#tlgr-chat-member-edit) | Edit a member's custom rank and paid-message exception | +| [`chat member get`](#tlgr-chat-member-get) | One member's status, rights, rank, join date and inviter | +| [`chat member list`](#tlgr-chat-member-list) | List members with the participant filters the API offers | +| [`chat member remove`](#tlgr-chat-member-remove) | Remove (kick) a member; they can rejoin | +| [`chat member report`](#tlgr-chat-member-report) | Report a member (and optionally their messages) as spam | +| [`chat member restrict`](#tlgr-chat-member-restrict) | Restrict what one member may do, with an expiry | +| [`chat member unban`](#tlgr-chat-member-unban) | Lift a ban or a restriction | | [`chat mention list`](#tlgr-chat-mention-list) | Unread mentions, reactions or poll votes of a chat | | [`chat mute`](#tlgr-chat-mute) | Mute or unmute chats, for a duration or forever | | [`chat notify get`](#tlgr-chat-notify-get) | Show a chat's notification settings, exception and effective value | | [`chat notify set`](#tlgr-chat-notify-set) | Set one chat's notification exception | | [`chat open`](#tlgr-chat-open) | Open a chat like a human: recent history AND a read receipt | +| [`chat permission get`](#tlgr-chat-permission-get) | Show the chat-wide default permissions (what every member may do) | +| [`chat permission list`](#tlgr-chat-permission-list) | Print the canonical right vocabulary (admin mask, member mask, layer support) | +| [`chat permission set`](#tlgr-chat-permission-set) | Set the chat-wide default permissions | +| [`chat photo delete`](#tlgr-chat-photo-delete) | Remove the group/channel photo | +| [`chat photo set`](#tlgr-chat-photo-set) | Set the group/channel photo (still, video or emoji avatar) | | [`chat pin`](#tlgr-chat-pin) | Pin or unpin dialogs, or rewrite the whole pinned order | | [`chat poster list`](#tlgr-chat-poster-list) | Harvest the senders that posted in a chat over a message window | | [`chat promo list`](#tlgr-chat-promo-list) | Chat-list top rows: pending suggestions, birthday bar, PSA promo | | [`chat read`](#tlgr-chat-read) | Send a read receipt for chats, a thread, or a whole folder | | [`chat report`](#tlgr-chat-report) | Report a chat, a user or specific messages | +| [`chat request approve`](#tlgr-chat-request-approve) | Approve join requests | +| [`chat request deny`](#tlgr-chat-request-deny) | Decline join requests | +| [`chat request list`](#tlgr-chat-request-list) | List pending join requests | +| [`chat revenue get`](#tlgr-chat-revenue-get) | Stars / TON revenue of a channel (and per-user paid-message revenue) | +| [`chat revenue list`](#tlgr-chat-revenue-list) | Stars / TON transaction history of a channel | | [`chat saved list`](#tlgr-chat-saved-list) | Saved-Messages sublists and channel direct-message topics | | [`chat secret discard`](#tlgr-chat-secret-discard) | Discard a secret chat, optionally deleting it or reporting spam | | [`chat secret list`](#tlgr-chat-secret-list) | Secret chats this session holds, with state and key fingerprint | | [`chat secret send`](#tlgr-chat-secret-send) | Send into a secret chat, set its timer, ack or show typing | | [`chat secret start`](#tlgr-chat-secret-start) | Start a secret chat with a user, or accept an incoming request | +| [`chat send-as list`](#tlgr-chat-send-as-list) | Peers I may post as in this chat | +| [`chat send-as set`](#tlgr-chat-send-as-set) | Set the default peer I post as in this chat | | [`chat set`](#tlgr-chat-set) | Per-dialog switches: content sharing, forum view mode, send-as | +| [`chat setting get`](#tlgr-chat-setting-get) | Print every administrable policy toggle with its current value | +| [`chat setting set`](#tlgr-chat-setting-set) | Change group/channel policy toggles (the Manage screen) | +| [`chat similar list`](#tlgr-chat-similar-list) | Channels similar to this one (or global recommendations) | +| [`chat sponsored list`](#tlgr-chat-sponsored-list) | Sponsored messages the server wants shown in this channel | +| [`chat sponsored report`](#tlgr-chat-sponsored-report) | Report a sponsored message | +| [`chat stats get`](#tlgr-chat-stats-get) | Channel, supergroup, post, story or poll statistics | +| [`chat stats list`](#tlgr-chat-stats-list) | Public forwards (reposts) of a post or a story | +| [`chat suggested-post approve`](#tlgr-chat-suggested-post-approve) | Approve a post suggested to a channel | +| [`chat suggested-post deny`](#tlgr-chat-suggested-post-deny) | Reject a post suggested to a channel | +| [`chat suggestion delete`](#tlgr-chat-suggestion-delete) | Dismiss a server-suggested admin action | | [`chat theme list`](#tlgr-chat-theme-list) | List the chat themes available | | [`chat theme set`](#tlgr-chat-theme-set) | Set or remove the theme of one chat | +| [`chat topic close`](#tlgr-chat-topic-close) | Close a topic | +| [`chat topic create`](#tlgr-chat-topic-create) | Create a topic | +| [`chat topic delete`](#tlgr-chat-topic-delete) | Delete a topic and all its messages | +| [`chat topic edit`](#tlgr-chat-topic-edit) | Rename a topic or change its icon | +| [`chat topic get`](#tlgr-chat-topic-get) | Get one or more topics by id | +| [`chat topic hide`](#tlgr-chat-topic-hide) | Hide the General topic | +| [`chat topic list`](#tlgr-chat-topic-list) | List or search a forum's topics | +| [`chat topic mute`](#tlgr-chat-topic-mute) | Mute a topic | +| [`chat topic pin`](#tlgr-chat-topic-pin) | Pin topics (pass several ids to set the pinned order) | +| [`chat topic read`](#tlgr-chat-topic-read) | Mark a topic read, including its mentions and reactions | +| [`chat topic reopen`](#tlgr-chat-topic-reopen) | Reopen a closed topic | +| [`chat topic unhide`](#tlgr-chat-topic-unhide) | Show the General topic again | +| [`chat topic unmute`](#tlgr-chat-topic-unmute) | Unmute a topic | +| [`chat topic unpin`](#tlgr-chat-topic-unpin) | Unpin a topic | +| [`chat transfer`](#tlgr-chat-transfer) | Transfer ownership of a group or channel (2FA) | | [`chat translate`](#tlgr-chat-translate) | Turn Telegram's translation bar on or off for a chat | | [`chat ttl set`](#tlgr-chat-ttl-set) | Set or show a chat's auto-delete timer | | [`chat typing`](#tlgr-chat-typing) | Send or cancel a chat action (typing, recording, uploading) | | [`chat unread`](#tlgr-chat-unread) | Mark a chat unread again — the undo for an accidental read receipt | +| [`chat username get`](#tlgr-chat-username-get) | Check a username: availability, owner, and Fragment collectible info | +| [`chat username set`](#tlgr-chat-username-set) | Set the public link, or the display order of the additional usernames | +| [`chat username toggle`](#tlgr-chat-username-toggle) | Activate or deactivate one additional username | +| [`chat username unset`](#tlgr-chat-username-unset) | Make a group/channel private by clearing its username(s) | +| [`chat verification set`](#tlgr-chat-verification-set) | Attach or remove a third-party (bot) verification badge | | [`chat wallpaper set`](#tlgr-chat-wallpaper-set) | Set, apply, revert or remove the wallpaper of one chat | +| [`chat welcome delete`](#tlgr-chat-welcome-delete) | Delete one or all welcome messages | +| [`chat welcome list`](#tlgr-chat-welcome-list) | List a group/channel's welcome messages | +| [`chat welcome set`](#tlgr-chat-welcome-set) | Add or edit a welcome message | ### `chat action-bar get` @@ -71,6 +154,238 @@ Full: `contacts-users.nearby-geo-distance`, `contacts-users.user-action-bar`, `c </details> +### `chat admin-log list` + +Recent actions (the admin log). + +The filter is built here rather than through Telethon's `iter_admin_log`, which shifts four names (restrict→ban, unrestrict→unban, ban→kick, unban→unkick) and has no flag at all for invites, send, forums, sub-extend or edit-rank. tlgr's `--filter` names are the API's. Retention is about 48 hours for most classes, and the endpoint is aggressively flood-limited. + +``` +tlgr chat admin-log list <CHAT> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[AdminLogEvent]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--admin` | user | | Only these admins. | +| `--filter` | text | | Comma-separated event classes; default all. | +| `--min-id` | int | | Stop at this event id. | +| `--search`, `-s` | text | | Free-text query. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr chat admin-log list @mygroup --filter ban,kick --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groupcall.admin-log`, `groups-channels-admin.admin-log` + +</details> + +### `chat admin-log report` + +Report an anti-spam deletion as a false positive. + +The candidate ids come from `chat admin-log list --filter delete`, where the anti-spam bot's deletions appear. + +``` +tlgr chat admin-log report <CHAT> <MSG_ID> [OPTIONS] +``` + +**mutating · returns `AntiSpamReport`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Supergroup. | +| `MSG_ID` | msg-id | yes | The wrongly deleted message. | + +```console +$ tlgr chat admin-log report @mygroup 918 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.antispam-false-positive` + +</details> + +### `chat admin demote` + +Dismiss an administrator. + +Sends an empty `ChatAdminRights`; the person keeps their membership. You need `add-admins` and — for somebody another admin promoted — `channelParticipantAdmin.can_edit`. + +``` +tlgr chat admin demote <CHAT> <USER> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `AdminResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | yes | Who to dismiss. | + +```console +$ tlgr chat admin demote @mygroup @alice --yes --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `groups-channels-admin.demote-admin` + +Partial: `groups-channels-admin.basic-group-admin` + +`is_admin=false` in a basic group; `chat admin list` owns the id. + +</details> + +### `chat admin list` + +List administrators with their rights and ranks. + +The creator is reported as `status: creator`. When the chat has Telegram's aggressive anti-spam turned on, its bot is appended locally exactly as the GUI does — the server never lists it, and its deletions do show up in the admin log. + +``` +tlgr chat admin list <CHAT> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[Participant]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--rights/--no-rights` | flag | `True` | Expand each admin's mask into right names. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr chat admin list @mygroup --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.admin-list`, `groups-channels-admin.basic-group-admin` + +</details> + +### `chat admin promote` + +Promote a member to admin, or change an existing admin's rights and rank. + +`--rights` sets the mask absolutely; `--grant`/`--revoke` patch the one the member already has, which is read first. `--all` grants every right *you* hold, because the server refuses to let you give away more. A basic group has no granular rights: the request collapses to `messages.editChatAdmin` and the dropped names are reported in `dropped` rather than silently lost. + +``` +tlgr chat admin promote <CHAT> <USER> [OPTIONS] +``` + +**mutating · returns `AdminResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | yes | Who to promote. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--anonymous/--no-anonymous` | flag | | Shorthand for the `anonymous` right. | +| `--all` | flag | | Grant every right you hold yourself. | +| `--except` | text | | With --all: withhold these. | +| `--grant` | text | | Add these to the current mask. | +| `--none` | flag | | Empty mask (the same as `chat admin demote`). | +| `--rank` | text | | Custom title, max 16 chars. | +| `--revoke` | text | | Remove these from the current mask. | +| `--rights` | text | | The whole mask, comma-separated. | + +Also invocable as: `tlgr chat admin edit` + +```console +$ tlgr chat admin promote @mygroup @alice --rights ban-users,delete-messages --json +``` + +<details><summary>Catalog coverage (4 full, 1 partial)</summary> + +Full: `groupcall.admin-right-manage-call`, `groups-channels-admin.member-tag-rank`, `groups-channels-admin.promote-admin`, `stories.admin-rights` + +Partial: `groups-channels-admin.basic-group-admin` + +Basic groups get the one all-or-nothing bit; `chat admin list` owns the id. + +</details> + +### `chat affiliate list` + +Affiliate (Star referral) bots connected to a channel, plus suggestions. + +``` +tlgr chat affiliate list <CHAT> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[AffiliateBot]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--suggested` | flag | | Suggested affiliate bots instead of the connected ones. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr chat affiliate list @mychannel --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `groups-channels-admin.affiliate-program` + +Listing; `chat affiliate set` connects one and owns the id. + +</details> + +### `chat affiliate set` + +Connect or disconnect an affiliate bot for a channel. + +Connecting is free; the Stars commission comes out of the bot's revenue. + +``` +tlgr chat affiliate set <CHAT> <BOT> [OPTIONS] +``` + +**mutating · returns `AffiliateResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The channel. | +| `BOT` | user | yes | The affiliate bot. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--off` | flag | | Revoke the affiliate link instead. | + +```console +$ tlgr chat affiliate set @mychannel @refbot --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.affiliate-program` + +</details> + ### `chat archive` Move chats to the archive, or back out of it. @@ -230,785 +545,2989 @@ Full: `dialogs.clear-history-both`, `dialogs.clear-history-by-date`, `dialogs.cl </details> -### `chat delete` +### `chat community ban` -Delete a chat: for me, for both sides, or for everyone. +Ban a member from a whole community. -`--for-everyone` is owner-only and destroys the chat itself. Leaving a channel does not delete the history for anyone else. +Registered and refusing with NOT_SUPPORTED (exit 13): layer 229. ``` -tlgr chat delete <CHAT> [OPTIONS] +tlgr chat community ban <COMMUNITY> <USER> [OPTIONS] ``` -**mutating · destructive (needs `--yes` off a TTY) · returns `DeleteChatResult`** +**mutating · destructive (needs `--yes` off a TTY) · returns `CommunityResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat to delete. | +| `COMMUNITY` | chat | yes | The community. | +| `USER` | user | yes | Who to ban. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--for-both` | flag | | Private chat / basic group: delete for the other side too. | -| `--for-everyone` | flag | | Owner only: destroy the group or channel itself. | -| `--for-me` | flag | | Leave it and wipe only my copy of the history. | +| `--off` | flag | | Unban instead. | ```console -$ tlgr chat delete @alice --yes --json +$ tlgr chat community ban @myhub @spammer --yes --json ``` -<details><summary>Catalog coverage (7 full, 0 partial)</summary> +<details><summary>Catalog coverage (1 full, 0 partial)</summary> -Full: `dialogs.delete-chat-private`, `dialogs.delete-group-for-all`, `groups-channels-admin.delete-basic-group`, `groups-channels-admin.delete-channel`, `groups-channels-admin.delete-for-all-members`, `messages-core.history-delete-conversation`, `messages-core.saved-dialog-delete` +Full: `groups-channels-admin.community-link-requests` </details> -### `chat get` +### `chat community create` -Full info for one chat: dialog record, settings, notify, ttl, theme. +Create a Community (a hub grouping several chats). -`--full` adds users.getFullUser / messages.getFullChat / channels.getFullChannel, which the server caches for about a minute. `read_outbox_max_id` is here; the per-member reader list is `message seen`. +Registered and refusing with NOT_SUPPORTED (exit 13): the whole Community surface arrived in MTProto layer 229 and Telethon 1.44 speaks 227, so there is no request class to send. The command shape is settled and will start working with the layer uplift. ``` -tlgr chat get <CHAT> [OPTIONS] +tlgr chat community create <TITLE> [OPTIONS] ``` -**returns `ChatInfo`** +**mutating · returns `CommunityResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat to describe. | +| `TITLE` | text | yes | The community's title. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--dialog/--no-dialog` | flag | `True` | Include the dialog record. | -| `--field` | text | | Emit one field only (scripting). | -| `--full` | flag | | Also fetch getFullUser / getFullChat / getFullChannel. | -| `--refresh` | flag | | Bypass the 60 s server-side *Full cache. | +| `--about` | text | | Description. | +| `--hidden` | flag | | Do not show the community publicly. | +| `--peer` | chat | | Seed it with this chat. | ```console -$ tlgr chat get @alice --full --json +$ tlgr chat community create 'Release hub' --json ``` -<details><summary>Catalog coverage (8 full, 1 partial)</summary> - -Full: `dialogs.chat-full-settings`, `dialogs.get-peer-dialog`, `dialogs.online-count`, `dialogs.read-receipts-outbox`, `groups-channels-admin.bulk-resolve-chats`, `groups-channels-admin.get-full-info`, `media.content-protection`, `updates.presence-group-online-count` - -Partial: `groups-channels-admin.pending-suggestions` +<details><summary>Catalog coverage (1 full, 0 partial)</summary> -Pending suggestions are reported here; dismissing one is PR-7's. +Full: `groups-channels-admin.community-create` </details> -### `chat import` +### `chat community list` -Import a chat history exported from another messenger. +List my communities, a community's chats, or its pending link requests. -Only a private chat, or a group you created (or hold import rights in), accepts an import. `--check` and `--dry-run` stop after the two feasibility calls. +Registered and refusing with NOT_SUPPORTED (exit 13): layer 229. ``` -tlgr chat import <CHAT> <EXPORT> [OPTIONS] +tlgr chat community list [COMMUNITY] [OPTIONS] ``` -**mutating · returns `ImportState`** +**paginated (`PARTICIPANTS` cursor) · returns `Page[CommunityRow]`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Where to import into. | -| `EXPORT` | path | yes | Exported .txt from the other app. | +| `COMMUNITY` | chat | no | One community. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--check` | flag | | Only report whether the import would be accepted. | -| `--media-dir` | path | | Attachments the export names. | +| `--collapse` | text | | Collapse it in the chat list. | +| `--mute` | text | | Community-wide notify settings. | +| `--requests` | flag | | Pending link requests instead of chats. | +| `--user` | user | | Which of its chats this member joined. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). ```console -$ tlgr chat import @alice ./whatsapp.txt --check --json +$ tlgr chat community list --json ``` -<details><summary>Catalog coverage (2 full, 0 partial)</summary> +<details><summary>Catalog coverage (2 full, 2 partial)</summary> -Full: `dialogs.history-import`, `groups-channels-admin.import-chat-history` +Full: `dialogs.community-collapse`, `dialogs.notify-community` + +Partial: `groups-channels-admin.community-link-requests`, `groups-channels-admin.community-manage-links` + +The whole Community surface is layer-229 and refuses with a reason; `chat community set` and `chat community ban` own the two ids. </details> -### `chat leave` +### `chat community set` -Leave groups and channels. +Add, hide, remove a chat in a community, or answer a link request. -Bulk leaving is the CHANNELS_TOO_MUCH escape hatch — pair it with `chat list --scope inactive`, which names the chats you have not opened in the longest time. +Registered and refusing with NOT_SUPPORTED (exit 13): layer 229. ``` -tlgr chat leave [CHAT]... [OPTIONS] +tlgr chat community set <COMMUNITY> <CHAT> [STATE] [OPTIONS] ``` -**mutating · destructive (needs `--yes` off a TTY) · returns `LeaveResult`** +**mutating · returns `CommunityResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | any number | Chats to leave. | +| `COMMUNITY` | chat | yes | The community. | +| `CHAT` | chat | yes | The chat to link. | +| `STATE` | text | no | visible | hidden | removed. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--common-with` | user | | Leave every group shared with. | -| `--delete-history` | flag | | Also delete my copy of the history. | -| `--remove-from-folders` | flag | | Strip the peer from every chat folder. | +| `--approve` | flag | | Approve this chat's link request. | +| `--collapsed` | text | | Collapse it in my dialog list. | +| `--deny` | flag | | Reject this chat's link request. | +| `--all` | flag | | Answer every pending request. | ```console -$ tlgr chat leave @somegroup --json +$ tlgr chat community set @myhub @mygroup visible --json ``` -<details><summary>Catalog coverage (4 full, 1 partial)</summary> +<details><summary>Catalog coverage (1 full, 1 partial)</summary> -Full: `dialogs.delete-and-leave`, `dialogs.leave-group`, `groups-channels-admin.leave`, `groups-channels-admin.owner-leave-successor` +Full: `groups-channels-admin.community-manage-links` -Partial: `contacts-users.user-leave-common-groups` +Partial: `groups-channels-admin.community-link-requests` -`--common-with` leaves the shared groups; listing them is `user chat list`. +Layer-229 surface; `chat community ban` owns the link-requests id. </details> -### `chat list` +### `chat convert` -List dialogs with folder, type, unread, pinned and search filters. +Convert a basic group to a supergroup, or a supergroup to a gigagroup. -Never emits a read receipt. `--folder` takes a peer-folder (main/archive/all) or a chat folder by id or name, evaluated client-side because Telegram has no getDialogs(filter_id). `--scope` swaps the dialog list for the admined-public, inactive or left-channel lists, which are chats rather than dialogs. +Both conversions are one-way. Both ids are reported, because the history stays in the old peer (`chat.migrated_to` points at the new one). Supergroup-only commands offer `--upgrade` rather than migrating a chat out from under its owner. ``` -tlgr chat list [OPTIONS] +tlgr chat convert <CHAT> <TARGET> [OPTIONS] ``` -**paginated (`DIALOGS` cursor) · returns `Page[Dialog]`** +**mutating · destructive (needs `--yes` off a TTY) · returns `MigrateResult`** -| Flag | Type | Default | Meaning | +| Argument | Type | Required | Meaning | |---|---|---|---| -| `--by-location` | flag | | With --scope admined-public: geogroups. | -| `--check-limit` | flag | | With --scope admined-public: report the limit instead. | -| `--common-with` | user | | Chats shared with this user. | -| `--folder` | text | `main` | main | archive | all | folder id | folder name. | -| `--for-personal` | flag | | With --scope admined-public: personal-channel candidates. | -| `--inactive` | flag | | The groups/channels you have not opened for longest. | -| `--muted` | flag | | Only muted chats. | -| `--pinned` | flag | | Only pinned dialogs, in pinned order. | -| `--scope` | dialogs|admined-public|inactive|left | `dialogs` | What to list instead of the dialog list. | -| `--search`, `-s` | text | | Match title or username. | -| `--sort` | default|date|unread|name|pinned | `default` | Ordering. | -| `--type` | user|bot|group|supergroup|channel|forum|saved|self | | Filter by peer kind. | -| `--unmuted` | flag | | Only unmuted chats. | -| `--unread` | flag | | Only chats with unread messages or a mark. | -| `--unread-mark` | flag | | Only chats carrying the manual unread mark. | -| `--with-drafts` | flag | | Only chats with a saved draft. | -| `--with-join-requests` | flag | | Only chats with pending join requests. | -| `--with-mentions` | flag | | Only chats with unread mentions. | -| `--with-reactions` | flag | | Only chats with unread reactions. | - -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 chats`, `tlgr inbox` +| `CHAT` | chat | yes | The chat to convert. | +| `TARGET` | text | yes | supergroup (from a basic group) or gigagroup. | ```console -$ tlgr chat list --unread --json +$ tlgr chat convert @mygroup supergroup --yes --json ``` -<details><summary>Catalog coverage (15 full, 2 partial)</summary> - -Full: `dialogs.folder-chat-count`, `dialogs.inactive-chats`, `dialogs.join-requests-badge`, `dialogs.list-archive`, `dialogs.list-folder`, `dialogs.list-main`, `dialogs.pinned-list`, `dialogs.restricted-peer`, `dialogs.saved-messages`, `dialogs.unread-marks-list`, `dialogs.unread-quick-filter`, `groups-channels-admin.admined-public-chats`, `groups-channels-admin.common-chats`, `groups-channels-admin.inactive-chats`, `groups-channels-admin.left-channels` - -Partial: `contacts-users.contacts-sort`, `dialogs.search-peers` +<details><summary>Catalog coverage (2 full, 0 partial)</summary> -Peer search here is a substring match over the dialog list; the global one is `contact search`. `--sort` orders chats, not contacts. +Full: `groups-channels-admin.convert-to-gigagroup`, `groups-channels-admin.upgrade-basic-to-supergroup` </details> -### `chat mention list` +### `chat create` -Unread mentions, reactions or poll votes of a chat. +Create a basic group, supergroup, broadcast channel or forum. -`--read` clears the queue it just listed, and honours --dry-run itself so that listing stays available under it. +`--type supergroup` is what the GUI creates today; `group` is the legacy basic group, which cannot have a username or a location. `missing` carries `messages.invitedUsers.missing_invitees` verbatim, and the command exits 1 when any seed member was refused. ``` -tlgr chat mention list <CHAT> [OPTIONS] +tlgr chat create <TITLE> [OPTIONS] ``` -**paginated (`HISTORY` cursor) · returns `Page[Message]`** +**mutating · returns `CreatedChat`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat to look in. | +| `TITLE` | text | yes | The chat's title. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--kind` | mention|reaction|poll-vote | `mention` | Which unread queue to list. | +| `--about` | text | | Description. | +| `--address` | text | | Street address that goes with --geo. | +| `--for-import` | flag | | Destination for a history import (see `chat import`). | +| `--forward-history` | int | | Basic groups: history new members see. | +| `--geo` | text | | Create a location-based group. | +| `--members` | user | | Seed members. | +| `--photo` | path | | Set the photo afterwards. | +| `--tabs` | flag | | With --type forum: the tabbed topic UI. | +| `--ttl` | text | | Auto-delete timer at creation. | +| `--type` | group|supergroup|channel|forum | `supergroup` | Peer shape. `group` is the legacy basic group. | +| `--username` | text | | Claim a public username afterwards. | + +```console +$ tlgr chat create 'Release team' --type supergroup --members @alice --json +``` + +<details><summary>Catalog coverage (7 full, 2 partial)</summary> + +Full: `groups-channels-admin.create-basic-group`, `groups-channels-admin.create-channel`, `groups-channels-admin.create-forum`, `groups-channels-admin.create-geo-group`, `groups-channels-admin.create-supergroup`, `groups-channels-admin.create-with-autodelete`, `location.geogroup-create` + +Partial: `groups-channels-admin.add-members`, `groups-channels-admin.add-members-failure-report` + +Seed members go in at creation; `chat member add` owns adding them later. + +</details> + +### `chat delete` + +Delete a chat: for me, for both sides, or for everyone. + +`--for-everyone` is owner-only and destroys the chat itself. Leaving a channel does not delete the history for anyone else. + +``` +tlgr chat delete <CHAT> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `DeleteChatResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to delete. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--for-both` | flag | | Private chat / basic group: delete for the other side too. | +| `--for-everyone` | flag | | Owner only: destroy the group or channel itself. | +| `--for-me` | flag | | Leave it and wipe only my copy of the history. | + +```console +$ tlgr chat delete @alice --yes --json +``` + +<details><summary>Catalog coverage (7 full, 0 partial)</summary> + +Full: `dialogs.delete-chat-private`, `dialogs.delete-group-for-all`, `groups-channels-admin.delete-basic-group`, `groups-channels-admin.delete-channel`, `groups-channels-admin.delete-for-all-members`, `messages-core.history-delete-conversation`, `messages-core.saved-dialog-delete` + +</details> + +### `chat direct ban` + +Ban a user from a channel's direct messages. + +Acts on `channel.linked_monoforum_id`, not on the channel: banning somebody from your DMs and banning them from your channel are different decisions. + +``` +tlgr chat direct ban <CHANNEL> <USER> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `DirectBanResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHANNEL` | chat | yes | The channel. | +| `USER` | user | yes | Who to ban from the DMs. | + +Also invocable as: `tlgr chat monoforum ban` + +```console +$ tlgr chat direct ban @mychannel @spammer --yes --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `groups-channels-admin.monoforum-ban` + +Banning; `chat direct unban` lifts it and owns the id. + +</details> + +### `chat direct list` + +Browse a channel's direct-message conversations. + +Each conversation is a `monoForumDialog` keyed by `saved_peer_id` (the user). Reading and replying inside one is `message list/send --direct <user>`. + +``` +tlgr chat direct list <CHANNEL> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[DirectDialog]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHANNEL` | chat | yes | The channel. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr chat monoforum list` + +```console +$ tlgr chat direct list @mychannel --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.monoforum-topic-list` + +</details> + +### `chat direct unban` + +Unban a user from a channel's direct messages. + +`chat member edit --free-messages on` waives the Stars price for one user without touching the ban. + +``` +tlgr chat direct unban <CHANNEL> <USER> [OPTIONS] +``` + +**mutating · returns `DirectBanResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHANNEL` | chat | yes | The channel. | +| `USER` | user | yes | Who to ban from the DMs. | + +Also invocable as: `tlgr chat monoforum unban` + +```console +$ tlgr chat direct unban @mychannel @alice --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.monoforum-ban` + +</details> + +### `chat discussion list` + +Groups eligible to become a channel's discussion group. + +A basic group in this list must be converted first (`chat convert <chat> supergroup`); `needs_migration` says which. + +``` +tlgr chat discussion list [OPTIONS] +``` + +**returns `list[DiscussionCandidate]`** + +```console +$ tlgr chat discussion list --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.discussion-candidates` + +</details> + +### `chat discussion set` + +Link a discussion group to a channel. + +The server refuses a group whose prehistory is hidden; `--unhide-prehistory` makes it visible first, and says so rather than doing it silently. LINK_NOT_MODIFIED reports `already: true`. + +``` +tlgr chat discussion set <CHANNEL> <GROUP> [OPTIONS] +``` + +**mutating · returns `DiscussionResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHANNEL` | chat | yes | The broadcast. | +| `GROUP` | chat | yes | The discussion group. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--unhide-prehistory` | flag | | Make the group's prehistory visible first. | + +```console +$ tlgr chat discussion set @mychannel @mygroup --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.discussion-link` + +</details> + +### `chat discussion unset` + +Unlink a channel's discussion group. + +``` +tlgr chat discussion unset <CHANNEL> [OPTIONS] +``` + +**mutating · returns `DiscussionResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHANNEL` | chat | yes | The broadcast. | + +```console +$ tlgr chat discussion unset @mychannel --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.discussion-unlink` + +</details> + +### `chat edit` + +Edit a group/channel profile: title, about, location, colors, emoji status, tab. + +One request per changed field, applied in a fixed order and reported in `changed`. Colours, emoji statuses and emoji packs are boost-gated: `--palettes` prints every option with the level it needs, without editing anything. A basic group accepts only `--title` and `--about`. + +``` +tlgr chat edit <CHAT> [OPTIONS] +``` + +**mutating · returns `ChatEditResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--about` | text | | New description, max 255 chars. | +| `--address` | text | | Street address. | +| `--color` | text | | Message accent palette id, or `off`. | +| `--color-emoji` | text | | Background emoji for --color. | +| `--emoji-status` | text | | Emoji status, or `off`. | +| `--emoji-status-until` | text | | Expiry for --emoji-status. | +| `--geo` | text | | Geogroup location; `off` clears it. | +| `--main-tab` | posts|gifts|media|files|music|voice|links|gifs | | Default profile tab (channels.setMainProfileTab). | +| `--palettes` | flag | | Do not edit: print the palettes and the level each needs. | +| `--profile-color` | text | | Profile palette id, or `off`. | +| `--profile-color-emoji` | text | | Background emoji for --profile-color. | +| `--title` | text | | New title. | + +```console +$ tlgr chat edit @mygroup --title 'Release team' --about 'Ship it' --json +``` + +<details><summary>Catalog coverage (8 full, 0 partial)</summary> + +Full: `groups-channels-admin.channel-emoji-status`, `groups-channels-admin.edit-about`, `groups-channels-admin.edit-title`, `groups-channels-admin.main-profile-tab`, `groups-channels-admin.peer-color-message`, `groups-channels-admin.peer-color-profile`, `groups-channels-admin.set-location`, `location.channel-geo` + +</details> + +### `chat get` + +Full info for one chat: dialog record, settings, notify, ttl, theme. + +`--full` adds users.getFullUser / messages.getFullChat / channels.getFullChannel, which the server caches for about a minute. `read_outbox_max_id` is here; the per-member reader list is `message seen`. + +``` +tlgr chat get <CHAT> [OPTIONS] +``` + +**returns `ChatInfo`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to describe. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--dialog/--no-dialog` | flag | `True` | Include the dialog record. | +| `--field` | text | | Emit one field only (scripting). | +| `--full` | flag | | Also fetch getFullUser / getFullChat / getFullChannel. | +| `--refresh` | flag | | Bypass the 60 s server-side *Full cache. | + +```console +$ tlgr chat get @alice --full --json +``` + +<details><summary>Catalog coverage (8 full, 1 partial)</summary> + +Full: `dialogs.chat-full-settings`, `dialogs.get-peer-dialog`, `dialogs.online-count`, `dialogs.read-receipts-outbox`, `groups-channels-admin.bulk-resolve-chats`, `groups-channels-admin.get-full-info`, `media.content-protection`, `updates.presence-group-online-count` + +Partial: `groups-channels-admin.pending-suggestions` + +Pending suggestions are reported here; dismissing one is PR-7's. + +</details> + +### `chat import` + +Import a chat history exported from another messenger. + +Only a private chat, or a group you created (or hold import rights in), accepts an import. `--check` and `--dry-run` stop after the two feasibility calls. + +``` +tlgr chat import <CHAT> <EXPORT> [OPTIONS] +``` + +**mutating · returns `ImportState`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Where to import into. | +| `EXPORT` | path | yes | Exported .txt from the other app. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--check` | flag | | Only report whether the import would be accepted. | +| `--media-dir` | path | | Attachments the export names. | + +```console +$ tlgr chat import @alice ./whatsapp.txt --check --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `dialogs.history-import`, `groups-channels-admin.import-chat-history` + +</details> + +### `chat invite create` + +Create an invite link (expiring, limited, approval-gated or paid). + +`--limit` and `--request-approval` are mutually exclusive, which is the server's rule and not ours. The subscription period is fixed at 30 days; creating a paid link costs nothing, only joining does. `--replace-primary` invalidates the old permanent link for everyone who holds it. + +``` +tlgr chat invite create <CHAT> [OPTIONS] +``` + +**mutating · returns `Invite`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--expires` | text | | Expiry, as a duration or a timestamp. | +| `--replace-primary` | flag | | Revoke and replace the permanent link. | +| `--request-approval` | flag | | Joins land in the approval queue instead. | +| `--subscription-stars` | int | | Paid link: Stars per 30-day period. | +| `--title` | text | | Label shown to admins. | +| `--limit` | int | | Maximum joins; excludes --request-approval. | + +```console +$ tlgr chat invite create @mygroup --title 'Launch week' --limit 25 --expires 7d --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `groups-channels-admin.invite-link-create`, `groups-channels-admin.invite-link-primary`, `groups-channels-admin.invite-link-subscription` + +</details> + +### `chat invite delete` + +Delete a revoked invite link, or every revoked link of an admin. + +Only revoked links can be deleted; revoke an active one first. `--revoked` reports `deleted: -1`, because `messages.deleteRevokedExportedChatInvites` answers with a bare `true` and inventing a count would be inventing data. + +``` +tlgr chat invite delete <CHAT> [LINK] [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `InviteDeleted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `LINK` | text | no | The revoked link to delete. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--admin` | user | | With --revoked: whose links to purge. | +| `--revoked` | flag | | Delete every revoked link instead. | + +```console +$ tlgr chat invite delete @mygroup --revoked --yes --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.invite-link-delete`, `groups-channels-admin.invite-link-delete-all-revoked` + +</details> + +### `chat invite edit` + +Edit an invite link. + +May answer with `messages.exportedChatInviteReplaced`; both links are reported, the new one in `link` and the old one in `replaced_link`. A paid subscription link accepts only `--title`. + +``` +tlgr chat invite edit <CHAT> <LINK> [OPTIONS] +``` + +**mutating · returns `Invite`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `LINK` | text | yes | The invite link to edit. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--expires` | text | | New expiry; `off` clears it. | +| `--request-approval/--no-request-approval` | flag | | Turn the approval queue on or off. | +| `--title` | text | | New label. | +| `--limit` | int | | New usage limit; 0 clears it. | + +```console +$ tlgr chat invite edit @mygroup https://t.me/+AbCdEf --limit 50 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.invite-link-edit` + +</details> + +### `chat invite get` + +Inspect one invite link, the primary link, or preview a link you were given. + +One argument that is a `t.me/+…` or `joinchat` link previews it with `messages.checkChatInvite` and needs no rights; a chat plus a link inspects your own with `messages.getExportedChatInvite` and needs `invite-users`. A peek answers with `peek_expires`, which is the window `chat invite open` reads inside. + +``` +tlgr chat invite get <CHAT|LINK> [LINK] [OPTIONS] +``` + +**returns `InviteInfo`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT|LINK` | chat | yes | A chat, or an invite link. | +| `LINK` | text | no | With a chat: which link. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--png` | path | | Write the QR code to a PNG. | +| `--qr` | flag | | Also render the link as an ASCII QR code. | + +```console +$ tlgr chat invite get https://t.me/+AbCdEf --json +``` + +<details><summary>Catalog coverage (3 full, 1 partial)</summary> + +Full: `groups-channels-admin.check-invite`, `groups-channels-admin.invite-link-get`, `groups-channels-admin.invite-link-qr` + +Partial: `groups-channels-admin.invite-link-primary` + +The QR is rendered locally and needs the optional `tlgr[qr]` extra; without it the link is still reported and a warning says so. Minting the primary link is `chat invite create`. + +</details> + +### `chat invite list` + +List a chat's invite links (active, revoked, or grouped by admin). + +`admin_id` is mandatory in the request, so it defaults to you; only the owner may name somebody else. The cursor packs the `(date, link)` pair of the last row. `--by-admin` swaps the rows for one per admin, with their active and revoked counts in `usage` and `requested`. + +``` +tlgr chat invite list <CHAT> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[Invite]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--admin` | user | | Whose links to list; defaults to me. | +| `--by-admin` | flag | | One row per admin with their link counts instead. | +| `--revoked` | flag | | Revoked links instead of active ones. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr chat invite list @mygroup --revoked --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.invite-link-admins`, `groups-channels-admin.invite-link-list` + +</details> + +### `chat invite open` + +Read a private channel through an invite peek, without joining. + +A peek is the server's offer, not ours: when it answers anything other than `chatInvitePeek` this exits 6 rather than joining on your behalf. + +``` +tlgr chat invite open <LINK> [OPTIONS] +``` + +**returns `InvitePeek`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `LINK` | chat | yes | The invite link. | + +```console +$ tlgr chat invite open https://t.me/+AbCdEf --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.invite-peek` + +</details> + +### `chat invite revoke` + +Revoke an invite link. + +Revoked links stay listable with `chat invite list --revoked` until `chat invite delete` removes them. + +``` +tlgr chat invite revoke <CHAT> <LINK> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `InviteRevoked`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `LINK` | text | yes | The invite link to revoke. | + +```console +$ tlgr chat invite revoke @mygroup https://t.me/+AbCdEf --yes --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.invite-link-revoke` + +</details> + +### `chat join` + +Join a public group/channel, or a private one by invite link. + +`INVITE_REQUEST_SENT` is success-with-pending (exit 0, `pending_approval: true`) and `USER_ALREADY_PARTICIPANT` is `already: true` (exit 0); an expired hash exits 5. A layer-229 join that needs a web view is reported in `needs_web_view` rather than claimed as a join. + +``` +tlgr chat join <CHAT|LINK> [OPTIONS] +``` + +**mutating · returns `JoinResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT|LINK` | chat | yes | A public chat, or an invite link. | + +```console +$ tlgr chat join @somechannel --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.join-by-invite`, `groups-channels-admin.join-by-username` + +</details> + +### `chat leave` + +Leave groups and channels. + +Bulk leaving is the CHANNELS_TOO_MUCH escape hatch — pair it with `chat list --scope inactive`, which names the chats you have not opened in the longest time. + +``` +tlgr chat leave [CHAT]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `LeaveResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | any number | Chats to leave. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--common-with` | user | | Leave every group shared with. | +| `--delete-history` | flag | | Also delete my copy of the history. | +| `--remove-from-folders` | flag | | Strip the peer from every chat folder. | + +```console +$ tlgr chat leave @somegroup --json +``` + +<details><summary>Catalog coverage (4 full, 1 partial)</summary> + +Full: `dialogs.delete-and-leave`, `dialogs.leave-group`, `groups-channels-admin.leave`, `groups-channels-admin.owner-leave-successor` + +Partial: `contacts-users.user-leave-common-groups` + +`--common-with` leaves the shared groups; listing them is `user chat list`. + +</details> + +### `chat list` + +List dialogs with folder, type, unread, pinned and search filters. + +Never emits a read receipt. `--folder` takes a peer-folder (main/archive/all) or a chat folder by id or name, evaluated client-side because Telegram has no getDialogs(filter_id). `--scope` swaps the dialog list for the admined-public, inactive or left-channel lists, which are chats rather than dialogs. + +``` +tlgr chat list [OPTIONS] +``` + +**paginated (`DIALOGS` cursor) · returns `Page[Dialog]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--by-location` | flag | | With --scope admined-public: geogroups. | +| `--check-limit` | flag | | With --scope admined-public: report the limit instead. | +| `--common-with` | user | | Chats shared with this user. | +| `--folder` | text | `main` | main | archive | all | folder id | folder name. | +| `--for-personal` | flag | | With --scope admined-public: personal-channel candidates. | +| `--inactive` | flag | | The groups/channels you have not opened for longest. | +| `--muted` | flag | | Only muted chats. | +| `--pinned` | flag | | Only pinned dialogs, in pinned order. | +| `--scope` | dialogs|admined-public|inactive|left | `dialogs` | What to list instead of the dialog list. | +| `--search`, `-s` | text | | Match title or username. | +| `--sort` | default|date|unread|name|pinned | `default` | Ordering. | +| `--type` | user|bot|group|supergroup|channel|forum|saved|self | | Filter by peer kind. | +| `--unmuted` | flag | | Only unmuted chats. | +| `--unread` | flag | | Only chats with unread messages or a mark. | +| `--unread-mark` | flag | | Only chats carrying the manual unread mark. | +| `--with-drafts` | flag | | Only chats with a saved draft. | +| `--with-join-requests` | flag | | Only chats with pending join requests. | +| `--with-mentions` | flag | | Only chats with unread mentions. | +| `--with-reactions` | flag | | Only chats with unread reactions. | + +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 chats`, `tlgr inbox` + +```console +$ tlgr chat list --unread --json +``` + +<details><summary>Catalog coverage (15 full, 2 partial)</summary> + +Full: `dialogs.folder-chat-count`, `dialogs.inactive-chats`, `dialogs.join-requests-badge`, `dialogs.list-archive`, `dialogs.list-folder`, `dialogs.list-main`, `dialogs.pinned-list`, `dialogs.restricted-peer`, `dialogs.saved-messages`, `dialogs.unread-marks-list`, `dialogs.unread-quick-filter`, `groups-channels-admin.admined-public-chats`, `groups-channels-admin.common-chats`, `groups-channels-admin.inactive-chats`, `groups-channels-admin.left-channels` + +Partial: `contacts-users.contacts-sort`, `dialogs.search-peers` + +Peer search here is a substring match over the dialog list; the global one is `contact search`. `--sort` orders chats, not contacts. + +</details> + +### `chat member add` + +Add members to a group or channel. + +`missing` carries `messages.invitedUsers.missing_invitees` verbatim — one `{user_id, reason}` per refusal, with reason in privacy-restricted, premium-would-allow-invite or premium-required-for-pm. `--invite-link-fallback` DMs the link to them, and is opt-in because it sends a message on your behalf. + +``` +tlgr chat member add <CHAT> [USER]... [OPTIONS] +``` + +**mutating · returns `MembersAdded`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | one or more | Who to add. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--forward-history` | int | | Basic groups: past messages the member sees. | +| `--invite-link-fallback` | flag | | DM the invite link to anyone who could not be added. | + +```console +$ tlgr chat member add @mygroup @alice @carol --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.add-members`, `groups-channels-admin.add-members-failure-report` + +</details> + +### `chat member ban` + +Ban a member, optionally purging and reporting them in one step. + +`participant` is an `InputPeer`, so a channel posting in the group — or an anonymous admin's channel — can be banned as well as a user. `--until` follows Telegram's own rounding: 0, under 30 seconds and over 366 days all mean forever. + +``` +tlgr chat member ban <CHAT> [USER]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `list[MemberResult]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | one or more | Who to ban. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--messages` | int | | Also delete these message ids. | +| `--purge` | flag | | Delete everything they ever sent here. | +| `--report` | flag | | Report the messages as spam too. | +| `--until` | text | | Ban expiry; 0, under 30s or over 366d = forever. | + +Also invocable as: `tlgr chat ban` + +```console +$ tlgr chat member ban @mygroup @spammer --purge --report --yes --json +``` + +<details><summary>Catalog coverage (2 full, 2 partial)</summary> + +Full: `groups-channels-admin.ban-member`, `groups-channels-admin.moderate-member` + +Partial: `groups-channels-admin.delete-member-history`, `groups-channels-admin.report-member` + +The moderate box bundles them; `chat member delete-history` and `chat member report` own the standalone ids. + +</details> + +### `chat member delete-history` + +Delete every message one member ever sent. + +`channels.deleteParticipantHistory` answers with `messages.affectedHistory` and an offset to resume from; the loop runs in the daemon until the offset is 0, because deleting the first hundred messages and reporting success is not deleting a history. + +``` +tlgr chat member delete-history <CHAT> <USER> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `MemberResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Supergroup or channel. | +| `USER` | user | yes | Whose messages to delete. | + +Also invocable as: `tlgr chat member purge` + +```console +$ tlgr chat member delete-history @mygroup @spammer --yes --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.delete-member-history` + +</details> + +### `chat member edit` + +Edit a member's custom rank and paid-message exception. + +The rank shows up as `message.from_rank` in supergroups and `chatParticipant.rank` in basic groups; `chat admin promote --rank` writes the same field for admins. + +``` +tlgr chat member edit <CHAT> <USER> [OPTIONS] +``` + +**mutating · returns `MemberResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | yes | The member. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--free-messages/--no-free-messages` | flag | | Let this user message the channel without paying Stars. | +| `--rank` | text | | Custom title, max 16 chars. | +| `--refund` | flag | | With --free-messages: refund the Stars already paid. | + +```console +$ tlgr chat member edit @mygroup @alice --rank moderator --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `groups-channels-admin.paid-messages-price` + +Partial: `groups-channels-admin.member-tag-rank` + +Ranks for plain members; `chat admin promote --rank` owns the admin half. + +</details> + +### `chat member get` + +One member's status, rights, rank, join date and inviter. + +`effective_permissions` is the chat's defaults patched with this member's own mask, in the same allow-polarity vocabulary `chat permission get` prints — which is the answer to “what may this person actually do” rather than “what did an admin type”. + +``` +tlgr chat member get <CHAT> <USER> [OPTIONS] +``` + +**returns `Participant`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | yes | The member. | + +```console +$ tlgr chat member get @mygroup @alice --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `groups-channels-admin.member-get`, `groups-channels-admin.member-invited-by`, `groups-channels-admin.member-permissions-view` + +</details> + +### `chat member list` + +List members with the participant filters the API offers. + +Every row keeps its `ChannelParticipant*` wrapper: status, rank, join date, inviter, promoter and both rights masks. `--filter kicked` is people who were removed; `--filter restricted` is people still in the chat with a mask on them. A chat with `participants_hidden` answers PERMISSION_DENIED rather than an empty page, because “nobody is in this group” would be a lie. + +``` +tlgr chat member list <CHAT> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[Participant]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--admins` | flag | | Shorthand for --filter admins (v1). | +| `--bots` | flag | | Shorthand for --filter bots. | +| `--filter` | recent|admins|bots|contacts|kicked|banned|restricted|mentions | `recent` | Which participant list. kicked = removed, restricted = still in the chat. | +| `--search`, `-s` | text | | Server-side name query. | +| `--subscription-expired` | flag | | With --via-link: lapsed paid subscribers. | +| `--topic` | msg-id | | With --filter mentions: one topic. | +| `--via-link` | text | | Only people who joined through this invite link. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr chat members` + +```console +$ tlgr chat member list @mygroup --filter admins --json +``` + +<details><summary>Catalog coverage (5 full, 1 partial)</summary> + +Full: `groups-channels-admin.banned-list`, `groups-channels-admin.channel-subscriptions-admin`, `groups-channels-admin.invite-link-importers`, `groups-channels-admin.member-mention-autocomplete`, `groups-channels-admin.members-list` + +Partial: `groups-channels-admin.admin-list` + +`--filter admins` lists them; `chat admin list` is the primary owner. + +</details> + +### `chat member remove` + +Remove (kick) a member; they can rejoin. + +A kick is `editBanned(view_messages)` followed by an empty mask, so the person may come back. Use `chat member ban` to keep them out. `--purge` drains `messages.affectedHistory` until the server stops handing back an offset. + +``` +tlgr chat member remove <CHAT> [USER]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `list[MemberResult]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | one or more | Who to remove. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--messages` | int | | Also delete these message ids. | +| `--purge` | flag | | Delete everything they ever sent here. | +| `--report` | flag | | Report the messages as spam too. | + +```console +$ tlgr chat member remove @mygroup @spammer --purge --yes --json +``` + +<details><summary>Catalog coverage (1 full, 2 partial)</summary> + +Full: `groups-channels-admin.remove-member` + +Partial: `groups-channels-admin.delete-member-history`, `groups-channels-admin.report-member` + +`--purge`/`--report` are the moderate box; the standalone commands are `chat member delete-history` and `chat member report`. + +</details> + +### `chat member report` + +Report a member (and optionally their messages) as spam. + +``` +tlgr chat member report <CHAT> <USER> [OPTIONS] +``` + +**mutating · returns `MemberResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Supergroup or channel. | +| `USER` | user | yes | Who to report. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--messages` | int | | Message ids to attach to the report. | + +```console +$ tlgr chat member report @mygroup @spammer --messages 918 --yes --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.report-member` + +</details> + +### `chat member restrict` + +Restrict what one member may do, with an expiry. + +Read-modify-write of the member's current mask: `--deny` and `--allow` patch it, `--replace` supplies it whole, `--none` is read-only and `--clear` drops it back to the chat default. Names come from `chat permission list --mask member`. + +``` +tlgr chat member restrict <CHAT> <USER> [OPTIONS] +``` + +**mutating · returns `MemberResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Supergroup. | +| `USER` | user | yes | The member. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--allow` | text | | Rights to give back, comma-separated. | +| `--clear` | flag | | Drop the mask; fall back to the chat default. | +| `--deny` | text | | Rights to take away, comma-separated. | +| `--all` | flag | | Allow everything. | +| `--none` | flag | | Read-only: deny everything but view-messages. | +| `--purge` | flag | | Also delete everything they sent. | +| `--replace` | flag | | Treat --deny/--allow as the whole mask, not a patch. | +| `--until` | text | | When the restriction lapses. | + +```console +$ tlgr chat member restrict @mygroup @alice --deny send-media --until 7d --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.restrict-member` + +</details> + +### `chat member unban` + +Lift a ban or a restriction. + +Sends an all-clear mask, which takes the user off the Removed and Restricted lists. It does not put them back in the chat. + +``` +tlgr chat member unban <CHAT> [USER]... [OPTIONS] +``` + +**mutating · returns `list[MemberResult]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | one or more | Who to unban. | + +```console +$ tlgr chat member unban @mygroup @alice --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.unban-member` + +</details> + +### `chat mention list` + +Unread mentions, reactions or poll votes of a chat. + +`--read` clears the queue it just listed, and honours --dry-run itself so that listing stays available under it. + +``` +tlgr chat mention list <CHAT> [OPTIONS] +``` + +**paginated (`HISTORY` cursor) · returns `Page[Message]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to look in. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--kind` | mention|reaction|poll-vote | `mention` | Which unread queue to list. | | `--read` | flag | | Mark the listed items read afterwards. | | `--saved-peer` | chat | | Restrict to a saved sublist. | | `--topic` | msg-id | | Restrict to a topic. | Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). -Also invocable as: `tlgr chat mentions`, `tlgr chat reactions`, `tlgr chat poll-votes` +Also invocable as: `tlgr chat mentions`, `tlgr chat reactions`, `tlgr chat poll-votes` + +```console +$ tlgr chat mention list @somegroup --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `dialogs.unread-mentions`, `dialogs.unread-poll-votes`, `dialogs.unread-reactions` + +</details> + +### `chat mute` + +Mute or unmute chats, for a duration or forever. + +`mute_until` is absolute wall-clock time (COR-01). `inputPeerNotifySettings` is sparse — only the field being changed is sent, so the rest keeps inheriting the scope default. `--folder` costs one RPC per chat because Telegram has no batched form. + +``` +tlgr chat mute [CHAT]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `MuteResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | any number | Chats to mute. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--folder` | text | | Apply to every chat of a folder. | +| `--for` | duration | | Mute for 1h | 8h | 2d. | +| `--off` | flag | | Unmute, keeping the other notify fields. | +| `--stories` | flag | | Mute the peer's stories instead. | +| `--topic` | msg-id | | A forum topic. | +| `--until` | datetime | | Mute until an absolute time. | + +Also invocable as: `tlgr chat unmute` + +```console +$ tlgr chat mute @alice --for 8h --json +``` + +<details><summary>Catalog coverage (5 full, 0 partial)</summary> + +Full: `dialogs.mute-folder`, `dialogs.mute-for-duration`, `dialogs.mute-forever`, `dialogs.unmute`, `groups-channels-admin.chat-notify-settings` + +</details> + +### `chat notify get` + +Show a chat's notification settings, exception and effective value. + +``` +tlgr chat notify get <CHAT> [OPTIONS] +``` + +**returns `NotifyView`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to describe. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--effective/--no-effective` | flag | `True` | Merge the scope default under the exception. | +| `--topic` | msg-id | | A forum topic. | + +```console +$ tlgr chat notify get @alice --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `dialogs.notify-get` + +</details> + +### `chat notify set` + +Set one chat's notification exception. + +Every switch takes on|off|default, because `default` is a real third state: it removes the exception so the chat inherits the scope again. + +``` +tlgr chat notify set <CHAT> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `NotifyView`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to change. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--gifts` | on|off | | Star-gift notifications for a channel you admin. | +| `--preview` | on|off|default | | Message preview in notifications. | +| `--reset` | flag | | Drop the exception and inherit the scope default. | +| `--silent` | on|off|default | | Deliver without a sound. | +| `--sound` | text | | none | default | <ringtone id> | local:<name>. | +| `--stories-hide-sender` | on|off|default | | Hide the sender on story alerts. | +| `--stories-mute` | on|off|default | | Mute this peer's stories. | +| `--topic` | msg-id | | A forum topic. | + +```console +$ tlgr chat notify set @alice --silent on --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `dialogs.gift-notifications`, `dialogs.notify-preview`, `dialogs.notify-silent` + +</details> + +### `chat open` + +Open a chat like a human: recent history AND a read receipt. + +SEMANTICS ARE FROZEN. The read receipt is visible to the other side and irreversible; `--no-read` is the silent peek, and so is `message list`. + +``` +tlgr chat open <CHAT> [OPTIONS] +``` + +**mutating · returns `OpenResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to open. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--increment-views` | flag | | Also count a view on channel posts. | +| `--no-read` | flag | | Peek: fetch the history and emit no read receipt. | +| `--topic` | msg-id | | Only this forum topic. | + +```console +$ tlgr chat open @alice --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `dialogs.open-chat`, `dialogs.peek-chat` + +</details> + +### `chat permission get` + +Show the chat-wide default permissions (what every member may do). + +Allow-polarity, using the same names `chat permission set --allow/--deny` accepts, so the output round-trips back into the input. + +``` +tlgr chat permission get <CHAT> [OPTIONS] +``` + +**returns `PermissionView`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +```console +$ tlgr chat permission get @mygroup --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `groups-channels-admin.default-permissions` + +Reading half; `chat permission set` writes them and owns the id. + +</details> + +### `chat permission list` + +Print the canonical right vocabulary (admin mask, member mask, layer support). + +The one place the names come from: `chat admin promote --rights`, `chat member restrict --deny`, `chat permission set --allow` and `bot default-rights set` all read this table. `manage-linked-peers` and `manage-welcome-messages` are layer-229 flags Telethon 1.44 cannot express and are marked `supported: false` rather than omitted. + +``` +tlgr chat permission list [OPTIONS] +``` + +**returns `list[RightInfo]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--chat` | chat | | Also mark what you may grant here. | +| `--mask` | admin|member|all | `all` | Which vocabulary to print. | + +```console +$ tlgr chat permission list --mask member --json +``` + +<details><summary>Catalog coverage (0 full, 3 partial)</summary> + +Partial: `groups-channels-admin.default-permissions`, `groups-channels-admin.promote-admin`, `groups-channels-admin.restrict-member` + +The vocabulary the three writing commands share; each of them owns its own catalog id. + +</details> + +### `chat permission set` + +Set the chat-wide default permissions. + +Read-modify-write: the current `default_banned_rights` are fetched and patched, because a fresh mask resets every flag you did not mention. `view-messages` is not settable here — a chat nobody may read is `chat member ban`, not a permission — and `until_date` is ignored. Works for basic groups too. + +``` +tlgr chat permission set <CHAT> [OPTIONS] +``` + +**mutating · returns `PermissionResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--allow` | text | | Rights every member may use. | +| `--deny` | text | | Rights no member may use. | +| `--all` | flag | | Allow everything (empty banned mask). | +| `--none` | flag | | Deny everything except view-messages. | +| `--replace` | flag | | Treat --allow/--deny as the whole mask, not a patch. | + +```console +$ tlgr chat permission set @mygroup --deny send-media,send-stickers --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.default-permissions` + +</details> + +### `chat photo delete` + +Remove the group/channel photo. + +``` +tlgr chat photo delete <CHAT> [OPTIONS] +``` + +**mutating · returns `ChatPhotoResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +```console +$ tlgr chat photo delete @mygroup --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.remove-photo` + +</details> + +### `chat photo set` + +Set the group/channel photo (still, video or emoji avatar). + +Exactly one of `<file>`, `--video` and `--emoji-markup` is required. + +``` +tlgr chat photo set <CHAT> [FILE] [OPTIONS] +``` + +**mutating · returns `ChatPhotoResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `FILE` | path | no | The still image. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--emoji-bg` | text | | Background palette for --emoji-markup. | +| `--emoji-markup` | int | | Build the avatar from an emoji. | +| `--video` | path | | Animated avatar. | +| `--video-start` | number | | Still frame from --video. | + +```console +$ tlgr chat photo set @mygroup ./logo.png --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `chat.photo-set`, `groups-channels-admin.edit-photo` + +</details> + +### `chat pin` + +Pin or unpin dialogs, or rewrite the whole pinned order. + +`--folder <name>` pins inside a chat folder, which is a filter edit rather than `toggleDialogPin`. PINNED_DIALOGS_TOO_MUCH is the server's answer when the pinned limit is reached. + +``` +tlgr chat pin [CHAT]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `PinnedDialogs`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | one or more | Chats to pin. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--folder` | text | `main` | Pin inside a chat folder, or main|archive. | +| `--order` | flag | | Treat the arguments as the complete pinned order. | +| `--saved-peer` | chat | | Pin a Saved-Messages sublist. | +| `--unpin` | flag | | Remove the pin instead. | + +Also invocable as: `tlgr chat unpin`, `tlgr chat pin-order` + +```console +$ tlgr chat pin @alice --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `dialogs.pin`, `dialogs.pin-in-folder`, `dialogs.pin-reorder`, `dialogs.unpin` + +</details> + +### `chat poster list` + +Harvest the senders that posted in a chat over a message window. + +Pagination is internal — do not hand-roll the walk. Senders are not always users: an anonymous admin and a linked channel post under a negative channel id, so filter to positive ids when harvesting people. + +``` +tlgr chat poster list <CHAT> [OPTIONS] +``` + +**returns `PosterReport`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to harvest. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--max-messages` | int | `2000` | How much history to walk. | +| `--min-messages` | int | `1` | Drop senders below this count. | +| `--since` | datetime | | Scan window start. | +| `--until` | datetime | | Scan window end. | + +Also invocable as: `tlgr chat posters` + +```console +$ tlgr chat poster list @somegroup --json +``` + +### `chat promo list` + +Chat-list top rows: pending suggestions, birthday bar, PSA promo. + +``` +tlgr chat promo list [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `Promo`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--dismiss` | text | | Dismiss one suggestion key. | +| `--hide-promo` | flag | | Hide the promoted / PSA dialog. | + +Also invocable as: `tlgr chat suggestions` + +```console +$ tlgr chat promo list --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `contacts-users.contacts-birthday-dismiss`, `dialogs.birthday-bar`, `dialogs.promo-psa`, `dialogs.suggestions-dismiss` + +</details> + +### `chat read` + +Send a read receipt for chats, a thread, or a whole folder. + +Irreversible for the other side. `readHistory` is namespace-split (channels.* for supergroups and channels) and Telethon picks the right one; the mention and reaction sweeps are looped until the server stops returning an offset. + +``` +tlgr chat read [CHAT]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `ReadChats`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | any number | Chats to read. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--continue-on-error/--no-continue-on-error` | flag | `True` | Keep going and report per-peer results. | +| `--folder` | text | | Read every chat of a folder instead. | +| `--from-file` | path | | Peers from a file, '-' for stdin. | +| `--mentions` | flag | | Also clear unread mentions. | +| `--polls` | flag | | Also clear unread poll votes. | +| `--reactions` | flag | | Also clear unread reactions. | +| `--saved-peer` | chat | | A Saved-Messages sublist. | +| `--topic` | msg-id | | Advance a comment thread instead. | +| `--type` | user|bot|group|channel | | With --folder: peer kind. | +| `--up-to` | msg-id | | Read up to this message id. | + +Also invocable as: `tlgr chat read-all` + +```console +$ tlgr chat read @alice --json +``` + +<details><summary>Catalog coverage (3 full, 3 partial)</summary> + +Full: `dialogs.mark-read`, `dialogs.mark-read-all`, `dialogs.read-discussion` + +Partial: `dialogs.bulk-chat-actions`, `dialogs.monoforum-topics`, `dialogs.saved-sublists` + +The read half of the bulk and saved-sublist surfaces; archiving in bulk is `chat archive` and listing sublists is `chat saved list`. + +</details> + +### `chat report` + +Report a chat, a user or specific messages. + +Without --spam or --reason this walks Telegram's own option tree: call it, read `options`, call again with `--option <hex>`. The bytes are opaque and path-specific — never persist them. + +``` +tlgr chat report <CHAT> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `ReportResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat to report. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--block` | flag | | Block the peer afterwards. | +| `--comment` | text | | Free text when the tree asks. | +| `--delete` | flag | | Delete the chat afterwards. | +| `--messages` | msg-id | | Message ids to report. | +| `--option` | text | | Option bytes from the previous step. | +| `--reason` | child-abuse|copyright|drugs|fake|geo-irrelevant|other|personal-details|porn|spam|violence | | Legacy account.reportPeer reason. | +| `--spam` | flag | | One-shot action-bar report instead of the tree. | + +Also invocable as: `tlgr chat report-spam` + +```console +$ tlgr chat report @spammer --spam --yes --json +``` + +<details><summary>Catalog coverage (8 full, 0 partial)</summary> + +Full: `contacts-users.user-report-messages`, `contacts-users.user-report-spam`, `dialogs.report-chat`, `dialogs.report-spam-bar`, `dialogs.report-spam-supergroup`, `groups-channels-admin.report-chat`, `groups-channels-admin.report-chat-photo`, `privacy.report-profile-photo` + +</details> + +### `chat request approve` + +Approve join requests. + +Per-user failures are collected in `failed` rather than aborting the batch. + +``` +tlgr chat request approve <CHAT> [USER]... [OPTIONS] +``` + +**mutating · returns `RequestResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | one or more | Which requesters. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | flag | | Answer every pending request. | +| `--link` | text | | With --all: only this invite link. | + +```console +$ tlgr chat request approve @mygroup @alice --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `groups-channels-admin.join-request-approve-all` + +Partial: `groups-channels-admin.join-request-approve-one` + +Approving one is here; declining one is `chat request deny`. + +</details> + +### `chat request deny` + +Decline join requests. + +``` +tlgr chat request deny <CHAT> [USER]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `RequestResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | one or more | Which requesters. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | flag | | Answer every pending request. | +| `--link` | text | | With --all: only this invite link. | + +```console +$ tlgr chat request deny @mygroup --all --yes --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `groups-channels-admin.join-request-approve-one` + +Partial: `groups-channels-admin.join-request-approve-all` + +Declining the whole queue is here too; `chat request approve` owns the other id. + +</details> + +### `chat request list` + +List pending join requests. + +The answer flags (`--approve`, `--decline`, `--approve-all`, `--decline-all`) run before the listing and honour `--dry-run` themselves, so listing keeps working under a dry run instead of printing a stub. + +``` +tlgr chat request list <CHAT> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[JoinRequest]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--approve` | user | | Approve one requester. | +| `--approve-all` | flag | | Approve every pending request. | +| `--community` | flag | | Target a layer-229 community's peer-link requests. | +| `--decline` | user | | Decline one requester. | +| `--decline-all` | flag | | Decline every pending request. | +| `--link` | text | | Only requests from this invite link. | +| `--search`, `-s` | text | | Filter by name. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +Also invocable as: `tlgr chat join-requests` + +```console +$ tlgr chat request list @mygroup --json +``` + +<details><summary>Catalog coverage (2 full, 1 partial)</summary> + +Full: `dialogs.community-join-requests`, `groups-channels-admin.join-request-list` + +Partial: `dialogs.join-requests-badge` + +The badge itself is `chat list --with-join-requests`; this is the queue. + +</details> + +### `chat revenue get` + +Stars / TON revenue of a channel (and per-user paid-message revenue). + +Read-only by design: when `withdrawal_enabled` is true the command says so and points at an official client, because `payments.getStarsRevenueWithdrawalUrl` moves money and wants the 2FA password. Needs `can_view_revenue` / `can_view_stars_revenue`. + +``` +tlgr chat revenue get <CHAT> [OPTIONS] +``` + +**returns `RevenueSummary`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--dark` | flag | | Dark colour set in the graph specs. | +| `--since` | user | | Stars earned from this user's messages. | +| `--ton` | flag | | TON (ad) revenue instead of Stars. | + +```console +$ tlgr chat revenue get @mychannel --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.paid-message-revenue`, `groups-channels-admin.stars-revenue-stats` + +</details> + +### `chat revenue list` + +Stars / TON transaction history of a channel. + +The cursor is the opaque `next_offset` string the server hands back. + +``` +tlgr chat revenue list <CHAT> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[RevenueTransaction]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--ascending` | flag | | Oldest first. | +| `--in` | flag | | Incoming only. | +| `--out` | flag | | Outgoing only. | +| `--subscription` | text | | Only this subscription's rows. | +| `--ton` | flag | | TON transactions instead of Stars. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr chat revenue list @mychannel --in --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.stars-transactions` + +</details> + +### `chat saved list` + +Saved-Messages sublists and channel direct-message topics. + +``` +tlgr chat saved list [OPTIONS] +``` + +**paginated (`DIALOGS` cursor) · returns `Page[SavedDialog]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--in` | chat | | me/saved, or a monoforum channel. | +| `--pinned` | flag | | Only pinned sublists, in order. | +| `--unread` | flag | | Only sublists with unread messages. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr chat saved list --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `dialogs.monoforum-topics`, `dialogs.saved-sublists` + +</details> + +### `chat secret discard` + +Discard a secret chat, optionally deleting it or reporting spam. + +``` +tlgr chat secret discard <ID> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `SecretChat`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `ID` | int | yes | Secret chat id. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--delete-history` | flag | | Also delete the history. | +| `--report-spam` | flag | | Report it as spam. | + +```console +$ tlgr chat secret discard 12 --yes --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `dialogs.report-encrypted-spam`, `dialogs.secret-discard` + +</details> + +### `chat secret list` + +Secret chats this session holds, with state and key fingerprint. + +NOT SUPPORTED. Secret chats never appear in `messages.getDialogs` and are bound to the one session that created them, so listing them needs the local key store the E2E module would own. + +``` +tlgr chat secret list [OPTIONS] +``` + +**returns `Page[SecretChat]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--fingerprint` | flag | | Include the key fingerprint. | +| `--requests` | flag | | Only incoming, not-yet-accepted requests. | + +```console +$ tlgr chat secret list --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `contacts-users.user-secret-chat`, `dialogs.secret-fingerprint`, `dialogs.secret-list` + +Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists. + +</details> + +### `chat secret send` + +Send into a secret chat, set its timer, ack or show typing. + +NOT SUPPORTED. The payload of `messages.sendEncrypted` is a separately-serialised, AES-IGE-encrypted message with its own sequence numbers; Telethon builds none of it. + +``` +tlgr chat secret send <ID> [TEXT] [OPTIONS] +``` + +**mutating · returns `SecretChat`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `ID` | int | yes | Secret chat id. | +| `TEXT` | text | no | What to send. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--read` | flag | | Acknowledge up to now. | +| `--ttl` | duration | | Self-destruct timer. | +| `--typing` | flag | | Show the typing indicator. | + +```console +$ tlgr chat secret send 12 hello --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `dialogs.secret-read-typing`, `dialogs.secret-send`, `dialogs.secret-ttl` + +Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists. + +</details> + +### `chat secret start` + +Start a secret chat with a user, or accept an incoming request. + +NOT SUPPORTED. `requestEncryption`/`acceptEncryption` need a validated Diffie-Hellman exchange tlgr cannot perform yet. + +``` +tlgr chat secret start [USER] [OPTIONS] +``` + +**mutating · returns `SecretChat`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | no | Who to start it with. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--accept` | int | | Accept this incoming secret chat. | + +Also invocable as: `tlgr chat secret accept` + +```console +$ tlgr chat secret start @alice --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `dialogs.secret-accept`, `dialogs.secret-create` + +Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists. + +</details> + +### `chat send-as list` + +Peers I may post as in this chat. + +``` +tlgr chat send-as list <CHAT> [OPTIONS] +``` + +**returns `list[SendAsPeer]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +```console +$ tlgr chat send-as list @mygroup --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `groups-channels-admin.send-as` + +Listing half; `chat send-as set` writes the default and owns the id. + +</details> + +### `chat send-as set` + +Set the default peer I post as in this chat. + +Applies to reactions as well as messages. Every send command still takes a one-off `--send-as`. The `anonymous` admin right forces the group itself. + +``` +tlgr chat send-as set <CHAT> <PEER> [OPTIONS] +``` + +**mutating · returns `SendAsResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `PEER` | chat | yes | Who to post as. | + +```console +$ tlgr chat send-as set @mygroup @mychannel --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.send-as` + +</details> + +### `chat set` + +Per-dialog switches: content sharing, forum view mode, send-as. + +`--sharing off` is `noforwards`, which needs Premium in a private chat and owner rights elsewhere. `--send-as-list` reports the identities available and changes nothing. + +``` +tlgr chat set <CHAT> [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `ChatSwitches`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Chat. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--request-msg` | msg-id | | The message that asked. | +| `--send-as` | chat | | Default identity to post as. | +| `--send-as-list` | flag | | List the identities you may post as and stop. | +| `--sharing` | on|off | | Allow forwarding and saving from this chat. | +| `--view-as` | topics|messages | | Show a forum as topics or one list. | + +```console +$ tlgr chat set @alice --sharing off --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `dialogs.no-forwards-private`, `dialogs.send-as-default`, `dialogs.view-as-topics` + +</details> + +### `chat setting get` + +Print every administrable policy toggle with its current value. + +The key names are exactly `chat setting set`'s flag names without the leading dashes, so the output round-trips into the input. `available` says whether *you* may change each key, and `gated_by` names the capability flag or boost level that blocks it. + +``` +tlgr chat setting get <CHAT> [OPTIONS] +``` + +**returns `SettingsView`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +Also invocable as: `tlgr chat settings` + +```console +$ tlgr chat setting get @mygroup --json +``` + +<details><summary>Catalog coverage (12 full, 6 partial)</summary> + +Full: `groups-channels-admin.antispam`, `groups-channels-admin.autotranslation`, `groups-channels-admin.content-protection`, `groups-channels-admin.forum-toggle`, `groups-channels-admin.gift-notifications`, `groups-channels-admin.group-emoji-set`, `groups-channels-admin.hidden-members`, `groups-channels-admin.prehistory-visibility`, `groups-channels-admin.reactions-settings`, `groups-channels-admin.signatures`, `groups-channels-admin.slow-mode`, `groups-channels-admin.toggle-join-to-send` + +Partial: `groups-channels-admin.channel-direct-messages`, `groups-channels-admin.group-sticker-set`, `groups-channels-admin.paid-messages-price`, `groups-channels-admin.restrict-sponsored`, `groups-channels-admin.toggle-join-request`, `groups-channels-admin.view-forum-as-messages` + +Reading half of the Manage screen; `chat setting set` writes each key. + +</details> + +### `chat setting set` + +Change group/channel policy toggles (the Manage screen). + +Idempotent: a toggle already in the requested state is reported in `already` and never sent. Failures are reported per key in `failed` and do not stop the rest, because a boost-gated refusal must not hide the changes that did land. `--direct-messages` and `--paid-messages` are the same call. + +``` +tlgr chat setting set <CHAT> [OPTIONS] +``` + +**mutating · returns `SettingResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--ads` | text | | `off` disables sponsored messages. | +| `--allow-custom-reactions` | text | | With --reactions all. | +| `--antispam` | text | | Aggressive anti-spam. | +| `--apply-to-links` | flag | | Apply --join-request to existing invite links too. | +| `--autotranslate` | text | | Channel auto-translation. | +| `--direct-messages` | text | | Enable the channel's direct messages. | +| `--emoji-set` | text | | Group custom-emoji pack short name. | +| `--forum` | text | | Enable or disable Topics. | +| `--forum-tabs` | tabs|list | | Tabbed vs list topic UI. | +| `--gift-notifications` | text | | Star-gift notices in the channel. | +| `--guard-bot` | user | | Bot that handles the queue. | +| `--hidden-members` | text | | Hide the member list from non-admins. | +| `--join-request` | text | | Require approval to join. | +| `--join-to-send` | text | | Require joining before sending. | +| `--paid-messages` | text | | Stars per incoming message. | +| `--paid-reactions` | text | | Enable Stars reactions. | +| `--prehistory` | visible|hidden | | History visibility for new members. | +| `--protect`, `--noforwards` | text | | Restrict saving and forwarding. | +| `--reactions` | text | | Allowed reactions. | +| `--reactions-limit` | int | | Max distinct reactions per message. | +| `--signature-profiles` | text | | Link signatures to profiles. | +| `--signatures` | text | | Sign channel posts. | +| `--slow-mode` | text | | Seconds between messages, or off. | +| `--sticker-set` | text | | Group sticker set short name. | +| `--view-as` | messages|topics | | My own view preference for this forum. | + +```console +$ tlgr chat setting set @mygroup --slow-mode 30s --hidden-members on --json +``` + +<details><summary>Catalog coverage (11 full, 14 partial)</summary> + +Full: `dialogs.channel-autotranslation`, `dialogs.forum-tabs-mode`, `emoji.status-channel`, `groups-channels-admin.channel-direct-messages`, `groups-channels-admin.group-sticker-set`, `groups-channels-admin.restrict-sponsored`, `groups-channels-admin.toggle-join-request`, `groups-channels-admin.view-forum-as-messages`, `messages-core.paid-messages-group-price`, `messages-core.translate-channel-autotranslation`, `sticker.group-sticker-set` + +Partial: `groups-channels-admin.antispam`, `groups-channels-admin.autotranslation`, `groups-channels-admin.channel-sponsored-messages`, `groups-channels-admin.content-protection`, `groups-channels-admin.forum-toggle`, `groups-channels-admin.gift-notifications`, `groups-channels-admin.group-emoji-set`, `groups-channels-admin.hidden-members`, `groups-channels-admin.paid-messages-price`, `groups-channels-admin.prehistory-visibility`, `groups-channels-admin.reactions-settings`, `groups-channels-admin.signatures`, `groups-channels-admin.slow-mode`, `groups-channels-admin.toggle-join-to-send` + +The writing half of the Manage screen; `chat setting get` reads the same keys and owns most of these ids. + +</details> + +### `chat similar list` + +Channels similar to this one (or global recommendations). + +A non-Premium account gets a truncated `messages.chatsSlice`; `total` reports the full count so the cut is visible. There is no bare `chat similar` alias: Click cannot hold a command and a group under one name, and `chat similar list` is the canonical path. + +``` +tlgr chat similar list [CHAT] [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[SimilarChat]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | Channel, or omit for mine. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--bots` | flag | | Bots similar to this bot instead. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr chat similar list @somechannel --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `contacts-users.people-you-may-know`, `dialogs.recommended-channels`, `groups-channels-admin.similar-channels` + +</details> + +### `chat sponsored list` + +Sponsored messages the server wants shown in this channel. + +Exposed as data only; `viewed` is always false because tlgr never reports an impression it did not make. Results are cached five minutes server-side, which is the API's own contract. Turning them off for your channel is `chat setting set --ads off`. + +``` +tlgr chat sponsored list <CHAT> [OPTIONS] +``` + +**returns `list[SponsoredMessage]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Channel. | + +```console +$ tlgr chat sponsored list @somechannel --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `groups-channels-admin.channel-sponsored-messages` + +Reading them; `chat sponsored report` owns the id and reports one. + +</details> + +### `chat sponsored report` + +Report a sponsored message. + +The reason menu is server-driven: with no `--option` the command prints the option blobs and exits 0, so a script can walk the tree one level at a time. + +``` +tlgr chat sponsored report <CHAT> <RANDOM_ID> [OPTIONS] +``` + +**mutating · returns `SponsoredReport`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Channel. | +| `RANDOM_ID` | text | yes | The opaque id from `chat sponsored list`. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--comment` | text | | Free-text comment. | +| `--option` | text | | Server-provided option for the next step. | + +```console +$ tlgr chat sponsored report @somechannel AQID --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.channel-sponsored-messages` + +</details> + +### `chat stats get` + +Channel, supergroup, post, story or poll statistics. + +Needs `channelFull.can_view_stats` (channels need about 500 members). Every call is routed to `channelFull.stats_dc`. Graph payloads are the API's own chart specification, emitted verbatim; async graphs stay as `{token, zoom_token}` until `--load-graphs` or `--graph` resolves them, and `--out DIR` writes each one to a file instead of inlining it. + +``` +tlgr chat stats get <CHAT> [OPTIONS] +``` + +**returns `ChatStats`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Channel or supergroup. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--dark` | flag | | Ask for the dark colour set in the specs. | +| `--graph` | text | | Resolve one async graph token. | +| `--load-graphs` | flag | | Resolve every async graph before printing. | +| `--message` | msg-id | | Per-post statistics. | +| `--out` | path | | Write graph specs to files. | +| `--poll` | msg-id | | Poll vote statistics. | +| `--story` | int | | Story statistics. | +| `--zoom` | int | | With --graph: zoom into x. | + +Also invocable as: `tlgr stats get` + +```console +$ tlgr chat stats get @mychannel --load-graphs --json +``` + +<details><summary>Catalog coverage (5 full, 2 partial)</summary> + +Full: `groups-channels-admin.channel-stats`, `groups-channels-admin.poll-stats`, `groups-channels-admin.stats-async-graph`, `groups-channels-admin.supergroup-stats`, `messages-core.message-statistics` + +Partial: `groups-channels-admin.message-stats`, `groups-channels-admin.story-stats` + +Per-post numbers are here; the repost list is `chat stats list`. + +</details> + +### `chat stats list` + +Public forwards (reposts) of a post or a story. + +Also routed to the stats DC. The cursor is the opaque `next_offset` string. + +``` +tlgr chat stats list <CHAT> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[PublicForward]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--message` | msg-id | | Public forwards of a post. | +| `--story` | int | | Public forwards of a story. | + +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 stats list` + +```console +$ tlgr chat stats list @mychannel --message 918 --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.message-stats`, `groups-channels-admin.story-stats` + +</details> + +### `chat suggested-post approve` + +Approve a post suggested to a channel. + +`--at` doubles as the accept-and-reschedule counter-offer. Accepting a priced post debits the payer, not you. + +``` +tlgr chat suggested-post approve <CHANNEL> <MSG_ID> [OPTIONS] +``` + +**mutating · returns `SuggestedPostResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHANNEL` | chat | yes | The channel. | +| `MSG_ID` | msg-id | yes | The suggested post. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--at` | text | | Publish then instead of when proposed. | ```console -$ tlgr chat mention list @somegroup --json +$ tlgr chat suggested-post approve @mychannel 918 --json ``` -<details><summary>Catalog coverage (3 full, 0 partial)</summary> +<details><summary>Catalog coverage (0 full, 1 partial)</summary> -Full: `dialogs.unread-mentions`, `dialogs.unread-poll-votes`, `dialogs.unread-reactions` +Partial: `groups-channels-admin.suggested-post-approve` -</details> +Approving; `chat suggested-post deny` rejects and owns the id. -### `chat mute` +</details> -Mute or unmute chats, for a duration or forever. +### `chat suggested-post deny` -`mute_until` is absolute wall-clock time (COR-01). `inputPeerNotifySettings` is sparse — only the field being changed is sent, so the rest keeps inheriting the scope default. `--folder` costs one RPC per chat because Telegram has no batched form. +Reject a post suggested to a channel. ``` -tlgr chat mute [CHAT]... [OPTIONS] +tlgr chat suggested-post deny <CHANNEL> <MSG_ID> [OPTIONS] ``` -**mutating · idempotent (reports `already`) · returns `MuteResult`** +**mutating · destructive (needs `--yes` off a TTY) · returns `SuggestedPostResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | any number | Chats to mute. | +| `CHANNEL` | chat | yes | The channel. | +| `MSG_ID` | msg-id | yes | The suggested post. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--folder` | text | | Apply to every chat of a folder. | -| `--for` | duration | | Mute for 1h | 8h | 2d. | -| `--off` | flag | | Unmute, keeping the other notify fields. | -| `--stories` | flag | | Mute the peer's stories instead. | -| `--topic` | msg-id | | A forum topic. | -| `--until` | datetime | | Mute until an absolute time. | - -Also invocable as: `tlgr chat unmute` +| `--comment` | text | | Reason sent back to the author. | ```console -$ tlgr chat mute @alice --for 8h --json +$ tlgr chat suggested-post deny @mychannel 918 --comment 'off topic' --yes --json ``` -<details><summary>Catalog coverage (5 full, 0 partial)</summary> +<details><summary>Catalog coverage (1 full, 0 partial)</summary> -Full: `dialogs.mute-folder`, `dialogs.mute-for-duration`, `dialogs.mute-forever`, `dialogs.unmute`, `groups-channels-admin.chat-notify-settings` +Full: `groups-channels-admin.suggested-post-approve` </details> -### `chat notify get` +### `chat suggestion delete` -Show a chat's notification settings, exception and effective value. +Dismiss a server-suggested admin action. + +The pending keys come from `chat get --full` (`channelFull.pending_suggestions`). ``` -tlgr chat notify get <CHAT> [OPTIONS] +tlgr chat suggestion delete <CHAT> <KEY> [OPTIONS] ``` -**returns `NotifyView`** +**mutating · returns `SuggestionResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat to describe. | +| `CHAT` | chat | yes | Group or channel. | +| `KEY` | text | yes | The suggestion key to dismiss. | + +```console +$ tlgr chat suggestion delete @mygroup CONVERT_GIGAGROUP --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.pending-suggestions` + +</details> + +### `chat theme list` + +List the chat themes available. + +``` +tlgr chat theme list [OPTIONS] +``` + +**paginated (`RATE` cursor) · returns `Page[ChatTheme]`** | Flag | Type | Default | Meaning | |---|---|---|---| -| `--effective/--no-effective` | flag | `True` | Merge the scope default under the exception. | -| `--topic` | msg-id | | A forum topic. | +| `--gifts` | flag | | Collectible gift themes instead of emoji ones. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). ```console -$ tlgr chat notify get @alice --json +$ tlgr chat theme list --json ``` <details><summary>Catalog coverage (1 full, 0 partial)</summary> -Full: `dialogs.notify-get` +Full: `dialogs.chat-theme-list` </details> -### `chat notify set` - -Set one chat's notification exception. +### `chat theme set` -Every switch takes on|off|default, because `default` is a real third state: it removes the exception so the chat inherits the scope again. +Set or remove the theme of one chat. ``` -tlgr chat notify set <CHAT> [OPTIONS] +tlgr chat theme set <CHAT> [OPTIONS] ``` -**mutating · idempotent (reports `already`) · returns `NotifyView`** +**mutating · idempotent (reports `already`) · returns `ThemeResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat to change. | +| `CHAT` | chat | yes | Chat. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--gifts` | on|off | | Star-gift notifications for a channel you admin. | -| `--preview` | on|off|default | | Message preview in notifications. | -| `--reset` | flag | | Drop the exception and inherit the scope default. | -| `--silent` | on|off|default | | Deliver without a sound. | -| `--sound` | text | | none | default | <ringtone id> | local:<name>. | -| `--stories-hide-sender` | on|off|default | | Hide the sender on story alerts. | -| `--stories-mute` | on|off|default | | Mute this peer's stories. | -| `--topic` | msg-id | | A forum topic. | +| `--emoji` | text | | Emoji theme from `chat theme list`. | +| `--gift` | text | | Collectible gift theme slug. | +| `--unset` | flag | | Remove the per-chat theme. | + +Also invocable as: `tlgr chat theme unset` ```console -$ tlgr chat notify set @alice --silent on --json +$ tlgr chat theme set @alice --emoji 🌷 --json ``` -<details><summary>Catalog coverage (3 full, 0 partial)</summary> +<details><summary>Catalog coverage (4 full, 0 partial)</summary> -Full: `dialogs.gift-notifications`, `dialogs.notify-preview`, `dialogs.notify-silent` +Full: `dialogs.chat-theme-reset`, `gift.as-chat-theme`, `gifts.set-as-chat-theme`, `theme.set-chat-theme` </details> -### `chat open` - -Open a chat like a human: recent history AND a read receipt. +### `chat topic close` -SEMANTICS ARE FROZEN. The read receipt is visible to the other side and irreversible; `--no-read` is the silent peek, and so is `message list`. +Close a topic. ``` -tlgr chat open <CHAT> [OPTIONS] +tlgr chat topic close <CHAT> <TOPIC> [OPTIONS] ``` -**mutating · returns `OpenResult`** +**mutating · returns `TopicResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat to open. | - -| Flag | Type | Default | Meaning | -|---|---|---|---| -| `--increment-views` | flag | | Also count a view on channel posts. | -| `--no-read` | flag | | Peek: fetch the history and emit no read receipt. | -| `--topic` | msg-id | | Only this forum topic. | +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | yes | Topic id. | ```console -$ tlgr chat open @alice --json +$ tlgr chat topic close @myforum 314 --json ``` -<details><summary>Catalog coverage (2 full, 0 partial)</summary> +<details><summary>Catalog coverage (0 full, 1 partial)</summary> -Full: `dialogs.open-chat`, `dialogs.peek-chat` +Partial: `groups-channels-admin.topic-close-reopen` + +The closing half; `chat topic reopen` owns the id. </details> -### `chat pin` +### `chat topic create` -Pin or unpin dialogs, or rewrite the whole pinned order. +Create a topic. -`--folder <name>` pins inside a chat folder, which is a filter edit rather than `toggleDialogPin`. PINNED_DIALOGS_TOO_MUCH is the server's answer when the pinned limit is reached. +The returned id is the id of the `messageActionTopicCreate` service message, which is exactly what every `--topic` flag takes. Non-Premium accounts may only use icons from `inputStickerSetEmojiDefaultTopicIcons`. ``` -tlgr chat pin [CHAT]... [OPTIONS] +tlgr chat topic create <CHAT> <TITLE> [OPTIONS] ``` -**mutating · idempotent (reports `already`) · returns `PinnedDialogs`** +**mutating · returns `TopicResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | one or more | Chats to pin. | +| `CHAT` | chat | yes | Forum supergroup. | +| `TITLE` | text | yes | Topic title. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--folder` | text | `main` | Pin inside a chat folder, or main|archive. | -| `--order` | flag | | Treat the arguments as the complete pinned order. | -| `--saved-peer` | chat | | Pin a Saved-Messages sublist. | -| `--unpin` | flag | | Remove the pin instead. | +| `--icon-color` | int | | Icon colour; immutable afterwards. | +| `--icon-emoji` | text | | Custom-emoji icon (document id). | +| `--send-as` | chat | | Post the creation notice as this peer. | -Also invocable as: `tlgr chat unpin`, `tlgr chat pin-order` +```console +$ tlgr chat topic create @myforum Releases --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.topic-create` + +</details> + +### `chat topic delete` + +Delete a topic and all its messages. + +Drains `messages.affectedHistory` until the offset is 0. No dedicated update is emitted; other clients learn about it from the deleted root message. + +``` +tlgr chat topic delete <CHAT> <TOPIC> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `TopicResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | yes | Topic id. | ```console -$ tlgr chat pin @alice --json +$ tlgr chat topic delete @myforum 314 --yes --json ``` -<details><summary>Catalog coverage (4 full, 0 partial)</summary> +<details><summary>Catalog coverage (1 full, 0 partial)</summary> -Full: `dialogs.pin`, `dialogs.pin-in-folder`, `dialogs.pin-reorder`, `dialogs.unpin` +Full: `groups-channels-admin.topic-delete` </details> -### `chat poster list` +### `chat topic edit` -Harvest the senders that posted in a chat over a message window. +Rename a topic or change its icon. -Pagination is internal — do not hand-roll the walk. Senders are not always users: an anonymous admin and a linked channel post under a negative channel id, so filter to positive ids when harvesting people. +`icon_color` cannot be changed after creation — the API has no field for it — and the General topic accepts only `--title` and `--hidden`. ``` -tlgr chat poster list <CHAT> [OPTIONS] +tlgr chat topic edit <CHAT> <TOPIC> [OPTIONS] ``` -**returns `PosterReport`** +**mutating · returns `TopicResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat to harvest. | +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | yes | Topic id. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--max-messages` | int | `2000` | How much history to walk. | -| `--min-messages` | int | `1` | Drop senders below this count. | -| `--since` | datetime | | Scan window start. | -| `--until` | datetime | | Scan window end. | - -Also invocable as: `tlgr chat posters` +| `--closed/--no-closed` | flag | | Close or reopen in the same call. | +| `--hidden/--no-hidden` | flag | | Hide or show (General only). | +| `--icon-emoji` | text | | New custom-emoji icon. | +| `--no-icon` | flag | | Drop the custom emoji icon. | +| `--title` | text | | New title. | ```console -$ tlgr chat poster list @somegroup --json +$ tlgr chat topic edit @myforum 314 --title 'Release notes' --json ``` -### `chat promo list` +<details><summary>Catalog coverage (1 full, 2 partial)</summary> -Chat-list top rows: pending suggestions, birthday bar, PSA promo. +Full: `groups-channels-admin.topic-edit` + +Partial: `groups-channels-admin.topic-close-reopen`, `groups-channels-admin.topic-hide-general` + +`--closed`/`--hidden` do it in one call; `chat topic reopen` and `chat topic unhide` own the ids. + +</details> + +### `chat topic get` + +Get one or more topics by id. + +`forumTopicDeleted` rows are reported as `{id, deleted: true}`, which is the only signal the API gives that a topic was removed. ``` -tlgr chat promo list [OPTIONS] +tlgr chat topic get <CHAT> [TOPIC]... [OPTIONS] ``` -**mutating · idempotent (reports `already`) · returns `Promo`** +**returns `list[Topic]`** -| Flag | Type | Default | Meaning | +| Argument | Type | Required | Meaning | |---|---|---|---| -| `--dismiss` | text | | Dismiss one suggestion key. | -| `--hide-promo` | flag | | Hide the promoted / PSA dialog. | - -Also invocable as: `tlgr chat suggestions` +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | one or more | Topic ids. | ```console -$ tlgr chat promo list --json +$ tlgr chat topic get @myforum 314 --json ``` -<details><summary>Catalog coverage (4 full, 0 partial)</summary> +<details><summary>Catalog coverage (2 full, 0 partial)</summary> -Full: `contacts-users.contacts-birthday-dismiss`, `dialogs.birthday-bar`, `dialogs.promo-psa`, `dialogs.suggestions-dismiss` +Full: `groups-channels-admin.topic-get`, `groups-channels-admin.topic-link` </details> -### `chat read` +### `chat topic hide` -Send a read receipt for chats, a thread, or a whole folder. +Hide the General topic. -Irreversible for the other side. `readHistory` is namespace-split (channels.* for supergroups and channels) and Telethon picks the right one; the mention and reaction sweeps are looped until the server stops returning an offset. +Only General (id 1) may be hidden; the server refuses any other id. ``` -tlgr chat read [CHAT]... [OPTIONS] +tlgr chat topic hide <CHAT> [OPTIONS] ``` -**mutating · idempotent (reports `already`) · returns `ReadChats`** +**mutating · returns `TopicResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | any number | Chats to read. | - -| Flag | Type | Default | Meaning | -|---|---|---|---| -| `--continue-on-error/--no-continue-on-error` | flag | `True` | Keep going and report per-peer results. | -| `--folder` | text | | Read every chat of a folder instead. | -| `--from-file` | path | | Peers from a file, '-' for stdin. | -| `--mentions` | flag | | Also clear unread mentions. | -| `--polls` | flag | | Also clear unread poll votes. | -| `--reactions` | flag | | Also clear unread reactions. | -| `--saved-peer` | chat | | A Saved-Messages sublist. | -| `--topic` | msg-id | | Advance a comment thread instead. | -| `--type` | user|bot|group|channel | | With --folder: peer kind. | -| `--up-to` | msg-id | | Read up to this message id. | - -Also invocable as: `tlgr chat read-all` +| `CHAT` | chat | yes | Forum supergroup. | ```console -$ tlgr chat read @alice --json +$ tlgr chat topic hide @myforum --json ``` -<details><summary>Catalog coverage (3 full, 3 partial)</summary> - -Full: `dialogs.mark-read`, `dialogs.mark-read-all`, `dialogs.read-discussion` +<details><summary>Catalog coverage (0 full, 1 partial)</summary> -Partial: `dialogs.bulk-chat-actions`, `dialogs.monoforum-topics`, `dialogs.saved-sublists` +Partial: `groups-channels-admin.topic-hide-general` -The read half of the bulk and saved-sublist surfaces; archiving in bulk is `chat archive` and listing sublists is `chat saved list`. +The hiding half; `chat topic unhide` owns the id. </details> -### `chat report` +### `chat topic list` -Report a chat, a user or specific messages. +List or search a forum's topics. -Without --spam or --reason this walks Telegram's own option tree: call it, read `options`, call again with `--option <hex>`. The bytes are opaque and path-specific — never persist them. +The cursor packs the `(offset_date, offset_id, offset_topic)` triple of the last row. `--closed`, `--hidden` and `--pinned` are client-side filters over the page, because the API offers no flag for any of them. ``` -tlgr chat report <CHAT> [OPTIONS] +tlgr chat topic list <CHAT> [OPTIONS] ``` -**mutating · destructive (needs `--yes` off a TTY) · returns `ReportResult`** +**paginated (`PARTICIPANTS` cursor) · returns `Page[Topic]`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat to report. | +| `CHAT` | chat | yes | Forum supergroup. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--block` | flag | | Block the peer afterwards. | -| `--comment` | text | | Free text when the tree asks. | -| `--delete` | flag | | Delete the chat afterwards. | -| `--messages` | msg-id | | Message ids to report. | -| `--option` | text | | Option bytes from the previous step. | -| `--reason` | child-abuse|copyright|drugs|fake|geo-irrelevant|other|personal-details|porn|spam|violence | | Legacy account.reportPeer reason. | -| `--spam` | flag | | One-shot action-bar report instead of the tree. | +| `--closed` | flag | | Only closed topics. | +| `--hidden` | flag | | Only hidden topics. | +| `--pinned` | flag | | Only pinned topics. | +| `--search`, `-s` | text | | Title query. | -Also invocable as: `tlgr chat report-spam` +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). ```console -$ tlgr chat report @spammer --spam --yes --json +$ tlgr chat topic list @myforum --json ``` -<details><summary>Catalog coverage (8 full, 0 partial)</summary> +<details><summary>Catalog coverage (1 full, 1 partial)</summary> -Full: `contacts-users.user-report-messages`, `contacts-users.user-report-spam`, `dialogs.report-chat`, `dialogs.report-spam-bar`, `dialogs.report-spam-supergroup`, `groups-channels-admin.report-chat`, `groups-channels-admin.report-chat-photo`, `privacy.report-profile-photo` +Full: `groups-channels-admin.topic-list` + +Partial: `groups-channels-admin.topic-unread-counters` + +The counters are on every row; `chat topic read` clears them and owns the id. </details> -### `chat saved list` +### `chat topic mute` -Saved-Messages sublists and channel direct-message topics. +Mute a topic. + +`mute_until` is an absolute timestamp computed from the wall clock. `--silent on` without a duration switches to silent delivery instead of muting. ``` -tlgr chat saved list [OPTIONS] +tlgr chat topic mute <CHAT> <TOPIC> [DURATION] [OPTIONS] ``` -**paginated (`DIALOGS` cursor) · returns `Page[SavedDialog]`** +**mutating · returns `TopicResult`** -| Flag | Type | Default | Meaning | +| Argument | Type | Required | Meaning | |---|---|---|---| -| `--in` | chat | | me/saved, or a monoforum channel. | -| `--pinned` | flag | | Only pinned sublists, in order. | -| `--unread` | flag | | Only sublists with unread messages. | +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | yes | Topic id. | +| `DURATION` | text | no | How long; omit to mute forever. | -Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--previews/--no-previews` | flag | | Show message text in notifications. | +| `--silent/--no-silent` | flag | | Deliver without a sound instead of muting. | ```console -$ tlgr chat saved list --json +$ tlgr chat topic mute @myforum 314 8h --json ``` -<details><summary>Catalog coverage (2 full, 0 partial)</summary> +<details><summary>Catalog coverage (0 full, 1 partial)</summary> -Full: `dialogs.monoforum-topics`, `dialogs.saved-sublists` +Partial: `groups-channels-admin.topic-notify-settings` + +Muting half; `chat topic unmute` owns the id. </details> -### `chat secret discard` +### `chat topic pin` -Discard a secret chat, optionally deleting it or reporting spam. +Pin topics (pass several ids to set the pinned order). + +`--reorder` sends the ids as the complete order; `--force` also unpins anything missing from the list. At most `topics_pinned_limit` topics can be pinned. ``` -tlgr chat secret discard <ID> [OPTIONS] +tlgr chat topic pin <CHAT> [TOPIC]... [OPTIONS] ``` -**mutating · destructive (needs `--yes` off a TTY) · returns `SecretChat`** +**mutating · returns `TopicPinResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `ID` | int | yes | Secret chat id. | +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | one or more | Topic ids. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--delete-history` | flag | | Also delete the history. | -| `--report-spam` | flag | | Report it as spam. | +| `--force` | flag | | With --reorder: unpin topics missing from the list. | +| `--reorder` | flag | | Treat the ids as the complete pinned order. | ```console -$ tlgr chat secret discard 12 --yes --json +$ tlgr chat topic pin @myforum 314 --json ``` -<details><summary>Catalog coverage (2 full, 0 partial)</summary> +<details><summary>Catalog coverage (1 full, 1 partial)</summary> -Full: `dialogs.report-encrypted-spam`, `dialogs.secret-discard` +Full: `groups-channels-admin.topic-reorder-pinned` + +Partial: `groups-channels-admin.topic-pin` + +Pinning is here; `chat topic unpin` owns the id. </details> -### `chat secret list` +### `chat topic read` -Secret chats this session holds, with state and key fingerprint. +Mark a topic read, including its mentions and reactions. -NOT SUPPORTED. Secret chats never appear in `messages.getDialogs` and are bound to the one session that created them, so listing them needs the local key store the E2E module would own. +SEMANTICS: this emits a read receipt inside the topic, exactly like `chat open` does for a chat. `--list` is the silent half. `top_msg_id` is omitted for General (id 1), which is what the API requires. Sending and listing messages inside a topic is `message send/list --topic`. ``` -tlgr chat secret list [OPTIONS] +tlgr chat topic read <CHAT> <TOPIC> [OPTIONS] ``` -**returns `Page[SecretChat]`** +**mutating · returns `TopicReadResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | yes | Topic id. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--fingerprint` | flag | | Include the key fingerprint. | -| `--requests` | flag | | Only incoming, not-yet-accepted requests. | +| `--list` | flag | | Do not read: list the unread mentions/reactions instead. | +| `--max-id` | int | | Read up to this message id; 0 means everything. | +| `--mentions` | flag | | Also clear the unread-mentions badge. | +| `--reactions` | flag | | Also clear the unread-reactions badge. | ```console -$ tlgr chat secret list --json +$ tlgr chat topic read @myforum 314 --mentions --json ``` -<details><summary>Catalog coverage (3 full, 0 partial)</summary> - -Full: `contacts-users.user-secret-chat`, `dialogs.secret-fingerprint`, `dialogs.secret-list` +<details><summary>Catalog coverage (2 full, 0 partial)</summary> -Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists. +Full: `groups-channels-admin.topic-messages`, `groups-channels-admin.topic-unread-counters` </details> -### `chat secret send` - -Send into a secret chat, set its timer, ack or show typing. +### `chat topic reopen` -NOT SUPPORTED. The payload of `messages.sendEncrypted` is a separately-serialised, AES-IGE-encrypted message with its own sequence numbers; Telethon builds none of it. +Reopen a closed topic. ``` -tlgr chat secret send <ID> [TEXT] [OPTIONS] +tlgr chat topic reopen <CHAT> <TOPIC> [OPTIONS] ``` -**mutating · returns `SecretChat`** +**mutating · returns `TopicResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `ID` | int | yes | Secret chat id. | -| `TEXT` | text | no | What to send. | - -| Flag | Type | Default | Meaning | -|---|---|---|---| -| `--read` | flag | | Acknowledge up to now. | -| `--ttl` | duration | | Self-destruct timer. | -| `--typing` | flag | | Show the typing indicator. | +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | yes | Topic id. | ```console -$ tlgr chat secret send 12 hello --json +$ tlgr chat topic reopen @myforum 314 --json ``` -<details><summary>Catalog coverage (3 full, 0 partial)</summary> - -Full: `dialogs.secret-read-typing`, `dialogs.secret-send`, `dialogs.secret-ttl` +<details><summary>Catalog coverage (1 full, 0 partial)</summary> -Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists. +Full: `groups-channels-admin.topic-close-reopen` </details> -### `chat secret start` - -Start a secret chat with a user, or accept an incoming request. +### `chat topic unhide` -NOT SUPPORTED. `requestEncryption`/`acceptEncryption` need a validated Diffie-Hellman exchange tlgr cannot perform yet. +Show the General topic again. ``` -tlgr chat secret start [USER] [OPTIONS] +tlgr chat topic unhide <CHAT> [OPTIONS] ``` -**mutating · returns `SecretChat`** +**mutating · returns `TopicResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `USER` | user | no | Who to start it with. | - -| Flag | Type | Default | Meaning | -|---|---|---|---| -| `--accept` | int | | Accept this incoming secret chat. | - -Also invocable as: `tlgr chat secret accept` +| `CHAT` | chat | yes | Forum supergroup. | ```console -$ tlgr chat secret start @alice --json +$ tlgr chat topic unhide @myforum --json ``` -<details><summary>Catalog coverage (2 full, 0 partial)</summary> - -Full: `dialogs.secret-accept`, `dialogs.secret-create` +<details><summary>Catalog coverage (1 full, 0 partial)</summary> -Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists. +Full: `groups-channels-admin.topic-hide-general` </details> -### `chat set` - -Per-dialog switches: content sharing, forum view mode, send-as. +### `chat topic unmute` -`--sharing off` is `noforwards`, which needs Premium in a private chat and owner rights elsewhere. `--send-as-list` reports the identities available and changes nothing. +Unmute a topic. ``` -tlgr chat set <CHAT> [OPTIONS] +tlgr chat topic unmute <CHAT> <TOPIC> [OPTIONS] ``` -**mutating · idempotent (reports `already`) · returns `ChatSwitches`** +**mutating · returns `TopicResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat. | - -| Flag | Type | Default | Meaning | -|---|---|---|---| -| `--request-msg` | msg-id | | The message that asked. | -| `--send-as` | chat | | Default identity to post as. | -| `--send-as-list` | flag | | List the identities you may post as and stop. | -| `--sharing` | on|off | | Allow forwarding and saving from this chat. | -| `--view-as` | topics|messages | | Show a forum as topics or one list. | +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | yes | Topic id. | ```console -$ tlgr chat set @alice --sharing off --json +$ tlgr chat topic unmute @myforum 314 --json ``` -<details><summary>Catalog coverage (3 full, 0 partial)</summary> +<details><summary>Catalog coverage (1 full, 0 partial)</summary> -Full: `dialogs.no-forwards-private`, `dialogs.send-as-default`, `dialogs.view-as-topics` +Full: `groups-channels-admin.topic-notify-settings` </details> -### `chat theme list` +### `chat topic unpin` -List the chat themes available. +Unpin a topic. ``` -tlgr chat theme list [OPTIONS] +tlgr chat topic unpin <CHAT> [TOPIC]... [OPTIONS] ``` -**paginated (`RATE` cursor) · returns `Page[ChatTheme]`** +**mutating · returns `TopicPinResult`** -| Flag | Type | Default | Meaning | +| Argument | Type | Required | Meaning | |---|---|---|---| -| `--gifts` | flag | | Collectible gift themes instead of emoji ones. | +| `CHAT` | chat | yes | Forum supergroup. | +| `TOPIC` | msg-id | any number | Topic ids. | -Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | flag | | Unpin every pinned topic. | ```console -$ tlgr chat theme list --json +$ tlgr chat topic unpin @myforum 314 --json ``` <details><summary>Catalog coverage (1 full, 0 partial)</summary> -Full: `dialogs.chat-theme-list` +Full: `groups-channels-admin.topic-pin` </details> -### `chat theme set` +### `chat transfer` -Set or remove the theme of one chat. +Transfer ownership of a group or channel (2FA). + +The target must already be an admin. PASSWORD_HASH_INVALID exits 4; PASSWORD_TOO_FRESH / SESSION_TOO_FRESH and CHANNELS_TOO_MUCH exit 6 with the wait reported. ``` -tlgr chat theme set <CHAT> [OPTIONS] +tlgr chat transfer <CHAT> <USER> [OPTIONS] ``` -**mutating · idempotent (reports `already`) · returns `ThemeResult`** +**mutating · destructive (needs `--yes` off a TTY) · returns `TransferResult`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | yes | Chat. | +| `CHAT` | chat | yes | Group or channel. | +| `USER` | user | yes | The new owner. | | Flag | Type | Default | Meaning | |---|---|---|---| -| `--emoji` | text | | Emoji theme from `chat theme list`. | -| `--gift` | text | | Collectible gift theme slug. | -| `--unset` | flag | | Remove the per-chat theme. | +| `--password` | text | | Two-factor password. Never taken on the command line. | -Also invocable as: `tlgr chat theme unset` +Also invocable as: `tlgr chat admin transfer` ```console -$ tlgr chat theme set @alice --emoji 🌷 --json +$ tlgr chat transfer @mygroup @alice --password-stdin --yes --json ``` -<details><summary>Catalog coverage (4 full, 0 partial)</summary> +<details><summary>Catalog coverage (1 full, 0 partial)</summary> -Full: `dialogs.chat-theme-reset`, `gift.as-chat-theme`, `gifts.set-as-chat-theme`, `theme.set-chat-theme` +Full: `groups-channels-admin.transfer-ownership` </details> @@ -1127,6 +3646,160 @@ Full: `dialogs.mark-unread`, `dialogs.mark-unread-clear`, `messages-core.chat-ma </details> +### `chat username get` + +Check a username: availability, owner, and Fragment collectible info. + +`status` is available, occupied, invalid or purchasable. On USERNAME_PURCHASE_AVAILABLE the Fragment purchase date, currency, amount and URL are attached — buying happens on fragment.com, not through the API. + +``` +tlgr chat username get <USERNAME> [OPTIONS] +``` + +**returns `UsernameCheck`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USERNAME` | text | yes | The name to check. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--chat` | chat | | Check it for this existing peer. | + +```console +$ tlgr chat username get mynews --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `groups-channels-admin.check-username`, `groups-channels-admin.collectible-username-info` + +</details> + +### `chat username set` + +Set the public link, or the display order of the additional usernames. + +Exactly one of `<username>` and `--order` is required. A basic group has no username at all: `--upgrade` migrates it first, and never silently. Public groups are forced to a visible prehistory by the server, and the public-peer count is capped per account. + +``` +tlgr chat username set <CHAT> [USERNAME] [OPTIONS] +``` + +**mutating · returns `UsernameResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USERNAME` | text | no | The public link to claim. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--order` | text | | Set the display order of the usernames. | +| `--upgrade` | flag | | Migrate a basic group to a supergroup first, if needed. | + +```console +$ tlgr chat username set @mygroup mynews --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `groups-channels-admin.public-private-toggle`, `groups-channels-admin.set-username`, `groups-channels-admin.username-reorder` + +</details> + +### `chat username toggle` + +Activate or deactivate one additional username. + +``` +tlgr chat username toggle <CHAT> <USERNAME> <ON|OFF> [OPTIONS] +``` + +**mutating · returns `UsernameResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `USERNAME` | text | yes | The additional username. | +| `ON|OFF` | text | yes | Activate or deactivate it. | + +```console +$ tlgr chat username toggle @mygroup mynews off --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.username-toggle` + +</details> + +### `chat username unset` + +Make a group/channel private by clearing its username(s). + +Prints the private invite link afterwards, so the chat stays reachable rather than becoming unfindable in one command. + +``` +tlgr chat username unset <CHAT> [OPTIONS] +``` + +**mutating · returns `UsernameResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | flag | | Deactivate every username; collectibles stay reserved. | + +```console +$ tlgr chat username unset @mygroup --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `groups-channels-admin.username-deactivate-all` + +Partial: `groups-channels-admin.public-private-toggle` + +Going private is here; `chat username set` goes public and owns the id. + +</details> + +### `chat verification set` + +Attach or remove a third-party (bot) verification badge. + +This is a bot's badge, not Telegram's blue check — `channel.verified` is server-assigned and has no API to set. + +``` +tlgr chat verification set <CHAT> [OPTIONS] +``` + +**mutating · returns `VerificationResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | The peer to badge. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--bot` | user | | Your bot with verifier_settings. | +| `--description` | text | | Custom description for the badge. | +| `--off` | flag | | Remove the badge instead. | + +```console +$ tlgr chat verification set @mygroup --bot @myverifierbot --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.verify-peer` + +</details> + ### `chat wallpaper set` Set, apply, revert or remove the wallpaper of one chat. @@ -1164,3 +3837,99 @@ $ tlgr chat wallpaper set @alice --slug pattern --json Full: `contacts-users.user-wallpaper`, `dialogs.chat-wallpaper-apply-suggested`, `dialogs.chat-wallpaper-revert`, `stories.story-set-wallpaper`, `wallpaper.set-for-channel-group`, `wallpaper.set-for-chat` </details> + +### `chat welcome delete` + +Delete one or all welcome messages. + +Registered and refusing with NOT_SUPPORTED (exit 13): layer 229. + +``` +tlgr chat welcome delete <CHAT> [ID] [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `WelcomeResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `ID` | int | no | Which welcome message. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | flag | | Delete every welcome message. | + +```console +$ tlgr chat welcome delete @mygroup --all --yes --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `groups-channels-admin.welcome-messages` + +Layer-229 surface; `chat welcome set` owns the id. + +</details> + +### `chat welcome list` + +List a group/channel's welcome messages. + +Registered and refusing with NOT_SUPPORTED (exit 13). `chatFull.has_welcome_messages` advertises them, but the `ephemeral.*` methods that read them are layer 229 and Telethon 1.44 speaks 227. + +``` +tlgr chat welcome list <CHAT> [OPTIONS] +``` + +**returns `list[WelcomeMessage]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | + +```console +$ tlgr chat welcome list @mygroup --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `messages-core.chat-welcome-messages` + +Partial: `groups-channels-admin.welcome-messages` + +Layer-229 surface; `chat welcome set` owns the writing id. + +</details> + +### `chat welcome set` + +Add or edit a welcome message. + +Registered and refusing with NOT_SUPPORTED (exit 13): layer 229. + +``` +tlgr chat welcome set <CHAT> [TEXT] [OPTIONS] +``` + +**mutating · returns `WelcomeResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Group or channel. | +| `TEXT` | text | no | The message. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--file` | path | | Attach media. | +| `--id` | int | | Edit this welcome message instead of adding. | +| `--parse` | text | | Markup of the text: md, html or none. | + +```console +$ tlgr chat welcome set @mygroup 'Welcome aboard' --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `groups-channels-admin.welcome-messages` + +</details> diff --git a/pyproject.toml b/pyproject.toml index b32a140..1bc7d16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ dev = [ fast = ["cryptg>=0.4"] proxy = ["python-socks[asyncio]>=2.4"] media = ["pillow>=10.0", "hachoir>=3.2"] +# `chat invite get --qr` renders locally; the API contributes only the link. +qr = ["segno>=1.6"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 8a5f87f..007c778 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -650,6 +650,39 @@ class World: #: Set when the peer should look uncallable to `call start --check`. calls_available: bool = True + # -- the administration world (Stage E) -------------------------------- + # + # Everything the `chat member/admin/invite/topic/setting/stats` group + # touches, kept as state rather than as canned replies — so a test can + # ban somebody and then *list* them among the banned, which is the only + # way to catch a mask that was written back incomplete. + + #: marked chat id → {user id: raw ChannelParticipant*}. Named `members` + #: because `participants` is the call world's, and one World holds both. + members: dict[int, dict[int, Any]] = field(default_factory=dict) + #: marked chat id → {user id: ChatBannedRights}, the restricted/removed set + banned: dict[int, dict[int, Any]] = field(default_factory=dict) + #: marked chat id → the settings `chat setting get` reads out of chatFull + settings: dict[int, dict[str, Any]] = field(default_factory=dict) + #: marked chat id → [chatInviteExported] + invites: dict[int, list[Any]] = field(default_factory=dict) + #: marked chat id → [chatInviteImporter] (join requests carry requested=True) + importers: dict[int, list[Any]] = field(default_factory=dict) + #: invite hash → what `messages.checkChatInvite` should answer + invite_previews: dict[str, Any] = field(default_factory=dict) + #: marked chat id → {topic id: forumTopic} + topics: dict[int, dict[int, Any]] = field(default_factory=dict) + #: marked chat id → [channelAdminLogEvent] + admin_log: dict[int, list[Any]] = field(default_factory=dict) + #: marked chat id → [boost] + boosts: dict[int, list[Any]] = field(default_factory=dict) + #: my premium boost slots + my_boosts: list[Any] = field(default_factory=list) + #: marked chat id → the default banned mask every member inherits + default_banned: dict[int, Any] = field(default_factory=dict) + #: usernames the server reports as already taken + taken_usernames: set[str] = field(default_factory=set) + # -- behaviour knobs --------------------------------------------------- _fail_next: dict[str, BaseException] = field(default_factory=dict) @@ -986,6 +1019,108 @@ def add_call_log_entry( self.call_log.append(message) return message + # -- the administration world ------------------------------------------ + + def add_member( + self, + chat_id: int, + user_id: int, + *, + status: str = "member", + rank: str | None = None, + admin_rights: Any = None, + inviter_id: int | None = None, + promoted_by: int | None = None, + can_edit: bool | None = None, + ) -> Any: + """Put a real `ChannelParticipant*` in a chat. + + The wrapper, not a bare user: "is this person an admin, and who + promoted them" is exactly what v1's member list threw away, so the + fake has to be able to answer it. + """ + now = datetime.now(timezone.utc) + if status == "creator": + row: Any = types.ChannelParticipantCreator( + user_id=user_id, + admin_rights=admin_rights or types.ChatAdminRights(change_info=True), + rank=rank, + ) + elif status == "admin": + row = types.ChannelParticipantAdmin( + user_id=user_id, + promoted_by=promoted_by or self.me.id, + date=now, + admin_rights=admin_rights or types.ChatAdminRights(ban_users=True), + can_edit=True if can_edit is None else can_edit, + inviter_id=inviter_id, + rank=rank, + ) + elif status == "self": + row = types.ChannelParticipantSelf( + user_id=user_id, inviter_id=inviter_id or self.me.id, date=now + ) + else: + row = types.ChannelParticipant(user_id=user_id, date=now, rank=rank) + self.members.setdefault(chat_id, {})[user_id] = row + return row + + def add_invite(self, chat_id: int, link: str, **kwargs: Any) -> Any: + invite = types.ChatInviteExported( + link=link, + admin_id=kwargs.pop("admin_id", self.me.id), + date=kwargs.pop("date", datetime.now(timezone.utc)), + **kwargs, + ) + self.invites.setdefault(chat_id, []).append(invite) + return invite + + def add_importer( + self, chat_id: int, user_id: int, *, requested: bool = False, **kw: Any + ) -> Any: + row = types.ChatInviteImporter( + user_id=user_id, + date=kw.pop("date", datetime.now(timezone.utc)), + requested=requested or None, + **kw, + ) + self.importers.setdefault(chat_id, []).append(row) + return row + + def add_topic(self, chat_id: int, topic_id: int, title: str, **kwargs: Any) -> Any: + topic = types.ForumTopic( + id=topic_id, + date=kwargs.pop("date", datetime.now(timezone.utc)), + peer=types.PeerChannel(channel_id=-1000000000000 - chat_id), + title=title, + icon_color=kwargs.pop("icon_color", 0x6FB9F0), + top_message=kwargs.pop("top_message", 0), + read_inbox_max_id=0, + read_outbox_max_id=0, + unread_count=kwargs.pop("unread_count", 0), + unread_mentions_count=0, + unread_reactions_count=0, + unread_poll_votes_count=0, + from_id=types.PeerUser(user_id=self.me.id), + notify_settings=types.PeerNotifySettings(), + **kwargs, + ) + self.topics.setdefault(chat_id, {})[topic_id] = topic + return topic + + def add_admin_log(self, chat_id: int, event_id: int, action: Any, *, user_id: int = 0) -> Any: + event = types.ChannelAdminLogEvent( + id=event_id, + date=datetime.now(timezone.utc), + user_id=user_id or self.me.id, + action=action, + ) + self.admin_log.setdefault(chat_id, []).append(event) + return event + + def settings_of(self, chat_id: int) -> dict[str, Any]: + return self.settings.setdefault(chat_id, {}) + class _Dialog: """The shape `iter_dialogs` yields, reduced to what tlgr reads.""" @@ -1628,11 +1763,6 @@ def _raw_GetFileRequest(self, request: Any) -> Any: type=types.storage.FileUnknown(), mtime=0, bytes=self.world.stream_chunk ) - def _raw_ExportChatInviteRequest(self, request: Any) -> Any: - return types.ChatInviteExported( - link="https://t.me/+fallback", admin_id=self.world.me.id, date=None - ) - def _raw_SendReactionRequest(self, request: Any) -> types.Updates: chat_id = self._chat_id(request.peer) message = self.world.find(chat_id, int(request.msg_id)) @@ -2243,44 +2373,6 @@ def _running_call(self, chat_id: int) -> Any: return None return types.InputGroupCall(id=call.id, access_hash=call.access_hash) - def _full_channel(self, chat_id: int) -> Any: - return types.ChannelFull( - id=abs(chat_id) - 1000000000000 if chat_id < -1000000000000 else abs(chat_id), - about="", - read_inbox_max_id=0, - read_outbox_max_id=0, - unread_count=0, - chat_photo=types.PhotoEmpty(id=0), - notify_settings=types.PeerNotifySettings(), - bot_info=[], - pts=1, - available_reactions=self.world.chat_reactions.get(chat_id) or types.ChatReactionsNone(), - reactions_limit=self.world.reactions_limit.get(chat_id), - paid_reactions_available=self.world.paid_enabled.get(chat_id), - call=self._running_call(chat_id), - ) - - def _raw_GetFullChannelRequest(self, request: Any) -> Any: - chat_id = self._chat_id(request.channel) - return types.messages.ChatFull(full_chat=self._full_channel(chat_id), chats=[], users=[]) - - def _raw_GetFullChatRequest(self, request: Any) -> Any: - chat_id = int(request.chat_id) - return types.messages.ChatFull( - full_chat=types.ChatFull( - id=chat_id, - about="", - participants=types.ChatParticipantsForbidden(chat_id=chat_id), - notify_settings=types.PeerNotifySettings(), - available_reactions=self.world.chat_reactions.get(-chat_id) - or types.ChatReactionsNone(), - reactions_limit=self.world.reactions_limit.get(-chat_id), - call=self._running_call(-chat_id), - ), - chats=[], - users=[], - ) - def _raw_SetChatAvailableReactionsRequest(self, request: Any) -> types.Updates: chat_id = self._chat_id(request.peer) self.world.chat_reactions[chat_id] = request.available_reactions @@ -2288,6 +2380,12 @@ def _raw_SetChatAvailableReactionsRequest(self, request: Any) -> types.Updates: self.world.reactions_limit[chat_id] = int(request.reactions_limit) if request.paid_enabled is not None: self.world.paid_enabled[chat_id] = bool(request.paid_enabled) + # The admin world reads the policy back out of `chatFull`, so it is + # written to both places: `chat reaction get` reads the first, `chat + # setting get` the second, and they must not be able to disagree. + settings = self.world.settings_of(chat_id) + settings["available_reactions"] = request.available_reactions + settings["reactions_limit"] = request.reactions_limit return self._updates() def _raw_SetDefaultReactionRequest(self, request: Any) -> bool: @@ -2864,7 +2962,17 @@ def _raw_HidePromoDataRequest(self, request: Any) -> bool: return True def _raw_DismissSuggestionRequest(self, request: Any) -> bool: - self.auth.dismissed_suggestions.append(request.suggestion) + # An account-level suggestion carries `inputPeerEmpty`; a chat's own + # pending suggestion carries the chat. Two different lists. + peer = getattr(request, "peer", None) + if peer is None or type(peer).__name__ == "InputPeerEmpty": + self.auth.dismissed_suggestions.append(request.suggestion) + return True + chat_id = self._chat_id(peer) + settings = self.world.settings_of(chat_id) + settings["pending_suggestions"] = [ + key for key in (settings.get("pending_suggestions") or []) if key != request.suggestion + ] return True def _raw_GetTermsOfServiceUpdateRequest(self, request: Any) -> Any: @@ -3423,19 +3531,34 @@ def _raw_GetDeepLinkInfoRequest(self, request: Any) -> Any: # dispatcher's per-kind branch is exercised rather than assumed. def _raw_CheckChatInviteRequest(self, request: Any) -> Any: + answer = self.world.invite_previews.get(request.hash) + if answer is not None: + return answer return types.ChatInvite( title="Shared group", photo=types.PhotoEmpty(id=0), participants_count=12, color=0, + about="A private group", ) def _raw_GetBoostsStatusRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + rows = self.world.boosts.get(chat_id) + if not rows: + return types.premium.BoostsStatus( + level=3, + current_level_boosts=10, + boosts=12, + boost_url="https://t.me/boost/news", + ) return types.premium.BoostsStatus( - level=3, - current_level_boosts=10, - boosts=12, - boost_url="https://t.me/boost/news", + level=len(rows) // 2, + current_level_boosts=len(rows), + boosts=len(rows), + boost_url=f"https://t.me/boost?c={abs(chat_id)}", + my_boost=any(getattr(b, "user_id", 0) == self.world.me.id for b in rows) or None, + next_level_boosts=len(rows) + 3, ) def _raw_CheckGiftCodeRequest(self, request: Any) -> Any: @@ -3475,6 +3598,753 @@ def _raw_GetAllReadPeerStoriesRequest(self, request: Any) -> types.Updates: seq=0, ) + # -- the administration world ------------------------------------------ + # + # Written as state, not as canned replies: `chat member ban` really moves + # the person into `world.banned`, so `chat member list --filter banned` + # finds them there afterwards and a mask that was written back incomplete + # shows up as a wrong answer rather than as a plausible request object. + + def _participants(self, chat_id: int) -> dict[int, Any]: + return self.world.members.setdefault(chat_id, {}) + + def _banned_row(self, chat_id: int, user_id: int) -> Any: + rights = self.world.banned[chat_id][user_id] + return types.ChannelParticipantBanned( + peer=types.PeerUser(user_id=user_id), + kicked_by=self.world.me.id, + date=datetime.now(timezone.utc), + banned_rights=rights, + left=bool(getattr(rights, "view_messages", False)), + ) + + def _people(self, ids: list[int]) -> list[Any]: + return [self.world.users[i] for i in ids if i in self.world.users] + + def _raw_GetParticipantsRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.channel) + name = type(request.filter).__name__ + rows: list[Any] = [] + if name in ("ChannelParticipantsKicked", "ChannelParticipantsBanned"): + rows = [ + self._banned_row(chat_id, user_id) for user_id in self.world.banned.get(chat_id, {}) + ] + if name == "ChannelParticipantsKicked": + rows = [row for row in rows if row.left] + else: + rows = [row for row in rows if not row.left] + else: + rows = list(self._participants(chat_id).values()) + if name == "ChannelParticipantsAdmins": + rows = [ + row + for row in rows + if type(row).__name__ + in ("ChannelParticipantAdmin", "ChannelParticipantCreator") + ] + elif name == "ChannelParticipantsBots": + rows = [ + row + for row in rows + if getattr(self.world.users.get(getattr(row, "user_id", 0)), "bot", False) + ] + query = (getattr(request.filter, "q", "") or "").lower() + if query: + rows = [ + row + for row in rows + if query + in ( + getattr(self.world.users.get(getattr(row, "user_id", 0)), "first_name", "") + or "" + ).lower() + ] + total = len(rows) + window = rows[int(request.offset) : int(request.offset) + int(request.limit)] + ids = [ + getattr(row, "user_id", None) or abs(self._chat_id(getattr(row, "peer", None))) + for row in window + ] + return types.channels.ChannelParticipants( + count=total, participants=window, chats=[], users=self._people(ids) + ) + + def _raw_GetParticipantRequest(self, request: Any) -> Any: + from telethon.errors import UserNotParticipantError + + chat_id = self._chat_id(request.channel) + user_id = abs(self._chat_id(request.participant)) + # A restricted member is still a member; the banned mask is the more + # specific answer, so it wins — which is what makes a second + # `chat member restrict` patch the mask instead of resetting it. + row = None + if user_id in self.world.banned.get(chat_id, {}): + row = self._banned_row(chat_id, user_id) + if row is None: + row = self._participants(chat_id).get(user_id) + if row is None: + raise UserNotParticipantError(request) + return types.channels.ChannelParticipant( + participant=row, chats=[], users=self._people([user_id]) + ) + + def _raw_EditBannedRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + user_id = abs(self._chat_id(request.participant)) + rights = request.banned_rights + flags = [ + name + for name in dir(rights) + if not name.startswith("_") and isinstance(getattr(rights, name, None), bool) + ] + store = self.world.banned.setdefault(chat_id, {}) + if any(getattr(rights, name, False) for name in flags): + store[user_id] = rights + if getattr(rights, "view_messages", False): + self._participants(chat_id).pop(user_id, None) + else: + store.pop(user_id, None) + return self._updates() + + def _raw_EditAdminRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + user_id = abs(self._chat_id(request.user_id)) + rights = request.admin_rights + granted = any( + getattr(rights, name, False) + for name in dir(rights) + if not name.startswith("_") and isinstance(getattr(rights, name, None), bool) + ) + if granted: + self.world.add_member( + chat_id, + user_id, + status="admin", + admin_rights=rights, + rank=request.rank or None, + ) + else: + self.world.add_member(chat_id, user_id, status="member") + return self._updates() + + def _raw_InviteToChannelRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.channel) + missing = [] + for user in request.users: + user_id = abs(self._chat_id(user)) + if user_id in getattr(self.world, "_privacy_blocked", ()): # pragma: no cover + missing.append(types.MissingInvitee(user_id=user_id)) + continue + self.world.add_member(chat_id, user_id) + return types.messages.InvitedUsers(updates=self._updates(), missing_invitees=missing) + + def _raw_CreateChatRequest(self, request: Any) -> Any: + return types.messages.InvitedUsers(updates=self._updates(), missing_invitees=[]) + + def _raw_EditChatAdminRequest(self, request: Any) -> bool: + return True + + def _raw_ReportSpamRequest(self, request: Any) -> bool: + return True + + def _raw_ReportAntiSpamFalsePositiveRequest(self, request: Any) -> bool: + return True + + def _raw_EditChatParticipantRankRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + user_id = abs(self._chat_id(request.participant)) + row = self._participants(chat_id).get(user_id) + if row is not None: + row.rank = request.rank + return self._updates() + + def _raw_ToggleNoPaidMessagesExceptionRequest(self, request: Any) -> bool: + return True + + def _raw_EditChatDefaultBannedRightsRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + self.world.default_banned[chat_id] = request.banned_rights + entity = self.world.entity_for(chat_id) + if entity is not None: + entity.default_banned_rights = request.banned_rights + return self._updates() + + # -- full chat --------------------------------------------------------- + + def _channel_full(self, chat_id: int) -> Any: + settings = self.world.settings_of(chat_id) + return types.ChannelFull( + id=abs(chat_id) - 1000000000000 if chat_id < -1000000000000 else abs(chat_id), + about=str(settings.get("about", "")), + read_inbox_max_id=0, + read_outbox_max_id=0, + unread_count=0, + chat_photo=types.PhotoEmpty(id=0), + notify_settings=types.PeerNotifySettings(), + bot_info=[], + pts=1, + participants_count=len(self._participants(chat_id)) or None, + exported_invite=(self.world.invites.get(chat_id) or [None])[0], + slowmode_seconds=settings.get("slowmode_seconds"), + hidden_prehistory=settings.get("hidden_prehistory"), + antispam=settings.get("antispam"), + participants_hidden=settings.get("participants_hidden"), + can_view_stats=settings.get("can_view_stats"), + can_set_stickers=settings.get("can_set_stickers"), + linked_chat_id=settings.get("linked_chat_id"), + pending_suggestions=settings.get("pending_suggestions"), + available_reactions=( + self.world.chat_reactions.get(chat_id) + or settings.get("available_reactions") + or types.ChatReactionsNone() + ), + reactions_limit=self.world.reactions_limit.get( + chat_id, settings.get("reactions_limit") + ), + paid_reactions_available=self.world.paid_enabled.get(chat_id), + call=self._running_call(chat_id), + send_paid_messages_stars=settings.get("send_paid_messages_stars"), + view_forum_as_messages=settings.get("view_forum_as_messages"), + restricted_sponsored=settings.get("restricted_sponsored"), + stats_dc=settings.get("stats_dc"), + default_send_as=settings.get("default_send_as"), + ) + + def _raw_GetFullChannelRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.channel) + entity = self.world.entity_for(chat_id) + return types.messages.ChatFull( + full_chat=self._channel_full(chat_id), + chats=[entity] if entity is not None else [], + users=list(self.world.users.values()), + ) + + def _raw_GetFullChatRequest(self, request: Any) -> Any: + chat_id = -int(request.chat_id) + entity = self.world.entity_for(chat_id) + rows = [ + types.ChatParticipant( + user_id=user_id, inviter_id=self.world.me.id, date=datetime.now(timezone.utc) + ) + if type(row).__name__ != "ChannelParticipantAdmin" + else types.ChatParticipantAdmin( + user_id=user_id, inviter_id=self.world.me.id, date=datetime.now(timezone.utc) + ) + for user_id, row in self._participants(chat_id).items() + ] + full = types.ChatFull( + id=abs(chat_id), + about=str(self.world.settings_of(chat_id).get("about", "")), + participants=types.ChatParticipants(chat_id=abs(chat_id), participants=rows, version=1), + notify_settings=types.PeerNotifySettings(), + available_reactions=self.world.chat_reactions.get(chat_id) or types.ChatReactionsNone(), + reactions_limit=self.world.reactions_limit.get(chat_id), + call=self._running_call(chat_id), + ) + return types.messages.ChatFull( + full_chat=full, + chats=[entity] if entity is not None else [], + users=list(self.world.users.values()), + ) + + # -- invites ----------------------------------------------------------- + + def _raw_ExportChatInviteRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + existing = self.world.invites.setdefault(chat_id, []) + link = f"https://t.me/+fake{len(existing) + 1}" + return self.world.add_invite( + chat_id, + link, + title=getattr(request, "title", None), + expire_date=getattr(request, "expire_date", None), + usage_limit=getattr(request, "usage_limit", None), + request_needed=getattr(request, "request_needed", None), + subscription_pricing=getattr(request, "subscription_pricing", None), + permanent=not existing or None, + ) + + def _find_invite(self, chat_id: int, link: str) -> Any: + for invite in self.world.invites.get(chat_id, []): + if invite.link == link: + return invite + return None + + def _raw_EditExportedChatInviteRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + invite = self._find_invite(chat_id, request.link) + if invite is None: + invite = self.world.add_invite(chat_id, request.link) + for name in ("title", "expire_date", "usage_limit", "request_needed", "revoked"): + value = getattr(request, name, None) + if value is not None: + setattr(invite, name, value) + return types.messages.ExportedChatInvite( + invite=invite, users=list(self.world.users.values()) + ) + + def _raw_GetExportedChatInviteRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + invite = self._find_invite(chat_id, request.link) + if invite is None: + from telethon.errors import RPCError + + raise RPCError(request, "INVITE_HASH_EXPIRED", 400) + return types.messages.ExportedChatInvite( + invite=invite, users=list(self.world.users.values()) + ) + + def _raw_GetExportedChatInvitesRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + wanted = bool(getattr(request, "revoked", False)) + rows = [i for i in self.world.invites.get(chat_id, []) if bool(i.revoked) == wanted] + return types.messages.ExportedChatInvites( + count=len(rows), + invites=rows[: int(request.limit)], + users=list(self.world.users.values()), + ) + + def _raw_GetAdminsWithInvitesRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + rows = self.world.invites.get(chat_id, []) + by_admin: dict[int, list[Any]] = {} + for invite in rows: + by_admin.setdefault(int(invite.admin_id), []).append(invite) + return types.messages.ChatAdminsWithInvites( + admins=[ + types.ChatAdminWithInvites( + admin_id=admin_id, + invites_count=len([i for i in items if not i.revoked]), + revoked_invites_count=len([i for i in items if i.revoked]), + ) + for admin_id, items in by_admin.items() + ], + users=list(self.world.users.values()), + ) + + def _raw_DeleteExportedChatInviteRequest(self, request: Any) -> bool: + chat_id = self._chat_id(request.peer) + self.world.invites[chat_id] = [ + i for i in self.world.invites.get(chat_id, []) if i.link != request.link + ] + return True + + def _raw_DeleteRevokedExportedChatInvitesRequest(self, request: Any) -> bool: + chat_id = self._chat_id(request.peer) + self.world.invites[chat_id] = [ + i for i in self.world.invites.get(chat_id, []) if not i.revoked + ] + return True + + def _raw_GetChatInviteImportersRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + want_requests = bool(getattr(request, "requested", False)) + rows = [ + row + for row in self.world.importers.get(chat_id, []) + if bool(getattr(row, "requested", False)) == want_requests + ] + window = rows[: int(request.limit)] + return types.messages.ChatInviteImporters( + count=len(rows), + importers=window, + users=self._people([int(row.user_id) for row in window]), + ) + + def _raw_HideChatJoinRequestRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + user_id = abs(self._chat_id(request.user_id)) + self.world.importers[chat_id] = [ + row + for row in self.world.importers.get(chat_id, []) + if int(row.user_id) != user_id or not getattr(row, "requested", False) + ] + if getattr(request, "approved", False): + self.world.add_member(chat_id, user_id) + return self._updates() + + def _raw_HideAllChatJoinRequestsRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + pending = [ + row for row in self.world.importers.get(chat_id, []) if getattr(row, "requested", False) + ] + self.world.importers[chat_id] = [ + row + for row in self.world.importers.get(chat_id, []) + if not getattr(row, "requested", False) + ] + if getattr(request, "approved", False): + for row in pending: + self.world.add_member(chat_id, int(row.user_id)) + return self._updates() + + def _raw_ImportChatInviteRequest(self, request: Any) -> types.Updates: + chats = list(self.world.chats.values()) + return types.Updates( + updates=[], users=[], chats=chats[:1], date=datetime.now(timezone.utc), seq=0 + ) + + def _raw_JoinChannelRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + entity = self.world.entity_for(chat_id) + return types.Updates( + updates=[], + users=[], + chats=[entity] if entity is not None else [], + date=datetime.now(timezone.utc), + seq=0, + ) + + # -- topics ------------------------------------------------------------ + + def _raw_CreateForumTopicRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + self.world.next_message_id += 1 + topic_id = self.world.next_message_id + self.world.add_topic( + chat_id, topic_id, request.title, icon_emoji_id=getattr(request, "icon_emoji_id", None) + ) + message = make_message(topic_id, chat_id=chat_id) + return types.Updates( + updates=[types.UpdateNewChannelMessage(message=message, pts=1, pts_count=1)], + users=[], + chats=[], + date=datetime.now(timezone.utc), + seq=0, + ) + + def _raw_EditForumTopicRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + topic = self.world.topics.setdefault(chat_id, {}).get(int(request.topic_id)) + if topic is None: + topic = self.world.add_topic(chat_id, int(request.topic_id), "General") + for name in ("title", "icon_emoji_id", "closed", "hidden"): + value = getattr(request, name, None) + if value is not None: + setattr(topic, name, value) + return self._updates() + + def _forum_topics(self, chat_id: int, rows: list[Any]) -> Any: + return types.messages.ForumTopics( + count=len(rows), + topics=rows, + messages=[], + chats=[], + users=[], + pts=1, + ) + + def _raw_GetForumTopicsRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + rows = list(self.world.topics.get(chat_id, {}).values()) + query = (getattr(request, "q", "") or "").lower() + if query: + rows = [row for row in rows if query in (row.title or "").lower()] + offset_topic = int(getattr(request, "offset_topic", 0) or 0) + if offset_topic: + rows = [row for row in rows if row.id > offset_topic] + return self._forum_topics(chat_id, rows[: int(request.limit)]) + + def _raw_GetForumTopicsByIDRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + known = self.world.topics.get(chat_id, {}) + rows = [ + known[int(topic_id)] + if int(topic_id) in known + else types.ForumTopicDeleted(id=int(topic_id)) + for topic_id in request.topics + ] + return self._forum_topics(chat_id, rows) + + def _raw_UpdatePinnedForumTopicRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + topic = self.world.topics.get(chat_id, {}).get(int(request.topic_id)) + if topic is not None: + topic.pinned = bool(request.pinned) or None + return self._updates() + + def _raw_ReorderPinnedForumTopicsRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + wanted = {int(t) for t in request.order} + for topic_id, topic in self.world.topics.get(chat_id, {}).items(): + if topic_id in wanted: + topic.pinned = True + elif getattr(request, "force", False): + topic.pinned = None + return self._updates() + + def _raw_DeleteTopicHistoryRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + self.world.topics.get(chat_id, {}).pop(int(request.top_msg_id), None) + return types.messages.AffectedHistory(pts=1, pts_count=1, offset=0) + + def _raw_ReadDiscussionRequest(self, request: Any) -> bool: + return True + + def _raw_ExportMessageLinkRequest(self, request: Any) -> Any: + """Only a public chat gets a server-minted link. + + A private one answers with an empty link, which is what makes the + caller fall back to the `t.me/c/<raw id>/<msg>` form — the shape the + message group already relies on. + """ + chat_id = self._chat_id(request.channel) + name = getattr(self.world.entity_for(chat_id), "username", None) + if not name: + return types.ExportedMessageLink(link="", html="") + return types.ExportedMessageLink(link=f"https://t.me/{name}/{request.id}", html="") + + # -- the admin log ----------------------------------------------------- + + def _raw_GetAdminLogRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.channel) + rows = sorted(self.world.admin_log.get(chat_id, []), key=lambda e: -e.id) + max_id = int(getattr(request, "max_id", 0) or 0) + if max_id: + rows = [row for row in rows if row.id < max_id] + window = rows[: int(request.limit)] + return types.channels.AdminLogResults( + events=window, chats=[], users=list(self.world.users.values()) + ) + + # -- settings ---------------------------------------------------------- + + def _setting_toggle(self, request: Any, key: str, attribute: str = "enabled") -> types.Updates: + chat_id = self._chat_id(getattr(request, "channel", None) or getattr(request, "peer", None)) + self.world.settings_of(chat_id)[key] = getattr(request, attribute, None) + return self._updates() + + def _raw_ToggleSlowModeRequest(self, request: Any) -> types.Updates: + return self._setting_toggle(request, "slowmode_seconds", "seconds") + + def _raw_TogglePreHistoryHiddenRequest(self, request: Any) -> types.Updates: + return self._setting_toggle(request, "hidden_prehistory") + + def _raw_ToggleAntiSpamRequest(self, request: Any) -> types.Updates: + return self._setting_toggle(request, "antispam") + + def _raw_ToggleParticipantsHiddenRequest(self, request: Any) -> types.Updates: + return self._setting_toggle(request, "participants_hidden") + + def _raw_ToggleJoinToSendRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + entity = self.world.entity_for(chat_id) + if entity is not None: + entity.join_to_send = request.enabled + return self._updates() + + def _raw_ToggleJoinRequestRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + entity = self.world.entity_for(chat_id) + if entity is not None: + entity.join_request = request.enabled + return self._updates() + + def _raw_ToggleForumRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + entity = self.world.entity_for(chat_id) + if entity is not None: + entity.forum = request.enabled + entity.forum_tabs = request.tabs + return self._updates() + + def _raw_ToggleSignaturesRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + entity = self.world.entity_for(chat_id) + if entity is not None: + entity.signatures = request.signatures_enabled + entity.signature_profiles = request.profiles_enabled + return self._updates() + + def _raw_ToggleAutotranslationRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + entity = self.world.entity_for(chat_id) + if entity is not None: + entity.autotranslation = request.enabled + return self._updates() + + def _raw_RestrictSponsoredMessagesRequest(self, request: Any) -> types.Updates: + return self._setting_toggle(request, "restricted_sponsored", "restricted") + + def _raw_ToggleNoForwardsRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + entity = self.world.entity_for(chat_id) + if entity is not None: + entity.noforwards = request.enabled + return self._updates() + + def _raw_UpdatePaidMessagesPriceRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + self.world.settings_of(chat_id)["send_paid_messages_stars"] = ( + request.send_paid_messages_stars + ) + return self._updates() + + def _raw_CheckUsernameRequest(self, request: Any) -> bool: + return request.username.lower() not in self.world.taken_usernames + + def _raw_UpdateUsernameRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.channel) + entity = self.world.entity_for(chat_id) + if entity is not None: + entity.username = request.username or None + return self._updates() + + def _raw_GetGroupsForDiscussionRequest(self, request: Any) -> Any: + return types.messages.Chats(chats=list(self.world.chats.values())) + + def _raw_SetDiscussionGroupRequest(self, request: Any) -> bool: + return True + + 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 not request.option: + return types.channels.SponsoredMessageReportResultChooseOption( + title="Why?", + options=[types.SponsoredMessageReportOption(text="Spam", option=b"\x09")], + ) + return types.channels.SponsoredMessageReportResultReported() + + def _raw_SetCustomVerificationRequest(self, request: Any) -> bool: + return True + + # -- boosts, stats and revenue ---------------------------------------- + + def _raw_GetBoostsListRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + rows = self.world.boosts.get(chat_id, []) + return types.premium.BoostsList( + count=len(rows), boosts=rows[: int(request.limit)], users=[], next_offset=None + ) + + def _raw_GetUserBoostsRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + user_id = abs(self._chat_id(request.user_id)) + rows = [ + b for b in self.world.boosts.get(chat_id, []) if getattr(b, "user_id", 0) == user_id + ] + return types.premium.BoostsList(count=len(rows), boosts=rows, users=[], next_offset=None) + + def _raw_GetMyBoostsRequest(self, request: Any) -> Any: + return types.premium.MyBoosts(my_boosts=list(self.world.my_boosts), chats=[], users=[]) + + def _raw_ApplyBoostRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + rows = self.world.boosts.setdefault(chat_id, []) + for slot in request.slots or []: + rows.append( + types.Boost( + id=f"slot{slot}", + date=datetime.now(timezone.utc), + expires=datetime.now(timezone.utc), + user_id=self.world.me.id, + ) + ) + return self._raw_GetBoostsStatusRequest(request) + + def _graph(self, token: str = "") -> Any: + if token: + return types.StatsGraphAsync(token=token) + return types.StatsGraph(json=types.DataJSON(data='{"columns": []}')) + + def _abs_prev(self, current: float, previous: float) -> Any: + return types.StatsAbsValueAndPrev(current=current, previous=previous) + + def _raw_GetBroadcastStatsRequest(self, request: Any) -> Any: + return types.stats.BroadcastStats( + period=types.StatsDateRangeDays( + min_date=datetime.now(timezone.utc), max_date=datetime.now(timezone.utc) + ), + followers=self._abs_prev(120.0, 100.0), + views_per_post=self._abs_prev(50.0, 40.0), + shares_per_post=self._abs_prev(5.0, 4.0), + reactions_per_post=self._abs_prev(9.0, 8.0), + views_per_story=self._abs_prev(0.0, 0.0), + shares_per_story=self._abs_prev(0.0, 0.0), + reactions_per_story=self._abs_prev(0.0, 0.0), + enabled_notifications=types.StatsPercentValue(part=60.0, total=120.0), + growth_graph=self._graph("growth-token"), + followers_graph=self._graph(), + mute_graph=self._graph(), + top_hours_graph=self._graph(), + interactions_graph=self._graph(), + iv_interactions_graph=self._graph(), + views_by_source_graph=self._graph(), + new_followers_by_source_graph=self._graph(), + languages_graph=self._graph(), + reactions_by_emotion_graph=self._graph(), + story_interactions_graph=self._graph(), + story_reactions_by_emotion_graph=self._graph(), + recent_posts_interactions=[ + types.PostInteractionCountersMessage(msg_id=918, views=100, forwards=3, reactions=7) + ], + ) + + def _raw_LoadAsyncGraphRequest(self, request: Any) -> Any: + return types.StatsGraph(json=types.DataJSON(data='{"columns": ["x"]}')) + + def _raw_GetMessagePublicForwardsRequest(self, request: Any) -> Any: + chats = list(self.world.chats.values()) + forwards = [ + types.PublicForwardMessage(message=make_message(12, chat_id=-1000000000000 - 5150)) + ] + return types.stats.PublicForwards( + count=1, forwards=forwards, chats=chats, users=[], next_offset=None + ) + + def _raw_GetStarsRevenueStatsRequest(self, request: Any) -> Any: + return types.payments.StarsRevenueStats( + revenue_graph=self._graph(), + status=types.StarsRevenueStatus( + current_balance=types.StarsAmount(amount=120, nanos=0), + available_balance=types.StarsAmount(amount=100, nanos=0), + overall_revenue=types.StarsAmount(amount=900, nanos=0), + withdrawal_enabled=True, + ), + usd_rate=0.013, + ) + + def _raw_GetStarsTransactionsRequest(self, request: Any) -> Any: + return types.payments.StarsStatus( + balance=types.StarsAmount(amount=120, nanos=0), + chats=[], + users=[], + history=[ + types.StarsTransaction( + id="tx1", + amount=types.StarsAmount(amount=50, nanos=0), + date=datetime.now(timezone.utc), + peer=types.StarsTransactionPeerFragment(), + title="Subscription", + ) + ], + 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: @@ -3846,7 +4716,20 @@ def _raw_ReportMusicListenRequest(self, request: Any) -> Any: return True def _raw_GetSponsoredMessagesRequest(self, request: Any) -> Any: - return types.messages.SponsoredMessagesEmpty() + return types.messages.SponsoredMessages( + messages=[ + types.SponsoredMessage( + url="https://example.invalid", + title="Sponsor", + message="An advert", + button_text="Open", + random_id=b"\x01\x02\x03", + can_report=True, + ) + ], + chats=[], + users=[], + ) def _raw_GetStoriesByIDRequest(self, request: Any) -> Any: return _FakeStories([self.world.stories[i] for i in request.id if i in self.world.stories]) diff --git a/tests/test_agentmd_compat.py b/tests/test_agentmd_compat.py index 5bf72bf..9064a48 100644 --- a/tests/test_agentmd_compat.py +++ b/tests/test_agentmd_compat.py @@ -57,6 +57,8 @@ ("chat", "leave"), ("chat", "typing"), ("chat", "posters"), + ("chat", "members"), + ("chat", "create"), ("chats",), ("inbox",), ("catchup",), @@ -75,10 +77,11 @@ ("user", "hide-stories"), ] -#: Documented v1 paths that are still hand-written commands rather than -#: registry operations: `chat create` and `chat members` migrate with the -#: groups-and-channels group (PR-7), and must keep working until they do. -V1_HAND_WRITTEN = [("chat", "members"), ("chat", "create")] +#: Nothing documented is hand-written any more inside a migrated group: PR-7 +#: moved `chat members` and `chat create` into the registry and deleted +#: `tlgr/cli/legacy/chat.py`. The list stays so a future group can name its +#: own stragglers here rather than inventing a second mechanism. +V1_HAND_WRITTEN: list[tuple[str, ...]] = [] #: The v1 paths PR-4 replaced. Every module behind them is deleted; every one #: of them still resolves, because §12.4 makes that absolute. @@ -181,6 +184,13 @@ "survive on each item, and one invocation can now produce several", "media.upload": "`{id, chat_id}` became the `Uploaded` object; `id` is " "`msg_id`, beside `msg_ids` for an album", + "chat.member.list": "`{members: [...]}` became `Page[Participant]`; each row " + "keeps its ChannelParticipant wrapper (status, rank, date, inviter_id, " + "promoted_by, kicked_by, both rights masks) and `first_name`/`last_name` " + "are joined into `name`. `id`, `username` and `is_bot` are unchanged", + "chat.create": "`{id, name, type}` became " + "`{id, type, title, username, invite_link, added, missing}`; `name` is now " + "`title`, and `missing` names every seed member the server refused", } diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index 8707c28..373366a 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -329,7 +329,7 @@ def raw(method: str, path: str, body: bytes | None = None) -> dict[str, Any]: conn.close() client._ready = True - v1 = await in_thread(raw, "GET", "/chat/members?chat=x&account=nope") + v1 = await in_thread(raw, "GET", "/profile/get?account=nope") assert {"code", "exit_code", "error"} <= set(v1) assert "ok" not in v1 diff --git a/tests/test_ops_call.py b/tests/test_ops_call.py index d8626e8..4b79262 100644 --- a/tests/test_ops_call.py +++ b/tests/test_ops_call.py @@ -873,7 +873,10 @@ def refuse(request: Any) -> Any: group_call.raw["ExportGroupCallInviteRequest"] = refuse envelope = await call(client, in_thread, "vc.link", {"chat": str(CHANNEL_ID)}) assert envelope["result"]["fallback"] is True - assert envelope["result"]["link"] == "https://t.me/+fallback" + # The chat invite the fake mints for `messages.exportChatInvite` — the + # same object `chat invite create` would return, since PR-7 taught the + # fake to hold invites as state rather than answer with a fixed link. + assert envelope["result"]["link"] == group_call.invites[CHANNEL_ID][0].link async def test_revoking_resets_the_invite_hash( self, live_daemon, client, in_thread, group_call diff --git a/tests/test_ops_chat_admin.py b/tests/test_ops_chat_admin.py new file mode 100644 index 0000000..c735cbf --- /dev/null +++ b/tests/test_ops_chat_admin.py @@ -0,0 +1,1819 @@ +"""The group- and channel-administration surface, end to end through a daemon. + +Every test goes over a real Unix socket, through the real middleware chain and +the real dispatcher, into the real implementation, against a fake Telegram +that holds *state*. The assertion is almost always that the world changed — +the member moved into the banned set, the mask that came back is complete, +the topic is really closed, the cursor really walks — because "the request +object looked plausible" is exactly the class of test that let v1 ship +`chat mute 3600` resolving to 1970 (COR-01). + +Three properties get more attention than the rest, because they are the ones +a wrong implementation gets wrong quietly: + +* **mask completeness** — `channels.editBanned` replaces the whole mask, so a + restrict that forgets to re-send an unrelated flag silently hands a right + back; +* **polarity** — `ChatBannedRights` is inverted, and a single missing `not` + turns "may not send media" into "may send media"; +* **the layer-229 refusals** — seven commands must exit 13 with an + explanation rather than 1 with a traceback. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from tlgr.core.errors import ( + EXIT_INDETERMINATE, + EXIT_NOT_FOUND, + EXIT_PERMISSION, + EXIT_USAGE, + classify, +) + +ALICE = 4242 +BOB = 4343 +CAROL = 4444 +GROUP = 5150 +GROUP_ID = -1000000000000 - GROUP +CHANNEL = 6100 +CHANNEL_ID = -1000000000000 - CHANNEL +BASIC = 320 +BASIC_ID = -BASIC + + +def make_basic_chat(chat_id: int, title: str = "Basic") -> Any: + from telethon.tl import types + + return types.Chat( + id=chat_id, + title=title, + photo=types.ChatPhotoEmpty(), + participants_count=3, + date=datetime.now(timezone.utc), + version=1, + ) + + +@pytest.fixture +def admin_world(world): + """A supergroup, a broadcast channel, a basic group, and three people.""" + from fake_telethon import make_channel, make_user + + world.add_user(make_user(ALICE, username="alice", first="Alice")) + world.add_user(make_user(BOB, username="bobby", first="Bob")) + world.add_user(make_user(CAROL, username="carol", first="Carol")) + world.add_channel(make_channel(GROUP, title="News chat", megagroup=True)) + world.add_channel(make_channel(CHANNEL, title="News", megagroup=False)) + world.chats[BASIC] = make_basic_chat(BASIC, "Old crew") + + world.add_member(GROUP_ID, world.me.id, status="creator") + world.add_member(GROUP_ID, ALICE, status="admin", rank="moderator") + world.add_member(GROUP_ID, BOB) + world.add_member(BASIC_ID, ALICE, status="admin") + world.add_member(BASIC_ID, BOB) + return world + + +async def call(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> dict[str, Any]: + kwargs.setdefault("account", "work") + return await in_thread(client.op, op, request, **kwargs) + + +async def result(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> Any: + envelope = await call(client, in_thread, op, request, **kwargs) + return envelope["result"] + + +async def fails(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> int: + with pytest.raises(Exception) as caught: + await call(client, in_thread, op, request, **kwargs) + return classify(caught.value).exit_code + + +# --------------------------------------------------------------------------- +# chat member list / get +# --------------------------------------------------------------------------- + + +class TestMemberList: + async def test_a_member_keeps_its_participant_wrapper( + self, live_daemon, client, in_thread, admin_world + ): + """v1 returned a bare user; status, rank and promoter went in the bin.""" + rows = await result(client, in_thread, "chat.member.list", {"chat": str(GROUP_ID)}) + by_id = {row["id"]: row for row in rows} + assert by_id[ALICE]["status"] == "admin" + assert by_id[ALICE]["rank"] == "moderator" + assert by_id[ALICE]["promoted_by"] == admin_world.me.id + assert by_id[BOB].get("status", "member") == "member" + + async def test_the_admins_filter_asks_the_server_for_admins( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result( + client, in_thread, "chat.member.list", {"chat": str(GROUP_ID), "filter": "admins"} + ) + assert {row["id"] for row in rows} == {admin_world.me.id, ALICE} + sent = admin_world.called("GetParticipantsRequest")[-1] + assert type(sent.filter).__name__ == "ChannelParticipantsAdmins" + + async def test_the_search_goes_to_the_server_not_to_the_page( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result( + client, in_thread, "chat.member.list", {"chat": str(GROUP_ID), "search": "ali"} + ) + assert [row["id"] for row in rows] == [ALICE] + sent = admin_world.called("GetParticipantsRequest")[-1] + assert type(sent.filter).__name__ == "ChannelParticipantsSearch" + + async def test_the_cursor_walks_forward_and_is_op_bound( + self, live_daemon, client, in_thread, admin_world + ): + first = await call(client, in_thread, "chat.member.list", {"chat": str(GROUP_ID)}, limit=1) + assert first["page"]["has_more"] is True + cursor = first["page"]["next_cursor"] + second = await call( + client, in_thread, "chat.member.list", {"chat": str(GROUP_ID)}, limit=1, cursor=cursor + ) + assert first["result"][0]["id"] != second["result"][0]["id"] + assert ( + await fails( + client, in_thread, "chat.admin.list", {"chat": str(GROUP_ID)}, cursor=cursor + ) + == EXIT_USAGE + ) + + async def test_a_basic_group_is_sliced_client_side( + self, live_daemon, client, in_thread, admin_world + ): + """chatFull hands over every participant at once; the offset is ours.""" + rows = await result(client, in_thread, "chat.member.list", {"chat": str(BASIC_ID)}) + assert {row["id"] for row in rows} == {ALICE, BOB} + + async def test_via_link_asks_the_importers_endpoint( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.add_importer(GROUP_ID, CAROL) + rows = await result( + client, + in_thread, + "chat.member.list", + {"chat": str(GROUP_ID), "via_link": "https://t.me/+abc"}, + ) + assert [row["id"] for row in rows] == [CAROL] + assert rows[0]["via_link"] == "https://t.me/+abc" + + async def test_the_v1_path_still_works(self, live_daemon, client, in_thread, admin_world): + """AGENT.md documents `chat members`; §12.4 says it never disappears.""" + rows = await result(client, in_thread, "chat.members", {"chat": str(GROUP_ID)}) + assert {row["id"] for row in rows} == {admin_world.me.id, ALICE, BOB} + + +class TestMemberGet: + async def test_it_reports_effective_permissions_not_just_the_mask( + self, live_daemon, client, in_thread, admin_world + ): + from tlgr.ops import _rights + + entity = admin_world.chats[GROUP] + entity.default_banned_rights = _rights.build_banned_rights( + ["view-messages", "send-messages"] + ) + row = await result( + client, in_thread, "chat.member.get", {"chat": str(GROUP_ID), "user": str(BOB)} + ) + effective = row["effective_permissions"] + assert effective["send_messages"] is True + assert effective["send_media"] is False + + async def test_a_stranger_is_not_found(self, live_daemon, client, in_thread, admin_world): + code = await fails( + client, in_thread, "chat.member.get", {"chat": str(GROUP_ID), "user": str(CAROL)} + ) + assert code == EXIT_NOT_FOUND + + +# --------------------------------------------------------------------------- +# chat member add / remove / ban / unban / restrict +# --------------------------------------------------------------------------- + + +class TestMemberWrite: + async def test_adding_a_member_moves_the_world( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, in_thread, "chat.member.add", {"chat": str(GROUP_ID), "user": [str(CAROL)]} + ) + assert out["added"] == [CAROL] + assert CAROL in admin_world.members[GROUP_ID] + + async def test_a_refused_invitee_is_named_not_counted( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.tl import types + + def refuse(request): + return types.messages.InvitedUsers( + updates=types.Updates(updates=[], users=[], chats=[], date=None, seq=0), + missing_invitees=[ + types.MissingInvitee(user_id=CAROL, premium_required_for_pm=True) + ], + ) + + admin_world.raw["InviteToChannelRequest"] = refuse + out = await result( + client, in_thread, "chat.member.add", {"chat": str(GROUP_ID), "user": [str(CAROL)]} + ) + assert out["missing"] == [{"user_id": CAROL, "reason": "premium-required-for-pm"}] + assert out.get("added", []) == [] + + async def test_a_kick_bans_then_unbans_so_they_may_return( + self, live_daemon, client, in_thread, admin_world + ): + await result( + client, in_thread, "chat.member.remove", {"chat": str(GROUP_ID), "user": [str(BOB)]} + ) + sent = admin_world.called("EditBannedRequest") + assert len(sent) == 2 + assert sent[0].banned_rights.view_messages is True + assert sent[1].banned_rights.view_messages is False + assert admin_world.banned.get(GROUP_ID, {}) == {} + + async def test_a_ban_leaves_the_mask_in_place( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, in_thread, "chat.member.ban", {"chat": str(GROUP_ID), "user": [str(BOB)]} + ) + assert out[0]["banned"] is True + assert BOB in admin_world.banned[GROUP_ID] + rows = await result( + client, in_thread, "chat.member.list", {"chat": str(GROUP_ID), "filter": "kicked"} + ) + assert [row["id"] for row in rows] == [BOB] + + async def test_a_timed_ban_writes_an_absolute_timestamp( + self, live_daemon, client, in_thread, admin_world + ): + """COR-01 again: `--until 7d` must not resolve to 1970.""" + import time + + await result( + client, + in_thread, + "chat.member.ban", + {"chat": str(GROUP_ID), "user": [str(BOB)], "until": "7d"}, + ) + until = admin_world.called("EditBannedRequest")[-1].banned_rights.until_date + stamp = until if isinstance(until, int) else int(until.timestamp()) + assert abs(stamp - (time.time() + 7 * 86400)) < 5 + + async def test_a_short_ban_is_rounded_to_forever_like_the_server_does( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.member.ban", + {"chat": str(GROUP_ID), "user": [str(BOB)], "until": "10s"}, + ) + assert "until" not in out[0] + + async def test_ban_with_purge_drains_the_history_loop( + self, live_daemon, client, in_thread, admin_world + ): + await result( + client, + in_thread, + "chat.member.ban", + {"chat": str(GROUP_ID), "user": [str(BOB)], "purge": True, "report": True}, + ) + assert admin_world.called("DeleteParticipantHistoryRequest") + assert admin_world.called("ReportSpamRequest") + + async def test_unban_sends_an_all_clear_mask(self, live_daemon, client, in_thread, admin_world): + await result( + client, in_thread, "chat.member.ban", {"chat": str(GROUP_ID), "user": [str(BOB)]} + ) + await result( + client, in_thread, "chat.member.unban", {"chat": str(GROUP_ID), "user": [str(BOB)]} + ) + sent = admin_world.called("EditBannedRequest")[-1] + assert sent.banned_rights.view_messages is False + assert sent.banned_rights.send_messages is False + assert admin_world.banned.get(GROUP_ID, {}) == {} + + +class TestMemberRestrict: + async def test_the_mask_that_goes_back_is_complete( + self, live_daemon, client, in_thread, admin_world + ): + """`editBanned` replaces the mask; an omitted flag is a cleared flag.""" + out = await result( + client, + in_thread, + "chat.member.restrict", + {"chat": str(GROUP_ID), "user": str(BOB), "deny": "send-media"}, + ) + sent = admin_world.called("EditBannedRequest")[-1] + rights = sent.banned_rights + assert rights.send_media is True, "the denied right must be prohibited" + assert rights.send_messages is False, "an untouched right must stay allowed" + assert rights.view_messages is False + assert "send-media" in out["deny"] + assert "send-messages" in out["allow"] + + async def test_a_second_restrict_patches_rather_than_resets( + self, live_daemon, client, in_thread, admin_world + ): + await result( + client, + in_thread, + "chat.member.restrict", + {"chat": str(GROUP_ID), "user": str(BOB), "deny": "send-media"}, + ) + await result( + client, + in_thread, + "chat.member.restrict", + {"chat": str(GROUP_ID), "user": str(BOB), "deny": "send-polls"}, + ) + rights = admin_world.called("EditBannedRequest")[-1].banned_rights + assert rights.send_media is True and rights.send_polls is True + + async def test_none_is_read_only_and_all_gives_it_back( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.member.restrict", + {"chat": str(GROUP_ID), "user": str(BOB), "none": True}, + ) + assert out["allow"] == ["view-messages"] + out = await result( + client, + in_thread, + "chat.member.restrict", + {"chat": str(GROUP_ID), "user": str(BOB), "everything": True}, + ) + assert "send-messages" in out["allow"] + + async def test_an_unknown_right_is_refused_not_ignored( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, + in_thread, + "chat.member.restrict", + {"chat": str(GROUP_ID), "user": str(BOB), "deny": "send-telepathy"}, + ) + assert code == EXIT_USAGE + + async def test_a_layer_229_right_exits_thirteen( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, + in_thread, + "chat.member.restrict", + {"chat": str(GROUP_ID), "user": str(BOB), "deny": "manage-linked-peers"}, + ) + assert code == EXIT_INDETERMINATE + + async def test_a_basic_group_has_no_per_member_mask( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, + in_thread, + "chat.member.restrict", + {"chat": str(BASIC_ID), "user": str(BOB), "deny": "send-media"}, + ) + assert code == EXIT_USAGE + + +class TestMemberOdds: + async def test_edit_sets_a_rank(self, live_daemon, client, in_thread, admin_world): + out = await result( + client, + in_thread, + "chat.member.edit", + {"chat": str(GROUP_ID), "user": str(BOB), "rank": "helper"}, + ) + assert out["rank"] == "helper" + assert admin_world.members[GROUP_ID][BOB].rank == "helper" + + async def test_edit_with_nothing_to_change_is_a_usage_error( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, in_thread, "chat.member.edit", {"chat": str(GROUP_ID), "user": str(BOB)} + ) + assert code == EXIT_USAGE + + async def test_delete_history_drains_and_reports_a_count( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.member.delete-history", + {"chat": str(GROUP_ID), "user": str(BOB)}, + ) + assert out["deleted"] >= 0 + assert admin_world.called("DeleteParticipantHistoryRequest") + + async def test_delete_history_needs_a_supergroup( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, + in_thread, + "chat.member.delete-history", + {"chat": str(BASIC_ID), "user": str(BOB)}, + ) + assert code == EXIT_USAGE + + async def test_report_sends_the_message_ids(self, live_daemon, client, in_thread, admin_world): + out = await result( + client, + in_thread, + "chat.member.report", + {"chat": str(GROUP_ID), "user": str(BOB), "messages": [918]}, + ) + assert out["reported"] is True + assert admin_world.called("ReportSpamRequest")[-1].id == [918] + + async def test_a_purge_is_gated_by_dry_run(self, live_daemon, client, in_thread, admin_world): + """COR-17: the short-circuit is above the implementation, always.""" + envelope = await call( + client, + in_thread, + "chat.member.delete-history", + {"chat": str(GROUP_ID), "user": str(BOB)}, + dry_run=True, + ) + assert envelope["result"]["dry_run"] is True + assert not admin_world.called("DeleteParticipantHistoryRequest") + + +# --------------------------------------------------------------------------- +# chat admin +# --------------------------------------------------------------------------- + + +class TestAdmins: + async def test_the_creator_is_reported_as_creator( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result(client, in_thread, "chat.admin.list", {"chat": str(GROUP_ID)}) + by_id = {row["id"]: row for row in rows} + assert by_id[admin_world.me.id]["status"] == "creator" + assert by_id[ALICE]["admin_rights"]["ban_users"] is True + + async def test_no_rights_drops_the_mask(self, live_daemon, client, in_thread, admin_world): + rows = await result( + client, in_thread, "chat.admin.list", {"chat": str(GROUP_ID), "rights": False} + ) + assert all("admin_rights" not in row for row in rows) + + async def test_the_antispam_bot_is_appended_the_way_the_gui_does( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.tl import types + + admin_world.settings_of(GROUP_ID)["antispam"] = True + admin_world.raw["GetAppConfigRequest"] = types.help.AppConfig( + hash=0, + config=types.JsonObject( + value=[ + types.JsonObjectValue( + key="telegram_antispam_user_id", value=types.JsonNumber(value=5434988373) + ) + ] + ), + ) + rows = await result(client, in_thread, "chat.admin.list", {"chat": str(GROUP_ID)}) + assert any(row.get("name") == "Telegram Anti-Spam" for row in rows) + + async def test_promote_sets_the_mask_absolutely( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.admin.promote", + {"chat": str(GROUP_ID), "user": str(BOB), "rights": "ban-users,delete-messages"}, + ) + rights = out["admin_rights"] + assert rights["ban_users"] is True and rights["delete_messages"] is True + assert rights["add_admins"] is False + + async def test_grant_patches_the_mask_it_read_first( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.admin.promote", + {"chat": str(GROUP_ID), "user": str(ALICE), "grant": "pin-messages"}, + ) + rights = out["admin_rights"] + assert rights["ban_users"] is True, "the right Alice already had must survive" + assert rights["pin_messages"] is True + + async def test_revoke_takes_one_right_away(self, live_daemon, client, in_thread, admin_world): + out = await result( + client, + in_thread, + "chat.admin.promote", + {"chat": str(GROUP_ID), "user": str(ALICE), "revoke": "ban-users"}, + ) + assert out["admin_rights"]["ban_users"] is False + + async def test_a_rank_reaches_the_request(self, live_daemon, client, in_thread, admin_world): + await result( + client, + in_thread, + "chat.admin.promote", + {"chat": str(GROUP_ID), "user": str(BOB), "grant": "pin-messages", "rank": "mod"}, + ) + assert admin_world.called("EditAdminRequest")[-1].rank == "mod" + + async def test_a_basic_group_reports_the_rights_it_dropped( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.admin.promote", + {"chat": str(BASIC_ID), "user": str(BOB), "rights": "ban-users"}, + ) + assert out["dropped"] == ["ban-users"] + assert admin_world.called("EditChatAdminRequest")[-1].is_admin is True + + async def test_demote_sends_an_empty_mask(self, live_daemon, client, in_thread, admin_world): + out = await result( + client, in_thread, "chat.admin.demote", {"chat": str(GROUP_ID), "user": str(ALICE)} + ) + assert all(value is False for value in out["admin_rights"].values()) + rows = await result(client, in_thread, "chat.admin.list", {"chat": str(GROUP_ID)}) + assert ALICE not in {row["id"] for row in rows} + + +# --------------------------------------------------------------------------- +# chat permission +# --------------------------------------------------------------------------- + + +class TestPermissions: + async def test_get_prints_the_polarity_set_accepts( + self, live_daemon, client, in_thread, admin_world + ): + from tlgr.ops import _rights + + admin_world.chats[GROUP].default_banned_rights = _rights.build_banned_rights( + ["view-messages", "send-messages"] + ) + out = await result(client, in_thread, "chat.permission.get", {"chat": str(GROUP_ID)}) + assert "send-messages" in out["allow"] + assert "send-media" in out["deny"] + + async def test_set_round_trips_through_get(self, live_daemon, client, in_thread, admin_world): + await result( + client, + in_thread, + "chat.permission.set", + {"chat": str(GROUP_ID), "deny": "send-media,send-stickers"}, + ) + out = await result(client, in_thread, "chat.permission.get", {"chat": str(GROUP_ID)}) + assert "send-media" in out["deny"] and "send-stickers" in out["deny"] + assert "send-messages" in out["allow"] + + async def test_view_messages_is_never_taken_away_here( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, in_thread, "chat.permission.set", {"chat": str(GROUP_ID), "none": True} + ) + assert out["allow"] == ["view-messages"] + + async def test_an_unchanged_mask_reports_already( + self, live_daemon, client, in_thread, admin_world + ): + await result( + client, in_thread, "chat.permission.set", {"chat": str(GROUP_ID), "deny": "send-media"} + ) + envelope = await call( + client, in_thread, "chat.permission.set", {"chat": str(GROUP_ID), "deny": "send-media"} + ) + assert envelope["result"]["already"] is True + assert envelope["meta"]["already"] is True + + async def test_set_with_nothing_named_is_a_usage_error( + self, live_daemon, client, in_thread, admin_world + ): + assert ( + await fails(client, in_thread, "chat.permission.set", {"chat": str(GROUP_ID)}) + == EXIT_USAGE + ) + + async def test_the_vocabulary_marks_the_layer_gap( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result(client, in_thread, "chat.permission.list", {"mask": "admin"}) + by_name = {row["name"]: row for row in rows} + assert by_name["ban-users"]["tl_flag"] == "ban_users" + assert by_name["manage-linked-peers"]["supported"] is False + assert by_name["manage-linked-peers"]["since_layer"] == 229 + + async def test_the_member_vocabulary_is_the_one_restrict_accepts( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result(client, in_thread, "chat.permission.list", {"mask": "member"}) + names = {row["name"] for row in rows} + assert "send-rounds" in names and "view-messages" in names + assert all(row["mask"] == "member" for row in rows) + + +# --------------------------------------------------------------------------- +# chat admin-log +# --------------------------------------------------------------------------- + + +class TestAdminLog: + def _events(self, world): + from telethon.tl import types + + world.add_admin_log( + GROUP_ID, + 91, + types.ChannelAdminLogEventActionChangeTitle(prev_value="Old", new_value="New"), + ) + world.add_admin_log( + GROUP_ID, + 92, + types.ChannelAdminLogEventActionToggleSlowMode(prev_value=0, new_value=30), + ) + + async def test_an_action_is_a_slug_and_keeps_its_tl_name( + self, live_daemon, client, in_thread, admin_world + ): + self._events(admin_world) + rows = await result(client, in_thread, "chat.admin-log.list", {"chat": str(GROUP_ID)}) + newest = rows[0] + assert newest["action"] == "toggle-slow-mode" + assert newest["raw_type"] == "ChannelAdminLogEventActionToggleSlowMode" + assert newest["prev"] == 0 and newest["new"] == 30 + + async def test_the_filter_names_are_the_apis_not_telethons( + self, live_daemon, client, in_thread, admin_world + ): + self._events(admin_world) + await result( + client, in_thread, "chat.admin-log.list", {"chat": str(GROUP_ID), "filter": "ban,kick"} + ) + sent = admin_world.called("GetAdminLogRequest")[-1] + assert sent.events_filter.ban is True and sent.events_filter.kick is True + assert sent.events_filter.promote is None + + async def test_an_unknown_filter_class_is_a_usage_error( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, + in_thread, + "chat.admin-log.list", + {"chat": str(GROUP_ID), "filter": "everything-please"}, + ) + assert code == EXIT_USAGE + + async def test_the_cursor_is_the_max_id_and_walks_backwards( + self, live_daemon, client, in_thread, admin_world + ): + self._events(admin_world) + first = await call( + client, in_thread, "chat.admin-log.list", {"chat": str(GROUP_ID)}, limit=1 + ) + assert first["result"][0]["id"] == 92 + second = await call( + client, + in_thread, + "chat.admin-log.list", + {"chat": str(GROUP_ID)}, + limit=1, + cursor=first["page"]["next_cursor"], + ) + assert second["result"][0]["id"] == 91 + + async def test_the_antispam_false_positive_report( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, in_thread, "chat.admin-log.report", {"chat": str(GROUP_ID), "msg_id": 918} + ) + assert out == {"chat_id": GROUP_ID, "msg_id": 918}, "reported=True is the default" + + +# --------------------------------------------------------------------------- +# chat transfer +# --------------------------------------------------------------------------- + + +class TestTransfer: + async def test_without_a_password_it_refuses_before_the_network( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, in_thread, "chat.transfer", {"chat": str(GROUP_ID), "user": str(ALICE)} + ) + assert code == EXIT_USAGE + assert not admin_world.called("EditChatCreatorRequest") + + async def test_dry_run_never_reaches_the_password_check( + self, live_daemon, client, in_thread, admin_world + ): + envelope = await call( + client, + in_thread, + "chat.transfer", + {"chat": str(GROUP_ID), "user": str(ALICE)}, + dry_run=True, + ) + assert envelope["result"]["would"] == "chat.transfer" + + +# --------------------------------------------------------------------------- +# chat invite +# --------------------------------------------------------------------------- + + +class TestInvites: + async def test_creating_a_link_carries_its_limits( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.invite.create", + {"chat": str(GROUP_ID), "title": "Launch", "usage_limit": 25, "expires": "7d"}, + ) + assert out["title"] == "Launch" and out["usage_limit"] == 25 + sent = admin_world.called("ExportChatInviteRequest")[-1] + assert sent.expire_date is not None + + async def test_a_limit_and_an_approval_queue_are_mutually_exclusive( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, + in_thread, + "chat.invite.create", + {"chat": str(GROUP_ID), "usage_limit": 5, "request_approval": True}, + ) + assert code == EXIT_USAGE + + async def test_a_paid_link_is_billed_per_thirty_days( + self, live_daemon, client, in_thread, admin_world + ): + await result( + client, + in_thread, + "chat.invite.create", + {"chat": str(GROUP_ID), "subscription_stars": 50}, + ) + pricing = admin_world.called("ExportChatInviteRequest")[-1].subscription_pricing + assert pricing.period == 30 * 86400 and pricing.amount == 50 + + async def test_listing_separates_active_from_revoked( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.add_invite(GROUP_ID, "https://t.me/+live") + admin_world.add_invite(GROUP_ID, "https://t.me/+dead", revoked=True) + active = await result(client, in_thread, "chat.invite.list", {"chat": str(GROUP_ID)}) + revoked = await result( + client, in_thread, "chat.invite.list", {"chat": str(GROUP_ID), "revoked": True} + ) + assert [row["link"] for row in active] == ["https://t.me/+live"] + assert [row["link"] for row in revoked] == ["https://t.me/+dead"] + + async def test_by_admin_counts_instead_of_linking( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.add_invite(GROUP_ID, "https://t.me/+one") + admin_world.add_invite(GROUP_ID, "https://t.me/+two", revoked=True) + rows = await result( + client, in_thread, "chat.invite.list", {"chat": str(GROUP_ID), "by_admin": True} + ) + assert rows[0]["invites_count"] == 1 and rows[0]["revoked_invites_count"] == 1 + + async def test_editing_a_link_changes_it(self, live_daemon, client, in_thread, admin_world): + admin_world.add_invite(GROUP_ID, "https://t.me/+one", title="Old") + out = await result( + client, + in_thread, + "chat.invite.edit", + {"chat": str(GROUP_ID), "link": "https://t.me/+one", "title": "New"}, + ) + assert out["title"] == "New" + + async def test_a_replaced_link_is_reported_beside_the_new_one( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.tl import types + + old = types.ChatInviteExported( + link="https://t.me/+old", admin_id=admin_world.me.id, date=None + ) + new = types.ChatInviteExported( + link="https://t.me/+new", admin_id=admin_world.me.id, date=None + ) + admin_world.raw["EditExportedChatInviteRequest"] = ( + types.messages.ExportedChatInviteReplaced(invite=old, new_invite=new, users=[]) + ) + out = await result( + client, + in_thread, + "chat.invite.revoke", + {"chat": str(GROUP_ID), "link": "https://t.me/+old"}, + ) + assert out["link"] == "https://t.me/+new" + assert out["replaced_link"] == "https://t.me/+old" + + async def test_deleting_needs_a_link_or_the_revoked_flag( + self, live_daemon, client, in_thread, admin_world + ): + assert ( + await fails(client, in_thread, "chat.invite.delete", {"chat": str(GROUP_ID)}) + == EXIT_USAGE + ) + + async def test_deleting_every_revoked_link_purges_them( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.add_invite(GROUP_ID, "https://t.me/+live") + admin_world.add_invite(GROUP_ID, "https://t.me/+dead", revoked=True) + await result( + client, in_thread, "chat.invite.delete", {"chat": str(GROUP_ID), "revoked": True} + ) + assert [i.link for i in admin_world.invites[GROUP_ID]] == ["https://t.me/+live"] + + async def test_getting_a_link_you_hold_previews_it_without_rights( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "chat.invite.get", {"target": "https://t.me/+AbCdEf"}) + assert out["chat_title"] == "Shared group" + assert out["already_member"] is False + assert admin_world.called("CheckChatInviteRequest")[-1].hash == "AbCdEf" + + async def test_a_link_you_already_joined_says_so( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.tl import types + + admin_world.invite_previews["AbCdEf"] = types.ChatInviteAlready( + chat=admin_world.chats[GROUP] + ) + out = await result(client, in_thread, "chat.invite.get", {"target": "https://t.me/+AbCdEf"}) + assert out["already_member"] is True + + async def test_the_qr_warns_instead_of_pretending( + self, live_daemon, client, in_thread, admin_world + ): + envelope = await call( + client, + in_thread, + "chat.invite.get", + {"target": "https://t.me/+AbCdEf", "qr": True}, + ) + warnings = envelope["meta"]["warnings"] + assert any("QR" in warning for warning in warnings) + + async def test_a_peek_reads_without_joining(self, live_daemon, client, in_thread, admin_world): + from telethon.tl import types + + admin_world.add_message(GROUP_ID, "secret", message_id=77) + admin_world.invite_previews["Peek"] = types.ChatInvitePeek( + chat=admin_world.chats[GROUP], expires=datetime.now(timezone.utc) + ) + out = await result(client, in_thread, "chat.invite.open", {"link": "https://t.me/+Peek"}) + assert out["peek_expires"] + assert [m["text"] for m in out["messages"]] == ["secret"] + + async def test_a_link_with_no_peek_on_offer_exits_six( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails(client, in_thread, "chat.invite.open", {"link": "https://t.me/+NoPeek"}) + assert code == EXIT_PERMISSION + + +class TestJoin: + async def test_joining_a_public_chat_by_username( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "chat.join", {"target": str(GROUP_ID)}) + assert out["joined"] is True and out["chat_id"] == GROUP_ID + assert admin_world.called("JoinChannelRequest") + + async def test_already_a_participant_is_success_not_failure( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.errors import UserAlreadyParticipantError + + admin_world.fail_next("ImportChatInviteRequest", UserAlreadyParticipantError(request=None)) + envelope = await call(client, in_thread, "chat.join", {"target": "https://t.me/+abc"}) + assert envelope["result"]["already"] is True + assert envelope["meta"]["already"] is True + + async def test_a_queued_join_reports_pending_approval( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.errors import InviteRequestSentError + + admin_world.fail_next("ImportChatInviteRequest", InviteRequestSentError(request=None)) + out = await result(client, in_thread, "chat.join", {"target": "https://t.me/+abc"}) + assert out["pending_approval"] is True and out.get("joined", False) is False + + +# --------------------------------------------------------------------------- +# chat request +# --------------------------------------------------------------------------- + + +class TestJoinRequests: + async def test_listing_shows_only_pending_requests( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.add_importer(GROUP_ID, CAROL, requested=True, about="let me in") + admin_world.add_importer(GROUP_ID, BOB) + rows = await result(client, in_thread, "chat.request.list", {"chat": str(GROUP_ID)}) + assert [row["user_id"] for row in rows] == [CAROL] + assert rows[0]["about"] == "let me in" + + async def test_approving_moves_them_into_the_chat( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.add_importer(GROUP_ID, CAROL, requested=True) + out = await result( + client, + in_thread, + "chat.request.approve", + {"chat": str(GROUP_ID), "user": [str(CAROL)]}, + ) + assert out["approved"] == [CAROL] + assert CAROL in admin_world.members[GROUP_ID] + + async def test_denying_leaves_them_out(self, live_daemon, client, in_thread, admin_world): + admin_world.add_importer(GROUP_ID, CAROL, requested=True) + out = await result( + client, in_thread, "chat.request.deny", {"chat": str(GROUP_ID), "user": [str(CAROL)]} + ) + assert out["denied"] == [CAROL] + assert CAROL not in admin_world.members.get(GROUP_ID, {}) + + async def test_approve_all_answers_the_whole_queue( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.add_importer(GROUP_ID, CAROL, requested=True) + out = await result( + client, in_thread, "chat.request.approve", {"chat": str(GROUP_ID), "everyone": True} + ) + assert out["all"] is True + assert admin_world.called("HideAllChatJoinRequestsRequest") + + async def test_answering_needs_a_target(self, live_daemon, client, in_thread, admin_world): + assert ( + await fails(client, in_thread, "chat.request.approve", {"chat": str(GROUP_ID)}) + == EXIT_USAGE + ) + + async def test_the_list_answer_flags_honour_dry_run_themselves( + self, live_daemon, client, in_thread, admin_world + ): + """The op is a read, so `--dry-run` must keep listing (DECISIONS).""" + admin_world.add_importer(GROUP_ID, CAROL, requested=True) + envelope = await call( + client, + in_thread, + "chat.request.list", + {"chat": str(GROUP_ID), "approve": [str(CAROL)]}, + dry_run=True, + ) + assert [row["user_id"] for row in envelope["result"]] == [CAROL] + assert any("dry-run" in w for w in envelope["meta"]["warnings"]) + assert not admin_world.called("HideChatJoinRequestRequest") + + async def test_the_list_answer_flags_do_fire_without_dry_run( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.add_importer(GROUP_ID, CAROL, requested=True) + rows = await result( + client, + in_thread, + "chat.request.list", + {"chat": str(GROUP_ID), "approve": [str(CAROL)]}, + ) + assert rows == [] + assert CAROL in admin_world.members[GROUP_ID] + + +# --------------------------------------------------------------------------- +# chat topic +# --------------------------------------------------------------------------- + + +@pytest.fixture +def forum(admin_world): + admin_world.chats[GROUP].forum = True + admin_world.add_topic(GROUP_ID, 1, "General") + admin_world.add_topic(GROUP_ID, 314, "Releases", unread_count=2) + return admin_world + + +class TestTopics: + async def test_listing_reports_the_counters(self, live_daemon, client, in_thread, forum): + rows = await result(client, in_thread, "chat.topic.list", {"chat": str(GROUP_ID)}) + by_id = {row["id"]: row for row in rows} + assert by_id[314]["title"] == "Releases" + assert by_id[314]["unread_count"] == 2 + assert 1 in by_id, "General is always present" + + async def test_the_search_reaches_the_request(self, live_daemon, client, in_thread, forum): + rows = await result( + client, in_thread, "chat.topic.list", {"chat": str(GROUP_ID), "search": "rele"} + ) + assert [row["id"] for row in rows] == [314] + + async def test_a_client_side_filter_does_not_move_the_cursor( + self, live_daemon, client, in_thread, forum + ): + """The cursor is built from the server's last row, not the survivor.""" + forum.topics[GROUP_ID][314].closed = True + envelope = await call( + client, in_thread, "chat.topic.list", {"chat": str(GROUP_ID), "closed": True} + ) + assert [row["id"] for row in envelope["result"]] == [314] + + async def test_a_chat_that_is_not_a_forum_says_so( + self, live_daemon, client, in_thread, admin_world + ): + assert ( + await fails(client, in_thread, "chat.topic.list", {"chat": str(BASIC_ID)}) == EXIT_USAGE + ) + + async def test_get_reports_a_deleted_topic_rather_than_dropping_it( + self, live_daemon, client, in_thread, forum + ): + rows = await result( + client, in_thread, "chat.topic.get", {"chat": str(GROUP_ID), "topic": [314, 999]} + ) + by_id = {row["id"]: row for row in rows} + assert by_id[999]["deleted"] is True + assert "deleted" not in by_id[314] + + async def test_creating_a_topic_returns_the_id_that_topic_flags_take( + self, live_daemon, client, in_thread, forum + ): + out = await result( + client, in_thread, "chat.topic.create", {"chat": str(GROUP_ID), "title": "Design"} + ) + assert out["topic_id"] in forum.topics[GROUP_ID] + assert forum.topics[GROUP_ID][out["topic_id"]].title == "Design" + + async def test_close_and_reopen_move_the_topic(self, live_daemon, client, in_thread, forum): + await result(client, in_thread, "chat.topic.close", {"chat": str(GROUP_ID), "topic": 314}) + assert forum.topics[GROUP_ID][314].closed is True + await result(client, in_thread, "chat.topic.reopen", {"chat": str(GROUP_ID), "topic": 314}) + assert forum.topics[GROUP_ID][314].closed is False + + async def test_edit_renames_and_reports_what_changed( + self, live_daemon, client, in_thread, forum + ): + out = await result( + client, + in_thread, + "chat.topic.edit", + {"chat": str(GROUP_ID), "topic": 314, "title": "Release notes"}, + ) + assert out["changed"] == ["title"] + assert forum.topics[GROUP_ID][314].title == "Release notes" + + async def test_edit_with_nothing_to_change_is_a_usage_error( + self, live_daemon, client, in_thread, forum + ): + assert ( + await fails(client, in_thread, "chat.topic.edit", {"chat": str(GROUP_ID), "topic": 314}) + == EXIT_USAGE + ) + + async def test_hide_and_unhide_always_target_general( + self, live_daemon, client, in_thread, forum + ): + out = await result(client, in_thread, "chat.topic.hide", {"chat": str(GROUP_ID)}) + assert out["topic_id"] == 1 + assert forum.topics[GROUP_ID][1].hidden is True + await result(client, in_thread, "chat.topic.unhide", {"chat": str(GROUP_ID)}) + assert forum.topics[GROUP_ID][1].hidden is False + + async def test_general_cannot_be_deleted(self, live_daemon, client, in_thread, forum): + assert ( + await fails(client, in_thread, "chat.topic.delete", {"chat": str(GROUP_ID), "topic": 1}) + == EXIT_USAGE + ) + + async def test_delete_drains_and_removes(self, live_daemon, client, in_thread, forum): + out = await result( + client, in_thread, "chat.topic.delete", {"chat": str(GROUP_ID), "topic": 314} + ) + assert out["deleted"] is True + assert 314 not in forum.topics[GROUP_ID] + + async def test_pin_and_unpin(self, live_daemon, client, in_thread, forum): + await result(client, in_thread, "chat.topic.pin", {"chat": str(GROUP_ID), "topic": [314]}) + assert forum.topics[GROUP_ID][314].pinned is True + await result(client, in_thread, "chat.topic.unpin", {"chat": str(GROUP_ID), "topic": [314]}) + assert not forum.topics[GROUP_ID][314].pinned + + async def test_reorder_sends_the_whole_order(self, live_daemon, client, in_thread, forum): + await result( + client, + in_thread, + "chat.topic.pin", + {"chat": str(GROUP_ID), "topic": [314, 1], "reorder": True}, + ) + sent = forum.called("ReorderPinnedForumTopicsRequest")[-1] + assert sent.order == [314, 1] + + async def test_unpin_all_clears_the_order(self, live_daemon, client, in_thread, forum): + await result( + client, in_thread, "chat.topic.unpin", {"chat": str(GROUP_ID), "everything": True} + ) + sent = forum.called("ReorderPinnedForumTopicsRequest")[-1] + assert sent.order == [] and sent.force is True + + async def test_muting_writes_an_absolute_timestamp(self, live_daemon, client, in_thread, forum): + """COR-01 for topics: `8h` is now + 8h, not 1970.""" + import time + + await result( + client, + in_thread, + "chat.topic.mute", + {"chat": str(GROUP_ID), "topic": 314, "duration": "8h"}, + ) + sent = forum.called("UpdateNotifySettingsRequest")[-1] + assert type(sent.peer).__name__ == "InputNotifyForumTopic" + assert sent.peer.top_msg_id == 314 + until = sent.settings.mute_until + stamp = until if isinstance(until, int) else int(until.timestamp()) + assert abs(stamp - (time.time() + 8 * 3600)) < 5 + + async def test_unmute_clears_it(self, live_daemon, client, in_thread, forum): + await result(client, in_thread, "chat.topic.unmute", {"chat": str(GROUP_ID), "topic": 314}) + sent = forum.called("UpdateNotifySettingsRequest")[-1] + assert sent.settings.mute_until is None + + async def test_reading_a_topic_omits_top_msg_id_for_general( + self, live_daemon, client, in_thread, forum + ): + await result( + client, + in_thread, + "chat.topic.read", + {"chat": str(GROUP_ID), "topic": 1, "mentions": True}, + ) + sent = forum.called("ReadMentionsRequest")[-1] + assert sent.top_msg_id is None + + async def test_reading_a_normal_topic_sends_top_msg_id( + self, live_daemon, client, in_thread, forum + ): + await result( + client, + in_thread, + "chat.topic.read", + {"chat": str(GROUP_ID), "topic": 314, "reactions": True}, + ) + assert forum.called("ReadReactionsRequest")[-1].top_msg_id == 314 + + async def test_list_only_reads_nothing(self, live_daemon, client, in_thread, forum): + await result( + client, + in_thread, + "chat.topic.read", + {"chat": str(GROUP_ID), "topic": 314, "list_only": True}, + ) + assert not forum.called("ReadDiscussionRequest") + assert forum.called("GetUnreadMentionsRequest") + + +# --------------------------------------------------------------------------- +# chat create / edit / convert / setting / username / photo / send-as +# --------------------------------------------------------------------------- + + +class TestCreate: + async def test_a_supergroup_is_what_the_gui_creates( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "chat.create", {"title": "Release team"}) + assert out["type"] == "supergroup" + sent = admin_world.called("CreateChannelRequest")[-1] + assert sent.megagroup is True and sent.broadcast is None + + async def test_a_forum_asks_for_a_forum(self, live_daemon, client, in_thread, admin_world): + await result(client, in_thread, "chat.create", {"title": "Hub", "type": "forum"}) + assert admin_world.called("CreateChannelRequest")[-1].forum is True + + async def test_a_basic_group_takes_the_other_request( + self, live_daemon, client, in_thread, admin_world + ): + await result( + client, + in_thread, + "chat.create", + {"title": "Old crew", "type": "group", "members": [str(ALICE)]}, + ) + assert admin_world.called("CreateChatRequest")[-1].title == "Old crew" + + async def test_a_basic_group_cannot_have_a_username( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, + in_thread, + "chat.create", + {"title": "Old crew", "type": "group", "username": "oldcrew"}, + ) + assert code == EXIT_USAGE + + async def test_the_v1_path_still_creates(self, live_daemon, client, in_thread, admin_world): + """AGENT.md documents `chat create`; it must keep working.""" + out = await result(client, in_thread, "chat.create", {"title": "Legacy"}) + assert out["title"] == "Legacy" + + +class TestEditAndConvert: + async def test_editing_a_title_and_an_about_reports_both( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.edit", + {"chat": str(GROUP_ID), "title": "Renamed", "about": "Ship it"}, + ) + assert out["changed"] == ["title", "about"] + assert admin_world.called("EditTitleRequest")[-1].title == "Renamed" + assert admin_world.called("EditChatAboutRequest")[-1].about == "Ship it" + + async def test_a_basic_group_edits_through_the_other_request( + self, live_daemon, client, in_thread, admin_world + ): + await result(client, in_thread, "chat.edit", {"chat": str(BASIC_ID), "title": "Renamed"}) + assert admin_world.called("EditChatTitleRequest")[-1].title == "Renamed" + + async def test_a_colour_is_refused_on_a_basic_group( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails(client, in_thread, "chat.edit", {"chat": str(BASIC_ID), "color": "5"}) + assert code == EXIT_USAGE + + async def test_nothing_to_change_is_a_usage_error( + self, live_daemon, client, in_thread, admin_world + ): + assert await fails(client, in_thread, "chat.edit", {"chat": str(GROUP_ID)}) == EXIT_USAGE + + async def test_converting_a_basic_group_reports_both_ids( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, in_thread, "chat.convert", {"chat": str(BASIC_ID), "target": "supergroup"} + ) + assert out["old_chat_id"] == BASIC_ID + assert out["type"] == "supergroup" + + async def test_converting_a_supergroup_to_a_supergroup_is_refused( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, in_thread, "chat.convert", {"chat": str(GROUP_ID), "target": "supergroup"} + ) + assert code == EXIT_USAGE + + async def test_an_unknown_target_is_a_usage_error( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, in_thread, "chat.convert", {"chat": str(GROUP_ID), "target": "megachat"} + ) + assert code == EXIT_USAGE + + +class TestSettings: + async def test_the_keys_round_trip_from_get_into_set( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.settings_of(GROUP_ID)["slowmode_seconds"] = 30 + out = await result(client, in_thread, "chat.setting.get", {"chat": str(GROUP_ID)}) + assert out["slow_mode"] == 30 + assert "slow_mode" in out["available"] + + async def test_a_toggle_already_in_that_state_is_not_sent( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.settings_of(GROUP_ID)["slowmode_seconds"] = 30 + envelope = await call( + client, in_thread, "chat.setting.set", {"chat": str(GROUP_ID), "slow_mode": "30s"} + ) + assert envelope["result"]["already"] == ["slow_mode"] + assert not admin_world.called("ToggleSlowModeRequest") + + async def test_slow_mode_is_rounded_onto_the_server_ladder( + self, live_daemon, client, in_thread, admin_world + ): + envelope = await call( + client, in_thread, "chat.setting.set", {"chat": str(GROUP_ID), "slow_mode": "45s"} + ) + assert admin_world.called("ToggleSlowModeRequest")[-1].seconds == 60 + assert any("rounded" in w for w in envelope["meta"]["warnings"]) + + async def test_a_failed_key_does_not_hide_the_ones_that_worked( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.errors import ChatAdminRequiredError + + admin_world.fail_next( + "ToggleParticipantsHiddenRequest", ChatAdminRequiredError(request=None) + ) + out = await result( + client, + in_thread, + "chat.setting.set", + {"chat": str(GROUP_ID), "slow_mode": "30s", "hidden_members": "on"}, + ) + assert out["changed"] == ["slow_mode"] + assert "hidden_members" in out["failed"] + + async def test_on_off_is_validated(self, live_daemon, client, in_thread, admin_world): + code = await fails( + client, in_thread, "chat.setting.set", {"chat": str(GROUP_ID), "antispam": "maybe"} + ) + assert code == EXIT_USAGE + + async def test_a_basic_group_refuses_the_supergroup_keys( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, in_thread, "chat.setting.set", {"chat": str(BASIC_ID), "slow_mode": "30s"} + ) + assert code == EXIT_USAGE + + async def test_nothing_to_change_is_a_usage_error( + self, live_daemon, client, in_thread, admin_world + ): + assert ( + await fails(client, in_thread, "chat.setting.set", {"chat": str(GROUP_ID)}) + == EXIT_USAGE + ) + + async def test_the_settings_alias_still_reads( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "chat.settings", {"chat": str(GROUP_ID)}) + assert out["chat_id"] == GROUP_ID + + +class TestUsernames: + async def test_an_available_name_says_so(self, live_daemon, client, in_thread, admin_world): + out = await result(client, in_thread, "chat.username.get", {"username": "mynews"}) + assert out == {"username": "mynews", "status": "available", "available": True} + + async def test_an_occupied_name_says_so(self, live_daemon, client, in_thread, admin_world): + admin_world.taken_usernames.add("mynews") + out = await result(client, in_thread, "chat.username.get", {"username": "mynews"}) + assert out["status"] == "occupied" and out.get("available", False) is False + + async def test_setting_a_username_updates_the_entity( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, in_thread, "chat.username.set", {"chat": str(GROUP_ID), "username": "mynews"} + ) + assert out["link"] == "https://t.me/mynews" + assert admin_world.chats[GROUP].username == "mynews" + + async def test_exactly_one_of_username_and_order( + self, live_daemon, client, in_thread, admin_world + ): + assert ( + await fails(client, in_thread, "chat.username.set", {"chat": str(GROUP_ID)}) + == EXIT_USAGE + ) + + async def test_a_basic_group_needs_upgrade_to_be_explicit( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails( + client, + in_thread, + "chat.username.set", + {"chat": str(BASIC_ID), "username": "oldcrew"}, + ) + assert code == EXIT_USAGE + + async def test_toggling_one_username(self, live_daemon, client, in_thread, admin_world): + out = await result( + client, + in_thread, + "chat.username.toggle", + {"chat": str(GROUP_ID), "username": "mynews", "state": "off"}, + ) + assert out.get("usernames", []) == [] + assert admin_world.called("ToggleUsernameRequest")[-1].active is False + + async def test_going_private_prints_the_invite_link( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "chat.username.unset", {"chat": str(GROUP_ID)}) + assert out["invite_link"].startswith("https://t.me/+") + + +class TestPhotoAndSendAs: + async def test_exactly_one_photo_source(self, live_daemon, client, in_thread, admin_world): + assert ( + await fails(client, in_thread, "chat.photo.set", {"chat": str(GROUP_ID)}) == EXIT_USAGE + ) + + async def test_deleting_the_photo_sends_the_empty_one( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "chat.photo.delete", {"chat": str(GROUP_ID)}) + assert out.get("ok", True) is True + sent = admin_world.called("EditPhotoRequest")[-1] + assert type(sent.photo).__name__ == "InputChatPhotoEmpty" + + async def test_send_as_lists_and_sets(self, live_daemon, client, in_thread, admin_world): + rows = await result(client, in_thread, "chat.send-as.list", {"chat": str(GROUP_ID)}) + assert rows[0]["id"] == admin_world.me.id + out = await result( + client, in_thread, "chat.send-as.set", {"chat": str(GROUP_ID), "peer": str(CHANNEL_ID)} + ) + assert out["send_as"] == CHANNEL_ID + + +class TestDiscussion: + async def test_candidates_flag_a_basic_group_for_migration( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result(client, in_thread, "chat.discussion.list", {}) + needs = {row["id"]: row.get("needs_migration", False) for row in rows} + assert needs[BASIC_ID] is True + assert needs[GROUP_ID] is False + + async def test_linking_unhides_the_prehistory_when_asked( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.discussion.set", + { + "channel": str(CHANNEL_ID), + "group": str(GROUP_ID), + "unhide_prehistory": True, + }, + ) + assert out["linked_chat_id"] == GROUP_ID + assert admin_world.called("TogglePreHistoryHiddenRequest")[-1].enabled is False + + async def test_unlinking_sends_the_empty_channel( + self, live_daemon, client, in_thread, admin_world + ): + await result(client, in_thread, "chat.discussion.unset", {"channel": str(CHANNEL_ID)}) + sent = admin_world.called("SetDiscussionGroupRequest")[-1] + assert type(sent.group).__name__ == "InputChannelEmpty" + + +# --------------------------------------------------------------------------- +# chat similar / sponsored / suggestion / verification / affiliate +# --------------------------------------------------------------------------- + + +class TestExtras: + async def test_similar_channels_report_the_truncation( + self, live_daemon, client, in_thread, admin_world + ): + envelope = await call( + client, in_thread, "chat.similar.list", {"chat": str(CHANNEL_ID)}, limit=1 + ) + assert envelope["page"]["total"] >= 1 + + async def test_sponsored_messages_are_never_marked_viewed( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result(client, in_thread, "chat.sponsored.list", {"chat": str(CHANNEL_ID)}) + assert rows[0]["random_id"] == "AQID" + assert rows[0].get("viewed", False) is False + assert not admin_world.called("ViewSponsoredMessageRequest") + + async def test_reporting_a_sponsored_message_walks_the_menu( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.sponsored.report", + {"chat": str(CHANNEL_ID), "random_id": "AQID"}, + ) + assert out["result"] == "choose-option" + assert out["options"][0]["text"] == "Spam" + picked = await result( + client, + in_thread, + "chat.sponsored.report", + {"chat": str(CHANNEL_ID), "random_id": "AQID", "option": out["options"][0]["option"]}, + ) + assert picked["result"] == "reported" + + async def test_dismissing_a_suggestion_reports_what_is_left( + self, live_daemon, client, in_thread, admin_world + ): + admin_world.settings_of(CHANNEL_ID)["pending_suggestions"] = [ + "CONVERT_GIGAGROUP", + "VALIDATE_PASSWORD", + ] + out = await result( + client, + in_thread, + "chat.suggestion.delete", + {"chat": str(CHANNEL_ID), "key": "CONVERT_GIGAGROUP"}, + ) + assert out["pending_suggestions"] == ["VALIDATE_PASSWORD"] + + async def test_verification_is_a_bots_badge(self, live_daemon, client, in_thread, admin_world): + out = await result( + client, + in_thread, + "chat.verification.set", + {"chat": str(GROUP_ID), "bot": str(BOB)}, + ) + assert out["enabled"] is True and out["bot_id"] == BOB + + async def test_suggested_post_approval_and_rejection( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, + in_thread, + "chat.suggested-post.approve", + {"channel": str(CHANNEL_ID), "msg_id": 918}, + ) + assert out["approved"] is True + out = await result( + client, + in_thread, + "chat.suggested-post.deny", + {"channel": str(CHANNEL_ID), "msg_id": 918, "comment": "off topic"}, + ) + assert out["rejected"] is True + assert admin_world.called("ToggleSuggestedPostApprovalRequest")[-1].reject is True + + async def test_affiliate_bots_come_back_with_their_commission( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result(client, in_thread, "chat.affiliate.list", {"chat": str(CHANNEL_ID)}) + assert rows[0]["bot_id"] == 8800 and rows[0]["commission_permille"] == 200 + + async def test_connecting_an_affiliate_bot(self, live_daemon, client, in_thread, admin_world): + out = await result( + client, in_thread, "chat.affiliate.set", {"chat": str(CHANNEL_ID), "bot": str(BOB)} + ) + assert out["commission_permille"] == 200 + assert admin_world.called("ConnectStarRefBotRequest") + + async def test_a_channel_without_direct_messages_is_not_found( + self, live_daemon, client, in_thread, admin_world + ): + code = await fails(client, in_thread, "chat.direct.list", {"channel": str(CHANNEL_ID)}) + assert code == EXIT_NOT_FOUND + + +# --------------------------------------------------------------------------- +# The layer-229 refusals +# --------------------------------------------------------------------------- + + +class TestLayerGaps: + @pytest.mark.parametrize( + ("op", "payload"), + [ + ("chat.community.create", {"title": "Hub"}), + ("chat.community.list", {}), + ("chat.community.set", {"community": "@myhub", "chat": "@mygroup"}), + ("chat.community.ban", {"community": "@myhub", "user": "@alice"}), + ("chat.welcome.list", {"chat": "@mygroup"}), + ("chat.welcome.set", {"chat": "@mygroup", "text": "hi"}), + ("chat.welcome.delete", {"chat": "@mygroup", "everything": True}), + ], + ) + async def test_it_refuses_with_an_explanation_not_a_traceback( + self, live_daemon, client, in_thread, admin_world, op, payload + ): + with pytest.raises(Exception) as caught: + await call(client, in_thread, op, payload) + body = classify(caught.value) + assert body.exit_code == EXIT_INDETERMINATE + assert "layer 229" in body.message + + async def test_the_rights_vocabulary_names_them_rather_than_hiding_them( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result(client, in_thread, "chat.permission.list", {}) + gaps = {row["name"] for row in rows if not row.get("supported", True)} + assert gaps == {"manage-linked-peers", "manage-welcome-messages"} + + +# --------------------------------------------------------------------------- +# chat stats / revenue / boost +# --------------------------------------------------------------------------- + + +class TestStats: + async def test_broadcast_stats_carry_growth_and_graphs( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "chat.stats.get", {"chat": str(CHANNEL_ID)}) + assert out["type"] == "broadcast" + assert out["followers"]["current"] == 120.0 + assert out["followers"]["growth"] == 20.0 + names = {graph["name"] for graph in out["graphs"]} + assert "growth_graph" in names + + async def test_an_async_graph_stays_a_token_until_asked( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "chat.stats.get", {"chat": str(CHANNEL_ID)}) + growth = next(g for g in out["graphs"] if g["name"] == "growth_graph") + assert growth["token"] == "growth-token" and "json" not in growth + + async def test_load_graphs_resolves_every_token( + self, live_daemon, client, in_thread, admin_world + ): + out = await result( + client, in_thread, "chat.stats.get", {"chat": str(CHANNEL_ID), "load_graphs": True} + ) + growth = next(g for g in out["graphs"] if g["name"] == "growth_graph") + assert growth["json"] == {"columns": ["x"]} + assert admin_world.called("LoadAsyncGraphRequest") + + async def test_out_writes_the_specs_to_files( + self, live_daemon, client, in_thread, admin_world, tmp_path + ): + out = await result( + client, + in_thread, + "chat.stats.get", + {"chat": str(CHANNEL_ID), "out": str(tmp_path / "graphs")}, + ) + written = [g for g in out["graphs"] if g.get("path")] + assert written and (tmp_path / "graphs").is_dir() + + async def test_a_basic_group_has_no_statistics( + self, live_daemon, client, in_thread, admin_world + ): + assert ( + await fails(client, in_thread, "chat.stats.get", {"chat": str(BASIC_ID)}) == EXIT_USAGE + ) + + async def test_public_forwards_need_a_target(self, live_daemon, client, in_thread, admin_world): + assert ( + await fails(client, in_thread, "chat.stats.list", {"chat": str(CHANNEL_ID)}) + == EXIT_USAGE + ) + + async def test_public_forwards_come_back_as_rows( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result( + client, in_thread, "chat.stats.list", {"chat": str(CHANNEL_ID), "message": 918} + ) + assert rows[0]["msg_id"] == 12 + + async def test_the_stats_alias_still_resolves( + self, live_daemon, client, in_thread, admin_world + ): + out = await result(client, in_thread, "stats.get", {"chat": str(CHANNEL_ID)}) + assert out["type"] == "broadcast" + + +class TestRevenue: + async def test_revenue_is_read_only_and_says_so( + self, live_daemon, client, in_thread, admin_world + ): + envelope = await call(client, in_thread, "chat.revenue.get", {"chat": str(CHANNEL_ID)}) + assert envelope["result"]["overall_revenue"] == 900 + assert any("official client" in w for w in envelope["meta"]["warnings"]) + + async def test_transactions_come_back_as_rows( + self, live_daemon, client, in_thread, admin_world + ): + rows = await result(client, in_thread, "chat.revenue.list", {"chat": str(CHANNEL_ID)}) + assert rows[0]["amount"] == 50 and rows[0]["title"] == "Subscription" + + +class TestBoosts: + async def test_status_carries_the_boost_url(self, live_daemon, client, in_thread, admin_world): + out = await result(client, in_thread, "boost.get", {"chat": str(CHANNEL_ID)}) + assert out["boost_url"].startswith("https://t.me/boost") + + async def test_features_map_a_level_onto_a_tlgr_flag( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.tl import types + + admin_world.raw["GetAppConfigRequest"] = types.help.AppConfig( + hash=0, + config=types.JsonObject( + value=[ + types.JsonObjectValue( + key="channel_autotranslation_level_min", value=types.JsonNumber(value=3) + ) + ] + ), + ) + out = await result(client, in_thread, "boost.get", {"features": True}) + assert out["features"] == [ + { + "key": "channel_autotranslation_level_min", + "level": 3, + "unlocks": "chat setting set --autotranslate", + } + ] + + async def test_boost_get_needs_a_chat_or_features( + self, live_daemon, client, in_thread, admin_world + ): + assert await fails(client, in_thread, "boost.get", {}) == EXIT_USAGE + + async def test_my_slots_report_their_cooldown( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.tl import types + + admin_world.my_boosts = [ + types.MyBoost( + slot=1, date=datetime.now(timezone.utc), expires=datetime.now(timezone.utc) + ) + ] + rows = await result(client, in_thread, "boost.list", {"mine": True}) + assert rows[0]["slot"] == 1 + + async def test_boosting_spends_the_free_slots( + self, live_daemon, client, in_thread, admin_world + ): + from telethon.tl import types + + admin_world.my_boosts = [ + types.MyBoost( + slot=7, date=datetime.now(timezone.utc), expires=datetime.now(timezone.utc) + ) + ] + out = await result(client, in_thread, "boost.add", {"chat": str(CHANNEL_ID)}) + assert out["slots"] == [7] + assert admin_world.called("ApplyBoostRequest")[-1].slots == [7] + + async def test_no_free_slot_is_not_found(self, live_daemon, client, in_thread, admin_world): + admin_world.my_boosts = [] + code = await fails(client, in_thread, "boost.add", {"chat": str(CHANNEL_ID)}) + assert code == EXIT_NOT_FOUND + + async def test_the_boost_apply_alias_resolves( + self, live_daemon, client, in_thread, admin_world + ): + from tlgr.registry import ALIASES + + assert ALIASES["boost.apply"] == "boost.add" + assert ALIASES["premium.boost.apply"] == "boost.add" + + async def test_listing_a_chats_boosters(self, live_daemon, client, in_thread, admin_world): + from telethon.tl import types + + admin_world.boosts[CHANNEL_ID] = [ + types.Boost( + id="b1", + date=datetime.now(timezone.utc), + expires=datetime.now(timezone.utc), + user_id=ALICE, + ) + ] + rows = await result(client, in_thread, "boost.list", {"chat": str(CHANNEL_ID)}) + assert rows[0]["user_id"] == ALICE diff --git a/tests/test_parity.py b/tests/test_parity.py index a99f513..b5b49d0 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 = 136 +P0_FLOOR = 148 #: The floor for total covered ids. Same rule, weaker guarantee. -COVERED_FLOOR = 1132 +COVERED_FLOOR = 1285 #: 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 @@ -244,15 +244,71 @@ ) +#: The P0 ids PR-7's own operations cover. They share the `chat.` prefix with +#: PR-3's, so they are told apart by the module the implementation lives in — +#: a swap between the two groups must not pass a count check either. +PR7_MODULES = frozenset( + { + "chat_admin", + "chat_extra", + "chat_invite", + "chat_manage", + "chat_member", + "chat_stats", + "chat_topic", + } +) + +PR7_P0_IDS = frozenset( + { + "groups-channels-admin.add-members", + "groups-channels-admin.create-basic-group", + "groups-channels-admin.create-channel", + "groups-channels-admin.create-supergroup", + "groups-channels-admin.invite-link-create", + "groups-channels-admin.invite-link-primary", + "groups-channels-admin.join-by-invite", + "groups-channels-admin.join-by-username", + "groups-channels-admin.members-list", + "groups-channels-admin.remove-member", + "groups-channels-admin.topic-list", + "groups-channels-admin.topic-messages", + } +) + + +def _module_of(spec) -> str: + return spec.impl.__module__.rsplit(".", 1)[-1] + + +def _by_prefix(*prefixes: str): + """The usual case: a group owns whole nouns, so the id prefix names it.""" + return lambda op_id, spec: op_id.startswith(prefixes) + + +def _chat_group(op_id, spec) -> bool: + """PR-3's half of `chat`: everything the admin modules do not implement.""" + return op_id.startswith(("chat.", "folder.")) and _module_of(spec) not in PR7_MODULES + + +def _admin_group(op_id, spec) -> bool: + """PR-7's half. It shares the `chat.` prefix, so the module tells them apart.""" + return _module_of(spec) in PR7_MODULES + + +#: Who owns which P0 ids, as a selector over the registry and the named set it +#: must equal exactly. Two entries select by module rather than by prefix, +#: because `chat.` is shared between the dialog group and the admin group. P0_OWNERS = ( - (("message.", "draft."), PR1_P0_IDS), - (("auth.", "account.", "passport."), PR2_P0_IDS), - (("chat.", "folder."), PR3_P0_IDS), - (("contact.", "user.", "resolve."), PR5_P0_IDS), - (PR4_GROUPS, PR4_P0_IDS), - (("media.", "sticker.", "gif.", "emoji."), PR6_P0_IDS), - (("poll.", "reaction.", "todo.", "location.", "search."), PR9_P0_IDS), - (("call.", "vc.", "conference."), PR11_P0_IDS), + ("pr1", _by_prefix("message.", "draft."), PR1_P0_IDS), + ("pr2", _by_prefix("auth.", "account.", "passport."), PR2_P0_IDS), + ("pr3", _chat_group, PR3_P0_IDS), + ("pr5", _by_prefix("contact.", "user.", "resolve."), PR5_P0_IDS), + ("pr4", _by_prefix(*PR4_GROUPS), PR4_P0_IDS), + ("pr6", _by_prefix("media.", "sticker.", "gif.", "emoji."), PR6_P0_IDS), + ("pr7", _admin_group, PR7_P0_IDS), + ("pr9", _by_prefix("poll.", "reaction.", "todo.", "location.", "search."), PR9_P0_IDS), + ("pr11", _by_prefix("call.", "vc.", "conference."), PR11_P0_IDS), ) @@ -318,17 +374,25 @@ def test_total_coverage_does_not_regress(self, report): def test_every_p0_id_this_pr_owns_is_covered(self): owned: set[str] = set() - for _, expected in P0_OWNERS: + for _, _selects, expected in P0_OWNERS: owned |= set(expected) missing = sorted(owned - _covered_ids()) assert missing == [], f"a landed PR dropped coverage of {missing}" + def test_every_p0_id_the_chat_group_owns_is_covered(self): + missing = sorted(PR3_P0_IDS - _covered_ids()) + assert missing == [], f"PR-3 dropped coverage of {missing}" + + def test_every_p0_id_the_admin_group_owns_is_covered(self): + missing = sorted(PR7_P0_IDS - _covered_ids()) + assert missing == [], f"PR-7 dropped coverage of {missing}" + @pytest.mark.parametrize( - "prefixes,expected", - P0_OWNERS, - ids=["pr1", "pr2", "pr3", "pr5", "pr4", "pr6", "pr9", "pr11"], + "selects,expected", + [(selects, expected) for _, selects, expected in P0_OWNERS], + ids=[name for name, _, _ in P0_OWNERS], ) - def test_the_floor_is_the_whole_truth(self, prefixes, expected): + def test_the_floor_is_the_whole_truth(self, selects, expected): """Each named list is exactly the P0 set its own groups claim. A floor that is a subset is a floor with holes in it: an op could drop @@ -340,7 +404,7 @@ def test_the_floor_is_the_whole_truth(self, prefixes, expected): actual = { cid for op_id, spec in REGISTRY.items() - if op_id.startswith(prefixes) + if selects(op_id, spec) for cid in (*spec.covers, *spec.covers_partial) if cid in catalogue and catalogue[cid].priority == "P0" } @@ -349,7 +413,7 @@ def test_the_floor_is_the_whole_truth(self, prefixes, expected): def test_the_floor_is_the_sum_of_what_the_landed_prs_own(self): """The floor is not a number somebody typed: it is those lists, added up.""" named: set[str] = set() - for _, expected in P0_OWNERS: + for _, _selects, expected in P0_OWNERS: named |= set(expected) assert len(named) == P0_FLOOR @@ -425,6 +489,15 @@ def test_contacts_users_is_fully_accounted_for(self, report): def test_the_contacts_users_domain_is_no_longer_waived_wholesale(self): assert "contacts_users" not in waivers().domains + def test_groups_channels_admin_is_fully_accounted_for(self, report): + """PR-7's own domain: implemented, or waived to a named later PR.""" + stats = report.by_domain["groups_channels_admin"] + assert stats["accounted_percent"] == 100.0 + assert stats["covered"] >= 150 + + def test_the_groups_channels_admin_domain_is_no_longer_waived_wholesale(self): + assert "groups_channels_admin" not in waivers().domains + class TestReport: def test_the_excluded_set_is_the_documented_one(self, report): diff --git a/tests/test_registry_contract.py b/tests/test_registry_contract.py index ee97c5e..1cb168f 100644 --- a/tests/test_registry_contract.py +++ b/tests/test_registry_contract.py @@ -190,8 +190,19 @@ def test_importing_the_package_registers_every_module(self): assert declared <= set(REGISTRY), f"{name}: {sorted(declared - set(REGISTRY))}" def test_every_registered_op_comes_from_a_discovered_module(self): - groups = {spec.id.split(".", 1)[0] for spec in SPECS} - assert groups <= set(tlgr.ops.op_module_names()) + """Checked against the module a spec's `impl` lives in, not its noun. + + The first segment of the id was a usable proxy only while every module + owned exactly one noun. `chat_stats` registers `chat stats`, `chat + revenue` *and* `boost`, because they read the same statistics DC — so + the proxy would fail on a layout that is correct. The module the + implementation is defined in is the thing discovery actually finds. + """ + discovered = set(tlgr.ops.op_module_names()) + for spec in SPECS: + module = spec.impl.__module__ + assert module.startswith("tlgr.ops."), f"{spec.id} is implemented in {module}" + assert module.rsplit(".", 1)[-1] in discovered, f"{spec.id} came from {module}" class TestTreeShape: diff --git a/tests/test_transport.py b/tests/test_transport.py index 935ef80..1f5b02e 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -204,8 +204,8 @@ async def test_legacy_requests_go_through_the_same_transport(live_daemon, tlgr_h await in_thread( ipc_request, "GET", - "/chat/members", - params={"chat": TRICKY, "account": "nope"}, + "/profile/get", + params={"account": "nope"}, base=tlgr_home, ) # Classified, not the flat 404/IPC_ERROR v1 returned for every reason a diff --git a/tlgr/cli/__init__.py b/tlgr/cli/__init__.py index d1213f6..05a66c5 100644 --- a/tlgr/cli/__init__.py +++ b/tlgr/cli/__init__.py @@ -230,7 +230,6 @@ def cli( # --------------------------------------------------------------------------- from tlgr.cli.gen import build_click_tree # noqa: E402 -from tlgr.cli.legacy.chat import chat_create, chat_members # noqa: E402 from tlgr.cli.legacy.profile import profile_group # noqa: E402 cli.add_command(profile_group, "profile") @@ -244,13 +243,10 @@ def cli( #: Commands that still live in `cli/legacy` *inside* a group the registry now #: generates. Each entry is a promise to delete, and an enumerated list is #: the only kind of overlap that is a decision rather than an accident. PR-2 -#: took `agent whoami` out of it, PR-4 took `daemon` and `job`; what is left -#: is the sanctioned overlap for the group PRs still to come. -LEGACY_EXTRAS: dict[str, list[click.Command]] = { - # `chat create` and `chat members` are member/admin operations and - # migrate with the groups-and-channels group (PR-7). - "chat": [chat_members, chat_create], -} +#: took `agent whoami` out of it, PR-4 took `daemon` and `job`, PR-7 took +#: `chat create` and `chat members`. Nothing is left: the dict is empty and +#: stays that way unless a future migration needs the same escape hatch. +LEGACY_EXTRAS: dict[str, list[click.Command]] = {} def build_cli() -> click.Group: diff --git a/tlgr/cli/legacy/chat.py b/tlgr/cli/legacy/chat.py deleted file mode 100644 index 7f6566c..0000000 --- a/tlgr/cli/legacy/chat.py +++ /dev/null @@ -1,86 +0,0 @@ -"""What is left of v1's `chat` group. - -Every dialog-level command — `list`, `open`, `catchup`, `read`, `unread`, -`get`, `archive`, `mute`, `leave`, `typing`, `posters` and the `chats` / -`inbox` / `catchup` shortcuts — is generated from the registry now (PR-3), -and the v1 spellings survive as `legacy_paths` on those specs. - -`create` and `members` are member-and-admin operations, so they migrate with -the groups/channels group (PR-7). Until then they stay here and are attached -into the *generated* `chat` group by `build_cli()`, which is the one -sanctioned overlap: enumerated in LEGACY_EXTRAS rather than a group defined -in two places. -""" - -from __future__ import annotations - -import click - -from tlgr.cli.legacy._common import resolve_account -from tlgr.core.output import emit -from tlgr.ipc_client import ipc_request - - -@click.command("members") -@click.argument("chat") -@click.option("--admins", is_flag=True, help="Only admins and the creator.") -@click.option("--search", "-s", default=None, help="Filter by name (server-side).") -@click.option("--limit", "-n", type=int, default=None) -@click.option("--account", "-a", default=None) -@click.pass_context -def chat_members( - ctx: click.Context, - chat: str, - admins: bool, - search: str | None, - limit: int | None, - account: str | None, -) -> None: - """List members of a group or channel.""" - acct = resolve_account(ctx, account) - params: dict[str, object] = {"chat": chat, "account": acct} - if admins: - params["admins"] = 1 - if search: - params["search"] = search - if limit: - params["limit"] = limit - result = ipc_request("GET", "/chat/members", params=params) - fmt = ctx.obj.get("fmt", "human") - if fmt == "json": - emit(ctx.obj, result) - else: - emit( - ctx.obj, - result.get("members", []), - columns=["id", "first_name", "last_name", "username", "is_bot"], - headers=["ID", "First", "Last", "Username", "Bot"], - ) - - -@click.command("create") -@click.argument("name") -@click.option("--type", "chat_type", default="group", type=click.Choice(["group", "channel"])) -@click.option("--members", multiple=True, help="Users to add.") -@click.option("--account", "-a", default=None) -@click.pass_context -def chat_create( - ctx: click.Context, - name: str, - chat_type: str, - members: tuple[str, ...], - account: str | None, -) -> None: - """Create a new group or channel.""" - acct = resolve_account(ctx, account) - result = ipc_request( - "POST", - "/chat/create", - body={ - "name": name, - "type": chat_type, - "members": list(members), - "account": acct, - }, - ) - emit(ctx.obj, result) diff --git a/tlgr/core/client.py b/tlgr/core/client.py index 90dc208..b6dcef9 100644 --- a/tlgr/core/client.py +++ b/tlgr/core/client.py @@ -419,63 +419,6 @@ async def react_to_message(self, chat_id: int | str, msg_id: int, emoji: str) -> return {"reacted": True, "msg_id": msg_id, "emoji": emoji, "already": True} return {"reacted": True, "msg_id": msg_id, "emoji": emoji, "already": False} - async def list_participants( - self, - chat_id: int | str, - *, - limit: int | None = None, - admins_only: bool = False, - search: str | None = None, - ) -> list[dict[str, Any]]: - from telethon.tl.types import ChannelParticipantsAdmins - - kwargs: dict[str, Any] = {} - if admins_only: - kwargs["filter"] = ChannelParticipantsAdmins - if search: - kwargs["search"] = search - users: list[dict[str, Any]] = [] - async for u in self.client.iter_participants(chat_id, limit=limit, **kwargs): - if not isinstance(u, User): - continue - users.append( - { - "id": u.id, - "first_name": u.first_name or "", - "last_name": u.last_name or "", - "username": u.username, - "is_bot": bool(u.bot), - "is_deleted": bool(u.deleted), - "is_contact": bool(u.contact), - "is_self": bool(u.is_self), - } - ) - return users - - async def create_chat( - self, - name: str, - *, - chat_type: str = "group", - members: list[str] | None = None, - ) -> dict[str, Any]: - if chat_type == "channel": - from telethon.tl.functions.channels import CreateChannelRequest - - result = await self.client( - CreateChannelRequest( - title=name, - about="", - megagroup=False, - ) - ) - ch = result.chats[0] - return {"id": utils.get_peer_id(ch), "name": name, "type": "channel"} - else: - users = members or [] - result = await self.client.create_group(name, users) - return {"id": result.id if hasattr(result, "id") else 0, "name": name, "type": "group"} - async def get_profile(self) -> dict[str, Any]: me = await self.client.get_me() return { diff --git a/tlgr/daemon/ipc.py b/tlgr/daemon/ipc.py index c72fcd8..c97f8d9 100644 --- a/tlgr/daemon/ipc.py +++ b/tlgr/daemon/ipc.py @@ -117,8 +117,6 @@ def _register_routes(self, app: web.Application) -> None: # through `legacy_paths` (§12.4). # Chats - app.router.add_post("/chat/create", self._chat_create) - app.router.add_get("/chat/members", self._chat_members) # Contacts @@ -130,41 +128,6 @@ def _register_routes(self, app: web.Application) -> None: # Media - # -- Chats -- - - async def _chat_create(self, request: web.Request) -> web.Response: - body = await _get_body(request) - account = body.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - result = await client.create_chat( - body["name"], - chat_type=body.get("type", "group"), - members=body.get("members"), - ) - return _json_response(result) - except Exception as e: - return _handle_exception(e) - - async def _chat_members(self, request: web.Request) -> web.Response: - q = request.query - account = q.get("account", "") - client = await self.daemon.ensure_client(account) - if not client: - return _no_client(account) - try: - members = await client.list_participants( - _ref(q["chat"]), - limit=int(q["limit"]) if q.get("limit") else None, - admins_only=q.get("admins") == "1", - search=q.get("search"), - ) - return _json_response({"members": members}) - except Exception as e: - return _handle_exception(e) - # -- Profile -- async def _profile_get(self, request: web.Request) -> web.Response: diff --git a/tlgr/data/parity_waivers.toml b/tlgr/data/parity_waivers.toml index bd9fbf7..49cfb12 100644 --- a/tlgr/data/parity_waivers.toml +++ b/tlgr/data/parity_waivers.toml @@ -17,11 +17,6 @@ final_pr = 12 # Whole domains that no PR has migrated yet. Each becomes its own group PR. # --------------------------------------------------------------------------- -[[domain]] -name = "groups_channels_admin" -pr = 7 -reason = "chat member/admin/invite/topic/permission land in PR-7." - [[domain]] name = "stories" pr = 8 @@ -42,6 +37,50 @@ reason = "profile, privacy, notify, settings, business, premium, gift and stars # matter: each is a decision, not a backlog entry. # --------------------------------------------------------------------------- +# The groups_channels_admin ids PR-7 does not own. The domain-wide waiver is +# gone: every id left in it names the group that owns it, which is what makes +# "the groups-and-channels group is done" checkable rather than asserted. +[[id]] +id = "groups-channels-admin.channel-subscription-manage" +pr = 12 +reason = "My own paid subscriptions are `stars subscription *` (PR-12); the admin side is `chat invite list`." + +[[id]] +id = "groups-channels-admin.personal-channel" +pr = 12 +reason = "Showing a channel on my profile is `profile set --channel` (PR-12)." + +[[id]] +id = "groups-channels-admin.gift-code-redeem" +pr = 12 +reason = "Gift codes are the `gift` noun (PR-12); `boost list --gifts` finds the winners." + +[[id]] +id = "groups-channels-admin.giveaway-info" +pr = 12 +reason = "`giveaway *` lands with gifts and Stars (PR-12); `boost get` reports the prepaid ones." + +[[id]] +id = "groups-channels-admin.giveaway-prepaid-launch" +pr = 12 +reason = "Launching a giveaway is `giveaway launch` (PR-12); `boost get` reports the prepaid slots it spends." + +[[id]] +id = "groups-channels-admin.hide-peer-stories" +pr = 8 +reason = "Hiding a peer's stories is `story hide` (PR-8); `user hide-stories` already does the user half." + +[[id]] +id = "groups-channels-admin.stories-as-channel" +pr = 8 +reason = "Posting as a channel is the story group (PR-8); the admin rights that gate it are `chat admin promote --rights post-stories`." + +[[id]] +id = "groups-channels-admin.report-reaction" +pr = 9 +reason = "Reporting a reaction is `reaction report` (PR-9)." + + # The dialogs_chats ids PR-3 does not own. Each names the group that does. [[id]] id = "dialogs.bot-stop-restart" diff --git a/tlgr/models/__init__.py b/tlgr/models/__init__.py index da1313a..1f9b6e3 100644 --- a/tlgr/models/__init__.py +++ b/tlgr/models/__init__.py @@ -2,6 +2,65 @@ from __future__ import annotations +from tlgr.models.admin import ( + AdminLogEvent, + AdminResult, + AffiliateBot, + AffiliateResult, + AntiSpamReport, + Boost, + BoostApplied, + BoostStatus, + ChatEditResult, + ChatPhotoResult, + ChatStats, + CommunityResult, + CommunityRow, + CreatedChat, + DirectBanResult, + DirectDialog, + DiscussionCandidate, + DiscussionResult, + Graph, + Invite, + InviteDeleted, + InviteInfo, + InvitePeek, + InviteRevoked, + JoinRequest, + JoinResult, + MemberResult, + MembersAdded, + MigrateResult, + MissingInvitee, + Participant, + PermissionResult, + PermissionView, + PublicForward, + RequestResult, + RevenueSummary, + RevenueTransaction, + RightInfo, + SendAsPeer, + SendAsResult, + SettingResult, + SettingsView, + SimilarChat, + SponsoredReport, + StatValue, + SuggestedPostResult, + SuggestionResult, + Topic, + TopicPinResult, + TopicReadResult, + TopicResult, + TransferResult, + UsernameCheck, + UsernameResult, + VerificationResult, + WelcomeMessage, + WelcomeResult, +) from tlgr.models.auth import ( AccountDeletion, AccountRecord, @@ -381,6 +440,11 @@ "AccountTtl", "ActionBar", "ActiveCall", + "AdminLogEvent", + "AdminResult", + "AffiliateBot", + "AffiliateResult", + "AntiSpamReport", "AppConfigDoc", "ArchiveResult", "ArchiveSettings", @@ -398,6 +462,9 @@ "BlockResult", "BlockedPeer", "BlockedSet", + "Boost", + "BoostApplied", + "BoostStatus", "Button", "CachedPeerRow", "Call", @@ -419,8 +486,11 @@ "ChainBlock", "ChannelState", "Chat", + "ChatEditResult", "ChatInfo", + "ChatPhotoResult", "ChatReactions", + "ChatStats", "ChatSwitches", "ChatTheme", "ChatWallpaper", @@ -429,6 +499,8 @@ "ChatlistUpdates", "ClearResult", "CloseFriends", + "CommunityResult", + "CommunityRow", "ComposeResult", "ConferenceCreated", "ConferenceDeclined", @@ -454,6 +526,7 @@ "ContentSettingsSaved", "Country", "CountryCode", + "CreatedChat", "DaemonStatus", "DcOption", "DeadLetter", @@ -466,6 +539,10 @@ "DialogStatus", "DiceCatalog", "DifferenceResult", + "DirectBanResult", + "DirectDialog", + "DiscussionCandidate", + "DiscussionResult", "Downloaded", "Draft", "DraftCleared", @@ -501,6 +578,7 @@ "GifResult", "GifSaved", "GifSent", + "Graph", "GroupCall", "GroupCallCreated", "GroupCallEnded", @@ -519,9 +597,16 @@ "InCallMessagesDeleted", "InfoTopic", "InitResult", + "Invite", + "InviteDeleted", + "InviteInfo", + "InvitePeek", + "InviteRevoked", "Job", "JobState", "JobTestFrame", + "JoinRequest", + "JoinResult", "LeaveResult", "LifecycleResult", "LinkKind", @@ -545,11 +630,15 @@ "MediaRead", "MediaSize", "MediaSummary", + "MemberResult", + "MembersAdded", "Message", "MessageEntity", "MessageRange", "MessageReactionState", "Meta", + "MigrateResult", + "MissingInvitee", "Model", "MusicTrack", "MuteResult", @@ -575,6 +664,7 @@ "PaidMessageSettings", "PaidPost", "PaidReactionResult", + "Participant", "ParticipantRemoved", "Passkey", "PassportDeletion", @@ -589,6 +679,8 @@ "PeerRef", "PeerRefKind", "PeerResult", + "PermissionResult", + "PermissionView", "PersonalChannel", "PhoneChange", "PhoneShared", @@ -610,6 +702,7 @@ "ProxyLink", "ProxyProbe", "ProxySelection", + "PublicForward", "QrLogin", "RaisedHand", "ReactionPrivacy", @@ -630,11 +723,15 @@ "ReplyMarkup", "ReportResult", "Request", + "RequestResult", "ResetResult", "ResolvedLink", "ResolvedPhone", "ResolvedRef", "ResolvedUsername", + "RevenueSummary", + "RevenueTransaction", + "RightInfo", "Rights", "RtmpInfo", "SaveStateResult", @@ -644,6 +741,8 @@ "SavedState", "ScheduledSent", "SecretChat", + "SendAsPeer", + "SendAsResult", "SentCode", "SentLocation", "ServerConfig", @@ -652,11 +751,16 @@ "Session", "SessionChange", "SessionTermination", + "SettingResult", + "SettingsView", "ShareDeleted", "SignUp", + "SimilarChat", "SmsJobs", "SponsoredHidden", "SponsoredMessage", + "SponsoredReport", + "StatValue", "Sticker", "StickerSet", "StickerSetOrder", @@ -669,8 +773,10 @@ "StreamDownload", "SuggestedBirthday", "SuggestedFolder", + "SuggestedPostResult", "SuggestedPostState", "Suggestion", + "SuggestionResult", "SummaryResult", "SupportInfo", "SyncCursors", @@ -686,9 +792,14 @@ "TopPeer", "TopPeerState", "TopReactor", + "Topic", + "TopicPinResult", + "TopicReadResult", + "TopicResult", "Transcription", "Transfer", "TransferRestarted", + "TransferResult", "TransferStopped", "TranslateResult", "Translation", @@ -702,9 +813,12 @@ "UserProfile", "UserRef", "UserStatus", + "UsernameCheck", + "UsernameResult", "ValidationIssue", "ValidationReport", "Venue", + "VerificationResult", "VideoState", "ViewCount", "VolumeState", @@ -719,6 +833,8 @@ "WebSessionRevocation", "WebhookProbe", "WebhookSettings", + "WelcomeMessage", + "WelcomeResult", "decode", "encode", "parse_message_link", diff --git a/tlgr/models/admin.py b/tlgr/models/admin.py new file mode 100644 index 0000000..8df4e91 --- /dev/null +++ b/tlgr/models/admin.py @@ -0,0 +1,744 @@ +"""Group and channel administration: members, invites, topics, logs, stats. + +The shapes here are what the `chat member/admin/invite/topic/permission`, +`chat setting`, `chat stats` and `boost` operations answer with. Three +decisions run through the whole file. + +* **A participant is never flattened into a user.** v1's `chat members` + returned `{id, first_name, username, is_bot}` and threw away the + `ChannelParticipant*` wrapper, so "who promoted this admin", "when did they + join", "what may they do" and "are they banned or merely restricted" were + all unanswerable. `Participant` keeps the wrapper: `status`, `rank`, + `date`, `inviter_id`, `promoted_by`, `kicked_by` and both rights masks. +* **Rights are always allow-polarity.** Telegram stores banned rights + inverted (`send_messages=True` means *cannot* send). `Rights` normalises + once, in `models/peer.py`, so every mask in this file reads the same way + and `chat permission get` round-trips into `chat permission set --allow`. +* **A graph is emitted verbatim.** `stats.*` answers with Telegram's own + chart specification; tlgr reports it as the API's JSON and never tries to + draw it, because a redrawn chart is a chart that can be wrong. +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.models.base import Model +from tlgr.models.message import Message +from tlgr.models.peer import Peer, Rights + +__all__ = [ + "AdminLogEvent", + "AdminResult", + "AffiliateBot", + "AffiliateResult", + "AntiSpamReport", + "Boost", + "BoostApplied", + "BoostStatus", + "ChatEditResult", + "ChatPhotoResult", + "ChatStats", + "CommunityResult", + "CommunityRow", + "CreatedChat", + "DirectBanResult", + "DirectDialog", + "DiscussionCandidate", + "DiscussionResult", + "Graph", + "Invite", + "InviteDeleted", + "InviteInfo", + "InvitePeek", + "InviteRevoked", + "JoinRequest", + "JoinResult", + "MemberResult", + "MembersAdded", + "MigrateResult", + "MissingInvitee", + "Participant", + "PermissionResult", + "PermissionView", + "PublicForward", + "RequestResult", + "RevenueSummary", + "RevenueTransaction", + "RightInfo", + "SendAsPeer", + "SendAsResult", + "SettingResult", + "SettingsView", + "SimilarChat", + "SponsoredReport", + "StatValue", + "SuggestedPostResult", + "SuggestionResult", + "Topic", + "TopicPinResult", + "TopicReadResult", + "TopicResult", + "TransferResult", + "UsernameCheck", + "UsernameResult", + "VerificationResult", + "WelcomeMessage", + "WelcomeResult", +] + + +# --------------------------------------------------------------------------- +# Rights vocabulary +# --------------------------------------------------------------------------- + + +class RightInfo(Model): + """One entry of the canonical right vocabulary (`chat permission list`). + + `supported` is False for a flag the installed Telethon layer cannot + express; naming it anyway is what lets an agent discover that the gap + exists instead of guessing why a right was silently dropped. + """ + + name: str + mask: str + tl_flag: str + polarity: str + supported: bool = True + since_layer: int | None = None + peer_types: list[str] = [] + grantable: bool | None = None + summary: str = "" + + +# --------------------------------------------------------------------------- +# Members and admins +# --------------------------------------------------------------------------- + + +class Participant(Model): + """One row of a member listing, with the participant wrapper intact.""" + + id: int + user_id: int = 0 + chat_id: int = 0 + peer: Peer | None = None + username: str | None = None + name: str = "" + is_bot: bool = False + #: creator | admin | member | self | restricted | banned | left + status: str = "member" + rank: str | None = None + date: str | None = None + date_unix: int | None = None + inviter_id: int | None = None + promoted_by: int | None = None + kicked_by: int | None = None + via_request: bool | None = None + subscription_until_date: str | None = None + admin_rights: Rights | None = None + banned_rights: Rights | None = None + effective_permissions: Rights | None = None + can_edit: bool | None = None + #: Only on `--via-link` rows: the invite the member came through. + via_link: str | None = None + approved_by: int | None = None + about: str | None = None + + +class MissingInvitee(Model): + """Someone `messages.invitedUsers` refused to add, and why. + + Reported verbatim rather than dropped: "added 3 of 5" with no names is + not an answer anybody can act on. + """ + + user_id: int + #: privacy-restricted | premium-would-allow-invite | premium-required-for-pm + reason: str + + +class MembersAdded(Model): + chat_id: int + added: list[int] = [] + missing: list[MissingInvitee] = [] + invited_by_link: list[int] = [] + + +class MemberResult(Model): + """The answer to a moderation verb applied to one member.""" + + chat_id: int + user_id: int + removed: bool | None = None + banned: bool | None = None + until: str | None = None + until_unix: int | None = None + allow: list[str] = [] + deny: list[str] = [] + purged_messages: int | None = None + deleted: int | None = None + reported: bool | None = None + rank: str | None = None + free_messages: bool | None = None + already: bool = False + + +class AdminResult(Model): + chat_id: int + user_id: int + admin_rights: Rights | None = None + rank: str | None = None + dropped: list[str] = [] + already: bool = False + + +class PermissionView(Model): + chat_id: int + allow: list[str] = [] + deny: list[str] = [] + rights: Rights | None = None + + +class PermissionResult(Model): + chat_id: int + allow: list[str] = [] + deny: list[str] = [] + changed: bool = False + already: bool = False + + +# --------------------------------------------------------------------------- +# Invites and join requests +# --------------------------------------------------------------------------- + + +class Invite(Model): + link: str + title: str | None = None + permanent: bool = False + revoked: bool = False + request_needed: bool = False + admin_id: int | None = None + date: str | None = None + date_unix: int | None = None + start_date: str | None = None + expire_date: str | None = None + usage_limit: int | None = None + usage: int | None = None + requested: int | None = None + subscription_pricing: dict[str, int] | None = None + subscription_expired: int | None = None + replaced_link: str | None = None + #: `--by-admin` rows: how many links this admin has made, and revoked. + invites_count: int | None = None + revoked_invites_count: int | None = None + + +class InviteInfo(Invite): + """`chat invite get`: our own link, or a preview of somebody else's.""" + + chat: Peer | None = None + chat_title: str = "" + members_count: int | None = None + already_member: bool | None = None + peek_expires: str | None = None + about: str | None = None + public: bool | None = None + qr: str | None = None + png: str | None = None + + +class InviteDeleted(Model): + chat_id: int + deleted: int = 0 + + +class InviteRevoked(Model): + link: str + revoked: bool = True + replaced_link: str | None = None + + +class JoinRequest(Model): + user_id: int + username: str | None = None + name: str = "" + date: str | None = None + date_unix: int | None = None + about: str | None = None + via_link: str | None = None + approved_by: int | None = None + + +class RequestResult(Model): + chat_id: int + approved: list[int] = [] + denied: list[int] = [] + failed: list[MissingInvitee] = [] + all: bool = False + + +class InvitePeek(Model): + chat: Peer | None = None + chat_title: str = "" + peek_expires: str | None = None + messages: list[Message] = [] + + +class JoinResult(Model): + chat_id: int + title: str = "" + joined: bool = False + pending_approval: bool = False + already: bool = False + #: A layer-229 join that needs a web view tlgr cannot open. + needs_web_view: str | None = None + + +# --------------------------------------------------------------------------- +# Topics +# --------------------------------------------------------------------------- + + +class Topic(Model): + id: int + chat_id: int = 0 + title: str = "" + icon_emoji_id: int | None = None + icon_color: int | None = None + closed: bool = False + pinned: bool = False + hidden: bool = False + my: bool = False + top_message: int | None = None + unread_count: int = 0 + unread_mentions_count: int = 0 + unread_reactions_count: int = 0 + from_id: int | None = None + muted: bool | None = None + link: str | None = None + date: str | None = None + date_unix: int | None = None + deleted: bool = False + + +class TopicResult(Model): + chat_id: int + topic_id: int + title: str | None = None + icon_emoji_id: int | None = None + closed: bool | None = None + hidden: bool | None = None + deleted: bool | None = None + mute_until: str | None = None + silent: bool | None = None + previews: bool | None = None + changed: list[str] = [] + already: bool = False + + +class TopicPinResult(Model): + chat_id: int + pinned: list[int] = [] + unpinned: list[int] = [] + already: bool = False + + +class TopicReadResult(Model): + chat_id: int + topic_id: int + unread_count: int = 0 + unread_mentions_count: int = 0 + unread_reactions_count: int = 0 + max_id: int | None = None + items: list[Message] = [] + + +# --------------------------------------------------------------------------- +# The admin log +# --------------------------------------------------------------------------- + + +class AdminLogEvent(Model): + """One `channelAdminLogEvent`, normalised but never lossy. + + Telegram has 50-odd `channelAdminLogEventAction*` constructors. They are + reduced to `{action, prev, new}` so a script can switch on one string, + and `raw_type` keeps the TL name so nothing that was in the reply is + unavailable. + """ + + id: int + date: str | None = None + date_unix: int | None = None + user_id: int = 0 + action: str = "" + raw_type: str = "" + prev: Any = None + new: Any = None + chat_id: int = 0 + + +class AntiSpamReport(Model): + chat_id: int + msg_id: int + reported: bool = True + + +# --------------------------------------------------------------------------- +# Creation, editing, settings +# --------------------------------------------------------------------------- + + +class CreatedChat(Model): + id: int + type: str = "" + title: str = "" + username: str | None = None + invite_link: str | None = None + added: list[int] = [] + missing: list[MissingInvitee] = [] + + +class ChatEditResult(Model): + id: int + changed: list[str] = [] + already: bool = False + palettes: list[dict[str, Any]] = [] + + +class MigrateResult(Model): + old_chat_id: int + chat_id: int + type: str = "" + + +class TransferResult(Model): + chat_id: int + new_owner_id: int + + +class SettingsView(Model): + """Every administrable toggle, keyed the way `chat setting set` spells it.""" + + chat_id: int = 0 + slow_mode: int | None = None + prehistory: str | None = None + join_to_send: bool | None = None + join_request: bool | None = None + guard_bot: int | None = None + noforwards: bool | None = None + antispam: bool | None = None + hidden_members: bool | None = None + signatures: bool | None = None + signature_profiles: bool | None = None + forum: bool | None = None + forum_tabs: str | None = None + view_as: str | None = None + autotranslate: bool | None = None + ads: bool | None = None + reactions: str | None = None + reactions_list: list[str] = [] + reactions_limit: int | None = None + paid_reactions: bool | None = None + sticker_set: str | None = None + emoji_set: str | None = None + paid_messages_stars: int | None = None + direct_messages: bool | None = None + gift_notifications: bool | None = None + #: key → may I change it here + available: dict[str, bool] = {} + #: key → the capability flag or boost level that blocks it + gated_by: dict[str, str] = {} + + +class SettingResult(Model): + chat_id: int + changed: list[str] = [] + already: list[str] = [] + failed: dict[str, str] = {} + + +class UsernameCheck(Model): + username: str + #: available | occupied | invalid | purchasable + status: str = "" + available: bool = False + collectible: dict[str, Any] | None = None + + +class UsernameResult(Model): + chat_id: int + username: str | None = None + link: str | None = None + usernames: list[str] = [] + invite_link: str | None = None + already: bool = False + + +class ChatPhotoResult(Model): + chat_id: int + photo_id: int | None = None + ok: bool = True + + +class SendAsPeer(Model): + id: int + type: str = "" + title: str = "" + premium_required: bool = False + default: bool = False + + +class SendAsResult(Model): + chat_id: int + send_as: int + + +class DiscussionCandidate(Model): + id: int + title: str = "" + type: str = "" + needs_migration: bool = False + prehistory_hidden: bool | None = None + + +class DiscussionResult(Model): + channel_id: int + linked_chat_id: int | None = None + already: bool = False + + +class SimilarChat(Model): + id: int + title: str = "" + username: str | None = None + participants_count: int | None = None + + +class SponsoredReport(Model): + #: reported | choose-option | premium-required + result: str = "" + title: str = "" + options: list[dict[str, str]] = [] + + +class SuggestionResult(Model): + chat_id: int + key: str = "" + pending_suggestions: list[str] = [] + already: bool = False + + +class SuggestedPostResult(Model): + channel_id: int + msg_id: int + approved: bool | None = None + rejected: bool | None = None + schedule_date: str | None = None + + +class VerificationResult(Model): + chat_id: int + bot_id: int | None = None + enabled: bool = False + + +class AffiliateBot(Model): + bot_id: int + url: str = "" + commission_permille: int = 0 + duration_months: int | None = None + participants: int | None = None + revenue: int | None = None + date: str | None = None + date_unix: int | None = None + + +class AffiliateResult(Model): + chat_id: int + bot_id: int | None = None + url: str = "" + commission_permille: int = 0 + revoked: bool = False + + +class DirectDialog(Model): + saved_peer_id: int + name: str = "" + top_message: int | None = None + unread_count: int = 0 + unread_reactions_count: int = 0 + date: str | None = None + date_unix: int | None = None + nopaid_messages_exception: bool | None = None + + +class DirectBanResult(Model): + channel_id: int + monoforum_id: int | None = None + user_id: int = 0 + banned: bool = False + + +class CommunityRow(Model): + """A layer-229 community. Reserved until Telethon speaks the layer.""" + + id: int + title: str = "" + type: str = "" + visible: bool | None = None + collapsed: bool | None = None + requested_by: int | None = None + date: str | None = None + date_unix: int | None = None + chats: list[int] = [] + + +class CommunityResult(Model): + community_id: int = 0 + chat_id: int | None = None + user_id: int | None = None + title: str = "" + state: str = "" + banned: bool | None = None + linked_peers: list[int] = [] + + +class WelcomeMessage(Model): + id: int + text: str = "" + entities: list[dict[str, Any]] = [] + media: str | None = None + date: str | None = None + date_unix: int | None = None + + +class WelcomeResult(Model): + chat_id: int + id: int | None = None + text: str = "" + deleted: int = 0 + + +# --------------------------------------------------------------------------- +# Statistics, revenue and boosts +# --------------------------------------------------------------------------- + + +class StatValue(Model): + current: float = 0.0 + previous: float = 0.0 + growth: float = 0.0 + + +class Graph(Model): + """One chart. Either resolved JSON, an async token, or an error.""" + + name: str + token: str | None = None + zoom_token: str | None = None + json: Any = None + error: str | None = None + path: str | None = None + + +class ChatStats(Model): + chat_id: int = 0 + #: broadcast | megagroup | message | story | poll + type: str = "" + period: dict[str, str] = {} + followers: StatValue | None = None + views_per_post: StatValue | None = None + shares_per_post: StatValue | None = None + reactions_per_post: StatValue | None = None + enabled_notifications: StatValue | None = None + members: StatValue | None = None + messages: StatValue | None = None + viewers: StatValue | None = None + posters: StatValue | None = None + views: int | None = None + forwards: int | None = None + reactions: int | None = None + graphs: list[Graph] = [] + recent_posts: list[dict[str, Any]] = [] + + +class PublicForward(Model): + chat_id: int = 0 + chat_title: str = "" + msg_id: int | None = None + story_id: int | None = None + views: int | None = None + date: str | None = None + date_unix: int | None = None + + +class RevenueSummary(Model): + chat_id: int + currency: str = "stars" + current_balance: int = 0 + available_balance: int = 0 + overall_revenue: int = 0 + withdrawal_enabled: bool = False + next_withdrawal_at: str | None = None + usd_rate: float | None = None + graphs: list[Graph] = [] + from_user_revenue: int | None = None + + +class RevenueTransaction(Model): + id: str + date: str | None = None + date_unix: int | None = None + amount: int = 0 + currency: str = "stars" + peer: str = "" + title: str = "" + refund: bool = False + pending: bool = False + failed: bool = False + subscription_period: int | None = None + + +class Boost(Model): + id: str = "" + slot: int | None = None + user_id: int | None = None + chat_id: int | None = None + gift: bool = False + giveaway: bool = False + unclaimed: bool = False + multiplier: int | None = None + stars: int | None = None + date: str | None = None + date_unix: int | None = None + expires: str | None = None + cooldown_until_date: str | None = None + + +class BoostStatus(Model): + chat_id: int = 0 + level: int = 0 + boosts: int = 0 + current_level_boosts: int = 0 + next_level_boosts: int | None = None + premium_audience: dict[str, float] | None = None + boost_url: str = "" + my_boost: bool = False + boosts_applied: int | None = None + prepaid_giveaways: list[dict[str, Any]] = [] + features: list[dict[str, Any]] = [] + + +class BoostApplied(Model): + chat_id: int + level: int = 0 + boosts: int = 0 + my_boost: bool = True + already: bool = False + peer: str = "" + slots: list[int] = [] + cooldown_until_date: str | None = None diff --git a/tlgr/models/peer.py b/tlgr/models/peer.py index 3a12ae3..5cdd6f7 100644 --- a/tlgr/models/peer.py +++ b/tlgr/models/peer.py @@ -151,8 +151,10 @@ class Rights(Model): edit_stories: bool | None = None delete_stories: bool | None = None manage_direct_messages: bool | None = None + manage_ranks: bool | None = None anonymous: bool | None = None other: bool | None = None + view_messages: bool | None = None send_messages: bool | None = None send_media: bool | None = None send_photos: bool | None = None @@ -167,8 +169,11 @@ class Rights(Model): send_inline: bool | None = None send_polls: bool | None = None send_plain: bool | None = None + send_reactions: bool | None = None embed_links: bool | None = None + edit_rank: bool | None = None until: str | None = None + until_unix: int | None = None class Chat(Model): diff --git a/tlgr/ops/_admin.py b/tlgr/ops/_admin.py new file mode 100644 index 0000000..8792738 --- /dev/null +++ b/tlgr/ops/_admin.py @@ -0,0 +1,313 @@ +"""Plumbing the `chat member/admin/invite/topic/setting/stats` modules share. + +Six things every group-administration operation needs and none of them should +own: the client off the context, the page window, an `InputChannel` with a +readable error when the peer is not one, the `channelFull`/`chatFull` fetch, +the `affectedHistory` drain loop, and the participant serialiser. + +The serialiser is the load-bearing one. v1 flattened a `ChannelParticipant*` +into a plain user and lost `status`, `rank`, `date`, `inviter_id`, +`promoted_by`, `kicked_by` and both rights masks in the process — which is +why v1 could list members but could not answer "is this person banned or +merely restricted". + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +from typing import Any + +from tlgr.core.errors import NotFoundError, UsageError +from tlgr.core.pagination import PageKind, decode_cursor +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.admin import Participant +from tlgr.models.peer import Peer, Rights +from tlgr.ops import _rights +from tlgr.ops._serialize import entity_to_peer, peer_id_of +from tlgr.ops._spec import OpContext + +__all__ = [ + "GENERAL_TOPIC", + "affected_loop", + "already", + "client", + "display_name", + "entity_id", + "entity_map", + "full_chat", + "input_channel", + "input_user", + "is_channel", + "is_small_chat", + "participant_model", + "small_chat_id", + "window", +] + +#: The General topic. It always exists, cannot be deleted, and is the one id +#: `top_msg_id` must be *omitted* for. +GENERAL_TOPIC = 1 + + +def client(ctx: OpContext) -> Any: + handle = getattr(ctx, "client", None) + if handle is None: # pragma: no cover - the daemon always supplies one + raise UsageError("this operation needs a connected account") + return handle + + +def already(ctx: OpContext) -> None: + """Flag `meta.already`: the world already looked the way we were asked for.""" + mark = getattr(ctx, "mark_already", None) + if callable(mark): + mark() + + +def window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]: + """`(limit, cursor state)` — `--limit`/`--cursor` are transport-level (L5).""" + limit = int(getattr(ctx, "limit", None) or default) + if limit < 1: + raise UsageError("--limit must be at least 1", field="limit") + token = getattr(ctx, "cursor", None) + state: dict[str, Any] = {} + if token: + state = decode_cursor(token, op=op, kind=kind, account=ctx.account) + return min(limit, 200), state + + +def is_channel(peer: Any) -> bool: + return type(peer).__name__ in ("InputPeerChannel", "InputPeerChannelFromMessage") + + +def is_small_chat(peer: Any) -> bool: + """A legacy basic group — the shape with no granular rights at all.""" + return type(peer).__name__ == "InputPeerChat" + + +def small_chat_id(peer: Any) -> int: + return int(getattr(peer, "chat_id", 0) or 0) + + +def input_channel(peer: Any) -> Any: + """The `InputChannel` a `channels.*` request wants, or a usage error.""" + from telethon import utils + + try: + return utils.get_input_channel(peer) + except (TypeError, ValueError) as exc: + raise UsageError( + "this operation only works in a channel or supergroup; " + "convert a basic group with `tlgr chat convert <chat> supergroup`", + field="chat", + ) from exc + + +def input_user(peer: Any) -> Any: + """The `InputUser` a `*.editAdmin`-style request wants.""" + from telethon import utils + + try: + return utils.get_input_user(peer) + except (TypeError, ValueError) as exc: + raise UsageError("that reference does not name a user", field="user") from exc + + +async def affected_loop(ctx: OpContext, make_request: Any) -> int: + """Drive an `affectedHistory` call until `offset == 0`. + + The server answers a large history with a partial result and an offset to + resume from. Calling once and reporting success is how "delete everything + this member sent" deletes the first hundred messages. + """ + handle = client(ctx) + total = 0 + offset = 0 + for _ in range(100): + result = await handle(make_request(offset)) + total += int(getattr(result, "pts_count", 0) or 0) + offset = int(getattr(result, "offset", 0) or 0) + if offset == 0: + break + limiter = getattr(ctx, "limiter", None) + if limiter is not None: + await limiter.acquire("bulk") + return total + + +def entity_map(result: Any) -> dict[int, Any]: + """`{marked id: entity}` for the chats and users a reply carried.""" + from telethon import utils + + out: dict[int, Any] = {} + for entity in list(getattr(result, "chats", None) or []) + list( + getattr(result, "users", None) or [] + ): + try: + out[int(utils.get_peer_id(entity))] = entity + except (TypeError, ValueError): # pragma: no cover - defensive + continue + return out + + +async def full_chat(ctx: OpContext, peer: Any) -> tuple[Any, Any, dict[int, Any]]: + """`(full, entity, entities)` for a group or channel of any shape. + + Basic groups and channels answer with different requests and different + field names; every caller here wants the same three things, so the split + happens once. + """ + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + handle = client(ctx) + if is_channel(peer): + result = await handle(chan_fn.GetFullChannelRequest(channel=input_channel(peer))) + elif is_small_chat(peer): + result = await handle(msg_fn.GetFullChatRequest(chat_id=small_chat_id(peer))) + else: + raise UsageError("this operation only works in a group or channel", field="chat") + full = getattr(result, "full_chat", None) + chat_id = peer_id_of(peer) or 0 + entities = entity_map(result) + entity = entities.get(chat_id) + if entity is None: + chats = list(getattr(result, "chats", None) or []) + entity = chats[0] if chats else None + return full, entity, entities + + +def entity_id(entity: Any) -> int: + """The marked id of an *entity* (`Chat`/`Channel`/`User`), or 0. + + Not the same call as `_serialize.peer_id_of`, which reads a `Peer*`/ + `InputPeer*` and answers None for an entity — an entity carries `id`, + not `channel_id`, so asking the wrong one of the two silently yields 0 + for every row. + """ + from telethon import utils + + if entity is None: + return 0 + try: + return int(utils.get_peer_id(entity)) + except (TypeError, ValueError): + return 0 + + +def display_name(entity: Any) -> str: + if entity is None: + return "" + title = getattr(entity, "title", None) + if title: + return str(title) + parts = [getattr(entity, "first_name", None), getattr(entity, "last_name", None)] + return " ".join(part for part in parts if part).strip() + + +_STATUS = { + "ChannelParticipantCreator": "creator", + "ChatParticipantCreator": "creator", + "ChannelParticipantAdmin": "admin", + "ChatParticipantAdmin": "admin", + "ChannelParticipantSelf": "self", + "ChannelParticipantLeft": "left", + "ChannelParticipant": "member", + "ChatParticipant": "member", +} + + +def _participant_id(raw: Any) -> int: + user_id = getattr(raw, "user_id", None) + if isinstance(user_id, int): + return user_id + peer = getattr(raw, "peer", None) + if peer is not None: + return abs(peer_id_of(peer) or 0) + return 0 + + +def effective_rights(default_banned: Rights | None, member: Rights | None) -> Rights | None: + """The chat defaults, patched with one member's own mask. + + A member's `banned_rights` only names what was taken away *from them*; + everything else falls back to the chat default. Reporting the per-user + mask alone answers "what did the admin type", not "what may this person + actually do", which is the question anybody moderating is asking. + """ + if default_banned is None and member is None: + return None + out = Rights() + for name in _rights.MEMBER_MASK: + flag = name.replace("-", "_") + flag = {"send_rounds": "send_roundvideos"}.get(flag, flag) + if not hasattr(out, flag): + # A layer-229 right the model has no field for. `chat permission + # list` names it as unsupported; inventing a field here would + # report a permission tlgr cannot actually read. + continue + value: bool | None = None + if default_banned is not None: + value = getattr(default_banned, flag, None) + if member is not None: + own = getattr(member, flag, None) + if own is False: + value = False + elif own is True and value is None: + value = True + setattr(out, flag, value) + return out + + +def participant_model( + raw: Any, + *, + chat_id: int = 0, + entities: dict[int, Any] | None = None, + default_banned: Rights | None = None, +) -> Participant: + """One `ChannelParticipant*`/`ChatParticipant*` as a `Participant`.""" + kind = type(raw).__name__ + user_id = _participant_id(raw) + entity = (entities or {}).get(user_id) + status = _STATUS.get(kind, "member") + if kind == "ChannelParticipantBanned": + status = "banned" if getattr(raw, "left", False) else "restricted" + + admin = _rights.model_from_admin(getattr(raw, "admin_rights", None)) + banned = _rights.model_from_banned(getattr(raw, "banned_rights", None)) + date = getattr(raw, "date", None) + subscription = getattr(raw, "subscription_until_date", None) + + return Participant( + id=user_id, + user_id=user_id, + chat_id=chat_id, + peer=entity_to_peer(entity) if entity is not None else None, + username=getattr(entity, "username", None), + name=display_name(entity), + is_bot=bool(getattr(entity, "bot", False)), + status=status, + rank=getattr(raw, "rank", None), + date=fmt_dt(date), + date_unix=to_unix(date), + inviter_id=getattr(raw, "inviter_id", None), + promoted_by=getattr(raw, "promoted_by", None), + kicked_by=getattr(raw, "kicked_by", None), + via_request=getattr(raw, "via_request", None), + subscription_until_date=fmt_dt(subscription), + admin_rights=admin, + banned_rights=banned, + effective_permissions=effective_rights(default_banned, banned), + can_edit=getattr(raw, "can_edit", None), + ) + + +def peer_row(entity: Any) -> Peer: + return entity_to_peer(entity) + + +def require_found(value: Any, message: str) -> Any: + if value is None: + raise NotFoundError(message) + return value diff --git a/tlgr/ops/_layer.py b/tlgr/ops/_layer.py new file mode 100644 index 0000000..ee079b4 --- /dev/null +++ b/tlgr/ops/_layer.py @@ -0,0 +1,46 @@ +"""What this build genuinely cannot do, said once and said the same way. + +Telethon 1.44 speaks MTProto layer 227. Two server features tlgr's command +surface names — Communities and the `ephemeral.*` welcome messages — are +layer 229 and have no TL classes to call. The commands are registered anyway +and refuse with `NOT_SUPPORTED` (exit 13 — "tlgr cannot do this", not "the +operation failed", §7.3), because a command that is absent teaches an agent +nothing while a command that refuses with a reason teaches it exactly what is +missing and when it will arrive. + +`ARCHITECTURE §6.14` describes the escape hatch (`core/custom_tl.py`) that +would close these: hand-written `TLRequest` subclasses invoked with an +explicit layer. Nothing on the P0/P1 path needs it, so the gap is declared +rather than hand-rolled. +""" + +from __future__ import annotations + +from tlgr.core.errors import NotSupportedError + +__all__ = ["LAYER", "NEEDED_LAYER", "community_gap", "welcome_gap"] + +#: The layer the installed Telethon speaks. +LAYER = 227 + +#: The layer these features arrived in. +NEEDED_LAYER = 229 + + +def _refuse(command: str, methods: str, feature: str) -> None: + raise NotSupportedError( + f"{command} needs {methods}, which arrived in MTProto layer {NEEDED_LAYER}; " + f"Telethon 1.44 speaks layer {LAYER}, so tlgr has no request class to send. " + f"The {feature} surface is reserved: the command shape is settled and will " + "start working with the layer uplift, without a flag or a path changing" + ) + + +def community_gap(command: str, methods: str) -> None: + """Refuse a `communities.*` command (layer 229).""" + _refuse(command, methods, "Community") + + +def welcome_gap(command: str, methods: str) -> None: + """Refuse an `ephemeral.*` welcome-message command (layer 229).""" + _refuse(command, methods, "welcome-message") diff --git a/tlgr/ops/_rights.py b/tlgr/ops/_rights.py new file mode 100644 index 0000000..478879b --- /dev/null +++ b/tlgr/ops/_rights.py @@ -0,0 +1,402 @@ +"""The one canonical vocabulary for admin and member rights. + +`chat admin promote --rights`, `chat member restrict --deny`, `chat permission +set --allow` and `chat permission list` all read this table, which is why a +right is spelled the same way in every one of them and why `chat permission +get` round-trips straight back into `chat permission set`. + +Two conversions live here because getting either wrong is silent. + +* **Polarity.** `ChatBannedRights` is inverted — `send_messages=True` means + *cannot* send. Every `Rights` model tlgr emits is allow-polarity, so the + inversion happens once, here, instead of once per caller. +* **Completeness.** `channels.editBanned` and + `messages.editChatDefaultBannedRights` replace the whole mask: a flag you + omit is a flag you cleared. Every writer therefore builds the *full* mask + from a name set rather than patching the request object. + +Telethon 1.44 speaks layer 227. Two layer-229 rights (`manage-linked-peers`, +`manage-welcome-messages`) have no field to set, so they are listed with +`supported=false` and refused with NOT_SUPPORTED rather than dropped — a +right that vanishes quietly is a permission bug waiting to happen. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from tlgr.core.errors import NotSupportedError, UsageError +from tlgr.core.timefmt import fmt_dt, parse_dt, parse_duration, to_unix +from tlgr.models.admin import RightInfo +from tlgr.models.peer import Rights + +__all__ = [ + "ADMIN_MASK", + "FOREVER", + "MEMBER_MASK", + "MaskEdit", + "all_allowed", + "build_admin_rights", + "build_banned_rights", + "catalog", + "denied_names", + "granted_names", + "model_from_admin", + "model_from_banned", + "parse_names", + "parse_until", + "read_only", + "require_supported", + "until_label", +] + +#: Telegram treats 0, anything under 30 s and anything over 366 d as "forever". +FOREVER = 0 +_MIN_BAN = 30 +_MAX_BAN = 366 * 86400 + + +@dataclass(frozen=True, slots=True) +class _Right: + name: str + mask: str + tl_flag: str + summary: str + peer_types: tuple[str, ...] = () + supported: bool = True + since_layer: int | None = None + + +#: The admin mask, in the order `ChatAdminRights` declares its flags. +_ADMIN: tuple[_Right, ...] = ( + _Right("change-info", "admin", "change_info", "Edit the title, photo and description"), + _Right("post-messages", "admin", "post_messages", "Post to the channel", ("channel",)), + _Right("edit-messages", "admin", "edit_messages", "Edit others' posts", ("channel",)), + _Right("delete-messages", "admin", "delete_messages", "Delete others' messages"), + _Right("ban-users", "admin", "ban_users", "Ban and restrict members", ("group", "supergroup")), + _Right("invite-users", "admin", "invite_users", "Add members and create invite links"), + _Right("pin-messages", "admin", "pin_messages", "Pin messages", ("group", "supergroup")), + _Right("add-admins", "admin", "add_admins", "Promote other admins"), + _Right("anonymous", "admin", "anonymous", "Post as the group", ("group", "supergroup")), + _Right("manage-call", "admin", "manage_call", "Start and manage video chats"), + _Right("other", "admin", "other", "The undocumented catch-all flag"), + _Right("manage-topics", "admin", "manage_topics", "Create and manage topics", ("forum",)), + _Right("post-stories", "admin", "post_stories", "Post stories", ("channel",)), + _Right("edit-stories", "admin", "edit_stories", "Edit others' stories", ("channel",)), + _Right("delete-stories", "admin", "delete_stories", "Delete others' stories", ("channel",)), + _Right( + "manage-direct-messages", + "admin", + "manage_direct_messages", + "Moderate the channel's direct messages", + ("channel",), + ), + _Right("manage-ranks", "admin", "manage_ranks", "Set other members' custom titles"), + _Right( + "manage-linked-peers", + "admin", + "", + "Manage a community's linked chats", + (), + supported=False, + since_layer=229, + ), + _Right( + "manage-welcome-messages", + "admin", + "", + "Write the chat's welcome messages", + (), + supported=False, + since_layer=229, + ), +) + +#: The member mask. Stored inverted by Telegram; named in allow-polarity here. +_MEMBER: tuple[_Right, ...] = ( + _Right("view-messages", "member", "view_messages", "Read the chat at all"), + _Right("send-messages", "member", "send_messages", "Send any message"), + _Right("send-media", "member", "send_media", "Send media of any kind"), + _Right("send-photos", "member", "send_photos", "Send photos"), + _Right("send-videos", "member", "send_videos", "Send videos"), + _Right("send-audios", "member", "send_audios", "Send music"), + _Right("send-docs", "member", "send_docs", "Send files"), + _Right("send-voices", "member", "send_voices", "Send voice notes"), + _Right("send-rounds", "member", "send_roundvideos", "Send video notes"), + _Right("send-stickers", "member", "send_stickers", "Send stickers"), + _Right("send-gifs", "member", "send_gifs", "Send GIFs"), + _Right("send-games", "member", "send_games", "Send games"), + _Right("send-inline", "member", "send_inline", "Use inline bots"), + _Right("send-polls", "member", "send_polls", "Send polls"), + _Right("send-plain", "member", "send_plain", "Send plain text"), + _Right("send-reactions", "member", "send_reactions", "React to messages"), + _Right("embed-links", "member", "embed_links", "Send links with a preview"), + _Right("change-info", "member", "change_info", "Edit the title, photo and description"), + _Right("invite-users", "member", "invite_users", "Add members"), + _Right("pin-messages", "member", "pin_messages", "Pin messages"), + _Right("manage-topics", "member", "manage_topics", "Create topics", ("forum",)), + _Right("edit-rank", "member", "edit_rank", "Change their own custom title"), + _Right( + "manage-linked-peers", + "member", + "", + "Manage a community's linked chats", + (), + supported=False, + since_layer=229, + ), +) + +ADMIN_MASK: tuple[str, ...] = tuple(right.name for right in _ADMIN) +MEMBER_MASK: tuple[str, ...] = tuple(right.name for right in _MEMBER) + +_BY_MASK: dict[str, dict[str, _Right]] = { + "admin": {right.name: right for right in _ADMIN}, + "member": {right.name: right for right in _MEMBER}, +} + +#: The member rights `--none` leaves alone: taking view-messages away is a +#: ban, not a restriction, and `chat member ban` is the command that says so. +_READ_ONLY_KEEP = frozenset({"view-messages"}) + +#: Model field per TL flag. They line up one-to-one today; the mapping exists +#: so a future rename in either direction stays a one-line change. +_MODEL_FIELD = {right.tl_flag: right.tl_flag for right in (*_ADMIN, *_MEMBER) if right.tl_flag} + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def parse_names(value: str | None, *, mask: str, field: str = "rights") -> list[str]: + """`"ban-users, pin-messages"` → the canonical names, or a USAGE error. + + Unknown names are refused rather than ignored: silently dropping a right + somebody asked for is how a member ends up with permissions nobody + intended. + """ + if not value: + return [] + known = _BY_MASK[mask] + out: list[str] = [] + for raw in str(value).replace(",", " ").split(): + name = raw.strip().lower().replace("_", "-") + if not name: + continue + if name in ("all", "*"): + out.extend(n for n in known if n not in out) + continue + if name not in known: + raise UsageError( + f"{name!r} is not a {mask} right; see `tlgr chat permission list --mask {mask}`", + field=field, + ) + if name not in out: + out.append(name) + return out + + +def require_supported(names: list[str], *, mask: str) -> None: + """Refuse the layer-229 rights this Telethon cannot express (exit 13).""" + known = _BY_MASK[mask] + gaps = [name for name in names if not known[name].supported] + if gaps: + raise NotSupportedError( + f"{', '.join(gaps)} {'is a' if len(gaps) == 1 else 'are'} layer-229 " + f"{'right' if len(gaps) == 1 else 'rights'} and Telethon 1.44 speaks layer 227, " + "so tlgr has no field to set. `chat permission list` marks them supported=false" + ) + + +def parse_until(value: str | None) -> int: + """`--until 7d` → an absolute unix timestamp, with Telegram's rounding. + + 0, under 30 seconds and over 366 days all mean *forever* server-side, so + they are collapsed here rather than sent and silently reinterpreted. + """ + if value is None: + return FOREVER + text = str(value).strip().lower() + if text in ("", "0", "forever", "never", "permanent"): + return FOREVER + seconds = parse_duration(text) + if seconds is not None: + if seconds < _MIN_BAN or seconds > _MAX_BAN: + return FOREVER + return int(datetime.now(timezone.utc).timestamp()) + int(seconds) + moment = parse_dt(text) + if moment is None: + raise UsageError(f"{value!r} is neither a duration nor a timestamp", field="until") + stamp = to_unix(moment) or 0 + delta = stamp - int(datetime.now(timezone.utc).timestamp()) + if delta < _MIN_BAN or delta > _MAX_BAN: + return FOREVER + return stamp + + +def until_label(stamp: int) -> tuple[str | None, int | None]: + """`(RFC-3339, unix)` for a ban expiry, or `(None, None)` for forever.""" + if not stamp: + return None, None + moment = datetime.fromtimestamp(stamp, tz=timezone.utc) + return fmt_dt(moment), stamp + + +# --------------------------------------------------------------------------- +# TL ⇄ model +# --------------------------------------------------------------------------- + + +def model_from_admin(raw: Any) -> Rights | None: + """`ChatAdminRights` → the allow-polarity `Rights` model.""" + if raw is None: + return None + rights = Rights() + for right in _ADMIN: + if not right.tl_flag: + continue + setattr(rights, _MODEL_FIELD[right.tl_flag], bool(getattr(raw, right.tl_flag, False))) + return rights + + +def model_from_banned(raw: Any) -> Rights | None: + """`ChatBannedRights` → `Rights`, inverted so True still means *allowed*.""" + if raw is None: + return None + rights = Rights() + for right in _MEMBER: + if not right.tl_flag: + continue + setattr(rights, _MODEL_FIELD[right.tl_flag], not bool(getattr(raw, right.tl_flag, False))) + # Telethon hands back a datetime, but a mask tlgr just built carries the + # int it will serialise; both have to read the same way in the response. + until = getattr(raw, "until_date", None) + if isinstance(until, int): + until = datetime.fromtimestamp(until, tz=timezone.utc) if until else None + if until is not None: + rights.until = fmt_dt(until) + rights.until_unix = to_unix(until) + return rights + + +def granted_names(rights: Rights | None, *, mask: str) -> list[str]: + """The names whose value is True, in table order.""" + if rights is None: + return [] + table = _ADMIN if mask == "admin" else _MEMBER + return [ + right.name + for right in table + if right.tl_flag and getattr(rights, _MODEL_FIELD[right.tl_flag], None) is True + ] + + +def denied_names(rights: Rights | None, *, mask: str) -> list[str]: + if rights is None: + return [] + table = _ADMIN if mask == "admin" else _MEMBER + return [ + right.name + for right in table + if right.tl_flag and getattr(rights, _MODEL_FIELD[right.tl_flag], None) is False + ] + + +def build_admin_rights(names: list[str] | set[str]) -> Any: + """The `ChatAdminRights` for exactly *names*; everything else is False.""" + from telethon.tl import types + + wanted = set(names) + return types.ChatAdminRights( + **{right.tl_flag: right.name in wanted for right in _ADMIN if right.tl_flag} + ) + + +def build_banned_rights(allowed: list[str] | set[str], *, until: int = FOREVER) -> Any: + """The `ChatBannedRights` allowing exactly *allowed*. + + The mask is always complete, because the server replaces it wholesale. + """ + from telethon.tl import types + + wanted = set(allowed) + flags = {right.tl_flag: right.name not in wanted for right in _MEMBER if right.tl_flag} + return types.ChatBannedRights(until_date=until, **flags) + + +def all_allowed(*, mask: str = "member") -> list[str]: + """Every supported name in a mask — the `--all` shorthand.""" + table = _ADMIN if mask == "admin" else _MEMBER + return [right.name for right in table if right.supported] + + +def read_only() -> list[str]: + """`--none`: nothing but reading.""" + return sorted(_READ_ONLY_KEEP) + + +def catalog(*, mask: str = "all", grantable: dict[str, bool] | None = None) -> list[RightInfo]: + """The vocabulary as data, for `chat permission list`.""" + tables: list[tuple[str, tuple[_Right, ...]]] = [] + if mask in ("admin", "all"): + tables.append(("admin", _ADMIN)) + if mask in ("member", "all"): + tables.append(("member", _MEMBER)) + out: list[RightInfo] = [] + for _label, table in tables: + for right in table: + out.append( + RightInfo( + name=right.name, + mask=right.mask, + tl_flag=right.tl_flag or "-", + polarity="allow" if right.mask == "admin" else "deny", + supported=right.supported, + since_layer=right.since_layer, + peer_types=list(right.peer_types), + grantable=(grantable or {}).get(f"{right.mask}:{right.name}"), + summary=right.summary, + ) + ) + return out + + +@dataclass +class MaskEdit: + """A `--rights/--grant/--revoke/--all/--none` request, resolved once. + + Every rights-taking command has the same four shapes (set absolutely, + patch, everything, nothing) and v1 would have implemented them four + times. Resolving them here means `chat admin promote --grant ban-users` + and `chat member restrict --allow send-media` agree about what "patch" + means. + """ + + mask: str + current: set[str] = field(default_factory=set) + + def resolve( + self, + *, + absolute: list[str] | set[str] | None = None, + add: list[str] | None = None, + remove: list[str] | None = None, + everything: bool = False, + nothing: bool = False, + exclude: list[str] | None = None, + ceiling: set[str] | None = None, + ) -> set[str]: + if everything: + base = set(ceiling) if ceiling is not None else set(all_allowed(mask=self.mask)) + return base - set(exclude or ()) + if nothing: + return set(read_only()) if self.mask == "member" else set() + if absolute is not None: + return set(absolute) + out = set(self.current) + out |= set(add or ()) + out -= set(remove or ()) + return out diff --git a/tlgr/ops/chat_admin.py b/tlgr/ops/chat_admin.py new file mode 100644 index 0000000..8cc18cf --- /dev/null +++ b/tlgr/ops/chat_admin.py @@ -0,0 +1,929 @@ +"""`chat admin *`, `chat permission *`, `chat admin-log *` and `chat transfer`. + +The rights vocabulary is the spine of this module and it lives in +`ops/_rights.py`, so `chat admin promote --rights`, `chat member restrict +--deny` and `chat permission set --allow` cannot drift apart. Three rules +follow from the API rather than from taste. + +* **Both masks are replaced, never patched, server-side.** Every writer here + therefore reads the current mask first and sends a complete one. +* **A right this Telethon has no field for is refused, not dropped.** + `manage-linked-peers` and `manage-welcome-messages` are layer-229 flags; + asking for one exits 13 with the reason instead of quietly granting less + than the caller asked for. +* **The admin log is normalised but never lossy.** Fifty-odd + `channelAdminLogEventAction*` constructors become `{action, prev, new}` so + a script can switch on one string, and `raw_type` keeps the TL name so + nothing that was in the reply is unavailable. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import base64 +from datetime import datetime +from typing import Annotated, Any + +from tlgr.core.errors import UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.admin import ( + AdminLogEvent, + AdminResult, + AntiSpamReport, + Participant, + PermissionResult, + PermissionView, + RightInfo, + TransferResult, +) +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _admin, _rights, _send +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_")] + +_EXAMPLE_ADMIN: dict[str, Any] = { + "id": 4242, + "user_id": 4242, + "name": "Alice", + "status": "admin", + "rank": "moderator", + "can_edit": True, +} + +#: `--filter` names for the admin log → `ChannelAdminLogEventsFilter` flags. +#: Telethon's own `iter_admin_log` shifts four of them (restrict→ban, +#: unrestrict→unban, ban→kick, unban→unkick) and has no flag at all for +#: invites/send/forums/sub_extend/edit_rank, so tlgr builds the filter itself +#: and keeps the CLI names identical to the API's. +_LOG_FILTERS = { + "join": "join", + "leave": "leave", + "invite": "invite", + "ban": "ban", + "unban": "unban", + "kick": "kick", + "unkick": "unkick", + "promote": "promote", + "demote": "demote", + "info": "info", + "settings": "settings", + "pinned": "pinned", + "edit": "edit", + "delete": "delete", + "group-call": "group_call", + "invites": "invites", + "send": "send", + "forums": "forums", + "sub-extend": "sub_extend", + "edit-rank": "edit_rank", +} + +_ACTION_PREFIX = "ChannelAdminLogEventAction" + + +def _kebab(name: str) -> str: + out: list[str] = [] + for index, char in enumerate(name): + if char.isupper() and index: + out.append("-") + out.append(char.lower()) + return "".join(out) + + +def _plain(value: Any) -> Any: + """A Telethon object as plain JSON-able data, losing nothing on the way. + + `prev`/`new` in an admin-log row can be a whole message, a rights mask or + an invite link, and guessing which fields matter is how a log viewer ends + up hiding the one field somebody needed. + """ + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, bytes): + return base64.b64encode(value).decode() + if isinstance(value, datetime): + return fmt_dt(value) + if isinstance(value, (list, tuple)): + return [_plain(item) for item in value] + if isinstance(value, dict): + return {key: _plain(item) for key, item in value.items() if item is not None} + as_dict = getattr(value, "to_dict", None) + if callable(as_dict): + return _plain(as_dict()) + return str(value) + + +# --------------------------------------------------------------------------- +# chat admin list +# --------------------------------------------------------------------------- + + +class AdminListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + rights: Annotated[ + bool, opt("--rights/--no-rights", help="Expand each admin's mask into right names.") + ] = True + + +async def _antispam_bot(ctx: OpContext, peer: Any) -> Participant | None: + """The anti-spam bot, which the server never lists but the GUI shows. + + `channelFull.antispam` means Telegram's own bot is moderating; it holds + admin rights and deletes messages, so leaving it out of "who administers + this chat" would make the admin log's deletions look like they came from + nobody. + """ + from telethon.tl.functions import help as help_fn + + full, _entity, _entities = await _admin.full_chat(ctx, peer) + if not getattr(full, "antispam", False): + return None + config = await _admin.client(ctx)(help_fn.GetAppConfigRequest(hash=0)) + bot_id = 0 + for item in getattr(getattr(config, "config", None), "value", None) or []: + if getattr(item, "key", "") == "telegram_antispam_user_id": + raw = getattr(getattr(item, "value", None), "value", 0) + bot_id = int(float(raw or 0)) + if not bot_id: + return None + return Participant( + id=bot_id, + user_id=bot_id, + chat_id=_send.peer_id_of(peer), + name="Telegram Anti-Spam", + is_bot=True, + status="admin", + rank="anti-spam", + ) + + +async def list_admins(ctx: OpContext, req: AdminListReq) -> Page[Participant]: + """Administrators with their rights, ranks and who promoted them.""" + from telethon.tl import types + from telethon.tl.functions import channels as fn + + limit, state = _admin.window(ctx, "chat.admin.list", PageKind.PARTICIPANTS) + peer = await _send.resolve(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + offset = int(state.get("offset", 0) or 0) + + if not _admin.is_channel(peer): + full, _entity, entities = await _admin.full_chat(ctx, peer) + holder = getattr(full, "participants", None) + rows = [ + row + for row in (getattr(holder, "participants", None) or []) + if type(row).__name__ != "ChatParticipant" + ] + window = rows[offset : offset + limit] + items = [ + _admin.participant_model(row, chat_id=chat_id, entities=entities) for row in window + ] + return build_page( + items, + op="chat.admin.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(window)}, + account=ctx.account, + has_more=offset + len(window) < len(rows), + total=len(rows), + ) + + reply = await _admin.client(ctx)( + fn.GetParticipantsRequest( + channel=_admin.input_channel(peer), + filter=types.ChannelParticipantsAdmins(), + offset=offset, + limit=limit, + hash=0, + ) + ) + entities = _admin.entity_map(reply) + items = [ + _admin.participant_model(row, chat_id=chat_id, entities=entities) + for row in (getattr(reply, "participants", None) or []) + ] + if not req.rights: + for item in items: + item.admin_rights = None + total = int(getattr(reply, "count", 0) or 0) + if not offset: + bot = await _antispam_bot(ctx, peer) + if bot is not None: + items.append(bot) + total += 1 + return build_page( + items, + op="chat.admin.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(items)}, + account=ctx.account, + limit=limit, + total=total, + ) + + +SPEC_ADMIN_LIST = OperationSpec( + id="chat.admin.list", + request=AdminListReq, + response=Page[Participant], + impl=list_admins, + summary="List administrators with their rights and ranks", + description=( + "The creator is reported as `status: creator`. When the chat has " + "Telegram's aggressive anti-spam turned on, its bot is appended " + "locally exactly as the GUI does — the server never lists it, and " + "its deletions do show up in the admin log." + ), + paginated=PageKind.PARTICIPANTS, + columns=("id", "name", "status", "rank", "can_edit"), + headers=("ID", "Name", "Status", "Rank", "Editable"), + example={"items": [_EXAMPLE_ADMIN], "has_more": False, "total": 1}, + example_args="chat admin list @mygroup", + covers=("groups-channels-admin.admin-list", "groups-channels-admin.basic-group-admin"), +) + + +# --------------------------------------------------------------------------- +# chat admin promote / demote +# --------------------------------------------------------------------------- + + +class AdminPromoteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to promote.")] + rights: Annotated[ + str | None, opt("--rights", metavar="RIGHTS", help="The whole mask, comma-separated.") + ] = None + grant: Annotated[ + str | None, opt("--grant", metavar="RIGHTS", help="Add these to the current mask.") + ] = None + revoke: Annotated[ + str | None, opt("--revoke", metavar="RIGHTS", help="Remove these from the current mask.") + ] = None + everything: Annotated[bool, opt("--all", help="Grant every right you hold yourself.")] = False + none: Annotated[bool, opt("--none", help="Empty mask (the same as `chat admin demote`).")] = ( + False + ) + except_rights: Annotated[ + str | None, opt("--except", metavar="RIGHTS", help="With --all: withhold these.") + ] = None + rank: Annotated[ + str | None, + opt("--rank", metavar="TITLE", help="Custom title, max 16 chars."), + ] = None + anonymous: Annotated[ + bool | None, opt("--anonymous", help="Shorthand for the `anonymous` right.") + ] = None + + +async def _my_admin_rights(ctx: OpContext, peer: Any) -> set[str]: + """What the caller may hand out: you cannot grant a right you do not hold. + + The owner holds everything, which is why the creator short-circuits; a + chat we cannot query answers with the full set so `--all` degrades to + "ask for everything and let the server refuse" rather than to nothing. + """ + from telethon.tl import types + from telethon.tl.functions import channels as fn + + try: + reply = await _admin.client(ctx)( + fn.GetParticipantRequest( + channel=_admin.input_channel(peer), participant=types.InputPeerSelf() + ) + ) + except Exception: + return set(_rights.all_allowed(mask="admin")) + participant = getattr(reply, "participant", None) + if type(participant).__name__ == "ChannelParticipantCreator": + return set(_rights.all_allowed(mask="admin")) + mask = _rights.model_from_admin(getattr(participant, "admin_rights", None)) + return set(_rights.granted_names(mask, mask="admin")) + + +async def promote_admin(ctx: OpContext, req: AdminPromoteReq) -> AdminResult: + """Promote a member, or re-cut an existing admin's mask and rank.""" + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + peer = await _send.resolve(ctx, req.chat) + user = await _send.resolve(ctx, req.user) + chat_id = _send.peer_id_of(peer) + user_id = abs(_send.peer_id_of(user)) + + absolute = _rights.parse_names(req.rights, mask="admin", field="rights") + add = _rights.parse_names(req.grant, mask="admin", field="grant") + remove = _rights.parse_names(req.revoke, mask="admin", field="revoke") + excluded = _rights.parse_names(req.except_rights, mask="admin", field="except") + _rights.require_supported(absolute + add + remove, mask="admin") + + if not _admin.is_channel(peer): + # A basic group has one bit, not a mask. Saying which rights were + # dropped beats pretending the whole mask was applied. + await _admin.client(ctx)( + msg_fn.EditChatAdminRequest( + chat_id=_admin.small_chat_id(peer), + user_id=_admin.input_user(user), + is_admin=not req.none, + ) + ) + dropped = sorted(set(absolute) | set(add)) + if dropped: + ctx.warn( + "a basic group has no granular admin rights; " + "`chat convert <chat> supergroup` first, or accept the all-or-nothing bit" + ) + return AdminResult(chat_id=chat_id, user_id=user_id, dropped=dropped, rank=req.rank) + + current: set[str] = set() + try: + reply = await _admin.client(ctx)( + chan_fn.GetParticipantRequest(channel=_admin.input_channel(peer), participant=user) + ) + current = set( + _rights.granted_names( + _rights.model_from_admin(getattr(reply.participant, "admin_rights", None)), + mask="admin", + ) + ) + except Exception as exc: + if type(exc).__name__ != "UserNotParticipantError": + raise + + editor = _rights.MaskEdit(mask="admin", current=current) + wanted = editor.resolve( + absolute=absolute or None, + add=add, + remove=remove, + everything=req.everything, + nothing=req.none, + exclude=excluded, + ceiling=await _my_admin_rights(ctx, peer) if req.everything else None, + ) + if req.anonymous is True: + wanted.add("anonymous") + elif req.anonymous is False: + wanted.discard("anonymous") + + await _admin.client(ctx)( + chan_fn.EditAdminRequest( + channel=_admin.input_channel(peer), + user_id=_admin.input_user(user), + admin_rights=_rights.build_admin_rights(wanted), + rank=req.rank or "", + ) + ) + ctx.emit("chat_admin_changed", {"chat_id": chat_id, "user_id": user_id}) + return AdminResult( + chat_id=chat_id, + user_id=user_id, + admin_rights=_rights.model_from_admin(_rights.build_admin_rights(wanted)), + rank=req.rank, + already=wanted == current and req.rank is None, + ) + + +SPEC_ADMIN_PROMOTE = OperationSpec( + id="chat.admin.promote", + request=AdminPromoteReq, + response=AdminResult, + impl=promote_admin, + summary="Promote a member to admin, or change an existing admin's rights and rank", + description=( + "`--rights` sets the mask absolutely; `--grant`/`--revoke` patch the " + "one the member already has, which is read first. `--all` grants " + "every right *you* hold, because the server refuses to let you give " + "away more. A basic group has no granular rights: the request " + "collapses to `messages.editChatAdmin` and the dropped names are " + "reported in `dropped` rather than silently lost." + ), + aliases=("chat.admin.edit",), + mutating=True, + columns=("chat_id", "user_id", "rank"), + example={"chat_id": -1001500, "user_id": 4242, "rank": "moderator"}, + example_args="chat admin promote @mygroup @alice --rights ban-users,delete-messages", + covers=( + "groupcall.admin-right-manage-call", + "groups-channels-admin.member-tag-rank", + "groups-channels-admin.promote-admin", + "stories.admin-rights", + ), + covers_partial=("groups-channels-admin.basic-group-admin",), + coverage_note="Basic groups get the one all-or-nothing bit; `chat admin list` owns the id.", + tags=frozenset({"visible-to-others"}), +) + + +class AdminDemoteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to dismiss.")] + + +async def demote_admin(ctx: OpContext, req: AdminDemoteReq) -> AdminResult: + """Send an empty mask. Membership survives; only the rights go.""" + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + peer = await _send.resolve(ctx, req.chat) + user = await _send.resolve(ctx, req.user) + if _admin.is_channel(peer): + await _admin.client(ctx)( + chan_fn.EditAdminRequest( + channel=_admin.input_channel(peer), + user_id=_admin.input_user(user), + admin_rights=_rights.build_admin_rights([]), + rank="", + ) + ) + else: + await _admin.client(ctx)( + msg_fn.EditChatAdminRequest( + chat_id=_admin.small_chat_id(peer), + user_id=_admin.input_user(user), + is_admin=False, + ) + ) + chat_id = _send.peer_id_of(peer) + user_id = abs(_send.peer_id_of(user)) + ctx.emit("chat_admin_changed", {"chat_id": chat_id, "user_id": user_id, "demoted": True}) + return AdminResult( + chat_id=chat_id, + user_id=user_id, + admin_rights=_rights.model_from_admin(_rights.build_admin_rights([])), + ) + + +SPEC_ADMIN_DEMOTE = OperationSpec( + id="chat.admin.demote", + request=AdminDemoteReq, + response=AdminResult, + impl=demote_admin, + summary="Dismiss an administrator", + description=( + "Sends an empty `ChatAdminRights`; the person keeps their " + "membership. You need `add-admins` and — for somebody another admin " + "promoted — `channelParticipantAdmin.can_edit`." + ), + mutating=True, + destructive=True, + columns=("chat_id", "user_id"), + example={"chat_id": -1001500, "user_id": 4242}, + example_args="chat admin demote @mygroup @alice --yes", + covers=("groups-channels-admin.demote-admin",), + covers_partial=("groups-channels-admin.basic-group-admin",), + coverage_note="`is_admin=false` in a basic group; `chat admin list` owns the id.", +) + + +# --------------------------------------------------------------------------- +# chat permission get / list / set +# --------------------------------------------------------------------------- + + +class PermissionGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + + +async def get_permissions(ctx: OpContext, req: PermissionGetReq) -> PermissionView: + """The chat-wide defaults, printed in the polarity `--allow` accepts.""" + peer = await _send.resolve(ctx, req.chat) + _full, entity, _entities = await _admin.full_chat(ctx, peer) + rights = _rights.model_from_banned(getattr(entity, "default_banned_rights", None)) + if rights is None: + rights = _rights.model_from_banned(_rights.build_banned_rights(_rights.all_allowed())) + return PermissionView( + chat_id=_send.peer_id_of(peer), + allow=_rights.granted_names(rights, mask="member"), + deny=_rights.denied_names(rights, mask="member"), + rights=rights, + ) + + +SPEC_PERMISSION_GET = OperationSpec( + id="chat.permission.get", + request=PermissionGetReq, + response=PermissionView, + impl=get_permissions, + summary="Show the chat-wide default permissions (what every member may do)", + description=( + "Allow-polarity, using the same names `chat permission set " + "--allow/--deny` accepts, so the output round-trips back into the " + "input." + ), + columns=("chat_id", "allow", "deny"), + example={"chat_id": -1001500, "allow": ["send-messages"], "deny": ["send-media"]}, + example_args="chat permission get @mygroup", + covers_partial=("groups-channels-admin.default-permissions",), + coverage_note="Reading half; `chat permission set` writes them and owns the id.", +) + + +class PermissionListReq(Request): + mask: Annotated[str, choice("admin", "member", "all", help="Which vocabulary to print.")] = ( + "all" + ) + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Also mark what you may grant here."), + ] = None + + +async def list_rights(ctx: OpContext, req: PermissionListReq) -> list[RightInfo]: + """The canonical right vocabulary — the single source of truth for names. + + Without `--chat` this is a static table and touches no network; with one + it also marks which rights the caller may currently hand out, which needs + the caller's own participant row. + """ + grantable: dict[str, bool] = {} + if req.chat is not None: + peer = await _send.resolve(ctx, req.chat) + mine = await _my_admin_rights(ctx, peer) + for name in _rights.ADMIN_MASK: + grantable[f"admin:{name}"] = name in mine + may_ban = "ban-users" in mine + for name in _rights.MEMBER_MASK: + grantable[f"member:{name}"] = may_ban + return _rights.catalog(mask=req.mask, grantable=grantable) + + +SPEC_PERMISSION_LIST = OperationSpec( + id="chat.permission.list", + request=PermissionListReq, + response=list[RightInfo], + impl=list_rights, + summary="Print the canonical right vocabulary (admin mask, member mask, layer support)", + description=( + "The one place the names come from: `chat admin promote --rights`, " + "`chat member restrict --deny`, `chat permission set --allow` and " + "`bot default-rights set` all read this table. " + "`manage-linked-peers` and `manage-welcome-messages` are layer-229 " + "flags Telethon 1.44 cannot express and are marked " + "`supported: false` rather than omitted." + ), + rate_class="local", + columns=("name", "mask", "tl_flag", "supported"), + headers=("Name", "Mask", "TL flag", "Supported"), + example=[ + { + "name": "ban-users", + "mask": "admin", + "tl_flag": "ban_users", + "polarity": "allow", + "supported": True, + } + ], + example_args="chat permission list --mask member", + covers_partial=( + "groups-channels-admin.default-permissions", + "groups-channels-admin.promote-admin", + "groups-channels-admin.restrict-member", + ), + coverage_note=( + "The vocabulary the three writing commands share; each of them owns its own catalog id." + ), +) + + +class PermissionSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + allow: Annotated[ + str | None, opt("--allow", metavar="RIGHTS", help="Rights every member may use.") + ] = None + deny: Annotated[ + str | None, opt("--deny", metavar="RIGHTS", help="Rights no member may use.") + ] = None + everything: Annotated[bool, opt("--all", help="Allow everything (empty banned mask).")] = False + none: Annotated[bool, opt("--none", help="Deny everything except view-messages.")] = False + replace: Annotated[ + bool, opt("--replace", help="Treat --allow/--deny as the whole mask, not a patch.") + ] = False + + +async def set_permissions(ctx: OpContext, req: PermissionSetReq) -> PermissionResult: + """Read the chat's current default mask, patch it, and send it complete.""" + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + _full, entity, _entities = await _admin.full_chat(ctx, peer) + current = _rights.model_from_banned(getattr(entity, "default_banned_rights", None)) + base = ( + set(_rights.granted_names(current, mask="member")) + if current is not None + else set(_rights.all_allowed()) + ) + + allow = _rights.parse_names(req.allow, mask="member", field="allow") + deny = _rights.parse_names(req.deny, mask="member", field="deny") + _rights.require_supported(allow + deny, mask="member") + if not (allow or deny or req.everything or req.none): + raise UsageError("nothing to change: pass --allow, --deny, --all or --none", field="allow") + + editor = _rights.MaskEdit(mask="member", current=base) + wanted = editor.resolve( + absolute=(set(allow) - set(deny)) if (req.replace and allow) else None, + add=allow, + remove=deny, + everything=req.everything, + nothing=req.none, + ) + # `view-messages` is not a chat-wide default: a chat nobody may read is a + # chat nobody is in, and the server refuses it. + wanted.add("view-messages") + + if wanted == base: + _admin.already(ctx) + order = list(_rights.MEMBER_MASK) + return PermissionResult( + chat_id=_send.peer_id_of(peer), + allow=[n for n in order if n in wanted], + deny=[n for n in order if n not in wanted and n in _rights.all_allowed()], + changed=False, + already=True, + ) + + await _admin.client(ctx)( + fn.EditChatDefaultBannedRightsRequest( + peer=peer, banned_rights=_rights.build_banned_rights(wanted) + ) + ) + order = list(_rights.MEMBER_MASK) + ctx.emit("chat_permissions_changed", {"chat_id": _send.peer_id_of(peer)}) + return PermissionResult( + chat_id=_send.peer_id_of(peer), + allow=[n for n in order if n in wanted], + deny=[n for n in order if n not in wanted and n in _rights.all_allowed()], + changed=True, + ) + + +SPEC_PERMISSION_SET = OperationSpec( + id="chat.permission.set", + request=PermissionSetReq, + response=PermissionResult, + impl=set_permissions, + summary="Set the chat-wide default permissions", + description=( + "Read-modify-write: the current `default_banned_rights` are fetched " + "and patched, because a fresh mask resets every flag you did not " + "mention. `view-messages` is not settable here — a chat nobody may " + "read is `chat member ban`, not a permission — and `until_date` is " + "ignored. Works for basic groups too." + ), + mutating=True, + columns=("chat_id", "allow", "deny", "changed"), + example={"chat_id": -1001500, "allow": ["send-messages"], "deny": [], "changed": True}, + example_args="chat permission set @mygroup --deny send-media,send-stickers", + covers=("groups-channels-admin.default-permissions",), +) + + +# --------------------------------------------------------------------------- +# chat admin-log +# --------------------------------------------------------------------------- + + +class AdminLogReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + filter: Annotated[ + str | None, + opt("--filter", metavar="CLASSES", help="Comma-separated event classes; default all."), + ] = None + admin: Annotated[ + list[PeerRef], opt("--admin", metavar="USER", kind="user", help="Only these admins.") + ] = [] + search: Annotated[str, opt("--search", "-s", metavar="TEXT", help="Free-text query.")] = "" + min_id: Annotated[int, opt("--min-id", metavar="ID", help="Stop at this event id.")] = 0 + + +def _log_filter(value: str | None) -> Any: + if not value: + return None + from telethon.tl import types + + flags: dict[str, bool] = {} + for raw in str(value).replace(",", " ").split(): + name = raw.strip().lower() + if not name: + continue + if name == "all": + return None + if name not in _LOG_FILTERS: + raise UsageError( + f"{name!r} is not an admin-log event class; " + f"pick from {', '.join(sorted(_LOG_FILTERS))}", + field="filter", + ) + flags[_LOG_FILTERS[name]] = True + return types.ChannelAdminLogEventsFilter(**flags) + + +def _log_event(raw: Any, *, chat_id: int) -> AdminLogEvent: + action = getattr(raw, "action", None) + name = type(action).__name__ + slug = _kebab(name[len(_ACTION_PREFIX) :]) if name.startswith(_ACTION_PREFIX) else _kebab(name) + body = _plain(action) if action is not None else {} + previous: Any = None + current: Any = None + if isinstance(body, dict): + for key, value in body.items(): + if key.startswith("prev"): + previous = value + elif key.startswith("new"): + current = value + if previous is None and current is None: + current = {k: v for k, v in body.items() if k != "_"} or None + return AdminLogEvent( + id=int(getattr(raw, "id", 0) or 0), + date=fmt_dt(getattr(raw, "date", None)), + date_unix=to_unix(getattr(raw, "date", None)), + user_id=int(getattr(raw, "user_id", 0) or 0), + action=slug, + raw_type=name, + prev=previous, + new=current, + chat_id=chat_id, + ) + + +async def list_admin_log(ctx: OpContext, req: AdminLogReq) -> Page[AdminLogEvent]: + """Recent actions, newest first, with `max_id` as the cursor.""" + from telethon.tl.functions import channels as fn + + limit, state = _admin.window(ctx, "chat.admin-log.list", PageKind.PARTICIPANTS) + peer = await _send.resolve(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + admins = [_admin.input_user(await _send.resolve(ctx, ref)) for ref in req.admin] + reply = await _admin.client(ctx)( + fn.GetAdminLogRequest( + channel=_admin.input_channel(peer), + q=req.search or "", + max_id=int(state.get("max_id", 0) or 0), + min_id=req.min_id, + limit=limit, + events_filter=_log_filter(req.filter), + admins=admins or None, + ) + ) + events = list(getattr(reply, "events", None) or []) + items = [_log_event(row, chat_id=chat_id) for row in events] + lowest = min((item.id for item in items), default=0) + return build_page( + items, + op="chat.admin-log.list", + kind=PageKind.PARTICIPANTS, + state={"max_id": lowest}, + account=ctx.account, + limit=limit, + ) + + +SPEC_ADMIN_LOG_LIST = OperationSpec( + id="chat.admin-log.list", + request=AdminLogReq, + response=Page[AdminLogEvent], + impl=list_admin_log, + summary="Recent actions (the admin log)", + description=( + "The filter is built here rather than through Telethon's " + "`iter_admin_log`, which shifts four names (restrict→ban, " + "unrestrict→unban, ban→kick, unban→unkick) and has no flag at all " + "for invites, send, forums, sub-extend or edit-rank. tlgr's " + "`--filter` names are the API's. Retention is about 48 hours for " + "most classes, and the endpoint is aggressively flood-limited." + ), + paginated=PageKind.PARTICIPANTS, + rate_class="bulk", + columns=("id", "date", "user_id", "action"), + headers=("Event", "When", "By", "Action"), + example={ + "items": [ + { + "id": 91, + "date": "2026-02-01T10:00:00Z", + "user_id": 777, + "action": "participant-toggle-ban", + "raw_type": "ChannelAdminLogEventActionParticipantToggleBan", + } + ], + "has_more": False, + }, + example_args="chat admin-log list @mygroup --filter ban,kick", + covers=("groupcall.admin-log", "groups-channels-admin.admin-log"), +) + + +class AntiSpamReportReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Supergroup.")] + msg_id: Annotated[ + int, arg(1, metavar="MSG_ID", kind="msg_id", help="The wrongly deleted message.") + ] + + +async def report_false_positive(ctx: OpContext, req: AntiSpamReportReq) -> AntiSpamReport: + """Tell Telegram its anti-spam bot deleted something it should not have.""" + from telethon.tl.functions import channels as fn + + peer = await _send.resolve(ctx, req.chat) + await _admin.client(ctx)( + fn.ReportAntiSpamFalsePositiveRequest(channel=_admin.input_channel(peer), msg_id=req.msg_id) + ) + return AntiSpamReport(chat_id=_send.peer_id_of(peer), msg_id=req.msg_id, reported=True) + + +SPEC_ADMIN_LOG_REPORT = OperationSpec( + id="chat.admin-log.report", + request=AntiSpamReportReq, + response=AntiSpamReport, + impl=report_false_positive, + summary="Report an anti-spam deletion as a false positive", + description=( + "The candidate ids come from `chat admin-log list --filter delete`, " + "where the anti-spam bot's deletions appear." + ), + mutating=True, + columns=("chat_id", "msg_id", "reported"), + example={"chat_id": -1001500, "msg_id": 918, "reported": True}, + example_args="chat admin-log report @mygroup 918", + covers=("groups-channels-admin.antispam-false-positive",), +) + + +# --------------------------------------------------------------------------- +# chat transfer +# --------------------------------------------------------------------------- + + +class TransferReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="The new owner.")] + password: Annotated[ + str | None, + opt( + secret=True, + envvar="TLGR_2FA_PASSWORD", + help="Two-factor password. Never taken on the command line.", + ), + ] = None + + +async def transfer_chat(ctx: OpContext, req: TransferReq) -> TransferResult: + """Hand ownership over. Needs the 2FA password, and is irreversible. + + The password is never a positional or a value-taking flag: argv is + world-readable through `ps` and lands in shell history (STYLE §3). + """ + from telethon.password import compute_check + from telethon.tl.functions import account as acct_fn + from telethon.tl.functions import messages as fn + + if not req.password: + raise UsageError( + "the 2FA password is required; pass --password-env, --password-stdin " + "or --password-file (never as an argument)", + field="password", + ) + peer = await _send.resolve(ctx, req.chat) + user = await _send.resolve(ctx, req.user) + handle = _admin.client(ctx) + algo = await handle(acct_fn.GetPasswordRequest()) + await handle( + fn.EditChatCreatorRequest( + peer=peer, + user_id=_admin.input_user(user), + password=compute_check(algo, req.password), + ) + ) + chat_id = _send.peer_id_of(peer) + new_owner = abs(_send.peer_id_of(user)) + ctx.emit("chat_owner_changed", {"chat_id": chat_id, "new_owner_id": new_owner}) + return TransferResult(chat_id=chat_id, new_owner_id=new_owner) + + +SPEC_TRANSFER = OperationSpec( + id="chat.transfer", + request=TransferReq, + response=TransferResult, + impl=transfer_chat, + summary="Transfer ownership of a group or channel (2FA)", + description=( + "The target must already be an admin. PASSWORD_HASH_INVALID exits 4; " + "PASSWORD_TOO_FRESH / SESSION_TOO_FRESH and CHANNELS_TOO_MUCH exit " + "6 with the wait reported." + ), + aliases=("chat.admin.transfer",), + mutating=True, + destructive=True, + columns=("chat_id", "new_owner_id"), + example={"chat_id": -1001500, "new_owner_id": 4242}, + example_args="chat transfer @mygroup @alice --password-stdin --yes", + covers=("groups-channels-admin.transfer-ownership",), + tags=frozenset({"visible-to-others"}), +) diff --git a/tlgr/ops/chat_extra.py b/tlgr/ops/chat_extra.py new file mode 100644 index 0000000..02c721f --- /dev/null +++ b/tlgr/ops/chat_extra.py @@ -0,0 +1,1061 @@ +"""The rest of the group/channel surface: the parts nobody puts in one menu. + +`chat similar`, `chat sponsored`, `chat suggestion`, `chat suggested-post`, +`chat verification`, `chat direct` (channel DMs), `chat affiliate` (Stars +referral bots), and the two layer-229 surfaces — `chat community` and +`chat welcome` — that are registered and refuse. + +Two deliberate refusals live here. + +* **tlgr never views or clicks a sponsored message.** `chat sponsored list` + reports them as data and stops there; `messages.viewSponsoredMessage` and + `clickSponsoredMessage` are impressions, and a headless CLI has no + viewport, so sending them would be reporting an impression that did not + happen. The owner-side switch is `chat setting set --ads off`. +* **Communities and welcome messages are layer 229.** Telethon 1.44 speaks + layer 227 and has no request class for `communities.*` or the + `ephemeral.*` welcome methods, so those seven commands are registered and + exit 13 with the reason (`ops/_layer.py`). Registering them is the point: + a command that refuses with an explanation teaches an agent what is + missing, and a command that is absent teaches it nothing. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import base64 +import binascii +from typing import Annotated, Any + +from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix +from tlgr.models.admin import ( + AffiliateBot, + AffiliateResult, + CommunityResult, + CommunityRow, + DirectBanResult, + DirectDialog, + SimilarChat, + SponsoredReport, + SuggestedPostResult, + SuggestionResult, + VerificationResult, + WelcomeMessage, + WelcomeResult, +) +from tlgr.models.base import Request +from tlgr.models.message import SponsoredMessage +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _admin, _layer, _rights, _send +from tlgr.ops._params import arg, opt +from tlgr.ops._serialize import message_entities, peer_id_of +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +_EXAMPLE_SIMILAR: dict[str, Any] = { + "id": -1001600, + "title": "Telegram Tips", + "username": "telegramtips", + "participants_count": 1200000, +} + + +def _blob(value: str, field: str) -> bytes: + """A server-issued opaque id, given back the way it was printed.""" + text = str(value).strip() + try: + return base64.b64decode(text + "=" * (-len(text) % 4)) + except (binascii.Error, ValueError): + try: + return bytes.fromhex(text) + except ValueError as exc: + raise UsageError( + f"{field} is the opaque id `chat sponsored list` printed", field=field + ) from exc + + +# --------------------------------------------------------------------------- +# chat similar +# --------------------------------------------------------------------------- + + +class SimilarReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="CHAT", kind="peer", required=False, help="Channel, or omit for mine."), + ] = None + bots: Annotated[bool, opt("--bots", help="Bots similar to this bot instead.")] = False + + +async def list_similar(ctx: OpContext, req: SimilarReq) -> Page[SimilarChat]: + """Channels similar to one channel, or the account's own recommendations.""" + from telethon.tl.functions import bots as bot_fn + from telethon.tl.functions import channels as fn + + limit, _state = _admin.window(ctx, "chat.similar.list", PageKind.PARTICIPANTS) + handle = _admin.client(ctx) + if req.bots: + if req.chat is None: + raise UsageError("--bots needs the bot to be similar to", field="chat") + peer = await _send.resolve(ctx, req.chat) + reply = await handle(bot_fn.GetBotRecommendationsRequest(bot=_admin.input_user(peer))) + else: + channel = None + if req.chat is not None: + channel = _admin.input_channel(await _send.resolve(ctx, req.chat)) + reply = await handle(fn.GetChannelRecommendationsRequest(channel=channel)) + rows = [ + SimilarChat( + id=_admin.entity_id(chat), + title=str(getattr(chat, "title", "") or ""), + username=getattr(chat, "username", None), + participants_count=getattr(chat, "participants_count", None), + ) + for chat in (getattr(reply, "chats", None) or []) + ] + # A `chatsSlice` means the server truncated the list for a non-Premium + # account; reporting `total` is how a script can see the cut instead of + # concluding there are only five similar channels. + total = int(getattr(reply, "count", len(rows)) or len(rows)) + return build_page( + rows[:limit], + op="chat.similar.list", + kind=PageKind.PARTICIPANTS, + account=ctx.account, + has_more=len(rows) > limit, + total=total, + ) + + +SPEC_SIMILAR_LIST = OperationSpec( + id="chat.similar.list", + request=SimilarReq, + response=Page[SimilarChat], + impl=list_similar, + summary="Channels similar to this one (or global recommendations)", + description=( + "A non-Premium account gets a truncated `messages.chatsSlice`; " + "`total` reports the full count so the cut is visible. There is no " + "bare `chat similar` alias: Click cannot hold a command and a group " + "under one name, and `chat similar list` is the canonical path." + ), + paginated=PageKind.PARTICIPANTS, + columns=("id", "title", "username", "participants_count"), + example={"items": [_EXAMPLE_SIMILAR], "has_more": False}, + example_args="chat similar list @somechannel", + covers=( + "contacts-users.people-you-may-know", + "dialogs.recommended-channels", + "groups-channels-admin.similar-channels", + ), +) + + +# --------------------------------------------------------------------------- +# chat sponsored +# --------------------------------------------------------------------------- + + +class SponsoredListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")] + + +async def list_sponsored(ctx: OpContext, req: SponsoredListReq) -> list[SponsoredMessage]: + """Sponsored messages the server wants shown here — as data, nothing else. + + tlgr never calls `messages.viewSponsoredMessage` or + `clickSponsoredMessage`: those report an impression, a headless CLI has + no viewport, and faking one would be dishonest to both the advertiser and + the channel owner. + """ + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + reply = await _admin.client(ctx)(fn.GetSponsoredMessagesRequest(peer=peer)) + out: list[SponsoredMessage] = [] + for row in getattr(reply, "messages", None) or []: + random_id = getattr(row, "random_id", b"") or b"" + out.append( + SponsoredMessage( + random_id=base64.b64encode(random_id).decode(), + title=getattr(row, "title", None), + message=str(getattr(row, "message", "") or ""), + entities=message_entities(row), + url=getattr(row, "url", None), + button_text=getattr(row, "button_text", None), + sponsor_info=getattr(row, "sponsor_info", None), + additional_info=getattr(row, "additional_info", None), + recommended=bool(getattr(row, "recommended", False)), + can_report=bool(getattr(row, "can_report", False)), + viewed=False, + ) + ) + return out + + +SPEC_SPONSORED_LIST = OperationSpec( + id="chat.sponsored.list", + request=SponsoredListReq, + response=list[SponsoredMessage], + impl=list_sponsored, + summary="Sponsored messages the server wants shown in this channel", + description=( + "Exposed as data only; `viewed` is always false because tlgr never " + "reports an impression it did not make. Results are cached five " + "minutes server-side, which is the API's own contract. Turning them " + "off for your channel is `chat setting set --ads off`." + ), + columns=("random_id", "title", "url"), + example=[{"random_id": "AQID", "title": "Sponsor", "message": "Try this", "url": "https://x"}], + example_args="chat sponsored list @somechannel", + empty_exit=EXIT_EMPTY, + covers_partial=("groups-channels-admin.channel-sponsored-messages",), + coverage_note="Reading them; `chat sponsored report` owns the id and reports one.", +) + + +class SponsoredReportReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")] + random_id: Annotated[ + str, arg(1, metavar="RANDOM_ID", help="The opaque id from `chat sponsored list`.") + ] + option: Annotated[ + str | None, + opt("--option", metavar="BLOB", help="Server-provided option for the next step."), + ] = None + comment: Annotated[str, opt("--comment", metavar="TEXT", help="Free-text comment.")] = "" + + +async def report_sponsored(ctx: OpContext, req: SponsoredReportReq) -> SponsoredReport: + """Report a sponsored message. The reason menu is the server's, not ours.""" + from telethon.tl.functions import messages as fn + + await _send.resolve(ctx, req.chat) + reply = await _admin.client(ctx)( + fn.ReportSponsoredMessageRequest( + random_id=_blob(req.random_id, "random_id"), + option=_blob(req.option, "option") if req.option else b"", + ) + ) + kind = type(reply).__name__ + if kind == "SponsoredMessageReportResultChooseOption": + return SponsoredReport( + result="choose-option", + title=str(getattr(reply, "title", "") or ""), + options=[ + { + "text": str(getattr(item, "text", "") or ""), + "option": base64.b64encode(getattr(item, "option", b"") or b"").decode(), + } + for item in (getattr(reply, "options", None) or []) + ], + ) + if kind == "SponsoredMessageReportResultAdsHidden": + return SponsoredReport(result="ads-hidden") + return SponsoredReport(result="reported") + + +SPEC_SPONSORED_REPORT = OperationSpec( + id="chat.sponsored.report", + request=SponsoredReportReq, + response=SponsoredReport, + impl=report_sponsored, + summary="Report a sponsored message", + description=( + "The reason menu is server-driven: with no `--option` the command " + "prints the option blobs and exits 0, so a script can walk the tree " + "one level at a time." + ), + mutating=True, + columns=("result", "title"), + example={"result": "choose-option", "title": "What is wrong?", "options": []}, + example_args="chat sponsored report @somechannel AQID", + covers=("groups-channels-admin.channel-sponsored-messages",), +) + + +# --------------------------------------------------------------------------- +# chat suggestion / suggested-post / verification +# --------------------------------------------------------------------------- + + +class SuggestionDeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + key: Annotated[str, arg(1, metavar="KEY", help="The suggestion key to dismiss.")] + + +async def dismiss_suggestion(ctx: OpContext, req: SuggestionDeleteReq) -> SuggestionResult: + """Dismiss one server-suggested admin action, and report what is left.""" + from telethon.tl.functions import help as fn + + peer = await _send.resolve(ctx, req.chat) + await _admin.client(ctx)(fn.DismissSuggestionRequest(peer=peer, suggestion=req.key)) + full, _entity, _entities = await _admin.full_chat(ctx, peer) + return SuggestionResult( + chat_id=peer_id_of(peer) or 0, + key=req.key, + pending_suggestions=list(getattr(full, "pending_suggestions", None) or []), + ) + + +SPEC_SUGGESTION_DELETE = OperationSpec( + id="chat.suggestion.delete", + request=SuggestionDeleteReq, + response=SuggestionResult, + impl=dismiss_suggestion, + summary="Dismiss a server-suggested admin action", + description="The pending keys come from `chat get --full` (`channelFull.pending_suggestions`).", + mutating=True, + columns=("chat_id", "key", "pending_suggestions"), + example={"chat_id": -1001500, "key": "CONVERT_GIGAGROUP", "pending_suggestions": []}, + example_args="chat suggestion delete @mygroup CONVERT_GIGAGROUP", + covers=("groups-channels-admin.pending-suggestions",), +) + + +class SuggestedPostApproveReq(Request): + channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The channel.")] + msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The suggested post.")] + at: Annotated[ + str | None, opt("--at", metavar="WHEN", help="Publish then instead of when proposed.") + ] = None + + +async def approve_suggested_post( + ctx: OpContext, req: SuggestedPostApproveReq +) -> SuggestedPostResult: + """Approve a post suggested to a channel, optionally rescheduling it.""" + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.channel) + when = parse_dt(req.at) if req.at else None + if req.at and when is None: + raise UsageError("--at takes a timestamp", field="at") + await _admin.client(ctx)( + fn.ToggleSuggestedPostApprovalRequest(peer=peer, msg_id=req.msg_id, schedule_date=when) + ) + return SuggestedPostResult( + channel_id=peer_id_of(peer) or 0, + msg_id=req.msg_id, + approved=True, + schedule_date=fmt_dt(when), + ) + + +SPEC_SUGGESTED_POST_APPROVE = OperationSpec( + id="chat.suggested-post.approve", + request=SuggestedPostApproveReq, + response=SuggestedPostResult, + impl=approve_suggested_post, + summary="Approve a post suggested to a channel", + description=( + "`--at` doubles as the accept-and-reschedule counter-offer. " + "Accepting a priced post debits the payer, not you." + ), + mutating=True, + columns=("channel_id", "msg_id", "approved"), + example={"channel_id": -1001600, "msg_id": 918, "approved": True}, + example_args="chat suggested-post approve @mychannel 918", + covers_partial=("groups-channels-admin.suggested-post-approve",), + coverage_note="Approving; `chat suggested-post deny` rejects and owns the id.", + tags=frozenset({"visible-to-others"}), +) + + +class SuggestedPostDenyReq(Request): + channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The channel.")] + msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The suggested post.")] + comment: Annotated[ + str, opt("--comment", metavar="TEXT", help="Reason sent back to the author.") + ] = "" + + +async def deny_suggested_post(ctx: OpContext, req: SuggestedPostDenyReq) -> SuggestedPostResult: + """Reject a suggested post, with a reason the author sees.""" + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.channel) + await _admin.client(ctx)( + fn.ToggleSuggestedPostApprovalRequest( + peer=peer, msg_id=req.msg_id, reject=True, reject_comment=req.comment or None + ) + ) + return SuggestedPostResult(channel_id=peer_id_of(peer) or 0, msg_id=req.msg_id, rejected=True) + + +SPEC_SUGGESTED_POST_DENY = OperationSpec( + id="chat.suggested-post.deny", + request=SuggestedPostDenyReq, + response=SuggestedPostResult, + impl=deny_suggested_post, + summary="Reject a post suggested to a channel", + mutating=True, + destructive=True, + columns=("channel_id", "msg_id", "rejected"), + example={"channel_id": -1001600, "msg_id": 918, "rejected": True}, + example_args="chat suggested-post deny @mychannel 918 --comment 'off topic' --yes", + covers=("groups-channels-admin.suggested-post-approve",), + tags=frozenset({"visible-to-others"}), +) + + +class VerificationSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The peer to badge.")] + bot: Annotated[ + PeerRef | None, + opt("--bot", metavar="BOT", kind="user", help="Your bot with verifier_settings."), + ] = None + description: Annotated[ + str, opt("--description", metavar="TEXT", help="Custom description for the badge.") + ] = "" + off: Annotated[bool, opt("--off", help="Remove the badge instead.")] = False + + +async def set_verification(ctx: OpContext, req: VerificationSetReq) -> VerificationResult: + """Attach or remove a third-party (bot) verification badge. + + Not the blue Telegram check: `channel.verified` is server-assigned and + cannot be set through the API at all. + """ + from telethon.tl.functions import bots as fn + + peer = await _send.resolve(ctx, req.chat) + bot = _admin.input_user(await _send.resolve(ctx, req.bot)) if req.bot is not None else None + await _admin.client(ctx)( + fn.SetCustomVerificationRequest( + peer=peer, + bot=bot, + enabled=None if req.off else True, + custom_description=req.description or None, + ) + ) + return VerificationResult( + chat_id=peer_id_of(peer) or 0, + bot_id=abs(peer_id_of(await _send.resolve(ctx, req.bot)) or 0) if req.bot else None, + enabled=not req.off, + ) + + +SPEC_VERIFICATION_SET = OperationSpec( + id="chat.verification.set", + request=VerificationSetReq, + response=VerificationResult, + impl=set_verification, + summary="Attach or remove a third-party (bot) verification badge", + description=( + "This is a bot's badge, not Telegram's blue check — `channel.verified` " + "is server-assigned and has no API to set." + ), + mutating=True, + columns=("chat_id", "bot_id", "enabled"), + example={"chat_id": -1001500, "bot_id": 8800, "enabled": True}, + example_args="chat verification set @mygroup --bot @myverifierbot", + covers=("groups-channels-admin.verify-peer",), +) + + +# --------------------------------------------------------------------------- +# chat direct (the channel direct-messages monoforum) +# --------------------------------------------------------------------------- + + +async def _monoforum(ctx: OpContext, ref: PeerRef) -> tuple[Any, Any, int]: + """`(channel peer, monoforum peer, monoforum id)` for a channel's DMs. + + Every `chat direct` verb acts on `channel.linked_monoforum_id`, not on + the channel — banning somebody from a channel's DMs while banning them + from the channel are different things, and conflating them would be a + moderation action nobody asked for. + """ + peer = await _send.resolve(ctx, ref) + _full, entity, _entities = await _admin.full_chat(ctx, peer) + linked = getattr(entity, "linked_monoforum_id", None) + if not linked: + raise NotFoundError( + "this channel has no direct-messages conversation; turn it on with " + "`tlgr chat setting set <channel> --direct-messages on`" + ) + from tlgr.ops._serialize import marked_id + + monoforum = await _send.resolve(ctx, str(marked_id(int(linked), "channel"))) + return peer, monoforum, peer_id_of(monoforum) or 0 + + +class DirectListReq(Request): + channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The channel.")] + + +async def list_direct(ctx: OpContext, req: DirectListReq) -> Page[DirectDialog]: + """Browse a channel's direct-message conversations.""" + from datetime import datetime, timezone + + from telethon.tl import types + from telethon.tl.functions import messages as fn + + limit, state = _admin.window(ctx, "chat.direct.list", PageKind.PARTICIPANTS) + _peer, monoforum, _mono_id = await _monoforum(ctx, req.channel) + offset_date = state.get("date") + reply = await _admin.client(ctx)( + fn.GetSavedDialogsRequest( + parent_peer=monoforum, + offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc) + if offset_date + else None, + offset_id=int(state.get("id", 0) or 0), + offset_peer=types.InputPeerEmpty(), + limit=limit, + hash=0, + ) + ) + entities = _admin.entity_map(reply) + rows: list[DirectDialog] = [] + for row in getattr(reply, "dialogs", None) or []: + saved = peer_id_of(getattr(row, "peer", None)) or 0 + entity = entities.get(saved) + rows.append( + DirectDialog( + saved_peer_id=saved, + name=_admin.display_name(entity), + top_message=int(getattr(row, "top_message", 0) or 0) or None, + unread_count=int(getattr(row, "unread_count", 0) or 0), + unread_reactions_count=int(getattr(row, "unread_reactions_count", 0) or 0), + nopaid_messages_exception=getattr(row, "nopaid_messages_exception", None), + ) + ) + next_state = {"id": rows[-1].top_message or 0} if rows else {} + return build_page( + rows, + op="chat.direct.list", + kind=PageKind.PARTICIPANTS, + state=next_state, + account=ctx.account, + limit=limit, + total=int(getattr(reply, "count", len(rows)) or len(rows)), + ) + + +SPEC_DIRECT_LIST = OperationSpec( + id="chat.direct.list", + request=DirectListReq, + response=Page[DirectDialog], + impl=list_direct, + summary="Browse a channel's direct-message conversations", + description=( + "Each conversation is a `monoForumDialog` keyed by `saved_peer_id` " + "(the user). Reading and replying inside one is " + "`message list/send --direct <user>`." + ), + aliases=("chat.monoforum.list",), + paginated=PageKind.PARTICIPANTS, + columns=("saved_peer_id", "name", "unread_count"), + example={"items": [{"saved_peer_id": 4242, "name": "Alice", "unread_count": 1}]}, + example_args="chat direct list @mychannel", + covers=("groups-channels-admin.monoforum-topic-list",), +) + + +class DirectBanReq(Request): + channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The channel.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to ban from the DMs.")] + + +async def ban_direct(ctx: OpContext, req: DirectBanReq) -> DirectBanResult: + """Ban a user from a channel's direct messages, not from the channel.""" + from telethon.tl.functions import channels as fn + + peer, monoforum, mono_id = await _monoforum(ctx, req.channel) + user = await _send.resolve(ctx, req.user) + await _admin.client(ctx)( + fn.EditBannedRequest( + channel=_admin.input_channel(monoforum), + participant=user, + banned_rights=_rights.build_banned_rights([]), + ) + ) + return DirectBanResult( + channel_id=peer_id_of(peer) or 0, + monoforum_id=mono_id, + user_id=abs(peer_id_of(user) or 0), + banned=True, + ) + + +SPEC_DIRECT_BAN = OperationSpec( + id="chat.direct.ban", + request=DirectBanReq, + response=DirectBanResult, + impl=ban_direct, + summary="Ban a user from a channel's direct messages", + description=( + "Acts on `channel.linked_monoforum_id`, not on the channel: banning " + "somebody from your DMs and banning them from your channel are " + "different decisions." + ), + aliases=("chat.monoforum.ban",), + mutating=True, + destructive=True, + columns=("channel_id", "user_id", "banned"), + example={"channel_id": -1001600, "monoforum_id": -1001700, "user_id": 4242, "banned": True}, + example_args="chat direct ban @mychannel @spammer --yes", + covers_partial=("groups-channels-admin.monoforum-ban",), + coverage_note="Banning; `chat direct unban` lifts it and owns the id.", +) + + +async def unban_direct(ctx: OpContext, req: DirectBanReq) -> DirectBanResult: + """Lift a direct-messages ban.""" + from telethon.tl.functions import channels as fn + + peer, monoforum, mono_id = await _monoforum(ctx, req.channel) + user = await _send.resolve(ctx, req.user) + await _admin.client(ctx)( + fn.EditBannedRequest( + channel=_admin.input_channel(monoforum), + participant=user, + banned_rights=_rights.build_banned_rights(_rights.all_allowed()), + ) + ) + return DirectBanResult( + channel_id=peer_id_of(peer) or 0, + monoforum_id=mono_id, + user_id=abs(peer_id_of(user) or 0), + banned=False, + ) + + +SPEC_DIRECT_UNBAN = OperationSpec( + id="chat.direct.unban", + request=DirectBanReq, + response=DirectBanResult, + impl=unban_direct, + summary="Unban a user from a channel's direct messages", + description=( + "`chat member edit --free-messages on` waives the Stars price for " + "one user without touching the ban." + ), + aliases=("chat.monoforum.unban",), + mutating=True, + columns=("channel_id", "user_id", "banned"), + example={"channel_id": -1001600, "monoforum_id": -1001700, "user_id": 4242, "banned": False}, + example_args="chat direct unban @mychannel @alice", + covers=("groups-channels-admin.monoforum-ban",), +) + + +# --------------------------------------------------------------------------- +# chat affiliate +# --------------------------------------------------------------------------- + + +class AffiliateListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel.")] + suggested: Annotated[ + bool, opt("--suggested", help="Suggested affiliate bots instead of the connected ones.") + ] = False + + +async def list_affiliates(ctx: OpContext, req: AffiliateListReq) -> Page[AffiliateBot]: + """Affiliate (Stars referral) bots connected to a channel, or suggested.""" + from datetime import datetime, timezone + + from telethon.tl.functions import payments as fn + + limit, state = _admin.window(ctx, "chat.affiliate.list", PageKind.PARTICIPANTS) + peer = await _send.resolve(ctx, req.chat) + handle = _admin.client(ctx) + rows: list[AffiliateBot] = [] + next_state: dict[str, Any] = {} + if req.suggested: + reply = await handle( + fn.GetSuggestedStarRefBotsRequest( + peer=peer, offset=str(state.get("offset", "") or ""), limit=limit + ) + ) + for row in getattr(reply, "suggested_bots", None) or []: + rows.append( + AffiliateBot( + bot_id=int(getattr(row, "bot_id", 0) or 0), + commission_permille=int(getattr(row, "commission_permille", 0) or 0), + duration_months=getattr(row, "duration_months", None), + ) + ) + next_state = {"offset": str(getattr(reply, "next_offset", "") or "")} + else: + offset_date = state.get("date") + reply = await handle( + fn.GetConnectedStarRefBotsRequest( + peer=peer, + limit=limit, + offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc) + if offset_date + else None, + offset_link=state.get("link") or None, + ) + ) + for row in getattr(reply, "connected_bots", None) or []: + date = getattr(row, "date", None) + rows.append( + AffiliateBot( + bot_id=int(getattr(row, "bot_id", 0) or 0), + url=str(getattr(row, "url", "") or ""), + commission_permille=int(getattr(row, "commission_permille", 0) or 0), + duration_months=getattr(row, "duration_months", None), + participants=getattr(row, "participants", None), + revenue=getattr(row, "revenue", None), + date=fmt_dt(date), + date_unix=to_unix(date), + ) + ) + if rows: + next_state = {"date": rows[-1].date_unix or 0, "link": rows[-1].url} + return build_page( + rows, + op="chat.affiliate.list", + kind=PageKind.PARTICIPANTS, + state=next_state, + account=ctx.account, + limit=limit, + total=int(getattr(reply, "count", len(rows)) or len(rows)), + ) + + +SPEC_AFFILIATE_LIST = OperationSpec( + id="chat.affiliate.list", + request=AffiliateListReq, + response=Page[AffiliateBot], + impl=list_affiliates, + summary="Affiliate (Star referral) bots connected to a channel, plus suggestions", + paginated=PageKind.PARTICIPANTS, + columns=("bot_id", "commission_permille", "participants", "revenue"), + example={"items": [{"bot_id": 8800, "commission_permille": 200}], "has_more": False}, + example_args="chat affiliate list @mychannel", + covers_partial=("groups-channels-admin.affiliate-program",), + coverage_note="Listing; `chat affiliate set` connects one and owns the id.", +) + + +class AffiliateSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel.")] + bot: Annotated[PeerRef, arg(1, metavar="BOT", kind="user", help="The affiliate bot.")] + off: Annotated[bool, opt("--off", help="Revoke the affiliate link instead.")] = False + + +async def set_affiliate(ctx: OpContext, req: AffiliateSetReq) -> AffiliateResult: + """Connect or revoke an affiliate bot for a channel.""" + from telethon.tl.functions import payments as fn + + peer = await _send.resolve(ctx, req.chat) + bot = await _send.resolve(ctx, req.bot) + handle = _admin.client(ctx) + if req.off: + connected = await list_affiliates(ctx, AffiliateListReq(chat=req.chat)) + bot_id = abs(peer_id_of(bot) or 0) + link = next((row.url for row in connected.items if row.bot_id == bot_id), "") + if not link: + raise NotFoundError("that bot is not connected to this channel") + reply = await handle(fn.EditConnectedStarRefBotRequest(peer=peer, link=link, revoked=True)) + else: + reply = await handle(fn.ConnectStarRefBotRequest(peer=peer, bot=_admin.input_user(bot))) + connected_bots = list(getattr(reply, "connected_bots", None) or []) + row = connected_bots[0] if connected_bots else None + return AffiliateResult( + chat_id=peer_id_of(peer) or 0, + bot_id=abs(peer_id_of(bot) or 0), + url=str(getattr(row, "url", "") or ""), + commission_permille=int(getattr(row, "commission_permille", 0) or 0), + revoked=req.off, + ) + + +SPEC_AFFILIATE_SET = OperationSpec( + id="chat.affiliate.set", + request=AffiliateSetReq, + response=AffiliateResult, + impl=set_affiliate, + summary="Connect or disconnect an affiliate bot for a channel", + description="Connecting is free; the Stars commission comes out of the bot's revenue.", + mutating=True, + columns=("chat_id", "bot_id", "url", "revoked"), + example={"chat_id": -1001600, "bot_id": 8800, "url": "https://t.me/mybot?start=ref"}, + example_args="chat affiliate set @mychannel @refbot", + covers=("groups-channels-admin.affiliate-program",), +) + + +# --------------------------------------------------------------------------- +# chat community — layer 229, registered and refusing +# --------------------------------------------------------------------------- + + +class CommunityCreateReq(Request): + title: Annotated[str, arg(0, metavar="TITLE", help="The community's title.")] + about: Annotated[str, opt("--about", metavar="TEXT", help="Description.")] = "" + hidden: Annotated[bool, opt("--hidden", help="Do not show the community publicly.")] = False + peer: Annotated[ + list[PeerRef], opt("--peer", metavar="CHAT", kind="peer", help="Seed it with this chat.") + ] = [] + + +async def create_community(ctx: OpContext, req: CommunityCreateReq) -> CommunityResult: + """Reserved: `communities.create` is layer 229 and Telethon speaks 227.""" + _layer.community_gap("chat community create", "communities.create") + raise AssertionError # pragma: no cover - community_gap always raises + + +SPEC_COMMUNITY_CREATE = OperationSpec( + id="chat.community.create", + request=CommunityCreateReq, + response=CommunityResult, + impl=create_community, + summary="Create a Community (a hub grouping several chats)", + description=( + "Registered and refusing with NOT_SUPPORTED (exit 13): the whole " + "Community surface arrived in MTProto layer 229 and Telethon 1.44 " + "speaks 227, so there is no request class to send. The command shape " + "is settled and will start working with the layer uplift." + ), + mutating=True, + columns=("community_id", "title"), + example={"community_id": 0, "title": "Release hub"}, + example_args="chat community create 'Release hub'", + covers=("groups-channels-admin.community-create",), + tags=frozenset({"layer-gap"}), +) + + +class CommunityListReq(Request): + community: Annotated[ + PeerRef | None, + arg(0, metavar="COMMUNITY", kind="peer", required=False, help="One community."), + ] = None + requests: Annotated[bool, opt("--requests", help="Pending link requests instead of chats.")] = ( + False + ) + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="Which of its chats this member joined."), + ] = None + collapse: Annotated[ + str | None, opt("--collapse", metavar="ID=ON|OFF", help="Collapse it in the chat list.") + ] = None + mute: Annotated[ + str | None, opt("--mute", metavar="ID=WHEN", help="Community-wide notify settings.") + ] = None + + +async def list_communities(ctx: OpContext, req: CommunityListReq) -> Page[CommunityRow]: + """Reserved: `communities.getJoinedCommunities` is layer 229.""" + _layer.community_gap( + "chat community list", + "communities.getJoinedCommunities / getPeerLinkRequests / getParticipantJoinedChats", + ) + raise AssertionError # pragma: no cover - community_gap always raises + + +SPEC_COMMUNITY_LIST = OperationSpec( + id="chat.community.list", + request=CommunityListReq, + response=Page[CommunityRow], + impl=list_communities, + summary="List my communities, a community's chats, or its pending link requests", + description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.", + paginated=PageKind.PARTICIPANTS, + columns=("id", "title", "type"), + example={"items": [], "has_more": False}, + example_args="chat community list", + covers=("dialogs.community-collapse", "dialogs.notify-community"), + covers_partial=( + "groups-channels-admin.community-link-requests", + "groups-channels-admin.community-manage-links", + ), + coverage_note=( + "The whole Community surface is layer-229 and refuses with a reason; " + "`chat community set` and `chat community ban` own the two ids." + ), + tags=frozenset({"layer-gap"}), +) + + +class CommunitySetReq(Request): + community: Annotated[PeerRef, arg(0, metavar="COMMUNITY", kind="peer", help="The community.")] + chat: Annotated[PeerRef, arg(1, metavar="CHAT", kind="peer", help="The chat to link.")] + state: Annotated[ + str | None, + arg(2, metavar="STATE", required=False, help="visible | hidden | removed."), + ] = None + approve: Annotated[bool, opt("--approve", help="Approve this chat's link request.")] = False + deny: Annotated[bool, opt("--deny", help="Reject this chat's link request.")] = False + everything: Annotated[bool, opt("--all", help="Answer every pending request.")] = False + collapsed: Annotated[ + str | None, opt("--collapsed", metavar="ON|OFF", help="Collapse it in my dialog list.") + ] = None + + +async def set_community(ctx: OpContext, req: CommunitySetReq) -> CommunityResult: + """Reserved: `communities.togglePeerLink` is layer 229.""" + _layer.community_gap( + "chat community set", + "communities.togglePeerLink / togglePeerLinkRequestApproval / " + "toggleAllPeerLinkRequestApproval / toggleCommunityCollapsedInDialogs", + ) + raise AssertionError # pragma: no cover - community_gap always raises + + +SPEC_COMMUNITY_SET = OperationSpec( + id="chat.community.set", + request=CommunitySetReq, + response=CommunityResult, + impl=set_community, + summary="Add, hide, remove a chat in a community, or answer a link request", + description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.", + mutating=True, + columns=("community_id", "chat_id", "state"), + example={"community_id": 0, "chat_id": -1001500, "state": "visible"}, + example_args="chat community set @myhub @mygroup visible", + covers=("groups-channels-admin.community-manage-links",), + covers_partial=("groups-channels-admin.community-link-requests",), + coverage_note="Layer-229 surface; `chat community ban` owns the link-requests id.", + tags=frozenset({"layer-gap"}), +) + + +class CommunityBanReq(Request): + community: Annotated[PeerRef, arg(0, metavar="COMMUNITY", kind="peer", help="The community.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to ban.")] + off: Annotated[bool, opt("--off", help="Unban instead.")] = False + + +async def ban_community(ctx: OpContext, req: CommunityBanReq) -> CommunityResult: + """Reserved: `communities.toggleParticipantBanned` is layer 229.""" + _layer.community_gap("chat community ban", "communities.toggleParticipantBanned") + raise AssertionError # pragma: no cover - community_gap always raises + + +SPEC_COMMUNITY_BAN = OperationSpec( + id="chat.community.ban", + request=CommunityBanReq, + response=CommunityResult, + impl=ban_community, + summary="Ban a member from a whole community", + description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.", + mutating=True, + destructive=True, + columns=("community_id", "user_id", "banned"), + example={"community_id": 0, "user_id": 4242, "banned": True}, + example_args="chat community ban @myhub @spammer --yes", + covers=("groups-channels-admin.community-link-requests",), + tags=frozenset({"layer-gap"}), +) + + +# --------------------------------------------------------------------------- +# chat welcome — layer 229, registered and refusing +# --------------------------------------------------------------------------- + + +class WelcomeListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + + +async def list_welcome(ctx: OpContext, req: WelcomeListReq) -> list[WelcomeMessage]: + """Reserved: `ephemeral.getWelcomeMessages` is layer 229.""" + _layer.welcome_gap("chat welcome list", "ephemeral.getWelcomeMessages") + raise AssertionError # pragma: no cover - welcome_gap always raises + + +SPEC_WELCOME_LIST = OperationSpec( + id="chat.welcome.list", + request=WelcomeListReq, + response=list[WelcomeMessage], + impl=list_welcome, + summary="List a group/channel's welcome messages", + description=( + "Registered and refusing with NOT_SUPPORTED (exit 13). " + "`chatFull.has_welcome_messages` advertises them, but the " + "`ephemeral.*` methods that read them are layer 229 and Telethon " + "1.44 speaks 227." + ), + columns=("id", "text"), + example=[{"id": 1, "text": "Welcome!"}], + example_args="chat welcome list @mygroup", + covers=("messages-core.chat-welcome-messages",), + covers_partial=("groups-channels-admin.welcome-messages",), + coverage_note="Layer-229 surface; `chat welcome set` owns the writing id.", + tags=frozenset({"layer-gap"}), +) + + +class WelcomeSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + text: Annotated[str | None, arg(1, metavar="TEXT", required=False, help="The message.")] = None + id: Annotated[ + int | None, opt("--id", metavar="ID", help="Edit this welcome message instead of adding.") + ] = None + file: Annotated[ + list[str], opt("--file", metavar="PATH", kind="path", help="Attach media.") + ] = [] + parse: Annotated[ + str | None, opt("--parse", metavar="MODE", help="Markup of the text: md, html or none.") + ] = None + + +async def set_welcome(ctx: OpContext, req: WelcomeSetReq) -> WelcomeResult: + """Reserved: `ephemeral.sendMessage(welcome=true)` is layer 229.""" + _layer.welcome_gap("chat welcome set", "ephemeral.sendMessage / ephemeral.editMessage") + raise AssertionError # pragma: no cover - welcome_gap always raises + + +SPEC_WELCOME_SET = OperationSpec( + id="chat.welcome.set", + request=WelcomeSetReq, + response=WelcomeResult, + impl=set_welcome, + summary="Add or edit a welcome message", + description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.", + mutating=True, + columns=("chat_id", "id", "text"), + example={"chat_id": -1001500, "id": 1, "text": "Welcome!"}, + example_args="chat welcome set @mygroup 'Welcome aboard'", + covers=("groups-channels-admin.welcome-messages",), + tags=frozenset({"layer-gap"}), +) + + +class WelcomeDeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + id: Annotated[ + int | None, arg(1, metavar="ID", required=False, help="Which welcome message.") + ] = None + everything: Annotated[bool, opt("--all", help="Delete every welcome message.")] = False + + +async def delete_welcome(ctx: OpContext, req: WelcomeDeleteReq) -> WelcomeResult: + """Reserved: `ephemeral.deleteWelcomeMessage` is layer 229.""" + _layer.welcome_gap( + "chat welcome delete", + "ephemeral.deleteWelcomeMessage / ephemeral.deleteAllWelcomeMessages", + ) + raise AssertionError # pragma: no cover - welcome_gap always raises + + +SPEC_WELCOME_DELETE = OperationSpec( + id="chat.welcome.delete", + request=WelcomeDeleteReq, + response=WelcomeResult, + impl=delete_welcome, + summary="Delete one or all welcome messages", + description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.", + mutating=True, + destructive=True, + columns=("chat_id", "deleted"), + example={"chat_id": -1001500, "deleted": 1}, + example_args="chat welcome delete @mygroup --all --yes", + covers_partial=("groups-channels-admin.welcome-messages",), + coverage_note="Layer-229 surface; `chat welcome set` owns the id.", + tags=frozenset({"layer-gap"}), +) diff --git a/tlgr/ops/chat_invite.py b/tlgr/ops/chat_invite.py new file mode 100644 index 0000000..940f72c --- /dev/null +++ b/tlgr/ops/chat_invite.py @@ -0,0 +1,716 @@ +"""`chat invite *` and `chat join`: links in, and the queue behind them. + +An invite link is two different objects depending on which side of it you +stand on, and conflating them is how a CLI ends up with one command that +sometimes needs admin rights and sometimes does not. Here the split is +explicit: `chat invite get <chat> <link>` inspects a link *you* own +(`messages.getExportedChatInvite`, needs `invite-users`), while `chat invite +get <link>` previews a link somebody handed you +(`messages.checkChatInvite`, needs nothing). + +Two behaviours are worth knowing before scripting against this module. + +* **Editing the permanent link replaces it.** `messages.editExportedChatInvite` + may answer with `exportedChatInviteReplaced`, and both links are reported + (`link` plus `replaced_link`) rather than only the new one. +* **Joining has three successful outcomes.** Joined, already a member + (`already: true`), and request-sent (`pending_approval: true`). All three + exit 0; only an expired or invalid hash is an error. + +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 EXIT_EMPTY, NotFoundError, PermissionError_, UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, parse_dt, parse_duration, to_unix +from tlgr.models.admin import ( + Invite, + InviteDeleted, + InviteInfo, + InvitePeek, + InviteRevoked, + JoinResult, +) +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _admin, _send +from tlgr.ops._params import arg, opt +from tlgr.ops._serialize import entity_to_peer, message_to_model +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +_EXAMPLE_INVITE: dict[str, Any] = { + "link": "https://t.me/+AbCdEf", + "title": "Launch week", + "permanent": False, + "usage_limit": 25, + "usage": 3, +} + +#: A Stars subscription link is billed per 30-day period, always. +_SUBSCRIPTION_PERIOD = 30 * 86400 + + +def _expiry(value: str | None) -> Any: + """`--expires 7d` or `--expires 2026-03-01T00:00Z` → a datetime, or None.""" + if value is None: + return None + text = str(value).strip().lower() + if text in ("", "off", "never", "none", "0"): + return None + seconds = parse_duration(text) + if seconds is not None: + from datetime import datetime, timedelta, timezone + + return datetime.now(timezone.utc) + timedelta(seconds=seconds) + moment = parse_dt(text) + if moment is None: + raise UsageError(f"{value!r} is neither a duration nor a timestamp", field="expires") + return moment + + +def _invite_model(raw: Any) -> Invite: + """`chatInviteExported` → `Invite`, with the Stars pricing spelled out.""" + pricing = getattr(raw, "subscription_pricing", None) + return Invite( + link=str(getattr(raw, "link", "") or ""), + title=getattr(raw, "title", None), + permanent=bool(getattr(raw, "permanent", False)), + revoked=bool(getattr(raw, "revoked", False)), + request_needed=bool(getattr(raw, "request_needed", False)), + admin_id=getattr(raw, "admin_id", None), + date=fmt_dt(getattr(raw, "date", None)), + date_unix=to_unix(getattr(raw, "date", None)), + start_date=fmt_dt(getattr(raw, "start_date", None)), + expire_date=fmt_dt(getattr(raw, "expire_date", None)), + usage_limit=getattr(raw, "usage_limit", None), + usage=getattr(raw, "usage", None), + requested=getattr(raw, "requested", None), + subscription_expired=getattr(raw, "subscription_expired", None), + subscription_pricing=( + { + "period": int(getattr(pricing, "period", 0) or 0), + "amount": int(getattr(pricing, "amount", 0) or 0), + } + if pricing is not None + else None + ), + ) + + +def _exported(reply: Any) -> tuple[Any, str | None]: + """`(the invite, the link it replaced)` — the replacement is never hidden.""" + if type(reply).__name__ == "ExportedChatInviteReplaced": + return getattr(reply, "new_invite", None), str( + getattr(getattr(reply, "invite", None), "link", "") or "" + ) + invite = getattr(reply, "invite", None) + return (invite if invite is not None else reply), None + + +def _render_qr(ctx: OpContext, link: str, png: str | None) -> tuple[str | None, str | None]: + """An ASCII QR, and optionally a PNG, when a QR library is installed. + + The API contributes nothing to a QR code beyond the link, so this is + purely local rendering — and a pure-Python QR encoder is not something + tlgr should carry when `pip install tlgr[qr]` says it in one line. + """ + try: + import segno + except ImportError: + ctx.warn( + "no QR encoder is installed; `pip install 'tlgr[qr]'` (segno) to render " + "the link as a QR code. The link itself is in `link`" + ) + return None, None + code = segno.make(link, error="m") + if png: + code.save(png, scale=6) + import io + + buffer = io.StringIO() + code.terminal(out=buffer, compact=True) + return buffer.getvalue(), png + + +# --------------------------------------------------------------------------- +# chat invite create / edit / revoke / delete +# --------------------------------------------------------------------------- + + +class InviteCreateReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + title: Annotated[str | None, opt("--title", metavar="TEXT", help="Label shown to admins.")] = ( + None + ) + expires: Annotated[ + str | None, opt("--expires", metavar="WHEN", help="Expiry, as a duration or a timestamp.") + ] = None + usage_limit: Annotated[ + int | None, + opt("--limit", metavar="N", help="Maximum joins; excludes --request-approval."), + ] = None + request_approval: Annotated[ + bool, opt("--request-approval", help="Joins land in the approval queue instead.") + ] = False + subscription_stars: Annotated[ + int | None, + opt("--subscription-stars", metavar="N", help="Paid link: Stars per 30-day period."), + ] = None + replace_primary: Annotated[ + bool, opt("--replace-primary", help="Revoke and replace the permanent link.") + ] = False + + +async def create_invite(ctx: OpContext, req: InviteCreateReq) -> Invite: + """Mint an invite link: expiring, join-limited, approval-gated or paid.""" + from telethon.tl import types + from telethon.tl.functions import messages as fn + + if req.usage_limit is not None and req.request_approval: + raise UsageError( + "--limit and --request-approval are mutually exclusive: a link that " + "queues joins has no join count to cap", + field="limit", + ) + peer = await _send.resolve(ctx, req.chat) + pricing = ( + types.StarsSubscriptionPricing( + period=_SUBSCRIPTION_PERIOD, amount=int(req.subscription_stars) + ) + if req.subscription_stars + else None + ) + reply = await _admin.client(ctx)( + fn.ExportChatInviteRequest( + peer=peer, + title=req.title, + expire_date=_expiry(req.expires), + usage_limit=req.usage_limit, + request_needed=req.request_approval or None, + subscription_pricing=pricing, + legacy_revoke_permanent=req.replace_primary or None, + ) + ) + invite, replaced = _exported(reply) + model = _invite_model(invite) + model.replaced_link = replaced + ctx.emit("chat_invite_created", {"chat_id": _send.peer_id_of(peer), "link": model.link}) + return model + + +SPEC_INVITE_CREATE = OperationSpec( + id="chat.invite.create", + request=InviteCreateReq, + response=Invite, + impl=create_invite, + summary="Create an invite link (expiring, limited, approval-gated or paid)", + description=( + "`--limit` and `--request-approval` are mutually exclusive, which is " + "the server's rule and not ours. The subscription period is fixed at " + "30 days; creating a paid link costs nothing, only joining does. " + "`--replace-primary` invalidates the old permanent link for everyone " + "who holds it." + ), + mutating=True, + columns=("link", "title", "expire_date", "usage_limit"), + headers=("Link", "Title", "Expires", "Limit"), + example=_EXAMPLE_INVITE, + example_args="chat invite create @mygroup --title 'Launch week' --limit 25 --expires 7d", + covers=( + "groups-channels-admin.invite-link-create", + "groups-channels-admin.invite-link-primary", + "groups-channels-admin.invite-link-subscription", + ), +) + + +class InviteEditReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + link: Annotated[str, arg(1, metavar="LINK", help="The invite link to edit.")] + title: Annotated[str | None, opt("--title", metavar="TEXT", help="New label.")] = None + expires: Annotated[ + str | None, opt("--expires", metavar="WHEN", help="New expiry; `off` clears it.") + ] = None + usage_limit: Annotated[ + int | None, opt("--limit", metavar="N", help="New usage limit; 0 clears it.") + ] = None + request_approval: Annotated[ + bool | None, opt("--request-approval", help="Turn the approval queue on or off.") + ] = None + + +async def edit_invite(ctx: OpContext, req: InviteEditReq) -> Invite: + """Change a link's label, expiry, cap or approval gate.""" + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + if ( + req.title is None + and req.expires is None + and req.usage_limit is None + and req.request_approval is None + ): + raise UsageError("nothing to change", field="title") + reply = await _admin.client(ctx)( + fn.EditExportedChatInviteRequest( + peer=peer, + link=req.link, + title=req.title, + expire_date=_expiry(req.expires), + usage_limit=req.usage_limit, + request_needed=req.request_approval, + ) + ) + invite, replaced = _exported(reply) + model = _invite_model(invite) + model.replaced_link = replaced + return model + + +SPEC_INVITE_EDIT = OperationSpec( + id="chat.invite.edit", + request=InviteEditReq, + response=Invite, + impl=edit_invite, + summary="Edit an invite link", + description=( + "May answer with `messages.exportedChatInviteReplaced`; both links " + "are reported, the new one in `link` and the old one in " + "`replaced_link`. A paid subscription link accepts only `--title`." + ), + mutating=True, + columns=("link", "title", "expire_date", "usage_limit"), + example=_EXAMPLE_INVITE, + example_args="chat invite edit @mygroup https://t.me/+AbCdEf --limit 50", + covers=("groups-channels-admin.invite-link-edit",), +) + + +class InviteRevokeReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + link: Annotated[str, arg(1, metavar="LINK", help="The invite link to revoke.")] + + +async def revoke_invite(ctx: OpContext, req: InviteRevokeReq) -> InviteRevoked: + """Revoke a link. Revoking the permanent one mints a replacement.""" + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + reply = await _admin.client(ctx)( + fn.EditExportedChatInviteRequest(peer=peer, link=req.link, revoked=True) + ) + invite, replaced = _exported(reply) + link = str(getattr(invite, "link", "") or req.link) + ctx.emit("chat_invite_revoked", {"chat_id": _send.peer_id_of(peer), "link": req.link}) + return InviteRevoked(link=link, revoked=True, replaced_link=replaced) + + +SPEC_INVITE_REVOKE = OperationSpec( + id="chat.invite.revoke", + request=InviteRevokeReq, + response=InviteRevoked, + impl=revoke_invite, + summary="Revoke an invite link", + description=( + "Revoked links stay listable with `chat invite list --revoked` until " + "`chat invite delete` removes them." + ), + mutating=True, + destructive=True, + columns=("link", "revoked"), + example={"link": "https://t.me/+AbCdEf", "revoked": True}, + example_args="chat invite revoke @mygroup https://t.me/+AbCdEf --yes", + covers=("groups-channels-admin.invite-link-revoke",), +) + + +class InviteDeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + link: Annotated[ + str | None, arg(1, metavar="LINK", required=False, help="The revoked link to delete.") + ] = None + revoked: Annotated[bool, opt("--revoked", help="Delete every revoked link instead.")] = False + admin: Annotated[ + PeerRef | None, + opt("--admin", metavar="USER", kind="user", help="With --revoked: whose links to purge."), + ] = None + + +async def delete_invite(ctx: OpContext, req: InviteDeleteReq) -> InviteDeleted: + """Delete one revoked link, or every revoked link of one admin.""" + from telethon.tl import types + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + handle = _admin.client(ctx) + if req.revoked: + admin: Any = types.InputUserSelf() + if req.admin is not None: + admin = _admin.input_user(await _send.resolve(ctx, req.admin)) + await handle(fn.DeleteRevokedExportedChatInvitesRequest(peer=peer, admin_id=admin)) + return InviteDeleted(chat_id=_send.peer_id_of(peer), deleted=-1) + if not req.link: + raise UsageError("name a link, or pass --revoked to purge them all", field="link") + await handle(fn.DeleteExportedChatInviteRequest(peer=peer, link=req.link)) + return InviteDeleted(chat_id=_send.peer_id_of(peer), deleted=1) + + +SPEC_INVITE_DELETE = OperationSpec( + id="chat.invite.delete", + request=InviteDeleteReq, + response=InviteDeleted, + impl=delete_invite, + summary="Delete a revoked invite link, or every revoked link of an admin", + description=( + "Only revoked links can be deleted; revoke an active one first. " + "`--revoked` reports `deleted: -1`, because " + "`messages.deleteRevokedExportedChatInvites` answers with a bare " + "`true` and inventing a count would be inventing data." + ), + mutating=True, + destructive=True, + columns=("chat_id", "deleted"), + example={"chat_id": -1001500, "deleted": 1}, + example_args="chat invite delete @mygroup --revoked --yes", + covers=( + "groups-channels-admin.invite-link-delete", + "groups-channels-admin.invite-link-delete-all-revoked", + ), +) + + +# --------------------------------------------------------------------------- +# chat invite get / list / open +# --------------------------------------------------------------------------- + + +class InviteGetReq(Request): + target: Annotated[ + PeerRef, arg(0, metavar="CHAT|LINK", kind="peer", help="A chat, or an invite link.") + ] + link: Annotated[ + str | None, arg(1, metavar="LINK", required=False, help="With a chat: which link.") + ] = None + qr: Annotated[bool, opt("--qr", help="Also render the link as an ASCII QR code.")] = False + png: Annotated[ + str | None, opt("--png", metavar="PATH", kind="path", help="Write the QR code to a PNG.") + ] = None + + +async def get_invite(ctx: OpContext, req: InviteGetReq) -> InviteInfo: + """One of your links, your primary link, or a preview of somebody else's.""" + from telethon.tl.functions import messages as fn + + handle = _admin.client(ctx) + + if req.target.kind == "invite" and req.link is None: + reply = await handle(fn.CheckChatInviteRequest(hash=str(req.target.value))) + kind = type(reply).__name__ + chat = getattr(reply, "chat", None) + info = InviteInfo(link=req.target.raw) + if kind == "ChatInviteAlready": + info.already_member = True + info.chat = entity_to_peer(chat) if chat is not None else None + info.chat_title = _admin.display_name(chat) + elif kind == "ChatInvitePeek": + info.already_member = False + info.chat = entity_to_peer(chat) if chat is not None else None + info.chat_title = _admin.display_name(chat) + info.peek_expires = fmt_dt(getattr(reply, "expires", None)) + else: + info.already_member = False + info.chat_title = str(getattr(reply, "title", "") or "") + info.members_count = getattr(reply, "participants_count", None) + info.about = getattr(reply, "about", None) + info.public = bool(getattr(reply, "public", False)) + info.request_needed = bool(getattr(reply, "request_needed", False)) + if req.qr or req.png: + info.qr, info.png = _render_qr(ctx, info.link, req.png) + return info + + peer = await _send.resolve(ctx, req.target) + if req.link: + reply = await handle(fn.GetExportedChatInviteRequest(peer=peer, link=req.link)) + invite, _replaced = _exported(reply) + else: + full, _entity, _entities = await _admin.full_chat(ctx, peer) + invite = getattr(full, "exported_invite", None) + if invite is None: + raise NotFoundError( + "this chat has no primary invite link visible to you; " + "`chat invite create` mints one" + ) + base = _invite_model(invite) + info = InviteInfo(**{key: getattr(base, key) for key in base.__struct_fields__}) + if req.qr or req.png: + info.qr, info.png = _render_qr(ctx, info.link, req.png) + return info + + +SPEC_INVITE_GET = OperationSpec( + id="chat.invite.get", + request=InviteGetReq, + response=InviteInfo, + impl=get_invite, + summary="Inspect one invite link, the primary link, or preview a link you were given", + description=( + "One argument that is a `t.me/+…` or `joinchat` link previews it " + "with `messages.checkChatInvite` and needs no rights; a chat plus a " + "link inspects your own with `messages.getExportedChatInvite` and " + "needs `invite-users`. A peek answers with `peek_expires`, which is " + "the window `chat invite open` reads inside." + ), + columns=("link", "chat_title", "members_count", "already_member"), + example={**_EXAMPLE_INVITE, "chat_title": "News", "members_count": 120}, + example_args="chat invite get https://t.me/+AbCdEf", + empty_exit=EXIT_EMPTY, + covers=( + "groups-channels-admin.check-invite", + "groups-channels-admin.invite-link-get", + "groups-channels-admin.invite-link-qr", + ), + covers_partial=("groups-channels-admin.invite-link-primary",), + coverage_note=( + "The QR is rendered locally and needs the optional `tlgr[qr]` extra; " + "without it the link is still reported and a warning says so. " + "Minting the primary link is `chat invite create`." + ), +) + + +class InviteListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + admin: Annotated[ + PeerRef | None, + opt("--admin", metavar="USER", kind="user", help="Whose links to list; defaults to me."), + ] = None + revoked: Annotated[bool, opt("--revoked", help="Revoked links instead of active ones.")] = False + by_admin: Annotated[ + bool, opt("--by-admin", help="One row per admin with their link counts instead.") + ] = False + + +async def list_invites(ctx: OpContext, req: InviteListReq) -> Page[Invite]: + """A chat's invite links, or (with `--by-admin`) who made how many.""" + from datetime import datetime, timezone + + from telethon.tl import types + from telethon.tl.functions import messages as fn + + limit, state = _admin.window(ctx, "chat.invite.list", PageKind.PARTICIPANTS) + peer = await _send.resolve(ctx, req.chat) + handle = _admin.client(ctx) + + if req.by_admin: + reply = await handle(fn.GetAdminsWithInvitesRequest(peer=peer)) + rows = [ + Invite( + link="", + admin_id=int(getattr(row, "admin_id", 0) or 0), + invites_count=int(getattr(row, "invites_count", 0) or 0), + revoked_invites_count=int(getattr(row, "revoked_invites_count", 0) or 0), + ) + for row in (getattr(reply, "admins", None) or []) + ] + return build_page( + rows, + op="chat.invite.list", + kind=PageKind.PARTICIPANTS, + account=ctx.account, + has_more=False, + total=len(rows), + ) + + admin: Any = types.InputUserSelf() + if req.admin is not None: + admin = _admin.input_user(await _send.resolve(ctx, req.admin)) + offset_date = state.get("date") + reply = await handle( + fn.GetExportedChatInvitesRequest( + peer=peer, + admin_id=admin, + limit=limit, + revoked=req.revoked or None, + offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc) + if offset_date + else None, + offset_link=state.get("link") or None, + ) + ) + items = [_invite_model(row) for row in (getattr(reply, "invites", None) or [])] + next_state: dict[str, Any] = {} + if items: + last = items[-1] + next_state = {"date": last.date_unix or 0, "link": last.link} + return build_page( + items, + op="chat.invite.list", + kind=PageKind.PARTICIPANTS, + state=next_state, + account=ctx.account, + limit=limit, + total=int(getattr(reply, "count", 0) or 0), + ) + + +SPEC_INVITE_LIST = OperationSpec( + id="chat.invite.list", + request=InviteListReq, + response=Page[Invite], + impl=list_invites, + summary="List a chat's invite links (active, revoked, or grouped by admin)", + description=( + "`admin_id` is mandatory in the request, so it defaults to you; only " + "the owner may name somebody else. The cursor packs the " + "`(date, link)` pair of the last row. `--by-admin` swaps the rows " + "for one per admin, with their active and revoked counts in `usage` " + "and `requested`." + ), + paginated=PageKind.PARTICIPANTS, + columns=("link", "title", "usage", "expire_date"), + headers=("Link", "Title", "Used", "Expires"), + example={"items": [_EXAMPLE_INVITE], "has_more": False}, + example_args="chat invite list @mygroup --revoked", + covers=( + "groups-channels-admin.invite-link-admins", + "groups-channels-admin.invite-link-list", + ), +) + + +class InviteOpenReq(Request): + link: Annotated[PeerRef, arg(0, metavar="LINK", kind="peer", help="The invite link.")] + + +async def open_invite(ctx: OpContext, req: InviteOpenReq) -> InvitePeek: + """Read a private channel through an invite peek, without joining. + + Only works while the server answers `chatInvitePeek`. `peek_expires` is + reported so a script knows the window; after it the peer is dropped and + reading answers CHANNEL_PRIVATE. + """ + from telethon.tl.functions import messages as fn + + if req.link.kind != "invite": + raise UsageError("this takes an invite link (t.me/+… or joinchat/…)", field="link") + handle = _admin.client(ctx) + reply = await handle(fn.CheckChatInviteRequest(hash=str(req.link.value))) + if type(reply).__name__ != "ChatInvitePeek": + raise PermissionError_( + "the server did not offer a peek for this link; " + "`chat invite get` shows what it did say, and `chat join` joins" + ) + chat = getattr(reply, "chat", None) + limit = int(getattr(ctx, "limit", None) or 20) + raw = [m async for m in handle.iter_messages(chat, limit=limit) if m is not None] + chat_id = _admin.entity_id(chat) + return InvitePeek( + chat=entity_to_peer(chat) if chat is not None else None, + chat_title=_admin.display_name(chat), + peek_expires=fmt_dt(getattr(reply, "expires", None)), + messages=[message_to_model(m, chat_id=chat_id) for m in raw], + ) + + +SPEC_INVITE_OPEN = OperationSpec( + id="chat.invite.open", + request=InviteOpenReq, + response=InvitePeek, + impl=open_invite, + summary="Read a private channel through an invite peek, without joining", + description=( + "A peek is the server's offer, not ours: when it answers anything " + "other than `chatInvitePeek` this exits 6 rather than joining on " + "your behalf." + ), + columns=("chat_title", "peek_expires"), + example={"chat_title": "News", "peek_expires": "2026-02-01T11:00:00Z", "messages": []}, + example_args="chat invite open https://t.me/+AbCdEf", + covers=("groups-channels-admin.invite-peek",), +) + + +# --------------------------------------------------------------------------- +# chat join +# --------------------------------------------------------------------------- + + +class JoinReq(Request): + target: Annotated[ + PeerRef, arg(0, metavar="CHAT|LINK", kind="peer", help="A public chat, or an invite link.") + ] + + +async def join_chat(ctx: OpContext, req: JoinReq) -> JoinResult: + """Join a public chat by username, or a private one by invite link. + + Three outcomes are all success: joined, already a member, and + request-sent. Only an expired or invalid hash is an error, because a + script that treats "you are already in this group" as a failure will + retry forever. + """ + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + handle = _admin.client(ctx) + if req.target.kind == "invite": + try: + updates = await handle(msg_fn.ImportChatInviteRequest(hash=str(req.target.value))) + except Exception as exc: + name = type(exc).__name__ + if name == "UserAlreadyParticipantError": + _admin.already(ctx) + return JoinResult(chat_id=0, joined=True, already=True) + if name == "InviteRequestSentError": + return JoinResult(chat_id=0, joined=False, pending_approval=True) + raise + chats = list(getattr(updates, "chats", None) or []) + chat = chats[0] if chats else None + peer = entity_to_peer(chat) if chat is not None else None + result = JoinResult( + chat_id=peer.id if peer is not None else 0, + title=peer.title if peer is not None else "", + joined=True, + ) + if type(updates).__name__ == "ChatInviteJoinResultNeedsWebView": # pragma: no cover + result.joined = False + result.needs_web_view = "the server wants a web-view confirmation tlgr cannot open" + ctx.emit("chat_joined", {"chat_id": result.chat_id}) + return result + + peer = await _send.resolve(ctx, req.target) + updates = await handle(chan_fn.JoinChannelRequest(channel=_admin.input_channel(peer))) + chats = list(getattr(updates, "chats", None) or []) + entity = chats[0] if chats else None + chat_id = _send.peer_id_of(peer) + ctx.emit("chat_joined", {"chat_id": chat_id}) + return JoinResult(chat_id=chat_id, title=_admin.display_name(entity), joined=True) + + +SPEC_JOIN = OperationSpec( + id="chat.join", + request=JoinReq, + response=JoinResult, + impl=join_chat, + summary="Join a public group/channel, or a private one by invite link", + description=( + "`INVITE_REQUEST_SENT` is success-with-pending (exit 0, " + "`pending_approval: true`) and `USER_ALREADY_PARTICIPANT` is " + "`already: true` (exit 0); an expired hash exits 5. A layer-229 join " + "that needs a web view is reported in `needs_web_view` rather than " + "claimed as a join." + ), + mutating=True, + columns=("chat_id", "title", "joined", "pending_approval"), + example={"chat_id": -1001500, "title": "News", "joined": True}, + example_args="chat join @somechannel", + covers=("groups-channels-admin.join-by-invite", "groups-channels-admin.join-by-username"), + tags=frozenset({"visible-to-others"}), +) diff --git a/tlgr/ops/chat_manage.py b/tlgr/ops/chat_manage.py new file mode 100644 index 0000000..92e6131 --- /dev/null +++ b/tlgr/ops/chat_manage.py @@ -0,0 +1,1691 @@ +"""Making a chat, and everything on its Manage screen. + +`chat create`, `chat edit`, `chat convert`, `chat setting get/set`, +`chat username *`, `chat photo *`, `chat send-as *` and `chat discussion *`. + +The Manage screen is one screen in the GUI and roughly twenty separate +MTProto methods underneath, which is why `chat setting set` is a *batch*: +flags are applied in a fixed order, a toggle already in the requested state is +reported as `already` and not sent, and each failure is reported per key so +one refusal — usually a boost level or a capability flag — does not hide the +nine changes that did land. `chat setting get` prints the same key names +without the leading dashes, so its output round-trips back into its input. + +Two peer-shape rules run through the module. A basic group accepts only a +title, an about and a photo; everything else needs a supergroup, and the +commands say so and point at `chat convert` rather than migrating a group out +from under its owner. And a colour, an emoji status or an emoji pack is +boost-gated: the server refuses below the level, and the refusal is reported +per key with the level it wanted. + +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.timefmt import fmt_dt, parse_dt, parse_duration +from tlgr.models.admin import ( + ChatEditResult, + ChatPhotoResult, + CreatedChat, + DiscussionCandidate, + DiscussionResult, + MigrateResult, + MissingInvitee, + SendAsPeer, + SendAsResult, + SettingResult, + SettingsView, + UsernameCheck, + UsernameResult, +) +from tlgr.models.base import Request +from tlgr.models.peer import PeerRef +from tlgr.ops import _admin, _send +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._serialize import peer_id_of +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +_EXAMPLE_CHAT: dict[str, Any] = { + "id": -1001500, + "type": "supergroup", + "title": "News", + "username": "mynews", +} + +#: `--main-tab` values → the `ProfileTab*` constructor they name. +_PROFILE_TABS = { + "posts": "ProfileTabPosts", + "gifts": "ProfileTabGifts", + "media": "ProfileTabMedia", + "files": "ProfileTabFiles", + "music": "ProfileTabMusic", + "voice": "ProfileTabVoice", + "links": "ProfileTabLinks", + "gifs": "ProfileTabGifs", +} + +#: The slow-mode ladder the server accepts. Anything else is rounded up to +#: the next rung and the rounding is reported, because silently accepting +#: `--slow-mode 45s` and applying 60 is a lie about what the chat now does. +_SLOW_MODE_LADDER = (0, 10, 30, 60, 300, 900, 3600) + + +def _tri(value: str | None) -> bool | None: + """`on`/`off` → a bool; anything else is a usage error.""" + if value is None: + return None + text = str(value).strip().lower() + if text in ("on", "yes", "true", "1", "enable", "enabled"): + return True + if text in ("off", "no", "false", "0", "disable", "disabled"): + return False + raise UsageError(f"{value!r} is not on or off") + + +def _geo(value: str | None) -> Any: + """`--geo 51.5,-0.12` → an `InputGeoPoint`; `off` → `InputGeoPointEmpty`.""" + from telethon.tl import types + + if value is None: + return None + text = str(value).strip().lower() + if text in ("off", "none", "clear"): + return types.InputGeoPointEmpty() + parts = text.replace(";", ",").split(",") + if len(parts) != 2: + raise UsageError("--geo takes 'lat,lon'", field="geo") + try: + return types.InputGeoPoint(lat=float(parts[0]), long=float(parts[1])) + except ValueError as exc: + raise UsageError("--geo takes 'lat,lon' as two numbers", field="geo") from exc + + +def _int_or_off(value: str | None, field: str) -> int | None: + if value is None: + return None + text = str(value).strip().lower() + if text in ("off", "none", "clear", ""): + return 0 + try: + return int(text) + except ValueError as exc: + raise UsageError( + f"--{field.replace('_', '-')} takes a number or 'off'", field=field + ) from exc + + +def _slow_mode(value: str | None, ctx: OpContext) -> int | None: + if value is None: + return None + text = str(value).strip().lower() + if text in ("off", "none", "0"): + return 0 + seconds = parse_duration(text) + if seconds is None: + raise UsageError("--slow-mode takes a duration or 'off'", field="slow_mode") + wanted = int(seconds) + for rung in _SLOW_MODE_LADDER: + if wanted <= rung: + if rung != wanted: + ctx.warn(f"slow mode has a fixed ladder; {wanted}s was rounded up to {rung}s") + return rung + ctx.warn("slow mode caps at 1h; the request was clamped") + return _SLOW_MODE_LADDER[-1] + + +# --------------------------------------------------------------------------- +# chat create +# --------------------------------------------------------------------------- + + +class CreateReq(Request): + title: Annotated[str, arg(0, metavar="TITLE", help="The chat's title.")] + type: Annotated[ + str, + choice( + "group", + "supergroup", + "channel", + "forum", + help="Peer shape. `group` is the legacy basic group.", + ), + ] = "supergroup" + about: Annotated[str, opt("--about", metavar="TEXT", help="Description.")] = "" + members: Annotated[ + list[PeerRef], opt("--members", metavar="USER", kind="user", help="Seed members.") + ] = [] + photo: Annotated[ + str | None, opt("--photo", metavar="PATH", kind="path", help="Set the photo afterwards.") + ] = None + username: Annotated[ + str | None, opt("--username", metavar="NAME", help="Claim a public username afterwards.") + ] = None + ttl: Annotated[ + str | None, opt("--ttl", metavar="DURATION", help="Auto-delete timer at creation.") + ] = None + geo: Annotated[ + str | None, opt("--geo", metavar="LAT,LON", help="Create a location-based group.") + ] = None + address: Annotated[ + str, opt("--address", metavar="TEXT", help="Street address that goes with --geo.") + ] = "" + tabs: Annotated[bool, opt("--tabs", help="With --type forum: the tabbed topic UI.")] = False + for_import: Annotated[ + bool, opt("--for-import", help="Destination for a history import (see `chat import`).") + ] = False + forward_history: Annotated[ + int, opt("--forward-history", metavar="N", help="Basic groups: history new members see.") + ] = 0 + + +async def create_chat(ctx: OpContext, req: CreateReq) -> CreatedChat: + """Create a basic group, supergroup, broadcast channel or forum. + + `missing` carries `messages.invitedUsers.missing_invitees` verbatim: a + creation that quietly added three of five seed members and reported + success is a creation nobody can trust. + """ + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + from tlgr.ops.chat_member import _missing + + handle = _admin.client(ctx) + ttl = int(parse_duration(req.ttl) or 0) if req.ttl else None + if req.type == "group" and (req.username or req.geo): + raise UsageError( + "a basic group cannot have a username or a location; " + "use --type supergroup, or `chat convert <chat> supergroup` later", + field="type", + ) + + missing: list[MissingInvitee] = [] + if req.type == "group": + users = [_admin.input_user(await _send.resolve(ctx, ref)) for ref in req.members] + reply = await handle(msg_fn.CreateChatRequest(users=users, title=req.title, ttl_period=ttl)) + missing = _missing(reply) + updates = getattr(reply, "updates", reply) + else: + updates = await handle( + chan_fn.CreateChannelRequest( + title=req.title, + about=req.about, + megagroup=req.type in ("supergroup", "forum") or None, + broadcast=req.type == "channel" or None, + forum=req.type == "forum" or None, + for_import=req.for_import or None, + geo_point=_geo(req.geo), + address=req.address or None, + ttl_period=ttl, + ) + ) + chats = list(getattr(updates, "chats", None) or []) + entity = chats[0] if chats else None + chat_id = _admin.entity_id(entity) + result = CreatedChat( + id=chat_id or 0, + type=req.type, + title=req.title, + added=[], + missing=missing, + ) + + if req.type != "group" and req.members: + peer = await _send.resolve(ctx, str(result.id)) + users = [_admin.input_user(await _send.resolve(ctx, ref)) for ref in req.members] + invited = await handle( + chan_fn.InviteToChannelRequest(channel=_admin.input_channel(peer), users=users) + ) + result.missing = _missing(invited) + refused = {item.user_id for item in result.missing} + result.added = [ + abs(peer_id_of(await _send.resolve(ctx, ref)) or 0) + for ref in req.members + if abs(peer_id_of(await _send.resolve(ctx, ref)) or 0) not in refused + ] + + if req.username and result.id: + peer = await _send.resolve(ctx, str(result.id)) + await handle( + chan_fn.UpdateUsernameRequest(channel=_admin.input_channel(peer), username=req.username) + ) + result.username = req.username + if req.photo and result.id: + peer = await _send.resolve(ctx, str(result.id)) + await _set_photo(ctx, peer, file=req.photo) + ctx.emit("chat_created", {"chat_id": result.id, "type": req.type}) + return result + + +SPEC_CREATE = OperationSpec( + id="chat.create", + request=CreateReq, + response=CreatedChat, + impl=create_chat, + summary="Create a basic group, supergroup, broadcast channel or forum", + description=( + "`--type supergroup` is what the GUI creates today; `group` is the " + "legacy basic group, which cannot have a username or a location. " + "`missing` carries `messages.invitedUsers.missing_invitees` " + "verbatim, and the command exits 1 when any seed member was refused." + ), + legacy_paths=("chat create",), + mutating=True, + columns=("id", "type", "title", "username"), + example=_EXAMPLE_CHAT, + example_args="chat create 'Release team' --type supergroup --members @alice", + covers=( + "groups-channels-admin.create-basic-group", + "groups-channels-admin.create-channel", + "groups-channels-admin.create-forum", + "groups-channels-admin.create-geo-group", + "groups-channels-admin.create-supergroup", + "groups-channels-admin.create-with-autodelete", + "location.geogroup-create", + ), + covers_partial=( + "groups-channels-admin.add-members", + "groups-channels-admin.add-members-failure-report", + ), + coverage_note="Seed members go in at creation; `chat member add` owns adding them later.", + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# chat edit +# --------------------------------------------------------------------------- + + +class EditReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + title: Annotated[str | None, opt("--title", metavar="TEXT", help="New title.")] = None + about: Annotated[ + str | None, opt("--about", metavar="TEXT", help="New description, max 255 chars.") + ] = None + geo: Annotated[ + str | None, opt("--geo", metavar="LAT,LON", help="Geogroup location; `off` clears it.") + ] = None + address: Annotated[str, opt("--address", metavar="TEXT", help="Street address.")] = "" + color: Annotated[ + str | None, opt("--color", metavar="ID", help="Message accent palette id, or `off`.") + ] = None + color_emoji: Annotated[ + str | None, opt("--color-emoji", metavar="ID", help="Background emoji for --color.") + ] = None + profile_color: Annotated[ + str | None, opt("--profile-color", metavar="ID", help="Profile palette id, or `off`.") + ] = None + profile_color_emoji: Annotated[ + str | None, + opt("--profile-color-emoji", metavar="ID", help="Background emoji for --profile-color."), + ] = None + emoji_status: Annotated[ + str | None, opt("--emoji-status", metavar="ID", help="Emoji status, or `off`.") + ] = None + emoji_status_until: Annotated[ + str | None, opt("--emoji-status-until", metavar="WHEN", help="Expiry for --emoji-status.") + ] = None + main_tab: Annotated[ + str | None, + choice(*_PROFILE_TABS, help="Default profile tab (channels.setMainProfileTab)."), + ] = None + palettes: Annotated[ + bool, opt("--palettes", help="Do not edit: print the palettes and the level each needs.") + ] = False + + +async def edit_chat(ctx: OpContext, req: EditReq) -> ChatEditResult: + """One request per changed field, in a fixed order, reported in `changed`.""" + from telethon.tl import types + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import help as help_fn + from telethon.tl.functions import messages as msg_fn + + handle = _admin.client(ctx) + if req.palettes: + colors = await handle(help_fn.GetPeerColorsRequest(hash=0)) + profile = await handle(help_fn.GetPeerProfileColorsRequest(hash=0)) + rows: list[dict[str, Any]] = [] + for kind, reply in (("message", colors), ("profile", profile)): + for option in getattr(reply, "colors", None) or []: + rows.append( + { + "kind": kind, + "id": int(getattr(option, "color_id", 0) or 0), + "channel_min_level": int(getattr(option, "channel_min_level", 0) or 0), + "group_min_level": int(getattr(option, "group_min_level", 0) or 0), + "hidden": bool(getattr(option, "hidden", False)), + } + ) + return ChatEditResult(id=0, palettes=rows) + + peer = await _send.resolve(ctx, req.chat) + chat_id = peer_id_of(peer) or 0 + channel = _admin.input_channel(peer) if _admin.is_channel(peer) else None + changed: list[str] = [] + + if req.title is not None: + if channel is not None: + await handle(chan_fn.EditTitleRequest(channel=channel, title=req.title)) + else: + await handle( + msg_fn.EditChatTitleRequest(chat_id=_admin.small_chat_id(peer), title=req.title) + ) + changed.append("title") + if req.about is not None: + # `messages.editChatAbout` works for every peer shape, which is why + # there is no channel/basic-group split here. + await handle(msg_fn.EditChatAboutRequest(peer=peer, about=req.about)) + changed.append("about") + if req.geo is not None: + if channel is None: + raise UsageError("only a supergroup can have a location", field="geo") + await handle( + chan_fn.EditLocationRequest( + channel=channel, geo_point=_geo(req.geo), address=req.address + ) + ) + changed.append("geo") + if req.color is not None or req.color_emoji is not None: + if channel is None: + raise UsageError("colours are a channel/supergroup feature", field="color") + await handle( + chan_fn.UpdateColorRequest( + channel=channel, + color=_int_or_off(req.color, "color"), + background_emoji_id=_int_or_off(req.color_emoji, "color_emoji"), + ) + ) + changed.append("color") + if req.profile_color is not None or req.profile_color_emoji is not None: + if channel is None: + raise UsageError("colours are a channel/supergroup feature", field="profile_color") + await handle( + chan_fn.UpdateColorRequest( + channel=channel, + for_profile=True, + color=_int_or_off(req.profile_color, "profile_color"), + background_emoji_id=_int_or_off(req.profile_color_emoji, "profile_color_emoji"), + ) + ) + changed.append("profile_color") + if req.emoji_status is not None: + if channel is None: + raise UsageError( + "an emoji status is a channel/supergroup feature", field="emoji_status" + ) + document = _int_or_off(req.emoji_status, "emoji_status") or 0 + until = None + if req.emoji_status_until: + seconds = parse_duration(req.emoji_status_until) + if seconds is not None: + from datetime import datetime, timedelta, timezone + + until = datetime.now(timezone.utc) + timedelta(seconds=seconds) + else: + until = parse_dt(req.emoji_status_until) + status: Any = ( + types.EmojiStatusEmpty() + if not document + else types.EmojiStatus(document_id=document, until=until) + ) + await handle(chan_fn.UpdateEmojiStatusRequest(channel=channel, emoji_status=status)) + changed.append("emoji_status") + ctx.warn( + "a collectible emoji status and a custom profile palette are mutually " + "exclusive; setting one clears the other" + ) + if req.main_tab is not None: + if channel is None: + raise UsageError("the profile tab is a channel/supergroup feature", field="main_tab") + await handle( + chan_fn.SetMainProfileTabRequest( + channel=channel, tab=getattr(types, _PROFILE_TABS[req.main_tab])() + ) + ) + changed.append("main_tab") + + if not changed: + raise UsageError("nothing to change; --palettes prints the colour options", field="title") + ctx.emit("chat_edited", {"chat_id": chat_id, "changed": changed}) + return ChatEditResult(id=chat_id, changed=changed) + + +SPEC_EDIT = OperationSpec( + id="chat.edit", + request=EditReq, + response=ChatEditResult, + impl=edit_chat, + summary="Edit a group/channel profile: title, about, location, colors, emoji status, tab", + description=( + "One request per changed field, applied in a fixed order and " + "reported in `changed`. Colours, emoji statuses and emoji packs are " + "boost-gated: `--palettes` prints every option with the level it " + "needs, without editing anything. A basic group accepts only " + "`--title` and `--about`." + ), + mutating=True, + columns=("id", "changed"), + example={"id": -1001500, "changed": ["title", "about"]}, + example_args="chat edit @mygroup --title 'Release team' --about 'Ship it'", + covers=( + "groups-channels-admin.channel-emoji-status", + "groups-channels-admin.edit-about", + "groups-channels-admin.edit-title", + "groups-channels-admin.main-profile-tab", + "groups-channels-admin.peer-color-message", + "groups-channels-admin.peer-color-profile", + "groups-channels-admin.set-location", + "location.channel-geo", + ), +) + + +# --------------------------------------------------------------------------- +# chat convert +# --------------------------------------------------------------------------- + + +class ConvertReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat to convert.")] + target: Annotated[ + str, + arg(1, metavar="TARGET", help="supergroup (from a basic group) or gigagroup."), + ] = "supergroup" + + +async def convert_chat(ctx: OpContext, req: ConvertReq) -> MigrateResult: + """Basic group → supergroup, or supergroup → gigagroup. One way, both.""" + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + target = req.target.strip().lower() + if target not in ("supergroup", "gigagroup"): + raise UsageError("target is 'supergroup' or 'gigagroup'", field="target") + peer = await _send.resolve(ctx, req.chat) + old_id = peer_id_of(peer) or 0 + handle = _admin.client(ctx) + if target == "gigagroup": + updates = await handle( + chan_fn.ConvertToGigagroupRequest(channel=_admin.input_channel(peer)) + ) + chats = list(getattr(updates, "chats", None) or []) + new_id = _admin.entity_id(chats[0]) if chats else old_id + ctx.emit("chat_converted", {"chat_id": new_id, "type": "gigagroup"}) + return MigrateResult(old_chat_id=old_id, chat_id=new_id or old_id, type="gigagroup") + + if _admin.is_channel(peer): + raise UsageError( + "this is already a supergroup or channel; only a basic group migrates", + field="chat", + ) + updates = await handle(msg_fn.MigrateChatRequest(chat_id=_admin.small_chat_id(peer))) + new_id = old_id + for chat in getattr(updates, "chats", None) or []: + if type(chat).__name__ == "Channel": + new_id = _admin.entity_id(chat) or old_id + ctx.emit("chat_converted", {"chat_id": new_id, "type": "supergroup"}) + return MigrateResult(old_chat_id=old_id, chat_id=new_id, type="supergroup") + + +SPEC_CONVERT = OperationSpec( + id="chat.convert", + request=ConvertReq, + response=MigrateResult, + impl=convert_chat, + summary="Convert a basic group to a supergroup, or a supergroup to a gigagroup", + description=( + "Both conversions are one-way. Both ids are reported, because the " + "history stays in the old peer (`chat.migrated_to` points at the new " + "one). Supergroup-only commands offer `--upgrade` rather than " + "migrating a chat out from under its owner." + ), + mutating=True, + destructive=True, + columns=("old_chat_id", "chat_id", "type"), + example={"old_chat_id": -1500, "chat_id": -1001500, "type": "supergroup"}, + example_args="chat convert @mygroup supergroup --yes", + covers=( + "groups-channels-admin.convert-to-gigagroup", + "groups-channels-admin.upgrade-basic-to-supergroup", + ), +) + + +# --------------------------------------------------------------------------- +# chat setting get / set +# --------------------------------------------------------------------------- + + +class SettingGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + + +def _reactions_view(raw: Any) -> tuple[str | None, list[str]]: + kind = type(raw).__name__ + if kind == "ChatReactionsAll": + return "all", [] + if kind == "ChatReactionsNone": + return "none", [] + if kind == "ChatReactionsSome": + names = [ + str(getattr(item, "emoticon", None) or getattr(item, "document_id", "")) + for item in (getattr(raw, "reactions", None) or []) + ] + return "some", names + return None, [] + + +async def get_settings(ctx: OpContext, req: SettingGetReq) -> SettingsView: + """Every administrable toggle, keyed the way `chat setting set` spells it.""" + peer = await _send.resolve(ctx, req.chat) + full, entity, _entities = await _admin.full_chat(ctx, peer) + rights = _admin_rights_of(entity) + reactions, reaction_names = _reactions_view(getattr(full, "available_reactions", None)) + sticker = getattr(full, "stickerset", None) + emojiset = getattr(full, "emojiset", None) + tab = getattr(full, "main_tab", None) + view = SettingsView( + chat_id=peer_id_of(peer) or 0, + slow_mode=getattr(full, "slowmode_seconds", None), + prehistory=( + None + if getattr(full, "hidden_prehistory", None) is None + else ("hidden" if full.hidden_prehistory else "visible") + ), + join_to_send=getattr(entity, "join_to_send", None), + join_request=getattr(entity, "join_request", None), + guard_bot=getattr(full, "guard_bot_id", None), + noforwards=getattr(entity, "noforwards", None), + antispam=getattr(full, "antispam", None), + hidden_members=getattr(full, "participants_hidden", None), + signatures=getattr(entity, "signatures", None), + signature_profiles=getattr(entity, "signature_profiles", None), + forum=getattr(entity, "forum", None), + forum_tabs=( + None + if getattr(entity, "forum", None) is None + else ("tabs" if getattr(entity, "forum_tabs", False) else "list") + ), + view_as=( + None + if getattr(full, "view_forum_as_messages", None) is None + else ("messages" if full.view_forum_as_messages else "topics") + ), + autotranslate=getattr(entity, "autotranslation", None), + ads=( + None + if getattr(full, "restricted_sponsored", None) is None + else not full.restricted_sponsored + ), + reactions=reactions, + reactions_list=reaction_names, + reactions_limit=getattr(full, "reactions_limit", None), + paid_reactions=getattr(full, "paid_reactions_available", None), + sticker_set=getattr(sticker, "short_name", None), + emoji_set=getattr(emojiset, "short_name", None), + paid_messages_stars=getattr(full, "send_paid_messages_stars", None), + direct_messages=getattr(entity, "broadcast_messages_allowed", None), + ) + if tab is not None: + view.gated_by["main_tab"] = type(tab).__name__ + change_info = rights.get("change_info", False) or rights.get("creator", False) + for key in ( + "slow_mode", + "prehistory", + "join_to_send", + "join_request", + "antispam", + "hidden_members", + "signatures", + "signature_profiles", + "reactions", + "reactions_limit", + "sticker_set", + "emoji_set", + ): + view.available[key] = bool(change_info) + for key in ("forum", "noforwards", "ads", "direct_messages", "autotranslate"): + view.available[key] = bool(rights.get("creator", False)) + for key, level in ( + ("autotranslate", "channel_autotranslation_level_min"), + ("emoji_set", "group_emoji_stickers_level_min"), + ("reactions_limit", "channel_custom_reactions_level_min"), + ("ads", "channel_restrict_sponsored_level_min"), + ): + view.gated_by[key] = f"boost level: {level}" + if not getattr(full, "can_set_stickers", False): + view.gated_by["sticker_set"] = "needs can_set_stickers" + return view + + +def _admin_rights_of(entity: Any) -> dict[str, bool]: + rights = getattr(entity, "admin_rights", None) + out = {"creator": bool(getattr(entity, "creator", False))} + for name in ("change_info", "ban_users", "invite_users", "post_messages"): + out[name] = bool(getattr(rights, name, False)) or out["creator"] + return out + + +SPEC_SETTING_GET = OperationSpec( + id="chat.setting.get", + request=SettingGetReq, + response=SettingsView, + impl=get_settings, + summary="Print every administrable policy toggle with its current value", + description=( + "The key names are exactly `chat setting set`'s flag names without " + "the leading dashes, so the output round-trips into the input. " + "`available` says whether *you* may change each key, and `gated_by` " + "names the capability flag or boost level that blocks it." + ), + aliases=("chat.settings",), + columns=("chat_id", "slow_mode", "prehistory", "forum"), + example={"chat_id": -1001500, "slow_mode": 30, "prehistory": "visible", "forum": False}, + example_args="chat setting get @mygroup", + covers=( + "groups-channels-admin.antispam", + "groups-channels-admin.autotranslation", + "groups-channels-admin.content-protection", + "groups-channels-admin.forum-toggle", + "groups-channels-admin.gift-notifications", + "groups-channels-admin.group-emoji-set", + "groups-channels-admin.hidden-members", + "groups-channels-admin.prehistory-visibility", + "groups-channels-admin.reactions-settings", + "groups-channels-admin.signatures", + "groups-channels-admin.slow-mode", + "groups-channels-admin.toggle-join-to-send", + ), + covers_partial=( + "groups-channels-admin.channel-direct-messages", + "groups-channels-admin.group-sticker-set", + "groups-channels-admin.paid-messages-price", + "groups-channels-admin.restrict-sponsored", + "groups-channels-admin.toggle-join-request", + "groups-channels-admin.view-forum-as-messages", + ), + coverage_note="Reading half of the Manage screen; `chat setting set` writes each key.", +) + + +class SettingSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + slow_mode: Annotated[ + str | None, opt("--slow-mode", metavar="DURATION", help="Seconds between messages, or off.") + ] = None + prehistory: Annotated[ + str | None, choice("visible", "hidden", help="History visibility for new members.") + ] = None + join_to_send: Annotated[ + str | None, opt("--join-to-send", metavar="ON|OFF", help="Require joining before sending.") + ] = None + join_request: Annotated[ + str | None, opt("--join-request", metavar="ON|OFF", help="Require approval to join.") + ] = None + guard_bot: Annotated[ + PeerRef | None, + opt("--guard-bot", metavar="BOT", kind="user", help="Bot that handles the queue."), + ] = None + apply_to_links: Annotated[ + bool, opt("--apply-to-links", help="Apply --join-request to existing invite links too.") + ] = False + protect: Annotated[ + str | None, + opt("--protect", "--noforwards", metavar="ON|OFF", help="Restrict saving and forwarding."), + ] = None + antispam: Annotated[ + str | None, opt("--antispam", metavar="ON|OFF", help="Aggressive anti-spam.") + ] = None + hidden_members: Annotated[ + str | None, + opt("--hidden-members", metavar="ON|OFF", help="Hide the member list from non-admins."), + ] = None + signatures: Annotated[ + str | None, opt("--signatures", metavar="ON|OFF", help="Sign channel posts.") + ] = None + signature_profiles: Annotated[ + str | None, + opt("--signature-profiles", metavar="ON|OFF", help="Link signatures to profiles."), + ] = None + forum: Annotated[ + str | None, opt("--forum", metavar="ON|OFF", help="Enable or disable Topics.") + ] = None + forum_tabs: Annotated[str | None, choice("tabs", "list", help="Tabbed vs list topic UI.")] = ( + None + ) + view_as: Annotated[ + str | None, choice("messages", "topics", help="My own view preference for this forum.") + ] = None + autotranslate: Annotated[ + str | None, opt("--autotranslate", metavar="ON|OFF", help="Channel auto-translation.") + ] = None + ads: Annotated[ + str | None, opt("--ads", metavar="ON|OFF", help="`off` disables sponsored messages.") + ] = None + reactions: Annotated[ + str | None, + opt("--reactions", metavar="ALL|NONE|LIST", help="Allowed reactions."), + ] = None + allow_custom_reactions: Annotated[ + str | None, + opt("--allow-custom-reactions", metavar="ON|OFF", help="With --reactions all."), + ] = None + reactions_limit: Annotated[ + int | None, + opt("--reactions-limit", metavar="N", help="Max distinct reactions per message."), + ] = None + paid_reactions: Annotated[ + str | None, opt("--paid-reactions", metavar="ON|OFF", help="Enable Stars reactions.") + ] = None + sticker_set: Annotated[ + str | None, opt("--sticker-set", metavar="NAME", help="Group sticker set short name.") + ] = None + emoji_set: Annotated[ + str | None, opt("--emoji-set", metavar="NAME", help="Group custom-emoji pack short name.") + ] = None + paid_messages: Annotated[ + str | None, opt("--paid-messages", metavar="N|OFF", help="Stars per incoming message.") + ] = None + direct_messages: Annotated[ + str | None, + opt("--direct-messages", metavar="ON|OFF", help="Enable the channel's direct messages."), + ] = None + gift_notifications: Annotated[ + str | None, + opt("--gift-notifications", metavar="ON|OFF", help="Star-gift notices in the channel."), + ] = None + + +def _stickerset(value: str) -> Any: + from telethon.tl import types + + if value.strip().lower() in ("off", "none", "clear", ""): + return types.InputStickerSetEmpty() + return types.InputStickerSetShortName(short_name=value.strip().lstrip("@")) + + +def _reactions(value: str, allow_custom: bool | None) -> Any: + from telethon.tl import types + + text = value.strip().lower() + if text == "all": + return types.ChatReactionsAll(allow_custom=allow_custom or None) + if text in ("none", "off"): + return types.ChatReactionsNone() + reactions: list[Any] = [] + for item in value.replace(",", " ").split(): + token = item.strip() + if not token: + continue + if token.isdigit(): + reactions.append(types.ReactionCustomEmoji(document_id=int(token))) + else: + reactions.append(types.ReactionEmoji(emoticon=token)) + return types.ChatReactionsSome(reactions=reactions) + + +async def set_settings(ctx: OpContext, req: SettingSetReq) -> SettingResult: + """Apply the Manage screen's toggles, one request per key, in a fixed order. + + A toggle already in the requested state is not sent and is reported in + `already`; a key the server refuses is reported in `failed` and the rest + still run. Aborting on the first refusal would make a boost-gated colour + hide nine successful changes. + """ + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + from telethon.tl.functions import payments as pay_fn + + peer = await _send.resolve(ctx, req.chat) + current = await get_settings(ctx, SettingGetReq(chat=req.chat)) + handle = _admin.client(ctx) + channel = _admin.input_channel(peer) if _admin.is_channel(peer) else None + result = SettingResult(chat_id=peer_id_of(peer) or 0) + + async def apply(key: str, wanted: Any, is_current: Any, build: Any) -> None: + if wanted is None: + return + if wanted == is_current: + result.already.append(key) + return + try: + await handle(build()) + except Exception as exc: # one refusal must not hide the other keys + result.failed[key] = f"{type(exc).__name__}: {exc}" + return + result.changed.append(key) + + if channel is None and any( + value is not None + for value in ( + req.slow_mode, + req.prehistory, + req.join_to_send, + req.join_request, + req.antispam, + req.hidden_members, + req.signatures, + req.forum, + ) + ): + raise UsageError( + "these settings need a supergroup or channel; `chat convert <chat> supergroup` first", + field="chat", + ) + + seconds = _slow_mode(req.slow_mode, ctx) + await apply( + "slow_mode", + seconds, + current.slow_mode, + lambda: chan_fn.ToggleSlowModeRequest(channel=channel, seconds=seconds or 0), + ) + prehistory = None if req.prehistory is None else (req.prehistory == "hidden") + await apply( + "prehistory", + prehistory, + None if current.prehistory is None else current.prehistory == "hidden", + lambda: chan_fn.TogglePreHistoryHiddenRequest(channel=channel, enabled=bool(prehistory)), + ) + join_to_send = _tri(req.join_to_send) + await apply( + "join_to_send", + join_to_send, + current.join_to_send, + lambda: chan_fn.ToggleJoinToSendRequest(channel=channel, enabled=bool(join_to_send)), + ) + join_request = _tri(req.join_request) + guard = ( + _admin.input_user(await _send.resolve(ctx, req.guard_bot)) + if req.guard_bot is not None + else None + ) + await apply( + "join_request", + join_request, + current.join_request, + lambda: chan_fn.ToggleJoinRequestRequest( + channel=channel, + enabled=bool(join_request), + apply_to_invites=req.apply_to_links or None, + guard_bot=guard, + ), + ) + protect = _tri(req.protect) + await apply( + "protect", + protect, + current.noforwards, + lambda: msg_fn.ToggleNoForwardsRequest(peer=peer, enabled=bool(protect)), + ) + antispam = _tri(req.antispam) + await apply( + "antispam", + antispam, + current.antispam, + lambda: chan_fn.ToggleAntiSpamRequest(channel=channel, enabled=bool(antispam)), + ) + hidden = _tri(req.hidden_members) + await apply( + "hidden_members", + hidden, + current.hidden_members, + lambda: chan_fn.ToggleParticipantsHiddenRequest(channel=channel, enabled=bool(hidden)), + ) + signatures = _tri(req.signatures) + profiles = _tri(req.signature_profiles) + if signatures is not None or profiles is not None: + await apply( + "signatures", + (signatures if signatures is not None else True, profiles), + (current.signatures, current.signature_profiles), + lambda: chan_fn.ToggleSignaturesRequest( + channel=channel, + signatures_enabled=(signatures if signatures is not None else True) or None, + profiles_enabled=profiles or None, + ), + ) + forum = _tri(req.forum) + tabs = None if req.forum_tabs is None else req.forum_tabs == "tabs" + if forum is not None or tabs is not None: + await apply( + "forum", + (forum if forum is not None else current.forum, tabs), + (current.forum, None if current.forum_tabs is None else current.forum_tabs == "tabs"), + lambda: chan_fn.ToggleForumRequest( + channel=channel, + enabled=bool(forum if forum is not None else current.forum), + tabs=bool(tabs), + ), + ) + view_as = None if req.view_as is None else req.view_as == "messages" + await apply( + "view_as", + view_as, + None if current.view_as is None else current.view_as == "messages", + lambda: chan_fn.ToggleViewForumAsMessagesRequest(channel=channel, enabled=bool(view_as)), + ) + autotranslate = _tri(req.autotranslate) + await apply( + "autotranslate", + autotranslate, + current.autotranslate, + lambda: chan_fn.ToggleAutotranslationRequest(channel=channel, enabled=bool(autotranslate)), + ) + ads = _tri(req.ads) + await apply( + "ads", + ads, + current.ads, + lambda: chan_fn.RestrictSponsoredMessagesRequest(channel=channel, restricted=not ads), + ) + if req.reactions is not None or req.reactions_limit is not None or req.paid_reactions: + allow_custom = _tri(req.allow_custom_reactions) + available = _reactions(req.reactions or "all", allow_custom) + await apply( + "reactions", + (req.reactions, req.reactions_limit, _tri(req.paid_reactions)), + (None, None, None), + lambda: msg_fn.SetChatAvailableReactionsRequest( + peer=peer, + available_reactions=available, + reactions_limit=req.reactions_limit, + paid_enabled=_tri(req.paid_reactions), + ), + ) + if req.sticker_set is not None: + await apply( + "sticker_set", + req.sticker_set, + current.sticker_set, + lambda: chan_fn.SetStickersRequest( + channel=channel, stickerset=_stickerset(req.sticker_set or "") + ), + ) + if req.emoji_set is not None: + await apply( + "emoji_set", + req.emoji_set, + current.emoji_set, + lambda: chan_fn.SetEmojiStickersRequest( + channel=channel, stickerset=_stickerset(req.emoji_set or "") + ), + ) + if req.paid_messages is not None or req.direct_messages is not None: + stars = _int_or_off(req.paid_messages, "paid_messages") + allowed = _tri(req.direct_messages) + await apply( + "paid_messages", + (stars, allowed), + (current.paid_messages_stars, current.direct_messages), + lambda: chan_fn.UpdatePaidMessagesPriceRequest( + channel=channel, + send_paid_messages_stars=stars or 0, + broadcast_messages_allowed=allowed, + ), + ) + gifts = _tri(req.gift_notifications) + await apply( + "gift_notifications", + gifts, + current.gift_notifications, + lambda: pay_fn.ToggleChatStarGiftNotificationsRequest(peer=peer, enabled=gifts), + ) + + if not (result.changed or result.already or result.failed): + raise UsageError("nothing to change; `chat setting get` lists the keys", field="chat") + if result.already and not result.changed: + _admin.already(ctx) + if result.changed: + ctx.emit("chat_settings_changed", {"chat_id": result.chat_id, "changed": result.changed}) + return result + + +SPEC_SETTING_SET = OperationSpec( + id="chat.setting.set", + request=SettingSetReq, + response=SettingResult, + impl=set_settings, + summary="Change group/channel policy toggles (the Manage screen)", + description=( + "Idempotent: a toggle already in the requested state is reported in " + "`already` and never sent. Failures are reported per key in `failed` " + "and do not stop the rest, because a boost-gated refusal must not " + "hide the changes that did land. `--direct-messages` and " + "`--paid-messages` are the same call." + ), + mutating=True, + columns=("chat_id", "changed", "already"), + example={"chat_id": -1001500, "changed": ["slow_mode"], "already": [], "failed": {}}, + example_args="chat setting set @mygroup --slow-mode 30s --hidden-members on", + covers=( + "dialogs.channel-autotranslation", + "dialogs.forum-tabs-mode", + "emoji.status-channel", + "groups-channels-admin.channel-direct-messages", + "groups-channels-admin.group-sticker-set", + "groups-channels-admin.restrict-sponsored", + "groups-channels-admin.toggle-join-request", + "groups-channels-admin.view-forum-as-messages", + "messages-core.paid-messages-group-price", + "messages-core.translate-channel-autotranslation", + "sticker.group-sticker-set", + ), + covers_partial=( + "groups-channels-admin.antispam", + "groups-channels-admin.autotranslation", + "groups-channels-admin.channel-sponsored-messages", + "groups-channels-admin.content-protection", + "groups-channels-admin.forum-toggle", + "groups-channels-admin.gift-notifications", + "groups-channels-admin.group-emoji-set", + "groups-channels-admin.hidden-members", + "groups-channels-admin.paid-messages-price", + "groups-channels-admin.prehistory-visibility", + "groups-channels-admin.reactions-settings", + "groups-channels-admin.signatures", + "groups-channels-admin.slow-mode", + "groups-channels-admin.toggle-join-to-send", + ), + coverage_note=( + "The writing half of the Manage screen; `chat setting get` reads the " + "same keys and owns most of these ids." + ), +) + + +# --------------------------------------------------------------------------- +# chat username * +# --------------------------------------------------------------------------- + + +class UsernameGetReq(Request): + username: Annotated[str, arg(0, metavar="USERNAME", help="The name to check.")] + chat: Annotated[ + PeerRef | None, + opt("--chat", metavar="CHAT", kind="peer", help="Check it for this existing peer."), + ] = None + + +async def check_username(ctx: OpContext, req: UsernameGetReq) -> UsernameCheck: + """Availability, and — when Fragment sells it — how to buy it.""" + from telethon.tl import types + from telethon.tl.functions import channels as fn + from telethon.tl.functions import fragment as frag_fn + + handle = _admin.client(ctx) + name = req.username.lstrip("@") + channel: Any = types.InputChannelEmpty() + if req.chat is not None: + channel = _admin.input_channel(await _send.resolve(ctx, req.chat)) + try: + available = bool(await handle(fn.CheckUsernameRequest(channel=channel, username=name))) + status = "available" if available else "occupied" + except Exception as exc: + message = str(exc) + if "USERNAME_PURCHASE_AVAILABLE" in message: + status, available = "purchasable", False + elif "USERNAME_INVALID" in message: + status, available = "invalid", False + elif "USERNAME_OCCUPIED" in message: + status, available = "occupied", False + else: + raise + collectible = None + if status == "purchasable": + try: + info = await handle( + frag_fn.GetCollectibleInfoRequest( + collectible=types.InputCollectibleUsername(username=name) + ) + ) + collectible = { + "purchase_date": fmt_dt(getattr(info, "purchase_date", None)), + "currency": str(getattr(info, "currency", "") or ""), + "amount": int(getattr(info, "amount", 0) or 0), + "crypto_currency": str(getattr(info, "crypto_currency", "") or ""), + "crypto_amount": int(getattr(info, "crypto_amount", 0) or 0), + "url": str(getattr(info, "url", "") or ""), + } + except Exception as exc: # pragma: no cover - Fragment is optional + ctx.warn(f"Fragment did not answer for @{name}: {exc}") + return UsernameCheck(username=name, status=status, available=available, collectible=collectible) + + +SPEC_USERNAME_GET = OperationSpec( + id="chat.username.get", + request=UsernameGetReq, + response=UsernameCheck, + impl=check_username, + summary="Check a username: availability, owner, and Fragment collectible info", + description=( + "`status` is available, occupied, invalid or purchasable. On " + "USERNAME_PURCHASE_AVAILABLE the Fragment purchase date, currency, " + "amount and URL are attached — buying happens on fragment.com, not " + "through the API." + ), + columns=("username", "status", "available"), + example={"username": "mynews", "status": "available", "available": True}, + example_args="chat username get mynews", + covers=( + "groups-channels-admin.check-username", + "groups-channels-admin.collectible-username-info", + ), +) + + +class UsernameSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + username: Annotated[ + str | None, arg(1, metavar="USERNAME", required=False, help="The public link to claim.") + ] = None + order: Annotated[ + str | None, opt("--order", metavar="LIST", help="Set the display order of the usernames.") + ] = None + upgrade: Annotated[ + bool, opt("--upgrade", help="Migrate a basic group to a supergroup first, if needed.") + ] = False + + +async def set_username(ctx: OpContext, req: UsernameSetReq) -> UsernameResult: + """Claim the public link, or reorder the additional usernames.""" + from telethon.tl.functions import channels as fn + + if bool(req.username) == bool(req.order): + raise UsageError("give exactly one of <username> and --order", field="username") + peer = await _send.resolve(ctx, req.chat) + if not _admin.is_channel(peer): + if not req.upgrade: + raise UsageError( + "a basic group cannot have a username; pass --upgrade to migrate it " + "to a supergroup first (this is one-way)", + field="chat", + ) + migrated = await convert_chat(ctx, ConvertReq(chat=req.chat, target="supergroup")) + peer = await _send.resolve(ctx, str(migrated.chat_id)) + channel = _admin.input_channel(peer) + handle = _admin.client(ctx) + if req.order: + order = [item.strip().lstrip("@") for item in req.order.replace(",", " ").split() if item] + await handle(fn.ReorderUsernamesRequest(channel=channel, order=order)) + return UsernameResult(chat_id=peer_id_of(peer) or 0, usernames=order) + name = (req.username or "").lstrip("@") + await handle(fn.UpdateUsernameRequest(channel=channel, username=name)) + ctx.emit("chat_username_changed", {"chat_id": peer_id_of(peer) or 0, "username": name}) + return UsernameResult(chat_id=peer_id_of(peer) or 0, username=name, link=f"https://t.me/{name}") + + +SPEC_USERNAME_SET = OperationSpec( + id="chat.username.set", + request=UsernameSetReq, + response=UsernameResult, + impl=set_username, + summary="Set the public link, or the display order of the additional usernames", + description=( + "Exactly one of `<username>` and `--order` is required. A basic " + "group has no username at all: `--upgrade` migrates it first, and " + "never silently. Public groups are forced to a visible prehistory by " + "the server, and the public-peer count is capped per account." + ), + mutating=True, + columns=("chat_id", "username", "link"), + example={"chat_id": -1001500, "username": "mynews", "link": "https://t.me/mynews"}, + example_args="chat username set @mygroup mynews", + covers=( + "groups-channels-admin.public-private-toggle", + "groups-channels-admin.set-username", + "groups-channels-admin.username-reorder", + ), + tags=frozenset({"visible-to-others"}), +) + + +class UsernameToggleReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + username: Annotated[str, arg(1, metavar="USERNAME", help="The additional username.")] + state: Annotated[str, arg(2, metavar="ON|OFF", help="Activate or deactivate it.")] + + +async def toggle_username(ctx: OpContext, req: UsernameToggleReq) -> UsernameResult: + """Activate or deactivate one additional (collectible) username.""" + from telethon.tl.functions import channels as fn + + peer = await _send.resolve(ctx, req.chat) + active = _tri(req.state) + name = req.username.lstrip("@") + await _admin.client(ctx)( + fn.ToggleUsernameRequest( + channel=_admin.input_channel(peer), username=name, active=bool(active) + ) + ) + return UsernameResult(chat_id=peer_id_of(peer) or 0, usernames=[name] if active else []) + + +SPEC_USERNAME_TOGGLE = OperationSpec( + id="chat.username.toggle", + request=UsernameToggleReq, + response=UsernameResult, + impl=toggle_username, + summary="Activate or deactivate one additional username", + mutating=True, + columns=("chat_id", "usernames"), + example={"chat_id": -1001500, "usernames": ["mynews"]}, + example_args="chat username toggle @mygroup mynews off", + covers=("groups-channels-admin.username-toggle",), +) + + +class UsernameUnsetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + everything: Annotated[ + bool, opt("--all", help="Deactivate every username; collectibles stay reserved.") + ] = False + + +async def unset_username(ctx: OpContext, req: UsernameUnsetReq) -> UsernameResult: + """Make a chat private, and print the invite link that keeps it reachable.""" + from telethon.tl.functions import channels as fn + from telethon.tl.functions import messages as msg_fn + + peer = await _send.resolve(ctx, req.chat) + channel = _admin.input_channel(peer) + handle = _admin.client(ctx) + if req.everything: + await handle(fn.DeactivateAllUsernamesRequest(channel=channel)) + else: + await handle(fn.UpdateUsernameRequest(channel=channel, username="")) + invite = await handle(msg_fn.ExportChatInviteRequest(peer=peer)) + return UsernameResult( + chat_id=peer_id_of(peer) or 0, + usernames=[], + invite_link=str(getattr(invite, "link", "") or ""), + ) + + +SPEC_USERNAME_UNSET = OperationSpec( + id="chat.username.unset", + request=UsernameUnsetReq, + response=UsernameResult, + impl=unset_username, + summary="Make a group/channel private by clearing its username(s)", + description=( + "Prints the private invite link afterwards, so the chat stays " + "reachable rather than becoming unfindable in one command." + ), + mutating=True, + columns=("chat_id", "invite_link"), + example={"chat_id": -1001500, "usernames": [], "invite_link": "https://t.me/+AbCdEf"}, + example_args="chat username unset @mygroup", + covers=("groups-channels-admin.username-deactivate-all",), + covers_partial=("groups-channels-admin.public-private-toggle",), + coverage_note="Going private is here; `chat username set` goes public and owns the id.", +) + + +# --------------------------------------------------------------------------- +# chat photo set / delete +# --------------------------------------------------------------------------- + + +async def _set_photo( + ctx: OpContext, + peer: Any, + *, + file: str | None = None, + video: str | None = None, + video_start: float | None = None, + emoji_markup: int | None = None, + emoji_bg: str = "", +) -> int | None: + from telethon.tl import types + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + handle = _admin.client(ctx) + markup = None + if emoji_markup: + colors = [int(c) for c in emoji_bg.replace(",", " ").split() if c.strip().isdigit()] + markup = types.VideoSizeEmojiMarkup( + emoji_id=emoji_markup, background_colors=colors or [0xFFFFFF] + ) + # The file pipeline is a service on the context, not an import: `ops/` + # may not reach into `daemon/` (§2.2). + upload: Any = getattr(ctx, "upload_file", None) + if (file or video) and upload is None: # pragma: no cover - the daemon supplies it + raise UsageError("this context cannot upload files") + uploaded_file = await upload(file) if file else None + uploaded_video = await upload(video) if video else None + photo: Any = types.InputChatUploadedPhoto( + file=uploaded_file, + video=uploaded_video, + video_start_ts=video_start, + video_emoji_markup=markup, + ) + if _admin.is_channel(peer): + updates = await handle( + chan_fn.EditPhotoRequest(channel=_admin.input_channel(peer), photo=photo) + ) + else: + updates = await handle( + msg_fn.EditChatPhotoRequest(chat_id=_admin.small_chat_id(peer), photo=photo) + ) + for chat in getattr(updates, "chats", None) or []: + candidate = getattr(getattr(chat, "photo", None), "photo_id", None) + if candidate: + return int(candidate) + return None + + +class PhotoSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + file: Annotated[ + str | None, arg(1, metavar="FILE", kind="path", required=False, help="The still image.") + ] = None + video: Annotated[ + str | None, opt("--video", metavar="PATH", kind="path", help="Animated avatar.") + ] = None + video_start: Annotated[ + float | None, opt("--video-start", metavar="SECONDS", help="Still frame from --video.") + ] = None + emoji_markup: Annotated[ + int | None, opt("--emoji-markup", metavar="ID", help="Build the avatar from an emoji.") + ] = None + emoji_bg: Annotated[ + str, opt("--emoji-bg", metavar="COLORS", help="Background palette for --emoji-markup.") + ] = "" + + +async def set_photo(ctx: OpContext, req: PhotoSetReq) -> ChatPhotoResult: + """Set the chat photo: a still, a video avatar, or a custom emoji.""" + given = [bool(req.file), bool(req.video), bool(req.emoji_markup)] + if sum(given) != 1: + raise UsageError("give exactly one of <file>, --video and --emoji-markup", field="file") + peer = await _send.resolve(ctx, req.chat) + photo_id = await _set_photo( + ctx, + peer, + file=req.file, + video=req.video, + video_start=req.video_start, + emoji_markup=req.emoji_markup, + emoji_bg=req.emoji_bg, + ) + return ChatPhotoResult(chat_id=peer_id_of(peer) or 0, photo_id=photo_id) + + +SPEC_PHOTO_SET = OperationSpec( + id="chat.photo.set", + request=PhotoSetReq, + response=ChatPhotoResult, + impl=set_photo, + summary="Set the group/channel photo (still, video or emoji avatar)", + description="Exactly one of `<file>`, `--video` and `--emoji-markup` is required.", + mutating=True, + rate_class="file", + columns=("chat_id", "photo_id"), + example={"chat_id": -1001500, "photo_id": 5522}, + example_args="chat photo set @mygroup ./logo.png", + covers=("chat.photo-set", "groups-channels-admin.edit-photo"), + tags=frozenset({"visible-to-others"}), +) + + +class PhotoDeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + + +async def delete_photo(ctx: OpContext, req: PhotoDeleteReq) -> ChatPhotoResult: + """Remove the chat photo.""" + from telethon.tl import types + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + peer = await _send.resolve(ctx, req.chat) + empty = types.InputChatPhotoEmpty() + if _admin.is_channel(peer): + await _admin.client(ctx)( + chan_fn.EditPhotoRequest(channel=_admin.input_channel(peer), photo=empty) + ) + else: + await _admin.client(ctx)( + msg_fn.EditChatPhotoRequest(chat_id=_admin.small_chat_id(peer), photo=empty) + ) + return ChatPhotoResult(chat_id=peer_id_of(peer) or 0, ok=True) + + +SPEC_PHOTO_DELETE = OperationSpec( + id="chat.photo.delete", + request=PhotoDeleteReq, + response=ChatPhotoResult, + impl=delete_photo, + summary="Remove the group/channel photo", + mutating=True, + columns=("chat_id", "ok"), + example={"chat_id": -1001500, "ok": True}, + example_args="chat photo delete @mygroup", + covers=("groups-channels-admin.remove-photo",), +) + + +# --------------------------------------------------------------------------- +# chat send-as list / set +# --------------------------------------------------------------------------- + + +class SendAsListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + + +async def list_send_as(ctx: OpContext, req: SendAsListReq) -> list[SendAsPeer]: + """The peers you may post as here, with the current default marked.""" + from telethon.tl.functions import channels as fn + + peer = await _send.resolve(ctx, req.chat) + reply = await _admin.client(ctx)(fn.GetSendAsRequest(peer=peer)) + entities = _admin.entity_map(reply) + full, _entity, _entities = await _admin.full_chat(ctx, peer) + default_raw = getattr(full, "default_send_as", None) + default_id = peer_id_of(default_raw) if default_raw is not None else None + out: list[SendAsPeer] = [] + for row in getattr(reply, "peers", None) or []: + marked = peer_id_of(getattr(row, "peer", None)) or 0 + entity = entities.get(marked) + out.append( + SendAsPeer( + id=marked, + type=("user" if marked > 0 else "channel"), + title=_admin.display_name(entity), + premium_required=bool(getattr(row, "premium_required", False)), + default=marked == default_id, + ) + ) + return out + + +SPEC_SEND_AS_LIST = OperationSpec( + id="chat.send-as.list", + request=SendAsListReq, + response=list[SendAsPeer], + impl=list_send_as, + summary="Peers I may post as in this chat", + columns=("id", "type", "title", "default"), + example=[{"id": -1001500, "type": "channel", "title": "News", "default": True}], + example_args="chat send-as list @mygroup", + covers_partial=("groups-channels-admin.send-as",), + coverage_note="Listing half; `chat send-as set` writes the default and owns the id.", +) + + +class SendAsSetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + peer: Annotated[PeerRef, arg(1, metavar="PEER", kind="peer", help="Who to post as.")] + + +async def set_send_as(ctx: OpContext, req: SendAsSetReq) -> SendAsResult: + """Set the default identity for this chat. Reactions follow it too.""" + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + identity = await _send.resolve(ctx, req.peer) + await _admin.client(ctx)(fn.SaveDefaultSendAsRequest(peer=peer, send_as=identity)) + return SendAsResult(chat_id=peer_id_of(peer) or 0, send_as=peer_id_of(identity) or 0) + + +SPEC_SEND_AS_SET = OperationSpec( + id="chat.send-as.set", + request=SendAsSetReq, + response=SendAsResult, + impl=set_send_as, + summary="Set the default peer I post as in this chat", + description=( + "Applies to reactions as well as messages. Every send command still " + "takes a one-off `--send-as`. The `anonymous` admin right forces the " + "group itself." + ), + mutating=True, + columns=("chat_id", "send_as"), + example={"chat_id": -1001500, "send_as": -1001600}, + example_args="chat send-as set @mygroup @mychannel", + covers=("groups-channels-admin.send-as",), +) + + +# --------------------------------------------------------------------------- +# chat discussion list / set / unset +# --------------------------------------------------------------------------- + + +class DiscussionListReq(Request): + pass + + +async def list_discussion_candidates( + ctx: OpContext, req: DiscussionListReq +) -> list[DiscussionCandidate]: + """Groups that could become a channel's discussion group.""" + from telethon.tl.functions import channels as fn + + reply = await _admin.client(ctx)(fn.GetGroupsForDiscussionRequest()) + out: list[DiscussionCandidate] = [] + for chat in getattr(reply, "chats", None) or []: + is_basic = type(chat).__name__ == "Chat" + out.append( + DiscussionCandidate( + id=_admin.entity_id(chat), + title=str(getattr(chat, "title", "") or ""), + type="group" if is_basic else "supergroup", + needs_migration=is_basic, + ) + ) + return out + + +SPEC_DISCUSSION_LIST = OperationSpec( + id="chat.discussion.list", + request=DiscussionListReq, + response=list[DiscussionCandidate], + impl=list_discussion_candidates, + summary="Groups eligible to become a channel's discussion group", + description=( + "A basic group in this list must be converted first " + "(`chat convert <chat> supergroup`); `needs_migration` says which." + ), + columns=("id", "title", "type", "needs_migration"), + example=[{"id": -1001500, "title": "News chat", "type": "supergroup"}], + example_args="chat discussion list", + covers=("groups-channels-admin.discussion-candidates",), +) + + +class DiscussionSetReq(Request): + channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The broadcast.")] + group: Annotated[PeerRef, arg(1, metavar="GROUP", kind="peer", help="The discussion group.")] + unhide_prehistory: Annotated[ + bool, opt("--unhide-prehistory", help="Make the group's prehistory visible first.") + ] = False + + +async def set_discussion(ctx: OpContext, req: DiscussionSetReq) -> DiscussionResult: + """Link a discussion group to a channel.""" + from telethon.tl.functions import channels as fn + + channel = _admin.input_channel(await _send.resolve(ctx, req.channel)) + group_peer = await _send.resolve(ctx, req.group) + group = _admin.input_channel(group_peer) + handle = _admin.client(ctx) + if req.unhide_prehistory: + await handle(fn.TogglePreHistoryHiddenRequest(channel=group, enabled=False)) + try: + await handle(fn.SetDiscussionGroupRequest(broadcast=channel, group=group)) + except Exception as exc: + if "LINK_NOT_MODIFIED" not in str(exc): + raise + _admin.already(ctx) + return DiscussionResult( + channel_id=peer_id_of(await _send.resolve(ctx, req.channel)) or 0, + linked_chat_id=peer_id_of(group_peer), + already=True, + ) + return DiscussionResult( + channel_id=peer_id_of(await _send.resolve(ctx, req.channel)) or 0, + linked_chat_id=peer_id_of(group_peer), + ) + + +SPEC_DISCUSSION_SET = OperationSpec( + id="chat.discussion.set", + request=DiscussionSetReq, + response=DiscussionResult, + impl=set_discussion, + summary="Link a discussion group to a channel", + description=( + "The server refuses a group whose prehistory is hidden; " + "`--unhide-prehistory` makes it visible first, and says so rather " + "than doing it silently. LINK_NOT_MODIFIED reports `already: true`." + ), + mutating=True, + columns=("channel_id", "linked_chat_id"), + example={"channel_id": -1001600, "linked_chat_id": -1001500}, + example_args="chat discussion set @mychannel @mygroup", + covers=("groups-channels-admin.discussion-link",), +) + + +class DiscussionUnsetReq(Request): + channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The broadcast.")] + + +async def unset_discussion(ctx: OpContext, req: DiscussionUnsetReq) -> DiscussionResult: + """Unlink a channel's discussion group.""" + from telethon.tl import types + from telethon.tl.functions import channels as fn + + peer = await _send.resolve(ctx, req.channel) + await _admin.client(ctx)( + fn.SetDiscussionGroupRequest( + broadcast=_admin.input_channel(peer), group=types.InputChannelEmpty() + ) + ) + return DiscussionResult(channel_id=peer_id_of(peer) or 0, linked_chat_id=None) + + +SPEC_DISCUSSION_UNSET = OperationSpec( + id="chat.discussion.unset", + request=DiscussionUnsetReq, + response=DiscussionResult, + impl=unset_discussion, + summary="Unlink a channel's discussion group", + mutating=True, + columns=("channel_id", "linked_chat_id"), + example={"channel_id": -1001600}, + example_args="chat discussion unset @mychannel", + covers=("groups-channels-admin.discussion-unlink",), +) diff --git a/tlgr/ops/chat_member.py b/tlgr/ops/chat_member.py new file mode 100644 index 0000000..bb85550 --- /dev/null +++ b/tlgr/ops/chat_member.py @@ -0,0 +1,1357 @@ +"""`chat member *` and `chat request *`: who is in a chat, and on what terms. + +Three shapes are deliberate, and each one is a v1 bug turned into a rule. + +* **A member is a participant, not a user.** v1's `chat members` returned + `{id, first_name, username, is_bot}`; the `ChannelParticipant*` wrapper — + status, rank, join date, inviter, promoter, both rights masks — went in the + bin. `chat members` still works and still answers with those keys, but the + row around them is now a whole `Participant`. +* **Restricting is a read-modify-write.** `channels.editBanned` replaces the + entire mask, so sending only the flags a caller named would silently give + back every restriction they did not mention. The current mask is fetched, + patched, and sent complete. +* **Kick and ban are different operations.** A kick is `editBanned(view)` + followed by an empty mask, so the person may come back; a ban leaves the + mask in place. v1 had one command and it did the second thing while + reading like the first. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import asyncio +from typing import Annotated, Any + +from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.admin import ( + JoinRequest, + MemberResult, + MembersAdded, + MissingInvitee, + Participant, + RequestResult, +) +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef, Rights +from tlgr.ops import _admin, _rights, _send +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_")] + +_EXAMPLE_MEMBER: dict[str, Any] = { + "id": 4242, + "user_id": 4242, + "name": "Alice", + "username": "alice", + "status": "admin", + "rank": "moderator", + "date": "2026-02-01T10:00:00Z", +} + +#: `channels.getParticipants` filters, keyed the way the CLI spells them. +_FILTERS = { + "recent": "ChannelParticipantsRecent", + "admins": "ChannelParticipantsAdmins", + "bots": "ChannelParticipantsBots", + "contacts": "ChannelParticipantsContacts", + "kicked": "ChannelParticipantsKicked", + "banned": "ChannelParticipantsBanned", + "restricted": "ChannelParticipantsBanned", + "mentions": "ChannelParticipantsMentions", + "search": "ChannelParticipantsSearch", +} + +#: `messages.missingInvitee` flags → the reason string tlgr reports. +_MISSING_REASONS = ( + ("premium_would_allow_invite", "premium-would-allow-invite"), + ("premium_required_for_pm", "premium-required-for-pm"), +) + + +def _participants_filter(name: str, search: str, topic: int | None) -> Any: + """The `ChannelParticipantsFilter` for a CLI `--filter` name.""" + from telethon.tl import types + + if search and name == "recent": + name = "search" + cls = getattr(types, _FILTERS[name]) + if name in ("kicked", "banned", "restricted", "search", "mentions"): + if name == "mentions": + return cls(q=search or None, top_msg_id=topic) + return cls(q=search or "") + return cls() + + +def _missing(raw: Any) -> list[MissingInvitee]: + """`messages.invitedUsers.missing_invitees`, verbatim rather than dropped.""" + out: list[MissingInvitee] = [] + for item in getattr(raw, "missing_invitees", None) or []: + reason = "privacy-restricted" + for flag, label in _MISSING_REASONS: + if getattr(item, flag, False): + reason = label + break + out.append(MissingInvitee(user_id=int(getattr(item, "user_id", 0) or 0), reason=reason)) + return out + + +async def _default_banned(ctx: OpContext, peer: Any) -> Rights | None: + """The chat-wide default mask, for the `effective_permissions` column.""" + try: + _full, entity, _entities = await _admin.full_chat(ctx, peer) + except UsageError: + return None + return _rights.model_from_banned(getattr(entity, "default_banned_rights", None)) + + +async def _member_of(ctx: OpContext, peer: Any, user: Any) -> Any: + """The raw participant object, or NOT_FOUND. + + Fetched before every restrict/promote so the mask that goes back is the + caller's change applied to what is actually there. + """ + from telethon.tl.functions import channels as fn + + if _admin.is_channel(peer): + try: + reply = await _admin.client(ctx)( + fn.GetParticipantRequest(channel=_admin.input_channel(peer), participant=user) + ) + except Exception as exc: + if type(exc).__name__ == "UserNotParticipantError": + raise NotFoundError("that user is not a member of this chat") from exc + raise + return reply + full, _entity, entities = await _admin.full_chat(ctx, peer) + wanted = _send.peer_id_of(user) + holder = getattr(full, "participants", None) + for row in getattr(holder, "participants", None) or []: + if int(getattr(row, "user_id", 0) or 0) == abs(wanted): + return type("_Small", (), {"participant": row, "users": list(entities.values())})() + raise NotFoundError("that user is not a member of this chat") + + +# --------------------------------------------------------------------------- +# chat member list +# --------------------------------------------------------------------------- + + +class MemberListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + filter: Annotated[ + str, + choice( + "recent", + "admins", + "bots", + "contacts", + "kicked", + "banned", + "restricted", + "mentions", + help="Which participant list. kicked = removed, restricted = still in the chat.", + ), + ] = "recent" + search: Annotated[ + str, opt("--search", "-s", metavar="TEXT", help="Server-side name query.") + ] = "" + topic: Annotated[ + int | None, + opt("--topic", metavar="ID", kind="msg_id", help="With --filter mentions: one topic."), + ] = None + via_link: Annotated[ + str | None, + opt("--via-link", metavar="LINK", help="Only people who joined through this invite link."), + ] = None + subscription_expired: Annotated[ + bool, + opt("--subscription-expired", help="With --via-link: lapsed paid subscribers."), + ] = False + bots: Annotated[bool, opt("--bots", help="Shorthand for --filter bots.")] = False + admins: Annotated[bool, opt("--admins", help="Shorthand for --filter admins (v1).")] = False + + +async def _importers( + ctx: OpContext, req: MemberListReq, peer: Any, limit: int, state: dict[str, Any], op: str +) -> Page[Participant]: + """`--via-link`: who came in through one invite (messages.getChatInviteImporters).""" + from datetime import datetime, timezone + + from telethon.tl import types + from telethon.tl.functions import messages as fn + + offset_date = state.get("date") + offset_user: Any = types.InputUserEmpty() + if state.get("user"): + offset_user = types.InputUser( + user_id=int(state["user"]), access_hash=int(state.get("hash", 0) or 0) + ) + reply = await _admin.client(ctx)( + fn.GetChatInviteImportersRequest( + peer=peer, + offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc) + if offset_date + else None, + offset_user=offset_user, + limit=limit, + link=req.via_link, + q=req.search or None, + subscription_expired=req.subscription_expired or None, + ) + ) + entities = _admin.entity_map(reply) + chat_id = _send.peer_id_of(peer) + items: list[Participant] = [] + last: Any = None + for row in getattr(reply, "importers", None) or []: + user_id = int(getattr(row, "user_id", 0) or 0) + entity = entities.get(user_id) + items.append( + Participant( + id=user_id, + user_id=user_id, + chat_id=chat_id, + peer=_admin.peer_row(entity) if entity is not None else None, + username=getattr(entity, "username", None), + name=_admin.display_name(entity), + is_bot=bool(getattr(entity, "bot", False)), + status="member", + date=fmt_dt(getattr(row, "date", None)), + date_unix=to_unix(getattr(row, "date", None)), + about=getattr(row, "about", None), + approved_by=getattr(row, "approved_by", None), + via_link=req.via_link, + ) + ) + last = row + next_state: dict[str, Any] = {} + if last is not None: + entity = entities.get(int(getattr(last, "user_id", 0) or 0)) + next_state = { + "date": to_unix(getattr(last, "date", None)) or 0, + "user": int(getattr(last, "user_id", 0) or 0), + "hash": int(getattr(entity, "access_hash", 0) or 0), + } + return build_page( + items, + op=op, + kind=PageKind.PARTICIPANTS, + state=next_state, + account=ctx.account, + limit=limit, + total=int(getattr(reply, "count", 0) or 0), + ) + + +async def list_members(ctx: OpContext, req: MemberListReq) -> Page[Participant]: + """One page of a chat's participants, wrapper and all.""" + from telethon.tl.functions import channels as fn + + limit, state = _admin.window(ctx, "chat.member.list", PageKind.PARTICIPANTS) + peer = await _send.resolve(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + + if req.via_link: + return await _importers(ctx, req, peer, limit, state, "chat.member.list") + + name = "bots" if req.bots else ("admins" if req.admins else req.filter) + if not _admin.is_channel(peer): + # A basic group has no participant paging at all: the whole list + # arrives inside chatFull, and slicing it here is the only offset. + full, entity, entities = await _admin.full_chat(ctx, peer) + holder = getattr(full, "participants", None) + rows = list(getattr(holder, "participants", None) or []) + if name == "admins": + rows = [r for r in rows if type(r).__name__ != "ChatParticipant"] + if name == "bots": + rows = [ + r + for r in rows + if getattr(entities.get(int(getattr(r, "user_id", 0) or 0)), "bot", False) + ] + default = _rights.model_from_banned(getattr(entity, "default_banned_rights", None)) + offset = int(state.get("offset", 0) or 0) + window = rows[offset : offset + limit] + items = [ + _admin.participant_model( + row, chat_id=chat_id, entities=entities, default_banned=default + ) + for row in window + ] + return build_page( + items, + op="chat.member.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(window)}, + account=ctx.account, + has_more=offset + len(window) < len(rows), + total=len(rows), + ) + + offset = int(state.get("offset", 0) or 0) + reply = await _admin.client(ctx)( + fn.GetParticipantsRequest( + channel=_admin.input_channel(peer), + filter=_participants_filter(name, req.search, req.topic), + offset=offset, + limit=limit, + hash=0, + ) + ) + entities = _admin.entity_map(reply) + default = await _default_banned(ctx, peer) if name in ("banned", "restricted") else None + rows = list(getattr(reply, "participants", None) or []) + if name == "restricted": + rows = [r for r in rows if not getattr(r, "left", False)] + elif name == "banned": + rows = [r for r in rows if getattr(r, "left", False) or True] + items = [ + _admin.participant_model(row, chat_id=chat_id, entities=entities, default_banned=default) + for row in rows + ] + return build_page( + items, + op="chat.member.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(rows)}, + account=ctx.account, + limit=limit, + total=int(getattr(reply, "count", 0) or 0), + ) + + +SPEC_MEMBER_LIST = OperationSpec( + id="chat.member.list", + request=MemberListReq, + response=Page[Participant], + impl=list_members, + summary="List members with the participant filters the API offers", + description=( + "Every row keeps its `ChannelParticipant*` wrapper: status, rank, " + "join date, inviter, promoter and both rights masks. `--filter " + "kicked` is people who were removed; `--filter restricted` is people " + "still in the chat with a mask on them. A chat with " + "`participants_hidden` answers PERMISSION_DENIED rather than an " + "empty page, because " + "“nobody is in this group” would be a lie." + ), + aliases=("chat.members",), + legacy_paths=("chat members",), + paginated=PageKind.PARTICIPANTS, + columns=("id", "username", "name", "status", "rank"), + headers=("ID", "Username", "Name", "Status", "Rank"), + example={"items": [_EXAMPLE_MEMBER], "has_more": False, "total": 1}, + example_args="chat member list @mygroup --filter admins", + covers=( + "groups-channels-admin.banned-list", + "groups-channels-admin.channel-subscriptions-admin", + "groups-channels-admin.invite-link-importers", + "groups-channels-admin.member-mention-autocomplete", + "groups-channels-admin.members-list", + ), + covers_partial=("groups-channels-admin.admin-list",), + coverage_note="`--filter admins` lists them; `chat admin list` is the primary owner.", +) + + +# --------------------------------------------------------------------------- +# chat member get +# --------------------------------------------------------------------------- + + +class MemberGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="The member.")] + + +async def get_member(ctx: OpContext, req: MemberGetReq) -> Participant: + """One member's status, rights, rank, join date and inviter.""" + peer = await _send.resolve(ctx, req.chat) + user = await _send.resolve(ctx, req.user) + reply = await _member_of(ctx, peer, user) + entities = _admin.entity_map(reply) + return _admin.participant_model( + reply.participant, + chat_id=_send.peer_id_of(peer), + entities=entities, + default_banned=await _default_banned(ctx, peer), + ) + + +SPEC_MEMBER_GET = OperationSpec( + id="chat.member.get", + request=MemberGetReq, + response=Participant, + impl=get_member, + summary="One member's status, rights, rank, join date and inviter", + description=( + "`effective_permissions` is the chat's defaults patched with this " + "member's own mask, in the same allow-polarity vocabulary " + "`chat permission get` prints — which is the answer to “what may " + "this person actually do” rather than “what did an admin type”." + ), + columns=("id", "name", "status", "rank"), + example=_EXAMPLE_MEMBER, + example_args="chat member get @mygroup @alice", + empty_exit=EXIT_EMPTY, + covers=( + "groups-channels-admin.member-get", + "groups-channels-admin.member-invited-by", + "groups-channels-admin.member-permissions-view", + ), +) + + +# --------------------------------------------------------------------------- +# chat member add +# --------------------------------------------------------------------------- + + +class MemberAddReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[ + list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Who to add.") + ] = [] + forward_history: Annotated[ + int, + opt("--forward-history", metavar="N", help="Basic groups: past messages the member sees."), + ] = 0 + invite_link_fallback: Annotated[ + bool, + opt("--invite-link-fallback", help="DM the invite link to anyone who could not be added."), + ] = False + + +async def add_members(ctx: OpContext, req: MemberAddReq) -> MembersAdded: + """Add members, and say by name who could not be added and why. + + `messages.invitedUsers.missing_invitees` is reported verbatim: "added 3 + of 5" with no names is not something a script can act on, and privacy + settings make partial failure the normal case rather than the exception. + """ + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + if not req.user: + raise UsageError("name at least one user to add", field="user") + peer = await _send.resolve(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + users = [_admin.input_user(await _send.resolve(ctx, ref)) for ref in req.user] + handle = _admin.client(ctx) + + missing: list[MissingInvitee] = [] + if _admin.is_channel(peer): + reply = await handle( + chan_fn.InviteToChannelRequest(channel=_admin.input_channel(peer), users=users) + ) + missing = _missing(reply) + else: + for user in users: + reply = await handle( + msg_fn.AddChatUserRequest( + chat_id=_admin.small_chat_id(peer), + user_id=user, + fwd_limit=req.forward_history, + ) + ) + missing.extend(_missing(reply)) + + refused = {item.user_id for item in missing} + added = [int(getattr(u, "user_id", 0) or 0) for u in users] + added = [uid for uid in added if uid not in refused] + invited: list[int] = [] + if missing and req.invite_link_fallback: + invited = await _dm_the_link(ctx, peer, missing) + ctx.emit("chat_members_added", {"chat_id": chat_id, "added": added}) + return MembersAdded(chat_id=chat_id, added=added, missing=missing, invited_by_link=invited) + + +async def _dm_the_link(ctx: OpContext, peer: Any, missing: list[MissingInvitee]) -> list[int]: + """`--invite-link-fallback`: send the link to whoever could not be added. + + Opt-in because it *sends a message* on the owner's behalf to people who + have already expressed a privacy preference against being added. + """ + from telethon.tl.functions import messages as fn + + handle = _admin.client(ctx) + invite = await handle(fn.ExportChatInviteRequest(peer=peer)) + link = str(getattr(invite, "link", "") or "") + reached: list[int] = [] + for item in missing: + try: + target = await _send.resolve(ctx, str(item.user_id)) + await handle.send_message(target, link) + reached.append(item.user_id) + except Exception as exc: + ctx.warn(f"could not send the invite link to {item.user_id}: {exc}") + return reached + + +SPEC_MEMBER_ADD = OperationSpec( + id="chat.member.add", + request=MemberAddReq, + response=MembersAdded, + impl=add_members, + summary="Add members to a group or channel", + description=( + "`missing` carries `messages.invitedUsers.missing_invitees` verbatim " + "— one `{user_id, reason}` per refusal, with reason in " + "privacy-restricted, premium-would-allow-invite or " + "premium-required-for-pm. `--invite-link-fallback` DMs the link to " + "them, and is opt-in because it sends a message on your behalf." + ), + mutating=True, + rate_class="bulk", + columns=("chat_id", "added"), + example={"chat_id": -1001500, "added": [4242], "missing": []}, + example_args="chat member add @mygroup @alice @carol", + covers=( + "groups-channels-admin.add-members", + "groups-channels-admin.add-members-failure-report", + ), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# Moderation: remove / ban / unban +# --------------------------------------------------------------------------- + + +async def _purge(ctx: OpContext, peer: Any, user: Any, ids: list[int]) -> int: + """`--purge` / `--messages`: everything that member wrote, or named ids.""" + from telethon.tl.functions import channels as fn + + channel = _admin.input_channel(peer) + removed = 0 + if ids: + reply = await _admin.client(ctx)(fn.DeleteMessagesRequest(channel=channel, id=list(ids))) + removed += int(getattr(reply, "pts_count", 0) or 0) + return removed + + +async def _purge_all(ctx: OpContext, peer: Any, user: Any) -> int: + from telethon.tl.functions import channels as fn + + channel = _admin.input_channel(peer) + return await _admin.affected_loop( + ctx, + lambda _offset: fn.DeleteParticipantHistoryRequest(channel=channel, participant=user), + ) + + +async def _report_spam(ctx: OpContext, peer: Any, user: Any, ids: list[int]) -> bool: + from telethon.tl.functions import channels as fn + + await _admin.client(ctx)( + fn.ReportSpamRequest( + channel=_admin.input_channel(peer), participant=user, id=list(ids) or [] + ) + ) + return True + + +class MemberRemoveReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[ + list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Who to remove.") + ] = [] + purge: Annotated[bool, opt("--purge", help="Delete everything they ever sent here.")] = False + messages: Annotated[ + list[int], opt("--messages", metavar="ID", help="Also delete these message ids.") + ] = [] + report: Annotated[bool, opt("--report", help="Report the messages as spam too.")] = False + + +async def remove_member(ctx: OpContext, req: MemberRemoveReq) -> list[MemberResult]: + """Kick: ban, then immediately lift the ban, so they may rejoin. + + Telegram has no kick method. `editBanned(view_messages=True)` followed by + an empty mask is what every client calls a kick, and the second call is + the whole difference from `chat member ban`. + """ + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + if not req.user: + raise UsageError("name at least one user to remove", field="user") + peer = await _send.resolve(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + handle = _admin.client(ctx) + out: list[MemberResult] = [] + for ref in req.user: + user = await _send.resolve(ctx, ref) + if _admin.is_channel(peer): + channel = _admin.input_channel(peer) + await handle( + chan_fn.EditBannedRequest( + channel=channel, + participant=user, + banned_rights=_rights.build_banned_rights([]), + ) + ) + await asyncio.sleep(0) + await handle( + chan_fn.EditBannedRequest( + channel=channel, + participant=user, + banned_rights=_rights.build_banned_rights(_rights.all_allowed()), + ) + ) + else: + await handle( + msg_fn.DeleteChatUserRequest( + chat_id=_admin.small_chat_id(peer), user_id=_admin.input_user(user) + ) + ) + purged = await _purge_all(ctx, peer, user) if req.purge else None + if req.messages: + purged = (purged or 0) + await _purge(ctx, peer, user, req.messages) + reported = ( + await _report_spam(ctx, peer, user, req.messages) + if (req.report and _admin.is_channel(peer)) + else None + ) + out.append( + MemberResult( + chat_id=chat_id, + user_id=abs(_send.peer_id_of(user)), + removed=True, + purged_messages=purged, + reported=reported, + ) + ) + ctx.emit("chat_members_removed", {"chat_id": chat_id, "users": [r.user_id for r in out]}) + return out + + +SPEC_MEMBER_REMOVE = OperationSpec( + id="chat.member.remove", + request=MemberRemoveReq, + response=list[MemberResult], + impl=remove_member, + summary="Remove (kick) a member; they can rejoin", + description=( + "A kick is `editBanned(view_messages)` followed by an empty mask, so " + "the person may come back. Use `chat member ban` to keep them out. " + "`--purge` drains `messages.affectedHistory` until the server stops " + "handing back an offset." + ), + mutating=True, + destructive=True, + rate_class="bulk", + columns=("chat_id", "user_id", "removed"), + example=[{"chat_id": -1001500, "user_id": 4242, "removed": True}], + example_args="chat member remove @mygroup @spammer --purge --yes", + covers=("groups-channels-admin.remove-member",), + covers_partial=( + "groups-channels-admin.delete-member-history", + "groups-channels-admin.report-member", + ), + coverage_note=( + "`--purge`/`--report` are the moderate box; the standalone commands " + "are `chat member delete-history` and `chat member report`." + ), + tags=frozenset({"visible-to-others"}), +) + + +class MemberBanReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[ + list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Who to ban.") + ] = [] + until: Annotated[ + str | None, + opt("--until", metavar="WHEN", help="Ban expiry; 0, under 30s or over 366d = forever."), + ] = None + purge: Annotated[bool, opt("--purge", help="Delete everything they ever sent here.")] = False + messages: Annotated[ + list[int], opt("--messages", metavar="ID", help="Also delete these message ids.") + ] = [] + report: Annotated[bool, opt("--report", help="Report the messages as spam too.")] = False + + +async def ban_member(ctx: OpContext, req: MemberBanReq) -> list[MemberResult]: + """The GUI's moderate box: ban, purge and report in one confirmation.""" + from telethon.tl.functions import channels as chan_fn + from telethon.tl.functions import messages as msg_fn + + if not req.user: + raise UsageError("name at least one user to ban", field="user") + peer = await _send.resolve(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + until = _rights.parse_until(req.until) + label, label_unix = _rights.until_label(until) + handle = _admin.client(ctx) + out: list[MemberResult] = [] + for ref in req.user: + user = await _send.resolve(ctx, ref) + if _admin.is_channel(peer): + await handle( + chan_fn.EditBannedRequest( + channel=_admin.input_channel(peer), + participant=user, + banned_rights=_rights.build_banned_rights([], until=until), + ) + ) + else: + # A basic group has no ban at all: removal is the strongest thing + # the peer shape supports, and saying so beats pretending. + ctx.warn("a basic group cannot ban; the member was removed instead") + await handle( + msg_fn.DeleteChatUserRequest( + chat_id=_admin.small_chat_id(peer), user_id=_admin.input_user(user) + ) + ) + purged = await _purge_all(ctx, peer, user) if req.purge else None + if req.messages: + purged = (purged or 0) + await _purge(ctx, peer, user, req.messages) + reported = ( + await _report_spam(ctx, peer, user, req.messages) + if (req.report and _admin.is_channel(peer)) + else None + ) + out.append( + MemberResult( + chat_id=chat_id, + user_id=abs(_send.peer_id_of(user)), + banned=True, + until=label, + until_unix=label_unix, + purged_messages=purged, + reported=reported, + ) + ) + ctx.emit("chat_members_banned", {"chat_id": chat_id, "users": [r.user_id for r in out]}) + return out + + +SPEC_MEMBER_BAN = OperationSpec( + id="chat.member.ban", + request=MemberBanReq, + response=list[MemberResult], + impl=ban_member, + summary="Ban a member, optionally purging and reporting them in one step", + description=( + "`participant` is an `InputPeer`, so a channel posting in the group " + "— or an anonymous admin's channel — can be banned as well as a " + "user. `--until` follows Telegram's own rounding: 0, under 30 " + "seconds and over 366 days all mean forever." + ), + aliases=("chat.ban",), + mutating=True, + destructive=True, + rate_class="bulk", + columns=("chat_id", "user_id", "banned", "until"), + example=[{"chat_id": -1001500, "user_id": 4242, "banned": True}], + example_args="chat member ban @mygroup @spammer --purge --report --yes", + covers=("groups-channels-admin.ban-member", "groups-channels-admin.moderate-member"), + covers_partial=( + "groups-channels-admin.delete-member-history", + "groups-channels-admin.report-member", + ), + coverage_note=( + "The moderate box bundles them; `chat member delete-history` and " + "`chat member report` own the standalone ids." + ), + tags=frozenset({"visible-to-others"}), +) + + +class MemberUnbanReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[ + list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Who to unban.") + ] = [] + + +async def unban_member(ctx: OpContext, req: MemberUnbanReq) -> list[MemberResult]: + """Send an all-clear mask. The user may return; they are not re-added.""" + from telethon.tl.functions import channels as fn + + if not req.user: + raise UsageError("name at least one user to unban", field="user") + peer = await _send.resolve(ctx, req.chat) + channel = _admin.input_channel(peer) + chat_id = _send.peer_id_of(peer) + out: list[MemberResult] = [] + for ref in req.user: + user = await _send.resolve(ctx, ref) + await _admin.client(ctx)( + fn.EditBannedRequest( + channel=channel, + participant=user, + banned_rights=_rights.build_banned_rights(_rights.all_allowed()), + ) + ) + out.append(MemberResult(chat_id=chat_id, user_id=abs(_send.peer_id_of(user)), banned=False)) + return out + + +SPEC_MEMBER_UNBAN = OperationSpec( + id="chat.member.unban", + request=MemberUnbanReq, + response=list[MemberResult], + impl=unban_member, + summary="Lift a ban or a restriction", + description=( + "Sends an all-clear mask, which takes the user off the Removed and " + "Restricted lists. It does not put them back in the chat." + ), + mutating=True, + columns=("chat_id", "user_id", "banned"), + example=[{"chat_id": -1001500, "user_id": 4242, "banned": False}], + example_args="chat member unban @mygroup @alice", + covers=("groups-channels-admin.unban-member",), +) + + +# --------------------------------------------------------------------------- +# chat member restrict +# --------------------------------------------------------------------------- + + +class MemberRestrictReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Supergroup.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="The member.")] + deny: Annotated[ + str | None, opt("--deny", metavar="RIGHTS", help="Rights to take away, comma-separated.") + ] = None + allow: Annotated[ + str | None, opt("--allow", metavar="RIGHTS", help="Rights to give back, comma-separated.") + ] = None + none: Annotated[bool, opt("--none", help="Read-only: deny everything but view-messages.")] = ( + False + ) + everything: Annotated[bool, opt("--all", help="Allow everything.")] = False + clear: Annotated[bool, opt("--clear", help="Drop the mask; fall back to the chat default.")] = ( + False + ) + replace: Annotated[ + bool, opt("--replace", help="Treat --deny/--allow as the whole mask, not a patch.") + ] = False + until: Annotated[ + str | None, opt("--until", metavar="WHEN", help="When the restriction lapses.") + ] = None + purge: Annotated[bool, opt("--purge", help="Also delete everything they sent.")] = False + + +async def restrict_member(ctx: OpContext, req: MemberRestrictReq) -> MemberResult: + """Read the member's current mask, patch it, and send it back complete. + + `channels.editBanned` replaces the whole mask. Sending only the flags the + caller named would hand back every restriction they did not mention — + which is a moderation action nobody asked for. + """ + from telethon.tl.functions import channels as fn + + peer = await _send.resolve(ctx, req.chat) + if not _admin.is_channel(peer): + raise UsageError( + "a basic group has no per-member mask; use `chat permission set`, " + "or convert the group with `chat convert <chat> supergroup`", + field="chat", + ) + user = await _send.resolve(ctx, req.user) + reply = await _member_of(ctx, peer, user) + current = _rights.model_from_banned(getattr(reply.participant, "banned_rights", None)) + base = set(_rights.granted_names(current, mask="member")) if current else set() + if current is None: + base = set(_rights.all_allowed()) + + deny = _rights.parse_names(req.deny, mask="member", field="deny") + allow = _rights.parse_names(req.allow, mask="member", field="allow") + _rights.require_supported(deny + allow, mask="member") + + if req.clear or req.everything: + allowed = set(_rights.all_allowed()) + elif req.none: + allowed = set(_rights.read_only()) + elif req.replace: + allowed = set(allow) if allow else set(_rights.all_allowed()) + allowed -= set(deny) + else: + allowed = (base | set(allow)) - set(deny) + + until = _rights.parse_until(req.until) + label, label_unix = _rights.until_label(until) + await _admin.client(ctx)( + fn.EditBannedRequest( + channel=_admin.input_channel(peer), + participant=user, + banned_rights=_rights.build_banned_rights(allowed, until=until), + ) + ) + purged = await _purge_all(ctx, peer, user) if req.purge else None + order = list(_rights.MEMBER_MASK) + return MemberResult( + chat_id=_send.peer_id_of(peer), + user_id=abs(_send.peer_id_of(user)), + allow=[n for n in order if n in allowed], + deny=[n for n in order if n not in allowed and n in _rights.all_allowed()], + until=label, + until_unix=label_unix, + purged_messages=purged, + ) + + +SPEC_MEMBER_RESTRICT = OperationSpec( + id="chat.member.restrict", + request=MemberRestrictReq, + response=MemberResult, + impl=restrict_member, + summary="Restrict what one member may do, with an expiry", + description=( + "Read-modify-write of the member's current mask: `--deny` and " + "`--allow` patch it, `--replace` supplies it whole, `--none` is " + "read-only and `--clear` drops it back to the chat default. Names " + "come from `chat permission list --mask member`." + ), + mutating=True, + columns=("chat_id", "user_id", "until"), + example={"chat_id": -1001500, "user_id": 4242, "deny": ["send-media"], "allow": []}, + example_args="chat member restrict @mygroup @alice --deny send-media --until 7d", + covers=("groups-channels-admin.restrict-member",), +) + + +# --------------------------------------------------------------------------- +# chat member edit / delete-history / report +# --------------------------------------------------------------------------- + + +class MemberEditReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="The member.")] + rank: Annotated[ + str | None, + opt("--rank", metavar="TITLE", help="Custom title, max 16 chars."), + ] = None + free_messages: Annotated[ + bool | None, + opt("--free-messages", help="Let this user message the channel without paying Stars."), + ] = None + refund: Annotated[ + bool, opt("--refund", help="With --free-messages: refund the Stars already paid.") + ] = False + + +async def edit_member(ctx: OpContext, req: MemberEditReq) -> MemberResult: + """A member's custom rank, and their paid-message exception.""" + from telethon.tl.functions import account as acct_fn + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + user = await _send.resolve(ctx, req.user) + handle = _admin.client(ctx) + if req.rank is None and req.free_messages is None: + raise UsageError("nothing to change: pass --rank or --free-messages", field="rank") + + if req.rank is not None: + await handle(fn.EditChatParticipantRankRequest(peer=peer, participant=user, rank=req.rank)) + if req.free_messages is not None: + await handle( + acct_fn.ToggleNoPaidMessagesExceptionRequest( + user_id=_admin.input_user(user), + parent_peer=peer, + refund_charged=req.refund or None, + require_payment=None if req.free_messages else True, + ) + ) + return MemberResult( + chat_id=_send.peer_id_of(peer), + user_id=abs(_send.peer_id_of(user)), + rank=req.rank, + free_messages=req.free_messages, + ) + + +SPEC_MEMBER_EDIT = OperationSpec( + id="chat.member.edit", + request=MemberEditReq, + response=MemberResult, + impl=edit_member, + summary="Edit a member's custom rank and paid-message exception", + description=( + "The rank shows up as `message.from_rank` in supergroups and " + "`chatParticipant.rank` in basic groups; `chat admin promote --rank` " + "writes the same field for admins." + ), + mutating=True, + columns=("chat_id", "user_id", "rank"), + example={"chat_id": -1001500, "user_id": 4242, "rank": "moderator"}, + example_args="chat member edit @mygroup @alice --rank moderator", + covers=("groups-channels-admin.paid-messages-price",), + covers_partial=("groups-channels-admin.member-tag-rank",), + coverage_note="Ranks for plain members; `chat admin promote --rank` owns the admin half.", +) + + +class MemberPurgeReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Supergroup or channel.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Whose messages to delete.")] + + +async def delete_member_history(ctx: OpContext, req: MemberPurgeReq) -> MemberResult: + """Delete every message one member ever sent, draining the offset loop.""" + peer = await _send.resolve(ctx, req.chat) + if not _admin.is_channel(peer): + raise UsageError( + "only supergroups and channels support deleting one member's history", field="chat" + ) + user = await _send.resolve(ctx, req.user) + deleted = await _purge_all(ctx, peer, user) + return MemberResult( + chat_id=_send.peer_id_of(peer), + user_id=abs(_send.peer_id_of(user)), + deleted=deleted, + purged_messages=deleted, + ) + + +SPEC_MEMBER_DELETE_HISTORY = OperationSpec( + id="chat.member.delete-history", + request=MemberPurgeReq, + response=MemberResult, + impl=delete_member_history, + summary="Delete every message one member ever sent", + description=( + "`channels.deleteParticipantHistory` answers with " + "`messages.affectedHistory` and an offset to resume from; the loop " + "runs in the daemon until the offset is 0, because deleting the " + "first hundred messages and reporting success is not deleting a " + "history." + ), + aliases=("chat.member.purge",), + mutating=True, + destructive=True, + rate_class="bulk", + timeout_s=300, + columns=("chat_id", "user_id", "deleted"), + example={"chat_id": -1001500, "user_id": 4242, "deleted": 42}, + example_args="chat member delete-history @mygroup @spammer --yes", + covers=("groups-channels-admin.delete-member-history",), +) + + +class MemberReportReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Supergroup or channel.")] + user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to report.")] + messages: Annotated[ + list[int], opt("--messages", metavar="ID", help="Message ids to attach to the report.") + ] = [] + + +async def report_member(ctx: OpContext, req: MemberReportReq) -> MemberResult: + """Report a member as spam. Reporting the *chat* is `chat report`.""" + peer = await _send.resolve(ctx, req.chat) + user = await _send.resolve(ctx, req.user) + await _report_spam(ctx, peer, user, req.messages) + return MemberResult( + chat_id=_send.peer_id_of(peer), user_id=abs(_send.peer_id_of(user)), reported=True + ) + + +SPEC_MEMBER_REPORT = OperationSpec( + id="chat.member.report", + request=MemberReportReq, + response=MemberResult, + impl=report_member, + summary="Report a member (and optionally their messages) as spam", + mutating=True, + columns=("chat_id", "user_id", "reported"), + example={"chat_id": -1001500, "user_id": 4242, "reported": True}, + example_args="chat member report @mygroup @spammer --messages 918 --yes", + covers=("groups-channels-admin.report-member",), + tags=frozenset({"visible-to-others"}), +) + + +# --------------------------------------------------------------------------- +# chat request list / approve / deny +# --------------------------------------------------------------------------- + + +class RequestListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + link: Annotated[ + str | None, opt("--link", metavar="LINK", help="Only requests from this invite link.") + ] = None + search: Annotated[str, opt("--search", "-s", metavar="TEXT", help="Filter by name.")] = "" + approve: Annotated[ + list[PeerRef], opt("--approve", metavar="USER", kind="user", help="Approve one requester.") + ] = [] + decline: Annotated[ + list[PeerRef], opt("--decline", metavar="USER", kind="user", help="Decline one requester.") + ] = [] + approve_all: Annotated[bool, opt("--approve-all", help="Approve every pending request.")] = ( + False + ) + decline_all: Annotated[bool, opt("--decline-all", help="Decline every pending request.")] = ( + False + ) + community: Annotated[ + bool, opt("--community", help="Target a layer-229 community's peer-link requests.") + ] = False + + +async def _hide_requests( + ctx: OpContext, + peer: Any, + *, + approve: list[PeerRef], + decline: list[PeerRef], + approve_all: bool, + decline_all: bool, + link: str | None, +) -> RequestResult: + """Answer join requests, collecting per-user failures instead of aborting.""" + from telethon.tl.functions import messages as fn + + handle = _admin.client(ctx) + result = RequestResult(chat_id=_send.peer_id_of(peer)) + for refs, approved in ((approve, True), (decline, False)): + for ref in refs: + user = await _send.resolve(ctx, ref) + user_id = abs(_send.peer_id_of(user)) + try: + await handle( + fn.HideChatJoinRequestRequest( + peer=peer, user_id=_admin.input_user(user), approved=approved + ) + ) + except Exception as exc: + result.failed.append(MissingInvitee(user_id=user_id, reason=str(exc))) + continue + (result.approved if approved else result.denied).append(user_id) + if approve_all or decline_all: + await handle(fn.HideAllChatJoinRequestsRequest(peer=peer, approved=approve_all, link=link)) + result.all = True + return result + + +async def list_requests(ctx: OpContext, req: RequestListReq) -> Page[JoinRequest]: + """Pending join requests, and — with the answer flags — the answer to them. + + Listing is a read, so the op is not marked mutating and `--dry-run + chat request list` keeps listing. The answer flags check `ctx.dry_run` + themselves and warn instead of firing (the same rule `folder list --tags` + follows). + """ + from datetime import datetime, timezone + + from telethon.tl import types + from telethon.tl.functions import messages as fn + + if req.community: + from tlgr.ops._layer import community_gap + + community_gap("chat request list --community", "communities.getPeerLinkRequests") + + limit, state = _admin.window(ctx, "chat.request.list", PageKind.PARTICIPANTS) + peer = await _send.resolve(ctx, req.chat) + + answering = bool(req.approve or req.decline or req.approve_all or req.decline_all) + if answering: + if ctx.dry_run: + ctx.warn("--dry-run: the join requests were listed, not answered") + else: + answered = await _hide_requests( + ctx, + peer, + approve=req.approve, + decline=req.decline, + approve_all=req.approve_all, + decline_all=req.decline_all, + link=req.link, + ) + ctx.emit( + "chat_join_requests_answered", + { + "chat_id": answered.chat_id, + "approved": answered.approved, + "denied": answered.denied, + }, + ) + + offset_date = state.get("date") + offset_user: Any = types.InputUserEmpty() + if state.get("user"): + offset_user = types.InputUser( + user_id=int(state["user"]), access_hash=int(state.get("hash", 0) or 0) + ) + reply = await _admin.client(ctx)( + fn.GetChatInviteImportersRequest( + peer=peer, + offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc) + if offset_date + else None, + offset_user=offset_user, + limit=limit, + requested=True, + link=req.link, + q=req.search or None, + ) + ) + entities = _admin.entity_map(reply) + items: list[JoinRequest] = [] + last: Any = None + for row in getattr(reply, "importers", None) or []: + user_id = int(getattr(row, "user_id", 0) or 0) + entity = entities.get(user_id) + items.append( + JoinRequest( + user_id=user_id, + username=getattr(entity, "username", None), + name=_admin.display_name(entity), + date=fmt_dt(getattr(row, "date", None)), + date_unix=to_unix(getattr(row, "date", None)), + about=getattr(row, "about", None), + via_link=req.link, + approved_by=getattr(row, "approved_by", None), + ) + ) + last = row + next_state: dict[str, Any] = {} + if last is not None: + entity = entities.get(int(getattr(last, "user_id", 0) or 0)) + next_state = { + "date": to_unix(getattr(last, "date", None)) or 0, + "user": int(getattr(last, "user_id", 0) or 0), + "hash": int(getattr(entity, "access_hash", 0) or 0), + } + return build_page( + items, + op="chat.request.list", + kind=PageKind.PARTICIPANTS, + state=next_state, + account=ctx.account, + limit=limit, + total=int(getattr(reply, "count", 0) or 0), + ) + + +SPEC_REQUEST_LIST = OperationSpec( + id="chat.request.list", + request=RequestListReq, + response=Page[JoinRequest], + impl=list_requests, + summary="List pending join requests", + description=( + "The answer flags (`--approve`, `--decline`, `--approve-all`, " + "`--decline-all`) run before the listing and honour `--dry-run` " + "themselves, so listing keeps working under a dry run instead of " + "printing a stub." + ), + aliases=("chat.join-requests",), + paginated=PageKind.PARTICIPANTS, + columns=("user_id", "username", "name", "date"), + headers=("User", "Username", "Name", "Requested"), + example={ + "items": [{"user_id": 4242, "username": "alice", "name": "Alice"}], + "has_more": False, + }, + example_args="chat request list @mygroup", + covers=("dialogs.community-join-requests", "groups-channels-admin.join-request-list"), + covers_partial=("dialogs.join-requests-badge",), + coverage_note="The badge itself is `chat list --with-join-requests`; this is the queue.", + tags=frozenset({"mutating-checked"}), +) + + +class RequestAnswerReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")] + user: Annotated[ + list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Which requesters.") + ] = [] + everyone: Annotated[bool, opt("--all", help="Answer every pending request.")] = False + link: Annotated[ + str | None, opt("--link", metavar="LINK", help="With --all: only this invite link.") + ] = None + + +async def approve_requests(ctx: OpContext, req: RequestAnswerReq) -> RequestResult: + """Approve join requests, one by one or the whole queue.""" + peer = await _send.resolve(ctx, req.chat) + if not req.user and not req.everyone: + raise UsageError("name a user, or pass --all", field="user") + result = await _hide_requests( + ctx, + peer, + approve=list(req.user), + decline=[], + approve_all=req.everyone, + decline_all=False, + link=req.link, + ) + ctx.emit( + "chat_join_requests_answered", + {"chat_id": result.chat_id, "approved": result.approved, "all": result.all}, + ) + return result + + +SPEC_REQUEST_APPROVE = OperationSpec( + id="chat.request.approve", + request=RequestAnswerReq, + response=RequestResult, + impl=approve_requests, + summary="Approve join requests", + description="Per-user failures are collected in `failed` rather than aborting the batch.", + mutating=True, + rate_class="bulk", + columns=("chat_id", "approved"), + example={"chat_id": -1001500, "approved": [4242], "failed": []}, + example_args="chat request approve @mygroup @alice", + covers=("groups-channels-admin.join-request-approve-all",), + covers_partial=("groups-channels-admin.join-request-approve-one",), + coverage_note="Approving one is here; declining one is `chat request deny`.", + tags=frozenset({"visible-to-others"}), +) + + +async def deny_requests(ctx: OpContext, req: RequestAnswerReq) -> RequestResult: + """Decline join requests. A declined user may ask again after a cooldown.""" + peer = await _send.resolve(ctx, req.chat) + if not req.user and not req.everyone: + raise UsageError("name a user, or pass --all", field="user") + result = await _hide_requests( + ctx, + peer, + approve=[], + decline=list(req.user), + approve_all=False, + decline_all=req.everyone, + link=req.link, + ) + ctx.emit( + "chat_join_requests_answered", + {"chat_id": result.chat_id, "denied": result.denied, "all": result.all}, + ) + return result + + +SPEC_REQUEST_DENY = OperationSpec( + id="chat.request.deny", + request=RequestAnswerReq, + response=RequestResult, + impl=deny_requests, + summary="Decline join requests", + mutating=True, + destructive=True, + rate_class="bulk", + columns=("chat_id", "denied"), + example={"chat_id": -1001500, "denied": [4242], "failed": []}, + example_args="chat request deny @mygroup --all --yes", + covers=("groups-channels-admin.join-request-approve-one",), + covers_partial=("groups-channels-admin.join-request-approve-all",), + coverage_note="Declining the whole queue is here too; `chat request approve` owns the other id.", +) diff --git a/tlgr/ops/chat_stats.py b/tlgr/ops/chat_stats.py new file mode 100644 index 0000000..ccddc23 --- /dev/null +++ b/tlgr/ops/chat_stats.py @@ -0,0 +1,902 @@ +"""`chat stats *`, `chat revenue *` and `boost *`: the numbers, never redrawn. + +Three rules, and the first one is the reason this module exists at all. + +* **Every `stats.*` call goes to `channelFull.stats_dc`, not the home DC.** + The server answers a stats request on the wrong data centre with + `STATS_MIGRATE_X`, and a client that does not follow it reports "no + statistics" for a channel that has plenty. `_stats()` follows the + migration through Telethon's exported sender, once, for every stats call + in the module. +* **A graph is emitted verbatim.** Telegram's chart specification + (columns/types/colors/names/subchart/y_scaled/percentage/stacked) is + reported as the API's own JSON. tlgr never tries to draw it: a redrawn + chart is a chart that can be subtly wrong, and the caller is better placed + to render than we are. Async graphs appear as `{token, zoom_token}` until + `--load-graphs` or `--graph` resolves them. +* **Revenue is read-only on purpose.** When `withdrawal_enabled` is true the + command says so and points at an official client: tlgr does not implement + `payments.getStarsRevenueWithdrawalUrl`, because it moves money and wants + the 2FA password. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Annotated, Any + +from tlgr.core.errors import IndeterminateError, NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.admin import ( + Boost, + BoostApplied, + BoostStatus, + ChatStats, + Graph, + PublicForward, + RevenueSummary, + RevenueTransaction, + StatValue, +) +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _admin, _send +from tlgr.ops._params import arg, opt +from tlgr.ops._serialize import peer_id_of +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +_EXAMPLE_STATS: dict[str, Any] = { + "chat_id": -1001600, + "type": "broadcast", + "followers": {"current": 1200.0, "previous": 1150.0, "growth": 4.3}, + "graphs": [{"name": "growth_graph", "token": "abc123"}], +} + +#: appConfig keys that gate a tlgr flag, and the flag they gate. `boost get +#: --features` maps them so "why is --autotranslate refused" has an answer +#: that does not require reading the API docs. +_BOOST_FEATURES = { + "channel_autotranslation_level_min": "chat setting set --autotranslate", + "channel_wallpaper_level_min": "chat wallpaper set", + "group_wallpaper_level_min": "chat wallpaper set", + "channel_emoji_status_level_min": "chat edit --emoji-status", + "group_emoji_status_level_min": "chat edit --emoji-status", + "group_emoji_stickers_level_min": "chat setting set --emoji-set", + "channel_restrict_sponsored_level_min": "chat setting set --ads off", + "channel_custom_wallpaper_level_min": "chat wallpaper set --file", + "channel_profile_bg_icon_level_min": "chat edit --profile-color-emoji", + "channel_bg_icon_level_min": "chat edit --color-emoji", + "boosts_channel_level_max": "(the level ceiling)", +} + + +async def _stats(ctx: OpContext, request: Any) -> Any: + """Send a `stats.*` request, following STATS_MIGRATE to the stats DC. + + Telethon's own `get_stats` does this with a borrowed exported sender; + doing it here means every stats call in the module — not just the two + Telethon wraps — reaches the right data centre. + """ + client = _admin.client(ctx) + try: + return await client(request) + except Exception as exc: + if type(exc).__name__ != "StatsMigrateError": + raise + dc = int(getattr(exc, "dc", 0) or 0) + borrow = getattr(client, "_borrow_exported_sender", None) + if borrow is None or not dc: # pragma: no cover - Telethon always has it + raise IndeterminateError( + "these statistics live on another data centre and this Telethon " + "build has no exported sender to reach it" + ) from exc + sender = await borrow(dc) + try: + return await sender.send(request) + finally: + release = getattr(client, "_return_exported_sender", None) + if release is not None: + await release(sender) + + +def _moment(value: Any) -> str | None: + """`next_withdrawal_at` arrives as a unix int, not a datetime.""" + if isinstance(value, int) and value: + from datetime import datetime, timezone + + return fmt_dt(datetime.fromtimestamp(value, tz=timezone.utc)) + return fmt_dt(value) + + +def _stat_value(raw: Any) -> StatValue | None: + """`statsAbsValueAndPrev` / `statsPercentValue` → one shape with growth.""" + if raw is None: + return None + if hasattr(raw, "part"): + part = float(getattr(raw, "part", 0.0) or 0.0) + total = float(getattr(raw, "total", 0.0) or 0.0) + return StatValue( + current=part, previous=total, growth=round(100.0 * part / total, 2) if total else 0.0 + ) + current = float(getattr(raw, "current", 0.0) or 0.0) + previous = float(getattr(raw, "previous", 0.0) or 0.0) + growth = round(100.0 * (current - previous) / previous, 2) if previous else 0.0 + return StatValue(current=current, previous=previous, growth=growth) + + +def _graph(name: str, raw: Any) -> Graph: + """One `statsGraph*`, whichever of the three shapes it is.""" + kind = type(raw).__name__ + if kind == "StatsGraphAsync": + return Graph(name=name, token=str(getattr(raw, "token", "") or "")) + if kind == "StatsGraphError": + return Graph(name=name, error=str(getattr(raw, "error", "") or "")) + payload = getattr(getattr(raw, "json", None), "data", None) + parsed: Any = None + if payload: + try: + parsed = json.loads(payload) + except (TypeError, ValueError): # pragma: no cover - the server sends JSON + parsed = payload + return Graph( + name=name, json=parsed, zoom_token=str(getattr(raw, "zoom_token", "") or "") or None + ) + + +def _collect_graphs(result: Any) -> list[Graph]: + """Every `*_graph` field on a stats reply, in declaration order. + + Reflected rather than listed: broadcast, megagroup, message and story + statistics carry different graph sets, and a hand-written list would go + stale the next time the server grows one. + """ + out: list[Graph] = [] + for name in getattr(result, "__slots__", ()) or dir(result): + if not name.endswith("_graph"): + continue + value = getattr(result, name, None) + if value is not None: + out.append(_graph(name, value)) + return out + + +async def _resolve_graphs(ctx: OpContext, graphs: list[Graph], *, zoom: int | None) -> None: + from telethon.tl.functions import stats as fn + + for graph in graphs: + if not graph.token: + continue + try: + resolved = await _stats(ctx, fn.LoadAsyncGraphRequest(token=graph.token, x=zoom)) + except Exception as exc: # a graph that will not load must not kill the report + graph.error = f"{type(exc).__name__}: {exc}" + continue + loaded = _graph(graph.name, resolved) + graph.json = loaded.json + graph.zoom_token = loaded.zoom_token or graph.zoom_token + graph.error = loaded.error + + +def _write_graphs(graphs: list[Graph], directory: str) -> None: + root = Path(directory).expanduser() + root.mkdir(parents=True, exist_ok=True) + for graph in graphs: + if graph.json is None: + continue + path = root / f"{graph.name}.json" + path.write_text(json.dumps(graph.json, ensure_ascii=False, indent=2), encoding="utf-8") + graph.path = str(path) + graph.json = None + + +# --------------------------------------------------------------------------- +# chat stats get +# --------------------------------------------------------------------------- + + +class StatsGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel or supergroup.")] + message: Annotated[ + int | None, opt("--message", metavar="ID", kind="msg_id", help="Per-post statistics.") + ] = None + story: Annotated[int | None, opt("--story", metavar="ID", help="Story statistics.")] = None + poll: Annotated[ + int | None, opt("--poll", metavar="ID", kind="msg_id", help="Poll vote statistics.") + ] = None + dark: Annotated[bool, opt("--dark", help="Ask for the dark colour set in the specs.")] = False + graph: Annotated[ + str | None, opt("--graph", metavar="TOKEN", help="Resolve one async graph token.") + ] = None + zoom: Annotated[int | None, opt("--zoom", metavar="X", help="With --graph: zoom into x.")] = ( + None + ) + load_graphs: Annotated[ + bool, opt("--load-graphs", help="Resolve every async graph before printing.") + ] = False + out: Annotated[ + str | None, opt("--out", metavar="DIR", kind="path", help="Write graph specs to files.") + ] = None + + +async def get_stats(ctx: OpContext, req: StatsGetReq) -> ChatStats: + """Channel, supergroup, post, story or poll statistics.""" + from telethon.tl.functions import stats as fn + + peer = await _send.resolve(ctx, req.chat) + chat_id = peer_id_of(peer) or 0 + + if req.graph: + resolved = await _stats(ctx, fn.LoadAsyncGraphRequest(token=req.graph, x=req.zoom)) + graph = _graph("graph", resolved) + stats = ChatStats(chat_id=chat_id, type="graph", graphs=[graph]) + if req.out: + _write_graphs(stats.graphs, req.out) + return stats + + channel = _admin.input_channel(peer) if _admin.is_channel(peer) else None + if req.message is not None: + if channel is None: + raise UsageError("post statistics need a channel", field="message") + result = await _stats( + ctx, fn.GetMessageStatsRequest(channel=channel, msg_id=req.message, dark=req.dark) + ) + kind = "message" + elif req.story is not None: + result = await _stats(ctx, fn.GetStoryStatsRequest(peer=peer, id=req.story, dark=req.dark)) + kind = "story" + elif req.poll is not None: + result = await _stats( + ctx, fn.GetPollStatsRequest(peer=peer, msg_id=req.poll, dark=req.dark) + ) + kind = "poll" + else: + if channel is None: + raise UsageError( + "statistics need a channel or supergroup; a basic group has none", field="chat" + ) + _full, entity, _entities = await _admin.full_chat(ctx, peer) + megagroup = bool(getattr(entity, "megagroup", False)) + request = ( + fn.GetMegagroupStatsRequest(channel=channel, dark=req.dark) + if megagroup + else fn.GetBroadcastStatsRequest(channel=channel, dark=req.dark) + ) + result = await _stats(ctx, request) + kind = "megagroup" if megagroup else "broadcast" + + period_raw = getattr(result, "period", None) + stats = ChatStats( + chat_id=chat_id, + type=kind, + period=( + { + "min_date": fmt_dt(getattr(period_raw, "min_date", None)) or "", + "max_date": fmt_dt(getattr(period_raw, "max_date", None)) or "", + } + if period_raw is not None + else {} + ), + followers=_stat_value(getattr(result, "followers", None)), + views_per_post=_stat_value(getattr(result, "views_per_post", None)), + shares_per_post=_stat_value(getattr(result, "shares_per_post", None)), + reactions_per_post=_stat_value(getattr(result, "reactions_per_post", None)), + enabled_notifications=_stat_value(getattr(result, "enabled_notifications", None)), + members=_stat_value(getattr(result, "members", None)), + messages=_stat_value(getattr(result, "messages", None)), + viewers=_stat_value(getattr(result, "viewers", None)), + posters=_stat_value(getattr(result, "posters", None)), + views=getattr(result, "views", None), + forwards=getattr(result, "forwards", None), + reactions=getattr(result, "reactions", None), + graphs=_collect_graphs(result), + ) + for post in getattr(result, "recent_posts_interactions", None) or []: + stats.recent_posts.append( + { + "msg_id": int(getattr(post, "msg_id", 0) or 0), + "story_id": int(getattr(post, "story_id", 0) or 0), + "views": int(getattr(post, "views", 0) or 0), + "forwards": int(getattr(post, "forwards", 0) or 0), + "reactions": int(getattr(post, "reactions", 0) or 0), + } + ) + if req.load_graphs: + await _resolve_graphs(ctx, stats.graphs, zoom=req.zoom) + if req.out: + _write_graphs(stats.graphs, req.out) + return stats + + +SPEC_STATS_GET = OperationSpec( + id="chat.stats.get", + request=StatsGetReq, + response=ChatStats, + impl=get_stats, + summary="Channel, supergroup, post, story or poll statistics", + description=( + "Needs `channelFull.can_view_stats` (channels need about 500 " + "members). Every call is routed to `channelFull.stats_dc`. Graph " + "payloads are the API's own chart specification, emitted verbatim; " + "async graphs stay as `{token, zoom_token}` until `--load-graphs` or " + "`--graph` resolves them, and `--out DIR` writes each one to a file " + "instead of inlining it." + ), + aliases=("stats.get",), + timeout_s=300, + columns=("chat_id", "type"), + example=_EXAMPLE_STATS, + example_args="chat stats get @mychannel --load-graphs", + covers=( + "groups-channels-admin.channel-stats", + "groups-channels-admin.poll-stats", + "groups-channels-admin.stats-async-graph", + "groups-channels-admin.supergroup-stats", + "messages-core.message-statistics", + ), + covers_partial=( + "groups-channels-admin.message-stats", + "groups-channels-admin.story-stats", + ), + coverage_note="Per-post numbers are here; the repost list is `chat stats list`.", +) + + +class StatsListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")] + message: Annotated[ + int | None, opt("--message", metavar="ID", kind="msg_id", help="Public forwards of a post.") + ] = None + story: Annotated[ + int | None, opt("--story", metavar="ID", help="Public forwards of a story.") + ] = None + + +async def list_public_forwards(ctx: OpContext, req: StatsListReq) -> Page[PublicForward]: + """Who reposted a post or a story, publicly.""" + from telethon.tl.functions import stats as fn + + limit, state = _admin.window(ctx, "chat.stats.list", PageKind.PARTICIPANTS) + peer = await _send.resolve(ctx, req.chat) + offset = str(state.get("offset", "") or "") + if req.message is not None: + reply = await _stats( + ctx, + fn.GetMessagePublicForwardsRequest( + channel=_admin.input_channel(peer), msg_id=req.message, offset=offset, limit=limit + ), + ) + elif req.story is not None: + reply = await _stats( + ctx, + fn.GetStoryPublicForwardsRequest(peer=peer, id=req.story, offset=offset, limit=limit), + ) + else: + raise UsageError("name --message or --story", field="message") + + entities = _admin.entity_map(reply) + rows: list[PublicForward] = [] + for item in getattr(reply, "forwards", None) or []: + message = getattr(item, "message", None) + story = getattr(item, "story", None) + source = message if message is not None else story + chat_id = peer_id_of(getattr(source, "peer_id", None) or getattr(source, "peer", None)) or 0 + entity = entities.get(chat_id) + date = getattr(source, "date", None) + rows.append( + PublicForward( + chat_id=chat_id, + chat_title=_admin.display_name(entity), + msg_id=int(getattr(message, "id", 0) or 0) or None, + story_id=int(getattr(story, "id", 0) or 0) or None, + views=getattr(source, "views", None), + date=fmt_dt(date), + date_unix=to_unix(date), + ) + ) + return build_page( + rows, + op="chat.stats.list", + kind=PageKind.PARTICIPANTS, + state={"offset": str(getattr(reply, "next_offset", "") or "")}, + account=ctx.account, + limit=limit, + total=int(getattr(reply, "count", len(rows)) or len(rows)), + ) + + +SPEC_STATS_LIST = OperationSpec( + id="chat.stats.list", + request=StatsListReq, + response=Page[PublicForward], + impl=list_public_forwards, + summary="Public forwards (reposts) of a post or a story", + description="Also routed to the stats DC. The cursor is the opaque `next_offset` string.", + aliases=("stats.list",), + paginated=PageKind.PARTICIPANTS, + columns=("chat_id", "chat_title", "msg_id", "views"), + example={"items": [{"chat_id": -1001700, "chat_title": "Repost", "msg_id": 12, "views": 90}]}, + example_args="chat stats list @mychannel --message 918", + covers=("groups-channels-admin.message-stats", "groups-channels-admin.story-stats"), +) + + +# --------------------------------------------------------------------------- +# chat revenue +# --------------------------------------------------------------------------- + + +def _stars(raw: Any) -> int: + """A `starsAmount` as whole Stars. + + The nanos field is a fractional Star and is dropped here deliberately: + every balance tlgr prints is the whole-Star figure the GUI shows, and + silently rounding it up or down per call would make two reports of the + same balance disagree. + """ + return int(getattr(raw, "amount", 0) or 0) + + +class RevenueGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")] + ton: Annotated[bool, opt("--ton", help="TON (ad) revenue instead of Stars.")] = False + since: Annotated[ + PeerRef | None, + opt("--since", metavar="USER", kind="user", help="Stars earned from this user's messages."), + ] = None + dark: Annotated[bool, opt("--dark", help="Dark colour set in the graph specs.")] = False + + +async def get_revenue(ctx: OpContext, req: RevenueGetReq) -> RevenueSummary: + """Stars or TON revenue, read-only by design.""" + from telethon.tl.functions import account as acct_fn + from telethon.tl.functions import payments as fn + + peer = await _send.resolve(ctx, req.chat) + handle = _admin.client(ctx) + reply = await handle( + fn.GetStarsRevenueStatsRequest(peer=peer, dark=req.dark or None, ton=req.ton or None) + ) + status = getattr(reply, "status", None) + next_at = getattr(status, "next_withdrawal_at", None) + summary = RevenueSummary( + chat_id=peer_id_of(peer) or 0, + currency="ton" if req.ton else "stars", + current_balance=_stars(getattr(status, "current_balance", None)), + available_balance=_stars(getattr(status, "available_balance", None)), + overall_revenue=_stars(getattr(status, "overall_revenue", None)), + withdrawal_enabled=bool(getattr(status, "withdrawal_enabled", False)), + next_withdrawal_at=_moment(next_at), + usd_rate=float(getattr(reply, "usd_rate", 0.0) or 0.0), + graphs=_collect_graphs(reply), + ) + if summary.withdrawal_enabled: + ctx.warn( + "a withdrawal is available. tlgr does not implement " + "payments.getStarsRevenueWithdrawalUrl: it moves money and needs your " + "2FA password, so use an official client for that step" + ) + if req.since is not None: + user = await _send.resolve(ctx, req.since) + paid = await handle( + acct_fn.GetPaidMessagesRevenueRequest(user_id=_admin.input_user(user), parent_peer=peer) + ) + summary.from_user_revenue = _stars(getattr(paid, "stars_amount", None)) + return summary + + +SPEC_REVENUE_GET = OperationSpec( + id="chat.revenue.get", + request=RevenueGetReq, + response=RevenueSummary, + impl=get_revenue, + summary="Stars / TON revenue of a channel (and per-user paid-message revenue)", + description=( + "Read-only by design: when `withdrawal_enabled` is true the command " + "says so and points at an official client, because " + "`payments.getStarsRevenueWithdrawalUrl` moves money and wants the " + "2FA password. Needs `can_view_revenue` / `can_view_stars_revenue`." + ), + columns=("chat_id", "currency", "available_balance", "overall_revenue"), + example={ + "chat_id": -1001600, + "currency": "stars", + "current_balance": 120, + "overall_revenue": 900, + }, + example_args="chat revenue get @mychannel", + covers=( + "groups-channels-admin.paid-message-revenue", + "groups-channels-admin.stars-revenue-stats", + ), +) + + +class RevenueListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")] + ton: Annotated[bool, opt("--ton", help="TON transactions instead of Stars.")] = False + inbound: Annotated[bool, opt("--in", help="Incoming only.")] = False + outbound: Annotated[bool, opt("--out", help="Outgoing only.")] = False + ascending: Annotated[bool, opt("--ascending", help="Oldest first.")] = False + subscription: Annotated[ + str | None, opt("--subscription", metavar="ID", help="Only this subscription's rows.") + ] = None + + +async def list_revenue(ctx: OpContext, req: RevenueListReq) -> Page[RevenueTransaction]: + """The Stars or TON transaction history of a channel.""" + from telethon.tl.functions import payments as fn + + limit, state = _admin.window(ctx, "chat.revenue.list", PageKind.PARTICIPANTS) + peer = await _send.resolve(ctx, req.chat) + reply = await _admin.client(ctx)( + fn.GetStarsTransactionsRequest( + peer=peer, + offset=str(state.get("offset", "") or ""), + limit=limit, + inbound=req.inbound or None, + outbound=req.outbound or None, + ascending=req.ascending or None, + ton=req.ton or None, + subscription_id=req.subscription, + ) + ) + rows: list[RevenueTransaction] = [] + for row in getattr(reply, "history", None) or []: + date = getattr(row, "date", None) + rows.append( + RevenueTransaction( + id=str(getattr(row, "id", "") or ""), + date=fmt_dt(date), + date_unix=to_unix(date), + amount=_stars(getattr(row, "amount", None)), + currency="ton" if req.ton else "stars", + peer=type(getattr(row, "peer", None)).__name__, + title=str(getattr(row, "title", "") or ""), + refund=bool(getattr(row, "refund", False)), + pending=bool(getattr(row, "pending", False)), + failed=bool(getattr(row, "failed", False)), + subscription_period=getattr(row, "subscription_period", None), + ) + ) + return build_page( + rows, + op="chat.revenue.list", + kind=PageKind.PARTICIPANTS, + state={"offset": str(getattr(reply, "next_offset", "") or "")}, + account=ctx.account, + limit=limit, + ) + + +SPEC_REVENUE_LIST = OperationSpec( + id="chat.revenue.list", + request=RevenueListReq, + response=Page[RevenueTransaction], + impl=list_revenue, + summary="Stars / TON transaction history of a channel", + description="The cursor is the opaque `next_offset` string the server hands back.", + paginated=PageKind.PARTICIPANTS, + columns=("id", "date", "amount", "title"), + example={"items": [{"id": "tx1", "amount": 50, "title": "Subscription"}], "has_more": False}, + example_args="chat revenue list @mychannel --in", + covers=("groups-channels-admin.stars-transactions",), +) + + +# --------------------------------------------------------------------------- +# boost +# --------------------------------------------------------------------------- + + +class BoostGetReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="CHAT", kind="peer", required=False, help="Chat to inspect."), + ] = None + features: Annotated[bool, opt("--features", help="Print what each boost level unlocks.")] = ( + False + ) + level: Annotated[ + int | None, opt("--level", metavar="N", help="With --features: one level.") + ] = None + kind: Annotated[ + str | None, opt("--kind", metavar="CHANNEL|GROUP", help="With --features and no chat.") + ] = None + + +async def get_boosts(ctx: OpContext, req: BoostGetReq) -> BoostStatus: + """A chat's boost level and progress, and what the next level unlocks.""" + from telethon.tl.functions import help as help_fn + from telethon.tl.functions import premium as fn + + handle = _admin.client(ctx) + status = BoostStatus() + if req.chat is not None: + peer = await _send.resolve(ctx, req.chat) + reply = await handle(fn.GetBoostsStatusRequest(peer=peer)) + audience = getattr(reply, "premium_audience", None) + status = BoostStatus( + chat_id=peer_id_of(peer) or 0, + level=int(getattr(reply, "level", 0) or 0), + boosts=int(getattr(reply, "boosts", 0) or 0), + current_level_boosts=int(getattr(reply, "current_level_boosts", 0) or 0), + next_level_boosts=getattr(reply, "next_level_boosts", None), + premium_audience=( + { + "part": float(getattr(audience, "part", 0.0) or 0.0), + "total": float(getattr(audience, "total", 0.0) or 0.0), + } + if audience is not None + else None + ), + boost_url=str(getattr(reply, "boost_url", "") or ""), + my_boost=bool(getattr(reply, "my_boost", False)), + boosts_applied=len(getattr(reply, "my_boost_slots", None) or []) or None, + prepaid_giveaways=[ + { + "id": int(getattr(item, "id", 0) or 0), + "quantity": int(getattr(item, "quantity", 0) or 0), + "months": int(getattr(item, "months", 0) or 0), + } + for item in (getattr(reply, "prepaid_giveaways", None) or []) + ], + ) + elif not req.features: + raise UsageError("name a chat, or pass --features for the level table", field="chat") + + if req.features: + config = await handle(help_fn.GetAppConfigRequest(hash=0)) + for item in getattr(getattr(config, "config", None), "value", None) or []: + key = str(getattr(item, "key", "") or "") + if key not in _BOOST_FEATURES: + continue + raw = getattr(getattr(item, "value", None), "value", None) + try: + needed = int(float(raw or 0)) + except (TypeError, ValueError): # pragma: no cover - appConfig is numeric here + continue + if req.level is not None and needed != req.level: + continue + status.features.append({"key": key, "level": needed, "unlocks": _BOOST_FEATURES[key]}) + status.features.sort(key=lambda row: (int(row["level"]), str(row["key"]))) + return status + + +SPEC_BOOST_GET = OperationSpec( + id="boost.get", + request=BoostGetReq, + response=BoostStatus, + impl=get_boosts, + summary="Boost status of a chat, or the boost-level feature table", + description=( + "`--features` maps the appConfig level keys to the tlgr flags they " + "gate, so “why was `--autotranslate` refused” has an answer without " + "reading the API docs. `boost_url` is the shareable boost link." + ), + aliases=("chat.boost.get",), + columns=("chat_id", "level", "boosts", "next_level_boosts"), + example={"chat_id": -1001600, "level": 3, "boosts": 12, "next_level_boosts": 15}, + example_args="boost get @mychannel", + covers=( + "giveaway.boost-status", + "groups-channels-admin.boost-level-features", + "groups-channels-admin.boost-link", + "groups-channels-admin.boost-status", + ), +) + + +class BoostListReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="CHAT", kind="peer", required=False, help="Chat whose boosters to list."), + ] = None + user: Annotated[ + PeerRef | None, + opt("--user", metavar="USER", kind="user", help="Only the boosts this user applied."), + ] = None + gifts: Annotated[bool, opt("--gifts", help="Only gift and giveaway boosts.")] = False + mine: Annotated[bool, opt("--mine", help="My own boost slots across every chat.")] = False + + +async def list_boosts(ctx: OpContext, req: BoostListReq) -> Page[Boost]: + """Boosters of a chat, one user's boosts, or my own slots.""" + from telethon.tl.functions import premium as fn + + limit, state = _admin.window(ctx, "boost.list", PageKind.PARTICIPANTS) + handle = _admin.client(ctx) + + if req.mine: + reply = await handle(fn.GetMyBoostsRequest()) + rows = [ + Boost( + slot=int(getattr(row, "slot", 0) or 0), + chat_id=peer_id_of(getattr(row, "peer", None)) or None, + date=fmt_dt(getattr(row, "date", None)), + date_unix=to_unix(getattr(row, "date", None)), + expires=fmt_dt(getattr(row, "expires", None)), + cooldown_until_date=fmt_dt(getattr(row, "cooldown_until_date", None)), + ) + for row in (getattr(reply, "my_boosts", None) or []) + ] + return build_page( + rows, + op="boost.list", + kind=PageKind.PARTICIPANTS, + account=ctx.account, + has_more=False, + total=len(rows), + ) + + if req.chat is None: + raise UsageError("name a chat, or pass --mine for your own slots", field="chat") + peer = await _send.resolve(ctx, req.chat) + if req.user is not None: + user = await _send.resolve(ctx, req.user) + reply = await handle(fn.GetUserBoostsRequest(peer=peer, user_id=_admin.input_user(user))) + raw_rows = getattr(reply, "boosts", None) or [] + next_state: dict[str, Any] = {} + more = False + else: + reply = await handle( + fn.GetBoostsListRequest( + peer=peer, + offset=str(state.get("offset", "") or ""), + limit=limit, + gifts=req.gifts or None, + ) + ) + raw_rows = getattr(reply, "boosts", None) or [] + next_state = {"offset": str(getattr(reply, "next_offset", "") or "")} + more = bool(next_state["offset"]) + + rows = [ + Boost( + id=str(getattr(row, "id", "") or ""), + user_id=getattr(row, "user_id", None), + chat_id=peer_id_of(peer) or None, + gift=bool(getattr(row, "gift", False)), + giveaway=bool(getattr(row, "giveaway", False)), + unclaimed=bool(getattr(row, "unclaimed", False)), + multiplier=getattr(row, "multiplier", None), + stars=getattr(row, "stars", None), + date=fmt_dt(getattr(row, "date", None)), + date_unix=to_unix(getattr(row, "date", None)), + expires=fmt_dt(getattr(row, "expires", None)), + ) + for row in raw_rows + ] + return build_page( + rows, + op="boost.list", + kind=PageKind.PARTICIPANTS, + state=next_state, + account=ctx.account, + has_more=more, + total=int(getattr(reply, "count", len(rows)) or len(rows)), + ) + + +SPEC_BOOST_LIST = OperationSpec( + id="boost.list", + request=BoostListReq, + response=Page[Boost], + impl=list_boosts, + summary="List boosters of a chat, one user's boosts, or my own boost slots", + description=( + "The cursor is the opaque `next_offset` string. `--mine` reports " + "each slot's `cooldown_until_date`, which is what `boost add` needs " + "before moving a slot to another chat." + ), + aliases=("chat.boost.list",), + paginated=PageKind.PARTICIPANTS, + columns=("id", "user_id", "gift", "expires"), + example={"items": [{"id": "b1", "user_id": 4242, "expires": "2026-06-01T00:00:00Z"}]}, + example_args="boost list @mychannel", + covers=( + "giveaway.boosts-list", + "giveaway.boosts-unrestrict", + "giveaway.user-boosts", + "groups-channels-admin.boost-list", + "groups-channels-admin.boost-user", + ), + coverage_note=( + "The boost surface a booster and an admin both read. Letting boosters " + "bypass restrictions is `chat setting set` territory and shares this " + "surface." + ), +) + + +class BoostAddReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to boost.")] + slots: Annotated[ + list[int], opt("--slots", metavar="ID", help="Slot ids to spend; default: the free ones.") + ] = [] + + +async def add_boost(ctx: OpContext, req: BoostAddReq) -> BoostApplied: + """Spend Premium boost slots on a chat. + + With no `--slots` the free slots reported by `premium.getMyBoosts` are + spent, which is what the GUI does. Moving a slot that is already on + another chat is rate-limited by its `cooldown_until_date`, so the + cooldown is reported rather than discovered as a flood wait. + """ + from telethon.tl.functions import premium as fn + + handle = _admin.client(ctx) + peer = await _send.resolve(ctx, req.chat) + chat_id = peer_id_of(peer) or 0 + + slots = [int(s) for s in req.slots] + cooldown: str | None = None + if not slots: + mine = await handle(fn.GetMyBoostsRequest()) + for row in getattr(mine, "my_boosts", None) or []: + if getattr(row, "peer", None) is None: + slots.append(int(getattr(row, "slot", 0) or 0)) + elif peer_id_of(row.peer) == chat_id: + _admin.already(ctx) + if not slots: + free_cooldowns = [ + fmt_dt(getattr(row, "cooldown_until_date", None)) + for row in (getattr(mine, "my_boosts", None) or []) + if getattr(row, "cooldown_until_date", None) is not None + ] + cooldown = next((value for value in free_cooldowns if value), None) + raise NotFoundError( + "no free boost slot; `boost list --mine` shows each slot and when " + "it comes off cooldown" + ) + + try: + reply = await handle(fn.ApplyBoostRequest(peer=peer, slots=slots or None)) + except Exception as exc: + if "BOOST_NOT_MODIFIED" not in str(exc): + raise + _admin.already(ctx) + return BoostApplied(chat_id=chat_id, already=True, slots=slots) + ctx.emit("chat_boosted", {"chat_id": chat_id, "slots": slots}) + return BoostApplied( + chat_id=chat_id, + level=int(getattr(reply, "level", 0) or 0), + boosts=int(getattr(reply, "boosts", 0) or 0), + my_boost=bool(getattr(reply, "my_boost", True)), + peer=str(req.chat.raw), + slots=slots, + cooldown_until_date=cooldown, + ) + + +SPEC_BOOST_ADD = OperationSpec( + id="boost.add", + request=BoostAddReq, + response=BoostApplied, + impl=add_boost, + summary="Boost a channel or group with my Premium slots", + description=( + "Needs Telegram Premium (PREMIUM_ACCOUNT_REQUIRED exits 6). " + "BOOST_NOT_MODIFIED reports `already: true` and exits 0; moving a " + "slot during its cooldown raises a flood wait and exits 7 with the " + "wait." + ), + aliases=("boost.apply", "chat.boost.apply", "premium.boost.apply"), + mutating=True, + columns=("chat_id", "level", "boosts"), + example={"chat_id": -1001600, "level": 4, "boosts": 15, "my_boost": True}, + example_args="boost add @mychannel", + covers=( + "giveaway.boost-status", + "groups-channels-admin.boost-apply", + "premium.apply-boost", + ), + tags=frozenset({"visible-to-others"}), +) diff --git a/tlgr/ops/chat_topic.py b/tlgr/ops/chat_topic.py new file mode 100644 index 0000000..e78d588 --- /dev/null +++ b/tlgr/ops/chat_topic.py @@ -0,0 +1,905 @@ +"""`chat topic *`: forum topics, which are message threads with a UI. + +A topic is not a chat. Its id is the id of the `messageActionTopicCreate` +service message that started it, which is also what every `--topic` flag on +`message send/list` takes — so the id this module returns is directly usable +in the messages group and nothing has to be translated. + +Three server rules shape the module and are worth stating once. + +* **General is id 1.** It always exists, cannot be deleted, and is the one + topic whose `top_msg_id` must be *omitted* rather than sent as 1. It is + also the only topic that may be hidden. +* **Deleting a topic is a history drain.** `messages.deleteTopicHistory` + answers with `affectedHistory` and an offset to resume from, and the server + emits no dedicated update — other clients learn about it from the deleted + root message. +* **Muting a topic is a notification exception, not a topic property.** It + goes through `account.updateNotifySettings` with an + `inputNotifyForumTopic`, and lives in your account rather than in the chat. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import random +from typing import Annotated, Any + +from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, parse_duration, to_unix +from tlgr.models.admin import Topic, TopicPinResult, TopicReadResult, TopicResult +from tlgr.models.base import Request +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef +from tlgr.ops import _admin, _send +from tlgr.ops._params import arg, opt +from tlgr.ops._serialize import message_to_model +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +_EXAMPLE_TOPIC: dict[str, Any] = { + "id": 314, + "title": "Releases", + "closed": False, + "pinned": True, + "unread_count": 2, + "top_message": 918, +} + +#: Telegram's own sentinel for "muted forever". +MUTE_FOREVER = 2**31 - 1 + + +def _topic_model(raw: Any, *, chat_id: int) -> Topic: + """`forumTopic` (or `forumTopicDeleted`) → `Topic`.""" + if type(raw).__name__ == "ForumTopicDeleted": + return Topic(id=int(getattr(raw, "id", 0) or 0), chat_id=chat_id, deleted=True) + notify = getattr(raw, "notify_settings", None) + mute_until = getattr(notify, "mute_until", None) + from_id = getattr(raw, "from_id", None) + return Topic( + id=int(getattr(raw, "id", 0) or 0), + chat_id=chat_id, + title=str(getattr(raw, "title", "") or ""), + icon_emoji_id=getattr(raw, "icon_emoji_id", None), + icon_color=getattr(raw, "icon_color", None), + closed=bool(getattr(raw, "closed", False)), + pinned=bool(getattr(raw, "pinned", False)), + hidden=bool(getattr(raw, "hidden", False)), + my=bool(getattr(raw, "my", False)), + top_message=int(getattr(raw, "top_message", 0) or 0) or None, + unread_count=int(getattr(raw, "unread_count", 0) or 0), + unread_mentions_count=int(getattr(raw, "unread_mentions_count", 0) or 0), + unread_reactions_count=int(getattr(raw, "unread_reactions_count", 0) or 0), + from_id=abs(_send.peer_id_of(from_id)) if from_id is not None else None, + muted=bool(mute_until) if mute_until is not None else None, + date=fmt_dt(getattr(raw, "date", None)), + date_unix=to_unix(getattr(raw, "date", None)), + ) + + +async def _forum_peer(ctx: OpContext, ref: PeerRef) -> Any: + """The peer, refusing a chat that is not a forum with a readable reason.""" + peer = await _send.resolve(ctx, ref) + if not _admin.is_channel(peer): + raise UsageError( + "topics only exist in forum supergroups; turn them on with " + "`tlgr chat setting set <chat> --forum on`", + field="chat", + ) + return peer + + +def _emoji_id(value: str | None) -> int | None: + if value is None: + return None + text = str(value).strip() + if text in ("", "off", "none", "0"): + return 0 + try: + return int(text) + except ValueError as exc: + raise UsageError( + "--icon-emoji takes a custom-emoji document id", field="icon_emoji" + ) from exc + + +async def _topic_link(ctx: OpContext, peer: Any, topic_id: int) -> str | None: + """`t.me/<username>/<topic_id>` for a public forum, else None.""" + from telethon.tl.functions import channels as fn + + try: + reply = await _admin.client(ctx)( + fn.ExportMessageLinkRequest( + channel=_admin.input_channel(peer), id=topic_id, thread=True + ) + ) + except Exception: + return None + return str(getattr(reply, "link", "") or "") or None + + +# --------------------------------------------------------------------------- +# chat topic list / get +# --------------------------------------------------------------------------- + + +class TopicListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + search: Annotated[str, opt("--search", "-s", metavar="TEXT", help="Title query.")] = "" + closed: Annotated[bool, opt("--closed", help="Only closed topics.")] = False + hidden: Annotated[bool, opt("--hidden", help="Only hidden topics.")] = False + pinned: Annotated[bool, opt("--pinned", help="Only pinned topics.")] = False + + +async def list_topics(ctx: OpContext, req: TopicListReq) -> Page[Topic]: + """A forum's topics, newest activity first. General (id 1) is always there.""" + from datetime import datetime, timezone + + from telethon.tl.functions import messages as fn + + limit, state = _admin.window(ctx, "chat.topic.list", PageKind.PARTICIPANTS) + peer = await _forum_peer(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + offset_date = state.get("date") + reply = await _admin.client(ctx)( + fn.GetForumTopicsRequest( + peer=peer, + q=req.search or None, + offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc) + if offset_date + else None, + offset_id=int(state.get("id", 0) or 0), + offset_topic=int(state.get("topic", 0) or 0), + limit=limit, + ) + ) + rows = [_topic_model(row, chat_id=chat_id) for row in (getattr(reply, "topics", None) or [])] + # The three filters are client-side over the page: the API has no flag + # for any of them. The cursor is built from the last row the *server* + # sent, not the last row that survived the filter — otherwise a page + # whose tail was filtered out would resume in the wrong place. + items = list(rows) + if req.closed: + items = [t for t in items if t.closed] + if req.hidden: + items = [t for t in items if t.hidden] + if req.pinned: + items = [t for t in items if t.pinned] + next_state: dict[str, Any] = {} + if rows: + last = rows[-1] + next_state = { + "date": last.date_unix or 0, + "id": last.top_message or 0, + "topic": last.id, + } + return build_page( + items, + op="chat.topic.list", + kind=PageKind.PARTICIPANTS, + state=next_state, + account=ctx.account, + has_more=len(rows) >= limit, + total=int(getattr(reply, "count", 0) or 0), + ) + + +SPEC_TOPIC_LIST = OperationSpec( + id="chat.topic.list", + request=TopicListReq, + response=Page[Topic], + impl=list_topics, + summary="List or search a forum's topics", + description=( + "The cursor packs the `(offset_date, offset_id, offset_topic)` " + "triple of the last row. `--closed`, `--hidden` and `--pinned` are " + "client-side filters over the page, because the API offers no flag " + "for any of them." + ), + paginated=PageKind.PARTICIPANTS, + columns=("id", "title", "unread_count", "closed", "pinned"), + headers=("ID", "Title", "Unread", "Closed", "Pinned"), + example={"items": [_EXAMPLE_TOPIC], "has_more": False, "total": 1}, + example_args="chat topic list @myforum", + covers=("groups-channels-admin.topic-list",), + covers_partial=("groups-channels-admin.topic-unread-counters",), + coverage_note="The counters are on every row; `chat topic read` clears them and owns the id.", +) + + +class TopicGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + topic: Annotated[ + list[int], arg(1, metavar="TOPIC", kind="msg_id", variadic=True, help="Topic ids.") + ] = [] + + +async def get_topics(ctx: OpContext, req: TopicGetReq) -> list[Topic]: + """One or more topics by id, with their public link when there is one. + + A `forumTopicDeleted` row comes back as `{id, deleted: true}` — that is + the only signal the API gives that a topic was removed, so dropping it + would turn "deleted" into "never existed". + """ + from telethon.tl.functions import messages as fn + + if not req.topic: + raise UsageError("name at least one topic id", field="topic") + peer = await _forum_peer(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + reply = await _admin.client(ctx)( + fn.GetForumTopicsByIDRequest(peer=peer, topics=[int(t) for t in req.topic]) + ) + items = [_topic_model(row, chat_id=chat_id) for row in (getattr(reply, "topics", None) or [])] + if not items: + raise NotFoundError("no such topic in this forum") + for item in items: + if not item.deleted: + item.link = await _topic_link(ctx, peer, item.id) + return items + + +SPEC_TOPIC_GET = OperationSpec( + id="chat.topic.get", + request=TopicGetReq, + response=list[Topic], + impl=get_topics, + summary="Get one or more topics by id", + description=( + "`forumTopicDeleted` rows are reported as `{id, deleted: true}`, " + "which is the only signal the API gives that a topic was removed." + ), + columns=("id", "title", "closed", "link"), + example=[_EXAMPLE_TOPIC], + example_args="chat topic get @myforum 314", + empty_exit=EXIT_EMPTY, + covers=("groups-channels-admin.topic-get", "groups-channels-admin.topic-link"), +) + + +# --------------------------------------------------------------------------- +# chat topic create / edit / close / reopen / hide / unhide / delete +# --------------------------------------------------------------------------- + + +class TopicCreateReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + title: Annotated[str, arg(1, metavar="TITLE", help="Topic title.")] + icon_emoji: Annotated[ + str | None, opt("--icon-emoji", metavar="ID", help="Custom-emoji icon (document id).") + ] = None + icon_color: Annotated[ + int | None, opt("--icon-color", metavar="RGB", help="Icon colour; immutable afterwards.") + ] = None + send_as: Annotated[ + PeerRef | None, + opt( + "--send-as", metavar="PEER", kind="peer", help="Post the creation notice as this peer." + ), + ] = None + + +async def create_topic(ctx: OpContext, req: TopicCreateReq) -> TopicResult: + """Create a topic. The id you get back is the one `--topic` takes.""" + from telethon.tl.functions import messages as fn + + peer = await _forum_peer(ctx, req.chat) + send_as = await _send.resolve(ctx, req.send_as) if req.send_as is not None else None + updates = await _admin.client(ctx)( + fn.CreateForumTopicRequest( + peer=peer, + title=req.title, + icon_color=req.icon_color, + icon_emoji_id=_emoji_id(req.icon_emoji) or None, + random_id=random.getrandbits(63), + send_as=send_as, + ) + ) + topic_id = 0 + for update in getattr(updates, "updates", None) or []: + message = getattr(update, "message", None) + if message is not None and getattr(message, "id", None): + topic_id = int(message.id) + break + chat_id = _send.peer_id_of(peer) + ctx.emit("chat_topic_created", {"chat_id": chat_id, "topic_id": topic_id}) + return TopicResult(chat_id=chat_id, topic_id=topic_id, title=req.title) + + +SPEC_TOPIC_CREATE = OperationSpec( + id="chat.topic.create", + request=TopicCreateReq, + response=TopicResult, + impl=create_topic, + summary="Create a topic", + description=( + "The returned id is the id of the `messageActionTopicCreate` service " + "message, which is exactly what every `--topic` flag takes. " + "Non-Premium accounts may only use icons from " + "`inputStickerSetEmojiDefaultTopicIcons`." + ), + mutating=True, + columns=("chat_id", "topic_id", "title"), + example={"chat_id": -1001500, "topic_id": 314, "title": "Releases"}, + example_args="chat topic create @myforum Releases", + covers=("groups-channels-admin.topic-create",), + tags=frozenset({"visible-to-others"}), +) + + +class TopicEditReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + topic: Annotated[int, arg(1, metavar="TOPIC", kind="msg_id", help="Topic id.")] + title: Annotated[str | None, opt("--title", metavar="TEXT", help="New title.")] = None + icon_emoji: Annotated[ + str | None, opt("--icon-emoji", metavar="ID", help="New custom-emoji icon.") + ] = None + no_icon: Annotated[bool, opt("--no-icon", help="Drop the custom emoji icon.")] = False + closed: Annotated[bool | None, opt("--closed", help="Close or reopen in the same call.")] = None + hidden: Annotated[bool | None, opt("--hidden", help="Hide or show (General only).")] = None + + +async def _edit_topic( + ctx: OpContext, + peer: Any, + topic_id: int, + *, + title: str | None = None, + icon_emoji_id: int | None = None, + closed: bool | None = None, + hidden: bool | None = None, +) -> TopicResult: + from telethon.tl.functions import messages as fn + + await _admin.client(ctx)( + fn.EditForumTopicRequest( + peer=peer, + topic_id=topic_id, + title=title, + icon_emoji_id=icon_emoji_id, + closed=closed, + hidden=hidden, + ) + ) + changed = [ + name + for name, value in ( + ("title", title), + ("icon_emoji", icon_emoji_id), + ("closed", closed), + ("hidden", hidden), + ) + if value is not None + ] + return TopicResult( + chat_id=_send.peer_id_of(peer), + topic_id=topic_id, + title=title, + icon_emoji_id=icon_emoji_id, + closed=closed, + hidden=hidden, + changed=changed, + ) + + +async def edit_topic(ctx: OpContext, req: TopicEditReq) -> TopicResult: + """Rename a topic, re-icon it, or close/hide it in the same call.""" + peer = await _forum_peer(ctx, req.chat) + if ( + req.title is None + and req.icon_emoji is None + and not req.no_icon + and req.closed is None + and req.hidden is None + ): + raise UsageError("nothing to change", field="title") + if req.topic == _admin.GENERAL_TOPIC and (req.icon_emoji or req.no_icon): + raise UsageError("the General topic has no icon of its own", field="icon_emoji") + icon = 0 if req.no_icon else _emoji_id(req.icon_emoji) + return await _edit_topic( + ctx, + peer, + req.topic, + title=req.title, + icon_emoji_id=icon, + closed=req.closed, + hidden=req.hidden, + ) + + +SPEC_TOPIC_EDIT = OperationSpec( + id="chat.topic.edit", + request=TopicEditReq, + response=TopicResult, + impl=edit_topic, + summary="Rename a topic or change its icon", + description=( + "`icon_color` cannot be changed after creation — the API has no " + "field for it — and the General topic accepts only `--title` and " + "`--hidden`." + ), + mutating=True, + columns=("chat_id", "topic_id", "title"), + example={"chat_id": -1001500, "topic_id": 314, "title": "Releases"}, + example_args="chat topic edit @myforum 314 --title 'Release notes'", + covers=("groups-channels-admin.topic-edit",), + covers_partial=( + "groups-channels-admin.topic-close-reopen", + "groups-channels-admin.topic-hide-general", + ), + coverage_note=( + "`--closed`/`--hidden` do it in one call; `chat topic reopen` and " + "`chat topic unhide` own the ids." + ), +) + + +class TopicOneReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + topic: Annotated[int, arg(1, metavar="TOPIC", kind="msg_id", help="Topic id.")] + + +async def close_topic(ctx: OpContext, req: TopicOneReq) -> TopicResult: + """Close a topic: only admins may post in it afterwards.""" + peer = await _forum_peer(ctx, req.chat) + return await _edit_topic(ctx, peer, req.topic, closed=True) + + +SPEC_TOPIC_CLOSE = OperationSpec( + id="chat.topic.close", + request=TopicOneReq, + response=TopicResult, + impl=close_topic, + summary="Close a topic", + mutating=True, + columns=("chat_id", "topic_id", "closed"), + example={"chat_id": -1001500, "topic_id": 314, "closed": True}, + example_args="chat topic close @myforum 314", + covers_partial=("groups-channels-admin.topic-close-reopen",), + coverage_note="The closing half; `chat topic reopen` owns the id.", +) + + +async def reopen_topic(ctx: OpContext, req: TopicOneReq) -> TopicResult: + """Reopen a closed topic.""" + peer = await _forum_peer(ctx, req.chat) + return await _edit_topic(ctx, peer, req.topic, closed=False) + + +SPEC_TOPIC_REOPEN = OperationSpec( + id="chat.topic.reopen", + request=TopicOneReq, + response=TopicResult, + impl=reopen_topic, + summary="Reopen a closed topic", + mutating=True, + columns=("chat_id", "topic_id", "closed"), + example={"chat_id": -1001500, "topic_id": 314, "closed": False}, + example_args="chat topic reopen @myforum 314", + covers=("groups-channels-admin.topic-close-reopen",), +) + + +class TopicGeneralReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + + +async def hide_topic(ctx: OpContext, req: TopicGeneralReq) -> TopicResult: + """Hide the General topic. The server refuses any other id.""" + peer = await _forum_peer(ctx, req.chat) + return await _edit_topic(ctx, peer, _admin.GENERAL_TOPIC, hidden=True) + + +SPEC_TOPIC_HIDE = OperationSpec( + id="chat.topic.hide", + request=TopicGeneralReq, + response=TopicResult, + impl=hide_topic, + summary="Hide the General topic", + description="Only General (id 1) may be hidden; the server refuses any other id.", + mutating=True, + columns=("chat_id", "topic_id", "hidden"), + example={"chat_id": -1001500, "topic_id": 1, "hidden": True}, + example_args="chat topic hide @myforum", + covers_partial=("groups-channels-admin.topic-hide-general",), + coverage_note="The hiding half; `chat topic unhide` owns the id.", +) + + +async def unhide_topic(ctx: OpContext, req: TopicGeneralReq) -> TopicResult: + """Show the General topic again.""" + peer = await _forum_peer(ctx, req.chat) + return await _edit_topic(ctx, peer, _admin.GENERAL_TOPIC, hidden=False) + + +SPEC_TOPIC_UNHIDE = OperationSpec( + id="chat.topic.unhide", + request=TopicGeneralReq, + response=TopicResult, + impl=unhide_topic, + summary="Show the General topic again", + mutating=True, + columns=("chat_id", "topic_id", "hidden"), + example={"chat_id": -1001500, "topic_id": 1, "hidden": False}, + example_args="chat topic unhide @myforum", + covers=("groups-channels-admin.topic-hide-general",), +) + + +async def delete_topic(ctx: OpContext, req: TopicOneReq) -> TopicResult: + """Delete a topic and every message in it, draining the offset loop.""" + from telethon.tl.functions import messages as fn + + peer = await _forum_peer(ctx, req.chat) + if req.topic == _admin.GENERAL_TOPIC: + raise UsageError( + "the General topic cannot be deleted; `chat topic hide` removes it from view", + field="topic", + ) + deleted = await _admin.affected_loop( + ctx, lambda _offset: fn.DeleteTopicHistoryRequest(peer=peer, top_msg_id=req.topic) + ) + chat_id = _send.peer_id_of(peer) + ctx.emit("chat_topic_deleted", {"chat_id": chat_id, "topic_id": req.topic}) + return TopicResult( + chat_id=chat_id, topic_id=req.topic, deleted=True, changed=[f"messages:{deleted}"] + ) + + +SPEC_TOPIC_DELETE = OperationSpec( + id="chat.topic.delete", + request=TopicOneReq, + response=TopicResult, + impl=delete_topic, + summary="Delete a topic and all its messages", + description=( + "Drains `messages.affectedHistory` until the offset is 0. No " + "dedicated update is emitted; other clients learn about it from the " + "deleted root message." + ), + mutating=True, + destructive=True, + rate_class="bulk", + timeout_s=300, + columns=("chat_id", "topic_id", "deleted"), + example={"chat_id": -1001500, "topic_id": 314, "deleted": True}, + example_args="chat topic delete @myforum 314 --yes", + covers=("groups-channels-admin.topic-delete",), +) + + +# --------------------------------------------------------------------------- +# chat topic pin / unpin +# --------------------------------------------------------------------------- + + +class TopicPinReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + topic: Annotated[ + list[int], arg(1, metavar="TOPIC", kind="msg_id", variadic=True, help="Topic ids.") + ] = [] + reorder: Annotated[ + bool, opt("--reorder", help="Treat the ids as the complete pinned order.") + ] = False + force: Annotated[ + bool, opt("--force", help="With --reorder: unpin topics missing from the list.") + ] = False + + +async def pin_topics(ctx: OpContext, req: TopicPinReq) -> TopicPinResult: + """Pin topics, or (with `--reorder`) declare the whole pinned order.""" + from telethon.tl.functions import messages as fn + + peer = await _forum_peer(ctx, req.chat) + if not req.topic: + raise UsageError("name at least one topic id", field="topic") + handle = _admin.client(ctx) + ids = [int(t) for t in req.topic] + if req.reorder: + await handle( + fn.ReorderPinnedForumTopicsRequest(peer=peer, order=ids, force=req.force or None) + ) + else: + for topic_id in ids: + await handle( + fn.UpdatePinnedForumTopicRequest(peer=peer, topic_id=topic_id, pinned=True) + ) + return TopicPinResult(chat_id=_send.peer_id_of(peer), pinned=ids) + + +SPEC_TOPIC_PIN = OperationSpec( + id="chat.topic.pin", + request=TopicPinReq, + response=TopicPinResult, + impl=pin_topics, + summary="Pin topics (pass several ids to set the pinned order)", + description=( + "`--reorder` sends the ids as the complete order; `--force` also " + "unpins anything missing from the list. At most `topics_pinned_limit` " + "topics can be pinned." + ), + mutating=True, + columns=("chat_id", "pinned"), + example={"chat_id": -1001500, "pinned": [314]}, + example_args="chat topic pin @myforum 314", + covers=("groups-channels-admin.topic-reorder-pinned",), + covers_partial=("groups-channels-admin.topic-pin",), + coverage_note="Pinning is here; `chat topic unpin` owns the id.", +) + + +class TopicUnpinReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + topic: Annotated[ + list[int], + arg(1, metavar="TOPIC", kind="msg_id", variadic=True, required=False, help="Topic ids."), + ] = [] + everything: Annotated[bool, opt("--all", help="Unpin every pinned topic.")] = False + + +async def unpin_topics(ctx: OpContext, req: TopicUnpinReq) -> TopicPinResult: + """Unpin topics, or clear the pinned order entirely.""" + from telethon.tl.functions import messages as fn + + peer = await _forum_peer(ctx, req.chat) + handle = _admin.client(ctx) + if req.everything: + await handle(fn.ReorderPinnedForumTopicsRequest(peer=peer, order=[], force=True)) + return TopicPinResult(chat_id=_send.peer_id_of(peer), unpinned=[], pinned=[]) + if not req.topic: + raise UsageError("name a topic id, or pass --all", field="topic") + ids = [int(t) for t in req.topic] + for topic_id in ids: + await handle(fn.UpdatePinnedForumTopicRequest(peer=peer, topic_id=topic_id, pinned=False)) + return TopicPinResult(chat_id=_send.peer_id_of(peer), unpinned=ids) + + +SPEC_TOPIC_UNPIN = OperationSpec( + id="chat.topic.unpin", + request=TopicUnpinReq, + response=TopicPinResult, + impl=unpin_topics, + summary="Unpin a topic", + mutating=True, + columns=("chat_id", "unpinned"), + example={"chat_id": -1001500, "unpinned": [314]}, + example_args="chat topic unpin @myforum 314", + covers=("groups-channels-admin.topic-pin",), +) + + +# --------------------------------------------------------------------------- +# chat topic mute / unmute +# --------------------------------------------------------------------------- + + +class TopicMuteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + topic: Annotated[int, arg(1, metavar="TOPIC", kind="msg_id", help="Topic id.")] + duration: Annotated[ + str | None, + arg(2, metavar="DURATION", required=False, help="How long; omit to mute forever."), + ] = None + silent: Annotated[ + bool | None, opt("--silent", help="Deliver without a sound instead of muting.") + ] = None + previews: Annotated[ + bool | None, opt("--previews", help="Show message text in notifications.") + ] = None + + +async def _notify_topic( + ctx: OpContext, + peer: Any, + topic_id: int, + *, + mute_until: int | None, + silent: bool | None = None, + previews: bool | None = None, +) -> TopicResult: + """One `inputNotifyForumTopic` exception, written absolutely. + + `mute_until` is an absolute wall-clock timestamp, computed here. v1 + computed a timed mute from the event loop's clock and every one of them + resolved to 1970 (COR-01); a topic mute must not repeat that. + """ + from datetime import datetime, timezone + + from telethon.tl import types + from telethon.tl.functions import account as fn + + stamp = ( + datetime.fromtimestamp(mute_until, tz=timezone.utc) if mute_until not in (None, 0) else None + ) + await _admin.client(ctx)( + fn.UpdateNotifySettingsRequest( + peer=types.InputNotifyForumTopic(peer=peer, top_msg_id=topic_id), + settings=types.InputPeerNotifySettings( + mute_until=stamp, + silent=silent, + show_previews=previews, + ), + ) + ) + return TopicResult( + chat_id=_send.peer_id_of(peer), + topic_id=topic_id, + mute_until=fmt_dt(stamp), + silent=silent, + previews=previews, + ) + + +async def mute_topic(ctx: OpContext, req: TopicMuteReq) -> TopicResult: + """Mute one topic. Omitting the duration means forever.""" + import time + + peer = await _forum_peer(ctx, req.chat) + until: int | None = MUTE_FOREVER + if req.duration: + seconds = parse_duration(req.duration) + if seconds is None: + raise UsageError(f"{req.duration!r} is not a duration", field="duration") + until = int(time.time()) + int(seconds) + if req.silent is not None and req.duration is None: + until = None + return await _notify_topic( + ctx, peer, req.topic, mute_until=until, silent=req.silent, previews=req.previews + ) + + +SPEC_TOPIC_MUTE = OperationSpec( + id="chat.topic.mute", + request=TopicMuteReq, + response=TopicResult, + impl=mute_topic, + summary="Mute a topic", + description=( + "`mute_until` is an absolute timestamp computed from the wall clock. " + "`--silent on` without a duration switches to silent delivery " + "instead of muting." + ), + mutating=True, + columns=("chat_id", "topic_id", "mute_until"), + example={"chat_id": -1001500, "topic_id": 314, "mute_until": "2038-01-19T03:14:07Z"}, + example_args="chat topic mute @myforum 314 8h", + covers_partial=("groups-channels-admin.topic-notify-settings",), + coverage_note="Muting half; `chat topic unmute` owns the id.", +) + + +async def unmute_topic(ctx: OpContext, req: TopicOneReq) -> TopicResult: + """Unmute a topic (mute_until = 0).""" + peer = await _forum_peer(ctx, req.chat) + return await _notify_topic(ctx, peer, req.topic, mute_until=0) + + +SPEC_TOPIC_UNMUTE = OperationSpec( + id="chat.topic.unmute", + request=TopicOneReq, + response=TopicResult, + impl=unmute_topic, + summary="Unmute a topic", + mutating=True, + columns=("chat_id", "topic_id", "mute_until"), + example={"chat_id": -1001500, "topic_id": 314}, + example_args="chat topic unmute @myforum 314", + covers=("groups-channels-admin.topic-notify-settings",), +) + + +# --------------------------------------------------------------------------- +# chat topic read +# --------------------------------------------------------------------------- + + +class TopicReadReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")] + topic: Annotated[int, arg(1, metavar="TOPIC", kind="msg_id", help="Topic id.")] + max_id: Annotated[ + int, opt("--max-id", metavar="ID", help="Read up to this message id; 0 means everything.") + ] = 0 + mentions: Annotated[bool, opt("--mentions", help="Also clear the unread-mentions badge.")] = ( + False + ) + reactions: Annotated[ + bool, opt("--reactions", help="Also clear the unread-reactions badge.") + ] = False + list_only: Annotated[ + bool, opt("--list", help="Do not read: list the unread mentions/reactions instead.") + ] = False + + +async def read_topic(ctx: OpContext, req: TopicReadReq) -> TopicReadResult: + """Mark a topic read, or list what is unread in it. + + Reading a topic does not read the rest of the forum, and `top_msg_id` + must be omitted for General — sending 1 there is how a client ends up + reading nothing. + """ + from telethon.tl.functions import messages as fn + + peer = await _forum_peer(ctx, req.chat) + chat_id = _send.peer_id_of(peer) + handle = _admin.client(ctx) + top = None if req.topic == _admin.GENERAL_TOPIC else req.topic + + if req.list_only: + items = [] + if req.mentions or not req.reactions: + reply = await handle( + fn.GetUnreadMentionsRequest( + peer=peer, + top_msg_id=top, + offset_id=0, + add_offset=0, + limit=int(getattr(ctx, "limit", None) or 50), + max_id=0, + min_id=0, + ) + ) + items += [ + message_to_model(m, chat_id=chat_id) + for m in (getattr(reply, "messages", None) or []) + ] + if req.reactions: + reply = await handle( + fn.GetUnreadReactionsRequest( + peer=peer, + top_msg_id=top, + offset_id=0, + add_offset=0, + limit=int(getattr(ctx, "limit", None) or 50), + max_id=0, + min_id=0, + ) + ) + items += [ + message_to_model(m, chat_id=chat_id) + for m in (getattr(reply, "messages", None) or []) + ] + return TopicReadResult(chat_id=chat_id, topic_id=req.topic, items=items) + + await handle( + fn.ReadDiscussionRequest(peer=peer, msg_id=req.topic, read_max_id=req.max_id or 0x7FFFFFFF) + ) + if req.mentions: + await handle(fn.ReadMentionsRequest(peer=peer, top_msg_id=top)) + if req.reactions: + await handle(fn.ReadReactionsRequest(peer=peer, top_msg_id=top)) + ctx.emit("chat_topic_read", {"chat_id": chat_id, "topic_id": req.topic}) + return TopicReadResult( + chat_id=chat_id, + topic_id=req.topic, + max_id=req.max_id or None, + unread_count=0, + ) + + +SPEC_TOPIC_READ = OperationSpec( + id="chat.topic.read", + request=TopicReadReq, + response=TopicReadResult, + impl=read_topic, + summary="Mark a topic read, including its mentions and reactions", + description=( + "SEMANTICS: this emits a read receipt inside the topic, exactly like " + "`chat open` does for a chat. `--list` is the silent half. " + "`top_msg_id` is omitted for General (id 1), which is what the API " + "requires. Sending and listing messages inside a topic is " + "`message send/list --topic`." + ), + mutating=True, + columns=("chat_id", "topic_id", "unread_count"), + example={"chat_id": -1001500, "topic_id": 314, "unread_count": 0}, + example_args="chat topic read @myforum 314 --mentions", + covers=( + "groups-channels-admin.topic-messages", + "groups-channels-admin.topic-unread-counters", + ), + tags=frozenset({"visible-to-others"}), +)