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