tlgr-cli 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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
|
+
)
|