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/location.py ADDED
@@ -0,0 +1,917 @@
1
+ """The `location` group: points, venues, live shares, nearby peers, map images.
2
+
3
+ Three things a headless client has to face that a GUI does not.
4
+
5
+ * **Nobody updates a live location for you.** The API stores one position and
6
+ a period; the phone re-sends it every few seconds. `location live start`
7
+ therefore reports `expires_at` rather than a duration, and `--follow`
8
+ refreshes from a source the caller names instead of pretending the share
9
+ moves on its own.
10
+ * **There is no "list my live shares" method.** `messages.getRecentLocations`
11
+ answers per chat, so `location live list` walks the chats it is given and
12
+ says so, rather than inventing a global answer.
13
+ * **A map thumbnail does not live on the home DC.** `upload.getWebFile` has
14
+ to be issued against `config.webfile_dc_id` through a borrowed sender, and
15
+ it needs the `geoPoint.access_hash` off the received message.
16
+
17
+ Telethon is imported inside functions, never at module scope (§2.2).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Annotated, Any
23
+
24
+ from tlgr.core.errors import NotFoundError, NotSupportedError, UsageError
25
+ from tlgr.core.pagination import PageKind, build_page
26
+ from tlgr.core.timefmt import fmt_dt, fmt_unix, parse_duration, to_unix
27
+ from tlgr.models.base import Request
28
+ from tlgr.models.location import (
29
+ GeoPoint,
30
+ LiveLocation,
31
+ LiveStopped,
32
+ MapPreview,
33
+ Nearby,
34
+ NearbyPeer,
35
+ SentLocation,
36
+ Venue,
37
+ )
38
+ from tlgr.models.page import Page
39
+ from tlgr.models.peer import PeerRef
40
+ from tlgr.ops import _send
41
+ from tlgr.ops._common import already, client, only, random_id, window
42
+ from tlgr.ops._params import arg, opt
43
+ from tlgr.ops._serialize import peer_id_of
44
+ from tlgr.ops._spec import OpContext, OperationSpec
45
+
46
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
47
+
48
+ #: The periods the official clients offer. Any value is accepted; these are
49
+ #: only what `--period` defaults and snaps against in the help text.
50
+ GUI_PERIODS = (900, 3600, 28800)
51
+
52
+ _EXAMPLE_LIVE: dict[str, Any] = {
53
+ "id": 12345,
54
+ "chat_id": 777123,
55
+ "geo": {"lat": 52.52, "lon": 13.405},
56
+ "period": 3600,
57
+ "expires_at": "2026-09-03T10:14:07Z",
58
+ "mine": True,
59
+ }
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Points
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _point(lat: float, lon: float, accuracy: int | None = None) -> Any:
68
+ from telethon.tl import types
69
+
70
+ if not -90.0 <= lat <= 90.0 or not -180.0 <= lon <= 180.0:
71
+ raise UsageError(f"{lat},{lon} is not a coordinate on Earth", field="lat")
72
+ return types.InputGeoPoint(lat=float(lat), long=float(lon), accuracy_radius=accuracy)
73
+
74
+
75
+ def geo_model(geo: Any) -> GeoPoint | None:
76
+ """A TL `GeoPoint` → the model, access hash included for the map endpoint."""
77
+ if geo is None or type(geo).__name__ == "GeoPointEmpty":
78
+ return None
79
+ return GeoPoint(
80
+ lat=float(getattr(geo, "lat", 0.0) or 0.0),
81
+ lon=float(getattr(geo, "long", 0.0) or 0.0),
82
+ accuracy=getattr(geo, "accuracy_radius", None),
83
+ access_hash=getattr(geo, "access_hash", None),
84
+ )
85
+
86
+
87
+ def _live_of(message: Any, *, chat_id: int, me: int | None = None) -> LiveLocation | None:
88
+ """A live-location message → the model, or None if it is not one."""
89
+ media = getattr(message, "media", None)
90
+ if type(media).__name__ != "MessageMediaGeoLive":
91
+ return None
92
+ date = getattr(message, "date", None)
93
+ period = int(getattr(media, "period", 0) or 0)
94
+ start = to_unix(date) or 0
95
+ expires = start + period if period else None
96
+ return LiveLocation(
97
+ chat_id=chat_id,
98
+ msg_id=int(getattr(message, "id", 0) or 0),
99
+ id=int(getattr(message, "id", 0) or 0),
100
+ peer_id=peer_id_of(getattr(message, "from_id", None)),
101
+ geo=geo_model(getattr(media, "geo", None)),
102
+ heading=getattr(media, "heading", None),
103
+ proximity=getattr(media, "proximity_notification_radius", None),
104
+ period=period or None,
105
+ expires_at=fmt_unix(expires) if expires else None,
106
+ expires_at_unix=expires,
107
+ stopped=bool(getattr(media, "stopped", False)) or period == 0,
108
+ mine=bool(getattr(message, "out", False)) or (me is not None and me == 0),
109
+ date=fmt_dt(date) or "",
110
+ date_unix=start,
111
+ )
112
+
113
+
114
+ async def _send_media(ctx: OpContext, peer: Any, media: Any, req: Any) -> Any:
115
+ """One `messages.sendMedia` for every "send a place" command."""
116
+ from telethon.tl.functions import messages as fn
117
+
118
+ values = {
119
+ "peer": peer,
120
+ "media": media,
121
+ "message": "",
122
+ "random_id": random_id(),
123
+ "silent": getattr(req, "silent", False) or None,
124
+ "noforwards": getattr(req, "protect", False) or None,
125
+ "reply_to": await _send.reply_target(
126
+ ctx, reply_to=getattr(req, "reply_to", None), topic=getattr(req, "topic", None)
127
+ ),
128
+ "schedule_date": _send.schedule_at(getattr(req, "schedule", None)),
129
+ "send_as": (
130
+ await _send.resolve(ctx, req.send_as) if getattr(req, "send_as", None) else None
131
+ ),
132
+ "effect": _send.effect_id(getattr(req, "effect", None)),
133
+ }
134
+ return await client(ctx)(fn.SendMediaRequest(**only(values, fn.SendMediaRequest)))
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # location send / venue send
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ class SendReq(_send.SendOptions, kw_only=True):
143
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to send it.")]
144
+ lat: Annotated[float, arg(1, metavar="LAT", help="Latitude.")]
145
+ lon: Annotated[float, arg(2, metavar="LON", help="Longitude.")]
146
+ accuracy: Annotated[
147
+ int | None, opt("--accuracy", metavar="METRES", help="accuracy_radius.")
148
+ ] = None
149
+ reply_to: Annotated[
150
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
151
+ ] = None
152
+
153
+
154
+ async def send(ctx: OpContext, req: SendReq) -> SentLocation:
155
+ """Send a static location."""
156
+ from telethon.tl import types
157
+
158
+ peer = await _send.resolve(ctx, req.chat)
159
+ chat_id = _send.peer_id_of(peer)
160
+ media = types.InputMediaGeoPoint(geo_point=_point(req.lat, req.lon, req.accuracy))
161
+ result = await _send_media(ctx, peer, media, req)
162
+ sent = _send.message_from_updates(result, chat_id=chat_id)
163
+ ctx.emit("location_sent", {"chat_id": chat_id, "id": sent.id})
164
+ return SentLocation(
165
+ id=sent.id,
166
+ chat_id=chat_id,
167
+ date=sent.date,
168
+ date_unix=sent.date_unix,
169
+ geo=GeoPoint(lat=req.lat, lon=req.lon, accuracy=req.accuracy),
170
+ )
171
+
172
+
173
+ SPEC_SEND = OperationSpec(
174
+ id="location.send",
175
+ request=SendReq,
176
+ response=SentLocation,
177
+ impl=send,
178
+ summary="Send a static location",
179
+ description="Shares the send-option set with `message send`.",
180
+ mutating=True,
181
+ rate_class="send",
182
+ columns=("id", "chat_id", "geo.lat", "geo.lon"),
183
+ example={
184
+ "id": 12345,
185
+ "chat_id": 777123,
186
+ "date": "2026-09-03T09:14:07Z",
187
+ "date_unix": 1788340447,
188
+ "geo": {"lat": 52.52, "lon": 13.405},
189
+ },
190
+ example_args="location send @alice 52.5200 13.4050",
191
+ tags=frozenset({"visible-to-others"}),
192
+ covers=("location.send-static",),
193
+ )
194
+
195
+
196
+ class VenueSendReq(_send.SendOptions, kw_only=True):
197
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to send it.")]
198
+ lat: Annotated[float, arg(1, metavar="LAT", help="Latitude.")]
199
+ lon: Annotated[float, arg(2, metavar="LON", help="Longitude.")]
200
+ title: Annotated[str, opt("--title", metavar="TEXT", help="Venue name.")] = ""
201
+ address: Annotated[str, opt("--address", metavar="TEXT", help="Street address.")] = ""
202
+ provider: Annotated[str, opt("--provider", metavar="NAME", help="Venue provider.")] = ""
203
+ venue_id: Annotated[str, opt("--venue-id", metavar="ID", help="Provider venue id.")] = ""
204
+ venue_type: Annotated[
205
+ str, opt("--venue-type", metavar="TYPE", help="Provider venue category.")
206
+ ] = ""
207
+ reply_to: Annotated[
208
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
209
+ ] = None
210
+
211
+
212
+ async def venue_send(ctx: OpContext, req: VenueSendReq) -> SentLocation:
213
+ """Send a venue / place.
214
+
215
+ `provider`, `venue_id` and `venue_type` may all be empty: a hand-made
216
+ venue is a title and an address on a point, which is what a CLI user
217
+ usually has. `location search` is where the provider ids come from.
218
+ """
219
+ from telethon.tl import types
220
+
221
+ if not req.title:
222
+ raise UsageError("a venue needs --title", field="title")
223
+ peer = await _send.resolve(ctx, req.chat)
224
+ chat_id = _send.peer_id_of(peer)
225
+ media = types.InputMediaVenue(
226
+ geo_point=_point(req.lat, req.lon),
227
+ title=req.title,
228
+ address=req.address,
229
+ provider=req.provider,
230
+ venue_id=req.venue_id,
231
+ venue_type=req.venue_type,
232
+ )
233
+ result = await _send_media(ctx, peer, media, req)
234
+ sent = _send.message_from_updates(result, chat_id=chat_id)
235
+ return SentLocation(
236
+ id=sent.id,
237
+ chat_id=chat_id,
238
+ date=sent.date,
239
+ date_unix=sent.date_unix,
240
+ venue=Venue(
241
+ title=req.title,
242
+ address=req.address,
243
+ provider=req.provider,
244
+ venue_id=req.venue_id,
245
+ venue_type=req.venue_type,
246
+ geo=GeoPoint(lat=req.lat, lon=req.lon),
247
+ ),
248
+ )
249
+
250
+
251
+ SPEC_VENUE_SEND = OperationSpec(
252
+ id="location.venue.send",
253
+ request=VenueSendReq,
254
+ response=SentLocation,
255
+ impl=venue_send,
256
+ summary="Send a venue / place",
257
+ description="Use `location search` to obtain provider/venue-id pairs.",
258
+ mutating=True,
259
+ rate_class="send",
260
+ columns=("id", "chat_id", "venue.title"),
261
+ example={
262
+ "id": 12345,
263
+ "chat_id": 777123,
264
+ "date": "2026-09-03T09:14:07Z",
265
+ "date_unix": 1788340447,
266
+ "venue": {"title": "Brandenburg Gate", "address": "Pariser Platz"},
267
+ },
268
+ example_args="location venue send @alice 52.5163 13.3777 --title 'Brandenburg Gate'",
269
+ tags=frozenset({"visible-to-others"}),
270
+ covers=("location.send-venue",),
271
+ )
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # location live start / edit / stop / list
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ class LiveStartReq(_send.SendOptions, kw_only=True):
280
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to share it.")]
281
+ lat: Annotated[float, arg(1, metavar="LAT", help="Latitude.")]
282
+ lon: Annotated[float, arg(2, metavar="LON", help="Longitude.")]
283
+ period: Annotated[
284
+ str, opt("--period", metavar="DURATION", help="How long to share (GUI: 15m/1h/8h).")
285
+ ] = "1h"
286
+ heading: Annotated[
287
+ int | None, opt("--heading", metavar="DEGREES", help="Movement direction, 1-360.")
288
+ ] = None
289
+ proximity: Annotated[
290
+ int | None, opt("--proximity", metavar="METRES", help="Proximity-alert radius.")
291
+ ] = None
292
+ accuracy: Annotated[
293
+ int | None, opt("--accuracy", metavar="METRES", help="accuracy_radius.")
294
+ ] = None
295
+ follow: Annotated[
296
+ str | None,
297
+ opt("--follow", metavar="PATH", help="Keep updating from this source (a daemon job)."),
298
+ ] = None
299
+ reply_to: Annotated[
300
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
301
+ ] = None
302
+
303
+
304
+ def _heading(value: int | None) -> int | None:
305
+ if value is None:
306
+ return None
307
+ if not 1 <= value <= 360:
308
+ raise UsageError("--heading is a bearing in degrees, 1 to 360", field="heading")
309
+ return value
310
+
311
+
312
+ async def live_start(ctx: OpContext, req: LiveStartReq) -> LiveLocation:
313
+ """Start sharing a live location.
314
+
315
+ The server stores one position and a period; nothing moves it afterwards.
316
+ `--follow` is refused rather than silently doing nothing, because a share
317
+ that never updates is worse than one the caller knows they must drive.
318
+ """
319
+ from telethon.tl import types
320
+
321
+ if req.follow:
322
+ raise NotSupportedError(
323
+ "--follow needs the daemon job scheduler, which lands with the jobs group; "
324
+ "until then, drive the share with repeated `tlgr location live edit` calls"
325
+ )
326
+ period = int(parse_duration(req.period) or 3600)
327
+ if period < 60:
328
+ raise UsageError("--period must be at least a minute", field="period")
329
+
330
+ peer = await _send.resolve(ctx, req.chat)
331
+ chat_id = _send.peer_id_of(peer)
332
+ media = types.InputMediaGeoLive(
333
+ geo_point=_point(req.lat, req.lon, req.accuracy),
334
+ period=period,
335
+ heading=_heading(req.heading),
336
+ proximity_notification_radius=req.proximity,
337
+ )
338
+ result = await _send_media(ctx, peer, media, req)
339
+ sent = _send.message_from_updates(result, chat_id=chat_id)
340
+ live = LiveLocation(
341
+ chat_id=chat_id,
342
+ msg_id=sent.id,
343
+ id=sent.id,
344
+ geo=GeoPoint(lat=req.lat, lon=req.lon, accuracy=req.accuracy),
345
+ heading=req.heading,
346
+ proximity=req.proximity,
347
+ period=period,
348
+ expires_at=fmt_unix(sent.date_unix + period),
349
+ expires_at_unix=sent.date_unix + period,
350
+ mine=True,
351
+ date=sent.date,
352
+ date_unix=sent.date_unix,
353
+ )
354
+ ctx.emit("location_live_started", {"chat_id": chat_id, "id": sent.id, "period": period})
355
+ return live
356
+
357
+
358
+ SPEC_LIVE_START = OperationSpec(
359
+ id="location.live.start",
360
+ request=LiveStartReq,
361
+ response=LiveLocation,
362
+ impl=live_start,
363
+ summary="Start sharing a live location",
364
+ description=(
365
+ "A headless client has to run its own updater: nothing moves the "
366
+ "share by itself, so `expires_at` is reported rather than a duration."
367
+ ),
368
+ mutating=True,
369
+ rate_class="send",
370
+ columns=("id", "chat_id", "period", "expires_at"),
371
+ example=_EXAMPLE_LIVE,
372
+ example_args="location live start @alice 52.5200 13.4050 --period 1h",
373
+ tags=frozenset({"visible-to-others"}),
374
+ covers=("location.live-send",),
375
+ )
376
+
377
+
378
+ class LiveEditReq(Request):
379
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
380
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The live message.")]
381
+ lat: Annotated[float | None, arg(2, metavar="LAT", required=False, help="Latitude.")] = None
382
+ lon: Annotated[float | None, arg(3, metavar="LON", required=False, help="Longitude.")] = None
383
+ heading: Annotated[int | None, opt("--heading", metavar="DEGREES", help="Direction arrow.")] = (
384
+ None
385
+ )
386
+ proximity: Annotated[
387
+ int | None, opt("--proximity", metavar="METRES", help="Proximity-alert radius.")
388
+ ] = None
389
+ accuracy: Annotated[
390
+ int | None, opt("--accuracy", metavar="METRES", help="accuracy_radius.")
391
+ ] = None
392
+
393
+
394
+ async def live_edit(ctx: OpContext, req: LiveEditReq) -> LiveLocation:
395
+ """Move a live location, or change its heading and proximity radius.
396
+
397
+ Position, heading and proximity are three fields of one edit, so a caller
398
+ that only wants the arrow still resends the position — read back from the
399
+ message rather than guessed.
400
+ """
401
+ from telethon.tl import types
402
+ from telethon.tl.functions import messages as fn
403
+
404
+ peer = await _send.resolve(ctx, req.chat)
405
+ chat_id = _send.peer_id_of(peer)
406
+ message = await client(ctx).get_messages(peer, ids=req.msg_id)
407
+ current = _live_of(message, chat_id=chat_id) if message is not None else None
408
+ if current is None:
409
+ raise NotFoundError(f"message {req.msg_id} in {chat_id} is not a live location")
410
+
411
+ lat = req.lat if req.lat is not None else (current.geo.lat if current.geo else 0.0)
412
+ lon = req.lon if req.lon is not None else (current.geo.lon if current.geo else 0.0)
413
+ media = types.InputMediaGeoLive(
414
+ geo_point=_point(lat, lon, req.accuracy),
415
+ heading=_heading(req.heading) if req.heading is not None else current.heading,
416
+ proximity_notification_radius=(
417
+ req.proximity if req.proximity is not None else current.proximity
418
+ ),
419
+ period=current.period,
420
+ )
421
+ result = await client(ctx)(fn.EditMessageRequest(peer=peer, id=req.msg_id, media=media))
422
+ for update in getattr(result, "updates", None) or []:
423
+ edited = getattr(update, "message", None)
424
+ live = _live_of(edited, chat_id=chat_id) if edited is not None else None
425
+ if live is not None:
426
+ return live
427
+ current.geo = GeoPoint(lat=lat, lon=lon, accuracy=req.accuracy)
428
+ return current
429
+
430
+
431
+ SPEC_LIVE_EDIT = OperationSpec(
432
+ id="location.live.edit",
433
+ request=LiveEditReq,
434
+ response=LiveLocation,
435
+ impl=live_edit,
436
+ summary="Update a live location (position, heading, proximity radius)",
437
+ description=(
438
+ "One edit carries all three; the fields not named are read off the "
439
+ "message and resent unchanged."
440
+ ),
441
+ aliases=("location.live.update",),
442
+ mutating=True,
443
+ rate_class="send",
444
+ columns=("id", "chat_id", "geo.lat", "geo.lon", "heading"),
445
+ example=_EXAMPLE_LIVE,
446
+ example_args="location live edit @alice 12345 52.5210 13.4100",
447
+ covers=("location.live-heading", "location.live-proximity", "location.live-update"),
448
+ )
449
+
450
+
451
+ class LiveStopReq(Request):
452
+ chat: Annotated[
453
+ PeerRef | None, arg(0, metavar="CHAT", required=False, kind="peer", help="Chat.")
454
+ ] = None
455
+ msg_id: Annotated[
456
+ int | None, arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Which share.")
457
+ ] = None
458
+ every: Annotated[bool, opt("--every", help="Stop every live share in that chat.")] = False
459
+
460
+
461
+ async def live_stop(ctx: OpContext, req: LiveStopReq) -> LiveStopped:
462
+ """Stop sharing a live location.
463
+
464
+ Stopping is an edit to `inputMediaGeoLive(stopped=True)` with an empty
465
+ point — the position is deliberately not resent, because the last thing a
466
+ stopped share should do is publish where you were when you stopped.
467
+ """
468
+ from telethon.tl import types
469
+ from telethon.tl.functions import messages as fn
470
+
471
+ if req.chat is None:
472
+ raise UsageError("name the chat whose share should stop", field="chat")
473
+ peer = await _send.resolve(ctx, req.chat)
474
+ chat_id = _send.peer_id_of(peer)
475
+
476
+ targets: list[int] = []
477
+ if req.msg_id is not None:
478
+ targets = [req.msg_id]
479
+ elif req.every:
480
+ targets = [
481
+ live.msg_id for live in await _recent(ctx, peer, chat_id, limit=100) if live.mine
482
+ ]
483
+ else:
484
+ raise UsageError("name a message id, or pass --every", field="msg_id")
485
+
486
+ stopped: list[LiveLocation] = []
487
+ for msg_id in targets:
488
+ result = await client(ctx)(
489
+ fn.EditMessageRequest(
490
+ peer=peer,
491
+ id=msg_id,
492
+ media=types.InputMediaGeoLive(geo_point=types.InputGeoPointEmpty(), stopped=True),
493
+ )
494
+ )
495
+ for update in getattr(result, "updates", None) or []:
496
+ edited = getattr(update, "message", None)
497
+ live = _live_of(edited, chat_id=chat_id) if edited is not None else None
498
+ if live is not None:
499
+ stopped.append(live)
500
+ if not targets:
501
+ already(ctx)
502
+ return LiveStopped(stopped=False, count=0, already=True)
503
+ ctx.emit("location_live_stopped", {"chat_id": chat_id, "ids": targets})
504
+ return LiveStopped(stopped=True, count=len(targets), items=stopped)
505
+
506
+
507
+ SPEC_LIVE_STOP = OperationSpec(
508
+ id="location.live.stop",
509
+ request=LiveStopReq,
510
+ response=LiveStopped,
511
+ impl=live_stop,
512
+ summary="Stop sharing a live location",
513
+ description=(
514
+ "The stop edit carries an empty point on purpose: a stopped share "
515
+ "should not publish where you were when you stopped it."
516
+ ),
517
+ mutating=True,
518
+ idempotent=True,
519
+ rate_class="send",
520
+ columns=("stopped", "count"),
521
+ example={"stopped": True, "count": 1},
522
+ example_args="location live stop @alice 12345",
523
+ covers=("location.live-stop",),
524
+ )
525
+
526
+
527
+ async def _recent(ctx: OpContext, peer: Any, chat_id: int, *, limit: int) -> list[LiveLocation]:
528
+ from telethon.tl.functions import messages as fn
529
+
530
+ result = await client(ctx)(fn.GetRecentLocationsRequest(peer=peer, limit=limit, hash=0))
531
+ out: list[LiveLocation] = []
532
+ for message in getattr(result, "messages", None) or []:
533
+ live = _live_of(message, chat_id=chat_id)
534
+ if live is not None:
535
+ out.append(live)
536
+ return out
537
+
538
+
539
+ class LiveListReq(Request):
540
+ chat: Annotated[
541
+ PeerRef | None, arg(0, metavar="CHAT", required=False, kind="peer", help="Chat.")
542
+ ] = None
543
+ mine: Annotated[bool, opt("--mine", help="Only my own active shares.")] = False
544
+
545
+
546
+ async def live_list(ctx: OpContext, req: LiveListReq) -> Page[LiveLocation]:
547
+ """Live locations shared in a chat — everyone's, or only mine.
548
+
549
+ There is no server method that lists my own shares across chats, so this
550
+ asks per chat and says so rather than pretending to a global answer.
551
+ """
552
+ limit, state = window(ctx, "location.live.list", PageKind.LOCAL)
553
+ if req.chat is None:
554
+ raise UsageError(
555
+ "name a chat: Telegram has no method that lists live shares across chats",
556
+ field="chat",
557
+ )
558
+ peer = await _send.resolve(ctx, req.chat)
559
+ chat_id = _send.peer_id_of(peer)
560
+ rows = await _recent(ctx, peer, chat_id, limit=100)
561
+ if req.mine:
562
+ rows = [row for row in rows if row.mine]
563
+
564
+ offset = int(state.get("offset") or 0)
565
+ page = rows[offset : offset + limit]
566
+ return build_page(
567
+ page,
568
+ op="location.live.list",
569
+ kind=PageKind.LOCAL,
570
+ state={"offset": offset + len(page)},
571
+ account=ctx.account,
572
+ has_more=offset + len(page) < len(rows),
573
+ total=len(rows),
574
+ )
575
+
576
+
577
+ SPEC_LIVE_LIST = OperationSpec(
578
+ id="location.live.list",
579
+ request=LiveListReq,
580
+ response=Page[LiveLocation],
581
+ impl=live_list,
582
+ summary="Live locations shared in a chat",
583
+ description=(
584
+ "`messages.getRecentLocations` answers per chat and there is no "
585
+ "cross-chat method, so a chat is required rather than optional."
586
+ ),
587
+ paginated=PageKind.LOCAL,
588
+ columns=("chat_id", "msg_id", "geo.lat", "geo.lon", "expires_at"),
589
+ example={"items": [_EXAMPLE_LIVE], "has_more": False},
590
+ example_args="location live list @alice",
591
+ covers=("location.live-list-mine", "location.recent-in-chat"),
592
+ coverage_note=(
593
+ "`location.live-list-mine` is answered per chat with `--mine`: there "
594
+ "is no MTProto method that enumerates a user's own live shares."
595
+ ),
596
+ )
597
+
598
+
599
+ # ---------------------------------------------------------------------------
600
+ # location nearby list
601
+ # ---------------------------------------------------------------------------
602
+
603
+
604
+ class NearbyListReq(Request):
605
+ lat: Annotated[float, arg(0, metavar="LAT", help="Latitude.")]
606
+ lon: Annotated[float, arg(1, metavar="LON", help="Longitude.")]
607
+ accuracy: Annotated[
608
+ int | None, opt("--accuracy", metavar="METRES", help="accuracy_radius.")
609
+ ] = None
610
+ publish: Annotated[
611
+ str | None,
612
+ opt("--publish", metavar="DURATION", help="Make myself visible for this long."),
613
+ ] = None
614
+ background: Annotated[
615
+ bool, opt("--background", help="Background refresh, without a visible listing.")
616
+ ] = False
617
+ unpublish: Annotated[bool, opt("--unpublish", help="Stop sharing (self_expires=0).")] = False
618
+
619
+
620
+ async def nearby_list(ctx: OpContext, req: NearbyListReq) -> Nearby:
621
+ """People and groups near a point, and optionally publish my own location.
622
+
623
+ Publishing is a privacy-relevant write, which is why it is opt-in
624
+ (`--publish`) rather than a side effect of looking: `contacts.getLocated`
625
+ with `self_expires` set is what puts you on other people's lists.
626
+ """
627
+ from telethon.tl.functions import contacts as fn
628
+
629
+ self_expires: int | None = None
630
+ if req.unpublish:
631
+ self_expires = 0
632
+ elif req.publish:
633
+ self_expires = int(parse_duration(req.publish) or 3600)
634
+
635
+ result = await client(ctx)(
636
+ fn.GetLocatedRequest(
637
+ geo_point=_point(req.lat, req.lon, req.accuracy),
638
+ background=req.background or None,
639
+ self_expires=self_expires,
640
+ )
641
+ )
642
+ items: list[NearbyPeer] = []
643
+ for update in getattr(result, "updates", None) or []:
644
+ for located in getattr(update, "peers", None) or []:
645
+ if type(located).__name__ == "PeerSelfLocated":
646
+ continue
647
+ peer_id = peer_id_of(getattr(located, "peer", None)) or 0
648
+ expires = getattr(located, "expires", None)
649
+ items.append(
650
+ NearbyPeer(
651
+ peer_id=peer_id,
652
+ kind="user" if peer_id > 0 else "group",
653
+ distance=getattr(located, "distance", None),
654
+ expires=fmt_dt(expires),
655
+ expires_unix=to_unix(expires),
656
+ )
657
+ )
658
+ if self_expires:
659
+ ctx.warn("your location is now visible to people nearby until it expires")
660
+ return Nearby(items=items, self_expires=self_expires, published=bool(self_expires))
661
+
662
+
663
+ SPEC_NEARBY_LIST = OperationSpec(
664
+ id="location.nearby.list",
665
+ request=NearbyListReq,
666
+ response=Nearby,
667
+ impl=nearby_list,
668
+ summary="People and groups near a point",
669
+ description=(
670
+ "`--publish` puts your own position on other people's lists for the "
671
+ "duration given, and `--unpublish` takes it off again. Looking never "
672
+ "publishes."
673
+ ),
674
+ mutating=True,
675
+ destructive=True,
676
+ rate_class="resolve",
677
+ columns=("published", "self_expires"),
678
+ example={
679
+ "items": [{"peer_id": 4242, "kind": "user", "distance": 120}],
680
+ "published": False,
681
+ },
682
+ example_args="location nearby list 52.5200 13.4050",
683
+ covers=(
684
+ "contacts-users.nearby-publish",
685
+ "contacts-users.nearby-stop",
686
+ "location.people-nearby",
687
+ ),
688
+ )
689
+
690
+
691
+ # ---------------------------------------------------------------------------
692
+ # location search
693
+ # ---------------------------------------------------------------------------
694
+
695
+
696
+ class SearchReq(Request):
697
+ lat: Annotated[float, arg(0, metavar="LAT", help="Latitude.")]
698
+ lon: Annotated[float, arg(1, metavar="LON", help="Longitude.")]
699
+ query: Annotated[str, arg(2, metavar="QUERY", required=False, help="What to look for.")] = ""
700
+ chat: Annotated[
701
+ PeerRef | None,
702
+ opt("--chat", metavar="CHAT", kind="peer", help="Peer context for the inline query."),
703
+ ] = None
704
+ provider: Annotated[
705
+ str | None,
706
+ opt("--provider", metavar="USERNAME", help="Override config.venue_search_username."),
707
+ ] = None
708
+
709
+
710
+ async def search(ctx: OpContext, req: SearchReq) -> Page[Venue]:
711
+ """Search nearby places.
712
+
713
+ Venue search is not an API method: it is an inline query against the bot
714
+ `help.getConfig().venue_search_username` (Foursquare by default) carrying
715
+ a geo point. That is why it needs a peer for context and why the results
716
+ come back as `botInlineMessageMediaVenue`.
717
+ """
718
+ from telethon.tl.functions import help as help_fn
719
+ from telethon.tl.functions import messages as fn
720
+
721
+ limit, state = window(ctx, "location.search", PageKind.PARTICIPANTS)
722
+ provider = req.provider
723
+ if not provider:
724
+ config = await client(ctx)(help_fn.GetConfigRequest())
725
+ provider = getattr(config, "venue_search_username", None)
726
+ if not provider:
727
+ raise NotSupportedError(
728
+ "this account's server config names no venue search bot "
729
+ "(help.config.venue_search_username), so there is nowhere to ask"
730
+ )
731
+
732
+ context = await _send.resolve(ctx, req.chat) if req.chat is not None else None
733
+ if context is None:
734
+ from telethon.tl import types
735
+
736
+ context = types.InputPeerSelf()
737
+ bot = await client(ctx).get_input_entity(provider)
738
+ result = await client(ctx)(
739
+ fn.GetInlineBotResultsRequest(
740
+ bot=bot,
741
+ peer=context,
742
+ query=req.query,
743
+ offset=state.get("offset") or "",
744
+ geo_point=_point(req.lat, req.lon),
745
+ )
746
+ )
747
+ items: list[Venue] = []
748
+ for row in getattr(result, "results", None) or []:
749
+ message = getattr(row, "send_message", None)
750
+ if type(message).__name__ not in ("BotInlineMessageMediaVenue",):
751
+ continue
752
+ items.append(
753
+ Venue(
754
+ title=str(getattr(message, "title", "") or ""),
755
+ address=str(getattr(message, "address", "") or ""),
756
+ provider=str(getattr(message, "provider", "") or ""),
757
+ venue_id=str(getattr(message, "venue_id", "") or ""),
758
+ venue_type=str(getattr(message, "venue_type", "") or ""),
759
+ geo=geo_model(getattr(message, "geo", None)),
760
+ )
761
+ )
762
+ next_offset = getattr(result, "next_offset", None)
763
+ return build_page(
764
+ items[:limit],
765
+ op="location.search",
766
+ kind=PageKind.PARTICIPANTS,
767
+ state={"offset": next_offset},
768
+ account=ctx.account,
769
+ has_more=bool(next_offset),
770
+ )
771
+
772
+
773
+ SPEC_SEARCH = OperationSpec(
774
+ id="location.search",
775
+ request=SearchReq,
776
+ response=Page[Venue],
777
+ impl=search,
778
+ summary="Search nearby places (venue provider inline bot)",
779
+ description=(
780
+ "There is no venue-search method: this is an inline query against "
781
+ "`help.config.venue_search_username` with a geo point attached."
782
+ ),
783
+ paginated=PageKind.PARTICIPANTS,
784
+ rate_class="resolve",
785
+ columns=("title", "address", "venue_id"),
786
+ example={
787
+ "items": [
788
+ {
789
+ "title": "Brandenburg Gate",
790
+ "address": "Pariser Platz",
791
+ "provider": "foursquare",
792
+ "venue_id": "4ac518",
793
+ }
794
+ ],
795
+ "has_more": False,
796
+ },
797
+ example_args="location search 52.5200 13.4050 museum",
798
+ covers=("location.venue-search",),
799
+ )
800
+
801
+
802
+ # ---------------------------------------------------------------------------
803
+ # location preview
804
+ # ---------------------------------------------------------------------------
805
+
806
+
807
+ class PreviewReq(Request):
808
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
809
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Location message id.")]
810
+ out: Annotated[
811
+ str | None, opt("--out", metavar="PATH", kind="path", help="Write the PNG here.")
812
+ ] = None
813
+ stdout: Annotated[bool, opt("--stdout", help="Return the image base64 instead.")] = False
814
+ zoom: Annotated[int, opt("--zoom", metavar="N", help="13-20.", ge=13, le=20)] = 15
815
+ size: Annotated[str, opt("--size", metavar="WxH", help="16-1024 per side.")] = "512x512"
816
+ scale: Annotated[int, opt("--scale", metavar="N", help="Pixel density 1-3.", ge=1, le=3)] = 2
817
+
818
+
819
+ def _size(value: str) -> tuple[int, int]:
820
+ width, sep, height = value.lower().partition("x")
821
+ if not sep:
822
+ raise UsageError("--size wants WxH", field="size")
823
+ try:
824
+ w, h = int(width), int(height)
825
+ except ValueError as exc:
826
+ raise UsageError(f"--size {value!r} is not WxH", field="size") from exc
827
+ if not (16 <= w <= 1024 and 16 <= h <= 1024):
828
+ raise UsageError("--size is 16..1024 on each side", field="size")
829
+ return w, h
830
+
831
+
832
+ async def preview(ctx: OpContext, req: PreviewReq) -> MapPreview:
833
+ """Render the map thumbnail for a location message.
834
+
835
+ Two things make this different from every other download: it needs the
836
+ `geoPoint.access_hash` off the *received* message (a point built from
837
+ coordinates will not do), and it is served by the webfile data centre
838
+ named in `config.webfile_dc_id`, not the home DC.
839
+ """
840
+ import base64 as b64
841
+ from pathlib import Path
842
+
843
+ from telethon.tl import types
844
+ from telethon.tl.functions import help as help_fn
845
+ from telethon.tl.functions import upload as fn
846
+
847
+ peer = await _send.resolve(ctx, req.chat)
848
+ chat_id = _send.peer_id_of(peer)
849
+ message = await client(ctx).get_messages(peer, ids=req.msg_id)
850
+ media = getattr(message, "media", None) if message is not None else None
851
+ geo = getattr(media, "geo", None)
852
+ point = geo_model(geo)
853
+ if point is None:
854
+ raise NotFoundError(f"message {req.msg_id} in {chat_id} carries no location")
855
+ if point.access_hash is None:
856
+ raise NotSupportedError(
857
+ "this location has no access hash, which the map endpoint requires; "
858
+ "it can only be rendered for a location tlgr actually received"
859
+ )
860
+
861
+ width, height = _size(req.size)
862
+ location = types.InputWebFileGeoPointLocation(
863
+ geo_point=types.InputGeoPoint(lat=point.lat, long=point.lon),
864
+ access_hash=point.access_hash,
865
+ w=width,
866
+ h=height,
867
+ zoom=req.zoom,
868
+ scale=req.scale,
869
+ )
870
+ config = await client(ctx)(help_fn.GetConfigRequest())
871
+ dc_id = int(getattr(config, "webfile_dc_id", 0) or 0)
872
+ request = fn.GetWebFileRequest(location=location, offset=0, limit=1024 * 1024)
873
+
874
+ handle = client(ctx)
875
+ sender = await handle._borrow_exported_sender(dc_id)
876
+ try:
877
+ result = await sender.send(request)
878
+ finally:
879
+ await handle._return_exported_sender(sender)
880
+
881
+ payload = bytes(getattr(result, "bytes", b"") or b"")
882
+ preview = MapPreview(
883
+ chat_id=chat_id,
884
+ msg_id=req.msg_id,
885
+ bytes=len(payload),
886
+ mime_type=getattr(result, "mime_type", None),
887
+ zoom=req.zoom,
888
+ size=req.size,
889
+ scale=req.scale,
890
+ )
891
+ if req.stdout or not req.out:
892
+ preview.base64 = b64.b64encode(payload).decode()
893
+ else:
894
+ path = Path(req.out).expanduser()
895
+ path.parent.mkdir(parents=True, exist_ok=True)
896
+ path.write_bytes(payload)
897
+ preview.path = str(path)
898
+ return preview
899
+
900
+
901
+ SPEC_PREVIEW = OperationSpec(
902
+ id="location.preview",
903
+ request=PreviewReq,
904
+ response=MapPreview,
905
+ impl=preview,
906
+ summary="Render the map thumbnail for a location message",
907
+ description=(
908
+ "Served by the webfile data centre, not the home DC, and it needs the "
909
+ "`geoPoint.access_hash` carried by the received message."
910
+ ),
911
+ rate_class="file",
912
+ columns=("chat_id", "msg_id", "bytes", "path"),
913
+ example={"chat_id": 777123, "msg_id": 12345, "bytes": 20480, "path": "/tmp/map.png"},
914
+ example_args="location preview @alice 12345 --out /tmp/map.png",
915
+ covers=("location.map-preview",),
916
+ timeout_s=180,
917
+ )