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/transfers.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The Downloads panel: transfers the daemon is running, or just ran.
|
|
2
|
+
|
|
3
|
+
There is no server-side list of a user's transfers, in tlgr or in any official
|
|
4
|
+
client — the GUI's Downloads section is client-local state too. What the
|
|
5
|
+
daemon adds over "just await the coroutine" is the three things a long
|
|
6
|
+
transfer needs and a request/response cycle cannot give it:
|
|
7
|
+
|
|
8
|
+
* **a name for it while it runs.** `--background` returns a job id
|
|
9
|
+
immediately, and `media transfer list` is how you find out how far it got.
|
|
10
|
+
* **cancellation between chunks.** A download stops cleanly and keeps its
|
|
11
|
+
`.part` file, so `--resume` continues it; an upload cannot be resumed at
|
|
12
|
+
all (saved parts expire server-side), so it restarts with a fresh file id.
|
|
13
|
+
* **a retry that re-fetches first.** A transfer that sat in the failed queue
|
|
14
|
+
for an hour is holding an expired `file_reference`; retrying with the stale
|
|
15
|
+
one fails identically, so the factory is re-run rather than the task.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import contextlib
|
|
22
|
+
import logging
|
|
23
|
+
import secrets
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("tlgr.daemon.transfers")
|
|
30
|
+
|
|
31
|
+
__all__ = ["TransferRecord", "TransferStore"]
|
|
32
|
+
|
|
33
|
+
#: How many finished transfers stay visible. The panel is a recent history,
|
|
34
|
+
#: not an archive; keeping every transfer forever would make the daemon's
|
|
35
|
+
#: memory a function of how much the user has ever downloaded.
|
|
36
|
+
KEEP_FINISHED = 100
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class TransferRecord:
|
|
41
|
+
job_id: str
|
|
42
|
+
direction: str
|
|
43
|
+
name: str = ""
|
|
44
|
+
chat_id: int | None = None
|
|
45
|
+
msg_id: int | None = None
|
|
46
|
+
bytes_done: int = 0
|
|
47
|
+
bytes_total: int = 0
|
|
48
|
+
state: str = "queued"
|
|
49
|
+
path: str | None = None
|
|
50
|
+
error: str | None = None
|
|
51
|
+
started: float = field(default_factory=time.monotonic)
|
|
52
|
+
started_at: str = ""
|
|
53
|
+
task: asyncio.Task[Any] | None = None
|
|
54
|
+
factory: Callable[[], Awaitable[Any]] | None = None
|
|
55
|
+
result: Any = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def pct(self) -> float:
|
|
59
|
+
if not self.bytes_total:
|
|
60
|
+
return 0.0
|
|
61
|
+
return round(100.0 * self.bytes_done / self.bytes_total, 1)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def bps(self) -> int:
|
|
65
|
+
elapsed = max(1e-6, time.monotonic() - self.started)
|
|
66
|
+
return int(self.bytes_done / elapsed)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def eta_s(self) -> int | None:
|
|
70
|
+
rate = self.bps
|
|
71
|
+
if not rate or not self.bytes_total or self.bytes_done >= self.bytes_total:
|
|
72
|
+
return None
|
|
73
|
+
return int((self.bytes_total - self.bytes_done) / rate)
|
|
74
|
+
|
|
75
|
+
def progress(self, done: int, total: int) -> None:
|
|
76
|
+
self.bytes_done = done
|
|
77
|
+
self.bytes_total = total or self.bytes_total
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"job_id": self.job_id,
|
|
82
|
+
"direction": self.direction,
|
|
83
|
+
"chat_id": self.chat_id,
|
|
84
|
+
"msg_id": self.msg_id,
|
|
85
|
+
"name": self.name,
|
|
86
|
+
"bytes_done": self.bytes_done,
|
|
87
|
+
"bytes_total": self.bytes_total,
|
|
88
|
+
"pct": self.pct,
|
|
89
|
+
"bps": self.bps,
|
|
90
|
+
"eta_s": self.eta_s,
|
|
91
|
+
"state": self.state,
|
|
92
|
+
"path": self.path,
|
|
93
|
+
"error": self.error,
|
|
94
|
+
"started": self.started_at or None,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TransferStore:
|
|
99
|
+
"""Every transfer this daemon started, keyed by job id."""
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
self._records: dict[str, TransferRecord] = {}
|
|
103
|
+
|
|
104
|
+
# -- writing -----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def submit(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
direction: str,
|
|
110
|
+
name: str,
|
|
111
|
+
factory: Callable[[], Awaitable[Any]],
|
|
112
|
+
chat_id: int | None = None,
|
|
113
|
+
msg_id: int | None = None,
|
|
114
|
+
) -> TransferRecord:
|
|
115
|
+
"""Register a transfer and start it in the background."""
|
|
116
|
+
from datetime import datetime, timezone
|
|
117
|
+
|
|
118
|
+
record = TransferRecord(
|
|
119
|
+
job_id=secrets.token_hex(3),
|
|
120
|
+
direction=direction,
|
|
121
|
+
name=name,
|
|
122
|
+
chat_id=chat_id,
|
|
123
|
+
msg_id=msg_id,
|
|
124
|
+
factory=factory,
|
|
125
|
+
started_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
126
|
+
)
|
|
127
|
+
self._records[record.job_id] = record
|
|
128
|
+
self._start(record)
|
|
129
|
+
self._trim()
|
|
130
|
+
return record
|
|
131
|
+
|
|
132
|
+
def _start(self, record: TransferRecord) -> None:
|
|
133
|
+
record.state = "running"
|
|
134
|
+
record.error = None
|
|
135
|
+
record.started = time.monotonic()
|
|
136
|
+
|
|
137
|
+
async def run() -> None:
|
|
138
|
+
try:
|
|
139
|
+
record.result = await record.factory() # type: ignore[misc]
|
|
140
|
+
except asyncio.CancelledError:
|
|
141
|
+
record.state = "cancelled"
|
|
142
|
+
raise
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
record.state = "failed"
|
|
145
|
+
record.error = f"{type(exc).__name__}: {exc}"
|
|
146
|
+
log.warning("transfer %s failed: %s", record.job_id, exc)
|
|
147
|
+
else:
|
|
148
|
+
record.state = "done"
|
|
149
|
+
path = getattr(record.result, "path", None)
|
|
150
|
+
if isinstance(path, str):
|
|
151
|
+
record.path = path
|
|
152
|
+
|
|
153
|
+
record.task = asyncio.get_event_loop().create_task(run(), name=f"tlgr-tx-{record.job_id}")
|
|
154
|
+
|
|
155
|
+
def _trim(self) -> None:
|
|
156
|
+
finished = [r for r in self._records.values() if r.state in ("done", "failed", "cancelled")]
|
|
157
|
+
for record in sorted(finished, key=lambda r: r.started)[:-KEEP_FINISHED]:
|
|
158
|
+
self._records.pop(record.job_id, None)
|
|
159
|
+
|
|
160
|
+
async def cancel(self, job_ids: list[str], *, keep_partial: bool = True) -> int:
|
|
161
|
+
"""Stop the named transfers, and forget their `.part` when asked to."""
|
|
162
|
+
cancelled = 0
|
|
163
|
+
for job_id in job_ids:
|
|
164
|
+
record = self._records.get(job_id)
|
|
165
|
+
if record is None or record.state not in ("queued", "running"):
|
|
166
|
+
continue
|
|
167
|
+
if record.task is not None:
|
|
168
|
+
record.task.cancel()
|
|
169
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
170
|
+
await record.task
|
|
171
|
+
record.state = "cancelled"
|
|
172
|
+
cancelled += 1
|
|
173
|
+
if not keep_partial and record.path:
|
|
174
|
+
from pathlib import Path
|
|
175
|
+
|
|
176
|
+
with contextlib.suppress(OSError):
|
|
177
|
+
Path(record.path + ".part").unlink()
|
|
178
|
+
return cancelled
|
|
179
|
+
|
|
180
|
+
def retry(self, job_ids: list[str], *, from_scratch: bool = False) -> tuple[int, int]:
|
|
181
|
+
"""Re-run the factory, which re-fetches the source before it reads bytes."""
|
|
182
|
+
restarted = 0
|
|
183
|
+
resumed_from = 0
|
|
184
|
+
for job_id in job_ids:
|
|
185
|
+
record = self._records.get(job_id)
|
|
186
|
+
if record is None or record.factory is None:
|
|
187
|
+
continue
|
|
188
|
+
if record.state in ("queued", "running"):
|
|
189
|
+
continue
|
|
190
|
+
resumed_from = max(resumed_from, 0 if from_scratch else record.bytes_done)
|
|
191
|
+
record.bytes_done = 0 if from_scratch else record.bytes_done
|
|
192
|
+
self._start(record)
|
|
193
|
+
restarted += 1
|
|
194
|
+
return restarted, resumed_from
|
|
195
|
+
|
|
196
|
+
# -- reading -----------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def get(self, job_id: str) -> TransferRecord | None:
|
|
199
|
+
return self._records.get(job_id)
|
|
200
|
+
|
|
201
|
+
def snapshot(self, *, active: bool = False, failed: bool = False) -> list[dict[str, Any]]:
|
|
202
|
+
records = list(self._records.values())
|
|
203
|
+
if active:
|
|
204
|
+
records = [r for r in records if r.state in ("queued", "running")]
|
|
205
|
+
if failed:
|
|
206
|
+
records = [r for r in records if r.state == "failed"]
|
|
207
|
+
return [record.to_dict() for record in sorted(records, key=lambda r: -r.started)]
|
|
208
|
+
|
|
209
|
+
async def settle(self, *, timeout: float = 30.0) -> None:
|
|
210
|
+
"""Wait for the running transfers, so `--watch` can report the end."""
|
|
211
|
+
tasks = [r.task for r in self._records.values() if r.task is not None and not r.task.done()]
|
|
212
|
+
if not tasks:
|
|
213
|
+
return
|
|
214
|
+
with contextlib.suppress(TimeoutError, asyncio.TimeoutError, Exception):
|
|
215
|
+
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout)
|
|
216
|
+
|
|
217
|
+
async def stop_all(self) -> None:
|
|
218
|
+
"""Cancel everything still running, at shutdown."""
|
|
219
|
+
await self.cancel([r.job_id for r in self._records.values()], keep_partial=True)
|
tlgr/daemon/webhook.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Outbound webhook delivery — a bus subscriber, not an update handler.
|
|
2
|
+
|
|
3
|
+
Four things were wrong with v1 and all four are structural:
|
|
4
|
+
|
|
5
|
+
* **COR-07.** The payload was built with `json.dumps(..., default=str)` over
|
|
6
|
+
a raw Telethon `to_dict()`. A `datetime` became a string in one place and a
|
|
7
|
+
`bytes` blew up in another, so a message with media could fail to serialise
|
|
8
|
+
*at delivery time*, be counted as a delivery failure, and be retried three
|
|
9
|
+
times before being dead-lettered. Payloads are now models encoded with
|
|
10
|
+
msgspec, and a serialisation failure is logged as a bug — never retried.
|
|
11
|
+
* **ROB-02.** The POST happened inside the Telethon handler. One unreachable
|
|
12
|
+
endpoint with three retries and exponential backoff held the update loop for
|
|
13
|
+
~97 s, during which every account was deaf. Delivery now happens on the
|
|
14
|
+
bus's worker lanes, behind a bounded queue.
|
|
15
|
+
* **SEC-08.** There was no signature: any process that learned the URL could
|
|
16
|
+
forge events. Every delivery now carries `X-Tlgr-Signature: sha256=<hmac of
|
|
17
|
+
the exact body>` and a monotonic `seq`, so a receiver can both authenticate
|
|
18
|
+
and order what it gets.
|
|
19
|
+
* **SEC-06.** Dead letters were appended to a world-readable file that grew
|
|
20
|
+
forever, with full message text in it. They are 0600, rotated, and capped.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextlib
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import random
|
|
31
|
+
import time
|
|
32
|
+
import uuid
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import msgspec
|
|
37
|
+
|
|
38
|
+
from tlgr.core.config import CONFIG_DIR, WebhookConfig
|
|
39
|
+
from tlgr.core.paths import write_private
|
|
40
|
+
from tlgr.core.signing import sign_body
|
|
41
|
+
from tlgr.models.event import EventEnvelope
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("tlgr.webhook")
|
|
44
|
+
|
|
45
|
+
__all__ = ["WebhookPusher", "sign_body"]
|
|
46
|
+
|
|
47
|
+
DEAD_LETTER_FILE = CONFIG_DIR / "dead_letter.jsonl"
|
|
48
|
+
|
|
49
|
+
#: 16 MB across four files is enough to diagnose an outage and small enough
|
|
50
|
+
#: that an endpoint that has been down for a week cannot fill a disk.
|
|
51
|
+
_DEAD_LETTER_MAX_BYTES = 16 * 1024 * 1024
|
|
52
|
+
_DEAD_LETTER_BACKUPS = 3
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WebhookPusher:
|
|
56
|
+
"""Bounded queue + worker pool + HMAC + dead letter."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, config: WebhookConfig, base: Path | None = None) -> None:
|
|
59
|
+
self.config = config
|
|
60
|
+
self.base = Path(base) if base is not None else CONFIG_DIR
|
|
61
|
+
self._session: Any = None
|
|
62
|
+
self._resolved_chat_ids: set[int] = set()
|
|
63
|
+
self._dead_letter_path = self.base / "dead_letter.jsonl"
|
|
64
|
+
self._queue: asyncio.Queue[tuple[bytes, dict[str, str]]] | None = None
|
|
65
|
+
self._workers: list[asyncio.Task[None]] = []
|
|
66
|
+
self._filter_node: Any = None
|
|
67
|
+
self.delivered = 0
|
|
68
|
+
self.failed = 0
|
|
69
|
+
self.dead_letters = 0
|
|
70
|
+
self.dropped = 0
|
|
71
|
+
if config.filters.raw:
|
|
72
|
+
from tlgr.filters.compose import parse_filter_config
|
|
73
|
+
|
|
74
|
+
self._filter_node = parse_filter_config(config.filters.raw)
|
|
75
|
+
|
|
76
|
+
# -- lifecycle ---------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async def start(self) -> None:
|
|
79
|
+
if not self.config.enabled:
|
|
80
|
+
return
|
|
81
|
+
import aiohttp
|
|
82
|
+
|
|
83
|
+
self._session = aiohttp.ClientSession()
|
|
84
|
+
self._queue = asyncio.Queue(maxsize=max(16, self.config.queue_size))
|
|
85
|
+
self._workers = [
|
|
86
|
+
asyncio.create_task(self._worker(), name=f"tlgr-webhook-{index}")
|
|
87
|
+
for index in range(max(1, self.config.workers))
|
|
88
|
+
]
|
|
89
|
+
if self.config.url.startswith("http://") and not _is_loopback(self.config.url):
|
|
90
|
+
log.warning(
|
|
91
|
+
"webhook URL is plain http:// to a non-loopback host; "
|
|
92
|
+
"event payloads and the signature travel in clear text",
|
|
93
|
+
)
|
|
94
|
+
log.info("webhook pusher started")
|
|
95
|
+
|
|
96
|
+
async def stop(self, *, drain: float = 10.0) -> None:
|
|
97
|
+
"""Flush what we can, dead-letter the rest (§6.11 step 4)."""
|
|
98
|
+
if self._queue is not None:
|
|
99
|
+
deadline = time.monotonic() + drain
|
|
100
|
+
while not self._queue.empty() and time.monotonic() < deadline:
|
|
101
|
+
await asyncio.sleep(0.05)
|
|
102
|
+
while not self._queue.empty():
|
|
103
|
+
body, headers = self._queue.get_nowait()
|
|
104
|
+
self._dead_letter(body, headers, "daemon shutting down")
|
|
105
|
+
for worker in self._workers:
|
|
106
|
+
worker.cancel()
|
|
107
|
+
for worker in self._workers:
|
|
108
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
109
|
+
await worker
|
|
110
|
+
self._workers = []
|
|
111
|
+
if self._session is not None:
|
|
112
|
+
await self._session.close()
|
|
113
|
+
self._session = None
|
|
114
|
+
|
|
115
|
+
# -- filtering ---------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def set_resolved_chats(self, chat_ids: set[int]) -> None:
|
|
118
|
+
self._resolved_chat_ids = chat_ids
|
|
119
|
+
|
|
120
|
+
def should_push(
|
|
121
|
+
self, event_type: str, chat_id: int | None = None, tg_event: Any = None
|
|
122
|
+
) -> bool:
|
|
123
|
+
if not self.config.enabled:
|
|
124
|
+
return False
|
|
125
|
+
if event_type not in self.config.events:
|
|
126
|
+
return False
|
|
127
|
+
if self.config.filters.chats and chat_id is not None:
|
|
128
|
+
if chat_id not in self._resolved_chat_ids:
|
|
129
|
+
return False
|
|
130
|
+
if self._filter_node is not None and tg_event is not None:
|
|
131
|
+
from tlgr.filters.compose import evaluate
|
|
132
|
+
from tlgr.gateway.event import Event
|
|
133
|
+
|
|
134
|
+
envelope = Event(source="telegram", raw=tg_event, event_type=event_type)
|
|
135
|
+
ok, _ = evaluate(self._filter_node, envelope)
|
|
136
|
+
if not ok:
|
|
137
|
+
return False
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# -- the bus handler ---------------------------------------------------
|
|
141
|
+
|
|
142
|
+
async def on_event(self, event: EventEnvelope, raw: Any = None) -> None:
|
|
143
|
+
"""The bus handler. Encodes, signs and enqueues; never sends inline."""
|
|
144
|
+
if not self.should_push(event.type, event.chat_id, raw):
|
|
145
|
+
return
|
|
146
|
+
self.enqueue(event)
|
|
147
|
+
|
|
148
|
+
def enqueue(self, event: EventEnvelope) -> None:
|
|
149
|
+
delivery_id = uuid.uuid4().hex
|
|
150
|
+
try:
|
|
151
|
+
body = msgspec.json.encode({"event": event, "delivery_id": delivery_id})
|
|
152
|
+
except (TypeError, msgspec.EncodeError) as exc:
|
|
153
|
+
# A payload we cannot encode is a *bug in tlgr*, not a delivery
|
|
154
|
+
# failure: retrying it three times and dead-lettering it hides the
|
|
155
|
+
# defect behind an endpoint that looks flaky (COR-07).
|
|
156
|
+
log.error(
|
|
157
|
+
"BUG: webhook payload for %s could not be encoded (%s): %s",
|
|
158
|
+
event.type,
|
|
159
|
+
type(exc).__name__,
|
|
160
|
+
exc,
|
|
161
|
+
extra={"event_type": event.type, "account": event.account},
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
headers = {
|
|
166
|
+
"Content-Type": "application/json",
|
|
167
|
+
"X-Tlgr-Delivery": delivery_id,
|
|
168
|
+
"X-Tlgr-Seq": str(event.seq),
|
|
169
|
+
"X-Tlgr-Event": event.type,
|
|
170
|
+
"X-Tlgr-Account": event.account,
|
|
171
|
+
}
|
|
172
|
+
secret = self.config.signing_key
|
|
173
|
+
if secret:
|
|
174
|
+
headers["X-Tlgr-Signature"] = sign_body(secret, body)
|
|
175
|
+
if self.config.token:
|
|
176
|
+
headers["Authorization"] = f"Bearer {self.config.token}"
|
|
177
|
+
|
|
178
|
+
if self._queue is None:
|
|
179
|
+
self._dead_letter(body, headers, "webhook is not running")
|
|
180
|
+
return
|
|
181
|
+
try:
|
|
182
|
+
self._queue.put_nowait((body, headers))
|
|
183
|
+
except asyncio.QueueFull:
|
|
184
|
+
self.dropped += 1
|
|
185
|
+
self._dead_letter(body, headers, "webhook queue is full")
|
|
186
|
+
|
|
187
|
+
# -- delivery ----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
async def _worker(self) -> None:
|
|
190
|
+
assert self._queue is not None
|
|
191
|
+
while True:
|
|
192
|
+
body, headers = await self._queue.get()
|
|
193
|
+
await self._deliver(body, headers)
|
|
194
|
+
|
|
195
|
+
async def _deliver(self, body: bytes, headers: dict[str, str]) -> None:
|
|
196
|
+
import aiohttp
|
|
197
|
+
|
|
198
|
+
retry = self.config.retry
|
|
199
|
+
attempts = retry.max_attempts if retry.enabled else 1
|
|
200
|
+
last = ""
|
|
201
|
+
for attempt in range(attempts):
|
|
202
|
+
try:
|
|
203
|
+
assert self._session is not None
|
|
204
|
+
async with self._session.post(
|
|
205
|
+
self.config.url,
|
|
206
|
+
data=body,
|
|
207
|
+
headers=headers,
|
|
208
|
+
timeout=aiohttp.ClientTimeout(total=self.config.timeout),
|
|
209
|
+
) as response:
|
|
210
|
+
if response.status < 400:
|
|
211
|
+
self.delivered += 1
|
|
212
|
+
return
|
|
213
|
+
last = f"HTTP {response.status}"
|
|
214
|
+
except asyncio.CancelledError:
|
|
215
|
+
raise
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
last = f"{type(exc).__name__}: {exc}"
|
|
218
|
+
self.failed += 1
|
|
219
|
+
if attempt + 1 < attempts:
|
|
220
|
+
# Jittered, so a restarted endpoint is not hit by every
|
|
221
|
+
# pending delivery in the same millisecond.
|
|
222
|
+
delay = (retry.backoff_base**attempt) * (1.0 + random.uniform(-0.2, 0.2))
|
|
223
|
+
await asyncio.sleep(delay)
|
|
224
|
+
self._dead_letter(body, headers, last or "delivery failed")
|
|
225
|
+
|
|
226
|
+
# -- dead letters ------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def _dead_letter(self, body: bytes, headers: dict[str, str], reason: str) -> None:
|
|
229
|
+
self.dead_letters += 1
|
|
230
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
231
|
+
record = {
|
|
232
|
+
"ts": now,
|
|
233
|
+
"reason": reason,
|
|
234
|
+
# `source` names the consumer that failed. One store is shared by
|
|
235
|
+
# the pusher and the gateway actions, and an operator draining it
|
|
236
|
+
# has to be able to re-drive one without the other.
|
|
237
|
+
"source": "webhook",
|
|
238
|
+
"attempts": max(1, self.config.retry.max_attempts if self.config.retry.enabled else 1),
|
|
239
|
+
"first_failed_at": now,
|
|
240
|
+
"delivery_id": headers.get("X-Tlgr-Delivery", ""),
|
|
241
|
+
"seq": headers.get("X-Tlgr-Seq", ""),
|
|
242
|
+
"event": headers.get("X-Tlgr-Event", ""),
|
|
243
|
+
"account": headers.get("X-Tlgr-Account", ""),
|
|
244
|
+
"body": body.decode("utf-8", errors="replace"),
|
|
245
|
+
}
|
|
246
|
+
try:
|
|
247
|
+
self._rotate_if_needed()
|
|
248
|
+
path = self._dead_letter_path
|
|
249
|
+
if not path.exists():
|
|
250
|
+
write_private(path, "")
|
|
251
|
+
with open(os.open(str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600), "a") as fh:
|
|
252
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
253
|
+
except OSError as exc:
|
|
254
|
+
log.error("could not write a dead letter: %s", exc)
|
|
255
|
+
|
|
256
|
+
def _rotate_if_needed(self) -> None:
|
|
257
|
+
path = self._dead_letter_path
|
|
258
|
+
try:
|
|
259
|
+
if not path.exists() or path.stat().st_size < _DEAD_LETTER_MAX_BYTES:
|
|
260
|
+
return
|
|
261
|
+
except OSError:
|
|
262
|
+
return
|
|
263
|
+
for index in range(_DEAD_LETTER_BACKUPS, 0, -1):
|
|
264
|
+
older = path.with_suffix(path.suffix + f".{index}")
|
|
265
|
+
newer = path if index == 1 else path.with_suffix(path.suffix + f".{index - 1}")
|
|
266
|
+
if newer.exists():
|
|
267
|
+
with contextlib.suppress(OSError):
|
|
268
|
+
newer.replace(older)
|
|
269
|
+
with contextlib.suppress(OSError):
|
|
270
|
+
write_private(path, "")
|
|
271
|
+
|
|
272
|
+
def read_dead_letters(self) -> list[dict[str, Any]]:
|
|
273
|
+
if not self._dead_letter_path.exists():
|
|
274
|
+
return []
|
|
275
|
+
entries: list[dict[str, Any]] = []
|
|
276
|
+
for line in self._dead_letter_path.read_text(errors="replace").splitlines():
|
|
277
|
+
line = line.strip()
|
|
278
|
+
if not line:
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
entries.append(json.loads(line))
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
continue
|
|
284
|
+
return entries
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def dead_letter_path(self) -> Path:
|
|
288
|
+
return self._dead_letter_path
|
|
289
|
+
|
|
290
|
+
def write_dead_letters(self, entries: list[dict[str, Any]]) -> None:
|
|
291
|
+
"""Replace the store. Private mode, one write, no partial file."""
|
|
292
|
+
body = "".join(json.dumps(entry, ensure_ascii=False) + "\n" for entry in entries)
|
|
293
|
+
write_private(self._dead_letter_path, body)
|
|
294
|
+
self.dead_letters = len(entries)
|
|
295
|
+
|
|
296
|
+
async def deliver_once(self, entry: dict[str, Any], *, url: str = "") -> tuple[bool, str]:
|
|
297
|
+
"""One delivery attempt for a stored entry. Returns `(ok, error)`.
|
|
298
|
+
|
|
299
|
+
Re-delivery reuses the original `X-Tlgr-Delivery` id, so a receiver
|
|
300
|
+
keyed on it sees a duplicate rather than a new event — which is the
|
|
301
|
+
difference between a safe replay and a double-processed message.
|
|
302
|
+
"""
|
|
303
|
+
import aiohttp
|
|
304
|
+
|
|
305
|
+
target = url or self.config.url
|
|
306
|
+
if not target:
|
|
307
|
+
return False, "no webhook URL is configured"
|
|
308
|
+
body = str(entry.get("body", "")).encode("utf-8")
|
|
309
|
+
headers = {
|
|
310
|
+
"Content-Type": "application/json",
|
|
311
|
+
"X-Tlgr-Delivery": str(entry.get("delivery_id", "")),
|
|
312
|
+
"X-Tlgr-Seq": str(entry.get("seq", "")),
|
|
313
|
+
"X-Tlgr-Event": str(entry.get("event", "")),
|
|
314
|
+
"X-Tlgr-Account": str(entry.get("account", "")),
|
|
315
|
+
"X-Tlgr-Redelivery": "1",
|
|
316
|
+
}
|
|
317
|
+
secret = self.config.signing_key
|
|
318
|
+
if secret:
|
|
319
|
+
headers["X-Tlgr-Signature"] = sign_body(secret, body)
|
|
320
|
+
if self.config.token:
|
|
321
|
+
headers["Authorization"] = f"Bearer {self.config.token}"
|
|
322
|
+
session = self._session
|
|
323
|
+
close_after = session is None
|
|
324
|
+
if session is None:
|
|
325
|
+
session = aiohttp.ClientSession()
|
|
326
|
+
try:
|
|
327
|
+
async with session.post(
|
|
328
|
+
target,
|
|
329
|
+
data=body,
|
|
330
|
+
headers=headers,
|
|
331
|
+
timeout=aiohttp.ClientTimeout(total=self.config.timeout),
|
|
332
|
+
) as response:
|
|
333
|
+
if response.status < 400:
|
|
334
|
+
self.delivered += 1
|
|
335
|
+
return True, ""
|
|
336
|
+
return False, f"HTTP {response.status}"
|
|
337
|
+
except asyncio.CancelledError:
|
|
338
|
+
raise
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
341
|
+
finally:
|
|
342
|
+
if close_after:
|
|
343
|
+
await session.close()
|
|
344
|
+
|
|
345
|
+
def purge_dead_letters(self) -> int:
|
|
346
|
+
if not self._dead_letter_path.exists():
|
|
347
|
+
return 0
|
|
348
|
+
count = len(self.read_dead_letters())
|
|
349
|
+
self._dead_letter_path.unlink(missing_ok=True)
|
|
350
|
+
self.dead_letters = 0
|
|
351
|
+
return count
|
|
352
|
+
|
|
353
|
+
# -- v1 compatibility --------------------------------------------------
|
|
354
|
+
|
|
355
|
+
async def push(self, event_type: str, data: dict[str, Any], account: str = "") -> None:
|
|
356
|
+
"""v1's entry point, kept for the gateway jobs that still call it.
|
|
357
|
+
|
|
358
|
+
It builds an envelope and hands it to the same queue, so a legacy
|
|
359
|
+
caller gets the signature, the bounded queue and the dead-letter
|
|
360
|
+
handling without being rewritten.
|
|
361
|
+
"""
|
|
362
|
+
if not self.config.enabled:
|
|
363
|
+
return
|
|
364
|
+
self.enqueue(
|
|
365
|
+
EventEnvelope(
|
|
366
|
+
seq=0,
|
|
367
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
368
|
+
account=account,
|
|
369
|
+
type=event_type,
|
|
370
|
+
payload=data,
|
|
371
|
+
chat_id=data.get("chat_id") if isinstance(data, dict) else None,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def snapshot(self) -> dict[str, Any]:
|
|
376
|
+
return {
|
|
377
|
+
"enabled": self.config.enabled,
|
|
378
|
+
"queued": self._queue.qsize() if self._queue is not None else 0,
|
|
379
|
+
"delivered": self.delivered,
|
|
380
|
+
"failed": self.failed,
|
|
381
|
+
"dead_letters": self.dead_letters,
|
|
382
|
+
"dropped": self.dropped,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _is_loopback(url: str) -> bool:
|
|
387
|
+
from urllib.parse import urlparse
|
|
388
|
+
|
|
389
|
+
host = (urlparse(url).hostname or "").lower()
|
|
390
|
+
return host in ("localhost", "127.0.0.1", "::1")
|