tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/daemon/events.py ADDED
@@ -0,0 +1,723 @@
1
+ """The event bus: one normalised stream per account, many consumers.
2
+
3
+ The shape of this module is dictated by one fact from ROB-02: with
4
+ `sequential_updates=True` a slow consumer stalls **every** account. v1's
5
+ webhook pusher did its HTTP POST inside the Telethon handler, with retries and
6
+ `asyncio.sleep` backoff, so one unreachable endpoint held the update loop for
7
+ up to 97 seconds and every account went deaf for that long.
8
+
9
+ So the Telethon handler here does exactly three things and then returns:
10
+
11
+ 1. normalise the update into an `EventEnvelope` (models, never `to_dict()` —
12
+ COR-07: a `datetime` or a `bytes` in a payload is a serialisation crash at
13
+ delivery time, far away from the cause);
14
+ 2. assign the account's next `seq`, which is monotonic and persisted so that
15
+ `--since` survives a daemon restart;
16
+ 3. `put_nowait` into the ring buffer, into each stream subscriber's bounded
17
+ queue, and into one of the worker lanes.
18
+
19
+ Everything expensive — webhooks, gateway jobs — happens on a worker lane.
20
+ Lanes are chosen by `chat_id`, so per-chat order is preserved (a message and
21
+ its edit cannot be processed out of order) while different chats run
22
+ concurrently. A subscriber that cannot keep up is told so with a `lag` frame
23
+ and loses its own oldest events; the bus never blocks and no other consumer
24
+ is affected.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import contextlib
31
+ import json
32
+ import logging
33
+ import time
34
+ from collections import deque
35
+ from collections.abc import Awaitable, Callable, Iterable
36
+ from dataclasses import dataclass, field
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from tlgr.core import eventtypes
42
+ from tlgr.core.paths import write_private
43
+ from tlgr.core.tl import CHANNEL_MARK, peer_marked_id, tl_to_builtins
44
+ from tlgr.models.event import EventEnvelope
45
+
46
+ log = logging.getLogger("tlgr.daemon.events")
47
+
48
+ __all__ = [
49
+ "EVENT_TYPES",
50
+ "EventBus",
51
+ "Subscriber",
52
+ "normalise",
53
+ "normalise_update",
54
+ "tl_to_builtins",
55
+ ]
56
+
57
+ #: The taxonomy, as a tuple, for the callers that want to iterate it. The
58
+ #: table itself is `tlgr.core.eventtypes`, which `ops/` and the doc generator
59
+ #: read too — `daemon/` must not be the only place that knows the vocabulary.
60
+ #: The five `story_*` types live there with every other name; PR-8 added the
61
+ #: payload shaping below, not a second vocabulary.
62
+ EVENT_TYPES: tuple[str, ...] = tuple(sorted(eventtypes.TYPES))
63
+
64
+ #: Raw story `Update*` class name → the fine-grained `kind` its payload
65
+ #: carries. The bus *type* comes from `eventtypes`, which already maps all six
66
+ #: constructors; two of them share `story_reaction`, and `kind` is what tells
67
+ #: a received reaction from one this account sent. Matched by class name so
68
+ #: this module still imports no Telethon.
69
+ _STORY_KINDS: dict[str, str] = {
70
+ "UpdateStory": "story.new",
71
+ "UpdateStoryID": "story.id-assigned",
72
+ "UpdateReadStories": "story.read",
73
+ "UpdateNewStoryReaction": "story.reaction-received",
74
+ "UpdateSentStoryReaction": "story.reaction-sent",
75
+ "UpdateStoriesStealthMode": "story.stealth",
76
+ }
77
+
78
+ _HEARTBEAT_SECONDS = 15.0
79
+ _STATE_FLUSH_SECONDS = 5.0
80
+
81
+
82
+ def _now() -> str:
83
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Subscribers
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ @dataclass
92
+ class Subscriber:
93
+ """One consumer's bounded view of the bus.
94
+
95
+ `dropped` is not a statistic, it is a promise: a consumer that fell behind
96
+ is told exactly how many events it lost rather than silently skipping
97
+ them, which is the difference between a gap you can recover from and one
98
+ you never learn about.
99
+ """
100
+
101
+ account: str
102
+ types: frozenset[str] = frozenset()
103
+ chats: frozenset[int] = frozenset()
104
+ maxsize: int = 1024
105
+ #: `watch --raw` needs the TL update itself, which is ten times the size
106
+ #: of the payload. Converting it for every event on the chance somebody
107
+ #: wants it would tax the update loop, so the bus does it only while a
108
+ #: subscriber has asked.
109
+ want_raw: bool = False
110
+ queue: asyncio.Queue[EventEnvelope] = field(init=False)
111
+ dropped: int = 0
112
+ closed: bool = False
113
+
114
+ def __post_init__(self) -> None:
115
+ self.queue = asyncio.Queue(maxsize=self.maxsize)
116
+
117
+ def wants(self, event: EventEnvelope) -> bool:
118
+ if self.account and event.account != self.account:
119
+ return False
120
+ if self.types and event.type not in self.types:
121
+ return False
122
+ return not (self.chats and (event.chat_id is None or event.chat_id not in self.chats))
123
+
124
+ def offer(self, event: EventEnvelope) -> None:
125
+ """Never blocks. Drops the oldest event when full and counts it."""
126
+ if self.closed:
127
+ return
128
+ try:
129
+ self.queue.put_nowait(event)
130
+ except asyncio.QueueFull:
131
+ with contextlib.suppress(asyncio.QueueEmpty):
132
+ self.queue.get_nowait()
133
+ self.dropped += 1
134
+ with contextlib.suppress(asyncio.QueueFull):
135
+ self.queue.put_nowait(event)
136
+
137
+ def take_lag(self) -> int:
138
+ lag, self.dropped = self.dropped, 0
139
+ return lag
140
+
141
+ def close(self) -> None:
142
+ self.closed = True
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Normalisation
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ #: Re-exported: the bus was the first caller, but `ops/` reads `help.*`
151
+ #: replies with the same converter and may not import `daemon/` (§2.2).
152
+ _CHANNEL_MARK = CHANNEL_MARK
153
+
154
+
155
+ def _int(value: Any) -> int | None:
156
+ try:
157
+ return int(value)
158
+ except (TypeError, ValueError):
159
+ return None
160
+
161
+
162
+ def _message_payload(message: Any, chat_id: int | None) -> dict[str, Any]:
163
+ from tlgr.models.base import to_builtins
164
+ from tlgr.ops._serialize import message_to_model
165
+
166
+ model = message_to_model(message, chat_id=chat_id)
167
+ payload = to_builtins(model)
168
+ return payload if isinstance(payload, dict) else {}
169
+
170
+
171
+ def _channel_chat_id(update: Any) -> int | None:
172
+ channel_id = _int(getattr(update, "channel_id", None))
173
+ return _CHANNEL_MARK - channel_id if channel_id is not None else None
174
+
175
+
176
+ def _chat_of(update: Any) -> int | None:
177
+ """The chat an update is about, from whichever field carries it."""
178
+ for attr in ("peer", "peer_id", "saved_peer_id"):
179
+ marked = peer_marked_id(getattr(update, attr, None))
180
+ if marked is not None:
181
+ return marked
182
+ channel = _channel_chat_id(update)
183
+ if channel is not None:
184
+ return channel
185
+ chat_id = _int(getattr(update, "chat_id", None))
186
+ if chat_id is not None:
187
+ return -chat_id
188
+ return _int(getattr(update, "user_id", None))
189
+
190
+
191
+ def normalise_update(
192
+ account: str, update: Any
193
+ ) -> tuple[str, dict[str, Any], int | None, int | None] | None:
194
+ """One raw TL `Update*` → `(type, payload, chat_id, sender_id)`, or None.
195
+
196
+ Raw rather than Telethon's high-level events, on purpose: `events.NewMessage`
197
+ and friends drop service messages, topic ids and every action kind Telethon
198
+ does not model, so a `watch` built on them can only ever show a subset of
199
+ what the GUI shows. Everything the taxonomy names is reachable from here.
200
+
201
+ None means the constructor is `INTERNAL` (a container, a transport signal)
202
+ or is not an update at all. tlgr never invents a type name for it: a name
203
+ meaning "we did not look" cannot be filtered on and changes meaning the
204
+ day the real one arrives.
205
+ """
206
+ name = type(update).__name__
207
+ event_type = eventtypes.type_for_constructor(name)
208
+ if event_type is None:
209
+ return None
210
+
211
+ chat_id = _chat_of(update)
212
+ sender_id: int | None = None
213
+ payload: dict[str, Any]
214
+
215
+ if event_type in ("message_new", "message_edited", "message_scheduled_new"):
216
+ message = getattr(update, "message", None)
217
+ if message is None or isinstance(message, str):
218
+ # updateShortMessage/updateShortChatMessage carry the text, not a
219
+ # Message; Telethon normalises them before they reach a handler,
220
+ # so this branch only fires for a hand-built update.
221
+ payload = tl_to_builtins(update)
222
+ return event_type, payload, chat_id, sender_id
223
+ if type(message).__name__ == "MessageService":
224
+ action = getattr(message, "action", None)
225
+ payload = _message_payload(message, chat_id)
226
+ payload["action"] = type(action).__name__ if action is not None else ""
227
+ return (
228
+ "message_service",
229
+ payload,
230
+ chat_id or payload.get("chat_id"),
231
+ payload.get("sender_id"),
232
+ )
233
+ payload = _message_payload(message, chat_id)
234
+ return event_type, payload, chat_id or payload.get("chat_id"), payload.get("sender_id")
235
+
236
+ if event_type == "message_deleted":
237
+ payload = {
238
+ "message_ids": [int(i) for i in (getattr(update, "messages", None) or [])],
239
+ }
240
+ channel = _channel_chat_id(update)
241
+ if channel is not None:
242
+ payload["channel_id"] = channel
243
+ return event_type, payload, channel, None
244
+
245
+ if event_type in ("read_inbox", "read_outbox"):
246
+ payload = {
247
+ "max_id": _int(getattr(update, "max_id", None)),
248
+ "outbox": event_type == "read_outbox",
249
+ }
250
+ unread = getattr(update, "still_unread_count", None)
251
+ if unread is not None:
252
+ payload["still_unread_count"] = _int(unread)
253
+ return event_type, payload, chat_id, None
254
+
255
+ if event_type == "typing":
256
+ action = getattr(update, "action", None)
257
+ actor = peer_marked_id(getattr(update, "from_id", None))
258
+ user_id = _int(getattr(update, "user_id", None))
259
+ payload = {
260
+ "user_id": user_id if user_id is not None else actor,
261
+ "action": type(action).__name__ if action is not None else "",
262
+ "progress": _int(getattr(action, "progress", None)),
263
+ "top_msg_id": _int(getattr(update, "top_msg_id", None)),
264
+ }
265
+ return event_type, payload, chat_id, payload["user_id"]
266
+
267
+ if event_type == "user_status":
268
+ status = getattr(update, "status", None)
269
+ status_name = type(status).__name__ if status is not None else None
270
+ user_id = _int(getattr(update, "user_id", None))
271
+ payload = {
272
+ "user_id": user_id,
273
+ "status": status_name,
274
+ "online": status_name == "UserStatusOnline",
275
+ "was_online": _int(getattr(status, "was_online", None)),
276
+ }
277
+ return event_type, payload, user_id, user_id
278
+
279
+ if event_type == "message_reactions":
280
+ payload = {
281
+ "msg_id": _int(getattr(update, "msg_id", None)),
282
+ "top_msg_id": _int(getattr(update, "top_msg_id", None)),
283
+ "reactions": tl_to_builtins(getattr(update, "reactions", None)),
284
+ }
285
+ return event_type, payload, chat_id, None
286
+
287
+ if event_type == "dialog_draft":
288
+ payload = {
289
+ "peer": tl_to_builtins(getattr(update, "peer", None)),
290
+ "draft": tl_to_builtins(getattr(update, "draft", None)),
291
+ "top_msg_id": _int(getattr(update, "top_msg_id", None)),
292
+ }
293
+ return event_type, payload, chat_id, None
294
+
295
+ if event_type == "message_id_assigned":
296
+ payload = {
297
+ "msg_id": _int(getattr(update, "id", None)),
298
+ "random_id": _int(getattr(update, "random_id", None)),
299
+ }
300
+ return event_type, payload, chat_id, None
301
+
302
+ if event_type == "message_pinned":
303
+ payload = {
304
+ "message_ids": [int(i) for i in (getattr(update, "messages", None) or [])],
305
+ "pinned": bool(getattr(update, "pinned", False)),
306
+ }
307
+ return event_type, payload, chat_id, None
308
+
309
+ if event_type == "sync_channel_too_long":
310
+ payload = {
311
+ "channel_id": _channel_chat_id(update),
312
+ "pts": _int(getattr(update, "pts", None)),
313
+ }
314
+ return event_type, payload, chat_id, None
315
+
316
+ if event_type in ("story_new", "story_id", "story_read", "story_reaction", "story_stealth"):
317
+ payload = {"kind": _STORY_KINDS.get(name, event_type)}
318
+ if chat_id is not None:
319
+ payload["peer"] = chat_id
320
+ story = getattr(update, "story", None)
321
+ story_id = getattr(story, "id", None) if story is not None else None
322
+ if story_id is None:
323
+ story_id = getattr(update, "story_id", None)
324
+ if story_id is None and event_type == "story_id":
325
+ story_id = getattr(update, "id", None)
326
+ if story_id is not None:
327
+ payload["story_id"] = int(story_id)
328
+ max_id = _int(getattr(update, "max_id", None))
329
+ if max_id is not None:
330
+ payload["max_read_id"] = max_id
331
+ reaction = getattr(update, "reaction", None)
332
+ if reaction is not None:
333
+ emoticon = getattr(reaction, "emoticon", None)
334
+ document = getattr(reaction, "document_id", None)
335
+ payload["reaction"] = (
336
+ str(emoticon) if emoticon else (f"custom:{document}" if document else "?")
337
+ )
338
+ stealth = getattr(update, "stealth_mode", None)
339
+ if stealth is not None:
340
+ payload["stealth_mode"] = {
341
+ "active_until_unix": _epoch(getattr(stealth, "active_until_date", None)),
342
+ "cooldown_until_unix": _epoch(getattr(stealth, "cooldown_until_date", None)),
343
+ }
344
+ return event_type, payload, chat_id, None
345
+
346
+ # Everything else is delivered as the update's own fields, JSON-safe. The
347
+ # taxonomy says so per type, so a consumer is never guessing.
348
+ payload = tl_to_builtins(update)
349
+ if not isinstance(payload, dict):
350
+ payload = {"value": payload}
351
+ return event_type, payload, chat_id, sender_id
352
+
353
+
354
+ def normalise(
355
+ account: str, event: Any
356
+ ) -> tuple[str, dict[str, Any], int | None, int | None] | None:
357
+ """Map one Telethon *event or update* onto `(type, payload, chat, sender)`.
358
+
359
+ Raw updates go through `normalise_update`; the high-level event classes
360
+ still work because a gateway job, a test and the v1 code path all hand
361
+ them over, and dropping that would be a compatibility break with nothing
362
+ gained.
363
+ """
364
+ if type(event).__name__ in eventtypes.CONSTRUCTORS or type(event).__name__ in (
365
+ eventtypes.INTERNAL
366
+ ):
367
+ return normalise_update(account, event)
368
+
369
+ chat_id = getattr(event, "chat_id", None)
370
+ if chat_id is not None:
371
+ with contextlib.suppress(TypeError, ValueError):
372
+ chat_id = int(chat_id)
373
+
374
+ kind = _event_kind(event)
375
+ if kind is None:
376
+ return None
377
+
378
+ if kind in ("message_new", "message_edited"):
379
+ message = getattr(event, "message", None)
380
+ payload = _message_payload(message, chat_id) if message is not None else {}
381
+ if message is not None and type(message).__name__ == "MessageService":
382
+ action = getattr(message, "action", None)
383
+ payload["action"] = type(action).__name__ if action is not None else ""
384
+ kind = "message_service"
385
+ return kind, payload, chat_id, payload.get("sender_id")
386
+
387
+ if kind == "message_deleted":
388
+ ids = list(getattr(event, "deleted_ids", None) or [])
389
+ return kind, {"message_ids": ids}, chat_id, None
390
+
391
+ if kind == "read":
392
+ outbox = bool(getattr(event, "outbox", False))
393
+ return (
394
+ "read_outbox" if outbox else "read_inbox",
395
+ {"max_id": getattr(event, "max_id", None), "outbox": outbox},
396
+ chat_id,
397
+ None,
398
+ )
399
+
400
+ if kind == "message_service":
401
+ action_message = getattr(event, "action_message", None)
402
+ action = type(getattr(action_message, "action", None)).__name__ if action_message else ""
403
+ return (
404
+ kind,
405
+ {
406
+ "action": action,
407
+ "user_id": getattr(event, "user_id", None),
408
+ "user_ids": list(getattr(event, "user_ids", None) or []),
409
+ },
410
+ chat_id,
411
+ getattr(event, "user_id", None),
412
+ )
413
+
414
+ if kind == "user_status":
415
+ status = getattr(event, "status", None)
416
+ return (
417
+ kind,
418
+ {
419
+ "user_id": getattr(event, "user_id", None),
420
+ "status": type(status).__name__ if status is not None else None,
421
+ "online": bool(getattr(event, "online", False)),
422
+ },
423
+ chat_id,
424
+ getattr(event, "user_id", None),
425
+ )
426
+
427
+ return None
428
+
429
+
430
+ def _epoch(value: Any) -> int | None:
431
+ timestamp = getattr(value, "timestamp", None)
432
+ return int(timestamp()) if callable(timestamp) else None
433
+
434
+
435
+ def _event_kind(event: Any) -> str | None:
436
+ """The tlgr type name for a Telethon *high-level* event object.
437
+
438
+ Matched on the qualified class name rather than by `isinstance`, so this
439
+ module — and therefore the bus — does not import Telethon at all and can
440
+ be unit-tested with a fake event.
441
+ """
442
+ qualname = f"{type(event).__module__}.{type(event).__qualname__}"
443
+ for needle, kind in (
444
+ ("newmessage", "message_new"),
445
+ ("messageedited", "message_edited"),
446
+ ("messagedeleted", "message_deleted"),
447
+ ("messageread", "read"),
448
+ ("chataction", "message_service"),
449
+ ("userupdate", "user_status"),
450
+ ):
451
+ if needle in qualname.lower():
452
+ return kind
453
+ explicit = getattr(event, "tlgr_type", None)
454
+ return str(explicit) if explicit else None
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # The bus
459
+ # ---------------------------------------------------------------------------
460
+
461
+
462
+ #: A handler is given the normalised envelope **and** the object it came from.
463
+ #: The envelope is what leaves the process; the raw Telethon event is what the
464
+ #: gateway's filters still read, and re-deriving it from the payload would be
465
+ #: both lossy and a second source of truth. It is `None` for an event tlgr
466
+ #: itself synthesised (a self-origin echo, a health event).
467
+ Handler = Callable[[EventEnvelope, Any], Awaitable[None]]
468
+
469
+
470
+ class EventBus:
471
+ def __init__(
472
+ self,
473
+ *,
474
+ state_dir_for: Callable[[str], Path] | None = None,
475
+ buffer_size: int = 4096,
476
+ workers: int = 8,
477
+ lane_queue_size: int = 512,
478
+ ) -> None:
479
+ self._state_path = state_dir_for
480
+ self.buffer_size = max(16, buffer_size)
481
+ self.workers = max(1, workers)
482
+ self.lane_queue_size = max(16, lane_queue_size)
483
+ self._seq: dict[str, int] = {}
484
+ self._buffers: dict[str, deque[EventEnvelope]] = {}
485
+ self._subscribers: list[Subscriber] = []
486
+ self._handlers: list[Handler] = []
487
+ self._lanes: list[asyncio.Queue[tuple[EventEnvelope, Any]]] = []
488
+ self._tasks: list[asyncio.Task[None]] = []
489
+ self._flush_task: asyncio.Task[None] | None = None
490
+ self._dirty: set[str] = set()
491
+ self._raw_wanted = 0
492
+ self._running = False
493
+
494
+ # -- lifecycle ---------------------------------------------------------
495
+
496
+ async def start(self) -> None:
497
+ if self._running:
498
+ return
499
+ self._running = True
500
+ self._lanes = [asyncio.Queue(maxsize=self.lane_queue_size) for _ in range(self.workers)]
501
+ self._tasks = [
502
+ asyncio.create_task(self._worker(index), name=f"tlgr-event-lane-{index}")
503
+ for index in range(self.workers)
504
+ ]
505
+ self._flush_task = asyncio.create_task(self._flush_loop(), name="tlgr-event-seq-flush")
506
+
507
+ async def stop(self) -> None:
508
+ self._running = False
509
+ for task in [*self._tasks, self._flush_task]:
510
+ if task is None:
511
+ continue
512
+ task.cancel()
513
+ for task in [*self._tasks, self._flush_task]:
514
+ if task is None:
515
+ continue
516
+ with contextlib.suppress(asyncio.CancelledError, Exception):
517
+ await task
518
+ self._tasks = []
519
+ self._flush_task = None
520
+ for subscriber in list(self._subscribers):
521
+ subscriber.close()
522
+ self.flush_state()
523
+
524
+ # -- sequence numbers --------------------------------------------------
525
+
526
+ def _state_file(self, account: str) -> Path | None:
527
+ return self._state_path(account) if self._state_path else None
528
+
529
+ def load_seq(self, account: str) -> int:
530
+ if account in self._seq:
531
+ return self._seq[account]
532
+ path = self._state_file(account)
533
+ value = 0
534
+ if path is not None and path.exists():
535
+ try:
536
+ raw = json.loads(path.read_text())
537
+ value = int(raw.get("seq", 0)) if isinstance(raw, dict) else int(raw)
538
+ except (OSError, ValueError, json.JSONDecodeError):
539
+ value = 0
540
+ self._seq[account] = value
541
+ return value
542
+
543
+ def flush_state(self) -> None:
544
+ """Persist every dirty account's `seq`. Cheap, and idempotent."""
545
+ for account in list(self._dirty):
546
+ path = self._state_file(account)
547
+ self._dirty.discard(account)
548
+ if path is None:
549
+ continue
550
+ with contextlib.suppress(OSError):
551
+ write_private(path, json.dumps({"seq": self._seq.get(account, 0)}))
552
+
553
+ async def _flush_loop(self) -> None:
554
+ while self._running:
555
+ await asyncio.sleep(_STATE_FLUSH_SECONDS)
556
+ self.flush_state()
557
+
558
+ # -- publishing --------------------------------------------------------
559
+
560
+ def emit(
561
+ self,
562
+ account: str,
563
+ event_type: str,
564
+ payload: dict[str, Any] | None = None,
565
+ *,
566
+ chat_id: int | None = None,
567
+ sender_id: int | None = None,
568
+ self_origin: bool = False,
569
+ raw: Any = None,
570
+ ) -> EventEnvelope:
571
+ """Build, number, buffer and fan out one event. Never blocks."""
572
+ seq = self.load_seq(account) + 1
573
+ self._seq[account] = seq
574
+ self._dirty.add(account)
575
+ envelope = EventEnvelope(
576
+ seq=seq,
577
+ ts=_now(),
578
+ account=account,
579
+ type=event_type,
580
+ payload=payload or {},
581
+ chat_id=chat_id,
582
+ sender_id=sender_id,
583
+ self_origin=self_origin,
584
+ )
585
+ self.publish(envelope, raw)
586
+ return envelope
587
+
588
+ def publish(self, envelope: EventEnvelope, raw: Any = None) -> None:
589
+ buffer = self._buffers.setdefault(envelope.account, deque(maxlen=self.buffer_size))
590
+ buffer.append(envelope)
591
+
592
+ if raw is not None and self._raw_wanted:
593
+ envelope.raw = tl_to_builtins(getattr(raw, "original_update", raw))
594
+
595
+ for subscriber in self._subscribers:
596
+ if subscriber.closed:
597
+ continue
598
+ if subscriber.wants(envelope):
599
+ subscriber.offer(envelope)
600
+
601
+ if self._lanes and self._handlers:
602
+ lane = self._lane_for(envelope)
603
+ try:
604
+ lane.put_nowait((envelope, raw))
605
+ except asyncio.QueueFull:
606
+ # The lane is the *handlers'* backlog. Dropping the oldest
607
+ # keeps the update loop moving; the stream subscribers and the
608
+ # ring buffer above still have the event.
609
+ with contextlib.suppress(asyncio.QueueEmpty):
610
+ lane.get_nowait()
611
+ log.warning(
612
+ "event worker lane is full; dropped the oldest event",
613
+ extra={"account": envelope.account, "seq": envelope.seq},
614
+ )
615
+ with contextlib.suppress(asyncio.QueueFull):
616
+ lane.put_nowait((envelope, raw))
617
+
618
+ def _lane_for(self, envelope: EventEnvelope) -> asyncio.Queue[tuple[EventEnvelope, Any]]:
619
+ key = envelope.chat_id if envelope.chat_id is not None else hash(envelope.account)
620
+ return self._lanes[abs(int(key)) % len(self._lanes)]
621
+
622
+ async def _worker(self, index: int) -> None:
623
+ lane = self._lanes[index]
624
+ while True:
625
+ envelope, raw = await lane.get()
626
+ for handler in list(self._handlers):
627
+ try:
628
+ await handler(envelope, raw)
629
+ except asyncio.CancelledError:
630
+ raise
631
+ except Exception:
632
+ log.exception(
633
+ "event handler failed",
634
+ extra={"account": envelope.account, "seq": envelope.seq},
635
+ )
636
+
637
+ # -- consumers ---------------------------------------------------------
638
+
639
+ def add_handler(self, handler: Handler) -> None:
640
+ self._handlers.append(handler)
641
+
642
+ def remove_handler(self, handler: Handler) -> None:
643
+ with contextlib.suppress(ValueError):
644
+ self._handlers.remove(handler)
645
+
646
+ def subscribe(
647
+ self,
648
+ account: str,
649
+ *,
650
+ types: Iterable[str] = (),
651
+ chats: Iterable[int] = (),
652
+ maxsize: int = 1024,
653
+ want_raw: bool = False,
654
+ ) -> Subscriber:
655
+ subscriber = Subscriber(
656
+ account=account,
657
+ types=frozenset(types),
658
+ chats=frozenset(int(c) for c in chats),
659
+ maxsize=maxsize,
660
+ want_raw=want_raw,
661
+ )
662
+ self._subscribers.append(subscriber)
663
+ if want_raw:
664
+ self._raw_wanted += 1
665
+ return subscriber
666
+
667
+ def unsubscribe(self, subscriber: Subscriber) -> None:
668
+ subscriber.close()
669
+ if subscriber.want_raw and self._raw_wanted:
670
+ self._raw_wanted -= 1
671
+ with contextlib.suppress(ValueError):
672
+ self._subscribers.remove(subscriber)
673
+
674
+ # -- replay ------------------------------------------------------------
675
+
676
+ def replay(
677
+ self, account: str, since: int | None
678
+ ) -> tuple[list[EventEnvelope], dict[str, Any] | None]:
679
+ """Events after *since*, plus a `gap` frame when some are already lost.
680
+
681
+ A consumer that asks for events after 91,820 when the buffer starts at
682
+ 95,000 has missed 3,180 of them. Returning the newest 4,096 with no
683
+ signal would be a silent lie; the gap frame is the honest answer and
684
+ the consumer decides what to do about it.
685
+ """
686
+ buffer = self._buffers.get(account)
687
+ if since is None or buffer is None or not buffer:
688
+ return (list(buffer or ()), None) if since is not None else ([], None)
689
+
690
+ oldest = buffer[0].seq
691
+ gap: dict[str, Any] | None = None
692
+ if since + 1 < oldest:
693
+ gap = {
694
+ "type": "gap",
695
+ "from": oldest,
696
+ "requested": since,
697
+ "lost": oldest - since - 1,
698
+ }
699
+ return [event for event in buffer if event.seq > since], gap
700
+
701
+ def latest_seq(self, account: str) -> int:
702
+ return self.load_seq(account)
703
+
704
+ def buffered(self, account: str) -> int:
705
+ return len(self._buffers.get(account, ()))
706
+
707
+
708
+ async def heartbeat_ticker(interval: float = _HEARTBEAT_SECONDS) -> Any:
709
+ """The `/v1/events` heartbeat clock, factored out so a test can shorten it."""
710
+ await asyncio.sleep(interval)
711
+ return {"type": "heartbeat", "ts": _now()}
712
+
713
+
714
+ def heartbeat_frame() -> dict[str, Any]:
715
+ return {"type": "heartbeat", "ts": _now()}
716
+
717
+
718
+ def now_iso() -> str:
719
+ return _now()
720
+
721
+
722
+ def monotonic() -> float:
723
+ return time.monotonic()