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/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
|
+
)
|