File size: 6,723 Bytes
de342dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Make sure the buttons shown in a channel are the ones we saved - and fix them when they are not.

Used by /repost (every new post is read back right after it is created) and by "Check buttons" in My posts,
which repairs posts whose buttons are saved in the bot but are missing in the channel.
"""
from __future__ import annotations

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

from telethon import errors, types

from .replace_engine import CHUNK, fetch_messages, is_skippable
from .tgutil import build_markup, button_url, edit_raw, flood_retry, peer_of

log = logging.getLogger(__name__)

ABORT_AFTER = 5  # stop repairing after this many failures in a row
# A throw-away keyboard. If Telegram answers "message not modified" to the real keyboard (it believes the post
# already has it), we first show this one, then the real one: two real changes.
NUDGE_ROWS = [[{"t": "…", "u": "https://t.me/telegram"}]]


class ButtonsNotShown(RuntimeError):
    """Telegram accepted the edit, but the post still does not show the buttons it should."""


def link_buttons(markup) -> list:
    """[(text, url)] of the link buttons of a keyboard, in reading order."""
    out: list = []
    if isinstance(markup, types.ReplyInlineMarkup):
        for row in markup.rows:
            for b in row.buttons:
                u = button_url(b)
                if u is not None:
                    out.append((getattr(b, "text", ""), u))
    return out


def link_urls(markup) -> list:
    return [u for _, u in link_buttons(markup)]


async def live_urls(client, peer, message_id: int) -> Optional[list]:
    """The link-button URLs the channel shows on a post right now; None if the post is gone."""
    msgs = await fetch_messages(client, peer, [message_id])
    m = msgs[0] if msgs else None
    if is_skippable(m):
        return None
    return link_urls(m.reply_markup)


async def ensure_markup(client, peer, message_id: int, markup, *, pause: float = 0.7) -> str:
    """Make post `message_id` show the link buttons of `markup`, and check it by reading the post back.

    Returns "fine" (it already did), "set" (one edit was enough) or "nudged" (needed the two-step edit).
    Raises ButtonsNotShown if the buttons still are not there, LookupError if the post no longer exists.
    """
    want = link_urls(markup)
    if not want:
        return "fine"
    live = await live_urls(client, peer, message_id)
    if live is None:
        raise LookupError(f"post {message_id} no longer exists")
    if live == want:
        return "fine"

    try:
        await flood_retry(lambda: edit_raw(client, peer, message_id, markup=markup))
    except errors.MessageNotModifiedError:
        pass  # Telegram thinks the keyboard is already there - checked below
    if pause:
        await asyncio.sleep(pause)
    if await live_urls(client, peer, message_id) == want:
        return "set"

    log.info("post %s: the edit did not make the buttons show - trying the two-step edit", message_id)
    for step in (build_markup(NUDGE_ROWS), markup):
        try:
            await flood_retry(lambda step=step: edit_raw(client, peer, message_id, markup=step))
        except errors.MessageNotModifiedError:
            pass
        if pause:
            await asyncio.sleep(pause)
    if await live_urls(client, peer, message_id) == want:
        return "nudged"
    raise ButtonsNotShown(f"post {message_id} still does not show its buttons")


# ------------------------------------------------------------------------------------ checking
@dataclass
class ButtonScan:
    channel: object
    total: int = 0  # saved posts that have link buttons
    fine: int = 0
    bad: list = field(default_factory=list)  # [(post, live urls, wanted urls)]
    gone: list = field(default_factory=list)  # posts that no longer exist in the channel


async def scan_buttons(
    client, ch, posts: list, *, progress: Optional[Callable[[ButtonScan], Awaitable[None]]] = None
) -> ButtonScan:
    """Compare the saved link buttons of `posts` with what the channel shows."""
    scan = ButtonScan(channel=ch)
    peer = peer_of(ch)
    wanted = []
    for p in posts:
        if not p.message_id:
            continue
        urls = link_urls(build_markup(p.buttons))
        if urls:
            wanted.append((p, urls))
    scan.total = len(wanted)
    for i in range(0, len(wanted), CHUNK):
        part = wanted[i : i + CHUNK]
        msgs = await fetch_messages(client, peer, [p.message_id for p, _ in part])
        for (p, urls), m in zip(part, msgs):
            if is_skippable(m):
                scan.gone.append(p)
            elif link_urls(m.reply_markup) == urls:
                scan.fine += 1
            else:
                scan.bad.append((p, link_urls(m.reply_markup), urls))
        if progress:
            await progress(scan)
    return scan


@dataclass
class FixResult:
    fixed: int = 0
    nudged: int = 0  # of those, how many needed the two-step edit
    already: int = 0  # turned out to be fine when looked at again
    gone: int = 0
    failed: list = field(default_factory=list)  # [(message id, error name)]
    aborted: Optional[str] = None
    stopped: bool = False


async def fix_buttons(
    client,
    ch,
    bad: list,
    *,
    delay: float = 1.2,
    pause: float = 0.7,
    progress: Optional[Callable[[FixResult], Awaitable[None]]] = None,
    should_stop: Optional[Callable[[], bool]] = None,
) -> FixResult:
    """Put the saved buttons back on the posts in `bad` (the first item of each entry is the saved post)."""
    res = FixResult()
    peer = peer_of(ch)
    streak = 0
    for entry in bad:
        post = entry[0]
        if should_stop and should_stop():
            res.stopped = True
            break
        try:
            how = await ensure_markup(client, peer, post.message_id, build_markup(post.buttons), pause=pause)
        except LookupError:
            res.gone += 1
            continue
        except Exception as e:  # one bad post must not stop the others
            name = type(e).__name__
            log.warning("could not fix the buttons of %s: %s %s", post.message_id, name, e)
            res.failed.append((post.message_id, name))
            streak += 1
            if streak >= ABORT_AFTER:
                res.aborted = name
                break
            await asyncio.sleep(delay)
            continue
        streak = 0
        if how == "fine":
            res.already += 1
        else:
            res.fixed += 1
            if how == "nudged":
                res.nudged += 1
        if progress:
            await progress(res)
        await asyncio.sleep(delay)
    return res