tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/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
+ )