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/ratelimit.py
ADDED
|
@@ -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())
|