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/resolve.py
ADDED
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
"""The `resolve` group: references, links and the per-account peer cache.
|
|
2
|
+
|
|
3
|
+
This is the group whose entire job is to be honest about *how* an answer was
|
|
4
|
+
reached, because every other group depends on it being right.
|
|
5
|
+
|
|
6
|
+
* **A bare numeric id cannot be turned into an access hash.** There is no
|
|
7
|
+
MTProto call that does it for a non-bot account: `users.getUsers` with
|
|
8
|
+
`access_hash=0` answers `UserEmpty` for any non-contact. So an uncached id
|
|
9
|
+
fails with NOT_FOUND or INDETERMINATE rather than being guessed, which is
|
|
10
|
+
the trap `user dialog-status` was built around.
|
|
11
|
+
* **`PHONE_NOT_OCCUPIED` is ambiguous.** No account, or an owner who refuses
|
|
12
|
+
lookups by phone — the two are indistinguishable from here, so
|
|
13
|
+
`resolve phone` exits 13 INDETERMINATE, never 5.
|
|
14
|
+
* **Resolution never acts.** Joining a chat, starting a bot, installing a
|
|
15
|
+
theme or a sticker set, enabling a proxy, applying a boost, redeeming a
|
|
16
|
+
gift: each is a separate, confirmed command in its own group, and
|
|
17
|
+
`resolve link` names it in `delegated_to` instead of doing it.
|
|
18
|
+
* **Access hashes are per login session.** They are never printed and never
|
|
19
|
+
copied between accounts; `access_hash_cached` is the only thing said about
|
|
20
|
+
them.
|
|
21
|
+
|
|
22
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import time
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import Annotated, Any
|
|
31
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
32
|
+
|
|
33
|
+
from tlgr.core.errors import NotFoundError, UsageError
|
|
34
|
+
from tlgr.core.pagination import PageKind, build_page, decode_cursor
|
|
35
|
+
from tlgr.core.timefmt import fmt_dt
|
|
36
|
+
from tlgr.models.base import Request
|
|
37
|
+
from tlgr.models.page import Page
|
|
38
|
+
from tlgr.models.peer import Peer, PeerRef, parse_peer_ref
|
|
39
|
+
from tlgr.models.resolve import (
|
|
40
|
+
CachedPeerRow,
|
|
41
|
+
ResolvedLink,
|
|
42
|
+
ResolvedPhone,
|
|
43
|
+
ResolvedRef,
|
|
44
|
+
ResolvedUsername,
|
|
45
|
+
)
|
|
46
|
+
from tlgr.ops import _send
|
|
47
|
+
from tlgr.ops._params import arg, choice, opt
|
|
48
|
+
from tlgr.ops._serialize import entity_to_peer
|
|
49
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
50
|
+
from tlgr.ops.contact import client_of, e164
|
|
51
|
+
|
|
52
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
53
|
+
|
|
54
|
+
#: Bot API ids offset channels by this much; the two id spaces differ and a
|
|
55
|
+
#: caller moving between them should not have to remember the arithmetic.
|
|
56
|
+
_CHANNEL_MARK = -1000000000000
|
|
57
|
+
|
|
58
|
+
#: `tg://` paths that name a settings screen rather than a peer.
|
|
59
|
+
_SETTINGS_SECTIONS = frozenset(
|
|
60
|
+
{
|
|
61
|
+
"settings",
|
|
62
|
+
"privacy",
|
|
63
|
+
"language",
|
|
64
|
+
"themes",
|
|
65
|
+
"devices",
|
|
66
|
+
"folders",
|
|
67
|
+
"chat_folders",
|
|
68
|
+
"stickers",
|
|
69
|
+
"premium",
|
|
70
|
+
"premium_offer",
|
|
71
|
+
"premium_multigift",
|
|
72
|
+
"stars",
|
|
73
|
+
"stars_topup",
|
|
74
|
+
"giftcode",
|
|
75
|
+
"restore_purchases",
|
|
76
|
+
"passport",
|
|
77
|
+
"change_number",
|
|
78
|
+
"auto_delete",
|
|
79
|
+
"edit_profile",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
#: `t.me/contacts/<section>` — a screen in the contacts UI, not an RPC.
|
|
84
|
+
_CONTACT_SECTIONS = frozenset({"new", "search", "sort", "invite", "manage"})
|
|
85
|
+
|
|
86
|
+
#: kind → the command that would *act* on a link of that kind.
|
|
87
|
+
DELEGATES: dict[str, str] = {
|
|
88
|
+
"invite": "chat join",
|
|
89
|
+
"chatlist-invite": "folder join",
|
|
90
|
+
"folder": "folder join",
|
|
91
|
+
"bot-start": "bot start",
|
|
92
|
+
"bot-startgroup": "bot add",
|
|
93
|
+
"bot-startchannel": "bot add",
|
|
94
|
+
"webapp": "webapp open",
|
|
95
|
+
"proxy": "proxy add",
|
|
96
|
+
"boost": "boost apply",
|
|
97
|
+
"giftcode": "gift redeem",
|
|
98
|
+
"unique-gift": "gift get",
|
|
99
|
+
"stars-topup": "stars buy",
|
|
100
|
+
"stickerset": "sticker set install",
|
|
101
|
+
"emojiset": "sticker set install",
|
|
102
|
+
"theme": "settings theme install",
|
|
103
|
+
"wallpaper": "chat wallpaper set",
|
|
104
|
+
"contact-token": "contact add",
|
|
105
|
+
"share-url": "message send",
|
|
106
|
+
"business-chat-link": "message send",
|
|
107
|
+
"message": "message get",
|
|
108
|
+
"private-post": "message get",
|
|
109
|
+
"story": "story get",
|
|
110
|
+
"public-username": "chat get",
|
|
111
|
+
"phone": "user get",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_EXAMPLE_REF: dict[str, Any] = {
|
|
115
|
+
"ref": "@alice",
|
|
116
|
+
"kind": "username",
|
|
117
|
+
"id": 777123,
|
|
118
|
+
"marked_id": 777123,
|
|
119
|
+
"type": "user",
|
|
120
|
+
"title": "Alice",
|
|
121
|
+
"username": "alice",
|
|
122
|
+
"source": "resolve_username",
|
|
123
|
+
"resolved": True,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def raw_id(marked: int) -> int:
|
|
128
|
+
"""The unmarked MTProto id behind a marked one.
|
|
129
|
+
|
|
130
|
+
The two id spaces differ only for chats and channels — `-100…` and `-`
|
|
131
|
+
are the marks — and every caller that has reimplemented this arithmetic
|
|
132
|
+
has eventually got a channel id wrong. `resolve peer` emits both.
|
|
133
|
+
"""
|
|
134
|
+
if marked < _CHANNEL_MARK:
|
|
135
|
+
return _CHANNEL_MARK - marked
|
|
136
|
+
if marked < 0:
|
|
137
|
+
return -marked
|
|
138
|
+
return marked
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _peer_of(entity: Any) -> Peer:
|
|
142
|
+
return entity_to_peer(entity)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _kind_matches(kind: str, wanted: str) -> bool:
|
|
146
|
+
return kind in {
|
|
147
|
+
"user": {"user", "saved"},
|
|
148
|
+
"bot": {"bot"},
|
|
149
|
+
"group": {"group", "supergroup"},
|
|
150
|
+
"channel": {"channel"},
|
|
151
|
+
}.get(wanted, {wanted})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# resolve username
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class UsernameReq(Request):
|
|
160
|
+
username: Annotated[str, arg(0, metavar="USERNAME", help="With or without the @.")]
|
|
161
|
+
referer: Annotated[
|
|
162
|
+
str | None,
|
|
163
|
+
opt("--referer", metavar="USERNAME", help="Attribute the resolution to a referrer."),
|
|
164
|
+
] = None
|
|
165
|
+
type: Annotated[
|
|
166
|
+
str | None,
|
|
167
|
+
choice("user", "bot", "group", "channel", help="Fail unless the result is of this kind."),
|
|
168
|
+
] = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def username(ctx: OpContext, req: UsernameReq) -> ResolvedUsername:
|
|
172
|
+
"""Resolve a public @username to a peer.
|
|
173
|
+
|
|
174
|
+
`USERNAME_INVALID` (malformed) and `USERNAME_NOT_OCCUPIED` (free) are
|
|
175
|
+
different answers and get different exit codes — 2 and 5 — because "you
|
|
176
|
+
typed it wrong" and "nobody has it" call for different reactions.
|
|
177
|
+
|
|
178
|
+
This hits the network every time and floods at roughly fifty lookups in a
|
|
179
|
+
short window, so the returned access hash is persisted in the per-account
|
|
180
|
+
peer cache on the way out.
|
|
181
|
+
"""
|
|
182
|
+
from telethon.tl.functions import contacts as fn
|
|
183
|
+
|
|
184
|
+
handle = (req.username or "").strip().lstrip("@")
|
|
185
|
+
if not handle:
|
|
186
|
+
raise UsageError("a username is required", field="username")
|
|
187
|
+
|
|
188
|
+
kwargs: dict[str, Any] = {"username": handle}
|
|
189
|
+
if req.referer:
|
|
190
|
+
# Layer 224+ only; an older build simply does not accept the field,
|
|
191
|
+
# and losing the attribution is better than losing the resolution.
|
|
192
|
+
try:
|
|
193
|
+
request = fn.ResolveUsernameRequest(referer=req.referer, **kwargs)
|
|
194
|
+
except TypeError:
|
|
195
|
+
ctx.warn("this Telethon build has no --referer support; resolving without it")
|
|
196
|
+
request = fn.ResolveUsernameRequest(**kwargs)
|
|
197
|
+
else:
|
|
198
|
+
request = fn.ResolveUsernameRequest(**kwargs)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
result = await client_of(ctx)(request)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
name = type(exc).__name__
|
|
204
|
+
if name == "UsernameInvalidError":
|
|
205
|
+
raise UsageError(f"@{handle} is not a valid username", field="username") from exc
|
|
206
|
+
if name == "UsernameNotOccupiedError":
|
|
207
|
+
raise NotFoundError(f"nobody holds @{handle}") from exc
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
entities = list(getattr(result, "users", None) or []) + list(
|
|
211
|
+
getattr(result, "chats", None) or []
|
|
212
|
+
)
|
|
213
|
+
if not entities:
|
|
214
|
+
raise NotFoundError(f"nobody holds @{handle}")
|
|
215
|
+
peer = _peer_of(entities[0])
|
|
216
|
+
if req.type and not _kind_matches(peer.kind, req.type):
|
|
217
|
+
raise NotFoundError(f"@{handle} is a {peer.kind}, not a {req.type}")
|
|
218
|
+
|
|
219
|
+
# Persist what we paid a round trip for.
|
|
220
|
+
resolver = getattr(ctx, "resolver", None)
|
|
221
|
+
if resolver is not None:
|
|
222
|
+
with contextlib.suppress(Exception):
|
|
223
|
+
resolver._remember(entities[0], username=handle)
|
|
224
|
+
return ResolvedUsername(
|
|
225
|
+
kind=peer.kind,
|
|
226
|
+
peer=peer,
|
|
227
|
+
username=handle,
|
|
228
|
+
access_hash_cached=bool(getattr(entities[0], "access_hash", None)),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
SPEC_USERNAME = OperationSpec(
|
|
233
|
+
id="resolve.username",
|
|
234
|
+
request=UsernameReq,
|
|
235
|
+
response=ResolvedUsername,
|
|
236
|
+
impl=username,
|
|
237
|
+
summary="Resolve a public @username to a peer",
|
|
238
|
+
description=(
|
|
239
|
+
"USERNAME_INVALID exits 2 and USERNAME_NOT_OCCUPIED exits 5: a typo "
|
|
240
|
+
"and a free username are different answers. Resolution always hits "
|
|
241
|
+
"the network and floods at roughly fifty lookups in a short period, "
|
|
242
|
+
"so the access hash is cached for a day afterwards."
|
|
243
|
+
),
|
|
244
|
+
rate_class="resolve",
|
|
245
|
+
columns=("kind", "peer.id", "peer.title", "username"),
|
|
246
|
+
example={
|
|
247
|
+
"kind": "user",
|
|
248
|
+
"username": "alice",
|
|
249
|
+
"peer": {"id": 777123, "raw_id": 777123, "kind": "user", "title": "Alice"},
|
|
250
|
+
},
|
|
251
|
+
example_args="resolve username @alice",
|
|
252
|
+
covers=("contacts-users.search-public-chat",),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# resolve phone
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class PhoneReq(Request):
|
|
262
|
+
phone: Annotated[str, arg(0, metavar="PHONE", help="+countrycode number.")] = ""
|
|
263
|
+
offline: Annotated[bool, opt("--offline", help="Format and validate only; perform no RPC.")] = (
|
|
264
|
+
False
|
|
265
|
+
)
|
|
266
|
+
countries: Annotated[bool, opt("--countries", help="Dump the country/prefix/format table.")] = (
|
|
267
|
+
False
|
|
268
|
+
)
|
|
269
|
+
lang: Annotated[str, opt("--lang", metavar="CODE", help="Language for the table.")] = ""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def _country_table(ctx: OpContext, lang: str) -> list[dict[str, Any]]:
|
|
273
|
+
from telethon.tl.functions import help as hfn
|
|
274
|
+
|
|
275
|
+
result = await client_of(ctx)(hfn.GetCountriesListRequest(lang_code=lang or "", hash=0))
|
|
276
|
+
out: list[dict[str, Any]] = []
|
|
277
|
+
for country in getattr(result, "countries", None) or []:
|
|
278
|
+
for code in getattr(country, "country_codes", None) or []:
|
|
279
|
+
out.append(
|
|
280
|
+
{
|
|
281
|
+
"iso2": getattr(country, "iso2", None),
|
|
282
|
+
"name": getattr(country, "default_name", None),
|
|
283
|
+
"prefix": "+" + str(getattr(code, "country_code", "") or ""),
|
|
284
|
+
"patterns": list(getattr(code, "patterns", None) or []),
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _match_country(number: str, table: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
291
|
+
"""Longest prefix wins: +1 and +1204 both exist and only one is right."""
|
|
292
|
+
best: dict[str, Any] | None = None
|
|
293
|
+
for row in table:
|
|
294
|
+
prefix = str(row.get("prefix") or "")
|
|
295
|
+
if (
|
|
296
|
+
len(prefix) > 1
|
|
297
|
+
and number.startswith(prefix)
|
|
298
|
+
and (best is None or len(prefix) > len(str(best.get("prefix") or "")))
|
|
299
|
+
):
|
|
300
|
+
best = row
|
|
301
|
+
return best
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _unknown(ctx: OpContext, out: ResolvedPhone, reason: str) -> ResolvedPhone:
|
|
305
|
+
"""Report a lookup we could not settle, and make the process fail closed.
|
|
306
|
+
|
|
307
|
+
An exception would throw away `reason` and the formatting work already
|
|
308
|
+
done; `mark_indeterminate` keeps the body and still exits 13.
|
|
309
|
+
"""
|
|
310
|
+
out.resolved = False
|
|
311
|
+
out.reason = reason
|
|
312
|
+
mark = getattr(ctx, "mark_indeterminate", None)
|
|
313
|
+
if callable(mark):
|
|
314
|
+
mark(reason)
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def phone(ctx: OpContext, req: PhoneReq) -> ResolvedPhone:
|
|
319
|
+
"""Resolve a phone number to a user, without adding a contact.
|
|
320
|
+
|
|
321
|
+
`PHONE_NOT_OCCUPIED` is genuinely ambiguous — the number may have no
|
|
322
|
+
account, or its owner may hide themselves behind
|
|
323
|
+
`inputPrivacyKeyAddedByPhone` — so this exits 13 INDETERMINATE and never
|
|
324
|
+
"not found". Unlike `contact add`, nothing is saved to the address book.
|
|
325
|
+
|
|
326
|
+
Telegram asks for at most one of these every three seconds, which is why
|
|
327
|
+
`--offline` exists: format and validate locally first.
|
|
328
|
+
"""
|
|
329
|
+
from telethon.tl.functions import contacts as fn
|
|
330
|
+
|
|
331
|
+
number = e164(req.phone)
|
|
332
|
+
table: list[dict[str, Any]] = []
|
|
333
|
+
if req.countries and not number:
|
|
334
|
+
table = await _country_table(ctx, req.lang)
|
|
335
|
+
return ResolvedPhone(phone="", e164="", resolved=False, countries=table)
|
|
336
|
+
if not number:
|
|
337
|
+
raise UsageError("a phone number is required", field="phone")
|
|
338
|
+
|
|
339
|
+
out = ResolvedPhone(phone=req.phone, e164=number)
|
|
340
|
+
if req.offline or req.countries:
|
|
341
|
+
table = await _country_table(ctx, req.lang)
|
|
342
|
+
match = _match_country(number, table)
|
|
343
|
+
if match is not None:
|
|
344
|
+
out.country = str(match.get("name") or "")
|
|
345
|
+
out.prefix = str(match.get("prefix") or "")
|
|
346
|
+
patterns = list(match.get("patterns") or [])
|
|
347
|
+
out.pattern = str(patterns[0]) if patterns else None
|
|
348
|
+
if req.countries:
|
|
349
|
+
out.countries = table
|
|
350
|
+
if req.offline:
|
|
351
|
+
out.reason = "offline: the number was formatted and validated, not looked up"
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
result = await client_of(ctx)(fn.ResolvePhoneRequest(phone=number.lstrip("+")))
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
name = type(exc).__name__
|
|
358
|
+
if name == "PhoneNumberInvalidError":
|
|
359
|
+
raise UsageError(f"{number} is not a valid phone number", field="phone") from exc
|
|
360
|
+
# Everything else — including PHONE_NOT_OCCUPIED — is "we could not
|
|
361
|
+
# establish it", and a caller must not read it as "no account". The
|
|
362
|
+
# body is still returned so `reason` survives; the exit code is 13.
|
|
363
|
+
return _unknown(
|
|
364
|
+
ctx,
|
|
365
|
+
out,
|
|
366
|
+
f"{type(exc).__name__}: the number may have no Telegram account, OR its "
|
|
367
|
+
"owner may refuse lookups by phone. These are not distinguishable.",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
entities = list(getattr(result, "users", None) or []) + list(
|
|
371
|
+
getattr(result, "chats", None) or []
|
|
372
|
+
)
|
|
373
|
+
if not entities:
|
|
374
|
+
return _unknown(
|
|
375
|
+
ctx, out, "the server answered with no peer: no account, or a privacy refusal"
|
|
376
|
+
)
|
|
377
|
+
out.peer = _peer_of(entities[0])
|
|
378
|
+
out.resolved = True
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
SPEC_PHONE = OperationSpec(
|
|
383
|
+
id="resolve.phone",
|
|
384
|
+
request=PhoneReq,
|
|
385
|
+
response=ResolvedPhone,
|
|
386
|
+
impl=phone,
|
|
387
|
+
summary="Resolve a phone number to a user without adding a contact",
|
|
388
|
+
description=(
|
|
389
|
+
"PHONE_NOT_OCCUPIED exits 13, never 5: no account and a privacy "
|
|
390
|
+
"refusal are indistinguishable from here. The server asks for at "
|
|
391
|
+
"most one lookup every three seconds, so --offline formats and "
|
|
392
|
+
"validates against help.getCountriesList without an RPC."
|
|
393
|
+
),
|
|
394
|
+
rate_class="resolve",
|
|
395
|
+
min_interval_s=3.0,
|
|
396
|
+
columns=("e164", "country", "resolved"),
|
|
397
|
+
example={"phone": "+15550001111", "e164": "+15550001111", "resolved": False},
|
|
398
|
+
example_args="resolve phone +15550001111 --offline",
|
|
399
|
+
covers=("contacts-users.phone-number-info", "contacts-users.resolve-phone"),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
# resolve peer
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class PeerReq(Request):
|
|
409
|
+
ref: Annotated[
|
|
410
|
+
list[str],
|
|
411
|
+
arg(0, metavar="REF", variadic=True, help="@username, id, marked id, +phone, link, me."),
|
|
412
|
+
] = []
|
|
413
|
+
from_chat: Annotated[
|
|
414
|
+
PeerRef | None,
|
|
415
|
+
opt("--from-chat", metavar="CHAT", kind="peer", help="Context chat for a `min` peer."),
|
|
416
|
+
] = None
|
|
417
|
+
from_message: Annotated[
|
|
418
|
+
int | None,
|
|
419
|
+
opt("--from-message", metavar="ID", kind="msg_id", help="Message id in --from-chat."),
|
|
420
|
+
] = None
|
|
421
|
+
ids: Annotated[
|
|
422
|
+
str | None,
|
|
423
|
+
choice("mtproto", "botapi", help="Also emit the id in the other id space."),
|
|
424
|
+
] = None
|
|
425
|
+
cache_only: Annotated[bool, opt("--cache-only", help="Never hit the network.")] = False
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def _describe(ctx: OpContext, target: Any) -> tuple[str, str]:
|
|
429
|
+
"""`(type, title)` for a resolved peer, from whatever is already known."""
|
|
430
|
+
kind = {
|
|
431
|
+
"InputPeerUser": "user",
|
|
432
|
+
"InputPeerUserFromMessage": "user",
|
|
433
|
+
"InputPeerChat": "group",
|
|
434
|
+
"InputPeerChannel": "channel",
|
|
435
|
+
"InputPeerChannelFromMessage": "channel",
|
|
436
|
+
"InputPeerSelf": "saved",
|
|
437
|
+
}.get(type(target).__name__, "unknown")
|
|
438
|
+
title = ""
|
|
439
|
+
with contextlib.suppress(Exception):
|
|
440
|
+
entity = await client_of(ctx).get_entity(target)
|
|
441
|
+
peer = _peer_of(entity)
|
|
442
|
+
kind, title = peer.kind, peer.title
|
|
443
|
+
return kind, title
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def peer(ctx: OpContext, req: PeerReq) -> Page[ResolvedRef]:
|
|
447
|
+
"""Resolve any peer reference to a normalised peer object.
|
|
448
|
+
|
|
449
|
+
Order: cache → `resolveUsername`/`resolvePhone` → `getPeerDialogs` →
|
|
450
|
+
dialog-list scan → `contacts.search`, cheapest first, and every step
|
|
451
|
+
exists because the one before it cannot answer. A bare numeric id that
|
|
452
|
+
nothing has cached fails — there is no call that mints an access hash for
|
|
453
|
+
it — rather than being guessed at.
|
|
454
|
+
|
|
455
|
+
`--from-chat/--from-message` builds `inputPeerUserFromMessage` for a
|
|
456
|
+
`min` peer, which Telethon never builds and which is what makes a
|
|
457
|
+
stranger seen in a channel actionable.
|
|
458
|
+
"""
|
|
459
|
+
from telethon import utils
|
|
460
|
+
from telethon.tl import types
|
|
461
|
+
|
|
462
|
+
if not req.ref:
|
|
463
|
+
raise UsageError("give at least one reference to resolve", field="ref")
|
|
464
|
+
|
|
465
|
+
rows: list[ResolvedRef] = []
|
|
466
|
+
for raw in req.ref:
|
|
467
|
+
row = ResolvedRef(ref=raw)
|
|
468
|
+
try:
|
|
469
|
+
parsed = parse_peer_ref(raw)
|
|
470
|
+
except ValueError as exc:
|
|
471
|
+
row.reason = str(exc)
|
|
472
|
+
rows.append(row)
|
|
473
|
+
continue
|
|
474
|
+
row.kind = parsed.kind
|
|
475
|
+
|
|
476
|
+
if req.from_chat is not None and req.from_message is not None and parsed.kind == "id":
|
|
477
|
+
container = await _send.resolve(ctx, req.from_chat)
|
|
478
|
+
target: Any = types.InputPeerUserFromMessage(
|
|
479
|
+
peer=container, msg_id=int(req.from_message), user_id=abs(int(parsed.value))
|
|
480
|
+
)
|
|
481
|
+
row.source = "from_message"
|
|
482
|
+
row.min = True
|
|
483
|
+
else:
|
|
484
|
+
resolver = getattr(ctx, "resolver", None)
|
|
485
|
+
if resolver is None: # pragma: no cover - the daemon always supplies one
|
|
486
|
+
raise UsageError("no peer resolver is available in this context")
|
|
487
|
+
try:
|
|
488
|
+
target = await resolver.resolve(parsed, allow_network=not req.cache_only)
|
|
489
|
+
except Exception as exc:
|
|
490
|
+
row.reason = f"{type(exc).__name__}: {exc}"
|
|
491
|
+
rows.append(row)
|
|
492
|
+
if len(req.ref) == 1:
|
|
493
|
+
raise
|
|
494
|
+
continue
|
|
495
|
+
row.source = "cache" if req.cache_only else _source_for(parsed.kind)
|
|
496
|
+
|
|
497
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
498
|
+
row.marked_id = int(utils.get_peer_id(target))
|
|
499
|
+
# `id` is the raw MTProto id, `marked_id` the signed form every tlgr
|
|
500
|
+
# response uses (COR-10). Both are always present so nobody has to
|
|
501
|
+
# redo the sign arithmetic; --ids adds the Bot API spelling, which is
|
|
502
|
+
# the marked one.
|
|
503
|
+
row.id = raw_id(row.marked_id) if row.marked_id is not None else None
|
|
504
|
+
row.access_hash_cached = bool(int(getattr(target, "access_hash", 0) or 0))
|
|
505
|
+
row.type, row.title = await _describe(ctx, target)
|
|
506
|
+
row.username = str(parsed.value) if parsed.kind == "username" else None
|
|
507
|
+
if req.ids is not None:
|
|
508
|
+
row.botapi_id = row.marked_id if req.ids == "botapi" else row.id
|
|
509
|
+
row.resolved = row.marked_id is not None
|
|
510
|
+
rows.append(row)
|
|
511
|
+
|
|
512
|
+
limit = int(getattr(ctx, "limit", None) or 100)
|
|
513
|
+
return build_page(
|
|
514
|
+
rows[:limit],
|
|
515
|
+
op="resolve.peer",
|
|
516
|
+
kind=PageKind.LOCAL,
|
|
517
|
+
state={"offset": limit},
|
|
518
|
+
account=ctx.account,
|
|
519
|
+
has_more=len(rows) > limit,
|
|
520
|
+
total=len(rows),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _source_for(kind: str) -> str:
|
|
525
|
+
return {
|
|
526
|
+
"username": "resolve_username",
|
|
527
|
+
"phone": "resolve_phone",
|
|
528
|
+
"id": "cache_or_dialogs",
|
|
529
|
+
"invite": "check_chat_invite",
|
|
530
|
+
"self": "self",
|
|
531
|
+
"saved": "self",
|
|
532
|
+
"link": "link",
|
|
533
|
+
}.get(kind, kind)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
SPEC_PEER = OperationSpec(
|
|
537
|
+
id="resolve.peer",
|
|
538
|
+
request=PeerReq,
|
|
539
|
+
response=Page[ResolvedRef],
|
|
540
|
+
impl=peer,
|
|
541
|
+
summary="Resolve any peer reference to a normalised peer object",
|
|
542
|
+
description=(
|
|
543
|
+
"There is NO method that turns a bare id into an access hash, so an "
|
|
544
|
+
"uncached numeric id fails (exit 5 or 13) instead of guessing — that "
|
|
545
|
+
"is the trap `user dialog-status` was built around. Access hashes "
|
|
546
|
+
"are per account and never printed."
|
|
547
|
+
),
|
|
548
|
+
paginated=PageKind.LOCAL,
|
|
549
|
+
rate_class="resolve",
|
|
550
|
+
columns=("ref", "id", "type", "title", "source"),
|
|
551
|
+
headers=("Ref", "Id", "Kind", "Title", "How"),
|
|
552
|
+
example={"items": [_EXAMPLE_REF], "has_more": False},
|
|
553
|
+
example_args="resolve peer @alice",
|
|
554
|
+
covers=("contacts-users.peer-id-conversion", "dialogs.resolve-peer"),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ---------------------------------------------------------------------------
|
|
559
|
+
# resolve link
|
|
560
|
+
# ---------------------------------------------------------------------------
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class LinkReq(Request):
|
|
564
|
+
url: Annotated[str, arg(0, metavar="URL", help="Any t.me / tg:// link, or a bare slug.")]
|
|
565
|
+
no_network: Annotated[
|
|
566
|
+
bool, opt("--no-network", help="Classify from the URL only; resolve nothing.")
|
|
567
|
+
] = False
|
|
568
|
+
open: Annotated[
|
|
569
|
+
bool, opt("--open", help="Perform the follow-up read for the classified kind.")
|
|
570
|
+
] = False
|
|
571
|
+
draft: Annotated[
|
|
572
|
+
PeerRef | None,
|
|
573
|
+
opt("--draft", metavar="CHAT", kind="peer", help="Save the carried text as a draft here."),
|
|
574
|
+
] = None
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _split(url: str) -> tuple[str, list[str], dict[str, str]]:
|
|
578
|
+
"""`(scheme, path segments, query)` for a t.me or tg:// reference."""
|
|
579
|
+
text = (url or "").strip()
|
|
580
|
+
if text.lower().startswith("tg://"):
|
|
581
|
+
rest = text[5:]
|
|
582
|
+
head, _, query = rest.partition("?")
|
|
583
|
+
return "tg", [s for s in head.split("/") if s], dict(parse_qsl(query))
|
|
584
|
+
if "://" not in text:
|
|
585
|
+
text = "https://" + text.lstrip("/")
|
|
586
|
+
parts = urlsplit(text)
|
|
587
|
+
host = (parts.netloc or "").lower()
|
|
588
|
+
if host not in ("t.me", "telegram.me", "telegram.dog", "www.t.me"):
|
|
589
|
+
return "", [], {}
|
|
590
|
+
return "tme", [s for s in parts.path.split("/") if s], dict(parse_qsl(parts.query))
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def classify(url: str) -> ResolvedLink:
|
|
594
|
+
"""Classify a link from its shape alone. No network, no side effects.
|
|
595
|
+
|
|
596
|
+
One function rather than twenty commands because the human pasting a
|
|
597
|
+
link does not know which of the twenty kinds it is — that is the
|
|
598
|
+
question. `unknown` is a real answer and keeps the raw path.
|
|
599
|
+
"""
|
|
600
|
+
out = ResolvedLink(kind="unknown", raw_url=url)
|
|
601
|
+
scheme, segments, query = _split(url)
|
|
602
|
+
out.scheme = scheme
|
|
603
|
+
if not scheme:
|
|
604
|
+
return out
|
|
605
|
+
|
|
606
|
+
if scheme == "tg":
|
|
607
|
+
verb = (segments[0] if segments else "").lower()
|
|
608
|
+
return _classify_tg(out, verb, query)
|
|
609
|
+
|
|
610
|
+
if not segments:
|
|
611
|
+
return out
|
|
612
|
+
|
|
613
|
+
head = segments[0]
|
|
614
|
+
lowered = head.lower()
|
|
615
|
+
|
|
616
|
+
if lowered == "contact" and len(segments) > 1:
|
|
617
|
+
out.kind = "contact-token"
|
|
618
|
+
out.contact_token = segments[1]
|
|
619
|
+
elif lowered == "addlist" and len(segments) > 1:
|
|
620
|
+
out.kind = "chatlist-invite"
|
|
621
|
+
out.chatlist_slug = segments[1]
|
|
622
|
+
elif lowered == "list" and len(segments) > 1:
|
|
623
|
+
out.kind = "folder"
|
|
624
|
+
out.chatlist_slug = segments[1]
|
|
625
|
+
elif lowered in ("addstickers", "addemoji") and len(segments) > 1:
|
|
626
|
+
out.kind = "emojiset" if lowered == "addemoji" else "stickerset"
|
|
627
|
+
out.stickerset = segments[1]
|
|
628
|
+
elif lowered == "addtheme" and len(segments) > 1:
|
|
629
|
+
out.kind = "theme"
|
|
630
|
+
out.theme = segments[1]
|
|
631
|
+
elif lowered == "bg" and len(segments) > 1:
|
|
632
|
+
out.kind = "wallpaper"
|
|
633
|
+
out.wallpaper = segments[1]
|
|
634
|
+
elif lowered == "proxy" or lowered == "socks":
|
|
635
|
+
out.kind = "proxy"
|
|
636
|
+
out.proxy = dict(query)
|
|
637
|
+
elif lowered == "share" and query:
|
|
638
|
+
out.kind = "share-url"
|
|
639
|
+
out.share = dict(query)
|
|
640
|
+
elif lowered == "giftcode" and len(segments) > 1:
|
|
641
|
+
out.kind = "giftcode"
|
|
642
|
+
out.gift = segments[1]
|
|
643
|
+
elif lowered == "nft" and len(segments) > 1:
|
|
644
|
+
out.kind = "unique-gift"
|
|
645
|
+
out.gift = segments[1]
|
|
646
|
+
elif lowered == "boost":
|
|
647
|
+
out.kind = "boost"
|
|
648
|
+
out.boost = True
|
|
649
|
+
out.username = query.get("c") or (segments[1] if len(segments) > 1 else None)
|
|
650
|
+
elif lowered == "m" and len(segments) > 1:
|
|
651
|
+
out.kind = "business-chat-link"
|
|
652
|
+
out.start_param = segments[1]
|
|
653
|
+
elif lowered == "invoice" and len(segments) > 1:
|
|
654
|
+
out.kind = "invoice"
|
|
655
|
+
out.start_param = segments[1]
|
|
656
|
+
elif lowered == "login" and len(segments) > 1:
|
|
657
|
+
out.kind = "login-code"
|
|
658
|
+
out.start_param = segments[1]
|
|
659
|
+
elif lowered == "contacts" and len(segments) > 1 and segments[1].lower() in _CONTACT_SECTIONS:
|
|
660
|
+
out.kind = "contacts-section"
|
|
661
|
+
out.section = segments[1].lower()
|
|
662
|
+
elif head.startswith("+") or lowered == "joinchat":
|
|
663
|
+
value = head[1:] if head.startswith("+") else (segments[1] if len(segments) > 1 else "")
|
|
664
|
+
# `t.me/+15550001111` is a PHONE when it parses as a number; only
|
|
665
|
+
# otherwise is it an invite hash. Guessing the wrong one turns a
|
|
666
|
+
# contact lookup into a join.
|
|
667
|
+
if value.isdigit():
|
|
668
|
+
out.kind = "phone"
|
|
669
|
+
out.phone = "+" + value
|
|
670
|
+
elif value:
|
|
671
|
+
out.kind = "invite"
|
|
672
|
+
out.invite_hash = value
|
|
673
|
+
elif lowered == "c" and len(segments) > 2 and segments[1].isdigit():
|
|
674
|
+
out.kind = "private-post"
|
|
675
|
+
out.msg_id = int(segments[2]) if segments[2].isdigit() else None
|
|
676
|
+
out.username = None
|
|
677
|
+
out.thread_id = int(segments[3]) if len(segments) > 3 and segments[3].isdigit() else None
|
|
678
|
+
elif lowered == "s" and len(segments) > 1:
|
|
679
|
+
out.kind = "public-username"
|
|
680
|
+
out.username = segments[1].lower()
|
|
681
|
+
else:
|
|
682
|
+
out.username = head.lower()
|
|
683
|
+
if len(segments) > 1 and segments[1].isdigit():
|
|
684
|
+
out.kind = "message"
|
|
685
|
+
out.msg_id = int(segments[1])
|
|
686
|
+
if len(segments) > 2 and segments[2].isdigit():
|
|
687
|
+
out.thread_id, out.msg_id = out.msg_id, int(segments[2])
|
|
688
|
+
elif len(segments) > 1 and segments[1].lower() == "s" and len(segments) > 2:
|
|
689
|
+
out.kind = "story"
|
|
690
|
+
out.story_id = int(segments[2]) if segments[2].isdigit() else None
|
|
691
|
+
elif "start" in query:
|
|
692
|
+
out.kind = "bot-start"
|
|
693
|
+
out.bot = head.lower()
|
|
694
|
+
out.start_param = query["start"]
|
|
695
|
+
elif "startgroup" in query:
|
|
696
|
+
out.kind = "bot-startgroup"
|
|
697
|
+
out.bot = head.lower()
|
|
698
|
+
out.start_param = query["startgroup"]
|
|
699
|
+
elif "startchannel" in query:
|
|
700
|
+
out.kind = "bot-startchannel"
|
|
701
|
+
out.bot = head.lower()
|
|
702
|
+
out.start_param = query["startchannel"]
|
|
703
|
+
elif "startapp" in query or "appname" in query:
|
|
704
|
+
out.kind = "webapp"
|
|
705
|
+
out.bot = head.lower()
|
|
706
|
+
out.start_param = query.get("startapp") or query.get("appname")
|
|
707
|
+
else:
|
|
708
|
+
out.kind = "public-username"
|
|
709
|
+
|
|
710
|
+
if "comment" in query and query["comment"].isdigit():
|
|
711
|
+
out.comment_id = int(query["comment"])
|
|
712
|
+
if "thread" in query and query["thread"].isdigit():
|
|
713
|
+
out.thread_id = int(query["thread"])
|
|
714
|
+
if "single" in query and out.kind == "message":
|
|
715
|
+
out.start_target = "single"
|
|
716
|
+
return out
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _classify_tg(out: ResolvedLink, verb: str, query: dict[str, str]) -> ResolvedLink:
|
|
720
|
+
if verb == "resolve":
|
|
721
|
+
out.username = (query.get("domain") or "").lower() or None
|
|
722
|
+
out.phone = ("+" + query["phone"]) if query.get("phone") else None
|
|
723
|
+
if query.get("post", "").isdigit():
|
|
724
|
+
out.kind = "message"
|
|
725
|
+
out.msg_id = int(query["post"])
|
|
726
|
+
elif "start" in query:
|
|
727
|
+
out.kind = "bot-start"
|
|
728
|
+
out.bot = out.username
|
|
729
|
+
out.start_param = query["start"]
|
|
730
|
+
elif "startapp" in query:
|
|
731
|
+
out.kind = "webapp"
|
|
732
|
+
out.bot = out.username
|
|
733
|
+
out.start_param = query["startapp"]
|
|
734
|
+
elif out.phone:
|
|
735
|
+
out.kind = "phone"
|
|
736
|
+
else:
|
|
737
|
+
out.kind = "public-username"
|
|
738
|
+
elif verb == "join":
|
|
739
|
+
out.kind = "invite"
|
|
740
|
+
out.invite_hash = query.get("invite")
|
|
741
|
+
elif verb == "privatepost":
|
|
742
|
+
out.kind = "private-post"
|
|
743
|
+
out.msg_id = int(query["post"]) if query.get("post", "").isdigit() else None
|
|
744
|
+
elif verb in ("addstickers", "addemoji"):
|
|
745
|
+
out.kind = "emojiset" if verb == "addemoji" else "stickerset"
|
|
746
|
+
out.stickerset = query.get("set")
|
|
747
|
+
elif verb == "addtheme":
|
|
748
|
+
out.kind = "theme"
|
|
749
|
+
out.theme = query.get("slug")
|
|
750
|
+
elif verb in ("bg", "wallpaper"):
|
|
751
|
+
out.kind = "wallpaper"
|
|
752
|
+
out.wallpaper = query.get("slug") or query.get("color")
|
|
753
|
+
elif verb in ("proxy", "socks"):
|
|
754
|
+
out.kind = "proxy"
|
|
755
|
+
out.proxy = dict(query)
|
|
756
|
+
elif verb == "msg_url":
|
|
757
|
+
out.kind = "share-url"
|
|
758
|
+
out.share = dict(query)
|
|
759
|
+
elif verb == "boost":
|
|
760
|
+
out.kind = "boost"
|
|
761
|
+
out.boost = True
|
|
762
|
+
out.username = (query.get("domain") or "").lower() or None
|
|
763
|
+
elif verb == "giftcode":
|
|
764
|
+
out.kind = "giftcode"
|
|
765
|
+
out.gift = query.get("slug")
|
|
766
|
+
elif verb == "nft":
|
|
767
|
+
out.kind = "unique-gift"
|
|
768
|
+
out.gift = query.get("slug")
|
|
769
|
+
elif verb in ("stars_topup", "premium_offer"):
|
|
770
|
+
out.kind = "stars-topup" if verb == "stars_topup" else "premium-offer"
|
|
771
|
+
out.stars = int(query["balance"]) if query.get("balance", "").isdigit() else None
|
|
772
|
+
elif verb == "confirmphone":
|
|
773
|
+
out.kind = "confirm-phone"
|
|
774
|
+
out.phone = ("+" + query["phone"]) if query.get("phone") else None
|
|
775
|
+
elif verb == "login":
|
|
776
|
+
out.kind = "login-code"
|
|
777
|
+
out.start_param = query.get("code")
|
|
778
|
+
elif verb == "message":
|
|
779
|
+
out.kind = "business-chat-link"
|
|
780
|
+
out.start_param = query.get("slug")
|
|
781
|
+
elif verb == "invoice":
|
|
782
|
+
out.kind = "invoice"
|
|
783
|
+
out.start_param = query.get("slug")
|
|
784
|
+
elif verb in _SETTINGS_SECTIONS:
|
|
785
|
+
out.kind = "settings-section"
|
|
786
|
+
out.section = verb
|
|
787
|
+
elif verb == "contacts":
|
|
788
|
+
out.kind = "contacts-section"
|
|
789
|
+
out.section = (query.get("section") or "new").lower()
|
|
790
|
+
return out
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
async def _open(ctx: OpContext, out: ResolvedLink) -> None:
|
|
794
|
+
"""The follow-up *read* for a classified link. Never an action."""
|
|
795
|
+
from telethon.tl import types as tl
|
|
796
|
+
from telethon.tl.functions import account as afn
|
|
797
|
+
from telethon.tl.functions import contacts as cfn
|
|
798
|
+
from telethon.tl.functions import messages as mfn
|
|
799
|
+
from telethon.tl.functions import payments as pfn
|
|
800
|
+
from telethon.tl.functions import premium as prfn
|
|
801
|
+
from telethon.tl.functions import stories as sfn
|
|
802
|
+
|
|
803
|
+
client = client_of(ctx)
|
|
804
|
+
kind = out.kind
|
|
805
|
+
if kind in ("public-username", "bot-start", "bot-startgroup", "bot-startchannel", "webapp"):
|
|
806
|
+
found = await client(cfn.ResolveUsernameRequest(out.username or out.bot or ""))
|
|
807
|
+
entities = list(getattr(found, "users", None) or []) + list(
|
|
808
|
+
getattr(found, "chats", None) or []
|
|
809
|
+
)
|
|
810
|
+
if entities:
|
|
811
|
+
out.peer = _peer_of(entities[0])
|
|
812
|
+
elif kind == "phone" and out.phone:
|
|
813
|
+
found = await client(cfn.ResolvePhoneRequest(phone=out.phone.lstrip("+")))
|
|
814
|
+
entities = list(getattr(found, "users", None) or [])
|
|
815
|
+
if entities:
|
|
816
|
+
out.peer = _peer_of(entities[0])
|
|
817
|
+
elif kind == "invite" and out.invite_hash:
|
|
818
|
+
preview = await client(mfn.CheckChatInviteRequest(hash=out.invite_hash))
|
|
819
|
+
out.title = str(getattr(preview, "title", "") or "")
|
|
820
|
+
chat = getattr(preview, "chat", None)
|
|
821
|
+
out.peer = _peer_of(chat) if chat is not None else None
|
|
822
|
+
out.opened = {"already_member": type(preview).__name__ == "ChatInviteAlready"}
|
|
823
|
+
elif kind in ("chatlist-invite", "folder") and out.chatlist_slug:
|
|
824
|
+
from telethon.tl.functions import chatlists as clfn
|
|
825
|
+
|
|
826
|
+
preview = await client(clfn.CheckChatlistInviteRequest(slug=out.chatlist_slug))
|
|
827
|
+
title = getattr(preview, "title", None)
|
|
828
|
+
out.title = str(getattr(title, "text", title) or "")
|
|
829
|
+
elif kind == "contact-token" and out.contact_token:
|
|
830
|
+
imported = await client(cfn.ImportContactTokenRequest(token=out.contact_token))
|
|
831
|
+
out.peer = _peer_of(imported) if imported is not None else None
|
|
832
|
+
elif kind == "business-chat-link" and out.start_param:
|
|
833
|
+
resolved = await client(afn.ResolveBusinessChatLinkRequest(slug=out.start_param))
|
|
834
|
+
message = getattr(resolved, "message", None)
|
|
835
|
+
out.opened = {"text": message}
|
|
836
|
+
elif kind in ("message", "private-post") and out.msg_id:
|
|
837
|
+
from tlgr.core.peers import channel_id_from_link
|
|
838
|
+
|
|
839
|
+
if out.username:
|
|
840
|
+
reference: Any = "@" + out.username
|
|
841
|
+
else:
|
|
842
|
+
found_link = channel_id_from_link(out.raw_url)
|
|
843
|
+
if found_link is None:
|
|
844
|
+
raise NotFoundError("that private-post link carries no channel id")
|
|
845
|
+
# A bare channel id needs an access hash this account already
|
|
846
|
+
# holds; there is no call that mints one, so this fails loudly.
|
|
847
|
+
reference = str(found_link[0])
|
|
848
|
+
target = await _send.resolve(ctx, reference)
|
|
849
|
+
found = await client.get_messages(target, ids=[out.msg_id])
|
|
850
|
+
text = next((getattr(m, "message", "") for m in found or [] if m is not None), "")
|
|
851
|
+
out.peer = out.peer or Peer(
|
|
852
|
+
id=_send.peer_id_of(target), raw_id=abs(_send.peer_id_of(target)), kind="unknown"
|
|
853
|
+
)
|
|
854
|
+
out.opened = {"text": text}
|
|
855
|
+
elif kind == "story" and out.story_id and out.username:
|
|
856
|
+
target = await _send.resolve(ctx, "@" + out.username)
|
|
857
|
+
found = await client(sfn.GetStoriesByIDRequest(peer=target, id=[out.story_id]))
|
|
858
|
+
out.opened = {"stories": len(list(getattr(found, "stories", None) or []))}
|
|
859
|
+
elif kind == "boost" and out.username:
|
|
860
|
+
target = await _send.resolve(ctx, "@" + out.username)
|
|
861
|
+
status = await client(prfn.GetBoostsStatusRequest(peer=target))
|
|
862
|
+
out.opened = {
|
|
863
|
+
"level": getattr(status, "level", None),
|
|
864
|
+
"boosts": getattr(status, "boosts", None),
|
|
865
|
+
}
|
|
866
|
+
elif kind == "giftcode" and out.gift:
|
|
867
|
+
info = await client(pfn.CheckGiftCodeRequest(slug=out.gift))
|
|
868
|
+
out.opened = {"used": bool(getattr(info, "used_date", None))}
|
|
869
|
+
elif kind == "unique-gift" and out.gift:
|
|
870
|
+
info = await client(pfn.GetUniqueStarGiftRequest(slug=out.gift))
|
|
871
|
+
out.opened = {"title": getattr(getattr(info, "gift", None), "title", None)}
|
|
872
|
+
elif kind in ("stickerset", "emojiset") and out.stickerset:
|
|
873
|
+
info = await client(
|
|
874
|
+
mfn.GetStickerSetRequest(
|
|
875
|
+
stickerset=tl.InputStickerSetShortName(short_name=out.stickerset), hash=0
|
|
876
|
+
)
|
|
877
|
+
)
|
|
878
|
+
out.title = str(getattr(getattr(info, "set", None), "title", "") or "")
|
|
879
|
+
elif kind == "theme" and out.theme:
|
|
880
|
+
info = await client(
|
|
881
|
+
afn.GetThemeRequest(format="android", theme=tl.InputThemeSlug(slug=out.theme))
|
|
882
|
+
)
|
|
883
|
+
out.title = str(getattr(info, "title", "") or "")
|
|
884
|
+
elif kind == "wallpaper" and out.wallpaper:
|
|
885
|
+
info = await client(
|
|
886
|
+
afn.GetWallPaperRequest(wallpaper=tl.InputWallPaperSlug(slug=out.wallpaper))
|
|
887
|
+
)
|
|
888
|
+
out.opened = {"id": getattr(info, "id", None)}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
async def link(ctx: OpContext, req: LinkReq) -> ResolvedLink:
|
|
892
|
+
"""Normalise any t.me / tg:// link into a typed object.
|
|
893
|
+
|
|
894
|
+
Classification is local and always happens; `--open` adds the read that
|
|
895
|
+
matches the kind. Nothing here ever *acts*: joining, starting a bot,
|
|
896
|
+
installing a theme, enabling a proxy, applying a boost and redeeming a
|
|
897
|
+
gift are separate confirmed verbs, and `delegated_to` names the one this
|
|
898
|
+
link would need.
|
|
899
|
+
"""
|
|
900
|
+
from telethon.tl.functions import help as hfn
|
|
901
|
+
|
|
902
|
+
out = classify(req.url)
|
|
903
|
+
out.delegated_to = DELEGATES.get(out.kind)
|
|
904
|
+
out.requires_action = out.kind in DELEGATES and out.kind not in (
|
|
905
|
+
"public-username",
|
|
906
|
+
"message",
|
|
907
|
+
"private-post",
|
|
908
|
+
"phone",
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
if out.kind == "unknown" and out.scheme == "tg" and not req.no_network:
|
|
912
|
+
# Telegram adds deep links faster than any client learns them;
|
|
913
|
+
# `help.getDeepLinkInfo` is the server telling us what it means. The
|
|
914
|
+
# query is deliberately not sent — it can carry a token.
|
|
915
|
+
path = req.url.split("://", 1)[-1].split("?", 1)[0]
|
|
916
|
+
info = await client_of(ctx)(hfn.GetDeepLinkInfoRequest(path=path))
|
|
917
|
+
message = getattr(info, "message", None)
|
|
918
|
+
if message:
|
|
919
|
+
out.deeplink_info = str(message)
|
|
920
|
+
|
|
921
|
+
if req.no_network:
|
|
922
|
+
return out
|
|
923
|
+
if req.open:
|
|
924
|
+
try:
|
|
925
|
+
await _open(ctx, out)
|
|
926
|
+
except Exception as exc:
|
|
927
|
+
ctx.warn(f"--open could not read this link: {type(exc).__name__}: {exc}")
|
|
928
|
+
|
|
929
|
+
if req.draft is not None:
|
|
930
|
+
text = (out.share or {}).get("text") or (out.opened or {}).get("text")
|
|
931
|
+
if not text:
|
|
932
|
+
raise UsageError("this link carries no text to save as a draft", field="draft")
|
|
933
|
+
if getattr(ctx, "dry_run", False):
|
|
934
|
+
ctx.warn("--dry-run: the carried text would be saved as a draft")
|
|
935
|
+
else:
|
|
936
|
+
from telethon.tl.functions import messages as mfn
|
|
937
|
+
|
|
938
|
+
target = await _send.resolve(ctx, req.draft)
|
|
939
|
+
await client_of(ctx)(mfn.SaveDraftRequest(peer=target, message=str(text)))
|
|
940
|
+
out.draft_saved = True
|
|
941
|
+
return out
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
SPEC_LINK = OperationSpec(
|
|
945
|
+
id="resolve.link",
|
|
946
|
+
request=LinkReq,
|
|
947
|
+
response=ResolvedLink,
|
|
948
|
+
impl=link,
|
|
949
|
+
summary="Normalise any t.me / tg:// link into a typed JSON object",
|
|
950
|
+
description=(
|
|
951
|
+
"One dispatcher and one discriminated union. `t.me/+X` is a PHONE "
|
|
952
|
+
"when X parses as a number and an invite hash otherwise. "
|
|
953
|
+
"`t.me/c/<id>/<msg>` carries a bare channel id, so the access hash "
|
|
954
|
+
"must come from this account's peer cache — it fails loudly rather "
|
|
955
|
+
"than guessing. Resolution NEVER acts: `delegated_to` names the "
|
|
956
|
+
"command that would."
|
|
957
|
+
),
|
|
958
|
+
rate_class="resolve",
|
|
959
|
+
tags=frozenset({"mutating-checked"}),
|
|
960
|
+
columns=("kind", "username", "msg_id", "delegated_to"),
|
|
961
|
+
example={
|
|
962
|
+
"kind": "message",
|
|
963
|
+
"raw_url": "https://t.me/alice/4210",
|
|
964
|
+
"scheme": "tme",
|
|
965
|
+
"username": "alice",
|
|
966
|
+
"msg_id": 4210,
|
|
967
|
+
"delegated_to": "message get",
|
|
968
|
+
},
|
|
969
|
+
example_args="resolve link https://t.me/alice/4210",
|
|
970
|
+
covers=(
|
|
971
|
+
"contact.share-token",
|
|
972
|
+
"contacts-users.contacts-deeplink-sections",
|
|
973
|
+
"contacts-users.resolve-account-maintenance-links",
|
|
974
|
+
"contacts-users.resolve-boost-link",
|
|
975
|
+
"contacts-users.resolve-bot-start-link",
|
|
976
|
+
"contacts-users.resolve-business-chat-link",
|
|
977
|
+
"contacts-users.resolve-deeplink",
|
|
978
|
+
"contacts-users.resolve-gift-link",
|
|
979
|
+
"contacts-users.resolve-invite-link",
|
|
980
|
+
"contacts-users.resolve-message-link",
|
|
981
|
+
"contacts-users.resolve-proxy-link",
|
|
982
|
+
"contacts-users.resolve-share-url-link",
|
|
983
|
+
"contacts-users.resolve-stickerset-link",
|
|
984
|
+
"contacts-users.resolve-story-link",
|
|
985
|
+
"contacts-users.resolve-theme-wallpaper-link",
|
|
986
|
+
"contacts-users.resolve-unknown-deeplink",
|
|
987
|
+
"dialogs.business-link-resolve",
|
|
988
|
+
),
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
# ---------------------------------------------------------------------------
|
|
993
|
+
# resolve cache get
|
|
994
|
+
# ---------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
class CacheGetReq(Request):
|
|
998
|
+
type: Annotated[
|
|
999
|
+
str | None, choice("user", "bot", "group", "channel", help="Only entries of this kind.")
|
|
1000
|
+
] = None
|
|
1001
|
+
refresh: Annotated[
|
|
1002
|
+
list[PeerRef],
|
|
1003
|
+
opt("--refresh", metavar="PEER", kind="peer", help="Re-fetch these peers."),
|
|
1004
|
+
] = []
|
|
1005
|
+
purge: Annotated[
|
|
1006
|
+
bool, opt("--purge", help="Drop cached entries (never the session auth key).")
|
|
1007
|
+
] = False
|
|
1008
|
+
stale: Annotated[
|
|
1009
|
+
str | None,
|
|
1010
|
+
opt("--stale", metavar="DURATION", kind="duration", help="Only entries older than this."),
|
|
1011
|
+
] = None
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
async def cache_get(ctx: OpContext, req: CacheGetReq) -> Page[CachedPeerRow]:
|
|
1015
|
+
"""Inspect, refresh or purge this account's peer database.
|
|
1016
|
+
|
|
1017
|
+
The cache is what makes a bare numeric id addressable at all, and it is
|
|
1018
|
+
per account: an access hash minted for one login is meaningless to
|
|
1019
|
+
another, which is why this never prints one and why `--purge` is scoped
|
|
1020
|
+
to the resolver's own store and never touches the session.
|
|
1021
|
+
|
|
1022
|
+
`min_context` is the `(chat, message)` where a `min` user was seen.
|
|
1023
|
+
Telethon records none, so tlgr keeps it; without it a stranger who posted
|
|
1024
|
+
in a channel cannot be addressed at all.
|
|
1025
|
+
"""
|
|
1026
|
+
from tlgr.core.timefmt import parse_duration
|
|
1027
|
+
|
|
1028
|
+
resolver = getattr(ctx, "resolver", None)
|
|
1029
|
+
if resolver is None: # pragma: no cover - the daemon always supplies one
|
|
1030
|
+
raise UsageError("no peer resolver is available in this context")
|
|
1031
|
+
cache = resolver.cache
|
|
1032
|
+
|
|
1033
|
+
refreshed: set[int] = set()
|
|
1034
|
+
if req.refresh:
|
|
1035
|
+
if getattr(ctx, "dry_run", False):
|
|
1036
|
+
ctx.warn(f"--dry-run: {len(req.refresh)} peers would be re-fetched")
|
|
1037
|
+
else:
|
|
1038
|
+
for ref in req.refresh:
|
|
1039
|
+
target = await resolver.resolve(ref)
|
|
1040
|
+
with contextlib.suppress(Exception):
|
|
1041
|
+
entity = await client_of(ctx).get_entity(target)
|
|
1042
|
+
resolver._remember(entity)
|
|
1043
|
+
refreshed.add(_send.peer_id_of(target))
|
|
1044
|
+
|
|
1045
|
+
cutoff = 0.0
|
|
1046
|
+
if req.stale:
|
|
1047
|
+
seconds = parse_duration(req.stale)
|
|
1048
|
+
if seconds is None:
|
|
1049
|
+
raise UsageError(f"--stale: cannot read {req.stale!r} as a duration", field="stale")
|
|
1050
|
+
cutoff = time.time() - float(seconds)
|
|
1051
|
+
|
|
1052
|
+
rows: list[CachedPeerRow] = []
|
|
1053
|
+
for entry in list(cache.by_id.values()):
|
|
1054
|
+
if req.type and not _kind_matches(entry.kind, req.type):
|
|
1055
|
+
continue
|
|
1056
|
+
if cutoff and entry.resolved_at > cutoff:
|
|
1057
|
+
continue
|
|
1058
|
+
seen = float(entry.resolved_at or 0.0)
|
|
1059
|
+
rows.append(
|
|
1060
|
+
CachedPeerRow(
|
|
1061
|
+
id=abs(int(entry.peer_id)),
|
|
1062
|
+
marked_id=int(entry.peer_id),
|
|
1063
|
+
type=entry.kind,
|
|
1064
|
+
username=entry.username or None,
|
|
1065
|
+
access_hash_cached=bool(entry.access_hash),
|
|
1066
|
+
min=not entry.access_hash and bool(entry.from_message),
|
|
1067
|
+
min_context=(
|
|
1068
|
+
f"{entry.from_chat}:{entry.from_message}" if entry.from_message else None
|
|
1069
|
+
),
|
|
1070
|
+
seen_at=fmt_dt(datetime.fromtimestamp(seen, tz=timezone.utc)) if seen else None,
|
|
1071
|
+
seen_at_unix=int(seen) if seen else None,
|
|
1072
|
+
refreshed=int(entry.peer_id) in refreshed,
|
|
1073
|
+
)
|
|
1074
|
+
)
|
|
1075
|
+
rows.sort(key=lambda row: (-(row.seen_at_unix or 0), row.marked_id))
|
|
1076
|
+
|
|
1077
|
+
purged = 0
|
|
1078
|
+
if req.purge:
|
|
1079
|
+
if getattr(ctx, "dry_run", False):
|
|
1080
|
+
ctx.warn(f"--dry-run: {len(rows)} cache entries would be dropped")
|
|
1081
|
+
else:
|
|
1082
|
+
for row in rows:
|
|
1083
|
+
entry = cache.by_id.pop(row.marked_id, None)
|
|
1084
|
+
if entry is not None:
|
|
1085
|
+
purged += 1
|
|
1086
|
+
if entry.username:
|
|
1087
|
+
cache.by_username.pop(entry.username.lower(), None)
|
|
1088
|
+
row.purged = True
|
|
1089
|
+
cache._dirty = True
|
|
1090
|
+
cache.save()
|
|
1091
|
+
ctx.emit("peer_cache_purge", {"count": purged})
|
|
1092
|
+
|
|
1093
|
+
limit = int(getattr(ctx, "limit", None) or 200)
|
|
1094
|
+
token = getattr(ctx, "cursor", None)
|
|
1095
|
+
offset = 0
|
|
1096
|
+
if token:
|
|
1097
|
+
offset = int(
|
|
1098
|
+
decode_cursor(
|
|
1099
|
+
token, op="resolve.cache.get", kind=PageKind.LOCAL, account=ctx.account
|
|
1100
|
+
).get("offset", 0)
|
|
1101
|
+
or 0
|
|
1102
|
+
)
|
|
1103
|
+
window = rows[offset : offset + limit]
|
|
1104
|
+
return build_page(
|
|
1105
|
+
window,
|
|
1106
|
+
op="resolve.cache.get",
|
|
1107
|
+
kind=PageKind.LOCAL,
|
|
1108
|
+
state={"offset": offset + len(window)},
|
|
1109
|
+
account=ctx.account,
|
|
1110
|
+
has_more=offset + len(window) < len(rows),
|
|
1111
|
+
total=len(rows),
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
SPEC_CACHE_GET = OperationSpec(
|
|
1116
|
+
id="resolve.cache.get",
|
|
1117
|
+
request=CacheGetReq,
|
|
1118
|
+
response=Page[CachedPeerRow],
|
|
1119
|
+
impl=cache_get,
|
|
1120
|
+
summary="Inspect, refresh or purge the local peer database",
|
|
1121
|
+
description=(
|
|
1122
|
+
"Access-hash priority is full > min > from-message > none. Telethon "
|
|
1123
|
+
"skips `min` entities in both of its caches, so tlgr records the "
|
|
1124
|
+
"(peer, msg_id) context itself — that is what makes a `chat posters` "
|
|
1125
|
+
"follow-up possible. Hashes are per login session: never printed, "
|
|
1126
|
+
"never copied between accounts. `--purge` drops cache rows only; the "
|
|
1127
|
+
"session and its auth key are untouched."
|
|
1128
|
+
),
|
|
1129
|
+
paginated=PageKind.LOCAL,
|
|
1130
|
+
rate_class="local",
|
|
1131
|
+
tags=frozenset({"mutating-checked"}),
|
|
1132
|
+
columns=("marked_id", "type", "username", "access_hash_cached", "seen_at"),
|
|
1133
|
+
headers=("Id", "Kind", "Username", "Hash", "Seen"),
|
|
1134
|
+
example={
|
|
1135
|
+
"items": [{"id": 777123, "marked_id": 777123, "type": "user", "access_hash_cached": True}],
|
|
1136
|
+
"has_more": False,
|
|
1137
|
+
},
|
|
1138
|
+
example_args="resolve cache get",
|
|
1139
|
+
covers=("contacts-users.peer-cache",),
|
|
1140
|
+
)
|