ButtonBot / app /buttons_sync.py
github-actions[bot]
Automated sync from GitHub
de342dc
Raw History Blame Contribute Delete
6.72 kB
"""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