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/daemon.py ADDED
@@ -0,0 +1,1397 @@
1
+ """The `daemon` group: lifecycle, health, floods and dead letters.
2
+
3
+ Two halves that look alike and are not. `start`, `stop`, `restart`, `install`,
4
+ `uninstall`, `logs` and `status` run **outside** the daemon — they are how you
5
+ find out that it is not running, so they cannot need it to answer. Everything
6
+ else (`reconnect`, `save-state`, `flood *`, `dead-letter *`) runs inside it,
7
+ because it is asking about state only the running process has.
8
+
9
+ `status` is the one worth reading the code of. v1 reported which clients the
10
+ daemon *held*: a client whose connection had died was still in the dict, still
11
+ listed under `accounts`, and the daemon still called itself healthy (COR-13,
12
+ COR-37). Here every account carries a state, a `pts` and a `behind_seconds`,
13
+ and `healthy` is false when any account needs a login, is frozen, or has
14
+ fallen behind — so "the process is alive" and "the daemon works" are separate
15
+ answers to separate questions.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import json
23
+ import os
24
+ import platform
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ from collections.abc import AsyncIterator
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Annotated, Any
33
+
34
+ from tlgr.core.errors import (
35
+ EXIT_EMPTY,
36
+ DaemonError,
37
+ DaemonNotRunningError,
38
+ NotFoundError,
39
+ UsageError,
40
+ )
41
+ from tlgr.core.pagination import PageKind, build_page
42
+ from tlgr.models.base import Request
43
+ from tlgr.models.daemon import (
44
+ AccountHealth,
45
+ DaemonStatus,
46
+ DeadLetter,
47
+ DeadLetterResult,
48
+ EventBusStatus,
49
+ FloodRecord,
50
+ FloodResult,
51
+ LifecycleResult,
52
+ LogLine,
53
+ ReconnectedAccount,
54
+ ReconnectResult,
55
+ SavedState,
56
+ SaveStateResult,
57
+ ServiceResult,
58
+ )
59
+ from tlgr.models.page import Page
60
+ from tlgr.models.peer import PeerRef
61
+ from tlgr.ops._params import choice, opt, parse_dt
62
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
63
+
64
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Shared plumbing
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _base() -> Path:
73
+ from tlgr.core.paths import default_base
74
+
75
+ return default_base()
76
+
77
+
78
+ def _writable_base(what: str) -> Path:
79
+ """The tlgr home, refused when it is somebody's live installation.
80
+
81
+ A home carrying a `.production` marker belongs to a running daemon with
82
+ real accounts in it. Starting a second one there shares the session files,
83
+ and Telegram revokes an auth key it sees two clients on — so the marker is
84
+ a hard stop rather than a warning, with `TLGR_ALLOW_PRODUCTION_HOME=1` as
85
+ the escape hatch a person types on purpose.
86
+ """
87
+ from tlgr.core.paths import refuse_production_home
88
+
89
+ base = _base()
90
+ refuse_production_home(base)
91
+ return base
92
+
93
+
94
+ def _probe(timeout: float = 2.0) -> dict[str, Any] | None:
95
+ """`GET /v1/status`, without ever starting a daemon to answer it.
96
+
97
+ `tlgr daemon status` exists to tell you the daemon is down; auto-starting
98
+ one to find out would make the question unanswerable.
99
+ """
100
+ from tlgr.transport.client import DaemonClient
101
+
102
+ client = DaemonClient(_base(), timeout=timeout, auto_start=False, no_restart=True)
103
+ with contextlib.suppress(Exception):
104
+ return client.probe_status()
105
+ return None
106
+
107
+
108
+ def _admin(action: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
109
+ from tlgr.transport.client import DaemonClient
110
+
111
+ client = DaemonClient(_base(), timeout=30.0, auto_start=False, no_restart=True)
112
+ return client.admin(action, body or {})
113
+
114
+
115
+ def _stamp(value: float | None) -> str | None:
116
+ if not value:
117
+ return None
118
+ return datetime.fromtimestamp(value, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
119
+
120
+
121
+ def _now() -> str:
122
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
123
+
124
+
125
+ def _spanned(ctx: OpContext) -> list[str]:
126
+ """Which accounts an `--account all` daemon operation covers.
127
+
128
+ Empty or `all` means every account the daemon holds. Naming one narrows
129
+ it. There is no "pick one for me": v1 did that and a two-account user
130
+ silently operated on the wrong identity (COR-02).
131
+ """
132
+ alias = (ctx.account or "").strip()
133
+ daemon = getattr(ctx, "daemon", None)
134
+ sessions = getattr(daemon, "sessions", None)
135
+ known = list(getattr(sessions, "aliases", []) or [])
136
+ if alias and alias != "all":
137
+ if known and alias not in known:
138
+ raise NotFoundError(f"account {alias!r} is not connected. Run: tlgr daemon status")
139
+ return [alias]
140
+ return known
141
+
142
+
143
+ def _daemon(ctx: OpContext) -> Any:
144
+ daemon = getattr(ctx, "daemon", None)
145
+ if daemon is None:
146
+ raise DaemonError("this operation runs inside the daemon")
147
+ return daemon
148
+
149
+
150
+ def _telethon_layer() -> int:
151
+ with contextlib.suppress(Exception):
152
+ from telethon.tl.alltlobjects import LAYER
153
+
154
+ return int(LAYER)
155
+ return 0
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Lifecycle
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def _spawn(base: Path, *, foreground: bool = False) -> Any:
164
+ """Start the daemon process.
165
+
166
+ Spawned rather than imported, and not only because `ops/` may not import
167
+ `daemon/` (§2.2): a daemon that shares this process's file descriptors,
168
+ signal handlers and event loop is not the process a supervisor will start
169
+ later, so testing one would not test the other.
170
+ """
171
+ command = [sys.executable, "-m", "tlgr.daemon.main", "--base", str(base)]
172
+ if foreground:
173
+ command.append("--foreground")
174
+ return subprocess.Popen(command)
175
+ return subprocess.Popen(
176
+ command,
177
+ stdout=subprocess.DEVNULL,
178
+ stderr=subprocess.DEVNULL,
179
+ start_new_session=True,
180
+ )
181
+
182
+
183
+ def _wait_ready(timeout: float) -> dict[str, Any] | None:
184
+ """Poll `/v1/status` until the daemon answers.
185
+
186
+ Readiness is a *reply*, not a file. v1 waited for the socket to appear,
187
+ which happens at `bind()` — before any account has connected and before
188
+ the daemon can serve anything (ROB-07).
189
+ """
190
+ from tlgr.transport.autostart import wait_ready
191
+ from tlgr.transport.client import DaemonClient
192
+
193
+ client = DaemonClient(_base(), auto_start=False, no_restart=True)
194
+ return wait_ready(client.probe_status, timeout=timeout)
195
+
196
+
197
+ class DaemonStartReq(Request):
198
+ foreground: Annotated[
199
+ bool, opt("--foreground", help="Run in the foreground instead of forking.")
200
+ ] = False
201
+ connect: Annotated[
202
+ list[str],
203
+ opt("--connect", metavar="ALIAS", help="Only connect these accounts (repeatable)."),
204
+ ] = []
205
+ catch_up: Annotated[
206
+ bool,
207
+ opt(
208
+ "--catch-up/--no-catch-up",
209
+ help="Load the persisted pts/qts/seq and fetch the difference before dispatching.",
210
+ ),
211
+ ] = True
212
+ idle_timeout: Annotated[
213
+ int | None,
214
+ opt(
215
+ "--idle-timeout", metavar="DURATION", kind="duration", help="0 disables the idle stop."
216
+ ),
217
+ ] = None
218
+ wait: Annotated[
219
+ int, opt("--wait", metavar="DURATION", kind="duration", help="How long to wait for ready.")
220
+ ] = 30
221
+
222
+
223
+ async def daemon_start(ctx: OpContext, req: DaemonStartReq) -> LifecycleResult:
224
+ """Start the update-receiving daemon.
225
+
226
+ `catch_up` defaults to true and `idle_timeout` to 0 for good reason: v1
227
+ combined an idle stop at 1,800 s with an effectively disabled catch-up, so
228
+ the daemon shut down, restarted on the next command, and never fetched
229
+ what it had missed. That combination is a guaranteed, permanent sync hole.
230
+ """
231
+ from tlgr.core.process import read_pid
232
+
233
+ base = _writable_base("tlgr daemon start")
234
+ existing = read_pid(base)
235
+ if existing:
236
+ running = _probe() or {}
237
+ ctx.mark_already()
238
+ return LifecycleResult(
239
+ started=False,
240
+ already=True,
241
+ pid=existing,
242
+ socket=str(running.get("daemon", {}).get("socket", "")),
243
+ ready=bool(running.get("daemon", {}).get("ready")),
244
+ catch_up=req.catch_up,
245
+ )
246
+
247
+ environment_note = _start_environment(req)
248
+ if req.foreground:
249
+ raise SystemExit(_spawn(base, foreground=True).wait())
250
+
251
+ process = _spawn(base)
252
+ status = _wait_ready(float(req.wait))
253
+ if status is None:
254
+ raise DaemonError(
255
+ f"the daemon did not become ready within {req.wait}s; check the log: tlgr daemon logs"
256
+ )
257
+ info = status.get("daemon", {})
258
+ if environment_note:
259
+ ctx.warn(environment_note)
260
+ return LifecycleResult(
261
+ started=True,
262
+ pid=int(info.get("pid") or read_pid(base) or process.pid),
263
+ socket=str(info.get("socket", "")),
264
+ ready=bool(info.get("ready")),
265
+ accounts=[row.get("alias", "") for row in status.get("accounts", [])],
266
+ catch_up=req.catch_up,
267
+ )
268
+
269
+
270
+ def _start_environment(req: DaemonStartReq) -> str:
271
+ """Apply the per-start overrides through the environment the child reads."""
272
+ notes: list[str] = []
273
+ if req.idle_timeout is not None:
274
+ os.environ["TLGR_IDLE_TIMEOUT"] = str(int(req.idle_timeout))
275
+ notes.append(f"idle_timeout was set to {int(req.idle_timeout)}s for this run only")
276
+ if not req.catch_up:
277
+ os.environ["TLGR_CATCH_UP"] = "0"
278
+ notes.append(
279
+ "catch-up is disabled for this run: updates that arrive while the "
280
+ "daemon is down will not be recovered"
281
+ )
282
+ if req.connect:
283
+ os.environ["TLGR_PRECONNECT"] = ",".join(req.connect)
284
+ return "; ".join(notes)
285
+
286
+
287
+ SPEC_DAEMON_START = OperationSpec(
288
+ id="daemon.start",
289
+ request=DaemonStartReq,
290
+ response=LifecycleResult,
291
+ impl=daemon_start,
292
+ summary="Start the update-receiving daemon",
293
+ description=(
294
+ "Waits for an HTTP 200 from `/v1/status`, not for the socket file: "
295
+ "the socket exists from `bind()`, before any account has connected "
296
+ "(ROB-07). Catch-up is on by default and the idle stop is off, "
297
+ "because the two together are what made v1 lose updates silently."
298
+ ),
299
+ legacy_paths=("daemon start",),
300
+ mutating=True,
301
+ needs_account=False,
302
+ needs_auth=False,
303
+ needs_client=False,
304
+ surface=Surface.LOCAL,
305
+ rate_class="local",
306
+ timeout_s=120,
307
+ example={"started": True, "pid": 41231, "ready": True, "catch_up": True},
308
+ example_args="daemon start",
309
+ covers=(
310
+ "updates.stream-daemon-multi-account",
311
+ "updates.sync-catch-up-on-start",
312
+ "updates.sync-new-session-triggers-diff",
313
+ ),
314
+ covers_partial=("updates.ops-daemon-lifecycle",),
315
+ coverage_note="starting the process; stopping it cleanly is `daemon stop`.",
316
+ tags=frozenset({"agent-safe"}),
317
+ )
318
+
319
+
320
+ class DaemonStopReq(Request):
321
+ timeout: Annotated[
322
+ int,
323
+ opt("--grace", metavar="DURATION", kind="duration", help="Drain period before SIGKILL."),
324
+ ] = 10
325
+
326
+
327
+ async def daemon_stop(ctx: OpContext, req: DaemonStopReq) -> LifecycleResult:
328
+ """Stop the daemon, letting it flush pts and the entity cache first.
329
+
330
+ Every shutdown path has to `await disconnect()`: a SIGKILL loses the
331
+ update state and the cached access hashes, and losing an access hash is
332
+ what makes the next catch-up silently skip a channel.
333
+ """
334
+ from tlgr.core.process import read_pid
335
+
336
+ base = _base()
337
+ pid = read_pid(base)
338
+ if pid is None:
339
+ return LifecycleResult(stopped=False, already=True)
340
+
341
+ with contextlib.suppress(Exception):
342
+ _admin("stop", {"drain_s": float(req.timeout)})
343
+
344
+ deadline = time.monotonic() + max(1.0, float(req.timeout))
345
+ while time.monotonic() < deadline:
346
+ if read_pid(base) is None:
347
+ return LifecycleResult(stopped=True, pid=pid)
348
+ time.sleep(0.1)
349
+
350
+ from tlgr.core.process import stop_daemon
351
+
352
+ stop_daemon(base)
353
+ for _ in range(20):
354
+ time.sleep(0.25)
355
+ if read_pid(base) is None:
356
+ return LifecycleResult(stopped=True, pid=pid)
357
+ raise DaemonError(f"the daemon (pid {pid}) did not stop within {req.timeout}s")
358
+
359
+
360
+ SPEC_DAEMON_STOP = OperationSpec(
361
+ id="daemon.stop",
362
+ request=DaemonStopReq,
363
+ response=LifecycleResult,
364
+ impl=daemon_stop,
365
+ summary="Stop the daemon",
366
+ description=(
367
+ "Asks it to drain in-flight requests and disconnect cleanly, then "
368
+ "falls back to SIGTERM. A killed daemon loses its `pts` and the "
369
+ "cached access hashes catch-up needs."
370
+ ),
371
+ legacy_paths=("daemon stop",),
372
+ mutating=True,
373
+ idempotent=True,
374
+ needs_account=False,
375
+ needs_auth=False,
376
+ needs_client=False,
377
+ surface=Surface.LOCAL,
378
+ rate_class="local",
379
+ timeout_s=60,
380
+ example={"stopped": True, "pid": 41231},
381
+ example_args="daemon stop",
382
+ covers=("updates.ops-daemon-lifecycle",),
383
+ tags=frozenset({"agent-safe"}),
384
+ )
385
+
386
+
387
+ class DaemonRestartReq(Request):
388
+ timeout: Annotated[
389
+ int,
390
+ opt("--grace", metavar="DURATION", kind="duration", help="Drain period before SIGKILL."),
391
+ ] = 10
392
+ wait: Annotated[
393
+ int, opt("--wait", metavar="DURATION", kind="duration", help="How long to wait for ready.")
394
+ ] = 30
395
+
396
+
397
+ async def daemon_restart(ctx: OpContext, req: DaemonRestartReq) -> LifecycleResult:
398
+ """Stop and start, waiting for readiness at both ends."""
399
+ from tlgr.core.process import read_pid
400
+
401
+ base = _writable_base("tlgr daemon restart")
402
+ if read_pid(base) is not None:
403
+ await daemon_stop(ctx, DaemonStopReq(timeout=req.timeout))
404
+ started = await daemon_start(ctx, DaemonStartReq(wait=req.wait))
405
+ return LifecycleResult(
406
+ restarted=True,
407
+ pid=started.pid,
408
+ socket=started.socket,
409
+ ready=started.ready,
410
+ accounts=started.accounts,
411
+ )
412
+
413
+
414
+ SPEC_DAEMON_RESTART = OperationSpec(
415
+ id="daemon.restart",
416
+ request=DaemonRestartReq,
417
+ response=LifecycleResult,
418
+ impl=daemon_restart,
419
+ summary="Restart the daemon",
420
+ legacy_paths=("daemon restart",),
421
+ mutating=True,
422
+ needs_account=False,
423
+ needs_auth=False,
424
+ needs_client=False,
425
+ surface=Surface.LOCAL,
426
+ rate_class="local",
427
+ timeout_s=180,
428
+ example={"restarted": True, "pid": 41999},
429
+ example_args="daemon restart",
430
+ covers_partial=("updates.ops-daemon-lifecycle",),
431
+ coverage_note="a stop and a start; the lifecycle itself is `daemon stop`.",
432
+ tags=frozenset({"agent-safe"}),
433
+ )
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Service installation
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ def _supervisor(choice_: str) -> str:
442
+ if choice_ != "auto":
443
+ return choice_
444
+ return "launchd" if platform.system() == "Darwin" else "systemd"
445
+
446
+
447
+ class DaemonInstallReq(Request):
448
+ supervisor: Annotated[
449
+ str, choice("auto", "launchd", "systemd", help="Which service manager to install into.")
450
+ ] = "auto"
451
+ keep_alive: Annotated[
452
+ bool, opt("--keep-alive/--no-keep-alive", help="Restart the daemon on crash.")
453
+ ] = True
454
+
455
+
456
+ async def daemon_install(ctx: OpContext, req: DaemonInstallReq) -> ServiceResult:
457
+ """Install as a *user* service: it holds session files under $HOME.
458
+
459
+ Both backends force `idle_timeout` to 0. Under a supervisor a clean idle
460
+ exit is either a respawn loop or a daemon that never comes back (COR-39).
461
+ """
462
+ base = _writable_base("tlgr daemon install")
463
+ kind = _supervisor(req.supervisor)
464
+ if kind == "launchd":
465
+ from tlgr.core import launchd
466
+ from tlgr.core.config import get_logs_dir
467
+
468
+ if launchd.is_installed():
469
+ ctx.mark_already()
470
+ return ServiceResult(
471
+ installed=True, already=True, supervisor=kind, path=str(launchd.PLIST_PATH)
472
+ )
473
+ path = launchd.install(base, get_logs_dir(base))
474
+ else:
475
+ from tlgr.core import systemd
476
+
477
+ if systemd.is_installed():
478
+ ctx.mark_already()
479
+ return ServiceResult(
480
+ installed=True, already=True, supervisor=kind, path=str(systemd.unit_path())
481
+ )
482
+ path = systemd.install(base)
483
+ if not req.keep_alive:
484
+ ctx.warn(
485
+ "--no-keep-alive is recorded but not honoured by the generated unit; "
486
+ "edit it directly to disable the restart"
487
+ )
488
+ return ServiceResult(installed=True, supervisor=kind, unit=path.name, path=str(path))
489
+
490
+
491
+ SPEC_DAEMON_INSTALL = OperationSpec(
492
+ id="daemon.install",
493
+ request=DaemonInstallReq,
494
+ response=ServiceResult,
495
+ impl=daemon_install,
496
+ summary="Install the daemon as a user service (auto-start, restart on crash)",
497
+ description=(
498
+ "macOS gets a LaunchAgent, Linux a systemd **user** unit — user, "
499
+ "because the daemon holds session files under $HOME and must run as "
500
+ "their owner."
501
+ ),
502
+ legacy_paths=("daemon install",),
503
+ mutating=True,
504
+ idempotent=True,
505
+ needs_account=False,
506
+ needs_auth=False,
507
+ needs_client=False,
508
+ surface=Surface.LOCAL,
509
+ rate_class="local",
510
+ timeout_s=60,
511
+ example={"installed": True, "supervisor": "launchd", "path": "~/Library/LaunchAgents/…"},
512
+ example_args="daemon install",
513
+ covers_partial=("updates.ops-daemon-lifecycle",),
514
+ coverage_note="the supervisor half; running the daemon is `daemon start`/`stop`.",
515
+ tags=frozenset({"agent-safe"}),
516
+ )
517
+
518
+
519
+ class DaemonUninstallReq(Request):
520
+ stop: Annotated[bool, opt("--stop/--no-stop", help="Also stop a running daemon.")] = True
521
+
522
+
523
+ async def daemon_uninstall(ctx: OpContext, req: DaemonUninstallReq) -> ServiceResult:
524
+ """Remove the user service."""
525
+ from tlgr.core import launchd, systemd
526
+
527
+ removed = launchd.uninstall() if platform.system() == "Darwin" else False
528
+ removed = systemd.uninstall() or removed
529
+ stopped = False
530
+ if req.stop:
531
+ result = await daemon_stop(ctx, DaemonStopReq())
532
+ stopped = result.stopped
533
+ if not removed:
534
+ ctx.mark_already()
535
+ return ServiceResult(uninstalled=removed, already=not removed, stopped=stopped)
536
+
537
+
538
+ SPEC_DAEMON_UNINSTALL = OperationSpec(
539
+ id="daemon.uninstall",
540
+ request=DaemonUninstallReq,
541
+ response=ServiceResult,
542
+ impl=daemon_uninstall,
543
+ summary="Remove the daemon service",
544
+ legacy_paths=("daemon uninstall",),
545
+ mutating=True,
546
+ destructive=True,
547
+ idempotent=True,
548
+ needs_account=False,
549
+ needs_auth=False,
550
+ needs_client=False,
551
+ surface=Surface.LOCAL,
552
+ rate_class="local",
553
+ timeout_s=60,
554
+ example={"uninstalled": True, "stopped": True},
555
+ example_args="daemon uninstall",
556
+ covers_partial=("updates.ops-daemon-lifecycle",),
557
+ coverage_note="the supervisor half; running the daemon is `daemon start`/`stop`.",
558
+ tags=frozenset({"agent-safe"}),
559
+ )
560
+
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # Logs
564
+ # ---------------------------------------------------------------------------
565
+
566
+ _LOG_LEVELS = ("debug", "info", "warning", "error")
567
+ _LEVEL_RANK = {name: index for index, name in enumerate(_LOG_LEVELS)}
568
+ _PLAIN_LOG = re.compile(r"^(?P<ts>\S+)\s+(?P<level>[A-Z]+)\s+(?P<logger>\S+)\s+(?P<message>.*)$")
569
+
570
+
571
+ def _parse_log(line: str) -> LogLine:
572
+ """One log line, structured where it can be and verbatim where it cannot."""
573
+ stripped = line.rstrip("\n")
574
+ if stripped.startswith("{"):
575
+ with contextlib.suppress(json.JSONDecodeError):
576
+ record = json.loads(stripped)
577
+ if isinstance(record, dict):
578
+ return LogLine(
579
+ ts=str(record.get("ts") or record.get("time") or ""),
580
+ level=str(record.get("level", "")).lower(),
581
+ account=record.get("account"),
582
+ logger=str(record.get("logger", "")),
583
+ message=str(record.get("message", "")),
584
+ raw=stripped,
585
+ )
586
+ match = _PLAIN_LOG.match(stripped)
587
+ if match:
588
+ return LogLine(
589
+ ts=match.group("ts"),
590
+ level=match.group("level").lower(),
591
+ logger=match.group("logger"),
592
+ message=match.group("message"),
593
+ raw=stripped,
594
+ )
595
+ return LogLine(message=stripped, raw=stripped)
596
+
597
+
598
+ def _wanted_log(entry: LogLine, level: str | None, account: str | None, grep: str | None) -> bool:
599
+ if level and _LEVEL_RANK.get(entry.level, 0) < _LEVEL_RANK.get(level, 0):
600
+ return False
601
+ if account and entry.account != account:
602
+ return False
603
+ return not (grep and grep.lower() not in entry.raw.lower())
604
+
605
+
606
+ class DaemonLogsReq(Request):
607
+ follow: Annotated[bool, opt("--follow", "-f", help="Follow the log as it is written.")] = False
608
+ lines: Annotated[int, opt("--lines", metavar="N", ge=1, le=100000)] = 50
609
+ level: Annotated[str | None, choice(*_LOG_LEVELS, help="Minimum level to show.")] = None
610
+ log_account: Annotated[
611
+ str | None,
612
+ opt("--for-account", metavar="ALIAS", help="Only lines tagged with this account."),
613
+ ] = None
614
+ grep: Annotated[str | None, opt("--grep", metavar="TEXT", help="Substring filter.")] = None
615
+
616
+
617
+ async def daemon_logs(ctx: OpContext, req: DaemonLogsReq) -> AsyncIterator[dict[str, Any]]:
618
+ """The daemon log, tailed and filtered.
619
+
620
+ Read here rather than exec'ing `tail`, so that `--level`, `--for-account`
621
+ and `--grep` mean the same thing whether or not you are following, and so
622
+ the output is structured rather than whatever the log formatter happened
623
+ to print.
624
+ """
625
+ path = _base() / "logs" / "daemon.log"
626
+ if not path.exists():
627
+ raise NotFoundError(f"no log file at {path}. Has the daemon ever started?")
628
+
629
+ with path.open(encoding="utf-8", errors="replace") as handle:
630
+ tail = handle.readlines()[-req.lines :]
631
+ for line in tail:
632
+ entry = _parse_log(line)
633
+ if _wanted_log(entry, req.level, req.log_account, req.grep):
634
+ yield {"type": "log", **_log_frame(entry)}
635
+ if not req.follow:
636
+ return
637
+ handle.seek(0, os.SEEK_END)
638
+ while True:
639
+ line = handle.readline()
640
+ if not line:
641
+ await asyncio.sleep(0.25)
642
+ continue
643
+ entry = _parse_log(line)
644
+ if _wanted_log(entry, req.level, req.log_account, req.grep):
645
+ yield {"type": "log", **_log_frame(entry)}
646
+
647
+
648
+ def _log_frame(entry: LogLine) -> dict[str, Any]:
649
+ from tlgr.models.base import to_builtins
650
+
651
+ frame = to_builtins(entry)
652
+ return frame if isinstance(frame, dict) else {"raw": entry.raw}
653
+
654
+
655
+ SPEC_DAEMON_LOGS = OperationSpec(
656
+ id="daemon.logs",
657
+ request=DaemonLogsReq,
658
+ response=None,
659
+ impl=daemon_logs,
660
+ summary="View or follow the daemon log",
661
+ description=(
662
+ "Structured lines with secrets redacted: an auth key, an access hash, "
663
+ "a proxy secret and a webhook token are never written to the log in "
664
+ "the first place."
665
+ ),
666
+ legacy_paths=("daemon logs",),
667
+ stream=True,
668
+ needs_account=False,
669
+ needs_auth=False,
670
+ needs_client=False,
671
+ surface=Surface.LOCAL,
672
+ rate_class="local",
673
+ timeout_s=900,
674
+ example={"type": "log", "level": "info", "message": "daemon ready with 1 account(s)"},
675
+ example_args="daemon logs --lines 100 --level warning",
676
+ covers_partial=("updates.ops-daemon-lifecycle",),
677
+ coverage_note="the operator's view of the process; the lifecycle is `daemon stop`.",
678
+ tags=frozenset({"agent-safe", "frames", "live-stream"}),
679
+ )
680
+
681
+
682
+ # ---------------------------------------------------------------------------
683
+ # Status
684
+ # ---------------------------------------------------------------------------
685
+
686
+
687
+ def _account_health(row: dict[str, Any]) -> AccountHealth:
688
+ return AccountHealth(
689
+ alias=str(row.get("alias", "")),
690
+ state=str(row.get("state", "unknown")),
691
+ user_id=row.get("user_id"),
692
+ username=row.get("username"),
693
+ dc_id=row.get("dc_id"),
694
+ proxy=row.get("proxy"),
695
+ pts=row.get("pts"),
696
+ qts=row.get("qts"),
697
+ seq=row.get("seq"),
698
+ date=row.get("date"),
699
+ behind_seconds=row.get("behind_seconds"),
700
+ catching_up=bool(row.get("catch_up_pending")),
701
+ channels_tracked=int(row.get("channels_tracked") or 0),
702
+ last_update_at=row.get("last_update"),
703
+ connected_since=row.get("connected_since"),
704
+ reconnects=int(row.get("reconnects") or 0),
705
+ in_flight=int(row.get("in_flight") or 0),
706
+ resync_needed=list(row.get("resync_needed") or []),
707
+ flood_waits=int(row.get("flood_entries") or 0),
708
+ circuit=str(row.get("circuit", "closed")),
709
+ frozen=str(row.get("state", "")) == "frozen",
710
+ error=row.get("reason"),
711
+ )
712
+
713
+
714
+ #: An account in one of these states is not doing its job, whatever the
715
+ #: process is doing. `healthy` has to be false for all of them, or the flag
716
+ #: means "a process exists" — which is the question nobody was asking.
717
+ _UNHEALTHY = frozenset({"needs_login", "frozen", "degraded", "stopped"})
718
+
719
+
720
+ class DaemonStatusReq(Request):
721
+ check: Annotated[
722
+ bool, opt("--check", help="Exit 11 when the daemon or any account is unhealthy.")
723
+ ] = False
724
+
725
+
726
+ async def daemon_status(ctx: OpContext, req: DaemonStatusReq) -> DaemonStatus:
727
+ """Daemon and per-account health, as two separate answers.
728
+
729
+ `running` has always meant "a process is alive". `ready` and `healthy`
730
+ are the questions people were actually asking, and v1 could not tell them
731
+ apart: an account whose connection had died was still counted (COR-37).
732
+ """
733
+ from tlgr.core.paths import TlgrPaths
734
+ from tlgr.core.process import read_pid
735
+
736
+ base = _base()
737
+ pid = read_pid(base)
738
+ status = _probe()
739
+ if status is None:
740
+ # The socket it *would* have asked, so "not running" says where it
741
+ # looked rather than only that it found nothing.
742
+ result = DaemonStatus(
743
+ running=pid is not None,
744
+ ready=False,
745
+ healthy=False,
746
+ pid=pid,
747
+ layer=_telethon_layer(),
748
+ socket=str(TlgrPaths(base).socket),
749
+ socket_owner=os.getuid(),
750
+ )
751
+ if req.check:
752
+ raise DaemonNotRunningError("the daemon is not answering on its socket")
753
+ return result
754
+
755
+ info = status.get("daemon", {})
756
+ rows = [_account_health(row) for row in status.get("accounts", [])]
757
+ if ctx.account and ctx.account != "all":
758
+ rows = [row for row in rows if row.alias == ctx.account]
759
+ unhealthy = [row for row in rows if row.state in _UNHEALTHY]
760
+ result = DaemonStatus(
761
+ running=True,
762
+ ready=bool(info.get("ready")),
763
+ healthy=bool(info.get("ready")) and not unhealthy,
764
+ pid=info.get("pid") or pid,
765
+ uptime_seconds=int(info.get("uptime_s") or 0),
766
+ version=str(info["version"]) if info.get("version") else None,
767
+ protocol=int(info.get("protocol") or 0),
768
+ layer=_telethon_layer(),
769
+ socket=str(info.get("socket", "")),
770
+ socket_owner=os.getuid(),
771
+ managed_by=info.get("managed_by"),
772
+ accounts=rows,
773
+ events=EventBusStatus(**status["events"])
774
+ if isinstance(status.get("events"), dict)
775
+ else None,
776
+ webhook=status.get("webhook") or {},
777
+ jobs=status.get("jobs") or [],
778
+ connections={row.alias: row.state == "online" for row in rows},
779
+ disconnected=sorted(row.alias for row in rows if row.state != "online"),
780
+ )
781
+ if req.check and not result.healthy:
782
+ raise DaemonError(
783
+ "the daemon is not healthy: "
784
+ + (", ".join(f"{row.alias} is {row.state}" for row in unhealthy) or "not ready")
785
+ )
786
+ return result
787
+
788
+
789
+ SPEC_DAEMON_STATUS = OperationSpec(
790
+ id="daemon.status",
791
+ request=DaemonStatusReq,
792
+ response=DaemonStatus,
793
+ impl=daemon_status,
794
+ summary="Show daemon and per-account connection health",
795
+ description=(
796
+ "`running` is about the process, `ready` about the socket, `healthy` "
797
+ "about the accounts. v1 had only the first and reported every client "
798
+ "it held as connected, so a fully deaf daemon looked fine (COR-37)."
799
+ ),
800
+ legacy_paths=("daemon status",),
801
+ needs_account=False,
802
+ needs_auth=False,
803
+ needs_client=False,
804
+ surface=Surface.LOCAL,
805
+ idempotent=True,
806
+ rate_class="local",
807
+ timeout_s=30,
808
+ columns=("running", "ready", "healthy", "pid", "uptime_seconds", "disconnected"),
809
+ example={
810
+ "running": True,
811
+ "ready": True,
812
+ "healthy": True,
813
+ "pid": 41231,
814
+ "uptime_seconds": 8123,
815
+ "accounts": [{"alias": "work", "state": "online", "pts": 91824}],
816
+ },
817
+ example_args="daemon status --check",
818
+ covers=(
819
+ "bots.bot-updates-status",
820
+ "updates.ops-single-updates-consumer",
821
+ "updates.session-persistence",
822
+ ),
823
+ covers_partial=(
824
+ "updates.config-account-frozen",
825
+ "updates.net-connection-status",
826
+ "updates.ops-reconnect-health",
827
+ "updates.stream-daemon-multi-account",
828
+ "updates.sync-updating-indicator",
829
+ ),
830
+ coverage_note=(
831
+ "reports the state; the network detail is `net status`, the freeze "
832
+ "fields are `config app get`, and recovery is `daemon reconnect`."
833
+ ),
834
+ tags=frozenset({"agent-safe"}),
835
+ )
836
+
837
+
838
+ # ---------------------------------------------------------------------------
839
+ # Reconnect and save-state
840
+ # ---------------------------------------------------------------------------
841
+
842
+
843
+ class DaemonReconnectReq(Request):
844
+ reset_proxy: Annotated[
845
+ bool, opt("--reset-proxy", help="Rebuild the client with the currently selected proxy.")
846
+ ] = False
847
+ catch_up: Annotated[
848
+ bool, opt("--catch-up/--no-catch-up", help="Fetch the difference after reconnecting.")
849
+ ] = True
850
+
851
+
852
+ async def daemon_reconnect(ctx: OpContext, req: DaemonReconnectReq) -> ReconnectResult:
853
+ """Force a reconnect, and by default a catch-up with it.
854
+
855
+ Also the documented recovery for a `TypeNotFoundError` from a constructor
856
+ of a newer layer: the guidance is to treat it like a 500 — reopen the
857
+ socket, re-`initConnection`, then `getDifference` — because a socket that
858
+ has met an unparseable constructor cannot be trusted to be in sync.
859
+ """
860
+ daemon = _daemon(ctx)
861
+ out: list[ReconnectedAccount] = []
862
+ for alias in _spanned(ctx):
863
+ session = daemon.sessions.get(alias)
864
+ if session is None:
865
+ out.append(ReconnectedAccount(alias=alias, error="not connected"))
866
+ continue
867
+ row = ReconnectedAccount(alias=alias)
868
+ try:
869
+ if req.reset_proxy:
870
+ await daemon.sessions.release(alias)
871
+ session = await daemon.sessions.ensure(alias)
872
+ else:
873
+ client = session.client
874
+ if client is not None:
875
+ await client.disconnect()
876
+ await client.connect()
877
+ row.reconnected = True
878
+ row.dc_id = getattr(getattr(session.client, "session", None), "dc_id", None)
879
+ if req.catch_up:
880
+ await session.catch_up()
881
+ session.resync_needed.clear()
882
+ row.caught_up = True
883
+ except Exception as exc:
884
+ row.error = f"{type(exc).__name__}: {exc}"
885
+ out.append(row)
886
+ if not out:
887
+ ctx.warn("no accounts are connected; nothing to reconnect")
888
+ return ReconnectResult(accounts=out)
889
+
890
+
891
+ SPEC_DAEMON_RECONNECT = OperationSpec(
892
+ id="daemon.reconnect",
893
+ request=DaemonReconnectReq,
894
+ response=ReconnectResult,
895
+ impl=daemon_reconnect,
896
+ summary="Force a reconnect (and catch-up) for one or every account",
897
+ mutating=True,
898
+ needs_account=False,
899
+ needs_client=False,
900
+ surface=Surface.DAEMON,
901
+ rate_class="local",
902
+ timeout_s=180,
903
+ columns=("accounts.alias", "accounts.reconnected", "accounts.caught_up", "accounts.error"),
904
+ example={"accounts": [{"alias": "work", "reconnected": True, "caught_up": True}]},
905
+ example_args="daemon reconnect",
906
+ covers=("updates.ops-reconnect-health", "updates.sync-old-layer-socket-reset"),
907
+ covers_partial=("updates.sync-new-session-triggers-diff",),
908
+ coverage_note="the manual recovery; the automatic one runs in the supervisor.",
909
+ tags=frozenset({"agent-safe"}),
910
+ )
911
+
912
+
913
+ class DaemonSaveStateReq(Request):
914
+ pass
915
+
916
+
917
+ async def daemon_save_state(ctx: OpContext, req: DaemonSaveStateReq) -> SaveStateResult:
918
+ """Flush pts/qts/seq and the entity cache to the session file now.
919
+
920
+ Telethon persists only on `disconnect()`, so a SIGKILL'd daemon loses both
921
+ the update state and the access hashes that make channel catch-up
922
+ possible. The daemon does this on a timer; this is the manual trigger.
923
+ """
924
+ from tlgr.core import telethon_compat as compat
925
+
926
+ daemon = _daemon(ctx)
927
+ rows: list[SavedState] = []
928
+ for alias in _spanned(ctx):
929
+ session = daemon.sessions.get(alias)
930
+ if session is None or session.client is None:
931
+ rows.append(SavedState(alias=alias, error="not connected"))
932
+ continue
933
+ row = SavedState(alias=alias)
934
+ try:
935
+ await compat.save_state(session.client)
936
+ state, channels = compat.session_state(session.client)
937
+ row.pts = state.get("pts")
938
+ row.qts = state.get("qts")
939
+ row.seq = state.get("seq")
940
+ row.date = state.get("date")
941
+ row.channels = len(channels)
942
+ row.entities = compat.entity_count(session.client)
943
+ except Exception as exc:
944
+ row.error = f"{type(exc).__name__}: {exc}"
945
+ rows.append(row)
946
+ daemon.bus.flush_state()
947
+ return SaveStateResult(accounts=rows)
948
+
949
+
950
+ SPEC_DAEMON_SAVE_STATE = OperationSpec(
951
+ id="daemon.save-state",
952
+ request=DaemonSaveStateReq,
953
+ response=SaveStateResult,
954
+ impl=daemon_save_state,
955
+ summary="Flush update state and the entity cache to the session file now",
956
+ description=(
957
+ "Telethon writes the session only on a clean `disconnect()`. A "
958
+ "SIGKILL therefore costs the `pts` progress *and* the cached access "
959
+ "hashes — and a channel whose access hash is gone is silently skipped "
960
+ "by the next catch-up."
961
+ ),
962
+ mutating=True,
963
+ idempotent=True,
964
+ needs_account=False,
965
+ needs_client=False,
966
+ surface=Surface.DAEMON,
967
+ rate_class="local",
968
+ timeout_s=60,
969
+ columns=("accounts.alias", "accounts.pts", "accounts.channels", "accounts.entities"),
970
+ example={"accounts": [{"alias": "work", "pts": 91824, "channels": 12, "entities": 480}]},
971
+ example_args="daemon save-state",
972
+ covers=("updates.sync-peer-cache-from-updates",),
973
+ covers_partial=("updates.session-persistence", "updates.sync-state-persistence"),
974
+ coverage_note="flushes it on demand; the periodic flush is the session supervisor's.",
975
+ tags=frozenset({"agent-safe"}),
976
+ )
977
+
978
+
979
+ # ---------------------------------------------------------------------------
980
+ # Floods
981
+ # ---------------------------------------------------------------------------
982
+
983
+
984
+ def _flood_kind(method: str) -> str:
985
+ lowered = method.lower()
986
+ for needle, kind in (
987
+ ("slowmode", "slowmode"),
988
+ ("premium", "premium_wait"),
989
+ ("peer_flood", "peer_flood"),
990
+ ("takeout", "takeout_delay"),
991
+ ):
992
+ if needle in lowered:
993
+ return kind
994
+ return "flood_wait"
995
+
996
+
997
+ class FloodListReq(Request):
998
+ include_expired: Annotated[
999
+ bool, opt("--include-expired", help="Also show deadlines that have already passed.")
1000
+ ] = False
1001
+
1002
+
1003
+ async def flood_list(ctx: OpContext, req: FloodListReq) -> Page[FloodRecord]:
1004
+ """The rate-limit deadlines this installation still owes.
1005
+
1006
+ tlgr keeps its own persistent store keyed `(account, method, peer)`.
1007
+ Telethon remembers a `FloodWaitError` in memory and forgets it on exit, so
1008
+ v1 re-hit every wait after a restart — and re-hitting a wait is how a
1009
+ short one becomes a long one.
1010
+ """
1011
+ daemon = _daemon(ctx)
1012
+ rows: list[FloodRecord] = []
1013
+ aliases = _spanned(ctx) or [row.alias for row in daemon.accounts.list_accounts()]
1014
+ for alias in aliases:
1015
+ limiter = daemon.sessions.limiter(alias)
1016
+ for deadline in limiter.flood.entries(include_expired=req.include_expired):
1017
+ rows.append(
1018
+ FloodRecord(
1019
+ account=alias,
1020
+ kind=_flood_kind(deadline.method),
1021
+ method=deadline.method,
1022
+ chat=deadline.peer or None,
1023
+ wait_seconds=deadline.remaining,
1024
+ until=_stamp(deadline.until),
1025
+ circuit_open=limiter.breaker.open,
1026
+ expired=deadline.remaining == 0,
1027
+ )
1028
+ )
1029
+ rows.sort(key=lambda row: (-row.wait_seconds, row.account, row.method))
1030
+ limit = int(getattr(ctx, "limit", None) or 100)
1031
+ return build_page(rows[:limit], op="daemon.flood.list", kind=PageKind.LOCAL, has_more=False)
1032
+
1033
+
1034
+ SPEC_FLOOD_LIST = OperationSpec(
1035
+ id="daemon.flood.list",
1036
+ request=FloodListReq,
1037
+ response=Page[FloodRecord],
1038
+ impl=flood_list,
1039
+ summary="List active rate-limit deadlines",
1040
+ aliases=("daemon.floods",),
1041
+ paginated=PageKind.LOCAL,
1042
+ needs_account=False,
1043
+ needs_client=False,
1044
+ surface=Surface.DAEMON,
1045
+ idempotent=True,
1046
+ rate_class="local",
1047
+ timeout_s=30,
1048
+ columns=("account", "kind", "method", "wait_seconds", "until", "circuit_open"),
1049
+ example={
1050
+ "items": [
1051
+ {
1052
+ "account": "work",
1053
+ "kind": "flood_wait",
1054
+ "method": "SendMessageRequest",
1055
+ "wait_seconds": 41,
1056
+ "until": "2026-09-03T09:20:00Z",
1057
+ }
1058
+ ],
1059
+ "has_more": False,
1060
+ },
1061
+ example_args="daemon flood list",
1062
+ covers=("updates.net-flood-wait",),
1063
+ tags=frozenset({"agent-safe"}),
1064
+ )
1065
+
1066
+
1067
+ class FloodClearReq(Request):
1068
+ method: Annotated[
1069
+ str | None, opt("--method", metavar="NAME", help="Only this request type.")
1070
+ ] = None
1071
+ chat: Annotated[
1072
+ PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Only this peer.")
1073
+ ] = None
1074
+ everything: Annotated[
1075
+ bool, opt("--every", help="Clear every remembered deadline for the account.")
1076
+ ] = False
1077
+
1078
+
1079
+ async def flood_clear(ctx: OpContext, req: FloodClearReq) -> FloodResult:
1080
+ """Forget remembered deadlines and close the circuit breaker.
1081
+
1082
+ Clearing a *live* server-side FLOOD_WAIT does not lift it — the next call
1083
+ re-trips it, more expensively. This is for after the cause is fixed, or to
1084
+ reopen an account an operator has actually looked at.
1085
+ """
1086
+ daemon = _daemon(ctx)
1087
+ if not (req.method or req.chat or req.everything):
1088
+ raise UsageError("say what to clear: --method, --chat, or --every", field="method")
1089
+ cleared = 0
1090
+ touched: list[str] = []
1091
+ for alias in _spanned(ctx) or [row.alias for row in daemon.accounts.list_accounts()]:
1092
+ limiter = daemon.sessions.limiter(alias)
1093
+ peer = req.chat.raw if req.chat is not None else None
1094
+ cleared += (
1095
+ limiter.flood.forget(method=req.method or "", peer=peer)
1096
+ if not req.everything
1097
+ else _clear_all(limiter)
1098
+ )
1099
+ limiter.reset_breaker()
1100
+ touched.append(alias)
1101
+ if not cleared:
1102
+ ctx.mark_already()
1103
+ return FloodResult(cleared=cleared, circuit_open=False, accounts=touched)
1104
+
1105
+
1106
+ def _clear_all(limiter: Any) -> int:
1107
+ count = len(limiter.flood.entries(include_expired=True))
1108
+ limiter.flood.clear()
1109
+ return count
1110
+
1111
+
1112
+ SPEC_FLOOD_CLEAR = OperationSpec(
1113
+ id="daemon.flood.clear",
1114
+ request=FloodClearReq,
1115
+ response=FloodResult,
1116
+ impl=flood_clear,
1117
+ summary="Clear remembered rate-limit deadlines and reset the circuit breaker",
1118
+ description=(
1119
+ "Local memory only. Telegram's own wait is unaffected, so clearing a "
1120
+ "deadline that has not actually passed simply spends the next request "
1121
+ "learning that again."
1122
+ ),
1123
+ mutating=True,
1124
+ destructive=True,
1125
+ needs_account=False,
1126
+ needs_client=False,
1127
+ surface=Surface.DAEMON,
1128
+ rate_class="local",
1129
+ timeout_s=30,
1130
+ example={"cleared": 3, "circuit_open": False, "accounts": ["work"]},
1131
+ example_args="daemon flood clear --every",
1132
+ covers_partial=("updates.net-flood-wait",),
1133
+ coverage_note="the reset half; the accounting is `daemon flood list`.",
1134
+ tags=frozenset({"agent-safe"}),
1135
+ )
1136
+
1137
+
1138
+ # ---------------------------------------------------------------------------
1139
+ # Dead letters
1140
+ # ---------------------------------------------------------------------------
1141
+
1142
+
1143
+ def _dead_letters(ctx: OpContext) -> tuple[Any, list[dict[str, Any]]]:
1144
+ daemon = _daemon(ctx)
1145
+ webhook = daemon.webhook
1146
+ return webhook, webhook.read_dead_letters()
1147
+
1148
+
1149
+ def _dead_letter_model(index: int, entry: dict[str, Any]) -> DeadLetter:
1150
+ identifier = str(entry.get("delivery_id") or f"dl-{index}")
1151
+ return DeadLetter(
1152
+ id=identifier,
1153
+ seq=int(entry.get("seq") or 0),
1154
+ source=str(entry.get("source", "webhook")),
1155
+ event=str(entry.get("event", "")),
1156
+ account=str(entry.get("account", "")),
1157
+ attempts=int(entry.get("attempts") or 1),
1158
+ last_error=str(entry.get("reason", "")),
1159
+ first_failed_at=str(entry.get("first_failed_at") or entry.get("ts", "")),
1160
+ last_failed_at=str(entry.get("ts", "")),
1161
+ )
1162
+
1163
+
1164
+ def _matches(entry: dict[str, Any], source: str, since: str | None, events: str | None) -> bool:
1165
+ if source != "all" and str(entry.get("source", "webhook")) != source:
1166
+ return False
1167
+ if since and str(entry.get("ts", "")) < since:
1168
+ return False
1169
+ if events:
1170
+ wanted = {part.strip() for part in events.split(",") if part.strip()}
1171
+ if str(entry.get("event", "")) not in wanted:
1172
+ return False
1173
+ return True
1174
+
1175
+
1176
+ class DeadLetterListReq(Request):
1177
+ source: Annotated[str, choice("webhook", "job", "all", help="Which consumer failed.")] = "all"
1178
+ since: Annotated[
1179
+ str | None,
1180
+ opt("--since", metavar="WHEN", kind="datetime", help="Only entries after this time."),
1181
+ ] = None
1182
+ events: Annotated[
1183
+ str | None, opt("--events", metavar="TYPES", help="Filter by event type.")
1184
+ ] = None
1185
+
1186
+
1187
+ async def dead_letter_list(ctx: OpContext, req: DeadLetterListReq) -> Page[DeadLetter]:
1188
+ """Events no consumer could be given.
1189
+
1190
+ One store, shared by the webhook pusher and the gateway actions, at mode
1191
+ 0600 and size-rotated. v1 appended full message text to a world-readable
1192
+ file that grew without limit (SEC-06).
1193
+ """
1194
+ _webhook, entries = _dead_letters(ctx)
1195
+ since = _iso(req.since)
1196
+ rows = [
1197
+ _dead_letter_model(index, entry)
1198
+ for index, entry in enumerate(entries)
1199
+ if _matches(entry, req.source, since, req.events)
1200
+ ]
1201
+ if ctx.account and ctx.account != "all":
1202
+ rows = [row for row in rows if row.account == ctx.account]
1203
+ limit = int(getattr(ctx, "limit", None) or 100)
1204
+ return build_page(
1205
+ rows[:limit],
1206
+ op="daemon.dead-letter.list",
1207
+ kind=PageKind.LOCAL,
1208
+ has_more=len(rows) > limit,
1209
+ total=len(rows),
1210
+ )
1211
+
1212
+
1213
+ def _iso(value: str | None) -> str | None:
1214
+ if not value:
1215
+ return None
1216
+ parsed = parse_dt(value)
1217
+ return parsed.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if parsed else value
1218
+
1219
+
1220
+ SPEC_DEAD_LETTER_LIST = OperationSpec(
1221
+ id="daemon.dead-letter.list",
1222
+ request=DeadLetterListReq,
1223
+ response=Page[DeadLetter],
1224
+ impl=dead_letter_list,
1225
+ summary="List events that could not be delivered",
1226
+ aliases=("webhook.dead-letter.list", "job.dead-letter.list"),
1227
+ paginated=PageKind.LOCAL,
1228
+ needs_account=False,
1229
+ needs_client=False,
1230
+ surface=Surface.DAEMON,
1231
+ idempotent=True,
1232
+ rate_class="local",
1233
+ timeout_s=30,
1234
+ columns=("id", "source", "event", "account", "attempts", "last_error", "last_failed_at"),
1235
+ example={
1236
+ "items": [
1237
+ {
1238
+ "id": "0f3c…",
1239
+ "source": "webhook",
1240
+ "event": "message_new",
1241
+ "account": "work",
1242
+ "attempts": 3,
1243
+ "last_error": "HTTP 502",
1244
+ }
1245
+ ],
1246
+ "has_more": False,
1247
+ },
1248
+ example_args="daemon dead-letter list",
1249
+ covers_partial=("updates.stream-webhook-delivery",),
1250
+ coverage_note="the failure store; delivery itself is the webhook pusher.",
1251
+ empty_exit=EXIT_EMPTY,
1252
+ tags=frozenset({"agent-safe"}),
1253
+ )
1254
+
1255
+
1256
+ class DeadLetterSendReq(Request):
1257
+ source: Annotated[str, choice("webhook", "job", "all", help="Which consumer to re-drive.")] = (
1258
+ "all"
1259
+ )
1260
+ id: Annotated[
1261
+ list[str], opt("--id", metavar="ID", help="Only these entries (repeatable).")
1262
+ ] = []
1263
+ since: Annotated[
1264
+ str | None, opt("--since", metavar="WHEN", kind="datetime", help="Only entries after this.")
1265
+ ] = None
1266
+ keep_on_success: Annotated[
1267
+ bool, opt("--keep-on-success", help="Do not remove entries that deliver.")
1268
+ ] = False
1269
+ url: Annotated[str | None, opt("--url", metavar="URL", help="Deliver to this URL instead.")] = (
1270
+ None
1271
+ )
1272
+
1273
+
1274
+ async def dead_letter_send(ctx: OpContext, req: DeadLetterSendReq) -> DeadLetterResult:
1275
+ """Re-deliver what was dead-lettered, keeping the original delivery id.
1276
+
1277
+ A receiver keyed on `Idempotency-Key` therefore sees a duplicate rather
1278
+ than a new event, which is what makes a drain safe to run twice.
1279
+ """
1280
+ webhook, entries = _dead_letters(ctx)
1281
+ since = _iso(req.since)
1282
+ wanted = set(req.id)
1283
+ remaining: list[dict[str, Any]] = []
1284
+ attempted = delivered = failed = 0
1285
+
1286
+ for index, entry in enumerate(entries):
1287
+ identifier = str(entry.get("delivery_id") or f"dl-{index}")
1288
+ if wanted and identifier not in wanted:
1289
+ remaining.append(entry)
1290
+ continue
1291
+ if not _matches(entry, req.source, since, None):
1292
+ remaining.append(entry)
1293
+ continue
1294
+ attempted += 1
1295
+ ok, error = await webhook.deliver_once(entry, url=req.url or "")
1296
+ if ok:
1297
+ delivered += 1
1298
+ if req.keep_on_success:
1299
+ remaining.append(entry)
1300
+ else:
1301
+ failed += 1
1302
+ entry["reason"] = error
1303
+ entry["attempts"] = int(entry.get("attempts") or 1) + 1
1304
+ entry["ts"] = _now()
1305
+ remaining.append(entry)
1306
+
1307
+ webhook.write_dead_letters(remaining)
1308
+ if attempted == 0:
1309
+ ctx.mark_already()
1310
+ return DeadLetterResult(
1311
+ attempted=attempted, delivered=delivered, failed=failed, remaining=len(remaining)
1312
+ )
1313
+
1314
+
1315
+ SPEC_DEAD_LETTER_SEND = OperationSpec(
1316
+ id="daemon.dead-letter.send",
1317
+ request=DeadLetterSendReq,
1318
+ response=DeadLetterResult,
1319
+ impl=dead_letter_send,
1320
+ summary="Re-deliver dead-lettered events",
1321
+ aliases=(
1322
+ "webhook.dead-letter.drain",
1323
+ "job.dead-letter.drain",
1324
+ "daemon.dead-letter.drain",
1325
+ ),
1326
+ mutating=True,
1327
+ needs_account=False,
1328
+ needs_client=False,
1329
+ surface=Surface.DAEMON,
1330
+ rate_class="local",
1331
+ timeout_s=300,
1332
+ example={"attempted": 4, "delivered": 3, "failed": 1, "remaining": 1},
1333
+ example_args="daemon dead-letter send",
1334
+ covers_partial=("updates.stream-webhook-delivery",),
1335
+ coverage_note="the replay half; the live delivery path is the webhook pusher.",
1336
+ tags=frozenset({"agent-safe"}),
1337
+ )
1338
+
1339
+
1340
+ class DeadLetterDeleteReq(Request):
1341
+ source: Annotated[str, choice("webhook", "job", "all", help="Restrict by consumer.")] = "all"
1342
+ id: Annotated[
1343
+ list[str], opt("--id", metavar="ID", help="Only these entries (repeatable).")
1344
+ ] = []
1345
+ until: Annotated[
1346
+ str | None,
1347
+ opt("--until", metavar="WHEN", kind="datetime", help="Only entries older than this."),
1348
+ ] = None
1349
+ everything: Annotated[bool, opt("--every", help="Discard everything.")] = False
1350
+
1351
+
1352
+ async def dead_letter_delete(ctx: OpContext, req: DeadLetterDeleteReq) -> DeadLetterResult:
1353
+ """Discard dead-lettered events permanently."""
1354
+ webhook, entries = _dead_letters(ctx)
1355
+ if not (req.id or req.until or req.everything):
1356
+ raise UsageError("say what to delete: --id, --until, or --every", field="id")
1357
+ until = _iso(req.until)
1358
+ wanted = set(req.id)
1359
+ remaining: list[dict[str, Any]] = []
1360
+ deleted = 0
1361
+ for index, entry in enumerate(entries):
1362
+ identifier = str(entry.get("delivery_id") or f"dl-{index}")
1363
+ drop = req.everything
1364
+ if wanted:
1365
+ drop = identifier in wanted
1366
+ elif until:
1367
+ drop = str(entry.get("ts", "")) < until
1368
+ if drop and _matches(entry, req.source, None, None):
1369
+ deleted += 1
1370
+ continue
1371
+ remaining.append(entry)
1372
+ webhook.write_dead_letters(remaining)
1373
+ if deleted == 0:
1374
+ ctx.mark_already()
1375
+ return DeadLetterResult(deleted=deleted, remaining=len(remaining))
1376
+
1377
+
1378
+ SPEC_DEAD_LETTER_DELETE = OperationSpec(
1379
+ id="daemon.dead-letter.delete",
1380
+ request=DeadLetterDeleteReq,
1381
+ response=DeadLetterResult,
1382
+ impl=dead_letter_delete,
1383
+ summary="Permanently discard dead-lettered events",
1384
+ aliases=("webhook.dead-letter.clear", "job.dead-letter.clear"),
1385
+ mutating=True,
1386
+ destructive=True,
1387
+ needs_account=False,
1388
+ needs_client=False,
1389
+ surface=Surface.DAEMON,
1390
+ rate_class="local",
1391
+ timeout_s=60,
1392
+ example={"deleted": 12, "remaining": 0},
1393
+ example_args="daemon dead-letter delete --every",
1394
+ covers_partial=("updates.stream-webhook-delivery",),
1395
+ coverage_note="the disposal half; delivery is the webhook pusher's.",
1396
+ tags=frozenset({"agent-safe"}),
1397
+ )