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
@@ -0,0 +1,391 @@
1
+ """Pacing, flood memory and the circuit breaker — one instance per account.
2
+
3
+ Four separate mechanisms live here because they fail differently:
4
+
5
+ * **token buckets per `rate_class`.** Reading and sending are not the same
6
+ activity: `contacts.resolveUsername` floods at roughly fifty calls in a
7
+ short period, while `messages.getHistory` tolerates ten a second. The op's
8
+ `rate_class` picks the bucket, so an implementation never has to know.
9
+ * **persisted flood memory.** Telethon remembers a `FloodWaitError` in
10
+ `_flood_waited_requests`, in memory, and forgets it when the process exits.
11
+ v1 therefore re-hit every wait after a daemon bounce — and re-hitting a
12
+ flood wait is how a short wait becomes a long one. Deadlines are written to
13
+ `accounts/<alias>/flood.json` keyed by `(method, peer)` and loaded at start.
14
+ * **the sleep policy.** A wait we can afford is slept inside the daemon and
15
+ reported as `meta.flood_wait_slept`; anything longer comes back immediately
16
+ as `RATE_LIMITED` with `wait_seconds`, because a CLI blocked for 40 minutes
17
+ with no output is indistinguishable from a hang.
18
+ * **the circuit breaker.** `PEER_FLOOD` and `FROZEN_*` are not waits. They are
19
+ the account being told to stop, and the correct response is to stop sending
20
+ entirely rather than back off and retry — so the breaker refuses every
21
+ `rate_class="send"` operation while leaving reads working, and only an
22
+ operator (or an expired `freeze_until`) closes it again.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import contextlib
29
+ import json
30
+ import logging
31
+ import time
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from tlgr.core.errors import AccountFrozenError, RateLimitError, SpamFlagError
37
+ from tlgr.core.paths import write_private
38
+
39
+ log = logging.getLogger("tlgr.daemon.ratelimit")
40
+
41
+ __all__ = ["CircuitBreaker", "FloodMemory", "RateLimiter", "TokenBucket"]
42
+
43
+
44
+ class TokenBucket:
45
+ """Classic token bucket on the monotonic clock.
46
+
47
+ `take()` is `async` because the only useful thing to do with an empty
48
+ bucket is wait for it, and waiting must not block the event loop.
49
+ """
50
+
51
+ __slots__ = ("_tokens", "_updated", "burst", "rate")
52
+
53
+ def __init__(self, rate: float, burst: int) -> None:
54
+ self.rate = max(rate, 0.0001)
55
+ self.burst = max(burst, 1)
56
+ self._tokens = float(self.burst)
57
+ self._updated = time.monotonic()
58
+
59
+ def _refill(self) -> None:
60
+ now = time.monotonic()
61
+ elapsed = now - self._updated
62
+ if elapsed > 0:
63
+ self._tokens = min(float(self.burst), self._tokens + elapsed * self.rate)
64
+ self._updated = now
65
+
66
+ @property
67
+ def tokens(self) -> float:
68
+ self._refill()
69
+ return self._tokens
70
+
71
+ def delay(self, cost: float = 1.0) -> float:
72
+ """Seconds until *cost* tokens are available. 0 when they already are."""
73
+ self._refill()
74
+ if self._tokens >= cost:
75
+ return 0.0
76
+ return (cost - self._tokens) / self.rate
77
+
78
+ def consume(self, cost: float = 1.0) -> None:
79
+ self._refill()
80
+ self._tokens -= cost
81
+
82
+ async def take(self, cost: float = 1.0) -> float:
83
+ """Wait for and consume *cost* tokens; returns how long it waited."""
84
+ waited = 0.0
85
+ while True:
86
+ wait = self.delay(cost)
87
+ if wait <= 0:
88
+ self.consume(cost)
89
+ return waited
90
+ await asyncio.sleep(wait)
91
+ waited += wait
92
+
93
+
94
+ @dataclass
95
+ class FloodDeadline:
96
+ until: float
97
+ seconds: int
98
+ method: str
99
+ peer: str = ""
100
+
101
+ @property
102
+ def remaining(self) -> int:
103
+ return max(0, round(self.until - time.time()))
104
+
105
+
106
+ class FloodMemory:
107
+ """Flood deadlines that survive a restart, keyed by `(method, peer)`."""
108
+
109
+ def __init__(self, path: Path | None = None, *, persist: bool = True) -> None:
110
+ self.path = path
111
+ self.persist = persist and path is not None
112
+ self._deadlines: dict[str, FloodDeadline] = {}
113
+ self.load()
114
+
115
+ @staticmethod
116
+ def key(method: str, peer: Any = None) -> str:
117
+ return f"{method}|{peer if peer is not None else ''}"
118
+
119
+ def load(self) -> None:
120
+ if self.path is None or not self.path.exists():
121
+ return
122
+ try:
123
+ raw = json.loads(self.path.read_text())
124
+ except (OSError, json.JSONDecodeError):
125
+ return
126
+ if not isinstance(raw, dict):
127
+ return
128
+ now = time.time()
129
+ for key, value in raw.items():
130
+ if not isinstance(value, dict):
131
+ continue
132
+ until = float(value.get("until", 0) or 0)
133
+ if until <= now:
134
+ continue
135
+ self._deadlines[str(key)] = FloodDeadline(
136
+ until=until,
137
+ seconds=int(value.get("seconds", 0) or 0),
138
+ method=str(value.get("method", "")),
139
+ peer=str(value.get("peer", "")),
140
+ )
141
+
142
+ def save(self) -> None:
143
+ if not self.persist or self.path is None:
144
+ return
145
+ now = time.time()
146
+ payload = {
147
+ key: {
148
+ "until": deadline.until,
149
+ "seconds": deadline.seconds,
150
+ "method": deadline.method,
151
+ "peer": deadline.peer,
152
+ }
153
+ for key, deadline in self._deadlines.items()
154
+ if deadline.until > now
155
+ }
156
+ with contextlib.suppress(OSError):
157
+ write_private(self.path, json.dumps(payload, indent=0))
158
+
159
+ def remember(self, method: str, seconds: int, peer: Any = None) -> None:
160
+ key = self.key(method, peer)
161
+ self._deadlines[key] = FloodDeadline(
162
+ until=time.time() + max(0, seconds),
163
+ seconds=int(seconds),
164
+ method=method,
165
+ peer=str(peer or ""),
166
+ )
167
+ self.save()
168
+
169
+ def remaining(self, method: str, peer: Any = None) -> int:
170
+ """Seconds still owed for `(method, peer)`, else 0."""
171
+ now = time.time()
172
+ for key in (self.key(method, peer), self.key(method, None)):
173
+ deadline = self._deadlines.get(key)
174
+ if deadline is None:
175
+ continue
176
+ if deadline.until <= now:
177
+ self._deadlines.pop(key, None)
178
+ continue
179
+ return deadline.remaining
180
+ return 0
181
+
182
+ def clear(self) -> None:
183
+ self._deadlines.clear()
184
+ self.save()
185
+
186
+ def snapshot(self) -> dict[str, int]:
187
+ return {key: deadline.remaining for key, deadline in self._deadlines.items()}
188
+
189
+ def entries(self, *, include_expired: bool = False) -> list[FloodDeadline]:
190
+ """Every remembered deadline, for `tlgr daemon flood list`.
191
+
192
+ Expired ones are hidden by default and available on request: "this
193
+ account hit a wait an hour ago" is diagnosis, not a live constraint,
194
+ and mixing the two makes a healthy account look throttled.
195
+ """
196
+ now = time.time()
197
+ return [
198
+ deadline
199
+ for deadline in self._deadlines.values()
200
+ if include_expired or deadline.until > now
201
+ ]
202
+
203
+ def forget(self, *, method: str = "", peer: Any = None) -> int:
204
+ """Drop the deadlines matching *method*/*peer*; returns how many.
205
+
206
+ Clearing a live server-side FLOOD_WAIT does not lift it — the next
207
+ call re-trips it. This exists for after the cause is fixed, and to
208
+ reset a breaker an operator has investigated.
209
+ """
210
+ removed = 0
211
+ for key, deadline in list(self._deadlines.items()):
212
+ if method and deadline.method != method:
213
+ continue
214
+ if peer is not None and deadline.peer != str(peer):
215
+ continue
216
+ del self._deadlines[key]
217
+ removed += 1
218
+ if removed:
219
+ self.save()
220
+ return removed
221
+
222
+ @property
223
+ def next_deadline(self) -> float | None:
224
+ alive = [d.until for d in self._deadlines.values() if d.until > time.time()]
225
+ return max(alive) if alive else None
226
+
227
+
228
+ @dataclass
229
+ class CircuitBreaker:
230
+ """Open means "this account has been told to stop sending"."""
231
+
232
+ open: bool = False
233
+ reason: str = ""
234
+ since: float = 0.0
235
+ until: float | None = None
236
+ appeal_url: str = ""
237
+ #: Consecutive privacy-class refusals on new peers, which are the early
238
+ #: warning that a PEER_FLOOD is coming.
239
+ strikes: list[float] = field(default_factory=list)
240
+
241
+ def trip(self, reason: str, *, until: float | None = None, appeal_url: str = "") -> None:
242
+ self.open = True
243
+ self.reason = reason
244
+ self.since = time.time()
245
+ self.until = until
246
+ self.appeal_url = appeal_url
247
+
248
+ def reset(self) -> None:
249
+ self.open = False
250
+ self.reason = ""
251
+ self.until = None
252
+ self.appeal_url = ""
253
+ self.strikes.clear()
254
+
255
+ def strike(self, *, window: float = 60.0, limit: int = 3) -> bool:
256
+ """Record a privacy refusal; True when that trips the breaker."""
257
+ now = time.time()
258
+ self.strikes = [t for t in self.strikes if now - t <= window]
259
+ self.strikes.append(now)
260
+ if len(self.strikes) >= limit:
261
+ self.trip("repeated privacy refusals on new peers")
262
+ return True
263
+ return False
264
+
265
+ @property
266
+ def state(self) -> str:
267
+ if not self.open:
268
+ return "closed"
269
+ if self.until is not None and self.until <= time.time():
270
+ return "half-open"
271
+ return "open"
272
+
273
+ def expired(self) -> bool:
274
+ return self.open and self.until is not None and self.until <= time.time()
275
+
276
+
277
+ class RateLimiter:
278
+ """Everything above, wired together for one account."""
279
+
280
+ def __init__(
281
+ self,
282
+ *,
283
+ buckets: dict[str, tuple[float, int]] | None = None,
284
+ flood_path: Path | None = None,
285
+ persist: bool = True,
286
+ sleep_threshold: int = 120,
287
+ max_wait: int = 600,
288
+ ) -> None:
289
+ specs = buckets or {}
290
+ self._buckets = {name: TokenBucket(rate, burst) for name, (rate, burst) in specs.items()}
291
+ self.flood = FloodMemory(flood_path, persist=persist)
292
+ self.breaker = CircuitBreaker()
293
+ self.sleep_threshold = sleep_threshold
294
+ self.max_wait = max_wait
295
+ self.slow_mode: dict[int, float] = {}
296
+
297
+ # -- buckets -----------------------------------------------------------
298
+
299
+ def bucket(self, rate_class: str) -> TokenBucket:
300
+ if rate_class not in self._buckets:
301
+ self._buckets[rate_class] = TokenBucket(10.0, 20)
302
+ return self._buckets[rate_class]
303
+
304
+ async def acquire(self, rate_class: str, *, cost: float = 1.0) -> float:
305
+ return await self.bucket(rate_class).take(cost)
306
+
307
+ # -- pre-flight --------------------------------------------------------
308
+
309
+ def check(self, *, rate_class: str, method: str = "", peer: Any = None) -> None:
310
+ """Refuse, without a round trip, what the server would refuse anyway.
311
+
312
+ Three things are known locally: the breaker is open, a flood deadline
313
+ for this `(method, peer)` has not passed, or the chat's slow mode says
314
+ the next send is in the future. Spending a request to be told so costs
315
+ the account's reputation as well as the round trip.
316
+ """
317
+ if self.breaker.expired():
318
+ self.breaker.reset()
319
+ if self.breaker.open and rate_class == "send":
320
+ error = AccountFrozenError if "froz" in self.breaker.reason.lower() else SpamFlagError
321
+ raise error(
322
+ f"this account is not allowed to send right now: {self.breaker.reason}"
323
+ + (f" (appeal: {self.breaker.appeal_url})" if self.breaker.appeal_url else "")
324
+ )
325
+
326
+ if method:
327
+ remaining = self.flood.remaining(method, peer)
328
+ if remaining > 0:
329
+ raise RateLimitError(
330
+ f"{method} is still rate limited for this account "
331
+ f"({remaining}s remaining from an earlier FLOOD_WAIT)",
332
+ wait_seconds=remaining,
333
+ )
334
+
335
+ if peer is not None and rate_class == "send":
336
+ next_send = self.slow_mode.get(int(peer)) if _is_int(peer) else None
337
+ if next_send and next_send > time.time():
338
+ wait = round(next_send - time.time())
339
+ raise RateLimitError(
340
+ f"slow mode is active in this chat for another {wait}s", wait_seconds=wait
341
+ )
342
+
343
+ # -- reactions ---------------------------------------------------------
344
+
345
+ def sleep_budget(self, requested_max: int | None, remaining_timeout: float) -> int:
346
+ """How long this request may sleep off a flood wait (§6.4, COR-15).
347
+
348
+ The per-request `--flood-wait-max` is honoured exactly: a caller that
349
+ said "never sleep more than 5 seconds" must not be held for 120 because
350
+ that is the daemon's default.
351
+ """
352
+ limit = self.sleep_threshold if requested_max is None else max(0, int(requested_max))
353
+ limit = min(limit, self.max_wait)
354
+ return int(max(0, min(limit, remaining_timeout)))
355
+
356
+ def note_flood(self, method: str, seconds: int, peer: Any = None) -> None:
357
+ self.flood.remember(method, seconds, peer)
358
+
359
+ def note_slow_mode(self, chat_id: int, next_send_date: float | None) -> None:
360
+ if next_send_date:
361
+ self.slow_mode[int(chat_id)] = float(next_send_date)
362
+ else:
363
+ self.slow_mode.pop(int(chat_id), None)
364
+
365
+ def trip(self, reason: str, *, until: float | None = None, appeal_url: str = "") -> None:
366
+ log.warning("circuit breaker opened: %s", reason, extra={"reason": reason})
367
+ self.breaker.trip(reason, until=until, appeal_url=appeal_url)
368
+
369
+ def reset_breaker(self) -> None:
370
+ self.breaker.reset()
371
+
372
+ # -- reporting ---------------------------------------------------------
373
+
374
+ def snapshot(self) -> dict[str, Any]:
375
+ deadline = self.flood.next_deadline
376
+ return {
377
+ "circuit": self.breaker.state,
378
+ "circuit_reason": self.breaker.reason or None,
379
+ "flood_until": (
380
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(deadline)) if deadline else None
381
+ ),
382
+ "flood_entries": len(self.flood.snapshot()),
383
+ }
384
+
385
+
386
+ def _is_int(value: Any) -> bool:
387
+ try:
388
+ int(value)
389
+ except (TypeError, ValueError):
390
+ return False
391
+ return True
tlgr/daemon/server.py ADDED
@@ -0,0 +1,24 @@
1
+ """`python -m tlgr.daemon.server`, kept working as an alias for `main`.
2
+
3
+ The daemon moved to `daemon/main.py` (entry) and `daemon/app.py` (the object),
4
+ but this module path is baked into v1's launchd plist and into anyone's shell
5
+ history, so it stays as a two-line forwarder. `DaemonServer` is the new
6
+ `Daemon` under its old name, which keeps the v1 status contract — and the
7
+ regression test that pins it — pointing at the same code.
8
+
9
+ Deleted at PR-12 with the rest of the v1 surface.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from tlgr.daemon.app import Daemon
15
+ from tlgr.daemon.main import main
16
+
17
+ #: v1's name for the daemon object.
18
+ DaemonServer = Daemon
19
+
20
+ __all__ = ["Daemon", "DaemonServer", "main"]
21
+
22
+
23
+ if __name__ == "__main__": # pragma: no cover
24
+ raise SystemExit(main())