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/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()
|