File size: 11,842 Bytes
109b8ca
61d6e2a
 
 
 
 
 
 
 
 
2e79f82
61d6e2a
53e3df0
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2a4cfc
61d6e2a
 
 
 
 
 
 
 
 
109b8ca
2e79f82
 
 
 
 
 
 
 
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2a4cfc
61d6e2a
 
 
 
 
 
 
 
b2a4cfc
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2a4cfc
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2a4cfc
61d6e2a
 
 
 
 
 
 
2e79f82
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2a4cfc
61d6e2a
 
 
 
 
 
 
 
 
 
b2a4cfc
61d6e2a
b2a4cfc
61d6e2a
 
 
2e79f82
61d6e2a
 
 
 
 
 
 
 
 
53e3df0
 
 
61d6e2a
 
 
 
 
 
b2a4cfc
5bcd8c9
 
 
 
 
 
 
 
b2a4cfc
61d6e2a
 
 
 
 
5bcd8c9
61d6e2a
 
 
 
53e3df0
 
 
 
 
 
 
 
 
61d6e2a
 
 
 
53e3df0
61d6e2a
 
 
 
 
 
 
 
b2a4cfc
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
109b8ca
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
53e3df0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""/swap engine: scan channels message by message, rewrite links, log every change, undo."""
from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Optional

from telethon import errors, functions, types

from .handleswap import compute_handle_changes
from .linkswap import compute_message_changes
from .richmsg import entry as rich_entry, is_rich_message, plain_of_message, rich_from_received, rich_of
from .tgutil import (
    build_markup,
    de_entities,
    edit_raw,
    flood_retry,
    media_info,
    peer_of,
    ser_entities,
    ser_markup,
    state_of,
)

log = logging.getLogger(__name__)

CHUNK = 100  # Telegram returns at most 100 messages per request
FALLBACK_EMPTY_BATCHES = 15  # used only when the newest message id can't be determined
ABORT_AFTER = 5  # stop a channel after this many identical Telegram refusals in a row


@dataclass
class ScanOptions:
    old: str
    new: str
    include_typed: bool = True
    include_posts: bool = False
    last: Optional[int] = None
    mode: str = "links"  # "links": the username inside t.me links (/swap); "handles": '@name' written in a post (/handleswap)


def compute_changes(m, opts: ScanOptions) -> tuple:
    """What this run would change in message `m`: (MsgChange or None, skipped)."""
    if opts.mode == "handles":
        return compute_handle_changes(m, opts.old, opts.new)
    return compute_message_changes(
        m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
    )


@dataclass
class ChangeInfo:
    message_id: int
    buttons: int = 0
    links: int = 0
    typed: int = 0
    mine: bool = False
    callbacks: bool = False


@dataclass
class ChannelScan:
    channel: Any
    can_edit_others: bool = True
    top: int = 0
    first: int = 1
    scanned: int = 0
    infos: dict = field(default_factory=dict)
    skipped_post_links: int = 0
    not_editable: int = 0
    error: Optional[str] = None
    rights: Any = None

    @property
    def total(self) -> int:
        return len(self.infos)

    @property
    def foreign(self) -> int:
        return sum(1 for i in self.infos.values() if not i.mine)


@dataclass
class ApplyResult:
    edited: int = 0
    unchanged: int = 0
    missing: int = 0
    no_perm: int = 0
    failed: list = field(default_factory=list)
    aborted: Optional[str] = None  # error name that made us stop early


@dataclass
class UndoResult:
    restored: int = 0
    failed: list = field(default_factory=list)


async def fetch_messages(client, peer, ids: list) -> list:
    res = await flood_retry(lambda: client.get_messages(peer, ids=ids))
    return list(res)


async def get_top_id(client, peer) -> Optional[int]:
    """Upper bound for the newest message id: the channel's event counter (pts) is never lower."""
    try:
        full = await flood_retry(lambda: client(functions.channels.GetFullChannelRequest(peer)))
        pts = getattr(full.full_chat, "pts", None)
        if pts:
            return int(pts)
    except Exception as e:
        log.info("could not read channel counter: %s", e)
    return None


def is_skippable(m) -> bool:
    return m is None or isinstance(m, (types.MessageEmpty, types.MessageService))


async def scan_channel(
    client,
    ch,
    opts: ScanOptions,
    *,
    can_edit_others: bool = True,
    mine_ids: Optional[set] = None,
    progress: Optional[Callable[[ChannelScan], Awaitable[None]]] = None,
) -> ChannelScan:
    scan = ChannelScan(channel=ch, can_edit_others=can_edit_others)
    peer = peer_of(ch)
    mine_ids = mine_ids or set()
    top = await get_top_id(client, peer)
    first = 1
    if opts.last and top:
        first = max(1, top - opts.last + 1)
    scan.first, scan.top = first, top or 0
    a, empty_run = first, 0
    while True:
        if top is not None and a > top:
            break
        hi = a + CHUNK - 1 if top is None else min(a + CHUNK - 1, top)
        msgs = await fetch_messages(client, peer, list(range(a, hi + 1)))
        found = False
        for m in msgs:
            if m is None or isinstance(m, types.MessageEmpty):
                continue
            found = True
            if isinstance(m, types.MessageService):
                continue
            change, skipped = compute_changes(m, opts)
            scan.skipped_post_links += skipped
            if not change:
                continue
            mine = change.mine or m.id in mine_ids
            if not mine and not can_edit_others:
                scan.not_editable += 1
                continue
            scan.infos[m.id] = ChangeInfo(
                m.id, change.n_buttons, change.n_links, change.n_typed, mine, change.callbacks
            )
        scan.scanned = hi
        if top is None:
            empty_run = 0 if found else empty_run + 1
            if empty_run >= FALLBACK_EMPTY_BATCHES:
                break
        if progress:
            await progress(scan)
        a = hi + 1
    if top is None:
        scan.top = scan.scanned
    return scan


def preview_flag(m):
    if isinstance(m.media, types.MessageMediaWebPage):
        return True
    return False if m.media is None else None


async def _do_edit(client, peer, m, change, markup, preview) -> None:
    if change.text_changed or change.entities_changed:
        await edit_raw(
            client, peer, m.id, text=change.new_text, entities=change.new_entities, markup=markup, preview=preview
        )
    else:  # only button links changed: leave the text untouched
        await edit_raw(client, peer, m.id, markup=markup)


async def apply_channel(
    client,
    db,
    scan: ChannelScan,
    opts: ScanOptions,
    batch_id: str,
    user_id: int,
    *,
    edit_delay: float = 1.2,
    progress: Optional[Callable[[ApplyResult], Awaitable[None]]] = None,
) -> ApplyResult:
    ch = scan.channel
    peer = peer_of(ch)
    mine_ids = await db.mine_message_ids(ch.id)
    res = ApplyResult()
    ids = sorted(scan.infos)
    buf: list = []
    streak, last_name = 0, None

    async def flush() -> None:
        nonlocal buf
        if buf:
            batch, buf = buf, []
            await db.record_changes(batch_id, ch.id, batch, user_id)

    try:
        for i in range(0, len(ids), CHUNK):
            chunk = ids[i:i + CHUNK]
            msgs = await fetch_messages(client, peer, chunk)
            for mid, m in zip(chunk, msgs):
                if is_skippable(m):
                    res.missing += 1
                    continue
                # re-check against the live message: it may have been edited since the scan
                change, _ = compute_changes(m, opts)
                if not change:
                    res.unchanged += 1
                    continue
                if not (change.mine or m.id in mine_ids) and not scan.can_edit_others:
                    res.no_perm += 1
                    continue
                preview = preview_flag(m)
                markup = change.new_markup if change.new_markup is not None else m.reply_markup
                old_state = state_of(m.message, m.entities, m.reply_markup, preview)
                rich = is_rich_message(m)
                if rich:  # its text is blocks this run never touches: undo puts the buttons back and nothing else
                    old_state["rich"] = True
                try:
                    await flood_retry(lambda: _do_edit(client, peer, m, change, markup, preview))
                except errors.MessageNotModifiedError:
                    res.unchanged += 1
                    continue
                except errors.RPCError as e:
                    name = type(e).__name__
                    log.warning("edit of %s/%s failed: %s", ch.id, m.id, name)
                    res.failed.append((m.id, name))
                    streak = streak + 1 if name == last_name else 1
                    last_name = name
                    if streak >= ABORT_AFTER:
                        res.aborted = name
                        break
                    continue
                streak, last_name = 0, None
                res.edited += 1
                kind, fid = media_info(m)
                adopt = {
                    "text": change.new_text,
                    "entities": ser_entities(change.new_entities),
                    "buttons": ser_markup(markup),
                    "link_preview": bool(preview),
                }
                if kind:
                    adopt["media_kind"], adopt["media_file_id"] = kind, fid
                new_state = state_of(change.new_text, change.new_entities, markup, preview)
                if rich:
                    new_state["rich"] = True
                    saved = await db.find_by_message(ch.id, m.id)
                    if saved is not None and rich_of(saved) is not None:
                        del adopt["text"], adopt["entities"]  # the saved copy keeps what it was sent from
                    else:  # a rich message nobody saved yet: it is taken over as its blocks
                        adopt["text"] = plain_of_message(m)
                        adopt["entities"] = [rich_entry(rich_from_received(m.rich_message))]
                buf.append(
                    {
                        "message_id": m.id,
                        "old_state": old_state,
                        "new_state": new_state,
                        "adopt": adopt,
                    }
                )
                if len(buf) >= 10:
                    await flush()
                if progress:
                    await progress(res)
                await asyncio.sleep(edit_delay)
            if res.aborted:
                break
    finally:
        await flush()
    return res


async def undo_batch(
    client,
    db,
    batch_id: str,
    *,
    edit_delay: float = 1.2,
    progress: Optional[Callable[[UndoResult], Awaitable[None]]] = None,
) -> UndoResult:
    """Put every post touched by a /swap run back exactly as it was."""
    res = UndoResult()
    channels: dict = {}
    done_ids: list = []
    restored: list = []
    for c in await db.batch_changes(batch_id):
        if c.channel_id not in channels:
            channels[c.channel_id] = await db.get_channel(c.channel_id)
        ch = channels[c.channel_id]
        if ch is None:
            res.failed.append((c.message_id, "ChannelRemoved"))
            continue
        old = c.old_state or {}
        try:
            if old.get("rich"):  # a rich message: only its buttons were changed, so only they are put back
                markup = build_markup(old.get("buttons"))
                if markup is not None:
                    await flood_retry(lambda: edit_raw(client, peer_of(ch), c.message_id, markup=markup))
            else:
                await flood_retry(
                    lambda: edit_raw(
                        client,
                        peer_of(ch),
                        c.message_id,
                        text=old.get("text", ""),
                        entities=de_entities(old.get("entities")),
                        markup=build_markup(old.get("buttons")),
                        preview=old.get("preview"),
                    )
                )
        except errors.MessageNotModifiedError:
            pass
        except errors.RPCError as e:
            log.warning("undo of %s/%s failed: %s", c.channel_id, c.message_id, e)
            res.failed.append((c.message_id, type(e).__name__))
            continue
        res.restored += 1
        done_ids.append(c.id)
        restored.append((c.channel_id, c.message_id, old))
        if progress:
            await progress(res)
        await asyncio.sleep(edit_delay)
    await db.finish_undo(batch_id, done_ids, restored, all_done=not res.failed)
    return res