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/sync.py ADDED
@@ -0,0 +1,788 @@
1
+ """The `sync` group: the update transport, made inspectable.
2
+
3
+ Not to be confused with `chat catchup`, which is the unread digest a human
4
+ reads. This is `updates.getDifference` and the boxes it advances — the
5
+ machinery that decides whether an event ever existed for the daemon at all.
6
+
7
+ The distinction that runs through the whole group: **catching up** replays a
8
+ gap, **resetting** gives up on one. `sync catch-up` asks Telegram for what was
9
+ missed; `sync reset` throws the local state away and re-baselines, marking
10
+ everything before the new state as seen and unrecoverable. Conflating them is
11
+ how a corrupted `pts` gets "fixed" by silently discarding a day of messages.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import time
18
+ from collections.abc import AsyncIterator
19
+ from typing import Annotated, Any
20
+
21
+ from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError
22
+ from tlgr.core.pagination import PageKind, build_page
23
+ from tlgr.models.base import Request
24
+ from tlgr.models.message import Message
25
+ from tlgr.models.page import Page
26
+ from tlgr.models.peer import PeerRef
27
+ from tlgr.models.sync import (
28
+ CatchUpResult,
29
+ ChannelState,
30
+ DifferenceResult,
31
+ ResetResult,
32
+ SyncStatus,
33
+ )
34
+ from tlgr.ops import _send
35
+ from tlgr.ops._params import arg, opt, parse_dt
36
+ from tlgr.ops._serialize import message_to_model
37
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
38
+
39
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
40
+
41
+ #: Telegram's own caps. `pts_total_limit` is bounded at 10,000 for the common
42
+ #: box and the channel `limit` at 100, and exceeding either is an RPC error
43
+ #: rather than a larger answer.
44
+ _COMMON_LIMIT = (1, 10_000)
45
+ _CHANNEL_LIMIT = (1, 100)
46
+
47
+
48
+ def _sessions(ctx: OpContext) -> Any:
49
+ daemon = getattr(ctx, "daemon", None)
50
+ sessions = getattr(daemon, "sessions", None)
51
+ if sessions is None:
52
+ raise UsageError("this operation runs inside the daemon")
53
+ return sessions
54
+
55
+
56
+ def _spanned(ctx: OpContext) -> list[str]:
57
+ alias = (ctx.account or "").strip()
58
+ sessions = _sessions(ctx)
59
+ known = list(getattr(sessions, "aliases", []) or [])
60
+ if alias and alias != "all":
61
+ if known and alias not in known:
62
+ raise NotFoundError(f"account {alias!r} is not connected. Run: tlgr daemon status")
63
+ return [alias]
64
+ return known
65
+
66
+
67
+ def _session(ctx: OpContext, alias: str) -> Any:
68
+ session = _sessions(ctx).get(alias)
69
+ if session is None or session.client is None:
70
+ raise NotFoundError(f"account {alias!r} is not connected. Run: tlgr daemon status")
71
+ return session
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # sync status
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ class SyncStatusReq(Request):
80
+ channels: Annotated[bool, opt("--channels", help="Include the per-channel pts table.")] = False
81
+ refresh: Annotated[
82
+ bool, opt("--refresh", help="Also call updates.getState and report the server delta.")
83
+ ] = False
84
+
85
+
86
+ async def sync_status(ctx: OpContext, req: SyncStatusReq) -> SyncStatus:
87
+ """The update cursors, and how far behind they are.
88
+
89
+ The cheapest health check a long-running daemon has. Read
90
+ `access_hash_known` first when a channel seems to have gone quiet: without
91
+ an access hash in the session, `catch_up()` *skips* that channel entirely
92
+ — Telethon logs "will not catch up" and carries on — so it looks idle
93
+ rather than broken.
94
+ """
95
+ from tlgr.core import telethon_compat as compat
96
+
97
+ alias = (_spanned(ctx) or [ctx.account])[0]
98
+ session = _session(ctx, alias)
99
+ client = session.client
100
+ state, channels = compat.session_state(client)
101
+
102
+ report = SyncStatus(
103
+ account=alias,
104
+ pts=state.get("pts"),
105
+ qts=state.get("qts"),
106
+ seq=state.get("seq"),
107
+ date=state.get("date"),
108
+ date_unix=state.get("date_unix"),
109
+ unread_count=state.get("unread_count"),
110
+ phase="catching_up" if session.catch_up_pending else str(session.state),
111
+ getting_difference=bool(session.catch_up_pending),
112
+ )
113
+ if report.date_unix:
114
+ report.behind_seconds = max(0, int(time.time()) - int(report.date_unix))
115
+ if session.last_update:
116
+ report.last_update_at = _stamp(session.last_update)
117
+ report.no_updates_for_seconds = max(0, int(time.time() - session.last_update))
118
+
119
+ if req.channels:
120
+ known = _known_channels(client)
121
+ report.channels = [
122
+ ChannelState(
123
+ chat_id=_marked(channel_id), pts=pts, access_hash_known=channel_id in known
124
+ )
125
+ for channel_id, pts in sorted(channels.items())
126
+ ]
127
+ blind = [row.chat_id for row in report.channels if not row.access_hash_known]
128
+ if blind:
129
+ ctx.warn(
130
+ f"{len(blind)} channel(s) have no access hash in the session; catch-up "
131
+ "skips them silently. Warm the dialog list: tlgr chat list --all"
132
+ )
133
+
134
+ if req.refresh:
135
+ from telethon.tl import functions
136
+
137
+ server = await client(functions.updates.GetStateRequest())
138
+ report.server_pts = int(getattr(server, "pts", 0) or 0)
139
+ report.server_seq = int(getattr(server, "seq", 0) or 0)
140
+ if report.pts is not None:
141
+ report.behind_pts = max(0, report.server_pts - int(report.pts))
142
+ return report
143
+
144
+
145
+ def _stamp(value: float) -> str:
146
+ from datetime import datetime, timezone
147
+
148
+ return datetime.fromtimestamp(value, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
149
+
150
+
151
+ def _marked(channel_id: int) -> int:
152
+ from tlgr.core.tl import CHANNEL_MARK
153
+
154
+ return CHANNEL_MARK - channel_id if channel_id > 0 else channel_id
155
+
156
+
157
+ def _known_channels(client: Any) -> set[int]:
158
+ """Channel ids the session holds an access hash for."""
159
+ session = getattr(client, "session", None)
160
+ cursor = getattr(session, "_cursor", None)
161
+ if not callable(cursor):
162
+ return set()
163
+ with contextlib.suppress(Exception):
164
+ rows = cursor().execute("select id from entities").fetchall()
165
+ return {abs(int(row[0])) % 10_000_000_000 for row in rows}
166
+ return set()
167
+
168
+
169
+ SPEC_SYNC_STATUS = OperationSpec(
170
+ id="sync.status",
171
+ request=SyncStatusReq,
172
+ response=SyncStatus,
173
+ impl=sync_status,
174
+ summary="Show the update cursors (pts/qts/seq/date) and how far behind the account is",
175
+ description=(
176
+ "`access_hash_known=false` on a channel means catch-up skips it "
177
+ "silently — Telethon will not call getChannelDifference without one — "
178
+ "so the channel looks idle rather than broken."
179
+ ),
180
+ aliases=("sync.state", "daemon.sync.status"),
181
+ needs_client=False,
182
+ surface=Surface.DAEMON,
183
+ idempotent=True,
184
+ rate_class="read",
185
+ timeout_s=60,
186
+ columns=("account", "pts", "qts", "seq", "date", "behind_seconds", "phase"),
187
+ example={
188
+ "account": "work",
189
+ "pts": 91824,
190
+ "qts": 12,
191
+ "seq": 4410,
192
+ "behind_seconds": 3,
193
+ "phase": "online",
194
+ },
195
+ example_args="sync status --channels --refresh",
196
+ covers=(
197
+ "updates.sync-get-state",
198
+ "updates.sync-qts-gap-algorithm",
199
+ "updates.sync-seq-gap-algorithm",
200
+ ),
201
+ covers_partial=(
202
+ "updates.sync-force-resync",
203
+ "updates.sync-get-channel-difference",
204
+ "updates.sync-pts-gap-algorithm",
205
+ "updates.sync-state-persistence",
206
+ "updates.sync-too-long",
207
+ ),
208
+ coverage_note=(
209
+ "reports the boxes; advancing them is `sync catch-up`, running one "
210
+ "difference by hand is `sync difference`, and discarding them is "
211
+ "`sync reset`."
212
+ ),
213
+ tags=frozenset({"agent-safe"}),
214
+ )
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # sync catch-up
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ class CatchUpReq(Request):
223
+ wait: Annotated[
224
+ bool, opt("--wait/--no-wait", help="Block until the difference is drained.")
225
+ ] = True
226
+ catch_up_timeout: Annotated[
227
+ int,
228
+ opt("--catch-up-timeout", metavar="SECONDS", ge=1, le=900, help="Give up waiting."),
229
+ ] = 120
230
+
231
+
232
+ async def sync_catch_up(ctx: OpContext, req: CatchUpReq) -> CatchUpResult:
233
+ """Force a difference fetch so nothing missed while offline is lost.
234
+
235
+ This is the single most important correctness operation in the group: an
236
+ account that reconnects without it silently misses everything that
237
+ happened while it was away, and there is no later signal that it did.
238
+ """
239
+ from tlgr.core import telethon_compat as compat
240
+
241
+ rows: list[CatchUpResult] = []
242
+ for alias in _spanned(ctx):
243
+ session = _session(ctx, alias)
244
+ before, _ = compat.session_state(session.client)
245
+ started = time.monotonic()
246
+ bus = getattr(ctx, "bus", None)
247
+ seq_before = bus.latest_seq(alias) if bus is not None else 0
248
+
249
+ if req.wait:
250
+ await _bounded(session.catch_up(), req.catch_up_timeout)
251
+ else:
252
+ await session.catch_up()
253
+
254
+ after, _ = compat.session_state(session.client)
255
+ rows.append(
256
+ CatchUpResult(
257
+ account=alias,
258
+ events_replayed=(bus.latest_seq(alias) - seq_before) if bus is not None else 0,
259
+ pts_before=before.get("pts"),
260
+ pts_after=after.get("pts"),
261
+ duration_ms=int((time.monotonic() - started) * 1000),
262
+ too_long=bool(session.resync_needed),
263
+ )
264
+ )
265
+ session.resync_needed.clear()
266
+
267
+ if not rows:
268
+ raise NotFoundError("no accounts are connected. Run: tlgr daemon status")
269
+ if len(rows) > 1:
270
+ ctx.warn(f"caught up {len(rows)} accounts; reporting the first")
271
+ return rows[0]
272
+
273
+
274
+ async def _bounded(coro: Any, seconds: int) -> None:
275
+ import asyncio
276
+
277
+ try:
278
+ await asyncio.wait_for(coro, timeout=seconds)
279
+ except (TimeoutError, asyncio.TimeoutError):
280
+ from tlgr.core.errors import RetryableError
281
+
282
+ raise RetryableError(
283
+ f"the difference did not drain within {seconds}s; it is still running "
284
+ "in the daemon — check progress with `tlgr sync status`"
285
+ ) from None
286
+
287
+
288
+ SPEC_SYNC_CATCH_UP = OperationSpec(
289
+ id="sync.catch-up",
290
+ request=CatchUpReq,
291
+ response=CatchUpResult,
292
+ impl=sync_catch_up,
293
+ summary="Force a difference fetch so nothing missed while offline is lost",
294
+ description=(
295
+ "Not `chat catchup`, which is the unread digest. This is "
296
+ "`updates.getDifference`: without it an account that was away silently "
297
+ "misses everything that happened, with no later signal that it did."
298
+ ),
299
+ aliases=("daemon.sync.catch-up",),
300
+ mutating=True,
301
+ idempotent=True,
302
+ needs_client=False,
303
+ surface=Surface.DAEMON,
304
+ rate_class="read",
305
+ timeout_s=900,
306
+ columns=("account", "events_replayed", "pts_before", "pts_after", "duration_ms"),
307
+ example={"account": "work", "events_replayed": 12, "pts_before": 91800, "pts_after": 91824},
308
+ example_args="sync catch-up",
309
+ covers=("updates.sync-get-difference", "updates.sync-too-long"),
310
+ covers_partial=("updates.sync-catch-up-on-start", "updates.sync-force-resync"),
311
+ coverage_note=(
312
+ "the manual fetch; doing it at start is `daemon start --catch-up`, and "
313
+ "giving up on a gap is `sync reset`."
314
+ ),
315
+ tags=frozenset({"agent-safe"}),
316
+ )
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # sync difference
321
+ # ---------------------------------------------------------------------------
322
+
323
+
324
+ class DifferenceReq(Request):
325
+ chat: Annotated[
326
+ PeerRef | None,
327
+ opt(
328
+ "--chat", metavar="CHAT", kind="peer", help="Run getChannelDifference for this channel."
329
+ ),
330
+ ] = None
331
+ pts: Annotated[int | None, opt("--pts", metavar="N", help="Start from this pts.")] = None
332
+ qts: Annotated[int | None, opt("--qts", metavar="N", help="Start from this qts.")] = None
333
+ date: Annotated[
334
+ str | None, opt("--date", metavar="WHEN", kind="datetime", help="Start from this date.")
335
+ ] = None
336
+ depth: Annotated[
337
+ int,
338
+ opt(
339
+ "--depth",
340
+ metavar="N",
341
+ ge=1,
342
+ le=10000,
343
+ help="pts_total_limit (common box) or limit (channel).",
344
+ ),
345
+ ] = 1000
346
+ follow: Annotated[
347
+ int | None,
348
+ opt(
349
+ "--follow",
350
+ metavar="SECONDS",
351
+ help="Short-poll the channel for this long, honouring the returned timeout.",
352
+ ),
353
+ ] = None
354
+ apply: Annotated[
355
+ bool, opt("--apply", help="Feed the result into the daemon's state and event stream.")
356
+ ] = False
357
+
358
+
359
+ async def sync_difference(ctx: OpContext, req: DifferenceReq) -> DifferenceResult:
360
+ """Run `updates.getDifference` explicitly, as a diagnostic.
361
+
362
+ Read-only by default, and that is the whole safety property: without
363
+ `--apply` the daemon's stored `pts` is not advanced, so running this
364
+ cannot create the gap it was meant to diagnose. `differenceSlice` is
365
+ looped until final.
366
+ """
367
+ from telethon.tl import functions
368
+
369
+ alias = (_spanned(ctx) or [ctx.account])[0]
370
+ session = _session(ctx, alias)
371
+ client = session.client
372
+
373
+ if req.chat is not None:
374
+ return await _channel_difference(ctx, client, req)
375
+
376
+ low, high = _COMMON_LIMIT
377
+ depth = max(low, min(high, req.depth))
378
+ state = await _resolve_common_state(client, req)
379
+ result = DifferenceResult(kind="common", final=True, dry_run=not req.apply)
380
+
381
+ for _ in range(64): # a slice loop with a bound, never an open one
382
+ request = functions.updates.GetDifferenceRequest(
383
+ pts=state["pts"], date=state["date"], qts=state["qts"], pts_total_limit=depth
384
+ )
385
+ result.requests.append({"request": type(request).__name__, "pts": state["pts"]})
386
+ reply = await client(request)
387
+ name = type(reply).__name__
388
+
389
+ if name == "DifferenceEmpty":
390
+ result.final = True
391
+ result.new_seq = int(getattr(reply, "seq", 0) or 0)
392
+ break
393
+ if name == "DifferenceTooLong":
394
+ result.too_long = True
395
+ result.final = True
396
+ result.new_pts = int(getattr(reply, "pts", 0) or 0)
397
+ ctx.warn(
398
+ "the server answered differenceTooLong: the gap is unrecoverable from "
399
+ "this pts. Re-baseline with `tlgr sync reset`, then refill ranges you "
400
+ "care about with `tlgr sync backfill`."
401
+ )
402
+ break
403
+
404
+ result.messages += len(getattr(reply, "new_messages", None) or [])
405
+ result.other_updates += len(getattr(reply, "other_updates", None) or [])
406
+ result.users += len(getattr(reply, "users", None) or [])
407
+ result.chats += len(getattr(reply, "chats", None) or [])
408
+
409
+ final_state = getattr(reply, "state", None) or getattr(reply, "intermediate_state", None)
410
+ result.new_pts = int(getattr(final_state, "pts", 0) or 0)
411
+ result.new_qts = int(getattr(final_state, "qts", 0) or 0)
412
+ result.new_seq = int(getattr(final_state, "seq", 0) or 0)
413
+ result.new_date = _fmt(getattr(final_state, "date", None))
414
+
415
+ if name == "Difference":
416
+ result.final = True
417
+ break
418
+ # `updates.differenceSlice`: keep going from the intermediate state.
419
+ result.final = False
420
+ state = {
421
+ "pts": result.new_pts,
422
+ "qts": result.new_qts,
423
+ "date": getattr(final_state, "date", state["date"]),
424
+ }
425
+
426
+ if req.apply:
427
+ await session.catch_up()
428
+ result.applied = True
429
+ return result
430
+
431
+
432
+ async def _resolve_common_state(client: Any, req: DifferenceReq) -> dict[str, Any]:
433
+ from telethon.tl import functions
434
+
435
+ from tlgr.core import telethon_compat as compat
436
+
437
+ stored, _channels = compat.session_state(client)
438
+ pts = req.pts if req.pts is not None else stored.get("pts")
439
+ qts = req.qts if req.qts is not None else stored.get("qts")
440
+ date = parse_dt(req.date) if req.date else None
441
+
442
+ if pts is None or date is None:
443
+ # No stored state and no override: ask the server where "now" is, so
444
+ # the probe starts from something real rather than from zero — which
445
+ # would ask Telegram to replay the account's entire history.
446
+ server = await client(functions.updates.GetStateRequest())
447
+ pts = pts if pts is not None else int(getattr(server, "pts", 0) or 0)
448
+ qts = qts if qts is not None else int(getattr(server, "qts", 0) or 0)
449
+ date = date or getattr(server, "date", None)
450
+ return {"pts": int(pts or 0), "qts": int(qts or 0), "date": date}
451
+
452
+
453
+ def _fmt(value: Any) -> str | None:
454
+ from tlgr.core.timefmt import fmt_dt
455
+
456
+ return fmt_dt(value)
457
+
458
+
459
+ async def _channel_difference(ctx: OpContext, client: Any, req: DifferenceReq) -> DifferenceResult:
460
+ """`updates.getChannelDifference`, optionally short-polled.
461
+
462
+ The `timeout` the server returns is an instruction, not a suggestion:
463
+ re-invoking a *final* channel difference sooner than it says is exactly
464
+ the polling Telegram asks clients not to do.
465
+ """
466
+ import asyncio
467
+
468
+ from telethon.tl import functions, types
469
+
470
+ from tlgr.core import telethon_compat as compat
471
+
472
+ peer = await _send.resolve(ctx, req.chat)
473
+ channel = _input_channel(peer)
474
+ low, high = _CHANNEL_LIMIT
475
+ limit = max(low, min(high, req.depth))
476
+
477
+ stored_pts = req.pts
478
+ if stored_pts is None:
479
+ _state, channels = compat.session_state(client)
480
+ marked = _send.peer_id_of(peer)
481
+ stored_pts = channels.get(abs(marked) % 10_000_000_000, 1)
482
+
483
+ result = DifferenceResult(kind="channel", final=True, dry_run=not req.apply)
484
+ deadline = time.monotonic() + (req.follow or 0)
485
+
486
+ while True:
487
+ request = functions.updates.GetChannelDifferenceRequest(
488
+ channel=channel,
489
+ filter=types.ChannelMessagesFilterEmpty(),
490
+ pts=int(stored_pts or 1),
491
+ limit=limit,
492
+ force=True,
493
+ )
494
+ result.requests.append({"request": type(request).__name__, "pts": int(stored_pts or 1)})
495
+ reply = await client(request)
496
+ name = type(reply).__name__
497
+ result.timeout = getattr(reply, "timeout", None)
498
+
499
+ if name == "ChannelDifferenceTooLong":
500
+ result.too_long = True
501
+ result.final = True
502
+ ctx.warn(
503
+ "the channel's gap is unrecoverable from this pts; refill the range "
504
+ "with `tlgr sync backfill <chat>`"
505
+ )
506
+ break
507
+ if name == "ChannelDifferenceEmpty":
508
+ result.final = bool(getattr(reply, "final", True))
509
+ result.new_pts = int(getattr(reply, "pts", 0) or 0)
510
+ else:
511
+ result.messages += len(getattr(reply, "new_messages", None) or [])
512
+ result.other_updates += len(getattr(reply, "other_updates", None) or [])
513
+ result.users += len(getattr(reply, "users", None) or [])
514
+ result.chats += len(getattr(reply, "chats", None) or [])
515
+ result.final = bool(getattr(reply, "final", True))
516
+ result.new_pts = int(getattr(reply, "pts", 0) or 0)
517
+ stored_pts = result.new_pts
518
+
519
+ if not result.final:
520
+ continue
521
+ if req.follow is None or time.monotonic() >= deadline:
522
+ break
523
+ # Honour the server's own pacing rather than inventing one.
524
+ await asyncio.sleep(max(1, int(result.timeout or 10)))
525
+
526
+ if req.apply:
527
+ result.applied = True
528
+ ctx.warn(
529
+ "--apply on a channel difference only advances the stored pts through the "
530
+ "daemon's own catch-up; run `tlgr sync catch-up` to dispatch the events"
531
+ )
532
+ return result
533
+
534
+
535
+ def _input_channel(peer: Any) -> Any:
536
+ from telethon import utils
537
+
538
+ try:
539
+ return utils.get_input_channel(peer)
540
+ except (TypeError, ValueError) as exc:
541
+ raise UsageError(
542
+ "--chat must be a channel or supergroup; the common box covers the rest",
543
+ field="chat",
544
+ ) from exc
545
+
546
+
547
+ SPEC_SYNC_DIFFERENCE = OperationSpec(
548
+ id="sync.difference",
549
+ request=DifferenceReq,
550
+ response=DifferenceResult,
551
+ impl=sync_difference,
552
+ summary="Run updates.getDifference / getChannelDifference explicitly (diagnostics)",
553
+ description=(
554
+ "Read-only without `--apply`: the daemon's stored pts is untouched, "
555
+ "so the probe cannot create the gap it was meant to diagnose. "
556
+ "`--follow` short-polls a channel, honouring the timeout the server "
557
+ "returns rather than a pace tlgr invented."
558
+ ),
559
+ needs_client=False,
560
+ surface=Surface.DAEMON,
561
+ idempotent=True,
562
+ rate_class="read",
563
+ timeout_s=300,
564
+ columns=("kind", "final", "new_pts", "messages", "other_updates", "too_long"),
565
+ example={"kind": "common", "final": True, "new_pts": 91824, "messages": 3},
566
+ example_args="sync difference --chat @news --follow 30",
567
+ covers=(
568
+ "updates.sync-channel-short-poll",
569
+ "updates.sync-get-channel-difference",
570
+ "updates.sync-pts-gap-algorithm",
571
+ ),
572
+ covers_partial=("updates.sync-get-difference", "updates.sync-qts-gap-algorithm"),
573
+ coverage_note="runs one by hand; the automatic path is `sync catch-up`.",
574
+ tags=frozenset({"agent-safe"}),
575
+ )
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # sync reset
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ class SyncResetReq(Request):
584
+ chat: Annotated[
585
+ list[PeerRef],
586
+ opt("--chat", metavar="CHAT", kind="peer", help="Only reset this channel's pts."),
587
+ ] = []
588
+ all_channels: Annotated[
589
+ bool, opt("--all-channels", help="Reset every per-channel pts, keeping the common box.")
590
+ ] = False
591
+
592
+
593
+ async def sync_reset(ctx: OpContext, req: SyncResetReq) -> ResetResult:
594
+ """Throw the local update state away and re-baseline from the server.
595
+
596
+ This is the *give up on the gap* path: everything before the new state is
597
+ marked seen and is not recoverable by any later catch-up. It exists for
598
+ the case a corrupted state loops on `getDifference` — and it is
599
+ destructive precisely because the alternative, silently discarding
600
+ messages while calling it a fix, is what makes a sync bug invisible.
601
+ """
602
+ from telethon.tl import functions, types
603
+
604
+ from tlgr.core import telethon_compat as compat
605
+
606
+ alias = (_spanned(ctx) or [ctx.account])[0]
607
+ session = _session(ctx, alias)
608
+ client = session.client
609
+ before, channels = compat.session_state(client)
610
+ result = ResetResult(account=alias, pts_before=before.get("pts"))
611
+
612
+ if req.chat:
613
+ for ref in req.chat:
614
+ peer = await _send.resolve(ctx, ref)
615
+ marked = _send.peer_id_of(peer)
616
+ entity_id = abs(marked) % 10_000_000_000
617
+ compat.set_session_state(
618
+ client,
619
+ types.updates.State(
620
+ pts=1, qts=0, date=before.get("date_unix") or 0, seq=0, unread_count=0
621
+ ),
622
+ entity_id=entity_id,
623
+ )
624
+ result.channels_reset.append(marked)
625
+ result.reset = True
626
+ return result
627
+
628
+ if req.all_channels:
629
+ for channel_id in channels:
630
+ compat.set_session_state(
631
+ client,
632
+ types.updates.State(pts=1, qts=0, date=0, seq=0, unread_count=0),
633
+ entity_id=channel_id,
634
+ )
635
+ result.channels_reset.append(_marked(channel_id))
636
+ result.reset = True
637
+ return result
638
+
639
+ server = await client(functions.updates.GetStateRequest())
640
+ compat.set_session_state(client, server, entity_id=0)
641
+ result.pts_after = int(getattr(server, "pts", 0) or 0)
642
+ result.reset = True
643
+ ctx.warn(
644
+ "the local update state was replaced with the server's: everything before "
645
+ f"pts {result.pts_after} is now marked seen and cannot be replayed"
646
+ )
647
+ return result
648
+
649
+
650
+ SPEC_SYNC_RESET = OperationSpec(
651
+ id="sync.reset",
652
+ request=SyncResetReq,
653
+ response=ResetResult,
654
+ impl=sync_reset,
655
+ summary="Throw away the local update state and re-baseline from the server",
656
+ description=(
657
+ "The give-up path, not the recovery one: everything before the new "
658
+ "state is marked seen and is unrecoverable. Use it when a corrupted "
659
+ "state loops on getDifference; use `sync catch-up` to replay a gap."
660
+ ),
661
+ mutating=True,
662
+ destructive=True,
663
+ needs_client=False,
664
+ surface=Surface.DAEMON,
665
+ rate_class="read",
666
+ timeout_s=120,
667
+ columns=("account", "reset", "pts_before", "pts_after"),
668
+ example={"account": "work", "reset": True, "pts_before": 91824, "pts_after": 91900},
669
+ example_args="sync reset --yes",
670
+ covers=("updates.sync-force-resync", "updates.sync-state-persistence"),
671
+ tags=frozenset({"agent-safe"}),
672
+ )
673
+
674
+
675
+ # ---------------------------------------------------------------------------
676
+ # sync backfill
677
+ # ---------------------------------------------------------------------------
678
+
679
+
680
+ class BackfillReq(Request):
681
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer")]
682
+ from_id: Annotated[
683
+ int | None, opt("--from-id", metavar="ID", help="Lowest message id (inclusive).")
684
+ ] = None
685
+ to_id: Annotated[
686
+ int | None, opt("--to-id", metavar="ID", help="Highest message id (inclusive).")
687
+ ] = None
688
+ chunk: Annotated[int, opt("--chunk", metavar="N", ge=1, le=200, help="Ids per request.")] = 200
689
+ emit: Annotated[
690
+ bool, opt("--emit", help="Emit the refilled messages as events, marked `backfill`.")
691
+ ] = False
692
+
693
+
694
+ async def sync_backfill(ctx: OpContext, req: BackfillReq) -> AsyncIterator[Page[Message]]:
695
+ """Refill a message-id range after a box overflow.
696
+
697
+ `messages.getHistory` cannot do this: it is bounded by the same box that
698
+ overflowed. Fetching by explicit id can, and deleted messages come back as
699
+ `messageEmpty`, so the answer is always complete — a missing id means the
700
+ message is gone, not that the fetch fell short.
701
+ """
702
+ peer = await _send.resolve(ctx, req.chat)
703
+ chat_id = _send.peer_id_of(peer)
704
+ low, high = _range(req)
705
+ client = getattr(ctx, "client", None)
706
+ if client is None:
707
+ raise UsageError("this operation needs a connected account")
708
+
709
+ for start in range(low, high + 1, req.chunk):
710
+ ids = list(range(start, min(start + req.chunk, high + 1)))
711
+ fetched = await client.get_messages(peer, ids=ids)
712
+ rows: list[Message] = []
713
+ missing: list[int] = []
714
+ for message_id, message in zip(ids, fetched, strict=False):
715
+ if message is None or type(message).__name__ == "MessageEmpty":
716
+ missing.append(message_id)
717
+ continue
718
+ model = message_to_model(message, chat_id=chat_id)
719
+ rows.append(model)
720
+ if req.emit:
721
+ from tlgr.models.base import to_builtins
722
+
723
+ payload = to_builtins(model)
724
+ ctx.emit(
725
+ "message_new",
726
+ {**(payload if isinstance(payload, dict) else {}), "backfill": True},
727
+ chat_id=chat_id,
728
+ )
729
+ page = build_page(
730
+ rows,
731
+ op="sync.backfill",
732
+ kind=PageKind.HISTORY,
733
+ state={"offset_id": ids[-1]},
734
+ account=ctx.account,
735
+ has_more=ids[-1] < high,
736
+ )
737
+ if missing:
738
+ ctx.warn(f"{len(missing)} id(s) in {ids[0]}-{ids[-1]} are deleted or never existed")
739
+ yield page
740
+
741
+
742
+ def _range(req: BackfillReq) -> tuple[int, int]:
743
+ if req.from_id is None or req.to_id is None:
744
+ raise UsageError("backfill needs an explicit range: --from-id and --to-id", field="from_id")
745
+ if req.to_id < req.from_id:
746
+ raise UsageError("--to-id is lower than --from-id", field="to_id")
747
+ if req.to_id - req.from_id > 100_000:
748
+ raise UsageError(
749
+ "that range is over 100,000 messages; narrow it or run it in pieces",
750
+ field="to_id",
751
+ )
752
+ return req.from_id, req.to_id
753
+
754
+
755
+ SPEC_SYNC_BACKFILL = OperationSpec(
756
+ id="sync.backfill",
757
+ request=BackfillReq,
758
+ response=Page[Message],
759
+ impl=sync_backfill,
760
+ summary="Refill a message-id range after a box overflow (differenceTooLong)",
761
+ description=(
762
+ "`messages.getHistory` cannot fill a channel gap — it is bounded by "
763
+ "the same box that overflowed. Fetching by explicit id can, and a "
764
+ "deleted message comes back as `messageEmpty`, so the range is always "
765
+ "complete."
766
+ ),
767
+ stream=True,
768
+ paginated=PageKind.HISTORY,
769
+ surface=Surface.DAEMON,
770
+ rate_class="read",
771
+ timeout_s=900,
772
+ columns=("id", "date", "text"),
773
+ empty_exit=EXIT_EMPTY,
774
+ example={
775
+ "items": [
776
+ {
777
+ "id": 91800,
778
+ "chat_id": -1001,
779
+ "date": "2026-09-03T09:00:00Z",
780
+ "date_unix": 1788339600,
781
+ }
782
+ ],
783
+ "has_more": True,
784
+ },
785
+ example_args="sync backfill @news --from-id 91800 --to-id 91900",
786
+ covers=("updates.sync-difference-too-long",),
787
+ tags=frozenset({"agent-safe"}),
788
+ )