From bc843b3170ff85df6cd05c2dccb53229afc9d9bb Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 00:37:41 +0330 Subject: [PATCH 01/10] story models: a story item, its audience, its overlays and its viewers Three shapes exist because the API makes a distinction the GUI hides: a story item comes back as one of three TL classes for one id, an audience is a base rule plus exceptions rather than a value, and the viewers screen mixes plain views with forwards and reposts. Media areas are flattened into one struct with a type discriminator so that `story get --areas-out` writes JSON `story post --areas` reads back. --- tlgr/models/__init__.py | 56 +++++ tlgr/models/story.py | 447 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 503 insertions(+) create mode 100644 tlgr/models/story.py diff --git a/tlgr/models/__init__.py b/tlgr/models/__init__.py index 1f9b6e3..50961b9 100644 --- a/tlgr/models/__init__.py +++ b/tlgr/models/__init__.py @@ -420,6 +420,35 @@ StickerSetOrder, StickerSetsChanged, ) +from tlgr.models.story import ( + AlbumDeleted, + AlbumOrder, + BlockedStoryUser, + BlocklistChange, + LiveStory, + MediaArea, + StealthMode, + StoriesDeleted, + Story, + StoryAlbum, + StoryEvent, + StoryExport, + StoryFeedPeer, + StoryFwdHeader, + StoryHidden, + StoryLimits, + StoryPinned, + StoryPostCheck, + StoryPrivacy, + StoryReactionResult, + StoryRead, + StoryReply, + StoryReport, + StoryShared, + StoryStats, + StoryViewer, + StoryViews, +) from tlgr.models.sync import ( BackfillPage, CatchUpResult, @@ -444,6 +473,8 @@ "AdminResult", "AffiliateBot", "AffiliateResult", + "AlbumDeleted", + "AlbumOrder", "AntiSpamReport", "AppConfigDoc", "ArchiveResult", @@ -462,6 +493,8 @@ "BlockResult", "BlockedPeer", "BlockedSet", + "BlockedStoryUser", + "BlocklistChange", "Boost", "BoostApplied", "BoostStatus", @@ -613,11 +646,13 @@ "LinkResult", "LiveLocation", "LiveStopped", + "LiveStory", "LogLine", "LoginCodes", "LoginEmail", "LoginResult", "MapPreview", + "MediaArea", "MediaEdited", "MediaEvent", "MediaExportResult", @@ -761,14 +796,35 @@ "SponsoredMessage", "SponsoredReport", "StatValue", + "StealthMode", "Sticker", "StickerSet", "StickerSetOrder", "StickerSetsChanged", "StorageCleared", "StorageUsage", + "StoriesDeleted", "StoriesHidden", "StoriesHiddenPeer", + "Story", + "StoryAlbum", + "StoryEvent", + "StoryExport", + "StoryFeedPeer", + "StoryFwdHeader", + "StoryHidden", + "StoryLimits", + "StoryPinned", + "StoryPostCheck", + "StoryPrivacy", + "StoryReactionResult", + "StoryRead", + "StoryReply", + "StoryReport", + "StoryShared", + "StoryStats", + "StoryViewer", + "StoryViews", "StreamChannel", "StreamDownload", "SuggestedBirthday", diff --git a/tlgr/models/story.py b/tlgr/models/story.py new file mode 100644 index 0000000..dd68cd0 --- /dev/null +++ b/tlgr/models/story.py @@ -0,0 +1,447 @@ +"""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 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. + """ + + user_id: int = 0 + username: str | None = None + peer_id: int = 0 + hidden: bool = False + already: bool = False + #: Set instead of the peer fields when `--all` collapsed the whole bar. + all: bool = False + + +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 = "" From 1eac7e04024e7de244fa509a4d240556ff0dedf2 Mon Sep 17 00:00:00 2001 From: Pouri Date: Fri, 4 Sep 2026 00:37:49 +0330 Subject: [PATCH 02/10] story: the 31 story operations, from post and feed to stealth and albums Four RPCs behind one `story list` because the GUI shows one grid with tabs; `story read` clears the unread ring and only `--register-view` puts the account in the poster's viewer list, because an agent that silently appears there is a privacy bug. The feed pages on an opaque state rather than an offset, so its cursor carries the state and the next flag instead of an id that would restart the walk. --- docs/reference/story.md | 1068 +++++++++++++ tlgr/ops/_story.py | 718 +++++++++ tlgr/ops/story.py | 3158 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 4944 insertions(+) create mode 100644 docs/reference/story.md create mode 100644 tlgr/ops/_story.py create mode 100644 tlgr/ops/story.py diff --git a/docs/reference/story.md b/docs/reference/story.md new file mode 100644 index 0000000..dd5607b --- /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. + +``` +tlgr story hide [CHAT] [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · returns `StoryHidden`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `CHAT` | chat | no | 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 (2 full, 2 partial)</summary> + +Full: `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 | no | 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/tlgr/ops/_story.py b/tlgr/ops/_story.py new file mode 100644 index 0000000..31ea093 --- /dev/null +++ b/tlgr/ops/_story.py @@ -0,0 +1,718 @@ +"""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: + from telethon.tl.functions import help as help_fn + + config = await client(ctx)(help_fn.GetConfigRequest()) + username = getattr(config, field, None) + if not username: + from tlgr.core.errors import NotSupportedError + + 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) diff --git a/tlgr/ops/story.py b/tlgr/ops/story.py new file mode 100644 index 0000000..084b0cd --- /dev/null +++ b/tlgr/ops/story.py @@ -0,0 +1,3158 @@ +"""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, + 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 = _send.peer_id_of(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, entities, 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, entities: Any, exclude: list[str]) -> None: + """Warn when the caption @-mentions somebody the audience shuts out. + + The GUI shows the same warning, and it is the difference between a story + that reads as a shout-out and one the person named never sees. + """ + if not exclude: + return + excluded = {str(e).lstrip("@").lower() for e in exclude} + for entity in entities or []: + if getattr(entity, "type", "") == "mention": + ctx.warn( + "a mentioned user may be excluded by the privacy rules " + f"({', '.join(sorted(excluded))}); they will not see the story" + ) + return + + +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 + + +#: `canSendStoryResult*` → the reason string tlgr reports. +_CANNOT: dict[str, str] = { + "CanSendStoryResultPremiumNeeded": "PREMIUM_ACCOUNT_REQUIRED", + "CanSendStoryResultBoostNeeded": "BOOSTS_REQUIRED", + "CanSendStoryResultActiveStoryLimitExceeded": "STORIES_TOO_MUCH", + "CanSendStoryResultWeeklyLimit": "STORY_SEND_FLOOD_WEEKLY", + "CanSendStoryResultMonthlyLimit": "STORY_SEND_FLOOD_MONTHLY", + "CanSendStoryResultLiveStoryIsActive": "STORY_LIVE_ALREADY", +} + + +async def _preflight(ctx: OpContext, peer: Any) -> None: + """`stories.canSendStory`, translated into a refusal a human can act on.""" + from telethon.tl.functions import stories as fn + + result = await client(ctx)(fn.CanSendStoryRequest(peer=peer)) + name = type(result).__name__ + reason = _CANNOT.get(name) + if reason is None: + return + retry = getattr(result, "retry_after", None) or getattr(result, "period", None) + detail = f" (retry after {retry}s)" if retry else "" + if reason == "BOOSTS_REQUIRED": + raise PermissionError_(f"posting a story here needs more boosts{detail}") + if reason == "PREMIUM_ACCOUNT_REQUIRED": + raise PermissionError_("posting this story needs Telegram Premium") + 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) + result = await client(ctx)(fn.CanSendStoryRequest(peer=peer)) + name = type(result).__name__ + check = StoryPostCheck( + can_post=name == "CanSendStoryCount", + reason=_CANNOT.get(name, ""), + count_remains=getattr(result, "count_remains", None), + free_slots=getattr(result, "count_remains", None), + retry_after=getattr(result, "retry_after", None) or getattr(result, "period", None), + boosts_required=getattr(result, "boosts_required", None) or getattr(result, "boosts", 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) + + if req.album is not None: + next_state = {"offset": offset + len(items)} + else: + next_state = {"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 not (req.album is None and not req.archive and not req.profile) else None, + has_more=None if (req.album is not None or req.archive or req.profile) 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[ + PeerRef | None, + arg(0, metavar="CHAT", required=False, kind="peer", help="Whose stories to hide."), + ] = None + 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_hidden(ctx: OpContext, req: HideReq, *, hidden: bool) -> StoryHidden: + from telethon.tl.functions import stories as fn + + if req.every: + await client(ctx)(fn.ToggleAllStoriesHiddenRequest(hidden=hidden)) + return StoryHidden(hidden=hidden, all=True) + if req.chat is None: + raise UsageError("give a peer, or --all for the whole stories bar", field="chat") + + peer = await _send.resolve(ctx, req.chat) + peer_id = _send.peer_id_of(peer) + entity = await client(ctx).get_entity(peer) + was = bool(getattr(entity, "stories_hidden", False)) + if was == hidden: + # v1 detected this and sent nothing, so a bulk pass is cheap to repeat. + already(ctx) + return StoryHidden( + user_id=peer_id if peer_id > 0 else 0, + username=getattr(entity, "username", None), + peer_id=peer_id, + hidden=hidden, + already=True, + ) + await client(ctx)(fn.TogglePeerStoriesHiddenRequest(peer=peer, hidden=hidden)) + ctx.emit("story_peer_hidden", {"peer": peer_id, "hidden": hidden}) + return StoryHidden( + user_id=peer_id if peer_id > 0 else 0, + username=getattr(entity, "username", None), + peer_id=peer_id, + hidden=hidden, + already=False, + ) + + +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." + ), + 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=("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: + entity = _peer_entity(getattr(row, "peer_id", None), table) + forwards.append( + { + "kind": "story", + "chat_id": peer_id_of(getattr(row, "peer_id", None)) 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 = _send.peer_id_of(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 = _send.peer_id_of(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 = _send.peer_id_of(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(getattr(path, "path", 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, "at", "") or payload.get("at") 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",), +) From 24af6c7468dd64e4c106871e6526bd6c91529e66 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 00:38:00 +0330 Subject: [PATCH 03/10] legacy: 'user hide-stories' is now a path of 'story hide' The v1 command, its IPC route and ClientWrapper.set_stories_hidden() are gone; the path stays invocable because the op declares it as a legacy path, --unhide included. That makes the registry generate the 'user' group, so 'user get' and 'user dialog-status' move into LEGACY_EXTRAS until PR-5 migrates them rather than being a second group of the same name, which build_cli() refuses on purpose. --- tlgr/models/__init__.py | 6 +-- tlgr/models/contact.py | 25 --------- tlgr/models/story.py | 15 +++++- tlgr/ops/story.py | 69 +++++++++++++++--------- tlgr/ops/user.py | 114 ++-------------------------------------- 5 files changed, 63 insertions(+), 166 deletions(-) diff --git a/tlgr/models/__init__.py b/tlgr/models/__init__.py index 50961b9..e52e6cd 100644 --- a/tlgr/models/__init__.py +++ b/tlgr/models/__init__.py @@ -174,8 +174,6 @@ ProfilePhoto, SavedPhoneContact, SignUp, - StoriesHidden, - StoriesHiddenPeer, SuggestedBirthday, TopPeer, TopPeerState, @@ -436,6 +434,7 @@ StoryFeedPeer, StoryFwdHeader, StoryHidden, + StoryHiddenPeer, StoryLimits, StoryPinned, StoryPostCheck, @@ -804,8 +803,6 @@ "StorageCleared", "StorageUsage", "StoriesDeleted", - "StoriesHidden", - "StoriesHiddenPeer", "Story", "StoryAlbum", "StoryEvent", @@ -813,6 +810,7 @@ "StoryFeedPeer", "StoryFwdHeader", "StoryHidden", + "StoryHiddenPeer", "StoryLimits", "StoryPinned", "StoryPostCheck", 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 index dd68cd0..ac49517 100644 --- a/tlgr/models/story.py +++ b/tlgr/models/story.py @@ -283,12 +283,24 @@ class StoryPostCheck(Model): chats: list[Peer] = [] +class StoryHiddenPeer(Model): + """One row of a bulk `story hide`, in the same keys the single call uses.""" + + user_id: int = 0 + username: str | None = None + peer_id: int = 0 + hidden: bool = False + already: bool = False + + 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. + 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. """ user_id: int = 0 @@ -298,6 +310,7 @@ class StoryHidden(Model): already: bool = False #: Set instead of the peer fields when `--all` collapsed the whole bar. all: bool = False + peers: list[StoryHiddenPeer] = [] class StoriesDeleted(Model): diff --git a/tlgr/ops/story.py b/tlgr/ops/story.py index 084b0cd..d0184f0 100644 --- a/tlgr/ops/story.py +++ b/tlgr/ops/story.py @@ -58,6 +58,7 @@ StoryExport, StoryFeedPeer, StoryHidden, + StoryHiddenPeer, StoryLimits, StoryPinned, StoryPostCheck, @@ -1817,47 +1818,59 @@ async def unpin(ctx: OpContext, req: PinReq) -> StoryPinned: class HideReq(Request): chat: Annotated[ - PeerRef | None, - arg(0, metavar="CHAT", required=False, kind="peer", help="Whose stories to hide."), - ] = None + 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_hidden(ctx: OpContext, req: HideReq, *, hidden: bool) -> StoryHidden: +async def _toggle_one(ctx: OpContext, ref: PeerRef, *, hidden: bool) -> StoryHiddenPeer: from telethon.tl.functions import stories as fn - if req.every: - await client(ctx)(fn.ToggleAllStoriesHiddenRequest(hidden=hidden)) - return StoryHidden(hidden=hidden, all=True) - if req.chat is None: - raise UsageError("give a peer, or --all for the whole stories bar", field="chat") - - peer = await _send.resolve(ctx, req.chat) + 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)) - if was == hidden: - # v1 detected this and sent nothing, so a bulk pass is cheap to repeat. - already(ctx) - return StoryHidden( - user_id=peer_id if peer_id > 0 else 0, - username=getattr(entity, "username", None), - peer_id=peer_id, - hidden=hidden, - already=True, - ) - await client(ctx)(fn.TogglePeerStoriesHiddenRequest(peer=peer, hidden=hidden)) - ctx.emit("story_peer_hidden", {"peer": peer_id, "hidden": hidden}) - return StoryHidden( + row = StoryHiddenPeer( user_id=peer_id if peer_id > 0 else 0, username=getattr(entity, "username", None), peer_id=peer_id, hidden=hidden, - already=False, + # 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) + 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: @@ -1892,7 +1905,11 @@ async def unhide(ctx: OpContext, req: HideReq) -> StoryHidden: columns=("user_id", "username", "hidden", "already"), example={"user_id": 4242, "username": "alice", "hidden": True, "already": False}, example_args="story hide @alice", - covers=("dialogs.hide-stories-peer", "groups-channels-admin.hide-peer-stories"), + 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.", ) diff --git a/tlgr/ops/user.py b/tlgr/ops/user.py index 0c89d12..416a511 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,9 @@ 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 +38,6 @@ PersonalChannel, PhotoResult, ProfilePhoto, - StoriesHidden, - StoriesHiddenPeer, SuggestedBirthday, UserLink, UserProfile, @@ -684,108 +680,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 # --------------------------------------------------------------------------- From dcd014e82d2cf3dc45cd1bf0cecc6585cc7bf32e Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 00:38:00 +0330 Subject: [PATCH 04/10] parity: the stories domain is accounted for, id by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-domain waiver is gone: 104 of the 120 required ids are covered and the remaining 16 name the group that owns them — close friends are contacts, the live-story call is 'vc', story notifications are 'notify'. Three ids other domains had waived to PR-8 or PR-5 are covered here instead, so their waivers go too. --- tests/test_parity.py | 39 +++++++++++- tlgr/data/parity_waivers.toml | 117 ++++++++++++++++++++++++---------- 2 files changed, 120 insertions(+), 36 deletions(-) diff --git a/tests/test_parity.py b/tests/test_parity.py index b5b49d0..13f6311 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,21 @@ 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 16 remaining ids belong to other groups. + + Close friends are a contacts surface, the live-story call is the `vc` + group, story notifications are the `notify` group — each is waived to + the PR that owns that command, so "the story group is done" is + checkable rather than asserted. + """ + stats = report.by_domain["stories"] + assert stats["accounted_percent"] == 100.0 + assert stats["covered"] >= 104 + + 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/data/parity_waivers.toml b/tlgr/data/parity_waivers.toml index 49cfb12..c6de04d 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,89 @@ 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.close-friends-list" +pr = 5 +reason = "The close-friends list is `contact close-friends list` (PR-5)." + +[[id]] +id = "stories.close-friends-set" +pr = 5 +reason = "Editing close friends is `contact close-friends set` (PR-5)." + +[[id]] +id = "stories.admin-rights" +pr = 7 +reason = "post/edit/delete_stories are admin rights on `chat admin` (PR-7)." + +[[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.live-comments" +pr = 11 +reason = "Commenting in a live story is a group-call message (PR-11)." + +[[id]] +id = "stories.live-end" +pr = 11 +reason = "Ending a live story discards its group call — `vc end` (PR-11)." + +[[id]] +id = "stories.live-highlight-comment" +pr = 11 +reason = "Highlighting a comment with Stars is a group-call action (PR-11)." + +[[id]] +id = "stories.live-message-sender" +pr = 11 +reason = "The send-as identity for call messages is `vc send-as` (PR-11)." + +[[id]] +id = "stories.live-rtmp-url" +pr = 11 +reason = "Getting or revoking an RTMP key is `vc rtmp` (PR-11); `story live start --rtmp` prints it once." + +[[id]] +id = "stories.live-settings" +pr = 11 +reason = "Live comment settings and price live on the call (PR-11)." + +[[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 +840,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 +919,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 From 88b93ca4961005bb48aef7d8c18b0982df52f11f Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 00:58:10 +0330 Subject: [PATCH 05/10] events: story updates reach the bus, so 'story watch' has something to watch Telethon has no event builder for stories, so the six story updates only arrive through a Raw handler. They get their own normaliser rather than a branch in normalise(): the raw feed also carries UpdateNewMessage, whose class name matches that function's substring table and would emit a second, empty message_new beside the real one. --- tlgr/daemon/app.py | 3 ++- tlgr/daemon/events.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) 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. From 846c3a334270170027141fe8f93c500135225567 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 00:58:10 +0330 Subject: [PATCH 06/10] tests: the story world in the fake, and the 132 assertions on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake grows real story items per peer, a profile page and an archive that pin/unpin really move ids between, albums, viewer rows and the opaque feed state. The suite asserts the three things only a request can show: reading a story sends readStories and nothing else, the privacy vector is base-then-allow-then-disallow, and a channel story's viewers come from getStoryReactionsList with the source named in a warning. canSendStory has no result union on layer 227 — the server raises — so the refusal is read off the error, keeping the seconds or boosts the message carries. --- tests/fake_telethon.py | 592 ++++++++++++++++- tests/test_ops_story.py | 1370 +++++++++++++++++++++++++++++++++++++++ tlgr/ops/_story.py | 29 +- tlgr/ops/story.py | 127 ++-- 4 files changed, 2060 insertions(+), 58 deletions(-) create mode 100644 tests/test_ops_story.py diff --git a/tests/fake_telethon.py b/tests/fake_telethon.py index 007c778..a3a2115 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, *, @@ -607,8 +652,48 @@ 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 + #: The story-only blocklist ("Hide my stories from"), as raw user ids. + story_blocklist: list[int] = field(default_factory=list) + #: 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 +931,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 +1908,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: @@ -4731,8 +4879,439 @@ 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_SearchPostsRequest(self, request: Any) -> Any: + 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, + ) + + def _raw_GetBlockedRequest(self, request: Any) -> Any: + if not getattr(request, "my_stories_from", False): + return types.contacts.Blocked(blocked=[], chats=[], users=[]) + rows = self.world.story_blocklist[request.offset : request.offset + request.limit] + return types.contacts.Blocked( + blocked=[ + types.PeerBlocked( + peer_id=types.PeerUser(user_id=user_id), date=datetime.now(timezone.utc) + ) + for user_id in rows + ], + chats=[], + users=[self.world.users[u] for u in rows if u in self.world.users], + ) + + def _raw_UnblockRequest(self, request: Any) -> bool: + raw = self._chat_id(request.id) + if raw in self.world.story_blocklist: + self.world.story_blocklist.remove(raw) + return True + return False + + def _raw_SetBlockedRequest(self, request: Any) -> bool: + self.world.story_blocklist = [self._chat_id(peer) for peer in request.id] + return True + + 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, + ) + + def _raw_GetGroupCallStreamRtmpUrlRequest(self, request: Any) -> Any: + return types.phone.GroupCallStreamRtmpUrl(url="rtmps://dc.tg/s/", key="secret-key") + + # 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_LoadAsyncGraphRequest(self, request: Any) -> Any: + return types.StatsGraph(json=types.DataJSON(data='{"columns": ["reactions"]}')) + + 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 +6012,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_ops_story.py b/tests/test_ops_story.py new file mode 100644 index 0000000..9fb43cb --- /dev/null +++ b/tests/test_ops_story.py @@ -0,0 +1,1370 @@ +"""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, + }, "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.story_blocklist = [BOB] + changed = await result( + client, in_thread, "story.blocklist.set", {"user": ["@bobby"], "remove": True} + ) + assert changed["removed"] == [BOB] + assert stories.story_blocklist == [] + + 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.story_blocklist = [ALICE] + changed = await result( + client, in_thread, "story.blocklist.set", {"user": ["@bobby"], "replace": True} + ) + assert changed["total"] == 1 + assert stories.story_blocklist == [BOB] + assert not stories.called("BlockRequest") + + async def test_the_list_is_its_own_blocklist(self, live_daemon, client, in_thread, stories): + stories.story_blocklist = [BOB] + 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"] == "rtmps://dc.tg/s/" + assert live["rtmp_key"] == "secret-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_story + + event_type, payload, chat_id = normalise_story( + 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_story + + kinds = { + normalise_story(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_story + + assert normalise_story(types.UpdateNewMessage(message=None, pts=1, pts_count=1)) is None + + +# --------------------------------------------------------------------------- +# 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/tlgr/ops/_story.py b/tlgr/ops/_story.py index 31ea093..de54a38 100644 --- a/tlgr/ops/_story.py +++ b/tlgr/ops/_story.py @@ -531,13 +531,22 @@ async def _inline_query(ctx: Any, username: str, query: str, near: str | None) - 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: - from tlgr.core.errors import NotSupportedError - + 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" ) @@ -716,3 +725,19 @@ async def resolve_or_self(ctx: Any, ref: PeerRef | None) -> Any: 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 index d0184f0..2190306 100644 --- a/tlgr/ops/story.py +++ b/tlgr/ops/story.py @@ -348,7 +348,7 @@ async def post(ctx: OpContext, req: PostReq) -> Page[Story]: raise UsageError("give at least one FILE to post", field="file") peer = await _story.resolve_or_self(ctx, req.send_as) - peer_id = _send.peer_id_of(peer) + 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, @@ -373,7 +373,7 @@ async def post(ctx: OpContext, req: PostReq) -> Page[Story]: if req.repost_message: areas.append(await _repost_message_area(ctx, req.repost_message)) - _warn_excluded_mentions(ctx, entities, req.exclude) + _warn_excluded_mentions(ctx, text, req.exclude) fwd_peer, fwd_story = (None, None) if req.repost: @@ -422,22 +422,24 @@ async def post(ctx: OpContext, req: PostReq) -> Page[Story]: return Page(items=items, has_more=False, total=len(items)) -def _warn_excluded_mentions(ctx: OpContext, entities: Any, exclude: list[str]) -> None: +def _warn_excluded_mentions(ctx: OpContext, caption: str, exclude: list[str]) -> None: """Warn when the caption @-mentions somebody the audience shuts out. - The GUI shows the same warning, and it is the difference between a story - that reads as a shout-out and one the person named never sees. + 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: + if not exclude or not caption: return - excluded = {str(e).lstrip("@").lower() for e in exclude} - for entity in entities or []: - if getattr(entity, "type", "") == "mention": - ctx.warn( - "a mentioned user may be excluded by the privacy rules " - f"({', '.join(sorted(excluded))}); they will not see the story" - ) - 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: @@ -613,32 +615,63 @@ def _limits(config: dict[str, Any], *, premium: bool) -> StoryLimits: return limits -#: `canSendStoryResult*` → the reason string tlgr reports. -_CANNOT: dict[str, str] = { - "CanSendStoryResultPremiumNeeded": "PREMIUM_ACCOUNT_REQUIRED", - "CanSendStoryResultBoostNeeded": "BOOSTS_REQUIRED", - "CanSendStoryResultActiveStoryLimitExceeded": "STORIES_TOO_MUCH", - "CanSendStoryResultWeeklyLimit": "STORY_SEND_FLOOD_WEEKLY", - "CanSendStoryResultMonthlyLimit": "STORY_SEND_FLOOD_MONTHLY", - "CanSendStoryResultLiveStoryIsActive": "STORY_LIVE_ALREADY", -} +#: 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", +) -async def _preflight(ctx: OpContext, peer: Any) -> None: - """`stories.canSendStory`, translated into a refusal a human can act on.""" +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 - result = await client(ctx)(fn.CanSendStoryRequest(peer=peer)) - name = type(result).__name__ - reason = _CANNOT.get(name) - if reason is None: + 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.""" + _result, reason, number = await _check(ctx, peer) + if not reason: return - retry = getattr(result, "retry_after", None) or getattr(result, "period", None) - detail = f" (retry after {retry}s)" if retry else "" - if reason == "BOOSTS_REQUIRED": - raise PermissionError_(f"posting a story here needs more boosts{detail}") + 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}") @@ -664,15 +697,14 @@ async def can_post(ctx: OpContext, req: CanPostReq) -> StoryPostCheck: from tlgr.ops import _media peer = await _story.resolve_or_self(ctx, req.send_as) - result = await client(ctx)(fn.CanSendStoryRequest(peer=peer)) - name = type(result).__name__ + outcome, reason, number = await _check(ctx, peer) check = StoryPostCheck( - can_post=name == "CanSendStoryCount", - reason=_CANNOT.get(name, ""), - count_remains=getattr(result, "count_remains", None), - free_slots=getattr(result, "count_remains", None), - retry_after=getattr(result, "retry_after", None) or getattr(result, "period", None), - boosts_required=getattr(result, "boosts_required", None) or getattr(result, "boosts", None), + 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() @@ -2776,11 +2808,12 @@ async def stats_get(ctx: OpContext, req: StatsGetReq) -> StoryStats: } ) else: - entity = _peer_entity(getattr(row, "peer_id", None), table) + 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(getattr(row, "peer_id", None)) or 0, + "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 ""), } @@ -2838,7 +2871,7 @@ async def live_get(ctx: OpContext, req: LiveGetReq) -> LiveStory: from telethon.tl.functions import stories as fn peer = await _story.resolve_or_self(ctx, req.chat) - peer_id = _send.peer_id_of(peer) + 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)] @@ -2910,7 +2943,7 @@ async def live_start(ctx: OpContext, req: LiveStartReq) -> LiveStory: from telethon.tl.functions import stories as fn peer = await _story.resolve_or_self(ctx, req.chat) - peer_id = _send.peer_id_of(peer) + 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 " @@ -3018,7 +3051,7 @@ async def export(ctx: OpContext, req: ExportReq) -> StoryExport: from telethon.tl.functions import stories as fn peer = await _story.resolve_or_self(ctx, req.chat) - peer_id = _send.peer_id_of(peer) + peer_id = await _story.peer_id_for(ctx, peer) directory = Path(os.path.expanduser(req.out)) directory.mkdir(parents=True, exist_ok=True) @@ -3148,7 +3181,7 @@ def _story_event(event: Any) -> StoryEvent | None: 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, "at", "") or payload.get("at") or ""), + at=str(getattr(event, "ts", "") or ""), ) From aa93ca2ae80b78284d211481a53cb25ac2d25d06 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 01:04:18 +0330 Subject: [PATCH 07/10] docs: what an agent can do with a story, and the eleven decisions behind it AGENT.md gains a Stories section built around the four rules that bite: reading is not being seen, the audience is a base rule plus exceptions in that order, a feed placeholder is not a caption-less story, and the post quota moves under you. The v1 'user hide-stories' paragraph now points at 'story hide' and keeps its promise. CHANGELOG records the group, the one deletion and the paths that survive it. --- AGENT.md | 82 +++++++++++++++++++++++---- CHANGELOG.md | 45 ++++++++++++++- README.md | 24 +++++++- docs/design/DECISIONS.md | 104 +++++++++++++++++++++++++++++++++++ tests/test_agentmd_compat.py | 7 +++ tests/test_ops_story.py | 20 ++++--- 6 files changed, 263 insertions(+), 19 deletions(-) diff --git a/AGENT.md b/AGENT.md index ff19cd1..2887ab1 100644 --- a/AGENT.md +++ b/AGENT.md @@ -738,7 +738,7 @@ tlgr user dialog-status <user> [--max-dialogs N] → {"ref": ..., "id": ..., "username": ..., "resolved": true, "has_dialog": true, "message_count": 12, "source": "peer_dialogs", "reason": null} -tlgr user hide-stories <user>... [--unhide] [--all on|off] +tlgr user hide-stories <user>... [--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 <peer>` (and `--unhide` is +`tlgr story unhide <peer>`). 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 <chat> # active; --profile, --archive, --album ID +→ {"items": [{"id": 42, "date": "…Z", "expire_date": "…Z", "caption": "…", + "media": {…}, "pinned": false}], "has_more": false} + +tlgr story get <chat> <id>... # --views, --link, --areas-out, --translate +tlgr story post <file>... # --caption, --privacy, --allow, --exclude, + # --period, --pin, --album, --area-* +tlgr story edit <chat> <id> # --caption, --file, --cover-ts, --privacy +tlgr story delete <chat> <id>... # irreversible; needs --yes off a TTY + +tlgr story read <chat> [<id>...] # clears YOUR unread ring +→ {"peer": …, "max_id": 43, "ids": [42, 43], "ok": true} +tlgr story read <chat> <id> --register-view # …and appear in their viewer list + +tlgr story react <chat> <id> 🔥 # --remove, --custom-emoji, --as-message +tlgr story reply <chat> <id> "text" # a private message carrying the story +tlgr story share <chat> <id> --until <chat> # sends a story card, not a copy + +tlgr story pin|unpin <chat> <id>... # the profile page; --top for the top row +tlgr story hide|unhide <chat> # the stories bar; --all for the whole bar +tlgr story viewer list <chat> <id> # --contacts, --q, --csv PATH, --hide-from +tlgr story blocklist list|set <user>... # "Hide my stories from"; --remove, --replace +tlgr story album create|edit|list|delete|reorder <chat> … +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 <chat> <id> # graphs; --forwards for public reposts +tlgr story export <chat> --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..4f6c9a0 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. +- **`ClientWrapper.set_stories_hidden()` and the `/user/stories-hidden` IPC + route.** `story hide` replaces both, and `tlgr user hide-stories` is + declared as its legacy path rather than kept as a second 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 <user> # --full --translate-bio LANG --from-chat/--from-message tlgr user dialog-status <user> # does THIS account have prior history with them? -tlgr user hide-stories <user>... # archive their stories for this account (--unhide) +tlgr user hide-stories <user>... # v1's spelling of `story hide` (--unhide) tlgr user block <user> # --stories --report-spam --delete-history tlgr user unblock <user> tlgr user can-message <user>... # 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 <chat> # active; --profile, --archive, --album ID +tlgr story get <chat> <id> # --views, --link, --areas-out, --translate +tlgr story post <file>... # --caption, --privacy, --allow, --exclude, + # --period, --pin, --album, --area-url, … +tlgr story read <chat> # clears YOUR ring; --register-view to be seen +tlgr story react|reply|share <chat> <id> +tlgr story pin|unpin|hide|unhide <chat> [<id>...] +tlgr story viewer list <chat> <id> # --contacts, --q, --csv PATH +tlgr story blocklist set <user>... # "Hide my stories from" +tlgr story album create|edit|list|delete|reorder <chat> … +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..9d55fda 100644 --- a/docs/design/DECISIONS.md +++ b/docs/design/DECISIONS.md @@ -1128,3 +1128,107 @@ 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 is waived to PR-11, which owns the call. + +## 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 16 ids it does not own + +Same shape as `media_files`: the catalog groups by subject, tlgr by command +group. The close-friends list is a contacts surface, a live story's comments +and RTMP key belong to the call group, story notification settings belong to +`notify`, 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. `stories` is 86.7 % +covered and 100 % accounted. 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_story.py b/tests/test_ops_story.py index 9fb43cb..d4fb57d 100644 --- a/tests/test_ops_story.py +++ b/tests/test_ops_story.py @@ -1258,12 +1258,13 @@ async def test_a_raw_story_update_reaches_the_stream(self, live_daemon, world): from telethon.tl import types - from tlgr.daemon.events import normalise_story + from tlgr.daemon.events import normalise_update - event_type, payload, chat_id = normalise_story( + 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) @@ -1278,10 +1279,10 @@ async def test_a_raw_story_update_reaches_the_stream(self, live_daemon, world): def test_every_story_update_class_is_named(self): from telethon.tl import types - from tlgr.daemon.events import normalise_story + from tlgr.daemon.events import normalise_update kinds = { - normalise_story(update)[1]["kind"] + 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), @@ -1307,9 +1308,14 @@ def test_every_story_update_class_is_named(self): def test_an_ordinary_update_is_not_a_story_event(self): from telethon.tl import types - from tlgr.daemon.events import normalise_story + from tlgr.daemon.events import normalise_update - assert normalise_story(types.UpdateNewMessage(message=None, pts=1, pts_count=1)) is None + # 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" # --------------------------------------------------------------------------- From 794f5a6aa74598b2c1bf4dea6a432520b533f70c Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 01:07:53 +0330 Subject: [PATCH 08/10] story list: name the three RPCs that page, instead of inverting the flags getPeerStories hands back the peer's whole active set in one call, so guessing has_more from a full page there would hand out a cursor that returns nothing. The condition now has a name and the comment says why. --- tlgr/ops/story.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tlgr/ops/story.py b/tlgr/ops/story.py index 2190306..fbb4106 100644 --- a/tlgr/ops/story.py +++ b/tlgr/ops/story.py @@ -662,7 +662,7 @@ async def _check(ctx: OpContext, peer: Any) -> tuple[Any, str, int | None]: async def _preflight(ctx: OpContext, peer: Any) -> None: """`stories.canSendStory`, translated into a refusal a human can act on.""" - _result, reason, number = await _check(ctx, peer) + _count, reason, number = await _check(ctx, peer) if not reason: return detail = f" ({number})" if number is not None else "" @@ -990,18 +990,23 @@ async def list_stories(ctx: OpContext, req: ListReq) -> Page[Story]: if req.translate: await _translate_captions(ctx, items, req.translate) - if req.album is not None: - next_state = {"offset": offset + len(items)} - else: - next_state = {"offset": items[-1].id if items else offset} + # 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 not (req.album is None and not req.archive and not req.profile) else None, - has_more=None if (req.album is not None or req.archive or req.profile) else False, + limit=limit if server_paged else None, + has_more=None if server_paged else False, total=getattr(result, "count", None), ) @@ -3105,7 +3110,7 @@ async def _export_media(ctx: OpContext, item: Any, directory: Path, story_id: in size=int(getattr(document, "size", 0) or 0), dc_id=int(getattr(document, "dc_id", 0) or 0), ) - return str(getattr(path, "path", path)) + return str(path) SPEC_EXPORT = OperationSpec( From 077e5dc35bfc55534540dfc7e5de7d33182d5b93 Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 04:15:25 +0330 Subject: [PATCH 09/10] rebase: the story group meets PR-4, PR-5 and PR-11 where they landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of this branch's decisions were written against a `main` that has since moved. Replaying them verbatim would have produced a tree that does not import, so they are merged rather than repeated. `user hide-stories`. PR-5 landed it as an operation of its own while this branch declared the same path as a legacy path of `story hide`, 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 and absorbs what the `user` op had that it did not: several peers in one pass, and the whole-bar toggle. AGENT.md's four frozen keys, the idempotence and the bulk `peers` shape are unchanged, and PR-5's contract tests exercise them through `story.hide`. `hidden` and `already` lose their defaults so that `omit_defaults` cannot drop the two false values AGENT.md publishes. Story events. PR-4's taxonomy already names all six story constructors and the daemon already subscribes with one `events.Raw()` handler, so the second normaliser and the second handler are gone: the payload shaping is a branch inside `normalise_update`, keeping the fine-grained `kind` that tells a received story reaction from one this account sent. Waivers. PR-5, PR-7 and PR-11 cover close friends, boost status and the whole live-story call surface outright, so `stories` needs 7 waivers rather than 16 and the floors rise to what the registry now claims. The fake client is one merged file, so nine handler pairs are merged by hand rather than shadowed: `SearchPosts` dispatches on the TL namespace (`messages.` and `stories.` share a class name), `LoadAsyncGraph` on the token, and the blocklist, hidden-peer and read-marker stores collapse onto one field each. --- AGENT.md | 2 +- CHANGELOG.md | 6 +-- docs/design/DECISIONS.md | 19 ++++---- tests/fake_telethon.py | 85 +++++++---------------------------- tests/test_ops_contacts.py | 35 +++++++-------- tests/test_ops_story.py | 33 +++++++------- tests/test_parity.py | 16 ++++--- tlgr/data/parity_waivers.toml | 45 ------------------- tlgr/models/story.py | 17 ++++--- tlgr/ops/story.py | 7 ++- tlgr/ops/user.py | 1 + 11 files changed, 94 insertions(+), 172 deletions(-) diff --git a/AGENT.md b/AGENT.md index 2887ab1..cf93e44 100644 --- a/AGENT.md +++ b/AGENT.md @@ -959,7 +959,7 @@ tlgr story reply <chat> <id> "text" # a private message carrying the story tlgr story share <chat> <id> --until <chat> # sends a story card, not a copy tlgr story pin|unpin <chat> <id>... # the profile page; --top for the top row -tlgr story hide|unhide <chat> # the stories bar; --all for the whole bar +tlgr story hide|unhide <chat>... # the stories bar; --all for the whole bar tlgr story viewer list <chat> <id> # --contacts, --q, --csv PATH, --hide-from tlgr story blocklist list|set <user>... # "Hide my stories from"; --remove, --replace tlgr story album create|edit|list|delete|reorder <chat> … diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f6c9a0..40ea533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -590,9 +590,9 @@ 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. -- **`ClientWrapper.set_stories_hidden()` and the `/user/stories-hidden` IPC - route.** `story hide` replaces both, and `tlgr user hide-stories` is - declared as its legacy path rather than kept as a second implementation. +- **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`, diff --git a/docs/design/DECISIONS.md b/docs/design/DECISIONS.md index 9d55fda..143c837 100644 --- a/docs/design/DECISIONS.md +++ b/docs/design/DECISIONS.md @@ -1183,7 +1183,8 @@ 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 is waived to PR-11, which owns the call. +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 @@ -1223,12 +1224,14 @@ 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 16 ids it does not own +## 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. The close-friends list is a contacts surface, a live story's comments -and RTMP key belong to the call group, story notification settings belong to -`notify`, 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. `stories` is 86.7 % -covered and 100 % accounted. +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/tests/fake_telethon.py b/tests/fake_telethon.py index a3a2115..2da8e82 100644 --- a/tests/fake_telethon.py +++ b/tests/fake_telethon.py @@ -580,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. @@ -684,8 +683,6 @@ class World: #: string to make the server answer `storiesAllStoriesNotModified`. story_feed_not_modified: str | None = None stealth_mode: Any = None - #: The story-only blocklist ("Hide my stories from"), as raw user ids. - story_blocklist: list[int] = field(default_factory=list) #: What `stories.canSendStory` answers; a count means "yes". can_send_story: Any = None story_albums_hash: int = 4242 @@ -2501,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: @@ -3723,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 @@ -4431,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: @@ -5228,42 +5219,6 @@ def _raw_ActivateStealthModeRequest(self, request: Any) -> types.Updates: ) return self._updates() - def _raw_SearchPostsRequest(self, request: Any) -> Any: - 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, - ) - - def _raw_GetBlockedRequest(self, request: Any) -> Any: - if not getattr(request, "my_stories_from", False): - return types.contacts.Blocked(blocked=[], chats=[], users=[]) - rows = self.world.story_blocklist[request.offset : request.offset + request.limit] - return types.contacts.Blocked( - blocked=[ - types.PeerBlocked( - peer_id=types.PeerUser(user_id=user_id), date=datetime.now(timezone.utc) - ) - for user_id in rows - ], - chats=[], - users=[self.world.users[u] for u in rows if u in self.world.users], - ) - - def _raw_UnblockRequest(self, request: Any) -> bool: - raw = self._chat_id(request.id) - if raw in self.world.story_blocklist: - self.world.story_blocklist.remove(raw) - return True - return False - - def _raw_SetBlockedRequest(self, request: Any) -> bool: - self.world.story_blocklist = [self._chat_id(peer) for peer in request.id] - return True - def _raw_StartLiveRequest(self, request: Any) -> types.Updates: chat_id = self._chat_id(request.peer) self.world.next_story_id += 1 @@ -5277,9 +5232,6 @@ def _raw_StartLiveRequest(self, request: Any) -> types.Updates: seq=0, ) - def _raw_GetGroupCallStreamRtmpUrlRequest(self, request: Any) -> Any: - return types.phone.GroupCallStreamRtmpUrl(url="rtmps://dc.tg/s/", key="secret-key") - # statistics ----------------------------------------------------------- def _raw_GetStoryStatsRequest(self, request: Any) -> Any: @@ -5288,9 +5240,6 @@ def _raw_GetStoryStatsRequest(self, request: Any) -> Any: reactions_by_emotion_graph=types.StatsGraphAsync(token="graph-token"), ) - def _raw_LoadAsyncGraphRequest(self, request: Any) -> Any: - return types.StatsGraph(json=types.DataJSON(data='{"columns": ["reactions"]}')) - def _raw_GetStoryPublicForwardsRequest(self, request: Any) -> Any: return types.stats.PublicForwards( count=1, 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 index d4fb57d..64c2799 100644 --- a/tests/test_ops_story.py +++ b/tests/test_ops_story.py @@ -709,32 +709,35 @@ async def test_unpin_top_with_no_ids_clears_the_row( 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"}) + 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"}) + 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"}) + 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}) + 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 @@ -766,7 +769,7 @@ def test_the_v1_path_is_still_invocable(self): 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"}) + hidden = await result(client, in_thread, "user.hide-stories", {"chat": ["@alice"]}) assert set(hidden) >= {"user_id", "username", "hidden"} @@ -1003,12 +1006,12 @@ async def test_adding_uses_the_story_only_flag(self, live_daemon, client, in_thr 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.story_blocklist = [BOB] + stories.block(BOB, stories=True) changed = await result( client, in_thread, "story.blocklist.set", {"user": ["@bobby"], "remove": True} ) assert changed["removed"] == [BOB] - assert stories.story_blocklist == [] + assert stories.blocked_stories == {} async def test_removing_somebody_absent_is_already( self, live_daemon, client, in_thread, stories @@ -1019,16 +1022,16 @@ async def test_removing_somebody_absent_is_already( assert envelope["result"]["already"] is True async def test_replace_overwrites_in_one_rpc(self, live_daemon, client, in_thread, stories): - stories.story_blocklist = [ALICE] + stories.block(ALICE, stories=True) changed = await result( client, in_thread, "story.blocklist.set", {"user": ["@bobby"], "replace": True} ) assert changed["total"] == 1 - assert stories.story_blocklist == [BOB] + 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.story_blocklist = [BOB] + 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 @@ -1214,8 +1217,8 @@ async def test_starting_without_rtmp_warns_about_the_silence( 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"] == "rtmps://dc.tg/s/" - assert live["rtmp_key"] == "secret-key" + 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): diff --git a/tests/test_parity.py b/tests/test_parity.py index 13f6311..902900c 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -490,16 +490,18 @@ 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 16 remaining ids belong to other groups. - - Close friends are a contacts surface, the live-story call is the `vc` - group, story notifications are the `notify` group — each is waived to - the PR that owns that command, so "the story group is done" is - checkable rather than asserted. + """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"] >= 104 + assert stats["covered"] >= 113 def test_the_stories_domain_is_no_longer_waived_wholesale(self): assert "stories" not in waivers().domains diff --git a/tlgr/data/parity_waivers.toml b/tlgr/data/parity_waivers.toml index c6de04d..a9654ad 100644 --- a/tlgr/data/parity_waivers.toml +++ b/tlgr/data/parity_waivers.toml @@ -731,21 +731,6 @@ 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.close-friends-list" -pr = 5 -reason = "The close-friends list is `contact close-friends list` (PR-5)." - -[[id]] -id = "stories.close-friends-set" -pr = 5 -reason = "Editing close friends is `contact close-friends set` (PR-5)." - -[[id]] -id = "stories.admin-rights" -pr = 7 -reason = "post/edit/delete_stories are admin rights on `chat admin` (PR-7)." - [[id]] id = "stories.boost-status" pr = 7 @@ -756,36 +741,6 @@ id = "stories.business-story" pr = 10 reason = "Posting for a business account goes through a bot connection (PR-10)." -[[id]] -id = "stories.live-comments" -pr = 11 -reason = "Commenting in a live story is a group-call message (PR-11)." - -[[id]] -id = "stories.live-end" -pr = 11 -reason = "Ending a live story discards its group call — `vc end` (PR-11)." - -[[id]] -id = "stories.live-highlight-comment" -pr = 11 -reason = "Highlighting a comment with Stars is a group-call action (PR-11)." - -[[id]] -id = "stories.live-message-sender" -pr = 11 -reason = "The send-as identity for call messages is `vc send-as` (PR-11)." - -[[id]] -id = "stories.live-rtmp-url" -pr = 11 -reason = "Getting or revoking an RTMP key is `vc rtmp` (PR-11); `story live start --rtmp` prints it once." - -[[id]] -id = "stories.live-settings" -pr = 11 -reason = "Live comment settings and price live on the call (PR-11)." - [[id]] id = "stories.notify-exceptions" pr = 12 diff --git a/tlgr/models/story.py b/tlgr/models/story.py index ac49517..79ba3e0 100644 --- a/tlgr/models/story.py +++ b/tlgr/models/story.py @@ -284,13 +284,17 @@ class StoryPostCheck(Model): class StoryHiddenPeer(Model): - """One row of a bulk `story hide`, in the same keys the single call uses.""" + """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 - hidden: bool = False - already: bool = False class StoryHidden(Model): @@ -303,11 +307,14 @@ class StoryHidden(Model): 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 - hidden: bool = False - already: bool = False #: Set instead of the peer fields when `--all` collapsed the whole bar. all: bool = False peers: list[StoryHiddenPeer] = [] diff --git a/tlgr/ops/story.py b/tlgr/ops/story.py index fbb4106..b6c291c 100644 --- a/tlgr/ops/story.py +++ b/tlgr/ops/story.py @@ -1888,7 +1888,7 @@ async def _toggle_one(ctx: OpContext, ref: PeerRef, *, hidden: bool) -> StoryHid async def _toggle_hidden(ctx: OpContext, req: HideReq, *, hidden: bool) -> StoryHidden: from telethon.tl.functions import stories as fn - result = StoryHidden(hidden=hidden) + result = StoryHidden(hidden=hidden, already=False) if req.every: await client(ctx)(fn.ToggleAllStoriesHiddenRequest(hidden=hidden)) result.all = True @@ -1934,7 +1934,10 @@ async def unhide(ctx: OpContext, req: HideReq) -> StoryHidden: 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." + "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, diff --git a/tlgr/ops/user.py b/tlgr/ops/user.py index 416a511..376ae2b 100644 --- a/tlgr/ops/user.py +++ b/tlgr/ops/user.py @@ -12,6 +12,7 @@ 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. + 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. From 3324f82d7350bd1fdb73c74a1e0c6ae7fc7ca26a Mon Sep 17 00:00:00 2001 From: Pouri <erfnzdeh@gmail.com> Date: Fri, 4 Sep 2026 04:15:30 +0330 Subject: [PATCH 10/10] docs: regenerate reference and parity after rebase --- docs/reference/PARITY.md | 69 ++++++++++++++++++++-------------------- docs/reference/README.md | 5 +-- docs/reference/story.md | 14 ++++---- docs/reference/user.md | 34 +------------------- 4 files changed, 46 insertions(+), 76 deletions(-) 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 index dd5607b..5cd870a 100644 --- a/docs/reference/story.md +++ b/docs/reference/story.md @@ -445,17 +445,17 @@ Full: `stories.album-link`, `stories.caption-entities`, `stories.get-by-id`, `st 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. +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] +tlgr story hide [CHAT]... [OPTIONS] ``` **mutating · idempotent (reports `already`) · returns `StoryHidden`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | no | Whose stories to hide. | +| `CHAT` | chat | one or more | Whose stories to hide. | | Flag | Type | Default | Meaning | |---|---|---|---| @@ -468,9 +468,9 @@ Also invocable as: `tlgr user hide-stories` $ tlgr story hide @alice --json ``` -<details><summary>Catalog coverage (2 full, 2 partial)</summary> +<details><summary>Catalog coverage (3 full, 2 partial)</summary> -Full: `dialogs.hide-stories-peer`, `groups-channels-admin.hide-peer-stories` +Full: `contacts-users.user-hide-stories`, `dialogs.hide-stories-peer`, `groups-channels-admin.hide-peer-stories` Partial: `stories.hide-all`, `stories.hide-peer` @@ -944,14 +944,14 @@ Full: `stories.stealth-activate`, `stories.stealth-status` Put a peer's stories back in the main bar. ``` -tlgr story unhide [CHAT] [OPTIONS] +tlgr story unhide [CHAT]... [OPTIONS] ``` **mutating · idempotent (reports `already`) · returns `StoryHidden`** | Argument | Type | Required | Meaning | |---|---|---|---| -| `CHAT` | chat | no | Whose stories to hide. | +| `CHAT` | chat | one or more | Whose stories to hide. | | Flag | Type | Default | Meaning | |---|---|---|---| 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.