tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/_story.py ADDED
@@ -0,0 +1,743 @@
1
+ """The story plumbing: items, audiences, overlays and feed state.
2
+
3
+ Four things in this group are genuinely fiddly, and every one of them is here
4
+ rather than in `ops/story.py` so that `post`, `edit` and `get` cannot disagree
5
+ about them.
6
+
7
+ * **Privacy rules are an ordered vector**, not a value. Telegram applies
8
+ `[base, allow…, disallow…]` in order, so building it in one place is what
9
+ makes "contacts, except Bob" mean the same thing on `story post` and
10
+ `story edit`.
11
+ * **Media areas round-trip.** `story get --areas-out` writes exactly the JSON
12
+ `--areas` reads back, which means the model → TL direction has to rebuild
13
+ every variant the TL → model direction can produce.
14
+ * **Story items come in three shapes** for one id (`storyItem`,
15
+ `storyItemSkipped`, `storyItemDeleted`), and a caller must be able to tell
16
+ a placeholder from a story with no caption.
17
+ * **The feed is not offset-paginated.** `stories.getAllStories` takes an
18
+ opaque `state` plus a `next` flag, so the cursor carries both and a naive
19
+ offset cursor would silently restart the walk.
20
+
21
+ Telethon is imported inside functions, never at module scope (§2.2).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from tlgr.core.errors import UsageError
31
+ from tlgr.core.timefmt import fmt_dt, to_unix
32
+ from tlgr.models.peer import PeerRef, parse_peer_ref
33
+ from tlgr.models.story import (
34
+ MediaArea,
35
+ StealthMode,
36
+ Story,
37
+ StoryAlbum,
38
+ StoryFwdHeader,
39
+ StoryPrivacy,
40
+ StoryViews,
41
+ )
42
+ from tlgr.ops._common import client
43
+ from tlgr.ops._serialize import entity_to_peer, media_summary, message_entities, peer_id_of
44
+
45
+ __all__ = [
46
+ "album_model",
47
+ "areas_from_json",
48
+ "build_areas",
49
+ "media_area_model",
50
+ "privacy_model",
51
+ "privacy_rules",
52
+ "stealth_model",
53
+ "story_ids",
54
+ "story_model",
55
+ "views_model",
56
+ ]
57
+
58
+ #: `--period` → the `period` seconds `stories.sendStory` wants.
59
+ PERIODS: dict[str, int] = {"6h": 6 * 3600, "12h": 12 * 3600, "24h": 86400, "48h": 48 * 3600}
60
+
61
+
62
+ def story_ids(values: Any) -> list[int]:
63
+ """Story ids from the CLI, accepting `10-14` ranges like message ids do."""
64
+ from tlgr.ops._common import ids
65
+
66
+ return ids(tuple(str(v) for v in (values or ())))
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Privacy
71
+ # ---------------------------------------------------------------------------
72
+
73
+ _BASE_RULES = ("everyone", "contacts", "close-friends", "selected")
74
+
75
+
76
+ def _split_target(text: str) -> tuple[str, str]:
77
+ """`chat:@team` → `("chat", "@team")`; anything else is a user."""
78
+ if text.startswith("chat:"):
79
+ return "chat", text[len("chat:") :]
80
+ return "user", text
81
+
82
+
83
+ async def privacy_rules(
84
+ ctx: Any,
85
+ *,
86
+ base: str,
87
+ allow: list[str] | tuple[str, ...] = (),
88
+ exclude: list[str] | tuple[str, ...] = (),
89
+ preset: str | None = None,
90
+ ) -> list[Any]:
91
+ """The `InputPrivacyRule` vector for an audience, in server order.
92
+
93
+ Order is load-bearing: Telegram evaluates the vector front to back, so the
94
+ base rule has to come first and the disallow entries last. Emitting them
95
+ in the order the flags happened to be typed is how "contacts, except Bob"
96
+ becomes "everyone".
97
+ """
98
+ from telethon.tl import types
99
+
100
+ from tlgr.ops import _send
101
+
102
+ if preset:
103
+ base, allow, exclude = _preset(ctx, preset, base, list(allow), list(exclude))
104
+
105
+ if base not in _BASE_RULES:
106
+ raise UsageError(
107
+ f"--privacy: expected one of {', '.join(_BASE_RULES)}, not {base!r}", field="privacy"
108
+ )
109
+
110
+ rules: list[Any] = []
111
+ if base == "everyone":
112
+ rules.append(types.InputPrivacyValueAllowAll())
113
+ elif base == "contacts":
114
+ rules.append(types.InputPrivacyValueAllowContacts())
115
+ elif base == "close-friends":
116
+ rules.append(types.InputPrivacyValueAllowCloseFriends())
117
+
118
+ async def targets(values: Any) -> tuple[list[Any], list[int]]:
119
+ users: list[Any] = []
120
+ chats: list[int] = []
121
+ for raw in values or ():
122
+ kind, reference = _split_target(str(raw))
123
+ peer = await _send.resolve(ctx, parse_peer_ref(reference))
124
+ if kind == "chat":
125
+ chats.append(abs(_send.peer_id_of(peer)) % 1_000_000_000_000)
126
+ else:
127
+ from telethon import utils
128
+
129
+ try:
130
+ users.append(utils.get_input_user(peer))
131
+ except (TypeError, ValueError) as exc:
132
+ raise UsageError(
133
+ f"{reference!r} is not a user; use chat:{reference} for a group",
134
+ field="allow",
135
+ ) from exc
136
+ return users, chats
137
+
138
+ allow_users, allow_chats = await targets(allow)
139
+ if allow_users:
140
+ rules.append(types.InputPrivacyValueAllowUsers(users=allow_users))
141
+ if allow_chats:
142
+ rules.append(types.InputPrivacyValueAllowChatParticipants(chats=allow_chats))
143
+
144
+ deny_users, deny_chats = await targets(exclude)
145
+ if deny_users:
146
+ rules.append(types.InputPrivacyValueDisallowUsers(users=deny_users))
147
+ if deny_chats:
148
+ rules.append(types.InputPrivacyValueDisallowChatParticipants(chats=deny_chats))
149
+
150
+ if base == "selected" and not allow_users and not allow_chats:
151
+ raise UsageError(
152
+ "--privacy selected needs at least one --allow, or the story is visible to nobody",
153
+ field="allow",
154
+ )
155
+ return rules
156
+
157
+
158
+ def _preset(
159
+ ctx: Any, name: str, base: str, allow: list[str], exclude: list[str]
160
+ ) -> tuple[str, list[str], list[str]]:
161
+ """A named audience from `[story.privacy_presets]` in the config.
162
+
163
+ Presets exist because the same eight-person allow list is retyped on every
164
+ post otherwise, and a mistyped one is a privacy incident rather than a
165
+ typo.
166
+ """
167
+ config = getattr(ctx, "config", None)
168
+ table: dict[str, Any] = {}
169
+ if config is not None:
170
+ section = getattr(config, "story", None) or {}
171
+ if isinstance(section, dict):
172
+ table = section.get("privacy_presets") or {}
173
+ else:
174
+ table = getattr(section, "privacy_presets", None) or {}
175
+ preset = table.get(name) if isinstance(table, dict) else None
176
+ if preset is None:
177
+ raise UsageError(
178
+ f"--privacy-preset: no preset named {name!r} in [story.privacy_presets]",
179
+ field="privacy_preset",
180
+ )
181
+ if isinstance(preset, str):
182
+ return preset, allow, exclude
183
+ return (
184
+ str(preset.get("base") or base),
185
+ [*preset.get("allow", []), *allow],
186
+ [*preset.get("exclude", []), *exclude],
187
+ )
188
+
189
+
190
+ def privacy_model(rules: Any) -> StoryPrivacy | None:
191
+ """A `privacyValue*` vector as the model. Only your own stories carry one."""
192
+ if not rules:
193
+ return None
194
+ out = StoryPrivacy()
195
+ for rule in rules:
196
+ name = type(rule).__name__
197
+ if name == "PrivacyValueAllowAll":
198
+ out.base = "everyone"
199
+ elif name == "PrivacyValueAllowContacts":
200
+ out.base = "contacts"
201
+ elif name == "PrivacyValueAllowCloseFriends":
202
+ out.base = "close-friends"
203
+ elif name == "PrivacyValueAllowUsers":
204
+ out.allow_users.extend(int(u) for u in (getattr(rule, "users", None) or []))
205
+ elif name == "PrivacyValueAllowChatParticipants":
206
+ out.allow_chats.extend(int(c) for c in (getattr(rule, "chats", None) or []))
207
+ elif name == "PrivacyValueDisallowUsers":
208
+ out.disallow_users.extend(int(u) for u in (getattr(rule, "users", None) or []))
209
+ elif name == "PrivacyValueDisallowChatParticipants":
210
+ out.disallow_chats.extend(int(c) for c in (getattr(rule, "chats", None) or []))
211
+ # No base rule came back, so the audience *is* the allow list.
212
+ has_base = any(
213
+ type(rule).__name__
214
+ in ("PrivacyValueAllowAll", "PrivacyValueAllowContacts", "PrivacyValueAllowCloseFriends")
215
+ for rule in rules
216
+ )
217
+ if not has_base and (out.allow_users or out.allow_chats):
218
+ out.base = "selected"
219
+ return out
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Media areas
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ def media_area_model(raw: Any) -> MediaArea:
228
+ """One TL media area as the flat, round-trippable model."""
229
+ coordinates = getattr(raw, "coordinates", None)
230
+ area = MediaArea(
231
+ x=float(getattr(coordinates, "x", 0.0) or 0.0),
232
+ y=float(getattr(coordinates, "y", 0.0) or 0.0),
233
+ w=float(getattr(coordinates, "w", 0.0) or 0.0),
234
+ h=float(getattr(coordinates, "h", 0.0) or 0.0),
235
+ rotation=float(getattr(coordinates, "rotation", 0.0) or 0.0),
236
+ radius=getattr(coordinates, "radius", None),
237
+ )
238
+ name = type(raw).__name__
239
+ geo = getattr(raw, "geo", None)
240
+ if geo is not None:
241
+ area.latitude = getattr(geo, "lat", None)
242
+ area.longitude = getattr(geo, "long", None)
243
+ if name == "MediaAreaGeoPoint":
244
+ area.type = "geo"
245
+ address = getattr(raw, "address", None)
246
+ if address is not None:
247
+ area.address = {
248
+ key: str(value)
249
+ for key, value in (
250
+ ("country_iso2", getattr(address, "country_iso2", None)),
251
+ ("state", getattr(address, "state", None)),
252
+ ("city", getattr(address, "city", None)),
253
+ ("street", getattr(address, "street", None)),
254
+ )
255
+ if value
256
+ }
257
+ elif name == "MediaAreaVenue":
258
+ area.type = "venue"
259
+ area.title = getattr(raw, "title", None)
260
+ area.address_text = getattr(raw, "address", None)
261
+ area.provider = getattr(raw, "provider", None)
262
+ area.venue_id = getattr(raw, "venue_id", None)
263
+ area.venue_type = getattr(raw, "venue_type", None)
264
+ elif name == "MediaAreaSuggestedReaction":
265
+ from tlgr.ops.reaction import name_of
266
+
267
+ area.type = "reaction"
268
+ area.reaction = name_of(getattr(raw, "reaction", None))
269
+ area.dark = bool(getattr(raw, "dark", False))
270
+ area.flipped = bool(getattr(raw, "flipped", False))
271
+ elif name == "MediaAreaChannelPost":
272
+ from tlgr.ops._serialize import marked_id
273
+
274
+ area.type = "channel_post"
275
+ area.chat_id = marked_id(int(getattr(raw, "channel_id", 0) or 0), "channel")
276
+ area.msg_id = getattr(raw, "msg_id", None)
277
+ elif name == "MediaAreaUrl":
278
+ area.type = "url"
279
+ area.url = getattr(raw, "url", None)
280
+ elif name == "MediaAreaWeather":
281
+ area.type = "weather"
282
+ area.emoji = getattr(raw, "emoji", None)
283
+ area.temperature_c = getattr(raw, "temperature_c", None)
284
+ area.color = getattr(raw, "color", None)
285
+ elif name == "MediaAreaStarGift":
286
+ area.type = "star_gift"
287
+ area.slug = getattr(raw, "slug", None)
288
+ return area
289
+
290
+
291
+ def _coordinates(area: MediaArea) -> Any:
292
+ from telethon.tl import types
293
+
294
+ return types.MediaAreaCoordinates(
295
+ x=area.x, y=area.y, w=area.w, h=area.h, rotation=area.rotation, radius=area.radius
296
+ )
297
+
298
+
299
+ async def _area_to_tl(ctx: Any, area: MediaArea) -> Any:
300
+ """The model back into a TL media area, resolving what has to be resolved."""
301
+ from telethon.tl import types
302
+
303
+ from tlgr.ops import _send
304
+
305
+ coordinates = _coordinates(area)
306
+ if area.type == "geo":
307
+ address = None
308
+ if area.address:
309
+ address = types.GeoPointAddress(
310
+ country_iso2=str(area.address.get("country_iso2", "")),
311
+ state=area.address.get("state"),
312
+ city=area.address.get("city"),
313
+ street=area.address.get("street"),
314
+ )
315
+ return types.MediaAreaGeoPoint(
316
+ coordinates=coordinates,
317
+ geo=types.GeoPoint(
318
+ long=float(area.longitude or 0.0), lat=float(area.latitude or 0.0), access_hash=0
319
+ ),
320
+ address=address,
321
+ )
322
+ if area.type == "venue":
323
+ return types.MediaAreaVenue(
324
+ coordinates=coordinates,
325
+ geo=types.GeoPoint(
326
+ long=float(area.longitude or 0.0), lat=float(area.latitude or 0.0), access_hash=0
327
+ ),
328
+ title=str(area.title or ""),
329
+ address=str(area.address_text or ""),
330
+ provider=str(area.provider or ""),
331
+ venue_id=str(area.venue_id or ""),
332
+ venue_type=str(area.venue_type or ""),
333
+ )
334
+ if area.type == "reaction":
335
+ from tlgr.ops.reaction import to_tl
336
+
337
+ return types.MediaAreaSuggestedReaction(
338
+ coordinates=coordinates,
339
+ reaction=to_tl(str(area.reaction or "")),
340
+ dark=area.dark or None,
341
+ flipped=area.flipped or None,
342
+ )
343
+ if area.type == "channel_post":
344
+ from tlgr.ops._common import input_channel
345
+
346
+ peer = await _send.resolve(ctx, parse_peer_ref(str(area.chat_id)))
347
+ return types.InputMediaAreaChannelPost(
348
+ coordinates=coordinates, channel=input_channel(peer), msg_id=int(area.msg_id or 0)
349
+ )
350
+ if area.type == "url":
351
+ return types.MediaAreaUrl(coordinates=coordinates, url=str(area.url or ""))
352
+ if area.type == "weather":
353
+ return types.MediaAreaWeather(
354
+ coordinates=coordinates,
355
+ emoji=str(area.emoji or ""),
356
+ temperature_c=float(area.temperature_c or 0.0),
357
+ color=int(area.color or 0),
358
+ )
359
+ if area.type == "star_gift":
360
+ return types.MediaAreaStarGift(coordinates=coordinates, slug=str(area.slug or ""))
361
+ raise UsageError(f"unknown media area type {area.type!r}", field="areas")
362
+
363
+
364
+ def _rect(text: str, flag: str) -> tuple[float, float, float, float, float, float | None]:
365
+ """`X,Y,W,H[,ROT[,RADIUS]]` as floats. Percentages of the media, not pixels."""
366
+ parts = [p.strip() for p in text.split(",") if p.strip() != ""]
367
+ if len(parts) < 4:
368
+ raise UsageError(f"{flag}: the position must be X,Y,W,H", field=flag.lstrip("-"))
369
+ try:
370
+ numbers = [float(p) for p in parts]
371
+ except ValueError as exc:
372
+ raise UsageError(
373
+ f"{flag}: {text!r} is not a X,Y,W,H rectangle", field=flag.lstrip("-")
374
+ ) from exc
375
+ x, y, w, h = numbers[:4]
376
+ rotation = numbers[4] if len(numbers) > 4 else 0.0
377
+ radius = numbers[5] if len(numbers) > 5 else None
378
+ return x, y, w, h, rotation, radius
379
+
380
+
381
+ def _split_spec(value: str, flag: str) -> tuple[str, str]:
382
+ """`payload@X,Y,W,H` → `(payload, rect)`; the last `@` wins."""
383
+ payload, sep, rect = value.rpartition("@")
384
+ if not sep:
385
+ raise UsageError(
386
+ f"{flag}: expected PAYLOAD@X,Y,W,H — got {value!r}", field=flag.lstrip("-")
387
+ )
388
+ return payload, rect
389
+
390
+
391
+ def _area(flag: str, value: str, **fields: Any) -> tuple[MediaArea, str]:
392
+ payload, rect = _split_spec(value, flag)
393
+ x, y, w, h, rotation, radius = _rect(rect, flag)
394
+ return MediaArea(x=x, y=y, w=w, h=h, rotation=rotation, radius=radius, **fields), payload
395
+
396
+
397
+ async def build_areas(
398
+ ctx: Any,
399
+ *,
400
+ areas_file: str | None = None,
401
+ geo: tuple[str, ...] = (),
402
+ venue: tuple[str, ...] = (),
403
+ venue_near: str | None = None,
404
+ venue_pick: int = 0,
405
+ url: tuple[str, ...] = (),
406
+ reaction: tuple[str, ...] = (),
407
+ post: tuple[str, ...] = (),
408
+ weather: tuple[str, ...] = (),
409
+ gift: tuple[str, ...] = (),
410
+ ) -> tuple[list[Any], list[MediaArea]]:
411
+ """`(TL areas, the models that describe them)`.
412
+
413
+ `--areas` is the authoritative form because it is what `--areas-out`
414
+ writes; the `--area-*` flags are sugar that produces the same models, so a
415
+ story can be repositioned by editing the JSON rather than by retyping
416
+ every pill.
417
+ """
418
+ models: list[MediaArea] = []
419
+
420
+ if areas_file:
421
+ models.extend(areas_from_json(areas_file))
422
+
423
+ for value in geo:
424
+ area, payload = _area("--area-geo", value, type="geo")
425
+ parts = [p.strip() for p in payload.split(",")]
426
+ if len(parts) < 2:
427
+ raise UsageError("--area-geo: expected LAT,LON[,ADDR]@X,Y,W,H", field="area_geo")
428
+ area.latitude, area.longitude = float(parts[0]), float(parts[1])
429
+ if len(parts) > 2:
430
+ keys = ("country_iso2", "state", "city", "street")
431
+ area.address = {k: v for k, v in zip(keys, parts[2:], strict=False) if v}
432
+ models.append(area)
433
+
434
+ for value in url:
435
+ area, payload = _area("--area-url", value, type="url")
436
+ area.url = payload
437
+ models.append(area)
438
+
439
+ for value in reaction:
440
+ area, payload = _area("--area-reaction", value, type="reaction")
441
+ emoji, *modifiers = payload.split(":")
442
+ area.reaction = emoji
443
+ area.dark = "dark" in modifiers
444
+ area.flipped = "flipped" in modifiers
445
+ models.append(area)
446
+
447
+ for value in post:
448
+ area, payload = _area("--area-post", value, type="channel_post")
449
+ chat, _, msg_id = payload.rpartition(":")
450
+ if not chat or not msg_id.isdigit():
451
+ raise UsageError("--area-post: expected CHAT:MSG_ID@X,Y,W,H", field="area_post")
452
+ from tlgr.ops import _send
453
+
454
+ peer = await _send.resolve(ctx, parse_peer_ref(chat))
455
+ area.chat_id = _send.peer_id_of(peer)
456
+ area.msg_id = int(msg_id)
457
+ models.append(area)
458
+
459
+ for value in gift:
460
+ area, payload = _area("--area-gift", value, type="star_gift")
461
+ area.slug = payload
462
+ models.append(area)
463
+
464
+ for value in weather:
465
+ area, payload = _area("--area-weather", value, type="weather")
466
+ if payload.strip().lower() == "auto":
467
+ models.append(await _resolve_weather(ctx, area, venue_near))
468
+ continue
469
+ parts = [p.strip() for p in payload.split(",")]
470
+ if len(parts) < 3:
471
+ raise UsageError(
472
+ "--area-weather: expected EMOJI,TEMP_C,#AARRGGBB@X,Y,W,H", field="area_weather"
473
+ )
474
+ area.emoji = parts[0]
475
+ area.temperature_c = float(parts[1])
476
+ area.color = int(parts[2].lstrip("#"), 16)
477
+ models.append(area)
478
+
479
+ for value in venue:
480
+ area, payload = _area("--area-venue", value, type="venue")
481
+ models.append(await _resolve_venue(ctx, area, payload, venue_near, venue_pick))
482
+
483
+ return [await _area_to_tl(ctx, area) for area in models], models
484
+
485
+
486
+ def areas_from_json(path_or_text: str) -> list[MediaArea]:
487
+ """Media areas from the JSON `story get --areas-out` writes.
488
+
489
+ A path is read; anything that starts with `[` is taken as inline JSON, so
490
+ a script can pipe the array in without a temp file.
491
+ """
492
+ import msgspec
493
+
494
+ text = path_or_text.strip()
495
+ if not text.startswith("["):
496
+ try:
497
+ text = Path(text).expanduser().read_text(encoding="utf-8")
498
+ except OSError as exc:
499
+ raise UsageError(f"--areas: {exc.strerror or exc}", field="areas") from exc
500
+ try:
501
+ return msgspec.json.decode(text.encode(), type=list[MediaArea])
502
+ except (msgspec.DecodeError, msgspec.ValidationError, json.JSONDecodeError) as exc:
503
+ raise UsageError(f"--areas: {exc}", field="areas") from exc
504
+
505
+
506
+ async def _inline_query(ctx: Any, username: str, query: str, near: str | None) -> Any:
507
+ """One inline-bot query, which is how venue and weather areas are built.
508
+
509
+ Neither is an API method: the official clients run an inline query against
510
+ a bot named in the server config and use the result's `query_id`, so tlgr
511
+ does the same rather than inventing a venue id the server will reject.
512
+ """
513
+ from telethon.tl import types
514
+ from telethon.tl.functions import messages as fn
515
+
516
+ point = None
517
+ if near:
518
+ try:
519
+ lat, _, lon = near.partition(",")
520
+ point = types.InputGeoPoint(lat=float(lat), long=float(lon))
521
+ except ValueError as exc:
522
+ raise UsageError(
523
+ "--area-venue-near: expected LAT,LON", field="area_venue_near"
524
+ ) from exc
525
+ bot = await client(ctx).get_input_entity(username)
526
+ return await client(ctx)(
527
+ fn.GetInlineBotResultsRequest(
528
+ bot=bot, peer=types.InputPeerSelf(), query=query, offset="", geo_point=point
529
+ )
530
+ )
531
+
532
+
533
+ async def _config_username(ctx: Any, field: str) -> str:
534
+ """The inline bot the server names for venue or weather lookups.
535
+
536
+ Venue search is in `help.getConfig`; the weather bot arrived later and
537
+ lives in `help.getAppConfig`, so both are consulted rather than assuming
538
+ one of them.
539
+ """
540
+ from telethon.tl.functions import help as help_fn
541
+
542
+ from tlgr.core.errors import NotSupportedError
543
+ from tlgr.ops import _media
544
+
545
+ config = await client(ctx)(help_fn.GetConfigRequest())
546
+ username = getattr(config, field, None)
547
+ if not username:
548
+ username = (await _media.app_config(ctx)).get(field)
549
+ if not username:
550
+ raise NotSupportedError(
551
+ f"this account's server config names no {field}, so there is nowhere to ask"
552
+ )
553
+ return str(username)
554
+
555
+
556
+ async def _resolve_venue(
557
+ ctx: Any, area: MediaArea, query: str, near: str | None, pick: int
558
+ ) -> MediaArea:
559
+ username = await _config_username(ctx, "venue_search_username")
560
+ result = await _inline_query(ctx, username, query, near)
561
+ rows = [
562
+ row
563
+ for row in (getattr(result, "results", None) or [])
564
+ if type(getattr(row, "send_message", None)).__name__ == "BotInlineMessageMediaVenue"
565
+ ]
566
+ if not rows:
567
+ raise UsageError(f"--area-venue: no venue matched {query!r}", field="area_venue")
568
+ if pick >= len(rows):
569
+ raise UsageError(
570
+ f"--area-venue-pick {pick}: only {len(rows)} venues matched", field="area_venue_pick"
571
+ )
572
+ message = rows[pick].send_message
573
+ geo = getattr(message, "geo", None)
574
+ area.title = getattr(message, "title", None)
575
+ area.address_text = getattr(message, "address", None)
576
+ area.provider = getattr(message, "provider", None)
577
+ area.venue_id = getattr(message, "venue_id", None)
578
+ area.venue_type = getattr(message, "venue_type", None)
579
+ area.latitude = getattr(geo, "lat", None)
580
+ area.longitude = getattr(geo, "long", None)
581
+ return area
582
+
583
+
584
+ async def _resolve_weather(ctx: Any, area: MediaArea, near: str | None) -> MediaArea:
585
+ username = await _config_username(ctx, "weather_search_username")
586
+ result = await _inline_query(ctx, username, "", near)
587
+ rows = list(getattr(result, "results", None) or [])
588
+ if not rows:
589
+ raise UsageError(
590
+ "--area-weather auto: the weather bot returned nothing for that point",
591
+ field="area_weather",
592
+ )
593
+ message = getattr(rows[0], "send_message", None)
594
+ area.emoji = getattr(rows[0], "title", None) or "🌡"
595
+ text = str(getattr(message, "message", "") or getattr(rows[0], "description", "") or "")
596
+ digits = "".join(c for c in text if c.isdigit() or c in "-.")
597
+ area.temperature_c = float(digits) if digits else 0.0
598
+ area.color = 0xFF000000
599
+ return area
600
+
601
+
602
+ # ---------------------------------------------------------------------------
603
+ # Items
604
+ # ---------------------------------------------------------------------------
605
+
606
+
607
+ def views_model(raw: Any) -> StoryViews | None:
608
+ if raw is None:
609
+ return None
610
+ from tlgr.ops.reaction import name_of
611
+
612
+ return StoryViews(
613
+ views_count=int(getattr(raw, "views_count", 0) or 0),
614
+ forwards_count=getattr(raw, "forwards_count", None),
615
+ reactions_count=getattr(raw, "reactions_count", None),
616
+ has_viewers=bool(getattr(raw, "has_viewers", False)),
617
+ recent_viewers=[int(v) for v in (getattr(raw, "recent_viewers", None) or [])],
618
+ reactions={
619
+ name_of(getattr(count, "reaction", None)): int(getattr(count, "count", 0) or 0)
620
+ for count in (getattr(raw, "reactions", None) or [])
621
+ },
622
+ )
623
+
624
+
625
+ def _fwd(raw: Any) -> StoryFwdHeader | None:
626
+ if raw is None:
627
+ return None
628
+ return StoryFwdHeader(
629
+ from_id=peer_id_of(getattr(raw, "from_", None)),
630
+ from_name=getattr(raw, "from_name", None),
631
+ story_id=getattr(raw, "story_id", None),
632
+ modified=bool(getattr(raw, "modified", False)),
633
+ )
634
+
635
+
636
+ def story_model(raw: Any, *, peer_id: int = 0, peer: Any = None, link: str | None = None) -> Story:
637
+ """A `storyItem` / `storyItemSkipped` / `storyItemDeleted` as one model."""
638
+ from tlgr.ops.reaction import name_of
639
+
640
+ name = type(raw).__name__
641
+ date = getattr(raw, "date", None)
642
+ expire = getattr(raw, "expire_date", None)
643
+ story = Story(
644
+ id=int(getattr(raw, "id", 0) or 0),
645
+ peer_id=peer_id,
646
+ peer=entity_to_peer(peer) if peer is not None else None,
647
+ date=fmt_dt(date),
648
+ date_unix=to_unix(date),
649
+ expire_date=fmt_dt(expire),
650
+ expire_date_unix=to_unix(expire),
651
+ link=link,
652
+ deleted=name == "StoryItemDeleted",
653
+ skipped=name == "StoryItemSkipped",
654
+ live=bool(getattr(raw, "live", False)),
655
+ close_friends=bool(getattr(raw, "close_friends", False)),
656
+ )
657
+ if story.deleted or story.skipped:
658
+ return story
659
+
660
+ story.caption = str(getattr(raw, "caption", "") or "")
661
+ story.entities = message_entities(raw)
662
+ story.media = media_summary(getattr(raw, "media", None))
663
+ story.media_areas = [media_area_model(a) for a in (getattr(raw, "media_areas", None) or [])]
664
+ story.privacy = privacy_model(getattr(raw, "privacy", None))
665
+ story.public = bool(getattr(raw, "public", False))
666
+ story.contacts = bool(getattr(raw, "contacts", False))
667
+ story.selected_contacts = bool(getattr(raw, "selected_contacts", False))
668
+ story.pinned = bool(getattr(raw, "pinned", False))
669
+ story.noforwards = bool(getattr(raw, "noforwards", False))
670
+ story.edited = bool(getattr(raw, "edited", False))
671
+ story.out = bool(getattr(raw, "out", False))
672
+ story.min = bool(getattr(raw, "min", False))
673
+ story.fwd_from = _fwd(getattr(raw, "fwd_from", None))
674
+ reaction = getattr(raw, "sent_reaction", None)
675
+ story.sent_reaction = name_of(reaction) if reaction is not None else None
676
+ story.albums = [int(a) for a in (getattr(raw, "albums", None) or [])]
677
+ music = getattr(raw, "music", None)
678
+ if music is not None:
679
+ from telethon.tl import types as tl
680
+
681
+ story.music = media_summary(tl.MessageMediaDocument(document=music))
682
+ story.views = views_model(getattr(raw, "views", None))
683
+ return story
684
+
685
+
686
+ def album_model(raw: Any, *, stories: list[int] | None = None) -> StoryAlbum:
687
+ from tlgr.ops._serialize import photo_summary
688
+
689
+ return StoryAlbum(
690
+ id=int(getattr(raw, "album_id", 0) or 0),
691
+ title=str(getattr(raw, "title", "") or ""),
692
+ icon=photo_summary(getattr(raw, "icon_photo", None)),
693
+ stories=list(stories or []),
694
+ )
695
+
696
+
697
+ def stealth_model(raw: Any, *, past: bool = False, future: bool = False) -> StealthMode:
698
+ import time
699
+
700
+ active = getattr(raw, "active_until_date", None)
701
+ cooldown = getattr(raw, "cooldown_until_date", None)
702
+ active_unix = to_unix(active)
703
+ return StealthMode(
704
+ active_until_date=fmt_dt(active),
705
+ active_until_unix=active_unix,
706
+ cooldown_until_date=fmt_dt(cooldown),
707
+ cooldown_until_unix=to_unix(cooldown),
708
+ past=past,
709
+ future=future,
710
+ active=bool(active_unix and active_unix > int(time.time())),
711
+ )
712
+
713
+
714
+ # ---------------------------------------------------------------------------
715
+ # Peers
716
+ # ---------------------------------------------------------------------------
717
+
718
+
719
+ async def resolve_or_self(ctx: Any, ref: PeerRef | None) -> Any:
720
+ """The peer, or this account. Story commands default to your own stories."""
721
+ from telethon.tl import types
722
+
723
+ from tlgr.ops import _send
724
+
725
+ if ref is None:
726
+ return types.InputPeerSelf()
727
+ return await _send.resolve(ctx, ref)
728
+
729
+
730
+ async def peer_id_for(ctx: Any, peer: Any) -> int:
731
+ """The marked id of a resolved peer, resolving `InputPeerSelf` to my own.
732
+
733
+ `utils.get_peer_id` has no answer for `inputPeerSelf` — it is "whoever is
734
+ logged in" — and reporting the story of `peer: 0` would make `story post`
735
+ and `story export` disagree with every other command about what "me" is.
736
+ """
737
+ from tlgr.ops import _send
738
+
739
+ marked = _send.peer_id_of(peer)
740
+ if marked:
741
+ return marked
742
+ me = await client(ctx).get_me()
743
+ return int(getattr(me, "id", 0) or 0)