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,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")