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