diff --git a/AGENT.md b/AGENT.md index ff19cd1..cf93e44 100644 --- a/AGENT.md +++ b/AGENT.md @@ -738,7 +738,7 @@ tlgr user dialog-status [--max-dialogs N] → {"ref": ..., "id": ..., "username": ..., "resolved": true, "has_dialog": true, "message_count": 12, "source": "peer_dialogs", "reason": null} -tlgr user hide-stories ... [--unhide] [--all on|off] +tlgr user hide-stories ... [--unhide] [--all] # v1's spelling of `story hide` → {"user_id": ..., "username": ..., "hidden": true, "already": false} # More than one peer fills `peers`; a single peer answers with exactly the # four keys above. @@ -774,15 +774,11 @@ tlgr resolve cache get [--type KIND] [--stale 7d] [--refresh PEER] [--purge] # `access_hash_cached`; they are per login session and worthless elsewhere. ``` -`hide-stories` is Telegram's own "Hide Stories" menu item: the peer leaves the -main stories bar for the collapsed Hidden list. Per-account and purely local — -**the other side is never notified**, the chat, the contact entry and their -access to you are untouched — so it is safe to apply in bulk to everyone an -outreach campaign has contacted, which is what keeps a working account's story -bar readable. Idempotent: it reads the fresh `stories_hidden` flag first and -returns `already: true` without an RPC when there is nothing to do, so -repeating a pass over hundreds of peers is nearly free. `tlgr user get` reports -the same flag as `stories_hidden`, so the state can be audited without writing. +`hide-stories` is now `tlgr story hide ` (and `--unhide` is +`tlgr story unhide `). The old path, the old flag and the four keys are +unchanged — see **Stories** below for what it does and why it is free to +repeat. `tlgr user get` still reports the current value as `stories_hidden`, +so the state can be audited without writing. `dialog-status` is the ONLY correct way to ask "does this account have prior history with this person?". Three outcomes, never conflated: @@ -937,6 +933,72 @@ the current tip; tlgr has no block builder, accepts one from an external implementation (`--block`, `--public-key`) and otherwise exits 2 naming exactly what is missing rather than sending a request that will fail. +### Stories + +``` +tlgr story feed list # the stories bar; --hidden, --unread-only +→ {"items": [{"peer_id": …, "max_read_id": 41, "unread_count": 1, + "has_unread": true}], "has_more": false} + +tlgr story list # active; --profile, --archive, --album ID +→ {"items": [{"id": 42, "date": "…Z", "expire_date": "…Z", "caption": "…", + "media": {…}, "pinned": false}], "has_more": false} + +tlgr story get ... # --views, --link, --areas-out, --translate +tlgr story post ... # --caption, --privacy, --allow, --exclude, + # --period, --pin, --album, --area-* +tlgr story edit # --caption, --file, --cover-ts, --privacy +tlgr story delete ... # irreversible; needs --yes off a TTY + +tlgr story read [...] # clears YOUR unread ring +→ {"peer": …, "max_id": 43, "ids": [42, 43], "ok": true} +tlgr story read --register-view # …and appear in their viewer list + +tlgr story react 🔥 # --remove, --custom-emoji, --as-message +tlgr story reply "text" # a private message carrying the story +tlgr story share --until # sends a story card, not a copy + +tlgr story pin|unpin ... # the profile page; --top for the top row +tlgr story hide|unhide ... # the stories bar; --all for the whole bar +tlgr story viewer list # --contacts, --q, --csv PATH, --hide-from +tlgr story blocklist list|set ... # "Hide my stories from"; --remove, --replace +tlgr story album create|edit|list|delete|reorder … +tlgr story can-post # free slots, limits, Premium gates, --chats +tlgr story stealth set --past --future # Premium; --status only reads +tlgr story search --hashtag berlin # public stories only +tlgr story stats get # graphs; --forwards for public reposts +tlgr story export --out DIR # the bulk export the GUI has no button for +tlgr story live start --rtmp # prints the ingest URL and key +tlgr story watch # story.new / read / reaction / stealth +``` + +Four rules matter more than the flags: + +- **Reading is not being seen.** `story read` sends `stories.readStories`, + which clears the ring on *your* side and tells the poster nothing. Appearing + in their viewer list is `--register-view`, and it is opt-in on purpose. +- **The audience is a base rule plus exceptions**, applied in that order: + `--privacy contacts --exclude @bob` is "contacts, except Bob". `--privacy + selected` with no `--allow` is refused, because it would post to nobody. + Channel stories ignore the vector entirely. +- **A placeholder is not a story.** A feed row can come back as + `{"id": 99, "skipped": true}` and a gone story as `{"id": 99, + "deleted": true}`. `story list` hydrates placeholders by default; + `--no-hydrate` gives you the raw shape. +- **Re-run `story can-post` immediately before posting.** The weekly and + monthly quotas move under you, and a refusal comes back as a named + `reason` (`STORY_SEND_FLOOD_WEEKLY`, `BOOSTS_REQUIRED`, …) with the + seconds or boosts still missing. + +`story hide` is Telegram's own "Hide Stories" menu item: the peer leaves the +main stories bar for the collapsed Hidden list. Per-account and purely local — +**the other side is never notified**, the chat, the contact entry and their +access to you are untouched — so it is safe to apply in bulk to everyone an +outreach campaign has contacted. Idempotent: it reads the fresh +`stories_hidden` flag first and returns `already: true` without an RPC when +there is nothing to do, so repeating a pass over hundreds of peers is nearly +free. + ### Agent Helpers ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index cf06a5c..40ea533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,12 +54,19 @@ freezes are unchanged: `user dialog-status` is still three-valued and still exits 13 for "could not establish", and `user hide-stories` still reports `already` and sends nothing when there is nothing to do. +`story` follows — 31 operations covering posting, the feed, viewers, albums, +the story blocklist, stealth mode and live stories, where v1 had exactly one +command (`user hide-stories`). That path still works, `--unhide` and bulk +peers included: `story hide` is now the single implementation of the toggle +and `user hide-stories` is a legacy path on it, so the two spellings cannot +drift apart. + ### Breaking Every change below applies **only to commands generated from the operation registry** — in this release that is the `message`, `draft`, `chat`, `folder`, `auth`, `account`, `passport`, `media`, `sticker`, `gif`, `emoji`, -`events`, `watch`, `daemon`, `sync`, `net`, `proxy`, `config`, `job`, +`story`, `events`, `watch`, `daemon`, `sync`, `net`, `proxy`, `config`, `job`, `webhook`, `export`, `contact`, `user` and `resolve` groups, `tlgr completion`, `tlgr status`, `tlgr schema` and the `agent` group. Commands still hand-written under `tlgr/cli/legacy/` behave exactly as they did in v1 until @@ -142,6 +149,37 @@ Two more, outside the documented output shapes: ### Added +- **The `story` group.** 31 operations covering the whole story surface: + posting (with the audience vector, media areas, albums, reposts and a + soundtrack), the stories bar, a peer's active/profile/archive/album grids, + reading, reacting, replying, sharing, pinning, hiding, viewers, the + story-only blocklist, stealth mode, hashtag and location search, statistics, + a bulk export and live stories. Four of Telegram's shapes drive the design + and are worth knowing before scripting against them: + - **Reading a story and being seen watching it are different calls.** + `story read` sends `stories.readStories`, which clears *your* unread ring + and tells the poster nothing; `--register-view` is what calls + `stories.incrementStoryViews` and puts you in their viewer list. It is + opt-in because an agent that silently appears there is a privacy bug. + - **The audience is an ordered vector**, `[base rule, allows…, disallows…]`, + which is the only way `--privacy contacts --exclude @bob` can mean + "contacts, except Bob". `--privacy selected` with no `--allow` is refused + rather than posted to nobody, and channel stories ignore the vector. + - **One id has three TL shapes.** A feed row can be a `storyItemSkipped` + placeholder and a gone story a `storyItemDeleted`; both come back with + `skipped: true` / `deleted: true` rather than as a story with no caption. + `story list` hydrates placeholders by default. + - **The feed pages on an opaque state, not an offset.** + `stories.getAllStories` returns a `state` that the next call sends back + with `next`; `--cursor` carries both, and `--refresh` re-sends the stored + state and reports `already: true` when nothing changed. +- **`tlgr user hide-stories` is now `tlgr story hide`.** The v1 path, its + `--unhide` flag and its four keys (`user_id`, `username`, `hidden`, + `already`) are unchanged; `story unhide` is the canonical inverse and + `--all` collapses the whole stories bar. +- **`story viewer list --csv PATH` and `story export`** are the two things the + official clients have no button for: the viewer list as a file, and every + story with its media on disk. - **The content groups: `poll`, `reaction`, `todo`, `location` and `search`.** 43 operations covering polls and quizzes, the whole reaction surface, checklists, places and live locations, and search outside a single chat. @@ -552,6 +590,11 @@ Two more, outside the documented output shapes: full set of reactions this account holds after the call, which is what the next `sendReaction` has to resend. +- **The second implementation of the "Hide Stories" toggle.** `story hide` + owns it, and `tlgr user hide-stories` is declared as its legacy path rather + than kept as an operation of its own — one alias, one implementation. + `tlgr user get` still reports `stories_hidden`. + - The dead `jobs.toml` job engine in `core/config.py` (`load_jobs`, `save_jobs`, `JobConfig`, `DestinationConfig`, …). It had no callers left; jobs are `jobs.yaml`, parsed by `gateway/config.py` (MNT-04). diff --git a/README.md b/README.md index 04a17fd..e1ed165 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ rather than the reply claiming "no such user". ```bash tlgr user get # --full --translate-bio LANG --from-chat/--from-message tlgr user dialog-status # does THIS account have prior history with them? -tlgr user hide-stories ... # archive their stories for this account (--unhide) +tlgr user hide-stories ... # v1's spelling of `story hide` (--unhide) tlgr user block # --stories --report-spam --delete-history tlgr user unblock tlgr user can-message ... # free | premium | paid (and the Stars price) @@ -265,6 +265,28 @@ tlgr resolve cache get # inspect the per-account peer database that would follow it in `delegated_to`. A phone lookup that comes back empty exits 13, never 5 — no account and a privacy refusal are indistinguishable. +### Stories + +```bash +tlgr story feed list # the stories bar (--hidden, --unread-only) +tlgr story list # active; --profile, --archive, --album ID +tlgr story get # --views, --link, --areas-out, --translate +tlgr story post ... # --caption, --privacy, --allow, --exclude, + # --period, --pin, --album, --area-url, … +tlgr story read # clears YOUR ring; --register-view to be seen +tlgr story react|reply|share +tlgr story pin|unpin|hide|unhide [...] +tlgr story viewer list # --contacts, --q, --csv PATH +tlgr story blocklist set ... # "Hide my stories from" +tlgr story album create|edit|list|delete|reorder … +tlgr story can-post | stealth set | search | stats get | export | live start | watch +``` + +`story read` clears your own unread ring and tells the poster nothing; +`--register-view` is what puts you in their viewer list, and it is opt-in. +`--privacy` sets the base audience and `--allow`/`--exclude` layer exceptions +on top, in that order, so "contacts, except Bob" is expressible. + ### Media, stickers, GIFs and emoji ```bash diff --git a/docs/design/DECISIONS.md b/docs/design/DECISIONS.md index f781bc2..143c837 100644 --- a/docs/design/DECISIONS.md +++ b/docs/design/DECISIONS.md @@ -1128,3 +1128,110 @@ 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. + +## 2026-09-04 — one "Hide Stories" toggle, owned by `story hide` + +PR-5 shipped `user hide-stories` as an operation of its own; `story hide` +declares the same path as a legacy path, and the registry refuses one alias +claimed by two ops — correctly, because a toggle with two implementations is +a toggle that will disagree with itself. `story hide` keeps the +implementation, because that is where the rest of the story surface reads the +same `stories_hidden` flag, and it absorbs what the `user` op had that it did +not: several peers in one pass, and `--all` for the whole bar. `user +hide-stories` is now purely the §12.4 path onto it, so v1's spelling and its +`--unhide` flag keep working with nothing behind them to drift. + +## 2026-09-04 — `story feed list` does not carry the stealth mode + +`stories.getAllStories` answers with the account's `stealth_mode` beside the +feed, and the work list asked for it as a top-level field. A paginated +operation must declare `response=Page[T]` (registry lint L6), and `Page` has +no room for a sidecar; putting the same object on every row would be worse +than not reporting it. `story stealth --status` reads it from the same call, +so the information is one command away and appears in exactly one shape. + +## 2026-09-04 — three two-segment story aliases are dropped + +`story feed`, `story stats` and `story stealth` were proposed as shorthands +for `story feed list`, `story stats get` and `story stealth set`. Click has one +namespace per level, so registering them would replace the *group* of the same +name and take the canonical three-segment command with it — the same trap +`chat badge` hit on 2026-09-03. The canonical paths stand alone; `story view` +and `story forward` are registered because they cannot collide. + +## 2026-09-04 — `story search --peer`, not `--since` + +The work list spells the poster filter `--since`, which is the date flag the +generator injects into every `SEARCH`-paginated command. Two parameters with +one name is a Click warning and a silent shadow, so the flag is `--peer`; the +work list's spelling would have meant "restrict to one poster" on this command +and "only after this time" on every other one. + +## 2026-09-04 — the viewer export is `--csv PATH`, not `--format csv` + +`--format table|json|csv` would have added a third output switch beside the +global `--json`/`--plain` pair, and its `json` value would have shadowed a +global flag. `story viewer list --csv PATH` writes the file the GUI has no +button for and leaves rendering to the flags every other command uses. + +## 2026-09-04 — `story live get` reports the story, not the call + +Telethon 1.44 speaks layer 227, whose `storyItem` carries no group-call +reference; there is no accessor from a live story to the call that carries its +viewer count, publisher and stream settings. The operation is registered and +reports what the layer does expose — the story id, its dates, the live flag — +and warns that the call-side fields are unreachable, rather than returning +zeros that read as an empty broadcast. `stories.live-join` and +`livestory.streamer-info` are therefore `covers_partial`; the rest of the +live-story surface belongs to PR-11, which owns the call and landed ahead of +this one. + +## 2026-09-04 — a refusal from `canSendStory` is an error, not a result + +The work list describes `canSendStoryResult*` variants. Layer 227 has none: +`stories.canSendStory` returns a count or the server raises +`PREMIUM_ACCOUNT_REQUIRED` / `BOOSTS_REQUIRED` / `STORIES_TOO_MUCH` / +`STORY_SEND_FLOOD_WEEKLY_%d`. `story can-post` catches the error and reports +the reason with the number the message carries, so a caller still gets +`{"can_post": false, "reason": …, "retry_after": …}` rather than a raw +exception — and `story post` turns the same refusal into exit 6. + +## 2026-09-04 — `story read --register-view` is opt-in + +`stories.readStories` clears the reader's own unread ring and tells the poster +nothing; `stories.incrementStoryViews` is what puts the account in the +poster's viewer list. The work list folds both into one command, and the +default is the private half: an agent walking a feed must not silently appear +in strangers' viewer lists, so being seen costs an explicit flag. + +## 2026-09-04 — story updates are shaped in `normalise_update`, not beside it + +Telethon has no event builder for stories, so the six story updates only reach +the bus through the daemon's single `events.Raw()` handler. PR-4's taxonomy in +`core/eventtypes.py` already maps all six constructors onto the five +`story_*` types, so PR-8 adds a payload branch to `normalise_update` rather +than a second normaliser and a second Raw handler: one table names the +vocabulary, one function shapes it. The branch keeps the fine-grained `kind` +the story surface wants — `UpdateNewStoryReaction` and +`UpdateSentStoryReaction` share a type, and only `kind` says which side the +reaction came from. + +## 2026-09-04 — `--music` takes a path, never a bare document id + +A soundtrack is sent as an `inputDocument`, which needs an access hash and a +file reference. A bare document id has neither, so accepting one would produce +a request the server rejects minutes later with `FILE_REFERENCE_EXPIRED`. The +flag takes a file, uploads it through `messages.uploadMedia`, and refuses a +numeric argument with a usage error that says why. + +## 2026-09-04 — the stories domain keeps 7 ids it does not own + +Same shape as `media_files`: the catalog groups by subject, tlgr by command +group. Story notification settings belong to `notify`, saving a story's +soundtrack is a profile surface, and posting for a business account goes +through a bot connection. Each is waived to the PR that owns the command +rather than implemented here under a `story` noun where nobody would look for +it. The close-friends list and the live story's call — comments, RTMP key, +send-as identity — were on that list too until PR-5 and PR-11 landed ahead of +this one and covered them outright. `stories` is 94.2 % covered and 100 % +accounted. diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index c9a17a0..0cb1f40 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 — 479 operations, 689 invocable paths +catalog 2026-09-02 — 509 operations, 722 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 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 +bots_inline_payments 18 175 10.3% 100.0% 8 +calls_voicechats 131 133 98.5% 100.0% 55 +contacts_users 108 121 89.3% 100.0% 51 +dialogs_chats 137 146 93.8% 100.0% 74 +groups_channels_admin 158 162 97.5% 100.0% 104 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 +messages_core 164 167 98.2% 100.0% 58 +polls_reactions_content 130 174 74.7% 100.0% 68 profile_settings_privacy 32 178 18.0% 100.0% 30 -stories 15 120 12.5% 100.0% 12 +stories 113 120 94.2% 100.0% 43 updates_sync_network 188 189 99.5% 100.0% 67 priority covered req % acct% -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% +P0 161 178 90.4% 100.0% +P1 323 379 85.2% 100.0% +P2 459 610 75.2% 100.0% +P3 447 630 71.0% 100.0% -TOTAL 1285 1797 71.5% 100.0% +TOTAL 1390 1797 77.4% 100.0% excluded: not-applicable 79, prohibited 40 -uncovered: 512 (512 waived with a PR number) +uncovered: 407 (407 waived with a PR number) ``` ## By domain @@ -39,26 +39,26 @@ uncovered: 512 (512 waived with a PR number) | Domain | Covered | Required | % | Accounted % | Ops | |---|---:|---:|---:|---:|---:| | `auth_sessions_security` | 87 | 89 | 97.8% | 100.0% | 44 | -| `bots_inline_payments` | 17 | 175 | 9.7% | 100.0% | 7 | -| `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 | +| `bots_inline_payments` | 18 | 175 | 10.3% | 100.0% | 8 | +| `calls_voicechats` | 131 | 133 | 98.5% | 100.0% | 55 | +| `contacts_users` | 108 | 121 | 89.3% | 100.0% | 51 | +| `dialogs_chats` | 137 | 146 | 93.8% | 100.0% | 74 | +| `groups_channels_admin` | 158 | 162 | 97.5% | 100.0% | 104 | | `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 | +| `messages_core` | 164 | 167 | 98.2% | 100.0% | 58 | +| `polls_reactions_content` | 130 | 174 | 74.7% | 100.0% | 68 | | `profile_settings_privacy` | 32 | 178 | 18.0% | 100.0% | 30 | -| `stories` | 15 | 120 | 12.5% | 100.0% | 12 | +| `stories` | 113 | 120 | 94.2% | 100.0% | 43 | | `updates_sync_network` | 188 | 189 | 99.5% | 100.0% | 67 | ## By priority | Priority | Covered | Required | % | Accounted % | |---|---:|---:|---:|---:| -| 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% | +| P0 | 161 | 178 | 90.4% | 100.0% | +| P1 | 323 | 379 | 85.2% | 100.0% | +| P2 | 459 | 610 | 75.2% | 100.0% | +| P3 | 447 | 630 | 71.0% | 100.0% | ## Partial coverage @@ -86,7 +86,7 @@ uncovered: 512 (512 waived with a PR number) | `richmsg.send` | `message.send` | A layer-229 rich body is refused with NOT_SUPPORTED: the pinned Telethon speaks layer 227 and cannot serialise inputRichMessage*. | | `richmsg.tasks` | `message.edit` | Checklist tasks live in a layer-229 rich body; --toggle-task is refused. | | `richmsg.translate` | `message.translate` | Rich-body translation is layer 229 and refused with NOT_SUPPORTED. | -| `stories.live-join` | `vc.download` | watching a live story as a viewer is owned by `story live get` | +| `stories.live-join` | `story.live.get` | The live story is reported; its group call is not reachable from layer 227's storyItem, and joining a broadcast needs a media engine tlgr does not have. | ## Gaps in a migrated domain @@ -104,6 +104,7 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `dialogs.notify-exceptions` | P1 | List notification exceptions | waived until PR-12: The exceptions *list* is `notify exceptions` (PR-12); one chat's exception is `chat notify`. | | `profile.photos-list-history` | P1 | View own / another user's profile photo history | waived until PR-12: Profile photo history is the `profile` group (PR-12). | | `stars.balance` | P1 | Telegram Stars balance | waived until PR-12: the Star balance and top-up packages are the `stars` surface (PR-12). | +| `stories.notify-peer` | P1 | Per-peer story notifications | waived until PR-12: Per-peer story notifications are `notify set --stories` (PR-12). | | `attach.menu-bots` | P2 | Attachment-menu / side-menu mini-app bots: list, info, add, remove | waived until PR-10: Attachment-menu bots are the `bot` group (PR-10). | | `auth.url-auth-bot-button` | P2 | Log in to a website via a bot's login button (Seamless Telegram Login) | waived until PR-10: Seamless Telegram Login is a bot keyboard button (messages.requestUrlAuth / acceptUrlAuth); it lands with the bots group in PR-10. | | `contacts-users.privacy-about` | P2 | Privacy: bio | waived until PR-12: Privacy keys are the `privacy` group (PR-12). | @@ -111,7 +112,6 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `contacts-users.privacy-exception-lists` | P2 | Always/Never allow exception lists | waived until PR-12: Always/Never lists are privacy rules (PR-12); the close-friends list is `contact close-friends`. | | `contacts-users.privacy-forwards` | P2 | Privacy: forwarded messages link back to me | waived until PR-12: Privacy keys are the `privacy` group (PR-12). | | `contacts-users.user-status-reveal` | P2 | Show My Last Seen to reveal theirs | waived until PR-12: Revealing my own last-seen to see theirs is a privacy setting (PR-12); `contact status list` reports the by_me flag that explains it. | -| `contacts-users.user-stories` | P2 | A user's stories on their profile | waived until PR-8: A user's stories are the `story` group (PR-8); hiding them is `user hide-stories`. | | `content.limits` | P2 | Server limits for polls, reactions, checklists and gifts | waived until PR-12: the app-config limit table is read through the settings surface (PR-12). | | `dialogs.business-bot-bar` | P2 | Manage connected business bot in a chat | waived until PR-12: The connected-business-bot bar is a business setting (PR-12). | | `dialogs.business-link-create` | P2 | Create a business 'link to chat' | waived until PR-12: Business chat links are a business setting (PR-12). | @@ -132,13 +132,15 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `giveaway.info` | P2 | Giveaway status / did I win? | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | | `giveaway.join-by-boosting` | P2 | Join a giveaway by boosting the channel | waived until PR-12: giveaways and channel boosts are the `giveaway`/`boost` surface (PR-12). | | `groups-channels-admin.channel-subscription-manage` | P2 | Manage my paid (Stars) channel subscriptions | waived until PR-12: My own paid subscriptions are `stars subscription *` (PR-12); the admin side is `chat invite list`. | -| `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.quick-reply-list` | P2 | Quick replies (business shortcuts): list shortcuts and their messages | waived until PR-12: Business quick-reply shortcuts are a business setting (PR-12); `message send --quick-reply` uses one. | | `messages-core.quick-reply-manage` | P2 | Create/edit/reorder/delete quick reply shortcuts and their messages | waived until PR-12: Business quick-reply shortcuts are a business setting (PR-12). | | `profile.contact-personal-photo` | P2 | Set a personal photo for a contact / suggest a photo to a contact | waived until PR-12: A personal photo for a contact is the `profile` group (PR-12). | | `profile.photo-set-as-main` | P2 | Set an older profile photo as main | waived until PR-12: Promoting an older photo is the `profile` group (PR-12). | | `profile.photo-set-video` | P2 | Animated profile photo (video avatar) | waived until PR-12: A video avatar is the `profile` group (PR-12). | | `profile.saved-music` | P2 | Music on profile (save songs to profile, list, reorder) | waived until PR-12: Music on a profile is the `profile` group (PR-12). | +| `stories.boost-status` | P2 | Boost level needed to post channel stories | waived until PR-7: Boost levels are the `boost` group (PR-7); `story can-post` reports the gate. | +| `stories.notify-global` | P2 | Global story notification settings | waived until PR-12: Global story notification settings are `notify set` (PR-12). | +| `stories.notify-reactions` | P2 | Notifications for reactions to my stories | waived until PR-12: Notifications for reactions to my stories are `notify set` (PR-12). | | `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). | @@ -183,17 +185,16 @@ Domains no PR has reached yet are waived wholesale and not listed here. These ar | `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). | -| `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). | | `profile.photo-fallback-public` | P3 | Public (fallback) profile photo for users who cannot see the main one | waived until PR-12: The public fallback photo is the `profile` group (PR-12). | | `profile.photo-set-emoji-sticker` | P3 | Profile photo from sticker / custom emoji on a colour background | waived until PR-12: An emoji avatar is the `profile` group (PR-12). | -| `reaction.story-list` | P3 | Who reacted to my story | waived until PR-8: story reactions are the `story` surface (PR-8). | | `ringtone.manage` | P3 | Custom notification sounds: list, upload, save from voice/audio, remove | waived until PR-12: Custom notification sounds are the `notify` group (PR-12). | | `ringtone.set-for-chat` | P3 | Set notification sound for a chat or chat category | waived until PR-12: Per-chat notification sounds are the `notify` group (PR-12). | | `stars.topup-options` | P3 | Star purchase packages | waived until PR-12: the Star balance and top-up packages are the `stars` surface (PR-12). | +| `stories.business-story` | P3 | Bot posting stories for a business account | waived until PR-10: Posting for a business account goes through a bot connection (PR-10). | +| `stories.notify-exceptions` | P3 | List peers with custom story notification settings | waived until PR-12: Per-peer notification exceptions are the `notify` group (PR-12). | +| `stories.story-music-save` | P3 | Save a story's soundtrack (Add to Profile / Saved Messages) | waived until PR-12: Saving a story's soundtrack to the profile is the profile group (PR-12). | | `theme.cloud-themes` | P3 | Cloud themes (list, install, create, update, upload theme file) | waived until PR-12: Cloud themes are the `settings` group (PR-12). | | `updates.invoke-business-connection` | P3 | Act on behalf of a connected business account | waived until PR-12: Acting on behalf of a connected business account is the business surface (PR-12). | diff --git a/docs/reference/README.md b/docs/reference/README.md index bca98b8..36b9b74 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -2,7 +2,7 @@ # Command reference -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. +509 operations across 34 groups, generated from the operation registry. Groups still served by v1's hand-written commands are not listed here; they arrive with their own PR. | Group | Operations | Reference | |---|---:|---| @@ -34,9 +34,10 @@ | `resolve` | 5 | [resolve.md](resolve.md) | | `search` | 3 | [search.md](search.md) | | `sticker` | 20 | [sticker.md](sticker.md) | +| `story` | 31 | [story.md](story.md) | | `sync` | 5 | [sync.md](sync.md) | | `todo` | 5 | [todo.md](todo.md) | -| `user` | 13 | [user.md](user.md) | +| `user` | 12 | [user.md](user.md) | | `vc` | 23 | [vc.md](vc.md) | | `webhook` | 3 | [webhook.md](webhook.md) | diff --git a/docs/reference/story.md b/docs/reference/story.md new file mode 100644 index 0000000..5cd870a --- /dev/null +++ b/docs/reference/story.md @@ -0,0 +1,1068 @@ + + +# `tlgr story` + +31 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 | +|---|---| +| [`story album create`](#tlgr-story-album-create) | Create a story album | +| [`story album delete`](#tlgr-story-album-delete) | Delete an album (the stories stay) | +| [`story album edit`](#tlgr-story-album-edit) | Rename an album, add/remove stories, or reorder the stories inside it | +| [`story album list`](#tlgr-story-album-list) | List the story albums on a profile | +| [`story album reorder`](#tlgr-story-album-reorder) | Reorder the album chips on the profile | +| [`story blocklist list`](#tlgr-story-blocklist-list) | List the users who never see your stories | +| [`story blocklist set`](#tlgr-story-blocklist-set) | Add to, remove from or replace the story blocklist | +| [`story can-post`](#tlgr-story-can-post) | Free story slots, limits, Premium gates and the chats you may post to | +| [`story delete`](#tlgr-story-delete) | Delete stories permanently | +| [`story edit`](#tlgr-story-edit) | Edit a posted story: caption, audience, media, cover frame or areas | +| [`story export`](#tlgr-story-export) | Bulk-export stories with their media to disk | +| [`story feed list`](#tlgr-story-feed-list) | List peers that have active stories (the stories bar) | +| [`story get`](#tlgr-story-get) | Fetch stories in full (media, caption, areas, privacy, link) | +| [`story hide`](#tlgr-story-hide) | Hide a peer's stories, or hide the whole stories bar | +| [`story list`](#tlgr-story-list) | List a peer's stories: active, profile page, archive or one album | +| [`story live get`](#tlgr-story-live-get) | Info about a peer's live story | +| [`story live start`](#tlgr-story-live-start) | Start a live story (optionally RTMP, so an external encoder supplies the video) | +| [`story pin`](#tlgr-story-pin) | Keep stories on the profile page, or pin them to the top | +| [`story post`](#tlgr-story-post) | Post one or more stories, with audience, media areas and duration | +| [`story react`](#tlgr-story-react) | React to a story, or remove your reaction | +| [`story read`](#tlgr-story-read) | Mark a peer's stories as seen (clears the unread ring) | +| [`story reply`](#tlgr-story-reply) | Reply privately to a story (text, media, voice or sticker) | +| [`story report`](#tlgr-story-report) | Report a story | +| [`story search`](#tlgr-story-search) | Search public stories by hashtag or location | +| [`story share`](#tlgr-story-share) | Share a story into chats as a story card | +| [`story stats get`](#tlgr-story-stats-get) | Story statistics: view/reaction graphs and public reposts | +| [`story stealth set`](#tlgr-story-stealth-set) | Stealth mode: erase recent views and/or hide the next ones | +| [`story unhide`](#tlgr-story-unhide) | Put a peer's stories back in the main bar | +| [`story unpin`](#tlgr-story-unpin) | Move stories off the profile page, or clear the pinned-to-top set | +| [`story viewer list`](#tlgr-story-viewer-list) | Who saw a story, with their reactions | +| [`story watch`](#tlgr-story-watch) | Stream story events (new stories, reads, reactions, stealth changes) | + +### `story album create` + +Create a story album. + +``` +tlgr story album create [OPTIONS] +``` + +**mutating · returns `StoryAlbum`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose profile. | +| `TITLE` | text | yes | Album title (1-12 characters). | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--story` | int | | Story to put in it. Repeatable. | + +```console +$ tlgr story album create me Trips --story 42 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.album-create` + +</details> + +### `story album delete` + +Delete an album (the stories stay). + +``` +tlgr story album delete <CHAT> <ALBUM_ID> [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `AlbumDeleted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose profile. | +| `ALBUM_ID` | int | yes | Album id. | + +```console +$ tlgr story album delete me 7 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.album-delete` + +</details> + +### `story album edit` + +Rename an album, add/remove stories, or reorder the stories inside it. + +``` +tlgr story album edit <CHAT> <ALBUM_ID> [OPTIONS] +``` + +**mutating · returns `StoryAlbum`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose profile. | +| `ALBUM_ID` | int | yes | Album id. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--add` | int | | Story to add. Repeatable. | +| `--order` | int | | Full story order inside the album. | +| `--remove` | int | | Story to remove. Repeatable. | +| `--title` | text | | New album title (1-12 chars). | + +```console +$ tlgr story album edit me 7 --title 'Trips 2026' --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `stories.album-add-stories`, `stories.album-remove-stories`, `stories.album-rename`, `stories.album-reorder-stories` + +</details> + +### `story album list` + +List the story albums on a profile. + +Open one with `story list PEER --album ID`. + +``` +tlgr story album list <CHAT> [OPTIONS] +``` + +**paginated (`LOCAL` cursor) · returns `Page[StoryAlbum]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose profile. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--hash` | int | | Cache hash; unchanged answers `already`. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr story album list me --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.album-list` + +</details> + +### `story album reorder` + +Reorder the album chips on the profile. + +``` +tlgr story album reorder <CHAT> [ALBUM_ID]... [OPTIONS] +``` + +**mutating · returns `AlbumOrder`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose profile. | +| `ALBUM_ID` | int | one or more | Albums, in the new order. | + +```console +$ tlgr story album reorder me 8 7 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.album-reorder` + +</details> + +### `story blocklist list` + +List the users who never see your stories. + +A second, independent blocklist; `user block` stays the global one. + +``` +tlgr story blocklist list [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[BlockedStoryUser]`** + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr story blocklist list --json +``` + +<details><summary>Catalog coverage (0 full, 1 partial)</summary> + +Partial: `stories.blocklist` + +`story blocklist set` owns the writing half of the list. + +</details> + +### `story blocklist set` + +Add to, remove from or replace the story blocklist. + +``` +tlgr story blocklist set [USER]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `BlocklistChange`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `USER` | user | one or more | Users. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--remove` | flag | | Remove them from the list instead. | +| `--replace` | flag | | Replace the whole list with exactly these users. | + +```console +$ tlgr story blocklist set @alice --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `dialogs.block-stories`, `stories.blocklist` + +</details> + +### `story can-post` + +Free story slots, limits, Premium gates and the chats you may post to. + +Re-run it immediately before posting; the quotas move under you. + +``` +tlgr story can-post [OPTIONS] +``` + +**returns `StoryPostCheck`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--chats` | flag | | Also list every chat where you hold post_stories. | +| `--limits/--no-limits` | flag | `True` | Include the limit block. | +| `--send-as` | chat | | Check this channel instead of you. | + +```console +$ tlgr story can-post --chats --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `stories.can-post`, `stories.chats-to-post`, `stories.limits-config`, `stories.premium-gates` + +</details> + +### `story delete` + +Delete stories permanently. + +Channel stories need the `delete_stories` admin right. + +``` +tlgr story delete <CHAT> [ID]... [OPTIONS] +``` + +**mutating · destructive (needs `--yes` off a TTY) · returns `StoriesDeleted`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose stories. | +| `ID` | text | one or more | Story ids, or `10-14` ranges. | + +```console +$ tlgr story delete me 42 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.delete` + +</details> + +### `story edit` + +Edit a posted story: caption, audience, media, cover frame or areas. + +Privacy edits are user stories only — a channel story has no rule vector. `--cover-ts` without `--file` re-sends the current document rather than uploading it again. + +``` +tlgr story edit <CHAT> <ID> [OPTIONS] +``` + +**mutating · returns `Story`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose story. | +| `ID` | int | yes | Story id. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--allow` | text | | Add to the allow list. Repeatable. | +| `--area-geo` | text | | Location pill. Repeatable. | +| `--area-gift` | text | | Collectible star-gift area. | +| `--area-post` | text | | Channel-post card. | +| `--area-reaction` | text | | Suggested-reaction bubble. | +| `--area-url` | text | | Link sticker (Premium). | +| `--area-venue` | text | | Venue pill (inline query). | +| `--area-venue-near` | text | | Anchor point for the venue query. | +| `--area-venue-pick` | int | | Which venue result to use. | +| `--area-weather` | text | | Weather widget; `auto` resolves it. | +| `--areas` | text | | Media areas as the JSON `--areas-out` writes. | +| `--caption` | text | | New caption. | +| `--cover-ts` | number | | New cover frame, without re-uploading. | +| `--entities` | json | | Explicit entities. | +| `--exclude` | text | | Add to the deny list. Repeatable. | +| `--file` | path | | Replace the media. | +| `--music` | path | | Replace the soundtrack. | +| `--parse` | md|html|none | | Caption formatting. | +| `--privacy` | everyone|contacts|close-friends|selected | | Audience base rule. | +| `--privacy-preset` | text | | Reuse [story.privacy_presets].<name>. | + +```console +$ tlgr story edit me 42 --caption 'still morning' --json +``` + +<details><summary>Catalog coverage (5 full, 0 partial)</summary> + +Full: `stories.edit-areas`, `stories.edit-caption`, `stories.edit-cover`, `stories.edit-media`, `stories.edit-privacy` + +</details> + +### `story export` + +Bulk-export stories with their media to disk. + +``` +tlgr story export [CHAT] [OPTIONS] +``` + +**returns `StoryExport`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | Whose stories. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--archive/--profile` | flag | `True` | Walk the private archive, not the profile page. | +| `--jsonl` | flag | | Also write one JSON object per story. | +| `--max-stories` | int | `1000` | Stop after this many stories. | +| `--out` | path | `.` | Output directory. | +| `--with-media/--no-media` | flag | `True` | Download each story's photo or video. | + +```console +$ tlgr story export me --out ./stories --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.export-stories` + +</details> + +### `story feed list` + +List peers that have active stories (the stories bar). + +Main and hidden feeds keep independent states. `--refresh` re-sends the stored state and reports `already: true` when nothing changed. + +``` +tlgr story feed list [OPTIONS] +``` + +**paginated (`DIALOGS` cursor) · returns `Page[StoryFeedPeer]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--hidden` | flag | | The archived stories bar instead. | +| `--peers` | chat | | Only the compact max-id summary. | +| `--read-state` | flag | | Emit the login-time read-state bootstrap instead. | +| `--refresh` | flag | | Re-send the stored state with no `next`. | +| `--state-file` | path | | Where the feed state is kept. | +| `--unread-only` | flag | | Keep only unread peers. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr story feed list --unread-only --json +``` + +<details><summary>Catalog coverage (6 full, 0 partial)</summary> + +Full: `stories.changelog-stories`, `stories.feed-all`, `stories.feed-hidden`, `stories.feed-refresh-state`, `stories.peer-max-ids`, `stories.read-state-bootstrap` + +</details> + +### `story get` + +Fetch stories in full (media, caption, areas, privacy, link). + +`privacy` is only populated on your own stories. A gone story comes back with `deleted: true` rather than as an error. + +``` +tlgr story get <CHAT> [ID]... [OPTIONS] +``` + +**returns `Page[Story]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose story; a story link works too. | +| `ID` | text | any number | Story ids. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--album-link` | int | | Build the album deep link instead. | +| `--areas-out` | path | | Write media_areas as JSON. | +| `--link` | flag | | Only export the t.me story link. | +| `--translate` | text | | Translate the caption. | +| `--views` | flag | | Also fetch fresh view counters. | + +```console +$ tlgr story get @alice 42 --views --json +``` + +<details><summary>Catalog coverage (11 full, 0 partial)</summary> + +Full: `stories.album-link`, `stories.caption-entities`, `stories.get-by-id`, `stories.link-export`, `stories.link-resolve`, `stories.media-areas-inspect`, `stories.privacy-inspect`, `stories.repost-origin`, `stories.skipped-hydrate`, `stories.translate-caption`, `stories.viewers-counters` + +</details> + +### `story hide` + +Hide a peer's stories, or hide the whole stories bar. + +v1 spelled this `tlgr user hide-stories`, and that path still works — including its `--unhide` flag, which is `story unhide` said the other way round. Idempotent: the fresh flag is read first and `already: true` means no RPC was sent, so repeating a bulk pass is nearly free. More than one peer fills `peers`; a single peer answers with exactly the four keys v1 printed. + +``` +tlgr story hide [CHAT]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `StoryHidden`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | one or more | Whose stories to hide. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | flag | | Collapse the whole stories bar. | +| `--unhide` | flag | | Put them back instead (v1's `user hide-stories --unhide`). | + +Also invocable as: `tlgr user hide-stories` + +```console +$ tlgr story hide @alice --json +``` + +<details><summary>Catalog coverage (3 full, 2 partial)</summary> + +Full: `contacts-users.user-hide-stories`, `dialogs.hide-stories-peer`, `groups-channels-admin.hide-peer-stories` + +Partial: `stories.hide-all`, `stories.hide-peer` + +`story unhide` owns the other half of both toggles. + +</details> + +### `story list` + +List a peer's stories: active, profile page, archive or one album. + +Four RPCs behind one list, because the GUI shows one grid with tabs. `--archive` on a channel needs the `edit_stories` admin right. + +``` +tlgr story list <CHAT> [OPTIONS] +``` + +**paginated (`HISTORY` cursor) · returns `Page[Story]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose stories. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--album` | int | | Only this album. | +| `--archive` | flag | | The private archive, expired included. | +| `--hydrate/--no-hydrate` | flag | `True` | Resolve skipped placeholders. | +| `--offset-id` | int | | Page from this story id downwards. | +| `--profile` | flag | | The stories kept on the profile page. | +| `--translate` | text | | Also translate the captions. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr story list @alice --json +``` + +<details><summary>Catalog coverage (6 full, 0 partial)</summary> + +Full: `contacts-users.user-stories`, `stories.album-stories`, `stories.channel-archive`, `stories.own-archive`, `stories.peer-active`, `stories.profile-stories` + +</details> + +### `story live get` + +Info about a peer's live story. + +Layer 227 exposes the live story itself but not its group call, so the call-side fields stay null and a warning says why. + +``` +tlgr story live get [CHAT] [OPTIONS] +``` + +**returns `LiveStory`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | Whose live story. | + +```console +$ tlgr story live get @alice --json +``` + +<details><summary>Catalog coverage (0 full, 2 partial)</summary> + +Partial: `livestory.streamer-info`, `stories.live-join` + +The live story is reported; its group call is not reachable from layer 227's storyItem, and joining a broadcast needs a media engine tlgr does not have. + +</details> + +### `story live start` + +Start a live story (optionally RTMP, so an external encoder supplies the video). + +One active live story per peer. Setting a comment price spends nothing; end the stream with the call commands. + +``` +tlgr story live start [CHAT] [OPTIONS] +``` + +**mutating · returns `LiveStory`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | Post as this channel. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--allow` | text | | Add to the allow list. Repeatable. | +| `--caption` | text | | Live story caption. | +| `--comment-price` | int | | Minimum Stars to comment (0 = free). | +| `--comments` | on|off | | In-call comment overlay. | +| `--exclude` | text | | Add to the deny list. Repeatable. | +| `--parse` | md|html|none | | Caption formatting. | +| `--pin` | flag | | Keep the recording on the profile page. | +| `--privacy` | everyone|contacts|close-friends|selected | | Audience base rule. | +| `--privacy-preset` | text | | Reuse [story.privacy_presets].<name>. | +| `--protect` | flag | | noforwards. | +| `--rtmp` | flag | | RTMP mode: an external encoder supplies video. | + +```console +$ tlgr story live start --rtmp --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `livestory.start-rtmp`, `stories.live-start` + +</details> + +### `story pin` + +Keep stories on the profile page, or pin them to the top. + +``` +tlgr story pin <CHAT> [ID]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `StoryPinned`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose stories. | +| `ID` | text | any number | Story ids. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--top` | flag | | Pin to the top of the profile grid instead. | + +```console +$ tlgr story pin me 42 --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `stories.pin-to-top` + +Partial: `stories.pin-to-profile` + +`story unpin` owns the other half of the profile-page toggle. + +</details> + +### `story post` + +Post one or more stories, with audience, media areas and duration. + +Several FILEs post several stories in one run, sharing the audience, period and pin settings. Vertical media only; overlays other than media areas must already be rendered into the file. + +``` +tlgr story post [FILE]... [OPTIONS] +``` + +**mutating · returns `Page[Story]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `FILE` | path | one or more | Media to post, one per story. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--album` | int | | Add to this album id. | +| `--allow` | text | | Add to the allow list. Repeatable. | +| `--area-geo` | text | | Location pill. Repeatable. | +| `--area-gift` | text | | Collectible star-gift area. | +| `--area-post` | text | | Channel-post card. | +| `--area-reaction` | text | | Suggested-reaction bubble. | +| `--area-url` | text | | Link sticker (Premium). | +| `--area-venue` | text | | Venue pill (inline query). | +| `--area-venue-near` | text | | Anchor point for the venue query. | +| `--area-venue-pick` | int | | Which venue result to use. | +| `--area-weather` | text | | Weather widget; `auto` resolves it. | +| `--areas` | text | | Media areas as the JSON `--areas-out` writes. | +| `--as-message` | flag | | Send the media to chats as ordinary messages instead. | +| `--caption` | text | | Story caption. | +| `--cover-ts` | number | | Video cover frame. | +| `--entities` | json | | Explicit entities. | +| `--exclude` | text | | Add to the deny list. Repeatable. | +| `--modified` | flag | | Mark the repost as edited. | +| `--music` | path | | Attach a soundtrack. | +| `--no-check` | flag | | Skip the canSendStory pre-flight. | +| `--parse` | md|html|none | | Caption formatting. | +| `--period` | 6h|12h|24h|48h | | How long it stays active. | +| `--pin` | flag | | Keep on my page when it expires. | +| `--privacy` | everyone|contacts|close-friends|selected | | Audience base rule. | +| `--privacy-preset` | text | | Reuse [story.privacy_presets].<name>. | +| `--protect` | flag | | noforwards: block saving/forwarding. | +| `--repost` | text | | Repost somebody else's story. | +| `--repost-message` | text | | 'Repost to story' from a message. | +| `--send-as` | chat | | Post as this channel. | +| `--sticker-doc` | int | | Declare a sticker baked into the media. | +| `--until` | chat | | Destinations for --as-message. | + +```console +$ tlgr story post morning.jpg --caption 'morning' --privacy contacts --json +``` + +<details><summary>Catalog coverage (29 full, 0 partial)</summary> + +Full: `bots.webapp-share-to-story`, `groups-channels-admin.stories-as-channel`, `stories.area-channel-post`, `stories.area-location`, `stories.area-star-gift`, `stories.area-suggested-reaction`, `stories.area-url`, `stories.area-venue`, `stories.area-weather`, `stories.attached-stickers`, `stories.mention-users`, `stories.post-as-channel`, `stories.post-batch`, `stories.post-keep-on-page`, `stories.post-music`, `stories.post-period`, `stories.post-photo`, `stories.post-protect`, `stories.post-stickers-drawing`, `stories.post-to-album`, `stories.post-video`, `stories.privacy-auto-exceptions`, `stories.privacy-close-friends`, `stories.privacy-contacts`, `stories.privacy-everyone`, `stories.privacy-selected`, `stories.repost`, `stories.repost-message-to-story`, `stories.send-as-message-instead` + +</details> + +### `story react` + +React to a story, or remove your reaction. + +Paid (Star) reactions do not exist on stories. + +``` +tlgr story react <CHAT> <ID> [EMOJI] [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `StoryReactionResult`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose story. | +| `ID` | int | yes | Story id. | +| `EMOJI` | text | no | The reaction to send. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--as-message` | flag | | Send the emoji as an ordinary story reply instead. | +| `--custom-emoji` | int | | Custom-emoji document id. | +| `--recent/--no-recent` | flag | `True` | Add it to the recent-reactions list. | +| `--remove` | flag | | Clear the reaction. | + +```console +$ tlgr story react @alice 42 🔥 --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `stories.react`, `stories.reaction-as-message`, `stories.unreact` + +</details> + +### `story read` + +Mark a peer's stories as seen (clears the unread ring). + +This does NOT make you appear in the poster's viewer list; `--register-view` does, and only for the ids you name. + +``` +tlgr story read <CHAT> [ID]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `StoryRead`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose stories. | +| `ID` | text | any number | Story ids. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--max-id` | int | | Mark everything up to this id. | +| `--register-view` | flag | | Also appear in the poster's viewer list. | + +Also invocable as: `tlgr story view` + +```console +$ tlgr story read @alice --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `stories.increment-views`, `stories.mark-read` + +</details> + +### `story reply` + +Reply privately to a story (text, media, voice or sticker). + +A reply is an ordinary private message carrying `InputReplyToStory`, so the peer's message restrictions — Premium-only, paid messages, channel story replies locked — apply exactly as they do to a DM. + +``` +tlgr story reply <CHAT> <ID> [TEXT] [OPTIONS] +``` + +**mutating · returns `StoryReply`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose story. | +| `ID` | int | yes | Story id. | +| `TEXT` | text | no | Reply body; '-' reads stdin. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--entities` | json | | Explicit entities. | +| `--file` | path | | Attach a file. Repeatable. | +| `--paid-stars` | int | | Agree to the peer's message price. | +| `--parse` | md|html|none | | Text formatting. | +| `--schedule` | text | | Schedule the reply. | +| `--silent` | flag | | Send without a notification. | +| `--sticker` | text | | Send a sticker document id. | +| `--voice` | flag | | Send the file as a voice note. | + +```console +$ tlgr story reply @alice 42 'nice one' --json +``` + +<details><summary>Catalog coverage (3 full, 0 partial)</summary> + +Full: `stories.reply`, `stories.reply-media`, `stories.reply-restrictions` + +</details> + +### `story report` + +Report a story. + +``` +tlgr story report <CHAT> [ID]... [OPTIONS] +``` + +**mutating · returns `StoryReport`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose story. | +| `ID` | text | one or more | Story ids. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--message` | text | | Free-text comment, when asked for. | +| `--option` | text | | Opaque option bytes from the last step. | + +```console +$ tlgr story report @alice 42 --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.report` + +</details> + +### `story search` + +Search public stories by hashtag or location. + +``` +tlgr story search [OPTIONS] +``` + +**paginated (`SEARCH` cursor) · returns `Page[Story]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--address` | text | | Address attached to --geo. | +| `--geo` | text | | Search by geo area (needs --address). | +| `--hashtag` | text | | Hashtag or cashtag, without the #. | +| `--peer` | chat | | Only this poster. | +| `--venue` | text | | Search by venue area. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr story search --hashtag berlin --json +``` + +<details><summary>Catalog coverage (4 full, 0 partial)</summary> + +Full: `messages-core.search-hashtag-stories`, `stories.search-hashtag`, `stories.search-location`, `stories.search-peer-scoped` + +</details> + +### `story share` + +Share a story into chats as a story card. + +``` +tlgr story share <CHAT> <ID> [OPTIONS] +``` + +**mutating · returns `StoryShared`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose story. | +| `ID` | int | yes | Story id. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--silent` | flag | | Send without a notification. | +| `--text` | text | | Caption to send with the card. | +| `--topic` | msg-id | | Forum topic id. | +| `--until`, `--to` | chat | | Destination chat. Repeatable. | + +Also invocable as: `tlgr story forward` + +```console +$ tlgr story share @alice 42 --until @bobby --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.share-to-chat` + +</details> + +### `story stats get` + +Story statistics: view/reaction graphs and public reposts. + +Needs `can_view_stats` on the channel, or your own story. + +``` +tlgr story stats get <CHAT> <ID> [OPTIONS] +``` + +**returns `StoryStats`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose story. | +| `ID` | int | yes | Story id. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--dark` | flag | | Ask for the dark-theme graph variant. | +| `--forwards` | flag | | List the public reposts instead of the graphs. | +| `--raw` | flag | | Emit the raw StatsGraph JSON. | + +```console +$ tlgr story stats get me 42 --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `stories.public-forwards`, `stories.stats` + +</details> + +### `story stealth set` + +Stealth mode: erase recent views and/or hide the next ones. + +`--status` reads the state out of the feed reply, which is also where `story feed list` gets it from. + +``` +tlgr story stealth set [OPTIONS] +``` + +**mutating · returns `StealthMode`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--future` | flag | | Hide your views for the next window. | +| `--past` | flag | | Erase your views from the recent window. | +| `--status` | flag | | Only report the state and do nothing. | + +```console +$ tlgr story stealth set --past --future --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `stories.stealth-activate`, `stories.stealth-status` + +</details> + +### `story unhide` + +Put a peer's stories back in the main bar. + +``` +tlgr story unhide [CHAT]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `StoryHidden`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | one or more | Whose stories to hide. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--all` | flag | | Collapse the whole stories bar. | +| `--unhide` | flag | | Put them back instead (v1's `user hide-stories --unhide`). | + +```console +$ tlgr story unhide @alice --json +``` + +<details><summary>Catalog coverage (2 full, 0 partial)</summary> + +Full: `stories.hide-all`, `stories.hide-peer` + +</details> + +### `story unpin` + +Move stories off the profile page, or clear the pinned-to-top set. + +``` +tlgr story unpin <CHAT> [ID]... [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `StoryPinned`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose stories. | +| `ID` | text | any number | Story ids. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--top` | flag | | Pin to the top of the profile grid instead. | + +```console +$ tlgr story unpin me 42 --json +``` + +<details><summary>Catalog coverage (1 full, 1 partial)</summary> + +Full: `stories.pin-to-profile` + +Partial: `stories.pin-to-top` + +`story pin` owns the other half of the pinned-to-top set. + +</details> + +### `story viewer list` + +Who saw a story, with their reactions. + +A non-Premium account loses the list `story_viewers_expire_period` seconds after the story expires; `views.has_viewers` on the story says whether it is still available. + +``` +tlgr story viewer list <CHAT> <ID> [OPTIONS] +``` + +**paginated (`PARTICIPANTS` cursor) · returns `Page[StoryViewer]`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | yes | Whose story. | +| `ID` | int | yes | Story id. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--contacts` | flag | | Contacts only. | +| `--csv` | path | | Also write the rows as CSV. | +| `--forwards-first` | flag | | Sort reposts and forwards first. | +| `--hide-from` | user | | Add a viewer to the blocklist. | +| `--q` | text | | Server-side name search. | +| `--reaction` | text | | Channel stories: only this reaction. | +| `--reactions-first` | flag | | Sort viewers who reacted first. | + +Pagination is transport-level: `--limit/-n`, `--cursor TOKEN`, `--all` (walked inside the daemon, paced by the account's own rate limiter). + +```console +$ tlgr story viewer list me 42 --json +``` + +<details><summary>Catalog coverage (7 full, 0 partial)</summary> + +Full: `reaction.story-list`, `stories.channel-story-interactions`, `stories.viewer-block`, `stories.viewers-export`, `stories.viewers-filters`, `stories.viewers-list`, `stories.viewers-search` + +</details> + +### `story watch` + +Stream story events (new stories, reads, reactions, stealth changes). + +Event kinds: story.new, story.id-assigned, story.read, story.reaction-received, story.reaction-sent, story.stealth. + +``` +tlgr story watch [OPTIONS] +``` + +**returns `Page[StoryEvent]`** + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--peer` | chat | | Only events for these peers. | +| `--since` | datetime | | Replay from this point. | + +```console +$ tlgr story watch --peer @alice --json +``` + +<details><summary>Catalog coverage (1 full, 0 partial)</summary> + +Full: `stories.new-story-events` + +</details> diff --git a/docs/reference/user.md b/docs/reference/user.md index 0959497..d14fe81 100644 --- a/docs/reference/user.md +++ b/docs/reference/user.md @@ -2,7 +2,7 @@ # `tlgr user` -13 operations. Every one takes the global flags (`--json`, `--plain`, `-a/--account`, `--results-only`, `--select`, `--dry-run`, `--yes`, `--no-input`, `--flood-wait-max`, `-v`) anywhere on the line. +12 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 | |---|---| @@ -12,7 +12,6 @@ | [`user chat list`](#tlgr-user-chat-list) | Groups and channels you share with a user | | [`user dialog-status`](#tlgr-user-dialog-status) | Does this account have prior history with this user? (three-valued, never guessed) | | [`user get`](#tlgr-user-get) | Full profile of a user | -| [`user hide-stories`](#tlgr-user-hide-stories) | Hide or unhide a peer's stories (per-account; the other side is never notified) | | [`user link`](#tlgr-user-link) | Build a link to a user (t.me / tg://), or my own temporary profile link | | [`user music list`](#tlgr-user-music-list) | Music a user pinned to their profile | | [`user personal-channel get`](#tlgr-user-personal-channel-get) | The channel a user pinned to their profile, with its latest posts | @@ -212,37 +211,6 @@ Full: `contacts-users.block-status`, `contacts-users.resolve-min-users`, `contac </details> -### `user hide-stories` - -Hide or unhide a peer's stories (per-account; the other side is never notified). - -Idempotent: the fresh flag is read first and `already: true` means no RPC was sent, so repeating a bulk pass is nearly free. Purely local to this account — the chat, the contact entry and their access to you are untouched. `user get` reports the current value as `stories_hidden`. More than one peer fills `peers`; a single peer answers exactly as v1 did. - -``` -tlgr user hide-stories [USER]... [OPTIONS] -``` - -**mutating · idempotent (reports `already`) · returns `StoriesHidden`** - -| Argument | Type | Required | Meaning | -|---|---|---|---| -| `USER` | user | one or more | Peers to hide. | - -| Flag | Type | Default | Meaning | -|---|---|---|---| -| `--all` | text | | Collapse or expand the whole story strip. | -| `--unhide` | flag | | Put them back in the main stories bar. | - -```console -$ tlgr user hide-stories @alice --json -``` - -<details><summary>Catalog coverage (2 full, 0 partial)</summary> - -Full: `contacts-users.user-hide-stories`, `dialogs.hide-stories-peer` - -</details> - ### `user link` Build a link to a user (t.me / tg://), or my own temporary profile link. diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 007c778..2da8e82 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -20,6 +20,13 @@ followed by `message list` shows the message, because the fake really stored it. +Stage E adds the story world: real `types.StoryItem`s per peer, a profile +page and an archive that `story pin`/`story unpin` really move ids between, +albums, viewer rows, the opaque feed state `stories.getAllStories` pages on, +and the stealth-mode object. A story posted through `story post` is therefore +findable with `story list`, and `story hide` really flips the `stories_hidden` +flag the next `get_entity` reports. + Stage D adds the dialog world: real `types.Dialog` rows with unread counters, notification settings, pin and archive state, plus the chat folders (`dialogFilter`) the folder group rewrites. `chat archive` therefore *moves* @@ -34,7 +41,7 @@ import asyncio import base64 from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -56,6 +63,7 @@ "make_photo", "make_sticker_document", "make_sticker_set", + "make_story", "make_user", "make_wallpaper", "make_web_authorization", @@ -214,6 +222,43 @@ def make_photo(photo_id: int = 900, *, dc_id: int = 2) -> types.Photo: ) +def make_story( + story_id: int, + *, + caption: str = "", + media: Any = None, + date: datetime | None = None, + expire: datetime | None = None, + public: bool = True, + pinned: bool = False, + close_friends: bool = False, + noforwards: bool = False, + out: bool = True, + privacy: list[Any] | None = None, + media_areas: list[Any] | None = None, + views: Any = None, + albums: list[int] | None = None, +) -> types.StoryItem: + """A real `types.StoryItem`, so a serialiser meets the shape it will meet.""" + now = date or datetime.now(timezone.utc) + return types.StoryItem( + id=story_id, + date=now, + expire_date=expire or now, + media=media or types.MessageMediaPhoto(photo=make_photo(900 + story_id)), + caption=caption or None, + pinned=pinned or None, + public=public or None, + close_friends=close_friends or None, + noforwards=noforwards or None, + out=out or None, + privacy=privacy, + media_areas=media_areas, + views=views, + albums=albums, + ) + + def make_sticker_set( short_name: str, *, @@ -535,7 +580,6 @@ class World: search_global: list[int] = field(default_factory=list) sponsored_peers: list[int] = field(default_factory=list) #: The story read marks `stories.getAllReadPeerStories` reports. - stories_read: dict[int, int] = field(default_factory=dict) #: `contacts.exportContactToken`. contact_token: str = "AbCdEfToken" #: `help.getDeepLinkInfo` for an unknown tg:// path. @@ -607,8 +651,46 @@ class World: auto_save: dict[str, Any] = field(default_factory=dict) #: profile photo bytes, per marked chat id. profile_photos: dict[int, bytes] = field(default_factory=dict) - #: story id → `types.MessageMediaDocument`-shaped media. - stories: dict[int, Any] = field(default_factory=dict) + # -- the story world --------------------------------------------------- + # + # Keyed by *marked* peer id throughout, like every other id the fake + # stores, so a story world assertion and a dialog world assertion talk + # about the same number. + + #: marked peer id → {story id: `types.StoryItem`}. + peer_stories: dict[int, dict[int, Any]] = field(default_factory=dict) + #: marked peer id → the ids kept on the profile page. + story_pinned: dict[int, set[int]] = field(default_factory=dict) + #: marked peer id → the pinned-to-top set, in order. + story_pinned_top: dict[int, list[int]] = field(default_factory=dict) + #: marked peer id → the ids in the private archive, newest last. + story_archive: dict[int, list[int]] = field(default_factory=dict) + #: marked peer id → {album id: {"title": str, "stories": [ids]}}. + story_albums: dict[int, dict[int, Any]] = field(default_factory=dict) + #: marked peer id → the album order shown on the profile. + story_album_order: dict[int, list[int]] = field(default_factory=dict) + #: (marked peer id, story id) → the rows the viewers screen shows. + story_viewers: dict[tuple[int, int], list[Any]] = field(default_factory=dict) + #: marked peer id → the highest story id this account has read. + story_read: dict[int, int] = field(default_factory=dict) + #: The peers whose stories are collapsed into the archive bar. + stories_hidden_peers: set[int] = field(default_factory=set) + all_stories_hidden: bool = False + #: The opaque state `stories.getAllStories` pages on. + story_feed_state: str = "feed-state-1" + story_feed_has_more: bool = False + #: `None` means "the fake answers a fresh AllStories"; set it to a state + #: string to make the server answer `storiesAllStoriesNotModified`. + story_feed_not_modified: str | None = None + stealth_mode: Any = None + #: What `stories.canSendStory` answers; a count means "yes". + can_send_story: Any = None + story_albums_hash: int = 4242 + #: `types.FoundStory` rows `stories.searchPosts` should return. + public_stories: list[Any] = field(default_factory=list) + #: marked peer id → the channels `stories.getChatsToSend` lists. + chats_to_send: list[Any] = field(default_factory=list) + next_story_id: int = 100 inline_results: list[Any] = field(default_factory=list) saved_gif_limit: int = 200 next_document_id: int = 7000 @@ -846,6 +928,65 @@ def find(self, chat_id: int, message_id: int) -> types.Message | None: # -- the dialog world -------------------------------------------------- + # -- the story world --------------------------------------------------- + + def add_story( + self, + peer_id: int, + story: Any = None, + *, + pinned: bool = False, + archived: bool = False, + album: int | None = None, + **fields: Any, + ) -> Any: + """Put a story on a peer, and on the shelves it belongs to. + + `pinned` and `archived` are the two *places* a story can also be, not + properties of the item: the profile page and the private archive are + separate RPCs, and a test that wants `story list --archive` to find + something has to say so. + """ + if story is None: + story_id = fields.pop("story_id", None) + if story_id is None: + self.next_story_id += 1 + story_id = self.next_story_id + story = make_story(int(story_id), **fields) + self.peer_stories.setdefault(peer_id, {})[story.id] = story + if pinned: + self.story_pinned.setdefault(peer_id, set()).add(story.id) + story.pinned = True + if archived: + self.story_archive.setdefault(peer_id, []).append(story.id) + if album is not None: + self.story_albums.setdefault(peer_id, {}).setdefault( + album, {"title": f"Album {album}", "stories": []} + )["stories"].append(story.id) + return story + + def stories_of(self, peer_id: int) -> dict[int, Any]: + return self.peer_stories.setdefault(peer_id, {}) + + def add_album(self, peer_id: int, album_id: int, title: str, stories: list[int]) -> Any: + self.story_albums.setdefault(peer_id, {})[album_id] = { + "title": title, + "stories": list(stories), + } + self.story_album_order.setdefault(peer_id, []).append(album_id) + return self.story_albums[peer_id][album_id] + + def add_story_viewer( + self, peer_id: int, story_id: int, user_id: int, *, reaction: str | None = None + ) -> Any: + row = types.StoryView( + user_id=user_id, + date=datetime.now(timezone.utc), + reaction=types.ReactionEmoji(emoticon=reaction) if reaction else None, + ) + self.story_viewers.setdefault((peer_id, story_id), []).append(row) + return row + def add_dialog(self, chat_id: int, **state: Any) -> DialogState: """Give a chat a dialog row. Anything unset is the server's default.""" row = DialogState(chat_id=chat_id, **state) @@ -1764,6 +1905,10 @@ def _raw_GetFileRequest(self, request: Any) -> Any: ) def _raw_SendReactionRequest(self, request: Any) -> types.Updates: + # `messages.sendReaction` and `stories.sendReaction` share a class + # name; the story one carries `story_id` and no `msg_id`. + if hasattr(request, "story_id"): + return self._story_reaction(request) chat_id = self._chat_id(request.peer) message = self.world.find(chat_id, int(request.msg_id)) if message is not None: @@ -2353,6 +2498,19 @@ def _call_log_search(self, request: Any) -> Any: ) def _raw_SearchPostsRequest(self, request: Any) -> Any: + # Two RPCs share this class name: `messages.searchPosts` (public + # messages) and `stories.searchPosts` (public stories). The fake + # dispatches by class name, so the TL namespace is what tells them + # apart — the requests have no field in common that would. + if type(request).__module__.rsplit(".", 1)[-1] == "stories": + rows = list(self.world.public_stories) + return types.stories.FoundStories( + count=len(rows), + stories=rows[: request.limit], + chats=list(self.world.chats.values()), + users=list(self.world.users.values()), + next_offset="page2" if len(rows) > request.limit else None, + ) return self._slice(list(self.world.public_posts)[: int(request.limit)], next_rate=7) def _raw_CheckSearchPostsFloodRequest(self, request: Any) -> Any: @@ -3575,29 +3733,6 @@ def _raw_GetThemeRequest(self, request: Any) -> Any: # -- stories ----------------------------------------------------------- - def _raw_TogglePeerStoriesHiddenRequest(self, request: Any) -> bool: - marked = abs(self._chat_id(request.peer)) - user = self.world.users.get(marked) - if user is not None: - user.stories_hidden = bool(request.hidden) - return True - - def _raw_ToggleAllStoriesHiddenRequest(self, request: Any) -> bool: - self.world.all_stories_hidden = bool(request.hidden) - return True - - def _raw_GetAllReadPeerStoriesRequest(self, request: Any) -> types.Updates: - return types.Updates( - updates=[ - types.UpdateReadStories(peer=_peer_for(uid), max_id=max_id) - for uid, max_id in self.world.stories_read.items() - ], - users=[], - chats=[], - date=datetime.now(timezone.utc), - seq=0, - ) - # -- the administration world ------------------------------------------ # # Written as state, not as canned replies: `chat member ban` really moves @@ -4283,6 +4418,10 @@ def _raw_GetBroadcastStatsRequest(self, request: Any) -> Any: ) def _raw_LoadAsyncGraphRequest(self, request: Any) -> Any: + # The token is the only thing that says which graph was asked for, so + # the story group's graph and the chat group's answer differently. + if getattr(request, "token", "") == "graph-token": + return types.StatsGraph(json=types.DataJSON(data='{"columns": ["reactions"]}')) return types.StatsGraph(json=types.DataJSON(data='{"columns": ["x"]}')) def _raw_GetMessagePublicForwardsRequest(self, request: Any) -> Any: @@ -4731,8 +4870,397 @@ def _raw_GetSponsoredMessagesRequest(self, request: Any) -> Any: users=[], ) + # -- the story world --------------------------------------------------- + # + # Stage E. Same rule as everywhere else: a request moves the world, so a + # test asserts against state that changed. `story pin` really adds the id + # to the profile page, `story delete` really removes the item, and + # `story hide` really flips the flag the next `get_entity` reports. + + def _stories(self, peer: Any) -> dict[int, Any]: + return self.world.stories_of(self._chat_id(peer)) + + def _story_page(self, items: list[Any], *, pinned_to_top: list[int] | None = None) -> Any: + return types.stories.Stories( + count=len(items), stories=items, chats=[], users=[], pinned_to_top=pinned_to_top + ) + def _raw_GetStoriesByIDRequest(self, request: Any) -> Any: - return _FakeStories([self.world.stories[i] for i in request.id if i in self.world.stories]) + stored = self._stories(request.peer) + return self._story_page([stored[i] for i in request.id if i in stored]) + + def _raw_GetPeerStoriesRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + stored = self.world.stories_of(chat_id) + archived = set(self.world.story_archive.get(chat_id, [])) + active = [story for sid, story in sorted(stored.items()) if sid not in archived] + return types.stories.PeerStories( + stories=types.PeerStories( + peer=self._peer_of(chat_id), + stories=active, + max_read_id=self.world.story_read.get(chat_id, 0), + ), + chats=list(self.world.chats.values()), + users=list(self.world.users.values()), + ) + + def _raw_GetPinnedStoriesRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + stored = self.world.stories_of(chat_id) + ids = sorted(self.world.story_pinned.get(chat_id, set())) + if request.offset_id: + ids = [i for i in ids if i < request.offset_id] + ids = ids[: request.limit] + return self._story_page( + [stored[i] for i in ids if i in stored], + pinned_to_top=list(self.world.story_pinned_top.get(chat_id, [])), + ) + + def _raw_GetStoriesArchiveRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + stored = self.world.stories_of(chat_id) + ids = sorted(self.world.story_archive.get(chat_id, []), reverse=True) + if request.offset_id: + ids = [i for i in ids if i < request.offset_id] + return self._story_page([stored[i] for i in ids[: request.limit] if i in stored]) + + def _raw_GetAlbumStoriesRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + album = self.world.story_albums.get(chat_id, {}).get(request.album_id) + stored = self.world.stories_of(chat_id) + ids = list(album["stories"]) if album else [] + window = ids[request.offset : request.offset + request.limit] + return self._story_page([stored[i] for i in window if i in stored]) + + def _raw_SendStoryRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + self.world.next_story_id += 1 + story = make_story( + self.world.next_story_id, + caption=request.caption or "", + pinned=bool(request.pinned), + noforwards=bool(request.noforwards), + privacy=None, + media_areas=list(request.media_areas or []) or None, + albums=list(request.albums or []) or None, + ) + story.entities = list(request.entities or []) or None + self.world.peer_stories.setdefault(chat_id, {})[story.id] = story + if request.pinned: + self.world.story_pinned.setdefault(chat_id, set()).add(story.id) + for album_id in request.albums or []: + self.world.story_albums.setdefault(chat_id, {}).setdefault( + album_id, {"title": f"Album {album_id}", "stories": []} + )["stories"].append(story.id) + return types.Updates( + updates=[ + types.UpdateStoryID(id=story.id, random_id=request.random_id), + types.UpdateStory(peer=self._peer_of(chat_id), story=story), + ], + users=[], + chats=[], + date=datetime.now(timezone.utc), + seq=0, + ) + + def _raw_EditStoryRequest(self, request: Any) -> types.Updates: + story = self._stories(request.peer).get(request.id) + if story is None: + raise ValueError(f"no story {request.id}") + if request.caption is not None: + story.caption = request.caption + story.entities = list(request.entities or []) or None + if request.media_areas is not None: + story.media_areas = list(request.media_areas) + if request.media is not None: + story.media = self.realise(request.media, existing=story.media) + story.edited = True + return self._updates() + + def _raw_DeleteStoriesRequest(self, request: Any) -> list[int]: + chat_id = self._chat_id(request.peer) + stored = self.world.stories_of(chat_id) + gone = [i for i in request.id if stored.pop(i, None) is not None] + self.world.story_pinned.get(chat_id, set()).difference_update(gone) + self.world.story_archive[chat_id] = [ + i for i in self.world.story_archive.get(chat_id, []) if i not in gone + ] + return gone + + def _raw_TogglePinnedRequest(self, request: Any) -> list[int]: + chat_id = self._chat_id(request.peer) + shelf = self.world.story_pinned.setdefault(chat_id, set()) + changed: list[int] = [] + for story_id in request.id: + if request.pinned and story_id not in shelf: + shelf.add(story_id) + changed.append(story_id) + elif not request.pinned and story_id in shelf: + shelf.discard(story_id) + changed.append(story_id) + return changed + + def _raw_TogglePinnedToTopRequest(self, request: Any) -> bool: + self.world.story_pinned_top[self._chat_id(request.peer)] = list(request.id) + return True + + def _raw_TogglePeerStoriesHiddenRequest(self, request: Any) -> bool: + chat_id = self._chat_id(request.peer) + entity = self._lookup(request.peer) + if entity is not None: + entity.stories_hidden = bool(request.hidden) + if request.hidden: + self.world.stories_hidden_peers.add(chat_id) + else: + self.world.stories_hidden_peers.discard(chat_id) + return True + + def _raw_ToggleAllStoriesHiddenRequest(self, request: Any) -> bool: + self.world.all_stories_hidden = bool(request.hidden) + return True + + def _raw_ReadStoriesRequest(self, request: Any) -> list[int]: + chat_id = self._chat_id(request.peer) + was = self.world.story_read.get(chat_id, 0) + if request.max_id <= was: + return [] + self.world.story_read[chat_id] = request.max_id + return [i for i in sorted(self.world.stories_of(chat_id)) if was < i <= request.max_id] + + def _raw_IncrementStoryViewsRequest(self, request: Any) -> bool: + return True + + def _story_reaction(self, request: Any) -> types.Updates: + story = self._stories(request.peer).get(request.story_id) + if story is not None: + empty = type(request.reaction).__name__ == "ReactionEmpty" + story.sent_reaction = None if empty else request.reaction + return self._updates() + + def _raw_CanSendStoryRequest(self, request: Any) -> Any: + return self.world.can_send_story or types.stories.CanSendStoryCount(count_remains=3) + + def _raw_GetChatsToSendRequest(self, request: Any) -> Any: + return types.messages.Chats(chats=list(self.world.chats_to_send)) + + def _raw_ExportStoryLinkRequest(self, request: Any) -> Any: + entity = self._lookup(request.peer) + username = getattr(entity, "username", None) or "someone" + return types.ExportedStoryLink(link=f"https://t.me/{username}/s/{request.id}") + + def _story_views(self, peer_id: int, story_id: int) -> types.StoryViews: + rows = self.world.story_viewers.get((peer_id, story_id), []) + return types.StoryViews( + views_count=len(rows), + has_viewers=True, + reactions_count=sum(1 for row in rows if getattr(row, "reaction", None)), + recent_viewers=[getattr(row, "user_id", 0) for row in rows][:3], + ) + + def _raw_GetStoriesViewsRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + return types.stories.StoryViews( + views=[self._story_views(chat_id, i) for i in request.id], users=[] + ) + + def _raw_GetStoryViewsListRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + rows = list(self.world.story_viewers.get((chat_id, request.id), [])) + if request.q: + wanted = request.q.lower() + rows = [ + row + for row in rows + if wanted + in ( + (self.world.users.get(getattr(row, "user_id", 0)) or make_user(0)).username + or "" + ) + ] + window = rows[: request.limit] + return types.stories.StoryViewsList( + count=len(rows), + views_count=len(rows), + forwards_count=0, + reactions_count=sum(1 for row in rows if getattr(row, "reaction", None)), + views=window, + chats=[], + users=list(self.world.users.values()), + next_offset="page2" if len(rows) > len(window) else None, + ) + + def _raw_GetStoryReactionsListRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + rows = [ + types.StoryReaction( + peer_id=types.PeerUser(user_id=getattr(row, "user_id", 0)), + date=getattr(row, "date", None), + reaction=getattr(row, "reaction", None) or types.ReactionEmoji(emoticon="👍"), + ) + for row in self.world.story_viewers.get((chat_id, request.id), []) + ] + return types.stories.StoryReactionsList( + count=len(rows), + reactions=rows[: request.limit], + chats=[], + users=list(self.world.users.values()), + next_offset=None, + ) + + # albums --------------------------------------------------------------- + + def _album_type(self, album_id: int, entry: dict[str, Any]) -> Any: + return types.StoryAlbum(album_id=album_id, title=entry["title"]) + + def _raw_CreateAlbumRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + albums = self.world.story_albums.setdefault(chat_id, {}) + album_id = max(albums, default=0) + 1 + albums[album_id] = {"title": request.title, "stories": list(request.stories)} + self.world.story_album_order.setdefault(chat_id, []).append(album_id) + return self._album_type(album_id, albums[album_id]) + + def _raw_DeleteAlbumRequest(self, request: Any) -> bool: + chat_id = self._chat_id(request.peer) + self.world.story_albums.get(chat_id, {}).pop(request.album_id, None) + order = self.world.story_album_order.get(chat_id, []) + self.world.story_album_order[chat_id] = [i for i in order if i != request.album_id] + return True + + def _raw_UpdateAlbumRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + entry = self.world.story_albums.setdefault(chat_id, {}).setdefault( + request.album_id, {"title": "", "stories": []} + ) + if request.title is not None: + entry["title"] = request.title + for story_id in request.add_stories or []: + if story_id not in entry["stories"]: + entry["stories"].append(story_id) + for story_id in request.delete_stories or []: + if story_id in entry["stories"]: + entry["stories"].remove(story_id) + if request.order: + entry["stories"] = list(request.order) + return self._album_type(request.album_id, entry) + + def _raw_GetAlbumsRequest(self, request: Any) -> Any: + chat_id = self._chat_id(request.peer) + if request.hash and request.hash == self.world.story_albums_hash: + return types.stories.AlbumsNotModified() + albums = self.world.story_albums.get(chat_id, {}) + order = self.world.story_album_order.get(chat_id) or sorted(albums) + return types.stories.Albums( + hash=self.world.story_albums_hash, + albums=[self._album_type(i, albums[i]) for i in order if i in albums], + ) + + def _raw_ReorderAlbumsRequest(self, request: Any) -> bool: + self.world.story_album_order[self._chat_id(request.peer)] = list(request.order) + return True + + # the feed ------------------------------------------------------------- + + def _raw_GetAllStoriesRequest(self, request: Any) -> Any: + stealth = self.world.stealth_mode or types.StoriesStealthMode() + if self.world.story_feed_not_modified is not None: + return types.stories.AllStoriesNotModified( + state=self.world.story_feed_not_modified, stealth_mode=stealth + ) + hidden = bool(getattr(request, "hidden", False)) + rows = [] + for chat_id, stored in sorted(self.world.peer_stories.items()): + is_hidden = chat_id in self.world.stories_hidden_peers + if is_hidden != hidden: + continue + rows.append( + types.PeerStories( + peer=self._peer_of(chat_id), + stories=[stored[i] for i in sorted(stored)], + max_read_id=self.world.story_read.get(chat_id, 0), + ) + ) + return types.stories.AllStories( + count=len(rows), + state=self.world.story_feed_state, + peer_stories=rows, + chats=list(self.world.chats.values()), + users=list(self.world.users.values()), + stealth_mode=stealth, + has_more=self.world.story_feed_has_more or None, + ) + + def _raw_GetPeerMaxIDsRequest(self, request: Any) -> Any: + out = [] + for peer in request.id: + stored = self.world.stories_of(self._chat_id(peer)) + out.append(types.RecentStory(max_id=max(stored, default=0))) + return out + + def _raw_GetAllReadPeerStoriesRequest(self, request: Any) -> Any: + return types.Updates( + updates=[ + types.UpdateReadStories(peer=self._peer_of(chat_id), max_id=max_id) + for chat_id, max_id in sorted(self.world.story_read.items()) + ], + users=list(self.world.users.values()), + chats=list(self.world.chats.values()), + date=datetime.now(timezone.utc), + seq=0, + ) + + # stealth, search, blocklist, live ------------------------------------- + + def _raw_ActivateStealthModeRequest(self, request: Any) -> types.Updates: + now = datetime.now(timezone.utc) + self.world.stealth_mode = types.StoriesStealthMode( + active_until_date=now + timedelta(minutes=5), + cooldown_until_date=now + timedelta(hours=1), + ) + return self._updates() + + def _raw_StartLiveRequest(self, request: Any) -> types.Updates: + chat_id = self._chat_id(request.peer) + self.world.next_story_id += 1 + story = make_story(self.world.next_story_id, caption=request.caption or "") + self.world.peer_stories.setdefault(chat_id, {})[story.id] = story + return types.Updates( + updates=[types.UpdateStoryID(id=story.id, random_id=request.random_id)], + users=[], + chats=[], + date=datetime.now(timezone.utc), + seq=0, + ) + + # statistics ----------------------------------------------------------- + + def _raw_GetStoryStatsRequest(self, request: Any) -> Any: + return types.stats.StoryStats( + views_graph=types.StatsGraph(json=types.DataJSON(data='{"columns": []}')), + reactions_by_emotion_graph=types.StatsGraphAsync(token="graph-token"), + ) + + def _raw_GetStoryPublicForwardsRequest(self, request: Any) -> Any: + return types.stats.PublicForwards( + count=1, + forwards=[ + types.PublicForwardStory( + peer=types.PeerChannel(channel_id=555), + story=make_story(7, caption="repost"), + ) + ], + chats=list(self.world.chats.values()), + users=[], + next_offset=None, + ) + + def _peer_of(self, chat_id: int) -> Any: + """A marked id back as the `Peer*` the TL types carry.""" + if chat_id < -1000000000000: + return types.PeerChannel(channel_id=-1000000000000 - chat_id) + if chat_id < 0: + return types.PeerChat(chat_id=-chat_id) + return types.PeerUser(user_id=chat_id) # -- sticker sets ------------------------------------------------------ @@ -5433,8 +5961,3 @@ def factory(session_path: Any, options: Any) -> FakeTelegramClient: factory.world = shared # type: ignore[attr-defined] return factory - - -class _FakeStories: - def __init__(self, stories: list[Any]) -> None: - self.stories = stories diff --git a/tests/test_agentmd_compat.py b/tests/test_agentmd_compat.py index 9064a48..fb5f1ef 100644 --- a/tests/test_agentmd_compat.py +++ b/tests/test_agentmd_compat.py @@ -74,6 +74,8 @@ ("contacts",), ("user", "get"), ("user", "dialog-status"), + # PR-8: the one story command v1 had. It is `story hide` now, and the old + # path is a legacy path on it rather than a second implementation. ("user", "hide-stories"), ] @@ -257,6 +259,11 @@ def test_the_shortcuts_still_reach_message_send(): assert ALIASES[name] == "message.send" +def test_the_v1_story_command_still_reaches_the_story_group(): + """`user hide-stories` was v1's only story command; `story hide` is it now.""" + assert ALIASES["user.hide-stories"] == "story.hide" + + def test_the_media_shortcuts_still_reach_the_media_operations(): """`tlgr dl` and `tlgr up` were the two shortcuts v1's README taught.""" assert ALIASES["dl"] == "media.download" diff --git a/tests/test_ops_contacts.py b/tests/test_ops_contacts.py index c246736..a6d048b 100644 --- a/tests/test_ops_contacts.py +++ b/tests/test_ops_contacts.py @@ -12,7 +12,7 @@ * `user dialog-status` is three-valued and its exit code is part of the answer — exit 13 must never be reachable by reading "unknown" as "no"; -* `user hide-stories` reports `already` and sends nothing when there is +* `user hide-stories` (now `story hide`) reports `already` and sends nothing when there is nothing to do; * `contact rename` writes only *our* view of a name, and an empty first name still becomes `"."` the way v1 sent it. @@ -169,9 +169,9 @@ async def test_export_without_a_destination_is_a_usage_error( async def test_with_stories_flags_unseen_ones(self, live_daemon, client, in_thread, book): book.users[ALICE].stories_max_id = types.RecentStory(max_id=9) - book.stories_read[ALICE] = 4 + book.story_read[ALICE] = 4 book.users[BOB].stories_max_id = types.RecentStory(max_id=2) - book.stories_read[BOB] = 2 + book.story_read[BOB] = 2 rows = await result(client, in_thread, "contact.list", {"with_stories": True}) unseen = {row["id"]: row["has_unseen_stories"] for row in rows} assert unseen == {ALICE: True, BOB: False} @@ -958,15 +958,19 @@ def fake_dispatch(spec, request, state): # --------------------------------------------------------------------------- -# user hide-stories — the other frozen contract +# user hide-stories — the frozen contract, now owned by `story hide` # --------------------------------------------------------------------------- class TestHideStories: + """The v1 path is a legacy path of `story.hide`, so the contract is tested + through the id an agent would actually call. AGENT.md's four keys, the + idempotence and the bulk shape are unchanged; only the owner moved.""" + async def test_hiding_moves_the_flag_and_reports_v1_keys( self, live_daemon, client, in_thread, book ): - answer = await result(client, in_thread, "user.hide-stories", {"user": ["@alice"]}) + answer = await result(client, in_thread, "story.hide", {"chat": ["@alice"]}) assert { "user_id": ALICE, "username": "alice", @@ -976,38 +980,34 @@ async def test_hiding_moves_the_flag_and_reports_v1_keys( assert book.users[ALICE].stories_hidden is True async def test_a_second_pass_costs_no_rpc(self, live_daemon, client, in_thread, book): - await result(client, in_thread, "user.hide-stories", {"user": ["@alice"]}) + await result(client, in_thread, "story.hide", {"chat": ["@alice"]}) book.calls.clear() - envelope = await call(client, in_thread, "user.hide-stories", {"user": ["@alice"]}) + envelope = await call(client, in_thread, "story.hide", {"chat": ["@alice"]}) assert envelope["result"]["already"] is True assert envelope["meta"]["already"] is True assert not book.called("TogglePeerStoriesHiddenRequest") async def test_unhide_puts_them_back(self, live_daemon, client, in_thread, book): book.users[ALICE].stories_hidden = True - answer = await result( - client, in_thread, "user.hide-stories", {"user": ["@alice"], "unhide": True} - ) + answer = await result(client, in_thread, "story.hide", {"chat": ["@alice"], "unhide": True}) assert answer["hidden"] is False assert book.users[ALICE].stories_hidden is False async def test_a_bulk_pass_keeps_the_single_peer_shape( self, live_daemon, client, in_thread, book ): - answer = await result( - client, in_thread, "user.hide-stories", {"user": ["@alice", "@bobby"]} - ) + answer = await result(client, in_thread, "story.hide", {"chat": ["@alice", "@bobby"]}) assert answer["user_id"] == ALICE assert [row["user_id"] for row in answer["peers"]] == [ALICE, BOB] async def test_the_whole_strip_can_be_collapsed(self, live_daemon, client, in_thread, book): - answer = await result(client, in_thread, "user.hide-stories", {"all_stories": "on"}) - assert answer["all_hidden"] is True + answer = await result(client, in_thread, "story.hide", {"every": True}) + assert answer["all"] is True assert book.all_stories_hidden is True async def test_no_target_at_all_is_a_usage_error(self, live_daemon, client, in_thread, book): with pytest.raises(Exception) as caught: - await result(client, in_thread, "user.hide-stories", {}) + await result(client, in_thread, "story.hide", {}) assert classify(caught.value).exit_code == EXIT_USAGE @@ -1439,7 +1439,6 @@ class TestDryRun: ("contact.remove", {"user": ["@alice"]}), ("contact.rename", {"user": "@alice", "first_name": "X"}), ("user.block", {"user": "@carol"}), - ("user.hide-stories", {"user": ["@alice"]}), ("contact.blocked.set", {"user": ["@carol"]}), ], ) @@ -1467,7 +1466,7 @@ class TestLegacyPaths: ("contact search", "contact.search"), ("user get", "user.get"), ("user dialog-status", "user.dialog-status"), - ("user hide-stories", "user.hide-stories"), + ("user hide-stories", "story.hide"), ], ) def test_the_v1_path_still_resolves(self, path, op_id): diff --git a/tests/test_ops_story.py b/tests/test_ops_story.py new file mode 100644 index 0000000..64c2799 --- /dev/null +++ b/tests/test_ops_story.py @@ -0,0 +1,1379 @@ +"""The `story` operations, end to end through a real daemon. + +Same arrangement as the other group suites: a real Unix socket, the real +middleware chain, the real dispatcher, a fake Telegram. Three properties are +worth more here than anywhere else and most of the file is about them. + +* **Reading is not being seen.** `story read` must send `readStories` and + nothing else; only `--register-view` may reach `incrementStoryViews`. That + is a privacy boundary, and it is only visible by inspecting the requests. +* **The audience vector is ordered.** `[base, allow…, disallow…]` is what the + server evaluates, so "contacts, except Bob" is asserted on the request tlgr + built, not on the reply. +* **A placeholder is not a story.** A feed hands back `storyItemSkipped`, and + a caller has to be able to tell that from a caption-less story. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from tlgr.core.errors import ( + EXIT_INDETERMINATE, + EXIT_NOT_FOUND, + EXIT_PERMISSION, + EXIT_USAGE, +) + +ALICE = 4242 +BOB = 9001 +CHANNEL = 5150 +CHANNEL_ID = -1000000000000 - CHANNEL +ME = 777 + + +@pytest.fixture +def stories(world): + """Alice has three stories; two of them are on her profile page.""" + from fake_telethon import make_channel, make_user + + world.add_user(make_user(ALICE, username="alice")) + world.add_user(make_user(BOB, username="bobby")) + world.add_channel(make_channel(CHANNEL, title="News")) + world.add_user(make_user(7777, username="foursquare", first="Venues")) + + world.add_story(ALICE, story_id=41, caption="yesterday", pinned=True, out=False) + world.add_story(ALICE, story_id=42, caption="morning", out=False) + world.add_story(ALICE, story_id=43, caption="evening", pinned=True, out=False) + world.add_story(ME, story_id=7, caption="mine", archived=True) + world.add_story_viewer(ALICE, 42, BOB, reaction="🔥") + world.add_story_viewer(ALICE, 42, ME) + 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: + return (await call(client, in_thread, op, request, **kwargs))["result"] + + +async def paged(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> Any: + """A paginated op's items and pagination, as one dict. + + The daemon puts a page's items in `result` and its cursor in `page`; every + assertion below wants both, and unpacking them at each call site is how a + cursor assertion ends up testing the wrong half. + """ + envelope = await call(client, in_thread, op, request, **kwargs) + return {"items": envelope["result"], **envelope["page"]} + + +async def fails(client, in_thread, op: str, request: Any = None, **kwargs: Any) -> Any: + from tlgr.core.errors import TlgrError + + try: + await call(client, in_thread, op, request, **kwargs) + except TlgrError as exc: + return exc + raise AssertionError(f"{op} was expected to fail") + + +def photo(tmp_path, name: str = "story.jpg") -> str: + path = tmp_path / name + path.write_bytes(b"\xff\xd8" + b"x" * 128) + return str(path) + + +# --------------------------------------------------------------------------- +# story list / get +# --------------------------------------------------------------------------- + + +class TestList: + async def test_the_active_stories_come_back_with_their_captions( + self, live_daemon, client, in_thread, stories + ): + page = await paged(client, in_thread, "story.list", {"chat": "@alice"}) + assert [item["id"] for item in page["items"]] == [41, 42, 43] + assert page["items"][1]["caption"] == "morning" + + async def test_the_profile_page_is_a_different_rpc( + self, live_daemon, client, in_thread, stories + ): + page = await paged(client, in_thread, "story.list", {"chat": "@alice", "profile": True}) + assert [item["id"] for item in page["items"]] == [41, 43] + assert stories.called("GetPinnedStoriesRequest") + assert not stories.called("GetStoriesArchiveRequest") + + async def test_the_archive_is_its_own_rpc(self, live_daemon, client, in_thread, stories): + page = await paged(client, in_thread, "story.list", {"chat": "me", "archive": True}) + assert [item["id"] for item in page["items"]] == [7] + assert stories.called("GetStoriesArchiveRequest") + + async def test_an_album_pages_on_an_integer_offset( + self, live_daemon, client, in_thread, stories + ): + stories.add_album(ALICE, 3, "Trips", [41, 42, 43]) + page = await paged(client, in_thread, "story.list", {"chat": "@alice", "album": 3}, limit=2) + assert [item["id"] for item in page["items"]] == [41, 42] + request = stories.called("GetAlbumStoriesRequest")[0] + assert (request.offset, request.limit) == (0, 2) + + async def test_the_cursor_continues_the_album_walk( + self, live_daemon, client, in_thread, stories + ): + stories.add_album(ALICE, 3, "Trips", [41, 42, 43]) + first = await paged( + client, in_thread, "story.list", {"chat": "@alice", "album": 3}, limit=2 + ) + assert first["has_more"] is True + second = await paged( + client, + in_thread, + "story.list", + {"chat": "@alice", "album": 3}, + limit=2, + cursor=first["next_cursor"], + ) + assert [item["id"] for item in second["items"]] == [43] + + async def test_a_skipped_placeholder_is_hydrated(self, live_daemon, client, in_thread, stories): + """A feed placeholder has no caption; hydrating it fetches the real item.""" + from telethon.tl import types + + stories.peer_stories[ALICE][42] = types.StoryItemSkipped(id=42, date=None, expire_date=None) + stories.raw["GetPeerStoriesRequest"] = lambda request: types.stories.PeerStories( + stories=types.PeerStories( + peer=types.PeerUser(user_id=ALICE), + stories=[types.StoryItemSkipped(id=99, date=None, expire_date=None)], + max_read_id=0, + ), + chats=[], + users=[], + ) + stories.peer_stories[ALICE][99] = stories.add_story(ALICE, story_id=99, caption="real") + page = await paged(client, in_thread, "story.list", {"chat": "@alice"}) + assert page["items"][0]["caption"] == "real" + assert stories.called("GetStoriesByIDRequest") + + async def test_no_hydrate_reports_the_placeholder_as_a_placeholder( + self, live_daemon, client, in_thread, stories + ): + from telethon.tl import types + + stories.raw["GetPeerStoriesRequest"] = lambda request: types.stories.PeerStories( + stories=types.PeerStories( + peer=types.PeerUser(user_id=ALICE), + stories=[types.StoryItemSkipped(id=99, date=None, expire_date=None)], + max_read_id=0, + ), + chats=[], + users=[], + ) + page = await paged(client, in_thread, "story.list", {"chat": "@alice", "hydrate": False}) + assert page["items"][0]["skipped"] is True + assert "caption" not in page["items"][0] + + async def test_listing_never_registers_a_view(self, live_daemon, client, in_thread, stories): + await paged(client, in_thread, "story.list", {"chat": "@alice"}) + assert not stories.called("IncrementStoryViewsRequest") + assert not stories.called("ReadStoriesRequest") + + +class TestGet: + async def test_a_story_comes_back_whole(self, live_daemon, client, in_thread, stories): + page = await result(client, in_thread, "story.get", {"chat": "@alice", "id": ["42"]}) + assert page["items"][0]["caption"] == "morning" + assert page["items"][0]["peer_id"] == ALICE + + async def test_a_story_link_replaces_the_chat_and_id_pair( + self, live_daemon, client, in_thread, stories + ): + page = await result(client, in_thread, "story.get", {"chat": "t.me/alice/s/42"}) + assert page["items"][0]["id"] == 42 + + async def test_link_only_exports_the_deep_link(self, live_daemon, client, in_thread, stories): + page = await result( + client, in_thread, "story.get", {"chat": "@alice", "id": ["42"], "link": True} + ) + assert page["items"][0]["link"] == "https://t.me/alice/s/42" + assert not stories.called("GetStoriesByIDRequest") + + async def test_the_album_link_is_built_from_the_username( + self, live_daemon, client, in_thread, stories + ): + page = await result(client, in_thread, "story.get", {"chat": "@alice", "album_link": 3}) + assert page["items"][0]["link"] == "https://t.me/alice/a/3" + + async def test_views_are_a_second_call(self, live_daemon, client, in_thread, stories): + page = await result( + client, in_thread, "story.get", {"chat": "@alice", "id": ["42"], "views": True} + ) + assert page["items"][0]["views"]["views_count"] == 2 + assert stories.called("GetStoriesViewsRequest") + + async def test_areas_out_writes_json_that_areas_reads_back( + self, live_daemon, client, in_thread, stories, tmp_path + ): + from telethon.tl import types + + stories.peer_stories[ALICE][42].media_areas = [ + types.MediaAreaUrl( + coordinates=types.MediaAreaCoordinates( + x=50.0, y=20.0, w=30.0, h=10.0, rotation=0.0 + ), + url="https://example.com", + ) + ] + target = tmp_path / "areas.json" + await result( + client, + in_thread, + "story.get", + {"chat": "@alice", "id": ["42"], "areas_out": str(target)}, + ) + written = json.loads(target.read_text()) + assert written[0]["type"] == "url" + assert written[0]["url"] == "https://example.com" + + async def test_a_missing_story_is_not_found(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.get", {"chat": "@alice", "id": ["999"]}) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_no_id_and_no_link_is_a_usage_error( + self, live_daemon, client, in_thread, stories + ): + error = await fails(client, in_thread, "story.get", {"chat": "@alice"}) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# story post / edit / delete +# --------------------------------------------------------------------------- + + +class TestPost: + async def test_a_story_is_posted_and_findable( + self, live_daemon, client, in_thread, stories, tmp_path + ): + page = await result( + client, + in_thread, + "story.post", + {"file": [photo(tmp_path)], "caption": "hello"}, + ) + posted = page["items"][0] + assert posted["id"] > 0 + assert stories.peer_stories[ME][posted["id"]].caption == "hello" + + async def test_two_files_post_two_stories_and_re_check_between_them( + self, live_daemon, client, in_thread, stories, tmp_path + ): + page = await result( + client, + in_thread, + "story.post", + {"file": [photo(tmp_path, "a.jpg"), photo(tmp_path, "b.jpg")]}, + ) + assert len(page["items"]) == 2 + assert len(stories.called("CanSendStoryRequest")) == 2 + + async def test_the_privacy_vector_is_base_then_allow_then_disallow( + self, live_daemon, client, in_thread, stories, tmp_path + ): + await result( + client, + in_thread, + "story.post", + { + "file": [photo(tmp_path)], + "privacy": "contacts", + "allow": ["@alice"], + "exclude": ["@bobby"], + }, + ) + request = stories.called("SendStoryRequest")[0] + assert [type(rule).__name__ for rule in request.privacy_rules] == [ + "InputPrivacyValueAllowContacts", + "InputPrivacyValueAllowUsers", + "InputPrivacyValueDisallowUsers", + ] + + async def test_close_friends_is_its_own_base_rule( + self, live_daemon, client, in_thread, stories, tmp_path + ): + await result( + client, + in_thread, + "story.post", + {"file": [photo(tmp_path)], "privacy": "close-friends"}, + ) + request = stories.called("SendStoryRequest")[0] + assert type(request.privacy_rules[0]).__name__ == "InputPrivacyValueAllowCloseFriends" + + async def test_selected_with_no_allow_list_is_refused( + self, live_daemon, client, in_thread, stories, tmp_path + ): + error = await fails( + client, + in_thread, + "story.post", + {"file": [photo(tmp_path)], "privacy": "selected"}, + ) + assert error.exit_code == EXIT_USAGE + assert not stories.called("SendStoryRequest") + + async def test_the_period_becomes_seconds( + self, live_daemon, client, in_thread, stories, tmp_path + ): + await result(client, in_thread, "story.post", {"file": [photo(tmp_path)], "period": "48h"}) + assert stories.called("SendStoryRequest")[0].period == 172800 + + async def test_a_url_area_is_built_from_the_flag( + self, live_daemon, client, in_thread, stories, tmp_path + ): + await result( + client, + in_thread, + "story.post", + { + "file": [photo(tmp_path)], + "area_url": ["https://example.com@50,20,30,10,15"], + }, + ) + area = stories.called("SendStoryRequest")[0].media_areas[0] + assert type(area).__name__ == "MediaAreaUrl" + assert area.url == "https://example.com" + assert (area.coordinates.x, area.coordinates.rotation) == (50.0, 15.0) + + async def test_a_malformed_area_is_a_usage_error( + self, live_daemon, client, in_thread, stories, tmp_path + ): + error = await fails( + client, + in_thread, + "story.post", + {"file": [photo(tmp_path)], "area_url": ["https://example.com"]}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_a_venue_area_runs_the_inline_query( + self, live_daemon, client, in_thread, stories, tmp_path + ): + from telethon.tl import types + + stories.venues = [ + types.BotInlineResult( + id="v1", + type="venue", + send_message=types.BotInlineMessageMediaVenue( + geo=types.GeoPoint(long=13.4, lat=52.5, access_hash=0), + title="Gate", + address="Platz", + provider="foursquare", + venue_id="4ac", + venue_type="landmark", + ), + ) + ] + await result( + client, + in_thread, + "story.post", + { + "file": [photo(tmp_path)], + "area_venue": ["gate@50,50,40,10"], + "area_venue_near": "52.5,13.4", + }, + ) + area = stories.called("SendStoryRequest")[0].media_areas[0] + assert type(area).__name__ == "MediaAreaVenue" + assert area.venue_id == "4ac" + + async def test_a_repost_carries_the_origin( + self, live_daemon, client, in_thread, stories, tmp_path + ): + await result( + client, + in_thread, + "story.post", + {"file": [photo(tmp_path)], "repost": "@alice:42", "modified": True}, + ) + request = stories.called("SendStoryRequest")[0] + assert request.fwd_from_story == 42 + assert request.fwd_modified is True + + async def test_a_mention_excluded_by_the_rules_is_warned_about( + self, live_daemon, client, in_thread, stories, tmp_path + ): + envelope = await call( + client, + in_thread, + "story.post", + { + "file": [photo(tmp_path)], + "caption": "hi @bobby", + "privacy": "contacts", + "exclude": ["@bobby"], + }, + ) + assert any("excluded" in warning for warning in envelope["meta"]["warnings"]) + + async def test_no_check_skips_the_preflight( + self, live_daemon, client, in_thread, stories, tmp_path + ): + await result(client, in_thread, "story.post", {"file": [photo(tmp_path)], "no_check": True}) + assert not stories.called("CanSendStoryRequest") + + async def test_a_premium_gate_is_a_permission_error( + self, live_daemon, client, in_thread, stories, tmp_path + ): + from telethon.errors import PremiumAccountRequiredError + + stories.fail_next("CanSendStoryRequest", PremiumAccountRequiredError(request=None)) + error = await fails(client, in_thread, "story.post", {"file": [photo(tmp_path)]}) + assert error.exit_code == EXIT_PERMISSION + assert not stories.called("SendStoryRequest") + + async def test_dry_run_posts_nothing(self, live_daemon, client, in_thread, stories, tmp_path): + envelope = await call( + client, + in_thread, + "story.post", + {"file": [photo(tmp_path)]}, + dry_run=True, + ) + assert envelope["result"]["dry_run"] is True + assert not stories.called("SendStoryRequest") + + async def test_no_file_is_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.post", {"file": []}) + assert error.exit_code == EXIT_USAGE + + +class TestEdit: + async def test_a_caption_edit_lands_on_the_stored_story( + self, live_daemon, client, in_thread, stories + ): + await result( + client, in_thread, "story.edit", {"chat": "me", "id": 7, "caption": "reworded"} + ) + assert stories.peer_stories[ME][7].caption == "reworded" + + async def test_only_what_was_passed_is_sent(self, live_daemon, client, in_thread, stories): + await result( + client, in_thread, "story.edit", {"chat": "me", "id": 7, "caption": "reworded"} + ) + request = stories.called("EditStoryRequest")[0] + assert request.media is None + assert request.privacy_rules is None + + async def test_an_empty_edit_is_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.edit", {"chat": "me", "id": 7}) + assert error.exit_code == EXIT_USAGE + + async def test_a_cover_change_on_a_photo_story_is_refused( + self, live_daemon, client, in_thread, stories + ): + error = await fails( + client, in_thread, "story.edit", {"chat": "me", "id": 7, "cover_ts": 1.5} + ) + assert error.exit_code == EXIT_USAGE + + +class TestDelete: + async def test_the_story_really_goes(self, live_daemon, client, in_thread, stories): + deleted = await result(client, in_thread, "story.delete", {"chat": "me", "id": ["7"]}) + assert deleted["deleted_ids"] == [7] + assert 7 not in stories.peer_stories[ME] + + async def test_a_range_expands(self, live_daemon, client, in_thread, stories): + await result(client, in_thread, "story.delete", {"chat": "@alice", "id": ["41-43"]}) + assert stories.called("DeleteStoriesRequest")[0].id == [41, 42, 43] + + async def test_dry_run_deletes_nothing(self, live_daemon, client, in_thread, stories): + envelope = await call( + client, in_thread, "story.delete", {"chat": "me", "id": ["7"]}, dry_run=True + ) + assert envelope["result"]["dry_run"] is True + assert 7 in stories.peer_stories[ME] + + +# --------------------------------------------------------------------------- +# story read / react / reply / share +# --------------------------------------------------------------------------- + + +class TestRead: + async def test_reading_clears_the_ring_without_registering_a_view( + self, live_daemon, client, in_thread, stories + ): + read = await result(client, in_thread, "story.read", {"chat": "@alice"}) + assert read["max_id"] == 43 + assert stories.called("ReadStoriesRequest") + assert not stories.called("IncrementStoryViewsRequest") + + async def test_register_view_is_the_opt_in(self, live_daemon, client, in_thread, stories): + read = await result( + client, + in_thread, + "story.read", + {"chat": "@alice", "id": ["42"], "register_view": True}, + ) + assert read["viewed_ids"] == [42] + assert stories.called("IncrementStoryViewsRequest")[0].id == [42] + + async def test_reading_twice_is_already(self, live_daemon, client, in_thread, stories): + await result(client, in_thread, "story.read", {"chat": "@alice"}) + envelope = await call(client, in_thread, "story.read", {"chat": "@alice"}) + assert envelope["result"]["already"] is True + assert envelope["meta"]["already"] is True + + async def test_a_peer_with_no_stories_is_not_found( + self, live_daemon, client, in_thread, stories + ): + error = await fails(client, in_thread, "story.read", {"chat": "@bobby"}) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_the_view_alias_is_the_same_operation(self, live_daemon, client, in_thread): + from tlgr.registry import canonical + + assert canonical("story view") == "story.read" + + +class TestReact: + async def test_a_reaction_lands_on_the_story(self, live_daemon, client, in_thread, stories): + reacted = await result( + client, in_thread, "story.react", {"chat": "@alice", "id": 42, "emoji": "🔥"} + ) + assert reacted["reaction"] == "🔥" + request = stories.called("SendReactionRequest")[0] + assert request.story_id == 42 + assert request.reaction.emoticon == "🔥" + + async def test_remove_sends_reaction_empty(self, live_daemon, client, in_thread, stories): + removed = await result( + client, in_thread, "story.react", {"chat": "@alice", "id": 42, "remove": True} + ) + assert removed["removed"] is True + assert type(stories.called("SendReactionRequest")[0].reaction).__name__ == "ReactionEmpty" + + async def test_a_custom_emoji_is_spelled_the_reaction_way( + self, live_daemon, client, in_thread, stories + ): + reacted = await result( + client, + in_thread, + "story.react", + {"chat": "@alice", "id": 42, "custom_emoji": 555}, + ) + assert reacted["reaction"] == "custom:555" + + async def test_as_message_sends_an_ordinary_story_reply( + self, live_daemon, client, in_thread, stories + ): + reacted = await result( + client, + in_thread, + "story.react", + {"chat": "@alice", "id": 42, "emoji": "🔥", "as_message": True}, + ) + assert reacted["msg_id"] > 0 + request = stories.called("SendMessageRequest")[0] + assert type(request.reply_to).__name__ == "InputReplyToStory" + + async def test_nothing_to_react_with_is_a_usage_error( + self, live_daemon, client, in_thread, stories + ): + error = await fails(client, in_thread, "story.react", {"chat": "@alice", "id": 42}) + assert error.exit_code == EXIT_USAGE + + +class TestReply: + async def test_a_text_reply_carries_input_reply_to_story( + self, live_daemon, client, in_thread, stories + ): + reply = await result( + client, + in_thread, + "story.reply", + {"chat": "@alice", "id": 42, "text": "nice one"}, + ) + assert reply["reply_to_story"] == 42 + request = stories.called("SendMessageRequest")[0] + assert (type(request.reply_to).__name__, request.reply_to.story_id) == ( + "InputReplyToStory", + 42, + ) + + async def test_a_file_reply_goes_through_send_media( + self, live_daemon, client, in_thread, stories, tmp_path + ): + await result( + client, + in_thread, + "story.reply", + {"chat": "@alice", "id": 42, "file": [photo(tmp_path)]}, + ) + assert type(stories.called("SendMediaRequest")[0].reply_to).__name__ == ( + "InputReplyToStory" + ) + + async def test_an_empty_reply_is_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.reply", {"chat": "@alice", "id": 42}) + assert error.exit_code == EXIT_USAGE + + +class TestShare: + async def test_a_share_sends_a_story_card(self, live_daemon, client, in_thread, stories): + shared = await result( + client, + in_thread, + "story.share", + {"chat": "@alice", "id": 42, "until": ["@bobby"]}, + ) + assert shared["story_id"] == 42 + request = stories.called("SendMediaRequest")[0] + assert type(request.media).__name__ == "InputMediaStory" + assert not stories.called("ForwardMessagesRequest") + + async def test_a_protected_story_is_refused_with_a_way_out( + self, live_daemon, client, in_thread, stories + ): + stories.peer_stories[ALICE][42].noforwards = True + error = await fails( + client, + in_thread, + "story.share", + {"chat": "@alice", "id": 42, "until": ["@bobby"]}, + ) + assert error.exit_code == EXIT_PERMISSION + assert "--link" in str(error) + + async def test_no_destination_is_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.share", {"chat": "@alice", "id": 42}) + assert error.exit_code == EXIT_USAGE + + async def test_the_forward_alias_resolves(self, live_daemon, client, in_thread): + from tlgr.registry import canonical + + assert canonical("story forward") == "story.share" + + +# --------------------------------------------------------------------------- +# story pin / unpin / hide / unhide +# --------------------------------------------------------------------------- + + +class TestPin: + async def test_pinning_puts_the_story_on_the_profile_page( + self, live_daemon, client, in_thread, stories + ): + pinned = await result(client, in_thread, "story.pin", {"chat": "me", "id": ["7"]}) + assert pinned["pinned"] is True + assert 7 in stories.story_pinned[ME] + + async def test_pinning_twice_is_already(self, live_daemon, client, in_thread, stories): + await result(client, in_thread, "story.pin", {"chat": "me", "id": ["7"]}) + envelope = await call(client, in_thread, "story.pin", {"chat": "me", "id": ["7"]}) + assert envelope["meta"]["already"] is True + + async def test_top_replaces_the_whole_set(self, live_daemon, client, in_thread, stories): + pinned = await result( + client, in_thread, "story.pin", {"chat": "@alice", "id": ["43"], "top": True} + ) + assert pinned["pinned_to_top"] == [43] + assert stories.story_pinned_top[ALICE] == [43] + + async def test_top_with_no_ids_is_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.pin", {"chat": "@alice", "top": True}) + assert error.exit_code == EXIT_USAGE + + async def test_unpin_takes_it_off_the_page(self, live_daemon, client, in_thread, stories): + await result(client, in_thread, "story.unpin", {"chat": "@alice", "id": ["41"]}) + assert 41 not in stories.story_pinned[ALICE] + + async def test_unpin_top_with_no_ids_clears_the_row( + self, live_daemon, client, in_thread, stories + ): + stories.story_pinned_top[ALICE] = [41, 43] + await result(client, in_thread, "story.unpin", {"chat": "@alice", "top": True}) + assert stories.story_pinned_top[ALICE] == [] + + +class TestHide: + async def test_hiding_a_peer_flips_the_flag(self, live_daemon, client, in_thread, stories): + hidden = await result(client, in_thread, "story.hide", {"chat": ["@alice"]}) + assert hidden == { + "user_id": ALICE, + "username": "alice", + "peer_id": ALICE, + "hidden": True, + "already": False, + }, "the v1 keys, and nothing invented beside them" + assert stories.called("TogglePeerStoriesHiddenRequest")[0].hidden is True + + async def test_hiding_twice_sends_no_request(self, live_daemon, client, in_thread, stories): + await result(client, in_thread, "story.hide", {"chat": ["@alice"]}) + envelope = await call(client, in_thread, "story.hide", {"chat": ["@alice"]}) + assert envelope["result"]["already"] is True + assert len(stories.called("TogglePeerStoriesHiddenRequest")) == 1 + + async def test_unhide_is_the_same_toggle_the_other_way( + self, live_daemon, client, in_thread, stories + ): + await result(client, in_thread, "story.hide", {"chat": ["@alice"]}) + await result(client, in_thread, "story.unhide", {"chat": ["@alice"]}) + assert stories.called("TogglePeerStoriesHiddenRequest")[1].hidden is False + + async def test_the_v1_unhide_flag_still_works(self, live_daemon, client, in_thread, stories): + """`user hide-stories --unhide` was v1's spelling of `story unhide`.""" + await result(client, in_thread, "story.hide", {"chat": ["@alice"]}) + unhidden = await result( + client, in_thread, "story.hide", {"chat": ["@alice"], "unhide": True} + ) + assert unhidden.get("hidden", False) is False + assert unhidden.get("already", False) is False + + async def test_all_collapses_the_whole_bar(self, live_daemon, client, in_thread, stories): + hidden = await result(client, in_thread, "story.hide", {"every": True}) + assert hidden["all"] is True + assert stories.all_stories_hidden is True + + async def test_no_peer_and_no_all_is_a_usage_error( + self, live_daemon, client, in_thread, stories + ): + error = await fails(client, in_thread, "story.hide", {}) + assert error.exit_code == EXIT_USAGE + + +class TestLegacyUserHideStories: + """AGENT.md's `tlgr user hide-stories` keeps working (§12.4).""" + + def test_the_v1_path_resolves_to_the_story_operation(self): + from tlgr.registry import canonical + + assert canonical("user hide-stories") == "story.hide" + + def test_the_v1_path_is_still_invocable(self): + from tlgr.cli import cli + + command = cli.commands["user"].commands["hide-stories"] + flags = {opt for param in command.params for opt in param.opts} + assert "--unhide" in flags + + async def test_the_v1_keys_survive(self, live_daemon, client, in_thread, stories): + hidden = await result(client, in_thread, "user.hide-stories", {"chat": ["@alice"]}) + assert set(hidden) >= {"user_id", "username", "hidden"} + + +# --------------------------------------------------------------------------- +# story feed +# --------------------------------------------------------------------------- + + +class TestFeed: + async def test_the_bar_lists_peers_with_unread_counts( + self, live_daemon, client, in_thread, stories + ): + page = await paged(client, in_thread, "story.feed.list", {}) + alice = next(row for row in page["items"] if row["peer_id"] == ALICE) + assert alice["unread_count"] == 3 + assert alice["has_unread"] is True + + async def test_the_cursor_carries_the_state_and_the_next_flag( + self, live_daemon, client, in_thread, stories + ): + stories.story_feed_has_more = True + page = await paged(client, in_thread, "story.feed.list", {}) + assert page["has_more"] is True + await paged(client, in_thread, "story.feed.list", {}, cursor=page["next_cursor"]) + second = stories.called("GetAllStoriesRequest")[1] + assert (second.state, second.next) == ("feed-state-1", True) + + async def test_the_hidden_feed_is_a_separate_flag( + self, live_daemon, client, in_thread, stories + ): + stories.stories_hidden_peers.add(ALICE) + main = await paged(client, in_thread, "story.feed.list", {}) + assert [row["peer_id"] for row in main["items"]] == [ME] + hidden = await paged(client, in_thread, "story.feed.list", {"hidden": True}) + assert [row["peer_id"] for row in hidden["items"]] == [ALICE] + + async def test_not_modified_reports_already(self, live_daemon, client, in_thread, stories): + stories.story_feed_not_modified = "feed-state-1" + envelope = await call(client, in_thread, "story.feed.list", {"refresh": True}) + assert envelope["result"] == [] + assert envelope["meta"]["already"] is True + + async def test_unread_only_drops_the_read_peers(self, live_daemon, client, in_thread, stories): + stories.story_read[ALICE] = 43 + page = await paged(client, in_thread, "story.feed.list", {"unread_only": True}) + assert [row["peer_id"] for row in page["items"]] == [ME] + + async def test_peers_uses_the_compact_summary(self, live_daemon, client, in_thread, stories): + page = await paged(client, in_thread, "story.feed.list", {"peers": ["@alice"]}) + assert page["items"][0]["max_id"] == 43 + assert stories.called("GetPeerMaxIDsRequest") + assert not stories.called("GetAllStoriesRequest") + + async def test_read_state_is_the_login_bootstrap(self, live_daemon, client, in_thread, stories): + stories.story_read[ALICE] = 41 + page = await paged(client, in_thread, "story.feed.list", {"read_state": True}) + assert page["items"][0]["max_read_id"] == 41 + assert stories.called("GetAllReadPeerStoriesRequest") + + +# --------------------------------------------------------------------------- +# story album +# --------------------------------------------------------------------------- + + +class TestAlbum: + async def test_creating_an_album_stores_it(self, live_daemon, client, in_thread, stories): + album = await result( + client, + in_thread, + "story.album.create", + {"chat": "me", "title": "Trips", "story": [7]}, + ) + assert album["title"] == "Trips" + assert stories.story_albums[ME][album["id"]]["stories"] == [7] + + async def test_a_too_long_title_is_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails( + client, + in_thread, + "story.album.create", + {"chat": "me", "title": "a much too long title", "story": [7]}, + ) + assert error.exit_code == EXIT_USAGE + + async def test_an_album_with_no_stories_is_a_usage_error( + self, live_daemon, client, in_thread, stories + ): + error = await fails( + client, in_thread, "story.album.create", {"chat": "me", "title": "Trips"} + ) + assert error.exit_code == EXIT_USAGE + + async def test_one_rpc_backs_all_four_edits(self, live_daemon, client, in_thread, stories): + stories.add_album(ME, 1, "Trips", [7]) + await result( + client, + in_thread, + "story.album.edit", + {"chat": "me", "album_id": 1, "title": "Trips 2026", "add": [8], "remove": [7]}, + ) + entry = stories.story_albums[ME][1] + assert entry["title"] == "Trips 2026" + assert entry["stories"] == [8] + assert len(stories.called("UpdateAlbumRequest")) == 1 + + async def test_an_edit_that_changes_nothing_is_a_usage_error( + self, live_daemon, client, in_thread, stories + ): + stories.add_album(ME, 1, "Trips", [7]) + error = await fails(client, in_thread, "story.album.edit", {"chat": "me", "album_id": 1}) + assert error.exit_code == EXIT_USAGE + + async def test_listing_albums(self, live_daemon, client, in_thread, stories): + stories.add_album(ME, 1, "Trips", [7]) + stories.add_album(ME, 2, "Food", [7]) + page = await paged(client, in_thread, "story.album.list", {"chat": "me"}) + assert [album["title"] for album in page["items"]] == ["Trips", "Food"] + + async def test_a_matching_hash_reports_already(self, live_daemon, client, in_thread, stories): + stories.add_album(ME, 1, "Trips", [7]) + envelope = await call( + client, in_thread, "story.album.list", {"chat": "me", "hash": stories.story_albums_hash} + ) + assert envelope["result"] == [] + assert envelope["meta"]["already"] is True + + async def test_deleting_an_album_keeps_the_stories( + self, live_daemon, client, in_thread, stories + ): + stories.add_album(ME, 1, "Trips", [7]) + await result(client, in_thread, "story.album.delete", {"chat": "me", "album_id": 1}) + assert 1 not in stories.story_albums[ME] + assert 7 in stories.peer_stories[ME] + + async def test_reordering_is_a_full_replace(self, live_daemon, client, in_thread, stories): + stories.add_album(ME, 1, "Trips", [7]) + stories.add_album(ME, 2, "Food", [7]) + order = await result( + client, in_thread, "story.album.reorder", {"chat": "me", "album_id": [2, 1]} + ) + assert order["order"] == [2, 1] + assert stories.story_album_order[ME] == [2, 1] + + async def test_reorder_with_no_ids_is_a_usage_error( + self, live_daemon, client, in_thread, stories + ): + error = await fails(client, in_thread, "story.album.reorder", {"chat": "me"}) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# story viewer / blocklist +# --------------------------------------------------------------------------- + + +class TestViewers: + async def test_the_viewers_come_back_with_their_reactions( + self, live_daemon, client, in_thread, stories + ): + page = await paged(client, in_thread, "story.viewer.list", {"chat": "@alice", "id": 42}) + assert [row["user_id"] for row in page["items"]] == [BOB, ME] + assert page["items"][0]["reaction"] == "🔥" + + async def test_the_source_rpc_is_reported(self, live_daemon, client, in_thread, stories): + envelope = await call(client, in_thread, "story.viewer.list", {"chat": "@alice", "id": 42}) + assert any("getStoryViewsList" in w for w in envelope["meta"]["warnings"]) + + async def test_a_channel_story_uses_the_reactions_rpc( + self, live_daemon, client, in_thread, stories + ): + stories.add_story(CHANNEL_ID, story_id=5, caption="channel") + stories.add_story_viewer(CHANNEL_ID, 5, BOB, reaction="👍") + envelope = await call( + client, in_thread, "story.viewer.list", {"chat": str(CHANNEL_ID), "id": 5} + ) + assert stories.called("GetStoryReactionsListRequest") + assert not stories.called("GetStoryViewsListRequest") + assert any("getStoryReactionsList" in w for w in envelope["meta"]["warnings"]) + + async def test_the_search_reaches_the_server(self, live_daemon, client, in_thread, stories): + await paged( + client, in_thread, "story.viewer.list", {"chat": "@alice", "id": 42, "q": "bobby"} + ) + assert stories.called("GetStoryViewsListRequest")[0].q == "bobby" + + async def test_the_cursor_carries_the_opaque_offset( + self, live_daemon, client, in_thread, stories + ): + page = await paged( + client, in_thread, "story.viewer.list", {"chat": "@alice", "id": 42}, limit=1 + ) + assert page["has_more"] is True + await paged( + client, + in_thread, + "story.viewer.list", + {"chat": "@alice", "id": 42}, + limit=1, + cursor=page["next_cursor"], + ) + assert stories.called("GetStoryViewsListRequest")[1].offset == "page2" + + async def test_csv_is_the_export_the_gui_has_no_button_for( + self, live_daemon, client, in_thread, stories, tmp_path + ): + target = tmp_path / "viewers.csv" + await paged( + client, + in_thread, + "story.viewer.list", + {"chat": "@alice", "id": 42, "csv_out": str(target)}, + ) + rows = target.read_text().splitlines() + assert rows[0] == "id,username,name,date,reaction,blocked,kind" + assert rows[1].startswith(f"{BOB},") + + async def test_hide_from_adds_the_viewer_to_the_blocklist( + self, live_daemon, client, in_thread, stories + ): + await paged( + client, + in_thread, + "story.viewer.list", + {"chat": "@alice", "id": 42, "hide_from": ["@bobby"]}, + ) + assert stories.called("BlockRequest")[0].my_stories_from is True + + +class TestBlocklist: + async def test_adding_uses_the_story_only_flag(self, live_daemon, client, in_thread, stories): + changed = await result(client, in_thread, "story.blocklist.set", {"user": ["@bobby"]}) + assert changed["added"] == [BOB] + assert stories.called("BlockRequest")[0].my_stories_from is True + + async def test_removing_is_the_inverse(self, live_daemon, client, in_thread, stories): + stories.block(BOB, stories=True) + changed = await result( + client, in_thread, "story.blocklist.set", {"user": ["@bobby"], "remove": True} + ) + assert changed["removed"] == [BOB] + assert stories.blocked_stories == {} + + async def test_removing_somebody_absent_is_already( + self, live_daemon, client, in_thread, stories + ): + envelope = await call( + client, in_thread, "story.blocklist.set", {"user": ["@bobby"], "remove": True} + ) + assert envelope["result"]["already"] is True + + async def test_replace_overwrites_in_one_rpc(self, live_daemon, client, in_thread, stories): + stories.block(ALICE, stories=True) + changed = await result( + client, in_thread, "story.blocklist.set", {"user": ["@bobby"], "replace": True} + ) + assert changed["total"] == 1 + assert list(stories.blocked_stories) == [BOB] + assert not stories.called("BlockRequest") + + async def test_the_list_is_its_own_blocklist(self, live_daemon, client, in_thread, stories): + stories.block(BOB, stories=True) + page = await paged(client, in_thread, "story.blocklist.list", {}) + assert page["items"][0]["user_id"] == BOB + assert stories.called("GetBlockedRequest")[0].my_stories_from is True + + async def test_no_user_is_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.blocklist.set", {"user": []}) + assert error.exit_code == EXIT_USAGE + + +# --------------------------------------------------------------------------- +# story can-post / stealth / search / report / stats +# --------------------------------------------------------------------------- + + +class TestCanPost: + async def test_the_preflight_reports_the_free_slots( + self, live_daemon, client, in_thread, stories + ): + check = await result(client, in_thread, "story.can-post", {}) + assert check["can_post"] is True + assert check["count_remains"] == 3 + + async def test_the_limits_come_from_the_app_config( + self, live_daemon, client, in_thread, stories + ): + stories.app_config = { + "story_expiring_limit_default": 3, + "story_expiring_limit_premium": 100, + "stories_albums_limit": 12, + } + check = await result(client, in_thread, "story.can-post", {}) + assert check["limits"]["expiring_limit"] == 3 + assert check["limits"]["albums_limit"] == 12 + assert "expiring_limit" in check["limits"]["premium_unlocks"] + + async def test_a_refusal_is_named_rather_than_raw( + self, live_daemon, client, in_thread, stories + ): + from telethon.errors import RPCError + + stories.fail_next( + "CanSendStoryRequest", + RPCError(request=None, message="STORY_SEND_FLOOD_WEEKLY_86400", code=420), + ) + check = await result(client, in_thread, "story.can-post", {}) + assert check.get("can_post", False) is False + assert check["reason"] == "STORY_SEND_FLOOD_WEEKLY" + assert check["retry_after"] == 86400 + + async def test_chats_lists_where_i_may_post(self, live_daemon, client, in_thread, stories): + from fake_telethon import make_channel + + stories.chats_to_send = [make_channel(CHANNEL, title="News")] + check = await result(client, in_thread, "story.can-post", {"chats": True}) + assert check["chats"][0]["title"] == "News" + + +class TestStealth: + async def test_status_only_reads(self, live_daemon, client, in_thread, stories): + mode = await result(client, in_thread, "story.stealth.set", {"status": True}) + assert mode.get("active", False) is False + assert not stories.called("ActivateStealthModeRequest") + + async def test_activating_sets_both_windows(self, live_daemon, client, in_thread, stories): + mode = await result(client, in_thread, "story.stealth.set", {"past": True, "future": True}) + assert (mode["past"], mode["future"]) == (True, True) + assert mode["active"] is True + request = stories.called("ActivateStealthModeRequest")[0] + assert (request.past, request.future) == (True, True) + + async def test_a_flood_wait_is_reported_as_the_cooldown( + self, live_daemon, client, in_thread, stories + ): + stories.flood("ActivateStealthModeRequest", 42) + error = await fails(client, in_thread, "story.stealth.set", {"past": True}) + assert error.exit_code == EXIT_PERMISSION + assert "42" in str(error) + + +class TestSearch: + async def test_a_hashtag_search_returns_public_stories( + self, live_daemon, client, in_thread, stories + ): + from fake_telethon import make_story + from telethon.tl import types + + stories.public_stories = [ + types.FoundStory( + peer=types.PeerUser(user_id=ALICE), story=make_story(60, caption="#berlin") + ) + ] + page = await paged(client, in_thread, "story.search", {"hashtag": "berlin"}) + assert page["items"][0]["caption"] == "#berlin" + assert stories.called("SearchPostsRequest")[0].hashtag == "berlin" + + async def test_a_venue_search_builds_the_area(self, live_daemon, client, in_thread, stories): + await paged(client, in_thread, "story.search", {"venue": "foursquare:4ac"}) + area = stories.called("SearchPostsRequest")[0].area + assert (type(area).__name__, area.venue_id) == ("MediaAreaVenue", "4ac") + + async def test_a_geo_search_needs_an_address(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.search", {"geo": "52.5,13.4"}) + assert error.exit_code == EXIT_USAGE + + async def test_a_geo_search_with_an_address_works( + self, live_daemon, client, in_thread, stories + ): + await paged(client, in_thread, "story.search", {"geo": "52.5,13.4", "address": "DE,Berlin"}) + area = stories.called("SearchPostsRequest")[0].area + assert area.address.country_iso2 == "DE" + + async def test_two_criteria_are_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails( + client, in_thread, "story.search", {"hashtag": "berlin", "venue": "f:1"} + ) + assert error.exit_code == EXIT_USAGE + + +class TestReport: + async def test_the_first_step_returns_the_menu(self, live_daemon, client, in_thread, stories): + report = await result(client, in_thread, "story.report", {"chat": "@alice", "id": ["42"]}) + assert report["result"] == "choose_option" + assert report["options"][0]["text"] == "Spam" + + async def test_the_second_step_reports(self, live_daemon, client, in_thread, stories): + report = await result( + client, + in_thread, + "story.report", + {"chat": "@alice", "id": ["42"], "option": "AQ=="}, + ) + assert report["reported"] is True + + async def test_no_id_is_a_usage_error(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.report", {"chat": "@alice", "id": []}) + assert error.exit_code == EXIT_USAGE + + +class TestStats: + async def test_an_async_graph_is_resolved_before_it_is_reported( + self, live_daemon, client, in_thread, stories + ): + stats = await result(client, in_thread, "story.stats.get", {"chat": "me", "id": 7}) + assert stats["views_graph"] == {"columns": []} + assert stats["reactions_by_emotion_graph"] == {"columns": ["reactions"]} + assert stories.called("LoadAsyncGraphRequest") + + async def test_forwards_lists_the_public_reposts(self, live_daemon, client, in_thread, stories): + stats = await result( + client, in_thread, "story.stats.get", {"chat": "me", "id": 7, "forwards": True} + ) + assert stats["forwards"][0]["kind"] == "story" + + +# --------------------------------------------------------------------------- +# story live / export / watch +# --------------------------------------------------------------------------- + + +class TestLive: + async def test_no_live_story_is_not_found(self, live_daemon, client, in_thread, stories): + error = await fails(client, in_thread, "story.live.get", {"chat": "@alice"}) + assert error.exit_code == EXIT_NOT_FOUND + + async def test_a_live_story_is_reported_with_a_layer_warning( + self, live_daemon, client, in_thread, stories + ): + from telethon.tl import types + + stories.peer_stories[ALICE][44] = types.StoryItemSkipped( + id=44, date=None, expire_date=None, live=True + ) + envelope = await call(client, in_thread, "story.live.get", {"chat": "@alice"}) + assert envelope["result"]["story_id"] == 44 + assert any("group call" in w for w in envelope["meta"]["warnings"]) + + async def test_starting_without_rtmp_warns_about_the_silence( + self, live_daemon, client, in_thread, stories + ): + envelope = await call(client, in_thread, "story.live.start", {}) + assert envelope["result"]["story_id"] > 0 + assert any("media engine" in w for w in envelope["meta"]["warnings"]) + + async def test_rtmp_prints_the_ingest_url(self, live_daemon, client, in_thread, stories): + live = await result(client, in_thread, "story.live.start", {"rtmp": True}) + assert live["rtmp_url"] == stories.rtmp_url + assert live["rtmp_key"] == stories.rtmp_key + assert stories.called("GetGroupCallStreamRtmpUrlRequest")[0].live_story is True + + async def test_dry_run_starts_nothing(self, live_daemon, client, in_thread, stories): + envelope = await call(client, in_thread, "story.live.start", {}, dry_run=True) + assert envelope["result"]["dry_run"] is True + assert not stories.called("StartLiveRequest") + + +class TestExport: + async def test_the_archive_is_written_to_disk( + self, live_daemon, client, in_thread, stories, tmp_path + ): + export = await result( + client, + in_thread, + "story.export", + {"chat": "me", "out": str(tmp_path), "with_media": False, "jsonl": True}, + ) + assert export["count"] == 1 + written = (tmp_path / f"stories-{ME}.jsonl").read_text().strip().splitlines() + assert json.loads(written[0])["id"] == 7 + + async def test_max_stories_caps_the_walk( + self, live_daemon, client, in_thread, stories, tmp_path + ): + stories.add_story(ME, story_id=8, caption="two", archived=True) + export = await result( + client, + in_thread, + "story.export", + {"chat": "me", "out": str(tmp_path), "with_media": False, "max_stories": 1}, + ) + assert export["count"] == 1 + + +class TestWatch: + async def test_a_raw_story_update_reaches_the_stream(self, live_daemon, world): + """`story watch` reads the same bus `watch --events story` reads.""" + import asyncio + + from telethon.tl import types + + from tlgr.daemon.events import normalise_update + + event_type, payload, chat_id, _sender = normalise_update( + "work", + types.UpdateStory( + peer=types.PeerUser(user_id=ALICE), story=types.StoryItemDeleted(id=42) + ), + ) + assert (event_type, payload["kind"], chat_id) == ("story_new", "story.new", ALICE) + + subscriber = live_daemon.bus.subscribe("work", types=(event_type,)) + try: + live_daemon.bus.emit("work", event_type, payload, chat_id=chat_id) + envelope = await asyncio.wait_for(subscriber.queue.get(), timeout=1) + finally: + live_daemon.bus.unsubscribe(subscriber) + assert envelope.payload["story_id"] == 42 + + def test_every_story_update_class_is_named(self): + from telethon.tl import types + + from tlgr.daemon.events import normalise_update + + kinds = { + normalise_update("work", update)[1]["kind"] + for update in ( + types.UpdateStory(peer=types.PeerUser(user_id=1), story=None), + types.UpdateStoryID(id=1, random_id=2), + types.UpdateReadStories(peer=types.PeerUser(user_id=1), max_id=3), + types.UpdateNewStoryReaction( + story_id=1, peer=types.PeerUser(user_id=1), reaction=None + ), + types.UpdateSentStoryReaction( + peer=types.PeerUser(user_id=1), story_id=1, reaction=None + ), + types.UpdateStoriesStealthMode(stealth_mode=types.StoriesStealthMode()), + ) + } + assert kinds == { + "story.new", + "story.id-assigned", + "story.read", + "story.reaction-received", + "story.reaction-sent", + "story.stealth", + } + + def test_an_ordinary_update_is_not_a_story_event(self): + from telethon.tl import types + + from tlgr.daemon.events import normalise_update + + # It is an ordinary message update, so it normalises as one — the + # story branch is keyed off the taxonomy, not off a substring. + event_type, _payload, _chat, _sender = normalise_update( + "work", types.UpdateNewMessage(message=None, pts=1, pts_count=1) + ) + assert event_type == "message_new" + + +# --------------------------------------------------------------------------- +# Cross-cutting +# --------------------------------------------------------------------------- + + +class TestGroupShape: + def test_every_story_op_is_registered(self): + from tlgr.registry import by_group + + assert len(by_group("story")) == 31 + + def test_the_privacy_preset_comes_from_the_config(self): + """A preset nobody defined is a usage error, not a silent 'everyone'.""" + import asyncio + + from tlgr.core.errors import UsageError + from tlgr.ops._story import privacy_rules + + class _Ctx: + account = "work" + dry_run = False + request_id = "t" + config = None + + def warn(self, message: str) -> None: ... + + def emit(self, event_type: str, payload: dict, **kwargs: Any) -> None: ... + + with pytest.raises(UsageError): + asyncio.run(privacy_rules(_Ctx(), base="everyone", preset="friends")) + + def test_an_unknown_media_area_type_is_refused(self): + from tlgr.core.errors import UsageError + from tlgr.ops._story import areas_from_json + + with pytest.raises(UsageError): + areas_from_json('[{"type": "not-a-thing"}]') + + def test_a_missing_areas_file_is_a_usage_error(self): + from tlgr.core.errors import UsageError + from tlgr.ops._story import areas_from_json + + with pytest.raises(UsageError): + areas_from_json("/nonexistent/areas.json") + + async def test_an_unknown_venue_bot_is_indeterminate( + self, live_daemon, client, in_thread, stories, tmp_path + ): + stories.venue_search_username = "" + error = await fails( + client, + in_thread, + "story.post", + {"file": [photo(tmp_path)], "area_venue": ["gate@50,50,40,10"]}, + ) + assert error.exit_code == EXIT_INDETERMINATE diff --git a/tests/test_parity.py b/tests/test_parity.py index b5b49d0..902900c 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 = 148 +P0_FLOOR = 161 #: The floor for total covered ids. Same rule, weaker guarantee. -COVERED_FLOOR = 1285 +COVERED_FLOOR = 1390 #: 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 @@ -216,6 +216,25 @@ } ) +#: Every P0 catalog id PR-8's `story` operations cover. Same rule again. +PR8_P0_IDS = frozenset( + { + "stories.delete", + "stories.feed-all", + "stories.get-by-id", + "stories.mark-read", + "stories.peer-active", + "stories.post-photo", + "stories.post-video", + "stories.privacy-close-friends", + "stories.privacy-contacts", + "stories.privacy-everyone", + "stories.react", + "stories.reply", + "stories.share-to-chat", + } +) + #: `(group prefixes, the P0 ids those groups claim)` for each landed PR. #: The P0 ids PR-5's own operations cover, named for the same reason. PR5_P0_IDS = frozenset( @@ -308,6 +327,7 @@ def _admin_group(op_id, spec) -> bool: ("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), + ("pr8", _by_prefix("story."), PR8_P0_IDS), ("pr11", _by_prefix("call.", "vc.", "conference."), PR11_P0_IDS), ) @@ -469,6 +489,23 @@ def test_dialogs_chats_is_fully_accounted_for(self, report): def test_the_dialogs_chats_domain_is_no_longer_waived_wholesale(self): assert "dialogs_chats" not in waivers().domains + def test_stories_is_fully_accounted_for(self, report): + """PR-8's own domain. The 7 remaining ids belong to other groups. + + Story notifications are the `notify` group, the soundtrack save is + the profile group, posting as a business account is a bot connection + — each is waived to the PR that owns that command, so "the story + group is done" is checkable rather than asserted. Close friends and + the live-story call were waived here too until PR-5 and PR-11 landed + and covered them outright. + """ + stats = report.by_domain["stories"] + assert stats["accounted_percent"] == 100.0 + assert stats["covered"] >= 113 + + def test_the_stories_domain_is_no_longer_waived_wholesale(self): + assert "stories" not in waivers().domains + def test_media_files_is_fully_accounted_for(self, report): """PR-6's own domain. The 22 remaining ids belong to other groups. diff --git a/tlgr/daemon/app.py b/tlgr/daemon/app.py index e9d86f7..b7adab2 100644 --- a/tlgr/daemon/app.py +++ b/tlgr/daemon/app.py @@ -179,7 +179,8 @@ async def on_update(event: Any) -> None: # builders drop service messages, topic ids and every action kind # Telethon does not model, so a stream built on them can only ever # show a subset of what the GUI shows; `normalise` names all 163 - # update constructors instead (docs/design/EVENTS.md). + # update constructors instead (docs/design/EVENTS.md). The six + # story updates, which have no builder at all, arrive here too. register(on_update, tl_events.Raw()) except Exception as exc: # pragma: no cover - a fake client has no builders log.debug("could not register the raw Telethon handler for %s: %s", alias, exc) diff --git a/tlgr/daemon/events.py b/tlgr/daemon/events.py index 8a6a3b5..a25c24b 100644 --- a/tlgr/daemon/events.py +++ b/tlgr/daemon/events.py @@ -57,8 +57,24 @@ #: The taxonomy, as a tuple, for the callers that want to iterate it. The #: table itself is `tlgr.core.eventtypes`, which `ops/` and the doc generator #: read too — `daemon/` must not be the only place that knows the vocabulary. +#: The five `story_*` types live there with every other name; PR-8 added the +#: payload shaping below, not a second vocabulary. EVENT_TYPES: tuple[str, ...] = tuple(sorted(eventtypes.TYPES)) +#: Raw story `Update*` class name → the fine-grained `kind` its payload +#: carries. The bus *type* comes from `eventtypes`, which already maps all six +#: constructors; two of them share `story_reaction`, and `kind` is what tells +#: a received reaction from one this account sent. Matched by class name so +#: this module still imports no Telethon. +_STORY_KINDS: dict[str, str] = { + "UpdateStory": "story.new", + "UpdateStoryID": "story.id-assigned", + "UpdateReadStories": "story.read", + "UpdateNewStoryReaction": "story.reaction-received", + "UpdateSentStoryReaction": "story.reaction-sent", + "UpdateStoriesStealthMode": "story.stealth", +} + _HEARTBEAT_SECONDS = 15.0 _STATE_FLUSH_SECONDS = 5.0 @@ -297,6 +313,36 @@ def normalise_update( } return event_type, payload, chat_id, None + if event_type in ("story_new", "story_id", "story_read", "story_reaction", "story_stealth"): + payload = {"kind": _STORY_KINDS.get(name, event_type)} + if chat_id is not None: + payload["peer"] = chat_id + story = getattr(update, "story", None) + story_id = getattr(story, "id", None) if story is not None else None + if story_id is None: + story_id = getattr(update, "story_id", None) + if story_id is None and event_type == "story_id": + story_id = getattr(update, "id", None) + if story_id is not None: + payload["story_id"] = int(story_id) + max_id = _int(getattr(update, "max_id", None)) + if max_id is not None: + payload["max_read_id"] = max_id + reaction = getattr(update, "reaction", None) + if reaction is not None: + emoticon = getattr(reaction, "emoticon", None) + document = getattr(reaction, "document_id", None) + payload["reaction"] = ( + str(emoticon) if emoticon else (f"custom:{document}" if document else "?") + ) + stealth = getattr(update, "stealth_mode", None) + if stealth is not None: + payload["stealth_mode"] = { + "active_until_unix": _epoch(getattr(stealth, "active_until_date", None)), + "cooldown_until_unix": _epoch(getattr(stealth, "cooldown_until_date", None)), + } + return event_type, payload, chat_id, None + # Everything else is delivered as the update's own fields, JSON-safe. The # taxonomy says so per type, so a consumer is never guessing. payload = tl_to_builtins(update) @@ -381,6 +427,11 @@ def normalise( return None +def _epoch(value: Any) -> int | None: + timestamp = getattr(value, "timestamp", None) + return int(timestamp()) if callable(timestamp) else None + + def _event_kind(event: Any) -> str | None: """The tlgr type name for a Telethon *high-level* event object. diff --git a/tlgr/data/parity_waivers.toml b/tlgr/data/parity_waivers.toml index 49cfb12..a9654ad 100644 --- a/tlgr/data/parity_waivers.toml +++ b/tlgr/data/parity_waivers.toml @@ -17,10 +17,6 @@ final_pr = 12 # Whole domains that no PR has migrated yet. Each becomes its own group PR. # --------------------------------------------------------------------------- -[[domain]] -name = "stories" -pr = 8 -reason = "the story group lands in PR-8, after media." [[domain]] name = "bots_inline_payments" @@ -65,16 +61,6 @@ 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 @@ -277,11 +263,6 @@ id = "messages-core.paid-messages-group-price" pr = 7 reason = "The per-group Star price is a supergroup setting (PR-7)." -[[id]] -id = "messages-core.search-hashtag-stories" -pr = 8 -reason = "Hashtag search over public stories is the story surface (PR-8)." - [[id]] id = "messages-core.url-authorization" pr = 10 @@ -594,11 +575,6 @@ id = "location.viewed-receipt" pr = 4 reason = "a live-location view receipt arrives as an update (PR-4)." -[[id]] -id = "reaction.story-list" -pr = 8 -reason = "story reactions are the `story` surface (PR-8)." - [[id]] id = "stars.balance" pr = 12 @@ -751,6 +727,44 @@ id = "theme.cloud-themes" pr = 12 reason = "Cloud themes are the `settings` group (PR-12)." +# --------------------------------------------------------------------------- +# The stories ids PR-8 does not own. Each names the group that does. +# --------------------------------------------------------------------------- + +[[id]] +id = "stories.boost-status" +pr = 7 +reason = "Boost levels are the `boost` group (PR-7); `story can-post` reports the gate." + +[[id]] +id = "stories.business-story" +pr = 10 +reason = "Posting for a business account goes through a bot connection (PR-10)." + +[[id]] +id = "stories.notify-exceptions" +pr = 12 +reason = "Per-peer notification exceptions are the `notify` group (PR-12)." + +[[id]] +id = "stories.notify-global" +pr = 12 +reason = "Global story notification settings are `notify set` (PR-12)." + +[[id]] +id = "stories.notify-peer" +pr = 12 +reason = "Per-peer story notifications are `notify set --stories` (PR-12)." + +[[id]] +id = "stories.notify-reactions" +pr = 12 +reason = "Notifications for reactions to my stories are `notify set` (PR-12)." + +[[id]] +id = "stories.story-music-save" +pr = 12 +reason = "Saving a story's soundtrack to the profile is the profile group (PR-12)." # --- PR-11 (calls) --------------------------------------------------------- # The call domain is migrated; what is left inside it belongs to other groups @@ -781,11 +795,6 @@ id = "groupcall.admin-right-manage-call" pr = 7 reason = "manage_call is one keyword in the admin-rights vocabulary owned by `chat admin promote` (PR-7)." -[[id]] -id = "livestory.start-rtmp" -pr = 8 -reason = "Starting a live story is stories.startLive (PR-8); the RTMP credentials half is already covered here by `vc rtmp get --live-story`." - [[id]] id = "calls.top-callers" pr = 5 @@ -865,11 +874,6 @@ id = "contacts-users.user-business-greeting-away" pr = 12 reason = "Business greeting and away messages are the `business` group (PR-12)." -[[id]] -id = "contacts-users.user-stories" -pr = 8 -reason = "A user's stories are the `story` group (PR-8); hiding them is `user hide-stories`." - [[id]] id = "contacts-users.people-you-may-know" pr = 7 diff --git a/tlgr/models/__init__.py b/tlgr/models/__init__.py index 1f9b6e3..e52e6cd 100644 --- a/tlgr/models/__init__.py +++ b/tlgr/models/__init__.py @@ -174,8 +174,6 @@ ProfilePhoto, SavedPhoneContact, SignUp, - StoriesHidden, - StoriesHiddenPeer, SuggestedBirthday, TopPeer, TopPeerState, @@ -420,6 +418,36 @@ StickerSetOrder, StickerSetsChanged, ) +from tlgr.models.story import ( + AlbumDeleted, + AlbumOrder, + BlockedStoryUser, + BlocklistChange, + LiveStory, + MediaArea, + StealthMode, + StoriesDeleted, + Story, + StoryAlbum, + StoryEvent, + StoryExport, + StoryFeedPeer, + StoryFwdHeader, + StoryHidden, + StoryHiddenPeer, + StoryLimits, + StoryPinned, + StoryPostCheck, + StoryPrivacy, + StoryReactionResult, + StoryRead, + StoryReply, + StoryReport, + StoryShared, + StoryStats, + StoryViewer, + StoryViews, +) from tlgr.models.sync import ( BackfillPage, CatchUpResult, @@ -444,6 +472,8 @@ "AdminResult", "AffiliateBot", "AffiliateResult", + "AlbumDeleted", + "AlbumOrder", "AntiSpamReport", "AppConfigDoc", "ArchiveResult", @@ -462,6 +492,8 @@ "BlockResult", "BlockedPeer", "BlockedSet", + "BlockedStoryUser", + "BlocklistChange", "Boost", "BoostApplied", "BoostStatus", @@ -613,11 +645,13 @@ "LinkResult", "LiveLocation", "LiveStopped", + "LiveStory", "LogLine", "LoginCodes", "LoginEmail", "LoginResult", "MapPreview", + "MediaArea", "MediaEdited", "MediaEvent", "MediaExportResult", @@ -761,14 +795,34 @@ "SponsoredMessage", "SponsoredReport", "StatValue", + "StealthMode", "Sticker", "StickerSet", "StickerSetOrder", "StickerSetsChanged", "StorageCleared", "StorageUsage", - "StoriesHidden", - "StoriesHiddenPeer", + "StoriesDeleted", + "Story", + "StoryAlbum", + "StoryEvent", + "StoryExport", + "StoryFeedPeer", + "StoryFwdHeader", + "StoryHidden", + "StoryHiddenPeer", + "StoryLimits", + "StoryPinned", + "StoryPostCheck", + "StoryPrivacy", + "StoryReactionResult", + "StoryRead", + "StoryReply", + "StoryReport", + "StoryShared", + "StoryStats", + "StoryViewer", + "StoryViews", "StreamChannel", "StreamDownload", "SuggestedBirthday", diff --git a/tlgr/models/contact.py b/tlgr/models/contact.py index a6867a0..5051154 100644 --- a/tlgr/models/contact.py +++ b/tlgr/models/contact.py @@ -52,8 +52,6 @@ "ProfilePhoto", "SavedPhoneContact", "SignUp", - "StoriesHidden", - "StoriesHiddenPeer", "SuggestedBirthday", "TopPeer", "TopPeerState", @@ -332,29 +330,6 @@ class DialogStatus(ContactModel): scanned_dialogs: int | None = None -class StoriesHiddenPeer(ContactModel): - user_id: int = 0 - username: str | None = None - hidden: bool = False - already: bool = False - - -class StoriesHidden(ContactModel): - """SEMANTICS FROZEN (AGENT.md): v1's four keys, plus a bulk tail. - - A single target answers exactly as v1 did. Extra targets appear in - `peers`, so a bulk pass stays one command without changing the shape the - documented single-peer call returns. - """ - - user_id: int = 0 - username: str | None = None - hidden: bool = False - already: bool = False - peers: list[StoriesHiddenPeer] = [] - all_hidden: bool | None = None - - class UserProfile(ContactModel): """`user get` — v1's keys, plus everything `users.getFullUser` carries. diff --git a/tlgr/models/story.py b/tlgr/models/story.py new file mode 100644 index 0000000..79ba3e0 --- /dev/null +++ b/tlgr/models/story.py @@ -0,0 +1,467 @@ +"""Stories: the item, its audience, its overlays, and who watched it. + +Four shapes carry most of this group and each exists because the API makes a +distinction the GUI hides. + +* **`Story`** is one story item. Telegram returns three different TL classes + for one id — the full `storyItem`, a `storyItemSkipped` placeholder in a + feed, and `storyItemDeleted` for one that is gone — so the model carries + `skipped` and `deleted` flags rather than pretending a placeholder is a + story with no caption. +* **`StoryPrivacy`** is the audience as a *base rule plus exceptions*, which + is how `inputPrivacyRule*` vectors actually work and how the GUI's + "Contacts, except Bob" is expressed. Flattening it into a single string + would make that sentence inexpressible. +* **`MediaArea`** is one overlay pill. All eight TL variants are flattened + into one struct with a `type` discriminator, because `story get --areas-out` + has to write JSON that `story post --areas` reads back verbatim: a nested + one-of would make that round trip depend on the caller knowing the variant. +* **`StoryViewer`** is one row of the viewers screen, which mixes plain views, + public forwards and public reposts — `kind` says which, so a caller counting + "people who watched" is not silently counting reposts too. + +Coordinates on a media area are percentages of the media (0–100), which is +what the TL type stores; they are never pixels, because a story's rendered +size is a client decision. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from tlgr.models.base import Model +from tlgr.models.message import MediaSummary, Message, MessageEntity +from tlgr.models.peer import Peer, Photo, User + +__all__ = [ + "AlbumDeleted", + "AlbumOrder", + "BlockedStoryUser", + "BlocklistChange", + "LiveStory", + "MediaArea", + "StealthMode", + "StoriesDeleted", + "Story", + "StoryAlbum", + "StoryEvent", + "StoryExport", + "StoryFeedPeer", + "StoryHidden", + "StoryLimits", + "StoryPinned", + "StoryPostCheck", + "StoryPrivacy", + "StoryReactionResult", + "StoryRead", + "StoryReply", + "StoryReport", + "StoryShared", + "StoryStats", + "StoryViewer", + "StoryViews", +] + +#: The eight `mediaArea*` variants, as one flat vocabulary. +MediaAreaKind = Literal[ + "geo", + "venue", + "reaction", + "channel_post", + "url", + "weather", + "star_gift", + "unknown", +] + +#: The base audience rule. `selected` is Telegram's "Only these people": an +#: empty allow list with this rule shows the story to nobody. +PrivacyBase = Literal["everyone", "contacts", "close-friends", "selected"] + + +class MediaArea(Model): + """One overlay pill on a story, in the round-trippable JSON form. + + `x`/`y` are the centre of the area and `w`/`h` its size, all as + percentages of the media, exactly as `mediaAreaCoordinates` stores them. + """ + + type: MediaAreaKind = "unknown" + x: float = 0.0 + y: float = 0.0 + w: float = 0.0 + h: float = 0.0 + rotation: float = 0.0 + radius: float | None = None + # geo / venue + latitude: float | None = None + longitude: float | None = None + address: dict[str, str] | None = None + title: str | None = None + address_text: str | None = None + provider: str | None = None + venue_id: str | None = None + venue_type: str | None = None + # suggested reaction + reaction: str | None = None + dark: bool = False + flipped: bool = False + # channel post + chat_id: int | None = None + msg_id: int | None = None + # url + url: str | None = None + # weather + emoji: str | None = None + temperature_c: float | None = None + color: int | None = None + # collectible star gift + slug: str | None = None + + +class StoryPrivacy(Model): + """Who may see a story: a base rule, then the exceptions on top of it. + + The lists are *marked* ids, like every other id tlgr emits. They are only + populated on your own stories — the server never tells you the audience of + somebody else's. + """ + + base: PrivacyBase = "everyone" + allow_users: list[int] = [] + allow_chats: list[int] = [] + disallow_users: list[int] = [] + disallow_chats: list[int] = [] + + +class StoryViews(Model): + """The counters under a story. + + `has_viewers` is the honest half: a non-Premium account loses the viewer + list `story_viewers_expire_period` seconds after the story expires, and + an empty list then means "no longer available", not "nobody watched". + """ + + views_count: int = 0 + forwards_count: int | None = None + reactions_count: int | None = None + has_viewers: bool = False + recent_viewers: list[int] = [] + reactions: dict[str, int] = {} + + +class StoryFwdHeader(Model): + """Where a reposted story came from.""" + + from_id: int | None = None + from_name: str | None = None + story_id: int | None = None + modified: bool = False + + +class Story(Model): + """One story. + + `skipped` and `deleted` are the two placeholder states the API has: + a feed hands back `storyItemSkipped` (id, dates and the close-friends flag + only) and a gone story comes back as `storyItemDeleted`. Reporting either + as an ordinary story with empty fields is how a caller ends up believing a + deleted story is a caption-less one. + """ + + id: int + peer_id: int = 0 + peer: Peer | None = None + date: str | None = None + date_unix: int | None = None + expire_date: str | None = None + expire_date_unix: int | None = None + caption: str = "" + entities: list[MessageEntity] = [] + media: MediaSummary | None = None + media_areas: list[MediaArea] = [] + privacy: StoryPrivacy | None = None + public: bool = False + close_friends: bool = False + contacts: bool = False + selected_contacts: bool = False + pinned: bool = False + noforwards: bool = False + edited: bool = False + out: bool = False + min: bool = False + #: A placeholder row in a feed: only id, dates and `close_friends` are set. + skipped: bool = False + #: The story is gone; nothing but `id` is meaningful. + deleted: bool = False + #: A live story (an ongoing broadcast rather than a recorded item). + live: bool = False + fwd_from: StoryFwdHeader | None = None + sent_reaction: str | None = None + albums: list[int] = [] + music: MediaSummary | None = None + views: StoryViews | None = None + link: str | None = None + translation: str | None = None + + +class StoryAlbum(Model): + """A profile album — the named chips above the story grid.""" + + id: int + title: str = "" + icon: Photo | None = None + stories: list[int] = [] + stories_count: int | None = None + + +class AlbumDeleted(Model): + peer: int = 0 + album_id: int = 0 + ok: bool = True + + +class AlbumOrder(Model): + peer: int = 0 + order: list[int] = [] + ok: bool = True + + +class BlockedStoryUser(Model): + """A row of "Hide my stories from" — a blocklist of its own.""" + + user_id: int + username: str | None = None + name: str = "" + date: str | None = None + date_unix: int | None = None + + +class BlocklistChange(Model): + added: list[int] = [] + removed: list[int] = [] + total: int | None = None + already: bool = False + + +class StoryLimits(Model): + """The app-config numbers that decide what a story may be. + + Every one of them is read from `help.getAppConfig`; none is hardcoded, + because Telegram changes them without changing the layer. + """ + + expiring_limit: int | None = None + sent_weekly_limit: int | None = None + sent_monthly_limit: int | None = None + caption_length_limit: int | None = None + suggested_reactions_limit: int | None = None + area_url_max: int | None = None + albums_limit: int | None = None + album_stories_limit: int | None = None + pinned_to_top_max: int | None = None + viewers_expire_period: int | None = None + stealth_past_period: int | None = None + stealth_future_period: int | None = None + stealth_cooldown_period: int | None = None + #: Which of the above this account's Premium status unlocks. + premium_unlocks: list[str] = [] + + +class StoryPostCheck(Model): + """`story can-post`: the pre-flight the GUI runs before opening the camera.""" + + can_post: bool = False + reason: str = "" + #: Seconds to wait, for a weekly/monthly flood; boosts missing, for a channel. + retry_after: int | None = None + boosts_required: int | None = None + free_slots: int | None = None + count_remains: int | None = None + premium: bool = False + limits: StoryLimits | None = None + chats: list[Peer] = [] + + +class StoryHiddenPeer(Model): + """One row of a bulk `story hide`, in the same keys the single call uses. + + `hidden` and `already` carry no default on purpose: they are the answer, + and `omit_defaults` would drop the false ones (see `StoryHidden`). + """ + + hidden: bool + already: bool + user_id: int = 0 + username: str | None = None + peer_id: int = 0 + + +class StoryHidden(Model): + """The per-account "Hide Stories" toggle, in v1's keys. + + `user_id`/`username` are what `tlgr user hide-stories` printed and stay + spelled that way; `peer_id` is the marked id, for the channels the same + RPC accepts. A single target answers exactly as v1 did; extra targets + appear in `peers`, so a bulk pass stays one command without changing the + shape the documented single-peer call returns. + """ + + #: Neither has a default: `hidden: false` and `already: false` are the + #: answer AGENT.md publishes for `user hide-stories`, and `omit_defaults` + #: would drop exactly the two values a caller has to read. + hidden: bool + already: bool + user_id: int = 0 + username: str | None = None + peer_id: int = 0 + #: Set instead of the peer fields when `--all` collapsed the whole bar. + all: bool = False + peers: list[StoryHiddenPeer] = [] + + +class StoriesDeleted(Model): + peer: int = 0 + deleted_ids: list[int] = [] + + +class StoryPinned(Model): + peer: int = 0 + ids: list[int] = [] + pinned: bool = False + pinned_to_top: list[int] = [] + + +class StoryRead(Model): + peer: int = 0 + max_id: int = 0 + ids: list[int] = [] + ok: bool = True + already: bool = False + #: Ids whose view counter was incremented (`--register-view`). + viewed_ids: list[int] = [] + + +class StoryReactionResult(Model): + peer: int = 0 + story_id: int = 0 + reaction: str = "" + removed: bool = False + #: Set when `--as-message` sent an ordinary reply instead. + msg_id: int | None = None + + +class StoryReply(Model): + chat_id: int = 0 + msg_id: int = 0 + reply_to_story: int = 0 + text: str = "" + message: Message | None = None + + +class StoryShared(Model): + sent: list[Message] = [] + story_id: int = 0 + peer: int = 0 + + +class StoryReport(Model): + """One step of the multi-step report flow.""" + + result: str = "" + title: str = "" + options: list[dict[str, str]] = [] + comment_required: bool = False + reported: bool = False + + +class StealthMode(Model): + active_until_date: str | None = None + active_until_unix: int | None = None + cooldown_until_date: str | None = None + cooldown_until_unix: int | None = None + past: bool = False + future: bool = False + active: bool = False + + +class StoryViewer(Model): + """One row of the viewers screen.""" + + kind: Literal["view", "forward", "repost"] = "view" + user_id: int = 0 + user: User | None = None + peer: Peer | None = None + date: str | None = None + date_unix: int | None = None + reaction: str | None = None + blocked: bool = False + blocked_my_stories_from: bool = False + #: For a forward/repost row: where it landed. + msg_id: int | None = None + story_id: int | None = None + + +class StoryFeedPeer(Model): + """One peer in the stories bar.""" + + peer_id: int = 0 + peer: Peer | None = None + max_read_id: int = 0 + stories: list[Story] = [] + unread_count: int = 0 + has_unread: bool = False + live: bool = False + hidden: bool = False + #: Set by `--peers`: the compact `stories.getPeerMaxIDs` answer. + max_id: int | None = None + + +class LiveStory(Model): + """A live story: the ongoing-broadcast form of a story.""" + + story_id: int = 0 + peer: int = 0 + live: bool = True + date: str | None = None + expire_date: str | None = None + call_id: int | None = None + participants_count: int | None = None + streamer: int | None = None + rtmp_stream: bool = False + rtmp_url: str | None = None + rtmp_key: str | None = None + stream_dc_id: int | None = None + messages_enabled: bool | None = None + send_paid_messages_stars: int | None = None + record_start_date: str | None = None + listeners_hidden: bool | None = None + pinned: bool = False + noforwards: bool = False + + +class StoryExport(Model): + count: int = 0 + out_dir: str = "" + files: list[str] = [] + stories: list[Story] = [] + + +class StoryStats(Model): + views_graph: dict[str, Any] | None = None + reactions_by_emotion_graph: dict[str, Any] | None = None + forwards: list[dict[str, Any]] = [] + + +class StoryEvent(Model): + """One frame of `story watch`.""" + + event: str = "story" + kind: str = "" + peer: int = 0 + story_id: int | None = None + ids: list[int] = [] + reaction: str | None = None + max_read_id: int | None = None + stealth_mode: StealthMode | None = None + at: str = "" diff --git a/tlgr/ops/_story.py b/tlgr/ops/_story.py new file mode 100644 index 0000000..de54a38 --- /dev/null +++ b/tlgr/ops/_story.py @@ -0,0 +1,743 @@ +"""The story plumbing: items, audiences, overlays and feed state. + +Four things in this group are genuinely fiddly, and every one of them is here +rather than in `ops/story.py` so that `post`, `edit` and `get` cannot disagree +about them. + +* **Privacy rules are an ordered vector**, not a value. Telegram applies + `[base, allow…, disallow…]` in order, so building it in one place is what + makes "contacts, except Bob" mean the same thing on `story post` and + `story edit`. +* **Media areas round-trip.** `story get --areas-out` writes exactly the JSON + `--areas` reads back, which means the model → TL direction has to rebuild + every variant the TL → model direction can produce. +* **Story items come in three shapes** for one id (`storyItem`, + `storyItemSkipped`, `storyItemDeleted`), and a caller must be able to tell + a placeholder from a story with no caption. +* **The feed is not offset-paginated.** `stories.getAllStories` takes an + opaque `state` plus a `next` flag, so the cursor carries both and a naive + offset cursor would silently restart the walk. + +Telethon is imported inside functions, never at module scope (§2.2). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from tlgr.core.errors import UsageError +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.peer import PeerRef, parse_peer_ref +from tlgr.models.story import ( + MediaArea, + StealthMode, + Story, + StoryAlbum, + StoryFwdHeader, + StoryPrivacy, + StoryViews, +) +from tlgr.ops._common import client +from tlgr.ops._serialize import entity_to_peer, media_summary, message_entities, peer_id_of + +__all__ = [ + "album_model", + "areas_from_json", + "build_areas", + "media_area_model", + "privacy_model", + "privacy_rules", + "stealth_model", + "story_ids", + "story_model", + "views_model", +] + +#: `--period` → the `period` seconds `stories.sendStory` wants. +PERIODS: dict[str, int] = {"6h": 6 * 3600, "12h": 12 * 3600, "24h": 86400, "48h": 48 * 3600} + + +def story_ids(values: Any) -> list[int]: + """Story ids from the CLI, accepting `10-14` ranges like message ids do.""" + from tlgr.ops._common import ids + + return ids(tuple(str(v) for v in (values or ()))) + + +# --------------------------------------------------------------------------- +# Privacy +# --------------------------------------------------------------------------- + +_BASE_RULES = ("everyone", "contacts", "close-friends", "selected") + + +def _split_target(text: str) -> tuple[str, str]: + """`chat:@team` → `("chat", "@team")`; anything else is a user.""" + if text.startswith("chat:"): + return "chat", text[len("chat:") :] + return "user", text + + +async def privacy_rules( + ctx: Any, + *, + base: str, + allow: list[str] | tuple[str, ...] = (), + exclude: list[str] | tuple[str, ...] = (), + preset: str | None = None, +) -> list[Any]: + """The `InputPrivacyRule` vector for an audience, in server order. + + Order is load-bearing: Telegram evaluates the vector front to back, so the + base rule has to come first and the disallow entries last. Emitting them + in the order the flags happened to be typed is how "contacts, except Bob" + becomes "everyone". + """ + from telethon.tl import types + + from tlgr.ops import _send + + if preset: + base, allow, exclude = _preset(ctx, preset, base, list(allow), list(exclude)) + + if base not in _BASE_RULES: + raise UsageError( + f"--privacy: expected one of {', '.join(_BASE_RULES)}, not {base!r}", field="privacy" + ) + + rules: list[Any] = [] + if base == "everyone": + rules.append(types.InputPrivacyValueAllowAll()) + elif base == "contacts": + rules.append(types.InputPrivacyValueAllowContacts()) + elif base == "close-friends": + rules.append(types.InputPrivacyValueAllowCloseFriends()) + + async def targets(values: Any) -> tuple[list[Any], list[int]]: + users: list[Any] = [] + chats: list[int] = [] + for raw in values or (): + kind, reference = _split_target(str(raw)) + peer = await _send.resolve(ctx, parse_peer_ref(reference)) + if kind == "chat": + chats.append(abs(_send.peer_id_of(peer)) % 1_000_000_000_000) + else: + from telethon import utils + + try: + users.append(utils.get_input_user(peer)) + except (TypeError, ValueError) as exc: + raise UsageError( + f"{reference!r} is not a user; use chat:{reference} for a group", + field="allow", + ) from exc + return users, chats + + allow_users, allow_chats = await targets(allow) + if allow_users: + rules.append(types.InputPrivacyValueAllowUsers(users=allow_users)) + if allow_chats: + rules.append(types.InputPrivacyValueAllowChatParticipants(chats=allow_chats)) + + deny_users, deny_chats = await targets(exclude) + if deny_users: + rules.append(types.InputPrivacyValueDisallowUsers(users=deny_users)) + if deny_chats: + rules.append(types.InputPrivacyValueDisallowChatParticipants(chats=deny_chats)) + + if base == "selected" and not allow_users and not allow_chats: + raise UsageError( + "--privacy selected needs at least one --allow, or the story is visible to nobody", + field="allow", + ) + return rules + + +def _preset( + ctx: Any, name: str, base: str, allow: list[str], exclude: list[str] +) -> tuple[str, list[str], list[str]]: + """A named audience from `[story.privacy_presets]` in the config. + + Presets exist because the same eight-person allow list is retyped on every + post otherwise, and a mistyped one is a privacy incident rather than a + typo. + """ + config = getattr(ctx, "config", None) + table: dict[str, Any] = {} + if config is not None: + section = getattr(config, "story", None) or {} + if isinstance(section, dict): + table = section.get("privacy_presets") or {} + else: + table = getattr(section, "privacy_presets", None) or {} + preset = table.get(name) if isinstance(table, dict) else None + if preset is None: + raise UsageError( + f"--privacy-preset: no preset named {name!r} in [story.privacy_presets]", + field="privacy_preset", + ) + if isinstance(preset, str): + return preset, allow, exclude + return ( + str(preset.get("base") or base), + [*preset.get("allow", []), *allow], + [*preset.get("exclude", []), *exclude], + ) + + +def privacy_model(rules: Any) -> StoryPrivacy | None: + """A `privacyValue*` vector as the model. Only your own stories carry one.""" + if not rules: + return None + out = StoryPrivacy() + for rule in rules: + name = type(rule).__name__ + if name == "PrivacyValueAllowAll": + out.base = "everyone" + elif name == "PrivacyValueAllowContacts": + out.base = "contacts" + elif name == "PrivacyValueAllowCloseFriends": + out.base = "close-friends" + elif name == "PrivacyValueAllowUsers": + out.allow_users.extend(int(u) for u in (getattr(rule, "users", None) or [])) + elif name == "PrivacyValueAllowChatParticipants": + out.allow_chats.extend(int(c) for c in (getattr(rule, "chats", None) or [])) + elif name == "PrivacyValueDisallowUsers": + out.disallow_users.extend(int(u) for u in (getattr(rule, "users", None) or [])) + elif name == "PrivacyValueDisallowChatParticipants": + out.disallow_chats.extend(int(c) for c in (getattr(rule, "chats", None) or [])) + # No base rule came back, so the audience *is* the allow list. + has_base = any( + type(rule).__name__ + in ("PrivacyValueAllowAll", "PrivacyValueAllowContacts", "PrivacyValueAllowCloseFriends") + for rule in rules + ) + if not has_base and (out.allow_users or out.allow_chats): + out.base = "selected" + return out + + +# --------------------------------------------------------------------------- +# Media areas +# --------------------------------------------------------------------------- + + +def media_area_model(raw: Any) -> MediaArea: + """One TL media area as the flat, round-trippable model.""" + coordinates = getattr(raw, "coordinates", None) + area = MediaArea( + x=float(getattr(coordinates, "x", 0.0) or 0.0), + y=float(getattr(coordinates, "y", 0.0) or 0.0), + w=float(getattr(coordinates, "w", 0.0) or 0.0), + h=float(getattr(coordinates, "h", 0.0) or 0.0), + rotation=float(getattr(coordinates, "rotation", 0.0) or 0.0), + radius=getattr(coordinates, "radius", None), + ) + name = type(raw).__name__ + geo = getattr(raw, "geo", None) + if geo is not None: + area.latitude = getattr(geo, "lat", None) + area.longitude = getattr(geo, "long", None) + if name == "MediaAreaGeoPoint": + area.type = "geo" + address = getattr(raw, "address", None) + if address is not None: + area.address = { + key: str(value) + for key, value in ( + ("country_iso2", getattr(address, "country_iso2", None)), + ("state", getattr(address, "state", None)), + ("city", getattr(address, "city", None)), + ("street", getattr(address, "street", None)), + ) + if value + } + elif name == "MediaAreaVenue": + area.type = "venue" + area.title = getattr(raw, "title", None) + area.address_text = getattr(raw, "address", None) + area.provider = getattr(raw, "provider", None) + area.venue_id = getattr(raw, "venue_id", None) + area.venue_type = getattr(raw, "venue_type", None) + elif name == "MediaAreaSuggestedReaction": + from tlgr.ops.reaction import name_of + + area.type = "reaction" + area.reaction = name_of(getattr(raw, "reaction", None)) + area.dark = bool(getattr(raw, "dark", False)) + area.flipped = bool(getattr(raw, "flipped", False)) + elif name == "MediaAreaChannelPost": + from tlgr.ops._serialize import marked_id + + area.type = "channel_post" + area.chat_id = marked_id(int(getattr(raw, "channel_id", 0) or 0), "channel") + area.msg_id = getattr(raw, "msg_id", None) + elif name == "MediaAreaUrl": + area.type = "url" + area.url = getattr(raw, "url", None) + elif name == "MediaAreaWeather": + area.type = "weather" + area.emoji = getattr(raw, "emoji", None) + area.temperature_c = getattr(raw, "temperature_c", None) + area.color = getattr(raw, "color", None) + elif name == "MediaAreaStarGift": + area.type = "star_gift" + area.slug = getattr(raw, "slug", None) + return area + + +def _coordinates(area: MediaArea) -> Any: + from telethon.tl import types + + return types.MediaAreaCoordinates( + x=area.x, y=area.y, w=area.w, h=area.h, rotation=area.rotation, radius=area.radius + ) + + +async def _area_to_tl(ctx: Any, area: MediaArea) -> Any: + """The model back into a TL media area, resolving what has to be resolved.""" + from telethon.tl import types + + from tlgr.ops import _send + + coordinates = _coordinates(area) + if area.type == "geo": + address = None + if area.address: + address = types.GeoPointAddress( + country_iso2=str(area.address.get("country_iso2", "")), + state=area.address.get("state"), + city=area.address.get("city"), + street=area.address.get("street"), + ) + return types.MediaAreaGeoPoint( + coordinates=coordinates, + geo=types.GeoPoint( + long=float(area.longitude or 0.0), lat=float(area.latitude or 0.0), access_hash=0 + ), + address=address, + ) + if area.type == "venue": + return types.MediaAreaVenue( + coordinates=coordinates, + geo=types.GeoPoint( + long=float(area.longitude or 0.0), lat=float(area.latitude or 0.0), access_hash=0 + ), + title=str(area.title or ""), + address=str(area.address_text or ""), + provider=str(area.provider or ""), + venue_id=str(area.venue_id or ""), + venue_type=str(area.venue_type or ""), + ) + if area.type == "reaction": + from tlgr.ops.reaction import to_tl + + return types.MediaAreaSuggestedReaction( + coordinates=coordinates, + reaction=to_tl(str(area.reaction or "")), + dark=area.dark or None, + flipped=area.flipped or None, + ) + if area.type == "channel_post": + from tlgr.ops._common import input_channel + + peer = await _send.resolve(ctx, parse_peer_ref(str(area.chat_id))) + return types.InputMediaAreaChannelPost( + coordinates=coordinates, channel=input_channel(peer), msg_id=int(area.msg_id or 0) + ) + if area.type == "url": + return types.MediaAreaUrl(coordinates=coordinates, url=str(area.url or "")) + if area.type == "weather": + return types.MediaAreaWeather( + coordinates=coordinates, + emoji=str(area.emoji or ""), + temperature_c=float(area.temperature_c or 0.0), + color=int(area.color or 0), + ) + if area.type == "star_gift": + return types.MediaAreaStarGift(coordinates=coordinates, slug=str(area.slug or "")) + raise UsageError(f"unknown media area type {area.type!r}", field="areas") + + +def _rect(text: str, flag: str) -> tuple[float, float, float, float, float, float | None]: + """`X,Y,W,H[,ROT[,RADIUS]]` as floats. Percentages of the media, not pixels.""" + parts = [p.strip() for p in text.split(",") if p.strip() != ""] + if len(parts) < 4: + raise UsageError(f"{flag}: the position must be X,Y,W,H", field=flag.lstrip("-")) + try: + numbers = [float(p) for p in parts] + except ValueError as exc: + raise UsageError( + f"{flag}: {text!r} is not a X,Y,W,H rectangle", field=flag.lstrip("-") + ) from exc + x, y, w, h = numbers[:4] + rotation = numbers[4] if len(numbers) > 4 else 0.0 + radius = numbers[5] if len(numbers) > 5 else None + return x, y, w, h, rotation, radius + + +def _split_spec(value: str, flag: str) -> tuple[str, str]: + """`payload@X,Y,W,H` → `(payload, rect)`; the last `@` wins.""" + payload, sep, rect = value.rpartition("@") + if not sep: + raise UsageError( + f"{flag}: expected PAYLOAD@X,Y,W,H — got {value!r}", field=flag.lstrip("-") + ) + return payload, rect + + +def _area(flag: str, value: str, **fields: Any) -> tuple[MediaArea, str]: + payload, rect = _split_spec(value, flag) + x, y, w, h, rotation, radius = _rect(rect, flag) + return MediaArea(x=x, y=y, w=w, h=h, rotation=rotation, radius=radius, **fields), payload + + +async def build_areas( + ctx: Any, + *, + areas_file: str | None = None, + geo: tuple[str, ...] = (), + venue: tuple[str, ...] = (), + venue_near: str | None = None, + venue_pick: int = 0, + url: tuple[str, ...] = (), + reaction: tuple[str, ...] = (), + post: tuple[str, ...] = (), + weather: tuple[str, ...] = (), + gift: tuple[str, ...] = (), +) -> tuple[list[Any], list[MediaArea]]: + """`(TL areas, the models that describe them)`. + + `--areas` is the authoritative form because it is what `--areas-out` + writes; the `--area-*` flags are sugar that produces the same models, so a + story can be repositioned by editing the JSON rather than by retyping + every pill. + """ + models: list[MediaArea] = [] + + if areas_file: + models.extend(areas_from_json(areas_file)) + + for value in geo: + area, payload = _area("--area-geo", value, type="geo") + parts = [p.strip() for p in payload.split(",")] + if len(parts) < 2: + raise UsageError("--area-geo: expected LAT,LON[,ADDR]@X,Y,W,H", field="area_geo") + area.latitude, area.longitude = float(parts[0]), float(parts[1]) + if len(parts) > 2: + keys = ("country_iso2", "state", "city", "street") + area.address = {k: v for k, v in zip(keys, parts[2:], strict=False) if v} + models.append(area) + + for value in url: + area, payload = _area("--area-url", value, type="url") + area.url = payload + models.append(area) + + for value in reaction: + area, payload = _area("--area-reaction", value, type="reaction") + emoji, *modifiers = payload.split(":") + area.reaction = emoji + area.dark = "dark" in modifiers + area.flipped = "flipped" in modifiers + models.append(area) + + for value in post: + area, payload = _area("--area-post", value, type="channel_post") + chat, _, msg_id = payload.rpartition(":") + if not chat or not msg_id.isdigit(): + raise UsageError("--area-post: expected CHAT:MSG_ID@X,Y,W,H", field="area_post") + from tlgr.ops import _send + + peer = await _send.resolve(ctx, parse_peer_ref(chat)) + area.chat_id = _send.peer_id_of(peer) + area.msg_id = int(msg_id) + models.append(area) + + for value in gift: + area, payload = _area("--area-gift", value, type="star_gift") + area.slug = payload + models.append(area) + + for value in weather: + area, payload = _area("--area-weather", value, type="weather") + if payload.strip().lower() == "auto": + models.append(await _resolve_weather(ctx, area, venue_near)) + continue + parts = [p.strip() for p in payload.split(",")] + if len(parts) < 3: + raise UsageError( + "--area-weather: expected EMOJI,TEMP_C,#AARRGGBB@X,Y,W,H", field="area_weather" + ) + area.emoji = parts[0] + area.temperature_c = float(parts[1]) + area.color = int(parts[2].lstrip("#"), 16) + models.append(area) + + for value in venue: + area, payload = _area("--area-venue", value, type="venue") + models.append(await _resolve_venue(ctx, area, payload, venue_near, venue_pick)) + + return [await _area_to_tl(ctx, area) for area in models], models + + +def areas_from_json(path_or_text: str) -> list[MediaArea]: + """Media areas from the JSON `story get --areas-out` writes. + + A path is read; anything that starts with `[` is taken as inline JSON, so + a script can pipe the array in without a temp file. + """ + import msgspec + + text = path_or_text.strip() + if not text.startswith("["): + try: + text = Path(text).expanduser().read_text(encoding="utf-8") + except OSError as exc: + raise UsageError(f"--areas: {exc.strerror or exc}", field="areas") from exc + try: + return msgspec.json.decode(text.encode(), type=list[MediaArea]) + except (msgspec.DecodeError, msgspec.ValidationError, json.JSONDecodeError) as exc: + raise UsageError(f"--areas: {exc}", field="areas") from exc + + +async def _inline_query(ctx: Any, username: str, query: str, near: str | None) -> Any: + """One inline-bot query, which is how venue and weather areas are built. + + Neither is an API method: the official clients run an inline query against + a bot named in the server config and use the result's `query_id`, so tlgr + does the same rather than inventing a venue id the server will reject. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + point = None + if near: + try: + lat, _, lon = near.partition(",") + point = types.InputGeoPoint(lat=float(lat), long=float(lon)) + except ValueError as exc: + raise UsageError( + "--area-venue-near: expected LAT,LON", field="area_venue_near" + ) from exc + bot = await client(ctx).get_input_entity(username) + return await client(ctx)( + fn.GetInlineBotResultsRequest( + bot=bot, peer=types.InputPeerSelf(), query=query, offset="", geo_point=point + ) + ) + + +async def _config_username(ctx: Any, field: str) -> str: + """The inline bot the server names for venue or weather lookups. + + Venue search is in `help.getConfig`; the weather bot arrived later and + lives in `help.getAppConfig`, so both are consulted rather than assuming + one of them. + """ + from telethon.tl.functions import help as help_fn + + from tlgr.core.errors import NotSupportedError + from tlgr.ops import _media + + config = await client(ctx)(help_fn.GetConfigRequest()) + username = getattr(config, field, None) + if not username: + username = (await _media.app_config(ctx)).get(field) + if not username: + raise NotSupportedError( + f"this account's server config names no {field}, so there is nowhere to ask" + ) + return str(username) + + +async def _resolve_venue( + ctx: Any, area: MediaArea, query: str, near: str | None, pick: int +) -> MediaArea: + username = await _config_username(ctx, "venue_search_username") + result = await _inline_query(ctx, username, query, near) + rows = [ + row + for row in (getattr(result, "results", None) or []) + if type(getattr(row, "send_message", None)).__name__ == "BotInlineMessageMediaVenue" + ] + if not rows: + raise UsageError(f"--area-venue: no venue matched {query!r}", field="area_venue") + if pick >= len(rows): + raise UsageError( + f"--area-venue-pick {pick}: only {len(rows)} venues matched", field="area_venue_pick" + ) + message = rows[pick].send_message + geo = getattr(message, "geo", None) + area.title = getattr(message, "title", None) + area.address_text = getattr(message, "address", None) + area.provider = getattr(message, "provider", None) + area.venue_id = getattr(message, "venue_id", None) + area.venue_type = getattr(message, "venue_type", None) + area.latitude = getattr(geo, "lat", None) + area.longitude = getattr(geo, "long", None) + return area + + +async def _resolve_weather(ctx: Any, area: MediaArea, near: str | None) -> MediaArea: + username = await _config_username(ctx, "weather_search_username") + result = await _inline_query(ctx, username, "", near) + rows = list(getattr(result, "results", None) or []) + if not rows: + raise UsageError( + "--area-weather auto: the weather bot returned nothing for that point", + field="area_weather", + ) + message = getattr(rows[0], "send_message", None) + area.emoji = getattr(rows[0], "title", None) or "🌡" + text = str(getattr(message, "message", "") or getattr(rows[0], "description", "") or "") + digits = "".join(c for c in text if c.isdigit() or c in "-.") + area.temperature_c = float(digits) if digits else 0.0 + area.color = 0xFF000000 + return area + + +# --------------------------------------------------------------------------- +# Items +# --------------------------------------------------------------------------- + + +def views_model(raw: Any) -> StoryViews | None: + if raw is None: + return None + from tlgr.ops.reaction import name_of + + return StoryViews( + views_count=int(getattr(raw, "views_count", 0) or 0), + forwards_count=getattr(raw, "forwards_count", None), + reactions_count=getattr(raw, "reactions_count", None), + has_viewers=bool(getattr(raw, "has_viewers", False)), + recent_viewers=[int(v) for v in (getattr(raw, "recent_viewers", None) or [])], + reactions={ + name_of(getattr(count, "reaction", None)): int(getattr(count, "count", 0) or 0) + for count in (getattr(raw, "reactions", None) or []) + }, + ) + + +def _fwd(raw: Any) -> StoryFwdHeader | None: + if raw is None: + return None + return StoryFwdHeader( + from_id=peer_id_of(getattr(raw, "from_", None)), + from_name=getattr(raw, "from_name", None), + story_id=getattr(raw, "story_id", None), + modified=bool(getattr(raw, "modified", False)), + ) + + +def story_model(raw: Any, *, peer_id: int = 0, peer: Any = None, link: str | None = None) -> Story: + """A `storyItem` / `storyItemSkipped` / `storyItemDeleted` as one model.""" + from tlgr.ops.reaction import name_of + + name = type(raw).__name__ + date = getattr(raw, "date", None) + expire = getattr(raw, "expire_date", None) + story = Story( + id=int(getattr(raw, "id", 0) or 0), + peer_id=peer_id, + peer=entity_to_peer(peer) if peer is not None else None, + date=fmt_dt(date), + date_unix=to_unix(date), + expire_date=fmt_dt(expire), + expire_date_unix=to_unix(expire), + link=link, + deleted=name == "StoryItemDeleted", + skipped=name == "StoryItemSkipped", + live=bool(getattr(raw, "live", False)), + close_friends=bool(getattr(raw, "close_friends", False)), + ) + if story.deleted or story.skipped: + return story + + story.caption = str(getattr(raw, "caption", "") or "") + story.entities = message_entities(raw) + story.media = media_summary(getattr(raw, "media", None)) + story.media_areas = [media_area_model(a) for a in (getattr(raw, "media_areas", None) or [])] + story.privacy = privacy_model(getattr(raw, "privacy", None)) + story.public = bool(getattr(raw, "public", False)) + story.contacts = bool(getattr(raw, "contacts", False)) + story.selected_contacts = bool(getattr(raw, "selected_contacts", False)) + story.pinned = bool(getattr(raw, "pinned", False)) + story.noforwards = bool(getattr(raw, "noforwards", False)) + story.edited = bool(getattr(raw, "edited", False)) + story.out = bool(getattr(raw, "out", False)) + story.min = bool(getattr(raw, "min", False)) + story.fwd_from = _fwd(getattr(raw, "fwd_from", None)) + reaction = getattr(raw, "sent_reaction", None) + story.sent_reaction = name_of(reaction) if reaction is not None else None + story.albums = [int(a) for a in (getattr(raw, "albums", None) or [])] + music = getattr(raw, "music", None) + if music is not None: + from telethon.tl import types as tl + + story.music = media_summary(tl.MessageMediaDocument(document=music)) + story.views = views_model(getattr(raw, "views", None)) + return story + + +def album_model(raw: Any, *, stories: list[int] | None = None) -> StoryAlbum: + from tlgr.ops._serialize import photo_summary + + return StoryAlbum( + id=int(getattr(raw, "album_id", 0) or 0), + title=str(getattr(raw, "title", "") or ""), + icon=photo_summary(getattr(raw, "icon_photo", None)), + stories=list(stories or []), + ) + + +def stealth_model(raw: Any, *, past: bool = False, future: bool = False) -> StealthMode: + import time + + active = getattr(raw, "active_until_date", None) + cooldown = getattr(raw, "cooldown_until_date", None) + active_unix = to_unix(active) + return StealthMode( + active_until_date=fmt_dt(active), + active_until_unix=active_unix, + cooldown_until_date=fmt_dt(cooldown), + cooldown_until_unix=to_unix(cooldown), + past=past, + future=future, + active=bool(active_unix and active_unix > int(time.time())), + ) + + +# --------------------------------------------------------------------------- +# Peers +# --------------------------------------------------------------------------- + + +async def resolve_or_self(ctx: Any, ref: PeerRef | None) -> Any: + """The peer, or this account. Story commands default to your own stories.""" + from telethon.tl import types + + from tlgr.ops import _send + + if ref is None: + return types.InputPeerSelf() + return await _send.resolve(ctx, ref) + + +async def peer_id_for(ctx: Any, peer: Any) -> int: + """The marked id of a resolved peer, resolving `InputPeerSelf` to my own. + + `utils.get_peer_id` has no answer for `inputPeerSelf` — it is "whoever is + logged in" — and reporting the story of `peer: 0` would make `story post` + and `story export` disagree with every other command about what "me" is. + """ + from tlgr.ops import _send + + marked = _send.peer_id_of(peer) + if marked: + return marked + me = await client(ctx).get_me() + return int(getattr(me, "id", 0) or 0) diff --git a/tlgr/ops/story.py b/tlgr/ops/story.py new file mode 100644 index 0000000..b6c291c --- /dev/null +++ b/tlgr/ops/story.py @@ -0,0 +1,3216 @@ +"""The `story` group: post, read, react to and manage stories. + +Stories are the one surface where Telegram's API and its GUI disagree the +most, and the shape of this module follows the API rather than the screen. + +* **Four RPCs are one list.** The GUI shows a peer's stories as one grid with + tabs; the API has `getPeerStories`, `getPinnedStories`, `getStoriesArchive` + and `getAlbumStories`. `story list` is one command with four flags, so a + caller never has to know which tab maps to which method. +* **Reading and being seen are different acts.** `stories.readStories` clears + *your* unread ring; `stories.incrementStoryViews` is what puts you in the + poster's viewer list. v1's story-less world never had to make the + distinction; here `story read` does the first and `--register-view` opts + into the second, because an agent that silently appears in somebody's + viewer list is a privacy bug. +* **The audience is a vector, not a value.** `--privacy` sets the base rule + and `--allow`/`--exclude` layer exceptions on top, in that order, which is + the only way "contacts, except Bob" is expressible. +* **The feed has no offsets.** `stories.getAllStories` pages with an opaque + `state` plus a `next` flag, so `story feed list`'s cursor carries both. + +`user hide-stories` is v1's spelling of `story hide` and keeps working: the +op declares it as a legacy path, so the old invocation resolves to the new +operation rather than to a module that no longer exists. +""" + +from __future__ import annotations + +import csv +import os +from pathlib import Path +from typing import Annotated, Any + +from tlgr.core.errors import ( + NotFoundError, + NotSupportedError, + PermissionError_, + UsageError, +) +from tlgr.core.pagination import PageKind, build_page +from tlgr.core.timefmt import fmt_dt, to_unix +from tlgr.models.base import Request +from tlgr.models.message import Message +from tlgr.models.page import Page +from tlgr.models.peer import PeerRef, UserRef +from tlgr.models.story import ( + AlbumDeleted, + AlbumOrder, + BlockedStoryUser, + BlocklistChange, + LiveStory, + MediaArea, + StealthMode, + StoriesDeleted, + Story, + StoryAlbum, + StoryEvent, + StoryExport, + StoryFeedPeer, + StoryHidden, + StoryHiddenPeer, + StoryLimits, + StoryPinned, + StoryPostCheck, + StoryReactionResult, + StoryRead, + StoryReply, + StoryReport, + StoryShared, + StoryStats, + StoryViewer, +) +from tlgr.ops import _send, _story +from tlgr.ops._common import already, client, random_id, window +from tlgr.ops._params import arg, choice, opt +from tlgr.ops._serialize import entity_to_peer, peer_id_of +from tlgr.ops._spec import OpContext, OperationSpec + +__all__ = [name for name in dir() if name.startswith("SPEC_")] + +_EXAMPLE_STORY: dict[str, Any] = { + "id": 42, + "peer_id": 4242, + "date": "2026-09-03T09:14:07Z", + "date_unix": 1788426847, + "expire_date": "2026-09-04T09:14:07Z", + "caption": "morning", + "public": True, + "views": {"views_count": 128, "reactions_count": 9, "has_viewers": True}, +} + + +# --------------------------------------------------------------------------- +# Shared plumbing +# --------------------------------------------------------------------------- + + +def _entities(result: Any) -> dict[int, Any]: + """`raw id → entity` for the users and chats a story reply carries.""" + table: dict[int, Any] = {} + for entity in ( + *(getattr(result, "users", None) or []), + *(getattr(result, "chats", None) or []), + ): + table[int(getattr(entity, "id", 0) or 0)] = entity + return table + + +def _peer_entity(peer: Any, table: dict[int, Any]) -> Any: + for attribute in ("user_id", "chat_id", "channel_id"): + value = getattr(peer, attribute, None) + if value is not None: + return table.get(int(value)) + return None + + +def _link_story_id(ref: PeerRef | None) -> int | None: + """The story id inside `t.me/<user>/s/<id>` or `tg://…&story=<id>`. + + The peer parser already reduced the link to its peer half and kept the + original text in `raw`, so the id is read back from there rather than by + parsing the link a second time somewhere else. + """ + if ref is None: + return None + raw = str(getattr(ref, "raw", "") or "") + if "story=" in raw: + tail = raw.split("story=", 1)[1].split("&", 1)[0] + return int(tail) if tail.isdigit() else None + parts = [p for p in raw.replace("?", "/").split("/") if p] + for index, part in enumerate(parts[:-1]): + if part == "s" and parts[index + 1].isdigit(): + return int(parts[index + 1]) + return None + + +async def _stories_of(ctx: OpContext, peer: Any, ids: list[int]) -> list[Any]: + """`stories.getStoriesByID`, which is also how a skipped item is hydrated.""" + from telethon.tl.functions import stories as fn + + if not ids: + return [] + result = await client(ctx)(fn.GetStoriesByIDRequest(peer=peer, id=ids)) + return list(getattr(result, "stories", None) or []) + + +async def _require_own_story(ctx: OpContext, peer: Any, story_id: int) -> Any: + """Fetch one story, or say which of the two reasons it is unavailable.""" + found = await _stories_of(ctx, peer, [story_id]) + if not found or type(found[0]).__name__ == "StoryItemDeleted": + raise NotFoundError(f"story {story_id} is not available") + return found[0] + + +def _cover_attributes(attributes: list[Any], cover_ts: float | None) -> list[Any]: + """Put `--cover-ts` on the video attribute, where the server reads it.""" + if cover_ts is None: + return attributes + for attribute in attributes: + if type(attribute).__name__ == "DocumentAttributeVideo": + attribute.video_start_ts = float(cover_ts) + return attributes + + +def _sticker_documents(ids: tuple[int, ...]) -> list[Any] | None: + """`--sticker-doc` as `InputDocument`s. + + Declarative only: the chip says "this media contains stickers" and the + server does not fetch them, which is why an id without an access hash is + enough here and nowhere else. + """ + if not ids: + return None + from telethon.tl import types + + return [types.InputDocument(id=int(i), access_hash=0, file_reference=b"") for i in ids] + + +# --------------------------------------------------------------------------- +# story post +# --------------------------------------------------------------------------- + + +class PrivacyOptions(Request, kw_only=True): + """The audience flags `story post`, `story edit` and `story live start` share. + + A base class rather than a duplicated block: an audience that can be set + on a story must be editable afterwards, and two copies of four flags is + how those two lists drift apart. + """ + + privacy: Annotated[ + str | None, + choice("everyone", "contacts", "close-friends", "selected", help="Audience base rule."), + ] = None + allow: Annotated[ + list[str], + opt("--allow", metavar="USER|chat:CHAT", help="Add to the allow list. Repeatable."), + ] = [] + exclude: Annotated[ + list[str], + opt("--exclude", metavar="USER|chat:CHAT", help="Add to the deny list. Repeatable."), + ] = [] + privacy_preset: Annotated[ + str | None, + opt("--privacy-preset", metavar="NAME", help="Reuse [story.privacy_presets].<name>."), + ] = None + + +class AreaOptions(PrivacyOptions): + """The media-area flags `story post` and `story edit` share. + + Chained onto `PrivacyOptions` rather than mixed in beside it: a msgspec + Struct has one instance layout, so two Struct bases is a TypeError. Every + command that takes areas also takes an audience, so the chain costs + nothing. + """ + + areas: Annotated[ + str | None, + opt("--areas", metavar="PATH", help="Media areas as the JSON `--areas-out` writes."), + ] = None + area_geo: Annotated[ + list[str], + opt("--area-geo", metavar="LAT,LON[,ADDR]@X,Y,W,H", help="Location pill. Repeatable."), + ] = [] + area_venue: Annotated[ + list[str], + opt("--area-venue", metavar="QUERY@X,Y,W,H", help="Venue pill (inline query)."), + ] = [] + area_venue_near: Annotated[ + str | None, + opt("--area-venue-near", metavar="LAT,LON", help="Anchor point for the venue query."), + ] = None + area_venue_pick: Annotated[ + int, opt("--area-venue-pick", metavar="N", help="Which venue result to use.", ge=0) + ] = 0 + area_url: Annotated[ + list[str], opt("--area-url", metavar="URL@X,Y,W,H", help="Link sticker (Premium).") + ] = [] + area_reaction: Annotated[ + list[str], + opt("--area-reaction", metavar="EMOJI@X,Y,W,H", help="Suggested-reaction bubble."), + ] = [] + area_post: Annotated[ + list[str], + opt("--area-post", metavar="CHAT:MSG_ID@X,Y,W,H", help="Channel-post card."), + ] = [] + area_weather: Annotated[ + list[str], + opt("--area-weather", metavar="SPEC@X,Y,W,H", help="Weather widget; `auto` resolves it."), + ] = [] + area_gift: Annotated[ + list[str], opt("--area-gift", metavar="SLUG@X,Y,W,H", help="Collectible star-gift area.") + ] = [] + + +class PostReq(AreaOptions, kw_only=True): + file: Annotated[ + list[str], + arg(0, metavar="FILE", variadic=True, kind="path", help="Media to post, one per story."), + ] = [] + caption: Annotated[str | None, opt("--caption", help="Story caption.")] = None + parse: Annotated[str | None, choice("md", "html", "none", help="Caption formatting.")] = None + entities: Annotated[ + str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.") + ] = None + send_as: Annotated[ + PeerRef | None, + opt("--send-as", metavar="CHAT", kind="peer", help="Post as this channel."), + ] = None + period: Annotated[ + str | None, choice("6h", "12h", "24h", "48h", help="How long it stays active.") + ] = None + pin: Annotated[bool, opt("--pin", help="Keep on my page when it expires.")] = False + protect: Annotated[bool, opt("--protect", help="noforwards: block saving/forwarding.")] = False + album: Annotated[list[int], opt("--album", metavar="ID", help="Add to this album id.")] = [] + music: Annotated[ + str | None, opt("--music", metavar="PATH", kind="path", help="Attach a soundtrack.") + ] = None + cover_ts: Annotated[ + float | None, opt("--cover-ts", metavar="SECONDS", help="Video cover frame.") + ] = None + sticker_doc: Annotated[ + list[int], + opt("--sticker-doc", metavar="ID", help="Declare a sticker baked into the media."), + ] = [] + repost: Annotated[ + str | None, opt("--repost", metavar="PEER:ID", help="Repost somebody else's story.") + ] = None + modified: Annotated[bool, opt("--modified", help="Mark the repost as edited.")] = False + repost_message: Annotated[ + str | None, + opt("--repost-message", metavar="CHAT:MSG_ID", help="'Repost to story' from a message."), + ] = None + as_message: Annotated[ + bool, opt("--as-message", help="Send the media to chats as ordinary messages instead.") + ] = False + until: Annotated[ + list[PeerRef], + opt("--until", metavar="CHAT", kind="peer", help="Destinations for --as-message."), + ] = [] + no_check: Annotated[bool, opt("--no-check", help="Skip the canSendStory pre-flight.")] = False + + +async def _post_media(ctx: OpContext, req: PostReq, source: str) -> Any: + """One `--file` as the `InputMedia` a story wants.""" + media = await _send.input_media(ctx, source) + if type(media).__name__ == "InputMediaUploadedDocument": + media.attributes = _cover_attributes(list(media.attributes or []), req.cover_ts) + stickers = _sticker_documents(tuple(req.sticker_doc)) + if stickers is not None and hasattr(media, "stickers"): + media.stickers = stickers + return media + + +async def _music_document(ctx: OpContext, source: str) -> Any: + """`--music` as an `InputDocument`, by uploading and realising the file.""" + from telethon.tl import types + from telethon.tl.functions import messages as fn + + if source.isdigit(): + raise UsageError( + "--music takes a path: a bare document id carries no access hash, " + "so the server cannot look the soundtrack up", + field="music", + ) + media = await _send.input_media(ctx, source) + result = await client(ctx)(fn.UploadMediaRequest(peer=types.InputPeerSelf(), media=media)) + document = getattr(result, "document", None) + if document is None: + raise UsageError(f"{source} is not an audio file Telegram accepted", field="music") + return types.InputDocument( + id=document.id, access_hash=document.access_hash, file_reference=document.file_reference + ) + + +async def post(ctx: OpContext, req: PostReq) -> Page[Story]: + """Post one story per `--file`, sharing one audience and one period. + + The pre-flight runs *between* items, not only once: the weekly and monthly + story quotas are consumed as the loop runs, and a batch that ignored that + would fail its fourth upload after paying for three. + """ + from telethon.tl.functions import stories as fn + + if not req.file: + raise UsageError("give at least one FILE to post", field="file") + + peer = await _story.resolve_or_self(ctx, req.send_as) + peer_id = await _story.peer_id_for(ctx, peer) + text, entities = _send.body(req.caption, parse=req.parse, entities=req.entities) + rules = await _story.privacy_rules( + ctx, + base=req.privacy or "everyone", + allow=tuple(req.allow), + exclude=tuple(req.exclude), + preset=req.privacy_preset, + ) + areas, area_models = await _story.build_areas( + ctx, + areas_file=req.areas, + geo=tuple(req.area_geo), + venue=tuple(req.area_venue), + venue_near=req.area_venue_near, + venue_pick=req.area_venue_pick, + url=tuple(req.area_url), + reaction=tuple(req.area_reaction), + post=tuple(req.area_post), + weather=tuple(req.area_weather), + gift=tuple(req.area_gift), + ) + if req.repost_message: + areas.append(await _repost_message_area(ctx, req.repost_message)) + + _warn_excluded_mentions(ctx, text, req.exclude) + + fwd_peer, fwd_story = (None, None) + if req.repost: + reference, _, story_id = str(req.repost).rpartition(":") + if not reference or not story_id.isdigit(): + raise UsageError("--repost takes PEER:ID", field="repost") + from tlgr.models.peer import parse_peer_ref + + fwd_peer = await _send.resolve(ctx, parse_peer_ref(reference)) + fwd_story = int(story_id) + + music = await _music_document(ctx, req.music) if req.music else None + period = _story.PERIODS.get(req.period or "24h") + + if req.as_message: + return await _send_as_messages(ctx, req, text, entities) + + items: list[Story] = [] + for index, source in enumerate(req.file): + if not req.no_check: + await _preflight(ctx, peer) + media = await _post_media(ctx, req, source) + updates = await client(ctx)( + fn.SendStoryRequest( + peer=peer, + media=media, + privacy_rules=rules, + pinned=req.pin or None, + noforwards=req.protect or None, + fwd_modified=req.modified or None, + media_areas=areas or None, + caption=text or None, + entities=_send.tl_entities(entities), + random_id=random_id(), + period=period, + fwd_from_id=fwd_peer, + fwd_from_story=fwd_story, + albums=list(req.album) or None, + music=music, + ) + ) + story = _story_from_updates(updates, peer_id=peer_id) + story.media_areas = story.media_areas or area_models + items.append(story) + ctx.emit("story_new", {"peer": peer_id, "story_id": story.id, "index": index}) + return Page(items=items, has_more=False, total=len(items)) + + +def _warn_excluded_mentions(ctx: OpContext, caption: str, exclude: list[str]) -> None: + """Warn when the caption @-mentions somebody the audience shuts out. + + Read off the raw text rather than off the parsed entities: Telegram + resolves a plain `@handle` into a mention server-side, so at send time + there is no entity to inspect and the check would silently never fire. + """ + if not exclude or not caption: + return + import re + + mentioned = {match.lower() for match in re.findall(r"@([A-Za-z0-9_]{4,32})", caption)} + hit = sorted(mentioned & {str(e).lstrip("@").lower() for e in exclude}) + if hit: + ctx.warn( + f"@{', @'.join(hit)} is excluded by the privacy rules and will not " + "see this story, even though the caption mentions them" + ) + + +async def _repost_message_area(ctx: OpContext, spec: str) -> Any: + """`--repost-message CHAT:MSG_ID[@X,Y,W,H]` as a channel-post area.""" + from telethon.tl import types + + from tlgr.models.peer import parse_peer_ref + from tlgr.ops._common import input_channel + + payload, _, rect = spec.partition("@") + chat, _, msg_id = payload.rpartition(":") + if not chat or not msg_id.isdigit(): + raise UsageError("--repost-message takes CHAT:MSG_ID", field="repost_message") + peer = await _send.resolve(ctx, parse_peer_ref(chat)) + coordinates = types.MediaAreaCoordinates(x=50.0, y=50.0, w=80.0, h=30.0, rotation=0.0) + if rect: + numbers = [float(p) for p in rect.split(",")] + coordinates = types.MediaAreaCoordinates( + x=numbers[0], + y=numbers[1], + w=numbers[2], + h=numbers[3], + rotation=numbers[4] if len(numbers) > 4 else 0.0, + ) + return types.InputMediaAreaChannelPost( + coordinates=coordinates, channel=input_channel(peer), msg_id=int(msg_id) + ) + + +async def _send_as_messages(ctx: OpContext, req: PostReq, text: str, entities: Any) -> Page[Story]: + """`--as-message`: the prepared media goes to chats instead of the profile.""" + from telethon.tl.functions import messages as fn + + if not req.until: + raise UsageError("--as-message needs at least one --until CHAT", field="until") + items: list[Story] = [] + for destination in req.until: + peer = await _send.resolve(ctx, destination) + for source in req.file: + media = await _post_media(ctx, req, source) + await client(ctx)( + fn.SendMediaRequest( + peer=peer, + media=media, + message=text, + entities=_send.tl_entities(entities), + random_id=random_id(), + noforwards=req.protect or None, + ) + ) + ctx.warn("--as-message sent the media as ordinary messages; no story was posted") + return Page(items=items, has_more=False, total=0) + + +def _story_from_updates(updates: Any, *, peer_id: int) -> Story: + """The story an `Updates` reply carries, or a stub with the assigned id.""" + for update in getattr(updates, "updates", None) or []: + name = type(update).__name__ + if name == "UpdateStory": + return _story.story_model(getattr(update, "story", None), peer_id=peer_id) + for update in getattr(updates, "updates", None) or []: + if type(update).__name__ == "UpdateStoryID": + return Story(id=int(getattr(update, "id", 0) or 0), peer_id=peer_id) + return Story(id=0, peer_id=peer_id) + + +SPEC_POST = OperationSpec( + id="story.post", + request=PostReq, + response=Page[Story], + impl=post, + summary="Post one or more stories, with audience, media areas and duration", + description=( + "Several FILEs post several stories in one run, sharing the audience, " + "period and pin settings. Vertical media only; overlays other than " + "media areas must already be rendered into the file." + ), + mutating=True, + rate_class="send", + timeout_s=600, + tags=frozenset({"visible-to-others"}), + columns=("id", "peer_id", "expire_date"), + headers=("ID", "Peer", "Expires"), + example={"items": [_EXAMPLE_STORY], "has_more": False}, + example_args="story post morning.jpg --caption 'morning' --privacy contacts", + covers=( + "bots.webapp-share-to-story", + "groups-channels-admin.stories-as-channel", + "stories.area-channel-post", + "stories.area-location", + "stories.area-star-gift", + "stories.area-suggested-reaction", + "stories.area-url", + "stories.area-venue", + "stories.area-weather", + "stories.attached-stickers", + "stories.mention-users", + "stories.post-as-channel", + "stories.post-batch", + "stories.post-keep-on-page", + "stories.post-music", + "stories.post-period", + "stories.post-photo", + "stories.post-protect", + "stories.post-stickers-drawing", + "stories.post-to-album", + "stories.post-video", + "stories.privacy-auto-exceptions", + "stories.privacy-close-friends", + "stories.privacy-contacts", + "stories.privacy-everyone", + "stories.privacy-selected", + "stories.repost", + "stories.repost-message-to-story", + "stories.send-as-message-instead", + ), +) + + +# --------------------------------------------------------------------------- +# story can-post +# --------------------------------------------------------------------------- + +#: app-config key → the `StoryLimits` field it fills. Nothing is hardcoded: +#: Telegram moves these numbers without moving the layer. +_LIMIT_KEYS: dict[str, tuple[str, str]] = { + "story_expiring_limit_default": ("expiring_limit", "story_expiring_limit_premium"), + "stories_sent_weekly_limit_default": ( + "sent_weekly_limit", + "stories_sent_weekly_limit_premium", + ), + "stories_sent_monthly_limit_default": ( + "sent_monthly_limit", + "stories_sent_monthly_limit_premium", + ), + "story_caption_length_limit_default": ( + "caption_length_limit", + "story_caption_length_limit_premium", + ), + "stories_suggested_reactions_limit_default": ( + "suggested_reactions_limit", + "stories_suggested_reactions_limit_premium", + ), +} + +#: app-config key → the `StoryLimits` field, for the ones with no Premium twin. +_FLAT_LIMIT_KEYS: dict[str, str] = { + "stories_area_url_max": "area_url_max", + "stories_albums_limit": "albums_limit", + "stories_album_stories_limit": "album_stories_limit", + "stories_pinned_to_top_count_max": "pinned_to_top_max", + "story_viewers_expire_period": "viewers_expire_period", + "stories_stealth_past_period": "stealth_past_period", + "stories_stealth_future_period": "stealth_future_period", + "stories_stealth_cooldown_period": "stealth_cooldown_period", +} + + +def _limits(config: dict[str, Any], *, premium: bool) -> StoryLimits: + limits = StoryLimits() + for key, (field, premium_key) in _LIMIT_KEYS.items(): + source = premium_key if premium and premium_key in config else key + value = config.get(source) + if isinstance(value, (int, float)): + setattr(limits, field, int(value)) + if premium_key in config and config.get(premium_key) != config.get(key): + limits.premium_unlocks.append(field) + for key, field in _FLAT_LIMIT_KEYS.items(): + value = config.get(key) + if isinstance(value, (int, float)): + setattr(limits, field, int(value)) + limits.premium_unlocks.sort() + return limits + + +#: The RPC errors `stories.canSendStory` answers a refusal with. Layer 227 +#: has no `canSendStoryResult*` union — the server raises — so the reason is +#: read off the error rather than off a result type. +_CANNOT: tuple[str, ...] = ( + "PREMIUM_ACCOUNT_REQUIRED", + "BOOSTS_REQUIRED", + "CHAT_ADMIN_REQUIRED", + "STORIES_TOO_MUCH", + "STORY_SEND_FLOOD_WEEKLY", + "STORY_SEND_FLOOD_MONTHLY", + "STORY_LIVE_ALREADY", +) + + +def _refusal(exc: BaseException) -> tuple[str, int | None]: + """`(reason, the number the message carries)` for a canSendStory failure. + + `STORY_SEND_FLOOD_WEEKLY_%d` and friends carry the wait in the name, and + reporting "an error occurred" while throwing that number away is what + makes a caller retry immediately and get flooded again. + """ + from tlgr.core.errors import strip_numeric_suffix + + raw = str(getattr(exc, "message", "") or exc).upper() + text, number = strip_numeric_suffix(raw) + for reason in _CANNOT: + if reason in text: + return reason, number + return "", number + + +async def _check(ctx: OpContext, peer: Any) -> tuple[Any, str, int | None]: + """`(result, reason, number)` from `stories.canSendStory`.""" + from telethon.errors import RPCError + from telethon.tl.functions import stories as fn + + try: + return await client(ctx)(fn.CanSendStoryRequest(peer=peer)), "", None + except RPCError as exc: + reason, number = _refusal(exc) + if not reason: + raise + return None, reason, number + + +async def _preflight(ctx: OpContext, peer: Any) -> None: + """`stories.canSendStory`, translated into a refusal a human can act on.""" + _count, reason, number = await _check(ctx, peer) + if not reason: + return + detail = f" ({number})" if number is not None else "" + if reason == "PREMIUM_ACCOUNT_REQUIRED": + raise PermissionError_("posting this story needs Telegram Premium") + if reason == "BOOSTS_REQUIRED": + raise PermissionError_(f"posting a story here needs more boosts{detail}") + if reason == "CHAT_ADMIN_REQUIRED": + raise PermissionError_("posting a story here needs the post_stories admin right") + raise PermissionError_(f"cannot post a story right now: {reason}{detail}") + + +class CanPostReq(Request): + send_as: Annotated[ + PeerRef | None, + opt("--send-as", metavar="CHAT", kind="peer", help="Check this channel instead of you."), + ] = None + chats: Annotated[ + bool, opt("--chats", help="Also list every chat where you hold post_stories.") + ] = False + limits: Annotated[bool, opt("--limits/--no-limits", help="Include the limit block.")] = True + + +async def can_post(ctx: OpContext, req: CanPostReq) -> StoryPostCheck: + """The pre-flight the GUI runs before it opens the camera. + + Re-run it immediately before posting: the answer is a snapshot of quotas + that other sessions are spending at the same time. + """ + from telethon.tl.functions import stories as fn + + from tlgr.ops import _media + + peer = await _story.resolve_or_self(ctx, req.send_as) + outcome, reason, number = await _check(ctx, peer) + check = StoryPostCheck( + can_post=outcome is not None, + reason=reason, + count_remains=getattr(outcome, "count_remains", None), + free_slots=getattr(outcome, "count_remains", None), + retry_after=number if reason.startswith("STORY_SEND_FLOOD") else None, + boosts_required=number if reason == "BOOSTS_REQUIRED" else None, + ) + + me = await client(ctx).get_me() + check.premium = bool(getattr(me, "premium", False)) + if req.limits: + check.limits = _limits(await _media.app_config(ctx), premium=check.premium) + if req.chats: + chats = await client(ctx)(fn.GetChatsToSendRequest()) + check.chats = [entity_to_peer(chat) for chat in (getattr(chats, "chats", None) or [])] + return check + + +SPEC_CAN_POST = OperationSpec( + id="story.can-post", + request=CanPostReq, + response=StoryPostCheck, + impl=can_post, + summary="Free story slots, limits, Premium gates and the chats you may post to", + description="Re-run it immediately before posting; the quotas move under you.", + columns=("can_post", "count_remains", "reason"), + headers=("Can post", "Remaining", "Reason"), + example={"can_post": True, "count_remains": 2, "free_slots": 2, "premium": False}, + example_args="story can-post --chats", + covers=( + "stories.can-post", + "stories.chats-to-post", + "stories.limits-config", + "stories.premium-gates", + ), +) + + +# --------------------------------------------------------------------------- +# story edit +# --------------------------------------------------------------------------- + + +class EditReq(AreaOptions, kw_only=True): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")] + id: Annotated[int, arg(1, metavar="ID", help="Story id.")] + caption: Annotated[str | None, opt("--caption", help="New caption.")] = None + parse: Annotated[str | None, choice("md", "html", "none", help="Caption formatting.")] = None + entities: Annotated[ + str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.") + ] = None + file: Annotated[ + str | None, opt("--file", metavar="PATH", kind="path", help="Replace the media.") + ] = None + cover_ts: Annotated[ + float | None, + opt("--cover-ts", metavar="SECONDS", help="New cover frame, without re-uploading."), + ] = None + music: Annotated[ + str | None, opt("--music", metavar="PATH", kind="path", help="Replace the soundtrack.") + ] = None + + +async def edit(ctx: OpContext, req: EditReq) -> Story: + """Change a posted story: only the flags you pass are sent. + + `--cover-ts` alone takes the no-reupload path — the current document is + wrapped in `inputFileStoryDocument` and resent with the new + `video_start_ts`, which is how the GUI moves a cover frame without + spending the upload again. + """ + from telethon.tl import types + from telethon.tl.functions import stories as fn + + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + + caption: str | None = None + entities: Any = None + if req.caption is not None: + text, parsed = _send.body(req.caption, parse=req.parse, entities=req.entities) + caption, entities = text, _send.tl_entities(parsed) + + media: Any = None + if req.file: + media = await _send.input_media(ctx, req.file) + if type(media).__name__ == "InputMediaUploadedDocument": + media.attributes = _cover_attributes(list(media.attributes or []), req.cover_ts) + elif req.cover_ts is not None: + current = await _require_own_story(ctx, peer, req.id) + document = getattr(getattr(current, "media", None), "document", None) + if document is None: + raise UsageError("--cover-ts only applies to a video story", field="cover_ts") + media = types.InputMediaUploadedDocument( + file=types.InputFileStoryDocument( + id=types.InputDocument( + id=document.id, + access_hash=document.access_hash, + file_reference=document.file_reference, + ) + ), + mime_type=str(getattr(document, "mime_type", "video/mp4")), + attributes=_cover_attributes( + list(getattr(document, "attributes", None) or []), req.cover_ts + ), + ) + + areas, _models = await _story.build_areas( + ctx, + areas_file=req.areas, + geo=tuple(req.area_geo), + venue=tuple(req.area_venue), + venue_near=req.area_venue_near, + venue_pick=req.area_venue_pick, + url=tuple(req.area_url), + reaction=tuple(req.area_reaction), + post=tuple(req.area_post), + weather=tuple(req.area_weather), + gift=tuple(req.area_gift), + ) + + rules = None + if req.privacy or req.allow or req.exclude or req.privacy_preset: + rules = await _story.privacy_rules( + ctx, + base=req.privacy or "everyone", + allow=tuple(req.allow), + exclude=tuple(req.exclude), + preset=req.privacy_preset, + ) + + music = await _music_document(ctx, req.music) if req.music else None + if not any((caption is not None, media is not None, areas, rules, music)): + raise UsageError("nothing to edit; pass a caption, media, areas or an audience", field="id") + + await client(ctx)( + fn.EditStoryRequest( + peer=peer, + id=req.id, + media=media, + media_areas=areas or None, + caption=caption, + entities=entities, + privacy_rules=rules, + music=music, + ) + ) + ctx.emit("story_edited", {"peer": peer_id, "story_id": req.id}) + fresh = await _stories_of(ctx, peer, [req.id]) + story = ( + _story.story_model(fresh[0], peer_id=peer_id) + if fresh + else Story(id=req.id, peer_id=peer_id) + ) + story.edited = True + return story + + +SPEC_EDIT = OperationSpec( + id="story.edit", + request=EditReq, + response=Story, + impl=edit, + summary="Edit a posted story: caption, audience, media, cover frame or areas", + description=( + "Privacy edits are user stories only — a channel story has no rule " + "vector. `--cover-ts` without `--file` re-sends the current document " + "rather than uploading it again." + ), + mutating=True, + rate_class="send", + timeout_s=300, + tags=frozenset({"visible-to-others"}), + columns=("id", "peer_id", "edited", "caption"), + example={**_EXAMPLE_STORY, "edited": True}, + example_args="story edit me 42 --caption 'still morning'", + covers=( + "stories.edit-areas", + "stories.edit-caption", + "stories.edit-cover", + "stories.edit-media", + "stories.edit-privacy", + ), +) + + +# --------------------------------------------------------------------------- +# story delete +# --------------------------------------------------------------------------- + + +class DeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose stories.")] + id: Annotated[ + list[str], arg(1, metavar="ID", variadic=True, help="Story ids, or `10-14` ranges.") + ] = [] + + +async def delete(ctx: OpContext, req: DeleteReq) -> StoriesDeleted: + """Delete stories permanently — active, profile-pinned or archived alike.""" + from telethon.tl.functions import stories as fn + + ids = _story.story_ids(req.id) + if not ids: + raise UsageError("give at least one story id", field="id") + peer = await _send.resolve(ctx, req.chat) + deleted = await client(ctx)(fn.DeleteStoriesRequest(peer=peer, id=ids)) + peer_id = _send.peer_id_of(peer) + ctx.emit("story_deleted", {"peer": peer_id, "ids": list(deleted or ids)}) + return StoriesDeleted(peer=peer_id, deleted_ids=[int(i) for i in (deleted or [])]) + + +SPEC_DELETE = OperationSpec( + id="story.delete", + request=DeleteReq, + response=StoriesDeleted, + impl=delete, + summary="Delete stories permanently", + description="Channel stories need the `delete_stories` admin right.", + mutating=True, + destructive=True, + rate_class="send", + columns=("peer", "deleted_ids"), + example={"peer": 4242, "deleted_ids": [42]}, + example_args="story delete me 42", + covers=("stories.delete",), +) + + +# --------------------------------------------------------------------------- +# story list +# --------------------------------------------------------------------------- + + +class ListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose stories.")] + profile: Annotated[bool, opt("--profile", help="The stories kept on the profile page.")] = False + archive: Annotated[bool, opt("--archive", help="The private archive, expired included.")] = ( + False + ) + album: Annotated[int | None, opt("--album", metavar="ID", help="Only this album.")] = None + offset_id: Annotated[ + int | None, opt("--offset-id", metavar="ID", help="Page from this story id downwards.") + ] = None + hydrate: Annotated[ + bool, opt("--hydrate/--no-hydrate", help="Resolve skipped placeholders.") + ] = True + translate: Annotated[ + str | None, opt("--translate", metavar="LANG", help="Also translate the captions.") + ] = None + + +async def list_stories(ctx: OpContext, req: ListReq) -> Page[Story]: + """A peer's stories: active by default, or the profile page, archive or an album. + + Listing never registers a view — that is `story read --register-view`. + """ + from telethon.tl.functions import stories as fn + + limit, state = window(ctx, "story.list", PageKind.HISTORY, 30) + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + offset = int(state.get("offset") or req.offset_id or 0) + + if req.album is not None: + result = await client(ctx)( + fn.GetAlbumStoriesRequest(peer=peer, album_id=req.album, offset=offset, limit=limit) + ) + elif req.archive: + result = await client(ctx)( + fn.GetStoriesArchiveRequest(peer=peer, offset_id=offset, limit=limit) + ) + elif req.profile: + result = await client(ctx)( + fn.GetPinnedStoriesRequest(peer=peer, offset_id=offset, limit=limit) + ) + else: + result = await client(ctx)(fn.GetPeerStoriesRequest(peer=peer)) + result = getattr(result, "stories", result) + + raw = list(getattr(result, "stories", None) or []) + if req.hydrate: + raw = await _hydrate(ctx, peer, raw) + pinned_top = set(getattr(result, "pinned_to_top", None) or []) + items = [_story.story_model(item, peer_id=peer_id) for item in raw] + for item in items: + if item.id in pinned_top: + item.pinned = True + if req.translate: + await _translate_captions(ctx, items, req.translate) + + # Only three of the four RPCs page at all; `getPeerStories` hands back the + # peer's whole active set in one shot, so guessing "there may be more" from + # a full page would hand out a cursor that returns nothing. + server_paged = req.album is not None or req.archive or req.profile + next_state = ( + {"offset": offset + len(items)} + if req.album is not None + else {"offset": items[-1].id if items else offset} + ) + return build_page( + items, + op="story.list", + kind=PageKind.HISTORY, + state=next_state, + account=ctx.account, + limit=limit if server_paged else None, + has_more=None if server_paged else False, + total=getattr(result, "count", None), + ) + + +async def _hydrate(ctx: OpContext, peer: Any, raw: list[Any]) -> list[Any]: + """Replace `storyItemSkipped` placeholders with the real items. + + A feed hands back placeholders for everything the client is assumed to + have cached; tlgr has no cache, so without this a listing is a list of + ids with no captions and no media. + """ + skipped = [ + int(getattr(item, "id", 0) or 0) + for item in raw + if type(item).__name__ == "StoryItemSkipped" + ] + if not skipped: + return raw + resolved = { + int(getattr(item, "id", 0) or 0): item for item in await _stories_of(ctx, peer, skipped) + } + return [resolved.get(int(getattr(item, "id", 0) or 0), item) for item in raw] + + +async def _translate_captions(ctx: OpContext, items: list[Story], language: str) -> None: + """Translate captions with the `text=` form — a story has no message id.""" + from telethon.tl import types + from telethon.tl.functions import messages as fn + + for item in items: + if not item.caption: + continue + result = await client(ctx)( + fn.TranslateTextRequest( + to_lang=language, text=[types.TextWithEntities(text=item.caption, entities=[])] + ) + ) + blocks = getattr(result, "result", None) or [] + if blocks: + item.translation = str(getattr(blocks[0], "text", "") or "") + + +SPEC_LIST = OperationSpec( + id="story.list", + request=ListReq, + response=Page[Story], + impl=list_stories, + summary="List a peer's stories: active, profile page, archive or one album", + description=( + "Four RPCs behind one list, because the GUI shows one grid with tabs. " + "`--archive` on a channel needs the `edit_stories` admin right." + ), + paginated=PageKind.HISTORY, + columns=("id", "date", "expire_date", "caption"), + headers=("ID", "Posted", "Expires", "Caption"), + example={"items": [_EXAMPLE_STORY], "has_more": False}, + example_args="story list @alice", + covers=( + "contacts-users.user-stories", + "stories.album-stories", + "stories.channel-archive", + "stories.own-archive", + "stories.peer-active", + "stories.profile-stories", + ), +) + + +# --------------------------------------------------------------------------- +# story get +# --------------------------------------------------------------------------- + + +class GetReq(Request): + chat: Annotated[ + PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story; a story link works too.") + ] + id: Annotated[ + list[str], arg(1, metavar="ID", required=False, variadic=True, help="Story ids.") + ] = [] + link: Annotated[bool, opt("--link", help="Only export the t.me story link.")] = False + album_link: Annotated[ + int | None, opt("--album-link", metavar="ID", help="Build the album deep link instead.") + ] = None + views: Annotated[bool, opt("--views", help="Also fetch fresh view counters.")] = False + translate: Annotated[ + str | None, opt("--translate", metavar="LANG", help="Translate the caption.") + ] = None + areas_out: Annotated[ + str | None, + opt("--areas-out", metavar="PATH", kind="path", help="Write media_areas as JSON."), + ] = None + + +async def get(ctx: OpContext, req: GetReq) -> Page[Story]: + """Fetch stories in full, or just their links. + + A `t.me/<user>/s/<id>` link may replace the CHAT+ID pair; the peer parser + keeps the original text, so the id is read straight back off it. + """ + import msgspec + from telethon.tl.functions import stories as fn + + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + ids = _story.story_ids(req.id) + linked = _link_story_id(req.chat) + if not ids and linked is not None: + ids = [linked] + + if req.album_link is not None: + username = await _username_of(ctx, peer) + album = req.album_link + return Page( + items=[Story(id=album, peer_id=peer_id, link=f"https://t.me/{username}/a/{album}")], + has_more=False, + total=1, + ) + + if not ids: + raise UsageError("give at least one story id, or a story link", field="id") + + if req.link: + items = [] + for story_id in ids: + exported = await client(ctx)(fn.ExportStoryLinkRequest(peer=peer, id=story_id)) + items.append( + Story(id=story_id, peer_id=peer_id, link=str(getattr(exported, "link", "") or "")) + ) + return Page(items=items, has_more=False, total=len(items)) + + raw = await _stories_of(ctx, peer, ids) + if not raw: + raise NotFoundError(f"no story {ids[0]} on that peer") + items = [_story.story_model(item, peer_id=peer_id) for item in raw] + + if req.views: + fresh = await client(ctx)(fn.GetStoriesViewsRequest(peer=peer, id=ids)) + for item, views in zip(items, getattr(fresh, "views", None) or [], strict=False): + item.views = _story.views_model(views) + if req.translate: + await _translate_captions(ctx, items, req.translate) + if req.areas_out: + areas: list[MediaArea] = [area for item in items for area in item.media_areas] + target = Path(os.path.expanduser(req.areas_out)) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(msgspec.json.format(msgspec.json.encode(areas))) + return Page(items=items, has_more=False, total=len(items)) + + +async def _username_of(ctx: OpContext, peer: Any) -> str: + entity = await client(ctx).get_entity(peer) + username = getattr(entity, "username", None) + if not username: + usernames = getattr(entity, "usernames", None) or [] + username = getattr(usernames[0], "username", None) if usernames else None + if not username: + raise NotSupportedError( + "USER_PUBLIC_MISSING: a story link only exists for a peer with a username" + ) + return str(username) + + +SPEC_GET = OperationSpec( + id="story.get", + request=GetReq, + response=Page[Story], + impl=get, + summary="Fetch stories in full (media, caption, areas, privacy, link)", + description=( + "`privacy` is only populated on your own stories. A gone story comes " + "back with `deleted: true` rather than as an error." + ), + columns=("id", "date", "caption", "link"), + example={"items": [_EXAMPLE_STORY], "has_more": False}, + example_args="story get @alice 42 --views", + covers=( + "stories.album-link", + "stories.caption-entities", + "stories.get-by-id", + "stories.link-export", + "stories.link-resolve", + "stories.media-areas-inspect", + "stories.privacy-inspect", + "stories.repost-origin", + "stories.skipped-hydrate", + "stories.translate-caption", + "stories.viewers-counters", + ), +) + + +# --------------------------------------------------------------------------- +# story feed list +# --------------------------------------------------------------------------- + + +class FeedListReq(Request): + hidden: Annotated[bool, opt("--hidden", help="The archived stories bar instead.")] = False + refresh: Annotated[bool, opt("--refresh", help="Re-send the stored state with no `next`.")] = ( + False + ) + state_file: Annotated[ + str | None, + opt("--state-file", metavar="PATH", kind="path", help="Where the feed state is kept."), + ] = None + peers: Annotated[ + list[PeerRef], + opt("--peers", metavar="PEER", kind="peer", help="Only the compact max-id summary."), + ] = [] + read_state: Annotated[ + bool, opt("--read-state", help="Emit the login-time read-state bootstrap instead.") + ] = False + unread_only: Annotated[bool, opt("--unread-only", help="Keep only unread peers.")] = False + + +async def feed_list(ctx: OpContext, req: FeedListReq) -> Page[StoryFeedPeer]: + """The stories bar. + + Pagination is not offset-based: the first call sends no state, the reply + carries one, and the walk continues with `state` plus `next`. The cursor + therefore carries both — an integer offset here would silently restart the + walk at the top every time. + + The reply also carries the account's stealth mode; `story stealth --status` + reads it from the same call, because `Page[T]` has no room for a sidecar + field and inventing one on every row would be worse. + """ + from telethon.tl.functions import stories as fn + + _limit, state = window(ctx, "story.feed.list", PageKind.DIALOGS, 30) + + if req.peers: + peers = [await _send.resolve(ctx, ref) for ref in req.peers] + recent = await client(ctx)(fn.GetPeerMaxIDsRequest(id=peers)) + items = [ + StoryFeedPeer( + peer_id=_send.peer_id_of(peer), + max_id=int(getattr(row, "max_id", 0) or 0), + live=bool(getattr(row, "live", False)), + ) + for peer, row in zip(peers, recent or [], strict=False) + ] + return Page(items=items, has_more=False, total=len(items)) + + if req.read_state: + result = await client(ctx)(fn.GetAllReadPeerStoriesRequest()) + table = _entities(result) + items = [] + for update in getattr(result, "updates", None) or []: + if type(update).__name__ != "UpdateReadStories": + continue + peer = getattr(update, "peer", None) + items.append( + StoryFeedPeer( + peer_id=peer_id_of(peer) or 0, + peer=_peer_model(peer, table), + max_read_id=int(getattr(update, "max_id", 0) or 0), + ) + ) + return Page(items=items, has_more=False, total=len(items)) + + stored = _feed_state(ctx, req) + token = state.get("state") or (stored if req.refresh else None) + result = await client(ctx)( + fn.GetAllStoriesRequest( + next=bool(state.get("next")) or None, + hidden=req.hidden or None, + state=token, + ) + ) + if type(result).__name__ == "AllStoriesNotModified": + already(ctx) + _save_feed_state(ctx, req, getattr(result, "state", "") or "") + return Page(items=[], has_more=False, total=0) + + table = _entities(result) + items = [] + for row in getattr(result, "peer_stories", None) or []: + peer = getattr(row, "peer", None) + stories = [ + _story.story_model(item, peer_id=peer_id_of(peer) or 0) + for item in (getattr(row, "stories", None) or []) + ] + max_read = int(getattr(row, "max_read_id", 0) or 0) + unread = [s for s in stories if s.id > max_read] + item = StoryFeedPeer( + peer_id=peer_id_of(peer) or 0, + peer=_peer_model(peer, table), + max_read_id=max_read, + stories=stories, + unread_count=len(unread), + has_unread=bool(unread), + live=any(s.live for s in stories), + hidden=req.hidden, + ) + if req.unread_only and not item.has_unread: + continue + items.append(item) + + feed_state = str(getattr(result, "state", "") or "") + _save_feed_state(ctx, req, feed_state) + return build_page( + items, + op="story.feed.list", + kind=PageKind.DIALOGS, + state={"state": feed_state, "next": True}, + account=ctx.account, + has_more=bool(getattr(result, "has_more", False)), + total=getattr(result, "count", None), + ) + + +def _peer_model(peer: Any, table: dict[int, Any]) -> Any: + entity = _peer_entity(peer, table) + return entity_to_peer(entity) if entity is not None else None + + +def _feed_path(ctx: OpContext, req: FeedListReq) -> Path | None: + if req.state_file: + return Path(os.path.expanduser(req.state_file)) + paths = getattr(ctx, "paths", None) + root = getattr(paths, "cache", None) or getattr(paths, "home", None) + if root is None: + return None + name = "story-feed-hidden.state" if req.hidden else "story-feed.state" + return Path(root) / f"{ctx.account or 'default'}-{name}" + + +def _feed_state(ctx: OpContext, req: FeedListReq) -> str | None: + path = _feed_path(ctx, req) + if path is None: + return None + try: + return path.read_text(encoding="utf-8").strip() or None + except OSError: + return None + + +def _save_feed_state(ctx: OpContext, req: FeedListReq, value: str) -> None: + """Persist the opaque feed state so `--refresh` means something next run.""" + path = _feed_path(ctx, req) + if path is None or not value: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + except OSError as exc: # a read-only cache must not fail the listing + ctx.warn(f"could not store the story feed state: {exc}") + + +SPEC_FEED_LIST = OperationSpec( + id="story.feed.list", + request=FeedListReq, + response=Page[StoryFeedPeer], + impl=feed_list, + summary="List peers that have active stories (the stories bar)", + description=( + "Main and hidden feeds keep independent states. `--refresh` re-sends " + "the stored state and reports `already: true` when nothing changed." + ), + paginated=PageKind.DIALOGS, + columns=("peer_id", "unread_count", "max_read_id"), + headers=("Peer", "Unread", "Read to"), + example={ + "items": [{"peer_id": 4242, "max_read_id": 41, "unread_count": 1, "has_unread": True}], + "has_more": False, + }, + example_args="story feed list --unread-only", + covers=( + "stories.changelog-stories", + "stories.feed-all", + "stories.feed-hidden", + "stories.feed-refresh-state", + "stories.peer-max-ids", + "stories.read-state-bootstrap", + ), +) + + +# --------------------------------------------------------------------------- +# story read +# --------------------------------------------------------------------------- + + +class ReadReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose stories.")] + id: Annotated[ + list[str], arg(1, metavar="ID", required=False, variadic=True, help="Story ids.") + ] = [] + max_id: Annotated[ + int | None, opt("--max-id", metavar="ID", help="Mark everything up to this id.") + ] = None + register_view: Annotated[ + bool, + opt("--register-view", help="Also appear in the poster's viewer list."), + ] = False + + +async def read(ctx: OpContext, req: ReadReq) -> StoryRead: + """Clear the unread ring, and only optionally appear as a viewer. + + `stories.readStories` is private bookkeeping; `incrementStoryViews` is + what the poster sees. Folding them into one command without a flag would + make every `story read` a disclosure. + """ + from telethon.tl.functions import stories as fn + + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + ids = _story.story_ids(req.id) + max_id = req.max_id or (max(ids) if ids else 0) + if not max_id: + peer_stories = await client(ctx)(fn.GetPeerStoriesRequest(peer=peer)) + stories = getattr(getattr(peer_stories, "stories", None), "stories", None) or [] + max_id = max((int(getattr(s, "id", 0) or 0) for s in stories), default=0) + if not max_id: + raise NotFoundError("that peer has no active stories to mark as read") + + marked = await client(ctx)(fn.ReadStoriesRequest(peer=peer, max_id=max_id)) + read_ids = [int(i) for i in (marked or [])] + if not read_ids: + already(ctx) + + viewed: list[int] = [] + if req.register_view and ids: + await client(ctx)(fn.IncrementStoryViewsRequest(peer=peer, id=ids)) + viewed = ids + ctx.emit("story_read", {"peer": peer_id, "max_id": max_id}) + return StoryRead( + peer=peer_id, + max_id=max_id, + ids=read_ids, + already=not read_ids, + viewed_ids=viewed, + ) + + +SPEC_READ = OperationSpec( + id="story.read", + request=ReadReq, + response=StoryRead, + impl=read, + summary="Mark a peer's stories as seen (clears the unread ring)", + description=( + "This does NOT make you appear in the poster's viewer list; " + "`--register-view` does, and only for the ids you name." + ), + aliases=("story.view",), + mutating=True, + idempotent=True, + columns=("peer", "max_id", "ids"), + example={"peer": 4242, "max_id": 42, "ids": [42], "ok": True}, + example_args="story read @alice", + covers=("stories.increment-views", "stories.mark-read"), +) + + +# --------------------------------------------------------------------------- +# story react +# --------------------------------------------------------------------------- + + +class ReactReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")] + id: Annotated[int, arg(1, metavar="ID", help="Story id.")] + emoji: Annotated[ + str | None, arg(2, metavar="EMOJI", required=False, help="The reaction to send.") + ] = None + remove: Annotated[bool, opt("--remove", help="Clear the reaction.")] = False + custom_emoji: Annotated[ + int | None, opt("--custom-emoji", metavar="ID", help="Custom-emoji document id.") + ] = None + recent: Annotated[ + bool, opt("--recent/--no-recent", help="Add it to the recent-reactions list.") + ] = True + as_message: Annotated[ + bool, opt("--as-message", help="Send the emoji as an ordinary story reply instead.") + ] = False + + +async def react(ctx: OpContext, req: ReactReq) -> StoryReactionResult: + """React to a story, or clear the reaction. + + A story carries at most one reaction per viewer — a single `Reaction`, + not the vector a message has — so this replaces rather than appends. + """ + from telethon.tl import types + from telethon.tl.functions import messages as msg_fn + from telethon.tl.functions import stories as fn + + from tlgr.ops.reaction import CUSTOM, to_tl + + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + name = "" + if req.custom_emoji is not None: + name = f"{CUSTOM}{req.custom_emoji}" + elif req.emoji: + name = req.emoji + if not req.remove and not name: + raise UsageError("give an emoji, or --remove to clear the reaction", field="emoji") + + if req.as_message: + if req.remove: + raise UsageError("--as-message cannot remove a reaction", field="remove") + sent = await client(ctx)( + msg_fn.SendMessageRequest( + peer=peer, + message=name, + random_id=random_id(), + reply_to=types.InputReplyToStory(peer=peer, story_id=req.id), + ) + ) + message = _send.message_from_updates(sent, chat_id=peer_id, sent_text=name) + return StoryReactionResult(peer=peer_id, story_id=req.id, reaction=name, msg_id=message.id) + + reaction = types.ReactionEmpty() if req.remove else to_tl(name) + await client(ctx)( + fn.SendReactionRequest( + peer=peer, + story_id=req.id, + reaction=reaction, + add_to_recent=(req.recent and not req.remove) or None, + ) + ) + ctx.emit("story_reaction", {"peer": peer_id, "story_id": req.id, "reaction": name}) + return StoryReactionResult( + peer=peer_id, story_id=req.id, reaction="" if req.remove else name, removed=req.remove + ) + + +SPEC_REACT = OperationSpec( + id="story.react", + request=ReactReq, + response=StoryReactionResult, + impl=react, + summary="React to a story, or remove your reaction", + description="Paid (Star) reactions do not exist on stories.", + mutating=True, + rate_class="send", + idempotent=True, + tags=frozenset({"visible-to-others"}), + columns=("peer", "story_id", "reaction"), + example={"peer": 4242, "story_id": 42, "reaction": "🔥"}, + example_args="story react @alice 42 🔥", + covers=("stories.react", "stories.reaction-as-message", "stories.unreact"), +) + + +# --------------------------------------------------------------------------- +# story reply +# --------------------------------------------------------------------------- + + +class ReplyReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")] + id: Annotated[int, arg(1, metavar="ID", help="Story id.")] + text: Annotated[ + str | None, arg(2, metavar="TEXT", required=False, help="Reply body; '-' reads stdin.") + ] = None + file: Annotated[ + list[str], opt("--file", metavar="PATH", kind="path", help="Attach a file. Repeatable.") + ] = [] + voice: Annotated[bool, opt("--voice", help="Send the file as a voice note.")] = False + sticker: Annotated[ + str | None, opt("--sticker", metavar="ID", help="Send a sticker document id.") + ] = None + parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None + entities: Annotated[ + str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.") + ] = None + silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False + schedule: Annotated[ + str | None, opt("--schedule", metavar="TS|online", help="Schedule the reply.") + ] = None + paid_stars: Annotated[ + int | None, opt("--paid-stars", metavar="N", help="Agree to the peer's message price.") + ] = None + + +async def reply(ctx: OpContext, req: ReplyReq) -> StoryReply: + """Reply privately to a story. + + The composition is the message group's: this builds `InputReplyToStory` + and hands it to the same send path, so every send-time flag behaves the + way it does on `message send` rather than almost the same way. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + text, entities = _send.body(req.text, parse=req.parse, entities=req.entities) + reply_to = types.InputReplyToStory(peer=peer, story_id=req.id) + schedule = _send.schedule_at(req.schedule) + + media: Any = None + if req.sticker: + if not req.sticker.isdigit(): + raise UsageError("--sticker takes a document id", field="sticker") + media = types.InputMediaDocument( + id=types.InputDocument(id=int(req.sticker), access_hash=0, file_reference=b"") + ) + elif req.file: + media = await _send.input_media(ctx, req.file[0], voice=req.voice) + + if media is not None: + updates = await client(ctx)( + fn.SendMediaRequest( + peer=peer, + media=media, + message=text, + entities=_send.tl_entities(entities), + random_id=random_id(), + reply_to=reply_to, + silent=req.silent or None, + schedule_date=schedule, + allow_paid_stars=req.paid_stars, + ) + ) + else: + if not text: + raise UsageError("give some text, --file or --sticker", field="text") + updates = await client(ctx)( + fn.SendMessageRequest( + peer=peer, + message=text, + entities=_send.tl_entities(entities), + random_id=random_id(), + reply_to=reply_to, + silent=req.silent or None, + schedule_date=schedule, + allow_paid_stars=req.paid_stars, + ) + ) + message = _send.message_from_updates(updates, chat_id=peer_id, sent_text=text) + ctx.emit("story_reply", {"peer": peer_id, "story_id": req.id, "msg_id": message.id}) + return StoryReply( + chat_id=peer_id, + msg_id=message.id, + reply_to_story=req.id, + text=text, + message=message, + ) + + +SPEC_REPLY = OperationSpec( + id="story.reply", + request=ReplyReq, + response=StoryReply, + impl=reply, + summary="Reply privately to a story (text, media, voice or sticker)", + description=( + "A reply is an ordinary private message carrying `InputReplyToStory`, " + "so the peer's message restrictions — Premium-only, paid messages, " + "channel story replies locked — apply exactly as they do to a DM." + ), + mutating=True, + rate_class="send", + tags=frozenset({"visible-to-others"}), + columns=("chat_id", "msg_id", "reply_to_story"), + example={"chat_id": 4242, "msg_id": 12345, "reply_to_story": 42, "text": "nice one"}, + example_args="story reply @alice 42 'nice one'", + covers=("stories.reply", "stories.reply-media", "stories.reply-restrictions"), +) + + +# --------------------------------------------------------------------------- +# story share +# --------------------------------------------------------------------------- + + +class ShareReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")] + id: Annotated[int, arg(1, metavar="ID", help="Story id.")] + until: Annotated[ + list[PeerRef], + opt("--until", "--to", metavar="CHAT", kind="peer", help="Destination chat. Repeatable."), + ] = [] + text: Annotated[str | None, opt("--text", help="Caption to send with the card.")] = None + silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False + topic: Annotated[ + int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.") + ] = None + + +async def share(ctx: OpContext, req: ShareReq) -> StoryShared: + """Share a story into chats as a story card. + + Not `forwardMessages`: the receiving message carries `messageMediaStory`, + which is what makes it render as a story rather than as a copy of its + media. Refused when the story is `noforwards`. + """ + from telethon.tl import types + from telethon.tl.functions import messages as fn + + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + if not req.until: + raise UsageError("give at least one --until CHAT", field="until") + + source = await _require_own_story(ctx, peer, req.id) + if getattr(source, "noforwards", False): + raise PermissionError_( + f"story {req.id} is protected against forwarding; " + f"`tlgr story get {req.chat.raw} {req.id} --link` shares a link instead" + ) + + sent: list[Message] = [] + for destination in req.until: + target = await _send.resolve(ctx, destination) + updates = await client(ctx)( + fn.SendMediaRequest( + peer=target, + media=types.InputMediaStory(peer=peer, id=req.id), + message=req.text or "", + random_id=random_id(), + silent=req.silent or None, + reply_to=types.InputReplyToMessage(reply_to_msg_id=req.topic) + if req.topic + else None, + ) + ) + sent.append(_send.message_from_updates(updates, chat_id=_send.peer_id_of(target))) + ctx.emit("story_shared", {"peer": peer_id, "story_id": req.id, "count": len(sent)}) + return StoryShared(sent=sent, story_id=req.id, peer=peer_id) + + +SPEC_SHARE = OperationSpec( + id="story.share", + request=ShareReq, + response=StoryShared, + impl=share, + summary="Share a story into chats as a story card", + aliases=("story.forward",), + mutating=True, + rate_class="send", + tags=frozenset({"visible-to-others"}), + columns=("story_id", "peer"), + example={ + "story_id": 42, + "peer": 4242, + "sent": [ + { + "id": 12345, + "chat_id": 777123, + "date": "2026-09-03T09:20:00Z", + "date_unix": 1788427200, + } + ], + }, + example_args="story share @alice 42 --until @bobby", + covers=("stories.share-to-chat",), +) + + +# --------------------------------------------------------------------------- +# story pin / unpin +# --------------------------------------------------------------------------- + + +class PinReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose stories.")] + id: Annotated[ + list[str], arg(1, metavar="ID", required=False, variadic=True, help="Story ids.") + ] = [] + top: Annotated[bool, opt("--top", help="Pin to the top of the profile grid instead.")] = False + + +async def _toggle_pinned(ctx: OpContext, req: PinReq, *, pinned: bool) -> StoryPinned: + from telethon.tl.functions import stories as fn + + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + ids = _story.story_ids(req.id) + + if req.top: + # `togglePinnedToTop` replaces the whole set, so an empty vector is + # how the pinned-to-top row is cleared. + if pinned and not ids: + raise UsageError("--top needs the ids to pin to the top", field="id") + order = ids if pinned else [] + await client(ctx)(fn.TogglePinnedToTopRequest(peer=peer, id=order)) + return StoryPinned(peer=peer_id, ids=ids, pinned=pinned, pinned_to_top=order) + + if not ids: + raise UsageError("give at least one story id", field="id") + changed = await client(ctx)(fn.TogglePinnedRequest(peer=peer, id=ids, pinned=pinned)) + if not changed: + already(ctx) + return StoryPinned(peer=peer_id, ids=[int(i) for i in (changed or [])], pinned=pinned) + + +async def pin(ctx: OpContext, req: PinReq) -> StoryPinned: + """Keep stories on the profile page, or pin them to the top of the grid. + + "Pinned" here means "shown on the profile page", not "first in the grid" — + that is `--top`, whose RPC replaces the whole pinned-to-top set. + """ + return await _toggle_pinned(ctx, req, pinned=True) + + +async def unpin(ctx: OpContext, req: PinReq) -> StoryPinned: + """Move stories off the profile page, or clear the pinned-to-top set.""" + return await _toggle_pinned(ctx, req, pinned=False) + + +SPEC_PIN = OperationSpec( + id="story.pin", + request=PinReq, + response=StoryPinned, + impl=pin, + summary="Keep stories on the profile page, or pin them to the top", + mutating=True, + idempotent=True, + columns=("peer", "ids", "pinned"), + example={"peer": 4242, "ids": [42], "pinned": True}, + example_args="story pin me 42", + covers=("stories.pin-to-top",), + covers_partial=("stories.pin-to-profile",), + coverage_note="`story unpin` owns the other half of the profile-page toggle.", +) + +SPEC_UNPIN = OperationSpec( + id="story.unpin", + request=PinReq, + response=StoryPinned, + impl=unpin, + summary="Move stories off the profile page, or clear the pinned-to-top set", + mutating=True, + idempotent=True, + columns=("peer", "ids", "pinned"), + example={"peer": 4242, "ids": [42], "pinned": False}, + example_args="story unpin me 42", + covers=("stories.pin-to-profile",), + covers_partial=("stories.pin-to-top",), + coverage_note="`story pin` owns the other half of the pinned-to-top set.", +) + + +# --------------------------------------------------------------------------- +# story hide / unhide +# --------------------------------------------------------------------------- + + +class HideReq(Request): + chat: Annotated[ + list[PeerRef], + arg(0, metavar="CHAT", variadic=True, kind="peer", help="Whose stories to hide."), + ] = [] + every: Annotated[bool, opt("--all", help="Collapse the whole stories bar.")] = False + unhide: Annotated[ + bool, opt("--unhide", help="Put them back instead (v1's `user hide-stories --unhide`).") + ] = False + + +async def _toggle_one(ctx: OpContext, ref: PeerRef, *, hidden: bool) -> StoryHiddenPeer: + from telethon.tl.functions import stories as fn + + peer = await _send.resolve(ctx, ref) + peer_id = _send.peer_id_of(peer) + entity = await client(ctx).get_entity(peer) + was = bool(getattr(entity, "stories_hidden", False)) + row = StoryHiddenPeer( + user_id=peer_id if peer_id > 0 else 0, + username=getattr(entity, "username", None), + peer_id=peer_id, + hidden=hidden, + # v1 detected this and sent nothing, so a bulk pass is cheap to repeat. + already=was == hidden, + ) + if not row.already: + await client(ctx)(fn.TogglePeerStoriesHiddenRequest(peer=peer, hidden=hidden)) + ctx.emit("story_peer_hidden", {"peer": peer_id, "hidden": hidden}) + return row + + +async def _toggle_hidden(ctx: OpContext, req: HideReq, *, hidden: bool) -> StoryHidden: + from telethon.tl.functions import stories as fn + + result = StoryHidden(hidden=hidden, already=False) + if req.every: + await client(ctx)(fn.ToggleAllStoriesHiddenRequest(hidden=hidden)) + result.all = True + if not req.chat: + return result + if not req.chat: + raise UsageError("give a peer, or --all for the whole stories bar", field="chat") + + rows = [await _toggle_one(ctx, ref, hidden=hidden) for ref in req.chat] + first = rows[0] + result.user_id = first.user_id + result.username = first.username + result.peer_id = first.peer_id + result.already = first.already + if len(rows) > 1: + result.peers = rows + if all(row.already for row in rows): + already(ctx) + return result + + +async def hide(ctx: OpContext, req: HideReq) -> StoryHidden: + """Move a peer's stories to the archive bar, or collapse the whole bar. + + Per-account and purely local: the other side is never told, and nothing + about the chat, the contact or their access changes. Idempotent — + `already: true` means the flag was already set and no RPC was sent. + """ + return await _toggle_hidden(ctx, req, hidden=not req.unhide) + + +async def unhide(ctx: OpContext, req: HideReq) -> StoryHidden: + """Put a peer's stories back in the main bar. The inverse of `story hide`.""" + return await _toggle_hidden(ctx, req, hidden=req.unhide) + + +SPEC_HIDE = OperationSpec( + id="story.hide", + request=HideReq, + response=StoryHidden, + impl=hide, + summary="Hide a peer's stories, or hide the whole stories bar", + description=( + "v1 spelled this `tlgr user hide-stories`, and that path still works " + "— including its `--unhide` flag, which is `story unhide` said the " + "other way round. Idempotent: the fresh flag is read first and " + "`already: true` means no RPC was sent, so repeating a bulk pass is " + "nearly free. More than one peer fills `peers`; a single peer answers " + "with exactly the four keys v1 printed." + ), + legacy_paths=("user hide-stories",), + mutating=True, + idempotent=True, + columns=("user_id", "username", "hidden", "already"), + example={"user_id": 4242, "username": "alice", "hidden": True, "already": False}, + example_args="story hide @alice", + covers=( + "contacts-users.user-hide-stories", + "dialogs.hide-stories-peer", + "groups-channels-admin.hide-peer-stories", + ), + covers_partial=("stories.hide-all", "stories.hide-peer"), + coverage_note="`story unhide` owns the other half of both toggles.", +) + +SPEC_UNHIDE = OperationSpec( + id="story.unhide", + request=HideReq, + response=StoryHidden, + impl=unhide, + summary="Put a peer's stories back in the main bar", + mutating=True, + idempotent=True, + columns=("user_id", "username", "hidden", "already"), + example={"user_id": 4242, "username": "alice", "hidden": False, "already": False}, + example_args="story unhide @alice", + covers=("stories.hide-all", "stories.hide-peer"), +) + + +# --------------------------------------------------------------------------- +# story album +# --------------------------------------------------------------------------- + + +class AlbumCreateReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")] + title: Annotated[str, arg(1, metavar="TITLE", help="Album title (1-12 characters).")] + story: Annotated[ + list[int], opt("--story", metavar="ID", help="Story to put in it. Repeatable.") + ] = [] + + +async def album_create(ctx: OpContext, req: AlbumCreateReq) -> StoryAlbum: + """Create a profile album. Channel albums need the `edit_stories` right.""" + from telethon.tl.functions import stories as fn + + if not 1 <= len(req.title) <= 12: + raise UsageError("an album title is 1 to 12 characters", field="title") + if not req.story: + raise UsageError("an album needs at least one --story", field="story") + peer = await _send.resolve(ctx, req.chat) + album = await client(ctx)( + fn.CreateAlbumRequest(peer=peer, title=req.title, stories=list(req.story)) + ) + return _story.album_model(album, stories=list(req.story)) + + +SPEC_ALBUM_CREATE = OperationSpec( + id="story.album.create", + request=AlbumCreateReq, + response=StoryAlbum, + impl=album_create, + summary="Create a story album", + mutating=True, + columns=("id", "title", "stories"), + example={"id": 7, "title": "Trips", "stories": [42]}, + example_args="story album create me Trips --story 42", + covers=("stories.album-create",), +) + + +class AlbumDeleteReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")] + album_id: Annotated[int, arg(1, metavar="ALBUM_ID", help="Album id.")] + + +async def album_delete(ctx: OpContext, req: AlbumDeleteReq) -> AlbumDeleted: + """Delete an album. The stories inside it stay.""" + from telethon.tl.functions import stories as fn + + peer = await _send.resolve(ctx, req.chat) + await client(ctx)(fn.DeleteAlbumRequest(peer=peer, album_id=req.album_id)) + return AlbumDeleted(peer=_send.peer_id_of(peer), album_id=req.album_id) + + +SPEC_ALBUM_DELETE = OperationSpec( + id="story.album.delete", + request=AlbumDeleteReq, + response=AlbumDeleted, + impl=album_delete, + summary="Delete an album (the stories stay)", + mutating=True, + destructive=True, + columns=("peer", "album_id", "ok"), + example={"peer": 4242, "album_id": 7, "ok": True}, + example_args="story album delete me 7", + covers=("stories.album-delete",), +) + + +class AlbumEditReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")] + album_id: Annotated[int, arg(1, metavar="ALBUM_ID", help="Album id.")] + title: Annotated[str | None, opt("--title", help="New album title (1-12 chars).")] = None + add: Annotated[list[int], opt("--add", metavar="ID", help="Story to add. Repeatable.")] = [] + remove: Annotated[ + list[int], opt("--remove", metavar="ID", help="Story to remove. Repeatable.") + ] = [] + order: Annotated[ + list[int], opt("--order", metavar="ID", help="Full story order inside the album.") + ] = [] + + +async def album_edit(ctx: OpContext, req: AlbumEditReq) -> StoryAlbum: + """Rename an album, add or remove stories, or reorder the ones inside it. + + One RPC (`stories.updateAlbum`) backs all four GUI actions, which is why + they are one command with four flags rather than four near-identical ones. + """ + from telethon.tl.functions import stories as fn + + if req.title is not None and not 1 <= len(req.title) <= 12: + raise UsageError("an album title is 1 to 12 characters", field="title") + if not any((req.title, req.add, req.remove, req.order)): + raise UsageError( + "nothing to change; pass --title, --add, --remove or --order", field="album_id" + ) + peer = await _send.resolve(ctx, req.chat) + album = await client(ctx)( + fn.UpdateAlbumRequest( + peer=peer, + album_id=req.album_id, + title=req.title, + delete_stories=list(req.remove) or None, + add_stories=list(req.add) or None, + order=list(req.order) or None, + ) + ) + return _story.album_model(album, stories=list(req.order) or list(req.add)) + + +SPEC_ALBUM_EDIT = OperationSpec( + id="story.album.edit", + request=AlbumEditReq, + response=StoryAlbum, + impl=album_edit, + summary="Rename an album, add/remove stories, or reorder the stories inside it", + mutating=True, + columns=("id", "title", "stories"), + example={"id": 7, "title": "Trips 2026", "stories": [42, 43]}, + example_args="story album edit me 7 --title 'Trips 2026'", + covers=( + "stories.album-add-stories", + "stories.album-remove-stories", + "stories.album-rename", + "stories.album-reorder-stories", + ), +) + + +class AlbumListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")] + hash: Annotated[ + int | None, opt("--hash", metavar="N", help="Cache hash; unchanged answers `already`.") + ] = None + + +async def album_list(ctx: OpContext, req: AlbumListReq) -> Page[StoryAlbum]: + """List the story albums on a profile.""" + from telethon.tl.functions import stories as fn + + limit, _state = window(ctx, "story.album.list", PageKind.LOCAL, 30) + peer = await _send.resolve(ctx, req.chat) + result = await client(ctx)(fn.GetAlbumsRequest(peer=peer, hash=req.hash or 0)) + if type(result).__name__ == "AlbumsNotModified": + already(ctx) + return Page(items=[], has_more=False, total=0) + albums = [_story.album_model(album) for album in (getattr(result, "albums", None) or [])] + return Page(items=albums[:limit], has_more=len(albums) > limit, total=len(albums)) + + +SPEC_ALBUM_LIST = OperationSpec( + id="story.album.list", + request=AlbumListReq, + response=Page[StoryAlbum], + impl=album_list, + summary="List the story albums on a profile", + description="Open one with `story list PEER --album ID`.", + paginated=PageKind.LOCAL, + columns=("id", "title", "stories_count"), + headers=("ID", "Title", "Stories"), + example={"items": [{"id": 7, "title": "Trips"}], "has_more": False}, + example_args="story album list me", + covers=("stories.album-list",), +) + + +class AlbumReorderReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")] + album_id: Annotated[ + list[int], arg(1, metavar="ALBUM_ID", variadic=True, help="Albums, in the new order.") + ] = [] + + +async def album_reorder(ctx: OpContext, req: AlbumReorderReq) -> AlbumOrder: + """Reorder the album chips. A full-replace vector, like every Telegram order.""" + from telethon.tl.functions import stories as fn + + if not req.album_id: + raise UsageError("give the album ids in the order you want them", field="album_id") + peer = await _send.resolve(ctx, req.chat) + await client(ctx)(fn.ReorderAlbumsRequest(peer=peer, order=list(req.album_id))) + return AlbumOrder(peer=_send.peer_id_of(peer), order=list(req.album_id)) + + +SPEC_ALBUM_REORDER = OperationSpec( + id="story.album.reorder", + request=AlbumReorderReq, + response=AlbumOrder, + impl=album_reorder, + summary="Reorder the album chips on the profile", + mutating=True, + columns=("peer", "order"), + example={"peer": 4242, "order": [8, 7]}, + example_args="story album reorder me 8 7", + covers=("stories.album-reorder",), +) + + +# --------------------------------------------------------------------------- +# story blocklist +# --------------------------------------------------------------------------- + + +class BlocklistListReq(Request): + pass + + +async def blocklist_list(ctx: OpContext, req: BlocklistListReq) -> Page[BlockedStoryUser]: + """ "Hide my stories from" — a second blocklist, independent of `user block`.""" + from telethon.tl.functions import contacts as fn + + limit, state = window(ctx, "story.blocklist.list", PageKind.PARTICIPANTS, 30) + offset = int(state.get("offset") or 0) + result = await client(ctx)( + fn.GetBlockedRequest(offset=offset, limit=limit, my_stories_from=True) + ) + users = {int(u.id): u for u in (getattr(result, "users", None) or [])} + items: list[BlockedStoryUser] = [] + for row in getattr(result, "blocked", None) or []: + raw_id = peer_id_of(getattr(row, "peer_id", None)) or 0 + user = users.get(abs(raw_id)) + items.append( + BlockedStoryUser( + user_id=raw_id, + username=getattr(user, "username", None), + name=" ".join( + part + for part in ( + getattr(user, "first_name", None), + getattr(user, "last_name", None), + ) + if part + ), + date=fmt_dt(getattr(row, "date", None)), + date_unix=to_unix(getattr(row, "date", None)), + ) + ) + return build_page( + items, + op="story.blocklist.list", + kind=PageKind.PARTICIPANTS, + state={"offset": offset + len(items)}, + account=ctx.account, + limit=limit, + total=getattr(result, "count", None), + ) + + +SPEC_BLOCKLIST_LIST = OperationSpec( + id="story.blocklist.list", + request=BlocklistListReq, + response=Page[BlockedStoryUser], + impl=blocklist_list, + summary="List the users who never see your stories", + description="A second, independent blocklist; `user block` stays the global one.", + paginated=PageKind.PARTICIPANTS, + columns=("user_id", "username", "name"), + headers=("ID", "Username", "Name"), + example={"items": [{"user_id": 4242, "username": "alice", "name": "Alice"}], "has_more": False}, + example_args="story blocklist list", + covers_partial=("stories.blocklist",), + coverage_note="`story blocklist set` owns the writing half of the list.", +) + + +class BlocklistSetReq(Request): + user: Annotated[ + list[UserRef], arg(0, metavar="USER", variadic=True, kind="user", help="Users.") + ] = [] + remove: Annotated[bool, opt("--remove", help="Remove them from the list instead.")] = False + replace: Annotated[ + bool, opt("--replace", help="Replace the whole list with exactly these users.") + ] = False + + +async def blocklist_set(ctx: OpContext, req: BlocklistSetReq) -> BlocklistChange: + """Add to, remove from or replace the story blocklist. + + `--replace` is one RPC that overwrites the list; `--add`/`--remove` are + per-user and idempotent, which is what makes a bulk pass safe to repeat. + """ + from telethon.tl.functions import contacts as fn + + if not req.user: + raise UsageError("name at least one user", field="user") + peers = [await _send.resolve(ctx, ref) for ref in req.user] + ids = [_send.peer_id_of(peer) for peer in peers] + + if req.replace: + await client(ctx)(fn.SetBlockedRequest(id=peers, limit=len(peers), my_stories_from=True)) + return BlocklistChange(added=ids, total=len(ids)) + + changed: list[int] = [] + for peer, peer_id in zip(peers, ids, strict=True): + request = ( + fn.UnblockRequest(id=peer, my_stories_from=True) + if req.remove + else fn.BlockRequest(id=peer, my_stories_from=True) + ) + if await client(ctx)(request): + changed.append(peer_id) + if not changed: + already(ctx) + return BlocklistChange( + added=[] if req.remove else changed, + removed=changed if req.remove else [], + already=not changed, + ) + + +SPEC_BLOCKLIST_SET = OperationSpec( + id="story.blocklist.set", + request=BlocklistSetReq, + response=BlocklistChange, + impl=blocklist_set, + summary="Add to, remove from or replace the story blocklist", + mutating=True, + idempotent=True, + columns=("added", "removed", "total"), + example={"added": [4242], "removed": [], "total": 1}, + example_args="story blocklist set @alice", + covers=("dialogs.block-stories", "stories.blocklist"), +) + + +# --------------------------------------------------------------------------- +# story viewer list +# --------------------------------------------------------------------------- + + +class ViewerListReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")] + id: Annotated[int, arg(1, metavar="ID", help="Story id.")] + contacts: Annotated[bool, opt("--contacts", help="Contacts only.")] = False + reactions_first: Annotated[ + bool, opt("--reactions-first", help="Sort viewers who reacted first.") + ] = False + forwards_first: Annotated[ + bool, opt("--forwards-first", help="Sort reposts and forwards first.") + ] = False + q: Annotated[str | None, opt("--q", metavar="TEXT", help="Server-side name search.")] = None + reaction: Annotated[ + str | None, opt("--reaction", metavar="EMOJI", help="Channel stories: only this reaction.") + ] = None + csv_out: Annotated[ + str | None, opt("--csv", metavar="PATH", kind="path", help="Also write the rows as CSV.") + ] = None + hide_from: Annotated[ + list[UserRef], + opt("--hide-from", metavar="USER", kind="user", help="Add a viewer to the blocklist."), + ] = [] + + +def _viewer_row(row: Any, table: dict[int, Any]) -> StoryViewer: + from tlgr.ops._serialize import entity_to_peer as to_peer + from tlgr.ops.reaction import name_of + + name = type(row).__name__ + if name in ("StoryView", "StoryReaction"): + raw_id = int(getattr(row, "user_id", 0) or 0) or ( + peer_id_of(getattr(row, "peer_id", None)) or 0 + ) + reaction = getattr(row, "reaction", None) + return StoryViewer( + kind="view", + user_id=raw_id, + date=fmt_dt(getattr(row, "date", None)), + date_unix=to_unix(getattr(row, "date", None)), + reaction=name_of(reaction) if reaction is not None else None, + blocked=bool(getattr(row, "blocked", False)), + blocked_my_stories_from=bool(getattr(row, "blocked_my_stories_from", False)), + ) + if name in ("StoryViewPublicForward", "StoryReactionPublicForward"): + message = getattr(row, "message", None) + return StoryViewer( + kind="forward", + user_id=peer_id_of(getattr(message, "peer_id", None)) or 0, + msg_id=int(getattr(message, "id", 0) or 0), + blocked=bool(getattr(row, "blocked", False)), + ) + story = getattr(row, "story", None) + peer = getattr(row, "peer_id", None) + entity = _peer_entity(peer, table) + return StoryViewer( + kind="repost", + user_id=peer_id_of(peer) or 0, + peer=to_peer(entity) if entity is not None else None, + story_id=int(getattr(story, "id", 0) or 0), + blocked=bool(getattr(row, "blocked", False)), + ) + + +async def viewer_list(ctx: OpContext, req: ViewerListReq) -> Page[StoryViewer]: + """Who saw a story, with their reactions. + + Your own user stories go through `getStoryViewsList`; a channel story you + administer only has `getStoryReactionsList`, which knows about reactions, + forwards and reposts but not about plain views. The RPC is chosen from the + peer type and `--reaction` forces the second one, because reporting an + empty viewer list for a channel story would read as "nobody watched". + """ + from telethon.tl.functions import stories as fn + + limit, state = window(ctx, "story.viewer.list", PageKind.PARTICIPANTS, 30) + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + offset = str(state.get("offset") or "") + + if req.hide_from: + await blocklist_set(ctx, BlocklistSetReq(user=list(req.hide_from))) + + reactions_only = req.reaction is not None or peer_id < 0 + if reactions_only: + from tlgr.ops.reaction import to_tl + + result = await client(ctx)( + fn.GetStoryReactionsListRequest( + peer=peer, + id=req.id, + limit=limit, + forwards_first=req.forwards_first or None, + reaction=to_tl(req.reaction) if req.reaction else None, + offset=offset or None, + ) + ) + rows = getattr(result, "reactions", None) or [] + else: + result = await client(ctx)( + fn.GetStoryViewsListRequest( + peer=peer, + id=req.id, + offset=offset, + limit=limit, + just_contacts=req.contacts or None, + reactions_first=req.reactions_first or None, + forwards_first=req.forwards_first or None, + q=req.q, + ) + ) + rows = getattr(result, "views", None) or [] + + table = _entities(result) + items = [_viewer_row(row, table) for row in rows] + ctx.warn( + "source: stories.getStoryReactionsList (channel stories have no plain view rows)" + if reactions_only + else "source: stories.getStoryViewsList" + ) + if req.csv_out: + _write_csv(req.csv_out, items) + + next_offset = getattr(result, "next_offset", None) + return build_page( + items, + op="story.viewer.list", + kind=PageKind.PARTICIPANTS, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + total=getattr(result, "count", None), + ) + + +def _write_csv(path: str, items: list[StoryViewer]) -> None: + """The export the GUI has no button for.""" + target = Path(os.path.expanduser(path)) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["id", "username", "name", "date", "reaction", "blocked", "kind"]) + for item in items: + user = item.user + writer.writerow( + [ + item.user_id, + getattr(user, "username", "") or "", + getattr(user, "title", "") or "", + item.date or "", + item.reaction or "", + int(item.blocked), + item.kind, + ] + ) + + +SPEC_VIEWER_LIST = OperationSpec( + id="story.viewer.list", + request=ViewerListReq, + response=Page[StoryViewer], + impl=viewer_list, + summary="Who saw a story, with their reactions", + description=( + "A non-Premium account loses the list `story_viewers_expire_period` " + "seconds after the story expires; `views.has_viewers` on the story " + "says whether it is still available." + ), + paginated=PageKind.PARTICIPANTS, + tags=frozenset({"mutating-checked"}), + columns=("user_id", "date", "reaction", "kind"), + headers=("User", "Seen", "Reaction", "Kind"), + example={ + "items": [{"user_id": 4242, "date": "2026-09-03T10:00:00Z", "reaction": "🔥"}], + "has_more": False, + }, + example_args="story viewer list me 42", + covers=( + "reaction.story-list", + "stories.channel-story-interactions", + "stories.viewer-block", + "stories.viewers-export", + "stories.viewers-filters", + "stories.viewers-list", + "stories.viewers-search", + ), +) + + +# --------------------------------------------------------------------------- +# story stealth +# --------------------------------------------------------------------------- + + +class StealthSetReq(Request): + past: Annotated[bool, opt("--past", help="Erase your views from the recent window.")] = False + future: Annotated[bool, opt("--future", help="Hide your views for the next window.")] = False + status: Annotated[bool, opt("--status", help="Only report the state and do nothing.")] = False + + +async def stealth_set(ctx: OpContext, req: StealthSetReq) -> StealthMode: + """Stealth mode: erase recent views and/or hide the next ones. + + Premium only, and rate limited by its own cooldown. A `FLOOD_WAIT` here is + the cooldown rather than a server complaint, so it is reported as the + remaining cooldown instead of as a raw error. + """ + from telethon.tl.functions import stories as fn + + if req.status or not (req.past or req.future): + feed = await client(ctx)(fn.GetAllStoriesRequest()) + return _story.stealth_model(getattr(feed, "stealth_mode", None)) + + from telethon.errors import FloodWaitError + + try: + await client(ctx)( + fn.ActivateStealthModeRequest(past=req.past or None, future=req.future or None) + ) + except FloodWaitError as exc: + raise PermissionError_(f"stealth mode is still cooling down; {exc.seconds}s left") from exc + feed = await client(ctx)(fn.GetAllStoriesRequest()) + mode = _story.stealth_model( + getattr(feed, "stealth_mode", None), past=req.past, future=req.future + ) + ctx.emit("story_stealth", {"active_until": mode.active_until_unix}) + return mode + + +SPEC_STEALTH_SET = OperationSpec( + id="story.stealth.set", + request=StealthSetReq, + response=StealthMode, + impl=stealth_set, + summary="Stealth mode: erase recent views and/or hide the next ones", + description=( + "`--status` reads the state out of the feed reply, which is also " + "where `story feed list` gets it from." + ), + mutating=True, + columns=("active_until_date", "cooldown_until_date"), + headers=("Active until", "Cooldown until"), + example={"active_until_date": "2026-09-03T09:39:07Z", "past": True, "future": True}, + example_args="story stealth set --past --future", + covers=("stories.stealth-activate", "stories.stealth-status"), +) + + +# --------------------------------------------------------------------------- +# story search +# --------------------------------------------------------------------------- + + +class SearchReq(Request): + hashtag: Annotated[ + str | None, opt("--hashtag", metavar="TAG", help="Hashtag or cashtag, without the #.") + ] = None + venue: Annotated[ + str | None, opt("--venue", metavar="PROVIDER:VENUE_ID", help="Search by venue area.") + ] = None + geo: Annotated[ + str | None, opt("--geo", metavar="LAT,LON", help="Search by geo area (needs --address).") + ] = None + address: Annotated[ + str | None, + opt("--address", metavar="CC[,state,city,street]", help="Address attached to --geo."), + ] = None + peer: Annotated[ + PeerRef | None, + opt("--peer", metavar="PEER", kind="peer", help="Only this poster."), + ] = None + + +async def search(ctx: OpContext, req: SearchReq) -> Page[Story]: + """Search public stories by hashtag or location. + + Only "Everyone" stories are searchable, so an empty result means "nothing + public matched", not "nothing exists". + """ + from telethon.tl import types + from telethon.tl.functions import stories as fn + + limit, state = window(ctx, "story.search", PageKind.SEARCH, 30) + given = [ + name + for name, value in (("hashtag", req.hashtag), ("venue", req.venue), ("geo", req.geo)) + if value + ] + if len(given) != 1: + raise UsageError( + "give exactly one of --hashtag, --venue or --geo", + field=given[0] if given else "hashtag", + ) + + area: Any = None + coordinates = types.MediaAreaCoordinates(x=0.0, y=0.0, w=0.0, h=0.0, rotation=0.0) + if req.venue: + provider, _, venue_id = req.venue.partition(":") + if not venue_id: + raise UsageError("--venue takes PROVIDER:VENUE_ID", field="venue") + area = types.MediaAreaVenue( + coordinates=coordinates, + geo=types.GeoPoint(long=0.0, lat=0.0, access_hash=0), + title="", + address="", + provider=provider, + venue_id=venue_id, + venue_type="", + ) + elif req.geo: + if not req.address: + raise UsageError("--geo is only searchable with an --address", field="address") + lat, _, lon = req.geo.partition(",") + parts = [p.strip() for p in req.address.split(",")] + area = types.MediaAreaGeoPoint( + coordinates=coordinates, + geo=types.GeoPoint(long=float(lon), lat=float(lat), access_hash=0), + address=types.GeoPointAddress( + country_iso2=parts[0], + state=parts[1] if len(parts) > 1 else None, + city=parts[2] if len(parts) > 2 else None, + street=parts[3] if len(parts) > 3 else None, + ), + ) + + peer = await _send.resolve(ctx, req.peer) if req.peer is not None else None + result = await client(ctx)( + fn.SearchPostsRequest( + offset=str(state.get("offset") or ""), + limit=limit, + hashtag=req.hashtag, + area=area, + peer=peer, + ) + ) + table = _entities(result) + items = [] + for found in getattr(result, "stories", None) or []: + found_peer = getattr(found, "peer", None) + story = _story.story_model( + getattr(found, "story", None), + peer_id=peer_id_of(found_peer) or 0, + peer=_peer_entity(found_peer, table), + ) + items.append(story) + next_offset = getattr(result, "next_offset", None) + return build_page( + items, + op="story.search", + kind=PageKind.SEARCH, + state={"offset": next_offset}, + account=ctx.account, + has_more=bool(next_offset), + total=getattr(result, "count", None), + ) + + +SPEC_SEARCH = OperationSpec( + id="story.search", + request=SearchReq, + response=Page[Story], + impl=search, + summary="Search public stories by hashtag or location", + paginated=PageKind.SEARCH, + rate_class="resolve", + columns=("peer_id", "id", "date", "caption"), + example={"items": [_EXAMPLE_STORY], "has_more": False}, + example_args="story search --hashtag berlin", + covers=( + "messages-core.search-hashtag-stories", + "stories.search-hashtag", + "stories.search-location", + "stories.search-peer-scoped", + ), +) + + +# --------------------------------------------------------------------------- +# story report +# --------------------------------------------------------------------------- + + +class ReportReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")] + id: Annotated[list[str], arg(1, metavar="ID", variadic=True, help="Story ids.")] = [] + option: Annotated[ + str | None, opt("--option", metavar="B64", help="Opaque option bytes from the last step.") + ] = None + message: Annotated[str | None, opt("--message", help="Free-text comment, when asked for.")] = ( + None + ) + + +async def report(ctx: OpContext, req: ReportReq) -> StoryReport: + """Report a story. Multi-step: an empty `--option` starts the flow. + + The server answers with a menu (`reportResultChooseOption`) or a request + for a comment, and `--json` makes both scriptable — the legacy + `inputReportReason*` constructors no longer exist. + """ + import base64 + + from telethon.tl.functions import stories as fn + + ids = _story.story_ids(req.id) + if not ids: + raise UsageError("give at least one story id", field="id") + peer = await _send.resolve(ctx, req.chat) + option = base64.b64decode(req.option) if req.option else b"" + result = await client(ctx)( + fn.ReportRequest(peer=peer, id=ids, option=option, message=req.message or "") + ) + name = type(result).__name__ + if name == "ReportResultChooseOption": + return StoryReport( + result="choose_option", + title=str(getattr(result, "title", "") or ""), + options=[ + { + "text": str(getattr(item, "text", "") or ""), + "option": base64.b64encode(getattr(item, "option", b"")).decode(), + } + for item in (getattr(result, "options", None) or []) + ], + ) + if name == "ReportResultAddComment": + return StoryReport( + result="add_comment", + comment_required=not bool(getattr(result, "optional", False)), + options=[{"option": base64.b64encode(getattr(result, "option", b"") or b"").decode()}], + ) + return StoryReport(result="reported", reported=True) + + +SPEC_REPORT = OperationSpec( + id="story.report", + request=ReportReq, + response=StoryReport, + impl=report, + summary="Report a story", + mutating=True, + columns=("result", "title", "comment_required"), + example={"result": "choose_option", "title": "What is wrong?", "options": []}, + example_args="story report @alice 42", + covers=("stories.report",), +) + + +# --------------------------------------------------------------------------- +# story stats +# --------------------------------------------------------------------------- + + +class StatsGetReq(Request): + chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")] + id: Annotated[int, arg(1, metavar="ID", help="Story id.")] + forwards: Annotated[ + bool, opt("--forwards", help="List the public reposts instead of the graphs.") + ] = False + dark: Annotated[bool, opt("--dark", help="Ask for the dark-theme graph variant.")] = False + raw: Annotated[bool, opt("--raw", help="Emit the raw StatsGraph JSON.")] = False + + +async def _graph(ctx: OpContext, graph: Any, *, dark: bool, raw: bool) -> dict[str, Any] | None: + """Resolve a `statsGraphAsync` token before reporting it. + + An async graph is a token, not data; handing the token to the caller would + make `story stats` return something nobody can plot. + """ + import json as jsonlib + + from telethon.tl.functions import stats as fn + + if graph is None: + return None + if type(graph).__name__ == "StatsGraphAsync": + graph = await client(ctx)( + fn.LoadAsyncGraphRequest(token=str(getattr(graph, "token", "")), x=1 if dark else None) + ) + if type(graph).__name__ == "StatsGraphError": + return {"error": str(getattr(graph, "error", ""))} + payload = getattr(getattr(graph, "json", None), "data", None) + if payload is None: + return None + if raw: + return {"json": str(payload)} + try: + return dict(jsonlib.loads(payload)) + except (ValueError, TypeError): + return {"json": str(payload)} + + +async def stats_get(ctx: OpContext, req: StatsGetReq) -> StoryStats: + """Story statistics: view/reaction graphs, or the public reposts.""" + from telethon.tl.functions import stats as fn + + peer = await _send.resolve(ctx, req.chat) + if req.forwards: + limit, state = window(ctx, "story.stats.get", PageKind.SEARCH, 30) + result = await client(ctx)( + fn.GetStoryPublicForwardsRequest( + peer=peer, id=req.id, offset=str(state.get("offset") or ""), limit=limit + ) + ) + table = _entities(result) + forwards: list[dict[str, Any]] = [] + for row in getattr(result, "forwards", None) or []: + if type(row).__name__ == "PublicForwardMessage": + message = getattr(row, "message", None) + forwards.append( + { + "kind": "message", + "chat_id": peer_id_of(getattr(message, "peer_id", None)) or 0, + "msg_id": int(getattr(message, "id", 0) or 0), + } + ) + else: + origin = getattr(row, "peer", None) or getattr(row, "peer_id", None) + entity = _peer_entity(origin, table) + forwards.append( + { + "kind": "story", + "chat_id": peer_id_of(origin) or 0, + "story_id": int(getattr(getattr(row, "story", None), "id", 0) or 0), + "title": str(getattr(entity, "title", "") or ""), + } + ) + return StoryStats(forwards=forwards) + + result = await client(ctx)(fn.GetStoryStatsRequest(peer=peer, id=req.id, dark=req.dark or None)) + return StoryStats( + views_graph=await _graph( + ctx, getattr(result, "views_graph", None), dark=req.dark, raw=req.raw + ), + reactions_by_emotion_graph=await _graph( + ctx, getattr(result, "reactions_by_emotion_graph", None), dark=req.dark, raw=req.raw + ), + ) + + +SPEC_STATS_GET = OperationSpec( + id="story.stats.get", + request=StatsGetReq, + response=StoryStats, + impl=stats_get, + summary="Story statistics: view/reaction graphs and public reposts", + description="Needs `can_view_stats` on the channel, or your own story.", + timeout_s=300, + columns=("forwards",), + example={"views_graph": {"columns": []}, "forwards": []}, + example_args="story stats get me 42", + covers=("stories.public-forwards", "stories.stats"), +) + + +# --------------------------------------------------------------------------- +# story live +# --------------------------------------------------------------------------- + + +class LiveGetReq(Request): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="CHAT", required=False, kind="peer", help="Whose live story."), + ] = None + + +async def live_get(ctx: OpContext, req: LiveGetReq) -> LiveStory: + """What is known about a peer's live story. + + Telethon 1.44 speaks layer 227, whose `storyItem` carries no group-call + reference, so the viewer count, publisher and stream settings a live story + keeps on its call are not reachable from here — the story id, its dates + and the live flag are. `vc` (PR-11) owns the call surface; this reports + what the story layer actually exposes and says so rather than returning + zeros that look like an empty broadcast. + """ + from telethon.tl.functions import stories as fn + + peer = await _story.resolve_or_self(ctx, req.chat) + peer_id = await _story.peer_id_for(ctx, peer) + result = await client(ctx)(fn.GetPeerStoriesRequest(peer=peer)) + stories = getattr(getattr(result, "stories", None), "stories", None) or [] + live = [item for item in stories if getattr(item, "live", False)] + if not live: + raise NotFoundError("that peer has no live story right now") + item = live[0] + ctx.warn( + "the pinned Telethon (layer 227) attaches no group call to a story, so the " + "viewer count, publisher and stream settings are not reported" + ) + return LiveStory( + story_id=int(getattr(item, "id", 0) or 0), + peer=peer_id, + date=fmt_dt(getattr(item, "date", None)), + expire_date=fmt_dt(getattr(item, "expire_date", None)), + ) + + +SPEC_LIVE_GET = OperationSpec( + id="story.live.get", + request=LiveGetReq, + response=LiveStory, + impl=live_get, + summary="Info about a peer's live story", + description=( + "Layer 227 exposes the live story itself but not its group call, so " + "the call-side fields stay null and a warning says why." + ), + columns=("story_id", "peer", "live"), + example={"story_id": 42, "peer": 4242, "live": True}, + example_args="story live get @alice", + covers_partial=("livestory.streamer-info", "stories.live-join"), + coverage_note=( + "The live story is reported; its group call is not reachable from " + "layer 227's storyItem, and joining a broadcast needs a media engine " + "tlgr does not have." + ), +) + + +class LiveStartReq(PrivacyOptions, kw_only=True): + chat: Annotated[ + PeerRef | None, + arg(0, metavar="CHAT", required=False, kind="peer", help="Post as this channel."), + ] = None + rtmp: Annotated[bool, opt("--rtmp", help="RTMP mode: an external encoder supplies video.")] = ( + False + ) + caption: Annotated[str | None, opt("--caption", help="Live story caption.")] = None + parse: Annotated[str | None, choice("md", "html", "none", help="Caption formatting.")] = None + pin: Annotated[bool, opt("--pin", help="Keep the recording on the profile page.")] = False + protect: Annotated[bool, opt("--protect", help="noforwards.")] = False + comments: Annotated[str | None, choice("on", "off", help="In-call comment overlay.")] = None + comment_price: Annotated[ + int | None, + opt("--comment-price", metavar="STARS", help="Minimum Stars to comment (0 = free)."), + ] = None + + +async def live_start(ctx: OpContext, req: LiveStartReq) -> LiveStory: + """Start a live story. + + Control-only unless `--rtmp`: `stories.startLive` creates the story and + its call, but tlgr has no media engine, so a non-RTMP live story would + broadcast silence. With `--rtmp` the CLI is a complete answer — it prints + the ingest URL and key, and ffmpeg or OBS supplies the video. + """ + from telethon.tl.functions import phone as phone_fn + from telethon.tl.functions import stories as fn + + peer = await _story.resolve_or_self(ctx, req.chat) + peer_id = await _story.peer_id_for(ctx, peer) + if not req.rtmp: + ctx.warn( + "without --rtmp nothing will supply the video: tlgr has no media " + "engine, so the broadcast would be silent" + ) + text, entities = _send.body(req.caption, parse=req.parse) + rules = await _story.privacy_rules( + ctx, + base=req.privacy or "everyone", + allow=tuple(req.allow), + exclude=tuple(req.exclude), + preset=req.privacy_preset, + ) + updates = await client(ctx)( + fn.StartLiveRequest( + peer=peer, + privacy_rules=rules, + pinned=req.pin or None, + noforwards=req.protect or None, + rtmp_stream=req.rtmp or None, + caption=text or None, + entities=_send.tl_entities(entities), + random_id=random_id(), + messages_enabled=(req.comments != "off") or None, + send_paid_messages_stars=req.comment_price, + ) + ) + story = _story_from_updates(updates, peer_id=peer_id) + live = LiveStory( + story_id=story.id, + peer=peer_id, + rtmp_stream=req.rtmp, + messages_enabled=req.comments != "off", + send_paid_messages_stars=req.comment_price, + pinned=req.pin, + noforwards=req.protect, + ) + for update in getattr(updates, "updates", None) or []: + call = getattr(update, "call", None) + if call is not None: + live.call_id = getattr(call, "id", None) + live.participants_count = getattr(call, "participants_count", None) + live.stream_dc_id = getattr(call, "stream_dc_id", None) + if req.rtmp: + rtmp = await client(ctx)( + phone_fn.GetGroupCallStreamRtmpUrlRequest(peer=peer, revoke=False, live_story=True) + ) + live.rtmp_url = getattr(rtmp, "url", None) + live.rtmp_key = getattr(rtmp, "key", None) + ctx.emit("story_live_started", {"peer": peer_id, "story_id": live.story_id}) + return live + + +SPEC_LIVE_START = OperationSpec( + id="story.live.start", + request=LiveStartReq, + response=LiveStory, + impl=live_start, + summary="Start a live story (optionally RTMP, so an external encoder supplies the video)", + description=( + "One active live story per peer. Setting a comment price spends " + "nothing; end the stream with the call commands." + ), + mutating=True, + rate_class="send", + timeout_s=300, + tags=frozenset({"visible-to-others"}), + columns=("story_id", "peer", "rtmp_stream", "rtmp_url"), + example={"story_id": 42, "peer": 4242, "rtmp_stream": True, "rtmp_url": "rtmps://…"}, + example_args="story live start --rtmp", + covers=("livestory.start-rtmp", "stories.live-start"), +) + + +# --------------------------------------------------------------------------- +# story export +# --------------------------------------------------------------------------- + + +class ExportReq(Request): + chat: Annotated[ + PeerRef | None, arg(0, metavar="CHAT", required=False, kind="peer", help="Whose stories.") + ] = None + out: Annotated[str, opt("--out", metavar="DIR", kind="path", help="Output directory.")] = "." + with_media: Annotated[ + bool, opt("--with-media/--no-media", help="Download each story's photo or video.") + ] = True + jsonl: Annotated[bool, opt("--jsonl", help="Also write one JSON object per story.")] = False + archive: Annotated[ + bool, opt("--archive/--profile", help="Walk the private archive, not the profile page.") + ] = True + max_stories: Annotated[ + int, opt("--max-stories", metavar="N", help="Stop after this many stories.", ge=1) + ] = 1000 + + +async def export(ctx: OpContext, req: ExportReq) -> StoryExport: + """Bulk-export stories with their media to disk. + + The "Export Telegram data → Stories" equivalent, and a thing the GUI has + no button for. File references expire, so each story's media is downloaded + from the item that was just fetched rather than from a cached listing. + """ + import msgspec + from telethon.tl.functions import stories as fn + + peer = await _story.resolve_or_self(ctx, req.chat) + peer_id = await _story.peer_id_for(ctx, peer) + directory = Path(os.path.expanduser(req.out)) + directory.mkdir(parents=True, exist_ok=True) + + collected: list[Story] = [] + files: list[str] = [] + offset = 0 + while len(collected) < req.max_stories: + page_size = min(100, req.max_stories - len(collected)) + request = ( + fn.GetStoriesArchiveRequest(peer=peer, offset_id=offset, limit=page_size) + if req.archive + else fn.GetPinnedStoriesRequest(peer=peer, offset_id=offset, limit=page_size) + ) + result = await client(ctx)(request) + raw = list(getattr(result, "stories", None) or []) + if not raw: + break + for item in raw: + story = _story.story_model(item, peer_id=peer_id) + collected.append(story) + if req.with_media and not story.deleted and not story.skipped: + path = await _export_media(ctx, item, directory, story.id) + if path: + files.append(path) + offset = int(getattr(raw[-1], "id", 0) or 0) + limiter = getattr(ctx, "limiter", None) + if limiter is not None: + await limiter.acquire("bulk") + + if req.jsonl: + target = directory / f"stories-{peer_id}.jsonl" + target.write_bytes(b"\n".join(msgspec.json.encode(item) for item in collected) + b"\n") + files.append(str(target)) + return StoryExport(count=len(collected), out_dir=str(directory), files=files, stories=collected) + + +async def _export_media(ctx: OpContext, item: Any, directory: Path, story_id: int) -> str | None: + from tlgr.ops import _media + + media = getattr(item, "media", None) + document = _media.document_of(media) or _media.photo_of(media) + if document is None: + return None + download = getattr(ctx, "download_file", None) + if download is None: # pragma: no cover - the daemon always supplies one + return None + target = directory / f"story_{story_id}" + path = await download( + document, + target, + size=int(getattr(document, "size", 0) or 0), + dc_id=int(getattr(document, "dc_id", 0) or 0), + ) + return str(path) + + +SPEC_EXPORT = OperationSpec( + id="story.export", + request=ExportReq, + response=StoryExport, + impl=export, + summary="Bulk-export stories with their media to disk", + mutating=False, + rate_class="bulk", + timeout_s=900, + columns=("count", "out_dir"), + example={"count": 12, "out_dir": "./stories", "files": ["./stories/story_42"]}, + example_args="story export me --out ./stories", + covers=("stories.export-stories",), +) + + +# --------------------------------------------------------------------------- +# story watch +# --------------------------------------------------------------------------- + + +class WatchReq(Request): + peer: Annotated[ + list[PeerRef], + opt("--peer", metavar="PEER", kind="peer", help="Only events for these peers."), + ] = [] + since: Annotated[ + str | None, opt("--since", metavar="TS", kind="datetime", help="Replay from this point.") + ] = None + + +async def watch(ctx: OpContext, req: WatchReq) -> Any: + """Stream story events off the daemon's update bus. + + A domain-scoped view of the one bus, not a second update loop: `watch + --events story` in the daemon group emits the same records with the same + field names, because both read the same normalised event. + """ + bus = getattr(ctx, "bus", None) + if bus is None: + raise NotSupportedError("this build has no event bus to watch") + + chats = [_send.peer_id_of(await _send.resolve(ctx, ref)) for ref in req.peer] + subscriber = bus.subscribe( + ctx.account, + types=("story_new", "story_id", "story_read", "story_reaction", "story_stealth"), + chats=chats, + ) + try: + while True: + event = await subscriber.queue.get() + frame = _story_event(event) + if frame is None: + continue + yield Page(items=[frame], has_more=True) + finally: + bus.unsubscribe(subscriber) + + +def _story_event(event: Any) -> StoryEvent | None: + payload = getattr(event, "payload", None) or {} + kind = str(payload.get("kind") or "") + if not kind: + return None + stealth = payload.get("stealth_mode") + return StoryEvent( + kind=kind, + peer=int(payload.get("peer") or getattr(event, "chat_id", 0) or 0), + story_id=payload.get("story_id"), + ids=[int(i) for i in (payload.get("ids") or [])], + reaction=payload.get("reaction"), + max_read_id=payload.get("max_read_id"), + stealth_mode=StealthMode(**stealth) if isinstance(stealth, dict) else None, + at=str(getattr(event, "ts", "") or ""), + ) + + +SPEC_WATCH = OperationSpec( + id="story.watch", + request=WatchReq, + response=Page[StoryEvent], + impl=watch, + summary="Stream story events (new stories, reads, reactions, stealth changes)", + description=( + "Event kinds: story.new, story.id-assigned, story.read, " + "story.reaction-received, story.reaction-sent, story.stealth." + ), + stream=True, + timeout_s=900, + columns=("kind", "peer", "story_id"), + headers=("Kind", "Peer", "Story"), + example={ + "items": [{"event": "story", "kind": "story.new", "peer": 4242, "story_id": 42}], + "has_more": True, + }, + example_args="story watch --peer @alice", + covers=("stories.new-story-events",), +) diff --git a/tlgr/ops/user.py b/tlgr/ops/user.py index 0c89d12..376ae2b 100644 --- a/tlgr/ops/user.py +++ b/tlgr/ops/user.py @@ -1,6 +1,6 @@ """The `user` group: one person's profile, and what this account may do to them. -Two contracts here are frozen by `AGENT.md` and must not drift; the tests in +One contract here is frozen by `AGENT.md` and must not drift; the tests in `tests/test_ops_contacts.py` hold the line. * **`user dialog-status` is three-valued.** `resolved=true, has_dialog=true` @@ -12,11 +12,10 @@ id only consults the local cache, and its network fallback returns `UserEmpty` for any non-contact. Reading that as "no history" is the cold-contact bug this command exists to remove. -* **`user hide-stories` is idempotent and local.** It reads the fresh - `stories_hidden` flag first and returns `already: true` with no RPC when - there is nothing to do, so a bulk pass over hundreds of peers is nearly - free. The other side is never notified and nothing about the chat, the - contact entry or their access to us changes. + +v1's other frozen `user` contract, `user hide-stories`, now lives in the +story group: `story hide` owns the implementation and keeps `user +hide-stories` as a legacy path, so one toggle has one definition. Access hashes are never printed. `access_hash_cached` says whether one is held; the value is per-login-session state that is useless — and unsafe — @@ -40,8 +39,6 @@ PersonalChannel, PhotoResult, ProfilePhoto, - StoriesHidden, - StoriesHiddenPeer, SuggestedBirthday, UserLink, UserProfile, @@ -684,108 +681,6 @@ def unknown(reason: str) -> DialogStatus: ) -# --------------------------------------------------------------------------- -# user hide-stories -# --------------------------------------------------------------------------- - - -class HideStoriesReq(Request): - user: Annotated[ - list[PeerRef], - arg(0, metavar="USER", variadic=True, kind="user", help="Peers to hide."), - ] = [] - unhide: Annotated[bool, opt("--unhide", help="Put them back in the main stories bar.")] = False - all_stories: Annotated[ - str | None, - opt("--all", metavar="ON|OFF", help="Collapse or expand the whole story strip."), - ] = None - - -async def hide_stories(ctx: OpContext, req: HideStoriesReq) -> StoriesHidden: - """Hide or unhide a peer's stories — per account, silently. - - SEMANTICS FROZEN (AGENT.md). Exactly Telegram's own "Hide Stories" menu - item: the peer leaves the main stories bar for the collapsed Hidden list. - The other side is never notified and nothing about the chat, the contact - entry or their access to us changes. - - The fresh `stories_hidden` flag is read first, so a peer already in the - requested state costs no RPC and reports `already: true` — which is what - makes a bulk pass over hundreds of peers nearly free to repeat. - """ - from telethon.tl.functions import stories as sfn - - hidden = not req.unhide - result = StoriesHidden(hidden=hidden) - - if req.all_stories is not None: - wanted = req.all_stories.strip().lower() - if wanted not in ("on", "off"): - raise UsageError("--all takes on or off", field="all") - await client_of(ctx)(sfn.ToggleAllStoriesHiddenRequest(hidden=wanted == "on")) - result.all_hidden = wanted == "on" - if not req.user: - return result - - if not req.user: - raise UsageError("give at least one user, or --all on|off", field="user") - - rows: list[StoriesHiddenPeer] = [] - for ref in req.user: - target = await input_user(ctx, ref) - user = await fetch_user(ctx, target) - was = bool(getattr(user, "stories_hidden", False)) - already = was == hidden - if not already: - peer = await _send.resolve(ctx, ref) - await client_of(ctx)(sfn.TogglePeerStoriesHiddenRequest(peer=peer, hidden=hidden)) - ctx.emit("stories_hidden", {"user_id": int(user.id), "hidden": hidden}) - rows.append( - StoriesHiddenPeer( - user_id=int(getattr(user, "id", 0) or 0), - username=getattr(user, "username", None), - hidden=hidden, - already=already, - ) - ) - - first = rows[0] - result.user_id = first.user_id - result.username = first.username - result.hidden = first.hidden - result.already = first.already - if len(rows) > 1: - result.peers = rows - elif all(row.already for row in rows): - mark_already(ctx) - return result - - -SPEC_HIDE_STORIES = OperationSpec( - id="user.hide-stories", - request=HideStoriesReq, - response=StoriesHidden, - impl=hide_stories, - summary="Hide or unhide a peer's stories (per-account; the other side is never notified)", - description=( - "Idempotent: the fresh flag is read first and `already: true` means " - "no RPC was sent, so repeating a bulk pass is nearly free. Purely " - "local to this account — the chat, the contact entry and their " - "access to you are untouched. `user get` reports the current value " - "as `stories_hidden`. More than one peer fills `peers`; a single " - "peer answers exactly as v1 did." - ), - legacy_paths=("user hide-stories",), - mutating=True, - idempotent=True, - rate_class="bulk", - columns=("user_id", "username", "hidden", "already"), - example={"user_id": 777123, "username": "alice", "hidden": True, "already": False}, - example_args="user hide-stories @alice", - covers=("contacts-users.user-hide-stories", "dialogs.hide-stories-peer"), -) - - # --------------------------------------------------------------------------- # user can-message # ---------------------------------------------------------------------------