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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|