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/net.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""The `net` group: what the connection is actually doing.
|
|
2
|
+
|
|
3
|
+
Separate from `daemon status`, which is about the process, and from `config
|
|
4
|
+
server get`, which is about Telegram's settings. This is about *this socket*:
|
|
5
|
+
which data centre, which transport, through which proxy, how far the clock has
|
|
6
|
+
drifted, and how long a round trip takes.
|
|
7
|
+
|
|
8
|
+
The clock is the field worth explaining. MTProto stamps every request with a
|
|
9
|
+
`msg_id` derived from the local time, and the server rejects one outside a
|
|
10
|
+
window of a few minutes — silently, as far as the client is concerned. An
|
|
11
|
+
account whose host clock has drifted therefore stops working with no error
|
|
12
|
+
anybody can read, which is why `time_offset_seconds` is reported and warned
|
|
13
|
+
about rather than left for somebody to guess at.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import time
|
|
20
|
+
from typing import Annotated, Any
|
|
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 (
|
|
26
|
+
DcOption,
|
|
27
|
+
NearestDc,
|
|
28
|
+
NetStatus,
|
|
29
|
+
NetUsage,
|
|
30
|
+
PingResult,
|
|
31
|
+
SyncCursors,
|
|
32
|
+
)
|
|
33
|
+
from tlgr.models.page import Page
|
|
34
|
+
from tlgr.ops._params import choice, opt
|
|
35
|
+
from tlgr.ops._spec import OpContext, OperationSpec, Surface
|
|
36
|
+
|
|
37
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _client(ctx: OpContext) -> Any:
|
|
41
|
+
client = getattr(ctx, "client", None)
|
|
42
|
+
if client is None:
|
|
43
|
+
raise UsageError("this operation needs a connected account")
|
|
44
|
+
return client
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sessions(ctx: OpContext) -> Any:
|
|
48
|
+
daemon = getattr(ctx, "daemon", None)
|
|
49
|
+
return getattr(daemon, "sessions", None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _spanned(ctx: OpContext) -> list[str]:
|
|
53
|
+
alias = (ctx.account or "").strip()
|
|
54
|
+
sessions = _sessions(ctx)
|
|
55
|
+
known = list(getattr(sessions, "aliases", []) or [])
|
|
56
|
+
if alias and alias != "all":
|
|
57
|
+
return [alias]
|
|
58
|
+
return known
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _session_client(ctx: OpContext, alias: str) -> Any:
|
|
62
|
+
sessions = _sessions(ctx)
|
|
63
|
+
session = sessions.get(alias) if sessions is not None else None
|
|
64
|
+
return getattr(session, "client", None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# net dc list / nearest
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DcListReq(Request):
|
|
73
|
+
ipv6: Annotated[bool, opt("--ipv6", help="Only IPv6 endpoints.")] = False
|
|
74
|
+
media_only: Annotated[bool, opt("--media-only", help="Only media endpoints.")] = False
|
|
75
|
+
cdn: Annotated[bool, opt("--cdn", help="Only CDN endpoints.")] = False
|
|
76
|
+
test: Annotated[
|
|
77
|
+
bool, opt("--test", help="List the test data centres instead of production.")
|
|
78
|
+
] = False
|
|
79
|
+
resolve: Annotated[
|
|
80
|
+
bool,
|
|
81
|
+
opt("--resolve", help="Fetch the config over DNS/HTTPS when every DC is unreachable."),
|
|
82
|
+
] = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
#: Telegram's published test data centres. Hard-coded because the point of
|
|
86
|
+
#: asking for them is that you cannot reach a production DC to be told.
|
|
87
|
+
_TEST_DCS: tuple[tuple[int, str, int], ...] = (
|
|
88
|
+
(1, "149.154.175.10", 80),
|
|
89
|
+
(2, "149.154.167.40", 80),
|
|
90
|
+
(3, "149.154.175.117", 80),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def dc_list(ctx: OpContext, req: DcListReq) -> Page[DcOption]:
|
|
95
|
+
"""Telegram's data centres and their endpoints.
|
|
96
|
+
|
|
97
|
+
Refreshed by the `sync_dc_options` event: ignoring `updateDcOptions` is
|
|
98
|
+
how a long-lived daemon ends up stranded on an address Telegram has
|
|
99
|
+
retired.
|
|
100
|
+
"""
|
|
101
|
+
if req.test:
|
|
102
|
+
rows = [
|
|
103
|
+
DcOption(id=dc_id, ip_address=address, port=port) for dc_id, address, port in _TEST_DCS
|
|
104
|
+
]
|
|
105
|
+
return build_page(rows, op="net.dc.list", kind=PageKind.LOCAL, has_more=False)
|
|
106
|
+
|
|
107
|
+
if req.resolve:
|
|
108
|
+
raise _no_doh()
|
|
109
|
+
|
|
110
|
+
from telethon.tl import functions
|
|
111
|
+
|
|
112
|
+
from tlgr.ops.config import _dc_options
|
|
113
|
+
|
|
114
|
+
client = _client(ctx)
|
|
115
|
+
config = await client(functions.help.GetConfigRequest())
|
|
116
|
+
current = int(getattr(getattr(client, "session", None), "dc_id", 0) or 0)
|
|
117
|
+
rows = _dc_options(config)
|
|
118
|
+
for row in rows:
|
|
119
|
+
row.current = row.id == current
|
|
120
|
+
if req.ipv6:
|
|
121
|
+
rows = [row for row in rows if row.ipv6]
|
|
122
|
+
if req.media_only:
|
|
123
|
+
rows = [row for row in rows if row.media_only]
|
|
124
|
+
if req.cdn:
|
|
125
|
+
rows = [row for row in rows if row.cdn]
|
|
126
|
+
if not rows:
|
|
127
|
+
raise NotFoundError("no data centre matches that filter")
|
|
128
|
+
return build_page(rows, op="net.dc.list", kind=PageKind.LOCAL, has_more=False, total=len(rows))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _no_doh() -> Exception:
|
|
132
|
+
"""`--resolve` is honest about not existing yet.
|
|
133
|
+
|
|
134
|
+
The DNS-over-HTTPS config fallback is real work — fetch the payload,
|
|
135
|
+
verify its RSA signature, feed the dcOptions into the session — and
|
|
136
|
+
Telethon has none of it. Shipping a `--resolve` that quietly did nothing
|
|
137
|
+
would be worse than one that says so; a configured MTProxy is the working
|
|
138
|
+
stand-in today.
|
|
139
|
+
"""
|
|
140
|
+
from tlgr.core.errors import NotSupportedError
|
|
141
|
+
|
|
142
|
+
return NotSupportedError(
|
|
143
|
+
"--resolve needs the DNS/HTTPS config fallback, which Telethon does not "
|
|
144
|
+
"implement (it only retries the hard-coded DC addresses). Configure an "
|
|
145
|
+
"MTProxy instead: tlgr proxy add 'tg://proxy?...' --set"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
SPEC_DC_LIST = OperationSpec(
|
|
150
|
+
id="net.dc.list",
|
|
151
|
+
request=DcListReq,
|
|
152
|
+
response=Page[DcOption],
|
|
153
|
+
impl=dc_list,
|
|
154
|
+
summary="List Telegram data centres and their endpoints",
|
|
155
|
+
paginated=PageKind.LOCAL,
|
|
156
|
+
surface=Surface.DAEMON,
|
|
157
|
+
idempotent=True,
|
|
158
|
+
rate_class="read",
|
|
159
|
+
timeout_s=30,
|
|
160
|
+
columns=("id", "ip_address", "port", "ipv6", "media_only", "cdn", "current"),
|
|
161
|
+
empty_exit=EXIT_EMPTY,
|
|
162
|
+
example={
|
|
163
|
+
"items": [{"id": 4, "ip_address": "149.154.167.91", "port": 443, "current": True}],
|
|
164
|
+
"has_more": False,
|
|
165
|
+
},
|
|
166
|
+
example_args="net dc list --ipv6",
|
|
167
|
+
covers=(
|
|
168
|
+
"updates.config-dc-options",
|
|
169
|
+
"updates.config-dns-fallback",
|
|
170
|
+
"updates.net-ipv6",
|
|
171
|
+
"updates.net-test-dc",
|
|
172
|
+
),
|
|
173
|
+
tags=frozenset({"agent-safe"}),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class DcNearestReq(Request):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def dc_nearest(ctx: OpContext, req: DcNearestReq) -> NearestDc:
|
|
182
|
+
"""Ask the server which data centre is nearest.
|
|
183
|
+
|
|
184
|
+
Callable before authorization, and the cheapest probe there is — which is
|
|
185
|
+
why `proxy test` and `net ping` both time this call rather than reaching
|
|
186
|
+
into Telethon's keepalive, which exposes no round-trip time.
|
|
187
|
+
"""
|
|
188
|
+
from telethon.tl import functions
|
|
189
|
+
|
|
190
|
+
client = _client(ctx)
|
|
191
|
+
result = await client(functions.help.GetNearestDcRequest())
|
|
192
|
+
return NearestDc(
|
|
193
|
+
country=str(getattr(result, "country", "") or ""),
|
|
194
|
+
this_dc=int(getattr(result, "this_dc", 0) or 0),
|
|
195
|
+
nearest_dc=int(getattr(result, "nearest_dc", 0) or 0),
|
|
196
|
+
current_dc=int(getattr(getattr(client, "session", None), "dc_id", 0) or 0) or None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
SPEC_DC_NEAREST = OperationSpec(
|
|
201
|
+
id="net.dc.nearest",
|
|
202
|
+
request=DcNearestReq,
|
|
203
|
+
response=NearestDc,
|
|
204
|
+
impl=dc_nearest,
|
|
205
|
+
summary="Ask the server which data centre is nearest",
|
|
206
|
+
aliases=("net.nearest-dc",),
|
|
207
|
+
surface=Surface.DAEMON,
|
|
208
|
+
idempotent=True,
|
|
209
|
+
rate_class="read",
|
|
210
|
+
timeout_s=30,
|
|
211
|
+
columns=("country", "this_dc", "nearest_dc", "current_dc"),
|
|
212
|
+
example={"country": "GB", "this_dc": 4, "nearest_dc": 4, "current_dc": 4},
|
|
213
|
+
example_args="net dc nearest",
|
|
214
|
+
covers=("updates.config-nearest-dc",),
|
|
215
|
+
tags=frozenset({"agent-safe"}),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# net ping
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def _time_call(client: Any, via: str) -> float | None:
|
|
225
|
+
"""One probe, in milliseconds, or None when it failed."""
|
|
226
|
+
from telethon.tl import functions
|
|
227
|
+
|
|
228
|
+
request = (
|
|
229
|
+
functions.updates.GetStateRequest()
|
|
230
|
+
if via == "get-state"
|
|
231
|
+
else functions.help.GetNearestDcRequest()
|
|
232
|
+
)
|
|
233
|
+
started = time.monotonic()
|
|
234
|
+
try:
|
|
235
|
+
await client(request)
|
|
236
|
+
except Exception:
|
|
237
|
+
return None
|
|
238
|
+
return round((time.monotonic() - started) * 1000, 2)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class PingReq(Request):
|
|
242
|
+
probes: Annotated[int, opt("--probes", metavar="N", ge=1, le=20)] = 3
|
|
243
|
+
via: Annotated[str, choice("nearest-dc", "get-state", help="Which RPC to time.")] = "nearest-dc"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def net_ping(ctx: OpContext, req: PingReq) -> PingResult:
|
|
247
|
+
"""Round-trip latency to the current data centre.
|
|
248
|
+
|
|
249
|
+
A cheap RPC is timed rather than the transport's own keepalive, because
|
|
250
|
+
Telethon exposes no round-trip accessor: `MTProtoSender._keepalive_ping`
|
|
251
|
+
is private and records nothing a caller can read.
|
|
252
|
+
"""
|
|
253
|
+
client = _client(ctx)
|
|
254
|
+
samples: list[float] = []
|
|
255
|
+
failures = 0
|
|
256
|
+
for _ in range(req.probes):
|
|
257
|
+
sample = await _time_call(client, req.via)
|
|
258
|
+
if sample is None:
|
|
259
|
+
failures += 1
|
|
260
|
+
else:
|
|
261
|
+
samples.append(sample)
|
|
262
|
+
|
|
263
|
+
result = PingResult(
|
|
264
|
+
account=ctx.account,
|
|
265
|
+
dc_id=int(getattr(getattr(client, "session", None), "dc_id", 0) or 0) or None,
|
|
266
|
+
probes=req.probes,
|
|
267
|
+
loss=round(failures / req.probes, 3) if req.probes else 0.0,
|
|
268
|
+
)
|
|
269
|
+
if samples:
|
|
270
|
+
result.min_ms = min(samples)
|
|
271
|
+
result.max_ms = max(samples)
|
|
272
|
+
result.avg_ms = round(sum(samples) / len(samples), 2)
|
|
273
|
+
else:
|
|
274
|
+
ctx.warn("every probe failed; the account may be disconnected")
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
SPEC_NET_PING = OperationSpec(
|
|
279
|
+
id="net.ping",
|
|
280
|
+
request=PingReq,
|
|
281
|
+
response=PingResult,
|
|
282
|
+
impl=net_ping,
|
|
283
|
+
summary="Measure round-trip latency to the current data centre",
|
|
284
|
+
aliases=("daemon.net.ping",),
|
|
285
|
+
surface=Surface.DAEMON,
|
|
286
|
+
idempotent=True,
|
|
287
|
+
rate_class="read",
|
|
288
|
+
timeout_s=60,
|
|
289
|
+
columns=("account", "dc_id", "probes", "min_ms", "avg_ms", "max_ms", "loss"),
|
|
290
|
+
example={"account": "work", "dc_id": 4, "probes": 3, "avg_ms": 41.2, "loss": 0.0},
|
|
291
|
+
example_args="net ping --probes 5",
|
|
292
|
+
covers=("updates.net-ping-latency",),
|
|
293
|
+
tags=frozenset({"agent-safe"}),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# net status
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
#: Beyond this, `msg_id`s start falling outside the server's acceptance window
|
|
302
|
+
#: and requests are dropped with nothing the client can report.
|
|
303
|
+
_CLOCK_WARN_SECONDS = 30
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class NetStatusReq(Request):
|
|
307
|
+
ping: Annotated[bool, opt("--ping/--no-ping", help="Measure latency as part of it.")] = True
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def net_status(ctx: OpContext, req: NetStatusReq) -> NetStatus:
|
|
311
|
+
"""The connection, in one object.
|
|
312
|
+
|
|
313
|
+
`phase` is derived rather than read: Telethon has no phase enum, only
|
|
314
|
+
`is_connected()` and a `disconnected` future, so "catching up" has to be
|
|
315
|
+
inferred from the session state the supervisor keeps.
|
|
316
|
+
"""
|
|
317
|
+
from tlgr.core import telethon_compat as compat
|
|
318
|
+
|
|
319
|
+
alias = ctx.account or (_spanned(ctx) or [""])[0]
|
|
320
|
+
client = _session_client(ctx, alias) or getattr(ctx, "client", None)
|
|
321
|
+
if client is None:
|
|
322
|
+
raise NotFoundError(f"account {alias!r} is not connected. Run: tlgr daemon status")
|
|
323
|
+
|
|
324
|
+
sessions = _sessions(ctx)
|
|
325
|
+
session = sessions.get(alias) if sessions is not None else None
|
|
326
|
+
tl_session = getattr(client, "session", None)
|
|
327
|
+
connected = bool(client.is_connected()) if hasattr(client, "is_connected") else False
|
|
328
|
+
|
|
329
|
+
state, channels = compat.session_state(client)
|
|
330
|
+
cursors = SyncCursors(
|
|
331
|
+
pts=state.get("pts"),
|
|
332
|
+
qts=state.get("qts"),
|
|
333
|
+
seq=state.get("seq"),
|
|
334
|
+
date=state.get("date"),
|
|
335
|
+
date_unix=state.get("date_unix"),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
report = NetStatus(
|
|
339
|
+
account=alias,
|
|
340
|
+
authorized=True,
|
|
341
|
+
connected=connected,
|
|
342
|
+
phase=_phase(session, connected),
|
|
343
|
+
dc_id=int(getattr(tl_session, "dc_id", 0) or 0) or None,
|
|
344
|
+
dc_address=str(getattr(tl_session, "server_address", "") or "") or None,
|
|
345
|
+
ipv6=bool(getattr(client, "_use_ipv6", False)),
|
|
346
|
+
transport=type(getattr(client, "_connection", None)).__name__,
|
|
347
|
+
proxy=_proxy_label(client),
|
|
348
|
+
layer=_layer(),
|
|
349
|
+
time_offset_seconds=_time_offset(client),
|
|
350
|
+
exported_senders=len(getattr(client, "_borrowed_senders", None) or {}),
|
|
351
|
+
reconnects=int(getattr(session, "reconnects", 0) or 0),
|
|
352
|
+
last_error=(getattr(session, "reason", "") or None) if session is not None else None,
|
|
353
|
+
state=cursors,
|
|
354
|
+
frozen=getattr(session, "state", "") == "frozen",
|
|
355
|
+
)
|
|
356
|
+
if channels:
|
|
357
|
+
ctx.warn(f"{len(channels)} channels have their own pts; see `tlgr sync status --channels`")
|
|
358
|
+
|
|
359
|
+
if cursors.date_unix:
|
|
360
|
+
report.behind_seconds = max(0, int(time.time()) - int(cursors.date_unix))
|
|
361
|
+
|
|
362
|
+
if abs(report.time_offset_seconds) >= _CLOCK_WARN_SECONDS:
|
|
363
|
+
ctx.warn(
|
|
364
|
+
f"this host's clock is {report.time_offset_seconds}s from the server's; "
|
|
365
|
+
"MTProto message ids fall outside the acceptance window and requests "
|
|
366
|
+
"are dropped with no error. Fix the clock."
|
|
367
|
+
)
|
|
368
|
+
if req.ping and connected:
|
|
369
|
+
sample = await _time_call(client, "nearest-dc")
|
|
370
|
+
report.ping_ms = sample
|
|
371
|
+
return report
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _phase(session: Any, connected: bool) -> str:
|
|
375
|
+
if session is None:
|
|
376
|
+
return "connected" if connected else "disconnected"
|
|
377
|
+
if getattr(session, "catch_up_pending", False):
|
|
378
|
+
return "catching_up"
|
|
379
|
+
state = str(getattr(session, "state", "") or "")
|
|
380
|
+
return state or ("connected" if connected else "disconnected")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _layer() -> int:
|
|
384
|
+
with contextlib.suppress(Exception):
|
|
385
|
+
from telethon.tl.alltlobjects import LAYER
|
|
386
|
+
|
|
387
|
+
return int(LAYER)
|
|
388
|
+
return 0
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _time_offset(client: Any) -> int:
|
|
392
|
+
sender = getattr(client, "_sender", None)
|
|
393
|
+
state = getattr(sender, "state", None)
|
|
394
|
+
return int(getattr(state, "time_offset", 0) or 0)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _proxy_label(client: Any) -> str | None:
|
|
398
|
+
"""The proxy in force, never its credentials."""
|
|
399
|
+
proxy = getattr(client, "_proxy", None)
|
|
400
|
+
if not proxy:
|
|
401
|
+
return None
|
|
402
|
+
if isinstance(proxy, dict):
|
|
403
|
+
return f"{proxy.get('proxy_type', 'proxy')}://{proxy.get('addr')}:{proxy.get('port')}"
|
|
404
|
+
with contextlib.suppress(Exception):
|
|
405
|
+
return f"{proxy[0]}:{proxy[1]}"
|
|
406
|
+
return "configured"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
SPEC_NET_STATUS = OperationSpec(
|
|
410
|
+
id="net.status",
|
|
411
|
+
request=NetStatusReq,
|
|
412
|
+
response=NetStatus,
|
|
413
|
+
impl=net_status,
|
|
414
|
+
summary="Show the connection: DC, transport, proxy, latency, layer, clock offset",
|
|
415
|
+
description=(
|
|
416
|
+
"A clock more than 30 seconds from the server's is reported as a "
|
|
417
|
+
"warning, because MTProto derives `msg_id` from local time and the "
|
|
418
|
+
"server drops anything outside its window — with no error the client "
|
|
419
|
+
"can see."
|
|
420
|
+
),
|
|
421
|
+
aliases=("daemon.net.status",),
|
|
422
|
+
needs_client=False,
|
|
423
|
+
surface=Surface.DAEMON,
|
|
424
|
+
idempotent=True,
|
|
425
|
+
rate_class="read",
|
|
426
|
+
timeout_s=60,
|
|
427
|
+
columns=("account", "connected", "phase", "dc_id", "ping_ms", "layer", "behind_seconds"),
|
|
428
|
+
example={
|
|
429
|
+
"account": "work",
|
|
430
|
+
"connected": True,
|
|
431
|
+
"phase": "online",
|
|
432
|
+
"dc_id": 4,
|
|
433
|
+
"layer": 227,
|
|
434
|
+
},
|
|
435
|
+
example_args="net status",
|
|
436
|
+
covers=(
|
|
437
|
+
"updates.net-connection-status",
|
|
438
|
+
"updates.session-export-auth-dc",
|
|
439
|
+
"updates.session-time-sync",
|
|
440
|
+
"updates.sync-updating-indicator",
|
|
441
|
+
),
|
|
442
|
+
covers_partial=("updates.net-migrate-errors", "updates.net-ping-latency"),
|
|
443
|
+
coverage_note=(
|
|
444
|
+
"reports the connection; a migration that escapes Telethon is named "
|
|
445
|
+
"by `agent exit-codes`, and repeated probes are `net ping`."
|
|
446
|
+
),
|
|
447
|
+
tags=frozenset({"agent-safe"}),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
# net usage
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class NetUsageReq(Request):
|
|
457
|
+
reset: Annotated[bool, opt("--reset", help="Zero the counters after reporting.")] = False
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def net_usage_get(ctx: OpContext, req: NetUsageReq) -> NetUsage:
|
|
461
|
+
"""Bytes and requests since the daemon started.
|
|
462
|
+
|
|
463
|
+
Partial parity, said plainly: official clients break usage down per
|
|
464
|
+
method, and Telethon does no byte accounting at all, so these are coarse
|
|
465
|
+
per-class counters tlgr keeps itself. They live in memory and reset with
|
|
466
|
+
the daemon.
|
|
467
|
+
"""
|
|
468
|
+
daemon = getattr(ctx, "daemon", None)
|
|
469
|
+
sessions = _sessions(ctx)
|
|
470
|
+
alias = ctx.account or "all"
|
|
471
|
+
counters = getattr(daemon, "usage", None) or {}
|
|
472
|
+
row = counters.get(alias, {}) if isinstance(counters, dict) else {}
|
|
473
|
+
|
|
474
|
+
report = NetUsage(
|
|
475
|
+
account=alias,
|
|
476
|
+
since=_started_at(daemon),
|
|
477
|
+
rpc_bytes_sent=int(row.get("rpc_bytes_sent", 0)),
|
|
478
|
+
rpc_bytes_received=int(row.get("rpc_bytes_received", 0)),
|
|
479
|
+
download_bytes=int(row.get("download_bytes", 0)),
|
|
480
|
+
upload_bytes=int(row.get("upload_bytes", 0)),
|
|
481
|
+
requests=int(row.get("requests", 0)),
|
|
482
|
+
updates_received=_updates_seen(daemon, ctx),
|
|
483
|
+
reconnects=sum(
|
|
484
|
+
int(getattr(sessions.get(name), "reconnects", 0) or 0)
|
|
485
|
+
for name in (_spanned(ctx) or [])
|
|
486
|
+
if sessions is not None and sessions.get(name) is not None
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
if not row:
|
|
490
|
+
ctx.warn(
|
|
491
|
+
"byte counters are not instrumented in this build; requests, updates "
|
|
492
|
+
"and reconnects are exact and the byte totals are zero"
|
|
493
|
+
)
|
|
494
|
+
if req.reset and isinstance(counters, dict):
|
|
495
|
+
counters.pop(alias, None)
|
|
496
|
+
return report
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _started_at(daemon: Any) -> str:
|
|
500
|
+
from tlgr.core.timefmt import fmt_unix
|
|
501
|
+
|
|
502
|
+
started = getattr(daemon, "_start_time", None)
|
|
503
|
+
return (fmt_unix(int(started)) or "") if started else ""
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _updates_seen(daemon: Any, ctx: OpContext) -> int:
|
|
507
|
+
bus = getattr(daemon, "bus", None)
|
|
508
|
+
if bus is None:
|
|
509
|
+
return 0
|
|
510
|
+
return sum(int(bus.latest_seq(alias)) for alias in (_spanned(ctx) or []))
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
SPEC_NET_USAGE = OperationSpec(
|
|
514
|
+
id="net.usage.get",
|
|
515
|
+
request=NetUsageReq,
|
|
516
|
+
response=NetUsage,
|
|
517
|
+
impl=net_usage_get,
|
|
518
|
+
summary="Report bytes sent/received and requests per account",
|
|
519
|
+
description=(
|
|
520
|
+
"Coarse per-class counters, not the official clients' per-method "
|
|
521
|
+
"breakdown: Telethon does no byte accounting, so anything finer would "
|
|
522
|
+
"be invented. In memory, and reset with the daemon."
|
|
523
|
+
),
|
|
524
|
+
aliases=("daemon.net.usage",),
|
|
525
|
+
needs_account=False,
|
|
526
|
+
needs_client=False,
|
|
527
|
+
surface=Surface.DAEMON,
|
|
528
|
+
idempotent=True,
|
|
529
|
+
rate_class="local",
|
|
530
|
+
timeout_s=30,
|
|
531
|
+
columns=("account", "requests", "updates_received", "reconnects"),
|
|
532
|
+
example={"account": "all", "requests": 128, "updates_received": 91824, "reconnects": 1},
|
|
533
|
+
example_args="net usage get",
|
|
534
|
+
covers=("updates.ops-network-usage-stats",),
|
|
535
|
+
tags=frozenset({"agent-safe"}),
|
|
536
|
+
)
|