tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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
+ )