File size: 28,498 Bytes
2e79f82
 
 
 
 
65651b1
 
 
 
2e79f82
 
 
 
 
 
 
 
c53c2ab
 
 
 
65651b1
 
 
 
2e79f82
 
 
 
 
65651b1
2e79f82
65651b1
2e79f82
65651b1
2e79f82
 
 
 
65651b1
53e3df0
65651b1
2e79f82
 
 
 
 
 
 
 
 
65651b1
2e79f82
 
 
 
 
 
 
 
 
65651b1
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
 
2e79f82
 
 
 
 
65651b1
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
65651b1
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53e3df0
 
 
 
 
 
2e79f82
 
 
53e3df0
2e79f82
53e3df0
 
2e79f82
 
 
 
 
 
 
53e3df0
 
2e79f82
 
 
 
 
 
 
 
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
53e3df0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
53e3df0
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c53c2ab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
2e79f82
 
 
65651b1
2e79f82
 
 
 
65651b1
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
2e79f82
 
65651b1
2e79f82
65651b1
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
c53c2ab
 
 
65651b1
c53c2ab
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
2e79f82
 
 
 
 
 
 
 
 
65651b1
 
 
2e79f82
65651b1
c53c2ab
 
 
 
 
 
 
 
 
2e79f82
c53c2ab
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
2e79f82
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c53c2ab
 
 
65651b1
 
 
 
 
 
c53c2ab
65651b1
 
 
 
 
 
 
 
c53c2ab
65651b1
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
65651b1
2e79f82
 
 
65651b1
 
 
2e79f82
 
65651b1
c53c2ab
2e79f82
 
65651b1
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
"""Keeps My posts in step with the channels: what admins do outside the bot is noticed and recorded.

What a look at a channel does
  * every post the bot has saved is read back from Telegram: a post that is gone is removed here, a post that was edited
    gets its saved copy updated (text, formatting, buttons, media);
  * messages that appeared since the last look are saved as new posts (source "adopted");
  * the posts that were in the channel BEFORE the bot was added are read into My posts too, oldest first, up to the
    newest message that existed at the first look. Where the reading stopped is saved, so a long channel takes a few
    looks. Posts the owner told the bot to forget are skipped for good;
  * service messages ("channel name changed", "pinned a message", video chat ...) found on the way are deleted.

Safety rules
  * posts the bot touched a moment ago, and messages that appeared a moment ago, are left for the next look (the bot may
    still be saving them);
  * a saved copy is only changed if nobody changed it since it was read (optimistic check in the database);
  * buttons that are missing in the channel are never removed from the saved copy (Telegram sometimes hides a keyboard
    for a while - the saved buttons are what "Check buttons" restores from);
  * if EVERY saved post of a channel looks deleted, the bot first makes sure it can read the channel at all: when it
    still sees other messages there (or the channel's event counter moved by at least that many events, which is what
    really deleting them looks like) the posts are really gone and leave My posts; only when it sees nothing at all
    - more likely the bot lost access - is nothing removed;
  * what TELEGRAM does to a channel is not an admin's edit: a channel it restricts (a copyright strike turns every post
    into "This message couldn't be displayed on your device due to copyright infringement"), a single post it holds
    back, a notice shown instead of a post, or many posts that suddenly all show one and the same text are left exactly
    as saved - nothing is edited, removed or added for them.
"""
from __future__ import annotations

import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Optional

from telethon import errors, types

from .db import as_utc, utcnow
from .replace_engine import fetch_messages
from .repost_engine import delete_batch
from .restrictions import message_restriction, norm_text, probe_channel
from .richmsg import Rich, entry as rich_entry, is_rich_message, plain_of_message, rich_from_received, rich_of, with_fingerprint
from .tgutil import classify_media, explain_rpc, media_from_ref, media_info, peer_of, ser_entities, ser_markup

log = logging.getLogger(__name__)

CHUNK = 100  # Telegram returns at most 100 messages per request
GRACE = 120.0  # seconds a post or message must be old before it is judged
PAUSE = 0.25  # between two requests of a scan
SLACK_CAP = 300  # a quick look reads this many ids beyond the most likely end of the new messages
DEEP_EVERY = 24 * 3600  # the automatic looks read every possible id at least this often
MIN_FOR_ANOMALY = 5
MASS_SAME = 5  # this many posts that suddenly show the very same new text are not edits by a person
MAX_PROBE = 300  # requests spent finding the newest message the first time (30 000 ids)


# ----------------------------------------------------------------------------------------------- options
@dataclass
class SyncOptions:
    deep: bool = False  # read every possible id for new messages, not only the likely ones
    delete_services: bool = True
    adopt: bool = True  # save new posts of others
    history: bool = True  # also read the posts from before the bot was added into My posts
    history_seconds: Optional[float] = None  # ... for at most this long per look (None: as long as it takes)
    grace: float = GRACE
    pause: float = PAUSE
    ignore: Any = field(default_factory=set)  # (channel id, message id) of posts the owner told the bot to forget


@dataclass
class SyncReport:
    channel: Any
    phase: str = ""
    checked: int = 0  # saved posts compared with the channel
    deleted: list = field(default_factory=list)  # message ids that were gone: removed from My posts
    edited: list = field(default_factory=list)  # message ids whose saved copy was brought up to date
    refreshed: int = 0  # saved copies corrected for trifles (a trailing space ...)
    adopted: list = field(default_factory=list)  # message ids of new posts that were saved
    imported: int = 0  # older posts (from before the bot was added) that were added to My posts
    history_unsupported: int = 0  # older messages My posts can't hold (polls, stickers ...)
    history_top: int = 0  # the newest message that counts as older (0: the older posts were not looked at)
    history_to: int = 0  # the older posts have been read up to this message id
    history_done: bool = False  # ... and that was all of them
    unsupported: int = 0  # new messages of a kind My posts can't show (polls, stickers ...)
    buttons_missing: list = field(default_factory=list)  # saved buttons that the channel shows no keyboard for
    services_deleted: int = 0
    services_failed: int = 0
    services_error: Optional[str] = None  # name of the refusal
    restricted: list = field(default_factory=list)  # message ids Telegram holds back (copyright ...): left as saved
    restricted_why: Optional[str] = None  # what Telegram says about them
    restricted_channel: Optional[str] = None  # the whole channel is restricted: nothing was looked at
    young: list = field(default_factory=list)  # saved posts the bot touched a moment ago: judge them later
    young_new: list = field(default_factory=list)  # new messages that appeared a moment ago: save them later
    changed_meanwhile: int = 0  # saved posts that were changed through the bot while we looked (left alone)
    first_look: bool = False
    busy: bool = False  # a long job started: the look was cut short
    anomaly: Optional[str] = None
    error: Optional[str] = None
    scanned_to: int = 0

    @property
    def changed(self) -> bool:
        return bool(self.deleted or self.edited or self.adopted or self.imported or self.services_deleted)


# ------------------------------------------------------------------------------------- comparing a post
def age(dt, now) -> float:
    """Seconds since `dt` (never negative); unknown = very old."""
    if dt is None:
        return float("inf")
    return max(0.0, (now - as_utc(dt)).total_seconds())


def media_key(media) -> Optional[tuple]:
    """Which photo / file a message carries (its id - the file reference changes from fetch to fetch)."""
    if isinstance(media, types.MessageMediaPhoto) and getattr(media, "photo", None):
        return ("photo", media.photo.id)
    if isinstance(media, types.MessageMediaDocument) and getattr(media, "document", None):
        return ("doc", media.document.id)
    return None


def stored_media_key(ref: Optional[str]) -> Optional[tuple]:
    if not ref:
        return None
    try:
        return media_key(media_from_ref(ref))
    except Exception:
        return ("unreadable", ref[:40])  # differs from anything real: the saved reference gets replaced


def live_rich(m) -> Optional[Rich]:
    """The rich message the live message is (as blocks), or None for an ordinary post."""
    rm = getattr(m, "rich_message", None)
    return rich_from_received(rm) if rm is not None else None


def live_fields(m) -> dict:
    """The saved-post fields of a message as it is in the channel now."""
    kind, ref = media_info(m)
    rich = live_rich(m)
    return {
        "text": plain_of_message(m) if rich is not None else (m.message or ""),
        "entities": [rich_entry(rich)] if rich is not None else ser_entities(m.entities),
        "buttons": ser_markup(m.reply_markup),
        "media_kind": kind,
        "media_file_id": ref,
    }


def message_supported(m) -> bool:
    """Something My posts can show and edit: text, a rich message, or a photo / video / GIF / audio / file."""
    return bool((m.message or "").strip()) or classify_media(m) is not None or is_rich_message(m)


def adopt_fields(m) -> dict:
    f = live_fields(m)
    f["link_preview"] = isinstance(getattr(m, "media", None), types.MessageMediaWebPage)
    return f


# ------------------------------------------------------------------- what Telegram itself does to a channel
MASS_NOTE = "many posts suddenly show one and the same text - that is Telegram's notice, not an edit"


def split_mass_text(edits: list) -> tuple:
    """(edits to save, edits to hold back). Five or more saved posts that had five different texts and now all show the
    very same text at once: nobody edits like that - Telegram does it to a channel it restricts."""
    groups: dict = {}
    for item in edits:
        new = norm_text(item[1].fields.get("text"))
        if new:
            groups.setdefault(new, []).append(item)
    bad: set = set()
    for items in groups.values():
        if len(items) >= MASS_SAME and len({norm_text(p.text) for p, _ in items}) >= MASS_SAME:
            bad.update(id(i) for i in items)
    return [i for i in edits if id(i) not in bad], [i for i in edits if id(i) in bad]


def hold_back(rep: "SyncReport", ids, why: Optional[str]) -> None:
    rep.restricted.extend(ids)
    rep.restricted_why = rep.restricted_why or why


@dataclass
class Diff:
    fields: dict = field(default_factory=dict)  # what to save
    buttons_missing: bool = False
    quiet: bool = False  # only a trifle differs (surrounding spaces): fix it without counting it as an edit


def diff_post(p, m) -> Diff:
    """What differs between the saved post `p` and the live message `m`."""
    live = live_fields(m)
    fields: dict = {}
    saved_rich, now_rich = rich_of(p), live_rich(m) if is_rich_message(m) else None
    first_look = False
    if saved_rich is None and now_rich is None:
        if (p.text or "") != live["text"]:
            fields["text"] = live["text"]
        if list(p.entities or []) != live["entities"]:
            fields["entities"] = live["entities"]
    elif now_rich is not None:
        # A rich message is compared by what Telegram shows of it (a fingerprint of its blocks), never by its text: the
        # saved copy keeps the source the bot sent. A post that became rich in the channel is taken over as its blocks.
        if saved_rich is None or (saved_rich.fp is not None and saved_rich.fp != now_rich.fp):
            fields["text"], fields["entities"] = live["text"], live["entities"]
        elif saved_rich.fp is None:  # the bot's own rich post, seen for the first time: remember what Telegram shows
            fields["entities"] = [rich_entry(with_fingerprint(saved_rich, now_rich.fp))]
            first_look = True
    elif live["text"].strip() or live["media_kind"] is not None:
        # the channel shows an ordinary post where a rich message was saved (an empty answer is not trusted)
        fields["text"], fields["entities"] = live["text"], live["entities"]
    # media: replaced media is taken over; media that seems to have vanished is not (a message can't lose its media)
    if live["media_kind"] is not None:
        if p.media_kind != live["media_kind"] or stored_media_key(p.media_file_id) != media_key(m.media):
            fields["media_kind"], fields["media_file_id"] = live["media_kind"], live["media_file_id"]
    missing = False
    if live["buttons"]:
        if list(p.buttons or []) != live["buttons"]:
            fields["buttons"] = live["buttons"]
    elif p.buttons:
        missing = True
    quiet = (set(fields) == {"text"} and (p.text or "").strip() == live["text"].strip()) or (
        first_look and set(fields) == {"entities"}
    )
    return Diff(fields, missing, quiet)


# --------------------------------------------------------------------------------------- talking to Telegram
def is_gone(m) -> bool:
    return m is None or isinstance(m, types.MessageEmpty)


async def find_top(client, peer, pts: int, floor: int, *, pause: float = PAUSE) -> int:
    """The newest message id that exists. Only the event counter is known (never lower), so look downwards from it;
    `floor` (the newest saved post) is where the looking can stop."""
    hi = pts
    for _ in range(MAX_PROBE):
        if hi <= floor:
            break
        lo = max(floor + 1, hi - CHUNK + 1)
        msgs = await fetch_messages(client, peer, list(range(lo, hi + 1)))
        alive = [m.id for m in msgs if not is_gone(m)]
        if alive:
            return max(alive)
        hi = lo - 1
        await asyncio.sleep(pause)
    return max(floor, hi)


async def sees_live_messages(client, peer, pts: Optional[int], *, pause: float = PAUSE, tries: int = MAX_PROBE) -> bool:
    """True when the bot can read at least one message of the channel (a post or a service message). Then a saved post
    that is missing from the channel really is gone - the bot is not just blind. Looks downwards from the event counter."""
    if not pts or pts < 1:
        return False
    hi = pts
    for _ in range(tries):
        if hi < 1:
            return False
        lo = max(1, hi - CHUNK + 1)
        msgs = await fetch_messages(client, peer, list(range(lo, hi + 1)))
        if any(not is_gone(m) for m in msgs):
            return True
        hi = lo - 1
        await asyncio.sleep(pause)
    return False


async def delete_services(client, peer, ids: list, rep: SyncReport) -> None:
    """Delete service messages and count what happened (the bot only: Telegram may refuse the old ones)."""
    ids = sorted(set(ids))
    for i in range(0, len(ids), CHUNK):
        part = ids[i : i + CHUNK]
        out = await delete_batch(client, peer, part)
        rep.services_deleted += len(out.gone)
        left = len(part) - len(out.gone)
        if left:
            rep.services_failed += left
            rep.services_error = rep.services_error or out.bot_error or "NotDeleted"


# ------------------------------------------------------------------------------------- the three steps
async def _save_edits(db, rep: SyncReport, edits: list, busy) -> None:
    for p, d in edits:
        if busy():
            rep.busy = True
            return
        if await db.sync_update_post(p.id, p.updated_at, d.fields):
            if d.quiet:
                rep.refreshed += 1
            else:
                rep.edited.append(p.message_id)
        else:
            rep.changed_meanwhile += 1


async def _adopt(db, ch, rep: SyncReport, fresh: list, opts: SyncOptions, busy, now) -> None:
    """Save messages the bot does not know (ascending ids); too fresh ones are left for the next look."""
    known: set = set()
    ids = [m.id for m in fresh]
    for i in range(0, len(ids), 500):
        known |= {p.message_id for p in await db.posts_at(ch.id, ids[i : i + 500])}
    forgotten = await db.ignored_ids(ch.id) if fresh else set()
    todo = []
    for m in fresh:
        if m.id in known or m.id in forgotten or (ch.id, m.id) in opts.ignore:
            continue
        why = message_restriction(m)
        if why:  # Telegram holds it back: what it shows now is not what the post says
            hold_back(rep, [m.id], why)
        elif age(m.date, now) < opts.grace:
            rep.young_new.append(m.id)
        elif not message_supported(m):
            rep.unsupported += 1
        else:
            todo.append(m)
    for m in todo:
        if busy():
            rep.busy = True
            return
        _, created = await db.adopt_message(ch.id, m.id, adopt_fields(m), sent_at=m.date)
        if created:
            rep.adopted.append(m.id)


async def _compare_saved(
    client, db, ch, rep: SyncReport, opts: SyncOptions, busy, progress, moved=None, pts: Optional[int] = None
) -> None:
    """Every saved post against the channel. `moved`: how far the channel's event counter has come since the last look
    (None: unknown) - deleting posts is an event each, which tells "everything was deleted" from "I can't see anything".
    `pts`: the channel's event counter now - where the bot looks for other messages to prove that it can read the channel."""
    rep.phase = "comparing the saved posts"
    peer = peer_of(ch)
    posts = await db.sent_posts(ch.id)  # the saved copies are read BEFORE the channel is, never after
    now = utcnow()
    judge = []
    for p in posts:
        if min(age(p.updated_at, now), age(p.sent_at, now)) < opts.grace:
            rep.young.append(p.message_id)
        else:
            judge.append(p)
    rep.checked = len(judge)
    by_id = {p.message_id: p for p in judge}
    ids = sorted(by_id)
    gone, edits = [], []
    for i in range(0, len(ids), CHUNK):
        if busy():
            rep.busy = True
            return
        chunk = ids[i : i + CHUNK]
        msgs = await fetch_messages(client, peer, chunk)
        for mid, m in zip(chunk, msgs):
            p = by_id[mid]
            if is_gone(m):
                gone.append(p)
            elif isinstance(m, types.MessageService):
                continue  # not a post any more (cannot happen: ids are never reused)
            else:
                why = message_restriction(m, p.text)
                if why:  # Telegram's doing, not an admin's: the saved copy stays exactly as it is
                    hold_back(rep, [mid], why)
                    continue
                d = diff_post(p, m)
                if d.buttons_missing:
                    rep.buttons_missing.append(mid)
                if d.fields:
                    edits.append((p, d))
        rep.scanned_to = chunk[-1]
        if progress:
            await progress(rep)
        await asyncio.sleep(opts.pause)
    edits, held = split_mass_text(edits)
    if held:
        hold_back(rep, [p.message_id for p, _ in held], MASS_NOTE)
    if gone:
        really = moved is not None and moved >= len(gone)  # at least one event for each post that vanished
        everything = len(ids) >= MIN_FOR_ANOMALY and len(gone) == len(ids)
        if everything and not really:
            # No earlier look to compare with (or too few events): is the bot blind, or are the posts really gone? If it
            # can read other messages of the channel it is not blind.
            if busy():
                rep.busy = True
                return
            really = await sees_live_messages(client, peer, pts, pause=opts.pause)
        if everything and not really:
            rep.anomaly = (
                f"All {len(ids)} saved posts look deleted, and I can't read any other message of the channel either, "
                "so none was removed here - check that the bot is still an admin of the channel."
            )
        else:
            gone_ids = [p.message_id for p in gone]
            await db.forget_posts(ch.id, gone_ids)
            rep.deleted.extend(gone_ids)
    await _save_edits(db, rep, edits, busy)


async def _look_for_new(client, db, ch, rep: SyncReport, opts: SyncOptions, state, pts: int, busy, progress) -> None:
    """Messages that appeared since the last look: new posts are saved, service messages deleted."""
    rep.phase = "looking for new posts"
    peer = peer_of(ch)
    now = utcnow()
    if state is None:  # the first look: only note where the channel stands
        floor = await db.max_saved_id(ch.id)
        top = await find_top(client, peer, pts, floor, pause=opts.pause)
        rep.first_look, rep.scanned_to = True, top
        await db.save_sync(ch.id, last_top=top, last_pts=pts, deferred=0, deep=True)
        return
    deep = opts.deep or age(state.last_deep, now) > DEEP_EVERY
    if pts == state.last_pts and not state.deferred and not deep:
        return  # not a single event since the last look: a quick look would read exactly what it read then
    delta = max(0, pts - state.last_pts)
    slack = min(max(0, state.last_pts - state.last_top), SLACK_CAP)
    soft = pts if deep else min(pts, state.last_top + delta + slack)
    a, highest, had_live = state.last_top + 1, state.last_top, True
    fresh, services = [], []
    while a <= pts:
        if a > soft and not had_live:
            break  # past the likely end and the last stretch was empty
        if busy():
            rep.busy = True
            return
        hi = min(a + CHUNK - 1, pts)
        msgs = await fetch_messages(client, peer, list(range(a, hi + 1)))
        had_live = False
        for m in msgs:
            if is_gone(m):
                continue
            had_live = True
            highest = max(highest, m.id)
            if isinstance(m, types.MessageService):
                services.append(m)
            else:
                fresh.append(m)
        rep.scanned_to = hi
        if progress:
            await progress(rep)
        a = hi + 1
        await asyncio.sleep(opts.pause)
    services_ids = [m.id for m in services]
    if opts.delete_services and services_ids:
        await delete_services(client, peer, services_ids, rep)
    if opts.adopt and fresh:
        await _adopt(db, ch, rep, fresh, opts, busy, now)
        if rep.busy:
            return
    new_top = highest
    if rep.young_new:  # these have to be looked at again: don't move the mark past them
        new_top = min(new_top, min(rep.young_new) - 1)
    await db.save_sync(
        ch.id, last_top=max(state.last_top, new_top), last_pts=pts, deferred=len(rep.young_new), deep=deep
    )


async def _import_history(client, db, ch, rep: SyncReport, opts: SyncOptions, busy, progress, state=None) -> None:
    """The posts that were in the channel before the bot was added are read into My posts: from message 1 up to the newest
    message that existed at the first look (what came after is the business of _look_for_new). The place where the
    reading stopped is saved - a long channel takes a few looks, and a restart loses nothing. `state`: where the look
    before this one stopped (None at the very first look: the mark that look has just set is used)."""
    hist = await db.get_history(ch.id)
    if hist is None:
        base = state if state is not None else await db.get_sync(ch.id)
        if base is None:
            return
        hist = await db.start_history(ch.id, base.last_top)
    if hist.done:
        return
    rep.phase = "reading the older posts"
    peer = peer_of(ch)
    began = time.monotonic()
    forgotten = await db.ignored_ids(ch.id)
    rep.history_top = hist.top
    a = hist.next_id
    rep.history_to = a - 1
    while a <= hist.top:
        if busy():
            rep.busy = True
            return
        if opts.history_seconds is not None and time.monotonic() - began > opts.history_seconds:
            return  # the next look goes on from here
        hi = min(a + CHUNK - 1, hist.top)
        msgs = await fetch_messages(client, peer, list(range(a, hi + 1)))
        now = utcnow()
        stop = None
        todo = []
        # posts the bot has saved already are none of the reading's business: the comparison of the saved posts looks
        # after them (and counts the ones Telegram holds back there - not a second time here)
        have = {p.message_id for p in await db.posts_at(ch.id, [m.id for m in msgs if not is_gone(m)])}
        for m in msgs:
            if is_gone(m) or isinstance(m, types.MessageService):
                continue
            if age(m.date, now) < opts.grace:  # the bot may still be saving it: the next look reads it
                stop = m.id
                break
            if m.id in forgotten or m.id in have or (ch.id, m.id) in opts.ignore:
                continue
            why = message_restriction(m)
            if why:
                hold_back(rep, [m.id], why)
            elif not message_supported(m):
                rep.history_unsupported += 1
            else:
                todo.append(m)
        made = await db.adopt_many(ch.id, [(m.id, adopt_fields(m), m.date) for m in todo])  # (skips what is saved)
        rep.imported += len(made)
        a = hi + 1 if stop is None else stop
        rep.history_done = stop is None and a > hist.top
        await db.save_history(ch.id, next_id=a, imported=len(made), done=rep.history_done)
        rep.history_to = rep.scanned_to = a - 1
        if stop is not None:
            return
        if progress:
            await progress(rep)
        await asyncio.sleep(opts.pause)
    rep.history_done = True  # (also the case when the channel had nothing to read)


# ----------------------------------------------------------------------------------------- entry points
async def sync_channel(client, db, ch, opts: Optional[SyncOptions] = None, *, busy=None, progress=None) -> SyncReport:
    """One full look at a channel (see the module text). Never raises Telegram's refusals: they end up in `report.error`."""
    opts = opts or SyncOptions()
    busy = busy or (lambda: False)
    rep = SyncReport(channel=ch)
    pts, restricted, err = await probe_channel(client, peer_of(ch))
    if pts is None:
        rep.error = err
        return rep
    if restricted:  # Telegram restricted the channel (a copyright strike ...): My posts stay untouched
        rep.restricted_channel = restricted
        return rep
    try:
        state = await db.get_sync(ch.id)
        moved = None if state is None else max(0, pts - state.last_pts)
        await _compare_saved(client, db, ch, rep, opts, busy, progress, moved, pts)
        if not rep.busy:
            await _look_for_new(client, db, ch, rep, opts, state, pts, busy, progress)
        if not rep.busy and opts.history:
            await _import_history(client, db, ch, rep, opts, busy, progress, state)
    except errors.RPCError as e:
        rep.error = explain_rpc(e)
    return rep


async def sync_ids(client, db, ch, ids, opts: Optional[SyncOptions] = None, *, adopt_ids=(), busy=None) -> SyncReport:
    """A look at these messages only - the ones an update told about. `adopt_ids`: the ones that are new posts, so a
    message the bot does not know may be saved (an edited message the bot does not know is history: left alone)."""
    opts = opts or SyncOptions()
    busy = busy or (lambda: False)
    rep = SyncReport(channel=ch)
    peer = peer_of(ch)
    ids = sorted(set(ids))
    adopt_set = set(adopt_ids)
    try:
        rows = {p.message_id: p for p in await db.posts_at(ch.id, ids)}  # saved copies first, the channel second
        now = utcnow()
        gone, edits, fresh, services = [], [], [], []
        for i in range(0, len(ids), CHUNK):
            if busy():
                rep.busy = True
                return rep
            chunk = ids[i : i + CHUNK]
            msgs = await fetch_messages(client, peer, chunk)
            for mid, m in zip(chunk, msgs):
                row = rows.get(mid)
                recent = row is not None and min(age(row.updated_at, now), age(row.sent_at, now)) < opts.grace
                if is_gone(m):
                    if row is not None and not recent:
                        gone.append(row)
                elif isinstance(m, types.MessageService):
                    services.append(mid)
                elif row is not None:
                    why = message_restriction(m, row.text)
                    if why:  # Telegram's doing, not an admin's: the saved copy stays exactly as it is
                        hold_back(rep, [mid], why)
                        continue
                    if recent:
                        rep.young.append(mid)
                        continue
                    d = diff_post(row, m)
                    if d.buttons_missing:
                        rep.buttons_missing.append(mid)
                    if d.fields:
                        edits.append((row, d))
                elif mid in adopt_set:
                    fresh.append(m)
        if opts.delete_services and services:
            await delete_services(client, peer, services, rep)
        edits, held = split_mass_text(edits)
        if held:
            hold_back(rep, [p.message_id for p, _ in held], MASS_NOTE)
        if gone:
            gone_ids = [p.message_id for p in gone]
            await db.forget_posts(ch.id, gone_ids)
            rep.deleted.extend(gone_ids)
        await _save_edits(db, rep, edits, busy)
        if opts.adopt and fresh and not rep.busy:
            await _adopt(db, ch, rep, fresh, opts, busy, now)
    except errors.RPCError as e:
        rep.error = explain_rpc(e)
    return rep