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/proxy.py ADDED
@@ -0,0 +1,750 @@
1
+ """The `proxy` group: saved proxies, and which one the daemon connects through.
2
+
3
+ A Telethon client takes its proxy at construction time and cannot change it,
4
+ so "switch proxy" is really "rebuild the client, reconnect, catch up". That is
5
+ why `proxy set` is a daemon operation with a reconnect in it rather than a
6
+ config write, and why `proxy test` uses a throwaway session: a probe that
7
+ became the account's update-receiving connection would divert its events.
8
+
9
+ Credentials live in `~/.tlgr/proxies.json` at mode 0600 and never appear in
10
+ argv, in a list, or in a log. `proxy link` is the single command that prints
11
+ them, and it says so.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import time
19
+ from typing import Annotated, Any
20
+ from urllib.parse import parse_qs, urlencode, urlparse
21
+
22
+ from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError
23
+ from tlgr.core.pagination import PageKind, build_page
24
+ from tlgr.models.base import Request
25
+ from tlgr.models.net import Proxy, ProxyLink, ProxyProbe, ProxySelection
26
+ from tlgr.models.page import Page
27
+ from tlgr.ops._params import arg, choice, opt
28
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
29
+
30
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
31
+
32
+ _TYPES = ("socks5", "http", "mtproxy")
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # The store
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def _store_path() -> Any:
41
+ from tlgr.core.paths import TlgrPaths
42
+
43
+ return TlgrPaths().proxies
44
+
45
+
46
+ def _load() -> dict[str, Any]:
47
+ path = _store_path()
48
+ if not path.exists():
49
+ return {"active": None, "proxies": []}
50
+ try:
51
+ loaded = json.loads(path.read_text(encoding="utf-8"))
52
+ except (OSError, json.JSONDecodeError) as exc:
53
+ raise UsageError(f"{path} is not readable JSON: {exc}") from exc
54
+ if not isinstance(loaded, dict):
55
+ raise UsageError(f"{path} must be a JSON object")
56
+ loaded.setdefault("proxies", [])
57
+ loaded.setdefault("active", None)
58
+ return loaded
59
+
60
+
61
+ def _save(document: dict[str, Any]) -> None:
62
+ from tlgr.core.paths import write_private
63
+
64
+ write_private(_store_path(), json.dumps(document, indent=2))
65
+
66
+
67
+ def _entry(document: dict[str, Any], identifier: str) -> dict[str, Any]:
68
+ for entry in document["proxies"]:
69
+ if isinstance(entry, dict) and (
70
+ entry.get("id") == identifier or entry.get("name") == identifier
71
+ ):
72
+ return entry
73
+ raise NotFoundError(f"no saved proxy {identifier!r}. Run: tlgr proxy list")
74
+
75
+
76
+ def _model(entry: dict[str, Any], active: str | None) -> Proxy:
77
+ return Proxy(
78
+ id=str(entry.get("id", "")),
79
+ name=str(entry.get("name", "") or ""),
80
+ type=str(entry.get("type", "socks5")),
81
+ host=str(entry.get("host", "")),
82
+ port=int(entry.get("port", 0) or 0),
83
+ user=entry.get("user") or None,
84
+ rdns=bool(entry.get("rdns", True)),
85
+ active=entry.get("id") == active,
86
+ order=int(entry.get("order", 0) or 0),
87
+ last_ping_ms=entry.get("last_ping_ms"),
88
+ last_ok_at=entry.get("last_ok_at"),
89
+ failures=int(entry.get("failures", 0) or 0),
90
+ has_password=bool(entry.get("password")),
91
+ has_secret=bool(entry.get("secret")),
92
+ )
93
+
94
+
95
+ def _next_id(document: dict[str, Any]) -> str:
96
+ used = {str(entry.get("id", "")) for entry in document["proxies"]}
97
+ index = 1
98
+ while f"p{index}" in used:
99
+ index += 1
100
+ return f"p{index}"
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Links
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def parse_proxy_link(link: str) -> dict[str, Any]:
109
+ """`tg://proxy?…` or `https://t.me/proxy?…` → the fields of one proxy.
110
+
111
+ Both spellings and both secret encodings (hex and base64url, with the
112
+ `dd`/`ee` prefixes) are accepted, because a user pastes whatever their
113
+ channel gave them and a parser that took only one form would be a puzzle
114
+ rather than a feature.
115
+ """
116
+ parsed = urlparse(link.strip())
117
+ kind = (parsed.netloc or parsed.path.strip("/")).lower()
118
+ if parsed.scheme not in ("tg", "http", "https") or kind not in ("proxy", "socks"):
119
+ raise UsageError(f"{link!r} is not a tg://proxy or t.me/proxy link", field="link")
120
+ query = {key: values[0] for key, values in parse_qs(parsed.query).items() if values}
121
+ server = query.get("server")
122
+ port = query.get("port")
123
+ if not server or not port:
124
+ raise UsageError("a proxy link needs both `server` and `port`", field="link")
125
+ entry: dict[str, Any] = {"host": server, "port": int(port)}
126
+ if query.get("secret"):
127
+ entry["type"] = "mtproxy"
128
+ entry["secret"] = query["secret"]
129
+ else:
130
+ entry["type"] = "socks5"
131
+ if query.get("user"):
132
+ entry["user"] = query["user"]
133
+ if query.get("pass"):
134
+ entry["password"] = query["pass"]
135
+ return entry
136
+
137
+
138
+ def _to_link(entry: dict[str, Any], form: str) -> str:
139
+ query: dict[str, str] = {
140
+ "server": str(entry.get("host", "")),
141
+ "port": str(entry.get("port", 0)),
142
+ }
143
+ if entry.get("type") == "mtproxy":
144
+ query["secret"] = str(entry.get("secret", ""))
145
+ else:
146
+ if entry.get("user"):
147
+ query["user"] = str(entry["user"])
148
+ if entry.get("password"):
149
+ query["pass"] = str(entry["password"])
150
+ base = "https://t.me/proxy" if form == "t.me" else "tg://proxy"
151
+ return f"{base}?{urlencode(query)}"
152
+
153
+
154
+ def _telethon_proxy(entry: dict[str, Any]) -> tuple[Any, Any]:
155
+ """`(proxy, connection class)` for a Telethon client.
156
+
157
+ MTProxy is a different *connection*, not a different proxy tuple, which is
158
+ the part that catches people out: passing an MTProxy secret as a SOCKS
159
+ password produces a connection that fails with nothing that names the
160
+ cause.
161
+ """
162
+ kind = str(entry.get("type", "socks5"))
163
+ if kind == "mtproxy":
164
+ from telethon.network import ConnectionTcpMTProxyRandomizedIntermediate
165
+
166
+ return (
167
+ (str(entry.get("host", "")), int(entry.get("port", 0)), str(entry.get("secret", ""))),
168
+ ConnectionTcpMTProxyRandomizedIntermediate,
169
+ )
170
+ proxy: dict[str, Any] = {
171
+ "proxy_type": kind,
172
+ "addr": str(entry.get("host", "")),
173
+ "port": int(entry.get("port", 0)),
174
+ "rdns": bool(entry.get("rdns", True)),
175
+ }
176
+ if entry.get("user"):
177
+ proxy["username"] = str(entry["user"])
178
+ if entry.get("password"):
179
+ proxy["password"] = str(entry["password"])
180
+ return proxy, None
181
+
182
+
183
+ def _proxy_url(entry: dict[str, Any]) -> str:
184
+ """The `[network] proxy` spelling `SessionManager._proxy_tuple` parses."""
185
+ kind = str(entry.get("type", "socks5"))
186
+ host = str(entry.get("host", ""))
187
+ port = int(entry.get("port", 0))
188
+ if kind == "mtproxy":
189
+ return f"mtproxy://{host}:{port}#{entry.get('secret', '')}"
190
+ credentials = ""
191
+ if entry.get("user"):
192
+ credentials = str(entry["user"])
193
+ if entry.get("password"):
194
+ credentials += f":{entry['password']}"
195
+ credentials += "@"
196
+ return f"{kind}://{credentials}{host}:{port}"
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # proxy add
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ class ProxyAddReq(Request):
205
+ link: Annotated[
206
+ str | None,
207
+ arg(0, metavar="LINK", required=False, help="tg://proxy?… or https://t.me/proxy?…"),
208
+ ] = None
209
+ type: Annotated[str | None, choice(*_TYPES, help="Proxy kind.")] = None
210
+ host: Annotated[str | None, opt("--host", metavar="HOST")] = None
211
+ port: Annotated[int | None, opt("--port", metavar="PORT")] = None
212
+ user: Annotated[str | None, opt("--user", metavar="NAME", help="SOCKS5/HTTP username.")] = None
213
+ password: Annotated[
214
+ str | None,
215
+ opt(
216
+ "--password",
217
+ secret=True,
218
+ envvar="TLGR_PROXY_PASSWORD",
219
+ help="SOCKS5/HTTP password.",
220
+ ),
221
+ ] = None
222
+ secret: Annotated[
223
+ str | None,
224
+ opt(
225
+ "--secret",
226
+ secret=True,
227
+ envvar="TLGR_PROXY_SECRET",
228
+ help="MTProxy secret (hex or base64url).",
229
+ ),
230
+ ] = None
231
+ name: Annotated[str | None, opt("--name", metavar="LABEL")] = None
232
+ rdns: Annotated[bool, opt("--rdns/--no-rdns", help="Resolve hostnames through the proxy.")] = (
233
+ True
234
+ )
235
+ activate: Annotated[
236
+ bool, opt("--set", help="Make it the active proxy immediately (reconnects).")
237
+ ] = False
238
+
239
+
240
+ async def proxy_add(ctx: OpContext, req: ProxyAddReq) -> Proxy:
241
+ """Save a proxy, from a link or from flags.
242
+
243
+ Secrets never arrive as argv — `ps` is world-readable and shell history is
244
+ forever — so `--password` and `--secret` are the `-env`/`-stdin`/`-file`
245
+ triples every secret field generates (STYLE §3).
246
+ """
247
+ entry: dict[str, Any] = {}
248
+ if req.link:
249
+ entry.update(parse_proxy_link(req.link))
250
+ if req.type:
251
+ entry["type"] = req.type
252
+ if req.host:
253
+ entry["host"] = req.host
254
+ if req.port:
255
+ entry["port"] = req.port
256
+ if req.user:
257
+ entry["user"] = req.user
258
+ if req.password:
259
+ entry["password"] = req.password
260
+ if req.secret:
261
+ entry["secret"] = req.secret
262
+ entry.setdefault("type", "mtproxy")
263
+ entry.setdefault("type", "socks5")
264
+ entry["rdns"] = req.rdns
265
+ if req.name:
266
+ entry["name"] = req.name
267
+
268
+ if not entry.get("host") or not entry.get("port"):
269
+ raise UsageError(
270
+ "a proxy needs a host and a port: pass a tg://proxy link, or --host and --port",
271
+ field="host",
272
+ )
273
+ if entry["type"] == "mtproxy" and not entry.get("secret"):
274
+ raise UsageError(
275
+ "an MTProxy needs its secret: --secret-env, --secret-stdin or --secret-file",
276
+ field="secret",
277
+ )
278
+
279
+ document = _load()
280
+ entry["id"] = _next_id(document)
281
+ entry["order"] = len(document["proxies"])
282
+ document["proxies"].append(entry)
283
+ if req.activate:
284
+ document["active"] = entry["id"]
285
+ _save(document)
286
+
287
+ model = _model(entry, document["active"])
288
+ if req.activate:
289
+ _write_network_proxy(_proxy_url(entry))
290
+ ctx.warn("the daemon adopts the new proxy on its next reconnect: tlgr daemon reconnect")
291
+ if entry["type"] == "mtproxy":
292
+ ctx.warn(
293
+ "Telethon's MTProxy support is experimental and cannot use proxies that require SSL"
294
+ )
295
+ return model
296
+
297
+
298
+ def _write_network_proxy(url: str) -> None:
299
+ from tlgr.core.config import _load_toml, _save_toml
300
+ from tlgr.core.paths import TlgrPaths
301
+
302
+ path = TlgrPaths().config
303
+ document = _load_toml(path)
304
+ document.setdefault("network", {})["proxy"] = url
305
+ _save_toml(path, document)
306
+
307
+
308
+ SPEC_PROXY_ADD = OperationSpec(
309
+ id="proxy.add",
310
+ request=ProxyAddReq,
311
+ response=Proxy,
312
+ impl=proxy_add,
313
+ summary="Save a proxy",
314
+ description=(
315
+ "Accepts both `tg://proxy?…` and `https://t.me/proxy?…`, and both "
316
+ "secret encodings. Secrets are read from an environment variable, a "
317
+ "file or stdin — never argv."
318
+ ),
319
+ aliases=("net.proxy.add",),
320
+ mutating=True,
321
+ needs_account=False,
322
+ needs_auth=False,
323
+ needs_client=False,
324
+ surface=Surface.LOCAL,
325
+ rate_class="local",
326
+ timeout_s=30,
327
+ columns=("id", "name", "type", "host", "port", "active"),
328
+ example={"id": "p1", "type": "socks5", "host": "10.0.0.5", "port": 1080, "active": False},
329
+ example_args="proxy add 'tg://proxy?server=1.2.3.4&port=443&secret=dd00' --set",
330
+ covers=("updates.net-proxy-mtproxy", "updates.net-proxy-socks5"),
331
+ covers_partial=(
332
+ "updates.net-proxy-http",
333
+ "updates.net-proxy-list",
334
+ "updates.net-proxy-share-link",
335
+ ),
336
+ coverage_note=(
337
+ "saves one; choosing it is `proxy set`, listing is `proxy list`, and "
338
+ "printing the shareable link is `proxy link`."
339
+ ),
340
+ tags=frozenset({"agent-safe"}),
341
+ )
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # proxy list / remove / link
346
+ # ---------------------------------------------------------------------------
347
+
348
+
349
+ class ProxyListReq(Request):
350
+ type: Annotated[str | None, choice(*_TYPES, help="Filter by kind.")] = None
351
+ active_only: Annotated[bool, opt("--active-only", help="Only the proxy currently in use.")] = (
352
+ False
353
+ )
354
+
355
+
356
+ async def proxy_list(ctx: OpContext, req: ProxyListReq) -> Page[Proxy]:
357
+ """Saved proxies. Credentials are reported as present, never printed."""
358
+ document = _load()
359
+ rows = [_model(entry, document["active"]) for entry in document["proxies"]]
360
+ if req.type:
361
+ rows = [row for row in rows if row.type == req.type]
362
+ if req.active_only:
363
+ rows = [row for row in rows if row.active]
364
+ rows.sort(key=lambda row: row.order)
365
+ return build_page(rows, op="proxy.list", kind=PageKind.LOCAL, has_more=False, total=len(rows))
366
+
367
+
368
+ SPEC_PROXY_LIST = OperationSpec(
369
+ id="proxy.list",
370
+ request=ProxyListReq,
371
+ response=Page[Proxy],
372
+ impl=proxy_list,
373
+ summary="List saved proxies",
374
+ description=(
375
+ "`order` is the failover order. `has_password`/`has_secret` say a "
376
+ "credential exists without printing it; only `proxy link` does that."
377
+ ),
378
+ aliases=("net.proxy.list",),
379
+ paginated=PageKind.LOCAL,
380
+ needs_account=False,
381
+ needs_auth=False,
382
+ needs_client=False,
383
+ surface=Surface.LOCAL,
384
+ idempotent=True,
385
+ rate_class="local",
386
+ timeout_s=15,
387
+ columns=("id", "name", "type", "host", "port", "active", "last_ping_ms"),
388
+ example={
389
+ "items": [{"id": "p1", "type": "socks5", "host": "10.0.0.5", "port": 1080}],
390
+ "has_more": False,
391
+ },
392
+ example_args="proxy list",
393
+ covers_partial=("updates.net-proxy-autoswitch", "updates.net-proxy-list"),
394
+ coverage_note="lists them; failover is `proxy test --reorder` plus `proxy set`.",
395
+ tags=frozenset({"agent-safe"}),
396
+ )
397
+
398
+
399
+ class ProxyIdReq(Request):
400
+ id: Annotated[str, arg(0, metavar="ID")]
401
+
402
+
403
+ async def proxy_remove(ctx: OpContext, req: ProxyIdReq) -> ProxySelection:
404
+ """Delete a saved proxy, and say whether it was the active one."""
405
+ document = _load()
406
+ entry = _entry(document, req.id)
407
+ was_active = document["active"] == entry.get("id")
408
+ document["proxies"] = [row for row in document["proxies"] if row is not entry]
409
+ if was_active:
410
+ document["active"] = None
411
+ _write_network_proxy("")
412
+ ctx.warn(
413
+ "the active proxy was removed; the daemon falls back to a direct "
414
+ "connection on its next reconnect"
415
+ )
416
+ _save(document)
417
+ return ProxySelection(removed=True, was_active=was_active, active=document["active"])
418
+
419
+
420
+ SPEC_PROXY_REMOVE = OperationSpec(
421
+ id="proxy.remove",
422
+ request=ProxyIdReq,
423
+ response=ProxySelection,
424
+ impl=proxy_remove,
425
+ summary="Delete a saved proxy",
426
+ aliases=("net.proxy.remove",),
427
+ mutating=True,
428
+ destructive=True,
429
+ needs_account=False,
430
+ needs_auth=False,
431
+ needs_client=False,
432
+ surface=Surface.LOCAL,
433
+ rate_class="local",
434
+ timeout_s=15,
435
+ example={"removed": True, "was_active": False},
436
+ example_args="proxy remove p1",
437
+ covers=("updates.net-proxy-list",),
438
+ tags=frozenset({"agent-safe"}),
439
+ )
440
+
441
+
442
+ class ProxyLinkReq(Request):
443
+ id: Annotated[str, arg(0, metavar="ID")]
444
+ form: Annotated[str, choice("tg", "t.me", help="Link flavour.")] = "tg"
445
+
446
+
447
+ async def proxy_link(ctx: OpContext, req: ProxyLinkReq) -> ProxyLink:
448
+ """Print a saved proxy as a shareable link.
449
+
450
+ The only command that prints proxy credentials, and it is destructive in
451
+ the sense that matters: the link *is* the password. It is marked so that
452
+ it asks off a TTY.
453
+ """
454
+ document = _load()
455
+ entry = _entry(document, req.id)
456
+ ctx.warn("this link contains the proxy's credentials; treat it as a secret")
457
+ return ProxyLink(
458
+ id=str(entry.get("id", "")),
459
+ link=_to_link(entry, req.form),
460
+ type=str(entry.get("type", "")),
461
+ host=str(entry.get("host", "")),
462
+ port=int(entry.get("port", 0) or 0),
463
+ )
464
+
465
+
466
+ SPEC_PROXY_LINK = OperationSpec(
467
+ id="proxy.link",
468
+ request=ProxyLinkReq,
469
+ response=ProxyLink,
470
+ impl=proxy_link,
471
+ summary="Print a saved proxy as a shareable tg:// link",
472
+ description="The link embeds the password or MTProxy secret. Confirm off a TTY.",
473
+ aliases=("net.proxy.link", "proxy.export"),
474
+ mutating=True,
475
+ destructive=True,
476
+ needs_account=False,
477
+ needs_auth=False,
478
+ needs_client=False,
479
+ surface=Surface.LOCAL,
480
+ rate_class="local",
481
+ timeout_s=15,
482
+ columns=("id", "link"),
483
+ example={"id": "p1", "link": "tg://proxy?server=1.2.3.4&port=443&secret=dd00"},
484
+ example_args="proxy link p1 --yes",
485
+ covers=("updates.net-proxy-share-link",),
486
+ tags=frozenset({"agent-safe", "mutating-checked"}),
487
+ )
488
+
489
+
490
+ # ---------------------------------------------------------------------------
491
+ # proxy set
492
+ # ---------------------------------------------------------------------------
493
+
494
+
495
+ class ProxySetReq(Request):
496
+ id: Annotated[
497
+ str,
498
+ arg(0, metavar="ID", help="A saved id, `none` for a direct connection, or `system`."),
499
+ ]
500
+ reconnect: Annotated[
501
+ bool, opt("--reconnect/--no-reconnect", help="Reconnect now rather than on the next start.")
502
+ ] = True
503
+
504
+
505
+ async def proxy_set(ctx: OpContext, req: ProxySetReq) -> ProxySelection:
506
+ """Choose the proxy the daemon connects through, and reconnect.
507
+
508
+ A Telethon client takes its proxy at construction, so switching means
509
+ rebuilding the client and reconnecting — and then catching up, because the
510
+ account was deaf for the moment it took.
511
+
512
+ `system` reads `ALL_PROXY`/`HTTPS_PROXY`. `NO_PROXY` is meaningless here —
513
+ there is exactly one destination — and is documented as ignored rather
514
+ than silently honoured.
515
+ """
516
+ document = _load()
517
+ if req.id == "none":
518
+ document["active"] = None
519
+ _save(document)
520
+ _write_network_proxy("")
521
+ selection = ProxySelection(active=None)
522
+ elif req.id == "system":
523
+ url = _system_proxy()
524
+ if not url:
525
+ raise NotFoundError(
526
+ "no ALL_PROXY or HTTPS_PROXY is set in the environment; "
527
+ "NO_PROXY is ignored (there is one destination)"
528
+ )
529
+ document["active"] = None
530
+ _save(document)
531
+ _write_network_proxy(url)
532
+ parsed = urlparse(url)
533
+ selection = ProxySelection(
534
+ active="system",
535
+ type=parsed.scheme,
536
+ host=parsed.hostname or "",
537
+ port=int(parsed.port or 0),
538
+ )
539
+ else:
540
+ entry = _entry(document, req.id)
541
+ document["active"] = entry.get("id")
542
+ _save(document)
543
+ _write_network_proxy(_proxy_url(entry))
544
+ selection = ProxySelection(
545
+ active=str(entry.get("id", "")),
546
+ type=str(entry.get("type", "")),
547
+ host=str(entry.get("host", "")),
548
+ port=int(entry.get("port", 0) or 0),
549
+ )
550
+
551
+ if req.reconnect:
552
+ selection.accounts = await _reconnect_all(ctx)
553
+ selection.reconnected = bool(selection.accounts)
554
+ if not selection.reconnected:
555
+ ctx.warn("the proxy is saved; the daemon adopts it on its next reconnect")
556
+ return selection
557
+
558
+
559
+ def _system_proxy() -> str:
560
+ import os
561
+
562
+ for name in ("ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy"):
563
+ value = os.environ.get(name, "").strip()
564
+ if value:
565
+ return value
566
+ return ""
567
+
568
+
569
+ async def _reconnect_all(ctx: OpContext) -> list[str]:
570
+ """Rebuild every session so the new proxy takes effect, then catch up."""
571
+ daemon = getattr(ctx, "daemon", None)
572
+ sessions = getattr(daemon, "sessions", None)
573
+ if sessions is None:
574
+ return []
575
+ reconnected: list[str] = []
576
+ for alias in list(getattr(sessions, "aliases", []) or []):
577
+ with contextlib.suppress(Exception):
578
+ await sessions.release(alias)
579
+ session = await sessions.ensure(alias)
580
+ await session.catch_up()
581
+ reconnected.append(alias)
582
+ return reconnected
583
+
584
+
585
+ SPEC_PROXY_SET = OperationSpec(
586
+ id="proxy.set",
587
+ request=ProxySetReq,
588
+ response=ProxySelection,
589
+ impl=proxy_set,
590
+ summary="Choose the proxy the daemon connects through",
591
+ description=(
592
+ "`none` is a direct connection; `system` reads ALL_PROXY/HTTPS_PROXY. "
593
+ "A Telethon client cannot change proxy in place, so this rebuilds the "
594
+ "client, reconnects and catches up."
595
+ ),
596
+ aliases=("net.proxy.set", "proxy.enable", "proxy.off"),
597
+ mutating=True,
598
+ needs_account=False,
599
+ needs_client=False,
600
+ surface=Surface.DAEMON,
601
+ rate_class="local",
602
+ timeout_s=180,
603
+ columns=("active", "type", "host", "port", "reconnected"),
604
+ example={"active": "p1", "type": "socks5", "host": "10.0.0.5", "reconnected": True},
605
+ example_args="proxy set p1",
606
+ covers=("updates.net-proxy-http", "updates.net-proxy-system"),
607
+ covers_partial=(
608
+ "updates.net-proxy-list",
609
+ "updates.net-proxy-mtproxy",
610
+ "updates.net-proxy-socks5",
611
+ ),
612
+ coverage_note="selects one; saving and describing them is `proxy add`/`proxy list`.",
613
+ tags=frozenset({"agent-safe"}),
614
+ )
615
+
616
+
617
+ # ---------------------------------------------------------------------------
618
+ # proxy test
619
+ # ---------------------------------------------------------------------------
620
+
621
+
622
+ class ProxyTestReq(Request):
623
+ id: Annotated[str | None, arg(0, metavar="ID", required=False)] = None
624
+ every: Annotated[bool, opt("--every", help="Test every saved proxy.")] = False
625
+ probe_timeout: Annotated[int, opt("--probe-timeout", metavar="SECONDS", ge=1, le=120)] = 10
626
+ reorder: Annotated[
627
+ bool, opt("--reorder", help="Rewrite the failover order by measured latency.")
628
+ ] = False
629
+
630
+
631
+ async def proxy_test(ctx: OpContext, req: ProxyTestReq) -> Page[ProxyProbe]:
632
+ """Probe a proxy and measure its latency.
633
+
634
+ Through a *scratch* session, deliberately. Updates go to the last active
635
+ connection, so a probe built on the account's real session could quietly
636
+ divert its events to a client that is about to be thrown away.
637
+ """
638
+ document = _load()
639
+ if req.every:
640
+ entries = [entry for entry in document["proxies"] if isinstance(entry, dict)]
641
+ elif req.id:
642
+ entries = [_entry(document, req.id)]
643
+ elif document["active"]:
644
+ entries = [_entry(document, str(document["active"]))]
645
+ else:
646
+ raise UsageError("name a proxy id, or pass --every", field="id")
647
+
648
+ rows: list[ProxyProbe] = []
649
+ for entry in entries:
650
+ rows.append(await _probe(ctx, entry, req.probe_timeout))
651
+
652
+ for row, entry in zip(rows, entries, strict=False):
653
+ if row.ok:
654
+ entry["last_ping_ms"] = row.ping_ms
655
+ entry["last_ok_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
656
+ entry["failures"] = 0
657
+ else:
658
+ entry["failures"] = int(entry.get("failures", 0) or 0) + 1
659
+
660
+ if req.reorder:
661
+ ranked = sorted(
662
+ document["proxies"],
663
+ key=lambda entry: (entry.get("last_ping_ms") is None, entry.get("last_ping_ms") or 0),
664
+ )
665
+ for order, entry in enumerate(ranked):
666
+ entry["order"] = order
667
+ document["proxies"] = ranked
668
+ _save(document)
669
+
670
+ if not any(row.ok for row in rows):
671
+ ctx.warn("no proxy answered; the network may be blocking them all")
672
+ return build_page(rows, op="proxy.test", kind=PageKind.LOCAL, has_more=False, total=len(rows))
673
+
674
+
675
+ async def _probe(ctx: OpContext, entry: dict[str, Any], timeout: int) -> ProxyProbe:
676
+ import asyncio
677
+
678
+ from telethon import TelegramClient
679
+ from telethon.sessions import MemorySession
680
+ from telethon.tl import functions
681
+
682
+ row = ProxyProbe(id=str(entry.get("id", "")), name=str(entry.get("name", "") or ""))
683
+ api_id, api_hash = _credentials(ctx)
684
+ if not api_id or not api_hash:
685
+ row.error = "no API credentials are registered; run `tlgr account add` first"
686
+ return row
687
+
688
+ proxy, connection = _telethon_proxy(entry)
689
+ kwargs: dict[str, Any] = {"proxy": proxy, "timeout": timeout, "connection_retries": 0}
690
+ if connection is not None:
691
+ kwargs["connection"] = connection
692
+ # A memory session, never the account's: a probe must not be able to
693
+ # become the connection Telegram delivers this account's updates to.
694
+ client = TelegramClient(MemorySession(), api_id, api_hash, **kwargs)
695
+ started = time.monotonic()
696
+ try:
697
+ await asyncio.wait_for(client.connect(), timeout=timeout)
698
+ result = await asyncio.wait_for(
699
+ client(functions.help.GetNearestDcRequest()), timeout=timeout
700
+ )
701
+ row.ok = True
702
+ row.ping_ms = round((time.monotonic() - started) * 1000, 2)
703
+ row.dc_id = int(getattr(result, "nearest_dc", 0) or 0) or None
704
+ except Exception as exc:
705
+ row.error = f"{type(exc).__name__}: {exc}"
706
+ finally:
707
+ with contextlib.suppress(Exception):
708
+ await client.disconnect()
709
+ return row
710
+
711
+
712
+ def _credentials(ctx: OpContext) -> tuple[int | None, str | None]:
713
+ from tlgr.core.accounts import AccountManager
714
+ from tlgr.core.paths import default_base
715
+
716
+ manager = AccountManager(default_base())
717
+ alias = ctx.account or manager.get_active() or ""
718
+ if not alias:
719
+ return None, None
720
+ with contextlib.suppress(Exception):
721
+ return manager.load_credentials(alias)
722
+ return None, None
723
+
724
+
725
+ SPEC_PROXY_TEST = OperationSpec(
726
+ id="proxy.test",
727
+ request=ProxyTestReq,
728
+ response=Page[ProxyProbe],
729
+ impl=proxy_test,
730
+ summary="Probe a proxy and measure its latency",
731
+ description=(
732
+ "Uses a throwaway in-memory session: updates go to the last active "
733
+ "connection, so a probe on the real session could divert the "
734
+ "account's events to a client that is about to be discarded."
735
+ ),
736
+ aliases=("net.proxy.test", "proxy.ping"),
737
+ paginated=PageKind.LOCAL,
738
+ mutating=True,
739
+ needs_account=False,
740
+ needs_client=False,
741
+ surface=Surface.DAEMON,
742
+ rate_class="local",
743
+ timeout_s=180,
744
+ columns=("id", "name", "ok", "ping_ms", "dc_id", "error"),
745
+ empty_exit=EXIT_EMPTY,
746
+ example={"items": [{"id": "p1", "ok": True, "ping_ms": 82.4, "dc_id": 4}], "has_more": False},
747
+ example_args="proxy test --every --reorder",
748
+ covers=("updates.net-proxy-autoswitch", "updates.net-proxy-ping"),
749
+ tags=frozenset({"agent-safe", "mutating-checked"}),
750
+ )