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/daemon/app.py
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
"""The daemon object and its aiohttp application (§5, §6.1).
|
|
2
|
+
|
|
3
|
+
Everything that used to be spread across `server.py` and `ipc.py` — deciding
|
|
4
|
+
who may connect, which account answers, what an exception means, whether the
|
|
5
|
+
daemon may stop — happens in one middleware chain here. That matters most for
|
|
6
|
+
the routes this file does **not** define: the v1 routes in `ipc.py` are
|
|
7
|
+
registered into the same application, so peer-uid authentication, the policy
|
|
8
|
+
allowlist, the §7.2 error mapping and idle accounting apply to every
|
|
9
|
+
unmigrated command from day one rather than at its own group's PR (§12.4).
|
|
10
|
+
|
|
11
|
+
Readiness is published in two steps, deliberately. The socket is bound and
|
|
12
|
+
`ready: false` is served *before* any account connects (ROB-07), so a CLI can
|
|
13
|
+
tell "the process is alive" from "the daemon works" (COR-37) instead of
|
|
14
|
+
waiting on a connect that may take thirty seconds or never finish.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import contextlib
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from aiohttp import web
|
|
31
|
+
|
|
32
|
+
from tlgr import __version__
|
|
33
|
+
from tlgr.core.accounts import AccountManager
|
|
34
|
+
from tlgr.core.config import AppConfig, load_app_config, load_webhook_config
|
|
35
|
+
from tlgr.core.errors import (
|
|
36
|
+
DaemonVersionMismatchError,
|
|
37
|
+
PermissionError_,
|
|
38
|
+
RetryableError,
|
|
39
|
+
UsageError,
|
|
40
|
+
classify,
|
|
41
|
+
error_body_dict,
|
|
42
|
+
http_status_for,
|
|
43
|
+
)
|
|
44
|
+
from tlgr.core.paths import TlgrPaths, write_private
|
|
45
|
+
from tlgr.daemon import dispatch as dispatch_module
|
|
46
|
+
from tlgr.daemon.events import EventBus
|
|
47
|
+
from tlgr.daemon.files import TransferSlots
|
|
48
|
+
from tlgr.daemon.idle import ActivityTracker, effective_idle_timeout
|
|
49
|
+
from tlgr.daemon.jobs import JobRunner
|
|
50
|
+
from tlgr.daemon.peercred import current_uid, peer_of, token_matches
|
|
51
|
+
from tlgr.daemon.policy import Policy
|
|
52
|
+
from tlgr.daemon.preauth import PreAuthService
|
|
53
|
+
from tlgr.daemon.sessions import SessionManager
|
|
54
|
+
from tlgr.daemon.stream import NdjsonResponse, walk_pages
|
|
55
|
+
from tlgr.daemon.transfers import TransferStore
|
|
56
|
+
from tlgr.daemon.webhook import WebhookPusher
|
|
57
|
+
from tlgr.version import HEADER_PROTOCOL, HEADER_TOKEN, MIN_DAEMON_PROTOCOL, PROTOCOL
|
|
58
|
+
|
|
59
|
+
log = logging.getLogger("tlgr.daemon")
|
|
60
|
+
|
|
61
|
+
__all__ = ["Daemon", "build_app"]
|
|
62
|
+
|
|
63
|
+
_V1_PREFIX = "/v1/"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _stamp(value: float | None) -> str | None:
|
|
67
|
+
if not value:
|
|
68
|
+
return None
|
|
69
|
+
return datetime.fromtimestamp(value, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Daemon:
|
|
73
|
+
"""The process's state: sessions, bus, webhook, jobs, activity, policy."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
base: Path | None = None,
|
|
78
|
+
*,
|
|
79
|
+
config: AppConfig | None = None,
|
|
80
|
+
client_factory: Any = None,
|
|
81
|
+
managed_by: str = "",
|
|
82
|
+
) -> None:
|
|
83
|
+
self.paths = TlgrPaths(base)
|
|
84
|
+
self.base = self.paths.base
|
|
85
|
+
self.config = config or load_app_config(self.paths.base)
|
|
86
|
+
self.accounts = AccountManager(self.paths.base)
|
|
87
|
+
self.policy = Policy.from_config(self.config.policy.allow, self.config.policy.deny)
|
|
88
|
+
self.activity = ActivityTracker()
|
|
89
|
+
self.bus = EventBus(
|
|
90
|
+
state_dir_for=self.paths.events_state,
|
|
91
|
+
buffer_size=self.config.daemon.event_buffer,
|
|
92
|
+
workers=self.config.daemon.event_workers,
|
|
93
|
+
)
|
|
94
|
+
factory_kwargs = {"client_factory": client_factory} if client_factory else {}
|
|
95
|
+
self.sessions = SessionManager(
|
|
96
|
+
self.paths,
|
|
97
|
+
self.config,
|
|
98
|
+
accounts=self.accounts,
|
|
99
|
+
on_session_ready=self._attach_handlers,
|
|
100
|
+
**factory_kwargs,
|
|
101
|
+
)
|
|
102
|
+
self.webhook_config = load_webhook_config(self.paths.base)
|
|
103
|
+
self.webhook = WebhookPusher(self.webhook_config, self.paths.base)
|
|
104
|
+
self._job_runner = JobRunner()
|
|
105
|
+
# Long file transfers: the ones `--background` hands over, and the
|
|
106
|
+
# per-DC budgets that stop one 2 GB download from starving five
|
|
107
|
+
# thumbnail fetches (§6.7).
|
|
108
|
+
self.transfers = TransferStore()
|
|
109
|
+
self.transfer_slots = TransferSlots()
|
|
110
|
+
# Login runs in the daemon because the daemon owns the session files
|
|
111
|
+
# (§6.8); a pending login holds one, so it counts as activity and the
|
|
112
|
+
# idle monitor cannot stop the daemon out from under a half-finished
|
|
113
|
+
# sign-in.
|
|
114
|
+
self.preauth = PreAuthService(self.sessions)
|
|
115
|
+
self.managed_by = managed_by
|
|
116
|
+
self.ready = False
|
|
117
|
+
self.shutting_down = asyncio.Event()
|
|
118
|
+
self._shutdown_event = asyncio.Event()
|
|
119
|
+
self._start_time = time.time()
|
|
120
|
+
self._runner: web.AppRunner | None = None
|
|
121
|
+
self._idle_task: asyncio.Task[None] | None = None
|
|
122
|
+
self._token: str | None = None
|
|
123
|
+
self.idle_timeout = effective_idle_timeout(
|
|
124
|
+
self.config.daemon.idle_timeout,
|
|
125
|
+
webhook_enabled=self.webhook_config.enabled,
|
|
126
|
+
managed_by=managed_by,
|
|
127
|
+
)
|
|
128
|
+
self.activity.webhook_enabled = self.webhook_config.enabled
|
|
129
|
+
|
|
130
|
+
# -- authentication ----------------------------------------------------
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def token(self) -> str | None:
|
|
134
|
+
if self._token is None:
|
|
135
|
+
try:
|
|
136
|
+
self._token = self.paths.token.read_text().strip() or ""
|
|
137
|
+
except OSError:
|
|
138
|
+
self._token = ""
|
|
139
|
+
return self._token or None
|
|
140
|
+
|
|
141
|
+
# -- session plumbing --------------------------------------------------
|
|
142
|
+
|
|
143
|
+
async def _attach_handlers(self, session: Any) -> None:
|
|
144
|
+
"""Feed a newly connected account's updates into the bus.
|
|
145
|
+
|
|
146
|
+
The handler body is deliberately tiny: normalise, number, fan out. Any
|
|
147
|
+
real work happens on a bus worker lane, because with
|
|
148
|
+
`sequential_updates=True` a slow handler stalls every account (ROB-02).
|
|
149
|
+
"""
|
|
150
|
+
client = session.client
|
|
151
|
+
if client is None:
|
|
152
|
+
return
|
|
153
|
+
register = getattr(client, "add_event_handler", None)
|
|
154
|
+
if register is None:
|
|
155
|
+
return
|
|
156
|
+
from tlgr.daemon.events import normalise
|
|
157
|
+
|
|
158
|
+
alias = session.alias
|
|
159
|
+
|
|
160
|
+
async def on_update(event: Any) -> None:
|
|
161
|
+
session.note_update()
|
|
162
|
+
normalised = normalise(alias, event)
|
|
163
|
+
if normalised is None:
|
|
164
|
+
return
|
|
165
|
+
event_type, payload, chat_id, sender_id = normalised
|
|
166
|
+
self.bus.emit(
|
|
167
|
+
alias,
|
|
168
|
+
event_type,
|
|
169
|
+
payload,
|
|
170
|
+
chat_id=chat_id,
|
|
171
|
+
sender_id=sender_id,
|
|
172
|
+
raw=event,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
from telethon import events as tl_events
|
|
177
|
+
|
|
178
|
+
# One `Raw` handler, not six high-level ones. The high-level
|
|
179
|
+
# builders drop service messages, topic ids and every action kind
|
|
180
|
+
# Telethon does not model, so a stream built on them can only ever
|
|
181
|
+
# show a subset of what the GUI shows; `normalise` names all 163
|
|
182
|
+
# update constructors instead (docs/design/EVENTS.md). The six
|
|
183
|
+
# story updates, which have no builder at all, arrive here too.
|
|
184
|
+
register(on_update, tl_events.Raw())
|
|
185
|
+
except Exception as exc: # pragma: no cover - a fake client has no builders
|
|
186
|
+
log.debug("could not register the raw Telethon handler for %s: %s", alias, exc)
|
|
187
|
+
register(on_update)
|
|
188
|
+
|
|
189
|
+
# -- the job engine's account handles ---------------------------------
|
|
190
|
+
|
|
191
|
+
def get_client(self, account: str = "") -> Any:
|
|
192
|
+
if not account:
|
|
193
|
+
return None
|
|
194
|
+
session = self.sessions.get(account)
|
|
195
|
+
return session.job_client if session and session.client is not None else None
|
|
196
|
+
|
|
197
|
+
async def ensure_client(self, account: str = "") -> Any:
|
|
198
|
+
"""Connect an account on demand and hand back its job client.
|
|
199
|
+
|
|
200
|
+
Returning `None` for an empty account is the point: v1 answered with
|
|
201
|
+
"whichever client came first", so an under-specified request silently
|
|
202
|
+
used the wrong identity (COR-02).
|
|
203
|
+
"""
|
|
204
|
+
if not account:
|
|
205
|
+
return None
|
|
206
|
+
try:
|
|
207
|
+
session = await self.sessions.ensure(account)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
log.warning(
|
|
210
|
+
"on-demand connect failed for %s: %s", account, exc, extra={"account": account}
|
|
211
|
+
)
|
|
212
|
+
return None
|
|
213
|
+
with contextlib.suppress(Exception):
|
|
214
|
+
await session.acquire(timeout=15.0)
|
|
215
|
+
return session.job_client if session.client is not None else None
|
|
216
|
+
|
|
217
|
+
def touch_ipc(self) -> None:
|
|
218
|
+
self.activity.touch()
|
|
219
|
+
|
|
220
|
+
def list_jobs(self) -> list[dict[str, Any]]:
|
|
221
|
+
return self._job_runner.list_jobs()
|
|
222
|
+
|
|
223
|
+
async def remove_job(self, name: str) -> bool:
|
|
224
|
+
return await self._job_runner.remove_job(name)
|
|
225
|
+
|
|
226
|
+
async def enable_job(self, name: str) -> bool:
|
|
227
|
+
return await self._job_runner.enable_job(name)
|
|
228
|
+
|
|
229
|
+
async def disable_job(self, name: str) -> bool:
|
|
230
|
+
return await self._job_runner.disable_job(name)
|
|
231
|
+
|
|
232
|
+
async def reload_jobs(self) -> dict[str, Any]:
|
|
233
|
+
from tlgr.gateway.config import load_gateway_configs
|
|
234
|
+
|
|
235
|
+
new_configs = await asyncio.to_thread(load_gateway_configs, self.base)
|
|
236
|
+
default_account = self.config.default_account or self.accounts.get_active() or ""
|
|
237
|
+
old_names = set(self._job_runner._jobs)
|
|
238
|
+
new_names = {jc.name for jc in new_configs}
|
|
239
|
+
removed = old_names - new_names
|
|
240
|
+
added = new_names - old_names
|
|
241
|
+
updated = old_names & new_names
|
|
242
|
+
|
|
243
|
+
for name in removed:
|
|
244
|
+
await self._job_runner.remove_job(name)
|
|
245
|
+
for job_config in new_configs:
|
|
246
|
+
if job_config.name not in added and job_config.name not in updated:
|
|
247
|
+
continue
|
|
248
|
+
if job_config.name in updated:
|
|
249
|
+
await self._job_runner.remove_job(job_config.name)
|
|
250
|
+
if not job_config.enabled:
|
|
251
|
+
continue
|
|
252
|
+
alias = job_config.account or default_account
|
|
253
|
+
client = await self.ensure_client(alias)
|
|
254
|
+
if client is None:
|
|
255
|
+
log.warning("job %r references unusable account %r", job_config.name, alias)
|
|
256
|
+
continue
|
|
257
|
+
try:
|
|
258
|
+
job = self._job_runner.create_job(job_config, client, self.webhook, self.bus)
|
|
259
|
+
if job.enabled:
|
|
260
|
+
job.start()
|
|
261
|
+
except Exception:
|
|
262
|
+
log.exception("could not create job %r", job_config.name)
|
|
263
|
+
self.activity.jobs_running = len(
|
|
264
|
+
[j for j in self._job_runner.list_jobs() if j.get("running")]
|
|
265
|
+
)
|
|
266
|
+
return {
|
|
267
|
+
"reloaded": True,
|
|
268
|
+
"added": sorted(added),
|
|
269
|
+
"removed": sorted(removed),
|
|
270
|
+
"updated": sorted(updated),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def request_shutdown(self) -> None:
|
|
274
|
+
self._shutdown_event.set()
|
|
275
|
+
|
|
276
|
+
# -- the v2 status -----------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def v1_status(self) -> dict[str, Any]:
|
|
279
|
+
return {
|
|
280
|
+
"ok": True,
|
|
281
|
+
"daemon": {
|
|
282
|
+
"version": __version__,
|
|
283
|
+
"protocol": PROTOCOL,
|
|
284
|
+
"pid": os.getpid(),
|
|
285
|
+
"uptime_s": int(time.time() - self._start_time),
|
|
286
|
+
"ready": self.ready,
|
|
287
|
+
"started_at": _stamp(self._start_time),
|
|
288
|
+
"managed_by": self.managed_by or None,
|
|
289
|
+
"idle_timeout_s": self.idle_timeout,
|
|
290
|
+
"socket": str(self.paths.socket),
|
|
291
|
+
"shutting_down": self.shutting_down.is_set(),
|
|
292
|
+
},
|
|
293
|
+
"accounts": self.sessions.snapshot(),
|
|
294
|
+
"jobs": self._job_runner.list_jobs(),
|
|
295
|
+
"webhook": self.webhook.snapshot(),
|
|
296
|
+
"activity": {
|
|
297
|
+
**{**self.activity.snapshot(), "pending_logins": self.preauth.pending_count},
|
|
298
|
+
"last_request": _stamp(self.activity.last_request_at),
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# -- lifecycle ---------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def write_state(self) -> None:
|
|
305
|
+
write_private(
|
|
306
|
+
self.paths.state,
|
|
307
|
+
json.dumps(
|
|
308
|
+
{
|
|
309
|
+
"version": __version__,
|
|
310
|
+
"protocol": PROTOCOL,
|
|
311
|
+
"pid": os.getpid(),
|
|
312
|
+
"socket": str(self.paths.socket),
|
|
313
|
+
"managed_by": self.managed_by,
|
|
314
|
+
"started_at": _stamp(self._start_time),
|
|
315
|
+
},
|
|
316
|
+
indent=2,
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
async def bind(self) -> web.AppRunner:
|
|
321
|
+
"""Bind the socket and start serving with `ready: false` (ROB-07)."""
|
|
322
|
+
app = build_app(self)
|
|
323
|
+
runner = web.AppRunner(app, access_log=None)
|
|
324
|
+
await runner.setup()
|
|
325
|
+
site = web.UnixSite(runner, str(self.paths.socket))
|
|
326
|
+
await site.start()
|
|
327
|
+
os.chmod(self.paths.socket, 0o600)
|
|
328
|
+
self._runner = runner
|
|
329
|
+
self.write_state()
|
|
330
|
+
log.info(
|
|
331
|
+
"daemon listening on %s", self.paths.socket, extra={"path": str(self.paths.socket)}
|
|
332
|
+
)
|
|
333
|
+
return runner
|
|
334
|
+
|
|
335
|
+
async def start_services(self) -> None:
|
|
336
|
+
await self.bus.start()
|
|
337
|
+
await self.webhook.start()
|
|
338
|
+
if self.webhook_config.enabled:
|
|
339
|
+
self.bus.add_handler(self.webhook.on_event)
|
|
340
|
+
|
|
341
|
+
async def connect_accounts(self) -> dict[str, str]:
|
|
342
|
+
aliases = self.accounts.connect_order(
|
|
343
|
+
self.config.default_account, list(self.config.daemon.preconnect)
|
|
344
|
+
)
|
|
345
|
+
if not aliases:
|
|
346
|
+
return {}
|
|
347
|
+
return await self.sessions.connect_all(aliases)
|
|
348
|
+
|
|
349
|
+
async def run(self) -> None:
|
|
350
|
+
"""Bind, connect, publish ready, and wait for a shutdown signal."""
|
|
351
|
+
await self.start_services()
|
|
352
|
+
await self.bind()
|
|
353
|
+
results = await self.connect_accounts()
|
|
354
|
+
self.ready = True
|
|
355
|
+
log.info(
|
|
356
|
+
"daemon ready with %d account(s)",
|
|
357
|
+
len(results),
|
|
358
|
+
extra={"count": len(results)},
|
|
359
|
+
)
|
|
360
|
+
self._idle_task = asyncio.create_task(self._idle_monitor(), name="tlgr-idle")
|
|
361
|
+
await self._shutdown_event.wait()
|
|
362
|
+
await self.shutdown()
|
|
363
|
+
|
|
364
|
+
async def _idle_monitor(self) -> None:
|
|
365
|
+
while not self._shutdown_event.is_set():
|
|
366
|
+
await asyncio.sleep(5)
|
|
367
|
+
if self.idle_timeout <= 0:
|
|
368
|
+
continue
|
|
369
|
+
self.activity.pending_logins = self.preauth.pending_count
|
|
370
|
+
if self.activity.may_stop(self.idle_timeout):
|
|
371
|
+
log.info(
|
|
372
|
+
"idle for %ds with nothing in flight — stopping",
|
|
373
|
+
int(self.activity.idle_seconds()),
|
|
374
|
+
)
|
|
375
|
+
self.request_shutdown()
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
async def shutdown(self, *, drain: float | None = None) -> None:
|
|
379
|
+
"""The ordered teardown of §6.11."""
|
|
380
|
+
if self.shutting_down.is_set():
|
|
381
|
+
return
|
|
382
|
+
self.shutting_down.set()
|
|
383
|
+
self.ready = False
|
|
384
|
+
deadline = time.monotonic() + (
|
|
385
|
+
drain if drain is not None else self.config.daemon.drain_seconds
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if self._idle_task is not None:
|
|
389
|
+
self._idle_task.cancel()
|
|
390
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
391
|
+
await self._idle_task
|
|
392
|
+
|
|
393
|
+
# Wait for in-flight requests rather than cancelling them: a ten
|
|
394
|
+
# minute scan that is killed at second 599 has cost the account the
|
|
395
|
+
# requests it already made and produced nothing (COR-11).
|
|
396
|
+
while self.activity.in_flight > 0 and time.monotonic() < deadline:
|
|
397
|
+
await asyncio.sleep(0.05)
|
|
398
|
+
|
|
399
|
+
with contextlib.suppress(Exception):
|
|
400
|
+
await self._job_runner.stop_all()
|
|
401
|
+
# A half-written download keeps its `.part` file, so the next
|
|
402
|
+
# `media download --resume` continues where the shutdown stopped it.
|
|
403
|
+
with contextlib.suppress(Exception):
|
|
404
|
+
await self.transfers.stop_all()
|
|
405
|
+
with contextlib.suppress(Exception):
|
|
406
|
+
await self.webhook.stop()
|
|
407
|
+
await self.bus.stop()
|
|
408
|
+
await self.sessions.stop_all()
|
|
409
|
+
if self._runner is not None:
|
|
410
|
+
with contextlib.suppress(Exception):
|
|
411
|
+
await self._runner.cleanup()
|
|
412
|
+
self._runner = None
|
|
413
|
+
for path in (self.paths.socket, self.paths.state):
|
|
414
|
+
with contextlib.suppress(OSError):
|
|
415
|
+
path.unlink(missing_ok=True)
|
|
416
|
+
log.info("daemon stopped")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
# Middleware
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _is_v1(request: web.Request) -> bool:
|
|
425
|
+
return request.path.startswith(_V1_PREFIX)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _error_response(request: web.Request, exc: BaseException, *, op: str = "") -> web.Response:
|
|
429
|
+
"""One error shape per surface, from one classification (§7.1, COR-06).
|
|
430
|
+
|
|
431
|
+
v1 collapsed every failure to `IPC_ERROR`/exit 12 on the way out of the
|
|
432
|
+
daemon, so a flood wait, a missing chat and a permission error were
|
|
433
|
+
indistinguishable to the caller. The body differs between the two surfaces
|
|
434
|
+
only in its wrapper: the v1 routes keep the flat shape their callers
|
|
435
|
+
parse, and both carry the same classified code and exit status.
|
|
436
|
+
"""
|
|
437
|
+
body = error_body_dict(classify(exc))
|
|
438
|
+
status = http_status_for(exc)
|
|
439
|
+
if _is_v1(request):
|
|
440
|
+
payload: dict[str, Any] = {"ok": False, "error": body}
|
|
441
|
+
if op:
|
|
442
|
+
payload["op"] = op
|
|
443
|
+
else:
|
|
444
|
+
payload = dict(body)
|
|
445
|
+
return web.Response(
|
|
446
|
+
body=json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8"),
|
|
447
|
+
content_type="application/json",
|
|
448
|
+
status=status,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@web.middleware
|
|
453
|
+
async def error_middleware(request: web.Request, handler: Any) -> web.StreamResponse:
|
|
454
|
+
daemon: Daemon = request.app[DAEMON_KEY]
|
|
455
|
+
try:
|
|
456
|
+
return await handler(request)
|
|
457
|
+
except web.HTTPException:
|
|
458
|
+
raise
|
|
459
|
+
except asyncio.CancelledError:
|
|
460
|
+
# The client hung up. Not an error, and not something to answer.
|
|
461
|
+
raise
|
|
462
|
+
except Exception as exc:
|
|
463
|
+
log.info(
|
|
464
|
+
"request failed: %s",
|
|
465
|
+
exc,
|
|
466
|
+
extra={"path": request.path, "code": classify(exc).code},
|
|
467
|
+
)
|
|
468
|
+
return _error_response(request, exc)
|
|
469
|
+
finally:
|
|
470
|
+
daemon.touch_ipc()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@web.middleware
|
|
474
|
+
async def auth_middleware(request: web.Request, handler: Any) -> web.StreamResponse:
|
|
475
|
+
"""Peer uid, with a shared token as the fallback (SEC-01, §8.2)."""
|
|
476
|
+
daemon: Daemon = request.app[DAEMON_KEY]
|
|
477
|
+
security = daemon.config.security
|
|
478
|
+
supplied = request.headers.get(HEADER_TOKEN)
|
|
479
|
+
|
|
480
|
+
peer = None
|
|
481
|
+
if security.peer_uid_check:
|
|
482
|
+
transport = request.transport
|
|
483
|
+
sock = transport.get_extra_info("socket") if transport is not None else None
|
|
484
|
+
peer = peer_of(sock)
|
|
485
|
+
|
|
486
|
+
if peer is not None and peer.uid != current_uid():
|
|
487
|
+
log.warning(
|
|
488
|
+
"refused a connection from uid %s (pid %s)",
|
|
489
|
+
peer.uid,
|
|
490
|
+
peer.pid,
|
|
491
|
+
extra={"uid": peer.uid, "pid": peer.pid, "path": request.path},
|
|
492
|
+
)
|
|
493
|
+
return _error_response(
|
|
494
|
+
request, PermissionError_("this socket only accepts connections from its own user")
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
needs_token = security.require_token or (peer is None and security.peer_uid_check)
|
|
498
|
+
if needs_token and not token_matches(supplied, daemon.token):
|
|
499
|
+
log.warning(
|
|
500
|
+
"refused a connection with a missing or wrong token", extra={"path": request.path}
|
|
501
|
+
)
|
|
502
|
+
return _error_response(
|
|
503
|
+
request,
|
|
504
|
+
PermissionError_(
|
|
505
|
+
"this daemon requires X-Tlgr-Token; the token is in ~/.tlgr/ipc.token"
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
return await handler(request)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@web.middleware
|
|
512
|
+
async def version_middleware(request: web.Request, handler: Any) -> web.StreamResponse:
|
|
513
|
+
"""Refuse a client we cannot understand; never guess (§5.7)."""
|
|
514
|
+
raw = request.headers.get(HEADER_PROTOCOL)
|
|
515
|
+
if raw and _is_v1(request) and request.path != "/v1/status":
|
|
516
|
+
try:
|
|
517
|
+
client_protocol = int(raw)
|
|
518
|
+
except ValueError:
|
|
519
|
+
return _error_response(request, UsageError(f"invalid {HEADER_PROTOCOL}: {raw!r}"))
|
|
520
|
+
if client_protocol < MIN_DAEMON_PROTOCOL:
|
|
521
|
+
return _error_response(
|
|
522
|
+
request,
|
|
523
|
+
DaemonVersionMismatchError(
|
|
524
|
+
f"this daemon speaks protocol {PROTOCOL}; the client speaks "
|
|
525
|
+
f"{client_protocol}. Restart the daemon: tlgr daemon restart"
|
|
526
|
+
),
|
|
527
|
+
)
|
|
528
|
+
return await handler(request)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def _requested_flood_budget(request: web.Request) -> tuple[str, int | None]:
|
|
532
|
+
"""`(account, flood_wait_max)` from a legacy request's body or query.
|
|
533
|
+
|
|
534
|
+
Reading the body here is safe: aiohttp caches it, so the handler's own
|
|
535
|
+
`request.json()` sees the same bytes.
|
|
536
|
+
"""
|
|
537
|
+
account = request.query.get("account", "")
|
|
538
|
+
raw = request.query.get("flood_wait_max")
|
|
539
|
+
if request.method in ("POST", "PUT", "PATCH"):
|
|
540
|
+
with contextlib.suppress(Exception):
|
|
541
|
+
body = await request.json()
|
|
542
|
+
if isinstance(body, dict):
|
|
543
|
+
account = str(body.get("account", account) or account)
|
|
544
|
+
raw = body.get("flood_wait_max", raw)
|
|
545
|
+
if raw in (None, ""):
|
|
546
|
+
return account, None
|
|
547
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
548
|
+
return account, int(raw)
|
|
549
|
+
return account, None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@web.middleware
|
|
553
|
+
async def flood_budget_middleware(request: web.Request, handler: Any) -> web.StreamResponse:
|
|
554
|
+
"""Honour `--flood-wait-max` on the v1 routes as well (COR-15).
|
|
555
|
+
|
|
556
|
+
The v2 dispatcher applies the budget itself, per operation. The forty
|
|
557
|
+
hand-written v1 handlers do not thread the value through, so it is applied
|
|
558
|
+
here instead — which is why the flag now means something for every command
|
|
559
|
+
rather than for none.
|
|
560
|
+
"""
|
|
561
|
+
if _is_v1(request):
|
|
562
|
+
return await handler(request)
|
|
563
|
+
daemon: Daemon = request.app[DAEMON_KEY]
|
|
564
|
+
account, budget = await _requested_flood_budget(request)
|
|
565
|
+
session = daemon.sessions.get(account) if account and budget is not None else None
|
|
566
|
+
if session is None:
|
|
567
|
+
return await handler(request)
|
|
568
|
+
with session.flood_budget(budget):
|
|
569
|
+
return await handler(request)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@web.middleware
|
|
573
|
+
async def activity_middleware(request: web.Request, handler: Any) -> web.StreamResponse:
|
|
574
|
+
"""Count the request, and refuse new work while shutting down (§6.11)."""
|
|
575
|
+
daemon: Daemon = request.app[DAEMON_KEY]
|
|
576
|
+
if daemon.shutting_down.is_set() and request.path != "/v1/status":
|
|
577
|
+
return _error_response(
|
|
578
|
+
request, RetryableError("the daemon is shutting down; retry in a moment")
|
|
579
|
+
)
|
|
580
|
+
daemon.activity.begin_request()
|
|
581
|
+
try:
|
|
582
|
+
return await handler(request)
|
|
583
|
+
finally:
|
|
584
|
+
daemon.activity.end_request()
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# ---------------------------------------------------------------------------
|
|
588
|
+
# Routes
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def handle_op(request: web.Request) -> web.StreamResponse:
|
|
593
|
+
daemon: Daemon = request.app[DAEMON_KEY]
|
|
594
|
+
raw = await request.read()
|
|
595
|
+
op_request = dispatch_module.decode_request(raw)
|
|
596
|
+
if op_request.stream or op_request.all or _is_stream_op(op_request.op):
|
|
597
|
+
# A streaming operation is streamed whether or not the caller
|
|
598
|
+
# remembered to say so: its result is an async iterator, and answering
|
|
599
|
+
# a plain POST with "cannot encode an async_generator" would report a
|
|
600
|
+
# tlgr bug as the caller's mistake.
|
|
601
|
+
return await _handle_op_stream(request, daemon, op_request)
|
|
602
|
+
envelope = await dispatch_module.dispatch(daemon, op_request)
|
|
603
|
+
return web.Response(
|
|
604
|
+
body=json.dumps(envelope, ensure_ascii=False, default=str).encode("utf-8"),
|
|
605
|
+
content_type="application/json",
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _is_stream_op(op_id: str) -> bool:
|
|
610
|
+
from tlgr.registry import ALIASES, REGISTRY
|
|
611
|
+
|
|
612
|
+
spec = REGISTRY.get(ALIASES.get(op_id.replace(" ", "."), op_id))
|
|
613
|
+
return bool(spec is not None and spec.stream)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
async def _handle_op_stream(
|
|
617
|
+
request: web.Request, daemon: Daemon, op_request: Any
|
|
618
|
+
) -> web.StreamResponse:
|
|
619
|
+
from tlgr.daemon.dispatch import resolve_spec
|
|
620
|
+
|
|
621
|
+
stream = NdjsonResponse(request)
|
|
622
|
+
try:
|
|
623
|
+
spec = resolve_spec(op_request.op)
|
|
624
|
+
except Exception as exc:
|
|
625
|
+
return _error_response(request, exc, op=op_request.op)
|
|
626
|
+
await stream.prepare(op=spec.id, account=op_request.account, request_id=op_request.request_id)
|
|
627
|
+
started = time.monotonic()
|
|
628
|
+
try:
|
|
629
|
+
# `spec` is the same object `resolve_spec` returned above; the
|
|
630
|
+
# execute() call is what runs the policy/account/timeout prologue.
|
|
631
|
+
_, context, result = await dispatch_module.execute(daemon, op_request)
|
|
632
|
+
if "frames" in spec.tags and hasattr(result, "__aiter__"):
|
|
633
|
+
# A frame-producing operation writes its own NDJSON vocabulary —
|
|
634
|
+
# events, `gap`, `lag`, `heartbeat`. Wrapping those in `item`
|
|
635
|
+
# frames would make a heartbeat indistinguishable from an event
|
|
636
|
+
# for anybody reading the stream one line at a time.
|
|
637
|
+
count = 0
|
|
638
|
+
async for frame in result:
|
|
639
|
+
if not isinstance(frame, dict): # pragma: no cover - impl contract
|
|
640
|
+
continue
|
|
641
|
+
await stream.write(frame)
|
|
642
|
+
count += 1
|
|
643
|
+
elif hasattr(result, "__aiter__"):
|
|
644
|
+
# A `--all` walk paces itself against the account's own limiter,
|
|
645
|
+
# inside the daemon: v1 looped in the client and hammered the
|
|
646
|
+
# socket with no backpressure between pages (ROB-01).
|
|
647
|
+
count = await walk_pages(
|
|
648
|
+
result, stream, limiter=context.limiter, rate_class=spec.rate_class
|
|
649
|
+
)
|
|
650
|
+
else:
|
|
651
|
+
count = await _stream_result(stream, result)
|
|
652
|
+
except Exception as exc:
|
|
653
|
+
return await stream.fail(exc, account=op_request.account)
|
|
654
|
+
return await stream.end(
|
|
655
|
+
ok=True, count=count, elapsed_ms=int((time.monotonic() - started) * 1000)
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
async def _stream_result(stream: NdjsonResponse, result: Any) -> int:
|
|
660
|
+
"""Emit a non-streaming result as items, so the framing is the same."""
|
|
661
|
+
from tlgr.models.base import to_builtins
|
|
662
|
+
|
|
663
|
+
body = to_builtins(result) if result is not None else None
|
|
664
|
+
if isinstance(body, dict) and isinstance(body.get("items"), list):
|
|
665
|
+
items = body["items"]
|
|
666
|
+
page = {
|
|
667
|
+
"has_more": bool(body.get("has_more")),
|
|
668
|
+
"next_cursor": body.get("next_cursor"),
|
|
669
|
+
}
|
|
670
|
+
else:
|
|
671
|
+
items = body if isinstance(body, list) else [body]
|
|
672
|
+
page = None
|
|
673
|
+
for index, item in enumerate(items, start=1):
|
|
674
|
+
await stream.write({"type": "item", "seq": index, "data": item})
|
|
675
|
+
if page is not None:
|
|
676
|
+
await stream.write({"type": "page", **page, "fetched": len(items)})
|
|
677
|
+
return len(items)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
#: v1's `/v1/events` query names → the `events.watch` request fields they are
|
|
681
|
+
#: now spelled as. The endpoint predates the operation; §12.4 says a
|
|
682
|
+
#: documented shape does not disappear because the code behind it moved.
|
|
683
|
+
_EVENTS_QUERY_ALIASES = {"types": "events", "chats": "chat", "timeout": "follow_for"}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _events_request(query: Any) -> dict[str, Any]:
|
|
687
|
+
"""`GET /v1/events?…` → the `events.watch` request body.
|
|
688
|
+
|
|
689
|
+
The endpoint is a GET-shaped alias of `POST /v1/op {op: events.watch}`:
|
|
690
|
+
one implementation, one filter vocabulary, one set of frames. Two code
|
|
691
|
+
paths reading the same bus with two ideas of what `--events` means is
|
|
692
|
+
exactly the drift the registry exists to remove.
|
|
693
|
+
"""
|
|
694
|
+
body: dict[str, Any] = {}
|
|
695
|
+
for key, value in query.items():
|
|
696
|
+
name = _EVENTS_QUERY_ALIASES.get(key, key)
|
|
697
|
+
if name in ("account", "flood_wait_max"):
|
|
698
|
+
continue
|
|
699
|
+
if name in ("chat", "sender"):
|
|
700
|
+
body[name] = [part for part in str(value).split(",") if part]
|
|
701
|
+
else:
|
|
702
|
+
body[name] = value
|
|
703
|
+
# The endpoint's historical default is everything; `tlgr watch`'s is v1's
|
|
704
|
+
# `new_message`, and that difference is deliberate.
|
|
705
|
+
body.setdefault("events", "all")
|
|
706
|
+
return body
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
async def handle_events(request: web.Request) -> web.StreamResponse:
|
|
710
|
+
daemon: Daemon = request.app[DAEMON_KEY]
|
|
711
|
+
account = request.query.get("account", "").strip()
|
|
712
|
+
if not account:
|
|
713
|
+
return _error_response(
|
|
714
|
+
request, UsageError("GET /v1/events needs ?account=<alias>", field="account")
|
|
715
|
+
)
|
|
716
|
+
from tlgr.models.envelope import OpRequest
|
|
717
|
+
from tlgr.version import VERSION
|
|
718
|
+
|
|
719
|
+
op_request = OpRequest(
|
|
720
|
+
op="events.watch",
|
|
721
|
+
account=account,
|
|
722
|
+
request=_events_request(request.query),
|
|
723
|
+
request_id=request.headers.get("X-Tlgr-Request-Id", "") or uuid.uuid4().hex,
|
|
724
|
+
client_version=VERSION,
|
|
725
|
+
protocol=PROTOCOL,
|
|
726
|
+
stream=True,
|
|
727
|
+
)
|
|
728
|
+
daemon.activity.begin_stream()
|
|
729
|
+
try:
|
|
730
|
+
return await _handle_op_stream(request, daemon, op_request)
|
|
731
|
+
finally:
|
|
732
|
+
daemon.activity.end_stream()
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
async def handle_status(request: web.Request) -> web.Response:
|
|
736
|
+
daemon: Daemon = request.app[DAEMON_KEY]
|
|
737
|
+
return web.Response(
|
|
738
|
+
body=json.dumps(daemon.v1_status(), ensure_ascii=False, default=str).encode("utf-8"),
|
|
739
|
+
content_type="application/json",
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
_ADMIN_OPS = {
|
|
744
|
+
"stop": "daemon.stop",
|
|
745
|
+
"reload": "daemon.reload",
|
|
746
|
+
"resync": "daemon.resync",
|
|
747
|
+
"logout": "account.logout",
|
|
748
|
+
"unfreeze": "account.unfreeze",
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
async def handle_admin(request: web.Request) -> web.Response:
|
|
753
|
+
daemon: Daemon = request.app[DAEMON_KEY]
|
|
754
|
+
action = request.match_info["action"]
|
|
755
|
+
if action not in _ADMIN_OPS:
|
|
756
|
+
return _error_response(request, UsageError(f"unknown admin action {action!r}"))
|
|
757
|
+
daemon.policy.enforce(_ADMIN_OPS[action], None)
|
|
758
|
+
|
|
759
|
+
body: dict[str, Any] = {}
|
|
760
|
+
with contextlib.suppress(Exception):
|
|
761
|
+
body = await request.json()
|
|
762
|
+
|
|
763
|
+
result: dict[str, Any]
|
|
764
|
+
if action == "stop":
|
|
765
|
+
drain = float(body.get("drain_s", daemon.config.daemon.drain_seconds))
|
|
766
|
+
# Answer first, then stop: a caller that never hears "stopping" cannot
|
|
767
|
+
# tell a graceful shutdown from a crash.
|
|
768
|
+
asyncio.get_running_loop().call_later(0.01, daemon.request_shutdown)
|
|
769
|
+
result = {"stopping": True, "drain_s": drain}
|
|
770
|
+
elif action == "reload":
|
|
771
|
+
result = await _reload(daemon, body.get("what") or ["config", "jobs", "policy"])
|
|
772
|
+
elif action == "resync":
|
|
773
|
+
result = await _resync(daemon, body)
|
|
774
|
+
elif action == "unfreeze":
|
|
775
|
+
alias = str(body.get("account", "")).strip()
|
|
776
|
+
daemon.sessions.limiter(alias).reset_breaker()
|
|
777
|
+
result = {"account": alias, "circuit": "closed"}
|
|
778
|
+
else: # logout
|
|
779
|
+
result = await _logout(daemon, body)
|
|
780
|
+
return web.Response(
|
|
781
|
+
body=json.dumps({"ok": True, **result}, ensure_ascii=False, default=str).encode("utf-8"),
|
|
782
|
+
content_type="application/json",
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
async def _reload(daemon: Daemon, what: list[str]) -> dict[str, Any]:
|
|
787
|
+
"""Re-read from disk and swap, atomically (§6.9).
|
|
788
|
+
|
|
789
|
+
Parsing into new structs before swapping is the whole trick: a config with
|
|
790
|
+
a typo leaves the running one in force and reports `CONFIG_ERROR`, rather
|
|
791
|
+
than half-applying and leaving the daemon in a state no file describes.
|
|
792
|
+
"""
|
|
793
|
+
changed: list[str] = []
|
|
794
|
+
requires_restart: list[str] = []
|
|
795
|
+
if "config" in what:
|
|
796
|
+
fresh = await asyncio.to_thread(load_app_config, daemon.base)
|
|
797
|
+
if fresh.network != daemon.config.network:
|
|
798
|
+
requires_restart.append("network")
|
|
799
|
+
if fresh.identity != daemon.config.identity:
|
|
800
|
+
requires_restart.append("identity")
|
|
801
|
+
daemon.config = fresh
|
|
802
|
+
daemon.policy = Policy.from_config(fresh.policy.allow, fresh.policy.deny)
|
|
803
|
+
daemon.idle_timeout = effective_idle_timeout(
|
|
804
|
+
fresh.daemon.idle_timeout,
|
|
805
|
+
webhook_enabled=daemon.webhook_config.enabled,
|
|
806
|
+
managed_by=daemon.managed_by,
|
|
807
|
+
)
|
|
808
|
+
changed.append("config")
|
|
809
|
+
if "policy" in what and "config" not in what:
|
|
810
|
+
fresh = await asyncio.to_thread(load_app_config, daemon.base)
|
|
811
|
+
daemon.policy = Policy.from_config(fresh.policy.allow, fresh.policy.deny)
|
|
812
|
+
changed.append("policy")
|
|
813
|
+
if "jobs" in what:
|
|
814
|
+
await daemon.reload_jobs()
|
|
815
|
+
changed.append("jobs")
|
|
816
|
+
return {"reloaded": changed, "requires_restart": requires_restart}
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
async def _resync(daemon: Daemon, body: dict[str, Any]) -> dict[str, Any]:
|
|
820
|
+
alias = str(body.get("account", "")).strip()
|
|
821
|
+
session = daemon.sessions.get(alias)
|
|
822
|
+
if session is None:
|
|
823
|
+
raise UsageError(f"account {alias!r} is not connected", field="account")
|
|
824
|
+
await session.catch_up()
|
|
825
|
+
session.resync_needed.clear()
|
|
826
|
+
return {"account": alias, "resynced": True}
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
async def _logout(daemon: Daemon, body: dict[str, Any]) -> dict[str, Any]:
|
|
830
|
+
alias = str(body.get("account", "")).strip()
|
|
831
|
+
session = daemon.sessions.get(alias)
|
|
832
|
+
if session is None or session.client is None:
|
|
833
|
+
raise UsageError(f"account {alias!r} is not connected", field="account")
|
|
834
|
+
await session.client.log_out()
|
|
835
|
+
await daemon.sessions.release(alias)
|
|
836
|
+
daemon.accounts.set_health(alias, "needs_login", reason="logged out")
|
|
837
|
+
return {"account": alias, "logged_out": True}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
#: aiohttp deprecates bare string keys on `Application`; a typed key is also
|
|
841
|
+
#: the difference between a missing dependency being a KeyError at request
|
|
842
|
+
#: time and a type error at import time.
|
|
843
|
+
DAEMON_KEY: web.AppKey[Daemon] = web.AppKey("daemon", Daemon)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def build_app(daemon: Daemon) -> web.Application:
|
|
847
|
+
"""The application: four `/v1/*` routes and one middleware chain.
|
|
848
|
+
|
|
849
|
+
PR-12 removed the last v1 route. Everything the daemon serves now goes
|
|
850
|
+
through `POST /v1/op`, which means the peer-uid check, the policy
|
|
851
|
+
allowlist, the version handshake, the flood budget and the error
|
|
852
|
+
classification apply to every command without exception — the thing the
|
|
853
|
+
v1 routes could only approximate by being registered here.
|
|
854
|
+
"""
|
|
855
|
+
app = web.Application(
|
|
856
|
+
middlewares=[
|
|
857
|
+
error_middleware,
|
|
858
|
+
auth_middleware,
|
|
859
|
+
version_middleware,
|
|
860
|
+
activity_middleware,
|
|
861
|
+
flood_budget_middleware,
|
|
862
|
+
]
|
|
863
|
+
)
|
|
864
|
+
app[DAEMON_KEY] = daemon
|
|
865
|
+
app.router.add_post("/v1/op", handle_op)
|
|
866
|
+
app.router.add_get("/v1/events", handle_events)
|
|
867
|
+
app.router.add_get("/v1/status", handle_status)
|
|
868
|
+
app.router.add_post("/v1/admin/{action}", handle_admin)
|
|
869
|
+
return app
|