tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/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