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/ops/events.py
ADDED
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
"""The `events` group and `watch`: discovering, replaying and following events.
|
|
2
|
+
|
|
3
|
+
Four of these five operations run without touching Telegram at all. That is
|
|
4
|
+
the point: an agent should be able to ask *what can arrive* (`events list`),
|
|
5
|
+
*what one looks like* (`events get`), and *what did arrive* (`events replay`)
|
|
6
|
+
before it commits to holding a stream open (`watch`).
|
|
7
|
+
|
|
8
|
+
`watch` replaces v1's poller. v1 asked the daemon for `chat list` every two
|
|
9
|
+
seconds, then `message list` per chat, and emitted only new messages — so an
|
|
10
|
+
edit, a deletion, a read receipt, a reaction and every service message were
|
|
11
|
+
invisible, and twenty chats cost thirty HTTP round trips a minute whether or
|
|
12
|
+
not anything happened. Here the daemon holds one `events.Raw` handler per
|
|
13
|
+
account and a watcher is a bounded queue on the bus: nothing is polled, and
|
|
14
|
+
everything in the taxonomy is selectable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import base64
|
|
21
|
+
import binascii
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import AsyncIterator
|
|
27
|
+
from typing import Annotated, Any
|
|
28
|
+
|
|
29
|
+
from tlgr.core import eventtypes
|
|
30
|
+
from tlgr.core.errors import (
|
|
31
|
+
EXIT_EMPTY,
|
|
32
|
+
IndeterminateError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
NotSupportedError,
|
|
35
|
+
UsageError,
|
|
36
|
+
)
|
|
37
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
38
|
+
from tlgr.models.base import Request, to_builtins
|
|
39
|
+
from tlgr.models.event import DecodedEvent, EventEnvelope, EventType, EventTypeDetail
|
|
40
|
+
from tlgr.models.page import Page
|
|
41
|
+
from tlgr.models.peer import PeerRef
|
|
42
|
+
from tlgr.ops import _send
|
|
43
|
+
from tlgr.ops._params import arg, choice, opt
|
|
44
|
+
from tlgr.ops._spec import OpContext, OperationSpec, Surface
|
|
45
|
+
|
|
46
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
47
|
+
|
|
48
|
+
_EXAMPLE_ENVELOPE: dict[str, Any] = {
|
|
49
|
+
"seq": 91824,
|
|
50
|
+
"ts": "2026-09-03T09:14:07Z",
|
|
51
|
+
"account": "work",
|
|
52
|
+
"type": "message_new",
|
|
53
|
+
"payload": {"id": 12345, "chat_id": 777123, "text": "on my way"},
|
|
54
|
+
"chat_id": 777123,
|
|
55
|
+
"sender_id": 4242,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Shared plumbing
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _row(name: str, spec: eventtypes.EventTypeSpec, sources: tuple[str, ...]) -> EventType:
|
|
65
|
+
return EventType(
|
|
66
|
+
type=name,
|
|
67
|
+
group=spec.group,
|
|
68
|
+
summary=spec.summary,
|
|
69
|
+
sources=list(sources),
|
|
70
|
+
telethon=spec.telethon,
|
|
71
|
+
box=spec.box,
|
|
72
|
+
bot_only=spec.bot_only,
|
|
73
|
+
since_layer=spec.since_layer,
|
|
74
|
+
available=spec.since_layer == 0,
|
|
75
|
+
derived=spec.derived,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _window(ctx: OpContext, op: str, default: int = 200) -> tuple[int, dict[str, Any]]:
|
|
80
|
+
from tlgr.core.pagination import decode_cursor
|
|
81
|
+
|
|
82
|
+
limit = int(getattr(ctx, "limit", None) or default)
|
|
83
|
+
if limit < 1:
|
|
84
|
+
raise UsageError("--limit must be at least 1", field="limit")
|
|
85
|
+
token = getattr(ctx, "cursor", None)
|
|
86
|
+
state: dict[str, Any] = {}
|
|
87
|
+
if token:
|
|
88
|
+
state = decode_cursor(token, op=op, kind=PageKind.LOCAL, account=ctx.account)
|
|
89
|
+
return min(limit, 5000), state
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _chat_ids(ctx: OpContext, refs: tuple[PeerRef, ...]) -> list[int]:
|
|
93
|
+
"""`--chat` → marked ids, resolving through the account when it is connected.
|
|
94
|
+
|
|
95
|
+
A watcher does not need a Telegram client, so the resolver may be absent;
|
|
96
|
+
a numeric id still works, and a username without a connected account is a
|
|
97
|
+
usage error naming the fix rather than a filter that silently matches
|
|
98
|
+
nothing.
|
|
99
|
+
"""
|
|
100
|
+
out: list[int] = []
|
|
101
|
+
for ref in refs:
|
|
102
|
+
if ref.kind == "id":
|
|
103
|
+
out.append(int(ref.value))
|
|
104
|
+
continue
|
|
105
|
+
if getattr(ctx, "resolver", None) is None:
|
|
106
|
+
raise UsageError(
|
|
107
|
+
f"{ref.raw!r} needs a connected account to resolve; "
|
|
108
|
+
"pass the numeric chat id, or start the daemon first",
|
|
109
|
+
field="chat",
|
|
110
|
+
)
|
|
111
|
+
out.append(_send.peer_id_of(await _send.resolve(ctx, ref)))
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _bus(ctx: OpContext) -> Any:
|
|
116
|
+
bus = getattr(ctx, "bus", None)
|
|
117
|
+
if bus is None:
|
|
118
|
+
raise UsageError("this operation needs the daemon's event bus")
|
|
119
|
+
return bus
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _accounts(ctx: OpContext) -> list[str]:
|
|
123
|
+
"""The accounts an `--account all` operation spans, or the one given."""
|
|
124
|
+
if ctx.account and ctx.account != "all":
|
|
125
|
+
return [ctx.account]
|
|
126
|
+
daemon = getattr(ctx, "daemon", None)
|
|
127
|
+
sessions = getattr(daemon, "sessions", None)
|
|
128
|
+
return list(getattr(sessions, "aliases", []) or [])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# events list
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class EventListReq(Request):
|
|
137
|
+
group: Annotated[
|
|
138
|
+
str | None,
|
|
139
|
+
choice(*eventtypes.GROUPS, help="Only this family."),
|
|
140
|
+
] = None
|
|
141
|
+
raw: Annotated[
|
|
142
|
+
bool,
|
|
143
|
+
opt("--raw", help="One row per raw TL update constructor instead of per type."),
|
|
144
|
+
] = False
|
|
145
|
+
available: Annotated[
|
|
146
|
+
bool,
|
|
147
|
+
opt(
|
|
148
|
+
"--available",
|
|
149
|
+
help="Only types this build can actually receive (hides bot-only and layer-229).",
|
|
150
|
+
),
|
|
151
|
+
] = False
|
|
152
|
+
search: Annotated[
|
|
153
|
+
str | None,
|
|
154
|
+
opt("--search", metavar="TEXT", help="Substring match on type, constructor or summary."),
|
|
155
|
+
] = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def event_list(ctx: OpContext, req: EventListReq) -> Page[EventType]:
|
|
159
|
+
"""The subscribable surface, machine-readable.
|
|
160
|
+
|
|
161
|
+
An agent that has to learn the vocabulary from prose will get it wrong;
|
|
162
|
+
this is the same table `docs/design/EVENTS.md` prints and `watch --events`
|
|
163
|
+
accepts, so there is exactly one source of truth for it.
|
|
164
|
+
"""
|
|
165
|
+
rows: list[EventType] = []
|
|
166
|
+
for name, spec in sorted(eventtypes.TYPES.items()):
|
|
167
|
+
if req.group and spec.group != req.group:
|
|
168
|
+
continue
|
|
169
|
+
if req.available and (spec.bot_only or spec.since_layer):
|
|
170
|
+
continue
|
|
171
|
+
sources = eventtypes.constructors_for(name)
|
|
172
|
+
if req.raw:
|
|
173
|
+
for source in sources:
|
|
174
|
+
if req.available and source in eventtypes.NEWER_THAN_LAYER_227:
|
|
175
|
+
continue
|
|
176
|
+
row = _row(name, spec, (source,))
|
|
177
|
+
row.available = source not in eventtypes.NEWER_THAN_LAYER_227
|
|
178
|
+
rows.append(row)
|
|
179
|
+
else:
|
|
180
|
+
rows.append(_row(name, spec, sources))
|
|
181
|
+
|
|
182
|
+
if req.search:
|
|
183
|
+
needle = req.search.lower()
|
|
184
|
+
rows = [
|
|
185
|
+
row
|
|
186
|
+
for row in rows
|
|
187
|
+
if needle in row.type
|
|
188
|
+
or needle in row.summary.lower()
|
|
189
|
+
or any(needle in source.lower() for source in row.sources)
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
limit, state = _window(ctx, "events.list")
|
|
193
|
+
offset = int(state.get("offset", 0))
|
|
194
|
+
window = rows[offset : offset + limit]
|
|
195
|
+
return build_page(
|
|
196
|
+
window,
|
|
197
|
+
op="events.list",
|
|
198
|
+
kind=PageKind.LOCAL,
|
|
199
|
+
state={"offset": offset + len(window)},
|
|
200
|
+
account=ctx.account,
|
|
201
|
+
has_more=offset + len(window) < len(rows),
|
|
202
|
+
total=len(rows),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
SPEC_EVENT_LIST = OperationSpec(
|
|
207
|
+
id="events.list",
|
|
208
|
+
request=EventListReq,
|
|
209
|
+
response=Page[EventType],
|
|
210
|
+
impl=event_list,
|
|
211
|
+
summary="List the event types tlgr can emit, with their source constructors",
|
|
212
|
+
description=(
|
|
213
|
+
"114 types covering every `Update*` constructor Telethon can parse, "
|
|
214
|
+
"plus the ones Telegram has added since. `--raw` lists the "
|
|
215
|
+
"constructors instead. These names are the only values `watch "
|
|
216
|
+
"--events`, `job add --events` and `webhook set --events` accept."
|
|
217
|
+
),
|
|
218
|
+
# No `schema events` alias: placing one would turn the top-level `schema`
|
|
219
|
+
# command into a group and take v1's bare `tlgr schema` with it. The same
|
|
220
|
+
# taxonomy is reachable as `tlgr schema events`, which is `agent.schema`'s
|
|
221
|
+
# own positional (DECISIONS, 2026-09-03).
|
|
222
|
+
needs_account=False,
|
|
223
|
+
needs_auth=False,
|
|
224
|
+
needs_client=False,
|
|
225
|
+
surface=Surface.LOCAL,
|
|
226
|
+
idempotent=True,
|
|
227
|
+
rate_class="local",
|
|
228
|
+
timeout_s=15,
|
|
229
|
+
paginated=PageKind.LOCAL,
|
|
230
|
+
columns=("type", "group", "box", "summary"),
|
|
231
|
+
example={
|
|
232
|
+
"items": [
|
|
233
|
+
{
|
|
234
|
+
"type": "message_new",
|
|
235
|
+
"group": "message",
|
|
236
|
+
"summary": "A message arrived in any chat the account can see",
|
|
237
|
+
"sources": ["UpdateNewMessage"],
|
|
238
|
+
"box": "pts",
|
|
239
|
+
}
|
|
240
|
+
],
|
|
241
|
+
"has_more": False,
|
|
242
|
+
"total": 114,
|
|
243
|
+
},
|
|
244
|
+
example_args="events list --group message",
|
|
245
|
+
covers=(
|
|
246
|
+
"updates.event-ai-compose-tones",
|
|
247
|
+
"updates.event-autosave-settings",
|
|
248
|
+
"updates.event-bot-callback-query",
|
|
249
|
+
"updates.event-bot-ephemeral-callback",
|
|
250
|
+
"updates.event-bot-inline-query",
|
|
251
|
+
"updates.event-bot-message-reactions",
|
|
252
|
+
"updates.event-bot-stars-subscription",
|
|
253
|
+
"updates.event-bot-webhook-json",
|
|
254
|
+
"updates.event-channel-forwards",
|
|
255
|
+
"updates.event-channel-views",
|
|
256
|
+
"updates.event-chat-participants",
|
|
257
|
+
"updates.event-config-changed",
|
|
258
|
+
"updates.event-dc-options",
|
|
259
|
+
"updates.event-dialog-filters",
|
|
260
|
+
"updates.event-dialog-unread-mark",
|
|
261
|
+
"updates.event-emoji-game-info",
|
|
262
|
+
"updates.event-ephemeral-messages",
|
|
263
|
+
"updates.event-folder-peers",
|
|
264
|
+
"updates.event-group-call",
|
|
265
|
+
"updates.event-join-chat-webview-decision",
|
|
266
|
+
"updates.event-login-token",
|
|
267
|
+
"updates.event-message-deleted",
|
|
268
|
+
"updates.event-new-authorization",
|
|
269
|
+
"updates.event-new-message",
|
|
270
|
+
"updates.event-peer-blocked",
|
|
271
|
+
"updates.event-peer-wallpaper",
|
|
272
|
+
"updates.event-pinned-messages",
|
|
273
|
+
"updates.event-pts-changed",
|
|
274
|
+
"updates.event-read-contents",
|
|
275
|
+
"updates.event-read-monoforum",
|
|
276
|
+
"updates.event-saved-dialogs",
|
|
277
|
+
"updates.event-scheduled-deleted",
|
|
278
|
+
"updates.event-service-message",
|
|
279
|
+
"updates.event-stars-balance",
|
|
280
|
+
"updates.event-stories-stealth",
|
|
281
|
+
"updates.event-story-reaction",
|
|
282
|
+
"updates.event-typing",
|
|
283
|
+
"updates.event-user-phone",
|
|
284
|
+
"updates.event-view-forum-as-messages",
|
|
285
|
+
"updates.event-webview-result-sent",
|
|
286
|
+
),
|
|
287
|
+
covers_partial=(
|
|
288
|
+
"updates.event-attach-menu-bots",
|
|
289
|
+
"updates.event-bot-business",
|
|
290
|
+
"updates.event-bot-commands",
|
|
291
|
+
"updates.event-bot-guest-chat-query",
|
|
292
|
+
"updates.event-bot-menu-button",
|
|
293
|
+
"updates.event-bot-payments",
|
|
294
|
+
"updates.event-bot-stopped",
|
|
295
|
+
"updates.event-channel-available-messages",
|
|
296
|
+
"updates.event-channel-participant",
|
|
297
|
+
"updates.event-chat-boost",
|
|
298
|
+
"updates.event-chat-refetch",
|
|
299
|
+
"updates.event-contacts-reset",
|
|
300
|
+
"updates.event-default-banned-rights",
|
|
301
|
+
"updates.event-dialog-pinned",
|
|
302
|
+
"updates.event-draft",
|
|
303
|
+
"updates.event-encrypted-chats",
|
|
304
|
+
"updates.event-extended-media",
|
|
305
|
+
"updates.event-geo-live-viewed",
|
|
306
|
+
"updates.event-history-ttl",
|
|
307
|
+
"updates.event-join-requests",
|
|
308
|
+
"updates.event-managed-bot",
|
|
309
|
+
"updates.event-message-edited",
|
|
310
|
+
"updates.event-message-id-map",
|
|
311
|
+
"updates.event-new-bot-connection",
|
|
312
|
+
"updates.event-new-channel-message",
|
|
313
|
+
"updates.event-notify-settings",
|
|
314
|
+
"updates.event-paid-reaction-privacy",
|
|
315
|
+
"updates.event-peer-located",
|
|
316
|
+
"updates.event-peer-settings",
|
|
317
|
+
"updates.event-phone-call",
|
|
318
|
+
"updates.event-pinned-forum-topics",
|
|
319
|
+
"updates.event-poll",
|
|
320
|
+
"updates.event-privacy",
|
|
321
|
+
"updates.event-quick-replies",
|
|
322
|
+
"updates.event-reactions",
|
|
323
|
+
"updates.event-read-discussion",
|
|
324
|
+
"updates.event-read-inbox",
|
|
325
|
+
"updates.event-read-outbox",
|
|
326
|
+
"updates.event-recent-reactions",
|
|
327
|
+
"updates.event-report-message-delivery",
|
|
328
|
+
"updates.event-saved-gifs",
|
|
329
|
+
"updates.event-saved-ringtones",
|
|
330
|
+
"updates.event-scheduled-new",
|
|
331
|
+
"updates.event-sent-phone-code",
|
|
332
|
+
"updates.event-service-notification",
|
|
333
|
+
"updates.event-star-gift-auction",
|
|
334
|
+
"updates.event-stars-revenue",
|
|
335
|
+
"updates.event-stickers-changed",
|
|
336
|
+
"updates.event-story-id",
|
|
337
|
+
"updates.event-story-new",
|
|
338
|
+
"updates.event-story-read",
|
|
339
|
+
"updates.event-transcription",
|
|
340
|
+
"updates.event-user-emoji-status",
|
|
341
|
+
"updates.event-user-name",
|
|
342
|
+
"updates.event-user-refetch",
|
|
343
|
+
"updates.event-user-status",
|
|
344
|
+
"updates.event-web-browser-settings",
|
|
345
|
+
"updates.event-webpage",
|
|
346
|
+
"updates.stream-event-types",
|
|
347
|
+
"updates.stream-raw-passthrough",
|
|
348
|
+
),
|
|
349
|
+
coverage_note=(
|
|
350
|
+
"the catalogue half: the type exists and is selectable. Receiving one "
|
|
351
|
+
"is `watch`, which owns those ids fully."
|
|
352
|
+
),
|
|
353
|
+
tags=frozenset({"agent-safe"}),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# events get
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class EventGetReq(Request):
|
|
363
|
+
type: Annotated[
|
|
364
|
+
str,
|
|
365
|
+
arg(0, metavar="TYPE", help="An event type name, or a `raw:Constructor`."),
|
|
366
|
+
]
|
|
367
|
+
example: Annotated[
|
|
368
|
+
bool, opt("--example/--no-example", help="Include a synthetic example envelope.")
|
|
369
|
+
] = True
|
|
370
|
+
json_schema: Annotated[
|
|
371
|
+
bool, opt("--json-schema", help="Emit the payload as a JSON Schema object.")
|
|
372
|
+
] = False
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _json_schema(payload: dict[str, str]) -> dict[str, Any]:
|
|
376
|
+
"""The payload table as draft 2020-12.
|
|
377
|
+
|
|
378
|
+
Deliberately loose: most payloads are the update's own fields made
|
|
379
|
+
JSON-safe, and a schema that claimed to be exhaustive about them would be
|
|
380
|
+
a promise the taxonomy does not make.
|
|
381
|
+
"""
|
|
382
|
+
properties: dict[str, Any] = {}
|
|
383
|
+
for name, described in payload.items():
|
|
384
|
+
if name in ("…", "_"):
|
|
385
|
+
continue
|
|
386
|
+
base = described.split("—")[0].strip()
|
|
387
|
+
kind = {
|
|
388
|
+
"int": "integer",
|
|
389
|
+
"str": "string",
|
|
390
|
+
"bool": "boolean",
|
|
391
|
+
"object": "object",
|
|
392
|
+
"true": "boolean",
|
|
393
|
+
"false": "boolean",
|
|
394
|
+
}.get(base.replace(" | null", "").strip(), "string")
|
|
395
|
+
if base.startswith("list["):
|
|
396
|
+
kind = "array"
|
|
397
|
+
properties[name] = {"type": kind, "description": described}
|
|
398
|
+
return {
|
|
399
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
400
|
+
"type": "object",
|
|
401
|
+
"properties": properties,
|
|
402
|
+
"additionalProperties": True,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
async def event_get(ctx: OpContext, req: EventGetReq) -> EventTypeDetail:
|
|
407
|
+
"""One event type in full: sources, box, payload, and an example."""
|
|
408
|
+
name = req.type.strip().lower()
|
|
409
|
+
if name.startswith("raw:"):
|
|
410
|
+
mapped = eventtypes.type_for_constructor(req.type[4:])
|
|
411
|
+
if mapped is None:
|
|
412
|
+
raise NotFoundError(f"no event type is produced by {req.type[4:]!r}")
|
|
413
|
+
name = mapped
|
|
414
|
+
for legacy, expansion in eventtypes.ALIASES.items():
|
|
415
|
+
if name == legacy and len(expansion) == 1:
|
|
416
|
+
name = expansion[0]
|
|
417
|
+
spec = eventtypes.TYPES.get(name)
|
|
418
|
+
if spec is None:
|
|
419
|
+
raise NotFoundError(f"unknown event type {req.type!r}; run `tlgr events list`")
|
|
420
|
+
|
|
421
|
+
row = _row(name, spec, eventtypes.constructors_for(name))
|
|
422
|
+
detail = EventTypeDetail(
|
|
423
|
+
type=row.type,
|
|
424
|
+
group=row.group,
|
|
425
|
+
summary=row.summary,
|
|
426
|
+
sources=row.sources,
|
|
427
|
+
telethon=row.telethon,
|
|
428
|
+
box=row.box,
|
|
429
|
+
bot_only=row.bot_only,
|
|
430
|
+
since_layer=row.since_layer,
|
|
431
|
+
available=row.available,
|
|
432
|
+
derived=row.derived,
|
|
433
|
+
payload=dict(spec.payload),
|
|
434
|
+
filters=["account", "chat", "sender", "type", "self_origin"],
|
|
435
|
+
)
|
|
436
|
+
if req.json_schema:
|
|
437
|
+
detail.json_schema = _json_schema(spec.payload)
|
|
438
|
+
if req.example:
|
|
439
|
+
detail.example = EventEnvelope(
|
|
440
|
+
seq=1,
|
|
441
|
+
ts="2026-09-03T09:14:07Z",
|
|
442
|
+
account=ctx.account or "work",
|
|
443
|
+
type=name,
|
|
444
|
+
payload={key: None for key in spec.payload if key not in ("…",)},
|
|
445
|
+
chat_id=-1001234567890,
|
|
446
|
+
)
|
|
447
|
+
return detail
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
SPEC_EVENT_GET = OperationSpec(
|
|
451
|
+
id="events.get",
|
|
452
|
+
request=EventGetReq,
|
|
453
|
+
response=EventTypeDetail,
|
|
454
|
+
impl=event_get,
|
|
455
|
+
summary="Show one event type: payload, source constructors, sequence box, example",
|
|
456
|
+
description=(
|
|
457
|
+
"`box` is the field to read first: it says which sequence orders the "
|
|
458
|
+
"event, and therefore whether a gap in it is recoverable with `sync "
|
|
459
|
+
"difference` or simply lost."
|
|
460
|
+
),
|
|
461
|
+
needs_account=False,
|
|
462
|
+
needs_auth=False,
|
|
463
|
+
needs_client=False,
|
|
464
|
+
surface=Surface.LOCAL,
|
|
465
|
+
idempotent=True,
|
|
466
|
+
rate_class="local",
|
|
467
|
+
timeout_s=15,
|
|
468
|
+
example={
|
|
469
|
+
"type": "message_new",
|
|
470
|
+
"group": "message",
|
|
471
|
+
"summary": "A message arrived in any chat the account can see",
|
|
472
|
+
"sources": ["UpdateNewMessage"],
|
|
473
|
+
"box": "pts",
|
|
474
|
+
"payload": {"message": "Message"},
|
|
475
|
+
},
|
|
476
|
+
example_args="events get message_new",
|
|
477
|
+
covers=(
|
|
478
|
+
"updates.event-attach-menu-bots",
|
|
479
|
+
"updates.event-bot-business",
|
|
480
|
+
"updates.event-bot-commands",
|
|
481
|
+
"updates.event-bot-guest-chat-query",
|
|
482
|
+
"updates.event-bot-menu-button",
|
|
483
|
+
"updates.event-bot-payments",
|
|
484
|
+
"updates.event-bot-stopped",
|
|
485
|
+
"updates.event-channel-available-messages",
|
|
486
|
+
"updates.event-channel-participant",
|
|
487
|
+
"updates.event-chat-boost",
|
|
488
|
+
"updates.event-chat-refetch",
|
|
489
|
+
"updates.event-contacts-reset",
|
|
490
|
+
"updates.event-default-banned-rights",
|
|
491
|
+
"updates.event-dialog-pinned",
|
|
492
|
+
"updates.event-draft",
|
|
493
|
+
"updates.event-encrypted-chats",
|
|
494
|
+
"updates.event-extended-media",
|
|
495
|
+
"updates.event-geo-live-viewed",
|
|
496
|
+
"updates.event-history-ttl",
|
|
497
|
+
"updates.event-join-requests",
|
|
498
|
+
"updates.event-managed-bot",
|
|
499
|
+
"updates.event-message-edited",
|
|
500
|
+
"updates.event-new-bot-connection",
|
|
501
|
+
"updates.event-notify-settings",
|
|
502
|
+
"updates.event-peer-located",
|
|
503
|
+
"updates.event-phone-call",
|
|
504
|
+
"updates.event-poll",
|
|
505
|
+
"updates.event-quick-replies",
|
|
506
|
+
"updates.event-read-discussion",
|
|
507
|
+
"updates.event-read-outbox",
|
|
508
|
+
"updates.event-saved-gifs",
|
|
509
|
+
"updates.event-scheduled-new",
|
|
510
|
+
"updates.event-service-notification",
|
|
511
|
+
"updates.event-stars-revenue",
|
|
512
|
+
"updates.event-story-id",
|
|
513
|
+
"updates.event-story-read",
|
|
514
|
+
"updates.event-user-emoji-status",
|
|
515
|
+
"updates.event-user-refetch",
|
|
516
|
+
"updates.event-web-browser-settings",
|
|
517
|
+
),
|
|
518
|
+
covers_partial=("updates.stream-event-types", "updates.sync-min-constructors"),
|
|
519
|
+
coverage_note=(
|
|
520
|
+
"documents the type and its payload; receiving one is `watch`, and "
|
|
521
|
+
"min-constructor hydration happens on the bus."
|
|
522
|
+
),
|
|
523
|
+
empty_exit=EXIT_EMPTY,
|
|
524
|
+
tags=frozenset({"agent-safe"}),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
# events decode
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
#: `loc_key` prefixes Telegram uses in a push payload → tlgr event types. The
|
|
533
|
+
#: two that matter are not messages: `DC_UPDATE` and `SESSION_REVOKE` are
|
|
534
|
+
#: security events, and the second one means this session has been killed.
|
|
535
|
+
_PUSH_KEYS: dict[str, str] = {
|
|
536
|
+
"DC_UPDATE": "sync_dc_options",
|
|
537
|
+
"SESSION_REVOKE": "account_session_revoked",
|
|
538
|
+
"AUTH_REGION": "account_new_authorization",
|
|
539
|
+
"AUTH_UNKNOWN": "account_new_authorization",
|
|
540
|
+
"MESSAGE": "message_new",
|
|
541
|
+
"CHAT_MESSAGE": "message_new",
|
|
542
|
+
"CHANNEL_MESSAGE": "message_new",
|
|
543
|
+
"ENCRYPTED_MESSAGE": "secret_message",
|
|
544
|
+
"PHONE_CALL": "call_phone",
|
|
545
|
+
"READ_HISTORY": "read_inbox",
|
|
546
|
+
"MESSAGE_DELETED": "message_deleted",
|
|
547
|
+
"REACT": "message_reactions",
|
|
548
|
+
"GEO_LIVE_PENDING": "message_geo_live_viewed",
|
|
549
|
+
"STORY": "story_new",
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _push_event_type(loc_key: str) -> str:
|
|
554
|
+
key = (loc_key or "").upper()
|
|
555
|
+
for prefix, mapped in _PUSH_KEYS.items():
|
|
556
|
+
if key == prefix or key.startswith(f"{prefix}_"):
|
|
557
|
+
return mapped
|
|
558
|
+
return "account_service_notification"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _read_input(source: str | None) -> str:
|
|
562
|
+
if source in (None, "", "-"):
|
|
563
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
564
|
+
raise UsageError("no input was given and stdin is a terminal", field="input")
|
|
565
|
+
return sys.stdin.read()
|
|
566
|
+
try:
|
|
567
|
+
with open(str(source), encoding="utf-8") as handle:
|
|
568
|
+
return handle.read()
|
|
569
|
+
except OSError as exc:
|
|
570
|
+
raise UsageError(f"{source}: {exc.strerror or exc}", field="input") from exc
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _decrypt_push(blob: bytes, auth_key: bytes) -> dict[str, Any]:
|
|
574
|
+
"""MTProto 2.0 decryption of an encrypted push payload.
|
|
575
|
+
|
|
576
|
+
Telegram encrypts a push notification with the *push* auth key, using the
|
|
577
|
+
same key derivation as a message: `msg_key` first, then AES-256-IGE. The
|
|
578
|
+
direction byte is not documented for push, so both are tried and the one
|
|
579
|
+
whose recomputed `msg_key` matches is the right one — which also means a
|
|
580
|
+
wrong key produces "could not be decrypted" rather than plausible rubbish.
|
|
581
|
+
"""
|
|
582
|
+
import hashlib
|
|
583
|
+
|
|
584
|
+
from telethon.crypto import AES
|
|
585
|
+
|
|
586
|
+
if len(auth_key) != 256:
|
|
587
|
+
raise UsageError(f"a push auth key is 256 bytes; this one is {len(auth_key)}", field="key")
|
|
588
|
+
if len(blob) < 16 or (len(blob) - 16) % 16:
|
|
589
|
+
raise UsageError("the push payload is not a multiple of the AES block size", field="input")
|
|
590
|
+
msg_key, body = blob[:16], blob[16:]
|
|
591
|
+
|
|
592
|
+
for offset in (0, 8):
|
|
593
|
+
sha256_a = hashlib.sha256(msg_key + auth_key[offset : offset + 36]).digest()
|
|
594
|
+
sha256_b = hashlib.sha256(auth_key[offset + 40 : offset + 76] + msg_key).digest()
|
|
595
|
+
key = sha256_a[:8] + sha256_b[8:24] + sha256_a[24:32]
|
|
596
|
+
iv = sha256_b[:8] + sha256_a[8:24] + sha256_b[24:32]
|
|
597
|
+
plain = AES.decrypt_ige(body, key, iv)
|
|
598
|
+
computed = hashlib.sha256(auth_key[88 + offset : 88 + offset + 32] + plain).digest()[8:24]
|
|
599
|
+
if computed != msg_key:
|
|
600
|
+
continue
|
|
601
|
+
length = int.from_bytes(plain[:4], "little")
|
|
602
|
+
payload = plain[4 : 4 + length]
|
|
603
|
+
try:
|
|
604
|
+
decoded = json.loads(payload.decode("utf-8"))
|
|
605
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
606
|
+
raise IndeterminateError(
|
|
607
|
+
"the payload decrypted but is not the JSON push body; "
|
|
608
|
+
"the key may be for a different session"
|
|
609
|
+
) from exc
|
|
610
|
+
if not isinstance(decoded, dict):
|
|
611
|
+
raise IndeterminateError("the decrypted push payload is not an object")
|
|
612
|
+
return decoded
|
|
613
|
+
|
|
614
|
+
raise IndeterminateError(
|
|
615
|
+
"the push payload could not be decrypted with this key — it belongs to "
|
|
616
|
+
"another session, or the payload is truncated"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class EventDecodeReq(Request):
|
|
621
|
+
input: Annotated[
|
|
622
|
+
str | None,
|
|
623
|
+
arg(
|
|
624
|
+
0,
|
|
625
|
+
metavar="INPUT",
|
|
626
|
+
required=False,
|
|
627
|
+
help="A file, or '-' for stdin: a JSON TL object, or a base64 push payload.",
|
|
628
|
+
),
|
|
629
|
+
] = None
|
|
630
|
+
push: Annotated[
|
|
631
|
+
bool, opt("--push", help="The input is a Telegram push-notification payload.")
|
|
632
|
+
] = False
|
|
633
|
+
key: Annotated[
|
|
634
|
+
str | None,
|
|
635
|
+
opt(
|
|
636
|
+
"--key",
|
|
637
|
+
secret=True,
|
|
638
|
+
envvar="TLGR_PUSH_KEY",
|
|
639
|
+
help="The base64 push auth key used to decrypt the payload.",
|
|
640
|
+
),
|
|
641
|
+
] = None
|
|
642
|
+
raw: Annotated[
|
|
643
|
+
bool, opt("--raw", help="Print the decoded TL object instead of the tlgr envelope.")
|
|
644
|
+
] = False
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
async def event_decode(ctx: OpContext, req: EventDecodeReq) -> DecodedEvent:
|
|
648
|
+
"""Turn a raw update or a push payload into the envelope tlgr would emit.
|
|
649
|
+
|
|
650
|
+
Offline and account-free on purpose. tlgr does not register for push —
|
|
651
|
+
the daemon holds a socket, so it has no need of one — but a phone-relay
|
|
652
|
+
setup does, and `DC_UPDATE`/`SESSION_REVOKE` are security events somebody
|
|
653
|
+
has to be able to read.
|
|
654
|
+
"""
|
|
655
|
+
text = _read_input(req.input)
|
|
656
|
+
|
|
657
|
+
if req.push:
|
|
658
|
+
try:
|
|
659
|
+
blob = base64.b64decode(text.strip(), validate=False)
|
|
660
|
+
except (binascii.Error, ValueError) as exc:
|
|
661
|
+
raise UsageError("the push payload is not base64", field="input") from exc
|
|
662
|
+
try:
|
|
663
|
+
body = json.loads(blob.decode("utf-8"))
|
|
664
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
665
|
+
# The CLI has already read the secret out of the environment, a
|
|
666
|
+
# file or stdin; it never travels as argv (STYLE §3).
|
|
667
|
+
if not req.key:
|
|
668
|
+
raise UsageError(
|
|
669
|
+
"this push payload is encrypted; supply the push auth key with "
|
|
670
|
+
"--key-env, --key-file or --key-stdin",
|
|
671
|
+
field="key",
|
|
672
|
+
) from None
|
|
673
|
+
body = _decrypt_push(blob, base64.b64decode(req.key))
|
|
674
|
+
if not isinstance(body, dict):
|
|
675
|
+
raise UsageError("the push payload is not a JSON object", field="input")
|
|
676
|
+
inner = body.get("data")
|
|
677
|
+
data: dict[str, Any] = inner if isinstance(inner, dict) else body
|
|
678
|
+
loc_key = str(data.get("loc_key", ""))
|
|
679
|
+
chat = data.get("chat_id") or data.get("channel_id") or data.get("from_id")
|
|
680
|
+
chat_id = (
|
|
681
|
+
int(chat) if isinstance(chat, (int, str)) and str(chat).lstrip("-").isdigit() else None
|
|
682
|
+
)
|
|
683
|
+
return DecodedEvent(
|
|
684
|
+
event=_push_event_type(loc_key),
|
|
685
|
+
account=ctx.account,
|
|
686
|
+
chat_id=chat_id,
|
|
687
|
+
sender_id=None,
|
|
688
|
+
data=dict(data),
|
|
689
|
+
raw=dict(body) if req.raw else None,
|
|
690
|
+
push=True,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
loaded = json.loads(text)
|
|
695
|
+
except json.JSONDecodeError as exc:
|
|
696
|
+
raise UsageError(f"the input is not JSON: {exc}", field="input") from exc
|
|
697
|
+
if not isinstance(loaded, dict):
|
|
698
|
+
raise UsageError("a TL update is a JSON object", field="input")
|
|
699
|
+
|
|
700
|
+
constructor = str(loaded.get("_") or loaded.get("constructor") or "")
|
|
701
|
+
if not constructor:
|
|
702
|
+
raise UsageError(
|
|
703
|
+
"the object has no `_` naming its TL constructor; "
|
|
704
|
+
"`tlgr watch --with-raw` emits that form",
|
|
705
|
+
field="input",
|
|
706
|
+
)
|
|
707
|
+
event_type = eventtypes.type_for_constructor(constructor)
|
|
708
|
+
if event_type is None:
|
|
709
|
+
reason = eventtypes.INTERNAL.get(constructor)
|
|
710
|
+
if reason:
|
|
711
|
+
raise NotSupportedError(f"{constructor} carries no event: {reason}")
|
|
712
|
+
raise NotFoundError(
|
|
713
|
+
f"{constructor} is not an update tlgr knows; run `tlgr events list --raw`"
|
|
714
|
+
)
|
|
715
|
+
chat = loaded.get("chat_id")
|
|
716
|
+
return DecodedEvent(
|
|
717
|
+
event=event_type,
|
|
718
|
+
account=ctx.account,
|
|
719
|
+
chat_id=int(chat) if isinstance(chat, int) else None,
|
|
720
|
+
data={key: value for key, value in loaded.items() if key != "_"},
|
|
721
|
+
raw=dict(loaded) if req.raw else None,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
SPEC_EVENT_DECODE = OperationSpec(
|
|
726
|
+
id="events.decode",
|
|
727
|
+
request=EventDecodeReq,
|
|
728
|
+
response=DecodedEvent,
|
|
729
|
+
impl=event_decode,
|
|
730
|
+
summary="Decode a raw TL update or an encrypted push payload into an event",
|
|
731
|
+
description=(
|
|
732
|
+
"Offline; no account needed. tlgr does not register for push "
|
|
733
|
+
"notifications — the daemon holds a socket — but a phone-relay setup "
|
|
734
|
+
"does, and `DC_UPDATE` and `SESSION_REVOKE` are security events: the "
|
|
735
|
+
"second one means this session has been terminated."
|
|
736
|
+
),
|
|
737
|
+
needs_account=False,
|
|
738
|
+
needs_auth=False,
|
|
739
|
+
needs_client=False,
|
|
740
|
+
surface=Surface.LOCAL,
|
|
741
|
+
idempotent=True,
|
|
742
|
+
rate_class="local",
|
|
743
|
+
timeout_s=15,
|
|
744
|
+
example={
|
|
745
|
+
"event": "message_new",
|
|
746
|
+
"data": {"pts": 4213, "pts_count": 1},
|
|
747
|
+
},
|
|
748
|
+
example_args="events decode - --push",
|
|
749
|
+
covers=("updates.push-payload-decrypt",),
|
|
750
|
+
covers_partial=("updates.stream-raw-passthrough",),
|
|
751
|
+
coverage_note="decodes one update offline; the live passthrough is `watch --raw`.",
|
|
752
|
+
tags=frozenset({"agent-safe"}),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# ---------------------------------------------------------------------------
|
|
757
|
+
# The shared selection used by watch and replay
|
|
758
|
+
# ---------------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
class _Selection:
|
|
762
|
+
"""The filter a watcher or a replay applies, resolved once."""
|
|
763
|
+
|
|
764
|
+
__slots__ = ("chats", "senders", "topic", "types")
|
|
765
|
+
|
|
766
|
+
def __init__(
|
|
767
|
+
self,
|
|
768
|
+
types: frozenset[str],
|
|
769
|
+
chats: list[int],
|
|
770
|
+
senders: list[int],
|
|
771
|
+
topic: int | None,
|
|
772
|
+
) -> None:
|
|
773
|
+
self.types = types
|
|
774
|
+
self.chats = set(chats)
|
|
775
|
+
self.senders = set(senders)
|
|
776
|
+
self.topic = topic
|
|
777
|
+
|
|
778
|
+
def wants(self, event: EventEnvelope) -> bool:
|
|
779
|
+
if self.types and event.type not in self.types:
|
|
780
|
+
return False
|
|
781
|
+
if self.chats and (event.chat_id is None or event.chat_id not in self.chats):
|
|
782
|
+
return False
|
|
783
|
+
if self.senders and (event.sender_id is None or event.sender_id not in self.senders):
|
|
784
|
+
return False
|
|
785
|
+
return not (self.topic is not None and event.payload.get("top_msg_id") != self.topic)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
async def _selection(
|
|
789
|
+
ctx: OpContext,
|
|
790
|
+
*,
|
|
791
|
+
events: str,
|
|
792
|
+
exclude: str | None,
|
|
793
|
+
chats: tuple[PeerRef, ...],
|
|
794
|
+
senders: tuple[PeerRef, ...],
|
|
795
|
+
topic: int | None,
|
|
796
|
+
) -> _Selection:
|
|
797
|
+
wanted = eventtypes.resolve_selectors(events)
|
|
798
|
+
if exclude:
|
|
799
|
+
wanted = wanted - eventtypes.resolve_selectors(exclude, allow_all=False)
|
|
800
|
+
if not wanted:
|
|
801
|
+
raise UsageError(
|
|
802
|
+
"--events and --exclude together select nothing; a watch that "
|
|
803
|
+
"matches nothing is indistinguishable from a broken daemon",
|
|
804
|
+
field="events",
|
|
805
|
+
)
|
|
806
|
+
return _Selection(
|
|
807
|
+
wanted,
|
|
808
|
+
await _chat_ids(ctx, chats),
|
|
809
|
+
await _chat_ids(ctx, senders),
|
|
810
|
+
topic,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# ---------------------------------------------------------------------------
|
|
815
|
+
# watch
|
|
816
|
+
# ---------------------------------------------------------------------------
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
class WatchReq(Request):
|
|
820
|
+
events: Annotated[
|
|
821
|
+
str,
|
|
822
|
+
opt(
|
|
823
|
+
"--events",
|
|
824
|
+
metavar="TYPES",
|
|
825
|
+
help=(
|
|
826
|
+
"Types, groups, `raw:Constructor` names or `all`, "
|
|
827
|
+
"comma-separated. See `tlgr events list`."
|
|
828
|
+
),
|
|
829
|
+
),
|
|
830
|
+
] = "new_message"
|
|
831
|
+
exclude: Annotated[
|
|
832
|
+
str | None,
|
|
833
|
+
opt("--exclude", metavar="TYPES", help="Subtract these after --events is applied."),
|
|
834
|
+
] = None
|
|
835
|
+
chat: Annotated[
|
|
836
|
+
list[PeerRef],
|
|
837
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Only events about this chat."),
|
|
838
|
+
] = []
|
|
839
|
+
sender: Annotated[
|
|
840
|
+
list[PeerRef],
|
|
841
|
+
opt("--sender", metavar="USER", kind="user", help="Only events from this user."),
|
|
842
|
+
] = []
|
|
843
|
+
topic: Annotated[
|
|
844
|
+
int | None, opt("--topic", metavar="ID", help="Only events inside this forum topic.")
|
|
845
|
+
] = None
|
|
846
|
+
since: Annotated[
|
|
847
|
+
int | None,
|
|
848
|
+
opt("--since", metavar="SEQ", help="Replay from this seq (exclusive) before following."),
|
|
849
|
+
] = None
|
|
850
|
+
follow: Annotated[
|
|
851
|
+
bool, opt("--follow/--no-follow", help="Keep streaming after the replay is drained.")
|
|
852
|
+
] = True
|
|
853
|
+
max_events: Annotated[
|
|
854
|
+
int | None,
|
|
855
|
+
opt("--max-events", metavar="N", help="Stop after this many events."),
|
|
856
|
+
] = None
|
|
857
|
+
raw: Annotated[
|
|
858
|
+
bool, opt("--raw", help="Emit only the raw TL update instead of the envelope.")
|
|
859
|
+
] = False
|
|
860
|
+
with_raw: Annotated[
|
|
861
|
+
bool, opt("--with-raw", help="Include the raw TL update beside the payload.")
|
|
862
|
+
] = False
|
|
863
|
+
heartbeat: Annotated[
|
|
864
|
+
int,
|
|
865
|
+
opt("--heartbeat", metavar="SECONDS", ge=0, help="Idle keepalive; 0 disables."),
|
|
866
|
+
] = 15
|
|
867
|
+
on_lag: Annotated[
|
|
868
|
+
str,
|
|
869
|
+
choice(
|
|
870
|
+
"drop",
|
|
871
|
+
"block",
|
|
872
|
+
"fail",
|
|
873
|
+
help=(
|
|
874
|
+
"Falling behind: drop the oldest and report it, take a much "
|
|
875
|
+
"larger queue, or stop with exit 13."
|
|
876
|
+
),
|
|
877
|
+
),
|
|
878
|
+
] = "drop"
|
|
879
|
+
follow_for: Annotated[
|
|
880
|
+
int,
|
|
881
|
+
opt("--follow-for", metavar="SECONDS", ge=1, le=86400, help="Close the stream after this."),
|
|
882
|
+
] = 3600
|
|
883
|
+
print_cursor: Annotated[
|
|
884
|
+
bool, opt("--print-cursor", help="Emit a final frame carrying the resume seq.")
|
|
885
|
+
] = False
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
async def watch(ctx: OpContext, req: WatchReq) -> AsyncIterator[dict[str, Any]]:
|
|
889
|
+
"""Follow the bus, as NDJSON frames.
|
|
890
|
+
|
|
891
|
+
Push, never polling: the daemon already holds the update socket, so a
|
|
892
|
+
watcher is a bounded queue on the bus rather than v1's two-second
|
|
893
|
+
`chat list` + `message list` loop, which cost thirty round trips a minute
|
|
894
|
+
and could only ever report new messages.
|
|
895
|
+
|
|
896
|
+
`--account all` multiplexes every connected account; each frame carries
|
|
897
|
+
its own `account`, and `seq` is per account because update state is.
|
|
898
|
+
"""
|
|
899
|
+
bus = _bus(ctx)
|
|
900
|
+
accounts = _accounts(ctx)
|
|
901
|
+
selection = await _selection(
|
|
902
|
+
ctx,
|
|
903
|
+
events=req.events,
|
|
904
|
+
exclude=req.exclude,
|
|
905
|
+
chats=tuple(req.chat),
|
|
906
|
+
senders=tuple(req.sender),
|
|
907
|
+
topic=req.topic,
|
|
908
|
+
)
|
|
909
|
+
watching = accounts or [ctx.account]
|
|
910
|
+
|
|
911
|
+
yield {
|
|
912
|
+
"type": "watching",
|
|
913
|
+
"accounts": watching,
|
|
914
|
+
"events": sorted(selection.types),
|
|
915
|
+
"chats": sorted(selection.chats),
|
|
916
|
+
"latest_seq": {alias: bus.latest_seq(alias) for alias in watching},
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
subscribers = [
|
|
920
|
+
bus.subscribe(
|
|
921
|
+
alias,
|
|
922
|
+
types=selection.types,
|
|
923
|
+
maxsize=8192 if req.on_lag == "block" else 2048,
|
|
924
|
+
want_raw=req.raw or req.with_raw,
|
|
925
|
+
)
|
|
926
|
+
for alias in watching
|
|
927
|
+
]
|
|
928
|
+
delivered = 0
|
|
929
|
+
last_seq: dict[str, int] = {}
|
|
930
|
+
reason = "closed"
|
|
931
|
+
try:
|
|
932
|
+
for alias in watching:
|
|
933
|
+
if req.since is None:
|
|
934
|
+
continue
|
|
935
|
+
replayed, gap = bus.replay(alias, req.since)
|
|
936
|
+
if gap is not None:
|
|
937
|
+
yield {**gap, "account": alias}
|
|
938
|
+
for event in replayed:
|
|
939
|
+
if not selection.wants(event):
|
|
940
|
+
continue
|
|
941
|
+
delivered += 1
|
|
942
|
+
last_seq[alias] = event.seq
|
|
943
|
+
yield _frame(event, req)
|
|
944
|
+
if req.max_events and delivered >= req.max_events:
|
|
945
|
+
reason = "limit"
|
|
946
|
+
break
|
|
947
|
+
if reason == "limit":
|
|
948
|
+
break
|
|
949
|
+
|
|
950
|
+
if req.follow and reason != "limit":
|
|
951
|
+
deadline = time.monotonic() + req.follow_for
|
|
952
|
+
heartbeat = float(req.heartbeat) if req.heartbeat else None
|
|
953
|
+
while reason == "closed":
|
|
954
|
+
remaining = deadline - time.monotonic()
|
|
955
|
+
if remaining <= 0:
|
|
956
|
+
reason = "timeout"
|
|
957
|
+
break
|
|
958
|
+
wait = min(heartbeat, remaining) if heartbeat else remaining
|
|
959
|
+
pending = [asyncio.ensure_future(sub.queue.get()) for sub in subscribers]
|
|
960
|
+
done, _ = await asyncio.wait(
|
|
961
|
+
pending, timeout=wait, return_when=asyncio.FIRST_COMPLETED
|
|
962
|
+
)
|
|
963
|
+
for task in pending:
|
|
964
|
+
if task not in done:
|
|
965
|
+
task.cancel()
|
|
966
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
967
|
+
await task
|
|
968
|
+
if not done:
|
|
969
|
+
if heartbeat:
|
|
970
|
+
yield {"type": "heartbeat", "ts": _now()}
|
|
971
|
+
continue
|
|
972
|
+
for task in done:
|
|
973
|
+
event = task.result()
|
|
974
|
+
lag = _take_lag(subscribers, event.account)
|
|
975
|
+
if lag:
|
|
976
|
+
if req.on_lag == "fail":
|
|
977
|
+
raise IndeterminateError(
|
|
978
|
+
f"this watcher fell behind and lost {lag} events; "
|
|
979
|
+
"resume from the last seq you saw with --since"
|
|
980
|
+
)
|
|
981
|
+
yield {"type": "lag", "dropped": lag, "account": event.account}
|
|
982
|
+
if not selection.wants(event):
|
|
983
|
+
continue
|
|
984
|
+
delivered += 1
|
|
985
|
+
last_seq[event.account] = event.seq
|
|
986
|
+
yield _frame(event, req)
|
|
987
|
+
if req.max_events and delivered >= req.max_events:
|
|
988
|
+
reason = "limit"
|
|
989
|
+
break
|
|
990
|
+
finally:
|
|
991
|
+
for subscriber in subscribers:
|
|
992
|
+
bus.unsubscribe(subscriber)
|
|
993
|
+
|
|
994
|
+
# Outside the `finally`, deliberately: yielding while an async generator
|
|
995
|
+
# is being closed raises, and the resume cursor is worth having only when
|
|
996
|
+
# the stream ended on its own terms.
|
|
997
|
+
if req.print_cursor:
|
|
998
|
+
yield {"type": "cursor", "latest_seq": last_seq, "reason": reason}
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _take_lag(subscribers: list[Any], account: str) -> int:
|
|
1002
|
+
for subscriber in subscribers:
|
|
1003
|
+
if subscriber.account == account:
|
|
1004
|
+
return int(subscriber.take_lag())
|
|
1005
|
+
return 0
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _now() -> str:
|
|
1009
|
+
from datetime import datetime, timezone
|
|
1010
|
+
|
|
1011
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _frame(event: EventEnvelope, req: WatchReq) -> dict[str, Any]:
|
|
1015
|
+
if req.raw:
|
|
1016
|
+
return {"type": event.type, "seq": event.seq, "account": event.account, "raw": event.raw}
|
|
1017
|
+
frame = to_builtins(event)
|
|
1018
|
+
if not isinstance(frame, dict): # pragma: no cover - EventEnvelope is a Struct
|
|
1019
|
+
return {"type": event.type}
|
|
1020
|
+
if not req.with_raw:
|
|
1021
|
+
frame.pop("raw", None)
|
|
1022
|
+
return frame
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
SPEC_WATCH = OperationSpec(
|
|
1026
|
+
id="events.watch",
|
|
1027
|
+
request=WatchReq,
|
|
1028
|
+
response=None,
|
|
1029
|
+
impl=watch,
|
|
1030
|
+
summary="Stream live events from the daemon as newline-delimited JSON",
|
|
1031
|
+
description=(
|
|
1032
|
+
"Push-driven from the daemon's event bus, not polled: v1 asked for "
|
|
1033
|
+
"`chat list` and then `message list` every two seconds and could only "
|
|
1034
|
+
"report new messages. Every type in `tlgr events list` is selectable, "
|
|
1035
|
+
"`--since <seq>` replays the ring buffer first (with a `gap` frame "
|
|
1036
|
+
"when it cannot reach that far back), and a watcher that falls behind "
|
|
1037
|
+
"gets a `lag` frame rather than silence."
|
|
1038
|
+
),
|
|
1039
|
+
aliases=("events.tail",),
|
|
1040
|
+
legacy_paths=("watch",),
|
|
1041
|
+
stream=True,
|
|
1042
|
+
needs_client=False,
|
|
1043
|
+
surface=Surface.DAEMON,
|
|
1044
|
+
rate_class="local",
|
|
1045
|
+
timeout_s=900,
|
|
1046
|
+
example={"type": "message_new", "seq": 91824},
|
|
1047
|
+
example_args="watch --events message_new,read_inbox --chat @alice",
|
|
1048
|
+
covers=(
|
|
1049
|
+
"bots.bot-side-update-stream",
|
|
1050
|
+
"bots.bot-subscription-update",
|
|
1051
|
+
"bots.ephemeral-message-view",
|
|
1052
|
+
"contacts-users.user-status-watch",
|
|
1053
|
+
"dialogs.typing-watch",
|
|
1054
|
+
"dialogs.watch-dialog-events",
|
|
1055
|
+
"giveaway.prize-stars",
|
|
1056
|
+
"location.proximity-alert-event",
|
|
1057
|
+
"location.viewed-receipt",
|
|
1058
|
+
"messages-core.message-watch-events",
|
|
1059
|
+
"updates.event-message-id-map",
|
|
1060
|
+
"updates.event-new-channel-message",
|
|
1061
|
+
"updates.event-paid-reaction-privacy",
|
|
1062
|
+
"updates.event-peer-settings",
|
|
1063
|
+
"updates.event-pinned-forum-topics",
|
|
1064
|
+
"updates.event-privacy",
|
|
1065
|
+
"updates.event-reactions",
|
|
1066
|
+
"updates.event-read-inbox",
|
|
1067
|
+
"updates.event-recent-reactions",
|
|
1068
|
+
"updates.event-saved-ringtones",
|
|
1069
|
+
"updates.event-sent-phone-code",
|
|
1070
|
+
"updates.event-star-gift-auction",
|
|
1071
|
+
"updates.event-stickers-changed",
|
|
1072
|
+
"updates.event-story-new",
|
|
1073
|
+
"updates.event-transcription",
|
|
1074
|
+
"updates.event-user-name",
|
|
1075
|
+
"updates.event-user-status",
|
|
1076
|
+
"updates.event-webpage",
|
|
1077
|
+
"updates.stream-event-types",
|
|
1078
|
+
"updates.stream-raw-passthrough",
|
|
1079
|
+
"updates.stream-watch-ndjson",
|
|
1080
|
+
"updates.sync-min-constructors",
|
|
1081
|
+
),
|
|
1082
|
+
covers_partial=(
|
|
1083
|
+
"updates.event-ai-compose-tones",
|
|
1084
|
+
"updates.event-attach-menu-bots",
|
|
1085
|
+
"updates.event-autosave-settings",
|
|
1086
|
+
"updates.event-bot-business",
|
|
1087
|
+
"updates.event-bot-callback-query",
|
|
1088
|
+
"updates.event-bot-commands",
|
|
1089
|
+
"updates.event-bot-ephemeral-callback",
|
|
1090
|
+
"updates.event-bot-guest-chat-query",
|
|
1091
|
+
"updates.event-bot-inline-query",
|
|
1092
|
+
"updates.event-bot-menu-button",
|
|
1093
|
+
"updates.event-bot-message-reactions",
|
|
1094
|
+
"updates.event-bot-payments",
|
|
1095
|
+
"updates.event-bot-stars-subscription",
|
|
1096
|
+
"updates.event-bot-stopped",
|
|
1097
|
+
"updates.event-bot-webhook-json",
|
|
1098
|
+
"updates.event-channel-available-messages",
|
|
1099
|
+
"updates.event-channel-forwards",
|
|
1100
|
+
"updates.event-channel-participant",
|
|
1101
|
+
"updates.event-channel-views",
|
|
1102
|
+
"updates.event-chat-boost",
|
|
1103
|
+
"updates.event-chat-participants",
|
|
1104
|
+
"updates.event-chat-refetch",
|
|
1105
|
+
"updates.event-config-changed",
|
|
1106
|
+
"updates.event-contacts-reset",
|
|
1107
|
+
"updates.event-dc-options",
|
|
1108
|
+
"updates.event-default-banned-rights",
|
|
1109
|
+
"updates.event-dialog-filters",
|
|
1110
|
+
"updates.event-dialog-pinned",
|
|
1111
|
+
"updates.event-dialog-unread-mark",
|
|
1112
|
+
"updates.event-draft",
|
|
1113
|
+
"updates.event-emoji-game-info",
|
|
1114
|
+
"updates.event-encrypted-chats",
|
|
1115
|
+
"updates.event-ephemeral-messages",
|
|
1116
|
+
"updates.event-extended-media",
|
|
1117
|
+
"updates.event-folder-peers",
|
|
1118
|
+
"updates.event-geo-live-viewed",
|
|
1119
|
+
"updates.event-group-call",
|
|
1120
|
+
"updates.event-history-ttl",
|
|
1121
|
+
"updates.event-join-chat-webview-decision",
|
|
1122
|
+
"updates.event-join-requests",
|
|
1123
|
+
"updates.event-login-token",
|
|
1124
|
+
"updates.event-managed-bot",
|
|
1125
|
+
"updates.event-message-deleted",
|
|
1126
|
+
"updates.event-message-edited",
|
|
1127
|
+
"updates.event-new-authorization",
|
|
1128
|
+
"updates.event-new-bot-connection",
|
|
1129
|
+
"updates.event-new-message",
|
|
1130
|
+
"updates.event-notify-settings",
|
|
1131
|
+
"updates.event-peer-blocked",
|
|
1132
|
+
"updates.event-peer-located",
|
|
1133
|
+
"updates.event-peer-wallpaper",
|
|
1134
|
+
"updates.event-phone-call",
|
|
1135
|
+
"updates.event-pinned-messages",
|
|
1136
|
+
"updates.event-poll",
|
|
1137
|
+
"updates.event-pts-changed",
|
|
1138
|
+
"updates.event-quick-replies",
|
|
1139
|
+
"updates.event-read-contents",
|
|
1140
|
+
"updates.event-read-discussion",
|
|
1141
|
+
"updates.event-read-monoforum",
|
|
1142
|
+
"updates.event-read-outbox",
|
|
1143
|
+
"updates.event-report-message-delivery",
|
|
1144
|
+
"updates.event-saved-dialogs",
|
|
1145
|
+
"updates.event-saved-gifs",
|
|
1146
|
+
"updates.event-scheduled-deleted",
|
|
1147
|
+
"updates.event-scheduled-new",
|
|
1148
|
+
"updates.event-service-message",
|
|
1149
|
+
"updates.event-service-notification",
|
|
1150
|
+
"updates.event-stars-balance",
|
|
1151
|
+
"updates.event-stars-revenue",
|
|
1152
|
+
"updates.event-stories-stealth",
|
|
1153
|
+
"updates.event-story-id",
|
|
1154
|
+
"updates.event-story-reaction",
|
|
1155
|
+
"updates.event-story-read",
|
|
1156
|
+
"updates.event-typing",
|
|
1157
|
+
"updates.event-user-emoji-status",
|
|
1158
|
+
"updates.event-user-phone",
|
|
1159
|
+
"updates.event-user-refetch",
|
|
1160
|
+
"updates.event-view-forum-as-messages",
|
|
1161
|
+
"updates.event-web-browser-settings",
|
|
1162
|
+
"updates.event-webview-result-sent",
|
|
1163
|
+
"updates.stream-daemon-multi-account",
|
|
1164
|
+
"updates.stream-event-filtering",
|
|
1165
|
+
"updates.stream-resume-cursor",
|
|
1166
|
+
"updates.sync-channel-short-poll",
|
|
1167
|
+
"updates.sync-difference-too-long",
|
|
1168
|
+
"updates.sync-dispatch-ordering",
|
|
1169
|
+
"updates.sync-duplicate-suppression",
|
|
1170
|
+
"updates.sync-peer-cache-from-updates",
|
|
1171
|
+
"updates.sync-too-long",
|
|
1172
|
+
"updates.sync-updating-indicator",
|
|
1173
|
+
),
|
|
1174
|
+
coverage_note=(
|
|
1175
|
+
"delivers every type; the catalogue half (what exists, what it means) "
|
|
1176
|
+
"is `events list`/`events get`, and gap recovery is the `sync` group."
|
|
1177
|
+
),
|
|
1178
|
+
tags=frozenset({"agent-safe", "frames", "live-stream"}),
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
# ---------------------------------------------------------------------------
|
|
1183
|
+
# events replay
|
|
1184
|
+
# ---------------------------------------------------------------------------
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
class EventReplayReq(Request):
|
|
1188
|
+
since: Annotated[
|
|
1189
|
+
int | None,
|
|
1190
|
+
opt("--since", metavar="SEQ", help="First seq (exclusive). Default: the whole buffer."),
|
|
1191
|
+
] = None
|
|
1192
|
+
until: Annotated[
|
|
1193
|
+
int | None, opt("--until", metavar="SEQ", help="Stop at this seq (inclusive).")
|
|
1194
|
+
] = None
|
|
1195
|
+
events: Annotated[str, opt("--events", metavar="TYPES", help="Filter the replay.")] = "all"
|
|
1196
|
+
exclude: Annotated[
|
|
1197
|
+
str | None, opt("--exclude", metavar="TYPES", help="Subtract these types.")
|
|
1198
|
+
] = None
|
|
1199
|
+
chat: Annotated[
|
|
1200
|
+
list[PeerRef], opt("--chat", metavar="CHAT", kind="peer", help="Only this chat.")
|
|
1201
|
+
] = []
|
|
1202
|
+
webhook: Annotated[
|
|
1203
|
+
bool,
|
|
1204
|
+
opt("--webhook", help="Re-deliver the range to the configured webhook, not to stdout."),
|
|
1205
|
+
] = False
|
|
1206
|
+
difference: Annotated[
|
|
1207
|
+
bool,
|
|
1208
|
+
opt(
|
|
1209
|
+
"--difference",
|
|
1210
|
+
help="Rebuild a range older than the buffer with updates.getDifference.",
|
|
1211
|
+
),
|
|
1212
|
+
] = False
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
async def event_replay(ctx: OpContext, req: EventReplayReq) -> AsyncIterator[Page[EventEnvelope]]:
|
|
1216
|
+
"""Read the ring buffer without following it.
|
|
1217
|
+
|
|
1218
|
+
The honest failure is the point. Asking for events after 91,820 when the
|
|
1219
|
+
buffer starts at 95,000 does not return the newest page as though it were
|
|
1220
|
+
the next one; it is INDETERMINATE with the oldest seq it does hold, so a
|
|
1221
|
+
consumer knows it has a hole rather than believing it caught up.
|
|
1222
|
+
"""
|
|
1223
|
+
bus = _bus(ctx)
|
|
1224
|
+
selection = await _selection(
|
|
1225
|
+
ctx,
|
|
1226
|
+
events=req.events,
|
|
1227
|
+
exclude=req.exclude,
|
|
1228
|
+
chats=tuple(req.chat),
|
|
1229
|
+
senders=(),
|
|
1230
|
+
topic=None,
|
|
1231
|
+
)
|
|
1232
|
+
limit, state = _window(ctx, "events.replay", default=1000)
|
|
1233
|
+
offset = int(state.get("offset", 0))
|
|
1234
|
+
|
|
1235
|
+
collected: list[EventEnvelope] = []
|
|
1236
|
+
for alias in _accounts(ctx) or [ctx.account]:
|
|
1237
|
+
events, gap = bus.replay(alias, req.since if req.since is not None else 0)
|
|
1238
|
+
if gap is not None and not req.difference:
|
|
1239
|
+
raise IndeterminateError(
|
|
1240
|
+
f"seq {req.since} is older than the buffer, which starts at "
|
|
1241
|
+
f"{gap['from']}; {gap['lost']} events are not recoverable from "
|
|
1242
|
+
"memory. Re-run with --difference to rebuild from Telegram, or "
|
|
1243
|
+
"start from that seq."
|
|
1244
|
+
)
|
|
1245
|
+
if gap is not None:
|
|
1246
|
+
ctx.warn(
|
|
1247
|
+
f"{gap['lost']} events before seq {gap['from']} were rebuilt from "
|
|
1248
|
+
"updates.getDifference and may be incomplete"
|
|
1249
|
+
)
|
|
1250
|
+
await _difference_backfill(ctx, alias)
|
|
1251
|
+
collected.extend(
|
|
1252
|
+
event
|
|
1253
|
+
for event in events
|
|
1254
|
+
if selection.wants(event) and (req.until is None or event.seq <= req.until)
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
collected.sort(key=lambda event: (event.account, event.seq))
|
|
1258
|
+
if req.webhook:
|
|
1259
|
+
pushed = _push_to_webhook(ctx, collected)
|
|
1260
|
+
ctx.warn(f"{pushed} events were re-queued for the webhook instead of printed")
|
|
1261
|
+
collected = []
|
|
1262
|
+
|
|
1263
|
+
window = collected[offset : offset + limit]
|
|
1264
|
+
yield build_page(
|
|
1265
|
+
window,
|
|
1266
|
+
op="events.replay",
|
|
1267
|
+
kind=PageKind.LOCAL,
|
|
1268
|
+
state={"offset": offset + len(window)},
|
|
1269
|
+
account=ctx.account,
|
|
1270
|
+
has_more=offset + len(window) < len(collected),
|
|
1271
|
+
total=len(collected),
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
async def _difference_backfill(ctx: OpContext, alias: str) -> None:
|
|
1276
|
+
"""Ask the session to catch up so the gap is at least *narrowed*."""
|
|
1277
|
+
daemon = getattr(ctx, "daemon", None)
|
|
1278
|
+
sessions = getattr(daemon, "sessions", None)
|
|
1279
|
+
session = sessions.get(alias) if sessions is not None else None
|
|
1280
|
+
if session is not None:
|
|
1281
|
+
await session.catch_up()
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _push_to_webhook(ctx: OpContext, events: list[EventEnvelope]) -> int:
|
|
1285
|
+
daemon = getattr(ctx, "daemon", None)
|
|
1286
|
+
webhook = getattr(daemon, "webhook", None)
|
|
1287
|
+
if webhook is None:
|
|
1288
|
+
raise UsageError("no webhook is configured; run `tlgr webhook set --url …`")
|
|
1289
|
+
for event in events:
|
|
1290
|
+
webhook.enqueue(event)
|
|
1291
|
+
return len(events)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
SPEC_EVENT_REPLAY = OperationSpec(
|
|
1295
|
+
id="events.replay",
|
|
1296
|
+
request=EventReplayReq,
|
|
1297
|
+
response=Page[EventEnvelope],
|
|
1298
|
+
impl=event_replay,
|
|
1299
|
+
summary="Replay buffered events from the daemon's ring buffer without following",
|
|
1300
|
+
description=(
|
|
1301
|
+
"Exit 3 when the range is inside the buffer and empty; exit 13, with "
|
|
1302
|
+
"the oldest seq the daemon still holds, when `--since` predates it. "
|
|
1303
|
+
"Returning the newest page instead would be a silent lie about having "
|
|
1304
|
+
"caught up."
|
|
1305
|
+
),
|
|
1306
|
+
stream=True,
|
|
1307
|
+
paginated=PageKind.LOCAL,
|
|
1308
|
+
needs_client=False,
|
|
1309
|
+
surface=Surface.DAEMON,
|
|
1310
|
+
rate_class="local",
|
|
1311
|
+
timeout_s=120,
|
|
1312
|
+
columns=("seq", "ts", "type", "chat_id"),
|
|
1313
|
+
empty_exit=EXIT_EMPTY,
|
|
1314
|
+
example={
|
|
1315
|
+
"items": [_EXAMPLE_ENVELOPE],
|
|
1316
|
+
"has_more": False,
|
|
1317
|
+
"total": 1,
|
|
1318
|
+
},
|
|
1319
|
+
example_args="events replay --since 91820 --events message_new",
|
|
1320
|
+
covers=("updates.stream-resume-cursor",),
|
|
1321
|
+
covers_partial=("updates.stream-watch-ndjson", "updates.sync-duplicate-suppression"),
|
|
1322
|
+
coverage_note=(
|
|
1323
|
+
"replays a range; following it live is `watch`, and de-duplication is "
|
|
1324
|
+
"the consumer's job through the envelope's stable seq."
|
|
1325
|
+
),
|
|
1326
|
+
tags=frozenset({"agent-safe"}),
|
|
1327
|
+
)
|