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