tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/story.py ADDED
@@ -0,0 +1,3216 @@
1
+ """The `story` group: post, read, react to and manage stories.
2
+
3
+ Stories are the one surface where Telegram's API and its GUI disagree the
4
+ most, and the shape of this module follows the API rather than the screen.
5
+
6
+ * **Four RPCs are one list.** The GUI shows a peer's stories as one grid with
7
+ tabs; the API has `getPeerStories`, `getPinnedStories`, `getStoriesArchive`
8
+ and `getAlbumStories`. `story list` is one command with four flags, so a
9
+ caller never has to know which tab maps to which method.
10
+ * **Reading and being seen are different acts.** `stories.readStories` clears
11
+ *your* unread ring; `stories.incrementStoryViews` is what puts you in the
12
+ poster's viewer list. v1's story-less world never had to make the
13
+ distinction; here `story read` does the first and `--register-view` opts
14
+ into the second, because an agent that silently appears in somebody's
15
+ viewer list is a privacy bug.
16
+ * **The audience is a vector, not a value.** `--privacy` sets the base rule
17
+ and `--allow`/`--exclude` layer exceptions on top, in that order, which is
18
+ the only way "contacts, except Bob" is expressible.
19
+ * **The feed has no offsets.** `stories.getAllStories` pages with an opaque
20
+ `state` plus a `next` flag, so `story feed list`'s cursor carries both.
21
+
22
+ `user hide-stories` is v1's spelling of `story hide` and keeps working: the
23
+ op declares it as a legacy path, so the old invocation resolves to the new
24
+ operation rather than to a module that no longer exists.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import csv
30
+ import os
31
+ from pathlib import Path
32
+ from typing import Annotated, Any
33
+
34
+ from tlgr.core.errors import (
35
+ NotFoundError,
36
+ NotSupportedError,
37
+ PermissionError_,
38
+ UsageError,
39
+ )
40
+ from tlgr.core.pagination import PageKind, build_page
41
+ from tlgr.core.timefmt import fmt_dt, to_unix
42
+ from tlgr.models.base import Request
43
+ from tlgr.models.message import Message
44
+ from tlgr.models.page import Page
45
+ from tlgr.models.peer import PeerRef, UserRef
46
+ from tlgr.models.story import (
47
+ AlbumDeleted,
48
+ AlbumOrder,
49
+ BlockedStoryUser,
50
+ BlocklistChange,
51
+ LiveStory,
52
+ MediaArea,
53
+ StealthMode,
54
+ StoriesDeleted,
55
+ Story,
56
+ StoryAlbum,
57
+ StoryEvent,
58
+ StoryExport,
59
+ StoryFeedPeer,
60
+ StoryHidden,
61
+ StoryHiddenPeer,
62
+ StoryLimits,
63
+ StoryPinned,
64
+ StoryPostCheck,
65
+ StoryReactionResult,
66
+ StoryRead,
67
+ StoryReply,
68
+ StoryReport,
69
+ StoryShared,
70
+ StoryStats,
71
+ StoryViewer,
72
+ )
73
+ from tlgr.ops import _send, _story
74
+ from tlgr.ops._common import already, client, random_id, window
75
+ from tlgr.ops._params import arg, choice, opt
76
+ from tlgr.ops._serialize import entity_to_peer, peer_id_of
77
+ from tlgr.ops._spec import OpContext, OperationSpec
78
+
79
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
80
+
81
+ _EXAMPLE_STORY: dict[str, Any] = {
82
+ "id": 42,
83
+ "peer_id": 4242,
84
+ "date": "2026-09-03T09:14:07Z",
85
+ "date_unix": 1788426847,
86
+ "expire_date": "2026-09-04T09:14:07Z",
87
+ "caption": "morning",
88
+ "public": True,
89
+ "views": {"views_count": 128, "reactions_count": 9, "has_viewers": True},
90
+ }
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Shared plumbing
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def _entities(result: Any) -> dict[int, Any]:
99
+ """`raw id → entity` for the users and chats a story reply carries."""
100
+ table: dict[int, Any] = {}
101
+ for entity in (
102
+ *(getattr(result, "users", None) or []),
103
+ *(getattr(result, "chats", None) or []),
104
+ ):
105
+ table[int(getattr(entity, "id", 0) or 0)] = entity
106
+ return table
107
+
108
+
109
+ def _peer_entity(peer: Any, table: dict[int, Any]) -> Any:
110
+ for attribute in ("user_id", "chat_id", "channel_id"):
111
+ value = getattr(peer, attribute, None)
112
+ if value is not None:
113
+ return table.get(int(value))
114
+ return None
115
+
116
+
117
+ def _link_story_id(ref: PeerRef | None) -> int | None:
118
+ """The story id inside `t.me/<user>/s/<id>` or `tg://…&story=<id>`.
119
+
120
+ The peer parser already reduced the link to its peer half and kept the
121
+ original text in `raw`, so the id is read back from there rather than by
122
+ parsing the link a second time somewhere else.
123
+ """
124
+ if ref is None:
125
+ return None
126
+ raw = str(getattr(ref, "raw", "") or "")
127
+ if "story=" in raw:
128
+ tail = raw.split("story=", 1)[1].split("&", 1)[0]
129
+ return int(tail) if tail.isdigit() else None
130
+ parts = [p for p in raw.replace("?", "/").split("/") if p]
131
+ for index, part in enumerate(parts[:-1]):
132
+ if part == "s" and parts[index + 1].isdigit():
133
+ return int(parts[index + 1])
134
+ return None
135
+
136
+
137
+ async def _stories_of(ctx: OpContext, peer: Any, ids: list[int]) -> list[Any]:
138
+ """`stories.getStoriesByID`, which is also how a skipped item is hydrated."""
139
+ from telethon.tl.functions import stories as fn
140
+
141
+ if not ids:
142
+ return []
143
+ result = await client(ctx)(fn.GetStoriesByIDRequest(peer=peer, id=ids))
144
+ return list(getattr(result, "stories", None) or [])
145
+
146
+
147
+ async def _require_own_story(ctx: OpContext, peer: Any, story_id: int) -> Any:
148
+ """Fetch one story, or say which of the two reasons it is unavailable."""
149
+ found = await _stories_of(ctx, peer, [story_id])
150
+ if not found or type(found[0]).__name__ == "StoryItemDeleted":
151
+ raise NotFoundError(f"story {story_id} is not available")
152
+ return found[0]
153
+
154
+
155
+ def _cover_attributes(attributes: list[Any], cover_ts: float | None) -> list[Any]:
156
+ """Put `--cover-ts` on the video attribute, where the server reads it."""
157
+ if cover_ts is None:
158
+ return attributes
159
+ for attribute in attributes:
160
+ if type(attribute).__name__ == "DocumentAttributeVideo":
161
+ attribute.video_start_ts = float(cover_ts)
162
+ return attributes
163
+
164
+
165
+ def _sticker_documents(ids: tuple[int, ...]) -> list[Any] | None:
166
+ """`--sticker-doc` as `InputDocument`s.
167
+
168
+ Declarative only: the chip says "this media contains stickers" and the
169
+ server does not fetch them, which is why an id without an access hash is
170
+ enough here and nowhere else.
171
+ """
172
+ if not ids:
173
+ return None
174
+ from telethon.tl import types
175
+
176
+ return [types.InputDocument(id=int(i), access_hash=0, file_reference=b"") for i in ids]
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # story post
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ class PrivacyOptions(Request, kw_only=True):
185
+ """The audience flags `story post`, `story edit` and `story live start` share.
186
+
187
+ A base class rather than a duplicated block: an audience that can be set
188
+ on a story must be editable afterwards, and two copies of four flags is
189
+ how those two lists drift apart.
190
+ """
191
+
192
+ privacy: Annotated[
193
+ str | None,
194
+ choice("everyone", "contacts", "close-friends", "selected", help="Audience base rule."),
195
+ ] = None
196
+ allow: Annotated[
197
+ list[str],
198
+ opt("--allow", metavar="USER|chat:CHAT", help="Add to the allow list. Repeatable."),
199
+ ] = []
200
+ exclude: Annotated[
201
+ list[str],
202
+ opt("--exclude", metavar="USER|chat:CHAT", help="Add to the deny list. Repeatable."),
203
+ ] = []
204
+ privacy_preset: Annotated[
205
+ str | None,
206
+ opt("--privacy-preset", metavar="NAME", help="Reuse [story.privacy_presets].<name>."),
207
+ ] = None
208
+
209
+
210
+ class AreaOptions(PrivacyOptions):
211
+ """The media-area flags `story post` and `story edit` share.
212
+
213
+ Chained onto `PrivacyOptions` rather than mixed in beside it: a msgspec
214
+ Struct has one instance layout, so two Struct bases is a TypeError. Every
215
+ command that takes areas also takes an audience, so the chain costs
216
+ nothing.
217
+ """
218
+
219
+ areas: Annotated[
220
+ str | None,
221
+ opt("--areas", metavar="PATH", help="Media areas as the JSON `--areas-out` writes."),
222
+ ] = None
223
+ area_geo: Annotated[
224
+ list[str],
225
+ opt("--area-geo", metavar="LAT,LON[,ADDR]@X,Y,W,H", help="Location pill. Repeatable."),
226
+ ] = []
227
+ area_venue: Annotated[
228
+ list[str],
229
+ opt("--area-venue", metavar="QUERY@X,Y,W,H", help="Venue pill (inline query)."),
230
+ ] = []
231
+ area_venue_near: Annotated[
232
+ str | None,
233
+ opt("--area-venue-near", metavar="LAT,LON", help="Anchor point for the venue query."),
234
+ ] = None
235
+ area_venue_pick: Annotated[
236
+ int, opt("--area-venue-pick", metavar="N", help="Which venue result to use.", ge=0)
237
+ ] = 0
238
+ area_url: Annotated[
239
+ list[str], opt("--area-url", metavar="URL@X,Y,W,H", help="Link sticker (Premium).")
240
+ ] = []
241
+ area_reaction: Annotated[
242
+ list[str],
243
+ opt("--area-reaction", metavar="EMOJI@X,Y,W,H", help="Suggested-reaction bubble."),
244
+ ] = []
245
+ area_post: Annotated[
246
+ list[str],
247
+ opt("--area-post", metavar="CHAT:MSG_ID@X,Y,W,H", help="Channel-post card."),
248
+ ] = []
249
+ area_weather: Annotated[
250
+ list[str],
251
+ opt("--area-weather", metavar="SPEC@X,Y,W,H", help="Weather widget; `auto` resolves it."),
252
+ ] = []
253
+ area_gift: Annotated[
254
+ list[str], opt("--area-gift", metavar="SLUG@X,Y,W,H", help="Collectible star-gift area.")
255
+ ] = []
256
+
257
+
258
+ class PostReq(AreaOptions, kw_only=True):
259
+ file: Annotated[
260
+ list[str],
261
+ arg(0, metavar="FILE", variadic=True, kind="path", help="Media to post, one per story."),
262
+ ] = []
263
+ caption: Annotated[str | None, opt("--caption", help="Story caption.")] = None
264
+ parse: Annotated[str | None, choice("md", "html", "none", help="Caption formatting.")] = None
265
+ entities: Annotated[
266
+ str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
267
+ ] = None
268
+ send_as: Annotated[
269
+ PeerRef | None,
270
+ opt("--send-as", metavar="CHAT", kind="peer", help="Post as this channel."),
271
+ ] = None
272
+ period: Annotated[
273
+ str | None, choice("6h", "12h", "24h", "48h", help="How long it stays active.")
274
+ ] = None
275
+ pin: Annotated[bool, opt("--pin", help="Keep on my page when it expires.")] = False
276
+ protect: Annotated[bool, opt("--protect", help="noforwards: block saving/forwarding.")] = False
277
+ album: Annotated[list[int], opt("--album", metavar="ID", help="Add to this album id.")] = []
278
+ music: Annotated[
279
+ str | None, opt("--music", metavar="PATH", kind="path", help="Attach a soundtrack.")
280
+ ] = None
281
+ cover_ts: Annotated[
282
+ float | None, opt("--cover-ts", metavar="SECONDS", help="Video cover frame.")
283
+ ] = None
284
+ sticker_doc: Annotated[
285
+ list[int],
286
+ opt("--sticker-doc", metavar="ID", help="Declare a sticker baked into the media."),
287
+ ] = []
288
+ repost: Annotated[
289
+ str | None, opt("--repost", metavar="PEER:ID", help="Repost somebody else's story.")
290
+ ] = None
291
+ modified: Annotated[bool, opt("--modified", help="Mark the repost as edited.")] = False
292
+ repost_message: Annotated[
293
+ str | None,
294
+ opt("--repost-message", metavar="CHAT:MSG_ID", help="'Repost to story' from a message."),
295
+ ] = None
296
+ as_message: Annotated[
297
+ bool, opt("--as-message", help="Send the media to chats as ordinary messages instead.")
298
+ ] = False
299
+ until: Annotated[
300
+ list[PeerRef],
301
+ opt("--until", metavar="CHAT", kind="peer", help="Destinations for --as-message."),
302
+ ] = []
303
+ no_check: Annotated[bool, opt("--no-check", help="Skip the canSendStory pre-flight.")] = False
304
+
305
+
306
+ async def _post_media(ctx: OpContext, req: PostReq, source: str) -> Any:
307
+ """One `--file` as the `InputMedia` a story wants."""
308
+ media = await _send.input_media(ctx, source)
309
+ if type(media).__name__ == "InputMediaUploadedDocument":
310
+ media.attributes = _cover_attributes(list(media.attributes or []), req.cover_ts)
311
+ stickers = _sticker_documents(tuple(req.sticker_doc))
312
+ if stickers is not None and hasattr(media, "stickers"):
313
+ media.stickers = stickers
314
+ return media
315
+
316
+
317
+ async def _music_document(ctx: OpContext, source: str) -> Any:
318
+ """`--music` as an `InputDocument`, by uploading and realising the file."""
319
+ from telethon.tl import types
320
+ from telethon.tl.functions import messages as fn
321
+
322
+ if source.isdigit():
323
+ raise UsageError(
324
+ "--music takes a path: a bare document id carries no access hash, "
325
+ "so the server cannot look the soundtrack up",
326
+ field="music",
327
+ )
328
+ media = await _send.input_media(ctx, source)
329
+ result = await client(ctx)(fn.UploadMediaRequest(peer=types.InputPeerSelf(), media=media))
330
+ document = getattr(result, "document", None)
331
+ if document is None:
332
+ raise UsageError(f"{source} is not an audio file Telegram accepted", field="music")
333
+ return types.InputDocument(
334
+ id=document.id, access_hash=document.access_hash, file_reference=document.file_reference
335
+ )
336
+
337
+
338
+ async def post(ctx: OpContext, req: PostReq) -> Page[Story]:
339
+ """Post one story per `--file`, sharing one audience and one period.
340
+
341
+ The pre-flight runs *between* items, not only once: the weekly and monthly
342
+ story quotas are consumed as the loop runs, and a batch that ignored that
343
+ would fail its fourth upload after paying for three.
344
+ """
345
+ from telethon.tl.functions import stories as fn
346
+
347
+ if not req.file:
348
+ raise UsageError("give at least one FILE to post", field="file")
349
+
350
+ peer = await _story.resolve_or_self(ctx, req.send_as)
351
+ peer_id = await _story.peer_id_for(ctx, peer)
352
+ text, entities = _send.body(req.caption, parse=req.parse, entities=req.entities)
353
+ rules = await _story.privacy_rules(
354
+ ctx,
355
+ base=req.privacy or "everyone",
356
+ allow=tuple(req.allow),
357
+ exclude=tuple(req.exclude),
358
+ preset=req.privacy_preset,
359
+ )
360
+ areas, area_models = await _story.build_areas(
361
+ ctx,
362
+ areas_file=req.areas,
363
+ geo=tuple(req.area_geo),
364
+ venue=tuple(req.area_venue),
365
+ venue_near=req.area_venue_near,
366
+ venue_pick=req.area_venue_pick,
367
+ url=tuple(req.area_url),
368
+ reaction=tuple(req.area_reaction),
369
+ post=tuple(req.area_post),
370
+ weather=tuple(req.area_weather),
371
+ gift=tuple(req.area_gift),
372
+ )
373
+ if req.repost_message:
374
+ areas.append(await _repost_message_area(ctx, req.repost_message))
375
+
376
+ _warn_excluded_mentions(ctx, text, req.exclude)
377
+
378
+ fwd_peer, fwd_story = (None, None)
379
+ if req.repost:
380
+ reference, _, story_id = str(req.repost).rpartition(":")
381
+ if not reference or not story_id.isdigit():
382
+ raise UsageError("--repost takes PEER:ID", field="repost")
383
+ from tlgr.models.peer import parse_peer_ref
384
+
385
+ fwd_peer = await _send.resolve(ctx, parse_peer_ref(reference))
386
+ fwd_story = int(story_id)
387
+
388
+ music = await _music_document(ctx, req.music) if req.music else None
389
+ period = _story.PERIODS.get(req.period or "24h")
390
+
391
+ if req.as_message:
392
+ return await _send_as_messages(ctx, req, text, entities)
393
+
394
+ items: list[Story] = []
395
+ for index, source in enumerate(req.file):
396
+ if not req.no_check:
397
+ await _preflight(ctx, peer)
398
+ media = await _post_media(ctx, req, source)
399
+ updates = await client(ctx)(
400
+ fn.SendStoryRequest(
401
+ peer=peer,
402
+ media=media,
403
+ privacy_rules=rules,
404
+ pinned=req.pin or None,
405
+ noforwards=req.protect or None,
406
+ fwd_modified=req.modified or None,
407
+ media_areas=areas or None,
408
+ caption=text or None,
409
+ entities=_send.tl_entities(entities),
410
+ random_id=random_id(),
411
+ period=period,
412
+ fwd_from_id=fwd_peer,
413
+ fwd_from_story=fwd_story,
414
+ albums=list(req.album) or None,
415
+ music=music,
416
+ )
417
+ )
418
+ story = _story_from_updates(updates, peer_id=peer_id)
419
+ story.media_areas = story.media_areas or area_models
420
+ items.append(story)
421
+ ctx.emit("story_new", {"peer": peer_id, "story_id": story.id, "index": index})
422
+ return Page(items=items, has_more=False, total=len(items))
423
+
424
+
425
+ def _warn_excluded_mentions(ctx: OpContext, caption: str, exclude: list[str]) -> None:
426
+ """Warn when the caption @-mentions somebody the audience shuts out.
427
+
428
+ Read off the raw text rather than off the parsed entities: Telegram
429
+ resolves a plain `@handle` into a mention server-side, so at send time
430
+ there is no entity to inspect and the check would silently never fire.
431
+ """
432
+ if not exclude or not caption:
433
+ return
434
+ import re
435
+
436
+ mentioned = {match.lower() for match in re.findall(r"@([A-Za-z0-9_]{4,32})", caption)}
437
+ hit = sorted(mentioned & {str(e).lstrip("@").lower() for e in exclude})
438
+ if hit:
439
+ ctx.warn(
440
+ f"@{', @'.join(hit)} is excluded by the privacy rules and will not "
441
+ "see this story, even though the caption mentions them"
442
+ )
443
+
444
+
445
+ async def _repost_message_area(ctx: OpContext, spec: str) -> Any:
446
+ """`--repost-message CHAT:MSG_ID[@X,Y,W,H]` as a channel-post area."""
447
+ from telethon.tl import types
448
+
449
+ from tlgr.models.peer import parse_peer_ref
450
+ from tlgr.ops._common import input_channel
451
+
452
+ payload, _, rect = spec.partition("@")
453
+ chat, _, msg_id = payload.rpartition(":")
454
+ if not chat or not msg_id.isdigit():
455
+ raise UsageError("--repost-message takes CHAT:MSG_ID", field="repost_message")
456
+ peer = await _send.resolve(ctx, parse_peer_ref(chat))
457
+ coordinates = types.MediaAreaCoordinates(x=50.0, y=50.0, w=80.0, h=30.0, rotation=0.0)
458
+ if rect:
459
+ numbers = [float(p) for p in rect.split(",")]
460
+ coordinates = types.MediaAreaCoordinates(
461
+ x=numbers[0],
462
+ y=numbers[1],
463
+ w=numbers[2],
464
+ h=numbers[3],
465
+ rotation=numbers[4] if len(numbers) > 4 else 0.0,
466
+ )
467
+ return types.InputMediaAreaChannelPost(
468
+ coordinates=coordinates, channel=input_channel(peer), msg_id=int(msg_id)
469
+ )
470
+
471
+
472
+ async def _send_as_messages(ctx: OpContext, req: PostReq, text: str, entities: Any) -> Page[Story]:
473
+ """`--as-message`: the prepared media goes to chats instead of the profile."""
474
+ from telethon.tl.functions import messages as fn
475
+
476
+ if not req.until:
477
+ raise UsageError("--as-message needs at least one --until CHAT", field="until")
478
+ items: list[Story] = []
479
+ for destination in req.until:
480
+ peer = await _send.resolve(ctx, destination)
481
+ for source in req.file:
482
+ media = await _post_media(ctx, req, source)
483
+ await client(ctx)(
484
+ fn.SendMediaRequest(
485
+ peer=peer,
486
+ media=media,
487
+ message=text,
488
+ entities=_send.tl_entities(entities),
489
+ random_id=random_id(),
490
+ noforwards=req.protect or None,
491
+ )
492
+ )
493
+ ctx.warn("--as-message sent the media as ordinary messages; no story was posted")
494
+ return Page(items=items, has_more=False, total=0)
495
+
496
+
497
+ def _story_from_updates(updates: Any, *, peer_id: int) -> Story:
498
+ """The story an `Updates` reply carries, or a stub with the assigned id."""
499
+ for update in getattr(updates, "updates", None) or []:
500
+ name = type(update).__name__
501
+ if name == "UpdateStory":
502
+ return _story.story_model(getattr(update, "story", None), peer_id=peer_id)
503
+ for update in getattr(updates, "updates", None) or []:
504
+ if type(update).__name__ == "UpdateStoryID":
505
+ return Story(id=int(getattr(update, "id", 0) or 0), peer_id=peer_id)
506
+ return Story(id=0, peer_id=peer_id)
507
+
508
+
509
+ SPEC_POST = OperationSpec(
510
+ id="story.post",
511
+ request=PostReq,
512
+ response=Page[Story],
513
+ impl=post,
514
+ summary="Post one or more stories, with audience, media areas and duration",
515
+ description=(
516
+ "Several FILEs post several stories in one run, sharing the audience, "
517
+ "period and pin settings. Vertical media only; overlays other than "
518
+ "media areas must already be rendered into the file."
519
+ ),
520
+ mutating=True,
521
+ rate_class="send",
522
+ timeout_s=600,
523
+ tags=frozenset({"visible-to-others"}),
524
+ columns=("id", "peer_id", "expire_date"),
525
+ headers=("ID", "Peer", "Expires"),
526
+ example={"items": [_EXAMPLE_STORY], "has_more": False},
527
+ example_args="story post morning.jpg --caption 'morning' --privacy contacts",
528
+ covers=(
529
+ "bots.webapp-share-to-story",
530
+ "groups-channels-admin.stories-as-channel",
531
+ "stories.area-channel-post",
532
+ "stories.area-location",
533
+ "stories.area-star-gift",
534
+ "stories.area-suggested-reaction",
535
+ "stories.area-url",
536
+ "stories.area-venue",
537
+ "stories.area-weather",
538
+ "stories.attached-stickers",
539
+ "stories.mention-users",
540
+ "stories.post-as-channel",
541
+ "stories.post-batch",
542
+ "stories.post-keep-on-page",
543
+ "stories.post-music",
544
+ "stories.post-period",
545
+ "stories.post-photo",
546
+ "stories.post-protect",
547
+ "stories.post-stickers-drawing",
548
+ "stories.post-to-album",
549
+ "stories.post-video",
550
+ "stories.privacy-auto-exceptions",
551
+ "stories.privacy-close-friends",
552
+ "stories.privacy-contacts",
553
+ "stories.privacy-everyone",
554
+ "stories.privacy-selected",
555
+ "stories.repost",
556
+ "stories.repost-message-to-story",
557
+ "stories.send-as-message-instead",
558
+ ),
559
+ )
560
+
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # story can-post
564
+ # ---------------------------------------------------------------------------
565
+
566
+ #: app-config key → the `StoryLimits` field it fills. Nothing is hardcoded:
567
+ #: Telegram moves these numbers without moving the layer.
568
+ _LIMIT_KEYS: dict[str, tuple[str, str]] = {
569
+ "story_expiring_limit_default": ("expiring_limit", "story_expiring_limit_premium"),
570
+ "stories_sent_weekly_limit_default": (
571
+ "sent_weekly_limit",
572
+ "stories_sent_weekly_limit_premium",
573
+ ),
574
+ "stories_sent_monthly_limit_default": (
575
+ "sent_monthly_limit",
576
+ "stories_sent_monthly_limit_premium",
577
+ ),
578
+ "story_caption_length_limit_default": (
579
+ "caption_length_limit",
580
+ "story_caption_length_limit_premium",
581
+ ),
582
+ "stories_suggested_reactions_limit_default": (
583
+ "suggested_reactions_limit",
584
+ "stories_suggested_reactions_limit_premium",
585
+ ),
586
+ }
587
+
588
+ #: app-config key → the `StoryLimits` field, for the ones with no Premium twin.
589
+ _FLAT_LIMIT_KEYS: dict[str, str] = {
590
+ "stories_area_url_max": "area_url_max",
591
+ "stories_albums_limit": "albums_limit",
592
+ "stories_album_stories_limit": "album_stories_limit",
593
+ "stories_pinned_to_top_count_max": "pinned_to_top_max",
594
+ "story_viewers_expire_period": "viewers_expire_period",
595
+ "stories_stealth_past_period": "stealth_past_period",
596
+ "stories_stealth_future_period": "stealth_future_period",
597
+ "stories_stealth_cooldown_period": "stealth_cooldown_period",
598
+ }
599
+
600
+
601
+ def _limits(config: dict[str, Any], *, premium: bool) -> StoryLimits:
602
+ limits = StoryLimits()
603
+ for key, (field, premium_key) in _LIMIT_KEYS.items():
604
+ source = premium_key if premium and premium_key in config else key
605
+ value = config.get(source)
606
+ if isinstance(value, (int, float)):
607
+ setattr(limits, field, int(value))
608
+ if premium_key in config and config.get(premium_key) != config.get(key):
609
+ limits.premium_unlocks.append(field)
610
+ for key, field in _FLAT_LIMIT_KEYS.items():
611
+ value = config.get(key)
612
+ if isinstance(value, (int, float)):
613
+ setattr(limits, field, int(value))
614
+ limits.premium_unlocks.sort()
615
+ return limits
616
+
617
+
618
+ #: The RPC errors `stories.canSendStory` answers a refusal with. Layer 227
619
+ #: has no `canSendStoryResult*` union — the server raises — so the reason is
620
+ #: read off the error rather than off a result type.
621
+ _CANNOT: tuple[str, ...] = (
622
+ "PREMIUM_ACCOUNT_REQUIRED",
623
+ "BOOSTS_REQUIRED",
624
+ "CHAT_ADMIN_REQUIRED",
625
+ "STORIES_TOO_MUCH",
626
+ "STORY_SEND_FLOOD_WEEKLY",
627
+ "STORY_SEND_FLOOD_MONTHLY",
628
+ "STORY_LIVE_ALREADY",
629
+ )
630
+
631
+
632
+ def _refusal(exc: BaseException) -> tuple[str, int | None]:
633
+ """`(reason, the number the message carries)` for a canSendStory failure.
634
+
635
+ `STORY_SEND_FLOOD_WEEKLY_%d` and friends carry the wait in the name, and
636
+ reporting "an error occurred" while throwing that number away is what
637
+ makes a caller retry immediately and get flooded again.
638
+ """
639
+ from tlgr.core.errors import strip_numeric_suffix
640
+
641
+ raw = str(getattr(exc, "message", "") or exc).upper()
642
+ text, number = strip_numeric_suffix(raw)
643
+ for reason in _CANNOT:
644
+ if reason in text:
645
+ return reason, number
646
+ return "", number
647
+
648
+
649
+ async def _check(ctx: OpContext, peer: Any) -> tuple[Any, str, int | None]:
650
+ """`(result, reason, number)` from `stories.canSendStory`."""
651
+ from telethon.errors import RPCError
652
+ from telethon.tl.functions import stories as fn
653
+
654
+ try:
655
+ return await client(ctx)(fn.CanSendStoryRequest(peer=peer)), "", None
656
+ except RPCError as exc:
657
+ reason, number = _refusal(exc)
658
+ if not reason:
659
+ raise
660
+ return None, reason, number
661
+
662
+
663
+ async def _preflight(ctx: OpContext, peer: Any) -> None:
664
+ """`stories.canSendStory`, translated into a refusal a human can act on."""
665
+ _count, reason, number = await _check(ctx, peer)
666
+ if not reason:
667
+ return
668
+ detail = f" ({number})" if number is not None else ""
669
+ if reason == "PREMIUM_ACCOUNT_REQUIRED":
670
+ raise PermissionError_("posting this story needs Telegram Premium")
671
+ if reason == "BOOSTS_REQUIRED":
672
+ raise PermissionError_(f"posting a story here needs more boosts{detail}")
673
+ if reason == "CHAT_ADMIN_REQUIRED":
674
+ raise PermissionError_("posting a story here needs the post_stories admin right")
675
+ raise PermissionError_(f"cannot post a story right now: {reason}{detail}")
676
+
677
+
678
+ class CanPostReq(Request):
679
+ send_as: Annotated[
680
+ PeerRef | None,
681
+ opt("--send-as", metavar="CHAT", kind="peer", help="Check this channel instead of you."),
682
+ ] = None
683
+ chats: Annotated[
684
+ bool, opt("--chats", help="Also list every chat where you hold post_stories.")
685
+ ] = False
686
+ limits: Annotated[bool, opt("--limits/--no-limits", help="Include the limit block.")] = True
687
+
688
+
689
+ async def can_post(ctx: OpContext, req: CanPostReq) -> StoryPostCheck:
690
+ """The pre-flight the GUI runs before it opens the camera.
691
+
692
+ Re-run it immediately before posting: the answer is a snapshot of quotas
693
+ that other sessions are spending at the same time.
694
+ """
695
+ from telethon.tl.functions import stories as fn
696
+
697
+ from tlgr.ops import _media
698
+
699
+ peer = await _story.resolve_or_self(ctx, req.send_as)
700
+ outcome, reason, number = await _check(ctx, peer)
701
+ check = StoryPostCheck(
702
+ can_post=outcome is not None,
703
+ reason=reason,
704
+ count_remains=getattr(outcome, "count_remains", None),
705
+ free_slots=getattr(outcome, "count_remains", None),
706
+ retry_after=number if reason.startswith("STORY_SEND_FLOOD") else None,
707
+ boosts_required=number if reason == "BOOSTS_REQUIRED" else None,
708
+ )
709
+
710
+ me = await client(ctx).get_me()
711
+ check.premium = bool(getattr(me, "premium", False))
712
+ if req.limits:
713
+ check.limits = _limits(await _media.app_config(ctx), premium=check.premium)
714
+ if req.chats:
715
+ chats = await client(ctx)(fn.GetChatsToSendRequest())
716
+ check.chats = [entity_to_peer(chat) for chat in (getattr(chats, "chats", None) or [])]
717
+ return check
718
+
719
+
720
+ SPEC_CAN_POST = OperationSpec(
721
+ id="story.can-post",
722
+ request=CanPostReq,
723
+ response=StoryPostCheck,
724
+ impl=can_post,
725
+ summary="Free story slots, limits, Premium gates and the chats you may post to",
726
+ description="Re-run it immediately before posting; the quotas move under you.",
727
+ columns=("can_post", "count_remains", "reason"),
728
+ headers=("Can post", "Remaining", "Reason"),
729
+ example={"can_post": True, "count_remains": 2, "free_slots": 2, "premium": False},
730
+ example_args="story can-post --chats",
731
+ covers=(
732
+ "stories.can-post",
733
+ "stories.chats-to-post",
734
+ "stories.limits-config",
735
+ "stories.premium-gates",
736
+ ),
737
+ )
738
+
739
+
740
+ # ---------------------------------------------------------------------------
741
+ # story edit
742
+ # ---------------------------------------------------------------------------
743
+
744
+
745
+ class EditReq(AreaOptions, kw_only=True):
746
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")]
747
+ id: Annotated[int, arg(1, metavar="ID", help="Story id.")]
748
+ caption: Annotated[str | None, opt("--caption", help="New caption.")] = None
749
+ parse: Annotated[str | None, choice("md", "html", "none", help="Caption formatting.")] = None
750
+ entities: Annotated[
751
+ str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
752
+ ] = None
753
+ file: Annotated[
754
+ str | None, opt("--file", metavar="PATH", kind="path", help="Replace the media.")
755
+ ] = None
756
+ cover_ts: Annotated[
757
+ float | None,
758
+ opt("--cover-ts", metavar="SECONDS", help="New cover frame, without re-uploading."),
759
+ ] = None
760
+ music: Annotated[
761
+ str | None, opt("--music", metavar="PATH", kind="path", help="Replace the soundtrack.")
762
+ ] = None
763
+
764
+
765
+ async def edit(ctx: OpContext, req: EditReq) -> Story:
766
+ """Change a posted story: only the flags you pass are sent.
767
+
768
+ `--cover-ts` alone takes the no-reupload path — the current document is
769
+ wrapped in `inputFileStoryDocument` and resent with the new
770
+ `video_start_ts`, which is how the GUI moves a cover frame without
771
+ spending the upload again.
772
+ """
773
+ from telethon.tl import types
774
+ from telethon.tl.functions import stories as fn
775
+
776
+ peer = await _send.resolve(ctx, req.chat)
777
+ peer_id = _send.peer_id_of(peer)
778
+
779
+ caption: str | None = None
780
+ entities: Any = None
781
+ if req.caption is not None:
782
+ text, parsed = _send.body(req.caption, parse=req.parse, entities=req.entities)
783
+ caption, entities = text, _send.tl_entities(parsed)
784
+
785
+ media: Any = None
786
+ if req.file:
787
+ media = await _send.input_media(ctx, req.file)
788
+ if type(media).__name__ == "InputMediaUploadedDocument":
789
+ media.attributes = _cover_attributes(list(media.attributes or []), req.cover_ts)
790
+ elif req.cover_ts is not None:
791
+ current = await _require_own_story(ctx, peer, req.id)
792
+ document = getattr(getattr(current, "media", None), "document", None)
793
+ if document is None:
794
+ raise UsageError("--cover-ts only applies to a video story", field="cover_ts")
795
+ media = types.InputMediaUploadedDocument(
796
+ file=types.InputFileStoryDocument(
797
+ id=types.InputDocument(
798
+ id=document.id,
799
+ access_hash=document.access_hash,
800
+ file_reference=document.file_reference,
801
+ )
802
+ ),
803
+ mime_type=str(getattr(document, "mime_type", "video/mp4")),
804
+ attributes=_cover_attributes(
805
+ list(getattr(document, "attributes", None) or []), req.cover_ts
806
+ ),
807
+ )
808
+
809
+ areas, _models = await _story.build_areas(
810
+ ctx,
811
+ areas_file=req.areas,
812
+ geo=tuple(req.area_geo),
813
+ venue=tuple(req.area_venue),
814
+ venue_near=req.area_venue_near,
815
+ venue_pick=req.area_venue_pick,
816
+ url=tuple(req.area_url),
817
+ reaction=tuple(req.area_reaction),
818
+ post=tuple(req.area_post),
819
+ weather=tuple(req.area_weather),
820
+ gift=tuple(req.area_gift),
821
+ )
822
+
823
+ rules = None
824
+ if req.privacy or req.allow or req.exclude or req.privacy_preset:
825
+ rules = await _story.privacy_rules(
826
+ ctx,
827
+ base=req.privacy or "everyone",
828
+ allow=tuple(req.allow),
829
+ exclude=tuple(req.exclude),
830
+ preset=req.privacy_preset,
831
+ )
832
+
833
+ music = await _music_document(ctx, req.music) if req.music else None
834
+ if not any((caption is not None, media is not None, areas, rules, music)):
835
+ raise UsageError("nothing to edit; pass a caption, media, areas or an audience", field="id")
836
+
837
+ await client(ctx)(
838
+ fn.EditStoryRequest(
839
+ peer=peer,
840
+ id=req.id,
841
+ media=media,
842
+ media_areas=areas or None,
843
+ caption=caption,
844
+ entities=entities,
845
+ privacy_rules=rules,
846
+ music=music,
847
+ )
848
+ )
849
+ ctx.emit("story_edited", {"peer": peer_id, "story_id": req.id})
850
+ fresh = await _stories_of(ctx, peer, [req.id])
851
+ story = (
852
+ _story.story_model(fresh[0], peer_id=peer_id)
853
+ if fresh
854
+ else Story(id=req.id, peer_id=peer_id)
855
+ )
856
+ story.edited = True
857
+ return story
858
+
859
+
860
+ SPEC_EDIT = OperationSpec(
861
+ id="story.edit",
862
+ request=EditReq,
863
+ response=Story,
864
+ impl=edit,
865
+ summary="Edit a posted story: caption, audience, media, cover frame or areas",
866
+ description=(
867
+ "Privacy edits are user stories only — a channel story has no rule "
868
+ "vector. `--cover-ts` without `--file` re-sends the current document "
869
+ "rather than uploading it again."
870
+ ),
871
+ mutating=True,
872
+ rate_class="send",
873
+ timeout_s=300,
874
+ tags=frozenset({"visible-to-others"}),
875
+ columns=("id", "peer_id", "edited", "caption"),
876
+ example={**_EXAMPLE_STORY, "edited": True},
877
+ example_args="story edit me 42 --caption 'still morning'",
878
+ covers=(
879
+ "stories.edit-areas",
880
+ "stories.edit-caption",
881
+ "stories.edit-cover",
882
+ "stories.edit-media",
883
+ "stories.edit-privacy",
884
+ ),
885
+ )
886
+
887
+
888
+ # ---------------------------------------------------------------------------
889
+ # story delete
890
+ # ---------------------------------------------------------------------------
891
+
892
+
893
+ class DeleteReq(Request):
894
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose stories.")]
895
+ id: Annotated[
896
+ list[str], arg(1, metavar="ID", variadic=True, help="Story ids, or `10-14` ranges.")
897
+ ] = []
898
+
899
+
900
+ async def delete(ctx: OpContext, req: DeleteReq) -> StoriesDeleted:
901
+ """Delete stories permanently — active, profile-pinned or archived alike."""
902
+ from telethon.tl.functions import stories as fn
903
+
904
+ ids = _story.story_ids(req.id)
905
+ if not ids:
906
+ raise UsageError("give at least one story id", field="id")
907
+ peer = await _send.resolve(ctx, req.chat)
908
+ deleted = await client(ctx)(fn.DeleteStoriesRequest(peer=peer, id=ids))
909
+ peer_id = _send.peer_id_of(peer)
910
+ ctx.emit("story_deleted", {"peer": peer_id, "ids": list(deleted or ids)})
911
+ return StoriesDeleted(peer=peer_id, deleted_ids=[int(i) for i in (deleted or [])])
912
+
913
+
914
+ SPEC_DELETE = OperationSpec(
915
+ id="story.delete",
916
+ request=DeleteReq,
917
+ response=StoriesDeleted,
918
+ impl=delete,
919
+ summary="Delete stories permanently",
920
+ description="Channel stories need the `delete_stories` admin right.",
921
+ mutating=True,
922
+ destructive=True,
923
+ rate_class="send",
924
+ columns=("peer", "deleted_ids"),
925
+ example={"peer": 4242, "deleted_ids": [42]},
926
+ example_args="story delete me 42",
927
+ covers=("stories.delete",),
928
+ )
929
+
930
+
931
+ # ---------------------------------------------------------------------------
932
+ # story list
933
+ # ---------------------------------------------------------------------------
934
+
935
+
936
+ class ListReq(Request):
937
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose stories.")]
938
+ profile: Annotated[bool, opt("--profile", help="The stories kept on the profile page.")] = False
939
+ archive: Annotated[bool, opt("--archive", help="The private archive, expired included.")] = (
940
+ False
941
+ )
942
+ album: Annotated[int | None, opt("--album", metavar="ID", help="Only this album.")] = None
943
+ offset_id: Annotated[
944
+ int | None, opt("--offset-id", metavar="ID", help="Page from this story id downwards.")
945
+ ] = None
946
+ hydrate: Annotated[
947
+ bool, opt("--hydrate/--no-hydrate", help="Resolve skipped placeholders.")
948
+ ] = True
949
+ translate: Annotated[
950
+ str | None, opt("--translate", metavar="LANG", help="Also translate the captions.")
951
+ ] = None
952
+
953
+
954
+ async def list_stories(ctx: OpContext, req: ListReq) -> Page[Story]:
955
+ """A peer's stories: active by default, or the profile page, archive or an album.
956
+
957
+ Listing never registers a view — that is `story read --register-view`.
958
+ """
959
+ from telethon.tl.functions import stories as fn
960
+
961
+ limit, state = window(ctx, "story.list", PageKind.HISTORY, 30)
962
+ peer = await _send.resolve(ctx, req.chat)
963
+ peer_id = _send.peer_id_of(peer)
964
+ offset = int(state.get("offset") or req.offset_id or 0)
965
+
966
+ if req.album is not None:
967
+ result = await client(ctx)(
968
+ fn.GetAlbumStoriesRequest(peer=peer, album_id=req.album, offset=offset, limit=limit)
969
+ )
970
+ elif req.archive:
971
+ result = await client(ctx)(
972
+ fn.GetStoriesArchiveRequest(peer=peer, offset_id=offset, limit=limit)
973
+ )
974
+ elif req.profile:
975
+ result = await client(ctx)(
976
+ fn.GetPinnedStoriesRequest(peer=peer, offset_id=offset, limit=limit)
977
+ )
978
+ else:
979
+ result = await client(ctx)(fn.GetPeerStoriesRequest(peer=peer))
980
+ result = getattr(result, "stories", result)
981
+
982
+ raw = list(getattr(result, "stories", None) or [])
983
+ if req.hydrate:
984
+ raw = await _hydrate(ctx, peer, raw)
985
+ pinned_top = set(getattr(result, "pinned_to_top", None) or [])
986
+ items = [_story.story_model(item, peer_id=peer_id) for item in raw]
987
+ for item in items:
988
+ if item.id in pinned_top:
989
+ item.pinned = True
990
+ if req.translate:
991
+ await _translate_captions(ctx, items, req.translate)
992
+
993
+ # Only three of the four RPCs page at all; `getPeerStories` hands back the
994
+ # peer's whole active set in one shot, so guessing "there may be more" from
995
+ # a full page would hand out a cursor that returns nothing.
996
+ server_paged = req.album is not None or req.archive or req.profile
997
+ next_state = (
998
+ {"offset": offset + len(items)}
999
+ if req.album is not None
1000
+ else {"offset": items[-1].id if items else offset}
1001
+ )
1002
+ return build_page(
1003
+ items,
1004
+ op="story.list",
1005
+ kind=PageKind.HISTORY,
1006
+ state=next_state,
1007
+ account=ctx.account,
1008
+ limit=limit if server_paged else None,
1009
+ has_more=None if server_paged else False,
1010
+ total=getattr(result, "count", None),
1011
+ )
1012
+
1013
+
1014
+ async def _hydrate(ctx: OpContext, peer: Any, raw: list[Any]) -> list[Any]:
1015
+ """Replace `storyItemSkipped` placeholders with the real items.
1016
+
1017
+ A feed hands back placeholders for everything the client is assumed to
1018
+ have cached; tlgr has no cache, so without this a listing is a list of
1019
+ ids with no captions and no media.
1020
+ """
1021
+ skipped = [
1022
+ int(getattr(item, "id", 0) or 0)
1023
+ for item in raw
1024
+ if type(item).__name__ == "StoryItemSkipped"
1025
+ ]
1026
+ if not skipped:
1027
+ return raw
1028
+ resolved = {
1029
+ int(getattr(item, "id", 0) or 0): item for item in await _stories_of(ctx, peer, skipped)
1030
+ }
1031
+ return [resolved.get(int(getattr(item, "id", 0) or 0), item) for item in raw]
1032
+
1033
+
1034
+ async def _translate_captions(ctx: OpContext, items: list[Story], language: str) -> None:
1035
+ """Translate captions with the `text=` form — a story has no message id."""
1036
+ from telethon.tl import types
1037
+ from telethon.tl.functions import messages as fn
1038
+
1039
+ for item in items:
1040
+ if not item.caption:
1041
+ continue
1042
+ result = await client(ctx)(
1043
+ fn.TranslateTextRequest(
1044
+ to_lang=language, text=[types.TextWithEntities(text=item.caption, entities=[])]
1045
+ )
1046
+ )
1047
+ blocks = getattr(result, "result", None) or []
1048
+ if blocks:
1049
+ item.translation = str(getattr(blocks[0], "text", "") or "")
1050
+
1051
+
1052
+ SPEC_LIST = OperationSpec(
1053
+ id="story.list",
1054
+ request=ListReq,
1055
+ response=Page[Story],
1056
+ impl=list_stories,
1057
+ summary="List a peer's stories: active, profile page, archive or one album",
1058
+ description=(
1059
+ "Four RPCs behind one list, because the GUI shows one grid with tabs. "
1060
+ "`--archive` on a channel needs the `edit_stories` admin right."
1061
+ ),
1062
+ paginated=PageKind.HISTORY,
1063
+ columns=("id", "date", "expire_date", "caption"),
1064
+ headers=("ID", "Posted", "Expires", "Caption"),
1065
+ example={"items": [_EXAMPLE_STORY], "has_more": False},
1066
+ example_args="story list @alice",
1067
+ covers=(
1068
+ "contacts-users.user-stories",
1069
+ "stories.album-stories",
1070
+ "stories.channel-archive",
1071
+ "stories.own-archive",
1072
+ "stories.peer-active",
1073
+ "stories.profile-stories",
1074
+ ),
1075
+ )
1076
+
1077
+
1078
+ # ---------------------------------------------------------------------------
1079
+ # story get
1080
+ # ---------------------------------------------------------------------------
1081
+
1082
+
1083
+ class GetReq(Request):
1084
+ chat: Annotated[
1085
+ PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story; a story link works too.")
1086
+ ]
1087
+ id: Annotated[
1088
+ list[str], arg(1, metavar="ID", required=False, variadic=True, help="Story ids.")
1089
+ ] = []
1090
+ link: Annotated[bool, opt("--link", help="Only export the t.me story link.")] = False
1091
+ album_link: Annotated[
1092
+ int | None, opt("--album-link", metavar="ID", help="Build the album deep link instead.")
1093
+ ] = None
1094
+ views: Annotated[bool, opt("--views", help="Also fetch fresh view counters.")] = False
1095
+ translate: Annotated[
1096
+ str | None, opt("--translate", metavar="LANG", help="Translate the caption.")
1097
+ ] = None
1098
+ areas_out: Annotated[
1099
+ str | None,
1100
+ opt("--areas-out", metavar="PATH", kind="path", help="Write media_areas as JSON."),
1101
+ ] = None
1102
+
1103
+
1104
+ async def get(ctx: OpContext, req: GetReq) -> Page[Story]:
1105
+ """Fetch stories in full, or just their links.
1106
+
1107
+ A `t.me/<user>/s/<id>` link may replace the CHAT+ID pair; the peer parser
1108
+ keeps the original text, so the id is read straight back off it.
1109
+ """
1110
+ import msgspec
1111
+ from telethon.tl.functions import stories as fn
1112
+
1113
+ peer = await _send.resolve(ctx, req.chat)
1114
+ peer_id = _send.peer_id_of(peer)
1115
+ ids = _story.story_ids(req.id)
1116
+ linked = _link_story_id(req.chat)
1117
+ if not ids and linked is not None:
1118
+ ids = [linked]
1119
+
1120
+ if req.album_link is not None:
1121
+ username = await _username_of(ctx, peer)
1122
+ album = req.album_link
1123
+ return Page(
1124
+ items=[Story(id=album, peer_id=peer_id, link=f"https://t.me/{username}/a/{album}")],
1125
+ has_more=False,
1126
+ total=1,
1127
+ )
1128
+
1129
+ if not ids:
1130
+ raise UsageError("give at least one story id, or a story link", field="id")
1131
+
1132
+ if req.link:
1133
+ items = []
1134
+ for story_id in ids:
1135
+ exported = await client(ctx)(fn.ExportStoryLinkRequest(peer=peer, id=story_id))
1136
+ items.append(
1137
+ Story(id=story_id, peer_id=peer_id, link=str(getattr(exported, "link", "") or ""))
1138
+ )
1139
+ return Page(items=items, has_more=False, total=len(items))
1140
+
1141
+ raw = await _stories_of(ctx, peer, ids)
1142
+ if not raw:
1143
+ raise NotFoundError(f"no story {ids[0]} on that peer")
1144
+ items = [_story.story_model(item, peer_id=peer_id) for item in raw]
1145
+
1146
+ if req.views:
1147
+ fresh = await client(ctx)(fn.GetStoriesViewsRequest(peer=peer, id=ids))
1148
+ for item, views in zip(items, getattr(fresh, "views", None) or [], strict=False):
1149
+ item.views = _story.views_model(views)
1150
+ if req.translate:
1151
+ await _translate_captions(ctx, items, req.translate)
1152
+ if req.areas_out:
1153
+ areas: list[MediaArea] = [area for item in items for area in item.media_areas]
1154
+ target = Path(os.path.expanduser(req.areas_out))
1155
+ target.parent.mkdir(parents=True, exist_ok=True)
1156
+ target.write_bytes(msgspec.json.format(msgspec.json.encode(areas)))
1157
+ return Page(items=items, has_more=False, total=len(items))
1158
+
1159
+
1160
+ async def _username_of(ctx: OpContext, peer: Any) -> str:
1161
+ entity = await client(ctx).get_entity(peer)
1162
+ username = getattr(entity, "username", None)
1163
+ if not username:
1164
+ usernames = getattr(entity, "usernames", None) or []
1165
+ username = getattr(usernames[0], "username", None) if usernames else None
1166
+ if not username:
1167
+ raise NotSupportedError(
1168
+ "USER_PUBLIC_MISSING: a story link only exists for a peer with a username"
1169
+ )
1170
+ return str(username)
1171
+
1172
+
1173
+ SPEC_GET = OperationSpec(
1174
+ id="story.get",
1175
+ request=GetReq,
1176
+ response=Page[Story],
1177
+ impl=get,
1178
+ summary="Fetch stories in full (media, caption, areas, privacy, link)",
1179
+ description=(
1180
+ "`privacy` is only populated on your own stories. A gone story comes "
1181
+ "back with `deleted: true` rather than as an error."
1182
+ ),
1183
+ columns=("id", "date", "caption", "link"),
1184
+ example={"items": [_EXAMPLE_STORY], "has_more": False},
1185
+ example_args="story get @alice 42 --views",
1186
+ covers=(
1187
+ "stories.album-link",
1188
+ "stories.caption-entities",
1189
+ "stories.get-by-id",
1190
+ "stories.link-export",
1191
+ "stories.link-resolve",
1192
+ "stories.media-areas-inspect",
1193
+ "stories.privacy-inspect",
1194
+ "stories.repost-origin",
1195
+ "stories.skipped-hydrate",
1196
+ "stories.translate-caption",
1197
+ "stories.viewers-counters",
1198
+ ),
1199
+ )
1200
+
1201
+
1202
+ # ---------------------------------------------------------------------------
1203
+ # story feed list
1204
+ # ---------------------------------------------------------------------------
1205
+
1206
+
1207
+ class FeedListReq(Request):
1208
+ hidden: Annotated[bool, opt("--hidden", help="The archived stories bar instead.")] = False
1209
+ refresh: Annotated[bool, opt("--refresh", help="Re-send the stored state with no `next`.")] = (
1210
+ False
1211
+ )
1212
+ state_file: Annotated[
1213
+ str | None,
1214
+ opt("--state-file", metavar="PATH", kind="path", help="Where the feed state is kept."),
1215
+ ] = None
1216
+ peers: Annotated[
1217
+ list[PeerRef],
1218
+ opt("--peers", metavar="PEER", kind="peer", help="Only the compact max-id summary."),
1219
+ ] = []
1220
+ read_state: Annotated[
1221
+ bool, opt("--read-state", help="Emit the login-time read-state bootstrap instead.")
1222
+ ] = False
1223
+ unread_only: Annotated[bool, opt("--unread-only", help="Keep only unread peers.")] = False
1224
+
1225
+
1226
+ async def feed_list(ctx: OpContext, req: FeedListReq) -> Page[StoryFeedPeer]:
1227
+ """The stories bar.
1228
+
1229
+ Pagination is not offset-based: the first call sends no state, the reply
1230
+ carries one, and the walk continues with `state` plus `next`. The cursor
1231
+ therefore carries both — an integer offset here would silently restart the
1232
+ walk at the top every time.
1233
+
1234
+ The reply also carries the account's stealth mode; `story stealth --status`
1235
+ reads it from the same call, because `Page[T]` has no room for a sidecar
1236
+ field and inventing one on every row would be worse.
1237
+ """
1238
+ from telethon.tl.functions import stories as fn
1239
+
1240
+ _limit, state = window(ctx, "story.feed.list", PageKind.DIALOGS, 30)
1241
+
1242
+ if req.peers:
1243
+ peers = [await _send.resolve(ctx, ref) for ref in req.peers]
1244
+ recent = await client(ctx)(fn.GetPeerMaxIDsRequest(id=peers))
1245
+ items = [
1246
+ StoryFeedPeer(
1247
+ peer_id=_send.peer_id_of(peer),
1248
+ max_id=int(getattr(row, "max_id", 0) or 0),
1249
+ live=bool(getattr(row, "live", False)),
1250
+ )
1251
+ for peer, row in zip(peers, recent or [], strict=False)
1252
+ ]
1253
+ return Page(items=items, has_more=False, total=len(items))
1254
+
1255
+ if req.read_state:
1256
+ result = await client(ctx)(fn.GetAllReadPeerStoriesRequest())
1257
+ table = _entities(result)
1258
+ items = []
1259
+ for update in getattr(result, "updates", None) or []:
1260
+ if type(update).__name__ != "UpdateReadStories":
1261
+ continue
1262
+ peer = getattr(update, "peer", None)
1263
+ items.append(
1264
+ StoryFeedPeer(
1265
+ peer_id=peer_id_of(peer) or 0,
1266
+ peer=_peer_model(peer, table),
1267
+ max_read_id=int(getattr(update, "max_id", 0) or 0),
1268
+ )
1269
+ )
1270
+ return Page(items=items, has_more=False, total=len(items))
1271
+
1272
+ stored = _feed_state(ctx, req)
1273
+ token = state.get("state") or (stored if req.refresh else None)
1274
+ result = await client(ctx)(
1275
+ fn.GetAllStoriesRequest(
1276
+ next=bool(state.get("next")) or None,
1277
+ hidden=req.hidden or None,
1278
+ state=token,
1279
+ )
1280
+ )
1281
+ if type(result).__name__ == "AllStoriesNotModified":
1282
+ already(ctx)
1283
+ _save_feed_state(ctx, req, getattr(result, "state", "") or "")
1284
+ return Page(items=[], has_more=False, total=0)
1285
+
1286
+ table = _entities(result)
1287
+ items = []
1288
+ for row in getattr(result, "peer_stories", None) or []:
1289
+ peer = getattr(row, "peer", None)
1290
+ stories = [
1291
+ _story.story_model(item, peer_id=peer_id_of(peer) or 0)
1292
+ for item in (getattr(row, "stories", None) or [])
1293
+ ]
1294
+ max_read = int(getattr(row, "max_read_id", 0) or 0)
1295
+ unread = [s for s in stories if s.id > max_read]
1296
+ item = StoryFeedPeer(
1297
+ peer_id=peer_id_of(peer) or 0,
1298
+ peer=_peer_model(peer, table),
1299
+ max_read_id=max_read,
1300
+ stories=stories,
1301
+ unread_count=len(unread),
1302
+ has_unread=bool(unread),
1303
+ live=any(s.live for s in stories),
1304
+ hidden=req.hidden,
1305
+ )
1306
+ if req.unread_only and not item.has_unread:
1307
+ continue
1308
+ items.append(item)
1309
+
1310
+ feed_state = str(getattr(result, "state", "") or "")
1311
+ _save_feed_state(ctx, req, feed_state)
1312
+ return build_page(
1313
+ items,
1314
+ op="story.feed.list",
1315
+ kind=PageKind.DIALOGS,
1316
+ state={"state": feed_state, "next": True},
1317
+ account=ctx.account,
1318
+ has_more=bool(getattr(result, "has_more", False)),
1319
+ total=getattr(result, "count", None),
1320
+ )
1321
+
1322
+
1323
+ def _peer_model(peer: Any, table: dict[int, Any]) -> Any:
1324
+ entity = _peer_entity(peer, table)
1325
+ return entity_to_peer(entity) if entity is not None else None
1326
+
1327
+
1328
+ def _feed_path(ctx: OpContext, req: FeedListReq) -> Path | None:
1329
+ if req.state_file:
1330
+ return Path(os.path.expanduser(req.state_file))
1331
+ paths = getattr(ctx, "paths", None)
1332
+ root = getattr(paths, "cache", None) or getattr(paths, "home", None)
1333
+ if root is None:
1334
+ return None
1335
+ name = "story-feed-hidden.state" if req.hidden else "story-feed.state"
1336
+ return Path(root) / f"{ctx.account or 'default'}-{name}"
1337
+
1338
+
1339
+ def _feed_state(ctx: OpContext, req: FeedListReq) -> str | None:
1340
+ path = _feed_path(ctx, req)
1341
+ if path is None:
1342
+ return None
1343
+ try:
1344
+ return path.read_text(encoding="utf-8").strip() or None
1345
+ except OSError:
1346
+ return None
1347
+
1348
+
1349
+ def _save_feed_state(ctx: OpContext, req: FeedListReq, value: str) -> None:
1350
+ """Persist the opaque feed state so `--refresh` means something next run."""
1351
+ path = _feed_path(ctx, req)
1352
+ if path is None or not value:
1353
+ return
1354
+ try:
1355
+ path.parent.mkdir(parents=True, exist_ok=True)
1356
+ path.write_text(value, encoding="utf-8")
1357
+ except OSError as exc: # a read-only cache must not fail the listing
1358
+ ctx.warn(f"could not store the story feed state: {exc}")
1359
+
1360
+
1361
+ SPEC_FEED_LIST = OperationSpec(
1362
+ id="story.feed.list",
1363
+ request=FeedListReq,
1364
+ response=Page[StoryFeedPeer],
1365
+ impl=feed_list,
1366
+ summary="List peers that have active stories (the stories bar)",
1367
+ description=(
1368
+ "Main and hidden feeds keep independent states. `--refresh` re-sends "
1369
+ "the stored state and reports `already: true` when nothing changed."
1370
+ ),
1371
+ paginated=PageKind.DIALOGS,
1372
+ columns=("peer_id", "unread_count", "max_read_id"),
1373
+ headers=("Peer", "Unread", "Read to"),
1374
+ example={
1375
+ "items": [{"peer_id": 4242, "max_read_id": 41, "unread_count": 1, "has_unread": True}],
1376
+ "has_more": False,
1377
+ },
1378
+ example_args="story feed list --unread-only",
1379
+ covers=(
1380
+ "stories.changelog-stories",
1381
+ "stories.feed-all",
1382
+ "stories.feed-hidden",
1383
+ "stories.feed-refresh-state",
1384
+ "stories.peer-max-ids",
1385
+ "stories.read-state-bootstrap",
1386
+ ),
1387
+ )
1388
+
1389
+
1390
+ # ---------------------------------------------------------------------------
1391
+ # story read
1392
+ # ---------------------------------------------------------------------------
1393
+
1394
+
1395
+ class ReadReq(Request):
1396
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose stories.")]
1397
+ id: Annotated[
1398
+ list[str], arg(1, metavar="ID", required=False, variadic=True, help="Story ids.")
1399
+ ] = []
1400
+ max_id: Annotated[
1401
+ int | None, opt("--max-id", metavar="ID", help="Mark everything up to this id.")
1402
+ ] = None
1403
+ register_view: Annotated[
1404
+ bool,
1405
+ opt("--register-view", help="Also appear in the poster's viewer list."),
1406
+ ] = False
1407
+
1408
+
1409
+ async def read(ctx: OpContext, req: ReadReq) -> StoryRead:
1410
+ """Clear the unread ring, and only optionally appear as a viewer.
1411
+
1412
+ `stories.readStories` is private bookkeeping; `incrementStoryViews` is
1413
+ what the poster sees. Folding them into one command without a flag would
1414
+ make every `story read` a disclosure.
1415
+ """
1416
+ from telethon.tl.functions import stories as fn
1417
+
1418
+ peer = await _send.resolve(ctx, req.chat)
1419
+ peer_id = _send.peer_id_of(peer)
1420
+ ids = _story.story_ids(req.id)
1421
+ max_id = req.max_id or (max(ids) if ids else 0)
1422
+ if not max_id:
1423
+ peer_stories = await client(ctx)(fn.GetPeerStoriesRequest(peer=peer))
1424
+ stories = getattr(getattr(peer_stories, "stories", None), "stories", None) or []
1425
+ max_id = max((int(getattr(s, "id", 0) or 0) for s in stories), default=0)
1426
+ if not max_id:
1427
+ raise NotFoundError("that peer has no active stories to mark as read")
1428
+
1429
+ marked = await client(ctx)(fn.ReadStoriesRequest(peer=peer, max_id=max_id))
1430
+ read_ids = [int(i) for i in (marked or [])]
1431
+ if not read_ids:
1432
+ already(ctx)
1433
+
1434
+ viewed: list[int] = []
1435
+ if req.register_view and ids:
1436
+ await client(ctx)(fn.IncrementStoryViewsRequest(peer=peer, id=ids))
1437
+ viewed = ids
1438
+ ctx.emit("story_read", {"peer": peer_id, "max_id": max_id})
1439
+ return StoryRead(
1440
+ peer=peer_id,
1441
+ max_id=max_id,
1442
+ ids=read_ids,
1443
+ already=not read_ids,
1444
+ viewed_ids=viewed,
1445
+ )
1446
+
1447
+
1448
+ SPEC_READ = OperationSpec(
1449
+ id="story.read",
1450
+ request=ReadReq,
1451
+ response=StoryRead,
1452
+ impl=read,
1453
+ summary="Mark a peer's stories as seen (clears the unread ring)",
1454
+ description=(
1455
+ "This does NOT make you appear in the poster's viewer list; "
1456
+ "`--register-view` does, and only for the ids you name."
1457
+ ),
1458
+ aliases=("story.view",),
1459
+ mutating=True,
1460
+ idempotent=True,
1461
+ columns=("peer", "max_id", "ids"),
1462
+ example={"peer": 4242, "max_id": 42, "ids": [42], "ok": True},
1463
+ example_args="story read @alice",
1464
+ covers=("stories.increment-views", "stories.mark-read"),
1465
+ )
1466
+
1467
+
1468
+ # ---------------------------------------------------------------------------
1469
+ # story react
1470
+ # ---------------------------------------------------------------------------
1471
+
1472
+
1473
+ class ReactReq(Request):
1474
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")]
1475
+ id: Annotated[int, arg(1, metavar="ID", help="Story id.")]
1476
+ emoji: Annotated[
1477
+ str | None, arg(2, metavar="EMOJI", required=False, help="The reaction to send.")
1478
+ ] = None
1479
+ remove: Annotated[bool, opt("--remove", help="Clear the reaction.")] = False
1480
+ custom_emoji: Annotated[
1481
+ int | None, opt("--custom-emoji", metavar="ID", help="Custom-emoji document id.")
1482
+ ] = None
1483
+ recent: Annotated[
1484
+ bool, opt("--recent/--no-recent", help="Add it to the recent-reactions list.")
1485
+ ] = True
1486
+ as_message: Annotated[
1487
+ bool, opt("--as-message", help="Send the emoji as an ordinary story reply instead.")
1488
+ ] = False
1489
+
1490
+
1491
+ async def react(ctx: OpContext, req: ReactReq) -> StoryReactionResult:
1492
+ """React to a story, or clear the reaction.
1493
+
1494
+ A story carries at most one reaction per viewer — a single `Reaction`,
1495
+ not the vector a message has — so this replaces rather than appends.
1496
+ """
1497
+ from telethon.tl import types
1498
+ from telethon.tl.functions import messages as msg_fn
1499
+ from telethon.tl.functions import stories as fn
1500
+
1501
+ from tlgr.ops.reaction import CUSTOM, to_tl
1502
+
1503
+ peer = await _send.resolve(ctx, req.chat)
1504
+ peer_id = _send.peer_id_of(peer)
1505
+ name = ""
1506
+ if req.custom_emoji is not None:
1507
+ name = f"{CUSTOM}{req.custom_emoji}"
1508
+ elif req.emoji:
1509
+ name = req.emoji
1510
+ if not req.remove and not name:
1511
+ raise UsageError("give an emoji, or --remove to clear the reaction", field="emoji")
1512
+
1513
+ if req.as_message:
1514
+ if req.remove:
1515
+ raise UsageError("--as-message cannot remove a reaction", field="remove")
1516
+ sent = await client(ctx)(
1517
+ msg_fn.SendMessageRequest(
1518
+ peer=peer,
1519
+ message=name,
1520
+ random_id=random_id(),
1521
+ reply_to=types.InputReplyToStory(peer=peer, story_id=req.id),
1522
+ )
1523
+ )
1524
+ message = _send.message_from_updates(sent, chat_id=peer_id, sent_text=name)
1525
+ return StoryReactionResult(peer=peer_id, story_id=req.id, reaction=name, msg_id=message.id)
1526
+
1527
+ reaction = types.ReactionEmpty() if req.remove else to_tl(name)
1528
+ await client(ctx)(
1529
+ fn.SendReactionRequest(
1530
+ peer=peer,
1531
+ story_id=req.id,
1532
+ reaction=reaction,
1533
+ add_to_recent=(req.recent and not req.remove) or None,
1534
+ )
1535
+ )
1536
+ ctx.emit("story_reaction", {"peer": peer_id, "story_id": req.id, "reaction": name})
1537
+ return StoryReactionResult(
1538
+ peer=peer_id, story_id=req.id, reaction="" if req.remove else name, removed=req.remove
1539
+ )
1540
+
1541
+
1542
+ SPEC_REACT = OperationSpec(
1543
+ id="story.react",
1544
+ request=ReactReq,
1545
+ response=StoryReactionResult,
1546
+ impl=react,
1547
+ summary="React to a story, or remove your reaction",
1548
+ description="Paid (Star) reactions do not exist on stories.",
1549
+ mutating=True,
1550
+ rate_class="send",
1551
+ idempotent=True,
1552
+ tags=frozenset({"visible-to-others"}),
1553
+ columns=("peer", "story_id", "reaction"),
1554
+ example={"peer": 4242, "story_id": 42, "reaction": "🔥"},
1555
+ example_args="story react @alice 42 🔥",
1556
+ covers=("stories.react", "stories.reaction-as-message", "stories.unreact"),
1557
+ )
1558
+
1559
+
1560
+ # ---------------------------------------------------------------------------
1561
+ # story reply
1562
+ # ---------------------------------------------------------------------------
1563
+
1564
+
1565
+ class ReplyReq(Request):
1566
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")]
1567
+ id: Annotated[int, arg(1, metavar="ID", help="Story id.")]
1568
+ text: Annotated[
1569
+ str | None, arg(2, metavar="TEXT", required=False, help="Reply body; '-' reads stdin.")
1570
+ ] = None
1571
+ file: Annotated[
1572
+ list[str], opt("--file", metavar="PATH", kind="path", help="Attach a file. Repeatable.")
1573
+ ] = []
1574
+ voice: Annotated[bool, opt("--voice", help="Send the file as a voice note.")] = False
1575
+ sticker: Annotated[
1576
+ str | None, opt("--sticker", metavar="ID", help="Send a sticker document id.")
1577
+ ] = None
1578
+ parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None
1579
+ entities: Annotated[
1580
+ str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
1581
+ ] = None
1582
+ silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
1583
+ schedule: Annotated[
1584
+ str | None, opt("--schedule", metavar="TS|online", help="Schedule the reply.")
1585
+ ] = None
1586
+ paid_stars: Annotated[
1587
+ int | None, opt("--paid-stars", metavar="N", help="Agree to the peer's message price.")
1588
+ ] = None
1589
+
1590
+
1591
+ async def reply(ctx: OpContext, req: ReplyReq) -> StoryReply:
1592
+ """Reply privately to a story.
1593
+
1594
+ The composition is the message group's: this builds `InputReplyToStory`
1595
+ and hands it to the same send path, so every send-time flag behaves the
1596
+ way it does on `message send` rather than almost the same way.
1597
+ """
1598
+ from telethon.tl import types
1599
+ from telethon.tl.functions import messages as fn
1600
+
1601
+ peer = await _send.resolve(ctx, req.chat)
1602
+ peer_id = _send.peer_id_of(peer)
1603
+ text, entities = _send.body(req.text, parse=req.parse, entities=req.entities)
1604
+ reply_to = types.InputReplyToStory(peer=peer, story_id=req.id)
1605
+ schedule = _send.schedule_at(req.schedule)
1606
+
1607
+ media: Any = None
1608
+ if req.sticker:
1609
+ if not req.sticker.isdigit():
1610
+ raise UsageError("--sticker takes a document id", field="sticker")
1611
+ media = types.InputMediaDocument(
1612
+ id=types.InputDocument(id=int(req.sticker), access_hash=0, file_reference=b"")
1613
+ )
1614
+ elif req.file:
1615
+ media = await _send.input_media(ctx, req.file[0], voice=req.voice)
1616
+
1617
+ if media is not None:
1618
+ updates = await client(ctx)(
1619
+ fn.SendMediaRequest(
1620
+ peer=peer,
1621
+ media=media,
1622
+ message=text,
1623
+ entities=_send.tl_entities(entities),
1624
+ random_id=random_id(),
1625
+ reply_to=reply_to,
1626
+ silent=req.silent or None,
1627
+ schedule_date=schedule,
1628
+ allow_paid_stars=req.paid_stars,
1629
+ )
1630
+ )
1631
+ else:
1632
+ if not text:
1633
+ raise UsageError("give some text, --file or --sticker", field="text")
1634
+ updates = await client(ctx)(
1635
+ fn.SendMessageRequest(
1636
+ peer=peer,
1637
+ message=text,
1638
+ entities=_send.tl_entities(entities),
1639
+ random_id=random_id(),
1640
+ reply_to=reply_to,
1641
+ silent=req.silent or None,
1642
+ schedule_date=schedule,
1643
+ allow_paid_stars=req.paid_stars,
1644
+ )
1645
+ )
1646
+ message = _send.message_from_updates(updates, chat_id=peer_id, sent_text=text)
1647
+ ctx.emit("story_reply", {"peer": peer_id, "story_id": req.id, "msg_id": message.id})
1648
+ return StoryReply(
1649
+ chat_id=peer_id,
1650
+ msg_id=message.id,
1651
+ reply_to_story=req.id,
1652
+ text=text,
1653
+ message=message,
1654
+ )
1655
+
1656
+
1657
+ SPEC_REPLY = OperationSpec(
1658
+ id="story.reply",
1659
+ request=ReplyReq,
1660
+ response=StoryReply,
1661
+ impl=reply,
1662
+ summary="Reply privately to a story (text, media, voice or sticker)",
1663
+ description=(
1664
+ "A reply is an ordinary private message carrying `InputReplyToStory`, "
1665
+ "so the peer's message restrictions — Premium-only, paid messages, "
1666
+ "channel story replies locked — apply exactly as they do to a DM."
1667
+ ),
1668
+ mutating=True,
1669
+ rate_class="send",
1670
+ tags=frozenset({"visible-to-others"}),
1671
+ columns=("chat_id", "msg_id", "reply_to_story"),
1672
+ example={"chat_id": 4242, "msg_id": 12345, "reply_to_story": 42, "text": "nice one"},
1673
+ example_args="story reply @alice 42 'nice one'",
1674
+ covers=("stories.reply", "stories.reply-media", "stories.reply-restrictions"),
1675
+ )
1676
+
1677
+
1678
+ # ---------------------------------------------------------------------------
1679
+ # story share
1680
+ # ---------------------------------------------------------------------------
1681
+
1682
+
1683
+ class ShareReq(Request):
1684
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")]
1685
+ id: Annotated[int, arg(1, metavar="ID", help="Story id.")]
1686
+ until: Annotated[
1687
+ list[PeerRef],
1688
+ opt("--until", "--to", metavar="CHAT", kind="peer", help="Destination chat. Repeatable."),
1689
+ ] = []
1690
+ text: Annotated[str | None, opt("--text", help="Caption to send with the card.")] = None
1691
+ silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
1692
+ topic: Annotated[
1693
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.")
1694
+ ] = None
1695
+
1696
+
1697
+ async def share(ctx: OpContext, req: ShareReq) -> StoryShared:
1698
+ """Share a story into chats as a story card.
1699
+
1700
+ Not `forwardMessages`: the receiving message carries `messageMediaStory`,
1701
+ which is what makes it render as a story rather than as a copy of its
1702
+ media. Refused when the story is `noforwards`.
1703
+ """
1704
+ from telethon.tl import types
1705
+ from telethon.tl.functions import messages as fn
1706
+
1707
+ peer = await _send.resolve(ctx, req.chat)
1708
+ peer_id = _send.peer_id_of(peer)
1709
+ if not req.until:
1710
+ raise UsageError("give at least one --until CHAT", field="until")
1711
+
1712
+ source = await _require_own_story(ctx, peer, req.id)
1713
+ if getattr(source, "noforwards", False):
1714
+ raise PermissionError_(
1715
+ f"story {req.id} is protected against forwarding; "
1716
+ f"`tlgr story get {req.chat.raw} {req.id} --link` shares a link instead"
1717
+ )
1718
+
1719
+ sent: list[Message] = []
1720
+ for destination in req.until:
1721
+ target = await _send.resolve(ctx, destination)
1722
+ updates = await client(ctx)(
1723
+ fn.SendMediaRequest(
1724
+ peer=target,
1725
+ media=types.InputMediaStory(peer=peer, id=req.id),
1726
+ message=req.text or "",
1727
+ random_id=random_id(),
1728
+ silent=req.silent or None,
1729
+ reply_to=types.InputReplyToMessage(reply_to_msg_id=req.topic)
1730
+ if req.topic
1731
+ else None,
1732
+ )
1733
+ )
1734
+ sent.append(_send.message_from_updates(updates, chat_id=_send.peer_id_of(target)))
1735
+ ctx.emit("story_shared", {"peer": peer_id, "story_id": req.id, "count": len(sent)})
1736
+ return StoryShared(sent=sent, story_id=req.id, peer=peer_id)
1737
+
1738
+
1739
+ SPEC_SHARE = OperationSpec(
1740
+ id="story.share",
1741
+ request=ShareReq,
1742
+ response=StoryShared,
1743
+ impl=share,
1744
+ summary="Share a story into chats as a story card",
1745
+ aliases=("story.forward",),
1746
+ mutating=True,
1747
+ rate_class="send",
1748
+ tags=frozenset({"visible-to-others"}),
1749
+ columns=("story_id", "peer"),
1750
+ example={
1751
+ "story_id": 42,
1752
+ "peer": 4242,
1753
+ "sent": [
1754
+ {
1755
+ "id": 12345,
1756
+ "chat_id": 777123,
1757
+ "date": "2026-09-03T09:20:00Z",
1758
+ "date_unix": 1788427200,
1759
+ }
1760
+ ],
1761
+ },
1762
+ example_args="story share @alice 42 --until @bobby",
1763
+ covers=("stories.share-to-chat",),
1764
+ )
1765
+
1766
+
1767
+ # ---------------------------------------------------------------------------
1768
+ # story pin / unpin
1769
+ # ---------------------------------------------------------------------------
1770
+
1771
+
1772
+ class PinReq(Request):
1773
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose stories.")]
1774
+ id: Annotated[
1775
+ list[str], arg(1, metavar="ID", required=False, variadic=True, help="Story ids.")
1776
+ ] = []
1777
+ top: Annotated[bool, opt("--top", help="Pin to the top of the profile grid instead.")] = False
1778
+
1779
+
1780
+ async def _toggle_pinned(ctx: OpContext, req: PinReq, *, pinned: bool) -> StoryPinned:
1781
+ from telethon.tl.functions import stories as fn
1782
+
1783
+ peer = await _send.resolve(ctx, req.chat)
1784
+ peer_id = _send.peer_id_of(peer)
1785
+ ids = _story.story_ids(req.id)
1786
+
1787
+ if req.top:
1788
+ # `togglePinnedToTop` replaces the whole set, so an empty vector is
1789
+ # how the pinned-to-top row is cleared.
1790
+ if pinned and not ids:
1791
+ raise UsageError("--top needs the ids to pin to the top", field="id")
1792
+ order = ids if pinned else []
1793
+ await client(ctx)(fn.TogglePinnedToTopRequest(peer=peer, id=order))
1794
+ return StoryPinned(peer=peer_id, ids=ids, pinned=pinned, pinned_to_top=order)
1795
+
1796
+ if not ids:
1797
+ raise UsageError("give at least one story id", field="id")
1798
+ changed = await client(ctx)(fn.TogglePinnedRequest(peer=peer, id=ids, pinned=pinned))
1799
+ if not changed:
1800
+ already(ctx)
1801
+ return StoryPinned(peer=peer_id, ids=[int(i) for i in (changed or [])], pinned=pinned)
1802
+
1803
+
1804
+ async def pin(ctx: OpContext, req: PinReq) -> StoryPinned:
1805
+ """Keep stories on the profile page, or pin them to the top of the grid.
1806
+
1807
+ "Pinned" here means "shown on the profile page", not "first in the grid" —
1808
+ that is `--top`, whose RPC replaces the whole pinned-to-top set.
1809
+ """
1810
+ return await _toggle_pinned(ctx, req, pinned=True)
1811
+
1812
+
1813
+ async def unpin(ctx: OpContext, req: PinReq) -> StoryPinned:
1814
+ """Move stories off the profile page, or clear the pinned-to-top set."""
1815
+ return await _toggle_pinned(ctx, req, pinned=False)
1816
+
1817
+
1818
+ SPEC_PIN = OperationSpec(
1819
+ id="story.pin",
1820
+ request=PinReq,
1821
+ response=StoryPinned,
1822
+ impl=pin,
1823
+ summary="Keep stories on the profile page, or pin them to the top",
1824
+ mutating=True,
1825
+ idempotent=True,
1826
+ columns=("peer", "ids", "pinned"),
1827
+ example={"peer": 4242, "ids": [42], "pinned": True},
1828
+ example_args="story pin me 42",
1829
+ covers=("stories.pin-to-top",),
1830
+ covers_partial=("stories.pin-to-profile",),
1831
+ coverage_note="`story unpin` owns the other half of the profile-page toggle.",
1832
+ )
1833
+
1834
+ SPEC_UNPIN = OperationSpec(
1835
+ id="story.unpin",
1836
+ request=PinReq,
1837
+ response=StoryPinned,
1838
+ impl=unpin,
1839
+ summary="Move stories off the profile page, or clear the pinned-to-top set",
1840
+ mutating=True,
1841
+ idempotent=True,
1842
+ columns=("peer", "ids", "pinned"),
1843
+ example={"peer": 4242, "ids": [42], "pinned": False},
1844
+ example_args="story unpin me 42",
1845
+ covers=("stories.pin-to-profile",),
1846
+ covers_partial=("stories.pin-to-top",),
1847
+ coverage_note="`story pin` owns the other half of the pinned-to-top set.",
1848
+ )
1849
+
1850
+
1851
+ # ---------------------------------------------------------------------------
1852
+ # story hide / unhide
1853
+ # ---------------------------------------------------------------------------
1854
+
1855
+
1856
+ class HideReq(Request):
1857
+ chat: Annotated[
1858
+ list[PeerRef],
1859
+ arg(0, metavar="CHAT", variadic=True, kind="peer", help="Whose stories to hide."),
1860
+ ] = []
1861
+ every: Annotated[bool, opt("--all", help="Collapse the whole stories bar.")] = False
1862
+ unhide: Annotated[
1863
+ bool, opt("--unhide", help="Put them back instead (v1's `user hide-stories --unhide`).")
1864
+ ] = False
1865
+
1866
+
1867
+ async def _toggle_one(ctx: OpContext, ref: PeerRef, *, hidden: bool) -> StoryHiddenPeer:
1868
+ from telethon.tl.functions import stories as fn
1869
+
1870
+ peer = await _send.resolve(ctx, ref)
1871
+ peer_id = _send.peer_id_of(peer)
1872
+ entity = await client(ctx).get_entity(peer)
1873
+ was = bool(getattr(entity, "stories_hidden", False))
1874
+ row = StoryHiddenPeer(
1875
+ user_id=peer_id if peer_id > 0 else 0,
1876
+ username=getattr(entity, "username", None),
1877
+ peer_id=peer_id,
1878
+ hidden=hidden,
1879
+ # v1 detected this and sent nothing, so a bulk pass is cheap to repeat.
1880
+ already=was == hidden,
1881
+ )
1882
+ if not row.already:
1883
+ await client(ctx)(fn.TogglePeerStoriesHiddenRequest(peer=peer, hidden=hidden))
1884
+ ctx.emit("story_peer_hidden", {"peer": peer_id, "hidden": hidden})
1885
+ return row
1886
+
1887
+
1888
+ async def _toggle_hidden(ctx: OpContext, req: HideReq, *, hidden: bool) -> StoryHidden:
1889
+ from telethon.tl.functions import stories as fn
1890
+
1891
+ result = StoryHidden(hidden=hidden, already=False)
1892
+ if req.every:
1893
+ await client(ctx)(fn.ToggleAllStoriesHiddenRequest(hidden=hidden))
1894
+ result.all = True
1895
+ if not req.chat:
1896
+ return result
1897
+ if not req.chat:
1898
+ raise UsageError("give a peer, or --all for the whole stories bar", field="chat")
1899
+
1900
+ rows = [await _toggle_one(ctx, ref, hidden=hidden) for ref in req.chat]
1901
+ first = rows[0]
1902
+ result.user_id = first.user_id
1903
+ result.username = first.username
1904
+ result.peer_id = first.peer_id
1905
+ result.already = first.already
1906
+ if len(rows) > 1:
1907
+ result.peers = rows
1908
+ if all(row.already for row in rows):
1909
+ already(ctx)
1910
+ return result
1911
+
1912
+
1913
+ async def hide(ctx: OpContext, req: HideReq) -> StoryHidden:
1914
+ """Move a peer's stories to the archive bar, or collapse the whole bar.
1915
+
1916
+ Per-account and purely local: the other side is never told, and nothing
1917
+ about the chat, the contact or their access changes. Idempotent —
1918
+ `already: true` means the flag was already set and no RPC was sent.
1919
+ """
1920
+ return await _toggle_hidden(ctx, req, hidden=not req.unhide)
1921
+
1922
+
1923
+ async def unhide(ctx: OpContext, req: HideReq) -> StoryHidden:
1924
+ """Put a peer's stories back in the main bar. The inverse of `story hide`."""
1925
+ return await _toggle_hidden(ctx, req, hidden=req.unhide)
1926
+
1927
+
1928
+ SPEC_HIDE = OperationSpec(
1929
+ id="story.hide",
1930
+ request=HideReq,
1931
+ response=StoryHidden,
1932
+ impl=hide,
1933
+ summary="Hide a peer's stories, or hide the whole stories bar",
1934
+ description=(
1935
+ "v1 spelled this `tlgr user hide-stories`, and that path still works "
1936
+ "— including its `--unhide` flag, which is `story unhide` said the "
1937
+ "other way round. Idempotent: the fresh flag is read first and "
1938
+ "`already: true` means no RPC was sent, so repeating a bulk pass is "
1939
+ "nearly free. More than one peer fills `peers`; a single peer answers "
1940
+ "with exactly the four keys v1 printed."
1941
+ ),
1942
+ legacy_paths=("user hide-stories",),
1943
+ mutating=True,
1944
+ idempotent=True,
1945
+ columns=("user_id", "username", "hidden", "already"),
1946
+ example={"user_id": 4242, "username": "alice", "hidden": True, "already": False},
1947
+ example_args="story hide @alice",
1948
+ covers=(
1949
+ "contacts-users.user-hide-stories",
1950
+ "dialogs.hide-stories-peer",
1951
+ "groups-channels-admin.hide-peer-stories",
1952
+ ),
1953
+ covers_partial=("stories.hide-all", "stories.hide-peer"),
1954
+ coverage_note="`story unhide` owns the other half of both toggles.",
1955
+ )
1956
+
1957
+ SPEC_UNHIDE = OperationSpec(
1958
+ id="story.unhide",
1959
+ request=HideReq,
1960
+ response=StoryHidden,
1961
+ impl=unhide,
1962
+ summary="Put a peer's stories back in the main bar",
1963
+ mutating=True,
1964
+ idempotent=True,
1965
+ columns=("user_id", "username", "hidden", "already"),
1966
+ example={"user_id": 4242, "username": "alice", "hidden": False, "already": False},
1967
+ example_args="story unhide @alice",
1968
+ covers=("stories.hide-all", "stories.hide-peer"),
1969
+ )
1970
+
1971
+
1972
+ # ---------------------------------------------------------------------------
1973
+ # story album
1974
+ # ---------------------------------------------------------------------------
1975
+
1976
+
1977
+ class AlbumCreateReq(Request):
1978
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")]
1979
+ title: Annotated[str, arg(1, metavar="TITLE", help="Album title (1-12 characters).")]
1980
+ story: Annotated[
1981
+ list[int], opt("--story", metavar="ID", help="Story to put in it. Repeatable.")
1982
+ ] = []
1983
+
1984
+
1985
+ async def album_create(ctx: OpContext, req: AlbumCreateReq) -> StoryAlbum:
1986
+ """Create a profile album. Channel albums need the `edit_stories` right."""
1987
+ from telethon.tl.functions import stories as fn
1988
+
1989
+ if not 1 <= len(req.title) <= 12:
1990
+ raise UsageError("an album title is 1 to 12 characters", field="title")
1991
+ if not req.story:
1992
+ raise UsageError("an album needs at least one --story", field="story")
1993
+ peer = await _send.resolve(ctx, req.chat)
1994
+ album = await client(ctx)(
1995
+ fn.CreateAlbumRequest(peer=peer, title=req.title, stories=list(req.story))
1996
+ )
1997
+ return _story.album_model(album, stories=list(req.story))
1998
+
1999
+
2000
+ SPEC_ALBUM_CREATE = OperationSpec(
2001
+ id="story.album.create",
2002
+ request=AlbumCreateReq,
2003
+ response=StoryAlbum,
2004
+ impl=album_create,
2005
+ summary="Create a story album",
2006
+ mutating=True,
2007
+ columns=("id", "title", "stories"),
2008
+ example={"id": 7, "title": "Trips", "stories": [42]},
2009
+ example_args="story album create me Trips --story 42",
2010
+ covers=("stories.album-create",),
2011
+ )
2012
+
2013
+
2014
+ class AlbumDeleteReq(Request):
2015
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")]
2016
+ album_id: Annotated[int, arg(1, metavar="ALBUM_ID", help="Album id.")]
2017
+
2018
+
2019
+ async def album_delete(ctx: OpContext, req: AlbumDeleteReq) -> AlbumDeleted:
2020
+ """Delete an album. The stories inside it stay."""
2021
+ from telethon.tl.functions import stories as fn
2022
+
2023
+ peer = await _send.resolve(ctx, req.chat)
2024
+ await client(ctx)(fn.DeleteAlbumRequest(peer=peer, album_id=req.album_id))
2025
+ return AlbumDeleted(peer=_send.peer_id_of(peer), album_id=req.album_id)
2026
+
2027
+
2028
+ SPEC_ALBUM_DELETE = OperationSpec(
2029
+ id="story.album.delete",
2030
+ request=AlbumDeleteReq,
2031
+ response=AlbumDeleted,
2032
+ impl=album_delete,
2033
+ summary="Delete an album (the stories stay)",
2034
+ mutating=True,
2035
+ destructive=True,
2036
+ columns=("peer", "album_id", "ok"),
2037
+ example={"peer": 4242, "album_id": 7, "ok": True},
2038
+ example_args="story album delete me 7",
2039
+ covers=("stories.album-delete",),
2040
+ )
2041
+
2042
+
2043
+ class AlbumEditReq(Request):
2044
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")]
2045
+ album_id: Annotated[int, arg(1, metavar="ALBUM_ID", help="Album id.")]
2046
+ title: Annotated[str | None, opt("--title", help="New album title (1-12 chars).")] = None
2047
+ add: Annotated[list[int], opt("--add", metavar="ID", help="Story to add. Repeatable.")] = []
2048
+ remove: Annotated[
2049
+ list[int], opt("--remove", metavar="ID", help="Story to remove. Repeatable.")
2050
+ ] = []
2051
+ order: Annotated[
2052
+ list[int], opt("--order", metavar="ID", help="Full story order inside the album.")
2053
+ ] = []
2054
+
2055
+
2056
+ async def album_edit(ctx: OpContext, req: AlbumEditReq) -> StoryAlbum:
2057
+ """Rename an album, add or remove stories, or reorder the ones inside it.
2058
+
2059
+ One RPC (`stories.updateAlbum`) backs all four GUI actions, which is why
2060
+ they are one command with four flags rather than four near-identical ones.
2061
+ """
2062
+ from telethon.tl.functions import stories as fn
2063
+
2064
+ if req.title is not None and not 1 <= len(req.title) <= 12:
2065
+ raise UsageError("an album title is 1 to 12 characters", field="title")
2066
+ if not any((req.title, req.add, req.remove, req.order)):
2067
+ raise UsageError(
2068
+ "nothing to change; pass --title, --add, --remove or --order", field="album_id"
2069
+ )
2070
+ peer = await _send.resolve(ctx, req.chat)
2071
+ album = await client(ctx)(
2072
+ fn.UpdateAlbumRequest(
2073
+ peer=peer,
2074
+ album_id=req.album_id,
2075
+ title=req.title,
2076
+ delete_stories=list(req.remove) or None,
2077
+ add_stories=list(req.add) or None,
2078
+ order=list(req.order) or None,
2079
+ )
2080
+ )
2081
+ return _story.album_model(album, stories=list(req.order) or list(req.add))
2082
+
2083
+
2084
+ SPEC_ALBUM_EDIT = OperationSpec(
2085
+ id="story.album.edit",
2086
+ request=AlbumEditReq,
2087
+ response=StoryAlbum,
2088
+ impl=album_edit,
2089
+ summary="Rename an album, add/remove stories, or reorder the stories inside it",
2090
+ mutating=True,
2091
+ columns=("id", "title", "stories"),
2092
+ example={"id": 7, "title": "Trips 2026", "stories": [42, 43]},
2093
+ example_args="story album edit me 7 --title 'Trips 2026'",
2094
+ covers=(
2095
+ "stories.album-add-stories",
2096
+ "stories.album-remove-stories",
2097
+ "stories.album-rename",
2098
+ "stories.album-reorder-stories",
2099
+ ),
2100
+ )
2101
+
2102
+
2103
+ class AlbumListReq(Request):
2104
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")]
2105
+ hash: Annotated[
2106
+ int | None, opt("--hash", metavar="N", help="Cache hash; unchanged answers `already`.")
2107
+ ] = None
2108
+
2109
+
2110
+ async def album_list(ctx: OpContext, req: AlbumListReq) -> Page[StoryAlbum]:
2111
+ """List the story albums on a profile."""
2112
+ from telethon.tl.functions import stories as fn
2113
+
2114
+ limit, _state = window(ctx, "story.album.list", PageKind.LOCAL, 30)
2115
+ peer = await _send.resolve(ctx, req.chat)
2116
+ result = await client(ctx)(fn.GetAlbumsRequest(peer=peer, hash=req.hash or 0))
2117
+ if type(result).__name__ == "AlbumsNotModified":
2118
+ already(ctx)
2119
+ return Page(items=[], has_more=False, total=0)
2120
+ albums = [_story.album_model(album) for album in (getattr(result, "albums", None) or [])]
2121
+ return Page(items=albums[:limit], has_more=len(albums) > limit, total=len(albums))
2122
+
2123
+
2124
+ SPEC_ALBUM_LIST = OperationSpec(
2125
+ id="story.album.list",
2126
+ request=AlbumListReq,
2127
+ response=Page[StoryAlbum],
2128
+ impl=album_list,
2129
+ summary="List the story albums on a profile",
2130
+ description="Open one with `story list PEER --album ID`.",
2131
+ paginated=PageKind.LOCAL,
2132
+ columns=("id", "title", "stories_count"),
2133
+ headers=("ID", "Title", "Stories"),
2134
+ example={"items": [{"id": 7, "title": "Trips"}], "has_more": False},
2135
+ example_args="story album list me",
2136
+ covers=("stories.album-list",),
2137
+ )
2138
+
2139
+
2140
+ class AlbumReorderReq(Request):
2141
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose profile.")]
2142
+ album_id: Annotated[
2143
+ list[int], arg(1, metavar="ALBUM_ID", variadic=True, help="Albums, in the new order.")
2144
+ ] = []
2145
+
2146
+
2147
+ async def album_reorder(ctx: OpContext, req: AlbumReorderReq) -> AlbumOrder:
2148
+ """Reorder the album chips. A full-replace vector, like every Telegram order."""
2149
+ from telethon.tl.functions import stories as fn
2150
+
2151
+ if not req.album_id:
2152
+ raise UsageError("give the album ids in the order you want them", field="album_id")
2153
+ peer = await _send.resolve(ctx, req.chat)
2154
+ await client(ctx)(fn.ReorderAlbumsRequest(peer=peer, order=list(req.album_id)))
2155
+ return AlbumOrder(peer=_send.peer_id_of(peer), order=list(req.album_id))
2156
+
2157
+
2158
+ SPEC_ALBUM_REORDER = OperationSpec(
2159
+ id="story.album.reorder",
2160
+ request=AlbumReorderReq,
2161
+ response=AlbumOrder,
2162
+ impl=album_reorder,
2163
+ summary="Reorder the album chips on the profile",
2164
+ mutating=True,
2165
+ columns=("peer", "order"),
2166
+ example={"peer": 4242, "order": [8, 7]},
2167
+ example_args="story album reorder me 8 7",
2168
+ covers=("stories.album-reorder",),
2169
+ )
2170
+
2171
+
2172
+ # ---------------------------------------------------------------------------
2173
+ # story blocklist
2174
+ # ---------------------------------------------------------------------------
2175
+
2176
+
2177
+ class BlocklistListReq(Request):
2178
+ pass
2179
+
2180
+
2181
+ async def blocklist_list(ctx: OpContext, req: BlocklistListReq) -> Page[BlockedStoryUser]:
2182
+ """ "Hide my stories from" — a second blocklist, independent of `user block`."""
2183
+ from telethon.tl.functions import contacts as fn
2184
+
2185
+ limit, state = window(ctx, "story.blocklist.list", PageKind.PARTICIPANTS, 30)
2186
+ offset = int(state.get("offset") or 0)
2187
+ result = await client(ctx)(
2188
+ fn.GetBlockedRequest(offset=offset, limit=limit, my_stories_from=True)
2189
+ )
2190
+ users = {int(u.id): u for u in (getattr(result, "users", None) or [])}
2191
+ items: list[BlockedStoryUser] = []
2192
+ for row in getattr(result, "blocked", None) or []:
2193
+ raw_id = peer_id_of(getattr(row, "peer_id", None)) or 0
2194
+ user = users.get(abs(raw_id))
2195
+ items.append(
2196
+ BlockedStoryUser(
2197
+ user_id=raw_id,
2198
+ username=getattr(user, "username", None),
2199
+ name=" ".join(
2200
+ part
2201
+ for part in (
2202
+ getattr(user, "first_name", None),
2203
+ getattr(user, "last_name", None),
2204
+ )
2205
+ if part
2206
+ ),
2207
+ date=fmt_dt(getattr(row, "date", None)),
2208
+ date_unix=to_unix(getattr(row, "date", None)),
2209
+ )
2210
+ )
2211
+ return build_page(
2212
+ items,
2213
+ op="story.blocklist.list",
2214
+ kind=PageKind.PARTICIPANTS,
2215
+ state={"offset": offset + len(items)},
2216
+ account=ctx.account,
2217
+ limit=limit,
2218
+ total=getattr(result, "count", None),
2219
+ )
2220
+
2221
+
2222
+ SPEC_BLOCKLIST_LIST = OperationSpec(
2223
+ id="story.blocklist.list",
2224
+ request=BlocklistListReq,
2225
+ response=Page[BlockedStoryUser],
2226
+ impl=blocklist_list,
2227
+ summary="List the users who never see your stories",
2228
+ description="A second, independent blocklist; `user block` stays the global one.",
2229
+ paginated=PageKind.PARTICIPANTS,
2230
+ columns=("user_id", "username", "name"),
2231
+ headers=("ID", "Username", "Name"),
2232
+ example={"items": [{"user_id": 4242, "username": "alice", "name": "Alice"}], "has_more": False},
2233
+ example_args="story blocklist list",
2234
+ covers_partial=("stories.blocklist",),
2235
+ coverage_note="`story blocklist set` owns the writing half of the list.",
2236
+ )
2237
+
2238
+
2239
+ class BlocklistSetReq(Request):
2240
+ user: Annotated[
2241
+ list[UserRef], arg(0, metavar="USER", variadic=True, kind="user", help="Users.")
2242
+ ] = []
2243
+ remove: Annotated[bool, opt("--remove", help="Remove them from the list instead.")] = False
2244
+ replace: Annotated[
2245
+ bool, opt("--replace", help="Replace the whole list with exactly these users.")
2246
+ ] = False
2247
+
2248
+
2249
+ async def blocklist_set(ctx: OpContext, req: BlocklistSetReq) -> BlocklistChange:
2250
+ """Add to, remove from or replace the story blocklist.
2251
+
2252
+ `--replace` is one RPC that overwrites the list; `--add`/`--remove` are
2253
+ per-user and idempotent, which is what makes a bulk pass safe to repeat.
2254
+ """
2255
+ from telethon.tl.functions import contacts as fn
2256
+
2257
+ if not req.user:
2258
+ raise UsageError("name at least one user", field="user")
2259
+ peers = [await _send.resolve(ctx, ref) for ref in req.user]
2260
+ ids = [_send.peer_id_of(peer) for peer in peers]
2261
+
2262
+ if req.replace:
2263
+ await client(ctx)(fn.SetBlockedRequest(id=peers, limit=len(peers), my_stories_from=True))
2264
+ return BlocklistChange(added=ids, total=len(ids))
2265
+
2266
+ changed: list[int] = []
2267
+ for peer, peer_id in zip(peers, ids, strict=True):
2268
+ request = (
2269
+ fn.UnblockRequest(id=peer, my_stories_from=True)
2270
+ if req.remove
2271
+ else fn.BlockRequest(id=peer, my_stories_from=True)
2272
+ )
2273
+ if await client(ctx)(request):
2274
+ changed.append(peer_id)
2275
+ if not changed:
2276
+ already(ctx)
2277
+ return BlocklistChange(
2278
+ added=[] if req.remove else changed,
2279
+ removed=changed if req.remove else [],
2280
+ already=not changed,
2281
+ )
2282
+
2283
+
2284
+ SPEC_BLOCKLIST_SET = OperationSpec(
2285
+ id="story.blocklist.set",
2286
+ request=BlocklistSetReq,
2287
+ response=BlocklistChange,
2288
+ impl=blocklist_set,
2289
+ summary="Add to, remove from or replace the story blocklist",
2290
+ mutating=True,
2291
+ idempotent=True,
2292
+ columns=("added", "removed", "total"),
2293
+ example={"added": [4242], "removed": [], "total": 1},
2294
+ example_args="story blocklist set @alice",
2295
+ covers=("dialogs.block-stories", "stories.blocklist"),
2296
+ )
2297
+
2298
+
2299
+ # ---------------------------------------------------------------------------
2300
+ # story viewer list
2301
+ # ---------------------------------------------------------------------------
2302
+
2303
+
2304
+ class ViewerListReq(Request):
2305
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")]
2306
+ id: Annotated[int, arg(1, metavar="ID", help="Story id.")]
2307
+ contacts: Annotated[bool, opt("--contacts", help="Contacts only.")] = False
2308
+ reactions_first: Annotated[
2309
+ bool, opt("--reactions-first", help="Sort viewers who reacted first.")
2310
+ ] = False
2311
+ forwards_first: Annotated[
2312
+ bool, opt("--forwards-first", help="Sort reposts and forwards first.")
2313
+ ] = False
2314
+ q: Annotated[str | None, opt("--q", metavar="TEXT", help="Server-side name search.")] = None
2315
+ reaction: Annotated[
2316
+ str | None, opt("--reaction", metavar="EMOJI", help="Channel stories: only this reaction.")
2317
+ ] = None
2318
+ csv_out: Annotated[
2319
+ str | None, opt("--csv", metavar="PATH", kind="path", help="Also write the rows as CSV.")
2320
+ ] = None
2321
+ hide_from: Annotated[
2322
+ list[UserRef],
2323
+ opt("--hide-from", metavar="USER", kind="user", help="Add a viewer to the blocklist."),
2324
+ ] = []
2325
+
2326
+
2327
+ def _viewer_row(row: Any, table: dict[int, Any]) -> StoryViewer:
2328
+ from tlgr.ops._serialize import entity_to_peer as to_peer
2329
+ from tlgr.ops.reaction import name_of
2330
+
2331
+ name = type(row).__name__
2332
+ if name in ("StoryView", "StoryReaction"):
2333
+ raw_id = int(getattr(row, "user_id", 0) or 0) or (
2334
+ peer_id_of(getattr(row, "peer_id", None)) or 0
2335
+ )
2336
+ reaction = getattr(row, "reaction", None)
2337
+ return StoryViewer(
2338
+ kind="view",
2339
+ user_id=raw_id,
2340
+ date=fmt_dt(getattr(row, "date", None)),
2341
+ date_unix=to_unix(getattr(row, "date", None)),
2342
+ reaction=name_of(reaction) if reaction is not None else None,
2343
+ blocked=bool(getattr(row, "blocked", False)),
2344
+ blocked_my_stories_from=bool(getattr(row, "blocked_my_stories_from", False)),
2345
+ )
2346
+ if name in ("StoryViewPublicForward", "StoryReactionPublicForward"):
2347
+ message = getattr(row, "message", None)
2348
+ return StoryViewer(
2349
+ kind="forward",
2350
+ user_id=peer_id_of(getattr(message, "peer_id", None)) or 0,
2351
+ msg_id=int(getattr(message, "id", 0) or 0),
2352
+ blocked=bool(getattr(row, "blocked", False)),
2353
+ )
2354
+ story = getattr(row, "story", None)
2355
+ peer = getattr(row, "peer_id", None)
2356
+ entity = _peer_entity(peer, table)
2357
+ return StoryViewer(
2358
+ kind="repost",
2359
+ user_id=peer_id_of(peer) or 0,
2360
+ peer=to_peer(entity) if entity is not None else None,
2361
+ story_id=int(getattr(story, "id", 0) or 0),
2362
+ blocked=bool(getattr(row, "blocked", False)),
2363
+ )
2364
+
2365
+
2366
+ async def viewer_list(ctx: OpContext, req: ViewerListReq) -> Page[StoryViewer]:
2367
+ """Who saw a story, with their reactions.
2368
+
2369
+ Your own user stories go through `getStoryViewsList`; a channel story you
2370
+ administer only has `getStoryReactionsList`, which knows about reactions,
2371
+ forwards and reposts but not about plain views. The RPC is chosen from the
2372
+ peer type and `--reaction` forces the second one, because reporting an
2373
+ empty viewer list for a channel story would read as "nobody watched".
2374
+ """
2375
+ from telethon.tl.functions import stories as fn
2376
+
2377
+ limit, state = window(ctx, "story.viewer.list", PageKind.PARTICIPANTS, 30)
2378
+ peer = await _send.resolve(ctx, req.chat)
2379
+ peer_id = _send.peer_id_of(peer)
2380
+ offset = str(state.get("offset") or "")
2381
+
2382
+ if req.hide_from:
2383
+ await blocklist_set(ctx, BlocklistSetReq(user=list(req.hide_from)))
2384
+
2385
+ reactions_only = req.reaction is not None or peer_id < 0
2386
+ if reactions_only:
2387
+ from tlgr.ops.reaction import to_tl
2388
+
2389
+ result = await client(ctx)(
2390
+ fn.GetStoryReactionsListRequest(
2391
+ peer=peer,
2392
+ id=req.id,
2393
+ limit=limit,
2394
+ forwards_first=req.forwards_first or None,
2395
+ reaction=to_tl(req.reaction) if req.reaction else None,
2396
+ offset=offset or None,
2397
+ )
2398
+ )
2399
+ rows = getattr(result, "reactions", None) or []
2400
+ else:
2401
+ result = await client(ctx)(
2402
+ fn.GetStoryViewsListRequest(
2403
+ peer=peer,
2404
+ id=req.id,
2405
+ offset=offset,
2406
+ limit=limit,
2407
+ just_contacts=req.contacts or None,
2408
+ reactions_first=req.reactions_first or None,
2409
+ forwards_first=req.forwards_first or None,
2410
+ q=req.q,
2411
+ )
2412
+ )
2413
+ rows = getattr(result, "views", None) or []
2414
+
2415
+ table = _entities(result)
2416
+ items = [_viewer_row(row, table) for row in rows]
2417
+ ctx.warn(
2418
+ "source: stories.getStoryReactionsList (channel stories have no plain view rows)"
2419
+ if reactions_only
2420
+ else "source: stories.getStoryViewsList"
2421
+ )
2422
+ if req.csv_out:
2423
+ _write_csv(req.csv_out, items)
2424
+
2425
+ next_offset = getattr(result, "next_offset", None)
2426
+ return build_page(
2427
+ items,
2428
+ op="story.viewer.list",
2429
+ kind=PageKind.PARTICIPANTS,
2430
+ state={"offset": next_offset},
2431
+ account=ctx.account,
2432
+ has_more=bool(next_offset),
2433
+ total=getattr(result, "count", None),
2434
+ )
2435
+
2436
+
2437
+ def _write_csv(path: str, items: list[StoryViewer]) -> None:
2438
+ """The export the GUI has no button for."""
2439
+ target = Path(os.path.expanduser(path))
2440
+ target.parent.mkdir(parents=True, exist_ok=True)
2441
+ with target.open("w", encoding="utf-8", newline="") as handle:
2442
+ writer = csv.writer(handle)
2443
+ writer.writerow(["id", "username", "name", "date", "reaction", "blocked", "kind"])
2444
+ for item in items:
2445
+ user = item.user
2446
+ writer.writerow(
2447
+ [
2448
+ item.user_id,
2449
+ getattr(user, "username", "") or "",
2450
+ getattr(user, "title", "") or "",
2451
+ item.date or "",
2452
+ item.reaction or "",
2453
+ int(item.blocked),
2454
+ item.kind,
2455
+ ]
2456
+ )
2457
+
2458
+
2459
+ SPEC_VIEWER_LIST = OperationSpec(
2460
+ id="story.viewer.list",
2461
+ request=ViewerListReq,
2462
+ response=Page[StoryViewer],
2463
+ impl=viewer_list,
2464
+ summary="Who saw a story, with their reactions",
2465
+ description=(
2466
+ "A non-Premium account loses the list `story_viewers_expire_period` "
2467
+ "seconds after the story expires; `views.has_viewers` on the story "
2468
+ "says whether it is still available."
2469
+ ),
2470
+ paginated=PageKind.PARTICIPANTS,
2471
+ tags=frozenset({"mutating-checked"}),
2472
+ columns=("user_id", "date", "reaction", "kind"),
2473
+ headers=("User", "Seen", "Reaction", "Kind"),
2474
+ example={
2475
+ "items": [{"user_id": 4242, "date": "2026-09-03T10:00:00Z", "reaction": "🔥"}],
2476
+ "has_more": False,
2477
+ },
2478
+ example_args="story viewer list me 42",
2479
+ covers=(
2480
+ "reaction.story-list",
2481
+ "stories.channel-story-interactions",
2482
+ "stories.viewer-block",
2483
+ "stories.viewers-export",
2484
+ "stories.viewers-filters",
2485
+ "stories.viewers-list",
2486
+ "stories.viewers-search",
2487
+ ),
2488
+ )
2489
+
2490
+
2491
+ # ---------------------------------------------------------------------------
2492
+ # story stealth
2493
+ # ---------------------------------------------------------------------------
2494
+
2495
+
2496
+ class StealthSetReq(Request):
2497
+ past: Annotated[bool, opt("--past", help="Erase your views from the recent window.")] = False
2498
+ future: Annotated[bool, opt("--future", help="Hide your views for the next window.")] = False
2499
+ status: Annotated[bool, opt("--status", help="Only report the state and do nothing.")] = False
2500
+
2501
+
2502
+ async def stealth_set(ctx: OpContext, req: StealthSetReq) -> StealthMode:
2503
+ """Stealth mode: erase recent views and/or hide the next ones.
2504
+
2505
+ Premium only, and rate limited by its own cooldown. A `FLOOD_WAIT` here is
2506
+ the cooldown rather than a server complaint, so it is reported as the
2507
+ remaining cooldown instead of as a raw error.
2508
+ """
2509
+ from telethon.tl.functions import stories as fn
2510
+
2511
+ if req.status or not (req.past or req.future):
2512
+ feed = await client(ctx)(fn.GetAllStoriesRequest())
2513
+ return _story.stealth_model(getattr(feed, "stealth_mode", None))
2514
+
2515
+ from telethon.errors import FloodWaitError
2516
+
2517
+ try:
2518
+ await client(ctx)(
2519
+ fn.ActivateStealthModeRequest(past=req.past or None, future=req.future or None)
2520
+ )
2521
+ except FloodWaitError as exc:
2522
+ raise PermissionError_(f"stealth mode is still cooling down; {exc.seconds}s left") from exc
2523
+ feed = await client(ctx)(fn.GetAllStoriesRequest())
2524
+ mode = _story.stealth_model(
2525
+ getattr(feed, "stealth_mode", None), past=req.past, future=req.future
2526
+ )
2527
+ ctx.emit("story_stealth", {"active_until": mode.active_until_unix})
2528
+ return mode
2529
+
2530
+
2531
+ SPEC_STEALTH_SET = OperationSpec(
2532
+ id="story.stealth.set",
2533
+ request=StealthSetReq,
2534
+ response=StealthMode,
2535
+ impl=stealth_set,
2536
+ summary="Stealth mode: erase recent views and/or hide the next ones",
2537
+ description=(
2538
+ "`--status` reads the state out of the feed reply, which is also "
2539
+ "where `story feed list` gets it from."
2540
+ ),
2541
+ mutating=True,
2542
+ columns=("active_until_date", "cooldown_until_date"),
2543
+ headers=("Active until", "Cooldown until"),
2544
+ example={"active_until_date": "2026-09-03T09:39:07Z", "past": True, "future": True},
2545
+ example_args="story stealth set --past --future",
2546
+ covers=("stories.stealth-activate", "stories.stealth-status"),
2547
+ )
2548
+
2549
+
2550
+ # ---------------------------------------------------------------------------
2551
+ # story search
2552
+ # ---------------------------------------------------------------------------
2553
+
2554
+
2555
+ class SearchReq(Request):
2556
+ hashtag: Annotated[
2557
+ str | None, opt("--hashtag", metavar="TAG", help="Hashtag or cashtag, without the #.")
2558
+ ] = None
2559
+ venue: Annotated[
2560
+ str | None, opt("--venue", metavar="PROVIDER:VENUE_ID", help="Search by venue area.")
2561
+ ] = None
2562
+ geo: Annotated[
2563
+ str | None, opt("--geo", metavar="LAT,LON", help="Search by geo area (needs --address).")
2564
+ ] = None
2565
+ address: Annotated[
2566
+ str | None,
2567
+ opt("--address", metavar="CC[,state,city,street]", help="Address attached to --geo."),
2568
+ ] = None
2569
+ peer: Annotated[
2570
+ PeerRef | None,
2571
+ opt("--peer", metavar="PEER", kind="peer", help="Only this poster."),
2572
+ ] = None
2573
+
2574
+
2575
+ async def search(ctx: OpContext, req: SearchReq) -> Page[Story]:
2576
+ """Search public stories by hashtag or location.
2577
+
2578
+ Only "Everyone" stories are searchable, so an empty result means "nothing
2579
+ public matched", not "nothing exists".
2580
+ """
2581
+ from telethon.tl import types
2582
+ from telethon.tl.functions import stories as fn
2583
+
2584
+ limit, state = window(ctx, "story.search", PageKind.SEARCH, 30)
2585
+ given = [
2586
+ name
2587
+ for name, value in (("hashtag", req.hashtag), ("venue", req.venue), ("geo", req.geo))
2588
+ if value
2589
+ ]
2590
+ if len(given) != 1:
2591
+ raise UsageError(
2592
+ "give exactly one of --hashtag, --venue or --geo",
2593
+ field=given[0] if given else "hashtag",
2594
+ )
2595
+
2596
+ area: Any = None
2597
+ coordinates = types.MediaAreaCoordinates(x=0.0, y=0.0, w=0.0, h=0.0, rotation=0.0)
2598
+ if req.venue:
2599
+ provider, _, venue_id = req.venue.partition(":")
2600
+ if not venue_id:
2601
+ raise UsageError("--venue takes PROVIDER:VENUE_ID", field="venue")
2602
+ area = types.MediaAreaVenue(
2603
+ coordinates=coordinates,
2604
+ geo=types.GeoPoint(long=0.0, lat=0.0, access_hash=0),
2605
+ title="",
2606
+ address="",
2607
+ provider=provider,
2608
+ venue_id=venue_id,
2609
+ venue_type="",
2610
+ )
2611
+ elif req.geo:
2612
+ if not req.address:
2613
+ raise UsageError("--geo is only searchable with an --address", field="address")
2614
+ lat, _, lon = req.geo.partition(",")
2615
+ parts = [p.strip() for p in req.address.split(",")]
2616
+ area = types.MediaAreaGeoPoint(
2617
+ coordinates=coordinates,
2618
+ geo=types.GeoPoint(long=float(lon), lat=float(lat), access_hash=0),
2619
+ address=types.GeoPointAddress(
2620
+ country_iso2=parts[0],
2621
+ state=parts[1] if len(parts) > 1 else None,
2622
+ city=parts[2] if len(parts) > 2 else None,
2623
+ street=parts[3] if len(parts) > 3 else None,
2624
+ ),
2625
+ )
2626
+
2627
+ peer = await _send.resolve(ctx, req.peer) if req.peer is not None else None
2628
+ result = await client(ctx)(
2629
+ fn.SearchPostsRequest(
2630
+ offset=str(state.get("offset") or ""),
2631
+ limit=limit,
2632
+ hashtag=req.hashtag,
2633
+ area=area,
2634
+ peer=peer,
2635
+ )
2636
+ )
2637
+ table = _entities(result)
2638
+ items = []
2639
+ for found in getattr(result, "stories", None) or []:
2640
+ found_peer = getattr(found, "peer", None)
2641
+ story = _story.story_model(
2642
+ getattr(found, "story", None),
2643
+ peer_id=peer_id_of(found_peer) or 0,
2644
+ peer=_peer_entity(found_peer, table),
2645
+ )
2646
+ items.append(story)
2647
+ next_offset = getattr(result, "next_offset", None)
2648
+ return build_page(
2649
+ items,
2650
+ op="story.search",
2651
+ kind=PageKind.SEARCH,
2652
+ state={"offset": next_offset},
2653
+ account=ctx.account,
2654
+ has_more=bool(next_offset),
2655
+ total=getattr(result, "count", None),
2656
+ )
2657
+
2658
+
2659
+ SPEC_SEARCH = OperationSpec(
2660
+ id="story.search",
2661
+ request=SearchReq,
2662
+ response=Page[Story],
2663
+ impl=search,
2664
+ summary="Search public stories by hashtag or location",
2665
+ paginated=PageKind.SEARCH,
2666
+ rate_class="resolve",
2667
+ columns=("peer_id", "id", "date", "caption"),
2668
+ example={"items": [_EXAMPLE_STORY], "has_more": False},
2669
+ example_args="story search --hashtag berlin",
2670
+ covers=(
2671
+ "messages-core.search-hashtag-stories",
2672
+ "stories.search-hashtag",
2673
+ "stories.search-location",
2674
+ "stories.search-peer-scoped",
2675
+ ),
2676
+ )
2677
+
2678
+
2679
+ # ---------------------------------------------------------------------------
2680
+ # story report
2681
+ # ---------------------------------------------------------------------------
2682
+
2683
+
2684
+ class ReportReq(Request):
2685
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")]
2686
+ id: Annotated[list[str], arg(1, metavar="ID", variadic=True, help="Story ids.")] = []
2687
+ option: Annotated[
2688
+ str | None, opt("--option", metavar="B64", help="Opaque option bytes from the last step.")
2689
+ ] = None
2690
+ message: Annotated[str | None, opt("--message", help="Free-text comment, when asked for.")] = (
2691
+ None
2692
+ )
2693
+
2694
+
2695
+ async def report(ctx: OpContext, req: ReportReq) -> StoryReport:
2696
+ """Report a story. Multi-step: an empty `--option` starts the flow.
2697
+
2698
+ The server answers with a menu (`reportResultChooseOption`) or a request
2699
+ for a comment, and `--json` makes both scriptable — the legacy
2700
+ `inputReportReason*` constructors no longer exist.
2701
+ """
2702
+ import base64
2703
+
2704
+ from telethon.tl.functions import stories as fn
2705
+
2706
+ ids = _story.story_ids(req.id)
2707
+ if not ids:
2708
+ raise UsageError("give at least one story id", field="id")
2709
+ peer = await _send.resolve(ctx, req.chat)
2710
+ option = base64.b64decode(req.option) if req.option else b""
2711
+ result = await client(ctx)(
2712
+ fn.ReportRequest(peer=peer, id=ids, option=option, message=req.message or "")
2713
+ )
2714
+ name = type(result).__name__
2715
+ if name == "ReportResultChooseOption":
2716
+ return StoryReport(
2717
+ result="choose_option",
2718
+ title=str(getattr(result, "title", "") or ""),
2719
+ options=[
2720
+ {
2721
+ "text": str(getattr(item, "text", "") or ""),
2722
+ "option": base64.b64encode(getattr(item, "option", b"")).decode(),
2723
+ }
2724
+ for item in (getattr(result, "options", None) or [])
2725
+ ],
2726
+ )
2727
+ if name == "ReportResultAddComment":
2728
+ return StoryReport(
2729
+ result="add_comment",
2730
+ comment_required=not bool(getattr(result, "optional", False)),
2731
+ options=[{"option": base64.b64encode(getattr(result, "option", b"") or b"").decode()}],
2732
+ )
2733
+ return StoryReport(result="reported", reported=True)
2734
+
2735
+
2736
+ SPEC_REPORT = OperationSpec(
2737
+ id="story.report",
2738
+ request=ReportReq,
2739
+ response=StoryReport,
2740
+ impl=report,
2741
+ summary="Report a story",
2742
+ mutating=True,
2743
+ columns=("result", "title", "comment_required"),
2744
+ example={"result": "choose_option", "title": "What is wrong?", "options": []},
2745
+ example_args="story report @alice 42",
2746
+ covers=("stories.report",),
2747
+ )
2748
+
2749
+
2750
+ # ---------------------------------------------------------------------------
2751
+ # story stats
2752
+ # ---------------------------------------------------------------------------
2753
+
2754
+
2755
+ class StatsGetReq(Request):
2756
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Whose story.")]
2757
+ id: Annotated[int, arg(1, metavar="ID", help="Story id.")]
2758
+ forwards: Annotated[
2759
+ bool, opt("--forwards", help="List the public reposts instead of the graphs.")
2760
+ ] = False
2761
+ dark: Annotated[bool, opt("--dark", help="Ask for the dark-theme graph variant.")] = False
2762
+ raw: Annotated[bool, opt("--raw", help="Emit the raw StatsGraph JSON.")] = False
2763
+
2764
+
2765
+ async def _graph(ctx: OpContext, graph: Any, *, dark: bool, raw: bool) -> dict[str, Any] | None:
2766
+ """Resolve a `statsGraphAsync` token before reporting it.
2767
+
2768
+ An async graph is a token, not data; handing the token to the caller would
2769
+ make `story stats` return something nobody can plot.
2770
+ """
2771
+ import json as jsonlib
2772
+
2773
+ from telethon.tl.functions import stats as fn
2774
+
2775
+ if graph is None:
2776
+ return None
2777
+ if type(graph).__name__ == "StatsGraphAsync":
2778
+ graph = await client(ctx)(
2779
+ fn.LoadAsyncGraphRequest(token=str(getattr(graph, "token", "")), x=1 if dark else None)
2780
+ )
2781
+ if type(graph).__name__ == "StatsGraphError":
2782
+ return {"error": str(getattr(graph, "error", ""))}
2783
+ payload = getattr(getattr(graph, "json", None), "data", None)
2784
+ if payload is None:
2785
+ return None
2786
+ if raw:
2787
+ return {"json": str(payload)}
2788
+ try:
2789
+ return dict(jsonlib.loads(payload))
2790
+ except (ValueError, TypeError):
2791
+ return {"json": str(payload)}
2792
+
2793
+
2794
+ async def stats_get(ctx: OpContext, req: StatsGetReq) -> StoryStats:
2795
+ """Story statistics: view/reaction graphs, or the public reposts."""
2796
+ from telethon.tl.functions import stats as fn
2797
+
2798
+ peer = await _send.resolve(ctx, req.chat)
2799
+ if req.forwards:
2800
+ limit, state = window(ctx, "story.stats.get", PageKind.SEARCH, 30)
2801
+ result = await client(ctx)(
2802
+ fn.GetStoryPublicForwardsRequest(
2803
+ peer=peer, id=req.id, offset=str(state.get("offset") or ""), limit=limit
2804
+ )
2805
+ )
2806
+ table = _entities(result)
2807
+ forwards: list[dict[str, Any]] = []
2808
+ for row in getattr(result, "forwards", None) or []:
2809
+ if type(row).__name__ == "PublicForwardMessage":
2810
+ message = getattr(row, "message", None)
2811
+ forwards.append(
2812
+ {
2813
+ "kind": "message",
2814
+ "chat_id": peer_id_of(getattr(message, "peer_id", None)) or 0,
2815
+ "msg_id": int(getattr(message, "id", 0) or 0),
2816
+ }
2817
+ )
2818
+ else:
2819
+ origin = getattr(row, "peer", None) or getattr(row, "peer_id", None)
2820
+ entity = _peer_entity(origin, table)
2821
+ forwards.append(
2822
+ {
2823
+ "kind": "story",
2824
+ "chat_id": peer_id_of(origin) or 0,
2825
+ "story_id": int(getattr(getattr(row, "story", None), "id", 0) or 0),
2826
+ "title": str(getattr(entity, "title", "") or ""),
2827
+ }
2828
+ )
2829
+ return StoryStats(forwards=forwards)
2830
+
2831
+ result = await client(ctx)(fn.GetStoryStatsRequest(peer=peer, id=req.id, dark=req.dark or None))
2832
+ return StoryStats(
2833
+ views_graph=await _graph(
2834
+ ctx, getattr(result, "views_graph", None), dark=req.dark, raw=req.raw
2835
+ ),
2836
+ reactions_by_emotion_graph=await _graph(
2837
+ ctx, getattr(result, "reactions_by_emotion_graph", None), dark=req.dark, raw=req.raw
2838
+ ),
2839
+ )
2840
+
2841
+
2842
+ SPEC_STATS_GET = OperationSpec(
2843
+ id="story.stats.get",
2844
+ request=StatsGetReq,
2845
+ response=StoryStats,
2846
+ impl=stats_get,
2847
+ summary="Story statistics: view/reaction graphs and public reposts",
2848
+ description="Needs `can_view_stats` on the channel, or your own story.",
2849
+ timeout_s=300,
2850
+ columns=("forwards",),
2851
+ example={"views_graph": {"columns": []}, "forwards": []},
2852
+ example_args="story stats get me 42",
2853
+ covers=("stories.public-forwards", "stories.stats"),
2854
+ )
2855
+
2856
+
2857
+ # ---------------------------------------------------------------------------
2858
+ # story live
2859
+ # ---------------------------------------------------------------------------
2860
+
2861
+
2862
+ class LiveGetReq(Request):
2863
+ chat: Annotated[
2864
+ PeerRef | None,
2865
+ arg(0, metavar="CHAT", required=False, kind="peer", help="Whose live story."),
2866
+ ] = None
2867
+
2868
+
2869
+ async def live_get(ctx: OpContext, req: LiveGetReq) -> LiveStory:
2870
+ """What is known about a peer's live story.
2871
+
2872
+ Telethon 1.44 speaks layer 227, whose `storyItem` carries no group-call
2873
+ reference, so the viewer count, publisher and stream settings a live story
2874
+ keeps on its call are not reachable from here — the story id, its dates
2875
+ and the live flag are. `vc` (PR-11) owns the call surface; this reports
2876
+ what the story layer actually exposes and says so rather than returning
2877
+ zeros that look like an empty broadcast.
2878
+ """
2879
+ from telethon.tl.functions import stories as fn
2880
+
2881
+ peer = await _story.resolve_or_self(ctx, req.chat)
2882
+ peer_id = await _story.peer_id_for(ctx, peer)
2883
+ result = await client(ctx)(fn.GetPeerStoriesRequest(peer=peer))
2884
+ stories = getattr(getattr(result, "stories", None), "stories", None) or []
2885
+ live = [item for item in stories if getattr(item, "live", False)]
2886
+ if not live:
2887
+ raise NotFoundError("that peer has no live story right now")
2888
+ item = live[0]
2889
+ ctx.warn(
2890
+ "the pinned Telethon (layer 227) attaches no group call to a story, so the "
2891
+ "viewer count, publisher and stream settings are not reported"
2892
+ )
2893
+ return LiveStory(
2894
+ story_id=int(getattr(item, "id", 0) or 0),
2895
+ peer=peer_id,
2896
+ date=fmt_dt(getattr(item, "date", None)),
2897
+ expire_date=fmt_dt(getattr(item, "expire_date", None)),
2898
+ )
2899
+
2900
+
2901
+ SPEC_LIVE_GET = OperationSpec(
2902
+ id="story.live.get",
2903
+ request=LiveGetReq,
2904
+ response=LiveStory,
2905
+ impl=live_get,
2906
+ summary="Info about a peer's live story",
2907
+ description=(
2908
+ "Layer 227 exposes the live story itself but not its group call, so "
2909
+ "the call-side fields stay null and a warning says why."
2910
+ ),
2911
+ columns=("story_id", "peer", "live"),
2912
+ example={"story_id": 42, "peer": 4242, "live": True},
2913
+ example_args="story live get @alice",
2914
+ covers_partial=("livestory.streamer-info", "stories.live-join"),
2915
+ coverage_note=(
2916
+ "The live story is reported; its group call is not reachable from "
2917
+ "layer 227's storyItem, and joining a broadcast needs a media engine "
2918
+ "tlgr does not have."
2919
+ ),
2920
+ )
2921
+
2922
+
2923
+ class LiveStartReq(PrivacyOptions, kw_only=True):
2924
+ chat: Annotated[
2925
+ PeerRef | None,
2926
+ arg(0, metavar="CHAT", required=False, kind="peer", help="Post as this channel."),
2927
+ ] = None
2928
+ rtmp: Annotated[bool, opt("--rtmp", help="RTMP mode: an external encoder supplies video.")] = (
2929
+ False
2930
+ )
2931
+ caption: Annotated[str | None, opt("--caption", help="Live story caption.")] = None
2932
+ parse: Annotated[str | None, choice("md", "html", "none", help="Caption formatting.")] = None
2933
+ pin: Annotated[bool, opt("--pin", help="Keep the recording on the profile page.")] = False
2934
+ protect: Annotated[bool, opt("--protect", help="noforwards.")] = False
2935
+ comments: Annotated[str | None, choice("on", "off", help="In-call comment overlay.")] = None
2936
+ comment_price: Annotated[
2937
+ int | None,
2938
+ opt("--comment-price", metavar="STARS", help="Minimum Stars to comment (0 = free)."),
2939
+ ] = None
2940
+
2941
+
2942
+ async def live_start(ctx: OpContext, req: LiveStartReq) -> LiveStory:
2943
+ """Start a live story.
2944
+
2945
+ Control-only unless `--rtmp`: `stories.startLive` creates the story and
2946
+ its call, but tlgr has no media engine, so a non-RTMP live story would
2947
+ broadcast silence. With `--rtmp` the CLI is a complete answer — it prints
2948
+ the ingest URL and key, and ffmpeg or OBS supplies the video.
2949
+ """
2950
+ from telethon.tl.functions import phone as phone_fn
2951
+ from telethon.tl.functions import stories as fn
2952
+
2953
+ peer = await _story.resolve_or_self(ctx, req.chat)
2954
+ peer_id = await _story.peer_id_for(ctx, peer)
2955
+ if not req.rtmp:
2956
+ ctx.warn(
2957
+ "without --rtmp nothing will supply the video: tlgr has no media "
2958
+ "engine, so the broadcast would be silent"
2959
+ )
2960
+ text, entities = _send.body(req.caption, parse=req.parse)
2961
+ rules = await _story.privacy_rules(
2962
+ ctx,
2963
+ base=req.privacy or "everyone",
2964
+ allow=tuple(req.allow),
2965
+ exclude=tuple(req.exclude),
2966
+ preset=req.privacy_preset,
2967
+ )
2968
+ updates = await client(ctx)(
2969
+ fn.StartLiveRequest(
2970
+ peer=peer,
2971
+ privacy_rules=rules,
2972
+ pinned=req.pin or None,
2973
+ noforwards=req.protect or None,
2974
+ rtmp_stream=req.rtmp or None,
2975
+ caption=text or None,
2976
+ entities=_send.tl_entities(entities),
2977
+ random_id=random_id(),
2978
+ messages_enabled=(req.comments != "off") or None,
2979
+ send_paid_messages_stars=req.comment_price,
2980
+ )
2981
+ )
2982
+ story = _story_from_updates(updates, peer_id=peer_id)
2983
+ live = LiveStory(
2984
+ story_id=story.id,
2985
+ peer=peer_id,
2986
+ rtmp_stream=req.rtmp,
2987
+ messages_enabled=req.comments != "off",
2988
+ send_paid_messages_stars=req.comment_price,
2989
+ pinned=req.pin,
2990
+ noforwards=req.protect,
2991
+ )
2992
+ for update in getattr(updates, "updates", None) or []:
2993
+ call = getattr(update, "call", None)
2994
+ if call is not None:
2995
+ live.call_id = getattr(call, "id", None)
2996
+ live.participants_count = getattr(call, "participants_count", None)
2997
+ live.stream_dc_id = getattr(call, "stream_dc_id", None)
2998
+ if req.rtmp:
2999
+ rtmp = await client(ctx)(
3000
+ phone_fn.GetGroupCallStreamRtmpUrlRequest(peer=peer, revoke=False, live_story=True)
3001
+ )
3002
+ live.rtmp_url = getattr(rtmp, "url", None)
3003
+ live.rtmp_key = getattr(rtmp, "key", None)
3004
+ ctx.emit("story_live_started", {"peer": peer_id, "story_id": live.story_id})
3005
+ return live
3006
+
3007
+
3008
+ SPEC_LIVE_START = OperationSpec(
3009
+ id="story.live.start",
3010
+ request=LiveStartReq,
3011
+ response=LiveStory,
3012
+ impl=live_start,
3013
+ summary="Start a live story (optionally RTMP, so an external encoder supplies the video)",
3014
+ description=(
3015
+ "One active live story per peer. Setting a comment price spends "
3016
+ "nothing; end the stream with the call commands."
3017
+ ),
3018
+ mutating=True,
3019
+ rate_class="send",
3020
+ timeout_s=300,
3021
+ tags=frozenset({"visible-to-others"}),
3022
+ columns=("story_id", "peer", "rtmp_stream", "rtmp_url"),
3023
+ example={"story_id": 42, "peer": 4242, "rtmp_stream": True, "rtmp_url": "rtmps://…"},
3024
+ example_args="story live start --rtmp",
3025
+ covers=("livestory.start-rtmp", "stories.live-start"),
3026
+ )
3027
+
3028
+
3029
+ # ---------------------------------------------------------------------------
3030
+ # story export
3031
+ # ---------------------------------------------------------------------------
3032
+
3033
+
3034
+ class ExportReq(Request):
3035
+ chat: Annotated[
3036
+ PeerRef | None, arg(0, metavar="CHAT", required=False, kind="peer", help="Whose stories.")
3037
+ ] = None
3038
+ out: Annotated[str, opt("--out", metavar="DIR", kind="path", help="Output directory.")] = "."
3039
+ with_media: Annotated[
3040
+ bool, opt("--with-media/--no-media", help="Download each story's photo or video.")
3041
+ ] = True
3042
+ jsonl: Annotated[bool, opt("--jsonl", help="Also write one JSON object per story.")] = False
3043
+ archive: Annotated[
3044
+ bool, opt("--archive/--profile", help="Walk the private archive, not the profile page.")
3045
+ ] = True
3046
+ max_stories: Annotated[
3047
+ int, opt("--max-stories", metavar="N", help="Stop after this many stories.", ge=1)
3048
+ ] = 1000
3049
+
3050
+
3051
+ async def export(ctx: OpContext, req: ExportReq) -> StoryExport:
3052
+ """Bulk-export stories with their media to disk.
3053
+
3054
+ The "Export Telegram data → Stories" equivalent, and a thing the GUI has
3055
+ no button for. File references expire, so each story's media is downloaded
3056
+ from the item that was just fetched rather than from a cached listing.
3057
+ """
3058
+ import msgspec
3059
+ from telethon.tl.functions import stories as fn
3060
+
3061
+ peer = await _story.resolve_or_self(ctx, req.chat)
3062
+ peer_id = await _story.peer_id_for(ctx, peer)
3063
+ directory = Path(os.path.expanduser(req.out))
3064
+ directory.mkdir(parents=True, exist_ok=True)
3065
+
3066
+ collected: list[Story] = []
3067
+ files: list[str] = []
3068
+ offset = 0
3069
+ while len(collected) < req.max_stories:
3070
+ page_size = min(100, req.max_stories - len(collected))
3071
+ request = (
3072
+ fn.GetStoriesArchiveRequest(peer=peer, offset_id=offset, limit=page_size)
3073
+ if req.archive
3074
+ else fn.GetPinnedStoriesRequest(peer=peer, offset_id=offset, limit=page_size)
3075
+ )
3076
+ result = await client(ctx)(request)
3077
+ raw = list(getattr(result, "stories", None) or [])
3078
+ if not raw:
3079
+ break
3080
+ for item in raw:
3081
+ story = _story.story_model(item, peer_id=peer_id)
3082
+ collected.append(story)
3083
+ if req.with_media and not story.deleted and not story.skipped:
3084
+ path = await _export_media(ctx, item, directory, story.id)
3085
+ if path:
3086
+ files.append(path)
3087
+ offset = int(getattr(raw[-1], "id", 0) or 0)
3088
+ limiter = getattr(ctx, "limiter", None)
3089
+ if limiter is not None:
3090
+ await limiter.acquire("bulk")
3091
+
3092
+ if req.jsonl:
3093
+ target = directory / f"stories-{peer_id}.jsonl"
3094
+ target.write_bytes(b"\n".join(msgspec.json.encode(item) for item in collected) + b"\n")
3095
+ files.append(str(target))
3096
+ return StoryExport(count=len(collected), out_dir=str(directory), files=files, stories=collected)
3097
+
3098
+
3099
+ async def _export_media(ctx: OpContext, item: Any, directory: Path, story_id: int) -> str | None:
3100
+ from tlgr.ops import _media
3101
+
3102
+ media = getattr(item, "media", None)
3103
+ document = _media.document_of(media) or _media.photo_of(media)
3104
+ if document is None:
3105
+ return None
3106
+ download = getattr(ctx, "download_file", None)
3107
+ if download is None: # pragma: no cover - the daemon always supplies one
3108
+ return None
3109
+ target = directory / f"story_{story_id}"
3110
+ path = await download(
3111
+ document,
3112
+ target,
3113
+ size=int(getattr(document, "size", 0) or 0),
3114
+ dc_id=int(getattr(document, "dc_id", 0) or 0),
3115
+ )
3116
+ return str(path)
3117
+
3118
+
3119
+ SPEC_EXPORT = OperationSpec(
3120
+ id="story.export",
3121
+ request=ExportReq,
3122
+ response=StoryExport,
3123
+ impl=export,
3124
+ summary="Bulk-export stories with their media to disk",
3125
+ mutating=False,
3126
+ rate_class="bulk",
3127
+ timeout_s=900,
3128
+ columns=("count", "out_dir"),
3129
+ example={"count": 12, "out_dir": "./stories", "files": ["./stories/story_42"]},
3130
+ example_args="story export me --out ./stories",
3131
+ covers=("stories.export-stories",),
3132
+ )
3133
+
3134
+
3135
+ # ---------------------------------------------------------------------------
3136
+ # story watch
3137
+ # ---------------------------------------------------------------------------
3138
+
3139
+
3140
+ class WatchReq(Request):
3141
+ peer: Annotated[
3142
+ list[PeerRef],
3143
+ opt("--peer", metavar="PEER", kind="peer", help="Only events for these peers."),
3144
+ ] = []
3145
+ since: Annotated[
3146
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Replay from this point.")
3147
+ ] = None
3148
+
3149
+
3150
+ async def watch(ctx: OpContext, req: WatchReq) -> Any:
3151
+ """Stream story events off the daemon's update bus.
3152
+
3153
+ A domain-scoped view of the one bus, not a second update loop: `watch
3154
+ --events story` in the daemon group emits the same records with the same
3155
+ field names, because both read the same normalised event.
3156
+ """
3157
+ bus = getattr(ctx, "bus", None)
3158
+ if bus is None:
3159
+ raise NotSupportedError("this build has no event bus to watch")
3160
+
3161
+ chats = [_send.peer_id_of(await _send.resolve(ctx, ref)) for ref in req.peer]
3162
+ subscriber = bus.subscribe(
3163
+ ctx.account,
3164
+ types=("story_new", "story_id", "story_read", "story_reaction", "story_stealth"),
3165
+ chats=chats,
3166
+ )
3167
+ try:
3168
+ while True:
3169
+ event = await subscriber.queue.get()
3170
+ frame = _story_event(event)
3171
+ if frame is None:
3172
+ continue
3173
+ yield Page(items=[frame], has_more=True)
3174
+ finally:
3175
+ bus.unsubscribe(subscriber)
3176
+
3177
+
3178
+ def _story_event(event: Any) -> StoryEvent | None:
3179
+ payload = getattr(event, "payload", None) or {}
3180
+ kind = str(payload.get("kind") or "")
3181
+ if not kind:
3182
+ return None
3183
+ stealth = payload.get("stealth_mode")
3184
+ return StoryEvent(
3185
+ kind=kind,
3186
+ peer=int(payload.get("peer") or getattr(event, "chat_id", 0) or 0),
3187
+ story_id=payload.get("story_id"),
3188
+ ids=[int(i) for i in (payload.get("ids") or [])],
3189
+ reaction=payload.get("reaction"),
3190
+ max_read_id=payload.get("max_read_id"),
3191
+ stealth_mode=StealthMode(**stealth) if isinstance(stealth, dict) else None,
3192
+ at=str(getattr(event, "ts", "") or ""),
3193
+ )
3194
+
3195
+
3196
+ SPEC_WATCH = OperationSpec(
3197
+ id="story.watch",
3198
+ request=WatchReq,
3199
+ response=Page[StoryEvent],
3200
+ impl=watch,
3201
+ summary="Stream story events (new stories, reads, reactions, stealth changes)",
3202
+ description=(
3203
+ "Event kinds: story.new, story.id-assigned, story.read, "
3204
+ "story.reaction-received, story.reaction-sent, story.stealth."
3205
+ ),
3206
+ stream=True,
3207
+ timeout_s=900,
3208
+ columns=("kind", "peer", "story_id"),
3209
+ headers=("Kind", "Peer", "Story"),
3210
+ example={
3211
+ "items": [{"event": "story", "kind": "story.new", "peer": 4242, "story_id": 42}],
3212
+ "has_more": True,
3213
+ },
3214
+ example_args="story watch --peer @alice",
3215
+ covers=("stories.new-story-events",),
3216
+ )